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.