From 68e8d3c4c433699d0e39c8e5560535681b0661a4 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 28 Jan 2026 12:00:40 -0700 Subject: [PATCH] Add TTF font support, network tools, and logging capabilities TTF Font Support: - Bundle 7 IBM PC fonts from Ultimate Oldschool PC Font Pack (CC BY-SA 4.0) - Add fonts.py module with resolve_font() for host/Docker path handling - Add fonts_list() MCP tool for font discovery - Extend launch() with TTF parameters (output, ttf_font, ttf_ptsize, etc.) - Mount fonts at /fonts in Docker container for TTF rendering Network Tools: - Add port_list() to show configured serial/parallel ports - Add port_status() to check port connectivity - Add modem_dial()/modem_hangup() for BBS dial-out - Extend launch() with serial1/serial2/ipx parameters Logging Tools: - Add logging_status/enable/disable for DOSBox-X debug logging - Add logging_category() for selective category control - Add log_capture()/log_clear() for log retrieval Code quality improvements: - Use contextlib.suppress instead of try-except-pass - Fix variable naming (VIDEO_BASE -> video_base) - Apply ruff formatting throughout --- Dockerfile | 9 +- pyproject.toml | 6 +- src/dosbox_mcp/dosbox.py | 288 +++++++-- src/dosbox_mcp/fonts.py | 114 ++++ src/dosbox_mcp/fonts/LICENSE.TXT | 428 +++++++++++++ src/dosbox_mcp/fonts/Px437_IBM_CGA.ttf | Bin 0 -> 23736 bytes src/dosbox_mcp/fonts/Px437_IBM_EGA_8x14.ttf | Bin 0 -> 25852 bytes src/dosbox_mcp/fonts/Px437_IBM_MDA.ttf | Bin 0 -> 26200 bytes src/dosbox_mcp/fonts/Px437_IBM_VGA_8x16.ttf | Bin 0 -> 26128 bytes src/dosbox_mcp/fonts/Px437_IBM_VGA_9x16.ttf | Bin 0 -> 26264 bytes src/dosbox_mcp/fonts/PxPlus_IBM_VGA_8x16.ttf | Bin 0 -> 70640 bytes src/dosbox_mcp/fonts/PxPlus_IBM_VGA_9x16.ttf | Bin 0 -> 71144 bytes src/dosbox_mcp/fonts/README.md | 45 ++ src/dosbox_mcp/gdb_client.py | 88 ++- src/dosbox_mcp/resources.py | 38 +- src/dosbox_mcp/server.py | 85 ++- src/dosbox_mcp/tools/__init__.py | 36 +- src/dosbox_mcp/tools/breakpoints.py | 59 +- src/dosbox_mcp/tools/control.py | 35 +- src/dosbox_mcp/tools/execution.py | 191 +++++- src/dosbox_mcp/tools/inspection.py | 150 +++-- src/dosbox_mcp/tools/logging.py | 357 +++++++++++ src/dosbox_mcp/tools/network.py | 250 ++++++++ src/dosbox_mcp/tools/peripheral.py | 612 +++++++++++++++---- src/dosbox_mcp/types.py | 147 ++--- src/dosbox_mcp/utils.py | 67 +- 26 files changed, 2560 insertions(+), 445 deletions(-) create mode 100644 src/dosbox_mcp/fonts.py create mode 100644 src/dosbox_mcp/fonts/LICENSE.TXT create mode 100644 src/dosbox_mcp/fonts/Px437_IBM_CGA.ttf create mode 100644 src/dosbox_mcp/fonts/Px437_IBM_EGA_8x14.ttf create mode 100644 src/dosbox_mcp/fonts/Px437_IBM_MDA.ttf create mode 100644 src/dosbox_mcp/fonts/Px437_IBM_VGA_8x16.ttf create mode 100644 src/dosbox_mcp/fonts/Px437_IBM_VGA_9x16.ttf create mode 100644 src/dosbox_mcp/fonts/PxPlus_IBM_VGA_8x16.ttf create mode 100644 src/dosbox_mcp/fonts/PxPlus_IBM_VGA_9x16.ttf create mode 100644 src/dosbox_mcp/fonts/README.md create mode 100644 src/dosbox_mcp/tools/logging.py create mode 100644 src/dosbox_mcp/tools/network.py diff --git a/Dockerfile b/Dockerfile index 7fd513d..5c8b367 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ autoconf \ libtool \ pkg-config \ + patch \ libsdl2-dev \ libsdl2-net-dev \ libsdl2-image-dev \ @@ -37,11 +38,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # # IMPORTANT: Clone and build MUST be in the same RUN to prevent BuildKit from # caching the build step separately from the git clone step. -ARG CACHE_BUST=2026-01-27-v19-add-ncurses-for-debug +ARG CACHE_BUST=2026-01-28-v23-git-only WORKDIR /build + # Configure and build with GDB server support # --enable-remotedebug: Enables C_REMOTEDEBUG flag for GDB/QMP servers # --enable-debug: Enables internal debugger (Alt+Pause) +# Note: Removed --disable-printer to enable parallel port support +# All patches are now committed to the rsp2k fork (joystick, parport, logging) RUN echo "Cache bust: ${CACHE_BUST}" && \ git clone --branch remotedebug --depth 1 https://github.com/rsp2k/dosbox-x-remotedebug.git dosbox-x && \ cd dosbox-x && \ @@ -50,8 +54,7 @@ RUN echo "Cache bust: ${CACHE_BUST}" && \ --prefix=/opt/dosbox-x \ --enable-remotedebug \ --enable-debug \ - --enable-sdl2 \ - --disable-printer && \ + --enable-sdl2 && \ make -j$(nproc) && \ make install diff --git a/pyproject.toml b/pyproject.toml index cfdada5..31b0161 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,11 +41,7 @@ build-backend = "hatchling.build" packages = ["src/dosbox_mcp"] [tool.hatch.build.targets.sdist] -include = ["src/dosbox_mcp"] - -# This is the key setting for src-layout -[tool.hatch.build] -sources = ["src"] +include = ["src/dosbox_mcp", "src/dosbox_mcp/fonts"] [tool.ruff] line-length = 100 diff --git a/src/dosbox_mcp/dosbox.py b/src/dosbox_mcp/dosbox.py index d439fb7..242d5e5 100644 --- a/src/dosbox_mcp/dosbox.py +++ b/src/dosbox_mcp/dosbox.py @@ -7,6 +7,7 @@ This module handles: - Configuration file handling """ +import contextlib import logging import os import shutil @@ -16,6 +17,8 @@ import time from dataclasses import dataclass, field from pathlib import Path +from .fonts import DOCKER_FONTS_PATH, FONTS_DIR, resolve_font + logger = logging.getLogger(__name__) @@ -34,6 +37,22 @@ class DOSBoxConfig: # Display settings fullscreen: bool = False windowresolution: str = "800x600" + output: str = "opengl" # Display output: opengl, ttf, surface, etc. + + # TrueType font settings (when output=ttf) + # TTF mode renders text using system TrueType fonts for sharper display + # Useful for terminal programs, word processors, and text-heavy apps + ttf_font: str | None = None # TTF font name (e.g., "Consola", "Nouveau_IBM") + ttf_fontbold: str | None = None # Bold variant + ttf_fontital: str | None = None # Italic variant + ttf_fontboit: str | None = None # Bold-italic variant + ttf_ptsize: int | None = None # Font size in points (default: auto) + ttf_winperc: int | None = None # Window size as percentage (e.g., 75) + ttf_lins: int | None = None # Screen lines (e.g., 50 for taller screen) + ttf_cols: int | None = None # Screen columns (e.g., 132 for wider screen) + ttf_blinkc: str | None = None # Cursor blink rate (0-7, or "false") + ttf_wp: str | None = None # Word processor mode: WP, WS, XY, FE + ttf_colors: str | None = None # Custom color scheme (16 RGB or hex values) # CPU settings # CRITICAL: Must use "normal" core for GDB breakpoints to work! @@ -48,10 +67,20 @@ class DOSBoxConfig: # Startup startbanner: bool = False # Disable splash screen for cleaner automation - # Serial ports (for future RIPscrip work) + # Serial ports (for RIPscrip work and modem dial-out) + # Options: disabled, nullmodem, modem, direct, dummy + # For nullmodem: use serial1_port/serial2_port for TCP port serial1: str = "disabled" serial2: str = "disabled" + # TCP ports for nullmodem serial mode + # These are the ports external clients connect to + serial1_port: int = 5555 + serial2_port: int = 5556 + + # IPX networking (for DOS multiplayer games like DOOM, Duke Nukem) + ipx: bool = False + # Parallel ports (LPT) # Options: disabled, file, printer, reallpt, disney # file: writes output to capture directory (useful for capturing print jobs) @@ -73,14 +102,47 @@ class DOSBoxConfig: # DOS startup files (written to mounted drive) # These are actual DOS files, not DOSBox config sections autoexec_bat: str | None = None # Content for AUTOEXEC.BAT - config_sys: str | None = None # Content for CONFIG.SYS + config_sys: str | None = None # Content for CONFIG.SYS - def to_conf(self) -> str: - """Generate DOSBox-X configuration file content.""" + def _build_serial_config(self, mode: str, tcp_port: int) -> str: + """Build serial port configuration string. + + Args: + mode: Serial mode (disabled, nullmodem, modem, direct, dummy) + tcp_port: TCP port for nullmodem mode + + Returns: + DOSBox-X serial configuration string + """ + mode_lower = mode.lower().strip() + + if mode_lower == "disabled": + return "disabled" + elif mode_lower == "nullmodem": + # nullmodem server mode - listens on TCP port for incoming connections + return f"nullmodem server:0.0.0.0 port:{tcp_port}" + elif mode_lower == "modem": + # modem mode - allows DOS programs to dial out via AT commands + return "modem" + elif mode_lower in ("direct", "dummy"): + return mode_lower + else: + # Custom configuration string passed directly + return mode + + def to_conf(self, container_fonts_path: str | None = None) -> str: + """Generate DOSBox-X configuration file content. + + Args: + container_fonts_path: If running in Docker, the container path where + fonts are mounted (e.g., "/fonts"). If None, + uses host paths. + """ lines = [ "[sdl]", f"fullscreen={str(self.fullscreen).lower()}", f"windowresolution={self.windowresolution}", + f"output={self.output}", "", "[cpu]", f"core={self.core}", @@ -95,33 +157,43 @@ class DOSBoxConfig: # GDB stub configuration (lokkju/dosbox-x-remotedebug fork) # Must be in [dosbox] section with "gdbserver port=" format if self.gdb_enabled: - lines.extend([ - "gdbserver=true", - f"gdbserver port={self.gdb_port}", - ]) + lines.extend( + [ + "gdbserver=true", + f"gdbserver port={self.gdb_port}", + ] + ) # QMP server configuration (for screenshots, keyboard, mouse) if self.qmp_enabled: - lines.extend([ - "qmpserver=true", - f"qmpserver port={self.qmp_port}", - ]) + lines.extend( + [ + "qmpserver=true", + f"qmpserver port={self.qmp_port}", + ] + ) - lines.extend([ - "", - "[serial]", - f"serial1={self.serial1}", - f"serial2={self.serial2}", - "", - "[parallel]", - f"parallel1={self.parallel1}", - f"parallel2={self.parallel2}", - "", - "[joystick]", - f"joysticktype={self.joysticktype}", - f"timed={str(self.joystick_timed).lower()}", - "", - ]) + # Serial port configuration with TCP port handling for nullmodem + serial1_config = self._build_serial_config(self.serial1, self.serial1_port) + serial2_config = self._build_serial_config(self.serial2, self.serial2_port) + + lines.extend( + [ + "", + "[serial]", + f"serial1={serial1_config}", + f"serial2={serial2_config}", + "", + "[parallel]", + f"parallel1={self.parallel1}", + f"parallel2={self.parallel2}", + "", + "[joystick]", + f"joysticktype={self.joysticktype}", + f"timed={str(self.joystick_timed).lower()}", + "", + ] + ) # Autoexec section lines.append("[autoexec]") @@ -133,7 +205,53 @@ class DOSBoxConfig: # Add custom autoexec commands lines.extend(self.autoexec) - return '\n'.join(lines) + # IPX networking section (for DOS multiplayer games) + if self.ipx: + lines.extend( + [ + "", + "[ipx]", + "ipx=true", + ] + ) + + # TrueType font section (when output=ttf or TTF settings provided) + # resolve_font() converts bundled font names to full paths + # container_fonts_path is used for Docker to map to container paths + ttf_settings = [] + font_path = resolve_font(self.ttf_font, container_fonts_path) + if font_path: + ttf_settings.append(f"font={font_path}") + fontbold_path = resolve_font(self.ttf_fontbold, container_fonts_path) + if fontbold_path: + ttf_settings.append(f"fontbold={fontbold_path}") + fontital_path = resolve_font(self.ttf_fontital, container_fonts_path) + if fontital_path: + ttf_settings.append(f"fontital={fontital_path}") + fontboit_path = resolve_font(self.ttf_fontboit, container_fonts_path) + if fontboit_path: + ttf_settings.append(f"fontboit={fontboit_path}") + if self.ttf_ptsize is not None: + ttf_settings.append(f"ptsize={self.ttf_ptsize}") + if self.ttf_winperc is not None: + ttf_settings.append(f"winperc={self.ttf_winperc}") + if self.ttf_lins is not None: + ttf_settings.append(f"lins={self.ttf_lins}") + if self.ttf_cols is not None: + ttf_settings.append(f"cols={self.ttf_cols}") + if self.ttf_blinkc is not None: + ttf_settings.append(f"blinkc={self.ttf_blinkc}") + if self.ttf_wp: + ttf_settings.append(f"wp={self.ttf_wp}") + if self.ttf_colors: + ttf_settings.append(f"colors={self.ttf_colors}") + + if ttf_settings: + lines.append("") + lines.append("[ttf]") + lines.extend(ttf_settings) + + return "\n".join(lines) class DOSBoxManager: @@ -151,6 +269,12 @@ class DOSBoxManager: self._gdb_port: int = 1234 self._qmp_port: int = 4444 self._xhost_granted: bool = False + # Serial port configuration tracking + self._serial1_mode: str = "disabled" + self._serial2_mode: str = "disabled" + self._serial1_port: int = 5555 + self._serial2_port: int = 5556 + self._ipx_enabled: bool = False def _setup_x11_access(self) -> bool: """Grant X11 access for Docker containers. @@ -199,8 +323,7 @@ class DOSBoxManager: pass logger.warning( - "Could not grant X11 access. Display may not work. " - "Try running: xhost +local:docker" + "Could not grant X11 access. Display may not work. Try running: xhost +local:docker" ) return False @@ -223,6 +346,46 @@ class DOSBoxManager: """Get the QMP port.""" return self._qmp_port + @property + def serial1_mode(self) -> str: + """Get serial port 1 mode.""" + return self._serial1_mode + + @property + def serial2_mode(self) -> str: + """Get serial port 2 mode.""" + return self._serial2_mode + + @property + def serial1_port(self) -> int: + """Get serial port 1 TCP port (for nullmodem mode).""" + return self._serial1_port + + @property + def serial2_port(self) -> int: + """Get serial port 2 TCP port (for nullmodem mode).""" + return self._serial2_port + + @property + def ipx_enabled(self) -> bool: + """Check if IPX networking is enabled.""" + return self._ipx_enabled + + def get_serial_tcp_port(self, com_port: int) -> int | None: + """Get the TCP port for a COM port if configured for nullmodem. + + Args: + com_port: COM port number (1 or 2) + + Returns: + TCP port number if nullmodem is configured, None otherwise + """ + if com_port == 1: + return self._serial1_port if self._serial1_mode == "nullmodem" else None + elif com_port == 2: + return self._serial2_port if self._serial2_mode == "nullmodem" else None + return None + @property def pid(self) -> int | None: """Get process ID if running natively.""" @@ -239,7 +402,7 @@ class DOSBoxManager: ["docker", "inspect", "-f", "{{.State.Running}}", self._container_id], capture_output=True, text=True, - timeout=5 + timeout=5, ) return result.stdout.strip() == "true" except (subprocess.SubprocessError, FileNotFoundError): @@ -280,15 +443,19 @@ class DOSBoxManager: dosbox_exe = self._find_dosbox() if not dosbox_exe: - raise RuntimeError( - "DOSBox-X not found. Install dosbox-x or use Docker container." - ) + raise RuntimeError("DOSBox-X not found. Install dosbox-x or use Docker container.") # Create temporary directory for config self._temp_dir = Path(tempfile.mkdtemp(prefix="dosbox-mcp-")) config = config or DOSBoxConfig() self._gdb_port = config.gdb_port self._qmp_port = config.qmp_port + # Store serial port configuration + self._serial1_mode = config.serial1 + self._serial2_mode = config.serial2 + self._serial1_port = config.serial1_port + self._serial2_port = config.serial2_port + self._ipx_enabled = config.ipx # If binary specified, set up mount and autoexec if binary_path: @@ -362,6 +529,12 @@ class DOSBoxManager: config = config or DOSBoxConfig() self._gdb_port = config.gdb_port self._qmp_port = config.qmp_port + # Store serial port configuration + self._serial1_mode = config.serial1 + self._serial2_mode = config.serial2 + self._serial1_port = config.serial1_port + self._serial2_port = config.serial2_port + self._ipx_enabled = config.ipx # If binary specified, set up mount and autoexec for container paths if binary_path: @@ -373,28 +546,44 @@ class DOSBoxManager: config.autoexec.append("C:") config.autoexec.append(binary.name) - # Write config file + # Write config file - use container font path for Docker self._config_path = self._temp_dir / "dosbox.conf" - self._config_path.write_text(config.to_conf()) + self._config_path.write_text(config.to_conf(container_fonts_path=DOCKER_FONTS_PATH)) # Build docker command display = display or os.environ.get("DISPLAY", ":0") cmd = [ - "docker", "run", + "docker", + "run", "--rm", "-d", # Detached - "--name", f"dosbox-mcp-{os.getpid()}", + "--name", + f"dosbox-mcp-{os.getpid()}", # Network - expose GDB and QMP ports - "-p", f"{self._gdb_port}:{self._gdb_port}", - "-p", f"{self._qmp_port}:{self._qmp_port}", + "-p", + f"{self._gdb_port}:{self._gdb_port}", + "-p", + f"{self._qmp_port}:{self._qmp_port}", # X11 forwarding - "-e", f"DISPLAY={display}", - "-v", "/tmp/.X11-unix:/tmp/.X11-unix", + "-e", + f"DISPLAY={display}", + "-v", + "/tmp/.X11-unix:/tmp/.X11-unix", # Config mount - "-v", f"{self._config_path}:/config/dosbox.conf:ro", + "-v", + f"{self._config_path}:/config/dosbox.conf:ro", + # Fonts mount (bundled TTF fonts) + "-v", + f"{FONTS_DIR}:{DOCKER_FONTS_PATH}:ro", ] + # Expose serial ports for nullmodem mode + if config.serial1.lower() == "nullmodem": + cmd.extend(["-p", f"{self._serial1_port}:{self._serial1_port}"]) + if config.serial2.lower() == "nullmodem": + cmd.extend(["-p", f"{self._serial2_port}:{self._serial2_port}"]) + # Mount binary directory if specified (rw for screenshots/capture to work) if binary_path: binary = Path(binary_path).resolve() @@ -441,23 +630,18 @@ class DOSBoxManager: subprocess.run( ["docker", "stop", "-t", str(int(timeout)), self._container_id], capture_output=True, - timeout=timeout + 5 + timeout=timeout + 5, ) except subprocess.SubprocessError: # Force remove - subprocess.run( - ["docker", "rm", "-f", self._container_id], - capture_output=True - ) + subprocess.run(["docker", "rm", "-f", self._container_id], capture_output=True) self._container_id = None logger.info("DOSBox-X container stopped") # Cleanup temp directory if self._temp_dir and self._temp_dir.exists(): - try: + with contextlib.suppress(OSError): shutil.rmtree(self._temp_dir) - except OSError: - pass self._temp_dir = None def get_logs(self, lines: int = 50) -> str: @@ -479,7 +663,7 @@ class DOSBoxManager: ["docker", "logs", "--tail", str(lines), self._container_id], capture_output=True, text=True, - timeout=5 + timeout=5, ) return result.stdout + result.stderr except subprocess.SubprocessError: diff --git a/src/dosbox_mcp/fonts.py b/src/dosbox_mcp/fonts.py new file mode 100644 index 0000000..3a3f560 --- /dev/null +++ b/src/dosbox_mcp/fonts.py @@ -0,0 +1,114 @@ +"""Font utilities for DOSBox-X TTF output. + +This module provides access to bundled IBM PC fonts from +The Ultimate Oldschool PC Font Pack by VileR (int10h.org). + +Fonts are licensed under CC BY-SA 4.0. +""" + +from pathlib import Path + +# Font directory location +FONTS_DIR = Path(__file__).parent / "fonts" + +# Available bundled fonts (name -> filename mapping) +BUNDLED_FONTS = { + # Strict CP437 encoding + "Px437_IBM_VGA_8x16": "Px437_IBM_VGA_8x16.ttf", + "Px437_IBM_VGA_9x16": "Px437_IBM_VGA_9x16.ttf", + "Px437_IBM_EGA_8x14": "Px437_IBM_EGA_8x14.ttf", + "Px437_IBM_CGA": "Px437_IBM_CGA.ttf", + "Px437_IBM_MDA": "Px437_IBM_MDA.ttf", + # Extended Unicode (CP437 + additional characters) + "PxPlus_IBM_VGA_8x16": "PxPlus_IBM_VGA_8x16.ttf", + "PxPlus_IBM_VGA_9x16": "PxPlus_IBM_VGA_9x16.ttf", +} + +# Default font for general use +DEFAULT_FONT = "Px437_IBM_VGA_9x16" + + +def get_font_path(font_name: str) -> Path | None: + """Get the full path to a bundled font. + + Args: + font_name: Font name without .ttf extension + (e.g., "Px437_IBM_VGA_9x16") + + Returns: + Path to font file, or None if not found + + Example: + >>> path = get_font_path("Px437_IBM_VGA_9x16") + >>> str(path) + '/path/to/dosbox_mcp/fonts/Px437_IBM_VGA_9x16.ttf' + """ + filename = BUNDLED_FONTS.get(font_name) + if filename: + path = FONTS_DIR / filename + if path.exists(): + return path + return None + + +def list_fonts() -> list[str]: + """List all available bundled font names. + + Returns: + List of font names that can be passed to get_font_path() + + Example: + >>> list_fonts() + ['Px437_IBM_CGA', 'Px437_IBM_EGA_8x14', ...] + """ + return sorted(BUNDLED_FONTS.keys()) + + +def is_bundled_font(font_name: str) -> bool: + """Check if a font name refers to a bundled font. + + Args: + font_name: Font name to check + + Returns: + True if font is bundled with dosbox-mcp + """ + return font_name in BUNDLED_FONTS + + +def resolve_font(font_name: str | None, container_path: str | None = None) -> str | None: + """Resolve a font name to a full path if bundled, or return as-is. + + This allows users to specify either: + - A bundled font name (e.g., "Px437_IBM_VGA_9x16") + - A system font name (e.g., "Consolas") + - A full path to a TTF file + + Args: + font_name: Font name, path, or None + container_path: If set, return container path instead of host path + (e.g., "/fonts" for Docker) + + Returns: + Full path to bundled font, or original value if not bundled + """ + if font_name is None: + return None + + # Check if it's a bundled font + filename = BUNDLED_FONTS.get(font_name) + if filename: + if container_path: + # Return container-relative path + return f"{container_path}/{filename}" + # Return host path + path = FONTS_DIR / filename + if path.exists(): + return str(path) + + # Return as-is (system font or path) + return font_name + + +# Docker container path for mounted fonts +DOCKER_FONTS_PATH = "/fonts" diff --git a/src/dosbox_mcp/fonts/LICENSE.TXT b/src/dosbox_mcp/fonts/LICENSE.TXT new file mode 100644 index 0000000..fd662a7 --- /dev/null +++ b/src/dosbox_mcp/fonts/LICENSE.TXT @@ -0,0 +1,428 @@ +Attribution-ShareAlike 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More_considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-ShareAlike 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-ShareAlike 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + + including for purposes of Section 3(b); and + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. + diff --git a/src/dosbox_mcp/fonts/Px437_IBM_CGA.ttf b/src/dosbox_mcp/fonts/Px437_IBM_CGA.ttf new file mode 100644 index 0000000000000000000000000000000000000000..2e4945a69d3c3652a1a5d71ab679db8b5be092e4 GIT binary patch literal 23736 zcmdUX3wT`RdFKDmXmpn>Ssq(gGb4FqLwwU^@C8Hg1=|oV4j0Esz-FWwTf({_jf}Yj zoP?w)rKy*Yl%&@3au=%G0#@zY@&bDsb za@AIoGz)Nih{reFw5mJ*@v61PSX^Un?;p;MU9|6WmBy6dnv!Sx4-`|T+5RtM%3s3o zCHry{V>2_LY;HY>W&at!{SCgC4&X%MRO}By ze+iD43=9_!wq2Y21&*hTiCr@^+Mjzh`B!g&)Nved8O|LXvzyG<@%wgMpBl*x7xwJ< zaicLiUIF{R8XKJ`F5mXx3&z}nZ|Td1y5hU(wj-PO%-isHrYfGo8S`p)ZHsIlcp;wgLBBuIl)>|wt( zdAnP#sE{>Ruvob4TzTV4&^4X=RR+kG=dU=}_XWh`hmldk{bN58;!+QO}QJ|8*b z_x<(ZuX@J+POhZ&cFRKrgm0XQq=Ctx{ ze(CnTw4tq{mLm;(BeF$U>RG#-TmKDyp2C?aZ~1rH53aso2dLA<`-n$TjiUSRQb8b5 z*s4g6AHf-7zSWe(KZEU)=8!2hi_HV(OXl~?m(ADAQS)= z|8CBhzc**ijC~~jx%gx8FU6mZeY8El4?(FNIj7Ha{BY>XWEjU`;op(^anIF%bp83(t8#592L`PR&fGo9x;&$XXxJh$-Nf^#+Js?OQ7 zGiT4A{?h4(PH#V*{?WQ0Ej{)7r(QVq)l?8-MY}_ulyS^c&N!PyeUsSEm2t z^!KL!bo!;~KbSr`{nhEmrav|PiRlNX@0-4RdU(2T`nKtv)3;1tKfQf=!^ziA{^iNn zPJa93ADn#g)SGS#LAeOb)RGj*ok%r_0_yOL(1 zS!5bblW8_Bz;7E+dl9m9+O(Svv&3Xfr&(&2nTyRO=6zEd(6G&KJzhiKcems@yv zQJg$**TK8*x%a-0-T%O$&wt^ufBE?DJn^rdeCl7DFCKaN_rCnhvtRk@*Z$4FeeUbe zn-~7wH@;~e`s9P3{^QU7u7c>d&8xrh7q5NlH*xfb=GP8?=S$|%PyG51Ui@?OnSWtE z9>Ez?iCccl@4Vwy>J9kr68PYL^NjhPdCM-aYwfjmz&>ohX-~#tu_dwHu>-Ns#*W5b ziM@q>cV&EMd@TMDveEJQiTKY;>PnX3v#Dff$)S>`OI}TsCmIu%CT>g|NIaByJn?km zXyQcTY-wF-Z|Sbm`%0fE{ch=NrRU4q%X-VMDH|#KMA_42Q)U09yrg_t`Ss;v<&Tsf zEq|^2t%{P0#T8pBc30e2@p#3bRQ#l}v~pSH&6OjShbkYhe6jK;Rk5m-RaaK^RTZlq zt$M2JXw`|TGu88}yQ_Cpk5xZXeYE=f)qh=6U6ZO=UvquU9W@WvJXiDmnxEEI*DkBw zRJ*(Oq1q$0$7|oLJu|Ov-X-&{#b@`td*(eg@A$l*)h(>sUbm}myzb$;r|Vv-J5~47 z`s(@(_2c!=)_=eL7xNqEUon60{7=t6GXK^2XBrwCu4u?NJks#phBp_KE?BZ)`+}hb zk1u$6!RrftmTXL}P2QL+CLc{6OTL->#lnRPyBF?UcyQqp3twLN>Y~`9&P6*G-LvS( zqNzncZ8VLojcXhCHr~_tSmSezCmVm>G{0$O)ApvJro&AyHXUy|)pWkOws}eOj^;a> zA8vlG`IY9=%|C0YZfR`kY`L^$Ys=1-y)E~&JlyhR%hy|`T28jS)mqZp+}hi^z4g}C zV(UY#Pqn_-`u*0^tv_$8YrCZFnzq~94z_)v?YXw&ZEv>yVsYK#m5Z-mJhJ$)#ZNDO zVe!k0Us-(WqAM=?d1`xVXDXk%FZF2ZYpLU@zfPB=SElpn&!(r+|D%0D``Y&1?T6Zr zw7=Z`6J(8MVC-*VN#vC>v+{XsR&6+1R$~5J_w%KR*EbxE$FOUjkMnebr;nDE+Or#u zTAuHz@2OwfQ{U12>Wfc48B3n~ug&(ypcV_N$tvU|lkLg&)Ti<3NTzL1wj-a9?K(e} zKYzj=o|?+%?V0l@a2VSq9dcRFHjd0T54mg+^4CSibb;5JY&zMI?8q+7_9Qdd+K&1j zn@*vHNfMji+3(3)z>N>7sx>ao}^dj{S$^dV)UX7-G*l2v{x zf}uDulmvXNm=X4fkl;@KBY7P386@#u;WfDlZZ}4wPH;KLTF?=1ap_! z`VR30TZ()rgDDoIqkycEK_~Bg!Pqcs(AJ}tK2?V1>xaCQwI&6ey{uxywa1%rlC~W0GSfClWhLkVh9`tHvmGhC1;52$lpmMQWg8{ zjF?W6gSbF9jv=}G#q)wK;J=0_s$ddaMXOCo;NSo91%*gS9>^00E8LZC9b zusFGqqnJYw4d`2FY}m)Q>&b$L*aht*IHe`=f)QrePEil5;)AxJLJ@`2oQ7&EwnbjV zTnTb@eL4vw@ahwxOt9rHM!dsrk|+x0(zu5#BAaYPJawlSSA1gfLI*aSo5y`yPr%FN zuV+82(M4^T_q2s}mn7MB)($$Abc5)!z}oueF4$%2ySLcKE> zuQ{(#bM4EaD(H#!kR6{S`(leNDJeq1MAW7wb=Pqb?pQHwC_XSui^CqM!~|!cEU+bj z_`CNnVuQV_@y(%{wYg|RKFY)MgR=(if zlVv89Y{-ogaB5;By0P|<+Q4KeEQN>IYHBtWB_;18drZ*=sQlzQiFs%SkI+WTk>(E5 zvK`J#eaeoEITDNDwu!xK_<_oikBBO^^EULtqq)ZxC%Ib0gHPd? zW;1O^BTCUNy67sxPvQ(_7xPC+5yNTr!=8``OE@_~*l2$!wiGM)42FDx)>?xuC2e9~ z5I}v!t&W<~i^u~Ug9LF&}EG;uQq(j6I`K*?FRKcCJ7iuZF zri_U5QAIgHo(`f(c%{Rf^dZnsauB5v+tSApvS=9Q8nM)c@lGGH966(MA-g9nG-QoL z>k2-U9}MgJ0IBPRIz;o_lsEDV7hU^}oZ{*NOgMYGw(3=!OR7&qT?VESf^iYCKHfNf zM72j$(IY^2R?wMcD4!n#BG4haT9^pcwjm~^j^|jGcEg8xHd&wSL3UO%3u82cJI{#G z!~CLSZkH26RApSi>ZlhnaRS}~w4s3nC;{bt=lW=oUo=RPzw{B1hv=g3=y#Dua*}r+ zMcYHZIOAuWaZ+&0MJZv02Y(;nnp?>LBi z(VBLl8E5+>Yzv(QP$Ki}wlpfvoF8M`5SI;{new^c^kZ2>hd>-chgUq3QBIO}5^|%h|n8>M+2qe=A zv-FBY3;8=wazYFGNcMy|ybMeP03O7M;IqT^tF)$k!c0J92s)&{R|zy3VBd)(XM?L}`c}w2fd#<4oog#K&P<*atYrhn&P7JR)bc4bNkrJcXX$5sB3JJdb6z9*_xc z3iQyL$mya+LVYhC2=g2MBKoAgOq7fMf&{3FCdW>jAo}!mRxf%DUY3tPvqQ66&SO?N8U0DnIu;S(AbFkNg2?D8F_)2)u`kbagvuNX9XX>#XG*r>@O@UFh&lwk zS+R1Ii5`eGEA@jEY4q%L3PKWiW5p+n-wJ=1AHzNf7A0O-tI<0YS`!>$3+`x*9UKIh zWi14JxVnPe1-qmnDOLhm=eE_wbMOw{DnEOyInvW)PIe|GnJ5Q)@OE_xc*`7?=PuQD zwgIRjZ&upuV8rs?172^&r)DIY7a!4FEJy3nR>GY-WbQVqH|I*#_MC%ac2;By27t+q zq>k-n04{Awgmz=N1o9I*9DDiRiTc+!WEuIshZEjvc=e@DLWOf86|jH-U-qqNkEBf5 zAv$w1H!_{-ctURK?e3uvF47ryu$6Zd2bh|f7!`q^DeEuqlLalJr>-cq_61_RNKE1R zoUnEfM~TFOFz+AQ7xk+2!Dd6@x<%gPU%+@>HQCeZow1Yin8)hL|z|}aAy^Ix^^o&VF5Bn8(mMMza z9?fOuRM&ydl-suw*a{lL{?zwBU>md{hBPM{XjXa-)%*gF?s)Y|u+)Cn!7g5rtol?n$=u07%wHqib_ zThedLS#*@>eu6;Zo~$TujIftJNXFl|M*!BiGm2tJ5H3HGzFOTu&@m&>-t?>9J80m&Jy%6hJ6Ii>!gy&hCAALEIhaeP zA+mVvZK_QCT74>WHuB79hItS70XP#UK%)$38}^G0-f@T#Ki{TZ0TPpV36d)wiG8`3 zjBF}ei_DOkD#I1@4HUq8}^xr zG+kQB0=H6ukP>JzGhvYxbE!O=0&r;FZ9pxjPr$$glCfoOCZ6qSi)k(eKz%{SI)q-u zn!P%Ut|``ro&jPEwg+urTLK@<(4b4>0=C%>NQ~UhSis^T!np9UM7d=CU2{9}t(gvU z*i9B%%4h_N5M5AF`Ufg09VY5L=_fQ^C*mXf2rKt5fW(%D(q7Wp4RFq4> ztH$^D21sw#{E1LS3|i3V+=W_*^-GY3w0I8lgr7f!Rt|K~D311EC4ggPhFxY!u`Zt_ zf?*lQarAlOFXYE0OHY1C2rA)|mHHjZ^4SdU733<;#Zn9@lJy6&z~P1dl4?xqsw zK?ZP+Ac@un_7(qPcd@m^8+^{(B&bZP&%K>_*uf{AXlb!42~r>~QW;3+uGsOoykI;_ zc`!B>I&M6Rfgl~GgqUkoEl~SMX{`Yr(o@(#E_vuj9j~qjRm$ryE%vVptdEv3I@Y*H z#}5#$wG_Qd-Q-@bj?_q*gAZ9WBf7jeHx}iY8^&~$CSpe^ z4U>MfL8)YpK*EMWEphFsL~{x&3yeB|QL(>{^6AAg(Ipt^3n=JINHXfRoUs$aKcwma*fe1&M4h@1PPd7jV@32rh@3#^$I`fR`8s3dn zf{e+wSkL7#&7z(jBXhR8qOrq)tKTs{z&pyIz#I<@NUw}`3`Gqk=*%`)Nt+Ai6qx16 z$!9oR&&E-_RnbGUEE4deD`fr&6aouW3qA4;`wmw_67iw5j05o}6`duriV*aTyD;{L zuHopP2q9}mA)Sl#j4xmj^5ippl9?5oOsFp3Aa{v(pen?Mp&^5F7!xQKFZbKhVCBdf z$k%jaaZV_=9ZxpG-VaUtl<2Ixyl%!)P?g;cb zq(Ek_Y#jf&=PDT`OFK9h$$mxJ;%X2HaiF|)Y|rtnvnB;rq`BcOGL1lA`$v7YGBUbn zy9RL6cI+zNp`F@p4!_WHj;|VR4h@&yJavqy1edft2J*>2GwN4Sfq};G;Q4GA}ECnY$2Bl7#dEt~1mW zP8fg|oWj0Y?o*(eLJdTp;fsM-pf_u-7S44rw<;$}OfLS86I78SoelOZg){u(#w;QR zeHg<3_(g~ark zPiaLR*;>);MA8jXpR*0?4OYMqF0?WQgHQ}O2#S5VGeI@g&_dS93J+ipc}+|$E1eT| z_wV2}C@54wlk{XfDWn7M1)VO>%vyX*u}?_yP6jiS%Br<7{p3cdkVAygkX9EsiDldg z;UupkBX~cHkCQ|eV@>XzLmft$q-rVA19?22G)@gFa(?JejHJO1_g?gV7OYh5@-c0p z^UTsQa!#Dm;*Oy}P)ZHztoWt*6tm=k@v-Yk)Pm&38c36#Mjl#@A(x|_#Gk|sd*!r@ zlf|c6_jth3YIJM@o`7abjZ}ERVD_-1e_>86lv#%Aq~&CwAy8pwM%!~9 z$335_R6tb_!iZz|$N)eq4wy(5$|U#7+_=(&5Er3rFJL~- z#jcwJbqJH$umlA4g`8@3NZwlh#{HF(r3$1+xVxr5}-%k`bA@=1sLARqH=RAj$Xsf z_X&5GVcd5=MTkdU_m@N+`u*#!ofXaYWcqaUIyggpP`OvKaXed-19(G!c8GyCPKP4xbzsI zC3LZx0=8sskv1ni{386^Sj8m3i?izhp|nw1&<3(**zu&}DP%O}FWlN@-Xeef9W-%) zwB`WGwI@&Xq1qlHfcAI_k9{|4c+$8u!@OYfxu&)QdJS0|zI1jgvQ^1EfJ%cFs&{Asz5i>_TSv zkec&48AQvaj-Un2R_+Dgz&9nH36(9x?#KA3%Dt5#cvoRDlM=RSVH2&!NP^R_ciGA|1mYM*|I ziX|r#`m>B+L#<+=1?w6CKgU*Jh&wf~&ADd?UDZ9zQ=yF*Ga?;Sf|)c>9S*=tM!x8G zKp$6%t-wUo=cN=6)TJg1?4^Azu9yE&t8YLnYK2DlBiO`j=c`pP;U&0tyfl(EKH+Av zfM_D4edIo&&k$|Gm=WAdyA0X(Diuz&=YcAb1rY!D30Y zK!Vr20WGKqksHjrJIsn)9$Oqc^CAx~$zSY$H5TH?46Zg&eBr+#1?BipN7lVSkMPhj zf~zlF-z2q(#Jor)p+_&NKXs!7D5FoXE(gQ6THC-o@(JuH@~U1gB*r2pjr@Nv^He=+ z0nI4hs_4)8Kb_DbV%^$Xy*G9 zi6j4;gS-BQXX6=T@*AQm_{|=6HpFiU(8SYb{?|IqfzpnM)R}$Wd*D6f_u+jGJ$Q%1 zWq5A`^zKQP!S<-bWSekXe_V#5rVZsoLlV#KY z;n@W}7GYcOwmhbOJU3TA%k%u*>7AH+JjWh$+dCTs_VG3mUFATAuG|YucHtTP8nqj* zrV+W&W{iu7{ap15dT6A7em^wwY>NkZ1~$JK@A%k=IR=!pGKGSA5TKCqI16nN8k|z$ zL;$MFB$A26N=H5&oLS^Ke6##NJFo{!*@}=7!I65JKSgZ=Js47vv)Y_-;>Ym7`x@Y# zU5pDNH+Dt3#Bbbe4e4`+UdFLN2|tK) z@c{b{Duov8zXST5ACV8{VfX=UT#u~GOLZ_8<9t5)CC+UkPrc{=TX%hw#1yjiyZ*m< zSTx8pzpDRtM^zlqjAkd=`=7(R9R6SYPdjknYJ0iM@GJb^e!0geclv=v%m)&S;XEI) zC^-gM$s-fH%uy^)w2zXy3G>}Ly9q=`q6P^<=n~Rg+=!BUE+()$hYsgAnQC;-0?9^_ ziqPR*B+A6bk}7=q&=e?zOc-N`7*44{9)6*fd?eO_)I@qp#HAjFbtF!qR}iNWy+BFn zP$|E7Vf?Yw;STFMj|xGMpkU;8@y?SlX65ddv?N;?hN^I|#93RlD3yQ$oc4fm;2-o5 zRK&E}ovID?m*P!KVjZk&%%g5Uh8p>(+mD-+IqUXIOpRUV_7kSs=G=ZM&OhY#%T2-_ zar+h6|EAkVU1d+Z{c2MmTj=&{%(~bUZok&7jQ6_z`KBiR0P?pj!MFzRjAD<(eT(&vu?l2v|H9! z+pjgZ#(wDb=i|MC{irx5@h++%yrpUcZ>&n;eOU*v?Z>%Myr=47 zoZ&IwST%?>=y_thX;74R;s%WpRKy#_)}szyB_1q7 zuhn?x*h=xtSvvusBBYXcW z$-S*siFEytWdvw1;JZq&z5;tW?5o790-N?b@%mLd8h6#uV1Hp`qA;>mY^g1?<+j3B+A3RZYizBZXX|Xeoo^fL0-Ll8 z?IPP~n{2afv8}evF18oh6ezsY+x7lgyj#z<+YY>$@ZJZ&?!(=N5k?8WvH`#w|(zhhU}m3Ec+HM`n&*>2lodu`TUYS-Ac zcAZ_1w^BZ0FS8rWm+j@~mp*T=u+iUCx@GiUS?Dh5r zyTg9a-e_;KH``n6PWvId%YN8?#D3J?YHzc;?xEhPw zZgJZ#x9xV@9=Gjv+pODO>b7g#cCFj4bK8w>yUA@gyX{qOyH&Scj(?Zq+vWInIlf(v zZ*OYqpk-6(&HeA~!xhde=~4Z?R1FCdVpeJ3hE?pr}9QNADWZy}r@nfbLC>MqWq%imn!Uuc~n8R_kG{e&J-uUXyech>ryb$;hk zzq7{gWZh1$r_=45XFZFor`+S|bo(>i{+e!2N46BT*I(S@#pn)pJoj!dPfxJpf9mxt zx;z~@LCW1;)ZXA?Pr292)9dBw^^`>|HqQnM26W_1+2mLsUUR*_48G(xp+`e|bvs^k z$Hj?(T)vQybyF^tk@Z?3-p038Sp;uDi2rF#p*fKUlniHR|YQqn&(*_Xfzyul(r zm_tXLuNt15aG)yGM)rYy>8vc?fEx>XuWG!jwFKue29R^wtI7K0Bk{jyi|Bj0|2J(k Bg6IGM literal 0 HcmV?d00001 diff --git a/src/dosbox_mcp/fonts/Px437_IBM_EGA_8x14.ttf b/src/dosbox_mcp/fonts/Px437_IBM_EGA_8x14.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e0fafb5ec4e9fd673438ae0db5032479b9d90a5f GIT binary patch literal 25852 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 N4E}{J;{WU8{{dlz{tf^D literal 0 HcmV?d00001 diff --git a/src/dosbox_mcp/fonts/Px437_IBM_MDA.ttf b/src/dosbox_mcp/fonts/Px437_IBM_MDA.ttf new file mode 100644 index 0000000000000000000000000000000000000000..37ad05a34bf14d4674a2ab97735dc9e6aa6d5de8 GIT binary patch literal 26200 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 TK76d?o_i?!PppytKPvwZm0hHg literal 0 HcmV?d00001 diff --git a/src/dosbox_mcp/fonts/Px437_IBM_VGA_8x16.ttf b/src/dosbox_mcp/fonts/Px437_IBM_VGA_8x16.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7bee1e9f414ef6b3f29cec2e5f55c7ddf77a4f15 GIT binary patch literal 26128 zcmdUY3wT`Bb?)9XqlYb7_E;XjEzejUnTH>G4K~JrZHzIAd4z}K5Qvd9V@seHdKg0- zaEU@_N~td7K@*pxNeYg`rG`2wB~GuCl2l3WO$l`gwRrf_e7XL@toU>=|wb$ccYp=b}nImJ3F;!-tiJMzDZ@%HGdoTN!#l{p)p>*LD zn=apMQf3y;U*`EW*RN{qUshdijBUsHt!-T`y^D7}GS!#_>Ld=f?H)**T>K}-lpn?S ziCr!Iz2oDcYzi;nynI*ZT|2Ya6w=02fX3Cw+6yiD(tleugztWc_XX`JD0|U<4D^@c zd}({vz@F^F`QO0#h%vEuboR8h{QZo7z80jO$N8qNmOZ_;)z;(tEvTRFZs{s~V#dhB z#=PehV@g)`_Vf>&fA7#?V|G*;Q~Gs7S@FL1qhB*Srmy>nsVH5K67ynX)gsy7I|^CG zdgJNDA)J?+7)!CCP2$j4uQBm7Pw`1g(JjFeo59C=DfTUSvQkPC@w6Q_CE#N{dyS|4 z-sEH3rN$KbJ!52Y$d)4sYR*`^W~}nY^6q zClB+kM32I9sb)rkZ~6TQUb;7Sxm1`~pXF>5QdK(slkF#OAr&66j@Fx8Tltw>POkix z2M_*C>+?)Yd^!0Hj(x+k?8AE0K(S=ucVS~Ki`G*vScBi`n_NaWS6@r*a_;>*s5XKU zwE>FGb{y26bO5w?-oo#+M9-tgz6KG<_d-Q_{PQ>?<`rgpbMeRHUx+^)|5E&D z{Q3Ad(@WCp()XsH$b2kwV9ER?i&{_?_K`Yili8}K(g zfxnmQ)&~3)>TV-{e*4w0jJ`Vh%IH6gzA*YfM!!A!x1&c#|7!G^(JziZI{Kl}`$q2_-8*{M zXxC`#=q;n$MsFOwdUVU^y5p}L|EJ?GAOFhnzdHWx@h`#JkBR>NqkdQ*GJ$wnYRV9) zr}R7|UX2x|v~;W~Qme*p)J~%^Wk=%ro=N0^oNMP`elrGh>#RTC>z-O`Tb0 z&N1hj^UV2XxmkgDy~@;^2GeMoOtZ#$0IDnv2XjbFsO^Tx!;v4Q8Xc%xp53 zo6Y75v&CF#-eKNpwwkNV)#e&=t$CNZ&RlP9FgKcQ=G|tyd5^ityw}`pZZYpOJIt-7 z#k86>cxJ)uG`mc@=`gpM+fAqGGTo-f^qM?&3=>UB6-DWfVdt*)j@xeU?CS36y`!&xU~u=Hd+xgX{d+%f&%OIT_VGvm_>-U7 z|LMm*^QY#spMU(%o;Yyu3txQlzyA4Chn_Zv|KdwuHXr%m(EZ!OLYv#klU;Ui<=zYKc-Dm&NJn&!4??rINP~x^f@&{+#OTGcSErb$Gb)x>Y^=yv+*k2b#dj)BS5{RvS8lG{S=m=PRC%EC+m$D$RZgp)cEhy2)AmpM z^0Z^qeo<9bbzarxs@AIes}5F;RQ+`N?CFiuw@$xp`p2h#dHQS9$7al!v2wSn{dlNb-%Dr8Cnr*U!9qX79|;&OBbduzG#SXrBa2^L{KIr2 zy)b=AdRw|T{ZRVx^pW%{>7Qn*GAlFJW_mOGGY2!zX1~^W!;v#-n#vDN9(@7taRCV%eE}L4N)Tpe|jx(9^y)wS^2az ztJXbJmN5U)_;hK>E9;(#$8cz#juQ z`8=DI1#RQfx*Cye0iu`5C7Y7fxlF1yRhx_Fno^nCTvK&zvdLyrnIy{dc{`jR!RPWk zcV{-mwx2l=+n$f7PoH3!euHurXm^RAuL{IX_QPI4tBI`Us#8tiwkelw%GH+SYExhv zbPtd`*k^Yjp3j4t980$%1hWR_scFzO^DJnPj!*^n;GZv00h_WR2?DLWQ01P@Ndvk8 z4RDnN7xE%UcnK9xhb{c3bo?19d0Py@5}q6K0&ZZCBs3{!WM*XS7&sb%2JLblz(C8# zj*T75!%8kEO2>!J#t$q4T9C`>L6kHC6Dv0&t5&+EdcGs%33y2?2!w}M7thaRi&yU8ANVk+1`DN@#> zZHT{%Y_e9A=dDV1VIIK>z=Dn(5Wz3y*13`3n4aN)Oo9qJm}fQEM?0Z0!VPL0v8R+t znvO&{*@FJZ_lplthi|}##eYFn_ZIv?%}^ilA&7xr?Im`G&uq%aPg08*ttAc&L7VEu zwa7aR4Y70#;vCl-uknJ$Uo5?9snq5b+PVSxtyT zIAesIJdE{nMy42u9f2O3kpGzm*d-Ilub8D= zh?gn)L{!IWR}2pL-lH09QC8W|58_L3I#%f=mv4Qo=!rH)1PSa5n8HgN0La)8JB+mo z>Ro*R2(B?ti8lvt0nQ8$3K?HBDZ~NB`$aYRG-=w%Zf zDS+=1jz-mV(^~pPs+LV;>`H8f>LlEVKM1}!1_EJ_ME%<7#QAlOI?1AU$R%CNPTB+4iiwShAUg1wZV`eQQ1{-i@3N>3C5UFooa zez>}!c?tQiWmJ#8yqa~7xR;{#NIl%(!(?1Gx1%9Mof>asY|CP9DY+EA-$e+Jr8J@? z$Oah@2N@xdSz)7Xs44zw?m-I#B^u)ynwUNcOz7|QeAo|JqorCfpr8$uz{E^qm<_N2 zzhX8ZJO~%+AFf9>Vt%I?p7%+QE3}oC_Y$Ail%G=_^v#Wcj47ZD5?j+)xVoq$p=%iR?q`Sxx8dPqo zwi@hvGyuGA1HmHmsJ<0;q9={})5K2dmDECt`r%uwgZw0r>m>1y+<E7If7Xmn@4p_PN;k;_7qVBdA!=as-0k>c#FBxNSi@65ES`@9&;qB zn>2M~(a{{Gbg39V&~ZEsZsC=f=@8#d>RrsxWc5L*x( zv2}>Jt@fx5kSZ~W*o8OgOWIOyZafJ9%wxZC3}m*5SS5T`Lx&b`I1Fdc5C&iC7#`^# zIgPx0U%vN73a%bRGdjCRL47WgfI(GUi)2;xG!{WL6OR-Vm`J znZ*+o#CT39z>hgn0t$o}5ivYMvla6J?+qdYc#%kz%wPgY)dnM?%#R_5Pe{&eVwweG zkz53$oIi0MA^->xniV{7hNmM3Sfjx?a7}<^#9wqB#Op}eK^zckKxbqS27{+i+xU}s zAoO{{BwtF4urBKY0e2FiYEy_R;y*`<@>=;9p9X>tRp7QnSjTb zEFPteoR@1|R6nZfc;UjSMjJwei%t=KBKoK1(H3Y>+|n8%gX+!eUrC`%oQM~CC-C4A z;lr}9>sm|qlDbBCfZw^fHe&;`?-cuH6A5Ho69D6L3CA-Biu()Ori6QE`Qo;AIaCX5 zOXDzB>l$Ma{Ub5jMSRgP6^39OiSe?o;iaU;V9it<2kMbTXpuiM8cKWS>KH@CZ^B4J z^K#85N400vZDjQfLW7nXjVT!?eo;Q&DmqYpPVs$ofFc53kZeN>W0lHJh)IkKI;Nwo zyj&!ecnYS#mdiZ^2}C>iqKcs>j55r>r5`DR^sueQe9CI7IZ(r%4#v6~DlkSS$>kWv zQMhLjj^HQ6viJ$~LqB0}TxsCD2*dDuiVQx19`Y(Lk%S!ajm<4_peOdnEs9%>{b;Q( zK_F15?Wy|s5W+M^>?7zMDvjV5`Xg{vbyOG6gb6B7RLV1U8So6}VkS4o=ctScu95zJk#REJ?maTd02U3B*HV1^H zUQD&o0oaOoi4TdEsDmL~qZoF&g2wzhkc%z=A2miWY91r0hj@~X&g23dgt1_r%0V5n zt&;NPsD^r^HkR#_4F&-6LW0;-80ClK zANbKo3!O?l5{r^AyrqF?(>0}P!5v6JHyTgqvET>eTnc&s4MrmR0zHeG(by*4gD1K= z8skG}SeVg(IT6$IIsHznG2~@lR7L;tAZN|8OT=Pn25I=dU)efxZX4cSKqlZ zAeh$m67-3t%;u;Nq!96wj0Z7;GU-#+VmafLMl>nboDf&oKxY(O>ZV)3B#A?49NM*b zoTuD)5o4-P!nR(SUZs(CzkEEWEyeLp37-xd@Lkam;<_g6;rZ0q>um^nHp2aAszL*5L@&bVh#rK&>|(}gP%lJK!@0Z{O}xydE!-V zM7#p;f&U2!oWnXmy0bId`mx-_Shs$_H~>2OB5*{NFg92q@@aTAI1v!p#O?$k^A_;s z;;Z^!jwqr((OYO#P=a}6uhg(S6tR!km7_Yf!jYZmV@PBy;7SYAX8n|Y0VdH0(17(f zdCAxX+0Y+5McR#wnQ!tO#ojL+&v7LgF$jI1(qz3@htPkHP=NlZHJ;I9z_IffoUuzP zj&J=?*XJo!AzKt@jwj7&s5!-ka zb>5F_Ll`QsdMc}bz!NJK^#O*ZieQj7hjWo7!65dKJM2UB#qk{UH3|^9qJ8j|Vokvp zLXF!f6JrMgN<1Cr)}kg@3oqVn08<9@ZyKJB(Xnh0jRiu-=No#xEJPh(8(t4UbwYr``V-f*Gw?J}iJM1qQ#N7fi z1O)thXF(sF7-J{*JYeA{4gAYJ4-J&sA&`LrcNuJ=8Oas`K8-AVVCDamV%ABTV{PA| z$Oxp6c@a(bT&4;)S??zn#P^tE1wO;@MD%DZjHg|o=4eMfli!nSrC_t=tG4IS-}AHM_nN1dJGWiffi-VAf91)Rf|ADv^fa}m>oka!Ds+7wJGOGLR7WJ z`;!7Bh>}PxRF(HsmQZ}$C&6oXj2Vk@Hznubw3&ZNFYJl0c(Q4pSd`zwTSKZmVkfk! zmbx8{J65TK8zRGm{+fPif6Qnhvx1Lk=V+xp_)qpJQsMrJK-9PBUh6|UAfOL)At=I!i?<0HT69M`a&;(}>4HEt_Cu{mAHmwLgiz_L(nAm?5R3HO^^nitKLOT_|7o|#_-1;n3vE7h#Xob!wPL7o3|O!PG8h}&>$0f5XVlDCt=J* zVi1fEp$zMPdqw}1o;*76w$(Tr;1Tp>MyS|HW&}5soyTIA??Ylp{(G4d(C(*{K7M9I*t} zDu@PsQ&!3x=#cj_L5DIs$xU9N;FVKRE=Z$c(P{-@fIpHH9mw}8I11<<6kL4q?fU@! zXeY|>)_qLs!lif<`oOq+Rym{(iUL92svmGnpq;HJAD1Z)^sjfGowYY2gfdN(hC|+4 z_rMq&(bd~ZBEHJuDwlzaPV9s9VQuMVQeQ(5lbhxmkR_NGLO+5YXoyGn1g0c4g1)FV zHlqhYOHBV&UA~avOTJ}J>BfBIsG9Ezzwk-5GA+&djjW(DfJpLz00m~D9m$P2qX0o{ z5xsETDB~w6J)!hk+1zGToqyx?M{u$&qQ@C;fT)=#6=kgfQ zFbyV4S3*gf`l%gVMdzwQ1LNTY8lEXI7SJJ2^gmrd8=}IvffnWl^gZY-m>b9)qmC4rvtTtfc9# zDeL&;_K+5w0~R*L#5=L6N8;Sh5YW(YV2MZbO( z9}?4^KH(R#&Q3F;e>;gLlZ1e#8wmrVQJTzui5KA$S}L+YF|QO;BC#jzmWsf!{_25- z2<^aoOMG7Bsi!HQ>rK}>@KZ7caLetj%!F0!^yCvi~_fr>VgOb*P+fGm3%)ZmBeC+LF{B+_!&!?hmX z7tBb7JXNmD9~(g?75YE#6ItN1Db1<9EdG8p2;^oR5&)oH>6SZjCeHR*2*h%u_Cg-y zJ{>h9<1Jp805HHP{v>R|*>tr}vS~*g0U=!R14XxSeagkrxV!(*? z!+cMc$-j5;N&ZoS620v*cF>q*R5Bux?Bl3Ax)g2nyHfdp|; z;uu*b4t>hPbB&6_%z;3d*QgMw^!`w;cz_6{29JaQsuR%~szJ}qHr<*C2!uLJ^G+@x zPsR?;`8Z^7qQMK+I^T%sg)!2N5{h#9%?E9!T!-PixDTv}ajhrRCR@`i5SdXyB!p9- zLo}2)gLRrAu>iYQN|C zEq}*=^+2u#68F=l#yMW6Ber=Ri?*F-fx~$gkw!GY7(+Bl zTc*ddjUXEuK}f{~Jj4!>UhU#-;nx@=D~t+5x;qUa7r2X1=deP|xwY}?fCqBHE00ur zu>Q}GuD>Y=*EG=|Q5gv(pyA9Vy*dG~bNF_B{o21pK%Dohr$h>0^bLsxB4gN-#$o~m z8+Y`H-`($E+}b?+6m~@9!x#}-&)E;0N%WKTZ>L7=82Wt70yZQZkUie}EbEVQPqc+p zTz^D5;q7Z=T$1Y-jDYlJ84z;70&kZ9C1@R^%Mfp2(CaevHC$hW%{2ds+(oL9j*(eB zRMzx}DsrKG$)tTb^rp6cyWeFHKSPW)!P)}GJ7!cI?IeHVa)icU#i?X^oEQ110a^H4 ztD6X8?TS(9e&S zt`~nGFK7c^EjY#6TpG&-;1#P|L+qbz^?SYML5~^6+8J;l?b#>R`q-X#-GVy$t4tii zDw|Xx4LdCTYn>eH@KRpl5RyX)b;_gAK^~`c$S1`EuOM~$1ljfcxO;oREf*KS5%V+3 z1V8Yx)5!&T;5#B2RKkTjq2+k@eF`nCzv0)z(rFPb^htY(jJ{vtPx}@gefPu?X@eKJ z$C?~)_M&TsvR_iw0;AP;DXozOdN#c90g z2lT|AtV{jK2!qgt3;}kdQJwhOx9}*xg|e)iDGBSt@W=(1=n*48Pm~|@ga@s;OlczDKz&zxgmh0z{8qe) zeAEM84k?N|NzT>2Xe^jwU72RVc{qEN3k43L_|c@=A;2-MEVau>TI$o&hB2t%*+i8H z8l*0|aJW`poHi8O3JRJ0Fq0J@5IZX`NW&a2yugQhe#8?zFTjq}Rl8sq%4Kl%5)HsR zZf1n@5FC!U+#BmK62YkG#ru%+BVk@VNpR=<&C6Tsu}5^WgV61OPQexElP8Ri7&~Fl zEESZrcmw$|spk)NUGQL5I#&7SVg6X#>C43-@gwme!H@G_RhjrOhL2P9p};qk$66~| zkO(Hf3>Fs=r(-j8f(Yl@I6gh1s6F0~$1D~K$At_u9WBZ+MXi_)L?gN$xy3F#ms z_TpMWO_MN<~XMB2d3WrPyx* z4fZXsK)1A=;1eA9oFh~-*FYHm98Qy*;VA;VrbB50jfWyd)jml!{%Qwwp(Q>SfOdC^ z3vqz&kdkGnC)j{%51~9kJAHbA{|>)p3}|xh;GD(s*!PDq2Xo5c+cfSg^xqMw`fC&Y zedjVu>OUvA;8aZ~no2&y1Sa z%|Dy}W&SswGxmS+oUu2}xZd?IHi_ud>y_n*&SKNIwJU7XzDLVx%~+~+#Tc8qC=`>WkNJsdAEpyRjL{I2L(7Jhz> zv6mW-%svCeGkHPw!UYSAEu@1rGK_M;T^3L?742Ul58L3za!e6|&9w&*z@r=MT^lI> zEuXni)E_)Hd~v1Ny`(4Y#eo=!yPPA3k+mV@`oG=B74=kx!0pI`O=>$yF|1lP6no(=Q6c!rUqC(rBP)mVMuiiuv&pnq`f``3Hcl3-uf z)5G=ATy&jDS0UxPfw%xxXK?ueIi~u8%UYSo3s!z7&*l=}h40HfVkyWel8G0KHzE>w zcf^9DgnmCTk`2nF{(lykKuPHYQ3D4`CoF-wNzAQhA(Bwx%z~+8Mt6>k=oUFYFCa{K z_8Z#%*3X>6^IGjTjfP-k8JWp`Xt|1B5pIy%=kADBWwa| z0C;{TlqSa(&*$9Zj$?ST=hN;uj%Pi7%N-}oQhSj*E-`cMfIBWl`KR1*xtVH@xnn#j z)c(L7SC}RCXYRPtEQzgj$J0z-jCa3~pDJ^6e5N~|X_m%6jakDWp3B*Zk-r<`e;UsY z-Hm-4%6jn3&T~=1b3VPZ10~%!Z$);H#&bYB(5isvf-c8dqp6p-xn^*nv!lC^K4*8K zZJ?*`-1P23Uw=nWce?ei^tO)9!gb5jjrEQ7cx*Lv(JV8VG_sJ5$U{i;3S=c|JfCzA zvXl!@X-{+0>hzTxuS##bVnce(o(7a%hfa5a=}sgpeK;-b8tiPrLCDbBUy1ly2-b+l zqc-9psh)i%M-3oFM%Pa6eC_VWm5nRN|B41w`;A37YsY7!zr(3$hf~px2`btF743kE zcBqO%Rj4RY<@l0zSa<+WW?hSUnv*(HeT$N2`K+lTL41&@lg6`0Ysi25*OVj0^e@ZMI$x!2Vy;jRt5bi-2$c&`#aS%IS#94p7G z0-d%w_S%#?DtA?9M_Zx0ztF#`^{y5DEvvv8>i@L+0b=e)A{h3+D6YgXR&t6!Y~5Fgt(D95g?{Q@=k8TecZKdE7tW z`1kRAW1_JW_WleIaR)q!F_N)VpKyFPq9mVi{4CaA?lJo?n|jVXZnL(|F0<#@bM1Nd zeEd%9Q+9=2X;+yK+j`qz8*P(qwmEx&U2WIc3+-Aw?fYSSkzHq=uos(;nvdB_?4@?S z-C#G`%j_n5x!r89uv_ev_8s<}cB{P#&k4T9UTfcFud~<)XYZNdK;XtQ}+usiK8+ipAXbm7}=r|q)cw#W9`J8Ylrw*z+2?zVT@J@zhp zw|&3eYd>J`vG>}2_V?_4_JelFeyD7)yQ3bP+i!CF2Dfi?`zE($kzl*WmhZaQ&}#`wfndjg2K&v~+c~l-$r>7-%WEzGGKcOZ?jQ4&k+89* zv#@iZOpXS7r^>#sV^{ltewpvNvs;f^dj{I|Xs|cmUFjO+yIcDULbkEt!l^xd1MNM# z82Bm?=P_1e*hUAV)Z;4-T5#R~SFFPJOdKSmO^a^amICgVp{Z=MI`Zokrh0=ex*x z%1xe5qc3Unbs9Y#*^|{~U%AP%(HI=~-WxqXO~HZxs@ZqZ;OR&KDK~mnn}f=paHXC^WA#hxwEvbqpz*AFr}k=ck3XU5Aev9^>-m# z+S$@p0DyJ}C-MHl?$VuwERBu>}> literal 0 HcmV?d00001 diff --git a/src/dosbox_mcp/fonts/Px437_IBM_VGA_9x16.ttf b/src/dosbox_mcp/fonts/Px437_IBM_VGA_9x16.ttf new file mode 100644 index 0000000000000000000000000000000000000000..0032bb20b4ea5f465716b6e4b2d53f51f7e88f16 GIT binary patch literal 26264 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�w@BC!N?JR7}R)H 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 literal 0 HcmV?d00001 diff --git a/src/dosbox_mcp/fonts/PxPlus_IBM_VGA_9x16.ttf b/src/dosbox_mcp/fonts/PxPlus_IBM_VGA_9x16.ttf new file mode 100644 index 0000000000000000000000000000000000000000..ac76345105976f049efc408f98d2a8b391c023d2 GIT binary patch literal 71144 zcmd?S34m4Qc|QJq-yIfV1cvbnZg_`r01<&<7*Iq6MBGpj6>$l&44YvKq9KMDlTNL* zX-aKkjIoI^){?{~G%<;_HZh5{#Ms1Io0vo*ki^n7ZT(r2xUl@5=Xt;HoOAEY07?4) z{kMDP-h1wubKdV=o_G0{bFTBwxdHBWS8|JH&%W}KJ0G3D+xcTE*n8snv(B6CMwTWz z-}pMdkGSlLaTDJ8wGaH#IlmI$7cE`0czxTnLGN*{&o7+o{hbwyZ(4uwASn69SMmS8 zD^|a2`Csk4Ym9TpyyV>Oj+M(6cMSgKN6*LqaJ-we5@25!6)#2*qX(g)|W2x0sp@M$5+-aUbF1-`9JucbHASMT(7g& zue)jE%g?-iv2*)wcW%h~^&8f$f9Lm)+6Z~3;+#huWyR}f@0>L7hJjOm8T$9edCon5 ze%l@V{Lzn2?r`qF`ch}zHhk^NeR|-Wx@`y6J6G!Dcf6BQ@lnS;r2%-GuHE3V=22Jb z^jlmn$T6L*mMZDfb%X^v_9#2!0oU(q)7|TD;ai1!=8v5>*4^&h!TrWV-8R2vk#k>r z`Pa^cCrW#8NX2<_R(xS^dO(-1LtpHjn;mhFZRPuQnA@KHm;c|6r+qpPTWVV$`5X50 zoSbU@FK+St!##M69oO|}@U3u%er#s?Bf9mf4|0nl#$8k^m6^~8Z>61M7be_qRYq2Pwoo{wb#}SeF z(fl92$)4P6|IIB?q{eMhEOM3pU%jvTTRNueXQGz3mz~)S!t$eHZ1iJzRC;CPiP22P zp0=~2yV*eQFh1B~p;aB4z9&x8XIDBU?BeT0%V2d+F&O#{b{eS_WF6Ry(gV;(uk8r z4g39JpBVPvl|hwqWq4&|WqhTr(q5TTnOd1%nN_*6^6tueD}PjZsPdPU&r}|1>ebZP zbZpb`rV&kJn=Wr!*!1D1t;56c-oyJ2A2@vQ@bd8Eho3zB)ZwQMzhL;{;md}9wAnR> z=0VL(Ev_ZB)VB<3Ii_W3%ZV+gw2Wz)*fO_D8nJZ5@)2(z+4xpCxF3}Y9}h%1-wZ<~=hKk$MabC+ zIX_tXl;m`ETOj9iydx%;zr2nP0oY6 z4u1FG*AG5&@WzAl4xV)2*9Sgx;L`^_w*RgDzuN!H{d@QS_x{)Re|P^=`#-dA<-V); z&HLGypAG+6(@&oH$zT8EhTZ4xp1FI*?rFOx?QY*aad+$P@w>7Y zKiRcw*UP(pyz7o#@7ndwU8{Gk+O=fYj9t@r4cxhZ=R-Rm-1*6!AKQ7~&U<%$c<0?a z|8VD>J6G?#YUdR@D=$C(^54At$jg7e<7+#El28b>DuaK|t{xQ_Y?l^b6I|2TFk{jkuMy1%~hP!5WifeHr+(k{) zqHA}P++=r#JJU^ZXSu2FY&XrFgX(sMo9WJVv)p-ZwmaWl;4X9*xr^N#cZs{yUFPPx z%iTP8g}c&S<>tGq-2!)wyVhOju6J*93*8NFkz4GRxTT0-%MfK&xRq{|yV1Sft#)hN zTDQ)vckgf;+)ZwyyV>31-sv{Ecez{LyWM8@9(S92FQZfFM-wAx{r_DPf8ic>Tis{e z7u;9e-??wNue)!$zjsf$r;%x%ao=*^cF(%+xPNrtbWr={i*vs*XiyIOWjA@lkQL4UEzc7!|o5mm)u{vzX{vi-Qg?YF?WZ%Cp;WJ8$RQ| z7{2KK$bHP;@BYF4z&+sJ@BTP^-Y*W1hOO=c?*7o>-se8)9&%syZGK|7D$EaW3k$`d#Qh^J}hzfhU>x&VY&O1`>cD=J>ov?KIcB~wzz;6r`@w#_f2Z5xZ}J=c&F-UqlfT8kLykTM$h`=tcm$HZ=8yGL{H6Xz|3Uw_ zeRP~F73IdvU)-d=Za-BWe1_Zrn} zQLhcXw)A=gk^ZN>e$_kl9?*M2?~dNL_TJk2x!$k$8QN!RpX>YF+2?b8p6v6zzM=22 zeP{Mv-uI5apX>WV-vdWgj+%Yc%A@W*YTHpyAGNbz-+sgUP4Bm`->v=b>-W`uyZaC7 zKfeF${ww?6(f{%OyX%jsA5lN2eqsG>^>@|ZU;kwN?xWq&ryPC3(W{UC(9w?`{hgzK zF<|h35d&rpSUBL;0UsamjR89c4j4Fg;6(#h54>yOR|o!hQ13w_2Awfz{-E`PK7{AC zLEjs6pkZ{w!iMz?Pc(d|;lSWwgXa!jGu^4;ZUk9EhMaqPll?>_daV_zLIV#u{aZXI&pkgpEe zIkbN0)S(?i?;85((4QVR;J7KrbsX1u+(XB0KknzppLqQA$E8RW(ZE$fl}UKw%XhSq~y7knTMu*WOM$a6*a`c^}9~r%U^q$k) zX`@fO^t4+}d*HNZPkVjLDPt}jvvJHlV;&px(&_H>F{dv${m#=LJN?D6y~j=*yJ+l( z#y&arwQ-ek3&w33_w4xk@fVDL|M;(tf3tN&>)h7cS|4uxPV28GR3^-wuy(?h316PD zyKO+*w6>LP_qILJ_S1=jCQhGt%fzh{Uur+TeQx_*?T@v;IcfZ)Ig>U_`rxF;C;e*j zgvm=LKRo%xGX|V7`;6Pp*m}nEXS{jlh%=|1dF7c)&fIk79cMmp=9kZW?#$Pw^qq3z zlryH>IAzn6ty8v7*>l#Qv!`|N&ak2(9I zvsa#d*VzxB{n*(rPU|;q^t5@??wI!Yv>%_-@0>B`EIQ`{=RA7O56^jX`iSWlOy4;D z!RgOV|M`reGbYYhGUL`6AD{8mj8|sX&73%M?#$&g@0j_yna|GLbFMpg)VYh!z5U#W z&VBmapU)aJYr?E0v+kVr*sPzQH|)IG=iPYT{pUS%-p<)!_KCBn&0aG5_Ss*U{loM7 zo3(pOJ2Kl@TJo(-Eiq$mp*vuW0yXE>1&q_x@_!ab1&O? z*}a!NbJ?47htIue?%KKc&;9b;@6FwN`7xJIx_tiScU}JI<-6y(dB@M2IB({>mGf?! zcmKT4&3k&@i}QYU#h@$3UorcNMOWN%#obqY?uzHG*mK3cD=Sw{zjEP~o3H%%mCszc z=gK#)8hX|EtL9v_@~Y0O?zw8)RnK1a(zH& zeb3boUA^tfKlGT~N2+*ahPkT(qEL!EFofS@7ir&n(!v;6JaaTr=sK1=p;- z=B{fVzUHZGp11!odrtEnKqj!G*8fFzSX) zH+*?fSTuLhJ&S(0c=Y017C*ma^pegc4=#CP$%{)~TYBu$sY{nHy=UoDOZRltcMR*8 z)3KmqbH`mB4|iWQ75A*zw&JN3FRplXW$%?IuAH=T&dMb#H?REA z%12gyXXPua+^S)#Cas#Y>c&-_tL|U*#Ht^!I&kBt8zA-p6opx`?hb#SNvmp$G)B0(IwUBUa5kyxBjTP z97wc*a8pBbxrvlIFrs&0Jx|$!Gs6=HUf932qtw~a!Iag9Z#uW6vm3?P$)Eym8jY{^ zQhQsuskyza5iI(qa#I8LcXaqIpj{UQ^! zERnb&FW?3SX@Dm6jm&I^O(_KX=-i-954>>T1sJ%)WP)8fd*TNc0WHWC^&ll}hl!0F zNh><&=Z6It)9vqE3l(7uH2b}3+|;t27y>pc7-=pML&y211`npy1jH{@UmlR@F40X8%3V+ZwbzLDC84a5OuY-lD58yX|u zGy}QGNEEpOU~GRxWweLJrB6VAgQ%tgBo^!el)?KV{?76Z%~D>nD%pj3gcX1V4MiY= zU&?Lc-NG?FBLbNO4RkQiWU!C(L<4>cavUrt8PYNT^C#7N4__+rcfiOucUSN3?( zyL*+}+Q}WB_QqJT{Xo4K28RG9rMIY)xg&P5$>@J|bBKUewZV2`ThV)PY!tVnZjQd# zTkZLo?}=jq)}B;UeE7Y#@z$gIMEk-Z;vaA`nGlC`va<02j%erRc1b4)$_|vvC`mfW zQkU7agxIKfIvJ-Usx%CVpHNYJ_%Y!LXdm|ACv6yG>Cx!-x+W-DiWW9N0Jt>{aP(FL zGemfZ0v-;m=Lnr)AUpv*wxMt9T49%_On$>Gw)?tx#Ukr6~EZYh#;AL0aJKsD*zdu@LMqdM7^62z_WVRyG5#M-S6K@WF;m{ z{Q^@?6H%zK%8Jbm;16eQ-_Gd62a*nE;tG7=oXHXAW^u-S!!R=+Y{dso5oSRL=fm5O z&!Dwjo76H~RBFirfSpRL7tS-3A$#FTt1uq(3`M#aIl8pDfb(w5uT8MgUIatPpYc?x zfTuhMmC*tygSHS%6z4Ht>WHyLtx!~x4bmc)K_Bq`f)?A5x9wqSVfi3kWXChNt1Y$E z+@5qD77+B*K5)vSV?nAn8W~f|fOiAkq>Ol{!;p1oUuHCfkM*ik(2DTgi`l4=ZrV)0 zC^z#I<*vFpP@Te!{6Y9)4g|s=iTQQZ3EV2vNefzsTxwb$f-m35G?C9w(lvPxo&i3# zrRWHRHUI&Z8%*2KGWJm_YXf$r6qKL(V==|{)F3w15`{ohmNw81yhZ%8reG{~W{C8j z)NG%Kdu3{m)bj^?n2hTYKWGT4)8dVCTMNd`Vs6VK1jy1Z(h_8Ys-Z7N2vk;h&^FW* z|E%_)1+qOB;~AP*J_;uEGkQMa6RJi}C?cWY94LW>nPQmFumQhfH6R|u1$cp0V~aeA z^_^9C$tM%8aIVfI*Thz|gEXKwGL=p?UoQHM&Y|o$Q!mI&y`W86%sym5(ufcG0cLt) zEmK=75kGkFWE{8s;QdJAW2gss3)Rp-^n$erG4kZMKwEo>0{Rp+2QfXhguUCjzXj(a z0N^1R7)w=uCWP20L?}EHyY?BC5kC^eBHN=Y{R1JwyR263~Mnv^S8d zI7T_LUK>v@>X@8R`BbdxGsu(Fo>UzLlj2FTimrNYMv*pyY#=E4M2|HR)lHg~SuC4l zFI_6&4GqJ8Shvx8iSIV^E>>vDKX}tItnB1{ux(k#(R+zqyyKi4e?3LuKMq>g&A{6O3(=n4!$QVs-Iq9LEs zmmJ_gJ=K*k#&Ino1fdH`!n>-X11~UC`7Qf~`n~CRI=R)Qu7yogr-*N8i9t<L!AK z&8u`Z9%6o{PS`4A1M~tO&^D_O;2Ox@gOfY<4J91+Z>??*c_5x!7WdKeY)(N?=lU^F zEwHVVbNt#EWA5>JjE)gM8>K@quEITAjV7VSV9jy~8|INjXq7)Q8tQ!3>d2uQPs_&( zY2GGOGVN4*levvX&maS6nbD*SfVjvQL1d{GXx=M-jwlL!g4^o^d-BQ1uYZ_0s0r~H$L!A zFVFi>tYlz*5=G`D5OD~cGmjk_A7;0^@HE^+b&7C$vkHUX=x%%%Zc^N2dpMh=9GcQF z1~}d~RM=z>3w>tbr6C$DwV8}#Bq@gcFMd@ku=l|;N|l56GgFu0BOD8p9NSBselO^v zZT5e`cIs2^fkq0rh8v@8MF4m{GrJCF-2x9$VaRX=L(&R*H3n|Pha8|Da0 z=SDe{F(C;=h(SYwNG3XMOk*jgLLUuSSCes(^Uw(O=x7Y{85`^skQWlf!-O$@m@mPP zMP}$!@kPffRIOK7pE?V`=cQGV}l%$V8-sch)5Mq;6ywoDM(b z`5aNV9vON-U`|OE^m03Ko+#xEannfZusE#>c1Q;R1sjadOjTUGo{vu*g_TGgNf4%O zd{Fi6*e!d=I*Dv!j0$mwCs+@JL*sM{n51qSPR{LGT^9%u z7QjrO#x*#Q{=~VELCB(c!&mY}tiR7|m}q(ljizIp8uP$NGsa}0bJGm#_~wSFV-9+v zG0c{rL{ga)bqibd;piYdS^AXKaUMBABdWxvsf`xGI}0k0Y_$7X4`veS2#M-IzU+g5d<31 zqliU|iSl?E*>sxM0ce-8)$g@Zzp8!I0kDUjQK!@-zsGzU^-7Ink2Ow-_cHqa{bIh-O4Mf)J-D1?*+$G3NkDOc!drgJT(cY`g<=P2dKj zu^LST-gr8SJ-L?g{cv-%Wv&7X+7e5|vfm8ygBJy6(+vk{-mjo3zDS6W>! zKp|_gb3~sYwqY?E4222jhD#isj6A5qXe2!#3P^&xqaWLBK^f;L=_&QV95g)L0sMhJ za!;dK+R#nw8p4~~TQYm6JvJLjtPqa`INuVCs_h&x5C268h51QV$IY1OMX;tgj5)ws zw{sdr-BGqO>dBMhh)@IOfW%{Sne*uU(g=Y)UcCT$F=ULu7N$<@lOlyhx+gS(T%%Y6 zO^T1w9{D-?60{5Zck(fD5C2!(z={$1Yo=d0CG-g$@OXS^-3ucf{8H;)T4|LSHyA6d z-^y^IBo3c}EvSdR=(8x!#9ZCZqf>*j3NT^{x7Yv^aHukWXwE0nL41ZB^jo|e)6^Og z0KHNH655ETtB^q-5%GC}WOd*eM;iy~BBs$Fg;VlNzl4ma_)woKnp*M2901`6GAP(A zfOZUT)XN@J(7(-E2F!CYtO7T%k%w*|Jq3aJz)uIpbQWN?4dWDCR|W_x5#s>Jh`6l8 zkdBQy!m;pNI4)zc`iQDPRI2g>m{C4PozRxbtL9^ozIhozRzLz##upl94yo}ojPRhb zETT%wjkFU;<~rvZJ5k`9tVC+LsiQpFfi|EK%|8=AC*vxIe2$)ifwpWJ-%AZ`$3;X6 z9~3{Q%q?pOR*Wsi5321zeH>F|Rhl-0D)5&~=}(SeEX z*pR8R)1ZU8f)2*ndKc5RG|*KG*h90cdVyxhRb#oWJ&v0L742;qd2N6TkJChE^HX32+X7UUeH9 z|Ak!CMs*t}Gy8NShuX06sHxj_7{9ERus$hdGkL5nCQ7zel&2Y20v)O?XiPn$?%Rtc zs!6BC0_q4OFQ*pyooxb(veO*dTPrnskT7dWBLW5;YSEC!aLrK&KG5ZkEvXGiFU&_g zus+t>JZpz~t44!jT+CirSyd*`=wh6KLw@L;YG!ONl^?gE!!1ksqGa)^z?W2K4J)@E%13TZw0h}lFy1K7YRvlq0CKa%BC zHzB76xld7pKBGtC89rkkqj0Z|gX(2QKi187HZbd%#}K@tfH%TO!ya^aN})e|*s4RR zB-)#PiFHM&#X%K%fu33Dfp)9`6o(-LsvuaH+M%nnEp@<^H!)!zS+C-qUUEp@qxR(S z)%?S@hZLd8CoP9Cwj8pxwlc<&IJ;5M6#At^Eceyw%49NKvG`m7duS|x)xO2UBt7O2&&=P?;uP;tEp?TvK4M zgScXJ^4b7x7b%peAK}mt%YH$AKE71@Q-&MkLH-M2u%^}!3J)VJ%&!a}Vn&?$Olf-G0CTd=mW+Tg`b*LIDbGPP~zvil-l z6%#ntXrpO4niyF!B=HImT?Jw=D&W2A`6j82#sP^man0%3SW1cs#r6@rGq^+^%&1+- z{ms@GQgx~b9j(dycqir*GA?aj1b+m(WQwvY+2I=a<(ExcXFr=Xo|Ao)ukP9Osn?P zQc)h%V?$U)_tX}UM-U4wjP+gGLL-V&U0P$bwnvJqYAcU{-AG29v#r`zm`uBuCee;| zIfNvQ=8W(F)C>SxZ5yPoXbYyhwAGnJvGz1akzdgQ9mV(<+py?JeQ8xla|<;Gc$pNi z4H_cogl)MWw)0OL*XfAkBpCBW)tv_u)r4$;)F};di$c~a4h>kK9XSpC&Bzw?#4)j^ zEVrS?L`)DZ&eX!~2(0KMdl5D*cuHF)f{jp!eKr%_lkSmn@f?l2_1H%2Tcb_lskRL< z%uFS>G|^1^HS}A0lmYh0gphx9X@hEklB!xUqq8Z>QpY#Nr!hSRJVEJ6XV&dnIIo983UO9LeLFE#r(P z#e*G1gd_$G%Ny7Si5ZQkc~qnt1&p@sgXo9qnbbh2fmfy#qaCh9Q`Jkbh^!(6eT{ux z&Pl6HwrgK&^LVOpNC)1k8Zya&kRb#j+bG$96k7o%m47f?DYDY5um~Hm#_Nt|X6@>_ zlmrcfkt8(0M;DsVfiggmPtlB^j9x^}EHRR9ca)2?frI|CV-;|0WZD_O;v$50hC3GjA)Uje1~-^>q^=c&vHUcHLpUVsn578 z8a44-XF3ILXn{1|nW$@)i_)W|vNT}yH~_+4tXVYQW_;8@5Q1IwJiVLKvJ zrVCZq%y>4(LzYE4EJ$+2=*^2@^tiOf7&8y5E0~wTFM>F8e?6UN z3&9M1#3CmZkO2m$Mc%tDN7`Kor# zfC)Qr>o}~3(Opay+RoxwFTxXLkMXn6i|bl%i)wWZoXvN{Acrhzz0WFs4>hhUJ6WqPJ`ARwYC8A?x3)MTI^_Wds2hCsoa{-549Q>{_&K zk1}Bn43>pNXs6UgCgOyb`VmD+VwB13L3rY!>mk%UEq8!O0 z-N}J{l+OBbaatnELTM~nn73GnqndIblszK-jAK%(v4_nO)z|{@s5=?>X@wlk`5>~<_Jsh##fleX`9?^~7*+O?LXKGLFAugww-<9vY35^P7{F}nz z*;PUq?dq7yDhygrqaWZ{`Q8KhiThGJQ-W5966_57G^ZX4tg1rx*p65T&$6*Mr!kyZ zlM(AczcfOtlBU@=tFjC-^eNs>r z0&94+E>VG51~o(j7U<+0{Ie8FHUzdcS`f8NZ9;bFLwcoN+8RoTHsYW}%4rw9F~>;9_rPpA}FmikE)IE*#aVYJVhoOu>rTBH4# zW))%!$`^EKYJmr_Eaup5HD9FO%=)CT?meTgv|E(Is6&=&+#g1gVx+0jKWj@kLpLZ& z(=c7_(BoJQTrCB(f;4mkmTnBk%;`CdXcKug9Xa|M{P;NBQ}gs5vaamW2#chAiyjZt zJ#a=;dvy?0U9nE7TDuFy9~QN&vRah^5$y_-ef?mg{VR z;X`fVE55T~fD-TQ6ePc_iY-SbZ?0Gcwr=F5c8^HfC@W&Z2^`Bkl&rtBN-oBWT6sXcJ5ekS zKj&AEgA_9^yUhvt?m7|1*@E#@7xR7v(l!nIbfg^NLXc3c#v6K0Oc|@&T^b0v( z&v-1x63tbk_h)@yyq9uU(~Oe5%bcs!33;|~kPJqcBkrsGn6wHR0s6sgaPnKL`jMT} z6){GWoifV^>uFk}s+3PZVl0AQ04%QWQ>}^7IQk=Bpl?rpSGeJu7PRw&Jt3|NK|eL4 z6W2;{R!MnF)srkqYS3%hID{7I;nX2V5aI^Jj}D&rQlCeoZsZHHf&x*26-<^FL6zf7 z>=7_?C}^cfizty#`=`7LIR`TH^+2zN+L$c3Ddd1XK#Mx#8~bBgE7}Ooe32hs++e&= z51p^S}TszV#R-UuG-$qkOz56oj7ANBc56;wrT4hsLQC0DqYYrqJi+8 z>5M#VURW{ccl&1sAcn1uuv2%>Q{Q6a~}yb3#zMqxba zfTV~RjGspuszf3cv|Y^P{7O1waEg(Ebfy!L$*$S5v3RZx!OGSmM~r`w6D?|rdaxiD zudl>hdym&xp)vA`hkT^OHL&#O7Qv9iZxo)Phf#rCmi7R4V)q4z%<)nzG^2lUY*VuG ze6BLFbu&Dl1Jo8p5tET&B{KQd0%%;Awr^;<(yZ4p#eSW9gI6CX8;~v7l)i#&iWvy# z3rL8l0%7?tp&4_>=b`>bv5SUB=4~Ce$1TObG!+EL; zl^0tLCF((dS6Eq%F#R?RQ934cP_@a};FGbz-W28024t|p2dV}l1S%YX#`1hp+ZOna zZBZM)L0j-&MW+~7tex2B0$#ERhtWc5r@ECIDFC9FGoXHKM*V>C?0WX7AI3A%!#x*` zbx;x>gf5%PXYB(>bsA5?rsy0cY$iQ3LW)Gjiw1DM0|Eh!ie zq(lQ?9XIg9n1+Zl#8S2ZDR-s`P`}cL#wY8$qctEi`fpj*7iJR@s3E$r9`p%?2x4wK*Oo=Rts)1OcD!;7-l|cl7 zSJH)l$2ptCYg*yR2-TSLD!a-niu^LR;!)~E>PB7w!$qC4g6qmJR`x3hO0yZiRD*@- zT(K$2QV;ofN)5j@ZzW<)Rr9DMi0FxsqH388nqz`t<2Kog*Wt?>c|AORrxfj`+`>AO zwD^XeO9$>r%=(Kjk(d=|%q(F|KYf|T)vPFtE_0duj8$#SliP!{mO8qwx4SqVr^+V8M*i_F3l zi1TE)j$l*0OS#j`V@BClXIhBu#lQofj@pDVX<0$UhDzui`5H|$0n(AaYSP2dZ;J7( zJNjvW01(pDG6f)c^FP&Kitp@i#(EW)43Es>9Bx-A7|?@JHtbC8!Y%7jbTYkYBV#oumHHA(NGnCX--Y zjX)Vx3m+*?!d{HO&55!hE%988z}@hzS|ZbRy0?xsP^$1E(`;-J@wi9ssd*KSh@S=S zLC1=fBL7AlbaVeZ1plQ%MDEJJVotZ3r?=<2S54F-_a!rjL@00%;&=i&Mea+T;-7lS z^NGe!SpQqm#r&MCYp6{6LC%(qqbHIwu}MoS@zci1>}kVV%-e%3N8Fw_L~;GD8mVf} zMkORp)tdpVT0Xlf?g-?|Fx`EdhHEn$Rh`S5XVj$EIgqn0-qi^jwP*KwW!I~W1Mq@^ z+uM4Omn~Ag-8@R2ac72i=s8JkIT9RnX`M;AfPK_XT{R8D=4KEU|1F3O(`T(_?ZVGe zyHVzD+C{6sLA$Ay+Rxw8#^9%X7#vH>5y=vmdM|Yv12tGaB5Bc|&YNxOM$6_u^4D51 znLo5I>R34lF}`?DuWD^FSEu`X;YJeb7h4 zNJHc|(vc?E{{?--GjdC}0YUL7Uhx%sja}7JkRJh^<146ls`RSdu8f!K6la4^(nt!$ zSF#HCAeR|`pcnb04@8~P--`T^4%mzQNy-QV^Dkzx6jL}hWffUm2i5fo6krRd!xHjj zk|!0;hL+yPRKMBNUd4_K9fAwN2)J0Tah6lBUJ=~(Mq}$ zPPJY{;}G&fh^Ike6a?T8W3h$0L}D^tr~|IS-L341WBCCdrOw1@qRX|DsII%GOGS!% zwL=Bs3EKG3XZ0PHVje;ZP(-57H6EA?JJhvGUHJzIY8dL_C<3^*Sa0t!l*Yo9jiD{} z-+|{$7DJm=?Qj!Y2U?Vc8fO(9*MjEos9kJHHXx5;PIf;7XzWlDCBHX~fq+Saard5N zQTB@XVXi@59LY+FPLe&*p+s`fJGb;ictJAO6$-=xxwYn!HJCDZM?qT7{!7$lo84Wj z%)H5a{HK;lxY#N~T2rI_TTGBHp(gquu2q=Ch<2p5bgMP)sg;xK^-^2>Jd~X9Y!Q`8 zYh>gCT>EFOh}oQQjMKKhBemJBe4N!Gv1+*>Tx=t?gln=nK%3)zBJ8wNMgr?`ZW6T=@68EcMWvz{f2P~~Y@Cbn2CGaFjHNKKYH$%q~lmGWxL zj#o%DY5Y}Uu{E`r-SweSC@02kya7@H6L^_YL7PqL9_wh+l$+^uj@I<@&MomSGDI1{ zOT97-VjI%YS|0nkhw5#>9*-B%r!gmrsFoD}u-C*kdGJ+_Qm1`lsS!&P`ZnyU>zcR4 zkx4{<({RKHT1!_p>)JQ8PlcNp3jJu9T(BeqFRaKAMmV*@eOTl$Ce=6a3%lMVzxqum ztiIwzeHu!MmJ_zkRMt*DN@;FLQQ0@gLngC)iCQWjYdPd70FV=r8f!E*T0OP{`S?F- znzCVoc;QQa8pqf3_FzGKMk~sev?J|zMD2lc&BI!Y^?>?os=+1Ll>|Jd!Q(OEh5+Cai^F3bUcCMQ3_&nvMHFGs6b>M@sw+rO-n9_cci~DLjCC8Dp-dC{g5@N zQqfqqB}6-v1=^FXx$a0SUBDSeViZ+bMdNx?Jhf6pYBL|#dBk3vV?cz&TwDPu%6`A= zy`gxYH{56p{z4l=07g)}$hUI3axdCW@ezw&+VLbO+%k@s>+ua@Fs(|xcwldO&!rDY*Ua$6H zC6+X%UJd%Ezy~dfjdW{QQj|zSdG`$5wF%a)0KqO%t_F4xdDiW z6p({&)J7VPDs{2}{6j@0Ns6xBlMTA*2FXY%1Xk*cFc*sdSRpc{tCj&#(30&`Z`z$c z(~5g)L$BZJ_hJxhS`9taCDDzlTlIxf7d07c?pz+$rCsFd{$9CUMZyPk9m^o|G08!# zRJW~fW@r8eLFB_AM@ZHjuOTR(X~zAc_54GI{}sCN> zAE7`cL_ENIRKu64OAyLeXTVrEo&my&z@pyz5JwFC{9RHZ2+>$^l9TFPg4 z$qV#D`q+ebadfNL2K+&8z`~7BOJWM1P&Sl|Cm4pxb@y-cHB#k+;LAo|;!fd$4iR%C z`~mvwt|}%|=3j7N%NwZ>rt0$ZfyYCQe+dI!NEyMHFS588=T$VvuL7DPLqwrlpA?pU z*U*eV-K(4fBKQL-%7z$_vP5KFJ1sv|^PP`0nQk-qN~>y=I!1HSGeuxSeCC^Kn>Msp z{HW3o{3~VB+*?f!qh`$G4#&E@o9menDnvNZ5l%5ap*UNNLpAx6d{srDOc^yPlX(bQ zSIwF3SqoVk>ATdpP>PI3!4y}qg)$^x;I5)vei+AZlFb+eK!sSxll+OMUCEDas~O2C zjSqbxob)iJt6Wc;<^Ik!+yS1LbdrMSXpho*eJ+V#$XY_Sbxp_K$ zz7bKFqN1m0Ri6N3aRbqo*LXs5@oS}^)~Uy_1+8q7_RAz8TnLj$)ckDs_@LsXZ^cH! zMV$cb0)6g7BpHJ{Z)(A7juq)r_o}g|obVD^kH5T5!a#~bF`3jP4EE_EEN(#z75<=0 z#9*#ZtpT4#fpgG-S@Om=#oze*WLa&(Pz8U3?xN?F1!B-;dBTS2Z1|w47<2=3*dd1S zX70#uv~koUvB%oI3dP_+=%u!D1)|ZEjo=Lgq!iFHKcN4_`Uc0dULx{|0~}55Kpvjn&TX>5 zm)`K3sFpm9H+HGB7=d-pXpK)*H_4}|>$rA6HU=$8AQ3od1oqe?08fZx1-&Zo%BT4o zP$H`P{uwW#p|Z3d+Ogz-{}klH`B+P8(a)l-R6^~UOt24trj8lT#IO)_l4FLxPrMms zh#H1HzUQbBD?)L6Gu#l)G94R|?uf|D{)!EUtEuv!P_z}tSD;NiffoFl5G{-FW0{d? zqYP1prav4<)iKLJ7dQukCUisZrA~aQr}TgrtJbIk!$lT%sK-olE9XgvE+S{a+@Lo3 zh14ZBBrnRW`_;#I7@O7ZEJ{s zs}bAO0!Z+w36kQ%0>-mKEn~k}QDZ-8Q8a`*d0K)$ZJA1q0v%f;EtxW3KhuKand3Zemf1P70_ixN1C%h?Q@hyUl9LuEjMKv zJ`RbOdLk;QZF4A4B-yC)EN&rcl3BW0!aV3(Jdb`K4F@|CUwA)~dx)lblD{E*n7%zy z=b`)d#vo0@hm@1_iLt2SokOxbC|)fe$MH99?j;^&(A;TpO>~c~{MPs3w%_pJ zb*$99G>zEYj^qpP8=~)K1=UZ&6d^*WqGy9{Ep$WYhBxI{gKlj6$>=4n(Kj}5TK25Kzi zOAIg&Wqc}jmYJo{0-wr3gfj^N6soSU3F*yq_z%mP6b1aJ`W`tCwqXKO(!;sR5Gu^* z?yMV>Ct&N}zORND3P)$(#4PDCs!2JJFRuu;V-#Rc`rZ5%^=hbSvNja3|U z2f1`2EWStf*6&vG(la&5H__0&(^QEu_n5{a71*LkB_B;@C^tem|HuJF6e{M??+G#) zKm#ffddgTazSa68a6v5~7Kt0$wh)_?2Vp=NG=VUVq(Nei@un->GdZ(4kG$e!3u;5x zkfSAxJd>#2xQK%8kuJB2L;bb%3M&(UXx2GGTO)Bud^0-3a7Y&DugKpF+@K5PQJWgM z>7+EolWL%B+|GbUy6|W99}tZ|hzj6>1HgejQ$hk$XnC&Cd@Afo{~{lJLe_j;gIa^A z(v>+hcU6yv7wK^%GIAp?14opUIE(Qh@+79{Mhq`3r;7La{J(8nooiX(Uv`H`K#M_w zrh=JRST}p>mo=7xkns(4F@f&eOldULvwv2p0s!QA&!etLfrfOXPi*CpQb>ihl449Z zRbzdKV`ed8o#?k`!3T&|)q#W%L8=dN%-E1#*l%7+d=alXzY(pBmPnpN6)>M_P1=)p zLn_e8GALR@J2Wx2qGbzo>G$N9Xv1gnDmfD0FoJKcq#qAQbWK==->2&~yPS=6A;d0T zu_s7M61i0EnKpv0Ql!9B!8VaA&@n%thAF3{u{YW#Mi7Q4{I6DbyxO1A2|AJx`T|4N zZ#Sr5keQh<={)me##Bl|bl@zuFB|ghP~-1MHXoAvsro9+xaOTmTrhH~Drr|0sfsw2 zA};<b-P0_n1ViPG$ zR#*|T*>XOZkZn1Z1;1rqmA!TPlMtp)XdnCj-!UE@#~#pU&8h%JG^>hm9}6w*XY@+? zm;QiH_OPTkL=@&YUcRSa8H2Xy?df>wE8^KATbubnB3|m;H?)}lPz&X}Sg4M1FF%7< z2gg_<`l^LASOrz($rwVls16*ZKuQ96j7H5&Oe$VT2yV$+%Bi-MaEjH+ z1A4Q}C8vR>5V2P8X3kU8lkV>mwVTBXW*6pjimCd-J~jJ_?~EGAD7>p5Y^sL?Vu?016R+CsO;7`hr{^W63B@F$YbcC!JGAsu7W? zvTCMqHUgrYl7Px6sd0{a773O*LDlG1$(JfqXpOU21MFb5FZ(KJUv zSBI%Z=q6k>!)TE7lE&yDnZ=eQ8QP`-(UjG@)(Zd_i?t(c-hC~?QF%XxiZmXh59;{e zZk)Nc7SYvF7%~)A;`lPWn{cB&SZ*p<(9IDj1c%r`f(*KtAsZ>>ub37w2b?5t01-}V z(YRWxtFDoHYz4r@@m=W>((!%~v>oFU*uZbjLWj^f7rW_Hnz6{YwHNCQwuJNWV&0_p z!)`d+2Jjee`2fEdPh!ts*vwX-%JZm%vV5*kXAm@&w}}IApb!VIrls(m+R#|Buqjzi zQ*8@glxcv+3?o9R;S{x*0f38g!=tsVqXhxW+0=BZ#91 z2|~tM;hpBm#%OEqv&Hr2paDuMvmqhsb-VWKcl}))V3-q5u$%P|WFe#ts-s!$vc6>q zrz@v>;o1EKbNtJXu{tgDVa}?unyebHg_)>H&6Q>2CGwxzv07>_QotGXI*wzo{-b3S zi0mWT75rl+sTy^xwwXtuWN=P1B&4zHO)jK!zHu%5VehR6@8A=>F!q%$)Ae%5r?x7>QP=|UV8%l$cp-Nt>J)qO*ci>%$foN2Z7=n_> zK3*n+;s&Z!AR{FQT`9=<1=@uZlW0?QKNYMXM432K3x1&i53NEhwaSWxXR1aNG^tbh zNQHG8Wupq1^V%SluoPtib*?)lYdLnR?2W1jHKxwuTeZEJ_c(4;RtwagEN=^*B!n^w z>S%ZiIm9f7Xt<)ZF)Pd2 zI8Jk|?1e-$Kp*_I3ACUlJ1bi9JaVcxo(ER>zLnme z+uUptxuDf4r`oZwg#~vYf{GXeLNC;`t^O#yFlr_uY%h+(pJc`@2yDF$T9153tbuP; z)bt+23kV%GoH5PV3ulrNNDpd6953P`-VIyJ0YHUM@?uYKR2!tcqz3hcs7D4f66H08 z+yJbBx6BV9S@Z-@BWijlyb{6~raVhx_j@NNnoFVg!OQ24VRwou3 zr^(S9RJ;IJ{0Rzn*5qz58kL^=8}OJpGgCKuzHA4DlNJcEEvOo@#I~x|3qlfkw_*U^ zD1`!?-g(NApJ%F!vf(@|h@)(WZO_i*JKBPuVJvWOsT1c}oC1f6QDiM$9|+F^Z62fE zw^bO7yNtnd5yb#z2)rmlt}j_q-mjynGModD@nB(;U_fd{l+VxOdSS#4#@y^$0%(|* z5)^17_R(MPM1&};jkBad$V;EPE=+T?vbt;_H*W{#wML0~kDiVv1mJtAg6jlepM5*A zCtlc(wiCU=Z>muXh9u?A^ef&Zi5khMNmgt_UM5mu3sRRl@g{sFx^@pjLuU|cN#5$Dv_R3rI$jPyo#3N z0aB@YJ|br{RU`g}G;0{O(vakW_1{7Be{OAZ(rH}7#9?+=9>G9ZvgoPMP>U(3j>RPL z4kf5p00&S=#covssIU?a7z3r>L)Ob>!xu5YB&-oEnobyFp_>M>N}VKazJ<2X{@y|@ z%EqZL)+@N$qVPY=CLPiL5O;91NYP~6#Wqia|986|I%KBmLf9M+M~ ziw1aR4-hs{{7ZMT=ISvLjeN=t29pen{L*{9? znsyp+D1}g-!6#2)lqhw^Qz8yHE(m#8n*zfYL2<`(44>jrHwS3d-Fr2SL2Sh1QF~(z zC88}c&-3hD@RA-3>@4q@(lx;c9-(7q8C)%n_F7mZP$HbFvX7$R33J8Rf}`Vakm9Ij zu8*af8R&3ad^ld~jQZMe$NCzy$Ubde$2axa8BH`(j8Ql?YELRPK1*nVBM7z<5ZD7F zd{?|ezOk>`hc^Z>?nEoXO}u8_#mEdUfKdPeREldOavSM)BVZ#w#laC@%U<1{8Cja7 zx3R_4;fCl*-@1mPvCvvNO&n58NR(ug&zhaGYcE(CLZ-6L1HaNn{${6A-#``W1N(gi zt%bn-J~RzEBz|Uw0~}c&5tgcxfGynLfLLVz7?A)Geg9TnN(DJo4<5XqxZ%BUh-y`8 z#I*WdWP!((Iuo9Ge*qW*JQ0_MBhqTZgSkY=6Lk4F`hw@C^>Tm`ga`SrDQ795e0y+ ztPOZ5G?0d=wY&oNbHMdNSRsH!!Gb$pJx?NVRAD(kU$rxofSQWRVSiF$TLiJvkue0; zifzU^fT$+^rG9a1zy01`=5LKLEZ|}7$9rlRU9bUi-N;}?If;Vo5R-uvqE7-MF{jGu zp*KdD7}Io4yOc(sz2VtgWbP|bMzn;-J4wm zSz0+=Gh}-EaDjm!!SOYH-(#=!mrK+F5z2Lmd;^x=b;c(9|p@pTN*>jG0C~K zh)FgSF{$LMd=}dPhiIelE27)Kw;gxnc)xy$P zm%rfW%Q*rfy<$$clXoABnK+40F5{ElAg-wtJS~QyEM&?g&d~z4&D;zF-92c+-gbQ% zbi%Rw({RtiHg}GBy?MFb(Q^JQnetg%wa3xWwsvf%@el^}n&w~#%LZl_if=YgA6)Na zI>x;YQ8#fvI|!yI@L9~^y!#)lHyLflYdUE%BA?hGTX;@}&RO@wc8nqNL1geV1)umI zs*SCoE!qz_LA{{fUR)W01~>{uY_LBM>hw}PEQ0fs>%X`|_ZMPyi6Q{^84x+fe_=2F z!V@m4q1oDcnRQ7MPef*}NyCHpeB`IdntJ#R0fhF&zamyt*T4sbW#vRV3zPx-5N+TT z9BU1;d?H7(*1U-%V8SrSe{pP6^8XL43qr)m?iF~lYdtKgxRM`FjoBOCEYeeMJ?Pks zp!mWhXZ!2}{9|Alg&Q9F50qY4)qdgrbHX&+DxeyxG@zwMT0^643_mE-U~p?7y=xW> zT+q`Pp}S*)ZbvNfK`2%6(RL(yP{1SBlx?Gb8$rDr7i@*mx+fme3E%-XjkXx)&dnnW zutANhC2Ol%CxR%BkZ*&VT8^R;q>0p81~agx5z*`)MMf<&*}uBzXxx$L2o@^LmgbFc z3r(|&q)q!z)REzYp(XNMqZ`D&6m^eWH(fdDRxgRAo|RI~R}nYk4jYwWC0dJJ3tEXU z`qN=_LNFz^Od5Xr#T1)(-fjxqy2y(8! z$?f-B`Z;xEct+gq*3T{89NN#tGy8kh@#Zvl))85(fMCX+!}4U9&Z^RGLr|uh%*TvB z+;6K8kK5d2@TmG;_M*+&sgL}bMMUw%_z)3h@ai@)#>f|~Q#2abQjpHLGIQ%bU>RSG zPUpLe55pHY?TYmr_lM&X@G{)nheCA^3}=vxwN&;Uaigfu(_n3KerSD??;49IUp*Yp z(Ls-d<0#4@^?bUU;m&gx9-d#KBG1Umk4J#T9tP_#}?GfEjJ>bj79v4hWulvu1(>kKtz&$O&xS5414UezQeXsjp2Uo(1kR;7YZ z(of#x)qJt@j2#V)-qSx!Q>RclZl?HxLMGd?*?ej}S@Yfgeoabn?zuHpfDIn4iTvKyXPZ)Sxs{9i9H=IYR7C|_AX4EX2G468Z9x0gv39cy5Y9F9Uy_04I zTe<@f$H_eSIl|IM;B+D=Zs9IpHeiD0c2y zN|!2}XOFQ9@{&ec0Z^ntAC1Ahy>P{*u>p7_dUQhYq_GBK#Pt3f#Vk*d* zCu#Cr`#)oc&IVar!B~O5IA4c0!5`0ucQ=7Lhvw?GA!F+h<4f(^F`&Xh2i(Pa8%(P5 zA@Kn^oEH`ywnXcLSCHDblk6sb(s7Jpq{hi$kbC1v*frH7&@tr#J;vKyGT4g|MYJOC zsZG(sEd+4;T{T)o%WRN%vv$RwwJjb~^Y9zl0=#HGoE)^^RqEuGpAmEr2gl(8Pt0-g zUcTg_t>LtQOBnVdePF`Yj1+AITDpfdC4oGk1>IoX373KH+lhG-IxOZ0que}JM-pBL z@xT}6A~|~6@Uq|3zBAg6Yh156JQWr8wTegI3jVaYmFEwU{rcZ zJ{0{(%*&I6JNd0LjO1<3uouynyB*LeTt%MHFYv-ii&W~tS*ero0wTd%3?7`VI8guA zqrIoU@g#K|V@cvszAOAh|1J2iloqjp<9VNJ+N9Y_3zQ1Lgc9T;ayq&`+CYSh{zTn@ z&x9yykH2gTPhhWt%tl;s(NWBy@aGA#>4|eS(cRZR1 z=aagyfpQ)yoQtpMYg%=i_-Z;S@D#FTDvVs^E=RtyI7L0sP1}Kaw8#j_XhqfxoAxsc zhE3TwLvw~WthN*6U|s|`!yGe%#qkoNjOh&#i&`@qGjc&`ye%kofbxawC}rWywrUTx z8Dx`PS?%db0aXtQB7zBtfJQ{5Y!~$jdy9q~1nr}2kyg3N9CnMUO?8m0Ar*7nnz1ip zw1GW_q-D~w$QBVR6c9y;8H+I{6v%ZNc%?_Iq7(+HdDwQ}W=0M~EbI01G-o zoA0Qm>?_Vv&xU8S8U9Z|#&Xhl2nU!MR5=@!&f|9@Q2a0UI5%B*V}Im#)%)+cKX8BK z{>1&6`-uCP`-Jc`rownAs-4; zRY!C^gL`nCfd%hHyd_!^tf9yv9WeJY z1A|1QgY^$~Ef5?uJ=OJwOLM#f51!Kz@Ak$mJmUFTd#asuhK(x_jC@c5)?{^ihz|La z1Bvmemh9B%Fz7VsJ8P)ex7W3n8HLDo;M_r8M+Z2jd`ZAk#wdiFrLFUS@T#9g5^{-6xzbo z4qL4qt0bsdwF2uG&cism&V-MmL3$O9M-Ya(XUI!MIUQIuhgDZ(ym zZu4`!^EPvE%Tgy4R+qJv{c;Y+)su!BqwRV9a4*`QL7ZV2gDnuA!j@1wuqZGbpmiAy z>wzQ#*B<7z7d@$rx`;^-=}YQlE!&QL7|pXD?tnzX6XOGHp>af;b1-i}UkDl@ zSsQ`U9iMSp)Pz_A9n%Z=BuV2Is!_bq`mB7;2J+j}r~-TNIh@23@rH8&bbaAhY}lIE zsArAQaYWjHioOOk3zUee6sZNZ%m$nsuzNG=X7^w+#S_Fk#4k}P>r9NT=}GVBFEqvG zNd&exf%r*tQznaHJ;($~Mu%slN7PUHYCatCFQ1_Z5C-Dl7vI1Y;~;ba0HD)?_71!N zK$bd<2l)TN`{g?}k}5T@R?_*n437cAabVtKxPqSMNU( zw|is%vvIqx>*aqDw~xa1fwfaN$dt?7Yal5bU=Xb{Kqpc!Z|d75&B^rt|& z32rQ2r)M&-0>|n^SckF>y_z*b7qKuybT{DtON33s-RT%TSt)xj!x6;m z+jaIt@Hif4T?lM$#GVcKH0ng_VjStt-LP!&##Og0tDL)T&6;&r4LxN*a(B{y#b zE>FK=<>C#?W~^TI_GOicW5-u6T)T1EhP60+-P*-CwOYodHM}pa;ymeUoVZ&GUe*HJ z%kUZ}pE(^{i?MASkIQtrG_tqUxTA8%tzNZs+1i_y-862=yH3Ap@i=gX<9`>VAque; z#{)Mw%c8yQvG>vdq=4W{zRvgZy?r0w*B|Bk`ToA%AMFSDfqsy0@PmD$KgO5+v3`gj z>W}ls`xE?${v(Gqia*qQ=Wz~w&_5{Okimi)(OpqXIub8T!fWba}ZZ9MUoq{oTI95K=Q~^AmiVpXj}B_mli&e}+HPPw{8@ss3y~&7b3^yVu96wh{ndVfzs6tduk+XYxA}$s2DjV& z#4qxT{Sv>_clc#~xnJQ|`c?i$|8~FHukmZ$Huu+lonPGXzs0}PZ^DS$ zKKE1iGyg7stADrO?BC;W^Y8Vy`}g_x```1O{`dVI{tx_}{tx|K{*U|z{2%)dy1(^* zf{N%*{oU>r_pkoX+{6Au{=@zr{}F$$|ERyuf6U+S|J;Avf5JcDKj~g{yZnO~$$QD| za68=(+{^w`{vrPt{$c-@eyjhq|BU~vf5iWl|D6B4-{$|?f5CszKkC2azwE!_AM=0X zANPOjpYVU@zv{o{pY&h%-|*k`Px-(1Py2uH+x@rvGydEDS^tmzJN~==IsZ@odH>J; z1^+Mpd;a_WMgIf;L;oZHlKYt7;eYI3_B;J9zuW)B|J47?zvBPZ@A3cUfA0U?|HA)= z|4;v4{#F0K{A>Qd(OvkZ|CRr>f8D>~-}GO!y3JM;;C!%?AM z=pX9C(P2Os7zTxgFgP@ZV?sF`8-|3T;ka;oI3b)EP71@q$)OUO!tl@>P6;hxL>L)H zg;T@ma9S7>P7hV;jAzB-?{tTN8NvguZFL=zi@YlC&Sm>{TOBblkg4qA@||%&G1zC`|x!5hp;_- zD?Agv9i9#U7`_v}8=edQ6rK{|x^XUJd^%ycYgD>KIewlIKhKPxGvepW_<3&pJTHFEw$Iksers&E zHMZLt+ii{Qw#If_W4o=f-PYJ{YizeQw%Z!pZH?`=M)_J}`>nD4$?gdZsX#ms0B9mTbgb5p0#u_NcZlrk6CDE@y0&q#Riwf2G6qwm+7P5 zdD+?hmSvlL&Wmg;v(Mh=8O3G#_}^VzTW{mW6_zQHvP7xD26@gRXi@ZDayVnjP21GA zq+O@!da+((lSLYUl4yx*iq!J5-rY7uddox77HNv2K>tFM_x_GP70nOn}w4qk0)K*$m8}Ff=MIfZcScFtWxhOc?$D@|CN2%vSyATM zdKopx>7p5>i}o3ZmvMM!6kcXT|7=;Mi!iTNNm!NrY`5gxBjkJsS#fA;;M^nRoWwcx zEsSu_w^QO4Tb*J);H5eUoclDmG@^B1FHor$=zL1lz}k;m!6 zuJpZsvFm@o+AXy&cp)p2?t`sg?)8rT&W`?Ut2+-V!skYGA8z(v_xVP(lPp~ncDBs& zlwv&|bdR)WG$`Dt?W3`&YKM`L?pcw9#gg=Fs~anVPY=PThv3sg@aZ8Gol0Urr;IqE zQzjUU?J`TVqCbzLJSyVCl)6XBdKntE6Q)@iB~cvq#_J-6`=)w-TypydT+?OWk$6`Fv8L)m7$)Y~RH196s<_Ui97BRsb9rm=n z+M}1aV_oisn~CT}I}z=}5t<$ykI`PKsJ$L)Ds3~qHAQ!Op@XyMQBsC&w$*x$hNf;T zO=#MfePu#ROlaif@rjjI*3-D1QGCsOnK^|H&bN(dmWYw@n`^{3!!O*iGd=M=ZMJsn zrT=rY$2MuzxX~24X0TDojS*#*U_zNpa>kV&?JeoiQ!72nG}5d4(OD!tIvJ!#8Bcn& z7o|sgQF@f|q(>*7^eE#=k20C`DC0?wGM(g%D>>sz&bX2@uH=j>Ipa#sxRNujIpaz}^C1Pt&k05fj$d&6g5wt)zu@=<$1ga3!SM@@UvT__ z;};yi;P?Z^4;Vk-`41RBV0?T#s^5?#Cmk6ue!%zv;|Gi%Fn+-J0pkaZA27aTe98Ed z@g?I+#^+olCC4urUoyUAe98Ed@g?I+#+QsQ8DB9zpKVexzG8gE_=@A_%p?`#E5=uh zuNYr3zG8gE_=@or;~z5qA;qtbsa^4NqDEXlBEQs#{8A(GOO41cHKKV@Bl1g)$S*Y_ zzto8QQX}$9jmR%GBEQtA#wEYhnEX;>#ve2OnDNJqKW6+fJ^rNjxw~%(*tA^PoPOI@h(P_p3iXoH*~$fM-VN zTH*O#_cC5oc{E2hX z0~1laSeDCPxx||;>4kd<(v$UGft5KDB*>47@dTVS67i!$uI-o#T_b8&<)(9O8Ht=m$=V-TTQ*_o;c64$#*V4MThBDfW>;pV z6doolzAoETn_QC2OD>mqgPupWxwq$6Ms_aaB*D3nJ#}tlk_Lfu+aD@PLispubaRgF z(FQjkE~+)joXhi+%E_gpKMW+186O0lIDIm$Ft*ehHFdLflyYUU!k12$;Vi=bd$K7s ziz;<4@ku#}TKZ-eYk1t6CDqhkMj=4wafq)9=I*L0cn?Nj+0pAGY(P)=@9tAC9A&!^4*>cYQn1s`hp#TPft z|D-al7P$43y$^7I8Rb3j>!1O_vvu7T(Ie{LH{Z^br*d4*TBku1FX6W zR{fhdTrlh3y6J*l|2AsAgR2FW{kvf8zlW;@uKoMae*nh435NZ3=s(2ef@%NJ%P!dV zA47km@y4qzIQO4Gf4A{2>VJ;Q1(*IF>O(NBb@T3`)-0sZ&Z@1TEVeFzI5TOXs(pR7OP{)zPoYW`vU8Tx0|XN^{~Ykl5u!I None: """Disconnect from GDB stub.""" if self._socket: - try: + import contextlib + + with contextlib.suppress(OSError): self._socket.close() - except OSError: - pass self._socket = None self._connected = False self._host = "" @@ -185,7 +185,7 @@ class GDBClient: logger.debug(f"Sending: {packet}") try: - self._socket.sendall(packet.encode('latin-1')) + self._socket.sendall(packet.encode("latin-1")) except OSError as e: self._connected = False raise GDBError(f"Send failed: {e}") from e @@ -212,20 +212,20 @@ class GDBClient: data += chunk # Look for packet boundaries - decoded = data.decode('latin-1') + decoded = data.decode("latin-1") # Skip any leading ACK/NAK - while decoded and decoded[0] in '+-': + while decoded and decoded[0] in "+-": decoded = decoded[1:] - if '$' in decoded and '#' in decoded: + if "$" in decoded and "#" in decoded: # Find packet bounds - start = decoded.index('$') - end = decoded.index('#', start) + start = decoded.index("$") + end = decoded.index("#", start) if end + 2 <= len(decoded): # Complete packet - packet_data = decoded[start + 1:end] - checksum = decoded[end + 1:end + 3] + packet_data = decoded[start + 1 : end] + checksum = decoded[end + 1 : end + 3] # Verify checksum expected = calculate_checksum(packet_data) @@ -234,11 +234,11 @@ class GDBClient: f"Checksum mismatch: got {checksum}, expected {expected}" ) # Send NAK - self._socket.sendall(b'-') + self._socket.sendall(b"-") continue # Send ACK - self._socket.sendall(b'+') + self._socket.sendall(b"+") logger.debug(f"Received: ${packet_data}#{checksum}") return packet_data @@ -284,16 +284,28 @@ class GDBClient: Args: regs: Registers object with values to write """ + # Build register string in GDB order (little-endian) def le32(val: int) -> str: - return val.to_bytes(4, 'little').hex() + return val.to_bytes(4, "little").hex() hex_data = ( - le32(regs.eax) + le32(regs.ecx) + le32(regs.edx) + le32(regs.ebx) + - le32(regs.esp) + le32(regs.ebp) + le32(regs.esi) + le32(regs.edi) + - le32(regs.eip) + le32(regs.eflags) + - le32(regs.cs) + le32(regs.ss) + le32(regs.ds) + - le32(regs.es) + le32(regs.fs) + le32(regs.gs) + le32(regs.eax) + + le32(regs.ecx) + + le32(regs.edx) + + le32(regs.ebx) + + le32(regs.esp) + + le32(regs.ebp) + + le32(regs.esi) + + le32(regs.edi) + + le32(regs.eip) + + le32(regs.eflags) + + le32(regs.cs) + + le32(regs.ss) + + le32(regs.ds) + + le32(regs.es) + + le32(regs.fs) + + le32(regs.gs) ) response = self._command(f"G{hex_data}") @@ -312,7 +324,7 @@ class GDBClient: response = self._command(f"p{reg_num:x}") if response.startswith("E"): raise GDBError(f"Failed to read register {reg_num}: {response}") - return int.from_bytes(bytes.fromhex(response), 'little') + return int.from_bytes(bytes.fromhex(response), "little") # ========================================================================= # Memory Operations @@ -371,10 +383,7 @@ class GDBClient: raise GDBError("Breakpoints not supported by this GDB stub") bp = Breakpoint( - id=self._next_bp_id, - address=address, - enabled=True, - original=f"{address:05x}" + id=self._next_bp_id, address=address, enabled=True, original=f"{address:05x}" ) self._breakpoints[bp.id] = bp self._next_bp_id += 1 @@ -528,24 +537,13 @@ class GDBClient: reason=StopReason.BREAKPOINT, address=addr, signal=signal, - breakpoint_id=bp.id + breakpoint_id=bp.id, ) - return StopEvent( - reason=StopReason.STEP, - address=addr, - signal=signal - ) - return StopEvent( - reason=StopReason.SIGNAL, - address=0, - signal=signal - ) + return StopEvent(reason=StopReason.STEP, address=addr, signal=signal) + return StopEvent(reason=StopReason.SIGNAL, address=0, signal=signal) elif stop_type == "exit": - return StopEvent( - reason=StopReason.EXITED, - signal=info.get("code", 0) - ) + return StopEvent(reason=StopReason.EXITED, signal=info.get("code", 0)) return StopEvent(reason=StopReason.UNKNOWN) @@ -562,7 +560,7 @@ class GDBClient: response = self._command("qSupported") if response.startswith("E"): return [] - return response.split(';') + return response.split(";") def query_attached(self) -> bool: """Query if attached to existing process. @@ -580,10 +578,10 @@ class GDBClient: def kill(self) -> None: """Kill the target process.""" - try: + import contextlib + + with contextlib.suppress(GDBError): self._command("k") - except GDBError: - pass # Connection may close immediately self.disconnect() # ========================================================================= @@ -599,7 +597,7 @@ class GDBClient: raise GDBError("Not connected to GDB stub") try: - self._socket.sendall(b'\x03') + self._socket.sendall(b"\x03") logger.debug("Sent interrupt") except OSError as e: self._connected = False diff --git a/src/dosbox_mcp/resources.py b/src/dosbox_mcp/resources.py index 8cd8172..ecdba02 100644 --- a/src/dosbox_mcp/resources.py +++ b/src/dosbox_mcp/resources.py @@ -13,10 +13,7 @@ from typing import Any _screenshot_registry: dict[str, dict[str, Any]] = {} # Default capture directory (can be overridden) -CAPTURE_DIR = os.environ.get( - "DOS_DIR", - os.path.expanduser("~/claude/dosbox-mcp/dos") -) + "/capture" +CAPTURE_DIR = os.environ.get("DOS_DIR", os.path.expanduser("~/claude/dosbox-mcp/dos")) + "/capture" def register_screenshot(filename: str, path: str, size: int, timestamp: str | None = None) -> str: @@ -81,12 +78,14 @@ def list_screenshots() -> list[dict[str, Any]]: # Add registered screenshots for filename, info in _screenshot_registry.items(): - screenshots.append({ - "uri": f"dosbox://screenshots/{filename}", - "filename": filename, - "size": info["size"], - "timestamp": info["timestamp"], - }) + screenshots.append( + { + "uri": f"dosbox://screenshots/{filename}", + "filename": filename, + "size": info["size"], + "timestamp": info["timestamp"], + } + ) # Scan capture directory for additional files if os.path.isdir(CAPTURE_DIR): @@ -94,12 +93,14 @@ def list_screenshots() -> list[dict[str, Any]]: filename = os.path.basename(path) if filename not in _screenshot_registry: stat = os.stat(path) - screenshots.append({ - "uri": f"dosbox://screenshots/{filename}", - "filename": filename, - "size": stat.st_size, - "timestamp": datetime.fromtimestamp(stat.st_mtime).isoformat(), - }) + screenshots.append( + { + "uri": f"dosbox://screenshots/{filename}", + "filename": filename, + "size": stat.st_size, + "timestamp": datetime.fromtimestamp(stat.st_mtime).isoformat(), + } + ) # Sort by timestamp (newest first) screenshots.sort(key=lambda x: x["timestamp"], reverse=True) @@ -123,8 +124,9 @@ def capture_screen_live() -> bytes | None: """ from .tools.peripheral import _get_qmp_port, _qmp_command - result = _qmp_command("localhost", _get_qmp_port(), "screendump", - {"filename": "_live_capture.png"}, timeout=10.0) + result = _qmp_command( + "localhost", _get_qmp_port(), "screendump", {"filename": "_live_capture.png"}, timeout=10.0 + ) if "error" in result: return None diff --git a/src/dosbox_mcp/server.py b/src/dosbox_mcp/server.py index 04e436d..b6473a4 100644 --- a/src/dosbox_mcp/server.py +++ b/src/dosbox_mcp/server.py @@ -17,8 +17,7 @@ from . import resources, tools # Configure logging logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" ) logger = logging.getLogger(__name__) @@ -53,7 +52,7 @@ Address formats supported: - Segment:offset: "1234:5678" (standard DOS format) - Flat hex: "0x12345" or "12345" - Decimal: "#12345" -""" +""", ) # ============================================================================= @@ -69,6 +68,7 @@ _TOOLS = [ tools.step, tools.step_over, tools.quit, + tools.fonts_list, # Breakpoints tools.breakpoint_set, tools.breakpoint_list, @@ -91,6 +91,12 @@ _TOOLS = [ tools.mouse_drag, tools.clipboard_copy, tools.clipboard_paste, + # Joystick (requires QMP patch) + tools.joystick_move, + tools.joystick_button, + # Parallel Port (requires QMP patch) + tools.parallel_write, + tools.parallel_read, # Control (QMP-based) tools.pause, tools.resume, @@ -99,6 +105,18 @@ _TOOLS = [ tools.loadstate, tools.memdump, tools.query_status, + # Logging (requires QMP logging patch) + tools.logging_status, + tools.logging_enable, + tools.logging_disable, + tools.logging_category, + tools.log_capture, + tools.log_clear, + # Network (port mapping and modem) + tools.port_list, + tools.port_status, + tools.modem_dial, + tools.modem_hangup, ] for func in _TOOLS: @@ -110,15 +128,22 @@ for func in _TOOLS: # ============================================================================= -@mcp.resource("dosbox://screen") +@mcp.resource("dosbox://screen", mime_type="image/png") def get_screen_resource() -> bytes: - """Capture and return the current DOSBox-X screen. + """Live capture of the current DOSBox-X display. - This is a live capture - no need to call screenshot() first. - Simply read this resource to get the current display as PNG. + Use this to see what's currently on screen without saving a file. + Returns PNG image data directly - just read this resource. + + Requires: DOSBox-X running with QMP enabled (port 4444) + + When to use: + - Quick visual check of current display state + - Verifying UI state before/after interactions + - Debugging display issues Returns: - PNG image data of the current screen + PNG image bytes of the current display """ data = resources.capture_screen_live() if data is None: @@ -128,24 +153,39 @@ def get_screen_resource() -> bytes: @mcp.resource("dosbox://screenshots") def list_screenshots_resource() -> str: - """List all available DOSBox-X screenshots. + """List all saved screenshots with metadata. - Returns a JSON list of screenshot metadata including URIs. + Returns JSON array of screenshot info including: + - filename: Use with dosbox://screenshots/{filename} to retrieve + - size: File size in bytes + - timestamp: When the screenshot was taken + + Workflow: + 1. Call screenshot() tool to capture display + 2. Note the filename from the response + 3. Access via dosbox://screenshots/{filename} + + Returns: + JSON array of screenshot metadata """ import json + screenshots = resources.list_screenshots() return json.dumps(screenshots, indent=2) -@mcp.resource("dosbox://screenshots/{filename}") +@mcp.resource("dosbox://screenshots/{filename}", mime_type="image/png") def get_screenshot_resource(filename: str) -> bytes: - """Get a specific screenshot by filename. + """Retrieve a saved screenshot by exact filename. Args: - filename: Screenshot filename (e.g., "ripterm_001.png") + filename: Exact filename from screenshot() result or list + Example: "screenshot_001.png" Returns: - PNG image data + PNG image bytes + + Note: Use dosbox://screenshots to list available files. """ data = resources.get_screenshot_data(filename) if data is None: @@ -153,23 +193,6 @@ def get_screenshot_resource(filename: str) -> bytes: return data -@mcp.resource("dosbox://screenshots/latest") -def get_latest_screenshot_resource() -> bytes: - """Get the most recent screenshot. - - Returns: - PNG image data of the latest screenshot - """ - latest = resources.get_latest_screenshot() - if latest is None: - raise ValueError("No screenshots available") - - data = resources.get_screenshot_data(latest["filename"]) - if data is None: - raise ValueError("Screenshot file not found") - return data - - # ============================================================================= # Entry Point # ============================================================================= diff --git a/src/dosbox_mcp/tools/__init__.py b/src/dosbox_mcp/tools/__init__.py index 46db365..77e6576 100644 --- a/src/dosbox_mcp/tools/__init__.py +++ b/src/dosbox_mcp/tools/__init__.py @@ -4,13 +4,15 @@ Tools are organized by function: - execution: launch, attach, continue, step, quit - breakpoints: breakpoint_set, breakpoint_list, breakpoint_delete - inspection: registers, memory_read, memory_write, disassemble, stack, status -- peripheral: screenshot, serial_send, keyboard_send, mouse_* +- peripheral: screenshot, serial_send, keyboard_send, mouse_*, joystick_*, parallel_* - control: pause, resume, reset, savestate, loadstate, memdump, query_status +- logging: logging_status, logging_enable, logging_disable, log_capture, log_clear +- network: port_list, port_status, modem_dial, modem_hangup """ from .breakpoints import breakpoint_delete, breakpoint_list, breakpoint_set from .control import loadstate, memdump, pause, query_status, reset, resume, savestate -from .execution import attach, continue_execution, launch, quit, step, step_over +from .execution import attach, continue_execution, fonts_list, launch, quit, step, step_over from .inspection import ( disassemble, memory_read, @@ -21,13 +23,26 @@ from .inspection import ( stack, status, ) +from .logging import ( + log_capture, + log_clear, + logging_category, + logging_disable, + logging_enable, + logging_status, +) +from .network import modem_dial, modem_hangup, port_list, port_status from .peripheral import ( clipboard_copy, clipboard_paste, + joystick_button, + joystick_move, keyboard_send, mouse_click, mouse_drag, mouse_move, + parallel_read, + parallel_write, screenshot, serial_send, ) @@ -40,6 +55,7 @@ __all__ = [ "step", "step_over", "quit", + "fonts_list", # Breakpoints "breakpoint_set", "breakpoint_list", @@ -62,6 +78,10 @@ __all__ = [ "mouse_drag", "clipboard_copy", "clipboard_paste", + "joystick_move", + "joystick_button", + "parallel_write", + "parallel_read", # Control "pause", "resume", @@ -70,4 +90,16 @@ __all__ = [ "loadstate", "memdump", "query_status", + # Logging + "logging_status", + "logging_enable", + "logging_disable", + "logging_category", + "log_capture", + "log_clear", + # Network + "port_list", + "port_status", + "modem_dial", + "modem_hangup", ] diff --git a/src/dosbox_mcp/tools/breakpoints.py b/src/dosbox_mcp/tools/breakpoints.py index b18929b..da0213d 100644 --- a/src/dosbox_mcp/tools/breakpoints.py +++ b/src/dosbox_mcp/tools/breakpoints.py @@ -1,4 +1,21 @@ -"""Breakpoint management tools.""" +"""Breakpoint management tools for DOS debugging. + +Breakpoints pause execution when the CPU reaches a specific address. +Essential for reverse engineering - set breakpoints at interesting +code locations, then continue execution to analyze program behavior. + +Address formats accepted: +- Segment:offset: "1234:0100" (standard DOS format) +- Flat hex: "0x12340" or "12340" +- With 0x prefix recommended for flat addresses + +Workflow: +1. breakpoint_set("CS:0100") - Set at code segment offset +2. continue_execution() - Run until breakpoint hit +3. registers() / memory_read() - Inspect state +4. breakpoint_list() - See all active breakpoints +5. breakpoint_delete(id=1) - Remove when done +""" from ..gdb_client import GDBError from ..state import client @@ -6,17 +23,26 @@ from ..utils import format_address, parse_address def breakpoint_set(address: str) -> dict: - """Set a software breakpoint at the specified address. + """Set a software breakpoint at a memory address. + + When the CPU's instruction pointer reaches this address, + execution will pause and you can inspect registers/memory. Args: - address: Memory address (segment:offset or flat hex) + address: Where to break. Formats: + - "1234:0100" - segment:offset (DOS standard) + - "0x12340" - flat 20-bit address + - "CS:0100" - relative to code segment Returns: - Breakpoint info + - breakpoint_id: Reference ID for delete/list operations + - address: Normalized address where breakpoint is set + - original: The address string you provided Examples: - breakpoint_set("1234:0100") # segment:offset - breakpoint_set("0x12340") # flat address + breakpoint_set("1234:0100") # At segment 1234, offset 0100 + breakpoint_set("0x12340") # At flat address + breakpoint_set("CS:0100") # Relative to current code segment """ try: addr = parse_address(address) @@ -35,10 +61,14 @@ def breakpoint_set(address: str) -> dict: def breakpoint_list() -> dict: - """List all active breakpoints. + """List all currently active breakpoints. Returns: - List of breakpoint info + - count: Number of active breakpoints + - breakpoints: Array of breakpoint info, each with: + - id: Use this ID with breakpoint_delete() + - address: Memory address being watched + - enabled: Whether breakpoint is active """ bps = client.list_breakpoints() return { @@ -48,14 +78,19 @@ def breakpoint_list() -> dict: def breakpoint_delete(id: int | None = None, all: bool = False) -> dict: - """Delete breakpoint(s). + """Delete one or all breakpoints. Args: - id: Specific breakpoint ID to delete - all: If True, delete all breakpoints + id: Breakpoint ID from breakpoint_set() or breakpoint_list() + all: Set True to delete ALL breakpoints at once Returns: - Deletion result + - deleted: Count of breakpoints removed (when all=True) + - deleted_id: ID that was removed (when id specified) + + Examples: + breakpoint_delete(id=1) # Delete breakpoint #1 + breakpoint_delete(all=True) # Clear all breakpoints """ try: if all: diff --git a/src/dosbox_mcp/tools/control.py b/src/dosbox_mcp/tools/control.py index dcf2872..6e3a979 100644 --- a/src/dosbox_mcp/tools/control.py +++ b/src/dosbox_mcp/tools/control.py @@ -1,4 +1,21 @@ -"""Control tools: pause, resume, reset, savestate, loadstate.""" +"""Emulator control tools via QMP (QEMU Machine Protocol). + +These tools control the DOSBox-X emulator itself, not the debugger. +Use these for: +- Pausing/resuming the emulator (different from GDB breakpoints) +- Creating save states for checkpointing +- Resetting the emulated system +- Dumping large memory regions efficiently + +QMP vs GDB: +- GDB (port 1234): CPU-level debugging, breakpoints, stepping +- QMP (port 4444): Emulator control, screenshots, keyboard/mouse + +Save State Workflow: +1. savestate("before_crash.sav") - Create checkpoint +2. +3. loadstate("before_crash.sav") - Restore and try again +""" from typing import Any @@ -93,8 +110,9 @@ def savestate(filename: str) -> dict: Example: savestate("checkpoint1.sav") """ - result = _qmp_command("localhost", _get_qmp_port(), "savestate", - {"filename": filename}, timeout=30.0) + result = _qmp_command( + "localhost", _get_qmp_port(), "savestate", {"file": filename}, timeout=30.0 + ) if "error" in result: return { @@ -132,8 +150,9 @@ def loadstate(filename: str) -> dict: Example: loadstate("checkpoint1.sav") """ - result = _qmp_command("localhost", _get_qmp_port(), "loadstate", - {"filename": filename}, timeout=30.0) + result = _qmp_command( + "localhost", _get_qmp_port(), "loadstate", {"file": filename}, timeout=30.0 + ) if "error" in result: return { @@ -175,10 +194,10 @@ def memdump(address: str, length: int, filename: str | None = None) -> dict: """ # Parse segment:offset if provided - if ':' in address: - seg, off = address.split(':') + if ":" in address: + seg, off = address.split(":") flat_addr = (int(seg, 16) << 4) + int(off, 16) - elif address.startswith('0x'): + elif address.startswith("0x"): flat_addr = int(address, 16) else: flat_addr = int(address, 16) diff --git a/src/dosbox_mcp/tools/execution.py b/src/dosbox_mcp/tools/execution.py index 25662e3..0ccb602 100644 --- a/src/dosbox_mcp/tools/execution.py +++ b/src/dosbox_mcp/tools/execution.py @@ -1,8 +1,26 @@ -"""Execution control tools: launch, attach, continue, step, quit.""" +"""Execution control tools for DOSBox-X debugging sessions. + +This module provides tools to: +- Start DOSBox-X with debugging enabled (launch) +- Connect to the GDB debug stub (attach) +- Control execution: step, continue, quit + +Typical workflow: +1. launch() - Start DOSBox-X (auto-detects Docker vs native) +2. attach() - Connect to GDB stub +3. Use breakpoints, step, continue to debug +4. quit() - Clean up when done + +GDB Stub Connection: +- Default port: 1234 +- Protocol: GDB Remote Serial Protocol +- Auto-connects to Docker container or native DOSBox-X +""" import time from ..dosbox import DOSBoxConfig +from ..fonts import get_font_path, list_fonts from ..gdb_client import GDBError from ..state import client, manager from ..utils import format_address @@ -18,6 +36,20 @@ def launch( memsize: int = 16, joystick: str = "auto", parallel1: str = "disabled", + serial1: str = "disabled", + serial2: str = "disabled", + serial1_port: int = 5555, + serial2_port: int = 5556, + ipx: bool = False, + # Display output settings + output: str = "opengl", + # TrueType font settings (when output="ttf") + ttf_font: str | None = None, + ttf_ptsize: int | None = None, + ttf_lins: int | None = None, + ttf_cols: int | None = None, + ttf_winperc: int | None = None, + ttf_wp: str | None = None, ) -> dict: """Launch DOSBox-X with GDB debugging and QMP control enabled. @@ -33,6 +65,21 @@ def launch( joystick: Joystick type - auto, none, 2axis, 4axis (default: auto) parallel1: Parallel port 1 - disabled, file, printer (default: disabled) Use "file" to capture print output to capture directory + serial1: Serial port 1 mode - disabled, nullmodem, modem (default: disabled) + Use "nullmodem" to accept TCP connections on serial1_port + Use "modem" for dial-out with AT commands (ATDT hostname:port) + serial2: Serial port 2 mode - same options as serial1 + serial1_port: TCP port for nullmodem mode on COM1 (default: 5555) + serial2_port: TCP port for nullmodem mode on COM2 (default: 5556) + ipx: Enable IPX networking for DOS multiplayer games (default: False) + output: Display output mode - opengl, ttf, surface (default: opengl) + Use "ttf" for TrueType font rendering (sharper text) + ttf_font: TrueType font name when output="ttf" (e.g., "Consola", "Nouveau_IBM") + ttf_ptsize: Font size in points for TTF mode + ttf_lins: Screen lines for TTF mode (e.g., 50 for taller screen) + ttf_cols: Screen columns for TTF mode (e.g., 132 for wider screen) + ttf_winperc: Window size as percentage for TTF mode (e.g., 75) + ttf_wp: Word processor mode for TTF - WP (WordPerfect), WS (WordStar), XY, FE Returns: Status dict with connection details @@ -41,6 +88,10 @@ def launch( launch("/path/to/GAME.EXE") # Auto-detects native vs Docker launch("/path/to/GAME.EXE", joystick="2axis") # With joystick launch("/path/to/GAME.EXE", parallel1="file") # Capture printer output + launch("/path/to/RIPTERM.EXE", serial1="nullmodem") # RIPscrip testing + launch("/path/to/TELIX.EXE", serial1="modem") # BBS dial-out + launch("/path/to/DOOM.EXE", ipx=True) # Multiplayer gaming + launch("/path/to/WP.EXE", output="ttf", ttf_font="Consola", ttf_lins=50) # TTF mode """ config = DOSBoxConfig( gdb_port=gdb_port, @@ -51,6 +102,20 @@ def launch( memsize=memsize, joysticktype=joystick, parallel1=parallel1, + serial1=serial1, + serial2=serial2, + serial1_port=serial1_port, + serial2_port=serial2_port, + ipx=ipx, + # Display output + output=output, + # TTF settings + ttf_font=ttf_font, + ttf_ptsize=ttf_ptsize, + ttf_lins=ttf_lins, + ttf_cols=ttf_cols, + ttf_winperc=ttf_winperc, + ttf_wp=ttf_wp, ) launch_method = None @@ -72,7 +137,7 @@ def launch( launch_method = "native" manager.launch_native(binary_path=binary_path, config=config) - return { + result = { "success": True, "message": f"DOSBox-X launched successfully ({launch_method})", "launch_method": launch_method, @@ -82,6 +147,35 @@ def launch( "pid": manager.pid, "hint": f"Use attach() to connect to debugger on port {gdb_port}. QMP (screenshots/keyboard) on port {qmp_port}.", } + + # Add serial port info if configured + if serial1 != "disabled": + result["serial1"] = { + "mode": serial1, + "tcp_port": serial1_port if serial1.lower() == "nullmodem" else None, + } + if serial2 != "disabled": + result["serial2"] = { + "mode": serial2, + "tcp_port": serial2_port if serial2.lower() == "nullmodem" else None, + } + if ipx: + result["ipx"] = True + + # Add TTF info if configured + if output == "ttf": + ttf_info = {"output": "ttf"} + if ttf_font: + ttf_info["font"] = ttf_font + if ttf_ptsize: + ttf_info["ptsize"] = ttf_ptsize + if ttf_lins: + ttf_info["lins"] = ttf_lins + if ttf_cols: + ttf_info["cols"] = ttf_cols + result["ttf"] = ttf_info + + return result except Exception as e: return { "success": False, @@ -152,13 +246,27 @@ def attach( def continue_execution(timeout: float | None = None) -> dict: - """Continue execution until breakpoint or signal. + """Continue execution until breakpoint hit or signal received. + + Resumes CPU execution. The debugger will stop when: + - A breakpoint is hit + - A signal/interrupt occurs + - Timeout is reached (if specified) Args: - timeout: Optional timeout in seconds + timeout: Maximum seconds to wait (None = wait forever) + Use timeout for programs that may hang or loop Returns: - Stop event info (reason, address, breakpoint hit) + - stop_reason: Why execution stopped (breakpoint, signal, timeout) + - address: Where execution stopped (segment:offset format) + - breakpoint_id: Which breakpoint was hit (if any) + - cs_ip: Current code segment:instruction pointer + + Common stop reasons: + - "breakpoint": Hit a set breakpoint + - "signal": Received interrupt (e.g., Ctrl+C) + - "timeout": Specified timeout elapsed """ try: event = client.continue_execution(timeout=timeout) @@ -254,3 +362,76 @@ def quit() -> dict: "success": False, "error": str(e), } + + +def fonts_list() -> dict: + """List available bundled TrueType fonts for DOSBox-X TTF output. + + These fonts are from The Ultimate Oldschool PC Font Pack by VileR (int10h.org) + and are bundled with dosbox-mcp for convenience. Licensed under CC BY-SA 4.0. + + Returns: + Dictionary with: + - fonts: List of font info (name, description, best_for) + - usage_hint: How to use fonts with launch() + + Font naming: + - Px437_* : Strict CP437 encoding (original DOS character set) + - PxPlus_* : Extended Unicode (CP437 + additional characters) + + Example: + fonts = fonts_list() + # Then use with launch(): + launch(output="ttf", ttf_font="Px437_IBM_VGA_9x16", ttf_ptsize=18) + """ + font_info = { + "Px437_IBM_VGA_8x16": { + "description": "Classic IBM VGA 8x16", + "best_for": "Standard DOS text (80x25)", + }, + "Px437_IBM_VGA_9x16": { + "description": "IBM VGA 9x16 (wider)", + "best_for": "Better readability, recommended default", + }, + "Px437_IBM_EGA_8x14": { + "description": "IBM EGA 8x14", + "best_for": "80x25 with smaller font", + }, + "Px437_IBM_CGA": { + "description": "IBM CGA 8x8", + "best_for": "40-column mode, retro look", + }, + "Px437_IBM_MDA": { + "description": "IBM Monochrome Display Adapter", + "best_for": "Word processing, green phosphor style", + }, + "PxPlus_IBM_VGA_8x16": { + "description": "VGA 8x16 + Unicode", + "best_for": "Extended character support", + }, + "PxPlus_IBM_VGA_9x16": { + "description": "VGA 9x16 + Unicode", + "best_for": "Extended chars + better readability", + }, + } + + fonts = [] + for name in list_fonts(): + info = font_info.get(name, {"description": name, "best_for": "General use"}) + path = get_font_path(name) + fonts.append( + { + "name": name, + "description": info["description"], + "best_for": info["best_for"], + "available": path is not None and path.exists(), + } + ) + + return { + "success": True, + "fonts": fonts, + "recommended": "Px437_IBM_VGA_9x16", + "usage_hint": 'Use with launch(output="ttf", ttf_font="", ttf_ptsize=18)', + "license": "CC BY-SA 4.0 - The Ultimate Oldschool PC Font Pack by VileR (int10h.org)", + } diff --git a/src/dosbox_mcp/tools/inspection.py b/src/dosbox_mcp/tools/inspection.py index 840dc3e..61b0b51 100644 --- a/src/dosbox_mcp/tools/inspection.py +++ b/src/dosbox_mcp/tools/inspection.py @@ -1,4 +1,24 @@ -"""Inspection tools: registers, memory, disassemble, stack, status.""" +"""Inspection tools for examining DOS program state. + +These tools read CPU registers, memory, and screen contents. +Essential for reverse engineering - use after hitting a breakpoint +to understand what the program is doing. + +Key registers for DOS (16-bit real mode): +- CS:IP = Code Segment:Instruction Pointer (where code runs) +- SS:SP = Stack Segment:Stack Pointer (function call stack) +- DS:SI = Data Segment:Source Index (string operations) +- ES:DI = Extra Segment:Destination Index (string operations) +- AX,BX,CX,DX = General purpose registers + +Memory addressing (20-bit real mode): +- Physical = (Segment * 16) + Offset +- Example: 1234:5678 = 0x12340 + 0x5678 = 0x179B8 + +VGA memory locations: +- 0xB8000 = Text mode buffer (character + attribute pairs) +- 0xA0000 = Graphics mode buffer (mode 13h, etc.) +""" from typing import Literal @@ -9,16 +29,25 @@ from ..utils import format_address, hexdump, parse_address def registers() -> dict: - """Read all CPU registers. + """Read all CPU registers - the core debugging primitive. + + Call this after breakpoint hits or stepping to see CPU state. Returns: - Complete register state including: - - 32-bit registers (EAX, EBX, etc.) - - 16-bit aliases (AX, BX, etc.) - - Segment registers (CS, DS, ES, SS, FS, GS) - - Instruction pointer (CS:IP) - - Stack pointer (SS:SP) - - Flags + 32-bit registers: eax, ebx, ecx, edx, esi, edi, ebp, esp, eip + 16-bit aliases: ax, bx, cx, dx, si, di, bp, sp, ip (lower 16 bits) + 8-bit aliases: al, ah, bl, bh, cl, ch, dl, dh + Segment registers: cs, ds, es, ss, fs, gs + Computed addresses: + - cs_ip: Physical address of next instruction + - ss_sp: Physical address of stack top + Flags: flags register value + + Key values for DOS debugging: + - cs:ip = Current execution point + - ds:bx/si = Common data pointers + - ax = Function return values, INT 21h function number + - dx:ax = 32-bit return values """ try: regs = client.read_registers() @@ -55,17 +84,21 @@ def memory_read( try: # Handle register-based addresses like "DS:SI" addr_str = address.upper() - if ':' in addr_str: - seg_part, off_part = addr_str.split(':') + if ":" in addr_str: + seg_part, off_part = addr_str.split(":") # Check if parts are register names regs = None - seg_regs = {'CS', 'DS', 'ES', 'SS', 'FS', 'GS'} - off_regs = {'IP', 'SP', 'BP', 'SI', 'DI', 'BX', 'AX', 'CX', 'DX'} + seg_regs = {"CS", "DS", "ES", "SS", "FS", "GS"} + off_regs = {"IP", "SP", "BP", "SI", "DI", "BX", "AX", "CX", "DX"} if seg_part in seg_regs or off_part in off_regs: regs = client.read_registers() - seg_val = getattr(regs, seg_part.lower()) if seg_part in seg_regs else int(seg_part, 16) - off_val = getattr(regs, off_part.lower()) if off_part in off_regs else int(off_part, 16) + seg_val = ( + getattr(regs, seg_part.lower()) if seg_part in seg_regs else int(seg_part, 16) + ) + off_val = ( + getattr(regs, off_part.lower()) if off_part in off_regs else int(off_part, 16) + ) addr = (seg_val << 4) + off_val else: addr = parse_address(address) @@ -121,10 +154,7 @@ def memory_write( try: addr = parse_address(address) - if format == "hex": - bytes_data = bytes.fromhex(data) - else: - bytes_data = data.encode('latin-1') + bytes_data = bytes.fromhex(data) if format == "hex" else data.encode("latin-1") client.write_memory(addr, bytes_data) @@ -176,26 +206,48 @@ def disassemble(address: str | None = None, count: int = 10) -> dict: 0xEB: "JMP short", 0x74: "JZ", 0x75: "JNZ", - 0x50: "PUSH AX", 0x51: "PUSH CX", 0x52: "PUSH DX", 0x53: "PUSH BX", - 0x54: "PUSH SP", 0x55: "PUSH BP", 0x56: "PUSH SI", 0x57: "PUSH DI", - 0x58: "POP AX", 0x59: "POP CX", 0x5A: "POP DX", 0x5B: "POP BX", - 0x5C: "POP SP", 0x5D: "POP BP", 0x5E: "POP SI", 0x5F: "POP DI", - 0xB8: "MOV AX,imm", 0xB9: "MOV CX,imm", 0xBA: "MOV DX,imm", 0xBB: "MOV BX,imm", - 0x89: "MOV r/m,r", 0x8B: "MOV r,r/m", - 0x01: "ADD r/m,r", 0x03: "ADD r,r/m", - 0x29: "SUB r/m,r", 0x2B: "SUB r,r/m", - 0x31: "XOR r/m,r", 0x33: "XOR r,r/m", - 0x39: "CMP r/m,r", 0x3B: "CMP r,r/m", + 0x50: "PUSH AX", + 0x51: "PUSH CX", + 0x52: "PUSH DX", + 0x53: "PUSH BX", + 0x54: "PUSH SP", + 0x55: "PUSH BP", + 0x56: "PUSH SI", + 0x57: "PUSH DI", + 0x58: "POP AX", + 0x59: "POP CX", + 0x5A: "POP DX", + 0x5B: "POP BX", + 0x5C: "POP SP", + 0x5D: "POP BP", + 0x5E: "POP SI", + 0x5F: "POP DI", + 0xB8: "MOV AX,imm", + 0xB9: "MOV CX,imm", + 0xBA: "MOV DX,imm", + 0xBB: "MOV BX,imm", + 0x89: "MOV r/m,r", + 0x8B: "MOV r,r/m", + 0x01: "ADD r/m,r", + 0x03: "ADD r,r/m", + 0x29: "SUB r/m,r", + 0x2B: "SUB r,r/m", + 0x31: "XOR r/m,r", + 0x33: "XOR r,r/m", + 0x39: "CMP r/m,r", + 0x3B: "CMP r,r/m", } lines = [] for i, b in enumerate(mem.data[:count]): hint = opcodes.get(b, f"?? ({b:02x})") - lines.append({ - "address": format_address(addr + i), - "byte": f"{b:02x}", - "hint": hint, - }) + lines.append( + { + "address": format_address(addr + i), + "byte": f"{b:02x}", + "hint": hint, + } + ) return { "success": True, @@ -231,12 +283,14 @@ def stack(count: int = 16) -> dict: words = [] for i in range(0, len(mem.data), 2): if i + 1 < len(mem.data): - word = int.from_bytes(mem.data[i:i+2], 'little') - words.append({ - "offset": f"+{i:02x}", - "address": format_address(sp_addr + i), - "value": f"{word:04x}", - }) + word = int.from_bytes(mem.data[i : i + 2], "little") + words.append( + { + "offset": f"+{i:02x}", + "address": format_address(sp_addr + i), + "value": f"{word:04x}", + } + ) return { "success": True, @@ -272,17 +326,17 @@ def screen_text(rows: int = 25, cols: int = 80) -> dict: # VGA text mode buffer at 0xB8000 # Each cell is 2 bytes: [character][attribute] # Attribute: bits 0-3 = foreground, 4-6 = background, 7 = blink - VIDEO_BASE = 0xB8000 + video_base = 0xB8000 bytes_per_row = cols * 2 total_bytes = rows * bytes_per_row - mem = client.read_memory(VIDEO_BASE, total_bytes) + mem = client.read_memory(video_base, total_bytes) lines = [] raw_lines = [] for row in range(rows): offset = row * bytes_per_row - row_data = mem.data[offset:offset + bytes_per_row] + row_data = mem.data[offset : offset + bytes_per_row] # Extract characters (every other byte) chars = [] @@ -292,11 +346,11 @@ def screen_text(rows: int = 25, cols: int = 80) -> dict: if 32 <= char_byte < 127: chars.append(chr(char_byte)) elif char_byte == 0: - chars.append(' ') + chars.append(" ") else: - chars.append('.') # Non-printable + chars.append(".") # Non-printable - line = ''.join(chars).rstrip() + line = "".join(chars).rstrip() lines.append(line) raw_lines.append(row_data.hex()) @@ -339,7 +393,7 @@ def screen_graphics(width: int = 320, height: int = 200, mode: str = "13h") -> d Mode 12h: 4 planes, more complex """ try: - VIDEO_BASE = 0xA0000 + video_base = 0xA0000 if mode == "13h": # Mode 13h: 320x200, 1 byte per pixel, linear @@ -348,7 +402,7 @@ def screen_graphics(width: int = 320, height: int = 200, mode: str = "13h") -> d # Conservative read for other modes total_bytes = min(width * height // 8, 38400) - mem = client.read_memory(VIDEO_BASE, total_bytes) + mem = client.read_memory(video_base, total_bytes) # Provide some statistics about the pixel data pixel_counts = {} diff --git a/src/dosbox_mcp/tools/logging.py b/src/dosbox_mcp/tools/logging.py new file mode 100644 index 0000000..d1a0e21 --- /dev/null +++ b/src/dosbox_mcp/tools/logging.py @@ -0,0 +1,357 @@ +"""Logging control tools: query, enable/disable, capture, and clear log output. + +These tools provide programmatic access to DOSBox-X debug logging via QMP, +enabling reverse engineering workflows like tracing INT 21h DOS API calls +and file I/O operations. +""" + +from typing import Any + +from .peripheral import _get_qmp_port, _qmp_command + + +def logging_status() -> dict: + """Query current DOSBox-X logging state. + + Returns the current state of logging options including: + - INT 21h call logging (tracks DOS API calls) + - File I/O logging (tracks file operations) + - Current log buffer size + + Returns: + Logging state dict with: + - int21: Whether INT 21h logging is enabled + - fileio: Whether file I/O logging is enabled + - buffer_size: Current log buffer line count + + Example: + status = logging_status() + if status["int21"]: + print("INT 21h logging is active") + """ + result = _qmp_command("localhost", _get_qmp_port(), "query-logging") + + if "error" in result: + error = result.get("error", {}) + if isinstance(error, dict): + desc = error.get("desc", str(error)) + if "CommandNotFound" in desc: + return { + "success": False, + "error": "Logging QMP commands not available. Apply qmp-logging.patch to DOSBox-X.", + } + return {"success": False, "error": desc} + return {"success": False, "error": str(error)} + + if "return" in result and isinstance(result["return"], dict): + ret = result["return"] + return { + "success": True, + "int21": ret.get("int21", False), + "fileio": ret.get("fileio", False), + "buffer_size": ret.get("buffer_size", 0), + } + + return {"success": True, "raw_response": result} + + +def logging_enable(int21: bool = True, fileio: bool = True) -> dict: + """Enable DOSBox-X debug logging options. + + Enables logging of DOS API calls and/or file operations. This is + essential for reverse engineering - you can trace exactly which + DOS functions a program calls and what files it accesses. + + Args: + int21: Enable INT 21h logging (DOS API call tracing) + fileio: Enable file I/O logging (file access tracing) + + Returns: + Result dict with new logging state + + Examples: + logging_enable() # Enable both + logging_enable(int21=True, fileio=False) # Just INT 21h + logging_enable(fileio=True, int21=False) # Just file I/O + + INT 21h Logging: + When enabled, DOSBox-X logs every INT 21h call including: + - Function number (AH register value) + - Parameters passed in other registers + - File handles, strings, memory addresses + + Common INT 21h functions you'll see: + - AH=3Dh: Open file + - AH=3Eh: Close file + - AH=3Fh: Read from file/device + - AH=40h: Write to file/device + - AH=4Ch: Exit program + + File I/O Logging: + Tracks file-level operations with filenames and byte counts. + """ + args: dict[str, Any] = {} + if int21: + args["int21"] = True + if fileio: + args["fileio"] = True + + # If neither specified, enable both (user called with defaults) + if not args: + args = {"int21": True, "fileio": True} + + result = _qmp_command("localhost", _get_qmp_port(), "set-logging", args) + + if "error" in result: + error = result.get("error", {}) + if isinstance(error, dict): + desc = error.get("desc", str(error)) + if "CommandNotFound" in desc: + return { + "success": False, + "error": "Logging QMP commands not available. Apply qmp-logging.patch to DOSBox-X.", + } + return {"success": False, "error": desc} + return {"success": False, "error": str(error)} + + if "return" in result and isinstance(result["return"], dict): + ret = result["return"] + enabled = [] + if ret.get("int21"): + enabled.append("INT 21h") + if ret.get("fileio"): + enabled.append("File I/O") + + return { + "success": True, + "message": f"Logging enabled: {', '.join(enabled) if enabled else 'none'}", + "int21": ret.get("int21", False), + "fileio": ret.get("fileio", False), + "changed": ret.get("changed", False), + } + + return {"success": True, "raw_response": result} + + +def logging_disable(int21: bool = True, fileio: bool = True) -> dict: + """Disable DOSBox-X debug logging options. + + Disables the specified logging options. Call with no arguments to + disable all logging, or specify which types to disable. + + Args: + int21: Disable INT 21h logging + fileio: Disable file I/O logging + + Returns: + Result dict with new logging state + + Examples: + logging_disable() # Disable all + logging_disable(int21=True, fileio=False) # Disable only INT 21h + """ + args: dict[str, Any] = {} + if int21: + args["int21"] = False + if fileio: + args["fileio"] = False + + # If neither specified, disable both + if not args: + args = {"int21": False, "fileio": False} + + result = _qmp_command("localhost", _get_qmp_port(), "set-logging", args) + + if "error" in result: + error = result.get("error", {}) + if isinstance(error, dict): + desc = error.get("desc", str(error)) + if "CommandNotFound" in desc: + return { + "success": False, + "error": "Logging QMP commands not available. Apply qmp-logging.patch to DOSBox-X.", + } + return {"success": False, "error": desc} + return {"success": False, "error": str(error)} + + if "return" in result and isinstance(result["return"], dict): + ret = result["return"] + still_enabled = [] + if ret.get("int21"): + still_enabled.append("INT 21h") + if ret.get("fileio"): + still_enabled.append("File I/O") + + return { + "success": True, + "message": f"Logging still enabled: {', '.join(still_enabled)}" + if still_enabled + else "All logging disabled", + "int21": ret.get("int21", False), + "fileio": ret.get("fileio", False), + "changed": ret.get("changed", False), + } + + return {"success": True, "raw_response": result} + + +def logging_category(category: str, level: str) -> dict: + """Set logging level for a specific category. + + DOSBox-X has multiple logging categories (CPU, files, VGA, etc.). + This allows fine-grained control over what gets logged. + + Args: + category: Log category name (e.g., "files", "cpu", "int21", "vga") + level: Severity level - one of: + - "debug": Most verbose, all messages + - "normal": Standard logging + - "warn": Warnings and errors only + - "error": Errors only + - "fatal": Fatal errors only + - "never": Disable logging for this category + + Returns: + Result dict with applied settings + + Examples: + logging_category("files", "debug") # Verbose file logging + logging_category("cpu", "warn") # Only CPU warnings + logging_category("vga", "never") # Silence VGA messages + + Common Categories: + - files: File system operations + - int21: DOS INT 21h calls + - cpu: CPU instruction execution + - vga: Video/graphics operations + - io: I/O port access + - dosmisc: Miscellaneous DOS operations + """ + result = _qmp_command( + "localhost", _get_qmp_port(), "set-logging-category", {"category": category, "level": level} + ) + + if "error" in result: + error = result.get("error", {}) + if isinstance(error, dict): + desc = error.get("desc", str(error)) + if "CommandNotFound" in desc: + return { + "success": False, + "error": "Logging QMP commands not available. Apply qmp-logging.patch to DOSBox-X.", + } + return {"success": False, "error": desc} + return {"success": False, "error": str(error)} + + if "return" in result and isinstance(result["return"], dict): + ret = result["return"] + return { + "success": True, + "message": f"Category '{ret.get('category')}' set to '{ret.get('level')}'", + "category": ret.get("category"), + "level": ret.get("level"), + } + + return {"success": True, "raw_response": result} + + +def log_capture(limit: int = 100) -> dict: + """Capture recent log output from DOSBox-X. + + Retrieves the current contents of the log buffer. This is the primary + way to see what INT 21h calls and file operations have occurred. + + Args: + limit: Maximum number of lines to return (default: 100, max: ~4000) + + Returns: + Result dict with: + - lines: The log text (newline-separated) + - line_count: Number of lines returned + + Examples: + # Capture last 50 lines of logs + result = log_capture(50) + print(result["lines"]) + + # Typical workflow: enable logging, run program, capture + logging_enable(int21=True) + # ... let program run ... + logs = log_capture(200) + for line in logs["lines"].split("\\n"): + if "INT 21" in line: + print(line) + + Note: + The log buffer is circular with a maximum size (typically 4000 lines). + Older entries are discarded when the buffer fills. + """ + result = _qmp_command( + "localhost", _get_qmp_port(), "get-log-buffer", {"limit": limit}, timeout=10.0 + ) + + if "error" in result: + error = result.get("error", {}) + if isinstance(error, dict): + desc = error.get("desc", str(error)) + if "CommandNotFound" in desc: + return { + "success": False, + "error": "Logging QMP commands not available. Apply qmp-logging.patch to DOSBox-X.", + } + return {"success": False, "error": desc} + return {"success": False, "error": str(error)} + + if "return" in result and isinstance(result["return"], dict): + ret = result["return"] + lines = ret.get("lines", "") + line_count = ret.get("line_count", 0) + + return { + "success": True, + "lines": lines, + "line_count": line_count, + "limit_requested": limit, + } + + return {"success": True, "raw_response": result} + + +def log_clear() -> dict: + """Clear the DOSBox-X log buffer. + + Removes all current log entries. Useful for: + - Starting a clean trace before running a program + - Reducing noise when looking for specific events + - Freeing memory if the buffer is very large + + Returns: + Success status + + Example: + # Clear logs, enable tracing, run operation, capture clean results + log_clear() + logging_enable(int21=True) + keyboard_send("DIR\\r") # Run DIR command + time.sleep(1) + result = log_capture() + # result["lines"] contains only logs from the DIR command + """ + result = _qmp_command("localhost", _get_qmp_port(), "clear-log-buffer") + + if "error" in result: + error = result.get("error", {}) + if isinstance(error, dict): + desc = error.get("desc", str(error)) + if "CommandNotFound" in desc: + return { + "success": False, + "error": "Logging QMP commands not available. Apply qmp-logging.patch to DOSBox-X.", + } + return {"success": False, "error": desc} + return {"success": False, "error": str(error)} + + return { + "success": True, + "message": "Log buffer cleared", + } diff --git a/src/dosbox_mcp/tools/network.py b/src/dosbox_mcp/tools/network.py new file mode 100644 index 0000000..4026142 --- /dev/null +++ b/src/dosbox_mcp/tools/network.py @@ -0,0 +1,250 @@ +"""Network and port mapping tools for DOSBox-X. + +Configure serial ports, IPX networking, and query connection status. + +Serial Port Modes: +- disabled: No serial port +- nullmodem: TCP server mode (accepts incoming connections) +- modem: Dial-out mode (DOS programs use AT commands like ATDT hostname:port) + +These tools help manage network connectivity for: +- RIPscrip testing via serial port +- BBS dial-out using terminal programs like TELIX +- Multiplayer gaming via IPX networking +""" + +import socket +import time + +from ..state import manager + + +def port_list() -> dict: + """List all configured port mappings. + + Returns current serial port, parallel port, and network + configuration from the running DOSBox-X instance. + + Returns: + Dictionary with port configuration: + - serial1: {mode, tcp_port (if nullmodem)} + - serial2: {mode, tcp_port (if nullmodem)} + - ipx: boolean + - gdb_port: debugging port + - qmp_port: control port + """ + if not manager.running: + return { + "success": False, + "error": "DOSBox-X is not running. Use launch() first.", + } + + result = { + "success": True, + "gdb_port": manager.gdb_port, + "qmp_port": manager.qmp_port, + "ipx_enabled": manager.ipx_enabled, + } + + # Serial port 1 configuration + if manager.serial1_mode != "disabled": + serial1_info = {"mode": manager.serial1_mode} + if manager.serial1_mode.lower() == "nullmodem": + serial1_info["tcp_port"] = manager.serial1_port + serial1_info["hint"] = f"Connect clients to localhost:{manager.serial1_port}" + elif manager.serial1_mode.lower() == "modem": + serial1_info["hint"] = "Use ATDT hostname:port to dial out from DOS" + result["serial1"] = serial1_info + else: + result["serial1"] = {"mode": "disabled"} + + # Serial port 2 configuration + if manager.serial2_mode != "disabled": + serial2_info = {"mode": manager.serial2_mode} + if manager.serial2_mode.lower() == "nullmodem": + serial2_info["tcp_port"] = manager.serial2_port + serial2_info["hint"] = f"Connect clients to localhost:{manager.serial2_port}" + elif manager.serial2_mode.lower() == "modem": + serial2_info["hint"] = "Use ATDT hostname:port to dial out from DOS" + result["serial2"] = serial2_info + else: + result["serial2"] = {"mode": "disabled"} + + return result + + +def port_status() -> dict: + """Check connection status of configured ports. + + Shows which ports are listening and can accept connections. + Tests connectivity to GDB, QMP, and serial nullmodem ports. + + Returns: + Dictionary with status for each port: + - gdb: {listening: bool, port: int} + - qmp: {listening: bool, port: int} + - serial1: {listening: bool, port: int} (if nullmodem) + - serial2: {listening: bool, port: int} (if nullmodem) + """ + if not manager.running: + return { + "success": False, + "error": "DOSBox-X is not running. Use launch() first.", + } + + def check_port(port: int, timeout: float = 0.5) -> bool: + """Check if a port is listening.""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + result = sock.connect_ex(("localhost", port)) + sock.close() + return result == 0 + except OSError: + return False + + result = { + "success": True, + "gdb": { + "port": manager.gdb_port, + "listening": check_port(manager.gdb_port), + }, + "qmp": { + "port": manager.qmp_port, + "listening": check_port(manager.qmp_port), + }, + } + + # Check serial ports if configured as nullmodem + if manager.serial1_mode.lower() == "nullmodem": + result["serial1"] = { + "mode": "nullmodem", + "port": manager.serial1_port, + "listening": check_port(manager.serial1_port), + } + + if manager.serial2_mode.lower() == "nullmodem": + result["serial2"] = { + "mode": "nullmodem", + "port": manager.serial2_port, + "listening": check_port(manager.serial2_port), + } + + return result + + +def modem_dial(address: str, port: int = 1) -> dict: + """Initiate modem dial-out connection. + + Sends AT dial command to connect to a TCP server. + Requires serial port configured as "modem" in launch(). + + The modem will translate ATDT to a TCP connection, allowing + DOS terminal programs to connect to modern services. + + Args: + address: Target address in format "hostname:port" or just "hostname" + Examples: "towel.blinkenlights.nl:23", "bbs.example.com:23" + port: COM port number (1 or 2) + + Returns: + Result dict with dial status + + Examples: + modem_dial("towel.blinkenlights.nl:23") # ASCII Star Wars! + modem_dial("bbs.synchronet.net:23") # Synchronet BBS + + Note: + The DOS program itself (TELIX, Procomm, etc.) may need to + initialize the modem first. This tool directly sends ATDT. + """ + if not manager.running: + return { + "success": False, + "error": "DOSBox-X is not running. Use launch() first.", + } + + # Check that the port is configured as modem + mode = manager.serial1_mode if port == 1 else manager.serial2_mode + if mode.lower() != "modem": + return { + "success": False, + "error": f"COM{port} is not configured as modem (current: {mode}). " + f'Use launch(serial{port}="modem") to enable dial-out.', + } + + # Import keyboard_send to type the dial command + from .peripheral import keyboard_send + + # Format the dial command: ATDT hostname:port followed by Enter + dial_cmd = f"ATDT{address}" + + # Send the dial command via keyboard (typed into the DOS program) + result = keyboard_send(dial_cmd, delay_ms=50) + if not result.get("success"): + return { + "success": False, + "error": f"Failed to type dial command: {result.get('error')}", + } + + # Send Enter to execute the command + keyboard_send("enter", delay_ms=50) + + return { + "success": True, + "message": f"Dial command sent: {dial_cmd}", + "address": address, + "port": f"COM{port}", + "hint": "Check screen to see connection status. Connection time depends on target server.", + } + + +def modem_hangup(port: int = 1) -> dict: + """Hang up active modem connection. + + Sends ATH (hang-up) command to disconnect the modem. + Use this to cleanly terminate a dial-out connection. + + Args: + port: COM port number (1 or 2) + + Returns: + Result dict with hangup status + + Note: + The modem may need to be in command mode (escaped from data mode) + for ATH to work. Some programs handle this automatically. + """ + if not manager.running: + return { + "success": False, + "error": "DOSBox-X is not running. Use launch() first.", + } + + # Check that the port is configured as modem + mode = manager.serial1_mode if port == 1 else manager.serial2_mode + if mode.lower() != "modem": + return { + "success": False, + "error": f"COM{port} is not configured as modem (current: {mode}).", + } + + from .peripheral import keyboard_send + + # Send escape sequence (+++) to enter command mode, then ATH + # The escape sequence requires 1 second guard time before and after + + # Type +++ to escape to command mode + keyboard_send("+++", delay_ms=100) + time.sleep(1.2) # Guard time + + # Send ATH to hang up + keyboard_send("ATH", delay_ms=50) + keyboard_send("enter", delay_ms=50) + + return { + "success": True, + "message": "Hang-up command sent (ATH)", + "port": f"COM{port}", + "hint": "Connection should terminate shortly. Check screen for confirmation.", + } diff --git a/src/dosbox_mcp/tools/peripheral.py b/src/dosbox_mcp/tools/peripheral.py index e458451..61a6622 100644 --- a/src/dosbox_mcp/tools/peripheral.py +++ b/src/dosbox_mcp/tools/peripheral.py @@ -1,4 +1,26 @@ -"""Peripheral tools: screenshot, serial communication, keyboard input.""" +"""Peripheral tools for interacting with DOSBox-X I/O. + +These tools simulate user input and capture output via QMP: +- Screenshots: Capture display state +- Keyboard: Send keystrokes to DOS programs +- Mouse: Move cursor and click +- Serial: Send data to COM ports (for terminal programs) +- Joystick: Game controller input +- Parallel: Printer port communication +- Clipboard: Copy/paste between host and DOS + +All peripheral tools use QMP (port 4444), not GDB. + +Screenshot workflow: +1. screenshot() - Capture current display, get filename +2. Read dosbox://screenshots/{filename} resource for image data +3. Or use dosbox://screen resource for live capture without saving + +Keyboard input examples: +- keyboard_send("dir") then keyboard_send("enter") +- keyboard_send("ctrl-c") for interrupt +- keyboard_send("alt-x") for menu shortcuts +""" import json import socket @@ -9,7 +31,7 @@ from ..state import manager # Default ports (fallback if manager not configured) DEFAULT_SERIAL_PORT = 5555 # nullmodem server:5555 -DEFAULT_QMP_PORT = 4444 # qmpserver port=4444 +DEFAULT_QMP_PORT = 4444 # qmpserver port=4444 def _get_qmp_port() -> int: @@ -39,12 +61,17 @@ def _tcp_send(host: str, port: int, data: bytes, timeout: float = 2.0) -> tuple[ except TimeoutError: return False, f"Connection timeout to {host}:{port}" except ConnectionRefusedError: - return False, f"Connection refused to {host}:{port} - is DOSBox-X running with serial enabled?" + return ( + False, + f"Connection refused to {host}:{port} - is DOSBox-X running with serial enabled?", + ) except Exception as e: return False, f"Socket error: {e}" -def _qmp_command(host: str, port: int, command: str, args: dict[str, Any] | None = None, timeout: float = 2.0) -> dict: +def _qmp_command( + host: str, port: int, command: str, args: dict[str, Any] | None = None, timeout: float = 2.0 +) -> dict: """Send QMP command to DOSBox-X. QMP (QEMU Machine Protocol) is a JSON-based protocol for machine control. @@ -74,7 +101,7 @@ def _qmp_command(host: str, port: int, command: str, args: dict[str, Any] | None if not chunk: break greeting += chunk - except (TimeoutError, socket.timeout): + except TimeoutError: pass # Expected - no more data # Build QMP command @@ -95,7 +122,7 @@ def _qmp_command(host: str, port: int, command: str, args: dict[str, Any] | None if not chunk: break response += chunk - except (TimeoutError, socket.timeout): + except TimeoutError: pass sock.close() @@ -103,7 +130,7 @@ def _qmp_command(host: str, port: int, command: str, args: dict[str, Any] | None # Parse response if response: try: - return json.loads(response.decode().strip().split('\n')[-1]) + return json.loads(response.decode().strip().split("\n")[-1]) except json.JSONDecodeError: return {"success": True, "raw_response": response.decode()} @@ -112,22 +139,37 @@ def _qmp_command(host: str, port: int, command: str, args: dict[str, Any] | None except TimeoutError: return {"success": False, "error": f"QMP connection timeout to {host}:{port}"} except ConnectionRefusedError: - return {"success": False, "error": "QMP connection refused - is DOSBox-X running with qmpserver enabled?"} + return { + "success": False, + "error": "QMP connection refused - is DOSBox-X running with qmpserver enabled?", + } except Exception as e: return {"success": False, "error": f"QMP error: {e}"} def screenshot(filename: str | None = None) -> dict: - """Capture DOSBox-X display. + """Capture DOSBox-X display and save to file. - Uses QMP screendump command which calls DOSBox-X's internal capture function. - Returns a resource URI that can be used to fetch the screenshot. + Takes a screenshot of the current display. The image is saved to + DOSBox-X's capture directory and registered for access via resources. + + Workflow: + 1. Call screenshot() - returns filename in response + 2. Access image via dosbox://screenshots/{filename} resource + 3. Or use dosbox://screen resource for live capture without saving Args: - filename: Optional output filename (DOSBox-X uses auto-naming) + filename: Output filename (default: DOSBox-X auto-generates name) Returns: - Screenshot info including resource URI for fetching the image + - success: True if capture succeeded + - filename: Use this with dosbox://screenshots/{filename} + - resource_uri: Direct URI to access the image + - size_bytes: File size + + Example response: + {"success": true, "filename": "screenshot_001.png", ...} + Then access via: dosbox://screenshots/screenshot_001.png """ import os from datetime import datetime @@ -139,8 +181,13 @@ def screenshot(filename: str | None = None) -> dict: resources = None # Use QMP screendump command - it returns base64 PNG and saves to capture/ - result = _qmp_command("localhost", _get_qmp_port(), "screendump", - {"filename": filename or "screenshot.png"}, timeout=10.0) + result = _qmp_command( + "localhost", + _get_qmp_port(), + "screendump", + {"filename": filename or "screenshot.png"}, + timeout=10.0, + ) # Check for QMP error response if "error" in result: @@ -194,15 +241,12 @@ def screenshot(filename: str | None = None) -> dict: "size_bytes": ret.get("size", 0), } - # Include resource URI (the only way clients should access screenshots) + # Include resource URI for direct access if resource_uri: response["resource_uri"] = resource_uri - else: - # Fallback: provide filename so client knows what was created - # but encourage using dosbox://screenshots/latest resource - response["hint"] = "Use resource dosbox://screenshots/latest to fetch" - if screenshot_filename: - response["filename"] = screenshot_filename + if screenshot_filename: + response["filename"] = screenshot_filename + response["hint"] = f"Access via: dosbox://screenshots/{screenshot_filename}" return response @@ -220,51 +264,65 @@ def serial_send(data: str, port: int = 1) -> dict: This is useful for RIPscrip testing - send graphics commands to a program listening on COM1. + Requires serial port configured as "nullmodem" in launch(). + The data is sent over TCP to the nullmodem server socket. + Args: data: Data to send (text or hex with \\x prefix) port: COM port number (1 or 2) Returns: Send result + + Example: + launch(serial1="nullmodem") # Enable nullmodem on COM1 + serial_send("!|L0000001919\\r") # Send RIPscrip line command """ + # Parse hex escapes like \x1b for ESC def parse_data(s: str) -> bytes: result = bytearray() i = 0 while i < len(s): - if s[i:i+2] == '\\x' and i + 4 <= len(s): + if s[i : i + 2] == "\\x" and i + 4 <= len(s): try: - result.append(int(s[i+2:i+4], 16)) + result.append(int(s[i + 2 : i + 4], 16)) i += 4 continue except ValueError: pass - elif s[i:i+2] == '\\r': - result.append(0x0d) + elif s[i : i + 2] == "\\r": + result.append(0x0D) i += 2 continue - elif s[i:i+2] == '\\n': - result.append(0x0a) + elif s[i : i + 2] == "\\n": + result.append(0x0A) i += 2 continue result.append(ord(s[i])) i += 1 return bytes(result) - # Map COM port to TCP port (based on dosbox.conf config) - # serial1=nullmodem server:5555 - tcp_port_map = { - 1: 5555, # COM1 - 2: 5556, # COM2 (if configured) - } - - if port not in tcp_port_map: + # Validate port number + if port not in (1, 2): return { "success": False, "error": f"Invalid COM port {port}. Supported: 1, 2", } - tcp_port = tcp_port_map[port] + # Get TCP port from manager if running, otherwise use defaults + if manager.running: + tcp_port = manager.get_serial_tcp_port(port) + if tcp_port is None: + mode = manager.serial1_mode if port == 1 else manager.serial2_mode + return { + "success": False, + "error": f"COM{port} is not configured as nullmodem (current: {mode}). " + f'Use launch(serial{port}="nullmodem") to enable.', + } + else: + # Fallback to defaults when manager not running (for direct testing) + tcp_port = 5555 if port == 1 else 5556 try: byte_data = parse_data(data) @@ -289,51 +347,121 @@ def serial_send(data: str, port: int = 1) -> dict: return { "success": False, "error": message, - "hint": f"Ensure DOSBox-X is running with serial{port}=nullmodem server:{tcp_port}", + "hint": f'Ensure DOSBox-X is running with serial{port}="nullmodem". ' + f"Expected TCP port {tcp_port}. Use port_status() to check.", } # QMP keyboard key mapping (from DOSBox-X remotedebug qmp.cpp) QMP_KEY_MAP = { # Letters - "a": "a", "b": "b", "c": "c", "d": "d", "e": "e", "f": "f", "g": "g", - "h": "h", "i": "i", "j": "j", "k": "k", "l": "l", "m": "m", "n": "n", - "o": "o", "p": "p", "q": "q", "r": "r", "s": "s", "t": "t", "u": "u", - "v": "v", "w": "w", "x": "x", "y": "y", "z": "z", + "a": "a", + "b": "b", + "c": "c", + "d": "d", + "e": "e", + "f": "f", + "g": "g", + "h": "h", + "i": "i", + "j": "j", + "k": "k", + "l": "l", + "m": "m", + "n": "n", + "o": "o", + "p": "p", + "q": "q", + "r": "r", + "s": "s", + "t": "t", + "u": "u", + "v": "v", + "w": "w", + "x": "x", + "y": "y", + "z": "z", # Numbers - "0": "0", "1": "1", "2": "2", "3": "3", "4": "4", - "5": "5", "6": "6", "7": "7", "8": "8", "9": "9", + "0": "0", + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + "8": "8", + "9": "9", # Function keys - "f1": "f1", "f2": "f2", "f3": "f3", "f4": "f4", "f5": "f5", "f6": "f6", - "f7": "f7", "f8": "f8", "f9": "f9", "f10": "f10", "f11": "f11", "f12": "f12", + "f1": "f1", + "f2": "f2", + "f3": "f3", + "f4": "f4", + "f5": "f5", + "f6": "f6", + "f7": "f7", + "f8": "f8", + "f9": "f9", + "f10": "f10", + "f11": "f11", + "f12": "f12", # Special keys - "ret": "ret", "enter": "ret", "return": "ret", - "esc": "esc", "escape": "esc", - "spc": "spc", "space": "spc", " ": "spc", + "ret": "ret", + "enter": "ret", + "return": "ret", + "esc": "esc", + "escape": "esc", + "spc": "spc", + "space": "spc", + " ": "spc", "tab": "tab", - "backspace": "backspace", "bs": "backspace", - "delete": "delete", "del": "delete", - "insert": "insert", "ins": "insert", - "home": "home", "end": "end", - "pgup": "pgup", "pageup": "pgup", - "pgdn": "pgdn", "pagedown": "pgdn", + "backspace": "backspace", + "bs": "backspace", + "delete": "delete", + "del": "delete", + "insert": "insert", + "ins": "insert", + "home": "home", + "end": "end", + "pgup": "pgup", + "pageup": "pgup", + "pgdn": "pgdn", + "pagedown": "pgdn", # Arrow keys - "up": "up", "down": "down", "left": "left", "right": "right", + "up": "up", + "down": "down", + "left": "left", + "right": "right", # Modifiers - "shift": "shift", "ctrl": "ctrl", "alt": "alt", - "shift_r": "shift_r", "ctrl_r": "ctrl_r", "alt_r": "alt_r", + "shift": "shift", + "ctrl": "ctrl", + "alt": "alt", + "shift_r": "shift_r", + "ctrl_r": "ctrl_r", + "alt_r": "alt_r", # Punctuation - "minus": "minus", "-": "minus", - "equal": "equal", "=": "equal", - "bracket_left": "bracket_left", "[": "bracket_left", - "bracket_right": "bracket_right", "]": "bracket_right", - "backslash": "backslash", "\\": "backslash", - "semicolon": "semicolon", ";": "semicolon", - "apostrophe": "apostrophe", "'": "apostrophe", - "grave_accent": "grave_accent", "`": "grave_accent", - "comma": "comma", ",": "comma", - "dot": "dot", ".": "dot", - "slash": "slash", "/": "slash", + "minus": "minus", + "-": "minus", + "equal": "equal", + "=": "equal", + "bracket_left": "bracket_left", + "[": "bracket_left", + "bracket_right": "bracket_right", + "]": "bracket_right", + "backslash": "backslash", + "\\": "backslash", + "semicolon": "semicolon", + ";": "semicolon", + "apostrophe": "apostrophe", + "'": "apostrophe", + "grave_accent": "grave_accent", + "`": "grave_accent", + "comma": "comma", + ",": "comma", + "dot": "dot", + ".": "dot", + "slash": "slash", + "/": "slash", } @@ -369,13 +497,13 @@ def keyboard_send(keys: str, delay_ms: int = 10) -> dict: while i < len(keys): # Check for modifier combinations like "ctrl-c" modifier = None - if i + 5 < len(keys) and keys[i:i+5].lower() == "ctrl-": + if i + 5 < len(keys) and keys[i : i + 5].lower() == "ctrl-": modifier = "ctrl" i += 5 - elif i + 4 < len(keys) and keys[i:i+4].lower() == "alt-": + elif i + 4 < len(keys) and keys[i : i + 4].lower() == "alt-": modifier = "alt" i += 4 - elif i + 6 < len(keys) and keys[i:i+6].lower() == "shift-": + elif i + 6 < len(keys) and keys[i : i + 6].lower() == "shift-": modifier = "shift" i += 6 @@ -398,14 +526,29 @@ def keyboard_send(keys: str, delay_ms: int = 10) -> dict: # Handle uppercase (needs shift) if char.isupper() and modifier is None: modifier = "shift" - elif char in "!@#$%^&*()_+{}|:\"<>?": + elif char in '!@#$%^&*()_+{}|:"<>?': # Shifted punctuation - map to base key with shift shift_map = { - "!": "1", "@": "2", "#": "3", "$": "4", "%": "5", - "^": "6", "&": "7", "*": "8", "(": "9", ")": "0", - "_": "minus", "+": "equal", "{": "bracket_left", - "}": "bracket_right", "|": "backslash", ":": "semicolon", - "\"": "apostrophe", "<": "comma", ">": "dot", "?": "slash", + "!": "1", + "@": "2", + "#": "3", + "$": "4", + "%": "5", + "^": "6", + "&": "7", + "*": "8", + "(": "9", + ")": "0", + "_": "minus", + "+": "equal", + "{": "bracket_left", + "}": "bracket_right", + "|": "backslash", + ":": "semicolon", + '"': "apostrophe", + "<": "comma", + ">": "dot", + "?": "slash", } if char in shift_map: key_to_send = shift_map[char] @@ -414,7 +557,7 @@ def keyboard_send(keys: str, delay_ms: int = 10) -> dict: i += 1 if key_to_send is None: - errors.append(f"Unknown key: {keys[i-1] if i > 0 else '?'}") + errors.append(f"Unknown key: {keys[i - 1] if i > 0 else '?'}") continue # Build QMP command arguments @@ -472,14 +615,8 @@ def mouse_move(dx: int, dy: int) -> dict: # QMP input-send-event for relative mouse movement args = { "events": [ - { - "type": "rel", - "data": {"axis": "x", "value": dx} - }, - { - "type": "rel", - "data": {"axis": "y", "value": dy} - } + {"type": "rel", "data": {"axis": "x", "value": dx}}, + {"type": "rel", "data": {"axis": "y", "value": dy}}, ] } @@ -538,11 +675,7 @@ def mouse_click(button: str = "left", double: bool = False) -> dict: for _ in range(clicks): # Press - args = { - "events": [ - {"type": "btn", "data": {"button": btn, "down": True}} - ] - } + args = {"events": [{"type": "btn", "data": {"button": btn, "down": True}}]} result = _qmp_command("localhost", _get_qmp_port(), "input-send-event", args) if "error" in result: errors.append(f"Press failed: {result.get('error')}") @@ -550,11 +683,7 @@ def mouse_click(button: str = "left", double: bool = False) -> dict: time.sleep(0.05) # Brief delay between press and release # Release - args = { - "events": [ - {"type": "btn", "data": {"button": btn, "down": False}} - ] - } + args = {"events": [{"type": "btn", "data": {"button": btn, "down": False}}]} result = _qmp_command("localhost", _get_qmp_port(), "input-send-event", args) if "error" in result: errors.append(f"Release failed: {result.get('error')}") @@ -614,9 +743,7 @@ def mouse_drag(start_x: int, start_y: int, end_x: int, end_y: int, button: str = time.sleep(0.05) # Press button - args = { - "events": [{"type": "btn", "data": {"button": btn, "down": True}}] - } + args = {"events": [{"type": "btn", "data": {"button": btn, "down": True}}]} result = _qmp_command("localhost", _get_qmp_port(), "input-send-event", args) if "error" in result: errors.append(f"Button press failed: {result.get('error')}") @@ -647,9 +774,7 @@ def mouse_drag(start_x: int, start_y: int, end_x: int, end_y: int, button: str = time.sleep(0.05) # Release button - args = { - "events": [{"type": "btn", "data": {"button": btn, "down": False}}] - } + args = {"events": [{"type": "btn", "data": {"button": btn, "down": False}}]} result = _qmp_command("localhost", _get_qmp_port(), "input-send-event", args) if "error" in result: errors.append(f"Button release failed: {result.get('error')}") @@ -683,11 +808,12 @@ def clipboard_copy() -> dict: from your host system's clipboard. """ # DOSBox-X: Ctrl+F5 = Copy screen text to host clipboard - result = _qmp_command("localhost", _get_qmp_port(), "send-key", - {"keys": [ - {"type": "qcode", "data": "ctrl"}, - {"type": "qcode", "data": "f5"} - ]}) + result = _qmp_command( + "localhost", + _get_qmp_port(), + "send-key", + {"keys": [{"type": "qcode", "data": "ctrl"}, {"type": "qcode", "data": "f5"}]}, + ) if "error" in result: return { @@ -716,11 +842,12 @@ def clipboard_paste() -> dict: Set your host clipboard content first, then call this. """ # DOSBox-X: Ctrl+F6 = Paste from host clipboard - result = _qmp_command("localhost", _get_qmp_port(), "send-key", - {"keys": [ - {"type": "qcode", "data": "ctrl"}, - {"type": "qcode", "data": "f6"} - ]}) + result = _qmp_command( + "localhost", + _get_qmp_port(), + "send-key", + {"keys": [{"type": "qcode", "data": "ctrl"}, {"type": "qcode", "data": "f6"}]}, + ) if "error" in result: return { @@ -733,3 +860,262 @@ def clipboard_paste() -> dict: "message": "Host clipboard pasted into DOSBox-X (Ctrl+F6)", "hint": "Text from host clipboard was typed as keystrokes", } + + +# ============================================================================= +# Joystick Tools +# ============================================================================= + + +def joystick_move(which: int = 0, x: float | None = None, y: float | None = None) -> dict: + """Move joystick axis to specified position. + + Sets joystick axis values. Values range from -1.0 (left/up) to 1.0 (right/down), + with 0.0 being center position. + + Args: + which: Joystick number (0 or 1) + x: X-axis value (-1.0 to 1.0), None to leave unchanged + y: Y-axis value (-1.0 to 1.0), None to leave unchanged + + Returns: + Result dict with new axis values + + Examples: + joystick_move(0, x=1.0) # Push right + joystick_move(0, y=-1.0) # Push up + joystick_move(0, x=0.5, y=0.5) # Diagonal down-right + joystick_move(0, x=0, y=0) # Center position + + Note: + Requires QMP joystick support patch applied to DOSBox-X. + """ + events = [] + + if x is not None: + # Clamp to valid range + x = max(-1.0, min(1.0, x)) + events.append({"type": "joy", "data": {"which": which, "axis": "x", "value": x}}) + + if y is not None: + y = max(-1.0, min(1.0, y)) + events.append({"type": "joy", "data": {"which": which, "axis": "y", "value": y}}) + + if not events: + return { + "success": False, + "error": "Must specify at least x or y axis value", + } + + result = _qmp_command("localhost", _get_qmp_port(), "input-send-event", {"events": events}) + + if "error" in result: + error = result.get("error", {}) + if isinstance(error, dict) and "CommandNotFound" in str(error): + return { + "success": False, + "error": "Joystick QMP support not available. Apply qmp-joystick-parport.patch to DOSBox-X.", + } + return { + "success": False, + "error": str(error), + } + + return { + "success": True, + "message": f"Joystick {which} moved", + "joystick": which, + "x": x, + "y": y, + } + + +def joystick_button(which: int = 0, button: int = 0, pressed: bool = True) -> dict: + """Press or release joystick button. + + Args: + which: Joystick number (0 or 1) + button: Button number (0-1) + pressed: True to press, False to release + + Returns: + Result dict with button state + + Examples: + joystick_button(0, 0, True) # Press button 0 + joystick_button(0, 0, False) # Release button 0 + joystick_button(0, 1) # Press button 1 + + Note: + Requires QMP joystick support patch applied to DOSBox-X. + """ + result = _qmp_command( + "localhost", + _get_qmp_port(), + "input-send-event", + { + "events": [ + {"type": "joybtn", "data": {"which": which, "button": button, "down": pressed}} + ] + }, + ) + + if "error" in result: + error = result.get("error", {}) + if isinstance(error, dict) and "CommandNotFound" in str(error): + return { + "success": False, + "error": "Joystick QMP support not available. Apply qmp-joystick-parport.patch to DOSBox-X.", + } + return { + "success": False, + "error": str(error), + } + + action = "pressed" if pressed else "released" + return { + "success": True, + "message": f"Joystick {which} button {button} {action}", + "joystick": which, + "button": button, + "pressed": pressed, + } + + +# ============================================================================= +# Parallel Port Tools +# ============================================================================= + + +def parallel_write(data: list[int] | str | bytes, port: int = 1) -> dict: + """Write data to parallel port (LPT). + + Sends bytes to the specified parallel port. Useful for testing + printer output or parallel port communication in DOS programs. + + Args: + data: Data to send - can be: + - List of integers (0-255) + - String (ASCII encoded) + - Bytes + port: LPT port number (1-3) + + Returns: + Result dict with bytes written + + Examples: + parallel_write([0x41, 0x42, 0x43], port=1) # Send "ABC" + parallel_write("Hello LPT1", port=1) + parallel_write(b"\\x1b@", port=1) # ESC @ (printer reset) + + Note: + Requires QMP parallel port support patch applied to DOSBox-X. + DOSBox-X must have parallel port configured in dosbox.conf. + """ + # Convert data to list of integers + if isinstance(data, str): + byte_list = list(data.encode("ascii", errors="replace")) + elif isinstance(data, bytes): + byte_list = list(data) + elif isinstance(data, list): + byte_list = [int(b) & 0xFF for b in data] + else: + return { + "success": False, + "error": f"Invalid data type: {type(data).__name__}. Use list, str, or bytes.", + } + + result = _qmp_command( + "localhost", _get_qmp_port(), "parport-write", {"port": port, "data": byte_list} + ) + + if "error" in result: + error = result.get("error", {}) + if isinstance(error, dict): + desc = error.get("desc", str(error)) + if "CommandNotFound" in desc: + return { + "success": False, + "error": "Parallel port QMP support not available. Apply qmp-joystick-parport.patch to DOSBox-X.", + } + return {"success": False, "error": desc} + return {"success": False, "error": str(error)} + + written = 0 + if "return" in result and isinstance(result["return"], dict): + written = result["return"].get("written", len(byte_list)) + + return { + "success": True, + "message": f"Wrote {written} bytes to LPT{port}", + "port": port, + "bytes_written": written, + } + + +def parallel_read(port: int = 1) -> dict: + """Read parallel port status and data register. + + Reads the current state of the parallel port registers. + Useful for checking printer status or bidirectional communication. + + Args: + port: LPT port number (1-3) + + Returns: + Result dict with: + - data: Data register value (last byte written or received) + - status: Status register (busy, ack, paper out, etc.) + - control: Control register (strobe, auto-feed, etc.) + + Status register bits: + - Bit 7: Busy (inverted) + - Bit 6: Acknowledge + - Bit 5: Paper Out + - Bit 4: Select + - Bit 3: Error + + Note: + Requires QMP parallel port support patch applied to DOSBox-X. + """ + result = _qmp_command("localhost", _get_qmp_port(), "parport-read", {"port": port}) + + if "error" in result: + error = result.get("error", {}) + if isinstance(error, dict): + desc = error.get("desc", str(error)) + if "CommandNotFound" in desc: + return { + "success": False, + "error": "Parallel port QMP support not available. Apply qmp-joystick-parport.patch to DOSBox-X.", + } + return {"success": False, "error": desc} + return {"success": False, "error": str(error)} + + if "return" in result and isinstance(result["return"], dict): + ret = result["return"] + status = ret.get("status", 0) + + # Decode status bits + status_flags = { + "busy": not bool(status & 0x80), # Inverted + "ack": bool(status & 0x40), + "paper_out": bool(status & 0x20), + "select": bool(status & 0x10), + "error": bool(status & 0x08), + } + + return { + "success": True, + "port": port, + "data": ret.get("data", 0), + "status": status, + "status_flags": status_flags, + "control": ret.get("control", 0), + } + + return { + "success": True, + "message": "Read completed but no data returned", + "port": port, + } diff --git a/src/dosbox_mcp/types.py b/src/dosbox_mcp/types.py index e131476..6fdb1fe 100644 --- a/src/dosbox_mcp/types.py +++ b/src/dosbox_mcp/types.py @@ -138,15 +138,24 @@ class Registers: - OF (Overflow): bit 11 """ flag_bits = { - 'cf': 0, 'carry': 0, - 'pf': 2, 'parity': 2, - 'af': 4, 'aux': 4, - 'zf': 6, 'zero': 6, - 'sf': 7, 'sign': 7, - 'tf': 8, 'trap': 8, - 'if': 9, 'interrupt': 9, - 'df': 10, 'direction': 10, - 'of': 11, 'overflow': 11, + "cf": 0, + "carry": 0, + "pf": 2, + "parity": 2, + "af": 4, + "aux": 4, + "zf": 6, + "zero": 6, + "sf": 7, + "sign": 7, + "tf": 8, + "trap": 8, + "if": 9, + "interrupt": 9, + "df": 10, + "direction": 10, + "of": 11, + "overflow": 11, } bit = flag_bits.get(flag.lower()) if bit is None: @@ -157,47 +166,47 @@ class Registers: """Convert to dictionary for JSON serialization.""" return { # 32-bit registers - 'eax': f'{self.eax:08x}', - 'ecx': f'{self.ecx:08x}', - 'edx': f'{self.edx:08x}', - 'ebx': f'{self.ebx:08x}', - 'esp': f'{self.esp:08x}', - 'ebp': f'{self.ebp:08x}', - 'esi': f'{self.esi:08x}', - 'edi': f'{self.edi:08x}', - 'eip': f'{self.eip:08x}', - 'eflags': f'{self.eflags:08x}', + "eax": f"{self.eax:08x}", + "ecx": f"{self.ecx:08x}", + "edx": f"{self.edx:08x}", + "ebx": f"{self.ebx:08x}", + "esp": f"{self.esp:08x}", + "ebp": f"{self.ebp:08x}", + "esi": f"{self.esi:08x}", + "edi": f"{self.edi:08x}", + "eip": f"{self.eip:08x}", + "eflags": f"{self.eflags:08x}", # 16-bit aliases - 'ax': f'{self.ax:04x}', - 'cx': f'{self.cx:04x}', - 'dx': f'{self.dx:04x}', - 'bx': f'{self.bx:04x}', - 'sp': f'{self.sp:04x}', - 'bp': f'{self.bp:04x}', - 'si': f'{self.si:04x}', - 'di': f'{self.di:04x}', - 'ip': f'{self.ip:04x}', + "ax": f"{self.ax:04x}", + "cx": f"{self.cx:04x}", + "dx": f"{self.dx:04x}", + "bx": f"{self.bx:04x}", + "sp": f"{self.sp:04x}", + "bp": f"{self.bp:04x}", + "si": f"{self.si:04x}", + "di": f"{self.di:04x}", + "ip": f"{self.ip:04x}", # Segment registers - 'cs': f'{self.cs:04x}', - 'ss': f'{self.ss:04x}', - 'ds': f'{self.ds:04x}', - 'es': f'{self.es:04x}', - 'fs': f'{self.fs:04x}', - 'gs': f'{self.gs:04x}', + "cs": f"{self.cs:04x}", + "ss": f"{self.ss:04x}", + "ds": f"{self.ds:04x}", + "es": f"{self.es:04x}", + "fs": f"{self.fs:04x}", + "gs": f"{self.gs:04x}", # Computed addresses - 'cs:ip': f'{self.cs:04x}:{self.ip:04x}', - 'ss:sp': f'{self.ss:04x}:{self.sp:04x}', + "cs:ip": f"{self.cs:04x}:{self.ip:04x}", + "ss:sp": f"{self.ss:04x}:{self.sp:04x}", # Flags - 'flags': { - 'carry': self.flag_set('cf'), - 'parity': self.flag_set('pf'), - 'aux': self.flag_set('af'), - 'zero': self.flag_set('zf'), - 'sign': self.flag_set('sf'), - 'trap': self.flag_set('tf'), - 'interrupt': self.flag_set('if'), - 'direction': self.flag_set('df'), - 'overflow': self.flag_set('of'), + "flags": { + "carry": self.flag_set("cf"), + "parity": self.flag_set("pf"), + "aux": self.flag_set("af"), + "zero": self.flag_set("zf"), + "sign": self.flag_set("sf"), + "trap": self.flag_set("tf"), + "interrupt": self.flag_set("if"), + "direction": self.flag_set("df"), + "overflow": self.flag_set("of"), }, } @@ -217,11 +226,11 @@ class Breakpoint: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'id': self.id, - 'address': f'{self.address:05x}', - 'enabled': self.enabled, - 'hit_count': self.hit_count, - 'original': self.original, + "id": self.id, + "address": f"{self.address:05x}", + "enabled": self.enabled, + "hit_count": self.hit_count, + "original": self.original, } @@ -237,10 +246,10 @@ class StopEvent: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'reason': self.reason.name.lower(), - 'address': f'{self.address:05x}', - 'signal': self.signal, - 'breakpoint_id': self.breakpoint_id, + "reason": self.reason.name.lower(), + "address": f"{self.address:05x}", + "signal": self.signal, + "breakpoint_id": self.breakpoint_id, } @@ -257,18 +266,18 @@ class MemoryRegion: def to_ascii(self) -> str: """Return data as ASCII with non-printables as dots.""" - return ''.join(chr(b) if 32 <= b < 127 else '.' for b in self.data) + return "".join(chr(b) if 32 <= b < 127 else "." for b in self.data) def to_dict(self, format: Literal["hex", "ascii", "both"] = "both") -> dict: """Convert to dictionary for JSON serialization.""" result = { - 'address': f'{self.address:05x}', - 'length': len(self.data), + "address": f"{self.address:05x}", + "length": len(self.data), } if format in ("hex", "both"): - result['hex'] = self.to_hex() + result["hex"] = self.to_hex() if format in ("ascii", "both"): - result['ascii'] = self.to_ascii() + result["ascii"] = self.to_ascii() return result @@ -284,9 +293,9 @@ class DisassemblyLine: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'address': f'{self.address:05x}', - 'bytes': self.bytes_hex, - 'instruction': f'{self.mnemonic} {self.operands}'.strip(), + "address": f"{self.address:05x}", + "bytes": self.bytes_hex, + "instruction": f"{self.mnemonic} {self.operands}".strip(), } @@ -304,10 +313,10 @@ class DOSBoxStatus: def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { - 'running': self.running, - 'connected': self.connected, - 'host': self.host, - 'port': self.port, - 'pid': self.pid, - 'breakpoint_count': len(self.breakpoints), + "running": self.running, + "connected": self.connected, + "host": self.host, + "port": self.port, + "pid": self.pid, + "breakpoint_count": len(self.breakpoints), } diff --git a/src/dosbox_mcp/utils.py b/src/dosbox_mcp/utils.py index accc047..da1b47a 100644 --- a/src/dosbox_mcp/utils.py +++ b/src/dosbox_mcp/utils.py @@ -1,7 +1,6 @@ """Utility functions for DOSBox-X MCP Server.""" - def parse_address(addr: str) -> int: """Parse a DOS address in various formats. @@ -30,8 +29,8 @@ def parse_address(addr: str) -> int: addr = addr.strip().lower() # Segment:offset format (e.g., "1234:5678") - if ':' in addr: - parts = addr.split(':') + if ":" in addr: + parts = addr.split(":") if len(parts) != 2: raise ValueError(f"Invalid segment:offset format: {addr}") segment = int(parts[0], 16) @@ -39,15 +38,15 @@ def parse_address(addr: str) -> int: return (segment << 4) + offset # Decimal format (e.g., "#12345") - if addr.startswith('#'): + if addr.startswith("#"): return int(addr[1:], 10) # Hex with suffix (e.g., "12345h") - if addr.endswith('h'): + if addr.endswith("h"): return int(addr[:-1], 16) # Hex with prefix (e.g., "0x12345") - if addr.startswith('0x'): + if addr.startswith("0x"): return int(addr, 16) # Assume hex @@ -76,13 +75,13 @@ def format_address(addr: int, style: str = "flat") -> str: # Convert to segment:offset (canonical form with offset < 16) segment = addr >> 4 offset = addr & 0x0F - return f'{segment:04x}:{offset:04x}' + return f"{segment:04x}:{offset:04x}" elif style == "both": segment = addr >> 4 offset = addr & 0x0F - return f'{addr:05x} ({segment:04x}:{offset:04x})' + return f"{addr:05x} ({segment:04x}:{offset:04x})" else: # flat - return f'{addr:05x}' + return f"{addr:05x}" def calculate_checksum(data: str) -> str: @@ -98,7 +97,7 @@ def calculate_checksum(data: str) -> str: Two-character hex checksum """ total = sum(ord(c) for c in data) - return f'{total & 0xFF:02x}' + return f"{total & 0xFF:02x}" def encode_hex(data: bytes) -> str: @@ -118,11 +117,11 @@ def escape_binary(data: bytes) -> bytes: Characters that must be escaped: $, #, }, * """ result = bytearray() - escape_chars = {0x24, 0x23, 0x7d, 0x2a} # $, #, }, * + escape_chars = {0x24, 0x23, 0x7D, 0x2A} # $, #, }, * for b in data: if b in escape_chars: - result.append(0x7d) + result.append(0x7D) result.append(b ^ 0x20) else: result.append(b) @@ -135,7 +134,7 @@ def unescape_binary(data: bytes) -> bytes: result = bytearray() i = 0 while i < len(data): - if data[i] == 0x7d and i + 1 < len(data): + if data[i] == 0x7D and i + 1 < len(data): result.append(data[i + 1] ^ 0x20) i += 2 else: @@ -160,26 +159,26 @@ def parse_stop_reply(response: str) -> tuple[str, dict]: if not response: return ("unknown", {}) - if response.startswith('S'): + if response.startswith("S"): signal = int(response[1:3], 16) return ("signal", {"signal": signal}) - if response.startswith('T'): + if response.startswith("T"): signal = int(response[1:3], 16) info = {"signal": signal} # Parse additional key:value pairs - pairs = response[3:].rstrip(';').split(';') + pairs = response[3:].rstrip(";").split(";") for pair in pairs: - if ':' in pair: - key, value = pair.split(':', 1) + if ":" in pair: + key, value = pair.split(":", 1) info[key] = value return ("signal", info) - if response.startswith('W'): + if response.startswith("W"): code = int(response[1:3], 16) return ("exit", {"code": code}) - if response.startswith('X'): + if response.startswith("X"): signal = int(response[1:3], 16) return ("terminated", {"signal": signal}) @@ -199,13 +198,13 @@ def hexdump(data: bytes, address: int = 0, width: int = 16) -> str: """ lines = [] for i in range(0, len(data), width): - chunk = data[i:i + width] - hex_part = ' '.join(f'{b:02x}' for b in chunk) - ascii_part = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk) + chunk = data[i : i + width] + hex_part = " ".join(f"{b:02x}" for b in chunk) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) # Pad hex part for alignment hex_part = hex_part.ljust(width * 3 - 1) - lines.append(f'{address + i:05x} {hex_part} |{ascii_part}|') - return '\n'.join(lines) + lines.append(f"{address + i:05x} {hex_part} |{ascii_part}|") + return "\n".join(lines) def parse_registers_x86(hex_data: str) -> dict[str, int]: @@ -219,40 +218,40 @@ def parse_registers_x86(hex_data: str) -> dict[str, int]: Segment registers are 4 hex characters. """ # Remove any whitespace - hex_data = hex_data.replace(' ', '').replace('\n', '') + hex_data = hex_data.replace(" ", "").replace("\n", "") def read_le32(offset: int) -> int: """Read 32-bit little-endian value from hex string.""" - chunk = hex_data[offset:offset + 8] + chunk = hex_data[offset : offset + 8] if len(chunk) < 8: return 0 # GDB sends in target byte order (little-endian for x86) - return int.from_bytes(bytes.fromhex(chunk), 'little') + return int.from_bytes(bytes.fromhex(chunk), "little") def read_le16(offset: int) -> int: """Read 16-bit little-endian value from hex string.""" - chunk = hex_data[offset:offset + 4] + chunk = hex_data[offset : offset + 4] if len(chunk) < 4: return 0 - return int.from_bytes(bytes.fromhex(chunk), 'little') + return int.from_bytes(bytes.fromhex(chunk), "little") # Parse in GDB order regs = {} pos = 0 # General purpose registers (32-bit each = 8 hex chars) - for name in ['eax', 'ecx', 'edx', 'ebx', 'esp', 'ebp', 'esi', 'edi']: + for name in ["eax", "ecx", "edx", "ebx", "esp", "ebp", "esi", "edi"]: regs[name] = read_le32(pos) pos += 8 # EIP and EFLAGS - regs['eip'] = read_le32(pos) + regs["eip"] = read_le32(pos) pos += 8 - regs['eflags'] = read_le32(pos) + regs["eflags"] = read_le32(pos) pos += 8 # Segment registers (32-bit in GDB response, but only 16-bit meaningful) - for name in ['cs', 'ss', 'ds', 'es', 'fs', 'gs']: + for name in ["cs", "ss", "ds", "es", "fs", "gs"]: regs[name] = read_le32(pos) & 0xFFFF pos += 8