diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cf9e3e..3b211a54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,8 @@ under [1.0.0](#100---2026-09-20) before upgrading. ## [Unreleased] -- `AddressResolutionOptions.UseHostSymbolTable` is retained only for source and binary compatibility and is rejected by - `EngineInspection.ResolveAddress`; use `EngineInspection.ResolveHostAddress` for host-symbol resolution. +- Removed the obsolete host-symbol compatibility flag from `AddressResolutionOptions`; use + `EngineInspection.ResolveHostAddress` for host-symbol resolution. ## [1.0.0] - 2026-09-20 diff --git a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticDescriptors.cs b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticDescriptors.cs index 372a8724..5a908203 100644 --- a/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticDescriptors.cs +++ b/analyzers/CheatEngine.SDK.Analyzers/Diagnostics/DiagnosticDescriptors.cs @@ -9,226 +9,226 @@ namespace CheatEngine.SDK.Analyzers.Diagnostics; /// internal static class DiagnosticDescriptors { - // One markdown page per rule, named after the identifier. - private const string HelpLinkBase = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/"; - - /// CESDK0001. Message arguments: the class name, then the sentence fragment describing the problem. - public static readonly DiagnosticDescriptor InvalidPluginClass = new( - DiagnosticIds.InvalidPluginClass, - "Plugin class cannot be constructed by the generated entry point", - "Plugin class '{0}' {1}", - DiagnosticCategories.Plugin, - DiagnosticSeverity.Error, - true, - "The entry point generated into a plugin assembly creates the plugin with 'new' from a top-level type of the same assembly. " - + "The class marked [CheatEnginePlugin] must therefore be a non-static, non-abstract, non-generic class that is not nested in a generic type, " - + "derives from CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin, is at least internal at every nesting level, is not file-local, does not take the reserved type name 'CESDK.CESDK', " - + "has a public or internal parameterless constructor that 'new' can call without an object initializer (no required members left unset, no [Obsolete] error) " - + "and carries a non-empty display name. When any of this is violated the generator emits nothing, or emits code that does not compile, and Cheat Engine cannot load the plugin.", - HelpLinkBase + DiagnosticIds.InvalidPluginClass + ".md"); - - /// CESDK0002 (compilation end). Message arguments: the class name, then the number of plugin classes found. - public static readonly DiagnosticDescriptor MultiplePluginClasses = new( - DiagnosticIds.MultiplePluginClasses, - "More than one plugin class in the assembly", - "'{0}' is one of {1} classes marked [CheatEnginePlugin]; a plugin assembly must contain exactly one, because Cheat Engine calls a single entry point per assembly", - DiagnosticCategories.Plugin, - DiagnosticSeverity.Error, - true, - "Cheat Engine loads one plugin per assembly through one fixed entry point. With several classes marked [CheatEnginePlugin] the generator cannot choose, " - + "emits nothing, and Cheat Engine cannot load the plugin. Keep the attribute on exactly one class, or move the other plugins to their own assemblies.", - HelpLinkBase + DiagnosticIds.MultiplePluginClasses + ".md", - WellKnownDiagnosticTags.CompilationEnd); - - /// CESDK0003 (compilation end). Message argument: the missing manual-bootstrap requirement. - public static readonly DiagnosticDescriptor InvalidManualBootstrap = new( - DiagnosticIds.InvalidManualBootstrap, - "Manual Cheat Engine bootstrap is missing or malformed", - "Entry-point generation is disabled, but {0}", - DiagnosticCategories.Plugin, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "When CheatEngineSdkGenerateEntryPoint is false, the assembly itself must provide the exact host entry point: " - + "a static CESDK.CESDK type whose public static CEPluginInitialize(System.IntPtr, int) method returns int. " - + "Cheat Engine looks up that identity by name and does not discover alternatives.", - HelpLinkBase + DiagnosticIds.InvalidManualBootstrap + ".md", - WellKnownDiagnosticTags.CompilationEnd); - - /// CESDK0004 (compilation end). Message argument: the declared namespace. - public static readonly DiagnosticDescriptor ReservedNamespace = new( - DiagnosticIds.ReservedNamespace, - "Plugin assembly declares a namespace under 'CESDK'", - "Namespace '{0}' is 'CESDK' or nested under it in a plugin assembly. Cheat Engine requires the type 'CESDK.CESDK' in every plugin assembly, " - + "so inside that namespace the simple name 'CESDK' binds to that type and a name that starts with 'CESDK.' no longer reaches a namespace you declared under 'CESDK' (CS0426). Use a different root namespace.", - DiagnosticCategories.Plugin, - DiagnosticSeverity.Warning, - true, - "Cheat Engine requires the plugin assembly to contain the type 'CESDK.CESDK', which the entry point generator emits, so the assembly always holds a class named 'CESDK' in the namespace 'CESDK'. " - + "Name lookup walks the enclosing namespaces before the global namespace: in code placed in 'CESDK' or 'CESDK.Something', the simple name 'CESDK' finds that class first, " - + "so a qualified name that starts with 'CESDK.' no longer resolves to a namespace you declared under 'CESDK' (CS0426). Keep plugin code in a namespace that does not start with 'CESDK'. " - + "The SDK itself lives under 'CheatEngine.SDK' and is not affected.", - HelpLinkBase + DiagnosticIds.ReservedNamespace + ".md", - WellKnownDiagnosticTags.CompilationEnd); - - /// CESDK0005 (compilation end). Message argument: the colliding source type. - public static readonly DiagnosticDescriptor GeneratedEntryPointCollision = new( - DiagnosticIds.GeneratedEntryPointCollision, - "Source type collides with the generated Cheat Engine entry point", - "Type '{0}' is declared by user code, but entry-point generation also emits CESDK.CESDK. Remove the type or disable generation and provide the complete manual bootstrap.", - DiagnosticCategories.Plugin, - DiagnosticSeverity.Error, - true, - "A generated plugin entry point always owns the type CESDK.CESDK. A user-authored type with that exact metadata " - + "identity makes the compilation ambiguous or duplicate the host entry point. Either let the generator own it, " - + "or disable generation and implement the complete manual bootstrap contract.", - HelpLinkBase + DiagnosticIds.GeneratedEntryPointCollision + ".md", - WellKnownDiagnosticTags.CompilationEnd); - - /// CESDK1001. Message argument: the enabled-only member called too early. - public static readonly DiagnosticDescriptor RequiresPluginEnabledTooEarly = new( - DiagnosticIds.RequiresPluginEnabledTooEarly, - "Plugin startup code calls an enabled-only API", - "'{0}' requires an enabled plugin and cannot be called from a plugin constructor, field initializer or property initializer", - DiagnosticCategories.Usage, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "Cheat Engine attaches the SDK runtime only after constructing the plugin. An API marked RequiresPluginEnabled " - + "therefore fails before OnEnable, including from instance construction and field or property initializers. Move the " - + "operation into OnEnable or a method OnEnable calls.", - HelpLinkBase + DiagnosticIds.RequiresPluginEnabledTooEarly + ".md"); - - /// CESDK1003. Message argument: the directly borrowed expression. - public static readonly DiagnosticDescriptor DisposeBorrowedValue = new( - DiagnosticIds.DisposeBorrowedValue, - "A Cheat Engine-owned value is being destroyed", - "'{0}' is explicitly marked CEOwned (borrowed) and must not be disposed; only dispose an Owned<T> value you own", - DiagnosticCategories.Usage, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "CEOwned marks a return value, property or parameter as a borrowed view of an object Cheat Engine owns. Calling " - + "Dispose or DisposeAsync directly on that value can leave Cheat Engine with a dangling object. Keep it borrowed, " - + "or obtain an explicit ownership-transfer contract before disposing it.", - HelpLinkBase + DiagnosticIds.DisposeBorrowedValue + ".md"); - - /// CESDK1004. Message argument: the method name. - public static readonly DiagnosticDescriptor UnguardedUnmanagedCallersOnly = new( - DiagnosticIds.UnguardedUnmanagedCallersOnly, - "Exception can escape an [UnmanagedCallersOnly] method", - "An exception can escape '{0}', which native code calls directly: the whole body must be one try statement whose catch-all clause returns a failure value", - DiagnosticCategories.Usage, - DiagnosticSeverity.Warning, - true, - "A managed exception that unwinds out of an [UnmanagedCallersOnly] method terminates the host process (Cheat Engine). " - + "The rule is structural: every top-level statement of the body must be a try statement with a 'catch' or 'catch (System.Exception)' clause without a filter, " - + "a local declaration or return whose value cannot throw, a local function declaration, an empty statement, or a nested block of such statements. " - + "No catch or finally block of such a try statement may contain a throw or a call of a [DoesNotReturn] method other than Environment.FailFast and Environment.Exit.", - HelpLinkBase + DiagnosticIds.UnguardedUnmanagedCallersOnly + ".md"); - - /// CESDK1005. Message argument: the lifecycle method name. - public static readonly DiagnosticDescriptor AsyncPluginLifecycle = new( - DiagnosticIds.AsyncPluginLifecycle, - "Plugin lifecycle callback must not be async void", - "'{0}' is an async void lifecycle callback: its continuation can outlive the plugin enable or disable transition", - DiagnosticCategories.Usage, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "Cheat Engine's enable and disable callbacks are synchronous and the host cannot await async void. A continuation may " - + "run after teardown, lose exceptions, or touch an invalid Lua state. Keep OnEnable and OnDisable synchronous; use a " - + "host-owned, explicitly tracked operation only when the API actually supports asynchronous waiting.", - HelpLinkBase + DiagnosticIds.AsyncPluginLifecycle + ".md"); - - /// CESDK2001. Message argument: the member name. - public static readonly DiagnosticDescriptor UnsafeBlocksRequired = new( - DiagnosticIds.UnsafeBlocksRequired, - "Lua binding needs AllowUnsafeBlocks", - "'{0}' is a Lua function export, but this compilation does not allow unsafe code (AllowUnsafeBlocks); registration thunks need it", - DiagnosticCategories.Generation, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "The generated registration table takes the address of the UnmanagedCallersOnly thunks emitted for LuaFunction, which needs unsafe code. " - + "LuaGlobal bodies do not take function addresses and therefore remain available without AllowUnsafeBlocks. A project that declares a LuaFunction must explicitly set true.", - HelpLinkBase + DiagnosticIds.UnsafeBlocksRequired + ".md"); - - /// CESDK2002. Message arguments: the member name, then the sentence fragment describing the problem. - public static readonly DiagnosticDescriptor InvalidLuaBindingContainingType = new( - DiagnosticIds.InvalidLuaBindingContainingType, - "Type cannot receive a generated Lua binding part", - "'{0}' is a Lua binding, but its containing type {1}", - DiagnosticCategories.Generation, - DiagnosticSeverity.Error, - true, - "The LuaBindings generator adds a generated part to the type that declares a [LuaFunction] or [LuaGlobal] member. " - + "That type, and every type it is nested in, must be a partial, non-generic class or struct that is not a file-local type. " - + "When any of this is violated the generator emits nothing for the member, and this rule names the cause.", - HelpLinkBase + DiagnosticIds.InvalidLuaBindingContainingType + ".md"); - - /// - /// CESDK2003. Message arguments: the method name, then the sentence fragment describing the problem. All reports - /// are local symbol diagnostics. CESDK2005 owns the one compilation-end condition, duplicate Lua names. - /// - public static readonly DiagnosticDescriptor InvalidLuaFunction = new( - DiagnosticIds.InvalidLuaFunction, - "[LuaFunction] method cannot be exported by a generated thunk", - "Lua function '{0}' {1}", - DiagnosticCategories.Generation, - DiagnosticSeverity.Error, - true, - "A method marked [LuaFunction] must be an ordinary, static, non-generic, non-async method with a valid Lua name; parameters are passed by value, none optional or params, " - + "each of a marshalled kind (int, long, float, double, bool, nuint, ReadOnlySpan, string) except an optional leading LuaState; the return type is void or one of the same marshalled kinds. " - + "When any of this is violated the generator emits no thunk for the method, and this rule names the cause.", - HelpLinkBase + DiagnosticIds.InvalidLuaFunction + ".md"); - - /// CESDK2004. Message arguments: the method name, then the sentence fragment describing the problem. - public static readonly DiagnosticDescriptor InvalidLuaGlobal = new( - DiagnosticIds.InvalidLuaGlobal, - "[LuaGlobal] method cannot receive a generated body", - "Lua global binding '{0}' {1}", - DiagnosticCategories.Generation, - DiagnosticSeverity.Error, - true, - "A method marked [LuaGlobal] must be the defining declaration of an ordinary, static, non-generic, non-async partial method with a valid Lua name and no implementing part yet. " - + "Parameters, in order: an optional leading LuaState, then by-value arguments of a marshalled kind, then the results (out parameters of a marshalled kind other than ReadOnlySpan, " - + "or a copy-out pair Span destination, out int written). Any out result makes it the Try form, which must return bool; no result makes it the throwing form, " - + "whose return type is void or a marshalled kind other than ReadOnlySpan. When any of this is violated the generator emits no body for the method, and this rule names the cause.", - HelpLinkBase + DiagnosticIds.InvalidLuaGlobal + ".md"); - - /// CESDK2005 (compilation end). Message arguments: the method and duplicated Lua name. - public static readonly DiagnosticDescriptor DuplicateLuaName = new( - DiagnosticIds.DuplicateLuaName, - "Lua function name is duplicated", - "Lua function '{0}' duplicates the Lua name '{1}' in the same containing type; one registration table cannot bind that name twice", - DiagnosticCategories.Generation, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "Two valid LuaFunction methods of one type cannot export the same Lua global name. The generator deliberately emits " - + "neither thunk so registration order cannot silently choose one. Give one method a distinct Lua name or move it to " - + "another binding type.", - HelpLinkBase + DiagnosticIds.DuplicateLuaName + ".md", - WellKnownDiagnosticTags.CompilationEnd); - - /// CESDK2006. Message arguments: the annotated member and its unsupported shape. - public static readonly DiagnosticDescriptor InvalidLuaAnnotationTarget = new( - DiagnosticIds.InvalidLuaAnnotationTarget, - "Lua annotation target cannot receive generated code", - "Lua annotation on '{0}' cannot be generated: {1}", - DiagnosticCategories.Generation, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "LuaClass, LuaMethod and LuaProperty are declarative generator inputs. Their target must have the exact partial " - + "borrowed-handle or member shape that the generator can implement. The analyzer reports the invalid declaration at " - + "its source location so an unsupported target never silently loses generated code.", - HelpLinkBase + DiagnosticIds.InvalidLuaAnnotationTarget + ".md"); - - /// CESDK2007. Message arguments: the member and the generated identity it collides with. - public static readonly DiagnosticDescriptor GeneratedLuaIdentityCollision = new( - DiagnosticIds.GeneratedLuaIdentityCollision, - "User member collides with a generated Lua binding identity", - "Member '{0}' collides with generated identity '{1}'; rename the user member or change the binding declaration", - DiagnosticCategories.Generation, - DiagnosticSeverity.Error, - isEnabledByDefault: true, - "Generated Lua bindings add required members such as registration methods, thunk methods, cached globals and LuaClass " - + "handle members. A source declaration with the same identity prevents compilation. This rule identifies the user " - + "declaration before generated code is emitted.", - HelpLinkBase + DiagnosticIds.GeneratedLuaIdentityCollision + ".md"); + // One markdown page per rule, named after the identifier. + private const string HelpLinkBase = "https://github.com/CheatEngineNet/CheatEngine.SDK/blob/main/analyzers/docs/"; + + /// CESDK0001. Message arguments: the class name, then the sentence fragment describing the problem. + public static readonly DiagnosticDescriptor InvalidPluginClass = new( + DiagnosticIds.InvalidPluginClass, + "Plugin class cannot be constructed by the generated entry point", + "Plugin class '{0}' {1}", + DiagnosticCategories.Plugin, + DiagnosticSeverity.Error, + true, + "The entry point generated into a plugin assembly creates the plugin with 'new' from a top-level type of the same assembly. " + + "The class marked [CheatEnginePlugin] must therefore be a non-static, non-abstract, non-generic class that is not nested in a generic type, " + + "derives from CheatEngine.SDK.Hosting.Plugin.CheatEnginePlugin, is at least internal at every nesting level, is not file-local, does not take the reserved type name 'CESDK.CESDK', " + + "has a public or internal parameterless constructor that 'new' can call without an object initializer (no required members left unset, no [Obsolete] error) " + + "and carries a non-empty display name. When any of this is violated the generator emits nothing, or emits code that does not compile, and Cheat Engine cannot load the plugin.", + HelpLinkBase + DiagnosticIds.InvalidPluginClass + ".md"); + + /// CESDK0002 (compilation end). Message arguments: the class name, then the number of plugin classes found. + public static readonly DiagnosticDescriptor MultiplePluginClasses = new( + DiagnosticIds.MultiplePluginClasses, + "More than one plugin class in the assembly", + "'{0}' is one of {1} classes marked [CheatEnginePlugin]; a plugin assembly must contain exactly one, because Cheat Engine calls a single entry point per assembly", + DiagnosticCategories.Plugin, + DiagnosticSeverity.Error, + true, + "Cheat Engine loads one plugin per assembly through one fixed entry point. With several classes marked [CheatEnginePlugin] the generator cannot choose, " + + "emits nothing, and Cheat Engine cannot load the plugin. Keep the attribute on exactly one class, or move the other plugins to their own assemblies.", + HelpLinkBase + DiagnosticIds.MultiplePluginClasses + ".md", + WellKnownDiagnosticTags.CompilationEnd); + + /// CESDK0003 (compilation end). Message argument: the missing manual-bootstrap requirement. + public static readonly DiagnosticDescriptor InvalidManualBootstrap = new( + DiagnosticIds.InvalidManualBootstrap, + "Manual Cheat Engine bootstrap is missing or malformed", + "Entry-point generation is disabled, but {0}", + DiagnosticCategories.Plugin, + DiagnosticSeverity.Error, + true, + "When CheatEngineSdkGenerateEntryPoint is false, the assembly itself must provide the exact host entry point: " + + "a static CESDK.CESDK type whose public static CEPluginInitialize(System.IntPtr, int) method returns int. " + + "Cheat Engine looks up that identity by name and does not discover alternatives.", + HelpLinkBase + DiagnosticIds.InvalidManualBootstrap + ".md", + WellKnownDiagnosticTags.CompilationEnd); + + /// CESDK0004 (compilation end). Message argument: the declared namespace. + public static readonly DiagnosticDescriptor ReservedNamespace = new( + DiagnosticIds.ReservedNamespace, + "Plugin assembly declares a namespace under 'CESDK'", + "Namespace '{0}' is 'CESDK' or nested under it in a plugin assembly. Cheat Engine requires the type 'CESDK.CESDK' in every plugin assembly, " + + "so inside that namespace the simple name 'CESDK' binds to that type and a name that starts with 'CESDK.' no longer reaches a namespace you declared under 'CESDK' (CS0426). Use a different root namespace.", + DiagnosticCategories.Plugin, + DiagnosticSeverity.Warning, + true, + "Cheat Engine requires the plugin assembly to contain the type 'CESDK.CESDK', which the entry point generator emits, so the assembly always holds a class named 'CESDK' in the namespace 'CESDK'. " + + "Name lookup walks the enclosing namespaces before the global namespace: in code placed in 'CESDK' or 'CESDK.Something', the simple name 'CESDK' finds that class first, " + + "so a qualified name that starts with 'CESDK.' no longer resolves to a namespace you declared under 'CESDK' (CS0426). Keep plugin code in a namespace that does not start with 'CESDK'. " + + "The SDK itself lives under 'CheatEngine.SDK' and is not affected.", + HelpLinkBase + DiagnosticIds.ReservedNamespace + ".md", + WellKnownDiagnosticTags.CompilationEnd); + + /// CESDK0005 (compilation end). Message argument: the colliding source type. + public static readonly DiagnosticDescriptor GeneratedEntryPointCollision = new( + DiagnosticIds.GeneratedEntryPointCollision, + "Source type collides with the generated Cheat Engine entry point", + "Type '{0}' is declared by user code, but entry-point generation also emits CESDK.CESDK. Remove the type or disable generation and provide the complete manual bootstrap.", + DiagnosticCategories.Plugin, + DiagnosticSeverity.Error, + true, + "A generated plugin entry point always owns the type CESDK.CESDK. A user-authored type with that exact metadata " + + "identity makes the compilation ambiguous or duplicate the host entry point. Either let the generator own it, " + + "or disable generation and implement the complete manual bootstrap contract.", + HelpLinkBase + DiagnosticIds.GeneratedEntryPointCollision + ".md", + WellKnownDiagnosticTags.CompilationEnd); + + /// CESDK1001. Message argument: the enabled-only member called too early. + public static readonly DiagnosticDescriptor RequiresPluginEnabledTooEarly = new( + DiagnosticIds.RequiresPluginEnabledTooEarly, + "Plugin startup code calls an enabled-only API", + "'{0}' requires an enabled plugin and cannot be called from a plugin constructor, field initializer or property initializer", + DiagnosticCategories.Usage, + DiagnosticSeverity.Error, + true, + "Cheat Engine attaches the SDK runtime only after constructing the plugin. An API marked RequiresPluginEnabled " + + "therefore fails before OnEnable, including from instance construction and field or property initializers. Move the " + + "operation into OnEnable or a method OnEnable calls.", + HelpLinkBase + DiagnosticIds.RequiresPluginEnabledTooEarly + ".md"); + + /// CESDK1003. Message argument: the directly borrowed expression. + public static readonly DiagnosticDescriptor DisposeBorrowedValue = new( + DiagnosticIds.DisposeBorrowedValue, + "A Cheat Engine-owned value is being destroyed", + "'{0}' is explicitly marked CEOwned (borrowed) and must not be disposed; only dispose an Owned<T> value you own", + DiagnosticCategories.Usage, + DiagnosticSeverity.Error, + true, + "CEOwned marks a return value, property or parameter as a borrowed view of an object Cheat Engine owns. Calling " + + "Dispose or DisposeAsync directly on that value can leave Cheat Engine with a dangling object. Keep it borrowed, " + + "or obtain an explicit ownership-transfer contract before disposing it.", + HelpLinkBase + DiagnosticIds.DisposeBorrowedValue + ".md"); + + /// CESDK1004. Message argument: the method name. + public static readonly DiagnosticDescriptor UnguardedUnmanagedCallersOnly = new( + DiagnosticIds.UnguardedUnmanagedCallersOnly, + "Exception can escape an [UnmanagedCallersOnly] method", + "An exception can escape '{0}', which native code calls directly: the whole body must be one try statement whose catch-all clause returns a failure value", + DiagnosticCategories.Usage, + DiagnosticSeverity.Warning, + true, + "A managed exception that unwinds out of an [UnmanagedCallersOnly] method terminates the host process (Cheat Engine). " + + "The rule is structural: every top-level statement of the body must be a try statement with a 'catch' or 'catch (System.Exception)' clause without a filter, " + + "a local declaration or return whose value cannot throw, a local function declaration, an empty statement, or a nested block of such statements. " + + "No catch or finally block of such a try statement may contain a throw or a call of a [DoesNotReturn] method other than Environment.FailFast and Environment.Exit.", + HelpLinkBase + DiagnosticIds.UnguardedUnmanagedCallersOnly + ".md"); + + /// CESDK1005. Message argument: the lifecycle method name. + public static readonly DiagnosticDescriptor AsyncPluginLifecycle = new( + DiagnosticIds.AsyncPluginLifecycle, + "Plugin lifecycle callback must not be async void", + "'{0}' is an async void lifecycle callback: its continuation can outlive the plugin enable or disable transition", + DiagnosticCategories.Usage, + DiagnosticSeverity.Error, + true, + "Cheat Engine's enable and disable callbacks are synchronous and the host cannot await async void. A continuation may " + + "run after teardown, lose exceptions, or touch an invalid Lua state. Keep OnEnable and OnDisable synchronous; use a " + + "host-owned, explicitly tracked operation only when the API actually supports asynchronous waiting.", + HelpLinkBase + DiagnosticIds.AsyncPluginLifecycle + ".md"); + + /// CESDK2001. Message argument: the member name. + public static readonly DiagnosticDescriptor UnsafeBlocksRequired = new( + DiagnosticIds.UnsafeBlocksRequired, + "Lua binding needs AllowUnsafeBlocks", + "'{0}' is a Lua function export, but this compilation does not allow unsafe code (AllowUnsafeBlocks); registration thunks need it", + DiagnosticCategories.Generation, + DiagnosticSeverity.Error, + true, + "The generated registration table takes the address of the UnmanagedCallersOnly thunks emitted for LuaFunction, which needs unsafe code. " + + "LuaGlobal bodies do not take function addresses and therefore remain available without AllowUnsafeBlocks. A project that declares a LuaFunction must explicitly set true.", + HelpLinkBase + DiagnosticIds.UnsafeBlocksRequired + ".md"); + + /// CESDK2002. Message arguments: the member name, then the sentence fragment describing the problem. + public static readonly DiagnosticDescriptor InvalidLuaBindingContainingType = new( + DiagnosticIds.InvalidLuaBindingContainingType, + "Type cannot receive a generated Lua binding part", + "'{0}' is a Lua binding, but its containing type {1}", + DiagnosticCategories.Generation, + DiagnosticSeverity.Error, + true, + "The LuaBindings generator adds a generated part to the type that declares a [LuaFunction] or [LuaGlobal] member. " + + "That type, and every type it is nested in, must be a partial, non-generic class or struct that is not a file-local type. " + + "When any of this is violated the generator emits nothing for the member, and this rule names the cause.", + HelpLinkBase + DiagnosticIds.InvalidLuaBindingContainingType + ".md"); + + /// + /// CESDK2003. Message arguments: the method name, then the sentence fragment describing the problem. All reports + /// are local symbol diagnostics. CESDK2005 owns the one compilation-end condition, duplicate Lua names. + /// + public static readonly DiagnosticDescriptor InvalidLuaFunction = new( + DiagnosticIds.InvalidLuaFunction, + "[LuaFunction] method cannot be exported by a generated thunk", + "Lua function '{0}' {1}", + DiagnosticCategories.Generation, + DiagnosticSeverity.Error, + true, + "A method marked [LuaFunction] must be an ordinary, static, non-generic, non-async method with a valid Lua name; parameters are passed by value, none optional or params, " + + "each of a marshalled kind (int, long, float, double, bool, nuint, ReadOnlySpan, string) except an optional leading LuaState; the return type is void or one of the same marshalled kinds. " + + "When any of this is violated the generator emits no thunk for the method, and this rule names the cause.", + HelpLinkBase + DiagnosticIds.InvalidLuaFunction + ".md"); + + /// CESDK2004. Message arguments: the method name, then the sentence fragment describing the problem. + public static readonly DiagnosticDescriptor InvalidLuaGlobal = new( + DiagnosticIds.InvalidLuaGlobal, + "[LuaGlobal] method cannot receive a generated body", + "Lua global binding '{0}' {1}", + DiagnosticCategories.Generation, + DiagnosticSeverity.Error, + true, + "A method marked [LuaGlobal] must be the defining declaration of an ordinary, static, non-generic, non-async partial method with a valid Lua name and no implementing part yet. " + + "Parameters, in order: an optional leading LuaState, then by-value arguments of a marshalled kind, then the results (out parameters of a marshalled kind other than ReadOnlySpan, " + + "or a copy-out pair Span destination, out int written). Any out result makes it the Try form, which must return bool; no result makes it the throwing form, " + + "whose return type is void or a marshalled kind other than ReadOnlySpan. When any of this is violated the generator emits no body for the method, and this rule names the cause.", + HelpLinkBase + DiagnosticIds.InvalidLuaGlobal + ".md"); + + /// CESDK2005 (compilation end). Message arguments: the method and duplicated Lua name. + public static readonly DiagnosticDescriptor DuplicateLuaName = new( + DiagnosticIds.DuplicateLuaName, + "Lua function name is duplicated", + "Lua function '{0}' duplicates the Lua name '{1}' in the same containing type; one registration table cannot bind that name twice", + DiagnosticCategories.Generation, + DiagnosticSeverity.Error, + true, + "Two valid LuaFunction methods of one type cannot export the same Lua global name. The generator deliberately emits " + + "neither thunk so registration order cannot silently choose one. Give one method a distinct Lua name or move it to " + + "another binding type.", + HelpLinkBase + DiagnosticIds.DuplicateLuaName + ".md", + WellKnownDiagnosticTags.CompilationEnd); + + /// CESDK2006. Message arguments: the annotated member and its unsupported shape. + public static readonly DiagnosticDescriptor InvalidLuaAnnotationTarget = new( + DiagnosticIds.InvalidLuaAnnotationTarget, + "Lua annotation target cannot receive generated code", + "Lua annotation on '{0}' cannot be generated: {1}", + DiagnosticCategories.Generation, + DiagnosticSeverity.Error, + true, + "LuaClass, LuaMethod and LuaProperty are declarative generator inputs. Their target must have the exact partial " + + "borrowed-handle or member shape that the generator can implement. The analyzer reports the invalid declaration at " + + "its source location so an unsupported target never silently loses generated code.", + HelpLinkBase + DiagnosticIds.InvalidLuaAnnotationTarget + ".md"); + + /// CESDK2007. Message arguments: the member and the generated identity it collides with. + public static readonly DiagnosticDescriptor GeneratedLuaIdentityCollision = new( + DiagnosticIds.GeneratedLuaIdentityCollision, + "User member collides with a generated Lua binding identity", + "Member '{0}' collides with generated identity '{1}'; rename the user member or change the binding declaration", + DiagnosticCategories.Generation, + DiagnosticSeverity.Error, + true, + "Generated Lua bindings add required members such as registration methods, thunk methods, cached globals and LuaClass " + + "handle members. A source declaration with the same identity prevents compilation. This rule identifies the user " + + "declaration before generated code is emitted.", + HelpLinkBase + DiagnosticIds.GeneratedLuaIdentityCollision + ".md"); } diff --git a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs index 3e977645..58b0e44d 100644 --- a/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs +++ b/libs/CheatEngine.SDK.Abi/Native/ClassicDebugEventDispatcher.cs @@ -21,7 +21,8 @@ namespace CheatEngine.SDK.Abi.Native; /// ContinueDebugEvent cell is not yet a qualified SDK ABI surface. /// /// -internal sealed unsafe class ClassicDebugEventDispatcher : IDisposable +internal sealed unsafe class + ClassicDebugEventDispatcher : IDisposable // NOSONAR: this type is the native debug-event ABI boundary. { private static readonly Lock SRegistrationGate = new(); private static ClassicDebugEventDispatcher? s_active; @@ -152,64 +153,29 @@ internal static ClassicDebugEventRegistrationStatus TryRegister( /// internal ClassicDebugEventReleaseStatus TryRelease() { - if (ReferenceEquals(t_dispatcher, this)) + if (!TryBeginRelease(out ClassicDebugEventReleaseStatus releaseStatus)) { - return ClassicDebugEventReleaseStatus.CallbackIsExecuting; + return releaseStatus; } - lock (_gate) - { - if (_released) - { - return ClassicDebugEventReleaseStatus.Released; - } - - if (_releaseInProgress) - { - return ClassicDebugEventReleaseStatus.ReleaseInProgress; - } - - _acceptingCallbacks = false; - _releaseInProgress = true; - } - - if (!TryUnregister()) - { - return ClassicDebugEventReleaseStatus.UnregisterUnconfirmed; - } - - CompleteRelease(); - return ClassicDebugEventReleaseStatus.Released; - } - - private bool TryUnregister() - { try { - if (_unregisterFunction(_pluginId, _functionId).IsTrue) + if (!_unregisterFunction(_pluginId, _functionId).IsTrue) { - return true; + EndUnconfirmedReleaseAttempt(); + return ClassicDebugEventReleaseStatus.UnregisterUnconfirmed; } } catch (Exception) { - // The host can fail without throwing or can throw from the unmanaged call. + EndUnconfirmedReleaseAttempt(); + return ClassicDebugEventReleaseStatus.UnregisterUnconfirmed; } - EndUnconfirmedReleaseAttempt(); - return false; - } - - private void CompleteRelease() - { - _callbacksDrained.Wait(); - lock (SRegistrationGate) - { - if (ReferenceEquals(s_active, this)) - { - s_active = null; - } - } + // Unregistration has already closed admission. The drain is therefore deliberately uninterruptible: allowing + // cancellation here would release the managed callback root while a native callback could still be in flight. + _callbacksDrained.Wait(CancellationToken.None); + ClearActiveRegistration(this); DebugEventPluginInit* initialization; lock (_gate) @@ -228,6 +194,7 @@ private void CompleteRelease() } _callbacksDrained.Dispose(); + return ClassicDebugEventReleaseStatus.Released; } private static int Dispatch(void* nativeEvent) @@ -239,13 +206,9 @@ private static int Dispatch(void* nativeEvent) return 0; } - ClassicDebugEventDispatcher? dispatcher; - lock (SRegistrationGate) - { - dispatcher = s_active; - } - - if (dispatcher is null || !dispatcher.TryEnterCallback(out DebugEventDecisionHandler handler, + if (!TryEnterActiveCallback( + out ClassicDebugEventDispatcher? dispatcher, + out DebugEventDecisionHandler handler, out BoundedDebugEventObservationBuffer? observations)) { return 0; @@ -255,7 +218,33 @@ private static int Dispatch(void* nativeEvent) t_dispatcher = dispatcher; try { - return InvokeHandler(dispatcher, handler, observations, nativeEvent); + DebugEventHeader header = Unsafe.ReadUnaligned(nativeEvent); + DebugEventObservation observation = new( + Interlocked.Increment(ref s_nextSequenceNumber), + header.EventCode, + header.ProcessId, + header.ThreadId); + + // This is a bounded copy, never a continuation path. It cannot call a consumer or await work. + observations?.TryPublish(in observation); + + DebugEventDecision decision; + try + { + decision = handler(in observation); + } + catch (Exception) + { + Interlocked.Increment(ref dispatcher._callbackFailureCount); + return 0; + } + + if (decision is not DebugEventDecision.ContinueWithCheatEngine) + { + Interlocked.Increment(ref dispatcher._unsupportedContinuationRequestCount); + } + + return 0; } catch (Exception) { @@ -270,39 +259,57 @@ private static int Dispatch(void* nativeEvent) } } - private static int InvokeHandler( - ClassicDebugEventDispatcher dispatcher, - DebugEventDecisionHandler handler, - BoundedDebugEventObservationBuffer? observations, - void* nativeEvent) + private bool TryBeginRelease(out ClassicDebugEventReleaseStatus releaseStatus) { - DebugEventHeader header = Unsafe.ReadUnaligned(nativeEvent); - DebugEventObservation observation = new( - Interlocked.Increment(ref s_nextSequenceNumber), - header.EventCode, - header.ProcessId, - header.ThreadId); - - // This is a bounded copy, never a continuation path. It cannot call a consumer or await work. - observations?.TryPublish(in observation); + if (ReferenceEquals(t_dispatcher, this)) + { + releaseStatus = ClassicDebugEventReleaseStatus.CallbackIsExecuting; + return false; + } - DebugEventDecision decision; - try + lock (_gate) { - decision = handler(in observation); + if (_released) + { + releaseStatus = ClassicDebugEventReleaseStatus.Released; + return false; + } + + if (_releaseInProgress) + { + releaseStatus = ClassicDebugEventReleaseStatus.ReleaseInProgress; + return false; + } + + _acceptingCallbacks = false; + _releaseInProgress = true; } - catch (Exception) + + releaseStatus = default; + return true; + } + + private static bool TryEnterActiveCallback( + out ClassicDebugEventDispatcher dispatcher, + out DebugEventDecisionHandler handler, + out BoundedDebugEventObservationBuffer? observations) + { + ClassicDebugEventDispatcher? active; + lock (SRegistrationGate) { - Interlocked.Increment(ref dispatcher._callbackFailureCount); - return 0; + active = s_active; } - if (decision is not DebugEventDecision.ContinueWithCheatEngine) + if (active is null) { - Interlocked.Increment(ref dispatcher._unsupportedContinuationRequestCount); + dispatcher = null!; + handler = null!; + observations = null; + return false; } - return 0; + dispatcher = active; + return dispatcher.TryEnterCallback(out handler, out observations); } private bool TryEnterCallback( @@ -366,13 +373,7 @@ private void AbandonFailedRegistration() _releaseInProgress = false; } - lock (SRegistrationGate) - { - if (ReferenceEquals(s_active, this)) - { - s_active = null; - } - } + ClearActiveRegistration(this); if (initialization is not null) { @@ -382,6 +383,17 @@ private void AbandonFailedRegistration() _callbacksDrained.Dispose(); } + private static void ClearActiveRegistration(ClassicDebugEventDispatcher dispatcher) + { + lock (SRegistrationGate) + { + if (ReferenceEquals(s_active, dispatcher)) + { + s_active = null; + } + } + } + private void EndUnconfirmedReleaseAttempt() { lock (_gate) diff --git a/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs b/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs index c218cc2e..78da8071 100644 --- a/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs +++ b/libs/CheatEngine.SDK.Engine/AddressList/AddressListMutations.cs @@ -120,34 +120,13 @@ public static MemoryRecordMutationOutcome SetParent(MemoryRecordId recordId, Mem bool mutationStarted = false; try { - MemoryRecordMutationOutcome preflight = TryGetCurrentList(state, out AddressList list); + MemoryRecordMutationOutcome preflight = TryPrepareParentAssignment(state, recordId, parentId, + traversalLimit, out MemoryRecord child, out MemoryRecord parent); if (preflight.Problem != MemoryRecordMutationProblem.None) { return preflight; } - preflight = TryResolveRecord(state, list, recordId, false, out MemoryRecord child); - if (preflight.Problem != MemoryRecordMutationProblem.None) - { - return preflight; - } - - MemoryRecord parent = default; - if (parentId.HasValue) - { - preflight = TryResolveRecord(state, list, parentId.Value, true, out parent); - if (preflight.Problem != MemoryRecordMutationProblem.None) - { - return preflight; - } - - preflight = ValidateParentChain(state, recordId, parent, traversalLimit); - if (preflight.Problem != MemoryRecordMutationProblem.None) - { - return preflight; - } - } - if (LuaRuntime.CurrentStateIdentity != identity) { return NotAttempted(MemoryRecordMutationProblem.GlobalUnavailable); @@ -179,6 +158,38 @@ public static MemoryRecordMutationOutcome SetParent(MemoryRecordId recordId, Mem } } + private static MemoryRecordMutationOutcome TryPrepareParentAssignment(LuaState state, MemoryRecordId recordId, + MemoryRecordId? parentId, MemoryRecordParentTraversalLimit traversalLimit, out MemoryRecord child, + out MemoryRecord parent) + { + child = default; + parent = default; + MemoryRecordMutationOutcome preflight = TryGetCurrentList(state, out AddressList list); + if (preflight.Problem != MemoryRecordMutationProblem.None) + { + return preflight; + } + + preflight = TryResolveRecord(state, list, recordId, false, out child); + if (preflight.Problem != MemoryRecordMutationProblem.None) + { + return preflight; + } + + if (!parentId.HasValue) + { + return Completed(); + } + + preflight = TryResolveRecord(state, list, parentId.Value, true, out parent); + if (preflight.Problem != MemoryRecordMutationProblem.None) + { + return preflight; + } + + return ValidateParentChain(state, recordId, parent, traversalLimit); + } + private static MemoryRecordMutationOutcome TryResolveRecord(LuaState state, AddressList list, MemoryRecordId id, bool isParent, out MemoryRecord record) { diff --git a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs index e1e90c39..6041aec6 100644 --- a/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs +++ b/libs/CheatEngine.SDK.Engine/Assembly/AutoAssemblerPatcher.cs @@ -99,6 +99,30 @@ internal static bool TryApplyCore(string script, [NotNullWhen(true)] out AutoAss TargetSelection.CreateUnavailableCheck(targetObservation)); } + if (!TryApplyScript(script, state, out int disableInfoIndex)) + { + patch = null; + return false; + } + + // Retain the original table on the stack while the copy is rooted. A protected ref failure consumes only the + // copy and leaves the original table as the one remaining authority for a direct, target-checked disable. + LuaRef disableInfo = TrackDisableInfo(script, state, disableInfoIndex, disableInfoTracker, targetObservation); + try + { + patch = patchFactory(script, disableInfo, targetObservation.Incarnation.GetValueOrDefault()); + return true; + } + catch (Exception exception) + { + TargetReleaseOutcome cleanupOutcome = CompensateFailedPublication(script, disableInfo, + targetObservation.Incarnation.GetValueOrDefault()); + throw new EngineResourceHandoffException(ApplyOperation, cleanupOutcome, exception); + } + } + + private static bool TryApplyScript(string script, LuaState state, out int disableInfoIndex) + { PushAutoAssemble(state, ApplyOperation); StringMarshaller.Push(state, script); LuaStatus status = state.TryCall(1, 2); @@ -114,7 +138,7 @@ internal static bool TryApplyCore(string script, [NotNullWhen(true)] out AutoAss if (!state.ToBoolean(-2)) { - patch = null; + disableInfoIndex = 0; return false; } @@ -123,18 +147,23 @@ internal static bool TryApplyCore(string script, [NotNullWhen(true)] out AutoAss ThrowUnexpectedResult(ApplyOperation, "a disable-info table on success", state.TypeOf(-1)); } - // Retain the original table on the stack while the copy is rooted. A protected ref failure consumes only the - // copy and leaves the original table as the one remaining authority for a direct, target-checked disable. - int disableInfoIndex = state.AbsoluteIndex(-1); + disableInfoIndex = state.AbsoluteIndex(-1); + return true; + } + + private static LuaRef TrackDisableInfo(string script, LuaState state, int disableInfoIndex, + AutoAssemblerDisableInfoTracker disableInfoTracker, TargetSelectionObservation targetObservation) + { state.PushValue(disableInfoIndex); - LuaRef disableInfo; try { - disableInfo = disableInfoTracker(state); + LuaRef disableInfo = disableInfoTracker(state); if (disableInfo is null) { throw new InvalidOperationException("The disable-info tracker returned no reference."); } + + return disableInfo; } catch (Exception exception) { @@ -143,18 +172,6 @@ internal static bool TryApplyCore(string script, [NotNullWhen(true)] out AutoAss targetObservation.Incarnation.GetValueOrDefault()); throw new EngineResourceHandoffException(ApplyOperation, cleanupOutcome, exception); } - - try - { - patch = patchFactory(script, disableInfo, targetObservation.Incarnation.GetValueOrDefault()); - return true; - } - catch (Exception exception) - { - TargetReleaseOutcome cleanupOutcome = CompensateFailedPublication(script, disableInfo, - targetObservation.Incarnation.GetValueOrDefault()); - throw new EngineResourceHandoffException(ApplyOperation, cleanupOutcome, exception); - } } // The owner always routes cleanup through this method. Keeping the LuaRef release in its finally block prevents a diff --git a/libs/CheatEngine.SDK.Engine/Inspection/AddressResolutionOptions.cs b/libs/CheatEngine.SDK.Engine/Inspection/AddressResolutionOptions.cs index bc8b3b5a..7681068e 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/AddressResolutionOptions.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/AddressResolutionOptions.cs @@ -1,24 +1,16 @@ -using System; - namespace CheatEngine.SDK.Engine.Inspection; /// Controls the optional shallow argument of Cheat Engine's getAddressSafe. /// /// Target and host symbol spaces have separate result types. is forwarded without managed /// reinterpretation; always queries the target table and -/// always queries the host table. The two-Boolean constructor -/// and property remain only as an obsolete source and binary compatibility -/// shape; passing that legacy host flag to is rejected so an old -/// positional call cannot silently query a different address space. +/// always queries the host table. /// public readonly record struct AddressResolutionOptions { - /// Creates options while retaining the released two-Boolean constructor shape. - public AddressResolutionOptions(bool UseHostSymbolTable = false, bool Shallow = false) + /// Creates options for the optional CE shallow argument. + public AddressResolutionOptions(bool Shallow = false) { - #pragma warning disable CS0618 - this.UseHostSymbolTable = UseHostSymbolTable; - #pragma warning restore CS0618 this.Shallow = Shallow; } @@ -28,24 +20,4 @@ public bool Shallow get; init; } - - /// Gets the removed CE local flag retained for source and binary compatibility. - [Obsolete("Use EngineInspection.ResolveHostAddress for host-symbol resolution; ResolveAddress rejects this flag.", - false)] - public bool UseHostSymbolTable - { - get; - init; - } - - #pragma warning disable CS0618 - internal bool HostSymbolTableRequested => UseHostSymbolTable; - #pragma warning restore CS0618 - - /// Deconstructs the compatibility shape used by the released 1.0.0 API. - public void Deconstruct(out bool UseHostSymbolTable, out bool Shallow) - { - UseHostSymbolTable = HostSymbolTableRequested; - Shallow = this.Shallow; - } } diff --git a/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs b/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs index ab90da75..e081e23c 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/EngineInspection.cs @@ -191,11 +191,6 @@ public static InspectionStatus ResolveAddress(SymbolExpression expression, Addre out Address address) { ValidateSymbolExpression(expression); - if (options.HostSymbolTableRequested) - { - throw new ArgumentException( - "Host-symbol resolution must use EngineInspection.ResolveHostAddress.", nameof(options)); - } using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); LuaState state = operation.State; diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationHandoffException.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationHandoffException.cs new file mode 100644 index 00000000..1464bdc0 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationHandoffException.cs @@ -0,0 +1,46 @@ +using System; + +using CheatEngine.SDK.Engine.Errors; + +namespace CheatEngine.SDK.Engine.Inspection; + +/// +/// Represents a managed lease-publication failure after Cheat Engine registered a symbol and the SDK made its one +/// permitted compensation attempt. +/// +/// +/// The managed publication failure remains . +/// separately records whether the single coordinator-qualified unregister was confirmed, could not begin, or +/// remains indeterminate; this exception does not provide a way to retry it. +/// +public sealed class SymbolRegistrationHandoffException : EngineException +{ + /// + /// Initializes a handoff failure with the result of the single compensation attempt and its managed publication + /// cause. + /// + /// The cleanup result observed after lease publication failed. + /// The managed failure that prevented lease publication. + public SymbolRegistrationHandoffException(SymbolRegistrationReleaseOutcome cleanupOutcome, + Exception? innerException) + : base(CreateMessage(cleanupOutcome), innerException) + { + CleanupOutcome = cleanupOutcome; + } + + /// Gets the factual result of the one compensation attempt. + public SymbolRegistrationReleaseOutcome CleanupOutcome + { + get; + } + + /// + public override EngineFailureKind Kind => EngineFailureKind.BindingFailure; + + private static string CreateMessage(SymbolRegistrationReleaseOutcome cleanupOutcome) + { + return + "Cheat Engine registered a symbol before its managed cleanup lease could be published; compensation ended as " + + cleanupOutcome.Kind + "."; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLeaseFactory.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLeaseFactory.cs new file mode 100644 index 00000000..68f7ce70 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLeaseFactory.cs @@ -0,0 +1,7 @@ +using CheatEngine.SDK.Lua.Runtime; + +namespace CheatEngine.SDK.Engine.Inspection; + +/// Internal lease-construction seam used only to test managed publication failures after CE registration. +internal delegate SymbolRegistrationLease SymbolRegistrationLeaseFactory(SymbolName name, + SymbolRegistrationOptions options, LuaStateIdentity identity); diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLeasePublisher.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLeasePublisher.cs new file mode 100644 index 00000000..9adf0d95 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistrationLeasePublisher.cs @@ -0,0 +1,4 @@ +namespace CheatEngine.SDK.Engine.Inspection; + +/// Internal lease-publication seam used only to test managed publication failures after CE registration. +internal delegate void SymbolRegistrationLeasePublisher(SymbolName name, SymbolRegistrationLease lease); diff --git a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs index bdc64b9b..b77904e1 100644 --- a/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs +++ b/libs/CheatEngine.SDK.Engine/Inspection/SymbolRegistry.cs @@ -97,15 +97,33 @@ public static LuaOperationStatus Unregister(SymbolName name) /// The protected registration status and a lease only on successful registration. /// is default or otherwise invalid. /// The plugin is not enabled or the calling thread has no Lua state. + /// + /// CE registered the name but managed lease construction or publication failed; the exception records the one + /// coordinator-qualified compensation attempt. + /// /// /// The lease prevents older leases from unregistering a newer registration made through this coordinator. CE has - /// no registration token, so this method makes no claim about replacements made outside that coordinator. + /// no registration token, so this method makes no claim about replacements made outside that coordinator. A + /// successful CE registration is compensated once if its managed lease cannot be published; callers must inspect + /// rather than retrying by name. /// [RequiresPluginEnabled] public static SymbolRegistrationAcquireOutcome TryRegisterOwned(SymbolName name, Address address, SymbolRegistrationOptions options = default) + { + return TryRegisterOwnedCore(name, address, options, CreateLease, PublishLease); + } + + // The seams are internal test infrastructure. They make the otherwise exceptional interval between a successful CE + // registration and managed lease publication deterministic, without allowing consumers to choose a different + // ownership or cleanup policy. + internal static SymbolRegistrationAcquireOutcome TryRegisterOwnedCore(SymbolName name, Address address, + SymbolRegistrationOptions options, SymbolRegistrationLeaseFactory leaseFactory, + SymbolRegistrationLeasePublisher leasePublisher) { ValidateName(name); + ArgumentNullException.ThrowIfNull(leaseFactory); + ArgumentNullException.ThrowIfNull(leasePublisher); lock (SOwnedRegistrationGate) { LuaStateIdentity identity = LuaRuntime.CurrentStateIdentity; @@ -127,9 +145,22 @@ public static SymbolRegistrationAcquireOutcome TryRegisterOwned(SymbolName name, } SupersedeCurrentLease(name); - SymbolRegistrationLease lease = new(name, options, identity); - SOwnedRegistrations.Add(name, lease); - return new SymbolRegistrationAcquireOutcome(status, lease); + try + { + SymbolRegistrationLease lease = leaseFactory(name, options, identity); + if (lease is null) + { + throw new InvalidOperationException("The symbol-registration lease factory returned no lease."); + } + + leasePublisher(name, lease); + return new SymbolRegistrationAcquireOutcome(status, lease); + } + catch (Exception exception) + { + SymbolRegistrationReleaseOutcome cleanupOutcome = CompensateFailedPublication(name, identity); + throw new SymbolRegistrationHandoffException(cleanupOutcome, exception); + } } } @@ -153,10 +184,7 @@ internal static SymbolRegistrationReleaseOutcome ReleaseOwned(SymbolRegistration if (!LuaRuntime.IsAttached || lease.Identity != LuaRuntime.CurrentStateIdentity) { - RemoveCurrentLease(lease); - lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.StaleRuntime); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, - LuaOperationStatus.Success); + return MarkStaleRuntime(lease); } if (!SOwnedRegistrations.TryGetValue(lease.Name, out SymbolRegistrationLease? current) || @@ -167,47 +195,63 @@ internal static SymbolRegistrationReleaseOutcome ReleaseOwned(SymbolRegistration LuaOperationStatus.Success); } - LuaOperationStatus status; - try - { - status = UnregisterCore(lease.Name); - } - catch (InvalidOperationException) when (!LuaRuntime.IsAttached || - lease.Identity != LuaRuntime.CurrentStateIdentity) - { - RemoveCurrentLease(lease); - lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.StaleRuntime); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, - LuaOperationStatus.Success); - } - catch (InvalidOperationException) - { - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, - LuaOperationStatus.GlobalUnavailable); - } - - if (status.IsSuccess) - { - RemoveCurrentLease(lease); - if (lease.Identity != LuaRuntime.CurrentStateIdentity) - { - lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.StaleRuntime); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, status); - } + return ReleaseCurrentLease(lease); + } + } - lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.Released); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.Released, status); - } + private static SymbolRegistrationReleaseOutcome ReleaseCurrentLease(SymbolRegistrationLease lease) + { + LuaOperationStatus status; + try + { + status = UnregisterCore(lease.Name); + } + catch (InvalidOperationException) when (!LuaRuntime.IsAttached || + lease.Identity != LuaRuntime.CurrentStateIdentity) + { + return MarkStaleRuntime(lease); + } + catch (InvalidOperationException) + { + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, + LuaOperationStatus.GlobalUnavailable); + } + catch (Exception) + { + RemoveCurrentLease(lease); + lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.CleanupIndeterminate); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupIndeterminate, + LuaOperationStatus.InvalidResult); + } - if (status.Kind is LuaOperationStatusKind.GlobalUnavailable or LuaOperationStatusKind.StackUnavailable) + if (status.IsSuccess) + { + RemoveCurrentLease(lease); + if (lease.Identity != LuaRuntime.CurrentStateIdentity) { - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, status); + return MarkStaleRuntime(lease, status); } - RemoveCurrentLease(lease); - lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.CleanupIndeterminate); - return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupIndeterminate, status); + lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.Released); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.Released, status); } + + if (status.Kind is LuaOperationStatusKind.GlobalUnavailable or LuaOperationStatusKind.StackUnavailable) + { + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, status); + } + + RemoveCurrentLease(lease); + lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.CleanupIndeterminate); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupIndeterminate, status); + } + + private static SymbolRegistrationReleaseOutcome MarkStaleRuntime(SymbolRegistrationLease lease, + LuaOperationStatus status = default) + { + RemoveCurrentLease(lease); + lease.MarkTerminalAndObserve(SymbolRegistrationReleaseKind.StaleRuntime); + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, status); } private static void ValidateName(SymbolName name) @@ -218,6 +262,63 @@ private static void ValidateName(SymbolName name) } } + private static SymbolRegistrationLease CreateLease(SymbolName name, SymbolRegistrationOptions options, + LuaStateIdentity identity) + { + return new SymbolRegistrationLease(name, options, identity); + } + + private static void PublishLease(SymbolName name, SymbolRegistrationLease lease) + { + SOwnedRegistrations.Add(name, lease); + } + + // Publication runs while the coordinator gate is held, so an SDK-coordinated replacement cannot interleave between + // the successful register and this one compensation attempt. The identity check prevents an old registration from + // being unregistered through a replacement Lua runtime. + private static SymbolRegistrationReleaseOutcome CompensateFailedPublication(SymbolName name, + LuaStateIdentity identity) + { + if (!LuaRuntime.IsAttached || identity != LuaRuntime.CurrentStateIdentity) + { + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, + LuaOperationStatus.Success); + } + + LuaOperationStatus status; + try + { + status = UnregisterCore(name); + } + catch (InvalidOperationException) when (!LuaRuntime.IsAttached || + identity != LuaRuntime.CurrentStateIdentity) + { + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, + LuaOperationStatus.Success); + } + catch (InvalidOperationException) + { + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, + LuaOperationStatus.GlobalUnavailable); + } + catch (Exception) + { + return new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupIndeterminate, + LuaOperationStatus.InvalidResult); + } + + if (status.IsSuccess) + { + return identity == LuaRuntime.CurrentStateIdentity + ? new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.Released, status) + : new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.StaleRuntime, status); + } + + return status.Kind is LuaOperationStatusKind.GlobalUnavailable or LuaOperationStatusKind.StackUnavailable + ? new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupUnavailable, status) + : new SymbolRegistrationReleaseOutcome(SymbolRegistrationReleaseKind.CleanupIndeterminate, status); + } + private static void SupersedeCurrentLease(SymbolName name) { if (!SOwnedRegistrations.Remove(name, out SymbolRegistrationLease? existing)) diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationOutcome.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationOutcome.cs new file mode 100644 index 00000000..b0d7c029 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationOutcome.cs @@ -0,0 +1,34 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.SDK.Engine.Scanning.Values; + +/// +/// A factual creation result for a target-dependent memory-scan session, including the target observation made +/// before either CE factory can acquire an owned object. +/// +[StructLayout(LayoutKind.Auto)] +public readonly record struct MemoryScanCreationOutcome +{ + internal MemoryScanCreationOutcome(MemoryScanCreationStatus status, TargetSelectionObservation targetObservation) + { + Status = status; + TargetObservation = targetObservation; + } + + /// Gets the stable factory result category. + public MemoryScanCreationStatus Status + { + get; + } + + /// + /// Gets the target observation made before scanner acquisition. Its facts are retained even when creation was + /// refused because they do not qualify a target incarnation. + /// + public TargetSelectionObservation TargetObservation + { + get; + } +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs index f4409bb2..de0b12f8 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanCreationStatus.cs @@ -32,5 +32,10 @@ public enum MemoryScanCreationStatus : byte AliasedFoundList = 7, /// A rollback destroy call began but Cheat Engine did not confirm all required cleanup operations. - RollbackUnconfirmed = 8 + RollbackUnconfirmed = 8, + + /// + /// The selected target could not be qualified before either target-dependent CE factory was invoked. + /// + TargetIdentityUnavailable = 9 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs index 88371597..47c592a1 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanMaterializationStatus.cs @@ -28,5 +28,8 @@ public enum MemoryScanMaterializationStatus : byte LuaFailure = 7, /// CE returned a count, address, or value that does not satisfy the declared scan contract. - InvalidResult = 8 + InvalidResult = 8, + + /// The requested page starts at or beyond a non-empty found-list count. + PageStartOutOfRange = 9 } diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanReleaseOutcome.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanReleaseOutcome.cs new file mode 100644 index 00000000..d0e18778 --- /dev/null +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanReleaseOutcome.cs @@ -0,0 +1,55 @@ +using System.Runtime.InteropServices; + +using CheatEngine.SDK.Engine.Targets; + +namespace CheatEngine.SDK.Engine.Scanning.Values; + +/// +/// The stable outcome of consuming a memory-scan session's child owner before its parent +/// owner. +/// +/// +/// Each owner is consumed exactly once. The two values distinguish confirmed +/// cleanup, a safe refusal before the CE destroy call, cleanup that could not begin, and a native effect that began +/// but could not be confirmed. A later release or returns this same outcome +/// and never retries either CE destroy call. +/// +[StructLayout(LayoutKind.Auto)] +public readonly record struct MemoryScanReleaseOutcome +{ + internal MemoryScanReleaseOutcome(TargetReleaseOutcome foundList, TargetReleaseOutcome memScan, + bool foundListOwnershipConsumed, bool memScanOwnershipConsumed) + { + FoundList = foundList; + MemScan = memScan; + FoundListOwnershipConsumed = foundListOwnershipConsumed; + MemScanOwnershipConsumed = memScanOwnershipConsumed; + } + + /// Gets the child found-list cleanup result, which is always processed before . + public TargetReleaseOutcome FoundList + { + get; + } + + /// Gets the parent scanner cleanup result. + public TargetReleaseOutcome MemScan + { + get; + } + + /// Gets whether the session consumed its found-list ownership capability. + public bool FoundListOwnershipConsumed + { + get; + } + + /// Gets whether the session consumed its scanner ownership capability. + public bool MemScanOwnershipConsumed + { + get; + } + + /// Gets whether both session ownership capabilities have been consumed. + public bool OwnershipConsumed => FoundListOwnershipConsumed && MemScanOwnershipConsumed; +} diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs index 88e3fb46..2a21df26 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSession.cs @@ -5,6 +5,7 @@ using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Threading; using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Values; @@ -110,6 +111,16 @@ public MemoryScanCancellationMilestone LastCancellationMilestone private set; } + /// + /// Gets the stable outcome of the one child-before-parent release attempt, or an unspecified outcome before the + /// session has been released or abandoned. + /// + public MemoryScanReleaseOutcome LastReleaseOutcome + { + get; + private set; + } + /// /// Gets the scanner as a borrowed handle. Direct raw operations on this value bypass the session's state checks; /// prefer the session members for the scan lifecycle. @@ -170,71 +181,59 @@ public ulong ResultCount } } - /// - /// Releases the readable list when necessary, then destroys the owned found-list child before the owned scanner - /// parent. Never retries a destruction and is safe to call more than once. - /// + /// Best-effort, no-throw disposal that consumes both owners and never implicitly retries CE cleanup. /// - /// A different runtime identity or target incarnation marks the session - /// with its before this method throws without releasing either owner. A merely - /// unavailable target leaves the current state intact. A caller may retry only while the original context remains - /// current; otherwise it must explicitly the managed owners. Once destruction begins, it - /// follows and does not retry a protected CE failure. + /// Use when the factual child and parent outcomes matter. This method is + /// idempotent; it performs neither a target-dependent destroy on a worker nor a retry after an uncertain native + /// destroy. It records safe refusal or an unconfirmed result in instead. /// - /// - /// The plugin is attached and the caller is not on Cheat Engine's main thread. - /// - // Attached-worker cleanup is unsafe. The documented exception preserves both owners so disposal can be retried on - // the main thread; making IDisposable.Dispose non-throwing here would either leak them or violate thread affinity. -#pragma warning disable S3877 [MainThreadOnly] public void Dispose() + { + _ = ReleaseWithOutcome(); + } + + /// + /// Consumes the found-list child and scanner parent in that order and returns both one-shot cleanup outcomes. + /// + /// + /// A stable result that identifies consumed ownership independently from confirmed, refused, unavailable, or + /// unconfirmed cleanup. The same result is returned after the session is already disposed. + /// + /// + /// This method never throws and never retries a destroy. If cleanup cannot safely begin (for example, a worker + /// thread, detached runtime, changed Lua identity, or changed target), it consumes the managed owners through + /// and reports the refusal or unavailable cleanup rather than routing handles into + /// a different CE context. A protected destroy failure consumes the corresponding owner and remains unconfirmed. + /// + public MemoryScanReleaseOutcome ReleaseWithOutcome() { if (State == MemoryScanState.Disposed) { - return; + return LastReleaseOutcome; } - // Detached or reattached cleanup cannot prove that this is the original Lua/target context, so context - // validation below preserves both owners and requires the explicit Abandon recovery path. An attached worker - // thread is different: attempting CE cleanup there is unsafe, so reject it before touching the Lua stack. - if (LuaRuntime.IsAttached && !LuaRuntime.IsMainThread) + try { - throw new InvalidOperationException( - "Memory scan disposal must run on Cheat Engine's main thread while the plugin is attached."); - } - - // Admit the complete cleanup before publishing any lifetime change. Owned retains an owner when it cannot - // begin destroy(), so this session must retain both owners in that case as well. - using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); - LuaState state = operation.State; - EnsureCurrentContext(state, "MemoryScan.Dispose"); - Owned foundList = _foundList!; - Owned scanner = _scanner!; + if (LuaRuntime.IsAttached && !LuaRuntime.IsMainThread) + { + return ConsumeWithoutCleanup(TargetReleaseOutcome.NotInvoked(EngineFailureKind.BindingFailure)); + } - if (State == MemoryScanState.ResultsReady) - { - using LuaFrame frame = new(state); - _ = foundList.Value.Handle.TryCallMethod(state, "deinitialize"u8, 0, 0); - } + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + MemoryScanMaterializationStatus context = TryEnsureCurrentContext(operation.State); + if (context != MemoryScanMaterializationStatus.Success) + { + return ConsumeWithoutCleanup(CreateRefusedReleaseOutcome(context)); + } - // Each frame removes a protected-call error before the next destroy. TryDestroy consumes an owner only once its - // protected invocation begins; the outer admitted operation keeps the binding stable for both child and parent. - using (LuaFrame frame = new(state)) - { - _ = foundList.TryDestroy(state); + return ReleaseWithinCurrentContext(operation.State); } - - using (LuaFrame frame = new(state)) + catch (Exception) { - _ = scanner.TryDestroy(state); + return ConsumeWithoutCleanup(TargetReleaseOutcome.NotInvoked(EngineFailureKind.BindingFailure)); } - - _foundList = null; - _scanner = null; - State = MemoryScanState.Disposed; } -#pragma warning restore S3877 /// /// Stops managed cleanup without invoking CE and makes this session unusable. @@ -251,19 +250,7 @@ public void Abandon() return; } - if (_foundList is not null && !_foundList.IsDisposed) - { - _ = _foundList.Abandon(); - } - - if (_scanner is not null && !_scanner.IsDisposed) - { - _ = _scanner.Abandon(); - } - - _foundList = null; - _scanner = null; - State = MemoryScanState.Disposed; + _ = ConsumeWithoutCleanup(TargetReleaseOutcome.NotInvoked()); } /// @@ -285,8 +272,15 @@ public static MemoryScanSession Adopt(Owned scanner, Owned f { RequireEnabledMainThread(); using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + MemoryScanSessionContext context = MemoryScanSessionContext.Capture(operation.State); + if (!context.TargetObservation.IsQualified) + { + throw new MemoryScanException(MemoryScanFailureKind.TargetIdentityUnavailable, "MemoryScan.Adopt", + "The memory scan session cannot adopt target-dependent owners without a qualified target incarnation."); + } + MemoryScanSession session = AdoptUnbound(scanner, foundList); - session.Bind(MemoryScanSessionContext.Capture(operation.State)); + session.Bind(context); return session; } @@ -623,7 +617,7 @@ public MemoryScanMaterializationStatus TryCopyResultsCancellable(Span + /// Copies one caller-bounded page of initialized results without allocating storage for the complete found list. + /// + /// The zero-based index of the first row requested for this page. + /// The caller-owned maximum page storage. + /// The complete CE row count when it was read successfully; otherwise zero. + /// The page row count, zero unless the returned status is successful. + /// A success, empty-result, page-boundary, capacity, cancellation, context, Lua, or malformed-result category. + /// is negative. + /// + /// The temporary staging array is limited to this page, never the full count. No page prefix is copied to + /// if cancellation is observed or any page row is malformed. + /// + [MainThreadOnly] + [RequiresPluginEnabled] + public MemoryScanMaterializationStatus TryCopyResultsPage(int firstResultIndex, Span destination, + out ulong totalCount, out int written) + { + return TryCopyResultsPageCancellable(firstResultIndex, destination, out totalCount, out written, + CancellationToken.None); + } + + /// + /// Copies one caller-bounded result page while observing cancellation between synchronous CE row calls. + /// + /// The zero-based index of the first row requested for this page. + /// The caller-owned maximum page storage. + /// The complete CE row count when it was read successfully; otherwise zero. + /// The page row count, zero unless the returned status is successful. + /// A cooperative cancellation token; it cannot interrupt a CE row call already begun. + /// A success, empty-result, page-boundary, capacity, cancellation, context, Lua, or malformed-result category. + /// is negative. + /// + /// This operation stages at most .Length rows, then publishes the page only when + /// every staged address/value pair is valid and cancellation has not been observed. + /// + [MainThreadOnly] + [RequiresPluginEnabled] + public MemoryScanMaterializationStatus TryCopyResultsPageCancellable(int firstResultIndex, + Span destination, out ulong totalCount, out int written, CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfNegative(firstResultIndex); + totalCount = 0; + written = 0; + LastCancellationMilestone = MemoryScanCancellationMilestone.None; + RequireEnabledMainThread(); + if (State != MemoryScanState.ResultsReady) + { + ThrowWrongState("TryCopyResultsPage"); + } + + using LuaRuntimeOperation operation = LuaRuntime.AcquireOperation(); + MemoryScanMaterializationStatus context = TryEnsureCurrentContext(operation.State); + if (context != MemoryScanMaterializationStatus.Success) + { + return context; + } + + if (cancellationToken.IsCancellationRequested) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.CancelledBeforeNativeCall; + return MemoryScanMaterializationStatus.Cancelled; + } + + FoundList foundList = RequireResults(); + try + { + totalCount = ReadResultCount(operation.State, foundList); + if (totalCount == 0) { - LastCancellationMilestone = MemoryScanCancellationMilestone.ObservedAfterNativeCall; - return MemoryScanMaterializationStatus.Cancelled; + return MemoryScanMaterializationStatus.NoResults; + } + + if ((ulong) firstResultIndex >= totalCount) + { + return MemoryScanMaterializationStatus.PageStartOutOfRange; + } + + if (destination.IsEmpty) + { + return MemoryScanMaterializationStatus.DestinationTooSmall; + } + + int pageLength = (int) Math.Min((ulong) destination.Length, totalCount - (ulong) firstResultIndex); + MemoryScanResult[] snapshot = new MemoryScanResult[pageLength]; + MemoryScanMaterializationStatus status = TryFillSnapshot(operation.State, foundList, snapshot, + firstResultIndex, cancellationToken); + if (status != MemoryScanMaterializationStatus.Success) + { + return status; } snapshot.AsSpan().CopyTo(destination); @@ -682,11 +759,138 @@ public MemoryScanMaterializationStatus TryCopyResultsCancellable(Span MemoryScanMaterializationStatus.LuaFailure, - _ => MemoryScanMaterializationStatus.InvalidResult - }; + LastCancellationMilestone = MemoryScanCancellationMilestone.ObservedAfterNativeCall; + return MemoryScanMaterializationStatus.Cancelled; + } + + int index = firstResultIndex + offset; + string addressText = CallString(state, foundList.Handle, "getAddress"u8, ResultAddressOperation, index); + if (cancellationToken.IsCancellationRequested) + { + LastCancellationMilestone = MemoryScanCancellationMilestone.ObservedAfterNativeCall; + return MemoryScanMaterializationStatus.Cancelled; + } + + if (!Address.TryParse(addressText, out Address address)) + { + return MemoryScanMaterializationStatus.InvalidResult; + } + + string value = CallString(state, foundList.Handle, "getValue"u8, ResultValueOperation, index); + snapshot[offset] = new MemoryScanResult(address, value); + } + + if (!cancellationToken.IsCancellationRequested) + { + return MemoryScanMaterializationStatus.Success; + } + + LastCancellationMilestone = MemoryScanCancellationMilestone.ObservedAfterNativeCall; + return MemoryScanMaterializationStatus.Cancelled; + } + + private static MemoryScanMaterializationStatus ToMaterializationStatus(MemoryScanException exception) + { + return exception.FailureKind == MemoryScanFailureKind.LuaError + ? MemoryScanMaterializationStatus.LuaFailure + : MemoryScanMaterializationStatus.InvalidResult; + } + + private MemoryScanReleaseOutcome ReleaseWithinCurrentContext(LuaState state) + { + Owned? foundList = _foundList; + Owned? scanner = _scanner; + if (State == MemoryScanState.ResultsReady && foundList is not null && !foundList.IsDisposed) + { + using LuaFrame frame = new(state); + _ = foundList.Value.Handle.TryCallMethod(state, "deinitialize"u8, 0, 0); + } + + TargetReleaseOutcome foundListOutcome = ReleaseOwned(state, foundList); + TargetReleaseOutcome scannerOutcome = ReleaseOwned(state, scanner); + return CompleteRelease(foundListOutcome, scannerOutcome); + } + + private MemoryScanReleaseOutcome ConsumeWithoutCleanup(TargetReleaseOutcome outcome) + { + ConsumeOwner(_foundList); + ConsumeOwner(_scanner); + return CompleteRelease(outcome, outcome); + } + + private MemoryScanReleaseOutcome CompleteRelease(TargetReleaseOutcome foundListOutcome, + TargetReleaseOutcome scannerOutcome) + { + _foundList = null; + _scanner = null; + State = MemoryScanState.Disposed; + LastReleaseOutcome = new MemoryScanReleaseOutcome(foundListOutcome, scannerOutcome, true, true); + return LastReleaseOutcome; + } + + private TargetReleaseOutcome CreateRefusedReleaseOutcome(MemoryScanMaterializationStatus context) + { + if (context is MemoryScanMaterializationStatus.TargetIdentityUnavailable or + MemoryScanMaterializationStatus.TargetIdentityMismatch && LastTargetCheck.HasValue) + { + return TargetReleaseOutcome.Refused(LastTargetCheck.GetValueOrDefault()); + } + + return TargetReleaseOutcome.NotInvoked(context == MemoryScanMaterializationStatus.RuntimeInvalidated + ? EngineFailureKind.BindingFailure + : EngineFailureKind.TargetIdentityUnavailable); + } + + private static TargetReleaseOutcome ReleaseOwned(LuaState state, Owned? owner) + where T : struct, ICEObject + { + if (owner is null || owner.IsDisposed) + { + return TargetReleaseOutcome.NotInvoked(); + } + + try + { + using LuaFrame frame = new(state); + LuaStatus status = owner.TryDestroy(state); + return status.IsOk + ? TargetReleaseOutcome.Released() + : TargetReleaseOutcome.Unconfirmed(EngineFailureKind.ProtectedLuaFailure); + } + catch (Exception) + { + return TargetReleaseOutcome.Unconfirmed(EngineFailureKind.BindingFailure); + } + finally + { + ConsumeOwner(owner); + } + } + + private static void ConsumeOwner(Owned? owner) + where T : struct, ICEObject + { + if (owner is not null && !owner.IsDisposed) + { + try + { + _ = owner.Abandon(); + } + catch (Exception) + { + // The session must not make IDisposable cleanup throw or expose a retry path after taking ownership. + } } } @@ -739,10 +943,10 @@ private static void ValidateFirstRequest(in FirstScanRequest request) // A default-initialized request can contain null strings. These are properties of the // value-type request rather than parameters of this helper, therefore its parameter name // must be the actual public argument ("request") instead of a local alias. - ArgumentNullException.ThrowIfNull(request.Input1, nameof(request)); - ArgumentNullException.ThrowIfNull(request.Input2, nameof(request)); - ArgumentNullException.ThrowIfNull(request.ProtectionFlags, nameof(request)); - ArgumentNullException.ThrowIfNull(request.AlignmentParameter, nameof(request)); + RequireValue(request.Input1, nameof(request)); + RequireValue(request.Input2, nameof(request)); + RequireValue(request.ProtectionFlags, nameof(request)); + RequireValue(request.AlignmentParameter, nameof(request)); if (request.ScanOption is < ScanOption.UnknownValue or > ScanOption.SmallerThan) { @@ -771,8 +975,8 @@ private static void ValidateFirstRequest(in FirstScanRequest request) private static void ValidateNextRequest(in NextScanRequest request) { - ArgumentNullException.ThrowIfNull(request.Input1, nameof(request)); - ArgumentNullException.ThrowIfNull(request.Input2, nameof(request)); + RequireValue(request.Input1, nameof(request)); + RequireValue(request.Input2, nameof(request)); if (request.ScanOption is < ScanOption.ExactValue or > ScanOption.Unchanged) { throw new ArgumentException("A next scan only accepts ExactValue through Unchanged, never UnknownValue.", @@ -785,6 +989,14 @@ private static void ValidateNextRequest(in NextScanRequest request) } } + private static void RequireValue(string? value, string parameterName) + { + if (value is null) + { + throw new ArgumentNullException(parameterName); + } + } + private void ThrowIfCancelledBeforeNativeCall(CancellationToken cancellationToken) { LastCancellationMilestone = MemoryScanCancellationMilestone.None; @@ -806,7 +1018,7 @@ private void ObserveCancellationAfterNativeCall(CancellationToken cancellationTo private void EnsureCurrentContext(LuaState state, string operation) { - MemoryScanMaterializationStatus status = TryEnsureCurrentContext(state, operation); + MemoryScanMaterializationStatus status = TryEnsureCurrentContext(state); if (status == MemoryScanMaterializationStatus.Success) { return; @@ -823,7 +1035,7 @@ private void EnsureCurrentContext(LuaState state, string operation) "'."); } - private MemoryScanMaterializationStatus TryEnsureCurrentContext(LuaState state, string operation) + private MemoryScanMaterializationStatus TryEnsureCurrentContext(LuaState state) { if (!_isBound || RuntimeIdentity != LuaRuntime.CurrentStateIdentity) { diff --git a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs index 5421963b..3502fd93 100644 --- a/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs +++ b/libs/CheatEngine.SDK.Engine/Scanning/Values/MemoryScanSessions.cs @@ -4,6 +4,7 @@ using CheatEngine.SDK.Annotations.Lifetime; using CheatEngine.SDK.Annotations.Threading; using CheatEngine.SDK.Engine.Objects; +using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Lua.CompilerServices; using CheatEngine.SDK.Lua.References; using CheatEngine.SDK.Lua.Runtime; @@ -71,7 +72,33 @@ public static bool TryCreate([NotNullWhen(true)] out MemoryScanSession? session) [RequiresPluginEnabled] public static MemoryScanCreationStatus TryCreateDetailed([NotNullWhen(true)] out MemoryScanSession? session) { - return TryCreateDetailedCore(out session, CreateSession); + return TryCreateWithOutcome(out session).Status; + } + + /// + /// Creates a scanner/found-list pair and returns the pre-acquisition target observation with the factual factory + /// result. + /// + /// + /// The new context-bound session only when is + /// . + /// + /// + /// The factory result and the exact target observation made before createMemScan(). An unqualified + /// observation returns and invokes neither CE + /// factory. + /// + /// + /// The plugin is not enabled, the caller has no host Lua state, the host cannot push objects, or the caller is + /// not on Cheat Engine's main thread. + /// + [MainThreadOnly] + [RequiresPluginEnabled] + public static MemoryScanCreationOutcome TryCreateWithOutcome([NotNullWhen(true)] out MemoryScanSession? session) + { + MemoryScanCreationStatus status = TryCreateDetailedCore(out session, CreateSession, + out TargetSelectionObservation targetObservation); + return new MemoryScanCreationOutcome(status, targetObservation); } // Tests use this seam to prove that an ownership-transfer failure rolls the child back before its parent. The raw @@ -81,11 +108,11 @@ public static MemoryScanCreationStatus TryCreateDetailed([NotNullWhen(true)] out internal static bool TryCreateCore([NotNullWhen(true)] out MemoryScanSession? session, MemoryScanSessionAdopter adopter) { - return TryCreateDetailedCore(out session, adopter) == MemoryScanCreationStatus.Success; + return TryCreateDetailedCore(out session, adopter, out _) == MemoryScanCreationStatus.Success; } private static MemoryScanCreationStatus TryCreateDetailedCore([NotNullWhen(true)] out MemoryScanSession? session, - MemoryScanSessionAdopter adopter) + MemoryScanSessionAdopter adopter, out TargetSelectionObservation targetObservation) { ArgumentNullException.ThrowIfNull(adopter); RequireEnabledMainThread(); @@ -99,9 +126,17 @@ private static MemoryScanCreationStatus TryCreateDetailedCore([NotNullWhen(true) CEObject foundListHandle = CEObject.Null; MemoryScanCreationStatus status = MemoryScanCreationStatus.Success; MemoryScanSessionContext context = MemoryScanSessionContext.Capture(state); + targetObservation = context.TargetObservation; session = null; try { + // A MemScan/FoundList pair acts against CE's ambient target. Do not acquire either caller-owned resource + // until the observation can identify a process incarnation for every later session operation and cleanup. + if (!context.TargetObservation.IsQualified) + { + return MemoryScanCreationStatus.TargetIdentityUnavailable; + } + status = TryCreateScanner(state, out scanner, out scannerHandle); if (status == MemoryScanCreationStatus.Success) { @@ -119,12 +154,13 @@ private static MemoryScanCreationStatus TryCreateDetailedCore([NotNullWhen(true) // Adoption transfers and empties both wrappers. Every other exit after construction must release the child // before the parent while the original operation is still admitted. Swallowing a protected destroy failure // avoids hiding the factory failure and, like Owned.Dispose, never retries an unknown native state. - if (!TryRollback(state, foundList, foundListHandle) | !TryRollback(state, scanner, scannerHandle)) + bool foundListRollbackFailed = !TryRollback(state, foundList, foundListHandle); + bool scannerRollbackFailed = !TryRollback(state, scanner, scannerHandle); + if ((foundListRollbackFailed || scannerRollbackFailed) + && session is null + && status != MemoryScanCreationStatus.Success) { - if (session is null && status != MemoryScanCreationStatus.Success) - { - status = MemoryScanCreationStatus.RollbackUnconfirmed; - } + status = MemoryScanCreationStatus.RollbackUnconfirmed; } } diff --git a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs index 5f9217e3..5a2f4670 100644 --- a/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs +++ b/libs/CheatEngine.SDK.Lua/Callbacks/LuaCallback.cs @@ -34,7 +34,7 @@ namespace CheatEngine.SDK.Lua.Callbacks; /// Inside the thunk: reads the state back from the upvalue. /// /// -/// Release (, , or for +/// Release (, , or for /// whatever is /// still alive): the closure's upvalue is set to a null light userdata first, so that a script which kept the /// function and calls it later gets a "callback released" error instead of touching freed memory; only then is the @@ -56,11 +56,11 @@ public abstract class LuaCallback : IDisposable { // Deterministic publication-race seam used only by the SDK's friend test assembly. Callback creation is cold, so // its volatile read is deliberately kept out of callback invocation hot paths. - internal static Action? BeforeRegistryAddForTesting; + private static Action? s_beforeRegistryAddForTesting; // Deterministic disposal-race seam used only by the SDK's friend test assembly. It runs after an atomic admission // refusal is observed, outside the runtime gate, so tests can let a failed transition reopen admission first. - internal static Action? DisposeAdmissionRefusedForTesting; + private static Action? s_disposeAdmissionRefusedForTesting; private readonly LuaRef _closure; private readonly LuaStateIdentity _identity; @@ -125,6 +125,28 @@ internal bool IsLinked /// public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + internal static void SetBeforeRegistryAddForTesting(Action? callback) + { + Volatile.Write(ref s_beforeRegistryAddForTesting, callback); + } + + internal static void SetDisposeAdmissionRefusedForTesting(Action? callback) + { + Volatile.Write(ref s_disposeAdmissionRefusedForTesting, callback); + } + + /// Releases the callback's managed and Lua resources. + protected virtual void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + LuaRuntime.LuaCallbackDisposeOperationResult result = LuaRuntime.TryAcquireOperationForCallbackDispose(out LuaRuntimeOperation operation); if (result == LuaRuntime.LuaCallbackDisposeOperationResult.Acquired) @@ -134,7 +156,6 @@ public void Dispose() Release(operation.State); } - GC.SuppressFinalize(this); return; } @@ -142,13 +163,11 @@ public void Dispose() { // This result was observed atomically with the closed gate. A later failed Detach can reopen admission, // but cannot make it safe to abandon the closure before a transition-owned state neutralizes its upvalue. - Volatile.Read(ref DisposeAdmissionRefusedForTesting)?.Invoke(); - GC.SuppressFinalize(this); + Volatile.Read(ref s_disposeAdmissionRefusedForTesting)?.Invoke(); return; } Release(default); - GC.SuppressFinalize(this); } /// @@ -185,7 +204,7 @@ public static LuaStatus TryCreate(LuaState state, LuaNativeFunction thun } private static unsafe LuaStatus TryCreateCore(LuaState state, LuaNativeFunction thunk, TState stateObject, - out LuaCallback? callback) + out LuaCallback? callback) // NOSONAR: this method is the required Lua C-ABI boundary. where TState : class { callback = null; @@ -221,7 +240,7 @@ private static unsafe LuaStatus TryCreateCore(LuaState state, LuaNativeF } LuaCallback created = new(handle, closure!, wrapped!); - Volatile.Read(ref BeforeRegistryAddForTesting)?.Invoke(); + Volatile.Read(ref s_beforeRegistryAddForTesting)?.Invoke(); LuaCallbackRegistry.Add(created); callback = created; transferred = true; @@ -241,11 +260,9 @@ private static unsafe LuaStatus TryCreateCore(LuaState state, LuaNativeF } } - private static unsafe LuaStatus TryCreateWrappedRef( - LuaState state, - lua_State* l, - int top, - out LuaRef? wrapped) + private static unsafe LuaStatus + TryCreateWrappedRef(LuaState state, lua_State* l, int top, + out LuaRef? wrapped) // NOSONAR: native Lua stack access is required here. { wrapped = null; LuaStatus status = LuaHelpers.Push(l, LuaHelper.Wrap); @@ -319,7 +336,7 @@ public void Release(LuaState state) // Private so that Release, which holds the gate, is the only way in: the flag test, GCHandle.Dispose (not thread // safe) and the unlink all depend on it. - private unsafe void ReleaseUnderGate(LuaState state) + private unsafe void ReleaseUnderGate(LuaState state) // NOSONAR: native Lua stack access is required here. { if (_released) { @@ -360,12 +377,10 @@ private unsafe void ReleaseUnderGate(LuaState state) } } - // This is the only unmanaged entry point for stateful LuaCallback instances. The user thunk remains a cdecl - // function pointer stored in upvalue 2, while upvalue 1 deliberately retains the historical GCHandle - // contract consumed by LuaThunk.TryGetState. A callback that begins before Detach closes admission keeps a lease; - // a callback that begins after that boundary reports an ordinary Lua error and never enters plugin code. + // This is the only native entry point for stateful callbacks. The thunk address and managed state handle are kept in + // Lua upvalues, so callbacks already admitted before shutdown can finish while later calls fail safely. [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static unsafe int Dispatch(lua_State* pointer) + private static unsafe int Dispatch(lua_State* pointer) // NOSONAR: unmanaged callback entry point required by Lua. { LuaState state = new(pointer); try diff --git a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs index dd2dad8c..0f5119bf 100644 --- a/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs +++ b/libs/CheatEngine.SDK.Lua/Runtime/LuaRuntime.cs @@ -200,7 +200,7 @@ public static bool TryAcquireOperation(out LuaRuntimeOperation operation) } /// - /// Acquires an operation for and reports whether an attached lifecycle + /// Acquires an operation for and reports whether an attached lifecycle /// transition, rather than an unavailable state, rejected it. /// /// diff --git a/libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs b/libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs index a56c0550..ffba013f 100644 --- a/libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs +++ b/libs/CheatEngine.SDK.Lua/State/LuaState.Callbacks.cs @@ -16,7 +16,8 @@ namespace CheatEngine.SDK.Lua.State; // Pushing managed functions: with the error channel (the normal form) or without it. -public readonly unsafe partial struct LuaState +public readonly unsafe partial struct + LuaState // NOSONAR: this partial contains the required Lua C-ABI function pointers. { /// /// Pushes a generated [LuaFunction] closure whose invocation is bound to one host attachment and Lua diff --git a/native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c b/native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c index 6781ab5b..06b878a5 100644 --- a/native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c +++ b/native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c @@ -18,35 +18,38 @@ /* This file deliberately does not include lua.h. The host owns Lua; callers provide * addresses resolved from that host module, so the bridge can never load a second Lua. */ typedef struct lua_State lua_State; -typedef int (CHEATENGINE_SDK_CALL *lua_CFunction)(lua_State *); +typedef int (CHEATENGINE_SDK_CALL *lua_CFunction)(lua_State*); typedef int64_t lua_Integer; typedef intptr_t lua_KContext; -typedef int (CHEATENGINE_SDK_CALL *lua_KFunction)(lua_State *, int, lua_KContext); -typedef void (CHEATENGINE_SDK_HOST_CALL *cheatengine_sdk_host_object_pusher)(lua_State *, void *); -typedef struct cheatengine_sdk_lua_exports { - int (CHEATENGINE_SDK_CALL *gettop)(lua_State *); - void (CHEATENGINE_SDK_CALL *settop)(lua_State *, int); - int (CHEATENGINE_SDK_CALL *checkstack)(lua_State *, int); - void (CHEATENGINE_SDK_CALL *rotate)(lua_State *, int, int); - const char *(CHEATENGINE_SDK_CALL *pushlstring)(lua_State *, const char *, size_t); - void (CHEATENGINE_SDK_CALL *pushinteger)(lua_State *, lua_Integer); - void (CHEATENGINE_SDK_CALL *createtable)(lua_State *, int, int); - void *(CHEATENGINE_SDK_CALL *newuserdata)(lua_State *, size_t); - void (CHEATENGINE_SDK_CALL *pushcclosure)(lua_State *, lua_CFunction, int); - void (CHEATENGINE_SDK_CALL *pushlightuserdata)(lua_State *, void *); - void (CHEATENGINE_SDK_CALL *rawset)(lua_State *, int); - void (CHEATENGINE_SDK_CALL *rawseti)(lua_State *, int, lua_Integer); - void (CHEATENGINE_SDK_CALL *rawsetp)(lua_State *, int, const void *); - int (CHEATENGINE_SDK_CALL *rawgetp)(lua_State *, int, const void *); - int (CHEATENGINE_SDK_CALL *rawgeti)(lua_State *, int, lua_Integer); - int (CHEATENGINE_SDK_CALL *type)(lua_State *, int); - int (CHEATENGINE_SDK_CALL *pcallk)(lua_State *, int, int, int, lua_KContext, lua_KFunction); - int (CHEATENGINE_SDK_CALL *error)(lua_State *); - int (CHEATENGINE_SDK_CALL *l_ref)(lua_State *, int); - void (CHEATENGINE_SDK_CALL *l_unref)(lua_State *, int, int); +typedef int (CHEATENGINE_SDK_CALL *lua_KFunction)(lua_State*, int, lua_KContext); +typedef void (CHEATENGINE_SDK_HOST_CALL *cheatengine_sdk_host_object_pusher)(lua_State*, void*); + +typedef struct cheatengine_sdk_lua_exports +{ + int (CHEATENGINE_SDK_CALL *gettop)(lua_State*); + void (CHEATENGINE_SDK_CALL *settop)(lua_State*, int); + int (CHEATENGINE_SDK_CALL *checkstack)(lua_State*, int); + void (CHEATENGINE_SDK_CALL *rotate)(lua_State*, int, int); + const char*(CHEATENGINE_SDK_CALL *pushlstring)(lua_State*, const char*, size_t); + void (CHEATENGINE_SDK_CALL *pushinteger)(lua_State*, lua_Integer); + void (CHEATENGINE_SDK_CALL *createtable)(lua_State*, int, int); + void*(CHEATENGINE_SDK_CALL *newuserdata)(lua_State*, size_t); + void (CHEATENGINE_SDK_CALL *pushcclosure)(lua_State*, lua_CFunction, int); + void (CHEATENGINE_SDK_CALL *pushlightuserdata)(lua_State*, void*); + void (CHEATENGINE_SDK_CALL *rawset)(lua_State*, int); + void (CHEATENGINE_SDK_CALL *rawseti)(lua_State*, int, lua_Integer); + void (CHEATENGINE_SDK_CALL *rawsetp)(lua_State*, int, const void*); + int (CHEATENGINE_SDK_CALL *rawgetp)(lua_State*, int, const void*); + int (CHEATENGINE_SDK_CALL *rawgeti)(lua_State*, int, lua_Integer); + int (CHEATENGINE_SDK_CALL *type)(lua_State*, int); + int (CHEATENGINE_SDK_CALL *pcallk)(lua_State*, int, int, int, lua_KContext, lua_KFunction); + int (CHEATENGINE_SDK_CALL *error)(lua_State*); + int (CHEATENGINE_SDK_CALL *l_ref)(lua_State*, int); + void (CHEATENGINE_SDK_CALL *l_unref)(lua_State*, int, int); } cheatengine_sdk_lua_exports; -typedef struct cheatengine_sdk_lua_bridge_contract { +typedef struct cheatengine_sdk_lua_bridge_contract +{ uint32_t magic; uint32_t contract_size; uint64_t supported_operations; @@ -61,7 +64,9 @@ typedef struct cheatengine_sdk_lua_bridge_contract { } cheatengine_sdk_lua_bridge_contract; enum { LUA_OK = 0, LUA_MULTRET = -1, LUA_TTABLE = 5, LUA_REGISTRYINDEX = -1001000, CHEATENGINE_SDK_NO_ERROR = -100 }; -enum { + +enum +{ OP_PUSH_BYTES = 0, OP_CREATE_TABLE = 1, OP_NEW_USERDATA = 2, @@ -76,8 +81,15 @@ enum { OP_PUSH_BYTE_TABLE = 11, CHEATENGINE_SDK_LUA_BRIDGE_OPERATION_COUNT = 12 }; -enum { CHEATENGINE_SDK_LUA_BRIDGE_LEGACY_ABI_VERSION = 1, CHEATENGINE_SDK_LUA_BRIDGE_ABI_MAJOR = 1, CHEATENGINE_SDK_LUA_BRIDGE_ABI_MINOR = 1 }; + +enum +{ + CHEATENGINE_SDK_LUA_BRIDGE_LEGACY_ABI_VERSION = 1, CHEATENGINE_SDK_LUA_BRIDGE_ABI_MAJOR = 1, + CHEATENGINE_SDK_LUA_BRIDGE_ABI_MINOR = 1 +}; + enum { CHEATENGINE_SDK_LUA_BRIDGE_MAGIC = 0x4345534B }; + #define CHEATENGINE_SDK_LUA_BRIDGE_OPERATION_MASK \ ((UINT64_C(1) << OP_PUSH_BYTES) | (UINT64_C(1) << OP_CREATE_TABLE) | \ (UINT64_C(1) << OP_NEW_USERDATA) | (UINT64_C(1) << OP_PUSH_CLOSURE) | \ @@ -88,66 +100,101 @@ enum { CHEATENGINE_SDK_LUA_BRIDGE_MAGIC = 0x4345534B }; _Static_assert(CHAR_BIT == 8, "The Lua bridge requires eight-bit bytes."); _Static_assert(sizeof(int) == 4, "The Lua bridge ABI requires a 32-bit int."); -_Static_assert(sizeof(void *) == 8, "The Lua bridge supports Windows x64 only."); -_Static_assert(sizeof(size_t) == sizeof(void *), "size_t must be pointer-sized."); +_Static_assert(sizeof(void*) == 8, "The Lua bridge supports Windows x64 only."); +_Static_assert(sizeof(size_t) == sizeof(void*), "size_t must be pointer-sized."); _Static_assert(sizeof(lua_Integer) == 8, "The supported Lua build uses a 64-bit lua_Integer."); -_Static_assert(sizeof(lua_KContext) == sizeof(void *), "lua_KContext must be pointer-sized."); +_Static_assert(sizeof(lua_KContext) == sizeof(void*), "lua_KContext must be pointer-sized."); _Static_assert(CHEATENGINE_SDK_LUA_BRIDGE_OPERATION_COUNT <= 64, "The operation bitmap has no remaining bit."); -_Static_assert(sizeof(cheatengine_sdk_lua_exports) == 20 * sizeof(void *), "The Lua export table layout changed."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, gettop) == 0 * sizeof(void *), "lua_gettop has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, settop) == 1 * sizeof(void *), "lua_settop has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, checkstack) == 2 * sizeof(void *), "lua_checkstack has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, rotate) == 3 * sizeof(void *), "lua_rotate has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, pushlstring) == 4 * sizeof(void *), "lua_pushlstring has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, pushinteger) == 5 * sizeof(void *), "lua_pushinteger has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, createtable) == 6 * sizeof(void *), "lua_createtable has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, newuserdata) == 7 * sizeof(void *), "lua_newuserdata has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, pushcclosure) == 8 * sizeof(void *), "lua_pushcclosure has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, pushlightuserdata) == 9 * sizeof(void *), "lua_pushlightuserdata has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, rawset) == 10 * sizeof(void *), "lua_rawset has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, rawseti) == 11 * sizeof(void *), "lua_rawseti has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, rawsetp) == 12 * sizeof(void *), "lua_rawsetp has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, rawgetp) == 13 * sizeof(void *), "lua_rawgetp has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, rawgeti) == 14 * sizeof(void *), "lua_rawgeti has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, type) == 15 * sizeof(void *), "lua_type has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, pcallk) == 16 * sizeof(void *), "lua_pcallk has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, error) == 17 * sizeof(void *), "lua_error has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, l_ref) == 18 * sizeof(void *), "luaL_ref has an unexpected export-table offset."); -_Static_assert(offsetof(cheatengine_sdk_lua_exports, l_unref) == 19 * sizeof(void *), "luaL_unref has an unexpected export-table offset."); +_Static_assert(sizeof(cheatengine_sdk_lua_exports) == 20 * sizeof(void*), "The Lua export table layout changed."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, gettop) == 0 * sizeof(void*), + "lua_gettop has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, settop) == 1 * sizeof(void*), + "lua_settop has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, checkstack) == 2 * sizeof(void*), + "lua_checkstack has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, rotate) == 3 * sizeof(void*), + "lua_rotate has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, pushlstring) == 4 * sizeof(void*), + "lua_pushlstring has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, pushinteger) == 5 * sizeof(void*), + "lua_pushinteger has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, createtable) == 6 * sizeof(void*), + "lua_createtable has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, newuserdata) == 7 * sizeof(void*), + "lua_newuserdata has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, pushcclosure) == 8 * sizeof(void*), + "lua_pushcclosure has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, pushlightuserdata) == 9 * sizeof(void*), + "lua_pushlightuserdata has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, rawset) == 10 * sizeof(void*), + "lua_rawset has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, rawseti) == 11 * sizeof(void*), + "lua_rawseti has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, rawsetp) == 12 * sizeof(void*), + "lua_rawsetp has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, rawgetp) == 13 * sizeof(void*), + "lua_rawgetp has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, rawgeti) == 14 * sizeof(void*), + "lua_rawgeti has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, type) == 15 * sizeof(void*), + "lua_type has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, pcallk) == 16 * sizeof(void*), + "lua_pcallk has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, error) == 17 * sizeof(void*), + "lua_error has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, l_ref) == 18 * sizeof(void*), + "luaL_ref has an unexpected export-table offset."); +_Static_assert(offsetof(cheatengine_sdk_lua_exports, l_unref) == 19 * sizeof(void*), + "luaL_unref has an unexpected export-table offset."); _Static_assert(sizeof(cheatengine_sdk_lua_bridge_contract) == 32, "The bridge contract layout changed."); _Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, magic) == 0, "The bridge contract magic offset changed."); -_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, contract_size) == 4, "The bridge contract size offset changed."); -_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, supported_operations) == 8, "The bridge contract operation bitmap offset changed."); -_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, export_table_size) == 16, "The bridge contract table-size offset changed."); -_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, abi_major) == 20, "The bridge contract major-version offset changed."); -_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, abi_minor) == 22, "The bridge contract minor-version offset changed."); -_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, pointer_size) == 24, "The bridge contract pointer-size offset changed."); -_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, lua_integer_size) == 25, "The bridge contract lua_Integer-size offset changed."); -_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, size_t_size) == 26, "The bridge contract size_t-size offset changed."); -_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, reserved) == 27, "The bridge contract reserved offset changed."); -_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, reserved_padding) == 28, "The bridge contract padding offset changed."); +_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, contract_size) == 4, + "The bridge contract size offset changed."); +_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, supported_operations) == 8, + "The bridge contract operation bitmap offset changed."); +_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, export_table_size) == 16, + "The bridge contract table-size offset changed."); +_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, abi_major) == 20, + "The bridge contract major-version offset changed."); +_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, abi_minor) == 22, + "The bridge contract minor-version offset changed."); +_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, pointer_size) == 24, + "The bridge contract pointer-size offset changed."); +_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, lua_integer_size) == 25, + "The bridge contract lua_Integer-size offset changed."); +_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, size_t_size) == 26, + "The bridge contract size_t-size offset changed."); +_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, reserved) == 27, + "The bridge contract reserved offset changed."); +_Static_assert(offsetof(cheatengine_sdk_lua_bridge_contract, reserved_padding) == 28, + "The bridge contract padding offset changed."); -typedef struct call_context { - const cheatengine_sdk_lua_exports *api; +typedef struct call_context +{ + const cheatengine_sdk_lua_exports* api; int op; int input_count; - const void *data; + const void* data; size_t size; intptr_t a; intptr_t b; } call_context; -static CHEATENGINE_SDK_THREAD_LOCAL call_context *s_context; -CHEATENGINE_SDK_EXPORT const char cheatengine_sdk_lua_bridge_source_fingerprint[] = CHEATENGINE_SDK_LUA_BRIDGE_SOURCE_FINGERPRINT; +static CHEATENGINE_SDK_THREAD_LOCAL call_context* s_context; -CHEATENGINE_SDK_EXPORT uint32_t CHEATENGINE_SDK_CALL cheatengine_sdk_lua_bridge_abi_version(void) { +CHEATENGINE_SDK_EXPORT const char cheatengine_sdk_lua_bridge_source_fingerprint[] = + CHEATENGINE_SDK_LUA_BRIDGE_SOURCE_FINGERPRINT; + +CHEATENGINE_SDK_EXPORT uint32_t CHEATENGINE_SDK_CALL cheatengine_sdk_lua_bridge_abi_version(void) +{ /* Retained for already-published managed clients. New clients consume the full contract below. */ return CHEATENGINE_SDK_LUA_BRIDGE_LEGACY_ABI_VERSION; } CHEATENGINE_SDK_EXPORT int CHEATENGINE_SDK_CALL cheatengine_sdk_lua_bridge_get_contract( - cheatengine_sdk_lua_bridge_contract *contract, - size_t contract_size) { + cheatengine_sdk_lua_bridge_contract* contract, + size_t contract_size) +{ const cheatengine_sdk_lua_bridge_contract value = { CHEATENGINE_SDK_LUA_BRIDGE_MAGIC, (uint32_t)sizeof(cheatengine_sdk_lua_bridge_contract), @@ -155,11 +202,11 @@ CHEATENGINE_SDK_EXPORT int CHEATENGINE_SDK_CALL cheatengine_sdk_lua_bridge_get_c (uint32_t)sizeof(cheatengine_sdk_lua_exports), CHEATENGINE_SDK_LUA_BRIDGE_ABI_MAJOR, CHEATENGINE_SDK_LUA_BRIDGE_ABI_MINOR, - (uint8_t)sizeof(void *), + (uint8_t)sizeof(void*), (uint8_t)sizeof(lua_Integer), (uint8_t)sizeof(size_t), 0, - { 0 } + {0} }; if (!contract || contract_size != sizeof(value)) return 0; @@ -167,13 +214,16 @@ CHEATENGINE_SDK_EXPORT int CHEATENGINE_SDK_CALL cheatengine_sdk_lua_bridge_get_c return 1; } -static int fail(lua_State *L, const char *message) { - size_t length = 0; while (message[length]) ++length; +static int fail(lua_State* L, const char* message) +{ + size_t length = 0; + while (message[length]) ++length; s_context->api->pushlstring(L, message, length); return s_context->api->error(L); } -static int lua_exports_are_complete(const cheatengine_sdk_lua_exports *api) { +static int lua_exports_are_complete(const cheatengine_sdk_lua_exports* api) +{ return api && api->gettop && api->settop && api->checkstack && api->rotate && api->pushlstring && api->pushinteger && api->createtable && api->newuserdata && api->pushcclosure && api->pushlightuserdata && api->rawset && api->rawseti && @@ -181,40 +231,47 @@ static int lua_exports_are_complete(const cheatengine_sdk_lua_exports *api) { api->error && api->l_ref && api->l_unref; } -static int is_nonnegative_int(intptr_t value) { +static int is_nonnegative_int(intptr_t value) +{ return value >= 0 && value <= INT_MAX; } -static int is_int(intptr_t value) { +static int is_int(intptr_t value) +{ return value >= INT_MIN && value <= INT_MAX; } -static int CHEATENGINE_SDK_CALL operation(lua_State *L) { - call_context *c = s_context; - const cheatengine_sdk_lua_exports *a = c->api; +static int CHEATENGINE_SDK_CALL operation(lua_State* L) +{ + call_context* c = s_context; + const cheatengine_sdk_lua_exports* a = c->api; - switch (c->op) { - case OP_PUSH_BYTES: { - static const char empty = 0; - if (c->input_count != 0 || (c->size != 0 && !c->data)) - return fail(L, "CheatEngine.SDK protected byte push has invalid arguments"); - a->pushlstring(L, c->size ? (const char *)c->data : &empty, c->size); - return 1; - } - case OP_PUSH_BYTE_TABLE: { - const uint8_t *bytes = (const uint8_t *)c->data; - size_t index; - if (c->input_count != 0 || c->size > INT_MAX || (c->size != 0 && !bytes)) - return fail(L, "CheatEngine.SDK protected byte-table push has invalid arguments"); - if (!a->checkstack(L, 2)) - return fail(L, "CheatEngine.SDK protected byte-table push could not reserve stack slots"); - a->createtable(L, (int)c->size, 0); - for (index = 0; index < c->size; ++index) { - a->pushinteger(L, (lua_Integer)bytes[index]); - a->rawseti(L, -2, (lua_Integer)(index + 1)); + switch (c->op) + { + case OP_PUSH_BYTES: + { + static const char empty = 0; + if (c->input_count != 0 || (c->size != 0 && !c->data)) + return fail(L, "CheatEngine.SDK protected byte push has invalid arguments"); + a->pushlstring(L, c->size ? (const char*)c->data : &empty, c->size); + return 1; + } + case OP_PUSH_BYTE_TABLE: + { + const uint8_t* bytes = c->data; + size_t index; + if (c->input_count != 0 || c->size > INT_MAX || (c->size != 0 && !bytes)) + return fail(L, "CheatEngine.SDK protected byte-table push has invalid arguments"); + if (!a->checkstack(L, 2)) + return fail(L, "CheatEngine.SDK protected byte-table push could not reserve stack slots"); + a->createtable(L, (int)c->size, 0); + for (index = 0; index < c->size; ++index) + { + a->pushinteger(L, (lua_Integer)bytes[index]); + a->rawseti(L, -2, (lua_Integer)(index + 1)); + } + return 1; } - return 1; - } case OP_CREATE_TABLE: if (c->input_count != 0 || !is_nonnegative_int(c->a) || !is_nonnegative_int(c->b)) return fail(L, "CheatEngine.SDK protected table creation has invalid capacities"); @@ -246,16 +303,25 @@ static int CHEATENGINE_SDK_CALL operation(lua_State *L) { return fail(L, "CheatEngine.SDK protected pointer raw set has invalid inputs"); a->rawsetp(L, 1, c->data); return 0; - case OP_REF: { - /* Argument one is the value. Get/create an SDK-private registry table keyed by stableKey. */ - if (c->input_count != 1) - return fail(L, "CheatEngine.SDK protected reference creation has invalid inputs"); - a->rawgetp(L, LUA_REGISTRYINDEX, c->data); - if (a->type(L, -1) != LUA_TTABLE) { a->settop(L, -2); a->createtable(L, 0, 0); a->pushlightuserdata(L, (void *)c->data); a->rotate(L, -2, 1); a->rawset(L, LUA_REGISTRYINDEX); a->rawgetp(L, LUA_REGISTRYINDEX, c->data); } - a->rotate(L, 1, 1); /* table, value */ - a->pushinteger(L, (lua_Integer)a->l_ref(L, 1)); - return 1; - } + case OP_REF: + { + /* Argument one is the value. Get/create an SDK-private registry table keyed by stableKey. */ + if (c->input_count != 1) + return fail(L, "CheatEngine.SDK protected reference creation has invalid inputs"); + a->rawgetp(L, LUA_REGISTRYINDEX, c->data); + if (a->type(L, -1) != LUA_TTABLE) + { + a->settop(L, -2); + a->createtable(L, 0, 0); + a->pushlightuserdata(L, (void*)c->data); + a->rotate(L, -2, 1); + a->rawset(L, LUA_REGISTRYINDEX); + a->rawgetp(L, LUA_REGISTRYINDEX, c->data); + } + a->rotate(L, 1, 1); /* table, value */ + a->pushinteger(L, (lua_Integer)a->l_ref(L, 1)); + return 1; + } case OP_PUSH_REF: if (c->input_count != 0) return fail(L, "CheatEngine.SDK protected reference push has invalid inputs"); @@ -275,7 +341,7 @@ static int CHEATENGINE_SDK_CALL operation(lua_State *L) { case OP_PUSH_HOST_OBJECT: if (c->input_count != 0 || !c->data) return fail(L, "CheatEngine.SDK host-object pusher is unavailable"); - ((cheatengine_sdk_host_object_pusher)c->data)(L, (void *)c->a); + ((cheatengine_sdk_host_object_pusher)c->data)(L, (void*)c->a); return 1; default: return fail(L, "CheatEngine.SDK protected Lua operation is invalid"); @@ -284,10 +350,13 @@ static int CHEATENGINE_SDK_CALL operation(lua_State *L) { /* Inputs already sit at top. A light C function needs no allocation, then pcall moves all * allocating/raising work below Lua's protected boundary. */ -CHEATENGINE_SDK_EXPORT int CHEATENGINE_SDK_CALL cheatengine_sdk_lua_protected(lua_State *L, const cheatengine_sdk_lua_exports *api, int op, int input_count, const void *data, size_t size, intptr_t a, intptr_t b) { +CHEATENGINE_SDK_EXPORT int CHEATENGINE_SDK_CALL cheatengine_sdk_lua_protected( + lua_State* L, const cheatengine_sdk_lua_exports* api, int op, int input_count, const void* data, size_t size, + intptr_t a, intptr_t b) +{ int top; call_context context; - call_context *previous; + call_context* previous; int status; if (!L || !lua_exports_are_complete(api) || input_count < 0 || input_count == INT_MAX) diff --git a/native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll b/native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll index d160e846..11808ce6 100644 Binary files a/native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll and b/native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll differ diff --git a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs index dbd0ab2a..98d6924b 100644 --- a/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs +++ b/source-generators/CheatEngine.SDK.SourceGenerators.Shared/Shapes/PluginShape.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Linq; using Microsoft.CodeAnalysis; @@ -327,12 +328,10 @@ private static bool HasRequiredMembers(INamedTypeSymbol type) { for (INamedTypeSymbol? current = type; current is not null; current = current.BaseType) { - foreach (ISymbol member in current.GetMembers()) + if (current.GetMembers().Any(static member => + member is IPropertySymbol { IsRequired: true } or IFieldSymbol { IsRequired: true })) { - if (member is IPropertySymbol { IsRequired: true } or IFieldSymbol { IsRequired: true }) - { - return true; - } + return true; } } @@ -347,16 +346,9 @@ private static bool IsObsoleteError(ISymbol symbol, INamedTypeSymbol? obsoleteAt return false; } - foreach (AttributeData attribute in symbol.GetAttributes()) - { - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, obsoleteAttribute) - && attribute.ConstructorArguments is [_, { Value: true } _]) - { - return true; - } - } - - return false; + return symbol.GetAttributes().Any(attribute => + SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, obsoleteAttribute) + && attribute.ConstructorArguments is [_, { Value: true } _]); } private static bool HasAttribute(ISymbol symbol, INamedTypeSymbol? attributeClass) @@ -366,15 +358,8 @@ private static bool HasAttribute(ISymbol symbol, INamedTypeSymbol? attributeClas return false; } - foreach (AttributeData attribute in symbol.GetAttributes()) - { - if (SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)) - { - return true; - } - } - - return false; + return symbol.GetAttributes().Any(attribute => + SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, attributeClass)); } // The generated factory is a top-level type of the same assembly with no inheritance relation to the plugin: diff --git a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureManagedComparisonTests.cs b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureManagedComparisonTests.cs index f2e9769f..509142ec 100644 --- a/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureManagedComparisonTests.cs +++ b/tests/CheatEngine.SDK.Abi.Tests/Fixture/NativeAbiFixtureManagedComparisonTests.cs @@ -1,5 +1,5 @@ -using System.Collections.Generic; using System.Globalization; + using CheatEngine.SDK.Abi.Native; using CheatEngine.SDK.Abi.Tests.Support; @@ -12,237 +12,251 @@ namespace CheatEngine.SDK.Abi.Tests.Fixture; /// public sealed unsafe class NativeAbiFixtureManagedComparisonTests { - /// Name of the CI-provided absolute path to the validated native fixture facts file. - internal const string FactsPathEnvironmentVariable = "CE77_NATIVE_ABI_FACTS_PATH"; - - /// Name of the opt-in gate that makes the native fixture facts mandatory. - internal const string RequiredEnvironmentVariable = "CE77_NATIVE_ABI_REQUIRED"; - - [Fact] - public void Native_fixture_layout_facts_match_the_managed_x64_measurements_when_CI_supplies_them() - { - var factsPath = ResolveFactsPath( - Environment.GetEnvironmentVariable(FactsPathEnvironmentVariable), - Environment.GetEnvironmentVariable(RequiredEnvironmentVariable)); - if (factsPath is null) - { - Assert.Null(factsPath); - return; - } - - Assert.True(Layout.Is64BitProcess, Layout.Requires64BitProcess); - - var nativeFacts = ReadFacts(factsPath); - var managedFacts = CreateManagedLayoutFacts(); - foreach (var managedFact in managedFacts) - { - Assert.True(nativeFacts.TryGetValue(managedFact.Key, out var nativeValue), - $"The native ABI fixture omitted '{managedFact.Key}'."); - Assert.Equal(managedFact.Value, nativeValue); - } - } - - [Fact] - public void Native_fixture_facts_path_is_optional_when_required_mode_is_not_enabled() - { - Assert.Null(ResolveFactsPath(null, null)); - } - - [Theory] - [InlineData(null)] - [InlineData("")] - [InlineData(" \t")] - public void Native_fixture_required_mode_rejects_an_absent_facts_path(string? factsPath) - { - var exception = Assert.Throws(() => ResolveFactsPath(factsPath, "true")); - - Assert.Equal( - $"'{RequiredEnvironmentVariable}=true' requires '{FactsPathEnvironmentVariable}' to name a validated native ABI fixture facts file.", - exception.Message); - } - - [Fact] - public void Native_fixture_comparison_rejects_a_supplied_missing_facts_file() - { - var factsPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.txt"); - - var exception = Assert.Throws(() => ReadFacts(factsPath)); - - Assert.Equal(factsPath, exception.FileName); - } - - private static string? ResolveFactsPath(string? factsPath, string? requiredMode) - { - if (!string.IsNullOrWhiteSpace(factsPath)) return factsPath; - - if (string.Equals(requiredMode, "true", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidOperationException( - $"'{RequiredEnvironmentVariable}=true' requires '{FactsPathEnvironmentVariable}' to name a validated native ABI fixture facts file."); - } - - return null; - } - - private static Dictionary ReadFacts(string factsPath) - { - if (!File.Exists(factsPath)) - { - throw new FileNotFoundException($"The native ABI fixture facts file '{factsPath}' was not found.", factsPath); - } - - var facts = new Dictionary(StringComparer.Ordinal); - foreach (var line in File.ReadLines(factsPath)) - { - if (string.IsNullOrWhiteSpace(line)) continue; - - var separator = line.IndexOf('='); - Assert.True(separator > 0, $"The native ABI fixture fact '{line}' is not key=value."); - var key = line[..separator]; - var value = line[(separator + 1)..]; - Assert.True(facts.TryAdd(key, value), $"The native ABI fixture emitted duplicate fact '{key}'."); - } - - return facts; - } - - private static Dictionary CreateManagedLayoutFacts() - { - var facts = new Dictionary(StringComparer.Ordinal); - AddLayout(facts, "plugin_version"); - AddLayout(facts, "plugin_type0_record"); - AddLayout(facts, "plugin_type0_init"); - AddLayout(facts, "plugin_type1_init"); - AddLayout(facts, "plugin_type2_init"); - AddLayout(facts, "plugin_type3_init"); - AddLayout(facts, "plugin_type4_init"); - AddLayout(facts, "plugin_type5_init"); - AddLayout(facts, "plugin_type6_init"); - AddLayout(facts, "plugin_type7_init"); - AddLayout(facts, "plugin_type8_init"); - AddLayout(facts, "register_modification_info"); - AddLayout(facts, "exported_functions_prefix"); - - PluginVersion pluginVersion = default; - void* pluginVersionOrigin = &pluginVersion; - AddOffset(facts, "plugin_version.Version", Layout.OffsetOf(pluginVersionOrigin, &pluginVersion.Version)); - AddOffset(facts, "plugin_version.PluginName", Layout.OffsetOf(pluginVersionOrigin, &pluginVersion.PluginName)); - - PluginType0Record pluginType0Record = default; - void* pluginType0RecordOrigin = &pluginType0Record; - AddOffset(facts, "plugin_type0_record.InterpretedAddress", - Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.InterpretedAddress)); - AddOffset(facts, "plugin_type0_record.Address", Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Address)); - AddOffset(facts, "plugin_type0_record.IsPointer", Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.IsPointer)); - AddOffset(facts, "plugin_type0_record.CountOffsets", - Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.CountOffsets)); - AddOffset(facts, "plugin_type0_record.Offsets", Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Offsets)); - AddOffset(facts, "plugin_type0_record.Description", - Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Description)); - AddOffset(facts, "plugin_type0_record.ValueType", Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.ValueType)); - AddOffset(facts, "plugin_type0_record.Size", Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Size)); - - AddPluginInitOffsets(facts); - AddRegisterModificationOffsets(facts); - AddExportedFunctionsPrefixOffsets(facts); - return facts; - } - - private static void AddPluginInitOffsets(Dictionary facts) - { - AddressListPluginInit type0 = default; - void* type0Origin = &type0; - AddOffset(facts, "plugin_type0_init.Name", Layout.OffsetOf(type0Origin, &type0.Name)); - AddOffset(facts, "plugin_type0_init.Callback", Layout.OffsetOf(type0Origin, &type0.Callback)); - - MemoryViewPluginInit type1 = default; - void* type1Origin = &type1; - AddOffset(facts, "plugin_type1_init.Name", Layout.OffsetOf(type1Origin, &type1.Name)); - AddOffset(facts, "plugin_type1_init.Callback", Layout.OffsetOf(type1Origin, &type1.Callback)); - AddOffset(facts, "plugin_type1_init.Shortcut", Layout.OffsetOf(type1Origin, &type1.Shortcut)); - - DebugEventPluginInit type2 = default; - AddOffset(facts, "plugin_type2_init.Callback", Layout.OffsetOf(&type2, &type2.Callback)); - - ProcessWatcherPluginInit type3 = default; - AddOffset(facts, "plugin_type3_init.Callback", Layout.OffsetOf(&type3, &type3.Callback)); - - FunctionPointerChangePluginInit type4 = default; - AddOffset(facts, "plugin_type4_init.Callback", Layout.OffsetOf(&type4, &type4.Callback)); - - MainMenuPluginInit type5 = default; - void* type5Origin = &type5; - AddOffset(facts, "plugin_type5_init.Name", Layout.OffsetOf(type5Origin, &type5.Name)); - AddOffset(facts, "plugin_type5_init.Callback", Layout.OffsetOf(type5Origin, &type5.Callback)); - AddOffset(facts, "plugin_type5_init.Shortcut", Layout.OffsetOf(type5Origin, &type5.Shortcut)); - - DisassemblerContextPluginInit type6 = default; - void* type6Origin = &type6; - AddOffset(facts, "plugin_type6_init.Name", Layout.OffsetOf(type6Origin, &type6.Name)); - AddOffset(facts, "plugin_type6_init.Callback", Layout.OffsetOf(type6Origin, &type6.Callback)); - AddOffset(facts, "plugin_type6_init.CallbackOnPopup", Layout.OffsetOf(type6Origin, &type6.CallbackOnPopup)); - AddOffset(facts, "plugin_type6_init.Shortcut", Layout.OffsetOf(type6Origin, &type6.Shortcut)); - - DisassemblerRenderLinePluginInit type7 = default; - AddOffset(facts, "plugin_type7_init.Callback", Layout.OffsetOf(&type7, &type7.Callback)); - - AutoAssemblerPluginInit type8 = default; - AddOffset(facts, "plugin_type8_init.Callback", Layout.OffsetOf(&type8, &type8.Callback)); - } - - private static void AddRegisterModificationOffsets(Dictionary facts) - { - RegisterModificationInfo info = default; - void* origin = &info; - AddOffset(facts, "register_modification_info.Address", Layout.OffsetOf(origin, &info.Address)); - AddOffset(facts, "register_modification_info.ChangeEax", Layout.OffsetOf(origin, &info.ChangeEax)); - AddOffset(facts, "register_modification_info.ChangeR15", Layout.OffsetOf(origin, &info.ChangeR15)); - AddOffset(facts, "register_modification_info.ChangeOf", Layout.OffsetOf(origin, &info.ChangeOf)); - AddOffset(facts, "register_modification_info.NewEax", Layout.OffsetOf(origin, &info.NewEax)); - AddOffset(facts, "register_modification_info.NewR15", Layout.OffsetOf(origin, &info.NewR15)); - AddOffset(facts, "register_modification_info.NewCf", Layout.OffsetOf(origin, &info.NewCf)); - AddOffset(facts, "register_modification_info.NewOf", Layout.OffsetOf(origin, &info.NewOf)); - } - - private static void AddExportedFunctionsPrefixOffsets(Dictionary facts) - { - ExportedFunctionsPrefix exports = default; - void* origin = &exports; - AddOffset(facts, "exported_functions_prefix.SizeOfExportedFunctions", - Layout.OffsetOf(origin, &exports.SizeOfExportedFunctions)); - AddOffset(facts, "exported_functions_prefix.ShowMessage", Layout.OffsetOf(origin, &exports.ShowMessage)); - AddOffset(facts, "exported_functions_prefix.RegisterFunction", Layout.OffsetOf(origin, &exports.RegisterFunction)); - AddOffset(facts, "exported_functions_prefix.UnregisterFunction", - Layout.OffsetOf(origin, &exports.UnregisterFunction)); - AddOffset(facts, "exported_functions_prefix.OpenedProcessId", Layout.OffsetOf(origin, &exports.OpenedProcessId)); - AddOffset(facts, "exported_functions_prefix.OpenedProcessHandle", - Layout.OffsetOf(origin, &exports.OpenedProcessHandle)); - AddOffset(facts, "exported_functions_prefix.GetMainWindowHandle", - Layout.OffsetOf(origin, &exports.GetMainWindowHandle)); - AddOffset(facts, "exported_functions_prefix.AutoAssemble", Layout.OffsetOf(origin, &exports.AutoAssemble)); - AddOffset(facts, "exported_functions_prefix.Assembler", Layout.OffsetOf(origin, &exports.Assembler)); - AddOffset(facts, "exported_functions_prefix.Disassembler", Layout.OffsetOf(origin, &exports.Disassembler)); - AddOffset(facts, "exported_functions_prefix.ChangeRegistersAtAddress", - Layout.OffsetOf(origin, &exports.ChangeRegistersAtAddress)); - AddOffset(facts, "exported_functions_prefix.InjectDll", Layout.OffsetOf(origin, &exports.InjectDll)); - AddOffset(facts, "exported_functions_prefix.FreezeMemory", Layout.OffsetOf(origin, &exports.FreezeMemory)); - AddOffset(facts, "exported_functions_prefix.UnfreezeMemory", Layout.OffsetOf(origin, &exports.UnfreezeMemory)); - AddOffset(facts, "exported_functions_prefix.FixMemory", Layout.OffsetOf(origin, &exports.FixMemory)); - AddOffset(facts, "exported_functions_prefix.ProcessList", Layout.OffsetOf(origin, &exports.ProcessList)); - AddOffset(facts, "exported_functions_prefix.ReloadSettings", Layout.OffsetOf(origin, &exports.ReloadSettings)); - AddOffset(facts, "exported_functions_prefix.GetAddressFromPointer", - Layout.OffsetOf(origin, &exports.GetAddressFromPointer)); - } - - private static void AddLayout(Dictionary facts, string key) - where T : unmanaged - { - facts.Add($"sizeof.{key}", Layout.SizeOf().ToString(CultureInfo.InvariantCulture)); - facts.Add($"alignof.{key}", Layout.AlignmentOf().ToString(CultureInfo.InvariantCulture)); - } - - private static void AddOffset(Dictionary facts, string key, int value) - { - facts.Add($"offsetof.{key}", value.ToString(CultureInfo.InvariantCulture)); - } + /// Name of the CI-provided absolute path to the validated native fixture facts file. + internal const string FactsPathEnvironmentVariable = "CE77_NATIVE_ABI_FACTS_PATH"; + + /// Name of the opt-in gate that makes the native fixture facts mandatory. + internal const string RequiredEnvironmentVariable = "CE77_NATIVE_ABI_REQUIRED"; + + [Fact] + public void Native_fixture_layout_facts_match_the_managed_x64_measurements_when_CI_supplies_them() + { + string? factsPath = ResolveFactsPath( + Environment.GetEnvironmentVariable(FactsPathEnvironmentVariable), + Environment.GetEnvironmentVariable(RequiredEnvironmentVariable)); + if (factsPath is null) + { + Assert.Null(factsPath); + return; + } + + Assert.True(Layout.Is64BitProcess, Layout.Requires64BitProcess); + + Dictionary nativeFacts = ReadFacts(factsPath); + Dictionary managedFacts = CreateManagedLayoutFacts(); + foreach (KeyValuePair managedFact in managedFacts) + { + Assert.True(nativeFacts.TryGetValue(managedFact.Key, out string? nativeValue), + $"The native ABI fixture omitted '{managedFact.Key}'."); + Assert.Equal(managedFact.Value, nativeValue); + } + } + + [Fact] + public void Native_fixture_facts_path_is_optional_when_required_mode_is_not_enabled() + { + Assert.Null(ResolveFactsPath(null, null)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" \t")] + public void Native_fixture_required_mode_rejects_an_absent_facts_path(string? factsPath) + { + InvalidOperationException exception = + Assert.Throws(() => ResolveFactsPath(factsPath, "true")); + + Assert.Equal( + $"'{RequiredEnvironmentVariable}=true' requires '{FactsPathEnvironmentVariable}' to name a validated native ABI fixture facts file.", + exception.Message); + } + + [Fact] + public void Native_fixture_comparison_rejects_a_supplied_missing_facts_file() + { + string factsPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid():N}.txt"); + + FileNotFoundException exception = Assert.Throws(() => ReadFacts(factsPath)); + + Assert.Equal(factsPath, exception.FileName); + } + + private static string? ResolveFactsPath(string? factsPath, string? requiredMode) + { + if (!string.IsNullOrWhiteSpace(factsPath)) + { + return factsPath; + } + + if (string.Equals(requiredMode, "true", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"'{RequiredEnvironmentVariable}=true' requires '{FactsPathEnvironmentVariable}' to name a validated native ABI fixture facts file."); + } + + return null; + } + + private static Dictionary ReadFacts(string factsPath) + { + if (!File.Exists(factsPath)) + { + throw new FileNotFoundException($"The native ABI fixture facts file '{factsPath}' was not found.", + factsPath); + } + + Dictionary facts = new(StringComparer.Ordinal); + foreach (string line in File.ReadLines(factsPath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + int separator = line.IndexOf('='); + Assert.True(separator > 0, $"The native ABI fixture fact '{line}' is not key=value."); + string key = line[..separator]; + string value = line[(separator + 1)..]; + Assert.True(facts.TryAdd(key, value), $"The native ABI fixture emitted duplicate fact '{key}'."); + } + + return facts; + } + + private static Dictionary CreateManagedLayoutFacts() + { + Dictionary facts = new(StringComparer.Ordinal); + AddLayout(facts, "plugin_version"); + AddLayout(facts, "plugin_type0_record"); + AddLayout(facts, "plugin_type0_init"); + AddLayout(facts, "plugin_type1_init"); + AddLayout(facts, "plugin_type2_init"); + AddLayout(facts, "plugin_type3_init"); + AddLayout(facts, "plugin_type4_init"); + AddLayout(facts, "plugin_type5_init"); + AddLayout(facts, "plugin_type6_init"); + AddLayout(facts, "plugin_type7_init"); + AddLayout(facts, "plugin_type8_init"); + AddLayout(facts, "register_modification_info"); + AddLayout(facts, "exported_functions_prefix"); + + PluginVersion pluginVersion = default; + void* pluginVersionOrigin = &pluginVersion; + AddOffset(facts, "plugin_version.Version", Layout.OffsetOf(pluginVersionOrigin, &pluginVersion.Version)); + AddOffset(facts, "plugin_version.PluginName", Layout.OffsetOf(pluginVersionOrigin, &pluginVersion.PluginName)); + + PluginType0Record pluginType0Record = default; + void* pluginType0RecordOrigin = &pluginType0Record; + AddOffset(facts, "plugin_type0_record.InterpretedAddress", + Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.InterpretedAddress)); + AddOffset(facts, "plugin_type0_record.Address", + Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Address)); + AddOffset(facts, "plugin_type0_record.IsPointer", + Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.IsPointer)); + AddOffset(facts, "plugin_type0_record.CountOffsets", + Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.CountOffsets)); + AddOffset(facts, "plugin_type0_record.Offsets", + Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Offsets)); + AddOffset(facts, "plugin_type0_record.Description", + Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Description)); + AddOffset(facts, "plugin_type0_record.ValueType", + Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.ValueType)); + AddOffset(facts, "plugin_type0_record.Size", Layout.OffsetOf(pluginType0RecordOrigin, &pluginType0Record.Size)); + + AddPluginInitOffsets(facts); + AddRegisterModificationOffsets(facts); + AddExportedFunctionsPrefixOffsets(facts); + return facts; + } + + private static void AddPluginInitOffsets(Dictionary facts) + { + AddressListPluginInit type0 = default; + void* type0Origin = &type0; + AddOffset(facts, "plugin_type0_init.Name", Layout.OffsetOf(type0Origin, &type0.Name)); + AddOffset(facts, "plugin_type0_init.Callback", Layout.OffsetOf(type0Origin, &type0.Callback)); + + MemoryViewPluginInit type1 = default; + void* type1Origin = &type1; + AddOffset(facts, "plugin_type1_init.Name", Layout.OffsetOf(type1Origin, &type1.Name)); + AddOffset(facts, "plugin_type1_init.Callback", Layout.OffsetOf(type1Origin, &type1.Callback)); + AddOffset(facts, "plugin_type1_init.Shortcut", Layout.OffsetOf(type1Origin, &type1.Shortcut)); + + DebugEventPluginInit type2 = default; + AddOffset(facts, "plugin_type2_init.Callback", Layout.OffsetOf(&type2, &type2.Callback)); + + ProcessWatcherPluginInit type3 = default; + AddOffset(facts, "plugin_type3_init.Callback", Layout.OffsetOf(&type3, &type3.Callback)); + + FunctionPointerChangePluginInit type4 = default; + AddOffset(facts, "plugin_type4_init.Callback", Layout.OffsetOf(&type4, &type4.Callback)); + + MainMenuPluginInit type5 = default; + void* type5Origin = &type5; + AddOffset(facts, "plugin_type5_init.Name", Layout.OffsetOf(type5Origin, &type5.Name)); + AddOffset(facts, "plugin_type5_init.Callback", Layout.OffsetOf(type5Origin, &type5.Callback)); + AddOffset(facts, "plugin_type5_init.Shortcut", Layout.OffsetOf(type5Origin, &type5.Shortcut)); + + DisassemblerContextPluginInit type6 = default; + void* type6Origin = &type6; + AddOffset(facts, "plugin_type6_init.Name", Layout.OffsetOf(type6Origin, &type6.Name)); + AddOffset(facts, "plugin_type6_init.Callback", Layout.OffsetOf(type6Origin, &type6.Callback)); + AddOffset(facts, "plugin_type6_init.CallbackOnPopup", Layout.OffsetOf(type6Origin, &type6.CallbackOnPopup)); + AddOffset(facts, "plugin_type6_init.Shortcut", Layout.OffsetOf(type6Origin, &type6.Shortcut)); + + DisassemblerRenderLinePluginInit type7 = default; + AddOffset(facts, "plugin_type7_init.Callback", Layout.OffsetOf(&type7, &type7.Callback)); + + AutoAssemblerPluginInit type8 = default; + AddOffset(facts, "plugin_type8_init.Callback", Layout.OffsetOf(&type8, &type8.Callback)); + } + + private static void AddRegisterModificationOffsets(Dictionary facts) + { + RegisterModificationInfo info = default; + void* origin = &info; + AddOffset(facts, "register_modification_info.Address", Layout.OffsetOf(origin, &info.Address)); + AddOffset(facts, "register_modification_info.ChangeEax", Layout.OffsetOf(origin, &info.ChangeEax)); + AddOffset(facts, "register_modification_info.ChangeR15", Layout.OffsetOf(origin, &info.ChangeR15)); + AddOffset(facts, "register_modification_info.ChangeOf", Layout.OffsetOf(origin, &info.ChangeOf)); + AddOffset(facts, "register_modification_info.NewEax", Layout.OffsetOf(origin, &info.NewEax)); + AddOffset(facts, "register_modification_info.NewR15", Layout.OffsetOf(origin, &info.NewR15)); + AddOffset(facts, "register_modification_info.NewCf", Layout.OffsetOf(origin, &info.NewCf)); + AddOffset(facts, "register_modification_info.NewOf", Layout.OffsetOf(origin, &info.NewOf)); + } + + private static void AddExportedFunctionsPrefixOffsets(Dictionary facts) + { + ExportedFunctionsPrefix exports = default; + void* origin = &exports; + AddOffset(facts, "exported_functions_prefix.SizeOfExportedFunctions", + Layout.OffsetOf(origin, &exports.SizeOfExportedFunctions)); + AddOffset(facts, "exported_functions_prefix.ShowMessage", Layout.OffsetOf(origin, &exports.ShowMessage)); + AddOffset(facts, "exported_functions_prefix.RegisterFunction", + Layout.OffsetOf(origin, &exports.RegisterFunction)); + AddOffset(facts, "exported_functions_prefix.UnregisterFunction", + Layout.OffsetOf(origin, &exports.UnregisterFunction)); + AddOffset(facts, "exported_functions_prefix.OpenedProcessId", + Layout.OffsetOf(origin, &exports.OpenedProcessId)); + AddOffset(facts, "exported_functions_prefix.OpenedProcessHandle", + Layout.OffsetOf(origin, &exports.OpenedProcessHandle)); + AddOffset(facts, "exported_functions_prefix.GetMainWindowHandle", + Layout.OffsetOf(origin, &exports.GetMainWindowHandle)); + AddOffset(facts, "exported_functions_prefix.AutoAssemble", Layout.OffsetOf(origin, &exports.AutoAssemble)); + AddOffset(facts, "exported_functions_prefix.Assembler", Layout.OffsetOf(origin, &exports.Assembler)); + AddOffset(facts, "exported_functions_prefix.Disassembler", Layout.OffsetOf(origin, &exports.Disassembler)); + AddOffset(facts, "exported_functions_prefix.ChangeRegistersAtAddress", + Layout.OffsetOf(origin, &exports.ChangeRegistersAtAddress)); + AddOffset(facts, "exported_functions_prefix.InjectDll", Layout.OffsetOf(origin, &exports.InjectDll)); + AddOffset(facts, "exported_functions_prefix.FreezeMemory", Layout.OffsetOf(origin, &exports.FreezeMemory)); + AddOffset(facts, "exported_functions_prefix.UnfreezeMemory", Layout.OffsetOf(origin, &exports.UnfreezeMemory)); + AddOffset(facts, "exported_functions_prefix.FixMemory", Layout.OffsetOf(origin, &exports.FixMemory)); + AddOffset(facts, "exported_functions_prefix.ProcessList", Layout.OffsetOf(origin, &exports.ProcessList)); + AddOffset(facts, "exported_functions_prefix.ReloadSettings", Layout.OffsetOf(origin, &exports.ReloadSettings)); + AddOffset(facts, "exported_functions_prefix.GetAddressFromPointer", + Layout.OffsetOf(origin, &exports.GetAddressFromPointer)); + } + + private static void AddLayout(Dictionary facts, string key) + where T : unmanaged + { + facts.Add($"sizeof.{key}", Layout.SizeOf().ToString(CultureInfo.InvariantCulture)); + facts.Add($"alignof.{key}", Layout.AlignmentOf().ToString(CultureInfo.InvariantCulture)); + } + + private static void AddOffset(Dictionary facts, string key, int value) + { + facts.Add($"offsetof.{key}", value.ToString(CultureInfo.InvariantCulture)); + } } diff --git a/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs b/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs index 5bee2180..59e14150 100644 --- a/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs +++ b/tests/CheatEngine.SDK.Analyzers.Tests/Architecture/LuaDirectApiBoundaryGuardTests.cs @@ -185,6 +185,60 @@ void PushUncheckedFunction(lua_CFunction thunk) Assert.Empty(violations); } + [Fact] + public void Guard_allows_the_audited_fast_path_when_the_throw_guard_uses_a_block() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectSource(policy, """ + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; + + unsafe struct C + { + private lua_State* Pointer; + + void PushUncheckedFunction(lua_CFunction thunk) + { + if (lua_checkstack(Pointer, 1) == 0) + { + throw new InvalidOperationException(); + } + + lua_pushcclosure(Pointer, thunk, 0); + } + } + """, LightCFunctionFastPathSourcePath); + + Assert.Empty(violations); + } + + [Fact] + public void Guard_rejects_the_audited_fast_path_when_a_braced_throw_guard_contains_a_Lua_call() + { + LuaDirectApiPolicy policy = LoadPolicy(); + List violations = InspectSource(policy, """ + using static CheatEngine.SDK.Lua.Interop.Api.LuaApi; + + unsafe struct C + { + private lua_State* Pointer; + + void PushUncheckedFunction(lua_CFunction thunk) + { + if (lua_checkstack(Pointer, 1) == 0) + { + lua_pushinteger(Pointer, 42); + throw new InvalidOperationException(); + } + + lua_pushcclosure(Pointer, thunk, 0); + } + } + """, LightCFunctionFastPathSourcePath); + + GuardViolation violation = Assert.Single(violations); + Assert.Equal("lua_pushcclosure", violation.MemberName); + } + [Fact] public void Guard_reports_the_audited_path_when_a_Lua_call_interrupts_the_reservation_and_push() { diff --git a/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs b/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs index b8d47b6f..b5d0e195 100644 --- a/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs +++ b/tests/CheatEngine.SDK.Benchmarks/CallbackBenchmarks.cs @@ -16,7 +16,7 @@ namespace CheatEngine.SDK.Benchmarks; /// [MemoryDiagnoser(false)] [BenchmarkCategory("Callbacks")] -public class CallbackBenchmarks : IDisposable +public sealed class CallbackBenchmarks : IDisposable { /// Lua-side calls per invocation of . private const int LoopCount = 1000; @@ -28,10 +28,20 @@ public class CallbackBenchmarks : IDisposable /// public void Dispose() { + Dispose(true); + GC.SuppressFinalize(this); + } + + private void Dispose(bool disposing) + { + if (!disposing) + { + return; + } + _ = BenchFunctions.UnregisterLuaFunctions(_l); LuaRuntime.Detach(); _state?.Dispose(); - GC.SuppressFinalize(this); } /// Opens a state, registers the thunk and defines the Lua-side loop that calls it times. @@ -42,7 +52,7 @@ public void Setup() _state = new NativeLuaState(); _l = FakeHostRuntime.Attach(_state, false); - var registered = BenchFunctions.RegisterLuaFunctions(_l); + LuaStatus registered = BenchFunctions.RegisterLuaFunctions(_l); if (!registered.IsOk) { throw new InvalidOperationException("RegisterLuaFunctions failed: " + LuaError.FromStack(_l, registered)); @@ -60,7 +70,7 @@ public void Setup() /// /// Unregisters the thunk, detaches the ambient runtime and closes the state. BenchmarkDotNet does not call - /// itself; this is what [GlobalCleanup] is for. + /// itself; this is what [GlobalCleanup] is for. /// [GlobalCleanup] public void Cleanup() diff --git a/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionOperationsTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionOperationsTests.cs index e30df03c..8e618dc8 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionOperationsTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Assembly/InstructionOperationsTests.cs @@ -1,8 +1,10 @@ -using System; +using System.Text; + using CheatEngine.SDK.Engine.Assembly; using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Engine.Values; +using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; namespace CheatEngine.SDK.Engine.Tests.Assembly; @@ -14,354 +16,362 @@ namespace CheatEngine.SDK.Engine.Tests.Assembly; [Trait("Category", "NativeLua")] public sealed class InstructionOperationsTests { - [Fact] - public void ObserveCurrent_builds_an_x64_profile_from_coherent_target_probes_and_rejects_a_contradiction() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallInstructionGlobals(scope.State); - - InstructionOperationStatus observed = InstructionProfiles.TryObserveCurrent(out var targetProfile); - - Assert.Equal(InstructionOperationStatus.Success, observed); - Assert.Equal(4242, targetProfile.Target.Value); - Assert.Equal(CheatEngineArchitecture.X64, targetProfile.Profile.Architecture); - Assert.Equal(PointerSize.Bit64, targetProfile.Profile.AddressWidth); - - EngineTest.Run(scope.State, "instruction_target_is_x86 = true\ninstruction_target_is_64bit = true"u8); - InstructionOperationStatus contradictory = InstructionProfiles.TryObserveCurrent(out var rejectedProfile); - - Assert.Equal(InstructionOperationStatus.InvalidProfile, contradictory); - Assert.Equal(default, rejectedProfile); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void ObserveCurrent_uses_the_target_ARM_probe_and_matching_width_without_using_the_host_width() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallInstructionGlobals(scope.State); - - EngineTest.Run(scope.State, "instruction_target_is_64bit = false\ninstruction_target_is_arm = true"u8); - InstructionTargetProfile arm32 = Observe(scope.State); - - EngineTest.Run(scope.State, "instruction_target_is_64bit = true"u8); - InstructionTargetProfile arm64 = Observe(scope.State); - - Assert.Equal(CheatEngineArchitecture.Arm32, arm32.Profile.Architecture); - Assert.Equal(PointerSize.Bit32, arm32.Profile.AddressWidth); - Assert.Equal(CheatEngineArchitecture.Arm64, arm64.Profile.Architecture); - Assert.Equal(PointerSize.Bit64, arm64.Profile.AddressWidth); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Assemble_passes_the_explicit_relative_origin_and_copies_the_complete_byte_table() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallInstructionGlobals(scope.State); - InstructionTargetProfile targetProfile = Observe(scope.State); - Span destination = stackalloc byte[5]; - Address origin = 0x0000_0001_4000_1000UL; - - InstructionOperationStatus status = InstructionAssembler.TryAssemble(targetProfile, "jmp rel", origin, - destination, out var written, out var requiredLength); - - Assert.Equal(InstructionOperationStatus.Success, status); - Assert.Equal(5, written); - Assert.Equal(5, requiredLength); - Assert.Equal(new byte[] { 0xE9, 0xFB, 0xFF, 0xFF, 0xFF }, destination.ToArray()); - Assert.Equal(origin.ToInt64(), ReadInteger(scope.State, "instruction_assemble_origin")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Assemble_rejection_capacity_and_malformed_tables_do_not_publish_a_prefix() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallInstructionGlobals(scope.State); - InstructionTargetProfile targetProfile = Observe(scope.State); - - Span rejectedDestination = stackalloc byte[5]; - rejectedDestination.Fill(0xA5); - InstructionOperationStatus rejected = InstructionAssembler.TryAssemble(targetProfile, "reject", 0x140001000UL, - rejectedDestination, out var rejectedWritten, out var rejectedRequired); - - Assert.Equal(InstructionOperationStatus.InstructionRejected, rejected); - Assert.Equal(0, rejectedWritten); - Assert.Equal(0, rejectedRequired); - Assert.Equal(new byte[] { 0xA5, 0xA5, 0xA5, 0xA5, 0xA5 }, rejectedDestination.ToArray()); - - Span tooSmallDestination = stackalloc byte[4]; - tooSmallDestination.Fill(0xA5); - InstructionOperationStatus tooSmall = InstructionAssembler.TryAssemble(targetProfile, "jmp rel", 0x140001000UL, - tooSmallDestination, out var tooSmallWritten, out var tooSmallRequired); - - Assert.Equal(InstructionOperationStatus.DestinationTooSmall, tooSmall); - Assert.Equal(0, tooSmallWritten); - Assert.Equal(5, tooSmallRequired); - Assert.Equal(new byte[] { 0xA5, 0xA5, 0xA5, 0xA5 }, tooSmallDestination.ToArray()); - - Span malformedDestination = stackalloc byte[2]; - malformedDestination.Fill(0xA5); - InstructionOperationStatus malformed = InstructionAssembler.TryAssemble(targetProfile, "malformed", 0x140001000UL, - malformedDestination, out var malformedWritten, out var malformedRequired); - - Assert.Equal(InstructionOperationStatus.InvalidResult, malformed); - Assert.Equal(0, malformedWritten); - Assert.Equal(0, malformedRequired); - Assert.Equal(new byte[] { 0xA5, 0xA5 }, malformedDestination.ToArray()); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Assemble_rejects_an_address_wider_than_the_observed_x86_profile_before_entering_Lua() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallInstructionGlobals(scope.State); - EngineTest.Run(scope.State, "instruction_target_is_64bit = false\ninstruction_target_is_x86 = true"u8); - InstructionTargetProfile targetProfile = Observe(scope.State); - Span destination = stackalloc byte[5]; - - InstructionOperationStatus overwide = InstructionAssembler.TryAssemble(targetProfile, "jmp rel", 0x1_0000_0000UL, - destination, out var written, out var requiredLength); - - Assert.Equal(InstructionOperationStatus.AddressExceedsProfileWidth, overwide); - Assert.Equal(0, written); - Assert.Equal(0, requiredLength); - Assert.Equal(0, ReadInteger(scope.State, "instruction_assemble_calls")); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Assemble_reports_a_target_change_without_copying_the_returned_bytes() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallInstructionGlobals(scope.State); - InstructionTargetProfile targetProfile = Observe(scope.State); - EngineTest.Run(scope.State, "instruction_assemble_behavior = \"target-change\""u8); - Span destination = stackalloc byte[1]; - destination[0] = 0xA5; - - InstructionOperationStatus status = InstructionAssembler.TryAssemble(targetProfile, "nop", 0x140001000UL, - destination, out var written, out var requiredLength); - - Assert.Equal(InstructionOperationStatus.TargetChanged, status); - Assert.Equal(0, written); - Assert.Equal(0, requiredLength); - Assert.Equal(0xA5, destination[0]); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Disassemble_parses_the_fixture_line_inside_the_SDK_and_copies_all_text() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallInstructionGlobals(scope.State); - InstructionTargetProfile targetProfile = Observe(scope.State); - Address address = 0x0000_0001_4000_1000UL; - - InstructionOperationStatus status = InstructionDisassembler.TryDisassemble(targetProfile, address, 256, - out var instruction, out var requiredUtf8Bytes); - - Assert.Equal(InstructionOperationStatus.Success, status); - Assert.Equal(address, instruction.Address); - Assert.Equal("0000000140001000", instruction.AddressText); - Assert.Equal("E9 FB FF FF FF", instruction.Bytes); - Assert.Equal("jmp", instruction.Opcode); - Assert.Equal("0000000140001000", instruction.Extra); - Assert.Equal(49, instruction.Utf8ByteLength); - Assert.Equal(55, requiredUtf8Bytes); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Disassemble_enforces_the_raw_text_bound_and_rejects_malformed_split_results() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallInstructionGlobals(scope.State); - InstructionTargetProfile targetProfile = Observe(scope.State); - - InstructionOperationStatus bounded = InstructionDisassembler.TryDisassemble(targetProfile, 0x140001000UL, 4, - out var boundedInstruction, out var requiredUtf8Bytes); - InstructionOperationStatus malformed = InstructionDisassembler.TryDisassemble(targetProfile, 0xBADUL, 256, - out var malformedInstruction, out var malformedRequiredUtf8Bytes); - InstructionOperationStatus oversizedFields = InstructionDisassembler.TryDisassemble(targetProfile, 0xBADDUL, 10, - out var oversizedInstruction, out var oversizedRequiredUtf8Bytes); - - Assert.Equal(InstructionOperationStatus.OutputTooLong, bounded); - Assert.Equal(default, boundedInstruction); - Assert.Equal(55, requiredUtf8Bytes); - Assert.Equal(InstructionOperationStatus.InvalidResult, malformed); - Assert.Equal(default, malformedInstruction); - Assert.Equal(9, malformedRequiredUtf8Bytes); - Assert.Equal(InstructionOperationStatus.OutputTooLong, oversizedFields); - Assert.Equal(default, oversizedInstruction); - Assert.Equal(10, oversizedRequiredUtf8Bytes); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Missing_and_raising_instruction_globals_have_distinct_structured_outcomes() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using (HostScope missingScope = new(state)) - { - InstallProfileGlobals(missingScope.State); - InstructionTargetProfile targetProfile = Observe(missingScope.State); - InstructionOperationStatus unavailable = InstructionDisassembler.TryDisassemble(targetProfile, 0x140001000UL, - 256, out _, out _); - - Assert.Equal(InstructionOperationStatus.GlobalUnavailable, unavailable); - Assert.Equal(0, missingScope.State.Top); - } - - using HostScope raisingScope = new(state); - InstallInstructionGlobals(raisingScope.State); - InstructionTargetProfile raisingProfile = Observe(raisingScope.State); - InstructionOperationStatus failure = InstructionDisassembler.TryDisassemble(raisingProfile, 0xDEADUL, - 256, out _, out _); - - Assert.Equal(InstructionOperationStatus.LuaFailure, failure); - Assert.Equal(0, raisingScope.State.Top); - } - - [Fact] - public void Navigation_returns_a_positive_length_and_an_explicitly_estimated_previous_address() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallInstructionGlobals(scope.State); - InstructionTargetProfile targetProfile = Observe(scope.State); - - InstructionOperationStatus lengthStatus = InstructionNavigator.TryGetLength(targetProfile, 0x140001000UL, - out var length); - InstructionOperationStatus previousStatus = InstructionNavigator.TryGetPrevious(targetProfile, 0x140001000UL, - out var previous); - - Assert.Equal(InstructionOperationStatus.Success, lengthStatus); - Assert.Equal(5, length); - Assert.Equal(InstructionOperationStatus.Success, previousStatus); - Assert.Equal(0x140000FFBUL, previous.Value); - Assert.Equal(0, scope.State.Top); - } - - [Fact] - public void Navigation_rejects_nonpositive_or_string_lengths_and_addresses_wider_than_the_profile() - { - EngineTest.RequireNativeLua(); - using NativeLuaState state = new(); - using HostScope scope = new(state); - InstallInstructionGlobals(scope.State); - InstructionTargetProfile x64Profile = Observe(scope.State); - - InstructionOperationStatus invalidLength = InstructionNavigator.TryGetLength(x64Profile, 0UL, out var zeroLength); - InstructionOperationStatus stringLength = InstructionNavigator.TryGetLength(x64Profile, 1UL, out var coercedLength); - - EngineTest.Run(scope.State, "instruction_target_is_64bit = false\ninstruction_target_is_x86 = true"u8); - InstructionTargetProfile x86Profile = Observe(scope.State); - InstructionOperationStatus overwidePrevious = InstructionNavigator.TryGetPrevious(x86Profile, 0x1000UL, - out var previous); - - Assert.Equal(InstructionOperationStatus.InvalidResult, invalidLength); - Assert.Equal(0, zeroLength); - Assert.Equal(InstructionOperationStatus.InvalidResult, stringLength); - Assert.Equal(0, coercedLength); - Assert.Equal(InstructionOperationStatus.AddressExceedsProfileWidth, overwidePrevious); - Assert.Equal(Address.Zero, previous); - Assert.Equal(0, scope.State.Top); - } - - private static InstructionTargetProfile Observe(CheatEngine.SDK.Lua.State.LuaState state) - { - InstructionOperationStatus status = InstructionProfiles.TryObserveCurrent(out var targetProfile); - Assert.Equal(InstructionOperationStatus.Success, status); - Assert.Equal(0, state.Top); - return targetProfile; - } - - private static void InstallInstructionGlobals(CheatEngine.SDK.Lua.State.LuaState state) - { - InstallProfileGlobals(state); - EngineTest.Run(state, """ - instruction_assemble_calls = 0 - instruction_assemble_origin = 0 - instruction_assemble_behavior = "normal" - - assemble = function(line, address) - instruction_assemble_calls = instruction_assemble_calls + 1 - instruction_assemble_origin = address - if instruction_assemble_behavior == "target-change" then - instruction_target_process_id = 7777 - return { 0x90 }, nil - end - if line == "reject" then return nil, "instruction rejected" end - if line == "malformed" then return { 0x90, 300 }, nil end - if line == "raise" then error("assembly failure") end - return { 0xE9, 0xFB, 0xFF, 0xFF, 0xFF }, nil - end - - disassemble = function(address) - if address == 0xDEAD then error("disassembly failure") end - if address == 0xBAD then return "bad-split" end - if address == 0xBADD then return "wide-split" end - return "0000000140001000 E9 FB FF FF FF jmp 0000000140001000" - end - - splitDisassembledString = function(line) - if line == "bad-split" then return "only-one" end - if line == "wide-split" then return "aaaa", "bbbb", "cccc", "dddd" end - return "0000000140001000", "E9 FB FF FF FF", "jmp", "0000000140001000" - end - - getInstructionSize = function(address) - if address == 0 then return 0 end - if address == 1 then return "5" end - return 5 - end - - getPreviousOpcode = function(address) - if address == 0x1000 then return 0x100000000 end - return address - 5 - end - """u8); - } - - private static void InstallProfileGlobals(CheatEngine.SDK.Lua.State.LuaState state) - { - EngineTest.Run(state, """ - instruction_target_process_id = 4242 - instruction_target_is_64bit = true - instruction_target_is_x86 = false - instruction_target_is_arm = false - function getOpenedProcessID() return instruction_target_process_id end - function targetIs64Bit() return instruction_target_is_64bit end - function targetIsX86() return instruction_target_is_x86 end - function targetIsArm() return instruction_target_is_arm end - """u8); - } - - private static long ReadInteger(CheatEngine.SDK.Lua.State.LuaState state, string name) - { - using var frame = new CheatEngine.SDK.Lua.State.LuaFrame(state); - Assert.True(state.TryGetGlobal(System.Text.Encoding.UTF8.GetBytes(name)).IsOk); - return EngineTest.ReadInteger(state, -1); - } + [Fact] + public void ObserveCurrent_builds_an_x64_profile_from_coherent_target_probes_and_rejects_a_contradiction() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + + InstructionOperationStatus observed = + InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile targetProfile); + + Assert.Equal(InstructionOperationStatus.Success, observed); + Assert.Equal(4242, targetProfile.Target.Value); + Assert.Equal(CheatEngineArchitecture.X64, targetProfile.Profile.Architecture); + Assert.Equal(PointerSize.Bit64, targetProfile.Profile.AddressWidth); + + EngineTest.Run(scope.State, "instruction_target_is_x86 = true\ninstruction_target_is_64bit = true"u8); + InstructionOperationStatus contradictory = + InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile rejectedProfile); + + Assert.Equal(InstructionOperationStatus.InvalidProfile, contradictory); + Assert.Equal(default, rejectedProfile); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void ObserveCurrent_uses_the_target_ARM_probe_and_matching_width_without_using_the_host_width() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + + EngineTest.Run(scope.State, "instruction_target_is_64bit = false\ninstruction_target_is_arm = true"u8); + InstructionTargetProfile arm32 = Observe(scope.State); + + EngineTest.Run(scope.State, "instruction_target_is_64bit = true"u8); + InstructionTargetProfile arm64 = Observe(scope.State); + + Assert.Equal(CheatEngineArchitecture.Arm32, arm32.Profile.Architecture); + Assert.Equal(PointerSize.Bit32, arm32.Profile.AddressWidth); + Assert.Equal(CheatEngineArchitecture.Arm64, arm64.Profile.Architecture); + Assert.Equal(PointerSize.Bit64, arm64.Profile.AddressWidth); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Assemble_passes_the_explicit_relative_origin_and_copies_the_complete_byte_table() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + InstructionTargetProfile targetProfile = Observe(scope.State); + Span destination = stackalloc byte[5]; + Address origin = 0x0000_0001_4000_1000UL; + + InstructionOperationStatus status = InstructionAssembler.TryAssemble(targetProfile, "jmp rel", origin, + destination, out int written, out int requiredLength); + + Assert.Equal(InstructionOperationStatus.Success, status); + Assert.Equal(5, written); + Assert.Equal(5, requiredLength); + Assert.Equal(new byte[] { 0xE9, 0xFB, 0xFF, 0xFF, 0xFF }, destination.ToArray()); + Assert.Equal(origin.ToInt64(), ReadInteger(scope.State, "instruction_assemble_origin")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Assemble_rejection_capacity_and_malformed_tables_do_not_publish_a_prefix() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + InstructionTargetProfile targetProfile = Observe(scope.State); + + Span rejectedDestination = stackalloc byte[5]; + rejectedDestination.Fill(0xA5); + InstructionOperationStatus rejected = InstructionAssembler.TryAssemble(targetProfile, "reject", 0x140001000UL, + rejectedDestination, out int rejectedWritten, out int rejectedRequired); + + Assert.Equal(InstructionOperationStatus.InstructionRejected, rejected); + Assert.Equal(0, rejectedWritten); + Assert.Equal(0, rejectedRequired); + Assert.Equal(new byte[] { 0xA5, 0xA5, 0xA5, 0xA5, 0xA5 }, rejectedDestination.ToArray()); + + Span tooSmallDestination = stackalloc byte[4]; + tooSmallDestination.Fill(0xA5); + InstructionOperationStatus tooSmall = InstructionAssembler.TryAssemble(targetProfile, "jmp rel", 0x140001000UL, + tooSmallDestination, out int tooSmallWritten, out int tooSmallRequired); + + Assert.Equal(InstructionOperationStatus.DestinationTooSmall, tooSmall); + Assert.Equal(0, tooSmallWritten); + Assert.Equal(5, tooSmallRequired); + Assert.Equal(new byte[] { 0xA5, 0xA5, 0xA5, 0xA5 }, tooSmallDestination.ToArray()); + + Span malformedDestination = stackalloc byte[2]; + malformedDestination.Fill(0xA5); + InstructionOperationStatus malformed = InstructionAssembler.TryAssemble(targetProfile, "malformed", + 0x140001000UL, + malformedDestination, out int malformedWritten, out int malformedRequired); + + Assert.Equal(InstructionOperationStatus.InvalidResult, malformed); + Assert.Equal(0, malformedWritten); + Assert.Equal(0, malformedRequired); + Assert.Equal(new byte[] { 0xA5, 0xA5 }, malformedDestination.ToArray()); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Assemble_rejects_an_address_wider_than_the_observed_x86_profile_before_entering_Lua() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + EngineTest.Run(scope.State, "instruction_target_is_64bit = false\ninstruction_target_is_x86 = true"u8); + InstructionTargetProfile targetProfile = Observe(scope.State); + Span destination = stackalloc byte[5]; + + InstructionOperationStatus overwide = InstructionAssembler.TryAssemble(targetProfile, "jmp rel", + 0x1_0000_0000UL, + destination, out int written, out int requiredLength); + + Assert.Equal(InstructionOperationStatus.AddressExceedsProfileWidth, overwide); + Assert.Equal(0, written); + Assert.Equal(0, requiredLength); + Assert.Equal(0, ReadInteger(scope.State, "instruction_assemble_calls")); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Assemble_reports_a_target_change_without_copying_the_returned_bytes() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + InstructionTargetProfile targetProfile = Observe(scope.State); + EngineTest.Run(scope.State, "instruction_assemble_behavior = \"target-change\""u8); + Span destination = stackalloc byte[1]; + destination[0] = 0xA5; + + InstructionOperationStatus status = InstructionAssembler.TryAssemble(targetProfile, "nop", 0x140001000UL, + destination, out int written, out int requiredLength); + + Assert.Equal(InstructionOperationStatus.TargetChanged, status); + Assert.Equal(0, written); + Assert.Equal(0, requiredLength); + Assert.Equal(0xA5, destination[0]); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Disassemble_parses_the_fixture_line_inside_the_SDK_and_copies_all_text() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + InstructionTargetProfile targetProfile = Observe(scope.State); + Address address = 0x0000_0001_4000_1000UL; + + InstructionOperationStatus status = InstructionDisassembler.TryDisassemble(targetProfile, address, 256, + out InstructionDisassembly instruction, out int requiredUtf8Bytes); + + Assert.Equal(InstructionOperationStatus.Success, status); + Assert.Equal(address, instruction.Address); + Assert.Equal("0000000140001000", instruction.AddressText); + Assert.Equal("E9 FB FF FF FF", instruction.Bytes); + Assert.Equal("jmp", instruction.Opcode); + Assert.Equal("0000000140001000", instruction.Extra); + Assert.Equal(49, instruction.Utf8ByteLength); + Assert.Equal(55, requiredUtf8Bytes); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Disassemble_enforces_the_raw_text_bound_and_rejects_malformed_split_results() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + InstructionTargetProfile targetProfile = Observe(scope.State); + + InstructionOperationStatus bounded = InstructionDisassembler.TryDisassemble(targetProfile, 0x140001000UL, 4, + out InstructionDisassembly boundedInstruction, out int requiredUtf8Bytes); + InstructionOperationStatus malformed = InstructionDisassembler.TryDisassemble(targetProfile, 0xBADUL, 256, + out InstructionDisassembly malformedInstruction, out int malformedRequiredUtf8Bytes); + InstructionOperationStatus oversizedFields = InstructionDisassembler.TryDisassemble(targetProfile, 0xBADDUL, 10, + out InstructionDisassembly oversizedInstruction, out int oversizedRequiredUtf8Bytes); + + Assert.Equal(InstructionOperationStatus.OutputTooLong, bounded); + Assert.Equal(default, boundedInstruction); + Assert.Equal(55, requiredUtf8Bytes); + Assert.Equal(InstructionOperationStatus.InvalidResult, malformed); + Assert.Equal(default, malformedInstruction); + Assert.Equal(9, malformedRequiredUtf8Bytes); + Assert.Equal(InstructionOperationStatus.OutputTooLong, oversizedFields); + Assert.Equal(default, oversizedInstruction); + Assert.Equal(10, oversizedRequiredUtf8Bytes); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Missing_and_raising_instruction_globals_have_distinct_structured_outcomes() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using (HostScope missingScope = new(state)) + { + InstallProfileGlobals(missingScope.State); + InstructionTargetProfile targetProfile = Observe(missingScope.State); + InstructionOperationStatus unavailable = InstructionDisassembler.TryDisassemble(targetProfile, + 0x140001000UL, + 256, out _, out _); + + Assert.Equal(InstructionOperationStatus.GlobalUnavailable, unavailable); + Assert.Equal(0, missingScope.State.Top); + } + + using HostScope raisingScope = new(state); + InstallInstructionGlobals(raisingScope.State); + InstructionTargetProfile raisingProfile = Observe(raisingScope.State); + InstructionOperationStatus failure = InstructionDisassembler.TryDisassemble(raisingProfile, 0xDEADUL, + 256, out _, out _); + + Assert.Equal(InstructionOperationStatus.LuaFailure, failure); + Assert.Equal(0, raisingScope.State.Top); + } + + [Fact] + public void Navigation_returns_a_positive_length_and_an_explicitly_estimated_previous_address() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + InstructionTargetProfile targetProfile = Observe(scope.State); + + InstructionOperationStatus lengthStatus = InstructionNavigator.TryGetLength(targetProfile, 0x140001000UL, + out int length); + InstructionOperationStatus previousStatus = InstructionNavigator.TryGetPrevious(targetProfile, 0x140001000UL, + out Address previous); + + Assert.Equal(InstructionOperationStatus.Success, lengthStatus); + Assert.Equal(5, length); + Assert.Equal(InstructionOperationStatus.Success, previousStatus); + Assert.Equal(0x140000FFBUL, previous.Value); + Assert.Equal(0, scope.State.Top); + } + + [Fact] + public void Navigation_rejects_nonpositive_or_string_lengths_and_addresses_wider_than_the_profile() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + InstallInstructionGlobals(scope.State); + InstructionTargetProfile x64Profile = Observe(scope.State); + + InstructionOperationStatus invalidLength = + InstructionNavigator.TryGetLength(x64Profile, 0UL, out int zeroLength); + InstructionOperationStatus stringLength = + InstructionNavigator.TryGetLength(x64Profile, 1UL, out int coercedLength); + + EngineTest.Run(scope.State, "instruction_target_is_64bit = false\ninstruction_target_is_x86 = true"u8); + InstructionTargetProfile x86Profile = Observe(scope.State); + InstructionOperationStatus overwidePrevious = InstructionNavigator.TryGetPrevious(x86Profile, 0x1000UL, + out Address previous); + + Assert.Equal(InstructionOperationStatus.InvalidResult, invalidLength); + Assert.Equal(0, zeroLength); + Assert.Equal(InstructionOperationStatus.InvalidResult, stringLength); + Assert.Equal(0, coercedLength); + Assert.Equal(InstructionOperationStatus.AddressExceedsProfileWidth, overwidePrevious); + Assert.Equal(Address.Zero, previous); + Assert.Equal(0, scope.State.Top); + } + + private static InstructionTargetProfile Observe(LuaState state) + { + InstructionOperationStatus status = + InstructionProfiles.TryObserveCurrent(out InstructionTargetProfile targetProfile); + Assert.Equal(InstructionOperationStatus.Success, status); + Assert.Equal(0, state.Top); + return targetProfile; + } + + private static void InstallInstructionGlobals(LuaState state) + { + InstallProfileGlobals(state); + EngineTest.Run(state, """ + instruction_assemble_calls = 0 + instruction_assemble_origin = 0 + instruction_assemble_behavior = "normal" + + assemble = function(line, address) + instruction_assemble_calls = instruction_assemble_calls + 1 + instruction_assemble_origin = address + if instruction_assemble_behavior == "target-change" then + instruction_target_process_id = 7777 + return { 0x90 }, nil + end + if line == "reject" then return nil, "instruction rejected" end + if line == "malformed" then return { 0x90, 300 }, nil end + if line == "raise" then error("assembly failure") end + return { 0xE9, 0xFB, 0xFF, 0xFF, 0xFF }, nil + end + + disassemble = function(address) + if address == 0xDEAD then error("disassembly failure") end + if address == 0xBAD then return "bad-split" end + if address == 0xBADD then return "wide-split" end + return "0000000140001000 E9 FB FF FF FF jmp 0000000140001000" + end + + splitDisassembledString = function(line) + if line == "bad-split" then return "only-one" end + if line == "wide-split" then return "aaaa", "bbbb", "cccc", "dddd" end + return "0000000140001000", "E9 FB FF FF FF", "jmp", "0000000140001000" + end + + getInstructionSize = function(address) + if address == 0 then return 0 end + if address == 1 then return "5" end + return 5 + end + + getPreviousOpcode = function(address) + if address == 0x1000 then return 0x100000000 end + return address - 5 + end + """u8); + } + + private static void InstallProfileGlobals(LuaState state) + { + EngineTest.Run(state, """ + instruction_target_process_id = 4242 + instruction_target_is_64bit = true + instruction_target_is_x86 = false + instruction_target_is_arm = false + function getOpenedProcessID() return instruction_target_process_id end + function targetIs64Bit() return instruction_target_is_64bit end + function targetIsX86() return instruction_target_is_x86 end + function targetIsArm() return instruction_target_is_arm end + """u8); + } + + private static long ReadInteger(LuaState state, string name) + { + using LuaFrame frame = new(state); + Assert.True(state.TryGetGlobal(Encoding.UTF8.GetBytes(name)).IsOk); + return EngineTest.ReadInteger(state, -1); + } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs index 9e188b9b..9985a1d8 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Inspection/EngineInspectionTests.cs @@ -206,7 +206,7 @@ public void ResolveAddress_distinguishes_a_nil_miss_from_a_zero_address_and_lua_ Assert.Equal(top, L.Top); status = EngineInspection.ResolveHostAddress(new SymbolExpression("hostSymbol"), - new AddressResolutionOptions(Shallow: true), out HostAddress found); + new AddressResolutionOptions(true), out HostAddress found); Assert.Equal(InspectionStatus.Success, status); Assert.Equal(unchecked((nuint) 0x7FF600001000UL), found.Value); @@ -243,32 +243,13 @@ public void ResolveAddress_reports_an_unavailable_global_without_entering_lua() } [Fact] - public void Legacy_positional_host_option_is_preserved_but_rejected_by_target_resolution() - { - AddressResolutionOptions legacy = new(true); - legacy.Deconstruct(out bool useHostSymbolTable, out bool shallow); - - Assert.True(useHostSymbolTable); - Assert.False(shallow); - Assert.Throws(() => - EngineInspection.ResolveAddress(new SymbolExpression("hostSymbol"), legacy, out _)); - } - - [Fact] - public void AddressResolutionOptions_preserves_init_and_with_compatibility() + public void AddressResolutionOptions_supports_init_and_with() { AddressResolutionOptions options = new() { Shallow = true }; AddressResolutionOptions updated = options with { Shallow = false }; Assert.True(options.Shallow); Assert.False(updated.Shallow); - -#pragma warning disable CS0618 - AddressResolutionOptions legacy = new() { UseHostSymbolTable = true }; -#pragma warning restore CS0618 -#pragma warning disable CS0618 - Assert.True(legacy.UseHostSymbolTable); -#pragma warning restore CS0618 } [Fact] diff --git a/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs index f238433f..cfeb2d2c 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Inspection/SymbolRegistryTests.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.Calls; +using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -146,6 +147,157 @@ public void Owned_registration_lease_never_unregisters_a_newer_coordinated_regis Assert.Equal(0, L.Top); } + [Fact] + public void Owned_registration_factory_failure_compensates_once_and_preserves_the_primary_cause() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + registrations = 0 + removals = 0 + registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end + unregisterSymbol = function(name) removals = removals + 1 end + """u8); + InvalidOperationException cause = new("injected lease factory failure"); + + SymbolRegistrationHandoffException exception = Assert.Throws(() => + SymbolRegistry.TryRegisterOwnedCore(new SymbolName("Player.Health"), 0x140001000UL, default, + (_, _, _) => throw cause, + static (_, _) => throw new InvalidOperationException("The publisher must not run."))); + + Assert.Same(cause, exception.InnerException); + Assert.Equal(SymbolRegistrationReleaseKind.Released, exception.CleanupOutcome.Kind); + Assert.True(exception.CleanupOutcome.Status.IsSuccess); + EngineTest.Run(L, "assert(registrations == 1 and removals == 1)"u8); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Owned_registration_publish_failure_compensates_once_and_preserves_the_primary_cause() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + registrations = 0 + removals = 0 + registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end + unregisterSymbol = function(name) removals = removals + 1 end + """u8); + InvalidOperationException cause = new("injected lease publication failure"); + + SymbolRegistrationHandoffException exception = Assert.Throws(() => + SymbolRegistry.TryRegisterOwnedCore(new SymbolName("Player.Health"), 0x140001000UL, default, + static (name, options, identity) => new SymbolRegistrationLease(name, options, identity), + (_, _) => throw cause)); + + Assert.Same(cause, exception.InnerException); + Assert.Equal(SymbolRegistrationReleaseKind.Released, exception.CleanupOutcome.Kind); + Assert.True(exception.CleanupOutcome.Status.IsSuccess); + EngineTest.Run(L, "assert(registrations == 1 and removals == 1)"u8); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Owned_registration_publication_failure_after_detach_preserves_the_primary_cause_without_unregistering() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + registrations = 0 + removals = 0 + registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end + unregisterSymbol = function(name) removals = removals + 1 end + """u8); + InvalidOperationException cause = new("injected lease publication failure after detach"); + + SymbolRegistrationHandoffException exception; + try + { + exception = Assert.Throws(() => + SymbolRegistry.TryRegisterOwnedCore(new SymbolName("Player.Health"), 0x140001000UL, default, + static (name, options, identity) => new SymbolRegistrationLease(name, options, identity), + (_, _) => + { + LuaRuntime.Detach(); + throw cause; + })); + } + finally + { + LuaRuntime.Attach(scope.Binding); + } + + Assert.Same(cause, exception.InnerException); + Assert.Equal(SymbolRegistrationReleaseKind.StaleRuntime, exception.CleanupOutcome.Kind); + Assert.True(exception.CleanupOutcome.Status.IsSuccess); + Assert.True(exception.CleanupOutcome.IsTerminal); + EngineTest.Run(L, "assert(registrations == 1 and removals == 0)"u8); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Owned_registration_publication_failure_with_unavailable_unregister_preserves_the_primary_cause() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + registrations = 0 + registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end + """u8); + InvalidOperationException cause = new("injected lease publication failure"); + + SymbolRegistrationHandoffException exception = Assert.Throws(() => + SymbolRegistry.TryRegisterOwnedCore(new SymbolName("Player.Health"), 0x140001000UL, default, + static (name, options, identity) => new SymbolRegistrationLease(name, options, identity), + (_, _) => throw cause)); + + Assert.Same(cause, exception.InnerException); + Assert.Equal(SymbolRegistrationReleaseKind.CleanupUnavailable, exception.CleanupOutcome.Kind); + Assert.Equal(LuaOperationStatusKind.GlobalUnavailable, exception.CleanupOutcome.Status.Kind); + Assert.False(exception.CleanupOutcome.IsTerminal); + EngineTest.Run(L, "assert(registrations == 1)"u8); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Owned_registration_compensation_failure_is_indeterminate_and_never_retries() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + registrations = 0 + removals = 0 + registerSymbol = function(name, address, doNotSave) registrations = registrations + 1 end + unregisterSymbol = function(name) + removals = removals + 1 + error('cleanup started then failed') + end + """u8); + InvalidOperationException cause = new("injected lease factory failure"); + + SymbolRegistrationHandoffException exception = Assert.Throws(() => + SymbolRegistry.TryRegisterOwnedCore(new SymbolName("Player.Health"), 0x140001000UL, default, + (_, _, _) => throw cause, + static (_, _) => throw new InvalidOperationException("The publisher must not run."))); + + Assert.Same(cause, exception.InnerException); + Assert.Equal(SymbolRegistrationReleaseKind.CleanupIndeterminate, exception.CleanupOutcome.Kind); + Assert.Equal(LuaOperationStatusKind.LuaFailure, exception.CleanupOutcome.Status.Kind); + Assert.True(exception.CleanupOutcome.IsTerminal); + EngineTest.Run(L, "assert(registrations == 1 and removals == 1)"u8); + Assert.Equal(0, L.Top); + } + [Fact] public void Symbol_mutations_that_raise_after_starting_supersede_tracked_leases() { @@ -273,7 +425,7 @@ public void Owned_registration_cleanup_after_a_lua_failure_is_indeterminate_and_ LuaState L = scope.State; EngineTest.Run(L, - "registerSymbol = function(name, address, doNotSave) end; unregisterSymbol = function(name) error('cleanup started then failed') end"u8); + "removals = 0; registerSymbol = function(name, address, doNotSave) end; unregisterSymbol = function(name) removals = removals + 1; error('cleanup started then failed') end"u8); SymbolRegistrationAcquireOutcome failed = SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Mana"), 0x140002000UL); SymbolRegistrationReleaseOutcome indeterminate = failed.Lease!.Release(); @@ -283,6 +435,34 @@ public void Owned_registration_cleanup_after_a_lua_failure_is_indeterminate_and_ Assert.False(indeterminate.Status.IsSuccess); Assert.True(failed.Lease.IsTerminal); Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, failed.Lease.Release().Kind); + failed.Lease.Dispose(); + failed.Lease.Dispose(); + EngineTest.Run(L, "assert(removals == 1)"u8); + Assert.Equal(0, L.Top); + } + + [Fact] + public void Owned_registration_dispose_is_no_throw_idempotent_and_unregisters_once() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + EngineTest.Run(L, """ + removals = 0 + registerSymbol = function(name, address, doNotSave) end + unregisterSymbol = function(name) removals = removals + 1 end + """u8); + SymbolRegistrationAcquireOutcome acquired = + SymbolRegistry.TryRegisterOwned(new SymbolName("Player.Health"), 0x140001000UL); + + Assert.True(acquired.HasLease); + acquired.Lease!.Dispose(); + acquired.Lease.Dispose(); + + Assert.True(acquired.Lease.IsTerminal); + Assert.Equal(SymbolRegistrationReleaseKind.AlreadyReleased, acquired.Lease.Release().Kind); + EngineTest.Run(L, "assert(removals == 1)"u8); Assert.Equal(0, L.Top); } } diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs index 0a3f4c2c..c419e50b 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionFactoryTests.cs @@ -1,7 +1,9 @@ +using System.Globalization; using System.Text; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Scanning.Values; +using CheatEngine.SDK.Engine.Targets; using CheatEngine.SDK.Engine.Tests.Support; using CheatEngine.SDK.Lua.State; using CheatEngine.SDK.Tests.Shared.NativeLua; @@ -13,17 +15,24 @@ namespace CheatEngine.SDK.Engine.Tests.Scanning; public sealed class MemoryScanSessionFactoryTests { [Fact] - public void TryCreate_when_both_factories_return_host_objects_transfers_ownership_to_the_session() + public void TryCreateWithOutcome_when_both_factories_return_host_objects_transfers_ownership_to_the_session() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; + InstallCurrentTarget(L); CEObject scanner = CreateScanner(L); CEObject foundList = CreateFoundList(L); InstallFactories(L, scanner, foundList); - Assert.True(MemoryScanSessions.TryCreate(out MemoryScanSession? created)); + MemoryScanCreationOutcome outcome = MemoryScanSessions.TryCreateWithOutcome(out MemoryScanSession? created); + + Assert.Equal(MemoryScanCreationStatus.Success, outcome.Status); + Assert.Equal(TargetSelectionObservationStatus.CurrentTargetQualified, outcome.TargetObservation.Status); + Assert.Equal(Environment.ProcessId, outcome.TargetObservation.SelectedProcessId); + Assert.True(outcome.TargetObservation.Incarnation.HasValue); + Assert.Equal(Environment.ProcessId, outcome.TargetObservation.Incarnation.Value.ProcessId); MemoryScanSession session = Assert.IsType(created); Assert.Equal(scanner, session.Scanner.Handle); Assert.Equal(0, L.Top); @@ -43,6 +52,7 @@ public void TryCreate_when_the_child_factory_is_unavailable_rolls_back_the_creat using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; + InstallCurrentTarget(L); CEObject scanner = CreateScanner(L); SetGlobalObject(L, "factory_scan"u8, scanner); EngineTest.Run(L, @@ -64,6 +74,7 @@ public void TryCreate_when_the_child_factory_raises_rolls_back_the_created_paren using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; + InstallCurrentTarget(L); CEObject scanner = CreateScanner(L); SetGlobalObject(L, "factory_scan"u8, scanner); EngineTest.Run(L, """ @@ -94,6 +105,7 @@ public void TryCreate_when_the_child_factory_returns_a_nonobject_rolls_back_the_ using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; + InstallCurrentTarget(L); CEObject scanner = CreateScanner(L); SetGlobalObject(L, "factory_scan"u8, scanner); EngineTest.Run(L, """ @@ -123,6 +135,7 @@ public void TryCreate_when_the_child_factory_aliases_the_parent_rolls_back_witho using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; + InstallCurrentTarget(L); CEObject scanner = CreateScanner(L); SetGlobalObject(L, "factory_scan"u8, scanner); EngineTest.Run(L, """ @@ -175,6 +188,7 @@ public void TryCreate_when_the_parent_factory_returns_a_nonobject_does_not_publi using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; + InstallCurrentTarget(L); EngineTest.Run(L, "function createMemScan() return 42 end"u8); Assert.Equal(MemoryScanCreationStatus.InvalidScannerResult, @@ -190,6 +204,7 @@ public void TryCreateDetailed_when_a_factory_returns_nil_keeps_absence_distinct_ using NativeLuaState state = new(); using HostScope scope = new(state); LuaState L = scope.State; + InstallCurrentTarget(L); EngineTest.Run(L, "function createMemScan() return nil end"u8); MemoryScanCreationStatus status = MemoryScanSessions.TryCreateDetailed(out MemoryScanSession? created); @@ -271,6 +286,35 @@ function getOpenedProcessID() Assert.Equal(0, L.Top); } + [Fact] + public void TryCreateWithOutcome_refuses_an_unqualified_target_before_either_factory_acquires_an_owner() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + LuaState L = scope.State; + CEObject scanner = CreateScanner(L); + CEObject foundList = CreateFoundList(L); + SetGlobalObject(L, "factory_scan"u8, scanner); + SetGlobalObject(L, "factory_found_list"u8, foundList); + EngineTest.Run(L, """ + trace = {} + function getOpenedProcessID() return 0 end + function createMemScan() table.insert(trace, 'factory.scan'); return factory_scan end + function createFoundList(scan) table.insert(trace, 'factory.list'); return factory_found_list end + """u8); + + MemoryScanCreationOutcome outcome = MemoryScanSessions.TryCreateWithOutcome(out MemoryScanSession? created); + + Assert.Equal(MemoryScanCreationStatus.TargetIdentityUnavailable, outcome.Status); + Assert.Equal(TargetSelectionObservationStatus.NoTargetSelected, outcome.TargetObservation.Status); + Assert.Null(created); + Assert.False(FakeHost.IsDestroyed(L, scanner)); + Assert.False(FakeHost.IsDestroyed(L, foundList)); + Assert.Equal(string.Empty, ReadTrace(L)); + Assert.Equal(0, L.Top); + } + private static CEObject CreateScanner(LuaState state) { return FakeHost.CreateObject(state, "Object", """ @@ -315,6 +359,13 @@ function getOpenedProcessID() """)); } + private static void InstallCurrentTarget(LuaState state) + { + EngineTest.Run(state, Encoding.UTF8.GetBytes("function getOpenedProcessID() return " + + Environment.ProcessId.ToString(CultureInfo.InvariantCulture) + + " end")); + } + private static void SetGlobalObject(LuaState state, ReadOnlySpan name, CEObject value) { using LuaFrame frame = new(state); diff --git a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs index 1963f1fd..630bfdd5 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Scanning/MemoryScanSessionTests.cs @@ -3,6 +3,7 @@ using System.Text; using CheatEngine.SDK.Engine.Enums; +using CheatEngine.SDK.Engine.Errors; using CheatEngine.SDK.Engine.Objects; using CheatEngine.SDK.Engine.Scanning.Values; using CheatEngine.SDK.Engine.Targets; @@ -119,7 +120,7 @@ public void Wait_error_invalidates_the_session_so_reset_can_recover() } [Fact] - public void Dispose_releases_and_destroys_the_child_before_the_parent() + public void ReleaseWithOutcome_confirms_and_consumes_the_child_before_the_parent_without_a_retry() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); @@ -130,16 +131,20 @@ public void Dispose_releases_and_destroys_the_child_before_the_parent() session.WaitForCompletion(); EngineTest.Run(scope.State, "trace = {}"u8); - session.Dispose(); - session.Dispose(); + MemoryScanReleaseOutcome outcome = session.ReleaseWithOutcome(); + MemoryScanReleaseOutcome repeated = session.ReleaseWithOutcome(); Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.True(outcome.OwnershipConsumed); + Assert.Equal(TargetReleaseStatus.Released, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.Released, outcome.MemScan.Status); + Assert.Equal(outcome, repeated); Assert.Equal("list.deinitialize,list.destroy,scan.destroy", ReadTrace(scope.State)); Assert.Throws(() => _ = session.Scanner); } [Fact] - public void Dispose_attempts_the_parent_cleanup_after_an_unconfirmed_child_destroy() + public void ReleaseWithOutcome_reports_unknown_child_cleanup_and_still_attempts_the_parent_once() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); @@ -150,15 +155,19 @@ public void Dispose_attempts_the_parent_cleanup_after_an_unconfirmed_child_destr CEObject scanner = session.Scanner.Handle; EngineTest.Run(scope.State, "trace = {}"u8); - session.Dispose(); + MemoryScanReleaseOutcome outcome = session.ReleaseWithOutcome(); Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.True(outcome.OwnershipConsumed); + Assert.Equal(TargetReleaseStatus.UnconfirmedAfterInvocation, outcome.FoundList.Status); + Assert.Equal(EngineFailureKind.ProtectedLuaFailure, outcome.FoundList.FailureKind); + Assert.Equal(TargetReleaseStatus.Released, outcome.MemScan.Status); Assert.True(FakeHost.IsDestroyed(scope.State, scanner)); Assert.Equal("list.deinitialize,list.destroy,scan.destroy", ReadTrace(scope.State)); } [Fact] - public void Dispose_after_a_detach_and_reattach_refuses_to_use_the_prior_runtime_and_can_be_abandoned() + public void Dispose_after_a_detach_consumes_ownership_without_throwing_or_using_the_prior_runtime() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); @@ -170,11 +179,16 @@ public void Dispose_after_a_detach_and_reattach_refuses_to_use_the_prior_runtime CEObject scanner = session.Scanner.Handle; CEObject foundList = session.Results.Handle; + MemoryScanReleaseOutcome outcome; LuaRuntime.Detach(); try { - Assert.Throws(session.Dispose); - Assert.Equal(MemoryScanState.ResultsReady, session.State); + session.Dispose(); + outcome = session.LastReleaseOutcome; + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.True(outcome.OwnershipConsumed); + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.NotInvoked, outcome.MemScan.Status); Assert.False(FakeHost.IsDestroyed(scope.State, scanner)); Assert.False(FakeHost.IsDestroyed(scope.State, foundList)); } @@ -183,11 +197,6 @@ public void Dispose_after_a_detach_and_reattach_refuses_to_use_the_prior_runtime LuaRuntime.Attach(scope.Binding); } - MemoryScanException invalidated = Assert.Throws(session.Dispose); - - Assert.Equal(MemoryScanFailureKind.RuntimeInvalidated, invalidated.FailureKind); - Assert.Equal(MemoryScanState.Invalidated, session.State); - session.Abandon(); Assert.Equal(MemoryScanState.Disposed, session.State); Assert.False(FakeHost.IsDestroyed(scope.State, foundList)); Assert.False(FakeHost.IsDestroyed(scope.State, scanner)); @@ -315,7 +324,7 @@ public void A_main_thread_only_scan_operation_is_rejected_before_it_touches_CE() } [Fact] - public void Disposal_on_a_worker_is_rejected_before_child_or_parent_cleanup() + public void Disposal_on_a_worker_never_throws_or_retries_unsafe_child_or_parent_cleanup() { EngineTest.RequireNativeLua(); using NativeLuaState state = new(); @@ -328,12 +337,12 @@ public void Disposal_on_a_worker_is_rejected_before_child_or_parent_cleanup() Exception? failure = EngineTest.RunOnWorker(session.Dispose); - InvalidOperationException exception = Assert.IsType(failure); - Assert.Contains("main thread", exception.Message, StringComparison.Ordinal); - Assert.Equal(MemoryScanState.ResultsReady, session.State); + Assert.Null(failure); + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.True(session.LastReleaseOutcome.OwnershipConsumed); + Assert.Equal(TargetReleaseStatus.NotInvoked, session.LastReleaseOutcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.NotInvoked, session.LastReleaseOutcome.MemScan.Status); Assert.Equal(string.Empty, ReadTrace(scope.State)); - - session.Dispose(); } [Fact] @@ -521,6 +530,197 @@ public void TryCopyResults_reports_a_preexisting_cancellation_without_reading_or Assert.Equal(string.Empty, ReadTrace(scope.State)); } + [Fact] + public void TryCopyResults_cancels_after_get_address_without_reading_a_value_or_publishing_a_snapshot() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult retained = new(new Address(0xA11CE), "retained"); + MemoryScanResult[] destination = [retained, retained]; + using CancellationTokenSource cancellation = new(); + using FakeHost.PCallProbe probe = FakeHost.ReplaceFoundListGetAddressWithPCallProbe(scope.State, + session.Results.Handle, + cancellation.Cancel); + + MemoryScanMaterializationStatus status = CopyWithCancellation(session, destination, cancellation.Token, + out ulong totalCount, out int written); + + Assert.Equal(MemoryScanMaterializationStatus.Cancelled, status); + Assert.Equal(2UL, totalCount); + Assert.Equal(0, written); + Assert.Equal(retained, destination[0]); + Assert.Equal(retained, destination[1]); + Assert.Equal(1, probe.GetAddressCallCount); + Assert.Equal(MemoryScanCancellationMilestone.ObservedAfterNativeCall, session.LastCancellationMilestone); + Assert.Equal("results.getCount", ReadTrace(scope.State)); + } + + [Fact] + public void TryCopyResultsPage_copies_only_the_requested_bounded_page_without_materializing_the_full_result_set() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State, resultCountLiteral: "3"); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult[] destination = new MemoryScanResult[1]; + + MemoryScanMaterializationStatus status = session.TryCopyResultsPage(1, destination, out ulong totalCount, + out int written); + + Assert.Equal(MemoryScanMaterializationStatus.Success, status); + Assert.Equal(3UL, totalCount); + Assert.Equal(1, written); + Assert.Equal(new MemoryScanResult(new Address(0xFFFF_FFFF_FFFF_FFFF), "100"), destination[0]); + Assert.Equal("results.getCount,results.getAddress:1,results.getValue:1", ReadTrace(scope.State)); + } + + [Fact] + public void TryCopyResultsPage_does_not_publish_a_prefix_when_a_page_row_is_invalid() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State, invalidAddress: true); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult retained = new(new Address(0xA11CE), "retained"); + MemoryScanResult[] destination = [retained]; + + MemoryScanMaterializationStatus status = session.TryCopyResultsPage(0, destination, out ulong totalCount, + out int written); + + Assert.Equal(MemoryScanMaterializationStatus.InvalidResult, status); + Assert.Equal(2UL, totalCount); + Assert.Equal(0, written); + Assert.Equal(retained, destination[0]); + Assert.Equal("results.getCount,results.getAddress:0", ReadTrace(scope.State)); + } + + [Fact] + public void TryCopyResultsPage_reports_no_results_without_touching_the_destination() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State, resultCountLiteral: "0"); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult retained = new(new Address(0xA11CE), "retained"); + MemoryScanResult[] destination = [retained]; + + MemoryScanMaterializationStatus status = session.TryCopyResultsPage(0, destination, out ulong totalCount, + out int written); + + Assert.Equal(MemoryScanMaterializationStatus.NoResults, status); + Assert.Equal(0UL, totalCount); + Assert.Equal(0, written); + Assert.Equal(retained, destination[0]); + Assert.Equal("results.getCount", ReadTrace(scope.State)); + } + + [Fact] + public void TryCopyResultsPage_rejects_an_exact_end_start_without_reading_a_row() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult retained = new(new Address(0xA11CE), "retained"); + MemoryScanResult[] destination = [retained]; + + MemoryScanMaterializationStatus status = session.TryCopyResultsPage(2, destination, out ulong totalCount, + out int written); + + Assert.Equal(MemoryScanMaterializationStatus.PageStartOutOfRange, status); + Assert.Equal(2UL, totalCount); + Assert.Equal(0, written); + Assert.Equal(retained, destination[0]); + Assert.Equal("results.getCount", ReadTrace(scope.State)); + } + + [Fact] + public void TryCopyResultsPage_rejects_zero_capacity_without_reading_a_row() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + + MemoryScanMaterializationStatus status = session.TryCopyResultsPage(0, [], out ulong totalCount, + out int written); + + Assert.Equal(MemoryScanMaterializationStatus.DestinationTooSmall, status); + Assert.Equal(2UL, totalCount); + Assert.Equal(0, written); + Assert.Equal("results.getCount", ReadTrace(scope.State)); + } + + [Fact] + public void TryCopyResultsPage_rejects_a_negative_start_before_reading_the_count() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult retained = new(new Address(0xA11CE), "retained"); + MemoryScanResult[] destination = [retained]; + + Assert.Throws(() => + { + session.TryCopyResultsPage(-1, destination, out _, out _); + }); + + Assert.Equal(retained, destination[0]); + Assert.Equal(string.Empty, ReadTrace(scope.State)); + } + + [Fact] + [SuppressMessage("xUnit.Analyzers", "xUnit1051", + Justification = "The fixture starts with a deliberately cancelled token to prove no paged-row call begins.")] + public void TryCopyResultsPage_does_not_publish_a_prefix_when_cancellation_precedes_the_page() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + using MemoryScanSession session = CreateSession(scope.State); + session.StartFirstScan(FirstScanRequest.ExactValue(VariableType.Dword, "100")); + session.WaitForCompletion(); + EngineTest.Run(scope.State, "trace = {}"u8); + MemoryScanResult retained = new(new Address(0xA11CE), "retained"); + MemoryScanResult[] destination = [retained]; + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + MemoryScanMaterializationStatus status = session.TryCopyResultsPageCancellable(0, destination, + out ulong totalCount, out int written, cancellation.Token); + + Assert.Equal(MemoryScanMaterializationStatus.Cancelled, status); + Assert.Equal(0UL, totalCount); + Assert.Equal(0, written); + Assert.Equal(retained, destination[0]); + Assert.Equal(MemoryScanCancellationMilestone.CancelledBeforeNativeCall, session.LastCancellationMilestone); + Assert.Equal(string.Empty, ReadTrace(scope.State)); + } + [Fact] [SuppressMessage("xUnit.Analyzers", "xUnit1051", Justification = "The fixture must start with a deliberately cancelled token to prove no CE scan call begins.")] @@ -582,6 +782,25 @@ public void A_session_refuses_scan_work_when_the_original_target_is_no_longer_se session.Abandon(); } + [Fact] + public void ReleaseWithOutcome_refuses_cleanup_on_a_replaced_target_and_consumes_both_owners() + { + EngineTest.RequireNativeLua(); + using NativeLuaState state = new(); + using HostScope scope = new(state); + MemoryScanSession session = CreateSession(scope.State); + EngineTest.Run(scope.State, "opened_process_id = 0; trace = {}"u8); + + MemoryScanReleaseOutcome outcome = session.ReleaseWithOutcome(); + + Assert.Equal(MemoryScanState.Disposed, session.State); + Assert.True(outcome.OwnershipConsumed); + Assert.Equal(TargetReleaseStatus.RefusedNoTarget, outcome.FoundList.Status); + Assert.Equal(TargetReleaseStatus.RefusedNoTarget, outcome.MemScan.Status); + Assert.Equal(TargetIdentityCheckKind.NoTargetSelected, outcome.FoundList.TargetCheck!.Value.Kind); + Assert.Equal(string.Empty, ReadTrace(scope.State)); + } + private static MemoryScanMaterializationStatus CopyWithCancellation(MemoryScanSession session, Span destination, CancellationToken cancellationToken, out ulong totalCount, out int written) { diff --git a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs index 4b9e099a..8e752300 100644 --- a/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs +++ b/tests/CheatEngine.SDK.Engine.Tests/Support/FakeHost.cs @@ -30,7 +30,7 @@ namespace CheatEngine.SDK.Engine.Tests.Support; /// state lives in a Lua table stored as the userdata's user value (lua_setuservalue), keyed by pointer in a /// registry table so that every push of the same pointer finds the same state. /// -internal static unsafe class FakeHost +internal static unsafe class FakeHost // NOSONAR: the fixture implements Cheat Engine's unmanaged callback ABI. { // Registry keys: light userdata whose values are the addresses of these bytes (stable for the process). private const int MetatableKey = 0; @@ -212,6 +212,21 @@ public static PCallProbe ReplaceWaitTillDoneWithPCallProbe(LuaState L, CEObject return new PCallProbe(afterWaitTillDone); } + /// Replaces a fake found-list's getAddress member with a callback that returns a valid address. + public static PCallProbe ReplaceFoundListGetAddressWithPCallProbe(LuaState L, CEObject foundList, + Action? afterGetAddress = null) + { + using LuaFrame frame = new(L); + Assert.Equal(LuaType.Table, L.RawGetPointer(LuaState.RegistryIndex, s_keys + ObjectsKey)); + Assert.Equal(LuaType.Table, L.RawGetPointer(-1, foundList.Value)); + Assert.True(L.TryGetField(-1, "props"u8).IsOk); + L.PushString("getAddress"u8); + L.PushUncheckedFunction( + new LuaNativeFunction((nint) (delegate* unmanaged[Cdecl]) &GetAddress)); + Assert.True(L.TryRawSet(-3)); + return new PCallProbe(null, afterGetAddress); + } + private static void Install(LuaState L) { using LuaFrame frame = new(L); @@ -276,24 +291,16 @@ private static int WaitTillDone(lua_State* state) } [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static int ObservePCall(lua_State* state, int argumentCount, int resultCount, int errorFunction, - nint context, nint continuation) + private static int GetAddress(lua_State* state) { - PCallProbe? probe = s_activePCallProbe; - if (probe is not null && - (nint) lua_tocfunction(state, -argumentCount - 1) == - (nint) (delegate* unmanaged[Cdecl]) &WaitTillDone) + s_activePCallProbe?.AfterGetAddress(); + ReadOnlySpan address = "00001234"u8; + fixed (byte* addressPointer = address) { - probe.Observe(argumentCount, resultCount); + _ = lua_pushlstring(state, addressPointer, (nuint) address.Length); } - return ((delegate* unmanaged[Cdecl]) s_forwardedPCall)( - state, - argumentCount, - resultCount, - errorFunction, - context, - continuation); + return 1; } private sealed class StateProviderSuppression : IDisposable @@ -307,11 +314,12 @@ public void Dispose() /// Captures protected calls specifically to the fake no-result waitTillDone function. internal sealed class PCallProbe : IDisposable { + private readonly Action? _afterGetAddress; private readonly Action? _afterWaitTillDone; private readonly FieldInfo _pcallField; private readonly object _table; - internal PCallProbe(Action? afterWaitTillDone) + internal PCallProbe(Action? afterWaitTillDone, Action? afterGetAddress = null) { if (s_activePCallProbe is not null) { @@ -332,9 +340,17 @@ internal PCallProbe(Action? afterWaitTillDone) (nint) (delegate* unmanaged[Cdecl]) &ObservePCall); tableField.SetValue(null, _table); s_activePCallProbe = this; + _afterGetAddress = afterGetAddress; _afterWaitTillDone = afterWaitTillDone; } + /// Gets how many calls reached the probe's replacement getAddress function. + public int GetAddressCallCount + { + get; + private set; + } + /// Gets how many protected calls reached the probe's waitTillDone function. public int WaitCallCount { @@ -382,5 +398,32 @@ internal void AfterWaitTillDone() { _afterWaitTillDone?.Invoke(); } + + internal void AfterGetAddress() + { + GetAddressCallCount++; + _afterGetAddress?.Invoke(); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] + private static int ObservePCall(lua_State* state, int argumentCount, int resultCount, int errorFunction, + nint context, nint continuation) + { + PCallProbe? probe = s_activePCallProbe; + if (probe is not null && + (nint) lua_tocfunction(state, -argumentCount - 1) == + (nint) (delegate* unmanaged[Cdecl]) &WaitTillDone) + { + probe.Observe(argumentCount, resultCount); + } + + return ((delegate* unmanaged[Cdecl]) s_forwardedPCall)( + state, + argumentCount, + resultCount, + errorFunction, + context, + continuation); + } } } diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs index e4147526..5446c428 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginA/CoexistencePluginA.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Plugin; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; namespace LivePlugin.Coexistence.PluginA; @@ -16,7 +17,7 @@ protected override void OnEnable() { CoexistenceDiagnostics.LogEnabled("A", typeof(CoexistencePluginA).Assembly, Context); - var result = CoexistencePluginAFunctions.RegisterLuaFunctions(LuaRuntime.AcquireState()); + LuaStatus result = CoexistencePluginAFunctions.RegisterLuaFunctions(LuaRuntime.AcquireState()); HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, "CheatEngine.SDK coexistence A: RegisterLuaFunctions -> " + result + "."); if (!result.IsOk) @@ -28,7 +29,7 @@ protected override void OnEnable() /// protected override void OnDisable() { - var result = CoexistencePluginAFunctions.UnregisterLuaFunctions(LuaRuntime.AcquireState()); + LuaStatus result = CoexistencePluginAFunctions.UnregisterLuaFunctions(LuaRuntime.AcquireState()); HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, "CheatEngine.SDK coexistence A: UnregisterLuaFunctions -> " + result + "."); } diff --git a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs index 0ef3af2f..61ef2f7f 100644 --- a/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs +++ b/tests/CheatEngine.SDK.LivePlugin.Coexistence/PluginB/CoexistencePluginB.cs @@ -1,6 +1,7 @@ using CheatEngine.SDK.Annotations.Plugin; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Plugin; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; namespace LivePlugin.Coexistence.PluginB; @@ -16,7 +17,7 @@ protected override void OnEnable() { CoexistenceDiagnostics.LogEnabled("B", typeof(CoexistencePluginB).Assembly, Context); - var result = CoexistencePluginBFunctions.RegisterLuaFunctions(LuaRuntime.AcquireState()); + LuaStatus result = CoexistencePluginBFunctions.RegisterLuaFunctions(LuaRuntime.AcquireState()); HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, "CheatEngine.SDK coexistence B: RegisterLuaFunctions -> " + result + "."); if (!result.IsOk) @@ -28,7 +29,7 @@ protected override void OnEnable() /// protected override void OnDisable() { - var result = CoexistencePluginBFunctions.UnregisterLuaFunctions(LuaRuntime.AcquireState()); + LuaStatus result = CoexistencePluginBFunctions.UnregisterLuaFunctions(LuaRuntime.AcquireState()); HostLog.Write(result.IsOk ? HostLogLevel.Information : HostLogLevel.Error, "CheatEngine.SDK coexistence B: UnregisterLuaFunctions -> " + result + "."); } diff --git a/tests/CheatEngine.SDK.LivePlugin/CheatEngineSdkLivePlugin.cs b/tests/CheatEngine.SDK.LivePlugin/CheatEngineSdkLivePlugin.cs index 9fe9f6e1..3feee226 100644 --- a/tests/CheatEngine.SDK.LivePlugin/CheatEngineSdkLivePlugin.cs +++ b/tests/CheatEngine.SDK.LivePlugin/CheatEngineSdkLivePlugin.cs @@ -6,6 +6,7 @@ using CheatEngine.SDK.Hosting.Context; using CheatEngine.SDK.Hosting.Diagnostics; using CheatEngine.SDK.Hosting.Plugin; +using CheatEngine.SDK.Lua.Calls; using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; @@ -33,7 +34,7 @@ protected override void OnEnable() LogEnableDiagnostics(enableCount); LuaState state = LuaRuntime.AcquireState(); - var registered = LiveFunctions.RegisterLuaFunctions(state); + LuaStatus registered = LiveFunctions.RegisterLuaFunctions(state); HostLog.Write( registered.IsOk ? HostLogLevel.Information : HostLogLevel.Error, string.Create(CultureInfo.InvariantCulture, @@ -46,7 +47,7 @@ protected override void OnEnable() protected override void OnDisable() { LuaState state = LuaRuntime.AcquireState(); - var unregistered = LiveFunctions.UnregisterLuaFunctions(state); + LuaStatus unregistered = LiveFunctions.UnregisterLuaFunctions(state); HostLog.Write( unregistered.IsOk ? HostLogLevel.Information : HostLogLevel.Error, string.Create(CultureInfo.InvariantCulture, @@ -70,10 +71,9 @@ private static void LogEnableDiagnostics(int enableCount) private static void ReadMemoryAdjacentPrimitive() { - // A placeholder address: point it at a readable location in an attached target (for example a module base - // from getAddress()) to log a real value. Address supplies the number-or-hex-text convention and the - // culture-invariant formatting. The read goes through this plugin's own readInteger binding (MemoryBindings); - // CheatEngine.SDK.Engine.Generated.MemoryScalars.TryReadInt32 offers the same call as a ready-made wrapper. + // A placeholder address: point it at a readable location in an attached target to log a real value. Address + // supplies the number-or-hex-text convention and culture-invariant formatting. The read uses this plugin's + // generated integer binding; the SDK also exposes the same operation through its ready-made scalar wrapper. Address probe = Address.FromUInt64(0x00400000UL); bool ok = MemoryBindings.TryReadInt32((nuint) probe.Value, out int value); HostLog.Write( diff --git a/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs b/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs index 9f500e56..eba8512b 100644 --- a/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs +++ b/tests/CheatEngine.SDK.LiveProbe/LiveProbeAuthorization.cs @@ -23,98 +23,152 @@ internal static class LiveProbeAuthorization Justification = "Authorization evaluation keeps all fail-closed checks and diagnostics in one auditable sequence.")] internal static AuthorizationDecision Evaluate() { + if (!TryAuthorizeManifest(out AuthorizationManifest manifest, out string manifestFailure)) + { + return AuthorizationDecision.Denied(manifestFailure); + } + + if (!TryAuthorizeHost(manifest, out string hostPath, out string hostHash, out string hostFailure)) + { + return AuthorizationDecision.Denied(hostFailure); + } + + if (!TryAuthorizeTarget(manifest, out string targetPath, out string targetHash, out string targetFailure)) + { + return AuthorizationDecision.Denied(targetFailure); + } + + return AuthorizationDecision.Allowed(hostPath, hostHash, manifest.TargetProcessId, targetPath, targetHash, + manifest.ExpiresUtc); + } + + private static bool TryAuthorizeManifest(out AuthorizationManifest manifest, out string failure) + { + manifest = default; + failure = string.Empty; if (IntPtr.Size != 8) { - return AuthorizationDecision.Denied("The current process is not x64."); + failure = "The current process is not x64."; + return false; } string? acknowledgement = Environment.GetEnvironmentVariable(AcknowledgementVariable); if (!string.Equals(acknowledgement, Acknowledgement, StringComparison.Ordinal)) { - return AuthorizationDecision.Denied("The explicit CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT phrase is absent."); + failure = "The explicit CE_SDK_LIVE_PROBE_ACKNOWLEDGEMENT phrase is absent."; + return false; } string? manifestPath = Environment.GetEnvironmentVariable(ManifestVariable); if (string.IsNullOrWhiteSpace(manifestPath)) { - return AuthorizationDecision.Denied("CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE is absent."); + failure = "CE_SDK_LIVE_PROBE_AUTHORIZATION_FILE is absent."; + return false; } - if (!TryReadManifest(manifestPath, out AuthorizationManifest manifest, out string manifestFailure)) + if (!TryReadManifest(manifestPath, out manifest, out failure)) { - return AuthorizationDecision.Denied(manifestFailure); + return false; } if (!string.Equals(manifest.Acknowledgement, Acknowledgement, StringComparison.Ordinal)) { - return AuthorizationDecision.Denied("The authorization manifest has no matching acknowledgement."); + failure = "The authorization manifest has no matching acknowledgement."; + return false; } if (!manifest.Disposable) { - return AuthorizationDecision.Denied("The authorization manifest does not mark the target disposable."); + failure = "The authorization manifest does not mark the target disposable."; + return false; } if (manifest.ExpiresUtc <= DateTimeOffset.UtcNow) { - return AuthorizationDecision.Denied("The authorization manifest has expired."); + failure = "The authorization manifest has expired."; + return false; } - if (!TryGetProcessImage(Environment.ProcessId, out string hostPath, out string hostFailure)) + return true; + } + + private static bool TryAuthorizeHost(AuthorizationManifest manifest, out string hostPath, out string hostHash, + out string failure) + { + hostPath = string.Empty; + hostHash = string.Empty; + failure = string.Empty; + if (!TryGetProcessImage(Environment.ProcessId, out hostPath, out string hostFailure)) { - return AuthorizationDecision.Denied("The CE host image cannot be inspected: " + hostFailure); + failure = "The CE host image cannot be inspected: " + hostFailure; + return false; } if (!IsAmd64Pe(hostPath, out string hostArchitectureFailure)) { - return AuthorizationDecision.Denied("The CE host image is not an AMD64 PE: " + hostArchitectureFailure); + failure = "The CE host image is not an AMD64 PE: " + hostArchitectureFailure; + return false; } - if (!TryHash(hostPath, out string hostHash, out string hostHashFailure)) + if (!TryHash(hostPath, out hostHash, out string hostHashFailure)) { - return AuthorizationDecision.Denied("The CE host image cannot be hashed: " + hostHashFailure); + failure = "The CE host image cannot be hashed: " + hostHashFailure; + return false; } if (!string.Equals(hostHash, ExactCheatEngineSha256, StringComparison.Ordinal)) { - return AuthorizationDecision.Denied("The host SHA-256 is not the pinned CE 7.7.0.10621 x64 binary."); + failure = "The host SHA-256 is not the pinned CE 7.7.0.10621 x64 binary."; + return false; } if (!string.Equals(manifest.HostSha256, ExactCheatEngineSha256, StringComparison.Ordinal)) { - return AuthorizationDecision.Denied("The manifest does not pin the CE 7.7.0.10621 x64 SHA-256."); + failure = "The manifest does not pin the CE 7.7.0.10621 x64 SHA-256."; + return false; } string? hostVersion = FileVersionInfo.GetVersionInfo(hostPath).FileVersion; if (!string.Equals(hostVersion, ExactCheatEngineFileVersion, StringComparison.Ordinal)) { - return AuthorizationDecision.Denied("The pinned CE executable has an unexpected file version: " + - hostVersion + "."); + failure = "The pinned CE executable has an unexpected file version: " + hostVersion + "."; + return false; } + return true; + } + + private static bool TryAuthorizeTarget(AuthorizationManifest manifest, out string targetPath, out string targetHash, + out string failure) + { + targetPath = string.Empty; + targetHash = string.Empty; + failure = string.Empty; if (manifest.TargetProcessId == Environment.ProcessId) { - return AuthorizationDecision.Denied("The declared disposable target is the Cheat Engine host itself."); + failure = "The declared disposable target is the Cheat Engine host itself."; + return false; } - if (!TryGetProcessImage(manifest.TargetProcessId, out string targetPath, out string targetFailure)) + if (!TryGetProcessImage(manifest.TargetProcessId, out targetPath, out string targetFailure)) { - return AuthorizationDecision.Denied("The declared disposable target cannot be inspected: " + targetFailure); + failure = "The declared disposable target cannot be inspected: " + targetFailure; + return false; } - if (!TryHash(targetPath, out string targetHash, out string targetHashFailure)) + if (!TryHash(targetPath, out targetHash, out string targetHashFailure)) { - return AuthorizationDecision.Denied("The declared disposable target cannot be hashed: " + - targetHashFailure); + failure = "The declared disposable target cannot be hashed: " + targetHashFailure; + return false; } if (!string.Equals(targetHash, manifest.TargetSha256, StringComparison.Ordinal)) { - return AuthorizationDecision.Denied("The declared target SHA-256 differs from its live process image."); + failure = "The declared target SHA-256 differs from its live process image."; + return false; } - return AuthorizationDecision.Allowed(hostPath, hostHash, manifest.TargetProcessId, targetPath, targetHash, - manifest.ExpiresUtc); + return true; } private static bool TryReadManifest(string path, out AuthorizationManifest manifest, out string failure) diff --git a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallbackTests.cs b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallbackTests.cs index e7048120..9cb13693 100644 --- a/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallbackTests.cs +++ b/tests/CheatEngine.SDK.Lua.Interop.Tests/RoundTrips/CallbackTests.cs @@ -228,8 +228,8 @@ private static int Add(lua_State* L) // Argument validation by inspection: a managed callback must not use luaL_check*, which raises. int firstIsNumber; int secondIsNumber; - Double first = lua_tonumberx(L, 1, &firstIsNumber); - Double second = lua_tonumberx(L, 2, &secondIsNumber); + double first = lua_tonumberx(L, 1, &firstIsNumber); + double second = lua_tonumberx(L, 2, &secondIsNumber); if (firstIsNumber == 0 || secondIsNumber == 0) { lua_pushnil(L); diff --git a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs index 462bf03c..07cd4881 100644 --- a/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs +++ b/tests/CheatEngine.SDK.Lua.Tests/Callbacks/LuaCallbackTests.cs @@ -353,14 +353,14 @@ public async Task Detach_closes_admission_and_drains_callback_creation_before_re using ManualResetEventSlim admissionClosed = new(false); Counter counter = new(); - LuaCallback.BeforeRegistryAddForTesting = () => + LuaCallback.SetBeforeRegistryAddForTesting(() => { creationPaused.Set(); if (!allowPublication.Wait(TimeSpan.FromSeconds(5), cancellationToken)) { throw new TimeoutException("The callback-publication barrier timed out."); } - }; + }); LuaRuntime.OperationAdmissionClosedForTesting = admissionClosed.Set; try @@ -394,7 +394,7 @@ public async Task Detach_closes_admission_and_drains_callback_creation_before_re } finally { - LuaCallback.BeforeRegistryAddForTesting = null; + LuaCallback.SetBeforeRegistryAddForTesting(null); LuaRuntime.OperationAdmissionClosedForTesting = null; allowPublication.Set(); } @@ -781,7 +781,7 @@ public DisposeAdmissionFailureRace(CancellationToken cancellationToken) { _cancellationToken = cancellationToken; LuaRuntime.OperationAdmissionClosedForTesting = OnAdmissionClosed; - LuaCallback.DisposeAdmissionRefusedForTesting = OnDisposeAdmissionRefused; + LuaCallback.SetDisposeAdmissionRefusedForTesting(OnDisposeAdmissionRefused); LuaCallbackRegistry.AfterReleaseForTesting = ThrowCleanupFailure; } @@ -819,7 +819,7 @@ public void Dispose() public static void DisableFailureSeams() { LuaRuntime.OperationAdmissionClosedForTesting = null; - LuaCallback.DisposeAdmissionRefusedForTesting = null; + LuaCallback.SetDisposeAdmissionRefusedForTesting(null); LuaCallbackRegistry.AfterReleaseForTesting = null; } diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.csproj b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.csproj index 84d5c59b..fcd78339 100644 --- a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.csproj +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.csproj @@ -13,6 +13,9 @@ + + diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/NativeBridgeParityTests.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/NativeBridgeParityTests.cs new file mode 100644 index 00000000..e3e72ed7 --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Generator/NativeBridgeParityTests.cs @@ -0,0 +1,113 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +using CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Infrastructure; + +namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Generator; + +/// Parity tests for the production protected-operation catalogue and its C11 implementation. +public sealed class NativeBridgeParityTests +{ + private static readonly TimeSpan RegularExpressionTimeout = TimeSpan.FromSeconds(1); + + private static readonly NativeOperation[] ExpectedOperations = + [ + new("OP_PUSH_BYTES", 0), + new("OP_CREATE_TABLE", 1), + new("OP_NEW_USERDATA", 2), + new("OP_PUSH_CLOSURE", 3), + new("OP_RAWSET", 4), + new("OP_RAWSETI", 5), + new("OP_RAWSETP", 6), + new("OP_REF", 7), + new("OP_PUSH_REF", 8), + new("OP_UNREF", 9), + new("OP_PUSH_HOST_OBJECT", 10), + new("OP_PUSH_BYTE_TABLE", 11) + ]; + + [Fact] + public void Production_catalogue_C11_enum_switch_and_operation_mask_remain_in_lockstep() + { + string source = ProductionNativeBridge.Read(); + string nativeEnum = ExtractNativeOperationEnum(source); + string operationSwitch = ExtractOperationSwitch(source); + string operationMask = ExtractOperationMask(source); + + Assert.Equal(ExpectedOperations.Length, CountOperationEnumValues(nativeEnum)); + Assert.Equal(ExpectedOperations.Length, ExtractOperationCount(nativeEnum)); + + for (int i = 0; i < ExpectedOperations.Length; i++) + { + NativeOperation operation = ExpectedOperations[i]; + Assert.True(HasNativeEnumValue(nativeEnum, operation), + $"The native operation enum does not define {operation.Name} = {operation.Opcode}."); + Assert.True(HasOperationCase(operationSwitch, operation.Name), + $"The native protected-operation switch has no case for {operation.Name}."); + Assert.Contains(operation.Name, operationMask, StringComparison.Ordinal); + } + } + + private static string ExtractNativeOperationEnum(string source) + { + Match match = Regex.Match(source, + "enum\\s*\\{\\s*(?OP_PUSH_BYTES\\s*=\\s*0,[\\s\\S]*?CHEATENGINE_SDK_LUA_BRIDGE_OPERATION_COUNT\\s*=\\s*\\d+)\\s*\\};", + RegexOptions.CultureInvariant, + RegularExpressionTimeout); + Assert.True(match.Success, "The C11 protected-operation enum was not found."); + return match.Groups["values"].Value; + } + + private static string ExtractOperationSwitch(string source) + { + Match match = Regex.Match(source, + "switch\\s*\\(c->op\\)\\s*\\{(?[\\s\\S]*?)\\n\\s*default:", + RegexOptions.CultureInvariant, + RegularExpressionTimeout); + Assert.True(match.Success, "The C11 protected-operation switch was not found."); + return match.Groups["cases"].Value; + } + + private static string ExtractOperationMask(string source) + { + Match match = Regex.Match(source, + "#define\\s+CHEATENGINE_SDK_LUA_BRIDGE_OPERATION_MASK\\s*\\\\(?[\\s\\S]*?)\\n\\s*\\n", + RegexOptions.CultureInvariant, + RegularExpressionTimeout); + Assert.True(match.Success, "The C11 protected-operation mask was not found."); + return match.Groups["mask"].Value; + } + + private static int CountOperationEnumValues(string nativeEnum) + { + MatchCollection matches = Regex.Matches(nativeEnum, + "\\bOP_[A-Z_]+\\s*=\\s*\\d+", + RegexOptions.CultureInvariant, + RegularExpressionTimeout); + return matches.Count; + } + + private static int ExtractOperationCount(string nativeEnum) + { + Match match = Regex.Match(nativeEnum, + "CHEATENGINE_SDK_LUA_BRIDGE_OPERATION_COUNT\\s*=\\s*(?\\d+)", + RegexOptions.CultureInvariant, + RegularExpressionTimeout); + Assert.True(match.Success, "The C11 protected-operation count sentinel was not found."); + return int.Parse(match.Groups["count"].Value, CultureInfo.InvariantCulture); + } + + private static bool HasNativeEnumValue(string nativeEnum, NativeOperation operation) + { + string pattern = $"\\b{Regex.Escape(operation.Name)}\\s*=\\s*{operation.Opcode}\\b"; + return Regex.IsMatch(nativeEnum, pattern, RegexOptions.CultureInvariant, RegularExpressionTimeout); + } + + private static bool HasOperationCase(string operationSwitch, string operationName) + { + string pattern = $"\\bcase\\s+{Regex.Escape(operationName)}\\s*:"; + return Regex.IsMatch(operationSwitch, pattern, RegexOptions.CultureInvariant, RegularExpressionTimeout); + } + + private readonly record struct NativeOperation(string Name, int Opcode); +} diff --git a/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/ProductionNativeBridge.cs b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/ProductionNativeBridge.cs new file mode 100644 index 00000000..a0a9c126 --- /dev/null +++ b/tests/CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests/Infrastructure/ProductionNativeBridge.cs @@ -0,0 +1,19 @@ +using System.Reflection; +using System.Text; + +namespace CheatEngine.SDK.SourceGenerators.LuaBridgeContract.Tests.Infrastructure; + +/// The exact checked-in C11 bridge source embedded as deterministic parity-test data. +internal static class ProductionNativeBridge +{ + private const string ResourceName = "CheatEngine.SDK.LuaBridgeContract.Tests.ProductionNativeBridge.c"; + + public static string Read() + { + Assembly assembly = typeof(ProductionNativeBridge).Assembly; + using Stream? stream = assembly.GetManifestResourceStream(ResourceName); + Assert.NotNull(stream); + using StreamReader reader = new(stream, Encoding.UTF8, true); + return reader.ReadToEnd(); + } +} diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs index cb235401..012779fc 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/PackagedUmbrellaFixture.cs @@ -269,6 +269,34 @@ public string PackedAotConsumerPublishOutput private set; } = ""; + /// + /// The AOT-relevant diagnostics configured as errors by the package-only consumer before it is published. + /// + public string PackedAotConsumerWarningsAsErrors + { + get; + private set; + } = ""; + + /// + /// Whether the package-only AOT consumer explicitly enabled the unsafe generated Lua thunks require. + /// + public string PackedAotConsumerAllowUnsafeBlocks + { + get; + private set; + } = ""; + + /// + /// The evaluated Native AOT publication settings and diagnostic suppressions for the package-only consumer. + /// + public IReadOnlyDictionary PackedAotConsumerProperties + { + get; + private set; + } = + new Dictionary(StringComparer.Ordinal); + /// /// Whether the published package-only trim and Native AOT executable completed successfully. /// @@ -379,9 +407,12 @@ public ValueTask DisposeAsync() catch (IOException) { // Best effort: a file a virus scanner or editor still has open must not fail the test run. + _tempRoot = null; } catch (UnauthorizedAccessException) { + // Best effort: an external process may temporarily deny the recursive cleanup operation. + _tempRoot = null; } } @@ -437,8 +468,13 @@ private async Task InitializeDefaultConsumerAsync(string tempRoot, string feedDi { ThrowawayConsumer consumer = ThrowawayConsumer.Create(tempRoot, "DefaultConsumer", PackageVersion, feedDirectory, - includeLegacyAobConsumer: true, includeTargetBoundAllocationConsumer: true, - includeRecordAndSymbolContract: true, includeValueScanConsumer: true); + new ThrowawayConsumer.CreateOptions + { + IncludeLegacyAobConsumer = true, + IncludeTargetBoundAllocationConsumer = true, + IncludeRecordAndSymbolContract = true, + IncludeValueScanConsumer = true + }); await RestoreAndBuildAsync(consumer, packagesDirectory).ConfigureAwait(false); LegacyAobConsumerBuildSucceeded = File.Exists(Path.Combine(consumer.Directory, "LegacyAobConsumer.cs")); DefaultProperties = await consumer.GetPropertiesAsync(BuildTimeout, "AllowUnsafeBlocks", "EnableDynamicLoading", @@ -462,7 +498,10 @@ private async Task InitializeExplicitUnsafeFalseConsumerAsync(string tempRoot, s { ThrowawayConsumer consumer = ThrowawayConsumer.Create(tempRoot, "ExplicitUnsafeFalseConsumer", PackageVersion, feedDirectory, - " false\n"); + new ThrowawayConsumer.CreateOptions + { + ExtraProperties = " false\n" + }); await RestoreAndBuildAsync(consumer, packagesDirectory).ConfigureAwait(false); ExplicitUnsafeFalseProperties = await consumer.GetPropertiesAsync(BuildTimeout, "AllowUnsafeBlocks") .ConfigureAwait(false); @@ -473,7 +512,10 @@ private async Task InitializeEntryPointOffConsumerAsync(string tempRoot, string { ThrowawayConsumer consumer = ThrowawayConsumer.Create(tempRoot, "EntryPointOffConsumer", PackageVersion, feedDirectory, - " false\n"); + new ThrowawayConsumer.CreateOptions + { + ExtraProperties = " false\n" + }); // CESDK0003 deliberately makes the handoff explicit: disabling generation transfers ownership of the exact // host lookup identity to the plugin author. If the generator ignored the false switch, this source would also // make the consumer fail with the duplicate CESDK.CESDK type - so a successful build proves both contracts. @@ -494,7 +536,10 @@ private async Task InitializeLuaFunctionConsumersAsync(string tempRoot, string f { ThrowawayConsumer optInConsumer = ThrowawayConsumer.Create(tempRoot, "LuaFunctionOptInConsumer", PackageVersion, feedDirectory, - " true\n", includeLuaFunction: true); + new ThrowawayConsumer.CreateOptions + { + ExtraProperties = " true\n", IncludeLuaFunction = true + }); ProcessResult optInRestore = await optInConsumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); EnsureSucceeded(optInRestore, "dotnet restore", optInConsumer.ProjectPath); @@ -504,7 +549,8 @@ private async Task InitializeLuaFunctionConsumersAsync(string tempRoot, string f ThrowawayConsumer withoutUnsafeConsumer = ThrowawayConsumer.Create(tempRoot, "LuaFunctionWithoutUnsafeConsumer", PackageVersion, - feedDirectory, includeLuaFunction: true); + feedDirectory, + new ThrowawayConsumer.CreateOptions { IncludeLuaFunction = true }); ProcessResult restore = await withoutUnsafeConsumer.RestoreAsync(RestoreTimeout, packagesDirectory) .ConfigureAwait(false); EnsureSucceeded(restore, "dotnet restore", withoutUnsafeConsumer.ProjectPath); @@ -557,15 +603,23 @@ private async Task InitializePackedAotConsumerAsync(string tempRoot, string feed ThrowawayConsumer consumer = ThrowawayConsumer.CreateAotExecutable(tempRoot, consumerName, PackageVersion, feedDirectory, """ + true win-x64 true true true true - IL3058 + IL2026;IL3050;IL3058 """); ProcessResult restore = await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); EnsureSucceeded(restore, "dotnet restore", consumer.ProjectPath); + IReadOnlyDictionary aotProperties = await consumer + .GetPropertiesAsync(BuildTimeout, "AllowUnsafeBlocks", "PublishAot", "PublishTrimmed", "SelfContained", + "RuntimeIdentifier", "VerifyReferenceAotCompatibility", "WarningsAsErrors", "NoWarn") + .ConfigureAwait(false); + PackedAotConsumerProperties = aotProperties; + PackedAotConsumerAllowUnsafeBlocks = aotProperties["AllowUnsafeBlocks"]; + PackedAotConsumerWarningsAsErrors = aotProperties["WarningsAsErrors"]; ProcessResult build = await consumer.BuildAsync(BuildTimeout).ConfigureAwait(false); EnsureSucceeded(build, "dotnet build", consumer.ProjectPath); @@ -578,7 +632,15 @@ private async Task InitializePackedAotConsumerAsync(string tempRoot, string feed return; } - ProcessResult run = await consumer.RunPublishedAsync(publishDirectory, RuntimeRunTimeout).ConfigureAwait(false); + string bundledLuaPath = RepositoryLayout.PathOf("native/cheat-engine/lua53-64.dll"); + if (!File.Exists(bundledLuaPath)) + { + throw new InvalidOperationException( + $"The package-only AOT consumer requires the bundled test-only Lua fixture at '{bundledLuaPath}'."); + } + + ProcessResult run = await consumer.RunPublishedAsync(publishDirectory, bundledLuaPath, RuntimeRunTimeout) + .ConfigureAwait(false); PackedAotConsumerRunSucceeded = run.ExitCode == 0; PackedAotConsumerRunOutput = run.CombinedOutput; } @@ -613,7 +675,7 @@ private async Task InitializePlatformTargetConsumersAsync(string tempRoot, strin foreach ((string key, string consumerName, string? platformTarget) in PlatformTargetConsumers) { ThrowawayConsumer consumer = ThrowawayConsumer.Create(tempRoot, consumerName, PackageVersion, feedDirectory, - platformTarget: platformTarget); + new ThrowawayConsumer.CreateOptions { PlatformTarget = platformTarget }); ProcessResult restore = await consumer.RestoreAsync(RestoreTimeout, packagesDirectory).ConfigureAwait(false); EnsureSucceeded(restore, "dotnet restore", consumer.ProjectPath); diff --git a/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs b/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs index 98a911a3..72c4e81f 100644 --- a/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs +++ b/tests/CheatEngine.SDK.Tests/Infrastructure/ThrowawayConsumer.cs @@ -261,8 +261,13 @@ private static long ExecuteForInteger(LuaState state, ReadOnlySpan source) private const string AotProgramSource = """ using System; + using System.Runtime.CompilerServices; + using System.Runtime.InteropServices; using CheatEngine.SDK.Annotations.Lua; using CheatEngine.SDK.Lua.Calls; + using CheatEngine.SDK.Lua.Interop.Api; + using CheatEngine.SDK.Lua.Interop.Types; + using CheatEngine.SDK.Lua.Runtime; using CheatEngine.SDK.Lua.State; namespace ThrowawayAot; @@ -273,18 +278,87 @@ internal static partial class GeneratedAotBinding public static partial LuaOperationStatus TryProbe(LuaState state); } - internal static class Program + // This is a standalone native-Lua probe. It uses the test fixture's Lua 5.3 DLL, + // not a Cheat Engine host, and executes the generated binding before reporting success. + internal static unsafe class Program { + private static nint s_state; + public static int Main(string[] args) { - // Native AOT keeps this generated body because the non-default command-line - // path references it, while the normal no-host test run never invokes it. - if (args.Length != 0) - _ = GeneratedAotBinding.TryProbe(default); - - Console.WriteLine("SDK-022-AOT-STANDALONE"); - Console.WriteLine("SDK-022-AOT-NO-CE-HOST"); - return 0; + if (args.Length != 1) + { + Console.Error.WriteLine("Expected exactly one Lua 5.3 DLL path."); + return 64; + } + + try + { + Run(args[0]); + Console.WriteLine("SDK-022-AOT-GENERATED-BINDING"); + Console.WriteLine("SDK-022-AOT-STANDALONE"); + Console.WriteLine("SDK-022-AOT-NO-CE-HOST"); + return 0; + } + catch (Exception exception) + { + Console.Error.WriteLine(exception); + return 1; + } + } + + private static void Run(string luaLibraryPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(luaLibraryPath); + LuaApi.Initialize(NativeLibrary.Load(luaLibraryPath)); + lua_State* statePointer = LuaApi.luaL_newstate(); + if (statePointer is null) + throw new InvalidOperationException("The Lua fixture could not create a state."); + + try + { + LuaApi.luaL_openlibs(statePointer); + LuaState state = new((nint)statePointer); + s_state = state.Handle; + delegate* unmanaged[Stdcall] stateProvider = &ProvideState; + LuaHostBinding binding = new((nint)stateProvider, 0, + Environment.CurrentManagedThreadId); + LuaRuntime.Attach(in binding); + try + { + using LuaFrame frame = new(state); + LuaStatus setup = state.TryExecute( + "sdk022_aot_probe_invocations = 0; function sdk022_aot_probe() sdk022_aot_probe_invocations = sdk022_aot_probe_invocations + 1 end"u8, + 0, "=sdk022-aot"u8); + if (!setup.IsOk) + throw new InvalidOperationException("The Lua fixture setup failed with " + setup + "."); + + LuaOperationStatus probe = GeneratedAotBinding.TryProbe(state); + if (!probe.IsSuccess) + throw new InvalidOperationException("The generated AOT binding failed with " + probe.Kind + "."); + + LuaStatus invocationCount = state.TryExecute( + "assert(sdk022_aot_probe_invocations == 1, 'GeneratedAotBinding.TryProbe did not invoke the Lua probe exactly once.')"u8, + 0, "=sdk022-aot-assertion"u8); + if (!invocationCount.IsOk) + throw new InvalidOperationException("The generated AOT binding invocation assertion failed with " + invocationCount + "."); + } + finally + { + LuaRuntime.Detach(); + s_state = 0; + } + } + finally + { + LuaApi.lua_close(statePointer); + } + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void* ProvideState() + { + return (void*)s_state; } } """; @@ -471,40 +545,38 @@ public string AssemblyPath /// Scaffolds a project named under : an /// net10.0 class library with one PackageReference to CheatEngine.SDK. Its generated /// NuGet.Config maps that exact package identity to while retaining - /// nuget.org for other package identities. The project has one minimal but valid plugin class and whatever - /// adds to its single PropertyGroup. When - /// is , - /// the project also declares one valid [LuaFunction] export. When - /// is , it compiles both historical AobScanner.TryScan overloads against the packed - /// SDK. When is , it compiles an + /// nuget.org for other package identities. The project has one minimal but valid plugin class and the optional + /// additions to its single PropertyGroup. When + /// is , the project also declares one + /// valid [LuaFunction] export. When is + /// , it compiles both historical AobScanner.TryScan overloads against the packed + /// SDK. When is , it compiles + /// an /// independent implementation of the target-bound allocation backend seam against that package. When - /// is , it compiles the + /// is , it compiles the /// SDK-021 typed mutation and coordinated-symbol surfaces without accessing raw CE handles. - /// When is , it compiles the value-scan - /// factory and bounded-copy APIs against the same packed SDK. - /// defaults to x64, but may be to prove the package - /// behavior when the consumer does not declare it. + /// When is , it compiles the + /// value-scan factory and bounded-copy APIs against the same packed SDK. The + /// property defaults to x64 and may be . /// public static ThrowawayConsumer Create(string parentDirectory, string name, string cheatEngineSdkVersion, - string localFeedDirectory, string extraProperties = "", string? platformTarget = "x64", - bool includeLuaFunction = false, bool includeLegacyAobConsumer = false, - bool includeTargetBoundAllocationConsumer = false, bool includeRecordAndSymbolContract = false, - bool includeValueScanConsumer = false) + string localFeedDirectory, CreateOptions? options = null) { + options ??= new CreateOptions(); string directory = Path.Combine(parentDirectory, name); System.IO.Directory.CreateDirectory(directory); string projectPath = Path.Combine(directory, $"{name}.csproj"); - string platformTargetProperty = platformTarget is null + string platformTargetProperty = options.PlatformTarget is null ? "" - : $" {platformTarget}\n"; + : $" {options.PlatformTarget}\n"; File.WriteAllText(projectPath, $""" net10.0 {platformTargetProperty} enable - {extraProperties} + {options.ExtraProperties} @@ -512,28 +584,28 @@ public static ThrowawayConsumer Create(string parentDirectory, string name, stri """); File.WriteAllText(Path.Combine(directory, "Plugin.cs"), PluginSource); - if (includeLuaFunction) + if (options.IncludeLuaFunction) { File.WriteAllText(Path.Combine(directory, "Functions.cs"), LuaFunctionSource); } - if (includeLegacyAobConsumer) + if (options.IncludeLegacyAobConsumer) { File.WriteAllText(Path.Combine(directory, "LegacyAobConsumer.cs"), LegacyAobSource); } - if (includeTargetBoundAllocationConsumer) + if (options.IncludeTargetBoundAllocationConsumer) { File.WriteAllText(Path.Combine(directory, "TargetBoundAllocationBackend.cs"), TargetBoundAllocationSource); } - if (includeRecordAndSymbolContract) + if (options.IncludeRecordAndSymbolContract) { File.WriteAllText(Path.Combine(directory, "RecordAndSymbolContractConsumer.cs"), RecordAndSymbolContractSource); } - if (includeValueScanConsumer) + if (options.IncludeValueScanConsumer) { File.WriteAllText(Path.Combine(directory, "ValueScanConsumer.cs"), ValueScanSource); } @@ -569,8 +641,8 @@ public static ThrowawayConsumer CreateInvalidDuplicateLuaFunctionConsumer(string } /// - /// Scaffolds a package-only executable whose source includes a generated Lua binding but whose program neither - /// loads a native Lua module nor activates a Cheat Engine host. The fixture supplies trim/AOT/RID properties. + /// Scaffolds a package-only executable whose source executes a generated Lua binding against the fixture-supplied + /// native Lua module. It never activates a Cheat Engine host; the fixture supplies trim/AOT/RID properties. /// public static ThrowawayConsumer CreateAotExecutable(string parentDirectory, string name, string cheatEngineSdkVersion, @@ -718,13 +790,17 @@ public Task PublishAsync(TimeSpan timeout, string outputDirectory timeout); } - /// Runs the native executable emitted by a publish into . - public Task RunPublishedAsync(string outputDirectory, TimeSpan timeout) + /// + /// Runs the native executable emitted by a publish into with the Lua 5.3 + /// fixture path the AOT probe requires to execute its generated binding. + /// + public Task RunPublishedAsync(string outputDirectory, string luaLibraryPath, TimeSpan timeout) { ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(luaLibraryPath); string executableName = Path.GetFileNameWithoutExtension(ProjectPath) + ".exe"; string executablePath = Path.Combine(outputDirectory, executableName); - return ProcessRunner.RunAsync(executablePath, "", outputDirectory, timeout); + return ProcessRunner.RunAsync(executablePath, $"\"{luaLibraryPath}\"", outputDirectory, timeout); } /// @@ -747,8 +823,7 @@ public async Task> GetPropertiesAsync(TimeSp Dictionary values = new(StringComparer.Ordinal); - // MSBuild's own documented split: "-getProperty to request a single property" emits a bare string; - // several properties (this project always requests at least one, so >= 2 here) emit one JSON object. + // MSBuild emits a bare value for one requested property and a properties object when several are requested. if (propertyNames.Length == 1) { values[propertyNames[0]] = result.StandardOutput.Trim(); @@ -773,4 +848,50 @@ public async Task> GetPropertiesAsync(TimeSp return values; } + + /// Optional source and project switches used when scaffolding a packaged consumer. + internal sealed class CreateOptions + { + public string ExtraProperties + { + get; + init; + } = ""; + + public string? PlatformTarget + { + get; + init; + } = "x64"; + + public bool IncludeLuaFunction + { + get; + init; + } + + public bool IncludeLegacyAobConsumer + { + get; + init; + } + + public bool IncludeTargetBoundAllocationConsumer + { + get; + init; + } + + public bool IncludeRecordAndSymbolContract + { + get; + init; + } + + public bool IncludeValueScanConsumer + { + get; + init; + } + } } diff --git a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs index a0921d2d..88ae1000 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/NativeBridgePeAuditTests.cs @@ -15,9 +15,10 @@ public sealed class NativeBridgePeAuditTests private const string BridgeRelativePath = "native/cheatengine-sdk-lua-bridge/runtimes/win-x64/native/cheatengine-sdk-lua-bridge.dll"; - private const string ManifestRelativePath = "native/cheatengine-sdk-lua-bridge/bridge-audit-manifest.json"; private const string SourceRelativePath = "native/cheatengine-sdk-lua-bridge/cheatengine_sdk_lua_bridge.c"; private const string BuildRelativePath = "native/cheatengine-sdk-lua-bridge/xmake.lua"; + private const string ContinuousIntegrationWorkflowRelativePath = ".github/workflows/ci.yml"; + private const string PinnedXmakeVersion = "3.0.9"; private static readonly string[] ExpectedExports = [ @@ -75,52 +76,61 @@ public void Checked_in_bridge_imports_only_kernel32_without_delay_load() } [Fact] - public void Audit_manifest_records_the_pinned_toolchain_flags_and_hashes() + public void Checked_in_bridge_exports_the_current_source_and_xmake_fingerprint() { - using JsonDocument manifest = ReadManifest(); - JsonElement root = manifest.RootElement; - - Assert.Equal(2, root.GetProperty("schemaVersion").GetInt32()); - Assert.Equal("xmake", root.GetProperty("toolchain").GetProperty("buildSystem").GetProperty("name").GetString()); - Assert.Equal("3.0.9", - root.GetProperty("toolchain").GetProperty("buildSystem").GetProperty("version").GetString()); - Assert.Equal("MSVC", root.GetProperty("toolchain").GetProperty("compiler").GetProperty("name").GetString()); - Assert.Equal("C11", root.GetProperty("toolchain").GetProperty("languageStandard").GetString()); - Assert.Equal("MT", root.GetProperty("toolchain").GetProperty("cRuntime").GetString()); - AssertJsonStringSet(root.GetProperty("toolchain").GetProperty("compilerFlags"), ["/MT", "allextra", "error"]); - AssertJsonStringSet(root.GetProperty("toolchain").GetProperty("linkerFlags"), ["/Brepro"]); - Assert.True(root.GetProperty("reproducibility").GetProperty("doubleBuildSha256Comparison").GetBoolean()); - - JsonElement nativeAsset = root.GetProperty("nativeAsset"); - Assert.Equal("PE32+", nativeAsset.GetProperty("pe").GetProperty("format").GetString()); - Assert.Equal("AMD64", nativeAsset.GetProperty("pe").GetProperty("machine").GetString()); - Assert.True(nativeAsset.GetProperty("pe").GetProperty("isDll").GetBoolean()); - AssertJsonStringSet(nativeAsset.GetProperty("exports"), ExpectedExports); - AssertJsonStringSet(nativeAsset.GetProperty("delayImports"), []); - AssertJsonStringSet(nativeAsset.GetProperty("imports"), s_allowedImportModules); - string sourceHash = CalculateSha256(RepositoryLayout.PathOf(SourceRelativePath)); string buildHash = CalculateSha256(RepositoryLayout.PathOf(BuildRelativePath)); string expectedFingerprint = $"{sourceHash}:{buildHash}"; - Assert.Equal(sourceHash, - root.GetProperty("source").GetProperty("hashes").GetProperty("cheatengine_sdk_lua_bridge.c").GetString()); - Assert.Equal(buildHash, root.GetProperty("source").GetProperty("hashes").GetProperty("xmake.lua").GetString()); - Assert.Equal(expectedFingerprint, root.GetProperty("source").GetProperty("fingerprint").GetString()); - - string bridgeHash = CalculateSha256(BridgePath); - Assert.Equal(bridgeHash, nativeAsset.GetProperty("sha256").GetString()); Assert.Equal(expectedFingerprint, ReadBridge().ReadExportedAsciiZ("cheatengine_sdk_lua_bridge_source_fingerprint")); } - private static PortableExecutableInspector ReadBridge() + [Fact] + public void Native_bridge_xmake_configuration_pins_the_required_compilation_contract() { - return PortableExecutableInspector.Read(BridgePath); + string xmakeConfiguration = ReadRepositoryText(BuildRelativePath); + + Assert.Contains("set_languages(\"c11\")", xmakeConfiguration, StringComparison.Ordinal); + Assert.Contains("set_warnings(\"allextra\", \"error\")", xmakeConfiguration, StringComparison.Ordinal); + Assert.Contains("set_toolchains(\"msvc\")", xmakeConfiguration, StringComparison.Ordinal); + Assert.Contains("set_policy(\"build.c++.msvc.runtime\", \"MT\")", xmakeConfiguration, + StringComparison.Ordinal); + Assert.Contains("set_runtimes(\"MT\")", xmakeConfiguration, StringComparison.Ordinal); + Assert.Contains("add_cflags(\"/MT\", {tools = \"cl\", force = true})", xmakeConfiguration, + StringComparison.Ordinal); + Assert.Contains("add_shflags(\"/Brepro\", {tools = \"link\", force = true})", xmakeConfiguration, + StringComparison.Ordinal); } - private static JsonDocument ReadManifest() + [Fact] + public void Native_bridge_ci_pins_xmake_and_enforces_a_double_build_reproducibility_gate() { - return JsonDocument.Parse(File.ReadAllText(RepositoryLayout.PathOf(ManifestRelativePath))); + string workflow = ReadRepositoryText(ContinuousIntegrationWorkflowRelativePath); + + Assert.Contains("xmake-io/github-action-setup-xmake@", workflow, StringComparison.Ordinal); + Assert.Contains($"xmake-version: '{PinnedXmakeVersion}'", workflow, StringComparison.Ordinal); + Assert.Contains("$primaryOutput = 'artifacts/native/cheatengine-sdk-lua-bridge'", workflow, + StringComparison.Ordinal); + Assert.Contains("$reproducibilityOutput = 'artifacts/native/cheatengine-sdk-lua-bridge-repro'", workflow, + StringComparison.Ordinal); + Assert.Contains("The primary and reproducibility bridge output directories must be distinct.", workflow, + StringComparison.Ordinal); + Assert.Contains("xmake f -P native/cheatengine-sdk-lua-bridge -o $primaryOutput", workflow, + StringComparison.Ordinal); + Assert.Contains("xmake f -P native/cheatengine-sdk-lua-bridge -o $reproducibilityOutput", workflow, + StringComparison.Ordinal); + Assert.Contains("$primaryHash = (Get-FileHash -LiteralPath $primaryBridge -Algorithm SHA256).Hash", workflow, + StringComparison.Ordinal); + Assert.Contains( + "$reproducibilityHash = (Get-FileHash -LiteralPath $reproducibilityBridge -Algorithm SHA256).Hash", + workflow, StringComparison.Ordinal); + Assert.Contains("$primaryHash, $reproducibilityHash, [StringComparison]::OrdinalIgnoreCase", workflow, + StringComparison.Ordinal); + } + + private static PortableExecutableInspector ReadBridge() + { + return PortableExecutableInspector.Read(BridgePath); } private static List GetExportNames(PortableExecutableInspector image) @@ -146,21 +156,9 @@ private static void AssertExactSet(string[] expected, List actual) } } - private static void AssertJsonStringSet(JsonElement array, string[] expected) + private static string ReadRepositoryText(string relativePath) { - Assert.Equal(JsonValueKind.Array, array.ValueKind); - Assert.Equal(expected.Length, array.GetArrayLength()); - HashSet values = new(StringComparer.Ordinal); - foreach (JsonElement entry in array.EnumerateArray()) - { - values.Add(entry.GetString() ?? string.Empty); - } - - Assert.Equal(expected.Length, values.Count); - for (int index = 0; index < expected.Length; index++) - { - Assert.Contains(expected[index], values, StringComparer.Ordinal); - } + return File.ReadAllText(RepositoryLayout.PathOf(relativePath)); } private static string CalculateSha256(string path) diff --git a/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs b/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs index b3f9a541..3bc108fe 100644 --- a/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs +++ b/tests/CheatEngine.SDK.Tests/Packaging/PackedConsumerBindingTests.cs @@ -9,6 +9,8 @@ namespace CheatEngine.SDK.Tests.Packaging; [Collection(PackagedUmbrellaSuite.Name)] public sealed class PackedConsumerBindingTests(PackagedUmbrellaFixture fixture) { + private static readonly string[] RequiredAotWarningIds = ["IL2026", "IL3050", "IL3058"]; + private static readonly string[] ExpectedLibNet10Assets = [ "lib/net10.0/CheatEngine.SDK.dll", @@ -69,10 +71,30 @@ public void Packaged_AOT_consumer_publishes_and_runs_only_as_a_standalone_execut Assert.True(fixture.PackedAotConsumerRunSucceeded, $"The published package-only AOT consumer did not exit successfully:{Environment.NewLine}" + fixture.PackedAotConsumerRunOutput); + Assert.Contains("SDK-022-AOT-GENERATED-BINDING", fixture.PackedAotConsumerRunOutput, + StringComparison.Ordinal); Assert.Contains("SDK-022-AOT-STANDALONE", fixture.PackedAotConsumerRunOutput, StringComparison.Ordinal); Assert.Contains("SDK-022-AOT-NO-CE-HOST", fixture.PackedAotConsumerRunOutput, StringComparison.Ordinal); } + [Fact] + public void Packaged_AOT_consumer_evaluates_its_publication_contract_and_does_not_suppress_trim_or_AOT_warnings() + { + Assert.Equal("true", fixture.PackedAotConsumerAllowUnsafeBlocks, true); + Assert.Equal("true", fixture.PackedAotConsumerProperties["PublishAot"], true); + Assert.Equal("true", fixture.PackedAotConsumerProperties["PublishTrimmed"], true); + Assert.Equal("true", fixture.PackedAotConsumerProperties["SelfContained"], true); + Assert.Equal("win-x64", fixture.PackedAotConsumerProperties["RuntimeIdentifier"], true); + Assert.Equal("true", fixture.PackedAotConsumerProperties["VerifyReferenceAotCompatibility"], true); + + foreach (string warningId in RequiredAotWarningIds) + { + Assert.Contains(warningId, fixture.PackedAotConsumerWarningsAsErrors, StringComparison.Ordinal); + Assert.DoesNotContain(warningId, fixture.PackedAotConsumerProperties["NoWarn"], + StringComparison.OrdinalIgnoreCase); + } + } + [Fact] public void Legacy_Aob_consumer_still_compiles_against_the_packed_package() {