Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<!-- Audit-source outages and low/moderate advisories are reported without blocking; high and critical fail. -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1900;NU1901;NU1902;NU1905</WarningsNotAsErrors>
<PlatformTarget>x64</PlatformTarget>
<Version>2.0.0-beta.1</Version>
<Version>2.0.0-beta.2</Version>
<Authors>CheatEngineNet</Authors>
<Company>CheatEngineNet</Company>
<RepositoryUrl>https://github.com/CheatEngineNet/CheatEngine.Mcp</RepositoryUrl>
Expand Down
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
5 changes: 3 additions & 2 deletions srcs/CheatEngine.Mcp.Tools/Code/CodeGraphTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,15 @@ internal static bool Is64Bit(ProcessSnapshot process)
/// <param name="entry">The graph's entry, whose decode failure fails the call, or <see langword="null" />.</param>
/// <param name="cancellationToken">The token of the enclosing dispatch body.</param>
/// <returns>The instruction, or <see langword="null" /> when that address alone cannot be decoded.</returns>
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);
}

Expand Down
1 change: 1 addition & 0 deletions srcs/CheatEngine.Mcp.Tools/Code/CodeJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public sealed partial class CodeJsonContext : JsonSerializerContext;
/// <summary>Source-generated metadata for the fixed Lua results that remain internal to the code tools.</summary>
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(CodeLuaByteDisassembly))]
[JsonSerializable(typeof(CodeLuaDisassemblyColumns))]
[JsonSerializable(typeof(CodeLuaFunction))]
[JsonSerializable(typeof(CodeLuaReferencePage))]
[JsonSerializable(typeof(CodeLuaStringPage))]
Expand Down
2 changes: 2 additions & 0 deletions srcs/CheatEngine.Mcp.Tools/Code/CodeLuaRecords.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions srcs/CheatEngine.Mcp.Tools/Code/CodeScripts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ namespace CheatEngine.Mcp.Tools.Code;
/// <summary>Fixed, bounded Lua bodies for Cheat Engine code-view APIs absent from the typed Client.</summary>
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
Expand Down
31 changes: 28 additions & 3 deletions srcs/CheatEngine.Mcp.Tools/Code/CodeTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down Expand Up @@ -454,7 +456,8 @@ private async Task RunSearchAsync(JobWriter<CodeJobEvent> 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))
Expand All @@ -468,6 +471,28 @@ private async Task RunSearchAsync(JobWriter<CodeJobEvent> writer, Address target
}
}

/// <summary>Copies CE's actual display columns while retaining Client-owned instruction bytes and length.</summary>
/// <param name="dispatch">The enclosing Client dispatch.</param>
/// <param name="instruction">The typed instruction snapshot.</param>
/// <param name="operation">The tool name.</param>
/// <param name="cancellationToken">The enclosing dispatch token.</param>
/// <returns>The snapshot with corrected display columns.</returns>
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());
}

/// <summary>Copies one decoded instruction into the contract form.</summary>
/// <param name="instruction">Cheat Engine's copied instruction.</param>
/// <param name="operation">The tool name, for a malformed host instruction.</param>
Expand Down
2 changes: 1 addition & 1 deletion srcs/CheatEngine.Mcp.Tools/Debugger/DebuggerLuaScripts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 24 additions & 2 deletions srcs/CheatEngine.Mcp.Tools/Speedhack/SpeedhackScripts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>());
Assert.False(debuggerState["attached"]!.GetValue<bool>());
Assert.False(debuggerState["broken"]!.GetValue<bool>());
speedhackAttempted = true;
await AssertSpeedAsync(instanceA, 0.5);
Assert.Equal(1.0,
Expand Down Expand Up @@ -339,7 +343,20 @@ await SuccessfulCallAsync(instanceA, CheatEngineToolNames.ScanFirst,
Assert.Equal(expected["count"]!.GetValue<long>(), scanner["count"]!.GetValue<long>());
JsonNode status = await SuccessfulCallAsync(instanceA, CheatEngineToolNames.ScanGetStatus,
new Dictionary<string, object?> { ["scannerName"] = name });
Assert.True(JsonNode.DeepEquals(scanner, status));
if (name == "main")
{
Assert.IsType<JsonObject>(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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
44 changes: 44 additions & 0 deletions tests/CheatEngine.Mcp.Tests/NativeLua/NativeLuaCodeTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,6 +32,7 @@ public sealed partial class NativeLuaToolRuntimeTests

public static TheoryData<string, string> CodeScriptBodies => new()
{
{ nameof(CodeScripts.DisassemblyColumns), CodeScripts.DisassemblyColumns },
{ nameof(CodeScripts.DisassembleBytes), CodeScripts.DisassembleBytes },
{ nameof(CodeScripts.GetFunction), CodeScripts.GetFunction },
{ nameof(CodeScripts.Dissect), CodeScripts.Dissect },
Expand All @@ -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],
Expand All @@ -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<CheatEngineToolException>(() =>
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()
{
Expand Down
Loading
Loading