diff --git a/CheatEngine.Client.slnx b/CheatEngine.Client.slnx index 33b6f04..dc3f222 100644 --- a/CheatEngine.Client.slnx +++ b/CheatEngine.Client.slnx @@ -44,6 +44,9 @@ + + + diff --git a/docs/engineering/work-items/CLI-012.md b/docs/engineering/work-items/CLI-012.md index 1e243e6..317ffd9 100644 --- a/docs/engineering/work-items/CLI-012.md +++ b/docs/engineering/work-items/CLI-012.md @@ -94,3 +94,15 @@ Required for the affected capability. Optional profiles may be deferred through ### Maintainer notes Keep execution updates, actual commands/results, decisions, and refinements here. The bootstrap does not overwrite an existing issue body on rerun. + +2026-09-21 — Prepared a follow-up coexistence fixture increment after the post-merge acceptance audit. The runner builds +fresh, disjoint A/B/collision plugin closures, validates their declared runtime/native assets and hashes, and emits a +`BuildPrepared_NotLiveQualified` receipt. The fixture now has an explicit overlapping Lua-export contender, a +disable-one/survive-other Lua assertion sequence, and opt-in target/retained-owner probe commands. No Cheat Engine host +or target process was started, inspected, attached, configured, or modified by this preparation. + +CLI-012 remains open. Exact blockers are: (1) no verified controlled Cheat Engine 7.7 x64 host profile and transcript; +(2) no two authorised disposable target fixtures for the selection-change extension; (3) no exact qualified +side-by-side Client/SDK package tuple and host-loader observation; and (4) the released Client/SDK 1.0.0 tuple reports +allocation ownership as `CapabilityUnavailable`, so retained-owner behavior cannot be counted as a passing result. +The SDK PR #56 merge is lifecycle/source evidence, not a completed live coexistence qualification. diff --git a/eng/Invoke-LivePluginCoexistenceFixture.ps1 b/eng/Invoke-LivePluginCoexistenceFixture.ps1 new file mode 100644 index 0000000..b7587a6 --- /dev/null +++ b/eng/Invoke-LivePluginCoexistenceFixture.ps1 @@ -0,0 +1,319 @@ +[CmdletBinding()] +param( + [switch]$Build, + + [ValidateSet('Debug', 'Release')] + [string]$Configuration = 'Release', + + [string]$PluginASdkVersion = '1.0.0', + + [string]$PluginBSdkVersion = '1.0.0', + + [string]$PluginCollisionSdkVersion = '1.0.0', + + [string]$BundleRoot, + + [string]$PluginABundlePath, + + [string]$PluginBBundlePath, + + [string]$PluginCollisionBundlePath, + + [string]$ReceiptPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..')) +$fixtureRoot = Join-Path $repositoryRoot 'tests/CheatEngine.Client.LivePlugin.Coexistence' +$pluginAProject = Join-Path $fixtureRoot 'PluginA/CheatEngine.Client.LivePlugin.Coexistence.PluginA.csproj' +$pluginBProject = Join-Path $fixtureRoot 'PluginB/CheatEngine.Client.LivePlugin.Coexistence.PluginB.csproj' +$pluginCollisionProject = Join-Path $fixtureRoot 'PluginCollision/CheatEngine.Client.LivePlugin.Coexistence.PluginCollision.csproj' + +function Resolve-ConcreteDirectory { + param( + [Parameter(Mandatory)] [string]$Path, + [Parameter(Mandatory)] [string]$Label + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Container)) { + throw "$Label directory '$Path' does not exist." + } + + return (Resolve-Path -LiteralPath $Path).Path +} + +function Assert-FreshBundleRoot { + param([Parameter(Mandatory)] [string]$Path) + + if (Test-Path -LiteralPath $Path) { + throw "Refusing to reuse coexistence bundle root '$Path'. Use a newly created, empty path so stale dependencies cannot qualify as part of a closure." + } +} + +function Invoke-FixtureBuild { + param( + [Parameter(Mandatory)] [string]$ProjectPath, + [Parameter(Mandatory)] [string]$SdkVersion, + [Parameter(Mandatory)] [string]$DeploymentPath + ) + + & dotnet build $ProjectPath --configuration $Configuration ` + "-p:CoexistenceSdkPackageVersion=$SdkVersion" ` + "-p:CheatEnginePluginOutputPath=$DeploymentPath" + if ($LASTEXITCODE -ne 0) { + throw "Fixture build failed for '$ProjectPath' (SDK package '$SdkVersion')." + } +} + +function Get-DependencyAssets { + param( + [Parameter(Mandatory)] [object]$Deps, + [Parameter(Mandatory)] [string]$BundlePath, + [Parameter(Mandatory)] [string]$Label + ) + + $targetProperties = @($Deps.targets.PSObject.Properties) + if ($targetProperties.Count -ne 1) { + throw "$Label dependency manifest must contain exactly one runtime target; found $($targetProperties.Count)." + } + + $assetPaths = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) + foreach ($library in $targetProperties[0].Value.PSObject.Properties) { + foreach ($sectionName in @('runtime', 'native')) { + $sectionProperty = $library.Value.PSObject.Properties[$sectionName] + if ($null -eq $sectionProperty) { + continue + } + + $section = $sectionProperty.Value + + foreach ($asset in $section.PSObject.Properties) { + $relativePath = $asset.Name.Replace('/', [IO.Path]::DirectorySeparatorChar) + $assetPath = Join-Path $BundlePath $relativePath + if (-not (Test-Path -LiteralPath $assetPath -PathType Leaf)) { + # NuGet's deps manifest names package assets (for example lib/net10.0/foo.dll), whereas a managed + # plugin deployment flattens its runtime closure beside the plugin DLL. + $assetPath = Join-Path $BundlePath ([IO.Path]::GetFileName($relativePath)) + if (-not (Test-Path -LiteralPath $assetPath -PathType Leaf)) { + throw "$Label bundle is missing dependency asset '$($asset.Name)' declared by '$($library.Name)'." + } + } + + [void]$assetPaths.Add($relativePath) + } + } + } + + return @($assetPaths | Sort-Object) +} + +function Get-ResolvedSdkPackage { + param( + [Parameter(Mandatory)] [string]$ProjectPath, + [Parameter(Mandatory)] [string]$Label, + [Parameter(Mandatory)] [string]$SdkAssemblyPath, + [Parameter(Mandatory)] [string]$RequestedSdkVersion + ) + + $projectName = [IO.Path]::GetFileNameWithoutExtension($ProjectPath) + $assetsPath = Join-Path $repositoryRoot "artifacts/obj/$projectName/project.assets.json" + if (-not (Test-Path -LiteralPath $assetsPath -PathType Leaf)) { + throw "$Label has no restored project.assets.json at '$assetsPath'." + } + + $assets = Get-Content -LiteralPath $assetsPath -Raw | ConvertFrom-Json + $sdkLibrary = @($assets.libraries.PSObject.Properties | Where-Object Name -like 'CheatEngine.SDK/*') + if ($sdkLibrary.Count -ne 1) { + throw "$Label must resolve exactly one CheatEngine.SDK package; found $($sdkLibrary.Count)." + } + + $resolvedVersion = $sdkLibrary[0].Name.Substring('CheatEngine.SDK/'.Length) + if ($resolvedVersion -ne $RequestedSdkVersion) { + throw "$Label resolved CheatEngine.SDK '$resolvedVersion', not requested '$RequestedSdkVersion'." + } + + $targetProperties = @($assets.targets.PSObject.Properties) + if ($targetProperties.Count -ne 1) { + throw "$Label restored project.assets.json must contain exactly one runtime target; found $($targetProperties.Count)." + } + + $sdkTarget = $targetProperties[0].Value.PSObject.Properties[$sdkLibrary[0].Name] + if ($null -eq $sdkTarget -or $null -eq $sdkTarget.Value.runtime) { + throw "$Label restored CheatEngine.SDK package has no runtime assets." + } + + $runtimeAssemblyPaths = @( + $sdkTarget.Value.runtime.PSObject.Properties.Name | + Where-Object { $_ -match '^lib/[^/]+/CheatEngine\.SDK\.dll$' } + ) + if ($runtimeAssemblyPaths.Count -ne 1) { + throw "$Label restored CheatEngine.SDK package must expose exactly one CheatEngine.SDK runtime assembly; found $($runtimeAssemblyPaths.Count)." + } + + $bundleAssemblyHash = (Get-FileHash -LiteralPath $SdkAssemblyPath -Algorithm SHA256).Hash + $packageRelativePath = $sdkLibrary[0].Value.path.Replace('/', [IO.Path]::DirectorySeparatorChar) + $runtimeAssemblyRelativePath = $runtimeAssemblyPaths[0].Replace('/', [IO.Path]::DirectorySeparatorChar) + foreach ($packageFolder in $assets.packageFolders.PSObject.Properties.Name) { + $packageAssemblyPath = Join-Path (Join-Path $packageFolder $packageRelativePath) $runtimeAssemblyRelativePath + if ((Test-Path -LiteralPath $packageAssemblyPath -PathType Leaf) -and + (Get-FileHash -LiteralPath $packageAssemblyPath -Algorithm SHA256).Hash -eq $bundleAssemblyHash) { + return [ordered]@{ + Id = 'CheatEngine.SDK' + Version = $resolvedVersion + ContentHash = $sdkLibrary[0].Value.sha512 + Verification = 'VerifiedPackageAssemblyMatch' + BundleAssemblySha256 = $bundleAssemblyHash + } + } + } + + return [ordered]@{ + Id = 'CheatEngine.SDK' + Verification = 'UnverifiedBundleAssembly' + BundleAssemblySha256 = $bundleAssemblyHash + Reason = 'The supplied bundle CheatEngine.SDK.dll did not match the runtime assembly from the resolved approved package.' + } +} + +function Get-BundleReceipt { + param( + [Parameter(Mandatory)] [string]$Label, + [Parameter(Mandatory)] [string]$BundlePath, + [Parameter(Mandatory)] [string]$ProjectPath, + [Parameter(Mandatory)] [string]$PluginAssemblyName, + [Parameter(Mandatory)] [string]$RequestedSdkVersion + ) + + $resolvedBundle = Resolve-ConcreteDirectory -Path $BundlePath -Label $Label + $pluginAssemblyPath = Join-Path $resolvedBundle "$PluginAssemblyName.dll" + $depsPath = Join-Path $resolvedBundle "$PluginAssemblyName.deps.json" + $runtimeConfigPath = Join-Path $resolvedBundle "$PluginAssemblyName.runtimeconfig.json" + $requiredFiles = @( + $pluginAssemblyPath, + $depsPath, + $runtimeConfigPath, + (Join-Path $resolvedBundle 'CheatEngine.SDK.dll'), + (Join-Path $resolvedBundle 'CheatEngine.Client.Abstractions.dll'), + (Join-Path $resolvedBundle 'CheatEngine.Client.Core.dll'), + (Join-Path $resolvedBundle 'CheatEngine.Client.Extensions.DependencyInjection.dll'), + (Join-Path $resolvedBundle 'CheatEngine.Client.Hosting.dll'), + (Join-Path $resolvedBundle 'cheatengine-sdk-lua-bridge.dll') + ) + foreach ($requiredFile in $requiredFiles) { + if (-not (Test-Path -LiteralPath $requiredFile -PathType Leaf)) { + throw "$Label bundle is missing required deployment file '$requiredFile'." + } + } + + $deps = Get-Content -LiteralPath $depsPath -Raw | ConvertFrom-Json + $dependencyAssets = Get-DependencyAssets -Deps $deps -BundlePath $resolvedBundle -Label $Label + $sdkAssemblyPath = Join-Path $resolvedBundle 'CheatEngine.SDK.dll' + $sdkPackage = Get-ResolvedSdkPackage -ProjectPath $ProjectPath -Label $Label -SdkAssemblyPath $sdkAssemblyPath ` + -RequestedSdkVersion $RequestedSdkVersion + + $fileRecords = @( + Get-ChildItem -LiteralPath $resolvedBundle -File -Recurse | + Sort-Object FullName | + ForEach-Object { + [ordered]@{ + Path = [IO.Path]::GetRelativePath($resolvedBundle, $_.FullName).Replace('\', '/') + Length = $_.Length + Sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + } + } + ) + + $sdkAssembly = [Reflection.AssemblyName]::GetAssemblyName($sdkAssemblyPath) + return [ordered]@{ + Label = $Label + BundlePath = $resolvedBundle + PluginAssembly = [ordered]@{ + Name = $PluginAssemblyName + Sha256 = (Get-FileHash -LiteralPath $pluginAssemblyPath -Algorithm SHA256).Hash + } + SdkPackage = $sdkPackage + SdkAssemblyIdentity = $sdkAssembly.FullName + DependencyAssets = $dependencyAssets + Files = $fileRecords + } +} + +if ($Build) { + if ([string]::IsNullOrWhiteSpace($BundleRoot)) { + $BundleRoot = Join-Path $repositoryRoot ("artifacts/live-plugin-coexistence/" + [Guid]::NewGuid().ToString('N')) + } + + $resolvedBundleRoot = [IO.Path]::GetFullPath($BundleRoot) + Assert-FreshBundleRoot -Path $resolvedBundleRoot + $PluginABundlePath = Join-Path $resolvedBundleRoot 'PluginA' + $PluginBBundlePath = Join-Path $resolvedBundleRoot 'PluginB' + $PluginCollisionBundlePath = Join-Path $resolvedBundleRoot 'PluginCollision' + + Invoke-FixtureBuild -ProjectPath $pluginAProject -SdkVersion $PluginASdkVersion -DeploymentPath $PluginABundlePath + Invoke-FixtureBuild -ProjectPath $pluginBProject -SdkVersion $PluginBSdkVersion -DeploymentPath $PluginBBundlePath + Invoke-FixtureBuild -ProjectPath $pluginCollisionProject -SdkVersion $PluginCollisionSdkVersion -DeploymentPath $PluginCollisionBundlePath +} +else { + if ([string]::IsNullOrWhiteSpace($PluginABundlePath) -or [string]::IsNullOrWhiteSpace($PluginBBundlePath) -or + [string]::IsNullOrWhiteSpace($PluginCollisionBundlePath)) { + throw 'Supply -Build or all three existing bundle paths.' + } +} + +$pluginAReceipt = Get-BundleReceipt -Label 'PluginA' -BundlePath $PluginABundlePath -ProjectPath $pluginAProject ` + -PluginAssemblyName 'CheatEngine.Client.LivePlugin.Coexistence.PluginA' -RequestedSdkVersion $PluginASdkVersion +$pluginBReceipt = Get-BundleReceipt -Label 'PluginB' -BundlePath $PluginBBundlePath -ProjectPath $pluginBProject ` + -PluginAssemblyName 'CheatEngine.Client.LivePlugin.Coexistence.PluginB' -RequestedSdkVersion $PluginBSdkVersion +$pluginCollisionReceipt = Get-BundleReceipt -Label 'PluginCollision' -BundlePath $PluginCollisionBundlePath ` + -ProjectPath $pluginCollisionProject -PluginAssemblyName 'CheatEngine.Client.LivePlugin.Coexistence.PluginCollision' ` + -RequestedSdkVersion $PluginCollisionSdkVersion + +$bundlePaths = @($pluginAReceipt.BundlePath, $pluginBReceipt.BundlePath, $pluginCollisionReceipt.BundlePath) +for ($first = 0; $first -lt $bundlePaths.Count; $first++) { + for ($second = $first + 1; $second -lt $bundlePaths.Count; $second++) { + if ($bundlePaths[$first] -eq $bundlePaths[$second] -or + $bundlePaths[$first].StartsWith($bundlePaths[$second] + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase) -or + $bundlePaths[$second].StartsWith($bundlePaths[$first] + [IO.Path]::DirectorySeparatorChar, + [StringComparison]::OrdinalIgnoreCase)) { + throw "Coexistence bundles must be disjoint directories; found '$($bundlePaths[$first])' and '$($bundlePaths[$second])'." + } + } +} + +if ([string]::IsNullOrWhiteSpace($ReceiptPath)) { + $receiptBase = if ($Build) { Split-Path -Parent $PluginABundlePath } else { [IO.Path]::GetTempPath() } + $ReceiptPath = Join-Path $receiptBase 'coexistence-build-receipt.json' +} + +$resolvedReceiptPath = [IO.Path]::GetFullPath($ReceiptPath) +$receiptDirectory = Split-Path -Parent $resolvedReceiptPath +if (-not [string]::IsNullOrWhiteSpace($receiptDirectory)) { + [IO.Directory]::CreateDirectory($receiptDirectory) | Out-Null +} + +$receipt = [ordered]@{ + Schema = 'CheatEngine.Client.LivePlugin.Coexistence.Receipt/v1' + EvidenceState = 'BuildPrepared_NotLiveQualified' + GeneratedAtUtc = [DateTime]::UtcNow.ToString('O', [Globalization.CultureInfo]::InvariantCulture) + Runner = [ordered]@{ + Path = $PSCommandPath + BuildPerformed = [bool]$Build + Configuration = $Configuration + } + Qualification = [ordered]@{ + HostRun = 'Not executed by this runner' + Collision = 'Specified; requires controlled-host transcript' + DisableOneSurvivesOther = 'Specified; requires controlled-host transcript' + TargetSwitch = 'Specified; requires two authorized disposable targets and controlled-host transcript' + RetainedOwner = 'Specified; current Client tuple can report capability unavailable and must not be counted as a pass' + SideBySideSdk = 'Specified; package tuples are recorded but loader isolation remains host-qualified' + } + Bundles = @($pluginAReceipt, $pluginBReceipt, $pluginCollisionReceipt) +} + +$receipt | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $resolvedReceiptPath -Encoding utf8NoBOM +Write-Host "Prepared and verified three isolated plugin bundles. Receipt: $resolvedReceiptPath" +Write-Host 'No Cheat Engine process was started, inspected, attached, configured, or modified. This is build/package-layout evidence only.' diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistenceDiagnostics.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistenceDiagnostics.cs index 3d46bdd..a1832e4 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistenceDiagnostics.cs +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistenceDiagnostics.cs @@ -2,6 +2,10 @@ using System.Reflection; using System.Runtime.Loader; +using CheatEngine.Client; +using CheatEngine.Client.Allocations; +using CheatEngine.Client.Processes; +using CheatEngine.Client.Results; using CheatEngine.Client.Hosting; using CheatEngine.SDK.Hosting.Bootstrap; @@ -15,13 +19,26 @@ internal static class CoexistenceDiagnostics { private static int s_allowedTableRootCount; private static long s_epoch; + private static ITargetMemoryLease? s_retainedOwner; private static uint s_pluginId; + private static ICheatEngineClient? s_activeClient; - internal static void RecordEnabled(uint pluginId, long epoch, int allowedTableRootCount) + internal static void RecordEnabled(uint pluginId, ICheatEngineClient client, int allowedTableRootCount) { + ArgumentNullException.ThrowIfNull(client); Volatile.Write(ref s_allowedTableRootCount, allowedTableRootCount); - Volatile.Write(ref s_epoch, epoch); + Volatile.Write(ref s_epoch, client.Epoch); Volatile.Write(ref s_pluginId, pluginId); + Interlocked.Exchange(ref s_activeClient, client); + } + + internal static void RecordDisabling() + { + // A fixture-only Lua callback must not retain an expired Client activation. The normal Client owner registry + // remains responsible for target-change and activation cleanup; this releases a still-retained probe lease early. + Interlocked.Exchange(ref s_activeClient, null); + ITargetMemoryLease? owner = Interlocked.Exchange(ref s_retainedOwner, null); + owner?.Dispose(); } internal static string GetIdentity(string pluginLabel, Assembly pluginAssembly) @@ -43,6 +60,93 @@ internal static string GetIdentity(string pluginLabel, Assembly pluginAssembly) $"AllowedTableRootCount={Volatile.Read(ref s_allowedTableRootCount)}"); } + /// Refreshes and reports the current target without selecting or otherwise mutating it. + internal static string ObserveTarget() + { + ICheatEngineClient? client = Volatile.Read(ref s_activeClient); + if (client is null) + { + return "Target=Inactive"; + } + + return client.Processes.TryRefresh(out ProcessSnapshot snapshot, out CheatEngineFailure failure) + ? string.Create( + CultureInfo.InvariantCulture, + $"Target=Selected; ProcessId={snapshot.Id.Value}; SelectionEpoch={snapshot.SelectionEpoch}; Architecture={snapshot.TargetArchitecture}") + : DescribeFailure("Target", failure); + } + + /// + /// Retains one intentionally tiny allocation only when the exact Client/SDK tuple exposes a qualified allocation + /// owner. The current released Client tuple reports capability unavailable; that outcome is an expected blocker, + /// never a passing retained-owner result. + /// + internal static string RetainOwner() + { + ICheatEngineClient? client = Volatile.Read(ref s_activeClient); + if (client is null) + { + return "Owner=Inactive"; + } + + ITargetMemoryLease? prior = Volatile.Read(ref s_retainedOwner); + if (prior is not null) + { + return DescribeOwner("Owner=AlreadyRetained", prior); + } + + if (!client.Allocations.TryAllocate(new TargetAllocationRequest(16), out ITargetMemoryLease? owner, + out CheatEngineFailure failure)) + { + return DescribeFailure("Owner", failure); + } + + ITargetMemoryLease? retainedOwner = Interlocked.CompareExchange(ref s_retainedOwner, owner, null); + if (retainedOwner is null) + { + return DescribeOwner("Owner=Retained", owner); + } + + // Generated Lua calls are normally serialized by the host. Keep the race deterministic if a future host invokes + // this fixture concurrently: the extra owner is released, rather than left associated with an unknown target. + owner.Dispose(); + return DescribeOwner("Owner=AlreadyRetained", retainedOwner); + } + + /// Reports the retained owner's Client-visible lifecycle state without invoking Cheat Engine. + internal static string GetOwnerState() + { + ITargetMemoryLease? owner = Volatile.Read(ref s_retainedOwner); + return owner is null ? "Owner=None" : DescribeOwner("Owner=Retained", owner); + } + + /// Releases the retained probe owner once, if one exists. + internal static string ReleaseOwner() + { + ITargetMemoryLease? owner = Interlocked.Exchange(ref s_retainedOwner, null); + if (owner is null) + { + return "Owner=None"; + } + + owner.Dispose(); + return "Owner=ReleasedByFixture"; + } + + private static string DescribeFailure(string prefix, CheatEngineFailure failure) + { + return string.Create( + CultureInfo.InvariantCulture, + $"{prefix}=Failure; Kind={failure.Kind}; Operation={failure.Operation}; Message={failure.Message}"); + } + + private static string DescribeOwner(string prefix, ITargetMemoryLease owner) + { + return string.Create( + CultureInfo.InvariantCulture, + $"{prefix}; Released={owner.IsReleased}; SelectionEpoch={owner.SelectionEpoch}; Size={owner.Size}"); + } + private static string Describe(AssemblyLoadContext? loadContext) { if (loadContext is null) diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistencePlugin.props b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistencePlugin.props index 099bc1a..ac5433a 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistencePlugin.props +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/CoexistencePlugin.props @@ -1,7 +1,12 @@ - + x64 x64 true @@ -10,6 +15,11 @@ true false false + true + true + 1.0.0 + + false @@ -20,8 +30,12 @@ ReferenceOutputAssembly="false" PrivateAssets="all"/> - + + + + diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs index 504b41f..c2b6e36 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs @@ -36,13 +36,14 @@ internal sealed class CoexistencePluginAModule( public void OnEnabled(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); - CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client.Epoch, _options.AllowedTableRoots?.Length ?? 0); + CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client, _options.AllowedTableRoots?.Length ?? 0); } /// public void OnDisabling(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); + CoexistenceDiagnostics.RecordDisabling(); } } diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs index 414cf21..c5c131c 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CoexistencePluginAFunctions.cs @@ -20,4 +20,39 @@ public static long Ping() { return Interlocked.Increment(ref s_pingCount); } + + /// Competes with Plugin Collision for the exact same Lua global name. + [LuaFunction("cheatengine_client_coexistence_a_collision")] + public static string Collision() + { + return "CollisionOwner=A"; + } + + /// Records the active target observation without selecting a target. + [LuaFunction("cheatengine_client_coexistence_a_target")] + public static string ObserveTarget() + { + return CoexistenceDiagnostics.ObserveTarget(); + } + + /// Attempts the explicitly opt-in retained-owner probe. + [LuaFunction("cheatengine_client_coexistence_a_retain_owner")] + public static string RetainOwner() + { + return CoexistenceDiagnostics.RetainOwner(); + } + + /// Reports the retained-owner state without issuing a CE call. + [LuaFunction("cheatengine_client_coexistence_a_owner_state")] + public static string OwnerState() + { + return CoexistenceDiagnostics.GetOwnerState(); + } + + /// Releases the retained owner when the operator has completed the probe. + [LuaFunction("cheatengine_client_coexistence_a_release_owner")] + public static string ReleaseOwner() + { + return CoexistenceDiagnostics.ReleaseOwner(); + } } diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/README.md b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/README.md index 0170197..8a9dafa 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/README.md +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/README.md @@ -1,6 +1,6 @@ # CheatEngine.Client.LivePlugin.Coexistence.PluginA Plugin A is one half of the opt-in two-plugin fixture described by the parent -[coexistence protocol](../README.md). It uses the public Client hosting and generated Lua-module path, with distinct -Lua globals, and must remain in its own complete build output directory. It is not a unit test or evidence that a -Cheat Engine loader isolates Plugin A from Plugin B. +[coexistence protocol](../README.md). It uses the public Client hosting and generated Lua-module path, retains the +positive Lua owner marker used by the collision contender, and must remain in its own complete build output directory. +It is not a unit test or evidence that a Cheat Engine loader isolates Plugin A from Plugin B. diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs index a530f86..f519f89 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs @@ -36,13 +36,14 @@ internal sealed class CoexistencePluginBModule( public void OnEnabled(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); - CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client.Epoch, _options.AllowedTableRoots?.Length ?? 0); + CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client, _options.AllowedTableRoots?.Length ?? 0); } /// public void OnDisabling(ICheatEngineClient client) { ArgumentNullException.ThrowIfNull(client); + CoexistenceDiagnostics.RecordDisabling(); } } diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs index 07295ea..d189bb4 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CoexistencePluginBFunctions.cs @@ -20,4 +20,11 @@ public static long Ping() { return Interlocked.Increment(ref s_pingCount); } + + /// Records the active target observation without selecting a target. + [LuaFunction("cheatengine_client_coexistence_b_target")] + public static string ObserveTarget() + { + return CoexistenceDiagnostics.ObserveTarget(); + } } diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/README.md b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/README.md index bb50e99..bb5dcd2 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/README.md +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/README.md @@ -2,5 +2,5 @@ Plugin B is one half of the opt-in two-plugin fixture described by the parent [coexistence protocol](../README.md). It uses the public Client hosting and generated Lua-module path, with distinct -Lua globals, and must remain in its own complete build output directory. It is not a unit test or evidence that a -Cheat Engine loader isolates Plugin B from Plugin A. +Lua globals and its own target observation, and must remain in its own complete build output directory. It is not a +unit test or evidence that a Cheat Engine loader isolates Plugin B from Plugin A. diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CheatEngine.Client.LivePlugin.Coexistence.PluginCollision.csproj b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CheatEngine.Client.LivePlugin.Coexistence.PluginCollision.csproj new file mode 100644 index 0000000..211a098 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CheatEngine.Client.LivePlugin.Coexistence.PluginCollision.csproj @@ -0,0 +1,10 @@ + + + + + + CheatEngine.Client.LivePlugin.Coexistence.PluginCollision + LivePlugin.Coexistence.PluginCollision + + + diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CoexistencePluginCollision.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CoexistencePluginCollision.cs new file mode 100644 index 0000000..dd03152 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CoexistencePluginCollision.cs @@ -0,0 +1,63 @@ +using CheatEngine.Client; +using CheatEngine.Client.Extensions.DependencyInjection; +using CheatEngine.Client.Hosting; +using CheatEngine.Client.Lua; +using CheatEngine.Client.Modules; +using CheatEngine.SDK.Annotations.Plugin; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace LivePlugin.Coexistence.PluginCollision; + +/// +/// Deliberately declares one export already owned by Plugin A. It must be loaded only after A in the controlled +/// collision protocol; a failure to enable must leave A's existing globals callable and unchanged. +/// +[CheatEnginePlugin("CheatEngine.Client Coexistence Collision Plugin")] +public sealed class CoexistencePluginCollision : CheatEngineClientPlugin +{ + /// + protected override void Configure(CheatEnginePluginBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + builder.Services.AddSingleton(new CoexistencePluginIdentity(Context.PluginId)); + builder.Client + .AddLuaModule() + .AddModule(); + } +} + +/// Records the activation facts only if collision registration was unexpectedly admitted. +internal sealed class CoexistencePluginCollisionModule( + IOptions options, + CoexistencePluginIdentity pluginIdentity) : ICheatEngineClientModule +{ + private readonly CheatEngineClientOptions _options = options.Value; + + /// + public void OnEnabled(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + CoexistenceDiagnostics.RecordEnabled(pluginIdentity.Id, client, _options.AllowedTableRoots?.Length ?? 0); + } + + /// + public void OnDisabling(ICheatEngineClient client) + { + ArgumentNullException.ThrowIfNull(client); + CoexistenceDiagnostics.RecordDisabling(); + } +} + +/// Declares the collision module in a distinct managed assembly. +[CheatEngineLuaModule(typeof(CoexistencePluginCollisionFunctions), "coexistence_collision")] +internal sealed partial class CoexistencePluginCollisionLuaModule : ILuaModule; + +internal sealed class CoexistencePluginIdentity(uint id) +{ + internal uint Id + { + get; + } = id; +} diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CoexistencePluginCollisionFunctions.cs b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CoexistencePluginCollisionFunctions.cs new file mode 100644 index 0000000..9064363 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/CoexistencePluginCollisionFunctions.cs @@ -0,0 +1,14 @@ +using CheatEngine.SDK.Annotations.Lua; + +namespace LivePlugin.Coexistence.PluginCollision; + +/// Exports the same global as Plugin A so the host-visible collision path can be observed. +internal static partial class CoexistencePluginCollisionFunctions +{ + /// Must never replace Plugin A's existing collision marker. + [LuaFunction("cheatengine_client_coexistence_a_collision")] + public static string Collision() + { + return "CollisionOwner=Collision"; + } +} diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/README.md b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/README.md new file mode 100644 index 0000000..55b520c --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/README.md @@ -0,0 +1,7 @@ +# CheatEngine.Client.LivePlugin.Coexistence.PluginCollision + +This plugin intentionally exports `cheatengine_client_coexistence_a_collision`, the same Lua name as Plugin A. It is +not a third positive coexistence participant. In the controlled protocol, enable A first, confirm its marker, then +attempt to enable this plugin. The host-visible result must be recorded and Plugin A's marker must remain callable. + +Do not load it before A, rename the collision global, or use a manual Lua assignment to repair a failed result. diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/packages.lock.json b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/packages.lock.json new file mode 100644 index 0000000..30eb595 --- /dev/null +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/PluginCollision/packages.lock.json @@ -0,0 +1,145 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "CheatEngine.SDK": { + "type": "Direct", + "requested": "[1.0.0, )", + "resolved": "1.0.0", + "contentHash": "n7nHqZ8vzo7Vf20jF0fkh/jUtR3yo1TwRGpXE7ERxZeJ4C5S/Nsft4lqOg7zGwfsD5Nh9tTVgdw4PrybJRF0gA==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" + }, + "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.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "8xaGcvS/qZ1otoxPQCEJkNva389CVL/plNcvIETZhQTETYdRkYDPEYhUMoAGONo4FU45ufdfE0j29AfWVVj0wA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "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": "Transitive", + "resolved": "10.0.12", + "contentHash": "lXyK2O5GoYvfxW8eCFcD16JFbcoSTM1sJkAM0UHS1jZyl9NYMW64Tqm6OQFT0IDBjZi+xHt95/Zg+nxZhGFhZg==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "9/qymSh7hVDMGTGwrLz8MRp5zRyXy9adGDOs4HwRdnLil3oZGYuWeZjbmHgCQ9BL1qBroVfgUK3U/nb61617Cw==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "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": "Transitive", + "resolved": "10.0.12", + "contentHash": "+24lC4plfbEDNfLAdTV/SWKS7dW+16X4HdydO3R++134kSNTzcbYA4KpR1Hdh6uWisB8Za3AzwyOn+K+NxWIug==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "TDYD33TSRpXKZWlmTXNlj5kCihxatmv2Ec1u6C+bMYLphCS7PoSLE9Pjd/nunDoE7yETk+LLKjVJX78HYtWjpA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Primitives": "10.0.12" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "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": "Transitive", + "resolved": "10.0.12", + "contentHash": "rPqU/cnDMmL4Gx7sglNnYwh/upWxSLTwVe0ysT88WH9HEE3OfZgaT7TyM86C4GeXwDsDQaj95fUGL5jEB3vxng==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.12", + "Microsoft.Extensions.Options": "10.0.12" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.12", + "contentHash": "dYfCLR52UA+3DL7C4I/pvSaRPkNqxrUAQmbFL2u0zvYKKzqgrFCJl08Df+F1aYc8leu9JvpC9bsURUdpExcBXQ==" + }, + "cheatengine.client.abstractions": { + "type": "Project", + "dependencies": { + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.core": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )", + "CheatEngine.SDK": "[1.0.0, 2.0.0)" + } + }, + "cheatengine.client.extensions.dependencyinjection": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Abstractions": "[0.1.0, )", + "CheatEngine.Client.Core": "[0.1.0, )", + "Microsoft.Extensions.Configuration.Abstractions": "[10.0.12, )", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )", + "Microsoft.Extensions.Options.ConfigurationExtensions": "[10.0.12, )", + "Microsoft.Extensions.Options.DataAnnotations": "[10.0.12, )" + } + }, + "cheatengine.client.hosting": { + "type": "Project", + "dependencies": { + "CheatEngine.Client.Extensions.DependencyInjection": "[0.1.0, )", + "CheatEngine.SDK": "[1.0.0, 2.0.0)", + "Microsoft.Extensions.DependencyInjection": "[10.0.12, )", + "Microsoft.Extensions.Logging": "[10.0.12, )" + } + } + } + } +} \ No newline at end of file diff --git a/tests/CheatEngine.Client.LivePlugin.Coexistence/README.md b/tests/CheatEngine.Client.LivePlugin.Coexistence/README.md index 04d453f..4c3a5bb 100644 --- a/tests/CheatEngine.Client.LivePlugin.Coexistence/README.md +++ b/tests/CheatEngine.Client.LivePlugin.Coexistence/README.md @@ -1,67 +1,159 @@ # CheatEngine.Client.LivePlugin.Coexistence -This is an opt-in manual fixture for the Client-facing part of multi-plugin observation. It builds two distinct managed -plugin assemblies, `PluginA` and `PluginB`, with the public `CheatEngineClientPlugin` composition path, generated Lua -modules, and separate output directories. It aligns with the evidence protocol introduced by -[SDK PR #56](https://github.com/CheatEngineNet/CheatEngine.SDK/pull/56): record the identities the exact Cheat Engine -host actually chose; do not manufacture an `AssemblyLoadContext`, infer loader isolation from separate Client DI -providers, or treat a successful build as a live result. +This is an opt-in manual fixture for the Client-facing part of multi-plugin observation. It builds three managed plugin +assemblies with the public `CheatEngineClientPlugin` composition path, generated Lua modules, and separate complete +output directories: + +- `PluginA` and `PluginB` are the positive, distinct-export pair. +- `PluginCollision` exports the exact same Lua name as Plugin A and is an expected-failure contender, not a third + successful plugin. + +It aligns with the evidence protocol introduced by [SDK PR #56](https://github.com/CheatEngineNet/CheatEngine.SDK/pull/56): +record the identities the exact Cheat Engine host actually chose; do not manufacture an `AssemblyLoadContext`, infer +loader isolation from separate Client DI providers, or treat a successful build as a live result. ## What the fixture observes Every plugin enable creates a fresh Client provider and one activation scope. Each fixture module records the SDK -plugin ID, its local Client epoch, and options count, and its two generated Lua globals return a copied -assembly/load-context identity or a monotonic ping count. The identity string includes the plugin, Client Hosting, and -SDK Hosting assemblies plus their module-version IDs and load-context facts. The fixture does not select a process, -mutate memory, allocate or release a target-owned resource, install a hook, create a loader policy, or add a -process-wide synchronization mechanism. +plugin ID, its local Client epoch, and options count. The identity Lua global reports copied assembly/load-context +identity and the ping global is monotonic. The identity string includes the plugin, Client Hosting, and SDK Hosting +assemblies plus their module-version IDs and load-context facts. -The two plugins deliberately use different Lua globals. That makes this fixture suitable for observing narrow -enable/disable coexistence, but it does **not** qualify overlapping-export collision handling, shared Lua/CE state, -worker concurrency, target switching, retained-owner safety, or side-by-side SDK versions. Those remain the -`Specified_Not_Executed` R25/T049–T050, R26/T051–T052, and R34/T067–T068/T076 scenarios. +Plugin A also exposes an exact collision marker, a target-observation function, and an explicitly opt-in retained-owner +probe. Plugin B exposes an independent target-observation function. Both target functions call only +`IProcessClient.TryRefresh`: they observe the current selection but never select, create, pause, or mutate a process. +The owner probe is inert until the operator calls it on an authorized disposable target. With the currently released +Client/SDK tuple it returns `CapabilityUnavailable` and no lease; this is a blocker record, not a passing owner test. +When a future qualified Client/SDK tuple provides a real allocation owner, the same probe retains a 16-byte lease so a +subsequent observed target change can prove the old lease was invalidated before anything can act on the new target. + +The fixture never creates a loader policy or process-wide synchronization mechanism. It does not add a target or +memory operation automatically. Side-by-side SDK packages, shared Lua/CE state, worker concurrency, target switching, +and retained-owner behavior remain `Specified_Not_Executed` until their exact controlled-host transcripts are attached. ## Package and host boundary The fixture references the changed Client Hosting/source-generator graph so a source build exercises the new Client contract. It still references the released `CheatEngine.SDK` 1.0.0 package directly, which supplies the SDK entry-point -generator and bridge assets. This source fixture is not a replacement for a clean Client package consumer: package smoke -must validate the eventual Client package. The resolved SDK package is not evidence that it contains the later SDK PR -#56 source merge, nor is that merge a published-package or live-host qualification. Before a real run, identify the -exact qualified Client/SDK package tuple, record package and DLL SHA-256 hashes, and keep the complete dependency -closure for each plugin in its own directory. Never copy DLLs from one output into the other or infer the selected SDK -version from a filename. +generator and bridge assets. `CoexistenceSdkPackageVersion` can be overridden per fixture project only when an operator +has an exact candidate SDK package source and tuple to qualify. A non-default version deliberately disables this +fixture's lock-file write path; it is not an invitation to invent or float package versions. + +The source fixture is not a replacement for a clean Client package consumer: `eng/Invoke-PackageSmoke.ps1` remains the +Client package-consumer gate. The coexistence runner imports the Hosting deployment target solely to stage a **fresh** +complete source-fixture closure. It skips the direct-package profile validation because the Client graph is a project +reference. Its receipt labels that distinction explicitly. + +The resolved SDK package is not evidence that it contains the later SDK PR #56 source merge, nor is that merge a +published-package or live-host qualification. Before a real run, identify the exact qualified Client/SDK package tuple, +record package and DLL SHA-256 hashes, and keep the complete dependency closure for each plugin in its own directory. +Never copy DLLs from one output into another, reuse a bundle directory, or infer the selected SDK version from a +filename. Use the controlled Windows x64 Cheat Engine 7.7 profile. Before loading either plugin, record the Cheat Engine binary version/architecture/SHA-256, .NET and `hostfxr` policy, source or package identities, complete output paths and hashes, timestamp/operator, and the full diagnostic/Lua transcript. A missing field means an unqualified manual observation, not a portable hosting claim. -## Build and manual protocol +## Prepare isolated bundles and a receipt + +Run the opt-in preparation runner from the repository root. It builds each project directly into a new bundle directory, +validates the `.deps.json` runtime/native closure plus the plugin, Client, SDK and native bridge assets, hashes every +file, and emits a JSON receipt. It does not start, inspect, attach to, configure, or modify Cheat Engine or a target +process. + +```powershell +pwsh .\eng\Invoke-LivePluginCoexistenceFixture.ps1 -Build +``` -Build the projects independently from the repository root: +The output contains three disjoint directories and `coexistence-build-receipt.json`. Preserve all three directories +and the receipt. To prepare a future exact SDK package tuple, pass its version independently for each plugin, point +NuGet at the approved package source through the normal restore configuration, and retain the resulting receipt: ```powershell -dotnet build tests/CheatEngine.Client.LivePlugin.Coexistence/PluginA/CheatEngine.Client.LivePlugin.Coexistence.PluginA.csproj -c Release -dotnet build tests/CheatEngine.Client.LivePlugin.Coexistence/PluginB/CheatEngine.Client.LivePlugin.Coexistence.PluginB.csproj -c Release +pwsh .\eng\Invoke-LivePluginCoexistenceFixture.ps1 -Build ` + -PluginASdkVersion ` + -PluginBSdkVersion ` + -PluginCollisionSdkVersion ``` -Keep the two output directories intact. In the controlled host's **Edit > Settings > Plugins** UI, add both plugin DLLs -without changing the installed host configuration. Enable A, then B, and record the result of each command in the Lua -Engine: +Different requested package versions only make a side-by-side live run eligible. The receipt's assembly identities, +package content hashes, full closures, and host transcript must still establish what the exact Cheat Engine loader did. + +## Controlled-host manual protocol + +Use only the controlled Windows x64 Cheat Engine 7.7 profile and two local, authorised, disposable target fixtures. +Before loading a plugin, supplement the generated receipt with the host EXE version/architecture/SHA-256, .NET and +`hostfxr` policy, source/package identities, timestamp/operator, target profile paths/hashes/PIDs, and complete +DebugView/Lua transcripts. A missing field makes the result an unqualified manual observation. + +In the controlled host's **Edit > Settings > Plugins** UI, add the Plugin A and B DLLs from their own bundle directories +without changing the installed host configuration. Enable A then B. Record the result of each command in the Lua Engine: ```lua print(cheatengine_client_coexistence_a_identity()) print(cheatengine_client_coexistence_b_identity()) print(cheatengine_client_coexistence_a_ping()) print(cheatengine_client_coexistence_b_ping()) +assert(cheatengine_client_coexistence_a_collision() == "CollisionOwner=A") +``` + +Disable A and record that its globals are absent while B's identity and ping remain callable. Use direct Lua assertions +and preserve their output: + +```lua +assert(cheatengine_client_coexistence_a_identity == nil) +assert(cheatengine_client_coexistence_a_ping == nil) +assert(cheatengine_client_coexistence_a_collision == nil) +assert(type(cheatengine_client_coexistence_b_identity) == "function") +assert(type(cheatengine_client_coexistence_b_ping) == "function") +print(cheatengine_client_coexistence_b_identity()) +print(cheatengine_client_coexistence_b_ping()) +``` + +Re-enable A. Before introducing `PluginCollision`, prove A's owner marker again, then add and attempt to enable the +collision DLL. The generated Client module must refuse to replace the non-`nil` A global. Record the host-visible enable +failure, then prove the established plugin survived untouched: + +```lua +assert(cheatengine_client_coexistence_a_collision() == "CollisionOwner=A") +assert(type(cheatengine_client_coexistence_a_identity) == "function") +assert(type(cheatengine_client_coexistence_a_ping) == "function") +``` + +Disable the failed contender if the host exposes it as enabled, then disable B and finally A, recording every lifecycle +result. Stop and retain the failure evidence if a positive plugin cannot load or enable, an expected collision does not +fail, a disabled plugin global remains, or a surviving plugin stops answering. Do not repair a failed observation by +assigning Lua globals manually. + +### Target switch and retained-owner extension + +Run this extension only after the positive/collision sequence and only against two purpose-built disposable targets. +Select target A in the controlled host, then record both observations. The commands themselves do not select a target: + +```lua +print(cheatengine_client_coexistence_a_target()) +print(cheatengine_client_coexistence_b_target()) +print(cheatengine_client_coexistence_a_retain_owner()) +print(cheatengine_client_coexistence_a_owner_state()) +``` + +If retain returns `Kind=CapabilityUnavailable`, record that the current tuple cannot perform the owner scenario and +stop this extension; it is not a failed live run and it is not a pass. If it returns `Owner=Retained`, switch Cheat +Engine to target B through the controlled host UI, then refresh **both** plugins and verify A's old lease is released +before any further operation: + +```lua +print(cheatengine_client_coexistence_b_target()) +print(cheatengine_client_coexistence_a_target()) +assert(string.find(cheatengine_client_coexistence_a_owner_state(), "Released=true", 1, true)) +print(cheatengine_client_coexistence_a_release_owner()) ``` -Disable A and record that its two globals are absent while B's identity and ping remain callable. Re-enable A, then -disable B and finally A, recording every enable/disable outcome. Stop and retain the failure evidence if either plugin -cannot load or enable, a disabled plugin's global remains, or the surviving plugin stops answering. Do not repair a -failed observation by assigning Lua globals manually. +Record both target PIDs, architectures, selection epochs, owner creation/release status, any cleanup failure, and +disable/re-enable results. An unqualified owner, a missing target observation, or an old owner that can act after the +switch is a stopped/failing result, never a reason to continue against target B. -No Cheat Engine execution is performed by this repository fixture or its ordinary CI build. A managed Native AOT probe -is publication evidence only; it does not prove that Cheat Engine can load, disable, remove, or unload a Native AOT -plugin. +No Cheat Engine execution is performed by this repository fixture, runner, or ordinary CI build. A managed Native AOT +probe is publication evidence only; it does not prove that Cheat Engine can load, disable, remove, or unload a Native +AOT plugin. The runner's JSON record is a reproducible build/package-layout receipt, not a `LiveQualified` result.