From 3e386957c65402459b7c6d68be2202defafc451a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Wed, 30 Sep 2026 09:22:48 +0200 Subject: [PATCH 1/4] Prepare 2.0.0-beta.2 release --- Directory.Build.props | 2 +- README.md | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9353e6d..19dcd71 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -20,7 +20,7 @@ $(WarningsNotAsErrors);NU1900;NU1901;NU1902;NU1905 x64 - 2.0.0-beta.1 + 2.0.0-beta.2 CheatEngineNet CheatEngineNet https://github.com/CheatEngineNet/CheatEngine.Mcp diff --git a/README.md b/README.md index 918b0a2..d91c481 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ CheatEngine.Mcp connects a local AI client to Cheat Engine 7.7 x64 through the M It combines a plugin loaded by Cheat Engine with a standalone stdio gateway. One gateway can discover several Cheat Engine processes and route each request to the instance you choose. -> **Using the current source?** -> Build the distribution from this checkout. -> Assets on the [releases page](https://github.com/CheatEngineNet/CheatEngine.Mcp/releases) may target an earlier version or tool contract. -> Keep the plugin folder and gateway executable from the same build. +> **Beta release:** Download [2.0.0-beta.2](https://github.com/CheatEngineNet/CheatEngine.Mcp/releases/tag/v2.0.0-beta.2) for Windows x64. +> Extract the complete distribution ZIP and keep the plugin folder and gateway executable together. +> This beta changes the plugin layout and tool contract from beta.1; replace the old installation rather than mixing its files with this release. +> To use later source changes, build the distribution from the matching checkout. ## How it works @@ -33,7 +33,11 @@ Run the gateway and Cheat Engine under the same Windows user account. ### 1. Get the deployment files -From the repository root, build the current source distribution with .NET SDK **10.0.401**, PowerShell 7, and the Windows C++ build tools required by Native AOT: +Download and extract `CheatEngine.Mcp-2.0.0-beta.2-win-x64.zip` from the [beta release](https://github.com/CheatEngineNet/CheatEngine.Mcp/releases/tag/v2.0.0-beta.2). +The ZIP includes the complete plugin folder, gateway executable, installation instructions, and licenses. +Remove the old single-DLL plugin entry when upgrading from beta.1, then add `CheatEngine.Mcp.Plugin.dll` from the new folder as described below. + +To build the same layout from source instead, run this from the repository root with .NET SDK **10.0.401**, PowerShell 7, and the Windows C++ build tools required by Native AOT: ```powershell pwsh -NoProfile -File eng/Publish.ps1 -Configuration Release From de42fa228362cd5c773a2fafed8672921e87f1ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Wed, 30 Sep 2026 09:29:03 +0200 Subject: [PATCH 2/4] Fix live scanner status contract comparison --- .../McpLiveQualificationTests.cs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs index 0cd9c21..9268a3f 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs @@ -339,7 +339,20 @@ await SuccessfulCallAsync(instanceA, CheatEngineToolNames.ScanFirst, Assert.Equal(expected["count"]!.GetValue(), scanner["count"]!.GetValue()); JsonNode status = await SuccessfulCallAsync(instanceA, CheatEngineToolNames.ScanGetStatus, new Dictionary { ["scannerName"] = name }); - Assert.True(JsonNode.DeepEquals(scanner, status)); + if (name == "main") + { + Assert.IsType(status["settings"]); + } + else + { + Assert.Null(status["settings"]); + } + + // Main status adds read-only UI settings; compare the shared scanner state separately. + JsonObject statusState = status.DeepClone().AsObject(); + statusState.Remove("settings"); + Assert.True(JsonNode.DeepEquals(scanner, statusState), + $"Scanner '{name}' state differs between scan_list_scanners and scan_get_status."); } Assert.Single( From f90e6e791433947f172f0d2122dfd72ac5d65c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Wed, 30 Sep 2026 09:44:57 +0200 Subject: [PATCH 3/4] Restore CE 7.7 disassembly display columns --- .../Code/CodeGraphTools.cs | 5 ++-- .../Code/CodeJsonContext.cs | 1 + .../Code/CodeLuaRecords.cs | 2 ++ .../CheatEngine.Mcp.Tools/Code/CodeScripts.cs | 9 ++++++ srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs | 30 +++++++++++++++++-- .../NativeLua/NativeLuaCodeTests.cs | 21 +++++++++++++ .../Resources/LiveResourceTests.cs | 12 ++++++-- .../Support/StateTestHarness.cs | 4 ++- .../Tools/Code/CodeGraphTarget.cs | 9 +++++- .../Tools/CodeAsmTableV2Tests.cs | 21 ++++++++++++- 10 files changed, 103 insertions(+), 11 deletions(-) diff --git a/srcs/CheatEngine.Mcp.Tools/Code/CodeGraphTools.cs b/srcs/CheatEngine.Mcp.Tools/Code/CodeGraphTools.cs index 6d6a994..6d2d85d 100644 --- a/srcs/CheatEngine.Mcp.Tools/Code/CodeGraphTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Code/CodeGraphTools.cs @@ -142,14 +142,15 @@ internal static bool Is64Bit(ProcessSnapshot process) /// The graph's entry, whose decode failure fails the call, or . /// The token of the enclosing dispatch body. /// The instruction, or when that address alone cannot be decoded. - private static AssemblyInstructionSnapshot? Decode(ICheatEngineClient client, ulong address, ulong? entry, + private AssemblyInstructionSnapshot? Decode(ICheatEngineClient client, ulong address, ulong? entry, CancellationToken cancellationToken) { if (client.Assembly.TryDisassemble(new Address(address), out AssemblyInstructionSnapshot instruction, out CheatEngineFailure failure, cancellationToken)) { return instruction.Length > 0 - ? instruction + ? CodeTools.CorrectColumns(_dispatch, instruction, CheatEngineToolNames.CodeGetFunctionGraph, + cancellationToken) : throw CodeTools.NonPositiveLength(CheatEngineToolNames.CodeGetFunctionGraph); } diff --git a/srcs/CheatEngine.Mcp.Tools/Code/CodeJsonContext.cs b/srcs/CheatEngine.Mcp.Tools/Code/CodeJsonContext.cs index 6598bbb..04d4f74 100644 --- a/srcs/CheatEngine.Mcp.Tools/Code/CodeJsonContext.cs +++ b/srcs/CheatEngine.Mcp.Tools/Code/CodeJsonContext.cs @@ -30,6 +30,7 @@ public sealed partial class CodeJsonContext : JsonSerializerContext; /// Source-generated metadata for the fixed Lua results that remain internal to the code tools. [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] [JsonSerializable(typeof(CodeLuaByteDisassembly))] +[JsonSerializable(typeof(CodeLuaDisassemblyColumns))] [JsonSerializable(typeof(CodeLuaFunction))] [JsonSerializable(typeof(CodeLuaReferencePage))] [JsonSerializable(typeof(CodeLuaStringPage))] diff --git a/srcs/CheatEngine.Mcp.Tools/Code/CodeLuaRecords.cs b/srcs/CheatEngine.Mcp.Tools/Code/CodeLuaRecords.cs index f2d1486..52a55ef 100644 --- a/srcs/CheatEngine.Mcp.Tools/Code/CodeLuaRecords.cs +++ b/srcs/CheatEngine.Mcp.Tools/Code/CodeLuaRecords.cs @@ -2,6 +2,8 @@ namespace CheatEngine.Mcp.Tools.Code; internal sealed record CodeLuaByteDisassembly(string Origin, string Text); +internal sealed record CodeLuaDisassemblyColumns(string AddressText, string Opcode, string Extra); + internal sealed record CodeLuaFunction( bool Found, string Address, diff --git a/srcs/CheatEngine.Mcp.Tools/Code/CodeScripts.cs b/srcs/CheatEngine.Mcp.Tools/Code/CodeScripts.cs index db823ef..7a7a029 100644 --- a/srcs/CheatEngine.Mcp.Tools/Code/CodeScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/Code/CodeScripts.cs @@ -3,6 +3,15 @@ namespace CheatEngine.Mcp.Tools.Code; /// Fixed, bounded Lua bodies for Cheat Engine code-view APIs absent from the typed Client. internal static class CodeScripts { + internal const string DisassemblyColumns = """ + local text = disassemble(a[1]) + local extra, opcode, bytes, addressText = splitDisassembledString(text) + if type(addressText) ~= 'string' or type(opcode) ~= 'string' or type(extra) ~= 'string' then + return mcp.err('host_refused', 'Cheat Engine returned invalid disassembly columns.', 'completed') + end + return { addressText=addressText, opcode=opcode, extra=extra } + """; + internal const string DisassembleBytes = """ local origin = 0 if a[2] ~= nil then diff --git a/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs b/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs index c56daa6..85b3e1b 100644 --- a/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs @@ -78,7 +78,8 @@ public CodeDisassembly Disassemble( Address current = first; for (int index = 0; index < instructions.Length; index++) { - AssemblyInstructionSnapshot decoded = client.Assembly.Disassemble(current, token); + AssemblyInstructionSnapshot decoded = CorrectColumns(_dispatch, + client.Assembly.Disassemble(current, token), CheatEngineToolNames.CodeDisassemble, token); instructions[index] = Instruction(decoded, CheatEngineToolNames.CodeDisassemble); current = decoded.Address + decoded.Length; } @@ -102,7 +103,8 @@ public CodeDecodeResult Decode( return _dispatch.Run(CheatEngineToolNames.CodeDecode, token => { Address target = MemoryTargets.Resolve(_dispatch.Client, expression, "address", token); - AssemblyInstructionSnapshot decoded = _dispatch.Client.Assembly.Disassemble(target, token); + AssemblyInstructionSnapshot decoded = CorrectColumns(_dispatch, + _dispatch.Client.Assembly.Disassemble(target, token), CheatEngineToolNames.CodeDecode, token); CodeInstruction instruction = Instruction(decoded, CheatEngineToolNames.CodeDecode); return new CodeDecodeResult(instruction, instruction.Size); }, cancellationToken); @@ -454,7 +456,8 @@ private async Task RunSearchAsync(JobWriter writer, Address target { ICheatEngineClient client = _dispatch.Client; MemoryTargets.RequireSameTarget(client, epoch, CheatEngineToolNames.CodeStartSearch, token); - return client.Assembly.Disassemble(current, token); + return CorrectColumns(_dispatch, client.Assembly.Disassemble(current, token), + CheatEngineToolNames.CodeStartSearch, token); }, cancellationToken).ConfigureAwait(false); CodeInstruction instruction = Instruction(decoded, CheatEngineToolNames.CodeStartSearch); if (instruction.Text.Contains(textContains, StringComparison.OrdinalIgnoreCase)) @@ -468,6 +471,27 @@ private async Task RunSearchAsync(JobWriter writer, Address target } } + /// Copies CE's actual display columns while retaining Client-owned instruction bytes and length. + /// The enclosing Client dispatch. + /// The typed instruction snapshot. + /// The tool name. + /// The enclosing dispatch token. + /// The snapshot with corrected display columns. + internal static AssemblyInstructionSnapshot CorrectColumns(ToolDispatch dispatch, + AssemblyInstructionSnapshot instruction, string operation, CancellationToken cancellationToken) + { + if (instruction.Length <= 0) + { + throw NonPositiveLength(operation); + } + + // Client 1.0 / SDK 2.0 follows the documented split order; CE 7.7 returns extra, opcode, bytes, address. + CodeLuaDisassemblyColumns columns = dispatch.ExecuteLua(operation, CodeScripts.DisassemblyColumns, + CodeLuaJsonContext.Default.CodeLuaDisassemblyColumns, cancellationToken, instruction.Address.Value); + return new AssemblyInstructionSnapshot(instruction.Address, instruction.Length, columns.AddressText, + columns.Opcode, columns.Extra, instruction.Bytes.AsSpan()); + } + /// Copies one decoded instruction into the contract form. /// Cheat Engine's copied instruction. /// The tool name, for a malformed host instruction. diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaCodeTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaCodeTests.cs index 50141b2..7943560 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaCodeTests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaCodeTests.cs @@ -31,6 +31,7 @@ public sealed partial class NativeLuaToolRuntimeTests public static TheoryData CodeScriptBodies => new() { + { nameof(CodeScripts.DisassemblyColumns), CodeScripts.DisassemblyColumns }, { nameof(CodeScripts.DisassembleBytes), CodeScripts.DisassembleBytes }, { nameof(CodeScripts.GetFunction), CodeScripts.GetFunction }, { nameof(CodeScripts.Dissect), CodeScripts.Dissect }, @@ -49,6 +50,7 @@ public void CodeScript_WithRepresentativeArguments_CompilesAndNeverLoadsCode(str using RuntimeScope scope = CreateScope(); object?[] arguments = name switch { + nameof(CodeScripts.DisassemblyColumns) => [0x401000UL], nameof(CodeScripts.DisassembleBytes) => ["488B05", "1000"], nameof(CodeScripts.GetFunction) => ["game.exe+10", 4096], nameof(CodeScripts.Dissect) => [0x140001000UL, 4096], @@ -65,6 +67,25 @@ public void CodeScript_WithRepresentativeArguments_CompilesAndNeverLoadsCode(str CodeAssertCompiles(name, LuaToolRuntime.BuildSource(body, 100, arguments)); } + [Fact] + public void CodeDisassemblyColumns_Ce77StackOrder_PreservesDisplayColumns() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(""" + disassemble = function(address) assert(address == 0x401000); return 'game.exe+1000 - 90 - nop' end + splitDisassembledString = function(text) + assert(text == 'game.exe+1000 - 90 - nop') + return 'annotation', 'nop', '90', 'game.exe+1000' + end + """); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + + CodeLuaDisassemblyColumns columns = dispatch.RunLua("code_decode", CodeScripts.DisassemblyColumns, + CodeLuaJsonContext.Default.CodeLuaDisassemblyColumns, CancellationToken.None, 0x401000UL); + + Assert.Equal(new CodeLuaDisassemblyColumns("game.exe+1000", "nop", "annotation"), columns); + } + [Fact] public void CodeFindReferences_Stubbed_SortsFromAddressesNumericallyAndUnsigned() { diff --git a/tests/CheatEngine.Mcp.Tests/Resources/LiveResourceTests.cs b/tests/CheatEngine.Mcp.Tests/Resources/LiveResourceTests.cs index 4b9cc27..75e41ed 100644 --- a/tests/CheatEngine.Mcp.Tests/Resources/LiveResourceTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Resources/LiveResourceTests.cs @@ -797,6 +797,7 @@ private sealed class DisassemblyTarget { internal DisassemblyTarget() { + Address current = new(0x401000); IInspectionClient inspection = ClientTestDouble.Create(static (method, arguments) => { Assert.Equal(nameof(IInspectionClient.TryResolveAddress), method.Name); @@ -804,15 +805,20 @@ internal DisassemblyTarget() arguments[3] = default(CheatEngineFailure); return true; }); - IAssemblyClient assembly = ClientTestDouble.Create(static (method, arguments) => + IAssemblyClient assembly = ClientTestDouble.Create((method, arguments) => { Assert.Equal(nameof(IAssemblyClient.Disassemble), method.Name); - return new AssemblyInstructionSnapshot((Address) arguments![0]!, 1, string.Empty, "90", "nop", [0x90]); + current = (Address) arguments![0]!; + return new AssemblyInstructionSnapshot(current, 1, string.Empty, "90", "nop", [0x90]); }); ICheatEngineClient client = ClientTestDouble.Client(new RecordingDispatcher().Dispatcher, CancellationToken.None, (nameof(ICheatEngineClient.Inspection), inspection), (nameof(ICheatEngineClient.Assembly), assembly)); - Dispatch = ModuleSymbolTarget.CreateDispatch(client); + StateTestHarness lua = new(); + lua.Answer(_ => new CodeLuaDisassemblyColumns(HexFormat.Address(current), "nop", string.Empty)); + IOptions execution = Options.Create(new McpExecutionOptions()); + Dispatch = new ToolDispatch(client, new McpFeatureGate(Options.Create(new McpFeatureOptions())), execution, + new DispatchStatistics(execution), TimeProvider.System, new RecordingLogger(), lua.FixedLua); Jobs = LiveResourceTests.Jobs(Dispatch, new TargetResources()); } diff --git a/tests/CheatEngine.Mcp.Tests/Support/StateTestHarness.cs b/tests/CheatEngine.Mcp.Tests/Support/StateTestHarness.cs index 77c0efe..cbe89d2 100644 --- a/tests/CheatEngine.Mcp.Tests/Support/StateTestHarness.cs +++ b/tests/CheatEngine.Mcp.Tests/Support/StateTestHarness.cs @@ -53,7 +53,7 @@ internal StateTestHarness(McpExecutionOptions? options = null, bool onMainThread Client = ClientTestDouble.Client(dispatcher, Stopping.Token, (nameof(ICheatEngineClient.Lua), lua)); IOptions execution = Options.Create(options ?? new McpExecutionOptions()); Dispatch = new ToolDispatch(Client, new McpFeatureGate(Options.Create(new McpFeatureOptions())), execution, - new DispatchStatistics(execution), Time, new RecordingLogger(), new FixedLuaExecutor(this)); + new DispatchStatistics(execution), Time, new RecordingLogger(), FixedLua); Ledger = withLedger ? new McpStateLedger(Dispatch, Time) : null; Resources = new TargetResources(Ledger, Time); Jobs = new JobRegistry(Dispatch, Resources, execution, Time); @@ -82,6 +82,8 @@ internal ToolDispatch Dispatch get; } + internal IFixedLuaExecutor FixedLua => new FixedLuaExecutor(this); + internal McpStateLedger? Ledger { get; diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Code/CodeGraphTarget.cs b/tests/CheatEngine.Mcp.Tests/Tools/Code/CodeGraphTarget.cs index d4eb8b9..0e9c666 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Code/CodeGraphTarget.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Code/CodeGraphTarget.cs @@ -8,6 +8,7 @@ using CheatEngine.Mcp.Core.Features; using CheatEngine.Mcp.Core.Values; using CheatEngine.Mcp.Tests.Support; +using CheatEngine.Mcp.Tools.Code; using CheatEngine.SDK.Engine.Inspection; using CheatEngine.SDK.Engine.Runtime; using CheatEngine.SDK.Engine.Values; @@ -34,8 +35,14 @@ internal CodeGraphTarget() (nameof(ICheatEngineClient.Processes), ClientTestDouble.Create(Process)), (nameof(ICheatEngineClient.Assembly), ClientTestDouble.Create(Disassemble))); IOptions execution = Options.Create(new McpExecutionOptions()); + StateTestHarness lua = new(); + lua.Answer(_ => + { + ulong address = Decoded[^1]; + return new CodeLuaDisassemblyColumns(HexFormat.Address(address), _code[address].Opcode, string.Empty); + }); Dispatch = new ToolDispatch(Client, new McpFeatureGate(Options.Create(new McpFeatureOptions())), execution, - new DispatchStatistics(execution), TimeProvider.System, new RecordingLogger()); + new DispatchStatistics(execution), TimeProvider.System, new RecordingLogger(), lua.FixedLua); } internal ICheatEngineClient Client diff --git a/tests/CheatEngine.Mcp.Tests/Tools/CodeAsmTableV2Tests.cs b/tests/CheatEngine.Mcp.Tests/Tools/CodeAsmTableV2Tests.cs index 73dd69d..c2ed564 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/CodeAsmTableV2Tests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/CodeAsmTableV2Tests.cs @@ -36,6 +36,23 @@ public void Dispose() _scratch.Dispose(); } + [Fact] + public void DisassemblyColumns_ClientColumnsMisordered_RetainsTypedBytesAndLength() + { + StateTestHarness harness = new(); + harness.Answer(_ => new CodeLuaDisassemblyColumns("game.exe+1000", "nop", "annotation")); + AssemblyInstructionSnapshot typed = new(new Address(0x401000), 1, "annotation", "90", "nop", [0x90]); + + AssemblyInstructionSnapshot corrected = harness.Dispatch.Run("code_decode", + token => CodeTools.CorrectColumns(harness.Dispatch, typed, "code_decode", token), Token); + + Assert.Equal((typed.Address, typed.Length), (corrected.Address, corrected.Length)); + Assert.Equal(typed.Bytes, corrected.Bytes); + Assert.Equal(("game.exe+1000", "nop", "annotation"), + (corrected.AddressText, corrected.Opcode, corrected.Extra)); + Assert.Equal(1, harness.Dispatches); + } + [Fact] public void DisassembleBytes_UsesFixedBoundedLuaScript() { @@ -602,8 +619,10 @@ internal SearchHarness(int instructionLength = 1) (nameof(ICheatEngineClient.Inspection), inspection), (nameof(ICheatEngineClient.Processes), processes), (nameof(ICheatEngineClient.Assembly), assembly)); IOptions execution = Options.Create(new McpExecutionOptions()); + StateTestHarness lua = new(); + lua.Answer(_ => new CodeLuaDisassemblyColumns("401000", "nop", string.Empty)); Dispatch = new ToolDispatch(Client, new McpFeatureGate(Options.Create(new McpFeatureOptions())), execution, - new DispatchStatistics(execution), TimeProvider.System, new RecordingLogger()); + new DispatchStatistics(execution), TimeProvider.System, new RecordingLogger(), lua.FixedLua); Resources = new TargetResources(); Jobs = new JobRegistry(Dispatch, Resources, execution, TimeProvider.System); } From 5f5d7262d37746efa8b22190a49a1642d07f30c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=96=A4=20ShadowNine=20=F0=9F=92=9B?= Date: Wed, 30 Sep 2026 10:13:55 +0200 Subject: [PATCH 4/4] Fix CE stopped-state guards and stable disassembly --- README.md | 2 +- .../CheatEngine.Mcp.Tools/Code/CodeScripts.cs | 6 +- srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs | 3 +- .../Debugger/DebuggerLuaScripts.cs | 2 +- .../Speedhack/SpeedhackScripts.cs | 26 ++++++- .../McpLiveQualificationTests.cs | 4 ++ .../NativeLua/NativeLuaAsmDebuggerV2Tests.cs | 19 +++++ .../NativeLua/NativeLuaCodeTests.cs | 27 +++++++- .../NativeLua/NativeLuaSpeedhackToolsTests.cs | 69 +++++++++++++++++++ .../Tools/Speedhack/SpeedhackToolsTests.cs | 2 +- 10 files changed, 151 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d91c481..badf294 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Run the gateway and Cheat Engine under the same Windows user account. Download and extract `CheatEngine.Mcp-2.0.0-beta.2-win-x64.zip` from the [beta release](https://github.com/CheatEngineNet/CheatEngine.Mcp/releases/tag/v2.0.0-beta.2). The ZIP includes the complete plugin folder, gateway executable, installation instructions, and licenses. -Remove the old single-DLL plugin entry when upgrading from beta.1, then add `CheatEngine.Mcp.Plugin.dll` from the new folder as described below. +When upgrading from beta.1, disable the plugin, close Cheat Engine and the gateway, and remove the old single-DLL plugin entry. Replace the complete plugin folder and gateway executable with the matching files from this release; do not mix versions. Then add `CheatEngine.Mcp.Plugin.dll` from the new folder as described below. To build the same layout from source instead, run this from the repository root with .NET SDK **10.0.401**, PowerShell 7, and the Windows C++ build tools required by Native AOT: diff --git a/srcs/CheatEngine.Mcp.Tools/Code/CodeScripts.cs b/srcs/CheatEngine.Mcp.Tools/Code/CodeScripts.cs index 7a7a029..37d1a04 100644 --- a/srcs/CheatEngine.Mcp.Tools/Code/CodeScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/Code/CodeScripts.cs @@ -6,9 +6,13 @@ internal static class CodeScripts internal const string DisassemblyColumns = """ local text = disassemble(a[1]) local extra, opcode, bytes, addressText = splitDisassembledString(text) - if type(addressText) ~= 'string' or type(opcode) ~= 'string' or type(extra) ~= 'string' then + if type(addressText) ~= 'string' or type(opcode) ~= 'string' or type(extra) ~= 'string' or type(bytes) ~= 'string' then return mcp.err('host_refused', 'Cheat Engine returned invalid disassembly columns.', 'completed') end + if bytes:gsub('%s', ''):upper() ~= a[2] then + return mcp.err('host_refused', 'The instruction changed while its disassembly columns were read.', 'completed', + 'Retry the disassembly while the target code is stable.') + end return { addressText=addressText, opcode=opcode, extra=extra } """; diff --git a/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs b/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs index 85b3e1b..8fdfb99 100644 --- a/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs +++ b/srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs @@ -487,7 +487,8 @@ internal static AssemblyInstructionSnapshot CorrectColumns(ToolDispatch dispatch // Client 1.0 / SDK 2.0 follows the documented split order; CE 7.7 returns extra, opcode, bytes, address. CodeLuaDisassemblyColumns columns = dispatch.ExecuteLua(operation, CodeScripts.DisassemblyColumns, - CodeLuaJsonContext.Default.CodeLuaDisassemblyColumns, cancellationToken, instruction.Address.Value); + CodeLuaJsonContext.Default.CodeLuaDisassemblyColumns, cancellationToken, instruction.Address.Value, + Convert.ToHexString(instruction.Bytes.AsSpan())); return new AssemblyInstructionSnapshot(instruction.Address, instruction.Length, columns.AddressText, columns.Opcode, columns.Extra, instruction.Bytes.AsSpan()); } diff --git a/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerLuaScripts.cs b/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerLuaScripts.cs index 1293f9a..103c229 100644 --- a/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerLuaScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerLuaScripts.cs @@ -26,7 +26,7 @@ local function interfaceName(value) end return {stateValid = true, attached = attached, canBreak = read('debug_canBreak'), broken = attached and read('debug_getContext', false) or false, - reportedBroken = read('debug_isBroken'), stepping = read('debug_isStepping'), + reportedBroken = attached and read('debug_isBroken') or false, stepping = read('debug_isStepping'), activeInterface = active} end) if not ok then return {stateValid = false, attached = false, canBreak = false, broken = false, diff --git a/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackScripts.cs b/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackScripts.cs index 1ebadad..dddb877 100644 --- a/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackScripts.cs +++ b/srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackScripts.cs @@ -66,7 +66,18 @@ local function finite(value) return { speed = current, hooksInstalled = true, firstActivation = false } end end - if type(debug_isBroken) == 'function' and debug_isBroken() then + -- CE 7.7 can return an opaque value from debug_isBroken when no debugger is attached. + -- A stopped context is the authoritative check before executing code in the target. + local debugging = debug_isDebugging() + if type(debugging) ~= 'boolean' then + return mcp.err('host_refused', 'Cheat Engine did not return a boolean debugger state.', 'not_started') + end + local stopped = false + if debugging then stopped = debug_getContext(false) end + if type(stopped) ~= 'boolean' then + return mcp.err('host_refused', 'Cheat Engine did not return a boolean stopped-context state.', 'not_started') + end + if stopped then return mcp.err('invalid_state', 'The debugger is stopped at a breakpoint.', 'not_started', 'Continue the debugger before changing speed.') end @@ -91,7 +102,18 @@ local function finite(value) if math.type(pid) ~= 'integer' or pid <= 0 then return mcp.err('invalid_state', 'No process is attached.', 'not_started', 'Attach a local process with process_attach first.') end - if type(debug_isBroken) == 'function' and debug_isBroken() then + -- CE 7.7 can return an opaque value from debug_isBroken when no debugger is attached. + -- A stopped context is the authoritative check before executing code in the target. + local debugging = debug_isDebugging() + if type(debugging) ~= 'boolean' then + return mcp.err('host_refused', 'Cheat Engine did not return a boolean debugger state.', 'not_started') + end + local stopped = false + if debugging then stopped = debug_getContext(false) end + if type(stopped) ~= 'boolean' then + return mcp.err('host_refused', 'Cheat Engine did not return a boolean stopped-context state.', 'not_started') + end + if stopped then return mcp.err('invalid_state', 'The debugger is stopped at a breakpoint.', 'not_started', 'Continue the debugger before changing speed.') end if type(isPaused) == 'function' and isPaused() then diff --git a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs index 9268a3f..e7259f2 100644 --- a/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs +++ b/tests/CheatEngine.Mcp.Tests/LiveQualification/McpLiveQualificationTests.cs @@ -140,6 +140,10 @@ await SuccessfulCallAsync(instanceA, CheatEngineToolNames.RecordSetActive, Assert.Empty((await SuccessfulCallAsync(instanceB, CheatEngineToolNames.RecordList))["records"]!.AsArray()); step = "instance A speedhack"; + JsonNode debuggerState = await SuccessfulCallAsync(instanceA, CheatEngineToolNames.DebuggerGetStatus); + Assert.True(debuggerState["stateValid"]!.GetValue()); + Assert.False(debuggerState["attached"]!.GetValue()); + Assert.False(debuggerState["broken"]!.GetValue()); speedhackAttempted = true; await AssertSpeedAsync(instanceA, 0.5); Assert.Equal(1.0, diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaAsmDebuggerV2Tests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaAsmDebuggerV2Tests.cs index 564c583..13d9ea2 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaAsmDebuggerV2Tests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaAsmDebuggerV2Tests.cs @@ -262,6 +262,25 @@ public void DebuggerV2_StatusContextAndRegisterWrite_ExecuteWithTypedResults() Assert.Equal((4294967295L, 1L), (ReadGlobal("RAX"), ReadGlobal("guiUpdates"))); } + [Fact] + public void DebuggerV2_Status_UnattachedWithOpaqueBrokenResult_IsValidAndNotStopped() + { + using RuntimeScope scope = CreateScope(); + InstallStubs(""" + debug_isDebugging = function() return false end + debug_canBreak = function() return false end + debug_isBroken = function() error('An unattached debugger has no broken state') end + debug_isStepping = function() return false end + debug_getContext = function(_) error('An unattached debugger has no context') end + """); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + + DebuggerStatus status = dispatch.RunLua("debugger_get_status", DebuggerLuaScripts.Status, + DebuggerJsonContext.Default.DebuggerStatus, Token); + + Assert.Equal(new DebuggerStatus(true, false, false, false, false, false), status); + } + [Fact] public void DebuggerV2_Attach_EmitsContractInterfaceNames() { diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaCodeTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaCodeTests.cs index 7943560..0e513e7 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaCodeTests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaCodeTests.cs @@ -1,5 +1,6 @@ using System.Text; +using CheatEngine.Mcp.Core.Contract; using CheatEngine.Mcp.Core.Features; using CheatEngine.Mcp.Core.Jobs; using CheatEngine.Mcp.Tests.Support; @@ -50,7 +51,7 @@ public void CodeScript_WithRepresentativeArguments_CompilesAndNeverLoadsCode(str using RuntimeScope scope = CreateScope(); object?[] arguments = name switch { - nameof(CodeScripts.DisassemblyColumns) => [0x401000UL], + nameof(CodeScripts.DisassemblyColumns) => [0x401000UL, "90"], nameof(CodeScripts.DisassembleBytes) => ["488B05", "1000"], nameof(CodeScripts.GetFunction) => ["game.exe+10", 4096], nameof(CodeScripts.Dissect) => [0x140001000UL, 4096], @@ -81,11 +82,33 @@ public void CodeDisassemblyColumns_Ce77StackOrder_PreservesDisplayColumns() ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); CodeLuaDisassemblyColumns columns = dispatch.RunLua("code_decode", CodeScripts.DisassemblyColumns, - CodeLuaJsonContext.Default.CodeLuaDisassemblyColumns, CancellationToken.None, 0x401000UL); + CodeLuaJsonContext.Default.CodeLuaDisassemblyColumns, CancellationToken.None, 0x401000UL, "90"); Assert.Equal(new CodeLuaDisassemblyColumns("game.exe+1000", "nop", "annotation"), columns); } + [Theory] + [InlineData("91")] + [InlineData("90 90")] + [InlineData("")] + public void CodeDisassemblyColumns_InstructionBytesChanged_RefusesInconsistentColumns(string bytes) + { + using RuntimeScope scope = CreateScope(); + InstallStubs(""" + disassemble = function(_) return 'changed instruction' end + splitDisassembledString = function(_) return '', 'changed opcode', changedBytes, '401000' end + """ + "\nchangedBytes = " + System.Text.Json.JsonSerializer.Serialize(bytes)); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + + CheatEngineToolException exception = Assert.Throws(() => + dispatch.RunLua("code_decode", CodeScripts.DisassemblyColumns, + CodeLuaJsonContext.Default.CodeLuaDisassemblyColumns, CancellationToken.None, 0x401000UL, "90")); + + Assert.Equal((ToolErrorKind.HostRefused, ToolHostEffect.Completed), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Contains("instruction changed", exception.Error.Message, StringComparison.Ordinal); + } + [Fact] public void CodeFindReferences_Stubbed_SortsFromAddressesNumericallyAndUnsigned() { diff --git a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaSpeedhackToolsTests.cs b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaSpeedhackToolsTests.cs index 4e398af..f38783c 100644 --- a/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaSpeedhackToolsTests.cs +++ b/tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaSpeedhackToolsTests.cs @@ -33,6 +33,8 @@ public sealed partial class NativeLuaToolRuntimeTests getOpenedProcessID = function() return openedProcess end isPaused = function() return paused or broken end debug_isBroken = function() return broken end + debug_isDebugging = function() return broken end + debug_getContext = function(_) return broken end targetIsX86 = function() return true end speedhack_getSpeed = function() return configuredSpeed end speedhack_setSpeed = function(speed) @@ -192,6 +194,73 @@ public void SpeedhackV2_RestoreResource_RestoresOnlyTheSpedUpProcess(string afte Assert.Equal(complete ? null : ResourceReleaseKind.CleanupFailed, release.Failed?.Release.Kind); } + [Theory] + [InlineData(2)] + [InlineData(1)] + public void SpeedhackV2_UnattachedDebuggerReturnsOpaqueValue_ChangesSpeed(double speed) + { + using RuntimeScope scope = CreateScope(); + InstallStubs(JobHostStubs + "\n" + SpeedhackStubs + """ + + debug_isBroken = function() return debug_isBroken end + debug_getContext = function(_) error('An unattached debugger has no context') end + speedhackSymbol = 0x7FF6A0000000 + configuredSpeed = 2.5 + wantedSpeed = 2.5 + """); + ToolDispatch dispatch = CreateNativeDispatch(new McpFeatureOptions()); + TargetResources resources = new(new McpStateLedger(dispatch, TimeProvider.System)); + SpeedhackTools tools = new(dispatch, resources); + + SpeedhackSetResult result = tools.SetSpeed(speed, Token); + + Assert.Equal(speed, result.Speed); + Assert.Equal((1L, 0L), (ReadGlobal("setCalls"), ReadGlobal("hookAttempts"))); + Assert.Equal(speed, Convert.ToDouble(ReadGlobal("wantedSpeed"), System.Globalization.CultureInfo.InvariantCulture)); + } + + [Theory] + [InlineData(2)] + [InlineData(1)] + public void SpeedhackV2_StoppedContextWithOpaqueReportedState_RefusesBeforeChangingSpeed(double speed) + { + using RuntimeScope scope = CreateScope(); + InstallStubs(SpeedhackStubs + """ + + debug_isBroken = function() return debug_isBroken end + broken = true + speedhackSymbol = 0x7FF6A0000000 + configuredSpeed = 2.5 + wantedSpeed = 2.5 + """); + SpeedhackTools tools = new(CreateNativeDispatch(new McpFeatureOptions()), new TargetResources()); + + CheatEngineToolException exception = + Assert.Throws(() => tools.SetSpeed(speed, Token)); + + Assert.Equal((ToolErrorKind.InvalidState, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Contains("stopped at a breakpoint", exception.Error.Message, StringComparison.Ordinal); + Assert.Equal((0L, 0L), (ReadGlobal("setCalls"), ReadGlobal("hookAttempts"))); + } + + [Theory] + [InlineData("debug_isDebugging = function() return debug_isDebugging end")] + [InlineData("broken = true; debug_getContext = function(_) return debug_getContext end")] + public void SpeedhackV2_InvalidDebuggerState_RefusesBeforeChangingSpeed(string stubs) + { + using RuntimeScope scope = CreateScope(); + InstallStubs(SpeedhackStubs + "\n" + stubs); + SpeedhackTools tools = new(CreateNativeDispatch(new McpFeatureOptions()), new TargetResources()); + + CheatEngineToolException exception = + Assert.Throws(() => tools.SetSpeed(2, Token)); + + Assert.Equal((ToolErrorKind.HostRefused, ToolHostEffect.NotStarted), + (exception.Error.Kind, exception.Error.HostEffect)); + Assert.Equal((0L, 0L), (ReadGlobal("setCalls"), ReadGlobal("hookAttempts"))); + } + private static string LuaBoolean(bool value) { return value ? "true" : "false"; diff --git a/tests/CheatEngine.Mcp.Tests/Tools/Speedhack/SpeedhackToolsTests.cs b/tests/CheatEngine.Mcp.Tests/Tools/Speedhack/SpeedhackToolsTests.cs index e36822f..cc87520 100644 --- a/tests/CheatEngine.Mcp.Tests/Tools/Speedhack/SpeedhackToolsTests.cs +++ b/tests/CheatEngine.Mcp.Tests/Tools/Speedhack/SpeedhackToolsTests.cs @@ -99,7 +99,7 @@ public void SetNormal_ChecksTheSymbolAndBothSpeedsBeforeItCallsSpeedhackSetSpeed int normal = body.IndexOf("math.abs(current - 1) <= 0.000001", StringComparison.Ordinal); int unhooked = body.IndexOf("if address == nil then", StringComparison.Ordinal); int target = body.IndexOf("pcall(readFloat, address)", StringComparison.Ordinal); - int broken = body.IndexOf("debug_isBroken()", StringComparison.Ordinal); + int broken = body.IndexOf("debug_getContext(false)", StringComparison.Ordinal); int paused = body.IndexOf("isPaused()", StringComparison.Ordinal); int set = body.IndexOf("speedhack_setSpeed(1)", StringComparison.Ordinal);