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..badf294 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.
+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:
```powershell
pwsh -NoProfile -File eng/Publish.ps1 -Configuration Release
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..37d1a04 100644
--- a/srcs/CheatEngine.Mcp.Tools/Code/CodeScripts.cs
+++ b/srcs/CheatEngine.Mcp.Tools/Code/CodeScripts.cs
@@ -3,6 +3,19 @@ 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' 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 }
+ """;
+
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..8fdfb99 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,28 @@ 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,
+ Convert.ToHexString(instruction.Bytes.AsSpan()));
+ 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/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 0cd9c21..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,
@@ -339,7 +343,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(
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 50141b2..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;
@@ -31,6 +32,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 +51,7 @@ public void CodeScript_WithRepresentativeArguments_CompilesAndNeverLoadsCode(str
using RuntimeScope scope = CreateScope();
object?[] arguments = name switch
{
+ nameof(CodeScripts.DisassemblyColumns) => [0x401000UL, "90"],
nameof(CodeScripts.DisassembleBytes) => ["488B05", "1000"],
nameof(CodeScripts.GetFunction) => ["game.exe+10", 4096],
nameof(CodeScripts.Dissect) => [0x140001000UL, 4096],
@@ -65,6 +68,47 @@ 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, "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/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);
}
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);