diff --git a/CHANGELOG.md b/CHANGELOG.md
index b5f8993..e631892 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,11 @@ ship.
### Added
+- **Source SDK builds.** Workspace consumers can opt into `CheatEngineSdkSourceRoot` to build Client and SDK together
+ through project references. Source builds record the SDK Engine version and Git commit from its PE metadata,
+ preserve plugin profile checks, and reject mismatched runtime identities. Normal NuGet builds retain their existing
+ package identity checks.
+
### Changed
- **Local process enumeration.** Ignore nonpositive operating-system process IDs, including the Windows idle
diff --git a/Directory.Build.targets b/Directory.Build.targets
index 6d0d2b8..6aa7272 100644
--- a/Directory.Build.targets
+++ b/Directory.Build.targets
@@ -1,4 +1,9 @@
+
+
+
-
diff --git a/README.md b/README.md
index a88537f..ee18235 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,34 @@ The aggregate `ICheatEngineClient` gives an enabled plugin access to runtime fac
dispatch, process selection, typed memory, AOB scans, inspection, address tables, and protected Lua operations. It
never exposes a `LuaState`, CE object handle, raw native pointer, or SDK ownership wrapper to plugin code.
+### Building with an SDK source checkout
+
+NuGet remains the default. A workspace that builds Client and SDK from source can place `CheatEngine.Source.props`
+beside the Client checkout with these settings:
+
+```xml
+
+
+ $(MSBuildThisFileDirectory)CheatEngine.SDK
+ $(MSBuildThisFileDirectory)locks
+
+
+```
+
+The SDK root must contain its Engine and Hosting runtime projects. The optional lock directory keeps this source
+graph separate from Client's committed NuGet locks. Parent-workspace settings are read during both restore and build;
+`CheatEngineImportSourceSettings=false` disables the import. Command-line properties can select the same settings.
+Clone complete Git history and tags so MinVer can derive the SDK assembly version and source revision.
+
+Source consumers reference the Client facade project. A plugin also directly references the SDK Engine and Hosting
+projects, imports the SDK and Client consumer build assets, and references their plugin generators as analyzers;
+project references do not propagate NuGet build assets or analyzers. The SDK's checked-in native bridge remains part
+of the plugin output. Source mode reads the consumed Engine PE metadata without executing it and requires that exact
+version and commit at runtime. It does not claim a NuGet package hash or native qualification.
+
+Package publication requires the normal NuGet graph; `CHEATENGINECLIENT9051` refuses source-mode packing or an invalid
+SDK source root. Restore and validate the package graph before publishing Client packages.
+
## Why this project exists
`CheatEngine.SDK` deliberately owns the difficult boundary: the generated Cheat Engine entry point, Lua protection,
diff --git a/eng/CheatEngineSdk.props b/eng/CheatEngineSdk.props
index c1ab5dc..7f5708b 100644
--- a/eng/CheatEngineSdk.props
+++ b/eng/CheatEngineSdk.props
@@ -1,4 +1,8 @@
+
+
+
+ true
+ <_CheatEngineSdkSourceDirectory Condition="'$(CheatEngineSdkSourceMode)' == 'true'">$([MSBuild]::EnsureTrailingSlash($([System.IO.Path]::GetFullPath('$(CheatEngineSdkSourceRoot)'))))
+ $(_CheatEngineSdkSourceDirectory)libs/CheatEngine.SDK.Engine/CheatEngine.SDK.Engine.csproj
+ $(_CheatEngineSdkSourceDirectory)libs/CheatEngine.SDK.Hosting/CheatEngine.SDK.Hosting.csproj
+ $(CheatEngineSourceLockFileDirectory)/$(MSBuildProjectName).lock.json
2.0.0
3.0.0
[$(CheatEngineSdkVersion),$(CheatEngineSdkUpperBound))
<_CheatEngineClientSupportedSdkMajor>2
+
+
+
diff --git a/eng/SourceSdkIdentity.targets b/eng/SourceSdkIdentity.targets
new file mode 100644
index 0000000..c4521e8
--- /dev/null
+++ b/eng/SourceSdkIdentity.targets
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (?[0-9]+)\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)\+(?[0-9a-f]{40})$");
+ if (!identity.Success || identity.Groups["major"].Value != SupportedMajor)
+ throw new InvalidDataException("The source SDK must declare its supported major, semantic version and full Git commit in AssemblyInformationalVersion.");
+ SdkVersion = identity.Groups["version"].Value;
+ SourceCommit = identity.Groups["commit"].Value;
+ }
+}
+catch (Exception exception)
+{
+ Log.LogError(null, "CHEATENGINECLIENT9050", null, AssemblyPath, 0, 0, 0, 0,
+ "Cannot establish the consumed source SDK identity: {0}", exception.Message);
+ return false;
+}
+ ]]>
+
+
+
+
+ <_CheatEngineClientSourceEngine Include="@(ReferenceCopyLocalPaths)"
+ Condition="'%(ReferenceCopyLocalPaths.Filename)%(ReferenceCopyLocalPaths.Extension)' == 'CheatEngine.SDK.Engine.dll'"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj b/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj
index e46d5da..ee09b8a 100644
--- a/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj
+++ b/libs/CheatEngine.Client.Abstractions/CheatEngine.Client.Abstractions.csproj
@@ -6,7 +6,9 @@
-
+
+
diff --git a/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj b/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj
index 186362c..0042386 100644
--- a/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj
+++ b/libs/CheatEngine.Client.Core/CheatEngine.Client.Core.csproj
@@ -11,7 +11,9 @@
-
+
+
+
@@ -38,7 +40,7 @@
Property functions: https://learn.microsoft.com/visualstudio/msbuild/property-functions
AssemblyMetadata items: https://learn.microsoft.com/dotnet/core/project-sdk/msbuild-props#assemblymetadata
-->
-
+
<_CheatEngineClientSdkLockFile>$(MSBuildThisFileDirectory)packages.lock.json
<_CheatEngineClientSdkLockJson Condition="Exists('$(_CheatEngineClientSdkLockFile)')">$([System.IO.File]::ReadAllText('$(_CheatEngineClientSdkLockFile)'))
<_CheatEngineClientLockedSdkVersion Condition="$([System.Text.RegularExpressions.Regex]::IsMatch('$(_CheatEngineClientSdkLockJson)', '"CheatEngine\.SDK"\s*:\s*\{[^}]*?"resolved"\s*:\s*"[0-9A-Za-z.+-]+"'))">$([System.Text.RegularExpressions.Regex]::Replace('$(_CheatEngineClientSdkLockJson)', '(?s)^.*?"CheatEngine\.SDK"\s*:\s*\{[^}]*?"resolved"\s*:\s*"([0-9A-Za-z.+-]+)".*$', '$1'))
@@ -59,7 +61,7 @@
<_CheatEngineClientEmbedConsumedSdk Condition="'$(_CheatEngineClientConsumedSdkProblem)' == ''">true
-
+
@@ -71,9 +73,11 @@
producing a Core assembly whose package gates would all report Unknown. Design-time builds are exempt.
-->
+ Condition="'$(DesignTimeBuild)' != 'true' and '$(CheatEngineSdkSourceMode)' != 'true'">
+
+
diff --git a/libs/CheatEngine.Client.Core/Infrastructure/ConsumedSdkIdentity.cs b/libs/CheatEngine.Client.Core/Infrastructure/ConsumedSdkIdentity.cs
index 8d976d7..568d5e3 100644
--- a/libs/CheatEngine.Client.Core/Infrastructure/ConsumedSdkIdentity.cs
+++ b/libs/CheatEngine.Client.Core/Infrastructure/ConsumedSdkIdentity.cs
@@ -21,6 +21,11 @@ namespace CheatEngine.Client.Core.Infrastructure;
/// this source.
///
///
+/// With CheatEngineSdkSourceRoot, the build instead reads the referenced Engine assembly's PE metadata.
+/// It records source kind, version, Git commit and supported major, with no NuGet content hash. A source build
+/// requires that exact informational version at runtime; another source revision is not interchangeable.
+///
+///
/// The package gate follows the dependency range the Client packages declare: it is
/// when the SemVer version of the loaded
/// CheatEngine.SDK.Engine informational version (build metadata ignored) has the supported major and is at
@@ -48,6 +53,12 @@ internal sealed class ConsumedSdkIdentity
/// The assembly-metadata key of the consumed CheatEngine.SDK NuGet content hash (SHA-512, base64).
internal const string ContentHashKey = "CheatEngine.Client.ConsumedSdk.ContentHashSha512";
+ /// The assembly-metadata key distinguishing source builds from package builds.
+ internal const string KindKey = "CheatEngine.Client.ConsumedSdk.Kind";
+
+ /// The metadata value for a source project dependency.
+ internal const string SourceKind = "source";
+
/// The assembly-metadata key of the one CheatEngine.SDK major this Client supports.
internal const string SupportedMajorKey = "CheatEngine.Client.ConsumedSdk.SupportedMajor";
@@ -69,8 +80,9 @@ internal sealed class ConsumedSdkIdentity
/// The embedded NuGet content hash of the consumed package.
/// The embedded supported major, as the decimal text of the assembly metadata.
/// The informational version of the loaded CheatEngine.SDK.Engine.
+ /// Whether the consumed identity was read from a source-built Engine assembly.
internal ConsumedSdkIdentity(string? version, string? sourceCommit, string? contentHashSha512,
- string? supportedMajor, string? loadedInformationalVersion)
+ string? supportedMajor, string? loadedInformationalVersion, bool source = false)
{
Version = Normalize(version);
SourceCommit = Normalize(sourceCommit);
@@ -79,6 +91,7 @@ internal ConsumedSdkIdentity(string? version, string? sourceCommit, string? cont
? major
: null;
LoadedInformationalVersion = Normalize(loadedInformationalVersion);
+ IsSource = source;
(PackageGate, IdentityLabel, ExactReviewedIdentity) = Evaluate();
}
@@ -109,6 +122,12 @@ internal string? ContentHashSha512
get;
}
+ /// Gets whether this build consumed an SDK source project instead of a NuGet package.
+ internal bool IsSource
+ {
+ get;
+ }
+
/// Gets the embedded CheatEngine.SDK major this Client supports, or .
internal int? SupportedMajor
{
@@ -121,9 +140,9 @@ internal string? LoadedInformationalVersion
get;
}
- /// Gets whether the version, commit, content hash and supported major were all embedded at build.
- internal bool IsEmbedded => Version is not null && SourceCommit is not null && ContentHashSha512 is not null &&
- SupportedMajor is not null;
+ /// Gets whether the identity is complete; a source build has no NuGet content hash.
+ internal bool IsEmbedded => Version is not null && SourceCommit is not null && SupportedMajor is not null &&
+ (IsSource || ContentHashSha512 is not null);
///
/// Gets the informational version of the reviewed package, or when not embedded.
@@ -189,6 +208,19 @@ internal string IdentityLabel
}
string supported = $"{SupportedMajor}.x at or above {Version}";
+ if (IsSource)
+ {
+ bool exact = string.Equals(LoadedInformationalVersion, ExpectedInformationalVersion, StringComparison.Ordinal);
+ return exact
+ ? (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Satisfied,
+ $"The loaded CheatEngine.SDK.Engine {LoadedInformationalVersion} matches the source build " +
+ "consumed by this Client build; its identity was read from the Engine PE metadata at build time."),
+ "the consumed source build", true)
+ : (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Missing,
+ $"The loaded CheatEngine.SDK.Engine {LoadedInformationalVersion} does not match the source build " +
+ $"consumed by this Client build ({ExpectedInformationalVersion})."), "another source build", false);
+ }
+
if (string.Equals(LoadedInformationalVersion, ExpectedInformationalVersion, StringComparison.Ordinal))
{
return (new ClientCapabilityEvidenceGate(ClientCapabilityEvidenceState.Satisfied,
@@ -216,6 +248,7 @@ private static ConsumedSdkIdentity ReadCurrent()
string? version = null;
string? sourceCommit = null;
string? contentHash = null;
+ bool source = false;
string? supportedMajor = null;
foreach (AssemblyMetadataAttribute metadata in typeof(ConsumedSdkIdentity).Assembly
.GetCustomAttributes())
@@ -231,6 +264,9 @@ private static ConsumedSdkIdentity ReadCurrent()
case ContentHashKey:
contentHash = metadata.Value;
break;
+ case KindKey:
+ source = string.Equals(metadata.Value, SourceKind, StringComparison.Ordinal);
+ break;
case SupportedMajorKey:
supportedMajor = metadata.Value;
break;
@@ -239,7 +275,7 @@ private static ConsumedSdkIdentity ReadCurrent()
string? loaded = typeof(RuntimeInfo).Assembly.GetCustomAttribute()
?.InformationalVersion;
- return new ConsumedSdkIdentity(version, sourceCommit, contentHash, supportedMajor, loaded);
+ return new ConsumedSdkIdentity(version, sourceCommit, contentHash, supportedMajor, loaded, source);
}
private static string? Normalize(string? value)
@@ -369,12 +405,7 @@ private static int CompareIdentifiers(string left, string right)
: string.CompareOrdinal(left, right);
}
- if (leftNumeric != rightNumeric)
- {
- return leftNumeric ? -1 : 1;
- }
-
- return string.CompareOrdinal(left, right);
+ return leftNumeric != rightNumeric ? leftNumeric ? -1 : 1 : string.CompareOrdinal(left, right);
}
///
diff --git a/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj b/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj
index eb0c741..44ad229 100644
--- a/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj
+++ b/libs/CheatEngine.Client.Hosting/CheatEngine.Client.Hosting.csproj
@@ -20,7 +20,9 @@
ReferenceOutputAssembly="false"
PrivateAssets="all"
PackAsAnalyzer="true"/>
-
+
+
diff --git a/libs/CheatEngine.Client.Hosting/README.md b/libs/CheatEngine.Client.Hosting/README.md
index a3e6167..8b52a4b 100644
--- a/libs/CheatEngine.Client.Hosting/README.md
+++ b/libs/CheatEngine.Client.Hosting/README.md
@@ -172,8 +172,8 @@ deployment folder"). `CheatEngineClientSkipPluginProjectValidation=true` skips t
| Code | Severity | Reported when | Fix |
|---|---|---|---|
-| `CECLIENT001` | Error | The plugin project has no direct `PackageReference` to `CheatEngine.SDK` | Reference `CheatEngine.SDK` 2.x directly: its entry-point generator and Lua bridge run only for a direct reference |
-| `CECLIENT002` | Error | The plugin project has no direct `PackageReference` to `CheatEngine.Client`, for example when it references `CheatEngine.Client.Hosting` only | Reference `CheatEngine.Client` directly |
+| `CECLIENT001` | Error | The plugin has neither a direct SDK package reference nor both Engine and Hosting project references below `CheatEngineSdkSourceRoot` | Reference SDK 2.x directly, or use its source projects with explicit generator and bridge imports |
+| `CECLIENT002` | Error | The plugin has neither a direct Client package reference nor the canonical Client facade project reference | Reference `CheatEngine.Client` directly |
| `CECLIENT003` | Error | The compiled assembly declares no `[CheatEnginePlugin]` type derived from `CheatEngineClientPlugin` | Declare exactly one |
| `CECLIENT004` | Error | The compiled assembly declares more than one such type | Keep one plugin type per assembly |
| `CECLIENT005` | Error | `TargetFramework` is not exactly `net10.0`, for example `net10.0-windows` | Target `net10.0` |
@@ -188,7 +188,7 @@ deployment folder"). `CheatEngineClientSkipPluginProjectValidation=true` skips t
| `CECLIENT014` | Error | Deployment: `CheatEngine.Client.Hosting.dll` is not in the build output | Reference `CheatEngine.Client` and keep package assemblies copied to the output |
| `CECLIENT015` | Error | Deployment: `cheatengine-sdk-lua-bridge.dll` is not in the build output, because the direct `CheatEngine.SDK` build asset did not copy it | Reference `CheatEngine.SDK` directly and keep its bridge `Content` item |
| `CECLIENT016` | Error | Deployment: the output holds no managed file, or staging or replacing a destination file failed; the message gives the reason | Check the destination folder and deploy while the plugin is disabled |
-| `CECLIENT017` | Error; Warning with `CheatEngineClientAllowUnsupportedSdk=true` | The plugin resolves a `CheatEngine.SDK` major this release does not support (3.x or later) | Reference `CheatEngine.SDK` 2.x, or a Client release that supports that SDK |
+| `CECLIENT017` | Error; Warning with `CheatEngineClientAllowUnsupportedSdk=true` | The plugin resolves an unsupported SDK major (3.x or later), or a source Engine assembly whose identity cannot be established | Reference SDK 2.x, or a Client release that supports that SDK |
A `CheatEngine.SDK` below 2.0.0 never reaches these checks: the restore fails with `NU1605`. The SDK's own build and
analyzer diagnostics (`CESDK...`) are documented in the
diff --git a/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets
index 3502f1b..189e639 100644
--- a/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets
+++ b/libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets
@@ -20,19 +20,35 @@
+
+ <_CheatEngineClientExpectedEngineSource Condition="'$(CheatEngineSdkSourceRoot)' != ''">$([MSBuild]::NormalizePath('$(CheatEngineSdkSourceRoot)', 'libs', 'CheatEngine.SDK.Engine', 'CheatEngine.SDK.Engine.csproj'))
+ <_CheatEngineClientExpectedHostingSource Condition="'$(CheatEngineSdkSourceRoot)' != ''">$([MSBuild]::NormalizePath('$(CheatEngineSdkSourceRoot)', 'libs', 'CheatEngine.SDK.Hosting', 'CheatEngine.SDK.Hosting.csproj'))
+ <_CheatEngineClientExpectedFacadeSource>$([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)', '../../../src/CheatEngine.Client/CheatEngine.Client.csproj'))
+
<_CheatEngineClientDirectPackageReference Include="@(PackageReference)"/>
<_CheatEngineClientDirectSdkReference Include="@(_CheatEngineClientDirectPackageReference)"
Condition="'%(Identity)' == 'CheatEngine.SDK'"/>
<_CheatEngineClientDirectClientReference Include="@(_CheatEngineClientDirectPackageReference)"
Condition="'%(Identity)' == 'CheatEngine.Client'"/>
+ <_CheatEngineClientDirectSdkEngineSourceReference Include="@(ProjectReference)"
+ Condition="'$(CheatEngineSdkSourceRoot)' != '' and '%(ProjectReference.FullPath)' == '$(_CheatEngineClientExpectedEngineSource)'"/>
+ <_CheatEngineClientDirectSdkHostingSourceReference Include="@(ProjectReference)"
+ Condition="'$(CheatEngineSdkSourceRoot)' != '' and '%(ProjectReference.FullPath)' == '$(_CheatEngineClientExpectedHostingSource)'"/>
+ <_CheatEngineClientDirectClientSourceReference Include="@(ProjectReference)"
+ Condition="'%(ProjectReference.FullPath)' == '$(_CheatEngineClientExpectedFacadeSource)'"/>
-
-
+ <_CheatEngineClientHasDirectSdkSourceReference>false
+ <_CheatEngineClientHasDirectSdkSourceReference Condition="'@(_CheatEngineClientDirectSdkEngineSourceReference)' != '' and '@(_CheatEngineClientDirectSdkHostingSourceReference)' != ''">true
+ <_CheatEngineClientHasDirectClientSourceReference>false
+ <_CheatEngineClientHasDirectClientSourceReference Condition="'@(_CheatEngineClientDirectClientSourceReference)' != ''">true
+
+
-
@@ -71,7 +87,7 @@
<_CheatEngineClientResolvedSdkAsset Remove="@(_CheatEngineClientResolvedSdkAsset)"/>
@@ -80,10 +96,20 @@
<_CheatEngineClientResolvedSdkAsset Include="@(RuntimeCopyLocalItems)"
Condition="'%(RuntimeCopyLocalItems.NuGetPackageId)' == 'CheatEngine.SDK'"/>
+
+ <_CheatEngineClientSourceSdkAssembly Include="@(ReferencePath)"
+ Condition="'$(CheatEngineSdkSourceRoot)' != '' and '%(ReferencePath.Filename)%(ReferencePath.Extension)' == 'CheatEngine.SDK.Engine.dll'"/>
+
+
+
+
<_CheatEngineClientResolvedSdkVersion>@(_CheatEngineClientResolvedSdkAsset->'%(NuGetPackageVersion)'->Distinct())
+ <_CheatEngineClientResolvedSdkVersion Condition="'$(CheatEngineSdkSourceRoot)' != ''">@(_CheatEngineClientSourceSdkIdentity->'%(Version)')
<_CheatEngineClientResolvedSdkMajor>$([System.Text.RegularExpressions.Regex]::Match('$(_CheatEngineClientResolvedSdkVersion)', '^[0-9]+').Value)
<_CheatEngineClientUnsupportedSdk>false
+ <_CheatEngineClientUnsupportedSdk Condition="'$(CheatEngineSdkSourceRoot)' != '' and '$(_CheatEngineClientResolvedSdkMajor)' == ''">true
+ <_CheatEngineClientUnsupportedSdk Condition="'$(CheatEngineSdkSourceRoot)' != '' and '$(_CheatEngineClientResolvedSdkMajor)' != '' and '$(_CheatEngineClientResolvedSdkMajor)' != '$([MSBuild]::Subtract($(_CheatEngineClientSdkUpperMajor), 1))'">true
<_CheatEngineClientUnsupportedSdk Condition="'$(_CheatEngineClientResolvedSdkMajor)' != '' and $(_CheatEngineClientResolvedSdkMajor) >= $(_CheatEngineClientSdkUpperMajor)">true
Runs the build-transitive source-reference profile guard without restoring or compiling a consumer.
+public sealed class SourceConsumerProfileTests
+{
+ private const string ValidationTarget = "CheatEngineClientValidatePluginProjectProfile";
+ private const string HostingTargets = "libs/CheatEngine.Client.Hosting/buildTransitive/CheatEngine.Client.Hosting.targets";
+ private const string ClientFacade = "src/CheatEngine.Client/CheatEngine.Client.csproj";
+
+ [Fact]
+ public async Task CanonicalClientFacadeAndExactSdkSourceProjectsPassAsync()
+ {
+ using TemporaryDirectory directory = new("source-consumer-profile-valid");
+ string sourceRoot = await CreateSdkSourceRootAsync(directory);
+ string project = await CreateConsumerProjectAsync(directory, sourceRoot,
+ [RepositoryLayout.Combine(ClientFacade), EngineProject(sourceRoot), HostingProject(sourceRoot)]);
+
+ DotNetProcessResult result = await RunValidationAsync(project);
+
+ Assert.True(result.ExitCode == 0, result.ToString());
+ Assert.DoesNotContain("CECLIENT001", result.StandardOutput, StringComparison.Ordinal);
+ Assert.DoesNotContain("CECLIENT002", result.StandardOutput, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task MissingClientFacadeFailsWithCECLIENT002Async()
+ {
+ using TemporaryDirectory directory = new("source-consumer-profile-missing-client");
+ string sourceRoot = await CreateSdkSourceRootAsync(directory);
+ string project = await CreateConsumerProjectAsync(directory, sourceRoot,
+ [EngineProject(sourceRoot), HostingProject(sourceRoot)]);
+
+ DotNetProcessResult result = await RunValidationAsync(project);
+
+ Assert.True(result.ExitCode != 0, result.ToString());
+ Assert.Contains("error CECLIENT002", result.StandardOutput, StringComparison.Ordinal);
+ Assert.DoesNotContain("error CECLIENT001", result.StandardOutput, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("CheatEngine.SDK.Engine")]
+ [InlineData("CheatEngine.SDK.Hosting")]
+ public async Task MissingEitherSdkSourceProjectFailsWithCECLIENT001Async(string missingProject)
+ {
+ using TemporaryDirectory directory = new($"source-consumer-profile-missing-{missingProject}");
+ string sourceRoot = await CreateSdkSourceRootAsync(directory);
+ List references = [RepositoryLayout.Combine(ClientFacade), EngineProject(sourceRoot), HostingProject(sourceRoot)];
+ references.RemoveAll(reference => string.Equals(Path.GetFileNameWithoutExtension(reference), missingProject,
+ StringComparison.Ordinal));
+ string project = await CreateConsumerProjectAsync(directory, sourceRoot, references);
+
+ DotNetProcessResult result = await RunValidationAsync(project);
+
+ Assert.True(result.ExitCode != 0, result.ToString());
+ Assert.Contains("error CECLIENT001", result.StandardOutput, StringComparison.Ordinal);
+ Assert.DoesNotContain("error CECLIENT002", result.StandardOutput, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task SameNamedSdkProjectOutsideConfiguredSourceRootFailsWithCECLIENT001Async()
+ {
+ using TemporaryDirectory directory = new("source-consumer-profile-outside-root");
+ string sourceRoot = await CreateSdkSourceRootAsync(directory);
+ string outside = directory.CreateDirectory("outside-source-root");
+ string outsideEngine = await CreateProjectFileAsync(outside, "CheatEngine.SDK.Engine");
+ string project = await CreateConsumerProjectAsync(directory, sourceRoot,
+ [RepositoryLayout.Combine(ClientFacade), outsideEngine, HostingProject(sourceRoot)]);
+
+ DotNetProcessResult result = await RunValidationAsync(project);
+
+ Assert.True(result.ExitCode != 0, result.ToString());
+ Assert.Contains("error CECLIENT001", result.StandardOutput, StringComparison.Ordinal);
+ Assert.DoesNotContain("error CECLIENT002", result.StandardOutput, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(2, true)]
+ [InlineData(1, false)]
+ [InlineData(3, false)]
+ [InlineData(0, false)]
+ public async Task SourceMajorGuardReadsTheResolvedEngineMetadataAsync(int major, bool accepted)
+ {
+ using TemporaryDirectory directory = new("source-consumer-major");
+ string engine = Path.Combine(directory.Path, "CheatEngine.SDK.Engine.dll");
+ if (major != 0)
+ {
+ MetadataBuilder metadata = new();
+ metadata.AddModule(0, metadata.GetOrAddString("CheatEngine.SDK.Engine.dll"),
+ metadata.GetOrAddGuid(Guid.NewGuid()), default, default);
+ metadata.AddAssembly(metadata.GetOrAddString("CheatEngine.SDK.Engine"), new Version(major, 0, 0, 0),
+ default, default, (AssemblyFlags) 0, AssemblyHashAlgorithm.None);
+ metadata.AddTypeDefinition(TypeAttributes.NotPublic, default, metadata.GetOrAddString(""),
+ default, MetadataTokens.FieldDefinitionHandle(1), MetadataTokens.MethodDefinitionHandle(1));
+ ManagedPEBuilder pe = new(new PEHeaderBuilder(imageCharacteristics: Characteristics.ExecutableImage | Characteristics.Dll),
+ new MetadataRootBuilder(metadata), new BlobBuilder(), flags: CorFlags.ILOnly);
+ BlobBuilder image = new();
+ pe.Serialize(image);
+ await File.WriteAllBytesAsync(engine, image.ToArray(), TestContext.Current.CancellationToken);
+ }
+ string reference = major == 0 ? "" : $"";
+ string project = Path.Combine(directory.Path, "SourceMajor.proj");
+ await File.WriteAllTextAsync(project, $"""
+
+
+ true
+ {Escape(directory.Path)}
+
+ {reference}
+
+
+ """, TestContext.Current.CancellationToken);
+
+ DotNetProcessResult result = await DotNetProcess.RunAsync(directory.Path, "msbuild", project,
+ "-t:CheatEngineClientValidateResolvedSdkMajor", "-nologo", "-nodeReuse:false", "-verbosity:minimal");
+
+ if (accepted)
+ {
+ Assert.True(result.ExitCode == 0, result.ToString());
+ }
+ else
+ {
+ Assert.True(result.ExitCode != 0, result.ToString());
+ Assert.Contains("error CECLIENT017", result.StandardOutput, StringComparison.Ordinal);
+ }
+ }
+
+ private static async Task CreateSdkSourceRootAsync(TemporaryDirectory directory)
+ {
+ string root = directory.CreateDirectory("sdk-source-root");
+ await CreateProjectFileAsync(Path.Combine(root, "libs", "CheatEngine.SDK.Engine"), "CheatEngine.SDK.Engine");
+ await CreateProjectFileAsync(Path.Combine(root, "libs", "CheatEngine.SDK.Hosting"), "CheatEngine.SDK.Hosting");
+ return root;
+ }
+
+ private static async Task CreateConsumerProjectAsync(TemporaryDirectory directory, string sourceRoot,
+ IReadOnlyList references)
+ {
+ string project = Path.Combine(directory.Path, "SourceConsumer.proj");
+ string projectReferences = string.Join(Environment.NewLine, references.Select(reference =>
+ $" "));
+ string targets = RepositoryLayout.Combine(HostingTargets);
+ string source = $"""
+
+
+ net10.0
+ 14.0
+ x64
+ true
+ {Escape(sourceRoot)}
+
+
+ {projectReferences}
+
+
+
+ """;
+ await File.WriteAllTextAsync(project, source, new UTF8Encoding(false), TestContext.Current.CancellationToken);
+ return project;
+ }
+
+ private static async Task CreateProjectFileAsync(string directory, string name)
+ {
+ Directory.CreateDirectory(directory);
+ string project = Path.Combine(directory, $"{name}.csproj");
+ await File.WriteAllTextAsync(project, "", new UTF8Encoding(false), TestContext.Current.CancellationToken);
+ return project;
+ }
+
+ private static Task RunValidationAsync(string project)
+ {
+ return DotNetProcess.RunAsync(Path.GetDirectoryName(project)!, "msbuild", project, $"-t:{ValidationTarget}",
+ "-nologo", "-nodeReuse:false", "-verbosity:minimal");
+ }
+
+ private static string EngineProject(string sourceRoot) =>
+ Path.Combine(sourceRoot, "libs", "CheatEngine.SDK.Engine", "CheatEngine.SDK.Engine.csproj");
+
+ private static string HostingProject(string sourceRoot) =>
+ Path.Combine(sourceRoot, "libs", "CheatEngine.SDK.Hosting", "CheatEngine.SDK.Hosting.csproj");
+
+ private static string Escape(string value) => value.Replace("&", "&", StringComparison.Ordinal)
+ .Replace("\"", """, StringComparison.Ordinal);
+}