Ryan Malloy 0f94edc48d Make TTF fonts download-on-demand instead of bundled
- Fonts downloaded from int10h.org on first use
- Cached in platform-appropriate directory (~/.cache/mcdosbox-x/fonts)
- Add fonts_download() MCP tool for explicit pre-download
- Wheel size reduced from 473KB to 56KB (88% smaller)
- 48 tools now registered
2026-01-28 12:46:59 -07:00

359 lines
11 KiB
Python

"""Execution control tools: launch, attach, continue, step, quit."""
import time
from ..dosbox import DOSBoxConfig
from ..fonts import (
FONTS_DIR,
fonts_installed,
get_font_path,
list_fonts,
)
from ..fonts import (
download_fonts as _download_fonts,
)
from ..gdb_client import GDBError
from ..state import client, manager
from ..utils import format_address
from .peripheral import keyboard_send as _keyboard_send
# Font metadata for the fonts_list() tool
_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",
},
"Px437_IBM_EGA_8x14": {
"description": "IBM EGA 8x14",
"best_for": "80x25 with smaller font",
},
"Px437_IBM_CGA": {
"description": "IBM CGA",
"best_for": "40-column mode, retro look",
},
"Px437_IBM_MDA": {
"description": "IBM MDA (Monochrome)",
"best_for": "Word processing 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 + better readability",
},
}
def launch(
binary_path: str | None = None,
gdb_port: int = 1234,
qmp_port: int = 4444,
use_docker: bool | None = None,
cycles: str = "auto",
memsize: int = 16,
joystick: str = "auto",
parallel1: str = "disabled",
) -> dict:
"""Launch DOSBox-X with GDB debugging and QMP control enabled.
Automatically uses Docker if native DOSBox-X is not installed.
Args:
binary_path: Path to DOS binary to run (optional)
gdb_port: Port for GDB stub (default: 1234)
qmp_port: Port for QMP control - screenshots, keyboard, mouse (default: 4444)
use_docker: Force Docker (True), force native (False), or auto-detect (None/default)
cycles: CPU cycles setting (auto, max, or number)
memsize: Conventional memory in MB (default: 16)
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
Returns:
Status dict with connection details
Example:
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
"""
config = DOSBoxConfig(
gdb_port=gdb_port,
gdb_enabled=True,
qmp_port=qmp_port,
qmp_enabled=True,
cycles=cycles,
memsize=memsize,
joysticktype=joystick,
parallel1=parallel1,
)
launch_method = None
try:
# Auto-detect: try native first, fallback to Docker
if use_docker is None:
if manager._find_dosbox():
launch_method = "native"
manager.launch_native(binary_path=binary_path, config=config)
else:
# Native not available, try Docker
launch_method = "docker"
manager.launch_docker(binary_path=binary_path, config=config)
elif use_docker:
launch_method = "docker"
manager.launch_docker(binary_path=binary_path, config=config)
else:
launch_method = "native"
manager.launch_native(binary_path=binary_path, config=config)
return {
"success": True,
"message": f"DOSBox-X launched successfully ({launch_method})",
"launch_method": launch_method,
"gdb_host": "localhost",
"gdb_port": gdb_port,
"qmp_port": qmp_port,
"pid": manager.pid,
"hint": f"Use attach() to connect to debugger on port {gdb_port}. QMP (screenshots/keyboard) on port {qmp_port}.",
}
except Exception as e:
return {
"success": False,
"error": str(e),
"hint": "Ensure Docker is installed and the dosbox-mcp image is built (make build)",
}
def attach(
host: str = "localhost",
port: int | None = None,
auto_keypress: str | None = None,
keypress_delay_ms: int = 500,
) -> dict:
"""Connect to a running DOSBox-X GDB stub.
Args:
host: Hostname or IP (default: localhost)
port: GDB port (default: uses port from launch(), or 1234)
auto_keypress: Optional key(s) to send after attaching (e.g., "enter", "space")
Useful for dismissing splash screens automatically.
keypress_delay_ms: Delay after connection before sending keypress (default: 500ms)
Returns:
Connection status and initial register state
Examples:
attach() # Auto-detect port from launch()
attach("localhost", 1234) # Explicit port
attach(auto_keypress="enter") # Dismiss splash screen with Enter
"""
# Use manager's port if not specified, fallback to 1234
if port is None:
port = manager.gdb_port if manager.running else 1234
try:
client.connect(host, port)
# Get initial state
regs = client.read_registers()
stop = client.get_stop_reason()
result = {
"success": True,
"message": f"Connected to {host}:{port}",
"gdb_port": port,
"stop_reason": stop.reason.name.lower(),
"cs_ip": f"{regs.cs:04x}:{regs.ip:04x}",
"physical_address": f"{regs.cs_ip:05x}",
}
# Send keypress to dismiss splash screen if requested
if auto_keypress:
time.sleep(keypress_delay_ms / 1000.0)
key_result = _keyboard_send(auto_keypress)
result["keypress_sent"] = auto_keypress
result["keypress_result"] = key_result.get("success", False)
if not key_result.get("success"):
result["keypress_error"] = key_result.get("error")
return result
except GDBError as e:
return {
"success": False,
"error": str(e),
}
def continue_execution(timeout: float | None = None) -> dict:
"""Continue execution until breakpoint or signal.
Args:
timeout: Optional timeout in seconds
Returns:
Stop event info (reason, address, breakpoint hit)
"""
try:
event = client.continue_execution(timeout=timeout)
regs = client.read_registers()
return {
"success": True,
"stop_reason": event.reason.name.lower(),
"address": format_address(event.address, "both"),
"breakpoint_id": event.breakpoint_id,
"signal": event.signal,
"cs_ip": f"{regs.cs:04x}:{regs.ip:04x}",
}
except GDBError as e:
return {
"success": False,
"error": str(e),
}
def step(count: int = 1) -> dict:
"""Step one or more instructions.
Args:
count: Number of instructions to step (default: 1)
Returns:
New register state after stepping
"""
try:
event = client.step(count)
regs = client.read_registers()
return {
"success": True,
"stop_reason": event.reason.name.lower(),
"cs_ip": f"{regs.cs:04x}:{regs.ip:04x}",
"physical_address": f"{regs.cs_ip:05x}",
"stepped": count,
}
except GDBError as e:
return {
"success": False,
"error": str(e),
}
def step_over() -> dict:
"""Step over a CALL instruction (execute subroutine and stop after return).
Returns:
New register state after step-over
"""
try:
event = client.step_over()
regs = client.read_registers()
return {
"success": True,
"stop_reason": event.reason.name.lower(),
"cs_ip": f"{regs.cs:04x}:{regs.ip:04x}",
"physical_address": f"{regs.cs_ip:05x}",
}
except GDBError as e:
return {
"success": False,
"error": str(e),
}
def quit() -> dict:
"""Stop DOSBox-X and clean up.
Returns:
Shutdown status
"""
try:
if client.connected:
try:
client.kill()
except GDBError:
client.disconnect()
if manager.running:
manager.stop()
return {
"success": True,
"message": "DOSBox-X stopped and cleaned up",
}
except Exception as e:
return {
"success": False,
"error": str(e),
}
def fonts_list() -> dict:
"""List available TrueType fonts for DOSBox-X TTF output.
These fonts from The Ultimate Oldschool PC Font Pack provide
authentic DOS-era rendering. Fonts are downloaded on first use.
Use fonts_download() to pre-download fonts, or they will be
auto-downloaded when you specify a font in launch().
Returns:
Dictionary with available fonts and installation status
Example:
fonts_list()
fonts_download() # Optional: pre-download
launch(binary_path="/dos/RIPTERM.EXE", ttf_font="Px437_IBM_VGA_9x16")
"""
installed = fonts_installed()
fonts = []
for name in list_fonts():
info = _FONT_INFO.get(name, {})
path = get_font_path(name)
fonts.append(
{
"name": name,
"description": info.get("description", ""),
"best_for": info.get("best_for", ""),
"installed": path is not None,
}
)
return {
"fonts": fonts,
"fonts_installed": installed,
"cache_dir": str(FONTS_DIR),
"recommended": "Px437_IBM_VGA_9x16",
"usage_hint": "Fonts auto-download on use. Use fonts_download() to pre-install.",
"license": "CC BY-SA 4.0 - The Ultimate Oldschool PC Font Pack by VileR",
"source": "https://int10h.org/oldschool-pc-fonts/",
}
def fonts_download(force: bool = False) -> dict:
"""Download IBM PC TrueType fonts for DOSBox-X TTF output.
Downloads fonts from The Ultimate Oldschool PC Font Pack (int10h.org)
and caches them locally. Fonts are also auto-downloaded when needed.
Args:
force: Re-download even if fonts already exist
Returns:
Status dict with download result and cache location
Example:
fonts_download() # Download fonts
fonts_list() # See installed fonts
"""
return _download_fonts(force=force)