From b0e42752958b6b2b861d56f4e87b0d48d48d0514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Thu, 8 Oct 2026 01:00:26 +0200 Subject: [PATCH 01/12] Add v2 pointer and C# compilation workflows with qualification gates --- .github/workflows/build.yml | 4 +- docs/qualification/v2.0.0/acceptance.md | 120 ++++ .../v2.0.0/compiler-probe-policy.md | 209 ++++++ docs/qualification/v2.0.0/local-checkpoint.md | 83 +++ docs/qualification/v2.0.0/release-notes.md | 53 ++ docs/qualification/v2.0.0/release-signoff.md | 113 ++++ docs/qualification/v2.0.0/support-matrix.md | 73 +++ eng/Publish.ps1 | 5 +- .../Contract/CheatEngineToolNames.cs | 16 +- .../Knowledge/Documents/mono-and-dotnet.md | 9 + .../Knowledge/Documents/pointers.md | 21 + .../Knowledge/Documents/tool-map.md | 3 + .../Workflows/manual-pointer-chain.md | 2 + .../Debugger/DebuggerLuaScripts.cs | 150 +---- .../Exec/ExecCSharpJsonContext.cs | 12 + .../Exec/ExecCSharpRecords.cs | 30 + .../Exec/ExecCSharpScripts.cs | 34 + .../Exec/ExecCSharpTools.cs | 274 ++++++++ .../Exec/ExecToolsBuilderExtensions.cs | 6 +- .../Pointer/PointerAccessJsonContext.cs | 12 + .../Pointer/PointerAccessLuaScripts.cs | 92 +++ .../Pointer/PointerAccessRecords.cs | 65 ++ .../Pointer/PointerAccessTools.cs | 187 ++++++ .../Pointer/PointerBatchJsonContext.cs | 12 + .../Pointer/PointerBatchRecords.cs | 99 +++ .../Pointer/PointerBatchTools.cs | 402 ++++++++++++ .../Pointer/PointerOperandLuaScripts.cs | 152 +++++ .../Pointer/PointerToolsBuilderExtensions.cs | 6 +- srcs/CheatEngine.Mcp.Tools/README.md | 6 +- .../PluginBundleProbe.cs | 30 +- tests/CheatEngine.Mcp.LiveTarget/Program.cs | 47 +- tests/CheatEngine.Mcp.LiveTarget/README.md | 32 +- .../Contract/Golden/backend-resources.json | 6 +- .../Contract/Golden/backend-tools.json | 580 +++++++++++++++++ .../Contract/Golden/gateway-resources.json | 6 +- .../Contract/Golden/gateway-tools.json | 595 ++++++++++++++++++ .../Contract/Golden/net-new-tools.txt | 3 + .../Contract/Golden/open-world-tools.txt | 1 + .../Contract/Golden/tool-summary.txt | 3 + .../Contract/ToolCatalogContractTests.cs | 12 +- .../Core/McpContractRulesTests.cs | 12 +- .../LiveQualificationInputs.cs | 3 +- .../LiveQualificationOptIn.cs | 8 +- .../LiveQualificationOptInTests.cs | 25 + .../LiveQualification/LiveSandboxHost.cs | 7 +- .../LiveQualification/LiveSandboxSession.cs | 195 +++++- .../McpLiveQualificationTests.cs | 93 +++ .../NativeLua/NativeLuaExecCSharpTests.cs | 92 +++ .../NativeLua/NativeLuaPointerAccessTests.cs | 276 ++++++++ .../Plugin/PluginBundleTests.cs | 30 + .../Resources/KnowledgeResourceTests.cs | 3 +- .../Tools/Exec/ExecCSharpToolsTests.cs | 273 ++++++++ .../Tools/Pointer/PointerAccessToolTests.cs | 53 ++ .../Tools/Pointer/PointerBatchToolTests.cs | 251 ++++++++ .../Tools/Pointer/PointerFixture.cs | 28 + 55 files changed, 4713 insertions(+), 201 deletions(-) create mode 100644 docs/qualification/v2.0.0/acceptance.md create mode 100644 docs/qualification/v2.0.0/compiler-probe-policy.md create mode 100644 docs/qualification/v2.0.0/local-checkpoint.md create mode 100644 docs/qualification/v2.0.0/release-notes.md create mode 100644 docs/qualification/v2.0.0/release-signoff.md create mode 100644 docs/qualification/v2.0.0/support-matrix.md create mode 100644 srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpJsonContext.cs create mode 100644 srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpRecords.cs create mode 100644 srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpScripts.cs create mode 100644 srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpTools.cs create mode 100644 srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessJsonContext.cs create mode 100644 srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessLuaScripts.cs create mode 100644 srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessRecords.cs create mode 100644 srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessTools.cs create mode 100644 srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchJsonContext.cs create mode 100644 srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchRecords.cs create mode 100644 srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchTools.cs create mode 100644 srcs/CheatEngine.Mcp.Tools/Pointer/PointerOperandLuaScripts.cs create mode 100644 tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaExecCSharpTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaPointerAccessTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/Tools/Exec/ExecCSharpToolsTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerAccessToolTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerBatchToolTests.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6c21cd..41a8978 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -26,7 +26,7 @@ jobs: run: dotnet restore CheatEngine.Mcp.slnx --locked-mode - name: Build Debug run: dotnet build CheatEngine.Mcp.slnx --no-restore - - name: Test + - name: Test Debug run: dotnet test --solution CheatEngine.Mcp.slnx --no-restore --no-build --filter-not-trait Category=LiveQualification --filter-not-trait Category=NativeLua --fail-skips on - name: Check formatting run: | @@ -34,6 +34,8 @@ jobs: dotnet format whitespace CheatEngine.Mcp.slnx --no-restore --verify-no-changes - name: Build Release run: dotnet build CheatEngine.Mcp.slnx -c Release --no-restore + - name: Test Release + run: dotnet test --solution CheatEngine.Mcp.slnx -c Release --no-restore --no-build --filter-not-trait Category=LiveQualification --filter-not-trait Category=NativeLua --fail-skips on - name: Publish Debug distribution run: pwsh -NoProfile -File eng/Publish.ps1 -Configuration Debug - name: Publish Release distribution diff --git a/docs/qualification/v2.0.0/acceptance.md b/docs/qualification/v2.0.0/acceptance.md new file mode 100644 index 0000000..b211cee --- /dev/null +++ b/docs/qualification/v2.0.0/acceptance.md @@ -0,0 +1,120 @@ +# Stable v2.0.0 acceptance criteria + +Status: Planned criteria that have not yet passed. + +Implementation, offline verification, native qualification, soak, and release publication are separate evidence levels. +A tool implementation or portable test result does not satisfy a native or release requirement. + +## Evidence levels + +- `implementation` proves that reviewed source and documentation implement the intended bounded contract. +- `V` proves locked restore, Debug build, and portable tests without LiveQualification or NativeLua. +- `V+` adds style and whitespace verification, Release build, `eng/Publish.ps1`, and review of intended golden changes. +- `VN` proves changed fixed Lua, Lua runtime, job kernel, or status-indicator behavior against a real Lua 5.3 x64 DLL with CE APIs stubbed. +- `AOT` proves Native AOT publication and the executable MCP smoke check for the exact gateway candidate. +- `VL` proves maintainer-authorized behavior in private real Cheat Engine copies against owned disposable targets. +- `soak` proves sustained candidate behavior and bounded resource recovery over the planned duration. +- `release` proves the final public package, tag, assets, hashes, installation, and first-use path. + +The portable suite must pass against both Debug and Release outputs for release sign-off. +VN evidence must record the selected Lua DLL path in redacted form, its version when available, and its SHA-256. +VL remains outside ordinary CI and must follow the existing explicit acknowledgement, private-copy, user-state backup, reviewed-autorun, and disposable-target restrictions. +Kernel, hypervisor, debugger injection, compiler, and managed invocation probes require their separately reviewed qualification policy before VL execution. + +## P1 pointer access analysis + +The planned tool name is `pointer_get_access_info`, subject to final contract review. +The tool accepts bounded supplied instruction facts, target architecture, register values, and an optional observed access address. +The authoritative instruction representation and contradiction rules must be explicit in the public schema. +The result reports the memory operand, base and index registers, scale, signed displacement, effective address, candidate structure base, next pointer-search value, and uncertainty. +The evaluator must preserve the existing `debugger_start_capture` effective-address behavior by reusing or factoring its operand rules. +The tool must distinguish pre-execution context from post-execution access or write context. +The tool must handle x86 and x64 width, register aliases, address-size override, RIP-relative next-instruction semantics, signed offsets, and documented overflow or wrap behavior. +The tool must describe indexed array access as dynamic and must not present it as a stable pointer-chain offset. +The tool must return typed unsupported, missing-fact, contradictory-fact, unresolved-symbol, and observed-address-mismatch outcomes without inventing an address. +The supplied-facts path must perform no live target read, symbol lookup, mutation, or hidden target selection. +Portable and VN coverage must include aliases, scaling, positive and negative displacement, RIP-relative length, address-size override, supplied symbols, missing symbols, missing registers, multiple operands, implicit operands, segment bases, VSIB, undecodable input, and mismatch reporting. +Native qualification must prove that the fact shape captured from reviewed x86 and x64 fixture instructions produces the expected analysis without changing capture behavior. + +## P1 batch pointer-chain validation + +The planned tool name is `pointer_read_chains`, subject to final contract review. +One call accepts at most 128 candidates, at most 64 offsets per candidate, and at most 4,096 combined pointer and final-value reads. +Each dispatch slice admits at most 32 candidates and 128 reads, and combined final-value reads are limited to 65,536 bytes. +Each candidate has a caller-supplied stable identifier, a base expression, and offsets in the existing dereference-then-add signed hexadecimal convention. +The result keeps chain resolution, optional target-address comparison, and optional final-value read as separate statuses. +An unreadable hop must remain distinct from a resolved miss, a resolved match, and a failed final-value read. +The tool compares the resolved address before the optional final-value read and preserves the comparison result when only that read fails. +Matches-only mode must retain aggregate processed, matched, missed, unreadable, error, and not-run counts. +Cancellation or interruption after completed candidates must return bounded partial progress without describing unprocessed candidates as misses. +Every candidate in one call must use one target selection epoch, and a changed epoch must stop the remaining work. +Module expressions must rebase at validation time, while absolute roots must be documented as restart-sensitive. +The tool must not create, mutate, or delete saved pointer maps, scans, paths, or caller input. +Portable coverage must verify bounds, stable ordering, all statuses, overflow, signed offsets, module rebasing, response summaries, cancellation, and target-epoch change. +Native qualification must exercise known stable and unstable chains on disposable x86 and x64 fixtures across target restarts, including an unreadable hop and a resolved address with an unreadable final typed value. + +## P1 typed C# compilation + +The planned tool name is `exec_compile_csharp`, subject to feasibility and final contract review. +The feasibility check must confirm `compileCS` availability, signature, diagnostics, prerequisites, output filename, and lifetime on the reviewed Cheat Engine 7.7 build. +The tool accepts at most 131,072 source characters and at most 32 explicit reference assemblies. +Every reference must pass the existing host-file policy and remain held against replacement while Cheat Engine reads it. +The tool requires the target-code-execution capability before invoking Cheat Engine. +The operation uses the blocking-native dispatch class and reports timeout or lost-response effects honestly. +The stable path must copy the generated assembly to a caller-selected approved-root path before returning success. +The result must include the checked output path, byte length, SHA-256, bounded diagnostics, and the compiler availability outcome. +Compiler diagnostic text is limited to 16,384 UTF-8 bytes with an explicit truncation flag. +The upstream compiler supplies line-message text rather than reliable structured diagnostic codes or severities. +Compilation remains separate from injection or invocation, and a later `exec_inject_dotnet` call is always explicit. +The implementation must not promise assembly unload, CE temporary-file retention, or cleanup that Cheat Engine cannot confirm. +Portable and VN coverage must include bounds, disabled capability, invalid references, changing files, protected paths, diagnostics, missing compiler, output collision, copy failure, and missing or expired CE output. +Native qualification requires a separately reviewed code-execution policy and must cover valid source, compiler diagnostics, unavailable prerequisites, instance-B shutdown during an instance-A artifact workflow, approved-root export, separate injection, and cleanup. +Failure of the feasibility check blocks this feature from contract freeze and requires an explicit linked deferral with evidence. + +## Contract and package acceptance + +The reviewed tool inventory, registrations, source-generated JSON metadata, backend and gateway schemas, resources, prompts, completions, knowledge documents, and packaged reflection output must agree. +Reflection-disabled JSON and Native AOT analysis must cover every new input and result type. +Exact integers, 64-bit addresses, signed offsets, null values, empty values, errors, host effects, retryability, details, hints, and correlation identifiers must survive backend and gateway serialization. +Every routed backend call must require its explicit `instanceId`, and disappearance must never select another instance. +The packaged-DLL probe must receive the reviewed golden directory explicitly and compare the actual lone packaged plugin DLL with the candidate goldens. +The plugin DLL, gateway executable, ZIP, checksums, version, source identity, and tag must agree before publication. + +## Performance acceptance + +Performance thresholds remain planned until three reproducible baseline runs record the environment, workload sizes, and measurement method. +For a short operation, the candidate p95 must not exceed the greater of twice the baseline p95 or the baseline p95 plus 100 milliseconds. +For a bounded batch or job operation, the candidate p95 must not exceed one and one-half times the baseline p95. +Instance discovery, busy refusal, and admission failure must remain bounded and must not wait for unrelated native work to finish. +Long work must expose bounded jobs, progress, truncation, dropped-item accounting, stop behavior, and TTL behavior. +Documented blocking-native calls may outlive an MCP timeout, but they must report that limitation and must not report false success. +Completions and live resources must share admission bounds and must not start an unbounded scan. + +## Planned two-hour soak + +The soak is planned and has not passed. +The exact candidate package must run for two continuous hours with two private Cheat Engine instances. +The workload must include at least 1,000 short calls, 20 bounded jobs, 30 plugin enable or disable cycles, and 10 target restart or switch cycles. +The soak fails on a crash, hang, cross-instance effect, stale target effect, cleanup failure, unexplained drop, false success, or retained owned resource. +After final cleanup, process handles must return to no more than the stabilized baseline plus 25. +After final cleanup, private bytes must return to no more than the stabilized baseline plus 64 MiB. +The baseline and final measurements must use the same idle interval and collection method. +Any threshold change requires recorded baseline evidence and review before RC freeze. + +## Evidence record schema + +Each evidence entry in [release-signoff.md](release-signoff.md) must include a unique evidence identifier and the roadmap requirement identifiers it covers. +Each entry must name its level, status, candidate version, full commit, clean-tree state, configuration, and applicable artifact hashes. +Each native entry must record the Windows build, Cheat Engine version and installation fingerprint, exact .NET runtimes, target architecture, fixture identity, and MCP client when applicable. +Each entry must record the start time, duration, bounded cases, expected result, actual result, cleanup outcome, limitations, reviewer, and safe evidence reference. +An entry status is `pass`, `fail`, `blocked`, `not_run`, or `not_applicable`. +Raw tokens, discovery records, personal paths, e-mail addresses, and unredacted live transcripts must never enter committed evidence. +A roadmap checkbox may close only when reviewed evidence covers its complete acceptance criterion on the applicable candidate or links an explicit reviewed deferral. + +## Installation and migration acceptance + +Clean installation must use only the exact candidate ZIP and its included instructions. +Upgrade qualification must replace beta.2 and any older folder deployment without mixing plugin and gateway builds. +Install, upgrade, disable, uninstall, and rollback must preserve user configuration, tables, unrelated plugins, Cheat Engine settings, and recoverable backups. +Paths with spaces, a standard user account, allowed roots, read-only deployment behavior, malformed configuration, occupied endpoints, and unavailable log locations must have recorded outcomes. +The final release notes must state the support matrix, beta migration, capability limitations, recovery paths, and changes since beta.2. diff --git a/docs/qualification/v2.0.0/compiler-probe-policy.md b/docs/qualification/v2.0.0/compiler-probe-policy.md new file mode 100644 index 0000000..ffd48c9 --- /dev/null +++ b/docs/qualification/v2.0.0/compiler-probe-policy.md @@ -0,0 +1,209 @@ +# Native compileCS qualification policy + +Status: The maintainer approved compiler-only phases 1 through 4 on 2026-10-08 for this roadmap session. +Optional phase 5 (managed injection) is not approved. + +[CONTRIBUTING.md](../../../CONTRIBUTING.md#live-qualification) currently prohibits live code-execution probes. +This approval is a bounded exception and does not change the general rule. +Every compiler run still requires both explicit runner acknowledgements, a reviewed clean candidate, and the prerequisites below. +Ordinary VL authorization alone is insufficient for a future compiler session; managed injection requires separate approval. + +## Decision and evidence boundary + +The purpose is to qualify Cheat Engine's `compileCS` availability, bounded diagnostics, immediate approved-root export, and observed temporary-output lifetime. +The compiler phase never accepts caller-authored or operator-edited source. +The compiler phase uses only the fixed payloads in this document and records their exact UTF-8 byte hashes before launch. +The compiler phase does not inject, invoke, debug, patch, allocate target memory, use a kernel driver, use DBVM, or contact a non-loopback network endpoint. +Compilation and export evidence does not qualify managed injection or execution. +An injection phase is a separate exception within this policy and remains disabled unless its additional approval and fixture prerequisites are satisfied. + +## Required authorization + +The runner must continue to require the existing live acknowledgement and every existing VL safety check. +The runner must also require a second exact acknowledgement dedicated to this exception. +The proposed variable is `CHEATENGINE_MCP_LIVE_CODE_EXECUTION_QUALIFICATION`. +The proposed value is `I_AUTHORIZE_FIXED_COMPILECS_PROBES_ON_PRIVATE_CE_AND_OWNED_TARGETS`. +The second acknowledgement must be absent by default, refused in CI, and evaluated before a CE process, target, run directory, or user-state backup is created. +The run report must record that both acknowledgements were present without recording their environment blocks or any discovery token. +Approval applies to one reviewed candidate commit, package hash, policy revision, fixed payload hash set, and operator session. +Changing any of those facts requires a new review before another run. + +## Fixed compiler payloads + +The valid payload is the following exact source with UTF-8 encoding and LF line endings inside the compiler argument. + +```csharp +public static class CheatEngineMcpCompileCsProbe +{ + public static int Run(string value) + { + return value == "CEMCP-COMPILECS-PROBE-V1" ? 7319 : -7319; + } +} +``` + +The payload has no file, environment, process, thread, reflection, native interop, unsafe, debugger, network, or target-memory operation. +The fixed invocation parameter is `CEMCP-COMPILECS-PROBE-V1`, and the only successful method result is decimal `7319`. +The invalid-source payload is the exact ASCII text `public static class CheatEngineMcpCompileCsProbe {`. +The invalid-reference fixture contains the exact ASCII bytes `CEMCP-NOT-AN-ASSEMBLY`. +No operator prompt, issue text, file content, or MCP argument may replace or append to these payloads. +The runner must compare the runtime payload hashes with the reviewed expected hashes before starting either CE host. + +## Prerequisites + +- The candidate checkout is clean and identifies one full commit. +- The exact Release distribution has passed V+, applicable VN, AOT, and packaged-DLL golden comparison before this exception run. +- The report records the candidate version and SHA-256 of the plugin DLL, gateway executable, and distribution ZIP when available. +- The installed Cheat Engine is an accepted x64 7.7 host and passes the existing dynamic installation fingerprint and reviewed-autorun checks. +- The report records the exact Cheat Engine version, executable hash, Windows build, and installed .NET runtime versions. +- Every ordinary Cheat Engine process, CE tutorial process, disposable target, and DebugView process is closed before admission. +- CI is false, and the run root remains outside the repository and outside the installed Cheat Engine directory. +- The existing registry and application-data backup guard is armed before either private CE copy starts. +- Private CE copies A and B use only the candidate plugin copy, one shared private instance registry, and the candidate gateway copy. +- Owned disposable managed fixtures exist for x86 and x64 and contain no input, window, network listener, persistent state, or unrelated workload. +- Each fixture loads the runtime required by `exec_inject_dotnet`, publishes only its process identity and bounded stop marker, and exits on that marker or its deadline. +- The compiler prerequisite inventory is read-only and records whether CE exposes `compileCS` and which compiler or framework dependency CE reports. +- No dependency, framework, compiler, certificate, registry value, driver, or runtime is installed or changed during the run. + +## Run-owned paths and configuration + +Each host receives a distinct `TEMP` and `TMP` directory below its live run directory before process start. +Host A uses `/compiler/A/temp`, `/compiler/A/references`, and `/compiler/A/output`. +Host B uses `/compiler/B/temp`, `/compiler/B/references`, and `/compiler/B/output`. +The fixed invalid-reference file is created only below host A's reference directory. +The plugin write policy lists only the two run-owned output directories required by the current phase. +Reference and core-assembly arguments, when used, must name reviewed files below the run-owned reference directories. +The instance registry, plugin data, CE copies, backups, and repository remain protected from tool file access. +`EnableUnsafeLua`, `EnableAutoAssembler`, and `EnableKernelAccess` are false for every phase. +`AllowedTableRoots` remains empty. +`EnableTargetCodeExecution` is false for the gate-refusal phase and true only after a fresh private-host activation for approved compiler phases. +Only the gateway's local stdio and authenticated loopback backend connections are permitted. + +## Admission and baseline + +1. Resolve both acknowledgements and every normal VL precondition before creating the run. +2. Record candidate, environment, payload hashes, path roots, configuration, and the requested target architecture. +3. Back up user state and fingerprint the source installation through the existing guard. +4. Create the two private CE copies, private plugin copies, gateway copy, fixture copy, and run-owned scratch directories. +5. Snapshot the run-owned temp, reference, and output trees as relative path, length, and SHA-256 entries. +6. Record CE A and B process identifiers, activation identities, instance identifiers, private bytes, handle counts, and runtime capability evidence. +7. Refuse the run if a path escapes the run directory, a payload hash differs, a private copy differs, or an unrelated CE-like process appears. + +## Phase 1: disabled gate before compiler admission + +1. Start host A with `EnableTargetCodeExecution=false` and the other three effect gates false. +2. Call `exec_compile_csharp` with the fixed valid source, no references, no core assembly, and `/compiler/A/output/gate-disabled.dll`. +3. Require `capability_disabled` with `hostEffect=not_started`. +4. Require no tool dispatch or fixed-Lua receipt for `exec_compile_csharp`. +5. Require no destination, protected partial file, raw compiler artifact, or run-owned temp-tree change. +6. Stop host A normally and verify its discovery record withdraws. +7. Stop the run immediately if the compiler is called, a file appears, or the result reports any started, partial, unknown, or cleanup-unconfirmed effect. + +## Phase 2: successful compile and immediate export + +1. Start fresh hosts A and B with `EnableTargetCodeExecution=true` and all other effect gates false. +2. Attach each private host only to its owned fixture of the selected architecture. +3. Call `exec_compile_csharp` through instance A with the fixed valid source, no references, no core assembly, `overwrite=false`, and `/compiler/A/output/probe.dll`. +4. Require one successful result with an absolute anchored output path, a positive length no greater than 16 MiB, and an uppercase SHA-256. +5. Reopen the exported file read-only and require the reported length and SHA-256 to match the bytes on disk. +6. Require the exported file to begin with the PE `MZ` signature and require no protected partial file to remain. +7. Require instance B to remain responsive and require no file below B's temp, reference, or output directories to change. +8. Record compile-to-result duration, export duration when separately measurable, CE A private-byte and handle deltas, tool result, correlation identifier, and redacted file facts. +9. Do not treat a successful export as proof that CE's original temporary file remains available. + +## Phase 3: invalid source and reference handling + +1. Call instance A with the fixed invalid-source payload and `/compiler/A/output/invalid-source.dll`. +2. Require a bounded compiler diagnostic, no published output, no protected partial file, and an honest non-success host effect. +3. Require diagnostic text to be at most 16,384 UTF-8 bytes and require the truncation flag to match the recorded byte count. +4. Call instance A with the fixed valid source and one nonexistent path below the reference root. +5. Require refusal before compiler dispatch with `hostEffect=not_started`, no output, and no temp-tree change. +6. Call instance A with the fixed valid source and the fixed invalid-reference file. +7. Require a bounded compiler or reference diagnostic, no published output, and no protected partial file. +8. Repeat the missing and invalid cases for `coreAssembly` only when the supported CE signature accepts that optional argument. +9. Stop the phase if CE loads or executes an invalid reference, publishes an output after a reported failure, or changes a file outside host A's run-owned roots. + +## Phase 4: raw CE temporary output lifetime + +This phase uses one reviewed qualification-only fixed bridge that calls `compileCS` with the valid payload and returns only the generated filename. +The bridge is test infrastructure, is not an MCP tool, accepts no caller arguments, and must match the reviewed source and payload hashes. +The bridge must not inject, load, invoke, delete, rename, or copy the generated assembly. + +1. With A and B running, call the fixed raw bridge once in A and record the returned path in redacted form. +2. Require the returned raw path to be a new regular file under A's run-owned temp directory with no reparse point or protected-path overlap. +3. Stop immediately if the raw path is outside A's run-owned temp directory, and do not read, delete, or otherwise modify an out-of-scope path. +4. Record the raw file's length and SHA-256 through a read-only held handle. +5. Keep the exported `/compiler/A/output/probe.dll` from phase 2 and record its length and SHA-256 again. +6. Stop private host B normally while A and both owned fixtures remain under harness control. +7. Wait for B to exit and for a fixed two-second filesystem quiet interval. +8. Record whether the raw A artifact exists, and record its length and hash again only when it still exists. +9. Require the exported artifact to exist with its original length and hash after B exits. +10. Require A's instance and target to remain selected and responsive after B withdraws. +11. Stop private host A normally and wait for a fixed two-second filesystem quiet interval. +12. Record whether the raw artifact exists after A exits without treating either existence or expiry as a product failure. +13. Require the exported artifact to remain present with its original length and hash after A exits. +14. Classify raw lifetime as `expired_after_b`, `retained_after_b_expired_after_a`, or `retained_after_a` from the observations. +15. Product guidance must promise only the exported copy and must report the observed raw classification as CE-owned environment behavior. + +This sequence distinguishes raw CE expiry from export retention because the raw file and exported file have separate paths, hashes, observations, and ownership. +No missing raw file may be reported as a missing exported file. +No retained exported file may be used to claim that CE's raw file survived. + +## Optional phase 5: separate managed injection + +This phase is excluded unless the maintainer separately approves managed injection in the same reviewed session. +Compiler qualification may pass without this phase, but the roadmap's managed-injection qualification remains open. +The phase is allowed only when the disposable target for the selected architecture loads a supported .NET runtime and exposes no user data or unrelated code. +The only permitted assembly is the exported phase-2 file whose length and SHA-256 still match. +The only permitted call is class `CheatEngineMcpCompileCsProbe`, method `Run`, parameter `CEMCP-COMPILECS-PROBE-V1`. +The expected signed decimal result is `7319`. + +1. Start a fresh A host and a fresh owned disposable target for the selected architecture. +2. Verify the target process identifier, executable hash, architecture, loaded runtime, and ownership immediately before injection. +3. Call `exec_inject_dotnet` once with the exported phase-2 assembly and the fixed class, method, and parameter. +4. Require decimal result `7319` and require the target to remain responsive until its normal stop marker is written. +5. Record call duration, gateway deadline, CE and target exit states, and the tool's reported host effect. +6. Do not call another method, retry after timeout, inspect unrelated target state, or promise assembly unload. +7. Stop and preserve evidence on any timeout, lost response, wrong result, crash, target change, or unknown effect. +8. Repeat this phase in a separate run for x86 and x64 rather than switching architecture inside one session. + +## Measurements and evidence + +The report must use the evidence fields defined in [acceptance.md](acceptance.md) and remain redacted before commit. +It must record the policy revision, authorization decision, candidate commit, clean-tree state, package hashes, payload hashes, and configuration. +It must record the Windows build, CE fingerprint and version, .NET runtimes, target architecture, fixture hash, and compiler availability evidence. +For every call it must record the instance, operation, start and end time, duration, expected result, actual result, error kind, host effect, retryability, and correlation identifier. +For every relevant file it must record a run-relative redacted path, existence observation, length, SHA-256, and observation time. +It must record process exits, discovery withdrawal, source-installation identity, user-state restoration, cleanup failures, private-byte deltas, and handle-count deltas. +It must not record bearer tokens, discovery-record bodies, absolute personal paths, e-mail addresses, caller source, or unredacted logs. + +## Stop conditions + +Stop before launch when approval, either acknowledgement, a required gate, a candidate hash, a payload hash, or a fixture identity is missing or different. +Stop before the compiler call when the source installation, reviewed autorun set, private copy, backup guard, run roots, or process inventory fails validation. +Stop during the run on any kernel, DBVM, debugger, Auto Assembler, unsafe-Lua, external-network, shell, process-launch, or non-fixture target activity. +Stop on an out-of-root raw path, cross-instance effect, cross-target effect, unexpected file, unbounded diagnostic, crash, hang, timeout, lost response, unknown mutation, or false success. +Stop on failure to withdraw a stopped instance, stop an owned process, restore user state, preserve the source installation, or account for an owned output. +After a stop, do not retry a compile or injection automatically. + +## Cleanup and restoration + +Stop fixtures through their owned stop markers and stop private CE hosts through the existing graceful path before any forced termination. +Close the gateway and require all owned processes to exit within the existing harness deadlines. +Verify discovery withdrawal and reject every stale instance identifier. +Restore the guarded registry and application-data state and verify the restoration before deleting a run-owned scratch file. +Verify the installed Cheat Engine fingerprint and runtime configuration are unchanged. +Delete exported outputs, invalid-reference fixtures, and raw artifacts only when their resolved paths remain below the current run directory. +Never delete a raw path that failed the run-root check. +Preserve the run directory and recovery marker when cleanup or restoration is unconfirmed. +Record every cleanup failure and do not report the run as passed when one remains. + +## Pass criteria + +Compiler and export qualification passes only when phases 1 through 4 complete on the exact candidate package for both x86 and x64 fixture runs. +The disabled gate must refuse before dispatch or files, valid source must export a verified bounded assembly, and invalid inputs must fail without published output. +The B-shutdown sequence must record the raw CE artifact outcome and must prove the exported artifact remains unchanged after both B and A close. +All owned processes, files, discovery records, and user-state changes must have confirmed cleanup or restoration. +The report must state the observed compiler prerequisites and raw-lifetime classification without generalizing beyond the exact environment. +Optional phase 5 qualifies only the fixed managed injection case on the architectures actually run. +No result from this policy qualifies arbitrary C# compilation, arbitrary managed code, general assembly loading, kernel execution, debugger behavior, or production targets. diff --git a/docs/qualification/v2.0.0/local-checkpoint.md b/docs/qualification/v2.0.0/local-checkpoint.md new file mode 100644 index 0000000..819901f --- /dev/null +++ b/docs/qualification/v2.0.0/local-checkpoint.md @@ -0,0 +1,83 @@ +# Local roadmap implementation checkpoint + +Date: 2026-10-08. +Status: Working-tree evidence, not a stable release or a clean-commit qualification. + +The branch is `codex/roadmap-v2`, based on `c51a0ec3af373c852cda062a91888ad94fa0cce9`, with uncommitted implementation changes. +The binaries still identify as `2.0.0-beta.2` and embed that base commit; that identity does not identify the modified source tree. +The package hashes below identify the actual tested bytes. +A future clean candidate must repeat the final-source and final-package gates in [release-signoff.md](release-signoff.md). + +## Implementation and verification + +| Evidence ID | Roadmap requirements | Level | Result | Scope and limitations | +| --- | --- | --- | --- | --- | +| LOCAL-01 | Sections 4, 5 and 6 implementation | implementation | Pass | Three new tools, typed schemas, bounded behavior, capability and file-policy integration, guidance, and regression cases. Native compiler qualification remains open. | +| LOCAL-02 | Section 11 restore and portable checks | V | Pass | Locked restore; Debug and Release builds with zero warnings or errors; 4,005 portable tests in each configuration, zero failures or skips. | +| LOCAL-03 | Sections 4 and 6 fixed Lua | VN | Pass | 607 NativeLua tests, including existing debugger-capture regressions and 15 cases added after independent review; actual Lua 5.3 x64 with stubbed CE APIs. | +| LOCAL-04 | Sections 3 and 11 public contract | V+ | Pass | Reviewed additive tool snapshots, resource-size updates, both format checks, and five isolated bundle tests including negative tool/resource/prompt cases. | +| LOCAL-05 | Section 11 distributions and packaged parity | V+, AOT | Pass | Debug and Release publication; actual lone packaged DLL compared to explicit goldens in two load contexts; Native AOT executable MCP smoke checks. | +| LOCAL-06 | Section 11 local package | implementation | Pass | Five-file Release ZIP inventory, embedded dependency inventory, matching DLL/gateway version and base-commit identity, entry hashes and checksum verification. No upload or tag operation. | +| LOCAL-07 | Section 5 x64 pointers; bounded parts of sections 7 and 8 | VL | Pass | Run `20261007T225551Z-5e8e`, 23.829 seconds; two private CE instances and owned x64 targets; supplied pointer facts, batch outcomes, isolation, target restart and existing maintained smoke scenario. | +| LOCAL-08 | Section 5 x86 pointers; bounded parts of sections 7 and 8 | VL | Pass | Run `20261007T225641Z-a5c0`, 22.967 seconds; the same Release package and scenario with an independently built x86 fixture whose manifest reports 32-bit pointers. | + +The full portable commands exclude `Category=LiveQualification` and `Category=NativeLua` and use `--fail-skips on`. +The existing catalog contains 193 backend tools, 43 resources, 15 resource templates, and 43 prompts; gateway discovery adds `instance_list`. +The stale embedded-documentation mismatch observed during incremental development was resolved by a nonincremental rebuild and then independently by fresh publication. + +## Independent Astra review + +An independent `gpt-6-astra` subagent reviewed the implementation, arithmetic, file and effect boundaries, batch cancellation and epochs, tests, packaging, native fixture cleanup, and evidence claims. +The first pass found three supplied-facts defects: accepted hexadecimal values were not normalized before Lua; EIP-relative addresses did not infer 32-bit wrapping; and stack-relative POP and bit-string memory instructions could access an address different from their bracket expression. +The fixes normalize accepted facts, narrow EIP arithmetic, and refuse unsupported POP, bit-string, and prefixed-text semantics without extending the existing capture opcode policy. +Fifteen public-tool NativeLua cases cover the fixes, and the full 607-test NativeLua suite passed. +The same independent reviewer re-reviewed the fixes and found no remaining actionable defects in scope. +That is a source and test review result, not a claim that all possible defects or outstanding native/release gates have been exhausted. + +## Environment + +| Component | Observed identity | +| --- | --- | +| OS | Windows 11 Pro x64, `10.0.26300` | +| Build SDK | .NET `10.0.401` | +| Native host | Cheat Engine x64 `7.7.1.10828` | +| Plugin frameworks | Microsoft.NETCore.App, Microsoft.AspNetCore.App, Microsoft.WindowsDesktop.App `10.0.12`, x64 | +| x86 fixture framework | Microsoft.NETCore.App `10.0.12`, x86 | +| Native host SHA-256 | `CF6CCC664A90C23531EC20F5C41458CFDC1D04679D6D05ED94C06BCECF95544F` | +| Lua 5.3 x64 SHA-256 | `C95DCDFA0F60F97B43D970D77FD1BB907AF4DE04B500A3C89A99600B20B35BD2` | + +The Lua DLL came from the reviewed CE installation. +The existing reviewed autorun hashes were unchanged and accepted by the native harness. +No runtime, compiler, driver, dependency, or host installation was changed for these runs. + +## Tested artifact SHA-256 + +| Artifact | SHA-256 | +| --- | --- | +| Release plugin DLL | `E6E2AE56B43CF54F1F5978B566EE762939B76E549D557FDDF8B073BB8262750B` | +| Release Native AOT gateway | `450D0E89EDABC95551689AFE277EB8FBDFF9683A8F0B4395D6293F3C74AE1FA8` | +| Debug plugin DLL | `0D95A43501EC95453E96DC7BF508CEE6A32CCCCEA3125732E5A55D8180CF9C45` | +| Debug Native AOT gateway | `B0D198A18B9955AA6AFBD917396339997CAA54EE089981A5BAEBE0C563479F61` | +| Local Release ZIP | `72B3E25DF77816DD9B5403EEB229D32350EC72DE1B92206C777E95D03321D37C` | + +Distributions are under the ignored `artifacts/dist/roadmap-debug` and `artifacts/dist/roadmap-release` directories. +The local ZIP is under `artifacts/releases/roadmap-candidate`; it is a working-build artifact and is not the public beta.2 payload. +Build, test, format, publication, and native console logs are retained locally under `artifacts/issue-edits/roadmap-*.log`. +Raw native reports remain outside the repository in the harness's per-user run storage. +Only redacted results and run identifiers are recorded here. + +## Native cleanup and limits + +Both native runs reported `passed=true`, empty `cleanupFailures`, `userStateRestored=true`, and `sourceInstallationUnchanged=true`. +The harness stopped only its owned private CE processes and disposable targets; existing MCP client gateways were preserved. +The live scenario covers signed pointer offsets, unreadable hops, zero-address resolution with an unreadable final value, two-instance isolation, and a fresh chain after target restart. +Supplied x86 and x64 instruction facts passed through the actual gateway and plugin; this is not a live debugger-capture qualification. +The maintained scanner, memory read/write, disassembly, record-freeze, speedhack restoration, and one-host withdrawal checks also passed in both runs. +No native compiler, injection, debugger capture, kernel, or hypervisor probe ran. + +## Remaining release work + +The compiler's actual availability, diagnostics, prerequisites, raw temporary-file lifetime, and separate injection remain pending the [proposed compiler policy](compiler-probe-policy.md). +The broader workflow matrix, repeated activation recovery, measured performance baselines, two-hour soak, installation/upgrade/rollback, named MCP clients, CI and analysis, clean final source identity, version/tag change, and public release verification remain open. +The completed working-tree checks do not close any of those requirements. +See [release notes](release-notes.md) for the draft migration path and [release-signoff.md](release-signoff.md) for final gates. diff --git a/docs/qualification/v2.0.0/release-notes.md b/docs/qualification/v2.0.0/release-notes.md new file mode 100644 index 0000000..003bebf --- /dev/null +++ b/docs/qualification/v2.0.0/release-notes.md @@ -0,0 +1,53 @@ +# Stable v2.0.0 release notes draft + +Status: Unpublished draft; the current working build still identifies itself as `2.0.0-beta.2`. +Publication requires the final candidate gates in [release-signoff.md](release-signoff.md). + +## Changes since beta.2 + +- `pointer_get_access_info` analyzes supplied x86 or x64 instruction and register facts and explains effective addresses, uncertainty, and pointer-search guidance without live symbol lookup. +- `pointer_read_chains` validates bounded candidate chains with separate resolution, comparison, and optional final-value outcomes, cancellation progress, and target-change guards. +- `exec_compile_csharp` wraps CE's compiler and immediately exports its temporary assembly through the existing file policy; compilation remains separate from injection. +- The backend catalog contains 193 tools; gateway discovery adds `instance_list`. +- Publication compares the actual bundled plugin against reviewed tool, resource, template, and prompt snapshots. +- Build CI now runs the portable suite in Debug and Release. +- The private native fixture supports separate x86 and x64 runs and target restart checks. + +The C# compiler's native prerequisites and temporary-file lifetime remain pending the separately approved [compiler qualification policy](compiler-probe-policy.md). +These notes must not describe that feature as natively qualified until its evidence is recorded. + +## Support and limitations + +The package remains Windows x64 with an x64 Cheat Engine 7.7 host and a self-contained x64 gateway. +The plugin requires x64 .NET 10, ASP.NET Core 10, and Windows Desktop 10 runtimes. +Target architecture and optional compiler, collector, debugger, kernel, and DBVM availability are separate from plugin-host support. +Use the [support matrix](support-matrix.md) and evidence ledger for exact tested environments and outstanding qualification. + +Instruction text is authoritative for supplied pointer analysis; optional instruction bytes validate length and supported address-size facts and are not an independent decoder. +Post-execution register facts and indexed operands can be uncertain or dynamic. +Absolute pointer roots must be refreshed after a target restart; symbolic roots resolve against the currently selected target. +An unreadable final value does not turn a resolved pointer address into a failed chain. + +## Upgrade from beta.2 or a folder deployment + +1. Preserve the existing matching plugin and gateway pair as a rollback copy, along with personal configuration and CE's runtime configuration. +2. Disable the plugin, close Cheat Engine, and stop the MCP client's gateway before replacing loaded files. +3. Install the plugin DLL and gateway from the same verified package into a new directory. +4. Remove an obsolete folder-deployment plugin entry before selecting the new single `CheatEngine.Mcp.dll`. +5. Point the MCP client at the new gateway executable and enable the plugin. +6. Discover a fresh `instanceId` using `instance_list`, then verify `runtime_get_info` before attaching to a target. + +Keep personal settings in the existing user data directory; no configuration migration or new dependency is introduced by these changes. +The four capability switches remain enabled by default and are exposure controls, not a sandbox. +File and table allowed roots remain empty by default. +Do not overwrite or delete unrelated CE plugins, tables, records, or settings during an upgrade. + +To roll back, close both processes, restore the previous matching plugin/gateway paths and any deliberately changed configuration, and rediscover the new activation. +Do not force replacement of locked files or reuse an old activation identifier. +These are migration instructions; clean-install, upgrade, rollback, and supported-client qualification must still be recorded on the final package. + +## Release identity + +The final notes must name the clean source commit, exact version, qualified environments, public ZIP, and checksum file. +Existing beta tags remain unchanged; stable publication uses a new annotated `v2.0.0` tag. +No local working-build evidence is a claim that stable assets have been published. diff --git a/docs/qualification/v2.0.0/release-signoff.md b/docs/qualification/v2.0.0/release-signoff.md new file mode 100644 index 0000000..910ddcd --- /dev/null +++ b/docs/qualification/v2.0.0/release-signoff.md @@ -0,0 +1,113 @@ +# Stable v2.0.0 release sign-off + +Status: Planned and not approved for release. + +This document is the durable progress and evidence index for the stable v2.0.0 program. +Unchecked items remain incomplete even when related source code exists. +Issue #28 checkboxes must be updated only after the corresponding evidence is reviewed. + +## Critical path + +- [ ] Review and approve the support matrix, acceptance criteria, release-stopping defects, and evidence rules. +- [x] Create focused implementation and qualification issues with owners, dependencies, required evidence levels, and roadmap requirement identifiers: #29 through #33 in the v2.0.0 milestone. +- [x] Run the local working candidate through the existing dynamic Cheat Engine 7.7.1.10828 fingerprint and reviewed-autorun checks; the final clean candidate must repeat them. +- [x] Add disposable x86 and x64 fixture coverage required by the target architecture promise. +- [x] Make the packaged-DLL probe compare the exact packaged plugin against the explicit candidate golden directory. +- [x] Complete `pointer_get_access_info` implementation, V+, applicable VN, documentation, and supplied-facts gateway/plugin qualification; live debugger capture remains a separate open workflow. +- [x] Complete `pointer_read_chains` implementation, V+, documentation, and x86 and x64 native fixture qualification. +- [ ] Complete the `compileCS` feasibility check under a reviewed policy. +- [ ] Complete `exec_compile_csharp` implementation and qualification or record an explicit reviewed deferral with a linked follow-up. +- [ ] Reconcile and freeze the final tools, schemas, resources, prompts, completions, routing, capability gates, and migration contract. +- [ ] Complete lifecycle, recovery, workflow-domain, installation, upgrade, rollback, and supported-client qualification. +- [ ] Record reproducible performance baselines and approve the measured-relative thresholds. +- [ ] Complete the planned two-hour soak against the exact RC package. +- [ ] Build and qualify the exact RC commit and package. +- [ ] Set the final version to `2.0.0` in committed source and rebuild from the clean final commit. +- [ ] Repeat the required final-source and final-package checks after the version change. +- [ ] Create the annotated `v2.0.0` tag at the exact binary source commit. +- [ ] Upload the ZIP and `SHA256SUMS.txt` as a draft and verify remote identity, hashes, sizes, and notes. +- [ ] Publish with `draft=false` and `prerelease=false` only after review. +- [ ] Download and verify the public package and smoke-test the shipped installation and first-use path. + +## Evidence ledger + +Working-tree evidence is recorded in [local-checkpoint.md](local-checkpoint.md), including package hashes, environment, test counts, two native run identifiers, cleanup, and limitations. +It does not establish a clean final stable candidate. +The exact candidate and release gates below remain open until that clean candidate is built and qualified. + +| Evidence ID | Requirements | Level | Candidate | Environment | Outcome | Cleanup | Safe reference | Status | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| LOCAL-01 through LOCAL-08 | Sections 3-6 and bounded portions of 7, 8 and 11 | implementation, V+, VN, AOT, VL | Uncommitted `codex/roadmap-v2`, beta.2 version; exact artifact hashes in checkpoint | Windows 11 Pro 26300; CE 7.7.1.10828; .NET 10.0.12; x86/x64 targets | All recorded checks passed | Both native runs restored user state and preserved the source installation with no cleanup failures | [Local checkpoint](local-checkpoint.md) | pass within recorded scope | + +The candidate column must include version, full commit, clean-tree state, configuration, and relevant SHA-256 values. +The environment column must include exact native versions for VL, soak, installation, and release results. +The cleanup column must state whether owned state was released, user state was restored, and the source installation stayed unchanged. +The safe reference may be a CI URL, release URL, or redacted evidence file and must not expose secrets or personal paths. + +## Exact candidate gates + +- [ ] V passed with locked restore, Debug build, and portable tests excluding LiveQualification and NativeLua. +- [ ] V+ passed with both format checks, Release build, `eng/Publish.ps1`, and reviewed intended golden changes. +- [ ] The portable suite passed against both Debug and Release outputs with zero failures and zero skips. +- [ ] VN passed when applicable and recorded the selected real Lua 5.3 x64 DLL identity. +- [ ] AOT passed for the exact candidate gateway executable. +- [ ] The packaged lone plugin DLL matched the reviewed candidate tool, resource, template, and prompt goldens in isolated load contexts. +- [ ] VL passed on the exact candidate package with explicit maintainer authorization and all existing live restrictions. +- [ ] CI build, format, test, and analysis results were reviewed for release-stopping findings. +- [ ] Locked dependencies, high and critical advisory handling, embedded inventory, licenses, and notices were verified. +- [ ] The flat distribution and ZIP inventories, deterministic packaging promises, checksums, versions, and source identity were verified. + +## Contract freeze + +- [ ] Final public names and catalog order are reviewed. +- [ ] Input, result, error, and annotation shapes are reviewed. +- [ ] Reflection-disabled JSON and Native AOT metadata are complete. +- [ ] Gateway `instanceId` routing and stale-instance refusal are verified. +- [ ] Capability requirements reject before covered effects. +- [ ] File and table root defaults and configuration precedence are documented accurately. +- [ ] Tool map, workflows, prompts, resources, READMEs, and golden files agree with the candidate. +- [ ] Beta.2 migration and future compatibility policy are reviewed. + +## Native and recovery sign-off + +- [ ] Repeated enable, disable, and re-enable creates fresh activation identity, token, endpoint, discovery record, and Client state. +- [ ] CE shutdown, target exit and restart, client disconnect, gateway restart, and one-backend withdrawal preserve the other instance. +- [ ] Target transition guards and stale selection epochs prevent work against a replacement process. +- [ ] Owned allocations, patches, symbols, scanners, breakpoints, pauses, speed changes, jobs, and Mono attachment follow their documented cleanup paths. +- [ ] User-owned records, structures, breakpoints, symbols, tables, settings, and unrelated plugins remain intact. +- [ ] Failed cleanup retains honest orphan and recovery information. +- [ ] Cancellation, timeout, lost response, cursor replay, pagination, TTL, bounded buffers, and stop or dispose races have reviewed outcomes. +- [ ] Every advertised domain has a reviewed qualification classification, limitations, and recovery path. + +## Performance and soak sign-off + +- [ ] Three reproducible baseline runs record workload sizes, environment, and measurement method. +- [ ] Candidate p95 results meet the measured-relative thresholds in `acceptance.md`. +- [ ] Busy responses and dispatch concurrency do not starve Cheat Engine's main thread. +- [ ] Large jobs, completions, and live resources remain within configured bounds. +- [ ] The planned two-hour two-instance soak completes its required workload. +- [ ] Final handle and private-byte measurements meet the approved recovery thresholds. +- [ ] No crash, hang, cross-instance effect, stale-target effect, false success, cleanup failure, or leaked owned state remains. + +## Installation, upgrade, and release sign-off + +- [ ] A clean install from the exact candidate ZIP passes on the supported environment. +- [ ] Beta.2 and older folder deployment upgrade paths pass without mixing versions. +- [ ] Disable, uninstall, rollback, and locked-file recovery are reproducible. +- [ ] Paths with spaces, a standard user account, allowed roots, configuration failures, and endpoint conflicts have reviewed outcomes. +- [ ] Every named supported MCP client passes the documented first-use workflow. +- [ ] Stable release notes include support, migration, recovery, limitations, and changes since beta.2. +- [ ] The final stable commit repeats V+, Debug and Release portable tests, and applicable VN. +- [ ] The final Release package repeats AOT, packaged-DLL golden comparison, ZIP, checksum, and source-identity checks. +- [ ] The final Release package repeats clean install, upgrade, rollback, and maintainer-authorized two-instance VL. +- [ ] Retained RC evidence has a reviewed intervening diff and explicit applicability decision. +- [ ] The public stable assets download successfully and match the reviewed hashes and source identity. +- [ ] The shipped plugin discovery, gateway, and documented first-use workflow pass after publication. + +## Final decision + +- [ ] Every P0 requirement has complete reviewed evidence. +- [ ] Every P1 requirement is implemented or has an explicit reviewed deferral and linked follow-up. +- [ ] No release-stopping defect remains unresolved. +- [ ] The final candidate contract, package, support matrix, migration path, soak, and release evidence are approved. +- [ ] Issue #28 may be closed only after the public stable package is verified. diff --git a/docs/qualification/v2.0.0/support-matrix.md b/docs/qualification/v2.0.0/support-matrix.md new file mode 100644 index 0000000..5a66a02 --- /dev/null +++ b/docs/qualification/v2.0.0/support-matrix.md @@ -0,0 +1,73 @@ +# Stable v2.0.0 support matrix + +Status: Draft scope for implementation and qualification. + +This document defines the minimum intended stable boundary and does not claim that the release candidate has passed qualification. +The final supported and qualified rows require evidence from the exact release candidate package. + +## Product boundary + +- The stable package is Windows x64 only. +- The Cheat Engine plugin host is x64 only. +- The gateway is the self-contained `win-x64` executable from the matching package. +- The plugin requires the x64 .NET 10, ASP.NET Core 10, and Windows Desktop 10 runtimes. +- The plugin and gateway must come from one build and must report matching version and source identity. +- External debugger and decompiler integration is outside the stable v2.0.0 boundary. +- General desktop automation and target-application UI automation are outside the stable v2.0.0 boundary. + +## Minimum compatibility matrix + +| Component | Intended stable boundary | Candidate qualification requirement | Current status | +| --- | --- | --- | --- | +| Windows | Windows x64 | Record the exact edition, version, build, and architecture used for every native, soak, installation, and release result. | Pending qualification. | +| Cheat Engine host | Cheat Engine 7.7 x64 | Qualify the exact installed host through the harness fingerprint and reviewed autorun checks. | Baseline only. | +| Installed candidate environment | Cheat Engine 7.7.1.10828 x64 | Run the exact candidate package and record the dynamic installation fingerprint, reviewed autorun result, and cleanup. | Local working package passed both native fixture runs; final clean candidate pending. | +| Plugin runtime | `Microsoft.NETCore.App`, `Microsoft.AspNetCore.App`, and `Microsoft.WindowsDesktop.App` 10.0.x x64 | Record every exact runtime patch used by the candidate qualification. | Pending qualification. | +| Gateway runtime | Self-contained `win-x64` Native AOT executable | Run the executable MCP smoke check from the exact candidate distribution. | Local Debug and Release executables passed; final clean candidate pending. | +| Target architectures | x86 and x64 Windows user-mode targets | Exercise independent disposable fixtures for each architecture and record pointer width, target identity, and cleanup. | Both local fixture architectures passed the maintained scenario and restart checks. | +| MCP transport | Local stdio gateway to authenticated per-activation loopback backends | Verify explicit `instanceId` routing, stale-instance refusal, and two-instance isolation. | Pending candidate qualification. | +| MCP clients | Clients named by the release documentation | Run packaged first-use registration, discovery, selection, resources, prompts, completions, errors, and cleanup in every named client. | No client is qualified by this draft. | + +The exact Windows build used during planning is `10.0.26300`, but that fact is not candidate qualification. +The local working package passed the bounded native cases recorded in [local-checkpoint.md](local-checkpoint.md). +That evidence must not be generalized to every advertised domain or to an untested final stable package. +The live harness already accepts and fingerprints an installed Cheat Engine 7.7-or-newer x64 host dynamically, so no new static profile is required for this build. + +## Capability classification + +Every advertised domain must have one of the following statuses in the final release evidence. + +- `qualified` means the exact candidate package passed its required portable and native cases on the supported environment. +- `optional_environment` means the contract and refusal paths are stable, but successful use depends on an installed runtime, collector, driver, hypervisor, compiler, or target property. +- `experimental` means the typed contract is available but its native coverage or operating boundary is intentionally narrower than the stable promise. +- `unsupported` means the release documents the boundary and does not advertise successful operation. + +Core routing, process selection, memory, scans, AOB, pointers, modules, symbols, code analysis, structures, records, tables, user-mode debugger, Lua, and speedhack are intended for qualification. +.NET, Mono, IL2CPP, debugger interfaces, compilation, injection, managed invocation, kernel, and DBVM capabilities require separate environment and policy evidence. +Kernel and DBVM unavailability on ordinary Windows configurations is an expected supported outcome and is not evidence that successful kernel operation works. +LBR, shared allocation, DBK map or unmap, caller-selected CR3 access, raw kernel allocation, and broader native-host support remain unsupported unless separately designed and qualified. + +## P1 stable inclusion + +All three P1 features are intended for stable v2.0.0 when their acceptance criteria in [acceptance.md](acceptance.md) are met. +`pointer_get_access_info` and `pointer_read_chains` are included in the planned stable contract. +`exec_compile_csharp` is included in the plan but remains pending a bounded feasibility check against the reviewed Cheat Engine 7.7 host. +If a P1 feature proves infeasible, it requires an explicit linked follow-up, the recorded blocking evidence, and a reviewed roadmap decision before contract freeze. +No P1 feature may be silently deferred to fit a work session or release schedule. + +## Compatibility policy + +The stable v2.0.0 contract freezes tool names, arguments, result and error shapes, resource URIs, prompts, and gateway routing semantics after the selected P1 changes land. +Additive optional fields and new tools may be added in compatible v2 patch or minor work when old callers keep their behavior. +Removing or renaming a tool, changing an existing field meaning, changing pointer-chain order, or changing host-effect reporting requires a future reviewed breaking contract. +Configuration keeps the current precedence of built-in defaults, deployment settings, user settings, and dedicated environment overrides. +The four capability switches remain exposure controls and do not become a sandbox. +File and table roots remain empty by default. +Migration from beta.2 must preserve user configuration, tables, unrelated plugins, Cheat Engine settings, and rollback paths. +The beta tags remain unchanged, and stable publication uses a new `v2.0.0` tag at the exact final binary source commit. + +## Release-stopping defects + +The release stops for a crash, corruption, cross-instance effect, cross-target effect, stale-epoch work, unreported partial or unknown mutation, leaked owned state, broken install or upgrade, incorrect package identity, or wrong success reporting. +The release also stops when cleanup claims success after an orphan, a destructive capability bypasses its gate, an uncertain mutation is replayed automatically, or a supported workflow cannot be recovered using its documented path. +An optional capability that is honestly unavailable is not a release-stopping defect when its refusal and limitation match the reviewed contract. diff --git a/eng/Publish.ps1 b/eng/Publish.ps1 index 1b1c3dd..4513195 100644 --- a/eng/Publish.ps1 +++ b/eng/Publish.ps1 @@ -124,7 +124,8 @@ try { dotnet build tests/CheatEngine.Mcp.LiveTarget -c $Configuration -p:RestoreLockedMode=true -p:ContinuousIntegrationBuild=true if ($LASTEXITCODE -ne 0) { throw 'Plugin bundle probe build failed.' } $probe = Join-Path $repoRoot "artifacts/bin/CheatEngine.Mcp.LiveTarget/$variant/CheatEngine.Mcp.LiveTarget.dll" - dotnet exec --runtimeconfig (Join-Path $pluginOutput 'CheatEngine.Mcp.runtimeconfig.json') $probe --probe-plugin (Join-Path $dist 'CheatEngine.Mcp.dll') + $goldenDirectory = Join-Path $repoRoot 'tests/CheatEngine.Mcp.Tests/Contract/Golden' + dotnet exec --runtimeconfig (Join-Path $pluginOutput 'CheatEngine.Mcp.runtimeconfig.json') $probe --probe-plugin (Join-Path $dist 'CheatEngine.Mcp.dll') $goldenDirectory if ($LASTEXITCODE -ne 0) { throw 'The published single-DLL plugin failed the isolated load probe.' } dotnet publish srcs/CheatEngine.Mcp.Gateway -c $Configuration -p:PublishProfile=Standalone -p:RestoreLockedMode=true -p:ContinuousIntegrationBuild=true @@ -145,6 +146,6 @@ try { if ($missing.Count -ne 0) { throw "Incomplete distribution in ${dist}; missing: $($missing -join ', ')" } $files = @(Get-ChildItem -LiteralPath $dist -Force) if ($files.Count -ne $expected.Count) { throw 'The distribution must contain exactly the single plugin DLL, gateway, instructions and notices.' } - Get-ChildItem -LiteralPath $dist | Select-Object Name, Length + Get-ChildItem -LiteralPath $dist | Select-Object Name, Length, @{ Name = 'SHA256'; Expression = { (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash } } } finally { Pop-Location } diff --git a/libs/CheatEngine.Mcp.Core/Contract/CheatEngineToolNames.cs b/libs/CheatEngine.Mcp.Core/Contract/CheatEngineToolNames.cs index 6e8b99c..8df34a8 100644 --- a/libs/CheatEngine.Mcp.Core/Contract/CheatEngineToolNames.cs +++ b/libs/CheatEngine.Mcp.Core/Contract/CheatEngineToolNames.cs @@ -3,7 +3,7 @@ namespace CheatEngine.Mcp.Core.Contract; /// -/// The reviewed names of the v2 tool contract: 186 backend tools in 21 domains, in catalog order, and the gateway's +/// The reviewed names of the v2 tool contract, in catalog order, and the gateway's /// own /// . Tools, prompts, server instructions and error hints cite these constants, so a rename /// breaks the build instead of the contract, and the startup validator refuses a v2 tool whose name is not listed. @@ -169,6 +169,12 @@ public static class CheatEngineToolNames /// The pointer_read_chain tool. public const string PointerReadChain = "pointer_read_chain"; + /// Resolve supplied pointer chains in a bounded batch. + public const string PointerReadChains = "pointer_read_chains"; + + /// Analyze supplied instruction and register facts for pointer access. + public const string PointerGetAccessInfo = "pointer_get_access_info"; + /// The pointer_find_references tool. public const string PointerFindReferences = "pointer_find_references"; @@ -521,6 +527,9 @@ public static class CheatEngineToolNames /// The exec_compile_c tool. public const string ExecCompileC = "exec_compile_c"; + /// Compile C# through Cheat Engine and export the resulting assembly. + public const string ExecCompileCSharp = "exec_compile_csharp"; + // dotnet (10) /// The dotnet_get_status tool. @@ -631,7 +640,7 @@ public static class CheatEngineToolNames /// The lua_find_api tool. public const string LuaFindApi = "lua_find_api"; - /// The 186 backend tool names; every tool a Cheat Engine instance serves. + /// The reviewed backend tool names; every tool a Cheat Engine instance serves. public static FrozenSet Backend { get; @@ -682,6 +691,8 @@ public static FrozenSet Backend AobGenerateSignature, AobFindValue, PointerReadChain, + PointerReadChains, + PointerGetAccessInfo, PointerFindReferences, PointerCreateMap, PointerListMaps, @@ -792,6 +803,7 @@ public static FrozenSet Backend ExecCallMethod, ExecCallLocal, ExecCompileC, + ExecCompileCSharp, DotNetGetStatus, DotNetListDomains, DotNetListModules, diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/mono-and-dotnet.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/mono-and-dotnet.md index 3c8cecd..3bbc8dd 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/mono-and-dotnet.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/mono-and-dotnet.md @@ -270,6 +270,15 @@ them after the game or the collector restarts. ## Loading your own managed code +`exec_compile_csharp(source="", outputPath="")` compiles and exports without invocation. +It requires TCE, CE compiler prerequisites, at most 131,072 source characters and 32 held `referencePaths`; optional `coreAssembly` is also checked and held. +It returns path/length/SHA-256, caps output at 16 MiB, and requires `overwrite=true` to replace a file. +Compiler errors carry `{text,truncated}` capped at 16 KiB; prerequisites and syntax errors may share the same diagnostic outcome. +CE owns its temporary file, which another CE instance closing may remove; missing/expired export is explicit. +Use the exported copy for separately authorized injection, inspect partial/unknown effects before retrying, and do not assume unload or native compiler qualification. + +### Explicit injection + `exec_inject_dotnet` loads a managed assembly into a .NET Framework or .NET Core target and calls its `public static int Method(string)`. Use it only on explicit request: diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/pointers.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/pointers.md index 6808a01..56a98a6 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/pointers.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/pointers.md @@ -59,6 +59,27 @@ The table shows one chain in each place you meet it: step 8 of the x64 tutorial address for this session, not a live chain. After the object moves, `symbol_unregister(name="playerBase")` and register it again (a name in use is refused). A record with offsets follows its chain on every read. +## Analyze supplied access facts + +`pointer_get_access_info` takes instruction text/address/length, x86/x64 `architecture`, `registers`, and optional `symbols` and `observedAccessAddress`. +Text is authoritative; optional `instructionBytes` checks length/address-size prefixes without decoding text. +RIP/EIP use the next instruction's address; EIP wraps to 32 bits. +No live memory or symbol lookup occurs. +Use `contextPhase="post_execution"` for data hits and an execute capture when registers may have changed. +Check `status`, `contextMayHaveChanged`, `observedAddressMismatch`, and `uncertainty` before using `candidateStructureBase` or `nextPointerSearchValue`. +`dynamicOffset=true` identifies indexed access rather than a stable chain offset. +Stack-relative POP, BT/BTC/BTR/BTS, and prefixed text are unsupported; bracket arithmetic is insufficient. + +## Validate several supplied chains + +`pointer_read_chains` accepts `{id,base,offsets}` candidates, optional `target` comparison and `valueType`, without changing scans. +Bounds: 128 candidates, 64 offsets each, 4,096 pointer/value reads, 65,536 final-value bytes; each dispatch allows 32 candidates and 128 reads. +`chainStatus`, `comparisonStatus`, and `valueStatus` are independent: failed value reads retain the resolved address and comparison. +`matchesOnly=true` retains all error/miss counts in `summary`. +Cancellation preserves completed candidates; submitted minus processed is unprocessed, and `cancelled` reports interruption. +`target_changed` discards mixed-target results. +Module roots rebase; absolute roots remain absolute, so revalidate after restarts. + ## Manual method: from a writer back to a static root Use it when you can make the game change the value. It needs the debugger (see [debugger](debugger.md)); the guided diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/tool-map.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/tool-map.md index 9e83c2f..a62573c 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/tool-map.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/tool-map.md @@ -126,6 +126,8 @@ Guide: [pointers](pointers.md). Offsets are hex strings in dereference order, th | Tool | Gate | Purpose | |---|---|---| | `pointer_read_chain` | | Follow a pointer chain hop by hop and read the final value. | +| `pointer_read_chains` | | Validate up to 128 supplied chains with independent resolution, comparison, and value outcomes. | +| `pointer_get_access_info` | | Analyze supplied instruction/register facts without live memory or symbol lookup. | | `pointer_find_references` | | Find the addresses pointing at or just below a target, live or in a map. `host_scan` | | `pointer_create_map` | | Start a job that captures a pointer map; poll `pointer_list_maps`. | | `pointer_list_maps` | | List pointer maps and their capture state. | @@ -320,6 +322,7 @@ Guide: [calling a game function once](x64-injection.md#calling-a-game-function-o | `exec_call_method` | TCE | Call a target instance method on a new thread; waits at most 10 s. `blocking_native` | | `exec_call_local` | TCE | Call a function in CE's own process; no timeout. `blocking_native` | | `exec_compile_c` | TCE →KA | Compile C source into new memory or at an address you own. `blocking_native` | +| `exec_compile_csharp` | TCE | Compile C# through CE and export to a required approved-root assembly path; does not inject. `blocking_native` | - `exec_call_remote` and `exec_call_method` take up to 16 typed arguments, each value a JSON string: `exec_call_remote(functionAddress="
", arguments=[{type="integer", value="10"}])`. A buffer is diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/manual-pointer-chain.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/manual-pointer-chain.md index 6590618..2585df7 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/manual-pointer-chain.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/manual-pointer-chain.md @@ -16,6 +16,7 @@ modify only. value; read the hits with `debugger_poll_capture(jobId=..., afterSequence=0)`, then `runtime_stop_job(jobId=...)`. 4. Pick a hit whose `disassembly` has a `[reg+off]` operand, such as `mov [rbx+000004C8],eax`: `4C8` is this level's offset; the watched address minus it is the object base, whatever the registers show. + `pointer_get_access_info` takes supplied instruction/register facts and explicit symbols; use `contextPhase="post_execution"` for data hits and check uncertainty. 5. `pointer_find_references(target="
-4C8", maxOffset=0)`: `target` is the base, `references` its holders. One with a `symbol` (such as `game.exe+1A2B30`) lies in a module image, a static root: go to step 7. 6. Only heap holders: capture one that a second search still returns: @@ -23,6 +24,7 @@ modify only. `mov rbx,[rsi+18]` gives the offset `18`; the base is `-18`. Repeat 4-6, at most `{maxLevels}` levels. 7. Verify: `pointer_read_chain(base="game.exe+1A2B30", offsets=["18", "4C8"], valueType="int32")`: hex offsets in dereference order, the one found last first; `address` must equal `{address}`. + For several candidates, use `pointer_read_chains` with `{id,base,offsets}`, optional `target` and `valueType`; inspect all three outcome statuses. 8. Stop every capture with `runtime_stop_job(jobId=...)`, then `debugger_detach()`. 9. The user restarts the game; `process_attach(process="")`, find the value again and repeat step 7: the new `address` must hold it. diff --git a/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerLuaScripts.cs b/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerLuaScripts.cs index 103c229..98786ec 100644 --- a/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerLuaScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerLuaScripts.cs @@ -1,4 +1,5 @@ using CheatEngine.Mcp.Core.Jobs; +using CheatEngine.Mcp.Tools.Pointer; namespace CheatEngine.Mcp.Tools.Debugger; @@ -378,7 +379,7 @@ elseif type(value) == 'table' then text = byteText(value) end // Arguments: namespace, job id, buffer limit, ttl ms, address, trigger, size, aggregate by instruction, group by // effective address. - internal const string StartCapture = LuaJobKernelScripts.Strategies + "\n" + """ + internal static string StartCapture => LuaJobKernelScripts.Strategies + "\n" + $$""" local function captureContext(trigger, includeStack) local wide = targetIs64Bit() local ip = wide and RIP or EIP @@ -408,144 +409,8 @@ local function captureContext(trigger, includeStack) isHeuristic = heuristic, disassembly = text, registers = registers, stackPointer = includeStack and registers[wide and 'RSP' or 'ESP'] or nil} end - -- Effective-address grouping parses the one memory operand of the instruction at the execute breakpoint once, - -- from Cheat Engine's own disassembly: a created disassembler prints hexadecimal instead of symbols and - -- resolves RIP-relative operands to absolute addresses. Displacements and symbols fold into one constant; - -- each hit adds the scaled registers that Cheat Engine sets as globals before the instruction runs. - local operandSizes = {byte = 1, word = 2, dword = 4, fword = 6, qword = 8, tword = 10, dqword = 16, - xmmword = 16, ymmword = 32, zmmword = 64} - local function operandRegisters(wide) - local registers = {} - if not wide then - for _, name in ipairs({'EAX','EBX','ECX','EDX','ESI','EDI','EBP','ESP'}) do - registers[name] = {name, true} - end - return registers - end - for _, name in ipairs({'RAX','RBX','RCX','RDX','RSI','RDI','RBP','RSP'}) do - registers[name] = {name, false} - registers['E' .. name:sub(2)] = {name, true} - end - for index = 8, 15 do - registers['R' .. index] = {'R' .. index, false} - registers['R' .. index .. 'D'] = {'R' .. index, true} - end - return registers - end - -- Cheat Engine keeps the legacy prefixes first in the instruction bytes, before any REX or VEX prefix. Its - -- 64-bit operand text names 64-bit registers even under an address-size override (67), which truncates the - -- address to 32 bits, so only the bytes reveal the override. - local legacyPrefixes = {[0xF0] = true, [0xF2] = true, [0xF3] = true, [0x2E] = true, [0x36] = true, - [0x3E] = true, [0x26] = true, [0x64] = true, [0x65] = true, [0x66] = true, [0x67] = true} - local function addressSizeOverride(bytes) - if type(bytes) ~= 'table' then return false end - for index = 1, #bytes do - if not legacyPrefixes[bytes[index]] then return false end - if bytes[index] == 0x67 then return true end - end - return false - end - -- Returns the operand plan, or nil with an error kind and message. - local function operandPlan(address, opcode, parameters, length, wide, override) - local name = type(opcode) == 'string' and opcode:lower() or '' - local text = type(parameters) == 'string' and parameters or '' - if name == '' or name == '??' then - return nil, 'invalid_argument', 'Cheat Engine could not decode an instruction at the capture address.' - end - local instruction = (name .. ' ' .. text):sub(1, 256) - if name == 'lea' or name == 'nop' then - return nil, 'invalid_argument', instruction .. ' only computes an address; it does not access memory.' - end - if override and not wide then - return nil, 'unsupported', instruction .. ' uses a 16-bit address-size override (67 prefix), whose ' .. - 'memory operand Cheat Engine does not disassemble.' - end - local open = text:find('[', 1, true) - if open == nil then - return nil, 'invalid_argument', instruction .. ' has no explicit [...] memory operand.' - end - local close = text:find(']', open + 1, true) - if close == nil or text:find('[', close + 1, true) ~= nil then - return nil, 'unsupported', instruction .. ' does not have exactly one memory operand.' - end - local lead = text:sub(1, open - 1):match('([^,]*)$'):lower() - local segment = lead:match('([fg]s)%s*:%s*$') - if segment ~= nil then - return nil, 'unsupported', instruction .. ' is relative to the ' .. segment .. - ' segment base, which a breakpoint context does not expose.' - end - local keyword = lead:match('(%a+)%s+ptr') - local plan = {constant = 0, terms = {}, narrow = not wide or override, - size = keyword and operandSizes[keyword] or nil} - local registers = operandRegisters(wide) - local malformed = instruction .. ' has a memory operand that MCP cannot evaluate.' - local expression = text:sub(open + 1, close - 1) - local cursor, count = 1, 0 - while cursor <= #expression do - local signs = expression:match('^[%s%+%-]*', cursor) - cursor = cursor + #signs - count = count + 1 - if cursor > #expression or count > 8 then return nil, 'unsupported', malformed end - -- Cheat Engine prints a negative 8-bit displacement after a SIB byte as '-' and a signed number, such - -- as --80, so any minus sign makes the term negative. - local sign = signs:find('-', 1, true) ~= nil and -1 or 1 - local term, quoted = nil, false - if expression:sub(cursor, cursor) == '"' then - local finish = expression:find('"', cursor + 1, true) - if finish == nil then return nil, 'unsupported', malformed end - term, quoted = expression:sub(cursor + 1, finish - 1), true - cursor = finish + 1 - else - term = expression:match('^[^%+%-]*', cursor) - cursor = cursor + #term - term = term:match('^%s*(.-)%s*$') - end - local base, scale = term, 1 - if not quoted then - local scaled, factor = term:match('^([%w_]+)%s*%*%s*(%d+)$') - if scaled ~= nil then base, scale = scaled, tonumber(factor) end - end - local upper = base:upper() - local register = not quoted and registers[upper] or nil - if register ~= nil then - if scale ~= 1 and scale ~= 2 and scale ~= 4 and scale ~= 8 then - return nil, 'unsupported', malformed - end - plan.terms[#plan.terms + 1] = {global = register[1], factor = sign * scale} - plan.narrow = plan.narrow or register[2] - elseif not quoted and upper:match('^[XYZ]MM%d+$') then - return nil, 'unsupported', instruction .. ' uses a vector index (VSIB) and accesses several addresses.' - elseif scale ~= 1 or term == '' or term:find('*', 1, true) ~= nil then - return nil, 'unsupported', malformed - elseif not quoted and (upper == 'RIP' or upper == 'EIP') then - plan.constant = plan.constant + sign * (address + length) - else - local value = nil - if not quoted and #term <= 16 and term:match('^%x+$') then - value = tonumber(term, 16) - else - value = getAddressSafe(term) - end - if math.type(value) ~= 'integer' then - return nil, 'unsupported', instruction .. ' names ' .. term:sub(1, 64) .. - ', which Cheat Engine could not resolve.' - end - plan.constant = plan.constant + sign * value - end - end - if count == 0 then return nil, 'unsupported', malformed end - return plan - end - local function operandAddress(plan) - local value = plan.constant - for _, term in ipairs(plan.terms) do - local register = _G[term.global] - if math.type(register) ~= 'integer' then return nil end - value = value + register * term.factor - end - if plan.narrow then value = value & 0xFFFFFFFF end - return value - end + -- Effective-address grouping uses the shared explicit-operand evaluator with CE's resolver and register globals. + {{PointerOperandLuaScripts.Evaluator}} -- One ring slot per group: a new group takes the slot of the oldest one and drops that group's index entry. local function captureGroup(current, key, context, effective, size) local group = current.groups[key] @@ -592,9 +457,10 @@ local function captureGroup(current, key, context, effective, size) return mcp.err('host_refused', 'Cheat Engine could not disassemble the capture address.', 'not_started') end local length = type(data.bytes) == 'table' and #data.bytes or 0 - local plan, kind, message = operandPlan(address, data.opcode, data.parameters, length, targetIs64Bit(), - addressSizeOverride(data.bytes)) + local plan, kind, message = mcpOperandPlan(address, data.opcode, data.parameters, length, targetIs64Bit(), + mcpOperandOverride(data.bytes), function(name) return getAddressSafe(name) end) if plan == nil then + if kind == 'missing_facts' then kind = 'unsupported' end return mcp.err(kind, message, 'not_started', 'Capture an instruction that reads or writes memory ' .. 'through one [...] operand, or omit groupByEffectiveAddress.') end @@ -621,7 +487,7 @@ local function captureGroup(current, key, context, effective, size) local context = captureContext(a[6], true) current.total = current.total + 1 if operand ~= nil then - local effective = operandAddress(operand) + local effective = mcpOperandAddress(operand, _G) assert(effective ~= nil, 'Cheat Engine did not supply the registers of the memory operand') local key = string.format('%X', effective) captureGroup(current, key, context, key, operand.size) diff --git a/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpJsonContext.cs b/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpJsonContext.cs new file mode 100644 index 0000000..11d8b21 --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpJsonContext.cs @@ -0,0 +1,12 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CheatEngine.Mcp.Tools.Exec; + +/// Source-generated JSON metadata for exec_compile_csharp. +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(ExecCSharpCompilerOutput))] +[JsonSerializable(typeof(ExecCSharpCompilerDiagnostic))] +[JsonSerializable(typeof(ExecCSharpCompileResult))] +[JsonSerializable(typeof(ExecCSharpExportFailure))] +internal sealed partial class ExecCSharpJsonContext : JsonSerializerContext; diff --git a/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpRecords.cs b/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpRecords.cs new file mode 100644 index 0000000..9bf75a6 --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpRecords.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; + +namespace CheatEngine.Mcp.Tools.Exec; + +/// The bounded outcome of Cheat Engine's C# compiler before its generated artifact is exported. +internal sealed record ExecCSharpCompilerOutput(string? AssemblyPath, string? Diagnostic = null, + bool DiagnosticTruncated = false, bool CompilerAvailable = true); + +/// A bounded compiler diagnostic reported by Cheat Engine without fabricated code or severity fields. +public sealed record ExecCSharpCompilerDiagnostic( + [property: Description("The compiler's line-message text, bounded to 16384 UTF-8 bytes.")] + string Text, + [property: Description("Whether MCP truncated compiler diagnostic text at its 16384-byte bound.")] + bool Truncated); + +/// The completed approved-root export of a C# assembly compiled by Cheat Engine. +public sealed record ExecCSharpCompileResult( + [property: Description("The anchored approved-root path where MCP copied the compiled assembly.")] + string OutputPath, + [property: Description("The exported assembly length in bytes.")] + long Length, + [property: Description("The uppercase SHA-256 hash of the exported assembly bytes.")] + string Sha256); + +/// Details for a compilation that completed in Cheat Engine but could not be exported through MCP. +public sealed record ExecCSharpExportFailure( + [property: Description("Whether MCP removed its uncommitted protected temporary export file.")] + bool TemporaryOutputCleanupConfirmed, + [property: Description("Whether the atomic destination publish had begun, so the destination may already have changed.")] + bool OutputPublicationMayHaveCompleted); diff --git a/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpScripts.cs b/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpScripts.cs new file mode 100644 index 0000000..5087cd7 --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpScripts.cs @@ -0,0 +1,34 @@ +namespace CheatEngine.Mcp.Tools.Exec; + +/// Fixed Lua bridge for Cheat Engine's C# compiler. +internal static class ExecCSharpScripts +{ + // LuaHandler.compilecs returns a generated filename, or nil and the compiler's line-message text. An absent bridge is + // an explicit unavailable outcome; a present bridge can still report its .NET Framework/compiler prerequisite through + // its own diagnostic. Arguments remain in the typed a table so caller source never becomes Lua source. + internal const string Compile = """ + local function diagnostic(value) + local text = tostring(value or 'Cheat Engine returned no compiler diagnostic.') + local maximum = 16384 + if #text <= maximum then return text, false end + local ok, nextCharacter = pcall(utf8.offset, text, 0, maximum + 1) + if ok and nextCharacter ~= nil then + return text:sub(1, nextCharacter - 1), true + end + return text:sub(1, maximum), true + end + if type(compileCS) ~= 'function' then + return {compilerAvailable = false} + end + local called, filename, message = pcall(compileCS, a[1], a[2], a[3]) + if not called then + local text, truncated = diagnostic(filename) + return {diagnostic = text, diagnosticTruncated = truncated} + end + if type(filename) ~= 'string' or #filename == 0 then + local text, truncated = diagnostic(message) + return {diagnostic = text, diagnosticTruncated = truncated} + end + return {assemblyPath = filename} + """; +} diff --git a/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpTools.cs b/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpTools.cs new file mode 100644 index 0000000..ac53f8f --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Exec/ExecCSharpTools.cs @@ -0,0 +1,274 @@ +using System.ComponentModel; +using System.Runtime.ExceptionServices; +using System.Security.Cryptography; +using System.Text.Json; + +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Features; +using CheatEngine.Mcp.Core.Files; + +using ModelContextProtocol.Server; + +namespace CheatEngine.Mcp.Tools.Exec; + +/// Compiles bounded C# source through Cheat Engine and immediately exports its CE-owned temporary assembly. +[McpServerToolType] +public sealed class ExecCSharpTools +{ + private const int MaximumSourceLength = 131_072; + private const int MaximumReferences = 32; + private const int MaximumArtifactBytes = 16 * 1024 * 1024; + private const int MaximumPathLength = 32_767; + private readonly ToolDispatch _dispatch; + private readonly McpFilePaths _files; + + /// Creates the C# compiler tool container without accessing Cheat Engine or the host filesystem. + public ExecCSharpTools(ToolDispatch dispatch, McpFilePaths files) + { + ArgumentNullException.ThrowIfNull(dispatch); + ArgumentNullException.ThrowIfNull(files); + _dispatch = dispatch; + _files = files; + } + + /// Compiles C# source through Cheat Engine and atomically exports the generated assembly. + [McpServerTool(Name = CheatEngineToolNames.ExecCompileCSharp, Title = "Compile managed source", ReadOnly = false, + Destructive = true, Idempotent = false, OpenWorld = true, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.BlockingNative)] + [RequiresFeature(McpFeature.TargetCodeExecution)] + [Description( + "Compile up to 131072 characters of C# source with Cheat Engine's compileCS bridge and immediately copy its " + + "generated assembly to outputPath under an approved write root. The compiler uses the optional checked coreAssembly " + + "and up to 32 checked reference assemblies. Compilation does not inject or invoke the assembly. Cheat Engine owns " + + "its original generated file and may remove it when Cheat Engine, or another instance, closes; MCP never deletes that " + + "file. Compiler availability still depends on Cheat Engine's supported .NET Framework compiler prerequisites.")] + public ExecCSharpCompileResult CompileCSharp( + [Description("C# source, 1 to 131072 characters.")] + string source, + [Description("Approved-root destination for the exported compiled assembly. It must not already exist unless overwrite is true.")] + string outputPath, + [Description("0 to 32 absolute local reference-assembly paths held against changes while Cheat Engine compiles.")] + string[]? referencePaths = null, + [Description("Optional absolute local core assembly path held against changes while Cheat Engine compiles.")] + string? coreAssembly = null, + [Description("Allow the approved-root outputPath to atomically replace an existing file.")] + bool overwrite = false, + CancellationToken cancellationToken = default) + { + _dispatch.Features.Require(McpFeature.TargetCodeExecution, CheatEngineToolNames.ExecCompileCSharp); + if (string.IsNullOrWhiteSpace(source) || source.Length > MaximumSourceLength) + { + throw CheatEngineToolException.InvalidArgument("source", $"must contain 1 to {MaximumSourceLength} characters."); + } + + string[] references = referencePaths ?? []; + if (references.Length > MaximumReferences) + { + throw CheatEngineToolException.LimitExceeded("referencePaths", $"contains more than {MaximumReferences} entries."); + } + + using HeldFiles held = HoldInputs(references, coreAssembly); + using McpFileWrite write = BeginWrite(outputPath, overwrite); + bool exportStarted = false; + try + { + return _dispatch.Run(CheatEngineToolNames.ExecCompileCSharp, token => + { + ExecCSharpCompilerOutput compiled = _dispatch.ExecuteLua(CheatEngineToolNames.ExecCompileCSharp, + ExecCSharpScripts.Compile, ExecCSharpJsonContext.Default.ExecCSharpCompilerOutput, token, source, + held.ReferencePaths, held.CoreAssemblyPath); + if (!compiled.CompilerAvailable) + { + throw CheatEngineToolException.Unsupported("Cheat Engine does not provide the compileCS C# compiler.", + CheatEngineToolNames.ExecCompileCSharp); + } + + if (compiled.AssemblyPath is null) + { + throw CompilerFailure(compiled); + } + + exportStarted = true; + return Export(compiled, write, token); + }, cancellationToken); + } + catch (Exception exception) when (!exportStarted) + { + if (write.TryAbort()) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + + throw PreExportCleanupFailure(exception); + } + } + + private HeldFiles HoldInputs(string[] references, string? coreAssembly) + { + List held = new(references.Length + (coreAssembly is null ? 0 : 1)); + try + { + foreach (string reference in references) + { + held.Add(_files.OpenRead(reference, CheatEngineToolNames.ExecCompileCSharp, 0, "referencePaths")); + } + + HeldFile? core = coreAssembly is null + ? null + : _files.OpenRead(coreAssembly, CheatEngineToolNames.ExecCompileCSharp, 0, "coreAssembly"); + return new HeldFiles(held, core); + } + catch + { + foreach (HeldFile file in held) + { + file.Dispose(); + } + + throw; + } + } + + private McpFileWrite BeginWrite(string outputPath, bool overwrite) + { + try + { + return _files.BeginWrite(outputPath, CheatEngineToolNames.ExecCompileCSharp, overwrite, "outputPath"); + } + catch (IOException exception) + { + throw new CheatEngineToolException(new ToolError(ToolErrorKind.InvalidState, + "The outputPath destination could not be reserved.", CheatEngineToolNames.ExecCompileCSharp, + ToolHostEffect.NotStarted, false, + "Check that outputPath is writable and does not already exist, then repeat the call."), exception); + } + } + + private ExecCSharpCompileResult Export(ExecCSharpCompilerOutput compiled, McpFileWrite write, + CancellationToken cancellationToken) + { + bool publishAttempted = false; + try + { + if (compiled.AssemblyPath!.Length > MaximumPathLength) + { + throw CheatEngineToolException.InvalidArgument("generatedAssemblyPath", + $"must contain an absolute path of at most {MaximumPathLength} characters."); + } + + using HeldFile generated = _files.OpenRead(compiled.AssemblyPath, CheatEngineToolNames.ExecCompileCSharp, + MaximumArtifactBytes, "generatedAssemblyPath"); + if (!generated.IsComplete) + { + throw CheatEngineToolException.LimitExceeded("generatedAssemblyPath", + $"exceeds the {MaximumArtifactBytes} byte export limit."); + } + + using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + Copy(generated, write.Stream, hash, cancellationToken); + string sha256 = Convert.ToHexString(hash.GetHashAndReset()); + publishAttempted = true; + write.Commit(); + return new ExecCSharpCompileResult(write.FullPath, generated.Length, sha256); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or CheatEngineToolException or OperationCanceledException) + { + bool cleanup = !publishAttempted && write.TryAbort(); + throw ExportFailure(exception, cleanup, publishAttempted); + } + } + + private static void Copy(HeldFile source, Stream destination, IncrementalHash hash, CancellationToken cancellationToken) + { + byte[] buffer = new byte[65_536]; + long offset = 0; + while (offset < source.Length) + { + cancellationToken.ThrowIfCancellationRequested(); + int read = source.ReadAt(buffer, offset); + if (read == 0) + { + throw new IOException("The generated assembly ended before its held length."); + } + + destination.Write(buffer, 0, read); + hash.AppendData(buffer, 0, read); + offset += read; + } + } + + private static CheatEngineToolException CompilerFailure(ExecCSharpCompilerOutput output) + { + ExecCSharpCompilerDiagnostic details = new(output.Diagnostic ?? "Cheat Engine returned no compiler diagnostic.", + output.DiagnosticTruncated); + JsonElement serialized = JsonSerializer.SerializeToElement(details, + ExecCSharpJsonContext.Default.ExecCSharpCompilerDiagnostic); + return new CheatEngineToolException(new ToolError(ToolErrorKind.HostRefused, + "Cheat Engine could not compile the C# source; inspect the bounded compiler diagnostic.", + CheatEngineToolNames.ExecCompileCSharp, ToolHostEffect.Unknown, false, + "Correct the C# source or references, then retry.", serialized)); + } + + private static CheatEngineToolException PreExportCleanupFailure(Exception exception) + { + CheatEngineToolException partial = CheatEngineToolException.PartialEffect( + "MCP did not start assembly export, but could not confirm removal of its protected temporary destination file.", + ToolHostEffect.CleanupUnconfirmed, new ExecCSharpExportFailure(false, false), + ExecCSharpJsonContext.Default.ExecCSharpExportFailure, + hint: "The destination was not published, but a protected .partial file may remain under outputPath's directory; inspect it before retrying."); + return new CheatEngineToolException(partial.Error, exception); + } + + private static CheatEngineToolException ExportFailure(Exception exception, bool cleanupConfirmed, + bool outputPublicationMayHaveCompleted) + { + ToolHostEffect effect = outputPublicationMayHaveCompleted || !cleanupConfirmed + ? ToolHostEffect.CleanupUnconfirmed + : ToolHostEffect.Started; + string message = exception is CheatEngineToolException { Error.Kind: ToolErrorKind.NotFound } + ? "Cheat Engine compiled the C# source, but its generated assembly was missing or expired before MCP could export it." + : "Cheat Engine compiled the C# source, but MCP could not export its generated assembly."; + string hint = outputPublicationMayHaveCompleted + ? "The destination publish may have completed; inspect outputPath before retrying the compilation." + : cleanupConfirmed + ? "The exported destination was not published; inspect the output path and repeat the compilation if needed." + : "The exported destination was not published, but MCP could not confirm removal of its protected temporary file; inspect the output path before retrying."; + CheatEngineToolException partial = CheatEngineToolException.PartialEffect(message, effect, + new ExecCSharpExportFailure(cleanupConfirmed, outputPublicationMayHaveCompleted), + ExecCSharpJsonContext.Default.ExecCSharpExportFailure, hint: hint); + return new CheatEngineToolException(partial.Error, exception); + } + + private sealed class HeldFiles : IDisposable + { + private readonly List _references; + private readonly HeldFile? _coreAssembly; + + internal HeldFiles(List references, HeldFile? coreAssembly) + { + _references = references; + _coreAssembly = coreAssembly; + ReferencePaths = references.Select(static reference => reference.FullPath).ToArray(); + CoreAssemblyPath = coreAssembly?.FullPath; + } + + internal string[] ReferencePaths + { + get; + } + + internal string? CoreAssemblyPath + { + get; + } + + public void Dispose() + { + _coreAssembly?.Dispose(); + foreach (HeldFile reference in _references) + { + reference.Dispose(); + } + } + } +} diff --git a/srcs/CheatEngine.Mcp.Tools/Exec/ExecToolsBuilderExtensions.cs b/srcs/CheatEngine.Mcp.Tools/Exec/ExecToolsBuilderExtensions.cs index 42e515f..c4f3668 100644 --- a/srcs/CheatEngine.Mcp.Tools/Exec/ExecToolsBuilderExtensions.cs +++ b/srcs/CheatEngine.Mcp.Tools/Exec/ExecToolsBuilderExtensions.cs @@ -1,6 +1,6 @@ namespace CheatEngine.Mcp.Tools.Exec; -/// The composition entry point of the exec tool domain (6 tools in the v2 catalog). +/// The composition entry point of the exec tool domain. public static class ExecToolsBuilderExtensions { extension(ICheatEngineMcpBuilder builder) @@ -14,7 +14,9 @@ public ICheatEngineMcpBuilder AddExecTools() ArgumentNullException.ThrowIfNull(builder); return builder .AddJsonTypeInfoResolver(ExecJsonContext.Default) - .AddToolType(); + .AddJsonTypeInfoResolver(ExecCSharpJsonContext.Default) + .AddToolType() + .AddToolType(); } } } diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessJsonContext.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessJsonContext.cs new file mode 100644 index 0000000..6dbae50 --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessJsonContext.cs @@ -0,0 +1,12 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CheatEngine.Mcp.Tools.Pointer; + +/// Source-generated metadata for supplied pointer-access analysis. +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(PointerAccessInfo))] +[JsonSerializable(typeof(PointerAccessStatus))] +[JsonSerializable(typeof(PointerAccessContextPhase))] +[JsonSerializable(typeof(IReadOnlyDictionary))] +internal sealed partial class PointerAccessJsonContext : JsonSerializerContext; diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessLuaScripts.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessLuaScripts.cs new file mode 100644 index 0000000..232b303 --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessLuaScripts.cs @@ -0,0 +1,92 @@ +namespace CheatEngine.Mcp.Tools.Pointer; + +/// Fixed Lua dispatch for supplied pointer-access facts. +internal static class PointerAccessLuaScripts +{ + internal static string GetAccessInfo => $$""" + {{PointerOperandLuaScripts.Evaluator}} + local function signedHex(value) + if value < 0 then return '-' .. string.format('%X', -value) end + return string.format('%X', value) + end + local function suppliedMap(pairs) + local result = {} + for _, pair in ipairs(pairs) do + local digits = pair[2]:gsub('^0[xX]', '') + result[pair[1]] = tonumber(digits, 16) + end + return result + end + local instruction = (a[1] .. ' ' .. a[2]):sub(1, 512) + local function refusal(status, message) + return {status = status, instruction = instruction, contextMayHaveChanged = a[10] == true, + uncertainty = message} + end + local opcode = a[1]:lower() + local bitAddressOpcodes = {bt = true, btc = true, btr = true, bts = true} + if bitAddressOpcodes[opcode] then + return refusal('unsupported', 'Bit-string memory operations can adjust the accessed address using a bit index ' .. + 'outside the memory operand; supplied bracket arithmetic is insufficient.') + end + local prefixes = {lock = true, rep = true, repe = true, repz = true, repne = true, repnz = true, + xacquire = true, xrelease = true, bnd = true, data16 = true, addr16 = true, addr32 = true} + if prefixes[opcode] then + return refusal('unsupported', 'Instruction prefixes in supplied text require opcode semantics that this analysis ' .. + 'does not evaluate.') + end + local registers, symbols = suppliedMap(a[7]), suppliedMap(a[8]) + local plan, kind, message = mcpOperandPlan(a[3], a[1], a[2], a[4], a[5], mcpOperandOverride(a[6]), + function(name) return symbols[name:upper()] end) + if plan == nil then + return refusal(kind == 'missing_facts' and 'missing_facts' or 'unsupported', message) + end + -- The shared capture evaluator accepts CE disassembly. Supplied facts additionally need a representable + -- base/index shape before returning pointer guidance; never silently discard extra register terms. + if #plan.terms > 2 then + return refusal('unsupported', 'The supplied operand has more than a base and an index register.') + end + local base, index, scale = nil, nil, nil + for _, term in ipairs(plan.terms) do + if opcode == 'pop' and (term.name == 'RSP' or term.name == 'ESP') then + return refusal('unsupported', 'POP computes a stack-relative destination after changing the stack pointer; ' .. + 'the supplied operand facts do not determine that intermediate context.') + end + if term.factor < 0 then + return refusal('unsupported', 'A subtracted register is not a supported x86/x64 address operand.') + end + if base == nil and term.factor == 1 then + base = term.name + elseif index == nil then + index, scale = term.name, term.factor + else + return refusal('unsupported', 'The supplied operand has more than one scaled index.') + end + end + local effective, missing = mcpOperandAddress(plan, registers) + if effective == nil then + return refusal('missing_facts', 'Missing captured register ' .. missing .. '.') + end + local mismatch = false + if a[9] ~= nil then + local digits = a[9]:gsub('^0[xX]', '') + mismatch = tonumber(digits, 16) ~= effective + end + local dynamic = index ~= nil + local structure = (not dynamic and base ~= nil) and (effective - plan.displacement) or nil + if structure ~= nil and plan.narrow then structure = structure & 0xFFFFFFFF end + local uncertainty = nil + if a[10] == true then + uncertainty = 'Post-execution registers may have changed before capture.' + elseif dynamic then + uncertainty = 'Indexed addressing is a dynamic array offset, not a stable pointer-chain offset.' + elseif mismatch then + uncertainty = 'The computed address differs from the supplied observed access address.' + end + return {status = 'supported', instruction = instruction, memoryOperand = plan.operand, + baseRegister = base, indexRegister = index, scale = scale, displacement = signedHex(plan.displacement), + effectiveAddress = string.format('%X', effective), + candidateStructureBase = structure and string.format('%X', structure) or nil, + nextPointerSearchValue = structure and string.format('%X', structure) or nil, dynamicOffset = dynamic, + observedAddressMismatch = mismatch, contextMayHaveChanged = a[10] == true, uncertainty = uncertainty} + """; +} diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessRecords.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessRecords.cs new file mode 100644 index 0000000..df0895c --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessRecords.cs @@ -0,0 +1,65 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +using CheatEngine.Mcp.Core.Contract; + +namespace CheatEngine.Mcp.Tools.Pointer; + +/// The point at which the supplied register context was observed. +[JsonConverter(typeof(ContractEnumConverter))] +public enum PointerAccessContextPhase +{ + /// The registers were captured before the instruction executed. + PreExecution, + + /// The registers were captured after a write or access event and may no longer describe the access. + PostExecution +} + +/// The confidence of supplied-facts access analysis. +[JsonConverter(typeof(ContractEnumConverter))] +public enum PointerAccessStatus +{ + /// The supplied facts describe one explicit memory operand. + Supported, + + /// A required supplied fact was absent. + MissingFacts, + + /// Two supplied facts disagree. + ContradictoryFacts, + + /// The operand form is not safely supported. + Unsupported +} + +/// Analysis of one supplied x86 or x64 instruction access. +public sealed record PointerAccessInfo( + [property: Description("Whether the supplied facts were supported, missing, contradictory, or unsupported.")] + PointerAccessStatus Status, + [property: Description("The supplied instruction text used as the authoritative representation.")] + string Instruction, + [property: Description("The evaluated memory operand, when supported.")] + string? MemoryOperand = null, + [property: Description("The canonical base register, when present.")] + string? BaseRegister = null, + [property: Description("The canonical index register, when present.")] + string? IndexRegister = null, + [property: Description("The index scale, when present.")] + int? Scale = null, + [property: Description("The signed displacement, uppercase hexadecimal.")] + string? Displacement = null, + [property: Description("The computed effective address, uppercase hexadecimal.")] + string? EffectiveAddress = null, + [property: Description("The candidate structure base before a stable displacement, uppercase hexadecimal.")] + string? CandidateStructureBase = null, + [property: Description("The value to search for in a pointer map: candidateStructureBase when stable.")] + string? NextPointerSearchValue = null, + [property: Description("Whether index addressing makes the displacement dynamic rather than a stable pointer-chain offset.")] + bool DynamicOffset = false, + [property: Description("Whether the supplied observed address differs from the computed effective address.")] + bool ObservedAddressMismatch = false, + [property: Description("Whether post-execution registers may have changed before capture.")] + bool ContextMayHaveChanged = false, + [property: Description("A bounded explanation of missing, contradictory, or unsupported facts.")] + string? Uncertainty = null); diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessTools.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessTools.cs new file mode 100644 index 0000000..47cf36f --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerAccessTools.cs @@ -0,0 +1,187 @@ +using System.ComponentModel; + +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Values; + +using ModelContextProtocol.Server; + +namespace CheatEngine.Mcp.Tools.Pointer; + +/// Analyzes caller-supplied instruction and capture facts without reading the selected target. +[McpServerToolType] +public sealed class PointerAccessTools +{ + private const int MaximumInstructionTextLength = 512; + private const int MaximumMapEntries = 64; + private readonly ToolDispatch _dispatch; + + /// Creates the read-only supplied-facts analyser. + public PointerAccessTools(ToolDispatch dispatch) + { + ArgumentNullException.ThrowIfNull(dispatch); + _dispatch = dispatch; + } + + /// Evaluates one explicit memory operand from supplied disassembly and register facts. + [McpServerTool(Name = CheatEngineToolNames.PointerGetAccessInfo, Title = "Analyze pointer access", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [Description("Analyzes one supplied x86 or x64 memory operand and captured registers without resolving live symbols or reading target memory. instructionText is authoritative disassembly; instructionBytes checks length and address-size prefixes, but is not decoded or compared with the text. Indexed operands report a dynamic offset and are not stable pointer-chain claims.")] + public PointerAccessInfo GetAccessInfo( + [Description("The authoritative disassembly text, such as mov eax,[rax+10].")] + string instructionText, + [Description("The instruction address as uppercase or 0x-prefixed hexadecimal.")] + string instructionAddress, + [Description("The supplied instruction length in bytes, 1 through 15.")] + int instructionLength, + [Description("x86 or x64.")] + string architecture, + [Description("Captured register names and hexadecimal values. A 32-bit alias cannot supply unknown upper bits for a 64-bit operand.")] + IReadOnlyDictionary registers, + [Description("Optional instruction bytes. Their count must equal instructionLength; they are not decoded by this supplied-text mode.")] + string? instructionBytes = null, + [Description("An optional observed accessed address for mismatch reporting.")] + string? observedAccessAddress = null, + [Description("Whether registers are from before execution or from a post-execution access/write event.")] + PointerAccessContextPhase contextPhase = PointerAccessContextPhase.PreExecution, + [Description("Optional bounded caller-supplied symbol or module-base map. No live symbol resolution occurs.")] + IReadOnlyDictionary? symbols = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(instructionText) || instructionText.Length > MaximumInstructionTextLength) + { + throw CheatEngineToolException.InvalidArgument("instructionText", "must be 1 to 512 characters."); + } + + if (!HexParse.TryAddress(instructionAddress, out ulong address)) + { + throw CheatEngineToolException.InvalidArgument("instructionAddress", "must be a hexadecimal address."); + } + + PointerSupport.Range(instructionLength, 1, 15, "instructionLength"); + if (architecture is null) + { + throw CheatEngineToolException.InvalidArgument("architecture", "must be x86 or x64."); + } + + bool wide = architecture.Equals("x64", StringComparison.OrdinalIgnoreCase); + if (!wide && !architecture.Equals("x86", StringComparison.OrdinalIgnoreCase)) + { + throw CheatEngineToolException.InvalidArgument("architecture", "must be x86 or x64."); + } + if (!wide && address > uint.MaxValue) + { + throw CheatEngineToolException.InvalidArgument("instructionAddress", "must fit the x86 address width."); + } + if (!Enum.IsDefined(contextPhase)) + { + throw CheatEngineToolException.InvalidArgument("contextPhase", "must be pre_execution or post_execution."); + } + if (observedAccessAddress is not null) + { + if (!HexParse.TryAddress(observedAccessAddress, out ulong observed)) + { + throw CheatEngineToolException.InvalidArgument("observedAccessAddress", "must be a hexadecimal address."); + } + observedAccessAddress = HexFormat.Address(observed); + } + + byte[]? bytes = instructionBytes is null ? null : HexParse.Bytes(instructionBytes, "instructionBytes", 15); + if (bytes is not null && bytes.Length != instructionLength) + { + return new PointerAccessInfo(PointerAccessStatus.ContradictoryFacts, instructionText.Trim(), + Uncertainty: "instructionBytes length differs from instructionLength."); + } + + ArgumentNullException.ThrowIfNull(registers); + string[][] registerPairs = RegisterPairs(registers, wide); + string[][] symbolPairs = symbols is null ? [] : Pairs(symbols, "symbols"); + string text = instructionText.Trim(); + string opcode; + string parameters; + int split = text.IndexOfAny([' ', '\t']); + if (split < 0) + { + opcode = text; + parameters = string.Empty; + } + else + { + opcode = text[..split].Trim(); + parameters = text[(split + 1)..].Trim(); + } + + return _dispatch.RunLua(CheatEngineToolNames.PointerGetAccessInfo, PointerAccessLuaScripts.GetAccessInfo, + PointerAccessJsonContext.Default.PointerAccessInfo, cancellationToken, opcode, parameters, address, + instructionLength, wide, bytes ?? [], registerPairs, symbolPairs, observedAccessAddress, + contextPhase == PointerAccessContextPhase.PostExecution); + } + + private static string[][] Pairs(IReadOnlyDictionary source, string parameter) + { + if (source.Count > MaximumMapEntries) + { + throw CheatEngineToolException.LimitExceeded(parameter, "accepts at most 64 entries."); + } + + string[][] result = new string[source.Count][]; + HashSet names = new(StringComparer.OrdinalIgnoreCase); + int index = 0; + foreach ((string name, string value) in source) + { + if (string.IsNullOrWhiteSpace(name) || name.Length > 64 || !HexParse.TryAddress(value, out ulong parsed)) + { + throw CheatEngineToolException.InvalidArgument(parameter, + "entries need a non-empty name and a hexadecimal address or register value."); + } + if (!names.Add(name)) + { + throw CheatEngineToolException.InvalidArgument(parameter, "contains duplicate names ignoring case."); + } + + result[index++] = [name.ToUpperInvariant(), HexFormat.Address(parsed)]; + } + + return result; + } + + private static string[][] RegisterPairs(IReadOnlyDictionary source, bool wide) + { + string[][] pairs = Pairs(source, "registers"); + Dictionary values = pairs.ToDictionary(static pair => pair[0], static pair => + { + _ = HexParse.TryAddress(pair[1], out ulong value); + return value; + }, StringComparer.Ordinal); + foreach ((string name, ulong value) in values) + { + bool narrow = !wide || name is "EAX" or "EBX" or "ECX" or "EDX" or "ESI" or "EDI" or "EBP" or "ESP" or "EIP" || + name is "R8D" or "R9D" or "R10D" or "R11D" or "R12D" or "R13D" or "R14D" or "R15D"; + if (narrow && value > uint.MaxValue) + { + throw CheatEngineToolException.InvalidArgument("registers", $"{name} must fit its 32-bit register width."); + } + + string? alias = name switch + { + "RAX" => "EAX", + "RBX" => "EBX", + "RCX" => "ECX", + "RDX" => "EDX", + "RSI" => "ESI", + "RDI" => "EDI", + "RBP" => "EBP", + "RSP" => "ESP", + "RIP" => "EIP", + "R8" or "R9" or "R10" or "R11" or "R12" or "R13" or "R14" or "R15" => name + "D", + _ => null + }; + if (alias is not null && values.TryGetValue(alias, out ulong low) && (uint) value != low) + { + throw CheatEngineToolException.InvalidArgument("registers", + $"contains conflicting aliases for {name}."); + } + } + return pairs; + } +} diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchJsonContext.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchJsonContext.cs new file mode 100644 index 0000000..17ba128 --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchJsonContext.cs @@ -0,0 +1,12 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CheatEngine.Mcp.Tools.Pointer; + +/// Source-generated JSON metadata for the batch pointer-chain tool. +[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +[JsonSerializable(typeof(PointerChainBatchResult))] +[JsonSerializable(typeof(PointerChainCandidate[]))] +[JsonSerializable(typeof(string))] +[JsonSerializable(typeof(bool))] +public sealed partial class PointerBatchJsonContext : JsonSerializerContext; diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchRecords.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchRecords.cs new file mode 100644 index 0000000..5b0249c --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchRecords.cs @@ -0,0 +1,99 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; + +using CheatEngine.Mcp.Core.Contract; + +namespace CheatEngine.Mcp.Tools.Pointer; + +/// A caller-supplied pointer chain to validate. +public sealed record PointerChainCandidate( + [property: Description("A stable caller-chosen identifier, unique within this request.")] + string Id, + [property: Description("The address expression that holds the first pointer.")] + string Base, + [property: Description("One to 64 signed hexadecimal offsets in dereference order.")] + string[] Offsets); + +/// Whether the chain itself resolved. +[JsonConverter(typeof(ContractEnumConverter))] +public enum PointerChainResolutionStatus +{ + Resolved, + Unreadable, + Error +} + +/// Whether a resolved final address matched the requested target. +[JsonConverter(typeof(ContractEnumConverter))] +public enum PointerChainComparisonStatus +{ + NotRequested, + NotResolved, + NotAttempted, + Match, + Miss +} + +/// Whether the optional final value was read. +[JsonConverter(typeof(ContractEnumConverter))] +public enum PointerChainValueStatus +{ + NotRequested, + NotResolved, + NotAttempted, + Read, + Failed +} + +/// The result for one submitted candidate. +public sealed record PointerChainCandidateResult( + [property: Description("The stable identifier supplied by the caller.")] + string Id, + [property: Description("Whether every pointer hop resolved.")] + PointerChainResolutionStatus ChainStatus, + [property: Description("The final address when the chain resolved, uppercase hexadecimal.")] + string? Address, + [property: Description("The zero-based hop that could not be read, when chainStatus is unreadable.")] + int? FailedHop, + [property: Description("The address that could not be read, when chainStatus is unreadable.")] + string? FailedReadAt, + [property: Description("The per-candidate chain error when chainStatus is error.")] + ToolError? ChainError, + [property: Description("Whether the resolved address matched target.")] + PointerChainComparisonStatus ComparisonStatus, + [property: Description("Whether the optional final value was read.")] + PointerChainValueStatus ValueStatus, + [property: Description("The final value as text when valueStatus is read.")] + string? Value, + [property: Description("The final-value read error when valueStatus is failed.")] + ToolError? ValueError); + +/// Counts covering submitted candidates, including ones hidden by matchesOnly. +public sealed record PointerChainBatchSummary( + [property: Description("How many candidates were submitted.")] + int Submitted, + [property: Description("How many candidates were validated before this response ended.")] + int Processed, + [property: Description("How many chains resolved.")] + int Resolved, + [property: Description("How many chains had an unreadable hop.")] + int Unreadable, + [property: Description("How many chains ended with another per-candidate error.")] + int Errors, + [property: Description("How many resolved chains matched target.")] + int Matches, + [property: Description("How many resolved chains missed target.")] + int Misses, + [property: Description("How many optional final values were read.")] + int ValuesRead, + [property: Description("How many optional final values could not be read.")] + int ValueErrors, + [property: Description("Whether caller cancellation stopped validation before every candidate was processed.")] + bool Cancelled); + +/// The bounded validation result for supplied pointer chains. +public sealed record PointerChainBatchResult( + [property: Description("Candidate outcomes, or only matches when matchesOnly was requested.")] + IReadOnlyList Candidates, + [property: Description("Counts over all processed candidates, including hidden errors and misses.")] + PointerChainBatchSummary Summary); diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchTools.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchTools.cs new file mode 100644 index 0000000..ea94105 --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerBatchTools.cs @@ -0,0 +1,402 @@ +using System.ComponentModel; + +using CheatEngine.Client; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Values; +using CheatEngine.SDK.Engine.Values; + +using ModelContextProtocol.Server; + +namespace CheatEngine.Mcp.Tools.Pointer; + +/// Validates a bounded set of supplied pointer chains without retaining a scan. +[McpServerToolType] +public sealed class PointerBatchTools +{ + /// The largest caller-supplied batch. + internal const int MaximumCandidates = 128; + + /// The candidates followed by one main-thread dispatch. + internal const int CandidatesPerDispatch = 32; + + /// The pointer and final-value reads admitted to one main-thread dispatch. + internal const int MaximumReadsPerDispatch = 128; + + /// The largest combined number of pointer dereferences and final-value reads in one request. + internal const int MaximumTotalReads = 4_096; + + /// The largest combined maximum byte count of requested final values. + internal const int MaximumFinalValueBytes = 65_536; + + private readonly ToolDispatch _dispatch; + + /// Creates the tool; no target state is read until validation starts. + public PointerBatchTools(ToolDispatch dispatch) + { + ArgumentNullException.ThrowIfNull(dispatch); + _dispatch = dispatch; + } + + /// Follows supplied pointer chains in bounded dispatch slices. + [McpServerTool(Name = CheatEngineToolNames.PointerReadChains, Title = "Read pointer chains", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [Description( + "Validates up to 128 supplied pointer chains without creating a scan. Each chain dereferences then adds its signed hexadecimal offset. " + + "Module expressions are resolved afresh in every bounded slice; absolute roots remain absolute after a target restart. " + + "An unreadable hop, target comparison, and optional final-value read are reported independently per candidate.")] + public PointerChainBatchResult ReadChains( + [Description("One to 128 candidates with a unique id, base expression and one to 64 signed hexadecimal offsets.")] + PointerChainCandidate[] candidates, + [Description("Optionally compare each resolved final address with this current-target expression.")] + string? target = null, + [Description("The optional type of the value read after comparison.")] + McpValueType? valueType = null, + [Description("For bytes, the required byte count; for string and wstring, the maximum length.")] + int? length = null, + [Description("Return only matching candidate entries while summary counts still include misses and errors.")] + bool matchesOnly = false, + CancellationToken cancellationToken = default) + { + PreparedCandidate[] prepared = Prepare(candidates); + string? expectedExpression = target is null ? null : PointerSupport.Expression(target, "target"); + if (matchesOnly && expectedExpression is null) + { + throw CheatEngineToolException.InvalidArgument("matchesOnly", "requires target."); + } + + ValidateValue(valueType, length, prepared.Length); + ValidateReadCount(prepared, valueType); + (ProcessSnapshot process, int width) = _dispatch.Run(CheatEngineToolNames.PointerReadChains, + token => + { + ICheatEngineClient client = _dispatch.Client; + ProcessSnapshot snapshot = client.Processes.GetCurrentProcess(token); + return (snapshot, PointerSupport.Width(snapshot)); + }, cancellationToken); + + List all = new(prepared.Length); + bool cancelled = false; + for (int first = 0; first < prepared.Length;) + { + if (cancellationToken.IsCancellationRequested) + { + cancelled = true; + break; + } + + int start = first; + int end = SliceEnd(prepared, start, valueType); + try + { + SliceResult slice = _dispatch.Run(CheatEngineToolNames.PointerReadChains, token => + { + ICheatEngineClient client = _dispatch.Client; + EnsureSameTarget(client, process, token); + ulong? expected = expectedExpression is null ? null : PointerTargets.Check( + PointerSupport.Resolve(client, expectedExpression, token), width, "target"); + EnsureSameTarget(client, process, token); + List outcomes = new(end - start); + for (int index = start; index < end; index++) + { + if (token.IsCancellationRequested) + { + return new SliceResult([.. outcomes], true); + } + + try + { + EnsureSameTarget(client, process, token); + outcomes.Add(Follow(client, process, prepared[index], width, expected, valueType, length, token)); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + return new SliceResult([.. outcomes], true); + } + } + + return new SliceResult([.. outcomes], false); + }, cancellationToken); + all.AddRange(slice.Candidates); + if (slice.Cancelled) + { + cancelled = true; + break; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + cancelled = true; + break; + } + + first = end; + } + + PointerChainBatchSummary summary = Summarize(prepared.Length, all, cancelled); + IReadOnlyList listed = matchesOnly + ? [.. all.Where(static item => item.ComparisonStatus is PointerChainComparisonStatus.Match)] + : all; + return new PointerChainBatchResult(listed, summary); + } + + private static PreparedCandidate[] Prepare(PointerChainCandidate[]? candidates) + { + if (candidates is null || candidates.Length == 0) + { + throw CheatEngineToolException.InvalidArgument("candidates", "must contain at least one candidate."); + } + + PointerSupport.Range(candidates.Length, 1, MaximumCandidates, "candidates"); + HashSet ids = new(StringComparer.Ordinal); + PreparedCandidate[] prepared = new PreparedCandidate[candidates.Length]; + int dereferences = 0; + for (int index = 0; index < candidates.Length; index++) + { + PointerChainCandidate candidate = candidates[index] ?? throw CheatEngineToolException.InvalidArgument( + $"candidates[{index}]", "must not be null."); + string id = PointerSupport.Name(candidate.Id, $"candidates[{index}].id"); + if (!ids.Add(id)) + { + throw CheatEngineToolException.InvalidArgument($"candidates[{index}].id", "must be unique within candidates."); + } + + string root = PointerSupport.Expression(candidate.Base, $"candidates[{index}].base"); + if (candidate.Offsets is null || candidate.Offsets.Length == 0) + { + throw CheatEngineToolException.InvalidArgument($"candidates[{index}].offsets", + $"must contain 1 to {PointerChainTools.MaximumOffsets} offsets; a chain dereferences at least once."); + } + dereferences = checked(dereferences + candidate.Offsets.Length); + if (dereferences > MaximumTotalReads) + { + throw CheatEngineToolException.LimitExceeded("candidates", + $"must contain no more than {MaximumTotalReads} total pointer dereferences."); + } + + prepared[index] = new PreparedCandidate(id, root, $"candidates[{index}].base", + HexParse.Offsets(candidate.Offsets, $"candidates[{index}].offsets", PointerChainTools.MaximumOffsets)); + } + + return prepared; + } + + private static void ValidateValue(McpValueType? valueType, int? length, int candidateCount) + { + if (valueType is { } type && !Enum.IsDefined(type)) + { + throw CheatEngineToolException.InvalidArgument("valueType", "must name a supported value type."); + } + + if (length is not null && valueType is null) + { + throw CheatEngineToolException.InvalidArgument("length", "requires valueType."); + } + + if (length is { } requested) + { + PointerSupport.Range(requested, 1, McpValueCodec.MaxLength, "length"); + } + else if (valueType is McpValueType.Bytes) + { + throw CheatEngineToolException.InvalidArgument("length", "is required for the bytes value type."); + } + + if (valueType is not { } requestedType) + { + return; + } + + long total = (long) candidateCount * ValueByteBound(requestedType, length); + if (total > MaximumFinalValueBytes) + { + throw CheatEngineToolException.LimitExceeded("length", + $"the combined final-value read limit is {MaximumFinalValueBytes} bytes."); + } + } + + private static int ValueByteBound(McpValueType type, int? length) + { + return type switch + { + McpValueType.Bytes => length!.Value, + McpValueType.String => length ?? McpValueCodec.DefaultStringLength, + McpValueType.WString => checked((length ?? McpValueCodec.DefaultStringLength) * 2), + _ => 8 + }; + } + + private static void ValidateReadCount(IReadOnlyList candidates, McpValueType? valueType) + { + int reads = candidates.Sum(static candidate => candidate.Offsets.Length); + if (valueType is not null) + { + reads = checked(reads + candidates.Count); + } + + if (reads > MaximumTotalReads) + { + throw CheatEngineToolException.LimitExceeded("candidates", + $"must contain no more than {MaximumTotalReads} total pointer and final-value reads."); + } + } + + private static int SliceEnd(IReadOnlyList candidates, int start, McpValueType? valueType) + { + int reads = 0; + int end = start; + while (end < candidates.Count && end - start < CandidatesPerDispatch) + { + int candidateReads = candidates[end].Offsets.Length + (valueType is null ? 0 : 1); + if (end > start && reads + candidateReads > MaximumReadsPerDispatch) + { + break; + } + + reads += candidateReads; + end++; + } + + return end; + } + + private static void EnsureSameTarget(ICheatEngineClient client, ProcessSnapshot expected, CancellationToken token) + { + token.ThrowIfCancellationRequested(); + ProcessSnapshot current = client.Processes.GetCurrentProcess(token); + if (current.Id != expected.Id || current.SelectionEpoch != expected.SelectionEpoch) + { + throw PointerSupport.TargetChanged( + "The selected process changed during pointer-chain validation; no mixed-target results were returned."); + } + } + + private static PointerChainCandidateResult Follow(ICheatEngineClient client, ProcessSnapshot process, + PreparedCandidate candidate, int width, + ulong? expected, McpValueType? valueType, int? length, CancellationToken token) + { + try + { + EnsureSameTarget(client, process, token); + ulong current = PointerTargets.Check(PointerSupport.Resolve(client, candidate.Root, token), width, + candidate.BaseParameter); + EnsureSameTarget(client, process, token); + for (int index = 0; index < candidate.Offsets.Length; index++) + { + EnsureSameTarget(client, process, token); + if (!client.Memory.TryReadPrimitive(new Address(current), out Address pointer, + out CheatEngineFailure failure, token)) + { + EnsureSameTarget(client, process, token); + CheatEngineToolException mapped = CheatEngineToolException.FromFailure(failure, + client.Stopping.IsCancellationRequested); + if (mapped.Error.Kind is ToolErrorKind.TargetChanged) + { + throw mapped; + } + + return failure.Kind is CheatEngineFailureKind.MemoryReadFailed + ? Unreadable(candidate.Id, index, current, expected is not null, valueType is not null) + : Error(candidate.Id, mapped.Error, expected is not null, valueType is not null); + } + EnsureSameTarget(client, process, token); + + if (!PointerMap.TryAdd(pointer.ToUInt64(), candidate.Offsets[index], width, out current)) + { + return Error(candidate.Id, CheatEngineToolException.InvalidArgument("offsets", + "pointer plus offset leaves the target address space.").Error, expected is not null, valueType is not null); + } + } + + PointerChainComparisonStatus comparison = expected is null + ? PointerChainComparisonStatus.NotRequested + : current == expected.Value ? PointerChainComparisonStatus.Match : PointerChainComparisonStatus.Miss; + if (valueType is not { } type) + { + return new PointerChainCandidateResult(candidate.Id, PointerChainResolutionStatus.Resolved, + HexFormat.Address(current), null, null, null, comparison, PointerChainValueStatus.NotRequested, null, null); + } + + try + { + EnsureSameTarget(client, process, token); + string value = McpValueCodec.Read(client, new Address(current), type, length, token); + EnsureSameTarget(client, process, token); + return new PointerChainCandidateResult(candidate.Id, PointerChainResolutionStatus.Resolved, + HexFormat.Address(current), null, null, null, comparison, PointerChainValueStatus.Read, + value, null); + } + catch (CheatEngineClientException exception) + { + CheatEngineToolException mapped = CheatEngineToolException.FromFailure(exception.Failure, + client.Stopping.IsCancellationRequested); + if (mapped.Error.Kind is ToolErrorKind.TargetChanged) + { + throw mapped; + } + + return new PointerChainCandidateResult(candidate.Id, PointerChainResolutionStatus.Resolved, + HexFormat.Address(current), null, null, null, comparison, PointerChainValueStatus.Failed, null, + mapped.Error); + } + catch (CheatEngineToolException exception) when (exception.Error.Kind is not ToolErrorKind.TargetChanged) + { + return new PointerChainCandidateResult(candidate.Id, PointerChainResolutionStatus.Resolved, + HexFormat.Address(current), null, null, null, comparison, PointerChainValueStatus.Failed, null, + exception.Error); + } + } + catch (CheatEngineToolException exception) when (exception.Error.Kind is not ToolErrorKind.TargetChanged) + { + return Error(candidate.Id, exception.Error, expected is not null, valueType is not null); + } + catch (CheatEngineClientException exception) + { + CheatEngineToolException mapped = CheatEngineToolException.FromFailure(exception.Failure, + client.Stopping.IsCancellationRequested); + if (mapped.Error.Kind is ToolErrorKind.TargetChanged) + { + throw mapped; + } + + return Error(candidate.Id, mapped.Error, + expected is not null, valueType is not null); + } + } + + private static PointerChainCandidateResult Unreadable(string id, int hop, ulong readAt, bool comparisonRequested, + bool valueRequested) + { + return new PointerChainCandidateResult(id, PointerChainResolutionStatus.Unreadable, null, hop, + HexFormat.Address(readAt), null, comparisonRequested ? PointerChainComparisonStatus.NotResolved : PointerChainComparisonStatus.NotRequested, + valueRequested ? PointerChainValueStatus.NotAttempted : PointerChainValueStatus.NotRequested, null, null); + } + + private static PointerChainCandidateResult Error(string id, ToolError error, bool comparisonRequested, + bool valueRequested) + { + return new PointerChainCandidateResult(id, PointerChainResolutionStatus.Error, null, null, null, error, + comparisonRequested ? PointerChainComparisonStatus.NotResolved : PointerChainComparisonStatus.NotRequested, + valueRequested ? PointerChainValueStatus.NotAttempted : PointerChainValueStatus.NotRequested, null, null); + } + + private static PointerChainBatchSummary Summarize(int submitted, IEnumerable candidates, + bool cancelled) + { + PointerChainCandidateResult[] items = [.. candidates]; + return new PointerChainBatchSummary(submitted, items.Length, + items.Count(static item => item.ChainStatus is PointerChainResolutionStatus.Resolved), + items.Count(static item => item.ChainStatus is PointerChainResolutionStatus.Unreadable), + items.Count(static item => item.ChainStatus is PointerChainResolutionStatus.Error), + items.Count(static item => item.ComparisonStatus is PointerChainComparisonStatus.Match), + items.Count(static item => item.ComparisonStatus is PointerChainComparisonStatus.Miss), + items.Count(static item => item.ValueStatus is PointerChainValueStatus.Read), + items.Count(static item => item.ValueStatus is PointerChainValueStatus.Failed), cancelled); + } + + private sealed record PreparedCandidate(string Id, string Root, string BaseParameter, long[] Offsets); + + private sealed record SliceResult(PointerChainCandidateResult[] Candidates, bool Cancelled); +} diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerOperandLuaScripts.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerOperandLuaScripts.cs new file mode 100644 index 0000000..c3d191e --- /dev/null +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerOperandLuaScripts.cs @@ -0,0 +1,152 @@ +namespace CheatEngine.Mcp.Tools.Pointer; + +/// Shared fixed Lua evaluator for captured and supplied x86/x64 memory operands. +internal static class PointerOperandLuaScripts +{ + internal const string Evaluator = """ + -- Effective-address grouping parses the one memory operand of the instruction at the execute breakpoint once, + -- from Cheat Engine's own disassembly: a created disassembler prints hexadecimal instead of symbols and + -- resolves RIP-relative operands to absolute addresses. Displacements and symbols fold into one constant; + -- each hit adds the scaled registers that Cheat Engine sets as globals before the instruction runs. + local operandSizes = {byte = 1, word = 2, dword = 4, fword = 6, qword = 8, tword = 10, dqword = 16, + xmmword = 16, ymmword = 32, zmmword = 64} + local function mcpOperandRegisters(wide) + local registers = {} + if not wide then + for _, name in ipairs({'EAX','EBX','ECX','EDX','ESI','EDI','EBP','ESP'}) do + registers[name] = {name, true} + end + return registers + end + for _, name in ipairs({'RAX','RBX','RCX','RDX','RSI','RDI','RBP','RSP'}) do + registers[name] = {name, false} + registers['E' .. name:sub(2)] = {name, true} + end + for index = 8, 15 do + registers['R' .. index] = {'R' .. index, false} + registers['R' .. index .. 'D'] = {'R' .. index, true} + end + return registers + end + -- Cheat Engine keeps the legacy prefixes first in the instruction bytes, before any REX or VEX prefix. Its + -- 64-bit operand text names 64-bit registers even under an address-size override (67), which truncates the + -- address to 32 bits, so only the bytes reveal the override. + local legacyPrefixes = {[0xF0] = true, [0xF2] = true, [0xF3] = true, [0x2E] = true, [0x36] = true, + [0x3E] = true, [0x26] = true, [0x64] = true, [0x65] = true, [0x66] = true, [0x67] = true} + local function mcpOperandOverride(bytes) + if type(bytes) ~= 'table' then return false end + for index = 1, #bytes do + if not legacyPrefixes[bytes[index]] then return false end + if bytes[index] == 0x67 then return true end + end + return false + end + -- Returns the operand plan, or nil with an error kind and message. + local function mcpOperandPlan(address, opcode, parameters, length, wide, override, resolve) + local name = type(opcode) == 'string' and opcode:lower() or '' + local text = type(parameters) == 'string' and parameters or '' + if name == '' or name == '??' then + return nil, 'invalid_argument', 'Cheat Engine could not decode an instruction at the capture address.' + end + local instruction = (name .. ' ' .. text):sub(1, 256) + if name == 'lea' or name == 'nop' then + return nil, 'invalid_argument', instruction .. ' only computes an address; it does not access memory.' + end + if override and not wide then + return nil, 'unsupported', instruction .. ' uses a 16-bit address-size override (67 prefix), whose ' .. + 'memory operand Cheat Engine does not disassemble.' + end + local open = text:find('[', 1, true) + if open == nil then + return nil, 'invalid_argument', instruction .. ' has no explicit [...] memory operand.' + end + local close = text:find(']', open + 1, true) + if close == nil or text:find('[', close + 1, true) ~= nil then + return nil, 'unsupported', instruction .. ' does not have exactly one memory operand.' + end + local lead = text:sub(1, open - 1):match('([^,]*)$'):lower() + local segment = lead:match('([fg]s)%s*:%s*$') + if segment ~= nil then + return nil, 'unsupported', instruction .. ' is relative to the ' .. segment .. + ' segment base, which a breakpoint context does not expose.' + end + local keyword = lead:match('(%a+)%s+ptr') + local plan = {constant = 0, displacement = 0, terms = {}, narrow = not wide or override, operand = text:sub(open, close), + size = keyword and operandSizes[keyword] or nil} + local registers = mcpOperandRegisters(wide) + local malformed = instruction .. ' has a memory operand that MCP cannot evaluate.' + local expression = text:sub(open + 1, close - 1) + local cursor, count = 1, 0 + while cursor <= #expression do + local signs = expression:match('^[%s%+%-]*', cursor) + cursor = cursor + #signs + count = count + 1 + if cursor > #expression or count > 8 then return nil, 'unsupported', malformed end + -- Cheat Engine prints a negative 8-bit displacement after a SIB byte as '-' and a signed number, such + -- as --80, so any minus sign makes the term negative. + local sign = signs:find('-', 1, true) ~= nil and -1 or 1 + local term, quoted = nil, false + if expression:sub(cursor, cursor) == '"' then + local finish = expression:find('"', cursor + 1, true) + if finish == nil then return nil, 'unsupported', malformed end + term, quoted = expression:sub(cursor + 1, finish - 1), true + cursor = finish + 1 + else + term = expression:match('^[^%+%-]*', cursor) + cursor = cursor + #term + term = term:match('^%s*(.-)%s*$') + end + local base, scale = term, 1 + if not quoted then + local scaled, factor = term:match('^([%w_]+)%s*%*%s*(%d+)$') + if scaled ~= nil then base, scale = scaled, tonumber(factor) end + end + local upper = base:upper() + local register = not quoted and registers[upper] or nil + if register ~= nil then + if scale ~= 1 and scale ~= 2 and scale ~= 4 and scale ~= 8 then + return nil, 'unsupported', malformed + end + plan.terms[#plan.terms + 1] = {name = register[1], narrow = register[2], factor = sign * scale} + plan.narrow = plan.narrow or register[2] + elseif not quoted and upper:match('^[XYZ]MM%d+$') then + return nil, 'unsupported', instruction .. ' uses a vector index (VSIB) and accesses several addresses.' + elseif scale ~= 1 or term == '' or term:find('*', 1, true) ~= nil then + return nil, 'unsupported', malformed + elseif not quoted and (upper == 'RIP' or upper == 'EIP') then + plan.constant = plan.constant + sign * (address + length) + plan.narrow = plan.narrow or upper == 'EIP' + else + local value = nil + if not quoted and #term <= 16 and term:match('^%x+$') then + value = tonumber(term, 16) + plan.displacement = plan.displacement + sign * value + else + value = resolve(term) + end + if math.type(value) ~= 'integer' then + return nil, 'missing_facts', instruction .. ' names ' .. term:sub(1, 64) .. + ', which Cheat Engine could not resolve.' + end + plan.constant = plan.constant + sign * value + end + end + if count == 0 then return nil, 'unsupported', malformed end + return plan + end + local function mcpOperandAddress(plan, registers) + local value = plan.constant + for _, term in ipairs(plan.terms) do + local register = registers[term.name] + if math.type(register) ~= 'integer' and plan.narrow then + local alias = term.name:match('^R%d+$') and (term.name .. 'D') or ('E' .. term.name:sub(2)) + register = registers[alias] + end + if math.type(register) ~= 'integer' then return nil, term.name end + value = value + register * term.factor + end + if plan.narrow then value = value & 0xFFFFFFFF end + return value + end + """; +} diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerToolsBuilderExtensions.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerToolsBuilderExtensions.cs index 6fd5f6c..fdff3e8 100644 --- a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerToolsBuilderExtensions.cs +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerToolsBuilderExtensions.cs @@ -2,7 +2,7 @@ namespace CheatEngine.Mcp.Tools.Pointer; -/// The composition entry point of the pointer tool domain (14 tools in the v2 catalog). +/// The composition entry point of the pointer tool domain. public static class PointerToolsBuilderExtensions { extension(ICheatEngineMcpBuilder builder) @@ -22,7 +22,11 @@ public ICheatEngineMcpBuilder AddPointerTools() return builder .AddJsonTypeInfoResolver(PointerJsonContext.Default) + .AddJsonTypeInfoResolver(PointerBatchJsonContext.Default) + .AddJsonTypeInfoResolver(PointerAccessJsonContext.Default) .AddToolType() + .AddToolType() + .AddToolType() .AddToolType() .AddToolType() .AddToolType() diff --git a/srcs/CheatEngine.Mcp.Tools/README.md b/srcs/CheatEngine.Mcp.Tools/README.md index 03c5b0c..af373e6 100644 --- a/srcs/CheatEngine.Mcp.Tools/README.md +++ b/srcs/CheatEngine.Mcp.Tools/README.md @@ -40,7 +40,7 @@ preserve the complete catalog and its ordering. ## Tool domains -The current v2 catalog contains 190 backend tools in 21 domains. +The current v2 catalog contains 193 backend tools in 21 domains. | Folder and registration | Tools | Scope | | --- | ---: | --- | @@ -49,7 +49,7 @@ The current v2 catalog contains 190 backend tools in 21 domains. | `Memory/` - `AddMemoryTools()` | 19 | Reads, writes, protection, allocations, files, snapshots, and samples. | | `Scan/` - `AddScanTools()` | 8 | Main value-scanner lifecycle, criteria, results, and named scanners. | | `Aob/` - `AddAobTools()` | 3 | Array-of-bytes search and signature generation. | -| `Pointer/` - `AddPointerTools()` | 14 | Pointer chains, maps, references, scans, and persisted scan files. | +| `Pointer/` - `AddPointerTools()` | 16 | Supplied access facts, batch chains, maps, references, scans, and persisted scan files. | | `Modules/` - `AddModuleTools()` | 5 | Module inventory, PE exports and imports, and patch inspection. | | `Symbol/` - `AddSymbolTools()` | 10 | Symbol resolution, search, sources, modules, and registrations. | | `Speedhack/` - `AddSpeedhackTools()` | 2 | Speedhack state and speed changes. | @@ -60,7 +60,7 @@ The current v2 catalog contains 190 backend tools in 21 domains. | `Table/` - `AddTableTools()` | 3 | Cheat-table loading, saving, and file listing. | | `Structures/` - `AddStructureTools()` | 15 | Structure Dissect definitions, elements, values, comparison, and C headers. | | `Debugger/` - `AddDebuggerTools()` | 18 | Debugger sessions, breakpoints, threads, context, capture, traces, and stepping. | -| `Exec/` - `AddExecTools()` | 6 | Native and managed injection, calls, and C compilation. | +| `Exec/` - `AddExecTools()` | 7 | Native and managed injection, calls, and C/C# compilation. | | `DotNet/` - `AddDotNetTools()` | 10 | Managed-runtime domains, types, methods, objects, and instance searches. | | `Mono/` - `AddMonoTools()` | 15 | Mono attachment, metadata, fields, invocation, objects, and instance searches. | | `Kernel/` - `AddKernelTools()` | 7 | DBVM, address translation, physical memory, and watches. | diff --git a/tests/CheatEngine.Mcp.LiveTarget/PluginBundleProbe.cs b/tests/CheatEngine.Mcp.LiveTarget/PluginBundleProbe.cs index 0535974..69645da 100644 --- a/tests/CheatEngine.Mcp.LiveTarget/PluginBundleProbe.cs +++ b/tests/CheatEngine.Mcp.LiveTarget/PluginBundleProbe.cs @@ -104,10 +104,38 @@ private static void AssertCatalogSnapshot(string directory, string fileName, Jso JsonNode? expected = JsonNode.Parse(File.ReadAllText(Path.Combine(directory, fileName))); if (!JsonNode.DeepEquals(expected, actual)) { - throw new InvalidOperationException($"The bundled plugin's reflected catalog differs from '{fileName}'."); + throw new InvalidOperationException($"The bundled plugin's reflected catalog differs from '{fileName}'. " + + $"First difference: {CatalogDifference(expected, actual, "$")}."); } } + private static string CatalogDifference(JsonNode? expected, JsonNode? actual, string path) + { + if (expected is JsonObject expectedObject && actual is JsonObject actualObject) + { + foreach (string key in expectedObject.Select(static item => item.Key).Union(actualObject.Select(static item => item.Key))) + { + if (!JsonNode.DeepEquals(expectedObject[key], actualObject[key])) + { + return CatalogDifference(expectedObject[key], actualObject[key], $"{path}.{key}"); + } + } + } + if (expected is JsonArray expectedArray && actual is JsonArray actualArray && expectedArray.Count == actualArray.Count) + { + for (int index = 0; index < expectedArray.Count; index++) + { + if (!JsonNode.DeepEquals(expectedArray[index], actualArray[index])) + { + return CatalogDifference(expectedArray[index], actualArray[index], $"{path}[{index}]"); + } + } + } + string left = expected is JsonValue ? expected.ToJsonString() : expected?.GetValueKind().ToString() ?? "null"; + string right = actual is JsonValue ? actual.ToJsonString() : actual?.GetValueKind().ToString() ?? "null"; + return $"{path}: expected {left[..Math.Min(left.Length, 120)]}, actual {right[..Math.Min(right.Length, 120)]}"; + } + private static LoadedPlugin LoadPlugin(PluginLoadContext context, string pluginPath) { Assembly plugin = context.LoadFromAssemblyPath(pluginPath); diff --git a/tests/CheatEngine.Mcp.LiveTarget/Program.cs b/tests/CheatEngine.Mcp.LiveTarget/Program.cs index c8293bb..3f1b5f5 100644 --- a/tests/CheatEngine.Mcp.LiveTarget/Program.cs +++ b/tests/CheatEngine.Mcp.LiveTarget/Program.cs @@ -8,6 +8,7 @@ internal static class Program { private const int InitialValue = 20260926; private static readonly TimeSpan MaximumLifetime = TimeSpan.FromMinutes(10); + private static readonly string[] PointerOffsets = ["-10", "20"]; private static int Main(string[] args) { @@ -23,16 +24,36 @@ private static int Main(string[] args) string manifestPath = Path.GetFullPath(args[0]); string stopMarkerPath = $"{manifestPath}.stop"; - IntPtr allocation = Marshal.AllocHGlobal(64); + IntPtr direct = IntPtr.Zero; + IntPtr root = IntPtr.Zero; + IntPtr middle = IntPtr.Zero; + IntPtr final = IntPtr.Zero; + IntPtr zeroRoot = IntPtr.Zero; try { - Marshal.WriteInt32(allocation, InitialValue); - string address = $"0x{unchecked((ulong) allocation.ToInt64()):X}"; + direct = Marshal.AllocHGlobal(64); + root = Marshal.AllocHGlobal(64); + middle = Marshal.AllocHGlobal(64); + final = Marshal.AllocHGlobal(64); + zeroRoot = Marshal.AllocHGlobal(IntPtr.Size); + Marshal.WriteInt32(direct, InitialValue); + Marshal.WriteInt32(final, InitialValue); + // dereference(root) - 0x10 == middle; dereference(middle) + 0x20 == final. + Marshal.WriteIntPtr(root, IntPtr.Add(middle, 0x10)); + Marshal.WriteIntPtr(middle, IntPtr.Subtract(final, 0x20)); + Marshal.WriteIntPtr(zeroRoot, IntPtr.Zero); + string address = Address(direct); string manifest = JsonSerializer.Serialize(new { processId = Environment.ProcessId, + pointerWidth = IntPtr.Size * 8, address, - initialValue = InitialValue + initialValue = InitialValue, + pointerRootAddress = Address(root), + pointerTargetAddress = Address(final), + zeroPointerRootAddress = Address(zeroRoot), + unreadableRootAddress = "0x1", + pointerOffsets = PointerOffsets }); File.WriteAllText(manifestPath + ".tmp", manifest); File.Move(manifestPath + ".tmp", manifestPath); @@ -46,6 +67,24 @@ private static int Main(string[] args) return 0; } finally + { + Free(zeroRoot); + Free(final); + Free(middle); + Free(root); + Free(direct); + } + } + + private static string Address(IntPtr value) + { + ulong bits = IntPtr.Size == 4 ? unchecked((uint) value.ToInt32()) : unchecked((ulong) value.ToInt64()); + return $"0x{bits:X}"; + } + + private static void Free(IntPtr allocation) + { + if (allocation != IntPtr.Zero) { Marshal.FreeHGlobal(allocation); } diff --git a/tests/CheatEngine.Mcp.LiveTarget/README.md b/tests/CheatEngine.Mcp.LiveTarget/README.md index 29d6b5a..28f0bdb 100644 --- a/tests/CheatEngine.Mcp.LiveTarget/README.md +++ b/tests/CheatEngine.Mcp.LiveTarget/README.md @@ -1,7 +1,7 @@ # CheatEngine.Mcp.LiveTarget -`CheatEngine.Mcp.LiveTarget` is the disposable x64 process used by live qualification. -It gives the qualification harness a known process, a known writable address, and a bounded lifetime, so native checks never need to attach to an unrelated application. +`CheatEngine.Mcp.LiveTarget` is the disposable x64 or explicitly selected x86 process used by live qualification. +It gives the qualification harness a known process, a known writable address, target-owned pointer-chain allocations, and a bounded lifetime, so native checks never need to attach to an unrelated application. This is a .NET 10 `WinExe` test utility. It is not shipped with the plugin or gateway and has no production role. @@ -11,7 +11,8 @@ It is not shipped with the plugin or gateway and has no production role. The executable requires exactly one argument: the absolute or relative path of a manifest file to create. It exits with code `64` when that argument is missing, blank, or accompanied by extra arguments. -At startup, the process allocates 64 bytes of unmanaged memory and writes the signed 32-bit value `20260926` at the allocation's first address. +At startup, the process owns four 64-byte unmanaged allocations and one pointer-sized allocation. +It writes the signed 32-bit value `20260926` at the direct-value and final pointer-chain addresses. It writes a JSON manifest through a temporary file in the manifest directory and moves that file into place. The manifest has this shape: @@ -20,16 +21,25 @@ The manifest has this shape: { "processId": 1234, "address": "0x7FF600000000", - "initialValue": 20260926 + "pointerWidth": 64, + "initialValue": 20260926, + "pointerRootAddress": "0x7FF600000000", + "pointerTargetAddress": "0x7FF600000000", + "zeroPointerRootAddress": "0x7FF600000000", + "unreadableRootAddress": "0x1", + "pointerOffsets": ["-10", "20"] } ``` `processId` identifies the live target process. `address` is the allocation address in uppercase hexadecimal with a `0x` prefix. +`pointerWidth` is the target process pointer width, either `32` or `64`. `initialValue` records the original value that the live scenario expects to find. +The pointer root reaches `pointerTargetAddress` through the signed offsets `-10`, then `20`. +`zeroPointerRootAddress` stores a zero pointer so qualification can distinguish a resolved address zero from an unreadable typed final value. After publishing the manifest, the process polls for a sibling stop marker named `.stop`. -It exits successfully when that file appears or when its ten-minute lifetime expires, then releases the unmanaged allocation. +It exits successfully when that file appears or when its ten-minute lifetime expires, then releases every owned unmanaged allocation. It has no window, network listener, input handling, or persistent state beyond the manifest and stop marker. ## Relationship to live qualification @@ -45,7 +55,17 @@ It requires a maintainer-authorized local session with every regular Cheat Engin ## Maintaining the target -Keep this executable deterministic, small, x64-only, and free of external dependencies. +Keep this executable deterministic, small, and free of external dependencies. +The normal live fixture uses the x64 executable. An explicitly selected x86 fixture uses the repository-built DLL under `artifacts/live-target-x86` through the installed x86 .NET host; it qualifies only the disposable target's pointer width, while Cheat Engine, the plugin, and gateway remain x64. + +Build the x86 fixture before selecting it, then set the bounded target-architecture choice for the live test: + +```powershell +dotnet build tests/CheatEngine.Mcp.LiveTarget/CheatEngine.Mcp.LiveTarget.csproj -c Release -p:PlatformTarget=x86 -p:UseAppHost=false -p:ArtifactsPath="$PWD/artifacts/live-target-x86" -p:RestoreLockedMode=true +$env:CHEATENGINE_MCP_LIVE_QUALIFICATION_TARGET_ARCHITECTURE = 'x86' +``` + +The live report records the selected target architecture. That is evidence for the x86 disposable fixture only; it is not evidence that the x64 Cheat Engine host, plugin, or gateway ran as x86. Do not add behavior that touches another process, accepts network input, or persists data outside its explicit manifest and stop marker. If the manifest schema, initial value, allocation layout, or lifetime changes, update the corresponding parsing and assertions in `LiveSandboxSession` and `McpLiveQualificationTests` in the test project. diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-resources.json b/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-resources.json index 1c27fd8..9bcdebc 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-resources.json +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-resources.json @@ -158,7 +158,7 @@ "uri": "cheatengine://docs/mono-and-dotnet", "description": "Choosing between the Mono and .NET collectors, what mono_attach and the Uses Mono option change, reading classes, fields, statics, instances and methods, and invoking or injecting managed code safely.", "mimeType": "text/markdown", - "size": 24608 + "size": 25424 }, { "name": "doc_pointers", @@ -166,7 +166,7 @@ "uri": "cheatengine://docs/pointers", "description": "Pointer notation and offset order, reading a chain, manual chains from a writer, pointer maps, path searches, rescans and their limits, ranking chains, storing them in records, and fallbacks.", "mimeType": "text/markdown", - "size": 27334 + "size": 28953 }, { "name": "doc_safety", @@ -198,7 +198,7 @@ "uri": "cheatengine://docs/tool-map", "description": "Every tool by domain with its purpose, the setting (gate) it needs and its dispatch class; the running schema stays the authority for parameters and results.", "mimeType": "text/markdown", - "size": 25382 + "size": 25769 }, { "name": "doc_troubleshooting_scans", diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-tools.json b/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-tools.json index f1e465d..c24e898 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-tools.json +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-tools.json @@ -5380,6 +5380,81 @@ "cheatengine/dispatchClass": "blocking_native" } }, + { + "name": "exec_compile_csharp", + "title": "Compile managed source", + "description": "Compile up to 131072 characters of C# source with Cheat Engine\u0027s compileCS bridge and immediately copy its generated assembly to outputPath under an approved write root. The compiler uses the optional checked coreAssembly and up to 32 checked reference assemblies. Compilation does not inject or invoke the assembly. Cheat Engine owns its original generated file and may remove it when Cheat Engine, or another instance, closes; MCP never deletes that file. Compiler availability still depends on Cheat Engine\u0027s supported .NET Framework compiler prerequisites.", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "description": "C# source, 1 to 131072 characters.", + "type": "string" + }, + "outputPath": { + "description": "Approved-root destination for the exported compiled assembly. It must not already exist unless overwrite is true.", + "type": "string" + }, + "referencePaths": { + "description": "0 to 32 absolute local reference-assembly paths held against changes while Cheat Engine compiles.", + "type": "array", + "items": { + "type": "string" + }, + "default": null + }, + "coreAssembly": { + "description": "Optional absolute local core assembly path held against changes while Cheat Engine compiles.", + "type": "string", + "default": null + }, + "overwrite": { + "description": "Allow the approved-root outputPath to atomically replace an existing file.", + "type": "boolean", + "default": false + } + }, + "required": [ + "source", + "outputPath" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "outputPath": { + "description": "The anchored approved-root path where MCP copied the compiled assembly.", + "type": "string" + }, + "length": { + "description": "The exported assembly length in bytes.", + "type": "integer" + }, + "sha256": { + "description": "The uppercase SHA-256 hash of the exported assembly bytes.", + "type": "string" + } + }, + "required": [ + "outputPath", + "length", + "sha256" + ] + }, + "annotations": { + "title": "Compile managed source", + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false + }, + "_meta": { + "cheatengine/requires": [ + "target_code_execution" + ], + "cheatengine/dispatchClass": "blocking_native" + } + }, { "name": "exec_inject_dotnet", "title": "Inject managed assembly", @@ -10869,6 +10944,166 @@ "cheatengine/dispatchClass": "host_scan" } }, + { + "name": "pointer_get_access_info", + "title": "Analyze pointer access", + "description": "Analyzes one supplied x86 or x64 memory operand and captured registers without resolving live symbols or reading target memory. instructionText is authoritative disassembly; instructionBytes checks length and address-size prefixes, but is not decoded or compared with the text. Indexed operands report a dynamic offset and are not stable pointer-chain claims.", + "inputSchema": { + "type": "object", + "properties": { + "instructionText": { + "description": "The authoritative disassembly text, such as mov eax,[rax\u002B10].", + "type": "string" + }, + "instructionAddress": { + "description": "The instruction address as uppercase or 0x-prefixed hexadecimal.", + "type": "string" + }, + "instructionLength": { + "description": "The supplied instruction length in bytes, 1 through 15.", + "type": "integer" + }, + "architecture": { + "description": "x86 or x64.", + "type": "string" + }, + "registers": { + "description": "Captured register names and hexadecimal values. A 32-bit alias cannot supply unknown upper bits for a 64-bit operand.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "instructionBytes": { + "description": "Optional instruction bytes. Their count must equal instructionLength; they are not decoded by this supplied-text mode.", + "type": "string", + "default": null + }, + "observedAccessAddress": { + "description": "An optional observed accessed address for mismatch reporting.", + "type": "string", + "default": null + }, + "contextPhase": { + "description": "Whether registers are from before execution or from a post-execution access/write event.", + "type": "string", + "enum": [ + "pre_execution", + "post_execution" + ], + "default": "pre_execution" + }, + "symbols": { + "description": "Optional bounded caller-supplied symbol or module-base map. No live symbol resolution occurs.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": null + } + }, + "required": [ + "instructionText", + "instructionAddress", + "instructionLength", + "architecture", + "registers" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "description": "Whether the supplied facts were supported, missing, contradictory, or unsupported.", + "type": "string", + "enum": [ + "supported", + "missing_facts", + "contradictory_facts", + "unsupported" + ] + }, + "instruction": { + "description": "The supplied instruction text used as the authoritative representation.", + "type": "string" + }, + "memoryOperand": { + "description": "The evaluated memory operand, when supported.", + "type": "string", + "default": null + }, + "baseRegister": { + "description": "The canonical base register, when present.", + "type": "string", + "default": null + }, + "indexRegister": { + "description": "The canonical index register, when present.", + "type": "string", + "default": null + }, + "scale": { + "description": "The index scale, when present.", + "type": "integer", + "default": null + }, + "displacement": { + "description": "The signed displacement, uppercase hexadecimal.", + "type": "string", + "default": null + }, + "effectiveAddress": { + "description": "The computed effective address, uppercase hexadecimal.", + "type": "string", + "default": null + }, + "candidateStructureBase": { + "description": "The candidate structure base before a stable displacement, uppercase hexadecimal.", + "type": "string", + "default": null + }, + "nextPointerSearchValue": { + "description": "The value to search for in a pointer map: candidateStructureBase when stable.", + "type": "string", + "default": null + }, + "dynamicOffset": { + "description": "Whether index addressing makes the displacement dynamic rather than a stable pointer-chain offset.", + "type": "boolean", + "default": false + }, + "observedAddressMismatch": { + "description": "Whether the supplied observed address differs from the computed effective address.", + "type": "boolean", + "default": false + }, + "contextMayHaveChanged": { + "description": "Whether post-execution registers may have changed before capture.", + "type": "boolean", + "default": false + }, + "uncertainty": { + "description": "A bounded explanation of missing, contradictory, or unsupported facts.", + "type": "string", + "default": null + } + }, + "required": [ + "status", + "instruction" + ] + }, + "annotations": { + "title": "Analyze pointer access", + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "_meta": { + "cheatengine/dispatchClass": "short" + } + }, { "name": "pointer_list_maps", "title": "List pointer maps", @@ -11589,6 +11824,351 @@ "cheatengine/dispatchClass": "short" } }, + { + "name": "pointer_read_chains", + "title": "Read pointer chains", + "description": "Validates up to 128 supplied pointer chains without creating a scan. Each chain dereferences then adds its signed hexadecimal offset. Module expressions are resolved afresh in every bounded slice; absolute roots remain absolute after a target restart. An unreadable hop, target comparison, and optional final-value read are reported independently per candidate.", + "inputSchema": { + "type": "object", + "properties": { + "candidates": { + "description": "One to 128 candidates with a unique id, base expression and one to 64 signed hexadecimal offsets.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "A stable caller-chosen identifier, unique within this request.", + "type": "string" + }, + "base": { + "description": "The address expression that holds the first pointer.", + "type": "string" + }, + "offsets": { + "description": "One to 64 signed hexadecimal offsets in dereference order.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "base", + "offsets" + ] + } + }, + "target": { + "description": "Optionally compare each resolved final address with this current-target expression.", + "type": "string", + "default": null + }, + "valueType": { + "description": "The optional type of the value read after comparison.", + "type": "string", + "enum": [ + "int8", + "uint8", + "int16", + "uint16", + "int32", + "uint32", + "int64", + "uint64", + "float", + "double", + "pointer", + "string", + "wstring", + "bytes" + ], + "default": null + }, + "length": { + "description": "For bytes, the required byte count; for string and wstring, the maximum length.", + "type": "integer", + "default": null + }, + "matchesOnly": { + "description": "Return only matching candidate entries while summary counts still include misses and errors.", + "type": "boolean", + "default": false + } + }, + "required": [ + "candidates" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "candidates": { + "description": "Candidate outcomes, or only matches when matchesOnly was requested.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "The stable identifier supplied by the caller.", + "type": "string" + }, + "chainStatus": { + "description": "Whether every pointer hop resolved.", + "type": "string", + "enum": [ + "resolved", + "unreadable", + "error" + ] + }, + "address": { + "description": "The final address when the chain resolved, uppercase hexadecimal.", + "type": "string" + }, + "failedHop": { + "description": "The zero-based hop that could not be read, when chainStatus is unreadable.", + "type": "integer" + }, + "failedReadAt": { + "description": "The address that could not be read, when chainStatus is unreadable.", + "type": "string" + }, + "chainError": { + "description": "The per-candidate chain error when chainStatus is error.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "invalid_argument", + "invalid_state", + "not_found", + "not_attached", + "busy", + "timeout", + "cancelled", + "capability_disabled", + "unsupported", + "target_changed", + "host_refused", + "memory_read_failed", + "memory_write_failed", + "limit_exceeded", + "partial_effect", + "stopping", + "instance_unavailable", + "internal" + ] + }, + "message": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "hostEffect": { + "type": "string", + "enum": [ + "not_started", + "not_applied", + "started", + "completed", + "cleanup_unconfirmed", + "unknown" + ] + }, + "retryable": { + "type": "boolean" + }, + "hint": { + "type": "string", + "default": null + }, + "details": { + "default": null + } + }, + "required": [ + "kind", + "message", + "hostEffect", + "retryable" + ] + }, + "comparisonStatus": { + "description": "Whether the resolved address matched target.", + "type": "string", + "enum": [ + "not_requested", + "not_resolved", + "not_attempted", + "match", + "miss" + ] + }, + "valueStatus": { + "description": "Whether the optional final value was read.", + "type": "string", + "enum": [ + "not_requested", + "not_resolved", + "not_attempted", + "read", + "failed" + ] + }, + "value": { + "description": "The final value as text when valueStatus is read.", + "type": "string" + }, + "valueError": { + "description": "The final-value read error when valueStatus is failed.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "invalid_argument", + "invalid_state", + "not_found", + "not_attached", + "busy", + "timeout", + "cancelled", + "capability_disabled", + "unsupported", + "target_changed", + "host_refused", + "memory_read_failed", + "memory_write_failed", + "limit_exceeded", + "partial_effect", + "stopping", + "instance_unavailable", + "internal" + ] + }, + "message": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "hostEffect": { + "type": "string", + "enum": [ + "not_started", + "not_applied", + "started", + "completed", + "cleanup_unconfirmed", + "unknown" + ] + }, + "retryable": { + "type": "boolean" + }, + "hint": { + "type": "string", + "default": null + }, + "details": { + "default": null + } + }, + "required": [ + "kind", + "message", + "hostEffect", + "retryable" + ] + } + }, + "required": [ + "id", + "chainStatus", + "comparisonStatus", + "valueStatus" + ] + } + }, + "summary": { + "description": "Counts over all processed candidates, including hidden errors and misses.", + "type": "object", + "properties": { + "submitted": { + "description": "How many candidates were submitted.", + "type": "integer" + }, + "processed": { + "description": "How many candidates were validated before this response ended.", + "type": "integer" + }, + "resolved": { + "description": "How many chains resolved.", + "type": "integer" + }, + "unreadable": { + "description": "How many chains had an unreadable hop.", + "type": "integer" + }, + "errors": { + "description": "How many chains ended with another per-candidate error.", + "type": "integer" + }, + "matches": { + "description": "How many resolved chains matched target.", + "type": "integer" + }, + "misses": { + "description": "How many resolved chains missed target.", + "type": "integer" + }, + "valuesRead": { + "description": "How many optional final values were read.", + "type": "integer" + }, + "valueErrors": { + "description": "How many optional final values could not be read.", + "type": "integer" + }, + "cancelled": { + "description": "Whether caller cancellation stopped validation before every candidate was processed.", + "type": "boolean" + } + }, + "required": [ + "submitted", + "processed", + "resolved", + "unreadable", + "errors", + "matches", + "misses", + "valuesRead", + "valueErrors", + "cancelled" + ] + } + }, + "required": [ + "candidates", + "summary" + ] + }, + "annotations": { + "title": "Read pointer chains", + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "_meta": { + "cheatengine/dispatchClass": "short" + } + }, { "name": "pointer_rescan_paths", "title": "Rescan pointer paths", diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-resources.json b/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-resources.json index 1fb12d7..c11644f 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-resources.json +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-resources.json @@ -158,7 +158,7 @@ "uri": "cheatengine://docs/mono-and-dotnet", "description": "Choosing between the Mono and .NET collectors, what mono_attach and the Uses Mono option change, reading classes, fields, statics, instances and methods, and invoking or injecting managed code safely.", "mimeType": "text/markdown", - "size": 24608 + "size": 25424 }, { "name": "doc_pointers", @@ -166,7 +166,7 @@ "uri": "cheatengine://docs/pointers", "description": "Pointer notation and offset order, reading a chain, manual chains from a writer, pointer maps, path searches, rescans and their limits, ranking chains, storing them in records, and fallbacks.", "mimeType": "text/markdown", - "size": 27334 + "size": 28953 }, { "name": "doc_safety", @@ -198,7 +198,7 @@ "uri": "cheatengine://docs/tool-map", "description": "Every tool by domain with its purpose, the setting (gate) it needs and its dispatch class; the running schema stays the authority for parameters and results.", "mimeType": "text/markdown", - "size": 25382 + "size": 25769 }, { "name": "doc_troubleshooting_scans", diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-tools.json b/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-tools.json index 5a6c8e8..c35fdb5 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-tools.json +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-tools.json @@ -5699,6 +5699,86 @@ "cheatengine/dispatchClass": "blocking_native" } }, + { + "name": "exec_compile_csharp", + "title": "Compile managed source", + "description": "Compile up to 131072 characters of C# source with Cheat Engine\u0027s compileCS bridge and immediately copy its generated assembly to outputPath under an approved write root. The compiler uses the optional checked coreAssembly and up to 32 checked reference assemblies. Compilation does not inject or invoke the assembly. Cheat Engine owns its original generated file and may remove it when Cheat Engine, or another instance, closes; MCP never deletes that file. Compiler availability still depends on Cheat Engine\u0027s supported .NET Framework compiler prerequisites.", + "inputSchema": { + "type": "object", + "properties": { + "instanceId": { + "type": "string", + "description": "Instance id from instance_list." + }, + "source": { + "description": "C# source, 1 to 131072 characters.", + "type": "string" + }, + "outputPath": { + "description": "Approved-root destination for the exported compiled assembly. It must not already exist unless overwrite is true.", + "type": "string" + }, + "referencePaths": { + "description": "0 to 32 absolute local reference-assembly paths held against changes while Cheat Engine compiles.", + "type": "array", + "items": { + "type": "string" + }, + "default": null + }, + "coreAssembly": { + "description": "Optional absolute local core assembly path held against changes while Cheat Engine compiles.", + "type": "string", + "default": null + }, + "overwrite": { + "description": "Allow the approved-root outputPath to atomically replace an existing file.", + "type": "boolean", + "default": false + } + }, + "required": [ + "instanceId", + "source", + "outputPath" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "outputPath": { + "description": "The anchored approved-root path where MCP copied the compiled assembly.", + "type": "string" + }, + "length": { + "description": "The exported assembly length in bytes.", + "type": "integer" + }, + "sha256": { + "description": "The uppercase SHA-256 hash of the exported assembly bytes.", + "type": "string" + } + }, + "required": [ + "outputPath", + "length", + "sha256" + ] + }, + "annotations": { + "title": "Compile managed source", + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false + }, + "_meta": { + "cheatengine/requires": [ + "target_code_execution" + ], + "cheatengine/dispatchClass": "blocking_native" + } + }, { "name": "exec_inject_dotnet", "title": "Inject managed assembly", @@ -11546,6 +11626,171 @@ "cheatengine/dispatchClass": "host_scan" } }, + { + "name": "pointer_get_access_info", + "title": "Analyze pointer access", + "description": "Analyzes one supplied x86 or x64 memory operand and captured registers without resolving live symbols or reading target memory. instructionText is authoritative disassembly; instructionBytes checks length and address-size prefixes, but is not decoded or compared with the text. Indexed operands report a dynamic offset and are not stable pointer-chain claims.", + "inputSchema": { + "type": "object", + "properties": { + "instanceId": { + "type": "string", + "description": "Instance id from instance_list." + }, + "instructionText": { + "description": "The authoritative disassembly text, such as mov eax,[rax\u002B10].", + "type": "string" + }, + "instructionAddress": { + "description": "The instruction address as uppercase or 0x-prefixed hexadecimal.", + "type": "string" + }, + "instructionLength": { + "description": "The supplied instruction length in bytes, 1 through 15.", + "type": "integer" + }, + "architecture": { + "description": "x86 or x64.", + "type": "string" + }, + "registers": { + "description": "Captured register names and hexadecimal values. A 32-bit alias cannot supply unknown upper bits for a 64-bit operand.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "instructionBytes": { + "description": "Optional instruction bytes. Their count must equal instructionLength; they are not decoded by this supplied-text mode.", + "type": "string", + "default": null + }, + "observedAccessAddress": { + "description": "An optional observed accessed address for mismatch reporting.", + "type": "string", + "default": null + }, + "contextPhase": { + "description": "Whether registers are from before execution or from a post-execution access/write event.", + "type": "string", + "enum": [ + "pre_execution", + "post_execution" + ], + "default": "pre_execution" + }, + "symbols": { + "description": "Optional bounded caller-supplied symbol or module-base map. No live symbol resolution occurs.", + "type": "object", + "additionalProperties": { + "type": "string" + }, + "default": null + } + }, + "required": [ + "instanceId", + "instructionText", + "instructionAddress", + "instructionLength", + "architecture", + "registers" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "status": { + "description": "Whether the supplied facts were supported, missing, contradictory, or unsupported.", + "type": "string", + "enum": [ + "supported", + "missing_facts", + "contradictory_facts", + "unsupported" + ] + }, + "instruction": { + "description": "The supplied instruction text used as the authoritative representation.", + "type": "string" + }, + "memoryOperand": { + "description": "The evaluated memory operand, when supported.", + "type": "string", + "default": null + }, + "baseRegister": { + "description": "The canonical base register, when present.", + "type": "string", + "default": null + }, + "indexRegister": { + "description": "The canonical index register, when present.", + "type": "string", + "default": null + }, + "scale": { + "description": "The index scale, when present.", + "type": "integer", + "default": null + }, + "displacement": { + "description": "The signed displacement, uppercase hexadecimal.", + "type": "string", + "default": null + }, + "effectiveAddress": { + "description": "The computed effective address, uppercase hexadecimal.", + "type": "string", + "default": null + }, + "candidateStructureBase": { + "description": "The candidate structure base before a stable displacement, uppercase hexadecimal.", + "type": "string", + "default": null + }, + "nextPointerSearchValue": { + "description": "The value to search for in a pointer map: candidateStructureBase when stable.", + "type": "string", + "default": null + }, + "dynamicOffset": { + "description": "Whether index addressing makes the displacement dynamic rather than a stable pointer-chain offset.", + "type": "boolean", + "default": false + }, + "observedAddressMismatch": { + "description": "Whether the supplied observed address differs from the computed effective address.", + "type": "boolean", + "default": false + }, + "contextMayHaveChanged": { + "description": "Whether post-execution registers may have changed before capture.", + "type": "boolean", + "default": false + }, + "uncertainty": { + "description": "A bounded explanation of missing, contradictory, or unsupported facts.", + "type": "string", + "default": null + } + }, + "required": [ + "status", + "instruction" + ] + }, + "annotations": { + "title": "Analyze pointer access", + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "_meta": { + "cheatengine/dispatchClass": "short" + } + }, { "name": "pointer_list_maps", "title": "List pointer maps", @@ -12302,6 +12547,356 @@ "cheatengine/dispatchClass": "short" } }, + { + "name": "pointer_read_chains", + "title": "Read pointer chains", + "description": "Validates up to 128 supplied pointer chains without creating a scan. Each chain dereferences then adds its signed hexadecimal offset. Module expressions are resolved afresh in every bounded slice; absolute roots remain absolute after a target restart. An unreadable hop, target comparison, and optional final-value read are reported independently per candidate.", + "inputSchema": { + "type": "object", + "properties": { + "instanceId": { + "type": "string", + "description": "Instance id from instance_list." + }, + "candidates": { + "description": "One to 128 candidates with a unique id, base expression and one to 64 signed hexadecimal offsets.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "A stable caller-chosen identifier, unique within this request.", + "type": "string" + }, + "base": { + "description": "The address expression that holds the first pointer.", + "type": "string" + }, + "offsets": { + "description": "One to 64 signed hexadecimal offsets in dereference order.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "base", + "offsets" + ] + } + }, + "target": { + "description": "Optionally compare each resolved final address with this current-target expression.", + "type": "string", + "default": null + }, + "valueType": { + "description": "The optional type of the value read after comparison.", + "type": "string", + "enum": [ + "int8", + "uint8", + "int16", + "uint16", + "int32", + "uint32", + "int64", + "uint64", + "float", + "double", + "pointer", + "string", + "wstring", + "bytes" + ], + "default": null + }, + "length": { + "description": "For bytes, the required byte count; for string and wstring, the maximum length.", + "type": "integer", + "default": null + }, + "matchesOnly": { + "description": "Return only matching candidate entries while summary counts still include misses and errors.", + "type": "boolean", + "default": false + } + }, + "required": [ + "instanceId", + "candidates" + ] + }, + "outputSchema": { + "type": "object", + "properties": { + "candidates": { + "description": "Candidate outcomes, or only matches when matchesOnly was requested.", + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "description": "The stable identifier supplied by the caller.", + "type": "string" + }, + "chainStatus": { + "description": "Whether every pointer hop resolved.", + "type": "string", + "enum": [ + "resolved", + "unreadable", + "error" + ] + }, + "address": { + "description": "The final address when the chain resolved, uppercase hexadecimal.", + "type": "string" + }, + "failedHop": { + "description": "The zero-based hop that could not be read, when chainStatus is unreadable.", + "type": "integer" + }, + "failedReadAt": { + "description": "The address that could not be read, when chainStatus is unreadable.", + "type": "string" + }, + "chainError": { + "description": "The per-candidate chain error when chainStatus is error.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "invalid_argument", + "invalid_state", + "not_found", + "not_attached", + "busy", + "timeout", + "cancelled", + "capability_disabled", + "unsupported", + "target_changed", + "host_refused", + "memory_read_failed", + "memory_write_failed", + "limit_exceeded", + "partial_effect", + "stopping", + "instance_unavailable", + "internal" + ] + }, + "message": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "hostEffect": { + "type": "string", + "enum": [ + "not_started", + "not_applied", + "started", + "completed", + "cleanup_unconfirmed", + "unknown" + ] + }, + "retryable": { + "type": "boolean" + }, + "hint": { + "type": "string", + "default": null + }, + "details": { + "default": null + } + }, + "required": [ + "kind", + "message", + "hostEffect", + "retryable" + ] + }, + "comparisonStatus": { + "description": "Whether the resolved address matched target.", + "type": "string", + "enum": [ + "not_requested", + "not_resolved", + "not_attempted", + "match", + "miss" + ] + }, + "valueStatus": { + "description": "Whether the optional final value was read.", + "type": "string", + "enum": [ + "not_requested", + "not_resolved", + "not_attempted", + "read", + "failed" + ] + }, + "value": { + "description": "The final value as text when valueStatus is read.", + "type": "string" + }, + "valueError": { + "description": "The final-value read error when valueStatus is failed.", + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "invalid_argument", + "invalid_state", + "not_found", + "not_attached", + "busy", + "timeout", + "cancelled", + "capability_disabled", + "unsupported", + "target_changed", + "host_refused", + "memory_read_failed", + "memory_write_failed", + "limit_exceeded", + "partial_effect", + "stopping", + "instance_unavailable", + "internal" + ] + }, + "message": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "hostEffect": { + "type": "string", + "enum": [ + "not_started", + "not_applied", + "started", + "completed", + "cleanup_unconfirmed", + "unknown" + ] + }, + "retryable": { + "type": "boolean" + }, + "hint": { + "type": "string", + "default": null + }, + "details": { + "default": null + } + }, + "required": [ + "kind", + "message", + "hostEffect", + "retryable" + ] + } + }, + "required": [ + "id", + "chainStatus", + "comparisonStatus", + "valueStatus" + ] + } + }, + "summary": { + "description": "Counts over all processed candidates, including hidden errors and misses.", + "type": "object", + "properties": { + "submitted": { + "description": "How many candidates were submitted.", + "type": "integer" + }, + "processed": { + "description": "How many candidates were validated before this response ended.", + "type": "integer" + }, + "resolved": { + "description": "How many chains resolved.", + "type": "integer" + }, + "unreadable": { + "description": "How many chains had an unreadable hop.", + "type": "integer" + }, + "errors": { + "description": "How many chains ended with another per-candidate error.", + "type": "integer" + }, + "matches": { + "description": "How many resolved chains matched target.", + "type": "integer" + }, + "misses": { + "description": "How many resolved chains missed target.", + "type": "integer" + }, + "valuesRead": { + "description": "How many optional final values were read.", + "type": "integer" + }, + "valueErrors": { + "description": "How many optional final values could not be read.", + "type": "integer" + }, + "cancelled": { + "description": "Whether caller cancellation stopped validation before every candidate was processed.", + "type": "boolean" + } + }, + "required": [ + "submitted", + "processed", + "resolved", + "unreadable", + "errors", + "matches", + "misses", + "valuesRead", + "valueErrors", + "cancelled" + ] + } + }, + "required": [ + "candidates", + "summary" + ] + }, + "annotations": { + "title": "Read pointer chains", + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "_meta": { + "cheatengine/dispatchClass": "short" + } + }, { "name": "pointer_rescan_paths", "title": "Rescan pointer paths", diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/net-new-tools.txt b/tests/CheatEngine.Mcp.Tests/Contract/Golden/net-new-tools.txt index f7e85b9..cde6d36 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/net-new-tools.txt +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/net-new-tools.txt @@ -16,6 +16,7 @@ dotnet_list_types dotnet_poll_instance_search dotnet_start_instance_search exec_call_method +exec_compile_csharp lua_find_api memory_compare_snapshot memory_create_snapshot @@ -42,9 +43,11 @@ mono_list_fields mono_list_methods mono_poll_instance_search mono_start_instance_search +pointer_get_access_info pointer_list_scans pointer_load_map pointer_load_scan +pointer_read_chains pointer_save_map pointer_save_scan record_group diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/open-world-tools.txt b/tests/CheatEngine.Mcp.Tests/Contract/Golden/open-world-tools.txt index 32601da..d15bbd6 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/open-world-tools.txt +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/open-world-tools.txt @@ -2,6 +2,7 @@ exec_call_local exec_call_method exec_call_remote exec_compile_c +exec_compile_csharp exec_inject_dotnet exec_inject_library lua_execute diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/tool-summary.txt b/tests/CheatEngine.Mcp.Tests/Contract/Golden/tool-summary.txt index 50ab5f4..5a67419 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/tool-summary.txt +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/tool-summary.txt @@ -57,6 +57,7 @@ exec_call_local | Call Cheat Engine function | false,true,false,true | blocking_ exec_call_method | Call target instance method | false,true,false,true | blocking_native | target_code_execution exec_call_remote | Call target function | false,true,false,true | blocking_native | target_code_execution exec_compile_c | Compile C source | false,true,false,true | blocking_native | target_code_execution +exec_compile_csharp | Compile managed source | false,true,false,true | blocking_native | target_code_execution exec_inject_dotnet | Inject managed assembly | false,true,false,true | blocking_native | target_code_execution exec_inject_library | Inject native library | false,true,false,true | blocking_native | target_code_execution instance_list | List Cheat Engine instances | true,false,true,false | gateway | - @@ -113,12 +114,14 @@ pointer_delete_map | Delete a pointer map | false,true,true,false | short | - pointer_delete_scan | Delete a pointer scan | false,true,true,false | short | - pointer_find_paths | Find pointer paths | false,false,false,false | short | - pointer_find_references | Find pointer references | true,false,true,false | host_scan | - +pointer_get_access_info | Analyze pointer access | true,false,true,false | short | - pointer_list_maps | List pointer maps | true,false,true,false | short | - pointer_list_paths | List pointer paths | true,false,true,false | short | - pointer_list_scans | List pointer scans | true,false,true,false | short | - pointer_load_map | Load pointer map | false,false,false,true | short | - pointer_load_scan | Load pointer scan | false,false,false,true | short | - pointer_read_chain | Read a pointer chain | true,false,true,false | short | - +pointer_read_chains | Read pointer chains | true,false,true,false | short | - pointer_rescan_paths | Rescan pointer paths | false,true,false,false | short | - pointer_save_map | Save pointer map | false,true,false,true | short | - pointer_save_scan | Save pointer scan | false,true,false,true | short | - diff --git a/tests/CheatEngine.Mcp.Tests/Contract/ToolCatalogContractTests.cs b/tests/CheatEngine.Mcp.Tests/Contract/ToolCatalogContractTests.cs index bbb032a..9983e05 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/ToolCatalogContractTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Contract/ToolCatalogContractTests.cs @@ -11,7 +11,7 @@ namespace CheatEngine.Mcp.Tests.Contract; /// -/// The frozen v2 catalog: the 191 names, their fit with the naming rules, the map from every pre-v2 name, the reviewed +/// The reviewed v2 catalog: the names, their fit with the naming rules, the map from every pre-v2 name, the reviewed /// open-world list and the per-tool summary that the reference documentation is generated from. /// public sealed class ToolCatalogContractTests @@ -28,7 +28,7 @@ public sealed class ToolCatalogContractTests { "memory", 19 }, { "scan", 8 }, { "aob", 3 }, - { "pointer", 14 }, + { "pointer", 16 }, { "module", 5 }, { "symbol", 10 }, { "speedhack", 2 }, @@ -39,7 +39,7 @@ public sealed class ToolCatalogContractTests { "table", 3 }, { "structure", 15 }, { "debugger", 18 }, - { "exec", 6 }, + { "exec", 7 }, { "dotnet", 10 }, { "mono", 15 }, { "kernel", 7 }, @@ -47,16 +47,16 @@ public sealed class ToolCatalogContractTests }; [Fact] - public void ToolNames_Constants_Are191UniqueNamesSplitBetweenBackendAndGateway() + public void ToolNames_Constants_Are194UniqueNamesSplitBetweenBackendAndGateway() { string[] constants = typeof(CheatEngineToolNames).GetFields(BindingFlags.Public | BindingFlags.Static) .Where(static field => field.IsLiteral) .Select(static field => (string) field.GetRawConstantValue()!) .ToArray(); - Assert.Equal(191, constants.Length); + Assert.Equal(194, constants.Length); Assert.Equal(constants.Order(StringComparer.Ordinal), CheatEngineToolNames.All.Order(StringComparer.Ordinal)); - Assert.Equal(190, CheatEngineToolNames.Backend.Count); + Assert.Equal(193, CheatEngineToolNames.Backend.Count); Assert.Equal([CheatEngineToolNames.InstanceList], CheatEngineToolNames.All.Except(CheatEngineToolNames.Backend)); } diff --git a/tests/CheatEngine.Mcp.Tests/Core/McpContractRulesTests.cs b/tests/CheatEngine.Mcp.Tests/Core/McpContractRulesTests.cs index 717373a..22e82ef 100644 --- a/tests/CheatEngine.Mcp.Tests/Core/McpContractRulesTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Core/McpContractRulesTests.cs @@ -21,7 +21,7 @@ public sealed class McpContractRulesTests """{"type":"object","properties":{"address":{"description":"Address expression.","type":"string"}},"required":["address"]}"""; /// - /// The 190 backend tool names of the v2 catalog, including four pointer file operations. + /// The reviewed backend tool names of the v2 catalog. /// public static TheoryData PlanCatalogNames => new( "runtime_get_info", "runtime_get_overview", "runtime_list_resources", "runtime_release_resources", @@ -34,7 +34,7 @@ public sealed class McpContractRulesTests "scan_first", "scan_next", "scan_get_status", "scan_list_results", "scan_list_scanners", "scan_reset", "scan_delete", "scan_stop", "aob_find", "aob_generate_signature", - "pointer_read_chain", "pointer_find_references", "pointer_create_map", "pointer_list_maps", + "pointer_read_chain", "pointer_read_chains", "pointer_get_access_info", "pointer_find_references", "pointer_create_map", "pointer_list_maps", "pointer_delete_map", "pointer_find_paths", "pointer_rescan_paths", "pointer_list_paths", "pointer_list_scans", "pointer_delete_scan", "pointer_save_map", "pointer_load_map", "pointer_save_scan", "pointer_load_scan", "module_list", "module_get", "module_list_exports", "module_find_patches", @@ -61,7 +61,7 @@ public sealed class McpContractRulesTests "debugger_get_stack_trace", "debugger_start_capture", "debugger_poll_capture", "debugger_start_trace", "debugger_poll_trace", "debugger_run_to", "exec_inject_library", "exec_inject_dotnet", "exec_call_remote", "exec_call_method", "exec_call_local", - "exec_compile_c", + "exec_compile_c", "exec_compile_csharp", "dotnet_get_status", "dotnet_list_domains", "dotnet_list_modules", "dotnet_list_types", "dotnet_get_type", "dotnet_list_methods", "dotnet_get_object", "dotnet_start_instance_search", "dotnet_poll_instance_search", "mono_attach", "mono_detach", "mono_get_status", "mono_list_assemblies", "mono_list_classes", @@ -77,10 +77,10 @@ public sealed class McpContractRulesTests "mono_get_object"); [Fact] - public void PlanCatalogNames_Count_Is190() + public void PlanCatalogNames_Count_Is193() { - Assert.Equal(190, PlanCatalogNames.Count); - Assert.Equal(190, PlanCatalogNames.Select(static row => row.Data).Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(193, PlanCatalogNames.Count); + Assert.Equal(193, PlanCatalogNames.Select(static row => row.Data).Distinct(StringComparer.Ordinal).Count()); } [Fact] diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs index 74a475f..dd69a4c 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs @@ -1,3 +1,4 @@ namespace CheatEngine.Mcp.Tests.LiveQualification; -internal sealed record LiveQualificationInputs(string RepositoryRoot, string CheatEngineDirectory, string RunRoot); +internal sealed record LiveQualificationInputs(string RepositoryRoot, string CheatEngineDirectory, string RunRoot, + string TargetArchitecture); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs index c407c3b..4f28020 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs @@ -7,6 +7,7 @@ internal static class LiveQualificationOptIn internal const string Acknowledgement = "I_AUTHORIZE_CE77_LIVE_PROBES_ON_A_DISPOSABLE_TARGET"; internal const string CheatEngineDirectoryVariable = "CHEATENGINE_MCP_LIVE_QUALIFICATION_CE_DIRECTORY"; internal const string RunRootVariable = "CHEATENGINE_MCP_LIVE_QUALIFICATION_RUN_ROOT"; + internal const string TargetArchitectureVariable = "CHEATENGINE_MCP_LIVE_QUALIFICATION_TARGET_ARCHITECTURE"; internal const string LocalCommand = "dotnet test --project tests/CheatEngine.Mcp.Tests -c Release --filter-trait Category=LiveQualification --fail-skips on"; @@ -58,10 +59,15 @@ internal static LiveQualificationDecision Evaluate(Func variabl return Refuse( "The run root must lie outside the repository and apart from the installed Cheat Engine directory."); } + string architecture = variables(TargetArchitectureVariable) ?? "x64"; + if (architecture is not ("x64" or "x86")) + { + return Refuse($"{TargetArchitectureVariable} must be x64 or x86."); + } return new LiveQualificationDecision( new LiveQualificationInputs(Path.GetFullPath(repositoryRoot), Path.GetFullPath(source), - Path.GetFullPath(runRoot)), null); + Path.GetFullPath(runRoot), architecture), null); } internal static bool IsSameOrBelow(string path, string directory) diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs index 42c58cd..80f9f80 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs @@ -56,9 +56,34 @@ public void Evaluate_AcknowledgedWorkstation_UsesExternalRunRoot() Assert.True(decision.IsAuthorized, decision.Refusal); Assert.Equal(Path.GetFullPath(Installation), decision.Inputs!.CheatEngineDirectory); Assert.Equal(Path.GetFullPath(LocalData + "/CheatEngine.Mcp.LiveQualification/runs"), decision.Inputs.RunRoot); + Assert.Equal("x64", decision.Inputs.TargetArchitecture); Assert.False(LiveQualificationOptIn.IsSameOrBelow(decision.Inputs.RunRoot, Repository)); } + [Fact] + public void Evaluate_X86TargetArchitecture_AuthorizesTheBoundedFixtureChoice() + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.TargetArchitectureVariable] = "x86"; + + LiveQualificationDecision decision = Evaluate(variables); + + Assert.True(decision.IsAuthorized, decision.Refusal); + Assert.Equal("x86", decision.Inputs!.TargetArchitecture); + } + + [Theory] + [InlineData("X86")] + [InlineData("arm64")] + [InlineData("C:/other.exe")] + public void Evaluate_UnsupportedTargetArchitecture_Refuses(string architecture) + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.TargetArchitectureVariable] = architecture; + + Assert.False(Evaluate(variables).IsAuthorized); + } + [Theory] [InlineData("relative")] [InlineData("C:/missing")] diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxHost.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxHost.cs index 28dc7a5..92b3047 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxHost.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxHost.cs @@ -5,6 +5,11 @@ internal sealed record LiveSandboxHost( int ProcessId, int TargetProcessId, string TargetAddress, + string PointerRootAddress, + string PointerTargetAddress, + string ZeroPointerRootAddress, + string UnreadableRootAddress, string PluginPath, string InstanceId, - string Endpoint); + string Endpoint, + int TargetIncarnation = 0); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs index abbaac6..ccd95b6 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Globalization; +using System.Reflection.PortableExecutable; using System.Runtime.Versioning; using System.Text; using System.Text.Json; @@ -29,6 +30,9 @@ internal sealed class LiveSandboxSession : IAsyncDisposable private bool _passed; private CheatEngineProfile? _profile; private string? _runtimeHash; + private string? _targetHostPath; + private string? _targetPath; + private string _targetArchitecture = "x64"; private string? _source; private CheatEngineRegistryGuard? _stateGuard; private ICheatEngineUserStateScope? _userState; @@ -53,6 +57,8 @@ public string GatewayExecutablePath get; } + public string TargetArchitecture => _targetArchitecture; + public LiveSandboxHost HostA => GetHost("A"); public LiveSandboxHost HostB => GetHost("B"); @@ -231,9 +237,107 @@ public async Task StopHostAsync(string name) } } + /// Restarts only the disposable target for one still-running private CE host and publishes a fresh manifest. + public async Task RestartTargetAsync(string name) + { + if (!_hosts.TryGetValue(name, out OwnedHost? host) || host.Stopped || host.Process is null || host.Process.HasExited) + { + throw new InvalidOperationException($"Owned host '{name}' is not running."); + } + + List failures = []; + TryCleanup(() => File.WriteAllText(host.TargetManifest + ".stop", "stop"), failures); + bool previousStopped = await StopOwnedAsync(host.Target, failures); + if (previousStopped) + { + host.Target = null; + } + if (!previousStopped || failures.Count != 0) + { + throw new AggregateException($"Disposable target {name} did not stop cleanly.", failures); + } + + int incarnation = host.TargetIncarnation + 1; + string manifest = Path.Combine(_layout.RunDirectory, $"target-{name}-{incarnation}.json"); + RequireInsideRun(manifest); + Process target = StartTarget(manifest, name); + host.Target = target; + try + { + await WaitUntilAsync(() => File.Exists(manifest), target, TimeSpan.FromSeconds(10)); + LiveTargetManifest facts = ReadTargetManifest(JsonNode.Parse(await File.ReadAllTextAsync(manifest))!, target.Id, name); + host.TargetManifest = manifest; + host.TargetIncarnation = incarnation; + host.Public = host.Public with + { + TargetIncarnation = incarnation, + TargetProcessId = target.Id, + TargetAddress = facts.Address, + PointerRootAddress = facts.PointerRootAddress, + PointerTargetAddress = facts.PointerTargetAddress, + ZeroPointerRootAddress = facts.ZeroPointerRootAddress, + UnreadableRootAddress = facts.UnreadableRootAddress + }; + Record($"target_{name}_restart", new + { + incarnation, + processId = target.Id, + address = facts.Address + }); + } + catch (Exception exception) + { + List cleanupFailures = []; + bool stopped = await StopOwnedAsync(target, cleanupFailures); + if (stopped) + { + host.Target = null; + } + Record($"target_{name}_restart_failure", new + { + exception = exception.ToString(), + cleanupFailures = cleanupFailures.Select(item => item.ToString()).ToArray() + }); + if (cleanupFailures.Count != 0) + { + throw new AggregateException($"Disposable target {name} restart and cleanup failed.", [exception, .. cleanupFailures]); + } + + throw; + } + } + private async Task StartCoreAsync(LiveQualificationInputs inputs) { string repository = inputs.RepositoryRoot; + _targetArchitecture = inputs.TargetArchitecture; + _targetPath = TargetExecutablePath(repository, _targetArchitecture); + if (!File.Exists(_targetPath)) + { + throw new FileNotFoundException("Build the selected disposable live target before live qualification.", _targetPath); + } + if (_targetArchitecture == "x86") + { + _targetHostPath = X86DotnetHostPath(); + if (!File.Exists(_targetHostPath)) + { + throw new FileNotFoundException("The x86 .NET host is required for the selected disposable target.", + _targetHostPath); + } + using FileStream targetStream = File.OpenRead(_targetPath); + using PEReader targetImage = new(targetStream); + if (PortableExecutableInspector.Instance.Describe(_targetHostPath).Machine != "I386" + || targetImage.PEHeaders.CoffHeader.Machine != Machine.I386 + || targetImage.PEHeaders.CorHeader is not { } managedHeader + || (managedHeader.Flags & CorFlags.Requires32Bit) == 0) + { + throw new InvalidOperationException("The selected x86 fixture must require 32-bit execution and use an x86 .NET host."); + } + } + else if (PortableExecutableInspector.Instance.Describe(_targetPath).Machine != "Amd64") + { + throw new InvalidOperationException("The selected x64 fixture must use an x64 application host."); + } _source = inputs.CheatEngineDirectory; if (LiveQualificationOptIn.IsSameOrBelow(_layout.RunRoot, _source) || LiveQualificationOptIn.IsSameOrBelow(_source, _layout.RunRoot)) @@ -360,28 +464,19 @@ private async Task StartHostAsync(string repository, string name) string targetManifest = Path.Combine(_layout.RunDirectory, $"target-{name}.json"); string driverPath = Path.Combine(HostInstallationDirectory(name), "autorun", "zz_cheatengine_mcp_live.lua"); File.WriteAllText(driverPath, BuildDriver(stopPath, pluginPath, name)); - string targetPath = Path.Combine(repository, "artifacts", "bin", "CheatEngine.Mcp.LiveTarget", configuration, - "CheatEngine.Mcp.LiveTarget.exe"); Process? target = null; Process? host = null; OwnedHost? owned = null; try { - ProcessStartInfo targetStart = CreateStartInfo(targetPath); - targetStart.ArgumentList.Add(targetManifest); - target = Process.Start(targetStart) ?? - throw new InvalidOperationException($"Disposable target {name} did not start."); + target = StartTarget(targetManifest, name); owned = new OwnedHost(null, target, stopPath, targetManifest, - new LiveSandboxHost(name, 0, target.Id, string.Empty, pluginPath, string.Empty, string.Empty)); + new LiveSandboxHost(name, 0, target.Id, string.Empty, string.Empty, string.Empty, string.Empty, + string.Empty, pluginPath, string.Empty, string.Empty)); _hosts.Add(name, owned); await WaitUntilAsync(() => File.Exists(targetManifest), target, TimeSpan.FromSeconds(10)); JsonNode manifest = JsonNode.Parse(await File.ReadAllTextAsync(targetManifest))!; - if (manifest["processId"]!.GetValue() != target.Id) - { - throw new InvalidOperationException($"Target manifest {name} belongs to a different process."); - } - - string targetAddress = manifest["address"]!.GetValue(); + LiveTargetManifest targetFacts = ReadTargetManifest(manifest, target.Id, name); ProcessStartInfo hostStart = CreateStartInfo(Path.Combine(HostInstallationDirectory(name), _profile!.HostExecutable)); hostStart.Environment["MCP_HOST"] = "127.0.0.1"; @@ -396,7 +491,11 @@ private async Task StartHostAsync(string repository, string name) owned.Public = owned.Public with { ProcessId = host.Id, - TargetAddress = targetAddress + TargetAddress = targetFacts.Address, + PointerRootAddress = targetFacts.PointerRootAddress, + PointerTargetAddress = targetFacts.PointerTargetAddress, + ZeroPointerRootAddress = targetFacts.ZeroPointerRootAddress, + UnreadableRootAddress = targetFacts.UnreadableRootAddress }; InstanceDescriptor descriptor = await WaitForInstanceAsync(owned, TimeSpan.FromSeconds(45)); owned.Public = owned.Public with @@ -407,7 +506,9 @@ private async Task StartHostAsync(string repository, string name) Record($"target_{name}", new { processId = target.Id, - address = targetAddress + address = targetFacts.Address, + pointerRootAddress = targetFacts.PointerRootAddress, + pointerTargetAddress = targetFacts.PointerTargetAddress }); } catch (Exception exception) @@ -447,6 +548,60 @@ private async Task StartHostAsync(string repository, string name) } } + private static string TargetExecutablePath(string repository, string architecture) + { +#if DEBUG + const string configuration = "debug"; +#else + const string configuration = "release"; +#endif + return architecture == "x86" + ? Path.Combine(repository, "artifacts", "live-target-x86", "bin", "CheatEngine.Mcp.LiveTarget", configuration, + "CheatEngine.Mcp.LiveTarget.dll") + : Path.Combine(repository, "artifacts", "bin", "CheatEngine.Mcp.LiveTarget", configuration, + "CheatEngine.Mcp.LiveTarget.exe"); + } + + private static string X86DotnetHostPath() => + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "dotnet", "dotnet.exe"); + + private Process StartTarget(string manifest, string name) + { + string target = _targetPath ?? throw new InvalidOperationException("Live target path was not prepared."); + ProcessStartInfo start; + if (_targetArchitecture == "x86") + { + start = CreateStartInfo(_targetHostPath ?? + throw new InvalidOperationException("The x86 .NET host was not prepared.")); + start.ArgumentList.Add(target); + } + else + { + start = CreateStartInfo(target); + } + + start.ArgumentList.Add(manifest); + return Process.Start(start) ?? throw new InvalidOperationException($"Disposable target {name} did not start."); + } + + private LiveTargetManifest ReadTargetManifest(JsonNode manifest, int processId, string name) + { + if (manifest["processId"]!.GetValue() != processId) + { + throw new InvalidOperationException($"Target manifest {name} belongs to a different process."); + } + + int width = manifest["pointerWidth"]!.GetValue(); + if (width != (_targetArchitecture == "x86" ? 32 : 64)) + { + throw new InvalidOperationException($"Target manifest {name} reported pointer width {width}, not the requested {_targetArchitecture} width."); + } + + return new LiveTargetManifest(manifest["address"]!.GetValue(), + manifest["pointerRootAddress"]!.GetValue(), manifest["pointerTargetAddress"]!.GetValue(), + manifest["zeroPointerRootAddress"]!.GetValue(), manifest["unreadableRootAddress"]!.GetValue()); + } + private static string DistributionDirectory(string repository, string configuration) { string? custom = Environment.GetEnvironmentVariable("CHEATENGINE_MCP_LIVE_QUALIFICATION_DISTRIBUTION_DIRECTORY"); @@ -886,8 +1041,15 @@ public string StopPath public string TargetManifest { get; + set; } = targetManifest; + public int TargetIncarnation + { + get; + set; + } + public Process? Process { get; @@ -912,4 +1074,7 @@ public bool Stopped set; } } + + private sealed record LiveTargetManifest(string Address, string PointerRootAddress, string PointerTargetAddress, + string ZeroPointerRootAddress, string UnreadableRootAddress); } diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs index e7259f2..b535921 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs @@ -14,6 +14,8 @@ namespace CheatEngine.Mcp.Tests.LiveQualification; public sealed class McpLiveQualificationTests(LiveQualificationFixture fixture) { private static readonly TimeSpan McpTimeout = TimeSpan.FromSeconds(15); + private static readonly string[] SignedPointerOffsets = ["-10", "20"]; + private static readonly string[] ZeroPointerOffsets = ["0"]; [Fact] public async Task OneGateway_TwoOwnedInstances_RoutesStateAndSurvivesOneHostShutdown() @@ -139,6 +141,12 @@ await SuccessfulCallAsync(instanceA, CheatEngineToolNames.RecordSetActive, AssertMemoryValue(await ReadMemoryAsync(instanceB, sandbox.HostB.TargetAddress), 20260926); Assert.Empty((await SuccessfulCallAsync(instanceB, CheatEngineToolNames.RecordList))["records"]!.AsArray()); + step = "supplied pointer access facts"; + await AssertPointerAccessFactsAsync(sandbox, instanceA); + + step = "pointer-chain outcomes and instance isolation"; + await AssertPointerChainsAsync(sandbox, instanceA, instanceB); + step = "instance A speedhack"; JsonNode debuggerState = await SuccessfulCallAsync(instanceA, CheatEngineToolNames.DebuggerGetStatus); Assert.True(debuggerState["stateValid"]!.GetValue()); @@ -185,6 +193,21 @@ await Assert.ThrowsAsync(async () => BInstanceId = sandbox.HostB.InstanceId }); + step = "target A restart and fresh pointer chain"; + string oldRoot = sandbox.HostA.PointerRootAddress; + int oldTargetProcess = sandbox.HostA.TargetProcessId; + int oldIncarnation = sandbox.HostA.TargetIncarnation; + await sandbox.RestartTargetAsync("A"); + Assert.Equal(oldIncarnation + 1, sandbox.HostA.TargetIncarnation); + await OpenTargetAsync(instanceA, sandbox.HostA); + await AssertPointerChainsAsync(sandbox, instanceA, null); + sandbox.Record("target_a_restart_pointer_chain", new + { + oldTargetProcess, + newTargetProcess = sandbox.HostA.TargetProcessId, + rootReused = string.Equals(oldRoot, sandbox.HostA.PointerRootAddress, StringComparison.OrdinalIgnoreCase) + }); + scenarioCompleted = true; } catch (Exception exception) @@ -244,6 +267,76 @@ await Assert.ThrowsAsync(async () => } } + private static async Task AssertPointerChainsAsync(LiveSandboxSession sandbox, ILiveMcpToolClient instanceA, + ILiveMcpToolClient? instanceB) + { + JsonNode result = await SuccessfulCallAsync(instanceA, CheatEngineToolNames.PointerReadChains, + new Dictionary + { + ["candidates"] = new[] + { + new { id = "match", @base = sandbox.HostA.PointerRootAddress, offsets = SignedPointerOffsets }, + new { id = "hop", @base = sandbox.HostA.UnreadableRootAddress, offsets = ZeroPointerOffsets }, + new { id = "zero", @base = sandbox.HostA.ZeroPointerRootAddress, offsets = ZeroPointerOffsets } + }, + ["target"] = sandbox.HostA.PointerTargetAddress, + ["valueType"] = "int32" + }); + JsonArray candidates = result["candidates"]!.AsArray(); + JsonNode match = Assert.Single(candidates, item => item!["id"]!.GetValue() == "match")!; + JsonNode hop = Assert.Single(candidates, item => item!["id"]!.GetValue() == "hop")!; + JsonNode zero = Assert.Single(candidates, item => item!["id"]!.GetValue() == "zero")!; + Assert.Equal(("resolved", "match", "read", "20260926"), (match["chainStatus"]!.GetValue(), + match["comparisonStatus"]!.GetValue(), match["valueStatus"]!.GetValue(), match["value"]!.GetValue())); + Assert.Equal(("unreadable", 0), (hop["chainStatus"]!.GetValue(), hop["failedHop"]!.GetValue())); + Assert.Equal(("resolved", "0", "miss", "failed"), (zero["chainStatus"]!.GetValue(), + zero["address"]!.GetValue(), zero["comparisonStatus"]!.GetValue(), zero["valueStatus"]!.GetValue())); + Assert.Equal((3, 3, 2, 1, 1), (result["summary"]!["submitted"]!.GetValue(), + result["summary"]!["processed"]!.GetValue(), result["summary"]!["resolved"]!.GetValue(), + result["summary"]!["unreadable"]!.GetValue(), result["summary"]!["valueErrors"]!.GetValue())); + if (instanceB is not null) + { + await WriteMemoryAsync(instanceA, sandbox.HostA.PointerTargetAddress, 20260933); + JsonNode isolated = await SuccessfulCallAsync(instanceB, CheatEngineToolNames.PointerReadChains, + new Dictionary { ["candidates"] = new[] { new { id = "b-root", @base = sandbox.HostB.PointerRootAddress, offsets = SignedPointerOffsets } }, ["target"] = sandbox.HostB.PointerTargetAddress, ["valueType"] = "int32" }); + JsonNode bRoot = Assert.Single(isolated["candidates"]!.AsArray())!; + Assert.Equal(("resolved", "match", "20260926"), (bRoot["chainStatus"]!.GetValue(), bRoot["comparisonStatus"]!.GetValue(), bRoot["value"]!.GetValue())); + AssertMemoryValue(await ReadMemoryAsync(instanceB, sandbox.HostB.PointerTargetAddress), 20260926); + await WriteMemoryAsync(instanceA, sandbox.HostA.PointerTargetAddress, 20260926); + } + sandbox.Record("pointer_read_chains", result.DeepClone()); + } + + private static async Task AssertPointerAccessFactsAsync(LiveSandboxSession sandbox, ILiveMcpToolClient client) + { + JsonNode x86 = await SuccessfulCallAsync(client, CheatEngineToolNames.PointerGetAccessInfo, + new Dictionary + { + ["instructionText"] = "mov eax,[eax-10]", + ["instructionAddress"] = "401000", + ["instructionLength"] = 3, + ["architecture"] = "x86", + ["registers"] = new Dictionary { ["EAX"] = "1000" } + }); + JsonNode x64 = await SuccessfulCallAsync(client, CheatEngineToolNames.PointerGetAccessInfo, + new Dictionary + { + ["instructionText"] = "mov eax,[rax+10]", + ["instructionAddress"] = "140001000", + ["instructionLength"] = 3, + ["architecture"] = "x64", + ["registers"] = new Dictionary { ["RAX"] = "1000" } + }); + Assert.Equal(("supported", "FF0"), (x86["status"]!.GetValue(), x86["effectiveAddress"]!.GetValue())); + Assert.Equal(("supported", "1010"), (x64["status"]!.GetValue(), x64["effectiveAddress"]!.GetValue())); + sandbox.Record("pointer_get_access_info_supplied_facts", new + { + x86 = x86.DeepClone(), + x64 = x64.DeepClone(), + nativeTargetArchitecture = sandbox.TargetArchitecture + }); + } + private static async Task AssertDisassemblyAsync(LiveSandboxSession sandbox, ILiveMcpToolClient instance) { const string allocationName = "disassembly-probe"; diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaExecCSharpTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaExecCSharpTests.cs new file mode 100644 index 0000000..eacf355 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaExecCSharpTests.cs @@ -0,0 +1,92 @@ +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Features; +using CheatEngine.Mcp.Core.Lua; +using CheatEngine.Mcp.Tools.Exec; + +namespace CheatEngine.Mcp.Tests.NativeLua; + +/// Native Lua coverage for the fixed compileCS bridge; it does not qualify Cheat Engine's compiler. +public sealed partial class NativeLuaToolRuntimeTests +{ + private const string CSharpCompilerStubs = """ + compilerCalls = 0 + compilerResult = table.pack('C:\\temp\\compiled.dll') + compileCS = function(source, references, core) + compilerCalls = compilerCalls + 1 + compilerSource, compilerReferences, compilerCore = source, references, core + return table.unpack(compilerResult, 1, compilerResult.n) + end + """; + + [Fact] + public void ExecCompileCSharp_Success_PassesSourceReferencesAndOptionalCoreWithoutInjection() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(CSharpCompilerStubs); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + + ExecCSharpCompilerOutput result = dispatch.RunLua(CheatEngineToolNames.ExecCompileCSharp, + ExecCSharpScripts.Compile, ExecCSharpJsonContext.Default.ExecCSharpCompilerOutput, Token, "class C {}", + new[] { "C:\\refs\\one.dll", "C:\\refs\\two.dll" }, "C:\\core.dll"); + + Assert.Equal("C:\\temp\\compiled.dll", result.AssemblyPath); + Assert.True(result.CompilerAvailable); + Assert.Equal((1L, "class C {}", "C:\\core.dll"), + (ReadGlobal("compilerCalls"), ReadGlobal("compilerSource"), ReadGlobal("compilerCore"))); + Assert.Equal(2L, EvaluateLua("#compilerReferences")); + } + + [Theory] + [InlineData(false, "line 4: expected ;")] + [InlineData(true, "compiler bridge failed")] + public void ExecCompileCSharp_DiagnosticOrPcallFailure_ReturnsBoundedDiagnosticOutcome(bool raises, + string expected) + { + using RuntimeScope scope = CreateScope(); + InstallStubs(raises + ? CSharpCompilerStubs + "\ncompileCS = function() error('compiler bridge failed') end" + : CSharpCompilerStubs + "\ncompilerResult = table.pack(nil, 'line 4: expected ;')"); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + + ExecCSharpCompilerOutput outcome = dispatch.RunLua(CheatEngineToolNames.ExecCompileCSharp, + ExecCSharpScripts.Compile, ExecCSharpJsonContext.Default.ExecCSharpCompilerOutput, Token, "class C {}", + Array.Empty(), null); + + Assert.Null(outcome.AssemblyPath); + Assert.True(outcome.CompilerAvailable); + Assert.False(outcome.DiagnosticTruncated); + Assert.Contains(expected, outcome.Diagnostic, StringComparison.Ordinal); + } + + [Fact] + public void ExecCompileCSharp_AbsentCompilerApi_ReturnsUnavailableOutcomeBeforeAnyCompilerCall() + { + using RuntimeScope scope = CreateScope(); + InstallStubs("compileCS = nil; compilerCalls = 0"); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + + ExecCSharpCompilerOutput outcome = dispatch.RunLua(CheatEngineToolNames.ExecCompileCSharp, + ExecCSharpScripts.Compile, ExecCSharpJsonContext.Default.ExecCSharpCompilerOutput, Token, "class C {}", + Array.Empty(), null); + + Assert.False(outcome.CompilerAvailable); + Assert.Null(outcome.AssemblyPath); + Assert.Equal(0L, ReadGlobal("compilerCalls")); + } + + [Fact] + public void ExecCompileCSharp_DiagnosticBeyondSixteenKiB_IsTruncatedAndMarked() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(CSharpCompilerStubs + "\ncompilerResult = table.pack(nil, string.rep('d', 17000))"); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + + ExecCSharpCompilerOutput outcome = dispatch.RunLua(CheatEngineToolNames.ExecCompileCSharp, + ExecCSharpScripts.Compile, ExecCSharpJsonContext.Default.ExecCSharpCompilerOutput, Token, "class C {}", + Array.Empty(), null); + + Assert.Null(outcome.AssemblyPath); + Assert.Equal(16_384, outcome.Diagnostic!.Length); + Assert.True(outcome.DiagnosticTruncated); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaPointerAccessTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaPointerAccessTests.cs new file mode 100644 index 0000000..9c46d8b --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaPointerAccessTests.cs @@ -0,0 +1,276 @@ +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Features; +using CheatEngine.Mcp.Tests.Support; +using CheatEngine.Mcp.Tools.Pointer; + +namespace CheatEngine.Mcp.Tests.NativeLua; + +/// Native Lua regression coverage for fixed supplied-facts pointer-access analysis. +public sealed partial class NativeLuaToolRuntimeTests +{ + private static readonly byte[] RipBytes = [0x8B, 0x05, 0, 0, 0, 0]; + private static readonly byte[] AddressSizeOverrideBytes = [0x67, 0x8B, 0x40, 0x08]; + private static readonly string[] EcxTwo = ["ECX", "2"]; + private static readonly string[][] GameSymbol = [["GAME.EXE", "140000000"]]; + private static readonly string[][] OnlyEax = [["EAX", "1000"]]; + + [Theory] + [InlineData("mov eax,[rax+10]")] + [InlineData("mov eax,[game.exe+10]")] + public void PointerAccess_PaddedHexFacts_NormalizesMapsAndObservedAddress(string instruction) + { + using RuntimeScope scope = CreateScope(); + PointerAccessTools tools = new(CreateNativeDispatch(new McpFeatureOptions())); + + PointerAccessInfo result = tools.GetAccessInfo(instruction, " \t0x401000 ", 4, "x64", + new Dictionary { ["RAX"] = " \t0x1000 " }, + observedAccessAddress: " \t0x1010 ", + symbols: new Dictionary { ["game.exe"] = " \t0x1000 " }, cancellationToken: Token); + + Assert.Equal(PointerAccessStatus.Supported, result.Status); + Assert.Equal("1010", result.EffectiveAddress); + Assert.False(result.ObservedAddressMismatch); + } + + [Theory] + [InlineData("eip", "140001000", "40001017")] + [InlineData("eip", "FFFFFFF8", "F")] + [InlineData("rip", "140001000", "140001017")] + public void PointerAccess_TextInstructionPointer_UsesItsDeclaredAddressWidth(string register, + string address, string expected) + { + using RuntimeScope scope = CreateScope(); + PointerAccessTools tools = new(CreateNativeDispatch(new McpFeatureOptions())); + + PointerAccessInfo result = tools.GetAccessInfo($"mov eax,[{register}+10]", address, 7, "x64", + new Dictionary(), cancellationToken: Token); + + Assert.Equal(PointerAccessStatus.Supported, result.Status); + Assert.Equal(expected, result.EffectiveAddress); + Assert.Equal("10", result.Displacement); + } + + [Theory] + [InlineData("pop dword ptr [esp+10]", "x86", "ESP", "8F 44 24 10", 4)] + [InlineData("pop qword ptr [rsp+10]", "x64", "RSP", "8F 44 24 10", 4)] + [InlineData("pop word ptr [esp+10]", "x86", "ESP", "66 8F 44 24 10", 5)] + [InlineData("pop word ptr [rsp+10]", "x64", "RSP", "66 8F 44 24 10", 5)] + public void PointerAccess_StackRelativePop_RefusesIntermediateStackPointerContext(string instruction, + string architecture, string register, string bytes, int length) + { + using RuntimeScope scope = CreateScope(); + PointerAccessTools tools = new(CreateNativeDispatch(new McpFeatureOptions())); + + PointerAccessInfo result = tools.GetAccessInfo(instruction, "401000", length, architecture, + new Dictionary { [register] = "1000" }, instructionBytes: bytes, cancellationToken: Token); + + Assert.Equal(PointerAccessStatus.Unsupported, result.Status); + Assert.Null(result.EffectiveAddress); + Assert.Null(result.CandidateStructureBase); + Assert.Contains("stack pointer", result.Uncertainty, StringComparison.Ordinal); + } + + [Fact] + public void PointerAccess_SuppliedFacts_EvaluatesRipRelativeOperandWithoutLiveResolver() + { + using RuntimeScope scope = CreateScope(); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + + PointerAccessInfo result = dispatch.RunLua(CheatEngineToolNames.PointerGetAccessInfo, + PointerAccessLuaScripts.GetAccessInfo, PointerAccessJsonContext.Default.PointerAccessInfo, Token, + "mov", "eax,[rip+10]", 0x401000UL, 6L, true, RipBytes, + Array.Empty(), Array.Empty(), null, false); + + Assert.Equal(PointerAccessStatus.Supported, result.Status); + Assert.Equal("401016", result.EffectiveAddress); + Assert.Null(result.BaseRegister); + Assert.Equal("10", result.Displacement); + LuaFixedScriptAssert.NeverLoadsCode(PointerAccessLuaScripts.GetAccessInfo); + } + + [Theory] + [InlineData("bt dword ptr [eax],ecx")] + [InlineData("btc dword ptr [eax],ecx")] + [InlineData("btr dword ptr [eax],ecx")] + [InlineData("bts qword ptr [rax],rcx")] + [InlineData("lock bts dword ptr [eax],ecx")] + [InlineData("rep movsb byte ptr [rdi],byte ptr [rsi]")] + public void PointerAccess_BitStringOrPrefixedInstruction_RefusesUnsupportedAddressSemantics(string instruction) + { + using RuntimeScope scope = CreateScope(); + PointerAccessTools tools = new(CreateNativeDispatch(new McpFeatureOptions())); + + PointerAccessInfo result = tools.GetAccessInfo(instruction, "401000", 4, "x64", + new Dictionary { ["RAX"] = "1000", ["RCX"] = "20" }, cancellationToken: Token); + + Assert.Equal(PointerAccessStatus.Unsupported, result.Status); + Assert.Null(result.EffectiveAddress); + Assert.Null(result.CandidateStructureBase); + } + + [Fact] + public void PointerAccess_MissingRegister_ReturnsTypedMissingFacts() + { + using RuntimeScope scope = CreateScope(); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + + PointerAccessInfo result = dispatch.RunLua(CheatEngineToolNames.PointerGetAccessInfo, + PointerAccessLuaScripts.GetAccessInfo, PointerAccessJsonContext.Default.PointerAccessInfo, Token, + "mov", "eax,[rax+rcx*8-20]", 0x401000UL, 4L, true, Array.Empty(), + new[] { new[] { "RAX", "1000" } }, Array.Empty(), null, false); + + Assert.Equal(PointerAccessStatus.MissingFacts, result.Status); + Assert.Contains("RCX", result.Uncertainty, StringComparison.Ordinal); + } + + [Fact] + public void PointerAccess_IndexedPostExecutionContext_ReportsDynamicOffsetAndMismatch() + { + using RuntimeScope scope = CreateScope(); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + + PointerAccessInfo result = dispatch.RunLua(CheatEngineToolNames.PointerGetAccessInfo, + PointerAccessLuaScripts.GetAccessInfo, PointerAccessJsonContext.Default.PointerAccessInfo, Token, + "mov", "eax,[rax+rcx*4+10]", 0x401000UL, 4L, true, Array.Empty(), + new[] { new[] { "RAX", "1000" }, new[] { "RCX", "2" } }, Array.Empty(), "1019", true); + + Assert.Equal((true, true, true), (result.DynamicOffset, result.ContextMayHaveChanged, + result.ObservedAddressMismatch)); + Assert.Null(result.NextPointerSearchValue); + } + + [Theory] + [InlineData("eax,[eax+10]", false, "EAX", "1000", "1010")] + [InlineData("eax,[eax-10]", false, "EAX", "1000", "FF0")] + [InlineData("eax,[eax+ecx*4+10]", false, "EAX", "1000", "1018")] + [InlineData("eax,[r8d+10]", true, "R8", "1000", "1010")] + public void PointerAccess_RegisterAliasesAndSignedDisplacements_EvaluateExactly(string parameters, bool wide, + string register, string value, string expected) + { + PointerAccessInfo result = Analyze("mov", parameters, wide, [new[] { register, value }, EcxTwo]); + + Assert.Equal(PointerAccessStatus.Supported, result.Status); + Assert.Equal(expected, result.EffectiveAddress); + if (parameters.Contains("-10", StringComparison.Ordinal)) + { + Assert.Equal("-10", result.Displacement); + } + } + + [Theory] + [InlineData("mov", "eax,[missing+10]", "missing_facts")] + [InlineData("movs", "byte ptr [rdi],byte ptr [rsi]", "unsupported")] + [InlineData("ret", "", "unsupported")] + [InlineData("mov", "eax,fs:[30]", "unsupported")] + [InlineData("vgatherdps", "xmm0,[rax+xmm1*4],xmm2", "unsupported")] + public void PointerAccess_UnsupportedOrMissingFacts_ReturnTypedStatus(string opcode, string parameters, + string expected) + { + PointerAccessInfo result = Analyze(opcode, parameters, true, Array.Empty()); + + Assert.Equal(expected, result.Status.ToString().ToLowerInvariant().Replace("facts", "_facts")); + } + + [Fact] + public void PointerAccess_SuppliedSymbolMap_ResolvesWithoutLiveSymbolLookup() + { + PointerAccessInfo result = Analyze("mov", "eax,[game.exe+20]", true, Array.Empty(), + GameSymbol); + + Assert.Equal((PointerAccessStatus.Supported, "140000020"), (result.Status, result.EffectiveAddress)); + Assert.Equal("20", result.Displacement); + } + + [Fact] + public void PointerAccess_AddressSizeOverride_WrapsTheEffectiveAddressAt32Bits() + { + using RuntimeScope scope = CreateScope(); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + PointerAccessInfo result = dispatch.RunLua(CheatEngineToolNames.PointerGetAccessInfo, + PointerAccessLuaScripts.GetAccessInfo, PointerAccessJsonContext.Default.PointerAccessInfo, Token, + "mov", "eax,[rax+08]", 0x401000UL, 4L, true, AddressSizeOverrideBytes, + new[] { new[] { "RAX", "100001000" } }, Array.Empty(), null, false); + + Assert.Equal("1008", result.EffectiveAddress); + } + + [Theory] + [InlineData("RAX", "EAX", "100000001", "0x0001")] + [InlineData("R8", "R8D", "100000001", "1")] + public void PointerAccess_ConsistentAliases_PreserveUnknownUpperBits(string fullName, string narrowName, + string full, string narrow) + { + using RuntimeScope scope = CreateScope(); + PointerAccessTools tools = new(CreateNativeDispatch(new McpFeatureOptions())); + Dictionary registers = new() + { + [fullName] = full, + [narrowName] = narrow + }; + + PointerAccessInfo result = tools.GetAccessInfo($"mov eax,[{fullName}+10]", "401000", 4, "x64", + registers, cancellationToken: Token); + + Assert.Equal(PointerAccessStatus.Supported, result.Status); + Assert.Equal("100000011", result.EffectiveAddress); + Assert.Equal("100000001", result.CandidateStructureBase); + } + + [Theory] + [InlineData("RAX", "EAX")] + [InlineData("R8", "R8D")] + public void PointerAccess_ConflictingAliases_RefusesInsteadOfChoosingARegister(string fullName, string narrowName) + { + using RuntimeScope scope = CreateScope(); + PointerAccessTools tools = new(CreateNativeDispatch(new McpFeatureOptions())); + Dictionary registers = new() + { + [fullName] = "100000001", + [narrowName] = "2" + }; + + CheatEngineToolException error = Assert.Throws(() => tools.GetAccessInfo( + $"mov eax,[{fullName}+10]", "401000", 4, "x64", registers, cancellationToken: Token)); + + Assert.Equal(ToolErrorKind.InvalidArgument, error.Error.Kind); + Assert.Contains("conflicting aliases", error.Error.Message, StringComparison.Ordinal); + } + + [Fact] + public void PointerAccess_X86Overflow_WrapsAddressAndCandidateBase() + { + using RuntimeScope scope = CreateScope(); + PointerAccessTools tools = new(CreateNativeDispatch(new McpFeatureOptions())); + PointerAccessInfo result = tools.GetAccessInfo("mov eax,[eax+20]", "401000", 3, "x86", + new Dictionary { ["EAX"] = "FFFFFFF0" }, cancellationToken: Token); + + Assert.Equal("10", result.EffectiveAddress); + Assert.Equal("FFFFFFF0", result.CandidateStructureBase); + } + + [Fact] + public void PointerAccess_WideOperandWithOnlyNarrowRegister_ReturnsMissingFacts() + { + PointerAccessInfo result = Analyze("mov", "eax,[rax]", true, OnlyEax); + + Assert.Equal(PointerAccessStatus.MissingFacts, result.Status); + } + + [Fact] + public void PointerAccess_NarrowOperandWithNarrowRegister_EvaluatesTheAddress() + { + PointerAccessInfo result = Analyze("mov", "eax,[eax+10]", true, OnlyEax); + + Assert.Equal((PointerAccessStatus.Supported, "1010"), (result.Status, result.EffectiveAddress)); + } + + private static PointerAccessInfo Analyze(string opcode, string parameters, bool wide, string[][] registers, + string[][]? symbols = null) + { + using RuntimeScope scope = CreateScope(); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + return dispatch.RunLua(CheatEngineToolNames.PointerGetAccessInfo, PointerAccessLuaScripts.GetAccessInfo, + PointerAccessJsonContext.Default.PointerAccessInfo, Token, opcode, parameters, 0x401000UL, 4L, wide, + Array.Empty(), registers, symbols ?? Array.Empty(), null, false); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/Plugin/PluginBundleTests.cs b/tests/CheatEngine.Mcp.Tests/Plugin/PluginBundleTests.cs index 3b171b8..26f97e1 100644 --- a/tests/CheatEngine.Mcp.Tests/Plugin/PluginBundleTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Plugin/PluginBundleTests.cs @@ -37,6 +37,23 @@ public async Task InstalledPlugin_LoneDll_ReflectedCatalogMatchesReviewedContrac Assert.Contains("PLUGIN_BUNDLE_PROBE_OK", result.Output, StringComparison.Ordinal); } + [Theory] + [InlineData("backend-tools.json")] + [InlineData("backend-resources.json")] + [InlineData("backend-prompts.json")] + public async Task InstalledPlugin_ReviewedCatalogMismatch_RefusesBundle(string fileName) + { + await using PluginBundleFixture fixture = new(); + string goldenDirectory = fixture.CreateMismatchedGoldenDirectory(fileName); + + ProbeResult result = await fixture.RunProbeAsync(goldenDirectory); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains($"The bundled plugin's reflected catalog differs from '{fileName}'.", result.Output, + StringComparison.Ordinal); + Assert.DoesNotContain("PLUGIN_BUNDLE_PROBE_OK", result.Output, StringComparison.Ordinal); + } + private sealed class PluginBundleFixture : IAsyncDisposable { private readonly string _root = Path.Combine(RepositoryPaths.Root, "artifacts", "test-results", "plugin-bundle", @@ -64,6 +81,19 @@ public ValueTask DisposeAsync() return new ValueTask(TestDirectory.DeleteAsync(_root)); } + internal string CreateMismatchedGoldenDirectory(string fileName) + { + string source = Path.Combine(RepositoryPaths.Root, "tests", "CheatEngine.Mcp.Tests", "Contract", "Golden"); + string destination = Directory.CreateDirectory(Path.Combine(_root, "reviewed-contract")).FullName; + foreach (string snapshot in new[] { "backend-tools.json", "backend-resources.json", "backend-prompts.json" }) + { + File.Copy(Path.Combine(source, snapshot), Path.Combine(destination, snapshot)); + } + + File.WriteAllText(Path.Combine(destination, fileName), "null"); + return destination; + } + internal async Task RunProbeAsync(string? goldenDirectory = null) { string target = FindLiveTarget(); diff --git a/tests/CheatEngine.Mcp.Tests/Resources/KnowledgeResourceTests.cs b/tests/CheatEngine.Mcp.Tests/Resources/KnowledgeResourceTests.cs index 08d25cd..1af7a0f 100644 --- a/tests/CheatEngine.Mcp.Tests/Resources/KnowledgeResourceTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Resources/KnowledgeResourceTests.cs @@ -156,11 +156,12 @@ public void Docs_Sizes_StayWithinTheBudgets() foreach (string slug in CheatEngineKnowledge.DocumentSlugs) { int size = Encoding.UTF8.GetByteCount(Embedded(McpKnowledgeText.DocumentsLogicalPrefix + slug + ".md")); - // Pointer persistence and the tool inventory need small, specific allowances; other budgets stay unchanged. + // Detailed pointer, compiler, and inventory contracts need bounded per-document allowances. int budget = slug switch { "pointers" => PointerDocumentBudgetBytes, "tool-map" => ToolMapDocumentBudgetBytes, + "mono-and-dotnet" => DocumentBudgetBytes + 1024, _ => DocumentBudgetBytes }; Assert.True(size <= budget, $"{slug} is {size} bytes; the budget is {budget}."); diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Exec/ExecCSharpToolsTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Exec/ExecCSharpToolsTests.cs new file mode 100644 index 0000000..0bb15fc --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/Tools/Exec/ExecCSharpToolsTests.cs @@ -0,0 +1,273 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Text.Json.Serialization.Metadata; + +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Features; +using CheatEngine.Mcp.Core.Files; +using CheatEngine.Mcp.Core.Lua; +using CheatEngine.Mcp.Tests.Core; +using CheatEngine.Mcp.Tests.Support; +using CheatEngine.Mcp.Tools.Exec; + +using Microsoft.Extensions.Options; + +namespace CheatEngine.Mcp.Tests.Tools.Exec; + +/// Portable coverage for the bounded C# compiler bridge and its required immediate export. +public sealed class ExecCSharpToolsTests +{ + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public void CompileCSharp_DisabledTargetExecution_RefusesBeforeFilesOrDispatch() + { + using McpFilePathsTests.Scratch scratch = new(); + Harness harness = new(new McpFeatureOptions { EnableTargetCodeExecution = false }); + + CheatEngineToolException exception = Assert.Throws(() => harness.Tools(scratch) + .CompileCSharp("public class C {}", Path.Combine(scratch.Root, "out.dll"), cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.CapabilityDisabled, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Equal(0, harness.Dispatcher.Calls); + } + + [Theory] + [InlineData(0)] + [InlineData(131_073)] + public void CompileCSharp_SourceOutsideBound_RefusesBeforeFilesOrDispatch(int length) + { + using McpFilePathsTests.Scratch scratch = new(); + Harness harness = new(); + string source = length == 0 ? "" : new string('x', length); + + CheatEngineToolException exception = Assert.Throws(() => harness.Tools(scratch) + .CompileCSharp(source, Path.Combine(scratch.Root, "out.dll"), cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.InvalidArgument, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Equal(0, harness.Dispatcher.Calls); + } + + [Fact] + public void CompileCSharp_MoreThanThirtyTwoReferences_RefusesBeforeFilesOrDispatch() + { + using McpFilePathsTests.Scratch scratch = new(); + Harness harness = new(); + + CheatEngineToolException exception = Assert.Throws(() => harness.Tools(scratch) + .CompileCSharp("class C {}", Path.Combine(scratch.Root, "out.dll"), Enumerable.Repeat("C:\\missing.dll", 33).ToArray(), + cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.LimitExceeded, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Equal(0, harness.Dispatcher.Calls); + } + + [Fact] + public void CompileCSharp_HeldReferenceCannotChangeAndGeneratedAssemblyIsAtomicallyExported() + { + using McpFilePathsTests.Scratch scratch = new(); + string input = Path.Combine(scratch.Root, "reference.dll"); + string generated = Path.Combine(scratch.Root, "ce-generated.dll"); + string output = Path.Combine(scratch.Root, "exported.dll"); + byte[] bytes = [1, 2, 3, 4, 5]; + File.WriteAllBytes(input, [0x4D, 0x5A]); + File.WriteAllBytes(generated, bytes); + Harness harness = new(executor: new CompilerLua(generated, () => + Assert.Throws(() => File.WriteAllBytes(input, [9])))); + + ExecCSharpCompileResult result = harness.Tools(scratch).CompileCSharp("class C {}", output, [input], + cancellationToken: Token); + + Assert.Equal(output, result.OutputPath); + Assert.Equal(bytes.Length, result.Length); + Assert.Equal(Convert.ToHexString(SHA256.HashData(bytes)), result.Sha256); + Assert.Equal(bytes, File.ReadAllBytes(output)); + Assert.True(File.Exists(generated)); + Assert.Equal(1, harness.Dispatcher.Calls); + } + + [Fact] + public void CompileCSharp_MissingGeneratedAssembly_ReportsPartialEffectWithoutPublishingOutput() + { + using McpFilePathsTests.Scratch scratch = new(); + string output = Path.Combine(scratch.Root, "exported.dll"); + Harness harness = new(executor: new CompilerLua(Path.Combine(scratch.Root, "expired.dll"))); + + CheatEngineToolException exception = Assert.Throws(() => harness.Tools(scratch) + .CompileCSharp("class C {}", output, cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.PartialEffect, ToolHostEffect.Started), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Equal("false", exception.Error.Details!.Value.GetProperty("outputPublicationMayHaveCompleted").GetRawText()); + Assert.False(File.Exists(output)); + } + + [Theory] + [InlineData("line 4: expected ;")] + [InlineData("Could not load file or assembly 'System.CodeDom'")] + public void CompileCSharp_CompilerDiagnostic_ReportsBoundedDetailsWithoutInventedCodeOrSeverity(string diagnostic) + { + using McpFilePathsTests.Scratch scratch = new(); + Harness harness = new(executor: new OutcomeLua(new ExecCSharpCompilerOutput(null, diagnostic, false))); + + CheatEngineToolException exception = Assert.Throws(() => harness.Tools(scratch) + .CompileCSharp("class C {}", Path.Combine(scratch.Root, "exported.dll"), cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.HostRefused, ToolHostEffect.Unknown), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Equal(diagnostic, exception.Error.Details!.Value.GetProperty("text").GetString()); + Assert.False(exception.Error.Details!.Value.GetProperty("truncated").GetBoolean()); + Assert.False(exception.Error.Details!.Value.TryGetProperty("code", out _)); + Assert.False(exception.Error.Details!.Value.TryGetProperty("severity", out _)); + } + + [Fact] + public void CompileCSharp_CompilerFailureWithUnremovableReservedOutput_ReportsCleanupUnconfirmed() + { + using McpFilePathsTests.Scratch scratch = new(); + string? partial = null; + Harness harness = new(executor: new OutcomeLua(new ExecCSharpCompilerOutput(null, "diagnostic"), () => + { + partial = Assert.Single(Directory.EnumerateFiles(scratch.Root, "*.partial")); + File.SetAttributes(partial, File.GetAttributes(partial) | FileAttributes.ReadOnly); + })); + try + { + CheatEngineToolException exception = Assert.Throws(() => harness.Tools(scratch) + .CompileCSharp("class C {}", Path.Combine(scratch.Root, "exported.dll"), cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.PartialEffect, ToolHostEffect.CleanupUnconfirmed), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.False(exception.Error.Details!.Value.GetProperty("outputPublicationMayHaveCompleted").GetBoolean()); + } + finally + { + if (partial is not null && File.Exists(partial)) + { + File.SetAttributes(partial, FileAttributes.Normal); + File.Delete(partial); + } + } + } + + [Fact] + public void CompileCSharp_GeneratedAssemblyBeyondExportCap_ReportsPartialEffectWithoutPublishingOutput() + { + using McpFilePathsTests.Scratch scratch = new(); + string generated = Path.Combine(scratch.Root, "too-large.dll"); + string output = Path.Combine(scratch.Root, "exported.dll"); + using (FileStream stream = File.Create(generated)) + { + stream.SetLength((16 * 1024 * 1024) + 1L); + } + + Harness harness = new(executor: new CompilerLua(generated)); + + CheatEngineToolException exception = Assert.Throws(() => harness.Tools(scratch) + .CompileCSharp("class C {}", output, cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.PartialEffect, ToolHostEffect.Started), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.False(File.Exists(output)); + } + + [Fact] + public void CompileCSharp_CancelledAfterCompilerReturn_ReportsPartialEffectWithoutPublishingOutput() + { + using McpFilePathsTests.Scratch scratch = new(); + using CancellationTokenSource cancellation = new(); + string generated = Path.Combine(scratch.Root, "ce-generated.dll"); + string output = Path.Combine(scratch.Root, "exported.dll"); + File.WriteAllBytes(generated, [1]); + Harness harness = new(executor: new CompilerLua(generated, cancellation.Cancel)); + + CheatEngineToolException exception = Assert.Throws(() => harness.Tools(scratch) + .CompileCSharp("class C {}", output, cancellationToken: cancellation.Token)); + + Assert.Equal((ToolErrorKind.PartialEffect, ToolHostEffect.Started), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.False(File.Exists(output)); + } + + [Fact] + public void CompileCSharp_ExistingOutputWithoutOverwrite_RefusesBeforeCompilerDispatch() + { + using McpFilePathsTests.Scratch scratch = new(); + string output = Path.Combine(scratch.Root, "exported.dll"); + File.WriteAllBytes(output, [8]); + Harness harness = new(executor: new CompilerLua(Path.Combine(scratch.Root, "unused.dll"))); + + CheatEngineToolException exception = Assert.Throws(() => harness.Tools(scratch) + .CompileCSharp("class C {}", output, cancellationToken: Token)); + + Assert.Equal(ToolHostEffect.NotStarted, exception.Error.HostEffect); + Assert.Equal([8], File.ReadAllBytes(output)); + Assert.Equal(0, harness.Dispatcher.Calls); + } + + [Fact] + public void CompileCSharp_DeclaresTargetCodeExecutionAndNeverLoadsCallerSource() + { + MethodInfo method = typeof(ExecCSharpTools).GetMethod(nameof(ExecCSharpTools.CompileCSharp))!; + + Assert.Contains(method.GetCustomAttributes(), + static requirement => requirement.Feature == McpFeature.TargetCodeExecution); + LuaFixedScriptAssert.NeverLoadsCode(ExecCSharpScripts.Compile); + Assert.Equal([McpFeature.TargetCodeExecution], LuaFeatureScan.Scan(ExecCSharpScripts.Compile)); + Assert.DoesNotContain("inject", ExecCSharpScripts.Compile, StringComparison.OrdinalIgnoreCase); + } + + private sealed class Harness + { + internal Harness(McpFeatureOptions? features = null, IFixedLuaExecutor? executor = null) + { + IOptions execution = Options.Create(new McpExecutionOptions()); + Executor = executor ?? UnavailableFixedLuaExecutor.Instance; + Dispatch = new ToolDispatch(ClientTestDouble.Client(Dispatcher.Dispatcher, CancellationToken.None), + new McpFeatureGate(Options.Create(features ?? new McpFeatureOptions())), execution, + new DispatchStatistics(execution), TimeProvider.System, new RecordingLogger(), Executor); + } + + internal RecordingDispatcher Dispatcher { get; } = new(); + internal IFixedLuaExecutor Executor + { + get; + } + internal ToolDispatch Dispatch + { + get; + } + + internal ExecCSharpTools Tools(McpFilePathsTests.Scratch scratch) + { + string registry = scratch.CreateFolder("registry"); + string data = scratch.CreateFolder("data"); + return new ExecCSharpTools(Dispatch, new McpFilePaths(new McpFileOptions { AllowedRoots = [scratch.Root] }, + registry, data)); + } + } + + private sealed class CompilerLua(string path, Action? duringExecution = null) : IFixedLuaExecutor + { + public LuaJsonResult Execute(string operation, string source, JsonTypeInfo resultType, + LuaJsonBufferPool buffers, LuaOpaqueValueHandling opaque, CancellationToken cancellationToken) + { + Assert.Equal(CheatEngineToolNames.ExecCompileCSharp, operation); + duringExecution?.Invoke(); + return new LuaJsonResult((T) (object) new ExecCSharpCompilerOutput(path), null, 0); + } + } + + private sealed class OutcomeLua(ExecCSharpCompilerOutput outcome, Action? duringExecution = null) : IFixedLuaExecutor + { + public LuaJsonResult Execute(string operation, string source, JsonTypeInfo resultType, + LuaJsonBufferPool buffers, LuaOpaqueValueHandling opaque, CancellationToken cancellationToken) + { + duringExecution?.Invoke(); + return new LuaJsonResult((T) (object) outcome, null, 0); + } + } +} diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerAccessToolTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerAccessToolTests.cs new file mode 100644 index 0000000..67771af --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerAccessToolTests.cs @@ -0,0 +1,53 @@ +using System.Text.Json; + +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Tests.Support; + +using ModelContextProtocol.Protocol; + +namespace CheatEngine.Mcp.Tests.Tools.Pointer; + +/// Portable input validation for pointer_get_access_info. +public sealed class PointerAccessToolTests +{ + [Theory] + [InlineData("", "401000", 4, "x64", ToolErrorKind.InvalidArgument, "instructionText")] + [InlineData("mov eax,[rax]", "nope", 4, "x64", ToolErrorKind.InvalidArgument, "instructionAddress")] + [InlineData("mov eax,[rax]", "401000", 0, "x64", ToolErrorKind.InvalidArgument, "instructionLength")] + [InlineData("mov eax,[rax]", "401000", 4, "arm64", ToolErrorKind.InvalidArgument, "architecture")] + public async Task GetAccessInfo_InvalidManagedInput_RefusesBeforeDispatch(string text, string address, int length, + string architecture, ToolErrorKind kind, string parameter) + { + await using PointerFixture fixture = new(); + await using TestMcpPipeline pipeline = await fixture.StartPipelineAsync(); + string arguments = JsonSerializer.Serialize(new + { + instructionText = text, + instructionAddress = address, + instructionLength = length, + architecture, + registers = new Dictionary() + }); + + ToolError error = TestMcpPipeline.AssertError( + await pipeline.CallAsync(CheatEngineToolNames.PointerGetAccessInfo, arguments), kind); + + Assert.Equal(parameter, error.Details!.Value.GetProperty("parameter").GetString()); + Assert.Equal(0, fixture.Dispatches); + } + + [Fact] + public async Task GetAccessInfo_ContradictoryBytesLength_ReturnsTypedResultBeforeDispatch() + { + await using PointerFixture fixture = new(); + await using TestMcpPipeline pipeline = await fixture.StartPipelineAsync(); + + CallToolResult result = await pipeline.CallAsync(CheatEngineToolNames.PointerGetAccessInfo, + """{"instructionText":"mov eax,[rax]","instructionAddress":"401000","instructionLength":4,"architecture":"x64","instructionBytes":"48 8B","registers":{"RAX":"1000"}}"""); + + Assert.NotEqual(true, result.IsError); + JsonElement content = Assert.IsType(result.StructuredContent); + Assert.Equal("contradictory_facts", content.GetProperty("status").GetString()); + Assert.Equal(0, fixture.Dispatches); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerBatchToolTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerBatchToolTests.cs new file mode 100644 index 0000000..003e3a5 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerBatchToolTests.cs @@ -0,0 +1,251 @@ +using System.Globalization; + +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Values; +using CheatEngine.Mcp.Tools.Pointer; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Mcp.Tests.Tools.Pointer; + +/// pointer_read_chains: bounded independent supplied-chain validation. +public sealed class PointerBatchToolTests +{ + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public async Task ReadChains_X64SignedOffsetsAndComparison_ReportsStableCandidateOutcomes() + { + await using PointerFixture fixture = new(); + PointerBatchTools batch = Tools(fixture); + + PointerChainBatchResult result = batch.ReadChains( + [ + new PointerChainCandidate("negative", "game.exe+108", ["-30", "20"]), + new PointerChainCandidate("miss", "game.exe+100", ["10", "0"]) + ], "21020", McpValueType.Int32, cancellationToken: Token); + + Assert.Collection(result.Candidates, + match => + { + Assert.Equal("negative", match.Id); + Assert.Equal(PointerChainResolutionStatus.Resolved, match.ChainStatus); + Assert.Equal("21020", match.Address); + Assert.Equal(PointerChainComparisonStatus.Match, match.ComparisonStatus); + Assert.Equal(PointerChainValueStatus.Read, match.ValueStatus); + Assert.Equal("1234", match.Value); + }, + miss => + { + Assert.Equal("miss", miss.Id); + Assert.Equal(PointerChainResolutionStatus.Resolved, miss.ChainStatus); + Assert.Equal(PointerChainComparisonStatus.Miss, miss.ComparisonStatus); + }); + Assert.Equal(2, result.Summary.Processed); + Assert.Equal(1, result.Summary.Matches); + Assert.Equal(1, result.Summary.Misses); + Assert.Equal(2, fixture.Dispatches); + } + + [Fact] + public async Task ReadChains_X86_UsesFourBytePointers() + { + await using PointerFixture fixture = new(pointerSize: PointerSize.Bit32); + + PointerChainBatchResult result = Tools(fixture).ReadChains( + [new PointerChainCandidate("x86", "game.exe+100", ["10", "20"])], "21020", cancellationToken: Token); + + PointerChainCandidateResult candidate = Assert.Single(result.Candidates); + Assert.Equal(PointerChainResolutionStatus.Resolved, candidate.ChainStatus); + Assert.Equal("21020", candidate.Address); + Assert.Equal(PointerChainComparisonStatus.Match, candidate.ComparisonStatus); + + PointerChainBatchResult maximumAddress = Tools(fixture).ReadChains( + [new PointerChainCandidate("maximum", "game.exe+100", ["10", "20"])], "FFFFFFFF", cancellationToken: Token); + Assert.Equal(PointerChainComparisonStatus.Miss, Assert.Single(maximumAddress.Candidates).ComparisonStatus); + + CheatEngineToolException tooWide = Assert.Throws(() => Tools(fixture).ReadChains( + [new PointerChainCandidate("wide", "game.exe+100", ["10", "20"])], "100000000", cancellationToken: Token)); + Assert.Equal(ToolErrorKind.InvalidArgument, tooWide.Error.Kind); + + PointerChainCandidateResult wideRoot = Assert.Single(Tools(fixture).ReadChains( + [new PointerChainCandidate("wide-root", "100000000", ["0"])], cancellationToken: Token).Candidates); + Assert.Equal(PointerChainResolutionStatus.Error, wideRoot.ChainStatus); + Assert.Equal(ToolErrorKind.InvalidArgument, wideRoot.ChainError!.Kind); + } + + [Fact] + public async Task ReadChains_UnreadableHopAndValueReadFailure_AreIndependentStatuses() + { + await using PointerFixture fixture = new(); + fixture.UnreadableFrom = PointerFixture.Target; + + PointerChainBatchResult result = Tools(fixture).ReadChains( + [ + new PointerChainCandidate("value", "game.exe+100", ["10", "20"]), + new PointerChainCandidate("hop", "60000", ["0"]) + ], "21020", McpValueType.Int32, cancellationToken: Token); + + PointerChainCandidateResult value = result.Candidates[0]; + Assert.Equal(PointerChainResolutionStatus.Resolved, value.ChainStatus); + Assert.Equal("21020", value.Address); + Assert.Equal(PointerChainComparisonStatus.Match, value.ComparisonStatus); + Assert.Equal(PointerChainValueStatus.Failed, value.ValueStatus); + Assert.NotNull(value.ValueError); + + PointerChainCandidateResult hop = result.Candidates[1]; + Assert.Equal(PointerChainResolutionStatus.Unreadable, hop.ChainStatus); + Assert.Equal(0, hop.FailedHop); + Assert.Equal("60000", hop.FailedReadAt); + Assert.Equal(PointerChainComparisonStatus.NotResolved, hop.ComparisonStatus); + Assert.Equal(PointerChainValueStatus.NotAttempted, hop.ValueStatus); + Assert.Equal(1, result.Summary.Unreadable); + Assert.Equal(1, result.Summary.ValueErrors); + } + + [Fact] + public async Task ReadChains_MatchesOnly_RetainsErrorSummary() + { + await using PointerFixture fixture = new(); + + PointerChainBatchResult result = Tools(fixture).ReadChains( + [ + new PointerChainCandidate("match", "game.exe+100", ["10", "20"]), + new PointerChainCandidate("unreadable", "60000", ["0"]), + new PointerChainCandidate("miss", "game.exe+100", ["10", "0"]) + ], "21020", matchesOnly: true, cancellationToken: Token); + + PointerChainCandidateResult candidate = Assert.Single(result.Candidates); + Assert.Equal("match", candidate.Id); + Assert.Equal(3, result.Summary.Processed); + Assert.Equal(1, result.Summary.Matches); + Assert.Equal(1, result.Summary.Misses); + Assert.Equal(1, result.Summary.Unreadable); + } + + [Fact] + public async Task ReadChains_Bounds_RefuseBeforeDispatch() + { + await using PointerFixture fixture = new(); + PointerChainCandidate[] tooMany = [.. Enumerable.Range(0, PointerBatchTools.MaximumCandidates + 1) + .Select(index => new PointerChainCandidate(index.ToString(CultureInfo.InvariantCulture), "game.exe+100", ["0"]))]; + + CheatEngineToolException candidates = Assert.Throws(() => + Tools(fixture).ReadChains(tooMany, cancellationToken: Token)); + CheatEngineToolException offsets = Assert.Throws(() => Tools(fixture).ReadChains( + [new PointerChainCandidate("deep", "game.exe+100", [.. Enumerable.Repeat("0", 65)])], cancellationToken: Token)); + CheatEngineToolException values = Assert.Throws(() => Tools(fixture).ReadChains( + [ + new PointerChainCandidate("first", "game.exe+100", ["10"]), + new PointerChainCandidate("second", "game.exe+100", ["10"]) + ], valueType: McpValueType.Bytes, length: McpValueCodec.MaxLength, cancellationToken: Token)); + + Assert.Equal(ToolErrorKind.LimitExceeded, candidates.Error.Kind); + Assert.Equal(ToolErrorKind.LimitExceeded, offsets.Error.Kind); + Assert.Equal(ToolErrorKind.LimitExceeded, values.Error.Kind); + Assert.Equal(0, fixture.Dispatches); + } + + [Fact] + public async Task ReadChains_TargetChangesMidChain_RefusesReplacementTargetResults() + { + await using PointerFixture fixture = new(); + int reads = 0; + fixture.OnCall = call => + { + if (call == "Memory.TryReadPrimitive" && Interlocked.Increment(ref reads) == 1) + { + fixture.ChangeTarget(); + } + }; + PointerChainCandidate[] candidates = + [ + new PointerChainCandidate("first", "game.exe+100", ["10", "20"]), + new PointerChainCandidate("replacement-must-not-run", "game.exe+100", ["10", "20"]) + ]; + + CheatEngineToolException error = Assert.Throws(() => + Tools(fixture).ReadChains(candidates, cancellationToken: Token)); + + Assert.Equal(ToolErrorKind.TargetChanged, error.Error.Kind); + Assert.Equal(1, reads); + Assert.Equal(2, fixture.Dispatches); + } + + [Fact] + public async Task ReadChains_SlicesDeepCandidatesAtTheReadBudgetWithoutReordering() + { + await using PointerFixture fixture = new(); + fixture.PutPointer(PointerFixture.ObjectA, PointerFixture.ObjectA); + List readsByCompletedDispatch = []; + int priorReads = 0; + fixture.OnDispatch = () => + { + if (fixture.Dispatches > 2) + { + readsByCompletedDispatch.Add(fixture.PrimitiveReads - priorReads); + } + + priorReads = fixture.PrimitiveReads; + }; + PointerChainCandidate[] candidates = [.. Enumerable.Range(0, PointerBatchTools.CandidatesPerDispatch) + .Select(index => new PointerChainCandidate($"candidate-{index}", "game.exe+100", + [.. Enumerable.Repeat("0", PointerChainTools.MaximumOffsets)]))]; + + PointerChainBatchResult result = Tools(fixture).ReadChains(candidates, cancellationToken: Token); + readsByCompletedDispatch.Add(fixture.PrimitiveReads - priorReads); + + Assert.Equal(PointerBatchTools.CandidatesPerDispatch, result.Summary.Processed); + Assert.Equal(candidates.Select(static candidate => candidate.Id), result.Candidates.Select(static candidate => candidate.Id)); + Assert.All(readsByCompletedDispatch, reads => Assert.InRange(reads, 1, PointerBatchTools.MaximumReadsPerDispatch)); + Assert.Equal(PointerBatchTools.CandidatesPerDispatch * PointerChainTools.MaximumOffsets, fixture.PrimitiveReads); + } + + [Fact] + public async Task ReadChains_PrimitiveTargetChangedFailure_AbortsWithoutCandidateResults() + { + await using PointerFixture fixture = new(); + fixture.PrimitiveReadFailureKind = CheatEngineFailureKind.TargetChanged; + + CheatEngineToolException error = Assert.Throws(() => Tools(fixture).ReadChains( + [ + new PointerChainCandidate("first", "game.exe+100", ["10", "20"]), + new PointerChainCandidate("second", "game.exe+100", ["10", "20"]) + ], cancellationToken: Token)); + + Assert.Equal(ToolErrorKind.TargetChanged, error.Error.Kind); + Assert.Equal(1, fixture.PrimitiveReads); + } + + [Fact] + public async Task ReadChains_Cancellation_ReturnsHonestPartialProgress() + { + await using PointerFixture fixture = new(); + using CancellationTokenSource cancellation = new(); + int reads = 0; + fixture.OnCall = call => + { + if (call == "Memory.TryReadPrimitive" && Interlocked.Increment(ref reads) == 3) + { + cancellation.Cancel(); + } + }; + + PointerChainBatchResult result = Tools(fixture).ReadChains( + [ + new PointerChainCandidate("first", "game.exe+100", ["10", "20"]), + new PointerChainCandidate("second", "game.exe+100", ["10", "20"]) + ], cancellationToken: cancellation.Token); + + Assert.Equal("first", Assert.Single(result.Candidates).Id); + Assert.True(result.Summary.Cancelled); + Assert.Equal(1, result.Summary.Processed); + Assert.Equal(2, result.Summary.Submitted); + } + + private static PointerBatchTools Tools(PointerFixture fixture) + { + return (PointerBatchTools) fixture.Targets.Get(typeof(PointerBatchTools)); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFixture.cs b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFixture.cs index 7ee340e..fefa495 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFixture.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFixture.cs @@ -56,6 +56,7 @@ internal sealed class PointerFixture : IAsyncDisposable private readonly ServiceProvider _root; private readonly AsyncServiceScope _scope; private int _dispatches; + private int _primitiveReads; private long _epoch = 1; internal PointerFixture(McpExecutionOptions? options = null, ulong? largeRegionSize = null, @@ -91,6 +92,7 @@ internal PointerFixture(McpExecutionOptions? options = null, ulong? largeRegionS if (method.Name.StartsWith("Invoke", StringComparison.Ordinal) && arguments?[0] is Delegate callback) { Interlocked.Increment(ref _dispatches); + OnDispatch?.Invoke(); return callback.DynamicInvoke(); } @@ -167,6 +169,16 @@ internal McpPrimitiveTargets Targets /// How many dispatches were requested. internal int Dispatches => Volatile.Read(ref _dispatches); + /// How many pointer-sized primitive reads the Client received. + internal int PrimitiveReads => Volatile.Read(ref _primitiveReads); + + /// Runs immediately before a Client dispatch callback executes. + internal Action? OnDispatch + { + get; + set; + } + /// Every Client member called, in order, as Service.Member. internal IReadOnlyCollection Calls => _calls; @@ -198,6 +210,13 @@ internal int? PrimitiveBatchReadFailureIndex set; } + /// When set, every pointer-sized primitive read returns this failure without reading memory. + internal CheatEngineFailureKind? PrimitiveReadFailureKind + { + get; + set; + } + /// How many modules inspection returns, including the main module. internal int ModuleCount { @@ -371,6 +390,15 @@ private ImmutableArray Modules() } case nameof(IMemoryClient.TryReadPrimitive) when method.GetGenericArguments()[0] == typeof(Address): { + Interlocked.Increment(ref _primitiveReads); + if (PrimitiveReadFailureKind is { } failureKind) + { + arguments![1] = default(Address); + arguments[2] = new CheatEngineFailure(failureKind, "Memory.ReadPrimitive", failureKind.ToString(), + hostEffect: CheatEngineHostEffect.NotStarted); + return false; + } + byte[] bytes = Read(((Address) arguments![0]!).ToUInt64(), _pointerWidth); arguments[1] = bytes.Length == _pointerWidth ? new Address(_pointerWidth == 8 From c440c56ef89fd59ae6d811d5a38eb24322a1ad14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Thu, 8 Oct 2026 01:17:24 +0200 Subject: [PATCH 02/12] Add separately authorized native C# compiler qualification --- .../v2.0.0/compiler-probe-policy.md | 22 +- docs/qualification/v2.0.0/local-checkpoint.md | 9 +- .../LiveQualification/ILiveMcpToolClient.cs | 3 + .../LiveQualification/LiveCompilerBridge.cs | 53 +++ .../LiveCompilerCandidate.cs | 126 ++++++ .../LiveQualification/LiveCompilerPayload.cs | 32 ++ .../LiveCompilerQualification.cs | 385 ++++++++++++++++++ .../LiveQualification/LiveMcpClient.cs | 20 +- .../LiveMcpInstanceClient.cs | 10 +- .../LiveQualification/LiveMcpToolResult.cs | 6 + .../LiveQualificationInputs.cs | 2 +- .../LiveQualificationOptIn.cs | 50 ++- .../LiveQualificationOptInTests.cs | 74 ++++ .../LiveQualificationScenario.cs | 8 + .../LiveQualification/LiveSandboxOptions.cs | 10 + .../LiveQualification/LiveSandboxSession.cs | 191 ++++++++- .../McpLiveQualificationTests.cs | 5 + tests/CheatEngine.Mcp.Tests/README.md | 2 + 18 files changed, 981 insertions(+), 27 deletions(-) create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerCandidate.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerPayload.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpToolResult.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs diff --git a/docs/qualification/v2.0.0/compiler-probe-policy.md b/docs/qualification/v2.0.0/compiler-probe-policy.md index ffd48c9..996aab8 100644 --- a/docs/qualification/v2.0.0/compiler-probe-policy.md +++ b/docs/qualification/v2.0.0/compiler-probe-policy.md @@ -21,8 +21,9 @@ An injection phase is a separate exception within this policy and remains disabl The runner must continue to require the existing live acknowledgement and every existing VL safety check. The runner must also require a second exact acknowledgement dedicated to this exception. -The proposed variable is `CHEATENGINE_MCP_LIVE_CODE_EXECUTION_QUALIFICATION`. -The proposed value is `I_AUTHORIZE_FIXED_COMPILECS_PROBES_ON_PRIVATE_CE_AND_OWNED_TARGETS`. +The variable is `CHEATENGINE_MCP_LIVE_CODE_EXECUTION_QUALIFICATION`. +The value is `I_AUTHORIZE_FIXED_COMPILECS_PROBES_ON_PRIVATE_CE_AND_OWNED_TARGETS`. +The runner must also select `CHEATENGINE_MCP_LIVE_QUALIFICATION_SCENARIO=compiler`; ordinary smoke qualification remains the default. The second acknowledgement must be absent by default, refused in CI, and evaluated before a CE process, target, run directory, or user-state backup is created. The run report must record that both acknowledgements were present without recording their environment blocks or any discovery token. Approval applies to one reviewed candidate commit, package hash, policy revision, fixed payload hash set, and operator session. @@ -31,6 +32,7 @@ Changing any of those facts requires a new review before another run. ## Fixed compiler payloads The valid payload is the following exact source with UTF-8 encoding and LF line endings inside the compiler argument. +It has no trailing newline. ```csharp public static class CheatEngineMcpCompileCsProbe @@ -61,13 +63,15 @@ The runner must compare the runtime payload hashes with the reviewed expected ha - The existing registry and application-data backup guard is armed before either private CE copy starts. - Private CE copies A and B use only the candidate plugin copy, one shared private instance registry, and the candidate gateway copy. - Owned disposable managed fixtures exist for x86 and x64 and contain no input, window, network listener, persistent state, or unrelated workload. -- Each fixture loads the runtime required by `exec_inject_dotnet`, publishes only its process identity and bounded stop marker, and exits on that marker or its deadline. +- Each fixture publishes its owned process identity and bounded stop marker and exits on that marker or its deadline; the runtime required by `exec_inject_dotnet` is a separate prerequisite of optional phase 5. - The compiler prerequisite inventory is read-only and records whether CE exposes `compileCS` and which compiler or framework dependency CE reports. - No dependency, framework, compiler, certificate, registry value, driver, or runtime is installed or changed during the run. ## Run-owned paths and configuration Each host receives a distinct `TEMP` and `TMP` directory below its live run directory before process start. +Refuse compiler admission when Cheat Engine's saved `Don't use tempdir` setting is enabled, because its `Scanfolder` value can override those environment variables. +Do not alter that setting to admit a run. Host A uses `/compiler/A/temp`, `/compiler/A/references`, and `/compiler/A/output`. Host B uses `/compiler/B/temp`, `/compiler/B/references`, and `/compiler/B/output`. The fixed invalid-reference file is created only below host A's reference directory. @@ -123,6 +127,10 @@ Only the gateway's local stdio and authenticated loopback backend connections ar 8. Repeat the missing and invalid cases for `coreAssembly` only when the supported CE signature accepts that optional argument. 9. Stop the phase if CE loads or executes an invalid reference, publishes an output after a reported failure, or changes a file outside host A's run-owned roots. +Expected compiler diagnostics currently report `host_refused` with `hostEffect=unknown`, because CE may have created temporary compiler files before failing. +Require that exact bounded error shape for invalid compiler inputs, record the changed owned temporary files, and prove that no destination or protected partial output was published. +This expected failure case does not authorize continuing after a timeout, lost response, unexpected error shape, or unaccounted file effect. + ## Phase 4: raw CE temporary output lifetime This phase uses one reviewed qualification-only fixed bridge that calls `compileCS` with the valid payload and returns only the generated filename. @@ -134,7 +142,7 @@ The bridge must not inject, load, invoke, delete, rename, or copy the generated 3. Stop immediately if the raw path is outside A's run-owned temp directory, and do not read, delete, or otherwise modify an out-of-scope path. 4. Record the raw file's length and SHA-256 through a read-only held handle. 5. Keep the exported `/compiler/A/output/probe.dll` from phase 2 and record its length and SHA-256 again. -6. Stop private host B normally while A and both owned fixtures remain under harness control. +6. Stop private host B and its owned fixture normally while A and A's owned fixture remain under harness control. 7. Wait for B to exit and for a fixed two-second filesystem quiet interval. 8. Record whether the raw A artifact exists, and record its length and hash again only when it still exists. 9. Require the exported artifact to exist with its original length and hash after B exits. @@ -148,6 +156,7 @@ The bridge must not inject, load, invoke, delete, rename, or copy the generated This sequence distinguishes raw CE expiry from export retention because the raw file and exported file have separate paths, hashes, observations, and ownership. No missing raw file may be reported as a missing exported file. No retained exported file may be used to claim that CE's raw file survived. +Because A and B use distinct temporary directories, this sequence qualifies that isolated configuration only; it does not establish cleanup behavior when CE instances share a temporary directory. ## Optional phase 5: separate managed injection @@ -181,8 +190,9 @@ It must not record bearer tokens, discovery-record bodies, absolute personal pat Stop before launch when approval, either acknowledgement, a required gate, a candidate hash, a payload hash, or a fixture identity is missing or different. Stop before the compiler call when the source installation, reviewed autorun set, private copy, backup guard, run roots, or process inventory fails validation. -Stop during the run on any kernel, DBVM, debugger, Auto Assembler, unsafe-Lua, external-network, shell, process-launch, or non-fixture target activity. -Stop on an out-of-root raw path, cross-instance effect, cross-target effect, unexpected file, unbounded diagnostic, crash, hang, timeout, lost response, unknown mutation, or false success. +Stop during the run on any kernel, DBVM, debugger, Auto Assembler, unsafe-Lua, external-network, shell, unexpected process-launch, or non-fixture target activity. +The reviewed CodeDOM compiler backend may start its installed C# compiler as part of compilation; this exception does not permit source-controlled process launches or execution of the generated assembly. +Stop on an out-of-root raw path, cross-instance effect, cross-target effect, unexpected file, unbounded diagnostic, crash, hang, timeout, lost response, unexpected or unaccounted mutation, or false success. Stop on failure to withdraw a stopped instance, stop an owned process, restore user state, preserve the source installation, or account for an owned output. After a stop, do not retry a compile or injection automatically. diff --git a/docs/qualification/v2.0.0/local-checkpoint.md b/docs/qualification/v2.0.0/local-checkpoint.md index 819901f..3a2103b 100644 --- a/docs/qualification/v2.0.0/local-checkpoint.md +++ b/docs/qualification/v2.0.0/local-checkpoint.md @@ -1,10 +1,10 @@ # Local roadmap implementation checkpoint Date: 2026-10-08. -Status: Working-tree evidence, not a stable release or a clean-commit qualification. +Status: Historical working-tree evidence, not a stable release or a clean-commit qualification. -The branch is `codex/roadmap-v2`, based on `c51a0ec3af373c852cda062a91888ad94fa0cce9`, with uncommitted implementation changes. -The binaries still identify as `2.0.0-beta.2` and embed that base commit; that identity does not identify the modified source tree. +These checks ran on `codex/roadmap-v2`, based on `c51a0ec3af373c852cda062a91888ad94fa0cce9`, before the implementation was committed locally as `b0e42752958b6b2b861d56f4e87b0d48d48d0514`. +The tested binaries identify as `2.0.0-beta.2` and embed the earlier base commit; that identity does not identify the modified source tree. The package hashes below identify the actual tested bytes. A future clean candidate must repeat the final-source and final-package gates in [release-signoff.md](release-signoff.md). @@ -77,7 +77,8 @@ No native compiler, injection, debugger capture, kernel, or hypervisor probe ran ## Remaining release work -The compiler's actual availability, diagnostics, prerequisites, raw temporary-file lifetime, and separate injection remain pending the [proposed compiler policy](compiler-probe-policy.md). +At this checkpoint, the compiler's actual availability, diagnostics, prerequisites, raw temporary-file lifetime, and separate injection remained unqualified. +Compiler-only phases 1 through 4 of the [compiler policy](compiler-probe-policy.md) were subsequently approved for this session; separate injection remains unapproved. The broader workflow matrix, repeated activation recovery, measured performance baselines, two-hour soak, installation/upgrade/rollback, named MCP clients, CI and analysis, clean final source identity, version/tag change, and public release verification remain open. The completed working-tree checks do not close any of those requirements. See [release notes](release-notes.md) for the draft migration path and [release-signoff.md](release-signoff.md) for final gates. diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/ILiveMcpToolClient.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/ILiveMcpToolClient.cs index 44c4398..b5f357e 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/ILiveMcpToolClient.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/ILiveMcpToolClient.cs @@ -5,4 +5,7 @@ namespace CheatEngine.Mcp.Tests.LiveQualification; internal interface ILiveMcpToolClient { public Task CallToolAsync(string name, IReadOnlyDictionary? arguments = null); + + public Task CallToolRawAsync(string name, + IReadOnlyDictionary? arguments = null); } diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs new file mode 100644 index 0000000..c7a4742 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs @@ -0,0 +1,53 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Reviewed fixed autorun bridge for native compiler qualification; it accepts no caller source or paths. +internal static class LiveCompilerBridge +{ + internal static readonly string Template = """ + local compilerReceipts = 0 + local originalCompileCS = compileCS + if type(originalCompileCS) == 'function' then + compileCS = function(...) + compilerReceipts = compilerReceipts + 1 + return originalCompileCS(...) + end + end + local function compilerProbe() + local request=io.open(__REQUEST__,'r') + if not request then return end + local command=request:read('*a'); request:close(); os.remove(__REQUEST__) + if command~='status' and command~='compile' then return end + local available=type(compileCS)=='function' + local value='' + if command=='compile' and available then + local called,filename,message=pcall(compileCS,__SOURCE__,{},nil) + if called and type(filename)=='string' and #filename>0 then value=filename + else available=false; value=tostring(called and message or filename) end + end + local response=assert(io.open(__RESPONSE__..'.tmp','w')) + response:write('ok\n'..tostring(available)..'\n'..tostring(compilerReceipts)..'\n'..value); response:close() + os.rename(__RESPONSE__..'.tmp',__RESPONSE__) + end + """.ReplaceLineEndings("\n").TrimEnd('\n'); + internal const string TemplateSha256 = "A5B0280CE702D720CD496E5700CE30AD92FFD362EB825A60265A02B6682DA7F2"; + + internal static void RequireReviewedHash() + { + string actual = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(Template))); + if (!string.Equals(actual, TemplateSha256, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The fixed compiler bridge template differs from its reviewed hash."); + } + } + + internal static string Render(string requestPath, string responsePath, string source, Func quote) + { + ArgumentNullException.ThrowIfNull(quote); + return Template.Replace("__REQUEST__", quote(requestPath), StringComparison.Ordinal) + .Replace("__RESPONSE__", quote(responsePath), StringComparison.Ordinal) + .Replace("__SOURCE__", quote(source), StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerCandidate.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerCandidate.cs new file mode 100644 index 0000000..a8d10fe --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerCandidate.cs @@ -0,0 +1,126 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Text.RegularExpressions; + +using CheatEngine.Mcp.Tests.LiveQualification.Infrastructure; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Read-only reviewed-candidate checks performed before a compiler run creates its sandbox. +[SupportedOSPlatform("windows")] +internal static partial class LiveCompilerCandidate +{ + private static readonly TimeSpan GitTimeout = TimeSpan.FromSeconds(5); + + internal static object Verify(LiveQualificationInputs inputs) + { + RequireReleaseBuild(); + RequireNoReparseAncestors(inputs.RunRoot); + LiveCompilerPayload.RequireReviewedHashes(); + string commit = Git(inputs.RepositoryRoot, "rev-parse", "HEAD").Trim(); + if (!FullCommit().IsMatch(commit) || !string.IsNullOrWhiteSpace(Git(inputs.RepositoryRoot, "status", "--porcelain"))) + { + throw new InvalidOperationException("Compiler qualification requires one clean checkout at a full Git commit."); + } + + string policy = Path.Combine(inputs.RepositoryRoot, "docs", "qualification", "v2.0.0", "compiler-probe-policy.md"); + string distribution = LiveSandboxSession.DistributionDirectory(inputs.RepositoryRoot, "release"); + string plugin = Path.Combine(distribution, "CheatEngine.Mcp.dll"); + string gateway = Path.Combine(distribution, "CheatEngine.Mcp.Gateway.exe"); + if (!File.Exists(policy) || !File.Exists(plugin) || !File.Exists(gateway)) + { + throw new FileNotFoundException("Compiler qualification requires the reviewed policy and Release distribution."); + } + + string pluginVersion = RequireCandidateVersion(plugin, commit); + string gatewayVersion = RequireCandidateVersion(gateway, commit); + if (!string.Equals(pluginVersion, gatewayVersion, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The plugin DLL and gateway executable do not have the same candidate ProductVersion."); + } + return new + { + commit, + cleanTree = true, + productVersion = pluginVersion, + policySha256 = CheatEngineInstallation.Sha256(policy), + bridgeTemplateSha256 = LiveCompilerBridge.TemplateSha256, + windowsVersion = Environment.OSVersion.VersionString, + testRuntime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, + pluginSha256 = CheatEngineInstallation.Sha256(plugin), + gatewaySha256 = CheatEngineInstallation.Sha256(gateway), + payloads = new + { + valid = LiveCompilerPayload.ValidSha256, + invalidSource = LiveCompilerPayload.InvalidSourceSha256, + invalidReference = LiveCompilerPayload.InvalidReferenceSha256 + } + }; + } + + private static void RequireReleaseBuild() + { +#if DEBUG + throw new InvalidOperationException("Compiler qualification requires the Release test build and Release distribution."); +#endif + } + + internal static void RequireNoReparseAncestors(string path) + { + for (string? component = Path.GetFullPath(path); component is not null; component = Path.GetDirectoryName(component)) + { + if ((File.Exists(component) || Directory.Exists(component)) && + (File.GetAttributes(component) & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidOperationException("Compiler qualification paths must not traverse reparse points."); + } + } + } + + private static string RequireCandidateVersion(string path, string commit) + { + string? version = FileVersionInfo.GetVersionInfo(path).ProductVersion; + if (string.IsNullOrWhiteSpace(version) || !version.EndsWith($"+{commit}", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"{Path.GetFileName(path)} does not identify candidate commit {commit}."); + } + + return version; + } + + private static string Git(string workingDirectory, params string[] arguments) + { + ProcessStartInfo start = new("git") + { + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + + using Process process = Process.Start(start) ?? throw new InvalidOperationException("Git did not start for candidate verification."); + Task output = process.StandardOutput.ReadToEndAsync(); + Task error = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit((int) GitTimeout.TotalMilliseconds)) + { + process.Kill(true); + process.WaitForExit(); + throw new TimeoutException("Git candidate verification exceeded its bounded timeout."); + } + + Task.WaitAll(output, error); + if (process.ExitCode != 0) + { + throw new InvalidOperationException("Git candidate verification did not complete successfully."); + } + + return output.Result; + } + + [GeneratedRegex("^[0-9a-f]{40}$", RegexOptions.CultureInvariant)] + private static partial Regex FullCommit(); +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerPayload.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerPayload.cs new file mode 100644 index 0000000..8f0bc04 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerPayload.cs @@ -0,0 +1,32 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Reviewed fixed compiler inputs; no live scenario accepts caller-supplied source or references. +internal static class LiveCompilerPayload +{ + internal const string ValidSource = "public static class CheatEngineMcpCompileCsProbe\n" + + "{\n" + + " public static int Run(string value)\n" + + " {\n" + + " return value == \"CEMCP-COMPILECS-PROBE-V1\" ? 7319 : -7319;\n" + + " }\n" + + "}"; + internal const string InvalidSource = "public static class CheatEngineMcpCompileCsProbe {"; + internal const string InvalidReference = "CEMCP-NOT-AN-ASSEMBLY"; + internal const string ValidSha256 = "C41BCC0E7D83835327D852627C674D4087F87ADFBB6CC3457C92E953D9C286BE"; + internal const string InvalidSourceSha256 = "17C627E367CFDB33D02316E394E19C6B9DECFE1A3CA11E63ACB882D085F337EF"; + internal const string InvalidReferenceSha256 = "EDC29B5453FDD2C53C796B7E2FBC21389587D9360F148A7D229DDF461EA04194"; + + internal static string Sha256(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))); + + internal static void RequireReviewedHashes() + { + if (Sha256(ValidSource) != ValidSha256 || Sha256(InvalidSource) != InvalidSourceSha256 || + Sha256(InvalidReference) != InvalidReferenceSha256) + { + throw new InvalidOperationException("The fixed compiler qualification payload hash differs from the reviewed policy."); + } + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs new file mode 100644 index 0000000..cf30393 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs @@ -0,0 +1,385 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +using CheatEngine.Mcp.Core.Contract; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Runs only the fixed, separately authorized compiler phases; it never loads the generated assembly. +[SupportedOSPlatform("windows")] +internal static class LiveCompilerQualification +{ + private static readonly TimeSpan QuietInterval = TimeSpan.FromSeconds(2); + private const long MaximumArtifactBytes = 16 * 1024 * 1024; + + internal static async Task RunAsync(LiveQualificationInputs inputs) + { + Assert.Equal(LiveQualificationScenario.Compiler, inputs.Scenario); + object candidate = LiveCompilerCandidate.Verify(inputs); + await VerifyDisabledGateAsync(inputs, candidate); + // The first session has fully restored user state before the enabled activation starts. + Assert.Equal(JsonSerializer.Serialize(candidate), JsonSerializer.Serialize(LiveCompilerCandidate.Verify(inputs))); + await VerifyCompilerAsync(inputs, candidate); + } + + private static async Task VerifyDisabledGateAsync(LiveQualificationInputs inputs, object candidate) + { + await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs, new LiveSandboxOptions(true, false)); + RecordAdmission(sandbox, candidate, "disabled"); + await using LiveMcpClient gateway = await ConnectAsync(sandbox); + LiveMcpInstanceClient instance = gateway.Bind(sandbox.HostA.InstanceId); + await VerifyGatesAsync(sandbox, instance, "A", false); + await VerifyGatesAsync(sandbox, gateway.Bind(sandbox.HostB.InstanceId), "B", false); + long receipts = await ReceiptCountAsync(sandbox, "A"); + FileFact[] before = Snapshot(sandbox, sandbox.CompilerA.Temp); + string output = Path.Combine(sandbox.CompilerA.Output, "gate-disabled.dll"); + LiveMcpToolResult result = await CompileAsync(sandbox, instance, "gate_disabled", Arguments(LiveCompilerPayload.ValidSource, output)); + AssertError(result, "capability_disabled", "not_started"); + Assert.Equal(receipts, await ReceiptCountAsync(sandbox, "A")); + Assert.Equal(before, Snapshot(sandbox, sandbox.CompilerA.Temp)); + Assert.Empty(Snapshot(sandbox, sandbox.CompilerA.Output)); + await sandbox.StopHostAsync("A"); + await RequireInstancesAsync(gateway, sandbox.HostB.InstanceId); + await RejectStaleInstanceAsync(instance); + sandbox.Record("compiler_gate_refusal", new + { + noCompilerReceipt = true, + noOutput = true, + tempUnchanged = true + }); + sandbox.MarkPassed(); + } + + private static async Task VerifyCompilerAsync(LiveQualificationInputs inputs, object candidate) + { + await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs, new LiveSandboxOptions(true, true)); + RecordAdmission(sandbox, candidate, "enabled"); + await using LiveMcpClient gateway = await ConnectAsync(sandbox); + LiveMcpInstanceClient instanceA = gateway.Bind(sandbox.HostA.InstanceId); + LiveMcpInstanceClient instanceB = gateway.Bind(sandbox.HostB.InstanceId); + await VerifyGatesAsync(sandbox, instanceA, "A", true); + await VerifyGatesAsync(sandbox, instanceB, "B", true); + await AttachAsync(instanceA, sandbox.HostA); + await AttachAsync(instanceB, sandbox.HostB); + JsonNode status = await sandbox.CompilerProbeAsync("A", "status"); + Assert.True(status["available"]!.GetValue(), "The installed CE host does not expose compileCS; qualification stops without installing prerequisites."); + sandbox.Record("compiler_availability", status.DeepClone()); + FileFact[] baselineA = Snapshot(sandbox, sandbox.CompilerA.Temp); + FileFact[] baselineB = SnapshotCompiler(sandbox, sandbox.CompilerB); + sandbox.Record("compiler_baseline", new + { + A = baselineA, + B = baselineB + }); + long receipts = await ReceiptCountAsync(sandbox, "A"); + string output = Path.Combine(sandbox.CompilerA.Output, "probe.dll"); + LiveMcpToolResult compiled = await CompileAsync(sandbox, instanceA, "valid_source", Arguments(LiveCompilerPayload.ValidSource, output)); + Assert.False(compiled.IsError, compiled.ErrorText); + JsonNode payload = Assert.IsAssignableFrom(compiled.Payload); + Assert.Equal(output, payload["outputPath"]!.GetValue(), true); + FileFact exported = ReadFile(sandbox, output, sandbox.CompilerA.Output, true); + Assert.Equal(exported.Length, payload["length"]!.GetValue()); + Assert.Equal(exported.Sha256, payload["sha256"]!.GetValue()); + Assert.Equal(receipts + 1, await ReceiptCountAsync(sandbox, "A")); + AssertOnlyExport(sandbox, exported); + await VerifySelectedTargetAsync(instanceB, sandbox.HostB); + Assert.Equal(baselineB, SnapshotCompiler(sandbox, sandbox.CompilerB)); + sandbox.Record("compiler_export", new + { + file = exported, + observedAt = DateTimeOffset.UtcNow + }); + + await VerifyInvalidAsync(sandbox, instanceA, "invalid_source", LiveCompilerPayload.InvalidSource, null, null, exported); + string missing = Path.Combine(sandbox.CompilerA.References, "missing.dll"); + string invalid = Path.Combine(sandbox.CompilerA.References, "invalid.dll"); + RequireOwnedPath(sandbox, invalid, sandbox.CompilerA.References); + await File.WriteAllTextAsync(invalid, LiveCompilerPayload.InvalidReference, new UTF8Encoding(false), TestContext.Current.CancellationToken); + Assert.Equal(LiveCompilerPayload.InvalidReferenceSha256, ReadFile(sandbox, invalid, sandbox.CompilerA.References).Sha256); + await VerifyMissingAsync(sandbox, instanceA, "missing_reference", missing, false, exported); + await VerifyInvalidAsync(sandbox, instanceA, "invalid_reference", LiveCompilerPayload.ValidSource, [invalid], null, exported); + // CE 7.7's reviewed compileCS signature supports its optional third coreAssembly argument. + await VerifyMissingAsync(sandbox, instanceA, "missing_core", missing, true, exported); + await VerifyInvalidAsync(sandbox, instanceA, "invalid_core", LiveCompilerPayload.ValidSource, null, invalid, exported); + Assert.Equal(baselineB, SnapshotCompiler(sandbox, sandbox.CompilerB)); + await VerifySelectedTargetAsync(instanceA, sandbox.HostA); + await VerifySelectedTargetAsync(instanceB, sandbox.HostB); + + receipts = await ReceiptCountAsync(sandbox, "A"); + FileFact[] beforeRaw = Snapshot(sandbox, sandbox.CompilerA.Temp); + JsonNode rawResult = await sandbox.CompilerProbeAsync("A", "compile"); + Assert.Equal(receipts + 1, rawResult["receiptCount"]!.GetValue()); + string rawPath = rawResult["assemblyPath"]?.GetValue() + ?? throw new InvalidOperationException("The fixed raw compiler bridge returned no assembly; qualification stops."); + // Containment and every existing path component are checked before any read of the CE-returned path. + FileFact raw = ReadFile(sandbox, rawPath, sandbox.CompilerA.Temp, true); + Assert.DoesNotContain(beforeRaw, file => file.Path == raw.Path); + Assert.Equal(exported, ReadFile(sandbox, output, sandbox.CompilerA.Output, true)); + sandbox.Record("compiler_raw_before_shutdown", new + { + file = raw, + exported, + observedAt = DateTimeOffset.UtcNow + }); + await sandbox.StopHostAsync("B"); + await RequireInstancesAsync(gateway, sandbox.HostA.InstanceId); + await RejectStaleInstanceAsync(instanceB); + await Task.Delay(QuietInterval, TestContext.Current.CancellationToken); + FileFact? afterB = ObserveRaw(sandbox, rawPath); + if (afterB is not null) + { + Assert.Equal(raw, afterB); + } + Assert.Equal(exported, ReadFile(sandbox, output, sandbox.CompilerA.Output, true)); + await VerifySelectedTargetAsync(instanceA, sandbox.HostA); + sandbox.Record("compiler_after_b", new + { + raw = afterB, + exported, + observedAt = DateTimeOffset.UtcNow + }); + await sandbox.StopHostAsync("A"); + await RequireInstancesAsync(gateway); + await RejectStaleInstanceAsync(instanceA); + await Task.Delay(QuietInterval, TestContext.Current.CancellationToken); + FileFact? afterA = ObserveRaw(sandbox, rawPath); + if (afterA is not null) + { + Assert.Equal(raw, afterA); + } + Assert.Equal(exported, ReadFile(sandbox, output, sandbox.CompilerA.Output, true)); + sandbox.Record("compiler_after_a", new + { + raw = afterA, + exported, + observedAt = DateTimeOffset.UtcNow, + classification = afterB is null ? "expired_after_b" : afterA is null ? "retained_after_b_expired_after_a" : "retained_after_a", + distinctTemporaryRoots = true, + injectionAttempted = false + }); + sandbox.MarkPassed(); + } + + private static void RecordAdmission(LiveSandboxSession sandbox, object candidate, string phase) + { + sandbox.Record("compiler_admission", new + { + candidate, + phase, + liveAcknowledgement = true, + compilerAcknowledgement = true, + targetArchitecture = sandbox.TargetArchitecture, + injectionApproved = false + }); + } + + private static Task ConnectAsync(LiveSandboxSession sandbox) => + LiveMcpClient.ConnectGatewayAsync(sandbox.GatewayExecutablePath, sandbox.InstanceDirectory, TestContext.Current.CancellationToken); + + private static async Task VerifyGatesAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient client, string name, bool enabled) + { + JsonNode runtime = (await client.CallToolAsync(CheatEngineToolNames.RuntimeGetInfo))!; + JsonNode gates = runtime["gates"]!; + Assert.Equal(enabled, gates["targetCodeExecution"]!.GetValue()); + Assert.False(gates["unsafeLua"]!.GetValue()); + Assert.False(gates["autoAssembler"]!.GetValue()); + Assert.False(gates["kernelAccess"]!.GetValue()); + sandbox.Record($"compiler_runtime_{name}", runtime.DeepClone()); + } + + private static async Task AttachAsync(LiveMcpInstanceClient client, LiveSandboxHost host) + { + JsonNode opened = (await client.CallToolAsync(CheatEngineToolNames.ProcessAttach, + new Dictionary { ["process"] = host.TargetProcessId.ToString(CultureInfo.InvariantCulture) }))!; + Assert.Equal(host.TargetProcessId, opened["processId"]!.GetValue()); + await VerifySelectedTargetAsync(client, host); + } + + private static async Task VerifySelectedTargetAsync(LiveMcpInstanceClient client, LiveSandboxHost host) + { + JsonNode overview = (await client.CallToolAsync(CheatEngineToolNames.RuntimeGetOverview))!; + Assert.Equal(host.TargetProcessId, overview["process"]!["processId"]!.GetValue()); + JsonNode memory = (await client.CallToolAsync(CheatEngineToolNames.MemoryRead, + new Dictionary { ["address"] = host.TargetAddress, ["valueType"] = "int32" }))!; + Assert.Equal("20260926", memory["value"]!.GetValue()); + } + + private static async Task ReceiptCountAsync(LiveSandboxSession sandbox, string name) => + (await sandbox.CompilerProbeAsync(name, "status"))["receiptCount"]!.GetValue(); + + private static Dictionary Arguments(string source, string output, string[]? references = null, string? core = null) => + new() + { + ["source"] = source, + ["outputPath"] = output, + ["overwrite"] = false, + ["referencePaths"] = references, + ["coreAssembly"] = core + }; + + private static async Task CompileAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient client, + string name, Dictionary arguments) + { + DateTimeOffset started = DateTimeOffset.UtcNow; + using Process host = Process.GetProcessById(sandbox.HostA.ProcessId); + long privateBytes = host.PrivateMemorySize64; + int handles = host.HandleCount; + LiveMcpToolResult result = await client.CallToolRawAsync(CheatEngineToolNames.ExecCompileCSharp, arguments); + host.Refresh(); + JsonNode? error = result.IsError ? result.Payload?["error"] : null; + string? diagnostic = error?["details"]?["text"]?.GetValue(); + sandbox.Record($"compiler_call_{name}", new + { + instanceId = client.InstanceId, + operation = CheatEngineToolNames.ExecCompileCSharp, + started, + ended = DateTimeOffset.UtcNow, + durationMilliseconds = (DateTimeOffset.UtcNow - started).TotalMilliseconds, + result.IsError, + kind = error?["kind"]?.GetValue(), + hostEffect = error?["hostEffect"]?.GetValue(), + retryable = error?["retryable"]?.GetValue(), + correlationId = error?["details"]?["errorId"]?.GetValue(), + diagnosticBytes = diagnostic is null ? 0 : Encoding.UTF8.GetByteCount(diagnostic), + diagnosticSha256 = diagnostic is null ? null : LiveCompilerPayload.Sha256(diagnostic), + diagnosticTruncated = error?["details"]?["truncated"]?.GetValue(), + privateBytesDelta = host.PrivateMemorySize64 - privateBytes, + handleDelta = host.HandleCount - handles + }); + return result; + } + + private static JsonNode AssertError(LiveMcpToolResult result, string kind, string effect) + { + Assert.True(result.IsError); + JsonNode error = result.Payload!["error"]!; + Assert.Equal(kind, error["kind"]!.GetValue()); + Assert.Equal(effect, error["hostEffect"]!.GetValue()); + Assert.False(error["retryable"]!.GetValue()); + return error; + } + + private static async Task VerifyMissingAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance, string name, + string missing, bool core, FileFact exported) + { + long receipts = await ReceiptCountAsync(sandbox, "A"); + FileFact[] before = Snapshot(sandbox, sandbox.CompilerA.Temp); + LiveMcpToolResult result = await CompileAsync(sandbox, instance, name, + Arguments(LiveCompilerPayload.ValidSource, Path.Combine(sandbox.CompilerA.Output, name + ".dll"), core ? null : [missing], core ? missing : null)); + AssertError(result, "not_found", "not_started"); + Assert.Equal(receipts, await ReceiptCountAsync(sandbox, "A")); + Assert.Equal(before, Snapshot(sandbox, sandbox.CompilerA.Temp)); + AssertOnlyExport(sandbox, exported); + } + + private static async Task VerifyInvalidAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance, string name, + string source, string[]? references, string? core, FileFact exported) + { + long receipts = await ReceiptCountAsync(sandbox, "A"); + LiveMcpToolResult result = await CompileAsync(sandbox, instance, name, + Arguments(source, Path.Combine(sandbox.CompilerA.Output, name + ".dll"), references, core)); + JsonNode error = AssertError(result, "host_refused", "unknown"); + string diagnostic = error["details"]!["text"]!.GetValue(); + Assert.InRange(Encoding.UTF8.GetByteCount(diagnostic), 1, 16_384); + // These three tiny fixed failures must fit the bound without truncation. + Assert.False(error["details"]!["truncated"]!.GetValue()); + Assert.Equal(receipts + 1, await ReceiptCountAsync(sandbox, "A")); + AssertOnlyExport(sandbox, exported); + sandbox.Record($"compiler_temp_after_{name}", Snapshot(sandbox, sandbox.CompilerA.Temp)); + } + + private static void AssertOnlyExport(LiveSandboxSession sandbox, FileFact exported) => + Assert.Equal(exported, Assert.Single(Snapshot(sandbox, sandbox.CompilerA.Output))); + + private static async Task RequireInstancesAsync(LiveMcpClient gateway, params string[] expected) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow.AddSeconds(10); + do + { + JsonNode response = (await gateway.CallToolAsync(CheatEngineToolNames.InstanceList))!; + string[] actual = response["instances"]!.AsArray().Select(instance => instance!["instanceId"]!.GetValue()).Order(StringComparer.Ordinal).ToArray(); + if (actual.SequenceEqual(expected.Order(StringComparer.Ordinal))) + { + return; + } + await Task.Delay(100, TestContext.Current.CancellationToken); + } while (DateTimeOffset.UtcNow < deadline); + Assert.Fail("The gateway did not report exactly the remaining private instances."); + } + + private static async Task RejectStaleInstanceAsync(LiveMcpInstanceClient instance) + { + LiveMcpToolResult result = await instance.CallToolRawAsync(CheatEngineToolNames.RuntimeGetInfo); + AssertError(result, "instance_unavailable", "not_started"); + } + + private static FileFact? ObserveRaw(LiveSandboxSession sandbox, string path) + { + RequireOwnedPath(sandbox, path, sandbox.CompilerA.Temp); + return File.Exists(path) ? ReadFile(sandbox, path, sandbox.CompilerA.Temp, true) : null; + } + + private static FileFact[] SnapshotCompiler(LiveSandboxSession sandbox, LiveCompilerRoots roots) => + [.. Snapshot(sandbox, roots.Temp), .. Snapshot(sandbox, roots.References), .. Snapshot(sandbox, roots.Output)]; + + private static FileFact[] Snapshot(LiveSandboxSession sandbox, string root) + { + List files = []; + Visit(root); + return files.OrderBy(file => file.Path, StringComparer.Ordinal).ToArray(); + void Visit(string directory) + { + RequireOwnedPath(sandbox, directory, root); + foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) + { + RequireOwnedPath(sandbox, entry, root); + if (Directory.Exists(entry)) + { + Visit(entry); + } + else + { + Assert.True(files.Count < 256, "The compiler scratch tree exceeds the bounded file inventory."); + files.Add(ReadFile(sandbox, entry, root)); + } + } + } + } + + private static FileFact ReadFile(LiveSandboxSession sandbox, string path, string root, bool assembly = false) + { + RequireOwnedPath(sandbox, path, root); + using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read); + // Recheck after acquiring the non-delete-shared handle and before reading any file data. + RequireOwnedPath(sandbox, path, root); + Assert.InRange(stream.Length, assembly ? 2 : 0, MaximumArtifactBytes); + if (assembly) + { + Assert.Equal('M', stream.ReadByte()); + Assert.Equal('Z', stream.ReadByte()); + stream.Position = 0; + } + return new FileFact(Path.GetRelativePath(sandbox.RunDirectory, path), stream.Length, Convert.ToHexString(SHA256.HashData(stream))); + } + + private static void RequireOwnedPath(LiveSandboxSession sandbox, string path, string root) + { + Assert.True(Path.IsPathFullyQualified(path)); + Assert.True(LiveQualificationOptIn.IsSameOrBelow(root, sandbox.RunDirectory)); + Assert.True(LiveQualificationOptIn.IsSameOrBelow(path, root), "The compiler returned a path outside its owned root; it will not be read or modified."); + for (string? component = Path.GetFullPath(path); component is not null; component = Path.GetDirectoryName(component)) + { + if (File.Exists(component) || Directory.Exists(component)) + { + Assert.False((File.GetAttributes(component) & FileAttributes.ReparsePoint) != 0, "Compiler paths must not contain reparse points."); + } + } + } + + private sealed record FileFact(string Path, long Length, string Sha256); +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs index 147d4d8..f746813 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs @@ -30,15 +30,23 @@ public ValueTask DisposeAsync() public async Task CallToolAsync(string name, IReadOnlyDictionary? arguments = null) { - using CancellationTokenSource timeout = CreateTimeout(cancellationToken); - CallToolResult result = await client.CallToolAsync(name, arguments ?? new Dictionary(), - cancellationToken: timeout.Token); - if (result.IsError == true) + LiveMcpToolResult result = await CallToolRawAsync(name, arguments); + if (result.IsError) { - throw new InvalidOperationException($"Tool '{name}' returned an MCP error: {FormatContent(result)}"); + throw new InvalidOperationException($"Tool '{name}' returned an MCP error: {result.ErrorText}"); } - return ExtractToolPayload(result); + return result.Payload; + } + + public async Task CallToolRawAsync(string name, + IReadOnlyDictionary? arguments = null) + { + using CancellationTokenSource timeout = CreateTimeout(cancellationToken); + CallToolResult result = await client.CallToolAsync(name, arguments ?? new Dictionary(), + cancellationToken: timeout.Token); + return new LiveMcpToolResult(result.IsError == true, ExtractToolPayload(result), + result.IsError == true ? FormatContent(result) : null); } public static Task ConnectAsync(string serverUrl, CancellationToken cancellationToken = default) diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpInstanceClient.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpInstanceClient.cs index 92e074b..aa55f0a 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpInstanceClient.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpInstanceClient.cs @@ -10,6 +10,14 @@ public string InstanceId } = instanceId; public Task CallToolAsync(string name, IReadOnlyDictionary? arguments = null) + { + return client.CallToolAsync(name, Route(arguments)); + } + + public Task CallToolRawAsync(string name, + IReadOnlyDictionary? arguments = null) => client.CallToolRawAsync(name, Route(arguments)); + + private Dictionary Route(IReadOnlyDictionary? arguments) { Dictionary routed = arguments is null ? new Dictionary(StringComparer.Ordinal) @@ -19,6 +27,6 @@ public string InstanceId throw new ArgumentException("Bound gateway calls must not replace their instanceId.", nameof(arguments)); } - return client.CallToolAsync(name, routed); + return routed; } } diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpToolResult.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpToolResult.cs new file mode 100644 index 0000000..7c3bef1 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpToolResult.cs @@ -0,0 +1,6 @@ +using System.Text.Json.Nodes; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// The structured MCP outcome for a call expected to succeed or fail during a live qualification assertion. +internal sealed record LiveMcpToolResult(bool IsError, JsonNode? Payload, string? ErrorText); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs index dd69a4c..22e5cfc 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs @@ -1,4 +1,4 @@ namespace CheatEngine.Mcp.Tests.LiveQualification; internal sealed record LiveQualificationInputs(string RepositoryRoot, string CheatEngineDirectory, string RunRoot, - string TargetArchitecture); + string TargetArchitecture, LiveQualificationScenario Scenario); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs index 4f28020..bcb0dd4 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs @@ -8,6 +8,10 @@ internal static class LiveQualificationOptIn internal const string CheatEngineDirectoryVariable = "CHEATENGINE_MCP_LIVE_QUALIFICATION_CE_DIRECTORY"; internal const string RunRootVariable = "CHEATENGINE_MCP_LIVE_QUALIFICATION_RUN_ROOT"; internal const string TargetArchitectureVariable = "CHEATENGINE_MCP_LIVE_QUALIFICATION_TARGET_ARCHITECTURE"; + internal const string ScenarioVariable = "CHEATENGINE_MCP_LIVE_QUALIFICATION_SCENARIO"; + internal const string CodeExecutionOptInVariable = "CHEATENGINE_MCP_LIVE_CODE_EXECUTION_QUALIFICATION"; + internal const string CodeExecutionAcknowledgement = + "I_AUTHORIZE_FIXED_COMPILECS_PROBES_ON_PRIVATE_CE_AND_OWNED_TARGETS"; internal const string LocalCommand = "dotnet test --project tests/CheatEngine.Mcp.Tests -c Release --filter-trait Category=LiveQualification --fail-skips on"; @@ -22,11 +26,12 @@ internal static LiveQualificationDecision ResolveFromEnvironment(string reposito return Evaluate(Environment.GetEnvironmentVariable, repositoryRoot, Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Cheat Engine"), - Directory.Exists); + Directory.Exists, CompilerTempSettingsAreSafe); } internal static LiveQualificationDecision Evaluate(Func variables, string repositoryRoot, - string localApplicationData, string defaultCheatEngineDirectory, Func directoryExists) + string localApplicationData, string defaultCheatEngineDirectory, Func directoryExists, + Func? compilerTempSettingsAreSafe = null) { if (string.Equals(variables("CI"), "true", StringComparison.OrdinalIgnoreCase)) { @@ -38,6 +43,27 @@ internal static LiveQualificationDecision Evaluate(Func variabl return Refuse($"{OptInVariable} must contain the exact acknowledgement {Acknowledgement}."); } + string scenarioValue = variables(ScenarioVariable) ?? "smoke"; + if (scenarioValue is not ("smoke" or "compiler")) + { + return Refuse($"{ScenarioVariable} must be smoke or compiler."); + } + + LiveQualificationScenario scenario = scenarioValue == "compiler" + ? LiveQualificationScenario.Compiler + : LiveQualificationScenario.Smoke; + + if (scenario == LiveQualificationScenario.Compiler && + variables(CodeExecutionOptInVariable) != CodeExecutionAcknowledgement) + { + return Refuse($"{CodeExecutionOptInVariable} must contain the exact acknowledgement {CodeExecutionAcknowledgement}."); + } + if (scenario == LiveQualificationScenario.Compiler && compilerTempSettingsAreSafe is not null && + !compilerTempSettingsAreSafe()) + { + return Refuse("Cheat Engine's Don't use tempdir setting is enabled; compiler qualification requires the owned TEMP directory."); + } + string source = variables(CheatEngineDirectoryVariable) is { Length: > 0 } configuredSource ? configuredSource : defaultCheatEngineDirectory; @@ -67,7 +93,7 @@ internal static LiveQualificationDecision Evaluate(Func variabl return new LiveQualificationDecision( new LiveQualificationInputs(Path.GetFullPath(repositoryRoot), Path.GetFullPath(source), - Path.GetFullPath(runRoot), architecture), null); + Path.GetFullPath(runRoot), architecture, scenario), null); } internal static bool IsSameOrBelow(string path, string directory) @@ -82,4 +108,22 @@ private static LiveQualificationDecision Refuse(string reason) { return new LiveQualificationDecision(null, reason + Environment.NewLine + Instructions); } + + private static bool CompilerTempSettingsAreSafe() + { + if (!OperatingSystem.IsWindows()) + { + return false; + } + using Microsoft.Win32.RegistryKey? key = Microsoft.Win32.Registry.CurrentUser.OpenSubKey("Software\\Cheat Engine"); + object? value = key?.GetValue("Don't use tempdir"); + return value switch + { + null => true, + int number => number == 0, + string text when bool.TryParse(text, out bool enabled) => !enabled, + string text when int.TryParse(text, out int number) => number == 0, + _ => false + }; + } } diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs index 80f9f80..9992d8a 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs @@ -6,6 +6,17 @@ public sealed class LiveQualificationOptInTests private const string Installation = "C:/Program Files/Cheat Engine"; private const string LocalData = "C:/Users/test/AppData/Local"; + [Fact] + public void CompilerPayload_ReviewedUtf8LfSource_MatchesExpectedHash() + { + Assert.DoesNotContain("\r", LiveCompilerPayload.ValidSource, StringComparison.Ordinal); + Assert.Equal(LiveCompilerPayload.ValidSha256, LiveCompilerPayload.Sha256(LiveCompilerPayload.ValidSource)); + Assert.Equal(LiveCompilerPayload.InvalidSourceSha256, LiveCompilerPayload.Sha256(LiveCompilerPayload.InvalidSource)); + Assert.Equal(LiveCompilerPayload.InvalidReferenceSha256, LiveCompilerPayload.Sha256(LiveCompilerPayload.InvalidReference)); + LiveCompilerPayload.RequireReviewedHashes(); + LiveCompilerBridge.RequireReviewedHash(); + } + [Theory] [InlineData(null)] [InlineData("")] @@ -57,9 +68,72 @@ public void Evaluate_AcknowledgedWorkstation_UsesExternalRunRoot() Assert.Equal(Path.GetFullPath(Installation), decision.Inputs!.CheatEngineDirectory); Assert.Equal(Path.GetFullPath(LocalData + "/CheatEngine.Mcp.LiveQualification/runs"), decision.Inputs.RunRoot); Assert.Equal("x64", decision.Inputs.TargetArchitecture); + Assert.Equal(LiveQualificationScenario.Smoke, decision.Inputs.Scenario); Assert.False(LiveQualificationOptIn.IsSameOrBelow(decision.Inputs.RunRoot, Repository)); } + [Fact] + public void Evaluate_CompilerScenarioWithSecondAcknowledgement_Authorizes() + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.ScenarioVariable] = "compiler"; + variables[LiveQualificationOptIn.CodeExecutionOptInVariable] = LiveQualificationOptIn.CodeExecutionAcknowledgement; + + LiveQualificationDecision decision = Evaluate(variables); + + Assert.True(decision.IsAuthorized, decision.Refusal); + Assert.Equal(LiveQualificationScenario.Compiler, decision.Inputs!.Scenario); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("I_AUTHORIZE_SOMETHING_ELSE")] + public void Evaluate_CompilerScenarioWithoutExactSecondAcknowledgement_RefusesBeforeDirectoryInspection( + string? acknowledgement) + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.ScenarioVariable] = "compiler"; + variables[LiveQualificationOptIn.CodeExecutionOptInVariable] = acknowledgement; + + LiveQualificationDecision decision = LiveQualificationOptIn.Evaluate(variables.GetValueOrDefault, + Repository, LocalData, Installation, _ => throw new InvalidOperationException("No directory inspection expected.")); + + Assert.False(decision.IsAuthorized); + Assert.Contains(LiveQualificationOptIn.CodeExecutionOptInVariable, decision.Refusal, StringComparison.Ordinal); + } + + [Theory] + [InlineData("COMpiler")] + [InlineData("inject")] + [InlineData("compiler ")] + public void Evaluate_UnknownScenario_RefusesBeforeDirectoryInspection(string scenario) + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.ScenarioVariable] = scenario; + + LiveQualificationDecision decision = LiveQualificationOptIn.Evaluate(variables.GetValueOrDefault, + Repository, LocalData, Installation, _ => throw new InvalidOperationException("No directory inspection expected.")); + + Assert.False(decision.IsAuthorized); + Assert.Contains(LiveQualificationOptIn.ScenarioVariable, decision.Refusal, StringComparison.Ordinal); + } + + [Fact] + public void Evaluate_CompilerScenarioWithTempdirDisabled_RefusesBeforeDirectoryInspection() + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.ScenarioVariable] = "compiler"; + variables[LiveQualificationOptIn.CodeExecutionOptInVariable] = LiveQualificationOptIn.CodeExecutionAcknowledgement; + + LiveQualificationDecision decision = LiveQualificationOptIn.Evaluate(variables.GetValueOrDefault, + Repository, LocalData, Installation, _ => throw new InvalidOperationException("No directory inspection expected."), + static () => false); + + Assert.False(decision.IsAuthorized); + Assert.Contains("Don't use tempdir", decision.Refusal, StringComparison.Ordinal); + } + [Fact] public void Evaluate_X86TargetArchitecture_AuthorizesTheBoundedFixtureChoice() { diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs new file mode 100644 index 0000000..c56d21e --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs @@ -0,0 +1,8 @@ +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// The explicitly admitted native qualification scenario for this one runner activation. +internal enum LiveQualificationScenario +{ + Smoke, + Compiler +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs new file mode 100644 index 0000000..2a39bf2 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs @@ -0,0 +1,10 @@ +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Fixed, test-owned activation settings selected before a private CE copy starts. +internal sealed record LiveSandboxOptions(bool CompilerQualification, bool EnableTargetCodeExecution) +{ + internal static LiveSandboxOptions Smoke { get; } = new(false, false); +} + +/// Per-host compiler files, all rooted below one live run. +internal sealed record LiveCompilerRoots(string Temp, string References, string Output); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs index ccd95b6..8b038d7 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs @@ -24,6 +24,7 @@ internal sealed class LiveSandboxSession : IAsyncDisposable private readonly SandboxLayout _layout; private readonly Dictionary _report = new(StringComparer.Ordinal); private readonly FileStream _runLock; + private readonly LiveSandboxOptions _options; private DebugOutputCapture? _debugOutput; private bool _disposed; private InstallationFingerprint? _fingerprint; @@ -37,10 +38,11 @@ internal sealed class LiveSandboxSession : IAsyncDisposable private CheatEngineRegistryGuard? _stateGuard; private ICheatEngineUserStateScope? _userState; - private LiveSandboxSession(SandboxLayout layout, FileStream runLock) + private LiveSandboxSession(SandboxLayout layout, FileStream runLock, LiveSandboxOptions options) { _layout = layout; _runLock = runLock; + _options = options; _report["schema"] = "cheatengine-mcp-live-multi-instance/v1"; _report["runId"] = layout.RunId; InstanceDirectory = Path.Combine(layout.RunDirectory, "instances"); @@ -59,6 +61,12 @@ public string GatewayExecutablePath public string TargetArchitecture => _targetArchitecture; + public string RunDirectory => _layout.RunDirectory; + + public LiveCompilerRoots CompilerA => CompilerRoots("A"); + + public LiveCompilerRoots CompilerB => CompilerRoots("B"); + public LiveSandboxHost HostA => GetHost("A"); public LiveSandboxHost HostB => GetHost("B"); @@ -133,6 +141,11 @@ public async ValueTask DisposeAsync() } }, failures); } + + if (_options.CompilerQualification && failures.Count == 0 && _hosts.Values.All(host => host.Stopped)) + { + TryCleanup(CleanupCompilerRoots, failures); + } } finally { @@ -155,8 +168,20 @@ public async ValueTask DisposeAsync() } } - public static async Task StartAsync(LiveQualificationInputs inputs) + public static async Task StartAsync(LiveQualificationInputs inputs, + LiveSandboxOptions? options = null) { + LiveSandboxOptions selected = options ?? LiveSandboxOptions.Smoke; + if (selected.CompilerQualification != (inputs.Scenario == LiveQualificationScenario.Compiler)) + { + throw new InvalidOperationException("Compiler sandbox options must exactly match the authorized live scenario."); + } + if (selected.CompilerQualification) + { + LiveCompilerPayload.RequireReviewedHashes(); + LiveCompilerBridge.RequireReviewedHash(); + } + RequireNoCheatEngine(); FileStream runLock = new(Path.Combine(Path.GetTempPath(), "CheatEngine.Mcp.LiveTests.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); @@ -164,7 +189,7 @@ public static async Task StartAsync(LiveQualificationInputs try { SandboxLayout layout = SandboxLayout.Create(inputs.RunRoot, DateTimeOffset.UtcNow); - sandbox = new LiveSandboxSession(layout, runLock); + sandbox = new LiveSandboxSession(layout, runLock, selected); Console.WriteLine($"Live evidence: {layout.RunDirectory}"); await sandbox.StartCoreAsync(inputs); return sandbox; @@ -237,6 +262,46 @@ public async Task StopHostAsync(string name) } } + /// Runs one fixed compiler availability or compile probe through the test-owned private autorun bridge. + public async Task CompilerProbeAsync(string hostName, string command) + { + if (!_options.CompilerQualification || command is not ("status" or "compile")) + { + throw new ArgumentOutOfRangeException(nameof(command)); + } + + if (!_hosts.TryGetValue(hostName, out OwnedHost? host) || host.Process is null || host.Stopped) + { + throw new InvalidOperationException($"Owned host '{hostName}' is not running."); + } + + string request = Path.Combine(_layout.RunDirectory, $"compiler-request-{hostName}.txt"); + string response = Path.Combine(_layout.RunDirectory, $"compiler-response-{hostName}.txt"); + RequireInsideRun(request); + RequireInsideRun(response); + File.Delete(response); + await File.WriteAllTextAsync(request, command); + await WaitUntilAsync(() => File.Exists(response), host.Process, TimeSpan.FromSeconds(10)); + string[] lines = await File.ReadAllLinesAsync(response); + if (lines.Length is < 3 or > 4 || lines[0] != "ok" || !bool.TryParse(lines[1], out bool available) || + !int.TryParse(lines[2], NumberStyles.None, CultureInfo.InvariantCulture, out int receipts)) + { + throw new InvalidOperationException("Fixed compiler bridge returned an invalid bounded response."); + } + + JsonObject result = new() + { + ["available"] = available, + ["receiptCount"] = receipts + }; + if (lines.Length == 4) + { + result[available ? "assemblyPath" : "diagnostic"] = lines[3]; + } + + return result; + } + /// Restarts only the disposable target for one still-running private CE host and publishes a fresh manifest. public async Task RestartTargetAsync(string name) { @@ -358,6 +423,18 @@ private async Task StartCoreAsync(LiveQualificationInputs inputs) _fingerprint = CheatEngineInstallation.Fingerprint(_source, _profile); _runtimeHash = HashIfExists(Path.Combine(_source, "ce.runtimeconfig.json")); Record("host", _profile); + if (_options.CompilerQualification) + { + CreateCompilerRoots("A"); + CreateCompilerRoots("B"); + Record("compiler_configuration", new + { + targetCodeExecution = _options.EnableTargetCodeExecution, + unsafeLua = false, + autoAssembler = false, + kernelAccess = false + }); + } Directory.CreateDirectory(InstanceDirectory); PrepareDistribution(repository); PrepareHostInstallation("A"); @@ -457,6 +534,28 @@ private async Task StartHostAsync(string repository, string name) RequireInsideRun(pluginDirectory); Directory.CreateDirectory(pluginDirectory); File.Copy(Path.Combine(pluginSource, PluginFileName), Path.Combine(pluginDirectory, PluginFileName)); + if (_options.CompilerQualification) + { + LiveCompilerRoots roots = CompilerRoots(name); + File.WriteAllText(Path.Combine(pluginDirectory, "appsettings.json"), JsonSerializer.Serialize(new + { + Mcp = new + { + EnableUnsafeLua = false, + EnableAutoAssembler = false, + EnableTargetCodeExecution = _options.EnableTargetCodeExecution, + EnableKernelAccess = false, + Files = new + { + AllowedRoots = new[] { roots.Output } + } + }, + CheatEngineClient = new + { + AllowedTableRoots = Array.Empty() + } + })); + } string pluginPath = Path.Combine(pluginDirectory, PluginFileName); VerifyDistributionStaging(name, pluginSource, pluginDirectory); Record($"plugin_{name}_sha256", CheatEngineInstallation.Sha256(pluginPath)); @@ -484,6 +583,12 @@ private async Task StartHostAsync(string repository, string name) hostStart.Environment["MCP_DATA_DIRECTORY"] = Path.Combine(_layout.RunDirectory, "plugin-data", name); hostStart.Environment["MCP_INSTANCE_DIRECTORY"] = InstanceDirectory; hostStart.Environment["MCP_INSTANCE_NAME"] = $"Live Qualification {name}"; + if (_options.CompilerQualification) + { + LiveCompilerRoots roots = CompilerRoots(name); + hostStart.Environment["TEMP"] = roots.Temp; + hostStart.Environment["TMP"] = roots.Temp; + } host = Process.Start(hostStart) ?? throw new InvalidOperationException($"Private CE host {name} did not start."); owned.Process = host; @@ -602,7 +707,7 @@ private LiveTargetManifest ReadTargetManifest(JsonNode manifest, int processId, manifest["zeroPointerRootAddress"]!.GetValue(), manifest["unreadableRootAddress"]!.GetValue()); } - private static string DistributionDirectory(string repository, string configuration) + internal static string DistributionDirectory(string repository, string configuration) { string? custom = Environment.GetEnvironmentVariable("CHEATENGINE_MCP_LIVE_QUALIFICATION_DISTRIBUTION_DIRECTORY"); if (custom is not null && !Path.IsPathFullyQualified(custom)) @@ -616,7 +721,10 @@ private static string DistributionDirectory(string repository, string configurat private void VerifyDistributionStaging(string name, string pluginSource, string pluginDirectory) { string[] pluginFiles = RelativeFiles(pluginDirectory); - if (!pluginFiles.SequenceEqual([PluginFileName], StringComparer.Ordinal) + string[] expectedPluginFiles = _options.CompilerQualification + ? [PluginFileName, "appsettings.json"] + : [PluginFileName]; + if (!pluginFiles.SequenceEqual(expectedPluginFiles, StringComparer.Ordinal) || CheatEngineInstallation.Sha256(Path.Combine(pluginSource, PluginFileName)) != CheatEngineInstallation.Sha256(Path.Combine(pluginDirectory, PluginFileName))) { @@ -799,6 +907,10 @@ internal async Task ScanUiAsync(string name, string action) private string BuildDriver(string stopPath, string pluginPath, string name) { + string compilerProbe = _options.CompilerQualification + ? LiveCompilerBridge.Render(Path.Combine(_layout.RunDirectory, $"compiler-request-{name}.txt"), + Path.Combine(_layout.RunDirectory, $"compiler-response-{name}.txt"), LiveCompilerPayload.ValidSource, LuaString) + : "local function compilerProbe() end"; return $$""" local output = {{LuaString(Path.Combine(_layout.RunDirectory, $"driver-{name}.log"))}} local function log(value) @@ -841,6 +953,7 @@ local function scanProbe() response:write(ok and answer or ('error: '..tostring(answer))); response:close() os.rename(responsePath..'.tmp',responsePath) end + {{compilerProbe}} local timer = createTimer(nil, false) timer.Interval = 100 timer.OnTimer = function() @@ -852,7 +965,7 @@ local function scanProbe() pcall(function() getAddressList().clear() end) closeCE(); return end - if loaded then scanProbe(); return end + if loaded then scanProbe(); compilerProbe(); return end if not getMainForm() then return end loaded = true hideAllCEWindows() @@ -1015,6 +1128,72 @@ private string HostInstallationDirectory(string name) return Path.Combine(_layout.RunDirectory, $"ce-{name}"); } + private LiveCompilerRoots CompilerRoots(string name) + { + if (!_options.CompilerQualification) + { + throw new InvalidOperationException("Compiler roots were not admitted for this live session."); + } + + string root = Path.Combine(_layout.RunDirectory, "compiler", name); + return new LiveCompilerRoots(Path.Combine(root, "temp"), Path.Combine(root, "references"), + Path.Combine(root, "output")); + } + + private void CreateCompilerRoots(string name) + { + LiveCompilerRoots roots = CompilerRoots(name); + foreach (string path in new[] { roots.Temp, roots.References, roots.Output }) + { + RequireInsideRun(path); + Directory.CreateDirectory(path); + } + } + + private void CleanupCompilerRoots() + { + string compilerRoot = Path.Combine(_layout.RunDirectory, "compiler"); + RequireInsideRun(compilerRoot); + if (!Directory.Exists(compilerRoot)) + { + return; + } + + RequireNoReparseAncestors(compilerRoot); + CleanupCompilerDirectory(compilerRoot); + Directory.Delete(compilerRoot, false); + Record("ownedCompilerFilesRemoved", true); + } + + private void CleanupCompilerDirectory(string directory) + { + foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) + { + RequireInsideRun(entry); + FileAttributes attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new IOException("Compiler cleanup refused a reparse-point entry."); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + CleanupCompilerDirectory(entry); + Directory.Delete(entry, false); + } + else + { + File.Delete(entry); + } + } + } + + private void RequireNoReparseAncestors(string path) + { + RequireInsideRun(path); + LiveCompilerCandidate.RequireNoReparseAncestors(path); + } + private static string? HashIfExists(string path) { return File.Exists(path) ? CheatEngineInstallation.Sha256(path) : null; diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs index b535921..ad090f2 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs @@ -21,6 +21,11 @@ public sealed class McpLiveQualificationTests(LiveQualificationFixture fixture) public async Task OneGateway_TwoOwnedInstances_RoutesStateAndSurvivesOneHostShutdown() { LiveQualificationInputs inputs = fixture.RequireAuthorization(); + if (inputs.Scenario == LiveQualificationScenario.Compiler) + { + await LiveCompilerQualification.RunAsync(inputs); + return; + } await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs); await using LiveMcpClient gateway = await LiveMcpClient.ConnectGatewayAsync(sandbox.GatewayExecutablePath, sandbox.InstanceDirectory, diff --git a/tests/CheatEngine.Mcp.Tests/README.md b/tests/CheatEngine.Mcp.Tests/README.md index 8cceef9..1d8e172 100644 --- a/tests/CheatEngine.Mcp.Tests/README.md +++ b/tests/CheatEngine.Mcp.Tests/README.md @@ -39,6 +39,8 @@ It is opt-in, refuses to run in CI, requires Cheat Engine and DebugView to be cl Follow [Live qualification](../../CONTRIBUTING.md#live-qualification) exactly, including publishing the matching configuration and supplying the required acknowledgement environment variable. Do not include this category in an ordinary local test run. +The compiler-only qualification is a separately approved scenario. It is never selected by ordinary VL runs: set `CHEATENGINE_MCP_LIVE_QUALIFICATION_SCENARIO` to `compiler` and `CHEATENGINE_MCP_LIVE_CODE_EXECUTION_QUALIFICATION` to its exact acknowledgement from [the compiler policy](../../docs/qualification/v2.0.0/compiler-probe-policy.md). It refuses CI, a non-clean/unidentified candidate, a saved Cheat Engine `Don't use tempdir` override, or a missing reviewed Release distribution before a private run directory or Cheat Engine process is created. It compiles only the policy's fixed payloads and does not authorize injection or invocation. + ## Test layout | Path | Scope | From a080f5cdef515074ab6420bfc754e3e2c0979820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Thu, 8 Oct 2026 01:22:56 +0200 Subject: [PATCH 03/12] Harden compiler probe handoff and bounded response parsing --- .../LiveQualification/LiveCompilerBridge.cs | 4 +- .../LiveCompilerProbeResponse.cs | 54 +++++++++++ .../LiveCompilerProbeResponseTests.cs | 87 +++++++++++++++++ .../LiveCompilerQualification.cs | 4 +- .../LiveQualification/LiveSandboxSession.cs | 22 +---- .../NativeLuaLiveCompilerBridgeTests.cs | 97 +++++++++++++++++++ 6 files changed, 245 insertions(+), 23 deletions(-) create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponse.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponseTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLiveCompilerBridgeTests.cs diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs index c7a4742..b035d4f 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs @@ -18,7 +18,7 @@ internal static class LiveCompilerBridge local function compilerProbe() local request=io.open(__REQUEST__,'r') if not request then return end - local command=request:read('*a'); request:close(); os.remove(__REQUEST__) + local command=request:read('*a'); request:close(); assert(os.remove(__REQUEST__), 'compiler request dequeue failed') if command~='status' and command~='compile' then return end local available=type(compileCS)=='function' local value='' @@ -32,7 +32,7 @@ local function compilerProbe() os.rename(__RESPONSE__..'.tmp',__RESPONSE__) end """.ReplaceLineEndings("\n").TrimEnd('\n'); - internal const string TemplateSha256 = "A5B0280CE702D720CD496E5700CE30AD92FFD362EB825A60265A02B6682DA7F2"; + internal const string TemplateSha256 = "E42693FFD25AD257262CEA04C228D5540F4887FA8F5EC759A1E087EFA404A84B"; internal static void RequireReviewedHash() { diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponse.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponse.cs new file mode 100644 index 0000000..b2eed91 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponse.cs @@ -0,0 +1,54 @@ +using System.Globalization; +using System.Runtime.Versioning; +using System.Text; +using System.Text.Json.Nodes; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Reads only bounded, complete responses from the fixed private compiler bridge. +internal static class LiveCompilerProbeResponse +{ + [SupportedOSPlatform("windows")] + internal static async Task ReadAsync(string path, string command) + { + LiveCompilerCandidate.RequireNoReparseAncestors(path); + using FileStream stream = new(path, FileMode.Open, FileAccess.Read, FileShare.Read); + LiveCompilerCandidate.RequireNoReparseAncestors(path); + if (stream.Length > 32_768) + { + throw new InvalidDataException("The fixed compiler response exceeds its byte bound."); + } + using StreamReader reader = new(stream, new UTF8Encoding(false, true), false); + List lines = []; + while (await reader.ReadLineAsync(TestContext.Current.CancellationToken) is { } line) + { + lines.Add(line); + if (lines.Count > 4) + { + throw new InvalidDataException("The fixed compiler response has extra lines."); + } + } + return Parse([.. lines], command); + } + + internal static JsonNode Parse(string[] lines, string command) + { + if (command is not ("status" or "compile") || lines.Length != (command == "status" ? 3 : 4) || + lines[0] != "ok" || !bool.TryParse(lines[1], out bool available) || + !int.TryParse(lines[2], NumberStyles.None, CultureInfo.InvariantCulture, out int receipts) || + (command == "compile" && string.IsNullOrEmpty(lines[3]))) + { + throw new InvalidDataException("Fixed compiler bridge returned an invalid bounded response."); + } + JsonObject result = new() + { + ["available"] = available, + ["receiptCount"] = receipts + }; + if (command == "compile") + { + result[available ? "assemblyPath" : "diagnostic"] = lines[3]; + } + return result; + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponseTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponseTests.cs new file mode 100644 index 0000000..9221ac8 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponseTests.cs @@ -0,0 +1,87 @@ +namespace CheatEngine.Mcp.Tests.LiveQualification; + +public sealed class LiveCompilerProbeResponseTests +{ + [Theory] + [InlineData("oversize")] + [InlineData("extra-lines")] + [InlineData("empty")] + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + public async Task ReadAsync_OutsideTheResponseBounds_Refuses(string example) + { + string contents = example switch + { + "oversize" => new string('x', 32_769), + "extra-lines" => "ok\ntrue\n1\npath\nextra", + _ => string.Empty + }; + string path = Path.Combine(Path.GetTempPath(), $"CheatEngine.Mcp.CompilerResponse-{Guid.NewGuid():N}.txt"); + try + { + await File.WriteAllTextAsync(path, contents, TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(() => LiveCompilerProbeResponse.ReadAsync(path, "compile")); + } + finally { File.Delete(path); } + } + + [Fact] + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + public async Task ReadAsync_InvalidUtf8_RejectsInsteadOfReplacingBytes() + { + string path = Path.Combine(Path.GetTempPath(), $"CheatEngine.Mcp.CompilerResponse-{Guid.NewGuid():N}.txt"); + try + { + await File.WriteAllBytesAsync(path, [0xff], TestContext.Current.CancellationToken); + await Assert.ThrowsAsync(() => LiveCompilerProbeResponse.ReadAsync(path, "compile")); + } + finally { File.Delete(path); } + } + + [Fact] + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + public async Task ReadAsync_ExactByteBound_AcceptsCompleteUtf8Response() + { + string path = Path.Combine(Path.GetTempPath(), $"CheatEngine.Mcp.CompilerResponse-{Guid.NewGuid():N}.txt"); + try + { + await File.WriteAllTextAsync(path, "ok\ntrue\n1\n" + new string('x', 32_758), TestContext.Current.CancellationToken); + System.Text.Json.Nodes.JsonNode response = await LiveCompilerProbeResponse.ReadAsync(path, "compile"); + Assert.Equal(32_758, response["assemblyPath"]!.GetValue().Length); + } + finally { File.Delete(path); } + } + + [Fact] + public void Parse_Status_PreservesTheReceiptTypeReadByTheScenario() + { + System.Text.Json.Nodes.JsonNode response = LiveCompilerProbeResponse.Parse(["ok", "true", "7"], "status"); + Assert.True(response["available"]!.GetValue()); + Assert.Equal(7, response["receiptCount"]!.GetValue()); + Assert.Null(response["assemblyPath"]); + } + + [Theory] + [InlineData("true", "assemblyPath")] + [InlineData("false", "diagnostic")] + public void Parse_CompileResponse_KeepsSuccessAndFailureDistinct(string available, string expectedField) + { + System.Text.Json.Nodes.JsonNode response = LiveCompilerProbeResponse.Parse(["ok", available, "1", "value"], "compile"); + Assert.Equal("value", response[expectedField]!.GetValue()); + Assert.Null(response[expectedField == "assemblyPath" ? "diagnostic" : "assemblyPath"]); + } + + [Theory] + [InlineData("status", "ok", "true", "-1", null)] + [InlineData("status", "ok", "true", "2147483648", null)] + [InlineData("status", "ok", "true", "1", "unexpected")] + [InlineData("status", "error", "true", "1", null)] + [InlineData("status", "ok", "maybe", "1", null)] + [InlineData("compile", "ok", "true", "1", null)] + [InlineData("compile", "ok", "true", "1", "")] + [InlineData("unknown", "ok", "true", "1", null)] + public void Parse_MalformedOrWrongCommandShape_Refuses(string command, string marker, string available, string receipts, string? value) + { + string[] lines = value is null ? [marker, available, receipts] : [marker, available, receipts, value]; + Assert.Throws(() => LiveCompilerProbeResponse.Parse(lines, command)); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs index cf30393..2b92dbd 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs @@ -113,7 +113,7 @@ private static async Task VerifyCompilerAsync(LiveQualificationInputs inputs, ob receipts = await ReceiptCountAsync(sandbox, "A"); FileFact[] beforeRaw = Snapshot(sandbox, sandbox.CompilerA.Temp); JsonNode rawResult = await sandbox.CompilerProbeAsync("A", "compile"); - Assert.Equal(receipts + 1, rawResult["receiptCount"]!.GetValue()); + Assert.Equal(receipts + 1, rawResult["receiptCount"]!.GetValue()); string rawPath = rawResult["assemblyPath"]?.GetValue() ?? throw new InvalidOperationException("The fixed raw compiler bridge returned no assembly; qualification stops."); // Containment and every existing path component are checked before any read of the CE-returned path. @@ -210,7 +210,7 @@ private static async Task VerifySelectedTargetAsync(LiveMcpInstanceClient client } private static async Task ReceiptCountAsync(LiveSandboxSession sandbox, string name) => - (await sandbox.CompilerProbeAsync(name, "status"))["receiptCount"]!.GetValue(); + (await sandbox.CompilerProbeAsync(name, "status"))["receiptCount"]!.GetValue(); private static Dictionary Arguments(string source, string output, string[]? references = null, string? core = null) => new() diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs index 8b038d7..1d486ff 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs @@ -280,26 +280,10 @@ public async Task CompilerProbeAsync(string hostName, string command) RequireInsideRun(request); RequireInsideRun(response); File.Delete(response); - await File.WriteAllTextAsync(request, command); + await File.WriteAllTextAsync(request + ".tmp", command); + File.Move(request + ".tmp", request); await WaitUntilAsync(() => File.Exists(response), host.Process, TimeSpan.FromSeconds(10)); - string[] lines = await File.ReadAllLinesAsync(response); - if (lines.Length is < 3 or > 4 || lines[0] != "ok" || !bool.TryParse(lines[1], out bool available) || - !int.TryParse(lines[2], NumberStyles.None, CultureInfo.InvariantCulture, out int receipts)) - { - throw new InvalidOperationException("Fixed compiler bridge returned an invalid bounded response."); - } - - JsonObject result = new() - { - ["available"] = available, - ["receiptCount"] = receipts - }; - if (lines.Length == 4) - { - result[available ? "assemblyPath" : "diagnostic"] = lines[3]; - } - - return result; + return await LiveCompilerProbeResponse.ReadAsync(response, command); } /// Restarts only the disposable target for one still-running private CE host and publishes a fresh manifest. diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLiveCompilerBridgeTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLiveCompilerBridgeTests.cs new file mode 100644 index 0000000..d68aa3a --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLiveCompilerBridgeTests.cs @@ -0,0 +1,97 @@ +using CheatEngine.Mcp.Tests.LiveQualification; + +namespace CheatEngine.Mcp.Tests.NativeLua; + +/// Real-Lua coverage for the qualification-only fixed compiler bridge; all file and compiler operations are stubbed. +public sealed partial class NativeLuaToolRuntimeTests +{ + private const string CompilerBridgeStubs = """ + compilerCalls = 0 + removeCalls = 0 + removeOk = true + command = 'status' + response = '' + seen = {} + compileCS = function(...) + compilerCalls = compilerCalls + 1 + seen = table.pack(...) + return 'C:\\owned\\ce-cscode.dll', nil, 'third' + end + io = { open = function(path, mode) + if mode == 'r' then + return { read = function() return command end, close = function() end } + end + return { write = function(_, value) response = response .. value end, close = function() end } + end } + os = { remove = function() removeCalls = removeCalls + 1; return removeOk end, + rename = function() return true end } + """; + + [Fact] + public void LiveCompilerBridge_Status_DoesNotCallCompiler() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(CompilerBridgeStubs); + + RunBridge("compilerProbe()"); + + Assert.Equal(0L, ReadGlobal("compilerCalls")); + Assert.Equal(1L, ReadGlobal("removeCalls")); + Assert.Contains("ok\ntrue\n0", (string) ReadGlobal("response")!, StringComparison.Ordinal); + } + + [Fact] + public void LiveCompilerBridge_Compile_UsesOnlyFixedPayloadAndEmptyReferences() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(CompilerBridgeStubs + "\ncommand = 'compile'"); + + RunBridge("compilerProbe()"); + + Assert.Equal(1L, ReadGlobal("compilerCalls")); + Assert.Equal(LiveCompilerPayload.ValidSource, EvaluateLua("seen[1]")); + Assert.Equal(0L, EvaluateLua("#seen[2]")); + Assert.Null(EvaluateLua("seen[3]")); + Assert.Contains("C:\\owned\\ce-cscode.dll", (string) ReadGlobal("response")!, StringComparison.Ordinal); + } + + [Fact] + public void LiveCompilerBridge_Wrapper_PreservesNilAndMultipleOriginalReturns() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(CompilerBridgeStubs + "\ncommand = 'ignored'"); + + RunBridge("local a,b,c=compileCS('sentinel', nil, 'core'); wrapperReturns=table.pack(a,b,c)"); + + Assert.Equal(1L, ReadGlobal("compilerCalls")); + Assert.Equal("sentinel", EvaluateLua("seen[1]")); + Assert.Null(EvaluateLua("seen[2]")); + Assert.Equal("core", EvaluateLua("seen[3]")); + Assert.Equal((3L, "C:\\owned\\ce-cscode.dll", "third"), + ((long) EvaluateLua("wrapperReturns.n")!, (string) EvaluateLua("wrapperReturns[1]")!, + (string) EvaluateLua("wrapperReturns[3]")!)); + Assert.Null(EvaluateLua("wrapperReturns[2]")); + } + + [Fact] + public void LiveCompilerBridge_RequestDequeueFailure_DoesNotCallCompiler() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(CompilerBridgeStubs + "\ncommand = 'compile'; removeOk = false"); + + RunBridge("probeOk, probeError = pcall(compilerProbe)"); + + Assert.Equal(0L, ReadGlobal("compilerCalls")); + Assert.False((bool) ReadGlobal("probeOk")!); + Assert.Equal(1L, ReadGlobal("removeCalls")); + } + + private static void RunBridge(string tail) + { + string bridge = LiveCompilerBridge.Render("request", "response", LiveCompilerPayload.ValidSource, + static value => string.Concat('"', value.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal) + .Replace("\r", "\\r", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal), '"')); + InstallStubs(bridge + "\n" + tail); + } +} From 808a626e65eddab113737a27a7c2345555b1c241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Thu, 8 Oct 2026 01:31:48 +0200 Subject: [PATCH 04/12] Account for exclusive CE scan leases in compiler inventories --- .../v2.0.0/compiler-probe-policy.md | 5 ++++ .../LiveCompilerInventoryTests.cs | 25 +++++++++++++++++ .../LiveCompilerQualification.cs | 28 +++++++++++++++++-- 3 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerInventoryTests.cs diff --git a/docs/qualification/v2.0.0/compiler-probe-policy.md b/docs/qualification/v2.0.0/compiler-probe-policy.md index 996aab8..0547000 100644 --- a/docs/qualification/v2.0.0/compiler-probe-policy.md +++ b/docs/qualification/v2.0.0/compiler-probe-policy.md @@ -93,6 +93,11 @@ Only the gateway's local stdio and authenticated loopback backend connections ar 6. Record CE A and B process identifiers, activation identities, instance identifiers, private bytes, handle counts, and runtime capability evidence. 7. Refuse the run if a path escapes the run directory, a payload hash differs, a private copy differs, or an unrelated CE-like process appears. +CE's scanner creates an exclusive four-byte PID lease at `Cheat Engine/{GUID}/inuse.lock` below each private temporary root. +When that exact path and length produce a Windows sharing violation, record its path, length, and exclusive-lease status with an unavailable hash; never claim its unreadable bytes were compared. +All other unreadable files still stop qualification, and this exception never applies to an assembly, output, or reference file. +Temporary-inventory comparisons cover readable file hashes and the path/length of these explicitly recorded leases. + ## Phase 1: disabled gate before compiler admission 1. Start host A with `EnableTargetCodeExecution=false` and the other three effect gates false. diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerInventoryTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerInventoryTests.cs new file mode 100644 index 0000000..ab81122 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerInventoryTests.cs @@ -0,0 +1,25 @@ +using System.Runtime.Versioning; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +[SupportedOSPlatform("windows")] +public sealed class LiveCompilerInventoryTests +{ + [Fact] + public void IsExpectedScanLease_ExactCeGuidLeaseAndDwordSize_Accepts() + { + Assert.True(LiveCompilerQualification.IsExpectedScanLease("Cheat Engine/{336E5A59-AAAB-426C-802D-3923A12D3EEE}/inuse.lock", 4)); + } + + [Theory] + [InlineData("Cheat Engine/{336E5A59-AAAB-426C-802D-3923A12D3EEE}/inuse.lock", 0)] + [InlineData("Cheat Engine/{336E5A59-AAAB-426C-802D-3923A12D3EEE}/inuse.lock", 5)] + [InlineData("Cheat Engine/other/inuse.lock", 4)] + [InlineData("Cheat Engine/{336E5A59-AAAB-426C-802D-3923A12D3EEE}/assembly.dll", 4)] + [InlineData("Cheat Engine/extra/{336E5A59-AAAB-426C-802D-3923A12D3EEE}/inuse.lock", 4)] + [InlineData("other/{336E5A59-AAAB-426C-802D-3923A12D3EEE}/inuse.lock", 4)] + public void IsExpectedScanLease_AnyOtherFileOrSize_Refuses(string path, long length) + { + Assert.False(LiveCompilerQualification.IsExpectedScanLease(path, length)); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs index 2b92dbd..81a70b1 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs @@ -50,7 +50,9 @@ private static async Task VerifyDisabledGateAsync(LiveQualificationInputs inputs { noCompilerReceipt = true, noOutput = true, - tempUnchanged = true + tempInventoryUnchanged = true, + exclusiveScanLeases = before.Count(file => file.ExclusiveScanLease), + limitation = "Exclusive CE scan leases are compared by path and length; their bytes cannot be read while CE holds them." }); sandbox.MarkPassed(); } @@ -345,7 +347,21 @@ void Visit(string directory) else { Assert.True(files.Count < 256, "The compiler scratch tree exceeds the bounded file inventory."); - files.Add(ReadFile(sandbox, entry, root)); + try + { + files.Add(ReadFile(sandbox, entry, root)); + } + catch (IOException exception) when ((exception.HResult & 0xffff) == 32 && + (root == sandbox.CompilerA.Temp || root == sandbox.CompilerB.Temp)) + { + RequireOwnedPath(sandbox, entry, root); + long length = new FileInfo(entry).Length; + if (!IsExpectedScanLease(Path.GetRelativePath(root, entry), length)) + { + throw; + } + files.Add(new FileFact(Path.GetRelativePath(sandbox.RunDirectory, entry), length, null, true)); + } } } } @@ -381,5 +397,11 @@ private static void RequireOwnedPath(LiveSandboxSession sandbox, string path, st } } - private sealed record FileFact(string Path, long Length, string Sha256); + internal static bool IsExpectedScanLease(string relativePath, long length) + { + string[] parts = relativePath.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]); + return length == 4 && parts is ["Cheat Engine", var id, "inuse.lock"] && Guid.TryParseExact(id, "B", out _); + } + + private sealed record FileFact(string Path, long Length, string? Sha256, bool ExclusiveScanLease = false); } From 42665b68a829cd46e458efe3f9184612731e4621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Thu, 8 Oct 2026 01:51:19 +0200 Subject: [PATCH 05/12] Record reviewed compiler qualification on x64 and x86 fixtures --- .../v2.0.0/compiler-checkpoint.md | 112 ++++++++++++++++++ docs/qualification/v2.0.0/local-checkpoint.md | 1 + docs/qualification/v2.0.0/release-notes.md | 5 +- docs/qualification/v2.0.0/release-signoff.md | 8 +- docs/qualification/v2.0.0/support-matrix.md | 3 +- 5 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 docs/qualification/v2.0.0/compiler-checkpoint.md diff --git a/docs/qualification/v2.0.0/compiler-checkpoint.md b/docs/qualification/v2.0.0/compiler-checkpoint.md new file mode 100644 index 0000000..a54f9b4 --- /dev/null +++ b/docs/qualification/v2.0.0/compiler-checkpoint.md @@ -0,0 +1,112 @@ +# Compiler qualification checkpoint + +Date: 2026-10-08. +Status: Approved compiler-only phases 1 through 4 passed locally; stable release qualification remains incomplete. + +## Exact tested candidate + +The tested checkout was clean on `codex/roadmap-v2` at `808a626e65eddab113737a27a7c2345555b1c241`. +Both published binaries identify as `2.0.0-beta.2+808a626e65eddab113737a27a7c2345555b1c241`. +This checkpoint records those bytes; a later documentation commit is not a newly qualified binary candidate. +No push, tag, release upload, installed-plugin replacement, or managed injection was performed. + +| Artifact | SHA-256 | +| --- | --- | +| Release plugin DLL | `C07B5DBE73B8AE509C091850CE0F74501028CB868D4AEE0000F07E821B5419FC` | +| Release Native AOT gateway | `7DB3AF0853D85198FE07280BCBB5A4047115D08A344FC31FB8FF6ABE5F23139D` | +| Debug plugin DLL | `93F1F3B22A177654BC092E3525985049C1AD39F250CF7F7AE905C07C42E5A610` | +| Debug Native AOT gateway | `85B423D61D20D4EED5C8E2929132D78DD2B5BC24200EF79B576653834477B740` | +| Local Release ZIP | `EB803348B3F953D89D735637CCE95F254DCA5EF66B2480CDCF72239453EBFB42` | +| Reviewed compiler policy | `C35BC2F0A21639C52D8F69088349CEAB5B49DB64C88983DED33272304EBAD3B2` | +| Fixed raw-compiler bridge template | `E42693FFD25AD257262CEA04C228D5540F4887FA8F5EC759A1E087EFA404A84B` | + +The fixed source and reference hashes are checked before admission and recorded in each run summary. +They match the payloads in the [approved compiler policy](compiler-probe-policy.md). + +## Build and package evidence + +- Locked restore passed; Debug and Release nonincremental builds passed with zero warnings and errors. +- Portable tests passed 4,037/4,037 in each configuration, with zero failures or skips. +- NativeLua passed 611/611 using the installed Lua 5.3 x64 DLL and stubbed CE APIs. +- Both format checks passed. +- Both publications compared the actual lone packaged plugin DLL against the explicit goldens in two isolated load contexts and passed the Native AOT executable MCP smoke checks. +- The Release ZIP passed file and embedded-dependency inventory, matching binary identity, entry-hash, and checksum checks. + +The catalog remains 193 backend tools, 43 resources, 15 templates, and 43 prompts; gateway discovery adds `instance_list`. +Portable commands excluded `Category=LiveQualification` and `Category=NativeLua` and used `--fail-skips on`. +Build and package logs are retained locally as `artifacts/issue-edits/compiler-qualified-*.log`. +The tested distributions are `artifacts/dist/roadmap-debug` and `artifacts/dist/roadmap-release`. +The ZIP is `artifacts/releases/compiler-qualified/CheatEngine.Mcp-2.0.0-beta.2-win-x64.zip`; it does not replace the public beta.2 release. + +## Native environment and prerequisites + +| Component | Observed identity | +| --- | --- | +| OS | Windows 11 Pro x64, `10.0.26300` | +| Build SDK and test runtime | .NET SDK `10.0.401`; runtime `10.0.12` | +| Plugin host frameworks | .NET, ASP.NET Core, and Windows Desktop `10.0.12`, x64 | +| x86 fixture runtime | .NET `10.0.12`, x86 | +| Cheat Engine host | x64 `7.7.1.10828`; SHA-256 `CF6CCC664A90C23531EC20F5C41458CFDC1D04679D6D05ED94C06BCECF95544F` | +| CE compiler bridge | `CSCompiler.dll` `1.0.0.0`; SHA-256 `13DCE393B59B9EF4A5D4FCDC27267D018B350BDC44A62AACC5DBC7F1DF7F7A1C` | +| Installed .NET Framework | v4 Full release `533509`, version `4.8.09221` | +| Framework C# compilers | x86 and x64 `csc.exe` `4.8.9221.0` | +| Lua 5.3 x64 | SHA-256 `C95DCDFA0F60F97B43D970D77FD1BB907AF4DE04B500A3C89A99600B20B35BD2` | + +CE exposed `compileCS` and successfully compiled the fixed source on this installed prerequisite set. +This proves availability in the recorded environment, not a minimum-runtime claim or behavior when a prerequisite is absent. +The existing reviewed autorun hashes passed unchanged. +No prerequisite was installed, no source installation was changed, and no saved registry setting was changed to admit the run. +The x86/x64 labels below describe the selected disposable targets; the compiler and plugin host remained x64, and no generated assembly was loaded into either target. + +## Native results + +| Evidence ID | Target | Disabled-gate run | Enabled compiler run | Result | +| --- | --- | --- | --- | --- | +| COMPILER-01 | x64 | `20261007T234620Z-a6d5` | `20261007T234637Z-777e` | Pass; combined test 40.443 seconds. | +| COMPILER-02 | x86 | `20261007T234723Z-ba94` | `20261007T234739Z-96c7` | Pass; combined test 41.210 seconds. | + +Each disabled-gate run refused before compiler dispatch and produced no output or temporary-tree change. +Exclusive CE scan leases were compared by exact path and four-byte length; their unreadable contents were not claimed as hashed evidence. +Each enabled run compiled the valid fixed source once and immediately exported a 3,072-byte PE file whose length and SHA-256 matched the result. +Instance B stayed responsive and its compiler directories remained unchanged during A's compiler calls. +Invalid source, invalid reference, and invalid core assembly each returned the expected `host_refused` / `hostEffect=unknown` diagnostic with no published output or protected partial file. +Their diagnostic lengths were 16, 241, and 241 UTF-8 bytes respectively in both target runs, without truncation. +Missing reference and missing core assembly were refused as `not_found` / `hostEffect=not_started` without a compiler receipt or temporary-tree change. + +| Target | Export SHA-256 | Separate raw artifact SHA-256 | +| --- | --- | --- | +| x64 | `64CAEF72C3096D846301D3E9AE700EEA427DE5267A4B83CB958682FB3769ACF8` | `B5F3AC1BF2D8CC006B02EB2EE7ED85861AC999FFB503F2E06988FF58A681E7B0` | +| x86 | `59ECF4F9EFF2A9AEC4BFE811E37548FD3E99E5B50E268146145EB42E5892859B` | `50630CFAA9D4B2A07D78DEE0D53A043EAA4A5AD54FEB6DEAB18952E61051DA93` | + +Both runs classified raw lifetime as `retained_after_a`: the separate raw artifact retained its original hash after B closed and after A closed, at the policy's two-second observation intervals. +The exported file independently retained its own original hash after both shutdowns. +A and B used distinct temporary roots; these observations do not qualify shared-temp cleanup, reload, eventual expiry, or missing-artifact behavior. +The harness subsequently removed the owned compiler files only after all owned processes stopped and restoration succeeded. +All four run summaries report `passed=true`, empty `cleanupFailures`, `userStateRestored=true`, `sourceInstallationUnchanged=true`, and `ownedCompilerFilesRemoved=true`. +Preexisting MCP gateways were preserved, and no owned CE host or fixture remained after the runs. +No generated assembly was injected, invoked, or executed. + +## Refused attempts and path diagnosis + +Run `20261007T232735Z-e269` stopped before a compiler call because CE's exclusive scan lease could not be hashed. +The harness now records only the exact known lease's path, length, and exclusive status, while every other unreadable file still stops qualification. +The correction has seven portable regression cases and independent review. +Run `20261007T233614Z-351e` passed the disabled gate; enabled run `20261007T233631Z-d1f6` then refused its output path before compiler admission. +Read-only native probes established that the desktop app's packaged-app filesystem redirected the logical LocalApplicationData path into its package cache, despite ordinary directory attributes and no filesystem reparse tag. +The existing secure path policy correctly refused the redirect. +The successful runs used the existing `CHEATENGINE_MCP_LIVE_QUALIFICATION_RUN_ROOT` override with the verified physical directory; the unchanged anchored walk succeeded through every component. +No production path restriction was weakened, no additional compiler call was replayed, and all stopped sessions restored state and removed owned compiler files successfully. +The earlier attempts had zero compiler receipts. + +## Review and remaining work + +Independent Astra review covered the candidate and compiler harness, including exact opt-in, fixed payloads, candidate identity, request publication, response bounds, path containment, exclusive leases, cleanup, and evidence claims. +Review findings were fixed before the successful native runs, with portable and NativeLua regressions. +The final independent Astra pass found no actionable findings in the completed scope after checking all four summaries and validation logs and independently recomputing the artifact hashes. +This review does not claim the absence of all possible defects or completion of the open qualification and release gates. + +The feasibility and installed-prerequisite roadmap item is complete for the recorded CE build. +Unavailable compiler or absent-prerequisite behavior, shared-temp lifetime and expiry, reload, delayed export after closing B, and separate managed injection remain unqualified. +Optional phase 5 has not been approved. +The broader lifecycle/domain matrix, measured performance baselines, two-hour soak, installation/upgrade/rollback, supported MCP clients, CI/analysis, final stable source and package, and public release remain open. +The earlier pointer/native smoke evidence is retained separately in [local-checkpoint.md](local-checkpoint.md) and must not be attributed to these compiler-only runs. diff --git a/docs/qualification/v2.0.0/local-checkpoint.md b/docs/qualification/v2.0.0/local-checkpoint.md index 3a2103b..1cda7db 100644 --- a/docs/qualification/v2.0.0/local-checkpoint.md +++ b/docs/qualification/v2.0.0/local-checkpoint.md @@ -81,4 +81,5 @@ At this checkpoint, the compiler's actual availability, diagnostics, prerequisit Compiler-only phases 1 through 4 of the [compiler policy](compiler-probe-policy.md) were subsequently approved for this session; separate injection remains unapproved. The broader workflow matrix, repeated activation recovery, measured performance baselines, two-hour soak, installation/upgrade/rollback, named MCP clients, CI and analysis, clean final source identity, version/tag change, and public release verification remain open. The completed working-tree checks do not close any of those requirements. +Subsequent clean-commit compiler evidence is recorded separately in [compiler-checkpoint.md](compiler-checkpoint.md). See [release notes](release-notes.md) for the draft migration path and [release-signoff.md](release-signoff.md) for final gates. diff --git a/docs/qualification/v2.0.0/release-notes.md b/docs/qualification/v2.0.0/release-notes.md index 003bebf..254545a 100644 --- a/docs/qualification/v2.0.0/release-notes.md +++ b/docs/qualification/v2.0.0/release-notes.md @@ -13,8 +13,9 @@ Publication requires the final candidate gates in [release-signoff.md](release-s - Build CI now runs the portable suite in Debug and Release. - The private native fixture supports separate x86 and x64 runs and target restart checks. -The C# compiler's native prerequisites and temporary-file lifetime remain pending the separately approved [compiler qualification policy](compiler-probe-policy.md). -These notes must not describe that feature as natively qualified until its evidence is recorded. +The approved compiler-only phases passed on the exact local candidate and environment recorded in [compiler-checkpoint.md](compiler-checkpoint.md), with separate x86 and x64 disposable targets. +Both immediate exports survived both CE shutdowns; raw artifacts were retained at the observation points with distinct per-instance temporary roots. +Shared-temp expiry, unavailable prerequisites, reload, and separate injection remain unqualified, as does the final stable package. ## Support and limitations diff --git a/docs/qualification/v2.0.0/release-signoff.md b/docs/qualification/v2.0.0/release-signoff.md index 910ddcd..edb83ca 100644 --- a/docs/qualification/v2.0.0/release-signoff.md +++ b/docs/qualification/v2.0.0/release-signoff.md @@ -15,7 +15,7 @@ Issue #28 checkboxes must be updated only after the corresponding evidence is re - [x] Make the packaged-DLL probe compare the exact packaged plugin against the explicit candidate golden directory. - [x] Complete `pointer_get_access_info` implementation, V+, applicable VN, documentation, and supplied-facts gateway/plugin qualification; live debugger capture remains a separate open workflow. - [x] Complete `pointer_read_chains` implementation, V+, documentation, and x86 and x64 native fixture qualification. -- [ ] Complete the `compileCS` feasibility check under a reviewed policy. +- [x] Complete the `compileCS` feasibility check under a reviewed policy; see the exact environment and remaining native cases in [compiler-checkpoint.md](compiler-checkpoint.md). - [ ] Complete `exec_compile_csharp` implementation and qualification or record an explicit reviewed deferral with a linked follow-up. - [ ] Reconcile and freeze the final tools, schemas, resources, prompts, completions, routing, capability gates, and migration contract. - [ ] Complete lifecycle, recovery, workflow-domain, installation, upgrade, rollback, and supported-client qualification. @@ -33,11 +33,13 @@ Issue #28 checkboxes must be updated only after the corresponding evidence is re Working-tree evidence is recorded in [local-checkpoint.md](local-checkpoint.md), including package hashes, environment, test counts, two native run identifiers, cleanup, and limitations. It does not establish a clean final stable candidate. -The exact candidate and release gates below remain open until that clean candidate is built and qualified. +Compiler-only evidence for a clean local candidate is recorded in [compiler-checkpoint.md](compiler-checkpoint.md). +The exact candidate gates below refer to the final stable candidate and remain open. | Evidence ID | Requirements | Level | Candidate | Environment | Outcome | Cleanup | Safe reference | Status | | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| LOCAL-01 through LOCAL-08 | Sections 3-6 and bounded portions of 7, 8 and 11 | implementation, V+, VN, AOT, VL | Uncommitted `codex/roadmap-v2`, beta.2 version; exact artifact hashes in checkpoint | Windows 11 Pro 26300; CE 7.7.1.10828; .NET 10.0.12; x86/x64 targets | All recorded checks passed | Both native runs restored user state and preserved the source installation with no cleanup failures | [Local checkpoint](local-checkpoint.md) | pass within recorded scope | +| LOCAL-01 through LOCAL-08 | Sections 3-6 and bounded portions of 7, 8 and 11 | implementation, V+, VN, AOT, VL | Historical uncommitted `codex/roadmap-v2`, beta.2 version; exact artifact hashes in checkpoint | Windows 11 Pro 26300; CE 7.7.1.10828; .NET 10.0.12; x86/x64 targets | All recorded checks passed | Both native runs restored user state and preserved the source installation with no cleanup failures | [Local checkpoint](local-checkpoint.md) | pass within recorded scope | +| COMPILER-01 and COMPILER-02 | Section 6 feasibility and compiler-only phases 1-4; bounded section 11 checks | V+, VN, AOT, VL | Clean `808a626e65eddab113737a27a7c2345555b1c241`, beta.2, Release; exact artifact hashes in checkpoint | Windows 11 Pro 26300; CE 7.7.1.10828 x64; .NET 10.0.12 and Framework 4.8.09221; x86/x64 targets | Build/package checks and all approved compiler-only phases passed | Four sessions restored user state and preserved installation; compiler files removed; no cleanup failures | [Compiler checkpoint](compiler-checkpoint.md) | pass within recorded scope; no injection or stable release | The candidate column must include version, full commit, clean-tree state, configuration, and relevant SHA-256 values. The environment column must include exact native versions for VL, soak, installation, and release results. diff --git a/docs/qualification/v2.0.0/support-matrix.md b/docs/qualification/v2.0.0/support-matrix.md index 5a66a02..5f2886e 100644 --- a/docs/qualification/v2.0.0/support-matrix.md +++ b/docs/qualification/v2.0.0/support-matrix.md @@ -51,7 +51,8 @@ LBR, shared allocation, DBK map or unmap, caller-selected CR3 access, raw kernel All three P1 features are intended for stable v2.0.0 when their acceptance criteria in [acceptance.md](acceptance.md) are met. `pointer_get_access_info` and `pointer_read_chains` are included in the planned stable contract. -`exec_compile_csharp` is included in the plan but remains pending a bounded feasibility check against the reviewed Cheat Engine 7.7 host. +`exec_compile_csharp` passed bounded compiler feasibility on the clean local candidate recorded in [compiler-checkpoint.md](compiler-checkpoint.md). +Unavailable-compiler, shared-temp expiry, reload, separate injection, and final stable-package qualification remain open. If a P1 feature proves infeasible, it requires an explicit linked follow-up, the recorded blocking evidence, and a reviewed roadmap decision before contract freeze. No P1 feature may be silently deferred to fit a work session or release schedule. From d3984574e0d0757beebd3ea36deb6f9bef7358d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Thu, 8 Oct 2026 02:48:44 +0200 Subject: [PATCH 06/12] Add bounded lifecycle and remaining qualification runners --- docs/qualification/v2.0.0/acceptance.md | 18 +- .../v2.0.0/compiler-extended-policy.md | 13 + .../v2.0.0/contract-checkpoint.md | 48 ++ .../v2.0.0/requirement-matrix.md | 151 +++++ docs/qualification/v2.0.0/runners.md | 35 + docs/qualification/v2.0.0/support-matrix.md | 43 +- eng/Build-LiveFrameworkTarget.ps1 | 43 ++ eng/LiveFrameworkTarget/Program.cs | 31 + eng/LiveFrameworkTarget/README.md | 12 + eng/Stage-QualificationPackage.ps1 | 95 +++ tests/CheatEngine.Mcp.LiveTarget/Program.cs | 12 +- tests/CheatEngine.Mcp.LiveTarget/README.md | 6 +- .../LiveQualification/LiveCompilerBridge.cs | 37 +- .../LiveCompilerCandidate.cs | 6 +- .../LiveCompilerProbeResponse.cs | 2 +- .../LiveCompilerProbeResponseTests.cs | 10 + .../LiveCompilerQualification.cs | 190 ++++++ .../LiveQualification/LiveCompilerVariant.cs | 16 + .../LiveLifecycleQualification.cs | 79 +++ .../LiveLifecycleQualificationTests.cs | 116 ++++ .../LiveQualification/LiveLifecycleSession.cs | 117 ++++ .../LiveManagedInjectionQualification.cs | 333 ++++++++++ .../LiveQualification/LiveMcpClient.cs | 39 ++ .../LivePerformanceQualification.cs | 211 ++++++ .../LiveQualification/LivePluginBridge.cs | 47 ++ .../LiveQualificationOptIn.cs | 33 +- .../LiveQualificationOptInTests.cs | 64 ++ .../LiveQualificationScenario.cs | 7 +- .../LiveResourceQualification.cs | 64 ++ .../LiveQualification/LiveSandboxOptions.cs | 55 ++ .../LiveSandboxOptionsTests.cs | 140 ++++ .../LiveSandboxSession.Compiler.cs | 167 +++++ .../LiveSandboxSession.Lifecycle.cs | 277 ++++++++ .../LiveQualification/LiveSandboxSession.cs | 102 ++- .../LiveSoakQualification.cs | 260 ++++++++ .../LiveWorkflowQualification.cs | 610 ++++++++++++++++++ .../McpLiveQualificationTests.cs | 28 +- .../NativeLuaLiveCompilerBridgeTests.cs | 24 +- .../NativeLuaLivePluginBridgeTests.cs | 67 ++ 39 files changed, 3556 insertions(+), 52 deletions(-) create mode 100644 docs/qualification/v2.0.0/compiler-extended-policy.md create mode 100644 docs/qualification/v2.0.0/contract-checkpoint.md create mode 100644 docs/qualification/v2.0.0/requirement-matrix.md create mode 100644 docs/qualification/v2.0.0/runners.md create mode 100644 eng/Build-LiveFrameworkTarget.ps1 create mode 100644 eng/LiveFrameworkTarget/Program.cs create mode 100644 eng/LiveFrameworkTarget/README.md create mode 100644 eng/Stage-QualificationPackage.ps1 create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerVariant.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualification.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualificationTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleSession.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveManagedInjectionQualification.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceQualification.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LivePluginBridge.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveResourceQualification.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptionsTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Compiler.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Lifecycle.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSoakQualification.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs create mode 100644 tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLivePluginBridgeTests.cs diff --git a/docs/qualification/v2.0.0/acceptance.md b/docs/qualification/v2.0.0/acceptance.md index b211cee..c77c449 100644 --- a/docs/qualification/v2.0.0/acceptance.md +++ b/docs/qualification/v2.0.0/acceptance.md @@ -1,9 +1,13 @@ # Stable v2.0.0 acceptance criteria -Status: Planned criteria that have not yet passed. +Status: Stable-scope acceptance plan; criteria pass only at the evidence level recorded for an exact candidate. Implementation, offline verification, native qualification, soak, and release publication are separate evidence levels. A tool implementation or portable test result does not satisfy a native or release requirement. +The current status of every remaining roadmap item is recorded in [requirement-matrix.md](requirement-matrix.md). +The reviewed plan selects all three P1 features for stable v2.0.0 and does not silently defer any of them. +The observed planning environment is Windows 11 Pro x64 build `10.0.26300`, Cheat Engine x64 `7.7.1.10828`, and .NET runtime `10.0.12`. +That observation is not evidence for another Windows build, Cheat Engine build, runtime patch, or final release package. ## Evidence levels @@ -20,6 +24,7 @@ The portable suite must pass against both Debug and Release outputs for release VN evidence must record the selected Lua DLL path in redacted form, its version when available, and its SHA-256. VL remains outside ordinary CI and must follow the existing explicit acknowledgement, private-copy, user-state backup, reviewed-autorun, and disposable-target restrictions. Kernel, hypervisor, debugger injection, compiler, and managed invocation probes require their separately reviewed qualification policy before VL execution. +The four capability switches are enabled by default, file and table allowed roots are empty by default, and the switches are exposure controls rather than a sandbox. ## P1 pointer access analysis @@ -79,6 +84,8 @@ Exact integers, 64-bit addresses, signed offsets, null values, empty values, err Every routed backend call must require its explicit `instanceId`, and disappearance must never select another instance. The packaged-DLL probe must receive the reviewed golden directory explicitly and compare the actual lone packaged plugin DLL with the candidate goldens. The plugin DLL, gateway executable, ZIP, checksums, version, source identity, and tag must agree before publication. +The clean tested compiler candidate is `808a626e65eddab113737a27a7c2345555b1c241`, still identified as `2.0.0-beta.2`, and is not the final stable candidate. +The historical pointer smoke used different package bytes recorded in `local-checkpoint.md`, so its native results must not be attributed to the clean compiler candidate. ## Performance acceptance @@ -118,3 +125,12 @@ Upgrade qualification must replace beta.2 and any older folder deployment withou Install, upgrade, disable, uninstall, and rollback must preserve user configuration, tables, unrelated plugins, Cheat Engine settings, and recoverable backups. Paths with spaces, a standard user account, allowed roots, read-only deployment behavior, malformed configuration, occupied endpoints, and unavailable log locations must have recorded outcomes. The final release notes must state the support matrix, beta migration, capability limitations, recovery paths, and changes since beta.2. +The beta-to-stable contract permits additive optional fields and new tools when existing callers retain their behavior. +Removing or renaming tools, changing existing field meanings, changing pointer offset order, or changing host-effect reporting requires a reviewed future breaking contract. +The beta tags remain unchanged, and stable publication uses a new annotated `v2.0.0` tag at the exact final binary source commit. + +## P2 and research disposition + +Section 12 conveniences and section 13 research are outside the stable v2.0.0 blocking path unless a later reviewed decision promotes one into scope. +Each item still requires an explicit linked follow-up or an unsupported disposition before roadmap closure. +No unchecked P2 or research item may be described as implemented, qualified, or silently deferred. diff --git a/docs/qualification/v2.0.0/compiler-extended-policy.md b/docs/qualification/v2.0.0/compiler-extended-policy.md new file mode 100644 index 0000000..feacb90 --- /dev/null +++ b/docs/qualification/v2.0.0/compiler-extended-policy.md @@ -0,0 +1,13 @@ +# Extended compiler qualification policy + +Status: harness preparation only. No native execution is approved by this document. + +`compiler-extended` may be selected only after the existing two compiler acknowledgements and a reviewed policy revision. It adds fixed, non-operator-controlled variants: + +* private-copy `CSCompiler.dll` quarantine after official-source identity preflight, to observe unavailable/prerequisite behavior without changing the installed CE copy; +* shared run-owned TEMP topology, to observe B shutdown and a reviewed plugin reload; and +* a one-shot bounded post-`compileCS` hold that writes an owned ready receipt, waits at most ten seconds for an owned release file, then returns the original values unchanged. The harness may stop B while the production `exec_compile_csharp` call is held, then release in `finally`. Cancellation, timeout, missing release, or an unexpected file is a stop condition and never retries compilation. + +The hold bridge accepts only fixed `armHold` and `releaseHold` commands; it cannot choose source, paths, target, or process. It never deletes, renames, or copies CE output. + +`compiler-injection` remains disabled pending a third exact acknowledgement and separate review. A fixed .NET Framework fixture must publish hash, PID, architecture, and CLR identity before one fixed `exec_inject_dotnet` call may be considered. The existing .NET 10 target is not evidence for that prerequisite. diff --git a/docs/qualification/v2.0.0/contract-checkpoint.md b/docs/qualification/v2.0.0/contract-checkpoint.md new file mode 100644 index 0000000..b55e111 --- /dev/null +++ b/docs/qualification/v2.0.0/contract-checkpoint.md @@ -0,0 +1,48 @@ +# Stable v2.0.0 contract checkpoint + +Date: 2026-10-08. +Status: Current-source audit with bounded clean-candidate evidence; final stable contract freeze remains incomplete. + +## Candidate identity + +The clean tested candidate is `808a626e65eddab113737a27a7c2345555b1c241` and still identifies as `2.0.0-beta.2+808a626e65eddab113737a27a7c2345555b1c241`. +Its Release plugin SHA-256 is `C07B5DBE73B8AE509C091850CE0F74501028CB868D4AEE0000F07E821B5419FC`. +Its Release Native AOT gateway SHA-256 is `7DB3AF0853D85198FE07280BCBB5A4047115D08A344FC31FB8FF6ABE5F23139D`. +Its local Release ZIP SHA-256 is `EB803348B3F953D89D735637CCE95F254DCA5EF66B2480CDCF72239453EBFB42`. +The clean candidate passed 4,037 portable tests in both Debug and Release, 611 NativeLua tests, both packaged-DLL golden comparisons, and the Native AOT executable checks recorded in `compiler-checkpoint.md`. +The historical pointer smoke used Release plugin SHA-256 `E6E2AE56B43CF54F1F5978B566EE762939B76E549D557FDDF8B073BB8262750B` and must not be attributed to the clean compiler candidate. +Neither candidate is the final stable package. + +## Section 3 crosswalk + +| ID | Current result | Exact existing evidence | Required candidate level and remaining work | +| --- | --- | --- | --- | +| 3.01 | partial | `ContractSnapshotTests.GatewayCatalog_CurrentBuild_MatchesGoldenSnapshot`, `ContractSnapshotTests.Backend_ToolsAndInitialize_MatchGoldenSnapshots`, `CompositionParityTests.SchemaOnlyCatalog_MatchesTheLiveBackendListing`, `CompositionParityTests.GatewayTools_AreTheBackendToolsWithOnlyTheRoutingArgumentAdded`, and the clean packaged-DLL comparison passed. | Repeat V+ and packaged reflection after final names, workflows, prompts, resources, and goldens freeze on the exact RC and final stable commit. | +| 3.02 | pass for the clean tested candidate | `CompositionParityTests.PluginAndGatewayExecutable_RegisterTheSameJsonResolvers`, both Debug and Release Native AOT checks, and the packaged-DLL golden comparison cover the three P1 input and result types. | Repeat V+, AOT, and packaged reflection on the exact RC and final stable commit. | +| 3.03 | partial | `OutputSchemaContractTests.ToolOutputSchemas_NeverRequireANullableMember`, `ContractJsonTests.Serialize_MinimalError_OmitsNullFields`, and domain portable tests cover many nullable results. | Audit every unavailable, zero, empty string, and empty collection result shape before contract freeze, then retain the audit with V+. | +| 3.04 | partial | Pointer portable and NativeLua tests cover signed offsets and width arithmetic, while schema goldens preserve string-form hexadecimal addresses. | Add an evidence audit through a real MCP client for maximum 64-bit addresses, negative offsets, exact integers above JavaScript safe range, and hexadecimal round trips. | +| 3.05 | pass for the clean tested candidate | `GatewayToolCatalogTests.RoutedTools_InstanceId_IsTheFirstPropertyAndRequirement`, `GatewayServerTests.CallTool_TwoPublishedInstances_RoutesOnlyToRequestedBackendAndStripsInstanceId`, `GatewayServerTests.CallTool_MissingInstanceId_ReturnsInvalidArgumentEnvelope`, `GatewayServerTests.CallTool_UnknownInstanceId_ReturnsInstanceUnavailableNotStarted`, and `GatewayServerTests.CallTool_StaleIdentity_ReturnsInstanceUnavailableNotStartedAndNeverForwards` cover explicit routing and disappearance. | Repeat portable V+ and the maintained two-instance VL on the exact final package. | +| 3.06 | partial | `McpContractRulesTests.BackendCatalog_ContainsOnlyValidatedV2Tools`, `McpContractRulesTests.ValidateTool_ReadOnlyAndDestructive_Fails`, `McpContractRulesTests.ValidateTool_PollNotReadOnlyOrNotIdempotent_Fails`, `ToolCatalogContractTests.RegisteredV2Tools_OpenWorldAnnotation_MatchesTheReviewedList`, and the generated `tool-summary.txt` cover structural rules. | Review every tool annotation, dispatch class, and capability against its implementation effect before contract freeze. | +| 3.07 | partial | `FeatureOptionsTests.ClientOptIns_FollowTheSameFeatureInstance`, `FeatureOptionsTests.ReadFeatureOptions_AbsentSection_EnablesEverySwitch`, compiler disabled-gate evidence, and domain gate tests cover direct paths. | Complete the loaded-table, record-script, indirect Lua, indirect Auto Assembler, and argument-dependent effect audit and retain effect-before-refusal evidence. | +| 3.08 | pass at documentation level | `McpConfigurationTests.Load_Defaults_AreLoopbackAndAllExecutionIsEnabled`, `McpConfigurationTests.Load_PluginUserAndEnvironment_UseDocumentedPrecedence`, README configuration text, `support-matrix.md`, and `acceptance.md` agree on enabled switches, empty roots, precedence, and exposure semantics. | Reconcile these statements against the exact RC configuration and final release notes. | +| 3.09 | partial | `ContractJsonTests.Serialize_FullError_KeepsTheEnvelopeFieldOrder`, `ContractJsonTests.TryRead_CreatedResult_RoundTripsTheError`, `GatewayServerTests.CallTool_BackendErrorResult_PassesThroughUnchanged`, `GatewayServerTests.CallTool_BackendProtocolError_PassesThroughWithItsCode`, and `ErrorCorrelationTests.CallTool_ReportedInternalError_KeepsItsDetailsAndLogsTheSameErrorId` cover typed envelopes and correlation. | Audit retryability, hints, details, and correlation for representative direct backend and routed failures at every host-effect class. | +| 3.10 | pass for portable routing behavior | `GatewayServerTests.CallTool_CancelledForward_DoesNotRetryAnotherInstance`, `GatewayServerTests.CallTool_BackendHang_ReturnsTimeoutUnknownAndNeverRetries`, and `GatewayServerTests.CallTool_BackendDropsConnection_ReportsUnknownEffectAndNeverRetries` prove the gateway does not replay uncertain calls. | Retain these tests in V+ and verify representative native mutation state after a timeout or lost response. | +| 3.11 | pass for the clean tested candidate | `ContractSnapshotTests.GatewayExecutable_StdioWithoutInstances_MatchesGoldenSnapshots`, `GatewayServerTests.ListTools_WithoutPublishedInstances_ExposesStableRoutedCatalog`, `GatewayResourceTests.ListResources_WithoutInstances_ServesTheDocumentsAndTheInstanceList`, `GatewayResourceTests.ReadResourceAndGetPrompt_Local_AreServedWithoutAnyBackend`, and `GatewayCompletionTests.Complete_AllowedValuesOfARoutedTemplate_AreServedByTheGatewayWithoutAnyBackend` cover no-instance behavior. | Repeat AOT executable and V+ checks on the exact RC and final stable gateway. | + +## Source boundaries reviewed + +The contract tests are in `tests/CheatEngine.Mcp.Tests/Contract/ContractSnapshotTests.cs`, `CompositionParityTests.cs`, `OutputSchemaContractTests.cs`, `ToolCatalogContractTests.cs`, and `ToolContractTests.cs`. +The serialization and catalog rules are tested in `tests/CheatEngine.Mcp.Tests/Core/ContractJsonTests.cs` and `McpContractRulesTests.cs`. +The gateway routing, resources, completions, and errors are tested in `tests/CheatEngine.Mcp.Tests/Hosting/GatewayServerTests.cs`, `GatewayToolCatalogTests.cs`, `GatewayResourceTests.cs`, `GatewayCompletionTests.cs`, and `ErrorCorrelationTests.cs`. +The feature defaults, precedence, validation, and activation boundary are tested in `tests/CheatEngine.Mcp.Tests/Plugin/FeatureOptionsTests.cs`, `McpConfigurationTests.cs`, and `McpModuleLifecycleTests.cs`. +`McpContractRules` validates public naming, annotations, dispatch classes, schemas, and output presence at catalog construction. +`GatewayToolCatalog` adds the required routing argument while preserving every backend field. +`GatewayRouter`, `BackendConnectionPool`, and `InstanceIdentityVerifier` bind a request to one verified activation and report unknown effects without replay. +`McpFeatureGate` and the domain builders carry the four capability requirements into the public catalog and runtime admission path. +`McpEnvironmentConfigurationSource`, `McpOptionsValidators`, and the plugin configuration builder implement the documented precedence and validation boundary. + +## Contract freeze gate + +The stable contract cannot freeze until partial rows 3.01, 3.03, 3.04, 3.06, 3.07, and 3.09 have reviewed evidence. +Rows that pass for `808a626e65eddab113737a27a7c2345555b1c241` still require the exact RC and final-candidate repetitions named above. +This checkpoint does not qualify any complete native workflow domain, installation path, client, soak, or public release. diff --git a/docs/qualification/v2.0.0/requirement-matrix.md b/docs/qualification/v2.0.0/requirement-matrix.md new file mode 100644 index 0000000..1d80a8e --- /dev/null +++ b/docs/qualification/v2.0.0/requirement-matrix.md @@ -0,0 +1,151 @@ +# Stable v2.0.0 requirement matrix + +Date: 2026-10-08. +Status: Crosswalk of the 121 currently unchecked roadmap entries; it is not a completion claim. + +pass means the complete requirement has evidence at the level stated, with final-candidate repetitions separate when named. +partial means useful evidence exists but one or more clauses remain unproved. +not_run means no execution evidence exists for the requirement. +environment_dependency means completion requires an approved policy, controlled environment, actual client, runtime, hardware, or external release boundary. + +The clean tested compiler candidate is 808a626e65eddab113737a27a7c2345555b1c241, still identified as beta.2. +The historical pointer smoke used Release plugin SHA-256 E6E2AE56B43CF54F1F5978B566EE762939B76E549D557FDDF8B073BB8262750B and is not evidence for the clean compiler candidate. +The observed environment is Windows 11 Pro x64 build 10.0.26300, Cheat Engine x64 7.7.1.10828, and .NET runtime 10.0.12. +No row generalizes that environment to another Windows, CE, runtime, target, or client. + +| ID | Requirement | Status | Evidence now | Remaining dependency or action | Owner | +| --- | --- | --- | --- | --- | --- | +| 2.01 | Confirm final version/tag as `2.0.0` / `v2.0.0`; retain existing beta tags and publish a new stable release. | partial | support-matrix.md selects 2.0.0 and v2.0.0 while retaining beta tags, but publication has not occurred. | Publish only after the final stable package passes every release gate. | release coordinator | +| 2.02 | Set the supported Windows versions, CE version/build, plugin architecture, .NET host frameworks, and target architectures in one compatibility matrix. | pass | support-matrix.md records the intended Windows, CE host, plugin, runtime, gateway, target, transport, and client boundaries. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | +| 2.03 | Start from the current Windows x64 / CE 7.7 support boundary; qualify x86 and x64 **targets** separately from the x64 plugin host. | partial | The stable definition is recorded in support-matrix.md and acceptance.md, with the remaining limitation named in this row. | Complete the selected x86 and x64 target workflow matrix; current pointer and compiler evidence is narrower. | release coordinator | +| 2.04 | Classify each advertised capability as qualified, optional/environment-dependent, experimental, or unsupported, with evidence and limitations. | partial | The stable definition is recorded in support-matrix.md and acceptance.md, with the remaining limitation named in this row. | Review the per-domain table and replace experimental or optional status only with complete evidence. | release coordinator | +| 2.05 | Define which P1 features belong in stable v2 before an RC is tagged; move deferred work into linked follow-ups. | pass | All three P1 features are intentionally selected in support-matrix.md and acceptance.md. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | +| 2.06 | Freeze stable tool names, arguments, result/error shapes, resource URIs, prompts, and gateway routing semantics. | partial | The stable definition is recorded in support-matrix.md and acceptance.md, with the remaining limitation named in this row. | Finish the section 3 audit, freeze the RC contract, and repeat exact-candidate snapshots. | release coordinator | +| 2.07 | Write the beta-to-stable compatibility policy: additive evolution, intentional breaking changes, configuration migration, and future deprecations. | pass | support-matrix.md and acceptance.md define additive compatibility, breaking changes, migration, and tag handling. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | +| 2.08 | Define release-stopping defects: crashes, corruption, cross-instance/target effects, unreported partial writes, leaked state, broken installation, and wrong outcome reporting. | pass | support-matrix.md defines the release-stopping defect classes. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | +| 2.09 | Set the RC bug-bash/soak duration, supported environments, exercised workloads, and pass/fail criteria; record results rather than infer stability from tool counts. | pass | acceptance.md defines the two-hour two-instance workload and pass or fail criteria. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | +| 2.10 | Agree measurable performance and responsiveness thresholds from section 9's baselines, including workload sizes, measurement method, and allowed exceptions for blocking native calls. | partial | acceptance.md defines proposed measured-relative p95 budgets and blocking-native exceptions, but no three-run baseline exists. | Run and review the three baselines before approving the budgets. | release coordinator | +| 3.01 | Reconcile catalog constants, registrations, generated snapshots, tool map, workflow prompts, and packaged-plugin reflection output for the final candidate. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Reconcile final names, schemas, prompts, resources, workflows, tool map, and packaged DLL on the exact RC. | contract owner | +| 3.02 | Verify schema compatibility with reflection-disabled JSON and the published Native AOT gateway, including every new input/result type. | pass | contract-checkpoint.md names resolver parity, AOT, packaged reflection, and the clean candidate result. | Complete the named audit gap and repeat V+ on the exact RC and final stable commit. | contract owner | +| 3.03 | Verify nullable/unavailable results remain distinct from legitimate zero/empty values. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Audit unavailable, null, zero, empty string, and empty collection results across every domain. | contract owner | +| 3.04 | Verify 64-bit addresses, signed offsets, exact integer values, and hexadecimal conventions survive client serialization without precision loss. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Run exact integer, maximum address, negative offset, and hexadecimal round trips through a real MCP client. | contract owner | +| 3.05 | Verify every routed call requires its explicit `instanceId`; instance disappearance must never silently select another CE process. | pass | contract-checkpoint.md names required-instance, stale-instance, and two-instance routing tests. | Complete the named audit gap and repeat V+ on the exact RC and final stable commit. | contract owner | +| 3.06 | Verify read-only/destructive/idempotent/open-world annotations, dispatch classes, and capability requirements match actual effects. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Review every annotation, dispatch class, and capability against the implementation effect. | contract owner | +| 3.07 | Verify disabled capabilities reject before the covered effect, including loaded tables, scripts, indirect Lua/AA paths, and argument-dependent operations. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Complete loaded-table, record-script, indirect Lua or AA, and argument-dependent gate cases. | contract owner | +| 3.08 | Document actual defaults: the four capability switches are currently on; file/table allowed roots are empty. Switches are exposure controls, not a sandbox. | pass | Configuration tests and public docs agree on enabled switches, empty roots, precedence, and exposure semantics. | Complete the named audit gap and repeat V+ on the exact RC and final stable commit. | contract owner | +| 3.09 | Verify typed `isError`, `error.kind`, `hostEffect`, retryability, details, hints, and correlation IDs through direct backend and gateway calls. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Audit retryability, details, hints, correlation, and every host-effect class through backend and gateway. | contract owner | +| 3.10 | Preserve the rule that uncertain writes/injections/patches are not automatically replayed after timeout or transport failure. | pass | contract-checkpoint.md names cancellation, timeout, and dropped-connection no-replay tests. | Complete the named audit gap and repeat V+ on the exact RC and final stable commit. | contract owner | +| 3.11 | Verify tools/list, resources, prompts, completions, and offline knowledge remain consistent when no CE instance is running. | pass | contract-checkpoint.md names no-instance tool, resource, prompt, completion, and executable snapshot tests. | Complete the named audit gap and repeat V+ on the exact RC and final stable commit. | contract owner | +| 6.01 | Report CE-close/reload lifetime accurately, including CE's documented possibility that another CE instance closing removes an unused compiler output; verify this behavior on the supported build. Do not promise assembly unload or cleanup that CE cannot confirm. | environment_dependency | compiler-checkpoint.md records approved phases 1 through 4 on clean 808a626e65eddab113737a27a7c2345555b1c241. | Run reviewed shared-temp close and reload observations on private CE copies and record retained or expired behavior. | compiler qualification owner | +| 6.02 | Qualify valid source, diagnostics, unavailable compiler, invalid/missing references, disabled capability, and generated-artifact expiry. | environment_dependency | compiler-checkpoint.md records approved phases 1 through 4 on clean 808a626e65eddab113737a27a7c2345555b1c241. | Add unavailable-prerequisite and expiry cases under an approved policy. | compiler qualification owner | +| 6.03 | Under the separately reviewed qualification policy, compile in instance A, close instance B, then export and separately inject from A; verify either retained availability or the documented missing/expired-artifact outcome, including cleanup. | environment_dependency | compiler-checkpoint.md records approved phases 1 through 4 on clean 808a626e65eddab113737a27a7c2345555b1c241. | Approve injection phase 5, define a deterministic owned method, and run close-B, delayed-export, injection, and cleanup. | compiler qualification owner | +| 7.01 | Exercise repeated plugin enable/disable/re-enable cycles; verify fresh activation identity, token, endpoint/discovery record, Client state, and no stale service reuse. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 7.02 | Exercise CE shutdown, target exit/restart, client disconnect, gateway restart, and backend withdrawal while another instance remains usable. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 7.03 | Qualify blocked target changes while retained state or active scans exist, and successful switching after explicit cleanup. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 7.04 | Prove a reused PID or stale selection epoch cannot apply work to a replacement target. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 7.05 | Verify allocations, patches, registered symbols, named scanners, MCP breakpoints, pauses, speed changes, jobs, and Mono attachment follow their documented ownership/release paths. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 7.06 | Preserve CE/user-owned table records, structures, breakpoints, symbols, and unrelated state during MCP cleanup. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 7.07 | Verify failed cleanup retains accurate orphan/recovery information instead of reporting successful release. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 7.08 | Exercise cancellation before admission, during a job, and after native work starts; keep outcomes distinct. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 7.09 | Exercise timeout/lost response after mutations; inspect state and report unknown effects without automatic replay. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 7.10 | Verify cursor replay, pagination, retained finished jobs, TTL expiry, bounded buffers, dropped-item accounting, and stop/dispose races. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 7.11 | Verify stale IDs/tokens/discovery records are rejected and refreshed only through explicit instance discovery. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 7.12 | Verify cleanup order and idempotency where supported, including failure midway through releasing several resources. | partial | Portable lifecycle and ownership tests plus bounded LOCAL-07 and LOCAL-08 smoke evidence cover only named subcases. | Extend the private lifecycle scenario, run the exact bounded case, and record ownership, cross-instance behavior, and cleanup. | lifecycle qualification owner | +| 8.01 | Memory: scalar/array/batch reads and writes, strings, bytes, signedness, byte order, readback, unreadable pages, and partial writes. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.02 | Memory utilities: snapshots/comparison/sampling/copy/hash, protection restore, allocation lifetime, dump/load limits, and file-policy failures. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.03 | Value scans: exact/unknown/between/changed/increased/decreased, empty/default inputs, x86/x64 ranges, first/next/reset/delete, saved/named scanner isolation, and cancellation support. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.04 | Mapped memory: inclusion and override restoration after success, refusal, error, cancellation, and target changes. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.05 | AOBs: module/range/protection/alignment filters, multiple patterns, wildcard matching, mapped memory, uniqueness, and signature verification against real fixture code. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.06 | Pointers: chain hops, positive/negative offsets, pointer maps, path limits, rescans, module rebasing, incomplete captures, and native scan-file import/export fidelity. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.07 | Modules/symbols: PE headers, imports/exports, patch comparison, exact resolution, PDB layouts, symbol reload/state, and ownership of registered symbols. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.08 | Code analysis: disassembly bytes/columns, function estimates, direct branches, CFG limits, strings/references, dissection jobs, cancellation, and explicit limits for stores/JIT/indirect control flow. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.09 | Structures: autoguess limitations, field alignment/signedness, nested definitions, typed read/write/compare, PDB/.NET fill, header generation, and CE-owned persistence. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.10 | Records/tables: hierarchy, groups, pointers, dropdowns, activation/freeze, script gates, load/save/merge, malformed/protected content, rollback/partial effects, and user-owned data preservation. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.11 | User-mode debugger: attach/detach, thread breakpoints, access/write/execute captures, effective-address grouping, context/register writes, stepping/tracing, thread filters, and restore/cleanup. | environment_dependency | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Define or approve the policy and controlled environment, then run the complete row without treating unavailability as success. | domain qualification owner | +| 8.12 | Debugger interface policy: Windows/VEH/kernel behavior, gate checks, fallback reporting, reattach restrictions, and explicit refusal of unsupported interfaces/connections. | environment_dependency | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Define or approve the policy and controlled environment, then run the complete row without treating unavailability as success. | domain qualification owner | +| 8.13 | .NET: fixture-supported domains/modules/types/methods/parameters, object reads and bounded instance searches, runtime unavailable cases, and collector lifetime. | environment_dependency | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Define or approve the policy and controlled environment, then run the complete row without treating unavailability as success. | domain qualification owner | +| 8.14 | Mono/IL2CPP: attachment/status, assemblies/classes/fields/methods, static fields, object layouts, bounded search, and documented generic/array/collector limitations; qualify invocation separately where permitted. | environment_dependency | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Define or approve the policy and controlled environment, then run the complete row without treating unavailability as success. | domain qualification owner | +| 8.15 | Assembly/execution: reversible patch apply/disable, API-hook/injection scaffolds, native/managed injection, local/remote/member calls, C compilation and (if selected for stable v2) C# compilation, calling conventions, and uncertain timeout effects, under a separately reviewed qualification policy. | environment_dependency | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Define or approve the policy and controlled environment, then run the complete row without treating unavailability as success. | domain qualification owner | +| 8.16 | Lua: fixed-script error/result conversion, bounded structured output, malformed/opaque values, capability classification, main-thread behavior, and lifecycle transitions. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.17 | Kernel/DBVM: retain explicit environment-dependent status; separately document qualified status/translation/physical-memory/watch cases and supported refusal paths without treating unavailable hardware as a pass. | environment_dependency | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Define or approve the policy and controlled environment, then run the complete row without treating unavailability as success. | domain qualification owner | +| 8.18 | Speedhack: supported/unsupported targets, non-default speed restoration, ownership, target switching, and cross-instance isolation. | environment_dependency | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Define or approve the policy and controlled environment, then run the complete row without treating unavailability as success. | domain qualification owner | +| 8.19 | Keep the existing live harness opt-in, outside ordinary CI, on private CE copies and disposable targets, with user-state backup/recovery evidence. | pass | The live harness is opt-in, private-copy, disposable-target, backup guarded, and outside ordinary CI. | Repeat the same admission and cleanup boundary for the exact RC and final package. | domain qualification owner | +| 8.20 | Preserve its reviewed-autorun checks; inspect upstream changes before updating approved hashes. | pass | The historical and compiler checkpoints record unchanged reviewed autorun hashes. | Repeat hash validation and inspect any upstream change before accepting a different autorun set. | domain qualification owner | +| 8.21 | Design richer disposable fixtures for pointer chains, native code/structures/RTTI, tables/PDBs, and x86 targets where supported. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.22 | Design separate controlled managed fixtures for .NET, Mono, and IL2CPP and record the exact collector/runtime versions. | not_run | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.23 | Define and review any new qualification policy for debugger/injection/compiler/managed-invocation/kernel work. The current harness explicitly excludes kernel, hypervisor, and code-execution probes; this issue does not remove that restriction. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 8.24 | Record unqualified optional capabilities explicitly, with follow-ups or experimental support status; do not claim universal native compatibility from the existing smoke scenario. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | +| 9.01 | Establish reproducible latency/UI-responsiveness baselines for short reads, large batches, regions, scans, pointers, symbols, structures, dissection, and instance discovery. | not_run | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Run three reproducible baseline measurements with fixed workload sizes and environment identity. | performance qualification owner | +| 9.02 | Choose practical acceptance budgets per dispatch class and document operations that can block CE or outlive an MCP timeout. | partial | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Approve planned budgets after baseline review and enumerate blocking-native exceptions. | performance qualification owner | +| 9.03 | Check dispatch concurrency/busy responses under multiple clients without starving CE's main thread. | not_run | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Measure multiple-client admission and busy latency while monitoring CE main-thread responsiveness. | performance qualification owner | +| 9.04 | Verify large jobs stay within configured job/buffer/TTL/result bounds and expose truncation or dropped data. | partial | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | +| 9.05 | Verify expensive completions/live-resource reads share admission limits and cannot turn ordinary client navigation into an unbounded scan. | partial | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | +| 9.06 | Exercise sustained polling and repeated enable/disable cycles; check managed/native memory, handles, sockets, retained jobs, and event subscriptions. | not_run | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Run the two-hour soak and compare handles, private bytes, sockets, jobs, and subscriptions to baseline. | performance qualification owner | +| 9.07 | Keep symbol waits, whole-heap searches, unsupported cancellation, and long native calls explicit rather than masking them with a successful timeout response. | partial | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | +| 9.08 | Document tuning defaults and measured tradeoffs; raising concurrency must not be described as making CE's main thread parallel. | partial | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | +| 10.01 | Qualify a clean installation from the exact candidate ZIP on a supported Windows environment using only the documented deployment files. | not_run | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Install only the exact candidate ZIP into a clean private CE copy and record first-use and cleanup. | installation and client owner | +| 10.02 | Verify CE version/architecture and all required .NET 10 host frameworks; provide actionable diagnostics for missing/mismatched runtime configuration. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | +| 10.03 | Qualify paths with spaces, a standard user account, valid file/table roots, and read-only deployment locations where supported. | environment_dependency | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Use a standard-user environment and an owned read-only fixture; do not infer these cases from administrator runs. | installation and client owner | +| 10.04 | Qualify replacing beta.2 with the RC/stable package, including migration from older folder deployments/plugin entries. | not_run | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run beta.2 and older folder-deployment upgrades with version-pair and rollback evidence. | installation and client owner | +| 10.05 | Preserve user configuration, tables, unrelated plugins, and CE settings; backup/rollback steps must be reproducible. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | +| 10.06 | Verify matching plugin/gateway versions and source identity; detect unsupported mismatches clearly. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | +| 10.07 | Document exact configuration precedence, per-activation reads, environment overrides, and validation failures. | pass | README, gateway README, and McpConfigurationTests.Load_PluginUserAndEnvironment_UseDocumentedPrecedence document and test precedence. | Reconcile final-package docs and repeat invalid configuration cases. | installation and client owner | +| 10.08 | Verify malformed config, invalid ports/roots/names, unavailable log locations, occupied endpoints, and startup refusal do not leave half-enabled state. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | +| 10.09 | Test first use in the supported MCP clients: registration, discovery, instance choice, target orientation, tool listing, structured errors, resources, prompts, and completions. | environment_dependency | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Name supported clients first, then run the complete first-use workflow in each actual client. | installation and client owner | +| 10.10 | Verify fresh/stale instance IDs, same-name instances, admin/user mismatch, target vs CE PID, and wrong executable/version troubleshooting. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | +| 10.11 | Provide a support-report checklist with versions, operation/error ID, effective gates, and redacted logs; never request tokens or discovery-record contents. | not_run | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | +| 10.12 | Document disable/uninstall, rollback, and recovery from a locked gateway or partially completed upgrade. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | +| 11.01 | Run required V+ checks on the exact RC commit and review intended golden-file changes. | not_run | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Create the exact RC commit and run V+ with reviewed golden changes. | release engineering owner | +| 11.02 | Verify all newly selected workflow-matrix evidence references the same candidate commit or record why a prior result remains applicable. | not_run | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Cross-reference every retained result to one candidate or document the intervening diff and applicability. | release engineering owner | +| 11.03 | Confirm CI format/build/test/analysis results and resolve actionable release-stopping findings; record limitations for checks unavailable in a given environment. | not_run | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Run and review remote CI build, format, test, and analysis for the exact RC. | release engineering owner | +| 11.04 | Verify locked dependency restore, high/critical advisory handling, embedded dependency inventory, licenses, and third-party notices. | partial | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Repeat this gate on the exact RC and final stable bytes, then record hashes, logs, remote references, and limitations. | release engineering owner | +| 11.05 | Verify the flat ZIP inventory, deterministic packaging where promised, checksums, absence of development leftovers, and plugin/gateway version/source agreement. | partial | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Repeat this gate on the exact RC and final stable bytes, then record hashes, logs, remote references, and limitations. | release engineering owner | +| 11.06 | Verify upload/publish repeatability, draft recovery, tag/source mismatch refusal, immutable published binaries, and uploaded digest/size checks. | partial | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Repeat this gate on the exact RC and final stable bytes, then record hashes, logs, remote references, and limitations. | release engineering owner | +| 11.07 | Prepare reviewed stable release notes with support matrix, migration instructions, known limitations, and changes since beta.2. | partial | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Repeat this gate on the exact RC and final stable bytes, then record hashes, logs, remote references, and limitations. | release engineering owner | +| 12.01 | Standalone structure XML export: define its format and fidelity; do not imply a lossless CE structure round trip from a simple XML fragment. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | +| 12.02 | Bulk register-context updates: define snapshot/stop-state requirements, validation before effects, per-register/partial outcomes, and readback. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | +| 12.03 | Optional typed FPU/XMM writes using CE-local context APIs; current extra-register reads already exist, so do not duplicate them. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | +| 12.04 | Extended CE-local native calls (`executeCodeLocalEx`): define convention/argument/return support and blocking/unknown-effect behavior; preserve the existing simple call. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | +| 12.05 | Native RTTI/vtable inventory beyond class-name lookup: bounded slots, module validation, unreadable entries, ABI/layout limitations, and no guessed prototypes. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | +| 12.06 | CE dissection-reference editing: explicitly track the deferred add/delete operations from the legacy map and protect CE/user-owned analysis state. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | +| 12.07 | Richer structured memory-region/analysis views using existing CE results; region enumeration/protection already exist. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | +| 12.08 | Read-only inspection of CE SDK exported-function availability/type/offset/callability where supported; raw entry points must not become unrestricted callable tools by default. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | +| 12.09 | Better compiler/calling-convention/object-search guidance and domain prompts where existing tools need too much manual orchestration. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | +| 12.10 | Evaluate typed window/input/pixel/clipboard helpers only for CE-owned windows and concrete CE UI workflows, excluding general desktop/target-application automation, with explicit host effects and bounded cleanup of synthetic input state. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | +| 12.11 | Evaluate remote access to our CE gateway only when required, with explicit authentication, instance identity, and documented transport scope; local Windows support remains the stable baseline. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | +| 13.01 | **LBR feasibility:** retain the deliberate removal of `dbg_lbr_enable`/`dbg_lbr_records` until modern Windows/CPU/CE support is demonstrated. CE's own documentation warns of OS/CPU restrictions. Do not make a thin wrapper a release requirement. | pass | The tools remain absent and support-matrix.md retains LBR as unsupported pending feasibility. | Keep LBR absent unless a linked effort supplies modern CE, OS, CPU, policy, and cleanup evidence. | research follow-up owner | +| 13.02 | If LBR is viable, define actual return semantics, kernel/debugger prerequisites, bounds, prior-state restoration, cleanup, and native qualification before adding typed tools. | not_run | No feasibility or native qualification evidence exists beyond the explicit unsupported boundary. | Create a linked research follow-up or retain an explicit unsupported disposition; do not claim qualification. | research follow-up owner | +| 13.03 | **Shared allocation/sections:** prove a release lifecycle for handles, views, named mappings, failure rollback, target exit, and plugin disable before restoring typed shared memory. | not_run | No feasibility or native qualification evidence exists beyond the explicit unsupported boundary. | Create a linked research follow-up or retain an explicit unsupported disposition; do not claim qualification. | research follow-up owner | +| 13.04 | **DBK map/unmap:** establish MDL ownership and confirmed cleanup before exposing mappings through MCP. | not_run | No feasibility or native qualification evidence exists beyond the explicit unsupported boundary. | Create a linked research follow-up or retain an explicit unsupported disposition; do not claim qualification. | research follow-up owner | +| 13.05 | **Caller-selected CR3 access/translation:** define an explicit address-space contract, bounds, residency/translation failures, and qualification; selected-target translation is already available. | not_run | No feasibility or native qualification evidence exists beyond the explicit unsupported boundary. | Create a linked research follow-up or retain an explicit unsupported disposition; do not claim qualification. | research follow-up owner | +| 13.06 | **Driver write-protection bypass:** distinguish `dbk_writesIgnoreWriteProtection` from page flags, establish prior-state/recovery behavior, and justify demand before adding a global toggle. | not_run | No feasibility or native qualification evidence exists beyond the explicit unsupported boundary. | Create a linked research follow-up or retain an explicit unsupported disposition; do not claim qualification. | research follow-up owner | +| 13.07 | **Raw kernel allocation:** require a demonstrated release/recovery path; compiler-created kernel code is not a general managed allocation service. | not_run | No feasibility or native qualification evidence exists beyond the explicit unsupported boundary. | Create a linked research follow-up or retain an explicit unsupported disposition; do not claim qualification. | research follow-up owner | +| 13.08 | **DBVM debugger or broader native host/ceserver support:** require a separate CE-specific design and qualification plan before changing the current unsupported boundary. | not_run | No feasibility or native qualification evidence exists beyond the explicit unsupported boundary. | Create a linked research follow-up or retain an explicit unsupported disposition; do not claim qualification. | research follow-up owner | +| 13.09 | Keep deliberately unsupported cases explicit when CE cannot provide reliable APIs; a stable release may document them without implementing them. | pass | support-matrix.md explicitly records unsupported low-level capabilities. | Retain the unsupported disposition and link future work if demand and reliable APIs emerge. | research follow-up owner | +| 14.01 | All P0 tasks are complete with evidence, and every P1 item has a completed implementation or explicit linked deferral. | partial | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.02 | Every advertised domain has a reviewed support/qualification status and documented recovery/limitations. | partial | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.03 | No unresolved release-stopping crash, corruption, ownership/isolation, installation, or outcome-reporting defect remains. | not_run | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.04 | Final tool/schema/resources/prompts contract and beta migration documentation are reviewed. | partial | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.05 | RC bug-bash/soak results and clean-install/upgrade/rollback results are recorded. | not_run | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.06 | Set `Version` to `2.0.0` in a committed change and build from that clean final source commit. Record its identity and the final DLL/gateway/ZIP hashes; an RC result alone does not qualify a rebuilt stable payload. | not_run | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.07 | On the final stable commit, repeat V+, portable tests against both Debug and Release outputs, and VN when fixed Lua/runtime/job/indicator code changed since the applicable VN evidence. On the final Release package, repeat the Native AOT executable smoke checks, packaged-DLL golden comparison, and ZIP/checksum/source-identity checks. | not_run | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.08 | Repeat clean-install/upgrade/rollback checks and the maintained two-instance real-CE qualification against the final Release package, with explicit maintainer opt-in and existing live restrictions. For other workflow/soak evidence retained from the RC, record the intervening diff, why the evidence still applies, and which affected cases were rerun; changes beyond version/packaging metadata require reassessing that evidence. | not_run | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.09 | Create the annotated `v2.0.0` tag at the exact binary source commit; do not move beta or published stable tags. | not_run | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.10 | Upload the maintained complete Windows x64 ZIP plus `SHA256SUMS.txt` as a draft, review notes/assets, and verify remote hashes and source identity. | not_run | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.11 | Publish with `draft=false` and `prerelease=false`; mark the intended stable release as Latest after verification. | not_run | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.12 | Download/verify the public stable package and smoke-test the shipped installation path, gateway, plugin discovery, and documented first-use workflow. | not_run | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.13 | Update README/status/support policy to the released stable version and document the patch-release process. | not_run | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | +| 14.14 | Close this roadmap only when the stable release is verified; keep optional/research work in linked follow-up issues. | not_run | No final stable release evidence exists. | Complete all dependent P0, P1, candidate, native, install, soak, and publication gates before checking this item. | release coordinator | + +## Suggested follow-up groups + +One P2 design follow-up can cover 12.01 through 12.09 as typed CE convenience contracts without implying native qualification. +One UI and transport evaluation follow-up can cover 12.10 and 12.11 while retaining the local Windows baseline. +One low-level feasibility follow-up can cover 13.01 through 13.08, with separate policies before any debugger, kernel, DBVM, driver, or raw allocation run. +These are suggested scopes only, and this document does not create or claim any linked issue. + +## Release interpretation + +Section 12 and section 13 remain outside the stable blocking path, but each row needs a linked follow-up or explicit unsupported disposition before roadmap closure. +Section 14 remains blocked until the exact stable package is built, qualified, published, downloaded, and smoke-tested. +A parent roadmap checkbox may be checked only after review confirms that the complete row, not a nearby subcase, has the required evidence. diff --git a/docs/qualification/v2.0.0/runners.md b/docs/qualification/v2.0.0/runners.md new file mode 100644 index 0000000..8ba6c7b --- /dev/null +++ b/docs/qualification/v2.0.0/runners.md @@ -0,0 +1,35 @@ +# Local qualification runners + +The maintained live test selects a fixed workload using `CHEATENGINE_MCP_LIVE_QUALIFICATION_SCENARIO`. +Every scenario requires the ordinary explicit live acknowledgement, private CE copies, owned fixtures, and successful user-state restoration. +The scenario selection is checked before starting a host. +Implementation of a runner does not constitute a passing qualification result. + +| Scenario | Fixed workload | Evidence boundary | +| --- | --- | --- | +| `smoke` | Existing two-instance pointer, scanner, record, and shutdown checks, plus packaged resources and prompts. | Existing smoke admission and capability settings apply. | +| `lifecycle` | Six bounded memory-analysis workflows, three in-process plugin cycles, target restart, gateway restart, and one private CE restart. | All four optional capability gates are disabled. Named scans require the existing MEM_PRIVATE preference. | +| `performance` | Three hash-pinned published beta.2 baselines and three candidate runs, each with 100 single reads and 30 batches of 16 reads after warmup. | Only these two workload profiles are compared; this does not qualify job latency or the whole catalog. | +| `soak` | Two hours, 1,000 reads, 20 bounded non-executing decode jobs, 30 plugin cycles, ten owned target restarts, and one gateway restart. | Uses 135-minute owned lifetimes. Both CE processes must remain within the documented handle/private-byte budgets after equal 15-second idle intervals. | +| `compiler` | Reviewed compiler-only phases 1 through 4. | Requires the separate compiler acknowledgement and [approved policy](compiler-probe-policy.md). | +| `compiler-extended` | Private missing compiler component, shared temporary directory, bounded delayed export after B shutdown, and actual plugin reload lifetime observation. | Prepared only; the [extended policy](compiler-extended-policy.md) still requires its own reviewed admission. | +| `compiler-injection` | The fixed compiled entry point in an owned .NET Framework target after B shutdown and verified export. | Prepared only; requires the additional managed-injection acknowledgement and explicit approval. | + +The lifecycle workflow uses data allocations that it frees, private named scanner state that it deletes, and a temporary global structure that it removes. +Synthetic instruction bytes remain ordinary writable data and are never invoked. +File-policy refusal checks require no output file to be created. +An error or failed cleanup cannot be counted as a pass. + +The performance baseline must first be downloaded from the existing `v2.0.0-beta.2` release and verified with `eng/Stage-QualificationPackage.ps1` into `artifacts/qualification-baseline/beta.2/staged`. +The runner checks the baseline version and both executable hashes before launch. +The staging script accepts only a reviewed ZIP hash, a new destination, and the five expected flat distribution files. +It never executes the extracted binaries. + +The separately prepared Framework fixture lives under `eng/LiveFrameworkTarget` and builds with `eng/Build-LiveFrameworkTarget.ps1`. +It uses the installed Framework compiler and adds no package dependency or solution project. +Fixture identity records the source, compiler, executable, and architecture; injection preparation checks the source and executable before starting it. +The fixture must remain alive until its explicit stop marker, exit with code zero, and be disposed before the scenario can pass. + +Use an unused distribution directory to preserve any gateway currently used by an MCP client. +Raw reports stay in the private run directory; committed evidence must redact personal paths and exclude discovery tokens. +Use the [requirement matrix](requirement-matrix.md) to map actual results to the issue checkboxes. diff --git a/docs/qualification/v2.0.0/support-matrix.md b/docs/qualification/v2.0.0/support-matrix.md index 5f2886e..5100f55 100644 --- a/docs/qualification/v2.0.0/support-matrix.md +++ b/docs/qualification/v2.0.0/support-matrix.md @@ -1,13 +1,14 @@ # Stable v2.0.0 support matrix -Status: Draft scope for implementation and qualification. +Status: Reviewed stable-scope proposal; exact release-candidate qualification remains incomplete. This document defines the minimum intended stable boundary and does not claim that the release candidate has passed qualification. The final supported and qualified rows require evidence from the exact release candidate package. +The current 121-item status and ownership crosswalk is in [requirement-matrix.md](requirement-matrix.md). ## Product boundary -- The stable package is Windows x64 only. +- The observed qualification baseline is Windows 11 Pro x64 build `10.0.26300`, and no broader Windows version is currently claimed as qualified. - The Cheat Engine plugin host is x64 only. - The gateway is the self-contained `win-x64` executable from the matching package. - The plugin requires the x64 .NET 10, ASP.NET Core 10, and Windows Desktop 10 runtimes. @@ -19,7 +20,7 @@ The final supported and qualified rows require evidence from the exact release c | Component | Intended stable boundary | Candidate qualification requirement | Current status | | --- | --- | --- | --- | -| Windows | Windows x64 | Record the exact edition, version, build, and architecture used for every native, soak, installation, and release result. | Pending qualification. | +| Windows | Windows 11 Pro x64 build `10.0.26300` is the only observed candidate environment | Record the exact edition, version, build, and architecture used for every native, soak, installation, and release result; broader Windows x64 versions remain unqualified until separately evidenced. | Local candidate environment observed; final package pending. | | Cheat Engine host | Cheat Engine 7.7 x64 | Qualify the exact installed host through the harness fingerprint and reviewed autorun checks. | Baseline only. | | Installed candidate environment | Cheat Engine 7.7.1.10828 x64 | Run the exact candidate package and record the dynamic installation fingerprint, reviewed autorun result, and cleanup. | Local working package passed both native fixture runs; final clean candidate pending. | | Plugin runtime | `Microsoft.NETCore.App`, `Microsoft.AspNetCore.App`, and `Microsoft.WindowsDesktop.App` 10.0.x x64 | Record every exact runtime patch used by the candidate qualification. | Pending qualification. | @@ -28,22 +29,49 @@ The final supported and qualified rows require evidence from the exact release c | MCP transport | Local stdio gateway to authenticated per-activation loopback backends | Verify explicit `instanceId` routing, stale-instance refusal, and two-instance isolation. | Pending candidate qualification. | | MCP clients | Clients named by the release documentation | Run packaged first-use registration, discovery, selection, resources, prompts, completions, errors, and cleanup in every named client. | No client is qualified by this draft. | -The exact Windows build used during planning is `10.0.26300`, but that fact is not candidate qualification. +The exact Windows build used during planning and bounded candidate checks is `10.0.26300`, but that fact is not final-package qualification. The local working package passed the bounded native cases recorded in [local-checkpoint.md](local-checkpoint.md). That evidence must not be generalized to every advertised domain or to an untested final stable package. The live harness already accepts and fingerprints an installed Cheat Engine 7.7-or-newer x64 host dynamically, so no new static profile is required for this build. ## Capability classification -Every advertised domain must have one of the following statuses in the final release evidence. +Every advertised domain has a current classification below, and the final release evidence must update the evidence without silently broadening the classification. - `qualified` means the exact candidate package passed its required portable and native cases on the supported environment. - `optional_environment` means the contract and refusal paths are stable, but successful use depends on an installed runtime, collector, driver, hypervisor, compiler, or target property. - `experimental` means the typed contract is available but its native coverage or operating boundary is intentionally narrower than the stable promise. - `unsupported` means the release documents the boundary and does not advertise successful operation. -Core routing, process selection, memory, scans, AOB, pointers, modules, symbols, code analysis, structures, records, tables, user-mode debugger, Lua, and speedhack are intended for qualification. -.NET, Mono, IL2CPP, debugger interfaces, compilation, injection, managed invocation, kernel, and DBVM capabilities require separate environment and policy evidence. +`experimental` in this table means that the typed contract is present but the full roadmap workflow row has not passed on an exact stable candidate. + +| Advertised domain | Current classification | Evidence and limitation | +| --- | --- | --- | +| AOB | experimental | Portable coverage exists, but the complete module, range, protection, alignment, mapped-memory, uniqueness, and real-fixture signature matrix has not passed. | +| Auto Assembler | optional_environment | Generation and portable gate behavior exist, while apply and cleanup require the capability gate, a target, and a separately reviewed execution policy for native qualification. | +| Code analysis | experimental | The maintained live smoke covered disassembly columns only, so functions, CFG, strings, references, dissection jobs, cancellation, and limits remain unqualified. | +| Debugger | optional_environment | Portable and NativeLua coverage exists, but debugger interfaces, capture, register mutation, stepping, tracing, and cleanup need a reviewed debugger policy and native fixture. | +| .NET | optional_environment | The domain depends on a compatible managed target and collector, and no controlled managed fixture has completed the roadmap matrix. | +| Execution | optional_environment | Calls, C compilation, injection, and invocation require target-code-execution and separate policies; only bounded C# compiler phases 1 through 4 have passed. | +| Instance discovery | experimental | Portable routing and the historical two-instance smoke passed, but the exact final gateway still needs stale-record, same-name, withdrawal, and client qualification. | +| Kernel and DBVM | optional_environment | Typed refusal paths exist, but unavailable hardware or drivers do not qualify successful translation, physical-memory, or watch behavior. | +| Lua | experimental | Fixed Lua has broad NativeLua evidence, while unrestricted `lua_execute`, main-thread behavior, and lifecycle transitions have not completed native qualification. | +| Memory | experimental | The maintained live smoke proved bounded scalar read and write cases, but the full scalar, array, batch, string, byte-order, unreadable-page, and partial-write matrix remains open. | +| Modules | experimental | Portable coverage exists, but the complete PE, import, export, and patch comparison matrix has not passed on the stable candidate. | +| Mono and IL2CPP | optional_environment | Collector availability, target runtime, attachment, object layout, searches, and invocation require controlled fixtures and a separate invocation policy. | +| Pointers | experimental | `pointer_get_access_info` and `pointer_read_chains` have bounded x86 and x64 evidence, but pointer maps, rescans, incomplete captures, and native file fidelity remain open. | +| Processes | experimental | Selection and target restart have bounded live evidence, but create, open-file, pause, pointer-width override, and full recovery behavior remain open. | +| Records | experimental | The maintained live smoke proved create, update, freeze, unfreeze, and delete on an owned record, but the full hierarchy, dropdown, script, rollback, and preservation matrix remains open. | +| Runtime | experimental | Portable lifecycle and resource-ledger coverage exists, but repeated native activation, all ownership classes, failed cleanup, and full recovery remain open. | +| Scans | experimental | The maintained live smoke covered main and independent scanner isolation, but every value mode, range, cancellation, mapped-memory, and lifecycle case remains open. | +| Speedhack | optional_environment | One owned x86 and x64 fixture scenario proved speed restoration and cross-instance isolation, while supported-target boundaries and target switching remain open. | +| Structures | experimental | Portable coverage exists, but native autoguess, nested layout, PDB and .NET fill, read and write, header generation, and CE-owned persistence remain open. | +| Symbols | experimental | Portable coverage exists, but exact resolution, PDB layouts, reload, source state, and registered-symbol ownership need candidate qualification. | +| Tables | experimental | File policy and portable coverage exist, but load, save, merge, malformed content, scripts, rollback, partial effects, and user-owned preservation remain open. | +| Utilities | qualified | The pure managed calculation and value-conversion contract passed the clean `808a626e65eddab113737a27a7c2345555b1c241` portable candidate; the final stable commit must repeat the portable suite. | + +No advertised domain other than Utilities is classified as fully qualified by the current evidence. +The broad native smoke is evidence only for the cases named above and is not a domain-wide pass. Kernel and DBVM unavailability on ordinary Windows configurations is an expected supported outcome and is not evidence that successful kernel operation works. LBR, shared allocation, DBK map or unmap, caller-selected CR3 access, raw kernel allocation, and broader native-host support remain unsupported unless separately designed and qualified. @@ -61,6 +89,7 @@ No P1 feature may be silently deferred to fit a work session or release schedule The stable v2.0.0 contract freezes tool names, arguments, result and error shapes, resource URIs, prompts, and gateway routing semantics after the selected P1 changes land. Additive optional fields and new tools may be added in compatible v2 patch or minor work when old callers keep their behavior. Removing or renaming a tool, changing an existing field meaning, changing pointer-chain order, or changing host-effect reporting requires a future reviewed breaking contract. +Future deprecations require release-note notice, keep the old behavior through the current major version, and remove it only in a reviewed breaking release. Configuration keeps the current precedence of built-in defaults, deployment settings, user settings, and dedicated environment overrides. The four capability switches remain exposure controls and do not become a sandbox. File and table roots remain empty by default. diff --git a/eng/Build-LiveFrameworkTarget.ps1 b/eng/Build-LiveFrameworkTarget.ps1 new file mode 100644 index 0000000..014d463 --- /dev/null +++ b/eng/Build-LiveFrameworkTarget.ps1 @@ -0,0 +1,43 @@ +param( + [ValidateSet('x86', 'x64')][string]$Architecture = 'x64', + [string]$OutputDirectory = (Join-Path $PSScriptRoot '..\artifacts\live-framework-target') +) + +$ErrorActionPreference = 'Stop' +$repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$source = Join-Path $repository 'eng\LiveFrameworkTarget\Program.cs' +$approvedOutput = [IO.Path]::GetFullPath((Join-Path $repository 'artifacts\live-framework-target')) +$requestedOutput = [IO.Path]::GetFullPath($OutputDirectory) +if (!$requestedOutput.StartsWith($approvedOutput + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) -and $requestedOutput -ne $approvedOutput) { + throw "The fixed fixture output must remain under $approvedOutput." +} +$csc = Join-Path ${env:WINDIR} 'Microsoft.NET\Framework64\v4.0.30319\csc.exe' +if ($Architecture -eq 'x86') { $csc = Join-Path ${env:WINDIR} 'Microsoft.NET\Framework\v4.0.30319\csc.exe' } +if (!(Test-Path -LiteralPath $csc)) { throw "Required .NET Framework 4 C# compiler was not found: $csc" } +$destination = Join-Path $requestedOutput $Architecture +foreach ($ancestor in @($approvedOutput, $requestedOutput, $destination)) { + if ((Test-Path -LiteralPath $ancestor) -and ((Get-Item -LiteralPath $ancestor).Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw "Fixture output refuses the reparse-point path: $ancestor" + } +} +for ($cursor = $destination; $cursor; $cursor = Split-Path $cursor -Parent) { + if ((Test-Path -LiteralPath $cursor) -and ((Get-Item -LiteralPath $cursor).Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Fixture output refuses reparse ancestor: $cursor" } +} +$outputExe = Join-Path $destination 'CheatEngine.Mcp.LiveFrameworkTarget.exe' +$identityPath = Join-Path $destination 'fixture-identity.json' +foreach ($leaf in @($outputExe, $identityPath)) { + if (Test-Path -LiteralPath $leaf) { throw "Fixture build requires unused output files: $leaf" } +} +New-Item -ItemType Directory -Force -Path $destination | Out-Null +& $csc /nologo /target:winexe "/platform:$Architecture" "/out:$outputExe" $source +if ($LASTEXITCODE -ne 0) { throw 'The fixed .NET Framework target did not compile.' } +$identity = [ordered]@{ + architecture = $Architecture + cscPath = $csc + cscSha256 = (Get-FileHash -LiteralPath $csc -Algorithm SHA256).Hash + targetPath = $outputExe + targetSha256 = (Get-FileHash -LiteralPath $outputExe -Algorithm SHA256).Hash + sourceSha256 = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash +} +$identity | ConvertTo-Json | Set-Content -LiteralPath $identityPath -Encoding utf8 diff --git a/eng/LiveFrameworkTarget/Program.cs b/eng/LiveFrameworkTarget/Program.cs new file mode 100644 index 0000000..12aa17e --- /dev/null +++ b/eng/LiveFrameworkTarget/Program.cs @@ -0,0 +1,31 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Reflection; +using System.Security.Cryptography; + +internal static class Program +{ + private static int Main(string[] args) + { + if (args.Length != 1 || string.IsNullOrWhiteSpace(args[0])) return 64; + if (!Path.IsPathRooted(args[0])) return 64; + string manifest = Path.GetFullPath(args[0]); + if (!string.Equals(manifest, args[0], StringComparison.OrdinalIgnoreCase)) return 64; + File.WriteAllText(manifest + ".tmp", "{\"processId\":" + Process.GetCurrentProcess().Id + + ",\"pointerWidth\":" + (IntPtr.Size * 8) + ",\"runtime\":\"" + Environment.Version + + "\",\"assembly\":\"" + Assembly.GetExecutingAssembly().GetName().Name + "\",\"sha256\":\"" + + Hash(Assembly.GetExecutingAssembly().Location) + "\"}"); + File.Move(manifest + ".tmp", manifest); + string stop = manifest + ".stop"; + Stopwatch lifetime = Stopwatch.StartNew(); + while (!File.Exists(stop) && lifetime.Elapsed < TimeSpan.FromMinutes(10)) System.Threading.Thread.Sleep(100); + return 0; + } + + private static string Hash(string path) + { + using (SHA256 sha = SHA256.Create()) + using (FileStream stream = File.OpenRead(path)) return BitConverter.ToString(sha.ComputeHash(stream)).Replace("-", string.Empty); + } +} diff --git a/eng/LiveFrameworkTarget/README.md b/eng/LiveFrameworkTarget/README.md new file mode 100644 index 0000000..2c10b5b --- /dev/null +++ b/eng/LiveFrameworkTarget/README.md @@ -0,0 +1,12 @@ +# .NET Framework live target fixture + +This fixed, disposable target is used only by the separately admitted compiler-injection qualification scenario. It is not part of the normal .NET target project and it is never built or launched by portable tests. + +Build either architecture with the fixed script from the repository root: + +```powershell +.\eng\Build-LiveFrameworkTarget.ps1 -Architecture x64 +.\eng\Build-LiveFrameworkTarget.ps1 -Architecture x86 +``` + +The script requires the installed .NET Framework 4 compiler and writes only below `artifacts/live-framework-target`. The target accepts exactly one manifest path, records its own PID/runtime facts, and exits when its adjacent `.stop` file is created. diff --git a/eng/Stage-QualificationPackage.ps1 b/eng/Stage-QualificationPackage.ps1 new file mode 100644 index 0000000..4572a65 --- /dev/null +++ b/eng/Stage-QualificationPackage.ps1 @@ -0,0 +1,95 @@ +#requires -Version 7.0 +<# +.SYNOPSIS +Verifies and extracts a release ZIP into a new qualification-only directory. +.DESCRIPTION +No binaries are executed and no existing deployment is replaced. A failed extraction is retained for inspection. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string] $ZipPath, + [Parameter(Mandatory)][ValidatePattern('^[0-9A-Fa-f]{64}$')][string] $ExpectedSha256, + [Parameter(Mandatory)][string] $OutputDirectory +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Assert-NoReparseAncestor([string] $Path) { + for ($current = [IO.Path]::GetFullPath($Path); $current; $current = [IO.Path]::GetDirectoryName($current)) { + if ((Test-Path -LiteralPath $current) -and + ((Get-Item -LiteralPath $current -Force).Attributes -band [IO.FileAttributes]::ReparsePoint)) { + throw 'Qualification package paths must not traverse a symbolic link or junction.' + } + } +} + +function Get-BoundedEntryHash([IO.Stream] $InputStream, [long] $ExpectedLength, [long] $MaximumLength) { + $hasher = [Security.Cryptography.IncrementalHash]::CreateHash([Security.Cryptography.HashAlgorithmName]::SHA256) + try { + $buffer = [byte[]]::new(65536) + [long] $length = 0 + while (($read = $InputStream.Read($buffer, 0, $buffer.Length)) -gt 0) { + $length += $read + if ($length -gt $ExpectedLength -or $length -gt $MaximumLength) { throw 'An expanded entry exceeds its declared size.' } + $hasher.AppendData($buffer, 0, $read) + } + if ($length -ne $ExpectedLength) { throw 'An expanded entry does not match its declared size.' } + return [Convert]::ToHexString($hasher.GetHashAndReset()) + } + finally { $hasher.Dispose() } +} + +$source = [IO.Path]::GetFullPath($ZipPath) +$destination = [IO.Path]::GetFullPath($OutputDirectory) +Assert-NoReparseAncestor $source +Assert-NoReparseAncestor $destination +if (Test-Path -LiteralPath $destination) { throw 'The qualification destination must not already exist.' } +$expected = @('CheatEngine.Mcp.dll', 'CheatEngine.Mcp.Gateway.exe', 'README.md', 'LICENSE', 'THIRD-PARTY-NOTICES.md') +$maximumBytes = 128MB +$stream = [IO.File]::Open($source, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) +try { + if ($stream.Length -gt $maximumBytes) { throw 'The ZIP exceeds the qualification size bound.' } + $hash = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($stream)) + if ($hash -cne $ExpectedSha256.ToUpperInvariant()) { throw 'The ZIP SHA-256 does not match the reviewed release.' } + $stream.Position = 0 + $archive = [IO.Compression.ZipArchive]::new($stream, [IO.Compression.ZipArchiveMode]::Read, $true) + try { + if ($archive.Entries.Count -ne $expected.Count) { throw 'The ZIP must contain exactly the five flat release files.' } + $inventory = [Collections.Generic.Dictionary[string, object]]::new([StringComparer]::Ordinal) + [long] $totalBytes = 0 + foreach ($entry in $archive.Entries) { + if ($entry.FullName -cnotin $expected -or $inventory.ContainsKey($entry.FullName)) { + throw 'The ZIP contains an unexpected or duplicate entry.' + } + $totalBytes += $entry.Length + if ($entry.Length -le 0 -or $totalBytes -gt $maximumBytes) { throw 'The expanded ZIP exceeds its non-empty file bounds.' } + $input = $entry.Open() + try { $entryHash = Get-BoundedEntryHash $input $entry.Length $maximumBytes } + finally { $input.Dispose() } + $inventory.Add($entry.FullName, @{ Length = $entry.Length; Sha256 = $entryHash }) + } + + # Complete hash and inventory validation precedes the first destination write. + [IO.Directory]::CreateDirectory($destination) | Out-Null + foreach ($entry in $archive.Entries) { + $path = Join-Path $destination $entry.FullName + $input = $entry.Open() + try { + $output = [IO.File]::Open($path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + try { $input.CopyTo($output) } + finally { $output.Dispose() } + } + finally { $input.Dispose() } + if ((Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash -cne $inventory[$entry.FullName].Sha256) { + throw 'An extracted file did not retain its archive hash.' + } + } + $pluginVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $destination 'CheatEngine.Mcp.dll')).ProductVersion + $gatewayVersion = [Diagnostics.FileVersionInfo]::GetVersionInfo((Join-Path $destination 'CheatEngine.Mcp.Gateway.exe')).ProductVersion + if ($pluginVersion -cnotmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\+[0-9a-f]{40}$' -or + $pluginVersion -cne $gatewayVersion) { throw 'The extracted plugin and gateway source identities do not agree.' } + [pscustomobject]@{ ZipSha256 = $hash; ProductVersion = $pluginVersion; Directory = $destination; Files = $inventory } + } + finally { $archive.Dispose() } +} +finally { $stream.Dispose() } diff --git a/tests/CheatEngine.Mcp.LiveTarget/Program.cs b/tests/CheatEngine.Mcp.LiveTarget/Program.cs index 3f1b5f5..00d1351 100644 --- a/tests/CheatEngine.Mcp.LiveTarget/Program.cs +++ b/tests/CheatEngine.Mcp.LiveTarget/Program.cs @@ -8,6 +8,7 @@ internal static class Program { private const int InitialValue = 20260926; private static readonly TimeSpan MaximumLifetime = TimeSpan.FromMinutes(10); + private static readonly TimeSpan SoakMaximumLifetime = TimeSpan.FromMinutes(135); private static readonly string[] PointerOffsets = ["-10", "20"]; private static int Main(string[] args) @@ -17,12 +18,14 @@ private static int Main(string[] args) return PluginBundleProbe.Run(args[1], args.Length == 3 ? args[2] : null); } - if (args.Length != 1 || string.IsNullOrWhiteSpace(args[0])) + bool soak = args.Length == 2 && string.Equals(args[0], "--soak", StringComparison.Ordinal); + if ((!soak && args.Length != 1) || (soak && string.IsNullOrWhiteSpace(args[1])) + || (!soak && string.IsNullOrWhiteSpace(args[0]))) { return 64; } - string manifestPath = Path.GetFullPath(args[0]); + string manifestPath = Path.GetFullPath(soak ? args[1] : args[0]); string stopMarkerPath = $"{manifestPath}.stop"; IntPtr direct = IntPtr.Zero; IntPtr root = IntPtr.Zero; @@ -36,6 +39,8 @@ private static int Main(string[] args) middle = Marshal.AllocHGlobal(64); final = Marshal.AllocHGlobal(64); zeroRoot = Marshal.AllocHGlobal(IntPtr.Size); + // A fixed data-only decode range for bounded code jobs; it is never executed. + Marshal.Copy(Enumerable.Repeat((byte) 0x90, 64).ToArray(), 0, direct, 64); Marshal.WriteInt32(direct, InitialValue); Marshal.WriteInt32(final, InitialValue); // dereference(root) - 0x10 == middle; dereference(middle) + 0x20 == final. @@ -59,7 +64,8 @@ private static int Main(string[] args) File.Move(manifestPath + ".tmp", manifestPath); Stopwatch lifetime = Stopwatch.StartNew(); - while (lifetime.Elapsed < MaximumLifetime && !File.Exists(stopMarkerPath)) + TimeSpan maximumLifetime = soak ? SoakMaximumLifetime : MaximumLifetime; + while (lifetime.Elapsed < maximumLifetime && !File.Exists(stopMarkerPath)) { Thread.Sleep(50); } diff --git a/tests/CheatEngine.Mcp.LiveTarget/README.md b/tests/CheatEngine.Mcp.LiveTarget/README.md index 28f0bdb..5e00996 100644 --- a/tests/CheatEngine.Mcp.LiveTarget/README.md +++ b/tests/CheatEngine.Mcp.LiveTarget/README.md @@ -8,8 +8,8 @@ It is not shipped with the plugin or gateway and has no production role. ## Runtime behavior -The executable requires exactly one argument: the absolute or relative path of a manifest file to create. -It exits with code `64` when that argument is missing, blank, or accompanied by extra arguments. +The executable normally requires exactly one argument: the absolute or relative path of a manifest file to create. +The reviewed live-soak harness may instead pass `--soak `, which selects the fixed 2-hour-15-minute lifetime. It accepts no caller-provided duration or other workload arguments. It exits with code `64` when those forms are missing, blank, or accompanied by other arguments. At startup, the process owns four 64-byte unmanaged allocations and one pointer-sized allocation. It writes the signed 32-bit value `20260926` at the direct-value and final pointer-chain addresses. @@ -39,7 +39,7 @@ The manifest has this shape: The pointer root reaches `pointerTargetAddress` through the signed offsets `-10`, then `20`. `zeroPointerRootAddress` stores a zero pointer so qualification can distinguish a resolved address zero from an unreadable typed final value. After publishing the manifest, the process polls for a sibling stop marker named `.stop`. -It exits successfully when that file appears or when its ten-minute lifetime expires, then releases every owned unmanaged allocation. +It exits successfully when that file appears or when its normal ten-minute lifetime expires. The reviewed `--soak` form uses its fixed 2-hour-15-minute maximum instead. It then releases every owned unmanaged allocation. It has no window, network listener, input handling, or persistent state beyond the manifest and stop marker. ## Relationship to live qualification diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs index b035d4f..bad85a5 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerBridge.cs @@ -8,21 +8,41 @@ internal static class LiveCompilerBridge { internal static readonly string Template = """ local compilerReceipts = 0 + local holdArmed = false local originalCompileCS = compileCS if type(originalCompileCS) == 'function' then compileCS = function(...) compilerReceipts = compilerReceipts + 1 - return originalCompileCS(...) + local results = table.pack(originalCompileCS(...)) + if holdArmed and type(results[1]) == 'string' and #results[1] > 0 then + holdArmed = false + local ready = assert(io.open(__HOLD_READY__..'.tmp', 'w')) + ready:write(results[1]); ready:close() + assert(os.rename(__HOLD_READY__..'.tmp', __HOLD_READY__), 'compiler hold receipt publish failed') + local started = getTickCount() + local release = io.open(__HOLD_RELEASE__, 'r') + while not release do + if getTickCount() - started > 10000 then + return nil, 'qualification compiler hold timed out' + end + sleep(25) + release = io.open(__HOLD_RELEASE__, 'r') + end + release:close() + end + return table.unpack(results, 1, results.n) end end local function compilerProbe() local request=io.open(__REQUEST__,'r') if not request then return end local command=request:read('*a'); request:close(); assert(os.remove(__REQUEST__), 'compiler request dequeue failed') - if command~='status' and command~='compile' then return end + if command~='status' and command~='compile' and command~='armHold' then return end local available=type(compileCS)=='function' local value='' - if command=='compile' and available then + if command=='armHold' then + holdArmed=available + elseif command=='compile' and available then local called,filename,message=pcall(compileCS,__SOURCE__,{},nil) if called and type(filename)=='string' and #filename>0 then value=filename else available=false; value=tostring(called and message or filename) end @@ -32,7 +52,7 @@ local function compilerProbe() os.rename(__RESPONSE__..'.tmp',__RESPONSE__) end """.ReplaceLineEndings("\n").TrimEnd('\n'); - internal const string TemplateSha256 = "E42693FFD25AD257262CEA04C228D5540F4887FA8F5EC759A1E087EFA404A84B"; + internal const string TemplateSha256 = "770C3777E8E265C6C5D6E14E87CF0F94917505BB514720A0CE412B98CD2C55F5"; internal static void RequireReviewedHash() { @@ -43,11 +63,18 @@ internal static void RequireReviewedHash() } } - internal static string Render(string requestPath, string responsePath, string source, Func quote) + internal static string Render(string requestPath, string responsePath, string source, Func quote) => + Render(requestPath, responsePath, responsePath + ".hold-ready", responsePath + ".hold-release", source, quote); + + /// Renders only reviewed paths and source supplied by the private live-test driver. + internal static string Render(string requestPath, string responsePath, string holdReadyPath, string holdReleasePath, + string source, Func quote) { ArgumentNullException.ThrowIfNull(quote); return Template.Replace("__REQUEST__", quote(requestPath), StringComparison.Ordinal) .Replace("__RESPONSE__", quote(responsePath), StringComparison.Ordinal) + .Replace("__HOLD_READY__", quote(holdReadyPath), StringComparison.Ordinal) + .Replace("__HOLD_RELEASE__", quote(holdReleasePath), StringComparison.Ordinal) .Replace("__SOURCE__", quote(source), StringComparison.Ordinal); } } diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerCandidate.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerCandidate.cs index a8d10fe..5c21525 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerCandidate.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerCandidate.cs @@ -23,7 +23,10 @@ internal static object Verify(LiveQualificationInputs inputs) throw new InvalidOperationException("Compiler qualification requires one clean checkout at a full Git commit."); } - string policy = Path.Combine(inputs.RepositoryRoot, "docs", "qualification", "v2.0.0", "compiler-probe-policy.md"); + string policyName = inputs.Scenario is LiveQualificationScenario.CompilerExtended or LiveQualificationScenario.CompilerInjection + ? "compiler-extended-policy.md" + : "compiler-probe-policy.md"; + string policy = Path.Combine(inputs.RepositoryRoot, "docs", "qualification", "v2.0.0", policyName); string distribution = LiveSandboxSession.DistributionDirectory(inputs.RepositoryRoot, "release"); string plugin = Path.Combine(distribution, "CheatEngine.Mcp.dll"); string gateway = Path.Combine(distribution, "CheatEngine.Mcp.Gateway.exe"); @@ -43,6 +46,7 @@ internal static object Verify(LiveQualificationInputs inputs) commit, cleanTree = true, productVersion = pluginVersion, + policy = policyName, policySha256 = CheatEngineInstallation.Sha256(policy), bridgeTemplateSha256 = LiveCompilerBridge.TemplateSha256, windowsVersion = Environment.OSVersion.VersionString, diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponse.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponse.cs index b2eed91..6dc4e27 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponse.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponse.cs @@ -33,7 +33,7 @@ internal static async Task ReadAsync(string path, string command) internal static JsonNode Parse(string[] lines, string command) { - if (command is not ("status" or "compile") || lines.Length != (command == "status" ? 3 : 4) || + if (command is not ("status" or "compile" or "armHold") || lines.Length != (command == "compile" ? 4 : 3) || lines[0] != "ok" || !bool.TryParse(lines[1], out bool available) || !int.TryParse(lines[2], NumberStyles.None, CultureInfo.InvariantCulture, out int receipts) || (command == "compile" && string.IsNullOrEmpty(lines[3]))) diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponseTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponseTests.cs index 9221ac8..64d8d07 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponseTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerProbeResponseTests.cs @@ -60,6 +60,15 @@ public void Parse_Status_PreservesTheReceiptTypeReadByTheScenario() Assert.Null(response["assemblyPath"]); } + [Fact] + public void Parse_ArmHold_AcceptsOnlyTheFixedStatusShape() + { + System.Text.Json.Nodes.JsonNode response = LiveCompilerProbeResponse.Parse(["ok", "true", "7"], "armHold"); + Assert.True(response["available"]!.GetValue()); + Assert.Equal(7, response["receiptCount"]!.GetValue()); + Assert.Null(response["assemblyPath"]); + } + [Theory] [InlineData("true", "assemblyPath")] [InlineData("false", "diagnostic")] @@ -74,6 +83,7 @@ public void Parse_CompileResponse_KeepsSuccessAndFailureDistinct(string availabl [InlineData("status", "ok", "true", "-1", null)] [InlineData("status", "ok", "true", "2147483648", null)] [InlineData("status", "ok", "true", "1", "unexpected")] + [InlineData("armHold", "ok", "true", "1", "unexpected")] [InlineData("status", "error", "true", "1", null)] [InlineData("status", "ok", "maybe", "1", null)] [InlineData("compile", "ok", "true", "1", null)] diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs index 81a70b1..2ff8ca1 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerQualification.cs @@ -19,6 +19,11 @@ internal static class LiveCompilerQualification internal static async Task RunAsync(LiveQualificationInputs inputs) { + if (inputs.Scenario == LiveQualificationScenario.CompilerExtended) + { + await VerifyExtendedAsync(inputs); + return; + } Assert.Equal(LiveQualificationScenario.Compiler, inputs.Scenario); object candidate = LiveCompilerCandidate.Verify(inputs); await VerifyDisabledGateAsync(inputs, candidate); @@ -27,6 +32,189 @@ internal static async Task RunAsync(LiveQualificationInputs inputs) await VerifyCompilerAsync(inputs, candidate); } + private static async Task VerifyExtendedAsync(LiveQualificationInputs inputs) + { + Assert.Equal(LiveQualificationScenario.CompilerExtended, inputs.Scenario); + object candidate = LiveCompilerCandidate.Verify(inputs); + await VerifyPrivateCompilerAbsentAsync(inputs, candidate); + Assert.Equal(JsonSerializer.Serialize(candidate), JsonSerializer.Serialize(LiveCompilerCandidate.Verify(inputs))); + await VerifyHeldExportAfterBAsync(inputs, candidate); + } + + private static async Task VerifyPrivateCompilerAbsentAsync(LiveQualificationInputs inputs, object candidate) + { + LiveSandboxOptions options = new(true, true) + { + Variant = LiveCompilerVariant.PrivateCompilerAbsent + }; + await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs, options); + RecordAdmission(sandbox, candidate, "private_component_absent"); + await using LiveMcpClient gateway = await ConnectAsync(sandbox); + LiveMcpInstanceClient instance = gateway.Bind(sandbox.HostA.InstanceId); + await VerifyGatesAsync(sandbox, instance, "A", true); + await AttachAsync(instance, sandbox.HostA); + long receipts = await ReceiptCountAsync(sandbox, "A"); + LiveMcpToolResult result = await CompileAsync(sandbox, instance, "private_component_absent", + Arguments(LiveCompilerPayload.ValidSource, Path.Combine(sandbox.CompilerA.Output, "private-component.dll"))); + Assert.True(result.IsError, "The private compiler-absence probe unexpectedly produced an export."); + JsonNode error = result.Payload?["error"] + ?? throw new InvalidOperationException("The private compiler-absence result did not contain a structured error."); + string kind = error["kind"]!.GetValue(); + string effect = error["hostEffect"]!.GetValue(); + Assert.True((kind == "unsupported" && effect == "not_started") || (kind == "host_refused" && effect == "unknown"), + "A missing private compiler component must remain unavailable or report CE's bounded compiler refusal."); + Assert.Empty(Snapshot(sandbox, sandbox.CompilerA.Output)); + long after = await ReceiptCountAsync(sandbox, "A"); + Assert.Equal(kind == "unsupported" ? receipts : receipts + 1, after); + sandbox.Record("compiler_private_component_absent", new + { + kind, + hostEffect = effect, + receiptsBefore = receipts, + receiptsAfter = after + }); + sandbox.MarkPassed(); + } + + private static async Task VerifyHeldExportAfterBAsync(LiveQualificationInputs inputs, object candidate) + { + LiveSandboxOptions options = new(true, true) + { + Variant = LiveCompilerVariant.HeldExportAfterB, + TempTopology = CompilerTempTopology.Shared + }; + await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs, options); + RecordAdmission(sandbox, candidate, "held_export_after_b"); + await using LiveMcpClient gateway = await ConnectAsync(sandbox); + LiveMcpInstanceClient instanceA = gateway.Bind(sandbox.HostA.InstanceId); + await VerifyGatesAsync(sandbox, instanceA, "A", true); + await AttachAsync(instanceA, sandbox.HostA); + LiveHeldCompilerExport? export = await CompileHeldExportAsync(sandbox, gateway, instanceA); + await VerifyPluginReloadLifetimeAsync(sandbox, gateway, instanceA, export); + sandbox.MarkPassed(); + } + + private static async Task VerifyPluginReloadLifetimeAsync(LiveSandboxSession sandbox, LiveMcpClient gateway, + LiveMcpInstanceClient previousClient, LiveHeldCompilerExport? export) + { + JsonNode rawResult = await sandbox.CompilerProbeAsync("A", "compile"); + string rawPath = rawResult["assemblyPath"]?.GetValue() + ?? throw new InvalidDataException("The fixed raw compiler probe did not return an assembly before reload."); + FileFact before = ReadFile(sandbox, rawPath, sandbox.CompilerA.Temp, true); + LiveSandboxHost previous = sandbox.HostA; + await sandbox.ReloadPluginAsync("A", false, TestContext.Current.CancellationToken); + await RequireInstancesAsync(gateway); + await RejectStaleInstanceAsync(previousClient); + FileFact? afterDisable = ObserveRaw(sandbox, rawPath); + if (afterDisable is not null) + { + Assert.Equal(before, afterDisable); + } + + await sandbox.ReloadPluginAsync("A", true, TestContext.Current.CancellationToken); + LiveLifecycleQualification.RequirePluginReload(previous, sandbox.HostA); + await RequireInstancesAsync(gateway, sandbox.HostA.InstanceId); + await RejectStaleInstanceAsync(previousClient); + LiveMcpInstanceClient current = gateway.Bind(sandbox.HostA.InstanceId); + await VerifyGatesAsync(sandbox, current, "A_after_reload", true); + await AttachAsync(current, sandbox.HostA); + await VerifySelectedTargetAsync(current, sandbox.HostA); + FileFact? afterEnable = ObserveRaw(sandbox, rawPath); + if (afterEnable is not null) + { + Assert.Equal(before, afterEnable); + } + + if (export is not null) + { + FileFact retained = ReadFile(sandbox, export.Path, sandbox.CompilerA.Output, true); + Assert.Equal(export.Length, retained.Length); + Assert.Equal(export.Sha256, retained.Sha256); + } + sandbox.Record("compiler_raw_plugin_reload", new + { + before, + afterDisable, + afterEnable, + export, + classification = afterDisable is null ? "expired_after_disable" : afterEnable is null ? "expired_after_enable" : "retained_after_reload", + injectionAttempted = false + }); + } + + internal static async Task CompileHeldExportAsync(LiveSandboxSession sandbox, + LiveMcpClient gateway, LiveMcpInstanceClient instanceA) + { + JsonNode armed = await sandbox.ArmCompilerHoldAsync("A"); + Assert.True(armed["available"]!.GetValue(), "The fixed hold bridge requires compileCS."); + DateTimeOffset armedAt = DateTimeOffset.UtcNow; + Stopwatch holdBudget = Stopwatch.StartNew(); + string output = Path.Combine(sandbox.CompilerA.Output, "held-export.dll"); + Task compile = CompileAsync(sandbox, instanceA, "held_export", Arguments(LiveCompilerPayload.ValidSource, output)); + bool released = false; + LiveHeldCompilerExport? export = null; + try + { + string rawPath = await sandbox.WaitForCompilerHoldAsync("A", TestContext.Current.CancellationToken); + DateTimeOffset? receiptAt = DateTimeOffset.UtcNow; + FileFact raw = ReadFile(sandbox, rawPath, sandbox.CompilerA.Temp, true); + await sandbox.StopHostAsync("B"); + FileFact? rawAfterB = ObserveRaw(sandbox, rawPath); + await sandbox.ReleaseCompilerHoldAsync("A", TestContext.Current.CancellationToken); + released = true; + DateTimeOffset? releaseAt = DateTimeOffset.UtcNow; + Assert.True(holdBudget.Elapsed < TimeSpan.FromSeconds(9), "The held compiler call approached its release deadline."); + LiveMcpToolResult result = await compile; + if (rawAfterB is not null) + { + Assert.Equal(raw, rawAfterB); + Assert.False(result.IsError, result.ErrorText); + FileFact exported = ReadFile(sandbox, output, sandbox.CompilerA.Output, true); + Assert.Equal(rawAfterB.Sha256, exported.Sha256); + Assert.Equal(rawAfterB.Length, exported.Length); + Assert.Equal(output, result.Payload!["outputPath"]!.GetValue(), true); + Assert.Equal(exported.Length, result.Payload["length"]!.GetValue()); + Assert.Equal(exported.Sha256, result.Payload["sha256"]!.GetValue()); + export = new LiveHeldCompilerExport(output, exported.Length, exported.Sha256!); + } + else + { + JsonNode error = AssertError(result, "not_found", "unknown"); + Assert.NotNull(error); + Assert.Empty(Snapshot(sandbox, sandbox.CompilerA.Output)); + } + sandbox.Record("compiler_held_export_after_b", new + { + armedAt, + receiptAt, + releaseAt, + holdMilliseconds = holdBudget.ElapsedMilliseconds, + raw, + rawAfterB, + export, + heldThroughBShutdown = true + }); + await RequireInstancesAsync(gateway, sandbox.HostA.InstanceId); + return export; + } + finally + { + if (!released) + { + try + { + await sandbox.ReleaseCompilerHoldAsync("A", CancellationToken.None); + } + catch (Exception exception) { sandbox.Record("compiler_hold_release_failure", exception.ToString()); } + } + try + { + _ = await compile; + } + catch (Exception exception) { sandbox.Record("compiler_held_compile_failure", exception.ToString()); } + } + } + private static async Task VerifyDisabledGateAsync(LiveQualificationInputs inputs, object candidate) { await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs, new LiveSandboxOptions(true, false)); @@ -405,3 +593,5 @@ internal static bool IsExpectedScanLease(string relativePath, long length) private sealed record FileFact(string Path, long Length, string? Sha256, bool ExclusiveScanLease = false); } + +internal sealed record LiveHeldCompilerExport(string Path, long Length, string Sha256); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerVariant.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerVariant.cs new file mode 100644 index 0000000..031870b --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveCompilerVariant.cs @@ -0,0 +1,16 @@ +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Closed-world compiler qualification variants; no value accepts a caller-selected path or Lua body. +internal enum LiveCompilerVariant +{ + Normal, + PrivateCompilerAbsent, + HeldExportAfterB +} + +/// Temporary-root topology selected only by the reviewed compiler scenario. +internal enum CompilerTempTopology +{ + Isolated, + Shared +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualification.cs new file mode 100644 index 0000000..639b40d --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualification.cs @@ -0,0 +1,79 @@ +using System.Runtime.Versioning; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Identity checks shared by live lifecycle scenarios and their portable regression tests. +internal static class LiveLifecycleQualification +{ + [SupportedOSPlatform("windows")] + internal static async Task RunAsync(LiveQualificationInputs inputs) + { + Assert.Equal(LiveQualificationScenario.Lifecycle, inputs.Scenario); + await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs, + LiveSandboxOptions.Smoke with + { + LifecycleQualification = true + }); + await using LiveLifecycleSession connection = await LiveLifecycleSession.ConnectAsync(sandbox); + await connection.AttachBothAsync(); + await connection.VerifyBothAsync(); + await LiveResourceQualification.VerifyAsync(sandbox, connection.Gateway); + await LiveWorkflowQualification.VerifyAsync(sandbox, connection.Gateway); + for (int cycle = 0; cycle < 3; cycle++) + { + await connection.CyclePluginAsync("A"); + } + await sandbox.RestartTargetAsync("A"); + await connection.AttachBothAsync(); + await connection.VerifyBothAsync(); + await connection.RestartGatewayAsync(); + await connection.VerifyBothAsync(); + LiveSandboxHost before = sandbox.HostA; + LiveSandboxHost unaffected = sandbox.HostB; + await sandbox.RestartHostAsync("A"); + RequireHostRestart(before, sandbox.HostA, sandbox.HostB); + Assert.Equal(unaffected, sandbox.HostB); + await connection.RequireInstancesAsync(sandbox.HostA.InstanceId, sandbox.HostB.InstanceId); + await connection.RejectStaleAsync(before.InstanceId); + await connection.AttachBothAsync(); + await connection.VerifyBothAsync(); + sandbox.Record("lifecycle_workload", new + { + pluginCycles = 3, + targetRestarts = 1, + gatewayRestarts = 1, + hostRestarts = 1 + }); + sandbox.MarkPassed(); + } + + internal static void RequirePluginReload(LiveSandboxHost before, LiveSandboxHost after) + { + ArgumentNullException.ThrowIfNull(before); + ArgumentNullException.ThrowIfNull(after); + if (before.ProcessId != after.ProcessId || before.TargetProcessId != after.TargetProcessId + || string.Equals(before.InstanceId, after.InstanceId, StringComparison.Ordinal) + || string.Equals(before.Endpoint, after.Endpoint, StringComparison.Ordinal)) + { + throw new InvalidOperationException("An in-process plugin reload must retain the CE/target PIDs and refresh discovery identity."); + } + } + + internal static void RequireHostRestart(LiveSandboxHost before, LiveSandboxHost after, LiveSandboxHost unaffected) + { + ArgumentNullException.ThrowIfNull(before); + ArgumentNullException.ThrowIfNull(after); + ArgumentNullException.ThrowIfNull(unaffected); + if (before.ProcessId == after.ProcessId || before.TargetProcessId == after.TargetProcessId) + { + throw new InvalidOperationException("A host restart must replace both the owned CE and target processes."); + } + if (unaffected.Name == after.Name || unaffected.ProcessId <= 0 || unaffected.TargetProcessId <= 0) + { + throw new InvalidOperationException("The independently owned second host was not preserved during the host restart."); + } + } +} + +internal sealed record LiveProcessMetrics(DateTimeOffset CapturedAt, int ProcessId, int HandleCount, long PrivateBytes, + long WorkingSetBytes, string? CollectionError); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualificationTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualificationTests.cs new file mode 100644 index 0000000..2a43811 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualificationTests.cs @@ -0,0 +1,116 @@ +namespace CheatEngine.Mcp.Tests.LiveQualification; + +public sealed class LiveLifecycleQualificationTests +{ + [Fact] + public void PerformanceProfile_UsesRecordedBaselineForCandidateThresholds() + { + LiveBaselineProfile baseline = LivePerformanceQualification.Baseline( + [ + LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(10), TimeSpan.FromMilliseconds(20)]), + LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(20), TimeSpan.FromMilliseconds(30)]), + LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(30), TimeSpan.FromMilliseconds(40)]) + ]); + + LivePerformanceDecision shortCalls = LivePerformanceQualification.EvaluateShortCalls(baseline, + LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(139)])); + LivePerformanceDecision jobs = LivePerformanceQualification.EvaluateBoundedJobs(baseline, + LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(61)])); + + Assert.True(shortCalls.Passed); + Assert.Equal(TimeSpan.FromMilliseconds(140), shortCalls.MaximumP95); + Assert.False(jobs.Passed); + Assert.Equal(TimeSpan.FromMilliseconds(60), jobs.MaximumP95); + Assert.False(LivePerformanceQualification.EvaluateShortCalls(baseline, + LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(140).Add(TimeSpan.FromTicks(1))])).Passed); + LiveBaselineProfile slowerBaseline = LivePerformanceQualification.Baseline( + [ + LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(120)]), + LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(140)]), + LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(130)]) + ]); + Assert.Equal(TimeSpan.FromMilliseconds(280), + LivePerformanceQualification.EvaluateShortCalls(slowerBaseline, + LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(1)])).MaximumP95); + } + + [Fact] + public void PerformanceProfile_RejectsCandidateWithoutThreeBaselineRuns() + { + InvalidOperationException exception = Assert.Throws(() => + LivePerformanceQualification.Baseline([LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(1)])])); + Assert.Contains("Exactly 3", exception.Message, StringComparison.Ordinal); + Assert.Throws(() => LivePerformanceQualification.Profile([])); + Assert.Throws(() => LivePerformanceQualification.Profile([TimeSpan.FromTicks(-1)])); + Assert.Throws(() => LivePerformanceQualification.Baseline( + Enumerable.Repeat(LivePerformanceQualification.Profile([TimeSpan.Zero]), 4))); + } + + [Fact] + public void SoakSchedule_ContainsEveryFixedWorkloadAndFitsTheReviewedDeadline() + { + IReadOnlyList schedule = LiveSoakQualification.CreateSchedule(); + Assert.Equal(LiveSoakQualification.RequiredShortCalls, schedule.Count(action => action.Kind == LiveSoakActionKind.ShortCall)); + Assert.Equal(LiveSoakQualification.RequiredBoundedJobs, schedule.Count(action => action.Kind == LiveSoakActionKind.BoundedJob)); + Assert.Equal(LiveSoakQualification.RequiredPluginCycles, schedule.Count(action => action.Kind == LiveSoakActionKind.PluginCycle)); + Assert.Equal(LiveSoakQualification.RequiredTargetRestarts, schedule.Count(action => action.Kind == LiveSoakActionKind.TargetRestart)); + LiveSoakAction gatewayRestart = Assert.Single(schedule, + action => action.Kind == LiveSoakActionKind.GatewayRestart); + Assert.Equal(LiveSoakActionKind.GatewayRestart, gatewayRestart.Kind); + Assert.All(schedule, action => Assert.InRange(action.At, TimeSpan.Zero, LiveSoakQualification.WorkloadDuration)); + Assert.Equal(schedule.OrderBy(action => action.At).ThenBy(action => action.Kind), schedule); + Assert.Equal(LiveSoakQualification.WorkloadDuration, schedule.Max(action => action.At)); + Assert.True(LiveSoakQualification.WorkloadDuration < LiveSoakQualification.MaximumLifetime); + } + + [Fact] + public void LifecycleEvidence_DistinguishesInProcessReloadFromHostRestart() + { + LiveSandboxHost before = Host("A", 10, 20, "one", "http://127.0.0.1:1001/"); + LiveSandboxHost reloaded = Host("A", 10, 20, "two", "http://127.0.0.1:1002/"); + LiveSandboxHost restarted = Host("A", 11, 21, "three", "http://127.0.0.1:1003/"); + LiveLifecycleQualification.RequirePluginReload(before, reloaded); + LiveLifecycleQualification.RequireHostRestart(before, restarted, Host("B", 12, 22, "b", "http://127.0.0.1:1004/")); + Assert.Throws(() => LiveLifecycleQualification.RequirePluginReload(before, restarted)); + } + + [Fact] + public void ResourceThresholds_RejectMetricGrowthOverAcceptanceLimits() + { + LiveProcessMetrics baseline = new(DateTimeOffset.UtcNow, 1, 100, 1024, 2048, null); + LiveResourceDecision accepted = LiveSoakQualification.EvaluateResources(baseline, + baseline with + { + HandleCount = 125, + PrivateBytes = 1024 + (64L * 1024 * 1024) + }); + LiveResourceDecision rejected = LiveSoakQualification.EvaluateResources(baseline, + baseline with + { + HandleCount = 126 + }); + LiveResourceDecision overPrivateLimit = LiveSoakQualification.EvaluateResources(baseline, + baseline with + { + PrivateBytes = 1024 + (64L * 1024 * 1024) + 1 + }); + LiveResourceDecision changedProcess = LiveSoakQualification.EvaluateResources(baseline, + baseline with + { + ProcessId = 2 + }); + LiveResourceDecision unavailable = LiveSoakQualification.EvaluateResources(baseline, + baseline with + { + CollectionError = "access denied" + }); + Assert.True(accepted.Passed); + Assert.False(rejected.Passed); + Assert.False(overPrivateLimit.Passed); + Assert.False(changedProcess.Passed); + Assert.False(unavailable.Passed); + } + + private static LiveSandboxHost Host(string name, int processId, int targetProcessId, string instanceId, string endpoint) => + new(name, processId, targetProcessId, "0x1", "0x2", "0x3", "0x4", "0x5", "plugin", instanceId, endpoint); +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleSession.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleSession.cs new file mode 100644 index 0000000..a7cafac --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleSession.cs @@ -0,0 +1,117 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.Versioning; +using System.Text.Json.Nodes; + +using CheatEngine.Mcp.Core.Contract; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Owns one gateway connection while the private hosts undergo fixed lifecycle transitions. +[SupportedOSPlatform("windows")] +internal sealed class LiveLifecycleSession(LiveSandboxSession sandbox, LiveMcpClient gateway) : IAsyncDisposable +{ + internal LiveMcpClient Gateway { get; private set; } = gateway; + private bool _ownsGateway = true; + + internal static async Task ConnectAsync(LiveSandboxSession sandbox) + { + LiveMcpClient client = await LiveMcpClient.ConnectGatewayAsync(sandbox.GatewayExecutablePath, + sandbox.InstanceDirectory, TestContext.Current.CancellationToken); + return new LiveLifecycleSession(sandbox, client); + } + + public async ValueTask DisposeAsync() + { + if (!_ownsGateway) + { + return; + } + + _ownsGateway = false; + await Gateway.DisposeAsync(); + } + + internal async Task RestartGatewayAsync() + { + await DisposeAsync(); + Gateway = await LiveMcpClient.ConnectGatewayAsync(sandbox.GatewayExecutablePath, + sandbox.InstanceDirectory, TestContext.Current.CancellationToken); + _ownsGateway = true; + await RequireInstancesAsync(sandbox.HostA.InstanceId, sandbox.HostB.InstanceId); + } + + internal async Task AttachBothAsync() + { + foreach (LiveSandboxHost host in new[] { sandbox.HostA, sandbox.HostB }) + { + JsonNode opened = (await Gateway.Bind(host.InstanceId).CallToolAsync(CheatEngineToolNames.ProcessAttach, + new Dictionary { ["process"] = host.TargetProcessId.ToString(CultureInfo.InvariantCulture) }))!; + Assert.Equal(host.TargetProcessId, opened["processId"]!.GetValue()); + } + } + + internal async Task VerifyBothAsync() + { + foreach (LiveSandboxHost host in new[] { sandbox.HostA, sandbox.HostB }) + { + LiveMcpInstanceClient instance = Gateway.Bind(host.InstanceId); + JsonNode runtime = (await instance.CallToolAsync(CheatEngineToolNames.RuntimeGetInfo))!; + foreach (string gate in new[] { "unsafeLua", "autoAssembler", "kernelAccess", "targetCodeExecution" }) + { + Assert.False(runtime["gates"]![gate]!.GetValue()); + } + Assert.Equal(Path.GetFileName(host.PluginPath), runtime["pluginFileName"]!.GetValue()); + Assert.Equal(Path.GetFileName(host.PluginPath), runtime["runtimeFileName"]!.GetValue()); + Assert.True(runtime["epoch"]!.GetValue() > 0); + JsonNode value = (await instance.CallToolAsync(CheatEngineToolNames.MemoryRead, + new Dictionary { ["address"] = host.TargetAddress, ["valueType"] = "int32" }))!; + Assert.Equal("20260926", value["value"]!.GetValue()); + } + } + + internal async Task CyclePluginAsync(string name) + { + LiveSandboxHost before = name == "A" ? sandbox.HostA : sandbox.HostB; + LiveSandboxHost unaffected = name == "A" ? sandbox.HostB : sandbox.HostA; + await sandbox.ReloadPluginAsync(name, false, TestContext.Current.CancellationToken); + await RequireInstancesAsync(unaffected.InstanceId); + await RejectStaleAsync(before.InstanceId); + JsonNode remaining = (await Gateway.Bind(unaffected.InstanceId).CallToolAsync(CheatEngineToolNames.MemoryRead, + new Dictionary { ["address"] = unaffected.TargetAddress, ["valueType"] = "int32" }))!; + Assert.Equal("20260926", remaining["value"]!.GetValue()); + await sandbox.ReloadPluginAsync(name, true, TestContext.Current.CancellationToken); + LiveSandboxHost after = name == "A" ? sandbox.HostA : sandbox.HostB; + LiveLifecycleQualification.RequirePluginReload(before, after); + Assert.Equal(unaffected, name == "A" ? sandbox.HostB : sandbox.HostA); + await RequireInstancesAsync(sandbox.HostA.InstanceId, sandbox.HostB.InstanceId); + await RejectStaleAsync(before.InstanceId); + await AttachBothAsync(); + await VerifyBothAsync(); + } + + internal async Task RequireInstancesAsync(params string[] expected) + { + Stopwatch deadline = Stopwatch.StartNew(); + do + { + JsonNode result = (await Gateway.CallToolAsync(CheatEngineToolNames.InstanceList))!; + string[] actual = result["instances"]!.AsArray().Select(item => item!["instanceId"]!.GetValue()).Order(StringComparer.Ordinal).ToArray(); + if (actual.SequenceEqual(expected.Order(StringComparer.Ordinal))) + { + return; + } + + await Task.Delay(100, TestContext.Current.CancellationToken); + } while (deadline.Elapsed < TimeSpan.FromSeconds(10)); + Assert.Fail("Discovery did not converge to the exact owned activation identities."); + } + + internal async Task RejectStaleAsync(string instanceId) + { + LiveMcpToolResult result = await Gateway.Bind(instanceId).CallToolRawAsync(CheatEngineToolNames.RuntimeGetInfo); + Assert.True(result.IsError); + Assert.Equal("instance_unavailable", result.Payload!["error"]!["kind"]!.GetValue()); + Assert.Equal("not_started", result.Payload["error"]!["hostEffect"]!.GetValue()); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveManagedInjectionQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveManagedInjectionQualification.cs new file mode 100644 index 0000000..8efda7d --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveManagedInjectionQualification.cs @@ -0,0 +1,333 @@ +using System.Diagnostics; +using System.Globalization; +using System.Reflection.PortableExecutable; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; + +using CheatEngine.Mcp.Core.Contract; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Closed-world preparation for the separately admitted managed-injection qualification. +[SupportedOSPlatform("windows")] +internal static class LiveManagedInjectionQualification +{ + internal const string TypeName = "CheatEngineMcpCompileCsProbe"; + internal const string MethodName = "Run"; + internal const string Parameter = "CEMCP-COMPILECS-PROBE-V1"; + internal const string ExpectedResult = "7319"; + internal static async Task RunAsync(LiveQualificationInputs inputs) + { + if (inputs.Scenario != LiveQualificationScenario.CompilerInjection) + { + throw new InvalidOperationException("Managed injection is admitted only by compiler-injection."); + } + object candidate = LiveCompilerCandidate.Verify(inputs); + LiveFrameworkFixture fixture = RequireFixture(inputs); + LiveSandboxOptions options = new(true, true) + { + EnableManagedInjection = true, + Variant = LiveCompilerVariant.HeldExportAfterB, + TempTopology = CompilerTempTopology.Shared + }; + await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs, options); + LiveFrameworkTargetSession target = await LiveFrameworkTargetSession.StartAsync(sandbox.RunDirectory, fixture, + TestContext.Current.CancellationToken); + try + { + await using LiveMcpClient gateway = await LiveMcpClient.ConnectGatewayAsync(sandbox.GatewayExecutablePath, + sandbox.InstanceDirectory, TestContext.Current.CancellationToken); + LiveMcpInstanceClient instanceA = gateway.Bind(sandbox.HostA.InstanceId); + LiveMcpInstanceClient instanceB = gateway.Bind(sandbox.HostB.InstanceId); + await AttachAsync(instanceA, target.ProcessId); + await AttachAsync(instanceB, sandbox.HostB.TargetProcessId); + await AssertAttachedAsync(instanceB, sandbox.HostB.TargetProcessId); + JsonNode? runtime = await instanceA.CallToolAsync(CheatEngineToolNames.RuntimeGetInfo); + JsonNode gates = runtime?["gates"] ?? throw new InvalidDataException("Injection runtime did not report gates."); + if (!gates["targetCodeExecution"]!.GetValue() || gates["unsafeLua"]!.GetValue() || + gates["autoAssembler"]!.GetValue() || gates["kernelAccess"]!.GetValue()) + { + throw new InvalidOperationException("Injection runtime gates differ from the reviewed admission."); + } + + LiveHeldCompilerExport export = await LiveCompilerQualification.CompileHeldExportAsync(sandbox, gateway, instanceA) + ?? throw new InvalidOperationException("B shutdown expired the raw assembly; injection was not attempted and remains unqualified."); + await AssertAttachedAsync(instanceA, target.ProcessId); + await VerifyOneFixedInjectionAsync(sandbox, instanceA, export, fixture); + target.RequireAlive(); + sandbox.Record("managed_injection_scenario", new + { + candidate, + fixture.Architecture, + fixture.Sha256, + target.ProcessId, + bClosedBeforeExport = true, + separateInjection = true, + fixtureRuntime = target.RuntimeVersion + }); + } + finally + { + await target.DisposeAsync(); + } + sandbox.MarkPassed(); + } + + private static async Task AttachAsync(LiveMcpInstanceClient instance, int processId) + { + JsonNode? opened = await instance.CallToolAsync(CheatEngineToolNames.ProcessAttach, + new Dictionary { ["process"] = processId.ToString(CultureInfo.InvariantCulture) }); + if (opened?["processId"]?.GetValue() != processId) + { + throw new InvalidOperationException("A did not attach to the owned Framework fixture."); + } + } + + private static async Task AssertAttachedAsync(LiveMcpInstanceClient instance, int processId) + { + JsonNode? overview = await instance.CallToolAsync(CheatEngineToolNames.RuntimeGetOverview); + if (overview?["process"]?["processId"]?.GetValue() != processId) + { + throw new InvalidOperationException("The isolation target changed unexpectedly."); + } + } + + /// Checks the reviewed Framework fixture before a caller starts an owned injection session. + internal static LiveFrameworkFixture RequireFixture(LiveQualificationInputs inputs) + { + if (inputs.Scenario != LiveQualificationScenario.CompilerInjection) + { + throw new InvalidOperationException("The Framework fixture is admitted only to compiler-injection."); + } + string root = Path.Combine(inputs.RepositoryRoot, "artifacts", "live-framework-target", inputs.TargetArchitecture); + string executable = Path.Combine(root, "CheatEngine.Mcp.LiveFrameworkTarget.exe"); + string identity = Path.Combine(root, "fixture-identity.json"); + if (!File.Exists(executable) || !File.Exists(identity)) + { + throw new FileNotFoundException("Build the fixed Framework fixture with eng/Build-LiveFrameworkTarget.ps1 before injection qualification.", executable); + } + LiveCompilerCandidate.RequireNoReparseAncestors(executable); + LiveCompilerCandidate.RequireNoReparseAncestors(identity); + if (new FileInfo(identity).Length > 4096 || new FileInfo(executable).Length is <= 0 or > 1024 * 1024) + { + throw new InvalidDataException("The fixed Framework fixture or identity exceeds its size bound."); + } + JsonNode document = JsonNode.Parse(File.ReadAllText(identity)) + ?? throw new InvalidDataException("The fixed Framework fixture identity file is empty."); + string architecture = document["architecture"]?.GetValue() ?? string.Empty; + string expectedHash = document["targetSha256"]?.GetValue() ?? string.Empty; + string expectedSource = document["sourceSha256"]?.GetValue() ?? string.Empty; + string source = Path.Combine(inputs.RepositoryRoot, "eng", "LiveFrameworkTarget", "Program.cs"); + string actualSource = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(source))); + string actualHash = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(executable))); + if (architecture != inputs.TargetArchitecture || !string.Equals(expectedHash, actualHash, StringComparison.OrdinalIgnoreCase) || + !string.Equals(expectedSource, actualSource, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException("The fixed Framework fixture identity does not match its selected architecture or bytes."); + } + using FileStream image = File.OpenRead(executable); + using PEReader pe = new(image); + if (pe.PEHeaders.CorHeader is not { } cor || + (architecture == "x86" && (pe.PEHeaders.CoffHeader.Machine != Machine.I386 || (cor.Flags & CorFlags.Requires32Bit) == 0)) || + (architecture == "x64" && pe.PEHeaders.CoffHeader.Machine != Machine.Amd64)) + { + throw new InvalidDataException("The Framework fixture PE architecture does not match its declared identity."); + } + return new LiveFrameworkFixture(executable, actualHash, inputs.TargetArchitecture); + } + + /// Issues the one approved fixed method call; callers must have compiled and held the artifact already. + internal static async Task VerifyOneFixedInjectionAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance, + LiveHeldCompilerExport export, LiveFrameworkFixture fixture) + { + ArgumentNullException.ThrowIfNull(sandbox); + ArgumentNullException.ThrowIfNull(instance); + ArgumentNullException.ThrowIfNull(fixture); + string exportedAssembly = export.Path; + if (!Path.IsPathFullyQualified(exportedAssembly) || !File.Exists(exportedAssembly)) + { + throw new FileNotFoundException("The fixed compiler export was not retained for injection.", exportedAssembly); + } + if (!LiveQualificationOptIn.IsSameOrBelow(exportedAssembly, sandbox.CompilerA.Output)) + { + throw new InvalidDataException("The injection assembly escaped the owned compiler output root."); + } + LiveCompilerCandidate.RequireNoReparseAncestors(exportedAssembly); + using FileStream heldAssembly = new(exportedAssembly, FileMode.Open, FileAccess.Read, FileShare.Read); + if (heldAssembly.Length != export.Length || Convert.ToHexString(SHA256.HashData(heldAssembly)) != export.Sha256) + { + throw new InvalidDataException("The fixed compiler export changed before injection."); + } + LiveMcpToolResult result = await instance.CallToolRawAsync(CheatEngineToolNames.ExecInjectDotNet, + new Dictionary + { + ["assemblyPath"] = exportedAssembly, + ["className"] = TypeName, + ["methodName"] = MethodName, + ["parameter"] = Parameter + }); + if (result.IsError) + { + throw new InvalidOperationException("The fixed managed injection did not succeed: " + result.ErrorText); + } + JsonNode payload = result.Payload ?? throw new InvalidDataException("Managed injection returned no structured payload."); + if (payload["assemblyPath"]?.GetValue() != exportedAssembly || + payload["className"]?.GetValue() != TypeName || payload["methodName"]?.GetValue() != MethodName || + payload["result"]?.GetValue() != ExpectedResult) + { + throw new InvalidDataException("Managed injection did not confirm the one reviewed entry point and return value."); + } + sandbox.Record("managed_injection_fixed_result", new + { + fixture.Architecture, + fixture.Sha256, + assemblyPath = exportedAssembly, + TypeName, + MethodName, + Parameter, + result = ExpectedResult + }); + } +} + +internal sealed record LiveFrameworkFixture(string ExecutablePath, string Sha256, string Architecture); + +/// Owns one no-input Framework fixture below the active run and always sends its adjacent stop marker. +[SupportedOSPlatform("windows")] +internal sealed class LiveFrameworkTargetSession : IAsyncDisposable +{ + private readonly string _stop; + private readonly Process _process; + private bool _disposed; + private LiveFrameworkTargetSession(Process process, string stop) + { + _process = process; + _stop = stop; + } + internal int ProcessId => _process.Id; + internal void RequireAlive() + { + if (_disposed || _process.HasExited) + { + throw new InvalidOperationException("The owned Framework fixture exited before its explicit graceful stop."); + } + } + internal string RuntimeVersion { get; private set; } = string.Empty; + + internal static async Task StartAsync(string runDirectory, LiveFrameworkFixture fixture, CancellationToken token) + { + string manifest = Path.Combine(runDirectory, "framework-injection-target.json"); + if (!Path.IsPathFullyQualified(manifest) || !LiveQualificationOptIn.IsSameOrBelow(manifest, runDirectory)) + { + throw new InvalidOperationException("Framework fixture manifest escaped the live run."); + } + + string executable = Path.Combine(runDirectory, "CheatEngine.Mcp.LiveFrameworkTarget.exe"); + LiveCompilerCandidate.RequireNoReparseAncestors(manifest); + LiveCompilerCandidate.RequireNoReparseAncestors(executable); + if (File.Exists(manifest) || File.Exists(manifest + ".tmp") || File.Exists(manifest + ".stop")) + { + throw new InvalidOperationException("The Framework fixture paths must be unused before startup."); + } + File.Copy(fixture.ExecutablePath, executable, false); + using (FileStream copied = File.OpenRead(executable)) + { + if (Convert.ToHexString(SHA256.HashData(copied)) != fixture.Sha256) + { + throw new InvalidDataException("The owned Framework fixture copy changed before startup."); + } + } + ProcessStartInfo start = new(executable) + { + UseShellExecute = false, + CreateNoWindow = true, + WindowStyle = ProcessWindowStyle.Hidden, + WorkingDirectory = runDirectory + }; + foreach (string key in start.Environment.Keys.Where(key => key.StartsWith("DOTNET_", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("MCP_", StringComparison.OrdinalIgnoreCase) || key.StartsWith("CHEATENGINE_", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("COR_", StringComparison.OrdinalIgnoreCase) || key.StartsWith("CORECLR_", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("COMPLUS_", StringComparison.OrdinalIgnoreCase)).ToArray()) + { + start.Environment.Remove(key); + } + + start.ArgumentList.Add(manifest); + Process process = Process.Start(start) ?? throw new InvalidOperationException("The owned Framework fixture did not start."); + LiveFrameworkTargetSession session = new(process, manifest + ".stop"); + try + { + Stopwatch lifetime = Stopwatch.StartNew(); + while (!File.Exists(manifest)) + { + if (process.HasExited || lifetime.Elapsed >= TimeSpan.FromSeconds(10)) + { + throw new TimeoutException("The owned Framework fixture did not publish its manifest."); + } + + await Task.Delay(50, token); + } + LiveCompilerCandidate.RequireNoReparseAncestors(manifest); + using FileStream input = new(manifest, FileMode.Open, FileAccess.Read, FileShare.Read); + if (input.Length is <= 0 or > 4096) + { + throw new InvalidDataException("The Framework fixture manifest exceeds its bound."); + } + + using StreamReader reader = new(input, new UTF8Encoding(false, true), false); + JsonNode identity = JsonNode.Parse(await reader.ReadToEndAsync(token)) + ?? throw new InvalidDataException("The Framework fixture manifest is empty."); + string runtime = identity["runtime"]?.GetValue() ?? string.Empty; + if (identity["processId"]?.GetValue() != process.Id || + identity["pointerWidth"]?.GetValue() != (fixture.Architecture == "x86" ? 32 : 64) || + identity["sha256"]?.GetValue() != fixture.Sha256 || + identity["assembly"]?.GetValue() != "CheatEngine.Mcp.LiveFrameworkTarget" || + !Version.TryParse(runtime, out Version? parsedRuntime) || parsedRuntime.Major != 4) + { + throw new InvalidDataException("The Framework target process, architecture, CLR or executable hash differs from the reviewed fixture."); + } + session.RuntimeVersion = runtime; + return session; + } + catch { await session.DisposeAsync(); throw; } + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + try + { + if (_process.HasExited) + { + throw new InvalidOperationException("The owned Framework fixture exited prematurely; cleanup cannot qualify it."); + } + try + { + LiveCompilerCandidate.RequireNoReparseAncestors(_stop); + File.WriteAllText(_stop, "stop"); + await _process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + if (_process.ExitCode != 0) + { + throw new InvalidOperationException("The owned Framework fixture returned a nonzero exit code."); + } + } + catch + { + if (!_process.HasExited) + { + _process.Kill(true); + } + await _process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + throw; + } + } + finally { _process.Dispose(); } + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs index f746813..ec44462 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs @@ -118,6 +118,45 @@ public LiveMcpInstanceClient Bind(string instanceId) return new LiveMcpInstanceClient(this, instanceId); } + public async Task ListResourcesAsync() + { + using CancellationTokenSource timeout = CreateTimeout(cancellationToken); + return await client.ListResourcesAsync(new ListResourcesRequestParams(), timeout.Token); + } + + public async Task> ListResourceTemplatesAsync() + { + using CancellationTokenSource timeout = CreateTimeout(cancellationToken); + return await client.ListResourceTemplatesAsync(cancellationToken: timeout.Token); + } + + public async Task ReadResourceAsync(string uri) + { + using CancellationTokenSource timeout = CreateTimeout(cancellationToken); + return await client.ReadResourceAsync(uri, cancellationToken: timeout.Token); + } + + public async Task> ListPromptsAsync() + { + using CancellationTokenSource timeout = CreateTimeout(cancellationToken); + return await client.ListPromptsAsync(cancellationToken: timeout.Token); + } + + public async Task GetPromptAsync(string name, IReadOnlyDictionary arguments) + { + using CancellationTokenSource timeout = CreateTimeout(cancellationToken); + return await client.GetPromptAsync(name, arguments, cancellationToken: timeout.Token); + } + + public async Task CompleteInstancesAsync() + { + using CancellationTokenSource timeout = CreateTimeout(cancellationToken); + return await client.CompleteAsync(new ResourceTemplateReference + { + Uri = "cheatengine://instances/{instanceId}/runtime" + }, "instanceId", "ce-", cancellationToken: timeout.Token); + } + private static CancellationTokenSource CreateTimeout(CancellationToken cancellationToken) { CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceQualification.cs new file mode 100644 index 0000000..e172ecf --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceQualification.cs @@ -0,0 +1,211 @@ +using System.Diagnostics; +using System.Globalization; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text.Json.Nodes; + +using CheatEngine.Mcp.Core.Contract; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Deterministic latency aggregation and acceptance checks for the reviewed live workloads. +internal static class LivePerformanceQualification +{ + internal const int RequiredBaselineRuns = 3; + internal static readonly TimeSpan ShortCallAllowance = TimeSpan.FromMilliseconds(100); + private const int ShortSamples = 100; + private const int BatchSamples = 30; + private const int BatchSize = 16; + + [SupportedOSPlatform("windows")] + internal static async Task RunAsync(LiveQualificationInputs inputs) + { + Assert.Equal(LiveQualificationScenario.Performance, inputs.Scenario); + string baselineDirectory = Path.Combine(inputs.RepositoryRoot, "artifacts", "qualification-baseline", "beta.2", "staged"); + const string baselineVersion = "2.0.0-beta.2+c51a0ec3af373c852cda062a91888ad94fa0cce9"; + Assert.Equal(baselineVersion, FileVersionInfo.GetVersionInfo(Path.Combine(baselineDirectory, "CheatEngine.Mcp.dll")).ProductVersion); + foreach ((string name, string expectedHash) in new[] + { + ("CheatEngine.Mcp.dll", "5699ECA2ED06461846A977802DF3E4FA7BF52159F50AA1446C4819908A7692AE"), + ("CheatEngine.Mcp.Gateway.exe", "2E0E7F8512D8A8AAC0197E982C7660BFA4336EBDB8981543817B709381C83885") + }) + { + using FileStream binary = File.OpenRead(Path.Combine(baselineDirectory, name)); + Assert.Equal(expectedHash, Convert.ToHexString(SHA256.HashData(binary))); + } + LivePerformanceRun[] baselineRuns = new LivePerformanceRun[RequiredBaselineRuns]; + for (int index = 0; index < baselineRuns.Length; index++) + { + baselineRuns[index] = await MeasureSessionAsync(inputs, baselineDirectory, index + 1, null); + } + LiveBaselineProfile shortBaseline = Baseline(baselineRuns.Select(run => run.ShortCalls)); + LiveBaselineProfile batchBaseline = Baseline(baselineRuns.Select(run => run.BatchCalls)); + for (int index = 0; index < RequiredBaselineRuns; index++) + { + _ = await MeasureSessionAsync(inputs, null, index + 1, (shortBaseline, batchBaseline)); + } + } + + [SupportedOSPlatform("windows")] + private static async Task MeasureSessionAsync(LiveQualificationInputs inputs, + string? distributionOverride, int runNumber, (LiveBaselineProfile Short, LiveBaselineProfile Batch)? baseline) + { + LiveSandboxOptions options = LiveSandboxOptions.Smoke with + { + PerformanceQualification = true, + DistributionDirectoryOverride = distributionOverride + }; + await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs, options); + await using LiveMcpClient gateway = await LiveMcpClient.ConnectGatewayAsync(sandbox.GatewayExecutablePath, + sandbox.InstanceDirectory, TestContext.Current.CancellationToken); + LiveMcpInstanceClient a = gateway.Bind(sandbox.HostA.InstanceId); + LiveMcpInstanceClient b = gateway.Bind(sandbox.HostB.InstanceId); + foreach ((LiveMcpInstanceClient client, LiveSandboxHost host) in new[] { (a, sandbox.HostA), (b, sandbox.HostB) }) + { + JsonNode runtime = (await client.CallToolAsync(CheatEngineToolNames.RuntimeGetInfo))!; + foreach (string gate in new[] { "unsafeLua", "autoAssembler", "kernelAccess", "targetCodeExecution" }) + { + Assert.False(runtime["gates"]![gate]!.GetValue()); + } + JsonNode opened = (await client.CallToolAsync(CheatEngineToolNames.ProcessAttach, + new Dictionary { ["process"] = host.TargetProcessId.ToString(CultureInfo.InvariantCulture) }))!; + Assert.Equal(host.TargetProcessId, opened["processId"]!.GetValue()); + } + + IReadOnlyDictionary shortArguments = new Dictionary + { + ["address"] = sandbox.HostA.TargetAddress, + ["valueType"] = "int32" + }; + IReadOnlyDictionary batchArguments = new Dictionary + { + ["items"] = Enumerable.Range(0, BatchSize) + .Select(_ => new { address = sandbox.HostA.TargetAddress, valueType = "int32" }).ToArray() + }; + async Task ShortCall() + { + JsonNode result = (await a.CallToolAsync(CheatEngineToolNames.MemoryRead, shortArguments))!; + Assert.Equal("20260926", result["value"]!.GetValue()); + } + async Task BatchCall() + { + JsonNode result = (await a.CallToolAsync(CheatEngineToolNames.MemoryReadBatch, batchArguments))!; + Assert.Equal(0, result["failed"]!.GetValue()); + JsonArray items = result["items"]!.AsArray(); + Assert.Equal(BatchSize, items.Count); + Assert.All(items, item => Assert.Equal("20260926", item!["value"]!.GetValue())); + } + // The same five warm-up pairs are excluded from every recorded profile. + for (int index = 0; index < 5; index++) + { + await ShortCall(); + await BatchCall(); + } + LiveLatencyProfile shortCalls = await MeasureAsync(ShortSamples, ShortCall, TestContext.Current.CancellationToken); + LiveLatencyProfile batchCalls = await MeasureAsync(BatchSamples, BatchCall, TestContext.Current.CancellationToken); + JsonNode unaffected = (await b.CallToolAsync(CheatEngineToolNames.MemoryRead, + new Dictionary { ["address"] = sandbox.HostB.TargetAddress, ["valueType"] = "int32" }))!; + Assert.Equal("20260926", unaffected["value"]!.GetValue()); + LivePerformanceDecision? shortDecision = baseline is { } first ? EvaluateShortCalls(first.Short, shortCalls) : null; + LivePerformanceDecision? batchDecision = baseline is { } second ? EvaluateBoundedJobs(second.Batch, batchCalls) : null; + sandbox.Record("performance", new + { + kind = baseline is null ? "published_beta2_baseline" : "candidate", + runNumber, + productVersion = FileVersionInfo.GetVersionInfo(sandbox.GatewayExecutablePath).ProductVersion, + method = "Stopwatch full stdio request, response parse and value assertion; five unrecorded warm-up pairs; nearest-rank p95; maximum of three baseline p95s", + shortWorkload = "memory_read int32", + shortCalls, + batchWorkload = "memory_read_batch of 16 int32 requests", + batchCalls, + baselineShort = baseline?.Short, + baselineBatch = baseline?.Batch, + shortDecision, + batchDecision, + limitation = "These results qualify the two recorded workloads only; retained jobs, completions and blocking-native calls are separate." + }); + if (shortDecision is not null && batchDecision is not null) + { + Assert.True(shortDecision.Passed, $"Short-call p95 {shortDecision.ObservedP95} exceeds {shortDecision.MaximumP95}."); + Assert.True(batchDecision.Passed, $"Batch p95 {batchDecision.ObservedP95} exceeds {batchDecision.MaximumP95}."); + } + sandbox.MarkPassed(); + return new LivePerformanceRun(shortCalls, batchCalls); + } + + internal static LiveLatencyProfile Profile(IEnumerable samples) + { + ArgumentNullException.ThrowIfNull(samples); + long[] ticks = samples.Select(sample => sample.Ticks).Order().ToArray(); + if (ticks.Length == 0 || ticks.Any(value => value < 0)) + { + throw new ArgumentException("Latency samples must be non-empty and non-negative.", nameof(samples)); + } + + return new LiveLatencyProfile(ticks.Length, TimeSpan.FromTicks(ticks[(ticks.Length - 1) / 2]), + TimeSpan.FromTicks(ticks[(int) Math.Ceiling(ticks.Length * 0.95) - 1]), TimeSpan.FromTicks(ticks[^1])); + } + + internal static LiveBaselineProfile Baseline(IEnumerable runs) + { + ArgumentNullException.ThrowIfNull(runs); + LiveLatencyProfile[] captured = runs.ToArray(); + if (captured.Length != RequiredBaselineRuns) + { + throw new InvalidOperationException($"Exactly {RequiredBaselineRuns} separately recorded baseline runs are required."); + } + + if (captured.Any(run => run.SampleCount == 0)) + { + throw new InvalidOperationException("A baseline run contained no latency samples."); + } + + return new LiveBaselineProfile(captured, captured.Max(run => run.P95)); + } + + internal static LivePerformanceDecision EvaluateShortCalls(LiveBaselineProfile baseline, LiveLatencyProfile candidate) + { + ArgumentNullException.ThrowIfNull(baseline); + ArgumentNullException.ThrowIfNull(candidate); + TimeSpan threshold = Max(Times(baseline.P95, 2), baseline.P95 + ShortCallAllowance); + return new LivePerformanceDecision(candidate.P95 <= threshold, candidate.P95, threshold); + } + + internal static LivePerformanceDecision EvaluateBoundedJobs(LiveBaselineProfile baseline, LiveLatencyProfile candidate) + { + ArgumentNullException.ThrowIfNull(baseline); + ArgumentNullException.ThrowIfNull(candidate); + TimeSpan threshold = Times(baseline.P95, 1.5); + return new LivePerformanceDecision(candidate.P95 <= threshold, candidate.P95, threshold); + } + + internal static async Task MeasureAsync(int count, Func operation, + CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); + ArgumentNullException.ThrowIfNull(operation); + TimeSpan[] samples = new TimeSpan[count]; + for (int index = 0; index < samples.Length; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + long started = Stopwatch.GetTimestamp(); + await operation(); + samples[index] = Stopwatch.GetElapsedTime(started); + } + + return Profile(samples); + } + + private static TimeSpan Times(TimeSpan value, double multiplier) => TimeSpan.FromTicks( + checked((long) Math.Ceiling(value.Ticks * multiplier))); + + private static TimeSpan Max(TimeSpan first, TimeSpan second) => first >= second ? first : second; +} + +internal sealed record LiveLatencyProfile(int SampleCount, TimeSpan P50, TimeSpan P95, TimeSpan Maximum); + +internal sealed record LiveBaselineProfile(IReadOnlyList Runs, TimeSpan P95); + +internal sealed record LivePerformanceDecision(bool Passed, TimeSpan ObservedP95, TimeSpan MaximumP95); + +internal sealed record LivePerformanceRun(LiveLatencyProfile ShortCalls, LiveLatencyProfile BatchCalls); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePluginBridge.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePluginBridge.cs new file mode 100644 index 0000000..89e3e9b --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePluginBridge.cs @@ -0,0 +1,47 @@ +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Fixed CE Settings/Plugins control bridge; only enable and disable records are accepted. +internal static class LivePluginBridge +{ + internal const string Template = """ + local function pluginResponse(text) + local response=assert(io.open(__RESPONSE__..'.tmp','w')) + response:write(text); response:close() + assert(os.rename(__RESPONSE__..'.tmp',__RESPONSE__),'plugin response publish failed') + end + local function pluginControl() + local request=io.open(__REQUEST__,'r') + if not request then return end + local command=request:read('*a'); request:close(); assert(os.remove(__REQUEST__),'plugin request dequeue failed') + local nonce,action=command:match('^(%d+)\n(%a+)\n$') + assert(nonce and (action=='enable' or action=='disable'), 'invalid fixed plugin request') + assert((action=='enable') ~= pluginEnabled, 'plugin requested state is already active') + reloadSettingsFromRegistry() + local f=assert(getSettingsForm(), 'settings form unavailable') + local pc=assert(f.findComponentByName('pcSetting'), 'settings page control unavailable') + local tab=assert(f.findComponentByName('Plugins'), 'plugins tab unavailable') + local clb=assert(f.findComponentByName('clbPlugins'), 'plugins checklist unavailable') + local ok=assert(f.findComponentByName('btnOK'), 'settings OK unavailable') + pc.ActivePage=tab + assert(clb.Items.Count==1, 'expected exactly one owned plugin row') + local row=-1 + for i=0,clb.Items.Count-1 do + local text=tostring(clb.Items[i]) + if text:find(__BASENAME__,1,true) and text:find(__DISPLAY__,1,true) then + assert(row==-1, 'duplicate MCP plugin rows'); row=i + end + end + assert(row>=0, 'owned MCP plugin row not found') + clb.ItemIndex=row + sendMessage(clb.Handle,0x0100,0x20,0); sendMessage(clb.Handle,0x0101,0x20,0) + ok.doClick(); pluginEnabled=(action=='enable') + pluginResponse('ok\n'..nonce..'\n'..action) + end + """; + + internal static string Render(string request, string response, Func quote) => Template + .Replace("__REQUEST__", quote(request), StringComparison.Ordinal) + .Replace("__RESPONSE__", quote(response), StringComparison.Ordinal) + .Replace("__BASENAME__", quote("CheatEngine.Mcp.dll"), StringComparison.Ordinal) + .Replace("__DISPLAY__", quote("CheatEngine.Mcp"), StringComparison.Ordinal); +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs index bcb0dd4..354e09a 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs @@ -12,6 +12,9 @@ internal static class LiveQualificationOptIn internal const string CodeExecutionOptInVariable = "CHEATENGINE_MCP_LIVE_CODE_EXECUTION_QUALIFICATION"; internal const string CodeExecutionAcknowledgement = "I_AUTHORIZE_FIXED_COMPILECS_PROBES_ON_PRIVATE_CE_AND_OWNED_TARGETS"; + internal const string InjectionOptInVariable = "CHEATENGINE_MCP_LIVE_MANAGED_INJECTION_QUALIFICATION"; + internal const string InjectionAcknowledgement = + "I_AUTHORIZE_FIXED_MANAGED_INJECTION_ON_OWNED_FRAMEWORK_TARGETS"; internal const string LocalCommand = "dotnet test --project tests/CheatEngine.Mcp.Tests -c Release --filter-trait Category=LiveQualification --fail-skips on"; @@ -44,21 +47,37 @@ internal static LiveQualificationDecision Evaluate(Func variabl } string scenarioValue = variables(ScenarioVariable) ?? "smoke"; - if (scenarioValue is not ("smoke" or "compiler")) + LiveQualificationScenario? selectedScenario = scenarioValue switch { - return Refuse($"{ScenarioVariable} must be smoke or compiler."); + "smoke" => LiveQualificationScenario.Smoke, + "compiler" => LiveQualificationScenario.Compiler, + "compiler-extended" => LiveQualificationScenario.CompilerExtended, + "compiler-injection" => LiveQualificationScenario.CompilerInjection, + "lifecycle" => LiveQualificationScenario.Lifecycle, + "performance" => LiveQualificationScenario.Performance, + "soak" => LiveQualificationScenario.Soak, + _ => null + }; + if (selectedScenario is null) + { + return Refuse($"{ScenarioVariable} must be smoke, compiler, compiler-extended, compiler-injection, lifecycle, performance, or soak."); } - LiveQualificationScenario scenario = scenarioValue == "compiler" - ? LiveQualificationScenario.Compiler - : LiveQualificationScenario.Smoke; + LiveQualificationScenario scenario = selectedScenario.Value; + bool compiler = scenario is LiveQualificationScenario.Compiler or LiveQualificationScenario.CompilerExtended + or LiveQualificationScenario.CompilerInjection; - if (scenario == LiveQualificationScenario.Compiler && + if (compiler && variables(CodeExecutionOptInVariable) != CodeExecutionAcknowledgement) { return Refuse($"{CodeExecutionOptInVariable} must contain the exact acknowledgement {CodeExecutionAcknowledgement}."); } - if (scenario == LiveQualificationScenario.Compiler && compilerTempSettingsAreSafe is not null && + if (scenario == LiveQualificationScenario.CompilerInjection && + variables(InjectionOptInVariable) != InjectionAcknowledgement) + { + return Refuse($"{InjectionOptInVariable} must contain the exact acknowledgement {InjectionAcknowledgement}."); + } + if (compiler && compilerTempSettingsAreSafe is not null && !compilerTempSettingsAreSafe()) { return Refuse("Cheat Engine's Don't use tempdir setting is enabled; compiler qualification requires the owned TEMP directory."); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs index 9992d8a..d451353 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs @@ -168,6 +168,70 @@ public void Evaluate_MissingOrRelativeInstallation_Refuses(string installation) Assert.False(Evaluate(variables).IsAuthorized); } + [Theory] + [InlineData("lifecycle", LiveQualificationScenario.Lifecycle)] + [InlineData("performance", LiveQualificationScenario.Performance)] + [InlineData("soak", LiveQualificationScenario.Soak)] + public void Evaluate_StandardQualificationScenario_UsesOnlyStandardAcknowledgement(string name, int expected) + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.ScenarioVariable] = name; + LiveQualificationDecision decision = LiveQualificationOptIn.Evaluate(variables.GetValueOrDefault, + Repository, LocalData, Installation, static _ => true, + static () => throw new InvalidOperationException("A standard scenario must not inspect compiler settings.")); + Assert.True(decision.IsAuthorized, decision.Refusal); + Assert.Equal((LiveQualificationScenario) expected, decision.Inputs!.Scenario); + } + + [Theory] + [InlineData("compiler-extended")] + [InlineData("compiler-injection")] + public void Evaluate_CompilerVariantsWithoutCompilerAcknowledgement_RefuseBeforeInspection(string name) + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.ScenarioVariable] = name; + variables[LiveQualificationOptIn.InjectionOptInVariable] = LiveQualificationOptIn.InjectionAcknowledgement; + LiveQualificationDecision decision = LiveQualificationOptIn.Evaluate(variables.GetValueOrDefault, + Repository, LocalData, Installation, static _ => throw new InvalidOperationException("Must refuse first.")); + Assert.False(decision.IsAuthorized); + Assert.Contains(LiveQualificationOptIn.CodeExecutionOptInVariable, decision.Refusal, StringComparison.Ordinal); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("true")] + [InlineData(LiveQualificationOptIn.CodeExecutionAcknowledgement)] + public void Evaluate_InjectionWithoutExactThirdAcknowledgement_RefusesBeforeInspection(string? acknowledgement) + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.ScenarioVariable] = "compiler-injection"; + variables[LiveQualificationOptIn.CodeExecutionOptInVariable] = LiveQualificationOptIn.CodeExecutionAcknowledgement; + variables[LiveQualificationOptIn.InjectionOptInVariable] = acknowledgement; + LiveQualificationDecision decision = LiveQualificationOptIn.Evaluate(variables.GetValueOrDefault, + Repository, LocalData, Installation, static _ => throw new InvalidOperationException("Must refuse first."), + static () => throw new InvalidOperationException("Must refuse first.")); + Assert.False(decision.IsAuthorized); + Assert.Contains(LiveQualificationOptIn.InjectionOptInVariable, decision.Refusal, StringComparison.Ordinal); + } + + [Theory] + [InlineData("compiler-extended", LiveQualificationScenario.CompilerExtended)] + [InlineData("compiler-injection", LiveQualificationScenario.CompilerInjection)] + public void Evaluate_CompilerVariantsWithRequiredAcknowledgements_Authorize(string name, int expected) + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.ScenarioVariable] = name; + variables[LiveQualificationOptIn.CodeExecutionOptInVariable] = LiveQualificationOptIn.CodeExecutionAcknowledgement; + if (name == "compiler-injection") + { + variables[LiveQualificationOptIn.InjectionOptInVariable] = LiveQualificationOptIn.InjectionAcknowledgement; + } + LiveQualificationDecision decision = Evaluate(variables); + Assert.True(decision.IsAuthorized, decision.Refusal); + Assert.Equal((LiveQualificationScenario) expected, decision.Inputs!.Scenario); + } + private static Dictionary Authorized() { return new Dictionary diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs index c56d21e..0778c20 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs @@ -4,5 +4,10 @@ namespace CheatEngine.Mcp.Tests.LiveQualification; internal enum LiveQualificationScenario { Smoke, - Compiler + Compiler, + CompilerExtended, + CompilerInjection, + Lifecycle, + Performance, + Soak } diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveResourceQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveResourceQualification.cs new file mode 100644 index 0000000..a4d486a --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveResourceQualification.cs @@ -0,0 +1,64 @@ +using System.Runtime.Versioning; +using System.Text.Json.Nodes; + +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Prompts.Workflows; +using CheatEngine.Mcp.Resources.Knowledge; + +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Read-only resource and prompt checks through the real packaged stdio gateway. +[SupportedOSPlatform("windows")] +internal static class LiveResourceQualification +{ + internal static async Task VerifyAsync(LiveSandboxSession sandbox, LiveMcpClient gateway) + { + Assert.Equal(LiveMcpClient.ProtocolVersion, gateway.NegotiatedProtocolVersion); + ListResourcesResult resources = await gateway.ListResourcesAsync(); + Assert.Null(resources.NextCursor); + Assert.Equal(CheatEngineKnowledge.DocumentSlugs.Select(McpResourceUris.Doc).Order(StringComparer.Ordinal), + resources.Resources.Where(resource => resource.Uri.StartsWith("cheatengine://docs/", StringComparison.Ordinal)) + .Select(resource => resource.Uri).Order(StringComparer.Ordinal)); + Assert.Contains(resources.Resources, resource => resource.Uri == McpResourceUris.GatewayInstances); + IList templates = await gateway.ListResourceTemplatesAsync(); + Assert.Contains(templates, template => template.UriTemplate == "cheatengine://instances/{instanceId}/runtime"); + Assert.Contains(templates, template => template.UriTemplate == "cheatengine://docs/workflows/{workflow}"); + ReadResourceResult safety = await gateway.ReadResourceAsync(McpResourceUris.Doc("safety")); + Assert.Equal(CheatEngineKnowledge.ReadDocument("safety"), + Assert.IsType(Assert.Single(safety.Contents)).Text); + IList prompts = await gateway.ListPromptsAsync(); + Assert.Equal(CheatEngineWorkflows.All.Count, prompts.Count); + GetPromptResult prompt = await gateway.GetPromptAsync("find_writer", + new Dictionary { ["address"] = sandbox.HostA.TargetAddress }); + Assert.Contains(CheatEngineToolNames.InstanceList, + Assert.IsType(prompt.Messages[0].Content).Text, StringComparison.Ordinal); + foreach (LiveSandboxHost host in new[] { sandbox.HostA, sandbox.HostB }) + { + ReadResourceResult runtime = await gateway.ReadResourceAsync(McpResourceUris.ToGateway( + McpResourceUris.InstancePrefix + "runtime", host.InstanceId)); + JsonNode payload = JsonNode.Parse(Assert.IsType(Assert.Single(runtime.Contents)).Text)!; + Assert.Equal(Path.GetFileName(host.PluginPath), payload["runtime"]!["pluginFileName"]!.GetValue()); + Assert.True(payload["runtime"]!["epoch"]!.GetValue() > 0); + } + await VerifyInstanceCompletionsAsync(gateway, sandbox.HostA.InstanceId, sandbox.HostB.InstanceId); + sandbox.Record("packaged_resources_prompts_completions", new + { + client = "ModelContextProtocol .NET test client", + protocol = gateway.NegotiatedProtocolVersion, + resources = resources.Resources.Count, + templates = templates.Count, + prompts = prompts.Count, + limitation = "This does not qualify an interactive client application's configuration or UI." + }); + } + + internal static async Task VerifyInstanceCompletionsAsync(LiveMcpClient gateway, params string[] expected) + { + CompleteResult completion = await gateway.CompleteInstancesAsync(); + Assert.Equal(expected.Order(StringComparer.Ordinal), completion.Completion.Values.Order(StringComparer.Ordinal)); + Assert.False(completion.Completion.HasMore == true); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs index 2a39bf2..624d335 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs @@ -4,6 +4,61 @@ namespace CheatEngine.Mcp.Tests.LiveQualification; internal sealed record LiveSandboxOptions(bool CompilerQualification, bool EnableTargetCodeExecution) { internal static LiveSandboxOptions Smoke { get; } = new(false, false); + + internal LiveCompilerVariant Variant { get; init; } = LiveCompilerVariant.Normal; + internal CompilerTempTopology TempTopology { get; init; } = CompilerTempTopology.Isolated; + internal bool EnableManagedInjection + { + get; init; + } + internal bool LifecycleQualification + { + get; init; + } + internal bool PerformanceQualification + { + get; init; + } + internal bool SoakQualification + { + get; init; + } + internal bool UseSoakLifetime + { + get; init; + } + internal string? DistributionDirectoryOverride + { + get; init; + } + + internal void ValidateFor(LiveQualificationScenario scenario) + { + bool compiler = scenario is LiveQualificationScenario.Compiler or LiveQualificationScenario.CompilerExtended + or LiveQualificationScenario.CompilerInjection; + bool injection = scenario == LiveQualificationScenario.CompilerInjection; + bool extended = scenario == LiveQualificationScenario.CompilerExtended; + bool valid = Enum.IsDefined(scenario) && Enum.IsDefined(Variant) && Enum.IsDefined(TempTopology) + && CompilerQualification == compiler + && EnableManagedInjection == injection + && (!EnableTargetCodeExecution || compiler) + && (!injection || EnableTargetCodeExecution) + && LifecycleQualification == (scenario == LiveQualificationScenario.Lifecycle) + && PerformanceQualification == (scenario == LiveQualificationScenario.Performance) + && SoakQualification == (scenario == LiveQualificationScenario.Soak) + && UseSoakLifetime == SoakQualification + && (extended || injection || (Variant == LiveCompilerVariant.Normal && TempTopology == CompilerTempTopology.Isolated)) + && (!injection || (Variant == LiveCompilerVariant.HeldExportAfterB && TempTopology == CompilerTempTopology.Shared)) + && (Variant != LiveCompilerVariant.HeldExportAfterB || TempTopology == CompilerTempTopology.Shared) + && (Variant != LiveCompilerVariant.PrivateCompilerAbsent || TempTopology == CompilerTempTopology.Isolated) + && (!extended || EnableTargetCodeExecution) + && (DistributionDirectoryOverride is null || (scenario == LiveQualificationScenario.Performance + && Path.IsPathFullyQualified(DistributionDirectoryOverride))); + if (!valid) + { + throw new InvalidOperationException("Live sandbox options do not match the explicitly admitted scenario."); + } + } } /// Per-host compiler files, all rooted below one live run. diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptionsTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptionsTests.cs new file mode 100644 index 0000000..fda2b94 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptionsTests.cs @@ -0,0 +1,140 @@ +namespace CheatEngine.Mcp.Tests.LiveQualification; + +public sealed class LiveSandboxOptionsTests +{ + [Fact] + public void ValidateFor_ExactScenarioOptions_Accept() + { + LiveSandboxOptions.Smoke.ValidateFor(LiveQualificationScenario.Smoke); + new LiveSandboxOptions(true, false).ValidateFor(LiveQualificationScenario.Compiler); + new LiveSandboxOptions(true, true).ValidateFor(LiveQualificationScenario.Compiler); + new LiveSandboxOptions(true, true) { Variant = LiveCompilerVariant.PrivateCompilerAbsent } + .ValidateFor(LiveQualificationScenario.CompilerExtended); + new LiveSandboxOptions(true, true) + { + EnableManagedInjection = true, + Variant = LiveCompilerVariant.HeldExportAfterB, + TempTopology = CompilerTempTopology.Shared + } + .ValidateFor(LiveQualificationScenario.CompilerInjection); + (LiveSandboxOptions.Smoke with + { + LifecycleQualification = true + }).ValidateFor(LiveQualificationScenario.Lifecycle); + (LiveSandboxOptions.Smoke with + { + PerformanceQualification = true + }).ValidateFor(LiveQualificationScenario.Performance); + (LiveSandboxOptions.Smoke with + { + SoakQualification = true, + UseSoakLifetime = true + }).ValidateFor(LiveQualificationScenario.Soak); + } + + [Fact] + public void ValidateFor_SmokeRejectsEveryExtraCapabilityAndLifetime() + { + LiveSandboxOptions[] invalid = + [ + new(true, false), new(false, true), + LiveSandboxOptions.Smoke with { Variant = LiveCompilerVariant.PrivateCompilerAbsent }, + LiveSandboxOptions.Smoke with { TempTopology = CompilerTempTopology.Shared }, + LiveSandboxOptions.Smoke with { EnableManagedInjection = true }, + LiveSandboxOptions.Smoke with { LifecycleQualification = true }, + LiveSandboxOptions.Smoke with { PerformanceQualification = true }, + LiveSandboxOptions.Smoke with { SoakQualification = true }, + LiveSandboxOptions.Smoke with { UseSoakLifetime = true } + ]; + foreach (LiveSandboxOptions options in invalid) + { + Assert.Throws(() => options.ValidateFor(LiveQualificationScenario.Smoke)); + } + } + + [Fact] + public void ValidateFor_MissingScenarioFlagsAndUnknownValues_Refuse() + { + foreach (LiveQualificationScenario scenario in Enum.GetValues() + .Where(value => value != LiveQualificationScenario.Smoke)) + { + Assert.Throws(() => LiveSandboxOptions.Smoke.ValidateFor(scenario)); + } + Assert.Throws(() => LiveSandboxOptions.Smoke.ValidateFor((LiveQualificationScenario) 999)); + Assert.Throws(() => new LiveSandboxOptions(true, true) { Variant = (LiveCompilerVariant) 999 } + .ValidateFor(LiveQualificationScenario.CompilerExtended)); + Assert.Throws(() => new LiveSandboxOptions(true, true) { TempTopology = (CompilerTempTopology) 999 } + .ValidateFor(LiveQualificationScenario.CompilerExtended)); + } + + [Fact] + public void ValidateFor_SoakRequiresItsLifetimeAndProhibitsExecution() + { + LiveSandboxOptions soak = LiveSandboxOptions.Smoke with + { + SoakQualification = true, + UseSoakLifetime = true + }; + Assert.Throws(() => (soak with { UseSoakLifetime = false }).ValidateFor(LiveQualificationScenario.Soak)); + Assert.Throws(() => (soak with { EnableTargetCodeExecution = true }).ValidateFor(LiveQualificationScenario.Soak)); + Assert.Throws(() => (soak with { LifecycleQualification = true }).ValidateFor(LiveQualificationScenario.Soak)); + } + + [Fact] + public void ValidateFor_ExtendedCompilerRequiresTheVariantTopology() + { + LiveSandboxOptions held = new(true, true) + { + Variant = LiveCompilerVariant.HeldExportAfterB, + TempTopology = CompilerTempTopology.Shared + }; + held.ValidateFor(LiveQualificationScenario.CompilerExtended); + Assert.Throws(() => (held with + { + TempTopology = CompilerTempTopology.Isolated + }) + .ValidateFor(LiveQualificationScenario.CompilerExtended)); + Assert.Throws(() => (held with + { + Variant = LiveCompilerVariant.PrivateCompilerAbsent + }) + .ValidateFor(LiveQualificationScenario.CompilerExtended)); + } + + [Fact] + public void ValidateFor_OnlyPerformanceAcceptsAnAbsoluteDistributionOverride() + { + string distribution = Path.Combine(Path.GetTempPath(), "reviewed-qualification-package"); + LiveSandboxOptions performance = LiveSandboxOptions.Smoke with + { + PerformanceQualification = true, + DistributionDirectoryOverride = distribution + }; + performance.ValidateFor(LiveQualificationScenario.Performance); + Assert.Throws(() => (performance with + { + DistributionDirectoryOverride = "relative" + }) + .ValidateFor(LiveQualificationScenario.Performance)); + Assert.Throws(() => new LiveSandboxOptions(true, true) + { + DistributionDirectoryOverride = distribution + }.ValidateFor(LiveQualificationScenario.Compiler)); + } + + [Fact] + public void ValidateFor_InjectionRequiresBothExecutionFlagsAndReviewedHeldTopology() + { + LiveSandboxOptions injection = new(true, true) + { + EnableManagedInjection = true, + Variant = LiveCompilerVariant.HeldExportAfterB, + TempTopology = CompilerTempTopology.Shared + }; + Assert.Throws(() => (injection with { EnableTargetCodeExecution = false }).ValidateFor(LiveQualificationScenario.CompilerInjection)); + Assert.Throws(() => (injection with { EnableManagedInjection = false }).ValidateFor(LiveQualificationScenario.CompilerInjection)); + Assert.Throws(() => (injection with { TempTopology = CompilerTempTopology.Isolated }).ValidateFor(LiveQualificationScenario.CompilerInjection)); + Assert.Throws(() => (injection with { Variant = LiveCompilerVariant.Normal }).ValidateFor(LiveQualificationScenario.CompilerInjection)); + Assert.Throws(() => injection.ValidateFor(LiveQualificationScenario.Compiler)); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Compiler.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Compiler.cs new file mode 100644 index 0000000..313676e --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Compiler.cs @@ -0,0 +1,167 @@ +using System.Diagnostics; +using System.Text; +using System.Text.Json.Nodes; + +using CheatEngine.Mcp.Tests.LiveQualification.Infrastructure; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Compiler-only lifecycle hooks; main session integration supplies the private-copy and driver call sites. +internal sealed partial class LiveSandboxSession +{ + private static readonly TimeSpan CompilerHoldTimeout = TimeSpan.FromSeconds(10); + private readonly List _privateCompilerQuarantines = []; + + internal static void ValidateCompilerVariant(LiveCompilerVariant variant, CompilerTempTopology topology, + bool enableManagedInjection) + { + if (variant == LiveCompilerVariant.HeldExportAfterB && topology != CompilerTempTopology.Shared) + { + throw new InvalidOperationException("The held export variant requires the reviewed shared temporary topology."); + } + if (enableManagedInjection && variant != LiveCompilerVariant.HeldExportAfterB) + { + throw new InvalidOperationException("Managed injection requires the reviewed held-export variant."); + } + } + + /// Arms exactly one successful original compileCS result to publish an owned hold receipt. + internal Task ArmCompilerHoldAsync(string hostName) => CompilerProbeAsync(hostName, "armHold"); + + /// Waits for the fixed bridge receipt without issuing a Lua request while CE is deliberately held. + internal async Task WaitForCompilerHoldAsync(string hostName, CancellationToken cancellationToken = default) + { + OwnedHost host = GetRunningHost(hostName); + string receipt = CompilerHoldReadyPath(hostName); + RequireInsideRun(receipt); + Stopwatch elapsed = Stopwatch.StartNew(); + while (!File.Exists(receipt)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (host.Process is null || host.Process.HasExited || elapsed.Elapsed > CompilerHoldTimeout) + { + throw new TimeoutException("The fixed compiler hold receipt was not published before its deadline."); + } + await Task.Delay(50, cancellationToken); + } + + RequireNoReparseAncestors(receipt); + using FileStream stream = new(receipt, FileMode.Open, FileAccess.Read, FileShare.Read); + RequireNoReparseAncestors(receipt); + if (stream.Length is <= 0 or > 4096) + { + throw new InvalidOperationException("The fixed compiler hold receipt has an invalid bounded length."); + } + using StreamReader reader = new(stream, new UTF8Encoding(false, true), false); + char[] buffer = new char[4097]; + int read = await reader.ReadAsync(buffer.AsMemory(), cancellationToken); + if (read == buffer.Length || await reader.ReadAsync(buffer.AsMemory(0, 1), cancellationToken) != 0) + { + throw new InvalidOperationException("The fixed compiler hold receipt exceeds its bounded text length."); + } + string rawPath = new string(buffer, 0, read); + if (rawPath.Length == 0 || rawPath.IndexOfAny(['\r', '\n']) >= 0) + { + throw new InvalidOperationException("The fixed compiler hold receipt did not contain one exact assembly path."); + } + return rawPath; + } + + /// Publishes the one owned release file directly because the held CE timer cannot service another request. + internal async Task ReleaseCompilerHoldAsync(string hostName, CancellationToken cancellationToken = default) + { + _ = GetRunningHost(hostName); + string release = CompilerHoldReleasePath(hostName); + RequireInsideRun(release); + RequireNoReparseAncestors(release); + if (File.Exists(release)) + { + throw new InvalidOperationException("The fixed compiler hold was already released."); + } + + string partial = release + ".partial"; + RequireInsideRun(partial); + await File.WriteAllTextAsync(partial, "release", cancellationToken); + File.Move(partial, release, false); + } + + internal string CompilerHoldReadyPath(string hostName) => + Path.Combine(CompilerRoots(hostName).Temp, $"hold-ready-{hostName}.txt"); + + internal string CompilerHoldReleasePath(string hostName) => + Path.Combine(CompilerRoots(hostName).Temp, $"hold-release-{hostName}.txt"); + + /// Removes only the reviewed compiler DLL from private host A before CE starts. + internal void ApplyPrivateCompilerAbsentVariant(string hostName, string installation) + { + if (_options.Variant != LiveCompilerVariant.PrivateCompilerAbsent || hostName != "A") + { + return; + } + + string installed = Path.Combine(_source ?? throw new InvalidOperationException("The CE source was not prepared."), "CSCompiler.dll"); + string privateCopy = Path.Combine(installation, "CSCompiler.dll"); + string quarantineRoot = Path.Combine(_layout.RunDirectory, "compiler", "quarantine", hostName); + string quarantine = Path.Combine(quarantineRoot, "CSCompiler.dll"); + RequireInsideRun(privateCopy); + RequireInsideRun(quarantine); + RequireNoReparseAncestors(privateCopy); + RequireNoReparseAncestors(quarantineRoot); + if (!File.Exists(installed) || !File.Exists(privateCopy)) + { + throw new FileNotFoundException("The private compiler-absence variant requires the installed CSCompiler.dll source.", installed); + } + string installedHash = CheatEngineInstallation.Sha256(installed); + string privateHash = CheatEngineInstallation.Sha256(privateCopy); + if (!string.Equals(installedHash, privateHash, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The private CSCompiler.dll does not match the installed source identity."); + } + + Directory.CreateDirectory(quarantineRoot); + if (File.Exists(quarantine)) + { + throw new IOException("The run-owned CSCompiler.dll quarantine path is unexpectedly occupied."); + } + File.Move(privateCopy, quarantine, false); + _privateCompilerQuarantines.Add(new PrivateCompilerQuarantine(privateCopy, quarantine, privateHash)); + Record("compiler_private_component_quarantined", new + { + hostName, + sha256 = privateHash + }); + } + + /// Restores a private compiler component only when its quarantine identity still matches. + internal void RestorePrivateCompilerVariants() + { + foreach (PrivateCompilerQuarantine item in _privateCompilerQuarantines) + { + RequireInsideRun(item.PrivatePath); + RequireInsideRun(item.QuarantinePath); + RequireNoReparseAncestors(item.QuarantinePath); + if (!File.Exists(item.QuarantinePath) || File.Exists(item.PrivatePath) || + !string.Equals(CheatEngineInstallation.Sha256(item.QuarantinePath), item.Sha256, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The private compiler quarantine identity changed; it is retained for recovery."); + } + File.Move(item.QuarantinePath, item.PrivatePath, false); + } + if (_privateCompilerQuarantines.Count != 0) + { + Record("compiler_private_component_restored", true); + _privateCompilerQuarantines.Clear(); + } + } + + private OwnedHost GetRunningHost(string hostName) + { + if (!_hosts.TryGetValue(hostName, out OwnedHost? host) || host.Process is null || host.Stopped) + { + throw new InvalidOperationException($"Owned host '{hostName}' is not running."); + } + return host; + } + + private sealed record PrivateCompilerQuarantine(string PrivatePath, string QuarantinePath, string Sha256); +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Lifecycle.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Lifecycle.cs new file mode 100644 index 0000000..1ac421e --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Lifecycle.cs @@ -0,0 +1,277 @@ +using System.Diagnostics; +using System.Runtime.Versioning; + +using CheatEngine.Mcp.Tests.LiveQualification.Infrastructure; + +using Microsoft.Win32; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +[SupportedOSPlatform("windows")] +internal sealed partial class LiveSandboxSession +{ + private long _pluginControlNonce; + private readonly Dictionary _pendingPluginIdentities = new(StringComparer.Ordinal); + + /// Issues one fixed Settings/Plugins enable or disable transition to an already-loaded owned plugin. + internal async Task ReloadPluginAsync(string hostName, bool enabled, + CancellationToken cancellationToken = default) + { + if (!_hosts.TryGetValue(hostName, out OwnedHost? host) || host.Process is null || host.Stopped) + { + throw new InvalidOperationException($"Owned host '{hostName}' is not running."); + } + PendingPluginIdentity? previous; + if (enabled) + { + previous = _pendingPluginIdentities.GetValueOrDefault(hostName); + } + else + { + InstanceDescriptor descriptor = new InstanceRegistry(InstanceDirectory).ReadActive(cancellationToken).Single(candidate => + candidate.ProcessId == host.Public.ProcessId && candidate.InstanceId == host.Public.InstanceId); + previous = new PendingPluginIdentity(descriptor.InstanceId, descriptor.Endpoint, descriptor.AccessToken); + } + if (previous is null) + { + throw new InvalidOperationException(enabled + ? "Plugin enable requires a successful prior disable transition." + : "The owned plugin discovery identity is unavailable before disable."); + } + + NeutralizeOwnedPluginRegistry(); + long nonce = Interlocked.Increment(ref _pluginControlNonce); + string request = Path.Combine(_layout.RunDirectory, $"plugin-request-{hostName}.txt"); + string response = Path.Combine(_layout.RunDirectory, $"plugin-response-{hostName}.txt"); + RequireInsideRun(request); + RequireInsideRun(response); + RequireNoReparseAncestors(request); + RequireNoReparseAncestors(response); + if (File.Exists(response)) + { + File.Delete(response); + } + string temporary = request + ".tmp"; + RequireNoReparseAncestors(temporary); + await File.WriteAllTextAsync(temporary, $"{nonce}\n{(enabled ? "enable" : "disable")}\n", cancellationToken); + File.Move(temporary, request, false); + await WaitUntilAsync(() => File.Exists(response), host.Process, TimeSpan.FromSeconds(15)); + RequireNoReparseAncestors(response); + FileInfo responseFacts = new(response); + if (responseFacts.Length is <= 0 or > 4096) + { + throw new InvalidOperationException("The fixed CE Plugin Manager bridge response has an invalid bounded length."); + } + using FileStream responseStream = new(response, FileMode.Open, FileAccess.Read, FileShare.Read); + if (responseStream.Length is <= 0 or > 4096) + { + throw new InvalidDataException("Plugin response exceeds its byte bound."); + } + + using StreamReader responseReader = new(responseStream, new System.Text.UTF8Encoding(false, true), false); + string[] fields = (await responseReader.ReadToEndAsync(cancellationToken)).Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); + if (fields.Length != 3 || fields[0] != "ok" || !long.TryParse(fields[1], out long received) + || received != nonce || fields[2] != (enabled ? "enable" : "disable")) + { + throw new InvalidOperationException("The fixed CE Plugin Manager bridge returned an invalid response."); + } + if (host.Process.HasExited) + { + throw new InvalidOperationException("The owned CE process exited during the plugin lifecycle transition."); + } + if (enabled) + { + InstanceDescriptor descriptor = await WaitForInstanceAsync(host, TimeSpan.FromSeconds(45)); + if (descriptor.InstanceId == previous.InstanceId || descriptor.Endpoint == previous.Endpoint + || descriptor.AccessToken == previous.AccessToken) + { + throw new InvalidOperationException("Plugin re-enable did not publish a fresh discovery identity."); + } + host.Public = host.Public with + { + InstanceId = descriptor.InstanceId, + Endpoint = descriptor.Endpoint + }; + _pendingPluginIdentities.Remove(hostName); + } + else + { + _pendingPluginIdentities[hostName] = previous; + } + + LivePluginCycle cycle = new(host.Public.ProcessId, nonce, enabled, DateTimeOffset.UtcNow); + Record($"plugin_{hostName}_{(enabled ? "enable" : "disable")}", cycle); + return cycle; + } + /// Restarts one private CE installation and its owned target without disturbing the second host. + public async Task RestartHostAsync(string name) + { + if (!_hosts.TryGetValue(name, out OwnedHost? previous) || previous.Stopped) + { + throw new InvalidOperationException($"Owned host '{name}' is not running."); + } + + string repository = _repository ?? throw new InvalidOperationException("The sandbox repository was not prepared."); + LiveSandboxHost before = previous.Public; + await StopHostAsync(name); + if (!previous.Stopped) + { + throw new InvalidOperationException($"Owned host '{name}' did not stop before restart."); + } + + PrepareRestartArtifacts(name, previous); + NeutralizeOwnedPluginRegistry(); + _hosts.Remove(name); + try + { + await StartHostAsync(repository, name); + LiveSandboxHost after = GetHost(name); + Record($"host_{name}_restart", new + { + before = new + { + before.ProcessId, + before.TargetProcessId, + before.InstanceId, + before.Endpoint + }, + after = new + { + after.ProcessId, + after.TargetProcessId, + after.InstanceId, + after.Endpoint + } + }); + } + catch (Exception exception) + { + Record($"host_{name}_restart_failure", exception.ToString()); + throw; + } + } + + /// Captures an owned CE process measurement without treating an unavailable metric as zero. + public LiveProcessMetrics CaptureMetrics(string name) + { + if (!_hosts.TryGetValue(name, out OwnedHost? host) || host.Process is null || host.Stopped) + { + throw new InvalidOperationException($"Owned host '{name}' is not running."); + } + + DateTimeOffset capturedAt = DateTimeOffset.UtcNow; + try + { + host.Process.Refresh(); + if (host.Process.HasExited) + { + throw new InvalidOperationException("The owned CE process exited before metrics were captured."); + } + + return new LiveProcessMetrics(capturedAt, host.Process.Id, host.Process.HandleCount, + host.Process.PrivateMemorySize64, host.Process.WorkingSet64, null); + } + catch (Exception exception) when (exception is InvalidOperationException or System.ComponentModel.Win32Exception) + { + return new LiveProcessMetrics(capturedAt, host.Public.ProcessId, 0, 0, 0, exception.Message); + } + } + + /// Removes only a CE plugin list proven to contain complete pairs for this run's private bundles. + internal void NeutralizeOwnedPluginRegistry() + { + if (_userState is null || _stateGuard is null) + { + throw new InvalidOperationException("The guarded CE user-state scope was not established."); + } + + const string pluginsSubKey = CheatEngineUserStateLocations.CheatEngineRegistrySubKey + @"\Plugins64"; + using RegistryKey? plugins = Registry.CurrentUser.OpenSubKey(pluginsSubKey, true); + if (plugins is null) + { + return; + } + if (plugins.GetSubKeyNames().Length != 0) + { + throw new InvalidOperationException("The CE Plugins64 registry key has unexpected subkeys; it is retained for recovery."); + } + + string[] names = plugins.GetValueNames(); + Dictionary pairs = new(StringComparer.Ordinal); + foreach (string valueName in names) + { + if (valueName.Length != 10 || valueName[8] != ' ' || valueName[9] is not ('A' or 'B') + || !valueName[..8].All(character => character is (>= '0' and <= '9') or (>= 'A' and <= 'F'))) + { + throw new InvalidOperationException("The CE Plugins64 registry key has an unknown value name; it is retained for recovery."); + } + + string pair = valueName[..8]; + (string? Path, object? Enabled) existing = pairs.GetValueOrDefault(pair); + RegistryValueKind kind = plugins.GetValueKind(valueName); + if (valueName[9] == 'A') + { + if (kind != RegistryValueKind.String || existing.Path is not null) + { + throw new InvalidOperationException("The CE Plugins64 registry key has an invalid plugin path pair; it is retained for recovery."); + } + pairs[pair] = ((string?) plugins.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames), existing.Enabled); + } + else + { + if (kind != RegistryValueKind.DWord || existing.Enabled is not null) + { + throw new InvalidOperationException("The CE Plugins64 registry key has an invalid enabled pair; it is retained for recovery."); + } + pairs[pair] = (existing.Path, plugins.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames)); + } + } + + HashSet expectedPaths = _hosts.Values.Select(host => Path.GetFullPath(host.Public.PluginPath)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + HashSet observedPaths = new(StringComparer.OrdinalIgnoreCase); + foreach ((string? path, object? enabled) in pairs.Values) + { + if (string.IsNullOrWhiteSpace(path) || enabled is not int enabledValue || enabledValue is not (0 or 1) + || !Path.IsPathFullyQualified(path) || !expectedPaths.Contains(Path.GetFullPath(path)) + || !observedPaths.Add(Path.GetFullPath(path))) + { + throw new InvalidOperationException("The CE Plugins64 registry key names a plugin outside this run; it is retained for recovery."); + } + } + + Registry.CurrentUser.DeleteSubKeyTree(pluginsSubKey, false); + Record("plugins64_neutralized", new + { + count = pairs.Count + }); + } + + private void PrepareRestartArtifacts(string name, OwnedHost previous) + { + string pluginRoot = Path.Combine(_layout.PluginsDirectory, name); + string currentManifest = Path.Combine(_layout.RunDirectory, $"target-{name}.json"); + RequireInsideRun(pluginRoot); + RequireInsideRun(currentManifest); + RequireInsideRun(previous.TargetManifest); + RequireNoReparseAncestors(pluginRoot); + foreach (string path in new[] { previous.StopPath, currentManifest, previous.TargetManifest, + previous.TargetManifest + ".stop", currentManifest + ".stop" }) + { + RequireInsideRun(path); + if (File.Exists(path)) + { + RequireNoReparseAncestors(path); + File.Delete(path); + } + } + if (Directory.Exists(pluginRoot)) + { + Directory.Delete(pluginRoot, true); + } + } +} + +internal sealed record LivePluginCycle(int ProcessId, long Nonce, bool Enabled, DateTimeOffset CompletedAt); + +internal sealed record PendingPluginIdentity(string InstanceId, string Endpoint, string AccessToken); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs index 1d486ff..5019800 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs @@ -12,7 +12,7 @@ namespace CheatEngine.Mcp.Tests.LiveQualification; /// Owns two isolated CE hosts and targets beneath one guarded live-test run. [SupportedOSPlatform("windows")] -internal sealed class LiveSandboxSession : IAsyncDisposable +internal sealed partial class LiveSandboxSession : IAsyncDisposable { private const string PluginFileName = "CheatEngine.Mcp.dll"; @@ -31,6 +31,7 @@ internal sealed class LiveSandboxSession : IAsyncDisposable private bool _passed; private CheatEngineProfile? _profile; private string? _runtimeHash; + private string? _repository; private string? _targetHostPath; private string? _targetPath; private string _targetArchitecture = "x64"; @@ -142,6 +143,11 @@ public async ValueTask DisposeAsync() }, failures); } + if (_options.CompilerQualification && _hosts.Values.All(host => host.Stopped)) + { + TryCleanup(RestorePrivateCompilerVariants, failures); + } + if (_options.CompilerQualification && failures.Count == 0 && _hosts.Values.All(host => host.Stopped)) { TryCleanup(CleanupCompilerRoots, failures); @@ -172,10 +178,7 @@ public static async Task StartAsync(LiveQualificationInputs LiveSandboxOptions? options = null) { LiveSandboxOptions selected = options ?? LiveSandboxOptions.Smoke; - if (selected.CompilerQualification != (inputs.Scenario == LiveQualificationScenario.Compiler)) - { - throw new InvalidOperationException("Compiler sandbox options must exactly match the authorized live scenario."); - } + selected.ValidateFor(inputs.Scenario); if (selected.CompilerQualification) { LiveCompilerPayload.RequireReviewedHashes(); @@ -265,7 +268,7 @@ public async Task StopHostAsync(string name) /// Runs one fixed compiler availability or compile probe through the test-owned private autorun bridge. public async Task CompilerProbeAsync(string hostName, string command) { - if (!_options.CompilerQualification || command is not ("status" or "compile")) + if (!_options.CompilerQualification || command is not ("status" or "compile" or "armHold")) { throw new ArgumentOutOfRangeException(nameof(command)); } @@ -309,7 +312,7 @@ public async Task RestartTargetAsync(string name) int incarnation = host.TargetIncarnation + 1; string manifest = Path.Combine(_layout.RunDirectory, $"target-{name}-{incarnation}.json"); RequireInsideRun(manifest); - Process target = StartTarget(manifest, name); + Process target = StartTarget(manifest, name, _options.UseSoakLifetime); host.Target = target; try { @@ -359,6 +362,7 @@ public async Task RestartTargetAsync(string name) private async Task StartCoreAsync(LiveQualificationInputs inputs) { string repository = inputs.RepositoryRoot; + _repository = repository; _targetArchitecture = inputs.TargetArchitecture; _targetPath = TargetExecutablePath(repository, _targetArchitecture); if (!File.Exists(_targetPath)) @@ -453,7 +457,8 @@ private void PrepareDistribution(string repository) #else const string configuration = "release"; #endif - string distribution = DistributionDirectory(repository, configuration); + string distribution = _options.DistributionDirectoryOverride ?? DistributionDirectory(repository, configuration); + ValidateDistribution(distribution); string gatewaySource = Path.Combine(distribution, "CheatEngine.Mcp.Gateway.exe"); if (!File.Exists(gatewaySource)) { @@ -475,6 +480,7 @@ private void PrepareHostInstallation(string name) { string installation = HostInstallationDirectory(name); CheatEngineInstallation.CopyTo(_source!, installation, _profile!, PortableExecutableInspector.Instance); + ApplyPrivateCompilerAbsentVariant(name, installation); string autorun = Path.Combine(installation, "autorun"); string disabledAutorun = Path.Combine(installation, "autorun.disabled"); RequireInsideRun(autorun); @@ -506,7 +512,8 @@ private async Task StartHostAsync(string repository, string name) #else const string configuration = "release"; #endif - string distribution = DistributionDirectory(repository, configuration); + string distribution = _options.DistributionDirectoryOverride ?? DistributionDirectory(repository, configuration); + ValidateDistribution(distribution); string pluginSource = distribution; if (!File.Exists(Path.Combine(pluginSource, PluginFileName))) { @@ -518,20 +525,22 @@ private async Task StartHostAsync(string repository, string name) RequireInsideRun(pluginDirectory); Directory.CreateDirectory(pluginDirectory); File.Copy(Path.Combine(pluginSource, PluginFileName), Path.Combine(pluginDirectory, PluginFileName)); - if (_options.CompilerQualification) + bool capabilityConfiguration = _options.CompilerQualification || _options.LifecycleQualification + || _options.PerformanceQualification || _options.SoakQualification; + if (capabilityConfiguration) { - LiveCompilerRoots roots = CompilerRoots(name); + string[] allowedRoots = _options.CompilerQualification ? [CompilerRoots(name).Output] : []; File.WriteAllText(Path.Combine(pluginDirectory, "appsettings.json"), JsonSerializer.Serialize(new { Mcp = new { EnableUnsafeLua = false, EnableAutoAssembler = false, - EnableTargetCodeExecution = _options.EnableTargetCodeExecution, + EnableTargetCodeExecution = _options.CompilerQualification && _options.EnableTargetCodeExecution, EnableKernelAccess = false, Files = new { - AllowedRoots = new[] { roots.Output } + AllowedRoots = allowedRoots } }, CheatEngineClient = new @@ -552,7 +561,7 @@ private async Task StartHostAsync(string repository, string name) OwnedHost? owned = null; try { - target = StartTarget(targetManifest, name); + target = StartTarget(targetManifest, name, _options.UseSoakLifetime); owned = new OwnedHost(null, target, stopPath, targetManifest, new LiveSandboxHost(name, 0, target.Id, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, pluginPath, string.Empty, string.Empty)); @@ -654,7 +663,7 @@ private static string TargetExecutablePath(string repository, string architectur private static string X86DotnetHostPath() => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "dotnet", "dotnet.exe"); - private Process StartTarget(string manifest, string name) + private Process StartTarget(string manifest, string name, bool soakLifetime = false) { string target = _targetPath ?? throw new InvalidOperationException("Live target path was not prepared."); ProcessStartInfo start; @@ -669,6 +678,10 @@ private Process StartTarget(string manifest, string name) start = CreateStartInfo(target); } + if (soakLifetime) + { + start.ArgumentList.Add("--soak"); + } start.ArgumentList.Add(manifest); return Process.Start(start) ?? throw new InvalidOperationException($"Disposable target {name} did not start."); } @@ -702,10 +715,48 @@ internal static string DistributionDirectory(string repository, string configura return custom ?? Path.Combine(repository, "artifacts", "dist", configuration); } + private void ValidateDistribution(string distribution) + { + if (!Path.IsPathFullyQualified(distribution) || !Directory.Exists(distribution)) + { + throw new DirectoryNotFoundException("The selected live qualification distribution directory is unavailable."); + } + + LiveCompilerCandidate.RequireNoReparseAncestors(distribution); + string[] expected = [PluginFileName, "CheatEngine.Mcp.Gateway.exe", "LICENSE", "README.md", "THIRD-PARTY-NOTICES.md"]; + string[] actual = Directory.EnumerateFiles(distribution, "*", SearchOption.TopDirectoryOnly) + .Select(Path.GetFileName).Where(name => name is not null).Select(name => name!).Order(StringComparer.Ordinal).ToArray(); + if (!actual.SequenceEqual(expected.Order(StringComparer.Ordinal), StringComparer.Ordinal) + || Directory.EnumerateDirectories(distribution, "*", SearchOption.TopDirectoryOnly).Any() + || actual.Any(name => new FileInfo(Path.Combine(distribution, name)).Length <= 0)) + { + throw new InvalidOperationException("The selected distribution must contain exactly the five reviewed flat release files."); + } + + string plugin = Path.Combine(distribution, PluginFileName); + string gateway = Path.Combine(distribution, "CheatEngine.Mcp.Gateway.exe"); + string? pluginVersion = FileVersionInfo.GetVersionInfo(plugin).ProductVersion; + string? gatewayVersion = FileVersionInfo.GetVersionInfo(gateway).ProductVersion; + if (string.IsNullOrWhiteSpace(pluginVersion) || !string.Equals(pluginVersion, gatewayVersion, StringComparison.Ordinal)) + { + throw new InvalidOperationException("The selected plugin and gateway do not have the same source identity."); + } + + Record("selected_distribution", new + { + directory = distribution, + productVersion = pluginVersion, + pluginSha256 = CheatEngineInstallation.Sha256(plugin), + gatewaySha256 = CheatEngineInstallation.Sha256(gateway) + }); + } + private void VerifyDistributionStaging(string name, string pluginSource, string pluginDirectory) { string[] pluginFiles = RelativeFiles(pluginDirectory); - string[] expectedPluginFiles = _options.CompilerQualification + bool capabilityConfiguration = _options.CompilerQualification || _options.LifecycleQualification + || _options.PerformanceQualification || _options.SoakQualification; + string[] expectedPluginFiles = capabilityConfiguration ? [PluginFileName, "appsettings.json"] : [PluginFileName]; if (!pluginFiles.SequenceEqual(expectedPluginFiles, StringComparer.Ordinal) @@ -891,9 +942,13 @@ internal async Task ScanUiAsync(string name, string action) private string BuildDriver(string stopPath, string pluginPath, string name) { + int maximumLifetimeMilliseconds = _options.UseSoakLifetime ? 8_100_000 : 300_000; + string pluginBridge = LivePluginBridge.Render(Path.Combine(_layout.RunDirectory, $"plugin-request-{name}.txt"), + Path.Combine(_layout.RunDirectory, $"plugin-response-{name}.txt"), LuaString); string compilerProbe = _options.CompilerQualification ? LiveCompilerBridge.Render(Path.Combine(_layout.RunDirectory, $"compiler-request-{name}.txt"), - Path.Combine(_layout.RunDirectory, $"compiler-response-{name}.txt"), LiveCompilerPayload.ValidSource, LuaString) + Path.Combine(_layout.RunDirectory, $"compiler-response-{name}.txt"), CompilerHoldReadyPath(name), + CompilerHoldReleasePath(name), LiveCompilerPayload.ValidSource, LuaString) : "local function compilerProbe() end"; return $$""" local output = {{LuaString(Path.Combine(_layout.RunDirectory, $"driver-{name}.log"))}} @@ -903,6 +958,8 @@ local function log(value) log('driver-loaded') local started = getTickCount() local loaded = false + local pluginEnabled = false + {{pluginBridge}} local function scanProbe() local requestPath={{LuaString(Path.Combine(_layout.RunDirectory, $"scan-request-{name}.txt"))}} local request=io.open(requestPath,'r') @@ -943,19 +1000,21 @@ local function scanProbe() timer.OnTimer = function() local tickOk, tickError = pcall(function() local stop = io.open({{LuaString(stopPath)}}, 'r') - if stop or (getTickCount()-started) % 4294967296 > 300000 then + if stop or (getTickCount()-started) % 4294967296 > {{maximumLifetimeMilliseconds}} then if stop then stop:close() end log('closing'); timer.Enabled=false pcall(function() getAddressList().clear() end) closeCE(); return end - if loaded then scanProbe(); compilerProbe(); return end + if loaded then pluginControl(); scanProbe(); compilerProbe(); return end if not getMainForm() then return end loaded = true hideAllCEWindows() log('loading-plugin') local ok, value = pcall(loadPlugin, {{LuaString(pluginPath)}}) log('loadPlugin ok='..tostring(ok)..' result='..tostring(value)) + assert(ok and value, 'owned plugin did not load') + pluginEnabled=true local menu = getMainForm().Menu local status = assert(menu.findComponentByName('CheatEngineMcpStatus'), 'MCP status menu is missing') local count = 0 @@ -1120,7 +1179,10 @@ private LiveCompilerRoots CompilerRoots(string name) } string root = Path.Combine(_layout.RunDirectory, "compiler", name); - return new LiveCompilerRoots(Path.Combine(root, "temp"), Path.Combine(root, "references"), + string temp = _options.TempTopology == CompilerTempTopology.Shared + ? Path.Combine(_layout.RunDirectory, "compiler", "shared", "temp") + : Path.Combine(root, "temp"); + return new LiveCompilerRoots(temp, Path.Combine(root, "references"), Path.Combine(root, "output")); } diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSoakQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSoakQualification.cs new file mode 100644 index 0000000..7278cae --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSoakQualification.cs @@ -0,0 +1,260 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Text.Json.Nodes; + +using CheatEngine.Mcp.Core.Contract; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Fixed workload and resource bounds for the explicitly authorized two-hour live soak. +internal static class LiveSoakQualification +{ + internal static readonly TimeSpan WorkloadDuration = TimeSpan.FromHours(2); + internal static readonly TimeSpan MaximumLifetime = TimeSpan.FromMinutes(135); + internal const int RequiredShortCalls = 1_000; + internal const int RequiredBoundedJobs = 20; + internal const int RequiredPluginCycles = 30; + internal const int RequiredTargetRestarts = 10; + internal const int MaximumHandleGrowth = 25; + internal const long MaximumPrivateBytesGrowth = 64L * 1024 * 1024; + + [SupportedOSPlatform("windows")] + internal static async Task RunAsync(LiveQualificationInputs inputs) + { + Assert.Equal(LiveQualificationScenario.Soak, inputs.Scenario); + await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs, + new LiveSandboxOptions(false, false) { SoakQualification = true, UseSoakLifetime = true }); + await using LiveLifecycleSession lifecycle = await LiveLifecycleSession.ConnectAsync(sandbox); + await lifecycle.AttachBothAsync(); + await lifecycle.VerifyBothAsync(); + foreach (string name in new[] { "A", "B" }) + { + LiveSandboxHost host = name == "A" ? sandbox.HostA : sandbox.HostB; + await VerifyBoundedJobAsync(lifecycle.Gateway.Bind(host.InstanceId), host); + await lifecycle.CyclePluginAsync(name); + } + await RequireEmptyAsync(sandbox, lifecycle); + TimeSpan idleInterval = TimeSpan.FromSeconds(15); + await Task.Delay(idleInterval, TestContext.Current.CancellationToken); + LiveProcessMetrics baselineA = sandbox.CaptureMetrics("A"); + LiveProcessMetrics baselineB = sandbox.CaptureMetrics("B"); + Assert.Null(baselineA.CollectionError); + Assert.Null(baselineB.CollectionError); + sandbox.Record("soak_baseline", new + { + baselineA, + baselineB, + idleSeconds = idleInterval.TotalSeconds + }); + Dictionary counts = Enum.GetValues().ToDictionary(kind => kind, _ => 0); + Stopwatch elapsed = Stopwatch.StartNew(); + try + { + foreach (LiveSoakAction action in CreateSchedule()) + { + TimeSpan remaining = action.At - elapsed.Elapsed; + if (remaining > TimeSpan.Zero) + { + await Task.Delay(remaining, TestContext.Current.CancellationToken); + } + + Assert.True(elapsed.Elapsed < MaximumLifetime - TimeSpan.FromMinutes(5), "The soak exceeded its workload deadline."); + string name = counts[action.Kind] % 2 == 0 ? "A" : "B"; + LiveSandboxHost host = name == "A" ? sandbox.HostA : sandbox.HostB; + switch (action.Kind) + { + case LiveSoakActionKind.ShortCall: + JsonNode value = (await lifecycle.Gateway.Bind(host.InstanceId).CallToolAsync(CheatEngineToolNames.MemoryRead, + new Dictionary { ["address"] = host.TargetAddress, ["valueType"] = "int32" }))!; + Assert.Equal("20260926", value["value"]!.GetValue()); + break; + case LiveSoakActionKind.BoundedJob: + await VerifyBoundedJobAsync(lifecycle.Gateway.Bind(host.InstanceId), host); + break; + case LiveSoakActionKind.PluginCycle: + await lifecycle.CyclePluginAsync(name); + break; + case LiveSoakActionKind.TargetRestart: + await sandbox.RestartTargetAsync(name); + await lifecycle.AttachBothAsync(); + await lifecycle.VerifyBothAsync(); + break; + case LiveSoakActionKind.GatewayRestart: + await lifecycle.RestartGatewayAsync(); + await lifecycle.VerifyBothAsync(); + break; + default: + throw new InvalidOperationException("Unknown fixed soak action."); + } + counts[action.Kind]++; + if (action.Kind != LiveSoakActionKind.ShortCall || counts[action.Kind] % 20 == 0) + { + sandbox.Record("soak_progress", new + { + elapsedSeconds = elapsed.Elapsed.TotalSeconds, + counts = counts.ToDictionary(pair => pair.Key.ToString(), pair => pair.Value) + }); + } + } + Assert.True(elapsed.Elapsed >= WorkloadDuration); + Assert.Equal(RequiredShortCalls, counts[LiveSoakActionKind.ShortCall]); + Assert.Equal(RequiredBoundedJobs, counts[LiveSoakActionKind.BoundedJob]); + Assert.Equal(RequiredPluginCycles, counts[LiveSoakActionKind.PluginCycle]); + Assert.Equal(RequiredTargetRestarts, counts[LiveSoakActionKind.TargetRestart]); + Assert.Equal(1, counts[LiveSoakActionKind.GatewayRestart]); + await lifecycle.VerifyBothAsync(); + await RequireEmptyAsync(sandbox, lifecycle); + await Task.Delay(idleInterval, TestContext.Current.CancellationToken); + LiveProcessMetrics finalA = sandbox.CaptureMetrics("A"); + LiveProcessMetrics finalB = sandbox.CaptureMetrics("B"); + LiveResourceDecision decisionA = EvaluateResources(baselineA, finalA); + LiveResourceDecision decisionB = EvaluateResources(baselineB, finalB); + sandbox.Record("soak_final", new + { + elapsedSeconds = elapsed.Elapsed.TotalSeconds, + counts, + finalA, + finalB, + decisionA, + decisionB + }); + Assert.True(decisionA.Passed, $"Host A exceeded resource limits: {decisionA}"); + Assert.True(decisionB.Passed, $"Host B exceeded resource limits: {decisionB}"); + sandbox.MarkPassed(); + } + catch (Exception exception) + { + sandbox.Record("soak_failure", new + { + elapsedSeconds = elapsed.Elapsed.TotalSeconds, + counts, + error = exception.ToString() + }); + throw; + } + } + + [SupportedOSPlatform("windows")] + private static async Task VerifyBoundedJobAsync(LiveMcpInstanceClient instance, LiveSandboxHost host) + { + JsonNode start = (await instance.CallToolAsync(CheatEngineToolNames.CodeStartSearch, + new Dictionary + { + ["address"] = host.TargetAddress + "+10", + ["size"] = 16, + ["textContains"] = "nop", + ["lifetimeSeconds"] = 30, + ["maximumResults"] = 32 + }))!; + string jobId = start["jobId"]!.GetValue(); + try + { + Dictionary arguments = new() + { + ["jobId"] = jobId, + ["afterSequence"] = 0, + ["limit"] = 32 + }; + Stopwatch deadline = Stopwatch.StartNew(); + JsonNode poll; + do + { + poll = (await instance.CallToolAsync(CheatEngineToolNames.CodePollJob, arguments))!; + string state = poll["job"]!["state"]!.GetValue(); + if (state == "completed") + { + break; + } + + Assert.Equal("running", state); + Assert.True(deadline.Elapsed < TimeSpan.FromSeconds(10), "The bounded data-only decode job did not finish."); + await Task.Delay(25, TestContext.Current.CancellationToken); + } while (true); + Assert.Null(poll["job"]!["error"]); + Assert.Null(poll["job"]!["cleanupError"]); + Assert.False(poll["job"]!["requiresManualRecovery"]!.GetValue()); + Assert.Equal(16, poll["items"]!.AsArray().Count); + Assert.Equal(0, poll["dropped"]!.GetValue()); + Assert.Equal(16, poll["nextAfterSequence"]!.GetValue()); + Assert.False(poll["more"]!.GetValue()); + JsonNode replay = (await instance.CallToolAsync(CheatEngineToolNames.CodePollJob, arguments))!; + Assert.True(JsonNode.DeepEquals(poll["items"], replay["items"]), "Polling consumed or changed retained job items."); + } + finally + { + JsonNode stopped = (await instance.CallToolAsync(CheatEngineToolNames.RuntimeStopJob, + new Dictionary { ["jobId"] = jobId }))!; + Assert.True(stopped["released"]!.GetValue()); + } + } + + [SupportedOSPlatform("windows")] + private static async Task RequireEmptyAsync(LiveSandboxSession sandbox, LiveLifecycleSession lifecycle) + { + foreach (LiveSandboxHost host in new[] { sandbox.HostA, sandbox.HostB }) + { + LiveMcpInstanceClient instance = lifecycle.Gateway.Bind(host.InstanceId); + JsonNode jobs = (await instance.CallToolAsync(CheatEngineToolNames.RuntimeListJobs))!; + JsonNode resources = (await instance.CallToolAsync(CheatEngineToolNames.RuntimeListResources))!; + Assert.Empty(jobs["jobs"]!.AsArray()); + Assert.Empty(resources["resources"]!.AsArray()); + } + } + + internal static IReadOnlyList CreateSchedule() + { + List schedule = []; + AddEvenly(schedule, LiveSoakActionKind.ShortCall, RequiredShortCalls, TimeSpan.FromMinutes(1), WorkloadDuration); + AddEvenly(schedule, LiveSoakActionKind.BoundedJob, RequiredBoundedJobs, TimeSpan.FromMinutes(4), TimeSpan.FromMinutes(116)); + AddEvenly(schedule, LiveSoakActionKind.PluginCycle, RequiredPluginCycles, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(110)); + AddEvenly(schedule, LiveSoakActionKind.TargetRestart, RequiredTargetRestarts, TimeSpan.FromMinutes(8), TimeSpan.FromMinutes(112)); + schedule.Add(new LiveSoakAction(LiveSoakActionKind.GatewayRestart, TimeSpan.FromMinutes(60))); + return schedule.OrderBy(action => action.At).ThenBy(action => action.Kind).ToArray(); + } + + internal static LiveResourceDecision EvaluateResources(LiveProcessMetrics baseline, LiveProcessMetrics final) + { + if (baseline.CollectionError is not null || final.CollectionError is not null) + { + return new LiveResourceDecision(false, "Process metrics were unavailable.", 0, 0); + } + if (baseline.ProcessId <= 0 || baseline.ProcessId != final.ProcessId) + { + return new LiveResourceDecision(false, "Process metrics belong to different owned CE processes.", 0, 0); + } + if (baseline.CapturedAt > final.CapturedAt || baseline.HandleCount < 0 || final.HandleCount < 0 + || baseline.PrivateBytes < 0 || final.PrivateBytes < 0 || baseline.WorkingSetBytes < 0 + || final.WorkingSetBytes < 0) + { + return new LiveResourceDecision(false, "Process metrics are invalid or out of chronological order.", 0, 0); + } + + long handleGrowth = final.HandleCount - baseline.HandleCount; + long privateBytesGrowth = final.PrivateBytes - baseline.PrivateBytes; + return new LiveResourceDecision(handleGrowth <= MaximumHandleGrowth && privateBytesGrowth <= MaximumPrivateBytesGrowth, + null, handleGrowth, privateBytesGrowth); + } + + private static void AddEvenly(List schedule, LiveSoakActionKind kind, int count, + TimeSpan first, TimeSpan last) + { + for (int index = 0; index < count; index++) + { + double ratio = count == 1 ? 0 : (double) index / (count - 1); + schedule.Add(new LiveSoakAction(kind, first + TimeSpan.FromTicks((long) ((last - first).Ticks * ratio)))); + } + } +} + +internal enum LiveSoakActionKind +{ + ShortCall, + BoundedJob, + PluginCycle, + TargetRestart, + GatewayRestart +} + +internal sealed record LiveSoakAction(LiveSoakActionKind Kind, TimeSpan At); + +internal sealed record LiveResourceDecision(bool Passed, string? Failure, long HandleGrowth, long PrivateBytesGrowth); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs new file mode 100644 index 0000000..8fa2e5a --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs @@ -0,0 +1,610 @@ +using System.Globalization; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text.Json.Nodes; + +using CheatEngine.Mcp.Core.Contract; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Bounded, reversible live checks for the safe memory-analysis workflows on the disposable A target. +[SupportedOSPlatform("windows")] +internal static class LiveWorkflowQualification +{ + private const string Source = "live-workflow-source"; + private const string Destination = "live-workflow-destination"; + private static readonly string[] InitialBatchValues = ["1", "-2", "3"]; + private static readonly string[] ScanTransitions = ["unknown", "changed", "increased", "decreased", "reset", "between"]; + private static readonly string[] HealthValues = ["7", "7"]; + private static readonly string[] FlagValues = ["100", "200"]; + + internal static async Task VerifyAsync(LiveSandboxSession sandbox, LiveMcpClient gateway) + { + ArgumentNullException.ThrowIfNull(sandbox); + ArgumentNullException.ThrowIfNull(gateway); + LiveMcpInstanceClient instance = gateway.Bind(sandbox.HostA.InstanceId); + await VerifyGatesAsync(sandbox, instance); + await VerifyMemoryAsync(sandbox, instance); + await VerifyScannerAsync(sandbox, instance); + await VerifyAobAsync(sandbox, instance); + await VerifyControlFlowAsync(sandbox, instance); + await VerifyModulesAsync(sandbox, instance); + await VerifyStructuresAsync(sandbox, instance); + } + + private static async Task VerifyGatesAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) + { + JsonNode runtime = await CallAsync(instance, CheatEngineToolNames.RuntimeGetInfo); + JsonNode? gates = runtime["gates"]; + Assert.NotNull(gates); + Assert.False(gates!["unsafeLua"]!.GetValue()); + Assert.False(gates["autoAssembler"]!.GetValue()); + Assert.False(gates["targetCodeExecution"]!.GetValue()); + Assert.False(gates["kernelAccess"]!.GetValue()); + sandbox.Record("live_workflow_gates", new + { + unsafeLua = false, + autoAssembler = false, + targetCodeExecution = false, + kernelAccess = false + }); + } + + private static async Task VerifyMemoryAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) + { + string snapshot = "live-workflow-snapshot"; + bool sourceAllocated = false, destinationAllocated = false, snapshotCreated = false; + JsonNode? previousProtection = null; + string? destinationAddress = null; + Exception? failure = null; + List cleanup = []; + try + { + JsonNode source = await CallAsync(instance, CheatEngineToolNames.MemoryAllocate, + new Dictionary { ["name"] = Source, ["size"] = 128 }); + sourceAllocated = true; + JsonNode destination = await CallAsync(instance, CheatEngineToolNames.MemoryAllocate, + new Dictionary { ["name"] = Destination, ["size"] = 128 }); + destinationAllocated = true; + string src = Text(source, "address"), dst = Text(destination, "address"); + destinationAddress = dst; + Assert.Equal(128L, source["size"]!.GetValue()); + Assert.False(source["executable"]!.GetValue()); + Assert.Equal(128L, destination["size"]!.GetValue()); + + JsonNode batch = await CallAsync(instance, CheatEngineToolNames.MemoryWriteBatch, new Dictionary + { + ["items"] = new object?[] + { + new Dictionary { ["address"] = At(src, 0), ["valueType"] = "int32", ["value"] = "1" }, + new Dictionary { ["address"] = At(src, 4), ["valueType"] = "int32", ["value"] = "-2" }, + new Dictionary { ["address"] = At(src, 8), ["valueType"] = "int32", ["value"] = "3" } + }, + ["verify"] = true + }); + Assert.Equal(3, batch["written"]!.GetValue()); + Assert.True(batch["verified"]!.GetValue()); + Assert.Null(batch["mismatched"]); + JsonNode values = await CallAsync(instance, CheatEngineToolNames.MemoryRead, + new Dictionary { ["address"] = src, ["valueType"] = "int32", ["count"] = 3 }); + Assert.Equal(InitialBatchValues, Strings(values["values"])); + + await WriteAsync(instance, At(src, 16), "int32", "-1"); + await WriteAsync(instance, At(src, 20), "uint32", "4294967295"); + await WriteAsync(instance, At(src, 24), "uint32", "287454020", "big_endian"); + await WriteAsync(instance, At(src, 32), "string", "MCP", nullTerminate: true); + await WriteAsync(instance, At(src, 40), "wstring", "CE", nullTerminate: true); + await WriteAsync(instance, At(src, 56), "uint64", "18446744073709551615"); + await WriteAsync(instance, At(src, 64), "int64", "-9223372036854775808"); + Assert.Equal("-1", await ReadAsync(instance, At(src, 16), "int32")); + Assert.Equal("4294967295", await ReadAsync(instance, At(src, 20), "uint32")); + Assert.Equal("11 22 33 44", await ReadAsync(instance, At(src, 24), "bytes", size: 4)); + Assert.Equal("287454020", await ReadAsync(instance, At(src, 24), "uint32", byteOrder: "big_endian")); + Assert.Equal("MCP", await ReadAsync(instance, At(src, 32), "string", length: 4)); + Assert.Equal("CE", await ReadAsync(instance, At(src, 40), "wstring", length: 3)); + Assert.Equal("18446744073709551615", await ReadAsync(instance, At(src, 56), "uint64")); + Assert.Equal("-9223372036854775808", await ReadAsync(instance, At(src, 64), "int64")); + + JsonNode readBatch = await CallAsync(instance, CheatEngineToolNames.MemoryReadBatch, new Dictionary + { + ["items"] = new object?[] + { + new Dictionary { ["address"] = src, ["valueType"] = "int32" }, + new Dictionary { ["address"] = sandbox.HostA.UnreadableRootAddress, ["valueType"] = "int32" }, + new Dictionary { ["address"] = At(src, 32), ["valueType"] = "string", ["length"] = 4 } + } + }); + Assert.Equal(1, readBatch["failed"]!.GetValue()); + JsonArray readItems = readBatch["items"]!.AsArray(); + Assert.Equal("1", Text(readItems[0], "value")); + Assert.Equal("memory_read_failed", Text(readItems[1]!["error"], "kind")); + Assert.Equal("MCP", Text(readItems[2], "value")); + + await WriteAsync(instance, At(src, 48), "int32", "123"); + LiveMcpToolResult partial = await instance.CallToolRawAsync(CheatEngineToolNames.MemoryWriteBatch, + new Dictionary + { + ["items"] = new object?[] + { + new Dictionary { ["address"] = At(src, 48), ["valueType"] = "int32", ["value"] = "456" }, + new Dictionary { ["address"] = sandbox.HostA.UnreadableRootAddress, ["valueType"] = "int32", ["value"] = "789" } + }, + ["verify"] = false + }); + Assert.True(partial.IsError); + JsonNode partialPayload = Payload(partial); + Assert.Equal("partial_effect", Text(partialPayload["error"], "kind")); + Assert.Equal("started", Text(partialPayload["error"], "hostEffect")); + Assert.False(partialPayload["error"]!["retryable"]!.GetValue()); + Assert.Equal(1, partialPayload["error"]!["details"]!["completed"]!.GetValue()); + Assert.Equal(1, partialPayload["error"]!["details"]!["failedIndex"]!.GetValue()); + Assert.Equal("partial", Text(partialPayload["error"]!["details"], "effectState")); + Assert.Equal("456", await ReadAsync(instance, At(src, 48), "int32")); + await WriteAsync(instance, At(src, 48), "int32", "123"); + + const string known = "00 11 22 33 44 55 66 77 88 99 AA BB CC DD EE FF"; + await WriteAsync(instance, src, "bytes", known); + JsonNode hash = await CallAsync(instance, CheatEngineToolNames.MemoryHash, + new Dictionary { ["address"] = src, ["size"] = 16, ["algorithm"] = "sha256" }); + Assert.Equal(Convert.ToHexString(SHA256.HashData(Convert.FromHexString(known.Replace(" ", string.Empty)))) + .ToLowerInvariant(), Text(hash, "hash")); + JsonNode copy = await CallAsync(instance, CheatEngineToolNames.MemoryCopy, + new Dictionary { ["source"] = src, ["destination"] = dst, ["size"] = 16 }); + Assert.Equal(src, Text(copy, "source")); + Assert.Equal(dst, Text(copy, "destination")); + JsonNode compare = await CallAsync(instance, CheatEngineToolNames.MemoryCompare, + new Dictionary { ["addressA"] = src, ["addressB"] = dst, ["size"] = 16, ["maxDifferences"] = 4 }); + Assert.True(compare["equal"]!.GetValue()); + Assert.False(compare["truncated"]!.GetValue()); + Assert.Empty(compare["differences"]!.AsArray()); + + JsonNode snapshotResult = await CallAsync(instance, CheatEngineToolNames.MemoryCreateSnapshot, + new Dictionary { ["name"] = snapshot, ["address"] = dst, ["size"] = 16 }); + snapshotCreated = true; + Assert.Equal(0, snapshotResult["unreadableBytes"]!.GetValue()); + Assert.Empty(snapshotResult["unreadable"]!.AsArray()); + await WriteAsync(instance, dst, "int32", "7"); + JsonNode diff = await CallAsync(instance, CheatEngineToolNames.MemoryCompareSnapshot, + new Dictionary { ["name"] = snapshot, ["valueType"] = "int32", ["alignment"] = 4, ["change"] = "changed", ["offset"] = 0, ["limit"] = 10 }); + Assert.Equal((4, 0, 1), (diff["compared"]!.GetValue(), diff["skipped"]!.GetValue(), diff["total"]!.GetValue())); + Assert.Equal(dst, Text(Assert.Single(diff["changes"]!.AsArray()), "address")); + + JsonNode protection = await CallAsync(instance, CheatEngineToolNames.MemorySetProtection, + new Dictionary { ["address"] = dst, ["size"] = 16, ["read"] = true, ["write"] = false, ["execute"] = false }); + previousProtection = protection["previous"]!.DeepClone(); + Assert.True(protection["current"]!["read"]!.GetValue()); + Assert.False(protection["current"]!["write"]!.GetValue()); + Assert.False(protection["current"]!["execute"]!.GetValue()); + string refusedPath = Path.Combine(sandbox.InstanceDirectory, "forbidden-workflow-dump.bin"); + LiveMcpToolResult refused = await instance.CallToolRawAsync(CheatEngineToolNames.MemoryDumpToFile, + new Dictionary { ["address"] = src, ["size"] = 16, ["path"] = refusedPath, ["overwrite"] = true }); + Assert.True(refused.IsError); + JsonNode refusal = Payload(refused); + Assert.Equal("invalid_argument", Text(refusal["error"], "kind")); + Assert.Equal("not_started", Text(refusal["error"], "hostEffect")); + Assert.False(File.Exists(refusedPath)); + sandbox.Record("live_workflow_memory", new + { + source = src, + destination = dst, + hash = Text(hash, "hash"), + integerRoundTrips = true, + batchPartial = true, + snapshot = snapshotResult.DeepClone(), + filePolicyRefusal = true + }); + } + catch (Exception exception) { failure = exception; throw; } + finally + { + if (previousProtection is not null && destinationAddress is not null) + { + await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemorySetProtection, + new Dictionary + { + ["address"] = destinationAddress, + ["size"] = 16, + ["read"] = previousProtection["read"]!.GetValue(), + ["write"] = previousProtection["write"]!.GetValue(), + ["execute"] = previousProtection["execute"]!.GetValue() + })); + } + if (snapshotCreated) + { + await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryDeleteSnapshot, new Dictionary { ["name"] = snapshot })); + } + + if (destinationAllocated) + { + await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = Destination })); + } + + if (sourceAllocated) + { + await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = Source })); + } + + sandbox.Record("live_workflow_memory_cleanup", new + { + protectionRestored = previousProtection is not null, + snapshotDeleted = snapshotCreated, + destinationFreed = destinationAllocated, + sourceFreed = sourceAllocated, + failures = cleanup.Count + }); + ThrowCleanup(failure, cleanup); + } + } + + private static async Task VerifyScannerAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) + { + JsonNode settings = await CallAsync(instance, CheatEngineToolNames.ScanGetStatus, + new Dictionary { ["scannerName"] = "main" }); + Assert.True(settings["settings"]!["memPrivate"]!.GetValue(), + "Named scan qualification requires the existing CE MEM_PRIVATE preference to be enabled."); + const string allocation = "live-workflow-scanner"; + const string scanner = "live-workflow-scanner-session"; + bool allocated = false, created = false; + List cleanup = []; + Exception? failure = null; + try + { + JsonNode result = await CallAsync(instance, CheatEngineToolNames.MemoryAllocate, new Dictionary { ["name"] = allocation, ["size"] = 64 }); + allocated = true; + string address = Text(result, "address"); + await WriteAsync(instance, address, "int32", "10"); + created = true; + await CallAsync(instance, CheatEngineToolNames.ScanFirst, ScanArguments(scanner, address, "unknown")); + await WriteAsync(instance, address, "int32", "20"); + await CallAsync(instance, CheatEngineToolNames.ScanNext, new Dictionary { ["scannerName"] = scanner, ["comparison"] = "changed" }); + await AssertScanAsync(instance, scanner, address, "20"); + await WriteAsync(instance, address, "int32", "30"); + await CallAsync(instance, CheatEngineToolNames.ScanNext, new Dictionary { ["scannerName"] = scanner, ["comparison"] = "increased" }); + await AssertScanAsync(instance, scanner, address, "30"); + await WriteAsync(instance, address, "int32", "25"); + await CallAsync(instance, CheatEngineToolNames.ScanNext, new Dictionary { ["scannerName"] = scanner, ["comparison"] = "decreased" }); + await AssertScanAsync(instance, scanner, address, "25"); + JsonNode reset = await CallAsync(instance, CheatEngineToolNames.ScanReset, new Dictionary { ["scannerName"] = scanner }); + Assert.False(reset["removed"]!.GetValue()); + Dictionary between = ScanArguments(scanner, address, "between"); + between["value"] = "20"; + between["upperValue"] = "30"; + await CallAsync(instance, CheatEngineToolNames.ScanFirst, between); + await AssertScanAsync(instance, scanner, address, "25"); + sandbox.Record("live_workflow_scanner", new + { + address, + transitions = ScanTransitions + }); + } + catch (Exception exception) { failure = exception; throw; } + finally + { + if (created) + { + await CleanupAsync(cleanup, async () => + { + LiveMcpToolResult deleted = await instance.CallToolRawAsync(CheatEngineToolNames.ScanDelete, + new Dictionary { ["scannerName"] = scanner }); + if (deleted.IsError) + { + Assert.Equal("not_found", Text(Payload(deleted)["error"], "kind")); + } + }); + } + + if (allocated) + { + await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = allocation })); + } + + sandbox.Record("live_workflow_scanner_cleanup", new + { + deleted = created, + freed = allocated, + failures = cleanup.Count + }); + ThrowCleanup(failure, cleanup); + } + } + + private static async Task VerifyAobAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) + { + const string allocation = "live-workflow-aob"; + bool allocated = false; + List cleanup = []; + Exception? failure = null; + try + { + JsonNode result = await CallAsync(instance, CheatEngineToolNames.MemoryAllocate, new Dictionary { ["name"] = allocation, ["size"] = 64 }); + allocated = true; + string address = Text(result, "address"); + await WriteAsync(instance, At(address, 16), "bytes", "D1 2B 7E 93 44 A8 19 C6"); + await WriteAsync(instance, At(address, 32), "int32", "16909060"); + Dictionary args = ScanBounds(address); + args["patterns"] = new[] { "D1 2B 7E 93 44 A8 19 C6", "D1 2B ?? 93 44 A8 19 C6" }; + JsonNode found = await CallAsync(instance, CheatEngineToolNames.AobFind, args); + Assert.Equal(2, found["results"]!.AsArray().Count); + Assert.Equal("D1 2B 7E 93 44 A8 19 C6", Text(found["results"]![0], "pattern")); + Assert.Equal("D1 2B ?? 93 44 A8 19 C6", Text(found["results"]![1], "pattern")); + foreach (JsonNode? match in found["results"]!.AsArray()) + { + AssertAob(match!, At(address, 16)); + } + + Dictionary value = ScanBounds(address); + value["valueType"] = "int32"; + value["value"] = "16909060"; + JsonNode foundValue = await CallAsync(instance, CheatEngineToolNames.AobFindValue, value); + Assert.Equal("04 03 02 01", Text(foundValue["result"], "pattern")); + AssertAob(foundValue["result"]!, At(address, 32)); + sandbox.Record("live_workflow_aob", new + { + address, + exactAndWildcard = found.DeepClone(), + value = foundValue.DeepClone() + }); + } + catch (Exception exception) { failure = exception; throw; } + finally { if (allocated) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = allocation })); } sandbox.Record("live_workflow_aob_cleanup", new { freed = allocated, failures = cleanup.Count }); ThrowCleanup(failure, cleanup); } + } + + private static async Task VerifyControlFlowAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) + { + const string allocation = "live-workflow-cfg"; + bool allocated = false; + List cleanup = []; + Exception? failure = null; + try + { + JsonNode allocationResult = await CallAsync(instance, CheatEngineToolNames.MemoryAllocate, new Dictionary { ["name"] = allocation, ["size"] = 64 }); + allocated = true; + string address = Text(allocationResult, "address"); + await WriteAsync(instance, address, "bytes", "31 C0 85 C0 74 06 B8 01 00 00 00 C3 B8 02 00 00 00 C3"); + JsonNode graph = await CallAsync(instance, CheatEngineToolNames.CodeGetFunctionGraph, new Dictionary { ["address"] = address, ["maxInstructions"] = 16, ["maxBytes"] = 18, ["includeInstructions"] = true }); + Assert.Equal((address, address, At(address, 18)), (Text(graph, "entry"), Text(graph, "start"), Text(graph, "end"))); + Assert.False(graph["truncated"]!.GetValue()); + Assert.Equal(7, graph["instructionCount"]!.GetValue()); + Assert.Empty(graph["calls"]!.AsArray()); + JsonArray blocks = graph["blocks"]!.AsArray(); + Assert.Equal(3, blocks.Count); + Assert.Equal("conditional", Text(blocks[0], "terminator")); + Assert.Equal(new[] { At(address, 12), At(address, 6) }, Strings(blocks[0]!["successors"])); + Assert.Equal("return", Text(blocks[1], "terminator")); + Assert.Equal("return", Text(blocks[2], "terminator")); + Assert.Equal(7, blocks.Sum(block => block!["instructionCount"]!.GetValue())); + JsonNode truncated = await CallAsync(instance, CheatEngineToolNames.CodeGetFunctionGraph, new Dictionary { ["address"] = address, ["maxInstructions"] = 1, ["maxBytes"] = 18, ["includeInstructions"] = false }); + Assert.True(truncated["truncated"]!.GetValue()); + Assert.Equal(1, truncated["instructionCount"]!.GetValue()); + Assert.Equal("limit", Text(Assert.Single(truncated["blocks"]!.AsArray()), "terminator")); + Assert.Null(Assert.Single(truncated["blocks"]!.AsArray())!["instructions"]); + sandbox.Record("live_workflow_cfg", new + { + address, + graph = graph.DeepClone(), + truncated = truncated.DeepClone(), + executed = false + }); + } + catch (Exception exception) { failure = exception; throw; } + finally { if (allocated) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = allocation })); } sandbox.Record("live_workflow_cfg_cleanup", new { freed = allocated, failures = cleanup.Count }); ThrowCleanup(failure, cleanup); } + } + + private static async Task VerifyModulesAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) + { + JsonNode listed = await CallAsync(instance, CheatEngineToolNames.ModuleList, new Dictionary { ["format"] = "detailed", ["limit"] = 1000 }); + string expected = sandbox.TargetArchitecture == "x86" ? "dotnet.exe" : "CheatEngine.Mcp.LiveTarget.exe"; + JsonNode module = Assert.Single(listed["modules"]!.AsArray(), item => string.Equals(Text(item, "name"), expected, StringComparison.OrdinalIgnoreCase))!; + string name = Text(module, "name"), @base = Text(module, "base"); + Assert.True(Parse(@base) > 0); + Assert.True(module["size"]!.GetValue() > 0); + Assert.Equal(sandbox.TargetArchitecture == "x64", module["is64Bit"]!.GetValue()); + JsonNode details = await CallAsync(instance, CheatEngineToolNames.ModuleGet, new Dictionary { ["module"] = name }); + Assert.Equal(@base, Text(details, "base")); + Assert.NotEmpty(details["sections"]!.AsArray()); + Assert.Contains(details["sections"]!.AsArray(), section => section!["executable"]?.GetValue() == true); + Assert.Equal(sandbox.TargetArchitecture, Text(details["pe"], "machine")); + JsonNode imports = await CallAsync(instance, CheatEngineToolNames.ModuleListImports, new Dictionary { ["module"] = name, ["includeDelayLoaded"] = true, ["offset"] = 0, ["limit"] = 1000 }); + Assert.True(imports["total"]!.GetValue() > 0); + foreach (JsonNode? import in imports["imports"]!.AsArray()) + { + Assert.False(string.IsNullOrWhiteSpace(Text(import, "dll"))); + _ = Parse(Text(import, "slotAddress")); + _ = Parse(Text(import, "value")); + } + JsonNode symbols = await CallAsync(instance, CheatEngineToolNames.SymbolResolve, new Dictionary { ["expressions"] = new[] { name, name + "+0" }, ["shallow"] = true }); + Assert.Equal(2, symbols["items"]!.AsArray().Count); + foreach (JsonNode? item in symbols["items"]!.AsArray()) + { + Assert.Null(item!["error"]); + Assert.Equal(@base, Text(item, "address")); + } + JsonNode patches = await CallAsync(instance, CheatEngineToolNames.ModuleFindPatches, new Dictionary { ["module"] = name, ["includeNonExecutable"] = false, ["limit"] = 1024 }); + Assert.Equal(name, Text(patches, "module")); + Assert.True(patches["comparedBytes"]!.GetValue() > 0); + Assert.Equal(0L, patches["unreadableBytes"]!.GetValue()); + Assert.False(patches["truncated"]!.GetValue()); + sandbox.Record("live_workflow_modules", new + { + expected, + selected = module.DeepClone(), + details = details.DeepClone(), + imports = imports.DeepClone(), + symbols = symbols.DeepClone(), + patches = patches.DeepClone() + }); + } + + private static async Task VerifyStructuresAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) + { + const string first = "live-workflow-structure-a", second = "live-workflow-structure-b"; + string name = "McpLiveWorkflow_" + sandbox.HostA.TargetProcessId.ToString(CultureInfo.InvariantCulture); + bool a = false, b = false, structure = false; + List cleanup = []; + Exception? failure = null; + try + { + JsonNode firstAllocation = await CallAsync(instance, CheatEngineToolNames.MemoryAllocate, new Dictionary { ["name"] = first, ["size"] = 16 }); + a = true; + JsonNode secondAllocation = await CallAsync(instance, CheatEngineToolNames.MemoryAllocate, new Dictionary { ["name"] = second, ["size"] = 16 }); + b = true; + string addressA = Text(firstAllocation, "address"), addressB = Text(secondAllocation, "address"); + foreach ((string address, string health, string flags) in new[] { (addressA, "7", "100"), (addressB, "7", "200") }) + { + await WriteAsync(instance, address, "int32", health); + await WriteAsync(instance, At(address, 4), "uint16", flags); + } + JsonNode created = await CallAsync(instance, CheatEngineToolNames.StructureCreate, new Dictionary { ["name"] = name, ["elements"] = new object?[] { new Dictionary { ["offset"] = "0", ["name"] = "health", ["valueType"] = "int32" }, new Dictionary { ["offset"] = "4", ["name"] = "flags", ["valueType"] = "uint16" } } }); + structure = true; + Assert.Equal(2, created["elementCount"]!.GetValue()); + Assert.True(created["size"]!.GetValue() >= 6); + JsonNode read = await CallAsync(instance, CheatEngineToolNames.StructureRead, new Dictionary { ["name"] = name, ["addresses"] = new[] { addressA, addressB }, ["offset"] = 0, ["limit"] = 8, ["format"] = "detailed" }); + JsonArray rows = read["elements"]!.AsArray(); + Assert.Equal(HealthValues, Strings(rows[0]!["values"])); + Assert.Equal(FlagValues, Strings(rows[1]!["values"])); + JsonNode compared = await CallAsync(instance, CheatEngineToolNames.StructureCompare, new Dictionary { ["groupA"] = new[] { addressA }, ["groupB"] = new[] { addressB }, ["structureName"] = name, ["granularity"] = 4, ["interpretAs"] = "auto", ["mode"] = "discriminate", ["offset"] = 0, ["limit"] = 8 }); + JsonNode row = Assert.Single(compared["rows"]!.AsArray())!; + Assert.Equal(("4", "flags", "discriminator"), (Text(row, "offset"), Text(row, "name"), Text(row, "classification"))); + JsonNode written = await CallAsync(instance, CheatEngineToolNames.StructureWriteElement, new Dictionary { ["name"] = name, ["address"] = addressA, ["index"] = 0, ["value"] = "9" }); + Assert.Equal("9", Text(written, "value")); + Assert.Equal("9", await ReadAsync(instance, addressA, "int32")); + JsonNode header = await CallAsync(instance, CheatEngineToolNames.StructureGenerateCHeader, new Dictionary { ["names"] = new[] { name }, ["generator"] = "managed" }); + Assert.Equal("managed", Text(header, "generator")); + Assert.Contains(name, Text(header, "text"), StringComparison.Ordinal); + Assert.Contains("int32_t health", Text(header, "text"), StringComparison.Ordinal); + Assert.Contains("uint16_t flags", Text(header, "text"), StringComparison.Ordinal); + sandbox.Record("live_workflow_structures", new + { + name, + read = read.DeepClone(), + comparison = compared.DeepClone(), + header = header.DeepClone() + }); + } + catch (Exception exception) { failure = exception; throw; } + finally { if (structure) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.StructureDelete, new Dictionary { ["name"] = name })); } if (b) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = second })); } if (a) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = first })); } sandbox.Record("live_workflow_structures_cleanup", new { deleted = structure, freedB = b, freedA = a, failures = cleanup.Count }); ThrowCleanup(failure, cleanup); } + } + + private static Dictionary ScanArguments(string name, string address, string comparison) => new() + { + ["scannerName"] = name, + ["valueType"] = "int32", + ["comparison"] = comparison, + ["startAddress"] = address, + ["endAddress"] = At(address, 64), + ["writable"] = "required", + ["executable"] = "excluded", + ["copyOnWrite"] = "any", + ["alignment"] = 4, + ["includeMapped"] = false + }; + + private static Dictionary ScanBounds(string address) => new() + { + ["startAddress"] = address, + ["endAddress"] = At(address, 63), + ["writable"] = "required", + ["executable"] = "excluded", + ["copyOnWrite"] = "excluded", + ["alignment"] = 4, + ["limit"] = 2, + ["includeMapped"] = false + }; + + private static async Task AssertScanAsync(LiveMcpInstanceClient instance, string name, string address, string value) + { + JsonNode status = await CallAsync(instance, CheatEngineToolNames.ScanGetStatus, new Dictionary { ["scannerName"] = name }); + Assert.Equal((name, "independent", "ResultsReady", true, 1L, "int32"), (Text(status, "scannerName"), Text(status, "mode"), Text(status, "state"), status["resultsReady"]!.GetValue(), status["count"]!.GetValue(), Text(status, "valueType"))); + JsonNode results = await CallAsync(instance, CheatEngineToolNames.ScanListResults, new Dictionary { ["scannerName"] = name, ["startIndex"] = 0, ["maximumResults"] = 8 }); + JsonNode item = Assert.Single(results["results"]!.AsArray())!; + Assert.Equal((address, value), (Text(item, "address"), Text(item, "value"))); + Assert.False(results["hasMore"]!.GetValue()); + } + + private static void AssertAob(JsonNode result, string address) + { + Assert.Equal(1, result["count"]!.GetValue()); + Assert.True(result["exact"]!.GetValue()); + Assert.True(result["unique"]!.GetValue()); + Assert.Equal("bounded_scan", Text(result, "scope")); + Assert.True(result["targetVerified"]!.GetValue()); + Assert.Equal(address, Assert.Single(Strings(result["matches"]))); + } + + private static async Task WriteAsync(LiveMcpInstanceClient client, string address, string valueType, string value, string? byteOrder = null, bool nullTerminate = false) + { + Dictionary arguments = new() + { + ["address"] = address, + ["valueType"] = valueType, + ["value"] = value, + ["verify"] = true + }; + if (byteOrder is not null) + { + arguments["byteOrder"] = byteOrder; + } + + if (nullTerminate) + { + arguments["nullTerminate"] = true; + } + + JsonNode result = await CallAsync(client, CheatEngineToolNames.MemoryWrite, arguments); + Assert.True(result["verified"]!.GetValue()); + } + + private static async Task ReadAsync(LiveMcpInstanceClient client, string address, string valueType, int? size = null, int? length = null, string? byteOrder = null) + { + Dictionary arguments = new() + { + ["address"] = address, + ["valueType"] = valueType + }; + if (size is not null) + { + arguments["size"] = size; + } + + if (length is not null) + { + arguments["length"] = length; + } + + if (byteOrder is not null) + { + arguments["byteOrder"] = byteOrder; + } + + return Text(await CallAsync(client, CheatEngineToolNames.MemoryRead, arguments), "value"); + } + + private static async Task CallAsync(LiveMcpInstanceClient client, string tool, IReadOnlyDictionary? arguments = null) => await client.CallToolAsync(tool, arguments) ?? throw new InvalidDataException($"Tool '{tool}' returned no payload."); + private static JsonNode Payload(LiveMcpToolResult result) => result.Payload ?? throw new InvalidDataException("Expected error returned no payload."); + private static string Text(JsonNode? node, string property) => node?[property]?.GetValue() ?? throw new InvalidDataException($"Missing string '{property}'."); + private static string[] Strings(JsonNode? node) => node is null ? [] : node.AsArray().Select(item => item!.GetValue()).ToArray(); + private static ulong Parse(string value) => ulong.Parse(value.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? value[2..] : value, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + private static string At(string address, int offset) => checked(Parse(address) + (ulong) offset).ToString("X", CultureInfo.InvariantCulture); + private static async Task CleanupAsync(List failures, Func action) + { + try + { + await action(); + } + catch (Exception exception) { failures.Add(exception); } + } + private static void ThrowCleanup(Exception? failure, List cleanup) + { + if (cleanup.Count == 0) + { + return; + } + + if (failure is null) + { + throw new AggregateException("Live workflow cleanup failed.", cleanup); + } + + throw new AggregateException("Live workflow failed and cleanup also failed.", [failure, .. cleanup]); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs index ad090f2..4f34a17 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs @@ -21,11 +21,35 @@ public sealed class McpLiveQualificationTests(LiveQualificationFixture fixture) public async Task OneGateway_TwoOwnedInstances_RoutesStateAndSurvivesOneHostShutdown() { LiveQualificationInputs inputs = fixture.RequireAuthorization(); - if (inputs.Scenario == LiveQualificationScenario.Compiler) + if (inputs.Scenario is LiveQualificationScenario.Compiler or LiveQualificationScenario.CompilerExtended) { await LiveCompilerQualification.RunAsync(inputs); return; } + if (inputs.Scenario == LiveQualificationScenario.Performance) + { + await LivePerformanceQualification.RunAsync(inputs); + return; + } + if (inputs.Scenario == LiveQualificationScenario.Lifecycle) + { + await LiveLifecycleQualification.RunAsync(inputs); + return; + } + if (inputs.Scenario == LiveQualificationScenario.Soak) + { + await LiveSoakQualification.RunAsync(inputs); + return; + } + if (inputs.Scenario == LiveQualificationScenario.CompilerInjection) + { + await LiveManagedInjectionQualification.RunAsync(inputs); + return; + } + if (inputs.Scenario != LiveQualificationScenario.Smoke) + { + throw new InvalidOperationException("The selected qualification runner has not been integrated yet; no host was started."); + } await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs); await using LiveMcpClient gateway = await LiveMcpClient.ConnectGatewayAsync(sandbox.GatewayExecutablePath, sandbox.InstanceDirectory, @@ -78,6 +102,7 @@ public async Task OneGateway_TwoOwnedInstances_RoutesStateAndSurvivesOneHostShut LiveMcpInstanceClient instanceB = gateway.Bind(sandbox.HostB.InstanceId); step = "instance runtime information"; + await LiveResourceQualification.VerifyAsync(sandbox, gateway); JsonNode runtimeA = await SuccessfulCallAsync(instanceA, CheatEngineToolNames.RuntimeGetInfo); JsonNode runtimeB = await SuccessfulCallAsync(instanceB, CheatEngineToolNames.RuntimeGetInfo); Assert.Equal(Path.GetFileName(sandbox.HostA.PluginPath), runtimeA["pluginFileName"]!.GetValue()); @@ -188,6 +213,7 @@ await SuccessfulCallAsync(instanceA, CheatEngineToolNames.RecordDelete, step = "instance B shutdown"; await sandbox.StopHostAsync("B"); await AssertEventuallyInstancesAsync(gateway, sandbox.HostA.InstanceId); + await LiveResourceQualification.VerifyInstanceCompletionsAsync(gateway, sandbox.HostA.InstanceId); await Assert.ThrowsAsync(async () => await instanceB.CallToolAsync(CheatEngineToolNames.RuntimeGetInfo)); AssertMemoryValue(await ReadMemoryAsync(instanceA, sandbox.HostA.TargetAddress), 20260931); diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLiveCompilerBridgeTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLiveCompilerBridgeTests.cs index d68aa3a..092a801 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLiveCompilerBridgeTests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLiveCompilerBridgeTests.cs @@ -8,7 +8,10 @@ public sealed partial class NativeLuaToolRuntimeTests private const string CompilerBridgeStubs = """ compilerCalls = 0 removeCalls = 0 + closeCalls = 0 removeOk = true + getTickCount = function() return 0 end + sleep = function() end command = 'status' response = '' seen = {} @@ -19,9 +22,9 @@ public sealed partial class NativeLuaToolRuntimeTests end io = { open = function(path, mode) if mode == 'r' then - return { read = function() return command end, close = function() end } + return { read = function() return command end, close = function() closeCalls = closeCalls + 1 end } end - return { write = function(_, value) response = response .. value end, close = function() end } + return { write = function(_, value) response = response .. value end, close = function() closeCalls = closeCalls + 1 end } end } os = { remove = function() removeCalls = removeCalls + 1; return removeOk end, rename = function() return true end } @@ -86,6 +89,23 @@ public void LiveCompilerBridge_RequestDequeueFailure_DoesNotCallCompiler() Assert.Equal(1L, ReadGlobal("removeCalls")); } + [Fact] + public void LiveCompilerBridge_ArmHold_DoesNotCompile_AndNextSuccessfulCompilePreservesReturns() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(CompilerBridgeStubs + "\ncommand = 'armHold'"); + + RunBridge("compilerProbe(); holdReturns=table.pack(compileCS('sentinel', {}, nil))"); + + Assert.Equal(1L, ReadGlobal("compilerCalls")); + Assert.Equal("sentinel", EvaluateLua("seen[1]")); + Assert.Equal(3L, EvaluateLua("holdReturns.n")); + Assert.Equal("C:\\owned\\ce-cscode.dll", EvaluateLua("holdReturns[1]")); + Assert.Null(EvaluateLua("holdReturns[2]")); + Assert.Equal("third", EvaluateLua("holdReturns[3]")); + Assert.True((long) ReadGlobal("closeCalls")! >= 4, "The hold release handle must be closed after observation."); + } + private static void RunBridge(string tail) { string bridge = LiveCompilerBridge.Render("request", "response", LiveCompilerPayload.ValidSource, diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLivePluginBridgeTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLivePluginBridgeTests.cs new file mode 100644 index 0000000..cfeb64e --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLivePluginBridgeTests.cs @@ -0,0 +1,67 @@ +using CheatEngine.Mcp.Tests.LiveQualification; + +namespace CheatEngine.Mcp.Tests.NativeLua; + +public sealed partial class NativeLuaToolRuntimeTests +{ + [Fact] + public void LivePluginBridge_Disable_UsesOnlySpaceKeyMessagesAndSettingsOk() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(Stubs("7\ndisable\n", "CheatEngine.Mcp.dll CheatEngine.Mcp")); + RunPluginBridge("pluginEnabled=true; pluginControl()"); + Assert.Equal("ok\n7\ndisable", EvaluateLua("response")); + Assert.Equal(2L, EvaluateLua("#messages")); + Assert.Equal(0x0100L, EvaluateLua("messages[1][2]")); + Assert.Equal(0x0101L, EvaluateLua("messages[2][2]")); + Assert.Equal(99L, EvaluateLua("messages[1][1]")); + Assert.Equal(0x20L, EvaluateLua("messages[1][3]")); + Assert.False((bool) EvaluateLua("pluginEnabled")!); + Assert.Equal(1L, EvaluateLua("okClicks")); + } + + [Theory] + [InlineData("8\nother\n", "CheatEngine.Mcp.dll CheatEngine.Mcp", 1)] + [InlineData("8\ndisable\n", "Other.dll Other", 1)] + [InlineData("8\ndisable\n", "CheatEngine.Mcp.dll CheatEngine.Mcp", 2)] + [InlineData("8\nenable\n", "CheatEngine.Mcp.dll CheatEngine.Mcp", 1)] + public void LivePluginBridge_InvalidRequestsOrRows_RefuseBeforeSettingsApply(string command, string row, int count) + { + using RuntimeScope scope = CreateScope(); + InstallStubs(Stubs(command, row, count)); + RunPluginBridge("pluginEnabled=true; success=pcall(pluginControl)"); + Assert.False((bool) EvaluateLua("success")!); + Assert.Equal(0L, EvaluateLua("#messages")); + Assert.Equal(0L, EvaluateLua("okClicks")); + Assert.Equal(string.Empty, EvaluateLua("response")); + } + + [Fact] + public void LivePluginBridge_Enable_UsesThePreviouslyDisabledState() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(Stubs("9\nenable\n", "CheatEngine.Mcp.dll CheatEngine.Mcp")); + RunPluginBridge("pluginEnabled=false; pluginControl()"); + Assert.True((bool) EvaluateLua("pluginEnabled")!); + Assert.Equal("ok\n9\nenable", EvaluateLua("response")); + Assert.Equal(1L, EvaluateLua("okClicks")); + } + + private static string Stubs(string command, string row, int count = 1) => $$""" + response=''; messages={}; okClicks=0 + io={open=function(_,mode) if mode=='r' then return {read=function() return {{Quote(command)}} end,close=function() end} end return {write=function(_,v) response=response..v end,close=function() end} end} + os={remove=function() return true end,rename=function() return true end} + reloadSettingsFromRegistry=function() end + local items={[0]={{Quote(row)}},Count={{count}}} + local clb={Items=items,Handle=99} + local pc,tab,ok={},{},{doClick=function() okClicks=okClicks+1 end} + local f={findComponentByName=function(n) return ({pcSetting=pc,Plugins=tab,clbPlugins=clb,btnOK=ok})[n] end} + getSettingsForm=function() return f end + sendMessage=function(...) table.insert(messages,{...}) end + """; + + private static void RunPluginBridge(string tail) => InstallStubs(LivePluginBridge.Render("request", "response", Quote) + "\n" + tail); + + private static string Quote(string value) => '"' + value.Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal) + '"'; +} From 207d6fdb62c4874185ba843f00add85da1438d1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Thu, 8 Oct 2026 03:04:47 +0200 Subject: [PATCH 07/12] Log private stack diagnostics for unknown native dispatch failures --- .../Execution/ToolDispatch.cs | 16 +++++ .../Core/ToolDispatchTests.cs | 60 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/libs/CheatEngine.Mcp.Core/Execution/ToolDispatch.cs b/libs/CheatEngine.Mcp.Core/Execution/ToolDispatch.cs index fd258dd..8e54f75 100644 --- a/libs/CheatEngine.Mcp.Core/Execution/ToolDispatch.cs +++ b/libs/CheatEngine.Mcp.Core/Execution/ToolDispatch.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using System.Text.Json.Serialization.Metadata; @@ -128,6 +129,17 @@ public T Run(string operation, Func body, CancellationT { return ToolExecution.Run(Client, body, probe, cancellationToken); } + catch (CheatEngineToolException exception) when (exception.Error.Kind is ToolErrorKind.HostRefused && + exception.Error.Operation is "Dispatcher.Invoke" && + exception.Error.HostEffect is ToolHostEffect.Unknown && + exception.InnerException is not null) + { + // Keep native dispatch diagnostics private, and omit exception messages, data and source-file paths. + Exception cause = exception.InnerException; + LogDispatcherFault(_logger, operation, cause.GetType().FullName ?? cause.GetType().Name, + new StackTrace(cause, false).ToString()); + throw; + } catch (CheatEngineToolException exception) when (exception.Error.Kind is ToolErrorKind.Internal && exception.InnerException is not null) { @@ -339,4 +351,8 @@ private void Measure(string operation, long invoked, DispatchProbe probe) [LoggerMessage(EventId = 3004, Level = LogLevel.Error, Message = "Dispatch {Operation} failed with an unexpected exception; reported as internal.")] private static partial void LogUnexpectedFault(ILogger logger, string operation); + + [LoggerMessage(EventId = 3005, Level = LogLevel.Error, + Message = "Dispatch {Operation} failed in the native dispatcher with {ExceptionType}; host effect is unknown. Stack: {Stack}")] + private static partial void LogDispatcherFault(ILogger logger, string operation, string exceptionType, string stack); } diff --git a/tests/CheatEngine.Mcp.Tests/Core/ToolDispatchTests.cs b/tests/CheatEngine.Mcp.Tests/Core/ToolDispatchTests.cs index aeda096..1655666 100644 --- a/tests/CheatEngine.Mcp.Tests/Core/ToolDispatchTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Core/ToolDispatchTests.cs @@ -1,3 +1,4 @@ +using System.Runtime.CompilerServices; using System.Text.Json.Serialization.Metadata; using CheatEngine.Client; @@ -84,6 +85,65 @@ public void Run_ClientFailureInsideBody_KeepsItsKindOperationAndHostEffect() Assert.Empty(harness.Logger.Entries); } + [Theory] + [InlineData("Dispatcher.Invoke", CheatEngineHostEffect.Unknown, true, true)] + [InlineData("Dispatcher.Invoke", CheatEngineHostEffect.Unknown, false, false)] + [InlineData("Dispatcher.Invoke", CheatEngineHostEffect.NotStarted, true, false)] + [InlineData("Memory.Allocate", CheatEngineHostEffect.Unknown, true, false)] + public void Run_NativeDispatchFailure_LogsOnlyPrivateTypeAndStackWhenOutcomeIsUnknown( + string operation, CheatEngineHostEffect effect, bool includeCause, bool expectDiagnostic) + { + const string privateData = "private-source-or-token-must-not-be-logged"; + Harness harness = new(); + Exception cause = CaptureDispatcherFault(privateData); + cause.Data["sensitive"] = privateData; + CheatEngineFailure failure = new(CheatEngineFailureKind.OperationRejected, operation, + "The native dispatcher refused the operation.", exception: includeCause ? cause : null, hostEffect: effect); + harness.Dispatcher.Admission = token => failure.Throw(token); + bool ran = false; + + CheatEngineToolException exception = Assert.Throws(() => + harness.Dispatch.Run("memory_allocate", _ => ran = true, CancellationToken.None)); + + Assert.False(ran); + Assert.Equal(1, harness.Dispatcher.Calls); + Assert.Equal(ToolErrorKind.HostRefused, exception.Error.Kind); + Assert.Equal(operation, exception.Error.Operation); + Assert.Equal(failure.Message, exception.Error.Message); + Assert.Equal(effect.ToString(), exception.Error.HostEffect.ToString()); + Assert.False(exception.Error.Retryable); + Assert.Same(includeCause ? cause : null, exception.InnerException); + Assert.Equal(0, harness.Statistics.Snapshot().Count); + if (expectDiagnostic) + { + (LogLevel level, EventId eventId, string message, Exception? logged) = Assert.Single(harness.Logger.Entries); + Assert.Equal((LogLevel.Error, 3005), (level, eventId.Id)); + Assert.Null(logged); + Assert.Contains(typeof(InvalidOperationException).FullName!, message, StringComparison.Ordinal); + Assert.Contains(nameof(CaptureDispatcherFault), + message, StringComparison.Ordinal); + Assert.DoesNotContain(privateData, message, StringComparison.Ordinal); + Assert.DoesNotContain("ToolDispatchTests.cs", message, StringComparison.Ordinal); + } + else + { + Assert.Empty(harness.Logger.Entries); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static InvalidOperationException CaptureDispatcherFault(string message) + { + try + { + throw new InvalidOperationException(message); + } + catch (InvalidOperationException exception) + { + return exception; + } + } + [Fact] public void Run_ToolExceptionInsideBody_PassesUnchanged() { From 4c0698981f50ee4867bc1efe08a62867fd5c89c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Thu, 8 Oct 2026 03:32:21 +0200 Subject: [PATCH 08/12] Preserve qualification failures and isolate native dispatch diagnostics --- .../v2.0.0/compiler-extended-policy.md | 2 +- .../v2.0.0/remaining-checkpoint.md | 129 +++++++++++++++ .../v2.0.0/requirement-matrix.md | 24 +-- .../LiveDispatchDiagnosticQualification.cs | 80 ++++++++++ .../LiveLifecycleQualification.cs | 147 ++++++++++++++---- .../LiveLifecycleQualificationTests.cs | 68 +++++++- .../LiveQualificationOptIn.cs | 3 +- .../LiveQualificationOptInTests.cs | 1 + .../LiveQualificationScenario.cs | 3 +- .../LiveQualification/LiveSandboxOptions.cs | 5 + .../LiveSandboxOptionsTests.cs | 28 ++++ .../LiveQualification/LiveSandboxSession.cs | 2 +- .../LiveWorkflowQualification.cs | 67 ++++++-- .../McpLiveQualificationTests.cs | 5 + 14 files changed, 505 insertions(+), 59 deletions(-) create mode 100644 docs/qualification/v2.0.0/remaining-checkpoint.md create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs diff --git a/docs/qualification/v2.0.0/compiler-extended-policy.md b/docs/qualification/v2.0.0/compiler-extended-policy.md index feacb90..96c82af 100644 --- a/docs/qualification/v2.0.0/compiler-extended-policy.md +++ b/docs/qualification/v2.0.0/compiler-extended-policy.md @@ -8,6 +8,6 @@ Status: harness preparation only. No native execution is approved by this docume * shared run-owned TEMP topology, to observe B shutdown and a reviewed plugin reload; and * a one-shot bounded post-`compileCS` hold that writes an owned ready receipt, waits at most ten seconds for an owned release file, then returns the original values unchanged. The harness may stop B while the production `exec_compile_csharp` call is held, then release in `finally`. Cancellation, timeout, missing release, or an unexpected file is a stop condition and never retries compilation. -The hold bridge accepts only fixed `armHold` and `releaseHold` commands; it cannot choose source, paths, target, or process. It never deletes, renames, or copies CE output. +The hold bridge accepts a fixed `armHold` command and waits for a fixed release file; neither can choose source, paths, target, or process. It never deletes, renames, or copies CE output. `compiler-injection` remains disabled pending a third exact acknowledgement and separate review. A fixed .NET Framework fixture must publish hash, PID, architecture, and CLR identity before one fixed `exec_inject_dotnet` call may be considered. The existing .NET 10 target is not evidence for that prerequisite. diff --git a/docs/qualification/v2.0.0/remaining-checkpoint.md b/docs/qualification/v2.0.0/remaining-checkpoint.md new file mode 100644 index 0000000..75c6d19 --- /dev/null +++ b/docs/qualification/v2.0.0/remaining-checkpoint.md @@ -0,0 +1,129 @@ +# Remaining qualification checkpoint + +Date: 2026-10-08. +Status: Reviewed local evidence; native corruption remains a release blocker. No roadmap closure or publication is claimed. + +## Candidate identity + +The tested checkout was clean at `d3984574e0d0757beebd3ea36deb6f9bef7358d1`. +The tested binaries identify as `2.0.0-beta.2+d3984574e0d0757beebd3ea36deb6f9bef7358d1`. +The staged Release plugin SHA-256 is `C87E44C8D68C54A660E1CFDB9208D411253D6AEE5BA9C5DFDBA8B41BF0682A12`. +The staged Release gateway SHA-256 is `F52824F10B0C33775B5B8BB9C8C9B95094A1CDA73AF51DD0B8C87B60C98F1DC8`. +The local Release ZIP SHA-256 is `F08D289243A285C294F386DD85331BEFB82F206602E289F9882191E2584AD37B`. +The ZIP contains five flat deployment files and is 17,717,063 bytes. +The candidate remains beta-versioned and is not an RC or stable release payload. +The d398457 logs identify the test process as `net10.0|x64`, but these receipts do not emit the exact Windows build or .NET runtime patch. +Promotion into tracked release evidence must join those environment fields from the reviewed candidate preflight rather than infer them here. + +## Offline and package evidence + +| Evidence | Result | Safe source | +| --- | --- | --- | +| Locked restore | Passed for the nine-project solution restore. | `artifacts/issue-edits/d398457-restore.log` | +| Debug build | Passed with zero warnings and zero errors in 8.34 seconds. | `artifacts/issue-edits/d398457-Debug-build.log` | +| Release build | Passed with zero warnings and zero errors in 7.58 seconds. | `artifacts/issue-edits/d398457-Release-build.log` | +| Debug portable tests | Passed 4,062 of 4,062 with zero failures and zero skips in 1 minute 41.709 seconds. | `artifacts/issue-edits/d398457-Debug-tests.log` | +| Release portable tests | Passed 4,062 of 4,062 with zero failures and zero skips in 1 minute 48.044 seconds. | `artifacts/issue-edits/d398457-Release-tests.log` | +| NativeLua | Passed 618 of 618 with zero failures and zero skips in 2.845 seconds. | `artifacts/issue-edits/d398457-native-lua.log` | +| Format | Style and whitespace commands completed with empty logs. | `artifacts/issue-edits/d398457-format-style.log` and `d398457-format-whitespace.log` | +| Release packaged DLL | The actual Release DLL passed both isolated reflection comparisons with 193 tools, 43 resources, 15 templates, and 43 prompts, followed by `PLUGIN_BUNDLE_PROBE_OK`. | `artifacts/issue-edits/d398457-publish-release.log` | +| Release Native AOT gateway | Native code generation and the executable MCP checks completed for the recorded Release gateway bytes. | `artifacts/issue-edits/d398457-publish-release.log` | +| Release distribution | The five staged files, embedded identities, ZIP inventory, and checksum verification passed. | `artifacts/issue-edits/d398457-publish-release.log`, `d398457-package.log`, and `d398457-stage.log` | + +The recorded Release distribution contains `CheatEngine.Mcp.dll`, `CheatEngine.Mcp.Gateway.exe`, `LICENSE`, `README.md`, and `THIRD-PARTY-NOTICES.md`. +Debug publication and its actual packaged-DLL, Native AOT, and distribution checks remain pending. +These results do not qualify native workflows, installation, interactive clients, CI, soak, or a stable release. + +## Native lifecycle and workflow result + +Evidence ID `LIFECYCLE-X64-01` is run `20261008T005457Z-838e` against the recorded `d3984574e0d0757beebd3ea36deb6f9bef7358d1` Release plugin and gateway hashes. +The native host was Cheat Engine x64 `7.7.1.10828` with executable SHA-256 `CF6CCC664A90C23531EC20F5C41458CFDC1D04679D6D05ED94C06BCECF95544F`. +The run used two private hosts, two owned x64 targets, the ModelContextProtocol .NET test client, and protocol `2025-06-18`. +The native summary leaves `TargetSha256` empty, so this checkpoint does not claim a target-binary hash. +The test process reported failure after 19.257 seconds, with the failing test body reporting 18.373 seconds. + +The packaged client checks reached 55 resources, 26 templates, and 43 prompts, but this is not interactive-client qualification. +All four execution gates were disabled for the workflow run. +The bounded memory cases passed integer round trips, partial batch reporting, a 16-byte snapshot, SHA-256 hashing, file-policy refusal, protection restoration, snapshot deletion, and both owned frees. +The independent scanner passed `unknown`, `changed`, `increased`, `decreased`, `reset`, and `between` transitions, then deleted the scanner and freed its owned region. +The bounded AOB cases found the exact pattern in 25 milliseconds, the wildcard pattern in 24 milliseconds, and the `int32` value pattern in 25 milliseconds, each as one exact unique target-verified result. + +The run then failed before the control-flow-graph fixture allocation completed. +`memory_allocate` returned `host_refused` with message `The Lua stack does not contain the inputs required by the protected operation.`. +The recorded operation was `Dispatcher.Invoke`, `hostEffect` was `unknown`, and `retryable` was `false`. +The CFG cleanup receipt records `freed=false` and zero cleanup failures because no owned CFG allocation was obtained. +The run stopped both owned hosts and targets, observed enabled and disabled lifecycle indicators for both hosts, restored user state, preserved the source installation, and reported no cleanup failures. +The summary correctly records `passed=false`. +This failed run is evidence only for the completed memory, scanner, AOB, and cleanup cases, and it is not lifecycle-wide or catalog-wide qualification. + +## x64 performance evidence + +The performance command passed one aggregate test in 1 minute 50.909 seconds. +Each run used two private Cheat Engine instances and measured full stdio request time, response parsing, and value assertion after five unrecorded warm-up pairs. +Each run recorded 100 `memory_read int32` calls and 30 `memory_read_batch` calls with 16 `int32` requests per batch. +The p95 calculation used nearest rank, and candidate limits used the maximum p95 across the three published beta.2 baseline runs. + +| Run | Package | Short p50 / p95 / max in ms | Batch p50 / p95 / max in ms | Outcome | +| --- | --- | --- | --- | --- | +| `20261008T005646Z-8411` | Published beta.2 baseline 1 | 9.2240 / 10.5650 / 62.5282 | 9.0115 / 10.4505 / 10.7427 | Passed; cleanup restored and source unchanged. | +| `20261008T005705Z-1b65` | Published beta.2 baseline 2 | 9.7902 / 10.9172 / 61.0560 | 9.3845 / 10.7567 / 11.0492 | Passed; cleanup restored and source unchanged. | +| `20261008T005723Z-469f` | Published beta.2 baseline 3 | 9.7945 / 11.8246 / 61.5980 | 7.2177 / 9.7169 / 10.0798 | Passed; cleanup restored and source unchanged. | +| `20261008T005741Z-c9b1` | `d398457` candidate 1 | 9.5969 / 11.8029 / 45.0871 | 9.5876 / 10.4264 / 10.7751 | Both p95 decisions passed; cleanup restored and source unchanged. | +| `20261008T005800Z-d335` | `d398457` candidate 2 | 9.1233 / 11.7610 / 44.2625 | 8.3695 / 9.4961 / 9.5099 | Both p95 decisions passed; cleanup restored and source unchanged. | +| `20261008T005819Z-99f0` | `d398457` candidate 3 | 6.9373 / 8.3891 / 34.2782 | 6.2625 / 7.4094 / 7.8098 | Both p95 decisions passed; cleanup restored and source unchanged. | + +The selected baseline short p95 was 11.8246 milliseconds, producing a candidate limit of 111.8246 milliseconds. +The selected baseline batch p95 was 10.7567 milliseconds, producing a candidate limit of 16.1351 milliseconds. +Every candidate run passed both recorded p95 limits. +Every baseline and candidate summary records `passed=true`, `userStateRestored=true`, `sourceInstallationUnchanged=true`, and zero cleanup failures. +The published beta.2 baseline plugin SHA-256 is `5699ECA2ED06461846A977802DF3E4FA7BF52159F50AA1446C4819908A7692AE`. +The published beta.2 baseline gateway SHA-256 is `2E0E7F8512D8A8AAC0197E982C7660BFA4336EBDB8981543817B709381C83885`. +These measurements qualify only the two recorded x64 memory workloads. +They do not qualify regions, scans, pointers, symbols, structures, dissection, instance discovery, busy responses, jobs, completions, live resources, blocking-native calls, UI responsiveness, resource recovery thresholds, or the two-hour soak. + +## Diagnostic candidate 207d6fd + +The diagnostic candidate is commit `207d6fdb62c4874185ba843f00add85da1438d1f` with product version `2.0.0-beta.2+207d6fdb62c4874185ba843f00add85da1438d1f`. +The staged Release plugin SHA-256 is `8588CBAC6665CAB094AC40DA0A6B9257835F7B93B5931E6DB1E2EDB9E45F1E26`. +The staged Release gateway SHA-256 is `BD9D4DFAA3A535A0AA388AA570A19E3A2B6D3B17BE09E74888716A28DBCA10F0`. +The local Release ZIP SHA-256 is `AC1E71B09F101BD9A347A7045F52A9795D1F076C1E4D617A305E9E6E847B513E`, and the ZIP is 17,726,054 bytes. +The Release build passed with zero warnings and zero errors in 7.20 seconds. +The Release portable suite passed 4,066 of 4,066 tests with zero failures and zero skips in 1 minute 50.370 seconds. +The actual Release DLL passed two isolated reflection comparisons with 193 tools, 43 resources, 15 templates, and 43 prompts, followed by `PLUGIN_BUNDLE_PROBE_OK`. +The Release Native AOT gateway build and the five-file flat ZIP inventory, checksum, and package verification passed. +These offline and package results are recorded in `artifacts/issue-edits/207d6fd-build.log`, `207d6fd-tests.log`, `207d6fd-publish-release.log`, `207d6fd-package.log`, and `207d6fd-stage.log`. + +Evidence ID `DIAGNOSTIC-X64-01` is native run `20261008T010811Z-ded4` against these exact Release plugin and gateway hashes. +The native host was Cheat Engine x64 `7.7.1.10828` with executable SHA-256 `CF6CCC664A90C23531EC20F5C41458CFDC1D04679D6D05ED94C06BCECF95544F`. +The run again used two private hosts, two owned x64 targets, the ModelContextProtocol .NET test client, and protocol `2025-06-18`. +The summary leaves `TargetSha256` empty, so this checkpoint does not claim a target-binary hash. +The memory cases and their cleanup passed before the crash, including protection restoration, snapshot deletion, both owned frees, and zero memory cleanup failures. +The scanner completed the `unknown`, `changed`, `increased`, `decreased`, `reset`, and `between` transitions, then deleted the scanner and freed its owned region with zero scanner cleanup failures. + +Host A then crashed during `AobTools.FindValue` with fatal error `0xC0000005`. +The recorded fatal path begins `LuaApi.lua_settop`, `LuaState.SetTop`, `LuaFrame.Dispose`, `MainThreadDispatcher.Dispatch`, and the client dispatch path before reaching `AobTools.FindValue`. +The intended diagnostic 3005 was not reached, so this candidate did not establish a root cause or a fix. +The test body failed after 20.882 seconds, and the assembly failed after 21.596 seconds. +No successful AOB result is claimed for this run. + +The summary records `userStateRestored=true`, `sourceInstallationUnchanged=true`, and `passed=false`. +AOB cleanup attempted to free its owned region and recorded one failure; successful release was not confirmed before owned-target shutdown. +The sole `cleanupFailures` entry says host A lacks the expected enabled and disabled MCP menu status indicators because the host crashed. +Host B recorded both MCP and SDK enabled and disabled lifecycle indicators, while host A recorded neither complete indicator pair. +Both host-stop receipts are present. +The parent post-run CIM inventory found no owned Cheat Engine or LiveTarget processes left running. +That process inventory is separate from the failed lifecycle-evidence cleanup check and does not turn the run into a cleanup pass. +Further native execution is paused pending a mechanism-level diagnosis of the dispatcher and Lua-frame failure. + +## Pending work and candidate boundary + +The diagnostic candidate `207d6fd` has one failed native x64 run and is not a root-cause fix. +Lifecycle x64 requires mechanism-level diagnosis and a fresh candidate rerun from the earliest failing AOB boundary before returning to the later CFG boundary. +Lifecycle x86, the broader workflow matrix, two-hour soak, installation, upgrade, rollback, named interactive clients, CI and analysis, extended compiler lifetime cases, and managed injection remain pending. +Debug publication remains pending. +The final version change, exact stable package rebuild, final native and installation repetition, tag, draft upload, publication, public download, and shipped first-use smoke remain pending. +Only the narrowly completed subcases above may be checked off. The broader workflow, lifecycle, and release requirements remain open. + +## Follow-up preparation + +The lifecycle harness now preserves the original workflow failure alongside cleanup faults and admits success only after gateway cleanup. Cleanup receipt flags say attempted rather than claiming successful release from an allocation-created flag. Focused portable tests also enforce the fixed soak counts and diagnostic scenario admission. An isolated, explicitly selected dispatch diagnostic reuses the original AOB-only, scanner-then-AOB, and memory/scanner-then-AOB workflows in fresh sessions, stopping on the first failure. That diagnostic has not been executed in this checkpoint. diff --git a/docs/qualification/v2.0.0/requirement-matrix.md b/docs/qualification/v2.0.0/requirement-matrix.md index 1d80a8e..f111fc9 100644 --- a/docs/qualification/v2.0.0/requirement-matrix.md +++ b/docs/qualification/v2.0.0/requirement-matrix.md @@ -1,14 +1,16 @@ # Stable v2.0.0 requirement matrix Date: 2026-10-08. -Status: Crosswalk of the 121 currently unchecked roadmap entries; it is not a completion claim. +Status: Crosswalk of the 121 roadmap entries that were unchecked when this matrix was created; subsequent evidence and checkbox updates are recorded in the linked checkpoints. pass means the complete requirement has evidence at the level stated, with final-candidate repetitions separate when named. partial means useful evidence exists but one or more clauses remain unproved. not_run means no execution evidence exists for the requirement. environment_dependency means completion requires an approved policy, controlled environment, actual client, runtime, hardware, or external release boundary. -The clean tested compiler candidate is 808a626e65eddab113737a27a7c2345555b1c241, still identified as beta.2. +The compiler checkpoint used clean candidate 808a626e65eddab113737a27a7c2345555b1c241, still identified as beta.2. +Later offline and two-workload x64 performance results are recorded in [remaining-checkpoint.md](remaining-checkpoint.md). +The later workflow runs exposed native Lua dispatch corruption, including a private-host crash. Lifecycle and soak remain blocked until a demonstrated fix is verified; the completed subcases do not override this release blocker. The historical pointer smoke used Release plugin SHA-256 E6E2AE56B43CF54F1F5978B566EE762939B76E549D557FDDF8B073BB8262750B and is not evidence for the clean compiler candidate. The observed environment is Windows 11 Pro x64 build 10.0.26300, Cheat Engine x64 7.7.1.10828, and .NET runtime 10.0.12. No row generalizes that environment to another Windows, CE, runtime, target, or client. @@ -24,7 +26,7 @@ No row generalizes that environment to another Windows, CE, runtime, target, or | 2.07 | Write the beta-to-stable compatibility policy: additive evolution, intentional breaking changes, configuration migration, and future deprecations. | pass | support-matrix.md and acceptance.md define additive compatibility, breaking changes, migration, and tag handling. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | | 2.08 | Define release-stopping defects: crashes, corruption, cross-instance/target effects, unreported partial writes, leaked state, broken installation, and wrong outcome reporting. | pass | support-matrix.md defines the release-stopping defect classes. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | | 2.09 | Set the RC bug-bash/soak duration, supported environments, exercised workloads, and pass/fail criteria; record results rather than infer stability from tool counts. | pass | acceptance.md defines the two-hour two-instance workload and pass or fail criteria. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | -| 2.10 | Agree measurable performance and responsiveness thresholds from section 9's baselines, including workload sizes, measurement method, and allowed exceptions for blocking native calls. | partial | acceptance.md defines proposed measured-relative p95 budgets and blocking-native exceptions, but no three-run baseline exists. | Run and review the three baselines before approving the budgets. | release coordinator | +| 2.10 | Agree measurable performance and responsiveness thresholds from section 9's baselines, including workload sizes, measurement method, and allowed exceptions for blocking native calls. | partial | acceptance.md defines measured-relative budgets; remaining-checkpoint.md records three baseline and three candidate sessions for two x64 memory workloads. | Complete and review the other workload baselines, UI responsiveness, and final-candidate repetitions. | release coordinator | | 3.01 | Reconcile catalog constants, registrations, generated snapshots, tool map, workflow prompts, and packaged-plugin reflection output for the final candidate. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Reconcile final names, schemas, prompts, resources, workflows, tool map, and packaged DLL on the exact RC. | contract owner | | 3.02 | Verify schema compatibility with reflection-disabled JSON and the published Native AOT gateway, including every new input/result type. | pass | contract-checkpoint.md names resolver parity, AOT, packaged reflection, and the clean candidate result. | Complete the named audit gap and repeat V+ on the exact RC and final stable commit. | contract owner | | 3.03 | Verify nullable/unavailable results remain distinct from legitimate zero/empty values. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Audit unavailable, null, zero, empty string, and empty collection results across every domain. | contract owner | @@ -75,14 +77,14 @@ No row generalizes that environment to another Windows, CE, runtime, target, or | 8.22 | Design separate controlled managed fixtures for .NET, Mono, and IL2CPP and record the exact collector/runtime versions. | not_run | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | | 8.23 | Define and review any new qualification policy for debugger/injection/compiler/managed-invocation/kernel work. The current harness explicitly excludes kernel, hypervisor, and code-execution probes; this issue does not remove that restriction. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | | 8.24 | Record unqualified optional capabilities explicitly, with follow-ups or experimental support status; do not claim universal native compatibility from the existing smoke scenario. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | -| 9.01 | Establish reproducible latency/UI-responsiveness baselines for short reads, large batches, regions, scans, pointers, symbols, structures, dissection, and instance discovery. | not_run | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Run three reproducible baseline measurements with fixed workload sizes and environment identity. | performance qualification owner | -| 9.02 | Choose practical acceptance budgets per dispatch class and document operations that can block CE or outlive an MCP timeout. | partial | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Approve planned budgets after baseline review and enumerate blocking-native exceptions. | performance qualification owner | -| 9.03 | Check dispatch concurrency/busy responses under multiple clients without starving CE's main thread. | not_run | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Measure multiple-client admission and busy latency while monitoring CE main-thread responsiveness. | performance qualification owner | -| 9.04 | Verify large jobs stay within configured job/buffer/TTL/result bounds and expose truncation or dropped data. | partial | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | -| 9.05 | Verify expensive completions/live-resource reads share admission limits and cannot turn ordinary client navigation into an unbounded scan. | partial | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | -| 9.06 | Exercise sustained polling and repeated enable/disable cycles; check managed/native memory, handles, sockets, retained jobs, and event subscriptions. | not_run | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Run the two-hour soak and compare handles, private bytes, sockets, jobs, and subscriptions to baseline. | performance qualification owner | -| 9.07 | Keep symbol waits, whole-heap searches, unsupported cancellation, and long native calls explicit rather than masking them with a successful timeout response. | partial | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | -| 9.08 | Document tuning defaults and measured tradeoffs; raising concurrency must not be described as making CE's main thread parallel. | partial | acceptance.md records reviewed budgets and workload, but no performance or soak result. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | +| 9.01 | Establish reproducible latency/UI-responsiveness baselines for short reads, large batches, regions, scans, pointers, symbols, structures, dissection, and instance discovery. | partial | remaining-checkpoint.md records the three-run x64 single-read and 16-read-batch baselines; the other workloads and UI responsiveness remain unmeasured. | Run three reproducible baseline measurements with fixed workload sizes and environment identity. | performance qualification owner | +| 9.02 | Choose practical acceptance budgets per dispatch class and document operations that can block CE or outlive an MCP timeout. | partial | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Approve planned budgets after baseline review and enumerate blocking-native exceptions. | performance qualification owner | +| 9.03 | Check dispatch concurrency/busy responses under multiple clients without starving CE's main thread. | not_run | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Measure multiple-client admission and busy latency while monitoring CE main-thread responsiveness. | performance qualification owner | +| 9.04 | Verify large jobs stay within configured job/buffer/TTL/result bounds and expose truncation or dropped data. | partial | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | +| 9.05 | Verify expensive completions/live-resource reads share admission limits and cannot turn ordinary client navigation into an unbounded scan. | partial | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | +| 9.06 | Exercise sustained polling and repeated enable/disable cycles; check managed/native memory, handles, sockets, retained jobs, and event subscriptions. | not_run | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Run the two-hour soak and compare handles, private bytes, sockets, jobs, and subscriptions to baseline. | performance qualification owner | +| 9.07 | Keep symbol waits, whole-heap searches, unsupported cancellation, and long native calls explicit rather than masking them with a successful timeout response. | partial | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | +| 9.08 | Document tuning defaults and measured tradeoffs; raising concurrency must not be described as making CE's main thread parallel. | partial | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | | 10.01 | Qualify a clean installation from the exact candidate ZIP on a supported Windows environment using only the documented deployment files. | not_run | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Install only the exact candidate ZIP into a clean private CE copy and record first-use and cleanup. | installation and client owner | | 10.02 | Verify CE version/architecture and all required .NET 10 host frameworks; provide actionable diagnostics for missing/mismatched runtime configuration. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | | 10.03 | Qualify paths with spaces, a standard user account, valid file/table roots, and read-only deployment locations where supported. | environment_dependency | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Use a standard-user environment and an owned read-only fixture; do not infer these cases from administrator runs. | installation and client owner | diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs new file mode 100644 index 0000000..34d9d77 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs @@ -0,0 +1,80 @@ +using System.Runtime.Versioning; +using System.Text.Json.Nodes; + +using CheatEngine.Mcp.Core.Contract; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Reproduces a bounded AOB dispatch ordering issue in fresh disposable sessions; it is not qualification evidence. +[SupportedOSPlatform("windows")] +internal static class LiveDispatchDiagnosticQualification +{ + internal static async Task RunAsync(LiveQualificationInputs inputs) + { + Assert.Equal(LiveQualificationScenario.DispatchDiagnostic, inputs.Scenario); + foreach (LiveDispatchDiagnosticCase @case in Enum.GetValues()) + { + await RunCaseAsync(inputs, @case); + } + } + + private static async Task RunCaseAsync(LiveQualificationInputs inputs, LiveDispatchDiagnosticCase @case) + { + LiveSandboxSession? sandbox = null; + LiveLifecycleSession? connection = null; + Exception? failure = null; + List cleanup = []; + try + { + sandbox = await LiveSandboxSession.StartAsync(inputs, LiveSandboxOptions.Smoke with + { + DispatchDiagnostic = true + }); + connection = await LiveLifecycleSession.ConnectAsync(sandbox); + await connection.AttachBothAsync(); + sandbox.Record("dispatch_diagnostic_case", new + { + @case = @case.ToString(), + step = "before_dispatch" + }); + await LiveWorkflowQualification.VerifyDispatchDiagnosticAsync(sandbox, connection.Gateway, @case); + foreach (LiveSandboxHost host in new[] { sandbox.HostA, sandbox.HostB }) + { + JsonNode value = (await connection.Gateway.Bind(host.InstanceId).CallToolAsync(CheatEngineToolNames.MemoryRead, + new Dictionary { ["address"] = host.TargetAddress, ["valueType"] = "int32" }))!; + Assert.Equal("20260926", value["value"]!.GetValue()); + } + sandbox.Record("dispatch_diagnostic_case", new + { + @case = @case.ToString(), + step = "completed" + }); + } + catch (Exception exception) + { + failure = exception; + throw; + } + finally + { + await LiveLifecycleQualification.DisposeOrderedAsync(failure, + connection is null ? null : connection.DisposeAsync, + sandbox is null ? null : exceptions => + { + sandbox.Record("dispatch_diagnostic_gateway_cleanup_failure", exceptions.Select(exception => exception.ToString()).ToArray()); + return ValueTask.CompletedTask; + }, + sandbox is null ? null : () => { sandbox.MarkPassed(); return ValueTask.CompletedTask; }, + sandbox is null ? null : sandbox.DisposeAsync, + cleanup); + LiveLifecycleQualification.ThrowWithCleanup(failure, cleanup); + } + } +} + +internal enum LiveDispatchDiagnosticCase +{ + AobOnly, + NamedScanThenAob, + MemoryNamedScanThenAob +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualification.cs index 639b40d..8200df7 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualification.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualification.cs @@ -9,42 +9,129 @@ internal static class LiveLifecycleQualification internal static async Task RunAsync(LiveQualificationInputs inputs) { Assert.Equal(LiveQualificationScenario.Lifecycle, inputs.Scenario); - await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs, - LiveSandboxOptions.Smoke with + LiveSandboxSession? sandbox = null; + LiveLifecycleSession? connection = null; + Exception? failure = null; + List cleanup = []; + try + { + sandbox = await LiveSandboxSession.StartAsync(inputs, LiveSandboxOptions.Smoke with { LifecycleQualification = true }); - await using LiveLifecycleSession connection = await LiveLifecycleSession.ConnectAsync(sandbox); - await connection.AttachBothAsync(); - await connection.VerifyBothAsync(); - await LiveResourceQualification.VerifyAsync(sandbox, connection.Gateway); - await LiveWorkflowQualification.VerifyAsync(sandbox, connection.Gateway); - for (int cycle = 0; cycle < 3; cycle++) + connection = await LiveLifecycleSession.ConnectAsync(sandbox); + await connection.AttachBothAsync(); + await connection.VerifyBothAsync(); + await LiveResourceQualification.VerifyAsync(sandbox, connection.Gateway); + await LiveWorkflowQualification.VerifyAsync(sandbox, connection.Gateway); + for (int cycle = 0; cycle < 3; cycle++) + { + await connection.CyclePluginAsync("A"); + } + await sandbox.RestartTargetAsync("A"); + await connection.AttachBothAsync(); + await connection.VerifyBothAsync(); + await connection.RestartGatewayAsync(); + await connection.VerifyBothAsync(); + LiveSandboxHost before = sandbox.HostA; + LiveSandboxHost unaffected = sandbox.HostB; + await sandbox.RestartHostAsync("A"); + RequireHostRestart(before, sandbox.HostA, sandbox.HostB); + Assert.Equal(unaffected, sandbox.HostB); + await connection.RequireInstancesAsync(sandbox.HostA.InstanceId, sandbox.HostB.InstanceId); + await connection.RejectStaleAsync(before.InstanceId); + await connection.AttachBothAsync(); + await connection.VerifyBothAsync(); + sandbox.Record("lifecycle_workload", new + { + pluginCycles = 3, + targetRestarts = 1, + gatewayRestarts = 1, + hostRestarts = 1 + }); + } + catch (Exception exception) + { + failure = exception; + throw; + } + finally + { + await DisposeOrderedAsync(failure, + connection is null ? null : connection.DisposeAsync, + sandbox is null ? null : exceptions => + { + sandbox.Record("lifecycle_connection_cleanup_failure", exceptions.Select(exception => exception.ToString()).ToArray()); + return ValueTask.CompletedTask; + }, + sandbox is null ? null : () => { sandbox.MarkPassed(); return ValueTask.CompletedTask; }, + sandbox is null ? null : sandbox.DisposeAsync, + cleanup); + ThrowWithCleanup(failure, cleanup); + } + } + + internal static async Task DisposeOrderedAsync(Exception? failure, Func? disposeConnection, + Func, ValueTask>? recordConnectionCleanup, Func? markPassed, + Func? disposeSandbox, List cleanup) + { + if (disposeConnection is not null) + { + try + { + await disposeConnection(); + } + catch (Exception exception) + { + cleanup.Add(exception); + } + } + if (cleanup.Count != 0 && recordConnectionCleanup is not null) { - await connection.CyclePluginAsync("A"); + try + { + await recordConnectionCleanup(cleanup); + } + catch (Exception exception) + { + cleanup.Add(exception); + } + } + if (failure is null && cleanup.Count == 0 && markPassed is not null) + { + try + { + await markPassed(); + } + catch (Exception exception) + { + cleanup.Add(exception); + } + } + if (disposeSandbox is not null) + { + try + { + await disposeSandbox(); + } + catch (Exception exception) + { + cleanup.Add(exception); + } + } + } + + internal static void ThrowWithCleanup(Exception? failure, List cleanup) + { + if (cleanup.Count == 0) + { + return; } - await sandbox.RestartTargetAsync("A"); - await connection.AttachBothAsync(); - await connection.VerifyBothAsync(); - await connection.RestartGatewayAsync(); - await connection.VerifyBothAsync(); - LiveSandboxHost before = sandbox.HostA; - LiveSandboxHost unaffected = sandbox.HostB; - await sandbox.RestartHostAsync("A"); - RequireHostRestart(before, sandbox.HostA, sandbox.HostB); - Assert.Equal(unaffected, sandbox.HostB); - await connection.RequireInstancesAsync(sandbox.HostA.InstanceId, sandbox.HostB.InstanceId); - await connection.RejectStaleAsync(before.InstanceId); - await connection.AttachBothAsync(); - await connection.VerifyBothAsync(); - sandbox.Record("lifecycle_workload", new + if (failure is null) { - pluginCycles = 3, - targetRestarts = 1, - gatewayRestarts = 1, - hostRestarts = 1 - }); - sandbox.MarkPassed(); + throw new AggregateException("Live lifecycle cleanup failed.", cleanup); + } + throw new AggregateException("Live lifecycle failed and cleanup also failed.", [failure, .. cleanup]); } internal static void RequirePluginReload(LiveSandboxHost before, LiveSandboxHost after) diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualificationTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualificationTests.cs index 2a43811..41282a7 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualificationTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveLifecycleQualificationTests.cs @@ -2,6 +2,66 @@ namespace CheatEngine.Mcp.Tests.LiveQualification; public sealed class LiveLifecycleQualificationTests { + [Fact] + public void ThrowWithCleanup_PreservesWorkflowFailure() + { + InvalidOperationException workflow = new("workflow transport failed"); + InvalidOperationException cleanup = new("lifecycle evidence incomplete"); + AggregateException combined = Assert.Throws(() => + LiveLifecycleQualification.ThrowWithCleanup(workflow, [cleanup])); + Assert.Equal([workflow, cleanup], combined.InnerExceptions); + } + + [Fact] + public void ThrowWithCleanup_ThrowsCleanupWhenWorkflowSucceeded() + { + InvalidOperationException cleanup = new("lifecycle evidence incomplete"); + AggregateException combined = Assert.Throws(() => + LiveLifecycleQualification.ThrowWithCleanup(null, [cleanup])); + Assert.Equal([cleanup], combined.InnerExceptions); + } + + [Fact] + public void ThrowWithCleanup_DoesNotMaskOriginalFailureWhenCleanupSucceeded() + { + InvalidOperationException workflow = new("workflow transport failed"); + Assert.Null(Record.Exception(() => LiveLifecycleQualification.ThrowWithCleanup(workflow, []))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DisposeOrderedAsync_ConnectionFailureSkipsPassAndStillDisposesSandbox(bool workflowFailed) + { + List calls = []; + List cleanup = []; + InvalidOperationException? workflow = workflowFailed ? new("workflow failed") : null; + InvalidOperationException gateway = new("gateway cleanup failed"); + await LiveLifecycleQualification.DisposeOrderedAsync(workflow, + () => throw gateway, + exceptions => { calls.Add($"record:{exceptions.Count}"); return ValueTask.CompletedTask; }, + () => { calls.Add("pass"); return ValueTask.CompletedTask; }, + () => { calls.Add("sandbox"); return ValueTask.CompletedTask; }, cleanup); + Assert.Equal(["record:1", "sandbox"], calls); + AggregateException combined = Assert.Throws(() => + LiveLifecycleQualification.ThrowWithCleanup(workflow, cleanup)); + Assert.Equal(workflow is null ? [gateway] : new Exception[] { workflow, gateway }, combined.InnerExceptions); + } + + [Fact] + public async Task DisposeOrderedAsync_SuccessMarksPassedOnlyAfterConnectionCleanup() + { + List calls = []; + List cleanup = []; + await LiveLifecycleQualification.DisposeOrderedAsync(null, + () => { calls.Add("connection"); return ValueTask.CompletedTask; }, + _ => throw new InvalidOperationException("Unexpected cleanup failure record."), + () => { calls.Add("pass"); return ValueTask.CompletedTask; }, + () => { calls.Add("sandbox"); return ValueTask.CompletedTask; }, cleanup); + Assert.Equal(["connection", "pass", "sandbox"], calls); + Assert.Empty(cleanup); + } + [Fact] public void PerformanceProfile_UsesRecordedBaselineForCandidateThresholds() { @@ -50,10 +110,10 @@ public void PerformanceProfile_RejectsCandidateWithoutThreeBaselineRuns() public void SoakSchedule_ContainsEveryFixedWorkloadAndFitsTheReviewedDeadline() { IReadOnlyList schedule = LiveSoakQualification.CreateSchedule(); - Assert.Equal(LiveSoakQualification.RequiredShortCalls, schedule.Count(action => action.Kind == LiveSoakActionKind.ShortCall)); - Assert.Equal(LiveSoakQualification.RequiredBoundedJobs, schedule.Count(action => action.Kind == LiveSoakActionKind.BoundedJob)); - Assert.Equal(LiveSoakQualification.RequiredPluginCycles, schedule.Count(action => action.Kind == LiveSoakActionKind.PluginCycle)); - Assert.Equal(LiveSoakQualification.RequiredTargetRestarts, schedule.Count(action => action.Kind == LiveSoakActionKind.TargetRestart)); + Assert.Equal(1_000, schedule.Count(action => action.Kind == LiveSoakActionKind.ShortCall)); + Assert.Equal(20, schedule.Count(action => action.Kind == LiveSoakActionKind.BoundedJob)); + Assert.Equal(30, schedule.Count(action => action.Kind == LiveSoakActionKind.PluginCycle)); + Assert.Equal(10, schedule.Count(action => action.Kind == LiveSoakActionKind.TargetRestart)); LiveSoakAction gatewayRestart = Assert.Single(schedule, action => action.Kind == LiveSoakActionKind.GatewayRestart); Assert.Equal(LiveSoakActionKind.GatewayRestart, gatewayRestart.Kind); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs index 354e09a..47b3c30 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs @@ -56,11 +56,12 @@ internal static LiveQualificationDecision Evaluate(Func variabl "lifecycle" => LiveQualificationScenario.Lifecycle, "performance" => LiveQualificationScenario.Performance, "soak" => LiveQualificationScenario.Soak, + "dispatch-diagnostic" => LiveQualificationScenario.DispatchDiagnostic, _ => null }; if (selectedScenario is null) { - return Refuse($"{ScenarioVariable} must be smoke, compiler, compiler-extended, compiler-injection, lifecycle, performance, or soak."); + return Refuse($"{ScenarioVariable} must be smoke, compiler, compiler-extended, compiler-injection, lifecycle, performance, soak, or dispatch-diagnostic."); } LiveQualificationScenario scenario = selectedScenario.Value; diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs index d451353..a69a4ca 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs @@ -172,6 +172,7 @@ public void Evaluate_MissingOrRelativeInstallation_Refuses(string installation) [InlineData("lifecycle", LiveQualificationScenario.Lifecycle)] [InlineData("performance", LiveQualificationScenario.Performance)] [InlineData("soak", LiveQualificationScenario.Soak)] + [InlineData("dispatch-diagnostic", LiveQualificationScenario.DispatchDiagnostic)] public void Evaluate_StandardQualificationScenario_UsesOnlyStandardAcknowledgement(string name, int expected) { Dictionary variables = Authorized(); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs index 0778c20..59efe92 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationScenario.cs @@ -9,5 +9,6 @@ internal enum LiveQualificationScenario CompilerInjection, Lifecycle, Performance, - Soak + Soak, + DispatchDiagnostic } diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs index 624d335..402e0be 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs @@ -27,6 +27,10 @@ internal bool UseSoakLifetime { get; init; } + internal bool DispatchDiagnostic + { + get; init; + } internal string? DistributionDirectoryOverride { get; init; @@ -46,6 +50,7 @@ internal void ValidateFor(LiveQualificationScenario scenario) && LifecycleQualification == (scenario == LiveQualificationScenario.Lifecycle) && PerformanceQualification == (scenario == LiveQualificationScenario.Performance) && SoakQualification == (scenario == LiveQualificationScenario.Soak) + && DispatchDiagnostic == (scenario == LiveQualificationScenario.DispatchDiagnostic) && UseSoakLifetime == SoakQualification && (extended || injection || (Variant == LiveCompilerVariant.Normal && TempTopology == CompilerTempTopology.Isolated)) && (!injection || (Variant == LiveCompilerVariant.HeldExportAfterB && TempTopology == CompilerTempTopology.Shared)) diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptionsTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptionsTests.cs index fda2b94..a1c8d67 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptionsTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptionsTests.cs @@ -30,6 +30,10 @@ public void ValidateFor_ExactScenarioOptions_Accept() SoakQualification = true, UseSoakLifetime = true }).ValidateFor(LiveQualificationScenario.Soak); + (LiveSandboxOptions.Smoke with + { + DispatchDiagnostic = true + }).ValidateFor(LiveQualificationScenario.DispatchDiagnostic); } [Fact] @@ -44,6 +48,7 @@ public void ValidateFor_SmokeRejectsEveryExtraCapabilityAndLifetime() LiveSandboxOptions.Smoke with { LifecycleQualification = true }, LiveSandboxOptions.Smoke with { PerformanceQualification = true }, LiveSandboxOptions.Smoke with { SoakQualification = true }, + LiveSandboxOptions.Smoke with { DispatchDiagnostic = true }, LiveSandboxOptions.Smoke with { UseSoakLifetime = true } ]; foreach (LiveSandboxOptions options in invalid) @@ -52,6 +57,29 @@ public void ValidateFor_SmokeRejectsEveryExtraCapabilityAndLifetime() } } + [Fact] + public void ValidateFor_DispatchDiagnosticRejectsExecutionAndUnrelatedScenarios() + { + LiveSandboxOptions diagnostic = LiveSandboxOptions.Smoke with + { + DispatchDiagnostic = true + }; + LiveSandboxOptions[] invalid = + [ + diagnostic with { EnableTargetCodeExecution = true }, + diagnostic with { EnableManagedInjection = true }, + diagnostic with { CompilerQualification = true }, + diagnostic with { LifecycleQualification = true }, + diagnostic with { PerformanceQualification = true }, + diagnostic with { SoakQualification = true, UseSoakLifetime = true }, + diagnostic with { DistributionDirectoryOverride = Path.GetTempPath() } + ]; + foreach (LiveSandboxOptions options in invalid) + { + Assert.Throws(() => options.ValidateFor(LiveQualificationScenario.DispatchDiagnostic)); + } + } + [Fact] public void ValidateFor_MissingScenarioFlagsAndUnknownValues_Refuse() { diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs index 5019800..ac9145a 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs @@ -526,7 +526,7 @@ private async Task StartHostAsync(string repository, string name) Directory.CreateDirectory(pluginDirectory); File.Copy(Path.Combine(pluginSource, PluginFileName), Path.Combine(pluginDirectory, PluginFileName)); bool capabilityConfiguration = _options.CompilerQualification || _options.LifecycleQualification - || _options.PerformanceQualification || _options.SoakQualification; + || _options.PerformanceQualification || _options.SoakQualification || _options.DispatchDiagnostic; if (capabilityConfiguration) { string[] allowedRoots = _options.CompilerQualification ? [CompilerRoots(name).Output] : []; diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs index 8fa2e5a..383fb53 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs @@ -32,7 +32,7 @@ internal static async Task VerifyAsync(LiveSandboxSession sandbox, LiveMcpClient await VerifyStructuresAsync(sandbox, instance); } - private static async Task VerifyGatesAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) + internal static async Task VerifyGatesAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) { JsonNode runtime = await CallAsync(instance, CheatEngineToolNames.RuntimeGetInfo); JsonNode? gates = runtime["gates"]; @@ -226,10 +226,10 @@ await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.Memor sandbox.Record("live_workflow_memory_cleanup", new { - protectionRestored = previousProtection is not null, - snapshotDeleted = snapshotCreated, - destinationFreed = destinationAllocated, - sourceFreed = sourceAllocated, + protectionRestoreAttempted = previousProtection is not null, + snapshotDeleteAttempted = snapshotCreated, + destinationFreeAttempted = destinationAllocated, + sourceFreeAttempted = sourceAllocated, failures = cleanup.Count }); ThrowCleanup(failure, cleanup); @@ -300,8 +300,8 @@ await CleanupAsync(cleanup, async () => sandbox.Record("live_workflow_scanner_cleanup", new { - deleted = created, - freed = allocated, + deleteAttempted = created, + freeAttempted = allocated, failures = cleanup.Count }); ThrowCleanup(failure, cleanup); @@ -323,6 +323,10 @@ private static async Task VerifyAobAsync(LiveSandboxSession sandbox, LiveMcpInst await WriteAsync(instance, At(address, 32), "int32", "16909060"); Dictionary args = ScanBounds(address); args["patterns"] = new[] { "D1 2B 7E 93 44 A8 19 C6", "D1 2B ?? 93 44 A8 19 C6" }; + sandbox.Record("live_workflow_aob_step", new + { + step = "before_aob_find" + }); JsonNode found = await CallAsync(instance, CheatEngineToolNames.AobFind, args); Assert.Equal(2, found["results"]!.AsArray().Count); Assert.Equal("D1 2B 7E 93 44 A8 19 C6", Text(found["results"]![0], "pattern")); @@ -335,6 +339,10 @@ private static async Task VerifyAobAsync(LiveSandboxSession sandbox, LiveMcpInst Dictionary value = ScanBounds(address); value["valueType"] = "int32"; value["value"] = "16909060"; + sandbox.Record("live_workflow_aob_step", new + { + step = "before_aob_find_value" + }); JsonNode foundValue = await CallAsync(instance, CheatEngineToolNames.AobFindValue, value); Assert.Equal("04 03 02 01", Text(foundValue["result"], "pattern")); AssertAob(foundValue["result"]!, At(address, 32)); @@ -346,7 +354,7 @@ private static async Task VerifyAobAsync(LiveSandboxSession sandbox, LiveMcpInst }); } catch (Exception exception) { failure = exception; throw; } - finally { if (allocated) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = allocation })); } sandbox.Record("live_workflow_aob_cleanup", new { freed = allocated, failures = cleanup.Count }); ThrowCleanup(failure, cleanup); } + finally { if (allocated) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = allocation })); } sandbox.Record("live_workflow_aob_cleanup", new { freeAttempted = allocated, failures = cleanup.Count }); ThrowCleanup(failure, cleanup); } } private static async Task VerifyControlFlowAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) @@ -387,7 +395,7 @@ private static async Task VerifyControlFlowAsync(LiveSandboxSession sandbox, Liv }); } catch (Exception exception) { failure = exception; throw; } - finally { if (allocated) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = allocation })); } sandbox.Record("live_workflow_cfg_cleanup", new { freed = allocated, failures = cleanup.Count }); ThrowCleanup(failure, cleanup); } + finally { if (allocated) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = allocation })); } sandbox.Record("live_workflow_cfg_cleanup", new { freeAttempted = allocated, failures = cleanup.Count }); ThrowCleanup(failure, cleanup); } } private static async Task VerifyModulesAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) @@ -482,7 +490,46 @@ private static async Task VerifyStructuresAsync(LiveSandboxSession sandbox, Live }); } catch (Exception exception) { failure = exception; throw; } - finally { if (structure) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.StructureDelete, new Dictionary { ["name"] = name })); } if (b) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = second })); } if (a) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = first })); } sandbox.Record("live_workflow_structures_cleanup", new { deleted = structure, freedB = b, freedA = a, failures = cleanup.Count }); ThrowCleanup(failure, cleanup); } + finally { if (structure) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.StructureDelete, new Dictionary { ["name"] = name })); } if (b) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = second })); } if (a) { await CleanupAsync(cleanup, () => CallAsync(instance, CheatEngineToolNames.MemoryFree, new Dictionary { ["name"] = first })); } sandbox.Record("live_workflow_structures_cleanup", new { deleteAttempted = structure, freeBAttempted = b, freeAAttempted = a, failures = cleanup.Count }); ThrowCleanup(failure, cleanup); } + } + + /// Reuses selected bounded workflows in a fresh diagnostic session; this does not qualify the full matrix. + internal static async Task VerifyDispatchDiagnosticAsync(LiveSandboxSession sandbox, LiveMcpClient gateway, + LiveDispatchDiagnosticCase @case) + { + LiveMcpInstanceClient instance = gateway.Bind(sandbox.HostA.InstanceId); + await VerifyGatesAsync(sandbox, instance); + if (@case == LiveDispatchDiagnosticCase.MemoryNamedScanThenAob) + { + sandbox.Record("dispatch_diagnostic_step", new + { + @case = @case.ToString(), + step = "before_memory" + }); + await VerifyMemoryAsync(sandbox, instance); + } + if (@case is LiveDispatchDiagnosticCase.NamedScanThenAob or LiveDispatchDiagnosticCase.MemoryNamedScanThenAob) + { + sandbox.Record("dispatch_diagnostic_step", new + { + @case = @case.ToString(), + step = "before_named_scan" + }); + await VerifyScannerAsync(sandbox, instance); + } + sandbox.Record("dispatch_diagnostic_step", new + { + @case = @case.ToString(), + step = "before_aob" + }); + await VerifyAobAsync(sandbox, instance); + sandbox.Record("dispatch_diagnostic_result", new + { + @case = @case.ToString(), + aobToolCalls = 2, + nativeScans = 3, + stableQualification = false + }); } private static Dictionary ScanArguments(string name, string address, string comparison) => new() diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs index 4f34a17..6372716 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs @@ -46,6 +46,11 @@ public async Task OneGateway_TwoOwnedInstances_RoutesStateAndSurvivesOneHostShut await LiveManagedInjectionQualification.RunAsync(inputs); return; } + if (inputs.Scenario == LiveQualificationScenario.DispatchDiagnostic) + { + await LiveDispatchDiagnosticQualification.RunAsync(inputs); + return; + } if (inputs.Scenario != LiveQualificationScenario.Smoke) { throw new InvalidOperationException("The selected qualification runner has not been integrated yet; no host was started."); From 09d76d7defec28756e621e167731f47efaff2f96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Thu, 8 Oct 2026 03:44:25 +0200 Subject: [PATCH 09/12] Align diagnostic settings staging with its verification --- .../LiveQualification/LiveSandboxOptions.cs | 3 +++ .../LiveQualification/LiveSandboxSession.cs | 8 ++------ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs index 402e0be..dffce7a 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxOptions.cs @@ -5,6 +5,9 @@ internal sealed record LiveSandboxOptions(bool CompilerQualification, bool Enabl { internal static LiveSandboxOptions Smoke { get; } = new(false, false); + internal bool RequiresCapabilityConfiguration => CompilerQualification || LifecycleQualification + || PerformanceQualification || SoakQualification || DispatchDiagnostic; + internal LiveCompilerVariant Variant { get; init; } = LiveCompilerVariant.Normal; internal CompilerTempTopology TempTopology { get; init; } = CompilerTempTopology.Isolated; internal bool EnableManagedInjection diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs index ac9145a..f47f023 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.cs @@ -525,9 +525,7 @@ private async Task StartHostAsync(string repository, string name) RequireInsideRun(pluginDirectory); Directory.CreateDirectory(pluginDirectory); File.Copy(Path.Combine(pluginSource, PluginFileName), Path.Combine(pluginDirectory, PluginFileName)); - bool capabilityConfiguration = _options.CompilerQualification || _options.LifecycleQualification - || _options.PerformanceQualification || _options.SoakQualification || _options.DispatchDiagnostic; - if (capabilityConfiguration) + if (_options.RequiresCapabilityConfiguration) { string[] allowedRoots = _options.CompilerQualification ? [CompilerRoots(name).Output] : []; File.WriteAllText(Path.Combine(pluginDirectory, "appsettings.json"), JsonSerializer.Serialize(new @@ -754,9 +752,7 @@ private void ValidateDistribution(string distribution) private void VerifyDistributionStaging(string name, string pluginSource, string pluginDirectory) { string[] pluginFiles = RelativeFiles(pluginDirectory); - bool capabilityConfiguration = _options.CompilerQualification || _options.LifecycleQualification - || _options.PerformanceQualification || _options.SoakQualification; - string[] expectedPluginFiles = capabilityConfiguration + string[] expectedPluginFiles = _options.RequiresCapabilityConfiguration ? [PluginFileName, "appsettings.json"] : [PluginFileName]; if (!pluginFiles.SequenceEqual(expectedPluginFiles, StringComparer.Ordinal) From 4645853b8cef6fca1026da0b08894dbd7bc806b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Thu, 8 Oct 2026 03:49:35 +0200 Subject: [PATCH 10/12] Record isolated dispatcher diagnostic evidence --- .../v2.0.0/remaining-checkpoint.md | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/docs/qualification/v2.0.0/remaining-checkpoint.md b/docs/qualification/v2.0.0/remaining-checkpoint.md index 75c6d19..b7ad0f3 100644 --- a/docs/qualification/v2.0.0/remaining-checkpoint.md +++ b/docs/qualification/v2.0.0/remaining-checkpoint.md @@ -113,12 +113,43 @@ Host B recorded both MCP and SDK enabled and disabled lifecycle indicators, whil Both host-stop receipts are present. The parent post-run CIM inventory found no owned Cheat Engine or LiveTarget processes left running. That process inventory is separate from the failed lifecycle-evidence cleanup check and does not turn the run into a cleanup pass. -Further native execution is paused pending a mechanism-level diagnosis of the dispatcher and Lua-frame failure. +This production-package failure remains unresolved. + +## Private dispatcher instrumentation + +The bounded diagnostic package uses MCP product base `4c0698981f50ee4867bc1efe08a62867fd5c89c3` and the latest harness commit `09d76d7defec28756e621e167731f47efaff2f96`. +The product base passed 4,074 of 4,074 Release portable tests with zero failures and zero skips. +The plugin privately pins `CheatEngine.SDK` version `2.0.1-mcpdiagnostic.1` from SDK base `325c47b573f8bd39a247f1d0101f110fa36c1696` with reviewed local instrumentation. +The private SDK package SHA-256 is `1BFC46AA5377464351B2F50967C0BEC23D7DB90A25B7AB7EDF2EE1EC1CFC9B2C`. +The SDK tracked patch SHA-256 is `682DA09724C37C9AC401951A76EFD63B27F675C7C81FFF667F4F063547F79FD7`, and the new trace-source SHA-256 is `4177B62FCD29AA5C465B45DC0DF19EFE65DF98810F75F70B77164AA2983F5773`. +The exact embedded SDK assembly hashes are recorded in `artifacts/issue-edits/dispatch-diagnostic-provenance.json`. +Strict SDK build and pack completed with zero errors, and the focused SDK suites passed 11 `MainThreadTests` plus 53 `AobBoundedScanTests` with zero failures or skips. +The private MCP package passed both isolated packaged-DLL comparisons, `PLUGIN_BUNDLE_PROBE_OK`, Native AOT publication, and five-file ZIP verification. +Its plugin SHA-256 is `3906E707D8C11947664E384FCA09E43CB1EA2451368D48E87D78C08D5485314A`, gateway SHA-256 is `A04CF2303D77BF7B2F560F1ABFC876C00D57C990330565599BE2981543D184C8`, and ZIP SHA-256 is `0F5A84AD54421AC06CF869F2ADBEE6E7B1C0322E8F63ADAA22613CCEC6BDAF8E`. + +The first diagnostic attempt, run `20261008T014302Z-328d`, failed before either host or target launched because the staging predicate did not admit the explicitly selected diagnostic configuration. +That failed attempt records `passed=false`, `userStateRestored=true`, `sourceInstallationUnchanged=true`, and an empty `cleanupFailures` array. +The shared `RequiresCapabilityConfiguration` predicate was corrected in harness commit `09d76d7defec28756e621e167731f47efaff2f96`; 83 focused portable tests passed, and the bounded correction received independent Astra review. + +Three subsequent fresh x64 diagnostic cases passed against the same private instrumented package. + +| Run | Case | Completed evidence | Cleanup | +| --- | --- | --- | --- | +| `20261008T014451Z-4fb2` | `AobOnly` | Two AOB tool calls completed three exact, unique, bounded, target-verified native scans. | State restored, source unchanged, both host lifecycle pairs observed, cleanup failures empty. | +| `20261008T014508Z-1e67` | `NamedScanThenAob` | The six named-scan transitions completed before the same two AOB calls and three native scans. | State restored, source unchanged, both host lifecycle pairs observed, cleanup failures empty. | +| `20261008T014525Z-4e08` | `MemoryNamedScanThenAob` | The bounded memory cases, six named-scan transitions, two AOB calls, and three native scans completed. | State restored, source unchanged, both host lifecycle pairs observed, cleanup failures empty. | + +The authoritative summaries and per-host diagnostic logs are under `C:\Users\Shadow\AppData\Local\Packages\OpenAI.Codex_2p2nqsd0c76g0\LocalCache\Local\CheatEngine.Mcp.LiveQualification\runs\`. +The parent post-run CIM inventory found no owned Cheat Engine or LiveTarget processes left after these attempts. +The trace field named `top` records the frame's saved top, not a contemporaneous Lua stack top measurement. +The added logging can change timing, so these passing diagnostic cases neither identify the root cause nor prove a production fix. +Each of the three passing summaries explicitly records `stableQualification=false`. +The full original-order instrumented probe remains pending. ## Pending work and candidate boundary The diagnostic candidate `207d6fd` has one failed native x64 run and is not a root-cause fix. -Lifecycle x64 requires mechanism-level diagnosis and a fresh candidate rerun from the earliest failing AOB boundary before returning to the later CFG boundary. +The reviewed bounded diagnostic mechanism has now run, but production lifecycle x64 remains blocked on mechanism-level diagnosis and an uninstrumented production candidate rerun through the original ordering. Lifecycle x86, the broader workflow matrix, two-hour soak, installation, upgrade, rollback, named interactive clients, CI and analysis, extended compiler lifetime cases, and managed injection remain pending. Debug publication remains pending. The final version change, exact stable package rebuild, final native and installation repetition, tag, draft upload, publication, public download, and shipped first-use smoke remain pending. @@ -126,4 +157,7 @@ Only the narrowly completed subcases above may be checked off. The broader workf ## Follow-up preparation -The lifecycle harness now preserves the original workflow failure alongside cleanup faults and admits success only after gateway cleanup. Cleanup receipt flags say attempted rather than claiming successful release from an allocation-created flag. Focused portable tests also enforce the fixed soak counts and diagnostic scenario admission. An isolated, explicitly selected dispatch diagnostic reuses the original AOB-only, scanner-then-AOB, and memory/scanner-then-AOB workflows in fresh sessions, stopping on the first failure. That diagnostic has not been executed in this checkpoint. +The lifecycle harness preserves the original workflow failure alongside cleanup faults and admits success only after gateway cleanup. +Cleanup receipt flags say attempted rather than claiming successful release from an allocation-created flag. +Focused portable tests enforce the fixed soak counts and diagnostic scenario admission. +The isolated dispatcher diagnostic has now passed its three bounded fresh-session cases, while the original full ordering, root-cause proof, production fix, and production-package qualification remain open. From 0f722ef7d6b37eee0985073dfe594c6559b31c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Thu, 8 Oct 2026 03:57:34 +0200 Subject: [PATCH 11/12] Select bounded dispatch cases and record native bridge provenance --- .../v2.0.0/remaining-checkpoint.md | 6 +- .../LiveDispatchDiagnosticQualification.cs | 5 +- .../LiveQualificationInputs.cs | 8 ++- .../LiveQualificationOptIn.cs | 23 ++++++- .../LiveQualificationOptInTests.cs | 62 +++++++++++++++++++ 5 files changed, 99 insertions(+), 5 deletions(-) diff --git a/docs/qualification/v2.0.0/remaining-checkpoint.md b/docs/qualification/v2.0.0/remaining-checkpoint.md index b7ad0f3..61d710c 100644 --- a/docs/qualification/v2.0.0/remaining-checkpoint.md +++ b/docs/qualification/v2.0.0/remaining-checkpoint.md @@ -142,9 +142,11 @@ Three subsequent fresh x64 diagnostic cases passed against the same private inst The authoritative summaries and per-host diagnostic logs are under `C:\Users\Shadow\AppData\Local\Packages\OpenAI.Codex_2p2nqsd0c76g0\LocalCache\Local\CheatEngine.Mcp.LiveQualification\runs\`. The parent post-run CIM inventory found no owned Cheat Engine or LiveTarget processes left after these attempts. The trace field named `top` records the frame's saved top, not a contemporaneous Lua stack top measurement. -The added logging can change timing, so these passing diagnostic cases neither identify the root cause nor prove a production fix. +The private `.1` package also used the SDK tag's checked-in native Lua bridge, SHA-256 `889DC4C231D182F9B7BAA9E29880555AAD949F42023DDE232FE327542C3C5387`, whereas the published SDK 2.0.0 package contains bridge `B008C8D8C136187F241542E6223DC0831999D8300DC2C4C01E1CF49F6FBA7698`. +Both managed instrumentation and native bridge bytes therefore changed in these three runs. The added logging can change timing, and different bridge hashes alone do not establish a functional difference or ABI defect. +These passing diagnostic cases neither isolate the effect of instrumentation nor identify the root cause or prove a production fix. Each of the three passing summaries explicitly records `stableQualification=false`. -The full original-order instrumented probe remains pending. +A controlled follow-up will restore the exact published bridge while retaining the managed trace patch and the previously passing `MemoryNamedScanThenAob` workload. The full original-order instrumented probe remains pending after that comparison. ## Pending work and candidate boundary diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs index 34d9d77..f33347d 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs @@ -12,7 +12,10 @@ internal static class LiveDispatchDiagnosticQualification internal static async Task RunAsync(LiveQualificationInputs inputs) { Assert.Equal(LiveQualificationScenario.DispatchDiagnostic, inputs.Scenario); - foreach (LiveDispatchDiagnosticCase @case in Enum.GetValues()) + IEnumerable selected = inputs.DispatchDiagnosticCase is { } selectedCase + ? [selectedCase] + : Enum.GetValues(); + foreach (LiveDispatchDiagnosticCase @case in selected) { await RunCaseAsync(inputs, @case); } diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs index 22e5cfc..aeb3c77 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationInputs.cs @@ -1,4 +1,10 @@ namespace CheatEngine.Mcp.Tests.LiveQualification; internal sealed record LiveQualificationInputs(string RepositoryRoot, string CheatEngineDirectory, string RunRoot, - string TargetArchitecture, LiveQualificationScenario Scenario); + string TargetArchitecture, LiveQualificationScenario Scenario) +{ + internal LiveDispatchDiagnosticCase? DispatchDiagnosticCase + { + get; init; + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs index 47b3c30..cde219f 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs @@ -9,6 +9,7 @@ internal static class LiveQualificationOptIn internal const string RunRootVariable = "CHEATENGINE_MCP_LIVE_QUALIFICATION_RUN_ROOT"; internal const string TargetArchitectureVariable = "CHEATENGINE_MCP_LIVE_QUALIFICATION_TARGET_ARCHITECTURE"; internal const string ScenarioVariable = "CHEATENGINE_MCP_LIVE_QUALIFICATION_SCENARIO"; + internal const string DispatchDiagnosticCaseVariable = "CHEATENGINE_MCP_LIVE_DISPATCH_DIAGNOSTIC_CASE"; internal const string CodeExecutionOptInVariable = "CHEATENGINE_MCP_LIVE_CODE_EXECUTION_QUALIFICATION"; internal const string CodeExecutionAcknowledgement = "I_AUTHORIZE_FIXED_COMPILECS_PROBES_ON_PRIVATE_CE_AND_OWNED_TARGETS"; @@ -65,6 +66,23 @@ internal static LiveQualificationDecision Evaluate(Func variabl } LiveQualificationScenario scenario = selectedScenario.Value; + string? dispatchCaseValue = variables(DispatchDiagnosticCaseVariable); + if (scenario != LiveQualificationScenario.DispatchDiagnostic && dispatchCaseValue is not null) + { + return Refuse($"{DispatchDiagnosticCaseVariable} is valid only when {ScenarioVariable} is dispatch-diagnostic."); + } + LiveDispatchDiagnosticCase? dispatchCase = dispatchCaseValue switch + { + null => null, + "AobOnly" => LiveDispatchDiagnosticCase.AobOnly, + "NamedScanThenAob" => LiveDispatchDiagnosticCase.NamedScanThenAob, + "MemoryNamedScanThenAob" => LiveDispatchDiagnosticCase.MemoryNamedScanThenAob, + _ => (LiveDispatchDiagnosticCase?) null + }; + if (scenario == LiveQualificationScenario.DispatchDiagnostic && dispatchCaseValue is not null && dispatchCase is null) + { + return Refuse($"{DispatchDiagnosticCaseVariable} must be AobOnly, NamedScanThenAob, or MemoryNamedScanThenAob."); + } bool compiler = scenario is LiveQualificationScenario.Compiler or LiveQualificationScenario.CompilerExtended or LiveQualificationScenario.CompilerInjection; @@ -113,7 +131,10 @@ internal static LiveQualificationDecision Evaluate(Func variabl return new LiveQualificationDecision( new LiveQualificationInputs(Path.GetFullPath(repositoryRoot), Path.GetFullPath(source), - Path.GetFullPath(runRoot), architecture, scenario), null); + Path.GetFullPath(runRoot), architecture, scenario) + { + DispatchDiagnosticCase = dispatchCase + }, null); } internal static bool IsSameOrBelow(string path, string directory) diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs index a69a4ca..f3671c5 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs @@ -184,6 +184,68 @@ public void Evaluate_StandardQualificationScenario_UsesOnlyStandardAcknowledgeme Assert.Equal((LiveQualificationScenario) expected, decision.Inputs!.Scenario); } + [Theory] + [InlineData("AobOnly", (int) LiveDispatchDiagnosticCase.AobOnly)] + [InlineData("NamedScanThenAob", (int) LiveDispatchDiagnosticCase.NamedScanThenAob)] + [InlineData("MemoryNamedScanThenAob", (int) LiveDispatchDiagnosticCase.MemoryNamedScanThenAob)] + public void Evaluate_DispatchDiagnostic_UsesTheExactSelectedCase(string selected, + int expected) + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.ScenarioVariable] = "dispatch-diagnostic"; + variables[LiveQualificationOptIn.DispatchDiagnosticCaseVariable] = selected; + + LiveQualificationDecision decision = Evaluate(variables); + + Assert.True(decision.IsAuthorized, decision.Refusal); + Assert.Equal(LiveQualificationScenario.DispatchDiagnostic, decision.Inputs!.Scenario); + Assert.Equal((LiveDispatchDiagnosticCase) expected, decision.Inputs.DispatchDiagnosticCase); + } + + [Fact] + public void Evaluate_DispatchDiagnostic_WithoutCaseRunsEveryFixedCase() + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.ScenarioVariable] = "dispatch-diagnostic"; + + LiveQualificationDecision decision = Evaluate(variables); + + Assert.True(decision.IsAuthorized, decision.Refusal); + Assert.Null(decision.Inputs!.DispatchDiagnosticCase); + } + + [Theory] + [InlineData("")] + [InlineData("aobonly")] + [InlineData(" AobOnly")] + [InlineData("AobOnly ")] + [InlineData("1")] + public void Evaluate_DispatchDiagnostic_RefusesAnUnknownCaseBeforeDirectoryInspection(string selected) + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.ScenarioVariable] = "dispatch-diagnostic"; + variables[LiveQualificationOptIn.DispatchDiagnosticCaseVariable] = selected; + + LiveQualificationDecision decision = LiveQualificationOptIn.Evaluate(variables.GetValueOrDefault, + Repository, LocalData, Installation, _ => throw new InvalidOperationException("No directory inspection expected.")); + + Assert.False(decision.IsAuthorized); + Assert.Contains(LiveQualificationOptIn.DispatchDiagnosticCaseVariable, decision.Refusal, StringComparison.Ordinal); + } + + [Fact] + public void Evaluate_DispatchDiagnosticCaseOnAnotherScenario_RefusesBeforeDirectoryInspection() + { + Dictionary variables = Authorized(); + variables[LiveQualificationOptIn.DispatchDiagnosticCaseVariable] = "MemoryNamedScanThenAob"; + + LiveQualificationDecision decision = LiveQualificationOptIn.Evaluate(variables.GetValueOrDefault, + Repository, LocalData, Installation, _ => throw new InvalidOperationException("No directory inspection expected.")); + + Assert.False(decision.IsAuthorized); + Assert.Contains(LiveQualificationOptIn.DispatchDiagnosticCaseVariable, decision.Refusal, StringComparison.Ordinal); + } + [Theory] [InlineData("compiler-extended")] [InlineData("compiler-injection")] From fb8f04daa0d2f88c0b7cd11272958fbf10ecf40a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Fri, 9 Oct 2026 17:24:36 +0200 Subject: [PATCH 12/12] Harden MCP contracts and prepared navigation --- README.md | 5 +- docs/qualification/v2.0.0/acceptance.md | 4 +- .../v2.0.0/admission-preparation.md | 46 ++ .../v2.0.0/contract-checkpoint.md | 84 +- .../qualification/v2.0.0/lifecycle-key-fix.md | 77 ++ docs/qualification/v2.0.0/lifecycle-resume.md | 68 ++ docs/qualification/v2.0.0/mcp-closeout.md | 40 + .../v2.0.0/navigation-followup.md | 57 ++ .../v2.0.0/navigation-native-regression.md | 43 + .../v2.0.0/navigation-package.md | 31 + .../v2.0.0/navigation-preparation.md | 46 ++ .../v2.0.0/nested-dispatch-regression.md | 86 ++ .../v2.0.0/next-live-investigation.md | 39 + .../v2.0.0/offline-contract-audit.md | 56 ++ .../v2.0.0/performance-preparation.md | 59 ++ docs/qualification/v2.0.0/release-notes.md | 22 +- docs/qualification/v2.0.0/release-signoff.md | 29 +- .../v2.0.0/remaining-checkpoint.md | 308 ++++++- .../v2.0.0/requirement-matrix.md | 42 +- .../v2.0.0/settings-observation.md | 44 + .../v2.0.0/stack-trace-diagnostic.md | 71 ++ docs/qualification/v2.0.0/support-matrix.md | 2 + .../CheatEngineMcpBuilderExtensions.cs | 2 + .../Composition/McpCompletionCost.cs | 5 +- .../Composition/McpCompletionReads.cs | 30 +- .../McpContractRules.Primitives.cs | 66 +- .../Composition/McpContractRules.cs | 10 +- .../Composition/McpErrorCorrelation.cs | 27 +- .../Composition/McpResourceCompletions.cs | 51 +- .../Composition/McpResourceSource.cs | 47 +- .../Composition/McpSourceToolAttribute.cs | 19 +- .../Inspection/PreparedInspectionStore.cs | 177 ++++ libs/CheatEngine.Mcp.Core/Jobs/JobPoll.cs | 4 +- .../Jobs/LuaJobKernelScripts.cs | 2 +- .../CheatEngine.Mcp.Plugin.csproj | 8 + .../Knowledge/Documents/auto-assembler.md | 4 +- .../Knowledge/Documents/configuration.md | 7 +- .../Documents/connection-troubleshooting.md | 30 +- .../Knowledge/Documents/debugger.md | 2 + .../Documents/errors-and-recovery.md | 6 +- .../Knowledge/Documents/getting-started.md | 10 + .../Knowledge/Documents/kernel.md | 5 +- .../Knowledge/Documents/memory-model.md | 7 +- .../Knowledge/Documents/mono-and-dotnet.md | 3 + .../Knowledge/Documents/pointers.md | 11 +- .../Knowledge/Documents/safety.md | 5 +- .../Knowledge/Documents/speedhack.md | 2 + .../Knowledge/Documents/structures.md | 6 + .../Knowledge/Documents/tool-map.md | 34 +- .../Knowledge/Documents/workflows.md | 4 +- .../Knowledge/Workflows/cleanup-session.md | 2 + .../Knowledge/Workflows/dotnet-recon.md | 5 +- .../Live/DebuggerLiveResources.cs | 15 +- .../Live/MemoryLiveResources.cs | 11 +- .../Live/ModuleLiveResources.cs | 66 +- .../Live/ProcessLiveResources.cs | 9 +- .../Live/SymbolLiveResources.cs | 13 +- srcs/CheatEngine.Mcp.Resources/README.md | 28 +- srcs/CheatEngine.Mcp.Tools/Asm/AsmTools.cs | 4 +- srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs | 2 +- .../Debugger/DebuggerTools.cs | 189 ++++- .../DotNet/DotNetLuaScripts.cs | 6 +- .../DotNet/DotNetRecords.cs | 10 +- .../DotNet/DotNetTools.cs | 2 +- .../Kernel/KernelRecords.cs | 6 +- .../Kernel/KernelScripts.cs | 8 +- .../Kernel/KernelTools.cs | 6 +- srcs/CheatEngine.Mcp.Tools/Lua/LuaTools.cs | 2 +- .../Memory/MemoryFileTools.cs | 2 +- .../Memory/MemoryInfoTools.cs | 82 +- .../Memory/MemoryRecords.cs | 11 +- .../Memory/MemoryScripts.cs | 6 +- .../Modules/ModuleExportTools.cs | 4 +- .../Modules/ModuleImportTools.cs | 4 +- .../Modules/ModuleTools.cs | 131 ++- .../Pointer/PointerMapSlot.cs | 2 +- .../Pointer/PointerRecords.cs | 12 +- .../Pointer/PointerReferenceTools.cs | 75 +- .../Pointer/PointerScanSlot.cs | 2 +- .../Processes/ProcessTools.cs | 151 +++- .../Record/RecordClearTools.cs | 6 +- .../Record/RecordMutationTools.cs | 13 +- .../Runtime/RuntimeTools.cs | 4 +- .../CheatEngine.Mcp.Tools/Scan/ScanRecords.cs | 2 +- .../CheatEngine.Mcp.Tools/Scan/ScanScripts.cs | 13 +- .../Speedhack/SpeedhackJsonContext.cs | 1 + .../Speedhack/SpeedhackRecords.cs | 14 +- .../Speedhack/SpeedhackScripts.cs | 62 +- .../Speedhack/SpeedhackTools.cs | 61 +- .../Structures/StructureLuaScripts.cs | 74 +- .../Structures/StructureTools.cs | 10 +- .../Symbol/SymbolRegistrationTools.cs | 163 +++- .../Symbol/SymbolScripts.cs | 10 +- .../CheatEngine.Mcp.Tools/Table/TableTools.cs | 2 +- .../Contract/ExactValuePipelineTests.cs | 174 ++++ .../Contract/Golden/backend-resources.json | 76 +- .../Contract/Golden/backend-tools.json | 130 ++- .../Contract/Golden/gateway-resources.json | 76 +- .../Contract/Golden/gateway-tools.json | 130 ++- .../Contract/Golden/legacy-tool-map.txt | 2 +- .../Contract/Golden/open-world-tools.txt | 1 + .../Contract/Golden/tool-summary.txt | 40 +- .../Core/McpContractRulesTests.cs | 13 +- .../Core/PreparedInspectionStoreTests.cs | 129 +++ .../Core/PreparedProjectionContractTests.cs | 309 +++++++ .../Hosting/BackendCompletionTests.cs | 15 +- .../Hosting/ErrorCorrelationTests.cs | 113 ++- .../Hosting/GatewayCompletionTests.cs | 13 +- .../Hosting/GatewayResourceTests.cs | 4 +- .../LiveAdmissionComparison.cs | 154 ++++ .../LiveAdmissionComparisonTests.cs | 249 ++++++ .../LiveAdmissionMeasurement.cs | 278 +++++++ .../LiveAdmissionMeasurementTests.cs | 271 +++++++ .../LiveAdmissionQualification.cs | 206 +++++ .../LiveDispatchDiagnosticQualification.cs | 16 +- .../LiveQualification/LiveMcpClient.cs | 16 + .../LivePerformanceMatrixTests.cs | 128 +++ .../LivePerformanceQualification.cs | 758 +++++++++++++++--- .../LiveQualification/LivePluginBridge.cs | 44 +- .../LiveQualificationOptIn.cs | 3 +- .../LiveQualificationOptInTests.cs | 1 + .../LiveSandboxSession.Lifecycle.cs | 10 +- .../LiveWorkflowQualification.cs | 63 +- .../NativeLua/NativeLuaKernelV2Tests.cs | 22 + .../NativeLuaLivePluginBridgeTests.cs | 75 +- .../NativeLua/NativeLuaMainScannerFixture.cs | 12 +- .../NativeLua/NativeLuaMemoryAobTests.cs | 7 +- .../NativeLua/NativeLuaModuleSymbolTests.cs | 10 +- .../NativeLua/NativeLuaRecordClearTests.cs | 2 + .../NativeLuaRuntimeProcessScanV2Tests.cs | 61 +- .../NativeLua/NativeLuaSpeedhackToolsTests.cs | 87 ++ .../NativeLua/NativeLuaStructureTests.cs | 53 ++ .../Plugin/McpModuleLifecycleTests.cs | 100 +++ .../Resources/KnowledgeResourceTests.cs | 12 +- .../Resources/LiveCompletionTests.cs | 142 +++- .../Resources/LiveResourceTests.cs | 154 ++-- .../Support/StateTestHarness.cs | 6 + .../Tools/CodeAsmTableV2Tests.cs | 138 ++++ .../PreparedBreakpointNavigationTests.cs | 146 ++++ .../Tools/DotNet/DotNetToolsTests.cs | 14 +- .../Tools/Kernel/KernelToolsTests.cs | 25 + .../Tools/Memory/MemoryInfoToolsTests.cs | 128 ++- .../Tools/Modules/ModuleSymbolTarget.cs | 43 +- .../Tools/Modules/ModuleToolTests.cs | 149 +++- .../Tools/Pointer/PointerFileToolTests.cs | 8 + .../Tools/Pointer/PointerFixture.cs | 16 +- .../Tools/Pointer/PointerJobToolTests.cs | 7 + .../Pointer/PointerReferenceRetentionTests.cs | 125 +++ .../PreparedThreadNavigationTests.cs | 134 ++++ .../Processes/ProcessMonoAutoAttachTests.cs | 31 + .../Tools/Record/RecordToolsTests.cs | 53 ++ .../Tools/Speedhack/SpeedhackToolsTests.cs | 89 +- .../Tools/Structures/StructureToolsTests.cs | 27 +- .../Structures/StructureValueCodecTests.cs | 2 +- .../Symbol/PreparedSymbolNavigationTests.cs | 217 +++++ 155 files changed, 7712 insertions(+), 949 deletions(-) create mode 100644 docs/qualification/v2.0.0/admission-preparation.md create mode 100644 docs/qualification/v2.0.0/lifecycle-key-fix.md create mode 100644 docs/qualification/v2.0.0/lifecycle-resume.md create mode 100644 docs/qualification/v2.0.0/mcp-closeout.md create mode 100644 docs/qualification/v2.0.0/navigation-followup.md create mode 100644 docs/qualification/v2.0.0/navigation-native-regression.md create mode 100644 docs/qualification/v2.0.0/navigation-package.md create mode 100644 docs/qualification/v2.0.0/navigation-preparation.md create mode 100644 docs/qualification/v2.0.0/nested-dispatch-regression.md create mode 100644 docs/qualification/v2.0.0/next-live-investigation.md create mode 100644 docs/qualification/v2.0.0/offline-contract-audit.md create mode 100644 docs/qualification/v2.0.0/performance-preparation.md create mode 100644 docs/qualification/v2.0.0/settings-observation.md create mode 100644 docs/qualification/v2.0.0/stack-trace-diagnostic.md create mode 100644 libs/CheatEngine.Mcp.Core/Inspection/PreparedInspectionStore.cs create mode 100644 tests/CheatEngine.Mcp.Tests/Contract/ExactValuePipelineTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/Core/PreparedInspectionStoreTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/Core/PreparedProjectionContractTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionComparison.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionComparisonTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionMeasurement.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionMeasurementTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionQualification.cs create mode 100644 tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceMatrixTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/Tools/Debugger/PreparedBreakpointNavigationTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerReferenceRetentionTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/Tools/Processes/PreparedThreadNavigationTests.cs create mode 100644 tests/CheatEngine.Mcp.Tests/Tools/Symbol/PreparedSymbolNavigationTests.cs diff --git a/README.md b/README.md index 8a46477..602e21d 100644 --- a/README.md +++ b/README.md @@ -245,12 +245,15 @@ The [session workflows](srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/workf Plugin logs are written per Cheat Engine PID to `%APPDATA%\CheatEngine.Mcp\CheatEngine.Mcp..log`, or to `MCP_DATA_DIRECTORY` when set. A load failure can occur before that log exists. See [connection troubleshooting](srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/connection-troubleshooting.md) for status details and recovery steps. +Its [support report checklist](srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/connection-troubleshooting.md#support-report-checklist) lists the versions, error fields and redacted excerpts to include when reporting a problem. ### Updating or removing the installation To update, finish task cleanup, close Cheat Engine, stop the client's MCP connection, and replace the plugin DLL and gateway together from one build. Restart Cheat Engine, enable the plugin, reconnect the client, and refresh `instance_list`. User settings survive because they are outside the distribution. +Keep the previous matching package and client registration as a rollback copy. +For a locked gateway, partial upgrade or rollback, follow the [recovery steps](srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/connection-troubleshooting.md#several-instances-updates-and-removal) before resuming work. To remove the integration, release or manually restore task-owned state, disable the plugin, remove the gateway registration from the AI client, and then delete the deployment files. Disabling the plugin does not automatically undo every Cheat Engine-owned change. @@ -310,7 +313,7 @@ Each linked README documents its own project's layout, dependencies, and contrib ## Migration, contributing, and license This branch is a breaking redesign of CeMCP 1.x. -The plugin is deployed as a folder rather than a single DLL, tool names and result contracts have changed, and instance routing is explicit. +The plugin is now a single bundled DLL paired with the matching gateway executable; tool names and result contracts have changed, and instance routing is explicit. Consult the [live tool schema](srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/tool-map.md) when migrating scripts or prompts. Use CheatEngine.Mcp only on software you own or are authorized to modify. diff --git a/docs/qualification/v2.0.0/acceptance.md b/docs/qualification/v2.0.0/acceptance.md index c77c449..8e7b31c 100644 --- a/docs/qualification/v2.0.0/acceptance.md +++ b/docs/qualification/v2.0.0/acceptance.md @@ -125,8 +125,10 @@ Upgrade qualification must replace beta.2 and any older folder deployment withou Install, upgrade, disable, uninstall, and rollback must preserve user configuration, tables, unrelated plugins, Cheat Engine settings, and recoverable backups. Paths with spaces, a standard user account, allowed roots, read-only deployment behavior, malformed configuration, occupied endpoints, and unavailable log locations must have recorded outcomes. The final release notes must state the support matrix, beta migration, capability limitations, recovery paths, and changes since beta.2. -The beta-to-stable contract permits additive optional fields and new tools when existing callers retain their behavior. +The default beta-to-stable contract permits additive optional fields and new tools when existing callers retain their behavior. Removing or renaming tools, changing existing field meanings, changing pointer offset order, or changing host-effect reporting requires a reviewed future breaking contract. +The current working tree proposes explicit exceptions before stable freeze: unavailable fields become omitted, collector descriptions are corrected, effect metadata changes, and the blocking module-exports resource URI is removed in favor of its explicit tool. +These changes are enumerated in release-notes.md and offline-contract-audit.md; they are not claimed as additive-compatible changes. Row 2.07 remains partial until the final contract review accepts or revises these exceptions and the migration guidance. The beta tags remain unchanged, and stable publication uses a new annotated `v2.0.0` tag at the exact final binary source commit. ## P2 and research disposition diff --git a/docs/qualification/v2.0.0/admission-preparation.md b/docs/qualification/v2.0.0/admission-preparation.md new file mode 100644 index 0000000..8c3ec2d --- /dev/null +++ b/docs/qualification/v2.0.0/admission-preparation.md @@ -0,0 +1,46 @@ +# Isolated admission measurement preparation + +Status: implemented in an isolated snapshot, managed-tested and independently reviewed by Astra high with no remaining actionable findings in this scope. No new native session or performance qualification is claimed. + +Snapshot: `artifacts/performance-preparation/admission-dcbf0ddf13a04534a0bc83af3fdeb37b`, copied from the previously reviewed [performance preparation](performance-preparation.md). The primary source, ordinary package and pending Settings-observation inputs remain unchanged. The source inventory verifies 861 files with exactly seven new or modified files relative to that prior snapshot. + +## Prepared measurement + +Each of the three hash-pinned beta.2 baseline sessions and three candidate sessions will open two independent gateway processes. Four rounds each issue eight 1,024-item int32 batches against owned host A, split equally between gateways, alongside discovery, a host-B sentinel read, one serialized CE UI snapshot, a one-item regions resource read, and module completion. All requests in a round settle before the next round or cleanup. Before/after checks compare retained jobs/resources on both hosts and verify the four optional gates are disabled. + +Every sample records its client, category, monotonic start/end timestamps and outcome. Only exact protocol/tool `busy` with `hostEffect=not_started` counts as a refusal. Timeouts, cancellations, unexpected failures and empty completions remain distinct. Failure receipts preserve bounded diagnostics, inner/stack information when available, operation and error correlation. A failed peer does not let the measurement return while other requests remain active. + +Comparison requires three complete baselines and one complete candidate: four rounds, thirteen uniquely identified requests per round, the exact client/category distribution and interval-derived concurrency. It requires cross-gateway batch overlap, and every observer or busy batch must overlap successful batch work in the same round. Missing successful/refused response classes, unloaded observations, or empty completions cannot become success evidence. Successful batch p95 uses the 1.5-times baseline budget; other measured response classes use the short-call allowance. The worst of three baseline p95 values is retained for each class. + +These intervals describe client calls, not native dispatch occupancy. The UI observation includes the CE driver's 100 ms timer and file exchange. First/repeated module completion is labeled separately; repeat calls may hit the five-second cache. Empty completion does not establish a busy refusal, and its refresh may outlive the response. Four observations of an observer category are a fixed diagnostic sample set, not broad statistical qualification. + +## Managed verification + +Release build passed with zero warnings/errors. The exact four-class selection passed **52 tests, zero failures, zero skips**: ten measurement tests, twenty-two comparison tests, nine performance-matrix tests and eleven lifecycle-helper tests. Logs and TRX reside inside the snapshot; exact source/log/test-module hashes are recorded in `artifacts/performance-preparation/admission-evidence.json`. + +| Requirement | Evidence | +| --- | --- | +| Wait for peers after failure and active cancellation | `MeasureAsync_WaitsForEveryPeerAfterOneFails`, `MeasureAsync_ActiveCallerCancellationSettlesAndClassifiesTheInvocation` | +| Refuse invalid request lists before starting work | `MeasureAsync_ValidatesEveryRequestBeforeInvokingAny`, `MeasureAsync_RejectsMoreThanSixteenRequestsBeforeInvokingAny`, `MeasureAsync_PreStartCancellationInvokesNothing` | +| Distinguish actual protocol refusals, malformed failures and empty completion | `ClassifyTool_RequiresExactBusyHostEffectAndRejectsMalformedErrors`, `Classifiers_DistinguishProtocolToolFailureAndEmptyCompletion` | +| Preserve failure diagnostics and correlation with bounded output | `Classifiers_RetainBoundedDiagnosticsAndProtocolCorrelation` | +| Reject missing refusals or a deficient baseline | `Compare_FastSuccessfulRequestsCannotReplaceMissingRefusals`, `Compare_OneBaselineMissingAnOutcomeCannotBeReplacedByTheOtherTwo` | +| Reject observer-only overlap and unloaded observations | `Compare_UiOverlapCannotReplaceCrossGatewayBatchOverlap`, `Compare_RejectsUnloadedObserverAndBusyTiming` | +| Refuse malformed rounds/clients/timing claims and enforce budget boundaries | `Validate_RejectsIncompleteOrMisreportedEvidence`, `Compare_AppliesResponseSpecificBudgetAtBoundary` | + +Successful commands from this snapshot: + +```powershell +dotnet build tests/CheatEngine.Mcp.Tests/CheatEngine.Mcp.Tests.csproj -c Release --no-restore -p:EnableSourceControlManagerQueries=false -p:RepositoryCommit=0f722ef7d6b37eee0985073dfe594c6559b31c14 -v:minimal +dotnet artifacts/bin/CheatEngine.Mcp.Tests/release/CheatEngine.Mcp.Tests.dll --filter-class CheatEngine.Mcp.Tests.LiveQualification.LiveAdmissionMeasurementTests --filter-class CheatEngine.Mcp.Tests.LiveQualification.LiveAdmissionComparisonTests --filter-class CheatEngine.Mcp.Tests.LiveQualification.LivePerformanceMatrixTests --filter-class CheatEngine.Mcp.Tests.LiveQualification.LiveLifecycleQualificationTests --fail-skips on --report-trx --results-directory artifacts/admission-final-results +``` + +The initial build found an invalid test JSON literal; a later build found formatting/analyzer violations. Both were corrected before the first passing 49-test run. Astra then found that observer timing could occur outside active batches and that exception classification discarded diagnostic details. The repairs added overlap controls and bounded diagnostics, followed by the clean 52-test run above. Astra's second read-only pass found no remaining actionable correctness findings in this admission preparation. + +## Remaining native enumeration gap + +The actual package source establishes that 4,096-module and 16,384-region collection sizes are destination bounds, not native traversal bounds. Client allocates that destination; SDK invokes CE's whole-table producer first, inspects the complete Lua sequence, and returns `DestinationTooSmall` before copying if necessary. CE's module iterator runs to exhaustion; its region iterator starts at zero and has no item cursor. Module-detail navigation also enumerates through `ModuleLocator.Find`. + +Package provenance: Client `v1.0.0` tag `110a302b0908cbf8d71e4f5cdf1f0f0e6e7fe31cfc`; SDK `2.0.0` source `325c47b573f8bd39a247f1d0101f110fa36c1696` and lock-matching package content hash. References: [Client inspection](https://github.com/CheatEngineNet/CheatEngine.Client/blob/v1.0.0/libs/CheatEngine.Client.Core/Domains/InspectionClient.cs), [SDK inspection](https://github.com/CheatEngineNet/CheatEngine.SDK/blob/325c47b573f8bd39a247f1d0101f110fa36c1696/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs). Installed CE binary equivalence to the inspected public CE source remains unproven. + +The separate [prepared-navigation repair](navigation-preparation.md) now preserves module/detail/region resources and module completion through five-second immutable snapshots prepared by explicit source tools, with process/epoch validation and truthful cold/stale errors. Its isolated implementation passes 4,207 portable tests. In that snapshot, admission qualification explicitly prepares modules/maps before timing each round and distinguishes the baseline completion cache from the candidate fresh policy. Final 9.01/9.03/9.05 qualification, integration, candidate packaging and native runs remain open. diff --git a/docs/qualification/v2.0.0/contract-checkpoint.md b/docs/qualification/v2.0.0/contract-checkpoint.md index b55e111..0ad2458 100644 --- a/docs/qualification/v2.0.0/contract-checkpoint.md +++ b/docs/qualification/v2.0.0/contract-checkpoint.md @@ -1,6 +1,6 @@ # Stable v2.0.0 contract checkpoint -Date: 2026-10-08. +Date: 2026-10-09. Status: Current-source audit with bounded clean-candidate evidence; final stable contract freeze remains incomplete. ## Candidate identity @@ -19,13 +19,13 @@ Neither candidate is the final stable package. | --- | --- | --- | --- | | 3.01 | partial | `ContractSnapshotTests.GatewayCatalog_CurrentBuild_MatchesGoldenSnapshot`, `ContractSnapshotTests.Backend_ToolsAndInitialize_MatchGoldenSnapshots`, `CompositionParityTests.SchemaOnlyCatalog_MatchesTheLiveBackendListing`, `CompositionParityTests.GatewayTools_AreTheBackendToolsWithOnlyTheRoutingArgumentAdded`, and the clean packaged-DLL comparison passed. | Repeat V+ and packaged reflection after final names, workflows, prompts, resources, and goldens freeze on the exact RC and final stable commit. | | 3.02 | pass for the clean tested candidate | `CompositionParityTests.PluginAndGatewayExecutable_RegisterTheSameJsonResolvers`, both Debug and Release Native AOT checks, and the packaged-DLL golden comparison cover the three P1 input and result types. | Repeat V+, AOT, and packaged reflection on the exact RC and final stable commit. | -| 3.03 | partial | `OutputSchemaContractTests.ToolOutputSchemas_NeverRequireANullableMember`, `ContractJsonTests.Serialize_MinimalError_OmitsNullFields`, and domain portable tests cover many nullable results. | Audit every unavailable, zero, empty string, and empty collection result shape before contract freeze, then retain the audit with V+. | -| 3.04 | partial | Pointer portable and NativeLua tests cover signed offsets and width arithmetic, while schema goldens preserve string-form hexadecimal addresses. | Add an evidence audit through a real MCP client for maximum 64-bit addresses, negative offsets, exact integers above JavaScript safe range, and hexadecimal round trips. | +| 3.03 | source audit and standalone fixed-Lua regressions complete; exact-candidate and CE evidence pending | The full-domain review in offline-contract-audit.md covers unavailable observations, zero/empty results, raw collector conventions and gateway forwarding. All 626 Release NativeLua cases passed after narrow user authorization. | Retain portable/schema/standalone/native results on the exact candidate; do not infer installed-collector parity or real CE behavior from public source and stubbed host APIs. | +| 3.04 | portable client audit complete; final candidate pending | The 12 `ExactValuePipelineTests` cases cover maximum 64-bit addresses and pointers, both signed 64-bit extremes, unsigned values above JavaScript's safe range, and negative hexadecimal offsets through a real .NET MCP client and gateway. | Retain these regressions in the clean RC and final stable checks; this is not qualification of named interactive clients or real target memory. | | 3.05 | pass for the clean tested candidate | `GatewayToolCatalogTests.RoutedTools_InstanceId_IsTheFirstPropertyAndRequirement`, `GatewayServerTests.CallTool_TwoPublishedInstances_RoutesOnlyToRequestedBackendAndStripsInstanceId`, `GatewayServerTests.CallTool_MissingInstanceId_ReturnsInvalidArgumentEnvelope`, `GatewayServerTests.CallTool_UnknownInstanceId_ReturnsInstanceUnavailableNotStarted`, and `GatewayServerTests.CallTool_StaleIdentity_ReturnsInstanceUnavailableNotStartedAndNeverForwards` cover explicit routing and disappearance. | Repeat portable V+ and the maintained two-instance VL on the exact final package. | -| 3.06 | partial | `McpContractRulesTests.BackendCatalog_ContainsOnlyValidatedV2Tools`, `McpContractRulesTests.ValidateTool_ReadOnlyAndDestructive_Fails`, `McpContractRulesTests.ValidateTool_PollNotReadOnlyOrNotIdempotent_Fails`, `ToolCatalogContractTests.RegisteredV2Tools_OpenWorldAnnotation_MatchesTheReviewedList`, and the generated `tool-summary.txt` cover structural rules. | Review every tool annotation, dispatch class, and capability against its implementation effect before contract freeze. | -| 3.07 | partial | `FeatureOptionsTests.ClientOptIns_FollowTheSameFeatureInstance`, `FeatureOptionsTests.ReadFeatureOptions_AbsentSection_EnablesEverySwitch`, compiler disabled-gate evidence, and domain gate tests cover direct paths. | Complete the loaded-table, record-script, indirect Lua, indirect Auto Assembler, and argument-dependent effect audit and retain effect-before-refusal evidence. | +| 3.06 | source audit complete; candidate validation pending | The inventory review covers all 193 backend tools. Six further metadata corrections supplement the earlier eight, and the slow exports resource projection is removed while the explicit tool remains. See offline-contract-audit.md. | Validate final snapshots, descriptions and packaged reflection on the exact candidate; native dispatch timing remains a separate qualification gate. | +| 3.07 | source audit, selected portable cases and standalone hint regression complete; real CE and final candidate pending | Existing gate tests plus the new table, secondary AA, mixed record batch and process-open tests below cover refusal before effects. `record_set_script` now declares its fixed AA requirement and refuses before dispatch; the `record_clear` recovery-hint assertion passed in the approved standalone NativeLua suite. | Verify actual CE table/script hooks when permitted and retain the complete portable/standalone/native evidence on the final candidate. | | 3.08 | pass at documentation level | `McpConfigurationTests.Load_Defaults_AreLoopbackAndAllExecutionIsEnabled`, `McpConfigurationTests.Load_PluginUserAndEnvironment_UseDocumentedPrecedence`, README configuration text, `support-matrix.md`, and `acceptance.md` agree on enabled switches, empty roots, precedence, and exposure semantics. | Reconcile these statements against the exact RC configuration and final release notes. | -| 3.09 | partial | `ContractJsonTests.Serialize_FullError_KeepsTheEnvelopeFieldOrder`, `ContractJsonTests.TryRead_CreatedResult_RoundTripsTheError`, `GatewayServerTests.CallTool_BackendErrorResult_PassesThroughUnchanged`, `GatewayServerTests.CallTool_BackendProtocolError_PassesThroughWithItsCode`, and `ErrorCorrelationTests.CallTool_ReportedInternalError_KeepsItsDetailsAndLogsTheSameErrorId` cover typed envelopes and correlation. | Audit retryability, hints, details, and correlation for representative direct backend and routed failures at every host-effect class. | +| 3.09 | source and portable audit complete; final candidate pending | Existing `ContractJsonTests` and gateway protocol-error tests remain applicable. `ErrorCorrelationTests.CallTool_EveryHostEffect_PreservesCorrelatedEnvelopeThroughGateway` adds direct and routed coverage for all six host effects, exact envelope equality, hint, details, retryability and the logged correlation ID. Non-object context and invalid reserved IDs have explicit regressions. | Include the reviewed correlation correction in a clean candidate; repeat portable and packaged checks on the exact RC and final source before checking the roadmap's final-candidate criterion. | | 3.10 | pass for portable routing behavior | `GatewayServerTests.CallTool_CancelledForward_DoesNotRetryAnotherInstance`, `GatewayServerTests.CallTool_BackendHang_ReturnsTimeoutUnknownAndNeverRetries`, and `GatewayServerTests.CallTool_BackendDropsConnection_ReportsUnknownEffectAndNeverRetries` prove the gateway does not replay uncertain calls. | Retain these tests in V+ and verify representative native mutation state after a timeout or lost response. | | 3.11 | pass for the clean tested candidate | `ContractSnapshotTests.GatewayExecutable_StdioWithoutInstances_MatchesGoldenSnapshots`, `GatewayServerTests.ListTools_WithoutPublishedInstances_ExposesStableRoutedCatalog`, `GatewayResourceTests.ListResources_WithoutInstances_ServesTheDocumentsAndTheInstanceList`, `GatewayResourceTests.ReadResourceAndGetPrompt_Local_AreServedWithoutAnyBackend`, and `GatewayCompletionTests.Complete_AllowedValuesOfARoutedTemplate_AreServedByTheGatewayWithoutAnyBackend` cover no-instance behavior. | Repeat AOT executable and V+ checks on the exact RC and final stable gateway. | @@ -43,6 +43,76 @@ The feature defaults, precedence, validation, and activation boundary are tested ## Contract freeze gate -The stable contract cannot freeze until partial rows 3.01, 3.03, 3.04, 3.06, 3.07, and 3.09 have reviewed evidence. +The stable contract cannot freeze until partial rows 3.01, 3.03 and 3.06 have complete reviewed evidence, native portions of 3.07 are verified, and the working-tree 3.04, 3.07 and 3.09 changes are validated on the clean candidate. Rows that pass for `808a626e65eddab113737a27a7c2345555b1c241` still require the exact RC and final-candidate repetitions named above. This checkpoint does not qualify any complete native workflow domain, installation path, client, soak, or public release. + +## Error-envelope follow-up + +An offline source audit found that an internal error with scalar or array details received a logged correlation ID but omitted that ID from the response. +`McpErrorCorrelation` now keeps non-object JSON context under `details.value` beside `errorId`, without numeric conversion. +It also replaces a non-string reserved `errorId` instead of emitting duplicate keys. +Object context and existing string IDs are preserved, other error kinds are unchanged, and gateway forwarding preserves the complete backend envelope. + +| Requirement | Portable evidence | +| --- | --- | +| Preserve array, string, exact large integer, boolean and null context with the logged ID. | `ErrorCorrelationTests.CallTool_NonObjectInternalDetails_KeepsValueAndReturnsTheLoggedErrorId` | +| Replace an invalid reserved ID while retaining the other object properties. | `ErrorCorrelationTests.Correlate_NonStringExistingId_ReplacesItWithoutDuplicateKeys` | +| Preserve every host-effect class, operation, retryability, hint, details and correlation through direct MCP and gateway calls. | `ErrorCorrelationTests.CallTool_EveryHostEffect_PreservesCorrelatedEnvelopeThroughGateway` | + +The 22 focused correlation cases passed in Release, and independent Astra source review found no remaining actionable findings after documentation corrections. +The previous and current full-suite results are recorded separately in [remaining-checkpoint.md](remaining-checkpoint.md). +These are portable source/filter/routing checks with controlled backends, not evidence that a real CE operation produced every host effect. + +The latest offline package refresh also verifies the working-tree contract in the actual Debug and Release single-DLL distributions and Native AOT gateway executables. Both packaged plugins match all backend catalog snapshots in two isolated contexts each; both AOT executables pass the existing no-instance initialization/catalog/knowledge test. Exact binary hashes, same-input ZIP reproduction, extraction checks and the uncommitted-source boundary are recorded in [remaining-checkpoint.md](remaining-checkpoint.md#offline-package-refresh---2026-10-09). These current local package results do not replace the required clean RC/final stable repetitions or native qualification. + +## Exact values, gates and effect metadata follow-up + +The 2026-10-09 continuation adds actual MCP serialization and loopback gateway forwarding around controlled Client doubles. +The gateway's controlled backend returns the direct tool response, so the check proves serialization and forwarding without claiming another target read. +`MemoryRead_ExactAndEmptyValues_RemainStringsThroughClientAndGateway` covers signed extrema, `9007199254740993`, unsigned maximum, pointer maximum, address maximum, zero and empty text. +`PointerReadChain_SignedOffsetsAndWideAddresses_RoundTripWithoutNumericConversion` covers `0`, `-1` and `-8000000000000000` offsets without numeric coercion. +All 12 focused cases passed in Release; both full portable suites at that earlier checkpoint passed 4,126 tests. The latest full offline audit passed 4,135 tests in each configuration, as recorded in [remaining-checkpoint.md](remaining-checkpoint.md). + +| Gate boundary | New portable evidence | +| --- | --- | +| Secondary Lua, target-code and kernel requirements before AA apply. | `CodeAsmTableV2Tests.Apply_SecondaryCapabilityDisabled_RefusesBeforeClientMutation` | +| Loaded XML AA, Mono and kernel content; each opaque-table gate; current-table Mono before load. | `Load_GatedTableContent_DisabledFeatureRefusesBeforeClientLoad`, `Load_OpaqueTable_DisabledExposureGate_RefusesBeforeClientLoad`, `Load_CurrentTableUsesMonoWithCodeExecutionDisabled_ProbesButRefusesBeforeClientLoad` | +| A mixed create/delete batch containing a gated AA record cannot mutate earlier records. | `RecordToolsTests.Create_BatchContainingAutoAssemblerWithoutGate_RefusesBeforeCreatingAnyRecord`, `Delete_BatchContainingActiveAutoAssemblerWithoutGate_RefusesBeforeDeletingAnyRecord` | +| A fixed script-editing requirement rejects before dispatch. | `RecordToolsTests.SetScript_AutoAssemblerWithoutGate_RefusesBeforeAnyDispatch` | +| Mono auto-attach on process creation or file-open cannot reach the open script while disabled. | `ProcessMonoAutoAttachTests.CreateOrOpenFile_MonoAutoAttachWithCodeExecutionOff_RefusesBeforeTheOpenScript` | + +The XML fixtures explicitly verify inspection, so a malformed/protected-table fallback cannot masquerade as content-classification coverage. +Focused Release results were 42 assembly/table tests, 177 record tests and 10 process-auto-attach tests, all passing. +The source audit traced the declarative filter, fixed-Lua scanner, table inspector, AA classifier, record mutations, Mono auto-attach, debugger-interface choices, kernel symbol sources, Exec/C# and Speedhack admission paths. +This remains the documented exposure-control boundary: address expressions and stored AA scripts do not acquire sandbox guarantees or new secondary gate promises. +The `record_clear` fixed-Lua hint assertion subsequently passed in the explicitly authorized standalone Release NativeLua suite; real CE hook behavior remains unqualified. + +Effect metadata now marks `record_create` and `table_save` destructive because an initial value or explicit overwrite can replace existing data. +`record_set_script` publishes `auto_assembler` in its fixed requirements. +`asm_release_patch`, `record_set_active`, `record_delete`, `record_clear` and `runtime_release_resources` publish `may_prompt`: original AA sections can block or show a dialog, including when called through bulk resource cleanup. +Independent Astra review cleared these scoped source/test changes after the identified corrections. It did not audit every remaining tool or qualify a native runtime. + +The .NET and Mono fallback review used public upstream commit `ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37`, separately from installed CE `7.7.1.10828`. +[Mono field enumeration](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/bin/autorun/monoscript.lua#L2159-L2205) reads every returned offset; the collector writes it for Mono and its IL2CPP binding uses `il2cpp_field_get_offset`. +The [.NET Lua field wrapper](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/luadotnetpipe.pas#L278-L337) emits every decoded `Offset`, and its [parameter wrapper](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/luadotnetpipe.pas#L210-L245) emits `CType` for each returned entry. +The [parameter decoder](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/dotnetpipe.pas#L493-L561) preallocates the result array and skips assignment for the explicit failure sentinel without compacting it; the Lua wrapper still emits every slot. +Consequently, a present `CType` can be default metadata from a failed parameter lookup. Its presence does not prove that meaningful metadata was reported. +Offset/property emission alone does not justify changing the public shape to nullable; changing the managed type would not recover failure information already lost upstream. Raw collector values remain preserved under the clarified contract below. +It does not establish parity with the installed collector or complete the remaining domain audit, and no collector was executed. + +## .NET parameter metadata correction + +The pinned collector calls `IMetaDataImport::GetParamProps` and copies `pdwCPlusTypeFlag` into `CType`. +Microsoft's [runtime implementation](https://github.com/dotnet/runtime/blob/9fc5e2dd35febb732f16020969b43da7933d326b/src/coreclr/md/compiler/import.cpp#L2764-L2793) obtains this code from the parameter's Constant metadata: it returns `ELEMENT_TYPE_VOID` when there is no constant, otherwise the constant's element type. +This is not the declared parameter type. The prior public description and example incorrectly suggested otherwise. + +`DotNetParameter.ElementType` retains the raw integer for compatibility, but now identifies it as this metadata-constant code; `ElementTypeName` names only that code. +The changed portable test preserves a declared `Game.Player` signature alongside `1` / `Void` for a parameter without a constant, and retains a zero-initialized unavailable entry without inventing a type name. +`0` can mean unavailable metadata; it must not be interpreted as a declared type. The collector does not retain enough information to reconstruct the original failure. + +The [ECMA-335 Param table](https://ecma-international.org/wp-content/uploads/ECMA-335_6th_edition_june_2012.pdf) permits missing rows and sequence `0` for return metadata. +The [runtime enumeration](https://github.com/dotnet/runtime/blob/9fc5e2dd35febb732f16020969b43da7933d326b/src/coreclr/md/compiler/import.cpp#L574-L625) does not filter those rows; the pinned CE implementation enumerates once into a 30-entry buffer, sorts by sequence and discards sequence before returning Lua entries. +The public array therefore promises collector order, potentially including return metadata, placeholders or an incomplete list, and its `index` is the returned position rather than a declared ordinal. +The optional `signature` remains the returned representation of declared types. No signature parser or inferred types were added. +The tool, output schema, knowledge guide and workflow use this same distinction; native collector parity remains unverified. diff --git a/docs/qualification/v2.0.0/lifecycle-key-fix.md b/docs/qualification/v2.0.0/lifecycle-key-fix.md new file mode 100644 index 0000000..444b425 --- /dev/null +++ b/docs/qualification/v2.0.0/lifecycle-key-fix.md @@ -0,0 +1,77 @@ +# Settings plugin checklist key experiment + +Status: the user authorized both stages conditionally. All eight standalone bridge cases passed; the one lifecycle attempt still failed at the first disable request with measured checklist count zero. Cleanup and independent Astra evidence review passed. Subsequent source review disproved the key-needed premise, and the unsupported registry change was rolled back. No further native run is authorized by this completed two-stage approval. + +## Corrected source interpretation + +The failed ordinary-SDK run `20261009T120128Z-8f44` reached the first disable request after all six workflow groups completed. Its refusal only established that the plugin checklist count was not one; the old message did not report whether it was zero or greater than one. + +The original preparation incorrectly interpreted `FillCheckListBox` as being inside the `OpenKey(...\Plugins64, false)` block. Inspection of the exact pinned file shows that the `end` at line 1248 closes that block; the checklist call at 1250 is outside it. A missing plugin registry key does not itself skip that call. This corrects and supersedes the earlier causal claim. Both the source researcher and Astra confirmed the correction. See [the exact block boundary and outer exception handler](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/MainUnit2.pas#L1214-L1261). + +The reload has early exits for a missing form or a different runtime class, plus a broad empty exception handler that can swallow failures before checklist population. The [Lua plugin loader](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaHandler.pas#L9114-L9145) uses the same plugin handler as the [checklist implementation](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/plugin.pas#L1694-L1714). A zero-row observation cannot distinguish those paths, a genuinely empty list, or installed-build differences. The actual cause remains unproven. Both the test process and CE are x64; a default registry-view mismatch has no supporting evidence here. + +## Tested experiment, subsequently rolled back + +- Open or create the empty `Plugins64` key only after the existing guarded user-state scope has been established. +- Retain all validation of subkeys, exact value names, paired types, enabled values and known private plugin paths before removing any existing values. +- Delete only the validated owned values, leaving the empty key available for CE's reload. The normal end-of-session settings restoration remains responsible for restoring the original registry state. +- Keep the exact one-row and exact plugin-name checks. A refusal now reports the observed count in the existing bounded ASCII receipt. +- Add the zero-row regression and assert count details for both zero and multiple rows. Refused requests still send no key messages and never apply Settings. + +The experiment changed `LiveSandboxSession.Lifecycle.cs`, `LivePluginBridge.cs`, and `NativeLuaLivePluginBridgeTests.cs`. After the failed run and corrected source review, only the unsupported key creation/retention change was reverted to the prior validated registry handling. The useful observed-count error and zero-row regression remain. Product source, published plugin/gateway bytes, capability gates and the lifecycle scenario are unchanged. The earlier failed run's source, input manifest and runners are retained under `artifacts/issue-edits/lifecycle-8f44-source/`. + +## Prepared verification sequence + +1. Run only the eight `NativeLuaToolRuntimeTests.LivePluginBridge_*` cases using the staged Lua DLL in one isolated process. The runner has a 60-second deadline, fails skips and requires exactly eight passed cases. This checks the fixed bridge and error contract using the existing simulated host; it does not touch registry settings or launch CE. +2. Only if all eight pass, run one ordinary-SDK x64 `lifecycle` session with the exact workload in [lifecycle-resume.md](lifecycle-resume.md#exact-proposed-workload). This is the first real verification of the preserved-key correction and all downstream plugin cycles/restarts. The attempt ends for review on any failure; there is no automatic retry. + +Both steps required a new explicit execution decision because the earlier one-session lifecycle approval had been consumed. The user subsequently approved this exact conditional sequence; the completed results follow below. + +The Release test-project build passed with zero warnings/errors (`artifacts/issue-edits/goal-lifecycle-key-build.log`). Source whitespace and runner syntax passed. The two no-execution preflights each verified 859 exact files, including all 527 unchanged product sources and the retained ordinary Release distribution. + +- Test module SHA-256: `6F43AD2E67629A39F1CACFC49B3F76ABA46D9BDBE05E33CA3476DF57A54C2630`. +- Standalone input manifest SHA-256: `9D74F0C5F926935C1265DBBE66784A8607FB9F2B3AEDDB1BF7EFF46E298B4A6E`. +- Lifecycle input manifest SHA-256: `72A30BC04C6544176346142AF9819A8209E518A6893C3CCB0B13470AF658711C`. + +Historical commands executed once, in order after approval (their original manifests are retained and intentionally no longer match the reverted current source): + +```powershell +& artifacts/issue-edits/Invoke-LifecycleKeyNativeTests.ps1 -Execute +# Proceed only after its eight-case result is verified as passing. +& artifacts/issue-edits/Invoke-LifecycleKeyFix.ps1 -Execute +``` + +Independent Astra high preparation review found no remaining actionable issues in ownership validation, retained-key semantics, exact row/name guards, the eight-case scope, both 859-hash manifests or runner cleanup. That source review did not establish that the correction would resolve the live failure. + +That review missed the incorrect source-block interpretation above. Its clean result must not be used as support for the superseded key-needed explanation. + +## Approved conditional execution + +The standalone stage ran first in `artifacts/lifecycle-key-native-results/c23fb28679324287a65c9936e1e59ffe`: **8 passed, 0 failed, 0 skipped**, 1.027 seconds. The TRX contains the exact five refusal theory cases (including zero and multiple rows), disable, enable and bounded multibyte-error cases. The owned test process exited 0 with no timeout or runner/cleanup failure and was absent afterward. The simulated-host result does not validate registry behavior. + +After verifying those eight outcomes, the single real-CE stage ran as `20261009T121623Z-71c7`: **0 passed, 1 failed, 0 skipped**, 17.852 seconds. The prelude and all six bounded workflow groups completed. The registry receipt reports `count=0, keyRetained=true`, but nonce 1's first disable request returned `expected exactly one owned plugin row (count=0)`. No plugin cycle or restart completed. Preserving the empty key was insufficient in this installed build; this evidence does not establish which registry path/view CE actually read or why its checklist remained empty. + +All four optional gates remained false. CFG bytes were inspected without execution. Mutating-workflow cleanup failures were zero, user settings were restored, the source installation was unchanged, and all four recorded host/target PIDs plus both test-process PIDs were absent. Both 859-file input manifests still matched after execution, including the unchanged ordinary product package. Independent Astra high review confirmed the test order, exact outcomes, identities and cleanup with no actionable evidence findings. + +Evidence: `artifacts/issue-edits/goal-lifecycle-key-execution-evidence.json`; live output is under `artifacts/lifecycle-key-results/380c1fe8ccf44d388c5a71294102af78`. The tested source, runners and manifests are retained under `artifacts/issue-edits/lifecycle-71c7-source/` before further edits. + +| Artifact | SHA-256 | +| --- | --- | +| Standalone result | `336B110762908D68AB06BD3B93E63713E92D6451FF43838A4E4C2B40F638CC78` | +| Standalone TRX | `45B8406473F9FB3C278F228B43058482052D21D511A765FF8E1D896B5B215618` | +| Lifecycle result | `214CAFD53A1955C5954BB0E31D6455E250B6C48F69F7C9BD1882C47ABAE63CB8` | +| Lifecycle TRX | `A4CA3FDAF66FDAAD3EED73FC9AF3F12DC6E5DE95B4194073A1440EE1CBAACDF9` | +| Native summary | `FA635351E2C6AE070681CA4FCE007BF59D947D41F0DA86F3925CB0F84032FBB8` | +| Native receipts | `AB01C154F5C590126F4718DF7B973B241BD9C6C7BA5175B184BCBAC7AD29F6CD` | + +This two-stage approval is consumed. Plugin lifecycle, the historical SDK stack failure and stable qualification remain unresolved. The registry rollback is a source correction, not a newly executed native pass. The next work is source-based diagnosis of observable reload boundaries; no automatic native retry follows this failure. + +The post-rollback Release test-project build passed with zero warnings/errors (`artifacts/issue-edits/goal-lifecycle-key-rollback-build.log`), and `git diff --check` passed. All 527 product-source hashes and the ordinary distribution's plugin/gateway hashes still match the original package manifest. The original execution manifests remain unchanged as historical evidence; they no longer describe the current harness/test binary. Final review caught an empty consolidated `LiveCounters` field; it was corrected to the explicit numeric counters from the unchanged live TRX. + +Final Astra review confirmed that correction with no remaining actionable findings. The checkpoint in #28 and #33 was replaced with the passed subcases, failed lifecycle result and retracted source rationale; exact normalized body readback succeeded. Both issues remain open. #31 was separately reread and remains open with its extended compiler/injection and final contract/CI gates unchanged. + +## Next diagnostic limits from source + +`getSettingsForm()` [wraps the existing global form](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaHandler.pas#L4657-L4662); it does not create or initialize it. The old form-before-reload ordering was not proof of form initialization. Lua's [object binding exposes `ClassName`](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaObject.pas#L126-L132), which could check the reload's exact-class guard. Existing checklist counts before/after reload are also observable. + +However, `LoadingSettingsFromRegistry` is a public field, not a published RTTI property in [the Settings declaration](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/formsettingsunit.pas#L360-L387). It must not be assumed readable through the ordinary Lua property binding. No plugin-array enumeration binding was established by the source review. A correct class plus zero rows both before and after reload would still leave a swallowed exception and an empty plugin list indistinguishable. These observations are an offline design boundary, not a prepared or authorized new session; a further run needs a probe that can discriminate the remaining paths. diff --git a/docs/qualification/v2.0.0/lifecycle-resume.md b/docs/qualification/v2.0.0/lifecycle-resume.md new file mode 100644 index 0000000..b3121c5 --- /dev/null +++ b/docs/qualification/v2.0.0/lifecycle-resume.md @@ -0,0 +1,68 @@ +# Ordinary-SDK lifecycle session preparation + +Status: the user approved one session after offline preparation and Astra review. It executed and failed at the first plugin-disable step after all six workflow groups completed. Cleanup passed; no retry is authorized or scheduled. + +## Why this is the next boundary + +Four ordinary-SDK prefix sessions and one instrumented prefix session passed, and two deterministic standalone nested-dispatch cases also passed. They did not execute plugin disable/re-enable or the later restart matrix. The historical private `.2` session `20261008T015918Z-ee72` completed all six workflow groups before the fixed Settings/Plugins bridge refused its first disable request. The bridge's form-initialization and bounded error-receipt fixes have offline coverage but still need verification in CE. + +The maintained `lifecycle` scenario covers that boundary using the current ordinary SDK package. A passing result could qualify this specific current-package workflow/lifecycle sequence. It would not establish the cause or fix of the earlier uninstrumented native stack failure, reproduce historical package bytes/timing, or qualify the stable release. + +## Exact proposed workload + +One x64 session starts two private CE copies and two owned x64 targets, using the existing guarded harness: + +1. Attach both targets; verify runtime identities, scalar sentinels and all four disabled capability gates; run the original read-only resource prelude. +2. Run all six bounded workflow groups: memory, named scanner, AOB, control-flow graph inspection, modules and structures. Temporary allocations, snapshots, named scans and structures are owned and cleaned up by the existing harness. The CFG fixture writes bytes into owned non-executable memory for analysis; it does not execute target code. +3. Disable and re-enable plugin A three times. Require fresh activation identity/endpoint/token and stale-instance rejection, while B remains available and unchanged. +4. Restart A's owned target and reconnect; restart the owned gateway and verify both instances; restart private host A together with its owned target, reject the former instance and verify both current instances. +5. Close the owned processes, restore the snapshotted user settings, compare the source installation fingerprint, and retain the original error together with any cleanup error. + +All optional gates remain false: `unsafeLua`, `autoAssembler`, `kernelAccess`, `targetCodeExecution`. No debugger attachment, compiler qualification, managed injection, kernel/DBVM workflow or soak is selected. No user process is closed to satisfy preflight; the harness refuses an existing CE or DebugView process. + +Each private CE driver retains its normal five-minute lifetime, and each owned target its normal ten-minute lifetime. These are per-process bounds, not a promise that the whole restart scenario completes in five minutes. The runner lets the maintained harness perform ordered shutdown and settings restoration; it does not abruptly terminate the harness parent. Any failure ends this single attempt for review, without an automatic retry. + +## Exact candidate and runner + +- Distribution: `artifacts/dist/goal-contract2-release`, already used by the four passing ordinary-SDK prefix sessions. +- Plugin SHA-256: `1620DD5978C4996BA80DEB5B81403EB3B695B3D029C1D1D533A3F39254D5A97F`. +- AOT gateway SHA-256: `ADB97797600614CEF21C7F5E2352F95E9102A3D4DFCE48DA4F5B19335E65CA88`. +- CE 7.7.1.10828 x64 executable SHA-256: `CF6CCC664A90C23531EC20F5C41458CFDC1D04679D6D05ED94C06BCECF95544F`. +- Source: `0f722ef7d6b37eee0985073dfe594c6559b31c14` plus the reviewed uncommitted MCP changes. This is not a clean stable candidate or a published release. +- SDK: ordinary dependency; the private stack trace package is not selected, and the child process receives no stack-trace opt-in. + +`artifacts/issue-edits/Prepare-LifecycleResume.ps1` verifies all 527 product-source hashes and the full Release distribution against the retained package manifest. It captures the current live-harness source, built test and target output trees, .NET host, CE executable and runner in `goal-lifecycle-resume-inputs.json`. + +The root Release test-project build passed with zero warnings/errors (`goal-lifecycle-resume-build.log`). Script syntax and no-execution preflight passed for 820 exact files. No source or package change was required to select the existing scenario. + +`Invoke-LifecycleResume.ps1` defaults to hash verification only. After explicit approval, its `-Execute` mode invokes exactly `CheatEngine.Mcp.Tests.LiveQualification.McpLiveQualificationTests` with the `LiveQualification` trait and fail-skips. It requires one executed/passed case, the complete three-cycle/three-restart receipt, disabled gates, unchanged package hashes, restored user state, unchanged installation, no cleanup failures and absence of recorded original/restarted host/target PIDs. + +Proposed command from the workspace, only after approval: + +```powershell +& artifacts/issue-edits/Invoke-LifecycleResume.ps1 -Execute +``` + +Independent Astra high review verified all 820 input hashes, the 527 product-source matches, exact scenario routing, original/restarted PID coverage, completion checks and preservation of the harness's restoration path. No remaining actionable findings were reported in that scope. + +- Prepared manifest SHA-256: `7A20146763110D9A459F4C4CF0FAF065A4C7EDD471C32042F26063BBDCD9D06F`. +- Built test module SHA-256: `7F8404D131ABC76F3CE612FE5B181DD83706C44ED2193E5E2C7D7906D20E43F7`. + +The user separately authorized this broader lifecycle run after review. That one-session authorization is consumed; further native execution requires a new decision. + +## Approved execution result + +Run `20261009T120128Z-8f44` completed the original prelude and all six workflow groups with the unchanged ordinary-SDK package. Memory, named scans, AOB, CFG, modules and structures each have their completion receipt; each mutating group's cleanup reported zero failures. CFG fixture bytes were not executed. All four optional gates were false. + +The first `CyclePluginAsync("A")` failed when the Settings/Plugins bridge returned `expected exactly one owned plugin row`. The new bounded error-response path worked: the harness reported the precise refusal immediately, instead of waiting for a missing response. The earlier form-before-registry-reload change therefore did not resolve checklist population in real CE. + +The test result is **Passed: 0, Failed: 1, Skipped: 0**, 18.441 seconds; the test process exited 2. No plugin reload or target/gateway/host restart pass is claimed. The six preceding workflow groups passing does not make the lifecycle test pass or demonstrate a fix for the historical native stack failure. + +User state was restored, the source installation stayed unchanged, cleanup failures were empty, and all four recorded owned host/target PIDs were absent. All 820 prepared input hashes still matched after execution. The failed attempt is retained for source-based diagnosis; it has not been retried. + +- Raw summary SHA-256: `FCAEA7CA2B06C37B854EF57B854FA511622B28D95EC9969BE761978609807594`. +- Runner receipt SHA-256: `80E6A139EC4E52D202ACEAE10E8A7ACA2AD5D5ECA2ABBDC62E6E918B0453A941`. +- TRX SHA-256: `0897C68743C4FC2A11922DF7DC872972915A8F7F05E1A04CE3F8184AF33FC87A`. +- Local result directory: `artifacts/lifecycle-resume-results/c244a91c720a41e88e3a71e59979a583`. +- Bounded evidence extract: `artifacts/issue-edits/goal-lifecycle-8f44-evidence.json`. +- Pre-edit bridge/lifecycle source snapshot: `artifacts/issue-edits/lifecycle-8f44-source/`. diff --git a/docs/qualification/v2.0.0/mcp-closeout.md b/docs/qualification/v2.0.0/mcp-closeout.md new file mode 100644 index 0000000..e51fc58 --- /dev/null +++ b/docs/qualification/v2.0.0/mcp-closeout.md @@ -0,0 +1,40 @@ +# MCP implementation closeout + +On 2026-10-09, the maintainer narrowed the remaining work to MCP changes and requested closure of issues #28, #31 and #33 when no further work could proceed, followed by a local commit to `main` if tests passed. This supersedes the earlier requirement to keep those issues open until a public stable release. It does not approve more native investigation or claim stable qualification. + +## Integrated changes + +The 61 changed or new source, test and knowledge files from the reviewed navigation follow-up are now integrated into the primary checkout. Before integration, existing destination files matched the original preparation baseline; all copied files then matched the reviewed snapshot. The 110 pre-existing modified/untracked files were backed up locally, and newer qualification documents were preserved. + +This includes prepared module, region, thread, symbol and breakpoint navigation; explicit blocking-native metadata for full enumeration; bounded structure lookup/page access; corrected thread StringList ownership; ordered snapshot publication; and retained pointer-search cleanup bounds. The reviewed performance/admission harness preparations and regression tests are retained, without claiming that their native workloads ran. + +Evidence for the integration is in the ignored local receipt `artifacts/issue-edits/final-mcp-integration.json`. Earlier isolated Debug/Release portable and local package results remain recorded in [navigation-followup.md](navigation-followup.md) and [navigation-package.md](navigation-package.md). They do not replace verification of this integrated checkout. + +## Verification + +Fresh checks ran against the integrated primary checkout, using the maintained CI commands: + +| Check | Result | +| --- | --- | +| Locked solution restore | Passed. | +| Debug and Release solution builds | Both passed with zero warnings/errors. | +| Debug portable suite | 4,234 passed, zero failed/skipped. | +| Release portable suite | 4,234 passed, zero failed/skipped. | +| Style and whitespace verification | Both passed with no source changes. | +| Debug and Release local publish | Both passed; each exact bundled DLL matched its catalog goldens in two isolated contexts, and each Native AOT gateway passed the empty-registry smoke check. | +| Local Release archive | Five-file inventory and checksum verification passed. | +| Independent Astra review | Scoped clear with no actionable findings; all 820 reviewed file hashes, both TRXs, logs, distributions and ZIP contents independently verified. | + +The portable runs excluded `NativeLua` and `LiveQualification` and cleared inherited `CHEATENGINE_*` variables. The bundle probe loads the ordinary native bridge to inspect its export; it does not execute Lua scripts or launch CE. Local publish/package commands do not upload anything. + +`artifacts/issue-edits/final-mcp-validation.json` records both TRX counts/times/hashes, all 820 reviewed product/test/knowledge file hashes, build/format/test logs, distribution files and ZIP identity. The ZIP is 17,802,177 bytes with SHA-256 `6603934531FACA7D37D49CAF0897B9BC57612B3C73EEBBD68268B0E8010D4799`. These pre-commit development binaries identify base commit `0f722ef7d6b37eee0985073dfe594c6559b31c14`; they do not prove a clean final stable package. + +## Deferred work and issue disposition + +The historical CE Lua dispatch failure and empty Settings plugin list remain unresolved. The five new standalone navigation Lua cases and ten Settings cases have not run; the proposed conditional real-CE lifecycle session has not run. Integration supersedes their frozen primary-checkout inputs: any future execution must prepare and review inputs for the then-current checkout instead of reusing those manifests. + +Compiler prerequisites/expiry/reload/injection, the broader native workflow matrix, performance measurements, soak, installation/upgrade/rollback and supported-client checks remain unqualified. The release sign-off checklist records these gaps; unchecked criteria stay unchecked. Closing the issues at the maintainer's request is administrative closure of this effort, not evidence that those criteria passed. + +The version remains `2.0.0-beta.2`. This work does not create a stable tag, upload assets, publish a release or push to GitHub. A future stable release requires an explicit qualification/release effort with a clean source identity and applicable native evidence. + +After the passing checks and final Astra review, [#28](https://github.com/CheatEngineNet/CheatEngine.Mcp/issues/28), [#31](https://github.com/CheatEngineNet/CheatEngine.Mcp/issues/31) and [#33](https://github.com/CheatEngineNet/CheatEngine.Mcp/issues/33) were closed as `not_planned`. Readback verified each closure reason and that the original issue body/checklist was unchanged. The closing comments explain the maintainer's scope decision, completed MCP checks and deferred native/stable requirements. diff --git a/docs/qualification/v2.0.0/navigation-followup.md b/docs/qualification/v2.0.0/navigation-followup.md new file mode 100644 index 0000000..e6a1ab5 --- /dev/null +++ b/docs/qualification/v2.0.0/navigation-followup.md @@ -0,0 +1,57 @@ +# Remaining prepared navigation routes + +Status: the isolated implementation and evidence below passed Astra high review. These repairs are now integrated into the primary checkout for the [MCP closeout](mcp-closeout.md), whose verification is recorded separately. The earlier [local package](navigation-package.md#latest-reviewed-follow-up-package) includes these repairs; native CE qualification remains incomplete. + +The snapshot is `artifacts/performance-preparation/navigation-followup-7adc921d5236434493cb15e1775855bf`, copied from the 863-file reviewed first navigation repair. The frozen ordinary package and both 859-file Settings preparations remain unchanged. + +## Implementation and limits + +Thread, registered-symbol and breakpoint resources now require a successful explicit preparation within five seconds. These reads use a copied bounded snapshot; they perform no native list enumeration. Thread and breakpoint snapshots require the same selected process/epoch; registered symbols remain global and available without a target. Symbol ownership is evaluated from current MCP leases. Breakpoint ownership describes the preparation-time observation; direct set/delete invalidates it, while other changes require an explicit refresh or expiry. + +The thread collector now passes a caller-owned StringList to `getThreadlist`, parses CE's hexadecimal entries and destroys the list even after collection failure. Pinned CE expects that argument; it does not return a thread table. All three native producers enumerate their full collections before MCP's copy cap. The six explicit module/detail/region/thread/symbol/breakpoint preparations therefore declare `blocking_native`, with descriptions stating that CE can block for seconds. + +Resources name their prepared helper through `McpSourceTool.PreparedProjection`, published as `cheatengine/preparedProjection`. Startup requires a unique public instance method on the same container, the source tool's exact result type, explicit `short` metadata, no tool annotation and no feature gate. Existing source-tool read-only, closed-world, ungated and catalog checks remain. Invalid declarations fail even for a short source. This metadata records the reviewed implementation promise; it cannot prove what a method executes. + +`structure_get` now searches at most 65,536 indexed global definitions, then reads only the requested element page, at most 1,024 entries. A miss beyond the lookup cap is `limit_exceeded`; exact totals and pagination are preserved. Filtered value/compare operations keep their existing scripts. [Pinned CE's binding](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaStructure.pas#L23-L53) is index-only and returns entries in `DissectedStructs`. Its [structure declaration](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/StructuresFrm2.pas#L133-L228) has no `Internal` property: the existing optional read defaults to false. The read description no longer promises a meaningful internal classification. Installed CE/source equivalence is unproven. + +## Verification ledger + +| Requirement | Evidence | +| --- | --- | +| Managed cache/projection behavior | 213 focused cases passed; final portable run passed 4,234/4,234, zero failures/skips. | +| Honest dispatch metadata and narrow projection validation | 38 catalog-capture cases passed; full portable run verifies without capture. Five golden files changed only intended descriptions, six dispatch classes, document sizes and six projection keys. Schemas, URIs and other metadata are unchanged. | +| Actual thread and structure Lua scripts | Regression cases compile with the test project; new native cases have not run. | +| Source and evidence review | Astra high found dispatch metadata, symbol publication ordering, the process test facade and an undefined source-class validation gap; all were repaired. Final source, golden and evidence reviews are scoped clear. | +| Earlier navigation package | Separately reviewed and scoped clear in [navigation-package.md](navigation-package.md); excludes these follow-up changes. | +| Latest follow-up package | All 867 reviewed files copied with matching hashes; maintained DLL probes, AOT smoke, deterministic ZIP and staging passed. Astra high package evidence review is scoped clear; see [the latest package](navigation-package.md#latest-reviewed-follow-up-package). | + +The initial focused run passed 200/203 cases. Two test fixtures were corrected (the detached target requires three scalar checks, and the supplied structure page lacked its asserted next offset); the tool-map document was shortened to its existing budget. Symbol reads now publish within their admitted dispatch and use an ordering token so a delayed older copy cannot replace a newer publication. The native process fixture now supplies the stable process facade needed to reach Lua; this correction is not a native execution result. + +The first broad run passed 4,233/4,234: a script-inventory assertion still expected 18 scripts after adding the nineteenth. That count was updated without weakening its per-script checks. The final Release build passed with zero warnings/errors; the complete portable rerun passed all 4,234 in 1 minute 46.069 seconds. Both broad results are retained. No native or live cases appear in the final TRX. + +Commands executed from the isolated snapshot: + +```powershell +dotnet build tests/CheatEngine.Mcp.Tests/CheatEngine.Mcp.Tests.csproj -c Release --no-restore -p:EnableSourceControlManagerQueries=false -p:RepositoryCommit=0f722ef7d6b37eee0985073dfe594c6559b31c14 -p:SourceRevisionId=0f722ef7d6b37eee0985073dfe594c6559b31c14 -v:minimal +dotnet artifacts/bin/CheatEngine.Mcp.Tests/release/CheatEngine.Mcp.Tests.dll --filter-not-trait Category=LiveQualification --filter-not-trait Category=NativeLua --fail-skips on --report-trx --results-directory artifacts/followup-portable-final-results +``` + +`artifacts/performance-preparation/navigation-followup-evidence.json` records all 867 source hashes (32 modified and four new files), build/module identity, final TRX, test names and log hashes. All 863 prior snapshot hashes still match. The base-commit product version identifies the source baseline, while the inventory identifies its uncommitted changes; it is not a clean stable candidate. Both no-execution Settings preflights again verified all 859 inputs, and their manifests are unchanged. + +Native thread/structure regression execution, integration, live performance/admission/lifecycle qualification and final release evidence remain outstanding. The local development package still needs final stable source/version identity. No issue closure, commit, push or publication is claimed. + +## Matching Debug portable verification + +All 867 reviewed files were copied with matching hashes to `artifacts/candidate-preparation/debug-followup-a80bc6b812c14cbb8bb98a94804d80f5`. Locked restore and the Debug test-project build passed; the build reported zero warnings/errors. The Debug portable suite passed **4,234/4,234**, zero failures/skips, in 1 minute 42.351 seconds. Its exact test-name inventory matches the reviewed Release TRX. NativeLua and LiveQualification were excluded, and no golden-update or native opt-in environment variable was inherited. + +The isolated copy and original reviewed source hashes still match. Both 859-file Settings inventories and the 1,088-file navigation native preparation also remain unchanged. `artifacts/candidate-preparation/debug-followup-evidence.json` records source/module/log/TRX hashes, run times and the comparison with Release; `Record-DebugFollowupEvidence.ps1` verifies these recorded conditions. Astra found that directly serializing the TRX timestamp XML element dropped its attributes; the recorder now writes their exact string values. Final independent Astra high evidence review is scoped clear, with no test rerun needed for that recording correction. + +Commands executed from the Debug snapshot: + +```powershell +dotnet restore tests/CheatEngine.Mcp.Tests/CheatEngine.Mcp.Tests.csproj --locked-mode -p:EnableSourceControlManagerQueries=false -p:RepositoryCommit=0f722ef7d6b37eee0985073dfe594c6559b31c14 -p:SourceRevisionId=0f722ef7d6b37eee0985073dfe594c6559b31c14 -v:minimal +dotnet build tests/CheatEngine.Mcp.Tests/CheatEngine.Mcp.Tests.csproj -c Debug --no-restore -p:EnableSourceControlManagerQueries=false -p:RepositoryCommit=0f722ef7d6b37eee0985073dfe594c6559b31c14 -p:SourceRevisionId=0f722ef7d6b37eee0985073dfe594c6559b31c14 -v:minimal +dotnet artifacts/bin/CheatEngine.Mcp.Tests/debug/CheatEngine.Mcp.Tests.dll --filter-not-trait Category=LiveQualification --filter-not-trait Category=NativeLua --fail-skips on --report-trx --results-directory artifacts/debug-portable-results +``` + +This adds Debug evidence for the same uncommitted development source. It does not satisfy final clean-candidate checks or execute the pending native regressions. diff --git a/docs/qualification/v2.0.0/navigation-native-regression.md b/docs/qualification/v2.0.0/navigation-native-regression.md new file mode 100644 index 0000000..6e75d1e --- /dev/null +++ b/docs/qualification/v2.0.0/navigation-native-regression.md @@ -0,0 +1,43 @@ +# Prepared standalone navigation Lua regression + +Status: historical unexecuted preparation, superseded by the [MCP-only closeout](mcp-closeout.md). The earlier runner/preflight/Astra preparation checks below passed, but no execution approval was received. Future runs require fresh reviewed inputs; do not reuse a pre-integration manifest against the integrated checkout. + +The [reviewed navigation follow-up](navigation-followup.md) passed 4,234 portable tests and was [packaged locally](navigation-package.md#latest-reviewed-follow-up-package). Its changed thread and structure Lua bodies remain unexecuted. This preparation uses that exact reviewed Release test module in the isolated follow-up snapshot; it does not replace the frozen primary Settings test module or either pending Settings preparation. + +## Exact proposed scope + +Run these five facts from `CheatEngine.Mcp.Tests.NativeLua.NativeLuaToolRuntimeTests` once, in one owned process: + +| Case | What it verifies | +| --- | --- | +| `ProcessV2_FixedEffects_ReturnObservedTypedHostState` | Hexadecimal thread IDs copied from the caller-owned StringList, successful destruction, and existing simulated create/open/save/pointer-size result contracts. | +| `ProcessV2_ThreadList_DestroysItsCallerOwnedStringListWhenCollectionFails` | The StringList is destroyed when the stubbed collector throws. | +| `StructureGet_StubbedElements_MapTypesAndReportMissingNames` | Detailed/concise element projection, paging, child metadata and case-sensitive missing-name refusal. | +| `StructureGet_BoundedDefinitionLookupAndPagedElements_AvoidWholeStructureTraversal` | Lookup stops at the matching definition and reads only indices 900/901 despite reported totals of 70,000 definitions and 100,000 elements. | +| `StructureGet_MissingDefinition_ReportsNotFoundWithinBoundAndLimitExceededPastIt` | A known-complete search reports not found; a search beyond the 65,536-definition cap reports limit exceeded. | + +The ordinary embedded SDK bridge and the pinned standalone Lua 5.3 DLL execute the scripts against Lua table/function stubs. CE is not launched, a real process is not selected, and no debugger or compiler/injection workflow is used. The process fixture's create/open/save operations are simulated; its managed file-output check uses a unique file under the runner's private temporary directory and deletes its output afterward. The runner sets `TEMP` and `TMP` to that run's retained result directory, clears stack-trace instrumentation and golden-update variables, and leaves the user's ordinary environment unchanged. + +The runner fails skips, checks the exact five TRX names and their passed outcomes, and requires five executed/passed with zero failed/not-executed. A 60-second deadline terminates only the test process it created; it waits for that process to exit before finishing console capture. Results and temporary artifacts are retained for review. There is no automatic retry or follow-on CE run, even if all five pass. These simulated-host results cannot prove installed CE ABI equivalence or complete native lifecycle/performance acceptance. + +## Frozen inputs and command + +The manifest verifies 1,088 files: all 867 reviewed snapshot sources, the complete test output file inventory, the Lua DLL, dotnet host, evidence and preparation scripts. + +| Input | SHA-256 | +| --- | --- | +| Input manifest | `0BABAF0BB237A137C1EADA7EC05DA8A5395A8C2D443A774EBC917D5386031267` | +| Reviewed test module | `D7A21B4ADD7204DF3312473A13D22451041FFBE2339EE4FA12BF23D830D60697` | +| Lua library | `C95DCDFA0F60F97B43D970D77FD1BB907AF4DE04B500A3C89A99600B20B35BD2` | + +The manifest and runner are under `artifacts/native-navigation-preparation/`. Preparation and preflight read/hash files without loading the test module or Lua DLL. The existing clean Release build and portable evidence are reused because no source/test code changed for this preparation. No new test discovery or native test run was performed. + +Only after explicit approval: + +```powershell +& artifacts/native-navigation-preparation/Invoke-NavigationLuaRegression.ps1 -Execute +``` + +The earlier native-work stop still applies. Previous one-session approvals are consumed; this proposal does not inherit them. The separate ten-case Settings bridge run followed conditionally by one ordinary-SDK lifecycle session remains pending and unchanged. + +Final Astra review found no actionable preparation issues. It independently verified all 1,088 hashes (867 source and 216 test-output files plus the five preparation/runtime inputs), the exact five-case allowlist, outcome/count checks, owned-process deadline, temporary-file isolation and absence of a follow-on CE run. Both existing 859-file Settings inventories remain unchanged. No discovery or native execution was used in that review. diff --git a/docs/qualification/v2.0.0/navigation-package.md b/docs/qualification/v2.0.0/navigation-package.md new file mode 100644 index 0000000..8e33f5e --- /dev/null +++ b/docs/qualification/v2.0.0/navigation-package.md @@ -0,0 +1,31 @@ +# Local navigation packages + +Status: local packaging and independent Astra high evidence review passed for the [first navigation repair](navigation-preparation.md). Subsequent thread/symbol/breakpoint/structure repairs are not included in this package. This is an isolated development payload, not the final stable candidate or a published release. + +The packaging source is `artifacts/candidate-preparation/source-bbea5ef9e63c4b5cb8dca301224b39cb`. All 863 copied source hashes match the reviewed navigation snapshot. Build environment properties disable SCM discovery and explicitly set `RepositoryCommit` and `SourceRevisionId` to `0f722ef7d6b37eee0985073dfe594c6559b31c14`; all differences from that base remain separately recorded in the source inventory. Source identity alone does not represent these uncommitted changes. + +The maintained `eng/Publish.ps1` completed a locked Release publish. Its probe cold-loaded the exact lone DLL into two isolated contexts and compared both reflected catalogs with the reviewed goldens: 193 backend tools, 43 resources, 14 templates and 43 prompts. The bridge check loaded the ordinary embedded native bridge and inspected its export; it did not invoke Lua operations or attach to a target. The maintained Native AOT gateway smoke completed initialization and tool/resource/template/prompt listing against a fresh empty instance registry. + +`eng/Release.ps1` produced two byte-identical ZIPs in separate output directories. The maintained staging script verified the ZIP checksum, exact five-file flat inventory, per-file extraction hashes and matching plugin/gateway version identity. No installed deployment was replaced, no CE process or target was launched, and no release was uploaded. + +| Artifact | SHA-256 | +| --- | --- | +| Plugin DLL | `964AB04832035031FA07BF4EBD1D6A8CE8BA743754C0077CABF36F92D958DFDD` | +| Native AOT gateway | `DDB7CEC18D354F911B0F2CBABFC5C84B3F2C9A0A71AEC01A6E2F17B975758971` | +| ZIP, 17,759,980 bytes | `0A5056CC8B482F959DA4F7C150D3B0112F0B46F7CC509DC08A44D4EE516A1342` | + +Exact paths, logs, source hashes and extracted inventory are in `artifacts/candidate-preparation/package-evidence.json`. The development version remains `2.0.0-beta.2+0f722ef7d6b37eee0985073dfe594c6559b31c14`; this changed local payload must not replace published beta.2 binaries. Final version/commit identity, later repairs, clean-candidate checks, live qualification and publication remain outstanding. + +The [follow-up](navigation-followup.md) also corrects the explicit preparation tools' dispatch metadata. The first package predates that correction; the later package below includes it. + +## Latest reviewed follow-up package + +The 867-file reviewed follow-up was copied with exact hash verification to `artifacts/candidate-preparation/followup-aa947ac543104f65954bcee7297bbb63`. The same maintained locked Release publish succeeded, including both isolated exact-DLL catalog checks (193 tools, 43 resources, 14 templates, 43 prompts) and the Native AOT gateway smoke against an empty registry. Two local release ZIP builds were byte-identical; maintained staging verified all five files, checksums and plugin/gateway version identity. + +| Latest artifact | SHA-256 | +| --- | --- | +| Plugin DLL | `A52F05A8641AE09F0C3F608BCDFF414B4C7B1C62418CAEA220FF2054C01D8DFD` | +| Native AOT gateway | `622FFD85EF21728A8FA04F0BB6A85135EEF41A765BDD3DD30C76138C33498579` | +| ZIP, 17,796,609 bytes | `08208984369011EF8829D8508D57E08657762C86D55B5AE13A1031A03993F3AB` | + +Source/file hashes, exact paths and retained log hashes are in `artifacts/candidate-preparation/followup-package-evidence.json`. Independent Astra high package evidence review is scoped clear, including all 867 source hashes, both identical ZIPs, extracted inventories, retained logs and unchanged Settings preparations. This payload includes the repaired prepared-resource metadata and remains a local development version `2.0.0-beta.2+0f722ef7d6b37eee0985073dfe594c6559b31c14`. It cannot replace published beta.2 assets or supply final stable identity evidence. The bridge probe only loaded the ordinary bridge to check exports; no Lua operation, CE host or target was run. New native Lua regressions, integration into primary product source, native qualification and publication remain outstanding. diff --git a/docs/qualification/v2.0.0/navigation-preparation.md b/docs/qualification/v2.0.0/navigation-preparation.md new file mode 100644 index 0000000..2c4f149 --- /dev/null +++ b/docs/qualification/v2.0.0/navigation-preparation.md @@ -0,0 +1,46 @@ +# Isolated prepared navigation repair + +Status: implemented, portable-tested and independently source-reviewed by Astra high with no actionable findings in this scope. Final Release build passed with zero warnings/errors; all 4,207 portable tests passed with zero failures/skips. No native execution or integration into the primary source is claimed. + +Snapshot: `artifacts/performance-preparation/navigation-9c315236323c4d41bf2d60075d20e411`, copied from the verified 861-file [admission preparation](admission-preparation.md). Its final inventory has 863 files: 26 modified and two new relative to that snapshot. The ordinary package and pending Settings-observation inputs remain unchanged; both no-execution preflights still verify 859 inputs. + +The later [navigation follow-up](navigation-followup.md) repairs the remaining thread/symbol/breakpoint routes and corrects the explicit preparation tools' dispatch classes. This earlier snapshot and its separate local package retain the older labels; their review and test results do not supersede those subsequent findings. + +## Behavior + +Explicit attached-process `module_list`, `module_get`, and `memory_list_regions` calls prepare immutable inspection data for five seconds. Navigation reads perform one admitted current-target check, then use that prepared data. Module, region and section enumeration, symbol lookup and PE/PDB reads are absent from these prepared navigation paths. The explicit tools retain their native enumeration behavior; destination limits still do not establish native traversal bounds. + +Module and memory-map publications share a process ID, selection epoch and revision. A newer epoch invalidates old data; delayed older publication is refused. Module-only publication invalidates a prior map. Every read checks freshness against a monotonic clock; the exact five-second boundary is expired. Cold, stale or wrong-target resources return `invalid_state` with an explicit preparation hint. Completion offers no values on those failures. + +Module detail retains only the latest successful `module_get` result, accepting its canonical name or exact normalized selector. Each returned sections array is detached from the retained copy. Any later module or memory-map publication invalidates that detail result. Calls to `module_list` with an explicit `processId` do not populate or replace the attached-target snapshot. + +The new module-completion policy joins one in-flight listing and keeps the existing global dispatch admission and 750 ms wait. Successful results have no outer completion cache that could extend snapshot lifetime or hide a changed target; failed listings retain a one-second retry interval. Other completion policies remain unchanged. Request intervals and wait deadlines do not prove native dispatch occupancy. + +Resource URIs, tool names and JSON schemas are preserved. Requiring explicit preparation before navigation is an intentional behavior change, described in tool/resource metadata and embedded documentation. Admission qualification now prepares modules and regions before each timed round, recording those calls separately; that native workload remains unexecuted. + +## Verification + +| Requirement | Evidence | +| --- | --- | +| Exact expiry and process identity | `Modules_ExpireAtTheLifetimeBoundaryAndRejectWrongIdentity`, `IsCurrent_RejectsExpiredVersion` | +| Reject delayed old publication | `Modules_OlderEpochCannotReplaceNewerPublication`, `MemoryMap_StalePublicationAfterNewerModulePublicationIsRejected`, `ReadOfNewerTargetRejectsDelayedOlderPublication` | +| Atomic map/module version and invalidation | `MemoryMap_PublicationSharesModuleVersionAndModuleRefreshInvalidatesMap` | +| Preserve prepared projections without further inspection | `PreparedReads_EqualTheirExplicitPreparationAndDoNoFurtherInspection`, `ListPreparedRegions_ProjectsTheExactDefaultExplicitResultWithoutInspectionCalls` | +| Retain complete raw data after filtered source calls | `ListPreparedRegions_UsesTheCompleteMapAfterAnExplicitFilteredRead` | +| Prevent other-process and caller-array contamination | `List_OtherProcess_DoesNotPoisonTheAttachedPreparedTarget`, `PreparedDetails_AreDetachedFromExplicitAndPreparedCallers` | +| Fresh completion, coalescing and failure throttle | `Complete_FreshDispatchSuccessesAreNeverServedFromTheOuterCache`, `Complete_FreshDispatchConcurrentRequestsShareOnlyTheirInFlightListing`, `Complete_FreshDispatchFailureIsThrottledUntilItsRefreshInterval` | +| Cold, expired and switched-target wire behavior | `CompleteOverTheWire_ColdModulesNeverEnumerate`, `CompleteOverTheWire_ExpiredOrChangedTargetNeverOffersStaleModules` | +| Stable protocol surface | Reviewed backend/gateway tool and resource goldens; non-capture `ContractSnapshotTests` and `ToolCatalogContractTests` | + +Final verification from the isolated snapshot: + +```powershell +dotnet build tests/CheatEngine.Mcp.Tests/CheatEngine.Mcp.Tests.csproj -c Release --no-restore -p:EnableSourceControlManagerQueries=false -p:RepositoryCommit=0f722ef7d6b37eee0985073dfe594c6559b31c14 -p:SourceRevisionId=0f722ef7d6b37eee0985073dfe594c6559b31c14 -v:minimal +dotnet artifacts/bin/CheatEngine.Mcp.Tests/release/CheatEngine.Mcp.Tests.dll --filter-not-trait Category=LiveQualification --filter-not-trait Category=NativeLua --fail-skips on --report-trx --results-directory artifacts/navigation-portable-final-results +``` + +The final portable run passed **4,207/4,207**, zero skips, in 1 minute 44.590 seconds. Its scope includes the focused navigation and admission classes, contract snapshots, packaging fixtures, knowledge lint and remaining managed regressions. NativeLua and live qualification were explicitly excluded. Build, test module, log, TRX and every source hash are recorded in `artifacts/performance-preparation/navigation-evidence.json`; the recorder also verifies the prior snapshot and unchanged Settings manifests. The informational version identifies the base commit plus this separately hashed uncommitted source inventory; it does not identify a clean release candidate. + +Before the final run, 214/214 focused cases and 38/38 non-capture contract cases passed. The first full portable run passed 4,196/4,207: one documentation lint failure omitted the tool name for `maxOffset`, and ten packaging fixtures failed because disabling SCM queries removed source-revision metadata. The documentation now names the tool, stays within its existing byte budget, and the final build explicitly supplies `SourceRevisionId`. No packager behavior was changed. Reviewed golden changes contain only preparation descriptions and embedded document sizes; names, URIs and schemas remain unchanged. + +Astra's read-only source review and final evidence review found no remaining actionable findings in this repair. The final review independently verified all 863 snapshot hashes, 861 prior hashes, both 859-input Settings preparations, test counters, build identity, documentation budget and the four constrained golden changes. Integration, candidate packaging, native latency/admission tests and the wider lifecycle/release matrix remain open; this repair alone cannot close #28, #31 or #33. diff --git a/docs/qualification/v2.0.0/nested-dispatch-regression.md b/docs/qualification/v2.0.0/nested-dispatch-regression.md new file mode 100644 index 0000000..3a1c3e7 --- /dev/null +++ b/docs/qualification/v2.0.0/nested-dispatch-regression.md @@ -0,0 +1,86 @@ +# Standalone nested-dispatch regression + +Status: Release build, hash preflight and independent Astra preparation review complete. The user approved one execution, and both standalone native-Lua cases passed. + +## Purpose and limits + +The approved instrumented CE session `20261009T111321Z-688b` observed balanced stacks but no synchronized callback inside its three short AOB waits. Repeating that workload would not guarantee the suspected nesting. This preparation creates deterministic nesting in the SDK's existing standalone Lua test host. + +It exercises the production `MainThreadDispatcher`, protected Lua calls, callback lifetime and `LuaFrame` restoration. It does not use `DispatchOverrideForTests`. The two cases cover a successful nested callback and a nested managed exception while the outer callback remains inside a protected host-wait call. + +This can test the SDK's stack restoration under the simulated contract. It cannot qualify CE's actual scheduler, reproduce the historical native crash, establish its cause, or demonstrate a production fix. + +## Isolated inputs + +- SDK source archive: commit `325c47b573f8bd39a247f1d0101f110fa36c1696`, ZIP SHA-256 `CAC8BF7218249088597E18011A3333AB30A360872A9A917AFA3B7D7DCA1DF30C`. +- Private source directory: `artifacts/dependencies/sdk-reentry-tests`. It is an archive without its own Git metadata; the parent MCP checkout's HEAD is not its source revision. +- Production SDK source remains the uninstrumented baseline. Changes are limited to two new test/helper files and staging the original published native bridge. +- Published bridge SHA-256: `B008C8D8C136187F241542E6223DC0831999D8300DC2C4C01E1CF49F6FBA7698`. +- Staged Lua library: `artifacts/native-lua-reentry/lua53-64.dll`, SHA-256 `C95DCDFA0F60F97B43D970D77FD1BB907AF4DE04B500A3C89A99600B20B35BD2`. +- Build identity: `2.0.1-mcpreentrytest.1`, explicit SDK source commit and disabled parent Git source queries. + +The earlier instrumented source trees, packages and raw run evidence are preserved separately. + +## Required assertions + +1. Both workers use distinct coroutines of the same Lua global state. Their queued callbacks execute on their captured worker states on the main OS thread, while the SDK's provider returns the main root state. +2. Callback order is exactly `outer-enter`, `wait-enter`, `inner-enter`, `inner-exit`, `wait-exit`, `outer-exit`. +3. Nested SDK work encounters the root host-wait sentinel at a nonzero stack height, changes that stack inside a frame and restores it on both success and exception. +4. Both worker stacks retain their original sentinel values and heights after dispatch returns. Main-thread dispatch flags are reset after the outer callback and on both joined workers. +5. The exact nested exception reaches the inner caller, while the outer caller continues successfully. + +## Process and lifetime boundary + +Only one OS thread may access the shared Lua global state at a time. Both workers must reach the native synchronize stand-in and block before the main thread pumps either callback. Completion of a callback does not release its worker's native epilogue. After the entire outer callback unwinds, the main thread releases and joins one worker before releasing the other. + +Lua states, coroutine references and wait handles remain alive until all workers exit. A failure that leaves a worker active must prevent another theory case or teardown from using those states. The external runner gives the isolated test process a 60-second execution deadline and terminates only that owned process if necessary. + +## Prepared runner + +`artifacts/issue-edits/Invoke-ReentryLuaTests.ps1` defaults to hash verification without execution. Its explicit `-Execute` mode invokes only `CheatEngine.SDK.Hosting.Tests.Threading.MainThreadReentrancyTests` with the `NativeLua` trait, fails skips, and requires exactly two executed and passed cases in the TRX report. It uses the staged Lua DLL, removes the trace opt-in variable, records console/results and applies bounded cleanup after runner errors. + +`Save-ReentryTestInputs.ps1` checks the extracted sources against the pinned archive, rejects unexpected source changes, and records hashes for the test output tree, source tree, runner, Lua library and .NET host. The final manifest is created only after the source and Release build are complete. + +No Cheat Engine process, target process, debugger, compiler/injection workflow or capability gate is used. The previous one-session instrumented CE approval was consumed. The user then separately approved one execution of these exact standalone cases; that execution is complete. No additional native execution is scheduled. + +## Completed offline evidence - 2026-10-09 + +- Locked restore passed. Release build of `tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj` passed with zero warnings/errors after correcting the new files' compile and style diagnostics. Initial failures are retained in `goal-reentry-test-build.log` and `goal-reentry-test-build-2.log`; the successful result is `goal-reentry-test-build-3.log`. +- Source comparison checked 1,277 entries. Exactly two new test/helper files and the original published native bridge differ from the archive; no existing SDK production source changed. +- No-execution runner preflight verified all 1,485 input hashes. The source and output copies of the native bridge both match the published bridge hash above. +- Test module SHA-256: `81F3DCABC50555F27FC9799CB94A73AAE231416447F8424BCAE5334E3E56E0A0`. +- Input manifest SHA-256: `8F921108C1B48C8891903C94AD20E9B5F8BE6996451AE63DA7135BB9C2630A29`. +- Installed runtime inventory records .NET x64 10.0.12; the SDK is pinned to 10.0.401. `dotnet --list-runtimes` passed. `dotnet --info` printed the identities but exited nonzero because sandbox access to Service Control Manager was denied; that command is not recorded as a successful check. +- Independent Astra high review found no remaining actionable findings in the prepared source, callback serialization, lifetime/quarantine paths, runner and exact input hashes. It verified the output bridge identity and successful build. It did not execute the cases. + +The exact build command, from the private SDK directory: + +```powershell +dotnet build tests/CheatEngine.SDK.Hosting.Tests/CheatEngine.SDK.Hosting.Tests.csproj -c Release --no-restore -p:MinVerVersionOverride=2.0.1-mcpreentrytest.1 -p:EnableSourceControlManagerQueries=false -p:RepositoryCommit=325c47b573f8bd39a247f1d0101f110fa36c1696 -p:SourceRevisionId=325c47b573f8bd39a247f1d0101f110fa36c1696 -v:minimal +``` + +The approved execution command, run once from the MCP workspace: + +```powershell +& artifacts/issue-edits/Invoke-ReentryLuaTests.ps1 -Execute +``` + +| Requirement | Evidence | +| --- | --- | +| Deterministic nested success and managed exception cases compile | `MainThreadReentrancyTests.Nested_synchronized_dispatch_preserves_worker_and_root_stacks(false/true)`; successful Release build and reviewed source | +| Exact inputs and isolated runner are prepared | 1,485-hash preflight; source/output bridge match; Astra scoped clear | +| Both native cases pass | Exact false/true cases passed in the approved run below; no real-CE qualification | + +## Approved standalone execution + +Run directory: `artifacts/reentry-lua-results/885121c89ef846fead7b76804b21e5f3`. The single approved runner invocation completed with **Passed: 2, Failed: 0, Skipped: 0**, total duration 237 ms. The TRX names identify both `innerThrows: False` and `innerThrows: True` cases; both passed. + +The owned test process (PID 46208) exited with code 0, without timeout, runner failure or cleanup failure. A follow-up process check found that PID absent. All 1,485 prepared input hashes still matched after execution. + +- Receipt SHA-256: `A99389CBB5DE27B418688FF3252A215CCCAF1D8473073B3F9B76E172ABCA7A11`. +- TRX SHA-256: `3B0C076521EDC02D36636319990259A4FC33ADB5C11A49143E78E940A532488D`. +- Evidence extract: `artifacts/issue-edits/goal-reentry-test-execution-check.json`. + +This result demonstrates balanced restoration and correct exception ownership for the two deterministic simulated-host paths. The earlier CE trace contains no synchronized reentry, so these passing standalone cases do not connect the historical CE failure to this mechanism, rule out other schedules, or demonstrate a production fix. The original crash and native qualification gates remain open. + +Independent Astra high final review confirmed both exact TRX cases, console/receipt agreement, raw evidence hashes and all 1,485 input hashes, with no remaining actionable findings in that scope. Preparation, approved execution and evidence review were checked off in #28 and #33; exact body readback confirmed both issues remain open. #31 remains open unchanged. diff --git a/docs/qualification/v2.0.0/next-live-investigation.md b/docs/qualification/v2.0.0/next-live-investigation.md new file mode 100644 index 0000000..6c93a56 --- /dev/null +++ b/docs/qualification/v2.0.0/next-live-investigation.md @@ -0,0 +1,39 @@ +# Next live investigation for the unresolved dispatch crash + +Status: The user explicitly authorized this bounded real-CE investigation on 2026-10-09 after the proposal was reviewed. Two initial runs, one run with the exact post-AOB allocation probe, and one with the restored original read-only prelude passed. Results are recorded in [remaining-checkpoint.md](remaining-checkpoint.md#authorized-bounded-ce-investigation---2026-10-09). The historical crash remains unresolved. This permission is separate from the earlier standalone NativeLua approval and does not authorize other native or release gates. + +## Executed bounded investigation + +The maintained `dispatch-diagnostic` scenario first ran `MemoryNamedScanThenAob` against the already verified local Release distribution at `artifacts/dist/goal-contract2-release`. This exercises the memory, named-scan and AOB ordering that preceded the recorded Lua stack failure. The narrower `NamedScanThenAob` and `AobOnly` cases were available to isolate a failing prefix, but these passing runs did not justify executing them. Each executed case was a separate disposable session; it is diagnostic evidence, not a full lifecycle or stable-release pass. + +Package identity and hashes are in [remaining-checkpoint.md](remaining-checkpoint.md#offline-package-refresh---2026-10-09) and `artifacts/issue-edits/goal-contract2-package-manifest.json`. The package uses the ordinary SDK dependency and uncommitted reviewed MCP source; the unfinished private diagnostic SDK/MCP clones are outside this proposed run. + +The runner settings select Release, x64 targets, `dispatch-diagnostic`, the explicit case, and the absolute current distribution path through `CHEATENGINE_MCP_LIVE_QUALIFICATION_DISTRIBUTION_DIRECTORY`. Following the explicit user authorization, the existing live acknowledgement is set only for the command process and restored afterward. No compiler or managed-injection acknowledgement is supplied. + +The two initial passes exercised the common Lua dispatcher/frame again through scalar sentinel reads, but did not exercise the allocation operation that failed after AOB in the older lifecycle run. The reviewed diagnostic now adds `memory_allocate` with name `live-dispatch-post-aob`, size 64 and `executable=false` immediately after AOB cleanup, followed by `memory_free` in `finally` after confirmed acquisition. Acquisition, release and cleanup results are recorded separately. This matches the first operation in the original CFG fixture without adding CFG writes or graph calls; product source and package bytes remain unchanged. + +After that probe passed, source comparison identified the next controlled difference: both failed lifecycle runs called `VerifyBothAsync` and `LiveResourceQualification.VerifyAsync` after attachment and before the memory/scanner/AOB sequence. The separate `ResourcePreludeMemoryNamedScanThenAob` diagnostic case restores those exact read-only prelude calls and keeps the same allocation/free endpoint. The prelude checks runtime identity/gates and scalar sentinels, reads static catalogs/documentation and each host's runtime resource, renders a prompt and checks instance completions. Rendering the prompt does not execute its debugger workflow. This variant does not run the later full lifecycle operations. It was explicitly selected for its run; omitting the case selector now runs all four fixed diagnostic cases in separate sessions. + +## Workload and ownership + +- Launch two private CE copies and two test-owned x64 targets through the existing live harness, with an isolated run directory outside the repository and installed CE directory. +- Keep all four optional capability gates disabled. Exercise only the bounded memory/scanner/AOB workflows, the post-AOB 64-byte non-executable allocation/free probe and final sentinel reads; their temporary allocations and named scanner state are owned and cleaned up by the harness. +- Preserve the existing refusal when CE or DebugView is already running; do not close user processes to satisfy preflight. Snapshot and restore the user settings that the private-host harness temporarily changes, and leave the source installation unchanged. +- Keep the existing five-minute private-host lifetime bound. Retain the original operation failure alongside any cleanup failure, then verify that the owned hosts/targets stopped and user state was restored. +- Stop for review after a crash, failed restoration or unexpected effect. No debugger, extended compiler, injection, kernel/DBVM, soak, tag, upload or release is included. + +## Evidence required before any completion claim + +Record the exact package and host hashes, selected case, original error or crash, bounded operation ordering, cleanup receipts and owned-process termination. A pass in this reduced scenario does not resolve the previously observed uninstrumented production crash or qualify plugin lifecycle. Root-cause evidence, a demonstrated production fix, original-order lifecycle reruns, and the other issue gates remain separate. + +## Next preparation boundary + +The four approved ordinary-SDK sessions are complete. A separately approved private stack-balance/reentrancy session also passed as `20261009T111321Z-688b`, retaining the exact published native bridge and measuring actual versus saved stack heights. The [diagnostic ledger](stack-trace-diagnostic.md) records the workload, hashes, offline checks, execution evidence and limits. Its 1,874 trace records show balanced instrumented boundaries, but no synchronized callback reentry during the three short AOB waits. No crash was reproduced and no production fix demonstrated. This one-session approval is consumed; no further live run is scheduled. An offline trace validator now passes 11 synthetic controls and both recorded host snapshots; a discriminating reproduction remains to be prepared. The callback/provider pointer difference alone remains expected under CE's per-thread state contract. + +Two separately approved [standalone nested-dispatch cases](nested-dispatch-regression.md#approved-standalone-execution) now pass. They force genuine dispatcher callbacks on captured worker coroutines during a protected root-state call, including stack perturbation and a managed inner exception. They establish that these simulated schedules restore the stacks correctly, so nesting alone has not reproduced the historical defect. A next live proposal must target a remaining difference in the real failing lifecycle or scheduling evidence; repeating the same short passing prefix does not supply that evidence. + +The separately approved [ordinary-SDK full lifecycle attempt](lifecycle-resume.md#approved-execution-result), `20261009T120128Z-8f44`, then completed all six workflow groups before the first Settings/Plugins disable request refused with `expected exactly one owned plugin row`. Cleanup and settings restoration passed. This reproduced the remaining bridge failure with the ordinary package, while still not reproducing the earlier native stack failure. Its one-session approval is consumed; further work is offline source-based bridge diagnosis before any new execution proposal. + +The subsequent [retained-key experiment](lifecycle-key-fix.md#approved-conditional-execution) passed eight approved standalone bridge cases, then its one conditionally approved lifecycle run `20261009T121623Z-71c7` again completed all six workflow groups and refused the first disable with measured `count=0`. Both 859-file manifests still matched; settings/source preservation, cleanup and owned-process termination passed. Astra confirmed the evidence. Exact source review then disproved the key-needed premise: checklist population is outside the key condition. That unsupported change was rolled back while keeping the observed-count error and zero-row test. The two-stage approval is consumed; the next source investigation must distinguish reload early return, swallowed exception and empty plugin list before any further execution is proposed. + +The [Settings observation proposal](settings-observation.md) is now built and independently reviewed, with exact manifests for ten standalone bridge cases and a conditional one-session lifecycle attempt. It adds only read-only runtime class and pre-reload count observations to the existing bounded refusal. These narrow the observable possibilities but do not fully distinguish a swallowed exception from an empty plugin list. Both stages await renewed authorization; no further native run has occurred. diff --git a/docs/qualification/v2.0.0/offline-contract-audit.md b/docs/qualification/v2.0.0/offline-contract-audit.md new file mode 100644 index 0000000..ab57059 --- /dev/null +++ b/docs/qualification/v2.0.0/offline-contract-audit.md @@ -0,0 +1,56 @@ +# Offline contract audit + +Date: 2026-10-09. +Scope: working-tree source, public contract metadata, result mappings and portable checks. +This is not installed-collector, native workflow, timing, or final-package qualification. + +## Coverage + +The metadata inventory is `tests/CheatEngine.Mcp.Tests/Contract/Golden/tool-summary.txt`: 193 backend tools and the gateway's `instance_list`. +The remaining-domain reviewer traced all 162 tools outside the previously reviewed Asm, Record, Runtime and Table domains. +The result-shape reviewer covered the other domains and gateway; the managed collector review is recorded in [contract-checkpoint.md](contract-checkpoint.md). + +| Domain | Backend tools | Metadata and result review | +| --- | ---: | --- | +| Aob | 3 | An indeterminate scan qualifies its empty result with `exact`, `unique` and `targetVerified`; no additional mismatch. | +| Asm | 8 | Earlier patch-release `may_prompt` correction; optional diagnostics and documented empty hook-template bytes retained. | +| Code | 14 | Each search creates a fresh job, so its starter is non-idempotent. Pinned dissector source confirms nil tables mean no recorded references, strings or functions. | +| Debugger | 18 | Feature-dependent branches traced; invalid state, optional registers and loss/truncation qualifiers distinguish unavailable observations. | +| DotNet | 10 | Constant metadata codes and collector array positions no longer promise declared parameter types or ordinals; see the pinned-source analysis. | +| Exec | 7 | Fixed execution requirement and conditional kernel compiler requirement match the implementation; unavailable paths/diagnostics remain optional. | +| Kernel | 7 | Watch polling drains a native log and can evict retained events. Initialization probes must distinguish unavailable from an observed false. | +| Lua | 2 | API lookup reads the host installation's `celua.txt`, so it is open-world. Return and search truncation/counts qualify their arrays. | +| Memory | 19 | Dump overwrite is destructive. The system-module probe must distinguish unavailable from false; read/batch errors already preserve unavailable values. | +| Module | 5 | Import/export enumeration parses the full directory in one dispatch before paging, so it is `host_scan`. The SDK requires a string path; no separate unavailable-path sentinel was established. | +| Mono | 15 | Fixed and conditional execution requirements traced; fields, optional method facts and object reads reviewed against the pinned public collector source. | +| Pointer | 16 | Imported maps/scans have no job, so their job ID is omitted; live capture/search results retain the actual ID. | +| Process | 9 | Target state is qualified by `isOpen`; optional process facts and file size remain unavailable when unobserved. | +| Record | 14 | Earlier destructive-write, AA requirement and prompting corrections; empty dropdown text remains a valid value. | +| Runtime | 6 | Earlier bulk-release prompting correction; empty jobs/resources are valid and process facts are qualified by attachment state. | +| Scan | 8 | A detached main scanner has no process ID. FoundList returns formatted strings, including read-failure markers, without a separate readability flag. | +| Speedhack | 2 | An unavailable symbol probe must not become observed absence or decide whether a mutation is needed. | +| Structure | 15 | Unreadable values are omitted and comparisons classify them. The CE bridge returns field names as strings; an empty name does not establish missing metadata. | +| Symbol | 10 | A non-table module-preference result must be a refusal, distinct from a valid empty list. | +| Table | 3 | Earlier save-overwrite correction; opaque format explicitly qualifies zero inspection counts. | +| Util | 2 | Pure calculations/conversions preserve valid zero and empty results. | + +Gateway forwarding preserves the backend result; `instance_list` qualifies incomplete discovery explicitly. +The audit treats a legitimate documented sentinel differently from a false observation. Defensive fallbacks alone are not proof that a native API can actually omit its promised data. + +Pinned public source confirms that [`CheckAddress`](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/DissectCodeThread.pas#L494-L576) returns false when no references are recorded, and the [Lua wrapper](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaDissectCode.pas#L144-L271) uses nil for these empty lists. Those normalizations remain unchanged. +[`FoundList.Value`](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaFoundlist.pas#L82-L100) always returns text, and [its reader](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/foundlisthelper.pas#L626-L665) includes formatted failure markers. The result description now states that a formatted or empty value is not proof of readability and directs callers to `memory_read` to verify it. + +## Corrections and verification boundaries + +Six more tools receive effect-metadata corrections: `code_start_search`, `memory_dump_to_file`, `lua_find_api`, `kernel_poll_watch`, `module_list_exports` and `module_list_imports`. +The kernel exception is explicit in contract validation: this tool must be destructive, non-read-only and non-idempotent, while passive poll tools retain their existing rule. +Recovery guidance and the legacy tool map distinguish the native-log drain from the passive retained-page primitive. +The module classification is conservative source review, not a measured duration or a claim that the work was made faster. +Its exports resource projection is removed before contract freeze because the unchanged resource validator admits only short source tools. Explicit `module_list_exports` calls remain available; the prerelease URI removal and replacement are recorded in the release notes. + +The result corrections cover imported pointer job IDs, memory/kernel status probes, detached scanner IDs, module-preference failures and speedhack probe failures. +The prepared fixed-Lua regressions subsequently passed in the explicitly authorized standalone Release NativeLua suite: 626 passed, zero failures/skips. These run against a standalone Lua 5.3 runtime with CE-shaped host stubs; they do not qualify actual CE operations or collector behavior. Live CE, target attachment, native diagnostic, debugger and extended compiler/injection work remain stopped. +The .NET parameter correction preserves raw payload fields and documents unrecoverable collector ambiguity; it does not synthesize types from signature text. + +Both Debug and Release portable suites passed 4,135 tests; builds, style and whitespace checks passed. Independent Astra final review found no remaining actionable findings in this scoped patch and its evidence; details are recorded in [remaining-checkpoint.md](remaining-checkpoint.md). +Roadmap rows 3.03 and 3.06 still require the applicable native and exact-candidate evidence before final contract freeze. diff --git a/docs/qualification/v2.0.0/performance-preparation.md b/docs/qualification/v2.0.0/performance-preparation.md new file mode 100644 index 0000000..da4ec60 --- /dev/null +++ b/docs/qualification/v2.0.0/performance-preparation.md @@ -0,0 +1,59 @@ +# Isolated performance preparation + +Status: isolated implementation, clean Release build, focused managed regressions and final Astra high review complete. No new native performance, lifecycle, or soak run is authorized or claimed. + +The snapshot is `artifacts/performance-preparation/source-f695c724e788433c97cb34470f17278a`. It was copied from the current working tree at HEAD `0f722ef7d6b37eee0985073dfe594c6559b31c14`; it is not a clean release commit. Changes below have not been integrated into the primary source tree or the ordinary product package. The prepared [Settings observation](settings-observation.md) and its two 859-file manifests remain unchanged while its execution decision is pending. + +## Workload matrix + +The earlier performance runner measured only short scalar reads and 16-item batches. The isolated expansion adds fixed work sizes, warmups and sample counts for larger batches, regions, named scans, AOB, baseline-compatible `pointer_read_chain`, module/symbol resolution, structures, dissection and discovery. It retains three separately started published-beta.2 baseline sessions and three candidate sessions, with exact published baseline plugin/gateway hashes checked before use. + +Samples measure complete client workflows, including transport, decoding, assertions and any explicitly included polling/reset/cleanup. They do not isolate CE native execution time. Scan/dissection polling deadlines begin after their start calls return; those calls can themselves block or outlive a client timeout. The current 100 ms short-call allowance and 1.5-times bounded-work threshold remain the planned acceptance policy in [acceptance.md](acceptance.md#performance-acceptance), not measured results. + +All 190 shared baseline/current backend input schemas match after ignoring only documentation fields (`title` and `description`). That comparison does not establish output or native-behavior compatibility. The new runner deliberately uses the baseline's single-chain pointer tool, not the newer batch-chain tool. + +The first independent Astra review found scanner reuse without reset, incomplete result assertions, mismatched profile validation gaps, ownership recorded too late after mutations, an AOB range extending beyond its stated allocation, and pass recording before gateway disposal. All were repaired. A second pass found that scanner cleanup could replace the original failure, and that a future runtime receipt would incorrectly label measured work as unexecuted. The repaired cleanup helper preserves both errors; its four outcome combinations passed managed regression tests. Final Astra high review found no remaining actionable findings in the prepared matrix and pointer-retention patch. + +Pinned public CE source synchronously waits for dissection work inside its Lua binding: [LuaDissectCode.pas, lines 31–83](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaDissectCode.pas#L31-L83) calls `waitTillDone` after `dowork`; [DissectCodeThread.pas, lines 420–432](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/DissectCodeThread.pas#L420-L432) implements the completion wait. Installed-binary equivalence remains unproven. The harness must retain the owned target buffer through uncertain start, response, poll or clear outcomes and leave its final containment to owned target shutdown. A stop receipt alone does not prove that native work ended. + +## Pointer-reference cleanup retention + +The resource audit found one concrete retention gap. A live pointer-reference range search creates a temporary value-scan session. If releasing it fails, the tool correctly tracks the session, but previously allowed another range search to append another failed session indefinitely. Lua ledger and managed-job limits do not cap these directly tracked Client leases. + +The isolated fix reserves one range-search slot before dispatch. Successful cleanup or failure before creating a session releases that reservation; failed cleanup retains it until the resource's release/acknowledgement callback runs. Identity comparison prevents an older callback from clearing a newer search's reservation. Exact AOB and stored-map paths do not use this temporary session. A failure to register the retained resource conservatively leaves the slot closed; a new activation is the recovery boundary if no resource was registered. + +The six focused managed cases passed and cover repeated cleanup-failure refusal, scan failure followed by cleanup failure, acknowledgement, retryable cleanup followed by successful release, reentry, and pre-body dispatch failure. Independent Astra source review found no actionable correctness defect in this scoped fix. + +## Managed verification + +- Release build: zero warnings and errors (`performance-final-build.log` in the snapshot). +- Final performance/lifecycle helper selection: 20 passed, zero failed/skipped (`performance-final-tests.log` and `artifacts/performance-final-results/*.trx`). +- All pointer regressions: 130 passed, zero failed/skipped (`pointer-managed-regressions.log` and `artifacts/pointer-managed-regressions/*.trx`). This separate run preceded the final performance-only cleanup repair; pointer source and tests did not change afterward. +- Source inventory: 856 verified files, exactly six changed/new files relative to the initial snapshot. Exact source, build-log and TRX hashes are retained in `artifacts/performance-preparation/preparation-evidence.json`. +- Both primary Settings no-execution preflights still verified 859 inputs. No native process was started. + +| Requirement | Managed evidence | +| --- | --- | +| Reject missing or mismatched paired workload evidence | `Pair_RefusesBaselineMissingOneMatrixWorkload`, `Pair_RefusesCandidateWithWrongFixedSampleCount`, `Pair_RefusesBaselineWithWrongFixedSampleCount` | +| Preserve a scan failure when cleanup also fails | `RunWithCleanupAsync_PreservesBothFailuresAndAlwaysReleases` (four combinations) | +| Prevent repeated failed pointer scans from accumulating resources | `FailedCleanup_RefusesRepeatedRangeSearchesWithoutAddingResources` (two cases) | +| Permit subsequent searches after confirmed release or acknowledgement | `RetryableCleanup_HoldsReservationUntilReleaseSucceeds`, `AcknowledgedFailedCleanup_AllowsANewRangeSearch` | +| Refuse reentry and recover from pre-body dispatch failure | `ReentrantRangeSearch_IsRefusedBeforeAnotherDispatch`, `DispatchFailure_ReleasesReservationForTheNextRangeSearch` | + +Successful commands, run from the isolated snapshot: + +```powershell +dotnet build tests/CheatEngine.Mcp.Tests/CheatEngine.Mcp.Tests.csproj -c Release --no-restore -p:EnableSourceControlManagerQueries=false -p:RepositoryCommit=0f722ef7d6b37eee0985073dfe594c6559b31c14 -v:minimal +dotnet artifacts/bin/CheatEngine.Mcp.Tests/release/CheatEngine.Mcp.Tests.dll --filter-class CheatEngine.Mcp.Tests.LiveQualification.LivePerformanceMatrixTests --filter-class CheatEngine.Mcp.Tests.LiveQualification.LiveLifecycleQualificationTests --fail-skips on --report-trx --results-directory artifacts/performance-final-results +dotnet artifacts/bin/CheatEngine.Mcp.Tests/release/CheatEngine.Mcp.Tests.dll --filter-class 'CheatEngine.Mcp.Tests.Tools.Pointer.*' --fail-skips on --report-trx --results-directory artifacts/pointer-managed-regressions +``` + +The first focused test attempt was 32/33: the retryable-release test used the fixture's default Lua ledger, whose fixed-Lua executor is deliberately unavailable in portable tests. It was corrected by injecting a managed-only `TargetResources` into that one test, after which all 33 focused tests passed. The final cleanup regression addition then passed its 20-test selection. No native substitute, broad native suite, full portable-suite rerun, or candidate-package rebuild is claimed. + +## Remaining requirements + +- The native matrix is unexecuted; roadmap 9.01 and its performance acceptance remain unchecked. +- Multi-client concurrency/UI and resource/completion measurements are now implemented, managed-tested and Astra-reviewed in a separate [admission preparation](admission-preparation.md) snapshot. Native 9.03/9.05 qualification remains outstanding. +- CE-backed module completion uses dispatch admission, but cached and empty responses cannot prove a fresh native call or a busy refusal. A 750 ms completion wait can return while its native refresh continues. +- Package-matched Client/SDK source confirms that collection caps apply after full native enumeration. A separate [prepared-navigation repair](navigation-preparation.md) now redirects module/detail/region resources and module completion to explicit five-second snapshots and passes 4,207 portable tests. It is not integrated into the primary source/package; explicit source-tool native enumeration remains unbounded by copy/page limits. +- The two-hour soak, lifecycle failure, historical SDK stack failure, broader native/compiler/installation/client matrix, final clean-candidate checks and publication are still outstanding. Issues #28, #31 and #33 cannot be closed from this preparation. diff --git a/docs/qualification/v2.0.0/release-notes.md b/docs/qualification/v2.0.0/release-notes.md index 254545a..ff2d062 100644 --- a/docs/qualification/v2.0.0/release-notes.md +++ b/docs/qualification/v2.0.0/release-notes.md @@ -12,17 +12,32 @@ Publication requires the final candidate gates in [release-signoff.md](release-s - Publication compares the actual bundled plugin against reviewed tool, resource, template, and prompt snapshots. - Build CI now runs the portable suite in Debug and Release. - The private native fixture supports separate x86 and x64 runs and target restart checks. +- Internal tool errors include the logged correlation ID; non-object details are preserved under `details.value` alongside `errorId`. +- `record_create` and `table_save` advertise their potentially destructive writes. `record_set_script` declares and checks its Auto Assembler requirement before dispatch; `record_clear` includes the missing recovery hint when that switch is off. +- Record activation/deletion/clear and individual or bulk patch release advertise `may_prompt`, because script execution can block CE or show dialogs. +- `dotnet_get_method_parameters` now describes `elementType` as the collector's metadata-constant code and `index` as its returned array position; declared types come from the optional signature, and the collector can return incomplete or placeholder entries. +- Additional effect annotations cover fresh code-search jobs, destructive file dumps, host-file API lookup, consuming DBVM polls and potentially blocking module-directory reads. +- The prerelease resource URI `cheatengine://instance/modules/{module}/exports{?offset,limit}` is removed: full export-directory reads can block CE and must not be prefetched as a short resource. Use the unchanged `module_list_exports` tool explicitly; module-list and module-detail resources remain available. +- Unavailable memory/kernel status and imported pointer job IDs are omitted instead of fabricated as false or empty text. Detached scanner results omit the process ID; failed module-preference and speedhack probes report refusal. An unavailable post-change speedhack observation omits `hooksInstalled` while retaining the restore resource. +- Module, region, thread, registered-symbol and breakpoint resources now use five-second prepared snapshots. Call the corresponding explicit list/get tool before reading its resource; cold, expired or target-mismatched snapshots report `invalid_state`. Module-name completion also requires a fresh attached-target module list. The explicit enumeration tools declare `blocking_native`; their result limits do not bound CE's underlying enumeration. +- `structure_get` bounds global definition lookup and reads only the requested element page. Thread collection now owns and disposes the StringList required by CE. Failed cleanup of a pointer-reference range search retains one reservation and refuses another search until recovery. + +The optional-field omissions, removed exports URI and explicit resource-preparation requirement above are beta-to-stable compatibility exceptions that still require final stable contract review (requirement 2.07). Clients must distinguish omitted status/job fields from observed false or empty values, use `module_list_exports` for exports, and prepare snapshots before navigation. The stable contract has not frozen, and the beta tags are unchanged. The [MCP-only closeout](mcp-closeout.md) permits administrative issue closure without stable sign-off. The approved compiler-only phases passed on the exact local candidate and environment recorded in [compiler-checkpoint.md](compiler-checkpoint.md), with separate x86 and x64 disposable targets. Both immediate exports survived both CE shutdowns; raw artifacts were retained at the observation points with distinct per-instance temporary roots. Shared-temp expiry, unavailable prerequisites, reload, and separate injection remain unqualified, as does the final stable package. +An uninstrumented local candidate also exposed native Lua dispatch corruption, including a private-host crash. +Later instrumented runs did not reproduce that failure, but they do not establish a production fix. +Stable publication remains blocked on resolving it and completing the missing qualification in [remaining-checkpoint.md](remaining-checkpoint.md). + ## Support and limitations The package remains Windows x64 with an x64 Cheat Engine 7.7 host and a self-contained x64 gateway. The plugin requires x64 .NET 10, ASP.NET Core 10, and Windows Desktop 10 runtimes. Target architecture and optional compiler, collector, debugger, kernel, and DBVM availability are separate from plugin-host support. -Use the [support matrix](support-matrix.md) and evidence ledger for exact tested environments and outstanding qualification. +Use the [support matrix](support-matrix.md) and [evidence ledger](release-signoff.md) for exact tested environments and outstanding qualification. Instruction text is authoritative for supplied pointer analysis; optional instruction bytes validate length and supported address-size facts and are not an independent decoder. Post-execution register facts and indexed operands can be uncertain or dynamic. @@ -34,7 +49,7 @@ An unreadable final value does not turn a resolved pointer address into a failed 1. Preserve the existing matching plugin and gateway pair as a rollback copy, along with personal configuration and CE's runtime configuration. 2. Disable the plugin, close Cheat Engine, and stop the MCP client's gateway before replacing loaded files. 3. Install the plugin DLL and gateway from the same verified package into a new directory. -4. Remove an obsolete folder-deployment plugin entry before selecting the new single `CheatEngine.Mcp.dll`. +4. Replace the previous entry for this plugin when its path changes, including a folder deployment, before selecting the new single `CheatEngine.Mcp.dll`; keep unrelated plugin entries. 5. Point the MCP client at the new gateway executable and enable the plugin. 6. Discover a fresh `instanceId` using `instance_list`, then verify `runtime_get_info` before attaching to a target. @@ -43,8 +58,9 @@ The four capability switches remain enabled by default and are exposure controls File and table allowed roots remain empty by default. Do not overwrite or delete unrelated CE plugins, tables, records, or settings during an upgrade. -To roll back, close both processes, restore the previous matching plugin/gateway paths and any deliberately changed configuration, and rediscover the new activation. +To roll back, close both processes, replace this plugin's CE entry with its previous entry, restore the matching gateway registration and any deliberately changed configuration, and rediscover the new activation. Do not force replacement of locked files or reuse an old activation identifier. +The [installation recovery guide](../../../srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/connection-troubleshooting.md#several-instances-updates-and-removal) covers a locked gateway, partial upgrade, rollback and removal. These are migration instructions; clean-install, upgrade, rollback, and supported-client qualification must still be recorded on the final package. ## Release identity diff --git a/docs/qualification/v2.0.0/release-signoff.md b/docs/qualification/v2.0.0/release-signoff.md index edb83ca..6a6eda1 100644 --- a/docs/qualification/v2.0.0/release-signoff.md +++ b/docs/qualification/v2.0.0/release-signoff.md @@ -1,6 +1,8 @@ # Stable v2.0.0 release sign-off -Status: Planned and not approved for release. +Status: Not approved for release. The real-CE Lua dispatch crash remains unresolved; passing offline and standalone Lua checks do not qualify the production host. + +The maintainer subsequently requested an [MCP-only implementation closeout](mcp-closeout.md), administrative issue closure and a tested local commit to `main`. The original stable-release criteria below remain unmet; issue closure no longer represents stable sign-off. This document is the durable progress and evidence index for the stable v2.0.0 program. Unchecked items remain incomplete even when related source code exists. @@ -18,6 +20,7 @@ Issue #28 checkboxes must be updated only after the corresponding evidence is re - [x] Complete the `compileCS` feasibility check under a reviewed policy; see the exact environment and remaining native cases in [compiler-checkpoint.md](compiler-checkpoint.md). - [ ] Complete `exec_compile_csharp` implementation and qualification or record an explicit reviewed deferral with a linked follow-up. - [ ] Reconcile and freeze the final tools, schemas, resources, prompts, completions, routing, capability gates, and migration contract. +- [ ] Diagnose the production Lua dispatch failure, demonstrate a fix without timing-altering instrumentation, and repeat the failed native workflow/lifecycle cases on the corrected candidate. - [ ] Complete lifecycle, recovery, workflow-domain, installation, upgrade, rollback, and supported-client qualification. - [ ] Record reproducible performance baselines and approve the measured-relative thresholds. - [ ] Complete the planned two-hour soak against the exact RC package. @@ -34,12 +37,36 @@ Issue #28 checkboxes must be updated only after the corresponding evidence is re Working-tree evidence is recorded in [local-checkpoint.md](local-checkpoint.md), including package hashes, environment, test counts, two native run identifiers, cleanup, and limitations. It does not establish a clean final stable candidate. Compiler-only evidence for a clean local candidate is recorded in [compiler-checkpoint.md](compiler-checkpoint.md). +Subsequent failed native runs, the unresolved production crash, and passing working-tree checks are recorded in [remaining-checkpoint.md](remaining-checkpoint.md). The separately approved standalone and real-CE sessions below have completed; their one-session approvals are consumed. The [Settings observation sequence](settings-observation.md) and [five standalone navigation cases](navigation-native-regression.md) are historical unexecuted preparations, superseded by the MCP-only closeout. Future execution requires fresh reviewed inputs and authorization under the earlier native-work stop. This ledger does not authorize further native, debugger, extended compiler/injection, soak or release execution. The exact candidate gates below refer to the final stable candidate and remain open. | Evidence ID | Requirements | Level | Candidate | Environment | Outcome | Cleanup | Safe reference | Status | | --- | --- | --- | --- | --- | --- | --- | --- | --- | | LOCAL-01 through LOCAL-08 | Sections 3-6 and bounded portions of 7, 8 and 11 | implementation, V+, VN, AOT, VL | Historical uncommitted `codex/roadmap-v2`, beta.2 version; exact artifact hashes in checkpoint | Windows 11 Pro 26300; CE 7.7.1.10828; .NET 10.0.12; x86/x64 targets | All recorded checks passed | Both native runs restored user state and preserved the source installation with no cleanup failures | [Local checkpoint](local-checkpoint.md) | pass within recorded scope | | COMPILER-01 and COMPILER-02 | Section 6 feasibility and compiler-only phases 1-4; bounded section 11 checks | V+, VN, AOT, VL | Clean `808a626e65eddab113737a27a7c2345555b1c241`, beta.2, Release; exact artifact hashes in checkpoint | Windows 11 Pro 26300; CE 7.7.1.10828 x64; .NET 10.0.12 and Framework 4.8.09221; x86/x64 targets | Build/package checks and all approved compiler-only phases passed | Four sessions restored user state and preserved installation; compiler files removed; no cleanup failures | [Compiler checkpoint](compiler-checkpoint.md) | pass within recorded scope; no injection or stable release | +| LIFECYCLE-X64-01 | Sections 7-9 | VL | Clean `d3984574e0d0757beebd3ea36deb6f9bef7358d1`, beta.2, Release; hashes in checkpoint | Windows x64; exact Windows build/runtime patch not emitted; CE 7.7.1.10828 x64; owned x64 targets | Memory, named scan and AOB subcases passed, then allocation refused with a Lua stack-input failure and unknown host effect | Owned hosts/targets stopped; user state restored and source preserved; no reported cleanup failures | [Native lifecycle result](remaining-checkpoint.md#native-lifecycle-and-workflow-result) | failed; not lifecycle-wide qualification | +| DIAGNOSTIC-X64-01 | Production crash investigation | diagnostic VL | `207d6fdb62c4874185ba843f00add85da1438d1f`, beta.2, Release; hashes in checkpoint | Windows x64; exact Windows build/runtime patch not emitted; CE 7.7.1.10828 x64; owned x64 targets | Host A crashed with `0xC0000005` in the Lua stack/dispatch path during AOB work; no root cause or fix established | User state/source preservation recorded and owned processes stopped; host A lifecycle-indicator cleanup evidence failed | [Diagnostic candidate](remaining-checkpoint.md#diagnostic-candidate-207d6fd) | failed; release blocker remains | +| OFFLINE-CONTRACT2 | Bounded sections 3 and 11 | portable, standalone VN, packaged reflection, AOT and local archive checks | Uncommitted working tree on `0f722ef7d6b37eee0985073dfe594c6559b31c14`, beta.2; Debug/Release binary and ZIP hashes plus source manifest in checkpoint | Windows x64; standalone Lua 5.3 DLL identity recorded; no CE host launched | 4,135 portable cases per configuration; 626 standalone Release NativeLua cases; actual packaged catalogs/AOT snapshots and same-input ZIP reproduction passed; independent Astra scoped review clear | Exact test-output executable restoration and staged-file hashes verified; no native host cleanup claimed | [Offline package refresh](remaining-checkpoint.md#offline-package-refresh---2026-10-09), [standalone NativeLua](remaining-checkpoint.md#authorized-standalone-nativelua-regressions---2026-10-09) | pass only within stated working-tree scope; clean candidate/native/CI/install/soak/release pending | +| DIAGNOSTIC-X64-02 | Bounded production crash investigation | diagnostic VL | Same uncommitted Release distribution as OFFLINE-CONTRACT2; ordinary SDK; exact package and harness hashes in checkpoint evidence | Windows 11 Pro x64 10.0.26300; CE 7.7.1.10828 with executable hash recorded; loaded CLR patch and target-binary hash not measured | Two memory/named-scan/AOB sessions passed; a third added the exact post-AOB 64-byte non-executable allocation/free probe; a fourth restored the original read-only prelude too; all passed with four optional gates false | Each run restored user state, preserved source installation and reported zero cleanup failures; all recorded host/target PIDs absent afterward | [Authorized bounded investigation](remaining-checkpoint.md#authorized-bounded-ce-investigation---2026-10-09) | bounded passes only; original crash unresolved and full lifecycle not qualified | +| DIAGNOSTIC-X64-03 | Stack observations during bounded crash investigation | instrumented diagnostic VL | Private SDK `2.0.1-mcpstacktrace.1`, MCP `2.0.0-mcpstacktrace.1`, modified source manifests and exact hashes in diagnostic ledger; original published native bridge retained | CE 7.7.1.10828 x64, owned x64 targets; prepared inputs hashed | One approved session passed; 1,874 records, 76 paired dispatches and 79 balanced frame restorations; no recorded refusal, underflow, overflow or incomplete trace; three short AOB waits contained no traced callback reentry | User state restored, source installation unchanged, zero cleanup failures and recorded owned PIDs absent | [Instrumented session](stack-trace-diagnostic.md#approved-instrumented-session) | balanced instrumented baseline only; historical crash and reentry hypothesis unresolved | +| DIAGNOSTIC-LUA-04 | Deterministic nested dispatcher stack restoration | standalone diagnostic VN | SDK archive `325c47b573f8bd39a247f1d0101f110fa36c1696` plus two new tests; private Release identity `2.0.1-mcpreentrytest.1`; production source unchanged; exact hashes in ledger | Windows x64, .NET 10.0.12, staged Lua 5.3 DLL and published native bridge identified by SHA-256 | Both selected nested success/managed-exception cases passed; 2 passed, 0 failed, 0 skipped | Owned test process exited 0 and absent afterward; no timeout or cleanup failure; all 1,485 prepared input hashes unchanged | [Standalone nested-dispatch execution](nested-dispatch-regression.md#approved-standalone-execution) | simulated SDK behavior verified only; no real-CE crash reproduction or production fix | +| DIAGNOSTIC-X64-05 | Ordinary-SDK full lifecycle attempt | incomplete VL | Same uncommitted ordinary Release distribution as OFFLINE-CONTRACT2; 820 prepared hashes matched before/after | CE 7.7.1.10828 x64, two private hosts and owned x64 targets | All six workflow groups completed; first plugin-disable bridge refused because its checklist did not contain exactly one owned row; 0 passed, 1 failed, 0 skipped | User state restored, source unchanged, no cleanup failures, all four recorded owned host/target PIDs absent | [Lifecycle failure](lifecycle-resume.md#approved-execution-result) | bridge failure reproduced; plugin cycles/restarts unqualified; historical stack failure unresolved | +| DIAGNOSTIC-X64-06 | Conditional bridge regression and retained-key lifecycle experiment | standalone VN and incomplete VL | Same ordinary Release distribution as OFFLINE-CONTRACT2; both 859-file manifests matched before/after execution | Windows x64 test module, staged Lua 5.3 DLL, CE 7.7.1.10828 x64 and owned x64 targets | Eight standalone cases passed, then one lifecycle attempt completed all six workflows but refused the first disable with measured count zero; the key-needed source premise was subsequently disproved and that change rolled back | User state restored, source unchanged, no cleanup failures, all recorded owned processes absent | [Conditional execution](lifecycle-key-fix.md#approved-conditional-execution) | eight bridge cases verified; lifecycle still failed; no post-rollback native pass or historical crash resolution | + +The following offline evidence belongs to isolated snapshots, not the primary product or a clean RC. The linked ledgers identify each snapshot, retained logs, test results, source inventory and independent Astra high review. Later snapshots include the earlier repairs, but the earlier focused results remain attributed to the source on which they ran. + +| Evidence ID | Requirements | Level | Candidate | Environment | Outcome | Cleanup | Safe reference | Status | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| OFFLINE-PERFORMANCE-01 | Preparation for 9.01, 9.02 and bounded 9.04 | implementation and focused portable checks | Isolated 856-file snapshot on `0f722ef7d6b37eee0985073dfe594c6559b31c14`, uncommitted beta.2, Release; inventory and module hashes in ledger | Windows x64, managed test doubles; no CE host | Twelve-workload measurement/comparison preparation and pointer range-search retention repair reviewed; 20 final helper cases and 130 earlier unchanged pointer regressions passed | No target or CE state created | [Performance preparation](performance-preparation.md) | preparation verified; native baselines and performance acceptance not run | +| OFFLINE-ADMISSION-01 | Preparation for 9.03 and 9.05 | implementation and focused portable checks | Isolated 861-file snapshot on `0f722ef7d6b37eee0985073dfe594c6559b31c14`, uncommitted beta.2, Release; inventory and module hashes in ledger | Windows x64, managed test doubles; no CE host | Two-gateway observation/comparison preparation reviewed; all 52 selected cases passed | No target or CE state created | [Admission preparation](admission-preparation.md) | preparation verified; native concurrency/UI acceptance not run | +| OFFLINE-NAVIGATION-02 | Bounded sections 3 and 8; preparation for 9.05 and 9.07 | implementation, Release portable and catalog checks | Isolated 867-file follow-up on `0f722ef7d6b37eee0985073dfe594c6559b31c14`, uncommitted beta.2, Release; source/module/TRX hashes in ledger | Windows x64, managed test doubles; NativeLua and LiveQualification excluded | Prepared module/region/thread/symbol/breakpoint navigation, dispatch metadata, thread collector and bounded structure script reviewed; 213 focused, 38 catalog-capture and final 4,234 portable cases passed | No target or CE state created | [Navigation follow-up](navigation-followup.md) | portable scope passed; changed Lua scripts compiled only; primary integration and native acceptance pending | +| OFFLINE-NAVIGATION-PACKAGE-02 | Bounded sections 3 and 11.05 | packaged reflection, AOT smoke and local archive checks | Hash-matched copy of all 867 reviewed files on `0f722ef7d6b37eee0985073dfe594c6559b31c14`, uncommitted beta.2, Release; exact DLL/gateway/ZIP hashes in ledger | Windows x64; two isolated DLL load contexts and gateway with empty registry | Maintained locked publish, exact-DLL golden comparison, AOT smoke, two identical ZIPs and five-file staging passed | No CE host, target or installed deployment changed; local output retained | [Latest navigation package](navigation-package.md#latest-reviewed-follow-up-package) | local package scope passed; no Lua execution, final stable identity, installation or publication evidence | + +The same reviewed navigation source also passed a separate Debug run: + +| Evidence ID | Requirements | Level | Candidate | Environment | Outcome | Cleanup | Safe reference | Status | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| OFFLINE-NAVIGATION-DEBUG-02 | Debug counterpart to OFFLINE-NAVIGATION-02; bounded section 11 | locked restore, Debug build and portable checks | Hash-matched isolated copy of all 867 files on `0f722ef7d6b37eee0985073dfe594c6559b31c14`, uncommitted beta.2, Debug; module/log/TRX hashes in ledger | Windows x64; NativeLua and LiveQualification excluded | Build had zero warnings/errors; 4,234 passed, zero failures/skips; exact test names match the reviewed Release run | No CE host or target attachment; all three pending native preparation inventories unchanged | [Debug verification](navigation-followup.md#matching-debug-portable-verification) | portable scope passed; Astra high evidence review scoped clear; final stable and native gates remain open | The candidate column must include version, full commit, clean-tree state, configuration, and relevant SHA-256 values. The environment column must include exact native versions for VL, soak, installation, and release results. diff --git a/docs/qualification/v2.0.0/remaining-checkpoint.md b/docs/qualification/v2.0.0/remaining-checkpoint.md index 61d710c..a806653 100644 --- a/docs/qualification/v2.0.0/remaining-checkpoint.md +++ b/docs/qualification/v2.0.0/remaining-checkpoint.md @@ -146,7 +146,28 @@ The private `.1` package also used the SDK tag's checked-in native Lua bridge, S Both managed instrumentation and native bridge bytes therefore changed in these three runs. The added logging can change timing, and different bridge hashes alone do not establish a functional difference or ABI defect. These passing diagnostic cases neither isolate the effect of instrumentation nor identify the root cause or prove a production fix. Each of the three passing summaries explicitly records `stableQualification=false`. -A controlled follow-up will restore the exact published bridge while retaining the managed trace patch and the previously passing `MemoryNamedScanThenAob` workload. The full original-order instrumented probe remains pending after that comparison. +A controlled follow-up restored the exact published bridge while retaining the managed trace patch, as recorded below. + +## Published-bridge diagnostic follow-up + +Private SDK `2.0.1-mcpdiagnostic.2` retains the managed trace patch and embeds the exact published `B008C8D8C136187F241542E6223DC0831999D8300DC2C4C01E1CF49F6FBA7698` native bridge. +The final bundled plugin's seven SDK assemblies and native bridge were compared byte-for-byte with the private package. The private package SHA-256 is `E28B6AA10006698BCBBE4672814C5DD519E79414D4A38AA57AD62270F57F034C`. +The plugin SHA-256 is `D75E915607687E97CB332E0E3C17EA2017A7876641B72E032A8C7C834A982E62`, gateway SHA-256 is `47CD2721DBD8E4FDB10C09F12966C77484BE42FDCDC1B77921AF6FFB8E9370D0`, and ZIP SHA-256 is `A661362E1B17990C5BE4CB54653A9C727FC754E33873D19660D2F72C2BBCFC8A`. +Both isolated packaged-DLL checks, the Native AOT gateway checks, and ZIP staging passed. After replacing stale private test-output bridge copies, the 11 dispatcher and 53 bounded-AOB standalone tests passed against verified `B008` files. +Product source remains `4c0698981f50ee4867bc1efe08a62867fd5c89c3`; harness source is `0f722ef7d6b37eee0985073dfe594c6559b31c14`, whose exact-case admission change passed 93 portable harness tests and independent Astra review. + +Run `20261008T015810Z-9f16` executed only `MemoryNamedScanThenAob` and passed. Both live owned CE processes were separately observed loading bridge bytes with the exact published hash. +The summary records three native scans, `stableQualification=false`, restored user state, unchanged source installation, and no cleanup failures. No owned CE or target processes remained afterward. + +Run `20261008T015918Z-ee72` restored the original lifecycle ordering using the same private `.2` package. All six workflow groups completed: memory, named scanner, AOB, control-flow graph, modules, and structures. +It then failed on the first plugin-disable cycle. The driver reported `expected exactly one owned plugin row`, and the harness timed out waiting for its response. +This run records `passed=false`, restored user state, unchanged source installation, and no cleanup failures. Both owned hosts and targets stopped; both hosts supplied enabled/disabled lifecycle indicators. +The subsequent offline harness correction initializes the settings form before reloading its contents, retains exact owned-row validation, and returns bounded error receipts immediately. Its initial build and six standalone bridge tests passed before live work was stopped; the correction has not been verified in CE. +Offline Astra review then identified byte truncation that could split a UTF-8 character in an error receipt. The formatter now converts error details to bounded printable ASCII before truncation. The added multibyte regression is compiled but has not been executed under the user's stop constraint. + +The initial attempt to select the full lifecycle was refused before host launch because the private PowerShell runner retained an empty diagnostic-case variable. The runner was corrected to remove an unset variable; this admission refusal is separate from the native run above. +These results do not resolve the earlier uninstrumented production corruption. Per-event instrumentation still changes timing, and no production fix is claimed. +At the user's request, live CE, native diagnostic, and debugger work is stopped. Further work is restricted to offline review, edits, and ordinary build checks; no additional native validation is implied by the prepared harness changes. ## Pending work and candidate boundary @@ -162,4 +183,287 @@ Only the narrowly completed subcases above may be checked off. The broader workf The lifecycle harness preserves the original workflow failure alongside cleanup faults and admits success only after gateway cleanup. Cleanup receipt flags say attempted rather than claiming successful release from an allocation-created flag. Focused portable tests enforce the fixed soak counts and diagnostic scenario admission. -The isolated dispatcher diagnostic has now passed its three bounded fresh-session cases, while the original full ordering, root-cause proof, production fix, and production-package qualification remain open. +The isolated dispatcher diagnostic passed its three `.1` cases and the single controlled `.2` case. The `.2` full-order run reached all six workflow groups but failed its first lifecycle transition. Root-cause proof, production fix, production-package qualification, and verification of the latest lifecycle harness correction remain open. + +## Offline goal checkpoint + +The all-issues goal is active for #28, #31 and #33. +This checkpoint describes local working-tree changes on `0f722ef7d6b37eee0985073dfe594c6559b31c14`; they are not a clean release candidate or a published package. +Live CE, NativeLua, native diagnostic, debugger, extended compiler and injection execution remain stopped. + +An error-contract audit found that internal tool errors with array or scalar context logged an ID without returning it. +The correction preserves that context under `details.value` beside `errorId`, replaces non-string reserved IDs without duplicate properties, and preserves all other envelope fields. +The 22 focused correlation cases passed; the six host-effect values now have direct and routed MCP coverage, including exact envelope and logged-ID equality. +The requirement-to-test mapping is in [contract-checkpoint.md](contract-checkpoint.md). + +The configuration reference now lists the C# compiler gate and explains private native-dispatch log events accurately. +The support-report checklist covers versions, operation/error ID, gates and redacted logs without requesting tokens or discovery contents. +Upgrade guidance covers a matching plugin/gateway pair, old plugin-entry replacement, locked files, partial upgrades, rollback and uninstall while retaining user data. +The reviewed release-note draft includes the unresolved production crash and does not claim stable publication. +Eight #28 documentation, harness-control and unsupported-disposition criteria are complete; broader native and release gates remain open. + +Validation caught an oversized knowledge page and the intended resource-size snapshot changes. +The page was shortened within its existing budget; only the three changed document sizes changed in the two resource snapshots. +It also caught a stale Costura bundle after an ordinary incremental dependency update: normal outputs were current but the single plugin DLL still embedded the old Resources assembly. +`McpTrackBundledDependencies` now supplies copy-local implementation files as compiler freshness inputs, allowing Fody to weave fresh plugin IL when a dependency implementation or embedded document changes. +The controlled offline check compared all five embedded product assemblies byte-for-byte by SHA-256 against current outputs after a resource-only edit, a no-op build and restoration. +All phases passed, the edited source was restored exactly, and the no-op build preserved the plugin output timestamp. +The report is `artifacts/issue-edits/incremental-bundle-verification.json`. +After the build-rule correction, all five actual single-DLL bundle tests passed again in both Release and Debug, including isolated catalog equality and deliberate mismatch rejection. +Those focused results are in `artifacts/issue-edits/goal-final-{debug,release}-bundle-tests.log`. + +Nonincremental Debug and Release builds succeeded without warnings or errors, and both full portable suites passed 4,097 tests with zero failures or skips after the correlation and documentation corrections. +Logs are `artifacts/issue-edits/goal-verified-{debug,release}-{build,tests}.log`. +Style and whitespace verification and `git diff --check` passed. +Independent Astra source review cleared the correlation, documentation and incremental-build changes after all reported findings were addressed. +The new native multibyte receipt regression remains compiled but unexecuted, and the earlier production Lua corruption remains unresolved. +No push, tag, release or additional native run was performed. + +## Offline contract and startup follow-up - 2026-10-09 + +The previous goal turn made concrete progress with source fixes, portable evidence and reviewed issue updates. +This continuation remains a local working tree on `0f722ef7d6b37eee0985073dfe594c6559b31c14`; no clean candidate, CI publication or native rerun is implied. + +The 12 new exact-value cases passed through actual .NET MCP serialization and loopback gateway forwarding with controlled target/backend doubles. +They cover signed and unsigned 64-bit extrema, values above JavaScript's exact integer range, maximum hexadecimal addresses and pointers, negative offsets, and distinct zero/empty/unavailable values. +The gate-before-effect additions passed with 42 assembly/table cases, 177 record cases and 10 process-auto-attach cases. +The XML table fixtures verify that they were actually inspected; opaque fixtures separately check all three documented exposure requirements. +Mixed record batches prove a later gated record cannot leave earlier records created or deleted, and process tests prove no open script was reached before refusal. + +The 12 lifecycle tests passed, including two new real-module checks. +An occupied loopback port refuses startup, leaves no discovery record, and can be rebound after the occupying listener stops. +A file in place of the log directory does not stop the actual authenticated backend; logging stays best-effort, the file is preserved, and disable withdraws discovery and closes the listener. +These cases are portable backend/module evidence, not exact-package installation in CE. + +Independent Astra review identified and cleared eight effect-metadata corrections: two destructive flags (`record_create`, `table_save`), a fixed AA requirement (`record_set_script`), and five `may_prompt` classes (patch release, bulk resource release, record activation, deletion and clear). +The script-editing runtime gate also runs before dispatch. +`record_clear` now includes the standard capability recovery hint; its new NativeLua assertion is compiled but remains unexecuted. +Tool descriptions, the tool map, safety reference, AA guidance and cleanup workflow explain the same script-dialog behavior. +The catalog, resource and summary golden changes are intentional and reviewed. + +The source/evidence crosswalk is in [contract-checkpoint.md](contract-checkpoint.md). +Full-domain nullable-result and metadata audits remain incomplete; these focused additions do not close roadmap section 3, the broader installation gate, #31 or #33. +The real-CE failure, final candidate, native/compiler lifetime matrix, soak, installation/client qualification and release dependencies recorded above remain open. + +Final validation for this continuation: Debug and Release builds both succeeded with zero warnings and errors. +Each full portable suite passed 4,126 tests with zero failures and skips, excluding `LiveQualification` and `NativeLua`. +The logs are `artifacts/issue-edits/goal-contract-{debug,release}-{build,tests}.log`. +An initial Release run found only an AA guide size overrun (24 bytes); the new paragraph was shortened within the unchanged budget, its resource snapshots were recaptured, and the complete Release suite then passed. +Only eight intended tools changed in the two tool catalogs and summary; the six resource-size changes match edited knowledge documents, including the three previous checkpoint changes. +Style, whitespace verification and `git diff --check` passed. +Final Astra re-review found no remaining actionable findings in this scoped patch and its evidence after correcting the upstream parameter-sentinel interpretation; the remaining collector ambiguity is recorded in the contract checkpoint. +The pinned read-only collector source copies and blob identities are retained under `artifacts/issue-edits/collector-ec45d5f/source-manifest.json`. +Five completed offline subcases were appended to each of #28 and #33 and verified by exact body readback; all broader checkboxes and all three issues remain open. +No push, tag, release or native execution occurred. + +## Full offline contract audit - 2026-10-09 + +The next continuation completed the remaining source review across all 193 backend tools and the gateway's discovery/result forwarding. Coverage and native-source limitations are recorded in [offline-contract-audit.md](offline-contract-audit.md). +It corrected six more tool-effect annotations, six unavailable-result mappings, the .NET collector parameter descriptions and the scan-value readability description. +The slow module-exports resource projection was removed while its explicit tool and the resource dispatch guard remain intact; current snapshots contain 43 backend resources, 14 templates and 43 prompts. + +The speedhack follow-up preserves restore tracking when a post-change symbol observation is unavailable, probes before releasing a prior resource, and reports prior restore effects honestly if a later operation refuses. +Failed cleanup details contain the resource ID and release outcome instead of invented observed values. +An internal marker preserves the kernel result's object shape when every probe is unavailable; the public typed result omits that marker and all unavailable fields. + +Debug and Release builds succeeded with zero warnings/errors; each full portable suite passed 4,135 tests with no failures or skips. +Build/test logs are `artifacts/issue-edits/goal-contract2-{debug,release}-{build,tests}.log`. Style, whitespace verification and `git diff --check` passed. +An initial catalog run correctly refused the slow exports-resource projection, and the first full Release run found a stale generic poll-annotation fixture. Both were corrected before the passing full runs; the failed logs are retained separately. +The focused speedhack suite passed 20 tests. NativeLua regression edits were compiled but not executed, and `LiveQualification` and `NativeLua` remain excluded from portable runs. +Independent Astra final review found no remaining actionable findings in the scoped source changes, snapshots, validation logs or issue draft. It verified all 27 changed tool entries per catalog, all 11 rendered document sizes and the single removed resource template; this does not qualify native behavior or a final candidate. + +The reviewed changes intentionally affect beta compatibility: unavailable status/job fields can be omitted and the exports URI is removed. They are documented explicitly in the release notes, acceptance and support policy; requirement 2.07 is reopened pending their final contract disposition rather than claiming additive compatibility. +Six completed offline subcases were appended to each of #28 and #33 and verified by exact body readback. The compatibility-policy checkbox in #28 was reopened to match requirement 2.07, and its earlier completion paragraph was qualified as historical. #31 was reviewed and left unchanged; all three issues remain open. +The original real-CE failure, native/compiler qualification, soak, installation/client checks, final candidate and release gates remain open. No commit, push, tag, release or native execution occurred in this continuation. + +## Offline package refresh - 2026-10-09 + +The reviewed contract changes were rebuilt through `eng/Publish.ps1` in both Debug and Release, with locked restore, into new local distribution directories. Each actual single plugin DLL passed cold loading and full catalog equality in two isolated contexts: 193 tools, 43 resources, 14 templates and 43 prompts. The bundle check resolves the embedded bridge export without invoking Lua or enabling a CE plugin. + +Both Native AOT gateways passed the maintained no-instance startup smoke. The exact published executable bytes also passed `GatewayExecutable_StdioWithoutInstances_MatchesGoldenSnapshots` in each configuration: one test passed with zero failures/skips each. This compares initialization, tools, resources/templates and prompts, and reads embedded knowledge and a rendered prompt against an empty private instance registry. The published executable temporarily replaced only its corresponding test-output gateway; hashes verified both the executed bytes and exact restoration afterward. + +`eng/Release.ps1` verified each five-file ZIP, embedded dependency inventory, notices, checksums and matching plugin/gateway product versions. `eng/Stage-QualificationPackage.ps1` verified and extracted each archive to a new path containing spaces; every staged file hash matches its distribution file. Independent packaging into a second empty directory produced the same ZIP hash in each configuration. This proves same-input archive reproducibility, not independent compiler/build reproducibility or installation in CE. + +| Configuration | Plugin SHA-256 | AOT gateway SHA-256 | ZIP SHA-256 | ZIP bytes | +| --- | --- | --- | --- | ---: | +| Release | `1620DD5978C4996BA80DEB5B81403EB3B695B3D029C1D1D533A3F39254D5A97F` | `ADB97797600614CEF21C7F5E2352F95E9102A3D4DFCE48DA4F5B19335E65CA88` | `E2F2EDC8CDA3A5EB4C152B642F35A9C06762CBF7EF9100F2C4D5671BD1F9DB0E` | 17,752,137 | +| Debug | `E6E00295DB20F07E2C413019368B549565E0AE7F112C6E112FA90CC431F8FBA7` | `5509B7209D29C35CDA3A5710F20BC452FBB595E3CC532093F3C278FF854D22F0` | `AF62B5E0AA7309984B9107E72906AE0DE3766CE0A9A0C8C59EA638B268B94918` | 26,031,849 | + +The embedded product identity is `2.0.0-beta.2+0f722ef7d6b37eee0985073dfe594c6559b31c14`, but these bytes include the uncommitted working-tree changes. They are not the published beta.2, a clean candidate, or a stable release. The local package manifest records 527 product-source file hashes and the retained product diff hash to make this boundary explicit. + +Evidence is under `artifacts/issue-edits/`: `goal-contract2-publish-{debug,release}.log`, `goal-contract2-aot-{debug,release}-golden.log`, `goal-contract2-aot-golden-receipts.json`, `goal-contract2-package-{debug,release}.log`, the corresponding `-repro.log` files, `goal-contract2-stage-{debug,release}.log`, and `goal-contract2-package-manifest.json`. + +Requirement 2.09 is corrected from pass to partial: defining the soak duration does not supply its missing two-hour execution result. No broader qualification checkbox is completed by this offline package refresh. At this package checkpoint, live CE, NativeLua, native diagnostic, debugger and extended compiler/injection work remained stopped; the later standalone-only authorization is recorded below. No commit, push, tag, upload or release occurred. + +## Authorized standalone NativeLua regressions - 2026-10-09 + +The user subsequently authorized the prepared standalone NativeLua regression suite in Release mode, explicitly without launching CE or attaching to a target. The earlier compiled-only and native-stop statements describe their historical checkpoints; this narrow permission now allows standalone NativeLua regression checks. It does not resume live CE, native diagnostic, debugger, extended compiler or injection qualification. + +An initial run lacked `CHEATENGINE_MCP_LUA53_PATH` and failed the explicit runtime prerequisite. The next run used `C:\Program Files\Cheat Engine\lua53-64.dll`, SHA-256 `C95DCDFA0F60F97B43D970D77FD1BB907AF4DE04B500A3C89A99600B20B35BD2`, set only for the command process and restored afterward. It passed 625 of 626 cases and exposed an incorrect test expectation: restoring an already-hooked speedhack fixture must make no new hook attempt. + +`SpeedhackV2_SymbolProbeFailsAfterRestoringSpeed_ReturnsAnUnknownObservation` now verifies one speed-setting call, zero hook attempts, and both requested and target speed equal to 1 while the unavailable post-change observation remains omitted. Only the assertion and its comment changed; product source and the packaged bytes above are unchanged. The Release test rebuild passed with zero warnings/errors, and the complete authorized suite then passed **626/626**, zero failures/skips, in 3.152 seconds. + +| Prepared behavior now executed | Test evidence within the passing suite | +| --- | --- | +| Unknown memory/kernel status and detached scanner ID | `AddressExtras_StubbedCheatEngine_ReportsSystemModulesAndRttiClasses`, `KernelStatus_ReportsBooleanStatesAndOmitsUnavailableOrInvalidAnswers`, `ScanV2_MainScannerWithoutAPositiveProcessId_IsNotAttached` | +| Missing module-preference list differs from a valid empty list | `ModuleSymbolGetModulePreference_Stubbed_CopiesAtMost1024NamesAndRefusesAMissingHostList` | +| Unavailable speedhack observations preserve mutation/restore reporting | `SpeedhackV2_SymbolProbeUnavailable_RefusesStateAndChangesBeforeAnyMutation`, `SpeedhackV2_SymbolProbeFailsAfterChangingSpeed_TracksTheRestoreAndReturnsAnUnknownObservation`, `SpeedhackV2_SymbolProbeFailsAfterRestoringSpeed_ReturnsAnUnknownObservation` | +| Record-clear refusal supplies the capability recovery hint | `RecordClear_ActiveAutoAssemblerWithoutGate_LeavesTheAddressListUntouched` | +| Bounded lifecycle error text remains printable ASCII | `LivePluginBridge_LongMultibyteError_RemainsBoundedAscii` | + +Command: `dotnet test --solution CheatEngine.Mcp.slnx -c Release --no-restore --no-build --filter-trait Category=NativeLua --filter-not-trait Category=LiveQualification --fail-skips on`, with the explicit process-local Lua DLL path above. +Logs/receipts: `artifacts/issue-edits/goal-contract2-native-fix-build.log`, `goal-contract2-native-lua-release.log`, `goal-contract2-native-lua-runtime.json`; the initial `-missing-path.log` and `-stale-assertion.log` failures are retained separately. + +These checks execute fixed Lua against CE-shaped stubs in a standalone runtime. They do not demonstrate a fix for the production dispatch crash or qualify real host lifecycle, collectors, debugger/injection/compiler workflows, installation, soak, or a clean final package. All three issues remain open. + +Changed-test style and whitespace verification and `git diff --check` passed. Independent Astra final review verified the package/source/hash evidence, the corrected assertion, the 626-pass result and the updated documentation/issue draft, with no remaining actionable findings in this scope. It explicitly retained the real-CE and final-candidate limits. +Five completed subcases were appended to both #28 and #33 and verified by exact body readback and OPEN state. #31 was reread and remains unchanged with its compiler-lifetime/injection and final contract/CI requirements open. The current GitHub issue list still contains exactly #28, #31 and #33, and the all-issues goal remains active. + +## Authorized bounded CE investigation - 2026-10-09 + +After reviewing [the concrete proposal](next-live-investigation.md), the user explicitly authorized bounded memory/scanner/AOB investigation in private CE copies and owned x64 targets, with all four optional capability gates disabled. This is separate from the earlier standalone Lua permission. It does not authorize the full lifecycle/workflow matrix, debugger, extended compiler/injection, kernel/DBVM, soak, or publication. + +The existing `MemoryNamedScanThenAob` diagnostic case ran in two fresh sessions against the unchanged ordinary-SDK Release distribution from the offline package refresh. Both private plugin copies and the gateway in each run match the recorded package hashes; the unfinished private diagnostic SDK/MCP clones were not used. + +| Run ID | Case | Reported total test duration | Result | +| --- | --- | ---: | --- | +| `20261009T101356Z-aa03` | `MemoryNamedScanThenAob` | 18.922 seconds | Passed; all gates false; user state restored, source installation unchanged, zero cleanup failures. | +| `20261009T101855Z-5bcc` | Same case, fresh session | 18.323 seconds | Passed with the same gate and cleanup results. | + +Each case exercised the existing bounded memory and named-scanner workflows followed by two AOB tool calls containing three native scans, then checked the original scalar sentinel in both targets. Every recorded owned host/target PID was absent in the post-run process check. Both host logs contain enabled and disabled lifecycle indicators for this single activation; repeated plugin cycles were not exercised. + +The immediately preceding preflight recorded Windows 11 Pro x64 `10.0.26300`, CE `7.7.1.10828`, and CE executable SHA-256 `CF6CCC664A90C23531EC20F5C41458CFDC1D04679D6D05ED94C06BCECF95544F`. An installed-runtime inventory was retained; it is not a measurement of the CLR patch loaded by CE. The native summary still contains no target-binary hash, so these results do not supply that missing identity fact. + +Evidence: `artifacts/issue-edits/goal-live-resume-preflight.json`, `goal-live-resume-runtimes.log`, `goal-live-resume-memory-scan-aob.log`, `goal-live-resume-memory-scan-aob-repeat.log`, and `goal-live-resume-evidence.json`. The evidence extract retains source summary/receipt hashes, exact package identities, workload/gate results, cleanup fields and the recorded-PID absence check. Raw private run directories remain outside the repository. + +These passes do not establish a root cause or production fix. The original `d398457` failure occurred at the next CFG fixture allocation after AOB; the later `207d6fd` failure occurred inside AOB value scanning. The reduced diagnostic case does not reproduce the entire original lifecycle ordering, and the production release blocker remains open. + +### Exact post-AOB allocation probe + +Independent Astra source review confirmed that the final scalar reads exercise the common Lua dispatcher/frame, but not the target allocation and ownership operation that failed in `d398457`. The diagnostic harness now allocates 64 non-executable bytes immediately after AOB cleanup and frees the named allocation in `finally` after confirmed acquisition. It records both results and preserves original and cleanup exceptions. No CFG writes, graph analysis, product changes or additional capability gates were introduced. Astra reviewed the probe before execution and found no actionable issues in that scope. + +Run `20261009T102831Z-f606` passed the augmented `MemoryNamedScanThenAob` case: **1 passed, 0 failed, 0 skipped**, total 18.317 seconds. The allocation returned size 64 and `executable=false`; release returned `kind=released`, `hostEffect=completed`, `isComplete=true`, and no manual recovery requirement. All four gates remained false. Memory/scanner/AOB/probe cleanup reported zero failures; both private hosts and targets stopped, the user state was restored and the source installation remained unchanged. All four recorded PIDs were absent afterward. + +The same plugin and gateway hashes from the package refresh were used. Evidence: `artifacts/issue-edits/goal-live-post-aob-allocation.log` and `goal-live-post-aob-evidence.json`; the latter records the harness source, summary and receipt hashes. Release build and scoped whitespace verification passed after formatting the new receipt objects; the initial formatting diagnostics remain in separate logs. This additional pass covers the missing post-AOB allocation operation but still does not identify or fix the historical crash or qualify the full original lifecycle sequence. + +### Original read-only prelude restored + +Source comparison showed that both historical failed lifecycle runs also called `VerifyBothAsync` and `LiveResourceQualification.VerifyAsync` after attaching the targets and before the workflow prefix. The separate `ResourcePreludeMemoryNamedScanThenAob` case restores those calls before the bounded memory/scanner/AOB and post-AOB allocation/free sequence. The prelude reads runtime/gate identity and scalar sentinels in each owned target, lists static catalogs, reads documentation and runtime resources, renders a prompt and checks instance completions. It does not execute the rendered debugger workflow. Runtime resource observation includes the fixed-Lua ledger snapshot; it does not create state or release resources. Astra reviewed this path and the four-file harness change before execution with no actionable findings. + +Run `20261009T103236Z-b6c6` passed: **1 passed, 0 failed, 0 skipped**, total 18.519 seconds. The prelude completed with 55 resources, 25 templates and 43 prompts. The original exports template was removed by the earlier reviewed contract change; the historical prelude only listed it and never invoked native export parsing. The 64-byte non-executable post-AOB allocation and completed release succeeded. All four gates were false; workflow and probe cleanup reported zero failures, user state was restored, source installation was unchanged, and every recorded owned host/target PID was absent afterward. + +Release build and scoped style/whitespace verification passed. The focused `LiveQualificationOptInTests` suite passed **50/50**, zero failures/skips, including the new exact-selector theory case. No full-suite rerun is claimed after this harness-only addition. Evidence: `artifacts/issue-edits/goal-live-prelude-{build,optin-tests,style,whitespace,run}.log`, `goal-live-prelude-evidence.json`, `goal-live-prelude-harness.patch` and `goal-live-prelude-source-hashes.json`. + +This restores the historical native call order through the previously failing allocation using the current package, but does not reproduce historical bytes or timing. The result narrows the reproduction gap; it does not establish a root cause, demonstrate a production fix or qualify subsequent workflow/lifecycle transitions. + +Independent Astra final review matched all four retained raw summaries and receipt hashes to their extracts, the latest harness hashes and patch, build/test/format logs, package identities and documentation. It found no actionable issues in this continuation's evidence. Four completed diagnostic subcases were appended to both #28 and #33 and verified by exact body readback; their original incomplete gates remain unchecked and both issues remain OPEN. #31 was reviewed and remains OPEN with its compiler/injection and final contract/CI requirements unchanged. A fresh product recheck confirmed all 527 source hashes and the Release plugin/gateway hashes still match the offline package manifest. + +### Mechanism-level evidence still missing + +The pinned SDK source review did not establish a root cause. The earlier refusal is the `LuaProtectedApi.ValidateInvocation` guard where required inputs exceed the observed Lua stack height. The later AV occurs while restoring the outer `MainThreadDispatcher` frame; the crash site does not establish which earlier operation corrupted state. + +An older passing instrumented `.2` run, `20261008T015810Z-9f16`, records different callback/worker and AOB-provider Lua state pointers in dispatches 63 and 64. Distinct coroutine pointers can share a Lua VM, so this observation alone is not a defect. Existing trace `top` fields contain saved frame heights, not contemporaneous measurements, and cannot prove balanced execution. + +A next diagnostic, if needed after confirming the CE coroutine contract, must distinguish actual and saved stack heights before frame restoration, around `synchronize` and callback disposal, and at the precise insufficient-input guard. It should record state/thread/dispatch identity with bounded storage and survive a host crash. A new isolated SDK diagnostic package with the exact published native bridge would require preparation, offline review and separate execution authorization; the current passing ordinary-SDK runs do not authorize or validate it. + +Follow-up authoritative-source review explains the pointer difference: CE's `lua_synchronize` retains the caller's `L` as `syncvm`, and its synchronized callback uses that captured state. The plugin state getter instead returns a Lua state associated with the current OS thread. Different pointers are therefore expected in this path and are not evidence of an invalid state. CE's main-thread `memscan_waitTillDone` loop calls `CheckSynchronize` while waiting, which supplies a concrete reentrancy boundary for the proposed stack-balance trace. These facts narrow the diagnostic design; they do not prove a reentrancy defect in the installed build. + +Pinned public-source references (`ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37`, not a byte-for-byte claim about installed CE): [capture caller state](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaHandler.pas#L3456-L3494), [invoke the synchronized callback](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaCaller.pas#L358-L386), [per-thread state getter](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaHandler.pas#L188-L208), [plugin getter export](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/pluginexports.pas#L2622-L2635), and [scan wait synchronization](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaMemscan.pas#L145-L159). + +## Private stack trace prepared offline - 2026-10-09 + +The [stack trace diagnostic ledger](stack-trace-diagnostic.md) records the new private SDK/MCP source identities, exact package hashes, one-session proposal and completed offline checks. The SDK Release build passed; all 9 managed recorder tests passed. An owned helper process was terminated with its mapping open: all 16 committed records survived, and disabled/collision/missing-directory cases passed. Package inspection confirmed all seven embedded SDK assemblies and the unchanged published native bridge. Actual-DLL reflection and AOT smoke passed. The private MCP portable suite passed 4,134 checks with two notices-version failures; the corrected notices class then passed all 3 cases. No full-suite rerun after that documentation-only fix is claimed. + +The prepared runner passed syntax and no-execution preflight for 235 exact input hashes. Independent Astra high review verified the repaired source, package and runner with no remaining actionable findings in that scope. The user then explicitly approved one instrumented session; its result follows. #28, #31 and #33 remain open. + +### One approved instrumented session + +Run `20261009T111321Z-688b` passed the exact prepared case: **1 passed, 0 failed, 0 skipped**, 18.433 seconds. Host A retained 1,753 records covering 71 synchronized dispatches and three AOB frames; B retained 121 records covering five dispatches. All 79 observed frame restorations matched their saved heights. There were no recorded pre-restore underflows, protected-input refusals, failed probes, overflows, incomplete records or frozen traces. The post-AOB 64-byte non-executable allocation/free passed; gates remained false, user state was restored, installation unchanged, cleanup failures empty, and all four recorded owned PIDs were absent. + +Astra independently verified the trace interpretation: the depth-2 observations were ordinary inline child calls, not synchronized reentry. Three AOB waits took 4.8749, 2.4261 and 4.7970 ms with top 0 at both boundaries and no recorded SDK callback reentry. This is a balanced baseline, not a reproduction or production fix; it does not rule out other host callbacks. The [diagnostic ledger](stack-trace-diagnostic.md#approved-instrumented-session) records exact identities and evidence. This one-session authorization is consumed; no further live session is scheduled. The offline trace validator then passed 11 synthetic positive/negative controls and both recorded host snapshots, including actual nested-thunk detection, stack mismatch/interruption cases and expected pointer differences. The original failure/reentry reproduction remains open. + +Final Astra review confirmed the validator's sequence tie-breaking and completed evidence ledger with no remaining actionable findings in that scope. The executed diagnostic and validator subcases were checked off in #28 and #33, with exact normalized body readback and both issues still open; #31 remains open unchanged. Rechecking all 527 primary product-source hashes and the ordinary Release plugin/gateway confirmed that this private diagnostic did not change them. + +### Deterministic standalone reentry cases prepared + +The [nested-dispatch regression ledger](nested-dispatch-regression.md) records two new standalone native-Lua cases in a separate, uninstrumented SDK archive at `325c47b573f8bd39a247f1d0101f110fa36c1696`. They force a nested synchronized callback during an active protected host-wait call, exercise nonzero root-stack restoration on success and managed exception, and assert both worker sentinels and exact exception ownership. Actual Lua access is serialized; worker epilogues are released and joined one at a time after the outer callback unwinds. + +The Release test-module build passed with zero warnings/errors. Source comparison verified 1,277 entries with only two new test files and the original published native bridge replacement. The no-execution runner verified 1,485 input hashes; its 60-second deadline terminates only the owned test process. Independent Astra high review found no remaining actionable findings in the source, lifetime handling, runner or hashes. + +The user then approved one standalone execution. Run `885121c89ef846fead7b76804b21e5f3` passed both exact cases: **2 passed, 0 failed, 0 skipped**, 237 ms. The owned process exited 0 and was absent afterward; no timeout, runner failure or cleanup failure occurred. All prepared hashes still matched. This proves the tested simulated-host restoration paths, not a real-CE reproduction or production fix. No further native execution is scheduled. #28, #31 and #33 remain open. + +Independent Astra final evidence review was clear. The three completed preparation/execution/review subcases were checked off in #28 and #33, and exact body readback preserved the original incomplete acceptance gates. Source review identified the maintained full `lifecycle` scenario as the remaining path through the historical Settings/Plugins disable failure and restart matrix; its [ordinary-SDK preparation](lifecycle-resume.md) is being reviewed separately, without implying execution authorization. + +### Ordinary-SDK lifecycle reached the remaining bridge failure + +After Release build and independent Astra preparation review passed, the user explicitly approved one full x64 `lifecycle` session with the same ordinary-SDK distribution. Run `20261009T120128Z-8f44` completed the prelude and all six workflow groups, then failed at the first plugin-disable request with `expected exactly one owned plugin row`. The bounded bridge error receipt now reports the real refusal immediately, but its earlier form-initialization correction did not resolve checklist population. The test reports **0 passed, 1 failed, 0 skipped**, 18.441 seconds. + +All four gates stayed disabled. Mutating workflow cleanup failures were zero, user state was restored, the source installation stayed unchanged, and all four recorded owned host/target PIDs were absent. All 820 reviewed inputs still matched. Exact hashes and limits are in the [executed lifecycle ledger](lifecycle-resume.md#approved-execution-result). Plugin cycles and restarts remain unqualified; no production stack-crash fix is claimed. This one-session approval is consumed. The next work is offline investigation of the real Settings/Plugins population event, preserving exact owned-row validation. + +Independent Astra final review confirmed that failed result and its limits. The completed six-group workflow and cleanup/evidence subcases were checked off in #28 and #33; exact issue readback preserved the incomplete lifecycle gates. A subsequent [retained-key experiment](lifecycle-key-fix.md) was prepared on the mistaken premise that checklist population was inside the `OpenKey(Plugins64, false)` branch. Release build, both 859-hash preflights and independent Astra source/runner review passed, but that review missed the incorrect block interpretation. + +The user approved the eight standalone bridge cases followed conditionally by one lifecycle session. The standalone stage passed **8/8**, zero failures/skips. Run `20261009T121623Z-71c7` then completed all six workflows but failed the first disable with measured `count=0`, despite the `keyRetained=true` receipt: **0 passed, 1 failed, 0 skipped**. Preserving the empty key is insufficient. All gates stayed false, cleanup and settings restoration passed, both test processes and all four host/target PIDs were absent, and both 859-file manifests still matched. Astra independently confirmed the evidence and its limits. Exact artifacts and hashes are in the [conditional execution ledger](lifecycle-key-fix.md#approved-conditional-execution). No plugin cycle/restart or historical stack-failure resolution is claimed; this approval is consumed and further diagnosis proceeds offline. + +Exact pinned-source inspection then established that `FillCheckListBox` is outside the registry-key conditional. The researcher and Astra confirmed the correction. The unsupported key creation/retention change was rolled back; the measured-count receipt and zero-row regression remain. CE's reload has early returns and a broad exception handler, so the zero-row result does not identify the failing path. Original run artifacts and both tested source snapshots are retained; no post-rollback native result is claimed. + +The rollback Release build and whitespace checks passed; all 527 product-source hashes and ordinary distribution plugin/gateway hashes remained unchanged. Final Astra review was clear after correcting the consolidated live-counter fields from the retained TRX. #28 and #33 now check off the eight bridge cases and verified evidence while preserving incomplete lifecycle gates; exact body readback passed. #31 was reviewed and remains open unchanged. The active goal remains unfinished; no issue closure, push or release is claimed. + +### Settings observation prepared offline + +Further source and harness review found no separate Settings UI refresh path or specific mismatched registry value explaining the zero-row failure. The [Settings observation preparation](settings-observation.md) now captures the existing form's runtime class and the checklist count before reload in the bounded post-reload row-count refusal. Exact owned-row/name guards and the original registry handling remain unchanged. Two new standalone theory cases check distinct pre/post counts, class text and refusal before input/application, bringing the selected bridge scope to ten cases. + +Release build passed with zero warnings/errors, both no-execution manifests verified 859 inputs including 527 unchanged product sources, and independent Astra high review found no actionable issues. The proposed sequence is ten standalone cases followed conditionally by one unchanged ordinary-SDK x64 lifecycle attempt. Neither stage is executed or authorized yet. This observation can narrow the early-class and list-change hypotheses but cannot identify a swallowed exception or resolve an unchanged zero-row list on its own; it is not a product fix. + +### Isolated performance preparation + +While the Settings decision remains pending, the [isolated performance checkpoint](performance-preparation.md) expands the 9.01 runner and fixes a separately discovered pointer-reference cleanup-retention gap. The primary source, ordinary package and prepared Settings inputs remain unchanged. Release build passed with zero warnings/errors; final performance/lifecycle managed checks passed 20/20, and the separate pointer regression run passed 130/130, all without skips. All actionable Astra findings in this staged scope were repaired, and final Astra high review was clear. A retained 856-file source inventory identifies exactly six changed/new files and hashes the evidence. + +The completed offline preparation subcases were checked off in #28 and #33 with exact body readback; integration and native acceptance remain unchecked. Both issues remain open, and a fresh open-issue list still contains #28, #31 and #33. No native performance run, full-suite rerun, issue closure, commit, push or release occurred. + +### Isolated admission preparation + +The [admission checkpoint](admission-preparation.md) adds two independent gateway processes, fixed concurrent batches and UI/resource/completion observations in a second isolated snapshot. Astra found and the implementation repaired missing observer-versus-batch overlap checks and loss of original exception diagnostics. The final Release build has zero warnings/errors; 52 selected managed tests passed with no skips. Astra's second pass found no remaining actionable correctness findings in this scope. The 861-file source inventory identifies seven changed/new files relative to the first reviewed snapshot. + +#28 and #33 now check off this managed preparation, with exact body readback and incomplete native gates preserved. Both 859-input Settings no-execution preflights still pass. Package-matched SDK/Client source confirms that module and region caps apply only after native enumeration, including module-detail navigation's lookup. The separate navigation repair is recorded below; integration and native 9.01/9.03/9.05 acceptance remain open. + +### Isolated prepared navigation repair + +The [prepared navigation checkpoint](navigation-preparation.md) redirects module/detail/region resources and module completion to five-second, process/epoch/version-checked snapshots from explicit source tools. Its navigation paths contain no module/section/region enumeration, and cold/stale reads fail with preparation hints. The explicit tools still perform native enumeration. Latest module details are retained with detached sections arrays; fresh module completion does not extend snapshot lifetime through an outer cache. Descriptions document the intentional preparation requirement while resource URIs and schemas remain unchanged. + +The final isolated Release build passed with zero warnings/errors and all **4,207 portable tests passed**, with zero failures/skips. Earlier focused 214/214 and non-capture contract 38/38 checks also passed. The first broad run found a pointer-documentation lint failure and ten packaging-fixture failures caused by missing source-revision metadata; the documentation and isolated build command were corrected. No packaging behavior changed. The 863-file inventory records 26 modified and two new files relative to the reviewed admission snapshot. Astra's source and final evidence reviews are scoped clear; the final review independently verified source/manifest hashes, counters, build identity and constrained golden changes. + +The primary product sources, ordinary distribution and both 859-input Settings preparations remain unchanged. A fresh issue review still shows #28, #31 and #33 open; #31 retains its compiler/shared-temp/export/injection and final stable-evidence gates. Integration, candidate packaging, native performance/admission/lifecycle acceptance and publication remain incomplete. No additional native session, commit, push or release occurred. + +The completed offline navigation subcase is now checked off in #28 and #33. Exact issue body readback passed for both edits (64,475 and 26,313 characters respectively), preserving the unchecked integration and native acceptance gates. #31 remains open unchanged. The Settings-observation approval remains pending; no earlier consumed approval was reused. + +### Navigation package and remaining route repairs + +The [first navigation package](navigation-package.md) passed the maintained local publish, two isolated exact-DLL catalog checks, Native AOT gateway smoke, deterministic repeated ZIP creation and exact five-file staging. Independent Astra high evidence review was clear. It is an uncommitted development payload with the base beta.2 version and cannot replace the published beta.2 assets. It excludes the follow-up below and retains earlier dispatch labels, so it requires rebuilding before final qualification. + +The [navigation follow-up](navigation-followup.md) adds five-second prepared thread/symbol/breakpoint resources, corrects the caller-owned thread StringList API, and bounds indexed structure lookup and page access. Six explicit whole-enumeration preparations now honestly declare `blocking_native`; their resource helpers declare `short` and are validated through explicit prepared-projection metadata. Astra found and the implementation repaired symbol publication ordering, a missing process facade in the native test fixture, and a resource-only source-class validation gap. The new thread/structure Lua regressions compile but remain unexecuted. + +Final Release build passed with zero warnings/errors. All 213 focused cases passed; catalog capture passed 38 cases, and the final non-capture portable suite passed **4,234/4,234**, zero failures/skips. The first broad run's single stale script-count assertion was corrected from 18 to 19 and the complete suite rerun. Final Astra high source, golden and evidence reviews were scoped clear, including all 867 source hashes, the 863 prior hashes and both frozen 859-input Settings inventories. Evidence: `artifacts/performance-preparation/navigation-followup-evidence.json`. + +Completed package and offline follow-up subcases are checked off in #28 and #33. Exact normalized issue readback passed (64,632 and 26,470 characters), preserving all incomplete native/integration/release gates. A fresh review confirmed #31 still requires compiler/prerequisite, shared-temp expiry/reload, delayed export, injection and final stable evidence. All three issues remain open; the goal is not complete. No primary product integration, new native execution, commit, push or publication occurred. + +The follow-up was subsequently [packaged locally](navigation-package.md#latest-reviewed-follow-up-package) from a fresh hash-matched copy of all 867 reviewed files. Both exact-DLL probes, Native AOT empty-registry smoke, two identical ZIP builds and maintained five-file staging passed. Final Astra high package evidence review was scoped clear; it independently verified both ZIPs, source and file hashes, versions, logs and both frozen Settings inventories. This supersedes the first development package for the repaired navigation source, but remains an uncommitted beta.2-version payload with no stable identity or native qualification claim. Evidence: `artifacts/candidate-preparation/followup-package-evidence.json`. + +The latest local-package subcase is also checked off in #28 and #33. Exact readback passed at 64,861 and 26,699 characters. Native Lua regressions, primary integration, final source/version identity, native acceptance and release remain open; #31 remains unchanged. The Settings-observation approval is still pending, and no consumed approval was reused. + +### Standalone navigation Lua cases prepared + +The [five-case navigation regression](navigation-native-regression.md) is now prepared against the already reviewed Release test module. It exercises thread StringList parsing/destruction and the new bounded structure definition/page script in an ordinary standalone Lua bridge with a simulated host. The runner restricts the exact five fact names, requires all five passed with no skips, uses a 60-second owned-process deadline and isolates temporary files. It has no CE launch, real target, debugger or follow-on session. + +The no-execution preflight and independent Astra high preparation review verified 1,088 files, including all 867 reviewed sources and 216 test-output files. The reviewed portable test module is unchanged, so no rebuild or test discovery was needed. Both 859-file Settings inventories also remain unchanged. The separate five-case native execution still requires explicit approval under the user's earlier stop. No additional acceptance gate is checked off on the strength of this unexecuted proposal. + +### Matching navigation Debug checks + +The [reviewed navigation follow-up](navigation-followup.md#matching-debug-portable-verification) now also has a passing Debug portable run from a separate hash-matched copy of its 867 files. Locked restore and build passed with zero build warnings/errors; all 4,234 cases passed with zero failures/skips in 1 minute 42.351 seconds. The Debug and Release TRX test names match exactly. Evidence: `artifacts/candidate-preparation/debug-followup-evidence.json`. + +Both Settings inventories (859 files each) and the navigation native inventory (1,088 files) still match. No primary product source, existing Release test output or pending native input changed. Astra found and verified the repair of a timestamp serialization error in the evidence recorder; final independent Astra high evidence review is scoped clear. Final clean-candidate and native gates remain open. + +The completed Debug subcase is checked off in #28 and #33. Exact body readback passed (64,663 and 26,501 characters), preserving all incomplete acceptance gates. Both issues remain open; #31 is unchanged. No native session, commit, push or publication occurred. + +### Maintainer-directed MCP closeout + +The maintainer subsequently requested focusing on MCP, closing the remaining issues where further work could not proceed, and committing tested code locally to `main`. The [closeout record](mcp-closeout.md) supersedes the earlier keep-open requirement and pending native preparations. It records integration of all 61 reviewed files, fresh 4,234-pass Debug and Release portable runs, clean builds/formatting, both package/AOT probes, local ZIP verification and final Astra review with no actionable findings. Issues #28, #31 and #33 are now verified closed as `not_planned`, with their acceptance checklists unchanged. The historical native defects and unperformed stable qualification remain explicitly deferred; no stable release or push is claimed. diff --git a/docs/qualification/v2.0.0/requirement-matrix.md b/docs/qualification/v2.0.0/requirement-matrix.md index f111fc9..51a8ea3 100644 --- a/docs/qualification/v2.0.0/requirement-matrix.md +++ b/docs/qualification/v2.0.0/requirement-matrix.md @@ -1,6 +1,6 @@ # Stable v2.0.0 requirement matrix -Date: 2026-10-08. +Date: 2026-10-09. Status: Crosswalk of the 121 roadmap entries that were unchecked when this matrix was created; subsequent evidence and checkbox updates are recorded in the linked checkpoints. pass means the complete requirement has evidence at the level stated, with final-candidate repetitions separate when named. @@ -23,19 +23,19 @@ No row generalizes that environment to another Windows, CE, runtime, target, or | 2.04 | Classify each advertised capability as qualified, optional/environment-dependent, experimental, or unsupported, with evidence and limitations. | partial | The stable definition is recorded in support-matrix.md and acceptance.md, with the remaining limitation named in this row. | Review the per-domain table and replace experimental or optional status only with complete evidence. | release coordinator | | 2.05 | Define which P1 features belong in stable v2 before an RC is tagged; move deferred work into linked follow-ups. | pass | All three P1 features are intentionally selected in support-matrix.md and acceptance.md. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | | 2.06 | Freeze stable tool names, arguments, result/error shapes, resource URIs, prompts, and gateway routing semantics. | partial | The stable definition is recorded in support-matrix.md and acceptance.md, with the remaining limitation named in this row. | Finish the section 3 audit, freeze the RC contract, and repeat exact-candidate snapshots. | release coordinator | -| 2.07 | Write the beta-to-stable compatibility policy: additive evolution, intentional breaking changes, configuration migration, and future deprecations. | pass | support-matrix.md and acceptance.md define additive compatibility, breaking changes, migration, and tag handling. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | +| 2.07 | Write the beta-to-stable compatibility policy: additive evolution, intentional breaking changes, configuration migration, and future deprecations. | partial | The default policy remains defined; release-notes.md now enumerates proposed unavailable-field and export-resource corrections that intentionally change prerelease compatibility. | Accept or revise the explicit exceptions and client migration guidance in the final contract review before freeze. | release coordinator | | 2.08 | Define release-stopping defects: crashes, corruption, cross-instance/target effects, unreported partial writes, leaked state, broken installation, and wrong outcome reporting. | pass | support-matrix.md defines the release-stopping defect classes. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | -| 2.09 | Set the RC bug-bash/soak duration, supported environments, exercised workloads, and pass/fail criteria; record results rather than infer stability from tool counts. | pass | acceptance.md defines the two-hour two-instance workload and pass or fail criteria. | Review the definition against exact RC evidence and retain its selected boundary. | release coordinator | +| 2.09 | Set the RC bug-bash/soak duration, supported environments, exercised workloads, and pass/fail criteria; record results rather than infer stability from tool counts. | partial | acceptance.md defines the two-hour two-instance workload and pass or fail criteria; no completed two-hour result exists. | Resolve the native release blocker, then run the approved soak on the qualified candidate and record its measured outcome. | release coordinator | | 2.10 | Agree measurable performance and responsiveness thresholds from section 9's baselines, including workload sizes, measurement method, and allowed exceptions for blocking native calls. | partial | acceptance.md defines measured-relative budgets; remaining-checkpoint.md records three baseline and three candidate sessions for two x64 memory workloads. | Complete and review the other workload baselines, UI responsiveness, and final-candidate repetitions. | release coordinator | -| 3.01 | Reconcile catalog constants, registrations, generated snapshots, tool map, workflow prompts, and packaged-plugin reflection output for the final candidate. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Reconcile final names, schemas, prompts, resources, workflows, tool map, and packaged DLL on the exact RC. | contract owner | +| 3.01 | Reconcile catalog constants, registrations, generated snapshots, tool map, workflow prompts, and packaged-plugin reflection output for the final candidate. | partial | contract-checkpoint.md records the source review; remaining-checkpoint.md records current Debug/Release single-DLL catalog equality and exact AOT executable snapshots. | Reconcile final names, schemas, prompts, resources, workflows, tool map, and packaged DLL on the exact clean RC. | contract owner | | 3.02 | Verify schema compatibility with reflection-disabled JSON and the published Native AOT gateway, including every new input/result type. | pass | contract-checkpoint.md names resolver parity, AOT, packaged reflection, and the clean candidate result. | Complete the named audit gap and repeat V+ on the exact RC and final stable commit. | contract owner | -| 3.03 | Verify nullable/unavailable results remain distinct from legitimate zero/empty values. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Audit unavailable, null, zero, empty string, and empty collection results across every domain. | contract owner | -| 3.04 | Verify 64-bit addresses, signed offsets, exact integer values, and hexadecimal conventions survive client serialization without precision loss. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Run exact integer, maximum address, negative offset, and hexadecimal round trips through a real MCP client. | contract owner | +| 3.03 | Verify nullable/unavailable results remain distinct from legitimate zero/empty values. | partial | offline-contract-audit.md records full-domain source coverage, six unavailable-result corrections and clarified collector/FoundList limits; all 626 standalone Release NativeLua cases passed after explicit authorization. | Verify the exact candidate and applicable real CE/collector behavior; preserve documented upstream ambiguity. | contract owner | +| 3.04 | Verify 64-bit addresses, signed offsets, exact integer values, and hexadecimal conventions survive client serialization without precision loss. | partial | All 12 ExactValuePipelineTests passed: real .NET MCP and gateway serialization preserve extrema, exact integers above the JavaScript safe range, signed offsets, addresses and pointers. | Include these working-tree regressions in the clean RC and final stable verification; named interactive clients are separate. | contract owner | | 3.05 | Verify every routed call requires its explicit `instanceId`; instance disappearance must never silently select another CE process. | pass | contract-checkpoint.md names required-instance, stale-instance, and two-instance routing tests. | Complete the named audit gap and repeat V+ on the exact RC and final stable commit. | contract owner | -| 3.06 | Verify read-only/destructive/idempotent/open-world annotations, dispatch classes, and capability requirements match actual effects. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Review every annotation, dispatch class, and capability against the implementation effect. | contract owner | -| 3.07 | Verify disabled capabilities reject before the covered effect, including loaded tables, scripts, indirect Lua/AA paths, and argument-dependent operations. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Complete loaded-table, record-script, indirect Lua or AA, and argument-dependent gate cases. | contract owner | +| 3.06 | Verify read-only/destructive/idempotent/open-world annotations, dispatch classes, and capability requirements match actual effects. | partial | The full 193-tool source inventory is reviewed; offline-contract-audit.md records 14 corrected tool entries across both continuations and the prefetchable exports-resource removal. | Reconcile the exact candidate catalog/package; measure applicable native dispatch budgets separately. | contract owner | +| 3.07 | Verify disabled capabilities reject before the covered effect, including loaded tables, scripts, indirect Lua/AA paths, and argument-dependent operations. | partial | Source audit and portable table, secondary AA, record batch and process auto-attach checks are recorded in contract-checkpoint.md; the record_clear hint assertion passed in the approved standalone NativeLua run. | Run actual CE hook cases when permitted and include the changes in final-candidate checks. | contract owner | | 3.08 | Document actual defaults: the four capability switches are currently on; file/table allowed roots are empty. Switches are exposure controls, not a sandbox. | pass | Configuration tests and public docs agree on enabled switches, empty roots, precedence, and exposure semantics. | Complete the named audit gap and repeat V+ on the exact RC and final stable commit. | contract owner | -| 3.09 | Verify typed `isError`, `error.kind`, `hostEffect`, retryability, details, hints, and correlation IDs through direct backend and gateway calls. | partial | Exact current tests and gaps are recorded in contract-checkpoint.md. | Audit retryability, details, hints, correlation, and every host-effect class through backend and gateway. | contract owner | +| 3.09 | Verify typed `isError`, `error.kind`, `hostEffect`, retryability, details, hints, and correlation IDs through direct backend and gateway calls. | partial | The working-tree source/portable audit in contract-checkpoint.md covers all six host effects through direct and routed errors and corrects missing correlation IDs on non-object context. | Include the reviewed correction in a clean candidate and repeat exact RC/final portable and package checks. | contract owner | | 3.10 | Preserve the rule that uncertain writes/injections/patches are not automatically replayed after timeout or transport failure. | pass | contract-checkpoint.md names cancellation, timeout, and dropped-connection no-replay tests. | Complete the named audit gap and repeat V+ on the exact RC and final stable commit. | contract owner | | 3.11 | Verify tools/list, resources, prompts, completions, and offline knowledge remain consistent when no CE instance is running. | pass | contract-checkpoint.md names no-instance tool, resource, prompt, completion, and executable snapshot tests. | Complete the named audit gap and repeat V+ on the exact RC and final stable commit. | contract owner | | 6.01 | Report CE-close/reload lifetime accurately, including CE's documented possibility that another CE instance closing removes an unused compiler output; verify this behavior on the supported build. Do not promise assembly unload or cleanup that CE cannot confirm. | environment_dependency | compiler-checkpoint.md records approved phases 1 through 4 on clean 808a626e65eddab113737a27a7c2345555b1c241. | Run reviewed shared-temp close and reload observations on private CE copies and record retained or expired behavior. | compiler qualification owner | @@ -77,13 +77,13 @@ No row generalizes that environment to another Windows, CE, runtime, target, or | 8.22 | Design separate controlled managed fixtures for .NET, Mono, and IL2CPP and record the exact collector/runtime versions. | not_run | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | | 8.23 | Define and review any new qualification policy for debugger/injection/compiler/managed-invocation/kernel work. The current harness explicitly excludes kernel, hypervisor, and code-execution probes; this issue does not remove that restriction. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | | 8.24 | Record unqualified optional capabilities explicitly, with follow-ups or experimental support status; do not claim universal native compatibility from the existing smoke scenario. | partial | local-checkpoint.md and compiler-checkpoint.md cover only their named pointer, smoke, and compiler cases. | Extend the owned fixture and run every clause in this domain row; current broad smoke evidence remains partial. | domain qualification owner | -| 9.01 | Establish reproducible latency/UI-responsiveness baselines for short reads, large batches, regions, scans, pointers, symbols, structures, dissection, and instance discovery. | partial | remaining-checkpoint.md records the three-run x64 single-read and 16-read-batch baselines; the other workloads and UI responsiveness remain unmeasured. | Run three reproducible baseline measurements with fixed workload sizes and environment identity. | performance qualification owner | -| 9.02 | Choose practical acceptance budgets per dispatch class and document operations that can block CE or outlive an MCP timeout. | partial | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Approve planned budgets after baseline review and enumerate blocking-native exceptions. | performance qualification owner | -| 9.03 | Check dispatch concurrency/busy responses under multiple clients without starving CE's main thread. | not_run | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Measure multiple-client admission and busy latency while monitoring CE main-thread responsiveness. | performance qualification owner | -| 9.04 | Verify large jobs stay within configured job/buffer/TTL/result bounds and expose truncation or dropped data. | partial | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | -| 9.05 | Verify expensive completions/live-resource reads share admission limits and cannot turn ordinary client navigation into an unbounded scan. | partial | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | +| 9.01 | Establish reproducible latency/UI-responsiveness baselines for short reads, large batches, regions, scans, pointers, symbols, structures, dissection, and instance discovery. | partial | remaining-checkpoint.md retains the two measured x64 memory workloads. [Performance preparation](performance-preparation.md) and [admission preparation](admission-preparation.md) add reviewed, managed-tested measurement runners; the broader native matrix is unexecuted. | Integrate the reviewed preparation, then run three reproducible baselines and candidate measurements with fixed workloads and environment identity under separate native authorization. | performance qualification owner | +| 9.02 | Choose practical acceptance budgets per dispatch class and document operations that can block CE or outlive an MCP timeout. | partial | acceptance.md defines planned budgets. [Performance preparation](performance-preparation.md) validates paired workload comparisons; [navigation follow-up](navigation-followup.md) labels six whole-enumeration preparations blocking_native. Neither proves native latency or meets the final-candidate gate. | Approve measured-relative budgets after native baseline review and verify the integrated candidate's blocking-native exceptions. | performance qualification owner | +| 9.03 | Check dispatch concurrency/busy responses under multiple clients without starving CE's main thread. | not_run | [Admission preparation](admission-preparation.md) implements two-gateway overlap, busy classification, UI observations and comparison checks; 52 managed cases passed. No native concurrency/UI measurement has run. | Execute the reviewed measurement on baseline and integrated candidate packages under separate native authorization; record all required response classes and overlap. | performance qualification owner | +| 9.04 | Verify large jobs stay within configured job/buffer/TTL/result bounds and expose truncation or dropped data. | partial | [Performance preparation](performance-preparation.md) adds measured-workload preparation and a managed-tested one-slot bound for retained failed pointer range searches. This narrow repair does not qualify every job, TTL, buffer or dropped-data contract. | Complete the broader native job/buffer/TTL and soak cases on the exact RC; record counts, limits and resource recovery. | performance qualification owner | +| 9.05 | Verify expensive completions/live-resource reads share admission limits and cannot turn ordinary client navigation into an unbounded scan. | partial | [Navigation follow-up](navigation-followup.md) uses explicit five-second prepared projections for module/region/thread/symbol/breakpoint navigation and passes 4,234 Release portable cases. The [local package](navigation-package.md#latest-reviewed-follow-up-package) is reviewed; primary integration, changed Lua execution and native admission evidence remain pending. | Run the prepared Lua regressions with approval, integrate the reviewed source, and qualify the packaged candidate's navigation/admission behavior in CE. | performance qualification owner | | 9.06 | Exercise sustained polling and repeated enable/disable cycles; check managed/native memory, handles, sockets, retained jobs, and event subscriptions. | not_run | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Run the two-hour soak and compare handles, private bytes, sockets, jobs, and subscriptions to baseline. | performance qualification owner | -| 9.07 | Keep symbol waits, whole-heap searches, unsupported cancellation, and long native calls explicit rather than masking them with a successful timeout response. | partial | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | +| 9.07 | Keep symbol waits, whole-heap searches, unsupported cancellation, and long native calls explicit rather than masking them with a successful timeout response. | partial | [Navigation follow-up](navigation-followup.md) corrects six explicit enumeration tools to blocking_native and keeps prepared resource helpers short. [Performance preparation](performance-preparation.md) documents dissection wait and uncertain cleanup limits. These scoped reviews do not qualify all native timeout paths. | Integrate the reviewed changes and verify remaining long-call/cancellation/timeout outcomes on the exact candidate without automatic replay. | performance qualification owner | | 9.08 | Document tuning defaults and measured tradeoffs; raising concurrency must not be described as making CE's main thread parallel. | partial | acceptance.md records budgets and workload; remaining-checkpoint.md has only two measured x64 memory workloads, with this criterion and soak still unqualified. | Implement the measurement or soak scenario, run it on the exact RC, and record counts, p95, limits, and recovery. | performance qualification owner | | 10.01 | Qualify a clean installation from the exact candidate ZIP on a supported Windows environment using only the documented deployment files. | not_run | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Install only the exact candidate ZIP into a clean private CE copy and record first-use and cleanup. | installation and client owner | | 10.02 | Verify CE version/architecture and all required .NET 10 host frameworks; provide actionable diagnostics for missing/mismatched runtime configuration. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | @@ -92,18 +92,18 @@ No row generalizes that environment to another Windows, CE, runtime, target, or | 10.05 | Preserve user configuration, tables, unrelated plugins, and CE settings; backup/rollback steps must be reproducible. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | | 10.06 | Verify matching plugin/gateway versions and source identity; detect unsupported mismatches clearly. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | | 10.07 | Document exact configuration precedence, per-activation reads, environment overrides, and validation failures. | pass | README, gateway README, and McpConfigurationTests.Load_PluginUserAndEnvironment_UseDocumentedPrecedence document and test precedence. | Reconcile final-package docs and repeat invalid configuration cases. | installation and client owner | -| 10.08 | Verify malformed config, invalid ports/roots/names, unavailable log locations, occupied endpoints, and startup refusal do not leave half-enabled state. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | +| 10.08 | Verify malformed config, invalid ports/roots/names, unavailable log locations, occupied endpoints, and startup refusal do not leave half-enabled state. | partial | Configuration coverage plus 12 passing McpModuleLifecycleTests include real-module occupied-port cleanup and best-effort logging when the log directory cannot be used. | Repeat against the exact installed candidate; portable loopback hosting does not prove the CE plugin installation lifecycle. | installation and client owner | | 10.09 | Test first use in the supported MCP clients: registration, discovery, instance choice, target orientation, tool listing, structured errors, resources, prompts, and completions. | environment_dependency | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Name supported clients first, then run the complete first-use workflow in each actual client. | installation and client owner | | 10.10 | Verify fresh/stale instance IDs, same-name instances, admin/user mismatch, target vs CE PID, and wrong executable/version troubleshooting. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | -| 10.11 | Provide a support-report checklist with versions, operation/error ID, effective gates, and redacted logs; never request tokens or discovery-record contents. | not_run | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | -| 10.12 | Document disable/uninstall, rollback, and recovery from a locked gateway or partially completed upgrade. | partial | Current docs and portable configuration checks cover design behavior, not an exact-package installation result. | Run the exact-package installation or client case and record preservation, refusal, rollback, and cleanup. | installation and client owner | +| 10.11 | Provide a support-report checklist with versions, operation/error ID, effective gates, and redacted logs; never request tokens or discovery-record contents. | pass | The connection-troubleshooting support checklist names environment/package/client versions, operation and error fields, gates, reproduction and redacted logs; it excludes tokens and discovery contents. README links to it. | Keep final-release version guidance current; this documentation pass does not qualify an installation or client. | installation and client owner | +| 10.12 | Document disable/uninstall, rollback, and recovery from a locked gateway or partially completed upgrade. | pass | The connection-troubleshooting recovery section documents separate staging, matching plugin/gateway paths, client registration, locked files, partial upgrades, rollback and removal while preserving user data. README and release notes link to it. | Execute the separate exact-package clean-install, upgrade and rollback gates before release. | installation and client owner | | 11.01 | Run required V+ checks on the exact RC commit and review intended golden-file changes. | not_run | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Create the exact RC commit and run V+ with reviewed golden changes. | release engineering owner | | 11.02 | Verify all newly selected workflow-matrix evidence references the same candidate commit or record why a prior result remains applicable. | not_run | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Cross-reference every retained result to one candidate or document the intervening diff and applicability. | release engineering owner | | 11.03 | Confirm CI format/build/test/analysis results and resolve actionable release-stopping findings; record limitations for checks unavailable in a given environment. | not_run | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Run and review remote CI build, format, test, and analysis for the exact RC. | release engineering owner | -| 11.04 | Verify locked dependency restore, high/critical advisory handling, embedded dependency inventory, licenses, and third-party notices. | partial | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Repeat this gate on the exact RC and final stable bytes, then record hashes, logs, remote references, and limitations. | release engineering owner | -| 11.05 | Verify the flat ZIP inventory, deterministic packaging where promised, checksums, absence of development leftovers, and plugin/gateway version/source agreement. | partial | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Repeat this gate on the exact RC and final stable bytes, then record hashes, logs, remote references, and limitations. | release engineering owner | +| 11.04 | Verify locked dependency restore, high/critical advisory handling, embedded dependency inventory, licenses, and third-party notices. | partial | remaining-checkpoint.md records fresh Debug/Release locked publication, embedded dependency and notice checks for the uncommitted working tree; these are not clean RC or stable bytes. | Repeat this gate on the exact RC and final stable bytes, then record hashes, logs, remote references, and limitations. | release engineering owner | +| 11.05 | Verify the flat ZIP inventory, deterministic packaging where promised, checksums, absence of development leftovers, and plugin/gateway version/source agreement. | partial | [Latest navigation package](navigation-package.md#latest-reviewed-follow-up-package) records the 867-file reviewed source copy, two byte-identical Release ZIPs, all five staged-file hashes and matching plugin/gateway beta.2 base identities. Its manifest records the uncommitted changes; it is not a clean RC or stable payload. | Repeat this gate on the exact RC and final stable bytes, then record hashes, logs, remote references, and limitations. | release engineering owner | | 11.06 | Verify upload/publish repeatability, draft recovery, tag/source mismatch refusal, immutable published binaries, and uploaded digest/size checks. | partial | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Repeat this gate on the exact RC and final stable bytes, then record hashes, logs, remote references, and limitations. | release engineering owner | -| 11.07 | Prepare reviewed stable release notes with support matrix, migration instructions, known limitations, and changes since beta.2. | partial | The clean compiler candidate has bounded local package evidence, but it is beta-versioned and is not the RC or final stable package. | Repeat this gate on the exact RC and final stable bytes, then record hashes, logs, remote references, and limitations. | release engineering owner | +| 11.07 | Prepare reviewed stable release notes with support matrix, migration instructions, known limitations, and changes since beta.2. | pass | release-notes.md is an unpublished reviewed draft covering all four clauses, including the unresolved production dispatch crash and outstanding qualification. | Finalize exact source, hashes, qualified environments and public asset links only after the separate final stable gates pass. | release engineering owner | | 12.01 | Standalone structure XML export: define its format and fidelity; do not imply a lossless CE structure round trip from a simple XML fragment. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | | 12.02 | Bulk register-context updates: define snapshot/stop-state requirements, validation before effects, per-register/partial outcomes, and readback. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | | 12.03 | Optional typed FPU/XMM writes using CE-local context APIs; current extra-register reads already exist, so do not duplicate them. | not_run | No implementation or qualification evidence exists; this item is outside the stable blocking path. | Create a linked P2 follow-up with design, ownership, acceptance, and evidence requirements; do not block stable v2.0.0. | P2 follow-up owner | diff --git a/docs/qualification/v2.0.0/settings-observation.md b/docs/qualification/v2.0.0/settings-observation.md new file mode 100644 index 0000000..15df10f --- /dev/null +++ b/docs/qualification/v2.0.0/settings-observation.md @@ -0,0 +1,44 @@ +# Settings reload observations + +Status: historical unexecuted preparation, superseded by the [MCP-only closeout](mcp-closeout.md). The earlier Release build, two 859-file preflights and Astra review passed. Neither the ten standalone cases nor the proposed live session was authorized or executed; integration supersedes the primary-checkout inputs, so future runs require fresh preparation and review. + +## Evidence and purpose + +The [retained-key experiment](lifecycle-key-fix.md) failed with a measured zero-row checklist. Its registry change was rolled back after the source premise was disproved. The ordinary product distribution remains unchanged; there is no demonstrated fix for the empty list or historical SDK stack failure. + +Pinned CE's reload returns early for a missing form or a runtime class other than `TformSettings`, and its broad exception handler can swallow an error before checklist population. `getSettingsForm()` wraps the existing form rather than creating it. The [object binding](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaObject.pas#L91-L96) exposes the runtime class through the read-only [ClassName property](https://github.com/cheat-engine/cheat-engine/blob/ec45d5f47f92a239ba0bf51ec5d04a7509c3fd37/Cheat%20Engine/LuaObject.pas#L123-L132). + +The fixed bridge now reads that class and the checklist count before reload. A row-count refusal adds both observations beside the existing post-reload count. Class text is capped at 128 characters, and the complete existing error receipt remains ASCII-sanitized and capped at 1,024 characters. These observations do not change settings, populate rows, reload the plugin, or weaken the exact one-row/name guard. The rest of the existing lifecycle scenario is unchanged. + +The new evidence can identify a class mismatch and distinguish a list already empty before reload from a changed list. It cannot by itself prove that the reload completed, identify a swallowed exception, or establish why an unchanged zero-row list is empty. Matching the expected class only rules out that one pinned-source guard; installed binary/source equivalence is still unproven. A passing lifecycle attempt would not demonstrate that these extra reads fixed anything. + +The additional source review confirmed that normal Settings opening and its page-change handler provide no independent checklist-refresh operation. The guard preserves non-plugin user settings and both private hosts share that guarded CE settings tree. No specific mismatched registry value has been identified as the cause. No internal flags, native addresses, user settings or checklist objects are modified by this observation patch. + +## Prepared sequence + +1. Run the ten exact `NativeLuaToolRuntimeTests.LivePluginBridge_*` cases in one owned process with the same staged Lua DLL and 60-second deadline. The two new `LivePluginBridge_ReloadChangesRows_ReportsObservedClassAndBeforeAfterCounts` theory cases assert independently observed pre/post counts and class text, refusal before key messages/Settings apply, and unchanged enabled state. Existing eight cases retain their earlier scope. Require ten passed, zero failed/skipped before continuing. +2. If all ten pass and the complete sequence is approved, run one maintained x64 `lifecycle` session with the unchanged ordinary SDK package. Keep the same two private CE copies, owned targets, original prelude/six workflow groups, three plugin cycles and target/gateway/host restarts described in [lifecycle-resume.md](lifecycle-resume.md#exact-proposed-workload). All four optional gates remain false; no compiler/injection acknowledgement is set. Stop for evidence review on failure; no automatic retry. + +This preserves the known failing ordering. Its incremental purpose is the bounded Settings observation, not a new product change. Existing settings backup/restoration, refusal of pre-existing user CE/DebugView processes, owned-process cleanup and post-run checks remain in force. No debugger, private SDK trace or additional native entry point is introduced. + +The Release test-project build passed with zero warnings/errors (`artifacts/issue-edits/goal-settings-observation-build.log`). Both no-execution preflights verified 859 files including all 527 unchanged product sources and the original distribution. Native tests have not been run under the earlier stop; this prepared sequence requires a new execution decision because previous approvals are consumed. + +| Prepared input | SHA-256 | +| --- | --- | +| Test module | `F767C901F32DA587DFB04723EE0339D9181BB88CCC3C66355BA842B35A7F1411` | +| Standalone manifest | `B2AAA114D396827219BEA835EB1A4C66536DA176F113388545438C7D2C3F628F` | +| Lifecycle manifest | `529ADA4C471080611FE176033EBC5C9EDBEE2A3A0D3C6CF00467C47605CE34BA` | +| Ordinary plugin | `1620DD5978C4996BA80DEB5B81403EB3B695B3D029C1D1D533A3F39254D5A97F` | +| Ordinary gateway | `ADB97797600614CEF21C7F5E2352F95E9102A3D4DFCE48DA4F5B19335E65CA88` | + +Prepared commands, only after approval: + +```powershell +& artifacts/issue-edits/Invoke-SettingsObservationNativeTests.ps1 -Execute +# Proceed only after verifying all ten exact cases passed. +& artifacts/issue-edits/Invoke-SettingsObservation.ps1 -Execute +``` + +These runs cannot satisfy the complete requirements of #28, #31 or #33. Final clean-candidate checks, the broader native matrix, compiler/injection qualification, installation/client/environment cases, soak and publication remain separate requirements. + +Independent Astra high review found no actionable issues in the read-only observations, exact row/name guards, bounded errors, two new cases, both manifests or conditional runners. It specifically confirmed that an expected class with unchanged zero rows remains ambiguous. Source whitespace and runner syntax checks passed. No new native pass is claimed. diff --git a/docs/qualification/v2.0.0/stack-trace-diagnostic.md b/docs/qualification/v2.0.0/stack-trace-diagnostic.md new file mode 100644 index 0000000..6830c27 --- /dev/null +++ b/docs/qualification/v2.0.0/stack-trace-diagnostic.md @@ -0,0 +1,71 @@ +# Private stack trace diagnostic - 2026-10-09 + +Status: the user separately approved one instrumented session, and run `20261009T111321Z-688b` passed. This is private diagnostic evidence, not a production fix or stable-release candidate. The four approved ordinary-SDK sessions and this one instrumented session are complete. No further live run is scheduled or authorized by this one-session approval. + +## Prepared workload + +Run exactly one `ResourcePreludeMemoryNamedScanThenAob` session through the maintained Release live harness, using two private CE copies and two owned x64 targets. Preserve the existing read-only resource/runtime prelude, bounded memory/named-scan/AOB operations, 64-byte non-executable post-AOB allocation/free and final sentinel reads. All four optional gates remain false. The private harness creates each host's absolute trace directory before launch and forwards the trace flag only for the diagnostic scenario. Normal environment scrubbing removes inherited trace flags. + +The harness retains its existing five-minute host lifetime, refusal while user CE/DebugView processes are running, ownership-based cleanup, user-settings restoration and source-installation checks. The runner requires the exact prepared hashes and one new session, then decodes both hosts' trace files and checks their process identities, package identity, gates, restoration and recorded-process absence. Missing, invalid, exhausted or frozen traces require review. There is no automatic retry or expansion to another workload. + +Execution entry point: `artifacts/issue-edits/Invoke-StackTraceLive.ps1 -Execute`. Without `-Execute`, the script only checks the prepared hashes; that dry preflight passed for **235 inputs**. `goal-stack-trace-run-manifest.json` holds the absolute inputs, selected case and hashes. No execution approval is inferred from the dry preflight. + +## Instrumentation and interpretation + +SDK source was archived from `325c47b573f8bd39a247f1d0101f110fa36c1696`, then modified in `artifacts/dependencies/sdk-stack-trace`. Source manifests retain the base archive identity and hashes for all 1,285 source entries, including changed/new files. The private MCP snapshot derives from the dirty `0f722ef7d6b37eee0985073dfe594c6559b31c14` working tree: 849 source entries, with explicit private package/version/lockfile/notices and harness changes. Neither source identity represents a clean published commit. + +The private SDK measures actual Lua stack heights separately from saved frame heights around dispatch, callback disposal, frame restoration, AOB acquisition and scanner waits. The protected-operation refusal records the exact existing guard observation and required inputs before the original exception. Dispatch/parent identities, OS and managed threads, runtime epoch/generation and nesting depth allow correlation across the known synchronization boundary. Different callback/provider Lua pointers are expected under CE's per-thread state contract and do not alone show a defect. + +Tracing adds native `lua_gettop` measurements and changes timing. It can therefore perturb the reproduction and can itself encounter an invalid state. A numeric probe-intent record precedes each added measurement; a missing result can also mean concurrent exhaustion or freezing and does not alone prove a native fault. Ordinary probes stop when full/frozen. A below-saved-height observation is a diagnostic anomaly to inspect, not independently a proven corruption cause. + +The writer uses a fixed file mapping: 128-byte header, 32,768 ordinary 96-byte slots and one dedicated first-anomaly slot. Slots never overwrite earlier evidence; their sequence is published last. Managed trace setup/write errors disable tracing without replacing SDK operation results or cleanup. Existing files are never overwritten. The first-anomaly slot is retained even after ordinary overflow. The reader omits unpublished slots and reports incomplete admitted records. Evidence survives the tested abrupt process termination; machine-crash/power-loss durability is not claimed. + +## Verified package identities + +| Input | SHA-256 | +| --- | --- | +| Private SDK `2.0.1-mcpstacktrace.1` NuGet package | `5B9501B338F52253CB012AACBD906878C4FF479A11346C6A02638D7C44688365` | +| Private MCP `2.0.0-mcpstacktrace.1` plugin | `86CBA116A248F11728D36A4112E310B0C81D8FFF2E1DEFD40F8C06048C3E5605` | +| Private Native AOT gateway | `B400424A25392DAF3B8E86C12B5CF23BD5364D198E691A2A8CD3BFE47EA5C350` | +| Original published SDK 2.0.0 native bridge | `B008C8D8C136187F241542E6223DC0831999D8300DC2C4C01E1CF49F6FBA7698` | +| Reviewed installed CE x64 executable | `CF6CCC664A90C23531EC20F5C41458CFDC1D04679D6D05ED94C06BCECF95544F` | + +The package directory is `artifacts/mcp-stack-trace/artifacts/dist/stack-trace1`. PE resource inspection extracted/decompressed and compared all seven embedded SDK assemblies plus the native bridge against the private NuGet package. The bridge hash was also checked against the published SDK 2.0.0 bytes. `goal-stack-trace-provenance.json` records these results. Product files and dependency references in the primary checkout were not changed for this diagnostic. + +## Completed offline checks + +- [x] SDK Release build: zero warnings/errors. +- [x] Focused managed recorder tests: **9 passed, 0 failed, 0 skipped**; NativeLua excluded. +- [x] Actual opt-in facade in an owned helper process: terminate with the mapping open and undisposed, then recover **16 committed records, 0 incomplete**, with matching PID, sequence, context, saved/actual values and synthetic state. Disabled tracing, existing-file collision and missing-directory containment also passed. +- [x] Private local publish: isolated actual-DLL checks in two fresh contexts, each **193 tools, 43 resources, 14 templates, 43 prompts**; Native AOT gateway build and no-instance smoke; five-file distribution. +- [x] Portable MCP regressions: initial **4,134 passed, 2 failed, 0 skipped** out of 4,136. Both failures were the private SDK version missing from notices. Corrected the private notices, retained licenses/attribution and reran the complete notices class: **3 passed, 0 failed, 0 skipped**. No later full-suite pass is claimed. +- [x] Script syntax and no-execution hash preflight passed. +- [x] Independent Astra high review: resolved managed-fault cleanup interference, mapping cleanup, first-anomaly accounting, the final-slot probe edge and mapped-memory disposal admission. Final source, package and runner review found no remaining actionable findings in this scope; it does not establish the native root cause. + +Logs and manifests are retained under `artifacts/issue-edits/goal-stack-trace-*`; source and binaries remain local artifacts. The historical crash, production fix, full lifecycle reruns and remaining issue gates are still unresolved. No issue closure, commit, push, tag or release follows from this evidence. + +## Approved instrumented session + +After the explicit one-session approval, the prepared command completed with **1 passed, 0 failed, 0 skipped**, total 18.433 seconds. Run `20261009T111321Z-688b` used the exact plugin/gateway hashes above. The resource prelude, memory/named-scan/AOB sequence, post-AOB allocation/free and final sentinel checks passed. The allocation was 64 bytes and non-executable; release reported complete success. All four optional gates were false. User state was restored, source installation was unchanged, cleanup failures were empty, and all four recorded owned process IDs were absent afterward. + +| Observation | Host A | Host B | +| --- | --- | --- | +| Committed trace records | 1,753 | 121 | +| Paired synchronized dispatches | 71 | 5 | +| Observed frame restorations | 74, including 3 AOB frames | 5 | +| Underflow, restoration mismatch, protected-input refusal or probe failure | 0 | 0 | +| Overflow, incomplete or frozen records | 0 | 0 | + +All 76 synchronized callback entries were depth 1 with no parent. The 200 depth-2 inline scopes were ordinary child calls inside their callbacks. Host A's three AOB waits lasted **4.8749, 2.4261 and 4.7970 ms**, each beginning and ending at stack height 0. No traced synchronized callback or inline call reentered during those waits. Different callback/provider Lua pointers remain expected; no pointer-equality requirement is inferred. + +Independent Astra review checked raw trace/summary hashes, event pairing, state/thread identity, nesting, stack observations and cleanup. No actionable discrepancy was found. This establishes balanced observations at the instrumented boundaries in one successful run. It does not reproduce the historical crash, identify its cause, exercise the synchronized-reentry hypothesis or rule out uninstrumented CE callbacks. + +Evidence: `goal-stack-trace-live-20261009T111321Z-688b.json`, decoded `goal-stack-trace-20261009T111321Z-688b-{A,B}.json`, and `goal-stack-trace-analysis-20261009T111321Z-688b.json`. The original preparation manifest intentionally retains its preparation-time authorization flag; subsequent execution was authorized explicitly in the conversation. + +### Offline trace validator controls + +`StackTraceValidation.ps1` classifies decoded measurements, incomplete/frozen/overflow evidence, paired AOB waits and synchronized callback entries strictly inside a wait on the same OS thread. It reports parent/depth correlation without treating ordinary inline nesting or unequal caller/provider state pointers as corruption. It is an observation classifier, not a complete Lua execution proof. + +`Test-StackTraceValidation.ps1` passed **11 synthetic controls and both recorded host snapshots**: balanced measurements, actual nested thunk with differing expected provider/callback pointers, timestamp ties at either wait boundary resolved by sequence order, pre-restore underflow, post-restore mismatch, unknown-height handling, unrelated-thread exclusion, interrupted records, ordinary inline nesting, and diagnostic evidence categories. The two recorded snapshots produced no findings/evidence/reentry and three paired waits total. Logs and script hashes are in `goal-stack-trace-validator-tests.log` and `goal-stack-trace-validator-hashes.json`. + +The remaining diagnostic gap is an evidence-backed reproduction of the original failure or synchronized reentry; another identical successful baseline would not resolve it. No additional live workload has been scheduled. diff --git a/docs/qualification/v2.0.0/support-matrix.md b/docs/qualification/v2.0.0/support-matrix.md index 5100f55..e6075a0 100644 --- a/docs/qualification/v2.0.0/support-matrix.md +++ b/docs/qualification/v2.0.0/support-matrix.md @@ -90,6 +90,8 @@ The stable v2.0.0 contract freezes tool names, arguments, result and error shape Additive optional fields and new tools may be added in compatible v2 patch or minor work when old callers keep their behavior. Removing or renaming a tool, changing an existing field meaning, changing pointer-chain order, or changing host-effect reporting requires a future reviewed breaking contract. Future deprecations require release-note notice, keep the old behavior through the current major version, and remove it only in a reviewed breaking release. +Before that freeze, the current working tree proposes the explicit beta-to-stable corrections in release-notes.md: nullable omissions, corrected collector/effect descriptions and removal of the prefetchable exports resource in favor of its tool. +They are intentional compatibility changes awaiting final contract review, not an assertion of additive compatibility; requirement 2.07 remains partial until their disposition and migration guidance are accepted. Configuration keeps the current precedence of built-in defaults, deployment settings, user settings, and dedicated environment overrides. The four capability switches remain exposure controls and do not become a sandbox. File and table roots remain empty by default. diff --git a/libs/CheatEngine.Mcp.Core/Composition/CheatEngineMcpBuilderExtensions.cs b/libs/CheatEngine.Mcp.Core/Composition/CheatEngineMcpBuilderExtensions.cs index 2fdba35..b1032f2 100644 --- a/libs/CheatEngine.Mcp.Core/Composition/CheatEngineMcpBuilderExtensions.cs +++ b/libs/CheatEngine.Mcp.Core/Composition/CheatEngineMcpBuilderExtensions.cs @@ -5,6 +5,7 @@ using CheatEngine.Client; using CheatEngine.Mcp.Core.Execution; using CheatEngine.Mcp.Core.Features; +using CheatEngine.Mcp.Core.Inspection; using CheatEngine.Mcp.Core.Jobs; using CheatEngine.Mcp.Core.Lua; using CheatEngine.Mcp.Core.Targets; @@ -94,6 +95,7 @@ public ICheatEngineMcpBuilder AddExecutionServices() services.GetRequiredService>(), services.GetRequiredService()))); builder.Services.TryAddScoped(); + builder.Services.TryAddScoped(); builder.Services.TryAddScoped(); builder.Services.TryAddEnumerable( ServiceDescriptor.Scoped(static services => diff --git a/libs/CheatEngine.Mcp.Core/Composition/McpCompletionCost.cs b/libs/CheatEngine.Mcp.Core/Composition/McpCompletionCost.cs index cb1b0ab..792606a 100644 --- a/libs/CheatEngine.Mcp.Core/Composition/McpCompletionCost.cs +++ b/libs/CheatEngine.Mcp.Core/Composition/McpCompletionCost.cs @@ -17,5 +17,8 @@ public enum McpCompletionCost /// thread, cached for a few seconds per target-selection epoch, refreshed at most once per second, never waited /// on for long, and never listed while another dispatched listing of the server runs. ///
- Dispatch + Dispatch, + + /// A dispatched listing that joins concurrent refreshes but is never served from the outer TTL cache. + DispatchFresh } diff --git a/libs/CheatEngine.Mcp.Core/Composition/McpCompletionReads.cs b/libs/CheatEngine.Mcp.Core/Composition/McpCompletionReads.cs index 7ad5b74..262ed6b 100644 --- a/libs/CheatEngine.Mcp.Core/Composition/McpCompletionReads.cs +++ b/libs/CheatEngine.Mcp.Core/Composition/McpCompletionReads.cs @@ -1,11 +1,8 @@ -using System.Collections.Immutable; - using CheatEngine.Client; -using CheatEngine.Client.Inspection; -using CheatEngine.Client.Results; +using CheatEngine.Client.Processes; using CheatEngine.Mcp.Core.Contract; using CheatEngine.Mcp.Core.Execution; -using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.Mcp.Core.Inspection; namespace CheatEngine.Mcp.Core.Composition; @@ -27,32 +24,39 @@ public static class McpCompletionReads /// Reads the attached process's module names and the target-selection epoch they belong to, in one dispatch. /// /// The activation's dispatch facade. + /// The activation's explicitly prepared module snapshots. /// The token of the listing. /// The distinct module names in Cheat Engine's order, ignoring case, and the selection epoch. /// /// not_attached without a process, busy when the dispatch limit is reached, or the Client's /// failure. /// - public static McpCompletionValues ModuleNames(ToolDispatch dispatch, CancellationToken cancellationToken) + public static McpCompletionValues ModuleNames(ToolDispatch dispatch, PreparedInspectionStore prepared, + CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(dispatch); + ArgumentNullException.ThrowIfNull(prepared); ICheatEngineClient client = dispatch.Client; return dispatch.Run(Operation, token => { - // Both reads run in the same main-thread callback, so the names belong to the epoch read first. - long epoch = client.Processes.GetCurrentProcess(token).SelectionEpoch; - if (!client.Inspection.TryGetModules(new InspectionCollectionRequest(MaximumModules), null, - out ImmutableArray modules, out CheatEngineFailure failure, token)) + ProcessSnapshot target = client.Processes.GetCurrentProcess(token); + if (!prepared.TryGetModules(target, out PreparedModuleSnapshot snapshot)) + { + throw CheatEngineToolException.InvalidState("No current prepared module snapshot is available.", + "Run module_list without processId first."); + } + if (snapshot.Modules.Length > MaximumModules) { - throw CheatEngineToolException.FromFailure(failure, client.Stopping.IsCancellationRequested); + throw CheatEngineToolException.LimitExceeded("modules", + $"Prepared module snapshots for completion may contain at most {MaximumModules} modules."); } string[] names = [ - .. modules.Select(static module => module.Name).Where(static name => !string.IsNullOrEmpty(name)) + .. snapshot.Modules.Select(static module => module.Name).Where(static name => !string.IsNullOrEmpty(name)) .Distinct(StringComparer.OrdinalIgnoreCase) ]; - return new McpCompletionValues(names, epoch); + return new McpCompletionValues(names, target.SelectionEpoch); }, cancellationToken); } } diff --git a/libs/CheatEngine.Mcp.Core/Composition/McpContractRules.Primitives.cs b/libs/CheatEngine.Mcp.Core/Composition/McpContractRules.Primitives.cs index 8add148..0adcfd0 100644 --- a/libs/CheatEngine.Mcp.Core/Composition/McpContractRules.Primitives.cs +++ b/libs/CheatEngine.Mcp.Core/Composition/McpContractRules.Primitives.cs @@ -416,8 +416,9 @@ private static void ValidateCompletions(List failures, string subject, s /// /// A live resource must name, with , the tool whose structured result it /// returns, and publish that name in _meta. A client may read or prefetch a resource without any tool call, - /// so the tool must be a frozen backend tool that is read-only, closed-world, ungated and short; when the - /// server serves tools, it must serve that one. A Local document projects no tool. + /// so the tool must be a frozen backend tool that is read-only, closed-world and ungated. It must be short, + /// unless it names a matching short, ungated prepared projection; when the server serves tools, it must serve the + /// explicit source. A Local document projects no tool. /// private static void ValidateSource(List failures, string subject, McpServerResource resource, McpPrimitiveRouting routing, IReadOnlySet servedTools) @@ -483,11 +484,68 @@ private static void ValidateSource(List failures, string subject, McpSer "must never need a feature switch."); } - if (!string.Equals(source.DispatchClass, McpDispatchClass.Short, StringComparison.Ordinal)) + bool dispatchDefined = McpDispatchClass.IsDefined(source.DispatchClass); + if (!dispatchDefined) { failures.Add($"{subject} projects '{tool}', whose dispatch class is {source.DispatchClass ?? "missing"}; " + - $"only a {McpDispatchClass.Short} tool may back a resource a client can prefetch."); + "it must be one of short, host_scan, blocking_native or may_prompt."); } + + bool preparedProjection = ValidatePreparedProjection(failures, subject, source); + if (dispatchDefined && !string.Equals(source.DispatchClass, McpDispatchClass.Short, StringComparison.Ordinal) && + !preparedProjection) + { + failures.Add($"{subject} projects '{tool}', whose dispatch class is {source.DispatchClass ?? "missing"}; " + + $"only a {McpDispatchClass.Short} tool or a valid prepared projection may back a resource a client can prefetch."); + } + } + + private static bool ValidatePreparedProjection(List failures, string subject, McpResourceSource source) + { + if (source.PreparedProjectionName is null) + { + return false; + } + + string name = source.PreparedProjectionName; + MethodInfo? projection = source.PreparedProjection; + if (projection is null) + { + failures.Add($"{subject} names the prepared projection '{name}', which {source.ToolType.Name} does not " + + "declare exactly once as a public instance method."); + return false; + } + + bool valid = true; + if (projection.GetCustomAttribute(false) is not null) + { + failures.Add($"{subject} names prepared projection '{name}', which must not be an MCP tool."); + valid = false; + } + + if (source.Method is not null && projection.ReturnType != source.Method.ReturnType) + { + failures.Add($"{subject} names prepared projection '{name}', whose return type must exactly match " + + $"'{source.Method.ReturnType.Name}'."); + valid = false; + } + + if (source.PreparedRequires.Count > 0) + { + failures.Add($"{subject} names prepared projection '{name}', which requires " + + $"{string.Join(", ", source.PreparedRequires.Select(McpFeatureGate.ContractName))}; a prepared " + + "resource read must never need a feature switch."); + valid = false; + } + + if (!string.Equals(source.PreparedDispatchClass, McpDispatchClass.Short, StringComparison.Ordinal)) + { + failures.Add($"{subject} names prepared projection '{name}', whose dispatch class is " + + $"{source.PreparedDispatchClass ?? "missing"}; it must declare {McpDispatchClass.Short}."); + valid = false; + } + + return valid; } /// diff --git a/libs/CheatEngine.Mcp.Core/Composition/McpContractRules.cs b/libs/CheatEngine.Mcp.Core/Composition/McpContractRules.cs index 5248632..d904363 100644 --- a/libs/CheatEngine.Mcp.Core/Composition/McpContractRules.cs +++ b/libs/CheatEngine.Mcp.Core/Composition/McpContractRules.cs @@ -195,7 +195,15 @@ private static void ValidateAnnotations(List failures, string name, Tool } string verb = name.Split('_') is [_, { } second, ..] ? second : string.Empty; - if (verb == "poll" && !(readOnly && idempotent)) + // This poll drains DBVM's native log into a bounded ring; retries can consume and evict more events. + if (name == CheatEngineToolNames.KernelPollWatch) + { + if (readOnly || !destructive || idempotent) + { + failures.Add($"Tool '{name}' consumes the native log, so it must be destructive, non-read-only and non-idempotent."); + } + } + else if (verb == "poll" && !(readOnly && idempotent)) { failures.Add($"Tool '{name}' polls, so it must be read-only and idempotent."); } diff --git a/libs/CheatEngine.Mcp.Core/Composition/McpErrorCorrelation.cs b/libs/CheatEngine.Mcp.Core/Composition/McpErrorCorrelation.cs index 6e085d5..e455ee1 100644 --- a/libs/CheatEngine.Mcp.Core/Composition/McpErrorCorrelation.cs +++ b/libs/CheatEngine.Mcp.Core/Composition/McpErrorCorrelation.cs @@ -25,14 +25,14 @@ internal static class McpErrorCorrelation /// /// Correlates an internal error: it keeps an id the error already carries, or adds a new one to its - /// details object, which it creates when there is none. + /// details object, which it creates when there is none. Non-object details are retained under + /// value in that object. /// /// The error to report. /// - /// The id to log; for any other kind. It is logged even when the error's - /// details are not an object and cannot carry it. + /// The id to log; for any other kind. /// - /// The error to report, correlated when possible. + /// The error to report, correlated for an internal failure. internal static ToolError Correlate(ToolError error, out string? errorId) { ArgumentNullException.ThrowIfNull(error); @@ -49,11 +49,6 @@ internal static ToolError Correlate(ToolError error, out string? errorId) } errorId = RandomNumberGenerator.GetHexString(IdLength, true); - if (error.Details is { ValueKind: not JsonValueKind.Object }) - { - return error; - } - return error with { Details = WithId(error.Details, errorId) @@ -72,20 +67,28 @@ internal static ToolError Correlate(ToolError error, out string? errorId) : null; } - // Rewrites the details object with the id appended; an object without details becomes {"errorId":"..."}. + // Keeps structured details and appends the id, wrapping any non-object value without discarding it. private static JsonElement WithId(JsonElement? details, string errorId) { ArrayBufferWriter buffer = new(); using (Utf8JsonWriter writer = new(buffer)) { writer.WriteStartObject(); - if (details is { } existing) + if (details is { ValueKind: JsonValueKind.Object } existing) { foreach (JsonProperty property in existing.EnumerateObject()) { - property.WriteTo(writer); + if (!property.NameEquals(Key)) + { + property.WriteTo(writer); + } } } + else if (details is { ValueKind: not JsonValueKind.Undefined } value) + { + writer.WritePropertyName("value"); + value.WriteTo(writer); + } writer.WriteString(Key, errorId); writer.WriteEndObject(); diff --git a/libs/CheatEngine.Mcp.Core/Composition/McpResourceCompletions.cs b/libs/CheatEngine.Mcp.Core/Composition/McpResourceCompletions.cs index ce1b170..69d9cf6 100644 --- a/libs/CheatEngine.Mcp.Core/Composition/McpResourceCompletions.cs +++ b/libs/CheatEngine.Mcp.Core/Composition/McpResourceCompletions.cs @@ -165,9 +165,12 @@ internal async ValueTask CompleteAsync(CompleteRequestParams? re return new CompleteResult(); } - IReadOnlyList values = listing.Cost is McpCompletionCost.Memory - ? ListNow(listing, cancellationToken) - : await ListCachedAsync(listing, cancellationToken).ConfigureAwait(false); + IReadOnlyList values = listing.Cost switch + { + McpCompletionCost.Memory => ListNow(listing, cancellationToken), + McpCompletionCost.DispatchFresh => await ListFreshAsync(listing, cancellationToken).ConfigureAwait(false), + _ => await ListCachedAsync(listing, cancellationToken).ConfigureAwait(false) + }; return new CompleteResult { Completion = McpCompletions.Match(values, argument.Value) }; } @@ -234,6 +237,42 @@ private async Task> ListCachedAsync(Listing listing, Cance } } + private async Task> ListFreshAsync(Listing listing, CancellationToken cancellationToken) + { + Task> pending; + lock (_gate) + { + if (listing.Refresh is { IsCompleted: false } running) + { + pending = running; + } + else if (listing.StartedAt is { } started && _time.GetElapsedTime(started, _time.GetTimestamp()) < RefreshInterval) + { + return []; + } + else if (_dispatching is not null) + { + return []; + } + else + { + listing.StartedAt = _time.GetTimestamp(); + _dispatching = listing; + pending = Task.Run(() => Refresh(listing), CancellationToken.None); + listing.Refresh = pending; + } + } + + try + { + return await pending.WaitAsync(_wait, _time, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + return []; + } + } + private IReadOnlyList Refresh(Listing listing) { McpCompletionValues? listed; @@ -280,9 +319,13 @@ private IReadOnlyList Refresh(Listing listing) .. (listed.Values ?? []).Where(static value => !string.IsNullOrEmpty(value)) .Distinct(StringComparer.Ordinal) ]; - listing.Values = values; + listing.Values = listing.Cost is McpCompletionCost.DispatchFresh ? null : values; listing.Epoch = listed.SelectionEpoch; listing.ListedAt = _time.GetTimestamp(); + if (listing.Cost is McpCompletionCost.DispatchFresh) + { + listing.StartedAt = null; + } return values; } } diff --git a/libs/CheatEngine.Mcp.Core/Composition/McpResourceSource.cs b/libs/CheatEngine.Mcp.Core/Composition/McpResourceSource.cs index 9b06e40..0525c62 100644 --- a/libs/CheatEngine.Mcp.Core/Composition/McpResourceSource.cs +++ b/libs/CheatEngine.Mcp.Core/Composition/McpResourceSource.cs @@ -21,6 +21,10 @@ namespace CheatEngine.Mcp.Core.Composition; /// Whether the tool is declared read-only. /// Whether the tool is declared open-world. /// The tool's declared dispatch class, if it publishes one as a string. +/// The optional declared prepared-projection method name. +/// The one public instance method resolved under that name, if any. +/// The switches the prepared projection requires, ordered and distinct. +/// The prepared projection's declared dispatch class, if it publishes one. internal sealed record McpResourceSource( string ToolName, Type ToolType, @@ -28,9 +32,14 @@ internal sealed record McpResourceSource( IReadOnlyList Requires, bool ReadOnly, bool OpenWorld, - string? DispatchClass) + string? DispatchClass, + string? PreparedProjectionName, + MethodInfo? PreparedProjection, + IReadOnlyList PreparedRequires, + string? PreparedDispatchClass) { private const BindingFlags ToolMethods = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static; + private const BindingFlags PreparedMethods = BindingFlags.Public | BindingFlags.Instance; /// Resolves the source tool a resource method declares. /// The resource method. @@ -59,8 +68,26 @@ .. declared.ToolType.GetMethods(ToolMethods).Where(method => .. tool.GetCustomAttributes(false) .Select(static requirement => requirement.Feature).Distinct().Order() ]; + string? preparedName = declared.PreparedProjection; + MethodInfo[] preparedCandidates = preparedName is null + ? [] + : + [ + .. declared.ToolType.GetMethods(PreparedMethods) + .Where(method => method.DeclaringType == declared.ToolType && + string.Equals(method.Name, preparedName, StringComparison.Ordinal)) + ]; + MethodInfo? prepared = preparedCandidates.Length == 1 ? preparedCandidates[0] : null; + McpFeature[] preparedRequires = prepared is null + ? [] + : + [ + .. prepared.GetCustomAttributes(false) + .Select(static requirement => requirement.Feature).Distinct().Order() + ]; return new McpResourceSource(name, declared.ToolType, tool, requires, attribute?.ReadOnly ?? false, - attribute?.OpenWorld ?? true, tool is null ? null : DispatchClassOf(tool)); + attribute?.OpenWorld ?? true, tool is null ? null : DispatchClassOf(tool), preparedName, prepared, + preparedRequires, prepared is null ? null : DispatchClassOf(prepared)); } /// Reads the resolved source a created resource carries. @@ -77,7 +104,21 @@ .. tool.GetCustomAttributes(false) /// The seed, or without a source. internal static JsonObject? CreateMeta(McpResourceSource? source) { - return source is null ? null : new JsonObject { [McpSourceToolAttribute.MetaKey] = source.ToolName }; + if (source is null) + { + return null; + } + + JsonObject meta = new() + { + [McpSourceToolAttribute.MetaKey] = source.ToolName + }; + if (source.PreparedProjection is { } projection) + { + meta[McpSourceToolAttribute.PreparedProjectionMetaKey] = projection.Name; + } + + return meta; } private static string? DispatchClassOf(MethodInfo tool) diff --git a/libs/CheatEngine.Mcp.Core/Composition/McpSourceToolAttribute.cs b/libs/CheatEngine.Mcp.Core/Composition/McpSourceToolAttribute.cs index e05ab70..73169eb 100644 --- a/libs/CheatEngine.Mcp.Core/Composition/McpSourceToolAttribute.cs +++ b/libs/CheatEngine.Mcp.Core/Composition/McpSourceToolAttribute.cs @@ -10,9 +10,9 @@ namespace CheatEngine.Mcp.Core.Composition; /// /// WithCheatEnginePrimitives publishes the tool name in the resource's _meta under /// . The startup validator requires the tool to exist on and to be -/// read-only, closed-world, ungated and of the short dispatch class, so a client that reads or prefetches a -/// resource can never cause more than one call of that tool would. Every read also re-checks the tool's feature -/// gates. +/// read-only, closed-world and ungated. Its dispatch class must be short, unless +/// names a separate short, ungated prepared read with the same result type. +/// Every read also re-checks the source tool's feature gates. /// /// /// The reference is typed, so the rule holds in any composition, including one that serves resources without @@ -30,6 +30,9 @@ public sealed class McpSourceToolAttribute( /// The resource _meta key that names the projected tool. public const string MetaKey = "cheatengine/sourceTool"; + /// The resource _meta key that names its bounded prepared projection. + public const string PreparedProjectionMetaKey = "cheatengine/preparedProjection"; + /// The tool container type that declares the tool method. [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] public Type ToolType @@ -42,4 +45,14 @@ public string ToolName { get; } = toolName; + + /// + /// The optional public instance method on that returns the latest prepared result without + /// repeating the source tool's host enumeration. + /// + public string? PreparedProjection + { + get; + set; + } } diff --git a/libs/CheatEngine.Mcp.Core/Inspection/PreparedInspectionStore.cs b/libs/CheatEngine.Mcp.Core/Inspection/PreparedInspectionStore.cs new file mode 100644 index 0000000..1247523 --- /dev/null +++ b/libs/CheatEngine.Mcp.Core/Inspection/PreparedInspectionStore.cs @@ -0,0 +1,177 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Processes; +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.SDK.Engine.Inspection; + +namespace CheatEngine.Mcp.Core.Inspection; + +/// Identifies an immutable prepared inspection snapshot. +public readonly record struct PreparedInspectionVersion(int ProcessId, long SelectionEpoch, long Revision); + +/// Prepared modules for one process-selection version. +public sealed record PreparedModuleSnapshot(PreparedInspectionVersion Version, ImmutableArray Modules); + +/// Prepared modules and memory regions for one process-selection version. +public sealed record PreparedMemoryMapSnapshot(PreparedInspectionVersion Version, ImmutableArray Modules, + ImmutableArray Regions); + +/// Activation-scoped immutable inspection snapshots prepared by explicit source tools. +public sealed class PreparedInspectionStore +{ + /// The maximum age of prepared data; the exact expiry boundary is stale. + public static readonly TimeSpan Lifetime = TimeSpan.FromSeconds(5); + + private readonly Lock _gate = new(); + private readonly TimeProvider _time; + private PreparedModuleSnapshot? _modules; + private PreparedMemoryMapSnapshot? _memoryMap; + private long _publishedAt; + private long _revision; + private long _observedEpoch = -1; + private int _observedProcessId; + + /// Creates an empty activation store using a monotonic clock for freshness. + public PreparedInspectionStore(TimeProvider time) + { + ArgumentNullException.ThrowIfNull(time); + _time = time; + } + + /// Publishes a complete module list, invalidating earlier versions and memory-map preparation. + public PreparedModuleSnapshot PublishModules(ProcessSnapshot target, ImmutableArray modules) + { + RequireModules(modules); + lock (_gate) + { + if (!Observe(target)) + { + throw TargetChanged(); + } + + PreparedInspectionVersion version = NextVersion(target); + PreparedModuleSnapshot snapshot = new(version, modules); + _modules = snapshot; + _memoryMap = null; + _publishedAt = _time.GetTimestamp(); + return snapshot; + } + } + + /// Atomically publishes a complete region map and its module ownership list under one new version. + public PreparedMemoryMapSnapshot PublishMemoryMap(ProcessSnapshot target, ImmutableArray modules, + ImmutableArray regions) + { + RequireModules(modules); + if (regions.IsDefault) + { + throw new ArgumentException("Memory regions must not use the default immutable array.", nameof(regions)); + } + + lock (_gate) + { + if (!Observe(target)) + { + throw TargetChanged(); + } + + PreparedInspectionVersion version = NextVersion(target); + PreparedMemoryMapSnapshot map = new(version, modules, regions); + _modules = new PreparedModuleSnapshot(version, modules); + _memoryMap = map; + _publishedAt = _time.GetTimestamp(); + return map; + } + } + + /// Observes the current target and returns only its fresh prepared module list. + public bool TryGetModules(ProcessSnapshot target, out PreparedModuleSnapshot snapshot) + { + lock (_gate) + { + if (Observe(target) && _modules is { } current && Matches(target, current.Version) && IsFresh()) + { + snapshot = current; + return true; + } + } + + snapshot = null!; + return false; + } + + /// Observes the current target and returns only its fresh prepared memory map. + public bool TryGetMemoryMap(ProcessSnapshot target, out PreparedMemoryMapSnapshot snapshot) + { + lock (_gate) + { + if (Observe(target) && _memoryMap is { } current && Matches(target, current.Version) && IsFresh()) + { + snapshot = current; + return true; + } + } + + snapshot = null!; + return false; + } + + /// Checks target identity, latest publication and expiry for a prepared version. + public bool IsCurrent(ProcessSnapshot target, PreparedInspectionVersion version) + { + lock (_gate) + { + return Observe(target) && _modules is { } current && current.Version == version && Matches(target, version) + && IsFresh(); + } + } + + private PreparedInspectionVersion NextVersion(ProcessSnapshot target) + { + return new PreparedInspectionVersion(target.Id.Value, target.SelectionEpoch, checked(++_revision)); + } + + private bool IsFresh() + { + return _time.GetElapsedTime(_publishedAt, _time.GetTimestamp()) < Lifetime; + } + + private bool Observe(ProcessSnapshot target) + { + if (target.SelectionEpoch < _observedEpoch + || (target.SelectionEpoch == _observedEpoch && target.Id.Value != _observedProcessId)) + { + return false; + } + + if (target.SelectionEpoch > _observedEpoch) + { + _observedEpoch = target.SelectionEpoch; + _observedProcessId = target.Id.Value; + _modules = null; + _memoryMap = null; + } + + return true; + } + + private static bool Matches(ProcessSnapshot target, PreparedInspectionVersion version) + { + return target.Id.Value == version.ProcessId && target.SelectionEpoch == version.SelectionEpoch; + } + + private static void RequireModules(ImmutableArray modules) + { + if (modules.IsDefault) + { + throw new ArgumentException("Modules must not use the default immutable array.", nameof(modules)); + } + } + + private static CheatEngineToolException TargetChanged() + { + return new CheatEngineToolException(new ToolError(ToolErrorKind.TargetChanged, + "The prepared inspection publication belongs to an older or different selected target.", null, + ToolHostEffect.NotStarted, false, "Read the current target again before publishing inspection data.")); + } +} diff --git a/libs/CheatEngine.Mcp.Core/Jobs/JobPoll.cs b/libs/CheatEngine.Mcp.Core/Jobs/JobPoll.cs index 679995d..a5339be 100644 --- a/libs/CheatEngine.Mcp.Core/Jobs/JobPoll.cs +++ b/libs/CheatEngine.Mcp.Core/Jobs/JobPoll.cs @@ -3,8 +3,8 @@ namespace CheatEngine.Mcp.Core.Jobs; /// -/// One page of a job's items. Polling is read-only and idempotent: afterSequence is a cursor, never an -/// acknowledgement, so repeating a poll returns the same page. +/// One page of a job's buffered items. Reading this page does not consume it: afterSequence is a cursor, +/// never an acknowledgement. Items remain readable until the job expires or the buffer evicts them. /// /// The item type. /// The job's status. diff --git a/libs/CheatEngine.Mcp.Core/Jobs/LuaJobKernelScripts.cs b/libs/CheatEngine.Mcp.Core/Jobs/LuaJobKernelScripts.cs index a1d88ce..af024b6 100644 --- a/libs/CheatEngine.Mcp.Core/Jobs/LuaJobKernelScripts.cs +++ b/libs/CheatEngine.Mcp.Core/Jobs/LuaJobKernelScripts.cs @@ -14,7 +14,7 @@ namespace CheatEngine.Mcp.Core.Jobs; /// /// Jobs buffer items in a ring of bufferLimit slots numbered by a sequence: pushing past the limit evicts /// the -/// oldest item and counts it in dropped. A poll is read-only and idempotent. A job lives at most its TTL +/// oldest item and counts it in dropped. The jobPoll primitive is read-only and idempotent. A job lives at most its TTL /// (300 s at most) from creation: the sweeper, a Lua timer that runs once per second while anything can still /// expire, finishes running jobs as expired through their hooks and removes them. A job whose cleanup /// failed is retained for manual recovery. No Client call is needed during a disable. diff --git a/srcs/CheatEngine.Mcp.Plugin/CheatEngine.Mcp.Plugin.csproj b/srcs/CheatEngine.Mcp.Plugin/CheatEngine.Mcp.Plugin.csproj index c299bad..7d684f8 100644 --- a/srcs/CheatEngine.Mcp.Plugin/CheatEngine.Mcp.Plugin.csproj +++ b/srcs/CheatEngine.Mcp.Plugin/CheatEngine.Mcp.Plugin.csproj @@ -50,6 +50,14 @@ + + + + + + diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/auto-assembler.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/auto-assembler.md index 04a5e2a..5cf4da9 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/auto-assembler.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/auto-assembler.md @@ -27,8 +27,8 @@ consent before each apply or record activation, and ask them to save first: a ba Details: -- Only `asm_check` and the two apply tools need `Mcp:EnableAutoAssembler`. The apply tools are `may_prompt`: CE can - show a dialog, and the call then waits for the user. +- Only `asm_check` and the apply tools need `Mcp:EnableAutoAssembler`. Apply and release are `may_prompt`: CE dialogs + can wait for the user, including patch cleanup through `runtime_release_resources`. - `script`: 1 to 1,048,576 characters with both `[ENABLE]` and `[DISABLE]` (any case), else `invalid_argument`; `asm_check` also refuses more than 1,048,576 UTF-8 bytes (`limit_exceeded`). `name` (up to 256 characters) only labels the patch. diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/configuration.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/configuration.md index 0a3a3f4..7d3922a 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/configuration.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/configuration.md @@ -74,7 +74,7 @@ may still be missing after the user turns that one on. record whose children option reaches one, `record_delete` of an active AA record, `record_clear` of a list holding an active AA record, and `table_load` of a table with AA scripts. - **`Mcp:EnableTargetCodeExecution`** (`target_code_execution`): `exec_call_local`, `exec_call_method`, - `exec_call_remote`, `exec_compile_c`, `exec_inject_dotnet`, `exec_inject_library`, `mono_attach`, + `exec_call_remote`, `exec_compile_c`, `exec_compile_csharp`, `exec_inject_dotnet`, `exec_inject_library`, `mono_attach`, `mono_compile_method`, `mono_invoke_method`, `speedhack_set_speed`. Also: - `debugger_attach(interface="veh")`, or `default` when CE's settings select the VEH debugger; - AA `{$luacode}`, `{$c}`, `{$ccode}`, `loadlibrary`, `createthread`, `createthreadandwait`, `include`, @@ -258,6 +258,11 @@ Raise the timeout only for a known long call; long work belongs in jobs. Setup a failure), 3002 and 3006 (a tool's unexpected exception, or an internal error it reported), 3005 and 3007 (the same for a resource). The 3002, 3005, 3006 and 3007 entries hold the tool or resource name, the exception type, the operation (3006, 3007) and an `errorId`; never the message, stack, paths or tokens. +- **Native dispatch diagnostic:** the `ToolDispatch` logger also uses event 3005 for a `host_refused` failure from + `Dispatcher.Invoke` with an unknown host effect and an inner exception. + That private log entry contains the operation, exception type and managed stack without source-file information; + it omits the exception message and data and does not add the stack to the MCP result. + Distinguish it from the resource error event by logger category and message, and review it before sharing. - **Error ids:** an `internal` tool error carries `details.errorId` (16 hex characters) and a failed resource read `error.data.errorId`. Quote it; the user finds the matching entry by searching the log of the instance that failed. Through the gateway the id comes from that backend. See [errors and recovery](errors-and-recovery.md). diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/connection-troubleshooting.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/connection-troubleshooting.md index a565848..cff03a7 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/connection-troubleshooting.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/connection-troubleshooting.md @@ -272,21 +272,45 @@ that a backend is healthy. Restart the client session when new tools do not appe and `hostVersion`. Its `capabilities` are the CE host's; its `gates` are the four `Mcp:Enable...` switches, as booleans. No tool reports any other setting, path or limit. +## Support report checklist + +Include: + +- Windows build/architecture, CE/.NET/package/client versions, source commit and target architecture; label CE and target PIDs. +- Tool/resource name, redacted arguments, expected/observed behavior and time. +- `error.operation`, `error.kind`, `hostEffect`, `retryable`, effective `gates`, and correlation ID when present: tool `error.details.errorId` or resource `error.data.errorId`. +- Redacted logs, activation/target changes, cleanup failures and unknown outcomes. Do not repeat mutations for a report. + +Remove private paths, source, target data and credentials. +Never share tokens, discovery contents, registry exports or unreviewed archives. +Redact configuration values. +See [configuration](configuration.md) for private stack diagnostics. + ## Several instances, updates and removal - **One activation per CE process**, with its own `instanceId`, resources and ids. Clean up each instance through its own id. - **Names.** Set `MCP_INSTANCE_NAME` in each CE launch environment to tell instances apart. - **Shared targets.** Two instances attached to the same target both change its memory. Keep one instance per target. -- **Updates.** Disable the plugin, and close CE and the client's gateway. Replace the whole `CheatEngine.Mcp` folder and - the executable from the same release, never individual DLLs. Restart, then call `instance_list()` again. Personal - settings outside the plugin folder are kept. +- **Updates.** Finish cleanup, disable the plugin, close CE and stop this gateway in the client. + Back up the old matching package, client registration and configuration being changed. + Extract the complete new package to a separate directory; select its `CheatEngine.Mcp.dll` in CE and its gateway in the client. + Replace this plugin's previous CE entry when its path changes; keep unrelated plugins and user data. + Restart, discover a fresh ID with `instance_list()` and verify `runtime_get_info()` before selecting a target. +- **Locked files or partial upgrade.** Stop the MCP connection in the client and close the CE instance that loaded the files. + Disable this registration if the client restarts the gateway. + Leave locked files in place; use a separate directory without mixing versions or terminating unrelated processes. + Keep a partial upgrade disabled until both paths select one complete matching package. +- **Rollback.** Stop this CE instance and gateway; replace this plugin's CE entry with its old entry, restore the old gateway registration and changed configuration from backup, including CE's runtime configuration. + Re-enable, reconnect, rediscover and verify the loaded version before resuming. - **Disable or removal.** Run [cleanup_session](../Workflows/cleanup-session.md) first, with the user's consent. A disable stops the activation's jobs and releases its Client leases (allocations, patches, named scanners, registered symbols). Effects recorded in CE's Lua state stay: a speed other than 1, MCP's pause, MCP-set breakpoints and any failed cleanup. The next activation lists them as `orphaned`. CE-owned state also survives a disable: address-list records and their freezes, structures, main scan results and a Mono collector attachment, which the next activation does not list. + To uninstall, remove this plugin's CE entry and gateway registration, close their processes and delete only this integration's deployment files. + Keep user settings, tables and unrelated plugins unless the user chooses their removal. ## Responsible use diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/debugger.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/debugger.md index 0d18484..76e8a44 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/debugger.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/debugger.md @@ -24,6 +24,8 @@ and get consent before the attach, before breakpoints that stop threads or write Live resources mirror two tools: `cheatengine://instance/debugger` (`debugger_get_status`) and `cheatengine://instance/debugger/breakpoints` (`debugger_list_breakpoints`); through the gateway they sit under `cheatengine://instances/{instanceId}/debugger`. Jobs and breakpoints belong to one CE instance. +First call `debugger_list_breakpoints` explicitly; its breakpoint resource reuses that preparation for five seconds. +Refresh after breakpoint changes. Missing or expired preparation returns `invalid_state`. ## Interfaces and gates diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/errors-and-recovery.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/errors-and-recovery.md index 476fc81..8430b09 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/errors-and-recovery.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/errors-and-recovery.md @@ -17,8 +17,8 @@ rules are in [workflows](workflows.md) and consent in [safety](safety.md); the the user; never repeat a mutation blindly. 3. `retryable: true` means the identical call may succeed later as it is. Wait until the condition in the message has changed, then repeat it once. `false` does not mean impossible: fix something or inspect the state first. -4. Reads are safe to repeat: read-only tools, resource reads and job polls (a poll's `afterSequence` is a cursor, not - an acknowledgement, so the same value returns the same page). +4. Read-only tools and resources are safe to repeat. Job pages use a cursor, not an acknowledgement, but + `kernel_poll_watch` also drains DBVM's log and can evict retained events; it is not a passive read. 5. Retry at most once, and never in a loop. Before continuing a change, tell the user what failed and what, if anything, was applied. @@ -41,7 +41,7 @@ rules are in [workflows](workflows.md) and consent in [safety](safety.md); the | `hostEffect` | How far Cheat Engine got, so whether anything changed (see Host effects below). | | `retryable` | `true` only when repeating the identical call later is safe and may succeed. | | `hint` | The suggested next step, when there is one. | -| `details` | Structured context, when the tool has some (see Details by tool below); an `internal` error adds `errorId`. | +| `details` | Structured context, when the tool has some (see Details by tool below); an `internal` error adds `errorId`, retaining non-object context under `details.value`. | Not every negative answer is a failure. Check these fields in successful results: diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/getting-started.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/getting-started.md index 1efe4d7..d465bae 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/getting-started.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/getting-started.md @@ -170,6 +170,16 @@ calling the tools it covers. that support completion offer `{instanceId}` values for recently verified instances, then `{module}`, `{structure}`, `{scannerName}` and `{scanName}`. A direct backend serves the same views as `cheatengine://instance/...`. The full list is in [workflows](workflows.md). +- Module and region browsing uses a five-second prepared snapshot. Call `module_list` without `processId` before + browsing modules or completing their names, and `memory_list_regions` before browsing regions. For a module's + detail resource, call `module_get` with that selector first; only its most recent successful result is retained. + A later module/map preparation replaces earlier detail preparation. Expired, missing or changed-target data + yields `invalid_state` with a preparation hint; completion offers no names. Browsing never starts module, + section or region enumeration. Refresh explicitly when the target's modules or memory map change. +- Prepare thread, symbol and breakpoint resources with `process_list_threads`, `symbol_list_registered` and + `debugger_list_breakpoints`, respectively, then browse within five seconds. Expired or missing preparation + yields `invalid_state`; refresh explicitly. These resource reads reuse bounded copies and do not enumerate + the native lists. ## Responsible use and consent diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/kernel.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/kernel.md index a0ac00a..b17d0e8 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/kernel.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/kernel.md @@ -38,7 +38,7 @@ On a default Windows 11 PC the kernel tools are expected not to work, and that i - Since Windows 10 1607, with Secure Boot on, Windows loads new kernel drivers only when Microsoft signed them through its Hardware Dev Center. -`kernel_get_status` then reports `dbkInitialized` and `dbvmInitialized` false, and the other kernel tools fail with +When its probes answer, `kernel_get_status` then reports `dbkInitialized` and `dbvmInitialized` false; other tools fail with `invalid_state`, `not_found` (translation), `host_refused` or `unsupported`. That is the answer, not a problem to solve. CE's own error dialogs may offer to explain how to disable the blocklist or suggest boot and signing changes: do not relay or expand on them. @@ -96,7 +96,8 @@ What the switch does not cover: | `kernel_read_physical`, `kernel_write_physical` | a running DBVM | `short` | | `kernel_start_watch`, `kernel_poll_watch` | a running DBVM | `blocking_native` | -`kernel_get_status` returns `dbkInitialized` and `dbvmInitialized`, plus `cr0`, `cr3` (of the selected process), +`kernel_get_status` returns `dbkInitialized` and `dbvmInitialized` when CE can answer those probes; omission means +unavailable, not false. It also returns `cr0`, `cr3` (of the selected process), `cr4` and `dbvmCr4` (the real CR4 under DBVM) as uppercase hex. A missing register means CE could not read it. CE returns `0` for `cr0` without DBK and for `cr4` without DBK or DBVM, so `0` also means unavailable. `cr3` needs the target opened through DBK, or a running DBVM. diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/memory-model.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/memory-model.md index 6b0394c..34f72d5 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/memory-model.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/memory-model.md @@ -209,8 +209,11 @@ Cautions: | What surrounds an address | `memory_list_regions(state="any", startAddress="", endAddress="")` | `state`, `type` | - memory_list_regions pages with `offset` and `limit` (default 500, at most 2000) until `nextOffset` is absent. It - re-reads the map on each call, so pages are not a snapshot. The live resource `cheatengine://instance/regions` - returns the same JSON for committed regions, 100 per page by default. + re-reads the map on each explicit tool call. Each successful call prepares the complete map for five seconds; + the live resource `cheatengine://instance/regions` reads that snapshot, with the same JSON for committed regions, + 100 per page by default. A missing/expired snapshot or target change produces `invalid_state` with a refresh + hint. Resource navigation never enumerates the map. Prepare regions after module-list/detail refreshes, which + invalidate an older prepared region map. - Cheat Engine skips `MEM_MAPPED` regions in every scan unless MEM_MAPPED is ticked in its scan settings, which a fresh install leaves off ([emulators](emulators.md)). The main scanner follows that box and the scan panel's writable, executable and copy-on-write filters; `scan_get_status(scannerName="main")` reports them in `settings`. diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/mono-and-dotnet.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/mono-and-dotnet.md index 3bbc8dd..e9282a7 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/mono-and-dotnet.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/mono-and-dotnet.md @@ -246,6 +246,9 @@ and they are all read-only. They do not check for a paused game or a stopped deb `nativeCode` is omitted until the method is compiled: have the user trigger that feature, then list again. 6. `dotnet_get_method_parameters(moduleHandle="", methodToken="")` returns the `parameters` (`index`, `name`, `elementType`, `elementTypeName`) and the `signature` text when reported. + Here `elementType` is the collector's metadata-constant/default-value code, **not the declared parameter type**. + `1` / `Void` means no constant; `0` can be unavailable metadata. The list may be incomplete or include return + metadata; `index` is the returned position, not necessarily the declared ordinal. Use `signature` for declared types. 7. `dotnet_get_object(address="")` returns the object's start `address`, its `typeName` and the fields (512 by default) with their current `value`: decimal numbers, true or false, hexadecimal references and pointers, nothing for value-type fields. CE documents that the query assumes a valid object, so pass an object start, such diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/pointers.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/pointers.md index 56a98a6..636d214 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/pointers.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/pointers.md @@ -1,9 +1,7 @@ # Pointers and pointer scans -A heap or stack address moves when the game restarts or reloads a level. A pointer chain finds it again from a static -root inside a module. Read this page once you have the value's current address (see [value-scans](value-scans.md)) and -before you save it in a table. It covers notation, reading a chain, the manual method from a writer, the pointer -scanner, ranking and storing a chain, and what to do when the scanner fails. +A pointer chain finds a moving address from a static module root. First find the value with +[value-scans](value-scans.md). Reading memory, capturing maps and searching paths do not change the target. Attaching the debugger, pausing the process, writing through a record and injecting code do: explain each one and get the user's consent first. Use them @@ -115,6 +113,10 @@ objects match too. The live search blocks Cheat Engine while it scans; with `poi searches a stored map instead, nearest first. On a 32-bit target a live search with a non-zero `maxOffset` is refused for targets above 7FFFFFFF; search a map there. +`pointer_find_references` permits one temporary live range scan per activation. Failed release keeps later range searches `busy`. +Use `runtime_list_resources` and `runtime_release_resources`; follow manual-recovery instructions before +acknowledging. Exact (`maxOffset=0`) and stored-map searches need no temporary scan. + Pitfalls: - Data breakpoints are traps. A write or access hit is reported after the instruction ran: `ip` is the next @@ -188,6 +190,7 @@ and scans across `process_attach`. `pointer_save_scan(scanName="hp", path="C:\CheatEngine\Files\hp.json")`. The destination must be inside that root. A save does not replace a file unless `overwrite=true`, and it commits the new file atomically. 3. After the new activation starts, call `pointer_load_map` and `pointer_load_scan` with unused map and scan names. + Imports omit `jobId`: loading saved data creates no capture or search job. Loading a map lets the server search or rescan against that snapshot without reading the target. Loading a scan restores paths as `unresolved`; it never claims that a stored result is still valid. 4. Attach the restarted game, find the value again, then call diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/safety.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/safety.md index 0dceace..fdb2f86 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/safety.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/safety.md @@ -42,8 +42,9 @@ then wait for a yes. Ask again when the target or the risk level changes, for ex - Look before you change: resolve, read and scan first, and write only addresses you identified. - `may_prompt` tools (`_meta` `cheatengine/dispatchClass`) can open a CE dialog that waits for a person: `asm_apply`, - `asm_apply_code_patch`, `debugger_attach`, `kernel_initialize_dbvm`, `lua_execute`, `mono_attach`, - `speedhack_set_speed`, `table_load`, `table_save`. Warn the user that they may have to answer it in CE. + `asm_apply_code_patch`, `asm_release_patch`, `debugger_attach`, `kernel_initialize_dbvm`, `lua_execute`, `mono_attach`, + `record_set_active`, `record_delete`, `record_clear`, `runtime_release_resources`, `speedhack_set_speed`, + `table_load`, `table_save`. Warn the user that they may have to answer it in CE, including during script cleanup. - `blocking_native` tools can hold CE's main thread for seconds. `exec_inject_dotnet` waits without a time limit until the injected method returns; only the MCP call timeout bounds the request, and CE stays blocked after it. - `exec_call_remote` and `exec_call_method` run the function on a new thread in the target, not on the game's own diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/speedhack.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/speedhack.md index 1ca791f..9058055 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/speedhack.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/speedhack.md @@ -105,6 +105,8 @@ The patches are private to the target process. Other processes and the system cl - Once the symbol exists, CE never retries hooking in that process. Only a restart of the target makes CE try again. - `hooksInstalled` false after an activation means CE stopped before the allocation, for example because the helper library failed. The next `speedhack_set_speed` with a speed other than 1 tries again. +- An unavailable symbol probe refuses before a change. If the probe fails after a change, `hooksInstalled` is omitted + and the restore resource is retained; omission is not evidence that the hooks are absent. - `firstActivation` true means the symbol was absent before the call, so CE made its hook attempt. Speed 1 never activates the speedhack and always reports false. - If `hooksInstalled` is already true before any MCP change, CE's speedhack already ran in this process: CE's checkbox diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/structures.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/structures.md index 415e5c9..60d7f57 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/structures.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/structures.md @@ -86,6 +86,12 @@ Then `memory_get_address_info(addresses=[""], includeRtti=true, includePoi ## Read, name and retype fields +`structure_get` checks at most 65536 global indexed definitions for the exact name, then reads only the requested element +page (up to 1024). If the name is not found within that bound and more definitions exist, it returns +`limit_exceeded` instead of claiming the name is absent. Total element counts and pagination remain exact. +The result's `internal` flag is best-effort: hosts without a readable `Internal` property report false. It does not +prove that an indexed definition will be saved with the table. + 1. `structure_read(name="Player", addresses=["", "", ""], fromOffset="0", toOffset="1FF")` returns one column per address (up to 16) and one row per element, 100 rows per page by default (`limit` up to 256; continue with `structure_read.offset` from `nextOffset`). Integers come back in decimal even with a `hex` diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/tool-map.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/tool-map.md index a62573c..fc041b7 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/tool-map.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/tool-map.md @@ -43,7 +43,7 @@ authority for parameters, bounds, defaults and results. Task guides: [workflows] | `runtime_get_info` | | Host and plugin versions, platform, Client epoch, capability evidence and the four gates. | | `runtime_get_overview` | | Orientation: host information, gates, selected process, retained resource and job counts. | | `runtime_list_resources` | | List resources this or an earlier activation holds, with kind, state and orphan flag. | -| `runtime_release_resources` | | Release every resource newest first, optionally orphans; acknowledge manually recovered ones. | +| `runtime_release_resources` | | Release every resource newest first, optionally orphans; acknowledge manually recovered ones. `may_prompt` | | `runtime_list_jobs` | | List running and retained jobs with state, progress, buffer counts and expiry. | | `runtime_stop_job` | | Stop any job and discard its buffered items. | @@ -61,7 +61,7 @@ IgnoreUsesMono off), opening a process makes CE inject its Mono data collector; | `process_open_file` | →TCE | Open a validated, pinned host file as the target (file-as-process). | | `process_save_file` | | Save the opened file target to a new path under `Mcp:Files:AllowedRoots`, atomically. | | `process_set_paused` | | Pause or resume the target; MCP's own pause is a tracked resource. | -| `process_list_threads` | | List up to 4096 target thread ids. | +| `process_list_threads` | | Prepare up to 4096 thread ids for five seconds. `blocking_native` | | `process_set_pointer_size` | | Set the target pointer size to 4 or 8 bytes and verify it. | ## memory @@ -78,7 +78,7 @@ samples) handles big-endian numbers. | `memory_write` | | Write a typed value, string or bytes; returns up to 64 previous bytes and reads back by default. | | `memory_write_batch` | | Write up to 1024 typed items in order; not atomic, a failure reports what was written. | | `memory_get_address_info` | | Describe up to 256 addresses: symbol, module, section, region, pointer value, RTTI class. | -| `memory_list_regions` | | Page regions filtered by range, module, state, backing and protection. | +| `memory_list_regions` | | Filter/page regions; prepare region navigation for five seconds. `blocking_native` | | `memory_set_protection` | | Change the protection of up to 16 MiB in one region; returns the previous one. | | `memory_allocate` | | Allocate named read/write or executable memory owned by this activation. | | `memory_free` | | Free a named allocation. | @@ -148,10 +148,10 @@ Guide: [repair after update](../Workflows/repair-after-update.md). | Tool | Gate | Purpose | |---|---|---| -| `module_list` | | Page the loaded modules, filtered by name. | -| `module_get` | | One module: base, size, path, sections and PE fields such as the build time stamp. | -| `module_list_exports` | | Page a module's exports. | -| `module_list_imports` | | Page a module's imports: DLL, IAT slot, name, and where the slot points now. | +| `module_list` | | Filter/page modules; without processId, prepare navigation for five seconds. `blocking_native` | +| `module_get` | | Prepare one module's details and PE fields for five seconds. `blocking_native` | +| `module_list_exports` | | Page a module's exports. `host_scan` | +| `module_list_imports` | | Page a module's imports: DLL, IAT slot, name, and where the slot points now. `host_scan` | | `module_find_patches` | | Compare a module's code in memory with its file and list differing ranges. | ## symbol @@ -164,7 +164,7 @@ Guide: [address expressions](address-expressions.md). | `symbol_find` | | Find symbols whose names contain a text, optionally in one module. `host_scan` | | `symbol_register` | | Register a named symbol owned by this activation. | | `symbol_unregister` | | Unregister a symbol this activation registered. | -| `symbol_list_registered` | | Page registered symbols, marking the ones MCP owns. | +| `symbol_list_registered` | | Prepare/page registered symbols for five seconds. `blocking_native` | | `symbol_get_module_preference` | | Read which module wins when several define a name. | | `symbol_set_module_preference` | | Change the module precedence of later lookups. | | `symbol_reload` | | Start a symbol reload and return at once. | @@ -221,7 +221,7 @@ globalalloc or a `$` before non-hex text (`unsupported`): CE runs parts of a che | `asm_check` | AA | Check a script's ENABLE and DISABLE sections without applying it. | | `asm_apply` | AA →UL/TCE/KA | Apply a script and keep its patch for release. `may_prompt` | | `asm_apply_code_patch` | AA | Replace 1 to 64 verified bytes as a reversible patch. `may_prompt` | -| `asm_release_patch` | | Release a patch by running its DISABLE section. | +| `asm_release_patch` | | Release a patch by running its DISABLE section. `may_prompt` | | `asm_list_patches` | | List the patches this activation holds. | | `asm_generate_injection` | | Generate an AOB injection scaffold from a verified signature. | | `asm_generate_api_hook` | | Ask CE for an API hook script with ENABLE and DISABLE sections, without applying it. | @@ -229,7 +229,7 @@ globalalloc or a `$` before non-hex text (`unsupported`): CE runs parts of a che ## record Guide: [cheat tables](cheat-tables.md). **→AA**: creating an Auto Assembler record or passing a script, activating or -deactivating one (or a group passing it on), deleting or clearing an active one, and every `record_set_script`. +deactivating one (or a group passing it on), deleting or clearing an active one. `record_set_script` always needs AA. Offsets are written as for pointers, and reads return them. | Tool | Gate | Purpose | @@ -241,13 +241,13 @@ Offsets are written as for pointers, and reads return them. | `record_select` | | Select a record, visibly to the user. | | `record_create` | →AA | Create 1 to 256 value, group or script records; rolled back on failure. | | `record_update` | | Update 1 to 256 records: description, address, type, length, Unicode, offsets, value. | -| `record_set_active` | →AA | Activate (freeze) or deactivate 1 to 256 records; a refused one reports CE's reason. | -| `record_delete` | →AA | Delete 1 to 256 records. | +| `record_set_active` | →AA | Activate (freeze) or deactivate 1 to 256 records; a refused one reports CE's reason. `may_prompt` | +| `record_delete` | →AA | Delete 1 to 256 records. `may_prompt` | | `record_move` | | Move one record under a parent or to the root. | | `record_group` | | Group 1 to 256 records under a new type-14 record, not CE's group header. | -| `record_set_script` | →AA | Replace an inactive script record's text without activating it. | +| `record_set_script` | AA | Replace an inactive script record's text without activating it. | | `record_set_dropdown` | | Replace or clear one record's dropdown list and its three dropdown options. | -| `record_clear` | →AA | Delete every record and return the count. | +| `record_clear` | →AA | Delete every record and return the count. `may_prompt` | ## table @@ -268,7 +268,7 @@ Guide: [structures](structures.md). | Tool | Gate | Purpose | |---|---|---| | `structure_list` | | Page Structure Dissect definitions. | -| `structure_get` | | Read a structure and a page of its elements. | +| `structure_get` | | Find a structure within 65536 global definitions and read only its requested element page. | | `structure_create` | | Create a structure from elements, a copy or a PDB type. | | `structure_set_name` | | Rename a structure in place; pointers to it stay. | | `structure_delete` | | Delete a structure. | @@ -297,7 +297,7 @@ or a ceserver connection is refused (`unsupported`). | `debugger_break_thread` | | Ask a thread to break. | | `debugger_set_breakpoint` | | Set a tracked execute, access or write breakpoint. | | `debugger_delete_breakpoint` | | Delete a breakpoint this activation set. | -| `debugger_list_breakpoints` | | List up to 1024 breakpoints, marking MCP's. | +| `debugger_list_breakpoints` | | Prepare up to 1024 breakpoints for five seconds. `blocking_native` | | `debugger_continue` | | Continue the stopped context. | | `debugger_step` | | Step into or over one instruction. | | `debugger_get_context` | | Read the stopped thread's registers, optionally FPU and XMM bytes. | @@ -345,7 +345,7 @@ Guide: [Mono and .NET](mono-and-dotnet.md). They use CE's out-of-process .NET da | `dotnet_list_types` | | Page a module's types, filtered by name. `host_scan` | | `dotnet_get_type` | | Read a type's layout, base type and fields. | | `dotnet_list_methods` | | Page a type's methods, filtered by name. | -| `dotnet_get_method_parameters` | | Read a method's parameter names, type codes and signature. | +| `dotnet_get_method_parameters` | | Read collector parameter entries, constant codes and optional declared-type signature. | | `dotnet_get_object` | | Inspect the object at an address with its field values. | | `dotnet_start_instance_search` | | Start a job that finds instances of a type. `host_scan` | | `dotnet_poll_instance_search` | | Poll an instance search. | diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/workflows.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/workflows.md index df9f1ee..1fd3e25 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/workflows.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Documents/workflows.md @@ -103,7 +103,7 @@ they run. - Stop any job with `runtime_stop_job`; a stopped pointer map or path search keeps what it found, while `pointer_delete_map` or `pointer_delete_scan` stops and deletes it. - Poll with `debugger_poll_capture(jobId="...", afterSequence=0)`, then pass `debugger_poll_capture.nextAfterSequence` - back. Polls are non-consuming; `debugger_poll_capture.more` true means poll again, and + back. These debugger polls are non-consuming; `debugger_poll_capture.more` true means poll again, and `debugger_poll_capture.dropped` counts evicted items (poll sooner or narrow the job). - Lifetime: `Mcp:Execution:JobDefaultTtlSeconds` (120 s) unless the start call sets `lifetimeSeconds`, never above 300 s. A finished job stays pollable until then; a stop discards its items, so poll first. An expired or unknown id @@ -178,7 +178,7 @@ path gives the default page; query values keep the template order (`?offset=..&l | `resources`, `jobs`, `patches`, `speedhack` | `runtime_list_resources`, `runtime_list_jobs`, `asm_list_patches`, `speedhack_get_state` | | | `scanners`, `scanners/{scannerName}` | `scan_list_scanners`, `scan_get_status` | | | `debugger`, `debugger/breakpoints{?limit}` | `debugger_get_status`, `debugger_list_breakpoints` | 256 (1-1024) | -| `modules{?offset,limit}`, `modules/{module}`, `modules/{module}/exports{?offset,limit}` | `module_list`, `module_get`, `module_list_exports` | 200 (1-1000) | +| `modules{?offset,limit}`, `modules/{module}` | `module_list`, `module_get` | 200 (1-1000) | | `regions{?offset,limit}` | `memory_list_regions` (committed) | 100 (1-2000), not the tool's 500 | | `memory/{address}{?size}` | `memory_read` (bytes) | 256 bytes (1-16384) | | `disassembly/{address}{?count}` | `code_disassemble` | 20 (1-1024) | diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/cleanup-session.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/cleanup-session.md index 47533bd..6aabf4f 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/cleanup-session.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/cleanup-session.md @@ -39,6 +39,8 @@ deletes them after asking. ## Decisions +- Script deactivation, deletion and patch release (including `runtime_release_resources`) can show a CE dialog or + block CE while the DISABLE section runs. Warn the user and inspect any dialog before retrying a timed-out cleanup. - `partial_effect`, an incomplete release or `asm_release_patch.released` false: stop there, report the `hint` and what remains; repeat once only when the result says retryable. - `not_found` on a stop, delete or release: it already ended; confirm with the list tools. diff --git a/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/dotnet-recon.md b/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/dotnet-recon.md index 932cfdc..f6495c7 100644 --- a/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/dotnet-recon.md +++ b/srcs/CheatEngine.Mcp.Resources/Knowledge/Workflows/dotnet-recon.md @@ -32,12 +32,13 @@ through Cheat Engine's out-of-process .NET collector, which injects nothing. `structure_read(name="{typeName}", addresses=[""])`. 10. Code: `dotnet_list_methods(moduleHandle="", typeToken="", nameContains="")` gives each method's `token` and `nativeCode`; `dotnet_get_method_parameters(moduleHandle="", - methodToken="")` its parameters. `symbol_reload(scope="dotnet")` adds managed names, then + methodToken="")` its collector entries and optional declared-type `signature`. Its `elementType` + describes a metadata constant, not a declared parameter type. `symbol_reload(scope="dotnet")` adds managed names, then `code_disassemble(address="")` or [find what writes](find-writer.md). ## Decisions -- `elementType` to `valueType`: 2 Boolean `uint8`, 8 Int32 `int32`, 10 Int64 `int64`, 12 Single `float`, 13 Double +- Field `elementType` to `valueType`: 2 Boolean `uint8`, 8 Int32 `int32`, 10 Int64 `int64`, 12 Single `float`, 13 Double `double`; 14 String and 18 Class hold a `pointer`. - No `nativeCode`: not JIT-compiled yet; ask the user to use that feature, then list again. - JIT code has no module, so `aob_generate_signature` refuses it: re-find the method each session. diff --git a/srcs/CheatEngine.Mcp.Resources/Live/DebuggerLiveResources.cs b/srcs/CheatEngine.Mcp.Resources/Live/DebuggerLiveResources.cs index 932cce8..424385c 100644 --- a/srcs/CheatEngine.Mcp.Resources/Live/DebuggerLiveResources.cs +++ b/srcs/CheatEngine.Mcp.Resources/Live/DebuggerLiveResources.cs @@ -33,24 +33,25 @@ public ReadResourceResult Debugger(CancellationToken cancellationToken = default DebuggerJsonContext.Default.DebuggerStatus)); } - /// Reads a bounded copy of the breakpoints. - /// The most breakpoints to copy; 256 when absent. + /// Reads a page of the explicitly prepared breakpoint snapshot. + /// The most prepared breakpoints to return; 256 when absent. /// The resource read cancellation. /// The structured result of debugger_list_breakpoints. [McpServerResource(UriTemplate = DebuggerPath + "/breakpoints{?limit}", Name = "instance_breakpoints", Title = "Debugger breakpoints", MimeType = McpResourceUris.JsonMimeType)] - [McpSourceTool(typeof(DebuggerTools), CheatEngineToolNames.DebuggerListBreakpoints)] + [McpSourceTool(typeof(DebuggerTools), CheatEngineToolNames.DebuggerListBreakpoints, PreparedProjection = nameof(DebuggerTools.ListPreparedBreakpoints))] [McpResourceAnnotations(Role.Assistant, Priority = LiveResourceResults.Priority)] - [Description("The breakpoint addresses Cheat Engine reports, marking those this activation owns: 256 by default " + - "or ?limit=.. from 1 to 1024. Its JSON is the structured result of " + + [Description("The latest breakpoint snapshot explicitly prepared by debugger_list_breakpoints, including ownership " + + "observed at preparation: 256 by default or ?limit=.. from 1 to 1024. The snapshot expires after five " + + "seconds; run debugger_list_breakpoints before reading this resource. Its JSON is the structured result of " + CheatEngineToolNames.DebuggerListBreakpoints + ".")] public ReadResourceResult Breakpoints( - [Description("The most breakpoint addresses to copy, 1 to 1024, 256 by default.")] + [Description("The most prepared breakpoint addresses to return, 1 to 1024, 256 by default.")] string? limit = null, CancellationToken cancellationToken = default) { int? count = McpResourceQuery.Number(limit, nameof(limit), 1, MaximumBreakpoints); - DebuggerBreakpointPage page = debugger.ListBreakpoints(count ?? DefaultBreakpoints, cancellationToken); + DebuggerBreakpointPage page = debugger.ListPreparedBreakpoints(count ?? DefaultBreakpoints, cancellationToken); return LiveResourceResults.Json(McpResourceQuery.WithQuery(DebuggerPath + "/breakpoints", ("limit", count)), JsonSerializer.Serialize(page, DebuggerJsonContext.Default.DebuggerBreakpointPage)); } diff --git a/srcs/CheatEngine.Mcp.Resources/Live/MemoryLiveResources.cs b/srcs/CheatEngine.Mcp.Resources/Live/MemoryLiveResources.cs index 8145880..b2daee7 100644 --- a/srcs/CheatEngine.Mcp.Resources/Live/MemoryLiveResources.cs +++ b/srcs/CheatEngine.Mcp.Resources/Live/MemoryLiveResources.cs @@ -34,12 +34,13 @@ public sealed class MemoryLiveResources(MemoryInfoTools memory, MemoryReadTools /// The structured result of memory_list_regions. [McpServerResource(UriTemplate = RegionsPath + "{?offset,limit}", Name = "instance_regions", Title = "Memory regions", MimeType = McpResourceUris.JsonMimeType)] - [McpSourceTool(typeof(MemoryInfoTools), CheatEngineToolNames.MemoryListRegions)] + [McpSourceTool(typeof(MemoryInfoTools), CheatEngineToolNames.MemoryListRegions, PreparedProjection = nameof(MemoryInfoTools.ListPreparedRegions))] [McpResourceAnnotations(Role.Assistant, Priority = LiveResourceResults.Priority)] [Description("A page of the attached target's committed memory regions in address order: 100 from offset 0 by " + - "default (fewer than the tool's 500), or ?offset=..&limit=.. in that order (limit 1 to 2000). Its " + - "JSON is the structured result of " + CheatEngineToolNames.MemoryListRegions + - "; use that tool to filter by range, module, state, backing or protection.")] + "default (fewer than the tool's 500), or ?offset=..&limit=.. in that order (limit 1 to 2000). First " + + "run " + CheatEngineToolNames.MemoryListRegions + " explicitly; this resource reads its prepared " + + "snapshot and must be read within 5 seconds. Use that tool to filter by range, module, state, backing " + + "or protection.")] public ReadResourceResult Regions( [Description("The index of the first region, 0 by default.")] string? offset = null, @@ -49,7 +50,7 @@ public ReadResourceResult Regions( { int? first = McpResourceQuery.Number(offset, nameof(offset), 0, LiveResourceResults.MaximumOffset); int? count = McpResourceQuery.Number(limit, nameof(limit), 1, MaximumRegions); - RegionList regions = memory.ListRegions(offset: first ?? 0, limit: count ?? DefaultRegions, + RegionList regions = memory.ListPreparedRegions(offset: first ?? 0, limit: count ?? DefaultRegions, cancellationToken: cancellationToken); return LiveResourceResults.Json(McpResourceQuery.WithQuery(RegionsPath, ("offset", first), ("limit", count)), JsonSerializer.Serialize(regions, MemoryJsonContext.Default.RegionList)); diff --git a/srcs/CheatEngine.Mcp.Resources/Live/ModuleLiveResources.cs b/srcs/CheatEngine.Mcp.Resources/Live/ModuleLiveResources.cs index 5bbdc6b..f33d226 100644 --- a/srcs/CheatEngine.Mcp.Resources/Live/ModuleLiveResources.cs +++ b/srcs/CheatEngine.Mcp.Resources/Live/ModuleLiveResources.cs @@ -3,6 +3,7 @@ using CheatEngine.Mcp.Core.Contract; using CheatEngine.Mcp.Core.Execution; +using CheatEngine.Mcp.Core.Inspection; using CheatEngine.Mcp.Tools.Modules; using ModelContextProtocol.Protocol; @@ -11,19 +12,17 @@ namespace CheatEngine.Mcp.Resources.Live; /// -/// The read-only module projections of one Cheat Engine instance: the loaded modules, one module's details and one -/// module's exports. The module variable completes from the attached process's module names. +/// The read-only module projections of one Cheat Engine instance: the loaded modules and one module's details. +/// The module variable completes from the attached process's module names. /// [McpServerResourceType] -public sealed class ModuleLiveResources(ModuleTools modules, ModuleExportTools exports, ToolDispatch dispatch) +public sealed class ModuleLiveResources(ModuleTools modules, ToolDispatch dispatch, PreparedInspectionStore prepared) : IMcpCompletionSource { private const string ModulesPath = McpResourceUris.InstancePrefix + "modules"; private const string ModuleVariable = "module"; private const int DefaultModules = 200; private const int MaximumModules = 1000; - private const int DefaultExports = 200; - private const int MaximumExports = 1000; /// Reads a page of the v2 module list. /// The index of the first module; 0 when absent. @@ -32,11 +31,11 @@ public sealed class ModuleLiveResources(ModuleTools modules, ModuleExportTools e /// The structured result of module_list. [McpServerResource(UriTemplate = ModulesPath + "{?offset,limit}", Name = "instance_modules", Title = "Loaded modules", MimeType = McpResourceUris.JsonMimeType)] - [McpSourceTool(typeof(ModuleTools), CheatEngineToolNames.ModuleList)] + [McpSourceTool(typeof(ModuleTools), CheatEngineToolNames.ModuleList, PreparedProjection = nameof(ModuleTools.ListPrepared))] [McpResourceAnnotations(Role.Assistant, Priority = LiveResourceResults.Priority)] - [Description("A page of the modules loaded in the attached target: 200 from offset 0 by default, or " + - "?offset=..&limit=.. in that order (limit 1 to 1000). Its JSON is the structured result of " + - CheatEngineToolNames.ModuleList + "; use that tool to filter by name or read another process.")] + [Description("A page of the latest modules explicitly prepared for the attached target: 200 from offset 0 by default, or " + + "?offset=..&limit=.. in that order (limit 1 to 1000). The preparation expires after five seconds. Its JSON is the structured result of " + + CheatEngineToolNames.ModuleList + "; run that tool without processId before reading this resource.")] public ReadResourceResult Modules( [Description("The index of the first module, 0 by default.")] string? offset = null, @@ -46,7 +45,7 @@ public ReadResourceResult Modules( { int? first = McpResourceQuery.Number(offset, nameof(offset), 0, LiveResourceResults.MaximumOffset); int? count = McpResourceQuery.Number(limit, nameof(limit), 1, MaximumModules); - ModuleList page = modules.List(offset: first ?? 0, limit: count ?? DefaultModules, + ModuleList page = modules.ListPrepared(offset: first ?? 0, limit: count ?? DefaultModules, cancellationToken: cancellationToken); return LiveResourceResults.Json(McpResourceQuery.WithQuery(ModulesPath, ("offset", first), ("limit", count)), JsonSerializer.Serialize(page, ModuleJsonContext.Default.ModuleList)); @@ -58,53 +57,20 @@ public ReadResourceResult Modules( /// The structured result of module_get. [McpServerResource(UriTemplate = ModulesPath + "/{module}", Name = "instance_module", Title = "Module details", MimeType = McpResourceUris.JsonMimeType)] - [McpSourceTool(typeof(ModuleTools), CheatEngineToolNames.ModuleGet)] + [McpSourceTool(typeof(ModuleTools), CheatEngineToolNames.ModuleGet, PreparedProjection = nameof(ModuleTools.GetPrepared))] [McpResourceAnnotations(Role.Assistant, Priority = LiveResourceResults.Priority)] - [Description("One module of the attached target, by name such as game.exe or by an address expression inside it " + - "(percent-encoded): base, size, path, sections and PE header fields, whose time stamp identifies " + - "the build. Its JSON is the structured result of " + CheatEngineToolNames.ModuleGet + ".")] + [Description("The latest module details explicitly prepared for the attached target, by the exact module_get selector or " + + "its canonical name: base, size, path, sections and PE header fields. The latest result expires after five seconds; " + + "run module_get before reading this resource. Its JSON is the structured result of " + CheatEngineToolNames.ModuleGet + ".")] public ReadResourceResult Module( [Description("The module name, such as game.exe, or an address expression inside it.")] - [McpCompletion(McpCompletionCost.Dispatch)] + [McpCompletion(McpCompletionCost.DispatchFresh)] string module, CancellationToken cancellationToken = default) { string wanted = McpResourceQuery.Required(module, nameof(module)); return LiveResourceResults.Json(ModulesPath + "/" + McpResourceQuery.Segment(wanted), - JsonSerializer.Serialize(modules.Get(wanted, cancellationToken), ModuleJsonContext.Default.ModuleDetails)); - } - - /// Reads a page of one module's exports. - /// The module name, or an address expression inside it. - /// The index of the first export; 0 when absent. - /// The most exports to return; 200 when absent. - /// The resource read cancellation. - /// The structured result of module_list_exports. - [McpServerResource(UriTemplate = ModulesPath + "/{module}/exports{?offset,limit}", Name = "instance_module_exports", - Title = "Module exports", MimeType = McpResourceUris.JsonMimeType)] - [McpSourceTool(typeof(ModuleExportTools), CheatEngineToolNames.ModuleListExports)] - [McpResourceAnnotations(Role.Assistant, Priority = LiveResourceResults.Priority)] - [Description("A page of one module's exports parsed from its export directory: 200 from offset 0 by default, or " + - "?offset=..&limit=.. in that order (limit 1 to 1000). Its JSON is the structured result of " + - CheatEngineToolNames.ModuleListExports + "; use that tool to filter by name.")] - public ReadResourceResult Exports( - [Description("The module name, such as kernel32.dll, or an address expression inside it.")] - [McpCompletion(McpCompletionCost.Dispatch)] - string module, - [Description("The index of the first export, 0 by default.")] - string? offset = null, - [Description("The most exports to return, 1 to 1000, 200 by default.")] - string? limit = null, - CancellationToken cancellationToken = default) - { - string wanted = McpResourceQuery.Required(module, nameof(module)); - int? first = McpResourceQuery.Number(offset, nameof(offset), 0, LiveResourceResults.MaximumOffset); - int? count = McpResourceQuery.Number(limit, nameof(limit), 1, MaximumExports); - ExportList page = exports.ListExports(wanted, null, first ?? 0, count ?? DefaultExports, cancellationToken); - return LiveResourceResults.Json( - McpResourceQuery.WithQuery(ModulesPath + "/" + McpResourceQuery.Segment(wanted) + "/exports", - ("offset", first), ("limit", count)), - JsonSerializer.Serialize(page, ModuleJsonContext.Default.ExportList)); + JsonSerializer.Serialize(modules.GetPrepared(wanted, cancellationToken), ModuleJsonContext.Default.ModuleDetails)); } /// @@ -118,7 +84,7 @@ public ReadResourceResult Exports( public McpCompletionValues ListCompletionValues(string variable, CancellationToken cancellationToken) { return string.Equals(variable, ModuleVariable, StringComparison.Ordinal) - ? McpCompletionReads.ModuleNames(dispatch, cancellationToken) + ? McpCompletionReads.ModuleNames(dispatch, prepared, cancellationToken) : throw new ArgumentOutOfRangeException(nameof(variable), variable, "Only module is completed."); } } diff --git a/srcs/CheatEngine.Mcp.Resources/Live/ProcessLiveResources.cs b/srcs/CheatEngine.Mcp.Resources/Live/ProcessLiveResources.cs index 14d549a..30f421f 100644 --- a/srcs/CheatEngine.Mcp.Resources/Live/ProcessLiveResources.cs +++ b/srcs/CheatEngine.Mcp.Resources/Live/ProcessLiveResources.cs @@ -36,13 +36,14 @@ public ReadResourceResult Process(CancellationToken cancellationToken = default) /// The structured result of process_list_threads. [McpServerResource(UriTemplate = ThreadsPath, Name = "instance_threads", Title = "Target threads", MimeType = McpResourceUris.JsonMimeType)] - [McpSourceTool(typeof(ProcessTools), CheatEngineToolNames.ProcessListThreads)] + [McpSourceTool(typeof(ProcessTools), CheatEngineToolNames.ProcessListThreads, PreparedProjection = nameof(ProcessTools.ListPreparedThreads))] [McpResourceAnnotations(Role.Assistant, Priority = LiveResourceResults.Priority)] - [Description("Up to 4096 thread ids of the attached target, marked truncated when Cheat Engine reports more. Its " + - "JSON is the structured result of " + CheatEngineToolNames.ProcessListThreads + ".")] + [Description("Up to 4096 thread ids of the attached target, marked truncated when Cheat Engine reports more. First " + + "run " + CheatEngineToolNames.ProcessListThreads + " explicitly; this resource reads its prepared snapshot " + + "and must be read within 5 seconds.")] public ReadResourceResult Threads(CancellationToken cancellationToken = default) { - return LiveResourceResults.Json(ThreadsPath, JsonSerializer.Serialize(processes.ListThreads(cancellationToken), + return LiveResourceResults.Json(ThreadsPath, JsonSerializer.Serialize(processes.ListPreparedThreads(cancellationToken), ProcessJsonContext.Default.ProcessThreadListResult)); } } diff --git a/srcs/CheatEngine.Mcp.Resources/Live/SymbolLiveResources.cs b/srcs/CheatEngine.Mcp.Resources/Live/SymbolLiveResources.cs index 93f42f8..14835a3 100644 --- a/srcs/CheatEngine.Mcp.Resources/Live/SymbolLiveResources.cs +++ b/srcs/CheatEngine.Mcp.Resources/Live/SymbolLiveResources.cs @@ -24,12 +24,13 @@ public sealed class SymbolLiveResources(SymbolRegistrationTools symbols) /// The structured result of symbol_list_registered. [McpServerResource(UriTemplate = SymbolsPath + "{?offset,limit}", Name = "instance_symbols", Title = "Registered symbols", MimeType = McpResourceUris.JsonMimeType)] - [McpSourceTool(typeof(SymbolRegistrationTools), CheatEngineToolNames.SymbolListRegistered)] + [McpSourceTool(typeof(SymbolRegistrationTools), CheatEngineToolNames.SymbolListRegistered, PreparedProjection = nameof(SymbolRegistrationTools.ListPreparedRegistered))] [McpResourceAnnotations(Role.Assistant, Priority = LiveResourceResults.Priority)] - [Description("A page of the symbols registered in Cheat Engine, with ownedByMcp on those this activation can " + - "unregister: 200 from offset 0 by default, or ?offset=..&limit=.. in that order (limit 1 to 1000); " + - "every read copies up to 8192 symbols. Its JSON is the structured result of " + - CheatEngineToolNames.SymbolListRegistered + "; use that tool to filter by name.")] + [Description("A page of the latest global registered-symbol copy explicitly prepared by symbol_list_registered, with " + + "ownedByMcp on those this activation can unregister: 200 from offset 0 by default, or ?offset=..&limit=.. " + + "in that order (limit 1 to 1000). The copy expires after five seconds and contains at most 8192 symbols; " + + "that copy cap does not bound Cheat Engine's native enumeration. Run symbol_list_registered before reading this " + + "resource; use the tool to filter by name.")] public ReadResourceResult Symbols( [Description("The index of the first symbol, 0 by default.")] string? offset = null, @@ -39,7 +40,7 @@ public ReadResourceResult Symbols( { int? first = McpResourceQuery.Number(offset, nameof(offset), 0, LiveResourceResults.MaximumOffset); int? count = McpResourceQuery.Number(limit, nameof(limit), 1, MaximumSymbols); - RegisteredSymbolList page = symbols.ListRegistered(null, first ?? 0, count ?? DefaultSymbols, + RegisteredSymbolList page = symbols.ListPreparedRegistered(first ?? 0, count ?? DefaultSymbols, cancellationToken); return LiveResourceResults.Json(McpResourceQuery.WithQuery(SymbolsPath, ("offset", first), ("limit", count)), JsonSerializer.Serialize(page, SymbolJsonContext.Default.RegisteredSymbolList)); diff --git a/srcs/CheatEngine.Mcp.Resources/README.md b/srcs/CheatEngine.Mcp.Resources/README.md index e543bab..3572ac8 100644 --- a/srcs/CheatEngine.Mcp.Resources/README.md +++ b/srcs/CheatEngine.Mcp.Resources/README.md @@ -41,23 +41,43 @@ The embedded copy rewrites those links when it is served, while the source files The classes in `Live/` publish JSON resource templates below `cheatengine://instance/`. Each live resource is a projection of one source tool, declared with `[McpSourceTool]`, and returns that tool's structured result for the equivalent arguments. -The Core validator permits only a read-only, closed-world, ungated, `short` tool as a live-resource source. +The Core validator requires a read-only, closed-world, ungated source tool. A non-`short` source must name a separate +prepared projection: a unique public instance method with the same result type, explicit `short` metadata and no feature +gates. The method cannot itself be a tool. Invalid projection declarations fail startup even for a `short` source. | Resource area | Resource templates | Source tools | | --- | --- | --- | | Runtime and process state | `runtime`, `process`, `threads`, `resources`, `jobs` | `runtime_get_overview`, `process_get_current`, `process_list_threads`, `runtime_list_resources`, `runtime_list_jobs` | | Scanning and debugging | `scanners`, `scanners/{scannerName}`, `debugger`, `debugger/breakpoints{?limit}` | `scan_list_scanners`, `scan_get_status`, `debugger_get_status`, `debugger_list_breakpoints` | | Memory and code | `regions{?offset,limit}`, `memory/{address}{?size}`, `disassembly/{address}{?count}`, `patches` | `memory_list_regions`, `memory_read`, `code_disassemble`, `asm_list_patches` | -| Modules and symbols | `modules{?offset,limit}`, `modules/{module}`, `modules/{module}/exports{?offset,limit}`, `symbols{?offset,limit}` | `module_list`, `module_get`, `module_list_exports`, `symbol_list_registered` | +| Modules and symbols | `modules{?offset,limit}`, `modules/{module}`, `symbols{?offset,limit}` | `module_list`, `module_get`, `symbol_list_registered` | | Records and structures | `records{?offset,limit}`, `records/{recordId}`, `structures{?offset,limit}`, `structures/{structure}{?offset,limit}` | `record_list`, `record_get`, `structure_list`, `structure_get` | | Pointer and speed state | `pointer-maps`, `pointer-scans`, `pointer-scans/{scanName}/paths{?offset,limit}`, `speedhack` | `pointer_list_maps`, `pointer_list_scans`, `pointer_list_paths`, `speedhack_get_state` | -Live resources are private, uncached views of a specific instance. +Live resource responses are private and carry no client cache lifetime. They are annotated for the assistant and carry their source tool name in `cheatengine/sourceTool` metadata. +Prepared reads also publish their method in `cheatengine/preparedProjection`; this is reviewed provenance, not proof of +the method's implementation. Explicit preparations can block CE for seconds during native enumeration. Their URI variables are parsed before dispatch, so absent, malformed, or out-of-range values produce `invalid_argument` rather than reaching Cheat Engine. +Module and region navigation reads prepared data held by the activation for five seconds. +Call `module_list` without `processId` before browsing modules or completing a module name; call `memory_list_regions` before browsing regions. +For module details, call `module_get` with the same selector first: the resource reuses that exact successful result, also available by its canonical module name. +Only the most recently prepared detail is retained; refreshing module/map data invalidates earlier details. +Every navigation request checks the current target and snapshot expiry through dispatch admission. +A missing, expired or mismatched snapshot produces `invalid_state` with a preparation hint; completion offers no candidates. +The explicit source tools perform native enumeration. These prepared resource reads and module completion never trigger it as a side effect of browsing. + +Thread, registered-symbol and breakpoint resources also require preparation within five seconds through +`process_list_threads`, `symbol_list_registered` and `debugger_list_breakpoints`, respectively. +Their native producers construct whole lists before copy limits apply; browsing reads the retained bounded copy. +Thread snapshots must still match the selected process and epoch. Symbol snapshots describe global CE state and +remain available without a selected process; ownership is re-evaluated from this activation's current registrations. +Structure details use a bounded global-name lookup and read only the requested element page. + Only cheap, bounded path variables are completed. -Module names and structure names use bounded dispatch completion, while scanner and pointer-scan names use instance memory completion. +Module names use a fresh admitted snapshot check for each request, with concurrent requests sharing an in-flight check and failures throttled briefly. +Structure names use cached dispatch completion, while scanner and pointer-scan names use instance memory completion. Addresses, record IDs, and query values deliberately do not complete. ## Project layout diff --git a/srcs/CheatEngine.Mcp.Tools/Asm/AsmTools.cs b/srcs/CheatEngine.Mcp.Tools/Asm/AsmTools.cs index af15ffe..22bdc8f 100644 --- a/srcs/CheatEngine.Mcp.Tools/Asm/AsmTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Asm/AsmTools.cs @@ -220,9 +220,9 @@ public AsmPatchApplied ApplyCodePatch( [McpServerTool(Name = CheatEngineToolNames.AsmReleasePatch, Title = "Release Auto Assembler patch", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.MayPrompt)] [Description( - "Run the original DISABLE section for one MCP-owned patch. A failed or target-changed release can leave a patch in the old target and require manual recovery; retry only when retryable is true. A successful release removes the patch from runtime resources.")] + "Run the original DISABLE section for one MCP-owned patch. Script execution can block Cheat Engine or show a dialog. A failed or target-changed release can leave a patch in the old target and require manual recovery; retry only when retryable is true. A successful release removes the patch from runtime resources.")] public AsmPatchReleased ReleasePatch( [Description("The patch id returned by asm_apply or asm_apply_code_patch.")] string patchId, diff --git a/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs b/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs index 8fdfb99..84b6ab6 100644 --- a/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs @@ -202,7 +202,7 @@ public CodeJobStart StartDissect( /// Starts a bounded instruction-text search over a target range. [McpServerTool(Name = CheatEngineToolNames.CodeStartSearch, Title = "Start code search", ReadOnly = true, - Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] [Description( "Start a background search for a case-insensitive substring in decoded instruction text. It disassembles at most 1 MiB in short Client dispatches, rechecks the selected target each time, and retains at most the configured job-buffer events. Poll code_poll_job or stop it with runtime_stop_job.")] diff --git a/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerTools.cs b/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerTools.cs index be1f477..da3595a 100644 --- a/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerTools.cs @@ -1,7 +1,9 @@ using System.ComponentModel; using System.Globalization; +using CheatEngine.Client; using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; using CheatEngine.Mcp.Core.Contract; using CheatEngine.Mcp.Core.Features; using CheatEngine.Mcp.Core.Jobs; @@ -24,13 +26,18 @@ public sealed class DebuggerTools private const int MaximumTraceSteps = 256; private const int MaximumStackDepth = 128; private const string CleanupFailedMessage = "The recorded cleanup did not complete."; + private static readonly TimeSpan PreparedBreakpointLifetime = TimeSpan.FromSeconds(5); private readonly ToolDispatch _dispatch; private readonly JobRegistry _jobs; + private readonly Lock _preparedBreakpointGate = new(); private readonly TargetResources _resources; + private readonly TimeProvider _time; + private PreparedBreakpoints? _preparedBreakpoints; + private long _preparedBreakpointGeneration; /// Creates the activation-scoped debugger tools. - public DebuggerTools(ToolDispatch dispatch, JobRegistry jobs, TargetResources resources) + public DebuggerTools(ToolDispatch dispatch, JobRegistry jobs, TargetResources resources, TimeProvider? time = null) { ArgumentNullException.ThrowIfNull(dispatch); ArgumentNullException.ThrowIfNull(jobs); @@ -38,6 +45,7 @@ public DebuggerTools(ToolDispatch dispatch, JobRegistry jobs, TargetResources re _dispatch = dispatch; _jobs = jobs; _resources = resources; + _time = time ?? TimeProvider.System; } /// Attaches the selected Cheat Engine debugger interface. @@ -179,11 +187,18 @@ public DebuggerBreakpointSet SetBreakpoint( } string resourceId = _resources.NextId("breakpoint"); - DebuggerBreakpointSet set = _dispatch.RunLua(CheatEngineToolNames.DebuggerSetBreakpoint, - DebuggerLuaScripts.SetBreakpoint, DebuggerJsonContext.Default.DebuggerBreakpointSet, cancellationToken, - _resources.Namespace, resourceId, expression, size, Trigger(trigger), Method(method), threadId, oneShot); - _resources.TrackState(resourceId, "breakpoint", address: set.Address, size: size, detail: Trigger(trigger)); - return set; + try + { + DebuggerBreakpointSet set = _dispatch.RunLua(CheatEngineToolNames.DebuggerSetBreakpoint, + DebuggerLuaScripts.SetBreakpoint, DebuggerJsonContext.Default.DebuggerBreakpointSet, cancellationToken, + _resources.Namespace, resourceId, expression, size, Trigger(trigger), Method(method), threadId, oneShot); + _resources.TrackState(resourceId, "breakpoint", address: set.Address, size: size, detail: Trigger(trigger)); + return set; + } + finally + { + InvalidatePreparedBreakpoints(); + } } /// Releases the MCP-owned breakpoint at the requested address. @@ -200,40 +215,81 @@ public DebuggerBreakpointDeleted DeleteBreakpoint( CancellationToken cancellationToken = default) { string expression = Address(address, "address"); - DebuggerBreakpointDeleted deleted = _dispatch.RunLua(CheatEngineToolNames.DebuggerDeleteBreakpoint, - DebuggerLuaScripts.DeleteBreakpoint, DebuggerJsonContext.Default.DebuggerBreakpointDeleted, - cancellationToken, _resources.Namespace, expression); - if (deleted.Released) + try { - _resources.Forget(deleted.ResourceId); - return deleted; - } + DebuggerBreakpointDeleted deleted = _dispatch.RunLua(CheatEngineToolNames.DebuggerDeleteBreakpoint, + DebuggerLuaScripts.DeleteBreakpoint, DebuggerJsonContext.Default.DebuggerBreakpointDeleted, + cancellationToken, _resources.Namespace, expression); + if (deleted.Released) + { + _resources.Forget(deleted.ResourceId); + return deleted; + } - deleted = deleted with + deleted = deleted with + { + CleanupError = CleanupFailedMessage + }; + throw CheatEngineToolException.PartialEffect( + "Cheat Engine did not confirm removal of the MCP-owned breakpoint.", ToolHostEffect.CleanupUnconfirmed, + deleted, DebuggerJsonContext.Default.DebuggerBreakpointDeleted, false, + "Inspect debugger_list_breakpoints and use runtime_release_resources after manual recovery."); + } + finally { - CleanupError = CleanupFailedMessage - }; - throw CheatEngineToolException.PartialEffect( - "Cheat Engine did not confirm removal of the MCP-owned breakpoint.", ToolHostEffect.CleanupUnconfirmed, - deleted, DebuggerJsonContext.Default.DebuggerBreakpointDeleted, false, - "Inspect debugger_list_breakpoints and use runtime_release_resources after manual recovery."); + InvalidatePreparedBreakpoints(); + } } /// Lists a bounded copy of all currently visible breakpoints. [McpServerTool(Name = CheatEngineToolNames.DebuggerListBreakpoints, Title = "List debugger breakpoints", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.BlockingNative)] [Description( - "Copies up to 1024 breakpoint addresses from Cheat Engine and identifies entries this MCP activation owns.")] + "Copies up to 1024 breakpoint addresses from Cheat Engine and identifies entries this MCP activation owns. A successful call explicitly prepares this snapshot for the breakpoints resource for five seconds; ownership fields are observations from that snapshot. The copy cap does not bound native enumeration, which can block Cheat Engine for seconds.")] public DebuggerBreakpointPage ListBreakpoints( [Description("The maximum addresses to copy, from 1 through 1024.")] int limit = 256, CancellationToken cancellationToken = default) { Range(limit, "limit", 1, MaximumBreakpointList); - return _dispatch.RunLua(CheatEngineToolNames.DebuggerListBreakpoints, DebuggerLuaScripts.ListBreakpoints, - DebuggerJsonContext.Default.DebuggerBreakpointPage, cancellationToken, _resources.Namespace, limit); + ICheatEngineClient client = _dispatch.Client; + return _dispatch.Run(CheatEngineToolNames.DebuggerListBreakpoints, token => + { + long generation = PreparedBreakpointGeneration(); + BreakpointTarget before = CurrentTarget(client, token); + DebuggerBreakpointPage copied = _dispatch.ExecuteLua(CheatEngineToolNames.DebuggerListBreakpoints, + DebuggerLuaScripts.ListBreakpoints, DebuggerJsonContext.Default.DebuggerBreakpointPage, token, + _resources.Namespace, MaximumBreakpointList); + BreakpointTarget after = CurrentTarget(client, token); + EnsureSameTarget(before, after); + PublishPreparedBreakpoints(before, copied, generation); + return ProjectBreakpoints(copied, limit); + }, cancellationToken); + } + + /// Pages the latest explicitly prepared breakpoint snapshot. + /// The snapshot expires after five seconds and its ownership fields remain observations from preparation. + /// The most breakpoint addresses to return. + /// The request's token. + /// A page projected from the prepared breakpoint snapshot. + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public DebuggerBreakpointPage ListPreparedBreakpoints(int limit = 256, + CancellationToken cancellationToken = default) + { + Range(limit, "limit", 1, MaximumBreakpointList); + return _dispatch.Run(CheatEngineToolNames.DebuggerListBreakpoints, token => + { + BreakpointTarget current = CurrentTarget(_dispatch.Client, token); + if (!TryGetPreparedBreakpoints(current, out DebuggerBreakpointPage copied)) + { + throw CheatEngineToolException.InvalidState("No current prepared breakpoint snapshot is available.", + "Run debugger_list_breakpoints explicitly, then retry within 5 seconds."); + } + + return ProjectBreakpoints(copied, limit); + }, cancellationToken); } /// Continues the current stopped debugger context. @@ -619,6 +675,91 @@ private static string Address(string value, string parameter) return expression; } + private long PreparedBreakpointGeneration() + { + lock (_preparedBreakpointGate) + { + return _preparedBreakpointGeneration; + } + } + + private void PublishPreparedBreakpoints(BreakpointTarget target, DebuggerBreakpointPage page, long generation) + { + lock (_preparedBreakpointGate) + { + if (_preparedBreakpointGeneration == generation) + { + _preparedBreakpoints = new PreparedBreakpoints(target, CloneBreakpointPage(page), _time.GetTimestamp()); + } + } + } + + private bool TryGetPreparedBreakpoints(BreakpointTarget target, out DebuggerBreakpointPage page) + { + lock (_preparedBreakpointGate) + { + if (_preparedBreakpoints is { } prepared && prepared.Target == target && + _time.GetElapsedTime(prepared.PublishedAt, _time.GetTimestamp()) < PreparedBreakpointLifetime) + { + page = CloneBreakpointPage(prepared.Page); + return true; + } + } + + page = null!; + return false; + } + + private void InvalidatePreparedBreakpoints() + { + lock (_preparedBreakpointGate) + { + _preparedBreakpointGeneration++; + _preparedBreakpoints = null; + } + } + + private static DebuggerBreakpointPage ProjectBreakpoints(DebuggerBreakpointPage copied, int limit) + { + DebuggerBreakpoint[] breakpoints = [.. copied.Breakpoints.Take(limit)]; + return new DebuggerBreakpointPage(breakpoints, copied.Total, + copied.Truncated || copied.Breakpoints.Length > breakpoints.Length); + } + + private static DebuggerBreakpointPage CloneBreakpointPage(DebuggerBreakpointPage page) + { + return new DebuggerBreakpointPage([.. page.Breakpoints], page.Total, page.Truncated); + } + + private static BreakpointTarget CurrentTarget(ICheatEngineClient client, CancellationToken token) + { + if (client.Processes.TryGetCurrentProcess(out ProcessSnapshot process, out CheatEngineFailure failure, token)) + { + return new BreakpointTarget(process.Id.Value, process.SelectionEpoch); + } + + if (failure.Kind is CheatEngineFailureKind.TargetNotAttached) + { + return default; + } + + throw CheatEngineToolException.FromFailure(failure, client.Stopping.IsCancellationRequested); + } + + private static void EnsureSameTarget(BreakpointTarget before, BreakpointTarget after) + { + if (before != after) + { + throw new CheatEngineToolException(new ToolError(ToolErrorKind.TargetChanged, + "The selected target changed while breakpoints were being copied.", CheatEngineToolNames.DebuggerListBreakpoints, + ToolHostEffect.Completed, false, "Re-attach the process, then repeat the breakpoint list.")); + } + } + + private readonly record struct BreakpointTarget(int? ProcessId, long? SelectionEpoch); + + private sealed record PreparedBreakpoints(BreakpointTarget Target, DebuggerBreakpointPage Page, long PublishedAt); + private static void BreakpointSize(int size, DebuggerBreakpointTrigger trigger) { if (size is not 1 and not 2 and not 4 and not 8) diff --git a/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetLuaScripts.cs b/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetLuaScripts.cs index f0f6b12..76fb4d0 100644 --- a/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetLuaScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetLuaScripts.cs @@ -244,8 +244,10 @@ local function read(kind, at) """; /// - /// Reads a method's parameters. Cheat Engine 7.7's collector returns { {Name, CType}, ... } and the - /// signature text as a second value (dotnetinfo.lua, getClassMethods). + /// Reads collector entries shaped as { {Name, CType}, ... }, preserving optional signature text + /// if a collector also returns it as a second value. The pinned upstream Lua bridge returns only the entries. + /// CType is GetParamProps' metadata-constant code, not the declared parameter type. Failed upstream lookups + /// can survive as zero-initialized entries; preserve the raw collector result without inferring a type. /// internal const string MethodParameters = """ local collector = getDotNetDataCollector() diff --git a/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetRecords.cs b/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetRecords.cs index 21e39ff..afc41e0 100644 --- a/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetRecords.cs +++ b/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetRecords.cs @@ -214,7 +214,7 @@ public sealed record DotNetMethodParameters( string ModuleHandle, [property: Description("The method token, as supplied.")] string MethodToken, - [property: Description("The parameters in declaration order, without the return value.")] + [property: Description("The metadata entries in collector order; the list can be incomplete and include return metadata or unavailable placeholders.")] DotNetParameter[] Parameters, [property: Description( "The signature text the collector reports, such as System.Int32 (System.Single, System.String), when available.")] @@ -222,11 +222,11 @@ public sealed record DotNetMethodParameters( /// One parameter of a managed method. public sealed record DotNetParameter( - [property: Description("The zero-based parameter position.")] + [property: Description("The zero-based position in the returned collector entries, not necessarily the declared parameter ordinal.")] int Index, - [property: Description("The parameter name; empty when the metadata has none.")] + [property: Description("The reported parameter name; empty when unnamed or unavailable.")] string Name, - [property: Description("The parameter's CorElementType code, such as 8 for Int32 or 18 for a class reference.")] + [property: Description("The collector's CType code for a metadata constant/default value, not the declared parameter type. 1 (Void) means no constant; 0 can mean unavailable metadata. Use signature for declared types.")] int ElementType, - [property: Description("The element type's name, such as Int32, String or Class, when the code is known.")] + [property: Description("The CorElementType name for elementType when known; this does not name the declared parameter type.")] string? ElementTypeName = null); diff --git a/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetTools.cs b/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetTools.cs index 86e549c..632f910 100644 --- a/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/DotNet/DotNetTools.cs @@ -157,7 +157,7 @@ public DotNetMethodPage ListMethods( ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] [Description( - "Read a managed method's parameter names and CorElementType codes, and the signature text when the out-of-process collector reports it. Pass a module handle from dotnet_list_modules and a method token from dotnet_list_methods; this never injects into the target.")] + "Read a managed method's collector parameter entries and optional signature text. Each elementType is the collector's metadata-constant code, not the declared parameter type; use signature for declared types when available. Pass a module handle from dotnet_list_modules and a method token from dotnet_list_methods; this never injects into the target.")] public DotNetMethodParameters GetMethodParameters( [Description("An opaque module handle from dotnet_list_modules.")] string moduleHandle, diff --git a/srcs/CheatEngine.Mcp.Tools/Kernel/KernelRecords.cs b/srcs/CheatEngine.Mcp.Tools/Kernel/KernelRecords.cs index a6fcdd0..27ff787 100644 --- a/srcs/CheatEngine.Mcp.Tools/Kernel/KernelRecords.cs +++ b/srcs/CheatEngine.Mcp.Tools/Kernel/KernelRecords.cs @@ -8,8 +8,10 @@ namespace CheatEngine.Mcp.Tools.Kernel; /// The available DBK/DBVM state and control-register values, without initializing either component. public sealed record KernelStatus( - bool DbkInitialized, - bool DbvmInitialized, + [property: Description("Whether DBK is initialized; omitted when Cheat Engine cannot report its state.")] + bool? DbkInitialized, + [property: Description("Whether DBVM is initialized; omitted when Cheat Engine cannot report its state.")] + bool? DbvmInitialized, string? Cr0, string? Cr3, string? Cr4, diff --git a/srcs/CheatEngine.Mcp.Tools/Kernel/KernelScripts.cs b/srcs/CheatEngine.Mcp.Tools/Kernel/KernelScripts.cs index 8a9e171..23a50b2 100644 --- a/srcs/CheatEngine.Mcp.Tools/Kernel/KernelScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/Kernel/KernelScripts.cs @@ -7,16 +7,18 @@ internal static class KernelScripts { internal const string GetStatus = """ local function initialized(f) - if type(f) ~= 'function' then return false end + if type(f) ~= 'function' then return nil end local ok, value = pcall(f) - return ok and value == true + if not ok or type(value) ~= 'boolean' then return nil end + return value end local function register(f) if type(f) ~= 'function' then return nil end local ok, value = pcall(f) return ok and type(value) == 'number' and mcp.hex(value) or nil end - return {dbkInitialized = initialized(dbk_initialized), dbvmInitialized = initialized(dbvm_initialized), + -- A named marker preserves object shape when all probes are unavailable; the typed result ignores it. + return {_mcpStatus = 'kernel', dbkInitialized = initialized(dbk_initialized), dbvmInitialized = initialized(dbvm_initialized), cr0 = register(dbk_getCR0), cr3 = register(dbk_getCR3), cr4 = register(dbk_getCR4), dbvmCr4 = register(dbvm_getCR4)} """; diff --git a/srcs/CheatEngine.Mcp.Tools/Kernel/KernelTools.cs b/srcs/CheatEngine.Mcp.Tools/Kernel/KernelTools.cs index b3ecc39..b946959 100644 --- a/srcs/CheatEngine.Mcp.Tools/Kernel/KernelTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Kernel/KernelTools.cs @@ -32,7 +32,7 @@ public KernelTools(ToolDispatch dispatch, JobRegistry jobs) [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] [RequiresFeature(McpFeature.KernelAccess)] [Description( - "Read whether DBK and DBVM are already initialized, plus any CR0, CR3, CR4 and DBVM real-CR4 values that Cheat Engine can read. It does not load a driver or initialize DBVM. Null register values are unavailable, not zero; call this before any kernel operation.")] + "Read whether DBK and DBVM are already initialized, plus any CR0, CR3, CR4 and DBVM real-CR4 values that Cheat Engine can read. It does not load a driver or initialize DBVM. Null status and register values are unavailable, not false or zero; call this before any kernel operation.")] public KernelStatus GetStatus(CancellationToken cancellationToken = default) { RequireKernel(CheatEngineToolNames.KernelGetStatus); @@ -209,8 +209,8 @@ public KernelWatchStart StartWatch( /// Drains newly available DBVM events into the job ring, then returns a non-consuming page through the job /// poll, which refreshes the managed job state. /// - [McpServerTool(Name = CheatEngineToolNames.KernelPollWatch, Title = "Poll DBVM watch", ReadOnly = true, - Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpServerTool(Name = CheatEngineToolNames.KernelPollWatch, Title = "Poll DBVM watch", ReadOnly = false, + Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.BlockingNative)] [RequiresFeature(McpFeature.KernelAccess)] [Description( diff --git a/srcs/CheatEngine.Mcp.Tools/Lua/LuaTools.cs b/srcs/CheatEngine.Mcp.Tools/Lua/LuaTools.cs index 6586b8e..d9a65da 100644 --- a/srcs/CheatEngine.Mcp.Tools/Lua/LuaTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Lua/LuaTools.cs @@ -72,7 +72,7 @@ public LuaExecuteResult Execute( /// Searches the Lua API reference installed with the current Cheat Engine instance. [McpServerTool(Name = CheatEngineToolNames.LuaFindApi, Title = "Find Lua API", ReadOnly = true, - Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + Destructive = false, Idempotent = true, OpenWorld = true, UseStructuredContent = true)] [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] [Description( "Search the celua.txt Lua API reference in this Cheat Engine installation, case-insensitively. Returns matching lines with their source line numbers, at most 100 per page. The file is read only from Cheat Engine's own installation directory, is never returned in full and is bounded to 4 MiB and 65536 lines; a source-file bound returns the bounded prefix with truncated true and no nextOffset. Use a specific function or class name and page with nextOffset.")] diff --git a/srcs/CheatEngine.Mcp.Tools/Memory/MemoryFileTools.cs b/srcs/CheatEngine.Mcp.Tools/Memory/MemoryFileTools.cs index ca1f0b0..c85d398 100644 --- a/srcs/CheatEngine.Mcp.Tools/Memory/MemoryFileTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Memory/MemoryFileTools.cs @@ -43,7 +43,7 @@ public MemoryFileTools(ToolDispatch dispatch, McpFilePaths files) /// Writes a target range to a file under the configured write roots. [McpServerTool(Name = CheatEngineToolNames.MemoryDumpToFile, Title = "Dump memory to a file", ReadOnly = false, - Destructive = false, Idempotent = true, OpenWorld = true, UseStructuredContent = true)] + Destructive = true, Idempotent = true, OpenWorld = true, UseStructuredContent = true)] [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] [Description( "Write up to 256 MiB of target memory to a file on the Cheat Engine host. The path must be absolute, local and inside a folder listed in Mcp:Files:AllowedRoots (empty by default, which refuses every dump); the MCP data and registry folders are always refused. Reads 1 MiB per dispatch into a temporary file that replaces the target file only when complete; unreadable memory fails the dump unless unreadable is zero.")] diff --git a/srcs/CheatEngine.Mcp.Tools/Memory/MemoryInfoTools.cs b/srcs/CheatEngine.Mcp.Tools/Memory/MemoryInfoTools.cs index afeff1b..2acc1c6 100644 --- a/srcs/CheatEngine.Mcp.Tools/Memory/MemoryInfoTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Memory/MemoryInfoTools.cs @@ -4,8 +4,10 @@ using CheatEngine.Client; using CheatEngine.Client.Inspection; +using CheatEngine.Client.Processes; using CheatEngine.Client.Results; using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Inspection; using CheatEngine.Mcp.Core.Values; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; @@ -30,13 +32,17 @@ public sealed class MemoryInfoTools private const int MaximumSections = 256; private readonly ToolDispatch _dispatch; + private readonly PreparedInspectionStore _prepared; /// Creates the tools; the constructor does no Cheat Engine work. /// The activation's dispatch facade. - public MemoryInfoTools(ToolDispatch dispatch) + /// The activation's explicitly prepared inspection snapshots. + public MemoryInfoTools(ToolDispatch dispatch, PreparedInspectionStore prepared) { ArgumentNullException.ThrowIfNull(dispatch); + ArgumentNullException.ThrowIfNull(prepared); _dispatch = dispatch; + _prepared = prepared; } /// Describes up to 256 addresses: symbol, module, section, region and optional pointer value and RTTI class. @@ -44,7 +50,7 @@ public MemoryInfoTools(ToolDispatch dispatch) Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] [Description( - "Describe up to 256 addresses: Cheat Engine's symbol name, the containing module and section, whether it is a system module, the memory region (state, protection, backing) and optionally the pointer-sized value stored there and the RTTI class name of the object there. An address that does not resolve reports its own error in band.")] + "Describe up to 256 addresses: Cheat Engine's symbol name, the containing module and section, whether it is a system module when Cheat Engine can answer, the memory region (state, protection, backing) and optionally the pointer-sized value stored there and the RTTI class name of the object there. An address that does not resolve reports its own error in band.")] public AddressInfoResult GetAddressInfo( [Description("The addresses or Cheat Engine address expressions to describe, 1 to 256.")] string[] addresses, @@ -63,9 +69,9 @@ public AddressInfoResult GetAddressInfo( /// Pages the target's memory regions with address, module, state, backing and protection filters. [McpServerTool(Name = CheatEngineToolNames.MemoryListRegions, Title = "List memory regions", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.BlockingNative)] [Description( - "List the target's memory regions in address order, one page at a time, filtered by address range, module, state (committed by default), backing and the writable, executable and copy-on-write flags. The detailed format adds the allocation base, allocation protection and mapped file.")] + "List the target's memory regions in address order, one page at a time, filtered by address range, module, state (committed by default), backing and the writable, executable and copy-on-write flags. The detailed format adds the allocation base, allocation protection and mapped file. This explicit read prepares the default region resource snapshot for 5 seconds. Native enumeration of the address space can block Cheat Engine for seconds.")] public RegionList ListRegions( [Description("Keep only regions that end after this address or expression; use with endAddress or alone.")] string? startAddress = null, @@ -104,7 +110,41 @@ public RegionList ListRegions( RegionFilter filter = new(state, type, writable, executable, copyOnWrite); bool detailed = format is ResultFormat.Detailed; MemoryRegionEntry[] regions = _dispatch.Run(CheatEngineToolNames.MemoryListRegions, - token => Regions(start, end, moduleName, filter, detailed, token), cancellationToken); + token => RegionsAndPublish(start, end, moduleName, filter, detailed, token), cancellationToken); + PageSlice page = Paging.Slice(regions, offset, limit, MaximumPage); + return new RegionList(page.Total, page.NextOffset, page.Truncated, [.. page.Items]); + } + + /// Reads a default region page from an explicit current-target preparation. + /// This method performs no memory-region enumeration and its preparation expires after five seconds. + /// The zero-based index of the first region. + /// The number of regions to return, from 1 to 2000. + /// The request's cancellation token. + /// A concise page from the explicitly prepared memory-region map. + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public RegionList ListPreparedRegions(int offset = 0, int limit = 500, + CancellationToken cancellationToken = default) + { + if (offset < 0) + { + throw CheatEngineToolException.InvalidArgument("offset", "must be zero or greater."); + } + + MemoryTargets.RequireRange(limit, "limit", 1, MaximumPage); + PreparedMemoryMapSnapshot snapshot = _dispatch.Run(CheatEngineToolNames.MemoryListRegions, token => + { + ProcessSnapshot target = _dispatch.Client.Processes.GetCurrentProcess(token); + if (!_prepared.TryGetMemoryMap(target, out PreparedMemoryMapSnapshot prepared) || + !_prepared.IsCurrent(target, prepared.Version)) + { + throw PreparedMissing(); + } + + return prepared; + }, cancellationToken); + MemoryRegionEntry[] regions = ProjectRegions(snapshot.Modules, snapshot.Regions, 0, ulong.MaxValue, + new RegionFilter(RegionStateFilter.Committed, RegionTypeFilter.Any, ProtectionRequirement.Any, + ProtectionRequirement.Any, ProtectionRequirement.Any), false); PageSlice page = Paging.Slice(regions, offset, limit, MaximumPage); return new RegionList(page.Total, page.NextOffset, page.Truncated, [.. page.Items]); } @@ -172,7 +212,7 @@ private AddressInfoResult Describe(string[] expressions, bool includeRtti, bool int index = resolved[position]; items[index] = items[index] with { - IsSystemModule = position < extras.System.Length && extras.System[position], + IsSystemModule = position < extras.System.Length ? extras.System[position] : null, RttiClass = position < extras.Rtti.Length ? extras.Rtti[position] : null }; } @@ -232,10 +272,11 @@ private static AddressInfo Describe(ICheatEngineClient client, Address address, return null; } - private MemoryRegionEntry[] Regions(string? start, string? end, string? moduleName, RegionFilter filter, + private MemoryRegionEntry[] RegionsAndPublish(string? start, string? end, string? moduleName, RegionFilter filter, bool detailed, CancellationToken cancellationToken) { ICheatEngineClient client = _dispatch.Client; + ProcessSnapshot before = client.Processes.GetCurrentProcess(cancellationToken); ulong low = start is null ? 0 : MemoryTargets.Resolve(client, start, "startAddress", cancellationToken) @@ -266,6 +307,16 @@ private MemoryRegionEntry[] Regions(string? start, string? end, string? moduleNa ImmutableArray map = client.Inspection.GetMemoryRegions( new InspectionCollectionRequest(MaximumRegions), cancellationToken); + ProcessSnapshot after = client.Processes.GetCurrentProcess(cancellationToken); + EnsureSameTarget(before, after); + MemoryRegionEntry[] regions = ProjectRegions(modules, map, low, high, filter, detailed); + _prepared.PublishMemoryMap(before, modules, map); + return regions; + } + + private static MemoryRegionEntry[] ProjectRegions(ImmutableArray modules, + ImmutableArray map, ulong low, ulong high, RegionFilter filter, bool detailed) + { List regions = []; foreach (MemoryRegionInfo region in map.OrderBy(static region => region.BaseAddress.ToUInt64())) { @@ -299,6 +350,23 @@ private MemoryRegionEntry[] Regions(string? start, string? end, string? moduleNa return [.. regions]; } + private static void EnsureSameTarget(ProcessSnapshot before, ProcessSnapshot after) + { + if (before.Id != after.Id || before.SelectionEpoch != after.SelectionEpoch) + { + throw new CheatEngineToolException(new ToolError(ToolErrorKind.TargetChanged, + "The attached process changed while memory regions were being read; the completed host read was discarded.", + CheatEngineToolNames.MemoryListRegions, ToolHostEffect.Completed, false, + "Repeat memory_list_regions for the current target.")); + } + } + + private static CheatEngineToolException PreparedMissing() + { + return CheatEngineToolException.InvalidState("No current prepared memory-region map is available.", + $"Run {CheatEngineToolNames.MemoryListRegions} explicitly, then retry within 5 seconds."); + } + private sealed record RegionFilter( RegionStateFilter State, RegionTypeFilter Type, diff --git a/srcs/CheatEngine.Mcp.Tools/Memory/MemoryRecords.cs b/srcs/CheatEngine.Mcp.Tools/Memory/MemoryRecords.cs index 394a129..2bf5249 100644 --- a/srcs/CheatEngine.Mcp.Tools/Memory/MemoryRecords.cs +++ b/srcs/CheatEngine.Mcp.Tools/Memory/MemoryRecords.cs @@ -213,7 +213,7 @@ public sealed record AddressInfoResult( /// Cheat Engine's name for the address. /// The loaded module that contains it. /// The module section that contains it. -/// Whether Cheat Engine counts it as inside a system module. +/// Whether Cheat Engine counts it as inside a system module, when available. /// The memory region that contains it. /// The pointer-sized value stored at it. /// The RTTI class name of the object at it. @@ -228,8 +228,9 @@ public sealed record AddressInfo( string? Module = null, [property: Description("The module section that contains the address, such as .text, when known.")] string? Section = null, - [property: Description("Whether Cheat Engine counts the address as inside a system module.")] - bool IsSystemModule = false, + [property: Description( + "Whether Cheat Engine counts the address as inside a system module; omitted when that host API is unavailable or fails.")] + bool? IsSystemModule = null, [property: Description("The memory region that contains the address, when Cheat Engine reports one.")] MemoryRegionDetail? Region = null, [property: @@ -602,9 +603,9 @@ public sealed record ZeroFilledRange( int Length); /// What the fixed memory_get_address_info script reports for each resolved address. -/// Whether each address is inside a system module. +/// Whether each address is inside a system module, or unavailable when Cheat Engine cannot answer. /// The RTTI class name of each address, when found. -public sealed record AddressExtras(bool[] System, string?[] Rtti); +public sealed record AddressExtras(bool?[] System, string?[] Rtti); /// What the fixed memory_set_protection script reports. /// The first page's access before the change. diff --git a/srcs/CheatEngine.Mcp.Tools/Memory/MemoryScripts.cs b/srcs/CheatEngine.Mcp.Tools/Memory/MemoryScripts.cs index 06f088c..8c40d3d 100644 --- a/srcs/CheatEngine.Mcp.Tools/Memory/MemoryScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/Memory/MemoryScripts.cs @@ -8,7 +8,8 @@ internal static class MemoryScripts { /// /// For each address of a[1]: whether inSystemModule places it in a system module and, when - /// a[2] is set, its getRTTIClassName. A missing or raising function reads as false or no class. + /// a[2] is set, its getRTTIClassName. A missing or raising function leaves that system-module + /// status unavailable and reports no class. /// internal const string AddressExtras = """ local addresses, rtti = a[1], a[2] @@ -17,10 +18,9 @@ internal static class MemoryScripts local inSystem, className = inSystemModule, getRTTIClassName for i = 1, count do local address = addresses[i] - system[i] = false if type(inSystem) == 'function' then local ok, value = pcall(inSystem, address) - system[i] = ok and value == true + if ok and type(value) == 'boolean' then system[i] = value end end if rtti and type(className) == 'function' then local ok, name = pcall(className, address) diff --git a/srcs/CheatEngine.Mcp.Tools/Modules/ModuleExportTools.cs b/srcs/CheatEngine.Mcp.Tools/Modules/ModuleExportTools.cs index 444fad6..6329435 100644 --- a/srcs/CheatEngine.Mcp.Tools/Modules/ModuleExportTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Modules/ModuleExportTools.cs @@ -35,9 +35,9 @@ public ModuleExportTools(ToolDispatch dispatch) /// The page. [McpServerTool(Name = CheatEngineToolNames.ModuleListExports, Title = "List module exports", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.HostScan)] [Description( - "Page the exports of one module of the attached process, parsed from its export directory in memory: name (omitted for ordinal-only exports), ordinal and address, or the forwarder such as NTDLL.RtlAllocateHeap for a forwarded export. Filter with nameContains, then page with offset and limit (at most 1000). A module whose export data exceeds 8 MiB or 65536 entries is refused.")] + "Page the exports of one module of the attached process, parsed from its export directory in memory: name (omitted for ordinal-only exports), ordinal and address, or the forwarder such as NTDLL.RtlAllocateHeap for a forwarded export. The whole directory is read in one dispatch before filtering and paging, holding Cheat Engine while it runs; large directories can take seconds. Filter with nameContains, then page with offset and limit (at most 1000). A module whose export data exceeds 8 MiB or 65536 entries is refused.")] public ExportList ListExports( [Description("The module name, such as kernel32.dll, or an address expression inside it.")] string module, diff --git a/srcs/CheatEngine.Mcp.Tools/Modules/ModuleImportTools.cs b/srcs/CheatEngine.Mcp.Tools/Modules/ModuleImportTools.cs index b54b768..bc03db3 100644 --- a/srcs/CheatEngine.Mcp.Tools/Modules/ModuleImportTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Modules/ModuleImportTools.cs @@ -43,9 +43,9 @@ public ModuleImportTools(ToolDispatch dispatch) /// The page. [McpServerTool(Name = CheatEngineToolNames.ModuleListImports, Title = "List module imports", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.HostScan)] [Description( - "Page the imported functions of one module of the attached process, parsed from its import directory (and delay-load directory) in memory: the DLL, the IAT slot address the code calls through, the name and hint or the ordinal, the pointer the slot holds now, and the loaded module and Cheat Engine symbol that pointer lands in. A bound IAT without a lookup table gives slots without names; a delay-loaded slot points at this module's loader stub until its first call. A pointer outside the named DLL is not proof of a hook: api-ms-win-* sets resolve into kernelbase.dll and others. Filter with dllContains and nameContains, then page with offset and limit (at most 1000). A module whose import data exceeds 8 MiB or 65536 entries is refused.")] + "Page the imported functions of one module of the attached process, parsed from its import directory (and delay-load directory) in memory: the DLL, the IAT slot address the code calls through, the name and hint or the ordinal, the pointer the slot holds now, and the loaded module and Cheat Engine symbol that pointer lands in. A bound IAT without a lookup table gives slots without names; a delay-loaded slot points at this module's loader stub until its first call. A pointer outside the named DLL is not proof of a hook: api-ms-win-* sets resolve into kernelbase.dll and others. The whole directory is read in one dispatch before filtering and paging, holding Cheat Engine while it runs; large directories can take seconds. Filter with dllContains and nameContains, then page with offset and limit (at most 1000). A module whose import data exceeds 8 MiB or 65536 entries is refused.")] public ImportList ListImports( [Description("The module name, such as game.exe, or an address expression inside it.")] string module, diff --git a/srcs/CheatEngine.Mcp.Tools/Modules/ModuleTools.cs b/srcs/CheatEngine.Mcp.Tools/Modules/ModuleTools.cs index 24b2801..9e2b49e 100644 --- a/srcs/CheatEngine.Mcp.Tools/Modules/ModuleTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Modules/ModuleTools.cs @@ -4,7 +4,9 @@ using CheatEngine.Client; using CheatEngine.Client.Inspection; +using CheatEngine.Client.Processes; using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Inspection; using CheatEngine.Mcp.Core.Values; using CheatEngine.SDK.Engine.Inspection; @@ -23,13 +25,19 @@ public sealed class ModuleTools internal const int MaximumSections = 4096; private readonly ToolDispatch _dispatch; + private readonly Lock _preparedGate = new(); + private readonly PreparedInspectionStore _prepared; + private PreparedModuleDetails? _preparedDetails; /// Creates the tools; the constructor does no Cheat Engine work. /// The activation's dispatch facade. - public ModuleTools(ToolDispatch dispatch) + /// The activation's explicitly prepared inspection snapshots. + public ModuleTools(ToolDispatch dispatch, PreparedInspectionStore prepared) { ArgumentNullException.ThrowIfNull(dispatch); + ArgumentNullException.ThrowIfNull(prepared); _dispatch = dispatch; + _prepared = prepared; } /// Pages the modules of the attached process or of another process. @@ -42,9 +50,9 @@ public ModuleTools(ToolDispatch dispatch) /// The page. [McpServerTool(Name = CheatEngineToolNames.ModuleList, Title = "List modules", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.BlockingNative)] [Description( - "Page the modules (EXE and DLLs) loaded in the attached process, or in another process by processId, in Cheat Engine's order. concise returns name, base and size; detailed adds is64Bit and the file path. Filter with nameContains, then page with offset and limit (at most 1000). Use it to classify the runtime (mono, GameAssembly, coreclr) and to find a module for module_get.")] + "Page the modules (EXE and DLLs) loaded in the attached process, or in another process by processId, in Cheat Engine's order. concise returns name, base and size; detailed adds is64Bit and the file path. Filter with nameContains, then page with offset and limit (at most 1000). A successful attached-process call explicitly prepares the complete module list for five seconds; calls with processId do not. Native enumeration can block Cheat Engine for seconds on a large module set. Use it to classify the runtime (mono, GameAssembly, coreclr) and to find a module for module_get.")] public ModuleList List( [Description("Another process's id; omit it for the attached process.")] int? processId = null, @@ -69,8 +77,17 @@ public ModuleList List( (int pid, ImmutableArray modules) = _dispatch.Run(CheatEngineToolNames.ModuleList, token => { TargetProcessId? requested = processId is { } value ? new TargetProcessId(value) : null; - int owner = processId ?? client.Processes.GetCurrentProcess(token).Id.Value; - return (owner, ModuleLocator.GetModules(client, requested, token)); + if (processId is { } otherProcess) + { + return (otherProcess, ModuleLocator.GetModules(client, requested, token)); + } + + ProcessSnapshot before = client.Processes.GetCurrentProcess(token); + ImmutableArray listed = ModuleLocator.GetModules(client, null, token); + ProcessSnapshot after = client.Processes.GetCurrentProcess(token); + EnsureSameTarget(before, after, CheatEngineToolNames.ModuleList); + _prepared.PublishModules(before, listed); + return (before.Id.Value, listed); }, cancellationToken); ModuleInfo[] matching = filter is null @@ -92,9 +109,9 @@ public ModuleList List( /// The module details. [McpServerTool(Name = CheatEngineToolNames.ModuleGet, Title = "Get module details", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.BlockingNative)] [Description( - "Read one module of the attached process: base, size, file path, its sections (with executable and writable flags) and the fields of its mapped PE header: machine, COFF time stamp, entry point, subsystem, DLL and .NET flags and the PDB it names. The time stamp identifies the build, so keep it to tell later whether the game was updated. module is a name such as game.exe (case-insensitive) or any address expression inside the module.")] + "Read one module of the attached process: base, size, file path, its sections (with executable and writable flags) and the fields of its mapped PE header: machine, COFF time stamp, entry point, subsystem, DLL and .NET flags and the PDB it names. The time stamp identifies the build, so keep it to tell later whether the game was updated. module is a name such as game.exe (case-insensitive) or any address expression inside the module. This explicitly prepares the current module list and this latest module result for five seconds. Native module enumeration can block Cheat Engine for seconds.")] public ModuleDetails Get( [Description("The module name, such as game.exe, or an address expression inside it, such as game.exe+1000.")] string module, @@ -104,14 +121,76 @@ public ModuleDetails Get( ICheatEngineClient client = _dispatch.Client; return _dispatch.Run(CheatEngineToolNames.ModuleGet, token => { - ModuleInfo found = ModuleLocator.Find(client, wanted, token); + ProcessSnapshot before = client.Processes.GetCurrentProcess(token); + ModuleInfo found = ModuleLocator.Find(client, wanted, out ImmutableArray modules, token); ImmutableArray sections = client.Inspection.GetModuleSections( new ModuleName(found.Name), new InspectionCollectionRequest(MaximumSections), token); PeHeaders? headers = TryParseHeaders(ModuleLocator.ReadHeaderBytes(client, found, token)); PdbReference? pdb = headers is null ? null : TryReadPdb(client, found, headers, token); ModuleSection[] copied = [.. sections.Select(section => Section(found, headers, section))]; - return new ModuleDetails(found.Name, HexFormat.Address(found.BaseAddress), found.PathToFile, + ModuleDetails details = new(found.Name, HexFormat.Address(found.BaseAddress), found.PathToFile, found.Is64Bit, copied, Size(found.ImageSize), headers is null ? null : Describe(found, headers, pdb)); + ProcessSnapshot after = client.Processes.GetCurrentProcess(token); + EnsureSameTarget(before, after, CheatEngineToolNames.ModuleGet); + PreparedModuleSnapshot snapshot = _prepared.PublishModules(before, modules); + CachePreparedDetails(snapshot.Version, found.Name, wanted, details); + return details; + }, cancellationToken); + } + + /// Reads a prepared page of modules after an explicit current-target module_list or module_get. + /// The latest prepared list expires after five seconds and this method performs no module enumeration. + /// The index of the first prepared module to return. + /// The most prepared modules to return. + /// The request's token. + /// The prepared concise module page. + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public ModuleList ListPrepared(int offset = 0, int limit = 200, CancellationToken cancellationToken = default) + { + _ = Paging.Slice(Array.Empty(), offset, limit, MaximumLimit); + return _dispatch.Run(CheatEngineToolNames.ModuleList, token => + { + ProcessSnapshot target = _dispatch.Client.Processes.GetCurrentProcess(token); + if (!_prepared.TryGetModules(target, out PreparedModuleSnapshot snapshot)) + { + throw PreparedMissing(CheatEngineToolNames.ModuleList); + } + + PageSlice page = Paging.Slice(snapshot.Modules, offset, limit, MaximumLimit); + ModuleEntry[] entries = [.. page.Items.Select(static module => new ModuleEntry(module.Name, + HexFormat.Address(module.BaseAddress), Size(module.ImageSize)))]; + return new ModuleList(target.Id.Value, page.Total, entries, page.NextOffset); + }, cancellationToken); + } + + /// Reads the latest prepared details after an explicit current-target module_get. + /// The prepared details expire with their five-second module list and are replaced by every later preparation. + /// The canonical module name or the exact normalized selector used by module_get. + /// The request's token. + /// A detached copy of the latest prepared module details. + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public ModuleDetails GetPrepared(string module, CancellationToken cancellationToken = default) + { + string wanted = ModuleLocator.RequireModule(module); + return _dispatch.Run(CheatEngineToolNames.ModuleGet, token => + { + ProcessSnapshot target = _dispatch.Client.Processes.GetCurrentProcess(token); + if (!_prepared.TryGetModules(target, out PreparedModuleSnapshot snapshot)) + { + throw PreparedMissing(CheatEngineToolNames.ModuleGet); + } + + lock (_preparedGate) + { + if (_preparedDetails is { } cached && cached.Version == snapshot.Version && + (cached.CanonicalName.Equals(wanted, StringComparison.OrdinalIgnoreCase) || + string.Equals(cached.Selector, wanted, StringComparison.Ordinal))) + { + return CloneDetails(cached.Details); + } + } + + throw PreparedMissing(CheatEngineToolNames.ModuleGet); }, cancellationToken); } @@ -207,4 +286,38 @@ private static ModuleSection Section(ModuleInfo module, PeHeaders? headers, Modu (long) Math.Min(section.Size.Value, long.MaxValue), HexFormat.Address(section.FileOffset.Value), header?.IsExecutable, header?.IsWritable); } + + private void CachePreparedDetails(PreparedInspectionVersion version, string canonicalName, string selector, + ModuleDetails details) + { + lock (_preparedGate) + { + _preparedDetails = new PreparedModuleDetails(version, canonicalName, selector, CloneDetails(details)); + } + } + + private static ModuleDetails CloneDetails(ModuleDetails details) + { + return new ModuleDetails(details.Name, details.Base, details.Path, details.Is64Bit, [.. details.Sections], + details.Size, details.Pe); + } + + private static void EnsureSameTarget(ProcessSnapshot before, ProcessSnapshot after, string operation) + { + if (before.Id != after.Id || before.SelectionEpoch != after.SelectionEpoch) + { + throw new CheatEngineToolException(new ToolError(ToolErrorKind.TargetChanged, + "The attached process changed while modules were being read.", operation, ToolHostEffect.Completed, false, + "Re-attach the process, then repeat the read.")); + } + } + + private static CheatEngineToolException PreparedMissing(string operation) + { + return CheatEngineToolException.InvalidState("No current prepared module result is available.", + $"Run {operation} explicitly, then retry within 5 seconds."); + } + + private sealed record PreparedModuleDetails(PreparedInspectionVersion Version, string CanonicalName, string Selector, + ModuleDetails Details); } diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerMapSlot.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerMapSlot.cs index b2e80b5..d3895f0 100644 --- a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerMapSlot.cs +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerMapSlot.cs @@ -252,7 +252,7 @@ internal PointerMapInfo Describe() int progress = _state is PointerJobState.Ready || _planned <= 0 ? 100 : (int) Math.Clamp(_attempted * 100 / _planned, 0, 100); - return new PointerMapInfo(Name, _job?.Id ?? string.Empty, _state, _processId, _width, _pointers, + return new PointerMapInfo(Name, _job?.Id, _state, _processId, _width, _pointers, _bytesRead, _unreadableBytes, _incomplete, progress, _error, _processId is null ? PointerCaptureCompleteness.Unknown : _incomplete ? PointerCaptureCompleteness.Incomplete : PointerCaptureCompleteness.Complete); diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerRecords.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerRecords.cs index 7dfc497..eb625c1 100644 --- a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerRecords.cs +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerRecords.cs @@ -132,7 +132,7 @@ public enum PointerCaptureCompleteness /// A pointer map and its capture. /// The map name. -/// The capture job's id. +/// The capture job's id, absent for an imported map. /// Where the capture stands. /// The captured process, or null for a native import. /// The pointer width, 4 or 8. @@ -146,8 +146,8 @@ public enum PointerCaptureCompleteness public sealed record PointerMapInfo( [property: Description("The map name.")] string MapName, - [property: Description("The capture job's id, for runtime_list_jobs and runtime_stop_job.")] - string JobId, + [property: Description("The capture job's id, for runtime_list_jobs and runtime_stop_job; omitted for an imported map.")] + string? JobId, [property: Description("Where the capture stands; the map is usable when ready, stopped or expired.")] PointerJobState State, [property: Description("The process the map was captured from.")] @@ -186,7 +186,7 @@ public sealed record PointerDeleteResult( /// A pointer path search and its results. /// The scan name. -/// The search job's id. +/// The search job's id, absent for an imported scan. /// Where the search stands. /// The searched map. /// The target address. @@ -199,8 +199,8 @@ public sealed record PointerDeleteResult( public sealed record PointerScanInfo( [property: Description("The scan name.")] string ScanName, - [property: Description("The search job's id, for runtime_list_jobs and runtime_stop_job.")] - string JobId, + [property: Description("The search job's id, for runtime_list_jobs and runtime_stop_job; omitted for an imported scan.")] + string? JobId, [property: Description("Where the search stands; the paths are usable when ready, stopped or expired.")] PointerJobState State, [property: Description("The searched map.")] diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerReferenceTools.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerReferenceTools.cs index 164ca2a..c72abcc 100644 --- a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerReferenceTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerReferenceTools.cs @@ -33,6 +33,7 @@ public sealed class PointerReferenceTools private readonly ToolDispatch _dispatch; private readonly TargetResources _resources; private readonly PointerStore _store; + private RangeSearchReservation? _rangeSearch; /// Creates the tool; nothing touches Cheat Engine until a call. /// The activation's dispatch facade. @@ -113,21 +114,43 @@ private PointerReferenceResult FindInMap(string mapName, string target, int maxO private PointerReferenceResult FindLive(string target, int maxOffset, string? moduleName, bool writableOnly, int limit, CancellationToken cancellationToken) { - return _dispatch.Run(CheatEngineToolNames.PointerFindReferences, token => + RangeSearchReservation? reservation = null; + if (maxOffset > 0) { - ICheatEngineClient client = _dispatch.Client; - int width = PointerSupport.Width(client.Processes.GetCurrentProcess(token)); - ulong address = PointerTargets.Check(PointerSupport.Resolve(client, target, token), width, "target"); - PointerModule[] modules = PointerSupport.Modules(client, null, token); - PointerModule? scope = moduleName is null ? null : ScopeModule(modules, moduleName); - ScanProtectionFilter protection = writableOnly - ? new ScanProtectionFilter(ScanProtectionRequirement.Unspecified, - ScanProtectionRequirement.Unspecified, ScanProtectionRequirement.Required) - : default; - return maxOffset == 0 - ? FindExact(client, address, width, scope, protection, limit, modules, token) - : FindRange(client, address, maxOffset, width, scope, protection, limit, modules, token); - }, cancellationToken); + reservation = new RangeSearchReservation(); + if (Interlocked.CompareExchange(ref _rangeSearch, reservation, null) is not null) + { + throw CheatEngineToolException.Busy( + "A temporary pointer-reference scan is running or still awaiting cleanup.", + "Wait for the current search, or release its retained scan with runtime_release_resources before repeating it."); + } + } + + try + { + return _dispatch.Run(CheatEngineToolNames.PointerFindReferences, token => + { + ICheatEngineClient client = _dispatch.Client; + int width = PointerSupport.Width(client.Processes.GetCurrentProcess(token)); + ulong address = PointerTargets.Check(PointerSupport.Resolve(client, target, token), width, "target"); + PointerModule[] modules = PointerSupport.Modules(client, null, token); + PointerModule? scope = moduleName is null ? null : ScopeModule(modules, moduleName); + ScanProtectionFilter protection = writableOnly + ? new ScanProtectionFilter(ScanProtectionRequirement.Unspecified, + ScanProtectionRequirement.Unspecified, ScanProtectionRequirement.Required) + : default; + return maxOffset == 0 + ? FindExact(client, address, width, scope, protection, limit, modules, token) + : FindRange(client, address, maxOffset, width, scope, protection, limit, modules, reservation!, token); + }, cancellationToken); + } + finally + { + if (reservation is not null && !reservation.Retained) + { + Interlocked.CompareExchange(ref _rangeSearch, null, reservation); + } + } } private static PointerReferenceResult FindExact(ICheatEngineClient client, ulong address, int width, @@ -174,7 +197,7 @@ .. result.Matches.Select(match => match.ToUInt64()).Order() private PointerReferenceResult FindRange(ICheatEngineClient client, ulong address, int maxOffset, int width, PointerModule? scope, ScanProtectionFilter protection, int limit, PointerModule[] modules, - CancellationToken cancellationToken) + RangeSearchReservation reservation, CancellationToken cancellationToken) { // Cheat Engine compares ordered scans of integers as signed values: a bound past the sign bit would invert. if (address > (width == 8 ? (ulong) long.MaxValue : int.MaxValue)) @@ -247,7 +270,7 @@ .. page.Matches.Select(static match => match.Address) LeaseReleaseOutcome cleanup = session.Release(); if (!cleanup.IsComplete) { - _resources.Track(session, "scan", name: CheatEngineToolNames.PointerFindReferences); + KeepFailedScan(session, reservation); } throw; @@ -256,7 +279,7 @@ .. page.Matches.Select(static match => match.Address) LeaseReleaseOutcome release = session.Release(); if (!release.IsComplete) { - ITargetResource kept = _resources.Track(session, "scan", name: CheatEngineToolNames.PointerFindReferences); + ITargetResource kept = KeepFailedScan(session, reservation); throw CheatEngineToolException.PartialEffect( $"The references were found, but the temporary value scan could not be released; it is kept as {kept.Descriptor.Id}.", ToolFailureMapping.MapHostEffect(release.HostEffect), @@ -268,6 +291,24 @@ .. page.Matches.Select(static match => match.Address) return result; } + private ITargetResource KeepFailedScan(IValueScanSession session, RangeSearchReservation reservation) + { + // Reserve before tracking: even a tracking failure must not permit another native scan to accumulate. + reservation.Retained = true; + return _resources.Track(session, "scan", + () => Interlocked.CompareExchange(ref _rangeSearch, null, reservation), + name: CheatEngineToolNames.PointerFindReferences); + } + + private sealed class RangeSearchReservation + { + internal bool Retained + { + get; + set; + } + } + private static PointerModule ScopeModule(IEnumerable modules, string name) { PointerModule[] matches = diff --git a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerScanSlot.cs b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerScanSlot.cs index 346391f..7ad418c 100644 --- a/srcs/CheatEngine.Mcp.Tools/Pointer/PointerScanSlot.cs +++ b/srcs/CheatEngine.Mcp.Tools/Pointer/PointerScanSlot.cs @@ -319,7 +319,7 @@ internal PointerScanInfo Describe() lock (_lock) { long elapsed = _elapsedMs ?? (long) Stopwatch.GetElapsedTime(_started).TotalMilliseconds; - return new PointerScanInfo(Name, _job?.Id ?? string.Empty, _state, MapName, HexFormat.Address(Target), + return new PointerScanInfo(Name, _job?.Id, _state, MapName, HexFormat.Address(Target), _count, _incomplete, _traversalLimited, _visitedNodes, elapsed, _error); } } diff --git a/srcs/CheatEngine.Mcp.Tools/Processes/ProcessTools.cs b/srcs/CheatEngine.Mcp.Tools/Processes/ProcessTools.cs index a9709e1..5da7588 100644 --- a/srcs/CheatEngine.Mcp.Tools/Processes/ProcessTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Processes/ProcessTools.cs @@ -43,11 +43,24 @@ public sealed class ProcessTools private const string ThreadListScript = """ assert(getOpenedProcessID() ~= 0, 'No process is attached.') - local threads = getThreadlist() - local result = {} - local maximum = a[1] - for i = 1, math.min(#threads, maximum) do result[i] = threads[i] end - return { threads = result, truncated = #threads > maximum } + local list = createStringList() + local ok, result = xpcall(function() + getThreadlist(list) + local count = list.Count + assert(type(count) == 'number' and count >= 0, 'getThreadlist returned an invalid count.') + local threads = {} + local maximum = a[1] + for i = 0, math.min(count, maximum) - 1 do + local threadId = tonumber(list.Strings[i], 16) + assert(threadId ~= nil, 'getThreadlist returned an invalid hexadecimal thread id.') + threads[#threads + 1] = threadId + end + return { threads = threads, truncated = count > maximum } + end, function(error) return error end) + local destroyed, destroyError = pcall(function() list.destroy() end) + if not destroyed then error(destroyError, 0) end + if not ok then error(result, 0) end + return result """; private const string PointerSizeScript = """ @@ -67,11 +80,17 @@ public sealed class ProcessTools private readonly TargetTransitionGuards _guards; private readonly Lock _pauseLock = new(); private readonly TargetResources _resources; + private readonly Lock _threadsLock = new(); + private readonly TimeProvider _time; private ITargetResource? _pause; + private PreparedThreads? _threads; + private long _threadsPublishedAt; + private long _threadsObservedEpoch = -1; + private int _threadsObservedProcessId; /// Creates the process tools without reading or changing Cheat Engine state. public ProcessTools(ToolDispatch dispatch, TargetResources resources, TargetTransitionGuards guards, - McpFilePaths files) + McpFilePaths files, TimeProvider? time = null) { ArgumentNullException.ThrowIfNull(dispatch); ArgumentNullException.ThrowIfNull(resources); @@ -81,6 +100,7 @@ public ProcessTools(ToolDispatch dispatch, TargetResources resources, TargetTran _files = files; _resources = resources; _guards = guards; + _time = time ?? TimeProvider.System; } /// Lists local processes available for an explicit attachment. @@ -309,12 +329,40 @@ public ProcessPausedResult SetPaused( /// Lists target threads through a bounded fixed Lua script. [McpServerTool(Name = CheatEngineToolNames.ProcessListThreads, Title = "List target threads", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] - [Description("List up to 4096 target thread ids reported by Cheat Engine. A larger host list is marked truncated.")] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.BlockingNative)] + [Description("List up to 4096 target thread ids reported by Cheat Engine. A larger host list is marked truncated. " + + "This explicit read prepares the target-thread resource snapshot for 5 seconds. Native thread enumeration " + + "can block Cheat Engine for seconds; the copy cap does not limit that enumeration.")] public ProcessThreadListResult ListThreads(CancellationToken cancellationToken = default) { - return _dispatch.RunLua(CheatEngineToolNames.ProcessListThreads, ThreadListScript, - ProcessJsonContext.Default.ProcessThreadListResult, cancellationToken, MaximumThreads); + return _dispatch.Run(CheatEngineToolNames.ProcessListThreads, token => + { + ICheatEngineClient client = _dispatch.Client; + ProcessSnapshot before = client.Processes.GetCurrentProcess(token); + ProcessThreadListResult listed = _dispatch.ExecuteLua(CheatEngineToolNames.ProcessListThreads, + ThreadListScript, ProcessJsonContext.Default.ProcessThreadListResult, token, MaximumThreads); + ProcessSnapshot after = client.Processes.GetCurrentProcess(token); + EnsureSameThreadTarget(before, after); + PublishThreads(before, listed); + return CopyThreads(listed); + }, cancellationToken); + } + + /// Reads the latest explicit target-thread preparation. + /// This method performs no Lua or native thread enumeration and its preparation expires after five seconds. + /// The request's cancellation token. + /// A copy of the latest prepared target-thread list. + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public ProcessThreadListResult ListPreparedThreads(CancellationToken cancellationToken = default) + { + PreparedThreads prepared = _dispatch.Run(CheatEngineToolNames.ProcessListThreads, token => + { + ProcessSnapshot current = _dispatch.Client.Processes.GetCurrentProcess(token); + return TryGetPreparedThreads(current, out PreparedThreads snapshot) + ? snapshot + : throw PreparedThreadsMissing(); + }, cancellationToken); + return new ProcessThreadListResult([.. prepared.Threads], prepared.Truncated); } /// Sets Cheat Engine's configured target pointer width. @@ -336,6 +384,89 @@ public ProcessPointerSizeResult SetPointerSize( ProcessJsonContext.Default.ProcessPointerSizeResult, cancellationToken, pointerSize); } + private void PublishThreads(ProcessSnapshot target, ProcessThreadListResult listed) + { + lock (_threadsLock) + { + if (!ObserveThreadTarget(target)) + { + throw ThreadTargetChanged(); + } + + _threads = new PreparedThreads(target.Id.Value, target.SelectionEpoch, [.. listed.Threads], listed.Truncated); + _threadsPublishedAt = _time.GetTimestamp(); + } + } + + private bool TryGetPreparedThreads(ProcessSnapshot target, out PreparedThreads snapshot) + { + lock (_threadsLock) + { + if (ObserveThreadTarget(target) && _threads is { } current && current.ProcessId == target.Id.Value && + current.SelectionEpoch == target.SelectionEpoch && IsThreadsFresh()) + { + snapshot = current; + return true; + } + } + + snapshot = null!; + return false; + } + + private bool ObserveThreadTarget(ProcessSnapshot target) + { + if (target.SelectionEpoch < _threadsObservedEpoch || + (target.SelectionEpoch == _threadsObservedEpoch && target.Id.Value != _threadsObservedProcessId)) + { + _threads = null; + return false; + } + + if (target.SelectionEpoch > _threadsObservedEpoch) + { + _threadsObservedEpoch = target.SelectionEpoch; + _threadsObservedProcessId = target.Id.Value; + _threads = null; + } + + return true; + } + + private bool IsThreadsFresh() + { + return _time.GetElapsedTime(_threadsPublishedAt, _time.GetTimestamp()) < TimeSpan.FromSeconds(5); + } + + private static ProcessThreadListResult CopyThreads(ProcessThreadListResult result) + { + return new ProcessThreadListResult([.. result.Threads], result.Truncated); + } + + private static void EnsureSameThreadTarget(ProcessSnapshot before, ProcessSnapshot after) + { + if (before.Id != after.Id || before.SelectionEpoch != after.SelectionEpoch) + { + throw ThreadTargetChanged(); + } + } + + private static CheatEngineToolException ThreadTargetChanged() + { + return new CheatEngineToolException(new ToolError(ToolErrorKind.TargetChanged, + "The attached process changed while target threads were being read; the completed host read was discarded.", + CheatEngineToolNames.ProcessListThreads, ToolHostEffect.Completed, false, + "Repeat process_list_threads for the current target.")); + } + + private static CheatEngineToolException PreparedThreadsMissing() + { + return CheatEngineToolException.InvalidState("No current prepared target-thread list is available.", + $"Run {CheatEngineToolNames.ProcessListThreads} explicitly, then retry within 5 seconds."); + } + + private sealed record PreparedThreads(int ProcessId, long SelectionEpoch, long[] Threads, bool Truncated); + /// /// Pauses the opened process inside the dispatch. A new pause is recorded under a fresh id and tracked; while /// MCP's tracked pause was recorded for the opened process, the call pauses again without a second record and diff --git a/srcs/CheatEngine.Mcp.Tools/Record/RecordClearTools.cs b/srcs/CheatEngine.Mcp.Tools/Record/RecordClearTools.cs index bade7a2..e584d44 100644 --- a/srcs/CheatEngine.Mcp.Tools/Record/RecordClearTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Record/RecordClearTools.cs @@ -42,7 +42,7 @@ local function enqueue(record) end local record = queue[index] if record.Type == vtAutoAssembler and record.Active and not a[1] then - return mcp.err('capability_disabled', 'An active Auto Assembler record requires Mcp:EnableAutoAssembler before the list can be cleared.', 'not_started') + return mcp.err('capability_disabled', 'An active Auto Assembler record requires Mcp:EnableAutoAssembler before the list can be cleared.', 'not_started', 'Set Mcp:EnableAutoAssembler to true in appsettings.json, then disable and re-enable the plugin.') end for child = 0, record.Count - 1 do enqueue(record.Child[child]) @@ -58,9 +58,9 @@ local function enqueue(record) /// Deletes every address-list record, including child records. [McpServerTool(Name = CheatEngineToolNames.RecordClear, Title = "Clear address list", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.MayPrompt)] [Description( - "Delete every Cheat Engine address-list record and return the number removed, including nested child records. Refuses lists above 100000 records before changing them. Active scripts may run their disable sections during clear.")] + "Delete every Cheat Engine address-list record and return the number removed, including nested child records. Refuses lists above 100000 records before changing them. Active scripts may run their disable sections during clear, which can block Cheat Engine or show a dialog.")] public RecordClearResult Clear(CancellationToken cancellationToken = default) { return dispatch.RunLua(CheatEngineToolNames.RecordClear, ClearScript, diff --git a/srcs/CheatEngine.Mcp.Tools/Record/RecordMutationTools.cs b/srcs/CheatEngine.Mcp.Tools/Record/RecordMutationTools.cs index 40b5138..38c3a83 100644 --- a/srcs/CheatEngine.Mcp.Tools/Record/RecordMutationTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Record/RecordMutationTools.cs @@ -56,7 +56,7 @@ public RecordEntry Select( /// Creates a bounded batch of records. [McpServerTool(Name = CheatEngineToolNames.RecordCreate, Title = "Create address-list records", ReadOnly = false, - Destructive = false, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] + Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)] [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] [Description( "Create 1 to 256 value, group or Auto Assembler records in order. Each record is built without touching " + @@ -180,9 +180,9 @@ public RecordUpdateResult Update( [McpServerTool(Name = CheatEngineToolNames.RecordSetActive, Title = "Set address-list records active", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.MayPrompt)] [Description( - "Set 1 to 256 records active or inactive, in order. Activating or deactivating an Auto Assembler record can " + + "Set 1 to 256 records active or inactive, in order. Scripts can block Cheat Engine or show a dialog. Activating or deactivating an Auto Assembler record can " + "execute its enable or disable section, so it requires Mcp:EnableAutoAssembler; so does a record whose Cheat " + "Engine option moActivateChildrenAsWell (when activating) or moDeactivateChildrenAsWell (when deactivating) " + "passes the change on to a nested Auto Assembler record, checked through at most 4096 nested records. A " + @@ -232,9 +232,9 @@ public RecordActivationResult SetActive( /// Deletes records in one bounded batch. [McpServerTool(Name = CheatEngineToolNames.RecordDelete, Title = "Delete address-list records", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.MayPrompt)] [Description( - "Delete 1 to 256 records by current id. Deleting a group can also remove or reparent its children according to Cheat Engine's address-list behavior. Deleting an active Auto Assembler record can execute its disable section and requires Mcp:EnableAutoAssembler. A failure after an earlier delete leaves those earlier deletions in effect.")] + "Delete 1 to 256 records by current id. Deleting a group can also remove or reparent its children according to Cheat Engine's address-list behavior. Deleting an active Auto Assembler record can execute its disable section and requires Mcp:EnableAutoAssembler. Script execution can block Cheat Engine or show a dialog. A failure after an earlier delete leaves those earlier deletions in effect.")] public RecordDeleteResult Delete( [Description("The distinct current record ids to delete, from 1 to 256 entries.")] int[] ids, @@ -344,6 +344,7 @@ public RecordGroupResult Group( [McpServerTool(Name = CheatEngineToolNames.RecordSetScript, Title = "Set address-list Auto Assembler script", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [RequiresFeature(McpFeature.AutoAssembler)] [Description( "Replace the stored Auto Assembler text of one existing, inactive Auto Assembler record. Storing the text " + "does not activate the record, but a later record_set_active can execute it. An active record, or one still " + @@ -361,6 +362,7 @@ public RecordScriptResult SetScript( RecordArguments.Id(id, "id"); ArgumentNullException.ThrowIfNull(script); RecordArguments.Script(script, "script"); + dispatch.Features.Require(McpFeature.AutoAssembler, CheatEngineToolNames.RecordSetScript); ICheatEngineClient client = dispatch.Client; return dispatch.Run(CheatEngineToolNames.RecordSetScript, token => { @@ -370,7 +372,6 @@ public RecordScriptResult SetScript( throw CheatEngineToolException.InvalidState("The address-list record is not an Auto Assembler record."); } - dispatch.Features.Require(McpFeature.AutoAssembler, CheatEngineToolNames.RecordSetScript); if (before.State.IsActive || before.State.IsAsyncProcessing) { throw CheatEngineToolException.InvalidState( diff --git a/srcs/CheatEngine.Mcp.Tools/Runtime/RuntimeTools.cs b/srcs/CheatEngine.Mcp.Tools/Runtime/RuntimeTools.cs index 6b17069..73457d0 100644 --- a/srcs/CheatEngine.Mcp.Tools/Runtime/RuntimeTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Runtime/RuntimeTools.cs @@ -84,9 +84,9 @@ public RuntimeResourceList ListResources(CancellationToken cancellationToken = d [McpServerTool(Name = CheatEngineToolNames.RuntimeReleaseResources, Title = "Release retained resources", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.MayPrompt)] [Description( - "Release retained resources newest first. includeOrphans also releases retained Lua state from earlier activations. acknowledgeIds only forgets resources already recovered manually; an incomplete cleanup is reported as partial_effect with its actual host effect.")] + "Release retained resources newest first. Releasing Auto Assembler patches runs their DISABLE sections, which can block Cheat Engine or show a dialog. includeOrphans also releases retained Lua state from earlier activations. acknowledgeIds only forgets resources already recovered manually; an incomplete cleanup is reported as partial_effect with its actual host effect.")] public RuntimeReleaseResourcesResult ReleaseResources( [Description("Also release orphaned or unmanaged Lua state from earlier plugin activations.")] bool includeOrphans = false, diff --git a/srcs/CheatEngine.Mcp.Tools/Scan/ScanRecords.cs b/srcs/CheatEngine.Mcp.Tools/Scan/ScanRecords.cs index d96d6e3..f2d2621 100644 --- a/srcs/CheatEngine.Mcp.Tools/Scan/ScanRecords.cs +++ b/srcs/CheatEngine.Mcp.Tools/Scan/ScanRecords.cs @@ -90,7 +90,7 @@ internal static class ScanStateText public sealed record ScanMatch( [property: Description("The matched address, uppercase hexadecimal without 0x.")] string Address, - [property: Description("The value text reported by Cheat Engine.")] + [property: Description("The formatted value text reported by Cheat Engine, which may contain read-failure markers. Empty text does not prove readability; use memory_read to verify.")] string Value); /// A page of value-scan matches. diff --git a/srcs/CheatEngine.Mcp.Tools/Scan/ScanScripts.cs b/srcs/CheatEngine.Mcp.Tools/Scan/ScanScripts.cs index 94b2bac..e26f657 100644 --- a/srcs/CheatEngine.Mcp.Tools/Scan/ScanScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/Scan/ScanScripts.cs @@ -22,13 +22,18 @@ local function valueType() if result == 'string' and f.cbUnicode.Checked then result = 'wstring' end return result end + local function openedProcessId() + local processId = getOpenedProcessID() + return math.type(processId) == 'integer' and processId > 0 and processId or nil + end local function isBusy() local repeating = f.findComponentByName('cbRepeatUntilStopped') - return getOpenedProcessID() ~= 0 and (not f.btnNewScan.Enabled or + return openedProcessId() ~= nil and (not f.btnNewScan.Enabled or (repeating ~= nil and repeating.Visible and repeating.Checked and ms.LastScanType ~= 'stNewScan')) end local function summary() - local attached = getOpenedProcessID() ~= 0 + local processId = openedProcessId() + local attached = processId ~= nil local busy = isBusy() local started = ms.LastScanType ~= 'stNewScan' local baseline = started and ms.LastScanWasRegionScan @@ -49,7 +54,7 @@ elseif baseline then end local count = state == 'ResultsReady' and ms.FoundList.Count or nil return {scannerName='main',mode='ui',state=state,isScanning=busy,resultsReady=state=='ResultsReady', - count=count,valueType=valueType(),processId=getOpenedProcessID(),error=failure ~= '' and failure or nil} + count=count,valueType=valueType(),processId=processId,error=failure ~= '' and failure or nil} end local function refuse(kind, message, hint) return mcp.err(kind, message, 'not_started', hint) @@ -61,7 +66,7 @@ local function busy() end end local function detached() - if getOpenedProcessID() == 0 then + if openedProcessId() == nil then return refuse('not_attached', 'No process is open in Cheat Engine.', 'Attach a process with process_attach.') end end diff --git a/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackJsonContext.cs b/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackJsonContext.cs index bbb1898..1866fbf 100644 --- a/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackJsonContext.cs +++ b/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackJsonContext.cs @@ -13,4 +13,5 @@ public sealed partial class SpeedhackJsonContext : JsonSerializerContext; /// Source-generated metadata for the fixed Lua copies used only inside the speedhack tool. [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] [JsonSerializable(typeof(LuaSpeedhackState))] +[JsonSerializable(typeof(SpeedhackRestoreFailure))] internal sealed partial class SpeedhackLuaJsonContext : JsonSerializerContext; diff --git a/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackRecords.cs b/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackRecords.cs index 37820f8..416b38c 100644 --- a/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackRecords.cs +++ b/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackRecords.cs @@ -20,7 +20,7 @@ public sealed record SpeedhackState( /// The observed state after a requested speed change. /// Cheat Engine's configured multiplier after the request. -/// Whether the speedhack_wantedspeed symbol exists, not whether a hook works. +/// Whether Cheat Engine reported that the speedhack_wantedspeed symbol exists after the request. /// /// Whether this request activated the speedhack in a process without the speedhack_wantedspeed symbol. Speed 1 /// never activates it and always reports false. Cheat Engine attempts its hooks again while the symbol is absent, @@ -32,9 +32,10 @@ public sealed record SpeedhackSetResult( double Speed, [property: Description( - "Whether the speedhack_wantedspeed symbol exists in the target; it also exists after a failed hook and " + - "on the Unity timeScale path, so it does not prove that the target's clocks are hooked.")] - bool HooksInstalled, + "Whether Cheat Engine reported that the speedhack_wantedspeed symbol exists in the target; omitted when it " + + "could not be observed after the change. The symbol also exists after a failed hook and on the Unity timeScale " + + "path, so it does not prove that the target's clocks are hooked.")] + bool? HooksInstalled, [property: Description( "Whether no speedhack_wantedspeed symbol existed before this request, so Cheat Engine attempted its " + @@ -48,4 +49,7 @@ public sealed record SpeedhackSetResult( string? ResourceId = null); /// The bounded result copied from a fixed speedhack Lua body. -internal sealed record LuaSpeedhackState(double Speed, bool HooksInstalled, bool FirstActivation = false); +internal sealed record LuaSpeedhackState(double Speed, bool? HooksInstalled, bool FirstActivation = false); + +/// The bounded cleanup context when MCP could not restore a prior speedhack resource. +internal sealed record SpeedhackRestoreFailure(string ResourceId, ResourceReleaseOutcome Release); diff --git a/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackScripts.cs b/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackScripts.cs index dddb877..5500df6 100644 --- a/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackScripts.cs @@ -5,17 +5,29 @@ namespace CheatEngine.Mcp.Tools.Speedhack; /// Fixed, bounded Lua bodies used by the speedhack tools. internal static class SpeedhackScripts { + private const string SymbolProbe = """ + local function speedhackSymbol(effect) + if type(getAddressSafe) ~= 'function' then + return nil, nil, mcp.err('host_refused', 'Cheat Engine does not provide getAddressSafe for the speedhack symbol.', effect) + end + local ok, address = pcall(getAddressSafe, 'speedhack_wantedspeed') + if not ok then + return nil, nil, mcp.err('host_refused', 'Cheat Engine could not resolve the speedhack symbol.', effect) + end + if address == nil or address == 0 then return false end + if math.type(address) == 'integer' then return true, address end + return nil, nil, mcp.err('host_refused', 'Cheat Engine returned an invalid speedhack symbol address.', effect) + end + """ + "\n"; + /// Reads the configured speed and the durable target symbol without changing Cheat Engine. - internal const string GetState = """ + internal const string GetState = SymbolProbe + """ local speed = speedhack_getSpeed() if type(speed) ~= 'number' or speed ~= speed or speed == math.huge or speed == -math.huge then return mcp.err('host_refused', 'Cheat Engine did not return a finite speedhack speed.', 'not_started') end - local hasSymbol = false - if type(getAddressSafe) == 'function' then - local ok, address = pcall(getAddressSafe, 'speedhack_wantedspeed') - hasSymbol = ok and math.type(address) == 'integer' - end + local hasSymbol, _, refused = speedhackSymbol('not_started') + if refused then return refused end return { speed = speed, hooksInstalled = hasSymbol } """; @@ -30,28 +42,23 @@ internal static class SpeedhackScripts /// is 1 too. It refuses, like a speed change, while the debugger is stopped or the target is paused, because /// the Unity path restores through mono_invoke_method. /// - internal const string SetNormal = """ + internal const string SetNormal = SymbolProbe + """ local pid = getOpenedProcessID() if math.type(pid) ~= 'integer' or pid <= 0 then return mcp.err('invalid_state', 'No process is attached.', 'not_started', 'Attach a local process with process_attach first.') end - local function symbol() - if type(getAddressSafe) ~= 'function' then return nil end - local ok, address = pcall(getAddressSafe, 'speedhack_wantedspeed') - if ok and math.type(address) == 'integer' then return address end - return nil - end local function finite(value) return type(value) == 'number' and value == value and value ~= math.huge and value ~= -math.huge end - local address = symbol() local current = speedhack_getSpeed() if not finite(current) then return mcp.err('host_refused', 'Cheat Engine did not return a finite speedhack speed.', 'not_started') end + local hasSymbol, address, refused = speedhackSymbol('not_started') + if refused then return refused end local normal = math.abs(current - 1) <= 0.000001 - if address == nil then + if not hasSymbol then -- Never sped up: setting 1 would tick Enable Speedhack and hook this process. if normal then return { speed = current, hooksInstalled = false, firstActivation = false } end return mcp.err('invalid_state', string.format('Cheat Engine reports speed %g, but the opened process has ' @@ -90,14 +97,16 @@ local function finite(value) if not finite(speed) then return mcp.err('host_refused', 'Cheat Engine did not return a finite speed after restoring it.', 'started') end - return { speed = speed, hooksInstalled = symbol() ~= nil, firstActivation = false } + local hooksInstalled, _, probeRefused = speedhackSymbol('started') + if probeRefused then hooksInstalled = nil end + return { speed = speed, hooksInstalled = hooksInstalled, firstActivation = false } """; /// /// Sets one valid speed and records a restore action in the Core-owned Lua state root. Arguments are speed, resource /// id and activation namespace. The body is deliberately fixed; callers only provide the number in a[1]. /// - internal static readonly string SetSpeed = LuaJobKernelScripts.Kernel + "\n" + """ + internal static readonly string SetSpeed = LuaJobKernelScripts.Kernel + "\n" + SymbolProbe + """ local pid = getOpenedProcessID() if math.type(pid) ~= 'integer' or pid <= 0 then return mcp.err('invalid_state', 'No process is attached.', 'not_started', 'Attach a local process with process_attach first.') @@ -122,21 +131,15 @@ local function finite(value) if type(targetIsX86) == 'function' and not targetIsX86() then return mcp.err('unsupported', 'Cheat Engine speedhack requires an x86 or x64 target.', 'not_started') end - local before = false - if type(getAddressSafe) == 'function' then - local ok, address = pcall(getAddressSafe, 'speedhack_wantedspeed') - before = ok and math.type(address) == 'integer' - end + local before, _, refused = speedhackSymbol('not_started') + if refused then return refused end speedhack_setSpeed(a[1]) local speed = speedhack_getSpeed() if type(speed) ~= 'number' or speed ~= speed or speed == math.huge or speed == -math.huge then return mcp.err('host_refused', 'Cheat Engine did not return a finite speed after changing it.', 'started') end - local after = before - if type(getAddressSafe) == 'function' then - local ok, address = pcall(getAddressSafe, 'speedhack_wantedspeed') - after = ok and math.type(address) == 'integer' - end + local after, _, probeRefused = speedhackSymbol('started') + if probeRefused then after = nil end resourceRecord(a[3], a[2], 'speedhack', { name = 'speedhack', detail = 'Restores the configured speed to 1.' }, function() -- Opening another process unticks Enable Speedhack without restoring this one; ticking it again would -- hook the newly opened process instead, so the restore needs manual recovery then. @@ -145,8 +148,9 @@ local function finite(value) -- speed, so the call is skipped only when the target has no hooks or its own speed is 1 too. local configured = speedhack_getSpeed() if type(configured) == 'number' and math.abs(configured - 1) <= 0.000001 then - local found, address = pcall(getAddressSafe, 'speedhack_wantedspeed') - if not found or math.type(address) ~= 'integer' then return end + local found, address, releaseRefused = speedhackSymbol('unknown') + if releaseRefused then error('Cheat Engine could not resolve the speedhack symbol while restoring.', 0) end + if not found then return end local read, wanted = pcall(readFloat, address) if read and type(wanted) == 'number' and math.abs(wanted - 1) <= 0.000001 then return end end diff --git a/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackTools.cs b/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackTools.cs index b312aa7..409c0e7 100644 --- a/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackTools.cs @@ -43,7 +43,14 @@ public SpeedhackState GetState(CancellationToken cancellationToken = default) { LuaSpeedhackState state = _dispatch.RunLua(CheatEngineToolNames.SpeedhackGetState, SpeedhackScripts.GetState, SpeedhackLuaJsonContext.Default.LuaSpeedhackState, cancellationToken); - return new SpeedhackState(state.Speed, state.HooksInstalled); + if (state.HooksInstalled is not { } hooksInstalled) + { + throw new CheatEngineToolException(new ToolError(ToolErrorKind.HostRefused, + "Cheat Engine did not return the speedhack symbol state.", CheatEngineToolNames.SpeedhackGetState, + ToolHostEffect.NotStarted, false)); + } + + return new SpeedhackState(state.Speed, hooksInstalled); } /// Installs or updates Cheat Engine speedhack, retaining an MCP restore action until speed returns to one. @@ -83,6 +90,7 @@ public SpeedhackSetResult SetSpeed( private SpeedhackSetResult SetSpeedCore(double speed, CancellationToken cancellationToken) { ITargetResource? previous; + bool priorRestoreApplied = false; lock (_resourceLock) { previous = _restoreResource; @@ -90,19 +98,22 @@ private SpeedhackSetResult SetSpeedCore(double speed, CancellationToken cancella if (previous is not null) { + _ = _dispatch.ExecuteLua(CheatEngineToolNames.SpeedhackSetSpeed, SpeedhackScripts.GetState, + SpeedhackLuaJsonContext.Default.LuaSpeedhackState, cancellationToken); ResourceReleaseOutcome released = previous.Release(cancellationToken); if (!released.IsComplete) { throw CheatEngineToolException.PartialEffect( "Cheat Engine could not restore the prior speedhack state before applying a new speed.", released.HostEffect, - new SpeedhackSetResult(1, false, false, previous.Descriptor.Id), - SpeedhackJsonContext.Default.SpeedhackSetResult, released.IsRetryable, + new SpeedhackRestoreFailure(previous.Descriptor.Id, released), + SpeedhackLuaJsonContext.Default.SpeedhackRestoreFailure, released.IsRetryable, released.IsRetryable ? "Repeat speedhack_set_speed after Cheat Engine accepts the restore." : "Set speedhack to 1 in Cheat Engine and acknowledge the resource after manual recovery."); } + priorRestoreApplied = released.HostEffect is not (ToolHostEffect.NotStarted or ToolHostEffect.NotApplied); _resources.Forget(previous); lock (_resourceLock) { @@ -113,25 +124,37 @@ private SpeedhackSetResult SetSpeedCore(double speed, CancellationToken cancella } } - if (speed == 1) + try { - LuaSpeedhackState normal = _dispatch.ExecuteLua(CheatEngineToolNames.SpeedhackSetSpeed, - SpeedhackScripts.SetNormal, SpeedhackLuaJsonContext.Default.LuaSpeedhackState, cancellationToken); - return new SpeedhackSetResult(normal.Speed, normal.HooksInstalled, normal.FirstActivation); - } + if (speed == 1) + { + LuaSpeedhackState normal = _dispatch.ExecuteLua(CheatEngineToolNames.SpeedhackSetSpeed, + SpeedhackScripts.SetNormal, SpeedhackLuaJsonContext.Default.LuaSpeedhackState, cancellationToken); + return new SpeedhackSetResult(normal.Speed, normal.HooksInstalled, normal.FirstActivation); + } - string id = _resources.NextId("speedhack"); - LuaSpeedhackState changed = _dispatch.ExecuteLua(CheatEngineToolNames.SpeedhackSetSpeed, - SpeedhackScripts.SetSpeed, SpeedhackLuaJsonContext.Default.LuaSpeedhackState, cancellationToken, speed, id, - _resources.Namespace); - ITargetResource tracked = _resources.TrackState(id, "speedhack", name: "speedhack", - detail: "Restores the configured speed to 1."); - lock (_resourceLock) + string id = _resources.NextId("speedhack"); + LuaSpeedhackState changed = _dispatch.ExecuteLua(CheatEngineToolNames.SpeedhackSetSpeed, + SpeedhackScripts.SetSpeed, SpeedhackLuaJsonContext.Default.LuaSpeedhackState, cancellationToken, speed, id, + _resources.Namespace); + ITargetResource tracked = _resources.TrackState(id, "speedhack", name: "speedhack", + detail: "Restores the configured speed to 1."); + lock (_resourceLock) + { + _restoreResource = tracked; + } + + return new SpeedhackSetResult(changed.Speed, changed.HooksInstalled, changed.FirstActivation, + tracked.Descriptor.Id); + } + catch (CheatEngineToolException exception) when (priorRestoreApplied && + exception.Error.HostEffect is ToolHostEffect.NotStarted or ToolHostEffect.NotApplied) { - _restoreResource = tracked; + throw new CheatEngineToolException(exception.Error with + { + HostEffect = ToolHostEffect.Started, + Retryable = false + }, exception); } - - return new SpeedhackSetResult(changed.Speed, changed.HooksInstalled, changed.FirstActivation, - tracked.Descriptor.Id); } } diff --git a/srcs/CheatEngine.Mcp.Tools/Structures/StructureLuaScripts.cs b/srcs/CheatEngine.Mcp.Tools/Structures/StructureLuaScripts.cs index f676746..11a8c19 100644 --- a/srcs/CheatEngine.Mcp.Tools/Structures/StructureLuaScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/Structures/StructureLuaScripts.cs @@ -6,11 +6,11 @@ namespace CheatEngine.Mcp.Tools.Structures; /// /// /// -/// Names are looked up with getStructure(name) and the result's name is checked, then the global list is -/// scanned, so a numeric-looking name never selects a structure by index. Element fields are read through the -/// documented accessors (Offset, Name, Vartype, getBytesize, ChildStruct, -/// getChildStructStart) and the published DisplayMethod; optional ones are read under -/// pcall. +/// Scripts that accept a structure name check the result of getStructure(name), then scan the global +/// list so a numeric-looking name never selects a structure by index. instead +/// uses a bounded indexed global lookup for structure_get. Element fields are read through the documented +/// accessors (Offset, Name, Vartype, getBytesize, ChildStruct, +/// getChildStructStart) and the published DisplayMethod; optional ones are read under pcall. /// /// /// Addresses that Cheat Engine parses itself (autoGuess, fillFromDotNetAddress) are passed as @@ -174,6 +174,70 @@ local function applySpec(e, f, child) elements = items, total = total, nextOffset = nextOffset} """; + /// + /// Finds one global definition by bounded indexed lookup, then copies only the requested page of its elements. + /// a: name, element offset, element limit, detailed format, the most global definitions to scan. + /// + internal const string DefinitionPage = """ + local function missing(name) + return mcp.err('not_found', 'No structure is named ' .. name .. ' (name).', 'not_started', + 'List the structures with structure_list; names are case-sensitive.') + end + local function optional(read, kind) + local ok, value = pcall(read) + if not ok then return nil end + if kind == 'integer' then + if math.type(value) == 'integer' then return value end + return nil + end + if type(value) == kind then return value end + return nil + end + local function describe(e, i, detailed) + local item = {index = i, offset = e.Offset, name = e.Name, vartype = e.Vartype, + display = optional(function() return e.DisplayMethod end, 'string'), byteSize = e.getBytesize()} + local child = e.ChildStruct + if child ~= nil then + item.childStructure = child.Name + if detailed then item.childStructureStart = e.getChildStructStart() end + end + if detailed and item.vartype == 13 then + item.customType = optional(function() return e.CustomTypeName end, 'string') + end + if detailed and item.vartype == 9 then + item.bitStart = optional(function() return e.BitStart end, 'integer') + item.bitSize = optional(function() return e.BitSize end, 'integer') + end + return item + end + local definitions = getStructureCount() + local scanned = math.min(definitions, a[5]) + local s = nil + for i = 0, scanned - 1 do + local candidate = getStructure(i) + if candidate ~= nil and candidate.Name == a[1] then + s = candidate + break + end + end + if s == nil then + if definitions > scanned then + return mcp.err('limit_exceeded', + 'structure_get scanned ' .. scanned .. ' of ' .. definitions .. ' global structures without finding ' .. a[1] .. '.', + 'not_started', 'Use structure_list to locate the structure within the bounded global lookup.') + end + return missing(a[1]) + end + local count = s.Count + local finish = math.min(count, a[2] + a[3]) + local items = {} + for i = a[2], finish - 1 do items[#items + 1] = describe(s.getElement(i), i, a[4]) end + local nextOffset = nil + if finish < count then nextOffset = finish end + local internal = optional(function() return s.Internal end, 'boolean') == true + return {name = s.Name, size = s.Size, elementCount = count, internal = internal, + elements = items, total = count, nextOffset = nextOffset} + """; /// Copies one element by index. a: name, index. internal const string ElementAt = Helpers + """ local s = findStructure(a[1]) diff --git a/srcs/CheatEngine.Mcp.Tools/Structures/StructureTools.cs b/srcs/CheatEngine.Mcp.Tools/Structures/StructureTools.cs index 73ef849..8ccd38d 100644 --- a/srcs/CheatEngine.Mcp.Tools/Structures/StructureTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Structures/StructureTools.cs @@ -59,9 +59,9 @@ public StructurePage List( Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] [Description( - "Reads one structure, internal ones included, and a page of its elements ordered by offset: index, hexadecimal " + - "offset, name, valueType, integer display, byteSize and child structure. The detailed format adds the child " + - "start, custom type and bit fields.")] + "Reads a global Structure Dissect definition after scanning at most 65,536 indexed definitions by exact case-sensitive " + + "name. Returns a page of its elements ordered by offset: index, hexadecimal offset, name, valueType, integer display, " + + "byteSize and child structure. The detailed format adds the child start, custom type and bit fields.")] public StructureDefinition Get( [Description("The structure's case-sensitive name.")] string name, @@ -77,8 +77,8 @@ public StructureDefinition Get( StructureArguments.Page(offset, limit, MaxGetLimit); bool detailed = format is ResultFormat.Detailed; StructureLuaDefinition definition = _dispatch.RunLua(CheatEngineToolNames.StructureGet, - StructureLuaScripts.Elements, StructureLuaJsonContext.Default.StructureLuaDefinition, cancellationToken, - structure, null, null, offset, limit, detailed); + StructureLuaScripts.DefinitionPage, StructureLuaJsonContext.Default.StructureLuaDefinition, cancellationToken, + structure, offset, limit, detailed, MaxScannedStructures); return new StructureDefinition(definition.Name, definition.Size, definition.ElementCount, definition.Internal, [.. definition.Elements.Select(element => Element(element, detailed))], definition.Total, definition.NextOffset); diff --git a/srcs/CheatEngine.Mcp.Tools/Symbol/SymbolRegistrationTools.cs b/srcs/CheatEngine.Mcp.Tools/Symbol/SymbolRegistrationTools.cs index 4d5db43..5849814 100644 --- a/srcs/CheatEngine.Mcp.Tools/Symbol/SymbolRegistrationTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Symbol/SymbolRegistrationTools.cs @@ -24,17 +24,24 @@ public sealed class SymbolRegistrationTools internal const int MaximumLimit = 1000; private const string ResourceKind = "symbol"; + private static readonly TimeSpan PreparedLifetime = TimeSpan.FromSeconds(5); private readonly ToolDispatch _dispatch; + private readonly Lock _preparedGate = new(); private readonly SymbolRegistrations _registrations; private readonly TargetResources _resources; + private readonly TimeProvider _time; + private PreparedRegisteredSymbols? _prepared; + private long _preparedGeneration; + private long _preparedPublication; /// Creates the tools; the constructor does no Cheat Engine work. /// The activation's dispatch facade. /// The activation's tracked target resources. /// The activation's owned symbols. + /// The monotonic clock used for prepared-list expiry, or the system clock. public SymbolRegistrationTools(ToolDispatch dispatch, TargetResources resources, - SymbolRegistrations registrations) + SymbolRegistrations registrations, TimeProvider? time = null) { ArgumentNullException.ThrowIfNull(dispatch); ArgumentNullException.ThrowIfNull(resources); @@ -42,6 +49,7 @@ public SymbolRegistrationTools(ToolDispatch dispatch, TargetResources resources, _dispatch = dispatch; _resources = resources; _registrations = registrations; + _time = time ?? TimeProvider.System; } /// Registers a symbol owned by this activation. @@ -66,6 +74,7 @@ public RegisteredSymbol Register( { string symbolName = RequireName(name); string expression = SymbolTools.RequireExpression(address, "address"); + InvalidatePrepared(); switch (_registrations.TryReserve(symbolName)) { case ReservationRefusal.AlreadyOwned: @@ -107,6 +116,8 @@ public RegisteredSymbol Register( } finally { + // A concurrent list started after the first invalidation cannot republish across this mutation's outcome. + InvalidatePrepared(); if (!committed) { _registrations.CancelReservation(symbolName); @@ -134,29 +145,37 @@ public SymbolReleaseResult Unregister( throw CheatEngineToolException.NotFound($"This activation owns no symbol named {symbolName}.", "List the owned symbols with symbol_list_registered."); } + InvalidatePrepared(); - return _dispatch.Run(CheatEngineToolNames.SymbolUnregister, token => + try { - ResourceReleaseOutcome outcome = owned.Resource.Release(token); - if (!outcome.IsRetryable) + return _dispatch.Run(CheatEngineToolNames.SymbolUnregister, token => { - _resources.Forget(owned.Resource); - _registrations.Remove(owned.Lease); - } + ResourceReleaseOutcome outcome = owned.Resource.Release(token); + if (!outcome.IsRetryable) + { + _resources.Forget(owned.Resource); + _registrations.Remove(owned.Lease); + } - SymbolReleaseResult result = new(owned.Name, HexFormat.Address(owned.Lease.Address), outcome); - if (!outcome.IsComplete) - { - throw CheatEngineToolException.PartialEffect( - $"The symbol {owned.Name} was not fully unregistered ({outcome.Kind}).", outcome.HostEffect, result, - SymbolJsonContext.Default.SymbolReleaseResult, outcome.IsRetryable, - outcome.IsRetryable - ? "Repeat symbol_unregister later." - : "Check the symbol with symbol_resolve and remove it manually; it is no longer tracked."); - } + SymbolReleaseResult result = new(owned.Name, HexFormat.Address(owned.Lease.Address), outcome); + if (!outcome.IsComplete) + { + throw CheatEngineToolException.PartialEffect( + $"The symbol {owned.Name} was not fully unregistered ({outcome.Kind}).", outcome.HostEffect, result, + SymbolJsonContext.Default.SymbolReleaseResult, outcome.IsRetryable, + outcome.IsRetryable + ? "Repeat symbol_unregister later." + : "Check the symbol with symbol_resolve and remove it manually; it is no longer tracked."); + } - return result; - }, cancellationToken); + return result; + }, cancellationToken); + } + finally + { + InvalidatePrepared(); + } } /// Pages Cheat Engine's registered symbols. @@ -167,9 +186,9 @@ public SymbolReleaseResult Unregister( /// The page. [McpServerTool(Name = CheatEngineToolNames.SymbolListRegistered, Title = "List registered symbols", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] - [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.BlockingNative)] [Description( - "Page every symbol registered in Cheat Engine (by tables, Auto Assembler scripts, Lua and this plugin), marking with ownedByMcp the ones this activation registered and can unregister. Allocation symbols carry their size. Filter with nameContains, then page with offset and limit (at most 1000); at most 8192 symbols are copied.")] + "Page every symbol registered in Cheat Engine (by tables, Auto Assembler scripts, Lua and this plugin), marking with ownedByMcp the ones this activation registered and can unregister. Allocation symbols carry their size. Filter with nameContains, then page with offset and limit (at most 1000); at most 8192 symbols are copied. Cheat Engine fully enumerates its registered-symbol table before that copy cap, so the native read can block Cheat Engine for seconds. A successful call explicitly prepares that unfiltered bounded copy for the symbols resource for five seconds.")] public RegisteredSymbolList ListRegistered( [Description("A case-insensitive substring of the symbol name; at most 256 characters.")] string? nameContains = null, @@ -186,9 +205,47 @@ public RegisteredSymbolList ListRegistered( } _ = Paging.Slice(Array.Empty(), offset, limit, MaximumLimit); - LuaRegisteredSymbols copied = _dispatch.RunLua(CheatEngineToolNames.SymbolListRegistered, - SymbolScripts.ListRegistered, SymbolLuaJsonContext.Default.LuaRegisteredSymbols, cancellationToken, - SymbolScripts.MaximumRegisteredSymbols); + LuaRegisteredSymbols copied = _dispatch.Run(CheatEngineToolNames.SymbolListRegistered, token => + { + long generation = PreparedGeneration(); + long publication = BeginPreparedPublication(); + LuaRegisteredSymbols listed = _dispatch.ExecuteLua(CheatEngineToolNames.SymbolListRegistered, + SymbolScripts.ListRegistered, SymbolLuaJsonContext.Default.LuaRegisteredSymbols, token, + SymbolScripts.MaximumRegisteredSymbols); + PublishPrepared(listed, generation, publication); + return listed; + }, cancellationToken); + return Project(copied, nameContains, offset, limit); + } + + /// Pages the latest explicitly prepared global registered-symbol copy. + /// + /// The copy expires after five seconds, contains at most 8192 symbols, and does not claim to bound Cheat Engine's + /// native enumeration that produced it. Ownership is evaluated when this method projects the cache. + /// + /// The index of the first symbol to return. + /// The most symbols to return. + /// The request's token. + /// The prepared unfiltered registered-symbol page. + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public RegisteredSymbolList ListPreparedRegistered(int offset = 0, int limit = 200, + CancellationToken cancellationToken = default) + { + _ = Paging.Slice(Array.Empty(), offset, limit, MaximumLimit); + return _dispatch.Run(CheatEngineToolNames.SymbolListRegistered, _ => + { + if (!TryGetPrepared(out LuaRegisteredSymbols copied)) + { + throw CheatEngineToolException.InvalidState("No current prepared registered-symbol copy is available.", + "Run symbol_list_registered explicitly, then retry within 5 seconds."); + } + + return Project(copied, null, offset, limit); + }, cancellationToken); + } + + private RegisteredSymbolList Project(LuaRegisteredSymbols copied, string? nameContains, int offset, int limit) + { Dictionary owned = _registrations.Snapshot(); LuaRegisteredSymbol[] matching = string.IsNullOrEmpty(nameContains) ? copied.Symbols @@ -211,6 +268,64 @@ .. page.Items.Select(symbol => return new RegisteredSymbolList(page.Total, page.Truncated, entries, page.NextOffset); } + private long PreparedGeneration() + { + lock (_preparedGate) + { + return _preparedGeneration; + } + } + + private long BeginPreparedPublication() + { + lock (_preparedGate) + { + return checked(++_preparedPublication); + } + } + + private void PublishPrepared(LuaRegisteredSymbols copied, long generation, long publication) + { + lock (_preparedGate) + { + if (_preparedGeneration == generation && _preparedPublication == publication) + { + _prepared = new PreparedRegisteredSymbols(Clone(copied), _time.GetTimestamp()); + } + } + } + + private bool TryGetPrepared(out LuaRegisteredSymbols copied) + { + lock (_preparedGate) + { + if (_prepared is { } current && _time.GetElapsedTime(current.PublishedAt, _time.GetTimestamp()) < PreparedLifetime) + { + copied = Clone(current.Symbols); + return true; + } + } + + copied = null!; + return false; + } + + private void InvalidatePrepared() + { + lock (_preparedGate) + { + _preparedGeneration++; + _prepared = null; + } + } + + private static LuaRegisteredSymbols Clone(LuaRegisteredSymbols copied) + { + return new LuaRegisteredSymbols([.. copied.Symbols], copied.Count, copied.Truncated); + } + + private sealed record PreparedRegisteredSymbols(LuaRegisteredSymbols Symbols, long PublishedAt); + private static string RequireName(string? name) { string trimmed = name?.Trim() ?? string.Empty; diff --git a/srcs/CheatEngine.Mcp.Tools/Symbol/SymbolScripts.cs b/srcs/CheatEngine.Mcp.Tools/Symbol/SymbolScripts.cs index 765d84a..294e99f 100644 --- a/srcs/CheatEngine.Mcp.Tools/Symbol/SymbolScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/Symbol/SymbolScripts.cs @@ -2,9 +2,9 @@ namespace CheatEngine.Mcp.Tools.Symbol; /// /// The fixed Lua bodies of the symbol_* tools. Caller data reaches them only through a[]; every copy they -/// return is bounded, and none of them waits for Cheat Engine's symbol loader. Only has Cheat -/// Engine build unbounded copies, of the main and the registered symbol lists, because Cheat Engine offers no other -/// way to enumerate them. +/// return is bounded, and none of them waits for Cheat Engine's symbol loader. and +/// make Cheat Engine build an unbounded native table before their bounded Lua copies: +/// respectively the main and registered symbol lists, and the registered symbol list alone. /// internal static class SymbolScripts { @@ -182,7 +182,9 @@ and find(name, pattern) then /// Copies at most 1024 names of getModulePreference(). internal const string GetModulePreference = """ local current = getModulePreference() - if type(current) ~= 'table' then current = {} end + if type(current) ~= 'table' then + return mcp.err('host_refused', 'Cheat Engine returned no module preference list.', 'not_started') + end local modules = {} local limit = math.min(#current, 1024) for i = 1, limit do modules[i] = tostring(current[i]) end diff --git a/srcs/CheatEngine.Mcp.Tools/Table/TableTools.cs b/srcs/CheatEngine.Mcp.Tools/Table/TableTools.cs index 67d3386..1b59802 100644 --- a/srcs/CheatEngine.Mcp.Tools/Table/TableTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Table/TableTools.cs @@ -68,7 +68,7 @@ public TableLoadResult Load( /// Saves the current table to a configured Client table root. [McpServerTool(Name = CheatEngineToolNames.TableSave, Title = "Save cheat table", ReadOnly = false, - Destructive = false, Idempotent = false, OpenWorld = true, UseStructuredContent = true)] + Destructive = true, Idempotent = false, OpenWorld = true, UseStructuredContent = true)] [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.MayPrompt)] [Description( "Save the current table through CheatEngine.Client to a .CT, .XML or .CETRAINER path under CheatEngineClient:AllowedTableRoots. overwrite must be true when a destination file already exists. A native save failure can leave a partially written destination.")] diff --git a/tests/CheatEngine.Mcp.Tests/Contract/ExactValuePipelineTests.cs b/tests/CheatEngine.Mcp.Tests/Contract/ExactValuePipelineTests.cs new file mode 100644 index 0000000..7e29f57 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/Contract/ExactValuePipelineTests.cs @@ -0,0 +1,174 @@ +using System.Globalization; +using System.Text.Json; + +using CheatEngine.Client.Memory; +using CheatEngine.Client.Results; +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Tests.Support; +using CheatEngine.Mcp.Tests.Tools.Memory; +using CheatEngine.Mcp.Tests.Tools.Pointer; +using CheatEngine.SDK.Engine.Values; + +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +namespace CheatEngine.Mcp.Tests.Contract; + +/// Exact textual numbers and unavailable values across real MCP serialization and gateway routing. +[Collection(nameof(SerialTestGroup))] +public sealed class ExactValuePipelineTests +{ + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Theory] + [InlineData("int64", "-9223372036854775808", "20000000000001")] + [InlineData("int64", "9223372036854775807", "20000000000001")] + [InlineData("uint64", "9007199254740993", "20000000000001")] + [InlineData("uint64", "18446744073709551615", "20000000000001")] + [InlineData("pointer", "FFFFFFFFFFFFFFFF", "20000000000001")] + [InlineData("uint8", "0", "FFFFFFFFFFFFFFFF")] + [InlineData("int32", "0", "1000")] + [InlineData("string", "", "1000")] + public async Task MemoryRead_ExactAndEmptyValues_RemainStringsThroughClientAndGateway(string valueType, + string expected, string address) + { + TargetDouble target = new(); + target.Memory = (method, arguments) => + { + if (valueType == "string") + { + Assert.Equal(nameof(IMemoryClient.ReadString), method.Name); + return string.Empty; + } + + Assert.Equal(nameof(IMemoryClient.ReadPrimitive), method.Name); + Assert.Equal(ulong.Parse(address, NumberStyles.HexNumber, CultureInfo.InvariantCulture), + Assert.IsType
(arguments[0]).ToUInt64()); + return valueType switch + { + "int64" => (object) long.Parse(expected, CultureInfo.InvariantCulture), + "uint64" => ulong.Parse(expected, CultureInfo.InvariantCulture), + "pointer" => new Address(ulong.MaxValue), + "uint8" => (byte) 0, + "int32" => 0, + _ => throw new InvalidOperationException("Unexpected value type.") + }; + }; + using TestActivation activation = new(target.Client); + await using TestMcpPipeline pipeline = await TestMcpPipeline.StartAsync(activation.Manifest, + McpPrimitiveBinding.FromTargets(activation.Targets)); + string arguments = $$"""{"address":"{{address}}","valueType":"{{valueType}}"}"""; + + CallToolResult direct = await pipeline.CallAsync(CheatEngineToolNames.MemoryRead, arguments); + JsonElement routed = await AssertGatewayRoundTripAsync(CheatEngineToolNames.MemoryRead, arguments, direct); + + Assert.Equal(address, routed.GetProperty("address").GetString()); + Assert.Equal(valueType, routed.GetProperty("valueType").GetString()); + Assert.Equal(JsonValueKind.String, routed.GetProperty("value").ValueKind); + Assert.Equal(expected, routed.GetProperty("value").GetString()); + Assert.False(routed.TryGetProperty("values", out _)); + Assert.Equal(1, target.Dispatcher.Calls); + Assert.Equal(0, target.Mutations); + } + + [Theory] + [InlineData("0", "FFFFFFFFFFFFFFFF", "FFFFFFFFFFFFFFFF")] + [InlineData("-8000000000000000", "FFFFFFFFFFFFFFFF", "7FFFFFFFFFFFFFFF")] + [InlineData("-1", "20000000000001", "20000000000000")] + public async Task PointerReadChain_SignedOffsetsAndWideAddresses_RoundTripWithoutNumericConversion(string offset, + string pointerValue, string expectedAddress) + { + await using PointerFixture fixture = new(); + fixture.PutPointer(PointerFixture.ModuleBase + 0x100, + ulong.Parse(pointerValue, NumberStyles.HexNumber, CultureInfo.InvariantCulture)); + await using TestMcpPipeline pipeline = await fixture.StartPipelineAsync(); + string arguments = $$"""{"base":"game.exe+100","offsets":["{{offset}}"]}"""; + + CallToolResult direct = await pipeline.CallAsync(CheatEngineToolNames.PointerReadChain, arguments); + JsonElement routed = await AssertGatewayRoundTripAsync(CheatEngineToolNames.PointerReadChain, arguments, + direct); + + JsonElement hop = Assert.Single(routed.GetProperty("hops").EnumerateArray()); + Assert.Equal(pointerValue, hop.GetProperty("pointer").GetString()); + Assert.Equal(offset, hop.GetProperty("offset").GetString()); + Assert.Equal(expectedAddress, routed.GetProperty("address").GetString()); + Assert.False(routed.TryGetProperty("value", out _)); + Assert.Equal(1, fixture.Dispatches); + } + + [Fact] + public async Task MemoryReadBatch_ZeroEmptyAndUnavailable_StayDistinctAcrossClientAndGateway() + { + TargetDouble target = new(); + int stringReads = 0; + target.Memory = (method, arguments) => + { + if (method.Name == nameof(IMemoryClient.ReadPrimitiveBatchDetailed)) + { + return new MemoryPrimitiveBatchReadOutcome(1, [0], null, null); + } + + Assert.Equal(nameof(IMemoryClient.TryReadString), method.Name); + bool available = ++stringReads == 1; + arguments[1] = available ? string.Empty : null; + arguments[2] = available ? default(CheatEngineFailure) : + new CheatEngineFailure(CheatEngineFailureKind.MemoryReadFailed, "Memory.ReadString", + "The fixture page is unreadable.", hostEffect: CheatEngineHostEffect.NotApplied); + return available; + }; + using TestActivation activation = new(target.Client); + await using TestMcpPipeline pipeline = await TestMcpPipeline.StartAsync(activation.Manifest, + McpPrimitiveBinding.FromTargets(activation.Targets)); + const string arguments = """ + {"items":[{"address":"1000","valueType":"int32"},{"address":"2000","valueType":"string"},{"address":"3000","valueType":"string"}]} + """; + + CallToolResult direct = await pipeline.CallAsync(CheatEngineToolNames.MemoryReadBatch, arguments); + JsonElement routed = await AssertGatewayRoundTripAsync(CheatEngineToolNames.MemoryReadBatch, arguments, direct); + + Assert.Equal(1, routed.GetProperty("failed").GetInt32()); + JsonElement items = routed.GetProperty("items"); + Assert.Equal(3, items.GetArrayLength()); + Assert.Equal("0", items[0].GetProperty("value").GetString()); + Assert.False(items[0].TryGetProperty("error", out _)); + Assert.Equal(string.Empty, items[1].GetProperty("value").GetString()); + Assert.False(items[1].TryGetProperty("error", out _)); + Assert.False(items[2].TryGetProperty("value", out _)); + Assert.Equal("memory_read_failed", items[2].GetProperty("error").GetProperty("kind").GetString()); + Assert.Equal(2, stringReads); + Assert.Equal(0, target.Mutations); + } + + private static async Task AssertGatewayRoundTripAsync(string tool, string argumentsJson, + CallToolResult direct) + { + Assert.False(direct.IsError is true, Assert.IsType(Assert.Single(direct.Content)).Text); + JsonElement expected = Assert.IsType(direct.StructuredContent); + await using GatewayTestHost gateway = await GatewayTestHost.StartAsync(); + await using FakeBackend backend = await FakeBackend.StartAsync(gateway.Registry, "exact-values"); + await using McpClient client = await gateway.ConnectAsync(); + backend.Respond = () => direct; + Dictionary routedArguments = backend.RoutedArguments(); + using JsonDocument arguments = JsonDocument.Parse(argumentsJson); + foreach (JsonProperty property in arguments.RootElement.EnumerateObject()) + { + routedArguments.Add(property.Name, property.Value.Clone()); + } + + CallToolResult routed = await client.CallToolAsync(tool, routedArguments, cancellationToken: Token); + + Assert.NotEqual(true, routed.IsError); + JsonElement actual = Assert.IsType(routed.StructuredContent); + Assert.Equal(expected.GetRawText(), actual.GetRawText()); + Assert.Equal(Assert.IsType(Assert.Single(direct.Content)).Text, + Assert.IsType(Assert.Single(routed.Content)).Text); + Assert.Equal(routedArguments.Count - 1, backend.LastArguments.Count); + foreach (JsonProperty property in arguments.RootElement.EnumerateObject()) + { + Assert.True(JsonElement.DeepEquals(property.Value, backend.LastArguments[property.Name])); + } + + Assert.Equal(1, backend.ForwardedCallCount); + return actual.Clone(); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-resources.json b/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-resources.json index 9bcdebc..c31e850 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-resources.json +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-resources.json @@ -22,7 +22,7 @@ "uri": "cheatengine://docs/auto-assembler", "description": "Auto Assembler syntax, commands and directives, how scripts are classified against the gates, what asm_check proves, the AOB injection scaffold, code patches, nearby allocation and the check-apply-verify-release cycle.", "mimeType": "text/markdown", - "size": 24851 + "size": 24884 }, { "name": "doc_ce_tutorial", @@ -62,7 +62,7 @@ "uri": "cheatengine://docs/configuration", "description": "Every plugin and gateway setting with its default and bounds: precedence, the capability gates and what each refuses, execution limits, file and table roots, environment variables and log files.", "mimeType": "text/markdown", - "size": 22399 + "size": 22923 }, { "name": "doc_connection_troubleshooting", @@ -70,7 +70,7 @@ "uri": "cheatengine://docs/connection-troubleshooting", "description": "Plugin, gateway and AI-client setup, and what to check when instance_list is empty, an instance is unavailable, or a call times out, is busy or is refused by a setting.", "mimeType": "text/markdown", - "size": 22321 + "size": 24299 }, { "name": "doc_debugger", @@ -78,7 +78,7 @@ "uri": "cheatengine://docs/debugger", "description": "Debugger interfaces and gates, the four breakpoint slots and methods, find-what-writes captures and data-hit heuristics, traces, registers and stepping, and how to leave the target running.", "mimeType": "text/markdown", - "size": 24208 + "size": 24415 }, { "name": "doc_emulators", @@ -94,7 +94,7 @@ "uri": "cheatengine://docs/errors-and-recovery", "description": "The error envelope and resource error codes, every failure kind and host effect with its next step, timeouts, stale ids, and releasing or acknowledging owned resources without repeating a side effect.", "mimeType": "text/markdown", - "size": 24662 + "size": 24739 }, { "name": "doc_game_engines", @@ -110,7 +110,7 @@ "uri": "cheatengine://docs/getting-started", "description": "Start here when new: what the plugin and gateway are, the first calls of a session, the settings that gate features, where the docs and prompts live, scope and consent, and how to release what MCP holds.", "mimeType": "text/markdown", - "size": 17384 + "size": 18388 }, { "name": "doc_glossary", @@ -126,7 +126,7 @@ "uri": "cheatengine://docs/kernel", "description": "The DBK driver and DBVM hypervisor tools: why they are usually unavailable, the kernel access switch and its gaps, loading DBVM, physical memory, page watches, hazards and recovery.", "mimeType": "text/markdown", - "size": 20223 + "size": 20314 }, { "name": "doc_lua", @@ -150,7 +150,7 @@ "uri": "cheatengine://docs/memory-model", "description": "Regions, protections, module sections, heaps, stacks and what static means in a Windows process; why a read fails, how to change protection safely, and which tool answers which question about an address.", "mimeType": "text/markdown", - "size": 20618 + "size": 20927 }, { "name": "doc_mono_and_dotnet", @@ -158,7 +158,7 @@ "uri": "cheatengine://docs/mono-and-dotnet", "description": "Choosing between the Mono and .NET collectors, what mono_attach and the Uses Mono option change, reading classes, fields, statics, instances and methods, and invoking or injecting managed code safely.", "mimeType": "text/markdown", - "size": 25424 + "size": 25774 }, { "name": "doc_pointers", @@ -166,7 +166,7 @@ "uri": "cheatengine://docs/pointers", "description": "Pointer notation and offset order, reading a chain, manual chains from a writer, pointer maps, path searches, rescans and their limits, ranking chains, storing them in records, and fallbacks.", "mimeType": "text/markdown", - "size": 28953 + "size": 29048 }, { "name": "doc_safety", @@ -174,7 +174,7 @@ "uri": "cheatengine://docs/safety", "description": "Read before the first change: allowed targets, consent, backups, what the four gates do and do not stop, what MCP tracks and how to undo the rest, and the DBK/DBVM and Windows-protection rules.", "mimeType": "text/markdown", - "size": 22330 + "size": 22469 }, { "name": "doc_speedhack", @@ -182,7 +182,7 @@ "uri": "cheatengine://docs/speedhack", "description": "How the speedhack hooks a game\u0027s clocks, its lasting host effects and how to restore normal speed.", "mimeType": "text/markdown", - "size": 21979 + "size": 22189 }, { "name": "doc_structures", @@ -190,7 +190,7 @@ "uri": "cheatengine://docs/structures", "description": "Structure layouts: finding and identifying a base (RTTI), .NET, Mono, PDB and autoguess sources, reading and retyping fields, recognising common data, comparing instances, rename, C header export and cleanup.", "mimeType": "text/markdown", - "size": 22410 + "size": 22920 }, { "name": "doc_tool_map", @@ -198,7 +198,7 @@ "uri": "cheatengine://docs/tool-map", "description": "Every tool by domain with its purpose, the setting (gate) it needs and its dispatch class; the running schema stays the authority for parameters and results.", "mimeType": "text/markdown", - "size": 25769 + "size": 26065 }, { "name": "doc_troubleshooting_scans", @@ -246,7 +246,7 @@ "uri": "cheatengine://docs/workflows", "description": "Read first: scope, session start, ids, scanners, jobs, cleanup, gates, errors, live resources and the index of guided workflows.", "mimeType": "text/markdown", - "size": 27034 + "size": 26983 }, { "name": "doc_x64_injection", @@ -420,7 +420,7 @@ "name": "instance_threads", "title": "Target threads", "uri": "cheatengine://instance/threads", - "description": "Up to 4096 thread ids of the attached target, marked truncated when Cheat Engine reports more. Its JSON is the structured result of process_list_threads.", + "description": "Up to 4096 thread ids of the attached target, marked truncated when Cheat Engine reports more. First run process_list_threads explicitly; this resource reads its prepared snapshot and must be read within 5 seconds.", "mimeType": "application/json", "annotations": { "audience": [ @@ -429,7 +429,8 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "process_list_threads" + "cheatengine/sourceTool": "process_list_threads", + "cheatengine/preparedProjection": "ListPreparedThreads" } } ], @@ -445,7 +446,7 @@ "name": "instance_breakpoints", "title": "Debugger breakpoints", "uriTemplate": "cheatengine://instance/debugger/breakpoints{?limit}", - "description": "The breakpoint addresses Cheat Engine reports, marking those this activation owns: 256 by default or ?limit=.. from 1 to 1024. Its JSON is the structured result of debugger_list_breakpoints.", + "description": "The latest breakpoint snapshot explicitly prepared by debugger_list_breakpoints, including ownership observed at preparation: 256 by default or ?limit=.. from 1 to 1024. The snapshot expires after five seconds; run debugger_list_breakpoints before reading this resource. Its JSON is the structured result of debugger_list_breakpoints.", "mimeType": "application/json", "annotations": { "audience": [ @@ -454,7 +455,8 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "debugger_list_breakpoints" + "cheatengine/sourceTool": "debugger_list_breakpoints", + "cheatengine/preparedProjection": "ListPreparedBreakpoints" } }, { @@ -493,7 +495,7 @@ "name": "instance_module", "title": "Module details", "uriTemplate": "cheatengine://instance/modules/{module}", - "description": "One module of the attached target, by name such as game.exe or by an address expression inside it (percent-encoded): base, size, path, sections and PE header fields, whose time stamp identifies the build. Its JSON is the structured result of module_get.", + "description": "The latest module details explicitly prepared for the attached target, by the exact module_get selector or its canonical name: base, size, path, sections and PE header fields. The latest result expires after five seconds; run module_get before reading this resource. Its JSON is the structured result of module_get.", "mimeType": "application/json", "annotations": { "audience": [ @@ -502,30 +504,15 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "module_get" - } - }, - { - "name": "instance_module_exports", - "title": "Module exports", - "uriTemplate": "cheatengine://instance/modules/{module}/exports{?offset,limit}", - "description": "A page of one module\u0027s exports parsed from its export directory: 200 from offset 0 by default, or ?offset=..\u0026limit=.. in that order (limit 1 to 1000). Its JSON is the structured result of module_list_exports; use that tool to filter by name.", - "mimeType": "application/json", - "annotations": { - "audience": [ - "assistant" - ], - "priority": 0.3 - }, - "_meta": { - "cheatengine/sourceTool": "module_list_exports" + "cheatengine/sourceTool": "module_get", + "cheatengine/preparedProjection": "GetPrepared" } }, { "name": "instance_modules", "title": "Loaded modules", "uriTemplate": "cheatengine://instance/modules{?offset,limit}", - "description": "A page of the modules loaded in the attached target: 200 from offset 0 by default, or ?offset=..\u0026limit=.. in that order (limit 1 to 1000). Its JSON is the structured result of module_list; use that tool to filter by name or read another process.", + "description": "A page of the latest modules explicitly prepared for the attached target: 200 from offset 0 by default, or ?offset=..\u0026limit=.. in that order (limit 1 to 1000). The preparation expires after five seconds. Its JSON is the structured result of module_list; run that tool without processId before reading this resource.", "mimeType": "application/json", "annotations": { "audience": [ @@ -534,7 +521,8 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "module_list" + "cheatengine/sourceTool": "module_list", + "cheatengine/preparedProjection": "ListPrepared" } }, { @@ -589,7 +577,7 @@ "name": "instance_regions", "title": "Memory regions", "uriTemplate": "cheatengine://instance/regions{?offset,limit}", - "description": "A page of the attached target\u0027s committed memory regions in address order: 100 from offset 0 by default (fewer than the tool\u0027s 500), or ?offset=..\u0026limit=.. in that order (limit 1 to 2000). Its JSON is the structured result of memory_list_regions; use that tool to filter by range, module, state, backing or protection.", + "description": "A page of the attached target\u0027s committed memory regions in address order: 100 from offset 0 by default (fewer than the tool\u0027s 500), or ?offset=..\u0026limit=.. in that order (limit 1 to 2000). First run memory_list_regions explicitly; this resource reads its prepared snapshot and must be read within 5 seconds. Use that tool to filter by range, module, state, backing or protection.", "mimeType": "application/json", "annotations": { "audience": [ @@ -598,7 +586,8 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "memory_list_regions" + "cheatengine/sourceTool": "memory_list_regions", + "cheatengine/preparedProjection": "ListPreparedRegions" } }, { @@ -653,7 +642,7 @@ "name": "instance_symbols", "title": "Registered symbols", "uriTemplate": "cheatengine://instance/symbols{?offset,limit}", - "description": "A page of the symbols registered in Cheat Engine, with ownedByMcp on those this activation can unregister: 200 from offset 0 by default, or ?offset=..\u0026limit=.. in that order (limit 1 to 1000); every read copies up to 8192 symbols. Its JSON is the structured result of symbol_list_registered; use that tool to filter by name.", + "description": "A page of the latest global registered-symbol copy explicitly prepared by symbol_list_registered, with ownedByMcp on those this activation can unregister: 200 from offset 0 by default, or ?offset=..\u0026limit=.. in that order (limit 1 to 1000). The copy expires after five seconds and contains at most 8192 symbols; that copy cap does not bound Cheat Engine\u0027s native enumeration. Run symbol_list_registered before reading this resource; use the tool to filter by name.", "mimeType": "application/json", "annotations": { "audience": [ @@ -662,7 +651,8 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "symbol_list_registered" + "cheatengine/sourceTool": "symbol_list_registered", + "cheatengine/preparedProjection": "ListPreparedRegistered" } } ] diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-tools.json b/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-tools.json index c24e898..5feadb4 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-tools.json +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/backend-tools.json @@ -1029,7 +1029,7 @@ { "name": "asm_release_patch", "title": "Release Auto Assembler patch", - "description": "Run the original DISABLE section for one MCP-owned patch. A failed or target-changed release can leave a patch in the old target and require manual recovery; retry only when retryable is true. A successful release removes the patch from runtime resources.", + "description": "Run the original DISABLE section for one MCP-owned patch. Script execution can block Cheat Engine or show a dialog. A failed or target-changed release can leave a patch in the old target and require manual recovery; retry only when retryable is true. A successful release removes the patch from runtime resources.", "inputSchema": { "type": "object", "properties": { @@ -1087,7 +1087,7 @@ "readOnlyHint": false }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "may_prompt" } }, { @@ -2523,7 +2523,7 @@ "annotations": { "title": "Start code search", "destructiveHint": false, - "idempotentHint": true, + "idempotentHint": false, "openWorldHint": false, "readOnlyHint": true }, @@ -2994,7 +2994,7 @@ { "name": "debugger_list_breakpoints", "title": "List debugger breakpoints", - "description": "Copies up to 1024 breakpoint addresses from Cheat Engine and identifies entries this MCP activation owns.", + "description": "Copies up to 1024 breakpoint addresses from Cheat Engine and identifies entries this MCP activation owns. A successful call explicitly prepares this snapshot for the breakpoints resource for five seconds; ownership fields are observations from that snapshot. The copy cap does not bound native enumeration, which can block Cheat Engine for seconds.", "inputSchema": { "type": "object", "properties": { @@ -3057,7 +3057,7 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { @@ -4085,7 +4085,7 @@ { "name": "dotnet_get_method_parameters", "title": "Get .NET method parameters", - "description": "Read a managed method\u0027s parameter names and CorElementType codes, and the signature text when the out-of-process collector reports it. Pass a module handle from dotnet_list_modules and a method token from dotnet_list_methods; this never injects into the target.", + "description": "Read a managed method\u0027s collector parameter entries and optional signature text. Each elementType is the collector\u0027s metadata-constant code, not the declared parameter type; use signature for declared types when available. Pass a module handle from dotnet_list_modules and a method token from dotnet_list_methods; this never injects into the target.", "inputSchema": { "type": "object", "properties": { @@ -4115,25 +4115,25 @@ "type": "string" }, "parameters": { - "description": "The parameters in declaration order, without the return value.", + "description": "The metadata entries in collector order; the list can be incomplete and include return metadata or unavailable placeholders.", "type": "array", "items": { "type": "object", "properties": { "index": { - "description": "The zero-based parameter position.", + "description": "The zero-based position in the returned collector entries, not necessarily the declared parameter ordinal.", "type": "integer" }, "name": { - "description": "The parameter name; empty when the metadata has none.", + "description": "The reported parameter name; empty when unnamed or unavailable.", "type": "string" }, "elementType": { - "description": "The parameter\u0027s CorElementType code, such as 8 for Int32 or 18 for a class reference.", + "description": "The collector\u0027s CType code for a metadata constant/default value, not the declared parameter type. 1 (Void) means no constant; 0 can mean unavailable metadata. Use signature for declared types.", "type": "integer" }, "elementTypeName": { - "description": "The element type\u0027s name, such as Int32, String or Class, when the code is known.", + "description": "The CorElementType name for elementType when known; this does not name the declared parameter type.", "type": "string", "default": null } @@ -5582,7 +5582,7 @@ { "name": "kernel_get_status", "title": "Get DBK and DBVM status", - "description": "Read whether DBK and DBVM are already initialized, plus any CR0, CR3, CR4 and DBVM real-CR4 values that Cheat Engine can read. It does not load a driver or initialize DBVM. Null register values are unavailable, not zero; call this before any kernel operation.", + "description": "Read whether DBK and DBVM are already initialized, plus any CR0, CR3, CR4 and DBVM real-CR4 values that Cheat Engine can read. It does not load a driver or initialize DBVM. Null status and register values are unavailable, not false or zero; call this before any kernel operation.", "inputSchema": { "type": "object", "properties": {} @@ -5591,9 +5591,11 @@ "type": "object", "properties": { "dbkInitialized": { + "description": "Whether DBK is initialized; omitted when Cheat Engine cannot report its state.", "type": "boolean" }, "dbvmInitialized": { + "description": "Whether DBVM is initialized; omitted when Cheat Engine cannot report its state.", "type": "boolean" }, "cr0": { @@ -5608,11 +5610,7 @@ "dbvmCr4": { "type": "string" } - }, - "required": [ - "dbkInitialized", - "dbvmInitialized" - ] + } }, "annotations": { "title": "Get DBK and DBVM status", @@ -5921,10 +5919,10 @@ }, "annotations": { "title": "Poll DBVM watch", - "destructiveHint": false, - "idempotentHint": true, + "destructiveHint": true, + "idempotentHint": false, "openWorldHint": false, - "readOnlyHint": true + "readOnlyHint": false }, "_meta": { "cheatengine/requires": [ @@ -6329,7 +6327,7 @@ "title": "Find Lua API", "destructiveHint": false, "idempotentHint": true, - "openWorldHint": false, + "openWorldHint": true, "readOnlyHint": true }, "_meta": { @@ -7011,7 +7009,7 @@ }, "annotations": { "title": "Dump memory to a file", - "destructiveHint": false, + "destructiveHint": true, "idempotentHint": true, "openWorldHint": true, "readOnlyHint": false @@ -7131,7 +7129,7 @@ { "name": "memory_get_address_info", "title": "Describe addresses", - "description": "Describe up to 256 addresses: Cheat Engine\u0027s symbol name, the containing module and section, whether it is a system module, the memory region (state, protection, backing) and optionally the pointer-sized value stored there and the RTTI class name of the object there. An address that does not resolve reports its own error in band.", + "description": "Describe up to 256 addresses: Cheat Engine\u0027s symbol name, the containing module and section, whether it is a system module when Cheat Engine can answer, the memory region (state, protection, backing) and optionally the pointer-sized value stored there and the RTTI class name of the object there. An address that does not resolve reports its own error in band.", "inputSchema": { "type": "object", "properties": { @@ -7186,9 +7184,9 @@ "default": null }, "isSystemModule": { - "description": "Whether Cheat Engine counts the address as inside a system module.", + "description": "Whether Cheat Engine counts the address as inside a system module; omitted when that host API is unavailable or fails.", "type": "boolean", - "default": false + "default": null }, "region": { "description": "The memory region that contains the address, when Cheat Engine reports one.", @@ -7414,7 +7412,7 @@ { "name": "memory_list_regions", "title": "List memory regions", - "description": "List the target\u0027s memory regions in address order, one page at a time, filtered by address range, module, state (committed by default), backing and the writable, executable and copy-on-write flags. The detailed format adds the allocation base, allocation protection and mapped file.", + "description": "List the target\u0027s memory regions in address order, one page at a time, filtered by address range, module, state (committed by default), backing and the writable, executable and copy-on-write flags. The detailed format adds the allocation base, allocation protection and mapped file. This explicit read prepares the default region resource snapshot for 5 seconds. Native enumeration of the address space can block Cheat Engine for seconds.", "inputSchema": { "type": "object", "properties": { @@ -7653,7 +7651,7 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { @@ -8729,7 +8727,7 @@ { "name": "module_get", "title": "Get module details", - "description": "Read one module of the attached process: base, size, file path, its sections (with executable and writable flags) and the fields of its mapped PE header: machine, COFF time stamp, entry point, subsystem, DLL and .NET flags and the PDB it names. The time stamp identifies the build, so keep it to tell later whether the game was updated. module is a name such as game.exe (case-insensitive) or any address expression inside the module.", + "description": "Read one module of the attached process: base, size, file path, its sections (with executable and writable flags) and the fields of its mapped PE header: machine, COFF time stamp, entry point, subsystem, DLL and .NET flags and the PDB it names. The time stamp identifies the build, so keep it to tell later whether the game was updated. module is a name such as game.exe (case-insensitive) or any address expression inside the module. This explicitly prepares the current module list and this latest module result for five seconds. Native module enumeration can block Cheat Engine for seconds.", "inputSchema": { "type": "object", "properties": { @@ -8913,13 +8911,13 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { "name": "module_list", "title": "List modules", - "description": "Page the modules (EXE and DLLs) loaded in the attached process, or in another process by processId, in Cheat Engine\u0027s order. concise returns name, base and size; detailed adds is64Bit and the file path. Filter with nameContains, then page with offset and limit (at most 1000). Use it to classify the runtime (mono, GameAssembly, coreclr) and to find a module for module_get.", + "description": "Page the modules (EXE and DLLs) loaded in the attached process, or in another process by processId, in Cheat Engine\u0027s order. concise returns name, base and size; detailed adds is64Bit and the file path. Filter with nameContains, then page with offset and limit (at most 1000). A successful attached-process call explicitly prepares the complete module list for five seconds; calls with processId do not. Native enumeration can block Cheat Engine for seconds on a large module set. Use it to classify the runtime (mono, GameAssembly, coreclr) and to find a module for module_get.", "inputSchema": { "type": "object", "properties": { @@ -9021,13 +9019,13 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { "name": "module_list_exports", "title": "List module exports", - "description": "Page the exports of one module of the attached process, parsed from its export directory in memory: name (omitted for ordinal-only exports), ordinal and address, or the forwarder such as NTDLL.RtlAllocateHeap for a forwarded export. Filter with nameContains, then page with offset and limit (at most 1000). A module whose export data exceeds 8 MiB or 65536 entries is refused.", + "description": "Page the exports of one module of the attached process, parsed from its export directory in memory: name (omitted for ordinal-only exports), ordinal and address, or the forwarder such as NTDLL.RtlAllocateHeap for a forwarded export. The whole directory is read in one dispatch before filtering and paging, holding Cheat Engine while it runs; large directories can take seconds. Filter with nameContains, then page with offset and limit (at most 1000). A module whose export data exceeds 8 MiB or 65536 entries is refused.", "inputSchema": { "type": "object", "properties": { @@ -9117,13 +9115,13 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "host_scan" } }, { "name": "module_list_imports", "title": "List module imports", - "description": "Page the imported functions of one module of the attached process, parsed from its import directory (and delay-load directory) in memory: the DLL, the IAT slot address the code calls through, the name and hint or the ordinal, the pointer the slot holds now, and the loaded module and Cheat Engine symbol that pointer lands in. A bound IAT without a lookup table gives slots without names; a delay-loaded slot points at this module\u0027s loader stub until its first call. A pointer outside the named DLL is not proof of a hook: api-ms-win-* sets resolve into kernelbase.dll and others. Filter with dllContains and nameContains, then page with offset and limit (at most 1000). A module whose import data exceeds 8 MiB or 65536 entries is refused.", + "description": "Page the imported functions of one module of the attached process, parsed from its import directory (and delay-load directory) in memory: the DLL, the IAT slot address the code calls through, the name and hint or the ordinal, the pointer the slot holds now, and the loaded module and Cheat Engine symbol that pointer lands in. A bound IAT without a lookup table gives slots without names; a delay-loaded slot points at this module\u0027s loader stub until its first call. A pointer outside the named DLL is not proof of a hook: api-ms-win-* sets resolve into kernelbase.dll and others. The whole directory is read in one dispatch before filtering and paging, holding Cheat Engine while it runs; large directories can take seconds. Filter with dllContains and nameContains, then page with offset and limit (at most 1000). A module whose import data exceeds 8 MiB or 65536 entries is refused.", "inputSchema": { "type": "object", "properties": { @@ -9248,7 +9246,7 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "host_scan" } }, { @@ -10515,7 +10513,7 @@ "type": "string" }, "jobId": { - "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported map.", "type": "string" }, "state": { @@ -10577,7 +10575,6 @@ }, "required": [ "mapName", - "jobId", "state", "pointerSize", "pointers", @@ -10755,7 +10752,7 @@ "type": "string" }, "jobId": { - "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported scan.", "type": "string" }, "state": { @@ -10807,7 +10804,6 @@ }, "required": [ "scanName", - "jobId", "state", "mapName", "target", @@ -11126,7 +11122,7 @@ "type": "string" }, "jobId": { - "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported map.", "type": "string" }, "state": { @@ -11188,7 +11184,6 @@ }, "required": [ "mapName", - "jobId", "state", "pointerSize", "pointers", @@ -11366,7 +11361,7 @@ "type": "string" }, "jobId": { - "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported scan.", "type": "string" }, "state": { @@ -11418,7 +11413,6 @@ }, "required": [ "scanName", - "jobId", "state", "mapName", "target", @@ -11492,7 +11486,7 @@ "type": "string" }, "jobId": { - "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported map.", "type": "string" }, "state": { @@ -11554,7 +11548,6 @@ }, "required": [ "mapName", - "jobId", "state", "pointerSize", "pointers", @@ -11623,7 +11616,7 @@ "type": "string" }, "jobId": { - "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported scan.", "type": "string" }, "state": { @@ -11675,7 +11668,6 @@ }, "required": [ "scanName", - "jobId", "state", "mapName", "target", @@ -12294,7 +12286,7 @@ "type": "string" }, "jobId": { - "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported map.", "type": "string" }, "state": { @@ -12356,7 +12348,6 @@ }, "required": [ "mapName", - "jobId", "state", "pointerSize", "pointers", @@ -12430,7 +12421,7 @@ "type": "string" }, "jobId": { - "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported scan.", "type": "string" }, "state": { @@ -12482,7 +12473,6 @@ }, "required": [ "scanName", - "jobId", "state", "mapName", "target", @@ -12735,7 +12725,7 @@ { "name": "process_list_threads", "title": "List target threads", - "description": "List up to 4096 target thread ids reported by Cheat Engine. A larger host list is marked truncated.", + "description": "List up to 4096 target thread ids reported by Cheat Engine. A larger host list is marked truncated. This explicit read prepares the target-thread resource snapshot for 5 seconds. Native thread enumeration can block Cheat Engine for seconds; the copy cap does not limit that enumeration.", "inputSchema": { "type": "object", "properties": {} @@ -12768,7 +12758,7 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { @@ -12970,7 +12960,7 @@ { "name": "record_clear", "title": "Clear address list", - "description": "Delete every Cheat Engine address-list record and return the number removed, including nested child records. Refuses lists above 100000 records before changing them. Active scripts may run their disable sections during clear.", + "description": "Delete every Cheat Engine address-list record and return the number removed, including nested child records. Refuses lists above 100000 records before changing them. Active scripts may run their disable sections during clear, which can block Cheat Engine or show a dialog.", "inputSchema": { "type": "object", "properties": {} @@ -12995,7 +12985,7 @@ "readOnlyHint": false }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "may_prompt" } }, { @@ -13216,7 +13206,7 @@ }, "annotations": { "title": "Create address-list records", - "destructiveHint": false, + "destructiveHint": true, "idempotentHint": false, "openWorldHint": false, "readOnlyHint": false @@ -13228,7 +13218,7 @@ { "name": "record_delete", "title": "Delete address-list records", - "description": "Delete 1 to 256 records by current id. Deleting a group can also remove or reparent its children according to Cheat Engine\u0027s address-list behavior. Deleting an active Auto Assembler record can execute its disable section and requires Mcp:EnableAutoAssembler. A failure after an earlier delete leaves those earlier deletions in effect.", + "description": "Delete 1 to 256 records by current id. Deleting a group can also remove or reparent its children according to Cheat Engine\u0027s address-list behavior. Deleting an active Auto Assembler record can execute its disable section and requires Mcp:EnableAutoAssembler. Script execution can block Cheat Engine or show a dialog. A failure after an earlier delete leaves those earlier deletions in effect.", "inputSchema": { "type": "object", "properties": { @@ -13264,7 +13254,7 @@ "readOnlyHint": false }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "may_prompt" } }, { @@ -14600,7 +14590,7 @@ { "name": "record_set_active", "title": "Set address-list records active", - "description": "Set 1 to 256 records active or inactive, in order. Activating or deactivating an Auto Assembler record can execute its enable or disable section, so it requires Mcp:EnableAutoAssembler; so does a record whose Cheat Engine option moActivateChildrenAsWell (when activating) or moDeactivateChildrenAsWell (when deactivating) passes the change on to a nested Auto Assembler record, checked through at most 4096 nested records. A record Cheat Engine refuses keeps its state and the call goes on: its active then differs from requestedActive and failure says why, with Cheat Engine\u0027s own reason and message for a failed activation, such as aob_not_found or assert_failed. pending means Cheat Engine accepted an asynchronous activation whose final state must be read later with record_get; the reason of a failure that comes after the call is not reported. An error on a later record leaves the earlier records changed.", + "description": "Set 1 to 256 records active or inactive, in order. Scripts can block Cheat Engine or show a dialog. Activating or deactivating an Auto Assembler record can execute its enable or disable section, so it requires Mcp:EnableAutoAssembler; so does a record whose Cheat Engine option moActivateChildrenAsWell (when activating) or moDeactivateChildrenAsWell (when deactivating) passes the change on to a nested Auto Assembler record, checked through at most 4096 nested records. A record Cheat Engine refuses keeps its state and the call goes on: its active then differs from requestedActive and failure says why, with Cheat Engine\u0027s own reason and message for a failed activation, such as aob_not_found or assert_failed. pending means Cheat Engine accepted an asynchronous activation whose final state must be read later with record_get; the reason of a failure that comes after the call is not reported. An error on a later record leaves the earlier records changed.", "inputSchema": { "type": "object", "properties": { @@ -14702,7 +14692,7 @@ "readOnlyHint": false }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "may_prompt" } }, { @@ -15082,6 +15072,9 @@ "readOnlyHint": false }, "_meta": { + "cheatengine/requires": [ + "auto_assembler" + ], "cheatengine/dispatchClass": "short" } }, @@ -15850,7 +15843,7 @@ { "name": "runtime_release_resources", "title": "Release retained resources", - "description": "Release retained resources newest first. includeOrphans also releases retained Lua state from earlier activations. acknowledgeIds only forgets resources already recovered manually; an incomplete cleanup is reported as partial_effect with its actual host effect.", + "description": "Release retained resources newest first. Releasing Auto Assembler patches runs their DISABLE sections, which can block Cheat Engine or show a dialog. includeOrphans also releases retained Lua state from earlier activations. acknowledgeIds only forgets resources already recovered manually; an incomplete cleanup is reported as partial_effect with its actual host effect.", "inputSchema": { "type": "object", "properties": { @@ -16165,7 +16158,7 @@ "readOnlyHint": false }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "may_prompt" } }, { @@ -16726,7 +16719,7 @@ "type": "string" }, "value": { - "description": "The value text reported by Cheat Engine.", + "description": "The formatted value text reported by Cheat Engine, which may contain read-failure markers. Empty text does not prove readability; use memory_read to verify.", "type": "string" } }, @@ -17210,7 +17203,7 @@ "type": "number" }, "hooksInstalled": { - "description": "Whether the speedhack_wantedspeed symbol exists in the target; it also exists after a failed hook and on the Unity timeScale path, so it does not prove that the target\u0027s clocks are hooked.", + "description": "Whether Cheat Engine reported that the speedhack_wantedspeed symbol exists in the target; omitted when it could not be observed after the change. The symbol also exists after a failed hook and on the Unity timeScale path, so it does not prove that the target\u0027s clocks are hooked.", "type": "boolean" }, "firstActivation": { @@ -17225,7 +17218,6 @@ }, "required": [ "speed", - "hooksInstalled", "firstActivation" ] }, @@ -17917,7 +17909,7 @@ { "name": "structure_get", "title": "Get a structure", - "description": "Reads one structure, internal ones included, and a page of its elements ordered by offset: index, hexadecimal offset, name, valueType, integer display, byteSize and child structure. The detailed format adds the child start, custom type and bit fields.", + "description": "Reads a global Structure Dissect definition after scanning at most 65,536 indexed definitions by exact case-sensitive name. Returns a page of its elements ordered by offset: index, hexadecimal offset, name, valueType, integer display, byteSize and child structure. The detailed format adds the child start, custom type and bit fields.", "inputSchema": { "type": "object", "properties": { @@ -19017,7 +19009,7 @@ { "name": "symbol_list_registered", "title": "List registered symbols", - "description": "Page every symbol registered in Cheat Engine (by tables, Auto Assembler scripts, Lua and this plugin), marking with ownedByMcp the ones this activation registered and can unregister. Allocation symbols carry their size. Filter with nameContains, then page with offset and limit (at most 1000); at most 8192 symbols are copied.", + "description": "Page every symbol registered in Cheat Engine (by tables, Auto Assembler scripts, Lua and this plugin), marking with ownedByMcp the ones this activation registered and can unregister. Allocation symbols carry their size. Filter with nameContains, then page with offset and limit (at most 1000); at most 8192 symbols are copied. Cheat Engine fully enumerates its registered-symbol table before that copy cap, so the native read can block Cheat Engine for seconds. A successful call explicitly prepares that unfiltered bounded copy for the symbols resource for five seconds.", "inputSchema": { "type": "object", "properties": { @@ -19115,7 +19107,7 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { @@ -19768,7 +19760,7 @@ }, "annotations": { "title": "Save cheat table", - "destructiveHint": false, + "destructiveHint": true, "idempotentHint": false, "openWorldHint": true, "readOnlyHint": false diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-resources.json b/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-resources.json index c11644f..6b042e8 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-resources.json +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-resources.json @@ -22,7 +22,7 @@ "uri": "cheatengine://docs/auto-assembler", "description": "Auto Assembler syntax, commands and directives, how scripts are classified against the gates, what asm_check proves, the AOB injection scaffold, code patches, nearby allocation and the check-apply-verify-release cycle.", "mimeType": "text/markdown", - "size": 24851 + "size": 24884 }, { "name": "doc_ce_tutorial", @@ -62,7 +62,7 @@ "uri": "cheatengine://docs/configuration", "description": "Every plugin and gateway setting with its default and bounds: precedence, the capability gates and what each refuses, execution limits, file and table roots, environment variables and log files.", "mimeType": "text/markdown", - "size": 22399 + "size": 22923 }, { "name": "doc_connection_troubleshooting", @@ -70,7 +70,7 @@ "uri": "cheatengine://docs/connection-troubleshooting", "description": "Plugin, gateway and AI-client setup, and what to check when instance_list is empty, an instance is unavailable, or a call times out, is busy or is refused by a setting.", "mimeType": "text/markdown", - "size": 22321 + "size": 24299 }, { "name": "doc_debugger", @@ -78,7 +78,7 @@ "uri": "cheatengine://docs/debugger", "description": "Debugger interfaces and gates, the four breakpoint slots and methods, find-what-writes captures and data-hit heuristics, traces, registers and stepping, and how to leave the target running.", "mimeType": "text/markdown", - "size": 24208 + "size": 24415 }, { "name": "doc_emulators", @@ -94,7 +94,7 @@ "uri": "cheatengine://docs/errors-and-recovery", "description": "The error envelope and resource error codes, every failure kind and host effect with its next step, timeouts, stale ids, and releasing or acknowledging owned resources without repeating a side effect.", "mimeType": "text/markdown", - "size": 24662 + "size": 24739 }, { "name": "doc_game_engines", @@ -110,7 +110,7 @@ "uri": "cheatengine://docs/getting-started", "description": "Start here when new: what the plugin and gateway are, the first calls of a session, the settings that gate features, where the docs and prompts live, scope and consent, and how to release what MCP holds.", "mimeType": "text/markdown", - "size": 17384 + "size": 18388 }, { "name": "doc_glossary", @@ -126,7 +126,7 @@ "uri": "cheatengine://docs/kernel", "description": "The DBK driver and DBVM hypervisor tools: why they are usually unavailable, the kernel access switch and its gaps, loading DBVM, physical memory, page watches, hazards and recovery.", "mimeType": "text/markdown", - "size": 20223 + "size": 20314 }, { "name": "doc_lua", @@ -150,7 +150,7 @@ "uri": "cheatengine://docs/memory-model", "description": "Regions, protections, module sections, heaps, stacks and what static means in a Windows process; why a read fails, how to change protection safely, and which tool answers which question about an address.", "mimeType": "text/markdown", - "size": 20618 + "size": 20927 }, { "name": "doc_mono_and_dotnet", @@ -158,7 +158,7 @@ "uri": "cheatengine://docs/mono-and-dotnet", "description": "Choosing between the Mono and .NET collectors, what mono_attach and the Uses Mono option change, reading classes, fields, statics, instances and methods, and invoking or injecting managed code safely.", "mimeType": "text/markdown", - "size": 25424 + "size": 25774 }, { "name": "doc_pointers", @@ -166,7 +166,7 @@ "uri": "cheatengine://docs/pointers", "description": "Pointer notation and offset order, reading a chain, manual chains from a writer, pointer maps, path searches, rescans and their limits, ranking chains, storing them in records, and fallbacks.", "mimeType": "text/markdown", - "size": 28953 + "size": 29048 }, { "name": "doc_safety", @@ -174,7 +174,7 @@ "uri": "cheatengine://docs/safety", "description": "Read before the first change: allowed targets, consent, backups, what the four gates do and do not stop, what MCP tracks and how to undo the rest, and the DBK/DBVM and Windows-protection rules.", "mimeType": "text/markdown", - "size": 22330 + "size": 22469 }, { "name": "doc_speedhack", @@ -182,7 +182,7 @@ "uri": "cheatengine://docs/speedhack", "description": "How the speedhack hooks a game\u0027s clocks, its lasting host effects and how to restore normal speed.", "mimeType": "text/markdown", - "size": 21979 + "size": 22189 }, { "name": "doc_structures", @@ -190,7 +190,7 @@ "uri": "cheatengine://docs/structures", "description": "Structure layouts: finding and identifying a base (RTTI), .NET, Mono, PDB and autoguess sources, reading and retyping fields, recognising common data, comparing instances, rename, C header export and cleanup.", "mimeType": "text/markdown", - "size": 22410 + "size": 22920 }, { "name": "doc_tool_map", @@ -198,7 +198,7 @@ "uri": "cheatengine://docs/tool-map", "description": "Every tool by domain with its purpose, the setting (gate) it needs and its dispatch class; the running schema stays the authority for parameters and results.", "mimeType": "text/markdown", - "size": 25769 + "size": 26065 }, { "name": "doc_troubleshooting_scans", @@ -246,7 +246,7 @@ "uri": "cheatengine://docs/workflows", "description": "Read first: scope, session start, ids, scanners, jobs, cleanup, gates, errors, live resources and the index of guided workflows.", "mimeType": "text/markdown", - "size": 27034 + "size": 26983 }, { "name": "doc_x64_injection", @@ -302,7 +302,7 @@ "name": "instance_breakpoints", "title": "Debugger breakpoints", "uriTemplate": "cheatengine://instances/{instanceId}/debugger/breakpoints{?limit}", - "description": "The breakpoint addresses Cheat Engine reports, marking those this activation owns: 256 by default or ?limit=.. from 1 to 1024. Its JSON is the structured result of debugger_list_breakpoints.", + "description": "The latest breakpoint snapshot explicitly prepared by debugger_list_breakpoints, including ownership observed at preparation: 256 by default or ?limit=.. from 1 to 1024. The snapshot expires after five seconds; run debugger_list_breakpoints before reading this resource. Its JSON is the structured result of debugger_list_breakpoints.", "mimeType": "application/json", "annotations": { "audience": [ @@ -311,7 +311,8 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "debugger_list_breakpoints" + "cheatengine/sourceTool": "debugger_list_breakpoints", + "cheatengine/preparedProjection": "ListPreparedBreakpoints" } }, { @@ -366,7 +367,7 @@ "name": "instance_module", "title": "Module details", "uriTemplate": "cheatengine://instances/{instanceId}/modules/{module}", - "description": "One module of the attached target, by name such as game.exe or by an address expression inside it (percent-encoded): base, size, path, sections and PE header fields, whose time stamp identifies the build. Its JSON is the structured result of module_get.", + "description": "The latest module details explicitly prepared for the attached target, by the exact module_get selector or its canonical name: base, size, path, sections and PE header fields. The latest result expires after five seconds; run module_get before reading this resource. Its JSON is the structured result of module_get.", "mimeType": "application/json", "annotations": { "audience": [ @@ -375,30 +376,15 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "module_get" - } - }, - { - "name": "instance_module_exports", - "title": "Module exports", - "uriTemplate": "cheatengine://instances/{instanceId}/modules/{module}/exports{?offset,limit}", - "description": "A page of one module\u0027s exports parsed from its export directory: 200 from offset 0 by default, or ?offset=..\u0026limit=.. in that order (limit 1 to 1000). Its JSON is the structured result of module_list_exports; use that tool to filter by name.", - "mimeType": "application/json", - "annotations": { - "audience": [ - "assistant" - ], - "priority": 0.3 - }, - "_meta": { - "cheatengine/sourceTool": "module_list_exports" + "cheatengine/sourceTool": "module_get", + "cheatengine/preparedProjection": "GetPrepared" } }, { "name": "instance_modules", "title": "Loaded modules", "uriTemplate": "cheatengine://instances/{instanceId}/modules{?offset,limit}", - "description": "A page of the modules loaded in the attached target: 200 from offset 0 by default, or ?offset=..\u0026limit=.. in that order (limit 1 to 1000). Its JSON is the structured result of module_list; use that tool to filter by name or read another process.", + "description": "A page of the latest modules explicitly prepared for the attached target: 200 from offset 0 by default, or ?offset=..\u0026limit=.. in that order (limit 1 to 1000). The preparation expires after five seconds. Its JSON is the structured result of module_list; run that tool without processId before reading this resource.", "mimeType": "application/json", "annotations": { "audience": [ @@ -407,7 +393,8 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "module_list" + "cheatengine/sourceTool": "module_list", + "cheatengine/preparedProjection": "ListPrepared" } }, { @@ -526,7 +513,7 @@ "name": "instance_regions", "title": "Memory regions", "uriTemplate": "cheatengine://instances/{instanceId}/regions{?offset,limit}", - "description": "A page of the attached target\u0027s committed memory regions in address order: 100 from offset 0 by default (fewer than the tool\u0027s 500), or ?offset=..\u0026limit=.. in that order (limit 1 to 2000). Its JSON is the structured result of memory_list_regions; use that tool to filter by range, module, state, backing or protection.", + "description": "A page of the attached target\u0027s committed memory regions in address order: 100 from offset 0 by default (fewer than the tool\u0027s 500), or ?offset=..\u0026limit=.. in that order (limit 1 to 2000). First run memory_list_regions explicitly; this resource reads its prepared snapshot and must be read within 5 seconds. Use that tool to filter by range, module, state, backing or protection.", "mimeType": "application/json", "annotations": { "audience": [ @@ -535,7 +522,8 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "memory_list_regions" + "cheatengine/sourceTool": "memory_list_regions", + "cheatengine/preparedProjection": "ListPreparedRegions" } }, { @@ -654,7 +642,7 @@ "name": "instance_symbols", "title": "Registered symbols", "uriTemplate": "cheatengine://instances/{instanceId}/symbols{?offset,limit}", - "description": "A page of the symbols registered in Cheat Engine, with ownedByMcp on those this activation can unregister: 200 from offset 0 by default, or ?offset=..\u0026limit=.. in that order (limit 1 to 1000); every read copies up to 8192 symbols. Its JSON is the structured result of symbol_list_registered; use that tool to filter by name.", + "description": "A page of the latest global registered-symbol copy explicitly prepared by symbol_list_registered, with ownedByMcp on those this activation can unregister: 200 from offset 0 by default, or ?offset=..\u0026limit=.. in that order (limit 1 to 1000). The copy expires after five seconds and contains at most 8192 symbols; that copy cap does not bound Cheat Engine\u0027s native enumeration. Run symbol_list_registered before reading this resource; use the tool to filter by name.", "mimeType": "application/json", "annotations": { "audience": [ @@ -663,14 +651,15 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "symbol_list_registered" + "cheatengine/sourceTool": "symbol_list_registered", + "cheatengine/preparedProjection": "ListPreparedRegistered" } }, { "name": "instance_threads", "title": "Target threads", "uriTemplate": "cheatengine://instances/{instanceId}/threads", - "description": "Up to 4096 thread ids of the attached target, marked truncated when Cheat Engine reports more. Its JSON is the structured result of process_list_threads.", + "description": "Up to 4096 thread ids of the attached target, marked truncated when Cheat Engine reports more. First run process_list_threads explicitly; this resource reads its prepared snapshot and must be read within 5 seconds.", "mimeType": "application/json", "annotations": { "audience": [ @@ -679,7 +668,8 @@ "priority": 0.3 }, "_meta": { - "cheatengine/sourceTool": "process_list_threads" + "cheatengine/sourceTool": "process_list_threads", + "cheatengine/preparedProjection": "ListPreparedThreads" } } ] diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-tools.json b/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-tools.json index c35fdb5..5f9e091 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-tools.json +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/gateway-tools.json @@ -1082,7 +1082,7 @@ { "name": "asm_release_patch", "title": "Release Auto Assembler patch", - "description": "Run the original DISABLE section for one MCP-owned patch. A failed or target-changed release can leave a patch in the old target and require manual recovery; retry only when retryable is true. A successful release removes the patch from runtime resources.", + "description": "Run the original DISABLE section for one MCP-owned patch. Script execution can block Cheat Engine or show a dialog. A failed or target-changed release can leave a patch in the old target and require manual recovery; retry only when retryable is true. A successful release removes the patch from runtime resources.", "inputSchema": { "type": "object", "properties": { @@ -1145,7 +1145,7 @@ "readOnlyHint": false }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "may_prompt" } }, { @@ -2658,7 +2658,7 @@ "annotations": { "title": "Start code search", "destructiveHint": false, - "idempotentHint": true, + "idempotentHint": false, "openWorldHint": false, "readOnlyHint": true }, @@ -3184,7 +3184,7 @@ { "name": "debugger_list_breakpoints", "title": "List debugger breakpoints", - "description": "Copies up to 1024 breakpoint addresses from Cheat Engine and identifies entries this MCP activation owns.", + "description": "Copies up to 1024 breakpoint addresses from Cheat Engine and identifies entries this MCP activation owns. A successful call explicitly prepares this snapshot for the breakpoints resource for five seconds; ownership fields are observations from that snapshot. The copy cap does not bound native enumeration, which can block Cheat Engine for seconds.", "inputSchema": { "type": "object", "properties": { @@ -3254,7 +3254,7 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { @@ -4329,7 +4329,7 @@ { "name": "dotnet_get_method_parameters", "title": "Get .NET method parameters", - "description": "Read a managed method\u0027s parameter names and CorElementType codes, and the signature text when the out-of-process collector reports it. Pass a module handle from dotnet_list_modules and a method token from dotnet_list_methods; this never injects into the target.", + "description": "Read a managed method\u0027s collector parameter entries and optional signature text. Each elementType is the collector\u0027s metadata-constant code, not the declared parameter type; use signature for declared types when available. Pass a module handle from dotnet_list_modules and a method token from dotnet_list_methods; this never injects into the target.", "inputSchema": { "type": "object", "properties": { @@ -4364,25 +4364,25 @@ "type": "string" }, "parameters": { - "description": "The parameters in declaration order, without the return value.", + "description": "The metadata entries in collector order; the list can be incomplete and include return metadata or unavailable placeholders.", "type": "array", "items": { "type": "object", "properties": { "index": { - "description": "The zero-based parameter position.", + "description": "The zero-based position in the returned collector entries, not necessarily the declared parameter ordinal.", "type": "integer" }, "name": { - "description": "The parameter name; empty when the metadata has none.", + "description": "The reported parameter name; empty when unnamed or unavailable.", "type": "string" }, "elementType": { - "description": "The parameter\u0027s CorElementType code, such as 8 for Int32 or 18 for a class reference.", + "description": "The collector\u0027s CType code for a metadata constant/default value, not the declared parameter type. 1 (Void) means no constant; 0 can mean unavailable metadata. Use signature for declared types.", "type": "integer" }, "elementTypeName": { - "description": "The element type\u0027s name, such as Int32, String or Class, when the code is known.", + "description": "The CorElementType name for elementType when known; this does not name the declared parameter type.", "type": "string", "default": null } @@ -5976,7 +5976,7 @@ { "name": "kernel_get_status", "title": "Get DBK and DBVM status", - "description": "Read whether DBK and DBVM are already initialized, plus any CR0, CR3, CR4 and DBVM real-CR4 values that Cheat Engine can read. It does not load a driver or initialize DBVM. Null register values are unavailable, not zero; call this before any kernel operation.", + "description": "Read whether DBK and DBVM are already initialized, plus any CR0, CR3, CR4 and DBVM real-CR4 values that Cheat Engine can read. It does not load a driver or initialize DBVM. Null status and register values are unavailable, not false or zero; call this before any kernel operation.", "inputSchema": { "type": "object", "properties": { @@ -5993,9 +5993,11 @@ "type": "object", "properties": { "dbkInitialized": { + "description": "Whether DBK is initialized; omitted when Cheat Engine cannot report its state.", "type": "boolean" }, "dbvmInitialized": { + "description": "Whether DBVM is initialized; omitted when Cheat Engine cannot report its state.", "type": "boolean" }, "cr0": { @@ -6010,11 +6012,7 @@ "dbvmCr4": { "type": "string" } - }, - "required": [ - "dbkInitialized", - "dbvmInitialized" - ] + } }, "annotations": { "title": "Get DBK and DBVM status", @@ -6335,10 +6333,10 @@ }, "annotations": { "title": "Poll DBVM watch", - "destructiveHint": false, - "idempotentHint": true, + "destructiveHint": true, + "idempotentHint": false, "openWorldHint": false, - "readOnlyHint": true + "readOnlyHint": false }, "_meta": { "cheatengine/requires": [ @@ -6773,7 +6771,7 @@ "title": "Find Lua API", "destructiveHint": false, "idempotentHint": true, - "openWorldHint": false, + "openWorldHint": true, "readOnlyHint": true }, "_meta": { @@ -7490,7 +7488,7 @@ }, "annotations": { "title": "Dump memory to a file", - "destructiveHint": false, + "destructiveHint": true, "idempotentHint": true, "openWorldHint": true, "readOnlyHint": false @@ -7615,7 +7613,7 @@ { "name": "memory_get_address_info", "title": "Describe addresses", - "description": "Describe up to 256 addresses: Cheat Engine\u0027s symbol name, the containing module and section, whether it is a system module, the memory region (state, protection, backing) and optionally the pointer-sized value stored there and the RTTI class name of the object there. An address that does not resolve reports its own error in band.", + "description": "Describe up to 256 addresses: Cheat Engine\u0027s symbol name, the containing module and section, whether it is a system module when Cheat Engine can answer, the memory region (state, protection, backing) and optionally the pointer-sized value stored there and the RTTI class name of the object there. An address that does not resolve reports its own error in band.", "inputSchema": { "type": "object", "properties": { @@ -7675,9 +7673,9 @@ "default": null }, "isSystemModule": { - "description": "Whether Cheat Engine counts the address as inside a system module.", + "description": "Whether Cheat Engine counts the address as inside a system module; omitted when that host API is unavailable or fails.", "type": "boolean", - "default": false + "default": null }, "region": { "description": "The memory region that contains the address, when Cheat Engine reports one.", @@ -7908,7 +7906,7 @@ { "name": "memory_list_regions", "title": "List memory regions", - "description": "List the target\u0027s memory regions in address order, one page at a time, filtered by address range, module, state (committed by default), backing and the writable, executable and copy-on-write flags. The detailed format adds the allocation base, allocation protection and mapped file.", + "description": "List the target\u0027s memory regions in address order, one page at a time, filtered by address range, module, state (committed by default), backing and the writable, executable and copy-on-write flags. The detailed format adds the allocation base, allocation protection and mapped file. This explicit read prepares the default region resource snapshot for 5 seconds. Native enumeration of the address space can block Cheat Engine for seconds.", "inputSchema": { "type": "object", "properties": { @@ -8154,7 +8152,7 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { @@ -9278,7 +9276,7 @@ { "name": "module_get", "title": "Get module details", - "description": "Read one module of the attached process: base, size, file path, its sections (with executable and writable flags) and the fields of its mapped PE header: machine, COFF time stamp, entry point, subsystem, DLL and .NET flags and the PDB it names. The time stamp identifies the build, so keep it to tell later whether the game was updated. module is a name such as game.exe (case-insensitive) or any address expression inside the module.", + "description": "Read one module of the attached process: base, size, file path, its sections (with executable and writable flags) and the fields of its mapped PE header: machine, COFF time stamp, entry point, subsystem, DLL and .NET flags and the PDB it names. The time stamp identifies the build, so keep it to tell later whether the game was updated. module is a name such as game.exe (case-insensitive) or any address expression inside the module. This explicitly prepares the current module list and this latest module result for five seconds. Native module enumeration can block Cheat Engine for seconds.", "inputSchema": { "type": "object", "properties": { @@ -9467,13 +9465,13 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { "name": "module_list", "title": "List modules", - "description": "Page the modules (EXE and DLLs) loaded in the attached process, or in another process by processId, in Cheat Engine\u0027s order. concise returns name, base and size; detailed adds is64Bit and the file path. Filter with nameContains, then page with offset and limit (at most 1000). Use it to classify the runtime (mono, GameAssembly, coreclr) and to find a module for module_get.", + "description": "Page the modules (EXE and DLLs) loaded in the attached process, or in another process by processId, in Cheat Engine\u0027s order. concise returns name, base and size; detailed adds is64Bit and the file path. Filter with nameContains, then page with offset and limit (at most 1000). A successful attached-process call explicitly prepares the complete module list for five seconds; calls with processId do not. Native enumeration can block Cheat Engine for seconds on a large module set. Use it to classify the runtime (mono, GameAssembly, coreclr) and to find a module for module_get.", "inputSchema": { "type": "object", "properties": { @@ -9582,13 +9580,13 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { "name": "module_list_exports", "title": "List module exports", - "description": "Page the exports of one module of the attached process, parsed from its export directory in memory: name (omitted for ordinal-only exports), ordinal and address, or the forwarder such as NTDLL.RtlAllocateHeap for a forwarded export. Filter with nameContains, then page with offset and limit (at most 1000). A module whose export data exceeds 8 MiB or 65536 entries is refused.", + "description": "Page the exports of one module of the attached process, parsed from its export directory in memory: name (omitted for ordinal-only exports), ordinal and address, or the forwarder such as NTDLL.RtlAllocateHeap for a forwarded export. The whole directory is read in one dispatch before filtering and paging, holding Cheat Engine while it runs; large directories can take seconds. Filter with nameContains, then page with offset and limit (at most 1000). A module whose export data exceeds 8 MiB or 65536 entries is refused.", "inputSchema": { "type": "object", "properties": { @@ -9683,13 +9681,13 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "host_scan" } }, { "name": "module_list_imports", "title": "List module imports", - "description": "Page the imported functions of one module of the attached process, parsed from its import directory (and delay-load directory) in memory: the DLL, the IAT slot address the code calls through, the name and hint or the ordinal, the pointer the slot holds now, and the loaded module and Cheat Engine symbol that pointer lands in. A bound IAT without a lookup table gives slots without names; a delay-loaded slot points at this module\u0027s loader stub until its first call. A pointer outside the named DLL is not proof of a hook: api-ms-win-* sets resolve into kernelbase.dll and others. Filter with dllContains and nameContains, then page with offset and limit (at most 1000). A module whose import data exceeds 8 MiB or 65536 entries is refused.", + "description": "Page the imported functions of one module of the attached process, parsed from its import directory (and delay-load directory) in memory: the DLL, the IAT slot address the code calls through, the name and hint or the ordinal, the pointer the slot holds now, and the loaded module and Cheat Engine symbol that pointer lands in. A bound IAT without a lookup table gives slots without names; a delay-loaded slot points at this module\u0027s loader stub until its first call. A pointer outside the named DLL is not proof of a hook: api-ms-win-* sets resolve into kernelbase.dll and others. The whole directory is read in one dispatch before filtering and paging, holding Cheat Engine while it runs; large directories can take seconds. Filter with dllContains and nameContains, then page with offset and limit (at most 1000). A module whose import data exceeds 8 MiB or 65536 entries is refused.", "inputSchema": { "type": "object", "properties": { @@ -9819,7 +9817,7 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "host_scan" } }, { @@ -11177,7 +11175,7 @@ "type": "string" }, "jobId": { - "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported map.", "type": "string" }, "state": { @@ -11239,7 +11237,6 @@ }, "required": [ "mapName", - "jobId", "state", "pointerSize", "pointers", @@ -11432,7 +11429,7 @@ "type": "string" }, "jobId": { - "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported scan.", "type": "string" }, "state": { @@ -11484,7 +11481,6 @@ }, "required": [ "scanName", - "jobId", "state", "mapName", "target", @@ -11821,7 +11817,7 @@ "type": "string" }, "jobId": { - "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported map.", "type": "string" }, "state": { @@ -11883,7 +11879,6 @@ }, "required": [ "mapName", - "jobId", "state", "pointerSize", "pointers", @@ -12074,7 +12069,7 @@ "type": "string" }, "jobId": { - "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported scan.", "type": "string" }, "state": { @@ -12126,7 +12121,6 @@ }, "required": [ "scanName", - "jobId", "state", "mapName", "target", @@ -12205,7 +12199,7 @@ "type": "string" }, "jobId": { - "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported map.", "type": "string" }, "state": { @@ -12267,7 +12261,6 @@ }, "required": [ "mapName", - "jobId", "state", "pointerSize", "pointers", @@ -12341,7 +12334,7 @@ "type": "string" }, "jobId": { - "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported scan.", "type": "string" }, "state": { @@ -12393,7 +12386,6 @@ }, "required": [ "scanName", - "jobId", "state", "mapName", "target", @@ -13032,7 +13024,7 @@ "type": "string" }, "jobId": { - "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The capture job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported map.", "type": "string" }, "state": { @@ -13094,7 +13086,6 @@ }, "required": [ "mapName", - "jobId", "state", "pointerSize", "pointers", @@ -13173,7 +13164,7 @@ "type": "string" }, "jobId": { - "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job.", + "description": "The search job\u0027s id, for runtime_list_jobs and runtime_stop_job; omitted for an imported scan.", "type": "string" }, "state": { @@ -13225,7 +13216,6 @@ }, "required": [ "scanName", - "jobId", "state", "mapName", "target", @@ -13503,7 +13493,7 @@ { "name": "process_list_threads", "title": "List target threads", - "description": "List up to 4096 target thread ids reported by Cheat Engine. A larger host list is marked truncated.", + "description": "List up to 4096 target thread ids reported by Cheat Engine. A larger host list is marked truncated. This explicit read prepares the target-thread resource snapshot for 5 seconds. Native thread enumeration can block Cheat Engine for seconds; the copy cap does not limit that enumeration.", "inputSchema": { "type": "object", "properties": { @@ -13544,7 +13534,7 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { @@ -13766,7 +13756,7 @@ { "name": "record_clear", "title": "Clear address list", - "description": "Delete every Cheat Engine address-list record and return the number removed, including nested child records. Refuses lists above 100000 records before changing them. Active scripts may run their disable sections during clear.", + "description": "Delete every Cheat Engine address-list record and return the number removed, including nested child records. Refuses lists above 100000 records before changing them. Active scripts may run their disable sections during clear, which can block Cheat Engine or show a dialog.", "inputSchema": { "type": "object", "properties": { @@ -13799,7 +13789,7 @@ "readOnlyHint": false }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "may_prompt" } }, { @@ -14025,7 +14015,7 @@ }, "annotations": { "title": "Create address-list records", - "destructiveHint": false, + "destructiveHint": true, "idempotentHint": false, "openWorldHint": false, "readOnlyHint": false @@ -14037,7 +14027,7 @@ { "name": "record_delete", "title": "Delete address-list records", - "description": "Delete 1 to 256 records by current id. Deleting a group can also remove or reparent its children according to Cheat Engine\u0027s address-list behavior. Deleting an active Auto Assembler record can execute its disable section and requires Mcp:EnableAutoAssembler. A failure after an earlier delete leaves those earlier deletions in effect.", + "description": "Delete 1 to 256 records by current id. Deleting a group can also remove or reparent its children according to Cheat Engine\u0027s address-list behavior. Deleting an active Auto Assembler record can execute its disable section and requires Mcp:EnableAutoAssembler. Script execution can block Cheat Engine or show a dialog. A failure after an earlier delete leaves those earlier deletions in effect.", "inputSchema": { "type": "object", "properties": { @@ -14078,7 +14068,7 @@ "readOnlyHint": false }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "may_prompt" } }, { @@ -15456,7 +15446,7 @@ { "name": "record_set_active", "title": "Set address-list records active", - "description": "Set 1 to 256 records active or inactive, in order. Activating or deactivating an Auto Assembler record can execute its enable or disable section, so it requires Mcp:EnableAutoAssembler; so does a record whose Cheat Engine option moActivateChildrenAsWell (when activating) or moDeactivateChildrenAsWell (when deactivating) passes the change on to a nested Auto Assembler record, checked through at most 4096 nested records. A record Cheat Engine refuses keeps its state and the call goes on: its active then differs from requestedActive and failure says why, with Cheat Engine\u0027s own reason and message for a failed activation, such as aob_not_found or assert_failed. pending means Cheat Engine accepted an asynchronous activation whose final state must be read later with record_get; the reason of a failure that comes after the call is not reported. An error on a later record leaves the earlier records changed.", + "description": "Set 1 to 256 records active or inactive, in order. Scripts can block Cheat Engine or show a dialog. Activating or deactivating an Auto Assembler record can execute its enable or disable section, so it requires Mcp:EnableAutoAssembler; so does a record whose Cheat Engine option moActivateChildrenAsWell (when activating) or moDeactivateChildrenAsWell (when deactivating) passes the change on to a nested Auto Assembler record, checked through at most 4096 nested records. A record Cheat Engine refuses keeps its state and the call goes on: its active then differs from requestedActive and failure says why, with Cheat Engine\u0027s own reason and message for a failed activation, such as aob_not_found or assert_failed. pending means Cheat Engine accepted an asynchronous activation whose final state must be read later with record_get; the reason of a failure that comes after the call is not reported. An error on a later record leaves the earlier records changed.", "inputSchema": { "type": "object", "properties": { @@ -15563,7 +15553,7 @@ "readOnlyHint": false }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "may_prompt" } }, { @@ -15953,6 +15943,9 @@ "readOnlyHint": false }, "_meta": { + "cheatengine/requires": [ + "auto_assembler" + ], "cheatengine/dispatchClass": "short" } }, @@ -16758,7 +16751,7 @@ { "name": "runtime_release_resources", "title": "Release retained resources", - "description": "Release retained resources newest first. includeOrphans also releases retained Lua state from earlier activations. acknowledgeIds only forgets resources already recovered manually; an incomplete cleanup is reported as partial_effect with its actual host effect.", + "description": "Release retained resources newest first. Releasing Auto Assembler patches runs their DISABLE sections, which can block Cheat Engine or show a dialog. includeOrphans also releases retained Lua state from earlier activations. acknowledgeIds only forgets resources already recovered manually; an incomplete cleanup is reported as partial_effect with its actual host effect.", "inputSchema": { "type": "object", "properties": { @@ -17080,7 +17073,7 @@ "readOnlyHint": false }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "may_prompt" } }, { @@ -17672,7 +17665,7 @@ "type": "string" }, "value": { - "description": "The value text reported by Cheat Engine.", + "description": "The formatted value text reported by Cheat Engine, which may contain read-failure markers. Empty text does not prove readability; use memory_read to verify.", "type": "string" } }, @@ -18198,7 +18191,7 @@ "type": "number" }, "hooksInstalled": { - "description": "Whether the speedhack_wantedspeed symbol exists in the target; it also exists after a failed hook and on the Unity timeScale path, so it does not prove that the target\u0027s clocks are hooked.", + "description": "Whether Cheat Engine reported that the speedhack_wantedspeed symbol exists in the target; omitted when it could not be observed after the change. The symbol also exists after a failed hook and on the Unity timeScale path, so it does not prove that the target\u0027s clocks are hooked.", "type": "boolean" }, "firstActivation": { @@ -18213,7 +18206,6 @@ }, "required": [ "speed", - "hooksInstalled", "firstActivation" ] }, @@ -18940,7 +18932,7 @@ { "name": "structure_get", "title": "Get a structure", - "description": "Reads one structure, internal ones included, and a page of its elements ordered by offset: index, hexadecimal offset, name, valueType, integer display, byteSize and child structure. The detailed format adds the child start, custom type and bit fields.", + "description": "Reads a global Structure Dissect definition after scanning at most 65,536 indexed definitions by exact case-sensitive name. Returns a page of its elements ordered by offset: index, hexadecimal offset, name, valueType, integer display, byteSize and child structure. The detailed format adds the child start, custom type and bit fields.", "inputSchema": { "type": "object", "properties": { @@ -20105,7 +20097,7 @@ { "name": "symbol_list_registered", "title": "List registered symbols", - "description": "Page every symbol registered in Cheat Engine (by tables, Auto Assembler scripts, Lua and this plugin), marking with ownedByMcp the ones this activation registered and can unregister. Allocation symbols carry their size. Filter with nameContains, then page with offset and limit (at most 1000); at most 8192 symbols are copied.", + "description": "Page every symbol registered in Cheat Engine (by tables, Auto Assembler scripts, Lua and this plugin), marking with ownedByMcp the ones this activation registered and can unregister. Allocation symbols carry their size. Filter with nameContains, then page with offset and limit (at most 1000); at most 8192 symbols are copied. Cheat Engine fully enumerates its registered-symbol table before that copy cap, so the native read can block Cheat Engine for seconds. A successful call explicitly prepares that unfiltered bounded copy for the symbols resource for five seconds.", "inputSchema": { "type": "object", "properties": { @@ -20210,7 +20202,7 @@ "readOnlyHint": true }, "_meta": { - "cheatengine/dispatchClass": "short" + "cheatengine/dispatchClass": "blocking_native" } }, { @@ -20907,7 +20899,7 @@ }, "annotations": { "title": "Save cheat table", - "destructiveHint": false, + "destructiveHint": true, "idempotentHint": false, "openWorldHint": true, "readOnlyHint": false diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/legacy-tool-map.txt b/tests/CheatEngine.Mcp.Tests/Contract/Golden/legacy-tool-map.txt index b456ee0..d7bdf23 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/legacy-tool-map.txt +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/legacy-tool-map.txt @@ -64,7 +64,7 @@ dbvm_initialize | renamed | kernel_initialize_dbvm | May prompt; gated by Mcp:En dbvm_read_physical | renamed | kernel_read_physical | Gated by Mcp:EnableKernelAccess. dbvm_status | merged | kernel_get_status | DBK and DBVM state in one result. dbvm_watch | renamed | kernel_start_watch | Now a job; no main-thread wait. -dbvm_watch_log | renamed | kernel_poll_watch | Non-consuming poll with afterSequence. +dbvm_watch_log | renamed | kernel_poll_watch | Drains the DBVM log into a retained page with afterSequence. dbvm_watch_stop | merged | runtime_stop_job | Generic job stop. dbvm_write_physical | renamed | kernel_write_physical | Gated by Mcp:EnableKernelAccess. debugger_add_breakpoint | merged | debugger_set_breakpoint | Adds method, thread and one-shot options. diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/open-world-tools.txt b/tests/CheatEngine.Mcp.Tests/Contract/Golden/open-world-tools.txt index d15bbd6..839dac7 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/open-world-tools.txt +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/open-world-tools.txt @@ -6,6 +6,7 @@ exec_compile_csharp exec_inject_dotnet exec_inject_library lua_execute +lua_find_api memory_dump_to_file memory_load_from_file module_find_patches diff --git a/tests/CheatEngine.Mcp.Tests/Contract/Golden/tool-summary.txt b/tests/CheatEngine.Mcp.Tests/Contract/Golden/tool-summary.txt index 5a67419..a825759 100644 --- a/tests/CheatEngine.Mcp.Tests/Contract/Golden/tool-summary.txt +++ b/tests/CheatEngine.Mcp.Tests/Contract/Golden/tool-summary.txt @@ -10,7 +10,7 @@ asm_check | Check Auto Assembler | true,false,true,false | short | auto_assemble asm_generate_api_hook | Generate API hook | true,false,true,false | short | - asm_generate_injection | Generate AOB injection | true,false,true,false | short | - asm_list_patches | List Auto Assembler patches | true,false,true,false | short | - -asm_release_patch | Release Auto Assembler patch | false,true,true,false | short | - +asm_release_patch | Release Auto Assembler patch | false,true,true,false | may_prompt | - code_clear_dissect | Clear code dissect | false,true,true,false | short | - code_decode | Decode an instruction | true,false,true,false | short | - code_disassemble | Disassemble code | true,false,true,false | short | - @@ -24,7 +24,7 @@ code_list_functions | List referenced functions | true,false,true,false | short code_poll_job | Poll code job | true,false,true,false | short | - code_set_comment | Set code comment | false,false,true,false | short | - code_start_dissect | Start code dissect | false,false,false,false | blocking_native | - -code_start_search | Start code search | true,false,true,false | short | - +code_start_search | Start code search | true,false,false,false | short | - debugger_attach | Attach debugger | false,false,true,false | may_prompt | - debugger_break_thread | Break a thread | false,false,false,false | short | - debugger_continue | Continue debugger | false,false,false,false | short | - @@ -33,7 +33,7 @@ debugger_detach | Detach debugger | false,false,true,false | short | - debugger_get_context | Get debugger context | true,false,true,false | short | - debugger_get_stack_trace | Get heuristic stack trace | true,false,true,false | host_scan | - debugger_get_status | Get debugger status | true,false,true,false | short | - -debugger_list_breakpoints | List debugger breakpoints | true,false,true,false | short | - +debugger_list_breakpoints | List debugger breakpoints | true,false,true,false | blocking_native | - debugger_poll_capture | Poll debugger capture | true,false,true,false | short | - debugger_poll_trace | Poll debugger trace | true,false,true,false | short | - debugger_run_to | Run debugger to address | false,false,false,false | short | - @@ -63,24 +63,24 @@ exec_inject_library | Inject native library | false,true,false,true | blocking_n instance_list | List Cheat Engine instances | true,false,true,false | gateway | - kernel_get_status | Get DBK and DBVM status | true,false,true,false | short | kernel_access kernel_initialize_dbvm | Initialize DBVM | false,true,false,false | may_prompt | kernel_access -kernel_poll_watch | Poll DBVM watch | true,false,true,false | blocking_native | kernel_access +kernel_poll_watch | Poll DBVM watch | false,true,false,false | blocking_native | kernel_access kernel_read_physical | Read physical memory | true,false,true,false | short | kernel_access kernel_start_watch | Start DBVM watch | false,false,false,false | blocking_native | kernel_access kernel_translate_address | Translate virtual address | true,false,true,false | short | kernel_access kernel_write_physical | Write physical memory | false,true,false,false | short | kernel_access lua_execute | Execute Lua | false,true,false,true | may_prompt | unsafe_lua -lua_find_api | Find Lua API | true,false,true,false | short | - +lua_find_api | Find Lua API | true,false,true,true | short | - memory_allocate | Allocate memory | false,false,false,false | short | - memory_compare | Compare memory | true,false,true,false | short | - memory_compare_snapshot | Compare a memory snapshot | true,false,true,false | short | - memory_copy | Copy memory | false,true,false,false | short | - memory_create_snapshot | Create a memory snapshot | false,false,false,false | short | - memory_delete_snapshot | Delete a memory snapshot | false,true,true,false | short | - -memory_dump_to_file | Dump memory to a file | false,false,true,true | short | - +memory_dump_to_file | Dump memory to a file | false,true,true,true | short | - memory_free | Free memory | false,true,true,false | short | - memory_get_address_info | Describe addresses | true,false,true,false | short | - memory_hash | Hash memory | true,false,true,false | short | - -memory_list_regions | List memory regions | true,false,true,false | short | - +memory_list_regions | List memory regions | true,false,true,false | blocking_native | - memory_list_snapshots | List memory snapshots | true,false,true,false | short | - memory_load_from_file | Load memory from a file | false,true,true,true | short | - memory_read | Read memory | true,false,true,false | short | - @@ -90,10 +90,10 @@ memory_set_protection | Set memory protection | false,true,false,false | short | memory_write | Write memory | false,true,false,false | short | - memory_write_batch | Write many addresses | false,true,false,false | short | - module_find_patches | Find module patches | true,false,true,true | short | - -module_get | Get module details | true,false,true,false | short | - -module_list | List modules | true,false,true,false | short | - -module_list_exports | List module exports | true,false,true,false | short | - -module_list_imports | List module imports | true,false,true,false | short | - +module_get | Get module details | true,false,true,false | blocking_native | - +module_list | List modules | true,false,true,false | blocking_native | - +module_list_exports | List module exports | true,false,true,false | host_scan | - +module_list_imports | List module imports | true,false,true,false | host_scan | - mono_attach | Attach Mono collector | false,true,false,false | may_prompt | target_code_execution mono_compile_method | Compile Mono method | false,true,false,false | blocking_native | target_code_execution mono_detach | Detach Mono collector | false,true,true,false | short | - @@ -129,14 +129,14 @@ process_attach | Attach to a process | false,true,false,true | short | - process_create | Create a process | false,true,false,true | short | - process_get_current | Get current process | true,false,true,false | short | - process_list | List local processes | true,false,true,true | short | - -process_list_threads | List target threads | true,false,true,false | short | - +process_list_threads | List target threads | true,false,true,false | blocking_native | - process_open_file | Open a file as a process | false,true,false,true | short | - process_save_file | Save opened file | false,true,false,true | short | - process_set_paused | Set process paused | false,true,true,false | short | - process_set_pointer_size | Set pointer size | false,true,true,false | short | - -record_clear | Clear address list | false,true,true,false | short | - -record_create | Create address-list records | false,false,false,false | short | - -record_delete | Delete address-list records | false,true,true,false | short | - +record_clear | Clear address list | false,true,true,false | may_prompt | - +record_create | Create address-list records | false,true,false,false | short | - +record_delete | Delete address-list records | false,true,true,false | may_prompt | - record_find | Find address-list records | true,false,true,false | short | - record_get | Get address-list records | true,false,true,false | short | - record_get_selected | Get selected address-list record | true,false,true,false | short | - @@ -144,15 +144,15 @@ record_group | Group address-list records | false,false,false,false | short | - record_list | List address-list records | true,false,true,false | short | - record_move | Move address-list record | false,true,true,false | short | - record_select | Select address-list record | false,false,true,false | short | - -record_set_active | Set address-list records active | false,true,true,false | short | - +record_set_active | Set address-list records active | false,true,true,false | may_prompt | - record_set_dropdown | Set address-list record dropdown | false,true,true,false | short | - -record_set_script | Set address-list Auto Assembler script | false,true,true,false | short | - +record_set_script | Set address-list Auto Assembler script | false,true,true,false | short | auto_assembler record_update | Update address-list records | false,true,true,false | short | - runtime_get_info | Get runtime information | true,false,true,false | short | - runtime_get_overview | Get runtime overview | true,false,true,false | short | - runtime_list_jobs | List retained jobs | true,false,true,false | short | - runtime_list_resources | List retained resources | true,false,true,false | short | - -runtime_release_resources | Release retained resources | false,true,true,false | short | - +runtime_release_resources | Release retained resources | false,true,true,false | may_prompt | - runtime_stop_job | Stop a job | false,true,true,false | short | - scan_delete | Delete independent scanner | false,true,true,false | short | - scan_first | Start value scan | false,true,false,false | host_scan | - @@ -183,7 +183,7 @@ symbol_add_module | Load symbols from a file | false,false,true,true | blocking_ symbol_enable_sources | Enable system symbol sources | false,false,true,true | blocking_native | - symbol_find | Find symbols | true,false,true,false | host_scan | - symbol_get_module_preference | Get module preference | true,false,true,false | short | - -symbol_list_registered | List registered symbols | true,false,true,false | short | - +symbol_list_registered | List registered symbols | true,false,true,false | blocking_native | - symbol_register | Register a symbol | false,false,false,false | short | - symbol_reload | Reload symbols | false,false,true,false | short | - symbol_resolve | Resolve addresses and names | true,false,true,false | short | - @@ -191,6 +191,6 @@ symbol_set_module_preference | Set module preference | false,true,true,false | s symbol_unregister | Unregister a symbol | false,true,true,false | short | - table_list_files | List table files | true,false,true,true | short | - table_load | Load cheat table | false,true,false,true | may_prompt | - -table_save | Save cheat table | false,false,false,true | may_prompt | - +table_save | Save cheat table | false,true,false,true | may_prompt | - util_calculate | Calculate a 64-bit expression | true,false,true,false | short | - util_convert_value | Convert a value | true,false,true,false | short | - diff --git a/tests/CheatEngine.Mcp.Tests/Core/McpContractRulesTests.cs b/tests/CheatEngine.Mcp.Tests/Core/McpContractRulesTests.cs index 22e82ef..4e4333a 100644 --- a/tests/CheatEngine.Mcp.Tests/Core/McpContractRulesTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Core/McpContractRulesTests.cs @@ -150,7 +150,9 @@ public void ValidateTool_GatewayTool_NeedsNoDispatchClass() public void ValidateTool_PlanCatalogName_PassesTheNameRules(string name) { bool polls = name.Split('_')[1] == "poll"; - Assert.Empty(McpContractRules.ValidateTool(Sample(name, idempotent: true, readOnly: polls), false)); + bool consumesLog = name == CheatEngineToolNames.KernelPollWatch; + Assert.Empty(McpContractRules.ValidateTool(Sample(name, idempotent: !consumesLog, + readOnly: polls && !consumesLog, destructive: consumesLog), false)); } [Fact] @@ -222,6 +224,15 @@ public void ValidateTool_PollNotReadOnlyOrNotIdempotent_Fails() Assert.NotEmpty(McpContractRules.ValidateTool(Sample("code_poll_job", idempotent: false), false)); } + [Fact] + public void ValidateTool_ConsumingKernelPoll_RequiresMutationAnnotations() + { + Assert.Empty(McpContractRules.ValidateTool(Sample("kernel_poll_watch", readOnly: false, + destructive: true, idempotent: false), false)); + Assert.Contains(McpContractRules.ValidateTool(Sample("kernel_poll_watch"), false), + static failure => failure.Contains("consumes the native log", StringComparison.Ordinal)); + } + [Theory] [InlineData("runtime_stop_job")] [InlineData("asm_release_patch")] diff --git a/tests/CheatEngine.Mcp.Tests/Core/PreparedInspectionStoreTests.cs b/tests/CheatEngine.Mcp.Tests/Core/PreparedInspectionStoreTests.cs new file mode 100644 index 0000000..1ee2258 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/Core/PreparedInspectionStoreTests.cs @@ -0,0 +1,129 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Inspection; +using CheatEngine.Mcp.Tests.Support; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; + +namespace CheatEngine.Mcp.Tests.Core; + +public sealed class PreparedInspectionStoreTests +{ + [Fact] + public void Modules_ExpireAtTheLifetimeBoundaryAndRejectWrongIdentity() + { + ManualTimeProvider time = new(); + PreparedInspectionStore store = new(time); + ProcessSnapshot target = Target(42, 7); + store.PublishModules(target, ImmutableArray.Empty); + + Assert.True(store.TryGetModules(target, out _)); + Assert.False(store.TryGetModules(Target(43, 7), out _)); + time.Advance(PreparedInspectionStore.Lifetime - TimeSpan.FromTicks(1)); + Assert.True(store.TryGetModules(target, out _)); + time.Advance(TimeSpan.FromTicks(1)); + Assert.False(store.TryGetModules(target, out _)); + } + + [Fact] + public void Modules_OlderEpochCannotReplaceNewerPublication() + { + PreparedInspectionStore store = new(new ManualTimeProvider()); + PreparedModuleSnapshot newer = store.PublishModules(Target(42, 8), ImmutableArray.Empty); + CheatEngineToolException rejected = Assert.Throws(() => + store.PublishModules(Target(42, 7), ImmutableArray.Empty)); + + Assert.Equal(ToolErrorKind.TargetChanged, rejected.Error.Kind); + Assert.True(store.TryGetModules(Target(42, 8), out PreparedModuleSnapshot current)); + Assert.Equal(newer.Version, current.Version); + } + + [Fact] + public void MemoryMap_StalePublicationAfterNewerModulePublicationIsRejected() + { + PreparedInspectionStore store = new(new ManualTimeProvider()); + store.PublishModules(Target(42, 8), ImmutableArray.Empty); + + Assert.Throws(() => store.PublishMemoryMap(Target(42, 7), + ImmutableArray.Empty, ImmutableArray.Empty)); + } + + [Fact] + public void ReadOfNewerTargetRejectsDelayedOlderPublication() + { + PreparedInspectionStore store = new(new ManualTimeProvider()); + store.PublishModules(Target(42, 7), ImmutableArray.Empty); + Assert.False(store.TryGetModules(Target(42, 8), out _)); + + Assert.Throws(() => + store.PublishModules(Target(42, 7), ImmutableArray.Empty)); + } + + [Fact] + public void IsCurrent_RejectsExpiredVersion() + { + ManualTimeProvider time = new(); + PreparedInspectionStore store = new(time); + PreparedModuleSnapshot snapshot = store.PublishModules(Target(42, 7), ImmutableArray.Empty); + Assert.True(store.IsCurrent(Target(42, 7), snapshot.Version)); + time.Advance(PreparedInspectionStore.Lifetime); + + Assert.False(store.IsCurrent(Target(42, 7), snapshot.Version)); + } + + [Fact] + public void MemoryMap_PublicationSharesModuleVersionAndModuleRefreshInvalidatesMap() + { + ManualTimeProvider time = new(); + PreparedInspectionStore store = new(time); + ProcessSnapshot target = Target(42, 7); + PreparedMemoryMapSnapshot map = store.PublishMemoryMap(target, ImmutableArray.Empty, + ImmutableArray.Empty); + Assert.True(store.TryGetModules(target, out PreparedModuleSnapshot modules)); + Assert.Equal(map.Version, modules.Version); + Assert.True(store.TryGetMemoryMap(target, out PreparedMemoryMapSnapshot read)); + Assert.Same(map, read); + + PreparedModuleSnapshot replacement = store.PublishModules(target, ImmutableArray.Empty); + + Assert.True(replacement.Version.Revision > map.Version.Revision); + Assert.False(store.IsCurrent(target, map.Version)); + Assert.False(store.TryGetMemoryMap(target, out _)); + Assert.True(store.IsCurrent(target, replacement.Version)); + } + + [Fact] + public void MemoryMap_ExpiresAndWrongTargetReadInvalidatesPreviousIdentity() + { + ManualTimeProvider time = new(); + PreparedInspectionStore store = new(time); + ProcessSnapshot target = Target(42, 7); + store.PublishMemoryMap(target, ImmutableArray.Empty, ImmutableArray.Empty); + Assert.True(store.TryGetMemoryMap(target, out _)); + time.Advance(PreparedInspectionStore.Lifetime); + Assert.False(store.TryGetMemoryMap(target, out _)); + store.PublishMemoryMap(target, ImmutableArray.Empty, ImmutableArray.Empty); + Assert.False(store.TryGetMemoryMap(Target(43, 8), out _)); + Assert.False(store.TryGetModules(target, out _)); + Assert.False(store.TryGetMemoryMap(target, out _)); + } + + [Fact] + public void Publish_RejectsDefaultImmutableArrays() + { + PreparedInspectionStore store = new(new ManualTimeProvider()); + ProcessSnapshot target = Target(42, 7); + + Assert.Throws(() => store.PublishModules(target, default)); + Assert.Throws(() => store.PublishMemoryMap(target, ImmutableArray.Empty, default)); + } + + private static ProcessSnapshot Target(int processId, long epoch) + { + return new ProcessSnapshot(new TargetProcessId(processId), "game.exe", null, TargetBackend.LocalProcess, + CheatEngineArchitecture.X64, PointerSize.Bit64, 8, DateTimeOffset.UnixEpoch, epoch); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/Core/PreparedProjectionContractTests.cs b/tests/CheatEngine.Mcp.Tests/Core/PreparedProjectionContractTests.cs new file mode 100644 index 0000000..cd40a9d --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/Core/PreparedProjectionContractTests.cs @@ -0,0 +1,309 @@ +using System.ComponentModel; +using System.Reflection; + +using CheatEngine.Mcp.Core.Composition; +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Features; + +using Microsoft.Extensions.Options; + +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace CheatEngine.Mcp.Tests.Core; + +#pragma warning disable CA1822 // Instance method shape is part of the reflection contract under test. + +/// Live resources may replace a long explicit source operation with one bounded prepared projection. +public sealed class PreparedProjectionContractTests +{ + [Fact] + public void ValidPreparedProjection_AllowsANonShortSourceAndPublishesResolvedProvenance() + { + McpPrimitiveCatalog catalog = McpPrimitiveCatalog.Create(Manifest()); + MethodInfo resource = typeof(ValidResource).GetMethod(nameof(ValidResource.Read))!; + McpResourceSource source = Assert.IsType(McpResourceSource.Resolve(resource)); + + Assert.Equal(nameof(ValidTools.ReadPrepared), source.PreparedProjection!.Name); + Assert.Equal(McpDispatchClass.Short, source.PreparedDispatchClass); + Assert.Empty(source.PreparedRequires); + McpCatalogResource live = Assert.Single(catalog.InstanceResources); + Assert.Equal(nameof(ValidTools.ReadPrepared), + live.Template.Meta![McpSourceToolAttribute.PreparedProjectionMetaKey]!.GetValue()); + } + + [Theory] + [InlineData(typeof(MissingProjectionResource), "does not declare exactly once as a public instance method")] + [InlineData(typeof(OverloadedProjectionResource), "does not declare exactly once as a public instance method")] + [InlineData(typeof(ToolProjectionResource), "must not be an MCP tool")] + [InlineData(typeof(WrongResultProjectionResource), "return type must exactly match 'String'")] + [InlineData(typeof(NonShortProjectionResource), "it must declare short")] + [InlineData(typeof(GatedProjectionResource), "a prepared resource read must never need a feature switch")] + [InlineData(typeof(MissingSourceClassResource), "whose dispatch class is missing; it must be one of")] + [InlineData(typeof(UnknownSourceClassResource), "whose dispatch class is unreviewed; it must be one of")] + public void InvalidPreparedProjection_FailsTheResourceContract(Type resource, string expected) + { + OptionsValidationException exception = Assert.Throws(() => + McpPrimitiveCatalog.Create(Manifest(resource))); + + Assert.Contains(expected, exception.Message, StringComparison.Ordinal); + } + + private static CheatEngineMcpPrimitiveOptions Manifest() + { + return Manifest(typeof(TResource)); + } + + private static CheatEngineMcpPrimitiveOptions Manifest(Type resource) + { + CheatEngineMcpPrimitiveOptions manifest = new(); + manifest.Add(new CheatEngineMcpPrimitive(CheatEngineMcpPrimitiveKind.Resource, resource)); + return manifest; + } + + [McpServerToolType] + public class ValidTools + { + [McpServerTool(Name = CheatEngineToolNames.RuntimeGetInfo, Title = "Read explicit", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.HostScan)] + [Description("Reads the complete explicit result.")] + public string ReadExplicit() + { + return "explicit"; + } + + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public string ReadPrepared() + { + return "prepared"; + } + } + + [McpServerToolType] + public sealed class MissingProjectionTools + { + [McpServerTool(Name = CheatEngineToolNames.RuntimeGetInfo, Title = "Read missing", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.HostScan)] + [Description("Reads the explicit result.")] + public string ReadExplicit() => "explicit"; + } + + [McpServerToolType] + public sealed class OverloadedProjectionTools + { + [McpServerTool(Name = CheatEngineToolNames.RuntimeGetInfo, Title = "Read overloaded", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.HostScan)] + [Description("Reads the explicit result.")] + public string ReadExplicit() => "explicit"; + + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public string Prepared() + { + return "a"; + } + + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public string Prepared(CancellationToken cancellationToken) + { + return cancellationToken.CanBeCanceled ? "b" : "c"; + } + } + + [McpServerToolType] + public sealed class ToolProjectionTools + { + [McpServerTool(Name = CheatEngineToolNames.RuntimeGetInfo, Title = "Read tool", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.HostScan)] + [Description("Reads the explicit result.")] + public string ReadExplicit() => "explicit"; + + [McpServerTool(Name = CheatEngineToolNames.RuntimeGetOverview, Title = "Prepared tool", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + [Description("Incorrectly publishes the prepared method.")] + public string Prepared() + { + return "prepared"; + } + } + + [McpServerToolType] + public sealed class WrongResultProjectionTools + { + [McpServerTool(Name = CheatEngineToolNames.RuntimeGetInfo, Title = "Read result", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.HostScan)] + [Description("Reads the explicit result.")] + public string ReadExplicit() => "explicit"; + + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public int Prepared() + { + return 1; + } + } + + [McpServerToolType] + public sealed class NonShortProjectionTools + { + [McpServerTool(Name = CheatEngineToolNames.RuntimeGetInfo, Title = "Read nonshort", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.HostScan)] + [Description("Reads the explicit result.")] + public string ReadExplicit() => "explicit"; + + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.HostScan)] + public string Prepared() + { + return "prepared"; + } + } + + [McpServerToolType] + public sealed class GatedProjectionTools + { + [McpServerTool(Name = CheatEngineToolNames.RuntimeGetInfo, Title = "Read gated", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.HostScan)] + [Description("Reads the explicit result.")] + public string ReadExplicit() => "explicit"; + + [RequiresFeature(McpFeature.UnsafeLua)] + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public string Prepared() + { + return "prepared"; + } + } + + [McpServerToolType] + public sealed class MissingSourceClassTools + { + [McpServerTool(Name = CheatEngineToolNames.RuntimeGetInfo, Title = "Read missing class", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [Description("Reads the explicit result.")] + public string ReadExplicit() => "explicit"; + + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public string Prepared() => "prepared"; + } + + [McpServerToolType] + public sealed class UnknownSourceClassTools + { + [McpServerTool(Name = CheatEngineToolNames.RuntimeGetInfo, Title = "Read unknown class", ReadOnly = true, + Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true)] + [McpMeta(McpDispatchClass.MetaKey, "unreviewed")] + [Description("Reads the explicit result.")] + public string ReadExplicit() => "explicit"; + + [McpMeta(McpDispatchClass.MetaKey, McpDispatchClass.Short)] + public string Prepared() => "prepared"; + } + + [McpServerResourceType] + public sealed class ValidResource + { + [McpServerResource(UriTemplate = "cheatengine://instance/prepared-valid", Name = "instance_prepared_valid", + Title = "Prepared valid", MimeType = "application/json")] + [McpSourceTool(typeof(ValidTools), CheatEngineToolNames.RuntimeGetInfo, + PreparedProjection = nameof(ValidTools.ReadPrepared))] + [Description("Reads the prepared result.")] + public string Read() + { + return "prepared"; + } + } + + [McpServerResourceType] + public sealed class MissingProjectionResource + { + [McpServerResource(UriTemplate = "cheatengine://instance/prepared-missing", Name = "instance_prepared_missing", + Title = "Prepared missing", MimeType = "application/json")] + [McpSourceTool(typeof(MissingProjectionTools), CheatEngineToolNames.RuntimeGetInfo, + PreparedProjection = "Missing")] + [Description("Names no prepared method.")] + public string Read() => "x"; + } + + [McpServerResourceType] + public sealed class OverloadedProjectionResource + { + [McpServerResource(UriTemplate = "cheatengine://instance/prepared-overloaded", Name = "instance_prepared_overloaded", + Title = "Prepared overloaded", MimeType = "application/json")] + [McpSourceTool(typeof(OverloadedProjectionTools), CheatEngineToolNames.RuntimeGetInfo, + PreparedProjection = nameof(OverloadedProjectionTools.Prepared))] + [Description("Names an overloaded prepared method.")] + public string Read() => "x"; + } + + [McpServerResourceType] + public sealed class ToolProjectionResource + { + [McpServerResource(UriTemplate = "cheatengine://instance/prepared-tool", Name = "instance_prepared_tool", + Title = "Prepared tool", MimeType = "application/json")] + [McpSourceTool(typeof(ToolProjectionTools), CheatEngineToolNames.RuntimeGetInfo, + PreparedProjection = nameof(ToolProjectionTools.Prepared))] + [Description("Names a published tool as prepared.")] + public string Read() => "x"; + } + + [McpServerResourceType] + public sealed class WrongResultProjectionResource + { + [McpServerResource(UriTemplate = "cheatengine://instance/prepared-result", Name = "instance_prepared_result", + Title = "Prepared result", MimeType = "application/json")] + [McpSourceTool(typeof(WrongResultProjectionTools), CheatEngineToolNames.RuntimeGetInfo, + PreparedProjection = nameof(WrongResultProjectionTools.Prepared))] + [Description("Names a prepared method with another result type.")] + public string Read() => "x"; + } + + [McpServerResourceType] + public sealed class NonShortProjectionResource + { + [McpServerResource(UriTemplate = "cheatengine://instance/prepared-nonshort", Name = "instance_prepared_nonshort", + Title = "Prepared nonshort", MimeType = "application/json")] + [McpSourceTool(typeof(NonShortProjectionTools), CheatEngineToolNames.RuntimeGetInfo, + PreparedProjection = nameof(NonShortProjectionTools.Prepared))] + [Description("Names a nonshort prepared method.")] + public string Read() => "x"; + } + + [McpServerResourceType] + public sealed class GatedProjectionResource + { + [McpServerResource(UriTemplate = "cheatengine://instance/prepared-gated", Name = "instance_prepared_gated", + Title = "Prepared gated", MimeType = "application/json")] + [McpSourceTool(typeof(GatedProjectionTools), CheatEngineToolNames.RuntimeGetInfo, + PreparedProjection = nameof(GatedProjectionTools.Prepared))] + [Description("Names a feature-gated prepared method.")] + public string Read() => "x"; + } + + [McpServerResourceType] + public sealed class MissingSourceClassResource + { + [McpServerResource(UriTemplate = "cheatengine://instance/prepared-missing-class", + Name = "instance_prepared_missing_class", Title = "Prepared missing class", MimeType = "application/json")] + [McpSourceTool(typeof(MissingSourceClassTools), CheatEngineToolNames.RuntimeGetInfo, + PreparedProjection = nameof(MissingSourceClassTools.Prepared))] + [Description("Names a source with no dispatch class.")] + public string Read() => "x"; + } + + [McpServerResourceType] + public sealed class UnknownSourceClassResource + { + [McpServerResource(UriTemplate = "cheatengine://instance/prepared-unknown-class", + Name = "instance_prepared_unknown_class", Title = "Prepared unknown class", MimeType = "application/json")] + [McpSourceTool(typeof(UnknownSourceClassTools), CheatEngineToolNames.RuntimeGetInfo, + PreparedProjection = nameof(UnknownSourceClassTools.Prepared))] + [Description("Names a source with an unreviewed dispatch class.")] + public string Read() => "x"; + } +} diff --git a/tests/CheatEngine.Mcp.Tests/Hosting/BackendCompletionTests.cs b/tests/CheatEngine.Mcp.Tests/Hosting/BackendCompletionTests.cs index fa41fd2..1d73ce5 100644 --- a/tests/CheatEngine.Mcp.Tests/Hosting/BackendCompletionTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Hosting/BackendCompletionTests.cs @@ -1,3 +1,4 @@ +using CheatEngine.Mcp.Core.Contract; using CheatEngine.Mcp.Tests.Support; using CheatEngine.Mcp.Tests.Tools.Modules; @@ -14,12 +15,11 @@ namespace CheatEngine.Mcp.Tests.Hosting; public sealed class BackendCompletionTests { private const string Modules = McpResourceUris.InstancePrefix + "modules/{module}"; - private const string Exports = McpResourceUris.InstancePrefix + "modules/{module}/exports{?offset,limit}"; private static CancellationToken Token => TestContext.Current.CancellationToken; [Fact] - public async Task Complete_ModuleTemplatesOverStatelessHttp_ShareOneListingAcrossRequests() + public async Task Complete_ModuleDetailsOverStatelessHttp_ReusePreparedModulesWithoutEnumeration() { ModuleSymbolTarget target = ModuleToolTests.LoadedSample(); target.AddModule("game.exe", 0x140000000, 0x5000); @@ -37,15 +37,20 @@ public async Task Complete_ModuleTemplatesOverStatelessHttp_ShareOneListingAcros TransportMode = HttpTransportMode.StreamableHttp }), cancellationToken: Token); + CallToolResult prepared = await client.CallToolAsync(CheatEngineToolNames.ModuleList, + new Dictionary(), cancellationToken: Token); + Assert.NotEqual(true, prepared.IsError); + Assert.Equal(1, target.Calls("TryGetModules")); + CompleteResult module = await CompleteAsync(client, Modules, "SA"); - CompleteResult exports = await CompleteAsync(client, Exports, ""); + CompleteResult all = await CompleteAsync(client, Modules, ""); CompleteResult narrowed = await CompleteAsync(client, Modules, "g"); Assert.NotNull(client.ServerCapabilities.Completions); Assert.Equal(["sample.dll"], module.Completion.Values); - Assert.Equal(["sample.dll", "game.exe"], exports.Completion.Values); + Assert.Equal(["sample.dll", "game.exe"], all.Completion.Values); Assert.Equal(["game.exe"], narrowed.Completion.Values); - // Three HTTP requests, three server option sets, one handler: one dispatch for the module names. + // Each request checks its target afresh; only the explicit source tool enumerates modules. Assert.Equal(1, target.Calls("TryGetModules")); } finally diff --git a/tests/CheatEngine.Mcp.Tests/Hosting/ErrorCorrelationTests.cs b/tests/CheatEngine.Mcp.Tests/Hosting/ErrorCorrelationTests.cs index d6bff6a..ea4326e 100644 --- a/tests/CheatEngine.Mcp.Tests/Hosting/ErrorCorrelationTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Hosting/ErrorCorrelationTests.cs @@ -153,29 +153,100 @@ await client.ReadResourceAsync($"cheatengine://instances/{backend.Descriptor.Ins } [Fact] - public void Correlate_ExistingIdOrDetailsThatAreNotAnObject_AreKept() + public void Correlate_ExistingIdIsKeptAndOtherKindsAreNotCorrelated() { ToolError first = McpErrorCorrelation.Correlate(CheatEngineToolException.Internal("Failed.").Error, out string? firstId); ToolError again = McpErrorCorrelation.Correlate(first, out string? againId); - using JsonDocument array = JsonDocument.Parse("[1,2]"); - ToolError listed = CheatEngineToolException.Internal("Failed.").Error with - { - Details = array.RootElement.Clone() - }; - ToolError unchanged = McpErrorCorrelation.Correlate(listed, out string? listedId); ToolError other = McpErrorCorrelation.Correlate( CheatEngineToolException.NotFound("Missing.", "List first.").Error, out string? otherId); Assert.Equal(firstId, againId); Assert.Same(first, again); - Assert.Matches("^[0-9a-f]{16}$", listedId); - Assert.Equal("[1,2]", unchanged.Details!.Value.GetRawText()); - Assert.Null(McpErrorCorrelation.Read(unchanged)); Assert.Null(otherId); Assert.Null(other.Details); } + [Theory] + [InlineData("array", "[1,2]")] + [InlineData("string", "\"probe context\"")] + [InlineData("number", "9007199254740993")] + [InlineData("boolean", "true")] + [InlineData("null", "null")] + public async Task CallTool_NonObjectInternalDetails_KeepsValueAndReturnsTheLoggedErrorId(string kind, + string expectedJson) + { + LogCapture logs = new(); + await using LoggedPipeline pipeline = await LoggedPipeline.StartAsync(CorrelationProbes.Manifest, logs); + + CallToolResult result = await pipeline.CallAsync(CorrelationProbeTool.Name, $$"""{"failure":"{{kind}}"}"""); + + ToolError error = TestMcpPipeline.AssertError(result, ToolErrorKind.Internal); + string errorId = AssertErrorId(error.Details); + Assert.Equal(expectedJson, error.Details!.Value.GetProperty("value").GetRawText()); + Assert.Equal(ToolHostEffect.Unknown, error.HostEffect); + Assert.False(error.Retryable); + Assert.Single(logs.Entries, entry => entry.Text.Contains(errorId, StringComparison.Ordinal)); + ToolError repeated = McpErrorCorrelation.Correlate(error, out string? repeatedId); + Assert.Same(error, repeated); + Assert.Equal(errorId, repeatedId); + } + + [Theory] + [InlineData("null")] + [InlineData("42")] + public void Correlate_NonStringExistingId_ReplacesItWithoutDuplicateKeys(string invalidId) + { + using JsonDocument details = JsonDocument.Parse($$"""{"errorId":{{invalidId}},"step":2}"""); + ToolError original = CheatEngineToolException.Internal("Failed.").Error with + { + Details = details.RootElement.Clone() + }; + + ToolError correlated = McpErrorCorrelation.Correlate(original, out string? errorId); + + Assert.Equal(errorId, AssertErrorId(correlated.Details)); + Assert.Equal(2, correlated.Details!.Value.GetProperty("step").GetInt32()); + Assert.Single(correlated.Details.Value.EnumerateObject(), property => property.NameEquals("errorId")); + } + + [Theory] + [InlineData(ToolHostEffect.NotStarted)] + [InlineData(ToolHostEffect.NotApplied)] + [InlineData(ToolHostEffect.Started)] + [InlineData(ToolHostEffect.Completed)] + [InlineData(ToolHostEffect.CleanupUnconfirmed)] + [InlineData(ToolHostEffect.Unknown)] + public async Task CallTool_EveryHostEffect_PreservesCorrelatedEnvelopeThroughGateway(ToolHostEffect effect) + { + LogCapture logs = new(); + await using LoggedPipeline pipeline = await LoggedPipeline.StartAsync(CorrelationProbes.Manifest, logs); + CallToolResult direct = await pipeline.CallAsync(CorrelationProbeTool.Name, + $$"""{"failure":"{{effect}}"}"""); + ToolError directError = TestMcpPipeline.AssertError(direct, ToolErrorKind.Internal); + string errorId = AssertErrorId(directError.Details); + Assert.Equal(effect, directError.HostEffect); + Assert.Equal(CorrelationProbeTool.Operation, directError.Operation); + Assert.False(directError.Retryable); + Assert.Equal("Inspect the probe state.", directError.Hint); + Assert.Equal(2, directError.Details!.Value.GetProperty("step").GetInt32()); + Assert.Single(logs.Entries, entry => entry.Text.Contains(errorId, StringComparison.Ordinal)); + await using GatewayTestHost gateway = await GatewayTestHost.StartAsync(); + await using FakeBackend backend = await FakeBackend.StartAsync(gateway.Registry, "correlated-tool"); + await using McpClient client = await gateway.ConnectAsync(); + backend.Respond = () => direct; + + CallToolResult routed = await client.CallToolAsync(CorrelationProbeTool.Name, backend.RoutedArguments(), + cancellationToken: Token); + + ToolError routedError = TestMcpPipeline.AssertError(routed, ToolErrorKind.Internal); + Assert.Equal(errorId, AssertErrorId(routedError.Details)); + Assert.Equal(Assert.IsType(Assert.Single(direct.Content)).Text, + Assert.IsType(Assert.Single(routed.Content)).Text); + Assert.Null(routed.StructuredContent); + Assert.Equal(1, backend.ForwardedCallCount); + } + private static string AssertErrorId(JsonElement? details) { Assert.NotNull(details); @@ -309,12 +380,26 @@ public sealed class CorrelationProbeTool [Description("Raises the internal failure its argument names.")] public static ContractProbeResult Fail([Description("The failure to raise.")] string failure) { - using JsonDocument details = JsonDocument.Parse("""{"step":2}"""); + using JsonDocument details = JsonDocument.Parse(failure switch + { + "array" => "[1,2]", + "string" => "\"probe context\"", + "number" => "9007199254740993", + "boolean" => "true", + "null" => "null", + _ => """{"step":2}""" + }); + if (Enum.TryParse(failure, out ToolHostEffect effect)) + { + throw new CheatEngineToolException(new ToolError(ToolErrorKind.Internal, + "The probe failed.", Operation, effect, false, "Inspect the probe state.", details.RootElement.Clone())); + } + throw failure switch { - "details" => new CheatEngineToolException(new ToolError(ToolErrorKind.Internal, - "The probe stopped at step 2.", null, ToolHostEffect.Unknown, false, null, - details.RootElement.Clone())), + "details" or "array" or "string" or "number" or "boolean" or "null" => new CheatEngineToolException( + new ToolError(ToolErrorKind.Internal, "The probe stopped at step 2.", null, ToolHostEffect.Unknown, false, + null, details.RootElement.Clone())), "wrapped" => new CheatEngineToolException(new ToolError(ToolErrorKind.Internal, "The probe's step failed.", Operation, ToolHostEffect.Unknown, false), new FormatException(CorrelationProbeResource.Secret)), diff --git a/tests/CheatEngine.Mcp.Tests/Hosting/GatewayCompletionTests.cs b/tests/CheatEngine.Mcp.Tests/Hosting/GatewayCompletionTests.cs index 694d207..fa05ab3 100644 --- a/tests/CheatEngine.Mcp.Tests/Hosting/GatewayCompletionTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Hosting/GatewayCompletionTests.cs @@ -242,6 +242,11 @@ public async Task Complete_ThroughARealBackend_OffersTheLiveScannerAndModuleName { [McpResourceUris.InstanceIdVariable] = publication.Descriptor.InstanceId }; + CallToolResult prepared = await client.CallToolAsync(CheatEngineToolNames.ModuleList, + new Dictionary { [McpResourceUris.InstanceIdVariable] = publication.Descriptor.InstanceId }, + cancellationToken: Token); + Assert.NotEqual(true, prepared.IsError); + Assert.Equal(1, target.Calls("TryGetModules")); CompleteResult scanners = await CompleteAsync(client, "cheatengine://instances/{instanceId}/scanners/{scannerName}", "scannerName", "M", selected); @@ -249,14 +254,14 @@ public async Task Complete_ThroughARealBackend_OffersTheLiveScannerAndModuleName "cheatengine://instances/{instanceId}/records/{recordId}", "recordId", "1", selected); CompleteResult modules = await CompleteAsync(client, "cheatengine://instances/{instanceId}/modules/{module}", "module", "G", selected); - CompleteResult exports = await CompleteAsync(client, - "cheatengine://instances/{instanceId}/modules/{module}/exports{?offset,limit}", "module", "", selected); + CompleteResult all = await CompleteAsync(client, + "cheatengine://instances/{instanceId}/modules/{module}", "module", "", selected); Assert.Equal(["main"], scanners.Completion.Values); Assert.Empty(records.Completion.Values); Assert.Equal(["game.exe"], modules.Completion.Values); - Assert.Equal(["sample.dll", "game.exe"], exports.Completion.Values); - // Each forwarded keystroke is a new stateless request; the backend's one handler listed the modules once. + Assert.Equal(["sample.dll", "game.exe"], all.Completion.Values); + // Forwarded keystrokes check the prepared snapshot; only the explicit tool enumerates modules. Assert.Equal(1, target.Calls("TryGetModules")); } finally diff --git a/tests/CheatEngine.Mcp.Tests/Hosting/GatewayResourceTests.cs b/tests/CheatEngine.Mcp.Tests/Hosting/GatewayResourceTests.cs index 33f4b2d..fa8482f 100644 --- a/tests/CheatEngine.Mcp.Tests/Hosting/GatewayResourceTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Hosting/GatewayResourceTests.cs @@ -27,8 +27,8 @@ public sealed class GatewayResourceTests private static readonly string[] LivePaths = [ "debugger", "debugger/breakpoints{?limit}", "disassembly/{address}{?count}", "jobs", - "memory/{address}{?size}", "modules/{module}", "modules/{module}/exports{?offset,limit}", - "modules{?offset,limit}", "patches", "pointer-maps", "pointer-scans", + "memory/{address}{?size}", "modules/{module}", "modules{?offset,limit}", "patches", "pointer-maps", + "pointer-scans", "pointer-scans/{scanName}/paths{?offset,limit}", "process", "records/{recordId}", "records{?offset,limit}", "regions{?offset,limit}", "resources", "runtime", "scanners", "scanners/{scannerName}", "speedhack", "structures/{structure}{?offset,limit}", "structures{?offset,limit}", "symbols{?offset,limit}", "threads" diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionComparison.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionComparison.cs new file mode 100644 index 0000000..a3c92c3 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionComparison.cs @@ -0,0 +1,154 @@ +using System.Diagnostics; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +internal sealed record LiveAdmissionMetric(string Category, LiveAdmissionOutcomeKind Outcome, + LivePerformanceDecision? Decision, string? MissingEvidence); + +internal sealed record LiveAdmissionAssessment(IReadOnlyList Metrics, + IReadOnlyList MissingEvidence) +{ + public bool Passed => MissingEvidence.Count == 0 && Metrics.Count > 0 + && Metrics.All(static metric => metric.Decision?.Passed == true); +} + +/// Compares observed response classes only; absent refusals never become successful admission evidence. +internal static class LiveAdmissionComparison +{ + internal const int Rounds = 4; + internal const int BatchRequestsPerRound = 8; + internal const int RequestsPerRound = BatchRequestsPerRound + 5; + private static readonly (string Category, string Client)[] s_observers = + [("discovery", "gateway-0"), ("host-b-sentinel", "gateway-1"), + ("ui-snapshot", "ce-ui-timer"), ("regions-resource", "gateway-1")]; + + internal static LiveAdmissionAssessment Compare(IReadOnlyList baselines, + LiveAdmissionSession candidate) + { + ArgumentNullException.ThrowIfNull(baselines); + if (baselines.Count != LivePerformanceQualification.RequiredBaselineRuns) + { + throw new InvalidOperationException("Admission comparisons require exactly three baseline sessions."); + } + List missing = []; + foreach (LiveAdmissionSession session in baselines.Append(candidate)) + { + Validate(session); + if (!session.Bursts.Any(burst => LiveAdmissionMeasurement.Analyze( + burst.Samples.Where(static sample => sample.Category == "batch").ToArray()).CrossClientOverlap)) + { + missing.Add("A session did not observe overlapping batch requests from both gateways."); + } + foreach (LiveAdmissionBurst burst in session.Bursts) + { + LiveAdmissionSample[] work = burst.Samples.Where(static sample => sample.Category == "batch" + && sample.Outcome.Kind == LiveAdmissionOutcomeKind.Succeeded).ToArray(); + foreach (LiveAdmissionSample sample in burst.Samples.Where(static sample => sample.Category != "batch" + || sample.Outcome.Kind == LiveAdmissionOutcomeKind.Busy)) + { + if (!work.Any(batch => Overlaps(sample, batch))) + { + missing.Add($"'{sample.Id}' did not overlap a successful batch request; its latency is not under-load evidence."); + } + } + } + } + + HashSet<(string Category, LiveAdmissionOutcomeKind Outcome)> keys = baselines.Append(candidate) + .SelectMany(static session => session.Bursts).SelectMany(static burst => burst.Samples) + .Select(static sample => (sample.Category, sample.Outcome.Kind)).ToHashSet(); + // Both successful work and a real refusal need their own observations. An empty completion cannot replace either. + keys.Add(("batch", LiveAdmissionOutcomeKind.Succeeded)); + keys.Add(("batch", LiveAdmissionOutcomeKind.Busy)); + keys.Add(("regions-resource", LiveAdmissionOutcomeKind.Succeeded)); + keys.Add(("regions-resource", LiveAdmissionOutcomeKind.Busy)); + keys.Add(("module-completion-first", LiveAdmissionOutcomeKind.Succeeded)); + keys.Add(("module-completion-repeat", LiveAdmissionOutcomeKind.Succeeded)); + List metrics = []; + foreach ((string category, LiveAdmissionOutcomeKind outcome) in keys.OrderBy(static key => key.Category, StringComparer.Ordinal) + .ThenBy(static key => key.Outcome)) + { + LiveLatencyProfile?[] baselineProfiles = baselines.Select(session => Profile(session, category, outcome)).ToArray(); + LiveLatencyProfile? candidateProfile = Profile(candidate, category, outcome); + if (candidateProfile is null || baselineProfiles.Any(static profile => profile is null)) + { + string reason = $"'{category}/{outcome}' lacks an observation in the candidate or one of the three baselines."; + missing.Add(reason); + metrics.Add(new LiveAdmissionMetric(category, outcome, null, reason)); + continue; + } + LiveBaselineProfile baseline = LivePerformanceQualification.Baseline(baselineProfiles.Select(static profile => profile!)); + LivePerformanceDecision decision = category == "batch" && outcome == LiveAdmissionOutcomeKind.Succeeded + ? LivePerformanceQualification.EvaluateBoundedJobs(baseline, candidateProfile) + : LivePerformanceQualification.EvaluateShortCalls(baseline, candidateProfile); + metrics.Add(new LiveAdmissionMetric(category, outcome, decision, null)); + } + return new LiveAdmissionAssessment(metrics, missing); + } + + internal static void Validate(LiveAdmissionSession session) + { + ArgumentNullException.ThrowIfNull(session); + if (session.Bursts.Count != Rounds) + { + throw new InvalidOperationException("An admission session must contain all four fixed rounds."); + } + HashSet ids = new(StringComparer.Ordinal); + for (int round = 0; round < session.Bursts.Count; round++) + { + LiveAdmissionBurst burst = session.Bursts[round]; + if (burst.Samples.Count != RequestsPerRound || burst.Samples.Any(sample => + string.IsNullOrWhiteSpace(sample.Id) || !ids.Add(sample.Id))) + { + throw new InvalidOperationException("Each admission round must contain thirteen uniquely identified started requests."); + } + LiveAdmissionBurst measured = LiveAdmissionMeasurement.Analyze(burst.Samples); + if (measured.MaximumConcurrentRequests != burst.MaximumConcurrentRequests || measured.CrossClientOverlap != burst.CrossClientOverlap) + { + throw new InvalidOperationException("Admission concurrency must match the recorded request intervals."); + } + foreach (string client in new[] { "gateway-0", "gateway-1" }) + { + if (burst.Samples.Count(sample => sample.Category == "batch" && sample.ClientId == client) != BatchRequestsPerRound / 2) + { + throw new InvalidOperationException("Each gateway must issue exactly four batch requests per round."); + } + } + foreach ((string category, string client) in s_observers.Append( + (round == 0 ? "module-completion-first" : "module-completion-repeat", "gateway-0"))) + { + if (burst.Samples.Count(sample => sample.Category == category && sample.ClientId == client) != 1) + { + throw new InvalidOperationException($"The fixed admission observer '{category}' is missing or repeated."); + } + } + if (burst.Samples.Any(static sample => !ExpectedOutcome(sample))) + { + throw new InvalidOperationException("Admission observations contain unexpected failures or incomplete requests."); + } + } + } + + internal static bool ExpectedOutcome(LiveAdmissionSample sample) + { + return sample.Outcome.Kind == LiveAdmissionOutcomeKind.Succeeded + || (sample.Outcome.Kind == LiveAdmissionOutcomeKind.Busy && sample.Outcome.ErrorKind == "busy" + && sample.Outcome.HostEffect == "not_started" && sample.Category is "batch" or "regions-resource") + || (sample.Outcome.Kind == LiveAdmissionOutcomeKind.EmptyCompletion + && sample.Category.StartsWith("module-completion-", StringComparison.Ordinal)); + } + + private static LiveLatencyProfile? Profile(LiveAdmissionSession session, string category, LiveAdmissionOutcomeKind outcome) + { + TimeSpan[] durations = session.Bursts.SelectMany(static burst => burst.Samples) + .Where(sample => sample.Category == category && sample.Outcome.Kind == outcome) + .Select(static sample => Stopwatch.GetElapsedTime(sample.StartedTimestamp, sample.FinishedTimestamp)).ToArray(); + return durations.Length == 0 ? null : LivePerformanceQualification.Profile(durations); + } + + private static bool Overlaps(LiveAdmissionSample sample, LiveAdmissionSample work) + { + return sample.StartedTimestamp < sample.FinishedTimestamp && work.StartedTimestamp < work.FinishedTimestamp + && sample.StartedTimestamp < work.FinishedTimestamp && work.StartedTimestamp < sample.FinishedTimestamp; + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionComparisonTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionComparisonTests.cs new file mode 100644 index 0000000..05dabc4 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionComparisonTests.cs @@ -0,0 +1,249 @@ +using System.Diagnostics; +using System.Text.Json; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +public sealed class LiveAdmissionComparisonTests +{ + [Fact] + public void Compare_RequiresObservedSuccessRefusalOverlapAndThreeBaselines() + { + LiveAdmissionSession baseline = Session(); + + LiveAdmissionAssessment result = LiveAdmissionComparison.Compare([baseline, baseline, baseline], Session()); + + Assert.True(result.Passed); + Assert.Empty(result.MissingEvidence); + Assert.Equal(9, result.Metrics.Count); + Assert.All(result.Metrics, metric => Assert.True(metric.Decision!.Passed)); + using JsonDocument receipt = JsonDocument.Parse(JsonSerializer.Serialize(result)); + Assert.True(receipt.RootElement.GetProperty("Passed").GetBoolean()); + Assert.Throws(() => LiveAdmissionComparison.Compare([baseline, baseline], Session())); + } + + [Fact] + public void Compare_FastSuccessfulRequestsCannotReplaceMissingRefusals() + { + LiveAdmissionSession baseline = Session(); + LiveAdmissionSession candidate = Map(Session(), sample => sample with { Outcome = Success() }); + + LiveAdmissionAssessment result = LiveAdmissionComparison.Compare([baseline, baseline, baseline], candidate); + + Assert.False(result.Passed); + Assert.Equal(2, result.MissingEvidence.Count); + Assert.All(result.Metrics.Where(metric => metric.Outcome == LiveAdmissionOutcomeKind.Busy), metric => + { + Assert.Null(metric.Decision); + Assert.NotNull(metric.MissingEvidence); + }); + } + + [Fact] + public void Compare_OneBaselineMissingAnOutcomeCannotBeReplacedByTheOtherTwo() + { + LiveAdmissionSession baseline = Session(); + LiveAdmissionSession incomplete = Map(Session(), sample => sample.Category == "regions-resource" + ? sample with + { + Outcome = Success() + } : sample); + + LiveAdmissionAssessment result = LiveAdmissionComparison.Compare([baseline, incomplete, baseline], Session()); + + Assert.False(result.Passed); + Assert.Contains("regions-resource/Busy", Assert.Single(result.MissingEvidence), StringComparison.Ordinal); + } + + [Fact] + public void Compare_EmptyCompletionDoesNotProveSuccessfulNativeListing() + { + LiveAdmissionSession baseline = Session(); + LiveAdmissionSession candidate = Map(Session(), sample => sample.Category.StartsWith("module-completion-", StringComparison.Ordinal) + ? sample with + { + Outcome = new(LiveAdmissionOutcomeKind.EmptyCompletion) + } : sample); + + LiveAdmissionAssessment result = LiveAdmissionComparison.Compare([baseline, baseline, baseline], candidate); + + Assert.False(result.Passed); + Assert.All(result.Metrics.Where(metric => metric.Category.StartsWith("module-completion-", StringComparison.Ordinal)), + metric => Assert.NotNull(metric.MissingEvidence)); + } + + [Fact] + public void Compare_UiOverlapCannotReplaceCrossGatewayBatchOverlap() + { + LiveAdmissionSession baseline = Session(); + LiveAdmissionSession candidate = Map(Session(), sample => sample.Category == "batch" && sample.ClientId == "gateway-1" + ? sample with + { + StartedTimestamp = sample.StartedTimestamp + Ticks(500), + FinishedTimestamp = sample.FinishedTimestamp + Ticks(500) + } + : sample); + Assert.All(candidate.Bursts, burst => Assert.True(burst.CrossClientOverlap)); + + LiveAdmissionAssessment result = LiveAdmissionComparison.Compare([baseline, baseline, baseline], candidate); + + Assert.False(result.Passed); + Assert.Contains("overlapping batch", Assert.Single(result.MissingEvidence), StringComparison.Ordinal); + } + + [Theory] + [InlineData("batch", "Succeeded", 150, true)] + [InlineData("batch", "Succeeded", 151, false)] + [InlineData("batch", "Busy", 200, true)] + [InlineData("batch", "Busy", 201, false)] + [InlineData("regions-resource", "Succeeded", 200, true)] + [InlineData("regions-resource", "Succeeded", 201, false)] + public void Compare_AppliesResponseSpecificBudgetAtBoundary(string category, string outcomeName, + int milliseconds, bool expected) + { + LiveAdmissionOutcomeKind outcome = Enum.Parse(outcomeName); + LiveAdmissionSession baseline = Session(); + LiveAdmissionSession candidate = Map(Session(), sample => sample.Category == category && sample.Outcome.Kind == outcome + ? sample with + { + FinishedTimestamp = sample.StartedTimestamp + Ticks(milliseconds) + } : sample); + + LiveAdmissionAssessment result = LiveAdmissionComparison.Compare([baseline, baseline, baseline], candidate); + + Assert.Equal(expected, result.Passed); + Assert.Equal(expected, Assert.Single(result.Metrics, metric => metric.Category == category && metric.Outcome == outcome).Decision!.Passed); + } + + [Theory] + [InlineData(false, 20)] + [InlineData(true, 8)] + public void Compare_RejectsUnloadedObserverAndBusyTiming(bool moveBusy, int missingCount) + { + LiveAdmissionSession baseline = Session(); + LiveAdmissionSession candidate = Map(Session(), sample => + (moveBusy ? sample.Category == "batch" && sample.Outcome.Kind == LiveAdmissionOutcomeKind.Busy : sample.Category != "batch") + ? sample with + { + StartedTimestamp = sample.StartedTimestamp + Ticks(500), + FinishedTimestamp = sample.FinishedTimestamp + Ticks(500) + } + : sample); + + LiveAdmissionAssessment result = LiveAdmissionComparison.Compare([baseline, baseline, baseline], candidate); + + Assert.False(result.Passed); + Assert.Equal(missingCount, result.MissingEvidence.Count); + Assert.All(result.MissingEvidence, gap => Assert.Contains("not under-load evidence", gap, StringComparison.Ordinal)); + } + + [Theory] + [InlineData("round")] + [InlineData("request")] + [InlineData("duplicate")] + [InlineData("concurrency")] + [InlineData("overlap")] + [InlineData("observer-client")] + [InlineData("batch-client")] + [InlineData("busy-effect")] + [InlineData("timeout")] + public void Validate_RejectsIncompleteOrMisreportedEvidence(string defect) + { + LiveAdmissionSession session = Session(); + LiveAdmissionBurst[] bursts = session.Bursts.ToArray(); + LiveAdmissionSample[] samples = bursts[0].Samples.ToArray(); + switch (defect) + { + case "round": + bursts = bursts[..3]; + break; + case "request": + bursts[0] = LiveAdmissionMeasurement.Analyze(samples[..12]); + break; + case "duplicate": + samples[1] = samples[1] with + { + Id = samples[0].Id + }; + bursts[0] = LiveAdmissionMeasurement.Analyze(samples); + break; + case "concurrency": + bursts[0] = bursts[0] with + { + MaximumConcurrentRequests = 1 + }; + break; + case "overlap": + bursts[0] = bursts[0] with + { + CrossClientOverlap = false + }; + break; + case "observer-client": + samples[8] = samples[8] with + { + ClientId = "gateway-1" + }; + bursts[0] = LiveAdmissionMeasurement.Analyze(samples); + break; + case "batch-client": + samples[0] = samples[0] with + { + ClientId = "gateway-1" + }; + bursts[0] = LiveAdmissionMeasurement.Analyze(samples); + break; + case "busy-effect": + samples[0] = samples[0] with + { + Outcome = new(LiveAdmissionOutcomeKind.Busy, "busy", "started") + }; + bursts[0] = LiveAdmissionMeasurement.Analyze(samples); + break; + case "timeout": + samples[0] = samples[0] with + { + Outcome = new(LiveAdmissionOutcomeKind.Timeout) + }; + bursts[0] = LiveAdmissionMeasurement.Analyze(samples); + break; + } + + Assert.Throws(() => LiveAdmissionComparison.Validate(new LiveAdmissionSession(bursts))); + } + + private static LiveAdmissionSession Session() + { + List bursts = []; + for (int round = 0; round < 4; round++) + { + long started = Ticks(1000 * (round + 1)); + List samples = []; + for (int index = 0; index < 8; index++) + { + samples.Add(new($"{round}-batch-{index}", $"gateway-{index % 2}", "batch", started, started + Ticks(100), + index < 2 ? Busy() : Success())); + } + (string Category, string Client)[] observers = + [ + ("discovery", "gateway-0"), ("host-b-sentinel", "gateway-1"), ("ui-snapshot", "ce-ui-timer"), + ("regions-resource", "gateway-1"), (round == 0 ? "module-completion-first" : "module-completion-repeat", "gateway-0") + ]; + foreach ((string category, string client) in observers) + { + samples.Add(new($"{round}-{category}", client, category, started, started + Ticks(100), + category == "regions-resource" && round % 2 == 0 ? Busy() : Success())); + } + bursts.Add(LiveAdmissionMeasurement.Analyze(samples)); + } + return new LiveAdmissionSession(bursts); + } + + private static LiveAdmissionSession Map(LiveAdmissionSession session, Func transform) + { + return new(session.Bursts.Select(burst => LiveAdmissionMeasurement.Analyze(burst.Samples.Select(transform).ToArray())).ToArray()); + } + + private static long Ticks(int milliseconds) => Stopwatch.Frequency * milliseconds / 1000; + private static LiveAdmissionOutcome Success() => new(LiveAdmissionOutcomeKind.Succeeded); + private static LiveAdmissionOutcome Busy() => new(LiveAdmissionOutcomeKind.Busy, "busy", "not_started"); +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionMeasurement.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionMeasurement.cs new file mode 100644 index 0000000..7aa618f --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionMeasurement.cs @@ -0,0 +1,278 @@ +using System.Diagnostics; +using System.Text.Json.Nodes; + +using ModelContextProtocol; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +internal enum LiveAdmissionOutcomeKind +{ + Succeeded, + Busy, + Timeout, + Cancelled, + EmptyCompletion, + Failed +} + +internal sealed record LiveAdmissionOutcome(LiveAdmissionOutcomeKind Kind, string? ErrorKind = null, + string? HostEffect = null, string? ExceptionType = null, string? Diagnostic = null, + string? Operation = null, string? ErrorId = null); + +internal sealed record LiveAdmissionRequest(string Id, string ClientId, string Category, + Func> Invoke); + +internal sealed record LiveAdmissionSample(string Id, string ClientId, string Category, long StartedTimestamp, + long FinishedTimestamp, LiveAdmissionOutcome Outcome); + +internal sealed record LiveAdmissionBurst(IReadOnlyList Samples, int MaximumConcurrentRequests, + bool CrossClientOverlap); + +/// Measures finite managed request bursts without asserting native admission from managed scheduling. +internal static class LiveAdmissionMeasurement +{ + private const int MaximumRequests = 16; + internal const int MaximumDiagnosticCharacters = 8192; + + internal static async Task MeasureAsync(IReadOnlyList requests, + CancellationToken cancellationToken) + { + ValidateRequests(requests); + cancellationToken.ThrowIfCancellationRequested(); + + TaskCompletionSource barrier = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task[] pending = requests + .Select(request => MeasureRequestAsync(request, barrier.Task, cancellationToken)) + .ToArray(); + barrier.SetResult(); + + LiveAdmissionSample?[] completed = await Task.WhenAll(pending); + return Analyze(completed.OfType().ToArray()); + } + + internal static LiveAdmissionBurst Analyze(IReadOnlyList samples) + { + ArgumentNullException.ThrowIfNull(samples); + foreach (LiveAdmissionSample sample in samples) + { + if (sample.FinishedTimestamp < sample.StartedTimestamp) + { + throw new ArgumentException("Admission samples must use monotonic nondecreasing timestamps.", nameof(samples)); + } + } + + int maximum = CalculateMaximumConcurrency(samples); + bool crossClientOverlap = HasCrossClientOverlap(samples); + return new LiveAdmissionBurst(samples.ToArray(), maximum, crossClientOverlap); + } + + internal static LiveAdmissionOutcome ClassifyTool(LiveMcpToolResult result) + { + ArgumentNullException.ThrowIfNull(result); + if (!result.IsError) + { + return new LiveAdmissionOutcome(LiveAdmissionOutcomeKind.Succeeded); + } + + if (result.Payload is not JsonObject payload + || payload["error"] is not JsonObject error + || !TryGetString(error["kind"], out string? kind)) + { + return new LiveAdmissionOutcome(LiveAdmissionOutcomeKind.Failed); + } + + _ = TryGetString(error["hostEffect"], out string? hostEffect); + _ = TryGetString(error["message"], out string? message); + _ = TryGetString(error["operation"], out string? operation); + _ = TryGetString((error["details"] as JsonObject)?["errorId"], out string? errorId); + LiveAdmissionOutcome outcome = new(LiveAdmissionOutcomeKind.Failed, kind, hostEffect, + Diagnostic: Bounded(message, MaximumDiagnosticCharacters), Operation: Bounded(operation, 256), ErrorId: Bounded(errorId, 256)); + if (string.Equals(kind, "busy", StringComparison.Ordinal) + && string.Equals(hostEffect, "not_started", StringComparison.Ordinal)) + { + return outcome with + { + Kind = LiveAdmissionOutcomeKind.Busy + }; + } + + if (string.Equals(kind, "timeout", StringComparison.Ordinal)) + { + return outcome with + { + Kind = LiveAdmissionOutcomeKind.Timeout + }; + } + + if (string.Equals(kind, "cancelled", StringComparison.Ordinal)) + { + return outcome with + { + Kind = LiveAdmissionOutcomeKind.Cancelled + }; + } + + return outcome; + } + + internal static LiveAdmissionOutcome ClassifyException(Exception exception, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(exception); + string? kind = null; + string? hostEffect = null; + string? operation = null; + string? errorId = null; + if (exception is McpProtocolException protocol) + { + kind = protocol.Data["kind"] as string; + hostEffect = protocol.Data["hostEffect"] as string; + operation = protocol.Data["operation"] as string; + errorId = protocol.Data["errorId"] as string; + } + LiveAdmissionOutcome outcome = new(LiveAdmissionOutcomeKind.Failed, kind, hostEffect, + exception.GetType().FullName, Bounded(exception.ToString(), MaximumDiagnosticCharacters), + Bounded(operation, 256), Bounded(errorId, 256)); + if (string.Equals(kind, "busy", StringComparison.Ordinal) + && string.Equals(hostEffect, "not_started", StringComparison.Ordinal)) + { + return outcome with + { + Kind = LiveAdmissionOutcomeKind.Busy + }; + } + + if (string.Equals(kind, "timeout", StringComparison.Ordinal) || exception is TimeoutException) + { + return outcome with + { + Kind = LiveAdmissionOutcomeKind.Timeout + }; + } + + if (string.Equals(kind, "cancelled", StringComparison.Ordinal) + || (exception is OperationCanceledException && cancellationToken.IsCancellationRequested)) + { + return outcome with + { + Kind = LiveAdmissionOutcomeKind.Cancelled + }; + } + + return outcome; + } + + private static string? Bounded(string? text, int maximum) + { + return text is null || text.Length <= maximum ? text : text[..(maximum - 3)] + "..."; + } + + private static async Task MeasureRequestAsync(LiveAdmissionRequest request, Task barrier, + CancellationToken cancellationToken) + { + await barrier.ConfigureAwait(false); + if (cancellationToken.IsCancellationRequested) + { + return null; + } + + long started = Stopwatch.GetTimestamp(); + LiveAdmissionOutcome outcome; + try + { + outcome = await request.Invoke().ConfigureAwait(false); + } + catch (Exception exception) + { + outcome = ClassifyException(exception, cancellationToken); + } + + long finished = Stopwatch.GetTimestamp(); + return new LiveAdmissionSample(request.Id, request.ClientId, request.Category, started, finished, outcome); + } + + private static void ValidateRequests(IReadOnlyList requests) + { + ArgumentNullException.ThrowIfNull(requests); + if (requests.Count is < 1 or > MaximumRequests) + { + throw new ArgumentOutOfRangeException(nameof(requests), "Admission bursts contain from one through sixteen requests."); + } + + HashSet ids = new(StringComparer.Ordinal); + foreach (LiveAdmissionRequest request in requests) + { + ArgumentNullException.ThrowIfNull(request); + if (string.IsNullOrWhiteSpace(request.Id) + || string.IsNullOrWhiteSpace(request.ClientId) + || string.IsNullOrWhiteSpace(request.Category)) + { + throw new ArgumentException("Admission request ID, client ID and category must be nonblank.", nameof(requests)); + } + + ArgumentNullException.ThrowIfNull(request.Invoke); + if (!ids.Add(request.Id)) + { + throw new ArgumentException("Admission request IDs must be unique.", nameof(requests)); + } + } + } + + private static int CalculateMaximumConcurrency(IReadOnlyList samples) + { + List<(long Timestamp, int Delta)> events = []; + foreach (LiveAdmissionSample sample in samples) + { + if (sample.StartedTimestamp != sample.FinishedTimestamp) + { + events.Add((sample.StartedTimestamp, 1)); + events.Add((sample.FinishedTimestamp, -1)); + } + } + + int active = 0; + int maximum = 0; + foreach ((long _, int delta) in events.OrderBy(static item => item.Timestamp).ThenBy(static item => item.Delta)) + { + active += delta; + maximum = Math.Max(maximum, active); + } + + return maximum; + } + + private static bool HasCrossClientOverlap(IReadOnlyList samples) + { + for (int left = 0; left < samples.Count; left++) + { + for (int right = left + 1; right < samples.Count; right++) + { + LiveAdmissionSample first = samples[left]; + LiveAdmissionSample second = samples[right]; + if (first.StartedTimestamp != first.FinishedTimestamp + && second.StartedTimestamp != second.FinishedTimestamp + && !string.Equals(first.ClientId, second.ClientId, StringComparison.Ordinal) + && first.StartedTimestamp < second.FinishedTimestamp + && second.StartedTimestamp < first.FinishedTimestamp) + { + return true; + } + } + } + + return false; + } + + private static bool TryGetString(JsonNode? node, out string? value) + { + value = null; + try + { + value = node?.GetValue(); + return value is not null; + } + catch (InvalidOperationException) + { + return false; + } + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionMeasurementTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionMeasurementTests.cs new file mode 100644 index 0000000..4d98971 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionMeasurementTests.cs @@ -0,0 +1,271 @@ +using System.Text.Json.Nodes; + +using ModelContextProtocol; +using ModelContextProtocol.Protocol; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +public sealed class LiveAdmissionMeasurementTests +{ + private static readonly TimeSpan s_coordinationTimeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task MeasureAsync_WaitsForEveryPeerAfterOneFails() + { + TaskCompletionSource peerEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource releasePeer = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task measuring = LiveAdmissionMeasurement.MeasureAsync( + [ + Request("failure", "client-a", () => throw new InvalidOperationException("not reported")), + Request("peer", "client-b", async () => + { + peerEntered.SetResult(); + await releasePeer.Task; + return Success(); + }) + ], CancellationToken.None); + + LiveAdmissionBurst? burst = null; + try + { + await peerEntered.Task.WaitAsync(s_coordinationTimeout, TestContext.Current.CancellationToken); + Assert.False(measuring.IsCompleted); + } + finally + { + releasePeer.TrySetResult(); + burst = await measuring.WaitAsync(s_coordinationTimeout, CancellationToken.None); + } + + LiveAdmissionBurst settled = burst ?? throw new InvalidOperationException("The measured burst did not settle."); + Assert.Equal(2, settled.Samples.Count); + Assert.Equal(LiveAdmissionOutcomeKind.Failed, Assert.Single(settled.Samples, sample => sample.Id == "failure").Outcome.Kind); + Assert.Contains("not reported", Assert.Single(settled.Samples, sample => sample.Id == "failure").Outcome.Diagnostic, StringComparison.Ordinal); + Assert.Equal(LiveAdmissionOutcomeKind.Succeeded, Assert.Single(settled.Samples, sample => sample.Id == "peer").Outcome.Kind); + } + + [Fact] + public async Task MeasureAsync_ValidatesEveryRequestBeforeInvokingAny() + { + int invoked = 0; + LiveAdmissionRequest first = Request("duplicate", "client-a", () => + { + invoked++; + return Task.FromResult(Success()); + }); + LiveAdmissionRequest second = Request("duplicate", "client-b", () => Task.FromResult(Success())); + + await Assert.ThrowsAsync(() => + LiveAdmissionMeasurement.MeasureAsync([first, second], CancellationToken.None)); + + Assert.Equal(0, invoked); + } + + [Fact] + public async Task MeasureAsync_RejectsMoreThanSixteenRequestsBeforeInvokingAny() + { + int invoked = 0; + LiveAdmissionRequest[] requests = Enumerable.Range(0, 17) + .Select(index => Request(index.ToString(System.Globalization.CultureInfo.InvariantCulture), "client-a", () => + { + invoked++; + return Task.FromResult(Success()); + })) + .ToArray(); + + await Assert.ThrowsAsync(() => + LiveAdmissionMeasurement.MeasureAsync(requests, CancellationToken.None)); + + Assert.Equal(0, invoked); + } + + [Fact] + public async Task MeasureAsync_PreStartCancellationInvokesNothing() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + int invoked = 0; + + await Assert.ThrowsAnyAsync(() => + LiveAdmissionMeasurement.MeasureAsync([Request("one", "client-a", () => + { + invoked++; + return Task.FromResult(Success()); + })], cancellation.Token)); + + Assert.Equal(0, invoked); + } + + [Fact] + public async Task MeasureAsync_ActiveCallerCancellationSettlesAndClassifiesTheInvocation() + { + using CancellationTokenSource cancellation = new(); + TaskCompletionSource cancelledEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource peerEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource releasePeer = new(TaskCreationOptions.RunContinuationsAsynchronously); + Task measuring = LiveAdmissionMeasurement.MeasureAsync( + [ + Request("cancelled", "client-a", async () => + { + cancelledEntered.SetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellation.Token); + return Success(); + }), + Request("peer", "client-b", async () => + { + peerEntered.SetResult(); + await releasePeer.Task; + return Success(); + }) + ], cancellation.Token); + + LiveAdmissionBurst? burst = null; + try + { + await Task.WhenAll(cancelledEntered.Task, peerEntered.Task).WaitAsync(s_coordinationTimeout, TestContext.Current.CancellationToken); + cancellation.Cancel(); + Assert.False(measuring.IsCompleted); + } + finally + { + cancellation.Cancel(); + releasePeer.TrySetResult(); + burst = await measuring.WaitAsync(s_coordinationTimeout, CancellationToken.None); + } + + LiveAdmissionBurst settled = burst ?? throw new InvalidOperationException("The measured burst did not settle."); + Assert.Equal(LiveAdmissionOutcomeKind.Cancelled, + Assert.Single(settled.Samples, sample => sample.Id == "cancelled").Outcome.Kind); + Assert.Equal(LiveAdmissionOutcomeKind.Succeeded, + Assert.Single(settled.Samples, sample => sample.Id == "peer").Outcome.Kind); + } + + [Fact] + public void Analyze_UsesHalfOpenIntervalsAndDistinctClientOverlap() + { + LiveAdmissionSample first = Sample("one", "client-a", 10, 20); + LiveAdmissionSample second = Sample("two", "client-b", 20, 30); + LiveAdmissionSample third = Sample("three", "client-c", 15, 25); + LiveAdmissionSample empty = Sample("empty", "client-d", 20, 20); + + LiveAdmissionBurst halfOpen = LiveAdmissionMeasurement.Analyze([first, second]); + LiveAdmissionBurst overlap = LiveAdmissionMeasurement.Analyze([first, third]); + LiveAdmissionBurst emptyOverlap = LiveAdmissionMeasurement.Analyze([Sample("outer", "client-a", 10, 30), empty]); + + Assert.Equal(1, halfOpen.MaximumConcurrentRequests); + Assert.False(halfOpen.CrossClientOverlap); + Assert.Equal(2, overlap.MaximumConcurrentRequests); + Assert.True(overlap.CrossClientOverlap); + Assert.Equal(1, emptyOverlap.MaximumConcurrentRequests); + Assert.False(emptyOverlap.CrossClientOverlap); + } + + [Fact] + public void ClassifyTool_RequiresExactBusyHostEffectAndRejectsMalformedErrors() + { + LiveAdmissionOutcome busy = LiveAdmissionMeasurement.ClassifyTool(Error("busy", "not_started")); + LiveAdmissionOutcome wrongEffect = LiveAdmissionMeasurement.ClassifyTool(Error("busy", "started")); + LiveAdmissionOutcome malformed = LiveAdmissionMeasurement.ClassifyTool(new LiveMcpToolResult(true, + new JsonObject { ["error"] = new JsonObject { ["kind"] = 42 } }, null)); + LiveAdmissionOutcome scalar = LiveAdmissionMeasurement.ClassifyTool(new LiveMcpToolResult(true, + JsonValue.Create("malformed"), null)); + LiveAdmissionOutcome array = LiveAdmissionMeasurement.ClassifyTool(new LiveMcpToolResult(true, + new JsonArray(), null)); + + Assert.Equal(LiveAdmissionOutcomeKind.Busy, busy.Kind); + Assert.Equal(LiveAdmissionOutcomeKind.Failed, wrongEffect.Kind); + Assert.Equal(LiveAdmissionOutcomeKind.Failed, malformed.Kind); + Assert.Equal(LiveAdmissionOutcomeKind.Failed, scalar.Kind); + Assert.Equal(LiveAdmissionOutcomeKind.Failed, array.Kind); + } + + [Fact] + public async Task Classifiers_DistinguishProtocolToolFailureAndEmptyCompletion() + { + McpProtocolException protocol = new("busy", McpErrorCode.InternalError); + protocol.Data["kind"] = "busy"; + protocol.Data["hostEffect"] = "not_started"; + InvalidOperationException arbitrary = new(); + arbitrary.Data["kind"] = "busy"; + arbitrary.Data["hostEffect"] = "not_started"; + + LiveAdmissionOutcome protocolOutcome = LiveAdmissionMeasurement.ClassifyException(protocol, CancellationToken.None); + LiveAdmissionOutcome arbitraryOutcome = LiveAdmissionMeasurement.ClassifyException(arbitrary, CancellationToken.None); + LiveAdmissionOutcome timeoutOutcome = LiveAdmissionMeasurement.ClassifyTool(Error("timeout", "not_started")); + LiveAdmissionOutcome cancelledOutcome = LiveAdmissionMeasurement.ClassifyTool(Error("cancelled", "not_started")); + LiveAdmissionOutcome toolFailure = LiveAdmissionMeasurement.ClassifyTool(Error("host_refused", "not_started")); + LiveAdmissionBurst empty = await LiveAdmissionMeasurement.MeasureAsync( + [Request("empty", "client-a", () => Task.FromResult(new LiveAdmissionOutcome(LiveAdmissionOutcomeKind.EmptyCompletion)))], + CancellationToken.None); + + Assert.Equal(LiveAdmissionOutcomeKind.Busy, protocolOutcome.Kind); + Assert.Equal("busy", protocolOutcome.ErrorKind); + Assert.Equal(LiveAdmissionOutcomeKind.Failed, arbitraryOutcome.Kind); + Assert.Equal(LiveAdmissionOutcomeKind.Timeout, timeoutOutcome.Kind); + Assert.Equal(LiveAdmissionOutcomeKind.Cancelled, cancelledOutcome.Kind); + Assert.Equal(LiveAdmissionOutcomeKind.Failed, toolFailure.Kind); + Assert.Equal(LiveAdmissionOutcomeKind.EmptyCompletion, Assert.Single(empty.Samples).Outcome.Kind); + } + + [Fact] + public void ClassifyException_UnexpectedOperationCancellationIsFailed() + { + LiveAdmissionOutcome outcome = LiveAdmissionMeasurement.ClassifyException( + new OperationCanceledException(), CancellationToken.None); + + Assert.Equal(LiveAdmissionOutcomeKind.Failed, outcome.Kind); + } + + [Fact] + public void Classifiers_RetainBoundedDiagnosticsAndProtocolCorrelation() + { + McpProtocolException exception = new("outer failure", new InvalidOperationException("distinguishing inner cause"), McpErrorCode.InternalError); + exception.Data["operation"] = "resources/read"; + exception.Data["errorId"] = "correlation-123"; + LiveAdmissionOutcome protocol = LiveAdmissionMeasurement.ClassifyException(exception, CancellationToken.None); + LiveAdmissionOutcome oversized = LiveAdmissionMeasurement.ClassifyException( + new InvalidOperationException(new string('x', 9000)), CancellationToken.None); + LiveMcpToolResult error = Error("host_refused", "not_started"); + error.Payload!["error"]!["message"] = "tool distinction"; + error.Payload["error"]!["operation"] = "memory_read_batch"; + error.Payload["error"]!["details"] = new JsonObject { ["errorId"] = "tool-123" }; + LiveAdmissionOutcome tool = LiveAdmissionMeasurement.ClassifyTool(error); + + Assert.Contains("outer failure", protocol.Diagnostic, StringComparison.Ordinal); + Assert.Contains("distinguishing inner cause", protocol.Diagnostic, StringComparison.Ordinal); + Assert.Equal("resources/read", protocol.Operation); + Assert.Equal("correlation-123", protocol.ErrorId); + Assert.Equal(LiveAdmissionMeasurement.MaximumDiagnosticCharacters, oversized.Diagnostic!.Length); + Assert.EndsWith("...", oversized.Diagnostic, StringComparison.Ordinal); + Assert.Equal("tool distinction", tool.Diagnostic); + Assert.Equal("memory_read_batch", tool.Operation); + Assert.Equal("tool-123", tool.ErrorId); + } + + private static LiveAdmissionRequest Request(string id, string clientId, Func> invoke) + { + return new LiveAdmissionRequest(id, clientId, "admission", invoke); + } + + private static LiveAdmissionSample Sample(string id, string clientId, long started, long finished) + { + return new LiveAdmissionSample(id, clientId, "admission", started, finished, Success()); + } + + private static LiveAdmissionOutcome Success() + { + return new LiveAdmissionOutcome(LiveAdmissionOutcomeKind.Succeeded); + } + + private static LiveMcpToolResult Error(string kind, string hostEffect) + { + return new LiveMcpToolResult(true, new JsonObject + { + ["error"] = new JsonObject + { + ["kind"] = kind, + ["hostEffect"] = hostEffect + } + }, null); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionQualification.cs new file mode 100644 index 0000000..8684ba7 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveAdmissionQualification.cs @@ -0,0 +1,206 @@ +using System.Diagnostics; +using System.Runtime.Versioning; +using System.Text.Json.Nodes; + +using CheatEngine.Mcp.Core.Contract; + +using ModelContextProtocol.Protocol; + +namespace CheatEngine.Mcp.Tests.LiveQualification; + +/// Finite multi-client and navigation observations; missing native evidence is reported explicitly. +[SupportedOSPlatform("windows")] +internal static class LiveAdmissionQualification +{ + internal const int Rounds = LiveAdmissionComparison.Rounds; + internal const int BatchRequestsPerRound = LiveAdmissionComparison.BatchRequestsPerRound; + internal const int BatchItems = 1024; + private const string Value = "20260926"; + private static readonly string[] s_gates = ["unsafeLua", "autoAssembler", "kernelAccess", "targetCodeExecution"]; + + internal static async Task MeasureAsync(LiveSandboxSession sandbox, LiveMcpClient first) + { + LiveMcpClient? second = null; + LiveAdmissionSession? result = null; + await LivePerformanceQualification.RunWithCleanupAsync(async () => + { + second = await LiveMcpClient.ConnectGatewayAsync(sandbox.GatewayExecutablePath, sandbox.InstanceDirectory, + TestContext.Current.CancellationToken); + LiveMcpClient[] gateways = [first, second]; + foreach (LiveMcpClient gateway in gateways) + { + AssertInstances((await gateway.CallToolAsync(CheatEngineToolNames.InstanceList))!, sandbox); + } + foreach (LiveSandboxHost host in new[] { sandbox.HostA, sandbox.HostB }) + { + JsonNode info = (await first.Bind(host.InstanceId).CallToolAsync(CheatEngineToolNames.RuntimeGetInfo))!; + foreach (string gate in s_gates) + { + Assert.False(info["gates"]![gate]!.GetValue()); + } + } + + JsonNode[] before = await RetainedStateAsync(first, sandbox); + List bursts = []; + for (int round = 0; round < Rounds; round++) + { + // Both package generations execute the same explicit preparation before measured navigation. + // Region preparation is last because it atomically prepares module names and the region map. + LiveMcpInstanceClient owned = first.Bind(sandbox.HostA.InstanceId); + JsonNode modules = (await owned.CallToolAsync(CheatEngineToolNames.ModuleList, + new Dictionary { ["limit"] = 1 }))!; + Assert.True(modules["total"]!.GetValue() > 0); + JsonNode regions = (await owned.CallToolAsync(CheatEngineToolNames.MemoryListRegions, + new Dictionary { ["limit"] = 1 }))!; + Assert.Single(regions["regions"]!.AsArray()); + sandbox.Record($"performance_admission_preparation_{round}", new + { + moduleListCalls = 1, + regionListCalls = 1, + timing = "Explicit source-tool preparation precedes the measured burst; it is not observer latency." + }); + LiveAdmissionBurst burst = await LiveAdmissionMeasurement.MeasureAsync( + Requests(sandbox, gateways, round), TestContext.Current.CancellationToken); + bursts.Add(burst); + sandbox.Record($"performance_admission_round_{round}", burst); + Assert.Equal(BatchRequestsPerRound + 5, burst.Samples.Count); + RequireExpectedOutcomes(burst); + } + + JsonNode[] after = await RetainedStateAsync(first, sandbox); + Assert.Equal(before.Length, after.Length); + for (int index = 0; index < before.Length; index++) + { + Assert.True(JsonNode.DeepEquals(before[index], after[index]), + "Navigation changed retained jobs or resources."); + } + result = new LiveAdmissionSession(bursts); + LiveAdmissionComparison.Validate(result); + sandbox.Record("performance_admission", new + { + independentGateways = 2, + rounds = Rounds, + batchRequestsPerRound = BatchRequestsPerRound, + batchItems = BatchItems, + batchPayloadBytes = BatchItems * sizeof(int), + stopwatchFrequency = Stopwatch.Frequency, + retainedStateUnchanged = true, + bursts, + limitations = new List + { + "Intervals measure client requests, not native dispatch occupancy. Missing cross-client workload overlap or busy refusal leaves that coverage unqualified.", + "One sequential UI snapshot per round includes the CE driver's 100 ms timer and file exchange, not pure UI latency.", + "Every round explicitly prepares modules and regions before timing. The published baseline may cache module completion; the candidate validates prepared data afresh. Empty responses do not prove refusal.", + "A baseline completion refresh can outlive its response. Candidate navigation reads prepared data; explicit preparation still performs native enumeration and is outside burst timing." + } + }); + }, () => second?.DisposeAsync().AsTask() ?? Task.CompletedTask); + return result ?? throw new InvalidOperationException("Admission observations did not complete."); + } + + private static List Requests(LiveSandboxSession sandbox, LiveMcpClient[] gateways, int round) + { + List requests = []; + for (int index = 0; index < BatchRequestsPerRound; index++) + { + int client = index % gateways.Length; + LiveMcpInstanceClient instance = gateways[client].Bind(sandbox.HostA.InstanceId); + requests.Add(new LiveAdmissionRequest($"round-{round}-batch-{index}", $"gateway-{client}", "batch", + async () => + { + LiveMcpToolResult reply = await instance.CallToolRawAsync(CheatEngineToolNames.MemoryReadBatch, + new Dictionary + { + ["items"] = Enumerable.Range(0, BatchItems) + .Select(_ => new { address = sandbox.HostA.TargetAddress, valueType = "int32" }).ToArray() + }); + LiveAdmissionOutcome outcome = LiveAdmissionMeasurement.ClassifyTool(reply); + if (outcome.Kind == LiveAdmissionOutcomeKind.Succeeded) + { + JsonNode payload = reply.Payload!; + Assert.Equal(0, payload["failed"]!.GetValue()); + JsonArray items = payload["items"]!.AsArray(); + Assert.Equal(BatchItems, items.Count); + Assert.All(items, item => + { + Assert.Null(item!["error"]); + Assert.Equal(Value, item["value"]!.GetValue()); + }); + } + return outcome; + })); + } + requests.Add(new LiveAdmissionRequest($"round-{round}-discovery", "gateway-0", "discovery", async () => + { + AssertInstances((await gateways[0].CallToolAsync(CheatEngineToolNames.InstanceList))!, sandbox); + return new LiveAdmissionOutcome(LiveAdmissionOutcomeKind.Succeeded); + })); + requests.Add(new LiveAdmissionRequest($"round-{round}-sentinel", "gateway-1", "host-b-sentinel", async () => + { + JsonNode reply = (await gateways[1].Bind(sandbox.HostB.InstanceId).CallToolAsync(CheatEngineToolNames.MemoryRead, + new Dictionary { ["address"] = sandbox.HostB.TargetAddress, ["valueType"] = "int32" }))!; + Assert.Equal(Value, reply["value"]!.GetValue()); + return new LiveAdmissionOutcome(LiveAdmissionOutcomeKind.Succeeded); + })); + requests.Add(new LiveAdmissionRequest($"round-{round}-ui", "ce-ui-timer", "ui-snapshot", async () => + { + Assert.Equal(8, (await sandbox.ScanUiAsync("A", "snapshot")).Length); + return new LiveAdmissionOutcome(LiveAdmissionOutcomeKind.Succeeded); + })); + requests.Add(new LiveAdmissionRequest($"round-{round}-regions", "gateway-1", "regions-resource", async () => + { + string uri = $"cheatengine://instances/{sandbox.HostA.InstanceId}/regions?offset=0&limit=1"; + ReadResourceResult reply = await gateways[1].ReadResourceAsync(uri); + TextResourceContents content = Assert.IsType(Assert.Single(reply.Contents)); + Assert.Equal(uri, content.Uri); + JsonNode payload = JsonNode.Parse(content.Text)!; + Assert.Single(payload["regions"]!.AsArray()); + Assert.True(payload["total"]!.GetValue() > 0); + return new LiveAdmissionOutcome(LiveAdmissionOutcomeKind.Succeeded); + })); + requests.Add(new LiveAdmissionRequest($"round-{round}-completion", "gateway-0", + round == 0 ? "module-completion-first" : "module-completion-repeat", async () => + { + string module = sandbox.TargetArchitecture == "x86" ? "dotnet.exe" : "CheatEngine.Mcp.LiveTarget.exe"; + CompleteResult reply = await gateways[0].CompleteModulesAsync(sandbox.HostA.InstanceId, module); + Assert.InRange(reply.Completion.Values.Count, 0, 100); + if (reply.Completion.Values.Count == 0) + { + return new LiveAdmissionOutcome(LiveAdmissionOutcomeKind.EmptyCompletion); + } + Assert.Contains(module, reply.Completion.Values, StringComparer.OrdinalIgnoreCase); + return new LiveAdmissionOutcome(LiveAdmissionOutcomeKind.Succeeded); + })); + return requests; + } + + private static async Task RetainedStateAsync(LiveMcpClient gateway, LiveSandboxSession sandbox) + { + List state = []; + foreach (LiveSandboxHost host in new[] { sandbox.HostA, sandbox.HostB }) + { + LiveMcpInstanceClient instance = gateway.Bind(host.InstanceId); + state.Add((await instance.CallToolAsync(CheatEngineToolNames.RuntimeListResources))!); + state.Add((await instance.CallToolAsync(CheatEngineToolNames.RuntimeListJobs))!); + } + return [.. state]; + } + + private static void AssertInstances(JsonNode payload, LiveSandboxSession sandbox) + { + Assert.Equal(new[] { sandbox.HostA.InstanceId, sandbox.HostB.InstanceId }.Order(StringComparer.Ordinal), + payload["instances"]!.AsArray().Select(instance => instance!["instanceId"]!.GetValue()) + .Order(StringComparer.Ordinal)); + } + + private static void RequireExpectedOutcomes(LiveAdmissionBurst burst) + { + Assert.All(burst.Samples, sample => + { + Assert.True(LiveAdmissionComparison.ExpectedOutcome(sample), + $"Admission observation '{sample.Id}' failed: {sample.Outcome}."); + }); + } +} + +internal sealed record LiveAdmissionSession(IReadOnlyList Bursts); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs index f33347d..fa2f505 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveDispatchDiagnosticQualification.cs @@ -35,6 +35,19 @@ private static async Task RunCaseAsync(LiveQualificationInputs inputs, LiveDispa }); connection = await LiveLifecycleSession.ConnectAsync(sandbox); await connection.AttachBothAsync(); + if (@case == LiveDispatchDiagnosticCase.ResourcePreludeMemoryNamedScanThenAob) + { + sandbox.Record("dispatch_diagnostic_prelude", new + { + step = "before_runtime_and_resources" + }); + await connection.VerifyBothAsync(); + await LiveResourceQualification.VerifyAsync(sandbox, connection.Gateway); + sandbox.Record("dispatch_diagnostic_prelude", new + { + step = "completed" + }); + } sandbox.Record("dispatch_diagnostic_case", new { @case = @case.ToString(), @@ -79,5 +92,6 @@ internal enum LiveDispatchDiagnosticCase { AobOnly, NamedScanThenAob, - MemoryNamedScanThenAob + MemoryNamedScanThenAob, + ResourcePreludeMemoryNamedScanThenAob } diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs index ec44462..6ebdf02 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveMcpClient.cs @@ -157,6 +157,22 @@ public async Task CompleteInstancesAsync() }, "instanceId", "ce-", cancellationToken: timeout.Token); } + public async Task CompleteModulesAsync(string instanceId, string prefix) + { + ArgumentException.ThrowIfNullOrWhiteSpace(instanceId); + ArgumentNullException.ThrowIfNull(prefix); + using CancellationTokenSource timeout = CreateTimeout(cancellationToken); + return await client.CompleteAsync(new CompleteRequestParams + { + Ref = new ResourceTemplateReference { Uri = "cheatengine://instances/{instanceId}/modules/{module}" }, + Argument = new Argument { Name = "module", Value = prefix }, + Context = new CompleteContext + { + Arguments = new Dictionary { ["instanceId"] = instanceId } + } + }, timeout.Token); + } + private static CancellationTokenSource CreateTimeout(CancellationToken cancellationToken) { CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceMatrixTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceMatrixTests.cs new file mode 100644 index 0000000..c229370 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceMatrixTests.cs @@ -0,0 +1,128 @@ +namespace CheatEngine.Mcp.Tests.LiveQualification; + +public sealed class LivePerformanceMatrixTests +{ + [Fact] + public void Matrix_DefinesEveryFixed901WorkloadWithBoundedSamples() + { + IReadOnlyList matrix = LivePerformanceQualification.Workloads; + + Assert.Equal(matrix.Count, matrix.Select(item => item.Name).Distinct(StringComparer.Ordinal).Count()); + Assert.All(matrix, item => + { + Assert.True(item.SampleCount > 0); + Assert.True(item.WarmupCount >= 0); + Assert.False(string.IsNullOrWhiteSpace(item.Method)); + Assert.False(string.IsNullOrWhiteSpace(item.Limits)); + }); + Assert.Contains(matrix, item => item.Name == "memory_read_batch_16" && item.Limits.Contains("16", StringComparison.Ordinal)); + Assert.Contains(matrix, item => item.Name == "memory_read_batch_1024" && item.Limits.Contains("4 KiB", StringComparison.Ordinal)); + Assert.Contains(matrix, item => item.Name == "code_dissect_whole_cycle" && item.BudgetClass == LivePerformanceBudgetClass.WholeCycle); + Assert.Contains(matrix, item => item.Name == "named_scan_64" && item.BudgetClass == LivePerformanceBudgetClass.WholeCycle); + } + + [Fact] + public void Pair_RefusesBaselineMissingOneMatrixWorkload() + { + LivePerformanceRun run = Run(TimeSpan.FromMilliseconds(10)); + Dictionary baseline = LivePerformanceQualification.Baselines([run, run, run]) + .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + baseline.Remove("aob_find_64"); + + InvalidOperationException exception = Assert.Throws(() => + LivePerformanceQualification.Pair(baseline, run)); + + Assert.Contains("cannot count as a pass", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Pair_UsesShortAllowanceOnlyForShortWorkload() + { + LivePerformanceRun baselineRun = Run(TimeSpan.FromMilliseconds(100)); + IReadOnlyDictionary baseline = LivePerformanceQualification.Baselines( + [baselineRun, baselineRun, baselineRun]); + LivePerformanceRun candidate = Run(TimeSpan.FromMilliseconds(151)); + + IReadOnlyList comparisons = LivePerformanceQualification.Pair(baseline, candidate); + + Assert.True(Assert.Single(comparisons, item => item.Name == "memory_read_int32").Decision.Passed); + Assert.False(Assert.Single(comparisons, item => item.Name == "aob_find_64").Decision.Passed); + } + + [Fact] + public void Pair_RefusesCandidateWithWrongFixedSampleCount() + { + LivePerformanceRun valid = Run(TimeSpan.FromMilliseconds(10)); + Dictionary malformed = valid.Workloads.ToDictionary( + static pair => pair.Key, + static pair => pair.Value, + StringComparer.Ordinal); + malformed["memory_read_batch_16"] = LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(10)]); + + InvalidOperationException exception = Assert.Throws(() => + LivePerformanceQualification.Pair(LivePerformanceQualification.Baselines([valid, valid, valid]), + new LivePerformanceRun(malformed))); + + Assert.Contains("wrong fixed sample count", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public void Pair_RefusesBaselineWithWrongFixedSampleCount() + { + LivePerformanceRun run = Run(TimeSpan.FromMilliseconds(10)); + Dictionary baseline = LivePerformanceQualification.Baselines([run, run, run]) + .ToDictionary(static pair => pair.Key, static pair => pair.Value, StringComparer.Ordinal); + LiveLatencyProfile malformed = LivePerformanceQualification.Profile([TimeSpan.FromMilliseconds(10)]); + baseline["memory_read_batch_16"] = new LiveBaselineProfile([malformed, malformed, malformed], malformed.P95); + + InvalidOperationException exception = Assert.Throws(() => + LivePerformanceQualification.Pair(baseline, run)); + + Assert.Contains("valid fixed matrix profile", exception.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task RunWithCleanupAsync_PreservesBothFailuresAndAlwaysReleases(bool operationFails, bool cleanupFails) + { + InvalidOperationException operationFailure = new("scan failed"); + InvalidOperationException cleanupFailure = new("delete failed"); + List calls = []; + + Exception? result = await Record.ExceptionAsync(() => LivePerformanceQualification.RunWithCleanupAsync( + () => + { + calls.Add("scan"); + return operationFails ? Task.FromException(operationFailure) : Task.CompletedTask; + }, + () => + { + calls.Add("delete"); + return cleanupFails ? Task.FromException(cleanupFailure) : Task.CompletedTask; + })); + + Assert.Equal(["scan", "delete"], calls); + if (cleanupFails) + { + AggregateException combined = Assert.IsType(result); + Assert.Equal(operationFails ? new Exception[] { operationFailure, cleanupFailure } : [cleanupFailure], + combined.InnerExceptions); + } + else + { + Assert.Same(operationFails ? operationFailure : null, result); + } + } + + private static LivePerformanceRun Run(TimeSpan value) + { + Dictionary profiles = LivePerformanceQualification.Workloads.ToDictionary( + static workload => workload.Name, + workload => LivePerformanceQualification.Profile(Enumerable.Repeat(value, workload.SampleCount)), + StringComparer.Ordinal); + return LivePerformanceQualification.CreateRun(profiles); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceQualification.cs index e172ecf..fc4a195 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceQualification.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePerformanceQualification.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Globalization; +using System.Runtime.ExceptionServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text.Json.Nodes; @@ -8,129 +9,257 @@ namespace CheatEngine.Mcp.Tests.LiveQualification; -/// Deterministic latency aggregation and acceptance checks for the reviewed live workloads. +/// Prepared, unexecuted workload and admission measurements; native qualification remains separate. internal static class LivePerformanceQualification { internal const int RequiredBaselineRuns = 3; internal static readonly TimeSpan ShortCallAllowance = TimeSpan.FromMilliseconds(100); - private const int ShortSamples = 100; - private const int BatchSamples = 30; - private const int BatchSize = 16; + private const string Value = "20260926"; + private static readonly string[] s_aobPatterns = ["D1 2B 7E 93 44 A8 19 C6"]; + private static readonly (string Name, string Hash)[] s_baselineFiles = + [ + ("CheatEngine.Mcp.dll", "5699ECA2ED06461846A977802DF3E4FA7BF52159F50AA1446C4819908A7692AE"), + ("CheatEngine.Mcp.Gateway.exe", "2E0E7F8512D8A8AAC0197E982C7660BFA4336EBDB8981543817B709381C83885") + ]; + private static readonly string[] s_pointerOffsets = ["-10", "20"]; + private static readonly string[] s_runtimeGates = ["unsafeLua", "autoAssembler", "kernelAccess", "targetCodeExecution"]; + private static readonly string[] s_structureFlagValues = ["100", "200"]; + private static readonly string[] s_structureHealthValues = ["7", "7"]; + private static readonly LivePerformanceWorkload[] s_workloads = + [ + new("memory_read_int32", 100, 5, LivePerformanceBudgetClass.Short, "end-to-end memory_read int32", "one int32"), + new("memory_read_batch_16", 30, 5, LivePerformanceBudgetClass.Bounded, "end-to-end memory_read_batch", "16 int32 items"), + new("memory_read_batch_1024", 6, 2, LivePerformanceBudgetClass.Bounded, "end-to-end memory_read_batch", "1024 int32 items (4 KiB)"), + new("memory_list_regions_64", 12, 2, LivePerformanceBudgetClass.Bounded, "end-to-end memory_list_regions", "committed; limit=64"), + new("named_scan_64", 3, 1, LivePerformanceBudgetClass.WholeCycle, "end-to-end scan_first, bounded poll and delete", "64 bytes; reset each sample; 15 second post-start poll bound"), + new("aob_find_64", 12, 2, LivePerformanceBudgetClass.Bounded, "end-to-end aob_find", "64 bytes; limit=2"), + new("pointer_read_chain", 12, 2, LivePerformanceBudgetClass.Bounded, "end-to-end pointer_read_chain", "two offsets"), + new("module_symbol_resolution", 12, 2, LivePerformanceBudgetClass.Bounded, "end-to-end module_list and symbol_resolve", "detailed limit=1000; two expressions"), + new("structure_read", 12, 2, LivePerformanceBudgetClass.Bounded, "end-to-end structure_read", "two owned instances; limit=8"), + new("structure_dissect_compare", 12, 2, LivePerformanceBudgetClass.Bounded, "end-to-end structure_compare", "two owned instances; limit=8"), + new("code_dissect_whole_cycle", 3, 1, LivePerformanceBudgetClass.WholeCycle, "end-to-end code_start_dissect, poll, clear and stop", "2 bytes; TTL=30; 10 second poll bound; retained on uncertain start, poll or clear"), + new("instance_discovery", 12, 2, LivePerformanceBudgetClass.Bounded, "end-to-end instance_list", "two owned instances") + ]; + + internal static IReadOnlyList Workloads => s_workloads; [SupportedOSPlatform("windows")] internal static async Task RunAsync(LiveQualificationInputs inputs) { Assert.Equal(LiveQualificationScenario.Performance, inputs.Scenario); string baselineDirectory = Path.Combine(inputs.RepositoryRoot, "artifacts", "qualification-baseline", "beta.2", "staged"); - const string baselineVersion = "2.0.0-beta.2+c51a0ec3af373c852cda062a91888ad94fa0cce9"; - Assert.Equal(baselineVersion, FileVersionInfo.GetVersionInfo(Path.Combine(baselineDirectory, "CheatEngine.Mcp.dll")).ProductVersion); - foreach ((string name, string expectedHash) in new[] - { - ("CheatEngine.Mcp.dll", "5699ECA2ED06461846A977802DF3E4FA7BF52159F50AA1446C4819908A7692AE"), - ("CheatEngine.Mcp.Gateway.exe", "2E0E7F8512D8A8AAC0197E982C7660BFA4336EBDB8981543817B709381C83885") - }) - { - using FileStream binary = File.OpenRead(Path.Combine(baselineDirectory, name)); - Assert.Equal(expectedHash, Convert.ToHexString(SHA256.HashData(binary))); - } + VerifyBaseline(baselineDirectory); LivePerformanceRun[] baselineRuns = new LivePerformanceRun[RequiredBaselineRuns]; for (int index = 0; index < baselineRuns.Length; index++) { - baselineRuns[index] = await MeasureSessionAsync(inputs, baselineDirectory, index + 1, null); + baselineRuns[index] = await MeasureSessionAsync(inputs, baselineDirectory, index + 1, null, null); } - LiveBaselineProfile shortBaseline = Baseline(baselineRuns.Select(run => run.ShortCalls)); - LiveBaselineProfile batchBaseline = Baseline(baselineRuns.Select(run => run.BatchCalls)); + IReadOnlyDictionary baseline = Baselines(baselineRuns); + LiveAdmissionSession[] admissionBaseline = baselineRuns.Select(static run => run.Admission + ?? throw new InvalidOperationException("Baseline admission evidence is missing.")).ToArray(); for (int index = 0; index < RequiredBaselineRuns; index++) { - _ = await MeasureSessionAsync(inputs, null, index + 1, (shortBaseline, batchBaseline)); + _ = await MeasureSessionAsync(inputs, null, index + 1, baseline, admissionBaseline); + } + } + + private static void VerifyBaseline(string directory) + { + const string version = "2.0.0-beta.2+c51a0ec3af373c852cda062a91888ad94fa0cce9"; + Assert.Equal(version, FileVersionInfo.GetVersionInfo(Path.Combine(directory, "CheatEngine.Mcp.dll")).ProductVersion); + foreach ((string name, string hash) in s_baselineFiles) + { + using FileStream binary = File.OpenRead(Path.Combine(directory, name)); + Assert.Equal(hash, Convert.ToHexString(SHA256.HashData(binary))); } } [SupportedOSPlatform("windows")] - private static async Task MeasureSessionAsync(LiveQualificationInputs inputs, - string? distributionOverride, int runNumber, (LiveBaselineProfile Short, LiveBaselineProfile Batch)? baseline) + private static async Task MeasureSessionAsync(LiveQualificationInputs inputs, string? distribution, + int runNumber, IReadOnlyDictionary? baseline, + IReadOnlyList? admissionBaseline) { LiveSandboxOptions options = LiveSandboxOptions.Smoke with { PerformanceQualification = true, - DistributionDirectoryOverride = distributionOverride + DistributionDirectoryOverride = distribution }; - await using LiveSandboxSession sandbox = await LiveSandboxSession.StartAsync(inputs, options); - await using LiveMcpClient gateway = await LiveMcpClient.ConnectGatewayAsync(sandbox.GatewayExecutablePath, - sandbox.InstanceDirectory, TestContext.Current.CancellationToken); - LiveMcpInstanceClient a = gateway.Bind(sandbox.HostA.InstanceId); - LiveMcpInstanceClient b = gateway.Bind(sandbox.HostB.InstanceId); - foreach ((LiveMcpInstanceClient client, LiveSandboxHost host) in new[] { (a, sandbox.HostA), (b, sandbox.HostB) }) + LiveSandboxSession? sandbox = null; + LiveMcpClient? gateway = null; + LivePerformanceFixture? fixture = null; + Exception? failure = null; + List cleanup = []; + Dictionary? captured = null; + LivePerformanceRun? result = null; + try + { + sandbox = await LiveSandboxSession.StartAsync(inputs, options); + gateway = await LiveMcpClient.ConnectGatewayAsync(sandbox.GatewayExecutablePath, + sandbox.InstanceDirectory, TestContext.Current.CancellationToken); + LiveMcpInstanceClient a = gateway.Bind(sandbox.HostA.InstanceId); + LiveMcpInstanceClient b = gateway.Bind(sandbox.HostB.InstanceId); + await AttachAndVerifyGatesAsync(sandbox, a, b); + fixture = await LivePerformanceFixture.CreateAsync(sandbox, gateway, a); + captured = []; + foreach (LivePerformanceWorkload workload in Workloads) + { + Func operation = fixture.GetOperation(workload.Name); + for (int warmup = 0; warmup < workload.WarmupCount; warmup++) + { + await operation(); + } + + captured.Add(workload.Name, await MeasureAsync(workload.SampleCount, operation, TestContext.Current.CancellationToken)); + } + + LivePerformanceRun run = CreateRun(captured ?? throw new InvalidOperationException("No performance profiles were recorded.")); + IReadOnlyList? comparisons = baseline is null ? null : Pair(baseline, run); + sandbox.Record("performance", new + { + kind = baseline is null ? "published_beta2_baseline" : "candidate", + runNumber, + productVersion = FileVersionInfo.GetVersionInfo(sandbox.GatewayExecutablePath).ProductVersion, + matrix = Workloads, + profiles = run.Workloads, + baseline, + comparisons, + limitation = "End-to-end 9.01 workflow measurements, including client validation and the listed polling/cleanup steps. These do not isolate native execution time or qualify 9.03, 9.05, or the complete output contract." + }); + if (comparisons is not null) + { + Assert.All(comparisons, comparison => Assert.True(comparison.Decision.Passed, + $"{comparison.Name} p95 {comparison.Decision.ObservedP95} exceeds {comparison.Decision.MaximumP95}.")); + } + + JsonNode unaffected = (await b.CallToolAsync(CheatEngineToolNames.MemoryRead, + new Dictionary { ["address"] = sandbox.HostB.TargetAddress, ["valueType"] = "int32" }))!; + Assert.Equal(Value, unaffected["value"]!.GetValue()); + LiveAdmissionSession admission = await LiveAdmissionQualification.MeasureAsync(sandbox, gateway); + if (admissionBaseline is not null) + { + LiveAdmissionAssessment assessment = LiveAdmissionComparison.Compare(admissionBaseline, admission); + sandbox.Record("performance_admission_comparison", assessment); + Assert.True(assessment.Passed, + "Admission timing exceeded its baseline budget or required overlap/refusal evidence was missing. See the comparison receipt."); + } + result = run with + { + Admission = admission + }; + } + catch (Exception exception) + { + failure = exception; + } + finally { - JsonNode runtime = (await client.CallToolAsync(CheatEngineToolNames.RuntimeGetInfo))!; - foreach (string gate in new[] { "unsafeLua", "autoAssembler", "kernelAccess", "targetCodeExecution" }) + if (fixture is not null) { - Assert.False(runtime["gates"]![gate]!.GetValue()); + await CleanupAsync(cleanup, async () => await fixture.DisposeAsync()); } - JsonNode opened = (await client.CallToolAsync(CheatEngineToolNames.ProcessAttach, - new Dictionary { ["process"] = host.TargetProcessId.ToString(CultureInfo.InvariantCulture) }))!; - Assert.Equal(host.TargetProcessId, opened["processId"]!.GetValue()); + + await LiveLifecycleQualification.DisposeOrderedAsync( + failure, + gateway is null ? null : gateway.DisposeAsync, + sandbox is null ? null : exceptions => + { + sandbox.Record("performance_gateway_cleanup_failure", exceptions.Select(exception => exception.ToString()).ToArray()); + return ValueTask.CompletedTask; + }, + sandbox is null ? null : () => + { + sandbox.MarkPassed(); + return ValueTask.CompletedTask; + }, + sandbox is null ? null : sandbox.DisposeAsync, + cleanup); + } + + ThrowWithCleanup(failure, cleanup); + return result ?? throw new InvalidOperationException("Performance session did not produce a run."); + } + + [SupportedOSPlatform("windows")] + private static async Task AttachAndVerifyGatesAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient a, LiveMcpInstanceClient b) + { + await AttachAndVerifyGatesAsync(sandbox.HostA, a); + await AttachAndVerifyGatesAsync(sandbox.HostB, b); + } + + [SupportedOSPlatform("windows")] + private static async Task AttachAndVerifyGatesAsync(LiveSandboxHost host, LiveMcpInstanceClient client) + { + JsonNode runtime = (await client.CallToolAsync(CheatEngineToolNames.RuntimeGetInfo))!; + foreach (string gate in s_runtimeGates) + { + Assert.False(runtime["gates"]![gate]!.GetValue()); } - IReadOnlyDictionary shortArguments = new Dictionary + JsonNode attached = (await client.CallToolAsync(CheatEngineToolNames.ProcessAttach, + new Dictionary { ["process"] = host.TargetProcessId.ToString(CultureInfo.InvariantCulture) }))!; + Assert.Equal(host.TargetProcessId, attached["processId"]!.GetValue()); + } + + internal static LivePerformanceRun CreateRun(IReadOnlyDictionary workloads) + { + ArgumentNullException.ThrowIfNull(workloads); + string[] expected = Workloads.Select(static item => item.Name).Order(StringComparer.Ordinal).ToArray(); + if (!expected.SequenceEqual(workloads.Keys.Order(StringComparer.Ordinal), StringComparer.Ordinal)) { - ["address"] = sandbox.HostA.TargetAddress, - ["valueType"] = "int32" - }; - IReadOnlyDictionary batchArguments = new Dictionary + throw new InvalidOperationException("Performance matrix must contain every supported workload exactly once."); + } + + foreach (LivePerformanceWorkload workload in Workloads) { - ["items"] = Enumerable.Range(0, BatchSize) - .Select(_ => new { address = sandbox.HostA.TargetAddress, valueType = "int32" }).ToArray() - }; - async Task ShortCall() + if (workloads[workload.Name].SampleCount != workload.SampleCount) + { + throw new InvalidOperationException($"'{workload.Name}' has the wrong fixed sample count."); + } + } + + return new LivePerformanceRun(new Dictionary(workloads, StringComparer.Ordinal)); + } + + internal static IReadOnlyDictionary Baselines(IEnumerable runs) + { + ArgumentNullException.ThrowIfNull(runs); + LivePerformanceRun[] captured = runs.ToArray(); + if (captured.Length != RequiredBaselineRuns) { - JsonNode result = (await a.CallToolAsync(CheatEngineToolNames.MemoryRead, shortArguments))!; - Assert.Equal("20260926", result["value"]!.GetValue()); + throw new InvalidOperationException($"Exactly {RequiredBaselineRuns} separately recorded baseline runs are required."); } - async Task BatchCall() + + foreach (LivePerformanceRun run in captured) { - JsonNode result = (await a.CallToolAsync(CheatEngineToolNames.MemoryReadBatch, batchArguments))!; - Assert.Equal(0, result["failed"]!.GetValue()); - JsonArray items = result["items"]!.AsArray(); - Assert.Equal(BatchSize, items.Count); - Assert.All(items, item => Assert.Equal("20260926", item!["value"]!.GetValue())); - } - // The same five warm-up pairs are excluded from every recorded profile. - for (int index = 0; index < 5; index++) - { - await ShortCall(); - await BatchCall(); - } - LiveLatencyProfile shortCalls = await MeasureAsync(ShortSamples, ShortCall, TestContext.Current.CancellationToken); - LiveLatencyProfile batchCalls = await MeasureAsync(BatchSamples, BatchCall, TestContext.Current.CancellationToken); - JsonNode unaffected = (await b.CallToolAsync(CheatEngineToolNames.MemoryRead, - new Dictionary { ["address"] = sandbox.HostB.TargetAddress, ["valueType"] = "int32" }))!; - Assert.Equal("20260926", unaffected["value"]!.GetValue()); - LivePerformanceDecision? shortDecision = baseline is { } first ? EvaluateShortCalls(first.Short, shortCalls) : null; - LivePerformanceDecision? batchDecision = baseline is { } second ? EvaluateBoundedJobs(second.Batch, batchCalls) : null; - sandbox.Record("performance", new - { - kind = baseline is null ? "published_beta2_baseline" : "candidate", - runNumber, - productVersion = FileVersionInfo.GetVersionInfo(sandbox.GatewayExecutablePath).ProductVersion, - method = "Stopwatch full stdio request, response parse and value assertion; five unrecorded warm-up pairs; nearest-rank p95; maximum of three baseline p95s", - shortWorkload = "memory_read int32", - shortCalls, - batchWorkload = "memory_read_batch of 16 int32 requests", - batchCalls, - baselineShort = baseline?.Short, - baselineBatch = baseline?.Batch, - shortDecision, - batchDecision, - limitation = "These results qualify the two recorded workloads only; retained jobs, completions and blocking-native calls are separate." - }); - if (shortDecision is not null && batchDecision is not null) - { - Assert.True(shortDecision.Passed, $"Short-call p95 {shortDecision.ObservedP95} exceeds {shortDecision.MaximumP95}."); - Assert.True(batchDecision.Passed, $"Batch p95 {batchDecision.ObservedP95} exceeds {batchDecision.MaximumP95}."); - } - sandbox.MarkPassed(); - return new LivePerformanceRun(shortCalls, batchCalls); + ValidateRun(run); + } + + return Workloads.ToDictionary( + workload => workload.Name, + workload => Baseline(captured.Select(run => RequireProfile(run, workload.Name))), + StringComparer.Ordinal); + } + + internal static IReadOnlyList Pair(IReadOnlyDictionary baseline, LivePerformanceRun candidate) + { + ArgumentNullException.ThrowIfNull(baseline); + ArgumentNullException.ThrowIfNull(candidate); + ValidateRun(candidate); + string[] expected = Workloads.Select(static item => item.Name).Order(StringComparer.Ordinal).ToArray(); + if (!expected.SequenceEqual(baseline.Keys.Order(StringComparer.Ordinal), StringComparer.Ordinal)) + { + throw new InvalidOperationException("Unsupported, missing or extra baseline workloads cannot count as a pass."); + } + + return Workloads.Select(workload => + { + LiveBaselineProfile profile = baseline[workload.Name]; + ValidateBaselineProfile(workload, profile); + LivePerformanceDecision decision = workload.BudgetClass == LivePerformanceBudgetClass.Short + ? EvaluateShortCalls(profile, candidate.Workloads[workload.Name]) + : EvaluateBoundedJobs(profile, candidate.Workloads[workload.Name]); + return new LivePerformanceComparison(workload.Name, decision); + }).ToArray(); } internal static LiveLatencyProfile Profile(IEnumerable samples) @@ -142,8 +271,7 @@ internal static LiveLatencyProfile Profile(IEnumerable samples) throw new ArgumentException("Latency samples must be non-empty and non-negative.", nameof(samples)); } - return new LiveLatencyProfile(ticks.Length, TimeSpan.FromTicks(ticks[(ticks.Length - 1) / 2]), - TimeSpan.FromTicks(ticks[(int) Math.Ceiling(ticks.Length * 0.95) - 1]), TimeSpan.FromTicks(ticks[^1])); + return new LiveLatencyProfile(ticks.Length, TimeSpan.FromTicks(ticks[(ticks.Length - 1) / 2]), TimeSpan.FromTicks(ticks[(int) Math.Ceiling(ticks.Length * .95) - 1]), TimeSpan.FromTicks(ticks[^1])); } internal static LiveBaselineProfile Baseline(IEnumerable runs) @@ -168,7 +296,7 @@ internal static LivePerformanceDecision EvaluateShortCalls(LiveBaselineProfile b ArgumentNullException.ThrowIfNull(baseline); ArgumentNullException.ThrowIfNull(candidate); TimeSpan threshold = Max(Times(baseline.P95, 2), baseline.P95 + ShortCallAllowance); - return new LivePerformanceDecision(candidate.P95 <= threshold, candidate.P95, threshold); + return new(candidate.P95 <= threshold, candidate.P95, threshold); } internal static LivePerformanceDecision EvaluateBoundedJobs(LiveBaselineProfile baseline, LiveLatencyProfile candidate) @@ -176,36 +304,460 @@ internal static LivePerformanceDecision EvaluateBoundedJobs(LiveBaselineProfile ArgumentNullException.ThrowIfNull(baseline); ArgumentNullException.ThrowIfNull(candidate); TimeSpan threshold = Times(baseline.P95, 1.5); - return new LivePerformanceDecision(candidate.P95 <= threshold, candidate.P95, threshold); + return new(candidate.P95 <= threshold, candidate.P95, threshold); } - internal static async Task MeasureAsync(int count, Func operation, - CancellationToken cancellationToken) + internal static async Task MeasureAsync(int count, Func operation, CancellationToken cancellationToken) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count); ArgumentNullException.ThrowIfNull(operation); TimeSpan[] samples = new TimeSpan[count]; - for (int index = 0; index < samples.Length; index++) + for (int index = 0; index < count; index++) { cancellationToken.ThrowIfCancellationRequested(); long started = Stopwatch.GetTimestamp(); await operation(); samples[index] = Stopwatch.GetElapsedTime(started); } - return Profile(samples); } - private static TimeSpan Times(TimeSpan value, double multiplier) => TimeSpan.FromTicks( - checked((long) Math.Ceiling(value.Ticks * multiplier))); + private static LiveLatencyProfile RequireProfile(LivePerformanceRun run, string workloadName) + { + if (!run.Workloads.TryGetValue(workloadName, out LiveLatencyProfile? profile) || profile is null) + { + throw new InvalidOperationException($"Baseline is missing '{workloadName}'."); + } + + return profile; + } + + private static void ValidateRun(LivePerformanceRun run) + { + ArgumentNullException.ThrowIfNull(run); + _ = CreateRun(run.Workloads); + } + + private static void ValidateBaselineProfile(LivePerformanceWorkload workload, LiveBaselineProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + if (profile.Runs.Count != RequiredBaselineRuns + || profile.Runs.Any(run => run.SampleCount != workload.SampleCount) + || profile.P95 != profile.Runs.Max(run => run.P95)) + { + throw new InvalidOperationException($"Baseline for '{workload.Name}' is not a valid fixed matrix profile."); + } + } + + private static TimeSpan Times(TimeSpan value, double multiplier) + { + return TimeSpan.FromTicks(checked((long) Math.Ceiling(value.Ticks * multiplier))); + } + + private static TimeSpan Max(TimeSpan first, TimeSpan second) + { + return first >= second ? first : second; + } + + private static void ThrowWithCleanup(Exception? failure, List cleanup) + { + if (failure is not null) + { + if (cleanup.Count != 0) + { + throw new AggregateException([failure, .. cleanup]); + } + + ExceptionDispatchInfo.Capture(failure).Throw(); + } + if (cleanup.Count != 0) + { + throw new AggregateException(cleanup); + } + } + + [SupportedOSPlatform("windows")] + private sealed class LivePerformanceFixture : IAsyncDisposable + { + private const string Scanner = "performance-9-01-scan"; + private readonly LiveSandboxSession _sandbox; + private readonly LiveMcpClient _gateway; + private readonly LiveMcpInstanceClient _instance; + private readonly List _allocations = []; + private string _named = null!; + private string _aob = null!; + private string _dissect = null!; + private string _structureA = null!; + private string _structureB = null!; + private string _module = null!; + private string _moduleBase = null!; + private bool _structureCreated; + private bool _retainDissectBuffer; + private LivePerformanceFixture(LiveSandboxSession sandbox, LiveMcpClient gateway, LiveMcpInstanceClient instance) + { + _sandbox = sandbox; + _gateway = gateway; + _instance = instance; + } - private static TimeSpan Max(TimeSpan first, TimeSpan second) => first >= second ? first : second; + public static async Task CreateAsync(LiveSandboxSession sandbox, LiveMcpClient gateway, LiveMcpInstanceClient instance) + { + LivePerformanceFixture fixture = new(sandbox, gateway, instance); + Exception? failure = null; + try + { + fixture._named = await fixture.AllocateAsync("performance-9-01-named", 64); + await fixture.WriteAsync(fixture._named, "int32", Value); + fixture._aob = await fixture.AllocateAsync("performance-9-01-aob", 64); + await fixture.WriteAsync(At(fixture._aob, 16), "bytes", "D1 2B 7E 93 44 A8 19 C6"); + fixture._dissect = await fixture.AllocateAsync("performance-9-01-dissect", 64); + await fixture.WriteAsync(fixture._dissect, "bytes", "90 C3"); + fixture._structureA = await fixture.AllocateAsync("performance-9-01-structure-a", 16); + fixture._structureB = await fixture.AllocateAsync("performance-9-01-structure-b", 16); + await fixture.WriteAsync(fixture._structureA, "int32", "7"); + await fixture.WriteAsync(At(fixture._structureA, 4), "uint16", "100"); + await fixture.WriteAsync(fixture._structureB, "int32", "7"); + await fixture.WriteAsync(At(fixture._structureB, 4), "uint16", "200"); + fixture._structureCreated = true; + JsonNode structure = await fixture.CallAsync(CheatEngineToolNames.StructureCreate, new Dictionary + { + ["name"] = "Performance901Structure", + ["elements"] = new object?[] + { + new Dictionary { ["offset"] = "0", ["name"] = "health", ["valueType"] = "int32" }, + new Dictionary { ["offset"] = "4", ["name"] = "flags", ["valueType"] = "uint16" } + } + }); + Assert.Equal(2, structure["elementCount"]!.GetValue()); + JsonNode modules = await fixture.CallAsync(CheatEngineToolNames.ModuleList, new Dictionary { ["format"] = "detailed", ["limit"] = 1000 }); + string expected = sandbox.TargetArchitecture == "x86" ? "dotnet.exe" : "CheatEngine.Mcp.LiveTarget.exe"; + JsonNode module = Assert.Single(modules["modules"]!.AsArray(), item => string.Equals(item!["name"]!.GetValue(), expected, StringComparison.OrdinalIgnoreCase))!; + fixture._module = module["name"]!.GetValue(); + fixture._moduleBase = module["base"]!.GetValue(); + return fixture; + } + catch (Exception exception) + { + failure = exception; + } + List cleanup = []; + try + { + await fixture.DisposeAsync(); + } + catch (Exception exception) + { + cleanup.Add(exception); + } + ThrowWithCleanup(failure, cleanup); + throw new InvalidOperationException("Fixture setup did not complete."); + } + + public Func GetOperation(string name) => name switch + { + "memory_read_int32" => ReadAsync, + "memory_read_batch_16" => () => BatchAsync(16), + "memory_read_batch_1024" => () => BatchAsync(1024), + "memory_list_regions_64" => RegionsAsync, + "named_scan_64" => ScanAsync, + "aob_find_64" => AobAsync, + "pointer_read_chain" => PointerAsync, + "module_symbol_resolution" => ModuleAsync, + "structure_read" => StructureReadAsync, + "structure_dissect_compare" => StructureCompareAsync, + "code_dissect_whole_cycle" => DissectAsync, + "instance_discovery" => InstancesAsync, + _ => throw new InvalidOperationException($"Unsupported workload '{name}'.") + }; + + public async ValueTask DisposeAsync() + { + List cleanup = []; + if (_structureCreated) + { + await CleanupAsync(cleanup, DeleteStructureAsync); + } + + await CleanupAsync(cleanup, DeleteScannerAsync); + if (_retainDissectBuffer) + { + await CleanupAsync(cleanup, RecordRetainedDissectBufferAsync); + } + + foreach (string allocation in _allocations.AsEnumerable().Reverse()) + { + if (!(_retainDissectBuffer && allocation == "performance-9-01-dissect")) + { + await CleanupAsync(cleanup, () => FreeAllocationAsync(allocation)); + } + } + + ThrowWithCleanup(null, cleanup); + } + + private async Task ReadAsync() + { + JsonNode result = await CallAsync(CheatEngineToolNames.MemoryRead, new Dictionary { ["address"] = _sandbox.HostA.TargetAddress, ["valueType"] = "int32" }); + Assert.Equal(Value, result["value"]!.GetValue()); + } + private async Task BatchAsync(int count) + { + JsonNode result = await CallAsync(CheatEngineToolNames.MemoryReadBatch, new Dictionary { ["items"] = Enumerable.Range(0, count).Select(_ => new { address = _sandbox.HostA.TargetAddress, valueType = "int32" }).ToArray() }); + Assert.Equal(0, result["failed"]!.GetValue()); + JsonArray items = result["items"]!.AsArray(); + Assert.Equal(count, items.Count); + Assert.All(items, item => + { + Assert.Equal(Value, item!["value"]!.GetValue()); + Assert.Null(item["error"]); + }); + } + private async Task RegionsAsync() + { + JsonNode result = await CallAsync(CheatEngineToolNames.MemoryListRegions, new Dictionary { ["state"] = "committed", ["limit"] = 64 }); + Assert.NotEmpty(result["regions"]!.AsArray()); + Assert.True(result["total"]!.GetValue() > 0); + } + private async Task ScanAsync() + { + await DeleteScannerAsync(); + await RunWithCleanupAsync(async () => + { + await CallAsync(CheatEngineToolNames.ScanFirst, new Dictionary { ["scannerName"] = Scanner, ["valueType"] = "int32", ["comparison"] = "exact", ["value"] = Value, ["startAddress"] = _named, ["endAddress"] = At(_named, 64), ["writable"] = "required", ["executable"] = "excluded", ["copyOnWrite"] = "any", ["alignment"] = 4, ["includeMapped"] = false }); + await WaitForScanAsync(); + JsonNode results = await CallAsync(CheatEngineToolNames.ScanListResults, new Dictionary { ["scannerName"] = Scanner, ["startIndex"] = 0, ["maximumResults"] = 8 }); + Assert.Contains(results["results"]!.AsArray(), item => item!["value"]!.GetValue() == Value); + }, DeleteScannerAsync); + } + private async Task AobAsync() + { + JsonNode found = await CallAsync(CheatEngineToolNames.AobFind, new Dictionary { ["patterns"] = s_aobPatterns, ["startAddress"] = _aob, ["endAddress"] = At(_aob, 56), ["writable"] = "required", ["executable"] = "excluded", ["copyOnWrite"] = "excluded", ["alignment"] = 4, ["limit"] = 2, ["includeMapped"] = false }); + JsonNode result = Assert.Single(found["results"]!.AsArray())!; + Assert.Equal(At(_aob, 16), Assert.Single(result["matches"]!.AsArray())!.GetValue()); + } + private async Task PointerAsync() + { + JsonNode result = await CallAsync(CheatEngineToolNames.PointerReadChain, new Dictionary { ["base"] = _sandbox.HostA.PointerRootAddress, ["offsets"] = s_pointerOffsets, ["valueType"] = "int32" }); + Assert.Equal(Value, result["value"]!.GetValue()); + } + private async Task ModuleAsync() + { + JsonNode modules = await CallAsync(CheatEngineToolNames.ModuleList, new Dictionary { ["format"] = "detailed", ["limit"] = 1000 }); + Assert.Contains(modules["modules"]!.AsArray(), item => item!["name"]!.GetValue() == _module); + JsonNode symbols = await CallAsync(CheatEngineToolNames.SymbolResolve, new Dictionary { ["expressions"] = new[] { _module, _module + "+0" }, ["shallow"] = true }); + JsonArray items = symbols["items"]!.AsArray(); + Assert.Equal(2, items.Count); + Assert.Equal(_module, items[0]!["expression"]!.GetValue()); + Assert.Equal(_module + "+0", items[1]!["expression"]!.GetValue()); + Assert.All(items, item => + { + Assert.Null(item!["error"]); + Assert.Equal(_moduleBase, item["address"]!.GetValue()); + }); + } + private async Task StructureReadAsync() + { + JsonNode result = await CallAsync(CheatEngineToolNames.StructureRead, new Dictionary { ["name"] = "Performance901Structure", ["addresses"] = new[] { _structureA, _structureB }, ["offset"] = 0, ["limit"] = 8, ["format"] = "detailed" }); + JsonArray elements = result["elements"]!.AsArray(); + Assert.Equal(2, elements.Count); + Assert.Equal("health", elements[0]!["name"]!.GetValue()); + Assert.Equal(s_structureHealthValues, elements[0]!["values"]!.AsArray().Select(item => item!.GetValue())); + Assert.Equal("flags", elements[1]!["name"]!.GetValue()); + Assert.Equal(s_structureFlagValues, elements[1]!["values"]!.AsArray().Select(item => item!.GetValue())); + } + private async Task StructureCompareAsync() + { + JsonNode result = await CallAsync(CheatEngineToolNames.StructureCompare, new Dictionary { ["groupA"] = new[] { _structureA }, ["groupB"] = new[] { _structureB }, ["structureName"] = "Performance901Structure", ["granularity"] = 4, ["interpretAs"] = "auto", ["mode"] = "discriminate", ["offset"] = 0, ["limit"] = 8 }); + JsonNode row = Assert.Single(result["rows"]!.AsArray())!; + Assert.Equal(("4", "flags", "discriminator"), (row["offset"]!.GetValue(), row["name"]!.GetValue(), row["classification"]!.GetValue())); + } + private async Task InstancesAsync() + { + JsonNode result = (await _gateway.CallToolAsync(CheatEngineToolNames.InstanceList))!; + Assert.Equal(2, result["instances"]!.AsArray().Count); + } + + private async Task DissectAsync() + { + _retainDissectBuffer = true; + string? jobId = null; + Exception? failure = null; + List cleanup = []; + try + { + JsonNode start = await CallAsync(CheatEngineToolNames.CodeStartDissect, new Dictionary { ["address"] = _dissect, ["size"] = 2, ["lifetimeSeconds"] = 30 }); + jobId = start["jobId"]!.GetValue(); + JsonNode poll = await PollAsync(jobId); + Assert.Null(poll["job"]!["error"]); + Assert.Null(poll["job"]!["cleanupError"]); + JsonNode item = Assert.Single(poll["items"]!.AsArray())!; + Assert.Equal(("dissect", 2), (item["kind"]!.GetValue(), item["size"]!.GetValue())); + await CallAsync(CheatEngineToolNames.CodeClearDissect); + _retainDissectBuffer = false; + } + catch (Exception exception) + { + failure = exception; + } + finally + { + if (jobId is not null) + { + await CleanupAsync(cleanup, () => StopJobAsync(jobId)); + } + } + ThrowWithCleanup(failure, cleanup); + } + + private async Task PollAsync(string jobId) + { + Dictionary arguments = new() + { + ["jobId"] = jobId, + ["afterSequence"] = 0, + ["limit"] = 8 + }; + Stopwatch deadline = Stopwatch.StartNew(); + while (true) + { + JsonNode poll = await CallAsync(CheatEngineToolNames.CodePollJob, arguments); + string state = poll["job"]!["state"]!.GetValue(); + if (state == "completed") + { + return poll; + } + + Assert.Equal("running", state); + Assert.True(deadline.Elapsed < TimeSpan.FromSeconds(10), "The bounded code-dissect job did not finish."); + await Task.Delay(25, TestContext.Current.CancellationToken); + } + } + private async Task StopJobAsync(string jobId) + { + JsonNode result = await CallAsync(CheatEngineToolNames.RuntimeStopJob, new Dictionary { ["jobId"] = jobId }); + Assert.True(result["released"]!.GetValue()); + } + private async Task WaitForScanAsync() + { + Stopwatch deadline = Stopwatch.StartNew(); + while (true) + { + JsonNode status = await CallAsync(CheatEngineToolNames.ScanGetStatus, new Dictionary { ["scannerName"] = Scanner }); + string state = status["state"]!.GetValue(); + if (state != "Scanning") + { + Assert.Equal("ResultsReady", state); + return; + } + Assert.True(deadline.Elapsed < TimeSpan.FromSeconds(15), "The named scan did not finish."); + await Task.Delay(50, TestContext.Current.CancellationToken); + } + } + private async Task AllocateAsync(string name, int size) + { + _allocations.Add(name); + JsonNode result = await CallAsync(CheatEngineToolNames.MemoryAllocate, new Dictionary { ["name"] = name, ["size"] = size }); + Assert.Equal(size, result["size"]!.GetValue()); + Assert.False(result["executable"]!.GetValue()); + return result["address"]!.GetValue(); + } + private async Task WriteAsync(string address, string type, string value) + { + JsonNode result = await CallAsync(CheatEngineToolNames.MemoryWrite, new Dictionary { ["address"] = address, ["valueType"] = type, ["value"] = value, ["verify"] = true }); + Assert.True(result["verified"]!.GetValue()); + } + private async Task DeleteScannerAsync() + { + LiveMcpToolResult result = await _instance.CallToolRawAsync(CheatEngineToolNames.ScanDelete, new Dictionary { ["scannerName"] = Scanner }); + if (result.IsError) + { + Assert.Equal("not_found", result.Payload!["error"]!["kind"]!.GetValue()); + } + } + private async Task DeleteStructureAsync() + { + LiveMcpToolResult result = await _instance.CallToolRawAsync(CheatEngineToolNames.StructureDelete, + new Dictionary { ["name"] = "Performance901Structure" }); + ThrowIfUnexpectedCleanupError(result, CheatEngineToolNames.StructureDelete); + } + private async Task FreeAllocationAsync(string allocation) + { + LiveMcpToolResult result = await _instance.CallToolRawAsync(CheatEngineToolNames.MemoryFree, + new Dictionary { ["name"] = allocation }); + ThrowIfUnexpectedCleanupError(result, CheatEngineToolNames.MemoryFree); + } + private Task RecordRetainedDissectBufferAsync() + { + _sandbox.Record("performance_retained_dissect_buffer", new + { + allocation = "performance-9-01-dissect", + reason = "The dissect start, poll, validation or clear outcome was uncertain; the owned target shutdown retains the buffer." + }); + return Task.CompletedTask; + } + private static void ThrowIfUnexpectedCleanupError(LiveMcpToolResult result, string tool) + { + if (!result.IsError || result.Payload?["error"]?["kind"]?.GetValue() == "not_found") + { + return; + } + + throw new InvalidOperationException($"Cleanup tool '{tool}' failed: {result.Payload?.ToJsonString() ?? ""}."); + } + private async Task CallAsync(string tool, IReadOnlyDictionary? args = null) => await _instance.CallToolAsync(tool, args) ?? throw new InvalidDataException($"Tool '{tool}' returned no payload."); + } + + internal static async Task RunWithCleanupAsync(Func operation, Func release) + { + ArgumentNullException.ThrowIfNull(operation); + ArgumentNullException.ThrowIfNull(release); + Exception? failure = null; + List cleanup = []; + try + { + await operation(); + } + catch (Exception exception) + { + failure = exception; + } + await CleanupAsync(cleanup, release); + ThrowWithCleanup(failure, cleanup); + } + + private static async Task CleanupAsync(List cleanup, Func action) + { + try + { + await action(); + } + catch (Exception exception) + { + cleanup.Add(exception); + } + } + private static string At(string address, int offset) + { + ulong value = ulong.Parse(address.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? address[2..] : address, NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + return checked(value + (ulong) offset).ToString("X", CultureInfo.InvariantCulture); + } } +internal enum LivePerformanceBudgetClass +{ + Short, Bounded, WholeCycle +} +internal sealed record LivePerformanceWorkload(string Name, int SampleCount, int WarmupCount, LivePerformanceBudgetClass BudgetClass, string Method, string Limits); internal sealed record LiveLatencyProfile(int SampleCount, TimeSpan P50, TimeSpan P95, TimeSpan Maximum); - internal sealed record LiveBaselineProfile(IReadOnlyList Runs, TimeSpan P95); - internal sealed record LivePerformanceDecision(bool Passed, TimeSpan ObservedP95, TimeSpan MaximumP95); - -internal sealed record LivePerformanceRun(LiveLatencyProfile ShortCalls, LiveLatencyProfile BatchCalls); +internal sealed record LivePerformanceRun(IReadOnlyDictionary Workloads) +{ + internal LiveAdmissionSession? Admission + { + get; init; + } + public LiveLatencyProfile ShortCalls => Workloads["memory_read_int32"]; + public LiveLatencyProfile BatchCalls => Workloads["memory_read_batch_16"]; +} +internal sealed record LivePerformanceComparison(string Name, LivePerformanceDecision Decision); diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePluginBridge.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePluginBridge.cs index 89e3e9b..319ff44 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePluginBridge.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LivePluginBridge.cs @@ -9,32 +9,36 @@ local function pluginResponse(text) response:write(text); response:close() assert(os.rename(__RESPONSE__..'.tmp',__RESPONSE__),'plugin response publish failed') end + local function pluginError(nonce,action,reason) + local message=tostring(reason):gsub('[\r\n]+',' '):gsub('[^ -~]','?'):sub(1,1024) + pluginResponse('error\n'..nonce..'\n'..action..'\n'..message) + end local function pluginControl() local request=io.open(__REQUEST__,'r') if not request then return end local command=request:read('*a'); request:close(); assert(os.remove(__REQUEST__),'plugin request dequeue failed') local nonce,action=command:match('^(%d+)\n(%a+)\n$') assert(nonce and (action=='enable' or action=='disable'), 'invalid fixed plugin request') - assert((action=='enable') ~= pluginEnabled, 'plugin requested state is already active') - reloadSettingsFromRegistry() - local f=assert(getSettingsForm(), 'settings form unavailable') - local pc=assert(f.findComponentByName('pcSetting'), 'settings page control unavailable') - local tab=assert(f.findComponentByName('Plugins'), 'plugins tab unavailable') - local clb=assert(f.findComponentByName('clbPlugins'), 'plugins checklist unavailable') - local ok=assert(f.findComponentByName('btnOK'), 'settings OK unavailable') - pc.ActivePage=tab - assert(clb.Items.Count==1, 'expected exactly one owned plugin row') - local row=-1 - for i=0,clb.Items.Count-1 do - local text=tostring(clb.Items[i]) - if text:find(__BASENAME__,1,true) and text:find(__DISPLAY__,1,true) then - assert(row==-1, 'duplicate MCP plugin rows'); row=i - end - end - assert(row>=0, 'owned MCP plugin row not found') - clb.ItemIndex=row - sendMessage(clb.Handle,0x0100,0x20,0); sendMessage(clb.Handle,0x0101,0x20,0) - ok.doClick(); pluginEnabled=(action=='enable') + local success,reason=pcall(function() + assert((action=='enable') ~= pluginEnabled, 'plugin requested state is already active') + local f=assert(getSettingsForm(), 'settings form unavailable') + local settingsClass=tostring(f.ClassName):sub(1,128) + local beforeReload=assert(f.findComponentByName('clbPlugins'), 'plugins checklist unavailable before reload').Items.Count + reloadSettingsFromRegistry() + local pc=assert(f.findComponentByName('pcSetting'), 'settings page control unavailable') + local tab=assert(f.findComponentByName('Plugins'), 'plugins tab unavailable') + local clb=assert(f.findComponentByName('clbPlugins'), 'plugins checklist unavailable') + local ok=assert(f.findComponentByName('btnOK'), 'settings OK unavailable') + pc.ActivePage=tab + local rowCount=clb.Items.Count + assert(rowCount==1, 'expected exactly one owned plugin row (count='..tostring(rowCount)..')'.. + '; settingsClass='..settingsClass..'; beforeReload='..tostring(beforeReload)) + assert(tostring(clb.Items[0])==__BASENAME__..':'..__DISPLAY__, 'owned MCP plugin row not found') + clb.ItemIndex=0 + sendMessage(clb.Handle,0x0100,0x20,0); sendMessage(clb.Handle,0x0101,0x20,0) + ok.doClick(); pluginEnabled=(action=='enable') + end) + if not success then pluginError(nonce,action,reason); return end pluginResponse('ok\n'..nonce..'\n'..action) end """; diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs index cde219f..01a7784 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptIn.cs @@ -77,11 +77,12 @@ internal static LiveQualificationDecision Evaluate(Func variabl "AobOnly" => LiveDispatchDiagnosticCase.AobOnly, "NamedScanThenAob" => LiveDispatchDiagnosticCase.NamedScanThenAob, "MemoryNamedScanThenAob" => LiveDispatchDiagnosticCase.MemoryNamedScanThenAob, + "ResourcePreludeMemoryNamedScanThenAob" => LiveDispatchDiagnosticCase.ResourcePreludeMemoryNamedScanThenAob, _ => (LiveDispatchDiagnosticCase?) null }; if (scenario == LiveQualificationScenario.DispatchDiagnostic && dispatchCaseValue is not null && dispatchCase is null) { - return Refuse($"{DispatchDiagnosticCaseVariable} must be AobOnly, NamedScanThenAob, or MemoryNamedScanThenAob."); + return Refuse($"{DispatchDiagnosticCaseVariable} must be AobOnly, NamedScanThenAob, MemoryNamedScanThenAob, or ResourcePreludeMemoryNamedScanThenAob."); } bool compiler = scenario is LiveQualificationScenario.Compiler or LiveQualificationScenario.CompilerExtended or LiveQualificationScenario.CompilerInjection; diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs index f3671c5..66bb447 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveQualificationOptInTests.cs @@ -188,6 +188,7 @@ public void Evaluate_StandardQualificationScenario_UsesOnlyStandardAcknowledgeme [InlineData("AobOnly", (int) LiveDispatchDiagnosticCase.AobOnly)] [InlineData("NamedScanThenAob", (int) LiveDispatchDiagnosticCase.NamedScanThenAob)] [InlineData("MemoryNamedScanThenAob", (int) LiveDispatchDiagnosticCase.MemoryNamedScanThenAob)] + [InlineData("ResourcePreludeMemoryNamedScanThenAob", (int) LiveDispatchDiagnosticCase.ResourcePreludeMemoryNamedScanThenAob)] public void Evaluate_DispatchDiagnostic_UsesTheExactSelectedCase(string selected, int expected) { diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Lifecycle.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Lifecycle.cs index 1ac421e..7be5d76 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Lifecycle.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveSandboxSession.Lifecycle.cs @@ -70,8 +70,14 @@ internal async Task ReloadPluginAsync(string hostName, bool ena using StreamReader responseReader = new(responseStream, new System.Text.UTF8Encoding(false, true), false); string[] fields = (await responseReader.ReadToEndAsync(cancellationToken)).Replace("\r\n", "\n", StringComparison.Ordinal).Split('\n'); + string action = enabled ? "enable" : "disable"; + if (fields.Length == 4 && fields[0] == "error" && long.TryParse(fields[1], out long failedNonce) + && failedNonce == nonce && fields[2] == action && fields[3].Length is > 0 and <= 1024) + { + throw new InvalidOperationException($"The fixed CE Plugin Manager bridge rejected {action}: {fields[3]}"); + } if (fields.Length != 3 || fields[0] != "ok" || !long.TryParse(fields[1], out long received) - || received != nonce || fields[2] != (enabled ? "enable" : "disable")) + || received != nonce || fields[2] != action) { throw new InvalidOperationException("The fixed CE Plugin Manager bridge returned an invalid response."); } @@ -177,7 +183,7 @@ public LiveProcessMetrics CaptureMetrics(string name) } } - /// Removes only a CE plugin list proven to contain complete pairs for this run's private bundles. + /// Removes only validated owned plugin registry entries before the next private-host transition. internal void NeutralizeOwnedPluginRegistry() { if (_userState is null || _stateGuard is null) diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs index 383fb53..e673d7d 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/LiveWorkflowQualification.cs @@ -499,7 +499,7 @@ internal static async Task VerifyDispatchDiagnosticAsync(LiveSandboxSession sand { LiveMcpInstanceClient instance = gateway.Bind(sandbox.HostA.InstanceId); await VerifyGatesAsync(sandbox, instance); - if (@case == LiveDispatchDiagnosticCase.MemoryNamedScanThenAob) + if (@case is LiveDispatchDiagnosticCase.MemoryNamedScanThenAob or LiveDispatchDiagnosticCase.ResourcePreludeMemoryNamedScanThenAob) { sandbox.Record("dispatch_diagnostic_step", new { @@ -508,7 +508,8 @@ internal static async Task VerifyDispatchDiagnosticAsync(LiveSandboxSession sand }); await VerifyMemoryAsync(sandbox, instance); } - if (@case is LiveDispatchDiagnosticCase.NamedScanThenAob or LiveDispatchDiagnosticCase.MemoryNamedScanThenAob) + if (@case is LiveDispatchDiagnosticCase.NamedScanThenAob or LiveDispatchDiagnosticCase.MemoryNamedScanThenAob + or LiveDispatchDiagnosticCase.ResourcePreludeMemoryNamedScanThenAob) { sandbox.Record("dispatch_diagnostic_step", new { @@ -523,15 +524,73 @@ internal static async Task VerifyDispatchDiagnosticAsync(LiveSandboxSession sand step = "before_aob" }); await VerifyAobAsync(sandbox, instance); + sandbox.Record("dispatch_diagnostic_step", new + { + @case = @case.ToString(), + step = "before_post_aob_allocation" + }); + await VerifyPostAobAllocationAsync(sandbox, instance); sandbox.Record("dispatch_diagnostic_result", new { @case = @case.ToString(), aobToolCalls = 2, nativeScans = 3, + postAobAllocationFreed = true, stableQualification = false }); } + private static async Task VerifyPostAobAllocationAsync(LiveSandboxSession sandbox, LiveMcpInstanceClient instance) + { + const string allocation = "live-dispatch-post-aob"; + bool allocated = false; + bool freed = false; + List cleanup = []; + Exception? failure = null; + try + { + JsonNode result = await CallAsync(instance, CheatEngineToolNames.MemoryAllocate, + new Dictionary { ["name"] = allocation, ["size"] = 64, ["executable"] = false }); + allocated = true; + sandbox.Record("dispatch_diagnostic_post_aob_allocation", new + { + result = result.DeepClone() + }); + Assert.Equal(allocation, Text(result, "name")); + Assert.NotEqual(0UL, Parse(Text(result, "address"))); + Assert.False(result["executable"]!.GetValue()); + } + catch (Exception exception) + { + failure = exception; + throw; + } + finally + { + if (allocated) + { + await CleanupAsync(cleanup, async () => + { + JsonNode result = await CallAsync(instance, CheatEngineToolNames.MemoryFree, + new Dictionary { ["name"] = allocation }); + freed = true; + sandbox.Record("dispatch_diagnostic_post_aob_free", new + { + result = result.DeepClone() + }); + }); + } + sandbox.Record("dispatch_diagnostic_post_aob_cleanup", new + { + allocated, + freeAttempted = allocated, + freed, + failures = cleanup.Count + }); + ThrowCleanup(failure, cleanup); + } + } + private static Dictionary ScanArguments(string name, string address, string comparison) => new() { ["scannerName"] = name, diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaKernelV2Tests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaKernelV2Tests.cs index 1487590..5637321 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaKernelV2Tests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaKernelV2Tests.cs @@ -29,6 +29,28 @@ public sealed partial class NativeLuaToolRuntimeTests end """; + [Fact] + public void KernelStatus_ReportsBooleanStatesAndOmitsUnavailableOrInvalidAnswers() + { + using RuntimeScope scope = CreateScope(); + InstallStubs("dbk_initialized = function() return true end; dbvm_initialized = function() return false end"); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + + KernelStatus reported = dispatch.RunLua(CheatEngineToolNames.KernelGetStatus, KernelScripts.GetStatus, + KernelJsonContext.Default.KernelStatus, Token); + + InstallStubs("dbk_initialized = function() error('not available') end; dbvm_initialized = function() return 1 end"); + KernelStatus unavailable = dispatch.RunLua(CheatEngineToolNames.KernelGetStatus, KernelScripts.GetStatus, + KernelJsonContext.Default.KernelStatus, Token); + InstallStubs("dbk_initialized = nil; dbvm_initialized = nil"); + KernelStatus missing = dispatch.RunLua(CheatEngineToolNames.KernelGetStatus, KernelScripts.GetStatus, + KernelJsonContext.Default.KernelStatus, Token); + + Assert.Equal(((bool?) true, (bool?) false), (reported.DbkInitialized, reported.DbvmInitialized)); + Assert.Equal(((bool?) null, (bool?) null), (unavailable.DbkInitialized, unavailable.DbvmInitialized)); + Assert.Equal(((bool?) null, (bool?) null), (missing.DbkInitialized, missing.DbvmInitialized)); + } + [Fact] public void KernelV2_PhysicalOperations_RefuseAnUninitializedDbvmBeforeTheDeviceCall() { diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLivePluginBridgeTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLivePluginBridgeTests.cs index cfeb64e..03839f0 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLivePluginBridgeTests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaLivePluginBridgeTests.cs @@ -8,7 +8,7 @@ public sealed partial class NativeLuaToolRuntimeTests public void LivePluginBridge_Disable_UsesOnlySpaceKeyMessagesAndSettingsOk() { using RuntimeScope scope = CreateScope(); - InstallStubs(Stubs("7\ndisable\n", "CheatEngine.Mcp.dll CheatEngine.Mcp")); + InstallStubs(Stubs("7\ndisable\n", "CheatEngine.Mcp.dll:CheatEngine.Mcp")); RunPluginBridge("pluginEnabled=true; pluginControl()"); Assert.Equal("ok\n7\ndisable", EvaluateLua("response")); Assert.Equal(2L, EvaluateLua("#messages")); @@ -21,42 +21,89 @@ public void LivePluginBridge_Disable_UsesOnlySpaceKeyMessagesAndSettingsOk() } [Theory] - [InlineData("8\nother\n", "CheatEngine.Mcp.dll CheatEngine.Mcp", 1)] - [InlineData("8\ndisable\n", "Other.dll Other", 1)] - [InlineData("8\ndisable\n", "CheatEngine.Mcp.dll CheatEngine.Mcp", 2)] - [InlineData("8\nenable\n", "CheatEngine.Mcp.dll CheatEngine.Mcp", 1)] - public void LivePluginBridge_InvalidRequestsOrRows_RefuseBeforeSettingsApply(string command, string row, int count) + [InlineData("8\nother\n", "CheatEngine.Mcp.dll:CheatEngine.Mcp", 1, false)] + [InlineData("8\ndisable\n", "Other.dll:Other", 1, true)] + [InlineData("8\ndisable\n", "CheatEngine.Mcp.dll:CheatEngine.Mcp", 0, true)] + [InlineData("8\ndisable\n", "CheatEngine.Mcp.dll:CheatEngine.Mcp", 2, true)] + [InlineData("8\nenable\n", "CheatEngine.Mcp.dll:CheatEngine.Mcp", 1, true)] + public void LivePluginBridge_InvalidRequestsOrRows_RefuseBeforeSettingsApply(string command, string row, int count, + bool expectsBridgeError) { using RuntimeScope scope = CreateScope(); InstallStubs(Stubs(command, row, count)); RunPluginBridge("pluginEnabled=true; success=pcall(pluginControl)"); - Assert.False((bool) EvaluateLua("success")!); + Assert.Equal(expectsBridgeError, (bool) EvaluateLua("success")!); Assert.Equal(0L, EvaluateLua("#messages")); Assert.Equal(0L, EvaluateLua("okClicks")); - Assert.Equal(string.Empty, EvaluateLua("response")); + if (expectsBridgeError) + { + string action = command.Split('\n')[1]; + Assert.StartsWith($"error\n8\n{action}\n", (string) EvaluateLua("response")!); + if (count != 1) + { + Assert.Contains($"expected exactly one owned plugin row (count={count})", (string) EvaluateLua("response")!); + } + } + else + { + Assert.Equal(string.Empty, EvaluateLua("response")); + } } [Fact] public void LivePluginBridge_Enable_UsesThePreviouslyDisabledState() { using RuntimeScope scope = CreateScope(); - InstallStubs(Stubs("9\nenable\n", "CheatEngine.Mcp.dll CheatEngine.Mcp")); + InstallStubs(Stubs("9\nenable\n", "CheatEngine.Mcp.dll:CheatEngine.Mcp")); RunPluginBridge("pluginEnabled=false; pluginControl()"); Assert.True((bool) EvaluateLua("pluginEnabled")!); Assert.Equal("ok\n9\nenable", EvaluateLua("response")); Assert.Equal(1L, EvaluateLua("okClicks")); } - private static string Stubs(string command, string row, int count = 1) => $$""" - response=''; messages={}; okClicks=0 + [Theory] + [InlineData(0, 2, "TformSettings")] + [InlineData(1, 0, "TUnexpectedSettings")] + public void LivePluginBridge_ReloadChangesRows_ReportsObservedClassAndBeforeAfterCounts(int beforeReload, + int afterReload, string settingsClass) + { + using RuntimeScope scope = CreateScope(); + InstallStubs(Stubs("8\ndisable\n", "CheatEngine.Mcp.dll:CheatEngine.Mcp", beforeReload, afterReload, settingsClass)); + RunPluginBridge("pluginEnabled=true; pluginControl()"); + string response = (string) EvaluateLua("response")!; + Assert.StartsWith("error\n8\ndisable\n", response); + Assert.Contains($"expected exactly one owned plugin row (count={afterReload})", response); + Assert.Contains($"; settingsClass={settingsClass}; beforeReload={beforeReload}", response); + Assert.Equal(0L, EvaluateLua("#messages")); + Assert.Equal(0L, EvaluateLua("okClicks")); + Assert.True((bool) EvaluateLua("pluginEnabled")!); + } + + [Fact] + public void LivePluginBridge_LongMultibyteError_RemainsBoundedAscii() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(Stubs("8\ndisable\n", "CheatEngine.Mcp.dll:CheatEngine.Mcp")); + RunPluginBridge("pluginEnabled=true; getSettingsForm=function() error(string.rep('a',1023)..utf8.char(0xE9)..'tail',0) end; pluginControl()"); + Assert.Equal("error\n8\ndisable\n" + new string('a', 1023) + "?", EvaluateLua("response")); + Assert.Equal(0L, EvaluateLua("#messages")); + Assert.Equal(0L, EvaluateLua("okClicks")); + } + + private static string Stubs(string command, string row, int count = 1, int? countAfterReload = null, + string settingsClass = "TformSettings") => $$""" + response=''; messages={}; okClicks=0; settingsFormRequested=false io={open=function(_,mode) if mode=='r' then return {read=function() return {{Quote(command)}} end,close=function() end} end return {write=function(_,v) response=response..v end,close=function() end} end} os={remove=function() return true end,rename=function() return true end} - reloadSettingsFromRegistry=function() end local items={[0]={{Quote(row)}},Count={{count}}} + reloadSettingsFromRegistry=function() + assert(settingsFormRequested, 'settings form must be requested before reload') + items.Count={{countAfterReload ?? count}} + end local clb={Items=items,Handle=99} local pc,tab,ok={},{},{doClick=function() okClicks=okClicks+1 end} - local f={findComponentByName=function(n) return ({pcSetting=pc,Plugins=tab,clbPlugins=clb,btnOK=ok})[n] end} - getSettingsForm=function() return f end + local f={ClassName={{Quote(settingsClass)}},findComponentByName=function(n) return ({pcSetting=pc,Plugins=tab,clbPlugins=clb,btnOK=ok})[n] end} + getSettingsForm=function() settingsFormRequested=true; return f end sendMessage=function(...) table.insert(messages,{...}) end """; diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaMainScannerFixture.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaMainScannerFixture.cs index d88bc76..efe757d 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaMainScannerFixture.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaMainScannerFixture.cs @@ -36,12 +36,14 @@ public void ScanV2_MainScannerBusy_IsARetryableBusyRefusalBeforeAnyEffect() Assert.Equal("Scanning", tools.GetStatus(cancellationToken: Token).State); } - [Fact] - public void ScanV2_MainScannerWithoutProcess_IsNotAttached() + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ScanV2_MainScannerWithoutAPositiveProcessId_IsNotAttached(int processId) { using RuntimeScope scope = CreateScope(); InstallMainScanner(); - InstallStubs("getOpenedProcessID=function() return 0 end"); + InstallStubs($"getOpenedProcessID=function() return {processId} end"); using ScanTools tools = new(CreateNativeDispatch(new McpFeatureOptions()), new TargetResources()); CheatEngineToolException first = @@ -52,7 +54,9 @@ public void ScanV2_MainScannerWithoutProcess_IsNotAttached() Assert.Equal((ToolErrorKind.NotAttached, ToolErrorKind.NotAttached), (first.Error.Kind, next.Error.Kind)); Assert.Equal((ToolHostEffect.NotStarted, "Attach a process with process_attach."), (first.Error.HostEffect, first.Error.Hint)); - Assert.Equal("NoTarget", tools.GetStatus(cancellationToken: Token).State); + ScanStatusResult status = tools.GetStatus(cancellationToken: Token); + Assert.Equal("NoTarget", status.State); + Assert.Null(status.ProcessId); Assert.Equal((0L, 0L), (ReadGlobal("firstCalls"), ReadGlobal("nextCalls"))); } diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaMemoryAobTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaMemoryAobTests.cs index b86dc9a..5d71d7b 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaMemoryAobTests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaMemoryAobTests.cs @@ -59,12 +59,15 @@ public void AddressExtras_StubbedCheatEngine_ReportsSystemModulesAndRttiClasses( AddressExtras withoutRtti = RunExtras(false); InstallStubs("inSystemModule = nil; getRTTIClassName = nil"); AddressExtras missing = RunExtras(true); + InstallStubs("inSystemModule = function() error('unavailable') end"); + AddressExtras raised = RunExtras(false); - Assert.Equal([false, true], withRtti.System); + Assert.Equal([false, true], withRtti.System); Assert.Equal(["Player", null], withRtti.Rtti); Assert.Equal([null, null], withoutRtti.Rtti); - Assert.Equal([false, false], missing.System); + Assert.Equal([null, null], missing.System); Assert.Equal([null, null], missing.Rtti); + Assert.Equal([null, null], raised.System); } [Fact] diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaModuleSymbolTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaModuleSymbolTests.cs index 69eaffb..cacf835 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaModuleSymbolTests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaModuleSymbolTests.cs @@ -123,7 +123,7 @@ public void ModuleSymbolListRegistered_OverTheCopyBound_IsTruncatedAndANonTableI } [Fact] - public void ModuleSymbolGetModulePreference_Stubbed_CopiesAtMost1024Names() + public void ModuleSymbolGetModulePreference_Stubbed_CopiesAtMost1024NamesAndRefusesAMissingHostList() { using RuntimeScope scope = CreateScope(); InstallStubs(ModulePreferenceStubs); @@ -131,12 +131,16 @@ public void ModuleSymbolGetModulePreference_Stubbed_CopiesAtMost1024Names() ModulePreference current = tools.GetModulePreference(Token); InstallStubs("preference = nil"); - ModulePreference missing = tools.GetModulePreference(Token); + CheatEngineToolException missing = Assert.Throws(() => tools.GetModulePreference(Token)); + InstallStubs("preference = {}"); + ModulePreference empty = tools.GetModulePreference(Token); InstallStubs("preference = {}; for i = 1, 1500 do preference[i] = 'm' .. i end"); ModulePreference large = tools.GetModulePreference(Token); Assert.Equal(["game", "engine", "kernel32"], current.Modules); - Assert.Empty(missing.Modules); + Assert.Equal((ToolErrorKind.HostRefused, ToolHostEffect.NotStarted), + (missing.Error.Kind, missing.Error.HostEffect)); + Assert.Empty(empty.Modules); Assert.Equal((1024, true, "m1024"), (large.Modules.Length, large.Truncated, large.Modules[^1])); } diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaRecordClearTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaRecordClearTests.cs index ab3a713..0033b86 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaRecordClearTests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaRecordClearTests.cs @@ -187,6 +187,8 @@ public void RecordClear_ActiveAutoAssemblerWithoutGate_LeavesTheAddressListUntou Assert.Equal((ToolErrorKind.CapabilityDisabled, ToolHostEffect.NotStarted), (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Equal("Set Mcp:EnableAutoAssembler to true in appsettings.json, then disable and re-enable the plugin.", + exception.Error.Hint); Assert.Equal((1L, 0L), (ModuleSymbolRead("addressList.Count"), ReadGlobal("clearCalls"))); } diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaRuntimeProcessScanV2Tests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaRuntimeProcessScanV2Tests.cs index 987fdee..5ca9f07 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaRuntimeProcessScanV2Tests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaRuntimeProcessScanV2Tests.cs @@ -1,11 +1,21 @@ using System.Reflection; +using CheatEngine.Client; +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; + using CheatEngine.Mcp.Core.Contract; using CheatEngine.Mcp.Core.Features; using CheatEngine.Mcp.Core.Files; using CheatEngine.Mcp.Tests.Support; using CheatEngine.Mcp.Tools.Processes; using CheatEngine.Mcp.Tools.Scan; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; +using CheatEngine.SDK.Engine.Values; + +using Microsoft.Extensions.Options; namespace CheatEngine.Mcp.Tests.NativeLua; @@ -179,13 +189,20 @@ public void ProcessV2_FixedEffects_ReturnObservedTypedHostState() getOpenedProcessID=function() return pid end getOpenedFileSize=function() return 4096 end saveOpenedFile=function(filename) saved=saved+1; savedFilename=filename end - getThreadlist=function() return {100, 200, 300} end + threadLists=0; threadListsDestroyed=0 + createStringList=function() + threadLists=threadLists+1 + return {Count=0, Strings={}, destroy=function() threadListsDestroyed=threadListsDestroyed+1 end} + end + getThreadlist=function(list) + list.Strings[0]='64'; list.Strings[1]='C8'; list.Strings[2]='12C'; list.Count=3 + end setPointerSize=function(value) pointerSize=value end getPointerSize=function() return pointerSize end """); string input = typeof(ProcessTools).Assembly.Location; string output = Path.Combine(Path.GetTempPath(), $"ce-mcp-save-{Guid.NewGuid():N}.bin"); - ProcessTools tools = new(CreateNativeDispatch(new McpFeatureOptions()), new TargetResources(), + ProcessTools tools = new(CreateNativeProcessDispatch(99), new TargetResources(), new TargetTransitionGuards([]), CreateProcessFiles()); try @@ -207,6 +224,7 @@ public void ProcessV2_FixedEffects_ReturnObservedTypedHostState() Assert.Equal(1L, ReadGlobal("created")); Assert.Equal(1L, ReadGlobal("opened")); Assert.Equal(1L, ReadGlobal("saved")); + Assert.Equal((1L, 1L), (ReadGlobal("threadLists"), ReadGlobal("threadListsDestroyed"))); Assert.Equal(input, ReadGlobal("createdPath")); Assert.Equal(input, ReadGlobal("openedFilename")); string savedTemporary = Assert.IsType(ReadGlobal("savedFilename")); @@ -220,6 +238,45 @@ public void ProcessV2_FixedEffects_ReturnObservedTypedHostState() } } + [Fact] + public void ProcessV2_ThreadList_DestroysItsCallerOwnedStringListWhenCollectionFails() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(""" + pid=77; lists=0; destroyed=0 + getOpenedProcessID=function() return pid end + createStringList=function() + lists=lists+1 + return {Count=0, Strings={}, destroy=function() destroyed=destroyed+1 end} + end + getThreadlist=function(list) error('thread enumeration failed', 0) end + """); + ProcessTools tools = new(CreateNativeProcessDispatch(77), new TargetResources(), + new TargetTransitionGuards([]), CreateProcessFiles()); + + Assert.Throws(() => tools.ListThreads(Token)); + + Assert.Equal((1L, 1L), (ReadGlobal("lists"), ReadGlobal("destroyed"))); + } + + /// Creates a native Lua dispatch with a stable target snapshot for the thread-list preparation. + private static ToolDispatch CreateNativeProcessDispatch(int processId) + { + IProcessClient processes = ClientTestDouble.Create((method, _) => + { + Assert.Equal(nameof(IProcessClient.GetCurrentProcess), method.Name); + return new ProcessSnapshot(new TargetProcessId(processId), "native-lua.exe", null, + TargetBackend.LocalProcess, CheatEngineArchitecture.X64, PointerSize.Bit64, 8, null, 1); + }); + IOptions options = Options.Create(new McpExecutionOptions()); + ICheatEngineClient client = ClientTestDouble.Client( + (nameof(ICheatEngineClient.Lua), CreateJsonLuaClient().Lua), + (nameof(ICheatEngineClient.Processes), processes)); + return new ToolDispatch(client, new McpFeatureGate(Options.Create(new McpFeatureOptions())), options, + new DispatchStatistics(options), TimeProvider.System, new RecordingLogger(), + new PluginFixedLuaExecutor(client)); + } + private static McpFilePaths CreateProcessFiles() { string root = Path.GetTempPath(); diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaSpeedhackToolsTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaSpeedhackToolsTests.cs index f38783c..45d7ec5 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaSpeedhackToolsTests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaSpeedhackToolsTests.cs @@ -75,6 +75,21 @@ public void SpeedhackV2_SetSpeedOne_AtNormalSpeed_ChangesNothingAndNeverHooks(bo LuaFixedScriptAssert.NeverLoadsCode(SpeedhackScripts.SetNormal); } + [Fact] + public void SpeedhackV2_ZeroSymbolAddress_IsAbsentAndNeverActivatesAtNormalSpeed() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(SpeedhackStubs + "\nspeedhackSymbol = 0"); + SpeedhackTools tools = new(CreateNativeDispatch(new McpFeatureOptions()), new TargetResources()); + + SpeedhackState state = tools.GetState(Token); + SpeedhackSetResult normal = tools.SetSpeed(1, Token); + + Assert.Equal(new SpeedhackState(1, false), state); + Assert.Equal(new SpeedhackSetResult(1, false, false), normal); + Assert.Equal((0L, 0L), (ReadGlobal("setCalls"), ReadGlobal("hookAttempts"))); + } + [Theory] [InlineData(false, false)] [InlineData(true, false)] @@ -124,6 +139,78 @@ public void SpeedhackV2_SetSpeedOne_ASpeedOfAnotherProcess_IsInvalidStateAndNeve Assert.Equal((0L, 0L), (ReadGlobal("setCalls"), ReadGlobal("hookAttempts"))); } + [Theory] + [InlineData("getAddressSafe = nil")] + [InlineData("getAddressSafe = function(_) error('symbol lookup failed') end")] + public void SpeedhackV2_SymbolProbeUnavailable_RefusesStateAndChangesBeforeAnyMutation(string stubs) + { + using RuntimeScope scope = CreateScope(); + InstallStubs(SpeedhackStubs + "\n" + stubs); + SpeedhackTools tools = new(CreateNativeDispatch(new McpFeatureOptions()), new TargetResources()); + + CheatEngineToolException state = Assert.Throws(() => tools.GetState(Token)); + CheatEngineToolException normal = Assert.Throws(() => tools.SetSpeed(1, Token)); + CheatEngineToolException changed = Assert.Throws(() => tools.SetSpeed(2, Token)); + + Assert.All([state, normal, changed], static exception => + Assert.Equal((ToolErrorKind.HostRefused, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect))); + Assert.Equal((0L, 0L), (ReadGlobal("setCalls"), ReadGlobal("hookAttempts"))); + } + + [Fact] + public void SpeedhackV2_SymbolProbeFailsAfterChangingSpeed_TracksTheRestoreAndReturnsAnUnknownObservation() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(JobHostStubs + "\n" + SpeedhackStubs + """ + + local lookup = getAddressSafe + local lookups = 0 + getAddressSafe = function(name) + lookups = lookups + 1 + if lookups > 1 then error('symbol lookup failed after changing speed') end + return lookup(name) + end + """); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + TargetResources resources = new(new McpStateLedger(dispatch, TimeProvider.System)); + SpeedhackTools tools = new(dispatch, resources); + + SpeedhackSetResult changed = tools.SetSpeed(2, Token); + + Assert.Equal((2d, (bool?) null, true), (changed.Speed, changed.HooksInstalled, changed.FirstActivation)); + Assert.NotNull(changed.ResourceId); + Assert.Single(resources.List()); + Assert.Equal((1L, 1L), (ReadGlobal("setCalls"), ReadGlobal("hookAttempts"))); + } + + [Fact] + public void SpeedhackV2_SymbolProbeFailsAfterRestoringSpeed_ReturnsAnUnknownObservation() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(SpeedhackStubs + """ + + speedhackSymbol = 0x7FF6A0000000 + configuredSpeed = 2 + wantedSpeed = 2 + local lookup = getAddressSafe + local lookups = 0 + getAddressSafe = function(name) + lookups = lookups + 1 + if lookups > 1 then error('symbol lookup failed after restoring speed') end + return lookup(name) + end + """); + SpeedhackTools tools = new(CreateNativeDispatch(new McpFeatureOptions()), new TargetResources()); + + SpeedhackSetResult restored = tools.SetSpeed(1, Token); + + Assert.Equal((1d, (bool?) null, false), (restored.Speed, restored.HooksInstalled, restored.FirstActivation)); + // The existing hooks restore their speed without another activation, even when the later probe fails. + Assert.Equal((1L, 0L, 1L, 1L), (ReadGlobal("setCalls"), ReadGlobal("hookAttempts"), + ReadGlobal("requestedSpeed"), ReadGlobal("wantedSpeed"))); + } + [Theory] [InlineData("openedProcess = 0", ToolErrorKind.InvalidState, ToolHostEffect.NotStarted)] [InlineData("configuredSpeed = 2; paused = true", ToolErrorKind.InvalidState, ToolHostEffect.NotStarted)] diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaStructureTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaStructureTests.cs index 3b04827..0d01a86 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaStructureTests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaStructureTests.cs @@ -131,6 +131,7 @@ function define(name, fields) { nameof(StructureLuaScripts.Helpers), [] }, { nameof(StructureLuaScripts.List), ["play", 0, 100, 65536] }, { nameof(StructureLuaScripts.Elements), ["Player", null, 16, 0, 100, true] }, + { nameof(StructureLuaScripts.DefinitionPage), ["Player", 0, 100, true, 65536] }, { nameof(StructureLuaScripts.ElementAt), ["Player", 1] }, { nameof(StructureLuaScripts.CreateFromElements), @@ -205,9 +206,14 @@ public void StructureGet_StubbedElements_MapTypesAndReportMissingNames() StructureTools tools = NativeStructureHarness().Structures; StructureDefinition definition = tools.Get("Player", 1, 2, ResultFormat.Detailed, StructureToken); + StructureDefinition concise = tools.Get("Player", 1, 2, ResultFormat.Concise, StructureToken); + StructureDefinition tail = tools.Get("Player", 2, 1, ResultFormat.Detailed, StructureToken); Assert.Equal(("Player", 3, false, (int?) null), (definition.Name, definition.Total, definition.Internal, definition.NextOffset)); + Assert.Equal((definition.Name, definition.Total, definition.Internal, definition.NextOffset), + (concise.Name, concise.Total, concise.Internal, concise.NextOffset)); + Assert.Equal((3, 1, (int?) null), (tail.Total, tail.Elements.Length, tail.NextOffset)); Assert.Equal(new StructureElement(1, "8", "speed", StructureElementType.Float, null, 4, "Enemy", "4"), definition.Elements[0]); Assert.Equal((StructureElementType.Binary, 1), @@ -216,6 +222,53 @@ public void StructureGet_StubbedElements_MapTypesAndReportMissingNames() ToolHostEffect.NotStarted); } + [Fact] + public void StructureGet_BoundedDefinitionLookupAndPagedElements_AvoidWholeStructureTraversal() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(StructureHostStubs); + InstallStubs(""" + virtualCalls, elementCalls = {}, {} + getStructureCount = function() return 70000 end + getStructure = function(index) + if index == 0 then virtualCalls[#virtualCalls + 1] = index; return player end + error('bounded lookup must stop at the matching definition') + end + setmetatable(player, {__index = function(t, key) + if key == 'Count' then return 100000 end + if key == 'Size' then return 400000 end + end}) + player.getElement = function(index) + elementCalls[#elementCalls + 1] = index + if index < 900 or index > 901 then error('page-only getElement access expected') end + return {Offset = index * 4, Name = 'field' .. index, Vartype = 2, + DisplayMethod = 'dtSignedInteger', getBytesize = function() return 4 end} + end + """); + StructureTools tools = NativeStructureHarness().Structures; + + StructureDefinition definition = tools.Get("Player", 900, 2, cancellationToken: StructureToken); + + Assert.Equal((100000, 2, (int?) 902), (definition.Total, definition.Elements.Length, definition.NextOffset)); + Assert.Equal((1L, 0L), (LuaValue("#virtualCalls"), LuaValue("virtualCalls[1]"))); + Assert.Equal((2L, 900L, 901L), (LuaValue("#elementCalls"), LuaValue("elementCalls[1]"), + LuaValue("elementCalls[2]"))); + } + + [Fact] + public void StructureGet_MissingDefinition_ReportsNotFoundWithinBoundAndLimitExceededPastIt() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(StructureHostStubs); + StructureTools tools = NativeStructureHarness().Structures; + + AssertDeclared(() => tools.Get("Missing", cancellationToken: StructureToken), ToolErrorKind.NotFound, + ToolHostEffect.NotStarted); + InstallStubs("getStructureCount = function() return 65537 end; getStructure = function() return nil end"); + AssertDeclared(() => tools.Get("Missing", cancellationToken: StructureToken), ToolErrorKind.LimitExceeded, + ToolHostEffect.NotStarted); + } + [Fact] public void StructureLookup_NumericNameResolvedByIndex_IsNotMistakenForAnotherStructure() { diff --git a/tests/CheatEngine.Mcp.Tests/Plugin/McpModuleLifecycleTests.cs b/tests/CheatEngine.Mcp.Tests/Plugin/McpModuleLifecycleTests.cs index 58363a2..95a4b58 100644 --- a/tests/CheatEngine.Mcp.Tests/Plugin/McpModuleLifecycleTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Plugin/McpModuleLifecycleTests.cs @@ -296,6 +296,106 @@ public void Enable_InvalidSettings_FailWhileTheActivationIsBuilt(string key, str Assert.Empty(accessed); } + [Fact] + public void Enable_OccupiedEndpoint_RefusesAndLeavesNoDiscoveryRecordOrActiveBackend() + { + string root = Path.Combine(Path.GetTempPath(), $"CheatEngine.Mcp.Tests-{Guid.NewGuid():N}"); + string instances = Path.Combine(root, "instances"); + List accessed = []; + ICheatEngineClient client = StrictClient(accessed); + using System.Net.Sockets.TcpListener occupied = new(IPAddress.Loopback, 0); + occupied.Start(); + int port = ((IPEndPoint) occupied.LocalEndpoint).Port; + using TestActivation activation = new(client, + new Dictionary + { + ["Mcp:Port"] = port.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["Mcp:InstanceDirectory"] = instances + }); + McpServerModule module = activation.Module; + InstanceRegistry registry = new(instances); + try + { + InvalidOperationException failure = Assert.Throws(() => module.OnEnabled(client)); + + Assert.Contains("Could not start the MCP server", failure.Message, StringComparison.Ordinal); + Assert.Empty(registry.ReadActive(TestContext.Current.CancellationToken)); + + occupied.Stop(); + using System.Net.Sockets.TcpListener replacement = new(IPAddress.Loopback, port); + replacement.Start(); + } + finally + { + module.Dispose(); + if (Directory.Exists(root)) + { + Directory.Delete(root, true); + } + } + + Assert.Empty(registry.ReadActive(TestContext.Current.CancellationToken)); + Assert.All(accessed, member => Assert.Contains(member, AllowedClientMembers)); + } + + [Fact] + public async Task Enable_UnavailableLogLocation_KeepsTheActualModuleRunningAndDiscoverable() + { + string root = Path.Combine(Path.GetTempPath(), $"CheatEngine.Mcp.Tests-{Guid.NewGuid():N}"); + string instances = Path.Combine(root, "instances"); + Directory.CreateDirectory(root); + string unavailableDirectory = Path.Combine(root, "not-a-directory"); + await File.WriteAllTextAsync(unavailableDirectory, "occupied", TestContext.Current.CancellationToken); + List accessed = []; + ICheatEngineClient client = StrictClient(accessed); + using TestActivation activation = new(client, + new Dictionary + { + ["Mcp:InstanceDirectory"] = instances + }); + PluginLog unavailableLog = new(unavailableDirectory); + McpServerModule? module = null; + string? endpoint = null; + string logLocationContents = string.Empty; + try + { + IOptions backend = activation.Services.GetRequiredService>(); + IOptions discovery = activation.Services.GetRequiredService>(); + McpBackendHostFactory factory = new(backend, discovery, unavailableLog, + activation.Services.GetRequiredService(), + activation.Services.GetRequiredService>()); + McpStatusIndicator status = new(discovery, unavailableLog, + activation.Services.GetRequiredService>()); + module = new McpServerModule(factory, activation.Targets, status); + InstanceRegistry registry = new(instances); + using HttpClient http = new(); + + module.OnEnabled(client); + InstanceDescriptor published = Assert.Single(registry.ReadActive(TestContext.Current.CancellationToken)); + endpoint = published.Endpoint; + await AssertPublishedIdentityAsync(http, published); + + module.OnDisabling(client); + Assert.Empty(registry.ReadActive(TestContext.Current.CancellationToken)); + module.Dispose(); + await WaitUntilStoppedAsync(http, endpoint); + } + finally + { + module?.OnDisabling(client); + module?.Dispose(); + await TestLog.ReleaseAsync(unavailableLog); + logLocationContents = await File.ReadAllTextAsync(unavailableDirectory, TestContext.Current.CancellationToken); + if (Directory.Exists(root)) + { + Directory.Delete(root, true); + } + } + + Assert.Equal("occupied", logLocationContents); + Assert.All(accessed, member => Assert.Contains(member, AllowedClientMembers)); + } + [Fact] public void Enable_PartiallyStartedBackendStopsBeforeItRethrowsTheStartupFailure() { diff --git a/tests/CheatEngine.Mcp.Tests/Resources/KnowledgeResourceTests.cs b/tests/CheatEngine.Mcp.Tests/Resources/KnowledgeResourceTests.cs index 1af7a0f..a5f1b47 100644 --- a/tests/CheatEngine.Mcp.Tests/Resources/KnowledgeResourceTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Resources/KnowledgeResourceTests.cs @@ -6,6 +6,7 @@ using System.Text.RegularExpressions; using CheatEngine.Client.Inspection; +using CheatEngine.Mcp.Core.Inspection; using CheatEngine.Mcp.Prompts; using CheatEngine.Mcp.Resources; using CheatEngine.Mcp.Resources.Docs; @@ -44,7 +45,6 @@ public sealed partial class KnowledgeResourceTests "cheatengine://instance/jobs", "cheatengine://instance/memory/{address}{?size}", "cheatengine://instance/modules/{module}", - "cheatengine://instance/modules/{module}/exports{?offset,limit}", "cheatengine://instance/modules{?offset,limit}", "cheatengine://instance/patches", "cheatengine://instance/pointer-maps", @@ -252,8 +252,10 @@ public void LiveModules_ProjectsTheDefaultModuleList() { ModuleSymbolTarget target = new(); target.AddModule("game.exe", 0x140000000, 0x5000); - ModuleLiveResources resources = new(new ModuleTools(target.Dispatch), - new ModuleExportTools(target.Dispatch), target.Dispatch); + PreparedInspectionStore prepared = new(TimeProvider.System); + ModuleTools tools = new(target.Dispatch, prepared); + _ = tools.List(cancellationToken: TestContext.Current.CancellationToken); + ModuleLiveResources resources = new(tools, target.Dispatch, prepared); ReadResourceResult result = resources.Modules(cancellationToken: TestContext.Current.CancellationToken); @@ -273,7 +275,9 @@ public void LiveMemoryRegions_ProjectsTheDefaultRegionList() { Inspection = LiveResourceInspection.Regions }; - MemoryLiveResources resources = new(new MemoryInfoTools(target.Dispatch), + MemoryInfoTools tools = new(target.Dispatch, new PreparedInspectionStore(TimeProvider.System)); + _ = tools.ListRegions(cancellationToken: TestContext.Current.CancellationToken); + MemoryLiveResources resources = new(tools, new MemoryReadTools(target.Dispatch)); ReadResourceResult result = resources.Regions(cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/CheatEngine.Mcp.Tests/Resources/LiveCompletionTests.cs b/tests/CheatEngine.Mcp.Tests/Resources/LiveCompletionTests.cs index 41a7501..06ea17f 100644 --- a/tests/CheatEngine.Mcp.Tests/Resources/LiveCompletionTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Resources/LiveCompletionTests.cs @@ -7,6 +7,7 @@ using CheatEngine.Client.Results; using CheatEngine.Client.Scanning; using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Inspection; using CheatEngine.Mcp.Resources.Live; using CheatEngine.Mcp.Tests.Support; using CheatEngine.Mcp.Tests.Tools.Modules; @@ -51,7 +52,6 @@ .. catalog.InstanceResources.SelectMany(static resource => Assert.Equal( [ Instance + "modules/{module} module", - Instance + "modules/{module}/exports{?offset,limit} module", Instance + "pointer-scans/{scanName}/paths{?offset,limit} scanName", Instance + "scanners/{scannerName} scannerName", Instance + "structures/{structure}{?offset,limit} structure" @@ -191,6 +191,71 @@ public async Task Complete_FailedDispatchListing_OffersNothingAndIsRetriedAtMost Assert.Equal(2, host.Listings.Calls("name")); } + [Fact] + public async Task Complete_FreshDispatchSuccessesAreNeverServedFromTheOuterCache() + { + await using ProbeHost host = ProbeHost.Create(); + host.Listings.Set("freshSlot", "first"); + Completion first = await host.CompleteAsync(Probe.FreshSlots, "freshSlot", ""); + host.Listings.Set("freshSlot", "second"); + Completion second = await host.CompleteAsync(Probe.FreshSlots, "freshSlot", ""); + + Assert.Equal(["first"], first.Values); + Assert.Equal(["second"], second.Values); + Assert.Equal(2, host.Listings.Calls("freshSlot")); + } + + [Fact] + public async Task Complete_FreshDispatchFailureIsThrottledUntilItsRefreshInterval() + { + await using ProbeHost host = ProbeHost.Create(); + host.Listings.Fail("freshSlot", CheatEngineToolException.Busy("busy", "retry")); + Completion failed = await host.CompleteAsync(Probe.FreshSlots, "freshSlot", ""); + Completion limited = await host.CompleteAsync(Probe.FreshSlots, "freshSlot", ""); + host.Time.Advance(McpResourceCompletions.RefreshInterval); + host.Listings.Set("freshSlot", "recovered"); + Completion recovered = await host.CompleteAsync(Probe.FreshSlots, "freshSlot", ""); + + Assert.Empty(failed.Values); + Assert.Empty(limited.Values); + Assert.Equal(["recovered"], recovered.Values); + Assert.Equal(2, host.Listings.Calls("freshSlot")); + } + + [Fact] + public async Task Complete_FreshDispatchConcurrentRequestsShareOnlyTheirInFlightListing() + { + await using ProbeHost host = ProbeHost.Create(TimeSpan.FromSeconds(10)); + using ManualResetEventSlim release = new(); + TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + host.Listings.Behave("freshSlot", _ => + { + entered.TrySetResult(); + Assert.True(release.Wait(TimeSpan.FromSeconds(5), Token)); + return new McpCompletionValues(["shared"]); + }); + Task first = host.CompleteAsync(Probe.FreshSlots, "freshSlot", ""); + Task? second = null; + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), Token); + second = host.CompleteAsync(Probe.FreshSlots, "freshSlot", ""); + Assert.False(second.IsCompleted); + } + finally + { + release.Set(); + await first.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + if (second is not null) + { + await second.WaitAsync(TimeSpan.FromSeconds(5), CancellationToken.None); + } + } + Assert.Equal(["shared"], (await first).Values); + Assert.Equal(["shared"], (await second!).Values); + Assert.Equal(1, host.Listings.Calls("freshSlot")); + } + [Fact] public async Task Complete_NewerSelectionEpoch_DropsTheValuesOfAnOlderEpochButNotEpochlessOnes() { @@ -339,15 +404,20 @@ public void ModuleSource_ListsDistinctModuleNamesWithTheSelectionEpochInOneDispa target.AddModule("game.exe", 0x140000000, 0x5000); target.AddModule("GAME.EXE", 0x150000000, 0x1000); target.AddModule("user32.dll", 0x7FF800000000, 0x1000); - ModuleLiveResources live = new(new ModuleTools(target.Dispatch), new ModuleExportTools(target.Dispatch), - target.Dispatch); + PreparedInspectionStore prepared = new(TimeProvider.System); + ModuleTools modules = new(target.Dispatch, prepared); + _ = modules.List(cancellationToken: Token); + int enumerations = target.Calls("TryGetModules"); + int dispatches = target.Dispatcher.Calls; + ModuleLiveResources live = new(modules, target.Dispatch, prepared); McpCompletionValues listed = live.ListCompletionValues("module", Token); target.Attached = false; Assert.Equal(["game.exe", "user32.dll"], listed.Values); Assert.Equal(42, listed.SelectionEpoch); - Assert.Equal(1, target.Dispatcher.Calls); + Assert.Equal(dispatches + 1, target.Dispatcher.Calls); + Assert.Equal(enumerations, target.Calls("TryGetModules")); Assert.Equal(ToolErrorKind.NotAttached, Assert.Throws(() => live.ListCompletionValues("module", Token)).Error.Kind); Assert.Throws(() => live.ListCompletionValues("offset", Token)); @@ -441,22 +511,63 @@ public async Task CompleteOverTheWire_ModuleTemplates_OfferTheAttachedProcessMod { await using ModuleCompletionActivation activation = await ModuleCompletionActivation.StartAsync(); ModelContextProtocol.Client.McpClient client = activation.Pipeline.Client; + Assert.NotEqual(true, (await activation.Pipeline.CallAsync(CheatEngineToolNames.ModuleList)).IsError); + int enumerations = activation.Target.Calls("TryGetModules"); CompleteResult module = await client.CompleteAsync( new ResourceTemplateReference { Uri = Instance + "modules/{module}" }, "module", "SA", cancellationToken: Token); - CompleteResult exports = await client.CompleteAsync( - new ResourceTemplateReference { Uri = Instance + "modules/{module}/exports{?offset,limit}" }, "module", - "", cancellationToken: Token); + CompleteResult all = await client.CompleteAsync( + new ResourceTemplateReference { Uri = Instance + "modules/{module}" }, "module", "", + cancellationToken: Token); CompleteResult limit = await client.CompleteAsync( new ResourceTemplateReference { Uri = Instance + "modules{?offset,limit}" }, "limit", "1", cancellationToken: Token); Assert.NotNull(client.ServerCapabilities.Completions); Assert.Equal(["sample.dll"], module.Completion.Values); - Assert.Equal(["sample.dll", "game.exe"], exports.Completion.Values); + Assert.Equal(["sample.dll", "game.exe"], all.Completion.Values); Assert.Empty(limit.Completion.Values); - // Both templates share one cached listing: one dispatch for the module names. + // Preparation is one source-tool enumeration; completions never enumerate again. + Assert.Equal(enumerations, activation.Target.Calls("TryGetModules")); + } + + [Fact] + public async Task CompleteOverTheWire_ColdModulesNeverEnumerate() + { + await using ModuleCompletionActivation activation = await ModuleCompletionActivation.StartAsync(); + CompleteResult result = await activation.Pipeline.Client.CompleteAsync( + new ResourceTemplateReference { Uri = Instance + "modules/{module}" }, "module", "", + cancellationToken: Token); + + Assert.Empty(result.Completion.Values); + Assert.Equal(0, activation.Target.Calls("TryGetModules")); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CompleteOverTheWire_ExpiredOrChangedTargetNeverOffersStaleModules(bool changeTarget) + { + ManualTimeProvider time = new(); + await using ModuleCompletionActivation activation = await ModuleCompletionActivation.StartAsync(time); + ModelContextProtocol.Client.McpClient client = activation.Pipeline.Client; + async Task Complete() + { + return (await client.CompleteAsync(new ResourceTemplateReference { Uri = Instance + "modules/{module}" }, + "module", "", cancellationToken: Token)).Completion; + } + Assert.NotEqual(true, (await activation.Pipeline.CallAsync(CheatEngineToolNames.ModuleList)).IsError); + Assert.Equal(["sample.dll", "game.exe"], (await Complete()).Values); + if (changeTarget) + { + activation.Target.SelectionEpoch++; + } + else + { + time.Advance(PreparedInspectionStore.Lifetime); + } + Assert.Empty((await Complete()).Values); Assert.Equal(1, activation.Target.Calls("TryGetModules")); } @@ -677,6 +788,7 @@ public sealed class Probe(ProbeListings listings) : IMcpCompletionSource internal const string Names = Instance + "probe-names/{name}"; internal const string NameIds = Instance + "probe-names/{name}/ids/{id}"; internal const string Slots = Instance + "probe-slots/{slot}"; + internal const string FreshSlots = Instance + "probe-fresh-slots/{freshSlot}"; internal const string Codes = Instance + "probe-codes/{code}"; [McpServerResource(UriTemplate = Items, Name = "instance_probe_item", Title = "Probe item", @@ -725,6 +837,15 @@ public string Slot([McpCompletion(McpCompletionCost.Dispatch)] string slot) return listings.Json; } + [McpServerResource(UriTemplate = FreshSlots, Name = "instance_probe_fresh_slot", Title = "Probe fresh slot", + MimeType = McpResourceUris.JsonMimeType)] + [McpSourceTool(typeof(RuntimeTools), CheatEngineToolNames.RuntimeGetInfo)] + [Description("A probe fresh slot.")] + public string FreshSlot([McpCompletion(McpCompletionCost.DispatchFresh)] string freshSlot) + { + return listings.Json; + } + [McpServerResource(UriTemplate = Codes, Name = "instance_probe_code", Title = "Probe code", MimeType = McpResourceUris.JsonMimeType)] [McpSourceTool(typeof(RuntimeTools), CheatEngineToolNames.RuntimeGetInfo)] @@ -866,12 +987,13 @@ public async ValueTask DisposeAsync() await _root.DisposeAsync(); } - internal static async Task StartAsync() + internal static async Task StartAsync(TimeProvider? time = null) { ModuleSymbolTarget target = ModuleToolTests.LoadedSample(); target.AddModule("game.exe", 0x140000000, 0x5000); ServiceCollection activation = new(); activation.AddSingleton(target.Client); + activation.AddSingleton(time ?? TimeProvider.System); activation.AddLogging(); new CheatEngineMcpBuilder(activation, CheatEngineMcpMode.Backend).AddExecutionServices() .AddJsonTypeInfoResolver(ModuleJsonContext.Default).AddToolType() diff --git a/tests/CheatEngine.Mcp.Tests/Resources/LiveResourceTests.cs b/tests/CheatEngine.Mcp.Tests/Resources/LiveResourceTests.cs index 75e41ed..6fc50f9 100644 --- a/tests/CheatEngine.Mcp.Tests/Resources/LiveResourceTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Resources/LiveResourceTests.cs @@ -14,6 +14,7 @@ using CheatEngine.Mcp.Core.Contract; using CheatEngine.Mcp.Core.Features; using CheatEngine.Mcp.Core.Files; +using CheatEngine.Mcp.Core.Inspection; using CheatEngine.Mcp.Core.Jobs; using CheatEngine.Mcp.Core.Values; using CheatEngine.Mcp.Resources; @@ -68,8 +69,6 @@ private static readonly (string Template, string Name, string Tool)[] Projection ("jobs", "instance_jobs", CheatEngineToolNames.RuntimeListJobs), ("memory/{address}{?size}", "instance_memory", CheatEngineToolNames.MemoryRead), ("modules/{module}", "instance_module", CheatEngineToolNames.ModuleGet), - ("modules/{module}/exports{?offset,limit}", "instance_module_exports", - CheatEngineToolNames.ModuleListExports), ("modules{?offset,limit}", "instance_modules", CheatEngineToolNames.ModuleList), ("patches", "instance_patches", CheatEngineToolNames.AsmListPatches), ("pointer-maps", "instance_pointer_maps", CheatEngineToolNames.PointerListMaps), @@ -103,8 +102,6 @@ private static readonly (string Template, string Name, string Tool)[] Projection { "modules", "limit", "1001" }, { "module", "module", "" }, { "module", "module", " " }, - { "exports", "module", "" }, - { "exports", "limit", "1001" }, { "regions", "offset", "1.5" }, { "regions", "limit", "2001" }, { "memory", "address", "" }, @@ -152,16 +149,30 @@ public void Catalog_EveryLiveResource_NamesItsSourceToolWithAssistantAnnotations } [Fact] - public void BackendCatalog_EverySourceTool_IsServedReadOnlyClosedWorldUngatedAndShort() + public void BackendCatalog_EverySourceTool_IsServedReadOnlyClosedWorldUngatedAndHasAnHonestReadPath() { McpPrimitiveCatalog catalog = McpPrimitiveCatalog.Create(TestComposition.BackendManifest); - foreach ((string _, string _, string name) in Projections) + foreach ((string _, string resourceName, string name) in Projections) { Tool tool = Assert.Single(catalog.Tools, candidate => candidate.Name == name); Assert.True(tool.Annotations!.ReadOnlyHint, name); Assert.False(tool.Annotations.OpenWorldHint, name); - Assert.Equal(McpDispatchClass.Short, tool.Meta![McpDispatchClass.MetaKey]!.GetValue()); + string? prepared = name switch + { + CheatEngineToolNames.ModuleList => nameof(ModuleTools.ListPrepared), + CheatEngineToolNames.ModuleGet => nameof(ModuleTools.GetPrepared), + CheatEngineToolNames.MemoryListRegions => nameof(MemoryInfoTools.ListPreparedRegions), + CheatEngineToolNames.ProcessListThreads => nameof(ProcessTools.ListPreparedThreads), + CheatEngineToolNames.SymbolListRegistered => nameof(SymbolRegistrationTools.ListPreparedRegistered), + CheatEngineToolNames.DebuggerListBreakpoints => nameof(DebuggerTools.ListPreparedBreakpoints), + _ => null + }; + Assert.Equal(prepared is null ? McpDispatchClass.Short : McpDispatchClass.BlockingNative, + tool.Meta![McpDispatchClass.MetaKey]!.GetValue()); + McpCatalogResource resource = Assert.Single(catalog.InstanceResources, + candidate => candidate.Template.Name == resourceName); + Assert.Equal(prepared, resource.Template.Meta?[McpSourceToolAttribute.PreparedProjectionMetaKey]?.GetValue()); Assert.False(tool.Meta.ContainsKey(McpFeatureGate.RequiresMetaKey), name); } } @@ -224,14 +235,17 @@ public void ProcessAndThreads_ProjectTheSelectedProcess() threads.LuaResults[typeof(ProcessThreadListResult)] = new ProcessThreadListResult([11, 12], false); ProcessTools current = new(target.Dispatch, new TargetResources(), new TargetTransitionGuards([]), Files()); ProcessTools listing = new(threads.Dispatch, new TargetResources(), new TargetTransitionGuards([]), Files()); + ProcessThreadListResult prepared = listing.ListThreads(Token); + int threadEnumerations = threads.LuaSources.Count; ReadResourceResult process = new ProcessLiveResources(current).Process(Token); ReadResourceResult list = new ProcessLiveResources(listing).Threads(Token); AssertProjects(process, Instance + "process", current.GetCurrent(Token), ProcessJsonContext.Default.ProcessCurrentResult); - AssertProjects(list, Instance + "threads", listing.ListThreads(Token), + AssertProjects(list, Instance + "threads", prepared, ProcessJsonContext.Default.ProcessThreadListResult); + Assert.Equal(threadEnumerations, threads.LuaSources.Count); Assert.Equal("""{"threads":[11,12],"truncated":false}""", Text(list)); } @@ -242,55 +256,62 @@ public void Modules_BareAndPagedReads_ProjectTheModuleList() target.AddModule("game.exe", 0x140000000, 0x5000); target.AddModule("mono.dll", 0x180000000, 0x2000); target.AddModule("user32.dll", 0x7FF800000000, 0x1000); - ModuleTools tools = new(target.Dispatch); - ModuleLiveResources live = new(tools, new ModuleExportTools(target.Dispatch), target.Dispatch); + PreparedInspectionStore prepared = new(TimeProvider.System); + ModuleTools tools = new(target.Dispatch, prepared); + ModuleLiveResources live = new(tools, target.Dispatch, prepared); + ModuleList expectedBare = tools.List(cancellationToken: Token); + ModuleList expectedPage = tools.List(offset: 1, limit: 1, cancellationToken: Token); + ModuleList expectedLimited = tools.List(limit: 2, cancellationToken: Token); + int enumerations = target.Calls("TryGetModules"); ReadResourceResult bare = live.Modules(cancellationToken: Token); ReadResourceResult page = live.Modules("1", "1", Token); ReadResourceResult limited = live.Modules(limit: "2", cancellationToken: Token); - AssertProjects(bare, Instance + "modules", tools.List(cancellationToken: Token), + Assert.Equal(enumerations, target.Calls("TryGetModules")); + AssertProjects(bare, Instance + "modules", expectedBare, ModuleJsonContext.Default.ModuleList); - AssertProjects(page, Instance + "modules?offset=1&limit=1", tools.List(offset: 1, limit: 1, - cancellationToken: Token), ModuleJsonContext.Default.ModuleList); - AssertProjects(limited, Instance + "modules?limit=2", tools.List(limit: 2, cancellationToken: Token), + AssertProjects(page, Instance + "modules?offset=1&limit=1", expectedPage, ModuleJsonContext.Default.ModuleList); + AssertProjects(limited, Instance + "modules?limit=2", expectedLimited, ModuleJsonContext.Default.ModuleList); Assert.Contains("\"name\":\"mono.dll\"", Text(page), StringComparison.Ordinal); Assert.DoesNotContain("game.exe", Text(page), StringComparison.Ordinal); } [Fact] - public void ModuleAndExports_ProjectOneModuleByNameOrExpression() + public void Module_ProjectsOneModuleByNameOrExpression() { ModuleSymbolTarget target = ModuleToolTests.LoadedSample(); target.Addresses["sample.dll+4010"] = SampleModule.LoadedBase + 0x4010; - ModuleTools modules = new(target.Dispatch); - ModuleExportTools exports = new(target.Dispatch); - ModuleLiveResources live = new(modules, exports, target.Dispatch); + PreparedInspectionStore prepared = new(TimeProvider.System); + ModuleTools modules = new(target.Dispatch, prepared); + ModuleLiveResources live = new(modules, target.Dispatch, prepared); + ModuleDetails expected = modules.Get("sample.dll+4010", Token); + int enumerations = target.Calls("TryGetModules"); + int sections = target.Calls("GetModuleSections"); ReadResourceResult module = live.Module("sample.dll+4010", Token); - ReadResourceResult all = live.Exports("SAMPLE.dll", cancellationToken: Token); - ReadResourceResult page = live.Exports("sample.dll", "1", "2", Token); - AssertProjects(module, Instance + "modules/sample.dll%2B4010", modules.Get("sample.dll+4010", Token), + Assert.Equal(enumerations, target.Calls("TryGetModules")); + Assert.Equal(sections, target.Calls("GetModuleSections")); + AssertProjects(module, Instance + "modules/sample.dll%2B4010", expected, ModuleJsonContext.Default.ModuleDetails); - AssertProjects(all, Instance + "modules/SAMPLE.dll/exports", - exports.ListExports("SAMPLE.dll", cancellationToken: Token), ModuleJsonContext.Default.ExportList); - AssertProjects(page, Instance + "modules/sample.dll/exports?offset=1&limit=2", - exports.ListExports("sample.dll", null, 1, 2, Token), ModuleJsonContext.Default.ExportList); - Assert.Equal(2, JsonNode.Parse(Text(page))!["exports"]!.AsArray().Count); } [Fact] - public void Module_Missing_IsNotFound() + public void Module_Unprepared_FailsWithoutEnumeration() { - ToolDispatch dispatch = ModuleToolTests.LoadedSample().Dispatch; - ModuleLiveResources live = new(new ModuleTools(dispatch), new ModuleExportTools(dispatch), dispatch); + ModuleSymbolTarget target = ModuleToolTests.LoadedSample(); + ToolDispatch dispatch = target.Dispatch; + PreparedInspectionStore prepared = new(TimeProvider.System); + ModuleLiveResources live = new(new ModuleTools(dispatch, prepared), dispatch, prepared); CheatEngineToolException exception = Assert.Throws(() => live.Module("missing.dll", Token)); - Assert.Equal(ToolErrorKind.NotFound, exception.Error.Kind); + Assert.Equal(ToolErrorKind.InvalidState, exception.Error.Kind); + Assert.Equal(0, target.Calls("TryGetModules")); + Assert.Equal(0, target.Calls("GetModuleSections")); } [Fact] @@ -306,16 +327,20 @@ public void Regions_BareReadIsASmallerPageThanTheTool_AndQueryPages() _ => throw new InvalidOperationException($"Unexpected inspection call {method.Name}.") } }; - MemoryInfoTools tools = new(target.Dispatch); + MemoryInfoTools tools = new(target.Dispatch, new PreparedInspectionStore(TimeProvider.System)); MemoryLiveResources live = new(tools, new MemoryReadTools(target.Dispatch)); + RegionList expectedBare = tools.ListRegions(limit: 100, cancellationToken: Token); + RegionList expectedPage = tools.ListRegions(offset: 148, limit: 5, cancellationToken: Token); + int enumerations = target.CallsTo("Inspection").Length; ReadResourceResult bare = live.Regions(cancellationToken: Token); ReadResourceResult page = live.Regions("148", "5", Token); - AssertProjects(bare, Instance + "regions", tools.ListRegions(limit: 100, cancellationToken: Token), + Assert.Equal(enumerations, target.CallsTo("Inspection").Length); + AssertProjects(bare, Instance + "regions", expectedBare, MemoryJsonContext.Default.RegionList); AssertProjects(page, Instance + "regions?offset=148&limit=5", - tools.ListRegions(offset: 148, limit: 5, cancellationToken: Token), MemoryJsonContext.Default.RegionList); + expectedPage, MemoryJsonContext.Default.RegionList); JsonNode first = JsonNode.Parse(Text(bare))!; Assert.Equal((150, 100, 100), (first["total"]!.GetValue(), first["nextOffset"]!.GetValue(), first["regions"]!.AsArray().Count)); @@ -336,7 +361,7 @@ public void Memory_ReadsBytesWith256ByDefaultOrTheRequestedSize() return ImmutableArray.CreateRange(Enumerable.Range(0, length).Select(static index => (byte) index)); }; MemoryReadTools reads = new(target.Dispatch); - MemoryLiveResources live = new(new MemoryInfoTools(target.Dispatch), reads); + MemoryLiveResources live = new(new MemoryInfoTools(target.Dispatch, new PreparedInspectionStore(TimeProvider.System)), reads); ReadResourceResult bare = live.Memory("game.exe+10", cancellationToken: Token); ReadResourceResult sized = live.Memory("401000", "4", Token); @@ -465,14 +490,18 @@ public void Symbols_BareAndPagedReads_ProjectTheRegisteredSymbols() ], 3, false); SymbolRegistrationTools symbols = new(target.Dispatch, new TargetResources(), new SymbolRegistrations()); SymbolLiveResources live = new(symbols); + RegisteredSymbolList prepared = symbols.ListRegistered(cancellationToken: Token); + RegisteredSymbolList preparedPage = symbols.ListRegistered(null, 2, 1, Token); + int enumerations = target.LuaSources.Count; ReadResourceResult bare = live.Symbols(cancellationToken: Token); ReadResourceResult page = live.Symbols("2", "1", Token); - AssertProjects(bare, Instance + "symbols", symbols.ListRegistered(cancellationToken: Token), + AssertProjects(bare, Instance + "symbols", prepared, SymbolJsonContext.Default.RegisteredSymbolList); - AssertProjects(page, Instance + "symbols?offset=2&limit=1", symbols.ListRegistered(null, 2, 1, Token), + AssertProjects(page, Instance + "symbols?offset=2&limit=1", preparedPage, SymbolJsonContext.Default.RegisteredSymbolList); + Assert.Equal(enumerations, target.LuaSources.Count); Assert.Contains("\"name\":\"gamma\"", Text(page), StringComparison.Ordinal); } @@ -518,19 +547,21 @@ public void DebuggerAndBreakpoints_ProjectTheDebuggerState() using JobRegistry jobs = Jobs(target.Dispatch, resources); DebuggerTools debugger = new(target.Dispatch, jobs, resources); DebuggerLiveResources live = new(debugger); + DebuggerBreakpointPage prepared = debugger.ListBreakpoints(256, Token); + DebuggerBreakpointPage preparedLimited = debugger.ListBreakpoints(8, Token); ReadResourceResult status = live.Debugger(Token); + int enumerations = target.LuaSources.Count; ReadResourceResult bare = live.Breakpoints(cancellationToken: Token); ReadResourceResult limited = live.Breakpoints("8", Token); + Assert.Equal(enumerations, target.LuaSources.Count); AssertProjects(status, Instance + "debugger", debugger.GetStatus(Token), DebuggerJsonContext.Default.DebuggerStatus); - AssertProjects(bare, Instance + "debugger/breakpoints", debugger.ListBreakpoints(256, Token), + AssertProjects(bare, Instance + "debugger/breakpoints", prepared, DebuggerJsonContext.Default.DebuggerBreakpointPage); - AssertProjects(limited, Instance + "debugger/breakpoints?limit=8", debugger.ListBreakpoints(8, Token), + AssertProjects(limited, Instance + "debugger/breakpoints?limit=8", preparedLimited, DebuggerJsonContext.Default.DebuggerBreakpointPage); - Assert.Contains("[2] = 256", target.LuaSources[1], StringComparison.Ordinal); - Assert.Contains("[2] = 8", target.LuaSources[2], StringComparison.Ordinal); } [Fact] @@ -552,9 +583,11 @@ public async Task ReadResource_BareListOverTheWire_EqualsTheToolsStructuredConte { await using ModuleActivation activation = await ModuleActivation.StartAsync(); + CallToolResult call = await activation.Pipeline.CallAsync(CheatEngineToolNames.ModuleList); + Assert.NotEqual(true, call.IsError); ReadResourceResult read = await activation.Pipeline.Client.ReadResourceAsync(Instance + "modules", cancellationToken: Token); - CallToolResult call = await activation.Pipeline.CallAsync(CheatEngineToolNames.ModuleList); + Assert.Equal(1, activation.Target.Calls("TryGetModules")); IList concrete = await activation.Pipeline.Client.ListResourcesAsync(cancellationToken: Token); IList templates = @@ -566,30 +599,26 @@ public async Task ReadResource_BareListOverTheWire_EqualsTheToolsStructuredConte Assert.Empty(concrete); Assert.Equal( [ - Instance + "modules/{module}", Instance + "modules/{module}/exports{?offset,limit}", - Instance + "modules{?offset,limit}" + Instance + "modules/{module}", Instance + "modules{?offset,limit}" ], templates.Select(static template => template.UriTemplate).Order(StringComparer.Ordinal)); + Assert.DoesNotContain(templates, static template => + template.UriTemplate == Instance + "modules/{module}/exports{?offset,limit}"); } [Fact] - public async Task ReadResource_PercentEncodedPathAndQuery_ReachTheToolDecoded() + public async Task ReadResource_PercentEncodedModulePath_ReachesTheToolDecoded() { await using ModuleActivation activation = await ModuleActivation.StartAsync(); activation.Target.Addresses["sample.dll+4010"] = SampleModule.LoadedBase + 0x4010; + CallToolResult prepared = await activation.Pipeline.CallAsync(CheatEngineToolNames.ModuleGet, + """{"module":"sample.dll+4010"}"""); + Assert.NotEqual(true, prepared.IsError); ReadResourceResult module = await activation.Pipeline.Client.ReadResourceAsync( Instance + "modules/sample.dll%2B4010", cancellationToken: Token); - ReadResourceResult exports = await activation.Pipeline.Client.ReadResourceAsync( - Instance + "modules/sample.dll/exports?offset=3&limit=1", cancellationToken: Token); - TextResourceContents details = Assert.IsType(Assert.Single(module.Contents)); Assert.Equal(Instance + "modules/sample.dll%2B4010", details.Uri); Assert.Equal("sample.dll", JsonNode.Parse(details.Text)!["name"]!.GetValue()); - TextResourceContents page = Assert.IsType(Assert.Single(exports.Contents)); - Assert.Equal(Instance + "modules/sample.dll/exports?offset=3&limit=1", page.Uri); - Assert.Equal("Forwarded", JsonNode.Parse(page.Text)!["exports"]![0]!["name"]!.GetValue()); - Assert.Equal(TimeSpan.Zero, exports.TimeToLive); - Assert.Equal(CacheScope.Private, exports.CacheScope); } [Theory] @@ -613,9 +642,9 @@ public async Task ReadResource_InvalidVariable_IsInvalidParamsWithTheContractDat } [Theory] - [InlineData("2025-06-18", McpErrorCode.ResourceNotFound)] - [InlineData("2026-07-28", McpErrorCode.InvalidParams)] - public async Task ReadResource_MissingModule_IsNotFoundForTheNegotiatedVersion(string version, McpErrorCode code) + [InlineData("2025-06-18")] + [InlineData("2026-07-28")] + public async Task ReadResource_UnpreparedModule_IsInvalidStateForTheNegotiatedVersion(string version) { await using ModuleActivation activation = await ModuleActivation.StartAsync(version); @@ -623,9 +652,10 @@ public async Task ReadResource_MissingModule_IsNotFoundForTheNegotiatedVersion(s await activation.Pipeline.Client.ReadResourceAsync(Instance + "modules/missing.dll", cancellationToken: Token)); - Assert.Equal(code, exception.ErrorCode); - Assert.Equal("not_found", exception.Data["kind"]); - Assert.Contains(CheatEngineToolNames.ModuleList, Assert.IsType(exception.Data["hint"]), + Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode); + Assert.Equal("invalid_state", exception.Data["kind"]); + Assert.Equal(0, activation.Target.Calls("TryGetModules")); + Assert.Contains(CheatEngineToolNames.ModuleGet, Assert.IsType(exception.Data["hint"]), StringComparison.Ordinal); } @@ -692,8 +722,9 @@ internal LiveSet(ModuleSymbolTarget target) _jobs = Jobs(dispatch, resources); _scans = new ScanTools(dispatch, resources); PointerStore store = new(); - ModuleLiveResources modules = new(new ModuleTools(dispatch), new ModuleExportTools(dispatch), dispatch); - MemoryLiveResources memory = new(new MemoryInfoTools(dispatch), new MemoryReadTools(dispatch)); + PreparedInspectionStore prepared = new(TimeProvider.System); + ModuleLiveResources modules = new(new ModuleTools(dispatch, prepared), dispatch, prepared); + MemoryLiveResources memory = new(new MemoryInfoTools(dispatch, prepared), new MemoryReadTools(dispatch)); CodeLiveResources code = new(new CodeTools(dispatch, _jobs)); RecordLiveResources records = new(new RecordReadTools(dispatch)); StructureLiveResources structures = new(new StructureTools(dispatch)); @@ -711,9 +742,6 @@ internal LiveSet(ModuleSymbolTarget target) ? modules.Modules(value, null, token) : modules.Modules(null, value, token), ["module"] = (_, value) => modules.Module(value, token), - ["exports"] = (name, value) => name == "module" - ? modules.Exports(value, cancellationToken: token) - : modules.Exports("game.exe", limit: value, cancellationToken: token), ["regions"] = (name, value) => name == "offset" ? memory.Regions(value, null, token) : memory.Regions(null, value, token), diff --git a/tests/CheatEngine.Mcp.Tests/Support/StateTestHarness.cs b/tests/CheatEngine.Mcp.Tests/Support/StateTestHarness.cs index cbe89d2..643f0ff 100644 --- a/tests/CheatEngine.Mcp.Tests/Support/StateTestHarness.cs +++ b/tests/CheatEngine.Mcp.Tests/Support/StateTestHarness.cs @@ -114,6 +114,12 @@ internal void Answer(Func answer) _answers[typeof(T)] = source => new LuaJsonResult(answer(source), null, 0); } + /// Answers fixed Lua with either a value or a declared script error selected from its complete source. + internal void AnswerResult(Func> answer) + { + _answers[typeof(T)] = source => answer(source); + } + /// Makes every Lua operation whose result is declare an mcp_error. internal void Declare(string kind, string message, string? hostEffect = "not_started") { diff --git a/tests/CheatEngine.Mcp.Tests/Tools/CodeAsmTableV2Tests.cs b/tests/CheatEngine.Mcp.Tests/Tools/CodeAsmTableV2Tests.cs index c2ed564..da170d7 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/CodeAsmTableV2Tests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/CodeAsmTableV2Tests.cs @@ -6,12 +6,16 @@ using CheatEngine.Client.Assembly; using CheatEngine.Client.Extensions.DependencyInjection; using CheatEngine.Client.Inspection; +using CheatEngine.Client.Lua; using CheatEngine.Client.Processes; using CheatEngine.Client.Results; +using CheatEngine.Client.Tables; using CheatEngine.Mcp.Core.Contract; using CheatEngine.Mcp.Core.Features; using CheatEngine.Mcp.Core.Files; using CheatEngine.Mcp.Core.Jobs; +using CheatEngine.Mcp.Core.Lua; +using CheatEngine.Mcp.Core.Tables; using CheatEngine.Mcp.Tests.Core; using CheatEngine.Mcp.Tests.Support; using CheatEngine.Mcp.Tools.Asm; @@ -382,6 +386,29 @@ public void Apply_WhenAutoAssemblerIsDisabled_RefusesBeforeClientMutation() Assert.Equal(0, harness.ApplyCalls); } + [Theory] + [InlineData("unsafe", "[ENABLE]\nluacall(print)\n[DISABLE]\nnop")] + [InlineData("target", "[ENABLE]\nloadlibrary(example.dll)\n[DISABLE]\nnop")] + [InlineData("kernel", "[ENABLE]\nkalloc(buffer,16)\n[DISABLE]\nnop")] + public void Apply_SecondaryCapabilityDisabled_RefusesBeforeClientMutation(string disabledFeature, string script) + { + McpFeatureOptions features = disabledFeature switch + { + "unsafe" => new McpFeatureOptions { EnableUnsafeLua = false }, + "target" => new McpFeatureOptions { EnableTargetCodeExecution = false }, + "kernel" => new McpFeatureOptions { EnableKernelAccess = false }, + _ => throw new InvalidOperationException($"Unexpected feature {disabledFeature}.") + }; + AutoAssemblerHarness harness = new(features); + + CheatEngineToolException exception = Assert.Throws(() => + harness.Tools.Apply(script, cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.CapabilityDisabled, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Equal(0, harness.ApplyCalls); + } + [Fact] public void AutoAssemblerEntryPoints_DeclareTheStaticAutoAssemblerRequirement() { @@ -449,6 +476,95 @@ public void Load_LuaTableWithUnsafeLuaDisabled_RefusesBeforeTheClientCanLoadIt() Assert.Equal(0, dispatcher.Calls); } + [Theory] + [InlineData("assembler", "[ENABLE]\nnop\n[DISABLE]\nnop")] + [InlineData("target", "1")] + [InlineData("kernel", "[ENABLE]\nkalloc(buffer,16)\n[DISABLE]\nnop")] + public void Load_GatedTableContent_DisabledFeatureRefusesBeforeClientLoad(string disabledFeature, string content) + { + McpFeatureOptions features = disabledFeature switch + { + "assembler" => new McpFeatureOptions { EnableAutoAssembler = false }, + "target" => new McpFeatureOptions { EnableTargetCodeExecution = false }, + "kernel" => new McpFeatureOptions { EnableKernelAccess = false }, + _ => throw new InvalidOperationException($"Unexpected feature {disabledFeature}.") + }; + string table = CreateTable($"{content}"); + Assert.True(CheatTableInspector.Inspect(table, File.ReadAllBytes(table), true).IsInspected); + RecordingDispatcher dispatcher = new(); + TableTools tools = CreateTableTools(dispatcher, features); + + CheatEngineToolException exception = Assert.Throws(() => tools.Load(table, + cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.CapabilityDisabled, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Equal(0, dispatcher.Calls); + } + + [Theory] + [InlineData(McpFeature.UnsafeLua)] + [InlineData(McpFeature.AutoAssembler)] + [InlineData(McpFeature.TargetCodeExecution)] + public void Load_OpaqueTable_DisabledExposureGate_RefusesBeforeClientLoad(McpFeature disabledFeature) + { + string table = CreateTable("not a Cheat Engine table"); + RecordingDispatcher dispatcher = new(); + TableTools tools = CreateTableTools(dispatcher, new McpFeatureOptions + { + EnableUnsafeLua = disabledFeature is not McpFeature.UnsafeLua, + EnableAutoAssembler = disabledFeature is not McpFeature.AutoAssembler, + EnableTargetCodeExecution = disabledFeature is not McpFeature.TargetCodeExecution + }); + + CheatEngineToolException exception = Assert.Throws(() => tools.Load(table, + cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.CapabilityDisabled, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Contains(McpFeatureGate.SettingName(disabledFeature), exception.Error.Message, StringComparison.Ordinal); + Assert.Contains("cannot be inspected", exception.Error.Message, StringComparison.Ordinal); + Assert.Equal(0, dispatcher.Calls); + } + + [Fact] + public void Load_CurrentTableUsesMonoWithCodeExecutionDisabled_ProbesButRefusesBeforeClientLoad() + { + string table = CreateTable(""); + Assert.True(CheatTableInspector.Inspect(table, File.ReadAllBytes(table), true).IsInspected); + int luaCalls = 0; + int loadCalls = 0; + ILuaClient lua = ClientTestDouble.Create((method, _) => + { + Assert.Equal(nameof(ILuaClient.Execute), method.Name); + luaCalls++; + return new LuaJsonResult(new MonoAutoAttachState(true, false, true, true), null, 0); + }); + ITableClient tables = ClientTestDouble.Create((method, _) => + { + Assert.Equal(nameof(ITableClient.LoadTrustedTable), method.Name); + loadCalls++; + return null; + }); + RecordingDispatcher dispatcher = new(); + IOptions execution = Options.Create(new McpExecutionOptions()); + ICheatEngineClient client = ClientTestDouble.Client(dispatcher.Dispatcher, CancellationToken.None, + (nameof(ICheatEngineClient.Lua), lua), (nameof(ICheatEngineClient.Tables), tables)); + ToolDispatch dispatch = new(client, new McpFeatureGate(Options.Create(new McpFeatureOptions + { + EnableTargetCodeExecution = false + })), execution, new DispatchStatistics(execution), TimeProvider.System, + new RecordingLogger(), new PluginFixedLuaExecutor(client)); + TableTools tools = CreateTableTools(dispatch, Path.GetDirectoryName(table)!); + + CheatEngineToolException exception = Assert.Throws(() => tools.Load(table, + cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.CapabilityDisabled, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Equal((1, 1, 0), (dispatcher.Calls, luaCalls, loadCalls)); + } + [Fact] public void Load_MalformedTablePath_ReportsInvalidArgument() { @@ -518,6 +634,28 @@ private static ToolDispatch CreateDispatch(RecordingDispatcher dispatcher, McpFe new RecordingLogger()); } + private string CreateTable(string content) + { + string root = _scratch.CreateFolder("tables"); + string table = Path.Combine(root, "gated.ct"); + File.WriteAllText(table, content); + return table; + } + + private TableTools CreateTableTools(RecordingDispatcher dispatcher, McpFeatureOptions features) + { + ToolDispatch dispatch = CreateDispatch(dispatcher, features); + return CreateTableTools(dispatch, _scratch.CreateFolder("tables")); + } + + private TableTools CreateTableTools(ToolDispatch dispatch, string root) + { + CheatEngineClientOptions options = new(); + options.AllowedTableRoots.Add(root); + return new TableTools(dispatch, new McpFilePaths(new McpFileOptions(), _scratch.CreateFolder("registry"), + _scratch.CreateFolder("data")), Options.Create(options)); + } + private static MethodInfo Method(string name) { return typeof(AsmTools).GetMethod(name, BindingFlags.Instance | BindingFlags.Public) diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Debugger/PreparedBreakpointNavigationTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Debugger/PreparedBreakpointNavigationTests.cs new file mode 100644 index 0000000..494e161 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/Tools/Debugger/PreparedBreakpointNavigationTests.cs @@ -0,0 +1,146 @@ +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Execution; +using CheatEngine.Mcp.Core.Features; +using CheatEngine.Mcp.Core.Jobs; +using CheatEngine.Mcp.Core.Targets; +using CheatEngine.Mcp.Tests.Support; +using CheatEngine.Mcp.Tests.Tools.Modules; +using CheatEngine.Mcp.Tools.Debugger; + +using Microsoft.Extensions.Options; + +namespace CheatEngine.Mcp.Tests.Tools.Debugger; + +/// Prepared breakpoint navigation over the explicit bounded debugger list. +public sealed class PreparedBreakpointNavigationTests +{ + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public void ListPreparedBreakpoints_WithoutPreparation_IsInvalidState() + { + Harness harness = new(); + + CheatEngineToolException exception = Assert.Throws(() => + harness.Tools.ListPreparedBreakpoints(cancellationToken: Token)); + + Assert.Equal(ToolErrorKind.InvalidState, exception.Error.Kind); + Assert.Contains("debugger_list_breakpoints", exception.Error.Hint, StringComparison.Ordinal); + Assert.Empty(harness.Target.LuaSources); + } + + [Fact] + public void ExplicitList_PreparesMaxCopyAndProjectsRequestedAndDefaultLimits() + { + Harness harness = new(); + harness.Serve(Page("401000", "402000", "403000")); + + DebuggerBreakpointPage explicitPage = harness.Tools.ListBreakpoints(2, Token); + DebuggerBreakpointPage prepared = harness.Tools.ListPreparedBreakpoints(cancellationToken: Token); + + Assert.Equal((2, 3, true), (explicitPage.Breakpoints.Length, explicitPage.Total, explicitPage.Truncated)); + Assert.Equal((3, 3, false), (prepared.Breakpoints.Length, prepared.Total, prepared.Truncated)); + Assert.Contains("[2] = 1024", Assert.Single(harness.Target.LuaSources), StringComparison.Ordinal); + } + + [Fact] + public void PreparedList_UsesNoAdditionalBreakpointLuaAndIsCallerIsolated() + { + Harness harness = new(); + harness.Serve(Page("401000")); + DebuggerBreakpointPage explicitPage = harness.Tools.ListBreakpoints(cancellationToken: Token); + int luaCalls = harness.Target.LuaSources.Count; + explicitPage.Breakpoints[0] = new DebuggerBreakpoint("mutated", false); + DebuggerBreakpointPage prepared = harness.Tools.ListPreparedBreakpoints(cancellationToken: Token); + prepared.Breakpoints[0] = new DebuggerBreakpoint("mutated-again", false); + + Assert.Equal("401000", Assert.Single(harness.Tools.ListPreparedBreakpoints(cancellationToken: Token).Breakpoints).Address); + Assert.Equal(luaCalls, harness.Target.LuaSources.Count); + } + + [Fact] + public void PreparingAcrossATargetChange_FailsAndDoesNotCache() + { + Harness harness = new(); + harness.Serve(Page("401000")); + harness.Target.BeforeLua = () => harness.Target.CurrentProcessId++; + + CheatEngineToolException changed = Assert.Throws(() => + harness.Tools.ListBreakpoints(cancellationToken: Token)); + + Assert.Equal((ToolErrorKind.TargetChanged, CheatEngineToolNames.DebuggerListBreakpoints, ToolHostEffect.Completed), + (changed.Error.Kind, changed.Error.Operation, changed.Error.HostEffect)); + harness.Target.BeforeLua = null; + Assert.Equal(ToolErrorKind.InvalidState, Assert.Throws(() => + harness.Tools.ListPreparedBreakpoints(cancellationToken: Token)).Error.Kind); + } + + [Fact] + public void PreparedList_ExpiresAtTheFiveSecondBoundary() + { + ManualTimeProvider time = new(); + Harness harness = new(time); + harness.Serve(Page("401000")); + harness.Tools.ListBreakpoints(cancellationToken: Token); + time.Advance(TimeSpan.FromSeconds(5)); + + Assert.Equal(ToolErrorKind.InvalidState, Assert.Throws(() => + harness.Tools.ListPreparedBreakpoints(cancellationToken: Token)).Error.Kind); + } + + [Fact] + public void DetachedTarget_PreparesAndReadsAnEmptyBreakpointSnapshot() + { + Harness harness = new(); + harness.Target.Attached = false; + harness.Serve(Page()); + + DebuggerBreakpointPage explicitPage = harness.Tools.ListBreakpoints(cancellationToken: Token); + + Assert.Empty(explicitPage.Breakpoints); + Assert.Empty(harness.Tools.ListPreparedBreakpoints(cancellationToken: Token).Breakpoints); + Assert.Equal(3, harness.Target.Calls("TryGetCurrentProcess")); + } + + private static DebuggerBreakpointPage Page(params string[] addresses) + { + return new DebuggerBreakpointPage([.. addresses.Select(static address => new DebuggerBreakpoint(address, false))], + addresses.Length, false); + } + + private sealed class Harness + { + internal Harness(TimeProvider? time = null) + { + IOptions execution = Options.Create(new McpExecutionOptions()); + Resources = new TargetResources(); + Jobs = new JobRegistry(Target.Dispatch, Resources, execution, time ?? TimeProvider.System); + Tools = new DebuggerTools(Target.Dispatch, Jobs, Resources, time); + } + + internal ModuleSymbolTarget Target + { + get; + } = new(); + + internal TargetResources Resources + { + get; + } + + internal JobRegistry Jobs + { + get; + } + + internal DebuggerTools Tools + { + get; + } + + internal void Serve(DebuggerBreakpointPage page) + { + Target.LuaResults[typeof(DebuggerBreakpointPage)] = page; + } + } +} diff --git a/tests/CheatEngine.Mcp.Tests/Tools/DotNet/DotNetToolsTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/DotNet/DotNetToolsTests.cs index 907ba6d..fe167eb 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/DotNet/DotNetToolsTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/DotNet/DotNetToolsTests.cs @@ -139,23 +139,23 @@ public void GetMethodParameters_InvalidHandle_RefusesBeforeDispatch(string modul } [Fact] - public void GetMethodParameters_CollectorCodes_AreNamedAfterOneFixedLuaDispatch() + public void GetMethodParameters_ConstantCodesAndUnavailableEntries_DoNotReplaceDeclaredSignatureTypes() { StateTestHarness harness = new(); harness.Answer(static _ => new DotNetMethodParameters(" 140 ", "100663297", [ - new DotNetParameter(0, "speed", 0x0C), new DotNetParameter(1, "target", 0x12), - new DotNetParameter(2, "unnamed", 0) - ], "System.Void (System.Single, Game.Player, ?)")); + new DotNetParameter(0, "speed", 0x0C), new DotNetParameter(1, "target", 0x01), + new DotNetParameter(2, "", 0) + ], "System.Void (System.Single, Game.Player, System.Int32)")); DotNetMethodParameters parameters = Tools(harness).GetMethodParameters(" 140 ", " 100663297 ", Token); Assert.Equal( [ - new DotNetParameter(0, "speed", 0x0C, "Single"), new DotNetParameter(1, "target", 0x12, "Class"), - new DotNetParameter(2, "unnamed", 0) + new DotNetParameter(0, "speed", 0x0C, "Single"), new DotNetParameter(1, "target", 0x01, "Void"), + new DotNetParameter(2, "", 0) ], parameters.Parameters); - Assert.Equal("System.Void (System.Single, Game.Player, ?)", parameters.Signature); + Assert.Equal("System.Void (System.Single, Game.Player, System.Int32)", parameters.Signature); (string operation, string source) = Assert.Single(harness.LuaCalls); Assert.Equal(CheatEngineToolNames.DotNetGetMethodParameters, operation); Assert.Contains("[1] = \"140\", [2] = \"100663297\"", source, StringComparison.Ordinal); diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Kernel/KernelToolsTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Kernel/KernelToolsTests.cs index 3401c17..8af5bbf 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Kernel/KernelToolsTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Kernel/KernelToolsTests.cs @@ -4,6 +4,7 @@ using CheatEngine.Mcp.Core.Contract; using CheatEngine.Mcp.Core.Features; using CheatEngine.Mcp.Core.Jobs; +using CheatEngine.Mcp.Core.Lua; using CheatEngine.Mcp.Tests.Support; using CheatEngine.Mcp.Tools.Kernel; @@ -46,6 +47,30 @@ public void GetStatus_DisabledKernelAccess_RefusesBeforeDispatch() Assert.Equal(0, harness.Dispatcher.Calls); } + [Fact] + public void KernelStatus_Availability_OmitsUnavailableAndRetainsBooleanAnswers() + { + KernelStatus value = new(null, false, null, null, null, null); + + using JsonDocument json = JsonDocument.Parse( + JsonSerializer.Serialize(value, KernelJsonContext.Default.KernelStatus)); + + Assert.False(json.RootElement.TryGetProperty("dbkInitialized", out _)); + Assert.False(json.RootElement.GetProperty("dbvmInitialized").GetBoolean()); + } + + [Fact] + public void KernelStatus_AllUnavailable_InternalObjectMarkerIsNotPublished() + { + KernelStatus value = LuaJsonWriter.Deserialize("""{"_mcpStatus":"kernel"}"""u8, + KernelJsonContext.Default.KernelStatus); + + Assert.Equal(new KernelStatus(null, null, null, null, null, null), value); + JsonElement json = JsonSerializer.SerializeToElement(value, KernelJsonContext.Default.KernelStatus); + Assert.Equal(JsonValueKind.Object, json.ValueKind); + Assert.Empty(json.EnumerateObject()); + } + [Theory] [InlineData(0, ToolErrorKind.InvalidArgument)] [InlineData(4097, ToolErrorKind.LimitExceeded)] diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Memory/MemoryInfoToolsTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Memory/MemoryInfoToolsTests.cs index 13d544b..c325a68 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Memory/MemoryInfoToolsTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Memory/MemoryInfoToolsTests.cs @@ -1,11 +1,14 @@ using System.Collections.Immutable; using System.Reflection; +using System.Text.Json; using CheatEngine.Client.Inspection; using CheatEngine.Client.Memory; using CheatEngine.Client.Results; using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Inspection; using CheatEngine.Mcp.Core.Values; +using CheatEngine.Mcp.Tests.Support; using CheatEngine.Mcp.Tools.Memory; using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Inspection; @@ -46,11 +49,11 @@ public void GetAddressInfo_ResolvedAndUnresolved_DescribesEachInOneDispatch() return new AddressExtras([false], ["Player"]); }; - AddressInfoResult result = new MemoryInfoTools(target.Dispatch).GetAddressInfo(["player", "missing"], true, + AddressInfoResult result = Tools(target).GetAddressInfo(["player", "missing"], true, true, Token); AddressInfo player = result.Items[0]; - Assert.Equal(("401010", "game.exe+1010", "game.exe", ".text", false), (player.Address, player.Symbol, + Assert.Equal(("401010", "game.exe+1010", "game.exe", ".text", (bool?) false), (player.Address, player.Symbol, player.Module, player.Section, player.IsSystemModule)); Assert.Equal(("12345678", "Player"), (player.PointerValue, player.RttiClass)); Assert.Equal(new MemoryRegionDetail("401000", 0x1000, RegionState.Committed, @@ -61,6 +64,39 @@ public void GetAddressInfo_ResolvedAndUnresolved_DescribesEachInOneDispatch() Assert.Equal(1, target.LuaCalls); } + [Fact] + public void AddressInfo_SystemModuleAvailability_OmitsUnavailableAndRetainsBooleanAnswers() + { + AddressInfoResult value = new( + [ + new AddressInfo("1000", IsSystemModule: null), + new AddressInfo("2000", IsSystemModule: false), + new AddressInfo("3000", IsSystemModule: true) + ]); + + using JsonDocument json = JsonDocument.Parse( + JsonSerializer.Serialize(value, MemoryJsonContext.Default.AddressInfoResult)); + JsonElement[] items = json.RootElement.GetProperty("items").EnumerateArray().ToArray(); + + Assert.False(items[0].TryGetProperty("isSystemModule", out _)); + Assert.False(items[1].GetProperty("isSystemModule").GetBoolean()); + Assert.True(items[2].GetProperty("isSystemModule").GetBoolean()); + } + + [Fact] + public void GetAddressInfo_UnavailableSystemModuleStatus_RetainsNull() + { + TargetDouble target = new(); + target.Symbols["player"] = 0x401010; + target.Inspection = Inspection; + target.LuaResult = _ => new AddressExtras([null], [null]); + + AddressInfo result = Assert.Single( + Tools(target).GetAddressInfo(["player"], cancellationToken: Token).Items); + + Assert.Null(result.IsSystemModule); + } + [Fact] public void GetAddressInfo_NoAddressResolves_SkipsTheScript() { @@ -70,7 +106,7 @@ public void GetAddressInfo_NoAddressResolves_SkipsTheScript() }; AddressInfoResult result = - new MemoryInfoTools(target.Dispatch).GetAddressInfo(["missing"], cancellationToken: Token); + Tools(target).GetAddressInfo(["missing"], cancellationToken: Token); Assert.Equal(ToolErrorKind.NotFound, Assert.Single(result.Items).Error!.Kind); Assert.Equal(0, target.LuaCalls); @@ -82,7 +118,7 @@ public void GetAddressInfo_TooManyAddresses_RefusesBeforeAnyDispatch() TargetDouble target = new(); CheatEngineToolException exception = Assert.Throws(() => - new MemoryInfoTools(target.Dispatch).GetAddressInfo([.. Enumerable.Repeat("1000", 257)], + Tools(target).GetAddressInfo([.. Enumerable.Repeat("1000", 257)], cancellationToken: Token)); Assert.Equal(ToolErrorKind.LimitExceeded, exception.Error.Kind); @@ -98,11 +134,11 @@ public void ListRegions_Filters_KeepMatchingRegionsInAddressOrderAndPage() }; RegionList all = - new MemoryInfoTools(target.Dispatch).ListRegions(state: RegionStateFilter.Any, cancellationToken: Token); - RegionList code = new MemoryInfoTools(target.Dispatch).ListRegions(executable: ProtectionRequirement.Required, + Tools(target).ListRegions(state: RegionStateFilter.Any, cancellationToken: Token); + RegionList code = Tools(target).ListRegions(executable: ProtectionRequirement.Required, format: ResultFormat.Detailed, cancellationToken: Token); - RegionList page = new MemoryInfoTools(target.Dispatch).ListRegions(limit: 1, cancellationToken: Token); - RegionList module = new MemoryInfoTools(target.Dispatch).ListRegions(module: "game.exe", + RegionList page = Tools(target).ListRegions(limit: 1, cancellationToken: Token); + RegionList module = Tools(target).ListRegions(module: "game.exe", writable: ProtectionRequirement.Excluded, cancellationToken: Token); Assert.Equal(["10000", "401000", "402000", "500000"], all.Regions.Select(static region => region.Base)); @@ -120,12 +156,86 @@ public void ListRegions_LimitAbove2000_RefusesBeforeAnyDispatch() TargetDouble target = new(); CheatEngineToolException exception = Assert.Throws(() => - new MemoryInfoTools(target.Dispatch).ListRegions(limit: 2001, cancellationToken: Token)); + Tools(target).ListRegions(limit: 2001, cancellationToken: Token)); Assert.Equal(ToolErrorKind.LimitExceeded, exception.Error.Kind); Assert.Equal(0, target.Dispatcher.Calls); } + [Fact] + public void ListPreparedRegions_ProjectsTheExactDefaultExplicitResultWithoutInspectionCalls() + { + TargetDouble target = new() + { + Inspection = Inspection + }; + PreparedInspectionStore prepared = new(TimeProvider.System); + MemoryInfoTools tools = Tools(target, prepared); + + RegionList explicitResult = tools.ListRegions(cancellationToken: Token); + string[] before = target.CallsTo("Inspection"); + RegionList projected = tools.ListPreparedRegions(cancellationToken: Token); + + Assert.Equal(JsonSerializer.Serialize(explicitResult, MemoryJsonContext.Default.RegionList), + JsonSerializer.Serialize(projected, MemoryJsonContext.Default.RegionList)); + Assert.Equal(before, target.CallsTo("Inspection")); + } + + [Fact] + public void ListPreparedRegions_UsesTheCompleteMapAfterAnExplicitFilteredRead() + { + TargetDouble target = new() + { + Inspection = Inspection + }; + MemoryInfoTools tools = Tools(target); + + RegionList filtered = tools.ListRegions(state: RegionStateFilter.Any, + executable: ProtectionRequirement.Required, format: ResultFormat.Detailed, cancellationToken: Token); + RegionList projected = tools.ListPreparedRegions(cancellationToken: Token); + + Assert.Equal(["401000"], filtered.Regions.Select(static region => region.Base)); + Assert.Equal(["401000", "402000", "500000"], projected.Regions.Select(static region => region.Base)); + Assert.Equal(3, projected.Total); + } + + [Fact] + public void ListPreparedRegions_ColdStaleAndChangedTarget_RefuseWithoutEnumeration() + { + TargetDouble target = new() + { + Inspection = Inspection + }; + ManualTimeProvider time = new(); + PreparedInspectionStore prepared = new(time); + MemoryInfoTools tools = Tools(target, prepared); + + CheatEngineToolException cold = Assert.Throws(() => + tools.ListPreparedRegions(cancellationToken: Token)); + Assert.Equal(ToolErrorKind.InvalidState, cold.Error.Kind); + Assert.Empty(target.CallsTo("Inspection")); + + tools.ListRegions(cancellationToken: Token); + time.Advance(PreparedInspectionStore.Lifetime); + string[] beforeStaleRead = target.CallsTo("Inspection"); + CheatEngineToolException stale = Assert.Throws(() => + tools.ListPreparedRegions(cancellationToken: Token)); + Assert.Equal(ToolErrorKind.InvalidState, stale.Error.Kind); + Assert.Equal(beforeStaleRead, target.CallsTo("Inspection")); + + target.Epochs.Enqueue(1); + target.Epochs.Enqueue(2); + CheatEngineToolException changed = Assert.Throws(() => + tools.ListRegions(cancellationToken: Token)); + Assert.Equal((ToolErrorKind.TargetChanged, ToolHostEffect.Completed), + (changed.Error.Kind, changed.Error.HostEffect)); + } + + private static MemoryInfoTools Tools(TargetDouble target, PreparedInspectionStore? prepared = null) + { + return new MemoryInfoTools(target.Dispatch, prepared ?? new PreparedInspectionStore(TimeProvider.System)); + } + private static object? Inspection(MethodInfo method, object?[] arguments) { switch (method.Name) diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Modules/ModuleSymbolTarget.cs b/tests/CheatEngine.Mcp.Tests/Tools/Modules/ModuleSymbolTarget.cs index 20ecfd2..3e6c877 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Modules/ModuleSymbolTarget.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Modules/ModuleSymbolTarget.cs @@ -8,6 +8,7 @@ using CheatEngine.Client.Processes; using CheatEngine.Client.Results; using CheatEngine.Mcp.Core.Features; +using CheatEngine.Mcp.Core.Inspection; using CheatEngine.Mcp.Tests.Support; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Runtime; @@ -28,14 +29,15 @@ internal sealed class ModuleSymbolTarget private readonly Dictionary _calls = new(StringComparer.Ordinal); - internal ModuleSymbolTarget() + internal ModuleSymbolTarget(TimeProvider? time = null) { - IProcessClient processes = ClientTestDouble.Create((method, _) => + IProcessClient processes = ClientTestDouble.Create((method, arguments) => { Count(method); return method.Name switch { nameof(IProcessClient.GetCurrentProcess) => CurrentProcess(), + nameof(IProcessClient.TryGetCurrentProcess) => TryCurrentProcess(arguments!), _ => throw new NotSupportedException($"Unexpected process call {method.Name}.") }; }); @@ -59,6 +61,7 @@ internal ModuleSymbolTarget() LuaSources.Add((string) operation.GetType().GetProperty("Source")!.GetValue(operation)!); Type resultType = method.GetGenericArguments()[1]; Type valueType = resultType.GetGenericArguments()[0]; + BeforeLua?.Invoke(); if (LuaFailure is { } failure) { throw failure; @@ -73,6 +76,7 @@ internal ModuleSymbolTarget() (nameof(ICheatEngineClient.Processes), processes), (nameof(ICheatEngineClient.Inspection), inspection), (nameof(ICheatEngineClient.Memory), memory), (nameof(ICheatEngineClient.Lua), lua)); Dispatch = CreateDispatch(Client); + Prepared = new PreparedInspectionStore(time ?? TimeProvider.System); } internal RecordingDispatcher Dispatcher @@ -90,6 +94,11 @@ internal ToolDispatch Dispatch get; } + internal PreparedInspectionStore Prepared + { + get; + } + internal bool Attached { get; @@ -102,6 +111,12 @@ internal long SelectionEpoch set; } = 7; + internal int CurrentProcessId + { + get; + set; + } = ProcessId; + /// Runs before every memory read; a test uses it to change the target mid-operation. internal Action? BeforeRead { @@ -109,6 +124,13 @@ internal Action? BeforeRead set; } + /// Runs before every fixed Lua result is returned. + internal Action? BeforeLua + { + get; + set; + } + internal List Modules { get; @@ -222,10 +244,25 @@ private ProcessSnapshot CurrentProcess() "No process is attached.", hostEffect: CheatEngineHostEffect.NotStarted).ToException(); } - return new ProcessSnapshot(new TargetProcessId(ProcessId), "game.exe", null, TargetBackend.LocalProcess, + return new ProcessSnapshot(new TargetProcessId(CurrentProcessId), "game.exe", null, TargetBackend.LocalProcess, CheatEngineArchitecture.X64, PointerSize.Bit64, 8, null, SelectionEpoch); } + private bool TryCurrentProcess(object?[] arguments) + { + if (Attached) + { + arguments[0] = CurrentProcess(); + arguments[1] = default(CheatEngineFailure); + return true; + } + + arguments[0] = default(ProcessSnapshot); + arguments[1] = new CheatEngineFailure(CheatEngineFailureKind.TargetNotAttached, "Process.Current", + "No process is attached.", hostEffect: CheatEngineHostEffect.NotStarted); + return false; + } + private object? Inspect(MethodInfo method, object?[] arguments) { switch (method.Name) diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Modules/ModuleToolTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Modules/ModuleToolTests.cs index bea9264..8a136d2 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Modules/ModuleToolTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Modules/ModuleToolTests.cs @@ -1,5 +1,7 @@ using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Inspection; using CheatEngine.Mcp.Core.Values; +using CheatEngine.Mcp.Tests.Support; using CheatEngine.Mcp.Tools.Modules; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; @@ -27,7 +29,7 @@ public void List_AttachedProcess_PagesTheFilteredModulesInBothFormats() target.AddModule("game.exe", 0x140000000, 0x5000); target.AddModule("mono-2.0-bdwgc.dll", 0x7FFA00000000, 0x20000, "C:\\Games\\Game\\mono.dll"); target.AddModule("MonoBleedingEdge.dll", 0x7FFB00000000, 0x1000); - ModuleTools tools = new(target.Dispatch); + ModuleTools tools = new(target.Dispatch, target.Prepared); ModuleList concise = tools.List(nameContains: "MONO", limit: 1, cancellationToken: Token); ModuleList detailed = tools.List(nameContains: "mono", offset: 1, format: ResultFormat.Detailed, @@ -51,7 +53,7 @@ public void List_OtherProcess_UsesItsIdWithoutReadingTheAttachedProcess() }; target.AddModule("other.exe", 0x400000, 0x1000); - ModuleList list = new ModuleTools(target.Dispatch).List(77, cancellationToken: Token); + ModuleList list = new ModuleTools(target.Dispatch, target.Prepared).List(77, cancellationToken: Token); Assert.Equal(77, list.ProcessId); Assert.Equal(0, target.Calls("GetCurrentProcess")); @@ -66,7 +68,7 @@ public void List_MoreModulesThanTheFirstCapacity_RetriesWithTheLargerOne() target.AddModule($"m{index}.dll", 0x10000000UL + ((ulong) index * 0x10000), 0x1000); } - ModuleList list = new ModuleTools(target.Dispatch).List(limit: 1000, cancellationToken: Token); + ModuleList list = new ModuleTools(target.Dispatch, target.Prepared).List(limit: 1000, cancellationToken: Token); Assert.Equal(5000, list.Total); Assert.Equal(1000, list.NextOffset); @@ -82,7 +84,7 @@ public void List_NothingAttached_IsNotAttached() }; CheatEngineToolException exception = Assert.Throws(() => - new ModuleTools(target.Dispatch).List(cancellationToken: Token)); + new ModuleTools(target.Dispatch, target.Prepared).List(cancellationToken: Token)); Assert.Equal(ToolErrorKind.NotAttached, exception.Error.Kind); } @@ -95,7 +97,7 @@ public void List_RefusedArgument_NeverDispatches(int? processId, string? nameCon ModuleSymbolTarget target = new(); CheatEngineToolException exception = Assert.Throws(() => - new ModuleTools(target.Dispatch).List(processId, nameContains, offset, limit, cancellationToken: Token)); + new ModuleTools(target.Dispatch, target.Prepared).List(processId, nameContains, offset, limit, cancellationToken: Token)); Assert.Equal((kind, ToolHostEffect.NotStarted), (exception.Error.Kind, exception.Error.HostEffect)); Assert.Equal(0, target.Dispatcher.Calls); @@ -107,7 +109,7 @@ public void Get_Module_ReportsSectionsWithTheirFlagsThePeFieldsAndThePdb() { ModuleSymbolTarget target = LoadedSample(); - ModuleDetails details = new ModuleTools(target.Dispatch).Get("SAMPLE.dll", Token); + ModuleDetails details = new ModuleTools(target.Dispatch, target.Prepared).Get("SAMPLE.dll", Token); Assert.Equal(("sample.dll", "7FFA00000000", 0x6000L, true), (details.Name, details.Base, details.Size, details.Is64Bit)); @@ -132,7 +134,7 @@ public void Get_InnerAddress_FindsTheContainingModule() ModuleSymbolTarget target = LoadedSample(); target.Addresses["sample.dll+4010"] = SampleModule.LoadedBase + 0x4010; - Assert.Equal("sample.dll", new ModuleTools(target.Dispatch).Get("sample.dll+4010", Token).Name); + Assert.Equal("sample.dll", new ModuleTools(target.Dispatch, target.Prepared).Get("sample.dll+4010", Token).Name); } [Fact] @@ -141,7 +143,7 @@ public void Get_UnknownModule_IsNotFound() ModuleSymbolTarget target = LoadedSample(); CheatEngineToolException exception = Assert.Throws(() => - new ModuleTools(target.Dispatch).Get("missing.dll", Token)); + new ModuleTools(target.Dispatch, target.Prepared).Get("missing.dll", Token)); Assert.Equal(ToolErrorKind.NotFound, exception.Error.Kind); Assert.Contains("module_list", exception.Error.Hint, StringComparison.Ordinal); @@ -154,7 +156,7 @@ public void Get_UnreadableHeader_OmitsThePeFields() ModuleSymbolTarget target = LoadedSample(); target.UnreadablePages.Add(SampleModule.LoadedBase); - ModuleDetails details = new ModuleTools(target.Dispatch).Get("sample.dll", Token); + ModuleDetails details = new ModuleTools(target.Dispatch, target.Prepared).Get("sample.dll", Token); Assert.Null(details.Pe); Assert.Equal(2, details.Sections.Length); @@ -170,12 +172,134 @@ public void Get_RefusedModuleArgument_NeverDispatches(string module) ModuleSymbolTarget target = new(); CheatEngineToolException exception = Assert.Throws(() => - new ModuleTools(target.Dispatch).Get(module, Token)); + new ModuleTools(target.Dispatch, target.Prepared).Get(module, Token)); Assert.Equal(ToolErrorKind.InvalidArgument, exception.Error.Kind); Assert.Equal(0, target.Dispatcher.Calls); } + [Fact] + public void PreparedReads_EqualTheirExplicitPreparationAndDoNoFurtherInspection() + { + ModuleSymbolTarget target = LoadedSample(); + ModuleTools tools = new(target.Dispatch, target.Prepared); + + ModuleList listed = tools.List(cancellationToken: Token); + ModuleDetails details = tools.Get("sample.dll", Token); + int enumerations = target.Calls("TryGetModules"); + int sections = target.Calls("GetModuleSections"); + int headers = target.Calls("ReadBytesDetailed"); + + ModuleList preparedList = tools.ListPrepared(cancellationToken: Token); + Assert.Equal((listed.ProcessId, listed.Total, listed.NextOffset), + (preparedList.ProcessId, preparedList.Total, preparedList.NextOffset)); + Assert.Equal(listed.Modules, preparedList.Modules); + ModuleDetails preparedDetails = tools.GetPrepared("SAMPLE.dll", Token); + Assert.Equal((details.Name, details.Base, details.Path, details.Is64Bit, details.Size, details.Pe), + (preparedDetails.Name, preparedDetails.Base, preparedDetails.Path, preparedDetails.Is64Bit, preparedDetails.Size, + preparedDetails.Pe)); + Assert.Equal(details.Sections, preparedDetails.Sections); + Assert.Equal(enumerations, target.Calls("TryGetModules")); + Assert.Equal(sections, target.Calls("GetModuleSections")); + Assert.Equal(headers, target.Calls("ReadBytesDetailed")); + } + + [Fact] + public void PreparedReads_WrongTarget_AreInvalidState() + { + ModuleSymbolTarget target = LoadedSample(); + ModuleTools tools = new(target.Dispatch, target.Prepared); + tools.Get("sample.dll", Token); + target.CurrentProcessId++; + + CheatEngineToolException list = Assert.Throws(() => + tools.ListPrepared(cancellationToken: Token)); + CheatEngineToolException details = Assert.Throws(() => + tools.GetPrepared("sample.dll", Token)); + + Assert.Equal(ToolErrorKind.InvalidState, list.Error.Kind); + Assert.Equal(ToolErrorKind.InvalidState, details.Error.Kind); + Assert.Contains("module_list", list.Error.Hint, StringComparison.Ordinal); + Assert.Contains("module_get", details.Error.Hint, StringComparison.Ordinal); + } + + [Fact] + public void Get_TargetChangesDuringTheRead_IsTargetChangedAndDoesNotPrepare() + { + ModuleSymbolTarget target = LoadedSample(); + ModuleTools tools = new(target.Dispatch, target.Prepared); + target.BeforeRead = _ => target.CurrentProcessId++; + + CheatEngineToolException exception = Assert.Throws(() => + tools.Get("sample.dll", Token)); + + Assert.Equal((ToolErrorKind.TargetChanged, CheatEngineToolNames.ModuleGet, ToolHostEffect.Completed), + (exception.Error.Kind, exception.Error.Operation, exception.Error.HostEffect)); + target.BeforeRead = null; + Assert.Equal(ToolErrorKind.InvalidState, Assert.Throws(() => + tools.ListPrepared(cancellationToken: Token)).Error.Kind); + } + + [Fact] + public void List_OtherProcess_DoesNotPoisonTheAttachedPreparedTarget() + { + ModuleSymbolTarget target = LoadedSample(); + ModuleTools tools = new(target.Dispatch, target.Prepared); + tools.List(cancellationToken: Token); + target.Modules.Clear(); + target.AddModule("other.exe", 0x400000, 0x1000); + + tools.List(77, cancellationToken: Token); + + Assert.Equal("sample.dll", Assert.Single(tools.ListPrepared(cancellationToken: Token).Modules).Name); + } + + [Fact] + public void NewModulePublication_InvalidatesEarlierPreparedDetails() + { + ModuleSymbolTarget target = LoadedSample(); + ModuleTools tools = new(target.Dispatch, target.Prepared); + tools.Get("sample.dll", Token); + tools.List(cancellationToken: Token); + + CheatEngineToolException exception = Assert.Throws(() => + tools.GetPrepared("sample.dll", Token)); + Assert.Equal(ToolErrorKind.InvalidState, exception.Error.Kind); + } + + [Fact] + public void PreparedDetails_AreDetachedFromExplicitAndPreparedCallers() + { + ModuleSymbolTarget target = LoadedSample(); + ModuleTools tools = new(target.Dispatch, target.Prepared); + ModuleDetails explicitDetails = tools.Get("sample.dll", Token); + explicitDetails.Sections[0] = explicitDetails.Sections[0] with + { + Name = "mutated-explicit" + }; + ModuleDetails prepared = tools.GetPrepared("sample.dll", Token); + prepared.Sections[0] = prepared.Sections[0] with + { + Name = "mutated-prepared" + }; + + Assert.Equal(".data", tools.GetPrepared("sample.dll", Token).Sections[0].Name); + } + + [Fact] + public void PreparedReads_ExpireAfterFiveSeconds() + { + ManualTimeProvider time = new(); + ModuleSymbolTarget target = LoadedSample(time: time); + ModuleTools tools = new(target.Dispatch, target.Prepared); + tools.List(cancellationToken: Token); + time.Advance(PreparedInspectionStore.Lifetime); + + CheatEngineToolException exception = Assert.Throws(() => + tools.ListPrepared(cancellationToken: Token)); + Assert.Equal(ToolErrorKind.InvalidState, exception.Error.Kind); + } + [Fact] public void ListExports_Module_PagesExportsByOrdinalWithForwarders() { @@ -240,10 +364,11 @@ public void ListExports_RefusedPaging_NeverDispatches(int offset, int limit, Too } /// The sample DLL mapped at in an attached target. - internal static ModuleSymbolTarget LoadedSample(TestPe? pe = null, string path = "C:\\Games\\Game\\sample.dll") + internal static ModuleSymbolTarget LoadedSample(TestPe? pe = null, string path = "C:\\Games\\Game\\sample.dll", + TimeProvider? time = null) { pe ??= SampleModule.Build(); - ModuleSymbolTarget target = new() + ModuleSymbolTarget target = new(time) { MemoryBase = SampleModule.LoadedBase, Memory = pe.Map(SampleModule.LoadedBase) diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFileToolTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFileToolTests.cs index 265f768..43d4221 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFileToolTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFileToolTests.cs @@ -1,3 +1,5 @@ +using System.Text.Json; + using CheatEngine.Mcp.Core.Contract; using CheatEngine.Mcp.Core.Files; using CheatEngine.Mcp.Tests.Core; @@ -33,6 +35,9 @@ public void SaveAndLoadMap_UsesAllowedRootAndReportsUnknownImportCompleteness() cancellationToken: TestContext.Current.CancellationToken); Assert.Equal("CE.scandata.v1", loaded.Format); Assert.Null(loaded.Map.ProcessId); + Assert.Null(loaded.Map.JobId); + JsonElement json = JsonSerializer.SerializeToElement(loaded, PointerJsonContext.Default.PointerMapFileResult); + Assert.False(json.GetProperty("map").TryGetProperty("jobId", out _)); Assert.True(loaded.Map.Incomplete); Assert.Equal(PointerCaptureCompleteness.Unknown, loaded.Map.CaptureCompleteness); PointerMap map = reopened.GetMap("after").GetUsableMap(); @@ -59,6 +64,9 @@ public void SaveAndLoadScan_PreservesContextButInvalidatesOldMatches() TestContext.Current.CancellationToken); Assert.Equal(("first-map", "3000", 1, true), (loaded.Scan.MapName, loaded.Scan.Target, loaded.Scan.Count, loaded.Scan.Incomplete)); + Assert.Null(loaded.Scan.JobId); + JsonElement json = JsonSerializer.SerializeToElement(loaded, PointerJsonContext.Default.PointerScanFileResult); + Assert.False(json.GetProperty("scan").TryGetProperty("jobId", out _)); PointerPath restored = Assert.Single(reopened.GetScan("after").GetUsablePaths(out bool incomplete)); Assert.True(incomplete); Assert.Equal([-0x20L, 0x40], restored.Offsets); diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFixture.cs b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFixture.cs index fefa495..c2f9995 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFixture.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerFixture.cs @@ -60,7 +60,7 @@ internal sealed class PointerFixture : IAsyncDisposable private long _epoch = 1; internal PointerFixture(McpExecutionOptions? options = null, ulong? largeRegionSize = null, - PointerSize? pointerSize = null) + PointerSize? pointerSize = null, TargetResources? resources = null) { _pointerSize = pointerSize ?? PointerSize.Bit64; _pointerWidth = _pointerSize == PointerSize.Bit32 ? 4 : 8; @@ -113,6 +113,10 @@ internal PointerFixture(McpExecutionOptions? options = null, ulong? largeRegionS Path.Combine(Path.GetTempPath(), "ce-mcp-test-registry"), Path.Combine(Path.GetTempPath(), "ce-mcp-test-data"))); new CheatEngineMcpBuilder(services, CheatEngineMcpMode.Backend).AddExecutionServices().AddPointerTools(); + if (resources is not null) + { + services.AddSingleton(resources); + } services.AddOptions(); _root = services.BuildServiceProvider(new ServiceProviderOptions { @@ -231,6 +235,12 @@ internal LeaseReleaseOutcome ReleaseOutcome set; } = new(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed); + /// An optional failure from the temporary scan before it produces results. + internal Exception? ValueScanFirstFailure + { + get; set; + } + /// The last AOB scan requested. internal AobScanRequest? LastAobScan { @@ -510,6 +520,10 @@ .. Aligned(request.Protection.Writable is ScanProtectionRequirement.Required, fi switch (member.Name) { case nameof(IValueScanSession.FirstScan): + if (ValueScanFirstFailure is { } failure) + { + throw failure; + } request = (ValueScanFirstRequest) values![0]!; LastValueScan = request; return null; diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerJobToolTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerJobToolTests.cs index 15b47fb..de725d5 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerJobToolTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerJobToolTests.cs @@ -29,6 +29,8 @@ public async Task CreateMapThenFindPaths_Jobs_StoreRankedModuleRootedPaths() Assert.StartsWith($"pointermap-{fixture.Jobs.Namespace}-", started.JobId, StringComparison.Ordinal); Assert.Equal(started.JobId, map.JobId); + Assert.Equal(started.JobId, JsonSerializer.SerializeToElement(map, + PointerJsonContext.Default.PointerMapInfo).GetProperty("jobId").GetString()); Assert.Equal((PointerJobState.Ready, PointerFixture.ProcessId, 8, 3, false, 100), (map.State, map.ProcessId, map.PointerSize, map.Pointers, map.Incomplete, map.ProgressPercent)); Assert.Equal((0x11000L, 0L), (map.BytesRead, map.UnreadableBytes)); @@ -41,6 +43,8 @@ public async Task CreateMapThenFindPaths_Jobs_StoreRankedModuleRootedPaths() PointerScanInfo searched = fixture.WaitForScan("hp"); Assert.StartsWith($"pointerscan-{fixture.Jobs.Namespace}-", scan.JobId, StringComparison.Ordinal); + Assert.Equal(scan.JobId, JsonSerializer.SerializeToElement(searched, + PointerJsonContext.Default.PointerScanInfo).GetProperty("jobId").GetString()); Assert.Equal((PointerJobState.Ready, 2, false, false, "21020"), (searched.State, searched.Count, searched.Incomplete, searched.TraversalLimited, searched.Target)); PointerPathPage page = fixture.Scans.ListPaths("hp", sortBy: PointerPathSort.OffsetSum); @@ -202,6 +206,7 @@ public async Task CreateMap_StopMidCapture_KeepsWhatWasReadAndARepeatedStopIsAlr PointerMapInfo started = fixture.Maps.CreateMap("map", maxBytes: 16 * 1024 * 1024, cancellationToken: Token); gate.WaitUntilBlocked(); McpJob job = fixture.Store.GetMap("map").Job!; + Assert.NotNull(started.JobId); Task stopping = Task.Run(() => fixture.Jobs.Stop(started.JobId, Token), Token); Assert.True(SpinWait.SpinUntil(() => job.State is JobState.Stopping, Wait)); @@ -267,6 +272,7 @@ public async Task DeleteMap_RunningCapture_StopsItsJobAndForgetsTheMap() await using PointerFixture fixture = new(largeRegionSize: LargeSize); using Gate gate = new(fixture, PointerFixture.LargeBase + 0x20000); PointerMapInfo started = fixture.Maps.CreateMap("map", maxBytes: 16 * 1024 * 1024, cancellationToken: Token); + Assert.NotNull(started.JobId); gate.WaitUntilBlocked(); Task deleting = Task.Run(() => fixture.Maps.DeleteMap("map", Token), Token); @@ -290,6 +296,7 @@ public async Task DeleteScanAndMap_EndedJobs_AreNotCancelledAndFreeTheStore() fixture.WaitForMap("map"); fixture.Scans.FindPaths("hp", "map", "21020", maxOffset: 0x40, cancellationToken: Token); PointerScanInfo scan = fixture.WaitForScan("hp"); + Assert.NotNull(scan.JobId); PointerDeleteResult deletedMap = fixture.Maps.DeleteMap("map", Token); diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerReferenceRetentionTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerReferenceRetentionTests.cs new file mode 100644 index 0000000..102dff6 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/Tools/Pointer/PointerReferenceRetentionTests.cs @@ -0,0 +1,125 @@ +using CheatEngine.Client.Results; +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Tests.Support; + +namespace CheatEngine.Mcp.Tests.Tools.Pointer; + +public sealed class PointerReferenceRetentionTests +{ + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FailedCleanup_RefusesRepeatedRangeSearchesWithoutAddingResources(bool scanFailed) + { + await using PointerFixture fixture = new() + { + ReleaseOutcome = new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, + CheatEngineHostEffect.CleanupUnconfirmed), + ValueScanFirstFailure = scanFailed ? new InvalidOperationException("scan fixture failed") : null + }; + Assert.Throws(() => + fixture.References.FindReferences("20040", 0x40, cancellationToken: Token)); + string retainedId = Assert.Single(fixture.Resources.List()).Id; + int dispatches = fixture.Dispatches; + await using TestMcpPipeline pipeline = await fixture.StartPipelineAsync(); + + for (int attempt = 0; attempt < 3; attempt++) + { + ToolError refusal = TestMcpPipeline.AssertError(await pipeline.CallAsync( + CheatEngineToolNames.PointerFindReferences, """{"target":"20040","maxOffset":64}"""), + ToolErrorKind.Busy); + Assert.Equal(ToolHostEffect.NotStarted, refusal.HostEffect); + Assert.True(refusal.Retryable); + Assert.Equal(retainedId, Assert.Single(fixture.Resources.List()).Id); + } + + Assert.Equal(dispatches, fixture.Dispatches); + Assert.Single(fixture.Calls, static call => call == "ValueScans.CreateSession"); + Assert.Equal(1, fixture.ValueScanReleases); + Assert.Single(fixture.References.FindReferences("20000", cancellationToken: Token).References); + } + + [Fact] + public async Task AcknowledgedFailedCleanup_AllowsANewRangeSearch() + { + await using PointerFixture fixture = new() + { + ReleaseOutcome = new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnconfirmed, + CheatEngineHostEffect.CleanupUnconfirmed) + }; + Assert.Throws(() => + fixture.References.FindReferences("20040", 0x40, cancellationToken: Token)); + string retainedId = Assert.Single(fixture.Resources.List()).Id; + + Assert.Equal(retainedId, Assert.Single(fixture.Resources.Acknowledge([retainedId], Token)).Id); + fixture.ReleaseOutcome = new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed); + Assert.Equal(2, fixture.References.FindReferences("20040", 0x40, cancellationToken: Token).Count); + + Assert.Empty(fixture.Resources.List()); + Assert.Equal(2, fixture.ValueScanReleases); + } + + [Fact] + public async Task RetryableCleanup_HoldsReservationUntilReleaseSucceeds() + { + // The case releases managed Client leases only; no Lua ledger participates in its cleanup. + await using PointerFixture fixture = new(resources: new TargetResources()) + { + ReleaseOutcome = new LeaseReleaseOutcome(LeaseReleaseKind.CleanupUnavailable, + CheatEngineHostEffect.NotStarted) + }; + Assert.Throws(() => + fixture.References.FindReferences("20040", 0x40, cancellationToken: Token)); + string retainedId = Assert.Single(fixture.Resources.List()).Id; + Assert.False(fixture.Resources.ReleaseAll(Token).IsComplete); + CheatEngineToolException refused = Assert.Throws(() => + fixture.References.FindReferences("20040", 0x40, cancellationToken: Token)); + Assert.Equal(ToolErrorKind.Busy, refused.Error.Kind); + Assert.Equal(retainedId, Assert.Single(fixture.Resources.List()).Id); + + fixture.ReleaseOutcome = new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed); + Assert.True(fixture.Resources.ReleaseAll(Token).IsComplete); + Assert.Equal(2, fixture.References.FindReferences("20040", 0x40, cancellationToken: Token).Count); + Assert.Empty(fixture.Resources.List()); + } + + [Fact] + public async Task ReentrantRangeSearch_IsRefusedBeforeAnotherDispatch() + { + await using PointerFixture fixture = new(); + CheatEngineToolException? refused = null; + fixture.OnDispatch = () => + { + fixture.OnDispatch = null; + refused = Assert.Throws(() => + fixture.References.FindReferences("20040", 0x40, cancellationToken: Token)); + }; + + Assert.Equal(2, fixture.References.FindReferences("20040", 0x40, cancellationToken: Token).Count); + + Assert.NotNull(refused); + Assert.Equal((ToolErrorKind.Busy, ToolHostEffect.NotStarted), + (refused.Error.Kind, refused.Error.HostEffect)); + Assert.Equal(1, fixture.Dispatches); + Assert.Single(fixture.Calls, static call => call == "ValueScans.CreateSession"); + Assert.Empty(fixture.Resources.List()); + } + + [Fact] + public async Task DispatchFailure_ReleasesReservationForTheNextRangeSearch() + { + await using PointerFixture fixture = new(); + fixture.OnDispatch = () => throw new InvalidOperationException("dispatch fixture failed"); + Assert.Throws(() => + fixture.References.FindReferences("20040", 0x40, cancellationToken: Token)); + fixture.OnDispatch = null; + + Assert.Equal(2, fixture.References.FindReferences("20040", 0x40, cancellationToken: Token).Count); + Assert.Equal(2, fixture.References.FindReferences("20040", 0x40, cancellationToken: Token).Count); + + Assert.Empty(fixture.Resources.List()); + Assert.Equal(2, fixture.ValueScanReleases); + } +} diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Processes/PreparedThreadNavigationTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Processes/PreparedThreadNavigationTests.cs new file mode 100644 index 0000000..f70b8d3 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/Tools/Processes/PreparedThreadNavigationTests.cs @@ -0,0 +1,134 @@ +using CheatEngine.Client; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Core.Features; +using CheatEngine.Mcp.Core.Files; +using CheatEngine.Mcp.Tests.Support; +using CheatEngine.Mcp.Tools.Processes; +using CheatEngine.SDK.Engine.Inspection; +using CheatEngine.SDK.Engine.Runtime; + +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace CheatEngine.Mcp.Tests.Tools.Processes; + +/// Prepared target-thread navigation is bounded to one target observation and never repeats Lua collection. +public sealed class PreparedThreadNavigationTests +{ + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public void PreparedThreads_CopyTheExplicitResultWithoutAnotherLuaEnumerationOrArrayAliasing() + { + ManualTimeProvider time = new(); + Target target = new(time); + + ProcessThreadListResult explicitResult = target.Tools.ListThreads(Token); + explicitResult.Threads[0] = 99; + ProcessThreadListResult prepared = target.Tools.ListPreparedThreads(Token); + + Assert.Equal(new long[] { 0x64, 0xC8, 0x12C }, prepared.Threads); + prepared.Threads[1] = 99; + ProcessThreadListResult repeated = target.Tools.ListPreparedThreads(Token); + Assert.Equal(new long[] { 0x64, 0xC8, 0x12C }, repeated.Threads); + Assert.False(prepared.Truncated); + Assert.Equal(1, target.LuaCalls); + Assert.Equal(4, target.ProcessCalls.Count); + } + + [Fact] + public void PreparedThreads_ColdStaleAndChangedTarget_RefuseWithoutLuaEnumeration() + { + ManualTimeProvider time = new(); + Target target = new(time); + + ToolError cold = Assert.Throws(() => target.Tools.ListPreparedThreads(Token)).Error; + Assert.Equal(ToolErrorKind.InvalidState, cold.Kind); + Assert.Contains(CheatEngineToolNames.ProcessListThreads, cold.Hint, StringComparison.Ordinal); + Assert.Equal(0, target.LuaCalls); + + target.Tools.ListThreads(Token); + time.Advance(TimeSpan.FromSeconds(5)); + ToolError stale = Assert.Throws(() => target.Tools.ListPreparedThreads(Token)).Error; + Assert.Equal(ToolErrorKind.InvalidState, stale.Kind); + Assert.Contains("5 seconds", stale.Hint, StringComparison.Ordinal); + Assert.Equal(1, target.LuaCalls); + + target.Observations.Enqueue(Process(42, 2)); + ToolError changed = Assert.Throws(() => target.Tools.ListPreparedThreads(Token)).Error; + Assert.Equal(ToolErrorKind.InvalidState, changed.Kind); + Assert.Contains(CheatEngineToolNames.ProcessListThreads, changed.Hint, StringComparison.Ordinal); + Assert.Equal(1, target.LuaCalls); + } + + [Fact] + public void ListThreads_TargetChangeDuringEnumeration_DiscardsTheUnpublishedResult() + { + ManualTimeProvider time = new(); + Target target = new(time); + target.Observations.Enqueue(Process(42, 1)); + target.Observations.Enqueue(Process(77, 2)); + + ToolError changed = Assert.Throws(() => target.Tools.ListThreads(Token)).Error; + + Assert.Equal((ToolErrorKind.TargetChanged, ToolHostEffect.Completed), (changed.Kind, changed.HostEffect)); + Assert.Contains(CheatEngineToolNames.ProcessListThreads, changed.Hint, StringComparison.Ordinal); + Assert.Equal(1, target.LuaCalls); + Assert.Equal(ToolErrorKind.InvalidState, + Assert.Throws(() => target.Tools.ListPreparedThreads(Token)).Error.Kind); + } + + private static ProcessSnapshot Process(int processId, long epoch) + { + return new ProcessSnapshot(new TargetProcessId(processId), "game.exe", null, TargetBackend.LocalProcess, + CheatEngineArchitecture.X64, PointerSize.Bit64, 8, null, epoch); + } + + private sealed class Target + { + private readonly ProcessSnapshot _default = Process(42, 1); + + internal Target(ManualTimeProvider time) + { + RecordingDispatcher dispatcher = new(); + ILuaClient lua = ClientTestDouble.Create((method, _) => + { + Assert.Equal(nameof(ILuaClient.Execute), method.Name); + Assert.Equal(typeof(LuaJsonResult), method.GetGenericArguments()[1]); + LuaCalls++; + return new LuaJsonResult( + new ProcessThreadListResult([0x64, 0xC8, 0x12C], false), null, 0); + }); + IProcessClient processes = ClientTestDouble.Create((method, _) => + { + Assert.Equal(nameof(IProcessClient.GetCurrentProcess), method.Name); + ProcessCalls.Add(method.Name); + return Observations.Count > 1 ? Observations.Dequeue() : + Observations.TryPeek(out ProcessSnapshot observed) ? observed : _default; + }); + ICheatEngineClient client = ClientTestDouble.Client(dispatcher.Dispatcher, CancellationToken.None, + (nameof(ICheatEngineClient.Lua), lua), (nameof(ICheatEngineClient.Processes), processes)); + IOptions execution = Options.Create(new McpExecutionOptions()); + ToolDispatch dispatch = new(client, new McpFeatureGate(Options.Create(new McpFeatureOptions())), execution, + new DispatchStatistics(execution), time, NullLogger.Instance, new PluginFixedLuaExecutor(client)); + string root = Path.GetTempPath(); + Tools = new ProcessTools(dispatch, new TargetResources(), new TargetTransitionGuards([]), + new McpFilePaths(new McpFileOptions { AllowedRoots = [root] }, Path.Combine(root, "ce-mcp-test-registry"), + Path.Combine(root, "ce-mcp-test-data")), time); + } + + internal ProcessTools Tools + { + get; + } + internal Queue Observations { get; } = []; + internal List ProcessCalls { get; } = []; + internal int LuaCalls + { + get; private set; + } + } +} diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Processes/ProcessMonoAutoAttachTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Processes/ProcessMonoAutoAttachTests.cs index b2e29d3..0c6d4c8 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Processes/ProcessMonoAutoAttachTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Processes/ProcessMonoAutoAttachTests.cs @@ -66,6 +66,31 @@ public void Attach_MonoAutoAttachWithCodeExecutionOff_RefusesBeforeTheClientAtta Assert.Empty(target.ProcessCalls); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CreateOrOpenFile_MonoAutoAttachWithCodeExecutionOff_RefusesBeforeTheOpenScript(bool create) + { + Target target = new(new MonoAutoAttachState(true, false, false, true), false); + string input = typeof(ProcessTools).Assembly.Location; + + ToolError error = Assert.Throws(() => + { + if (create) + { + target.Tools.Create(input, cancellationToken: Token); + } + else + { + target.Tools.OpenFile(input, cancellationToken: Token); + } + }).Error; + + Assert.Equal((ToolErrorKind.CapabilityDisabled, ToolHostEffect.NotStarted), (error.Kind, error.HostEffect)); + Assert.Equal([typeof(LuaJsonResult)], target.LuaResults); + Assert.Empty(target.ProcessCalls); + } + [Fact] public void MonoAutoAttach_IsFalseByDefaultForAResultReadFromCheatEngine() { @@ -83,6 +108,7 @@ internal Target(MonoAutoAttachState state, bool codeExecution) { Assert.Equal(nameof(ILuaClient.Execute), method.Name); Type result = method.GetGenericArguments()[1]; + LuaResults.Add(result); if (result == typeof(LuaJsonResult)) { return new LuaJsonResult(state, null, 0); @@ -126,5 +152,10 @@ internal List ProcessCalls { get; } = []; + + internal List LuaResults + { + get; + } = []; } } diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Record/RecordToolsTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Record/RecordToolsTests.cs index b20f42a..89d825d 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Record/RecordToolsTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Record/RecordToolsTests.cs @@ -161,6 +161,25 @@ public void ListAndFind_DescribeTheWholeListWithNestedRecords() StringComparison.Ordinal); } + [Fact] + public void Create_BatchContainingAutoAssemblerWithoutGate_RefusesBeforeCreatingAnyRecord() + { + RecordTarget target = new(new McpFeatureOptions { EnableAutoAssembler = false }); + + CheatEngineToolException exception = Assert.Throws(() => + new RecordMutationTools(target.Dispatch).Create( + [ + new RecordCreateSpec("Health", "game.exe+20", "100"), + new RecordCreateSpec("Patch", "0", VariableType: VariableType.AutoAssembler, Script: "[ENABLE]\nnop") + ], Token)); + + Assert.Equal((ToolErrorKind.CapabilityDisabled, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Empty(target.Calls); + Assert.Empty(target.Records); + Assert.Equal(0, target.Dispatcher.Calls); + } + [Fact] public void Create_ValueRecord_CreatesThroughFixedLuaAndWritesTheValueLast() { @@ -837,6 +856,25 @@ .. Enum.GetValues().Select(static reason => Assert.DoesNotContain("failure", json, StringComparison.Ordinal); } + [Fact] + public void Delete_BatchContainingActiveAutoAssemblerWithoutGate_RefusesBeforeDeletingAnyRecord() + { + RecordTarget target = new(new McpFeatureOptions { EnableAutoAssembler = false }); + target.Records.Add(Snapshot(4, 0, "Health")); + target.Records.Add(new MemoryRecordSnapshot(new MemoryRecordId(8), 1, + new MemoryRecordContentSnapshot("Patch", "0", string.Empty, VariableType.AutoAssembler), + new MemoryRecordStateSnapshot(null, true))); + + CheatEngineToolException exception = Assert.Throws(() => + new RecordMutationTools(target.Dispatch).Delete([4, 8], Token)); + + Assert.Equal((ToolErrorKind.CapabilityDisabled, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Equal(["Tables.GetRecord", "Tables.GetRecord"], target.Calls); + Assert.Empty(target.Deleted); + Assert.Equal(2, target.Records.Count); + } + [Fact] public void Delete_UsesTypedDeleteAfterItCopiesEveryRequestedRecord() { @@ -851,6 +889,21 @@ public void Delete_UsesTypedDeleteAfterItCopiesEveryRequestedRecord() Assert.Equal(["Tables.GetRecord", "Tables.GetRecord", "Tables.Delete", "Tables.Delete"], target.Calls); } + [Fact] + public void SetScript_AutoAssemblerWithoutGate_RefusesBeforeAnyDispatch() + { + RecordTarget target = new(new McpFeatureOptions { EnableAutoAssembler = false }); + target.Records.Add(Snapshot(12, 0, "Patch", variableType: VariableType.AutoAssembler)); + + CheatEngineToolException exception = Assert.Throws(() => + new RecordMutationTools(target.Dispatch).SetScript(12, "[ENABLE]\nnop", Token)); + + Assert.Equal((ToolErrorKind.CapabilityDisabled, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Empty(target.Calls); + Assert.Equal(0, target.Dispatcher.Calls); + } + [Fact] public void SetScript_AutoAssemblerRecord_UsesFixedLuaAndReadsBack() { diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Speedhack/SpeedhackToolsTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Speedhack/SpeedhackToolsTests.cs index cc87520..d148d7d 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Speedhack/SpeedhackToolsTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Speedhack/SpeedhackToolsTests.cs @@ -1,9 +1,12 @@ using System.ComponentModel; using System.Reflection; +using System.Text.Json; using CheatEngine.Client; using CheatEngine.Mcp.Core.Contract; using CheatEngine.Mcp.Core.Features; +using CheatEngine.Mcp.Core.Lua; +using CheatEngine.Mcp.Core.Targets; using CheatEngine.Mcp.Tests.Support; using CheatEngine.Mcp.Tools.Speedhack; @@ -91,13 +94,97 @@ public void SetSpeed_One_ReturnsTheStateTheFixedBodyObservedWithoutAResource(boo Assert.Empty(harness.Resources.List()); } + [Fact] + public void SetSpeedResult_UnknownHooksInstalled_IsOmittedFromThePortableJson() + { + string json = JsonSerializer.Serialize(new SpeedhackSetResult(2, null, true, "speedhack-aaaa11-1"), + SpeedhackJsonContext.Default.SpeedhackSetResult); + + Assert.DoesNotContain("hooksInstalled", json, StringComparison.Ordinal); + Assert.Contains("firstActivation", json, StringComparison.Ordinal); + } + + [Fact] + public void SetSpeed_WithARetainedResource_RefusesThePreflightBeforeItReleasesThePriorSpeed() + { + StateTestHarness harness = new(); + harness.Answer(_ => new LuaSpeedhackState(2, true, true)); + SpeedhackTools tools = new(harness.Dispatch, harness.Resources); + + _ = tools.SetSpeed(2, Token); + harness.Declare("host_refused", "symbol lookup unavailable"); + CheatEngineToolException refused = Assert.Throws(() => tools.SetSpeed(3, Token)); + + Assert.Equal((ToolErrorKind.HostRefused, ToolHostEffect.NotStarted), + (refused.Error.Kind, refused.Error.HostEffect)); + Assert.Single(harness.Resources.List()); + Assert.Equal(2, harness.LuaCalls.Count); + } + + [Theory] + [InlineData("not_started", true, ToolHostEffect.Started)] + [InlineData("not_applied", true, ToolHostEffect.Started)] + [InlineData("not_started", false, ToolHostEffect.NotStarted)] + public void SetSpeed_AfterReleasingARetainedResource_PreservesTheStartedEffectOfALateRefusal( + string laterEffect, bool resourceFound, ToolHostEffect expectedEffect) + { + StateTestHarness harness = new(); + bool initialChange = true; + harness.AnswerResult(source => + { + if (source.Contains(SpeedhackScripts.GetState, StringComparison.Ordinal)) + { + return new LuaJsonResult(new LuaSpeedhackState(2, true), null, 0); + } + + if (initialChange) + { + initialChange = false; + return new LuaJsonResult(new LuaSpeedhackState(2, true, true), null, 0); + } + + return new LuaJsonResult(default, + new LuaScriptError("host_refused", "symbol lookup unavailable", laterEffect, null), 0); + }); + harness.Answer(_ => new LuaResourceRelease(resourceFound, true)); + SpeedhackTools tools = new(harness.Dispatch, harness.Resources); + + _ = tools.SetSpeed(2, Token); + CheatEngineToolException refused = Assert.Throws(() => tools.SetSpeed(3, Token)); + + Assert.Equal((ToolErrorKind.HostRefused, expectedEffect, false), + (refused.Error.Kind, refused.Error.HostEffect, refused.Error.Retryable)); + Assert.Empty(harness.Resources.List()); + Assert.Equal(4, harness.LuaCalls.Count); + } + + [Fact] + public void SetSpeed_FailedPriorRestore_ReportsRecoveryContextWithoutInventingObservedValues() + { + StateTestHarness harness = new(); + harness.Answer(_ => new LuaSpeedhackState(2, true, true)); + harness.Answer(_ => new LuaResourceRelease(true, false, "restore failed")); + SpeedhackTools tools = new(harness.Dispatch, harness.Resources); + SpeedhackSetResult first = tools.SetSpeed(2, Token); + + CheatEngineToolException error = Assert.Throws(() => tools.SetSpeed(3, Token)); + + Assert.Equal(ToolErrorKind.PartialEffect, error.Error.Kind); + JsonElement details = Assert.IsType(error.Error.Details); + Assert.Equal(first.ResourceId, details.GetProperty("resourceId").GetString()); + Assert.Equal("cleanup_failed", details.GetProperty("release").GetProperty("kind").GetString()); + Assert.False(details.TryGetProperty("speed", out _)); + Assert.False(details.TryGetProperty("hooksInstalled", out _)); + Assert.Single(harness.Resources.List()); + } + [Fact] public void SetNormal_ChecksTheSymbolAndBothSpeedsBeforeItCallsSpeedhackSetSpeed() { string body = SpeedhackScripts.SetNormal; int attached = body.IndexOf("getOpenedProcessID()", StringComparison.Ordinal); int normal = body.IndexOf("math.abs(current - 1) <= 0.000001", StringComparison.Ordinal); - int unhooked = body.IndexOf("if address == nil then", StringComparison.Ordinal); + int unhooked = body.IndexOf("if not hasSymbol then", StringComparison.Ordinal); int target = body.IndexOf("pcall(readFloat, address)", StringComparison.Ordinal); int broken = body.IndexOf("debug_getContext(false)", StringComparison.Ordinal); int paused = body.IndexOf("isPaused()", StringComparison.Ordinal); diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Structures/StructureToolsTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Structures/StructureToolsTests.cs index 3b1fcba..e7c0e7e 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Structures/StructureToolsTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Structures/StructureToolsTests.cs @@ -104,7 +104,7 @@ public void Get_CheatEngineElementFields_MapToContractTypesAndOffsets() StructureDefinition definition = harness.Structures.Get("Player", format: ResultFormat.Detailed, cancellationToken: Token); - Assert.Contains("[6] = true", Assert.Single(harness.LuaCalls).Arguments, StringComparison.Ordinal); + Assert.Contains("[4] = true", Assert.Single(harness.LuaCalls).Arguments, StringComparison.Ordinal); Assert.Equal(5, definition.Total); Assert.Null(definition.NextOffset); StructureElement[] elements = definition.Elements; @@ -118,6 +118,31 @@ public void Get_CheatEngineElementFields_MapToContractTypesAndOffsets() Assert.Equal((StructureElementType.Custom, "-8"), (elements[4].ValueType, elements[4].Offset)); } + [Fact] + public void Get_UsesBoundedDefinitionPageScriptAndPreservesTheReturnedPage() + { + StructureToolHarness harness = new() + { + Lua = static call => call.Runs(StructureLuaScripts.DefinitionPage) + ? """ + {"name":"Player","size":64,"elementCount":3,"internal":false, + "elements":[{"index":1,"offset":8,"name":"speed","vartype":4,"display":"dtUnsignedInteger","byteSize":4}], + "total":3,"nextOffset":2} + """ + : null + }; + + StructureDefinition definition = harness.Structures.Get("Player", 1, 1, cancellationToken: Token); + + StructureLuaCall call = Assert.Single(harness.LuaCalls); + Assert.True(call.Runs(StructureLuaScripts.DefinitionPage)); + Assert.Contains("[1] = \"Player\", [2] = 1, [3] = 1, [4] = false, [5] = 65536", call.Arguments, + StringComparison.Ordinal); + Assert.Equal((3, 1, 2), (definition.Total, definition.Elements.Length, definition.NextOffset)); + Assert.Equal(new StructureElement(1, "8", "speed", StructureElementType.Float, null, 4), + Assert.Single(definition.Elements)); + } + [Theory] [MemberData(nameof(InvalidSpecs))] public void Create_InvalidElement_IsRefusedWithoutDispatch(StructureElementSpec spec, ToolErrorKind kind, diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Structures/StructureValueCodecTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Structures/StructureValueCodecTests.cs index cda7635..193042b 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Structures/StructureValueCodecTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Structures/StructureValueCodecTests.cs @@ -135,7 +135,7 @@ public void Decode_FewerBytesThanTheType_IsNull() public void Scripts_NameNoGatedApiAndLoadNoCode() { FieldInfo[] fields = [.. StructureToolHarness.ScriptFields()]; - Assert.Equal(18, fields.Length); + Assert.Equal(19, fields.Length); foreach (FieldInfo field in fields) { string script = (string) field.GetRawConstantValue()!; diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Symbol/PreparedSymbolNavigationTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Symbol/PreparedSymbolNavigationTests.cs new file mode 100644 index 0000000..7c4a646 --- /dev/null +++ b/tests/CheatEngine.Mcp.Tests/Tools/Symbol/PreparedSymbolNavigationTests.cs @@ -0,0 +1,217 @@ +using CheatEngine.Client.Inspection; +using CheatEngine.Client.Results; +using CheatEngine.Mcp.Core.Contract; +using CheatEngine.Mcp.Tests.Support; +using CheatEngine.Mcp.Tests.Tools.Modules; +using CheatEngine.Mcp.Tools.Symbol; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Mcp.Tests.Tools.Symbol; + +/// Prepared navigation over the bounded global registered-symbol copy. +public sealed class PreparedSymbolNavigationTests +{ + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public void ListPreparedRegistered_WithoutPreparation_IsInvalidState() + { + Harness harness = new(); + + CheatEngineToolException exception = Assert.Throws(() => + harness.Tools.ListPreparedRegistered(cancellationToken: Token)); + + Assert.Equal(ToolErrorKind.InvalidState, exception.Error.Kind); + Assert.Contains("symbol_list_registered", exception.Error.Hint, StringComparison.Ordinal); + Assert.Empty(harness.Target.LuaSources); + } + + [Fact] + public void ListPreparedRegistered_ExpiresAtFiveSeconds() + { + ManualTimeProvider time = new(); + Harness harness = new(time); + harness.Serve(Symbols("one")); + harness.Tools.ListRegistered(cancellationToken: Token); + time.Advance(TimeSpan.FromSeconds(5)); + + CheatEngineToolException exception = Assert.Throws(() => + harness.Tools.ListPreparedRegistered(cancellationToken: Token)); + + Assert.Equal(ToolErrorKind.InvalidState, exception.Error.Kind); + } + + [Fact] + public void FilteredExplicitList_PreparesTheUnfilteredResourceProjectionWithoutLua() + { + Harness harness = new(); + harness.Serve(Symbols("alpha", "beta")); + + RegisteredSymbolList filtered = harness.Tools.ListRegistered("beta", cancellationToken: Token); + int luaCalls = harness.Target.LuaSources.Count; + RegisteredSymbolList prepared = harness.Tools.ListPreparedRegistered(cancellationToken: Token); + + Assert.Equal("beta", Assert.Single(filtered.Symbols).Name); + Assert.Collection(prepared.Symbols, symbol => Assert.Equal("alpha", symbol.Name), + symbol => Assert.Equal("beta", symbol.Name)); + Assert.Equal(luaCalls, harness.Target.LuaSources.Count); + } + + [Fact] + public void NestedLaterPublication_WinsOverAnEarlierCopyThatCompletesAfterIt() + { + Harness harness = new(); + harness.Serve(Symbols("first")); + harness.Target.BeforeLua = () => + { + harness.Target.BeforeLua = null; + harness.Serve(Symbols("second")); + harness.Tools.ListRegistered(cancellationToken: Token); + harness.Serve(Symbols("first")); + }; + + harness.Tools.ListRegistered(cancellationToken: Token); + + Assert.Equal("second", Assert.Single(harness.Tools.ListPreparedRegistered(cancellationToken: Token).Symbols).Name); + } + + [Fact] + public void ListPreparedRegistered_PagesAndPreservesTheCopyTruncationFlag() + { + Harness harness = new(); + harness.Serve(new LuaRegisteredSymbols( + [ + new LuaRegisteredSymbol("one", "1000"), new LuaRegisteredSymbol("two", "2000"), + new LuaRegisteredSymbol("three", "3000") + ], 9000, true)); + harness.Tools.ListRegistered(cancellationToken: Token); + + RegisteredSymbolList page = harness.Tools.ListPreparedRegistered(offset: 1, limit: 1, cancellationToken: Token); + + Assert.Equal((3, true, 2), (page.Total, page.Truncated, page.NextOffset)); + Assert.Equal("two", Assert.Single(page.Symbols).Name); + } + + [Fact] + public void PreparedCopy_IsDetachedFromExplicitAndPreparedCallers() + { + Harness harness = new(); + harness.Serve(Symbols("one")); + RegisteredSymbolList explicitList = harness.Tools.ListRegistered(cancellationToken: Token); + explicitList.Symbols[0] = explicitList.Symbols[0] with + { + Name = "mutated-explicit" + }; + RegisteredSymbolList prepared = harness.Tools.ListPreparedRegistered(cancellationToken: Token); + prepared.Symbols[0] = prepared.Symbols[0] with + { + Name = "mutated-prepared" + }; + + Assert.Equal("one", Assert.Single(harness.Tools.ListPreparedRegistered(cancellationToken: Token).Symbols).Name); + } + + [Fact] + public void PreparedProjection_ReevaluatesOwnershipAfterResourceRelease() + { + Harness harness = new(); + harness.Serve(Symbols("playerBase")); + harness.Tools.Register("playerBase", "game.exe+10", cancellationToken: Token); + harness.Tools.ListRegistered(cancellationToken: Token); + + harness.Target.Dispatch.Run("runtime_release_resources", token => harness.Resources.ReleaseAll(token), Token); + + RegisteredSymbolEntry symbol = Assert.Single(harness.Tools.ListPreparedRegistered(cancellationToken: Token).Symbols); + Assert.False(symbol.OwnedByMcp); + Assert.Null(symbol.ResourceId); + } + + [Fact] + public void PreparedRegistrationNavigation_DoesNotRequireAnAttachedTarget() + { + Harness harness = new(); + harness.Target.Attached = false; + harness.Serve(Symbols("global")); + + harness.Tools.ListRegistered(cancellationToken: Token); + + Assert.Equal("global", Assert.Single(harness.Tools.ListPreparedRegistered(cancellationToken: Token).Symbols).Name); + Assert.Equal(0, harness.Target.Calls("GetCurrentProcess")); + } + + private static LuaRegisteredSymbols Symbols(params string[] names) + { + return new LuaRegisteredSymbols([.. names.Select(static name => new LuaRegisteredSymbol(name, "1000"))], names.Length, + false); + } + + private sealed class Harness + { + internal Harness(TimeProvider? time = null) + { + Target.Addresses["game.exe+10"] = 0x140000010; + Target.Register = registration => new FakeLease(registration.Name, registration.Address).Lease; + Tools = new SymbolRegistrationTools(Target.Dispatch, Resources, Registrations, time); + } + + internal ModuleSymbolTarget Target + { + get; + } = new(); + + internal TargetResources Resources + { + get; + } = new(); + + internal SymbolRegistrations Registrations + { + get; + } = new(); + + internal SymbolRegistrationTools Tools + { + get; + } + + internal void Serve(LuaRegisteredSymbols symbols) + { + Target.LuaResults[typeof(LuaRegisteredSymbols)] = symbols; + } + } + + private sealed class FakeLease + { + internal FakeLease(string name, Address address) + { + Lease = ClientTestDouble.Create((method, _) => method.Name switch + { + "get_Name" => name, + "get_Address" => address, + "get_IsReleased" => Released, + "get_RequiresManualRecovery" => false, + "get_LastReleaseOutcome" => Released ? new LeaseReleaseOutcome(LeaseReleaseKind.Released, + CheatEngineHostEffect.Completed) : null, + "Release" => Release(), + _ => throw new NotSupportedException($"Unexpected lease call {method.Name}.") + }); + } + + internal ISymbolRegistrationLease Lease + { + get; + } + + private bool Released + { + get; + set; + } + + private LeaseReleaseOutcome Release() + { + Released = true; + return new LeaseReleaseOutcome(LeaseReleaseKind.Released, CheatEngineHostEffect.Completed); + } + } +}