diff --git a/.vscode/launch.json b/.vscode/launch.json index f34fa11a5..7e9bbff30 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -50,12 +50,31 @@ // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console "console": "externalTerminal", "stopAtEntry": false - }, + }, + { + "name": "MCP server console app - .NET Core Launch", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build mcp server", + // If you have changed target frameworks, make sure to update the program path. + "program": "${workspaceFolder}/src/utils/Highbyte.DotNet6502.Util.MCPServer/bin/Debug/net9.0/Highbyte.DotNet6502.Util.MCPServer.dll", + "args": [], + "cwd": "${workspaceFolder}/src/utils/Highbyte.DotNet6502.Util.MCPServer/bin/Debug/net9.0/", + // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console + "console": "externalTerminal", + "stopAtEntry": false + }, { "name": ".NET Core Attach", "type": "coreclr", "request": "attach", "processId": "${command:pickProcess}" + }, + { + "name": ".NET Core Attach MCPServer (Windows)", + "type": "coreclr", + "request": "attach", + "processName": "Highbyte.DotNet6502.Util.MCPServer.exe" } ] } \ No newline at end of file diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..41ca54bfd --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,50 @@ +{ + "inputs": [], + "servers": { + // "DotNet6502-Embedded-Windows": { + // "type": "stdio", + // "command": "dotnet", + // "args": [ + // "run", + // "--project", + // "C:\\Users\\highb\\source\\repos\\dotnet-6502\\src\\utils\\Highbyte.DotNet6502.Util.MCPServer\\Highbyte.DotNet6502.Util.MCPServer.csproj" + // ], + // "cwd": "C:\\Users\\highb\\source\\repos\\dotnet-6502\\src\\utils\\Highbyte.DotNet6502.Util.MCPServer" + // } + + // "DotNet6502-Embedded-Mac": { + // "type": "stdio", + // "command": "dotnet", + // "args": [ + // "run", + // "--project", + // "/Users/highbyte/source/repos/dotnet-6502/src/utils/Highbyte.DotNet6502.Util.MCPServer/Highbyte.DotNet6502.Util.MCPServer.csproj" + // ], + // "cwd": "/Users/highbyte/source/repos/dotnet-6502/src/utils/Highbyte.DotNet6502.Util.MCPServer" + // } + + "DotNet6502-SadConsole-Windows": { + "type": "stdio", + "command": "C:\\Users\\highb\\source\\repos\\dotnet-6502\\src\\apps\\Highbyte.DotNet6502.App.SadConsole\\bin\\Debug\\net9.0\\Highbyte.DotNet6502.App.SadConsole.exe", + "cwd": "C:\\Users\\highb\\source\\repos\\dotnet-6502\\src\\apps\\Highbyte.DotNet6502.App.SadConsole\\bin\\Debug\\net9.0" + } + + // "DotNet6502-SadConsole-Mac": { + // "type": "stdio", + // "command": "/Users/highbyte/source/repos/dotnet-6502/src/apps/Highbyte.DotNet6502.App.SadConsole/bin/Debug/net9.0/Highbyte.DotNet6502.App.SadConsole", + // "cwd": "/Users/highbyte/source/repos/dotnet-6502/src/apps/Highbyte.DotNet6502.App.SadConsole/bin/Debug/net9.0" + // } + + //"DotNet6502-SilkNet-Windows": { + // "type": "stdio", + // "command": "C:\\Users\\highb\\source\\repos\\dotnet-6502\\src\\apps\\Highbyte.DotNet6502.App.SilkNetNative\\bin\\Debug\\net9.0\\Highbyte.DotNet6502.App.SilkNetNative.exe", + // "cwd": "C:\\Users\\highb\\source\\repos\\dotnet-6502\\src\\apps\\Highbyte.DotNet6502.App.SilkNetNative\\bin\\Debug\\net9.0" + //} + + // "DotNet6502-SilkNet-Mac": { + // "type": "stdio", + // "command": "/Users/highbyte/source/repos/dotnet-6502/src/apps/Highbyte.DotNet6502.App.SilkNetNative/bin/Debug/net9.0/Highbyte.DotNet6502.App.SilkNetNative", + // "cwd": "/Users/highbyte/source/repos/dotnet-6502/src/apps/Highbyte.DotNet6502.App.SilkNetNative/bin/Debug/net9.0" + // } + } +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 357d7e327..7c3de8f57 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -116,6 +116,22 @@ "kind": "test", "isDefault": true } + }, + { + "label": "build mcp server", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/utils/Highbyte.DotNet6502.Util.MCPServer/Highbyte.DotNet6502.Util.MCPServer.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile", + "group": { + "kind": "build", + "isDefault": true + } } ] } \ No newline at end of file diff --git a/dotnet-6502.sln b/dotnet-6502.sln index d60ed7718..eddfdf377 100644 --- a/dotnet-6502.sln +++ b/dotnet-6502.sln @@ -47,6 +47,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Highbyte.DotNet6502.Systems EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Highbyte.DotNet6502.AI", "src\libraries\Highbyte.DotNet6502.AI\Highbyte.DotNet6502.AI.csproj", "{B04E0DA2-F0EB-4E71-BFF9-3D3B17E30688}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utils", "Utils", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Highbyte.DotNet6502.Util.MCPServer", "src\utils\Highbyte.DotNet6502.Util.MCPServer\Highbyte.DotNet6502.Util.MCPServer.csproj", "{1AB6BB50-42BF-44ED-8B5B-40266C9ED016}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -273,6 +277,18 @@ Global {B04E0DA2-F0EB-4E71-BFF9-3D3B17E30688}.Release|x64.Build.0 = Release|Any CPU {B04E0DA2-F0EB-4E71-BFF9-3D3B17E30688}.Release|x86.ActiveCfg = Release|Any CPU {B04E0DA2-F0EB-4E71-BFF9-3D3B17E30688}.Release|x86.Build.0 = Release|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Debug|x64.ActiveCfg = Debug|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Debug|x64.Build.0 = Debug|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Debug|x86.ActiveCfg = Debug|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Debug|x86.Build.0 = Debug|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Release|Any CPU.Build.0 = Release|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Release|x64.ActiveCfg = Release|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Release|x64.Build.0 = Release|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Release|x86.ActiveCfg = Release|Any CPU + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -296,6 +312,7 @@ Global {4E078F4C-9C1B-4C17-A2AE-6BF2345DC791} = {B406AD5D-CB8D-45F2-A5A2-4C0AD82A410A} {A52EFEC2-FD60-453E-AC55-703AD2F2A895} = {B406AD5D-CB8D-45F2-A5A2-4C0AD82A410A} {B04E0DA2-F0EB-4E71-BFF9-3D3B17E30688} = {B406AD5D-CB8D-45F2-A5A2-4C0AD82A410A} + {1AB6BB50-42BF-44ED-8B5B-40266C9ED016} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0F55B6C2-E4B4-4F2C-9D2A-D63A17F3B5C4} diff --git a/src/apps/Highbyte.DotNet6502.App.SadConsole/EmulatorConfig.cs b/src/apps/Highbyte.DotNet6502.App.SadConsole/EmulatorConfig.cs index f7c6c87a3..2c57de4b6 100644 --- a/src/apps/Highbyte.DotNet6502.App.SadConsole/EmulatorConfig.cs +++ b/src/apps/Highbyte.DotNet6502.App.SadConsole/EmulatorConfig.cs @@ -49,6 +49,8 @@ public class EmulatorConfig /// public GenericComputerHostConfig GenericComputerHostConfig { get; set; } + public bool MCPServerEnabled { get; set; } + public EmulatorConfig() { UIFont = null; diff --git a/src/apps/Highbyte.DotNet6502.App.SadConsole/Highbyte.DotNet6502.App.SadConsole.csproj b/src/apps/Highbyte.DotNet6502.App.SadConsole/Highbyte.DotNet6502.App.SadConsole.csproj index fc43ba5f6..557e2fe08 100644 --- a/src/apps/Highbyte.DotNet6502.App.SadConsole/Highbyte.DotNet6502.App.SadConsole.csproj +++ b/src/apps/Highbyte.DotNet6502.App.SadConsole/Highbyte.DotNet6502.App.SadConsole.csproj @@ -29,12 +29,14 @@ + - - - + + + + diff --git a/src/apps/Highbyte.DotNet6502.App.SadConsole/MCP/C64SadConsoleTools.cs b/src/apps/Highbyte.DotNet6502.App.SadConsole/MCP/C64SadConsoleTools.cs new file mode 100644 index 000000000..3abe840bf --- /dev/null +++ b/src/apps/Highbyte.DotNet6502.App.SadConsole/MCP/C64SadConsoleTools.cs @@ -0,0 +1,33 @@ +using System.ComponentModel; +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Util.MCPServer; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Highbyte.DotNet6502.App.SadConsole.MCP; + +[McpServerToolType] +public static class C64SadConsoleTools +{ + [McpServerTool, Description("Get C64 emulator log messages")] + public static async Task GetLogMessages(IHostApp hostApp, int numberOfLogMessages) + { + try + { + List logs = null!; + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunningOrPaused(hostApp); + var logStore = ((SadConsoleHostApp)hostApp).LogStore; + logs = logStore.GetLogMessages().Take(numberOfLogMessages).ToList(); + }); + + return C64ToolHelper.BuildCallToolDataResult(logs); + + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } +} diff --git a/src/apps/Highbyte.DotNet6502.App.SadConsole/Program.cs b/src/apps/Highbyte.DotNet6502.App.SadConsole/Program.cs index 53d47eb35..6590487bb 100644 --- a/src/apps/Highbyte.DotNet6502.App.SadConsole/Program.cs +++ b/src/apps/Highbyte.DotNet6502.App.SadConsole/Program.cs @@ -5,7 +5,10 @@ using Highbyte.DotNet6502.Systems; using Highbyte.DotNet6502.Systems.Logging.InMem; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Hosting; +using Highbyte.DotNet6502.Util.MCPServer; // ---------- // Get config file @@ -22,7 +25,6 @@ builder.AddUserSecrets(); } - IConfiguration Configuration = builder.Build(); // ---------- @@ -30,9 +32,9 @@ // ---------- DotNet6502InMemLogStore logStore = new() { WriteDebugMessage = true }; var logConfig = new DotNet6502InMemLoggerConfiguration(logStore); +logConfig.LogLevel = LogLevel.Information; // LogLevel.Debug, LogLevel.Information var loggerFactory = LoggerFactory.Create(builder => { - logConfig.LogLevel = LogLevel.Information; // LogLevel.Debug, LogLevel.Information, builder.AddInMem(logConfig); builder.SetMinimumLevel(LogLevel.Trace); }); @@ -54,10 +56,28 @@ var genericComputerSetup = new GenericComputerSetup(loggerFactory, Configuration); systemList.AddSystem(genericComputerSetup); + // ---------- -// Start SadConsoleHostApp +// Init SadConsoleHostApp // ---------- emulatorConfig.Validate(systemList); +var sadConsoleHostApp = new SadConsoleHostApp(systemList, loggerFactory, emulatorConfig, logStore, logConfig, Configuration); -var silkNetHostApp = new SadConsoleHostApp(systemList, loggerFactory, emulatorConfig, logStore, logConfig, Configuration); -silkNetHostApp.Run(); +// ---------- +// Start MCP server as a background host if enabled +// ---------- +if (emulatorConfig.MCPServerEnabled) +{ + Task.Run(async () => + { + var mcpBuilder = Host.CreateApplicationBuilder(); + mcpBuilder.ConfigureDotNet6502McpServerTools(sadConsoleHostApp, + additionalToolsAssembly: typeof(Highbyte.DotNet6502.App.SadConsole.MCP.C64SadConsoleTools).Assembly); + await mcpBuilder.Build().RunAsync(); + }); +} + +// ---------- +// Start SadConsoleHostApp +// ---------- +sadConsoleHostApp.Run(); diff --git a/src/apps/Highbyte.DotNet6502.App.SadConsole/SadConsoleHostApp.cs b/src/apps/Highbyte.DotNet6502.App.SadConsole/SadConsoleHostApp.cs index 5d96f078c..2124feee3 100644 --- a/src/apps/Highbyte.DotNet6502.App.SadConsole/SadConsoleHostApp.cs +++ b/src/apps/Highbyte.DotNet6502.App.SadConsole/SadConsoleHostApp.cs @@ -28,6 +28,7 @@ public class SadConsoleHostApp : HostApp _emulatorConfig; private readonly DotNet6502InMemLogStore _logStore; + public DotNet6502InMemLogStore LogStore => _logStore; private readonly DotNet6502InMemLoggerConfiguration _logConfig; private readonly IConfiguration _configuration; private readonly ILoggerFactory _loggerFactory; @@ -75,7 +76,6 @@ protected virtual void OnMonitorStateChange(bool monitorEnabled) private int _logsFrameCount = 0; private DrawImage _logoDrawImage; - /// /// Constructor /// @@ -610,4 +610,22 @@ private async Task HandleUIKeyboardInput() } } } + + public override void ExternalControlRefreshUI() + { + // Refresh UI controls + if (_menuConsole != null) + _menuConsole.IsDirty = true; + if (_systemMenuConsole != null) + _systemMenuConsole.IsDirty = true; + if (_sadConsoleEmulatorConsole != null) + _sadConsoleEmulatorConsole.IsDirty = true; + + //if (_monitorConsole != null) + // _monitorConsole.IsDirty = true; + //if (_monitorStatusConsole != null) + // _monitorStatusConsole.IsDirty = true; + //if (_infoConsole != null) + // _infoConsole.IsDirty = true; + } } diff --git a/src/apps/Highbyte.DotNet6502.App.SadConsole/appsettings.json b/src/apps/Highbyte.DotNet6502.App.SadConsole/appsettings.json index ef2b47fad..e2a743223 100644 --- a/src/apps/Highbyte.DotNet6502.App.SadConsole/appsettings.json +++ b/src/apps/Highbyte.DotNet6502.App.SadConsole/appsettings.json @@ -4,7 +4,9 @@ "UIFont": null, // UI Console font. Leave blank for default SadConsole font. "UIFontSize": "One", // UI consoles font (not Emulator console). Possible values: "Quarter", "Half, "One", "Two", "Three", "Four", "Five" - "DefaultAudioVolumePercent": 20 + "DefaultAudioVolumePercent": 20, + + "MCPServerEnabled": true }, "Highbyte.DotNet6502.C64.SadConsole": { diff --git a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/EmulatorConfig.cs b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/EmulatorConfig.cs index a28f25362..c2692111e 100644 --- a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/EmulatorConfig.cs +++ b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/EmulatorConfig.cs @@ -13,6 +13,7 @@ public class EmulatorConfig public float DefaultDrawScale { get; set; } public float CurrentDrawScale { get; set; } public MonitorConfig Monitor { get; set; } + public bool MCPServerEnabled { get; set; } public EmulatorConfig() { diff --git a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Highbyte.DotNet6502.App.SilkNetNative.csproj b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Highbyte.DotNet6502.App.SilkNetNative.csproj index b2ff293e4..411ae28e2 100644 --- a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Highbyte.DotNet6502.App.SilkNetNative.csproj +++ b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Highbyte.DotNet6502.App.SilkNetNative.csproj @@ -24,11 +24,12 @@ + - - + + diff --git a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/MCP/C64SadConsoleTools.cs b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/MCP/C64SadConsoleTools.cs new file mode 100644 index 000000000..c37ec797d --- /dev/null +++ b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/MCP/C64SadConsoleTools.cs @@ -0,0 +1,33 @@ +using System.ComponentModel; +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Util.MCPServer; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Highbyte.DotNet6502.App.SilkNetNative.MCP; + +[McpServerToolType] +public static class C64SilkNetNativeTools +{ + [McpServerTool, Description("Get C64 emulator log messages")] + public static async Task GetLogMessages(IHostApp hostApp, int numberOfLogMessages) + { + try + { + List logs = null!; + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunningOrPaused(hostApp); + var logStore = ((SilkNetHostApp)hostApp).LogStore; + logs = logStore.GetLogMessages().Take(numberOfLogMessages).ToList(); + }); + + return C64ToolHelper.BuildCallToolDataResult(logs); + + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } +} diff --git a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Program.cs b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Program.cs index 7741930d1..dfe1824c3 100644 --- a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Program.cs +++ b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Program.cs @@ -4,7 +4,9 @@ using Highbyte.DotNet6502.Impl.SilkNet; using Highbyte.DotNet6502.Systems; using Highbyte.DotNet6502.Systems.Logging.InMem; +using Highbyte.DotNet6502.Util.MCPServer; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; // Fix for starting in debug mode from VS Code. By default the OS current directory is set to the project folder, not the folder containing the built .exe file... @@ -72,6 +74,27 @@ var window = Window.Create(windowOptions); +// ---------- +// Init SilkNetHostApp +// ---------- var silkNetHostApp = new SilkNetHostApp(systemList, loggerFactory, emulatorConfig, window, logStore, logConfig); silkNetHostApp.SelectSystem(emulatorConfig.DefaultEmulator).Wait(); + +// ---------- +// Start MCP server as a background host if enabled +// ---------- +if (emulatorConfig.MCPServerEnabled) +{ + Task.Run(async () => + { + var mcpBuilder = Host.CreateApplicationBuilder(); + mcpBuilder.ConfigureDotNet6502McpServerTools(silkNetHostApp, + typeof(Highbyte.DotNet6502.App.SilkNetNative.MCP.C64SilkNetNativeTools).Assembly); + await mcpBuilder.Build().RunAsync(); + }); +} + +// ---------- +// Start SilkNetHostApp +// ---------- silkNetHostApp.Run(); diff --git a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/SilkNetHostApp.cs b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/SilkNetHostApp.cs index 057909f9b..73d1b926a 100644 --- a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/SilkNetHostApp.cs +++ b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/SilkNetHostApp.cs @@ -22,6 +22,7 @@ public class SilkNetHostApp : HostApp _emulatorConfig; private readonly DotNet6502InMemLogStore _logStore; + public DotNet6502InMemLogStore LogStore => _logStore; private readonly DotNet6502InMemLoggerConfiguration _logConfig; private readonly bool _defaultAudioEnabled; private float _defaultAudioVolumePercent; diff --git a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/appsettings.json b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/appsettings.json index 3205d8e89..b9fd45127 100644 --- a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/appsettings.json +++ b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/appsettings.json @@ -6,7 +6,9 @@ "Monitor": { "MaxLineLength": 100 //"DefaultDirectory": "../../../../../../samples/Assembler/C64/Build" - } + }, + + "MCPServerEnabled": true }, "Highbyte.DotNet6502.C64.SilkNetNative": { diff --git a/src/apps/Highbyte.DotNet6502.App.WASM/Highbyte.DotNet6502.App.WASM.csproj b/src/apps/Highbyte.DotNet6502.App.WASM/Highbyte.DotNet6502.App.WASM.csproj index fd7f29277..99a4af9e2 100644 --- a/src/apps/Highbyte.DotNet6502.App.WASM/Highbyte.DotNet6502.App.WASM.csproj +++ b/src/apps/Highbyte.DotNet6502.App.WASM/Highbyte.DotNet6502.App.WASM.csproj @@ -32,12 +32,12 @@ - - - + + + - + diff --git a/src/libraries/Highbyte.DotNet6502.AI/Highbyte.DotNet6502.AI.csproj b/src/libraries/Highbyte.DotNet6502.AI/Highbyte.DotNet6502.AI.csproj index 8c9954edd..07a5f7e03 100644 --- a/src/libraries/Highbyte.DotNet6502.AI/Highbyte.DotNet6502.AI.csproj +++ b/src/libraries/Highbyte.DotNet6502.AI/Highbyte.DotNet6502.AI.csproj @@ -26,7 +26,7 @@ - + diff --git a/src/libraries/Highbyte.DotNet6502.Impl.AspNet/Highbyte.DotNet6502.Impl.AspNet.csproj b/src/libraries/Highbyte.DotNet6502.Impl.AspNet/Highbyte.DotNet6502.Impl.AspNet.csproj index 8961be646..244468053 100644 --- a/src/libraries/Highbyte.DotNet6502.Impl.AspNet/Highbyte.DotNet6502.Impl.AspNet.csproj +++ b/src/libraries/Highbyte.DotNet6502.Impl.AspNet/Highbyte.DotNet6502.Impl.AspNet.csproj @@ -29,7 +29,7 @@ - + diff --git a/src/libraries/Highbyte.DotNet6502.Systems/HostApp.cs b/src/libraries/Highbyte.DotNet6502.Systems/HostApp.cs index ec6562ea4..6b0246da9 100644 --- a/src/libraries/Highbyte.DotNet6502.Systems/HostApp.cs +++ b/src/libraries/Highbyte.DotNet6502.Systems/HostApp.cs @@ -1,6 +1,7 @@ -using Microsoft.Extensions.Logging; +using System.Collections.Concurrent; using Highbyte.DotNet6502.Systems.Instrumentation; using Highbyte.DotNet6502.Systems.Instrumentation.Stats; +using Microsoft.Extensions.Logging; namespace Highbyte.DotNet6502.Systems; @@ -15,7 +16,7 @@ public enum EmulatorState { Uninitialized, Running, Paused } /// /// /// -public class HostApp +public class HostApp : IHostApp where TRenderContext : IRenderContext where TInputHandlerContext : IInputHandlerContext where TAudioHandlerContext : IAudioHandlerContext @@ -52,13 +53,13 @@ public IHostSystemConfig CurrentHostSystemConfig throw new DotNet6502Exception("Internal error. No system selected yet. Call SelectSystem() first."); return _currentHostSystemConfig; } - private set + set { _currentHostSystemConfig = value; } } - protected List GetHostSystemConfigs() + public List GetHostSystemConfigs() { var list = new List(); foreach (var system in AvailableSystemNames) @@ -77,12 +78,18 @@ protected List GetHostSystemConfigs() private ElapsedMillisecondsTimedStatSystem? _systemTime; private ElapsedMillisecondsTimedStatSystem? _renderTime; private ElapsedMillisecondsTimedStatSystem? _inputTime; + //private ElapsedMillisecondsTimedStatSystem _audioTime; private readonly Instrumentations _instrumentations = new(); private readonly PerSecondTimedStat _updateFps; private readonly PerSecondTimedStat _renderFps; + private bool _externalControlEnabled = false; + public bool ExternalControlEnabled => _externalControlEnabled; + private Func<(bool shouldRun, bool shouldReceiveInput)>? _externalOnBeforeRunEmulatorOneFrame; + private Action? _externalOnAfterRunEmulatorOneFrame; + public HostApp( string hostName, SystemList systemList, @@ -260,15 +267,29 @@ public virtual void OnBeforeRunEmulatorOneFrame(out bool shouldRun, out bool sho shouldRun = true; shouldReceiveInput = true; } - public void RunEmulatorOneFrame() + public ExecEvaluatorTriggerResult RunEmulatorOneFrame() { + // Process any UI actions that have been queued up from other threads + ExternalControlProcessUIActions(); + // Safety check to avoid running emulator if it's not in a running state. if (EmulatorState != EmulatorState.Running) - return; + return ExecEvaluatorTriggerResult.NotTriggered; + + bool shouldRun = false; + bool shouldReceiveInput = false; + if (_externalControlEnabled) + { + if (_externalOnBeforeRunEmulatorOneFrame != null) + (shouldRun, shouldReceiveInput) = _externalOnBeforeRunEmulatorOneFrame(); + } + else + { + OnBeforeRunEmulatorOneFrame(out shouldRun, out shouldReceiveInput); + } - OnBeforeRunEmulatorOneFrame(out bool shouldRun, out bool shouldReceiveInput); if (!shouldRun) - return; + return ExecEvaluatorTriggerResult.NotTriggered; _updateFps.Update(); @@ -281,10 +302,20 @@ public void RunEmulatorOneFrame() _systemTime!.Start(); var execEvaluatorTriggerResult = _systemRunner!.RunEmulatorOneFrame(); - OnAfterRunEmulatorOneFrame(execEvaluatorTriggerResult); + + if (_externalControlEnabled) + { + _externalOnAfterRunEmulatorOneFrame?.Invoke(execEvaluatorTriggerResult); + } + else + { + OnAfterRunEmulatorOneFrame(execEvaluatorTriggerResult); + } _systemTime!.Stop(); + return execEvaluatorTriggerResult; } + public virtual void OnAfterRunEmulatorOneFrame(ExecEvaluatorTriggerResult execEvaluatorTriggerResult) { } public virtual void OnBeforeDrawFrame(bool emulatorWillBeRendered) { } @@ -374,4 +405,80 @@ private void InitInstrumentation(ISystem system) .Union(_systemRunner.InputHandler.Instrumentations.Stats.Select(x => (Name: $"{_hostName}-{InputTimeStatName}-{x.Name}", x.Stat))) .ToList(); } + + + private readonly ConcurrentQueue _externalControlUIActions = new(); + public virtual bool ExternalControlDirectInvoke { get; } = false; + public Task ExternalControlInvokeOnUIThread(Func action) + { + var tcs = new TaskCompletionSource(); + + // If HostApp is running on same thread as the external control code, execute the action directly. + if (ExternalControlDirectInvoke) + { + Task.Run(async () => + { + try + { + await action(); + tcs.SetResult(null); + //return Task.CompletedTask; + } + catch (Exception ex) + { + tcs.SetException(ex); + //return Task.FromException(ex); + } + }); + return tcs.Task; + } + + // Otherwise, enqueue the action to be executed on the UI thread (see ExternalControlProcessUIActions); + _externalControlUIActions.Enqueue(async () => + { + try + { + await action(); + tcs.SetResult(null); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + return tcs.Task; + } + + public void ExternalControlProcessUIActions() + { + while (_externalControlUIActions.TryDequeue(out var action)) + { + action(); + } + ExternalControlRefreshUI(); + } + + public virtual void ExternalControlRefreshUI() + { + // This method can be overridden by derived classes to refresh the UI. + // It is called after processing UI actions to ensure the UI is up-to-date. + } + + public void EnableExternalControl( + Func<(bool shouldRun, bool shouldReceiveInput)>? externalOnBeforeRunEmulatorOneFrame = null, + Action? externalOnAfterRunEmulatorOneFrame = null) + { + _externalControlEnabled = true; + _externalOnBeforeRunEmulatorOneFrame = externalOnBeforeRunEmulatorOneFrame; + _externalOnAfterRunEmulatorOneFrame = externalOnAfterRunEmulatorOneFrame; + _logger.LogInformation("External control enabled."); + } + + public void DisableExternalControl() + { + _externalControlEnabled = false; + _externalOnBeforeRunEmulatorOneFrame = null; + _externalOnAfterRunEmulatorOneFrame = null; + _logger.LogInformation("External control disabled."); + } } diff --git a/src/libraries/Highbyte.DotNet6502.Systems/IHostApp.cs b/src/libraries/Highbyte.DotNet6502.Systems/IHostApp.cs new file mode 100644 index 000000000..b7ccc3362 --- /dev/null +++ b/src/libraries/Highbyte.DotNet6502.Systems/IHostApp.cs @@ -0,0 +1,53 @@ +using Highbyte.DotNet6502.Systems.Instrumentation.Stats; + +namespace Highbyte.DotNet6502.Systems; + +public interface IHostApp +{ + public string SelectedSystemName { get; } + + public SystemRunner? CurrentSystemRunner { get; } + public ISystem? CurrentRunningSystem { get; } + + public EmulatorState EmulatorState { get; } + + public IHostSystemConfig CurrentHostSystemConfig { get; } + public List GetHostSystemConfigs(); + + public Task SelectSystem(string systemName); + public Task SelectSystemConfigurationVariant(string configurationVariant); + + public Task Start(); + public void Pause(); + public void Stop(); + public Task Reset(); + + public void Close(); + public ExecEvaluatorTriggerResult RunEmulatorOneFrame(); + public void DrawFrame(); + + public Task IsSystemConfigValid(); + public Task<(bool, List validationErrors)> IsValidConfigWithDetails(); + public Task IsAudioSupported(); + public Task IsAudioEnabled(); + public Task SetAudioEnabled(bool enabled); + + public Task GetSelectedSystem(); + + public void UpdateHostSystemConfig(IHostSystemConfig newConfig); + public Task PersistCurrentHostSystemConfig(); + + public List<(string name, IStat stat)> GetStats(); + + /// + /// Set to true if the host app doesn't have UI thread. Invoking external control actions will then be done on current thread. + /// + public bool ExternalControlDirectInvoke { get; } + public Task ExternalControlInvokeOnUIThread(Func action); + public void ExternalControlProcessUIActions(); + public bool ExternalControlEnabled { get; } + public void EnableExternalControl( + Func<(bool shouldRun, bool shouldReceiveInput)>? externalOnBeforeRunEmulatorOneFrame = null, + Action? externalOnAfterRunEmulatorOneFrame = null); + public void DisableExternalControl(); +} diff --git a/src/libraries/Highbyte.DotNet6502.Systems/SystemRunner.cs b/src/libraries/Highbyte.DotNet6502.Systems/SystemRunner.cs index 8219aaa6a..0324b6bf7 100644 --- a/src/libraries/Highbyte.DotNet6502.Systems/SystemRunner.cs +++ b/src/libraries/Highbyte.DotNet6502.Systems/SystemRunner.cs @@ -91,6 +91,12 @@ public ExecEvaluatorTriggerResult RunEmulatorOneFrame() return execEvaluatorTriggerResult; } + public ExecEvaluatorTriggerResult RunEmulatorOneInstruction() + { + var execEvaluatorTriggerResult = _system.ExecuteOneInstruction(this, out _, _customExecEvaluator); + return execEvaluatorTriggerResult; + } + /// /// Called by host app that runs the emulator, once per frame tied to the host app rendering frequency. /// diff --git a/src/libraries/Highbyte.DotNet6502/Highbyte.DotNet6502.csproj b/src/libraries/Highbyte.DotNet6502/Highbyte.DotNet6502.csproj index 9703f7dea..26f1a251a 100644 --- a/src/libraries/Highbyte.DotNet6502/Highbyte.DotNet6502.csproj +++ b/src/libraries/Highbyte.DotNet6502/Highbyte.DotNet6502.csproj @@ -24,10 +24,10 @@ - - - - + + + + diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64BreakpointTool.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64BreakpointTool.cs new file mode 100644 index 000000000..d026ff532 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64BreakpointTool.cs @@ -0,0 +1,79 @@ +using System.ComponentModel; +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Monitor; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Highbyte.DotNet6502.Util.MCPServer; + +[McpServerToolType] +public static class C64BreakpointTool +{ + [McpServerTool, Description("List all breakpoints in the C64 emulator.")] + public static async Task ListBreakpoints(IHostApp hostApp, BreakpointManager breakpointManager) + { + try + { + var breakpoints = breakpointManager.BreakPoints; + var result = new List(); + foreach (var bp in breakpoints) + { + result.Add(new { Address = bp.Key, Enabled = bp.Value.Enabled }); + } + return C64ToolHelper.BuildCallToolDataResult(result); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Add a breakpoint at the specified address.")] + public static async Task AddBreakpoint(IHostApp hostApp, BreakpointManager breakpointManager, ushort address) + { + try + { + var breakpoints = breakpointManager.BreakPoints; + if (!breakpoints.ContainsKey(address)) + breakpoints.Add(address, new BreakPoint { Enabled = true }); + else + breakpoints[address].Enabled = true; + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Remove a breakpoint at the specified address.")] + public static async Task RemoveBreakpoint(IHostApp hostApp, BreakpointManager breakpointManager, ushort address) + { + try + { + var breakpoints = breakpointManager.BreakPoints; + if (breakpoints.ContainsKey(address)) + breakpoints.Remove(address); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Remove all breakpoints.")] + public static async Task RemoveAllBreakpoints(IHostApp hostApp, BreakpointManager breakpointManager) + { + try + { + var breakpoints = breakpointManager.BreakPoints; + breakpoints.Clear(); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } +} \ No newline at end of file diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64MemoryTool.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64MemoryTool.cs new file mode 100644 index 000000000..fdc98b739 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64MemoryTool.cs @@ -0,0 +1,218 @@ +using System.ComponentModel; +using System.Text.Json.Nodes; +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Utils; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Highbyte.DotNet6502.Util.MCPServer; + +[McpServerToolType] +public static class C64MemoryTool +{ + [McpServerTool, Description("Returns value of specified memory address in C64 emulator")] + public static async Task ReadMemory(IHostApp hostApp, ushort address) + { + try + { + byte value = 0; + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var c64 = C64ToolHelper.GetC64(hostApp); + value = c64.Mem[address]; + }); + return C64ToolHelper.BuildCallToolDataResult(value); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool(UseStructuredContent = true, ReadOnly = true), Description("Returns a range of values from memory start address up to specified length in C64 emulator")] + public static async Task ReadMemoryRange(IHostApp hostApp, ushort startAddress, ushort length) + { + try + { + byte[] values = null!; + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + if (length <= 0) + throw new ArgumentException("Length must be greater than zero.", nameof(length)); + + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var c64 = C64ToolHelper.GetC64(hostApp); + if (startAddress + length > c64.Mem.Size) + length = (ushort)(c64.Mem.Size - startAddress); + values = c64.Mem.ReadData(startAddress, length); + + }); + // Convert a byte array to System.Text.Json.JsonArray + object jsonArray = new JsonArray(values.Select(b => (JsonNode)b).ToArray()); + var result = C64ToolHelper.BuildCallToolDataResult(jsonArray); + + return result; + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + + [McpServerTool, Description("Writes value at specified memory address in C64 emulator")] + public static async Task WriteMemory(IHostApp hostApp, ushort address, byte value) + { + try + { + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var c64 = C64ToolHelper.GetC64(hostApp); + c64.Mem[address] = value; + }); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + /// + /// + /// + /// + /// Array of bytes to write to memory> + [McpServerTool, Description("Writes a range of numeric values (array) starting at specified memory address in C64 emulator.")] + public static async Task WriteMemoryRange(IHostApp hostApp, ushort address, int[] values) + { + try + { + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + // Convert values to byte array + if (values == null || values.Length == 0) + throw new ArgumentException("Values cannot be null or empty.", nameof(values)); + + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var c64 = C64ToolHelper.GetC64(hostApp); + + var valuesAsBytes = values.Select(v => (byte)(v & 0xFF)).ToArray(); // Ensure values are within byte range + + if (address + valuesAsBytes.Length > c64.Mem.Size) + valuesAsBytes = valuesAsBytes.Take(c64.Mem.Size - address).ToArray(); + c64.Mem.StoreData(address, valuesAsBytes); + }); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + /// + /// + /// + /// + /// Sequence of 8-bit bytes in hex separated by space to write to memory> + [McpServerTool, Description("Writes a range of hex values separated by space starting at specified memory address in C64 emulator.")] + public static async Task WriteMemoryRangeAsHexString(IHostApp hostApp, ushort address, string hexValues) + { + try + { + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + // Convert hex string to byte array + if (string.IsNullOrWhiteSpace(hexValues)) + throw new ArgumentException("Hex values cannot be null or empty.", nameof(hexValues)); + int[] values = hexValues + .Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries) + .Select(hex => Convert.ToInt32(hex, 16)) + .ToArray(); + + await WriteMemoryRange(hostApp, address, values); + }); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool(UseStructuredContent = true, ReadOnly = true), Description("Reads current C64 screen memory and converts screen codes to ASCII characters.")] + public static async Task ReadScreenMemoryAsAscii(IHostApp hostApp) + { + try + { + string asciiScreen = string.Empty; + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var c64 = C64ToolHelper.GetC64(hostApp); + // C64 screen memory: $0400-$07E7 (1000 bytes, 40x25) + const ushort screenMemStart = 0x0400; + const int screenMemLen = 1000; + var screenCodes = c64.Mem.ReadData(screenMemStart, screenMemLen); + // Use existing conversion: screen code -> petscii -> ascii + var asciiSb = new System.Text.StringBuilder(screenMemLen); + foreach (var screenCode in screenCodes) + { + var petscii = Highbyte.DotNet6502.Systems.Commodore64.Video.Petscii.C64ScreenCodeToPetscII(screenCode); + var ascii = Highbyte.DotNet6502.Systems.Commodore64.Video.Petscii.PetscIIToAscII(petscii); + asciiSb.Append((char)ascii); + } + asciiScreen = asciiSb.ToString(); + }); + return C64ToolHelper.BuildCallToolDataResult(asciiScreen); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool(UseStructuredContent = true, ReadOnly = true), Description("Disassembles a memory range in the C64 emulator and returns the disassembly as a list of strings.")] + public static async Task DisassembleMemoryRange(IHostApp hostApp, ushort startAddress, ushort endAddress) + { + try + { + List disassembly = new(); + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var c64 = C64ToolHelper.GetC64(hostApp); + var cpu = c64.CPU; + var mem = c64.Mem; + + ushort currentAddress = startAddress; + bool cont = true; + while (cont) + { + // Use OutputGen to get disassembly for current instruction + var line = Highbyte.DotNet6502.Utils.OutputGen.GetInstructionDisassembly(cpu, mem, currentAddress); + disassembly.Add(line); + // Get address of next instruction + var nextInstructionAddress = cpu.GetNextInstructionAddress(mem, currentAddress); + if (nextInstructionAddress > endAddress || (currentAddress >= nextInstructionAddress)) + cont = false; + else + currentAddress = nextInstructionAddress; + } + }); + return C64ToolHelper.BuildCallToolDataResult(disassembly); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64RegistersTool.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64RegistersTool.cs new file mode 100644 index 000000000..5ff604153 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64RegistersTool.cs @@ -0,0 +1,160 @@ +using System.ComponentModel; +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Util.MCPServer.Contract; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Highbyte.DotNet6502.Util.MCPServer; + +[McpServerToolType] +public static class C64RegistersTool +{ + [McpServerTool, Description("Returns the current value of the C64 CPU registers: A, X, Y, PS, PC, SP")] + public static async Task GetCPURegisters(IHostApp hostApp) + { + try + { + CPURegisters cpuRegisters = null!; + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var cpu = C64ToolHelper.GetC64(hostApp).CPU; + cpuRegisters = new CPURegisters + { + A = cpu.A, + X = cpu.X, + Y = cpu.Y, + PC = cpu.PC, + SP = cpu.SP, + ProcessorStatus = new ProcessorStatus(cpu.ProcessorStatus.Value) + }; + }); + return C64ToolHelper.BuildCallToolDataResult(cpuRegisters); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Sets the CPU register A")] + public static async Task SetCPURegisterA(IHostApp hostApp, byte value) + { + try + { + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var cpu = C64ToolHelper.GetC64(hostApp).CPU; + cpu.A = value; + }); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Sets the CPU register X")] + public static async Task SetCPURegisterX(IHostApp hostApp, byte value) + { + try + { + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var cpu = C64ToolHelper.GetC64(hostApp).CPU; + cpu.X = value; + }); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Sets the CPU register Y")] + public static async Task SetCPURegisterY(IHostApp hostApp, byte value) + { + try + { + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var cpu = C64ToolHelper.GetC64(hostApp).CPU; + cpu.Y = value; + }); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Sets the CPU register PC (Program Counter)")] + public static async Task SetCPURegisterPC(IHostApp hostApp, ushort value) + { + try + { + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var cpu = C64ToolHelper.GetC64(hostApp).CPU; + cpu.PC = value; + }); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Sets the CPU register SP (Stack Pointer)")] + public static async Task SetCPURegisterSP(IHostApp hostApp, byte value) + { + try + { + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var cpu = C64ToolHelper.GetC64(hostApp).CPU; + cpu.SP = value; + }); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Sets the CPU register PS (Processor Status)")] + public static async Task SetCPURegisterPS(IHostApp hostApp, byte value) + { + try + { + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + var cpu = C64ToolHelper.GetC64(hostApp).CPU; + cpu.ProcessorStatus.Value = value; + }); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64StateTool.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64StateTool.cs new file mode 100644 index 000000000..7207ceb24 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64StateTool.cs @@ -0,0 +1,281 @@ +using System.ComponentModel; +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Systems.Commodore64; +using Highbyte.DotNet6502.Util.MCPServer.Contract; +using Highbyte.DotNet6502.Utils; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace Highbyte.DotNet6502.Util.MCPServer; + +[McpServerToolType] +public static class C64StateTool +{ + [McpServerTool, Description("Get C64 emulator state (Uninitialized, Running, Paused)")] + public static async Task GetState(IHostApp hostApp, StateManager stateManager) + { + EmulatorState emulatorState = default; + bool isMCPControlEnabled = false; + try + { + C64ToolHelper.AssertEmulatorIsC64(hostApp); + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + emulatorState = hostApp.EmulatorState; + isMCPControlEnabled = stateManager.IsMCPControlEnabled(hostApp); + }); + return C64ToolHelper.BuildCallToolDataResult( + new + { + emulatorState = emulatorState, + isMCPControlEnabled = stateManager.IsMCPControlEnabled(hostApp), + IsCPUPaused = stateManager.IsCpuExecutionPaused + }); + + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Starts C64 emulator.")] + public static async Task Start(IHostApp hostApp, StateManager stateManager) + { + try + { + if (hostApp.EmulatorState == EmulatorState.Running) + return C64ToolHelper.BuildCallToolDataResult("C64 emulator is already running."); + + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + await hostApp.Start(); + }); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + + } + + [McpServerTool, Description("Stop C64 emulator")] + public static async Task Stop(IHostApp hostApp, StateManager stateManager) + { + try + { + C64ToolHelper.AssertC64EmulatorIsRunningOrPaused(hostApp); + C64ToolHelper.AssertMCPControlEnabled(hostApp, stateManager); + + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + hostApp.Stop(); + }); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Runs the C64 emulator for specified number of seconds")] + public static async Task RunNumberOfSeconds(IHostApp hostApp, StateManager stateManager, int numberOfSeconds) + { + try + { + if (numberOfSeconds <= 0) + throw new ArgumentException("Number of seconds must be greater than zero.", nameof(numberOfSeconds)); + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + C64ToolHelper.AssertMCPControlEnabled(hostApp, stateManager); + + ExecutionResult executionResult = new ExecutionResult + { + ExecutionPauseWasTriggered = false, + ExecutionPauseReason = null + }; + + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + var c64 = C64ToolHelper.GetC64(hostApp); + //var numberOfFrames = numberOfSeconds * c64.Vic2.Vic2Model.?? + int numberOfFrames = (int)(numberOfSeconds * c64.Screen.RefreshFrequencyHz); + for (int i = 0; i < numberOfFrames; i++) + { + var execEvaluatorTriggerResult = hostApp.RunEmulatorOneFrame(); + if (execEvaluatorTriggerResult.Triggered) + { + executionResult.ExecutionPauseWasTriggered = execEvaluatorTriggerResult.Triggered; + executionResult.ExecutionPauseReason = execEvaluatorTriggerResult.TriggerType; + break; + } + } + + executionResult.NextInstruction = OutputGen.GetNextInstructionDisassembly(c64.CPU, c64.Mem); + }); + + return C64ToolHelper.BuildCallToolDataResult(executionResult); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Runs the C64 emulator for specified number of frames")] + public static async Task RunNumberOfFrames(IHostApp hostApp, StateManager stateManager, int numberOfFrames) + { + try + { + if (numberOfFrames <= 0) + throw new ArgumentException("Frame count must be greater than zero.", nameof(numberOfFrames)); + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + C64ToolHelper.AssertMCPControlEnabled(hostApp, stateManager); + + ExecutionResult executionResult = new ExecutionResult + { + ExecutionPauseWasTriggered = false, + ExecutionPauseReason = null + }; + + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + for (int i = 0; i < numberOfFrames; i++) + { + var execEvaluatorTriggerResult = hostApp.RunEmulatorOneFrame(); + if (execEvaluatorTriggerResult.Triggered) + { + executionResult.ExecutionPauseWasTriggered = execEvaluatorTriggerResult.Triggered; + executionResult.ExecutionPauseReason = execEvaluatorTriggerResult.TriggerType; + break; + } + } + + var c64 = C64ToolHelper.GetC64(hostApp); + executionResult.NextInstruction = OutputGen.GetNextInstructionDisassembly(c64.CPU, c64.Mem); + }); + + return C64ToolHelper.BuildCallToolDataResult(executionResult); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Runs the C64 emulator for specified number of instructions")] + public static async Task RunNumberOfInstructions(IHostApp hostApp, StateManager stateManager, int numberOfInstructions) + { + try + { + if (numberOfInstructions <= 0) + throw new ArgumentException("Instruction count must be greater than zero.", nameof(numberOfInstructions)); + C64ToolHelper.AssertC64EmulatorIsRunning(hostApp); + C64ToolHelper.AssertMCPControlEnabled(hostApp, stateManager); + + ExecutionResult executionResult = new ExecutionResult + { + ExecutionPauseWasTriggered = false, + ExecutionPauseReason = null + }; + + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + for (int i = 0; i < numberOfInstructions; i++) + { + var execEvaluatorTriggerResult = hostApp.CurrentSystemRunner.RunEmulatorOneInstruction(); + if (execEvaluatorTriggerResult.Triggered) + { + executionResult.ExecutionPauseWasTriggered = execEvaluatorTriggerResult.Triggered; + executionResult.ExecutionPauseReason = execEvaluatorTriggerResult.TriggerType; + break; + } + } + + var c64 = C64ToolHelper.GetC64(hostApp); + executionResult.NextInstruction = OutputGen.GetNextInstructionDisassembly(c64.CPU, c64.Mem); + }); + return C64ToolHelper.BuildCallToolDataResult(executionResult); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + + [McpServerTool, Description("Checks if C64 emulator has MCP control enabled.")] + public static async Task IsMCPControlEnabled(IHostApp hostApp, StateManager stateManager) + { + try + { + return C64ToolHelper.BuildCallToolDataResult(stateManager.IsMCPControlEnabled(hostApp)); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + [McpServerTool, Description("Enable MCP control of C64 emulator.")] + public static async Task EnableMCPControl(IHostApp hostApp, StateManager stateManager) + { + try + { + stateManager.EnableMCPControl(hostApp); + + ExecutionResult executionResult = new ExecutionResult + { + ExecutionPauseWasTriggered = false, + ExecutionPauseReason = null, + }; + + // Safest to run code that uses objects the emulator uses on the UI thread. + await hostApp.ExternalControlInvokeOnUIThread(async () => + { + var c64 = C64ToolHelper.GetC64(hostApp); + executionResult.NextInstruction = OutputGen.GetNextInstructionDisassembly(c64.CPU, c64.Mem); + }); + + return C64ToolHelper.BuildCallToolDataResult(executionResult); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + + [McpServerTool, Description("Disable MCP control of C64 emulator.")] + public static async Task DisableMCPControl(IHostApp hostApp, StateManager stateManager) + { + try + { + stateManager.DisableMCPControl(hostApp); + return new CallToolResult(); + } + catch (Exception ex) + { + return C64ToolHelper.BuildCallToolErrorResult(ex); + } + } + + //[McpServerTool, Description("Check if the CPU in the C64 emulator is paused.")] + //public static async Task IsCPUPaused(IHostApp hostApp, StateManager stateManager) + //{ + // try + // { + // return C64ToolHelper.BuildCallToolDataResult(stateManager.IsMCPControlEnabled(hostApp) && stateManager.IsCpuExecutionPaused); + // } + // catch (Exception ex) + // { + // return C64ToolHelper.BuildCallToolErrorResult(ex); + // } + //} +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64ToolHelper.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64ToolHelper.cs new file mode 100644 index 000000000..1100a401e --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/C64ToolHelper.cs @@ -0,0 +1,158 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Systems.Commodore64; +using ModelContextProtocol.Protocol; + +namespace Highbyte.DotNet6502.Util.MCPServer; + +public static class C64ToolHelper +{ + public static void AssertC64EmulatorIsRunning(IHostApp hostApp) + { + AssertEmulatorIsC64(hostApp); + if (hostApp.EmulatorState != EmulatorState.Running) + { + throw new InvalidOperationException($"C64 emulator is not running. Current state: {hostApp.EmulatorState}"); + } + } + + public static void AssertC64EmulatorIsRunningOrPaused(IHostApp hostApp) + { + AssertEmulatorIsC64(hostApp); + if (hostApp.EmulatorState != EmulatorState.Running && hostApp.EmulatorState != EmulatorState.Paused) + { + throw new InvalidOperationException($"C64 emulator is not running or paused. Current state: {hostApp.EmulatorState}"); + } + } + + public static void AssertC64EmulatorIsPausedOrUninitialzied(IHostApp hostApp) + { + AssertEmulatorIsC64(hostApp); + if (hostApp.EmulatorState != EmulatorState.Paused && hostApp.EmulatorState != EmulatorState.Uninitialized) + { + throw new InvalidOperationException($"C64 emulator not paused or uninitialzied. Current state: {hostApp.EmulatorState}"); + } + } + + public static void AssertC64EmulatorIsUninitialzied(IHostApp hostApp) + { + AssertEmulatorIsC64(hostApp); + if (hostApp.EmulatorState != EmulatorState.Uninitialized) + { + throw new InvalidOperationException($"C64 emulator is running or paused. Current state: {hostApp.EmulatorState}"); + } + } + + public static void AssertEmulatorIsC64(IHostApp hostApp) + { + if (hostApp.SelectedSystemName != C64.SystemName) + { + throw new InvalidOperationException("Current emulated system is not a C64 instance."); + } + } + + public static C64 GetC64(IHostApp hostApp) + { + if (hostApp.CurrentRunningSystem is C64 c64) + { + return c64; + } + throw new InvalidOperationException("Current running system is not a C64 instance."); + } + + public static void AssertMCPControlEnabled(IHostApp hostApp, StateManager stateManager) + { + if (!stateManager.IsMCPControlEnabled(hostApp)) + throw new DotNet6502Exception("MCP control is not enabled. Please enable it first."); + } + + public static CallToolResult BuildCallToolDataResult(object data) + { + // NOTE: Doesn't seem to work with returning StructuredContent, it's always empty. + //JsonNode? jsonNode = JsonSerializer.SerializeToNode(data, s_jsonSerializerOptions); + //return new CallToolResult + //{ + // StructuredContent = jsonNode + //}; + Type type = data.GetType(); + bool needsJsonElementName; + if (type.IsEnum || type == typeof(JsonArray)) + { + needsJsonElementName = true; + } + else if (type.IsArray) + { + var elementType = type.GetElementType(); + needsJsonElementName = elementType.IsPrimitive || elementType == typeof(string) || elementType == typeof(decimal); + } + else + { + needsJsonElementName = type.IsPrimitive || type == typeof(string) || type == typeof(decimal); + } + + object objectToSerialize; + if (needsJsonElementName) + { + objectToSerialize = new + { + Data = data + }; + } + else + { + objectToSerialize = data; + } + string json = JsonSerializer.Serialize(objectToSerialize, s_jsonSerializerOptions); + return new CallToolResult + { + Content = new List + { + new TextContentBlock + { + Text = json + } + } + }; + } + + //public static CallToolResult BuildCallToolDataResult(JsonNode jsonNode) + //{ + // // NOTE: Doesn't seem to work with returning StructuredContent, it's always empty. + // //return new CallToolResult + // //{ + // // StructuredContent = jsonNode + // //}; + //} + + public static CallToolResult BuildCallToolErrorResult(Exception ex) + { + return new CallToolResult + { + IsError = true, + Content = new List + { + new TextContentBlock + { + Text = $"C64 emulator error: {ex.Message}" + }, + }, + }; + } + + + private static readonly JsonSerializerOptions s_jsonSerializerOptions = BuildJsonSerializerOptions(); + + public static JsonSerializerOptions BuildJsonSerializerOptions() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + //WriteIndented = true, + //DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/Contract/CPURegisters.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Contract/CPURegisters.cs new file mode 100644 index 000000000..86ccedae2 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Contract/CPURegisters.cs @@ -0,0 +1,48 @@ + +namespace Highbyte.DotNet6502.Util.MCPServer.Contract; +public class CPURegisters +{ + /// + /// Program Counter + /// + public ushort PC { get; set; } + + /// + /// Stack Pointer + /// The 6502 microprocessor supports a 256 byte stack fixed between memory locations $0100 and $01FF. + /// A special 8-bit register, S, is used to keep track of the next free byte of stack space. + /// Pushing a byte on to the stack causes the value to be stored at the current free location (e.g. $0100,S) + /// and then the stack pointer is post decremented. + /// Pull operations reverse this procedure. + /// + /// The stack register can only be accessed by transferring its value to or from the X register via instructions TSX and TXS. + /// Its value is automatically modified by push/pull instructions, subroutine calls and returns, interrupts and returns from interrupts. + /// + /// Other instructions for storing values on stack: PHA, PHP, PLA, PLP + /// + public byte SP { get; set; } + + /// + /// Accumulator + /// + public byte A { get; set; } + + /// + /// Index Register X + /// + public byte X { get; set; } + + /// + /// Index Register Y + /// + public byte Y { get; set; } + + /// + /// Processor Status. + /// + /// As instructions are executed a set of processor flags are set or clear to record the results of the operation. This flags and some additional control flags are held in a special status register. Each flag has a single bit within the register. + /// + /// Instructions exist to test the values of the various bits, to set or clear some of them and to push or pull the entire set to or from the stack. + /// + public ProcessorStatus ProcessorStatus { get; set; } +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/Contract/ExecutionResult.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Contract/ExecutionResult.cs new file mode 100644 index 000000000..a7b1a26af --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Contract/ExecutionResult.cs @@ -0,0 +1,9 @@ + +namespace Highbyte.DotNet6502.Util.MCPServer.Contract; +public class ExecutionResult +{ + public CPURegisters CPURegisters { get; set; } + public bool ExecutionPauseWasTriggered { get; set; } + public ExecEvaluatorTriggerReasonType? ExecutionPauseReason { get; set; } + public string NextInstruction { get; set; } +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/EmbeddedMCPHostApp.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/EmbeddedMCPHostApp.cs new file mode 100644 index 000000000..4d46433e1 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/EmbeddedMCPHostApp.cs @@ -0,0 +1,127 @@ +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Systems.Logging.InMem; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Highbyte.DotNet6502.Util.MCPServer.Emulator; + +/// +/// Host app for running Highbyte.DotNet6502 emulator embbedded in a MCP server +/// +public class EmbeddedMCPHostApp : HostApp +{ + // -------------------- + // Injected variables + // -------------------- + private readonly ILogger _logger; + + private readonly DotNet6502InMemLogStore _logStore; + private readonly DotNet6502InMemLoggerConfiguration _logConfig; + private readonly IConfiguration _configuration; + private readonly ILoggerFactory _loggerFactory; + + // -------------------- + // Other variables / constants + // -------------------- + private NullRenderContext _renderContext = default!; + private NullInputHandlerContext _inputHandlerContext = default!; + private NullAudioHandlerContext _audioHandlerContext = default!; + + + private const int STATS_UPDATE_EVERY_X_FRAME = 60 * 1; + private const int DEBUGINFO_UPDATE_EVERY_X_FRAME = 10 * 1; + + private int _statsFrameCount = 0; + private int _debugInfoFrameCount = 0; + + private const int LOGS_UPDATE_EVERY_X_FRAME = 60 * 1; + private int _logsFrameCount = 0; + + /// + /// Constructor + /// + /// + /// + /// + /// + /// + public EmbeddedMCPHostApp( + SystemList systemList, + ILoggerFactory loggerFactory, + EmulatorConfig emulatorConfig, + DotNet6502InMemLogStore logStore, + DotNet6502InMemLoggerConfiguration logConfig, + IConfiguration configuration) + : base("MCPServer", systemList, loggerFactory) + { + _logStore = logStore; + _logConfig = logConfig; + _configuration = configuration; + _loggerFactory = loggerFactory; + _logger = loggerFactory.CreateLogger(typeof(EmbeddedMCPHostApp).Name); + } + + public void Init() + { + _renderContext = new NullRenderContext(); + _inputHandlerContext = new NullInputHandlerContext(); + _audioHandlerContext = new NullAudioHandlerContext(); + + SetContexts(() => _renderContext, () => _inputHandlerContext, () => _audioHandlerContext); + InitRenderContext(); + InitInputHandlerContext(); + InitAudioHandlerContext(); + } + + public override void OnAfterSelectSystem() + { + } + + public override bool OnBeforeStart(ISystem systemAboutToBeStarted) + { + return true; + } + + public override void OnAfterStart(EmulatorState emulatorStateBeforeStart) + { + } + + public override void OnBeforeStop() + { + } + + public override void OnAfterStop() + { + } + + public override void OnAfterClose() + { + } + + + public override void OnBeforeRunEmulatorOneFrame(out bool shouldRun, out bool shouldReceiveInput) + { + shouldRun = true; + shouldReceiveInput = false; + } + + public override void OnAfterRunEmulatorOneFrame(ExecEvaluatorTriggerResult execEvaluatorTriggerResult) + { + } + + public override void OnBeforeDrawFrame(bool emulatorWillBeRendered) + { + // If any ImGui window is visible, make sure to clear Gl buffer before rendering emulator + if (emulatorWillBeRendered) + { + } + } + public override void OnAfterDrawFrame(bool emulatorRendered) + { + if (emulatorRendered) + { + } + } + + public override bool ExternalControlDirectInvoke => true; +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/EmulatorConfig.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/EmulatorConfig.cs new file mode 100644 index 000000000..a93f4b38a --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/EmulatorConfig.cs @@ -0,0 +1,38 @@ +using Highbyte.DotNet6502.Monitor; +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Util.MCPServer.Emulator.SystemSetup; + +namespace Highbyte.DotNet6502.Util.MCPServer.Emulator; + +public class EmulatorConfig +{ + public const string ConfigSectionName = "Highbyte.DotNet6502.MCPServer"; + + /// + /// The name of the emulator to start. + /// Ex: GenericComputer, C64 + /// + /// + public string DefaultEmulator { get; set; } + + public MonitorConfig Monitor { get; set; } + + + /// + /// SadConsole-specific configuration for specific system. + /// + public C64HostConfig C64HostConfig { get; set; } + + public EmulatorConfig() + { + Monitor = new(); + C64HostConfig = new(); + } + + public void Validate(SystemList systemList) + { + if (!systemList.Systems.Contains(DefaultEmulator)) + throw new DotNet6502Exception($"Setting {nameof(DefaultEmulator)} value {DefaultEmulator} is not supported. Valid values are: {string.Join(',', systemList.Systems)}"); + //Monitor.Validate(); + } +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/SystemSetup/C64HostConfig.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/SystemSetup/C64HostConfig.cs new file mode 100644 index 000000000..d1f12f42e --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/SystemSetup/C64HostConfig.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Systems.Commodore64.Config; + +namespace Highbyte.DotNet6502.Util.MCPServer.Emulator.SystemSetup; + +public class C64HostConfig : IHostSystemConfig, ICloneable +{ + public const string ConfigSectionName = "Highbyte.DotNet6502.C64.MCPServer"; + + private C64SystemConfig _systemConfig; + ISystemConfig IHostSystemConfig.SystemConfig => _systemConfig; + public C64SystemConfig SystemConfig => _systemConfig; + + [JsonIgnore] + public bool AudioSupported => false; + + private bool _isDirty = false; + [JsonIgnore] + public bool IsDirty => _isDirty; + public void ClearDirty() + { + _isDirty = false; + } + + public void Validate() + { + if (!IsValid(out List validationErrors)) + throw new DotNet6502Exception($"Config errors: {string.Join(',', validationErrors)}"); + } + + public bool IsValid(out List validationErrors) + { + validationErrors = new List(); + + SystemConfig.IsValid(out var systemConfigValidationErrors); + validationErrors.AddRange(systemConfigValidationErrors); + + return validationErrors.Count == 0; + } + + public C64HostConfig() + { + _systemConfig = new(); + } + + public new object Clone() + { + var clone = (C64HostConfig)MemberwiseClone(); + clone._systemConfig = (C64SystemConfig)SystemConfig.Clone(); + return clone; + } +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/SystemSetup/C64Setup.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/SystemSetup/C64Setup.cs new file mode 100644 index 000000000..a6e9ed967 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Emulator/SystemSetup/C64Setup.cs @@ -0,0 +1,79 @@ +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Systems.Commodore64; +using Highbyte.DotNet6502.Systems.Commodore64.Config; +using Highbyte.DotNet6502.Systems.Commodore64.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Highbyte.DotNet6502.Util.MCPServer.Emulator.SystemSetup; +internal class C64Setup : ISystemConfigurer +{ + public string SystemName => C64.SystemName; + + public Task> GetConfigurationVariants(IHostSystemConfig hostSystemConfig) => Task.FromResult(s_systemVariants); + public List ConfigurationVariants => s_systemVariants; + + private static readonly List s_systemVariants = C64ModelInventory.C64Models.Keys.ToList(); + + private readonly ILoggerFactory _loggerFactory; + private readonly IConfiguration _configuration; + + + internal C64Setup(ILoggerFactory loggerFactory, IConfiguration configuration) + { + _loggerFactory = loggerFactory; + _configuration = configuration; + } + + public Task GetNewHostSystemConfig() + { + var c64HostConfig = new C64HostConfig(); + _configuration.GetSection($"{C64HostConfig.ConfigSectionName}").Bind(c64HostConfig); + + // TODO: Why is list of ROMs are duplicated when binding from appsettings.json? + // This is a workaround to remove duplicates. + c64HostConfig.SystemConfig.ROMs = c64HostConfig.SystemConfig.ROMs.DistinctBy(p => p.Name).ToList(); + + return Task.FromResult(c64HostConfig); + } + + public Task PersistHostSystemConfig(IHostSystemConfig hostSystemConfig) + { + // TODO: Should user settings be persisted? If so method GetNewHostSystemConfig() also needs to be updated to read from there instead of appsettings.json. + return Task.CompletedTask; + } + + public Task BuildSystem(string configurationVariant, IHostSystemConfig hostSystemConfig) + { + var c64HostSystemConfig = (C64HostConfig)hostSystemConfig; + + var c64Config = new C64Config + { + C64Model = configurationVariant, + Vic2Model = C64ModelInventory.C64Models[configurationVariant].Vic2Models.First().Name, // NTSC, NTSC_old, PAL + AudioEnabled = c64HostSystemConfig.SystemConfig.AudioEnabled, + KeyboardJoystickEnabled = c64HostSystemConfig.SystemConfig.KeyboardJoystickEnabled, + KeyboardJoystick = c64HostSystemConfig.SystemConfig.KeyboardJoystick, + ROMs = c64HostSystemConfig.SystemConfig.ROMs, + ROMDirectory = c64HostSystemConfig.SystemConfig.ROMDirectory, + }; + + var c64 = C64.BuildC64(c64Config, _loggerFactory); + return Task.FromResult(c64); + } + + public Task BuildSystemRunner( + ISystem system, + IHostSystemConfig hostSystemConfig, + NullRenderContext renderContext, + NullInputHandlerContext inputHandlerContext, + NullAudioHandlerContext audioHandlerContext + ) + { + var c64 = (C64)system; + var renderer = new NullRenderer(c64); + var inputHandler = new NullInputHandler(c64); + var audioHandler = new NullAudioHandler(c64); + return Task.FromResult(new SystemRunner(c64, renderer, inputHandler, audioHandler)); + } +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/Highbyte.DotNet6502.Util.MCPServer.csproj b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Highbyte.DotNet6502.Util.MCPServer.csproj new file mode 100644 index 000000000..8cc63e69a --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Highbyte.DotNet6502.Util.MCPServer.csproj @@ -0,0 +1,31 @@ + + + + Exe + net9.0 + enable + enable + + + + portable + true + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/Program.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Program.cs new file mode 100644 index 000000000..c2a1ee226 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Program.cs @@ -0,0 +1,66 @@ +using Highbyte.DotNet6502.Systems; +using Highbyte.DotNet6502.Systems.Commodore64; +using Highbyte.DotNet6502.Systems.Logging.InMem; +using Highbyte.DotNet6502.Util.MCPServer; +using Highbyte.DotNet6502.Util.MCPServer.Emulator; +using Highbyte.DotNet6502.Util.MCPServer.Emulator.SystemSetup; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); +builder.Logging.AddConsole(consoleLogOptions => +{ + // Configure all logs to go to stderr + consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; +}); + +builder.Configuration + .AddJsonFile("appsettings.json") + .AddJsonFile("appsettings.Development.json", optional: true); + +// ---------- +// Register the emulator EmbeddedMCPHostApp as singleton service +// ---------- +var configuration = builder.Configuration; + + +// ---------- +// Create emulator logging +// ---------- +DotNet6502InMemLogStore logStore = new() { WriteDebugMessage = true }; +var logConfig = new DotNet6502InMemLoggerConfiguration(logStore); +var loggerFactory = LoggerFactory.Create(builder => +{ + logConfig.LogLevel = LogLevel.Information; // LogLevel.Debug, LogLevel.Information, + builder.AddInMem(logConfig); + builder.SetMinimumLevel(LogLevel.Trace); +}); + +// ---------- +// Get emulator host config +// ---------- +var emulatorConfig = new EmulatorConfig(); +configuration.GetSection(EmulatorConfig.ConfigSectionName).Bind(emulatorConfig); + +// ---------- +// Get systems +// ---------- +var systemList = new SystemList(); +var c64Setup = new C64Setup(loggerFactory, configuration); +systemList.AddSystem(c64Setup); + +// ---------- +// Create & init emulator host app +// ---------- +var embeddedMCPHostApp = new EmbeddedMCPHostApp(systemList, loggerFactory, emulatorConfig, logStore, logConfig, configuration); +embeddedMCPHostApp.Init(); +embeddedMCPHostApp.SelectSystem(C64.SystemName).Wait(); + +// ---------- +// Configure MCP server and tools +// ---------- +builder.ConfigureDotNet6502McpServerTools(embeddedMCPHostApp, mcpControlEnabledFromStart: true); + +await builder.Build().RunAsync(); diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/README_MCP.md b/src/utils/Highbyte.DotNet6502.Util.MCPServer/README_MCP.md new file mode 100644 index 000000000..d5f91e525 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/README_MCP.md @@ -0,0 +1,24 @@ +# MCP server commands +The MCP server communicates over STDIO with JSON. + +To troubleshoot the JSON commands, start the project `Highbyte.DotNet6502.Util.MCPServer.csproj` and paste the JSON commands in the terminal and press enter. + +## List tools +```json +{"jsonrpc":"2.0","method":"tools/list","params":{},"id":1} +``` + + +## Examples: state handling + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"GetState","args":{}},"id":2} +``` + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"Start","args":{}},"id":2} +``` + +```json +{"jsonrpc":"2.0","method":"tools/call","params":{"name":"GetCPURegisters","args":{}},"id":2} +``` diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/Services/BreakpointManager.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Services/BreakpointManager.cs new file mode 100644 index 000000000..cccbf2db8 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Services/BreakpointManager.cs @@ -0,0 +1,26 @@ +using Highbyte.DotNet6502.Monitor; + +public class BreakpointManager +{ + private readonly Dictionary _breakPoints = new(); + public Dictionary BreakPoints => _breakPoints; + + public void AddBreakpoint(ushort address) + { + if (!_breakPoints.ContainsKey(address)) + _breakPoints.Add(address, new BreakPoint { Enabled = true }); + else + _breakPoints[address].Enabled = true; + } + + public void RemoveBreakpoint(ushort address) + { + if (_breakPoints.ContainsKey(address)) + _breakPoints.Remove(address); + } + + public void RemoveAllBreakpoints() + { + _breakPoints.Clear(); + } +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/Services/StateManager.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Services/StateManager.cs new file mode 100644 index 000000000..434484954 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/Services/StateManager.cs @@ -0,0 +1,75 @@ +using Highbyte.DotNet6502; +using Highbyte.DotNet6502.Monitor; +using Highbyte.DotNet6502.Systems; + +public class StateManager +{ + private IExecEvaluator? _originalExecEvaluator; + + private bool _cpuExecutionPaused = false; + private readonly BreakpointManager _breakPointManager; + + public bool IsCpuExecutionPaused => _cpuExecutionPaused; + + public StateManager(BreakpointManager breakpointManager) + { + _breakPointManager = breakpointManager; + } + + public bool IsMCPControlEnabled(IHostApp hostApp) + { + return hostApp.ExternalControlEnabled; + } + + public void EnableMCPControl(IHostApp hostApp) + { + if (hostApp.CurrentSystemRunner == null) + throw new DotNet6502Exception("CurrentSystemRunner is not set in the host app."); + if (IsMCPControlEnabled(hostApp)) + throw new DotNet6502Exception("MCP control is already enabled in the host app."); + + PauseCPUExecution(); + + hostApp.EnableExternalControl(OnBeforeRunEmulatorOneFrame, OnAfterRunEmulatorOneFrame); + _originalExecEvaluator = hostApp.CurrentSystemRunner.CustomExecEvaluator; + hostApp.CurrentSystemRunner.SetCustomExecEvaluator(new BreakPointExecEvaluator(_breakPointManager.BreakPoints)); + } + + public void DisableMCPControl(IHostApp hostApp) + { + if (hostApp.CurrentSystemRunner == null) + throw new DotNet6502Exception("CurrentSystemRunner is not set in the host app."); + if (!IsMCPControlEnabled(hostApp)) + throw new DotNet6502Exception("MCP control is not enabled in the host app."); + + hostApp.DisableExternalControl(); + hostApp.CurrentSystemRunner.SetCustomExecEvaluator(_originalExecEvaluator); + ResumeCPUExecution(); + } + + private void PauseCPUExecution() + { + _cpuExecutionPaused = true; + } + + private void ResumeCPUExecution() + { + _cpuExecutionPaused = false; + } + + private (bool shouldRun, bool shouldReceiveInput) OnBeforeRunEmulatorOneFrame() + { + // If CPU execution is paused, we should not run the emulator. + return (!_cpuExecutionPaused, true); + } + + private void OnAfterRunEmulatorOneFrame(ExecEvaluatorTriggerResult execEvaluatorTriggerResult) + { + if (execEvaluatorTriggerResult.Triggered) + { + if (execEvaluatorTriggerResult.TriggerType == ExecEvaluatorTriggerReasonType.DebugBreakPoint) + { + } + } + } +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/ToolSetup.cs b/src/utils/Highbyte.DotNet6502.Util.MCPServer/ToolSetup.cs new file mode 100644 index 000000000..3bb301bc2 --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/ToolSetup.cs @@ -0,0 +1,61 @@ +using System.Reflection; +using Highbyte.DotNet6502.Systems; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Highbyte.DotNet6502.Util.MCPServer; + +public static class ToolSetup +{ + public static void ConfigureDotNet6502McpServerTools( + this IHostApplicationBuilder builder, + IHostApp hostApp, + Assembly? additionalToolsAssembly = null, + bool mcpControlEnabledFromStart = false) + { + // DI: Register the emulator host app + builder.Services.AddSingleton((sp) => { + + if (mcpControlEnabledFromStart) + { + // Automatically start the emulator. + hostApp.Start(); + } + return hostApp; + }); + + // DI: Register MCP server dependencies + builder.Services.AddSingleton((sp) => + { + + var breakpointManager = sp.GetRequiredService(); + var stateManger = new StateManager(breakpointManager); + if (mcpControlEnabledFromStart) + { + // Enable external control of the emulator. + stateManger.EnableMCPControl(hostApp); + } + return stateManger; + }); + builder.Services.AddSingleton(); + + // Add console logging + builder.Logging.AddConsole(consoleLogOptions => + { + // Configure all console logs to go to stderr to no interfere with with MCP server STDIO communication + consoleLogOptions.LogToStandardErrorThreshold = LogLevel.Trace; + }); + + // Register MCP server and tools + var mcpServerBuilder = builder.Services + .AddMcpServer() + .WithStdioServerTransport(); + + // Register MCP tools + mcpServerBuilder.WithToolsFromAssembly(typeof(C64StateTool).Assembly); + // Add additional MCP server tools from the specified assembly + if (additionalToolsAssembly != null) + mcpServerBuilder.WithToolsFromAssembly(additionalToolsAssembly); + } +} diff --git a/src/utils/Highbyte.DotNet6502.Util.MCPServer/appsettings.json b/src/utils/Highbyte.DotNet6502.Util.MCPServer/appsettings.json new file mode 100644 index 000000000..35372e0bf --- /dev/null +++ b/src/utils/Highbyte.DotNet6502.Util.MCPServer/appsettings.json @@ -0,0 +1,29 @@ +{ + "Highbyte.DotNet6502.MCPServer": { + "DefaultEmulator": "C64" + }, + + "Highbyte.DotNet6502.C64.MCPServer": { + + "SystemConfig": { + "ROMDirectory": "%HOME%/Downloads/C64", + "ROMs": [ + { + "Name": "basic", + "File": "basic.901226-01.bin" + }, + { + "Name": "kernal", + "File": "kernal.901227-03.bin" + }, + { + "Name": "chargen", + "File": "characters.901225-01.bin" + } + ], + + "ColorMapName": "Default", + "AudioEnabled": false + } + } +} \ No newline at end of file diff --git a/tests/Highbyte.DotNet6502.Systems.Tests/Highbyte.DotNet6502.Systems.Tests.csproj b/tests/Highbyte.DotNet6502.Systems.Tests/Highbyte.DotNet6502.Systems.Tests.csproj index 58b9474bf..abb0e8b8d 100644 --- a/tests/Highbyte.DotNet6502.Systems.Tests/Highbyte.DotNet6502.Systems.Tests.csproj +++ b/tests/Highbyte.DotNet6502.Systems.Tests/Highbyte.DotNet6502.Systems.Tests.csproj @@ -9,8 +9,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Highbyte.DotNet6502.Tests/Highbyte.DotNet6502.Tests.csproj b/tests/Highbyte.DotNet6502.Tests/Highbyte.DotNet6502.Tests.csproj index 7673259c3..21bbef543 100644 --- a/tests/Highbyte.DotNet6502.Tests/Highbyte.DotNet6502.Tests.csproj +++ b/tests/Highbyte.DotNet6502.Tests/Highbyte.DotNet6502.Tests.csproj @@ -9,8 +9,8 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive