From c284b00a5edaad7e9ea78e93d014c619c50f3314 Mon Sep 17 00:00:00 2001 From: AriusII Date: Mon, 21 Sep 2026 12:42:11 +0200 Subject: [PATCH 1/2] Add fluent high-level APIs and generated Lua modules --- .github/workflows/ci.yml | 20 - .github/workflows/main-ci.yml | 4 +- .github/workflows/pull-request-ci.yml | 9 +- .github/workflows/sonar.yml | 55 +- CheatEngine.Client.slnx | 7 + Directory.Build.targets | 3 +- Directory.Packages.props | 4 +- README.md | 4 +- docs/live-capability-gates.md | 146 +++ eng/Invoke-PackageSmoke.ps1 | 262 ++++- eng/Tests.props | 1 - .../Allocations/IAllocationClient.cs | 21 + .../Allocations/ITargetMemoryLease.cs | 35 + .../Allocations/TargetAllocationAccess.cs | 11 + .../Allocations/TargetAllocationRequest.cs | 32 + .../Assembly/AssemblyInstructionRequest.cs | 28 + .../Assembly/AssemblyInstructionSnapshot.cs | 56 + .../Assembly/AutoAssemblerScript.cs | 31 + .../Assembly/IAssemblyClient.cs | 55 + .../Assembly/IAutoAssemblerPatchLease.cs | 23 + .../Dbvm/DbvmInitializationRequest.cs | 17 + .../Dbvm/DbvmState.cs | 17 + .../Dbvm/DbvmStatusSnapshot.cs | 35 + .../Dbvm/DbvmWatchEvent.cs | 35 + .../Dbvm/DbvmWatchHandler.cs | 4 + .../Dbvm/DbvmWatchRequest.cs | 28 + .../Dbvm/IDbvmClient.cs | 41 + .../Dbvm/IDbvmWatchLease.cs | 19 + .../Debugger/BreakpointDisposition.cs | 11 + .../Debugger/BreakpointEvent.cs | 42 + .../Debugger/BreakpointHandler.cs | 4 + .../Debugger/BreakpointKind.cs | 14 + .../Debugger/BreakpointRegisterSnapshot.cs | 26 + .../Debugger/BreakpointRequest.cs | 32 + .../Debugger/IBreakpointLease.cs | 19 + .../Debugger/IDebuggerClient.cs | 26 + .../Events/EventStreamOptions.cs | 34 + .../Events/EventStreamOverflowPolicy.cs | 14 + .../Events/IEventStreamLease.cs | 28 + .../Hashing/FileHashRequest.cs | 36 + .../Hashing/HashDigest.cs | 31 + .../Hashing/IHashingClient.cs | 21 + .../Hashing/MemoryHashRequest.cs | 40 + .../Hashing/TargetHashAlgorithm.cs | 14 + .../Hotkeys/HotkeyEvent.cs | 33 + .../Hotkeys/HotkeyGesture.cs | 39 + .../Hotkeys/HotkeyHandler.cs | 4 + .../Hotkeys/HotkeyModifiers.cs | 21 + .../Hotkeys/HotkeyRegistration.cs | 26 + .../Hotkeys/IHotkeyClient.cs | 26 + .../Hotkeys/IHotkeyLease.cs | 13 + .../ICheatEngineClient.cs | 63 ++ .../Lua/CheatEngineLuaModuleAttribute.cs | 33 + .../Lua/CheatEngineLuaOperationAttribute.cs | 39 + .../Lua/IDescribedLuaModule.cs | 18 + .../Lua/ILuaClient.cs | 45 + .../Lua/ILuaModule.cs | 5 + .../Lua/ILuaResultMapper.cs | 19 + .../Lua/LuaExportDescriptor.cs | 19 + .../Lua/LuaModuleDescriptor.cs | 42 + .../Memory/IMemoryClient.cs | 16 + .../Memory/MemoryAddressValue.cs | 29 + .../Memory/MemoryBatchLimits.cs | 12 + .../Memory/MemoryPrimitiveBatchReadRequest.cs | 36 + .../MemoryPrimitiveBatchWriteRequest.cs | 34 + .../Memory/MemoryStringEncoding.cs | 11 + .../Memory/MemoryStringReadRequest.cs | 23 + .../Memory/MemoryStringWriteRequest.cs | 52 + .../Processes/IProcessClient.cs | 45 + .../Processes/ProcessEnumerationRequest.cs | 30 + .../Processes/ProcessEnumerationResult.cs | 32 + .../Processes/ProcessInfoSnapshot.cs | 43 + .../Processes/ProcessPauseSnapshot.cs | 34 + .../Processes/ProcessPauseState.cs | 14 + .../Processes/ProcessStartRequest.cs | 43 + .../PublicAPI.Unshipped.txt | 528 ++++++++++ .../CheatEngine.Client.Abstractions/README.md | 18 +- .../RemoteExecution/IRemoteExecutionClient.cs | 21 + .../RemoteExecution/RemoteCallRequest.cs | 41 + .../RemoteExecution/RemoteCallResult.cs | 26 + .../RemoteDllInjectionRequest.cs | 29 + .../Runtime/ClientCapabilityId.cs | 27 + .../Speed/ISpeedClient.cs | 21 + .../Speed/SpeedMultiplier.cs | 24 + .../Timers/ITimerClient.cs | 26 + .../Timers/ITimerLease.cs | 13 + .../Timers/TimerHandler.cs | 4 + .../Timers/TimerRequest.cs | 23 + .../Timers/TimerTick.cs | 26 + .../CheatEngineClient.cs | 63 ++ .../CheatEngineClientDomainServices.cs | 22 +- .../Dispatching/IMainThreadInvoker.cs | 12 + .../IStatefulCheatEngineDispatcher.cs | 17 + .../Dispatching/SdkMainThreadDispatcher.cs | 160 ++- .../UnavailableAllocationClient.cs | 26 + .../Assembly/UnavailableAssemblyClient.cs | 107 ++ .../Domains/Dbvm/UnavailableDbvmClient.cs | 65 ++ .../Debugger/UnavailableDebuggerClient.cs | 33 + .../Domains/Events/BoundedEventStream.cs | 478 +++++++++ .../Domains/Events/EventStreamLease.cs | 94 ++ .../Events/UnavailableCapabilityFailure.cs | 34 + .../Hashing/UnavailableHashingClient.cs | 42 + .../Hotkeys/UnavailableHotkeyClient.cs | 31 + .../Domains/IProcessHost.cs | 1 + .../Domains/LocalProcessHost.cs | 15 + .../Domains/LuaClient.cs | 297 +++++- .../Domains/MemoryClient.cs | 448 +++++--- .../Domains/ProcessClient.cs | 229 ++++ .../UnavailableRemoteExecutionClient.cs | 41 + .../Domains/RuntimeClient.cs | 25 +- .../Domains/Speed/UnavailableSpeedClient.cs | 41 + .../Domains/Timers/UnavailableTimerClient.cs | 30 + .../CheatEngineClientBuilder.cs | 22 + ...EngineClientServiceCollectionExtensions.cs | 54 +- .../LuaModuleLifecycle.cs | 38 + .../PublicAPI.Unshipped.txt | 1 + .../CheatEngineMemoryFluentExtensions.cs | 10 + .../Memory/Memory.cs | 11 + .../Memory/MemoryAddressBuilder.cs | 153 +++ .../Memory/MemoryPointerChainBuilder.cs | 60 ++ .../Memory/MemoryPrimitiveBatchBuilder.cs | 60 ++ .../PublicAPI.Unshipped.txt | 27 + libs/CheatEngine.Client.Fluent/README.md | 3 +- .../CheatEngine.Client.Hosting.csproj | 21 + libs/CheatEngine.Client.Hosting/README.md | 23 +- .../CheatEngine.Client.Hosting.props | 10 + .../CheatEngine.Client.Hosting.targets | 349 ++++++- .../AnalyzerReleases.Unshipped.md | 16 + ...tEngine.Client.SourceGenerators.Lua.csproj | 20 + .../CheatEngineLuaGenerator.cs | 983 ++++++++++++++++++ .../README.md | 14 + .../packages.lock.json | 153 +++ .../CheatEngine.Client.Templates/README.md | 5 +- .../CheatEngine.Plugin.csproj | 2 +- .../Modules/PluginClientModule.cs | 24 +- .../Modules/PluginLuaModule.cs | 37 +- .../content/CheatEngine.Plugin/Plugin.cs | 4 +- .../content/CheatEngine.Plugin/README.md | 17 +- .../Allocations/AllocationContractsTests.cs | 29 + .../Assembly/AssemblyContractsTests.cs | 64 ++ .../Dbvm/DbvmContractsTests.cs | 61 ++ .../Debugger/DebuggerContractsTests.cs | 54 + .../Events/EventStreamOptionsTests.cs | 39 + .../Hashing/HashingContractsTests.cs | 65 ++ .../Hotkeys/HotkeyContractsTests.cs | 65 ++ .../Lua/LuaContractTests.cs | 72 +- .../Memory/MemoryBoundedRequestTests.cs | 43 + .../Processes/ProcessSnapshotTests.cs | 39 + .../RemoteExecutionContractsTests.cs | 58 ++ .../Runtime/ClientCapabilitiesTests.cs | 21 + .../Speed/SpeedContractsTests.cs | 25 + .../Timers/TimerContractsTests.cs | 40 + .../packages.lock.json | 23 +- .../AotProbeLuaBindings.cs | 14 + .../AotProbeLuaModule.cs | 7 + .../AotProbeMapperInvocation.cs | 14 + .../AotProbeScalarMapper.cs | 13 + .../CheatEngine.Client.AotProbe.csproj | 4 + tests/CheatEngine.Client.AotProbe/Program.cs | 52 +- .../BenchmarkSuiteMetadata.cs | 67 ++ .../CheatEngine.Client.Benchmarks.csproj | 14 + .../DependencyInjectionBenchmarks.cs | 25 + .../FluentBuilderBenchmarks.cs | 39 + .../LuaMapperBenchmarks.cs | 42 + .../CheatEngine.Client.Benchmarks/Program.cs | 12 + .../packages.lock.json | 296 ++++++ .../Composition/CheatEngineClientTests.cs | 45 +- .../SdkMainThreadDispatcherBehaviorTests.cs | 45 + .../Domains/Events/BoundedEventStreamTests.cs | 193 ++++ .../Domains/Events/EventStreamLeaseTests.cs | 87 ++ .../Domains/InspectionClientBehaviorTests.cs | 194 +++- .../Domains/LocalProcessHostTests.cs | 12 + .../MemoryClientDispatchFailureTests.cs | 30 + .../Domains/ProcessClientTests.cs | 239 +++++ .../Domains/RuntimeClientTests.cs | 29 + .../UnavailableAdvancedClientsTests.cs | 403 +++++++ .../Lua/LuaClientTests.cs | 115 +- .../Lua/LuaModuleRegistrationTests.cs | 229 ++++ .../packages.lock.json | 23 +- ...eClientServiceCollectionExtensionsTests.cs | 228 ++++ .../packages.lock.json | 23 +- .../Memory/MemoryAddressBuilderTests.cs | 116 ++- .../Scanning/AobFluentBuilderTests.cs | 18 + .../packages.lock.json | 23 +- .../CheatEngineClientPluginTests.cs | 18 + .../ClientActivationLifecycleTests.cs | 18 + .../packages.lock.json | 23 +- ...e.Client.SourceGenerators.Lua.Tests.csproj | 13 + .../CheatEngineLuaGeneratorTests.cs | 397 +++++++ .../Infrastructure/GeneratorRun.cs | 109 ++ .../README.md | 9 + .../packages.lock.json | 323 ++++++ .../AllocationGateTests.cs | 71 ++ .../PublicClientSignatureBoundaryTests.cs | 224 ++++ .../packages.lock.json | 23 +- 195 files changed, 11618 insertions(+), 508 deletions(-) create mode 100644 docs/live-capability-gates.md create mode 100644 libs/CheatEngine.Client.Abstractions/Allocations/IAllocationClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Allocations/ITargetMemoryLease.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationAccess.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionSnapshot.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Assembly/AutoAssemblerScript.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Assembly/IAssemblyClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Assembly/IAutoAssemblerPatchLease.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Dbvm/DbvmInitializationRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Dbvm/DbvmState.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Dbvm/DbvmStatusSnapshot.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchEvent.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchHandler.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmWatchLease.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Debugger/BreakpointDisposition.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Debugger/BreakpointEvent.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Debugger/BreakpointHandler.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Debugger/BreakpointKind.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRegisterSnapshot.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Debugger/IBreakpointLease.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Debugger/IDebuggerClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Events/EventStreamOptions.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Events/EventStreamOverflowPolicy.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Events/IEventStreamLease.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hashing/FileHashRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hashing/HashDigest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hashing/IHashingClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hashing/MemoryHashRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hashing/TargetHashAlgorithm.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyEvent.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyGesture.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyHandler.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyModifiers.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyRegistration.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyLease.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaModuleAttribute.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaOperationAttribute.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/IDescribedLuaModule.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/ILuaResultMapper.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/LuaExportDescriptor.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Lua/LuaModuleDescriptor.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/MemoryAddressValue.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchLimits.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Memory/MemoryStringEncoding.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationResult.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Processes/ProcessInfoSnapshot.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseSnapshot.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseState.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Processes/ProcessStartRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/RemoteExecution/IRemoteExecutionClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallResult.cs create mode 100644 libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteDllInjectionRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Speed/ISpeedClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Speed/SpeedMultiplier.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Timers/ITimerClient.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Timers/ITimerLease.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Timers/TimerHandler.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Timers/TimerRequest.cs create mode 100644 libs/CheatEngine.Client.Abstractions/Timers/TimerTick.cs create mode 100644 libs/CheatEngine.Client.Core/Dispatching/IStatefulCheatEngineDispatcher.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/Allocations/UnavailableAllocationClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/Assembly/UnavailableAssemblyClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/Dbvm/UnavailableDbvmClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/Debugger/UnavailableDebuggerClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/Events/BoundedEventStream.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/Events/EventStreamLease.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/Events/UnavailableCapabilityFailure.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/Hashing/UnavailableHashingClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/Hotkeys/UnavailableHotkeyClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/RemoteExecution/UnavailableRemoteExecutionClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/Speed/UnavailableSpeedClient.cs create mode 100644 libs/CheatEngine.Client.Core/Domains/Timers/UnavailableTimerClient.cs create mode 100644 libs/CheatEngine.Client.Extensions.DependencyInjection/LuaModuleLifecycle.cs create mode 100644 libs/CheatEngine.Client.Fluent/Memory/MemoryPointerChainBuilder.cs create mode 100644 libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs create mode 100644 libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.props create mode 100644 source-generators/CheatEngine.Client.SourceGenerators.Lua/AnalyzerReleases.Unshipped.md create mode 100644 source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngine.Client.SourceGenerators.Lua.csproj create mode 100644 source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs create mode 100644 source-generators/CheatEngine.Client.SourceGenerators.Lua/README.md create mode 100644 source-generators/CheatEngine.Client.SourceGenerators.Lua/packages.lock.json create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Allocations/AllocationContractsTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Assembly/AssemblyContractsTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Dbvm/DbvmContractsTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Debugger/DebuggerContractsTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Events/EventStreamOptionsTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Hashing/HashingContractsTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Hotkeys/HotkeyContractsTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/RemoteExecution/RemoteExecutionContractsTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Speed/SpeedContractsTests.cs create mode 100644 tests/CheatEngine.Client.Abstractions.Tests/Timers/TimerContractsTests.cs create mode 100644 tests/CheatEngine.Client.AotProbe/AotProbeLuaBindings.cs create mode 100644 tests/CheatEngine.Client.AotProbe/AotProbeLuaModule.cs create mode 100644 tests/CheatEngine.Client.AotProbe/AotProbeMapperInvocation.cs create mode 100644 tests/CheatEngine.Client.AotProbe/AotProbeScalarMapper.cs create mode 100644 tests/CheatEngine.Client.Benchmarks/BenchmarkSuiteMetadata.cs create mode 100644 tests/CheatEngine.Client.Benchmarks/CheatEngine.Client.Benchmarks.csproj create mode 100644 tests/CheatEngine.Client.Benchmarks/DependencyInjectionBenchmarks.cs create mode 100644 tests/CheatEngine.Client.Benchmarks/FluentBuilderBenchmarks.cs create mode 100644 tests/CheatEngine.Client.Benchmarks/LuaMapperBenchmarks.cs create mode 100644 tests/CheatEngine.Client.Benchmarks/Program.cs create mode 100644 tests/CheatEngine.Client.Benchmarks/packages.lock.json create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/Events/BoundedEventStreamTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/Events/EventStreamLeaseTests.cs create mode 100644 tests/CheatEngine.Client.Core.Tests/Domains/UnavailableAdvancedClientsTests.cs create mode 100644 tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngine.Client.SourceGenerators.Lua.Tests.csproj create mode 100644 tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs create mode 100644 tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Infrastructure/GeneratorRun.cs create mode 100644 tests/CheatEngine.Client.SourceGenerators.Lua.Tests/README.md create mode 100644 tests/CheatEngine.Client.SourceGenerators.Lua.Tests/packages.lock.json create mode 100644 tests/CheatEngine.Client.Tests/AllocationGateTests.cs create mode 100644 tests/CheatEngine.Client.Tests/PublicClientSignatureBoundaryTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bbd905..ce9b073 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,6 @@ name: Client validation on: workflow_call: - inputs: - collect-coverage: - description: Emit Visual Studio XML coverage for the SonarQube Cloud analysis workflow. - type: boolean - default: false permissions: contents: read @@ -49,8 +44,6 @@ jobs: run: dotnet build CheatEngine.Client.slnx --configuration Release --no-restore --warnaserror - name: Run unit tests through Microsoft Testing Platform - env: - COLLECT_COVERAGE: ${{ inputs.collect-coverage }} run: | $projects = @(Get-ChildItem -Path tests -Filter '*.Tests.csproj' -Recurse -File | Sort-Object Name) if ($projects.Count -eq 0) { @@ -67,10 +60,6 @@ jobs: '--results-directory', $results, '--fail-skips', 'on' ) - if ($env:COLLECT_COVERAGE -eq 'true') { - $options += '--coverage', '--coverage-output-format', 'xml', '--coverage-output', 'coverage.xml' - } - dotnet test @options } @@ -106,15 +95,6 @@ jobs: if-no-files-found: warn retention-days: 14 - - name: Upload Sonar coverage - if: ${{ inputs.collect-coverage && !cancelled() }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: coverage-client - path: artifacts/test-results/*/coverage.xml - if-no-files-found: error - retention-days: 7 - - name: Pack and validate public package APIs run: dotnet pack CheatEngine.Client.slnx --configuration Release --no-build --no-restore diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index c820499..404b801 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -32,14 +32,12 @@ jobs: ci: name: CI uses: ./.github/workflows/ci.yml - with: - collect-coverage: ${{ github.ref == 'refs/heads/main' }} # Main analysis is the SonarQube Cloud baseline for pull-request new-code comparisons. sonar: name: Sonar needs: ci - if: github.ref == 'refs/heads/main' + if: vars.SONAR_CI_ENABLED == 'true' && github.ref == 'refs/heads/main' uses: ./.github/workflows/sonar.yml secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pull-request-ci.yml b/.github/workflows/pull-request-ci.yml index fb67677..aef0f7c 100644 --- a/.github/workflows/pull-request-ci.yml +++ b/.github/workflows/pull-request-ci.yml @@ -30,17 +30,14 @@ jobs: name: CI if: github.event.pull_request.draft == false uses: ./.github/workflows/ci.yml - with: - collect-coverage: >- - ${{ github.event.pull_request.head.repo.full_name == github.repository - && github.actor != 'dependabot[bot]' }} - # Fork and Dependabot pull requests receive no repository secret. Keep this condition equal to collect-coverage. + # Fork and Dependabot pull requests receive no repository secret, so Sonar skips them. sonar: name: Sonar needs: ci if: >- - github.event.pull_request.head.repo.full_name == github.repository + vars.SONAR_CI_ENABLED == 'true' + && github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' uses: ./.github/workflows/sonar.yml secrets: diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index 57650ee..1b9a4b2 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -12,12 +12,12 @@ on: type: string default: cheatenginenet wait-quality-gate: - description: Fail the analysis job when SonarQube Cloud reports a failing Quality Gate. + description: Fail the job when the quality gate fails. type: boolean default: false secrets: SONAR_TOKEN: - description: SonarQube Cloud token. Analysis is skipped when it is unavailable. + description: SonarQube Cloud token. The analysis is skipped when it is empty. required: false permissions: @@ -96,13 +96,6 @@ jobs: **/packages.lock.json Directory.Packages.props - - name: Download coverage - if: env.SONAR_ENABLED == 'true' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: coverage-client - path: ${{ runner.temp }}/coverage - - name: Install scanner if: env.SONAR_ENABLED == 'true' run: >- @@ -118,22 +111,40 @@ jobs: env: SONARQUBE_SCANNER_PARAMS: '{"sonar.token":"${{ secrets.SONAR_TOKEN }}"}' run: | - $coverage = "$env:RUNNER_TEMP/coverage" - $reports = @(Get-ChildItem -Path $coverage -Filter *.xml -Recurse -File -ErrorAction SilentlyContinue) - if ($reports.Count -eq 0) { - Write-Host '::error::No coverage report was downloaded. Call ci.yml with collect-coverage set to true.' - exit 1 - } - - Write-Host "Coverage reports: $($reports.Count)" + # These findings conflict with deliberate repository contracts. Keep them in the scanner configuration so + # production and test sources do not need Sonar-only attributes or code changes. + $ignoredIssues = @( + @{ Key = 'noLinq'; Rule = 'csharpsquid:S3267'; Resource = '**/*.cs' } + @{ Key = 'unsafeInterop'; Rule = 'csharpsquid:S6640'; Resource = '**/*.cs' } + @{ Key = 'nullableFlow'; Rule = 'csharpsquid:S8970'; Resource = '**/*.cs' } + @{ Key = 'apiOverloadLayout'; Rule = 'csharpsquid:S4136'; Resource = 'libs/**' } + @{ Key = 'generatorInstances'; Rule = 'csharpsquid:S2325'; Resource = 'source-generators/**' } + @{ Key = 'emittedFragments'; Rule = 'csharpsquid:S1192'; Resource = 'source-generators/**' } + @{ Key = 'generatorComments'; Rule = 'csharpsquid:S125'; Resource = 'source-generators/**' } + @{ Key = 'testReflection'; Rule = 'csharpsquid:S3011'; Resource = 'tests/**' } + @{ Key = 'testLiterals'; Rule = 'csharpsquid:S1192'; Resource = 'tests/**' } + @{ Key = 'testGc'; Rule = 'csharpsquid:S1215'; Resource = 'tests/**' } + @{ Key = 'testStaticHooks'; Rule = 'csharpsquid:S2696'; Resource = 'tests/**' } + @{ Key = 'testDoubleDispose'; Rule = 'csharpsquid:S3966'; Resource = 'tests/**' } + @{ Key = 'testBooleanTables'; Rule = 'csharpsquid:S1125'; Resource = 'tests/**' } + @{ Key = 'testComplexity'; Rule = 'csharpsquid:S3776'; Resource = 'tests/**' } + @{ Key = 'testParameters'; Rule = 'csharpsquid:S107'; Resource = 'tests/**' } + @{ Key = 'testMarkerClasses'; Rule = 'csharpsquid:S1118'; Resource = 'tests/**' } + @{ Key = 'testDuplicateScenarios'; Rule = 'csharpsquid:S4144'; Resource = 'tests/**' } + @{ Key = 'testFixedDoubles'; Rule = 'csharpsquid:S3400'; Resource = 'tests/**' } + @{ Key = 'testComments'; Rule = 'csharpsquid:S125'; Resource = 'tests/**' } + ) $arguments = @( - "/k:$env:SONAR_PROJECT_KEY", - "/o:$env:SONAR_ORGANIZATION", - '/d:sonar.exclusions=artifacts/**', - '/d:sonar.coverage.exclusions=tests/**', - "/d:sonar.cs.vscoveragexml.reportsPaths=$coverage/**/*.xml", + "/k:$env:SONAR_PROJECT_KEY" + "/o:$env:SONAR_ORGANIZATION" + '/d:sonar.exclusions=artifacts/**,tests/CheatEngine.Client.Benchmarks/**' + "/d:sonar.issue.ignore.multicriteria=$(($ignoredIssues.Key) -join ',')" "/d:sonar.qualitygate.wait=$env:SONAR_WAIT_QUALITY_GATE" ) + foreach ($issue in $ignoredIssues) { + $arguments += "/d:sonar.issue.ignore.multicriteria.$($issue.Key).ruleKey=$($issue.Rule)" + $arguments += "/d:sonar.issue.ignore.multicriteria.$($issue.Key).resourceKey=$($issue.Resource)" + } & "$env:RUNNER_TEMP/sonar-scanner/dotnet-sonarscanner.exe" begin @arguments - name: Build diff --git a/CheatEngine.Client.slnx b/CheatEngine.Client.slnx index 710e369..c680960 100644 --- a/CheatEngine.Client.slnx +++ b/CheatEngine.Client.slnx @@ -25,14 +25,21 @@ + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets index f7d374e..5c1ef69 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -36,8 +36,9 @@ BeforeTargets="Pack"> + <_CheatEngineClientDirectProjectReference Include="@(ProjectReference)" - Condition="'%(ProjectReference.DefiningProjectName)' == '$(MSBuildProjectName)'"/> + Condition="'%(ProjectReference.DefiningProjectName)' == '$(MSBuildProjectName)' and '%(ProjectReference.ReferenceOutputAssembly)' != 'false'"/> <_CheatEngineClientSdkReference Include="@(PackageReference)" Condition="'%(PackageReference.Identity)' == 'CheatEngine.SDK' and '%(PackageReference.DefiningProjectName)' == '$(MSBuildProjectName)'"/> <_CheatEngineClientSdkRangeReference Include="@(_CheatEngineClientSdkReference)" diff --git a/Directory.Packages.props b/Directory.Packages.props index 07c6748..ab4e128 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,12 +17,14 @@ + - + + diff --git a/README.md b/README.md index 4a6b39e..68c6cff 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,9 @@ dotnet publish tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csp The [Windows CI workflow](.github/workflows/ci.yml) runs the locked restore, Release build, Microsoft Testing Platform tests, package API validation, isolated package smoke test, template smoke test, and Native AOT graph probe. The Cheat Engine 7.7 x64 live suite is opt-in and intentionally excluded from ordinary CI; no CI result should be read as -proof that an untested live-host feature is available. +proof that an untested live-host feature is available. The required success, failure, cleanup, disable, re-enable, and +target-change evidence for every advanced capability is defined in the +[live-capability gate protocol](docs/live-capability-gates.md). ## Security and scope diff --git a/docs/live-capability-gates.md b/docs/live-capability-gates.md new file mode 100644 index 0000000..dff1bc8 --- /dev/null +++ b/docs/live-capability-gates.md @@ -0,0 +1,146 @@ +# Cheat Engine 7.7 x64 live-capability gates + +## Purpose and promotion rule + +This document is the release gate for advanced CheatEngine.Client capabilities. It is deliberately stricter than a build, unit test, package smoke test, or Native AOT probe: every listed operation crosses a Cheat Engine-owned Lua, ABI, callback, or ownership boundary whose real lifecycle must be observed in the supported host. + +Every capability below starts as Unknown or Unavailable. A public interface, a completed implementation, an SDK binding, or an ordinary-CI result is not evidence that it may return Available. The only promotion rule is: + +> A capability may return Available only after its complete domain scenario below has passed in Cheat Engine 7.7 x64, the required evidence has been captured and reviewed, and the result has been linked from the capability matrix. A partial result, another CE version or architecture, or a run without cleanup, disable, re-enable, and target replacement leaves the capability Unknown or Unavailable. + +Run gates only against a local process the operator is authorised to control and that is disposable. Use a purpose-made test executable with known memory, code, file, and timing behaviour. Never use a production process, an online game, or a target that cannot be restored. The test must use an actual 64-bit Cheat Engine 7.7 host rather than an SDK fake or simulated Lua runtime. + +The supported artifact remains a framework-dependent managed net10.0 plugin folder. Client libraries are analysed for trimming and Native AOT compatibility, and the AOT probe validates that analysis. Neither analysis nor the probe means Cheat Engine can load a Native AOT plugin DLL. Exercise the managed plugin with its .deps.json, .runtimeconfig.json, Client and SDK assemblies, and native SDK bridge deployed together. + +## Common protocol and evidence + +Capture this bundle for every domain: + +1. **Host:** CE version/build, x64, Windows version, Client/SDK package or commit versions, plugin hash, timestamp. +2. **Target:** fixture executable path/hash, PID, bitness, selection epoch, and confirmation it is local, authorised, and disposable. Do not log sensitive target-memory contents. +3. **Operation:** request metadata, copied Client result, stable CheatEngineFailure on errors, and host-side evidence (log, UI screenshot, process/module or memory-map observation) of the intended effect. +4. **Lifecycle:** enable, success, intentional failure, cleanup, disable, fresh re-enable, and target replacement; record Client epoch and capability observation at every boundary. +5. **Negative checks:** rejected work makes no host mutation; stale leases/callbacks never act in a new epoch; no callback blocks a Cheat Engine or Lua thread. + +The raw log remains access controlled; the attached review summary is redacted. It names the exact host, SDK, and Client versions. A changed SDK ABI or CE version requires a new relevant gate run; older evidence is not inherited. + +| Required phase | Assertion | +|---|---| +| Initial state | The service is resolvable, returns Unknown/Unavailable, and makes no speculative mutation. | +| Success | Only copied Client data or activation-scoped Client leases return; the host effect is independently observed. | +| Expected failure | Invalid input, unavailable primitive, and host error become stable failures; no partial resource, callback, patch, or allocation survives. | +| Cleanup | A lease disposes once in the documented order and the host returns to its recorded baseline. | +| Disable | Admission closes before SDK/Lua teardown; Client resources are neutralised and released while the SDK context is valid. | +| Re-enable | A new Client epoch has only new resources; old leases, callbacks, and subscriptions are rejected or inert. | +| Target change | Old selection-bound resources are reclaimed/invalidated before a new PID is used and cannot act on it. | + +## Allocations — Allocations + +**Preconditions.** Select the disposable local target, record PID and selection epoch, and choose a positive bounded size with a documented protection mode. The allocation must not enter a cache or pool. Start with the Allocations capability observed as Unknown/Unavailable. + +**Success.** Allocate(TargetAllocationRequest) returns ITargetMemoryLease with a non-zero address, requested size, and current selection epoch. Verify the range belongs to the selected PID and, where supported, write/read one bounded fixture value. No SDK owner or raw handle may escape in its result. + +**Failure, cleanup, and lifecycle.** Exercise zero, oversized, unsupported, and injected SDK allocation failures; each must yield CapabilityUnavailable or a classified failure without a remote range leak. Dispose a successful lease twice and prove deallocation occurs at most once. Selection change and disable release the old range before detachment; an old lease cannot touch the replacement target. Re-enable then allocates a fresh range in a new epoch. + +**Evidence.** Capture address, size, PID, old/new epochs, allocation/deallocation statuses, memory-map evidence, and no-leak proof after failure, disable, and target replacement. + +## Assembly and Auto Assembler — Assembly + +**Preconditions.** Use a known instruction sequence in the fixture. Capture original bounded byte and disassembly snapshots. A test Auto Assembler script is reversible, has a valid [DISABLE] path, contains neither targetSelf nor host pointers, and touches only the fixture target. Start Unknown/Unavailable. + +**Success.** Verify copied disassembly, instruction size, previous-instruction lookup, comments, and a one-instruction assembly result. Apply the reversible script and obtain IAutoAssemblerPatchLease; independently observe its one intended effect. Dispose it and prove the SDK executes retained disableInfo once and restores original bytes. + +**Failure, cleanup, and lifecycle.** Test malformed assembly, invalid address, and a script failing part way through. Each returns a classified error and leaves original bytes intact; a partially-created patch rolls back. Double disposal cannot execute [DISABLE] twice. Disable and target change restore a live patch before Lua detaches; an old lease cannot disable a patch from a new epoch or PID. Re-enable uses a newly created lease only. + +**Evidence.** Capture script hash, original/patched/restored byte hashes, copied instruction values, disableInfo status, PID/epochs, failures, and host disassembly evidence. + +## Remote execution and DLL injection — RemoteExecution + +**Preconditions.** Select the authorised disposable x64 target. Supply a trusted target-compatible test DLL using an existing absolute path. Calls use a strictly positive finite timeout; parameters flow through a Client-owned allocation lease, never a caller-supplied host pointer. Begin Unknown/Unavailable. + +**Success.** Demonstrate deterministic injection and a harmless exported fixture action with a bounded copied result. Verify parameter allocation belongs to the PID, the action honours its timeout, and allocation is released on both normal completion and remote error. A public result retains no remote pointer. + +**Failure, cleanup, and lifecycle.** Reject relative, missing, inaccessible, and wrong-bitness DLL paths; zero or negative timeout; bad parameters; remote exception; and timeout. None may leak an allocation, pending callback, or unrelated mutation. If CE cannot safely unload an injected DLL, state that limitation and use a new fixture process for remaining phases instead of claiming unload semantics. Disable while a call is pending, re-enable, and prove old state cannot complete into the new epoch. On target change, release old allocations/call state before new PID use. + +**Evidence.** Capture canonical DLL path/hash, PID/epochs, timeout, allocation lifecycle, exit/result status, host logs, and process-module evidence. + +## Debugger and breakpoints — Debugger + +**Preconditions.** Use a debugger-safe fixture with a known breakpoint address and controlled trigger. An IBreakpointLease callback receives a copied event and returns BreakpointDisposition synchronously; it never awaits an async consumer or blocks Lua/CE. Any event stream has positive capacity and named overflow policy. Begin Unknown/Unavailable. + +**Success.** Trigger once and verify copied address/register/context values and immediate disposition against fixture behaviour. Saturate an optional stream to prove its selected overflow policy and observable drop count without blocking the producer. + +**Failure, cleanup, and lifecycle.** Invalid/unmapped registration and a handler exception leave no armed breakpoint or uncaught host exception. Dispose, trigger again, and prove no admission. On disable: close admission, neutralise the SDK callback, complete streams, release the lease, then detach Lua. Re-enable has a new scope; a retained old closure/subscription is inert. Changing target before trigger never delivers old breakpoint work to the new PID. + +**Evidence.** Capture breakpoint IDs, event sequence, disposition, stream capacity/drop count, callback-thread proof, PID/epochs, and removal proof. + +## Hotkeys — Hotkeys + +**Preconditions.** Choose a non-conflicting reversible fixture chord in a controlled environment. A handler receives only copied events. An asynchronous projection always supplies positive-capacity EventStreamOptions and an explicit overflow policy (DropOldest default, DropNewest, or FailSubscription). Begin Unknown/Unavailable. + +**Success.** Register IHotkeyLease, trigger the chord through the host, and prove exactly one copied event reaches the active epoch. Deliberately fill a stream and record loss behaviour without blocking the Lua/registration thread. + +**Failure, cleanup, and lifecycle.** A duplicate/conflicting chord and handler failure leave no partial registration or host exception. Disposal prevents the chord reaching the plugin. Disable closes admission, neutralises callbacks, completes subscribers, releases host hotkey, then permits Lua detach. A fresh re-enable lease works while the former delegate/stream remains inert. Changing target must not retain old selection-bound state or direct work to it. + +**Evidence.** Capture chord, registration/removal statuses, event/loss counts, epochs, and host UI/log proof. + +## Timers — Timers + +**Preconditions.** Use a positive bounded interval and callbacks that record copied timestamps only. Every async consumer uses positive-capacity EventStreamOptions; a timer callback never waits for a consumer or calls a blocking dispatcher. Begin Unknown/Unavailable. + +**Success.** Acquire ITimerLease, observe a bounded number of ticks within documented tolerance, and prove timer and stream are activation-scoped. Fill the stream to verify overflow/drop accounting and a non-blocked CE/Lua thread. + +**Failure, cleanup, and lifecycle.** Zero, negative, unsupported interval and injected host creation failure leave no timer. After disposal, wait more than two intervals and prove no admission. Disable closes admission, neutralises callback, completes stream, and frees the timer before Lua teardown. Re-enable creates a new timer/scope; an old lease cannot stop or receive it. A target replacement must not cause a tick to use an old selection epoch. + +**Evidence.** Capture requested/observed intervals, tick/event/drop counts, timer IDs, callback-thread proof, PID/epochs, and final stop evidence. + +## Speed — Speed + +**Preconditions.** Record the host baseline speed and use a disposable local fixture. Inputs are finite and strictly positive. Speed is host-wide rather than a target allocation lease, so the gate explicitly restores baseline; Client must not silently initialise or reset it. Begin Unknown/Unavailable. + +**Success.** Read a copied baseline, set a known finite positive multiplier, read it back, and verify the fixture's timing change within a documented tolerance. Restore the exact recorded baseline as the test cleanup action. + +**Failure, cleanup, and lifecycle.** Reject zero, negative, NaN, and infinities before host mutation. A host error leaves the last valid value intact. At disable, re-enable, and target change, observe/record state without assuming per-process ownership, then restore the pre-test setting before finishing. + +**Evidence.** Capture baseline/request/read-back values, timing samples/tolerance, validation/host failures, host setting evidence, PID/epochs, and baseline-restoration proof. + +## Hashing — Hashing + +**Preconditions.** Test target-memory hashing and file hashing separately. For memory, select the local fixture and a bounded readable known-byte range. For files, use an authorised local fixture with a known digest. Begin Unknown/Unavailable. + +**Success.** Hash each input independently and compare copied digests with external known values. Define any same-content equivalence only under the documented algorithm/encoding. Neither operation mutates target memory or a file. + +**Failure, cleanup, and lifecycle.** Exercise unreadable/out-of-range memory, missing/unauthorised file, invalid range/algorithm, and selection change during memory hashing. Each returns a stable failure without a borrowed buffer, open file handle, or hashing the replacement PID. The service has no host lease, but disable/re-enable safely stop admission and a prior-epoch result is never reported as current. + +**Evidence.** Capture algorithm, bounded range metadata or canonical fixture path/hash, expected/actual digest, errors, resource checks, PID/epochs, and redacted no-content-logging proof. + +## DBVM observation, initialisation, and watches — Dbvm + +**Preconditions.** Use only a dedicated, authorised VM test environment whose hardware/OS support is understood by the operator. Observe DBVM state first: Client never initialises DBVM implicitly. Explicit initialisation requires a separate operator-approved rollback/VM-reset plan. Begin Unknown/Unavailable. + +**Success.** Prove state observation is side-effect free. Only when explicit initialisation is authorised, record pre/post state and install IDbvmWatchLease on a harmless fixture range. Verify copied events, bounded stream behaviour, and immediate non-blocking callback handling. + +**Failure, cleanup, and lifecycle.** Unsupported hardware, denied init, invalid range, and callback failure may not initialise DBVM or leave a watch active. Dispose watch and prove removal. If no safe in-session DBVM shutdown exists, document it, use the approved VM reset, and do not claim a Client deinitialisation guarantee. Disable closes admission, neutralises callbacks, completes streams, and removes watches before Lua detach. Re-enable creates fresh watches; selection change removes old watches before a new PID. + +**Evidence.** Capture observation/explicit-init decision, hardware/VM facts, watch IDs/ranges, stream/drop counts, lifecycle logs, reset proof where needed, and PID/epochs. + +## Value scanning — ValueScanning + +**Preconditions.** Select a fixture exposing known mutable values in a bounded documented region. SDK must have a production factory that creates and owns MemScan plus FoundList; Client must not hand-construct or adopt an unproven owner. Begin Unknown/Unavailable even if the public state machine exists. + +**Success.** Create one session, perform first scan, mutate the fixture, perform next scan, and read bounded copied result pages. Prove factory construction order is parent scan then child found-list. Confirm Client metadata exposes no LuaState, LuaRef, CEObject, Owned, borrowed Lua value, or raw pointer. + +**Failure, cleanup, and lifecycle.** Inject child creation failure after parent creation and prove parent rollback. Exercise invalid requests, cancellation before admission, empty/no-result scan, and failure during next scan. Dispose and prove child is destroyed before parent; disposal is idempotent and no owner remains after disable or selection change. Re-enable creates a new session; retained old page/session cannot scan or read new epoch data. + +**Evidence.** Capture request/result counts without sensitive values, parent/child create-destroy order, page bounds, cancellation/failure statuses, PID/epochs, CE scan UI/log evidence, and leak checks. + +## Recording the decision + +Attach the evidence bundle to the release issue or capability matrix, with exact host/SDK/Client versions and a pass/fail result for every phase. The review must affirm all four points: + +1. The managed plugin artifact ran in Cheat Engine 7.7 x64. +2. Success, expected failure, cleanup, disable, re-enable, and target replacement passed. +3. Ownership, epochs, callback admission, and Lua detachment were observed rather than inferred. +4. The high-level boundary holds: normal Client code sees copied data and leases only, never SDK/Lua handles or raw pointers. + +Only four affirmative answers and the domain-specific evidence permit Available. Otherwise keep Unknown/Unavailable, retain the published contract if any, and exclude the feature from unguarded examples. diff --git a/eng/Invoke-PackageSmoke.ps1 b/eng/Invoke-PackageSmoke.ps1 index 75a2d55..559bd1b 100644 --- a/eng/Invoke-PackageSmoke.ps1 +++ b/eng/Invoke-PackageSmoke.ps1 @@ -17,6 +17,11 @@ if (-not (Test-Path -LiteralPath $expectedPackage -PathType Leaf)) { throw "Expected package '$expectedPackage' was not found. Run dotnet pack before the smoke test." } +$expectedHostingPackage = Join-Path $resolvedPackageSource "CheatEngine.Client.Hosting.$ClientVersion.nupkg" +if (-not (Test-Path -LiteralPath $expectedHostingPackage -PathType Leaf)) { + throw "Expected package '$expectedHostingPackage' was not found. Run dotnet pack before the smoke test." +} + $temporaryBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) $smokeDirectory = [IO.Path]::GetFullPath((Join-Path $temporaryBase ("CheatEngine.Client.PackageSmoke." + [Guid]::NewGuid().ToString('N')))) if (-not $smokeDirectory.StartsWith($temporaryBase, [StringComparison]::OrdinalIgnoreCase)) { @@ -26,9 +31,23 @@ if (-not $smokeDirectory.StartsWith($temporaryBase, [StringComparison]::OrdinalI function Write-SmokeProject { param( [Parameter(Mandatory)] [string]$ProjectDirectory, - [Parameter(Mandatory)] [bool]$IncludeSdkReference + [Parameter(Mandatory)] [bool]$IncludeClientReference, + [Parameter(Mandatory)] [bool]$IncludeSdkReference, + [bool]$IncludeHostingReference = $false, + [ValidateRange(0, 2)] [int]$PluginCount = 1, + [string]$LanguageVersion = '14.0', + [string]$PlatformTarget = 'x64', + [bool]$GenerateEntryPoint = $true, + [bool]$ManualBootstrap = $false ) + $clientReference = if ($IncludeClientReference) { + ' ' + } + else { + '' + } + $sdkReference = if ($IncludeSdkReference) { ' ' } @@ -36,36 +55,41 @@ function Write-SmokeProject { '' } + $hostingReference = if ($IncludeHostingReference) { + ' ' + } + else { + '' + } + $projectXml = @" net10.0 - 14.0 + $LanguageVersion enable enable + $PlatformTarget true + $GenerateEntryPoint + $ManualBootstrap false true obj/Generated - +$clientReference +$hostingReference $sdkReference "@ Set-Content -LiteralPath (Join-Path $ProjectDirectory 'Smoke.Plugin.csproj') -Value $projectXml -Encoding utf8NoBOM - $pluginSource = @' -using CheatEngine.Client; -using CheatEngine.Client.Hosting; -using CheatEngine.Client.Memory; -using CheatEngine.Client.Scanning; -using CheatEngine.SDK.Annotations.Plugin; -using CheatEngine.SDK.Engine.Values; - -[CheatEnginePlugin("Package smoke plugin")] -public sealed class Plugin : CheatEngineClientPlugin + $pluginClasses = for ($index = 1; $index -le $PluginCount; $index++) { + @" +[CheatEnginePlugin("Package smoke plugin $index")] +public sealed class Plugin$index : CheatEngineClientPlugin { protected override void Configure(CheatEnginePluginBuilder builder) { @@ -77,13 +101,101 @@ public sealed class Plugin : CheatEngineClientPlugin _ = client.Patterns.Aob("00").FirstOrNone(); } } +"@ + } + + if ($PluginCount -eq 0) { + $pluginClasses = @' +public sealed class NotAPlugin; '@ + } + + $manualBootstrapSource = if ($ManualBootstrap) { + @' +namespace CESDK +{ + public static class CESDK + { + public static int CEPluginInitialize(IntPtr initialization, int version) + { + return 1; + } + } +} +'@ + } + else { + '' + } + + $pluginSource = @" +using System; +using CheatEngine.Client; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Memory; +using CheatEngine.Client.Scanning; +using CheatEngine.SDK.Annotations.Plugin; +using CheatEngine.SDK.Engine.Values; + +$pluginClasses +$manualBootstrapSource +"@ Set-Content -LiteralPath (Join-Path $ProjectDirectory 'Plugin.cs') -Value $pluginSource -Encoding utf8NoBOM } +function Assert-ExpectedBuildFailure { + param( + [Parameter(Mandatory)] [string]$ProjectPath, + [Parameter(Mandatory)] [string]$ExpectedDiagnostic, + [string[]]$AdditionalArguments = @() + ) + + $output = & dotnet build $ProjectPath --configuration Release --no-restore @AdditionalArguments 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + throw "The negative isolated plugin build unexpectedly succeeded; expected $ExpectedDiagnostic." + } + if ($output -notmatch $ExpectedDiagnostic) { + throw "The negative isolated plugin build failed, but did not report $ExpectedDiagnostic.`n$output" + } + + # A diagnosed negative build is expected. Clear the native command status only after asserting its exact diagnostic. + $global:LASTEXITCODE = 0 +} + +function Assert-PackageEntries { + param( + [Parameter(Mandatory)] [string]$PackagePath, + [Parameter(Mandatory)] [string[]]$ExpectedEntries + ) + + Add-Type -AssemblyName System.IO.Compression.FileSystem + $archive = [IO.Compression.ZipFile]::OpenRead($PackagePath) + try { + $entries = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($entry in $archive.Entries) { + [void]$entries.Add($entry.FullName) + } + + foreach ($expectedEntry in $ExpectedEntries) { + if (-not $entries.Contains($expectedEntry)) { + throw "Package '$PackagePath' is missing '$expectedEntry'." + } + } + } + finally { + $archive.Dispose() + } +} + try { New-Item -ItemType Directory -Path $smokeDirectory | Out-Null + Assert-PackageEntries -PackagePath $expectedHostingPackage -ExpectedEntries @( + 'analyzers/dotnet/cs/CheatEngine.Client.SourceGenerators.Lua.dll', + 'buildTransitive/CheatEngine.Client.Hosting.props', + 'buildTransitive/CheatEngine.Client.Hosting.targets' + ) + $configurationPath = Join-Path $smokeDirectory 'NuGet.Config' $escapedSource = [Security.SecurityElement]::Escape($resolvedPackageSource) $escapedPackageCache = [Security.SecurityElement]::Escape((Join-Path $smokeDirectory '.packages')) @@ -104,20 +216,30 @@ try { $positiveDirectory = Join-Path $smokeDirectory 'positive' New-Item -ItemType Directory -Path $positiveDirectory | Out-Null - Write-SmokeProject -ProjectDirectory $positiveDirectory -IncludeSdkReference $true + Write-SmokeProject -ProjectDirectory $positiveDirectory -IncludeClientReference $true -IncludeSdkReference $true & dotnet restore (Join-Path $positiveDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath if ($LASTEXITCODE -ne 0) { throw 'The positive isolated package restore failed.' } - & dotnet build (Join-Path $positiveDirectory 'Smoke.Plugin.csproj') --configuration Release --no-restore + $deploymentDirectory = Join-Path $smokeDirectory 'deployment' + & dotnet build (Join-Path $positiveDirectory 'Smoke.Plugin.csproj') --configuration Release --no-restore ` + "-p:CheatEnginePluginOutputPath=$deploymentDirectory" if ($LASTEXITCODE -ne 0) { throw 'The positive isolated package build failed.' } + # Reuse the destination so the deployment task must replace every staged file, not only create a fresh layout. + & dotnet build (Join-Path $positiveDirectory 'Smoke.Plugin.csproj') --configuration Release --no-restore ` + "-p:CheatEnginePluginOutputPath=$deploymentDirectory" + if ($LASTEXITCODE -ne 0) { + throw 'The managed deployment could not atomically replace an existing plugin layout.' + } + $positiveOutput = Join-Path $positiveDirectory 'bin/Release/net10.0' $requiredOutputFiles = @( 'Smoke.Plugin.dll', + 'Smoke.Plugin.deps.json', 'Smoke.Plugin.runtimeconfig.json', 'cheatengine-sdk-lua-bridge.dll', 'CheatEngine.SDK.dll', @@ -134,6 +256,13 @@ try { } } + foreach ($requiredDeploymentFile in $requiredOutputFiles) { + $requiredDeploymentPath = Join-Path $deploymentDirectory $requiredDeploymentFile + if (-not (Test-Path -LiteralPath $requiredDeploymentPath -PathType Leaf)) { + throw "The prepared deployment is missing '$requiredDeploymentFile'." + } + } + $generatedEntryPoints = @(Get-ChildItem -LiteralPath (Join-Path $positiveDirectory 'obj') -Recurse -File | Where-Object Name -eq 'CheatEngine.SDK.EntryPoint.g.cs') if ($generatedEntryPoints.Count -ne 1) { @@ -148,24 +277,107 @@ try { $negativeDirectory = Join-Path $smokeDirectory 'negative' New-Item -ItemType Directory -Path $negativeDirectory | Out-Null - Write-SmokeProject -ProjectDirectory $negativeDirectory -IncludeSdkReference $false + Write-SmokeProject -ProjectDirectory $negativeDirectory -IncludeClientReference $true -IncludeSdkReference $false & dotnet restore (Join-Path $negativeDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath if ($LASTEXITCODE -ne 0) { throw 'The negative isolated package restore failed before CECLIENT001 could be evaluated.' } - $negativeOutput = & dotnet build (Join-Path $negativeDirectory 'Smoke.Plugin.csproj') --configuration Release --no-restore 2>&1 | Out-String - if ($LASTEXITCODE -eq 0) { - throw 'The negative isolated plugin build unexpectedly succeeded without a direct CheatEngine.SDK reference.' + Assert-ExpectedBuildFailure -ProjectPath (Join-Path $negativeDirectory 'Smoke.Plugin.csproj') -ExpectedDiagnostic 'CECLIENT001' + + $missingClientDirectory = Join-Path $smokeDirectory 'missing-client' + New-Item -ItemType Directory -Path $missingClientDirectory | Out-Null + Write-SmokeProject -ProjectDirectory $missingClientDirectory -IncludeClientReference $false -IncludeSdkReference $true ` + -IncludeHostingReference $true + & dotnet restore (Join-Path $missingClientDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'The missing-Client isolated package restore failed before CECLIENT002 could be evaluated.' } - if ($negativeOutput -notmatch 'CECLIENT001') { - throw "The negative isolated plugin build failed, but did not report CECLIENT001.`n$negativeOutput" + Assert-ExpectedBuildFailure -ProjectPath (Join-Path $missingClientDirectory 'Smoke.Plugin.csproj') -ExpectedDiagnostic 'CECLIENT002' + + $zeroPluginDirectory = Join-Path $smokeDirectory 'zero-plugin' + New-Item -ItemType Directory -Path $zeroPluginDirectory | Out-Null + Write-SmokeProject -ProjectDirectory $zeroPluginDirectory -IncludeClientReference $true -IncludeSdkReference $true -PluginCount 0 + & dotnet restore (Join-Path $zeroPluginDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'The zero-plugin isolated package restore failed before CECLIENT003 could be evaluated.' } + Assert-ExpectedBuildFailure -ProjectPath (Join-Path $zeroPluginDirectory 'Smoke.Plugin.csproj') -ExpectedDiagnostic 'CECLIENT003' - # The expected negative build leaves PowerShell's native-command status non-zero. Clear it only after both - # assertions prove that the failure was the intended CECLIENT001 guard. - $global:LASTEXITCODE = 0 - Write-Host 'Package smoke test passed: direct SDK reference accepted and CECLIENT001 enforced for marked plugin projects.' + $multiplePluginDirectory = Join-Path $smokeDirectory 'multiple-plugin' + New-Item -ItemType Directory -Path $multiplePluginDirectory | Out-Null + Write-SmokeProject -ProjectDirectory $multiplePluginDirectory -IncludeClientReference $true -IncludeSdkReference $true ` + -PluginCount 2 -GenerateEntryPoint $false -ManualBootstrap $true + & dotnet restore (Join-Path $multiplePluginDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'The multiple-plugin isolated package restore failed before CECLIENT004 could be evaluated.' + } + Assert-ExpectedBuildFailure -ProjectPath (Join-Path $multiplePluginDirectory 'Smoke.Plugin.csproj') -ExpectedDiagnostic 'CECLIENT004' + + $manualBootstrapDirectory = Join-Path $smokeDirectory 'manual-bootstrap' + New-Item -ItemType Directory -Path $manualBootstrapDirectory | Out-Null + Write-SmokeProject -ProjectDirectory $manualBootstrapDirectory -IncludeClientReference $true -IncludeSdkReference $true ` + -GenerateEntryPoint $false + & dotnet restore (Join-Path $manualBootstrapDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'The manual-bootstrap isolated package restore failed before CECLIENT008 could be evaluated.' + } + Assert-ExpectedBuildFailure -ProjectPath (Join-Path $manualBootstrapDirectory 'Smoke.Plugin.csproj') -ExpectedDiagnostic 'CECLIENT008' + + $languageDirectory = Join-Path $smokeDirectory 'language' + New-Item -ItemType Directory -Path $languageDirectory | Out-Null + Write-SmokeProject -ProjectDirectory $languageDirectory -IncludeClientReference $true -IncludeSdkReference $true -LanguageVersion '13.0' + & dotnet restore (Join-Path $languageDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'The language-version isolated package restore failed before CECLIENT006 could be evaluated.' + } + Assert-ExpectedBuildFailure -ProjectPath (Join-Path $languageDirectory 'Smoke.Plugin.csproj') -ExpectedDiagnostic 'CECLIENT006' + + $platformDirectory = Join-Path $smokeDirectory 'platform' + New-Item -ItemType Directory -Path $platformDirectory | Out-Null + Write-SmokeProject -ProjectDirectory $platformDirectory -IncludeClientReference $true -IncludeSdkReference $true -PlatformTarget 'x86' + & dotnet restore (Join-Path $platformDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'The platform isolated package restore failed before CECLIENT007 could be evaluated.' + } + Assert-ExpectedBuildFailure -ProjectPath (Join-Path $platformDirectory 'Smoke.Plugin.csproj') -ExpectedDiagnostic 'CECLIENT007' + + $frameworkDirectory = Join-Path $smokeDirectory 'framework' + New-Item -ItemType Directory -Path $frameworkDirectory | Out-Null + Write-SmokeProject -ProjectDirectory $frameworkDirectory -IncludeClientReference $true -IncludeSdkReference $true + & dotnet restore (Join-Path $frameworkDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'The framework isolated package restore failed before CECLIENT005 could be evaluated.' + } + Assert-ExpectedBuildFailure -ProjectPath (Join-Path $frameworkDirectory 'Smoke.Plugin.csproj') -ExpectedDiagnostic 'CECLIENT005' ` + -AdditionalArguments @('-p:TargetFramework=net9.0') + + $anyCpuDirectory = Join-Path $smokeDirectory 'anycpu' + New-Item -ItemType Directory -Path $anyCpuDirectory | Out-Null + Write-SmokeProject -ProjectDirectory $anyCpuDirectory -IncludeClientReference $true -IncludeSdkReference $true -PlatformTarget 'AnyCPU' + & dotnet restore (Join-Path $anyCpuDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'The AnyCPU isolated package restore failed.' + } + & dotnet build (Join-Path $anyCpuDirectory 'Smoke.Plugin.csproj') --configuration Release --no-restore + if ($LASTEXITCODE -ne 0) { + throw 'The AnyCPU isolated plugin build failed.' + } + + $manualBootstrapSuccessDirectory = Join-Path $smokeDirectory 'manual-bootstrap-success' + New-Item -ItemType Directory -Path $manualBootstrapSuccessDirectory | Out-Null + Write-SmokeProject -ProjectDirectory $manualBootstrapSuccessDirectory -IncludeClientReference $true -IncludeSdkReference $true ` + -GenerateEntryPoint $false -ManualBootstrap $true + & dotnet restore (Join-Path $manualBootstrapSuccessDirectory 'Smoke.Plugin.csproj') --configfile $configurationPath + if ($LASTEXITCODE -ne 0) { + throw 'The manual-bootstrap-success isolated package restore failed.' + } + & dotnet build (Join-Path $manualBootstrapSuccessDirectory 'Smoke.Plugin.csproj') --configuration Release --no-restore + if ($LASTEXITCODE -ne 0) { + throw 'The explicit manual bootstrap isolated plugin build failed.' + } + + Write-Host 'Package smoke test passed: direct package references, Client plugin profile diagnostics, generated SDK bootstrap, and managed deployment layout are verified.' } finally { if (Test-Path -LiteralPath $smokeDirectory -PathType Container) { diff --git a/eng/Tests.props b/eng/Tests.props index b66e29e..f20f859 100644 --- a/eng/Tests.props +++ b/eng/Tests.props @@ -14,7 +14,6 @@ - diff --git a/libs/CheatEngine.Client.Abstractions/Allocations/IAllocationClient.cs b/libs/CheatEngine.Client.Abstractions/Allocations/IAllocationClient.cs new file mode 100644 index 0000000..3282469 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Allocations/IAllocationClient.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Allocations; + +/// Creates explicitly owned target-memory allocations. +public interface IAllocationClient +{ + /// Tries to allocate bounded target memory for the current selection. + public bool TryAllocate( + TargetAllocationRequest request, + [NotNullWhen(true)] out ITargetMemoryLease? lease, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Allocates bounded target memory or throws when the host rejects the request. + public ITargetMemoryLease Allocate( + TargetAllocationRequest request, + CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Allocations/ITargetMemoryLease.cs b/libs/CheatEngine.Client.Abstractions/Allocations/ITargetMemoryLease.cs new file mode 100644 index 0000000..82cd6eb --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Allocations/ITargetMemoryLease.cs @@ -0,0 +1,35 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Allocations; + +/// Owns one Client-created allocation in the selected target process. +/// +/// The activation owner releases forgotten leases before disable. A selection-epoch change also invalidates and +/// releases an allocation; implementations must not cache or pool target allocations. +/// +public interface ITargetMemoryLease : IDisposable +{ + /// Gets the allocated target address. + public Address Address + { + get; + } + + /// Gets the allocated byte count. + public long Size + { + get; + } + + /// Gets the target-selection epoch captured when this allocation was created. + public long SelectionEpoch + { + get; + } + + /// Gets whether the allocation has been released or invalidated. + public bool IsReleased + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationAccess.cs b/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationAccess.cs new file mode 100644 index 0000000..71018c4 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationAccess.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.Client.Allocations; + +/// Describes the access required by a Client-owned target-memory allocation. +public enum TargetAllocationAccess +{ + /// Creates memory for copied parameter and data exchange. + ReadWrite, + + /// Creates executable memory when a capability-gated operation has proven that requirement. + ExecuteReadWrite +} diff --git a/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationRequest.cs b/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationRequest.cs new file mode 100644 index 0000000..5f8ca12 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Allocations/TargetAllocationRequest.cs @@ -0,0 +1,32 @@ +namespace CheatEngine.Client.Allocations; + +/// Describes one bounded allocation requested in the currently selected target. +public readonly record struct TargetAllocationRequest +{ + /// Creates a target allocation request. + /// is not positive. + /// is not a defined value. + public TargetAllocationRequest(long size, TargetAllocationAccess access = TargetAllocationAccess.ReadWrite) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(size); + if (!Enum.IsDefined(access)) + { + throw new ArgumentOutOfRangeException(nameof(access)); + } + + Size = size; + Access = access; + } + + /// Gets the exact positive byte count requested from the target process. + public long Size + { + get; + } + + /// Gets the requested target-memory access. + public TargetAllocationAccess Access + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionRequest.cs b/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionRequest.cs new file mode 100644 index 0000000..69d430e --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionRequest.cs @@ -0,0 +1,28 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Assembly; + +/// Describes a single instruction to assemble at one target address. +public readonly record struct AssemblyInstructionRequest +{ + /// Creates an instruction-assembly request. + /// is blank. + public AssemblyInstructionRequest(Address address, string instruction) + { + ArgumentException.ThrowIfNullOrWhiteSpace(instruction); + Address = address; + Instruction = instruction; + } + + /// Gets the address used as the instruction's assembly origin. + public Address Address + { + get; + } + + /// Gets the assembly source for exactly one instruction. + public string Instruction + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionSnapshot.cs new file mode 100644 index 0000000..09c3a97 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Assembly/AssemblyInstructionSnapshot.cs @@ -0,0 +1,56 @@ +using System.Collections.Immutable; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Assembly; + +/// A copied disassembly instruction and its exact target byte representation. +public readonly record struct AssemblyInstructionSnapshot +{ + /// Creates a copied assembly-instruction snapshot. + /// is not positive. + /// is blank or is empty. + public AssemblyInstructionSnapshot(Address address, int length, string text, ReadOnlySpan bytes) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); + ArgumentException.ThrowIfNullOrWhiteSpace(text); + if (bytes.IsEmpty) + { + throw new ArgumentException("An instruction snapshot requires at least one byte.", nameof(bytes)); + } + + if (bytes.Length != length) + { + throw new ArgumentException("The byte count must match the instruction length.", nameof(bytes)); + } + + Address = address; + Length = length; + Text = text; + Bytes = ImmutableArray.Create(bytes); + } + + /// Gets the address of the instruction. + public Address Address + { + get; + } + + /// Gets the exact positive instruction length. + public int Length + { + get; + } + + /// Gets the copied disassembly text. + public string Text + { + get; + } + + /// Gets the immutable copy of the target instruction bytes. + public ImmutableArray Bytes + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/AutoAssemblerScript.cs b/libs/CheatEngine.Client.Abstractions/Assembly/AutoAssemblerScript.cs new file mode 100644 index 0000000..f3136cd --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Assembly/AutoAssemblerScript.cs @@ -0,0 +1,31 @@ +namespace CheatEngine.Client.Assembly; + +/// Describes an Auto Assembler script supplied to the capability-gated high-level API. +public readonly record struct AutoAssemblerScript +{ + /// Creates an Auto Assembler script request. + /// is blank or is blank. + public AutoAssemblerScript(string source, string? name = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(source); + if (name is not null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + } + + Source = source; + Name = name; + } + + /// Gets the Auto Assembler source. + public string Source + { + get; + } + + /// Gets the optional Client diagnostic name for the patch. + public string? Name + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/IAssemblyClient.cs b/libs/CheatEngine.Client.Abstractions/Assembly/IAssemblyClient.cs new file mode 100644 index 0000000..08e1fde --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Assembly/IAssemblyClient.cs @@ -0,0 +1,55 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Assembly; + +/// Disassembles, assembles, comments, and applies Client-owned Auto Assembler patches. +public interface IAssemblyClient +{ + /// Tries to copy one instruction from the target. + public bool TryDisassemble(Address address, out AssemblyInstructionSnapshot instruction, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Copies one target instruction or throws when disassembly fails. + public AssemblyInstructionSnapshot Disassemble(Address address, CancellationToken cancellationToken = default); + + /// Tries to get the exact size of the instruction at an address. + public bool TryGetInstructionSize(Address address, out int size, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Gets the instruction size or throws when Cheat Engine cannot decode it. + public int GetInstructionSize(Address address, CancellationToken cancellationToken = default); + + /// Tries to resolve the start address of the preceding target instruction. + public bool TryGetPreviousInstruction(Address address, out Address previousAddress, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Gets the preceding instruction address or throws when it cannot be resolved. + public Address GetPreviousInstruction(Address address, CancellationToken cancellationToken = default); + + /// Tries to copy the optional comment associated with one target address. + public bool TryGetComment(Address address, [NotNullWhen(true)] out string? comment, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Gets an address comment or throws when no comment is available. + public string GetComment(Address address, CancellationToken cancellationToken = default); + + /// Tries to assemble exactly one instruction into copied bytes. + public bool TryAssemble(AssemblyInstructionRequest request, out ImmutableArray bytes, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Assembles exactly one instruction into copied bytes or throws when assembly fails. + public ImmutableArray Assemble(AssemblyInstructionRequest request, + CancellationToken cancellationToken = default); + + /// Tries to apply a Client-owned Auto Assembler script. + public bool TryApplyPatch(AutoAssemblerScript script, [NotNullWhen(true)] out IAutoAssemblerPatchLease? lease, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Applies a Client-owned Auto Assembler script or throws when the host rejects it. + public IAutoAssemblerPatchLease ApplyPatch(AutoAssemblerScript script, + CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Assembly/IAutoAssemblerPatchLease.cs b/libs/CheatEngine.Client.Abstractions/Assembly/IAutoAssemblerPatchLease.cs new file mode 100644 index 0000000..5535d28 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Assembly/IAutoAssemblerPatchLease.cs @@ -0,0 +1,23 @@ +namespace CheatEngine.Client.Assembly; + +/// Owns one Client-applied Auto Assembler patch for the current activation and target selection. +public interface IAutoAssemblerPatchLease : IDisposable +{ + /// Gets the Client diagnostic name supplied when the patch was applied. + public string? Name + { + get; + } + + /// Gets the target-selection epoch captured by this patch. + public long SelectionEpoch + { + get; + } + + /// Gets whether the patch has already executed its disable cleanup or was invalidated. + public bool IsReleased + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmInitializationRequest.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmInitializationRequest.cs new file mode 100644 index 0000000..feb9ab0 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmInitializationRequest.cs @@ -0,0 +1,17 @@ +namespace CheatEngine.Client.Dbvm; + +/// Describes an explicit DBVM initialization request. +public readonly record struct DbvmInitializationRequest +{ + /// Creates a DBVM initialization request. + public DbvmInitializationRequest(bool useStealthMode = false) + { + UseStealthMode = useStealthMode; + } + + /// Gets whether the caller explicitly requests the host's documented stealth mode. + public bool UseStealthMode + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmState.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmState.cs new file mode 100644 index 0000000..3c57db8 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmState.cs @@ -0,0 +1,17 @@ +namespace CheatEngine.Client.Dbvm; + +/// Describes the observed DBVM lifecycle without triggering initialization. +public enum DbvmState +{ + /// No host observation has established the DBVM state. + Unknown, + + /// The active host cannot expose DBVM operations. + Unavailable, + + /// DBVM is supported but has not been initialized. + NotInitialized, + + /// DBVM has been initialized explicitly for the active host. + Initialized +} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmStatusSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmStatusSnapshot.cs new file mode 100644 index 0000000..2a6c742 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmStatusSnapshot.cs @@ -0,0 +1,35 @@ +namespace CheatEngine.Client.Dbvm; + +/// Contains a copied DBVM status observation. +public readonly record struct DbvmStatusSnapshot +{ + /// Creates a copied DBVM status observation. + /// is not a defined value. + public DbvmStatusSnapshot(DbvmState state, string? version = null) + { + if (!Enum.IsDefined(state)) + { + throw new ArgumentOutOfRangeException(nameof(state)); + } + + if (version is not null && string.IsNullOrWhiteSpace(version)) + { + throw new ArgumentException("A DBVM version must be null or non-empty.", nameof(version)); + } + + State = state; + Version = version; + } + + /// Gets the observed state without causing initialization. + public DbvmState State + { + get; + } + + /// Gets the optional copied DBVM version reported by the host. + public string? Version + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchEvent.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchEvent.cs new file mode 100644 index 0000000..1f4e705 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchEvent.cs @@ -0,0 +1,35 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Dbvm; + +/// Contains a copied DBVM watch observation. +public readonly record struct DbvmWatchEvent +{ + /// Creates a copied DBVM watch observation. + /// is not positive. + public DbvmWatchEvent(Address address, int length, DateTimeOffset occurredAt) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); + Address = address; + Length = length; + OccurredAt = occurredAt; + } + + /// Gets the copied target address that triggered the watch. + public Address Address + { + get; + } + + /// Gets the copied positive byte count associated with the event. + public int Length + { + get; + } + + /// Gets the copied observation timestamp. + public DateTimeOffset OccurredAt + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchHandler.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchHandler.cs new file mode 100644 index 0000000..9933c3b --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchHandler.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.Client.Dbvm; + +/// Handles a copied DBVM watch event synchronously without blocking the host callback thread. +public delegate void DbvmWatchHandler(DbvmWatchEvent watchEvent); diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchRequest.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchRequest.cs new file mode 100644 index 0000000..0cc79e0 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Dbvm/DbvmWatchRequest.cs @@ -0,0 +1,28 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Dbvm; + +/// Describes a bounded DBVM watch over one target-memory range. +public readonly record struct DbvmWatchRequest +{ + /// Creates a bounded DBVM watch request. + /// is not positive. + public DbvmWatchRequest(Address address, int length) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); + Address = address; + Length = length; + } + + /// Gets the first target address covered by the watch. + public Address Address + { + get; + } + + /// Gets the exact positive watched byte count. + public int Length + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmClient.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmClient.cs new file mode 100644 index 0000000..cd44597 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmClient.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Events; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Dbvm; + +/// Observes DBVM state and exposes explicit initialization and watch operations. +public interface IDbvmClient +{ + /// Tries to observe DBVM state without initializing DBVM. + public bool TryGetStatus(out DbvmStatusSnapshot status, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Observes DBVM state without initializing DBVM. + public DbvmStatusSnapshot GetStatus(CancellationToken cancellationToken = default); + + /// Tries to initialize DBVM only after an explicit caller request. + public bool TryInitialize(DbvmInitializationRequest request, out DbvmStatusSnapshot status, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Initializes DBVM only after an explicit caller request. + public DbvmStatusSnapshot Initialize(DbvmInitializationRequest request, + CancellationToken cancellationToken = default); + + /// Tries to register a DBVM watch and its bounded copied event stream. + public bool TryRegisterWatch( + DbvmWatchRequest request, + DbvmWatchHandler handler, + EventStreamOptions streamOptions, + [NotNullWhen(true)] out IDbvmWatchLease? lease, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Registers a DBVM watch or throws when the capability is unavailable or registration fails. + public IDbvmWatchLease RegisterWatch( + DbvmWatchRequest request, + DbvmWatchHandler handler, + EventStreamOptions streamOptions, + CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmWatchLease.cs b/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmWatchLease.cs new file mode 100644 index 0000000..a8ad5ac --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Dbvm/IDbvmWatchLease.cs @@ -0,0 +1,19 @@ +using CheatEngine.Client.Events; + +namespace CheatEngine.Client.Dbvm; + +/// Owns one Client DBVM watch and its bounded copied event stream. +public interface IDbvmWatchLease : IEventStreamLease +{ + /// Gets the request used to create this watch. + public DbvmWatchRequest Request + { + get; + } + + /// Gets the target-selection epoch captured when the watch was registered. + public long SelectionEpoch + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointDisposition.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointDisposition.cs new file mode 100644 index 0000000..2bb23d3 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointDisposition.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.Client.Debugger; + +/// Specifies the immediate continuation decision returned by a synchronous breakpoint handler. +public enum BreakpointDisposition +{ + /// Continues target execution after the synchronous handler returns. + Continue, + + /// Leaves execution broken for the host's debugger workflow. + Break +} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointEvent.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointEvent.cs new file mode 100644 index 0000000..0630b8a --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointEvent.cs @@ -0,0 +1,42 @@ +using System.Collections.Immutable; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Debugger; + +/// Contains copied data delivered synchronously for one breakpoint hit. +public readonly record struct BreakpointEvent +{ + /// Creates a copied breakpoint event. + /// is negative. + public BreakpointEvent(Address address, int threadId, ImmutableArray registers) + { + ArgumentOutOfRangeException.ThrowIfNegative(threadId); + if (registers.IsDefault) + { + throw new ArgumentException("Register snapshots must be initialized.", nameof(registers)); + } + + Address = address; + ThreadId = threadId; + Registers = registers; + } + + /// Gets the copied instruction address that triggered the breakpoint. + public Address Address + { + get; + } + + /// Gets the non-negative target thread identifier. + public int ThreadId + { + get; + } + + /// Gets immutable copied register observations. + public ImmutableArray Registers + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointHandler.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointHandler.cs new file mode 100644 index 0000000..05ccfa3 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointHandler.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.Client.Debugger; + +/// Handles one copied breakpoint event without awaiting on a Cheat Engine callback thread. +public delegate BreakpointDisposition BreakpointHandler(BreakpointEvent breakpointEvent); diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointKind.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointKind.cs new file mode 100644 index 0000000..806436e --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointKind.cs @@ -0,0 +1,14 @@ +namespace CheatEngine.Client.Debugger; + +/// Specifies the target-memory access that triggers a breakpoint. +public enum BreakpointKind +{ + /// Breaks when execution reaches the target address. + Execute, + + /// Breaks when the target address is written. + Write, + + /// Breaks when the target address is read or written. + Access +} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRegisterSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRegisterSnapshot.cs new file mode 100644 index 0000000..5d50206 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRegisterSnapshot.cs @@ -0,0 +1,26 @@ +namespace CheatEngine.Client.Debugger; + +/// Contains one copied register observation associated with a breakpoint event. +public readonly record struct BreakpointRegisterSnapshot +{ + /// Creates a copied register observation. + /// is blank. + public BreakpointRegisterSnapshot(string name, ulong value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + Name = name; + Value = value; + } + + /// Gets the normalized register name. + public string Name + { + get; + } + + /// Gets the copied register machine value. + public ulong Value + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRequest.cs b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRequest.cs new file mode 100644 index 0000000..e3890d3 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Debugger/BreakpointRequest.cs @@ -0,0 +1,32 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Debugger; + +/// Describes one Client-owned breakpoint. +public readonly record struct BreakpointRequest +{ + /// Creates a breakpoint request. + /// is not a defined value. + public BreakpointRequest(Address address, BreakpointKind kind = BreakpointKind.Execute) + { + if (!Enum.IsDefined(kind)) + { + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + Address = address; + Kind = kind; + } + + /// Gets the target address observed by the breakpoint. + public Address Address + { + get; + } + + /// Gets the access kind that triggers the breakpoint. + public BreakpointKind Kind + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/IBreakpointLease.cs b/libs/CheatEngine.Client.Abstractions/Debugger/IBreakpointLease.cs new file mode 100644 index 0000000..9a0e123 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Debugger/IBreakpointLease.cs @@ -0,0 +1,19 @@ +using CheatEngine.Client.Events; + +namespace CheatEngine.Client.Debugger; + +/// Owns one Client breakpoint and its bounded copied event stream. +public interface IBreakpointLease : IEventStreamLease +{ + /// Gets the request used to create this breakpoint. + public BreakpointRequest Request + { + get; + } + + /// Gets the target-selection epoch captured when the breakpoint was registered. + public long SelectionEpoch + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Debugger/IDebuggerClient.cs b/libs/CheatEngine.Client.Abstractions/Debugger/IDebuggerClient.cs new file mode 100644 index 0000000..53d2cc7 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Debugger/IDebuggerClient.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Events; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Debugger; + +/// Registers Client-owned breakpoints with immediate handler decisions and bounded copied event streams. +public interface IDebuggerClient +{ + /// Tries to register one breakpoint for the current activation and selection. + public bool TryRegisterBreakpoint( + BreakpointRequest request, + BreakpointHandler handler, + EventStreamOptions streamOptions, + [NotNullWhen(true)] out IBreakpointLease? lease, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Registers one breakpoint or throws when the capability is unavailable or registration fails. + public IBreakpointLease RegisterBreakpoint( + BreakpointRequest request, + BreakpointHandler handler, + EventStreamOptions streamOptions, + CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Events/EventStreamOptions.cs b/libs/CheatEngine.Client.Abstractions/Events/EventStreamOptions.cs new file mode 100644 index 0000000..c60a913 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Events/EventStreamOptions.cs @@ -0,0 +1,34 @@ +namespace CheatEngine.Client.Events; + +/// Configures bounded, non-blocking materialization of host callback events. +public readonly record struct EventStreamOptions +{ + /// Creates bounded stream options. + /// is not positive. + /// is not a defined value. + public EventStreamOptions( + int capacity, + EventStreamOverflowPolicy overflowPolicy = EventStreamOverflowPolicy.DropOldest) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); + if (!Enum.IsDefined(overflowPolicy)) + { + throw new ArgumentOutOfRangeException(nameof(overflowPolicy)); + } + + Capacity = capacity; + OverflowPolicy = overflowPolicy; + } + + /// Gets the mandatory maximum number of events buffered for a consumer. + public int Capacity + { + get; + } + + /// Gets the policy applied when is reached. + public EventStreamOverflowPolicy OverflowPolicy + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Events/EventStreamOverflowPolicy.cs b/libs/CheatEngine.Client.Abstractions/Events/EventStreamOverflowPolicy.cs new file mode 100644 index 0000000..37b8048 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Events/EventStreamOverflowPolicy.cs @@ -0,0 +1,14 @@ +namespace CheatEngine.Client.Events; + +/// Specifies the bounded-admission policy for a copied Client event stream. +public enum EventStreamOverflowPolicy +{ + /// Evicts the oldest buffered event before admitting the newest event. + DropOldest, + + /// Rejects the newly raised event while retaining the buffered events. + DropNewest, + + /// Terminates the subscription when its bounded buffer cannot admit an event. + FailSubscription +} diff --git a/libs/CheatEngine.Client.Abstractions/Events/IEventStreamLease.cs b/libs/CheatEngine.Client.Abstractions/Events/IEventStreamLease.cs new file mode 100644 index 0000000..c166cb5 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Events/IEventStreamLease.cs @@ -0,0 +1,28 @@ +namespace CheatEngine.Client.Events; + +/// Owns a bounded stream of copied callback events for one Client activation. +/// The copied event snapshot type. +/// +/// Implementations must never wait for a stream consumer on a Cheat Engine or Lua callback thread. Disposing a +/// lease closes admission, detaches its callback, completes , and releases host state. +/// +public interface IEventStreamLease : IDisposable +{ + /// Gets the bounded stream of copied events. + public IAsyncEnumerable Events + { + get; + } + + /// Gets the number of events that were not admitted to the stream. + public long DroppedEventCount + { + get; + } + + /// Gets whether this activation-scoped subscription has released its host registration. + public bool IsReleased + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Hashing/FileHashRequest.cs b/libs/CheatEngine.Client.Abstractions/Hashing/FileHashRequest.cs new file mode 100644 index 0000000..8f5173c --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hashing/FileHashRequest.cs @@ -0,0 +1,36 @@ +namespace CheatEngine.Client.Hashing; + +/// Describes a file hash request that is intentionally separate from target-memory hashing. +public readonly record struct FileHashRequest +{ + /// Creates a file hash request. + /// is blank or relative, or the algorithm is undefined. + public FileHashRequest(string filePath, TargetHashAlgorithm algorithm = TargetHashAlgorithm.Sha256) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + if (!Path.IsPathFullyQualified(filePath)) + { + throw new ArgumentException("A file hash path must be absolute.", nameof(filePath)); + } + + if (!Enum.IsDefined(algorithm)) + { + throw new ArgumentOutOfRangeException(nameof(algorithm)); + } + + FilePath = filePath; + Algorithm = algorithm; + } + + /// Gets the absolute file path. The implementation verifies existence before hashing. + public string FilePath + { + get; + } + + /// Gets the requested hash algorithm. + public TargetHashAlgorithm Algorithm + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Hashing/HashDigest.cs b/libs/CheatEngine.Client.Abstractions/Hashing/HashDigest.cs new file mode 100644 index 0000000..6341fef --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hashing/HashDigest.cs @@ -0,0 +1,31 @@ +namespace CheatEngine.Client.Hashing; + +/// Contains a copied, normalized digest value. +public readonly record struct HashDigest +{ + /// Creates a digest result. + /// is blank or the algorithm is undefined. + public HashDigest(TargetHashAlgorithm algorithm, string value) + { + if (!Enum.IsDefined(algorithm)) + { + throw new ArgumentOutOfRangeException(nameof(algorithm)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(value); + Algorithm = algorithm; + Value = value; + } + + /// Gets the algorithm that produced . + public TargetHashAlgorithm Algorithm + { + get; + } + + /// Gets the copied textual digest. + public string Value + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Hashing/IHashingClient.cs b/libs/CheatEngine.Client.Abstractions/Hashing/IHashingClient.cs new file mode 100644 index 0000000..f72ab52 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hashing/IHashingClient.cs @@ -0,0 +1,21 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Hashing; + +/// Hashes bounded target memory and files through explicitly separate operations. +public interface IHashingClient +{ + /// Tries to hash an exact target-memory range. + public bool TryHashMemory(MemoryHashRequest request, out HashDigest digest, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Hashes an exact target-memory range or throws when unavailable. + public HashDigest HashMemory(MemoryHashRequest request, CancellationToken cancellationToken = default); + + /// Tries to hash an existing file independently from the selected target. + public bool TryHashFile(FileHashRequest request, out HashDigest digest, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Hashes an existing file independently from the selected target. + public HashDigest HashFile(FileHashRequest request, CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Hashing/MemoryHashRequest.cs b/libs/CheatEngine.Client.Abstractions/Hashing/MemoryHashRequest.cs new file mode 100644 index 0000000..1119f64 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hashing/MemoryHashRequest.cs @@ -0,0 +1,40 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Hashing; + +/// Describes a bounded target-memory hash request. +public readonly record struct MemoryHashRequest +{ + /// Creates a target-memory hash request. + /// is not positive or the algorithm is undefined. + public MemoryHashRequest(Address address, int length, TargetHashAlgorithm algorithm = TargetHashAlgorithm.Sha256) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length); + if (!Enum.IsDefined(algorithm)) + { + throw new ArgumentOutOfRangeException(nameof(algorithm)); + } + + Address = address; + Length = length; + Algorithm = algorithm; + } + + /// Gets the first target address included in the hash. + public Address Address + { + get; + } + + /// Gets the exact positive number of target bytes included in the hash. + public int Length + { + get; + } + + /// Gets the requested hash algorithm. + public TargetHashAlgorithm Algorithm + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Hashing/TargetHashAlgorithm.cs b/libs/CheatEngine.Client.Abstractions/Hashing/TargetHashAlgorithm.cs new file mode 100644 index 0000000..eaf5b3d --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hashing/TargetHashAlgorithm.cs @@ -0,0 +1,14 @@ +namespace CheatEngine.Client.Hashing; + +/// Identifies a hash algorithm supported by the Client hashing contract. +public enum TargetHashAlgorithm +{ + /// MD5. + Md5, + + /// SHA-1. + Sha1, + + /// SHA-256. + Sha256 +} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyEvent.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyEvent.cs new file mode 100644 index 0000000..ee89eb5 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyEvent.cs @@ -0,0 +1,33 @@ +namespace CheatEngine.Client.Hotkeys; + +/// Contains a copied target hotkey activation. +public readonly record struct HotkeyEvent +{ + /// Creates a copied hotkey activation. + /// is blank. + public HotkeyEvent(string name, HotkeyGesture gesture, DateTimeOffset occurredAt) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + Name = name; + Gesture = gesture; + OccurredAt = occurredAt; + } + + /// Gets the Client registration name. + public string Name + { + get; + } + + /// Gets the copied gesture that was activated. + public HotkeyGesture Gesture + { + get; + } + + /// Gets the copied activation timestamp. + public DateTimeOffset OccurredAt + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyGesture.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyGesture.cs new file mode 100644 index 0000000..750f6ac --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyGesture.cs @@ -0,0 +1,39 @@ +namespace CheatEngine.Client.Hotkeys; + +/// Describes a platform-neutral virtual-key gesture. +public readonly record struct HotkeyGesture +{ + /// Creates a hotkey gesture. + /// + /// is outside the byte-sized Windows + /// virtual-key range. + /// + public HotkeyGesture(int virtualKey, HotkeyModifiers modifiers = HotkeyModifiers.None) + { + if (virtualKey is < byte.MinValue or > byte.MaxValue) + { + throw new ArgumentOutOfRangeException(nameof(virtualKey)); + } + + if ((modifiers & ~(HotkeyModifiers.Alt | HotkeyModifiers.Control | HotkeyModifiers.Shift | + HotkeyModifiers.Windows)) != 0) + { + throw new ArgumentOutOfRangeException(nameof(modifiers)); + } + + VirtualKey = virtualKey; + Modifiers = modifiers; + } + + /// Gets the platform virtual-key code. + public int VirtualKey + { + get; + } + + /// Gets the requested modifier combination. + public HotkeyModifiers Modifiers + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyHandler.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyHandler.cs new file mode 100644 index 0000000..411552f --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyHandler.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.Client.Hotkeys; + +/// Handles a copied hotkey event synchronously without blocking the host callback thread. +public delegate void HotkeyHandler(HotkeyEvent hotkeyEvent); diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyModifiers.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyModifiers.cs new file mode 100644 index 0000000..c6e2197 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyModifiers.cs @@ -0,0 +1,21 @@ +namespace CheatEngine.Client.Hotkeys; + +/// Specifies modifier keys that participate in a registered target hotkey. +[Flags] +public enum HotkeyModifiers +{ + /// No modifier key. + None = 0, + + /// The Alt modifier. + Alt = 1, + + /// The Control modifier. + Control = 2, + + /// The Shift modifier. + Shift = 4, + + /// The Windows modifier. + Windows = 8 +} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyRegistration.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyRegistration.cs new file mode 100644 index 0000000..300a709 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hotkeys/HotkeyRegistration.cs @@ -0,0 +1,26 @@ +namespace CheatEngine.Client.Hotkeys; + +/// Describes one Client-owned hotkey registration. +public readonly record struct HotkeyRegistration +{ + /// Creates a hotkey registration. + /// is blank. + public HotkeyRegistration(string name, HotkeyGesture gesture) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + Name = name; + Gesture = gesture; + } + + /// Gets the Client-unique diagnostic name. + public string Name + { + get; + } + + /// Gets the registered hotkey gesture. + public HotkeyGesture Gesture + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyClient.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyClient.cs new file mode 100644 index 0000000..01962d7 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyClient.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Events; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Hotkeys; + +/// Registers Client-owned hotkeys for the current activation. +public interface IHotkeyClient +{ + /// Tries to register one hotkey and a bounded copied event stream. + public bool TryRegister( + HotkeyRegistration registration, + HotkeyHandler handler, + EventStreamOptions streamOptions, + [NotNullWhen(true)] out IHotkeyLease? lease, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Registers one hotkey or throws when the capability is unavailable or registration fails. + public IHotkeyLease Register( + HotkeyRegistration registration, + HotkeyHandler handler, + EventStreamOptions streamOptions, + CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyLease.cs b/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyLease.cs new file mode 100644 index 0000000..29b4268 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Hotkeys/IHotkeyLease.cs @@ -0,0 +1,13 @@ +using CheatEngine.Client.Events; + +namespace CheatEngine.Client.Hotkeys; + +/// Owns one Client hotkey registration and its bounded copied event stream. +public interface IHotkeyLease : IEventStreamLease +{ + /// Gets the registration owned by this lease. + public HotkeyRegistration Registration + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs b/libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs index a5c1938..4b05b8e 100644 --- a/libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs +++ b/libs/CheatEngine.Client.Abstractions/ICheatEngineClient.cs @@ -1,11 +1,20 @@ +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Hashing; +using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Processes; +using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; +using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; +using CheatEngine.Client.Timers; namespace CheatEngine.Client; @@ -82,4 +91,58 @@ public ILuaClient Lua { get; } + + /// Gets owned target-memory allocation operations. + public IAllocationClient Allocations + { + get; + } + + /// Gets copied assembly, disassembly, comment, and Auto Assembler patch operations. + public IAssemblyClient Assembly + { + get; + } + + /// Gets bounded DLL injection and remote-call operations. + public IRemoteExecutionClient RemoteExecution + { + get; + } + + /// Gets breakpoint and copied debugger-event operations. + public IDebuggerClient Debugger + { + get; + } + + /// Gets activation-scoped hotkey operations. + public IHotkeyClient Hotkeys + { + get; + } + + /// Gets activation-scoped timer operations. + public ITimerClient Timers + { + get; + } + + /// Gets validated target-speed operations. + public ISpeedClient Speed + { + get; + } + + /// Gets explicitly separated target-memory and file hashing operations. + public IHashingClient Hashing + { + get; + } + + /// Gets observational and explicitly initialized DBVM operations. + public IDbvmClient Dbvm + { + get; + } } diff --git a/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaModuleAttribute.cs b/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaModuleAttribute.cs new file mode 100644 index 0000000..add6b76 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaModuleAttribute.cs @@ -0,0 +1,33 @@ +namespace CheatEngine.Client.Lua; + +/// +/// Declares a Client-owned Lua module whose registration adapter is emitted at compile time. +/// +/// +/// +/// The annotated type is a non-static partial class. names the static partial +/// SDK binding type that owns the generated RegisterLuaFunctions and UnregisterLuaFunctions +/// methods. The Client generator reads this declaration at compile time; it never discovers modules through +/// reflection at run time. +/// +/// +/// The optional name is the stable identity reserved by for one activation. When it +/// is omitted, the generator uses the annotated module type's simple name. Lua export identities are copied +/// from the [LuaFunction] declarations on . +/// +/// +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class CheatEngineLuaModuleAttribute(Type bindingsType, string? name = null) : Attribute +{ + /// Gets the static SDK binding type that owns the generated Lua registration pair. + public Type BindingsType + { + get; + } = bindingsType ?? throw new ArgumentNullException(nameof(bindingsType)); + + /// Gets the optional stable module identity, or to use the module type name. + public string? Name + { + get; + } = name; +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaOperationAttribute.cs b/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaOperationAttribute.cs new file mode 100644 index 0000000..0d88d85 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/CheatEngineLuaOperationAttribute.cs @@ -0,0 +1,39 @@ +namespace CheatEngine.Client.Lua; + +/// +/// Marks one SDK [LuaGlobal] declaration for generation of a copied, typed Client operation. +/// +/// +/// +/// The Client generator emits a readonly operation value and a strongly typed factory beside the declaring +/// binding type. The operation catches SDK Lua failures inside the Client boundary and returns +/// instead of exposing a Lua state, reference, +/// status or CE object. +/// +/// +/// Supply a mapper type when the SDK result is not itself an approved copied scalar. The named type must +/// implement for the SDK result and the +/// desired Client DTO. The generator calls that mapper statically; no reflection is involved. +/// +/// +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class CheatEngineLuaOperationAttribute : Attribute +{ + /// Initializes scalar-result operation generation without a mapper. + public CheatEngineLuaOperationAttribute() + { + } + + /// Initializes operation generation with one explicit static Client result mapper. + /// The type that implements the required static mapper contract. + public CheatEngineLuaOperationAttribute(Type mapperType) + { + MapperType = mapperType ?? throw new ArgumentNullException(nameof(mapperType)); + } + + /// Gets the explicit result mapper type, or for a copied scalar result. + public Type? MapperType + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/IDescribedLuaModule.cs b/libs/CheatEngine.Client.Abstractions/Lua/IDescribedLuaModule.cs new file mode 100644 index 0000000..1b5521c --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/IDescribedLuaModule.cs @@ -0,0 +1,18 @@ +namespace CheatEngine.Client.Lua; + +/// +/// Describes an explicit Lua module before the Client mutates Cheat Engine's global Lua environment. +/// +/// +/// The descriptor is copied metadata only. It intentionally contains neither a Lua state nor any SDK ownership, +/// reference, callback, or native handle. uses it to reserve the module and all export +/// names atomically for an activation before invoking . +/// +public interface IDescribedLuaModule : ILuaModule +{ + /// Gets this module's stable identity and exported Lua global names. + public LuaModuleDescriptor Descriptor + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs index b6d6349..f3372e3 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaClient.cs @@ -21,10 +21,55 @@ public bool TryRegisterModule( /// Registers one explicitly supplied application Lua module or throws when registration fails. public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken cancellationToken = default); +#pragma warning disable RS0026, RS0027 // The constrained overloads preserve the shipped overloads and require a token. + /// Tries to execute one typed operation on Cheat Engine's main thread. public bool TryExecute(ILuaOperation operation, [MaybeNullWhen(false)] out TResult result, out CheatEngineFailure failure, CancellationToken cancellationToken = default); /// Executes one typed operation or throws when it fails. public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default); + + /// + /// Tries to execute a value-type operation without forcing callers to convert it to an interface explicitly. + /// + /// The readonly value operation type. + /// The copied result type. + /// The operation to execute. + /// The copied result on success. + /// The mapped Client failure on failure. + /// Cancellation observed before dispatch admission. + /// when the operation completed successfully. + /// + /// This default implementation preserves source and binary compatibility for third-party implementations of + /// . Core supplies a constrained implementation that avoids boxing the generated + /// readonly record struct operation on its measured path. + /// + public bool TryExecute( + TOperation operation, + [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure, + CancellationToken cancellationToken) + where TOperation : struct, ILuaOperation + { + return TryExecute(operation, out result, out failure, cancellationToken); + } + + /// Executes a value-type operation or throws when it fails. + /// The readonly value operation type. + /// The copied result type. + /// The operation to execute. + /// Cancellation observed before dispatch admission. + /// The copied result. + /// + /// This default implementation preserves compatibility for existing third-party implementations. Core replaces + /// it with constrained dispatch for generated value operations. + /// + public TResult Execute(TOperation operation, CancellationToken cancellationToken) + where TOperation : struct, ILuaOperation + { + return Execute(operation, cancellationToken); + } + +#pragma warning restore RS0026, RS0027 } diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs index c50778d..ca04619 100644 --- a/libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaModule.cs @@ -13,6 +13,11 @@ public interface ILuaModule /// /// Throw when the generated SDK registration reports a non-success status so /// can return the mapped failure. + /// + /// A manual module that implements only this interface remains a compatible advanced escape hatch. Because it + /// does not declare its identity or exports, it cannot participate in the Client's activation-wide global + /// collision guarantee. Implement for ordinary application modules. + /// /// public void Register(); diff --git a/libs/CheatEngine.Client.Abstractions/Lua/ILuaResultMapper.cs b/libs/CheatEngine.Client.Abstractions/Lua/ILuaResultMapper.cs new file mode 100644 index 0000000..6b185c5 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/ILuaResultMapper.cs @@ -0,0 +1,19 @@ +namespace CheatEngine.Client.Lua; + +/// +/// Projects one SDK-returned value into a copied Client result while a typed Lua operation is still active. +/// +/// The SDK binding result type visible only inside generated operation code. +/// The copied Client result type exposed by the operation. +/// +/// Implementations must not retain their source value when it is backed by a Lua reference, CE object, owned +/// wrapper, native pointer, span, or other activation-bound SDK resource. The generated operation invokes this +/// member directly through static abstract interface dispatch; it never uses reflection. +/// +public interface ILuaResultMapper +{ + /// Creates the Client-owned result before the Lua operation scope ends. + /// The value returned by the SDK binding. + /// A copied Client result. + public static abstract TResult Map(TSource source); +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/LuaExportDescriptor.cs b/libs/CheatEngine.Client.Abstractions/Lua/LuaExportDescriptor.cs new file mode 100644 index 0000000..3c7b5ac --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/LuaExportDescriptor.cs @@ -0,0 +1,19 @@ +namespace CheatEngine.Client.Lua; + +/// Copied metadata that identifies one Lua global exported by a Client module. +public readonly record struct LuaExportDescriptor +{ + /// Initializes one validated Lua global descriptor. + /// The case-sensitive Lua global name. + public LuaExportDescriptor(string name) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + Name = name; + } + + /// Gets the case-sensitive Lua global name. + public string Name + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Lua/LuaModuleDescriptor.cs b/libs/CheatEngine.Client.Abstractions/Lua/LuaModuleDescriptor.cs new file mode 100644 index 0000000..2d16ef6 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Lua/LuaModuleDescriptor.cs @@ -0,0 +1,42 @@ +using System.Collections.Immutable; + +namespace CheatEngine.Client.Lua; + +/// Copied metadata that identifies one explicit Lua module and all global names it exports. +public readonly record struct LuaModuleDescriptor +{ + /// Initializes one immutable module descriptor. + /// The stable module identity for the Client activation. + /// The Lua global names this module will register. + /// is blank or exports contain a blank or duplicate name. + public LuaModuleDescriptor(string name, ImmutableArray exports) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + Name = name; + Exports = exports.IsDefault ? ImmutableArray.Empty : exports; + + HashSet? names = Exports.IsEmpty ? null : new HashSet(StringComparer.Ordinal); + foreach (LuaExportDescriptor export in Exports) + { + ArgumentException.ThrowIfNullOrWhiteSpace(export.Name); + if (!names!.Add(export.Name)) + { + throw new ArgumentException( + $"The Lua module '{name}' contains the export '{export.Name}' more than once.", + nameof(exports)); + } + } + } + + /// Gets the stable module identity for the Client activation. + public string Name + { + get; + } + + /// Gets immutable copied descriptors of each Lua global this module exports. + public ImmutableArray Exports + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs index 7e61307..d078d30 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/IMemoryClient.cs @@ -23,6 +23,22 @@ public bool TryWritePrimitive(Address address, T value, out CheatEngineFailur /// Writes a built-in scalar or pointer type or throws when unsupported or rejected. public void WritePrimitive(Address address, T value, CancellationToken cancellationToken = default); + /// Tries to read a bounded homogeneous batch of built-in scalars or pointers in one dispatch admission. + public bool TryReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, out ImmutableArray values, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Reads a bounded homogeneous batch of built-in scalars or pointers or throws on failure. + public ImmutableArray ReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, + CancellationToken cancellationToken = default); + + /// Tries to write a bounded homogeneous batch of built-in scalars or pointers in one dispatch admission. + public bool TryWritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Writes a bounded homogeneous batch of built-in scalars or pointers or throws on failure. + public void WritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, + CancellationToken cancellationToken = default); + /// Tries to copy an exact, caller-bounded byte range. public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray bytes, out CheatEngineFailure failure, CancellationToken cancellationToken = default); diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryAddressValue.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryAddressValue.cs new file mode 100644 index 0000000..9d319b6 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryAddressValue.cs @@ -0,0 +1,29 @@ +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// Associates one copied managed value with its target address. +/// The homogeneous scalar type written by a batch. +public readonly struct MemoryAddressValue +{ + /// Creates one copied address/value pair. + /// The target address. + /// The managed value to write. + public MemoryAddressValue(Address address, T value) + { + Address = address; + Value = value; + } + + /// Gets the target address. + public Address Address + { + get; + } + + /// Gets the managed value to write. + public T Value + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchLimits.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchLimits.cs new file mode 100644 index 0000000..1327380 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryBatchLimits.cs @@ -0,0 +1,12 @@ +namespace CheatEngine.Client.Memory; + +/// Defines the bounded sizes accepted by target-memory batch contracts. +public static class MemoryBatchLimits +{ + /// Gets the largest number of homogeneous scalar operations accepted in one batch. + /// + /// The limit bounds request copying, result materialization and the amount of work admitted to one Cheat Engine + /// main-thread dispatch. It does not make the individual Lua globals atomic. + /// + public const int MaximumOperations = 1024; +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadRequest.cs new file mode 100644 index 0000000..65fcb26 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchReadRequest.cs @@ -0,0 +1,36 @@ +using System.Collections.Immutable; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// A copied, bounded request to read one homogeneous built-in scalar from multiple target addresses. +/// The built-in scalar or target-aware pointer type to read. +public readonly struct MemoryPrimitiveBatchReadRequest +{ + /// Creates a bounded batch by copying every target address. + /// The non-empty target addresses to read in order. + /// is empty. + /// exceeds the supported batch size. + public MemoryPrimitiveBatchReadRequest(ReadOnlySpan
addresses) + { + if (addresses.IsEmpty) + { + throw new ArgumentException("A memory batch requires at least one address.", nameof(addresses)); + } + + if (addresses.Length > MemoryBatchLimits.MaximumOperations) + { + throw new ArgumentOutOfRangeException(nameof(addresses), + $"A memory batch is limited to {MemoryBatchLimits.MaximumOperations} operations."); + } + + Addresses = ImmutableArray.Create(addresses.ToArray()); + } + + /// Gets the copied target addresses in result order. + public ImmutableArray
Addresses + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteRequest.cs new file mode 100644 index 0000000..5e8d6ec --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryPrimitiveBatchWriteRequest.cs @@ -0,0 +1,34 @@ +using System.Collections.Immutable; + +namespace CheatEngine.Client.Memory; + +/// A copied, bounded request to write one homogeneous built-in scalar to multiple target addresses. +/// The built-in scalar or target-aware pointer type to write. +public readonly struct MemoryPrimitiveBatchWriteRequest +{ + /// Creates a bounded batch by copying every address/value pair. + /// The non-empty target writes to perform in order. + /// is empty. + /// exceeds the supported batch size. + public MemoryPrimitiveBatchWriteRequest(ReadOnlySpan> values) + { + if (values.IsEmpty) + { + throw new ArgumentException("A memory batch requires at least one value.", nameof(values)); + } + + if (values.Length > MemoryBatchLimits.MaximumOperations) + { + throw new ArgumentOutOfRangeException(nameof(values), + $"A memory batch is limited to {MemoryBatchLimits.MaximumOperations} operations."); + } + + Values = ImmutableArray.Create(values.ToArray()); + } + + /// Gets the copied address/value pairs in execution order. + public ImmutableArray> Values + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringEncoding.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringEncoding.cs new file mode 100644 index 0000000..c3142d6 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringEncoding.cs @@ -0,0 +1,11 @@ +namespace CheatEngine.Client.Memory; + +/// Identifies the target representation used by a bounded string operation. +public enum MemoryStringEncoding +{ + /// Uses the UTF-8 representation accepted by Cheat Engine's narrow string globals. + Utf8 = 0, + + /// Uses the UTF-16 representation accepted by Cheat Engine's wide string globals. + Utf16 = 1 +} diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs index 4d9b66f..59b2361 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringReadRequest.cs @@ -31,4 +31,27 @@ public bool WideCharacter { get; } + + /// Gets the explicit UTF-8 or UTF-16 target representation. + public MemoryStringEncoding Encoding => WideCharacter ? MemoryStringEncoding.Utf16 : MemoryStringEncoding.Utf8; + + /// Creates a bounded text read with an explicit target encoding. + /// The first target address. + /// The positive maximum length accepted by Cheat Engine. + /// The UTF-8 or UTF-16 target representation. + /// A request that preserves the supplied encoding choice. + public static MemoryStringReadRequest Create(Address address, int maximumLength, MemoryStringEncoding encoding) + { + return new MemoryStringReadRequest(address, maximumLength, ToWideCharacter(encoding)); + } + + private static bool ToWideCharacter(MemoryStringEncoding encoding) + { + return encoding switch + { + MemoryStringEncoding.Utf8 => false, + MemoryStringEncoding.Utf16 => true, + _ => throw new ArgumentOutOfRangeException(nameof(encoding)) + }; + } } diff --git a/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs index c7240a9..6e3bdf5 100644 --- a/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs +++ b/libs/CheatEngine.Client.Abstractions/Memory/MemoryStringWriteRequest.cs @@ -11,6 +11,22 @@ public MemoryStringWriteRequest(Address address, string value, bool wideCharacte ArgumentNullException.ThrowIfNull(value); Address = address; Value = value; + MaximumLength = 0; + WideCharacter = wideCharacter; + } + + private MemoryStringWriteRequest(Address address, string value, int maximumLength, bool wideCharacter) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumLength); + if (GetEncodedLength(value, wideCharacter) > maximumLength) + { + throw new ArgumentException("The encoded text exceeds the explicit maximum length.", nameof(value)); + } + + Address = address; + Value = value; + MaximumLength = maximumLength; WideCharacter = wideCharacter; } @@ -26,9 +42,45 @@ public string Value get; } + /// Gets the explicit encoded-length bound, or zero for the legacy unbounded constructor. + public int MaximumLength + { + get; + } + /// Gets whether Cheat Engine should write UTF-16 target text. public bool WideCharacter { get; } + + /// Gets the explicit UTF-8 or UTF-16 target representation. + public MemoryStringEncoding Encoding => WideCharacter ? MemoryStringEncoding.Utf16 : MemoryStringEncoding.Utf8; + + /// Creates a text write with an explicit maximum encoded length and target encoding. + /// The first target address. + /// The managed text to copy. + /// The positive maximum number of UTF-8 bytes or UTF-16 code units accepted. + /// The UTF-8 or UTF-16 target representation. + /// A request whose encoded text is bounded before it reaches Cheat Engine. + public static MemoryStringWriteRequest CreateBounded(Address address, string value, int maximumLength, + MemoryStringEncoding encoding) + { + return new MemoryStringWriteRequest(address, value, maximumLength, ToWideCharacter(encoding)); + } + + private static bool ToWideCharacter(MemoryStringEncoding encoding) + { + return encoding switch + { + MemoryStringEncoding.Utf8 => false, + MemoryStringEncoding.Utf16 => true, + _ => throw new ArgumentOutOfRangeException(nameof(encoding)) + }; + } + + private static int GetEncodedLength(string value, bool wideCharacter) + { + return wideCharacter ? value.Length : System.Text.Encoding.UTF8.GetByteCount(value); + } } diff --git a/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs b/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs index efa5018..f90e41c 100644 --- a/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs +++ b/libs/CheatEngine.Client.Abstractions/Processes/IProcessClient.cs @@ -6,6 +6,14 @@ namespace CheatEngine.Client.Processes; /// Reads the process selected by the active Cheat Engine session. public interface IProcessClient { + /// Tries to enumerate copied local-process metadata within an explicit materialization bound. + public bool TryGetProcesses(ProcessEnumerationRequest request, out ProcessEnumerationResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken = default); + + /// Enumerates copied local-process metadata within an explicit materialization bound. + public ProcessEnumerationResult GetProcesses(ProcessEnumerationRequest request, + CancellationToken cancellationToken = default); + /// Tries to get a copied snapshot of the currently selected target process. public bool TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure failure, CancellationToken cancellationToken = default); @@ -37,4 +45,41 @@ public bool TryAttachExactName(string processName, out ProcessSnapshot snapshot, /// Attaches to the single exact-name match or throws when none or several exist. public ProcessSnapshot AttachExactName(string processName, CancellationToken cancellationToken = default); + + /// Tries to attach Cheat Engine to the foreground process when the host exposes a validated binding. + public bool TryAttachForeground(out ProcessSnapshot snapshot, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Attaches Cheat Engine to the foreground process when the host exposes a validated binding. + public ProcessSnapshot AttachForeground(CancellationToken cancellationToken = default); + + /// + /// Tries to create and select an explicit process when the host exposes a validated create-and-attach binding. + /// + public bool TryCreate(ProcessStartRequest request, out ProcessSnapshot snapshot, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Creates and selects an explicit process when the host exposes a validated create-and-attach binding. + public ProcessSnapshot Create(ProcessStartRequest request, CancellationToken cancellationToken = default); + + /// Tries to pause the selected target when the host exposes a validated pause binding. + public bool TryPause(out ProcessSnapshot snapshot, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Pauses the selected target when the host exposes a validated pause binding. + public ProcessSnapshot Pause(CancellationToken cancellationToken = default); + + /// Tries to resume the selected target when the host exposes a validated resume binding. + public bool TryResumeExecution(out ProcessSnapshot snapshot, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Resumes the selected target when the host exposes a validated resume binding. + public ProcessSnapshot ResumeExecution(CancellationToken cancellationToken = default); + + /// Tries to observe the selected target's pause state when the host exposes a validated binding. + public bool TryGetPauseState(out ProcessPauseSnapshot snapshot, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Observes the selected target's pause state when the host exposes a validated binding. + public ProcessPauseSnapshot GetPauseState(CancellationToken cancellationToken = default); } diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationRequest.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationRequest.cs new file mode 100644 index 0000000..189c08e --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationRequest.cs @@ -0,0 +1,30 @@ +namespace CheatEngine.Client.Processes; + +/// Defines a bounded, copied local-process enumeration. +public readonly record struct ProcessEnumerationRequest +{ + /// Creates a bounded process enumeration request. + public ProcessEnumerationRequest(int maximumItems, string? nameContains = null) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maximumItems); + if (nameContains is { Length: 0 }) + { + throw new ArgumentException("A process-name filter must be null or non-empty.", nameof(nameContains)); + } + + MaximumItems = maximumItems; + NameContains = nameContains; + } + + /// Gets the maximum number of copied process records the caller permits. + public int MaximumItems + { + get; + } + + /// Gets the optional case-insensitive substring applied to process names. + public string? NameContains + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationResult.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationResult.cs new file mode 100644 index 0000000..5dc7a5f --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/ProcessEnumerationResult.cs @@ -0,0 +1,32 @@ +using System.Collections.Immutable; + +namespace CheatEngine.Client.Processes; + +/// A copied, bounded local-process enumeration result. +public readonly record struct ProcessEnumerationResult +{ + /// Creates a copied process enumeration result. + public ProcessEnumerationResult(ImmutableArray processes, bool isTruncated) + { + Processes = processes.IsDefault ? ImmutableArray.Empty : processes; + if (isTruncated && Processes.IsEmpty) + { + throw new ArgumentException("A truncated process result must retain at least one copied process.", + nameof(processes)); + } + + IsTruncated = isTruncated; + } + + /// Gets copied local-process metadata, ordered by process identifier. + public ImmutableArray Processes + { + get; + } + + /// Gets whether matching local processes were omitted at the caller's explicit limit. + public bool IsTruncated + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessInfoSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessInfoSnapshot.cs new file mode 100644 index 0000000..b8cb42c --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/ProcessInfoSnapshot.cs @@ -0,0 +1,43 @@ +using CheatEngine.SDK.Engine.Inspection; + +namespace CheatEngine.Client.Processes; + +/// Copied local-process metadata that is independent of Cheat Engine's selected target. +public readonly record struct ProcessInfoSnapshot +{ + /// Creates copied local-process metadata. + public ProcessInfoSnapshot(TargetProcessId id, string? name, string? executablePath) + { + if (name is { Length: 0 }) + { + throw new ArgumentException("A process name must be null or non-empty.", nameof(name)); + } + + if (executablePath is { Length: 0 }) + { + throw new ArgumentException("An executable path must be null or non-empty.", nameof(executablePath)); + } + + Id = id; + Name = name; + ExecutablePath = executablePath; + } + + /// Gets the local process identifier. + public TargetProcessId Id + { + get; + } + + /// Gets the display name when managed metadata could be read. + public string? Name + { + get; + } + + /// Gets the executable path when managed metadata could be read. + public string? ExecutablePath + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseSnapshot.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseSnapshot.cs new file mode 100644 index 0000000..a039637 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseSnapshot.cs @@ -0,0 +1,34 @@ +using CheatEngine.SDK.Engine.Inspection; + +namespace CheatEngine.Client.Processes; + +/// A copied pause-state observation tied to one selected-target epoch. +public readonly record struct ProcessPauseSnapshot +{ + /// Creates a target pause-state observation. + public ProcessPauseSnapshot(TargetProcessId processId, ProcessPauseState state, long selectionEpoch) + { + ArgumentOutOfRangeException.ThrowIfNegative(selectionEpoch); + ProcessId = processId; + State = state; + SelectionEpoch = selectionEpoch; + } + + /// Gets the selected process identifier observed with this state. + public TargetProcessId ProcessId + { + get; + } + + /// Gets the observed pause state. + public ProcessPauseState State + { + get; + } + + /// Gets the target-selection epoch associated with the observation. + public long SelectionEpoch + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseState.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseState.cs new file mode 100644 index 0000000..ca611fc --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/ProcessPauseState.cs @@ -0,0 +1,14 @@ +namespace CheatEngine.Client.Processes; + +/// Describes whether a selected process is paused. +public enum ProcessPauseState +{ + /// The host could not establish the state. + Unknown = 0, + + /// The target is executing. + Running = 1, + + /// The target is paused. + Paused = 2 +} diff --git a/libs/CheatEngine.Client.Abstractions/Processes/ProcessStartRequest.cs b/libs/CheatEngine.Client.Abstractions/Processes/ProcessStartRequest.cs new file mode 100644 index 0000000..3dd5356 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Processes/ProcessStartRequest.cs @@ -0,0 +1,43 @@ +namespace CheatEngine.Client.Processes; + +/// Describes an explicit executable launch that must also become the selected Cheat Engine target. +public readonly record struct ProcessStartRequest +{ + /// Creates an explicit process-launch request. + public ProcessStartRequest(string executablePath, string? arguments = null, string? workingDirectory = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + if (!Path.IsPathFullyQualified(executablePath)) + { + throw new ArgumentException("The executable path must be absolute.", nameof(executablePath)); + } + + if (workingDirectory is { } directory && !Path.IsPathFullyQualified(directory)) + { + throw new ArgumentException("The working directory must be absolute when specified.", + nameof(workingDirectory)); + } + + ExecutablePath = executablePath; + Arguments = arguments; + WorkingDirectory = workingDirectory; + } + + /// Gets the absolute executable path. + public string ExecutablePath + { + get; + } + + /// Gets the optional command-line arguments. + public string? Arguments + { + get; + } + + /// Gets the optional absolute working directory. + public string? WorkingDirectory + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt index 7dc5c58..5fc66a3 100644 --- a/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt +++ b/libs/CheatEngine.Client.Abstractions/PublicAPI.Unshipped.txt @@ -1 +1,529 @@ #nullable enable +CheatEngine.Client.ICheatEngineClient.Allocations.get -> CheatEngine.Client.Allocations.IAllocationClient! +CheatEngine.Client.ICheatEngineClient.Assembly.get -> CheatEngine.Client.Assembly.IAssemblyClient! +CheatEngine.Client.ICheatEngineClient.Dbvm.get -> CheatEngine.Client.Dbvm.IDbvmClient! +CheatEngine.Client.ICheatEngineClient.Debugger.get -> CheatEngine.Client.Debugger.IDebuggerClient! +CheatEngine.Client.ICheatEngineClient.Hashing.get -> CheatEngine.Client.Hashing.IHashingClient! +CheatEngine.Client.ICheatEngineClient.Hotkeys.get -> CheatEngine.Client.Hotkeys.IHotkeyClient! +CheatEngine.Client.ICheatEngineClient.RemoteExecution.get -> CheatEngine.Client.RemoteExecution.IRemoteExecutionClient! +CheatEngine.Client.ICheatEngineClient.Speed.get -> CheatEngine.Client.Speed.ISpeedClient! +CheatEngine.Client.ICheatEngineClient.Timers.get -> CheatEngine.Client.Timers.ITimerClient! +static CheatEngine.Client.Runtime.ClientCapabilityId.Allocations.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.Assembly.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.Dbvm.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.Debugger.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.Hashing.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.Hotkeys.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.RemoteExecution.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.Speed.get -> CheatEngine.Client.Runtime.ClientCapabilityId +static CheatEngine.Client.Runtime.ClientCapabilityId.Timers.get -> CheatEngine.Client.Runtime.ClientCapabilityId +CheatEngine.Client.Processes.IProcessClient.AttachForeground(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.Create(CheatEngine.Client.Processes.ProcessStartRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.GetPauseState(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessPauseSnapshot +CheatEngine.Client.Processes.IProcessClient.GetProcesses(CheatEngine.Client.Processes.ProcessEnumerationRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessEnumerationResult +CheatEngine.Client.Processes.IProcessClient.Pause(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.ResumeExecution(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Processes.ProcessSnapshot +CheatEngine.Client.Processes.IProcessClient.TryAttachForeground(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryCreate(CheatEngine.Client.Processes.ProcessStartRequest request, out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryGetPauseState(out CheatEngine.Client.Processes.ProcessPauseSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryGetProcesses(CheatEngine.Client.Processes.ProcessEnumerationRequest request, out CheatEngine.Client.Processes.ProcessEnumerationResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryPause(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.IProcessClient.TryResumeExecution(out CheatEngine.Client.Processes.ProcessSnapshot snapshot, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Processes.ProcessEnumerationRequest +CheatEngine.Client.Processes.ProcessEnumerationRequest.Equals(CheatEngine.Client.Processes.ProcessEnumerationRequest other) -> bool +CheatEngine.Client.Processes.ProcessEnumerationRequest.MaximumItems.get -> int +CheatEngine.Client.Processes.ProcessEnumerationRequest.NameContains.get -> string? +CheatEngine.Client.Processes.ProcessEnumerationRequest.ProcessEnumerationRequest() -> void +CheatEngine.Client.Processes.ProcessEnumerationRequest.ProcessEnumerationRequest(int maximumItems, string? nameContains = null) -> void +~override CheatEngine.Client.Processes.ProcessEnumerationRequest.Equals(object obj) -> bool +override CheatEngine.Client.Processes.ProcessEnumerationRequest.GetHashCode() -> int +~override CheatEngine.Client.Processes.ProcessEnumerationRequest.ToString() -> string +static CheatEngine.Client.Processes.ProcessEnumerationRequest.operator !=(CheatEngine.Client.Processes.ProcessEnumerationRequest left, CheatEngine.Client.Processes.ProcessEnumerationRequest right) -> bool +static CheatEngine.Client.Processes.ProcessEnumerationRequest.operator ==(CheatEngine.Client.Processes.ProcessEnumerationRequest left, CheatEngine.Client.Processes.ProcessEnumerationRequest right) -> bool +CheatEngine.Client.Processes.ProcessEnumerationResult +CheatEngine.Client.Processes.ProcessEnumerationResult.Equals(CheatEngine.Client.Processes.ProcessEnumerationResult other) -> bool +CheatEngine.Client.Processes.ProcessEnumerationResult.IsTruncated.get -> bool +CheatEngine.Client.Processes.ProcessEnumerationResult.ProcessEnumerationResult() -> void +CheatEngine.Client.Processes.ProcessEnumerationResult.ProcessEnumerationResult(System.Collections.Immutable.ImmutableArray processes, bool isTruncated) -> void +CheatEngine.Client.Processes.ProcessEnumerationResult.Processes.get -> System.Collections.Immutable.ImmutableArray +~override CheatEngine.Client.Processes.ProcessEnumerationResult.Equals(object obj) -> bool +override CheatEngine.Client.Processes.ProcessEnumerationResult.GetHashCode() -> int +~override CheatEngine.Client.Processes.ProcessEnumerationResult.ToString() -> string +static CheatEngine.Client.Processes.ProcessEnumerationResult.operator !=(CheatEngine.Client.Processes.ProcessEnumerationResult left, CheatEngine.Client.Processes.ProcessEnumerationResult right) -> bool +static CheatEngine.Client.Processes.ProcessEnumerationResult.operator ==(CheatEngine.Client.Processes.ProcessEnumerationResult left, CheatEngine.Client.Processes.ProcessEnumerationResult right) -> bool +CheatEngine.Client.Processes.ProcessInfoSnapshot +CheatEngine.Client.Processes.ProcessInfoSnapshot.Equals(CheatEngine.Client.Processes.ProcessInfoSnapshot other) -> bool +CheatEngine.Client.Processes.ProcessInfoSnapshot.ExecutablePath.get -> string? +CheatEngine.Client.Processes.ProcessInfoSnapshot.Id.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.Client.Processes.ProcessInfoSnapshot.Name.get -> string? +CheatEngine.Client.Processes.ProcessInfoSnapshot.ProcessInfoSnapshot() -> void +CheatEngine.Client.Processes.ProcessInfoSnapshot.ProcessInfoSnapshot(CheatEngine.SDK.Engine.Inspection.TargetProcessId id, string? name, string? executablePath) -> void +~override CheatEngine.Client.Processes.ProcessInfoSnapshot.Equals(object obj) -> bool +override CheatEngine.Client.Processes.ProcessInfoSnapshot.GetHashCode() -> int +~override CheatEngine.Client.Processes.ProcessInfoSnapshot.ToString() -> string +static CheatEngine.Client.Processes.ProcessInfoSnapshot.operator !=(CheatEngine.Client.Processes.ProcessInfoSnapshot left, CheatEngine.Client.Processes.ProcessInfoSnapshot right) -> bool +static CheatEngine.Client.Processes.ProcessInfoSnapshot.operator ==(CheatEngine.Client.Processes.ProcessInfoSnapshot left, CheatEngine.Client.Processes.ProcessInfoSnapshot right) -> bool +CheatEngine.Client.Processes.ProcessPauseSnapshot +CheatEngine.Client.Processes.ProcessPauseSnapshot.Equals(CheatEngine.Client.Processes.ProcessPauseSnapshot other) -> bool +CheatEngine.Client.Processes.ProcessPauseSnapshot.ProcessId.get -> CheatEngine.SDK.Engine.Inspection.TargetProcessId +CheatEngine.Client.Processes.ProcessPauseSnapshot.ProcessPauseSnapshot() -> void +CheatEngine.Client.Processes.ProcessPauseSnapshot.ProcessPauseSnapshot(CheatEngine.SDK.Engine.Inspection.TargetProcessId processId, CheatEngine.Client.Processes.ProcessPauseState state, long selectionEpoch) -> void +CheatEngine.Client.Processes.ProcessPauseSnapshot.SelectionEpoch.get -> long +CheatEngine.Client.Processes.ProcessPauseSnapshot.State.get -> CheatEngine.Client.Processes.ProcessPauseState +~override CheatEngine.Client.Processes.ProcessPauseSnapshot.Equals(object obj) -> bool +override CheatEngine.Client.Processes.ProcessPauseSnapshot.GetHashCode() -> int +~override CheatEngine.Client.Processes.ProcessPauseSnapshot.ToString() -> string +static CheatEngine.Client.Processes.ProcessPauseSnapshot.operator !=(CheatEngine.Client.Processes.ProcessPauseSnapshot left, CheatEngine.Client.Processes.ProcessPauseSnapshot right) -> bool +static CheatEngine.Client.Processes.ProcessPauseSnapshot.operator ==(CheatEngine.Client.Processes.ProcessPauseSnapshot left, CheatEngine.Client.Processes.ProcessPauseSnapshot right) -> bool +CheatEngine.Client.Processes.ProcessPauseState +CheatEngine.Client.Processes.ProcessPauseState.Paused = 2 -> CheatEngine.Client.Processes.ProcessPauseState +CheatEngine.Client.Processes.ProcessPauseState.Running = 1 -> CheatEngine.Client.Processes.ProcessPauseState +CheatEngine.Client.Processes.ProcessPauseState.Unknown = 0 -> CheatEngine.Client.Processes.ProcessPauseState +CheatEngine.Client.Processes.ProcessStartRequest +CheatEngine.Client.Processes.ProcessStartRequest.Arguments.get -> string? +CheatEngine.Client.Processes.ProcessStartRequest.Equals(CheatEngine.Client.Processes.ProcessStartRequest other) -> bool +CheatEngine.Client.Processes.ProcessStartRequest.ExecutablePath.get -> string! +CheatEngine.Client.Processes.ProcessStartRequest.ProcessStartRequest() -> void +CheatEngine.Client.Processes.ProcessStartRequest.ProcessStartRequest(string! executablePath, string? arguments = null, string? workingDirectory = null) -> void +CheatEngine.Client.Processes.ProcessStartRequest.WorkingDirectory.get -> string? +~override CheatEngine.Client.Processes.ProcessStartRequest.Equals(object obj) -> bool +override CheatEngine.Client.Processes.ProcessStartRequest.GetHashCode() -> int +~override CheatEngine.Client.Processes.ProcessStartRequest.ToString() -> string +static CheatEngine.Client.Processes.ProcessStartRequest.operator !=(CheatEngine.Client.Processes.ProcessStartRequest left, CheatEngine.Client.Processes.ProcessStartRequest right) -> bool +static CheatEngine.Client.Processes.ProcessStartRequest.operator ==(CheatEngine.Client.Processes.ProcessStartRequest left, CheatEngine.Client.Processes.ProcessStartRequest right) -> bool +CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute +CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute.BindingsType.get -> System.Type! +CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute.CheatEngineLuaModuleAttribute(System.Type! bindingsType, string? name = null) -> void +CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute.Name.get -> string? +CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute +CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute.CheatEngineLuaOperationAttribute() -> void +CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute.CheatEngineLuaOperationAttribute(System.Type! mapperType) -> void +CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute.MapperType.get -> System.Type? +CheatEngine.Client.Lua.IDescribedLuaModule +CheatEngine.Client.Lua.IDescribedLuaModule.Descriptor.get -> CheatEngine.Client.Lua.LuaModuleDescriptor +CheatEngine.Client.Lua.ILuaClient.Execute(TOperation operation, System.Threading.CancellationToken cancellationToken) -> TResult +CheatEngine.Client.Lua.ILuaClient.TryExecute(TOperation operation, out TResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken) -> bool +CheatEngine.Client.Lua.ILuaResultMapper +CheatEngine.Client.Lua.ILuaResultMapper.Map(TSource source) -> TResult +CheatEngine.Client.Lua.LuaExportDescriptor +CheatEngine.Client.Lua.LuaExportDescriptor.Equals(CheatEngine.Client.Lua.LuaExportDescriptor other) -> bool +CheatEngine.Client.Lua.LuaExportDescriptor.LuaExportDescriptor() -> void +CheatEngine.Client.Lua.LuaExportDescriptor.LuaExportDescriptor(string! name) -> void +CheatEngine.Client.Lua.LuaExportDescriptor.Name.get -> string! +CheatEngine.Client.Lua.LuaModuleDescriptor +CheatEngine.Client.Lua.LuaModuleDescriptor.Equals(CheatEngine.Client.Lua.LuaModuleDescriptor other) -> bool +CheatEngine.Client.Lua.LuaModuleDescriptor.Exports.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Lua.LuaModuleDescriptor.LuaModuleDescriptor() -> void +CheatEngine.Client.Lua.LuaModuleDescriptor.LuaModuleDescriptor(string! name, System.Collections.Immutable.ImmutableArray exports) -> void +CheatEngine.Client.Lua.LuaModuleDescriptor.Name.get -> string! +~override CheatEngine.Client.Lua.LuaExportDescriptor.Equals(object obj) -> bool +~override CheatEngine.Client.Lua.LuaExportDescriptor.ToString() -> string +~override CheatEngine.Client.Lua.LuaModuleDescriptor.Equals(object obj) -> bool +~override CheatEngine.Client.Lua.LuaModuleDescriptor.ToString() -> string +override CheatEngine.Client.Lua.LuaExportDescriptor.GetHashCode() -> int +override CheatEngine.Client.Lua.LuaModuleDescriptor.GetHashCode() -> int +static CheatEngine.Client.Lua.LuaExportDescriptor.operator !=(CheatEngine.Client.Lua.LuaExportDescriptor left, CheatEngine.Client.Lua.LuaExportDescriptor right) -> bool +static CheatEngine.Client.Lua.LuaExportDescriptor.operator ==(CheatEngine.Client.Lua.LuaExportDescriptor left, CheatEngine.Client.Lua.LuaExportDescriptor right) -> bool +static CheatEngine.Client.Lua.LuaModuleDescriptor.operator !=(CheatEngine.Client.Lua.LuaModuleDescriptor left, CheatEngine.Client.Lua.LuaModuleDescriptor right) -> bool +CheatEngine.Client.Allocations.IAllocationClient +CheatEngine.Client.Allocations.IAllocationClient.Allocate(CheatEngine.Client.Allocations.TargetAllocationRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Allocations.ITargetMemoryLease! +CheatEngine.Client.Allocations.IAllocationClient.TryAllocate(CheatEngine.Client.Allocations.TargetAllocationRequest request, out CheatEngine.Client.Allocations.ITargetMemoryLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Allocations.ITargetMemoryLease +CheatEngine.Client.Allocations.ITargetMemoryLease.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Allocations.ITargetMemoryLease.IsReleased.get -> bool +CheatEngine.Client.Allocations.ITargetMemoryLease.SelectionEpoch.get -> long +CheatEngine.Client.Allocations.ITargetMemoryLease.Size.get -> long +CheatEngine.Client.Allocations.TargetAllocationAccess +CheatEngine.Client.Allocations.TargetAllocationAccess.ExecuteReadWrite = 1 -> CheatEngine.Client.Allocations.TargetAllocationAccess +CheatEngine.Client.Allocations.TargetAllocationAccess.ReadWrite = 0 -> CheatEngine.Client.Allocations.TargetAllocationAccess +CheatEngine.Client.Allocations.TargetAllocationRequest +CheatEngine.Client.Allocations.TargetAllocationRequest.Access.get -> CheatEngine.Client.Allocations.TargetAllocationAccess +CheatEngine.Client.Allocations.TargetAllocationRequest.Equals(CheatEngine.Client.Allocations.TargetAllocationRequest other) -> bool +CheatEngine.Client.Allocations.TargetAllocationRequest.Size.get -> long +CheatEngine.Client.Allocations.TargetAllocationRequest.TargetAllocationRequest() -> void +CheatEngine.Client.Allocations.TargetAllocationRequest.TargetAllocationRequest(long size, CheatEngine.Client.Allocations.TargetAllocationAccess access = CheatEngine.Client.Allocations.TargetAllocationAccess.ReadWrite) -> void +CheatEngine.Client.Assembly.AssemblyInstructionRequest +CheatEngine.Client.Assembly.AssemblyInstructionRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Assembly.AssemblyInstructionRequest.AssemblyInstructionRequest() -> void +CheatEngine.Client.Assembly.AssemblyInstructionRequest.AssemblyInstructionRequest(CheatEngine.SDK.Engine.Values.Address address, string! instruction) -> void +CheatEngine.Client.Assembly.AssemblyInstructionRequest.Equals(CheatEngine.Client.Assembly.AssemblyInstructionRequest other) -> bool +CheatEngine.Client.Assembly.AssemblyInstructionRequest.Instruction.get -> string! +CheatEngine.Client.Assembly.AssemblyInstructionSnapshot +CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.AssemblyInstructionSnapshot() -> void +CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.AssemblyInstructionSnapshot(CheatEngine.SDK.Engine.Values.Address address, int length, string! text, System.ReadOnlySpan bytes) -> void +CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Bytes.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Equals(CheatEngine.Client.Assembly.AssemblyInstructionSnapshot other) -> bool +CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Length.get -> int +CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Text.get -> string! +CheatEngine.Client.Assembly.AutoAssemblerScript +CheatEngine.Client.Assembly.AutoAssemblerScript.AutoAssemblerScript() -> void +CheatEngine.Client.Assembly.AutoAssemblerScript.AutoAssemblerScript(string! source, string? name = null) -> void +CheatEngine.Client.Assembly.AutoAssemblerScript.Equals(CheatEngine.Client.Assembly.AutoAssemblerScript other) -> bool +CheatEngine.Client.Assembly.AutoAssemblerScript.Name.get -> string? +CheatEngine.Client.Assembly.AutoAssemblerScript.Source.get -> string! +CheatEngine.Client.Assembly.IAssemblyClient +CheatEngine.Client.Assembly.IAssemblyClient.ApplyPatch(CheatEngine.Client.Assembly.AutoAssemblerScript script, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Assembly.IAutoAssemblerPatchLease! +CheatEngine.Client.Assembly.IAssemblyClient.Assemble(CheatEngine.Client.Assembly.AssemblyInstructionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Assembly.IAssemblyClient.Disassemble(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Assembly.AssemblyInstructionSnapshot +CheatEngine.Client.Assembly.IAssemblyClient.GetComment(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! +CheatEngine.Client.Assembly.IAssemblyClient.GetInstructionSize(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> int +CheatEngine.Client.Assembly.IAssemblyClient.GetPreviousInstruction(CheatEngine.SDK.Engine.Values.Address address, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Assembly.IAssemblyClient.TryApplyPatch(CheatEngine.Client.Assembly.AutoAssemblerScript script, out CheatEngine.Client.Assembly.IAutoAssemblerPatchLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Assembly.IAssemblyClient.TryAssemble(CheatEngine.Client.Assembly.AssemblyInstructionRequest request, out System.Collections.Immutable.ImmutableArray bytes, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Assembly.IAssemblyClient.TryDisassemble(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Assembly.AssemblyInstructionSnapshot instruction, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Assembly.IAssemblyClient.TryGetComment(CheatEngine.SDK.Engine.Values.Address address, out string? comment, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Assembly.IAssemblyClient.TryGetInstructionSize(CheatEngine.SDK.Engine.Values.Address address, out int size, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Assembly.IAssemblyClient.TryGetPreviousInstruction(CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.SDK.Engine.Values.Address previousAddress, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Assembly.IAutoAssemblerPatchLease +CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.IsReleased.get -> bool +CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.Name.get -> string? +CheatEngine.Client.Assembly.IAutoAssemblerPatchLease.SelectionEpoch.get -> long +CheatEngine.Client.Dbvm.DbvmInitializationRequest +CheatEngine.Client.Dbvm.DbvmInitializationRequest.DbvmInitializationRequest() -> void +CheatEngine.Client.Dbvm.DbvmInitializationRequest.DbvmInitializationRequest(bool useStealthMode = false) -> void +CheatEngine.Client.Dbvm.DbvmInitializationRequest.Equals(CheatEngine.Client.Dbvm.DbvmInitializationRequest other) -> bool +CheatEngine.Client.Dbvm.DbvmInitializationRequest.UseStealthMode.get -> bool +CheatEngine.Client.Dbvm.DbvmState +CheatEngine.Client.Dbvm.DbvmState.Initialized = 3 -> CheatEngine.Client.Dbvm.DbvmState +CheatEngine.Client.Dbvm.DbvmState.NotInitialized = 2 -> CheatEngine.Client.Dbvm.DbvmState +CheatEngine.Client.Dbvm.DbvmState.Unavailable = 1 -> CheatEngine.Client.Dbvm.DbvmState +CheatEngine.Client.Dbvm.DbvmState.Unknown = 0 -> CheatEngine.Client.Dbvm.DbvmState +CheatEngine.Client.Dbvm.DbvmStatusSnapshot +CheatEngine.Client.Dbvm.DbvmStatusSnapshot.DbvmStatusSnapshot() -> void +CheatEngine.Client.Dbvm.DbvmStatusSnapshot.DbvmStatusSnapshot(CheatEngine.Client.Dbvm.DbvmState state, string? version = null) -> void +CheatEngine.Client.Dbvm.DbvmStatusSnapshot.Equals(CheatEngine.Client.Dbvm.DbvmStatusSnapshot other) -> bool +CheatEngine.Client.Dbvm.DbvmStatusSnapshot.State.get -> CheatEngine.Client.Dbvm.DbvmState +CheatEngine.Client.Dbvm.DbvmStatusSnapshot.Version.get -> string? +CheatEngine.Client.Dbvm.DbvmWatchEvent +CheatEngine.Client.Dbvm.DbvmWatchEvent.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Dbvm.DbvmWatchEvent.DbvmWatchEvent() -> void +CheatEngine.Client.Dbvm.DbvmWatchEvent.DbvmWatchEvent(CheatEngine.SDK.Engine.Values.Address address, int length, System.DateTimeOffset occurredAt) -> void +CheatEngine.Client.Dbvm.DbvmWatchEvent.Equals(CheatEngine.Client.Dbvm.DbvmWatchEvent other) -> bool +CheatEngine.Client.Dbvm.DbvmWatchEvent.Length.get -> int +CheatEngine.Client.Dbvm.DbvmWatchEvent.OccurredAt.get -> System.DateTimeOffset +CheatEngine.Client.Dbvm.DbvmWatchHandler +CheatEngine.Client.Dbvm.DbvmWatchRequest +CheatEngine.Client.Dbvm.DbvmWatchRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Dbvm.DbvmWatchRequest.DbvmWatchRequest() -> void +CheatEngine.Client.Dbvm.DbvmWatchRequest.DbvmWatchRequest(CheatEngine.SDK.Engine.Values.Address address, int length) -> void +CheatEngine.Client.Dbvm.DbvmWatchRequest.Equals(CheatEngine.Client.Dbvm.DbvmWatchRequest other) -> bool +CheatEngine.Client.Dbvm.DbvmWatchRequest.Length.get -> int +CheatEngine.Client.Dbvm.IDbvmClient +CheatEngine.Client.Dbvm.IDbvmClient.GetStatus(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Dbvm.DbvmStatusSnapshot +CheatEngine.Client.Dbvm.IDbvmClient.Initialize(CheatEngine.Client.Dbvm.DbvmInitializationRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Dbvm.DbvmStatusSnapshot +CheatEngine.Client.Dbvm.IDbvmClient.RegisterWatch(CheatEngine.Client.Dbvm.DbvmWatchRequest request, CheatEngine.Client.Dbvm.DbvmWatchHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Dbvm.IDbvmWatchLease! +CheatEngine.Client.Dbvm.IDbvmClient.TryGetStatus(out CheatEngine.Client.Dbvm.DbvmStatusSnapshot status, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Dbvm.IDbvmClient.TryInitialize(CheatEngine.Client.Dbvm.DbvmInitializationRequest request, out CheatEngine.Client.Dbvm.DbvmStatusSnapshot status, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Dbvm.IDbvmClient.TryRegisterWatch(CheatEngine.Client.Dbvm.DbvmWatchRequest request, CheatEngine.Client.Dbvm.DbvmWatchHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, out CheatEngine.Client.Dbvm.IDbvmWatchLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Dbvm.IDbvmWatchLease +CheatEngine.Client.Dbvm.IDbvmWatchLease.Request.get -> CheatEngine.Client.Dbvm.DbvmWatchRequest +CheatEngine.Client.Dbvm.IDbvmWatchLease.SelectionEpoch.get -> long +CheatEngine.Client.Debugger.BreakpointDisposition +CheatEngine.Client.Debugger.BreakpointDisposition.Break = 1 -> CheatEngine.Client.Debugger.BreakpointDisposition +CheatEngine.Client.Debugger.BreakpointDisposition.Continue = 0 -> CheatEngine.Client.Debugger.BreakpointDisposition +CheatEngine.Client.Debugger.BreakpointEvent +CheatEngine.Client.Debugger.BreakpointEvent.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Debugger.BreakpointEvent.BreakpointEvent() -> void +CheatEngine.Client.Debugger.BreakpointEvent.BreakpointEvent(CheatEngine.SDK.Engine.Values.Address address, int threadId, System.Collections.Immutable.ImmutableArray registers) -> void +CheatEngine.Client.Debugger.BreakpointEvent.Equals(CheatEngine.Client.Debugger.BreakpointEvent other) -> bool +CheatEngine.Client.Debugger.BreakpointEvent.Registers.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Debugger.BreakpointEvent.ThreadId.get -> int +CheatEngine.Client.Debugger.BreakpointHandler +CheatEngine.Client.Debugger.BreakpointKind +CheatEngine.Client.Debugger.BreakpointKind.Access = 2 -> CheatEngine.Client.Debugger.BreakpointKind +CheatEngine.Client.Debugger.BreakpointKind.Execute = 0 -> CheatEngine.Client.Debugger.BreakpointKind +CheatEngine.Client.Debugger.BreakpointKind.Write = 1 -> CheatEngine.Client.Debugger.BreakpointKind +CheatEngine.Client.Debugger.BreakpointRegisterSnapshot +CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.BreakpointRegisterSnapshot() -> void +CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.BreakpointRegisterSnapshot(string! name, ulong value) -> void +CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.Equals(CheatEngine.Client.Debugger.BreakpointRegisterSnapshot other) -> bool +CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.Name.get -> string! +CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.Value.get -> ulong +CheatEngine.Client.Debugger.BreakpointRequest +CheatEngine.Client.Debugger.BreakpointRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Debugger.BreakpointRequest.BreakpointRequest() -> void +CheatEngine.Client.Debugger.BreakpointRequest.BreakpointRequest(CheatEngine.SDK.Engine.Values.Address address, CheatEngine.Client.Debugger.BreakpointKind kind = CheatEngine.Client.Debugger.BreakpointKind.Execute) -> void +CheatEngine.Client.Debugger.BreakpointRequest.Equals(CheatEngine.Client.Debugger.BreakpointRequest other) -> bool +CheatEngine.Client.Debugger.BreakpointRequest.Kind.get -> CheatEngine.Client.Debugger.BreakpointKind +CheatEngine.Client.Debugger.IBreakpointLease +CheatEngine.Client.Debugger.IBreakpointLease.Request.get -> CheatEngine.Client.Debugger.BreakpointRequest +CheatEngine.Client.Debugger.IBreakpointLease.SelectionEpoch.get -> long +CheatEngine.Client.Debugger.IDebuggerClient +CheatEngine.Client.Debugger.IDebuggerClient.RegisterBreakpoint(CheatEngine.Client.Debugger.BreakpointRequest request, CheatEngine.Client.Debugger.BreakpointHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Debugger.IBreakpointLease! +CheatEngine.Client.Debugger.IDebuggerClient.TryRegisterBreakpoint(CheatEngine.Client.Debugger.BreakpointRequest request, CheatEngine.Client.Debugger.BreakpointHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, out CheatEngine.Client.Debugger.IBreakpointLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Events.EventStreamOptions +CheatEngine.Client.Events.EventStreamOptions.Capacity.get -> int +CheatEngine.Client.Events.EventStreamOptions.Equals(CheatEngine.Client.Events.EventStreamOptions other) -> bool +CheatEngine.Client.Events.EventStreamOptions.EventStreamOptions() -> void +CheatEngine.Client.Events.EventStreamOptions.EventStreamOptions(int capacity, CheatEngine.Client.Events.EventStreamOverflowPolicy overflowPolicy = CheatEngine.Client.Events.EventStreamOverflowPolicy.DropOldest) -> void +CheatEngine.Client.Events.EventStreamOptions.OverflowPolicy.get -> CheatEngine.Client.Events.EventStreamOverflowPolicy +CheatEngine.Client.Events.EventStreamOverflowPolicy +CheatEngine.Client.Events.EventStreamOverflowPolicy.DropNewest = 1 -> CheatEngine.Client.Events.EventStreamOverflowPolicy +CheatEngine.Client.Events.EventStreamOverflowPolicy.DropOldest = 0 -> CheatEngine.Client.Events.EventStreamOverflowPolicy +CheatEngine.Client.Events.EventStreamOverflowPolicy.FailSubscription = 2 -> CheatEngine.Client.Events.EventStreamOverflowPolicy +CheatEngine.Client.Events.IEventStreamLease +CheatEngine.Client.Events.IEventStreamLease.DroppedEventCount.get -> long +CheatEngine.Client.Events.IEventStreamLease.Events.get -> System.Collections.Generic.IAsyncEnumerable! +CheatEngine.Client.Events.IEventStreamLease.IsReleased.get -> bool +CheatEngine.Client.Hashing.FileHashRequest +CheatEngine.Client.Hashing.FileHashRequest.Algorithm.get -> CheatEngine.Client.Hashing.TargetHashAlgorithm +CheatEngine.Client.Hashing.FileHashRequest.Equals(CheatEngine.Client.Hashing.FileHashRequest other) -> bool +CheatEngine.Client.Hashing.FileHashRequest.FileHashRequest() -> void +CheatEngine.Client.Hashing.FileHashRequest.FileHashRequest(string! filePath, CheatEngine.Client.Hashing.TargetHashAlgorithm algorithm = CheatEngine.Client.Hashing.TargetHashAlgorithm.Sha256) -> void +CheatEngine.Client.Hashing.FileHashRequest.FilePath.get -> string! +CheatEngine.Client.Hashing.HashDigest +CheatEngine.Client.Hashing.HashDigest.Algorithm.get -> CheatEngine.Client.Hashing.TargetHashAlgorithm +CheatEngine.Client.Hashing.HashDigest.Equals(CheatEngine.Client.Hashing.HashDigest other) -> bool +CheatEngine.Client.Hashing.HashDigest.HashDigest() -> void +CheatEngine.Client.Hashing.HashDigest.HashDigest(CheatEngine.Client.Hashing.TargetHashAlgorithm algorithm, string! value) -> void +CheatEngine.Client.Hashing.HashDigest.Value.get -> string! +CheatEngine.Client.Hashing.IHashingClient +CheatEngine.Client.Hashing.IHashingClient.HashFile(CheatEngine.Client.Hashing.FileHashRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Hashing.HashDigest +CheatEngine.Client.Hashing.IHashingClient.HashMemory(CheatEngine.Client.Hashing.MemoryHashRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Hashing.HashDigest +CheatEngine.Client.Hashing.IHashingClient.TryHashFile(CheatEngine.Client.Hashing.FileHashRequest request, out CheatEngine.Client.Hashing.HashDigest digest, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Hashing.IHashingClient.TryHashMemory(CheatEngine.Client.Hashing.MemoryHashRequest request, out CheatEngine.Client.Hashing.HashDigest digest, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Hashing.MemoryHashRequest +CheatEngine.Client.Hashing.MemoryHashRequest.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Hashing.MemoryHashRequest.Algorithm.get -> CheatEngine.Client.Hashing.TargetHashAlgorithm +CheatEngine.Client.Hashing.MemoryHashRequest.Equals(CheatEngine.Client.Hashing.MemoryHashRequest other) -> bool +CheatEngine.Client.Hashing.MemoryHashRequest.Length.get -> int +CheatEngine.Client.Hashing.MemoryHashRequest.MemoryHashRequest() -> void +CheatEngine.Client.Hashing.MemoryHashRequest.MemoryHashRequest(CheatEngine.SDK.Engine.Values.Address address, int length, CheatEngine.Client.Hashing.TargetHashAlgorithm algorithm = CheatEngine.Client.Hashing.TargetHashAlgorithm.Sha256) -> void +CheatEngine.Client.Hashing.TargetHashAlgorithm +CheatEngine.Client.Hashing.TargetHashAlgorithm.Md5 = 0 -> CheatEngine.Client.Hashing.TargetHashAlgorithm +CheatEngine.Client.Hashing.TargetHashAlgorithm.Sha1 = 1 -> CheatEngine.Client.Hashing.TargetHashAlgorithm +CheatEngine.Client.Hashing.TargetHashAlgorithm.Sha256 = 2 -> CheatEngine.Client.Hashing.TargetHashAlgorithm +CheatEngine.Client.Hotkeys.HotkeyEvent +CheatEngine.Client.Hotkeys.HotkeyEvent.Equals(CheatEngine.Client.Hotkeys.HotkeyEvent other) -> bool +CheatEngine.Client.Hotkeys.HotkeyEvent.Gesture.get -> CheatEngine.Client.Hotkeys.HotkeyGesture +CheatEngine.Client.Hotkeys.HotkeyEvent.HotkeyEvent() -> void +CheatEngine.Client.Hotkeys.HotkeyEvent.HotkeyEvent(string! name, CheatEngine.Client.Hotkeys.HotkeyGesture gesture, System.DateTimeOffset occurredAt) -> void +CheatEngine.Client.Hotkeys.HotkeyEvent.Name.get -> string! +CheatEngine.Client.Hotkeys.HotkeyEvent.OccurredAt.get -> System.DateTimeOffset +CheatEngine.Client.Hotkeys.HotkeyGesture +CheatEngine.Client.Hotkeys.HotkeyGesture.Equals(CheatEngine.Client.Hotkeys.HotkeyGesture other) -> bool +CheatEngine.Client.Hotkeys.HotkeyGesture.HotkeyGesture() -> void +CheatEngine.Client.Hotkeys.HotkeyGesture.HotkeyGesture(int virtualKey, CheatEngine.Client.Hotkeys.HotkeyModifiers modifiers = CheatEngine.Client.Hotkeys.HotkeyModifiers.None) -> void +CheatEngine.Client.Hotkeys.HotkeyGesture.Modifiers.get -> CheatEngine.Client.Hotkeys.HotkeyModifiers +CheatEngine.Client.Hotkeys.HotkeyGesture.VirtualKey.get -> int +CheatEngine.Client.Hotkeys.HotkeyHandler +CheatEngine.Client.Hotkeys.HotkeyModifiers +CheatEngine.Client.Hotkeys.HotkeyModifiers.Alt = 1 -> CheatEngine.Client.Hotkeys.HotkeyModifiers +CheatEngine.Client.Hotkeys.HotkeyModifiers.Control = 2 -> CheatEngine.Client.Hotkeys.HotkeyModifiers +CheatEngine.Client.Hotkeys.HotkeyModifiers.None = 0 -> CheatEngine.Client.Hotkeys.HotkeyModifiers +CheatEngine.Client.Hotkeys.HotkeyModifiers.Shift = 4 -> CheatEngine.Client.Hotkeys.HotkeyModifiers +CheatEngine.Client.Hotkeys.HotkeyModifiers.Windows = 8 -> CheatEngine.Client.Hotkeys.HotkeyModifiers +CheatEngine.Client.Hotkeys.HotkeyRegistration +CheatEngine.Client.Hotkeys.HotkeyRegistration.Equals(CheatEngine.Client.Hotkeys.HotkeyRegistration other) -> bool +CheatEngine.Client.Hotkeys.HotkeyRegistration.Gesture.get -> CheatEngine.Client.Hotkeys.HotkeyGesture +CheatEngine.Client.Hotkeys.HotkeyRegistration.HotkeyRegistration() -> void +CheatEngine.Client.Hotkeys.HotkeyRegistration.HotkeyRegistration(string! name, CheatEngine.Client.Hotkeys.HotkeyGesture gesture) -> void +CheatEngine.Client.Hotkeys.HotkeyRegistration.Name.get -> string! +CheatEngine.Client.Hotkeys.IHotkeyClient +CheatEngine.Client.Hotkeys.IHotkeyClient.Register(CheatEngine.Client.Hotkeys.HotkeyRegistration registration, CheatEngine.Client.Hotkeys.HotkeyHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Hotkeys.IHotkeyLease! +CheatEngine.Client.Hotkeys.IHotkeyClient.TryRegister(CheatEngine.Client.Hotkeys.HotkeyRegistration registration, CheatEngine.Client.Hotkeys.HotkeyHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, out CheatEngine.Client.Hotkeys.IHotkeyLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Hotkeys.IHotkeyLease +CheatEngine.Client.Hotkeys.IHotkeyLease.Registration.get -> CheatEngine.Client.Hotkeys.HotkeyRegistration +CheatEngine.Client.RemoteExecution.IRemoteExecutionClient +CheatEngine.Client.RemoteExecution.IRemoteExecutionClient.InjectLibrary(CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.RemoteExecution.IRemoteExecutionClient.Invoke(CheatEngine.Client.RemoteExecution.RemoteCallRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.RemoteExecution.RemoteCallResult +CheatEngine.Client.RemoteExecution.IRemoteExecutionClient.TryInjectLibrary(CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.RemoteExecution.IRemoteExecutionClient.TryInvoke(CheatEngine.Client.RemoteExecution.RemoteCallRequest request, out CheatEngine.Client.RemoteExecution.RemoteCallResult result, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.RemoteExecution.RemoteCallRequest +CheatEngine.Client.RemoteExecution.RemoteCallRequest.EntryPoint.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.RemoteExecution.RemoteCallRequest.Equals(CheatEngine.Client.RemoteExecution.RemoteCallRequest other) -> bool +CheatEngine.Client.RemoteExecution.RemoteCallRequest.Parameters.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.RemoteExecution.RemoteCallRequest.RemoteCallRequest() -> void +CheatEngine.Client.RemoteExecution.RemoteCallRequest.RemoteCallRequest(CheatEngine.SDK.Engine.Values.Address entryPoint, System.ReadOnlySpan parameters, System.TimeSpan timeout) -> void +CheatEngine.Client.RemoteExecution.RemoteCallRequest.Timeout.get -> System.TimeSpan +CheatEngine.Client.RemoteExecution.RemoteCallResult +CheatEngine.Client.RemoteExecution.RemoteCallResult.Equals(CheatEngine.Client.RemoteExecution.RemoteCallResult other) -> bool +CheatEngine.Client.RemoteExecution.RemoteCallResult.Output.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.RemoteExecution.RemoteCallResult.RemoteCallResult() -> void +CheatEngine.Client.RemoteExecution.RemoteCallResult.RemoteCallResult(ulong returnValue, System.ReadOnlySpan output) -> void +CheatEngine.Client.RemoteExecution.RemoteCallResult.ReturnValue.get -> ulong +CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest +CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.Equals(CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest other) -> bool +CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.LibraryPath.get -> string! +CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.RemoteDllInjectionRequest() -> void +CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.RemoteDllInjectionRequest(string! libraryPath) -> void +CheatEngine.Client.Speed.ISpeedClient +CheatEngine.Client.Speed.ISpeedClient.GetMultiplier(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Speed.SpeedMultiplier +CheatEngine.Client.Speed.ISpeedClient.SetMultiplier(CheatEngine.Client.Speed.SpeedMultiplier multiplier, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Speed.ISpeedClient.TryGetMultiplier(out CheatEngine.Client.Speed.SpeedMultiplier multiplier, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Speed.ISpeedClient.TrySetMultiplier(CheatEngine.Client.Speed.SpeedMultiplier multiplier, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Speed.SpeedMultiplier +CheatEngine.Client.Speed.SpeedMultiplier.Equals(CheatEngine.Client.Speed.SpeedMultiplier other) -> bool +CheatEngine.Client.Speed.SpeedMultiplier.SpeedMultiplier() -> void +CheatEngine.Client.Speed.SpeedMultiplier.SpeedMultiplier(double value) -> void +CheatEngine.Client.Speed.SpeedMultiplier.Value.get -> double +CheatEngine.Client.Timers.ITimerClient +CheatEngine.Client.Timers.ITimerClient.Register(CheatEngine.Client.Timers.TimerRequest request, CheatEngine.Client.Timers.TimerHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.Client.Timers.ITimerLease! +CheatEngine.Client.Timers.ITimerClient.TryRegister(CheatEngine.Client.Timers.TimerRequest request, CheatEngine.Client.Timers.TimerHandler! handler, CheatEngine.Client.Events.EventStreamOptions streamOptions, out CheatEngine.Client.Timers.ITimerLease? lease, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Timers.ITimerLease +CheatEngine.Client.Timers.ITimerLease.Request.get -> CheatEngine.Client.Timers.TimerRequest +CheatEngine.Client.Timers.TimerHandler +CheatEngine.Client.Timers.TimerRequest +CheatEngine.Client.Timers.TimerRequest.Equals(CheatEngine.Client.Timers.TimerRequest other) -> bool +CheatEngine.Client.Timers.TimerRequest.Interval.get -> System.TimeSpan +CheatEngine.Client.Timers.TimerRequest.TimerRequest() -> void +CheatEngine.Client.Timers.TimerRequest.TimerRequest(System.TimeSpan interval) -> void +CheatEngine.Client.Timers.TimerTick +CheatEngine.Client.Timers.TimerTick.Equals(CheatEngine.Client.Timers.TimerTick other) -> bool +CheatEngine.Client.Timers.TimerTick.OccurredAt.get -> System.DateTimeOffset +CheatEngine.Client.Timers.TimerTick.Sequence.get -> long +CheatEngine.Client.Timers.TimerTick.TimerTick() -> void +CheatEngine.Client.Timers.TimerTick.TimerTick(long sequence, System.DateTimeOffset occurredAt) -> void +~override CheatEngine.Client.Allocations.TargetAllocationRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Allocations.TargetAllocationRequest.ToString() -> string +~override CheatEngine.Client.Assembly.AssemblyInstructionRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Assembly.AssemblyInstructionRequest.ToString() -> string +~override CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.ToString() -> string +~override CheatEngine.Client.Assembly.AutoAssemblerScript.Equals(object obj) -> bool +~override CheatEngine.Client.Assembly.AutoAssemblerScript.ToString() -> string +~override CheatEngine.Client.Dbvm.DbvmInitializationRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Dbvm.DbvmInitializationRequest.ToString() -> string +~override CheatEngine.Client.Dbvm.DbvmStatusSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Dbvm.DbvmStatusSnapshot.ToString() -> string +~override CheatEngine.Client.Dbvm.DbvmWatchEvent.Equals(object obj) -> bool +~override CheatEngine.Client.Dbvm.DbvmWatchEvent.ToString() -> string +~override CheatEngine.Client.Dbvm.DbvmWatchRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Dbvm.DbvmWatchRequest.ToString() -> string +~override CheatEngine.Client.Debugger.BreakpointEvent.Equals(object obj) -> bool +~override CheatEngine.Client.Debugger.BreakpointEvent.ToString() -> string +~override CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.Equals(object obj) -> bool +~override CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.ToString() -> string +~override CheatEngine.Client.Debugger.BreakpointRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Debugger.BreakpointRequest.ToString() -> string +~override CheatEngine.Client.Events.EventStreamOptions.Equals(object obj) -> bool +~override CheatEngine.Client.Events.EventStreamOptions.ToString() -> string +~override CheatEngine.Client.Hashing.FileHashRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Hashing.FileHashRequest.ToString() -> string +~override CheatEngine.Client.Hashing.HashDigest.Equals(object obj) -> bool +~override CheatEngine.Client.Hashing.HashDigest.ToString() -> string +~override CheatEngine.Client.Hashing.MemoryHashRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Hashing.MemoryHashRequest.ToString() -> string +~override CheatEngine.Client.Hotkeys.HotkeyEvent.Equals(object obj) -> bool +~override CheatEngine.Client.Hotkeys.HotkeyEvent.ToString() -> string +~override CheatEngine.Client.Hotkeys.HotkeyGesture.Equals(object obj) -> bool +~override CheatEngine.Client.Hotkeys.HotkeyGesture.ToString() -> string +~override CheatEngine.Client.Hotkeys.HotkeyRegistration.Equals(object obj) -> bool +~override CheatEngine.Client.Hotkeys.HotkeyRegistration.ToString() -> string +~override CheatEngine.Client.RemoteExecution.RemoteCallRequest.Equals(object obj) -> bool +~override CheatEngine.Client.RemoteExecution.RemoteCallRequest.ToString() -> string +~override CheatEngine.Client.RemoteExecution.RemoteCallResult.Equals(object obj) -> bool +~override CheatEngine.Client.RemoteExecution.RemoteCallResult.ToString() -> string +~override CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.Equals(object obj) -> bool +~override CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.ToString() -> string +~override CheatEngine.Client.Speed.SpeedMultiplier.Equals(object obj) -> bool +~override CheatEngine.Client.Speed.SpeedMultiplier.ToString() -> string +~override CheatEngine.Client.Timers.TimerRequest.Equals(object obj) -> bool +~override CheatEngine.Client.Timers.TimerRequest.ToString() -> string +~override CheatEngine.Client.Timers.TimerTick.Equals(object obj) -> bool +~override CheatEngine.Client.Timers.TimerTick.ToString() -> string +override CheatEngine.Client.Allocations.TargetAllocationRequest.GetHashCode() -> int +override CheatEngine.Client.Assembly.AssemblyInstructionRequest.GetHashCode() -> int +override CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.GetHashCode() -> int +override CheatEngine.Client.Assembly.AutoAssemblerScript.GetHashCode() -> int +override CheatEngine.Client.Dbvm.DbvmInitializationRequest.GetHashCode() -> int +override CheatEngine.Client.Dbvm.DbvmStatusSnapshot.GetHashCode() -> int +override CheatEngine.Client.Dbvm.DbvmWatchEvent.GetHashCode() -> int +override CheatEngine.Client.Dbvm.DbvmWatchRequest.GetHashCode() -> int +override CheatEngine.Client.Debugger.BreakpointEvent.GetHashCode() -> int +override CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.GetHashCode() -> int +override CheatEngine.Client.Debugger.BreakpointRequest.GetHashCode() -> int +override CheatEngine.Client.Events.EventStreamOptions.GetHashCode() -> int +override CheatEngine.Client.Hashing.FileHashRequest.GetHashCode() -> int +override CheatEngine.Client.Hashing.HashDigest.GetHashCode() -> int +override CheatEngine.Client.Hashing.MemoryHashRequest.GetHashCode() -> int +override CheatEngine.Client.Hotkeys.HotkeyEvent.GetHashCode() -> int +override CheatEngine.Client.Hotkeys.HotkeyGesture.GetHashCode() -> int +override CheatEngine.Client.Hotkeys.HotkeyRegistration.GetHashCode() -> int +override CheatEngine.Client.RemoteExecution.RemoteCallRequest.GetHashCode() -> int +override CheatEngine.Client.RemoteExecution.RemoteCallResult.GetHashCode() -> int +override CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.GetHashCode() -> int +override CheatEngine.Client.Speed.SpeedMultiplier.GetHashCode() -> int +override CheatEngine.Client.Timers.TimerRequest.GetHashCode() -> int +override CheatEngine.Client.Timers.TimerTick.GetHashCode() -> int +static CheatEngine.Client.Allocations.TargetAllocationRequest.operator !=(CheatEngine.Client.Allocations.TargetAllocationRequest left, CheatEngine.Client.Allocations.TargetAllocationRequest right) -> bool +static CheatEngine.Client.Allocations.TargetAllocationRequest.operator ==(CheatEngine.Client.Allocations.TargetAllocationRequest left, CheatEngine.Client.Allocations.TargetAllocationRequest right) -> bool +static CheatEngine.Client.Assembly.AssemblyInstructionRequest.operator !=(CheatEngine.Client.Assembly.AssemblyInstructionRequest left, CheatEngine.Client.Assembly.AssemblyInstructionRequest right) -> bool +static CheatEngine.Client.Assembly.AssemblyInstructionRequest.operator ==(CheatEngine.Client.Assembly.AssemblyInstructionRequest left, CheatEngine.Client.Assembly.AssemblyInstructionRequest right) -> bool +static CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.operator !=(CheatEngine.Client.Assembly.AssemblyInstructionSnapshot left, CheatEngine.Client.Assembly.AssemblyInstructionSnapshot right) -> bool +static CheatEngine.Client.Assembly.AssemblyInstructionSnapshot.operator ==(CheatEngine.Client.Assembly.AssemblyInstructionSnapshot left, CheatEngine.Client.Assembly.AssemblyInstructionSnapshot right) -> bool +static CheatEngine.Client.Assembly.AutoAssemblerScript.operator !=(CheatEngine.Client.Assembly.AutoAssemblerScript left, CheatEngine.Client.Assembly.AutoAssemblerScript right) -> bool +static CheatEngine.Client.Assembly.AutoAssemblerScript.operator ==(CheatEngine.Client.Assembly.AutoAssemblerScript left, CheatEngine.Client.Assembly.AutoAssemblerScript right) -> bool +static CheatEngine.Client.Dbvm.DbvmInitializationRequest.operator !=(CheatEngine.Client.Dbvm.DbvmInitializationRequest left, CheatEngine.Client.Dbvm.DbvmInitializationRequest right) -> bool +static CheatEngine.Client.Dbvm.DbvmInitializationRequest.operator ==(CheatEngine.Client.Dbvm.DbvmInitializationRequest left, CheatEngine.Client.Dbvm.DbvmInitializationRequest right) -> bool +static CheatEngine.Client.Dbvm.DbvmStatusSnapshot.operator !=(CheatEngine.Client.Dbvm.DbvmStatusSnapshot left, CheatEngine.Client.Dbvm.DbvmStatusSnapshot right) -> bool +static CheatEngine.Client.Dbvm.DbvmStatusSnapshot.operator ==(CheatEngine.Client.Dbvm.DbvmStatusSnapshot left, CheatEngine.Client.Dbvm.DbvmStatusSnapshot right) -> bool +static CheatEngine.Client.Dbvm.DbvmWatchEvent.operator !=(CheatEngine.Client.Dbvm.DbvmWatchEvent left, CheatEngine.Client.Dbvm.DbvmWatchEvent right) -> bool +static CheatEngine.Client.Dbvm.DbvmWatchEvent.operator ==(CheatEngine.Client.Dbvm.DbvmWatchEvent left, CheatEngine.Client.Dbvm.DbvmWatchEvent right) -> bool +static CheatEngine.Client.Dbvm.DbvmWatchRequest.operator !=(CheatEngine.Client.Dbvm.DbvmWatchRequest left, CheatEngine.Client.Dbvm.DbvmWatchRequest right) -> bool +static CheatEngine.Client.Dbvm.DbvmWatchRequest.operator ==(CheatEngine.Client.Dbvm.DbvmWatchRequest left, CheatEngine.Client.Dbvm.DbvmWatchRequest right) -> bool +static CheatEngine.Client.Debugger.BreakpointEvent.operator !=(CheatEngine.Client.Debugger.BreakpointEvent left, CheatEngine.Client.Debugger.BreakpointEvent right) -> bool +static CheatEngine.Client.Debugger.BreakpointEvent.operator ==(CheatEngine.Client.Debugger.BreakpointEvent left, CheatEngine.Client.Debugger.BreakpointEvent right) -> bool +static CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.operator !=(CheatEngine.Client.Debugger.BreakpointRegisterSnapshot left, CheatEngine.Client.Debugger.BreakpointRegisterSnapshot right) -> bool +static CheatEngine.Client.Debugger.BreakpointRegisterSnapshot.operator ==(CheatEngine.Client.Debugger.BreakpointRegisterSnapshot left, CheatEngine.Client.Debugger.BreakpointRegisterSnapshot right) -> bool +static CheatEngine.Client.Debugger.BreakpointRequest.operator !=(CheatEngine.Client.Debugger.BreakpointRequest left, CheatEngine.Client.Debugger.BreakpointRequest right) -> bool +static CheatEngine.Client.Debugger.BreakpointRequest.operator ==(CheatEngine.Client.Debugger.BreakpointRequest left, CheatEngine.Client.Debugger.BreakpointRequest right) -> bool +static CheatEngine.Client.Events.EventStreamOptions.operator !=(CheatEngine.Client.Events.EventStreamOptions left, CheatEngine.Client.Events.EventStreamOptions right) -> bool +static CheatEngine.Client.Events.EventStreamOptions.operator ==(CheatEngine.Client.Events.EventStreamOptions left, CheatEngine.Client.Events.EventStreamOptions right) -> bool +static CheatEngine.Client.Hashing.FileHashRequest.operator !=(CheatEngine.Client.Hashing.FileHashRequest left, CheatEngine.Client.Hashing.FileHashRequest right) -> bool +static CheatEngine.Client.Hashing.FileHashRequest.operator ==(CheatEngine.Client.Hashing.FileHashRequest left, CheatEngine.Client.Hashing.FileHashRequest right) -> bool +static CheatEngine.Client.Hashing.HashDigest.operator !=(CheatEngine.Client.Hashing.HashDigest left, CheatEngine.Client.Hashing.HashDigest right) -> bool +static CheatEngine.Client.Hashing.HashDigest.operator ==(CheatEngine.Client.Hashing.HashDigest left, CheatEngine.Client.Hashing.HashDigest right) -> bool +static CheatEngine.Client.Hashing.MemoryHashRequest.operator !=(CheatEngine.Client.Hashing.MemoryHashRequest left, CheatEngine.Client.Hashing.MemoryHashRequest right) -> bool +static CheatEngine.Client.Hashing.MemoryHashRequest.operator ==(CheatEngine.Client.Hashing.MemoryHashRequest left, CheatEngine.Client.Hashing.MemoryHashRequest right) -> bool +static CheatEngine.Client.Hotkeys.HotkeyEvent.operator !=(CheatEngine.Client.Hotkeys.HotkeyEvent left, CheatEngine.Client.Hotkeys.HotkeyEvent right) -> bool +static CheatEngine.Client.Hotkeys.HotkeyEvent.operator ==(CheatEngine.Client.Hotkeys.HotkeyEvent left, CheatEngine.Client.Hotkeys.HotkeyEvent right) -> bool +static CheatEngine.Client.Hotkeys.HotkeyGesture.operator !=(CheatEngine.Client.Hotkeys.HotkeyGesture left, CheatEngine.Client.Hotkeys.HotkeyGesture right) -> bool +static CheatEngine.Client.Hotkeys.HotkeyGesture.operator ==(CheatEngine.Client.Hotkeys.HotkeyGesture left, CheatEngine.Client.Hotkeys.HotkeyGesture right) -> bool +static CheatEngine.Client.Hotkeys.HotkeyRegistration.operator !=(CheatEngine.Client.Hotkeys.HotkeyRegistration left, CheatEngine.Client.Hotkeys.HotkeyRegistration right) -> bool +static CheatEngine.Client.Hotkeys.HotkeyRegistration.operator ==(CheatEngine.Client.Hotkeys.HotkeyRegistration left, CheatEngine.Client.Hotkeys.HotkeyRegistration right) -> bool +static CheatEngine.Client.RemoteExecution.RemoteCallRequest.operator !=(CheatEngine.Client.RemoteExecution.RemoteCallRequest left, CheatEngine.Client.RemoteExecution.RemoteCallRequest right) -> bool +static CheatEngine.Client.RemoteExecution.RemoteCallRequest.operator ==(CheatEngine.Client.RemoteExecution.RemoteCallRequest left, CheatEngine.Client.RemoteExecution.RemoteCallRequest right) -> bool +static CheatEngine.Client.RemoteExecution.RemoteCallResult.operator !=(CheatEngine.Client.RemoteExecution.RemoteCallResult left, CheatEngine.Client.RemoteExecution.RemoteCallResult right) -> bool +static CheatEngine.Client.RemoteExecution.RemoteCallResult.operator ==(CheatEngine.Client.RemoteExecution.RemoteCallResult left, CheatEngine.Client.RemoteExecution.RemoteCallResult right) -> bool +static CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.operator !=(CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest left, CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest right) -> bool +static CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest.operator ==(CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest left, CheatEngine.Client.RemoteExecution.RemoteDllInjectionRequest right) -> bool +static CheatEngine.Client.Speed.SpeedMultiplier.operator !=(CheatEngine.Client.Speed.SpeedMultiplier left, CheatEngine.Client.Speed.SpeedMultiplier right) -> bool +static CheatEngine.Client.Speed.SpeedMultiplier.operator ==(CheatEngine.Client.Speed.SpeedMultiplier left, CheatEngine.Client.Speed.SpeedMultiplier right) -> bool +static CheatEngine.Client.Timers.TimerRequest.operator !=(CheatEngine.Client.Timers.TimerRequest left, CheatEngine.Client.Timers.TimerRequest right) -> bool +static CheatEngine.Client.Timers.TimerRequest.operator ==(CheatEngine.Client.Timers.TimerRequest left, CheatEngine.Client.Timers.TimerRequest right) -> bool +static CheatEngine.Client.Timers.TimerTick.operator !=(CheatEngine.Client.Timers.TimerTick left, CheatEngine.Client.Timers.TimerTick right) -> bool +static CheatEngine.Client.Timers.TimerTick.operator ==(CheatEngine.Client.Timers.TimerTick left, CheatEngine.Client.Timers.TimerTick right) -> bool +virtual CheatEngine.Client.Dbvm.DbvmWatchHandler.Invoke(CheatEngine.Client.Dbvm.DbvmWatchEvent watchEvent) -> void +virtual CheatEngine.Client.Debugger.BreakpointHandler.Invoke(CheatEngine.Client.Debugger.BreakpointEvent breakpointEvent) -> CheatEngine.Client.Debugger.BreakpointDisposition +virtual CheatEngine.Client.Hotkeys.HotkeyHandler.Invoke(CheatEngine.Client.Hotkeys.HotkeyEvent hotkeyEvent) -> void +virtual CheatEngine.Client.Timers.TimerHandler.Invoke(CheatEngine.Client.Timers.TimerTick tick) -> void +static CheatEngine.Client.Lua.LuaModuleDescriptor.operator ==(CheatEngine.Client.Lua.LuaModuleDescriptor left, CheatEngine.Client.Lua.LuaModuleDescriptor right) -> bool +CheatEngine.Client.Memory.IMemoryClient.ReadPrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.IMemoryClient.TryReadPrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest request, out System.Collections.Immutable.ImmutableArray values, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.TryWritePrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest request, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.IMemoryClient.WritePrimitiveBatch(CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest request, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.MemoryAddressValue +CheatEngine.Client.Memory.MemoryAddressValue.Address.get -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryAddressValue.MemoryAddressValue(CheatEngine.SDK.Engine.Values.Address address, T value) -> void +CheatEngine.Client.Memory.MemoryAddressValue.MemoryAddressValue() -> void +CheatEngine.Client.Memory.MemoryAddressValue.Value.get -> T +CheatEngine.Client.Memory.MemoryBatchLimits +const CheatEngine.Client.Memory.MemoryBatchLimits.MaximumOperations = 1024 -> int +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest.Addresses.get -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest.MemoryPrimitiveBatchReadRequest(System.ReadOnlySpan addresses) -> void +CheatEngine.Client.Memory.MemoryPrimitiveBatchReadRequest.MemoryPrimitiveBatchReadRequest() -> void +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest.MemoryPrimitiveBatchWriteRequest(System.ReadOnlySpan> values) -> void +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest.MemoryPrimitiveBatchWriteRequest() -> void +CheatEngine.Client.Memory.MemoryPrimitiveBatchWriteRequest.Values.get -> System.Collections.Immutable.ImmutableArray> +CheatEngine.Client.Memory.MemoryStringEncoding +CheatEngine.Client.Memory.MemoryStringEncoding.Utf16 = 1 -> CheatEngine.Client.Memory.MemoryStringEncoding +CheatEngine.Client.Memory.MemoryStringEncoding.Utf8 = 0 -> CheatEngine.Client.Memory.MemoryStringEncoding +static CheatEngine.Client.Memory.MemoryStringReadRequest.Create(CheatEngine.SDK.Engine.Values.Address address, int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding) -> CheatEngine.Client.Memory.MemoryStringReadRequest +CheatEngine.Client.Memory.MemoryStringReadRequest.Encoding.get -> CheatEngine.Client.Memory.MemoryStringEncoding +static CheatEngine.Client.Memory.MemoryStringWriteRequest.CreateBounded(CheatEngine.SDK.Engine.Values.Address address, string! value, int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding) -> CheatEngine.Client.Memory.MemoryStringWriteRequest +CheatEngine.Client.Memory.MemoryStringWriteRequest.Encoding.get -> CheatEngine.Client.Memory.MemoryStringEncoding +CheatEngine.Client.Memory.MemoryStringWriteRequest.MaximumLength.get -> int diff --git a/libs/CheatEngine.Client.Abstractions/README.md b/libs/CheatEngine.Client.Abstractions/README.md index 499cbcc..d344bae 100644 --- a/libs/CheatEngine.Client.Abstractions/README.md +++ b/libs/CheatEngine.Client.Abstractions/README.md @@ -39,8 +39,9 @@ CheatEngine.Client.Abstractions `CheatEngineClientException`-derived exceptions only from the convenience methods. - Keeps CE-owned objects out of the public surface: no `LuaState`, `LuaRef`, `CEObject`, `Owned`, or raw native handle escapes this package. -- Requires bounded copies for scans, table snapshots, strings, byte reads, pointer chains, and - inspection collections, avoiding unbounded materialization and leaked SDK ownership. +- Requires bounded copies for scans, table snapshots, strings, byte reads, finite pointer chains, + homogeneous primitive batches, and inspection collections, avoiding unbounded materialization and + leaked SDK ownership. - Makes lifecycle constraints visible: `ICheatEngineClient.Epoch`, `Stopping`, leases, and scan sessions are activation-scoped; stale resources report `CheatEngineActivationExpiredException`. @@ -58,6 +59,11 @@ namespaces only: | `.Scanning` | AOB contracts and the value-scan session contract | | `.Tables` | copied Address List records and explicitly trusted table I/O requests | | `.Lua` / `.Modules` | typed protected Lua operations, explicit modules, and leases | +| `.Allocations` / `.Assembly` | selection-bound allocation and reversible patch leases | +| `.RemoteExecution` | bounded DLL injection and remote-call requests | +| `.Debugger` / `.Hotkeys` | synchronous copied callbacks and bounded stream projections | +| `.Timers` / `.Speed` | activation-scoped timers and validated speed observations/mutations | +| `.Hashing` / `.Dbvm` | separate memory/file hashes and explicit DBVM observation/control | | `.Results` | classified expected failures and lifecycle exceptions | No public consumer should use `CheatEngine.Client.Abstractions` as a namespace. @@ -69,10 +75,10 @@ and explicitly disposable value-scan sessions. A contract is not an availability must inspect `ICheatEngineRuntime` capability observations or handle `CapabilityUnavailable`. In particular, the value-scan contract and state model are published, but the Core implementation -does **not** currently create a live `MemScan`/`FoundList` session. CheatEngine.SDK 1.0.0 has no -public ownership factory for the required objects; Client enablement remains blocked by the -Cheat Engine 7.7 x64 ownership and reactivation live gate. Do not treat `IValueScanner` as an -available v1 runtime feature. +does **not** currently create a live `MemScan`/`FoundList` session. The next SDK line now contains a +production owner factory with parent rollback and child-before-parent teardown, but Client +enablement remains blocked by the Cheat Engine 7.7 x64 ownership and reactivation live gate. Do +not treat `IValueScanner` as available until that gate promotes its capability. `IUnsafeLuaClient` is intentionally separate from `ILuaClient` and is not registered by default. It is for explicitly trusted source only and still never exposes a raw Lua state. diff --git a/libs/CheatEngine.Client.Abstractions/RemoteExecution/IRemoteExecutionClient.cs b/libs/CheatEngine.Client.Abstractions/RemoteExecution/IRemoteExecutionClient.cs new file mode 100644 index 0000000..fac13de --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/RemoteExecution/IRemoteExecutionClient.cs @@ -0,0 +1,21 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.RemoteExecution; + +/// Injects existing DLLs and executes bounded target calls without exposing target allocation handles. +public interface IRemoteExecutionClient +{ + /// Tries to inject an existing absolute DLL path into the selected target process. + public bool TryInjectLibrary(RemoteDllInjectionRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Injects an existing absolute DLL path or throws when the host rejects it. + public void InjectLibrary(RemoteDllInjectionRequest request, CancellationToken cancellationToken = default); + + /// Tries to call a target function with a positive timeout and copied parameter result data. + public bool TryInvoke(RemoteCallRequest request, out RemoteCallResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Calls a target function or throws when remote execution fails. + public RemoteCallResult Invoke(RemoteCallRequest request, CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallRequest.cs b/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallRequest.cs new file mode 100644 index 0000000..64d3b71 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallRequest.cs @@ -0,0 +1,41 @@ +using System.Collections.Immutable; + +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.RemoteExecution; + +/// Describes one bounded remote call and its copied parameter payload. +public readonly record struct RemoteCallRequest +{ + /// Creates a remote-call request. + /// is not strictly positive. + public RemoteCallRequest(Address entryPoint, ReadOnlySpan parameters, TimeSpan timeout) + { + if (timeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(timeout), "A remote-call timeout must be strictly positive."); + } + + EntryPoint = entryPoint; + Parameters = ImmutableArray.Create(parameters); + Timeout = timeout; + } + + /// Gets the target function entry-point address. + public Address EntryPoint + { + get; + } + + /// Gets an immutable copy of the call parameter bytes. + public ImmutableArray Parameters + { + get; + } + + /// Gets the strictly positive call timeout. + public TimeSpan Timeout + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallResult.cs b/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallResult.cs new file mode 100644 index 0000000..71697bf --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteCallResult.cs @@ -0,0 +1,26 @@ +using System.Collections.Immutable; + +namespace CheatEngine.Client.RemoteExecution; + +/// Contains the copied result returned by a completed remote call. +public readonly record struct RemoteCallResult +{ + /// Creates a copied remote-call result. + public RemoteCallResult(ulong returnValue, ReadOnlySpan output) + { + ReturnValue = returnValue; + Output = ImmutableArray.Create(output); + } + + /// Gets the target ABI return value represented as an unsigned machine value. + public ulong ReturnValue + { + get; + } + + /// Gets an immutable copy of any requested output parameter bytes. + public ImmutableArray Output + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteDllInjectionRequest.cs b/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteDllInjectionRequest.cs new file mode 100644 index 0000000..6f37967 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/RemoteExecution/RemoteDllInjectionRequest.cs @@ -0,0 +1,29 @@ +namespace CheatEngine.Client.RemoteExecution; + +/// Describes a DLL whose existing absolute path is injected into the selected target. +public readonly record struct RemoteDllInjectionRequest +{ + /// Creates a remote DLL-injection request. + /// is blank, relative, or not a DLL path. + public RemoteDllInjectionRequest(string libraryPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(libraryPath); + if (!Path.IsPathFullyQualified(libraryPath)) + { + throw new ArgumentException("A DLL injection path must be absolute.", nameof(libraryPath)); + } + + if (!string.Equals(Path.GetExtension(libraryPath), ".dll", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("A DLL injection path must have a .dll extension.", nameof(libraryPath)); + } + + LibraryPath = libraryPath; + } + + /// Gets the absolute DLL path. The implementation verifies that it exists before injection. + public string LibraryPath + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs index 93c4614..1c7ec58 100644 --- a/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs +++ b/libs/CheatEngine.Client.Abstractions/Runtime/ClientCapabilityId.cs @@ -42,6 +42,33 @@ public ClientCapabilityId(string value) /// Gets the Client capability for explicitly opted-in arbitrary Lua source execution. public static ClientCapabilityId UnsafeLuaExecution => new("Client.UnsafeLuaExecution"); + /// Gets the Client capability for owned target-memory allocations. + public static ClientCapabilityId Allocations => new("Client.Allocations"); + + /// Gets the Client capability for assembly, disassembly, comments, and Auto Assembler patches. + public static ClientCapabilityId Assembly => new("Client.Assembly"); + + /// Gets the Client capability for bounded remote execution and DLL injection. + public static ClientCapabilityId RemoteExecution => new("Client.RemoteExecution"); + + /// Gets the Client capability for debugger breakpoints and copied debugger events. + public static ClientCapabilityId Debugger => new("Client.Debugger"); + + /// Gets the Client capability for activation-scoped hotkeys. + public static ClientCapabilityId Hotkeys => new("Client.Hotkeys"); + + /// Gets the Client capability for activation-scoped timers. + public static ClientCapabilityId Timers => new("Client.Timers"); + + /// Gets the Client capability for observing and changing target speed. + public static ClientCapabilityId Speed => new("Client.Speed"); + + /// Gets the Client capability for target-memory and file hashing. + public static ClientCapabilityId Hashing => new("Client.Hashing"); + + /// Gets the Client capability for explicitly initialized DBVM operations and watches. + public static ClientCapabilityId Dbvm => new("Client.Dbvm"); + /// public bool Equals(ClientCapabilityId other) { diff --git a/libs/CheatEngine.Client.Abstractions/Speed/ISpeedClient.cs b/libs/CheatEngine.Client.Abstractions/Speed/ISpeedClient.cs new file mode 100644 index 0000000..292bc7e --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Speed/ISpeedClient.cs @@ -0,0 +1,21 @@ +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Speed; + +/// Reads and updates the selected target's validated speed multiplier. +public interface ISpeedClient +{ + /// Tries to get the selected target's current speed multiplier. + public bool TryGetMultiplier(out SpeedMultiplier multiplier, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Gets the selected target's current speed multiplier or throws when unavailable. + public SpeedMultiplier GetMultiplier(CancellationToken cancellationToken = default); + + /// Tries to set a finite, strictly positive speed multiplier. + public bool TrySetMultiplier(SpeedMultiplier multiplier, out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Sets a finite, strictly positive speed multiplier or throws when unavailable. + public void SetMultiplier(SpeedMultiplier multiplier, CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Speed/SpeedMultiplier.cs b/libs/CheatEngine.Client.Abstractions/Speed/SpeedMultiplier.cs new file mode 100644 index 0000000..a8a63d1 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Speed/SpeedMultiplier.cs @@ -0,0 +1,24 @@ +namespace CheatEngine.Client.Speed; + +/// Represents a finite, strictly positive Client speed multiplier. +public readonly record struct SpeedMultiplier +{ + /// Creates a speed multiplier. + /// is non-finite or not positive. + public SpeedMultiplier(double value) + { + if (!double.IsFinite(value) || value <= 0) + { + throw new ArgumentOutOfRangeException(nameof(value), + "A speed multiplier must be finite and strictly positive."); + } + + Value = value; + } + + /// Gets the finite, strictly positive multiplier value. + public double Value + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Timers/ITimerClient.cs b/libs/CheatEngine.Client.Abstractions/Timers/ITimerClient.cs new file mode 100644 index 0000000..11045c5 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Timers/ITimerClient.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Events; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Timers; + +/// Registers Client-owned recurring timers for the current activation. +public interface ITimerClient +{ + /// Tries to register a timer and its bounded copied tick stream. + public bool TryRegister( + TimerRequest request, + TimerHandler handler, + EventStreamOptions streamOptions, + [NotNullWhen(true)] out ITimerLease? lease, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default); + + /// Registers a timer or throws when the capability is unavailable or registration fails. + public ITimerLease Register( + TimerRequest request, + TimerHandler handler, + EventStreamOptions streamOptions, + CancellationToken cancellationToken = default); +} diff --git a/libs/CheatEngine.Client.Abstractions/Timers/ITimerLease.cs b/libs/CheatEngine.Client.Abstractions/Timers/ITimerLease.cs new file mode 100644 index 0000000..bb70118 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Timers/ITimerLease.cs @@ -0,0 +1,13 @@ +using CheatEngine.Client.Events; + +namespace CheatEngine.Client.Timers; + +/// Owns one Client timer registration and its bounded copied tick stream. +public interface ITimerLease : IEventStreamLease +{ + /// Gets the request used to create this timer. + public TimerRequest Request + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Timers/TimerHandler.cs b/libs/CheatEngine.Client.Abstractions/Timers/TimerHandler.cs new file mode 100644 index 0000000..df79112 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Timers/TimerHandler.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.Client.Timers; + +/// Handles one copied timer tick synchronously without blocking the host callback thread. +public delegate void TimerHandler(TimerTick tick); diff --git a/libs/CheatEngine.Client.Abstractions/Timers/TimerRequest.cs b/libs/CheatEngine.Client.Abstractions/Timers/TimerRequest.cs new file mode 100644 index 0000000..d2cc5a6 --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Timers/TimerRequest.cs @@ -0,0 +1,23 @@ +namespace CheatEngine.Client.Timers; + +/// Describes a recurring Client timer with an explicit positive interval. +public readonly record struct TimerRequest +{ + /// Creates a recurring timer request. + /// is not strictly positive. + public TimerRequest(TimeSpan interval) + { + if (interval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(interval), "A timer interval must be strictly positive."); + } + + Interval = interval; + } + + /// Gets the strictly positive timer interval. + public TimeSpan Interval + { + get; + } +} diff --git a/libs/CheatEngine.Client.Abstractions/Timers/TimerTick.cs b/libs/CheatEngine.Client.Abstractions/Timers/TimerTick.cs new file mode 100644 index 0000000..022cd8b --- /dev/null +++ b/libs/CheatEngine.Client.Abstractions/Timers/TimerTick.cs @@ -0,0 +1,26 @@ +namespace CheatEngine.Client.Timers; + +/// Contains a copied timer tick. +public readonly record struct TimerTick +{ + /// Creates a copied timer tick. + /// is negative. + public TimerTick(long sequence, DateTimeOffset occurredAt) + { + ArgumentOutOfRangeException.ThrowIfNegative(sequence); + Sequence = sequence; + OccurredAt = occurredAt; + } + + /// Gets the non-negative sequence number within this timer registration. + public long Sequence + { + get; + } + + /// Gets the copied tick timestamp. + public DateTimeOffset OccurredAt + { + get; + } +} diff --git a/libs/CheatEngine.Client.Core/CheatEngineClient.cs b/libs/CheatEngine.Client.Core/CheatEngineClient.cs index 4ce4fc2..d7aebc0 100644 --- a/libs/CheatEngine.Client.Core/CheatEngineClient.cs +++ b/libs/CheatEngine.Client.Core/CheatEngineClient.cs @@ -1,12 +1,21 @@ +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Hashing; +using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Processes; +using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; +using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; +using CheatEngine.Client.Timers; namespace CheatEngine.Client.Core; @@ -32,6 +41,15 @@ internal CheatEngineClient( Inspection = domainServices.Inspection ?? throw new ArgumentNullException(nameof(domainServices)); Tables = domainServices.Tables ?? throw new ArgumentNullException(nameof(domainServices)); Lua = domainServices.Lua ?? throw new ArgumentNullException(nameof(domainServices)); + Allocations = domainServices.Allocations ?? throw new ArgumentNullException(nameof(domainServices)); + Assembly = domainServices.Assembly ?? throw new ArgumentNullException(nameof(domainServices)); + RemoteExecution = domainServices.RemoteExecution ?? throw new ArgumentNullException(nameof(domainServices)); + Debugger = domainServices.Debugger ?? throw new ArgumentNullException(nameof(domainServices)); + Hotkeys = domainServices.Hotkeys ?? throw new ArgumentNullException(nameof(domainServices)); + Timers = domainServices.Timers ?? throw new ArgumentNullException(nameof(domainServices)); + Speed = domainServices.Speed ?? throw new ArgumentNullException(nameof(domainServices)); + Hashing = domainServices.Hashing ?? throw new ArgumentNullException(nameof(domainServices)); + Dbvm = domainServices.Dbvm ?? throw new ArgumentNullException(nameof(domainServices)); } public long Epoch => _lifetime.Epoch; @@ -81,4 +99,49 @@ public ILuaClient Lua { get; } + + public IAllocationClient Allocations + { + get; + } + + public IAssemblyClient Assembly + { + get; + } + + public IRemoteExecutionClient RemoteExecution + { + get; + } + + public IDebuggerClient Debugger + { + get; + } + + public IHotkeyClient Hotkeys + { + get; + } + + public ITimerClient Timers + { + get; + } + + public ISpeedClient Speed + { + get; + } + + public IHashingClient Hashing + { + get; + } + + public IDbvmClient Dbvm + { + get; + } } diff --git a/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs b/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs index 7ce2156..c7358f9 100644 --- a/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs +++ b/libs/CheatEngine.Client.Core/CheatEngineClientDomainServices.cs @@ -1,9 +1,18 @@ -using CheatEngine.Client.Inspection; +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Debugger; +using CheatEngine.Client.Hashing; +using CheatEngine.Client.Hotkeys; +using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Processes; +using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Scanning; +using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; +using CheatEngine.Client.Timers; namespace CheatEngine.Client.Core; @@ -15,4 +24,13 @@ internal sealed record CheatEngineClientDomainServices( IValueScanner Scans, IInspectionClient Inspection, ITableClient Tables, - ILuaClient Lua); + ILuaClient Lua, + IAllocationClient Allocations, + IAssemblyClient Assembly, + IRemoteExecutionClient RemoteExecution, + IDebuggerClient Debugger, + IHotkeyClient Hotkeys, + ITimerClient Timers, + ISpeedClient Speed, + IHashingClient Hashing, + IDbvmClient Dbvm); diff --git a/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs b/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs index db33040..710fa52 100644 --- a/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs +++ b/libs/CheatEngine.Client.Core/Dispatching/IMainThreadInvoker.cs @@ -5,4 +5,16 @@ internal interface IMainThreadInvoker public Exception? Invoke(Action callback); public MainThreadInvocationResult Invoke(Func callback); + + public Exception? Invoke(Action callback, TState state) + { + ArgumentNullException.ThrowIfNull(callback); + return Invoke(() => callback(state)); + } + + public MainThreadInvocationResult Invoke(Func callback, TState state) + { + ArgumentNullException.ThrowIfNull(callback); + return Invoke(() => callback(state)); + } } diff --git a/libs/CheatEngine.Client.Core/Dispatching/IStatefulCheatEngineDispatcher.cs b/libs/CheatEngine.Client.Core/Dispatching/IStatefulCheatEngineDispatcher.cs new file mode 100644 index 0000000..1827b20 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Dispatching/IStatefulCheatEngineDispatcher.cs @@ -0,0 +1,17 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Dispatching; + +/// Internal fast path that carries explicit state into the SDK main-thread dispatcher. +/// +/// The public dispatcher contract deliberately remains small and source-compatible for third-party +/// implementations. Core services retain the closure-based public fallback only for non-SDK dispatchers. +/// +internal interface IStatefulCheatEngineDispatcher +{ + public bool TryInvoke(TState state, Func callback, + [MaybeNullWhen(false)] out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken); +} diff --git a/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs b/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs index b4395f5..eb0a510 100644 --- a/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs +++ b/libs/CheatEngine.Client.Core/Dispatching/SdkMainThreadDispatcher.cs @@ -10,7 +10,7 @@ namespace CheatEngine.Client.Core.Dispatching; /// Adapts the SDK's synchronous main-thread dispatcher without retaining Lua state. -internal sealed class SdkMainThreadDispatcher : ICheatEngineDispatcher +internal sealed class SdkMainThreadDispatcher : ICheatEngineDispatcher, IStatefulCheatEngineDispatcher { private const string _invokeOperation = "Dispatcher.Invoke"; @@ -132,6 +132,93 @@ public T Invoke(Func callback, CancellationToken cancellationToken = defau return ThrowFailure(failure); } + public bool TryInvoke(TState state, Func callback, + [MaybeNullWhen(false)] out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + _lifetime.ThrowIfDispatchAllowed(_invokeOperation); + if (cancellationToken.IsCancellationRequested) + { + result = default; + failure = CoreFailureFactory.Cancelled(_invokeOperation); + return false; + } + + MainThreadInvocationResult callbackResult; + try + { + callbackResult = _mainThread.Invoke(callback, state); + } + catch (CheatEngineActivationExpiredException) + { + result = default; + throw; + } + catch (Exception exception) when (!_lifetime.IsActivationCurrent) + { + result = default; + throw new CheatEngineActivationExpiredException(_invokeOperation, + "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); + } + catch (Exception exception) + { + result = default; + failure = CoreFailureFactory.FromException(_invokeOperation, exception); + return false; + } + + if (callbackResult.Exception is not null) + { + result = default; + RethrowCallback(callbackResult.Exception); + } + + result = callbackResult.Result; + failure = default; + return true; + } + + public bool TryInvoke(TState state, Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + _lifetime.ThrowIfDispatchAllowed(_invokeOperation); + if (cancellationToken.IsCancellationRequested) + { + failure = CoreFailureFactory.Cancelled(_invokeOperation); + return false; + } + + Exception? callbackException; + try + { + callbackException = _mainThread.Invoke(callback, state); + } + catch (CheatEngineActivationExpiredException) + { + throw; + } + catch (Exception exception) when (!_lifetime.IsActivationCurrent) + { + throw new CheatEngineActivationExpiredException(_invokeOperation, + "The Cheat Engine plugin lifecycle changed while dispatching work.", exception); + } + catch (Exception exception) + { + failure = CoreFailureFactory.FromException(_invokeOperation, exception); + return false; + } + + if (callbackException is not null) + { + RethrowCallback(callbackException); + } + + failure = default; + return true; + } + private static T ThrowFailure(CheatEngineFailure failure) { failure.Throw(); @@ -169,6 +256,59 @@ private static MainThreadInvocationResult InvokeCallback(Func callback) } } + private static Exception? InvokeCallback(Action callback, TState state) + { + try + { + callback(state); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static MainThreadInvocationResult InvokeCallback( + Func callback, + TState state) + { + try + { + return new MainThreadInvocationResult(callback(state), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + } + + private static Exception? InvokeCallback(CallbackState invocation) + { + try + { + invocation.Callback(invocation.State); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static MainThreadInvocationResult InvokeCallback( + CallbackState invocation) + { + try + { + return new MainThreadInvocationResult(invocation.Callback(invocation.State), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + } + private sealed class SdkMainThreadInvoker : IMainThreadInvoker { internal static SdkMainThreadInvoker Instance @@ -185,5 +325,23 @@ public MainThreadInvocationResult Invoke(Func callback) { return MainThread.Invoke(static function => InvokeCallback(function), callback); } + + public Exception? Invoke(Action callback, TState state) + { + return MainThread.Invoke(static invocation => InvokeCallback(invocation), + new CallbackState(callback, state)); + } + + public MainThreadInvocationResult Invoke(Func callback, + TState state) + { + return MainThread.Invoke( + static invocation => InvokeCallback(invocation), + new CallbackState(callback, state)); + } } + + private readonly record struct CallbackState(Action Callback, TState State); + + private readonly record struct CallbackState(Func Callback, TState State); } diff --git a/libs/CheatEngine.Client.Core/Domains/Allocations/UnavailableAllocationClient.cs b/libs/CheatEngine.Client.Core/Domains/Allocations/UnavailableAllocationClient.cs new file mode 100644 index 0000000..5626467 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Allocations/UnavailableAllocationClient.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains.Allocations; + +/// Preserves the allocation contract while its SDK ownership factory awaits live-host validation. +internal sealed class UnavailableAllocationClient : IAllocationClient +{ + public bool TryAllocate(TargetAllocationRequest request, [NotNullWhen(true)] out ITargetMemoryLease? lease, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + lease = null; + failure = UnavailableCapabilityFailure.Create("Target allocations", "Allocations.Allocate", cancellationToken); + return false; + } + + public ITargetMemoryLease Allocate(TargetAllocationRequest request, CancellationToken cancellationToken = default) + { + _ = TryAllocate(request, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Assembly/UnavailableAssemblyClient.cs b/libs/CheatEngine.Client.Core/Domains/Assembly/UnavailableAssemblyClient.cs new file mode 100644 index 0000000..94daf0a --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Assembly/UnavailableAssemblyClient.cs @@ -0,0 +1,107 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Domains.Assembly; + +/// Preserves the assembly and patch surface until Auto Assembler ownership passes its live-host gate. +internal sealed class UnavailableAssemblyClient : IAssemblyClient +{ + public bool TryDisassemble(Address address, out AssemblyInstructionSnapshot instruction, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + instruction = default; + failure = CreateFailure("Assembly.Disassemble", cancellationToken); + return false; + } + + public AssemblyInstructionSnapshot Disassemble(Address address, CancellationToken cancellationToken = default) + { + _ = TryDisassemble(address, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } + + public bool TryGetInstructionSize(Address address, out int size, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + size = default; + failure = CreateFailure("Assembly.GetInstructionSize", cancellationToken); + return false; + } + + public int GetInstructionSize(Address address, CancellationToken cancellationToken = default) + { + _ = TryGetInstructionSize(address, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } + + public bool TryGetPreviousInstruction(Address address, out Address previousAddress, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + previousAddress = default; + failure = CreateFailure("Assembly.GetPreviousInstruction", cancellationToken); + return false; + } + + public Address GetPreviousInstruction(Address address, CancellationToken cancellationToken = default) + { + _ = TryGetPreviousInstruction(address, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw
(failure); + } + + public bool TryGetComment(Address address, [NotNullWhen(true)] out string? comment, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + comment = null; + failure = CreateFailure("Assembly.GetComment", cancellationToken); + return false; + } + + public string GetComment(Address address, CancellationToken cancellationToken = default) + { + _ = TryGetComment(address, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } + + public bool TryAssemble(AssemblyInstructionRequest request, out ImmutableArray bytes, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + bytes = default; + failure = CreateFailure("Assembly.Assemble", cancellationToken); + return false; + } + + public ImmutableArray Assemble(AssemblyInstructionRequest request, + CancellationToken cancellationToken = default) + { + _ = TryAssemble(request, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw>(failure); + } + + public bool TryApplyPatch(AutoAssemblerScript script, [NotNullWhen(true)] out IAutoAssemblerPatchLease? lease, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + lease = null; + failure = CreateFailure("Assembly.ApplyPatch", cancellationToken); + return false; + } + + public IAutoAssemblerPatchLease ApplyPatch(AutoAssemblerScript script, + CancellationToken cancellationToken = default) + { + _ = TryApplyPatch(script, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } + + private static CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) + { + return UnavailableCapabilityFailure.Create("Assembly, disassembly, and Auto Assembler patches", operation, + cancellationToken); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Dbvm/UnavailableDbvmClient.cs b/libs/CheatEngine.Client.Core/Domains/Dbvm/UnavailableDbvmClient.cs new file mode 100644 index 0000000..801de60 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Dbvm/UnavailableDbvmClient.cs @@ -0,0 +1,65 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Events; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains.Dbvm; + +/// Observes no inferred DBVM state and never initializes DBVM before its explicit live-host gate passes. +internal sealed class UnavailableDbvmClient : IDbvmClient +{ + public bool TryGetStatus(out DbvmStatusSnapshot status, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + status = default; + failure = CreateFailure("Dbvm.GetStatus", cancellationToken); + return false; + } + + public DbvmStatusSnapshot GetStatus(CancellationToken cancellationToken = default) + { + _ = TryGetStatus(out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } + + public bool TryInitialize(DbvmInitializationRequest request, out DbvmStatusSnapshot status, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + status = default; + failure = CreateFailure("Dbvm.Initialize", cancellationToken); + return false; + } + + public DbvmStatusSnapshot Initialize(DbvmInitializationRequest request, + CancellationToken cancellationToken = default) + { + _ = TryInitialize(request, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } + + public bool TryRegisterWatch(DbvmWatchRequest request, DbvmWatchHandler handler, EventStreamOptions streamOptions, + [NotNullWhen(true)] out IDbvmWatchLease? lease, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(handler); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamOptions.Capacity); + lease = null; + failure = CreateFailure("Dbvm.RegisterWatch", cancellationToken); + return false; + } + + public IDbvmWatchLease RegisterWatch(DbvmWatchRequest request, DbvmWatchHandler handler, + EventStreamOptions streamOptions, CancellationToken cancellationToken = default) + { + _ = TryRegisterWatch(request, handler, streamOptions, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } + + private static CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) + { + return UnavailableCapabilityFailure.Create("DBVM observation, explicit initialization, and watches", operation, + cancellationToken); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Debugger/UnavailableDebuggerClient.cs b/libs/CheatEngine.Client.Core/Domains/Debugger/UnavailableDebuggerClient.cs new file mode 100644 index 0000000..9e00343 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Debugger/UnavailableDebuggerClient.cs @@ -0,0 +1,33 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Debugger; +using CheatEngine.Client.Events; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains.Debugger; + +/// Preserves copied breakpoint semantics until debugger callback ownership and reactivation pass a live gate. +internal sealed class UnavailableDebuggerClient : IDebuggerClient +{ + public bool TryRegisterBreakpoint(BreakpointRequest request, BreakpointHandler handler, + EventStreamOptions streamOptions, + [NotNullWhen(true)] out IBreakpointLease? lease, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(handler); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamOptions.Capacity); + lease = null; + failure = UnavailableCapabilityFailure.Create("Debugger breakpoints", "Debugger.RegisterBreakpoint", + cancellationToken); + return false; + } + + public IBreakpointLease RegisterBreakpoint(BreakpointRequest request, BreakpointHandler handler, + EventStreamOptions streamOptions, CancellationToken cancellationToken = default) + { + _ = TryRegisterBreakpoint(request, handler, streamOptions, out _, out CheatEngineFailure failure, + cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Events/BoundedEventStream.cs b/libs/CheatEngine.Client.Core/Domains/Events/BoundedEventStream.cs new file mode 100644 index 0000000..06fb809 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Events/BoundedEventStream.cs @@ -0,0 +1,478 @@ +using System.Runtime.CompilerServices; + +using CheatEngine.Client.Events; + +namespace CheatEngine.Client.Core.Domains.Events; + +/// +/// Provides a bounded, non-blocking handoff from a Cheat Engine callback to an asynchronous consumer. Callback +/// admission is synchronous and never waits for a reader; reader continuations always run asynchronously. +/// +/// The copied event type. +internal sealed class BoundedEventStream : IAsyncEnumerable, IDisposable +{ + private readonly T[] _buffer; + private readonly object _gate = new(); + private readonly EventStreamOverflowPolicy _overflowPolicy; + private readonly LinkedList _pendingReads = []; + private bool _completed; + private Exception? _completionError; + private int _count; + private bool _disposed; + private bool _isAdmissionOpen = true; + private long _lostCount; + private int _readIndex; + private int _writeIndex; + + /// Initializes a stream with the supplied bounded-buffer policy. + internal BoundedEventStream(EventStreamOptions options) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(options.Capacity); + if (!Enum.IsDefined(options.OverflowPolicy)) + { + throw new ArgumentOutOfRangeException(nameof(options)); + } + + _buffer = new T[options.Capacity]; + _overflowPolicy = options.OverflowPolicy; + } + + /// Gets the number of events intentionally not delivered because the bounded buffer was full. + internal long LostCount + { + get + { + lock (_gate) + { + return _lostCount; + } + } + } + + /// Gets whether callbacks can still publish into the stream. + internal bool IsAdmissionOpen + { + get + { + lock (_gate) + { + return _isAdmissionOpen; + } + } + } + + /// Gets whether no further events will be admitted. + internal bool IsCompleted + { + get + { + lock (_gate) + { + return _completed; + } + } + } + + /// Returns an asynchronous enumerator over copied events. + public IAsyncEnumerator GetAsyncEnumerator(CancellationToken cancellationToken = default) + { + return new Enumerator(this, cancellationToken); + } + + /// Closes admission and discards buffered events during deterministic subscription teardown. + public void Dispose() + { + List? pendingReads = null; + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + _isAdmissionOpen = false; + _completed = true; + ClearBuffer(); + pendingReads = DetachAllPendingReads(); + } + + CompletePendingReads(pendingReads, ReadResult.End); + } + + /// + /// Attempts to publish a copied callback event without waiting for an asynchronous consumer. A + /// + /// result means admission has closed or the selected overflow policy terminated the subscription. + /// + internal bool TryPublish(T value) + { + PendingRead? pendingRead = null; + lock (_gate) + { + if (!_isAdmissionOpen) + { + return false; + } + + pendingRead = DetachNextPendingRead(); + if (pendingRead is null) + { + if (_count < _buffer.Length) + { + Enqueue(value); + return true; + } + + switch (_overflowPolicy) + { + case EventStreamOverflowPolicy.DropOldest: + _buffer[_writeIndex] = value; + _writeIndex = NextIndex(_writeIndex); + _readIndex = _writeIndex; + _lostCount++; + return true; + + case EventStreamOverflowPolicy.DropNewest: + _lostCount++; + return true; + + case EventStreamOverflowPolicy.FailSubscription: + _lostCount++; + CompleteCore(new InvalidOperationException( + "The bounded callback stream overflowed and the subscription was closed."), true); + return false; + + default: + throw new InvalidOperationException( + "The bounded callback stream has an unknown overflow policy."); + } + } + } + + pendingRead.Complete(new ReadResult(value)); + return true; + } + + /// Closes admission and lets readers drain the copied events already accepted by the stream. + internal void Complete() + { + lock (_gate) + { + CompleteCore(null, false); + } + } + + /// Closes callback admission without completing existing readers until the host callback is neutralized. + internal void CloseAdmission() + { + lock (_gate) + { + _isAdmissionOpen = false; + } + } + + /// Closes admission and completes all consumers with the supplied failure. + internal void Complete(Exception error) + { + ArgumentNullException.ThrowIfNull(error); + lock (_gate) + { + CompleteCore(error, true); + } + } + + private ValueTask ReadAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return ValueTask.FromCanceled(cancellationToken); + } + + PendingRead? pendingRead = null; + lock (_gate) + { + if (_count != 0) + { + return ValueTask.FromResult(new ReadResult(Dequeue())); + } + + if (_completionError is not null) + { + return ValueTask.FromException(_completionError); + } + + if (_completed) + { + return ValueTask.FromResult(ReadResult.End); + } + + pendingRead = new PendingRead(this, cancellationToken); + pendingRead.Node = _pendingReads.AddLast(pendingRead); + } + + pendingRead.RegisterCancellation(); + return new ValueTask(pendingRead.Task); + } + + private void CancelPendingRead(PendingRead pendingRead, CancellationToken cancellationToken) + { + lock (_gate) + { + if (pendingRead.Node is null || !pendingRead.TryBeginCompletion()) + { + return; + } + + _pendingReads.Remove(pendingRead.Node); + pendingRead.Node = null; + } + + pendingRead.Cancel(cancellationToken); + } + + private PendingRead? DetachNextPendingRead() + { + while (_pendingReads.First is LinkedListNode node) + { + PendingRead pendingRead = node.Value; + _pendingReads.Remove(node); + pendingRead.Node = null; + if (pendingRead.TryBeginCompletion()) + { + return pendingRead; + } + } + + return null; + } + + private List? DetachAllPendingReads() + { + if (_pendingReads.Count == 0) + { + return null; + } + + List detached = new(_pendingReads.Count); + while (_pendingReads.First is LinkedListNode node) + { + PendingRead pendingRead = node.Value; + _pendingReads.RemoveFirst(); + pendingRead.Node = null; + if (pendingRead.TryBeginCompletion()) + { + detached.Add(pendingRead); + } + } + + return detached; + } + + private void CompleteCore(Exception? error, bool clearBufferedEvents) + { + if (_completed) + { + return; + } + + _isAdmissionOpen = false; + _completed = true; + _completionError = error; + if (clearBufferedEvents) + { + ClearBuffer(); + } + + List? pendingReads = DetachAllPendingReads(); + if (pendingReads is null) + { + return; + } + + if (error is null) + { + CompletePendingReads(pendingReads, ReadResult.End); + return; + } + + CompletePendingReads(pendingReads, error); + } + + private void Enqueue(T value) + { + _buffer[_writeIndex] = value; + _writeIndex = NextIndex(_writeIndex); + _count++; + } + + private T Dequeue() + { + T value = _buffer[_readIndex]; + _buffer[_readIndex] = default!; + _readIndex = NextIndex(_readIndex); + _count--; + return value; + } + + private void ClearBuffer() + { + if (RuntimeHelpers.IsReferenceOrContainsReferences()) + { + Array.Clear(_buffer); + } + + _readIndex = 0; + _writeIndex = 0; + _count = 0; + } + + private int NextIndex(int index) + { + return index == _buffer.Length - 1 ? 0 : index + 1; + } + + private static void CompletePendingReads(IEnumerable? pendingReads, ReadResult result) + { + if (pendingReads is null) + { + return; + } + + foreach (PendingRead pendingRead in pendingReads) + { + pendingRead.Complete(result); + } + } + + private static void CompletePendingReads(IEnumerable pendingReads, Exception error) + { + foreach (PendingRead pendingRead in pendingReads) + { + pendingRead.Fail(error); + } + } + + private readonly record struct ReadResult(bool HasValue, T? Value) + { + internal ReadResult(T value) + : this(true, value) + { + } + + internal static ReadResult End => new(false, default); + } + + private sealed class PendingRead(BoundedEventStream owner, CancellationToken cancellationToken) + { + private readonly CancellationToken _cancellationToken = cancellationToken; + private readonly BoundedEventStream _owner = owner; + + private readonly TaskCompletionSource _source = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + private int _completionStarted; + private CancellationTokenRegistration _registration; + + internal LinkedListNode? Node + { + get; + set; + } + + internal Task Task => _source.Task; + + internal bool TryBeginCompletion() + { + return Interlocked.CompareExchange(ref _completionStarted, 1, 0) == 0; + } + + internal void RegisterCancellation() + { + if (!_cancellationToken.CanBeCanceled) + { + return; + } + + _registration = _cancellationToken.UnsafeRegister(static state => + { + PendingRead pendingRead = (PendingRead) state!; + pendingRead._owner.CancelPendingRead(pendingRead, pendingRead._cancellationToken); + }, this); + + if (Volatile.Read(ref _completionStarted) != 0) + { + _registration.Unregister(); + } + } + + internal void Complete(ReadResult result) + { + _registration.Unregister(); + _source.TrySetResult(result); + } + + internal void Cancel(CancellationToken cancellationToken) + { + _source.TrySetCanceled(cancellationToken); + } + + internal void Fail(Exception error) + { + _registration.Unregister(); + _source.TrySetException(error); + } + } + + private sealed class Enumerator(BoundedEventStream owner, CancellationToken cancellationToken) + : IAsyncEnumerator + { + private readonly CancellationToken _cancellationToken = cancellationToken; + private readonly BoundedEventStream _owner = owner; + private int _disposed; + private int _moveNextInProgress; + + public T Current + { + get; + private set; + } = default!; + + public async ValueTask MoveNextAsync() + { + if (Volatile.Read(ref _disposed) != 0) + { + return false; + } + + if (Interlocked.Exchange(ref _moveNextInProgress, 1) != 0) + { + throw new InvalidOperationException( + "Concurrent MoveNextAsync calls are not supported for one event-stream enumerator."); + } + + try + { + ReadResult result = await _owner.ReadAsync(_cancellationToken).ConfigureAwait(false); + if (!result.HasValue) + { + Current = default!; + return false; + } + + Current = result.Value!; + return true; + } + finally + { + Volatile.Write(ref _moveNextInProgress, 0); + } + } + + public ValueTask DisposeAsync() + { + Interlocked.Exchange(ref _disposed, 1); + return ValueTask.CompletedTask; + } + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Events/EventStreamLease.cs b/libs/CheatEngine.Client.Core/Domains/Events/EventStreamLease.cs new file mode 100644 index 0000000..17c937b --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Events/EventStreamLease.cs @@ -0,0 +1,94 @@ +using System.Runtime.ExceptionServices; + +using CheatEngine.Client.Events; + +namespace CheatEngine.Client.Core.Domains.Events; + +/// +/// Owns the release sequence for one host callback and its copied, bounded Client event stream. The sequence is +/// deliberately ordered so that the host can no longer enqueue an event before consumers observe completion. +/// +/// The copied event type admitted by the callback. +internal sealed class EventStreamLease( + BoundedEventStream stream, + Action neutralizeCallback, + Action releaseHostRegistration, + Action>? untrack = null) : IEventStreamLease +{ + private readonly Lock _gate = new(); + + private readonly Action _neutralizeCallback = + neutralizeCallback ?? throw new ArgumentNullException(nameof(neutralizeCallback)); + + private readonly Action _releaseHostRegistration = releaseHostRegistration ?? + throw new ArgumentNullException(nameof(releaseHostRegistration)); + + private readonly BoundedEventStream _stream = stream ?? throw new ArgumentNullException(nameof(stream)); + private readonly Action>? _untrack = untrack; + private int _disposing; + private int _released; + + /// + public IAsyncEnumerable Events => _stream; + + /// + public long DroppedEventCount => _stream.LostCount; + + /// + public bool IsReleased => Volatile.Read(ref _released) != 0; + + /// + /// Releases the subscription in lifecycle order: stop admission, neutralize the host callback, complete the + /// stream, release the host registration, and finally untrack the lease. + /// + public void Dispose() + { + lock (_gate) + { + if (Volatile.Read(ref _released) != 0 || Volatile.Read(ref _disposing) != 0) + { + return; + } + + Volatile.Write(ref _disposing, 1); + Exception? firstFailure = null; + firstFailure = RunCleanupStep(_stream.CloseAdmission, firstFailure); + firstFailure = RunCleanupStep(_neutralizeCallback, firstFailure); + firstFailure = RunCleanupStep(_stream.Complete, firstFailure); + firstFailure = RunCleanupStep(_releaseHostRegistration, firstFailure); + firstFailure = RunCleanupStep(Untrack, firstFailure); + Volatile.Write(ref _released, 1); + Volatile.Write(ref _disposing, 0); + + if (firstFailure is not null) + { + ExceptionDispatchInfo.Capture(firstFailure).Throw(); + } + } + } + + /// Attempts to admit one copied event from the host callback without waiting for a consumer. + internal bool TryPublish(TEvent value) + { + return _stream.TryPublish(value); + } + + private void Untrack() + { + _untrack?.Invoke(this); + } + + private static Exception? RunCleanupStep(Action cleanup, Exception? firstFailure) + { + try + { + cleanup(); + } + catch (Exception exception) + { + return firstFailure ?? exception; + } + + return firstFailure; + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Events/UnavailableCapabilityFailure.cs b/libs/CheatEngine.Client.Core/Domains/Events/UnavailableCapabilityFailure.cs new file mode 100644 index 0000000..15e68e8 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Events/UnavailableCapabilityFailure.cs @@ -0,0 +1,34 @@ +using System.Diagnostics; + +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains.Events; + +/// Creates the uniform capability-gated result used by high-level domains awaiting their live-host gate. +internal static class UnavailableCapabilityFailure +{ + internal static CheatEngineFailure Create(string capabilityName, string operation, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(capabilityName); + ArgumentException.ThrowIfNullOrWhiteSpace(operation); + + return cancellationToken.IsCancellationRequested + ? new CheatEngineFailure(CheatEngineFailureKind.Cancelled, operation, + "The operation was cancelled before Cheat Engine work began.") + : new CheatEngineFailure(CheatEngineFailureKind.CapabilityUnavailable, operation, + $"{capabilityName} is unavailable until its ownership, thread-affinity, cleanup, disable, re-enable, " + + "and target-change behavior pass the required Cheat Engine 7.7 x64 live gate."); + } + + internal static T Throw(CheatEngineFailure failure) + { + failure.Throw(); + throw new UnreachableException(); + } + + internal static void Throw(CheatEngineFailure failure) + { + failure.Throw(); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Hashing/UnavailableHashingClient.cs b/libs/CheatEngine.Client.Core/Domains/Hashing/UnavailableHashingClient.cs new file mode 100644 index 0000000..8804838 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Hashing/UnavailableHashingClient.cs @@ -0,0 +1,42 @@ +using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Hashing; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains.Hashing; + +/// Preserves the separate file and target-memory hash contracts until their live-host gates are complete. +internal sealed class UnavailableHashingClient : IHashingClient +{ + public bool TryHashMemory(MemoryHashRequest request, out HashDigest digest, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + digest = default; + failure = CreateFailure("Hashing.HashMemory", cancellationToken); + return false; + } + + public HashDigest HashMemory(MemoryHashRequest request, CancellationToken cancellationToken = default) + { + _ = TryHashMemory(request, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } + + public bool TryHashFile(FileHashRequest request, out HashDigest digest, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + digest = default; + failure = CreateFailure("Hashing.HashFile", cancellationToken); + return false; + } + + public HashDigest HashFile(FileHashRequest request, CancellationToken cancellationToken = default) + { + _ = TryHashFile(request, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } + + private static CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) + { + return UnavailableCapabilityFailure.Create("Target-memory and file hashing", operation, cancellationToken); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Hotkeys/UnavailableHotkeyClient.cs b/libs/CheatEngine.Client.Core/Domains/Hotkeys/UnavailableHotkeyClient.cs new file mode 100644 index 0000000..459e033 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Hotkeys/UnavailableHotkeyClient.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Events; +using CheatEngine.Client.Hotkeys; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains.Hotkeys; + +/// Preserves copied hotkey semantics until host callback lifecycle behavior passes the live-host gate. +internal sealed class UnavailableHotkeyClient : IHotkeyClient +{ + public bool TryRegister(HotkeyRegistration registration, HotkeyHandler handler, EventStreamOptions streamOptions, + [NotNullWhen(true)] out IHotkeyLease? lease, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(handler); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamOptions.Capacity); + lease = null; + failure = UnavailableCapabilityFailure.Create("Hotkeys", "Hotkeys.Register", cancellationToken); + return false; + } + + public IHotkeyLease Register(HotkeyRegistration registration, HotkeyHandler handler, + EventStreamOptions streamOptions, + CancellationToken cancellationToken = default) + { + _ = TryRegister(registration, handler, streamOptions, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs b/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs index 2defb5b..57dec30 100644 --- a/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs +++ b/libs/CheatEngine.Client.Core/Domains/IProcessHost.cs @@ -8,6 +8,7 @@ internal interface IProcessHost public long GetOpenedProcessId(); public void OpenProcess(long processId); public bool TryGetLocalProcess(int processId, out LocalProcessInfo process); + public IReadOnlyList GetLocalProcesses(); public IReadOnlyList FindProcessesByExactName(string processName); public CheatEngineArchitecture GetTargetArchitecture(); } diff --git a/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs b/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs index 42d79e0..f8a5959 100644 --- a/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs +++ b/libs/CheatEngine.Client.Core/Domains/LocalProcessHost.cs @@ -32,6 +32,21 @@ public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) } } + public IReadOnlyList GetLocalProcesses() + { + using ProcessCollection processes = new(Process.GetProcesses()); + List captured = new(processes.Count); + for (int index = 0; index < processes.Count; index++) + { + if (TryCapture(processes[index], out LocalProcessInfo process)) + { + captured.Add(process); + } + } + + return captured; + } + public IReadOnlyList FindProcessesByExactName(string processName) { ArgumentException.ThrowIfNullOrWhiteSpace(processName); diff --git a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs index 047b2ad..fd3d6a4 100644 --- a/libs/CheatEngine.Client.Core/Domains/LuaClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/LuaClient.cs @@ -13,9 +13,18 @@ internal sealed class LuaClient : ILuaClient { private readonly ICheatEngineDispatcher _dispatcher; private readonly Func _epochProvider; + private readonly Func _isContextCurrent; - private readonly HashSet _registeredModules = new(ReferenceEqualityComparer.Instance); + + // This is deliberately a single lock-protected reservation. A generated module has two identities that must move + // together: its managed module instance and the immutable Lua name set published in its descriptor. Reserving the + // complete set before entering the dispatcher prevents a concurrent registration from partially mutating Lua. + private readonly Dictionary _registeredModules = + new(ReferenceEqualityComparer.Instance); + private readonly Lock _registeredModulesLock = new(); + private readonly HashSet _reservedExportNames = new(StringComparer.Ordinal); + private readonly HashSet _reservedModuleNames = new(StringComparer.Ordinal); private readonly Action _trackLease; private readonly Action _untrackLease; @@ -65,8 +74,16 @@ public bool TryRegisterModule(ILuaModule luaModule, [NotNullWhen(true)] out ILua return false; } - if (!TryReserveModule(luaModule, out failure)) + try + { + if (!TryReserveModule(luaModule, out failure)) + { + return false; + } + } + catch (Exception exception) { + failure = CoreFailureFactory.FromException("Lua.RegisterModule", exception); return false; } @@ -147,31 +164,14 @@ public bool TryExecute(ILuaOperation operation, [MaybeNullWhen } long epoch = _epochProvider(); - LuaOperationResult operationResult = default; - if (!_dispatcher.TryInvoke( - () => operationResult = ExecuteOperation(operation, epoch), - out failure, + if (!TryDispatchOperation(operation, epoch, out LuaOperationResult operationResult, out failure, cancellationToken)) { result = default; return false; } - if (!operationResult.Succeeded) - { - result = default; - failure = IsDefined(operationResult.Failure) - ? operationResult.Failure - : new CheatEngineFailure( - CheatEngineFailureKind.Unknown, - "Lua.Execute", - "The typed Lua operation returned failure without an associated Cheat Engine failure."); - return false; - } - - result = operationResult.Result; - failure = default; - return true; + return TryMaterializeOperationResult(operationResult, out result, out failure); } public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default) @@ -185,16 +185,37 @@ public TResult Execute(ILuaOperation operation, CancellationTo } public bool TryExecute(TOperation operation, [MaybeNullWhen(false)] out TResult result, - out CheatEngineFailure failure, CancellationToken cancellationToken = default) - where TOperation : ILuaOperation + out CheatEngineFailure failure, CancellationToken cancellationToken) + where TOperation : struct, ILuaOperation { - return TryExecute(operation, out result, out failure, cancellationToken); + if (cancellationToken.IsCancellationRequested) + { + result = default; + failure = CoreFailureFactory.Cancelled("Lua.Execute"); + return false; + } + + long epoch = _epochProvider(); + if (!TryDispatchOperation(operation, epoch, out LuaOperationResult operationResult, out failure, + cancellationToken)) + { + result = default; + return false; + } + + return TryMaterializeOperationResult(operationResult, out result, out failure); } - public TResult Execute(TOperation operation, CancellationToken cancellationToken = default) - where TOperation : ILuaOperation + public TResult Execute(TOperation operation, CancellationToken cancellationToken) + where TOperation : struct, ILuaOperation { - return Execute(operation, cancellationToken); + if (TryExecute(operation, out TResult? result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + return ThrowFailure(failure); } private static T ThrowFailure(CheatEngineFailure failure) @@ -218,6 +239,95 @@ private LuaOperationResult ExecuteOperation(ILuaOperation ExecuteOperation(TOperation operation, long epoch) + where TOperation : struct, ILuaOperation + { + LuaOperationContext context = new(epoch, _isContextCurrent); + try + { + context.ThrowIfExpired(); + // The constraint produces a constrained interface call for generated readonly record structs. This keeps the + // normal generated-operation path free of an ILuaOperation box. + bool succeeded = operation.TryExecute(context, out TResult? result, out CheatEngineFailure failure); + return new LuaOperationResult(succeeded, result!, failure); + } + finally + { + context.Expire(); + } + } + + private bool TryDispatchOperation( + ILuaOperation operation, + long epoch, + out LuaOperationResult result, + out CheatEngineFailure failure, + CancellationToken cancellationToken) + { + if (_dispatcher is IStatefulCheatEngineDispatcher statefulDispatcher) + { + return statefulDispatcher.TryInvoke( + new LuaOperationDispatchState(this, operation, epoch), + static dispatchState => dispatchState.Execute(), + out result, + out failure, + cancellationToken); + } + + return _dispatcher.TryInvoke( + () => ExecuteOperation(operation, epoch), + out result, + out failure, + cancellationToken); + } + + private bool TryDispatchOperation( + TOperation operation, + long epoch, + out LuaOperationResult result, + out CheatEngineFailure failure, + CancellationToken cancellationToken) + where TOperation : struct, ILuaOperation + { + if (_dispatcher is IStatefulCheatEngineDispatcher statefulDispatcher) + { + return statefulDispatcher.TryInvoke( + new LuaOperationDispatchState(this, operation, epoch), + static dispatchState => dispatchState.Execute(), + out result, + out failure, + cancellationToken); + } + + return _dispatcher.TryInvoke( + () => ExecuteOperation(operation, epoch), + out result, + out failure, + cancellationToken); + } + + private static bool TryMaterializeOperationResult( + LuaOperationResult operationResult, + [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure) + { + if (!operationResult.Succeeded) + { + result = default; + failure = IsDefined(operationResult.Failure) + ? operationResult.Failure + : new CheatEngineFailure( + CheatEngineFailureKind.Unknown, + "Lua.Execute", + "The typed Lua operation returned failure without an associated Cheat Engine failure."); + return false; + } + + result = operationResult.Result; + failure = default; + return true; + } + private static bool IsDefined(CheatEngineFailure failure) { return !string.IsNullOrWhiteSpace(failure.Operation) && !string.IsNullOrWhiteSpace(failure.Message); @@ -227,23 +337,70 @@ private bool TryReserveModule(ILuaModule module, out CheatEngineFailure failure) { lock (_registeredModulesLock) { - if (_registeredModules.Add(module)) + if (_registeredModules.ContainsKey(module)) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Lua.RegisterModule", + "This client activation already owns the supplied Lua module instance."); + return false; + } + + if (module is IDescribedLuaModule describedModule) { + LuaModuleDescriptor descriptor = describedModule.Descriptor; + if (_reservedModuleNames.Contains(descriptor.Name)) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Lua.RegisterModule", + $"The Lua module identity '{descriptor.Name}' is already reserved by this client activation."); + return false; + } + + foreach (LuaExportDescriptor export in descriptor.Exports) + { + if (_reservedExportNames.Contains(export.Name)) + { + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Lua.RegisterModule", + $"The Lua export '{export.Name}' is already reserved by this client activation."); + return false; + } + } + + LuaModuleReservation reservation = LuaModuleReservation.Create(descriptor); + _registeredModules.Add(module, reservation); + _reservedModuleNames.Add(reservation.ModuleName!); + foreach (string exportName in reservation.ExportNames) + { + _reservedExportNames.Add(exportName); + } + failure = default; return true; } - } - failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Lua.RegisterModule", - "This client activation already owns the supplied Lua module instance."); - return false; + // Manual ILuaModule implementations remain a supported escape hatch. They participate in instance ownership + // only because the Client cannot truthfully infer their global Lua names without reflection or raw Lua access. + _registeredModules.Add(module, LuaModuleReservation.Manual); + failure = default; + return true; + } } private void ReleaseModule(ILuaModule module) { lock (_registeredModulesLock) { - _registeredModules.Remove(module); + if (!_registeredModules.Remove(module, out LuaModuleReservation? reservation)) + { + return; + } + + if (reservation.ModuleName is not null) + { + _reservedModuleNames.Remove(reservation.ModuleName); + foreach (string exportName in reservation.ExportNames) + { + _reservedExportNames.Remove(exportName); + } + } } } @@ -267,4 +424,78 @@ private readonly record struct LuaClientInitialization( Func IsContextCurrent, Action TrackLease, Action UntrackLease); + + private readonly struct LuaOperationDispatchState + { + private readonly LuaClient _client; + private readonly long _epoch; + private readonly ILuaOperation _operation; + + internal LuaOperationDispatchState(LuaClient client, ILuaOperation operation, long epoch) + { + _client = client; + _operation = operation; + _epoch = epoch; + } + + internal LuaOperationResult Execute() + { + return _client.ExecuteOperation(_operation, _epoch); + } + } + + private readonly struct LuaOperationDispatchState + where TOperation : struct, ILuaOperation + { + private readonly LuaClient _client; + private readonly long _epoch; + private readonly TOperation _operation; + + internal LuaOperationDispatchState(LuaClient client, TOperation operation, long epoch) + { + _client = client; + _operation = operation; + _epoch = epoch; + } + + internal LuaOperationResult Execute() + { + return _client.ExecuteOperation(_operation, _epoch); + } + } + + private sealed class LuaModuleReservation + { + private LuaModuleReservation(string? moduleName, string[] exportNames) + { + ModuleName = moduleName; + ExportNames = exportNames; + } + + internal static LuaModuleReservation Manual + { + get; + } = new(null, []); + + internal string[] ExportNames + { + get; + } + + internal string? ModuleName + { + get; + } + + internal static LuaModuleReservation Create(LuaModuleDescriptor descriptor) + { + string[] exportNames = new string[descriptor.Exports.Length]; + for (int index = 0; index < exportNames.Length; index++) + { + exportNames[index] = descriptor.Exports[index].Name; + } + + return new LuaModuleReservation(descriptor.Name, exportNames); + } + } } diff --git a/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs index fa2d872..1318867 100644 --- a/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/MemoryClient.cs @@ -1,6 +1,10 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using CheatEngine.Client.Core.Dispatching; using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Dispatching; using CheatEngine.Client.Memory; @@ -18,27 +22,25 @@ internal sealed class MemoryClient(ICheatEngineDispatcher dispatcher) : IMemoryC public bool TryReadPrimitive(Address address, [MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - T? captured = default; - string? hostFailure = null; - bool succeeded = false; - if (!_dispatcher.TryInvoke(() => succeeded = TryReadKnown(address, out captured, out hostFailure), - out failure, cancellationToken)) + if (!TryInvoke(address, static current => PrimitiveMemoryCodec.Read(current), + out PrimitiveReadOutcome outcome, out failure, cancellationToken)) { value = default; return false; } - if (!succeeded) + if (!outcome.Succeeded) { value = default; - failure = hostFailure is null + failure = !outcome.Handled ? new CheatEngineFailure(CheatEngineFailureKind.Unsupported, "Memory.ReadPrimitive", $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type.") - : new CheatEngineFailure(CheatEngineFailureKind.MemoryReadFailed, "Memory.ReadPrimitive", hostFailure); + : new CheatEngineFailure(CheatEngineFailureKind.MemoryReadFailed, "Memory.ReadPrimitive", + outcome.Failure ?? "Cheat Engine rejected the target-memory read."); return false; } - value = captured!; + value = outcome.Value; failure = default; return true; } @@ -57,23 +59,20 @@ public T ReadPrimitive(Address address, CancellationToken cancellationToken = public bool TryWritePrimitive(Address address, T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - string? hostFailure = null; - bool handled = false; - bool succeeded = false; - if (!_dispatcher.TryInvoke( - () => succeeded = TryWriteKnown(address, value, out handled, out hostFailure), - out failure, cancellationToken)) + PrimitiveWriteInput input = new(address, value); + if (!TryInvoke(input, static current => PrimitiveMemoryCodec.Write(current.Address, current.Value), + out PrimitiveWriteOutcome outcome, out failure, cancellationToken)) { return false; } - if (!succeeded) + if (!outcome.Succeeded) { - failure = !handled + failure = !outcome.Handled ? new CheatEngineFailure(CheatEngineFailureKind.Unsupported, "Memory.WritePrimitive", $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type.") : new CheatEngineFailure(CheatEngineFailureKind.MemoryWriteFailed, "Memory.WritePrimitive", - hostFailure ?? "Cheat Engine rejected the target-memory write."); + outcome.Failure ?? "Cheat Engine rejected the target-memory write."); return false; } @@ -89,6 +88,79 @@ public void WritePrimitive(Address address, T value, CancellationToken cancel } } + public bool TryReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, out ImmutableArray values, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ValidateBatch(request.Addresses, nameof(request)); + if (!TryInvoke(request, static current => PrimitiveMemoryCodec.ReadBatch(current), + out PrimitiveBatchReadOutcome outcome, out failure, cancellationToken)) + { + values = []; + return false; + } + + if (!outcome.Succeeded) + { + values = []; + failure = !outcome.Handled + ? new CheatEngineFailure(CheatEngineFailureKind.Unsupported, "Memory.ReadPrimitiveBatch", + $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type.") + : new CheatEngineFailure(CheatEngineFailureKind.MemoryReadFailed, "Memory.ReadPrimitiveBatch", + $"The batch read failed at index {outcome.FailedIndex}: {outcome.Failure}"); + return false; + } + + values = ImmutableCollectionsMarshal.AsImmutableArray(outcome.Values!); + failure = default; + return true; + } + + public ImmutableArray ReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, + CancellationToken cancellationToken = default) + { + if (TryReadPrimitiveBatch(request, out ImmutableArray values, out CheatEngineFailure failure, + cancellationToken)) + { + return values; + } + + failure.Throw(); + return []; + } + + public bool TryWritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + ValidateBatch(request.Values, nameof(request)); + if (!TryInvoke(request, static current => PrimitiveMemoryCodec.WriteBatch(current), + out PrimitiveBatchWriteOutcome outcome, out failure, cancellationToken)) + { + return false; + } + + if (!outcome.Succeeded) + { + failure = !outcome.Handled + ? new CheatEngineFailure(CheatEngineFailureKind.Unsupported, "Memory.WritePrimitiveBatch", + $"'{typeof(T).FullName}' is not a built-in CheatEngine.Client memory type.") + : new CheatEngineFailure(CheatEngineFailureKind.MemoryWriteFailed, "Memory.WritePrimitiveBatch", + $"The batch write failed at index {outcome.FailedIndex}: {outcome.Failure}"); + return false; + } + + failure = default; + return true; + } + + public void WritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, + CancellationToken cancellationToken = default) + { + if (!TryWritePrimitiveBatch(request, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + public bool TryRead(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { @@ -169,7 +241,7 @@ public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray 0 && GetEncodedLength(request.Value, request.WideCharacter) > request.MaximumLength) + { + throw new ArgumentException("The encoded text exceeds the explicit maximum length.", nameof(request)); + } + string? hostFailure = null; bool succeeded = false; if (!_dispatcher.TryInvoke(() => @@ -350,6 +427,19 @@ public Address ResolvePointerChain(PointerChainRequest request, CancellationToke return default; } + private bool TryInvoke(TState state, Func callback, + [MaybeNullWhen(false)] out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(callback); + if (_dispatcher is IStatefulCheatEngineDispatcher statefulDispatcher) + { + return statefulDispatcher.TryInvoke(state, callback, out result, out failure, cancellationToken); + } + + return _dispatcher.TryInvoke(() => callback(state), out result, out failure, cancellationToken); + } + private static bool TryReadCore(MemoryReadRequest request, [MaybeNullWhen(false)] out T value, out string? failure) { @@ -378,174 +468,236 @@ private static bool TryWriteCore(MemoryWriteRequest request, out string? f return false; } - private static bool TryReadKnown(Address address, [MaybeNullWhen(false)] out T value, out string? failure) + private static void ValidateBatch(ImmutableArray values, string parameterName) { - object? boxed = null; - bool succeeded; - if (typeof(T) == typeof(byte)) - { - succeeded = Read(TargetMemory.TryReadUInt8, address, out boxed, out failure); - } - else if (typeof(T) == typeof(sbyte)) - { - succeeded = Read(TargetMemory.TryReadInt8, address, out boxed, out failure); - } - else if (typeof(T) == typeof(ushort)) - { - succeeded = Read(TargetMemory.TryReadUInt16, address, out boxed, out failure); - } - else if (typeof(T) == typeof(short)) - { - succeeded = Read(TargetMemory.TryReadInt16, address, out boxed, out failure); - } - else if (typeof(T) == typeof(uint)) - { - succeeded = Read(TargetMemory.TryReadUInt32, address, out boxed, out failure); - } - else if (typeof(T) == typeof(int)) - { - succeeded = Read(TargetMemory.TryReadInt32, address, out boxed, out failure); - } - else if (typeof(T) == typeof(ulong)) - { - succeeded = Read(TargetMemory.TryReadUInt64, address, out boxed, out failure); - } - else if (typeof(T) == typeof(long)) - { - succeeded = Read(TargetMemory.TryReadInt64, address, out boxed, out failure); - } - else if (typeof(T) == typeof(float)) - { - succeeded = Read(TargetMemory.TryReadSingle, address, out boxed, out failure); - } - else if (typeof(T) == typeof(double)) - { - succeeded = Read(TargetMemory.TryReadDouble, address, out boxed, out failure); - } - else if (typeof(T) == typeof(Address)) + if (values.IsDefaultOrEmpty) { - succeeded = Read
(TargetMemory.TryReadPointer, address, out boxed, out failure); + throw new ArgumentException("A memory batch requires at least one operation.", parameterName); } - else + + if (values.Length > MemoryBatchLimits.MaximumOperations) { - value = default; - failure = null; - return false; + throw new ArgumentOutOfRangeException(parameterName, + $"A memory batch is limited to {MemoryBatchLimits.MaximumOperations} operations."); } + } + + private static int GetEncodedLength(string value, bool wideCharacter) + { + return wideCharacter ? value.Length : Encoding.UTF8.GetByteCount(value); + } - if (succeeded && boxed is T typed) + private static bool TryMapMemoryFailure(bool succeeded, bool isWrite, string operation, string? hostFailure, + out CheatEngineFailure failure) + { + if (succeeded) { - value = typed; + failure = default; return true; } - value = default; + failure = new CheatEngineFailure( + isWrite ? CheatEngineFailureKind.MemoryWriteFailed : CheatEngineFailureKind.MemoryReadFailed, + operation, + hostFailure ?? "Cheat Engine rejected the target-memory operation."); return false; } - private static bool TryWriteKnown(Address address, T value, out bool handled, out string? failure) + private static TTo Reinterpret(TFrom value) { - handled = true; - if (typeof(T) == typeof(byte)) - { - return Write(TargetMemory.TryWriteUInt8, address, (byte) (object) value!, out failure); - } + return Unsafe.As(ref value); + } - if (typeof(T) == typeof(sbyte)) - { - return Write(TargetMemory.TryWriteInt8, address, (sbyte) (object) value!, out failure); - } + private readonly record struct PrimitiveWriteInput(Address Address, T Value); - if (typeof(T) == typeof(ushort)) - { - return Write(TargetMemory.TryWriteUInt16, address, (ushort) (object) value!, out failure); - } + private readonly record struct PrimitiveReadOutcome(bool Handled, bool Succeeded, T Value, string? Failure); - if (typeof(T) == typeof(short)) - { - return Write(TargetMemory.TryWriteInt16, address, (short) (object) value!, out failure); - } + private readonly record struct PrimitiveWriteOutcome(bool Handled, bool Succeeded, string? Failure); - if (typeof(T) == typeof(uint)) - { - return Write(TargetMemory.TryWriteUInt32, address, (uint) (object) value!, out failure); - } + private readonly record struct PrimitiveBatchReadOutcome( + bool Handled, + bool Succeeded, + T[]? Values, + int FailedIndex, + string? Failure); - if (typeof(T) == typeof(int)) - { - return Write(TargetMemory.TryWriteInt32, address, (int) (object) value!, out failure); - } + private readonly record struct PrimitiveBatchWriteOutcome( + bool Handled, + bool Succeeded, + int FailedIndex, + string? Failure); - if (typeof(T) == typeof(ulong)) + private static class PrimitiveMemoryCodec + { + internal static PrimitiveReadOutcome Read(Address address) { - return Write(TargetMemory.TryWriteUInt64, address, (ulong) (object) value!, out failure); - } + if (typeof(T) == typeof(byte)) + { + return Read(TargetMemory.TryReadUInt8, address); + } - if (typeof(T) == typeof(long)) - { - return Write(TargetMemory.TryWriteInt64, address, (long) (object) value!, out failure); - } + if (typeof(T) == typeof(sbyte)) + { + return Read(TargetMemory.TryReadInt8, address); + } - if (typeof(T) == typeof(float)) - { - return Write(TargetMemory.TryWriteSingle, address, (float) (object) value!, out failure); + if (typeof(T) == typeof(ushort)) + { + return Read(TargetMemory.TryReadUInt16, address); + } + + if (typeof(T) == typeof(short)) + { + return Read(TargetMemory.TryReadInt16, address); + } + + if (typeof(T) == typeof(uint)) + { + return Read(TargetMemory.TryReadUInt32, address); + } + + if (typeof(T) == typeof(int)) + { + return Read(TargetMemory.TryReadInt32, address); + } + + if (typeof(T) == typeof(ulong)) + { + return Read(TargetMemory.TryReadUInt64, address); + } + + if (typeof(T) == typeof(long)) + { + return Read(TargetMemory.TryReadInt64, address); + } + + if (typeof(T) == typeof(float)) + { + return Read(TargetMemory.TryReadSingle, address); + } + + if (typeof(T) == typeof(double)) + { + return Read(TargetMemory.TryReadDouble, address); + } + + if (typeof(T) == typeof(Address)) + { + return Read
(TargetMemory.TryReadPointer, address); + } + + return new PrimitiveReadOutcome(false, false, default!, null); } - if (typeof(T) == typeof(double)) + internal static PrimitiveWriteOutcome Write(Address address, T value) { - return Write(TargetMemory.TryWriteDouble, address, (double) (object) value!, out failure); + if (typeof(T) == typeof(byte)) + { + return Write(TargetMemory.TryWriteUInt8, address, value); + } + + if (typeof(T) == typeof(sbyte)) + { + return Write(TargetMemory.TryWriteInt8, address, value); + } + + if (typeof(T) == typeof(ushort)) + { + return Write(TargetMemory.TryWriteUInt16, address, value); + } + + if (typeof(T) == typeof(short)) + { + return Write(TargetMemory.TryWriteInt16, address, value); + } + + if (typeof(T) == typeof(uint)) + { + return Write(TargetMemory.TryWriteUInt32, address, value); + } + + if (typeof(T) == typeof(int)) + { + return Write(TargetMemory.TryWriteInt32, address, value); + } + + if (typeof(T) == typeof(ulong)) + { + return Write(TargetMemory.TryWriteUInt64, address, value); + } + + if (typeof(T) == typeof(long)) + { + return Write(TargetMemory.TryWriteInt64, address, value); + } + + if (typeof(T) == typeof(float)) + { + return Write(TargetMemory.TryWriteSingle, address, value); + } + + if (typeof(T) == typeof(double)) + { + return Write(TargetMemory.TryWriteDouble, address, value); + } + + if (typeof(T) == typeof(Address)) + { + return Write
(TargetMemory.TryWritePointer, address, value); + } + + return new PrimitiveWriteOutcome(false, false, null); } - if (typeof(T) == typeof(Address)) + internal static PrimitiveBatchReadOutcome ReadBatch(MemoryPrimitiveBatchReadRequest request) { - return Write(TargetMemory.TryWritePointer, address, (Address) (object) value!, out failure); - } + T[] values = new T[request.Addresses.Length]; + for (int index = 0; index < request.Addresses.Length; index++) + { + PrimitiveReadOutcome current = Read(request.Addresses[index]); + if (!current.Succeeded) + { + return new PrimitiveBatchReadOutcome(current.Handled, false, null, index, current.Failure); + } - handled = false; - failure = null; - return false; - } + values[index] = current.Value; + } - private static bool Read(Reader reader, Address address, out object? value, out string? failure) - { - if (reader(address, out T result, out MemoryAccessFailure sdkFailure)) - { - value = result; - failure = null; - return true; + return new PrimitiveBatchReadOutcome(true, true, values, -1, null); } - value = null; - failure = sdkFailure.ToString(); - return false; - } - - private static bool Write(Writer writer, Address address, T value, out string? failure) - { - if (writer(address, value, out MemoryAccessFailure sdkFailure)) + internal static PrimitiveBatchWriteOutcome WriteBatch(MemoryPrimitiveBatchWriteRequest request) { - failure = null; - return true; - } + for (int index = 0; index < request.Values.Length; index++) + { + MemoryAddressValue current = request.Values[index]; + PrimitiveWriteOutcome outcome = Write(current.Address, current.Value); + if (!outcome.Succeeded) + { + return new PrimitiveBatchWriteOutcome(outcome.Handled, false, index, outcome.Failure); + } + } - failure = sdkFailure.ToString(); - return false; - } + return new PrimitiveBatchWriteOutcome(true, true, -1, null); + } - private static bool TryMapMemoryFailure(bool succeeded, bool isWrite, string operation, string? hostFailure, - out CheatEngineFailure failure) - { - if (succeeded) + private static PrimitiveReadOutcome Read(Reader reader, Address address) { - failure = default; - return true; + if (reader(address, out TValue value, out MemoryAccessFailure failure)) + { + return new PrimitiveReadOutcome(true, true, Reinterpret(value), null); + } + + return new PrimitiveReadOutcome(true, false, default!, failure.ToString()); } - failure = new CheatEngineFailure( - isWrite ? CheatEngineFailureKind.MemoryWriteFailed : CheatEngineFailureKind.MemoryReadFailed, - operation, - hostFailure ?? "Cheat Engine rejected the target-memory operation."); - return false; + private static PrimitiveWriteOutcome Write(Writer writer, Address address, T value) + { + TValue targetValue = Reinterpret(value); + return writer(address, targetValue, out MemoryAccessFailure failure) + ? new PrimitiveWriteOutcome(true, true, null) + : new PrimitiveWriteOutcome(true, false, failure.ToString()); + } } private delegate bool Reader(Address address, out T value, out MemoryAccessFailure failure); diff --git a/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs b/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs index a51718d..639152d 100644 --- a/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/ProcessClient.cs @@ -1,3 +1,6 @@ +using System.Collections.Immutable; +using System.ComponentModel; + using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Dispatching; using CheatEngine.Client.Processes; @@ -41,6 +44,78 @@ public bool TryGetCurrent( return TryReadCurrent("Processes.GetCurrent", out snapshot, out failure, cancellationToken); } + public bool TryGetProcesses( + ProcessEnumerationRequest request, + out ProcessEnumerationResult result, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ValidateEnumerationRequest(request); + if (cancellationToken.IsCancellationRequested) + { + result = default; + failure = Cancelled("Processes.GetProcesses"); + return false; + } + + try + { + IReadOnlyList localProcesses = _host.GetLocalProcesses(); + List matching = new(localProcesses.Count); + for (int index = 0; index < localProcesses.Count; index++) + { + LocalProcessInfo process = localProcesses[index]; + if (Matches(request, process)) + { + matching.Add(process); + } + } + + matching.Sort(static (left, right) => left.Id.CompareTo(right.Id)); + int materializedCount = Math.Min(matching.Count, request.MaximumItems); + ProcessInfoSnapshot[] snapshots = new ProcessInfoSnapshot[materializedCount]; + for (int index = 0; index < materializedCount; index++) + { + LocalProcessInfo process = matching[index]; + snapshots[index] = new ProcessInfoSnapshot( + new TargetProcessId(process.Id), + process.Name, + process.ExecutablePath); + } + + result = new ProcessEnumerationResult( + ImmutableArray.Create(snapshots), + matching.Count > materializedCount); + failure = default; + return true; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException + or Win32Exception or PlatformNotSupportedException) + { + result = default; + failure = new CheatEngineFailure( + CheatEngineFailureKind.OperationRejected, + "Processes.GetProcesses", + "The local process list could not be materialized.", + exception); + return false; + } + } + + public ProcessEnumerationResult GetProcesses( + ProcessEnumerationRequest request, + CancellationToken cancellationToken = default) + { + if (TryGetProcesses(request, out ProcessEnumerationResult result, out CheatEngineFailure failure, + cancellationToken)) + { + return result; + } + + failure.Throw(); + return default; + } + public ProcessSnapshot GetCurrent(CancellationToken cancellationToken = default) { if (TryGetCurrent(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) @@ -158,6 +233,103 @@ public ProcessSnapshot AttachExactName(string processName, CancellationToken can return default; } + public bool TryAttachForeground( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryUnavailable("Processes.AttachForeground", out snapshot, out failure, cancellationToken); + } + + public ProcessSnapshot AttachForeground(CancellationToken cancellationToken = default) + { + if (TryAttachForeground(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryCreate( + ProcessStartRequest request, + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ValidateStartRequest(request); + return TryUnavailable("Processes.Create", out snapshot, out failure, cancellationToken); + } + + public ProcessSnapshot Create(ProcessStartRequest request, CancellationToken cancellationToken = default) + { + if (TryCreate(request, out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryPause( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryUnavailable("Processes.Pause", out snapshot, out failure, cancellationToken); + } + + public ProcessSnapshot Pause(CancellationToken cancellationToken = default) + { + if (TryPause(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryResumeExecution( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryUnavailable("Processes.Resume", out snapshot, out failure, cancellationToken); + } + + public ProcessSnapshot ResumeExecution(CancellationToken cancellationToken = default) + { + if (TryResumeExecution(out ProcessSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + + public bool TryGetPauseState( + out ProcessPauseSnapshot snapshot, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return TryUnavailable("Processes.GetPauseState", out snapshot, out failure, cancellationToken); + } + + public ProcessPauseSnapshot GetPauseState(CancellationToken cancellationToken = default) + { + if (TryGetPauseState(out ProcessPauseSnapshot snapshot, out CheatEngineFailure failure, cancellationToken)) + { + return snapshot; + } + + failure.Throw(); + return default; + } + private bool TryReadCurrent( string operation, out ProcessSnapshot snapshot, @@ -288,5 +460,62 @@ private static string NormalizeExactProcessName(string processName) return normalized; } + private static void ValidateEnumerationRequest(ProcessEnumerationRequest request) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.MaximumItems); + if (request.NameContains is { Length: 0 }) + { + throw new ArgumentException("A process-name filter must be null or non-empty.", nameof(request)); + } + } + + private static void ValidateStartRequest(ProcessStartRequest request) + { + if (string.IsNullOrWhiteSpace(request.ExecutablePath)) + { + throw new ArgumentException("The executable path must not be empty.", nameof(request)); + } + + if (!Path.IsPathFullyQualified(request.ExecutablePath)) + { + throw new ArgumentException("The executable path must be absolute.", nameof(request)); + } + + if (request.WorkingDirectory is { } directory && !Path.IsPathFullyQualified(directory)) + { + throw new ArgumentException("The working directory must be absolute when specified.", nameof(request)); + } + } + + private static bool Matches(ProcessEnumerationRequest request, LocalProcessInfo process) + { + return request.NameContains is null || + process.Name?.IndexOf(request.NameContains, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static bool TryUnavailable( + string operation, + out T result, + out CheatEngineFailure failure, + CancellationToken cancellationToken) + { + result = default!; + failure = cancellationToken.IsCancellationRequested + ? Cancelled(operation) + : new CheatEngineFailure( + CheatEngineFailureKind.CapabilityUnavailable, + operation, + "This operation requires a validated Cheat Engine process-control binding."); + return false; + } + + private static CheatEngineFailure Cancelled(string operation) + { + return new CheatEngineFailure( + CheatEngineFailureKind.Cancelled, + operation, + "The operation was cancelled before process-host admission."); + } + private readonly record struct ProcessSelection(TargetProcessId Id, CheatEngineArchitecture Architecture); } diff --git a/libs/CheatEngine.Client.Core/Domains/RemoteExecution/UnavailableRemoteExecutionClient.cs b/libs/CheatEngine.Client.Core/Domains/RemoteExecution/UnavailableRemoteExecutionClient.cs new file mode 100644 index 0000000..96893e1 --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/RemoteExecution/UnavailableRemoteExecutionClient.cs @@ -0,0 +1,41 @@ +using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.RemoteExecution; +using CheatEngine.Client.Results; + +namespace CheatEngine.Client.Core.Domains.RemoteExecution; + +/// Preserves remote-execution intent contracts until allocation and thread-affinity live gates are complete. +internal sealed class UnavailableRemoteExecutionClient : IRemoteExecutionClient +{ + public bool TryInjectLibrary(RemoteDllInjectionRequest request, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + failure = CreateFailure("RemoteExecution.InjectLibrary", cancellationToken); + return false; + } + + public void InjectLibrary(RemoteDllInjectionRequest request, CancellationToken cancellationToken = default) + { + _ = TryInjectLibrary(request, out CheatEngineFailure failure, cancellationToken); + UnavailableCapabilityFailure.Throw(failure); + } + + public bool TryInvoke(RemoteCallRequest request, out RemoteCallResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + result = default; + failure = CreateFailure("RemoteExecution.Invoke", cancellationToken); + return false; + } + + public RemoteCallResult Invoke(RemoteCallRequest request, CancellationToken cancellationToken = default) + { + _ = TryInvoke(request, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } + + private static CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) + { + return UnavailableCapabilityFailure.Create("Remote execution and injection", operation, cancellationToken); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs index ab730cc..fdfd1fd 100644 --- a/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs +++ b/libs/CheatEngine.Client.Core/Domains/RuntimeClient.cs @@ -244,7 +244,25 @@ private ClientCapabilities CreateClientCapabilities() : new ClientCapabilityAvailability( ClientCapabilityId.UnsafeLuaExecution, ClientCapabilityAvailabilityState.Unavailable, - "Unsafe Lua execution requires explicit EnableUnsafeLuaExecution opt-in for this activation.") + "Unsafe Lua execution requires explicit EnableUnsafeLuaExecution opt-in for this activation."), + Unavailable(ClientCapabilityId.Allocations, + "Owned target allocations remain unavailable until the SDK production owner and CE 7.7 x64 cleanup gate pass."), + Unknown(ClientCapabilityId.Assembly, + "Assembly primitives and Auto Assembler ownership have not yet passed their CE 7.7 x64 live gate."), + Unavailable(ClientCapabilityId.RemoteExecution, + "Remote execution remains unavailable until allocation, timeout, and cleanup behavior pass the CE 7.7 x64 live gate."), + Unavailable(ClientCapabilityId.Debugger, + "Debugger callback ownership and synchronous continuation have not yet passed the CE 7.7 x64 live gate."), + Unavailable(ClientCapabilityId.Hotkeys, + "Hotkey callback ownership has not yet passed the CE 7.7 x64 live gate."), + Unavailable(ClientCapabilityId.Timers, + "Timer callback ownership has not yet passed the CE 7.7 x64 live gate."), + Unknown(ClientCapabilityId.Speed, + "The runtime snapshot does not yet probe the complete speed-control contract."), + Unknown(ClientCapabilityId.Hashing, + "The runtime snapshot does not yet probe target-memory and file hashing independently."), + Unavailable(ClientCapabilityId.Dbvm, + "DBVM observation, explicit initialization, and watch ownership have not yet passed the CE 7.7 x64 live gate.") ]; return ClientCapabilities.Create(capabilities); @@ -255,6 +273,11 @@ private static ClientCapabilityAvailability Unknown(ClientCapabilityId capabilit return new ClientCapabilityAvailability(capability, ClientCapabilityAvailabilityState.Unknown, reason); } + private static ClientCapabilityAvailability Unavailable(ClientCapabilityId capability, string reason) + { + return new ClientCapabilityAvailability(capability, ClientCapabilityAvailabilityState.Unavailable, reason); + } + private static CheatEngineArchitecture DecodeSystemArchitecture(ProbeResult probe) { return probe is { State: RuntimeCapabilityAvailabilityState.Available, Value: int architecture } && diff --git a/libs/CheatEngine.Client.Core/Domains/Speed/UnavailableSpeedClient.cs b/libs/CheatEngine.Client.Core/Domains/Speed/UnavailableSpeedClient.cs new file mode 100644 index 0000000..a20440b --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Speed/UnavailableSpeedClient.cs @@ -0,0 +1,41 @@ +using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Results; +using CheatEngine.Client.Speed; + +namespace CheatEngine.Client.Core.Domains.Speed; + +/// Preserves validated speed semantics until Cheat Engine speed control passes its live-host gate. +internal sealed class UnavailableSpeedClient : ISpeedClient +{ + public bool TryGetMultiplier(out SpeedMultiplier multiplier, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + multiplier = default; + failure = CreateFailure("Speed.GetMultiplier", cancellationToken); + return false; + } + + public SpeedMultiplier GetMultiplier(CancellationToken cancellationToken = default) + { + _ = TryGetMultiplier(out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } + + public bool TrySetMultiplier(SpeedMultiplier multiplier, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + failure = CreateFailure("Speed.SetMultiplier", cancellationToken); + return false; + } + + public void SetMultiplier(SpeedMultiplier multiplier, CancellationToken cancellationToken = default) + { + _ = TrySetMultiplier(multiplier, out CheatEngineFailure failure, cancellationToken); + UnavailableCapabilityFailure.Throw(failure); + } + + private static CheatEngineFailure CreateFailure(string operation, CancellationToken cancellationToken) + { + return UnavailableCapabilityFailure.Create("Target speed control", operation, cancellationToken); + } +} diff --git a/libs/CheatEngine.Client.Core/Domains/Timers/UnavailableTimerClient.cs b/libs/CheatEngine.Client.Core/Domains/Timers/UnavailableTimerClient.cs new file mode 100644 index 0000000..81f36dd --- /dev/null +++ b/libs/CheatEngine.Client.Core/Domains/Timers/UnavailableTimerClient.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Events; +using CheatEngine.Client.Results; +using CheatEngine.Client.Timers; + +namespace CheatEngine.Client.Core.Domains.Timers; + +/// Preserves recurring timer semantics until timer callback cleanup passes the live-host gate. +internal sealed class UnavailableTimerClient : ITimerClient +{ + public bool TryRegister(TimerRequest request, TimerHandler handler, EventStreamOptions streamOptions, + [NotNullWhen(true)] out ITimerLease? lease, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(handler); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(streamOptions.Capacity); + lease = null; + failure = UnavailableCapabilityFailure.Create("Timers", "Timers.Register", cancellationToken); + return false; + } + + public ITimerLease Register(TimerRequest request, TimerHandler handler, EventStreamOptions streamOptions, + CancellationToken cancellationToken = default) + { + _ = TryRegister(request, handler, streamOptions, out _, out CheatEngineFailure failure, cancellationToken); + return UnavailableCapabilityFailure.Throw(failure); + } +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs index f8162fe..c965825 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientBuilder.cs @@ -70,6 +70,28 @@ public CheatEngineClientBuilder AddModule< return this; } + /// Adds one descriptor-backed Lua module to every Client activation. + /// The generated or explicitly described Lua module type. + /// + /// The module is created from its public constructor by the activation-scoped provider and is registered only + /// after the Client and Lua runtime are live. Its lease is released in reverse module order during disable. This + /// method intentionally accepts only described modules: manual implementations remain + /// available through , but cannot participate in the Client-wide export + /// collision guarantee because they do not publish an immutable descriptor. + /// + public CheatEngineClientBuilder AddLuaModule< + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] + TModule>() + where TModule : class, IDescribedLuaModule + { + Services.TryAdd(ServiceDescriptor.Describe(typeof(TModule), typeof(TModule), ServiceLifetime.Scoped)); + Services.TryAddEnumerable(ServiceDescriptor.Describe( + typeof(ICheatEngineClientModule), + typeof(LuaModuleLifecycle), + ServiceLifetime.Scoped)); + return this; + } + /// Adds a singleton, deterministic codec for a managed memory value type. /// The managed memory value type. /// The concrete codec type. diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs index 74b4704..4881b3b 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/CheatEngineClientServiceCollectionExtensions.cs @@ -1,15 +1,33 @@ +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; using CheatEngine.Client.Core; using CheatEngine.Client.Core.Dispatching; using CheatEngine.Client.Core.Domains; +using CheatEngine.Client.Core.Domains.Allocations; +using CheatEngine.Client.Core.Domains.Assembly; +using CheatEngine.Client.Core.Domains.Dbvm; +using CheatEngine.Client.Core.Domains.Debugger; +using CheatEngine.Client.Core.Domains.Hashing; +using CheatEngine.Client.Core.Domains.Hotkeys; +using CheatEngine.Client.Core.Domains.RemoteExecution; +using CheatEngine.Client.Core.Domains.Speed; +using CheatEngine.Client.Core.Domains.Timers; using CheatEngine.Client.Core.Infrastructure; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Hashing; +using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Processes; +using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; +using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; +using CheatEngine.Client.Timers; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -118,6 +136,15 @@ private static void AddCoreServices(IServiceCollection services) serviceProvider.GetRequiredService()); services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); services.TryAddSingleton(static serviceProvider => new InspectionClient( @@ -152,11 +179,36 @@ private static void AddCoreServices(IServiceCollection services) IInspectionClient inspection = serviceProvider.GetRequiredService(); ITableClient tables = serviceProvider.GetRequiredService(); ILuaClient lua = serviceProvider.GetRequiredService(); + IAllocationClient allocations = serviceProvider.GetRequiredService(); + IAssemblyClient assembly = serviceProvider.GetRequiredService(); + IRemoteExecutionClient remoteExecution = serviceProvider.GetRequiredService(); + IDebuggerClient debugger = serviceProvider.GetRequiredService(); + IHotkeyClient hotkeys = serviceProvider.GetRequiredService(); + ITimerClient timers = serviceProvider.GetRequiredService(); + ISpeedClient speed = serviceProvider.GetRequiredService(); + IHashingClient hashing = serviceProvider.GetRequiredService(); + IDbvmClient dbvm = serviceProvider.GetRequiredService(); return new CheatEngineClient( lifetime, new CheatEngineClientRuntimeServices(runtime, dispatcher), - new CheatEngineClientDomainServices(processes, memory, patterns, scans, inspection, tables, lua)); + new CheatEngineClientDomainServices( + processes, + memory, + patterns, + scans, + inspection, + tables, + lua, + allocations, + assembly, + remoteExecution, + debugger, + hotkeys, + timers, + speed, + hashing, + dbvm)); }); services.TryAddSingleton(static serviceProvider => serviceProvider.GetRequiredService()); diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/LuaModuleLifecycle.cs b/libs/CheatEngine.Client.Extensions.DependencyInjection/LuaModuleLifecycle.cs new file mode 100644 index 0000000..662d865 --- /dev/null +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/LuaModuleLifecycle.cs @@ -0,0 +1,38 @@ +using CheatEngine.Client.Lua; +using CheatEngine.Client.Modules; + +namespace CheatEngine.Client.Extensions.DependencyInjection; + +/// Bridges one descriptor-backed Lua module into the activation lifecycle. +/// The explicitly registered Lua module type. +/// +/// Hosting calls modules in registration order and compensates them in reverse order. The lease is retained here, +/// rather than in a user module, so generated modules never need to expose a raw Lua state, state lease, or SDK +/// operation to application code. +/// +internal sealed class LuaModuleLifecycle(ILuaClient lua, TModule module) : ICheatEngineClientModule + where TModule : class, IDescribedLuaModule +{ + private readonly ILuaClient _lua = lua ?? throw new ArgumentNullException(nameof(lua)); + private readonly TModule _module = module ?? throw new ArgumentNullException(nameof(module)); + private ILuaModuleLease? _lease; + + public void OnEnabled(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + if (_lease is not null) + { + throw new InvalidOperationException( + "The Lua module lifecycle has already been enabled for this activation."); + } + + _lease = _lua.RegisterModule(_module); + } + + public void OnDisabling(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + ILuaModuleLease? lease = Interlocked.Exchange(ref _lease, null); + lease?.Dispose(); + } +} diff --git a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt index 7dc5c58..0242f49 100644 --- a/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt +++ b/libs/CheatEngine.Client.Extensions.DependencyInjection/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder.AddLuaModule() -> CheatEngine.Client.Extensions.DependencyInjection.CheatEngineClientBuilder! diff --git a/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs b/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs index 3cbd0b6..d2c9fa2 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/CheatEngineMemoryFluentExtensions.cs @@ -14,4 +14,14 @@ public static MemoryAddressBuilder At(this IMemoryClient memory, Address address { return Memory.At(memory, address); } + + /// Starts a bounded homogeneous primitive batch through the scoped target-memory service. + /// The built-in scalar or target-aware pointer type. + /// The scoped target-memory service used by terminal operations. + /// An immutable batch builder bound to . + /// is . + public static MemoryPrimitiveBatchBuilder Batch(this IMemoryClient memory) + { + return Memory.Batch(memory); + } } diff --git a/libs/CheatEngine.Client.Fluent/Memory/Memory.cs b/libs/CheatEngine.Client.Fluent/Memory/Memory.cs index 0c9f4b5..935963c 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/Memory.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/Memory.cs @@ -26,4 +26,15 @@ public static MemoryAddressBuilder At(IMemoryClient memory, Address address) ArgumentNullException.ThrowIfNull(memory); return new MemoryAddressBuilder(address, memory); } + + /// Creates a fluent terminal for one bounded homogeneous primitive batch. + /// The built-in scalar or target-aware pointer type. + /// The scoped target-memory service used by terminal operations. + /// An immutable batch builder bound to . + /// is . + public static MemoryPrimitiveBatchBuilder Batch(IMemoryClient memory) + { + ArgumentNullException.ThrowIfNull(memory); + return new MemoryPrimitiveBatchBuilder(memory); + } } diff --git a/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs index 4ad5b91..4468c71 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryAddressBuilder.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using CheatEngine.Client.Results; @@ -78,6 +79,158 @@ public bool TryWrite(T value, out CheatEngineFailure failure, return RequireMemory().TryWritePrimitive(Address, value, out failure, cancellationToken); } + /// Copies an exact, positive number of target bytes from this address. + /// The exact positive number of bytes to copy. + /// Cancels before the operation reaches Cheat Engine. + /// An immutable, caller-owned byte snapshot. + /// No memory service has been bound to this builder. + public ImmutableArray ReadBytes(int length, CancellationToken cancellationToken = default) + { + return RequireMemory().ReadBytes(new MemoryBytesReadRequest(Address, length), cancellationToken); + } + + /// Tries to copy an exact, positive number of target bytes from this address. + /// The exact positive number of bytes to copy. + /// The immutable byte snapshot when the method returns . + /// The classified operation failure when the method returns . + /// Cancels before the operation reaches Cheat Engine. + /// when the bytes were copied. + /// No memory service has been bound to this builder. + public bool TryReadBytes(int length, out ImmutableArray bytes, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return RequireMemory().TryReadBytes(new MemoryBytesReadRequest(Address, length), out bytes, out failure, + cancellationToken); + } + + /// Copies the supplied non-empty bytes to this address. + /// The caller-owned bytes copied into an immutable request before dispatch. + /// Cancels before the operation reaches Cheat Engine. + /// No memory service has been bound to this builder. + public void WriteBytes(ReadOnlySpan bytes, CancellationToken cancellationToken = default) + { + RequireMemory().WriteBytes(new MemoryBytesWriteRequest(Address, bytes), cancellationToken); + } + + /// Tries to copy the supplied non-empty bytes to this address. + /// The caller-owned bytes copied into an immutable request before dispatch. + /// The classified operation failure when the method returns . + /// Cancels before the operation reaches Cheat Engine. + /// when Cheat Engine accepted the write. + /// No memory service has been bound to this builder. + public bool TryWriteBytes(ReadOnlySpan bytes, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return RequireMemory().TryWriteBytes(new MemoryBytesWriteRequest(Address, bytes), out failure, + cancellationToken); + } + + /// Reads a bounded UTF-8 string from this address. + /// The positive maximum length passed to Cheat Engine. + /// Cancels before the operation reaches Cheat Engine. + /// The copied UTF-8 text. + /// No memory service has been bound to this builder. + public string ReadUtf8(int maximumLength, CancellationToken cancellationToken = default) + { + return ReadString(maximumLength, MemoryStringEncoding.Utf8, cancellationToken); + } + + /// Reads a bounded UTF-16 string from this address. + /// The positive maximum length passed to Cheat Engine. + /// Cancels before the operation reaches Cheat Engine. + /// The copied UTF-16 text. + /// No memory service has been bound to this builder. + public string ReadUtf16(int maximumLength, CancellationToken cancellationToken = default) + { + return ReadString(maximumLength, MemoryStringEncoding.Utf16, cancellationToken); + } + + /// Reads a bounded string with an explicit target encoding from this address. + /// The positive maximum length passed to Cheat Engine. + /// The UTF-8 or UTF-16 target representation. + /// Cancels before the operation reaches Cheat Engine. + /// The copied target text. + /// No memory service has been bound to this builder. + public string ReadString(int maximumLength, MemoryStringEncoding encoding, + CancellationToken cancellationToken = default) + { + return RequireMemory().ReadString(MemoryStringReadRequest.Create(Address, maximumLength, encoding), + cancellationToken); + } + + /// Tries to read a bounded string with an explicit target encoding from this address. + /// The positive maximum length passed to Cheat Engine. + /// The UTF-8 or UTF-16 target representation. + /// The copied target text when the method returns . + /// The classified operation failure when the method returns . + /// Cancels before the operation reaches Cheat Engine. + /// when the text was copied. + /// No memory service has been bound to this builder. + public bool TryReadString(int maximumLength, MemoryStringEncoding encoding, [NotNullWhen(true)] out string? value, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + return RequireMemory().TryReadString(MemoryStringReadRequest.Create(Address, maximumLength, encoding), + out value, + out failure, cancellationToken); + } + + /// Writes UTF-8 text whose encoded length is bounded explicitly at this address. + /// The managed text to copy. + /// The positive maximum number of UTF-8 bytes accepted. + /// Cancels before the operation reaches Cheat Engine. + /// No memory service has been bound to this builder. + public void WriteUtf8(string value, int maximumLength, CancellationToken cancellationToken = default) + { + WriteString(value, maximumLength, MemoryStringEncoding.Utf8, cancellationToken); + } + + /// Writes UTF-16 text whose code-unit length is bounded explicitly at this address. + /// The managed text to copy. + /// The positive maximum number of UTF-16 code units accepted. + /// Cancels before the operation reaches Cheat Engine. + /// No memory service has been bound to this builder. + public void WriteUtf16(string value, int maximumLength, CancellationToken cancellationToken = default) + { + WriteString(value, maximumLength, MemoryStringEncoding.Utf16, cancellationToken); + } + + /// Writes text with an explicit target encoding and maximum encoded length. + /// The managed text to copy. + /// The positive maximum number of UTF-8 bytes or UTF-16 code units accepted. + /// The UTF-8 or UTF-16 target representation. + /// Cancels before the operation reaches Cheat Engine. + /// No memory service has been bound to this builder. + public void WriteString(string value, int maximumLength, MemoryStringEncoding encoding, + CancellationToken cancellationToken = default) + { + RequireMemory().WriteString(MemoryStringWriteRequest.CreateBounded(Address, value, maximumLength, encoding), + cancellationToken); + } + + /// Tries to write text with an explicit target encoding and maximum encoded length. + /// The managed text to copy. + /// The positive maximum number of UTF-8 bytes or UTF-16 code units accepted. + /// The UTF-8 or UTF-16 target representation. + /// The classified operation failure when the method returns . + /// Cancels before the operation reaches Cheat Engine. + /// when Cheat Engine accepted the write. + /// No memory service has been bound to this builder. + public bool TryWriteString(string value, int maximumLength, MemoryStringEncoding encoding, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + return RequireMemory().TryWriteString( + MemoryStringWriteRequest.CreateBounded(Address, value, maximumLength, encoding), + out failure, cancellationToken); + } + + /// Starts a finite, target-aware pointer chain from this address. + /// The non-empty sequence of at most 64 offsets applied after each dereference. + /// An immutable chain builder that remains bound to this builder's memory service. + public MemoryPointerChainBuilder Follow(ReadOnlySpan offsets) + { + return new MemoryPointerChainBuilder(new PointerChainRequest(Address, offsets), _memory); + } + /// Reads one typed value through the service bound to this builder. /// The managed value type represented by . /// The deterministic codec that maps to Cheat Engine memory. diff --git a/libs/CheatEngine.Client.Fluent/Memory/MemoryPointerChainBuilder.cs b/libs/CheatEngine.Client.Fluent/Memory/MemoryPointerChainBuilder.cs new file mode 100644 index 0000000..3b84437 --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryPointerChainBuilder.cs @@ -0,0 +1,60 @@ +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// An immutable, handle-free fluent operation for one finite target-aware pointer chain. +public readonly struct MemoryPointerChainBuilder +{ + private readonly IMemoryClient? _memory; + + internal MemoryPointerChainBuilder(PointerChainRequest request, IMemoryClient? memory) + { + Request = request; + _memory = memory; + } + + /// Gets the copied finite pointer-chain request. + public PointerChainRequest Request + { + get; + } + + /// Returns an equivalent pointer-chain builder bound to a scoped target-memory service. + /// The scoped target-memory service used by terminal operations. + /// A new immutable builder. + /// is . + public MemoryPointerChainBuilder Using(IMemoryClient memory) + { + ArgumentNullException.ThrowIfNull(memory); + return new MemoryPointerChainBuilder(Request, memory); + } + + /// Resolves every pointer dereference and offset in the chain. + /// Cancels before the operation reaches Cheat Engine. + /// The copied final target address. + /// No memory service has been bound to this builder. + public Address Resolve(CancellationToken cancellationToken = default) + { + return RequireMemory().ResolvePointerChain(Request, cancellationToken); + } + + /// Tries to resolve every pointer dereference and offset in the chain. + /// The copied final target address when the method returns . + /// The classified operation failure when the method returns . + /// Cancels before the operation reaches Cheat Engine. + /// when the chain was resolved. + /// No memory service has been bound to this builder. + public bool TryResolve(out Address address, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return RequireMemory().TryResolvePointerChain(Request, out address, out failure, cancellationToken); + } + + private IMemoryClient RequireMemory() + { + return _memory ?? throw new InvalidOperationException( + "This pointer-chain builder has no bound target-memory service. Use Memory.At(memory, address), " + + "memory.At(address), or bind the chain with Using(memory) before a terminal operation."); + } +} diff --git a/libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs b/libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs new file mode 100644 index 0000000..ed6e7ef --- /dev/null +++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs @@ -0,0 +1,60 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Results; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Memory; + +/// An immutable, handle-free fluent terminal for one bounded homogeneous scalar batch. +/// The built-in scalar or target-aware pointer type. +public readonly struct MemoryPrimitiveBatchBuilder +{ + private readonly IMemoryClient _memory; + + internal MemoryPrimitiveBatchBuilder(IMemoryClient memory) + { + _memory = memory ?? throw new ArgumentNullException(nameof(memory)); + } + + /// Copies one homogeneous scalar from every supplied target address in one dispatch admission. + /// The non-empty target addresses to read in result order. + /// Cancels before the batch reaches Cheat Engine. + /// The immutable scalar snapshot in the same order as . + public ImmutableArray Read(ReadOnlySpan
addresses, CancellationToken cancellationToken = default) + { + return _memory.ReadPrimitiveBatch(new MemoryPrimitiveBatchReadRequest(addresses), cancellationToken); + } + + /// Tries to copy one homogeneous scalar from every supplied target address in one dispatch admission. + /// The non-empty target addresses to read in result order. + /// The immutable scalar snapshot when the method returns . + /// The classified operation failure when the method returns . + /// Cancels before the batch reaches Cheat Engine. + /// when all scalar reads succeeded. + public bool TryRead(ReadOnlySpan
addresses, out ImmutableArray values, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return _memory.TryReadPrimitiveBatch(new MemoryPrimitiveBatchReadRequest(addresses), out values, + out failure, cancellationToken); + } + + /// Writes every copied homogeneous scalar in one dispatch admission. + /// The non-empty address/value pairs to write in execution order. + /// Cancels before the batch reaches Cheat Engine. + public void Write(ReadOnlySpan> values, CancellationToken cancellationToken = default) + { + _memory.WritePrimitiveBatch(new MemoryPrimitiveBatchWriteRequest(values), cancellationToken); + } + + /// Tries to write every copied homogeneous scalar in one dispatch admission. + /// The non-empty address/value pairs to write in execution order. + /// The classified operation failure when the method returns . + /// Cancels before the batch reaches Cheat Engine. + /// when all scalar writes succeeded. + public bool TryWrite(ReadOnlySpan> values, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + return _memory.TryWritePrimitiveBatch(new MemoryPrimitiveBatchWriteRequest(values), out failure, + cancellationToken); + } +} diff --git a/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt b/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt index 7dc5c58..d9f4230 100644 --- a/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt +++ b/libs/CheatEngine.Client.Fluent/PublicAPI.Unshipped.txt @@ -1 +1,28 @@ #nullable enable +static CheatEngine.Client.Memory.CheatEngineMemoryFluentExtensions.Batch(this CheatEngine.Client.Memory.IMemoryClient! memory) -> CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder +static CheatEngine.Client.Memory.Memory.Batch(CheatEngine.Client.Memory.IMemoryClient! memory) -> CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder +CheatEngine.Client.Memory.MemoryAddressBuilder.Follow(System.ReadOnlySpan offsets) -> CheatEngine.Client.Memory.MemoryPointerChainBuilder +CheatEngine.Client.Memory.MemoryAddressBuilder.ReadBytes(int length, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.MemoryAddressBuilder.ReadString(int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! +CheatEngine.Client.Memory.MemoryAddressBuilder.ReadUtf16(int maximumLength, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! +CheatEngine.Client.Memory.MemoryAddressBuilder.ReadUtf8(int maximumLength, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> string! +CheatEngine.Client.Memory.MemoryAddressBuilder.TryReadBytes(int length, out System.Collections.Immutable.ImmutableArray bytes, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.TryReadString(int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding, out string? value, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.TryWriteBytes(System.ReadOnlySpan bytes, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.TryWriteString(string! value, int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryAddressBuilder.WriteBytes(System.ReadOnlySpan bytes, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.MemoryAddressBuilder.WriteString(string! value, int maximumLength, CheatEngine.Client.Memory.MemoryStringEncoding encoding, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.MemoryAddressBuilder.WriteUtf16(string! value, int maximumLength, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.MemoryAddressBuilder.WriteUtf8(string! value, int maximumLength, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void +CheatEngine.Client.Memory.MemoryPointerChainBuilder +CheatEngine.Client.Memory.MemoryPointerChainBuilder.MemoryPointerChainBuilder() -> void +CheatEngine.Client.Memory.MemoryPointerChainBuilder.Request.get -> CheatEngine.Client.Memory.PointerChainRequest +CheatEngine.Client.Memory.MemoryPointerChainBuilder.Resolve(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> CheatEngine.SDK.Engine.Values.Address +CheatEngine.Client.Memory.MemoryPointerChainBuilder.TryResolve(out CheatEngine.SDK.Engine.Values.Address address, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryPointerChainBuilder.Using(CheatEngine.Client.Memory.IMemoryClient! memory) -> CheatEngine.Client.Memory.MemoryPointerChainBuilder +CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder +CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder.MemoryPrimitiveBatchBuilder() -> void +CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder.Read(System.ReadOnlySpan addresses, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Immutable.ImmutableArray +CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder.TryRead(System.ReadOnlySpan addresses, out System.Collections.Immutable.ImmutableArray values, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder.TryWrite(System.ReadOnlySpan> values, out CheatEngine.Client.Results.CheatEngineFailure failure, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> bool +CheatEngine.Client.Memory.MemoryPrimitiveBatchBuilder.Write(System.ReadOnlySpan> values, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> void diff --git a/libs/CheatEngine.Client.Fluent/README.md b/libs/CheatEngine.Client.Fluent/README.md index ae53709..1135cbd 100644 --- a/libs/CheatEngine.Client.Fluent/README.md +++ b/libs/CheatEngine.Client.Fluent/README.md @@ -49,7 +49,8 @@ Abstractions ← Fluent - Preserves bounded materialization rules; callers can inspect `AobScanResult.IsTruncated` when a bounded scan is intentionally incomplete. - Provides `Memory.At(...)` and `memory.At(...)` builders for primitive and codec-based reads and - writes without retaining a live target handle. + writes without retaining a live target handle, including exact byte copies, explicit UTF-8/UTF-16 + bounds, finite pointer chains, and bounded homogeneous primitive batches. - Uses the normal `Try...` plus `CheatEngineFailure` pattern and leaves the actual lifecycle, dispatch, and SDK translation to the supplied contract implementation. diff --git a/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj b/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj index ea347b1..b111498 100644 --- a/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj +++ b/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj @@ -4,10 +4,22 @@ {CF0D14B3-B4CB-4BDD-91AD-4C8B6B8BBCD0} true true + $(TargetsForTfmSpecificContentInPackage);_CheatEngineClientHostingPackLuaGenerator + + + + + false + + + + + + + + + + diff --git a/libs/CheatEngine.Client.Hosting/README.md b/libs/CheatEngine.Client.Hosting/README.md index 7722e43..b746f15 100644 --- a/libs/CheatEngine.Client.Hosting/README.md +++ b/libs/CheatEngine.Client.Hosting/README.md @@ -62,8 +62,11 @@ public sealed class Plugin : CheatEngineClientPlugin Plugin projects must reference `CheatEngine.SDK` directly as well as `CheatEngine.Client`. The SDK's plugin entry-point generator and native bridge build assets cannot be supplied through a transitive NuGet dependency. Set -`CheatEngineClientPluginProject` to `true` to opt into the Hosting build guard; it emits `CECLIENT001` at compile time -when the direct SDK `PackageReference` is absent. +`CheatEngineClientPluginProject` to `true` to opt into the Hosting profile. It verifies the two direct references +(`CECLIENT001`/`CECLIENT002`), exactly one attributed `CheatEngineClientPlugin` (`CECLIENT003`/`CECLIENT004`), +`net10.0`, C# 14, and an x64 or AnyCPU target (`CECLIENT005`–`CECLIENT007`). Disabling the SDK generator additionally +requires an explicit `CheatEngineClientManualBootstrap=true` acknowledgement; the SDK then validates the exact manual +entry point (`CECLIENT008` and `CESDK0003`). ```xml @@ -79,3 +82,19 @@ when the direct SDK `PackageReference` is absent. `CheatEngine.Client.Templates` contains a complete plugin layout that applies this configuration and includes a bounded AOB, memory, Address List, and Lua-module example. + +## Managed deployment folder + +Set `CheatEnginePluginOutputPath` only when a build should prepare a local managed deployment folder: + +```powershell +dotnet build .\MyPlugin.csproj --configuration Release ` + -p:CheatEnginePluginOutputPath=C:\CheatEngineDeploy\MyPlugin +``` + +`PrepareCheatEnginePluginDeployment` runs only for the marked plugin profile and only when that property is non-empty. +It validates the plugin DLL, manifests, Client/SDK managed closure, and the SDK Lua bridge, stages the complete output, +then replaces each destination file with Windows write-through replacement semantics. It does not inspect or change a +Cheat Engine installation, runtime configuration, or plugin list. Windows cannot atomically replace a non-empty +directory, so deploy while the plugin is disabled; destination files are individually never copied in a partially +written state. diff --git a/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.props b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.props new file mode 100644 index 0000000..a29231b --- /dev/null +++ b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.props @@ -0,0 +1,10 @@ + + + + + + + diff --git a/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets index 49256b5..0e58943 100644 --- a/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets +++ b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets @@ -1,13 +1,352 @@ - + + - <_CheatEngineClientDirectSdkReference Include="@(PackageReference)" + <_CheatEngineClientDirectPackageReference Include="@(PackageReference)"/> + <_CheatEngineClientDirectSdkReference Include="@(_CheatEngineClientDirectPackageReference)" Condition="'%(Identity)' == 'CheatEngine.SDK'"/> + <_CheatEngineClientDirectClientReference Include="@(_CheatEngineClientDirectPackageReference)" + Condition="'%(Identity)' == 'CheatEngine.Client'"/> - + + + + + + + + + + + + + + + + + + + + + + + + 1) + { + Log.LogError(null, "CECLIENT004", null, AssemblyPath, 0, 0, 0, 0, + "Cheat Engine Client plugin projects declare more than one valid [CheatEnginePlugin] type derived from CheatEngineClientPlugin."); + } + } + catch (Exception exception) + { + Log.LogError(null, "CECLIENT009", null, AssemblyPath, 0, 0, 0, 0, + "Cheat Engine Client could not validate plugin metadata: {0}", exception.Message); + } + + return !Log.HasLoggedErrors; + } + + private static bool HasSdkPluginAttribute(TypeDefinition type, MetadataReader metadata) + { + foreach (CustomAttributeHandle attributeHandle in type.GetCustomAttributes()) + { + CustomAttribute attribute = metadata.GetCustomAttribute(attributeHandle); + if (IsTypeNamed(GetAttributeType(attribute, metadata), metadata, + SdkPluginAttributeNamespace, SdkPluginAttributeName)) + { + return true; + } + } + + return false; + } + + private static EntityHandle GetAttributeType(CustomAttribute attribute, MetadataReader metadata) + { + return attribute.Constructor.Kind switch + { + HandleKind.MemberReference => metadata.GetMemberReference((MemberReferenceHandle)attribute.Constructor).Parent, + HandleKind.MethodDefinition => metadata.GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor).GetDeclaringType(), + _ => default, + }; + } + + private static bool DerivesFromClientPlugin(TypeDefinition type, MetadataReader metadata) + { + EntityHandle baseType = type.BaseType; + while (!baseType.IsNil) + { + if (IsTypeNamed(baseType, metadata, ClientPluginNamespace, ClientPluginName)) + { + return true; + } + + if (baseType.Kind != HandleKind.TypeDefinition) + { + return false; + } + + baseType = metadata.GetTypeDefinition((TypeDefinitionHandle)baseType).BaseType; + } + + return false; + } + + private static bool IsTypeNamed(EntityHandle handle, MetadataReader metadata, string expectedNamespace, string expectedName) + { + return handle.Kind switch + { + HandleKind.TypeDefinition => IsNamed(metadata.GetTypeDefinition((TypeDefinitionHandle)handle).Namespace, + metadata.GetTypeDefinition((TypeDefinitionHandle)handle).Name, metadata, expectedNamespace, expectedName), + HandleKind.TypeReference => IsNamed(metadata.GetTypeReference((TypeReferenceHandle)handle).Namespace, + metadata.GetTypeReference((TypeReferenceHandle)handle).Name, metadata, expectedNamespace, expectedName), + _ => false, + }; + } + + private static bool IsNamed(StringHandle namespaceHandle, StringHandle nameHandle, MetadataReader metadata, + string expectedNamespace, string expectedName) + { + return String.Equals(metadata.GetString(namespaceHandle), expectedNamespace, StringComparison.Ordinal) && + String.Equals(metadata.GetString(nameHandle), expectedName, StringComparison.Ordinal); + } +} + ]]> + + + + + + + + + + + + + + + + + + + + + + + + (); + + [Required] + public string Destination { get; set; } = String.Empty; + + public override bool Execute() + { + try + { + string destination = Path.GetFullPath(Destination); + string? parentDirectory = Path.GetDirectoryName(destination); + string directoryName = Path.GetFileName(destination.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + if (String.IsNullOrWhiteSpace(parentDirectory) || String.IsNullOrWhiteSpace(directoryName)) + { + throw new InvalidOperationException("CheatEnginePluginOutputPath must name a concrete output directory."); + } + + Directory.CreateDirectory(parentDirectory); + stagingDirectory = Path.Combine(parentDirectory, "." + directoryName + ".ceclient-staging-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(stagingDirectory); + + HashSet names = new HashSet(StringComparer.OrdinalIgnoreCase); + List stagedFiles = new List(Files.Length); + foreach (ITaskItem item in Files) + { + string source = Path.GetFullPath(item.ItemSpec); + string fileName = Path.GetFileName(source); + if (!File.Exists(source)) + { + throw new FileNotFoundException("The validated plugin deployment file no longer exists.", source); + } + + if (String.IsNullOrWhiteSpace(fileName) || !names.Add(fileName)) + { + throw new InvalidOperationException("Plugin deployment contains duplicate output file names."); + } + + string stagedFile = Path.Combine(stagingDirectory, fileName); + File.Copy(source, stagedFile, false); + stagedFiles.Add(stagedFile); + } + + Directory.CreateDirectory(destination); + foreach (string stagedFile in stagedFiles) + { + string destinationFile = Path.Combine(destination, Path.GetFileName(stagedFile)); + if (!MoveFileEx(stagedFile, destinationFile, MoveFileReplaceExisting | MoveFileWriteThrough)) + { + throw new Win32Exception(Marshal.GetLastWin32Error(), + "Windows could not atomically replace plugin deployment file '" + destinationFile + "'."); + } + } + + Log.LogMessage(MessageImportance.High, + "Prepared Cheat Engine managed plugin deployment in '{0}' ({1} file(s)).", destination, stagedFiles.Count); + return true; + } + catch (Exception exception) + { + Log.LogError(null, "CECLIENT016", null, Destination, 0, 0, 0, 0, + "Cheat Engine plugin deployment failed: {0}", exception.Message); + return false; + } + finally + { + if (!String.IsNullOrWhiteSpace(stagingDirectory) && Directory.Exists(stagingDirectory)) + { + Directory.Delete(stagingDirectory, true); + } + } + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool MoveFileEx(string existingFileName, string newFileName, int flags); +} + ]]> + + + + + + + + + + + + + <_CheatEngineClientDeploymentFile Include="$(TargetDir)*.dll"/> + <_CheatEngineClientDeploymentFile Include="$(TargetDir)*.json"/> + <_CheatEngineClientDeploymentFile Include="$(TargetDir)*.pdb"/> + + + + + + diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/AnalyzerReleases.Unshipped.md b/source-generators/CheatEngine.Client.SourceGenerators.Lua/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..12d96d3 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/AnalyzerReleases.Unshipped.md @@ -0,0 +1,16 @@ +## Release 0.1.0 + + Rule ID | Category | Severity | Notes +------------|------------------------|----------|--------------------------------------------------------------- + CECLUA1001 | CheatEngine.Client.Lua | Error | A generated Lua module must be a supported partial class. + CECLUA1002 | CheatEngine.Client.Lua | Error | A generated Lua module requires static SDK bindings. + CECLUA1003 | CheatEngine.Client.Lua | Error | A generated Lua module requires at least one export. + CECLUA1004 | CheatEngine.Client.Lua | Error | Generated Lua module export names must be unique. + CECLUA1005 | CheatEngine.Client.Lua | Error | A generated Lua module identity cannot be blank. + CECLUA1006 | CheatEngine.Client.Lua | Error | A generated Lua module must have a public DI constructor. + CECLUA1101 | CheatEngine.Client.Lua | Error | A generated Lua operation must target a supported Lua global. + CECLUA1102 | CheatEngine.Client.Lua | Error | Generated Lua operation methods cannot be overloaded. + CECLUA1103 | CheatEngine.Client.Lua | Error | Generated Lua operation signatures must be bounded. + CECLUA1104 | CheatEngine.Client.Lua | Error | Non-scalar Lua operation results require a mapper. + CECLUA1105 | CheatEngine.Client.Lua | Error | A Lua operation mapper must match the SDK result. + CECLUA1106 | CheatEngine.Client.Lua | Error | A Lua mapper must project a safe Client result. diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngine.Client.SourceGenerators.Lua.csproj b/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngine.Client.SourceGenerators.Lua.csproj new file mode 100644 index 0000000..3ad4eb5 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngine.Client.SourceGenerators.Lua.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + Analyzer + false + + false + true + true + false + Compile-time adapters for the handle-free CheatEngine.Client Lua module and operation contracts. + + + + + + + + diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs new file mode 100644 index 0000000..4196866 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs @@ -0,0 +1,983 @@ +using System.Collections.Immutable; +using System.Text; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace CheatEngine.Client.SourceGenerators.Lua; + +/// +/// Emits the Client-facing, handle-free adapters for explicitly declared SDK Lua binding types. +/// +/// +/// The generator is deliberately attribute-driven. It neither scans assemblies nor persists compilation state: +/// every pipeline transform is static and converts Roslyn symbols into immutable value data before emission. +/// +[Generator(LanguageNames.CSharp)] +public sealed class CheatEngineLuaGenerator : IIncrementalGenerator +{ + internal const string LuaModuleAttributeMetadataName = + "CheatEngine.Client.Lua.CheatEngineLuaModuleAttribute"; + + internal const string LuaOperationAttributeMetadataName = + "CheatEngine.Client.Lua.CheatEngineLuaOperationAttribute"; + + private const string LuaFunctionAttributeMetadataName = "CheatEngine.SDK.Annotations.Lua.LuaFunctionAttribute"; + private const string LuaGlobalAttributeMetadataName = "CheatEngine.SDK.Annotations.Lua.LuaGlobalAttribute"; + private const string LuaResultMapperMetadataName = "CheatEngine.Client.Lua.ILuaResultMapper"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + IncrementalValuesProvider modules = context.SyntaxProvider + .ForAttributeWithMetadataName( + LuaModuleAttributeMetadataName, + static (node, _) => node is ClassDeclarationSyntax, + static (attributeContext, _) => ModuleCandidate.Create(attributeContext)) + .WithTrackingName("CheatEngineLuaModule"); + + context.RegisterSourceOutput(modules, static (productionContext, candidate) => + { + if (candidate.Diagnostic is not null) + { + productionContext.ReportDiagnostic(candidate.Diagnostic); + return; + } + + productionContext.AddSource(candidate.HintName, EmitModule(candidate)); + }); + + IncrementalValuesProvider operations = context.SyntaxProvider + .ForAttributeWithMetadataName( + LuaOperationAttributeMetadataName, + static (node, _) => node is MethodDeclarationSyntax, + static (attributeContext, _) => OperationCandidate.Create(attributeContext)) + .WithTrackingName("CheatEngineLuaOperation"); + + context.RegisterSourceOutput(operations, static (productionContext, candidate) => + { + if (candidate.Diagnostic is not null) + { + productionContext.ReportDiagnostic(candidate.Diagnostic); + return; + } + + productionContext.AddSource(candidate.HintName, EmitOperation(candidate)); + }); + } + + private static string EmitModule(ModuleCandidate candidate) + { + SourceBuilder source = new(); + source.WriteGeneratedHeader(); + source.OpenNamespace(candidate.Namespace); + source.WriteLine(candidate.TypeDeclaration + " : global::CheatEngine.Client.Lua.IDescribedLuaModule"); + source.OpenBlock(); + if (candidate.EmitsPublicParameterlessConstructor) + { + source.WriteLine( + "/// Initializes a Lua module instance for activation-scoped dependency injection."); + source.WriteLine("public " + candidate.ModuleTypeName + "()"); + source.OpenBlock(); + source.CloseBlock(); + source.WriteLine(); + } + + source.WriteLine("private static readonly global::CheatEngine.Client.Lua.LuaModuleDescriptor s_descriptor ="); + source.Indent(); + source.WriteLine("new global::CheatEngine.Client.Lua.LuaModuleDescriptor("); + source.Indent(); + source.WriteLine(CSharpLiteral(candidate.ModuleName) + ","); + source.WriteLine(EmitExports(candidate.Exports) + ");"); + source.Unindent(); + source.Unindent(); + source.WriteLine(); + source.WriteLine("/// "); + source.WriteLine("public global::CheatEngine.Client.Lua.LuaModuleDescriptor Descriptor => s_descriptor;"); + source.WriteLine(); + source.WriteLine("/// "); + source.WriteLine("public void Register()"); + source.OpenBlock(); + source.WriteLine("using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation operation ="); + source.Indent(); + source.WriteLine("global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation();"); + source.Unindent(); + source.WriteLine("EnsureExportsAreVacant(operation.State);"); + source.WriteLine("global::CheatEngine.SDK.Lua.Calls.LuaStatus status = " + candidate.BindingsType + + ".RegisterLuaFunctions(operation.State);"); + source.WriteLine("if (status.IsOk)"); + source.OpenBlock(); + source.WriteLine("return;"); + source.CloseBlock(); + source.WriteLine(); + source.WriteLine("try"); + source.OpenBlock(); + source.WriteLine("status.ThrowIfFailed(operation.State);"); + source.CloseBlock(); + source.WriteLine("finally"); + source.OpenBlock(); + source.WriteLine("_ = " + candidate.BindingsType + ".UnregisterLuaFunctions(operation.State);"); + source.CloseBlock(); + source.CloseBlock(); + source.WriteLine(); + source.WriteLine("/// "); + source.WriteLine("public void Unregister()"); + source.OpenBlock(); + source.WriteLine("using global::CheatEngine.SDK.Lua.Runtime.LuaRuntimeOperation operation ="); + source.Indent(); + source.WriteLine("global::CheatEngine.SDK.Lua.Runtime.LuaRuntime.AcquireOperation();"); + source.Unindent(); + source.WriteLine("global::CheatEngine.SDK.Lua.Calls.LuaStatus status = " + candidate.BindingsType + + ".UnregisterLuaFunctions(operation.State);"); + source.WriteLine("if (!status.IsOk)"); + source.OpenBlock(); + source.WriteLine("status.ThrowIfFailed(operation.State);"); + source.CloseBlock(); + source.CloseBlock(); + source.WriteLine(); + source.WriteLine( + "private static void EnsureExportsAreVacant(global::CheatEngine.SDK.Lua.State.LuaState state)"); + source.OpenBlock(); + source.WriteLine("int top = state.Top;"); + source.WriteLine("try"); + source.OpenBlock(); + foreach (string export in candidate.Exports) + { + source.WriteLine("global::CheatEngine.SDK.Lua.Calls.LuaStatus status = state.TryGetGlobal(" + + CSharpLiteral(export) + "u8);"); + source.WriteLine("if (!status.IsOk)"); + source.OpenBlock(); + source.WriteLine("status.ThrowIfFailed(state);"); + source.CloseBlock(); + source.WriteLine("if (!state.IsNil(-1))"); + source.OpenBlock(); + source.WriteLine("throw new global::System.InvalidOperationException(" + + CSharpLiteral("Lua global '" + export + + "' is already defined and cannot be replaced by Client module '" + + candidate.ModuleName + "'.") + ");"); + source.CloseBlock(); + source.WriteLine("state.Pop(1);"); + } + + source.CloseBlock(); + source.WriteLine("finally"); + source.OpenBlock(); + source.WriteLine("state.SetTop(top);"); + source.CloseBlock(); + source.CloseBlock(); + source.CloseBlock(); + + return source.ToString(); + } + + private static string EmitOperation(OperationCandidate candidate) + { + SourceBuilder source = new(); + source.WriteGeneratedHeader(); + source.OpenNamespace(candidate.Namespace); + source.WriteLine(candidate.ContainingTypeDeclaration); + source.OpenBlock(); + source.WriteLine("/// Creates a handle-free Client operation for " + candidate.MethodName + + "."); + source.WriteLine("public static " + candidate.OperationTypeName + " Create" + candidate.OperationTypeName + + "(" + + EmitParameterList(candidate.Parameters) + ")"); + source.OpenBlock(); + source.WriteLine("return new " + candidate.OperationTypeName + "(" + + EmitArgumentList(candidate.Parameters, false) + ");"); + source.CloseBlock(); + source.WriteLine(); + source.WriteLine("/// Generated readonly value operation for " + candidate.MethodName + + "."); + source.WriteLine(candidate.OperationVisibility + " readonly record struct " + candidate.OperationTypeName + + "(" + + EmitRecordParameterList(candidate.Parameters) + + ") : global::CheatEngine.Client.Lua.ILuaOperation<" + + candidate.ResultType + ">"); + source.OpenBlock(); + source.WriteLine("/// "); + source.WriteLine("public bool TryExecute(global::CheatEngine.Client.Lua.ILuaExecutionContext context, out " + + candidate.ResultType + + " result, out global::CheatEngine.Client.Results.CheatEngineFailure failure)"); + source.OpenBlock(); + source.WriteLine("global::System.ArgumentNullException.ThrowIfNull(context);"); + source.WriteLine("context.ThrowIfExpired();"); + source.WriteLine("try"); + source.OpenBlock(); + if (candidate.HasOutResult) + { + source.WriteLine(candidate.SourceResultType + " source;"); + source.WriteLine("if (!" + candidate.BindingsType + "." + candidate.MethodName + "(" + + EmitOutArgumentList(candidate.Parameters) + "))"); + source.OpenBlock(); + source.WriteLine("result = default;"); + source.WriteLine("failure = new global::CheatEngine.Client.Results.CheatEngineFailure("); + source.Indent(); + source.WriteLine("global::CheatEngine.Client.Results.CheatEngineFailureKind.LuaError,"); + source.WriteLine(CSharpLiteral(candidate.FailureOperation) + ","); + source.WriteLine( + CSharpLiteral("The SDK Lua Try binding returned false without exposing an unsafe Lua handle.") + ");"); + source.Unindent(); + source.WriteLine("return false;"); + source.CloseBlock(); + } + else + { + source.WriteLine(candidate.SourceResultType + " source = " + candidate.BindingsType + "." + + candidate.MethodName + "(" + EmitArgumentList(candidate.Parameters, true) + ");"); + } + + source.WriteLine(candidate.MapperType is null + ? "result = source;" + : "result = " + candidate.MapperType + ".Map(source);"); + source.WriteLine("failure = default;"); + source.WriteLine("return true;"); + source.CloseBlock(); + source.WriteLine("catch (global::CheatEngine.SDK.Lua.Calls.LuaException exception)"); + source.OpenBlock(); + source.WriteLine("result = default;"); + source.WriteLine("failure = new global::CheatEngine.Client.Results.CheatEngineFailure("); + source.Indent(); + source.WriteLine("global::CheatEngine.Client.Results.CheatEngineFailureKind.LuaError,"); + source.WriteLine(CSharpLiteral(candidate.FailureOperation) + ","); + source.WriteLine("exception.Message,"); + source.WriteLine("exception);"); + source.Unindent(); + source.WriteLine("return false;"); + source.CloseBlock(); + source.WriteLine("catch (global::System.Exception exception)"); + source.OpenBlock(); + source.WriteLine("result = default;"); + source.WriteLine("failure = new global::CheatEngine.Client.Results.CheatEngineFailure("); + source.Indent(); + source.WriteLine("global::CheatEngine.Client.Results.CheatEngineFailureKind.BindingError,"); + source.WriteLine(CSharpLiteral(candidate.FailureOperation) + ","); + source.WriteLine("exception.Message,"); + source.WriteLine("exception);"); + source.Unindent(); + source.WriteLine("return false;"); + source.CloseBlock(); + source.CloseBlock(); + source.CloseBlock(); + source.CloseBlock(); + + return source.ToString(); + } + + private static string EmitExports(ImmutableArray exports) + { + if (exports.IsEmpty) + { + return + "global::System.Collections.Immutable.ImmutableArray.Empty"; + } + + StringBuilder source = new("global::System.Collections.Immutable.ImmutableArray.Create("); + for (int index = 0; index < exports.Length; index++) + { + if (index != 0) + { + source.Append(", "); + } + + source.Append("new global::CheatEngine.Client.Lua.LuaExportDescriptor("); + source.Append(CSharpLiteral(exports[index])); + source.Append(')'); + } + + source.Append(')'); + return source.ToString(); + } + + private static string EmitParameterList(ImmutableArray parameters) + { + return string.Join(", ", parameters.Select(static parameter => parameter.Type + " " + parameter.Name)); + } + + private static string EmitRecordParameterList(ImmutableArray parameters) + { + return string.Join(", ", parameters.Select(static parameter => parameter.Type + " " + parameter.FieldName)); + } + + private static string EmitArgumentList(ImmutableArray parameters, bool fields) + { + return string.Join(", ", parameters.Select(parameter => fields ? parameter.FieldName : parameter.Name)); + } + + private static string EmitOutArgumentList(ImmutableArray parameters) + { + string arguments = EmitArgumentList(parameters, true); + return arguments.Length == 0 ? "out source" : arguments + ", out source"; + } + + private static string CSharpLiteral(string value) + { + return SymbolDisplay.FormatLiteral(value, true); + } + + private static ImmutableArray GetExports(INamedTypeSymbol bindings, out string? invalidExport) + { + ImmutableArray.Builder exports = ImmutableArray.CreateBuilder(); + HashSet names = new(StringComparer.Ordinal); + foreach (IMethodSymbol method in bindings.GetMembers().OfType()) + { + AttributeData? attribute = GetAttribute(method, LuaFunctionAttributeMetadataName); + if (attribute is null) + { + continue; + } + + string? name = attribute.ConstructorArguments.Length == 1 + ? attribute.ConstructorArguments[0].Value as string + : null; + if (name is null) + { + invalidExport = "(missing name)"; + return ImmutableArray.Empty; + } + + if (string.IsNullOrWhiteSpace(name)) + { + invalidExport = name; + return ImmutableArray.Empty; + } + + string exportName = name; + if (!names.Add(exportName)) + { + invalidExport = exportName; + return ImmutableArray.Empty; + } + + exports.Add(exportName); + } + + invalidExport = null; + return exports.ToImmutable(); + } + + private static bool TryGetMapperResult(INamedTypeSymbol mapper, ITypeSymbol source, out ITypeSymbol result) + { + foreach (INamedTypeSymbol contract in mapper.AllInterfaces) + { + if (!string.Equals(contract.OriginalDefinition.ToDisplayString(), LuaResultMapperMetadataName, + StringComparison.Ordinal) || + !SymbolEqualityComparer.Default.Equals(contract.TypeArguments[0], source)) + { + continue; + } + + result = contract.TypeArguments[1]; + return true; + } + + result = source; + return false; + } + + private static bool HasAttribute(ISymbol symbol, string metadataName) + { + return GetAttribute(symbol, metadataName) is not null; + } + + private static AttributeData? GetAttribute(ISymbol symbol, string metadataName) + { + foreach (AttributeData attribute in symbol.GetAttributes()) + { + if (string.Equals(attribute.AttributeClass?.ToDisplayString(), metadataName, StringComparison.Ordinal)) + { + return attribute; + } + } + + return null; + } + + private static bool IsPartial(INamedTypeSymbol type) + { + foreach (SyntaxReference declaration in type.DeclaringSyntaxReferences) + { + if (declaration.GetSyntax() is TypeDeclarationSyntax syntax && + syntax.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword))) + { + return true; + } + } + + return false; + } + + private static bool IsScalar(ITypeSymbol type) + { + if (type.TypeKind == TypeKind.Pointer || type.IsRefLikeType) + { + return false; + } + + return type.SpecialType switch + { + SpecialType.System_Boolean or SpecialType.System_Byte or SpecialType.System_SByte or + SpecialType.System_Char or SpecialType.System_Int16 or SpecialType.System_UInt16 or + SpecialType.System_Int32 or SpecialType.System_UInt32 or SpecialType.System_Int64 or + SpecialType.System_UInt64 or SpecialType.System_Single or SpecialType.System_Double or + SpecialType.System_String or SpecialType.System_IntPtr or SpecialType.System_UIntPtr => true, + _ => false + }; + } + + private static bool IsSafeMapperSource(ITypeSymbol type) + { + return !IsRawLuaOrOwnershipType(type) && type.TypeKind != TypeKind.Pointer && !type.IsRefLikeType; + } + + private static bool IsSafeClientResult(ITypeSymbol type) + { + return IsSafeMapperSource(type) && !HasAttribute(type, "CheatEngine.SDK.Annotations.Lua.LuaClassAttribute"); + } + + private static bool IsRawLuaOrOwnershipType(ITypeSymbol type) + { + string metadataName = type.OriginalDefinition.ToDisplayString(); + return string.Equals(metadataName, "CheatEngine.SDK.Lua.State.LuaState", StringComparison.Ordinal) || + string.Equals(metadataName, "CheatEngine.SDK.Lua.References.LuaRef", StringComparison.Ordinal) || + string.Equals(metadataName, "CheatEngine.SDK.Engine.Objects.CEObject", StringComparison.Ordinal) || + string.Equals(metadataName, "CheatEngine.SDK.Engine.Objects.Owned", StringComparison.Ordinal); + } + + private static string TypeDeclaration(INamedTypeSymbol type, bool isStatic) + { + string accessibility = type.DeclaredAccessibility == Accessibility.Public ? "public" : "internal"; + return accessibility + (isStatic ? " static" : string.Empty) + " partial class " + EscapeIdentifier(type.Name); + } + + private static string TypeName(ITypeSymbol type) + { + return type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier)); + } + + private static string EscapeIdentifier(string name) + { + return SyntaxFacts.GetKeywordKind(name) != SyntaxKind.None || + SyntaxFacts.GetContextualKeywordKind(name) != SyntaxKind.None + ? "@" + name + : name; + } + + private sealed class ModuleCandidate + { + private ModuleCandidate(Diagnostic? diagnostic, string hintName, string @namespace, string typeDeclaration, + string moduleTypeName, string bindingsType, string moduleName, ImmutableArray exports, + bool emitsPublicParameterlessConstructor) + { + Diagnostic = diagnostic; + HintName = hintName; + Namespace = @namespace; + TypeDeclaration = typeDeclaration; + ModuleTypeName = moduleTypeName; + BindingsType = bindingsType; + ModuleName = moduleName; + Exports = exports; + EmitsPublicParameterlessConstructor = emitsPublicParameterlessConstructor; + } + + public Diagnostic? Diagnostic + { + get; + } + + public string HintName + { + get; + } + + public string Namespace + { + get; + } + + public string TypeDeclaration + { + get; + } + + public string ModuleTypeName + { + get; + } + + public string BindingsType + { + get; + } + + public string ModuleName + { + get; + } + + public ImmutableArray Exports + { + get; + } + + public bool EmitsPublicParameterlessConstructor + { + get; + } + + public static ModuleCandidate Create(GeneratorAttributeSyntaxContext context) + { + INamedTypeSymbol? module = context.TargetSymbol as INamedTypeSymbol; + Location location = context.TargetNode.GetLocation(); + if (module is null || module.TypeKind != TypeKind.Class || module.IsStatic || + module.ContainingType is not null || + module.TypeParameters.Length != 0 || !IsPartial(module)) + { + return Invalid(ModuleDiagnosticDescriptors.InvalidModuleShape, location); + } + + if (!TryDetermineConstructorEmission(module, out bool emitsPublicParameterlessConstructor, + out IMethodSymbol? nonPublicConstructor)) + { + return Invalid(ModuleDiagnosticDescriptors.NoPublicConstructor, + nonPublicConstructor?.Locations.FirstOrDefault() ?? location, + module.Name, + nonPublicConstructor is null + ? "non-public" + : nonPublicConstructor.DeclaredAccessibility.ToString()); + } + + AttributeData attribute = context.Attributes[0]; + if (attribute.ConstructorArguments.Length == 0 || + attribute.ConstructorArguments[0].Value is not INamedTypeSymbol bindings || !bindings.IsStatic || + bindings.TypeKind != TypeKind.Class) + { + return Invalid(ModuleDiagnosticDescriptors.InvalidBindingsType, location); + } + + ImmutableArray exports = GetExports(bindings, out string? duplicateOrInvalidExport); + if (duplicateOrInvalidExport is not null) + { + return Invalid(ModuleDiagnosticDescriptors.InvalidExportSet, location, duplicateOrInvalidExport); + } + + if (exports.IsEmpty) + { + return Invalid(ModuleDiagnosticDescriptors.NoExports, location, bindings.Name); + } + + string? configuredName = attribute.ConstructorArguments.Length > 1 + ? attribute.ConstructorArguments[1].Value as string + : null; + string moduleName = configuredName ?? module.Name; + if (string.IsNullOrWhiteSpace(moduleName)) + { + return Invalid(ModuleDiagnosticDescriptors.InvalidModuleName, location); + } + + return new ModuleCandidate( + null, + module.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + .Replace("global::", string.Empty) + .Replace('.', '_') + ".CheatEngineLuaModule.g.cs", + module.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : module.ContainingNamespace.ToDisplayString(), + TypeDeclaration(module, false), + EscapeIdentifier(module.Name), + TypeName(bindings), + moduleName, + exports, + emitsPublicParameterlessConstructor); + } + + private static ModuleCandidate Invalid(DiagnosticDescriptor descriptor, Location location, + params object[] arguments) + { + return new ModuleCandidate(Diagnostic.Create(descriptor, location, arguments), string.Empty, string.Empty, + string.Empty, string.Empty, string.Empty, string.Empty, ImmutableArray.Empty, false); + } + + private static bool TryDetermineConstructorEmission(INamedTypeSymbol module, + out bool emitsPublicParameterlessConstructor, out IMethodSymbol? nonPublicConstructor) + { + emitsPublicParameterlessConstructor = true; + nonPublicConstructor = null; + foreach (IMethodSymbol constructor in module.InstanceConstructors) + { + if (constructor.IsImplicitlyDeclared) + { + continue; + } + + if (constructor.DeclaredAccessibility == Accessibility.Public) + { + emitsPublicParameterlessConstructor = false; + return true; + } + + nonPublicConstructor = constructor; + } + + return nonPublicConstructor is null; + } + } + + private sealed class OperationCandidate + { + private OperationCandidate(Diagnostic? diagnostic, string hintName, string @namespace, + string containingTypeDeclaration, string operationVisibility, string bindingsType, string methodName, + string operationTypeName, ImmutableArray parameters, string sourceResultType, + string resultType, string? mapperType, bool hasOutResult, string failureOperation) + { + Diagnostic = diagnostic; + HintName = hintName; + Namespace = @namespace; + ContainingTypeDeclaration = containingTypeDeclaration; + OperationVisibility = operationVisibility; + BindingsType = bindingsType; + MethodName = methodName; + OperationTypeName = operationTypeName; + Parameters = parameters; + SourceResultType = sourceResultType; + ResultType = resultType; + MapperType = mapperType; + HasOutResult = hasOutResult; + FailureOperation = failureOperation; + } + + public Diagnostic? Diagnostic + { + get; + } + + public string HintName + { + get; + } + + public string Namespace + { + get; + } + + public string ContainingTypeDeclaration + { + get; + } + + public string OperationVisibility + { + get; + } + + public string BindingsType + { + get; + } + + public string MethodName + { + get; + } + + public string OperationTypeName + { + get; + } + + public ImmutableArray Parameters + { + get; + } + + public string SourceResultType + { + get; + } + + public string ResultType + { + get; + } + + public string? MapperType + { + get; + } + + public bool HasOutResult + { + get; + } + + public string FailureOperation + { + get; + } + + public static OperationCandidate Create(GeneratorAttributeSyntaxContext context) + { + IMethodSymbol? method = context.TargetSymbol as IMethodSymbol; + Location location = context.TargetNode.GetLocation(); + if (method is null || !HasAttribute(method, LuaGlobalAttributeMetadataName) || + method.MethodKind != MethodKind.Ordinary || + !method.IsStatic || method.IsGenericMethod || !method.IsPartialDefinition || + method.PartialImplementationPart is not null || method.ReturnsByRef || method.ReturnsByRefReadonly || + method.ContainingType.ContainingType is not null || method.ContainingType.TypeParameters.Length != 0 || + !method.ContainingType.IsStatic || !IsPartial(method.ContainingType)) + { + return Invalid(OperationDiagnosticDescriptors.InvalidOperationShape, location); + } + + if (method.ContainingType.GetMembers(method.Name).OfType() + .Count(static candidate => HasAttribute(candidate, LuaOperationAttributeMetadataName)) != 1) + { + return Invalid(OperationDiagnosticDescriptors.OverloadedOperation, location, method.Name); + } + + ImmutableArray.Builder parameters = ImmutableArray.CreateBuilder(); + IParameterSymbol? outResult = null; + foreach (IParameterSymbol parameter in method.Parameters) + { + if (parameter.RefKind == RefKind.Out) + { + if (outResult is not null || parameter.Ordinal != method.Parameters.Length - 1 || + parameter.IsParams || + parameter.HasExplicitDefaultValue) + { + return Invalid(OperationDiagnosticDescriptors.UnsupportedSignature, location, method.Name); + } + + outResult = parameter; + continue; + } + + if (parameter.RefKind != RefKind.None || parameter.IsParams || parameter.HasExplicitDefaultValue || + !IsScalar(parameter.Type)) + { + return Invalid(OperationDiagnosticDescriptors.UnsupportedSignature, location, method.Name); + } + + parameters.Add(new OperationParameter(TypeName(parameter.Type), EscapeIdentifier(parameter.Name))); + } + + if (outResult is not null && method.ReturnType.SpecialType != SpecialType.System_Boolean) + { + return Invalid(OperationDiagnosticDescriptors.UnsupportedSignature, location, method.Name); + } + + if (outResult is null && method.ReturnsVoid) + { + return Invalid(OperationDiagnosticDescriptors.UnsupportedSignature, location, method.Name); + } + + ITypeSymbol sourceResult = outResult?.Type ?? method.ReturnType; + AttributeData operationAttribute = context.Attributes[0]; + INamedTypeSymbol? mapper = operationAttribute.ConstructorArguments.Length == 1 + ? operationAttribute.ConstructorArguments[0].Value as INamedTypeSymbol + : null; + ITypeSymbol result = sourceResult; + if (mapper is null) + { + if (!IsScalar(sourceResult)) + { + return Invalid(OperationDiagnosticDescriptors.MapperRequired, location, method.Name); + } + } + else if (!TryGetMapperResult(mapper, sourceResult, out result)) + { + return Invalid(OperationDiagnosticDescriptors.InvalidMapper, location, mapper.Name, method.Name); + } + else if (!IsSafeMapperSource(sourceResult) || !IsSafeClientResult(result)) + { + return Invalid(OperationDiagnosticDescriptors.UnsafeMappedType, location, method.Name); + } + + string operationTypeName = method.Name + "LuaOperation"; + return new OperationCandidate( + null, + method.ContainingType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat) + .Replace("global::", string.Empty) + .Replace('.', '_') + "_" + method.Name + ".CheatEngineLuaOperation.g.cs", + method.ContainingNamespace.IsGlobalNamespace + ? string.Empty + : method.ContainingNamespace.ToDisplayString(), + TypeDeclaration(method.ContainingType, true), + method.ContainingType.DeclaredAccessibility == Accessibility.Public ? "public" : "internal", + TypeName(method.ContainingType), + method.Name, + operationTypeName, + parameters.ToImmutable(), + TypeName(sourceResult), + TypeName(result), + mapper is null ? null : TypeName(mapper), + outResult is not null, + "Lua.Operation." + method.ContainingType.Name + "." + method.Name); + } + + private static OperationCandidate Invalid(DiagnosticDescriptor descriptor, Location location, + params object[] arguments) + { + return new OperationCandidate(Diagnostic.Create(descriptor, location, arguments), string.Empty, + string.Empty, + string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, + ImmutableArray.Empty, + string.Empty, string.Empty, null, false, string.Empty); + } + } + + private readonly struct OperationParameter + { + public OperationParameter(string type, string name) + { + Type = type; + Name = name; + FieldName = "_" + name.TrimStart('@'); + } + + public string Type + { + get; + } + + public string Name + { + get; + } + + public string FieldName + { + get; + } + } + + private static class ModuleDiagnosticDescriptors + { + public static readonly DiagnosticDescriptor InvalidModuleShape = new( + "CECLUA1001", "Lua module must be a non-static partial class", + "[CheatEngineLuaModule] requires a top-level, non-static, non-generic partial class", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor InvalidBindingsType = new( + "CECLUA1002", "Lua module requires a static SDK bindings type", + "The bindings type for [CheatEngineLuaModule] must be a static class that owns SDK-generated registration methods", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor NoExports = new( + "CECLUA1003", "Lua module bindings export nothing", + "The bindings type '{0}' does not declare a [LuaFunction] export", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor InvalidExportSet = new( + "CECLUA1004", "Lua module exports must have unique names", + "The Lua module bindings contain an invalid or duplicate export name: '{0}'", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor InvalidModuleName = new( + "CECLUA1005", "Lua module name cannot be blank", + "The explicit [CheatEngineLuaModule] name cannot be empty or whitespace", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor NoPublicConstructor = new( + "CECLUA1006", "Lua module needs a public constructor", + "[CheatEngineLuaModule] cannot provide a safe public constructor because '{0}' declares constructors with no public accessibility; expose a public DI constructor or remove the explicit constructors so the generator can provide one", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + } + + private static class OperationDiagnosticDescriptors + { + public static readonly DiagnosticDescriptor InvalidOperationShape = new( + "CECLUA1101", "Lua operation requires a supported SDK global declaration", + "[CheatEngineLuaOperation] requires a static partial [LuaGlobal] method in a top-level static partial class", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor OverloadedOperation = new( + "CECLUA1102", "Lua operation method cannot be overloaded", + "Lua operation '{0}' is overloaded; use distinct method names so generated operation factories remain unambiguous", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor UnsupportedSignature = new( + "CECLUA1103", "Lua operation has an unsupported result shape", + "Lua operation '{0}' must have scalar input arguments and exactly one return value or one trailing out result", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor MapperRequired = new( + "CECLUA1104", "Lua operation result requires a mapper", + "Lua operation '{0}' returns a non-scalar SDK value; declare [CheatEngineLuaOperation(typeof(TMapper))] with an ILuaResultMapper implementation", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor InvalidMapper = new( + "CECLUA1105", "Lua operation mapper does not match the SDK result", + "Mapper '{0}' does not implement ILuaResultMapper for the result of Lua operation '{1}'", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + + public static readonly DiagnosticDescriptor UnsafeMappedType = new( + "CECLUA1106", "Lua operation mapper must project a safe Client result", + "Lua operation '{0}' maps a raw Lua, ownership, pointer, ref-like, or borrowed Lua-class value across the Client boundary", + "CheatEngine.Client.Lua", DiagnosticSeverity.Error, true); + } + + private sealed class SourceBuilder + { + private readonly StringBuilder _source = new(); + private int _indent; + + public void WriteGeneratedHeader() + { + WriteLine("// "); + WriteLine("#nullable enable"); + WriteLine(); + } + + public void OpenNamespace(string @namespace) + { + if (string.IsNullOrEmpty(@namespace)) + { + return; + } + + WriteLine("namespace " + @namespace + ";"); + WriteLine(); + } + + public void OpenBlock() + { + WriteLine("{"); + Indent(); + } + + public void CloseBlock() + { + Unindent(); + WriteLine("}"); + } + + public void WriteLine(string value = "") + { + if (value.Length != 0) + { + _source.Append('\t', _indent); + } + + _source.AppendLine(value); + } + + public void Indent() + { + _indent++; + } + + public void Unindent() + { + _indent--; + } + + public override string ToString() + { + return _source.ToString(); + } + } +} diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/README.md b/source-generators/CheatEngine.Client.SourceGenerators.Lua/README.md new file mode 100644 index 0000000..e699ee4 --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/README.md @@ -0,0 +1,14 @@ +# CheatEngine.Client.SourceGenerators.Lua + +This Roslyn component turns explicit `CheatEngine.Client.Lua` declarations into activation-safe, +handle-free Client adapters. It is an analyzer asset consumed by plugin projects; it is not a runtime +dependency and never discovers application code through reflection. + +`[CheatEngineLuaModule]` generates an `IDescribedLuaModule` adapter around the SDK-generated Lua +registration pair. It performs a protected, stack-balanced preflight of every exported global before +it writes the first one, then attempts rollback inside the same admitted `LuaRuntimeOperation` if a +registration fails. + +`[CheatEngineLuaOperation]` turns a scalar `[LuaGlobal]` declaration into a readonly value operation +and strongly typed factory. Mapper calls use static abstract interface dispatch so the generated +runtime path stays trim- and AOT-friendly. diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/packages.lock.json b/source-generators/CheatEngine.Client.SourceGenerators.Lua/packages.lock.json new file mode 100644 index 0000000..49c5cce --- /dev/null +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/packages.lock.json @@ -0,0 +1,153 @@ +{ + "version": 2, + "dependencies": { + ".NETStandard,Version=v2.0": { + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "jMNOo+uoBGiWbZ6Wx0dqU93r5WD4P5WnIRZdoz67RP5N09OUetHgAbo4L7hihOH1NHdD2TgW5/l+SP4gQukwww==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "NETStandard.Library": { + "type": "Direct", + "requested": "[2.0.3, )", + "resolved": "2.0.3", + "contentHash": "st47PosZSHrjECdjeIzZQbzivYBJFv6P2nv4cj2ypdI204DO+vZ7l5raGMiX4eXMJ53RfOIg+/s4DHVZ54Nu2A==", + "dependencies": { + "Microsoft.NETCore.Platforms": "1.1.0" + } + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "System.Buffers": "4.6.1", + "System.Collections.Immutable": "10.0.1", + "System.Memory": "4.6.3", + "System.Numerics.Vectors": "4.6.1", + "System.Reflection.Metadata": "10.0.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2", + "System.Text.Encoding.CodePages": "8.0.0", + "System.Threading.Tasks.Extensions": "4.6.3" + } + }, + "Microsoft.NETCore.Platforms": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "System.Buffers": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "N8GXpmiLMtljq7gwvyS+1QvKT/W2J8sNAvx+HVg4NGmsG/H+2k/y9QI23auLJRterrzCiDH+IWAw4V/GPwsMlw==" + }, + "System.Collections.Immutable": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "kdTe61B8P7i2M1pODC3MLbZ/CfFGjpC6c6jzxjQoB5DHZNewayCRqgFUmx3JKB6vLQtozpMQEiw+R5fO32Jv4g==", + "dependencies": { + "System.Memory": "4.6.3", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Memory": "4.6.3" + } + }, + "System.Memory": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "qdcDOgnFZY40+Q9876JUHnlHu7bosOHX8XISRoH94fwk6hgaeQGSgfZd8srWRZNt5bV9ZW2TljcegDNxsf+96A==", + "dependencies": { + "System.Buffers": "4.6.1", + "System.Numerics.Vectors": "4.6.1", + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + }, + "System.Numerics.Vectors": { + "type": "Transitive", + "resolved": "4.6.1", + "contentHash": "sQxefTnhagrhoq2ReR0D/6K0zJcr9Hrd6kikeXsA1I8kOCboTavcUC4r7TSfpKFeE163uMuxZcyfO1mGO3EN8Q==" + }, + "System.Reflection.Metadata": { + "type": "Transitive", + "resolved": "10.0.1", + "contentHash": "zpcfT/wacPPhE17zcudozlxQtWN/84qyiMyZNGLnK4cj2IMBtLsZYwYjVnALUhPliwyUVj/P7kaZvBWYBCnf2Q==", + "dependencies": { + "System.Collections.Immutable": "10.0.1" + } + }, + "System.Runtime.CompilerServices.Unsafe": { + "type": "Transitive", + "resolved": "6.1.2", + "contentHash": "2hBr6zdbIBTDE3EhK7NSVNdX58uTK6iHW/P/Axmm9sl1xoGSLqDvMtpecn226TNwHByFokYwJmt/aQQNlO5CRw==" + }, + "System.Text.Encoding.CodePages": { + "type": "Transitive", + "resolved": "8.0.0", + "contentHash": "OZIsVplFGaVY90G2SbpgU7EnCoOO5pw1t4ic21dBF3/1omrJFpAGoNAVpPyMVOC90/hvgkGG3VFqR13YgZMQfg==", + "dependencies": { + "System.Memory": "4.5.5", + "System.Runtime.CompilerServices.Unsafe": "6.0.0" + } + }, + "System.Threading.Tasks.Extensions": { + "type": "Transitive", + "resolved": "4.6.3", + "contentHash": "7sCiwilJLYbTZELaKnc7RecBBXWXA+xMLQWZKWawBxYjp6DBlSE3v9/UcvKBvr1vv2tTOhipiogM8rRmxlhrVA==", + "dependencies": { + "System.Runtime.CompilerServices.Unsafe": "6.1.2" + } + } + } + } +} \ No newline at end of file diff --git a/templates/CheatEngine.Client.Templates/README.md b/templates/CheatEngine.Client.Templates/README.md index c26cb16..3cdac49 100644 --- a/templates/CheatEngine.Client.Templates/README.md +++ b/templates/CheatEngine.Client.Templates/README.md @@ -16,8 +16,9 @@ Cheat Engine plugins need a direct reference to both packages below: guaranteed to flow through a transitive dependency. The template makes that deployment-critical relationship explicit. Its -`true` marker activates Hosting's `CECLIENT001` guard, -which fails a marked plugin build if the SDK reference stops being direct. +`true` marker activates Hosting's full plugin profile: +both references remain direct, exactly one Client plugin is required, and the `net10.0`/C# 14/x64-or-AnyCPU host +contract is checked before compilation. ## How it helps improve CheatEngine.Client diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj index 324736f..53f7116 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/CheatEngine.Plugin.csproj @@ -7,7 +7,7 @@ enable x64 true - + true true true diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs index 5039262..c3f084e 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginClientModule.cs @@ -1,6 +1,5 @@ using CheatEngine.Client; using CheatEngine.Client.Extensions.DependencyInjection; -using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Modules; using CheatEngine.Client.Processes; @@ -23,20 +22,12 @@ internal sealed partial class PluginClientModule( ILogger logger) : ICheatEngineClientModule { private readonly CheatEngineClientOptions _options = options.Value; - private ILuaModuleLease? _luaModuleLease; - /// Registers the activation-scoped Lua module and demonstrates bounded Client operations. + /// Demonstrates bounded Client operations after generated Lua modules have been registered. public void OnEnabled(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); - if (_luaModuleLease is not null) - { - throw new InvalidOperationException("The Lua module is already registered for this activation."); - } - - _luaModuleLease = client.Lua.RegisterModule(new PluginLuaModule()); - int allowedTableRootCount = _options.AllowedTableRoots?.Length ?? 0; LogEnabled(logger, client.Epoch, allowedTableRootCount); @@ -84,21 +75,10 @@ public void OnEnabled(ICheatEngineClient client) } } - /// - /// Releases the activation-scoped Lua module so the hosting lifecycle can aggregate any cleanup failure. - /// + /// Completes the application module lifecycle before generated Lua modules are released. public void OnDisabling(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); - - ILuaModuleLease? lease = _luaModuleLease; - _luaModuleLease = null; - if (lease is null) - { - return; - } - - lease.Dispose(); } /// Writes a bounded Client operation failure without exposing target-memory data. diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaModule.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaModule.cs index 5c550da..9ee587a 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaModule.cs +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Modules/PluginLuaModule.cs @@ -1,34 +1,15 @@ using CheatEngine.Client.Lua; -using CheatEngine.SDK.Lua.Calls; -using CheatEngine.SDK.Lua.Runtime; namespace CheatEngine.Plugin.Modules; -/// Application-owned Lua module that encapsulates the generated SDK binding calls. +/// +/// Declares the application Lua module. CheatEngine.Client generates the descriptor and the activation-safe +/// register/unregister adapter from at compile time. +/// /// -/// invokes this module only while the activation is current and owns the lease that calls -/// . The SDK state remains inside this implementation; it never crosses the public Client -/// contract or the consuming Client module. +/// Application code deliberately contains no Lua state, operation, lease, or SDK ownership handle. Register the +/// module through builder.Client.AddLuaModule<PluginLuaModule>() so the Client owns its activation-scoped +/// lifecycle and checks global-name collisions before it mutates Lua. /// -internal sealed class PluginLuaModule : ILuaModule -{ - /// - public void Register() - { - LuaStatus status = PluginLuaFunctions.RegisterLuaFunctions(LuaRuntime.AcquireState()); - if (!status.IsOk) - { - throw new InvalidOperationException($"Unable to register the plugin Lua module: {status}."); - } - } - - /// - public void Unregister() - { - LuaStatus status = PluginLuaFunctions.UnregisterLuaFunctions(LuaRuntime.AcquireState()); - if (!status.IsOk) - { - throw new InvalidOperationException($"Unable to unregister the plugin Lua module: {status}."); - } - } -} +[CheatEngineLuaModule(typeof(PluginLuaFunctions), "plugin")] +internal sealed partial class PluginLuaModule : ILuaModule; diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs index 99700fc..d7943e9 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/Plugin.cs @@ -18,7 +18,9 @@ protected override void Configure(CheatEnginePluginBuilder builder) .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false); - builder.Client.AddModule(); + builder.Client + .AddLuaModule() + .AddModule(); } /// Runs after the Client activation scope and its modules have started. diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md index 023eb17..cb91161 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md @@ -21,8 +21,10 @@ The project provides a minimal but production-shaped plugin boundary: The project references `CheatEngine.Client` **and** `CheatEngine.SDK` directly. The SDK reference must remain direct: its plugin generator and native Lua bridge assets are build inputs, not a transitive implementation detail. -`true` enables the `CECLIENT001` build guard to enforce -that rule. +`true` enables the complete Hosting profile: it guards +both direct references, requires exactly one attributed `CheatEngineClientPlugin`, and checks `net10.0`, C# 14, and an +x64 or AnyCPU target. Leave `CheatEngineSdkGenerateEntryPoint` enabled unless you deliberately write the exact SDK +manual bootstrap and explicitly set `CheatEngineClientManualBootstrap=true`. ## How it helps improve CheatEngine.Client @@ -50,6 +52,17 @@ Deploy the complete `bin\Release\net10.0` managed output produced by that build, Native AOT plugin binary: `IsAotCompatible` validates library compatibility only and is not a Cheat Engine plugin loader guarantee. +To prepare a separate deployment folder without modifying Cheat Engine itself, pass an explicit output path: + +```powershell +dotnet build .\CheatEngine.Plugin.csproj --configuration Release --no-restore ` + -p:CheatEnginePluginOutputPath=C:\CheatEngineDeploy\CheatEngine.Plugin +``` + +The opt-in target validates and stages the managed closure before individually replacing destination files with +write-through Windows replacement semantics. It never changes a Cheat Engine installation, configuration, or plugin +list. Because Windows cannot transactionally swap a non-empty directory, run it only while the plugin is disabled. + ## Configure and adapt `appsettings.json` is optional and is loaded only because `Plugin.Configure` explicitly adds it. Leave diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Allocations/AllocationContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Allocations/AllocationContractsTests.cs new file mode 100644 index 0000000..84c7d90 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Allocations/AllocationContractsTests.cs @@ -0,0 +1,29 @@ +using CheatEngine.Client.Allocations; + +namespace CheatEngine.Client.Abstractions.Tests.Allocations; + +public sealed class AllocationContractsTests +{ + [Fact] + public void RequestPreservesTheExactSizeAndAccess() + { + TargetAllocationRequest request = new(4096, TargetAllocationAccess.ExecuteReadWrite); + + Assert.Equal(4096, request.Size); + Assert.Equal(TargetAllocationAccess.ExecuteReadWrite, request.Access); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void RequestRejectsANonPositiveSize(long size) + { + Assert.Throws(() => new TargetAllocationRequest(size)); + } + + [Fact] + public void RequestRejectsAnUndefinedAccess() + { + Assert.Throws(() => new TargetAllocationRequest(1, (TargetAllocationAccess) 99)); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Assembly/AssemblyContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Assembly/AssemblyContractsTests.cs new file mode 100644 index 0000000..8a59ec9 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Assembly/AssemblyContractsTests.cs @@ -0,0 +1,64 @@ +using CheatEngine.Client.Assembly; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Abstractions.Tests.Assembly; + +public sealed class AssemblyContractsTests +{ + [Fact] + public void InstructionSnapshotCopiesTheCallerByteSpan() + { + byte[] bytes = [0x90, 0x90]; + AssemblyInstructionSnapshot snapshot = new(0x401000, 2, "nop", bytes); + bytes[0] = 0xCC; + + Assert.Equal((Address) 0x401000, snapshot.Address); + Assert.Equal(2, snapshot.Length); + Assert.Equal("nop", snapshot.Text); + Assert.Equal([0x90, 0x90], snapshot.Bytes); + } + + [Fact] + public void InstructionSnapshotRejectsEmptyAndMismatchedBytePayloads() + { + Assert.Throws(() => new AssemblyInstructionSnapshot(0x401000, 0, "nop", [0x90])); + Assert.Throws(() => new AssemblyInstructionSnapshot(0x401000, 1, " ", [0x90])); + Assert.Throws(() => new AssemblyInstructionSnapshot(0x401000, 1, "nop", [])); + Assert.Throws(() => new AssemblyInstructionSnapshot(0x401000, 2, "nop", [0x90])); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void InstructionRequestRejectsBlankSource(string source) + { + Assert.Throws(() => new AssemblyInstructionRequest(0x401000, source)); + } + + [Fact] + public void InstructionRequestPreservesTheAssemblyOriginAndSource() + { + AssemblyInstructionRequest request = new(0x401010, "mov eax, 1"); + + Assert.Equal((Address) 0x401010, request.Address); + Assert.Equal("mov eax, 1", request.Instruction); + } + + [Fact] + public void AutoAssemblerScriptPreservesItsOptionalDiagnosticName() + { + AutoAssemblerScript script = new("[ENABLE]\nnop", "sample.patch"); + + Assert.Equal("[ENABLE]\nnop", script.Source); + Assert.Equal("sample.patch", script.Name); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void AutoAssemblerScriptRejectsBlankSourceAndName(string blank) + { + Assert.Throws(() => new AutoAssemblerScript(blank)); + Assert.Throws(() => new AutoAssemblerScript("nop", blank)); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Dbvm/DbvmContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Dbvm/DbvmContractsTests.cs new file mode 100644 index 0000000..01885eb --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Dbvm/DbvmContractsTests.cs @@ -0,0 +1,61 @@ +using CheatEngine.Client.Dbvm; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Abstractions.Tests.Dbvm; + +public sealed class DbvmContractsTests +{ + [Fact] + public void StatusPreservesAnExplicitUninitializedObservation() + { + DbvmStatusSnapshot status = new(DbvmState.NotInitialized, "7.7"); + + Assert.Equal(DbvmState.NotInitialized, status.State); + Assert.Equal("7.7", status.Version); + } + + [Fact] + public void StatusRejectsAnUndefinedState() + { + Assert.Throws(() => new DbvmStatusSnapshot((DbvmState) 99)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void StatusRejectsAnEmptyVersionWhenOneIsProvided(string version) + { + Assert.Throws(() => new DbvmStatusSnapshot(DbvmState.Initialized, version)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void WatchRequestAndEventRejectANonPositiveLength(int length) + { + Assert.Throws(() => new DbvmWatchRequest(0x600000, length)); + Assert.Throws(() => new DbvmWatchEvent(0x600000, length, DateTimeOffset.UtcNow)); + } + + [Fact] + public void InitializationRequestPreservesTheExplicitStealthChoice() + { + DbvmInitializationRequest request = new(true); + + Assert.True(request.UseStealthMode); + } + + [Fact] + public void WatchRequestAndEventPreserveTheirBoundedCopiedObservation() + { + DateTimeOffset occurredAt = new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); + DbvmWatchRequest request = new(0x600000, 8); + DbvmWatchEvent watchEvent = new(0x600000, 8, occurredAt); + + Assert.Equal((Address) 0x600000, request.Address); + Assert.Equal(8, request.Length); + Assert.Equal((Address) 0x600000, watchEvent.Address); + Assert.Equal(8, watchEvent.Length); + Assert.Equal(occurredAt, watchEvent.OccurredAt); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Debugger/DebuggerContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Debugger/DebuggerContractsTests.cs new file mode 100644 index 0000000..82d272d --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Debugger/DebuggerContractsTests.cs @@ -0,0 +1,54 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Debugger; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Abstractions.Tests.Debugger; + +public sealed class DebuggerContractsTests +{ + [Fact] + public void BreakpointRequestPreservesAddressAndKind() + { + BreakpointRequest request = new(0x401000, BreakpointKind.Access); + + Assert.Equal((Address) 0x401000, request.Address); + Assert.Equal(BreakpointKind.Access, request.Kind); + } + + [Fact] + public void BreakpointRequestRejectsAnUndefinedKind() + { + Assert.Throws(() => new BreakpointRequest(0x401000, (BreakpointKind) 99)); + } + + [Fact] + public void BreakpointEventRequiresInitializedCopiedRegisterSnapshots() + { + Assert.Throws(() => new BreakpointEvent(0x401000, 1, default)); + + ImmutableArray registers = [new("RAX", 42)]; + BreakpointEvent breakpointEvent = new(0x401000, 1, registers); + + Assert.Equal((Address) 0x401000, breakpointEvent.Address); + Assert.Equal(1, breakpointEvent.ThreadId); + Assert.Equal(registers, breakpointEvent.Registers); + } + + [Fact] + public void BreakpointEventRejectsANegativeThreadIdentifier() + { + Assert.Throws(() => new BreakpointEvent( + 0x401000, + -1, + ImmutableArray.Empty)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void RegisterSnapshotRejectsABlankName(string name) + { + Assert.Throws(() => new BreakpointRegisterSnapshot(name, 0)); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Events/EventStreamOptionsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Events/EventStreamOptionsTests.cs new file mode 100644 index 0000000..cf7b934 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Events/EventStreamOptionsTests.cs @@ -0,0 +1,39 @@ +using CheatEngine.Client.Events; + +namespace CheatEngine.Client.Abstractions.Tests.Events; + +public sealed class EventStreamOptionsTests +{ + [Fact] + public void ConstructorUsesDropOldestByDefaultForAPositiveCapacity() + { + EventStreamOptions options = new(32); + + Assert.Equal(32, options.Capacity); + Assert.Equal(EventStreamOverflowPolicy.DropOldest, options.OverflowPolicy); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ConstructorRejectsANonPositiveCapacity(int capacity) + { + Assert.Throws(() => new EventStreamOptions(capacity)); + } + + [Theory] + [InlineData(EventStreamOverflowPolicy.DropNewest)] + [InlineData(EventStreamOverflowPolicy.FailSubscription)] + public void ConstructorPreservesEachExplicitOverflowPolicy(EventStreamOverflowPolicy overflowPolicy) + { + EventStreamOptions options = new(1, overflowPolicy); + + Assert.Equal(overflowPolicy, options.OverflowPolicy); + } + + [Fact] + public void ConstructorRejectsAnUndefinedOverflowPolicy() + { + Assert.Throws(() => new EventStreamOptions(1, (EventStreamOverflowPolicy) 99)); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Hashing/HashingContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Hashing/HashingContractsTests.cs new file mode 100644 index 0000000..428181d --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Hashing/HashingContractsTests.cs @@ -0,0 +1,65 @@ +using CheatEngine.Client.Hashing; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Abstractions.Tests.Hashing; + +public sealed class HashingContractsTests +{ + [Fact] + public void MemoryHashRequestPreservesTheBoundedRangeAndAlgorithm() + { + MemoryHashRequest request = new(0x500000, 64, TargetHashAlgorithm.Sha1); + + Assert.Equal((Address) 0x500000, request.Address); + Assert.Equal(64, request.Length); + Assert.Equal(TargetHashAlgorithm.Sha1, request.Algorithm); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void MemoryHashRequestRejectsANonPositiveLength(int length) + { + Assert.Throws(() => new MemoryHashRequest(0x500000, length)); + } + + [Fact] + public void HashContractsRejectAnUndefinedAlgorithm() + { + TargetHashAlgorithm invalid = (TargetHashAlgorithm) 99; + + Assert.Throws(() => new MemoryHashRequest(0x500000, 1, invalid)); + Assert.Throws(() => new FileHashRequest( + Path.Combine(Path.GetTempPath(), "client-hash.bin"), invalid)); + Assert.Throws(() => new HashDigest(invalid, "digest")); + } + + [Fact] + public void FileHashRequestRequiresAnAbsolutePathButNotFileSystemAccessAtConstruction() + { + string path = Path.Combine(Path.GetTempPath(), "not-created.bin"); + FileHashRequest request = new(path); + + Assert.Equal(path, request.FilePath); + Assert.Equal(TargetHashAlgorithm.Sha256, request.Algorithm); + Assert.False(File.Exists(path)); + Assert.Throws(() => new FileHashRequest("relative.bin")); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void DigestRejectsBlankValues(string value) + { + Assert.Throws(() => new HashDigest(TargetHashAlgorithm.Sha256, value)); + } + + [Fact] + public void DigestPreservesAlgorithmAndCopiedText() + { + HashDigest digest = new(TargetHashAlgorithm.Md5, "a94a8fe5"); + + Assert.Equal(TargetHashAlgorithm.Md5, digest.Algorithm); + Assert.Equal("a94a8fe5", digest.Value); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Hotkeys/HotkeyContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Hotkeys/HotkeyContractsTests.cs new file mode 100644 index 0000000..255fdc2 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Hotkeys/HotkeyContractsTests.cs @@ -0,0 +1,65 @@ +using CheatEngine.Client.Hotkeys; + +namespace CheatEngine.Client.Abstractions.Tests.Hotkeys; + +public sealed class HotkeyContractsTests +{ + [Fact] + public void GesturePreservesTheVirtualKeyAndModifierCombination() + { + HotkeyGesture gesture = new(0x70, HotkeyModifiers.Control | HotkeyModifiers.Shift); + + Assert.Equal(0x70, gesture.VirtualKey); + Assert.Equal(HotkeyModifiers.Control | HotkeyModifiers.Shift, gesture.Modifiers); + } + + [Theory] + [InlineData(0)] + [InlineData(255)] + public void GestureAcceptsBothDocumentedVirtualKeyBoundaries(int virtualKey) + { + HotkeyGesture gesture = new(virtualKey); + + Assert.Equal(virtualKey, gesture.VirtualKey); + } + + [Theory] + [InlineData(-1)] + [InlineData(256)] + public void GestureRejectsValuesOutsideTheVirtualKeyRange(int virtualKey) + { + Assert.Throws(() => new HotkeyGesture(virtualKey)); + } + + [Fact] + public void GestureRejectsUnknownModifierBits() + { + Assert.Throws(() => new HotkeyGesture(0x70, (HotkeyModifiers) 16)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void RegistrationAndEventRejectBlankNames(string name) + { + HotkeyGesture gesture = new(0x70); + + Assert.Throws(() => new HotkeyRegistration(name, gesture)); + Assert.Throws(() => new HotkeyEvent(name, gesture, DateTimeOffset.UtcNow)); + } + + [Fact] + public void RegistrationAndEventPreserveCopiedGestureAndTimestamp() + { + HotkeyGesture gesture = new(0x71, HotkeyModifiers.Alt); + DateTimeOffset occurredAt = new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); + HotkeyRegistration registration = new("plugin.toggle", gesture); + HotkeyEvent hotkeyEvent = new("plugin.toggle", gesture, occurredAt); + + Assert.Equal("plugin.toggle", registration.Name); + Assert.Equal(gesture, registration.Gesture); + Assert.Equal("plugin.toggle", hotkeyEvent.Name); + Assert.Equal(gesture, hotkeyEvent.Gesture); + Assert.Equal(occurredAt, hotkeyEvent.OccurredAt); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs index 905ac19..411c8c0 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Lua/LuaContractTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Reflection; @@ -32,6 +33,21 @@ public void LuaScriptPreservesValidatedSourceAndChunkName() Assert.Equal("=calculation", script.ChunkName); } + /// Preserves copied module metadata and rejects duplicate Lua global ownership declarations. + [Fact] + public void LuaModuleDescriptorOwnsAStableIdentityAndUniqueCopiedExports() + { + LuaModuleDescriptor descriptor = new( + "plugin", + ImmutableArray.Create(new LuaExportDescriptor("plugin_status"), new LuaExportDescriptor("plugin_ping"))); + + Assert.Equal("plugin", descriptor.Name); + Assert.Equal(["plugin_status", "plugin_ping"], descriptor.Exports.Select(static export => export.Name)); + Assert.Throws(() => new LuaModuleDescriptor( + "plugin", + ImmutableArray.Create(new LuaExportDescriptor("duplicate"), new LuaExportDescriptor("duplicate")))); + } + /// Ensures public Lua contracts remain handle-free at every reflected signature boundary. [Fact] public void PublicLuaContractsDoNotExposeRawSdkLuaHandles() @@ -41,10 +57,16 @@ public void PublicLuaContractsDoNotExposeRawSdkLuaHandles() typeof(ILuaClient), typeof(ILuaModule), typeof(ILuaModuleLease), + typeof(IDescribedLuaModule), typeof(ILuaOperation<>), + typeof(ILuaResultMapper<,>), typeof(ILuaExecutionContext), typeof(IUnsafeLuaClient), - typeof(LuaScript) + typeof(LuaScript), + typeof(LuaModuleDescriptor), + typeof(LuaExportDescriptor), + typeof(CheatEngineLuaModuleAttribute), + typeof(CheatEngineLuaOperationAttribute) ]; IEnumerable signatures = contracts.SelectMany(GetPublicSignatureTypes) @@ -76,6 +98,32 @@ public void InterfaceExecutionInfersTheOperationResultTypeAndForwardsSuccess() Assert.Equal(2, operation.ExecutionCount); } + /// Preserves the value-type operation overload for third-party Lua client implementations. + [Fact] + public void DefaultValueOperationOverloadsRemainCompatibleWithExistingImplementations() + { + ILuaClient client = new ForwardingLuaClient(); + StructIntLuaOperation operation = new(17); + + bool succeeded = client.TryExecute( + operation, + out int result, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(17, result); + Assert.Equal(default, failure); + Assert.Equal(17, client.Execute(operation, TestContext.Current.CancellationToken)); + } + + /// Uses static abstract mapper dispatch without reflection or an SDK handle in the result. + [Fact] + public void LuaResultMapperUsesTheDeclaredStaticMapContract() + { + Assert.Equal("value:42", TextMapper.Map(new MappingSource(42))); + } + private static IEnumerable GetPublicSignatureTypes(Type type) { foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance | @@ -161,6 +209,28 @@ public bool TryExecute(ILuaExecutionContext context, out int operationResult, ou } } + private readonly struct StructIntLuaOperation(int value) : ILuaOperation + { + public bool TryExecute(ILuaExecutionContext context, out int result, out CheatEngineFailure failure) + { + ArgumentNullException.ThrowIfNull(context); + context.ThrowIfExpired(); + result = value; + failure = default; + return true; + } + } + + private readonly record struct MappingSource(int Value); + + private readonly struct TextMapper : ILuaResultMapper + { + public static string Map(MappingSource source) + { + return $"value:{source.Value}"; + } + } + private sealed class ActiveLuaExecutionContext : ILuaExecutionContext { public long Epoch => 1; diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs index da7f651..add36e7 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Memory/MemoryBoundedRequestTests.cs @@ -82,6 +82,49 @@ public void StringWriteRequestRejectsNullText() Assert.Throws(() => new MemoryStringWriteRequest(0x402200, null!)); } + [Fact] + public void ExplicitStringFactoriesPreserveTheRequestedEncodingAndBound() + { + MemoryStringReadRequest read = MemoryStringReadRequest.Create(0x402210, 64, MemoryStringEncoding.Utf16); + MemoryStringWriteRequest write = MemoryStringWriteRequest.CreateBounded(0x402220, "é", 2, + MemoryStringEncoding.Utf8); + + Assert.Equal(MemoryStringEncoding.Utf16, read.Encoding); + Assert.True(read.WideCharacter); + Assert.Equal(MemoryStringEncoding.Utf8, write.Encoding); + Assert.False(write.WideCharacter); + Assert.Equal(2, write.MaximumLength); + } + + [Fact] + public void ExplicitBoundedStringFactoryRejectsAnOverlongUtf8Payload() + { + Assert.Throws(() => MemoryStringWriteRequest.CreateBounded(0x402230, "é", 1, + MemoryStringEncoding.Utf8)); + Assert.Throws(() => MemoryStringReadRequest.Create(0x402240, 10, + (MemoryStringEncoding) 42)); + } + + [Fact] + public void PrimitiveBatchRequestsCopyCallerInputsAndEnforceTheSharedBound() + { + Address[] addresses = [0x403000, 0x403010]; + MemoryAddressValue[] writes = [new(0x403020, 12), new(0x403024, 24)]; + + MemoryPrimitiveBatchReadRequest reads = new(addresses); + MemoryPrimitiveBatchWriteRequest batchWrites = new(writes); + addresses[0] = 0xDEAD; + writes[0] = new MemoryAddressValue(0xDEAD, 99); + + Assert.Equal([0x403000UL, 0x403010UL], reads.Addresses); + Assert.Equal(0x403020UL, batchWrites.Values[0].Address); + Assert.Equal(12, batchWrites.Values[0].Value); + Assert.Equal(MemoryBatchLimits.MaximumOperations, 1024); + Assert.Throws(() => new MemoryPrimitiveBatchReadRequest(Array.Empty
())); + Assert.Throws(() => new MemoryPrimitiveBatchWriteRequest( + new MemoryAddressValue[MemoryBatchLimits.MaximumOperations + 1])); + } + [Fact] public void PointerChainRequestCopiesOffsetsAndPreservesTheirOrder() { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs index 0b0a0ba..7f7f477 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Processes/ProcessSnapshotTests.cs @@ -6,6 +6,45 @@ namespace CheatEngine.Client.Abstractions.Tests.Processes; public sealed class ProcessSnapshotTests { + [Fact] + public void ProcessEnumerationRequestRejectsZeroMaximumAndAnEmptyNameFilter() + { + Assert.Throws(() => new ProcessEnumerationRequest(0)); + Assert.Throws(() => new ProcessEnumerationRequest(1, string.Empty)); + } + + [Fact] + public void ProcessEnumerationResultNormalizesTheDefaultArrayAndRejectsEmptyTruncation() + { + ProcessEnumerationResult result = new(default, false); + + Assert.Empty(result.Processes); + Assert.False(result.IsTruncated); + Assert.Throws(() => new ProcessEnumerationResult([], true)); + } + + [Fact] + public void ProcessStartRequestRequiresAbsoluteExecutableAndWorkingDirectoryPaths() + { + Assert.Throws(() => new ProcessStartRequest("target.exe")); + Assert.Throws(() => new ProcessStartRequest("C:\\target.exe", null, "working")); + + ProcessStartRequest request = new("C:\\target.exe", "--fixture", "C:\\working"); + Assert.Equal("C:\\target.exe", request.ExecutablePath); + Assert.Equal("--fixture", request.Arguments); + Assert.Equal("C:\\working", request.WorkingDirectory); + } + + [Fact] + public void ProcessPauseSnapshotPreservesCopiedTargetStateAndEpoch() + { + ProcessPauseSnapshot snapshot = new(new TargetProcessId(42), ProcessPauseState.Paused, 7); + + Assert.Equal(new TargetProcessId(42), snapshot.ProcessId); + Assert.Equal(ProcessPauseState.Paused, snapshot.State); + Assert.Equal(7, snapshot.SelectionEpoch); + } + [Fact] public void SnapshotPreservesCopiedIdentityArchitectureAndSelectionEpoch() { diff --git a/tests/CheatEngine.Client.Abstractions.Tests/RemoteExecution/RemoteExecutionContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/RemoteExecution/RemoteExecutionContractsTests.cs new file mode 100644 index 0000000..f95af65 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/RemoteExecution/RemoteExecutionContractsTests.cs @@ -0,0 +1,58 @@ +using CheatEngine.Client.RemoteExecution; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Abstractions.Tests.RemoteExecution; + +public sealed class RemoteExecutionContractsTests +{ + [Fact] + public void DllInjectionRequestPreservesAnAbsoluteDllPath() + { + string path = Path.Combine(Path.GetTempPath(), "client-plugin.dll"); + + RemoteDllInjectionRequest request = new(path); + + Assert.Equal(path, request.LibraryPath); + } + + [Theory] + [InlineData("")] + [InlineData("relative.dll")] + [InlineData("C:\\plugins\\client.txt")] + public void DllInjectionRequestRejectsRelativeOrNonDllPaths(string path) + { + Assert.Throws(() => new RemoteDllInjectionRequest(path)); + } + + [Fact] + public void RemoteCallRequestCopiesParameterBytesAndPreservesAPositiveTimeout() + { + byte[] parameters = [1, 2, 3]; + RemoteCallRequest request = new(0x401000, parameters, TimeSpan.FromMilliseconds(25)); + parameters[0] = 99; + + Assert.Equal((Address) 0x401000, request.EntryPoint); + Assert.Equal([1, 2, 3], request.Parameters); + Assert.Equal(TimeSpan.FromMilliseconds(25), request.Timeout); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void RemoteCallRequestRejectsANonPositiveTimeout(int milliseconds) + { + Assert.Throws(() => new RemoteCallRequest( + 0x401000, [], TimeSpan.FromMilliseconds(milliseconds))); + } + + [Fact] + public void RemoteCallResultCopiesOutputBytes() + { + byte[] output = [0x10, 0x20]; + RemoteCallResult result = new(42, output); + output[0] = 0xCC; + + Assert.Equal(42UL, result.ReturnValue); + Assert.Equal([0x10, 0x20], result.Output); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs index 9e88d6f..dac2490 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs +++ b/tests/CheatEngine.Client.Abstractions.Tests/Runtime/ClientCapabilitiesTests.cs @@ -62,4 +62,25 @@ public void CapabilityIdentifierRejectsBlankInputAndFormatsItsStableValue() Assert.Equal("Client.Sample", capability.Value); Assert.Equal(capability.Value, capability.ToString()); } + + [Fact] + public void AdvancedDomainCapabilityIdentifiersAreStableAndDistinct() + { + ClientCapabilityId[] capabilities = + [ + ClientCapabilityId.Allocations, + ClientCapabilityId.Assembly, + ClientCapabilityId.RemoteExecution, + ClientCapabilityId.Debugger, + ClientCapabilityId.Hotkeys, + ClientCapabilityId.Timers, + ClientCapabilityId.Speed, + ClientCapabilityId.Hashing, + ClientCapabilityId.Dbvm + ]; + + Assert.Equal(9, capabilities.Length); + Assert.Equal(9, capabilities.Select(static capability => capability.Value).Distinct().Count()); + Assert.All(capabilities, static capability => Assert.StartsWith("Client.", capability.Value)); + } } diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Speed/SpeedContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Speed/SpeedContractsTests.cs new file mode 100644 index 0000000..40f91c0 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Speed/SpeedContractsTests.cs @@ -0,0 +1,25 @@ +using CheatEngine.Client.Speed; + +namespace CheatEngine.Client.Abstractions.Tests.Speed; + +public sealed class SpeedContractsTests +{ + [Fact] + public void MultiplierPreservesAFinitePositiveValue() + { + SpeedMultiplier multiplier = new(1.5); + + Assert.Equal(1.5, multiplier.Value); + } + + [Theory] + [InlineData(0d)] + [InlineData(-1d)] + [InlineData(double.PositiveInfinity)] + [InlineData(double.NegativeInfinity)] + [InlineData(double.NaN)] + public void MultiplierRejectsNonFiniteOrNonPositiveValues(double value) + { + Assert.Throws(() => new SpeedMultiplier(value)); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/Timers/TimerContractsTests.cs b/tests/CheatEngine.Client.Abstractions.Tests/Timers/TimerContractsTests.cs new file mode 100644 index 0000000..3d21a56 --- /dev/null +++ b/tests/CheatEngine.Client.Abstractions.Tests/Timers/TimerContractsTests.cs @@ -0,0 +1,40 @@ +using CheatEngine.Client.Timers; + +namespace CheatEngine.Client.Abstractions.Tests.Timers; + +public sealed class TimerContractsTests +{ + [Fact] + public void RequestPreservesAStrictlyPositiveInterval() + { + TimerRequest request = new(TimeSpan.FromSeconds(1)); + + Assert.Equal(TimeSpan.FromSeconds(1), request.Interval); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void RequestRejectsANonPositiveInterval(int milliseconds) + { + Assert.Throws(() => new TimerRequest(TimeSpan.FromMilliseconds(milliseconds))); + } + + [Theory] + [InlineData(-1)] + [InlineData(-2)] + public void TickRejectsANegativeSequence(long sequence) + { + Assert.Throws(() => new TimerTick(sequence, DateTimeOffset.UtcNow)); + } + + [Fact] + public void TickPreservesTheInitialSequenceAndTimestamp() + { + DateTimeOffset occurredAt = new(2026, 9, 21, 12, 0, 0, TimeSpan.Zero); + TimerTick tick = new(0, occurredAt); + + Assert.Equal(0, tick.Sequence); + Assert.Equal(occurredAt, tick.OccurredAt); + } +} diff --git a/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json b/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json index ef33d6d..8c00db6 100644 --- a/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Abstractions.Tests/packages.lock.json @@ -13,17 +13,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.11.2, )", - "resolved": "18.11.2", - "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.10", - "Microsoft.Extensions.DependencyModel": "10.0.10", - "Microsoft.Testing.Platform": "2.4.0" - } - }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -63,16 +52,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.DiaSymReader": { - "type": "Transitive", - "resolved": "2.2.10", - "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" - }, "Microsoft.SourceLink.Common": { "type": "Transitive", "resolved": "10.0.401", @@ -195,4 +174,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.AotProbe/AotProbeLuaBindings.cs b/tests/CheatEngine.Client.AotProbe/AotProbeLuaBindings.cs new file mode 100644 index 0000000..4d06aad --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/AotProbeLuaBindings.cs @@ -0,0 +1,14 @@ +using CheatEngine.SDK.Annotations.Lua; + +namespace CheatEngine.Client.AotProbe; + +/// Minimal explicit SDK-shaped binding fixture consumed by the Client generator during probe compilation. +internal static partial class AotProbeLuaBindings +{ + /// Declares one generated Lua export so descriptor and registration generation are rooted. + [LuaFunction("aot_probe_ping")] + public static int Ping() + { + return 1; + } +} diff --git a/tests/CheatEngine.Client.AotProbe/AotProbeLuaModule.cs b/tests/CheatEngine.Client.AotProbe/AotProbeLuaModule.cs new file mode 100644 index 0000000..b4d2be9 --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/AotProbeLuaModule.cs @@ -0,0 +1,7 @@ +using CheatEngine.Client.Lua; + +namespace CheatEngine.Client.AotProbe; + +/// Roots the Client Lua adapter generator in the AOT compatibility probe without connecting to a live host. +[CheatEngineLuaModule(typeof(AotProbeLuaBindings), "aot-probe")] +internal sealed partial class AotProbeLuaModule : ILuaModule; diff --git a/tests/CheatEngine.Client.AotProbe/AotProbeMapperInvocation.cs b/tests/CheatEngine.Client.AotProbe/AotProbeMapperInvocation.cs new file mode 100644 index 0000000..7be81d3 --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/AotProbeMapperInvocation.cs @@ -0,0 +1,14 @@ +using CheatEngine.Client.Lua; + +namespace CheatEngine.Client.AotProbe; + +/// Exercises static abstract scalar mapper dispatch in the AOT reachability graph. +internal static class AotProbeMapperInvocation +{ + /// Maps one scalar through a compile-time mapper without interface boxing. + public static int Map(int source) + where TMapper : ILuaResultMapper + { + return TMapper.Map(source); + } +} diff --git a/tests/CheatEngine.Client.AotProbe/AotProbeScalarMapper.cs b/tests/CheatEngine.Client.AotProbe/AotProbeScalarMapper.cs new file mode 100644 index 0000000..eae8349 --- /dev/null +++ b/tests/CheatEngine.Client.AotProbe/AotProbeScalarMapper.cs @@ -0,0 +1,13 @@ +using CheatEngine.Client.Lua; + +namespace CheatEngine.Client.AotProbe; + +/// Provides the scalar mapping shape used by generated Lua operations. +internal readonly struct AotProbeScalarMapper : ILuaResultMapper +{ + /// Copies the scalar value. + public static int Map(int source) + { + return source; + } +} diff --git a/tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj b/tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj index 0cd445e..45733d3 100644 --- a/tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj +++ b/tests/CheatEngine.Client.AotProbe/CheatEngine.Client.AotProbe.csproj @@ -5,10 +5,14 @@ win-x64 true false + true + diff --git a/tests/CheatEngine.Client.AotProbe/Program.cs b/tests/CheatEngine.Client.AotProbe/Program.cs index 03350d8..7c0aa94 100644 --- a/tests/CheatEngine.Client.AotProbe/Program.cs +++ b/tests/CheatEngine.Client.AotProbe/Program.cs @@ -1,12 +1,27 @@ using CheatEngine.Client; +using CheatEngine.Client.Allocations; +using CheatEngine.Client.AotProbe; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Debugger; +using CheatEngine.Client.Events; using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Hashing; using CheatEngine.Client.Hosting; +using CheatEngine.Client.Hotkeys; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Scanning; +using CheatEngine.Client.Speed; +using CheatEngine.Client.Timers; using Microsoft.Extensions.DependencyInjection; ServiceCollection services = new(); -services.AddCheatEngineClient().EnableUnsafeLuaExecution(); +services.AddCheatEngineClient() + .AddLuaModule() + .EnableUnsafeLuaExecution(); using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions { @@ -16,6 +31,41 @@ _ = typeof(ICheatEngineClient); _ = typeof(CheatEngineClientPlugin); _ = typeof(AobScanBuilder); +_ = typeof(MemoryAddressBuilder); +_ = typeof(MemoryPrimitiveBatchReadRequest<>); +_ = typeof(MemoryPrimitiveBatchWriteRequest<>); +_ = typeof(PointerChainRequest); +_ = typeof(CheatEngineLuaModuleAttribute); +_ = typeof(CheatEngineLuaOperationAttribute); +_ = typeof(IDescribedLuaModule); +_ = typeof(ILuaResultMapper<,>); +_ = typeof(LuaModuleDescriptor); +_ = typeof(AotProbeLuaModule); +_ = AotProbeMapperInvocation.Map(42); +_ = typeof(IAllocationClient); +_ = typeof(ITargetMemoryLease); +_ = typeof(IAssemblyClient); +_ = typeof(IAutoAssemblerPatchLease); +_ = typeof(IRemoteExecutionClient); +_ = typeof(IDebuggerClient); +_ = typeof(IBreakpointLease); +_ = typeof(IHotkeyClient); +_ = typeof(IHotkeyLease); +_ = typeof(ITimerClient); +_ = typeof(ITimerLease); +_ = typeof(ISpeedClient); +_ = typeof(IHashingClient); +_ = typeof(IDbvmClient); +_ = typeof(IDbvmWatchLease); +_ = typeof(IEventStreamLease<>); +_ = typeof(EventStreamOptions); +_ = typeof(BreakpointRequest); +_ = typeof(HotkeyRegistration); +_ = typeof(TimerRequest); +_ = typeof(RemoteCallRequest); +_ = typeof(MemoryHashRequest); +_ = typeof(DbvmWatchRequest); +_ = typeof(AssemblyInstructionRequest); _ = new AobPattern("90"); return services.Count == 0 ? 1 : 0; diff --git a/tests/CheatEngine.Client.Benchmarks/BenchmarkSuiteMetadata.cs b/tests/CheatEngine.Client.Benchmarks/BenchmarkSuiteMetadata.cs new file mode 100644 index 0000000..f1e5ac5 --- /dev/null +++ b/tests/CheatEngine.Client.Benchmarks/BenchmarkSuiteMetadata.cs @@ -0,0 +1,67 @@ +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +using BenchmarkDotNet.Running; + +using CheatEngine.Client.Memory; + +namespace CheatEngine.Client.Benchmarks; + +/// Versioned metadata that makes a benchmark result comparable across future suite revisions. +public static partial class BenchmarkSuiteMetadata +{ + /// Gets the schema version for CheatEngine.Client.Benchmarks.metadata.json. + public const int SchemaVersion = 1; + + /// Writes the suite descriptor beside BenchmarkDotNet's versioned result artifacts. + /// The BenchmarkDotNet artifact directory for the current run. + public static void WriteTo(string artifactsDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(artifactsDirectory); + + BenchmarkSuiteDescriptor descriptor = new( + SchemaVersion, + typeof(MemoryAddressBuilder).Assembly.GetName().Version?.ToString() ?? "0.0.0.0", + typeof(BenchmarkSwitcher).Assembly.GetName().Version?.ToString() ?? "0.0.0.0", + RuntimeInformation.FrameworkDescription, + DateTimeOffset.UtcNow, + [ + new BenchmarkWorkloadDescriptor( + "fluent-pure-builder", + 1, + true, + "Pure immutable builder construction after warm-up."), + new BenchmarkWorkloadDescriptor( + "lua-scalar-mapper", + 1, + true, + "Static abstract scalar mapping used by generated Lua operations."), + new BenchmarkWorkloadDescriptor( + "di-client-registration", + 1, + false, + "Informational allocation and elapsed-time baseline for DI composition.") + ]); + string content = + JsonSerializer.Serialize(descriptor, BenchmarkSuiteJsonContext.Default.BenchmarkSuiteDescriptor); + File.WriteAllText(Path.Combine(artifactsDirectory, "CheatEngine.Client.Benchmarks.metadata.json"), content); + } + + private sealed record BenchmarkSuiteDescriptor( + int SchemaVersion, + string ClientAssemblyVersion, + string BenchmarkDotNetVersion, + string Runtime, + DateTimeOffset CreatedUtc, + BenchmarkWorkloadDescriptor[] Workloads); + + private sealed record BenchmarkWorkloadDescriptor( + string Id, + int Version, + bool RequiresZeroAllocationAfterWarmup, + string Description); + + [JsonSerializable(typeof(BenchmarkSuiteDescriptor))] + private sealed partial class BenchmarkSuiteJsonContext : JsonSerializerContext; +} diff --git a/tests/CheatEngine.Client.Benchmarks/CheatEngine.Client.Benchmarks.csproj b/tests/CheatEngine.Client.Benchmarks/CheatEngine.Client.Benchmarks.csproj new file mode 100644 index 0000000..5ade650 --- /dev/null +++ b/tests/CheatEngine.Client.Benchmarks/CheatEngine.Client.Benchmarks.csproj @@ -0,0 +1,14 @@ + + + + Exe + CheatEngine.Client.Benchmarks + + + + + + + + + diff --git a/tests/CheatEngine.Client.Benchmarks/DependencyInjectionBenchmarks.cs b/tests/CheatEngine.Client.Benchmarks/DependencyInjectionBenchmarks.cs new file mode 100644 index 0000000..d4ee7f8 --- /dev/null +++ b/tests/CheatEngine.Client.Benchmarks/DependencyInjectionBenchmarks.cs @@ -0,0 +1,25 @@ +using BenchmarkDotNet.Attributes; + +using CheatEngine.Client.Extensions.DependencyInjection; + +using Microsoft.Extensions.DependencyInjection; + +namespace CheatEngine.Client.Benchmarks; + +/// Tracks the intentionally informative cost of starting Client dependency-injection composition. +[MemoryDiagnoser(false)] +[RankColumn] +public class DependencyInjectionBenchmarks +{ + private int _invocationCount; + + /// Measures explicit registration of the Client service graph without activating a Cheat Engine host. + [Benchmark] + [BenchmarkCategory("DependencyInjection", "Informational")] + public int AddClientServices() + { + ServiceCollection services = new(); + _ = services.AddCheatEngineClient(); + return services.Count + _invocationCount++; + } +} diff --git a/tests/CheatEngine.Client.Benchmarks/FluentBuilderBenchmarks.cs b/tests/CheatEngine.Client.Benchmarks/FluentBuilderBenchmarks.cs new file mode 100644 index 0000000..62b8cb5 --- /dev/null +++ b/tests/CheatEngine.Client.Benchmarks/FluentBuilderBenchmarks.cs @@ -0,0 +1,39 @@ +using BenchmarkDotNet.Attributes; + +using CheatEngine.Client.Memory; +using CheatEngine.SDK.Engine.Values; + +using MemoryFluent = CheatEngine.Client.Memory.Memory; + +namespace CheatEngine.Client.Benchmarks; + +/// Measures allocation-free construction of immutable fluent address builders. +[MemoryDiagnoser(false)] +[RankColumn] +public class FluentBuilderBenchmarks +{ + private Address _address; + + /// Initializes a non-constant input so the JIT cannot fold the fluent operation. + [GlobalSetup] + public void Setup() + { + _address = 0x401000UL; + } + + /// Measures one unbound pure-builder creation without a Client-to-SDK transition. + [Benchmark] + [BenchmarkCategory("PureBuilder", "AllocationGate")] + public MemoryAddressBuilder CreateUnboundBuilder() + { + return MemoryFluent.At(_address); + } + + /// Measures the builder construction and its copied address projection. + [Benchmark] + [BenchmarkCategory("PureBuilder", "AllocationGate")] + public Address CreateAndProjectAddress() + { + return MemoryFluent.At(_address).Address; + } +} diff --git a/tests/CheatEngine.Client.Benchmarks/LuaMapperBenchmarks.cs b/tests/CheatEngine.Client.Benchmarks/LuaMapperBenchmarks.cs new file mode 100644 index 0000000..1aab5ab --- /dev/null +++ b/tests/CheatEngine.Client.Benchmarks/LuaMapperBenchmarks.cs @@ -0,0 +1,42 @@ +using BenchmarkDotNet.Attributes; + +using CheatEngine.Client.Lua; + +namespace CheatEngine.Client.Benchmarks; + +/// Measures direct static dispatch through the copied scalar Lua mapper contract. +[MemoryDiagnoser(false)] +[RankColumn] +public class LuaMapperBenchmarks +{ + private int _source; + + /// Initializes a non-constant scalar input for the mapper benchmark. + [GlobalSetup] + public void Setup() + { + _source = 42; + } + + /// Measures the mapper path used by generated scalar Lua operations after they receive an SDK scalar. + [Benchmark] + [BenchmarkCategory("LuaMapper", "AllocationGate")] + public int MapCopiedScalar() + { + return Map(_source); + } + + private static TResult Map(TResult source) + where TMapper : ILuaResultMapper + { + return TMapper.Map(source); + } + + private readonly struct IdentityInt32Mapper : ILuaResultMapper + { + public static int Map(int source) + { + return source; + } + } +} diff --git a/tests/CheatEngine.Client.Benchmarks/Program.cs b/tests/CheatEngine.Client.Benchmarks/Program.cs new file mode 100644 index 0000000..f10fbb9 --- /dev/null +++ b/tests/CheatEngine.Client.Benchmarks/Program.cs @@ -0,0 +1,12 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Exporters.Json; +using BenchmarkDotNet.Running; + +using CheatEngine.Client.Benchmarks; + +string artifactsDirectory = Path.Combine(Environment.CurrentDirectory, "BenchmarkDotNet.Artifacts"); +Directory.CreateDirectory(artifactsDirectory); +BenchmarkSuiteMetadata.WriteTo(artifactsDirectory); + +IConfig config = DefaultConfig.Instance.AddExporter(JsonExporter.Full); +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); diff --git a/tests/CheatEngine.Client.Benchmarks/packages.lock.json b/tests/CheatEngine.Client.Benchmarks/packages.lock.json new file mode 100644 index 0000000..d97abc4 --- /dev/null +++ b/tests/CheatEngine.Client.Benchmarks/packages.lock.json @@ -0,0 +1,296 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "BenchmarkDotNet": { + "type": "Direct", + "requested": "[0.15.8, )", + "resolved": "0.15.8", + "contentHash": "paCfrWxSeHqn3rUZc0spYXVFnHCF0nzRhG0nOLnyTjZYs8spsimBaaNmb3vwqvALKIplbYq/TF393vYiYSnh/Q==", + "dependencies": { + "BenchmarkDotNet.Annotations": "0.15.8", + "CommandLineParser": "2.9.1", + "Gee.External.Capstone": "2.3.0", + "Iced": "1.21.0", + "Microsoft.CodeAnalysis.CSharp": "4.14.0", + "Microsoft.Diagnostics.Runtime": "3.1.512801", + "Microsoft.Diagnostics.Tracing.TraceEvent": "3.1.21", + "Microsoft.DotNet.PlatformAbstractions": "3.1.6", + "Perfolizer": "[0.6.1]", + "System.Management": "9.0.5" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "BenchmarkDotNet.Annotations": { + "type": "Transitive", + "resolved": "0.15.8", + "contentHash": "hfucY0ycAsB0SsoaZcaAp9oq5wlWBJcylvEJb9pmvdYUx6PD6S4mDiYnZWjdjAlLhIpe/xtGCwzORfzAzPqvzA==" + }, + "CommandLineParser": { + "type": "Transitive", + "resolved": "2.9.1", + "contentHash": "OE0sl1/sQ37bjVsPKKtwQlWDgqaxWgtme3xZz7JssWUzg5JpMIyHgCTY9MVMxOg48fJ1AgGT3tgdH5m/kQ5xhA==" + }, + "Gee.External.Capstone": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "2ap/rYmjtzCOT8hxrnEW/QeiOt+paD8iRrIcdKX0cxVwWLFa1e+JDBNeECakmccXrSFeBQuu5AV8SNkipFMMMw==" + }, + "Iced": { + "type": "Transitive", + "resolved": "1.21.0", + "contentHash": "dv5+81Q1TBQvVMSOOOmRcjJmvWcX3BZPZsIq31+RLc5cNft0IHAyNlkdb7ZarOWG913PyBoFDsDXoCIlKmLclg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.14.0", + "contentHash": "PC3tuwZYnC+idaPuoC/AZpEdwrtX7qFpmnrfQkgobGIWiYmGi5MCRtl5mx6QrfMGQpK78X2lfIEoZDLg/qnuHg==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0" + } + }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.510501", + "contentHash": "juoqJYMDs+lRrrZyOkXXMImJHneCF23cuvO4waFRd2Ds7j+ZuGIPbJm0Y/zz34BdeaGiiwGWraMUlln05W1PCQ==", + "dependencies": { + "Microsoft.Extensions.Logging": "6.0.0" + } + }, + "Microsoft.Diagnostics.Runtime": { + "type": "Transitive", + "resolved": "3.1.512801", + "contentHash": "0lMUDr2oxNZa28D6NH5BuSQEe5T9tZziIkvkD44YkkCGQXPJqvFjLq5ZQq1hYLl3RjQJrY+hR0jFgap+EWPDTw==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.410101" + } + }, + "Microsoft.Diagnostics.Tracing.TraceEvent": { + "type": "Transitive", + "resolved": "3.1.21", + "contentHash": "/OrJFKaojSR6TkUKtwh8/qA9XWNtxLrXMqvEb89dBSKCWjaGVTbKMYodIUgF5deCEtmd6GXuRerciXGl5bhZ7Q==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.510501", + "System.Reflection.TypeExtensions": "4.7.0" + } + }, + "Microsoft.DotNet.PlatformAbstractions": { + "type": "Transitive", + "resolved": "3.1.6", + "contentHash": "jek4XYaQ/PGUwDKKhwR8K47Uh1189PFzMeLqO83mXrXQVIpARZCcfuDedH50YDTepBkfijCZN5U/vZi++erxtg==" + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "e3IPP32CRNL031VZJAUTlCTG0YN7WFh4mN3fsSHTDQCJB+3+f0jGycv4fXk3rrftaY3B85XrQaj7sRthrOsavg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "Perfolizer": { + "type": "Transitive", + "resolved": "0.6.1", + "contentHash": "CR1QmWg4XYBd1Pb7WseP+sDmV8nGPwvmowKynExTqr3OuckIGVMhvmN4LC5PGzfXqDlR295+hz/T7syA1CxEqA==", + "dependencies": { + "Pragmastat": "3.2.4" + } + }, + "Pragmastat": { + "type": "Transitive", + "resolved": "3.2.4", + "contentHash": "I5qFifWw/gaTQT52MhzjZpkm/JPlfjSeO/DTZJjO7+hTKI+0aGRgOgZ3NN6D96dDuuqbIAZSeA5RimtHjqrA2A==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "9.0.5", + "contentHash": "cuzLM2MWutf9ZBEMPYYfd0DXwYdvntp7VCT6a/wvbKCa2ZuvGmW74xi+YBa2mrfEieAXqM4TNKlMmSnfAfpUoQ==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "9.0.5", + "contentHash": "n6o9PZm9p25+zAzC3/48K0oHnaPKTInRrxqFq1fi/5TPbMLjuoCm/h//mS3cUmSy+9AO1Z+qsC/Ilt/ZFatv5Q==", + "dependencies": { + "System.CodeDom": "9.0.5" + } + }, + "System.Reflection.TypeExtensions": { + "type": "Transitive", + "resolved": "4.7.0", + "contentHash": "VybpaOQQhqE6siHppMktjfGBw1GCwvCqiufqmP8F1nj7fTUNtW35LOEt3UZTEsECfo+ELAl/9o9nJx3U91i7vA==" + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.core": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )", + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.extensions.dependencyinjection": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )", + "CheatEngine.Client.Core": "[0.1.0, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", + "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" + } + }, + "cheatengine.client.fluent": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )" + } + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "CentralTransitive", + "requested": "[5.9.0, )", + "resolved": "3.11.0", + "contentHash": "v/EW3UE8/lbEYHoC2Qq7AR/DnmvpgdtAMndfQNmpuIMx/Mto8L5JnuCfdBYtgvalQOtfNCnxFejxuRrryvUTsg==" + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "CentralTransitive", + "requested": "[5.9.0, )", + "resolved": "4.14.0", + "contentHash": "568a6wcTivauIhbeWcCwfWwIn7UV7MeHEBvFB2uzGIpM2OhJ4eM/FZ8KS0yhPoNxnSpjGzz7x7CIjTxhslojQA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.11.0", + "Microsoft.CodeAnalysis.Common": "[4.14.0]" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Configuration.Binder": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs index 3e68585..90f8bb2 100644 --- a/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Composition/CheatEngineClientTests.cs @@ -1,14 +1,23 @@ using System.Reflection; +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; using CheatEngine.Client.Core.Tests.TestSupport; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Hashing; +using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Processes; +using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; +using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; +using CheatEngine.Client.Timers; namespace CheatEngine.Client.Core.Tests.Composition; @@ -26,11 +35,36 @@ public void ExposesTheRuntimeAndDomainServicesFromItsActivationComposition() IInspectionClient inspection = CreateProxy(); ITableClient tables = CreateProxy(); ILuaClient lua = CreateProxy(); + IAllocationClient allocations = CreateProxy(); + IAssemblyClient assembly = CreateProxy(); + IRemoteExecutionClient remoteExecution = CreateProxy(); + IDebuggerClient debugger = CreateProxy(); + IHotkeyClient hotkeys = CreateProxy(); + ITimerClient timers = CreateProxy(); + ISpeedClient speed = CreateProxy(); + IHashingClient hashing = CreateProxy(); + IDbvmClient dbvm = CreateProxy(); CheatEngineClient client = new( InertCoreLifetime.Create(), new CheatEngineClientRuntimeServices(runtime, dispatcher), - new CheatEngineClientDomainServices(processes, memory, patterns, scans, inspection, tables, lua)); + new CheatEngineClientDomainServices( + processes, + memory, + patterns, + scans, + inspection, + tables, + lua, + allocations, + assembly, + remoteExecution, + debugger, + hotkeys, + timers, + speed, + hashing, + dbvm)); Assert.Same(runtime, client.Runtime); Assert.Same(dispatcher, client.Dispatcher); @@ -41,6 +75,15 @@ public void ExposesTheRuntimeAndDomainServicesFromItsActivationComposition() Assert.Same(inspection, client.Inspection); Assert.Same(tables, client.Tables); Assert.Same(lua, client.Lua); + Assert.Same(allocations, client.Allocations); + Assert.Same(assembly, client.Assembly); + Assert.Same(remoteExecution, client.RemoteExecution); + Assert.Same(debugger, client.Debugger); + Assert.Same(hotkeys, client.Hotkeys); + Assert.Same(timers, client.Timers); + Assert.Same(speed, client.Speed); + Assert.Same(hashing, client.Hashing); + Assert.Same(dbvm, client.Dbvm); } private static T CreateProxy() diff --git a/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs index 043d3bd..f7b9481 100644 --- a/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Dispatching/SdkMainThreadDispatcherBehaviorTests.cs @@ -31,6 +31,26 @@ public void TryInvokeExecutesActionAndGenericCallbacksThroughTheInjectedMainThre Assert.Equal(1, invoker.FunctionCalls); } + [Fact] + public void InternalStatefulFastPathForwardsValueStateWithoutUsingThePublicClosureFallback() + { + using ControlledCoreLifetimeContext context = new(); + using CoreLifetime lifetime = new(context); + RecordingMainThreadInvoker invoker = new(); +#pragma warning disable CA1859 // This test intentionally dispatches through the internal interface contract. + IStatefulCheatEngineDispatcher dispatcher = new SdkMainThreadDispatcher(lifetime, invoker); +#pragma warning restore CA1859 + + bool succeeded = dispatcher.TryInvoke(21, static value => value * 2, out int result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(42, result); + Assert.Equal(default, failure); + Assert.Equal(1, invoker.StateFunctionCalls); + Assert.Equal(0, invoker.FunctionCalls); + } + [Fact] public void CallbackExceptionsAreRethrownWithoutBeingClassifiedAsHostFailures() { @@ -98,6 +118,12 @@ internal int FunctionCalls private set; } + internal int StateFunctionCalls + { + get; + private set; + } + internal Exception? HostException { get; @@ -140,5 +166,24 @@ public MainThreadInvocationResult Invoke(Func callback) return new MainThreadInvocationResult(default!, exception); } } + + public MainThreadInvocationResult Invoke(Func callback, + TState state) + { + StateFunctionCalls++; + if (HostException is not null) + { + throw HostException; + } + + try + { + return new MainThreadInvocationResult(callback(state), null); + } + catch (Exception exception) + { + return new MainThreadInvocationResult(default!, exception); + } + } } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/Events/BoundedEventStreamTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/Events/BoundedEventStreamTests.cs new file mode 100644 index 0000000..368e408 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/Events/BoundedEventStreamTests.cs @@ -0,0 +1,193 @@ +using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Events; + +namespace CheatEngine.Client.Core.Tests.Domains.Events; + +public sealed class BoundedEventStreamTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void ConstructorRejectsNonPositiveCapacity(int capacity) + { + Assert.Throws(() => + { + _ = new BoundedEventStream(new EventStreamOptions(capacity)); + }); + } + + [Fact] + public void ConstructorDefensivelyRejectsDefaultOptionsWhoseCapacityIsZero() + { + Assert.Throws(() => + { + _ = new BoundedEventStream(default); + }); + } + + [Fact] + public async Task DropOldestEvictsTheOldestCopiedEventAndRecordsTheLoss() + { + using BoundedEventStream stream = new(new EventStreamOptions(2)); + + Assert.True(stream.TryPublish(1)); + Assert.True(stream.TryPublish(2)); + Assert.True(stream.TryPublish(3)); + stream.Complete(); + + await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Equal(2, enumerator.Current); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Equal(3, enumerator.Current); + Assert.False(await enumerator.MoveNextAsync()); + Assert.Equal(1, stream.LostCount); + } + + [Fact] + public async Task DropNewestPreservesTheBufferedEventsAndRecordsTheLoss() + { + using BoundedEventStream stream = new(new EventStreamOptions(2, EventStreamOverflowPolicy.DropNewest)); + + Assert.True(stream.TryPublish(1)); + Assert.True(stream.TryPublish(2)); + Assert.True(stream.TryPublish(3)); + stream.Complete(); + + await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Equal(1, enumerator.Current); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Equal(2, enumerator.Current); + Assert.False(await enumerator.MoveNextAsync()); + Assert.Equal(1, stream.LostCount); + } + + [Fact] + public async Task FailSubscriptionClosesTheStreamWithAnErrorAndRejectsFutureAdmission() + { + using BoundedEventStream stream = + new(new EventStreamOptions(1, EventStreamOverflowPolicy.FailSubscription)); + + Assert.True(stream.TryPublish(1)); + Assert.False(stream.TryPublish(2)); + + await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); + InvalidOperationException exception = await Assert.ThrowsAsync(async () => + { + _ = await enumerator.MoveNextAsync(); + }); + + Assert.Contains("overflowed", exception.Message, StringComparison.Ordinal); + Assert.True(stream.IsCompleted); + Assert.False(stream.IsAdmissionOpen); + Assert.Equal(1, stream.LostCount); + Assert.False(stream.TryPublish(3)); + } + + [Fact] + public async Task PendingReaderReceivesPublishedValueWithoutBlockingTheCallbackAdmissionPath() + { + using BoundedEventStream stream = new(new EventStreamOptions(1)); + await using IAsyncEnumerator enumerator = + stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); + + Task moveNext = enumerator.MoveNextAsync().AsTask(); + Assert.False(moveNext.IsCompleted); + + Assert.True(stream.TryPublish("copied")); + Assert.True(await moveNext); + Assert.Equal("copied", enumerator.Current); + } + + [Fact] + public async Task MultipleWaitingReadersReceiveCopiedEventsInAdmissionOrder() + { + using BoundedEventStream stream = new(new EventStreamOptions(1)); + await using IAsyncEnumerator first = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); + await using IAsyncEnumerator second = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); + + Task firstMoveNext = first.MoveNextAsync().AsTask(); + Task secondMoveNext = second.MoveNextAsync().AsTask(); + + Assert.True(stream.TryPublish(10)); + Assert.True(stream.TryPublish(20)); + + Assert.True(await firstMoveNext); + Assert.Equal(10, first.Current); + Assert.True(await secondMoveNext); + Assert.Equal(20, second.Current); + } + + [Fact] + public async Task CompletionClosesAdmissionAndCompletesAWaitingReader() + { + using BoundedEventStream stream = new(new EventStreamOptions(1)); + await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); + + Task moveNext = enumerator.MoveNextAsync().AsTask(); + Assert.False(moveNext.IsCompleted); + + stream.Complete(); + + Assert.False(await moveNext); + Assert.True(stream.IsCompleted); + Assert.False(stream.IsAdmissionOpen); + Assert.False(stream.TryPublish(1)); + } + + [Fact] + public async Task CancellationRemovesTheWaitingReaderWithoutDiscardingTheNextPublishedEvent() + { + using BoundedEventStream stream = new(new EventStreamOptions(1)); + using CancellationTokenSource cancellation = new(); + await using IAsyncEnumerator cancelledEnumerator = stream.GetAsyncEnumerator(cancellation.Token); + + Task cancelledMoveNext = cancelledEnumerator.MoveNextAsync().AsTask(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(async () => + { + _ = await cancelledMoveNext; + }); + + Assert.True(stream.TryPublish(7)); + await using IAsyncEnumerator activeEnumerator = + stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); + Assert.True(await activeEnumerator.MoveNextAsync()); + Assert.Equal(7, activeEnumerator.Current); + Assert.Equal(0, stream.LostCount); + } + + [Fact] + public async Task DisposeClosesAdmissionDiscardsBufferedValuesAndCompletesReaders() + { + BoundedEventStream stream = new(new EventStreamOptions(1)); + Assert.True(stream.TryPublish(1)); + stream.Dispose(); + + await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); + Assert.False(await enumerator.MoveNextAsync()); + Assert.True(stream.IsCompleted); + Assert.False(stream.IsAdmissionOpen); + Assert.False(stream.TryPublish(2)); + + stream.Dispose(); + } + + [Fact] + public async Task OneEnumeratorRejectsOverlappingMoveNextCalls() + { + using BoundedEventStream stream = new(new EventStreamOptions(1)); + await using IAsyncEnumerator enumerator = stream.GetAsyncEnumerator(TestContext.Current.CancellationToken); + + Task firstMoveNext = enumerator.MoveNextAsync().AsTask(); + InvalidOperationException exception = await Assert.ThrowsAsync(async () => + { + _ = await enumerator.MoveNextAsync(); + }); + Assert.Contains("Concurrent", exception.Message, StringComparison.Ordinal); + + stream.Complete(); + Assert.False(await firstMoveNext); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/Events/EventStreamLeaseTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/Events/EventStreamLeaseTests.cs new file mode 100644 index 0000000..780d6dc --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/Events/EventStreamLeaseTests.cs @@ -0,0 +1,87 @@ +using CheatEngine.Client.Core.Domains.Events; +using CheatEngine.Client.Events; + +namespace CheatEngine.Client.Core.Tests.Domains.Events; + +public sealed class EventStreamLeaseTests +{ + [Fact] + public async Task DisposeStopsAdmissionBeforeNeutralizingThenCompletesAndReleasesInOrder() + { + List calls = []; + BoundedEventStream stream = new(new EventStreamOptions(1)); + EventStreamLease lease = new( + stream, + () => + { + Assert.False(stream.IsAdmissionOpen); + calls.Add("neutralize"); + }, + () => calls.Add("release"), + _ => calls.Add("untrack")); + + lease.Dispose(); + + Assert.True(lease.IsReleased); + Assert.Equal(["neutralize", "release", "untrack"], calls); + Assert.False(lease.TryPublish(1)); + await using IAsyncEnumerator enumerator = + lease.Events.GetAsyncEnumerator(TestContext.Current.CancellationToken); + Assert.False(await enumerator.MoveNextAsync()); + } + + [Fact] + public void DisposeIsIdempotentAndDoesNotReleaseTheHostTwice() + { + int neutralized = 0; + int released = 0; + BoundedEventStream stream = new(new EventStreamOptions(1)); + EventStreamLease lease = new(stream, () => neutralized++, () => released++); + + lease.Dispose(); + lease.Dispose(); + + Assert.Equal(1, neutralized); + Assert.Equal(1, released); + } + + [Fact] + public async Task DisposeCompletesTheStreamAndReleasesTheHostEvenWhenNeutralizationFails() + { + BoundedEventStream stream = new(new EventStreamOptions(1)); + int released = 0; + EventStreamLease lease = new(stream, + static () => throw new InvalidOperationException("neutralize"), + () => released++); + + InvalidOperationException exception = Assert.Throws(lease.Dispose); + + Assert.Equal("neutralize", exception.Message); + Assert.Equal(1, released); + Assert.True(lease.IsReleased); + Assert.False(lease.TryPublish(1)); + await using IAsyncEnumerator enumerator = + lease.Events.GetAsyncEnumerator(TestContext.Current.CancellationToken); + Assert.False(await enumerator.MoveNextAsync()); + } + + [Fact] + public async Task LeaseExposesTheStreamLossCounterWithoutWaitingForConsumers() + { + BoundedEventStream stream = new(new EventStreamOptions(1)); + using EventStreamLease lease = new(stream, static () => + { + }, static () => + { + }); + + Assert.True(lease.TryPublish(1)); + Assert.True(lease.TryPublish(2)); + Assert.Equal(1, lease.DroppedEventCount); + + await using IAsyncEnumerator enumerator = + lease.Events.GetAsyncEnumerator(TestContext.Current.CancellationToken); + Assert.True(await enumerator.MoveNextAsync()); + Assert.Equal(2, enumerator.Current); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs index 0c9b602..c5e17be 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/InspectionClientBehaviorTests.cs @@ -21,7 +21,8 @@ public void GetModulesCopiesOnlyTheWrittenEntriesAndUsesTheRequestedProcessOverl FakeInspectionPort port = new() { ModulesWritten = 1 }; InspectionClient client = CreateClient(lifetime, port); - bool succeeded = client.TryGetModules(new InspectionCollectionRequest(2), out ImmutableArray modules, + bool succeeded = client.TryGetModules(new InspectionCollectionRequest(2), + out ImmutableArray modules, out CheatEngineFailure failure, new TargetProcessId(42), TestContext.Current.CancellationToken); Assert.True(succeeded); @@ -40,7 +41,8 @@ public void GetModulesCopiesOnlyTheWrittenEntriesAndUsesTheRequestedProcessOverl [InlineData(InspectionStatus.LuaFailure, CheatEngineFailureKind.LuaError)] [InlineData(InspectionStatus.InvalidResult, CheatEngineFailureKind.InvalidHostResult)] [InlineData((InspectionStatus) 999, CheatEngineFailureKind.Unknown)] - public void InspectionStatusesMapToStableClientFailures(InspectionStatus status, CheatEngineFailureKind expectedKind) + public void InspectionStatusesMapToStableClientFailures(InspectionStatus status, + CheatEngineFailureKind expectedKind) { using ControlledCoreLifetimeContext context = new(); using CoreLifetime lifetime = new(context); @@ -61,7 +63,10 @@ public void CollectionAndScalarQueriesForwardInputsAndCopyOnlyTheWrittenEntries( { using ControlledCoreLifetimeContext context = new(); using CoreLifetime lifetime = new(context); - FakeInspectionPort port = new() { SectionsWritten = 1, RegionsWritten = 1, ResolvedAddress = new Address(0xC0FFEE) }; + FakeInspectionPort port = new() + { + SectionsWritten = 1, RegionsWritten = 1, ResolvedAddress = new Address(0xC0FFEE) + }; InspectionClient client = CreateClient(lifetime, port); ModuleName module = new("fixture.exe"); SymbolExpression symbol = new("fixture+10"); @@ -75,7 +80,8 @@ public void CollectionAndScalarQueriesForwardInputsAndCopyOnlyTheWrittenEntries( TestContext.Current.CancellationToken)); Assert.True(client.TryGetSymbol(symbol, out SymbolInfo returnedSymbol, out CheatEngineFailure symbolFailure, TestContext.Current.CancellationToken)); - Assert.True(client.TryResolveAddress(symbol, options, out Address resolved, out CheatEngineFailure addressFailure, + Assert.True(client.TryResolveAddress(symbol, options, out Address resolved, + out CheatEngineFailure addressFailure, TestContext.Current.CancellationToken)); Assert.Single(sections); @@ -130,7 +136,7 @@ public void RegisterSymbolReservesOneNameAndReleasesItWithTheLease() using CoreLifetime lifetime = new(context); FakeInspectionPort port = new(); InspectionClient client = CreateClient(lifetime, port); - SymbolRegistration registration = new("fixture-symbol", new Address(0x401000), true); + SymbolRegistration registration = new("fixture-symbol", new Address(0x401000)); Assert.True(client.TryRegisterSymbol(registration, out ISymbolRegistrationLease? lease, out CheatEngineFailure firstFailure, TestContext.Current.CancellationToken)); @@ -166,7 +172,8 @@ public void CancelledInspectionDoesNotContactTheSdkPort() using CancellationTokenSource cancellation = new(); cancellation.Cancel(); - bool succeeded = client.TryGetModules(new InspectionCollectionRequest(1), out ImmutableArray modules, + bool succeeded = client.TryGetModules(new InspectionCollectionRequest(1), + out ImmutableArray modules, out CheatEngineFailure failure, cancellationToken: cancellation.Token); Assert.False(succeeded); @@ -178,34 +185,153 @@ public void CancelledInspectionDoesNotContactTheSdkPort() private static InspectionClient CreateClient(CoreLifetime lifetime, IInspectionPort port) { - return new InspectionClient(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), lifetime, port); + return new InspectionClient(new SdkMainThreadDispatcher(lifetime, new InlineMainThreadInvoker()), lifetime, + port); } private sealed class FakeInspectionPort : IInspectionPort { - internal int CurrentProcessModuleCalls { get; private set; } - internal int ExplicitProcessModuleCalls { get; private set; } - internal int LastModuleBufferLength { get; private set; } - internal TargetProcessId? LastModuleProcessId { get; private set; } - internal int SectionsWritten { get; init; } - internal int RegionsWritten { get; init; } - internal int LastSectionBufferLength { get; private set; } - internal int LastRegionBufferLength { get; private set; } - internal ModuleName LastSectionModule { get; private set; } - internal SymbolExpression LastSymbolExpression { get; private set; } - internal SymbolExpression LastAddressExpression { get; private set; } - internal AddressResolutionOptions LastAddressOptions { get; private set; } - internal InspectionStatus ModulesStatus { get; init; } = InspectionStatus.Success; - internal InspectionStatus MemoryRegionStatus { get; init; } = InspectionStatus.Success; - internal Address ResolvedAddress { get; init; } - internal bool ResolveNameResult { get; init; } - internal string? ResolvedName { get; init; } - internal nuint LastNameAddress { get; private set; } - internal int RegisterCalls { get; private set; } - internal string? LastRegisteredName { get; private set; } - internal nuint LastRegisteredAddress { get; private set; } - internal bool LastRegisteredDoNotSave { get; private set; } - internal List UnregisteredNames { get; } = []; + internal int CurrentProcessModuleCalls + { + get; + private set; + } + + internal int ExplicitProcessModuleCalls + { + get; + private set; + } + + internal int LastModuleBufferLength + { + get; + private set; + } + + internal TargetProcessId? LastModuleProcessId + { + get; + private set; + } + + internal int SectionsWritten + { + get; + init; + } + + internal int RegionsWritten + { + get; + init; + } + + internal int LastSectionBufferLength + { + get; + private set; + } + + internal int LastRegionBufferLength + { + get; + private set; + } + + internal ModuleName LastSectionModule + { + get; + private set; + } + + internal SymbolExpression LastSymbolExpression + { + get; + private set; + } + + internal SymbolExpression LastAddressExpression + { + get; + private set; + } + + internal AddressResolutionOptions LastAddressOptions + { + get; + private set; + } + + internal InspectionStatus ModulesStatus + { + get; + } = InspectionStatus.Success; + + internal InspectionStatus MemoryRegionStatus + { + get; + init; + } = InspectionStatus.Success; + + internal Address ResolvedAddress + { + get; + init; + } + + internal bool ResolveNameResult + { + get; + init; + } + + internal string? ResolvedName + { + get; + init; + } + + internal nuint LastNameAddress + { + get; + private set; + } + + internal int RegisterCalls + { + get; + private set; + } + + internal string? LastRegisteredName + { + get; + private set; + } + + internal nuint LastRegisteredAddress + { + get; + private set; + } + + internal bool LastRegisteredDoNotSave + { + get; + private set; + } + + internal List UnregisteredNames + { + get; + } = []; + + public int ModulesWritten + { + get; + init; + } public InspectionStatus EnumerateModules(ModuleInfo[] destination, out int written) { @@ -224,9 +350,8 @@ public InspectionStatus EnumerateModules(TargetProcessId processId, ModuleInfo[] return ModulesStatus; } - public int ModulesWritten { get; init; } - - public InspectionStatus EnumerateSections(ModuleName moduleName, ModuleSectionInfo[] destination, out int written) + public InspectionStatus EnumerateSections(ModuleName moduleName, ModuleSectionInfo[] destination, + out int written) { LastSectionModule = moduleName; LastSectionBufferLength = destination.Length; @@ -254,7 +379,8 @@ public InspectionStatus GetSymbol(SymbolExpression expression, out SymbolInfo sy return InspectionStatus.Success; } - public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, out Address address) + public InspectionStatus ResolveAddress(SymbolExpression expression, AddressResolutionOptions options, + out Address address) { LastAddressExpression = expression; LastAddressOptions = options; diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessHostTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessHostTests.cs index 99835e8..6bdfaa7 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessHostTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/LocalProcessHostTests.cs @@ -29,4 +29,16 @@ public void TryGetLocalProcessReturnsFalseForAnInvalidProcessId() Assert.False(succeeded); Assert.Equal(default, process); } + + [Fact] + public void GetLocalProcessesContainsCopiedMetadataForTheCurrentManagedProcess() + { + using Process current = Process.GetCurrentProcess(); + LocalProcessHost host = new(); + + IReadOnlyList processes = host.GetLocalProcesses(); + LocalProcessInfo currentSnapshot = Assert.Single(processes, process => process.Id == current.Id); + + Assert.Equal(current.ProcessName, currentSnapshot.Name); + } } diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs index 0c8fd6f..534a84e 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/MemoryClientDispatchFailureTests.cs @@ -94,6 +94,36 @@ public void PointerResolutionPreservesTheDispatcherFailureAndDefaultAddress() Assert.Equal(expected, failure); } + [Fact] + public void PrimitiveBatchesPreserveTheDispatcherFailureWithoutAdmittingAnyTargetOperation() + { + CheatEngineFailure expected = Failure("Test.Batch"); + MemoryClient client = new(new RejectingDispatcher(expected)); + MemoryPrimitiveBatchReadRequest reads = new([Address, Address + 4]); + MemoryPrimitiveBatchWriteRequest writes = new([new MemoryAddressValue(Address, 12)]); + + Assert.False(client.TryReadPrimitiveBatch(reads, out ImmutableArray values, + out CheatEngineFailure readFailure, + TestContext.Current.CancellationToken)); + Assert.True(values.IsEmpty); + Assert.Equal(expected, readFailure); + Assert.False(client.TryWritePrimitiveBatch(writes, out CheatEngineFailure writeFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(expected, writeFailure); + } + + [Fact] + public void DefaultPrimitiveBatchesAreRejectedBeforeDispatch() + { + MemoryClient client = new(new RejectingDispatcher(Failure("Test.ShouldNotDispatch"))); + + Assert.Throws(() => client.TryReadPrimitiveBatch(default, out ImmutableArray _, out _, + TestContext.Current.CancellationToken)); + Assert.Throws(() => client.TryWritePrimitiveBatch( + default(MemoryPrimitiveBatchWriteRequest), out _, + TestContext.Current.CancellationToken)); + } + [Fact] public void UnsupportedPrimitiveTypesReturnTheSpecificUnsupportedFailureWithoutAccessingTheHost() { diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs index 256ae4f..37a693b 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/ProcessClientTests.cs @@ -26,6 +26,85 @@ public void RefreshKeepsTheSelectionEpochForTheSamePidAndArchitecture() Assert.Equal(initial.SelectionEpoch, refreshed.SelectionEpoch); } + [Fact] + public void GetProcessesFiltersOrdersAndTruncatesCopiedLocalMetadataWithoutSelectingAnyProcess() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.LocalProcesses[52] = new LocalProcessInfo(52, "alpha-worker", "C:\\fixtures\\alpha-worker.exe"); + host.LocalProcesses[43] = new LocalProcessInfo(43, "alpha-server", "C:\\fixtures\\alpha-server.exe"); + host.LocalProcesses[44] = new LocalProcessInfo(44, "beta", "C:\\fixtures\\beta.exe"); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + ProcessEnumerationResult result = client.GetProcesses( + new ProcessEnumerationRequest(1, "ALPHA"), + TestContext.Current.CancellationToken); + + Assert.True(result.IsTruncated); + ProcessInfoSnapshot snapshot = Assert.Single(result.Processes); + Assert.Equal(new TargetProcessId(43), snapshot.Id); + Assert.Equal("alpha-server", snapshot.Name); + Assert.Equal("C:\\fixtures\\alpha-server.exe", snapshot.ExecutablePath); + Assert.Empty(host.OpenProcessCalls); + Assert.Equal(0, selectionLifetime.Epoch); + } + + [Fact] + public void TryGetProcessesHonorsCancellationBeforeEnumeratingTheHost() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = client.TryGetProcesses( + new ProcessEnumerationRequest(1), + out ProcessEnumerationResult result, + out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal(0, host.GetLocalProcessesCalls); + } + + [Fact] + public void TryGetProcessesRejectsTheDefaultRequestBeforeEnumeratingTheHost() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + Assert.Throws(() => + client.TryGetProcesses(default, out _, out _, TestContext.Current.CancellationToken)); + + Assert.Equal(0, host.GetLocalProcessesCalls); + } + + [Fact] + public void TryGetProcessesMapsAHostMaterializationFailureWithoutSelectingAnyProcess() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + host.GetLocalProcessesException = new InvalidOperationException("fixture enumeration failed"); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + bool succeeded = client.TryGetProcesses( + new ProcessEnumerationRequest(1), + out ProcessEnumerationResult result, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, result); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + Assert.Equal("Processes.GetProcesses", failure.Operation); + Assert.Equal(1, host.GetLocalProcessesCalls); + Assert.Empty(host.OpenProcessCalls); + } + [Fact] public void RefreshChangesPidAdvancesSelectionEpochAndDisposesTheOldTargetLease() { @@ -207,6 +286,143 @@ public void ExactNameAttachmentRejectsPathsAndInvalidNamesBeforeProcessDiscovery Assert.Empty(host.OpenProcessCalls); } + [Fact] + public void TryAttachForegroundIsCapabilityGatedWithoutChangingTheObservedSelection() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + + bool succeeded = client.TryAttachForeground( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal(42, host.OpenedProcessId); + Assert.Empty(host.OpenProcessCalls); + Assert.Equal(initial.SelectionEpoch, selectionLifetime.Epoch); + } + + [Fact] + public void AttachForegroundThrowsTheStableCapabilityUnavailableFailure() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + CheatEngineOperationException exception = Assert.Throws(() => + client.AttachForeground(TestContext.Current.CancellationToken)); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + Assert.Equal("Processes.AttachForeground", exception.Failure.Operation); + Assert.Empty(host.OpenProcessCalls); + } + + [Fact] + public void TryCreateIsCapabilityGatedWithoutStartingOrSelectingAnyProcess() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + + bool succeeded = client.TryCreate( + new ProcessStartRequest("C:\\fixtures\\target.exe"), + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal(42, host.OpenedProcessId); + Assert.Empty(host.OpenProcessCalls); + Assert.Equal(initial.SelectionEpoch, selectionLifetime.Epoch); + } + + [Fact] + public void TryCreateRejectsTheDefaultRequestBeforeHostAdmission() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + + Assert.Throws(() => client.TryCreate( + default, + out _, + out _, + TestContext.Current.CancellationToken)); + + Assert.Empty(host.OpenProcessCalls); + } + + [Fact] + public void TryPauseIsCapabilityGatedWithoutChangingTheObservedSelection() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + + bool succeeded = client.TryPause( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal(42, host.OpenedProcessId); + Assert.Empty(host.OpenProcessCalls); + Assert.Equal(initial.SelectionEpoch, selectionLifetime.Epoch); + } + + [Fact] + public void TryResumeExecutionIsCapabilityGatedWithoutChangingTheObservedSelection() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + + bool succeeded = client.TryResumeExecution( + out ProcessSnapshot snapshot, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal(42, host.OpenedProcessId); + Assert.Empty(host.OpenProcessCalls); + Assert.Equal(initial.SelectionEpoch, selectionLifetime.Epoch); + } + + [Fact] + public void TryGetPauseStateIsCapabilityGatedWithoutChangingTheObservedSelection() + { + FakeProcessHost host = FakeProcessHost.CreateSelected(42, CheatEngineArchitecture.X64); + using TargetSelectionLifetime selectionLifetime = CreateSelectionLifetime(); + ProcessClient client = new(new InlineDispatcher(), host, selectionLifetime); + ProcessSnapshot initial = client.GetCurrent(TestContext.Current.CancellationToken); + + bool succeeded = client.TryGetPauseState( + out ProcessPauseSnapshot snapshot, + out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Equal(default, snapshot); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal(42, host.OpenedProcessId); + Assert.Empty(host.OpenProcessCalls); + Assert.Equal(initial.SelectionEpoch, selectionLifetime.Epoch); + } + private static TargetSelectionLifetime CreateSelectionLifetime() { return new TargetSelectionLifetime(static _ => @@ -246,6 +462,18 @@ internal List OpenProcessCalls get; } = []; + internal int GetLocalProcessesCalls + { + get; + private set; + } + + internal Exception? GetLocalProcessesException + { + get; + set; + } + internal long OpenedProcessId { get; @@ -280,6 +508,17 @@ public bool TryGetLocalProcess(int processId, out LocalProcessInfo process) return LocalProcesses.TryGetValue(processId, out process); } + public IReadOnlyList GetLocalProcesses() + { + GetLocalProcessesCalls++; + if (GetLocalProcessesException is { } exception) + { + throw exception; + } + + return LocalProcesses.Values.ToArray(); + } + public IReadOnlyList FindProcessesByExactName(string processName) { return NameMatches.TryGetValue(processName, out IReadOnlyList? matches) ? matches : []; diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs index 5392fa6..f78156f 100644 --- a/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Domains/RuntimeClientTests.cs @@ -141,6 +141,35 @@ public void SnapshotReportsClientGatesWithoutClaimingUnprobedDomainsAreAvailable Assert.True(snapshot.ClientCapabilities.TryGet(ClientCapabilityId.UnsafeLuaExecution, out ClientCapabilityAvailability unsafeLua)); Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, unsafeLua.State); + + ClientCapabilityId[] unavailableCapabilities = + [ + ClientCapabilityId.Allocations, + ClientCapabilityId.RemoteExecution, + ClientCapabilityId.Debugger, + ClientCapabilityId.Hotkeys, + ClientCapabilityId.Timers, + ClientCapabilityId.Dbvm + ]; + foreach (ClientCapabilityId capability in unavailableCapabilities) + { + Assert.True(snapshot.ClientCapabilities.TryGet(capability, out ClientCapabilityAvailability availability)); + Assert.Equal(ClientCapabilityAvailabilityState.Unavailable, availability.State); + Assert.False(availability.IsAvailable); + } + + ClientCapabilityId[] unknownCapabilities = + [ + ClientCapabilityId.Assembly, + ClientCapabilityId.Speed, + ClientCapabilityId.Hashing + ]; + foreach (ClientCapabilityId capability in unknownCapabilities) + { + Assert.True(snapshot.ClientCapabilities.TryGet(capability, out ClientCapabilityAvailability availability)); + Assert.Equal(ClientCapabilityAvailabilityState.Unknown, availability.State); + Assert.False(availability.IsKnown); + } } [Fact] diff --git a/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableAdvancedClientsTests.cs b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableAdvancedClientsTests.cs new file mode 100644 index 0000000..d7594f9 --- /dev/null +++ b/tests/CheatEngine.Client.Core.Tests/Domains/UnavailableAdvancedClientsTests.cs @@ -0,0 +1,403 @@ +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Core.Domains.Allocations; +using CheatEngine.Client.Core.Domains.Assembly; +using CheatEngine.Client.Core.Domains.Dbvm; +using CheatEngine.Client.Core.Domains.Debugger; +using CheatEngine.Client.Core.Domains.Hashing; +using CheatEngine.Client.Core.Domains.Hotkeys; +using CheatEngine.Client.Core.Domains.RemoteExecution; +using CheatEngine.Client.Core.Domains.Speed; +using CheatEngine.Client.Core.Domains.Timers; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Debugger; +using CheatEngine.Client.Events; +using CheatEngine.Client.Hashing; +using CheatEngine.Client.Hotkeys; +using CheatEngine.Client.RemoteExecution; +using CheatEngine.Client.Results; +using CheatEngine.Client.Speed; +using CheatEngine.Client.Timers; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Core.Tests.Domains; + +public sealed class UnavailableAdvancedClientsTests +{ + private static readonly Address _address = new(0x401000); + + [Fact] + public void AllocationTryOperationReportsTheLiveGateAndLeavesNoLease() + { + UnavailableAllocationClient client = new(); + + bool succeeded = client.TryAllocate(new TargetAllocationRequest(4096), out ITargetMemoryLease? lease, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal("Allocations.Allocate", failure.Operation); + Assert.Contains("Cheat Engine 7.7 x64 live gate", failure.Message, StringComparison.Ordinal); + } + + [Fact] + public void AllocationThrowingOperationPreservesTheUnavailableFailure() + { + UnavailableAllocationClient client = new(); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = client.Allocate(new TargetAllocationRequest(1), TestContext.Current.CancellationToken); + }); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + } + + [Fact] + public void AssemblyTryOperationsReturnDefaultsAndAStableCapabilityFailure() + { + UnavailableAssemblyClient client = new(); + + Assert.False(client.TryDisassemble(_address, out AssemblyInstructionSnapshot instruction, + out CheatEngineFailure disassemble, + TestContext.Current.CancellationToken)); + Assert.Equal(default, instruction); + Assert.Equal("Assembly.Disassemble", disassemble.Operation); + + Assert.False(client.TryGetInstructionSize(_address, out int size, out CheatEngineFailure sizeFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(0, size); + Assert.Equal("Assembly.GetInstructionSize", sizeFailure.Operation); + + Assert.False(client.TryGetPreviousInstruction(_address, out Address previous, + out CheatEngineFailure previousFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, previous); + Assert.Equal("Assembly.GetPreviousInstruction", previousFailure.Operation); + + Assert.False(client.TryGetComment(_address, out string? comment, out CheatEngineFailure commentFailure, + TestContext.Current.CancellationToken)); + Assert.Null(comment); + Assert.Equal("Assembly.GetComment", commentFailure.Operation); + + Assert.False(client.TryAssemble(new AssemblyInstructionRequest(_address, "nop"), out _, + out CheatEngineFailure assemble, + TestContext.Current.CancellationToken)); + Assert.Equal("Assembly.Assemble", assemble.Operation); + + Assert.False(client.TryApplyPatch(new AutoAssemblerScript("[ENABLE]\n[DISABLE]"), + out IAutoAssemblerPatchLease? patch, out CheatEngineFailure patchFailure, + TestContext.Current.CancellationToken)); + Assert.Null(patch); + Assert.Equal("Assembly.ApplyPatch", patchFailure.Operation); + } + + [Fact] + public void AssemblyThrowingOperationPreservesTheUnavailableFailure() + { + UnavailableAssemblyClient client = new(); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = client.Disassemble(_address, TestContext.Current.CancellationToken); + }); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + } + + [Fact] + public void RemoteExecutionTryOperationsReturnCopiedDefaultsAndStableFailures() + { + UnavailableRemoteExecutionClient client = new(); + RemoteDllInjectionRequest injection = new(Path.GetFullPath("fixture.dll")); + RemoteCallRequest call = new(_address, [1, 2, 3], TimeSpan.FromMilliseconds(1)); + + Assert.False(client.TryInjectLibrary(injection, out CheatEngineFailure injectFailure, + TestContext.Current.CancellationToken)); + Assert.Equal("RemoteExecution.InjectLibrary", injectFailure.Operation); + + Assert.False(client.TryInvoke(call, out RemoteCallResult result, out CheatEngineFailure callFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, result); + Assert.Equal("RemoteExecution.Invoke", callFailure.Operation); + } + + [Fact] + public void RemoteExecutionThrowingOperationPreservesTheUnavailableFailure() + { + UnavailableRemoteExecutionClient client = new(); + RemoteDllInjectionRequest request = new(Path.GetFullPath("fixture.dll")); + + CheatEngineOperationException exception = Assert.Throws(() => + { + client.InjectLibrary(request, TestContext.Current.CancellationToken); + }); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + } + + [Fact] + public void SpeedTryOperationsReturnDefaultsAndStableFailures() + { + UnavailableSpeedClient client = new(); + + Assert.False(client.TryGetMultiplier(out SpeedMultiplier multiplier, out CheatEngineFailure getFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, multiplier); + Assert.Equal("Speed.GetMultiplier", getFailure.Operation); + + Assert.False(client.TrySetMultiplier(new SpeedMultiplier(1), out CheatEngineFailure setFailure, + TestContext.Current.CancellationToken)); + Assert.Equal("Speed.SetMultiplier", setFailure.Operation); + } + + [Fact] + public void SpeedThrowingOperationPreservesTheUnavailableFailure() + { + UnavailableSpeedClient client = new(); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = client.GetMultiplier(TestContext.Current.CancellationToken); + }); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + } + + [Fact] + public void HashingKeepsMemoryAndFileFailuresAsSeparateOperations() + { + UnavailableHashingClient client = new(); + MemoryHashRequest memory = new(_address, 4); + FileHashRequest file = new(Path.GetFullPath("fixture.bin")); + + Assert.False(client.TryHashMemory(memory, out HashDigest memoryDigest, out CheatEngineFailure memoryFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, memoryDigest); + Assert.Equal("Hashing.HashMemory", memoryFailure.Operation); + + Assert.False(client.TryHashFile(file, out HashDigest fileDigest, out CheatEngineFailure fileFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, fileDigest); + Assert.Equal("Hashing.HashFile", fileFailure.Operation); + } + + [Fact] + public void CancellationTakesPrecedenceOverTheCapabilityGate() + { + UnavailableHashingClient client = new(); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + bool succeeded = client.TryHashMemory(new MemoryHashRequest(_address, 1), out _, out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Equal("Hashing.HashMemory", failure.Operation); + } + + [Fact] + public void DebuggerTryRegistrationReturnsNoLeaseAndTheLiveGateFailure() + { + UnavailableDebuggerClient client = new(); + + bool succeeded = client.TryRegisterBreakpoint(new BreakpointRequest(_address), + static _ => BreakpointDisposition.Continue, + new EventStreamOptions(1), out IBreakpointLease? lease, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal("Debugger.RegisterBreakpoint", failure.Operation); + } + + [Fact] + public void DebuggerThrowingRegistrationPreservesTheUnavailableFailure() + { + UnavailableDebuggerClient client = new(); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = client.RegisterBreakpoint(new BreakpointRequest(_address), static _ => BreakpointDisposition.Continue, + new EventStreamOptions(1), TestContext.Current.CancellationToken); + }); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + } + + [Fact] + public void DebuggerRegistrationRejectsDefaultStreamOptionsAndNullHandlerBeforeCapabilityGate() + { + UnavailableDebuggerClient client = new(); + BreakpointRequest request = new(_address); + + Assert.Throws(() => client.TryRegisterBreakpoint(request, + static _ => BreakpointDisposition.Continue, default, out _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => client.TryRegisterBreakpoint(request, null!, + new EventStreamOptions(1), + out _, out _, TestContext.Current.CancellationToken)); + } + + [Fact] + public void HotkeyTryRegistrationReturnsNoLeaseAndTheLiveGateFailure() + { + UnavailableHotkeyClient client = new(); + HotkeyRegistration registration = new("fixture", new HotkeyGesture(0x41)); + + bool succeeded = client.TryRegister(registration, static _ => + { + }, new EventStreamOptions(1), + out IHotkeyLease? lease, out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal("Hotkeys.Register", failure.Operation); + } + + [Fact] + public void HotkeyThrowingRegistrationPreservesTheUnavailableFailure() + { + UnavailableHotkeyClient client = new(); + HotkeyRegistration registration = new("fixture", new HotkeyGesture(0x41)); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = client.Register(registration, static _ => + { + }, new EventStreamOptions(1), TestContext.Current.CancellationToken); + }); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + } + + [Fact] + public void HotkeyRegistrationRejectsDefaultStreamOptionsAndNullHandlerBeforeCapabilityGate() + { + UnavailableHotkeyClient client = new(); + HotkeyRegistration registration = new("fixture", new HotkeyGesture(0x41)); + + Assert.Throws(() => client.TryRegister(registration, static _ => + { + }, default, + out _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => client.TryRegister(registration, null!, new EventStreamOptions(1), + out _, out _, TestContext.Current.CancellationToken)); + } + + [Fact] + public void TimerTryRegistrationReturnsNoLeaseAndTheLiveGateFailure() + { + UnavailableTimerClient client = new(); + + bool succeeded = client.TryRegister(new TimerRequest(TimeSpan.FromMilliseconds(1)), static _ => + { + }, + new EventStreamOptions(1), out ITimerLease? lease, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, failure.Kind); + Assert.Equal("Timers.Register", failure.Operation); + } + + [Fact] + public void TimerThrowingRegistrationPreservesTheUnavailableFailure() + { + UnavailableTimerClient client = new(); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = client.Register(new TimerRequest(TimeSpan.FromMilliseconds(1)), static _ => + { + }, + new EventStreamOptions(1), TestContext.Current.CancellationToken); + }); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + } + + [Fact] + public void TimerRegistrationRejectsDefaultStreamOptionsAndNullHandlerBeforeCapabilityGate() + { + UnavailableTimerClient client = new(); + TimerRequest request = new(TimeSpan.FromMilliseconds(1)); + + Assert.Throws(() => client.TryRegister(request, static _ => + { + }, default, + out _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => client.TryRegister(request, null!, new EventStreamOptions(1), + out _, out _, TestContext.Current.CancellationToken)); + } + + [Fact] + public void DbvmObservesWithoutInitializingAndKeepsWatchRegistrationGated() + { + UnavailableDbvmClient client = new(); + + Assert.False(client.TryGetStatus(out DbvmStatusSnapshot status, out CheatEngineFailure statusFailure, + TestContext.Current.CancellationToken)); + Assert.Equal(default, status); + Assert.Equal("Dbvm.GetStatus", statusFailure.Operation); + + Assert.False(client.TryInitialize(new DbvmInitializationRequest(), out DbvmStatusSnapshot initialized, + out CheatEngineFailure initializeFailure, TestContext.Current.CancellationToken)); + Assert.Equal(default, initialized); + Assert.Equal("Dbvm.Initialize", initializeFailure.Operation); + + Assert.False(client.TryRegisterWatch(new DbvmWatchRequest(_address, 1), static _ => + { + }, new EventStreamOptions(1), + out IDbvmWatchLease? lease, out CheatEngineFailure watchFailure, TestContext.Current.CancellationToken)); + Assert.Null(lease); + Assert.Equal("Dbvm.RegisterWatch", watchFailure.Operation); + } + + [Fact] + public void DbvmThrowingObservationPreservesTheUnavailableFailure() + { + UnavailableDbvmClient client = new(); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = client.GetStatus(TestContext.Current.CancellationToken); + }); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + } + + [Fact] + public void DbvmWatchRegistrationRejectsDefaultStreamOptionsAndNullHandlerBeforeCapabilityGate() + { + UnavailableDbvmClient client = new(); + DbvmWatchRequest request = new(_address, 1); + + Assert.Throws(() => client.TryRegisterWatch(request, static _ => + { + }, default, + out _, out _, TestContext.Current.CancellationToken)); + Assert.Throws(() => client.TryRegisterWatch(request, null!, new EventStreamOptions(1), + out _, out _, TestContext.Current.CancellationToken)); + } + + [Fact] + public void DbvmThrowingWatchRegistrationPreservesTheUnavailableFailure() + { + UnavailableDbvmClient client = new(); + + CheatEngineOperationException exception = Assert.Throws(() => + { + _ = client.RegisterWatch(new DbvmWatchRequest(_address, 1), static _ => + { + }, new EventStreamOptions(1), + TestContext.Current.CancellationToken); + }); + + Assert.Equal(CheatEngineFailureKind.CapabilityUnavailable, exception.Failure.Kind); + } +} diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs index 2a6db32..7106689 100644 --- a/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaClientTests.cs @@ -1,3 +1,4 @@ +using CheatEngine.Client.Core.Dispatching; using CheatEngine.Client.Core.Domains; using CheatEngine.Client.Dispatching; using CheatEngine.Client.Lua; @@ -14,7 +15,7 @@ public void TryExecuteProvidesTheCapturedEpochOnlyDuringItsSynchronousOperation( RetainingOperation operation = new(); LuaClient client = new(dispatcher, static () => 42, static () => true); - bool succeeded = client.TryExecute(operation, out int result, + bool succeeded = client.TryExecute(operation, out int result, out CheatEngineFailure failure, TestContext.Current.CancellationToken); @@ -35,7 +36,7 @@ public void TryExecutePreservesAnExpectedOperationFailure() CheatEngineFailure expected = new(CheatEngineFailureKind.LuaError, "Lua.Custom", "The Lua global failed."); LuaClient client = new(new ImmediateDispatcher(), static () => 1, static () => true); - bool succeeded = client.TryExecute(new FailingOperation(expected), out int result, + bool succeeded = client.TryExecute(new FailingOperation(expected), out int result, out CheatEngineFailure failure, TestContext.Current.CancellationToken); Assert.False(succeeded); @@ -106,6 +107,39 @@ public void TryExecuteHonorsCancellationBeforeTheTypedOperationRuns() Assert.Null(operation.Context); } + [Fact] + public void TryExecuteUsesTheConstrainedValueOperationOverloadForGeneratedStructOperations() + { + ImmediateDispatcher dispatcher = new(); + LuaClient client = new(dispatcher, static () => 8, static () => true); + GeneratedValueOperation operation = new(73); + + bool succeeded = client.TryExecute(operation, out int result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(73, result); + Assert.Equal(default, failure); + Assert.Equal(1, dispatcher.InvocationCount); + } + + [Fact] + public void TryExecuteUsesTheStatefulDispatcherPathForGeneratedStructOperations() + { + StatefulDispatcher dispatcher = new(); + LuaClient client = new(dispatcher, static () => 8, static () => true); + GeneratedValueOperation operation = new(73); + + bool succeeded = client.TryExecute(operation, out int result, + out CheatEngineFailure failure, TestContext.Current.CancellationToken); + + Assert.True(succeeded); + Assert.Equal(73, result); + Assert.Equal(default, failure); + Assert.Equal(1, dispatcher.StatefulInvocationCount); + Assert.Equal(0, dispatcher.FallbackInvocationCount); + } + private sealed class RetainingOperation : ILuaOperation { internal ILuaExecutionContext? Context @@ -135,6 +169,17 @@ public bool TryExecute(ILuaExecutionContext context, out int result, out CheatEn } } + private readonly record struct GeneratedValueOperation(int Value) : ILuaOperation + { + public bool TryExecute(ILuaExecutionContext context, out int result, out CheatEngineFailure failure) + { + context.ThrowIfExpired(); + result = Value; + failure = default; + return true; + } + } + private sealed class ImmediateDispatcher : ICheatEngineDispatcher { public int InvocationCount @@ -197,4 +242,70 @@ public TResult Invoke(Func callback, CancellationToken cancell return default!; } } + + private sealed class StatefulDispatcher : ICheatEngineDispatcher, IStatefulCheatEngineDispatcher + { + public int FallbackInvocationCount + { + get; + private set; + } + + public int StatefulInvocationCount + { + get; + private set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + FallbackInvocationCount++; + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + FallbackInvocationCount++; + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out TResult result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + + public bool TryInvoke(TState state, Func callback, out TResult result, + out CheatEngineFailure failure, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(callback); + StatefulInvocationCount++; + result = callback(state); + failure = default; + return true; + } + } } diff --git a/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs index 941ebcf..1432f43 100644 --- a/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs +++ b/tests/CheatEngine.Client.Core.Tests/Lua/LuaModuleRegistrationTests.cs @@ -1,3 +1,5 @@ +using System.Collections.Immutable; + using CheatEngine.Client.Core.Domains; using CheatEngine.Client.Core.Infrastructure; using CheatEngine.Client.Dispatching; @@ -64,6 +66,127 @@ public void TryRegisterModuleRejectsTheSameModuleInstanceUntilItsLeaseIsReleased Assert.Equal(["diagnostics.register", "diagnostics.unregister"], module.Events); } + [Fact] + public void TryRegisterModuleAllowsSeparateManualModulesBecauseTheyDoNotClaimGlobalNames() + { + ImmediateDispatcher dispatcher = new(); + RecordingModule first = new("first"); + RecordingModule second = new("second"); + LuaClient client = CreateClient(dispatcher, static () => true); + + using ILuaModuleLease firstLease = client.RegisterModule(first, TestContext.Current.CancellationToken); + using ILuaModuleLease secondLease = client.RegisterModule(second, TestContext.Current.CancellationToken); + + Assert.Equal(["first.register"], first.Events); + Assert.Equal(["second.register"], second.Events); + } + + [Fact] + public void TryRegisterModuleRejectsAnAlreadyReservedDescribedModuleIdentityBeforeLuaMutation() + { + ImmediateDispatcher dispatcher = new(); + DescribedRecordingModule first = new("first", "diagnostics", ["first_export"]); + DescribedRecordingModule second = new("second", "diagnostics", ["second_export"]); + LuaClient client = CreateClient(dispatcher, static () => true); + + using ILuaModuleLease firstLease = client.RegisterModule(first, TestContext.Current.CancellationToken); + bool succeeded = client.TryRegisterModule(second, out ILuaModuleLease? lease, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Equal("Lua.RegisterModule", failure.Operation); + Assert.Contains("identity 'diagnostics'", failure.Message, StringComparison.Ordinal); + Assert.Equal(["first.register"], first.Events); + Assert.Empty(second.Events); + Assert.Equal(1, dispatcher.InvocationCount); + } + + [Fact] + public void TryRegisterModuleRejectsAnAlreadyReservedDescribedExportBeforeLuaMutation() + { + ImmediateDispatcher dispatcher = new(); + DescribedRecordingModule first = new("first", "first", ["diagnostics"]); + DescribedRecordingModule second = new("second", "second", ["diagnostics"]); + LuaClient client = CreateClient(dispatcher, static () => true); + + using ILuaModuleLease firstLease = client.RegisterModule(first, TestContext.Current.CancellationToken); + bool succeeded = client.TryRegisterModule(second, out ILuaModuleLease? lease, out CheatEngineFailure failure, + TestContext.Current.CancellationToken); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.InvalidState, failure.Kind); + Assert.Contains("export 'diagnostics'", failure.Message, StringComparison.Ordinal); + Assert.Equal(["first.register"], first.Events); + Assert.Empty(second.Events); + Assert.Equal(1, dispatcher.InvocationCount); + } + + [Fact] + public void FailedDescribedRegistrationReleasesItsNameReservationForTheNextModule() + { + ImmediateDispatcher dispatcher = new(); + DescribedRecordingModule failed = new("failed", "diagnostics", ["diagnostics"], + new InvalidOperationException("generated registration failed")); + DescribedRecordingModule replacement = new("replacement", "diagnostics", ["diagnostics"]); + LuaClient client = CreateClient(dispatcher, static () => true); + + Assert.False(client.TryRegisterModule(failed, out _, out CheatEngineFailure failure, + TestContext.Current.CancellationToken)); + Assert.Equal(CheatEngineFailureKind.OperationRejected, failure.Kind); + using ILuaModuleLease lease = client.RegisterModule(replacement, TestContext.Current.CancellationToken); + + Assert.Equal(["failed.register"], failed.Events); + Assert.Equal(["replacement.register"], replacement.Events); + } + + [Fact] + public async Task ConcurrentDescribedRegistrationRejectsTheSecondModuleBeforeEitherOfItsLuaExportsMutate() + { + using BlockingDispatcher dispatcher = new(); + DescribedRecordingModule first = new("first", "one", ["diagnostics"]); + DescribedRecordingModule second = new("second", "two", ["diagnostics"]); + LuaClient client = CreateClient(dispatcher, static () => true); + ILuaModuleLease? firstLease = null; + + Task firstRegistration = Task.Run(() => + client.TryRegisterModule(first, out firstLease, out _, CancellationToken.None)); + Assert.True(dispatcher.WaitUntilEntered(TimeSpan.FromSeconds(5))); + + bool secondSucceeded = client.TryRegisterModule(second, out ILuaModuleLease? secondLease, + out CheatEngineFailure secondFailure, TestContext.Current.CancellationToken); + dispatcher.Release(); + + Assert.True(await firstRegistration); + Assert.False(secondSucceeded); + Assert.Null(secondLease); + Assert.Equal(CheatEngineFailureKind.InvalidState, secondFailure.Kind); + Assert.Empty(second.Events); + Assert.Equal(["first.register"], first.Events); + firstLease!.Dispose(); + } + + [Fact] + public void TryRegisterModuleHonorsCancellationBeforeReservingOrDispatching() + { + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + ImmediateDispatcher dispatcher = new(); + DescribedRecordingModule module = new("diagnostics", "diagnostics", ["diagnostics"]); + LuaClient client = CreateClient(dispatcher, static () => true); + + bool succeeded = client.TryRegisterModule(module, out ILuaModuleLease? lease, out CheatEngineFailure failure, + cancellation.Token); + + Assert.False(succeeded); + Assert.Null(lease); + Assert.Equal(CheatEngineFailureKind.Cancelled, failure.Kind); + Assert.Empty(module.Events); + Assert.Equal(0, dispatcher.InvocationCount); + } + [Fact] public void RegisterModuleReturnsTheLeaseAndThrowsTheMappedFailureForARejectedRegistration() { @@ -270,6 +393,39 @@ public void Unregister() } } + private sealed class DescribedRecordingModule : IDescribedLuaModule + { + private readonly RecordingModule _inner; + + internal DescribedRecordingModule( + string name, + string moduleName, + string[] exports, + Exception? registerException = null) + { + _inner = new RecordingModule(name, registerException); + Descriptor = new LuaModuleDescriptor(moduleName, + exports.Select(static export => new LuaExportDescriptor(export)).ToImmutableArray()); + } + + internal List Events => _inner.Events; + + public LuaModuleDescriptor Descriptor + { + get; + } + + public void Register() + { + _inner.Register(); + } + + public void Unregister() + { + _inner.Unregister(); + } + } + private sealed class ImmediateDispatcher : ICheatEngineDispatcher { public int InvocationCount @@ -343,4 +499,77 @@ public TResult Invoke(Func callback, CancellationToken cancell return default!; } } + + private sealed class BlockingDispatcher : ICheatEngineDispatcher, IDisposable + { + private readonly ManualResetEventSlim _entered = new(false); + private readonly ManualResetEventSlim _release = new(false); + + public int InvocationCount + { + get; + private set; + } + + public bool IsMainThread => true; + + public bool TryInvoke(Action callback, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + _entered.Set(); + _release.Wait(cancellationToken); + callback(); + failure = default; + return true; + } + + public bool TryInvoke(Func callback, out TResult result, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + InvocationCount++; + _entered.Set(); + _release.Wait(cancellationToken); + result = callback(); + failure = default; + return true; + } + + public void Invoke(Action callback, CancellationToken cancellationToken = default) + { + if (!TryInvoke(callback, out CheatEngineFailure failure, cancellationToken)) + { + failure.Throw(); + } + } + + public TResult Invoke(Func callback, CancellationToken cancellationToken = default) + { + if (TryInvoke(callback, out TResult result, out CheatEngineFailure failure, cancellationToken)) + { + return result; + } + + failure.Throw(); + return default!; + } + + public void Dispose() + { + _entered.Dispose(); + _release.Dispose(); + } + + public void Release() + { + _release.Set(); + } + + internal bool WaitUntilEntered(TimeSpan timeout) + { + return _entered.Wait(timeout); + } + } } diff --git a/tests/CheatEngine.Client.Core.Tests/packages.lock.json b/tests/CheatEngine.Client.Core.Tests/packages.lock.json index 13e5d02..d9d0a56 100644 --- a/tests/CheatEngine.Client.Core.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Core.Tests/packages.lock.json @@ -13,17 +13,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.11.2, )", - "resolved": "18.11.2", - "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.10", - "Microsoft.Extensions.DependencyModel": "10.0.10", - "Microsoft.Testing.Platform": "2.4.0" - } - }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -63,16 +52,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.DiaSymReader": { - "type": "Transitive", - "resolved": "2.2.10", - "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" - }, "Microsoft.SourceLink.Common": { "type": "Transitive", "resolved": "10.0.401", @@ -202,4 +181,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs index d138fa7..f47d265 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/CheatEngineClientServiceCollectionExtensionsTests.cs @@ -1,6 +1,25 @@ +using System.Collections.Immutable; +using System.Diagnostics.CodeAnalysis; + +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Debugger; +using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Hashing; +using CheatEngine.Client.Hotkeys; +using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Modules; +using CheatEngine.Client.Processes; +using CheatEngine.Client.RemoteExecution; +using CheatEngine.Client.Results; +using CheatEngine.Client.Runtime; +using CheatEngine.Client.Scanning; +using CheatEngine.Client.Speed; +using CheatEngine.Client.Tables; +using CheatEngine.Client.Timers; using CheatEngine.SDK.Engine.Values; using Microsoft.Extensions.Configuration; @@ -21,6 +40,15 @@ public void AddCheatEngineClientRegistersDescriptorsThatPassProviderValidationWi Assert.NotNull(builder); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(ICheatEngineClient)); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IMemoryCodec)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IAllocationClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IAssemblyClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IRemoteExecutionClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IDebuggerClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IHotkeyClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(ITimerClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(ISpeedClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IHashingClient)); + Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IDbvmClient)); Assert.Contains(services, static descriptor => descriptor.ServiceType == typeof(IValidateOptions)); Assert.DoesNotContain(services, static descriptor => descriptor.ServiceType == typeof(IUnsafeLuaClient)); @@ -151,6 +179,50 @@ public void AddModuleConstructsAModuleWithScopedDependencyOncePerActivationScope Assert.Same(first.Dependency, scope.ServiceProvider.GetRequiredService()); } + [Fact] + public void AddLuaModuleRegistersOneActivationLifecyclePerExplicitDescribedModule() + { + ServiceCollection services = new(); + services.AddCheatEngineClient() + .AddLuaModule() + .AddLuaModule() + .AddLuaModule(); + + using ServiceProvider provider = services.BuildServiceProvider(new ServiceProviderOptions + { + ValidateOnBuild = true, ValidateScopes = true + }); + ServiceDescriptor[] lifecycleDescriptors = services + .Where(static descriptor => descriptor.ServiceType == typeof(ICheatEngineClientModule)) + .ToArray(); + + Assert.Collection( + lifecycleDescriptors, + descriptor => Assert.Equal(typeof(LuaModuleLifecycle), descriptor.ImplementationType), + descriptor => Assert.Equal(typeof(LuaModuleLifecycle), descriptor.ImplementationType)); + Assert.Contains(services, static descriptor => + descriptor.ServiceType == typeof(FirstLuaModule) && descriptor.Lifetime == ServiceLifetime.Scoped); + Assert.Contains(services, static descriptor => + descriptor.ServiceType == typeof(SecondLuaModule) && descriptor.Lifetime == ServiceLifetime.Scoped); + } + + [Fact] + public void LuaModuleLifecycleRegistersAndReleasesItsModuleExactlyOnce() + { + RecordingLuaClient lua = new(); + FirstLuaModule module = new(); + LuaModuleLifecycle lifecycle = new(lua, module); + TestClient client = new(); + + lifecycle.OnEnabled(client); + lifecycle.OnDisabling(client); + lifecycle.OnDisabling(client); + + Assert.Same(module, lua.RegisteredModule); + RecordingLease lease = Assert.IsType(lua.Lease); + Assert.Equal(1, lease.DisposeCount); + } + private readonly record struct CustomValue(int Value); private sealed class FirstCustomCodec : IMemoryCodec @@ -226,4 +298,160 @@ public void OnDisabling(ICheatEngineClient client) { } } + + public sealed class FirstLuaModule : IDescribedLuaModule + { + public LuaModuleDescriptor Descriptor + { + get; + } = new("first", ImmutableArray.Create(new LuaExportDescriptor("first"))); + + public void Register() + { + } + + public void Unregister() + { + } + } + + public sealed class SecondLuaModule : IDescribedLuaModule + { + public LuaModuleDescriptor Descriptor + { + get; + } = new("second", ImmutableArray.Create(new LuaExportDescriptor("second"))); + + public void Register() + { + } + + public void Unregister() + { + } + } + + private sealed class RecordingLuaClient : ILuaClient + { + internal RecordingLease? Lease + { + get; + private set; + } + + internal ILuaModule? RegisteredModule + { + get; + private set; + } + + public bool TryRegisterModule( + ILuaModule luaModule, + [NotNullWhen(true)] out ILuaModuleLease? lease, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(luaModule); + RegisteredModule = luaModule; + Lease = new RecordingLease(); + lease = Lease; + failure = default; + return true; + } + + public ILuaModuleLease RegisterModule(ILuaModule luaModule, CancellationToken cancellationToken = default) + { + if (TryRegisterModule(luaModule, out ILuaModuleLease? lease, out CheatEngineFailure failure, + cancellationToken)) + { + return lease; + } + + failure.Throw(); + throw new InvalidOperationException("A failed Lua registration must throw its mapped exception."); + } + + public bool TryExecute( + ILuaOperation operation, + [MaybeNullWhen(false)] out TResult result, + out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(operation); + result = default; + failure = new CheatEngineFailure(CheatEngineFailureKind.InvalidState, "Test.Lua", "Not used by this test."); + return false; + } + + public TResult Execute(ILuaOperation operation, CancellationToken cancellationToken = default) + { + _ = TryExecute(operation, out TResult? result, out CheatEngineFailure failure, cancellationToken); + failure.Throw(); + return result!; + } + } + + private sealed class RecordingLease : ILuaModuleLease + { + internal int DisposeCount + { + get; + private set; + } + + public long Epoch => 1; + + public bool IsReleased => DisposeCount != 0; + + public void Dispose() + { + if (DisposeCount == 0) + { + DisposeCount++; + } + } + } + + private sealed class TestClient : ICheatEngineClient + { + public long Epoch => 1; + + public CancellationToken Stopping => CancellationToken.None; + + public ICheatEngineRuntime Runtime => null!; + + public ICheatEngineDispatcher Dispatcher => null!; + + public IProcessClient Processes => null!; + + public IMemoryClient Memory => null!; + + public IPatternScanner Patterns => null!; + + public IValueScanner Scans => null!; + + public IInspectionClient Inspection => null!; + + public ITableClient Tables => null!; + + public ILuaClient Lua => null!; + + public IAllocationClient Allocations => null!; + + public IAssemblyClient Assembly => null!; + + public IRemoteExecutionClient RemoteExecution => null!; + + public IDebuggerClient Debugger => null!; + + public IHotkeyClient Hotkeys => null!; + + public ITimerClient Timers => null!; + + public ISpeedClient Speed => null!; + + public IHashingClient Hashing => null!; + + public IDbvmClient Dbvm => null!; + } } diff --git a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json index 9f2e7b0..0f9f765 100644 --- a/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Extensions.DependencyInjection.Tests/packages.lock.json @@ -13,17 +13,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.11.2, )", - "resolved": "18.11.2", - "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.10", - "Microsoft.Extensions.DependencyModel": "10.0.10", - "Microsoft.Testing.Platform": "2.4.0" - } - }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -63,11 +52,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.DiaSymReader": { - "type": "Transitive", - "resolved": "2.2.10", - "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" - }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "10.0.12", @@ -77,11 +61,6 @@ "Microsoft.Extensions.Primitives": "10.0.12" } }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" - }, "Microsoft.Extensions.Options": { "type": "Transitive", "resolved": "10.0.12", @@ -314,4 +293,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs index ce9ff0e..1421b59 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs +++ b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryAddressBuilderTests.cs @@ -208,6 +208,42 @@ public void WriteWithRejectsMissingBoundMemoryAndNullExplicitArguments() memory, 77, null!, TestContext.Current.CancellationToken)); } + [Fact] + public void BoundedBytesAndStringsProduceCopiedRequestsWithExplicitEncoding() + { + FakeMemoryClient memory = new(0); + MemoryAddressBuilder builder = memory.At(0x40D000); + + ImmutableArray bytes = builder.ReadBytes(2, TestContext.Current.CancellationToken); + builder.WriteUtf8("é", 2, TestContext.Current.CancellationToken); + string text = builder.ReadUtf16(32, TestContext.Current.CancellationToken); + + Assert.Equal([0x10, 0x20], bytes); + Assert.Equal(2, memory.LastBytesReadRequest.Length); + Assert.Equal("é", memory.LastStringWriteRequest.Value); + Assert.Equal(2, memory.LastStringWriteRequest.MaximumLength); + Assert.Equal(MemoryStringEncoding.Utf8, memory.LastStringWriteRequest.Encoding); + Assert.Equal(MemoryStringEncoding.Utf16, memory.LastStringReadRequest.Encoding); + Assert.Equal(string.Empty, text); + } + + [Fact] + public void PointerChainsAndPrimitiveBatchesRemainBoundedAndUseOneTerminalContract() + { + FakeMemoryClient memory = new(1337); + Address baseAddress = 0x40E000; + MemoryPointerChainBuilder chain = memory.At(baseAddress).Follow([0x10L, -0x20L]); + + Address resolved = chain.Resolve(TestContext.Current.CancellationToken); + ImmutableArray values = memory.Batch().Read([baseAddress, baseAddress + 4], + TestContext.Current.CancellationToken); + + Assert.Equal(baseAddress, resolved); + Assert.Equal([0x10L, -0x20L], chain.Request.Offsets); + Assert.Equal([1337, 1337], values); + Assert.Equal(2, memory.LastPrimitiveBatchReadCount); + } + private sealed class Int32Codec : IMemoryCodec { public bool TryRead(IMemoryReadContext context, Address address, out int value) @@ -224,6 +260,30 @@ public bool TryWrite(IMemoryWriteContext context, Address address, in int value) private sealed class FakeMemoryClient(int primitiveReadValue) : IMemoryClient { + internal MemoryBytesReadRequest LastBytesReadRequest + { + get; + private set; + } + + internal MemoryStringReadRequest LastStringReadRequest + { + get; + private set; + } + + internal MemoryStringWriteRequest LastStringWriteRequest + { + get; + private set; + } + + internal int LastPrimitiveBatchReadCount + { + get; + private set; + } + internal Address LastPrimitiveReadAddress { get; @@ -315,10 +375,62 @@ public void WritePrimitive(Address address, T value, CancellationToken cancel _ = TryWritePrimitive(address, value, out _, cancellationToken); } + public bool TryReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, + out ImmutableArray values, out CheatEngineFailure failure, + CancellationToken cancellationToken = default) + { + LastPrimitiveBatchReadCount = request.Addresses.Length; + T[] result = new T[request.Addresses.Length]; + for (int index = 0; index < result.Length; index++) + { + if (!TryReadPrimitive(request.Addresses[index], out T? value, out failure, cancellationToken)) + { + values = []; + return false; + } + + result[index] = value!; + } + + values = ImmutableArray.Create(result); + failure = default; + return true; + } + + public ImmutableArray ReadPrimitiveBatch(MemoryPrimitiveBatchReadRequest request, + CancellationToken cancellationToken = default) + { + _ = TryReadPrimitiveBatch(request, out ImmutableArray values, out _, cancellationToken); + return values; + } + + public bool TryWritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, + out CheatEngineFailure failure, CancellationToken cancellationToken = default) + { + for (int index = 0; index < request.Values.Length; index++) + { + MemoryAddressValue current = request.Values[index]; + if (!TryWritePrimitive(current.Address, current.Value, out failure, cancellationToken)) + { + return false; + } + } + + failure = default; + return true; + } + + public void WritePrimitiveBatch(MemoryPrimitiveBatchWriteRequest request, + CancellationToken cancellationToken = default) + { + _ = TryWritePrimitiveBatch(request, out _, cancellationToken); + } + public bool TryReadBytes(MemoryBytesReadRequest request, out ImmutableArray bytes, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - bytes = []; + LastBytesReadRequest = request; + bytes = [0x10, 0x20]; failure = default; return true; } @@ -345,6 +457,7 @@ public void WriteBytes(MemoryBytesWriteRequest request, CancellationToken cancel public bool TryReadString(MemoryStringReadRequest request, [NotNullWhen(true)] out string? value, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + LastStringReadRequest = request; value = string.Empty; failure = default; return true; @@ -359,6 +472,7 @@ public string ReadString(MemoryStringReadRequest request, CancellationToken canc public bool TryWriteString(MemoryStringWriteRequest request, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { + LastStringWriteRequest = request; failure = default; return true; } diff --git a/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs index d6a8ab3..d8200fc 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs +++ b/tests/CheatEngine.Client.Fluent.Tests/Scanning/AobFluentBuilderTests.cs @@ -1,14 +1,23 @@ using System.Collections.Immutable; +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Hashing; +using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Processes; +using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Results; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; +using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; +using CheatEngine.Client.Timers; using CheatEngine.SDK.Engine.Enums; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Values; @@ -422,6 +431,15 @@ private sealed class FakeCheatEngineClient(IPatternScanner patterns) : ICheatEng public IInspectionClient Inspection => NotUsed(); public ITableClient Tables => NotUsed(); public ILuaClient Lua => NotUsed(); + public IAllocationClient Allocations => NotUsed(); + public IAssemblyClient Assembly => NotUsed(); + public IRemoteExecutionClient RemoteExecution => NotUsed(); + public IDebuggerClient Debugger => NotUsed(); + public IHotkeyClient Hotkeys => NotUsed(); + public ITimerClient Timers => NotUsed(); + public ISpeedClient Speed => NotUsed(); + public IHashingClient Hashing => NotUsed(); + public IDbvmClient Dbvm => NotUsed(); private static T NotUsed() where T : class diff --git a/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json b/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json index be25ffb..e036eed 100644 --- a/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Fluent.Tests/packages.lock.json @@ -13,17 +13,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.11.2, )", - "resolved": "18.11.2", - "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.10", - "Microsoft.Extensions.DependencyModel": "10.0.10", - "Microsoft.Testing.Platform": "2.4.0" - } - }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -63,16 +52,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.DiaSymReader": { - "type": "Transitive", - "resolved": "2.2.10", - "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" - }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" - }, "Microsoft.SourceLink.Common": { "type": "Transitive", "resolved": "10.0.401", @@ -201,4 +180,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs index db80a57..3bab725 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs +++ b/tests/CheatEngine.Client.Hosting.Tests/CheatEngineClientPluginTests.cs @@ -1,16 +1,25 @@ using System.Reflection; +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Hashing; +using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Modules; using CheatEngine.Client.Processes; +using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Results; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; +using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; +using CheatEngine.Client.Timers; using Microsoft.Extensions.DependencyInjection; @@ -435,5 +444,14 @@ private sealed class FakeClient(long epoch) : ICheatEngineClient public IInspectionClient Inspection => null!; public ITableClient Tables => null!; public ILuaClient Lua => null!; + public IAllocationClient Allocations => null!; + public IAssemblyClient Assembly => null!; + public IRemoteExecutionClient RemoteExecution => null!; + public IDebuggerClient Debugger => null!; + public IHotkeyClient Hotkeys => null!; + public ITimerClient Timers => null!; + public ISpeedClient Speed => null!; + public IHashingClient Hashing => null!; + public IDbvmClient Dbvm => null!; } } diff --git a/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs b/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs index fdc7f77..dd3a419 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs +++ b/tests/CheatEngine.Client.Hosting.Tests/ClientActivationLifecycleTests.cs @@ -1,12 +1,21 @@ +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Assembly; +using CheatEngine.Client.Dbvm; +using CheatEngine.Client.Debugger; using CheatEngine.Client.Dispatching; +using CheatEngine.Client.Hashing; +using CheatEngine.Client.Hotkeys; using CheatEngine.Client.Inspection; using CheatEngine.Client.Lua; using CheatEngine.Client.Memory; using CheatEngine.Client.Modules; using CheatEngine.Client.Processes; +using CheatEngine.Client.RemoteExecution; using CheatEngine.Client.Runtime; using CheatEngine.Client.Scanning; +using CheatEngine.Client.Speed; using CheatEngine.Client.Tables; +using CheatEngine.Client.Timers; namespace CheatEngine.Client.Hosting.Tests; @@ -155,5 +164,14 @@ private sealed class FakeClient : ICheatEngineClient public IInspectionClient Inspection => null!; public ITableClient Tables => null!; public ILuaClient Lua => null!; + public IAllocationClient Allocations => null!; + public IAssemblyClient Assembly => null!; + public IRemoteExecutionClient RemoteExecution => null!; + public IDebuggerClient Debugger => null!; + public IHotkeyClient Hotkeys => null!; + public ITimerClient Timers => null!; + public ISpeedClient Speed => null!; + public IHashingClient Hashing => null!; + public IDbvmClient Dbvm => null!; } } diff --git a/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json b/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json index df232a5..9610c29 100644 --- a/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Hosting.Tests/packages.lock.json @@ -13,17 +13,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.11.2, )", - "resolved": "18.11.2", - "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.10", - "Microsoft.Extensions.DependencyModel": "10.0.10", - "Microsoft.Testing.Platform": "2.4.0" - } - }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -63,11 +52,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.DiaSymReader": { - "type": "Transitive", - "resolved": "2.2.10", - "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" - }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "10.0.12", @@ -77,11 +61,6 @@ "Microsoft.Extensions.Primitives": "10.0.12" } }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" - }, "Microsoft.Extensions.Options": { "type": "Transitive", "resolved": "10.0.12", @@ -323,4 +302,4 @@ } } } -} +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngine.Client.SourceGenerators.Lua.Tests.csproj b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngine.Client.SourceGenerators.Lua.Tests.csproj new file mode 100644 index 0000000..0dedb09 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngine.Client.SourceGenerators.Lua.Tests.csproj @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs new file mode 100644 index 0000000..4740a7d --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs @@ -0,0 +1,397 @@ +using System.Globalization; +using System.Reflection; + +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Microsoft.Extensions.DependencyInjection; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests; + +public sealed class CheatEngineLuaGeneratorTests +{ + private const string ModuleSource = ModulePrefix + + """ + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + internal sealed partial class PluginLuaModule : ILuaModule; + """; + + private const string ModulePrefix = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + internal static partial class PluginLuaBindings + { + [LuaFunction("status")] + public static string Status() => "ok"; + + [LuaFunction("ping")] + public static int Ping() => 1; + } + """; + + private const string ScalarOperationSource = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + internal static partial class Globals + { + [CheatEngineLuaOperation] + [LuaGlobal("getVersion")] + public static partial int ReadVersion(int address); + } + """; + + private const string OutResultOperationSource = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + internal static partial class Globals + { + [CheatEngineLuaOperation] + [LuaGlobal("getVersion")] + public static partial bool TryReadVersion(int address, out int version); + } + """; + + private const string ModuleCompilationSource = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.Calls; + using CheatEngine.SDK.Lua.State; + namespace TestPlugin; + internal static partial class PluginLuaBindings + { + [LuaFunction("status")] + public static string Status() => "ok"; + + public static LuaStatus RegisterLuaFunctions(LuaState state) => default; + public static LuaStatus UnregisterLuaFunctions(LuaState state) => default; + } + + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + internal sealed partial class PluginLuaModule : ILuaModule; + """; + + private const string MappedOperationSource = + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + internal sealed class SdkSnapshot + { + } + + internal readonly record struct Snapshot(int Value); + + internal readonly struct SnapshotMapper : ILuaResultMapper + { + public static Snapshot Map(SdkSnapshot source) => new(0); + } + + internal static partial class Globals + { + [CheatEngineLuaOperation(typeof(SnapshotMapper))] + [LuaGlobal("getSnapshot")] + public static partial SdkSnapshot ReadSnapshot(); + } + """; + + private static readonly string[] InvalidModuleDiagnosticIds = ["CECLUA1001", "CECLUA1005"]; + + [Fact] + public void ModuleAdapterSnapshotUsesOneAdmittedOperationAndPreflightsEveryExport() + { + GeneratorRun run = GeneratorRun.Execute(ModuleSource); + + Assert.Empty(run.Diagnostics); + string generated = run.GeneratedText("PluginLuaModule.CheatEngineLuaModule.g.cs"); + Assert.Equal( + """ + // + #nullable enable + + namespace TestPlugin; + + internal partial class PluginLuaModule : global::CheatEngine.Client.Lua.IDescribedLuaModule + { + /// Initializes a Lua module instance for activation-scoped dependency injection. + public PluginLuaModule() + { + } + + private static readonly global::CheatEngine.Client.Lua.LuaModuleDescriptor s_descriptor = + new global::CheatEngine.Client.Lua.LuaModuleDescriptor( + "plugin", + global::System.Collections.Immutable.ImmutableArray.Create(new global::CheatEngine.Client.Lua.LuaExportDescriptor("status"), new global::CheatEngine.Client.Lua.LuaExportDescriptor("ping"))); + + """ + "\n", + NormalizeLineEndings(generated[..generated.IndexOf("\t/// ", StringComparison.Ordinal)])); + Assert.Equal(2, Count(generated, "LuaRuntime.AcquireOperation()")); + Assert.Contains("EnsureExportsAreVacant(operation.State);", generated, StringComparison.Ordinal); + Assert.Contains("state.TryGetGlobal(\"status\"u8)", generated, StringComparison.Ordinal); + Assert.Contains("state.TryGetGlobal(\"ping\"u8)", generated, StringComparison.Ordinal); + Assert.Contains( + "finally\n\t\t{\n\t\t\t_ = global::TestPlugin.PluginLuaBindings.UnregisterLuaFunctions(operation.State);", + NormalizeLineEndings(generated), StringComparison.Ordinal); + } + + [Fact] + public void ModuleAdapterCompilesAgainstTheSdkRegistrationContract() + { + GeneratorRun run = GeneratorRun.Execute(ModuleCompilationSource); + + Diagnostic[] diagnostics = + [ + .. run.OutputCompilation.GetDiagnostics(TestContext.Current.CancellationToken) + .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning) + ]; + Assert.Empty(diagnostics); + } + + [Fact] + public void GeneratedInternalModuleHasAPublicConstructorAndResolvesViaAddLuaModule() + { + GeneratorRun run = GeneratorRun.Execute(ModuleCompilationSource); + Assert.Empty(run.Diagnostics); + Assert.Contains("public PluginLuaModule()", run.GeneratedText("PluginLuaModule.CheatEngineLuaModule.g.cs"), + StringComparison.Ordinal); + + using MemoryStream image = new(); + EmitResult emitResult = + run.OutputCompilation.Emit(image, cancellationToken: TestContext.Current.CancellationToken); + Assert.True(emitResult.Success, string.Join(Environment.NewLine, emitResult.Diagnostics)); + System.Reflection.Assembly generatedAssembly = System.Reflection.Assembly.Load(image.ToArray()); + Type? discoveredModuleType = generatedAssembly.GetType("TestPlugin.PluginLuaModule", false); + Assert.NotNull(discoveredModuleType); + Type moduleType = discoveredModuleType!; + Assert.False(moduleType.IsPublic); + Assert.NotNull(moduleType.GetConstructor(BindingFlags.Instance | BindingFlags.Public, null, + Type.EmptyTypes, null)); + + ServiceCollection services = new(); + CheatEngineClientBuilder builder = services.AddCheatEngineClient(); + MethodInfo addLuaModule = Assert.Single( + typeof(CheatEngineClientBuilder).GetMethods(BindingFlags.Instance | BindingFlags.Public), + static method => method.Name == nameof(CheatEngineClientBuilder.AddLuaModule) && + method.IsGenericMethodDefinition); + Assert.Same(builder, addLuaModule.MakeGenericMethod(moduleType).Invoke(builder, null)); + + using ServiceProvider provider = services.BuildServiceProvider(); + object module = provider.GetRequiredService(moduleType); + Assert.Equal(moduleType, module.GetType()); + } + + [Fact] + public void ExplicitPublicConstructorIsPreservedForDependencyInjection() + { + GeneratorRun run = GeneratorRun.Execute(ModulePrefix + + """ + public sealed class ModuleDependency; + + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + internal sealed partial class PluginLuaModule : ILuaModule + { + public PluginLuaModule(ModuleDependency dependency) + { + } + } + """); + + Assert.Empty(run.Diagnostics); + string generated = run.GeneratedText("PluginLuaModule.CheatEngineLuaModule.g.cs"); + Assert.DoesNotContain("public PluginLuaModule()", generated, StringComparison.Ordinal); + } + + [Fact] + public void OnlyNonPublicExplicitConstructorsProduceAnActionableDiagnostic() + { + GeneratorRun run = GeneratorRun.Execute(ModulePrefix + + """ + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + internal sealed partial class PluginLuaModule : ILuaModule + { + internal PluginLuaModule() + { + } + } + """); + + Assert.Contains(run.Diagnostics, static diagnostic => diagnostic.Id == "CECLUA1006"); + Assert.Empty(run.GeneratedSources); + } + + [Fact] + public void ScalarOperationAdapterEmitsAReadonlyValueAndTypedFactory() + { + GeneratorRun run = GeneratorRun.Execute(ScalarOperationSource); + + Assert.Empty(run.Diagnostics); + string generated = run.GeneratedText("ReadVersion.CheatEngineLuaOperation.g.cs"); + Assert.Contains( + "public static ReadVersionLuaOperation CreateReadVersionLuaOperation(global::System.Int32 address)", + generated, StringComparison.Ordinal); + Assert.Contains("internal readonly record struct ReadVersionLuaOperation(global::System.Int32 _address)", + generated, StringComparison.Ordinal); + Assert.Contains("global::TestPlugin.Globals.ReadVersion(_address)", generated, StringComparison.Ordinal); + Assert.Contains("CheatEngineFailureKind.LuaError", generated, StringComparison.Ordinal); + Assert.DoesNotContain("LuaState", generated, StringComparison.Ordinal); + Assert.DoesNotContain("LuaRef", generated, StringComparison.Ordinal); + } + + [Fact] + public void OutResultOperationProjectsTheSingleSdkOutValueAndMapsFalseToAFailure() + { + GeneratorRun run = GeneratorRun.Execute(OutResultOperationSource); + + Assert.Empty(run.Diagnostics); + string generated = run.GeneratedText("TryReadVersion.CheatEngineLuaOperation.g.cs"); + Assert.Contains("global::System.Int32 source;", generated, StringComparison.Ordinal); + Assert.Contains("if (!global::TestPlugin.Globals.TryReadVersion(_address, out source))", generated, + StringComparison.Ordinal); + Assert.Contains("CheatEngineFailureKind.LuaError", generated, StringComparison.Ordinal); + Assert.Contains("result = source;", generated, StringComparison.Ordinal); + } + + [Fact] + public void MappedOperationUsesTheDeclaredStaticMapperAndProjectsTheResultType() + { + GeneratorRun run = GeneratorRun.Execute(MappedOperationSource); + + Assert.Empty(run.Diagnostics); + string generated = run.GeneratedText("ReadSnapshot.CheatEngineLuaOperation.g.cs"); + Assert.Contains("ILuaOperation", generated, StringComparison.Ordinal); + Assert.Contains("global::TestPlugin.SnapshotMapper.Map(source)", generated, StringComparison.Ordinal); + } + + [Theory] + [InlineData( + "[CheatEngineLuaModule(typeof(PluginLuaBindings), \" \")] internal sealed partial class PluginLuaModule { }")] + [InlineData( + "[CheatEngineLuaModule(typeof(PluginLuaBindings), \"plugin\")] internal sealed class PluginLuaModule { }")] + public void InvalidModuleFormsProduceActionableDiagnostics(string moduleDeclaration) + { + GeneratorRun run = GeneratorRun.Execute(ModulePrefix + moduleDeclaration); + + Diagnostic diagnostic = + Assert.Single(run.Diagnostics.Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)); + Assert.Contains(diagnostic.Id, InvalidModuleDiagnosticIds); + Assert.Empty(run.GeneratedSources); + } + + [Fact] + public void DuplicateModuleExportsProduceAnActionableDiagnostic() + { + GeneratorRun run = GeneratorRun.Execute( + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + namespace TestPlugin; + internal static partial class PluginLuaBindings + { + [LuaFunction("status")] + public static string First() => "first"; + + [LuaFunction("status")] + public static string Second() => "second"; + } + + [CheatEngineLuaModule(typeof(PluginLuaBindings), "plugin")] + internal sealed partial class PluginLuaModule + { + } + """); + + Diagnostic diagnostic = + Assert.Single(run.Diagnostics.Where(static diagnostic => diagnostic.Id == "CECLUA1004")); + Assert.Contains("duplicate", diagnostic.GetMessage(CultureInfo.InvariantCulture), + StringComparison.OrdinalIgnoreCase); + Assert.Empty(run.GeneratedSources); + } + + [Fact] + public void OperationWithoutLuaGlobalProducesAnActionableDiagnostic() + { + GeneratorRun run = GeneratorRun.Execute( + """ + using CheatEngine.Client.Lua; + namespace TestPlugin; + internal static partial class Globals + { + [CheatEngineLuaOperation] + public static partial int ReadVersion(); + } + """); + + Diagnostic diagnostic = + Assert.Single(run.Diagnostics.Where(static diagnostic => diagnostic.Id == "CECLUA1101")); + Assert.Contains("[LuaGlobal]", diagnostic.GetMessage(CultureInfo.InvariantCulture), StringComparison.Ordinal); + } + + [Fact] + public void MapperCannotReturnRawLuaStateAcrossTheClientBoundary() + { + GeneratorRun run = GeneratorRun.Execute( + """ + using CheatEngine.Client.Lua; + using CheatEngine.SDK.Annotations.Lua; + using CheatEngine.SDK.Lua.State; + namespace TestPlugin; + internal readonly struct UnsafeMapper : ILuaResultMapper + { + public static LuaState Map(LuaState source) => source; + } + + internal static partial class Globals + { + [CheatEngineLuaOperation(typeof(UnsafeMapper))] + [LuaGlobal("unsafe")] + public static partial LuaState GetUnsafe(); + } + """); + + Assert.Contains(run.Diagnostics, static diagnostic => diagnostic.Id == "CECLUA1106"); + Assert.Empty(run.GeneratedSources); + } + + [Fact] + public void UnchangedInputReusesTheIncrementalOutput() + { + GeneratorRun first = GeneratorRun.Execute(ModuleSource); + Compilation unchangedCompilation = first.OutputCompilation.RemoveAllSyntaxTrees() + .AddSyntaxTrees(CSharpSyntaxTree.ParseText(ModuleSource, + cancellationToken: TestContext.Current.CancellationToken)); + GeneratorRun second = GeneratorRun.Execute(first.Driver, unchangedCompilation); + + Assert.Empty(second.Diagnostics); + Assert.Equal(first.GeneratedSources.Select(static source => source.SourceText.ToString()), + second.GeneratedSources.Select(static source => source.SourceText.ToString())); + GeneratorRunResult result = Assert.Single(second.Result.Results); + Assert.NotEmpty(result.TrackedSteps); + } + + private static int Count(string value, string fragment) + { + int count = 0; + int start = 0; + while ((start = value.IndexOf(fragment, start, StringComparison.Ordinal)) >= 0) + { + count++; + start += fragment.Length; + } + + return count; + } + + private static string NormalizeLineEndings(string value) + { + return value.Replace("\r\n", "\n", StringComparison.Ordinal); + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Infrastructure/GeneratorRun.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Infrastructure/GeneratorRun.cs new file mode 100644 index 0000000..10bf1d8 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/Infrastructure/GeneratorRun.cs @@ -0,0 +1,109 @@ +using System.Collections.Immutable; + +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Lua; +using CheatEngine.SDK.Annotations.Lua; +using CheatEngine.SDK.Lua.Runtime; + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; +using Microsoft.Extensions.DependencyInjection; + +namespace CheatEngine.Client.SourceGenerators.Lua.Tests.Infrastructure; + +internal sealed class GeneratorRun +{ + private GeneratorRun(GeneratorDriver driver, GeneratorDriverRunResult result, Compilation outputCompilation, + ImmutableArray diagnostics) + { + Driver = driver; + Result = result; + OutputCompilation = outputCompilation; + Diagnostics = diagnostics; + } + + public GeneratorDriver Driver + { + get; + } + + public GeneratorDriverRunResult Result + { + get; + } + + public Compilation OutputCompilation + { + get; + } + + public ImmutableArray Diagnostics + { + get; + } + + public ImmutableArray GeneratedSources => Assert.Single(Result.Results).GeneratedSources; + + public static GeneratorRun Execute(string source) + { + CSharpParseOptions parseOptions = new(LanguageVersion.CSharp14); + SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(SourceText.From(source), parseOptions); + CSharpCompilation compilation = CSharpCompilation.Create( + "CheatEngineClientLuaGeneratorTests", + [syntaxTree], + GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + GeneratorDriver driver = CSharpGeneratorDriver.Create( + [new CheatEngineLuaGenerator().AsSourceGenerator()], + parseOptions: parseOptions, + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, + true)); + return Execute(driver, compilation); + } + + public static GeneratorRun Execute(GeneratorDriver driver, Compilation compilation) + { + GeneratorDriver updated = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation outputCompilation, + out ImmutableArray diagnostics, + TestContext.Current.CancellationToken); + return new GeneratorRun(updated, updated.GetRunResult(), outputCompilation, diagnostics); + } + + public string GeneratedText(string suffix) + { + GeneratedSourceResult source = Assert.Single(GeneratedSources.Where(source => + source.HintName.EndsWith(suffix, StringComparison.Ordinal))); + return source.SourceText.ToString(); + } + + private static ImmutableArray GetMetadataReferences() + { + HashSet paths = new(StringComparer.OrdinalIgnoreCase); + string? trustedPlatformAssemblies = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") as string; + if (!string.IsNullOrWhiteSpace(trustedPlatformAssemblies)) + { + foreach (string path in trustedPlatformAssemblies.Split(Path.PathSeparator)) + { + paths.Add(path); + } + } + + AddAssembly(paths, typeof(CheatEngineLuaModuleAttribute).Assembly); + AddAssembly(paths, typeof(CheatEngineClientBuilder).Assembly); + AddAssembly(paths, typeof(LuaFunctionAttribute).Assembly); + AddAssembly(paths, typeof(LuaRuntime).Assembly); + AddAssembly(paths, typeof(ServiceCollection).Assembly); + return [.. paths.Select(static path => MetadataReference.CreateFromFile(path))]; + } + + private static void AddAssembly(HashSet paths, System.Reflection.Assembly assembly) + { + if (!string.IsNullOrWhiteSpace(assembly.Location)) + { + paths.Add(assembly.Location); + } + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/README.md b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/README.md new file mode 100644 index 0000000..8a78759 --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/README.md @@ -0,0 +1,9 @@ +# CheatEngine.Client.SourceGenerators.Lua.Tests + +This project runs `CheatEngineLuaGenerator` directly through `CSharpGeneratorDriver`. It locks down the +generated module descriptor and registration adapter, typed operation factory, invalid declaration diagnostics, +and incremental re-run behavior without requiring an attached Cheat Engine process. + +The SDK itself owns live Lua 5.3 lifecycle and closure tests. These tests validate only the Client-owned +source boundary: generated code must keep raw Lua values inside its implementation and emit the contracts +that Core can reserve before mutation. diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/packages.lock.json b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/packages.lock.json new file mode 100644 index 0000000..fa761cf --- /dev/null +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/packages.lock.json @@ -0,0 +1,323 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.CodeAnalysis.CSharp": { + "type": "Direct", + "requested": "[5.9.0, )", + "resolved": "5.9.0", + "contentHash": "7JGDA0UT1+h7k9ZcA3rF4eFC8+QPq1xyYaXxag4p8r/zzPurEJxvdi7aM+MRL/SfP7XADXpWF/pl/eUYXOq/ww==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17", + "Microsoft.CodeAnalysis.Common": "[5.9.0]" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.401, )", + "resolved": "10.0.401", + "contentHash": "LGmlwgP1Cx37JEWzyjS0o1/+xs/s/e3E2TBSuogI5ePA/9L0pfIfeYX0k5in7Bfcw8Nn2y2sG9jXxydTjiR2Fg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.401", + "Microsoft.SourceLink.Common": "10.0.401", + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.Testing.Extensions.TrxReport": { + "type": "Direct", + "requested": "[2.4.1, )", + "resolved": "2.4.1", + "contentHash": "KGAvJKRqhod45ecH4L1cCKIjGrzziUcLo3L4hRlfOg/Ww2Q7MnA30qHY2rnKzGyLVWf91tc2VleGe8c33FmPaQ==", + "dependencies": { + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.1", + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "xunit.v3.mtp-v2": { + "type": "Direct", + "requested": "[4.0.1, )", + "resolved": "4.0.1", + "contentHash": "s88KiWwDDYgOWV3A+ViJiqCe9cLU/Rt6Gb5TfOC7Abv/HKSoj4IHVPcOQk7jPt7HhZHHEts5IuaVpB30eO5B1w==", + "dependencies": { + "xunit.analyzers": "2.1.0", + "xunit.v3.assert": "[4.0.1]", + "xunit.v3.core.mtp-v2": "[4.0.1]" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "ZYctNuT10V9IYyCFydy63DXx0ggZQuynuzQOdLvW62dPgzjIz7f0ISEP75RGiq1jFQh8p6TmGSqxeQZQ87LCig==", + "dependencies": { + "System.IO.Hashing": "10.0.12" + } + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "5.9.0", + "contentHash": "IYaIaUWdIx539AReKZOBEqTskFusZfCh/wFSPilDvCn5Say8MegLw2LONcSIcVy+v3Gzv53qYBspgvBGSErfbQ==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "5.9.0-1.26328.17" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "e3IPP32CRNL031VZJAUTlCTG0YN7WFh4mN3fsSHTDQCJB+3+f0jGycv4fXk3rrftaY3B85XrQaj7sRthrOsavg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.401", + "contentHash": "u3rLxIwi/9MqDFaWGE/QQgLR1NBEzLOW2lv5+9OrZPDBYIAmFdYSWCWrR1ufpXWOqFn+x02TgKropl/oDuHmgA==" + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "JeP1RFqBa11fWmBk8xEfZcMKr4rxWSyI6OZ+659V069CaMkTEOQBW2UdSSeNz3absOsygcn7JJkzerC4LGnZ9w==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "tDxLLic2IfeChbyo8oeGZj/eBdFpPe3Dp80hUoIOefSCqqMHbsob+lX1TfFWV75PJCPouxj2zz0zzPHZxiM4nQ==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.1, 3.0.0)" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "2.4.1", + "contentHash": "3nW9NyhN1BnHRF994DCOOUQCqVA9pDT+gPpbuhI/xUntZnGnXO4ySovKxFlFuoT/48pnI74LK/DeKVlve1L3VQ==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "2.4.0", + "contentHash": "qr5M6h16YHMJLFDcWELFVMMpGte2BUmveBZKT5YoBV+bmuJRPu9bv/Zqke4yQuOEKRNxoAETrG5jr+/6Rnr3Hg==", + "dependencies": { + "Microsoft.Testing.Platform": "[2.4.0, 3.0.0)" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "jDix4bBMYnpZdSPcnY+KDV6ik3SRMzpMKby/bZl/XUwIiflwRNAFZ0oOl61R/pSaveIJ8t1gs2BUlrGsPs/bcg==" + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "IQ4NXP/B3Ayzvw0rDQzVTYsCKyy0Jp9KI6aYcK7UnGVlR9+Awz++TIPCQtPYfLJfOpm8ajowMR09V7quD3sEHw==" + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "2.1.0", + "contentHash": "X7QXEcZQGz0G/HL4HUyK+aAvNa/IMGbOCnFIq4jD/Evktq12xANKwzOUr7b08vCmC1LXu/47qHWOdjm3KfaJ0A==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "nC7d3cY06Oo7hkdwWPZkBR0Ud75xNhgx0P7i0GmMMZC3puCGlc4szFiT30biH03IN/eDnr8h+nv3A1UD17bk/A==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Qf25TVdadDQYf9zVxSd7L9RNQhuP0jCUHmo96XTmIKLxGefylIEV63jRbXBPFzdXmV09TCdcurk+AQ9LiWS2Hg==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v2": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "7qfTfrIfS2wybpSVyRLmhXHbSudD2eNw66LfukixmKsRCTcfvOrLsx0qGL/lObcEh3Z2F4SIXgoFnLIiB8yfjQ==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "2.4.0", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.4.0", + "Microsoft.Testing.Platform": "2.4.0", + "Microsoft.Testing.Platform.MSBuild": "2.4.0", + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.inproc.console": "[4.0.1]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "J0d5OfFcp920nZxCRIvU14+TZhiSPQR0wvqEMZ3MRiJLPu1VmYlcRNkmcSW0i1DwkB81gAHc+fHUQf/cU64MYg==", + "dependencies": { + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "p9AyfBpj2e5Iws8B57SbLiSngg7mEdvegHInKR4T2cjt2mis9h8htVgCnmu//agZO4zri6p6VQOj5EkHHTy3gA==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "System.Security.AccessControl": "[6.0.1]", + "xunit.v3.common": "[4.0.1]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "4.0.1", + "contentHash": "Hqwfd6ehMIhPmVWUQ2dgmknzuLFTWeyp8ES1q3D4YR5bQVyiXDcaIoaFwqAz2zgLr49WaW4Mz7VVncP7u/Q8/Q==", + "dependencies": { + "xunit.v3.extensibility.core": "[4.0.1]", + "xunit.v3.runner.common": "[4.0.1]" + } + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.core": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )", + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.extensions.dependencyinjection": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )", + "CheatEngine.Client.Core": "[0.1.0, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", + "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" + } + }, + "cheatengine.client.sourcegenerators.lua": { + "type": "Project" + }, + "CheatEngine.SDK": { + "type": "CentralTransitive", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "CentralTransitive", + "requested": "[5.9.0, )", + "resolved": "5.9.0-1.26328.17", + "contentHash": "HP9NNk8ZjOSI2hgOyXnQg+kv7/X837Vr2nAlXiGAtqtYnYKjRRa1UmQFr8KFs5ynGYKqfbb8zB9APoWjiAGdMg==" + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "dAgIf1TOr8KLs+aBRIbXUZBjHoSH4rDG8+XkX/Q6AZwkQdMA0+yPDKTHsieeXdZDfpOpZVHzOnuAb5Z2nX3KsA==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.12", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "6I46fTPfgYkrjRYfRXbho9WOvOelTnNjWuZws/hzGHDASH1LEJeA4VKK9k3wJvido8o7jJSB5WkMTonX7HM1bA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.12", + "Microsoft.Extensions.Logging.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rqpu4qj5WE9x1IHGXSIgHBKi7IUlQaHyp4aXCYIanG2OghlUMFZpZTgExaXwcvmLAJHsxKQWMPpc7D2WIbCVtA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.12", + "Microsoft.Extensions.Configuration.Binder": "10.0.12", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Options.DataAnnotations": { + "type": "CentralTransitive", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.Tests/AllocationGateTests.cs b/tests/CheatEngine.Client.Tests/AllocationGateTests.cs new file mode 100644 index 0000000..78a57a0 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/AllocationGateTests.cs @@ -0,0 +1,71 @@ +using System.Runtime.CompilerServices; + +using CheatEngine.Client.Lua; +using CheatEngine.Client.Memory; +using CheatEngine.SDK.Engine.Values; + +using MemoryFluent = CheatEngine.Client.Memory.Memory; + +namespace CheatEngine.Client.Tests; + +/// Protects measured, pure fluent paths from accidental managed allocations. +public sealed class AllocationGateTests +{ + [Fact] + public void PureMemoryBuilderConstructionDoesNotAllocateAfterWarmup() + { + Address address = 0x401000UL; + _ = CreateBuilder(address); + + long before = GC.GetAllocatedBytesForCurrentThread(); + MemoryAddressBuilder builder = default; + for (int index = 0; index < 1_024; index++) + { + builder = CreateBuilder(address); + } + + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(address, builder.Address); + Assert.Equal(0, allocated); + } + + [Fact] + public void ScalarLuaResultMapperDoesNotAllocateAfterWarmup() + { + _ = MapScalar(42); + + long before = GC.GetAllocatedBytesForCurrentThread(); + int result = 0; + for (int index = 0; index < 1_024; index++) + { + result = MapScalar(index); + } + + long allocated = GC.GetAllocatedBytesForCurrentThread() - before; + + Assert.Equal(1_023, result); + Assert.Equal(0, allocated); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static MemoryAddressBuilder CreateBuilder(Address address) + { + return MemoryFluent.At(address); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int MapScalar(int source) + where TMapper : ILuaResultMapper + { + return TMapper.Map(source); + } + + private readonly struct IdentityInt32Mapper : ILuaResultMapper + { + public static int Map(int source) + { + return source; + } + } +} diff --git a/tests/CheatEngine.Client.Tests/PublicClientSignatureBoundaryTests.cs b/tests/CheatEngine.Client.Tests/PublicClientSignatureBoundaryTests.cs new file mode 100644 index 0000000..1bb78b5 --- /dev/null +++ b/tests/CheatEngine.Client.Tests/PublicClientSignatureBoundaryTests.cs @@ -0,0 +1,224 @@ +using System.Reflection; + +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Memory; + +using ReflectionAssembly = System.Reflection.Assembly; + +namespace CheatEngine.Client.Tests; + +/// Protects the aggregate package boundary from raw SDK lifetime and Lua implementation types. +public sealed class PublicClientSignatureBoundaryTests +{ + private static readonly HashSet ApprovedSdkValueTypes = new(StringComparer.Ordinal) + { + "CheatEngine.SDK.Engine.AddressList.MemoryRecordId", + "CheatEngine.SDK.Engine.Enums.FastScanMethod", + "CheatEngine.SDK.Engine.Enums.VariableType", + "CheatEngine.SDK.Engine.Inspection.AddressResolutionOptions", + "CheatEngine.SDK.Engine.Inspection.MemoryRegionInfo", + "CheatEngine.SDK.Engine.Inspection.ModuleInfo", + "CheatEngine.SDK.Engine.Inspection.ModuleName", + "CheatEngine.SDK.Engine.Inspection.ModuleSectionInfo", + "CheatEngine.SDK.Engine.Inspection.SymbolInfo", + "CheatEngine.SDK.Engine.Inspection.SymbolExpression", + "CheatEngine.SDK.Engine.Inspection.TargetProcessId", + "CheatEngine.SDK.Engine.Runtime.CheatEngineArchitecture", + "CheatEngine.SDK.Engine.Runtime.CheatEngineVersion", + "CheatEngine.SDK.Engine.Runtime.PointerSize", + "CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityAvailability", + "CheatEngine.SDK.Engine.Runtime.RuntimeCapabilityId", + "CheatEngine.SDK.Engine.Runtime.TargetAbi", + "CheatEngine.SDK.Engine.Scanning.Aob.AobScanOptions", + "CheatEngine.SDK.Engine.Scanning.Aob.AobPattern", + "CheatEngine.SDK.Engine.Scanning.Values.FirstScanRequest", + "CheatEngine.SDK.Engine.Scanning.Values.NextScanRequest", + "CheatEngine.SDK.Engine.Values.Address" + }; + + [Fact] + public void AllPublicClientSignaturesAreHandleFreeAndUseOnlyApprovedSdkValueTypes() + { + List violations = []; + + foreach (ReflectionAssembly assembly in GetAggregateClientAssemblies()) + { + foreach (Type publicType in assembly.GetExportedTypes()) + { + VerifyType(publicType, $"{assembly.GetName().Name}:{publicType.FullName}", violations); + VerifyDeclaredMembers(publicType, violations); + } + } + + Assert.True(violations.Count == 0, string.Join(Environment.NewLine, violations)); + } + + private static IEnumerable GetAggregateClientAssemblies() + { + Queue pending = new( + [ + typeof(ICheatEngineClient).Assembly, + typeof(MemoryAddressBuilder).Assembly, + typeof(CheatEngineClientBuilder).Assembly, + typeof(CheatEngineClientPlugin).Assembly + ]); + HashSet visited = new(StringComparer.Ordinal); + + while (pending.TryDequeue(out ReflectionAssembly? candidate)) + { + if (candidate is null) + { + continue; + } + + ReflectionAssembly assembly = candidate; + string? assemblyName = assembly.GetName().Name; + if (assemblyName is null || !assemblyName.StartsWith("CheatEngine.Client", StringComparison.Ordinal) || + !visited.Add(assemblyName)) + { + continue; + } + + yield return assembly; + + foreach (AssemblyName reference in assembly.GetReferencedAssemblies()) + { + if (reference.Name?.StartsWith("CheatEngine.Client", StringComparison.Ordinal) == true) + { + pending.Enqueue(ReflectionAssembly.Load(reference)); + } + } + } + } + + private static void VerifyDeclaredMembers(Type publicType, List violations) + { + const BindingFlags PublicDeclared = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | + BindingFlags.DeclaredOnly; + + foreach (ConstructorInfo constructor in publicType.GetConstructors(PublicDeclared)) + { + VerifyParameters(constructor.GetParameters(), constructor, violations); + } + + foreach (MethodInfo method in publicType.GetMethods(PublicDeclared)) + { + VerifyType(method.ReturnType, method.ToString(), violations, method); + VerifyParameters(method.GetParameters(), method, violations); + VerifyGenericParameterConstraints(method.GetGenericArguments(), method.ToString(), violations); + } + + foreach (PropertyInfo property in publicType.GetProperties(PublicDeclared)) + { + VerifyType(property.PropertyType, property.ToString(), violations, property); + VerifyParameters(property.GetIndexParameters(), property, violations); + } + + foreach (FieldInfo field in publicType.GetFields(PublicDeclared)) + { + VerifyType(field.FieldType, field.ToString(), violations, field); + } + + foreach (EventInfo @event in publicType.GetEvents(PublicDeclared)) + { + VerifyType(@event.EventHandlerType, @event.ToString(), violations, @event); + } + + VerifyGenericParameterConstraints(publicType.GetGenericArguments(), publicType.FullName, violations); + } + + private static void VerifyParameters(IEnumerable parameters, MemberInfo member, + List violations) + { + foreach (ParameterInfo parameter in parameters) + { + VerifyType(parameter.ParameterType, $"{member} parameter '{parameter.Name}'", violations, member); + } + } + + private static void VerifyGenericParameterConstraints(IEnumerable genericParameters, string? member, + List violations) + { + foreach (Type genericParameter in genericParameters.Where(static parameter => parameter.IsGenericParameter)) + { + foreach (Type constraint in genericParameter.GetGenericParameterConstraints()) + { + VerifyType(constraint, $"{member} generic parameter '{genericParameter.Name}'", violations); + } + } + } + + private static void VerifyType(Type? type, string? source, List violations, + MemberInfo? declaringMember = null) + { + if (type is null || type.IsGenericParameter) + { + return; + } + + if (type.IsPointer) + { + violations.Add($"{source} exposes a pointer type '{type}'."); + return; + } + + if (type.HasElementType) + { + VerifyType(type.GetElementType(), source, violations, declaringMember); + return; + } + + if (type.IsGenericType) + { + Type genericDefinition = type.GetGenericTypeDefinition(); + if (genericDefinition != type) + { + VerifyType(genericDefinition, source, violations, declaringMember); + } + + foreach (Type argument in type.GetGenericArguments()) + { + VerifyType(argument, source, violations, declaringMember); + } + + return; + } + + string typeName = type.FullName ?? type.Name; + if (type.Name is "LuaState" or "LuaRef" or "CEObject" || + type.Name.StartsWith("Owned`", StringComparison.Ordinal)) + { + violations.Add($"{source} exposes forbidden SDK handle '{typeName}'."); + return; + } + + if (type.Namespace?.Contains(".Interop", StringComparison.Ordinal) == true) + { + violations.Add($"{source} exposes interop namespace type '{typeName}'."); + return; + } + + if (type.Assembly.GetName().Name?.StartsWith("CheatEngine.SDK", StringComparison.Ordinal) == true && + (!type.IsValueType || !ApprovedSdkValueTypes.Contains(typeName)) && + !IsShippedRuntimeCapabilitiesDebt(type, declaringMember)) + { + violations.Add($"{source} exposes non-approved SDK type '{typeName}'."); + } + } + + private static bool IsShippedRuntimeCapabilitiesDebt(Type type, MemberInfo? declaringMember) + { + // PublicAPI.Shipped preserves this one legacy class reference. It is deliberately a member-level exception: + // every other SDK reference type remains prohibited by this recursive boundary test. + return type.FullName == "CheatEngine.SDK.Engine.Runtime.RuntimeCapabilities" && + declaringMember?.DeclaringType?.FullName == "CheatEngine.Client.Runtime.CheatEngineRuntimeSnapshot" && + declaringMember switch + { + ConstructorInfo => true, + MethodInfo { Name: "get_SdkCapabilities" } => true, + PropertyInfo { Name: "SdkCapabilities" } => true, + _ => false + }; + } +} diff --git a/tests/CheatEngine.Client.Tests/packages.lock.json b/tests/CheatEngine.Client.Tests/packages.lock.json index ae8ee85..e1916b1 100644 --- a/tests/CheatEngine.Client.Tests/packages.lock.json +++ b/tests/CheatEngine.Client.Tests/packages.lock.json @@ -13,17 +13,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.Testing.Extensions.CodeCoverage": { - "type": "Direct", - "requested": "[18.11.2, )", - "resolved": "18.11.2", - "contentHash": "bT6awBEUR+fjPpeLAN++4qx5q0sgA9SeJt6QfijnFFPxNSr68BYsYFMQH8L8kY6vMJd3fuvFAFBht4JtWQcWtQ==", - "dependencies": { - "Microsoft.DiaSymReader": "2.2.10", - "Microsoft.Extensions.DependencyModel": "10.0.10", - "Microsoft.Testing.Platform": "2.4.0" - } - }, "Microsoft.Testing.Extensions.TrxReport": { "type": "Direct", "requested": "[2.4.1, )", @@ -63,11 +52,6 @@ "System.IO.Hashing": "10.0.12" } }, - "Microsoft.DiaSymReader": { - "type": "Transitive", - "resolved": "2.2.10", - "contentHash": "zmGsm6b2y3STDa/Of7rdkkfTDV8VuGB8aCqIkLoJIQh5tL78K3zJ8OUyFKnfsaORXmGr9iOKwDkZfUjeXi+CwA==" - }, "Microsoft.Extensions.Configuration": { "type": "Transitive", "resolved": "10.0.12", @@ -77,11 +61,6 @@ "Microsoft.Extensions.Primitives": "10.0.12" } }, - "Microsoft.Extensions.DependencyModel": { - "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "rfZA1RjR021RPqSmIPovfz2aOd79TGqJ9BengbjnzIISOVwjLmuSDnhCMmiY/1c6iYvGolQ1iNGzkav0u11XEA==" - }, "Microsoft.Extensions.Options": { "type": "Transitive", "resolved": "10.0.12", @@ -336,4 +315,4 @@ } } } -} +} \ No newline at end of file From 919314cf43f46d50f34b570cde3a0c569aecffc3 Mon Sep 17 00:00:00 2001 From: AriusII Date: Mon, 21 Sep 2026 13:34:10 +0200 Subject: [PATCH 2/2] Address Client code review findings --- README.md | 9 +- .../Memory/MemoryPrimitiveBatchBuilder.cs | 21 ++- .../AnalyzerReleases.Unshipped.md | 2 +- .../CheatEngineLuaGenerator.cs | 2 + .../content/CheatEngine.Plugin/README.md | 12 +- .../CheatEngine.Client.Benchmarks/Program.cs | 124 +++++++++++++++++- .../MemoryPrimitiveBatchBuilderTests.cs | 37 ++++++ .../CheatEngineLuaGeneratorTests.cs | 5 +- 8 files changed, 191 insertions(+), 21 deletions(-) create mode 100644 tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryPrimitiveBatchBuilderTests.cs diff --git a/README.md b/README.md index 68c6cff..b338b1f 100644 --- a/README.md +++ b/README.md @@ -220,9 +220,12 @@ following table is a delivery statement, not a substitute for a live host check. | Value scanning | **Capability-gated** | The public state machine exists, but Client session creation stays unavailable until the internal `MemScan`/`FoundList` ownership path passes its Cheat Engine 7.7 x64 live gate | | Arbitrary Lua source | Policy-gated and off by default | Requires explicit unsafe opt-in; raw Lua state remains hidden | -IPC, remote clients, UI/forms, debugger and breakpoints, Auto Assembler, injection, remote allocations, structures, -hotkeys/timers, speedhack, DBVM, Mono/IL2CPP, and advanced ABI hooks are outside v0.1. They have no placeholder -public API. The full current-state rationale is in [ADR 0004](docs/adr/0004-capability-matrix.md). +Target allocations; assembly and Auto Assembler; remote execution and DLL injection; debugger and breakpoints; +hotkeys and timers; speed; hashing; and DBVM now have public Client API contracts. Each remains +`Unknown` or `Unavailable` until its primitive, ownership, and lifecycle behavior passes the corresponding Cheat +Engine 7.7 x64 live gate. IPC, remote clients, UI/forms, structures, Mono/IL2CPP, and advanced ABI hooks remain +outside v0.1 and have no placeholder public API. The full current-state rationale is in +[ADR 0004](docs/adr/0004-capability-matrix.md). ## AOT, trimming, and deployment diff --git a/libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs b/libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs index ed6e7ef..8c9330f 100644 --- a/libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs +++ b/libs/CheatEngine.Client.Fluent/Memory/MemoryPrimitiveBatchBuilder.cs @@ -9,7 +9,7 @@ namespace CheatEngine.Client.Memory; /// The built-in scalar or target-aware pointer type. public readonly struct MemoryPrimitiveBatchBuilder { - private readonly IMemoryClient _memory; + private readonly IMemoryClient? _memory; internal MemoryPrimitiveBatchBuilder(IMemoryClient memory) { @@ -20,9 +20,10 @@ internal MemoryPrimitiveBatchBuilder(IMemoryClient memory) /// The non-empty target addresses to read in result order. /// Cancels before the batch reaches Cheat Engine. /// The immutable scalar snapshot in the same order as . + /// No memory service has been bound to this builder. public ImmutableArray Read(ReadOnlySpan
addresses, CancellationToken cancellationToken = default) { - return _memory.ReadPrimitiveBatch(new MemoryPrimitiveBatchReadRequest(addresses), cancellationToken); + return RequireMemory().ReadPrimitiveBatch(new MemoryPrimitiveBatchReadRequest(addresses), cancellationToken); } /// Tries to copy one homogeneous scalar from every supplied target address in one dispatch admission. @@ -31,19 +32,21 @@ public ImmutableArray Read(ReadOnlySpan
addresses, CancellationToken /// The classified operation failure when the method returns . /// Cancels before the batch reaches Cheat Engine. /// when all scalar reads succeeded. + /// No memory service has been bound to this builder. public bool TryRead(ReadOnlySpan
addresses, out ImmutableArray values, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - return _memory.TryReadPrimitiveBatch(new MemoryPrimitiveBatchReadRequest(addresses), out values, + return RequireMemory().TryReadPrimitiveBatch(new MemoryPrimitiveBatchReadRequest(addresses), out values, out failure, cancellationToken); } /// Writes every copied homogeneous scalar in one dispatch admission. /// The non-empty address/value pairs to write in execution order. /// Cancels before the batch reaches Cheat Engine. + /// No memory service has been bound to this builder. public void Write(ReadOnlySpan> values, CancellationToken cancellationToken = default) { - _memory.WritePrimitiveBatch(new MemoryPrimitiveBatchWriteRequest(values), cancellationToken); + RequireMemory().WritePrimitiveBatch(new MemoryPrimitiveBatchWriteRequest(values), cancellationToken); } /// Tries to write every copied homogeneous scalar in one dispatch admission. @@ -51,10 +54,18 @@ public void Write(ReadOnlySpan> values, CancellationToken /// The classified operation failure when the method returns . /// Cancels before the batch reaches Cheat Engine. /// when all scalar writes succeeded. + /// No memory service has been bound to this builder. public bool TryWrite(ReadOnlySpan> values, out CheatEngineFailure failure, CancellationToken cancellationToken = default) { - return _memory.TryWritePrimitiveBatch(new MemoryPrimitiveBatchWriteRequest(values), out failure, + return RequireMemory().TryWritePrimitiveBatch(new MemoryPrimitiveBatchWriteRequest(values), out failure, cancellationToken); } + + private IMemoryClient RequireMemory() + { + return _memory ?? throw new InvalidOperationException( + "This primitive-batch builder has no bound target-memory service. Use Memory.Batch(memory) or " + + "memory.Batch() before a terminal operation."); + } } diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/AnalyzerReleases.Unshipped.md b/source-generators/CheatEngine.Client.SourceGenerators.Lua/AnalyzerReleases.Unshipped.md index 12d96d3..afc3277 100644 --- a/source-generators/CheatEngine.Client.SourceGenerators.Lua/AnalyzerReleases.Unshipped.md +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/AnalyzerReleases.Unshipped.md @@ -1,4 +1,4 @@ -## Release 0.1.0 +### New Rules Rule ID | Category | Severity | Notes ------------|------------------------|----------|--------------------------------------------------------------- diff --git a/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs b/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs index 4196866..3089817 100644 --- a/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs +++ b/source-generators/CheatEngine.Client.SourceGenerators.Lua/CheatEngineLuaGenerator.cs @@ -144,6 +144,7 @@ private static string EmitModule(ModuleCandidate candidate) source.OpenBlock(); foreach (string export in candidate.Exports) { + source.OpenBlock(); source.WriteLine("global::CheatEngine.SDK.Lua.Calls.LuaStatus status = state.TryGetGlobal(" + CSharpLiteral(export) + "u8);"); source.WriteLine("if (!status.IsOk)"); @@ -158,6 +159,7 @@ private static string EmitModule(ModuleCandidate candidate) candidate.ModuleName + "'.") + ");"); source.CloseBlock(); source.WriteLine("state.Pop(1);"); + source.CloseBlock(); } source.CloseBlock(); diff --git a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md index cb91161..018e502 100644 --- a/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md +++ b/templates/CheatEngine.Client.Templates/content/CheatEngine.Plugin/README.md @@ -15,9 +15,9 @@ The project provides a minimal but production-shaped plugin boundary: - `[CheatEnginePlugin]` is the SDK entry-point annotation recognized by the generated bootstrap. - `CheatEngineClientPlugin` creates a fresh DI container and Client activation for every enable cycle. - `Configure` explicitly loads the optional `appsettings.json` beside the plugin with `reloadOnChange: false` and - registers an application module. + registers the generated `PluginLuaModule` through `AddLuaModule()`, then the application module. - `PluginClientModule` demonstrates options, logging, a bounded AOB request, typed memory access, an Address List - snapshot, and an activation-scoped Lua module lease. + snapshot, and normal Client module lifecycle callbacks. The project references `CheatEngine.Client` **and** `CheatEngine.SDK` directly. The SDK reference must remain direct: its plugin generator and native Lua bridge assets are build inputs, not a transitive implementation detail. @@ -33,10 +33,10 @@ matters to consumers: package restore, SDK-generated bootstrap, copied bridge as and the DI-first lifecycle. Its normal host preconditions use `Try...` APIs, so an absent process or pattern does not turn the example into an artificial activation failure. -The Lua implementation deliberately keeps `LuaRuntime.AcquireState()` and generated SDK calls inside -`PluginLuaModule`; no raw Lua state or SDK ownership handle crosses the Client-facing module boundary. The project does -not demonstrate value scans because their complete Create/Scan/Destroy lifecycle is still capability-gated pending -the opt-in Cheat Engine 7.7 x64 live validation. +`PluginLuaModule` is an attribute-only declaration. The Client generator emits the activation-scoped implementation +that acquires Lua state and invokes the generated SDK registration calls; application code contains neither those calls +nor raw Lua state or SDK ownership handles. The project does not demonstrate value scans because their complete +Create/Scan/Destroy lifecycle is still capability-gated pending the opt-in Cheat Engine 7.7 x64 live validation. ## Build diff --git a/tests/CheatEngine.Client.Benchmarks/Program.cs b/tests/CheatEngine.Client.Benchmarks/Program.cs index f10fbb9..6aea1bd 100644 --- a/tests/CheatEngine.Client.Benchmarks/Program.cs +++ b/tests/CheatEngine.Client.Benchmarks/Program.cs @@ -1,12 +1,126 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Exporters.Json; +using BenchmarkDotNet.Reports; using BenchmarkDotNet.Running; using CheatEngine.Client.Benchmarks; -string artifactsDirectory = Path.Combine(Environment.CurrentDirectory, "BenchmarkDotNet.Artifacts"); -Directory.CreateDirectory(artifactsDirectory); -BenchmarkSuiteMetadata.WriteTo(artifactsDirectory); +return BenchmarkEntryPoint.Run(args); -IConfig config = DefaultConfig.Instance.AddExporter(JsonExporter.Full); -BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); +internal static class BenchmarkEntryPoint +{ + public static int Run(string[] args) + { + string artifactsDirectory = BenchmarkArtifactsDirectory.Resolve(args); + IConfig config = DefaultConfig.Instance + .WithArtifactsPath(artifactsDirectory) + .AddExporter(JsonExporter.Full); + + Summary[] summaries = BenchmarkSwitcher + .FromAssembly(typeof(Program).Assembly) + .Run(args, config) + .ToArray(); + + if (summaries.Length == 0) + { + return BenchmarkInvocation.IsInformational(args) ? 0 : 1; + } + + if (summaries.HasError()) + { + return 1; + } + + BenchmarkSuiteMetadata.WriteTo(artifactsDirectory); + return 0; + } +} + +internal static class BenchmarkArtifactsDirectory +{ + private const string DefaultDirectoryName = "BenchmarkDotNet.Artifacts"; + + public static string Resolve(IReadOnlyList args) + { + ArgumentNullException.ThrowIfNull(args); + + string artifactsPath = Path.Combine(Environment.CurrentDirectory, DefaultDirectoryName); + for (int index = 0; index < args.Count; index++) + { + if (TryReadArtifactsPath(args, ref index, out string? specifiedPath)) + { + artifactsPath = specifiedPath!; + } + } + + return new DirectoryInfo(artifactsPath).FullName; + } + + private static bool TryReadArtifactsPath( + IReadOnlyList args, + ref int index, + out string? artifactsPath) + { + string argument = args[index]!; + if (argument.Equals("-a", StringComparison.OrdinalIgnoreCase) || + argument.Equals("--artifacts", StringComparison.OrdinalIgnoreCase)) + { + artifactsPath = index + 1 < args.Count ? args[++index] : null; + return artifactsPath is not null; + } + + return TryReadAssignedArtifactsPath(argument, "-a=", out artifactsPath) || + TryReadAssignedArtifactsPath(argument, "--artifacts=", out artifactsPath); + } + + private static bool TryReadAssignedArtifactsPath( + string argument, + string prefix, + out string? artifactsPath) + { + if (argument.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + artifactsPath = argument[prefix.Length..]; + return true; + } + + artifactsPath = null; + return false; + } +} + +internal static class BenchmarkInvocation +{ + public static bool IsInformational(IReadOnlyList args) + { + ArgumentNullException.ThrowIfNull(args); + + return args.Any(static argument => + argument.Equals("--help", StringComparison.OrdinalIgnoreCase) || + argument.Equals("-?", StringComparison.Ordinal) || + argument.Equals("--version", StringComparison.OrdinalIgnoreCase) || + argument.Equals("--info", StringComparison.OrdinalIgnoreCase) || + argument.Equals("--list", StringComparison.OrdinalIgnoreCase) || + argument.StartsWith("--list=", StringComparison.OrdinalIgnoreCase)); + } +} + +internal static class BenchmarkSummaryExtensions +{ + /// + /// Determines whether BenchmarkDotNet 0.15.8 reported a critical validation failure or an unsuccessful benchmark. + /// + /// + /// Version 0.15.8 does not expose the HasError() helper suggested by the review. Its public result + /// surface exposes these two signals instead: and + /// . + /// + public static bool HasError(this IEnumerable summaries) + { + ArgumentNullException.ThrowIfNull(summaries); + + return summaries.Any(static summary => + summary.HasCriticalValidationErrors || + summary.Reports.Any(static report => !report.Success)); + } +} diff --git a/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryPrimitiveBatchBuilderTests.cs b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryPrimitiveBatchBuilderTests.cs new file mode 100644 index 0000000..3839531 --- /dev/null +++ b/tests/CheatEngine.Client.Fluent.Tests/Memory/MemoryPrimitiveBatchBuilderTests.cs @@ -0,0 +1,37 @@ +using CheatEngine.Client.Memory; +using CheatEngine.SDK.Engine.Values; + +namespace CheatEngine.Client.Fluent.Tests.Memory; + +public sealed class MemoryPrimitiveBatchBuilderTests +{ + [Fact] + public void DefaultBatchBuilderRejectsEveryTerminalBeforeBuildingOrDispatchingARequest() + { + MemoryPrimitiveBatchBuilder builder = default; + Address[] addresses = []; + MemoryAddressValue[] values = []; + + InvalidOperationException readException = Assert.Throws(() => + builder.Read(addresses, TestContext.Current.CancellationToken)); + InvalidOperationException tryReadException = Assert.Throws(() => + { + _ = builder.TryRead(addresses, out _, out _, TestContext.Current.CancellationToken); + }); + InvalidOperationException writeException = Assert.Throws(() => + builder.Write(values, TestContext.Current.CancellationToken)); + InvalidOperationException tryWriteException = Assert.Throws(() => + { + _ = builder.TryWrite(values, out _, TestContext.Current.CancellationToken); + }); + + foreach (InvalidOperationException exception in new[] + { + readException, tryReadException, writeException, tryWriteException, + }) + { + Assert.Contains("Memory.Batch(memory)", exception.Message); + Assert.Contains("memory.Batch()", exception.Message); + } + } +} diff --git a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs index 4740a7d..3455677 100644 --- a/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs +++ b/tests/CheatEngine.Client.SourceGenerators.Lua.Tests/CheatEngineLuaGeneratorTests.cs @@ -72,6 +72,9 @@ internal static partial class PluginLuaBindings [LuaFunction("status")] public static string Status() => "ok"; + [LuaFunction("ping")] + public static int Ping() => 1; + public static LuaStatus RegisterLuaFunctions(LuaState state) => default; public static LuaStatus UnregisterLuaFunctions(LuaState state) => default; } @@ -144,7 +147,7 @@ public PluginLuaModule() } [Fact] - public void ModuleAdapterCompilesAgainstTheSdkRegistrationContract() + public void ModuleAdapterWithMultipleExportsCompilesAgainstTheSdkRegistrationContract() { GeneratorRun run = GeneratorRun.Execute(ModuleCompilationSource);