From 1c49d9a4872eec1fff83190fe0d812b8794eb018 Mon Sep 17 00:00:00 2001 From: Edoardo BAROLO Date: Wed, 24 Jun 2026 08:06:17 +0000 Subject: [PATCH 1/4] fix: Enable stateful MCP transport for opencode remote MCP compatibility Change Stateless=false so the SDK maps GET /mcp endpoint (required by opencode remote MCP client). The GET endpoint establishes an SSE connection for session negotiation, and POST handles JSON-RPC requests. Previously stateless mode was used to avoid session-ID issues with custom AI client code, but opencode native remote MCP support uses the official MCP SDK which handles session negotiation correctly. --- src/Spice86.Core/Emulator/Mcp/McpHttpHost.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/Spice86.Core/Emulator/Mcp/McpHttpHost.cs b/src/Spice86.Core/Emulator/Mcp/McpHttpHost.cs index 2e4ec78385..1133f467e6 100644 --- a/src/Spice86.Core/Emulator/Mcp/McpHttpHost.cs +++ b/src/Spice86.Core/Emulator/Mcp/McpHttpHost.cs @@ -56,11 +56,14 @@ public void Start(EmulatorMcpServices services, int port = 8081, }; }) .WithHttpTransport(options => { - // Stateless mode: the server never issues Mcp-Session-Id headers. - // Stateful sessions cause 404 when AI clients reuse a session ID - // from a fresh TCP connection or skip the notifications/initialized - // handshake step, which is a common real-world pattern. - options.Stateless = true; + // Stateful mode enables the GET /mcp endpoint for SSE-based + // client-to-server communication and allows the MCP client SDK + // (used by opencode remote MCP) to negotiate sessions via the + // initialize handshake with Mcp-Session-Id headers. + // Previously stateless mode was used, but AI coding agents + // now support the standard Streamable HTTP transport with + // session negotiation natively. + options.Stateless = false; }) .WithToolsFromAssembly(typeof(EmulatorMcpTools).Assembly); From f6d64da5c657bc2623f0e2558215cffae37c23fc Mon Sep 17 00:00:00 2001 From: Edoardo BAROLO Date: Wed, 24 Jun 2026 08:21:46 +0000 Subject: [PATCH 2/4] fix: UseUrls for reliable MCP bind, custom GET handler for opencode SSE compatibility MCP transport fixes for opencode remote MCP client compatibility: Use builder.WebHost.UseUrls() instead of ConfigureKestrel.ListenLocalhost() which was silently failing to bind on background threads. Replace _app.Run() (premature return after ~28s) with StartAsync() + ManualResetEvent so the server stays up on a background thread. Add custom GET /mcp/ handler returning SSE endpoint: /mcp/ event so MCP clients that probe with GET first (like opencode) can discover the POST endpoint. Stateless mode retained; custom GET handler coexists with SDK POST handler. --- src/BattleTechMcpTools/BattleTechMcpTools.cs | 759 ++++++++++++++++++ .../BattleTechMcpTools.csproj | 11 + .../BattleTechOverrideSupplier.cs | 29 + .../VGA/Data/RegisterValueSet.cs | 1 + src/Spice86.Core/Emulator/Mcp/McpHttpHost.cs | 46 +- .../OperatingSystem/DosDriveManager.cs | 4 +- .../Structures/DosDriveBase.cs | 2 +- src/Spice86.sln | 14 + 8 files changed, 848 insertions(+), 18 deletions(-) create mode 100644 src/BattleTechMcpTools/BattleTechMcpTools.cs create mode 100644 src/BattleTechMcpTools/BattleTechMcpTools.csproj create mode 100644 src/BattleTechMcpTools/BattleTechOverrideSupplier.cs diff --git a/src/BattleTechMcpTools/BattleTechMcpTools.cs b/src/BattleTechMcpTools/BattleTechMcpTools.cs new file mode 100644 index 0000000000..24944788d9 --- /dev/null +++ b/src/BattleTechMcpTools/BattleTechMcpTools.cs @@ -0,0 +1,759 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Spice86.Core.Emulator.Mcp; +using Spice86.Core.Emulator.Memory; +using Spice86.Shared.Emulator.Mouse; +using Spice86.Shared.Emulator.Keyboard; +using Spice86.Core.Emulator.VM; +using Spice86.Core.Emulator.Devices.Input.Keyboard; +using Spice86.Shared.Utils; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace BattleTechMcpTools; + +[McpServerToolType] +public sealed class BattleTechMcpTools +{ + // DS-relative offsets for BattleTech data structures. + // Physical address computed at runtime as (DS << 4) + Offset. + private const ushort StateArrayOff = 0xD30C; + private const ushort StorySlotsOff = 0xC724; + private const ushort UnitSlotsOff = 0xC614; + private const ushort CursorXOff = 0xA44B; + private const ushort CursorYOff = 0xA44D; + private const ushort CreditsOff = 0xD370; + private const ushort FogGridAOff = 0x40B4; + private const ushort FogGridBOff = 0x41D4; + private const ushort CombatUnitXOff = 0x4004; + private const ushort CombatUnitYOff = 0x4036; + private const ushort CombatUnitStatusOff = 0x406A; + private const ushort TrainingCompleteOff = 0xD450; + private const ushort MilestoneOff = 0xD451; + + private const int StateArraySize = 256; + private const int StorySlotSize = 125; + private const int StorySlotCount = 8; + private const int UnitSlotSize = 17; + private const int UnitSlotCount = 8; + private const int CombatUnitCount = 24; + private const int FogGridRows = 12; + private const int FogGridCols = 24; + + private static readonly JsonSerializerOptions SerializerOptions = new() + { + Converters = { new JsonStringEnumConverter() } + }; + + private readonly EmulatorMcpServices _services; + + public BattleTechMcpTools(EmulatorMcpServices services) + { + _services = services; + } + + private CallToolResult Success(object response) + { + JsonElement structuredContent = JsonSerializer.SerializeToElement( + response, response.GetType(), SerializerOptions); + return new CallToolResult { StructuredContent = structuredContent }; + } + + private CallToolResult Error(string message) + { + JsonElement structuredContent = JsonSerializer.SerializeToElement( + new { success = false, message }, SerializerOptions); + return new CallToolResult + { + IsError = true, + StructuredContent = structuredContent, + Content = [new TextContentBlock { Text = message }] + }; + } + + private CallToolResult ExecuteTool( + Func action, [CallerMemberName] string methodName = "") + { + try + { + return Success(action()); + } + catch (ArgumentException ex) { return Error(ex.Message); } + catch (InvalidOperationException ex) { return Error(ex.Message); } + catch (KeyNotFoundException ex) { return Error(ex.Message); } + catch (FormatException ex) { return Error(ex.Message); } + catch (OverflowException ex) { return Error(ex.Message); } + } + + private ushort DsSegment => _services.State.DS; + private uint DsAddr(ushort off) => (uint)((DsSegment << 4) + off); + private IMemory Memory => _services.Memory; + + // ────────────────────────────────────────────── + // BattleTech State Tools + // ────────────────────────────────────────────── + + [McpServerTool(Name = "bt_read_state_array", UseStructuredContent = true)] + [Description("Read the 256-byte BattleTech StateArray (DS:0xD30C). Returns hex dump + named fields.")] + public CallToolResult ReadStateArray() + { + return ExecuteTool(() => + { + uint addr = DsAddr(StateArrayOff); + byte[] data = Memory.GetData(addr, StateArraySize); + return new + { + Segmented = $"DS:0x{StateArrayOff:X4}", + Physical = $"0x{addr:X}", + Size = StateArraySize, + Hex = Convert.ToHexString(data), + Fields = new + { + TrainingComplete = data[0x50], + Milestone = data[0x51], + CacheFound = data[0x52], + WinScene = data[0x53], + ShopSelectionSlot = data[0x14], + StoryStateVariant = data[0x0E], + EncounterMask = data[0x24], + DecrementTarget = data[0x23] + } + }; + }); + } + + [McpServerTool(Name = "bt_write_state_array", UseStructuredContent = true)] + [Description("Write bytes to BattleTech StateArray (DS:0xD30C). Params: offset (0-255), data (hex string).")] + public CallToolResult WriteStateArray(int offset, string data) + { + return ExecuteTool(() => + { + if (offset < 0 || offset >= StateArraySize) + throw new ArgumentException($"Offset must be 0-{StateArraySize - 1}"); + byte[] bytes = Convert.FromHexString(data); + if (offset + bytes.Length > StateArraySize) + throw new ArgumentException("Data overflow"); + uint baseAddr = DsAddr(StateArrayOff); + for (int i = 0; i < bytes.Length; i++) + Memory.UInt8[baseAddr + (uint)(offset + i)] = bytes[i]; + byte[] readBack = Memory.GetData(baseAddr + (uint)offset, (uint)bytes.Length); + return new + { + Segmented = $"DS:0x{StateArrayOff + (ushort)offset:X4}", + Written = Convert.ToHexString(bytes), + ReadBack = Convert.ToHexString(readBack) + }; + }); + } + + [McpServerTool(Name = "bt_read_story_slot", UseStructuredContent = true)] + [Description("Read a story slot (DS:0xC724 + index*0x7D). Index 0-7. Returns hex + parsed fields.")] + public CallToolResult ReadStorySlot(int slotIndex) + { + return ExecuteTool(() => + { + if (slotIndex < 0 || slotIndex >= StorySlotCount) + throw new ArgumentException($"Slot index 0-{StorySlotCount - 1}"); + uint slotOff = (uint)(StorySlotsOff + slotIndex * StorySlotSize); + uint addr = DsAddr((ushort)slotOff); + byte[] data = Memory.GetData(addr, StorySlotSize); + string name = ReadPaddedString(addr, 16); + return new + { + SlotIndex = slotIndex, + Segmented = $"DS:0x{slotOff:X4}", + Physical = $"0x{addr:X}", + Hex = Convert.ToHexString(data), + StoryFields = new + { + StatusByte = data[0x00], + FlagsLow = data[0x04], + FlagsHigh = data[0x05], + TimingNibble = data[0x06], + CounterA = data[0x55], + CounterB = data[0x56], + StoryState = data[0x57], + LatchMarker = data[0x58], + LinkedUnitSlot = data[0x79], + SecondaryUnitSlot = data[0x7A], + MechID = data[0x7B] + }, + MechFields = new + { + Name = name, + Tonnage = data[0x10], + CurrentArmour = ToByteArray(data, 0x11, 11), + CurrentStructure = ToByteArray(data, 0x1C, 8), + CurrentAmmo = ToByteArray(data, 0x27, 10), + WalkMove = data[0x33], + JumpMove = data[0x34], + MaxArmour = ToByteArray(data, 0x58, 11), + MaxStructure = ToByteArray(data, 0x63, 8), + MaxAmmo = ToByteArray(data, 0x6F, 10) + } + }; + }); + } + + [McpServerTool(Name = "bt_read_unit_slot", UseStructuredContent = true)] + [Description("Read a unit slot (DS:0xC614 + index*0x11). Index 0-7. Returns TypeId, attrs, inventory.")] + public CallToolResult ReadUnitSlot(int slotIndex) + { + return ExecuteTool(() => + { + if (slotIndex < 0 || slotIndex >= UnitSlotCount) + throw new ArgumentException($"Slot index 0-{UnitSlotCount - 1}"); + uint slotOff = (uint)(UnitSlotsOff + slotIndex * UnitSlotSize); + uint addr = DsAddr((ushort)slotOff); + byte[] data = Memory.GetData(addr, UnitSlotSize); + return new + { + SlotIndex = slotIndex, + Segmented = $"DS:0x{slotOff:X4}", + Physical = $"0x{addr:X}", + TypeId = data[0x00], + IsEmpty = data[0x00] == 0xFF, + Attr1 = data[0x01], + Attr2 = data[0x02], + Attr3 = data[0x03], + Inventory = new { S0 = data[0x04], S1 = data[0x05], S2 = data[0x06], + S3 = data[0x07], S4 = data[0x08], S5 = data[0x09], S6 = data[0x0A] }, + LinkedStorySlot = data[0x0C], + DerivedAttr = data[0x0F] + }; + }); + } + + [McpServerTool(Name = "bt_read_cursor", UseStructuredContent = true)] + [Description("Read world map cursor (DS:0xA44B/A44D, two uint16). Returns raw + tile coords.")] + public CallToolResult ReadCursor() + { + return ExecuteTool(() => + { + ushort rawX = Memory.UInt16[DsAddr(CursorXOff)]; + ushort rawY = Memory.UInt16[DsAddr(CursorYOff)]; + return new + { + SegmentedX = $"DS:0x{CursorXOff:X4}", + SegmentedY = $"DS:0x{CursorYOff:X4}", + RawX = rawX, + RawY = rawY, + TileX = (rawX & 0x7F) >> 1, + TileY = (rawY & 0x7F) >> 1, + TileIndex = ((rawY & 0x7F) >> 1) * 64 + ((rawX & 0x7F) >> 1), + HexX = $"0x{rawX:X4}", + HexY = $"0x{rawY:X4}" + }; + }); + } + + [McpServerTool(Name = "bt_read_credits", UseStructuredContent = true)] + [Description("Read C-Bills (DS:0xD370, uint32). Returns integer value.")] + public CallToolResult ReadCredits() + { + return ExecuteTool(() => + { + uint credits = Memory.UInt32[DsAddr(CreditsOff)]; + return new { Credits = (int)credits, Hex = $"0x{credits:X8}" }; + }); + } + + [McpServerTool(Name = "bt_read_flags", UseStructuredContent = true)] + [Description("Read TrainingComplete (DS:0xD450) and Milestone (DS:0xD451) flags.")] + public CallToolResult ReadFlags() + { + return ExecuteTool(() => + { + byte training = Memory.UInt8[DsAddr(TrainingCompleteOff)]; + byte milestone = Memory.UInt8[DsAddr(MilestoneOff)]; + return new + { + TrainingComplete = training != 0, + TrainingByte = training, + Milestone = milestone != 0, + MilestoneByte = milestone + }; + }); + } + + [McpServerTool(Name = "bt_read_combat_grids", UseStructuredContent = true)] + [Description("Read both combat fog grids (12×24, DS:0x40B4 and 0x41D4). Returns 2D arrays.")] + public CallToolResult ReadCombatGrids() + { + return ExecuteTool(() => + { + byte[] a = Memory.GetData(DsAddr(FogGridAOff), FogGridRows * FogGridCols); + byte[] b = Memory.GetData(DsAddr(FogGridBOff), FogGridRows * FogGridCols); + return new + { + GridA = new + { + Segmented = $"DS:0x{FogGridAOff:X4}", + Rows = FogGridRows, + Cols = FogGridCols, + Data = To2DArray(a, FogGridRows, FogGridCols), + Hex = Convert.ToHexString(a) + }, + GridB = new + { + Segmented = $"DS:0x{FogGridBOff:X4}", + Rows = FogGridRows, + Cols = FogGridCols, + Data = To2DArray(b, FogGridRows, FogGridCols), + Hex = Convert.ToHexString(b) + } + }; + }); + } + + [McpServerTool(Name = "bt_read_combat_units", UseStructuredContent = true)] + [Description("Read 24 combat unit positions/statuses (DS:0x4004, 0x4036, 0x406A).")] + public CallToolResult ReadCombatUnits() + { + return ExecuteTool(() => + { + var units = new List(); + for (int i = 0; i < CombatUnitCount; i++) + { + ushort x = Memory.UInt16[DsAddr((ushort)(CombatUnitXOff + i * 2))]; + ushort y = Memory.UInt16[DsAddr((ushort)(CombatUnitYOff + i * 2))]; + ushort status = Memory.UInt16[DsAddr((ushort)(CombatUnitStatusOff + i * 2))]; + units.Add(new { UnitId = i, X = x, Y = y, Status = status, IsActive = status != 0 }); + } + return new { Units = units }; + }); + } + + [McpServerTool(Name = "bt_get_state", UseStructuredContent = true)] + [Description("Comprehensive game state snapshot: state array, cursor, credits, flags, active counts.")] + public CallToolResult GetState() + { + return ExecuteTool(() => + { + byte[] stateArr = Memory.GetData(DsAddr(StateArrayOff), 64); + ushort rawX = Memory.UInt16[DsAddr(CursorXOff)]; + ushort rawY = Memory.UInt16[DsAddr(CursorYOff)]; + uint credits = Memory.UInt32[DsAddr(CreditsOff)]; + byte training = Memory.UInt8[DsAddr(TrainingCompleteOff)]; + byte milestone = Memory.UInt8[DsAddr(MilestoneOff)]; + + int activeSlots = 0; + for (int i = 0; i < StorySlotCount; i++) + if (Memory.UInt8[DsAddr((ushort)(StorySlotsOff + i * StorySlotSize))] != 0xFF) + activeSlots++; + + int activeUnits = 0; + for (int i = 0; i < CombatUnitCount; i++) + if (Memory.UInt16[DsAddr((ushort)(CombatUnitStatusOff + i * 2))] != 0) + activeUnits++; + + return new + { + StateArrayHex = Convert.ToHexString(stateArr), + Cursor = new + { + RawX = rawX, RawY = rawY, + TileX = (rawX & 0x7F) >> 1, + TileY = (rawY & 0x7F) >> 1 + }, + Credits = credits, + Flags = new { TrainingComplete = training != 0, Milestone = milestone != 0 }, + ActiveStorySlots = activeSlots, + ActiveCombatUnits = activeUnits, + DsSegment = DsSegment + }; + }); + } + + [McpServerTool(Name = "bt_read_registers", UseStructuredContent = true)] + [Description("Read current emulated CPU registers: segment regs (CS, DS, ES, FS, GS, SS), general regs (AX, BX, CX, DX, SI, DI, BP, SP), and IP.")] + public CallToolResult ReadRegisters() + { + return ExecuteTool(() => + { + var s = _services.State; + return new + { + Segments = new { CS = s.CS, DS = s.DS, ES = s.ES, FS = s.FS, GS = s.GS, SS = s.SS }, + Registers16 = new { AX = s.AX, BX = s.BX, CX = s.CX, DX = s.DX, SI = s.SI, DI = s.DI, BP = s.BP, SP = s.SP }, + IP = s.IP, + PhysicalIP = $"0x{s.IpPhysicalAddress:X}", + ZeroFlag = s.ZeroFlag, + CarryFlag = s.CarryFlag, + Cycles = s.Cycles + }; + }); + } + + // ────────────────────────────────────────────── + // Generic Memory Tools + // ────────────────────────────────────────────── + + [McpServerTool(Name = "bt_read_memory", UseStructuredContent = true)] + [Description("Read raw emulated memory at a physical address. Params: address (uint), length (1-65536). Returns hex dump.")] + public CallToolResult ReadMemory(uint address, int length) + { + return ExecuteTool(() => + { + if (length <= 0 || length > 65536) + throw new ArgumentException("Length 1-65536"); + if (address + (uint)length > Memory.Length) + throw new ArgumentException("Exceeds memory bounds"); + byte[] data = Memory.GetData(address, (uint)length); + return new + { + Address = $"0x{address:X}", + Length = length, + Hex = Convert.ToHexString(data), + Segmented = FormatSegmented(address) + }; + }); + } + + [McpServerTool(Name = "bt_write_memory", UseStructuredContent = true)] + [Description("Write bytes to emulated memory at a physical address. Params: address (uint), data (hex string). Returns readback.")] + public CallToolResult WriteMemory(uint address, string data) + { + return ExecuteTool(() => + { + byte[] bytes = Convert.FromHexString(data); + if (address + (uint)bytes.Length > Memory.Length) + throw new ArgumentException("Exceeds memory bounds"); + for (int i = 0; i < bytes.Length; i++) + Memory.UInt8[address + (uint)i] = bytes[i]; + byte[] readBack = Memory.GetData(address, (uint)bytes.Length); + return new + { + Address = $"0x{address:X}", + Written = Convert.ToHexString(bytes), + ReadBack = Convert.ToHexString(readBack) + }; + }); + } + + [McpServerTool(Name = "bt_read_ds", UseStructuredContent = true)] + [Description("Read memory at a DS-relative offset. Params: offset (ushort, hex e.g. 0xD30C), length (1-65536). Returns hex dump at (DS<<4)+offset.")] + public CallToolResult ReadDs(ushort offset, int length) + { + return ExecuteTool(() => + { + if (length <= 0 || length > 65536) + throw new ArgumentException("Length 1-65536"); + uint addr = DsAddr(offset); + byte[] data = Memory.GetData(addr, (uint)length); + return new + { + Segmented = $"DS:0x{offset:X4}", + Physical = $"0x{addr:X}", + Ds = DsSegment, + Length = length, + Hex = Convert.ToHexString(data) + }; + }); + } + + [McpServerTool(Name = "bt_read_string", UseStructuredContent = true)] + [Description("Read null-terminated string at a physical address. Params: address (uint), maxLength (default 256).")] + public CallToolResult ReadString(uint address, int maxLength = 256) + { + return ExecuteTool(() => + { + if (maxLength <= 0 || maxLength > 4096) + throw new ArgumentException("maxLength 1-4096"); + string str = Memory.GetZeroTerminatedString(address, maxLength); + return new + { + Address = $"0x{address:X}", + Length = str.Length, + Text = str, + Segmented = FormatSegmented(address) + }; + }); + } + + // ────────────────────────────────────────────── + // Keyboard Input Tools + // ────────────────────────────────────────────── + + [McpServerTool(Name = "bt_send_key", UseStructuredContent = true)] + [Description("Send a keyboard key event. Params: key (string, e.g. 'Enter', 'Escape', 'F1', 'A'), isPressed (bool). For press-release call twice.")] + public CallToolResult SendKey(string key, bool isPressed) + { + return ExecuteTool(() => + { + if (!Enum.TryParse(key, true, out PcKeyboardKey parsedKey)) + throw new ArgumentException($"Invalid key: '{key}'"); + PhysicalKey pk = KeyboardScancodeConverter.ConvertToPhysicalKey(parsedKey); + if (pk == PhysicalKey.None) + throw new ArgumentException($"No mapping for '{key}'"); + InputEventHub? hub = _services.InputEventHub; + if (hub == null) + throw new InvalidOperationException("InputEventHub not available"); + hub.PostKeyboardEvent(new KeyboardEventArgs(pk, isPressed)); + return new { Success = true, Key = key, Action = isPressed ? "down" : "up" }; + }); + } + + [McpServerTool(Name = "bt_type_text", UseStructuredContent = true)] + [Description("Type a text string via keyboard events. Only printable ASCII letters/digits/space/simple punctuation.")] + public CallToolResult TypeText(string text) + { + return ExecuteTool(() => + { + InputEventHub? hub = _services.InputEventHub; + if (hub == null) + throw new InvalidOperationException("InputEventHub not available"); + int count = 0; + foreach (char ch in text) + { + if (TryGetPhysicalKey(ch, out PhysicalKey pk)) + { + hub.PostKeyboardEvent(new KeyboardEventArgs(pk, true)); + hub.PostKeyboardEvent(new KeyboardEventArgs(pk, false)); + count++; + } + } + return new { Success = true, CharactersTyped = count, Text = text }; + }); + } + + [McpServerTool(Name = "bt_press_enter", UseStructuredContent = true)] + [Description("Press and release the Enter key.")] + public CallToolResult PressEnter() + { + return ExecuteTool(() => + { + InputEventHub? hub = _services.InputEventHub; + if (hub == null) + throw new InvalidOperationException("InputEventHub not available"); + PhysicalKey enter = KeyboardScancodeConverter.ConvertToPhysicalKey(PcKeyboardKey.Enter); + hub.PostKeyboardEvent(new KeyboardEventArgs(enter, true)); + hub.PostKeyboardEvent(new KeyboardEventArgs(enter, false)); + return new { Success = true, Action = "Enter pressed" }; + }); + } + + [McpServerTool(Name = "bt_press_escape", UseStructuredContent = true)] + [Description("Press and release the Escape key.")] + public CallToolResult PressEscape() + { + return ExecuteTool(() => + { + InputEventHub? hub = _services.InputEventHub; + if (hub == null) + throw new InvalidOperationException("InputEventHub not available"); + PhysicalKey esc = KeyboardScancodeConverter.ConvertToPhysicalKey(PcKeyboardKey.Escape); + hub.PostKeyboardEvent(new KeyboardEventArgs(esc, true)); + hub.PostKeyboardEvent(new KeyboardEventArgs(esc, false)); + return new { Success = true, Action = "Escape pressed" }; + }); + } + + [McpServerTool(Name = "bt_inject_key", UseStructuredContent = true)] + [Description("Directly inject a key into the BIOS keyboard buffer. " + + "Pauses emulator for atomic access. Params: ascii (int 0-255), scanCode (int 0-255). " + + "Key code = (scanCode << 8) | ascii. " + + "Standard keys: ascii=char code, scanCode=PC scancode. " + + "Extended keys (arrows): ascii=0, scanCode=0x48/0x50/0x4B/0x4D (up/down/left/right).")] + public CallToolResult InjectKey(int ascii, int scanCode) + { + return ExecuteTool(() => + { + if (ascii < 0 || ascii > 255) + throw new ArgumentException("ascii must be 0-255"); + if (scanCode < 0 || scanCode > 255) + throw new ArgumentException("scanCode must be 0-255"); + + ushort keyCode = (ushort)((scanCode << 8) | ascii); + + var pauseHandler = _services.PauseHandler; + var buffer = _services.BiosKeyboardBuffer; + + // Pause emulator for atomic buffer access + pauseHandler.RequestPause("bt_inject_key"); + + // Spin-wait for emulator to actually pause + int timeout = 500; + while (!pauseHandler.IsPaused && timeout > 0) + { + System.Threading.Thread.Sleep(1); + timeout--; + } + + bool paused = pauseHandler.IsPaused; + bool result = false; + if (paused && buffer != null) + { + result = buffer.EnqueueKeyCode(keyCode); + pauseHandler.Resume(); + } + else if (buffer == null) + { + // Fallback: write directly via Memory at physical 0x041E-0x043D + // Use same circular buffer logic as BiosKeyboardBuffer + pauseHandler.Resume(); // Already not paused or we resume + result = FallbackInjectKey(keyCode); + } + else + { + pauseHandler.Resume(); + } + + return new + { + Success = result, + KeyCode = $"0x{keyCode:X4}", + Ascii = (byte)ascii, + ScanCode = (byte)scanCode, + WasPaused = paused, + Char = ascii >= 32 && ascii <= 126 ? ((char)ascii).ToString() : null + }; + }); + } + + [McpServerTool(Name = "bt_press_key", UseStructuredContent = true)] + [Description("Press and release a key (injects ascii+scanCode into BIOS buffer twice).")] + public CallToolResult PressKey(int ascii, int scanCode) + { + return ExecuteTool(() => + { + var pauseHandler = _services.PauseHandler; + var buffer = _services.BiosKeyboardBuffer; + + pauseHandler.RequestPause("bt_press_key"); + + int timeout = 500; + while (!pauseHandler.IsPaused && timeout > 0) + { + System.Threading.Thread.Sleep(1); + timeout--; + } + + bool result = false; + if (pauseHandler.IsPaused && buffer != null) + { + ushort keyCode = (ushort)((scanCode << 8) | ascii); + result = buffer.EnqueueKeyCode(keyCode); + result = buffer.EnqueueKeyCode(keyCode) && result; + pauseHandler.Resume(); + } + else + { + pauseHandler.Resume(); + } + + return new + { + Success = result, + Ascii = (byte)ascii, + ScanCode = (byte)scanCode, + Char = ascii >= 32 && ascii <= 126 ? ((char)ascii).ToString() : null + }; + }); + } + + private bool FallbackInjectKey(ushort keyCode) + { + const ushort bufStart = 0x041E; + const ushort bufEnd = 0x043E; + + ushort tail = _services.Memory.UInt16[0x041C]; + ushort head = _services.Memory.UInt16[0x041A]; + + if (tail < bufStart || tail >= bufEnd) + tail = bufStart; + + ushort newTail = (ushort)(tail + 2); + if (newTail >= bufEnd) + newTail = bufStart; + + if (newTail == head) + return false; + + _services.Memory.UInt16[0, tail] = keyCode; + _services.Memory.UInt16[0x041C] = newTail; + return true; + } + + // ────────────────────────────────────────────── + // Helpers + // ────────────────────────────────────────────── + + private string ReadPaddedString(uint addr, int maxLen) + { + Span buf = stackalloc byte[maxLen]; + for (int i = 0; i < maxLen; i++) + buf[i] = Memory.UInt8[addr + (uint)i]; + int nullIdx = buf.IndexOf((byte)0); + int len = nullIdx >= 0 ? nullIdx : maxLen; + return System.Text.Encoding.ASCII.GetString(buf[..len]); + } + + private static byte[] ToByteArray(byte[] data, int offset, int count) + { + byte[] result = new byte[count]; + Array.Copy(data, offset, result, 0, count); + return result; + } + + private static int[][] To2DArray(byte[] flat, int rows, int cols) + { + int[][] result = new int[rows][]; + for (int r = 0; r < rows; r++) + { + result[r] = new int[cols]; + for (int c = 0; c < cols; c++) + result[r][c] = flat[r * cols + c]; + } + return result; + } + + private static string FormatSegmented(uint physicalAddress) + { + ushort segment = MemoryUtils.ToSegment(physicalAddress); + ushort offset = (ushort)(physicalAddress & 0x0F); + return $"{segment:X4}:{offset:X4}"; + } + + private static bool TryGetPhysicalKey(char ch, out PhysicalKey key) + { + key = PhysicalKey.None; + if (ch >= 'a' && ch <= 'z') + return TryParseKey($"A{char.ToUpperInvariant(ch)}", out key); + if (ch >= 'A' && ch <= 'Z') + return TryParseKey($"A{ch}", out key); + if (ch >= '0' && ch <= '9') + return TryParseKey($"D{ch}", out key); + return ch switch + { + ' ' => TryParseKey("Space", out key), + '.' => TryParseKey("Period", out key), + ',' => TryParseKey("Comma", out key), + '-' => TryParseKey("Minus", out key), + '=' => TryParseKey("Equals", out key), + '/' => TryParseKey("Slash", out key), + ';' => TryParseKey("Semicolon", out key), + '\'' => TryParseKey("Apostrophe", out key), + '[' => TryParseKey("LeftBracket", out key), + ']' => TryParseKey("RightBracket", out key), + '\\' => TryParseKey("Backslash", out key), + '`' => TryParseKey("GraveAccent", out key), + '\t' => TryParseKey("Tab", out key), + '\n' => TryParseKey("Enter", out key), + _ => false + }; + } + + private static bool TryParseKey(string name, out PhysicalKey key) + { + if (Enum.TryParse(name, true, out PcKeyboardKey pk)) + { + key = KeyboardScancodeConverter.ConvertToPhysicalKey(pk); + return key != PhysicalKey.None; + } + key = PhysicalKey.None; + return false; + } +} diff --git a/src/BattleTechMcpTools/BattleTechMcpTools.csproj b/src/BattleTechMcpTools/BattleTechMcpTools.csproj new file mode 100644 index 0000000000..87209c204b --- /dev/null +++ b/src/BattleTechMcpTools/BattleTechMcpTools.csproj @@ -0,0 +1,11 @@ + + + BattleTechMcpTools + + + + + + + + diff --git a/src/BattleTechMcpTools/BattleTechOverrideSupplier.cs b/src/BattleTechMcpTools/BattleTechOverrideSupplier.cs new file mode 100644 index 0000000000..33b994317f --- /dev/null +++ b/src/BattleTechMcpTools/BattleTechOverrideSupplier.cs @@ -0,0 +1,29 @@ +using Spice86.Core.CLI; +using Spice86.Core.Emulator.Function; +using Spice86.Core.Emulator.Mcp; +using Spice86.Core.Emulator.VM; +using Spice86.Shared.Emulator.Memory; +using Spice86.Shared.Interfaces; +using System.Reflection; + +namespace BattleTechMcpTools; + +public class BattleTechOverrideSupplier : IOverrideSupplier, IMcpToolSupplier +{ + public IDictionary GenerateFunctionInformations( + ILoggerService loggerService, Configuration configuration, + ushort programStartAddress, Machine machine) + { + return new Dictionary(); + } + + public IEnumerable GetMcpToolAssemblies() + { + return [typeof(BattleTechMcpTools).Assembly]; + } + + public IEnumerable GetMcpServices() + { + return []; + } +} diff --git a/src/Spice86.Core/Emulator/InterruptHandlers/VGA/Data/RegisterValueSet.cs b/src/Spice86.Core/Emulator/InterruptHandlers/VGA/Data/RegisterValueSet.cs index 1294604ba0..4070054477 100644 --- a/src/Spice86.Core/Emulator/InterruptHandlers/VGA/Data/RegisterValueSet.cs +++ b/src/Spice86.Core/Emulator/InterruptHandlers/VGA/Data/RegisterValueSet.cs @@ -54,6 +54,7 @@ internal readonly struct RegisterValueSet { [0x11] = new VideoMode(new VgaMode(MemoryModel.Planar, 640, 480, 1, 8, 16, VgaConstants.GraphicsSegment), 0xFF, Palettes.Ega, SequencerRegisterValueSet6, 0xe3, CrtControllerRegisterValueSet9, AttributeControllerRegisterValueSet8, GraphicsControllerRegisterValueSet5), [0x12] = new VideoMode(new VgaMode(MemoryModel.Planar, 640, 480, 4, 8, 16, VgaConstants.GraphicsSegment), 0xFF, Palettes.Ega, SequencerRegisterValueSet6, 0xe3, CrtControllerRegisterValueSet9, AttributeControllerRegisterValueSet7, GraphicsControllerRegisterValueSet5), [0x13] = new VideoMode(new VgaMode(MemoryModel.Packed, 320, 200, 8, 8, 8, VgaConstants.GraphicsSegment), 0xFF, Palettes.Vga, SequencerRegisterValueSet7, 0x63, CrtControllerRegisterValueSet10, AttributeControllerRegisterValueSet9, GraphicsControllerRegisterValueSet6), + [0x69] = new VideoMode(new VgaMode(MemoryModel.Planar, 640, 480, 4, 8, 16, VgaConstants.GraphicsSegment), 0xFF, Palettes.Ega, SequencerRegisterValueSet6, 0xe3, CrtControllerRegisterValueSet11, AttributeControllerRegisterValueSet7, GraphicsControllerRegisterValueSet5), [0x6A] = new VideoMode(new VgaMode(MemoryModel.Planar, 800, 600, 4, 8, 16, VgaConstants.GraphicsSegment), 0xFF, Palettes.Ega, SequencerRegisterValueSet6, 0xe3, CrtControllerRegisterValueSet11, AttributeControllerRegisterValueSet7, GraphicsControllerRegisterValueSet5) }; } \ No newline at end of file diff --git a/src/Spice86.Core/Emulator/Mcp/McpHttpHost.cs b/src/Spice86.Core/Emulator/Mcp/McpHttpHost.cs index 1133f467e6..4a5c5352a2 100644 --- a/src/Spice86.Core/Emulator/Mcp/McpHttpHost.cs +++ b/src/Spice86.Core/Emulator/Mcp/McpHttpHost.cs @@ -56,14 +56,12 @@ public void Start(EmulatorMcpServices services, int port = 8081, }; }) .WithHttpTransport(options => { - // Stateful mode enables the GET /mcp endpoint for SSE-based - // client-to-server communication and allows the MCP client SDK - // (used by opencode remote MCP) to negotiate sessions via the - // initialize handshake with Mcp-Session-Id headers. - // Previously stateless mode was used, but AI coding agents - // now support the standard Streamable HTTP transport with - // session negotiation natively. - options.Stateless = false; + // Stateless mode keeps things simple — no session management, + // no Mcp-Session-Id headers, compatible with AI clients. + // The SDK does not map GET /mcp in stateless mode (POST only), + // but we add our own GET handler below to satisfy clients + // (like opencode remote MCP) that probe with GET first. + options.Stateless = true; }) .WithToolsFromAssembly(typeof(EmulatorMcpTools).Assembly); @@ -73,10 +71,7 @@ public void Start(EmulatorMcpServices services, int port = 8081, } } - builder.WebHost.ConfigureKestrel(kestrel => { - kestrel.ListenLocalhost(port); - }); - + builder.WebHost.UseUrls($"http://localhost:{port}"); _app = builder.Build(); _app.MapGet("/health", () => Results.Json(new { status = "ok", @@ -84,6 +79,19 @@ public void Start(EmulatorMcpServices services, int port = 8081, })); _app.MapMcp("/mcp"); + // The SDK doesn't map GET /mcp in stateless mode, but some MCP clients + // (including opencode remote MCP) probe with GET first. Return a minimal + // SSE endpoint event telling the client to POST to the same URL. + _app.MapGet("/mcp", async (HttpContext context) => + { + context.Response.Headers.ContentType = "text/event-stream"; + context.Response.Headers.CacheControl = "no-cache,no-store"; + context.Response.Headers["X-Accel-Buffering"] = "no"; + await context.Response.WriteAsync( + $"event: endpoint\ndata: /mcp/\n\n"); + await context.Response.Body.FlushAsync(); + }); + _serverThread = new Thread(RunServerLoop) { Name = "McpHttpHost", IsBackground = true @@ -98,12 +106,20 @@ private void RunServerLoop() { } try { - _app.Run(); + _app.StartAsync().GetAwaiter().GetResult(); + _loggerService.Information("MCP HTTP server is now listening"); + // Block this thread indefinitely so the server keeps running. + // Using _app.Run() instead caused premature shutdown on background + // threads because ConsoleLifetime has no console to watch on a + // non-main thread. } catch (ObjectDisposedException) { // Host disposed while thread was exiting. - } catch (InvalidOperationException ex) { - _loggerService.Error(ex, "MCP HTTP server stopped unexpectedly"); + } catch (Exception ex) { + _loggerService.Error(ex, "MCP HTTP server crashed"); } + + // Wait forever so the thread never exits. + new System.Threading.ManualResetEvent(false).WaitOne(); } public void Dispose() { diff --git a/src/Spice86.Core/Emulator/OperatingSystem/DosDriveManager.cs b/src/Spice86.Core/Emulator/OperatingSystem/DosDriveManager.cs index c387e723dd..13a0ffb9f9 100644 --- a/src/Spice86.Core/Emulator/OperatingSystem/DosDriveManager.cs +++ b/src/Spice86.Core/Emulator/OperatingSystem/DosDriveManager.cs @@ -48,8 +48,8 @@ public DosDriveManager(ILoggerService loggerService, string? cDriveFolderPath, s cDriveFolderPath = DosPathResolver.GetExeParentFolder(executablePath); } cDriveFolderPath = ConvertUtils.ToSlashFolderPath(cDriveFolderPath); - _driveMap[GetDriveIndex('A')] = new VirtualDrive() { DriveLetter = 'A', MountedHostDirectory = "" }; - _driveMap[GetDriveIndex('B')] = new VirtualDrive() { DriveLetter = 'B', MountedHostDirectory = "" }; + _driveMap[GetDriveIndex('A')] = new VirtualDrive() { DriveLetter = 'A', MountedHostDirectory = cDriveFolderPath }; + _driveMap[GetDriveIndex('B')] = new VirtualDrive() { DriveLetter = 'B', MountedHostDirectory = cDriveFolderPath }; var cDrive = new VirtualDrive { DriveLetter = 'C', MountedHostDirectory = cDriveFolderPath }; _driveMap[GetDriveIndex('C')] = cDrive; CurrentDrive = cDrive; diff --git a/src/Spice86.Core/Emulator/OperatingSystem/Structures/DosDriveBase.cs b/src/Spice86.Core/Emulator/OperatingSystem/Structures/DosDriveBase.cs index cdd170f7c8..d1540080d8 100644 --- a/src/Spice86.Core/Emulator/OperatingSystem/Structures/DosDriveBase.cs +++ b/src/Spice86.Core/Emulator/OperatingSystem/Structures/DosDriveBase.cs @@ -32,7 +32,7 @@ public abstract class DosDriveBase { /// /// Gets the absolute path to the current DOS directory in use on the drive. /// - public string CurrentDosDirectory { get; set; } = ""; + public string CurrentDosDirectory { get; set; } = string.Empty; /// /// Gets if it is a network drive. Not supported, always diff --git a/src/Spice86.sln b/src/Spice86.sln index 8896323a6b..8ca8ae6b5b 100644 --- a/src/Spice86.sln +++ b/src/Spice86.sln @@ -29,6 +29,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Spice86.Storage.Fat", "Spic EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Spice86.Storage.Cd", "Spice86.Storage.Cd\Spice86.Storage.Cd.csproj", "{058F9104-71C6-4544-A7C6-ADC0DD71FEF8}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BattleTechMcpTools", "BattleTechMcpTools\BattleTechMcpTools.csproj", "{191D3DE1-DB65-4B39-BDF2-14C95A75144E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -147,6 +149,18 @@ Global {058F9104-71C6-4544-A7C6-ADC0DD71FEF8}.Release|x64.Build.0 = Release|Any CPU {058F9104-71C6-4544-A7C6-ADC0DD71FEF8}.Release|x86.ActiveCfg = Release|Any CPU {058F9104-71C6-4544-A7C6-ADC0DD71FEF8}.Release|x86.Build.0 = Release|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x64.ActiveCfg = Debug|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x64.Build.0 = Debug|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x86.ActiveCfg = Debug|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x86.Build.0 = Debug|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|Any CPU.Build.0 = Release|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x64.ActiveCfg = Release|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x64.Build.0 = Release|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x86.ActiveCfg = Release|Any CPU + {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From 34b6efd1fb2f238ba9dc09dbab5d0b32a28a0406 Mon Sep 17 00:00:00 2001 From: Edoardo BAROLO Date: Fri, 31 Jul 2026 12:06:20 +0000 Subject: [PATCH 3/4] feat: add bt_screenshot/bt_read_video_mode MCP tools, fix DsAddr to fixed game data segment - bt_screenshot renders the emulator display as ASCII art: text modes (0x03) read char/attr pairs from B800:0000 as 80x25 text; graphics modes (0x13/0x0D/0x0E) sample A000:0000 pixels into an 80x50 luminance grid via the standard VGA 16-color palette. - bt_read_video_mode reports the BIOS mode number + description. - DsAddr now uses fixed GameDataSegment (0x1DE9) instead of the runtime DS register, which varies per scene (0x1DE9=world map, 0x3858=building interiors). --- src/BattleTechMcpTools/BattleTechMcpTools.cs | 168 ++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/src/BattleTechMcpTools/BattleTechMcpTools.cs b/src/BattleTechMcpTools/BattleTechMcpTools.cs index 24944788d9..14d5c7ae55 100644 --- a/src/BattleTechMcpTools/BattleTechMcpTools.cs +++ b/src/BattleTechMcpTools/BattleTechMcpTools.cs @@ -33,6 +33,11 @@ public sealed class BattleTechMcpTools private const ushort TrainingCompleteOff = 0xD450; private const ushort MilestoneOff = 0xD451; + // Fixed segment for BattleTech game data. The runtime DS register can + // vary (0x1DE9=world map, 0x3858=building, etc.) but the data structures + // always live at (0x1DE9 << 4) + offset in physical memory. + private const ushort GameDataSegment = 0x1DE9; + private const int StateArraySize = 256; private const int StorySlotSize = 125; private const int StorySlotCount = 8; @@ -88,7 +93,7 @@ private CallToolResult ExecuteTool( } private ushort DsSegment => _services.State.DS; - private uint DsAddr(ushort off) => (uint)((DsSegment << 4) + off); + private uint DsAddr(ushort off) => (uint)((GameDataSegment << 4) + off); private IMemory Memory => _services.Memory; // ────────────────────────────────────────────── @@ -473,6 +478,167 @@ public CallToolResult ReadString(uint address, int maxLength = 256) }); } + // ────────────────────────────────────────────── + // Video / Screenshot Tools + // ────────────────────────────────────────────── + + // Standard VGA 16-color palette (R,G,B each 0-63) for brightness calculation + private static readonly byte[,] VgaPaletteRgb = { + { 0, 0, 0 }, // 0 black + { 0, 0, 42 }, // 1 blue + { 0, 42, 0 }, // 2 green + { 0, 42, 42 }, // 3 cyan + { 42, 0, 0 }, // 4 red + { 42, 0, 42 }, // 5 magenta + { 42, 21, 0 }, // 6 brown + { 42, 42, 42 }, // 7 light gray + { 21, 21, 21 }, // 8 dark gray + { 21, 21, 63 }, // 9 light blue + { 21, 63, 21 }, // 10 light green + { 21, 63, 63 }, // 11 light cyan + { 63, 21, 21 }, // 12 light red + { 63, 21, 63 }, // 13 light magenta + { 63, 63, 21 }, // 14 yellow + { 63, 63, 63 }, // 15 bright white + }; + + private static readonly char[] AsciiRamp = " .:-=+*#%@".ToCharArray(); + + [McpServerTool(Name = "bt_screenshot", UseStructuredContent = true)] + [Description("Capture the emulator's display as ASCII art. " + + "Reads video mode from BIOS (0x0040:0x0049). " + + "For text modes (0x03): reads char/attr pairs from B800:0000, renders 80x25 text. " + + "For graphics modes (0x13/0x0D/0x0E): reads pixel data from A000:0000, " + + "converts to 80x50 ASCII via VGA palette brightness. " + + "Returns the text rendering + video mode info.")] + public CallToolResult ScreenShot() + { + return ExecuteTool(() => + { + byte videoMode = Memory.UInt8[0x449]; + string modeDesc; + string asciiArt; + int outCols, outRows; + + if (videoMode == 0x03 || videoMode == 0x01 || videoMode == 0x02 || + videoMode == 0x07 || videoMode == 0x00) + { + // ── Text mode ── + modeDesc = $"Text 80x25 (mode 0x{videoMode:X2})"; + const int textCols = 80, textRows = 25; + uint fbAddr = 0xB8000; + byte[] buffer = Memory.GetData(fbAddr, (uint)(textCols * textRows * 2)); + + var sb = new System.Text.StringBuilder(); + sb.Append('+').Append(new string('-', textCols)).AppendLine("+"); + for (int r = 0; r < textRows; r++) + { + sb.Append('|'); + for (int c = 0; c < textCols; c++) + { + int idx = (r * textCols + c) * 2; + byte ch = idx < buffer.Length ? buffer[idx] : (byte)0; + byte attr = idx + 1 < buffer.Length ? buffer[idx + 1] : (byte)0; + // Use blink/intensity bits to vary brightness + char display = (ch >= 0x20 && ch <= 0x7E) ? (char)ch : + (ch == 0xB0 ? '.' : (ch == 0xDB ? '#' : ' ')); + sb.Append(display); + } + sb.Append('|').Append('\n'); + } + sb.Append('+').Append(new string('-', textCols)).Append('+'); + asciiArt = sb.ToString(); + outCols = textCols; outRows = textRows; + } + else + { + // ── Graphics mode ── + int width = videoMode == 0x0E ? 640 : 320; + int height = 200; + modeDesc = videoMode switch + { + 0x13 => "VGA 320x200 256-color (mode 13h)", + 0x0D => "EGA 320x200 16-color (mode 0Dh)", + 0x0E => "EGA 640x200 16-color (mode 0Eh)", + _ => $"Graphics (0x{videoMode:X2})" + }; + + uint fbAddr = 0xA0000; + byte[] pixels = Memory.GetData(fbAddr, (uint)(width * height)); + + // Brightness lookup from standard VGA 16-color palette + int[] brightnessLevel = new int[256]; + for (int i = 0; i < 256; i++) + { + int ci = i & 0x0F; + int r = VgaPaletteRgb[ci, 0]; + int g = VgaPaletteRgb[ci, 1]; + int b = VgaPaletteRgb[ci, 2]; + int lum = (r * 299 + g * 587 + b * 114) / 630; + if (lum > 9) lum = 9; + brightnessLevel[i] = lum; + } + + int sampleW = 4, sampleH = 4; + int cols = width / sampleW; + int rows = height / sampleH; + var sb = new System.Text.StringBuilder(); + sb.Append('+').Append(new string('-', cols)).AppendLine("+"); + + for (int y = 0; y < height; y += sampleH) + { + sb.Append('|'); + for (int x = 0; x < width; x += sampleW) + { + int idx = y * width + x; + byte color = idx < pixels.Length ? pixels[idx] : (byte)0; + sb.Append(AsciiRamp[brightnessLevel[color]]); + } + sb.AppendLine("|"); + } + sb.Append('+').Append(new string('-', cols)).Append('+'); + asciiArt = sb.ToString(); + outCols = cols; outRows = rows; + } + + return new + { + VideoMode = new + { + BiosAddress = "0x0040:0x0049", + Mode = (int)videoMode, + Description = modeDesc + }, + AsciiArt = asciiArt, + Dimensions = new { Cols = outCols, Rows = outRows } + }; + }); + } + + [McpServerTool(Name = "bt_read_video_mode", UseStructuredContent = true)] + [Description("Read current video mode from BIOS data area (0x0040:0x0049). Returns mode number + description.")] + public CallToolResult ReadVideoMode() + { + return ExecuteTool(() => + { + byte videoMode = Memory.UInt8[0x449]; + return new + { + BiosAddress = "0x0040:0x0049", + Mode = (int)videoMode, + Description = videoMode switch + { + 0x13 => "VGA 320x200 256-color (mode 13h)", + 0x0D => "EGA 320x200 16-color (mode 0Dh)", + 0x0E => "EGA 640x200 16-color (mode 0Eh)", + 0x03 => "Text 80x25 (mode 03h)", + 0x12 => "VGA 640x480 16-color (mode 12h)", + _ => $"Unknown (0x{videoMode:X2})" + } + }; + }); + } + // ────────────────────────────────────────────── // Keyboard Input Tools // ────────────────────────────────────────────── From 9e77265f6b9834a6f89d4939e7e319e2411dcea4 Mon Sep 17 00:00:00 2001 From: Edoardo BAROLO Date: Fri, 31 Jul 2026 12:09:18 +0000 Subject: [PATCH 4/4] chore: remove BattleTech-specific MCP tools from repository The BattleTechMcpTools project (bt_* tools + BattleTechOverrideSupplier) is game-specific and does not belong in the Spice86 codebase. It now lives in the UnBattletech project (AIATTEMPT) and is referenced from there. The generic MCP transport fixes (UseUrls, StartAsync, GET /mcp SSE handler) and the A:/B: drive mount fix remain. --- src/BattleTechMcpTools/BattleTechMcpTools.cs | 925 ------------------ .../BattleTechMcpTools.csproj | 11 - .../BattleTechOverrideSupplier.cs | 29 - src/Spice86.sln | 14 - 4 files changed, 979 deletions(-) delete mode 100644 src/BattleTechMcpTools/BattleTechMcpTools.cs delete mode 100644 src/BattleTechMcpTools/BattleTechMcpTools.csproj delete mode 100644 src/BattleTechMcpTools/BattleTechOverrideSupplier.cs diff --git a/src/BattleTechMcpTools/BattleTechMcpTools.cs b/src/BattleTechMcpTools/BattleTechMcpTools.cs deleted file mode 100644 index 14d5c7ae55..0000000000 --- a/src/BattleTechMcpTools/BattleTechMcpTools.cs +++ /dev/null @@ -1,925 +0,0 @@ -using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; -using Spice86.Core.Emulator.Mcp; -using Spice86.Core.Emulator.Memory; -using Spice86.Shared.Emulator.Mouse; -using Spice86.Shared.Emulator.Keyboard; -using Spice86.Core.Emulator.VM; -using Spice86.Core.Emulator.Devices.Input.Keyboard; -using Spice86.Shared.Utils; -using System.ComponentModel; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace BattleTechMcpTools; - -[McpServerToolType] -public sealed class BattleTechMcpTools -{ - // DS-relative offsets for BattleTech data structures. - // Physical address computed at runtime as (DS << 4) + Offset. - private const ushort StateArrayOff = 0xD30C; - private const ushort StorySlotsOff = 0xC724; - private const ushort UnitSlotsOff = 0xC614; - private const ushort CursorXOff = 0xA44B; - private const ushort CursorYOff = 0xA44D; - private const ushort CreditsOff = 0xD370; - private const ushort FogGridAOff = 0x40B4; - private const ushort FogGridBOff = 0x41D4; - private const ushort CombatUnitXOff = 0x4004; - private const ushort CombatUnitYOff = 0x4036; - private const ushort CombatUnitStatusOff = 0x406A; - private const ushort TrainingCompleteOff = 0xD450; - private const ushort MilestoneOff = 0xD451; - - // Fixed segment for BattleTech game data. The runtime DS register can - // vary (0x1DE9=world map, 0x3858=building, etc.) but the data structures - // always live at (0x1DE9 << 4) + offset in physical memory. - private const ushort GameDataSegment = 0x1DE9; - - private const int StateArraySize = 256; - private const int StorySlotSize = 125; - private const int StorySlotCount = 8; - private const int UnitSlotSize = 17; - private const int UnitSlotCount = 8; - private const int CombatUnitCount = 24; - private const int FogGridRows = 12; - private const int FogGridCols = 24; - - private static readonly JsonSerializerOptions SerializerOptions = new() - { - Converters = { new JsonStringEnumConverter() } - }; - - private readonly EmulatorMcpServices _services; - - public BattleTechMcpTools(EmulatorMcpServices services) - { - _services = services; - } - - private CallToolResult Success(object response) - { - JsonElement structuredContent = JsonSerializer.SerializeToElement( - response, response.GetType(), SerializerOptions); - return new CallToolResult { StructuredContent = structuredContent }; - } - - private CallToolResult Error(string message) - { - JsonElement structuredContent = JsonSerializer.SerializeToElement( - new { success = false, message }, SerializerOptions); - return new CallToolResult - { - IsError = true, - StructuredContent = structuredContent, - Content = [new TextContentBlock { Text = message }] - }; - } - - private CallToolResult ExecuteTool( - Func action, [CallerMemberName] string methodName = "") - { - try - { - return Success(action()); - } - catch (ArgumentException ex) { return Error(ex.Message); } - catch (InvalidOperationException ex) { return Error(ex.Message); } - catch (KeyNotFoundException ex) { return Error(ex.Message); } - catch (FormatException ex) { return Error(ex.Message); } - catch (OverflowException ex) { return Error(ex.Message); } - } - - private ushort DsSegment => _services.State.DS; - private uint DsAddr(ushort off) => (uint)((GameDataSegment << 4) + off); - private IMemory Memory => _services.Memory; - - // ────────────────────────────────────────────── - // BattleTech State Tools - // ────────────────────────────────────────────── - - [McpServerTool(Name = "bt_read_state_array", UseStructuredContent = true)] - [Description("Read the 256-byte BattleTech StateArray (DS:0xD30C). Returns hex dump + named fields.")] - public CallToolResult ReadStateArray() - { - return ExecuteTool(() => - { - uint addr = DsAddr(StateArrayOff); - byte[] data = Memory.GetData(addr, StateArraySize); - return new - { - Segmented = $"DS:0x{StateArrayOff:X4}", - Physical = $"0x{addr:X}", - Size = StateArraySize, - Hex = Convert.ToHexString(data), - Fields = new - { - TrainingComplete = data[0x50], - Milestone = data[0x51], - CacheFound = data[0x52], - WinScene = data[0x53], - ShopSelectionSlot = data[0x14], - StoryStateVariant = data[0x0E], - EncounterMask = data[0x24], - DecrementTarget = data[0x23] - } - }; - }); - } - - [McpServerTool(Name = "bt_write_state_array", UseStructuredContent = true)] - [Description("Write bytes to BattleTech StateArray (DS:0xD30C). Params: offset (0-255), data (hex string).")] - public CallToolResult WriteStateArray(int offset, string data) - { - return ExecuteTool(() => - { - if (offset < 0 || offset >= StateArraySize) - throw new ArgumentException($"Offset must be 0-{StateArraySize - 1}"); - byte[] bytes = Convert.FromHexString(data); - if (offset + bytes.Length > StateArraySize) - throw new ArgumentException("Data overflow"); - uint baseAddr = DsAddr(StateArrayOff); - for (int i = 0; i < bytes.Length; i++) - Memory.UInt8[baseAddr + (uint)(offset + i)] = bytes[i]; - byte[] readBack = Memory.GetData(baseAddr + (uint)offset, (uint)bytes.Length); - return new - { - Segmented = $"DS:0x{StateArrayOff + (ushort)offset:X4}", - Written = Convert.ToHexString(bytes), - ReadBack = Convert.ToHexString(readBack) - }; - }); - } - - [McpServerTool(Name = "bt_read_story_slot", UseStructuredContent = true)] - [Description("Read a story slot (DS:0xC724 + index*0x7D). Index 0-7. Returns hex + parsed fields.")] - public CallToolResult ReadStorySlot(int slotIndex) - { - return ExecuteTool(() => - { - if (slotIndex < 0 || slotIndex >= StorySlotCount) - throw new ArgumentException($"Slot index 0-{StorySlotCount - 1}"); - uint slotOff = (uint)(StorySlotsOff + slotIndex * StorySlotSize); - uint addr = DsAddr((ushort)slotOff); - byte[] data = Memory.GetData(addr, StorySlotSize); - string name = ReadPaddedString(addr, 16); - return new - { - SlotIndex = slotIndex, - Segmented = $"DS:0x{slotOff:X4}", - Physical = $"0x{addr:X}", - Hex = Convert.ToHexString(data), - StoryFields = new - { - StatusByte = data[0x00], - FlagsLow = data[0x04], - FlagsHigh = data[0x05], - TimingNibble = data[0x06], - CounterA = data[0x55], - CounterB = data[0x56], - StoryState = data[0x57], - LatchMarker = data[0x58], - LinkedUnitSlot = data[0x79], - SecondaryUnitSlot = data[0x7A], - MechID = data[0x7B] - }, - MechFields = new - { - Name = name, - Tonnage = data[0x10], - CurrentArmour = ToByteArray(data, 0x11, 11), - CurrentStructure = ToByteArray(data, 0x1C, 8), - CurrentAmmo = ToByteArray(data, 0x27, 10), - WalkMove = data[0x33], - JumpMove = data[0x34], - MaxArmour = ToByteArray(data, 0x58, 11), - MaxStructure = ToByteArray(data, 0x63, 8), - MaxAmmo = ToByteArray(data, 0x6F, 10) - } - }; - }); - } - - [McpServerTool(Name = "bt_read_unit_slot", UseStructuredContent = true)] - [Description("Read a unit slot (DS:0xC614 + index*0x11). Index 0-7. Returns TypeId, attrs, inventory.")] - public CallToolResult ReadUnitSlot(int slotIndex) - { - return ExecuteTool(() => - { - if (slotIndex < 0 || slotIndex >= UnitSlotCount) - throw new ArgumentException($"Slot index 0-{UnitSlotCount - 1}"); - uint slotOff = (uint)(UnitSlotsOff + slotIndex * UnitSlotSize); - uint addr = DsAddr((ushort)slotOff); - byte[] data = Memory.GetData(addr, UnitSlotSize); - return new - { - SlotIndex = slotIndex, - Segmented = $"DS:0x{slotOff:X4}", - Physical = $"0x{addr:X}", - TypeId = data[0x00], - IsEmpty = data[0x00] == 0xFF, - Attr1 = data[0x01], - Attr2 = data[0x02], - Attr3 = data[0x03], - Inventory = new { S0 = data[0x04], S1 = data[0x05], S2 = data[0x06], - S3 = data[0x07], S4 = data[0x08], S5 = data[0x09], S6 = data[0x0A] }, - LinkedStorySlot = data[0x0C], - DerivedAttr = data[0x0F] - }; - }); - } - - [McpServerTool(Name = "bt_read_cursor", UseStructuredContent = true)] - [Description("Read world map cursor (DS:0xA44B/A44D, two uint16). Returns raw + tile coords.")] - public CallToolResult ReadCursor() - { - return ExecuteTool(() => - { - ushort rawX = Memory.UInt16[DsAddr(CursorXOff)]; - ushort rawY = Memory.UInt16[DsAddr(CursorYOff)]; - return new - { - SegmentedX = $"DS:0x{CursorXOff:X4}", - SegmentedY = $"DS:0x{CursorYOff:X4}", - RawX = rawX, - RawY = rawY, - TileX = (rawX & 0x7F) >> 1, - TileY = (rawY & 0x7F) >> 1, - TileIndex = ((rawY & 0x7F) >> 1) * 64 + ((rawX & 0x7F) >> 1), - HexX = $"0x{rawX:X4}", - HexY = $"0x{rawY:X4}" - }; - }); - } - - [McpServerTool(Name = "bt_read_credits", UseStructuredContent = true)] - [Description("Read C-Bills (DS:0xD370, uint32). Returns integer value.")] - public CallToolResult ReadCredits() - { - return ExecuteTool(() => - { - uint credits = Memory.UInt32[DsAddr(CreditsOff)]; - return new { Credits = (int)credits, Hex = $"0x{credits:X8}" }; - }); - } - - [McpServerTool(Name = "bt_read_flags", UseStructuredContent = true)] - [Description("Read TrainingComplete (DS:0xD450) and Milestone (DS:0xD451) flags.")] - public CallToolResult ReadFlags() - { - return ExecuteTool(() => - { - byte training = Memory.UInt8[DsAddr(TrainingCompleteOff)]; - byte milestone = Memory.UInt8[DsAddr(MilestoneOff)]; - return new - { - TrainingComplete = training != 0, - TrainingByte = training, - Milestone = milestone != 0, - MilestoneByte = milestone - }; - }); - } - - [McpServerTool(Name = "bt_read_combat_grids", UseStructuredContent = true)] - [Description("Read both combat fog grids (12×24, DS:0x40B4 and 0x41D4). Returns 2D arrays.")] - public CallToolResult ReadCombatGrids() - { - return ExecuteTool(() => - { - byte[] a = Memory.GetData(DsAddr(FogGridAOff), FogGridRows * FogGridCols); - byte[] b = Memory.GetData(DsAddr(FogGridBOff), FogGridRows * FogGridCols); - return new - { - GridA = new - { - Segmented = $"DS:0x{FogGridAOff:X4}", - Rows = FogGridRows, - Cols = FogGridCols, - Data = To2DArray(a, FogGridRows, FogGridCols), - Hex = Convert.ToHexString(a) - }, - GridB = new - { - Segmented = $"DS:0x{FogGridBOff:X4}", - Rows = FogGridRows, - Cols = FogGridCols, - Data = To2DArray(b, FogGridRows, FogGridCols), - Hex = Convert.ToHexString(b) - } - }; - }); - } - - [McpServerTool(Name = "bt_read_combat_units", UseStructuredContent = true)] - [Description("Read 24 combat unit positions/statuses (DS:0x4004, 0x4036, 0x406A).")] - public CallToolResult ReadCombatUnits() - { - return ExecuteTool(() => - { - var units = new List(); - for (int i = 0; i < CombatUnitCount; i++) - { - ushort x = Memory.UInt16[DsAddr((ushort)(CombatUnitXOff + i * 2))]; - ushort y = Memory.UInt16[DsAddr((ushort)(CombatUnitYOff + i * 2))]; - ushort status = Memory.UInt16[DsAddr((ushort)(CombatUnitStatusOff + i * 2))]; - units.Add(new { UnitId = i, X = x, Y = y, Status = status, IsActive = status != 0 }); - } - return new { Units = units }; - }); - } - - [McpServerTool(Name = "bt_get_state", UseStructuredContent = true)] - [Description("Comprehensive game state snapshot: state array, cursor, credits, flags, active counts.")] - public CallToolResult GetState() - { - return ExecuteTool(() => - { - byte[] stateArr = Memory.GetData(DsAddr(StateArrayOff), 64); - ushort rawX = Memory.UInt16[DsAddr(CursorXOff)]; - ushort rawY = Memory.UInt16[DsAddr(CursorYOff)]; - uint credits = Memory.UInt32[DsAddr(CreditsOff)]; - byte training = Memory.UInt8[DsAddr(TrainingCompleteOff)]; - byte milestone = Memory.UInt8[DsAddr(MilestoneOff)]; - - int activeSlots = 0; - for (int i = 0; i < StorySlotCount; i++) - if (Memory.UInt8[DsAddr((ushort)(StorySlotsOff + i * StorySlotSize))] != 0xFF) - activeSlots++; - - int activeUnits = 0; - for (int i = 0; i < CombatUnitCount; i++) - if (Memory.UInt16[DsAddr((ushort)(CombatUnitStatusOff + i * 2))] != 0) - activeUnits++; - - return new - { - StateArrayHex = Convert.ToHexString(stateArr), - Cursor = new - { - RawX = rawX, RawY = rawY, - TileX = (rawX & 0x7F) >> 1, - TileY = (rawY & 0x7F) >> 1 - }, - Credits = credits, - Flags = new { TrainingComplete = training != 0, Milestone = milestone != 0 }, - ActiveStorySlots = activeSlots, - ActiveCombatUnits = activeUnits, - DsSegment = DsSegment - }; - }); - } - - [McpServerTool(Name = "bt_read_registers", UseStructuredContent = true)] - [Description("Read current emulated CPU registers: segment regs (CS, DS, ES, FS, GS, SS), general regs (AX, BX, CX, DX, SI, DI, BP, SP), and IP.")] - public CallToolResult ReadRegisters() - { - return ExecuteTool(() => - { - var s = _services.State; - return new - { - Segments = new { CS = s.CS, DS = s.DS, ES = s.ES, FS = s.FS, GS = s.GS, SS = s.SS }, - Registers16 = new { AX = s.AX, BX = s.BX, CX = s.CX, DX = s.DX, SI = s.SI, DI = s.DI, BP = s.BP, SP = s.SP }, - IP = s.IP, - PhysicalIP = $"0x{s.IpPhysicalAddress:X}", - ZeroFlag = s.ZeroFlag, - CarryFlag = s.CarryFlag, - Cycles = s.Cycles - }; - }); - } - - // ────────────────────────────────────────────── - // Generic Memory Tools - // ────────────────────────────────────────────── - - [McpServerTool(Name = "bt_read_memory", UseStructuredContent = true)] - [Description("Read raw emulated memory at a physical address. Params: address (uint), length (1-65536). Returns hex dump.")] - public CallToolResult ReadMemory(uint address, int length) - { - return ExecuteTool(() => - { - if (length <= 0 || length > 65536) - throw new ArgumentException("Length 1-65536"); - if (address + (uint)length > Memory.Length) - throw new ArgumentException("Exceeds memory bounds"); - byte[] data = Memory.GetData(address, (uint)length); - return new - { - Address = $"0x{address:X}", - Length = length, - Hex = Convert.ToHexString(data), - Segmented = FormatSegmented(address) - }; - }); - } - - [McpServerTool(Name = "bt_write_memory", UseStructuredContent = true)] - [Description("Write bytes to emulated memory at a physical address. Params: address (uint), data (hex string). Returns readback.")] - public CallToolResult WriteMemory(uint address, string data) - { - return ExecuteTool(() => - { - byte[] bytes = Convert.FromHexString(data); - if (address + (uint)bytes.Length > Memory.Length) - throw new ArgumentException("Exceeds memory bounds"); - for (int i = 0; i < bytes.Length; i++) - Memory.UInt8[address + (uint)i] = bytes[i]; - byte[] readBack = Memory.GetData(address, (uint)bytes.Length); - return new - { - Address = $"0x{address:X}", - Written = Convert.ToHexString(bytes), - ReadBack = Convert.ToHexString(readBack) - }; - }); - } - - [McpServerTool(Name = "bt_read_ds", UseStructuredContent = true)] - [Description("Read memory at a DS-relative offset. Params: offset (ushort, hex e.g. 0xD30C), length (1-65536). Returns hex dump at (DS<<4)+offset.")] - public CallToolResult ReadDs(ushort offset, int length) - { - return ExecuteTool(() => - { - if (length <= 0 || length > 65536) - throw new ArgumentException("Length 1-65536"); - uint addr = DsAddr(offset); - byte[] data = Memory.GetData(addr, (uint)length); - return new - { - Segmented = $"DS:0x{offset:X4}", - Physical = $"0x{addr:X}", - Ds = DsSegment, - Length = length, - Hex = Convert.ToHexString(data) - }; - }); - } - - [McpServerTool(Name = "bt_read_string", UseStructuredContent = true)] - [Description("Read null-terminated string at a physical address. Params: address (uint), maxLength (default 256).")] - public CallToolResult ReadString(uint address, int maxLength = 256) - { - return ExecuteTool(() => - { - if (maxLength <= 0 || maxLength > 4096) - throw new ArgumentException("maxLength 1-4096"); - string str = Memory.GetZeroTerminatedString(address, maxLength); - return new - { - Address = $"0x{address:X}", - Length = str.Length, - Text = str, - Segmented = FormatSegmented(address) - }; - }); - } - - // ────────────────────────────────────────────── - // Video / Screenshot Tools - // ────────────────────────────────────────────── - - // Standard VGA 16-color palette (R,G,B each 0-63) for brightness calculation - private static readonly byte[,] VgaPaletteRgb = { - { 0, 0, 0 }, // 0 black - { 0, 0, 42 }, // 1 blue - { 0, 42, 0 }, // 2 green - { 0, 42, 42 }, // 3 cyan - { 42, 0, 0 }, // 4 red - { 42, 0, 42 }, // 5 magenta - { 42, 21, 0 }, // 6 brown - { 42, 42, 42 }, // 7 light gray - { 21, 21, 21 }, // 8 dark gray - { 21, 21, 63 }, // 9 light blue - { 21, 63, 21 }, // 10 light green - { 21, 63, 63 }, // 11 light cyan - { 63, 21, 21 }, // 12 light red - { 63, 21, 63 }, // 13 light magenta - { 63, 63, 21 }, // 14 yellow - { 63, 63, 63 }, // 15 bright white - }; - - private static readonly char[] AsciiRamp = " .:-=+*#%@".ToCharArray(); - - [McpServerTool(Name = "bt_screenshot", UseStructuredContent = true)] - [Description("Capture the emulator's display as ASCII art. " - + "Reads video mode from BIOS (0x0040:0x0049). " - + "For text modes (0x03): reads char/attr pairs from B800:0000, renders 80x25 text. " - + "For graphics modes (0x13/0x0D/0x0E): reads pixel data from A000:0000, " - + "converts to 80x50 ASCII via VGA palette brightness. " - + "Returns the text rendering + video mode info.")] - public CallToolResult ScreenShot() - { - return ExecuteTool(() => - { - byte videoMode = Memory.UInt8[0x449]; - string modeDesc; - string asciiArt; - int outCols, outRows; - - if (videoMode == 0x03 || videoMode == 0x01 || videoMode == 0x02 || - videoMode == 0x07 || videoMode == 0x00) - { - // ── Text mode ── - modeDesc = $"Text 80x25 (mode 0x{videoMode:X2})"; - const int textCols = 80, textRows = 25; - uint fbAddr = 0xB8000; - byte[] buffer = Memory.GetData(fbAddr, (uint)(textCols * textRows * 2)); - - var sb = new System.Text.StringBuilder(); - sb.Append('+').Append(new string('-', textCols)).AppendLine("+"); - for (int r = 0; r < textRows; r++) - { - sb.Append('|'); - for (int c = 0; c < textCols; c++) - { - int idx = (r * textCols + c) * 2; - byte ch = idx < buffer.Length ? buffer[idx] : (byte)0; - byte attr = idx + 1 < buffer.Length ? buffer[idx + 1] : (byte)0; - // Use blink/intensity bits to vary brightness - char display = (ch >= 0x20 && ch <= 0x7E) ? (char)ch : - (ch == 0xB0 ? '.' : (ch == 0xDB ? '#' : ' ')); - sb.Append(display); - } - sb.Append('|').Append('\n'); - } - sb.Append('+').Append(new string('-', textCols)).Append('+'); - asciiArt = sb.ToString(); - outCols = textCols; outRows = textRows; - } - else - { - // ── Graphics mode ── - int width = videoMode == 0x0E ? 640 : 320; - int height = 200; - modeDesc = videoMode switch - { - 0x13 => "VGA 320x200 256-color (mode 13h)", - 0x0D => "EGA 320x200 16-color (mode 0Dh)", - 0x0E => "EGA 640x200 16-color (mode 0Eh)", - _ => $"Graphics (0x{videoMode:X2})" - }; - - uint fbAddr = 0xA0000; - byte[] pixels = Memory.GetData(fbAddr, (uint)(width * height)); - - // Brightness lookup from standard VGA 16-color palette - int[] brightnessLevel = new int[256]; - for (int i = 0; i < 256; i++) - { - int ci = i & 0x0F; - int r = VgaPaletteRgb[ci, 0]; - int g = VgaPaletteRgb[ci, 1]; - int b = VgaPaletteRgb[ci, 2]; - int lum = (r * 299 + g * 587 + b * 114) / 630; - if (lum > 9) lum = 9; - brightnessLevel[i] = lum; - } - - int sampleW = 4, sampleH = 4; - int cols = width / sampleW; - int rows = height / sampleH; - var sb = new System.Text.StringBuilder(); - sb.Append('+').Append(new string('-', cols)).AppendLine("+"); - - for (int y = 0; y < height; y += sampleH) - { - sb.Append('|'); - for (int x = 0; x < width; x += sampleW) - { - int idx = y * width + x; - byte color = idx < pixels.Length ? pixels[idx] : (byte)0; - sb.Append(AsciiRamp[brightnessLevel[color]]); - } - sb.AppendLine("|"); - } - sb.Append('+').Append(new string('-', cols)).Append('+'); - asciiArt = sb.ToString(); - outCols = cols; outRows = rows; - } - - return new - { - VideoMode = new - { - BiosAddress = "0x0040:0x0049", - Mode = (int)videoMode, - Description = modeDesc - }, - AsciiArt = asciiArt, - Dimensions = new { Cols = outCols, Rows = outRows } - }; - }); - } - - [McpServerTool(Name = "bt_read_video_mode", UseStructuredContent = true)] - [Description("Read current video mode from BIOS data area (0x0040:0x0049). Returns mode number + description.")] - public CallToolResult ReadVideoMode() - { - return ExecuteTool(() => - { - byte videoMode = Memory.UInt8[0x449]; - return new - { - BiosAddress = "0x0040:0x0049", - Mode = (int)videoMode, - Description = videoMode switch - { - 0x13 => "VGA 320x200 256-color (mode 13h)", - 0x0D => "EGA 320x200 16-color (mode 0Dh)", - 0x0E => "EGA 640x200 16-color (mode 0Eh)", - 0x03 => "Text 80x25 (mode 03h)", - 0x12 => "VGA 640x480 16-color (mode 12h)", - _ => $"Unknown (0x{videoMode:X2})" - } - }; - }); - } - - // ────────────────────────────────────────────── - // Keyboard Input Tools - // ────────────────────────────────────────────── - - [McpServerTool(Name = "bt_send_key", UseStructuredContent = true)] - [Description("Send a keyboard key event. Params: key (string, e.g. 'Enter', 'Escape', 'F1', 'A'), isPressed (bool). For press-release call twice.")] - public CallToolResult SendKey(string key, bool isPressed) - { - return ExecuteTool(() => - { - if (!Enum.TryParse(key, true, out PcKeyboardKey parsedKey)) - throw new ArgumentException($"Invalid key: '{key}'"); - PhysicalKey pk = KeyboardScancodeConverter.ConvertToPhysicalKey(parsedKey); - if (pk == PhysicalKey.None) - throw new ArgumentException($"No mapping for '{key}'"); - InputEventHub? hub = _services.InputEventHub; - if (hub == null) - throw new InvalidOperationException("InputEventHub not available"); - hub.PostKeyboardEvent(new KeyboardEventArgs(pk, isPressed)); - return new { Success = true, Key = key, Action = isPressed ? "down" : "up" }; - }); - } - - [McpServerTool(Name = "bt_type_text", UseStructuredContent = true)] - [Description("Type a text string via keyboard events. Only printable ASCII letters/digits/space/simple punctuation.")] - public CallToolResult TypeText(string text) - { - return ExecuteTool(() => - { - InputEventHub? hub = _services.InputEventHub; - if (hub == null) - throw new InvalidOperationException("InputEventHub not available"); - int count = 0; - foreach (char ch in text) - { - if (TryGetPhysicalKey(ch, out PhysicalKey pk)) - { - hub.PostKeyboardEvent(new KeyboardEventArgs(pk, true)); - hub.PostKeyboardEvent(new KeyboardEventArgs(pk, false)); - count++; - } - } - return new { Success = true, CharactersTyped = count, Text = text }; - }); - } - - [McpServerTool(Name = "bt_press_enter", UseStructuredContent = true)] - [Description("Press and release the Enter key.")] - public CallToolResult PressEnter() - { - return ExecuteTool(() => - { - InputEventHub? hub = _services.InputEventHub; - if (hub == null) - throw new InvalidOperationException("InputEventHub not available"); - PhysicalKey enter = KeyboardScancodeConverter.ConvertToPhysicalKey(PcKeyboardKey.Enter); - hub.PostKeyboardEvent(new KeyboardEventArgs(enter, true)); - hub.PostKeyboardEvent(new KeyboardEventArgs(enter, false)); - return new { Success = true, Action = "Enter pressed" }; - }); - } - - [McpServerTool(Name = "bt_press_escape", UseStructuredContent = true)] - [Description("Press and release the Escape key.")] - public CallToolResult PressEscape() - { - return ExecuteTool(() => - { - InputEventHub? hub = _services.InputEventHub; - if (hub == null) - throw new InvalidOperationException("InputEventHub not available"); - PhysicalKey esc = KeyboardScancodeConverter.ConvertToPhysicalKey(PcKeyboardKey.Escape); - hub.PostKeyboardEvent(new KeyboardEventArgs(esc, true)); - hub.PostKeyboardEvent(new KeyboardEventArgs(esc, false)); - return new { Success = true, Action = "Escape pressed" }; - }); - } - - [McpServerTool(Name = "bt_inject_key", UseStructuredContent = true)] - [Description("Directly inject a key into the BIOS keyboard buffer. " - + "Pauses emulator for atomic access. Params: ascii (int 0-255), scanCode (int 0-255). " - + "Key code = (scanCode << 8) | ascii. " - + "Standard keys: ascii=char code, scanCode=PC scancode. " - + "Extended keys (arrows): ascii=0, scanCode=0x48/0x50/0x4B/0x4D (up/down/left/right).")] - public CallToolResult InjectKey(int ascii, int scanCode) - { - return ExecuteTool(() => - { - if (ascii < 0 || ascii > 255) - throw new ArgumentException("ascii must be 0-255"); - if (scanCode < 0 || scanCode > 255) - throw new ArgumentException("scanCode must be 0-255"); - - ushort keyCode = (ushort)((scanCode << 8) | ascii); - - var pauseHandler = _services.PauseHandler; - var buffer = _services.BiosKeyboardBuffer; - - // Pause emulator for atomic buffer access - pauseHandler.RequestPause("bt_inject_key"); - - // Spin-wait for emulator to actually pause - int timeout = 500; - while (!pauseHandler.IsPaused && timeout > 0) - { - System.Threading.Thread.Sleep(1); - timeout--; - } - - bool paused = pauseHandler.IsPaused; - bool result = false; - if (paused && buffer != null) - { - result = buffer.EnqueueKeyCode(keyCode); - pauseHandler.Resume(); - } - else if (buffer == null) - { - // Fallback: write directly via Memory at physical 0x041E-0x043D - // Use same circular buffer logic as BiosKeyboardBuffer - pauseHandler.Resume(); // Already not paused or we resume - result = FallbackInjectKey(keyCode); - } - else - { - pauseHandler.Resume(); - } - - return new - { - Success = result, - KeyCode = $"0x{keyCode:X4}", - Ascii = (byte)ascii, - ScanCode = (byte)scanCode, - WasPaused = paused, - Char = ascii >= 32 && ascii <= 126 ? ((char)ascii).ToString() : null - }; - }); - } - - [McpServerTool(Name = "bt_press_key", UseStructuredContent = true)] - [Description("Press and release a key (injects ascii+scanCode into BIOS buffer twice).")] - public CallToolResult PressKey(int ascii, int scanCode) - { - return ExecuteTool(() => - { - var pauseHandler = _services.PauseHandler; - var buffer = _services.BiosKeyboardBuffer; - - pauseHandler.RequestPause("bt_press_key"); - - int timeout = 500; - while (!pauseHandler.IsPaused && timeout > 0) - { - System.Threading.Thread.Sleep(1); - timeout--; - } - - bool result = false; - if (pauseHandler.IsPaused && buffer != null) - { - ushort keyCode = (ushort)((scanCode << 8) | ascii); - result = buffer.EnqueueKeyCode(keyCode); - result = buffer.EnqueueKeyCode(keyCode) && result; - pauseHandler.Resume(); - } - else - { - pauseHandler.Resume(); - } - - return new - { - Success = result, - Ascii = (byte)ascii, - ScanCode = (byte)scanCode, - Char = ascii >= 32 && ascii <= 126 ? ((char)ascii).ToString() : null - }; - }); - } - - private bool FallbackInjectKey(ushort keyCode) - { - const ushort bufStart = 0x041E; - const ushort bufEnd = 0x043E; - - ushort tail = _services.Memory.UInt16[0x041C]; - ushort head = _services.Memory.UInt16[0x041A]; - - if (tail < bufStart || tail >= bufEnd) - tail = bufStart; - - ushort newTail = (ushort)(tail + 2); - if (newTail >= bufEnd) - newTail = bufStart; - - if (newTail == head) - return false; - - _services.Memory.UInt16[0, tail] = keyCode; - _services.Memory.UInt16[0x041C] = newTail; - return true; - } - - // ────────────────────────────────────────────── - // Helpers - // ────────────────────────────────────────────── - - private string ReadPaddedString(uint addr, int maxLen) - { - Span buf = stackalloc byte[maxLen]; - for (int i = 0; i < maxLen; i++) - buf[i] = Memory.UInt8[addr + (uint)i]; - int nullIdx = buf.IndexOf((byte)0); - int len = nullIdx >= 0 ? nullIdx : maxLen; - return System.Text.Encoding.ASCII.GetString(buf[..len]); - } - - private static byte[] ToByteArray(byte[] data, int offset, int count) - { - byte[] result = new byte[count]; - Array.Copy(data, offset, result, 0, count); - return result; - } - - private static int[][] To2DArray(byte[] flat, int rows, int cols) - { - int[][] result = new int[rows][]; - for (int r = 0; r < rows; r++) - { - result[r] = new int[cols]; - for (int c = 0; c < cols; c++) - result[r][c] = flat[r * cols + c]; - } - return result; - } - - private static string FormatSegmented(uint physicalAddress) - { - ushort segment = MemoryUtils.ToSegment(physicalAddress); - ushort offset = (ushort)(physicalAddress & 0x0F); - return $"{segment:X4}:{offset:X4}"; - } - - private static bool TryGetPhysicalKey(char ch, out PhysicalKey key) - { - key = PhysicalKey.None; - if (ch >= 'a' && ch <= 'z') - return TryParseKey($"A{char.ToUpperInvariant(ch)}", out key); - if (ch >= 'A' && ch <= 'Z') - return TryParseKey($"A{ch}", out key); - if (ch >= '0' && ch <= '9') - return TryParseKey($"D{ch}", out key); - return ch switch - { - ' ' => TryParseKey("Space", out key), - '.' => TryParseKey("Period", out key), - ',' => TryParseKey("Comma", out key), - '-' => TryParseKey("Minus", out key), - '=' => TryParseKey("Equals", out key), - '/' => TryParseKey("Slash", out key), - ';' => TryParseKey("Semicolon", out key), - '\'' => TryParseKey("Apostrophe", out key), - '[' => TryParseKey("LeftBracket", out key), - ']' => TryParseKey("RightBracket", out key), - '\\' => TryParseKey("Backslash", out key), - '`' => TryParseKey("GraveAccent", out key), - '\t' => TryParseKey("Tab", out key), - '\n' => TryParseKey("Enter", out key), - _ => false - }; - } - - private static bool TryParseKey(string name, out PhysicalKey key) - { - if (Enum.TryParse(name, true, out PcKeyboardKey pk)) - { - key = KeyboardScancodeConverter.ConvertToPhysicalKey(pk); - return key != PhysicalKey.None; - } - key = PhysicalKey.None; - return false; - } -} diff --git a/src/BattleTechMcpTools/BattleTechMcpTools.csproj b/src/BattleTechMcpTools/BattleTechMcpTools.csproj deleted file mode 100644 index 87209c204b..0000000000 --- a/src/BattleTechMcpTools/BattleTechMcpTools.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - BattleTechMcpTools - - - - - - - - diff --git a/src/BattleTechMcpTools/BattleTechOverrideSupplier.cs b/src/BattleTechMcpTools/BattleTechOverrideSupplier.cs deleted file mode 100644 index 33b994317f..0000000000 --- a/src/BattleTechMcpTools/BattleTechOverrideSupplier.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Spice86.Core.CLI; -using Spice86.Core.Emulator.Function; -using Spice86.Core.Emulator.Mcp; -using Spice86.Core.Emulator.VM; -using Spice86.Shared.Emulator.Memory; -using Spice86.Shared.Interfaces; -using System.Reflection; - -namespace BattleTechMcpTools; - -public class BattleTechOverrideSupplier : IOverrideSupplier, IMcpToolSupplier -{ - public IDictionary GenerateFunctionInformations( - ILoggerService loggerService, Configuration configuration, - ushort programStartAddress, Machine machine) - { - return new Dictionary(); - } - - public IEnumerable GetMcpToolAssemblies() - { - return [typeof(BattleTechMcpTools).Assembly]; - } - - public IEnumerable GetMcpServices() - { - return []; - } -} diff --git a/src/Spice86.sln b/src/Spice86.sln index 8ca8ae6b5b..8896323a6b 100644 --- a/src/Spice86.sln +++ b/src/Spice86.sln @@ -29,8 +29,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Spice86.Storage.Fat", "Spic EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Spice86.Storage.Cd", "Spice86.Storage.Cd\Spice86.Storage.Cd.csproj", "{058F9104-71C6-4544-A7C6-ADC0DD71FEF8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BattleTechMcpTools", "BattleTechMcpTools\BattleTechMcpTools.csproj", "{191D3DE1-DB65-4B39-BDF2-14C95A75144E}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -149,18 +147,6 @@ Global {058F9104-71C6-4544-A7C6-ADC0DD71FEF8}.Release|x64.Build.0 = Release|Any CPU {058F9104-71C6-4544-A7C6-ADC0DD71FEF8}.Release|x86.ActiveCfg = Release|Any CPU {058F9104-71C6-4544-A7C6-ADC0DD71FEF8}.Release|x86.Build.0 = Release|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x64.ActiveCfg = Debug|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x64.Build.0 = Debug|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x86.ActiveCfg = Debug|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Debug|x86.Build.0 = Debug|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|Any CPU.Build.0 = Release|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x64.ActiveCfg = Release|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x64.Build.0 = Release|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x86.ActiveCfg = Release|Any CPU - {191D3DE1-DB65-4B39-BDF2-14C95A75144E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE