Initial implementation of DOSBox-X MCP Server
MCP server for AI-assisted debugging of DOS binaries via GDB protocol. Features: - GDB remote protocol client for DOSBox-X debugging - 16 debugging tools: launch, attach, breakpoint management, registers, memory read/write, disassemble, step, continue, etc. - Docker container with DOSBox-X for consistent environment - Support for DOS segment:offset addressing - Comprehensive test suite (49 tests) Primary use case: Reverse engineering the unpublished Bezier algorithm in RIPTERM.EXE for the RIPscrip graphics protocol project.
This commit is contained in:
commit
170eba0843
72
.gitignore
vendored
Normal file
72
.gitignore
vendored
Normal file
@ -0,0 +1,72 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# uv
|
||||
.uv/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Linting
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Local config
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# DOS files (user-specific)
|
||||
dos/
|
||||
config/dosbox.conf
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.temp
|
||||
*.log
|
||||
|
||||
# Screenshots and artifacts
|
||||
screenshots/
|
||||
*.png
|
||||
*.bmp
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Ignore compiled DOSBox-X builds
|
||||
dosbox-x-build/
|
||||
120
Dockerfile
Normal file
120
Dockerfile
Normal file
@ -0,0 +1,120 @@
|
||||
# DOSBox-X with GDB stub support
|
||||
# Multi-stage build for minimal final image
|
||||
|
||||
# =============================================================================
|
||||
# Stage 1: Build DOSBox-X with GDB support
|
||||
# =============================================================================
|
||||
FROM debian:bookworm-slim AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
build-essential \
|
||||
automake \
|
||||
autoconf \
|
||||
libtool \
|
||||
pkg-config \
|
||||
libsdl2-dev \
|
||||
libsdl2-net-dev \
|
||||
libsdl2-image-dev \
|
||||
libpng-dev \
|
||||
libpcap-dev \
|
||||
libslirp-dev \
|
||||
libfluidsynth-dev \
|
||||
libavcodec-dev \
|
||||
libavformat-dev \
|
||||
libavutil-dev \
|
||||
libswscale-dev \
|
||||
nasm \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Clone DOSBox-X (main repo - check for GDB support)
|
||||
# Note: If hezi/dosbox-x-gdb is stale, main DOSBox-X may have debugger support
|
||||
WORKDIR /build
|
||||
RUN git clone --depth 1 https://github.com/joncampbell123/dosbox-x.git
|
||||
|
||||
WORKDIR /build/dosbox-x
|
||||
|
||||
# Configure and build
|
||||
# DOSBox-X has built-in debugger that can be enabled
|
||||
RUN ./autogen.sh && \
|
||||
./configure \
|
||||
--prefix=/opt/dosbox-x \
|
||||
--enable-debug \
|
||||
--enable-sdl2 \
|
||||
--disable-printer \
|
||||
&& make -j$(nproc) \
|
||||
&& make install
|
||||
|
||||
# =============================================================================
|
||||
# Stage 2: Runtime image
|
||||
# =============================================================================
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install runtime dependencies only
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libsdl2-2.0-0 \
|
||||
libsdl2-net-2.0-0 \
|
||||
libsdl2-image-2.0-0 \
|
||||
libpng16-16 \
|
||||
libpcap0.8 \
|
||||
libslirp0 \
|
||||
libfluidsynth3 \
|
||||
libavcodec59 \
|
||||
libavformat59 \
|
||||
libavutil57 \
|
||||
libswscale6 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy DOSBox-X from builder
|
||||
COPY --from=builder /opt/dosbox-x /opt/dosbox-x
|
||||
|
||||
# Create symlink in PATH
|
||||
RUN ln -s /opt/dosbox-x/bin/dosbox-x /usr/local/bin/dosbox-x
|
||||
|
||||
# Create directories for config and DOS files
|
||||
RUN mkdir -p /config /dos
|
||||
|
||||
# Default configuration with GDB stub enabled
|
||||
RUN cat > /config/dosbox.conf << 'EOF'
|
||||
[sdl]
|
||||
fullscreen=false
|
||||
windowresolution=800x600
|
||||
output=opengl
|
||||
|
||||
[cpu]
|
||||
core=auto
|
||||
cputype=auto
|
||||
cycles=auto
|
||||
|
||||
[dosbox]
|
||||
memsize=16
|
||||
|
||||
[debugger]
|
||||
# Enable GDB server stub
|
||||
gdbserver=true
|
||||
gdbport=1234
|
||||
|
||||
[serial]
|
||||
serial1=disabled
|
||||
serial2=disabled
|
||||
|
||||
[autoexec]
|
||||
# Mount /dos as C:
|
||||
MOUNT C /dos
|
||||
C:
|
||||
EOF
|
||||
|
||||
# Expose GDB port
|
||||
EXPOSE 1234
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /dos
|
||||
|
||||
# Environment for X11 forwarding
|
||||
ENV DISPLAY=:0
|
||||
|
||||
# Entry point
|
||||
ENTRYPOINT ["dosbox-x", "-conf", "/config/dosbox.conf"]
|
||||
CMD []
|
||||
109
Makefile
Normal file
109
Makefile
Normal file
@ -0,0 +1,109 @@
|
||||
# DOSBox-X MCP Makefile
|
||||
# Convenient commands for development
|
||||
|
||||
.PHONY: all build up down logs shell test lint format clean help
|
||||
|
||||
# Default target
|
||||
all: help
|
||||
|
||||
# Build the Docker image
|
||||
build:
|
||||
docker compose build
|
||||
|
||||
# Start DOSBox-X container
|
||||
up:
|
||||
@echo "Starting DOSBox-X..."
|
||||
@echo "Note: Run 'xhost +local:docker' first for X11 display"
|
||||
docker compose up -d
|
||||
@echo "DOSBox-X started. GDB port: 1234"
|
||||
@echo "Connect with: claude mcp add dosbox-mcp"
|
||||
|
||||
# Start headless (no GUI)
|
||||
up-headless:
|
||||
docker compose --profile headless up -d dosbox-headless
|
||||
@echo "DOSBox-X headless started. GDB port: 1235"
|
||||
|
||||
# Stop container
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# View logs
|
||||
logs:
|
||||
docker compose logs -f
|
||||
|
||||
# Shell into container
|
||||
shell:
|
||||
docker compose exec dosbox /bin/bash
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
uv run pytest tests/ -v
|
||||
|
||||
# Run specific test
|
||||
test-%:
|
||||
uv run pytest tests/ -v -k "$*"
|
||||
|
||||
# Lint code
|
||||
lint:
|
||||
uv run ruff check src/
|
||||
|
||||
# Format code
|
||||
format:
|
||||
uv run ruff format src/ tests/
|
||||
|
||||
# Clean up
|
||||
clean:
|
||||
docker compose down -v --rmi local
|
||||
rm -rf __pycache__ .pytest_cache .ruff_cache
|
||||
find . -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Install dependencies (development)
|
||||
install:
|
||||
uv sync --dev
|
||||
|
||||
# Register MCP server with Claude Code
|
||||
register:
|
||||
claude mcp add dosbox-mcp -- uv run --directory $(shell pwd) dosbox-mcp
|
||||
|
||||
# Unregister MCP server
|
||||
unregister:
|
||||
claude mcp remove dosbox-mcp
|
||||
|
||||
# Create DOS directory structure
|
||||
init:
|
||||
mkdir -p dos config
|
||||
@echo "Created dos/ and config/ directories"
|
||||
@echo "Place DOS binaries in dos/ directory"
|
||||
|
||||
# Quick test - launch and attach
|
||||
quicktest: up
|
||||
@sleep 3
|
||||
@echo "Testing GDB connection..."
|
||||
@nc -zv localhost 1234 && echo "GDB stub is listening!" || echo "GDB stub not responding"
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "DOSBox-X MCP Server"
|
||||
@echo ""
|
||||
@echo "Docker commands:"
|
||||
@echo " make build Build Docker image"
|
||||
@echo " make up Start DOSBox-X (GUI mode)"
|
||||
@echo " make up-headless Start DOSBox-X (headless mode)"
|
||||
@echo " make down Stop container"
|
||||
@echo " make logs View container logs"
|
||||
@echo " make shell Shell into container"
|
||||
@echo ""
|
||||
@echo "Development commands:"
|
||||
@echo " make install Install dependencies"
|
||||
@echo " make test Run tests"
|
||||
@echo " make lint Lint code"
|
||||
@echo " make format Format code"
|
||||
@echo " make clean Clean up"
|
||||
@echo ""
|
||||
@echo "MCP commands:"
|
||||
@echo " make register Register with Claude Code"
|
||||
@echo " make unregister Unregister from Claude Code"
|
||||
@echo ""
|
||||
@echo "Setup:"
|
||||
@echo " make init Create DOS directory structure"
|
||||
@echo " make quicktest Test GDB connection"
|
||||
203
README.md
Normal file
203
README.md
Normal file
@ -0,0 +1,203 @@
|
||||
# DOSBox-X MCP Server
|
||||
|
||||
AI-assisted debugging of DOS binaries via the Model Context Protocol (MCP).
|
||||
|
||||
This MCP server enables Claude to programmatically debug DOS programs running in DOSBox-X by providing tools for:
|
||||
|
||||
- Setting breakpoints
|
||||
- Reading/writing registers and memory
|
||||
- Stepping through code
|
||||
- Tracing execution
|
||||
|
||||
## Primary Use Case
|
||||
|
||||
**Reverse engineering classic DOS programs** - specifically, tracing the unpublished Bezier curve algorithm in RIPTERM.EXE for the RIPscrip graphics protocol research project.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- [uv](https://github.com/astral-sh/uv) package manager
|
||||
- Docker (for DOSBox-X container)
|
||||
- X11 display (for DOSBox GUI)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/yourusername/dosbox-mcp.git
|
||||
cd dosbox-mcp
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Build Docker image
|
||||
make build
|
||||
|
||||
# Create DOS directory
|
||||
make init
|
||||
```
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# Allow X11 access for Docker
|
||||
xhost +local:docker
|
||||
|
||||
# Start DOSBox-X
|
||||
make up
|
||||
|
||||
# Register MCP server with Claude Code
|
||||
make register
|
||||
```
|
||||
|
||||
### Usage with Claude
|
||||
|
||||
Once registered, Claude can use these tools:
|
||||
|
||||
```
|
||||
# Launch DOSBox-X with a binary
|
||||
launch("/path/to/GAME.EXE")
|
||||
|
||||
# Connect to debugger
|
||||
attach("localhost", 1234)
|
||||
|
||||
# Set a breakpoint
|
||||
breakpoint_set("1234:0100")
|
||||
|
||||
# Run until breakpoint
|
||||
continue_execution()
|
||||
|
||||
# Read registers
|
||||
registers()
|
||||
|
||||
# Read memory
|
||||
memory_read("DS:0100", 64)
|
||||
|
||||
# Step through code
|
||||
step(10)
|
||||
|
||||
# Clean up
|
||||
quit()
|
||||
```
|
||||
|
||||
## Tools Reference
|
||||
|
||||
### Execution Control
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `launch` | Start DOSBox-X with optional binary |
|
||||
| `attach` | Connect to GDB stub |
|
||||
| `continue_execution` | Run until breakpoint |
|
||||
| `step` | Step N instructions |
|
||||
| `step_over` | Step over CALL instructions |
|
||||
| `quit` | Stop DOSBox-X |
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `breakpoint_set` | Set breakpoint at address |
|
||||
| `breakpoint_list` | List all breakpoints |
|
||||
| `breakpoint_delete` | Remove breakpoint(s) |
|
||||
|
||||
### Inspection
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `registers` | Read all CPU registers |
|
||||
| `memory_read` | Read memory region |
|
||||
| `memory_write` | Write to memory |
|
||||
| `disassemble` | Simple disassembly view |
|
||||
| `stack` | Dump stack contents |
|
||||
| `status` | Get debugger status |
|
||||
|
||||
### Address Formats
|
||||
|
||||
The server supports multiple address formats:
|
||||
|
||||
- **Segment:offset**: `1234:5678` (standard DOS format)
|
||||
- **Flat hex**: `0x12345` or `12345h`
|
||||
- **Decimal**: `#65536`
|
||||
- **Register-based**: `DS:SI`, `CS:IP`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Claude Code │────▶│ DOSBox-X MCP │────▶│ DOSBox-X │
|
||||
│ │ MCP │ Server │ GDB │ (GDB stub) │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ GDB Remote │
|
||||
│ Protocol │
|
||||
│ (TCP :1234) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run tests
|
||||
make test
|
||||
|
||||
# Lint code
|
||||
make lint
|
||||
|
||||
# Format code
|
||||
make format
|
||||
|
||||
# View logs
|
||||
make logs
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
dosbox-mcp/
|
||||
├── src/dosbox_mcp/
|
||||
│ ├── server.py # FastMCP server (tools)
|
||||
│ ├── gdb_client.py # GDB protocol client
|
||||
│ ├── dosbox.py # DOSBox-X management
|
||||
│ ├── types.py # Type definitions
|
||||
│ └── utils.py # Utilities
|
||||
├── tests/
|
||||
├── examples/
|
||||
├── Dockerfile # DOSBox-X with GDB
|
||||
└── docker-compose.yml
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### GDB Remote Protocol
|
||||
|
||||
This server implements a client for the [GDB Remote Serial Protocol](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Remote-Protocol.html), which provides:
|
||||
|
||||
- Register read/write (`g`, `G`, `p`, `P`)
|
||||
- Memory read/write (`m`, `M`)
|
||||
- Software breakpoints (`Z0`, `z0`)
|
||||
- Execution control (`c`, `s`, `?`)
|
||||
|
||||
### Real Mode Addressing
|
||||
|
||||
DOS uses real mode with segment:offset addressing:
|
||||
|
||||
```
|
||||
Physical Address = (Segment << 4) + Offset
|
||||
```
|
||||
|
||||
This gives a 20-bit address space (1MB), though only 640KB is conventional memory.
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [RIPscrip Research](../rpmesh/) - Parent project for RIPscrip graphics protocol
|
||||
- [dosbox-x-gdb](https://github.com/hezi/dosbox-x-gdb) - DOSBox-X fork with GDB support
|
||||
- [FastMCP](https://gofastmcp.com/) - MCP server framework
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
95
docker-compose.yml
Normal file
95
docker-compose.yml
Normal file
@ -0,0 +1,95 @@
|
||||
# DOSBox-X MCP Docker Compose
|
||||
#
|
||||
# Usage:
|
||||
# docker compose up -d # Start DOSBox-X
|
||||
# docker compose logs -f # View logs
|
||||
# docker compose down # Stop
|
||||
#
|
||||
# For GUI (X11 forwarding):
|
||||
# xhost +local:docker # Allow Docker X11 access
|
||||
# docker compose up -d
|
||||
|
||||
services:
|
||||
dosbox:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: dosbox-mcp
|
||||
|
||||
# Ports
|
||||
ports:
|
||||
- "${GDB_PORT:-1234}:1234" # GDB stub
|
||||
- "${SERIAL_PORT:-5555}:5555" # Serial (optional)
|
||||
|
||||
# X11 forwarding for display
|
||||
environment:
|
||||
- DISPLAY=${DISPLAY:-:0}
|
||||
volumes:
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix:rw
|
||||
- ${XDG_RUNTIME_DIR:-/run/user/1000}:${XDG_RUNTIME_DIR:-/run/user/1000}:rw
|
||||
|
||||
# DOS files directory
|
||||
- ${DOS_DIR:-./dos}:/dos:rw
|
||||
|
||||
# Custom config (optional)
|
||||
- ${CONFIG_FILE:-./config/dosbox.conf}:/config/dosbox.conf:ro
|
||||
|
||||
# Audio (PulseAudio)
|
||||
devices:
|
||||
- /dev/snd:/dev/snd
|
||||
|
||||
# For PulseAudio sound
|
||||
# Uncomment if you want sound support:
|
||||
# - ${XDG_RUNTIME_DIR}/pulse:/run/user/1000/pulse:rw
|
||||
|
||||
# Run options
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
# Resource limits
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 1G
|
||||
|
||||
# Healthcheck - verify GDB port is listening
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z localhost 1234 || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Restart policy
|
||||
restart: unless-stopped
|
||||
|
||||
# Optional: Separate container for headless operation
|
||||
dosbox-headless:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: dosbox-mcp-headless
|
||||
profiles:
|
||||
- headless
|
||||
|
||||
ports:
|
||||
- "${GDB_PORT_HEADLESS:-1235}:1234"
|
||||
|
||||
environment:
|
||||
# Use virtual framebuffer for headless
|
||||
- SDL_VIDEODRIVER=dummy
|
||||
- SDL_AUDIODRIVER=dummy
|
||||
|
||||
volumes:
|
||||
- ${DOS_DIR:-./dos}:/dos:rw
|
||||
- ${CONFIG_FILE:-./config/dosbox.conf}:/config/dosbox.conf:ro
|
||||
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1'
|
||||
memory: 512M
|
||||
156
examples/ripterm_bezier.py
Normal file
156
examples/ripterm_bezier.py
Normal file
@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Example: Tracing the Bezier algorithm in RIPTERM.EXE
|
||||
|
||||
This script demonstrates how to use the DOSBox-X MCP server to trace
|
||||
the unpublished Bezier curve algorithm in RIPTERM.EXE.
|
||||
|
||||
The goal is to:
|
||||
1. Launch DOSBox-X with RIPTERM
|
||||
2. Set breakpoints at suspected Bezier drawing code
|
||||
3. Feed a test RIPscrip file with Bezier commands
|
||||
4. Capture register/memory state at each point
|
||||
5. Reconstruct the algorithm from the captured data
|
||||
|
||||
Prerequisites:
|
||||
- RIPTERM.EXE in the ./dos directory
|
||||
- A test RIP file with Bezier commands
|
||||
- DOSBox-X MCP server running
|
||||
|
||||
Usage:
|
||||
python examples/ripterm_bezier.py
|
||||
|
||||
Note: This is a conceptual example. The actual addresses would need to be
|
||||
determined through static analysis (e.g., in Ghidra) first.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def trace_bezier():
|
||||
"""Trace the Bezier algorithm execution."""
|
||||
|
||||
# These would be determined from Ghidra analysis
|
||||
# Hypothetical addresses for RIPTERM's Bezier code
|
||||
BEZIER_ENTRY = "1234:0100" # Where Bezier processing starts
|
||||
DRAW_POINT = "1234:0200" # Where individual points are drawn
|
||||
CALCULATE = "1234:0300" # The core calculation routine
|
||||
|
||||
print("=" * 60)
|
||||
print("RIPTERM Bezier Algorithm Tracer")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# This would use the MCP tools via Claude or direct API
|
||||
# For demonstration, we'll show the intended flow:
|
||||
|
||||
print("Step 1: Launch DOSBox-X with RIPTERM")
|
||||
print(" launch('/path/to/dos/RIPTERM.EXE')")
|
||||
print()
|
||||
|
||||
print("Step 2: Attach to GDB stub")
|
||||
print(" attach('localhost', 1234)")
|
||||
print()
|
||||
|
||||
print("Step 3: Set breakpoints at key addresses")
|
||||
print(f" breakpoint_set('{BEZIER_ENTRY}') # Bezier entry")
|
||||
print(f" breakpoint_set('{DRAW_POINT}') # Draw point")
|
||||
print(f" breakpoint_set('{CALCULATE}') # Calculation")
|
||||
print()
|
||||
|
||||
print("Step 4: Continue execution until breakpoint")
|
||||
print(" continue_execution()")
|
||||
print()
|
||||
|
||||
print("Step 5: When breakpoint hit, capture state:")
|
||||
print("""
|
||||
# Read registers
|
||||
regs = registers()
|
||||
print(f"AX={regs['ax']} BX={regs['bx']} CX={regs['cx']} DX={regs['dx']}")
|
||||
|
||||
# Read stack (parameters often passed on stack)
|
||||
stack_data = stack(16)
|
||||
|
||||
# Read data segment (for global variables)
|
||||
mem = memory_read("DS:0000", 256)
|
||||
|
||||
# Step through to see calculation
|
||||
for i in range(100):
|
||||
step()
|
||||
regs = registers()
|
||||
# Log coordinate values
|
||||
print(f"Step {i}: X={regs['cx']} Y={regs['dx']}")
|
||||
""")
|
||||
|
||||
print("Step 6: Analyze captured data to reconstruct algorithm")
|
||||
print()
|
||||
|
||||
# Example of what the captured data might look like
|
||||
example_trace = [
|
||||
{"step": 0, "x": 100, "y": 50, "note": "Control point 1"},
|
||||
{"step": 10, "x": 112, "y": 58, "note": "Interpolated"},
|
||||
{"step": 20, "x": 125, "y": 65, "note": "Interpolated"},
|
||||
{"step": 30, "x": 138, "y": 71, "note": "Interpolated"},
|
||||
{"step": 40, "x": 150, "y": 75, "note": "Control point 2 region"},
|
||||
{"step": 50, "x": 162, "y": 71, "note": "Curving back"},
|
||||
{"step": 60, "x": 175, "y": 65, "note": "Interpolated"},
|
||||
{"step": 70, "x": 188, "y": 58, "note": "Interpolated"},
|
||||
{"step": 80, "x": 200, "y": 50, "note": "End point"},
|
||||
]
|
||||
|
||||
print("Example trace output:")
|
||||
print("-" * 40)
|
||||
for point in example_trace:
|
||||
print(f" Step {point['step']:3d}: ({point['x']:3d}, {point['y']:3d}) - {point['note']}")
|
||||
print()
|
||||
|
||||
print("From this data, we could determine:")
|
||||
print(" - Whether it uses De Casteljau's algorithm")
|
||||
print(" - The number of subdivisions")
|
||||
print(" - Fixed-point vs floating-point math")
|
||||
print(" - Any optimizations or approximations")
|
||||
|
||||
|
||||
def create_test_rip():
|
||||
"""Create a simple RIP file to test Bezier drawing."""
|
||||
|
||||
# RIPscrip Level 0 Bezier command
|
||||
# The format is: !|z<x1><y1><x2><y2><x3><y3><x4><y4>
|
||||
# Using MegaNum encoding
|
||||
|
||||
test_rip = """!|
|
||||
!|E
|
||||
!|c0F
|
||||
!|z00320064009600C800C8006400640032
|
||||
"""
|
||||
|
||||
# This draws a Bezier curve with:
|
||||
# Start: (50, 100)
|
||||
# Control 1: (150, 200)
|
||||
# Control 2: (200, 100)
|
||||
# End: (100, 50)
|
||||
|
||||
test_file = Path("dos/test-bezier.RIP")
|
||||
test_file.parent.mkdir(exist_ok=True)
|
||||
test_file.write_text(test_rip)
|
||||
|
||||
print(f"Created test file: {test_file}")
|
||||
return test_file
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
print()
|
||||
trace_bezier()
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("To actually run this:")
|
||||
print("1. Use Ghidra to find the real Bezier addresses in RIPTERM.EXE")
|
||||
print("2. Start the DOSBox-X MCP server")
|
||||
print("3. Use Claude to interactively debug with these tools")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
60
pyproject.toml
Normal file
60
pyproject.toml
Normal file
@ -0,0 +1,60 @@
|
||||
[project]
|
||||
name = "dosbox-mcp"
|
||||
version = "2025.01.27"
|
||||
description = "MCP server for debugging DOS binaries in DOSBox-X via GDB protocol"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
|
||||
license = {text = "MIT"}
|
||||
keywords = ["mcp", "dosbox", "gdb", "reverse-engineering", "dos", "debugging"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development :: Debuggers",
|
||||
"Topic :: System :: Emulators",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"fastmcp>=2.0.0",
|
||||
"pillow>=10.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"ruff>=0.8.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
dosbox-mcp = "dosbox_mcp.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
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"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "N", "W", "UP", "B", "C4", "SIM"]
|
||||
ignore = ["E501"] # Line length handled separately
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
3
src/dosbox_mcp/__init__.py
Normal file
3
src/dosbox_mcp/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""DOSBox-X MCP Server - AI-assisted DOS binary debugging."""
|
||||
|
||||
__version__ = "2025.01.27"
|
||||
411
src/dosbox_mcp/dosbox.py
Normal file
411
src/dosbox_mcp/dosbox.py
Normal file
@ -0,0 +1,411 @@
|
||||
"""DOSBox-X process and container management.
|
||||
|
||||
This module handles:
|
||||
- Launching DOSBox-X with GDB stub enabled
|
||||
- Docker container management for dosbox-x-gdb
|
||||
- Process lifecycle management
|
||||
- Configuration file handling
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DOSBoxConfig:
|
||||
"""Configuration for DOSBox-X instance."""
|
||||
|
||||
# GDB settings
|
||||
gdb_port: int = 1234
|
||||
gdb_enabled: bool = True
|
||||
|
||||
# Display settings
|
||||
fullscreen: bool = False
|
||||
windowresolution: str = "800x600"
|
||||
|
||||
# CPU settings
|
||||
core: str = "auto" # auto, dynamic, normal, simple
|
||||
cputype: str = "auto"
|
||||
cycles: str = "auto"
|
||||
|
||||
# Memory
|
||||
memsize: int = 16 # MB of conventional memory
|
||||
|
||||
# Serial ports (for future RIPscrip work)
|
||||
serial1: str = "disabled"
|
||||
serial2: str = "disabled"
|
||||
|
||||
# Mount points
|
||||
mounts: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
# Autoexec commands
|
||||
autoexec: list[str] = field(default_factory=list)
|
||||
|
||||
def to_conf(self) -> str:
|
||||
"""Generate DOSBox-X configuration file content."""
|
||||
lines = [
|
||||
"[sdl]",
|
||||
f"fullscreen={str(self.fullscreen).lower()}",
|
||||
f"windowresolution={self.windowresolution}",
|
||||
"",
|
||||
"[cpu]",
|
||||
f"core={self.core}",
|
||||
f"cputype={self.cputype}",
|
||||
f"cycles={self.cycles}",
|
||||
"",
|
||||
"[dosbox]",
|
||||
f"memsize={self.memsize}",
|
||||
"",
|
||||
"[serial]",
|
||||
f"serial1={self.serial1}",
|
||||
f"serial2={self.serial2}",
|
||||
"",
|
||||
]
|
||||
|
||||
# GDB stub configuration (DOSBox-X specific)
|
||||
if self.gdb_enabled:
|
||||
lines.extend([
|
||||
"[debugger]",
|
||||
f"gdbserver=true",
|
||||
f"gdbport={self.gdb_port}",
|
||||
"",
|
||||
])
|
||||
|
||||
# Autoexec section
|
||||
lines.append("[autoexec]")
|
||||
|
||||
# Add mount commands
|
||||
for drive, path in self.mounts.items():
|
||||
lines.append(f"MOUNT {drive.upper()} {path}")
|
||||
|
||||
# Add custom autoexec commands
|
||||
lines.extend(self.autoexec)
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
class DOSBoxManager:
|
||||
"""Manager for DOSBox-X instances.
|
||||
|
||||
Supports both native DOSBox-X and Docker containers.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._process: subprocess.Popen | None = None
|
||||
self._container_id: str | None = None
|
||||
self._config_path: Path | None = None
|
||||
self._temp_dir: Path | None = None
|
||||
self._gdb_port: int = 1234
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
"""Check if DOSBox-X is running."""
|
||||
if self._process:
|
||||
return self._process.poll() is None
|
||||
if self._container_id:
|
||||
return self._check_container_running()
|
||||
return False
|
||||
|
||||
@property
|
||||
def gdb_port(self) -> int:
|
||||
"""Get the GDB port."""
|
||||
return self._gdb_port
|
||||
|
||||
@property
|
||||
def pid(self) -> int | None:
|
||||
"""Get process ID if running natively."""
|
||||
if self._process:
|
||||
return self._process.pid
|
||||
return None
|
||||
|
||||
def _check_container_running(self) -> bool:
|
||||
"""Check if Docker container is running."""
|
||||
if not self._container_id:
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.State.Running}}", self._container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
return result.stdout.strip() == "true"
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def _find_dosbox(self) -> str | None:
|
||||
"""Find DOSBox-X executable."""
|
||||
# Check common locations
|
||||
candidates = [
|
||||
"dosbox-x",
|
||||
"dosbox-x-gdb",
|
||||
"/usr/bin/dosbox-x",
|
||||
"/usr/local/bin/dosbox-x",
|
||||
"/opt/dosbox-x/dosbox-x",
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if shutil.which(candidate):
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
def launch_native(
|
||||
self,
|
||||
binary_path: str | None = None,
|
||||
config: DOSBoxConfig | None = None,
|
||||
extra_args: list[str] | None = None,
|
||||
) -> None:
|
||||
"""Launch DOSBox-X natively.
|
||||
|
||||
Args:
|
||||
binary_path: Optional DOS binary to run
|
||||
config: Configuration (uses defaults if not provided)
|
||||
extra_args: Additional command-line arguments
|
||||
"""
|
||||
if self.running:
|
||||
raise RuntimeError("DOSBox-X is already running")
|
||||
|
||||
dosbox_exe = self._find_dosbox()
|
||||
if not dosbox_exe:
|
||||
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
|
||||
|
||||
# If binary specified, set up mount and autoexec
|
||||
if binary_path:
|
||||
binary = Path(binary_path).resolve()
|
||||
if not binary.exists():
|
||||
raise FileNotFoundError(f"Binary not found: {binary_path}")
|
||||
|
||||
# Mount the directory containing the binary as C:
|
||||
config.mounts["C"] = str(binary.parent)
|
||||
config.autoexec.append("C:")
|
||||
config.autoexec.append(binary.name)
|
||||
|
||||
# Write config file
|
||||
self._config_path = self._temp_dir / "dosbox.conf"
|
||||
self._config_path.write_text(config.to_conf())
|
||||
|
||||
# Build command line
|
||||
cmd = [dosbox_exe, "-conf", str(self._config_path)]
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
|
||||
logger.info(f"Launching: {' '.join(cmd)}")
|
||||
|
||||
# Start process
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=str(self._temp_dir),
|
||||
)
|
||||
|
||||
# Wait a moment for GDB stub to start
|
||||
time.sleep(1.0)
|
||||
|
||||
if not self.running:
|
||||
stderr = self._process.stderr.read().decode() if self._process.stderr else ""
|
||||
raise RuntimeError(f"DOSBox-X failed to start: {stderr}")
|
||||
|
||||
logger.info(f"DOSBox-X started (PID: {self._process.pid})")
|
||||
|
||||
def launch_docker(
|
||||
self,
|
||||
binary_path: str | None = None,
|
||||
config: DOSBoxConfig | None = None,
|
||||
image: str = "dosbox-mcp:latest",
|
||||
display: str | None = None,
|
||||
) -> None:
|
||||
"""Launch DOSBox-X in Docker container.
|
||||
|
||||
Args:
|
||||
binary_path: Optional DOS binary to run
|
||||
config: Configuration (uses defaults if not provided)
|
||||
image: Docker image name
|
||||
display: X11 display (default: $DISPLAY)
|
||||
"""
|
||||
if self.running:
|
||||
raise RuntimeError("DOSBox-X is already running")
|
||||
|
||||
# Check Docker availability
|
||||
try:
|
||||
subprocess.run(["docker", "version"], capture_output=True, check=True)
|
||||
except (subprocess.SubprocessError, FileNotFoundError) as e:
|
||||
raise RuntimeError("Docker not available") from e
|
||||
|
||||
# Create temporary directory
|
||||
self._temp_dir = Path(tempfile.mkdtemp(prefix="dosbox-mcp-"))
|
||||
config = config or DOSBoxConfig()
|
||||
self._gdb_port = config.gdb_port
|
||||
|
||||
# Write config file
|
||||
self._config_path = self._temp_dir / "dosbox.conf"
|
||||
self._config_path.write_text(config.to_conf())
|
||||
|
||||
# Build docker command
|
||||
display = display or os.environ.get("DISPLAY", ":0")
|
||||
|
||||
cmd = [
|
||||
"docker", "run",
|
||||
"--rm",
|
||||
"-d", # Detached
|
||||
"--name", f"dosbox-mcp-{os.getpid()}",
|
||||
# Network
|
||||
"-p", f"{self._gdb_port}:{self._gdb_port}",
|
||||
# X11 forwarding
|
||||
"-e", f"DISPLAY={display}",
|
||||
"-v", "/tmp/.X11-unix:/tmp/.X11-unix",
|
||||
# Config mount
|
||||
"-v", f"{self._config_path}:/config/dosbox.conf:ro",
|
||||
]
|
||||
|
||||
# Mount binary directory if specified
|
||||
if binary_path:
|
||||
binary = Path(binary_path).resolve()
|
||||
if not binary.exists():
|
||||
raise FileNotFoundError(f"Binary not found: {binary_path}")
|
||||
cmd.extend(["-v", f"{binary.parent}:/dos:ro"])
|
||||
|
||||
cmd.append(image)
|
||||
|
||||
logger.info(f"Launching Docker: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Docker launch failed: {result.stderr}")
|
||||
|
||||
self._container_id = result.stdout.strip()
|
||||
|
||||
# Wait for container and GDB stub
|
||||
time.sleep(2.0)
|
||||
|
||||
if not self.running:
|
||||
logs = self.get_logs()
|
||||
raise RuntimeError(f"Container failed to start: {logs}")
|
||||
|
||||
logger.info(f"DOSBox-X container started: {self._container_id[:12]}")
|
||||
|
||||
def stop(self, timeout: float = 5.0) -> None:
|
||||
"""Stop DOSBox-X.
|
||||
|
||||
Args:
|
||||
timeout: Seconds to wait before force-killing
|
||||
"""
|
||||
if self._process:
|
||||
self._process.terminate()
|
||||
try:
|
||||
self._process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
self._process.wait()
|
||||
self._process = None
|
||||
logger.info("DOSBox-X process stopped")
|
||||
|
||||
if self._container_id:
|
||||
try:
|
||||
subprocess.run(
|
||||
["docker", "stop", "-t", str(int(timeout)), self._container_id],
|
||||
capture_output=True,
|
||||
timeout=timeout + 5
|
||||
)
|
||||
except subprocess.SubprocessError:
|
||||
# Force remove
|
||||
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:
|
||||
shutil.rmtree(self._temp_dir)
|
||||
except OSError:
|
||||
pass
|
||||
self._temp_dir = None
|
||||
|
||||
def get_logs(self, lines: int = 50) -> str:
|
||||
"""Get recent logs.
|
||||
|
||||
Args:
|
||||
lines: Number of lines to retrieve
|
||||
|
||||
Returns:
|
||||
Log output string
|
||||
"""
|
||||
if self._process:
|
||||
# For native process, we don't capture logs in real-time
|
||||
return "(Native process - logs not captured)"
|
||||
|
||||
if self._container_id:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "logs", "--tail", str(lines), self._container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
return result.stdout + result.stderr
|
||||
except subprocess.SubprocessError:
|
||||
return "(Failed to get container logs)"
|
||||
|
||||
return "(Not running)"
|
||||
|
||||
def screenshot(self, output_path: str | None = None) -> bytes | None:
|
||||
"""Capture screenshot.
|
||||
|
||||
This uses DOSBox-X's built-in screenshot capability or
|
||||
external tools depending on the setup.
|
||||
|
||||
Args:
|
||||
output_path: Optional path to save screenshot
|
||||
|
||||
Returns:
|
||||
PNG image data, or None if not available
|
||||
"""
|
||||
# DOSBox-X screenshots are typically saved via hotkey (F12)
|
||||
# For programmatic capture, we'd need to:
|
||||
# 1. Send the hotkey
|
||||
# 2. Wait for the file
|
||||
# 3. Read and return it
|
||||
|
||||
# For now, this is a placeholder
|
||||
logger.warning("Screenshot not yet implemented")
|
||||
return None
|
||||
|
||||
def send_keys(self, keys: str) -> None:
|
||||
"""Send keystrokes to DOSBox-X.
|
||||
|
||||
This is useful for interacting with DOS programs.
|
||||
|
||||
Args:
|
||||
keys: String of characters to send
|
||||
"""
|
||||
# This would require X11 integration or DOSBox-X's mapper
|
||||
# For now, placeholder
|
||||
logger.warning("Key sending not yet implemented")
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Context manager exit - ensure cleanup."""
|
||||
self.stop()
|
||||
return False
|
||||
584
src/dosbox_mcp/gdb_client.py
Normal file
584
src/dosbox_mcp/gdb_client.py
Normal file
@ -0,0 +1,584 @@
|
||||
"""GDB Remote Serial Protocol client for DOSBox-X debugging.
|
||||
|
||||
The GDB Remote Serial Protocol is a simple text-based protocol used for
|
||||
debugger communication over serial lines or TCP sockets. Each packet has
|
||||
the format:
|
||||
|
||||
$<command>#<checksum>
|
||||
|
||||
Where checksum is the sum of all command bytes modulo 256, as two hex digits.
|
||||
|
||||
The receiver acknowledges with '+' (success) or '-' (retry).
|
||||
|
||||
This implementation provides a synchronous client that connects to DOSBox-X's
|
||||
GDB stub, typically running on localhost:1234.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from .types import Breakpoint, MemoryRegion, Registers, StopEvent, StopReason
|
||||
from .utils import (
|
||||
calculate_checksum,
|
||||
decode_hex,
|
||||
encode_hex,
|
||||
parse_registers_x86,
|
||||
parse_stop_reply,
|
||||
signal_name,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GDBError(Exception):
|
||||
"""Error communicating with GDB stub."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GDBClient:
|
||||
"""Client for GDB Remote Serial Protocol.
|
||||
|
||||
This client implements the subset of GDB protocol needed for debugging
|
||||
DOS programs in DOSBox-X:
|
||||
- Register read/write
|
||||
- Memory read/write
|
||||
- Breakpoints (software)
|
||||
- Continue/step execution
|
||||
|
||||
Example:
|
||||
client = GDBClient()
|
||||
client.connect("localhost", 1234)
|
||||
regs = client.read_registers()
|
||||
print(f"CS:IP = {regs.cs:04x}:{regs.ip:04x}")
|
||||
client.set_breakpoint(0x10100)
|
||||
client.continue_execution()
|
||||
"""
|
||||
|
||||
def __init__(self, timeout: float = 5.0):
|
||||
"""Initialize GDB client.
|
||||
|
||||
Args:
|
||||
timeout: Socket timeout in seconds
|
||||
"""
|
||||
self.timeout = timeout
|
||||
self._socket: socket.socket | None = None
|
||||
self._connected = False
|
||||
self._host = ""
|
||||
self._port = 0
|
||||
self._breakpoints: dict[int, Breakpoint] = {}
|
||||
self._next_bp_id = 1
|
||||
self._stop_callback: Callable[[StopEvent], None] | None = None
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""Check if connected to GDB stub."""
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def host(self) -> str:
|
||||
"""Get connected host."""
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
"""Get connected port."""
|
||||
return self._port
|
||||
|
||||
def connect(self, host: str = "localhost", port: int = 1234) -> None:
|
||||
"""Connect to GDB stub.
|
||||
|
||||
Args:
|
||||
host: Hostname or IP address
|
||||
port: Port number (default 1234 for DOSBox-X)
|
||||
|
||||
Raises:
|
||||
GDBError: If connection fails
|
||||
"""
|
||||
if self._connected:
|
||||
self.disconnect()
|
||||
|
||||
try:
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self._socket.settimeout(self.timeout)
|
||||
self._socket.connect((host, port))
|
||||
self._host = host
|
||||
self._port = port
|
||||
self._connected = True
|
||||
logger.info(f"Connected to GDB stub at {host}:{port}")
|
||||
|
||||
# Some GDB stubs send an initial packet; try to read it
|
||||
try:
|
||||
self._socket.settimeout(0.5)
|
||||
initial = self._socket.recv(1024)
|
||||
if initial:
|
||||
logger.debug(f"Initial data from stub: {initial!r}")
|
||||
except socket.timeout:
|
||||
pass
|
||||
finally:
|
||||
self._socket.settimeout(self.timeout)
|
||||
|
||||
except OSError as e:
|
||||
self._connected = False
|
||||
raise GDBError(f"Failed to connect to {host}:{port}: {e}") from e
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from GDB stub."""
|
||||
if self._socket:
|
||||
try:
|
||||
self._socket.close()
|
||||
except OSError:
|
||||
pass
|
||||
self._socket = None
|
||||
self._connected = False
|
||||
self._host = ""
|
||||
self._port = 0
|
||||
logger.info("Disconnected from GDB stub")
|
||||
|
||||
def _send_packet(self, command: str) -> None:
|
||||
"""Send a GDB packet.
|
||||
|
||||
Args:
|
||||
command: Command string (without $ and checksum)
|
||||
|
||||
Raises:
|
||||
GDBError: If not connected or send fails
|
||||
"""
|
||||
if not self._connected or not self._socket:
|
||||
raise GDBError("Not connected to GDB stub")
|
||||
|
||||
checksum = calculate_checksum(command)
|
||||
packet = f"${command}#{checksum}"
|
||||
logger.debug(f"Sending: {packet}")
|
||||
|
||||
try:
|
||||
self._socket.sendall(packet.encode('latin-1'))
|
||||
except OSError as e:
|
||||
self._connected = False
|
||||
raise GDBError(f"Send failed: {e}") from e
|
||||
|
||||
def _recv_packet(self) -> str:
|
||||
"""Receive a GDB packet.
|
||||
|
||||
Returns:
|
||||
Response string (without $ and checksum)
|
||||
|
||||
Raises:
|
||||
GDBError: If receive fails or checksum mismatch
|
||||
"""
|
||||
if not self._connected or not self._socket:
|
||||
raise GDBError("Not connected to GDB stub")
|
||||
|
||||
try:
|
||||
data = b""
|
||||
# Read until we get a complete packet
|
||||
while True:
|
||||
chunk = self._socket.recv(4096)
|
||||
if not chunk:
|
||||
raise GDBError("Connection closed by remote")
|
||||
data += chunk
|
||||
|
||||
# Look for packet boundaries
|
||||
decoded = data.decode('latin-1')
|
||||
|
||||
# Skip any leading ACK/NAK
|
||||
while decoded and decoded[0] in '+-':
|
||||
decoded = decoded[1:]
|
||||
|
||||
if '$' in decoded and '#' in decoded:
|
||||
# Find packet bounds
|
||||
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]
|
||||
|
||||
# Verify checksum
|
||||
expected = calculate_checksum(packet_data)
|
||||
if checksum.lower() != expected.lower():
|
||||
logger.warning(
|
||||
f"Checksum mismatch: got {checksum}, expected {expected}"
|
||||
)
|
||||
# Send NAK
|
||||
self._socket.sendall(b'-')
|
||||
continue
|
||||
|
||||
# Send ACK
|
||||
self._socket.sendall(b'+')
|
||||
logger.debug(f"Received: ${packet_data}#{checksum}")
|
||||
return packet_data
|
||||
|
||||
except socket.timeout:
|
||||
raise GDBError("Receive timeout") from None
|
||||
except OSError as e:
|
||||
self._connected = False
|
||||
raise GDBError(f"Receive failed: {e}") from e
|
||||
|
||||
def _command(self, cmd: str) -> str:
|
||||
"""Send command and receive response.
|
||||
|
||||
Args:
|
||||
cmd: GDB command
|
||||
|
||||
Returns:
|
||||
Response string
|
||||
"""
|
||||
self._send_packet(cmd)
|
||||
return self._recv_packet()
|
||||
|
||||
# =========================================================================
|
||||
# Register Operations
|
||||
# =========================================================================
|
||||
|
||||
def read_registers(self) -> Registers:
|
||||
"""Read all CPU registers.
|
||||
|
||||
Returns:
|
||||
Registers object with all CPU register values
|
||||
"""
|
||||
response = self._command("g")
|
||||
|
||||
if response.startswith("E"):
|
||||
raise GDBError(f"Failed to read registers: {response}")
|
||||
|
||||
reg_dict = parse_registers_x86(response)
|
||||
return Registers(**reg_dict)
|
||||
|
||||
def write_registers(self, regs: Registers) -> None:
|
||||
"""Write all CPU registers.
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
response = self._command(f"G{hex_data}")
|
||||
if response.startswith("E"):
|
||||
raise GDBError(f"Failed to write registers: {response}")
|
||||
|
||||
def read_register(self, reg_num: int) -> int:
|
||||
"""Read a single register by number.
|
||||
|
||||
Args:
|
||||
reg_num: Register number (0=EAX, 1=ECX, ..., 8=EIP, etc.)
|
||||
|
||||
Returns:
|
||||
Register value
|
||||
"""
|
||||
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')
|
||||
|
||||
# =========================================================================
|
||||
# Memory Operations
|
||||
# =========================================================================
|
||||
|
||||
def read_memory(self, address: int, length: int) -> MemoryRegion:
|
||||
"""Read memory from target.
|
||||
|
||||
Args:
|
||||
address: Physical memory address
|
||||
length: Number of bytes to read
|
||||
|
||||
Returns:
|
||||
MemoryRegion with read data
|
||||
"""
|
||||
response = self._command(f"m{address:x},{length:x}")
|
||||
|
||||
if response.startswith("E"):
|
||||
raise GDBError(f"Failed to read memory at {address:05x}: {response}")
|
||||
|
||||
data = decode_hex(response)
|
||||
return MemoryRegion(address=address, data=data)
|
||||
|
||||
def write_memory(self, address: int, data: bytes) -> None:
|
||||
"""Write memory to target.
|
||||
|
||||
Args:
|
||||
address: Physical memory address
|
||||
data: Bytes to write
|
||||
"""
|
||||
hex_data = encode_hex(data)
|
||||
response = self._command(f"M{address:x},{len(data):x}:{hex_data}")
|
||||
|
||||
if response.startswith("E"):
|
||||
raise GDBError(f"Failed to write memory at {address:05x}: {response}")
|
||||
|
||||
# =========================================================================
|
||||
# Breakpoint Operations
|
||||
# =========================================================================
|
||||
|
||||
def set_breakpoint(self, address: int) -> Breakpoint:
|
||||
"""Set a software breakpoint.
|
||||
|
||||
Args:
|
||||
address: Physical memory address
|
||||
|
||||
Returns:
|
||||
Breakpoint object
|
||||
"""
|
||||
# Z0 = software breakpoint, address, kind (1 byte for x86)
|
||||
response = self._command(f"Z0,{address:x},1")
|
||||
|
||||
if response.startswith("E"):
|
||||
raise GDBError(f"Failed to set breakpoint at {address:05x}: {response}")
|
||||
if response == "":
|
||||
raise GDBError("Breakpoints not supported by this GDB stub")
|
||||
|
||||
bp = Breakpoint(
|
||||
id=self._next_bp_id,
|
||||
address=address,
|
||||
enabled=True,
|
||||
original=f"{address:05x}"
|
||||
)
|
||||
self._breakpoints[bp.id] = bp
|
||||
self._next_bp_id += 1
|
||||
logger.info(f"Set breakpoint {bp.id} at {address:05x}")
|
||||
return bp
|
||||
|
||||
def delete_breakpoint(self, bp_id: int) -> None:
|
||||
"""Delete a breakpoint by ID.
|
||||
|
||||
Args:
|
||||
bp_id: Breakpoint ID
|
||||
"""
|
||||
if bp_id not in self._breakpoints:
|
||||
raise GDBError(f"Breakpoint {bp_id} not found")
|
||||
|
||||
bp = self._breakpoints[bp_id]
|
||||
response = self._command(f"z0,{bp.address:x},1")
|
||||
|
||||
if response.startswith("E"):
|
||||
raise GDBError(f"Failed to delete breakpoint {bp_id}: {response}")
|
||||
|
||||
del self._breakpoints[bp_id]
|
||||
logger.info(f"Deleted breakpoint {bp_id}")
|
||||
|
||||
def delete_all_breakpoints(self) -> int:
|
||||
"""Delete all breakpoints.
|
||||
|
||||
Returns:
|
||||
Number of breakpoints deleted
|
||||
"""
|
||||
count = 0
|
||||
for bp_id in list(self._breakpoints.keys()):
|
||||
try:
|
||||
self.delete_breakpoint(bp_id)
|
||||
count += 1
|
||||
except GDBError as e:
|
||||
logger.warning(f"Failed to delete breakpoint {bp_id}: {e}")
|
||||
return count
|
||||
|
||||
def list_breakpoints(self) -> list[Breakpoint]:
|
||||
"""List all breakpoints.
|
||||
|
||||
Returns:
|
||||
List of Breakpoint objects
|
||||
"""
|
||||
return list(self._breakpoints.values())
|
||||
|
||||
# =========================================================================
|
||||
# Execution Control
|
||||
# =========================================================================
|
||||
|
||||
def continue_execution(self, timeout: float | None = None) -> StopEvent:
|
||||
"""Continue execution until breakpoint or signal.
|
||||
|
||||
Args:
|
||||
timeout: Optional timeout in seconds (None = use default)
|
||||
|
||||
Returns:
|
||||
StopEvent describing why execution stopped
|
||||
"""
|
||||
old_timeout = self._socket.gettimeout() if self._socket else self.timeout
|
||||
if timeout is not None and self._socket:
|
||||
self._socket.settimeout(timeout)
|
||||
|
||||
try:
|
||||
self._send_packet("c")
|
||||
response = self._recv_packet()
|
||||
finally:
|
||||
if self._socket:
|
||||
self._socket.settimeout(old_timeout)
|
||||
|
||||
return self._parse_stop(response)
|
||||
|
||||
def step(self, count: int = 1) -> StopEvent:
|
||||
"""Step one or more instructions.
|
||||
|
||||
Args:
|
||||
count: Number of instructions to step
|
||||
|
||||
Returns:
|
||||
StopEvent describing current state
|
||||
"""
|
||||
event = None
|
||||
for _ in range(count):
|
||||
self._send_packet("s")
|
||||
response = self._recv_packet()
|
||||
event = self._parse_stop(response)
|
||||
if event.reason != StopReason.STEP:
|
||||
break
|
||||
return event or StopEvent(reason=StopReason.UNKNOWN)
|
||||
|
||||
def step_over(self) -> StopEvent:
|
||||
"""Step over a call instruction (step + continue if at call).
|
||||
|
||||
This is a higher-level operation that checks the current instruction
|
||||
and sets a temporary breakpoint after it if it's a CALL.
|
||||
|
||||
Returns:
|
||||
StopEvent describing current state
|
||||
"""
|
||||
# Read current instruction to see if it's a CALL
|
||||
regs = self.read_registers()
|
||||
addr = regs.cs_ip
|
||||
mem = self.read_memory(addr, 8)
|
||||
|
||||
# Check for CALL instructions (E8 = near call, FF /2 = call r/m)
|
||||
# This is simplified - a real implementation would need a disassembler
|
||||
opcode = mem.data[0] if mem.data else 0
|
||||
|
||||
if opcode == 0xE8: # CALL rel16/rel32
|
||||
# Set breakpoint after the call (5 bytes for E8 xx xx xx xx)
|
||||
next_addr = addr + 5
|
||||
tmp_bp = self.set_breakpoint(next_addr)
|
||||
try:
|
||||
event = self.continue_execution()
|
||||
finally:
|
||||
self.delete_breakpoint(tmp_bp.id)
|
||||
return event
|
||||
elif opcode == 0xFF:
|
||||
# Check for FF /2 (CALL r/m)
|
||||
modrm = mem.data[1] if len(mem.data) > 1 else 0
|
||||
reg = (modrm >> 3) & 0x07
|
||||
if reg == 2: # CALL
|
||||
# Instruction length varies - this is simplified
|
||||
next_addr = addr + 2 # Minimum size
|
||||
tmp_bp = self.set_breakpoint(next_addr)
|
||||
try:
|
||||
event = self.continue_execution()
|
||||
finally:
|
||||
self.delete_breakpoint(tmp_bp.id)
|
||||
return event
|
||||
|
||||
# Not a call, just step
|
||||
return self.step()
|
||||
|
||||
def _parse_stop(self, response: str) -> StopEvent:
|
||||
"""Parse a stop reply into a StopEvent."""
|
||||
stop_type, info = parse_stop_reply(response)
|
||||
|
||||
if stop_type == "signal":
|
||||
signal = info.get("signal", 0)
|
||||
# SIGTRAP (5) usually indicates a breakpoint
|
||||
if signal == 5:
|
||||
# Check if we hit a known breakpoint
|
||||
regs = self.read_registers()
|
||||
addr = regs.cs_ip
|
||||
for bp in self._breakpoints.values():
|
||||
if bp.address == addr or bp.address == addr - 1:
|
||||
bp.hit_count += 1
|
||||
return StopEvent(
|
||||
reason=StopReason.BREAKPOINT,
|
||||
address=addr,
|
||||
signal=signal,
|
||||
breakpoint_id=bp.id
|
||||
)
|
||||
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.UNKNOWN)
|
||||
|
||||
# =========================================================================
|
||||
# Query Operations
|
||||
# =========================================================================
|
||||
|
||||
def query_supported(self) -> list[str]:
|
||||
"""Query supported features.
|
||||
|
||||
Returns:
|
||||
List of supported feature strings
|
||||
"""
|
||||
response = self._command("qSupported")
|
||||
if response.startswith("E"):
|
||||
return []
|
||||
return response.split(';')
|
||||
|
||||
def query_attached(self) -> bool:
|
||||
"""Query if attached to existing process.
|
||||
|
||||
Returns:
|
||||
True if attached to existing process
|
||||
"""
|
||||
response = self._command("qAttached")
|
||||
return response == "1"
|
||||
|
||||
def detach(self) -> None:
|
||||
"""Detach from target (allow it to continue running)."""
|
||||
self._command("D")
|
||||
self.disconnect()
|
||||
|
||||
def kill(self) -> None:
|
||||
"""Kill the target process."""
|
||||
try:
|
||||
self._command("k")
|
||||
except GDBError:
|
||||
pass # Connection may close immediately
|
||||
self.disconnect()
|
||||
|
||||
# =========================================================================
|
||||
# Utility Methods
|
||||
# =========================================================================
|
||||
|
||||
def interrupt(self) -> None:
|
||||
"""Send interrupt to stop running target.
|
||||
|
||||
This sends Ctrl-C (0x03) to halt execution.
|
||||
"""
|
||||
if not self._connected or not self._socket:
|
||||
raise GDBError("Not connected to GDB stub")
|
||||
|
||||
try:
|
||||
self._socket.sendall(b'\x03')
|
||||
logger.debug("Sent interrupt")
|
||||
except OSError as e:
|
||||
self._connected = False
|
||||
raise GDBError(f"Interrupt failed: {e}") from e
|
||||
|
||||
def get_stop_reason(self) -> StopEvent:
|
||||
"""Query current stop reason.
|
||||
|
||||
Returns:
|
||||
StopEvent describing current state
|
||||
"""
|
||||
response = self._command("?")
|
||||
return self._parse_stop(response)
|
||||
684
src/dosbox_mcp/server.py
Normal file
684
src/dosbox_mcp/server.py
Normal file
@ -0,0 +1,684 @@
|
||||
"""DOSBox-X MCP Server - AI-assisted DOS binary debugging.
|
||||
|
||||
This server exposes DOSBox-X debugging capabilities through the Model Context
|
||||
Protocol (MCP), enabling Claude to programmatically debug DOS binaries.
|
||||
|
||||
Primary use case: Reverse engineering classic DOS programs by setting breakpoints,
|
||||
reading memory, and tracing execution.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from importlib.metadata import version
|
||||
from typing import Literal
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from .dosbox import DOSBoxConfig, DOSBoxManager
|
||||
from .gdb_client import GDBClient, GDBError
|
||||
from .types import DOSBoxStatus
|
||||
from .utils import format_address, hexdump, parse_address
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Get package version
|
||||
try:
|
||||
PACKAGE_VERSION = version("dosbox-mcp")
|
||||
except Exception:
|
||||
PACKAGE_VERSION = "2025.01.27"
|
||||
|
||||
# Initialize FastMCP server
|
||||
mcp = FastMCP(
|
||||
name="dosbox-mcp",
|
||||
instructions="""
|
||||
DOSBox-X MCP Server for debugging DOS binaries.
|
||||
|
||||
This server provides tools to:
|
||||
- Launch DOSBox-X with GDB debugging enabled
|
||||
- Set breakpoints and trace execution
|
||||
- Read/write CPU registers and memory
|
||||
- Disassemble code at any address
|
||||
|
||||
Typical workflow:
|
||||
1. launch() - Start DOSBox-X with a DOS binary
|
||||
2. attach() - Connect to the GDB stub
|
||||
3. breakpoint_set() - Set breakpoints at interesting addresses
|
||||
4. continue() - Run until breakpoint hit
|
||||
5. registers() / memory_read() - Inspect state
|
||||
6. step() - Step through code
|
||||
7. quit() - Clean up
|
||||
|
||||
Address formats supported:
|
||||
- Segment:offset: "1234:5678" (standard DOS format)
|
||||
- Flat hex: "0x12345" or "12345"
|
||||
- Decimal: "#12345"
|
||||
"""
|
||||
)
|
||||
|
||||
# Global state
|
||||
_manager = DOSBoxManager()
|
||||
_client = GDBClient()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# P0 Tools - MVP for Bezier tracing
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def launch(
|
||||
binary_path: str | None = None,
|
||||
gdb_port: int = 1234,
|
||||
use_docker: bool = False,
|
||||
cycles: str = "auto",
|
||||
memsize: int = 16,
|
||||
) -> dict:
|
||||
"""Launch DOSBox-X with GDB debugging enabled.
|
||||
|
||||
Args:
|
||||
binary_path: Path to DOS binary to run (optional)
|
||||
gdb_port: Port for GDB stub (default: 1234)
|
||||
use_docker: Use Docker container instead of native DOSBox-X
|
||||
cycles: CPU cycles setting (auto, max, or number)
|
||||
memsize: Conventional memory in MB (default: 16)
|
||||
|
||||
Returns:
|
||||
Status dict with connection details
|
||||
|
||||
Example:
|
||||
launch("/path/to/GAME.EXE", gdb_port=1234)
|
||||
"""
|
||||
config = DOSBoxConfig(
|
||||
gdb_port=gdb_port,
|
||||
gdb_enabled=True,
|
||||
cycles=cycles,
|
||||
memsize=memsize,
|
||||
)
|
||||
|
||||
try:
|
||||
if use_docker:
|
||||
_manager.launch_docker(binary_path=binary_path, config=config)
|
||||
else:
|
||||
_manager.launch_native(binary_path=binary_path, config=config)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "DOSBox-X launched successfully",
|
||||
"gdb_host": "localhost",
|
||||
"gdb_port": gdb_port,
|
||||
"pid": _manager.pid,
|
||||
"hint": f"Use attach() to connect to the debugger on port {gdb_port}",
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def attach(host: str = "localhost", port: int = 1234) -> dict:
|
||||
"""Connect to a running DOSBox-X GDB stub.
|
||||
|
||||
Args:
|
||||
host: Hostname or IP (default: localhost)
|
||||
port: GDB port (default: 1234)
|
||||
|
||||
Returns:
|
||||
Connection status and initial register state
|
||||
|
||||
Example:
|
||||
attach("localhost", 1234)
|
||||
"""
|
||||
try:
|
||||
_client.connect(host, port)
|
||||
|
||||
# Get initial state
|
||||
regs = _client.read_registers()
|
||||
stop = _client.get_stop_reason()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Connected to {host}:{port}",
|
||||
"stop_reason": stop.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),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def breakpoint_set(address: str) -> dict:
|
||||
"""Set a software breakpoint at the specified address.
|
||||
|
||||
Args:
|
||||
address: Memory address (segment:offset or flat hex)
|
||||
|
||||
Returns:
|
||||
Breakpoint info
|
||||
|
||||
Examples:
|
||||
breakpoint_set("1234:0100") # segment:offset
|
||||
breakpoint_set("0x12340") # flat address
|
||||
"""
|
||||
try:
|
||||
addr = parse_address(address)
|
||||
bp = _client.set_breakpoint(addr)
|
||||
return {
|
||||
"success": True,
|
||||
"breakpoint_id": bp.id,
|
||||
"address": format_address(bp.address, "both"),
|
||||
"original": address,
|
||||
}
|
||||
except (GDBError, ValueError) as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def breakpoint_list() -> dict:
|
||||
"""List all active breakpoints.
|
||||
|
||||
Returns:
|
||||
List of breakpoint info
|
||||
"""
|
||||
bps = _client.list_breakpoints()
|
||||
return {
|
||||
"count": len(bps),
|
||||
"breakpoints": [bp.to_dict() for bp in bps],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def breakpoint_delete(id: int | None = None, all: bool = False) -> dict:
|
||||
"""Delete breakpoint(s).
|
||||
|
||||
Args:
|
||||
id: Specific breakpoint ID to delete
|
||||
all: If True, delete all breakpoints
|
||||
|
||||
Returns:
|
||||
Deletion result
|
||||
"""
|
||||
try:
|
||||
if all:
|
||||
count = _client.delete_all_breakpoints()
|
||||
return {
|
||||
"success": True,
|
||||
"deleted": count,
|
||||
}
|
||||
elif id is not None:
|
||||
_client.delete_breakpoint(id)
|
||||
return {
|
||||
"success": True,
|
||||
"deleted_id": id,
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Specify either 'id' or 'all=True'",
|
||||
}
|
||||
except GDBError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def registers() -> dict:
|
||||
"""Read all CPU registers.
|
||||
|
||||
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
|
||||
"""
|
||||
try:
|
||||
regs = _client.read_registers()
|
||||
return {
|
||||
"success": True,
|
||||
**regs.to_dict(),
|
||||
}
|
||||
except GDBError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def memory_read(
|
||||
address: str,
|
||||
length: int = 16,
|
||||
format: Literal["hex", "ascii", "dump"] = "dump",
|
||||
) -> dict:
|
||||
"""Read memory from target.
|
||||
|
||||
Args:
|
||||
address: Memory address (segment:offset or flat hex)
|
||||
length: Number of bytes to read (default: 16, max: 4096)
|
||||
format: Output format - "hex", "ascii", or "dump" (default)
|
||||
|
||||
Returns:
|
||||
Memory contents in requested format
|
||||
|
||||
Examples:
|
||||
memory_read("DS:0100", 64)
|
||||
memory_read("0x12340", 256, format="hex")
|
||||
"""
|
||||
try:
|
||||
# Handle register-based addresses like "DS:SI"
|
||||
addr_str = address.upper()
|
||||
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'}
|
||||
|
||||
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)
|
||||
addr = (seg_val << 4) + off_val
|
||||
else:
|
||||
addr = parse_address(address)
|
||||
else:
|
||||
addr = parse_address(address)
|
||||
|
||||
# Limit read size
|
||||
length = min(length, 4096)
|
||||
|
||||
mem = _client.read_memory(addr, length)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"address": format_address(addr, "both"),
|
||||
"length": len(mem.data),
|
||||
}
|
||||
|
||||
if format == "hex":
|
||||
result["data"] = mem.to_hex()
|
||||
elif format == "ascii":
|
||||
result["data"] = mem.to_ascii()
|
||||
else: # dump
|
||||
result["dump"] = hexdump(mem.data, addr)
|
||||
|
||||
return result
|
||||
|
||||
except (GDBError, ValueError) as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def memory_write(
|
||||
address: str,
|
||||
data: str,
|
||||
format: Literal["hex", "ascii"] = "hex",
|
||||
) -> dict:
|
||||
"""Write memory to target.
|
||||
|
||||
Args:
|
||||
address: Memory address (segment:offset or flat hex)
|
||||
data: Data to write (hex string or ASCII)
|
||||
format: Input format - "hex" or "ascii"
|
||||
|
||||
Returns:
|
||||
Write result
|
||||
|
||||
Examples:
|
||||
memory_write("1234:0100", "90909090", format="hex") # NOP sled
|
||||
memory_write("DS:0100", "Hello", format="ascii")
|
||||
"""
|
||||
try:
|
||||
addr = parse_address(address)
|
||||
|
||||
if format == "hex":
|
||||
bytes_data = bytes.fromhex(data)
|
||||
else:
|
||||
bytes_data = data.encode('latin-1')
|
||||
|
||||
_client.write_memory(addr, bytes_data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"address": format_address(addr, "both"),
|
||||
"bytes_written": len(bytes_data),
|
||||
}
|
||||
except (GDBError, ValueError) as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def disassemble(address: str | None = None, count: int = 10) -> dict:
|
||||
"""Disassemble instructions at address.
|
||||
|
||||
Note: This is a simplified disassembler. For complex analysis,
|
||||
use a dedicated tool like Ghidra.
|
||||
|
||||
Args:
|
||||
address: Start address (default: current CS:IP)
|
||||
count: Number of bytes to read for disassembly (default: 10)
|
||||
|
||||
Returns:
|
||||
Raw bytes and simple instruction hints
|
||||
"""
|
||||
try:
|
||||
if address:
|
||||
addr = parse_address(address)
|
||||
else:
|
||||
regs = _client.read_registers()
|
||||
addr = regs.cs_ip
|
||||
|
||||
# Read memory for disassembly
|
||||
mem = _client.read_memory(addr, count * 4) # Rough estimate
|
||||
|
||||
# Simple x86 opcode hints (not a full disassembler)
|
||||
# This is just to give Claude some context
|
||||
opcodes = {
|
||||
0x90: "NOP",
|
||||
0xCC: "INT 3",
|
||||
0xCD: "INT",
|
||||
0xC3: "RET",
|
||||
0xCB: "RETF",
|
||||
0xE8: "CALL",
|
||||
0xE9: "JMP",
|
||||
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",
|
||||
}
|
||||
|
||||
lines = []
|
||||
offset = 0
|
||||
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,
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"start_address": format_address(addr, "both"),
|
||||
"raw_bytes": mem.data[:count].hex(),
|
||||
"instructions": lines,
|
||||
"note": "This is a simplified view. Use Ghidra for full disassembly.",
|
||||
}
|
||||
|
||||
except (GDBError, ValueError) as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def stack(count: int = 16) -> dict:
|
||||
"""Dump stack contents.
|
||||
|
||||
Args:
|
||||
count: Number of words to dump (default: 16)
|
||||
|
||||
Returns:
|
||||
Stack contents with SS:SP and values
|
||||
"""
|
||||
try:
|
||||
regs = _client.read_registers()
|
||||
sp_addr = regs.ss_sp
|
||||
|
||||
# Read stack (2 bytes per word in real mode)
|
||||
mem = _client.read_memory(sp_addr, count * 2)
|
||||
|
||||
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}",
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"ss_sp": f"{regs.ss:04x}:{regs.sp:04x}",
|
||||
"physical_address": format_address(sp_addr, "both"),
|
||||
"words": words,
|
||||
}
|
||||
|
||||
except GDBError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def status() -> dict:
|
||||
"""Get current debugger and emulator status.
|
||||
|
||||
Returns:
|
||||
Complete status including connection state, breakpoints, etc.
|
||||
"""
|
||||
status = DOSBoxStatus(
|
||||
running=_manager.running,
|
||||
connected=_client.connected,
|
||||
host=_client.host,
|
||||
port=_client.port,
|
||||
pid=_manager.pid,
|
||||
breakpoints=_client.list_breakpoints() if _client.connected else [],
|
||||
)
|
||||
|
||||
result = status.to_dict()
|
||||
|
||||
# Add register state if connected
|
||||
if _client.connected:
|
||||
try:
|
||||
regs = _client.read_registers()
|
||||
result["cs_ip"] = f"{regs.cs:04x}:{regs.ip:04x}"
|
||||
result["ss_sp"] = f"{regs.ss:04x}:{regs.sp:04x}"
|
||||
except GDBError:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# P2 Tools - Nice to have
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def screenshot(filename: str | None = None) -> dict:
|
||||
"""Capture DOSBox-X display.
|
||||
|
||||
Args:
|
||||
filename: Optional output filename
|
||||
|
||||
Returns:
|
||||
Screenshot info or error
|
||||
"""
|
||||
# Placeholder - requires X11 or DOSBox-X specific integration
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Screenshot not yet implemented. Use DOSBox-X hotkey F12.",
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def serial_send(data: str, port: int = 1) -> dict:
|
||||
"""Send data to DOSBox-X serial port.
|
||||
|
||||
This is useful for RIPscrip testing - send graphics commands
|
||||
to a program listening on COM1.
|
||||
|
||||
Args:
|
||||
data: Data to send (text or hex with \\x prefix)
|
||||
port: COM port number (1 or 2)
|
||||
|
||||
Returns:
|
||||
Send result
|
||||
"""
|
||||
# Placeholder - requires serial port configuration
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Serial port communication not yet implemented.",
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Entry Point
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point for the MCP server."""
|
||||
print(f"🎮 DOSBox-X MCP Server v{PACKAGE_VERSION}")
|
||||
print("AI-assisted DOS binary debugging")
|
||||
print()
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
313
src/dosbox_mcp/types.py
Normal file
313
src/dosbox_mcp/types.py
Normal file
@ -0,0 +1,313 @@
|
||||
"""Type definitions for DOSBox-X MCP Server."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Literal
|
||||
|
||||
|
||||
class StopReason(Enum):
|
||||
"""Reasons why execution stopped."""
|
||||
|
||||
BREAKPOINT = auto()
|
||||
STEP = auto()
|
||||
SIGNAL = auto()
|
||||
EXITED = auto()
|
||||
UNKNOWN = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Registers:
|
||||
"""x86 real-mode CPU registers.
|
||||
|
||||
In real mode, segment registers (CS, DS, ES, SS) combine with offsets
|
||||
to form 20-bit physical addresses: (segment << 4) + offset.
|
||||
|
||||
GDB returns these in a specific order that we must parse correctly.
|
||||
"""
|
||||
|
||||
# General purpose registers (32-bit, but real mode uses 16-bit)
|
||||
eax: int = 0
|
||||
ecx: int = 0
|
||||
edx: int = 0
|
||||
ebx: int = 0
|
||||
esp: int = 0
|
||||
ebp: int = 0
|
||||
esi: int = 0
|
||||
edi: int = 0
|
||||
|
||||
# Instruction pointer
|
||||
eip: int = 0
|
||||
|
||||
# Flags register
|
||||
eflags: int = 0
|
||||
|
||||
# Segment registers (16-bit)
|
||||
cs: int = 0
|
||||
ss: int = 0
|
||||
ds: int = 0
|
||||
es: int = 0
|
||||
fs: int = 0
|
||||
gs: int = 0
|
||||
|
||||
@property
|
||||
def ax(self) -> int:
|
||||
"""Lower 16 bits of EAX."""
|
||||
return self.eax & 0xFFFF
|
||||
|
||||
@property
|
||||
def bx(self) -> int:
|
||||
"""Lower 16 bits of EBX."""
|
||||
return self.ebx & 0xFFFF
|
||||
|
||||
@property
|
||||
def cx(self) -> int:
|
||||
"""Lower 16 bits of ECX."""
|
||||
return self.ecx & 0xFFFF
|
||||
|
||||
@property
|
||||
def dx(self) -> int:
|
||||
"""Lower 16 bits of EDX."""
|
||||
return self.edx & 0xFFFF
|
||||
|
||||
@property
|
||||
def sp(self) -> int:
|
||||
"""Lower 16 bits of ESP."""
|
||||
return self.esp & 0xFFFF
|
||||
|
||||
@property
|
||||
def bp(self) -> int:
|
||||
"""Lower 16 bits of EBP."""
|
||||
return self.ebp & 0xFFFF
|
||||
|
||||
@property
|
||||
def si(self) -> int:
|
||||
"""Lower 16 bits of ESI."""
|
||||
return self.esi & 0xFFFF
|
||||
|
||||
@property
|
||||
def di(self) -> int:
|
||||
"""Lower 16 bits of EDI."""
|
||||
return self.edi & 0xFFFF
|
||||
|
||||
@property
|
||||
def ip(self) -> int:
|
||||
"""Lower 16 bits of EIP."""
|
||||
return self.eip & 0xFFFF
|
||||
|
||||
@property
|
||||
def flags(self) -> int:
|
||||
"""Lower 16 bits of EFLAGS."""
|
||||
return self.eflags & 0xFFFF
|
||||
|
||||
def physical_address(self, segment: str, offset: str) -> int:
|
||||
"""Calculate physical address from segment:offset.
|
||||
|
||||
Args:
|
||||
segment: Segment register name ('cs', 'ds', 'es', 'ss')
|
||||
offset: Offset register name ('ip', 'sp', 'si', 'di', 'bx', etc.)
|
||||
|
||||
Returns:
|
||||
20-bit physical address
|
||||
"""
|
||||
seg_val = getattr(self, segment.lower())
|
||||
off_val = getattr(self, offset.lower())
|
||||
return (seg_val << 4) + off_val
|
||||
|
||||
@property
|
||||
def cs_ip(self) -> int:
|
||||
"""Physical address of CS:IP (current instruction)."""
|
||||
return (self.cs << 4) + self.ip
|
||||
|
||||
@property
|
||||
def ss_sp(self) -> int:
|
||||
"""Physical address of SS:SP (current stack)."""
|
||||
return (self.ss << 4) + self.sp
|
||||
|
||||
def flag_set(self, flag: str) -> bool:
|
||||
"""Check if a CPU flag is set.
|
||||
|
||||
Flags in EFLAGS register:
|
||||
- CF (Carry): bit 0
|
||||
- PF (Parity): bit 2
|
||||
- AF (Aux Carry): bit 4
|
||||
- ZF (Zero): bit 6
|
||||
- SF (Sign): bit 7
|
||||
- TF (Trap): bit 8
|
||||
- IF (Interrupt): bit 9
|
||||
- DF (Direction): bit 10
|
||||
- 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,
|
||||
}
|
||||
bit = flag_bits.get(flag.lower())
|
||||
if bit is None:
|
||||
raise ValueError(f"Unknown flag: {flag}")
|
||||
return bool(self.eflags & (1 << bit))
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""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}',
|
||||
# 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}',
|
||||
# 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}',
|
||||
# Computed addresses
|
||||
'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'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Breakpoint:
|
||||
"""A debugger breakpoint."""
|
||||
|
||||
id: int
|
||||
address: int
|
||||
enabled: bool = True
|
||||
hit_count: int = 0
|
||||
|
||||
# Original format provided by user
|
||||
original: str = ""
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StopEvent:
|
||||
"""Event describing why execution stopped."""
|
||||
|
||||
reason: StopReason
|
||||
address: int = 0
|
||||
signal: int | None = None
|
||||
breakpoint_id: int | None = None
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryRegion:
|
||||
"""A region of memory read from the target."""
|
||||
|
||||
address: int
|
||||
data: bytes
|
||||
|
||||
def to_hex(self) -> str:
|
||||
"""Return data as hex string."""
|
||||
return self.data.hex()
|
||||
|
||||
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)
|
||||
|
||||
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),
|
||||
}
|
||||
if format in ("hex", "both"):
|
||||
result['hex'] = self.to_hex()
|
||||
if format in ("ascii", "both"):
|
||||
result['ascii'] = self.to_ascii()
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class DisassemblyLine:
|
||||
"""A single disassembled instruction."""
|
||||
|
||||
address: int
|
||||
bytes_hex: str
|
||||
mnemonic: str
|
||||
operands: str = ""
|
||||
|
||||
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(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DOSBoxStatus:
|
||||
"""Status of the DOSBox-X instance."""
|
||||
|
||||
running: bool = False
|
||||
connected: bool = False
|
||||
host: str = ""
|
||||
port: int = 0
|
||||
pid: int | None = None
|
||||
breakpoints: list[Breakpoint] = field(default_factory=list)
|
||||
|
||||
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),
|
||||
}
|
||||
282
src/dosbox_mcp/utils.py
Normal file
282
src/dosbox_mcp/utils.py
Normal file
@ -0,0 +1,282 @@
|
||||
"""Utility functions for DOSBox-X MCP Server."""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def parse_address(addr: str) -> int:
|
||||
"""Parse a DOS address in various formats.
|
||||
|
||||
Supports:
|
||||
- Segment:offset format: "1234:5678" -> physical address
|
||||
- Flat hex: "0x12345" or "12345h" or "12345"
|
||||
- Decimal: "#12345"
|
||||
|
||||
In real mode, physical address = (segment << 4) + offset
|
||||
This gives a 20-bit address space (1MB).
|
||||
|
||||
Args:
|
||||
addr: Address string in any supported format
|
||||
|
||||
Returns:
|
||||
Integer physical address
|
||||
|
||||
Examples:
|
||||
>>> parse_address("1000:0100") # segment:offset
|
||||
65792 # 0x10100
|
||||
>>> parse_address("0x10100") # flat hex
|
||||
65792
|
||||
>>> parse_address("F000:FFF0") # BIOS reset vector
|
||||
1048560 # 0xFFFF0
|
||||
"""
|
||||
addr = addr.strip().lower()
|
||||
|
||||
# Segment:offset format (e.g., "1234:5678")
|
||||
if ':' in addr:
|
||||
parts = addr.split(':')
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Invalid segment:offset format: {addr}")
|
||||
segment = int(parts[0], 16)
|
||||
offset = int(parts[1], 16)
|
||||
return (segment << 4) + offset
|
||||
|
||||
# Decimal format (e.g., "#12345")
|
||||
if addr.startswith('#'):
|
||||
return int(addr[1:], 10)
|
||||
|
||||
# Hex with suffix (e.g., "12345h")
|
||||
if addr.endswith('h'):
|
||||
return int(addr[:-1], 16)
|
||||
|
||||
# Hex with prefix (e.g., "0x12345")
|
||||
if addr.startswith('0x'):
|
||||
return int(addr, 16)
|
||||
|
||||
# Assume hex
|
||||
return int(addr, 16)
|
||||
|
||||
|
||||
def format_address(addr: int, style: str = "flat") -> str:
|
||||
"""Format a physical address in the specified style.
|
||||
|
||||
Args:
|
||||
addr: Physical address (20-bit)
|
||||
style: "flat" (default), "segoff", or "both"
|
||||
|
||||
Returns:
|
||||
Formatted address string
|
||||
|
||||
Examples:
|
||||
>>> format_address(0x10100, "flat")
|
||||
'10100'
|
||||
>>> format_address(0x10100, "segoff")
|
||||
'1010:0000'
|
||||
>>> format_address(0x10100, "both")
|
||||
'10100 (1010:0000)'
|
||||
"""
|
||||
if style == "segoff":
|
||||
# Convert to segment:offset (canonical form with offset < 16)
|
||||
segment = addr >> 4
|
||||
offset = addr & 0x0F
|
||||
return f'{segment:04x}:{offset:04x}'
|
||||
elif style == "both":
|
||||
segment = addr >> 4
|
||||
offset = addr & 0x0F
|
||||
return f'{addr:05x} ({segment:04x}:{offset:04x})'
|
||||
else: # flat
|
||||
return f'{addr:05x}'
|
||||
|
||||
|
||||
def calculate_checksum(data: str) -> str:
|
||||
"""Calculate GDB packet checksum.
|
||||
|
||||
The checksum is the sum of all characters modulo 256,
|
||||
returned as two hex digits.
|
||||
|
||||
Args:
|
||||
data: Packet data (without $ prefix or # suffix)
|
||||
|
||||
Returns:
|
||||
Two-character hex checksum
|
||||
"""
|
||||
total = sum(ord(c) for c in data)
|
||||
return f'{total & 0xFF:02x}'
|
||||
|
||||
|
||||
def encode_hex(data: bytes) -> str:
|
||||
"""Encode bytes to hex string for GDB protocol."""
|
||||
return data.hex()
|
||||
|
||||
|
||||
def decode_hex(hex_str: str) -> bytes:
|
||||
"""Decode hex string to bytes from GDB protocol."""
|
||||
return bytes.fromhex(hex_str)
|
||||
|
||||
|
||||
def escape_binary(data: bytes) -> bytes:
|
||||
"""Escape binary data for GDB protocol.
|
||||
|
||||
GDB uses escape character 0x7d ('}') followed by XOR'd byte.
|
||||
Characters that must be escaped: $, #, }, *
|
||||
"""
|
||||
result = bytearray()
|
||||
escape_chars = {0x24, 0x23, 0x7d, 0x2a} # $, #, }, *
|
||||
|
||||
for b in data:
|
||||
if b in escape_chars:
|
||||
result.append(0x7d)
|
||||
result.append(b ^ 0x20)
|
||||
else:
|
||||
result.append(b)
|
||||
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def unescape_binary(data: bytes) -> bytes:
|
||||
"""Unescape binary data from GDB protocol."""
|
||||
result = bytearray()
|
||||
i = 0
|
||||
while i < len(data):
|
||||
if data[i] == 0x7d and i + 1 < len(data):
|
||||
result.append(data[i + 1] ^ 0x20)
|
||||
i += 2
|
||||
else:
|
||||
result.append(data[i])
|
||||
i += 1
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def parse_stop_reply(response: str) -> tuple[str, dict]:
|
||||
"""Parse a GDB stop reply packet.
|
||||
|
||||
Stop replies start with S (signal), T (signal with info), or W (exit).
|
||||
|
||||
Returns:
|
||||
Tuple of (stop_type, info_dict)
|
||||
|
||||
Examples:
|
||||
"S05" -> ("signal", {"signal": 5})
|
||||
"T05thread:01;" -> ("signal", {"signal": 5, "thread": "01"})
|
||||
"W00" -> ("exit", {"code": 0})
|
||||
"""
|
||||
if not response:
|
||||
return ("unknown", {})
|
||||
|
||||
if response.startswith('S'):
|
||||
signal = int(response[1:3], 16)
|
||||
return ("signal", {"signal": signal})
|
||||
|
||||
if response.startswith('T'):
|
||||
signal = int(response[1:3], 16)
|
||||
info = {"signal": signal}
|
||||
# Parse additional key:value pairs
|
||||
pairs = response[3:].rstrip(';').split(';')
|
||||
for pair in pairs:
|
||||
if ':' in pair:
|
||||
key, value = pair.split(':', 1)
|
||||
info[key] = value
|
||||
return ("signal", info)
|
||||
|
||||
if response.startswith('W'):
|
||||
code = int(response[1:3], 16)
|
||||
return ("exit", {"code": code})
|
||||
|
||||
if response.startswith('X'):
|
||||
signal = int(response[1:3], 16)
|
||||
return ("terminated", {"signal": signal})
|
||||
|
||||
return ("unknown", {"raw": response})
|
||||
|
||||
|
||||
def hexdump(data: bytes, address: int = 0, width: int = 16) -> str:
|
||||
"""Format data as a hex dump.
|
||||
|
||||
Args:
|
||||
data: Bytes to dump
|
||||
address: Starting address for display
|
||||
width: Bytes per line
|
||||
|
||||
Returns:
|
||||
Formatted hex dump string
|
||||
"""
|
||||
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)
|
||||
# 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)
|
||||
|
||||
|
||||
def parse_registers_x86(hex_data: str) -> dict[str, int]:
|
||||
"""Parse GDB register dump for x86 (32-bit).
|
||||
|
||||
GDB returns registers in a specific order as concatenated hex values.
|
||||
For i386, the order is: EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI,
|
||||
EIP, EFLAGS, CS, SS, DS, ES, FS, GS.
|
||||
|
||||
Each 32-bit register is 8 hex characters (little-endian).
|
||||
Segment registers are 4 hex characters.
|
||||
"""
|
||||
# Remove any whitespace
|
||||
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]
|
||||
if len(chunk) < 8:
|
||||
return 0
|
||||
# GDB sends in target byte order (little-endian for x86)
|
||||
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]
|
||||
if len(chunk) < 4:
|
||||
return 0
|
||||
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']:
|
||||
regs[name] = read_le32(pos)
|
||||
pos += 8
|
||||
|
||||
# EIP and EFLAGS
|
||||
regs['eip'] = read_le32(pos)
|
||||
pos += 8
|
||||
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']:
|
||||
regs[name] = read_le32(pos) & 0xFFFF
|
||||
pos += 8
|
||||
|
||||
return regs
|
||||
|
||||
|
||||
# Signal numbers (Unix signals used by GDB protocol)
|
||||
SIGNALS = {
|
||||
0: "SIGNONE",
|
||||
1: "SIGHUP",
|
||||
2: "SIGINT",
|
||||
3: "SIGQUIT",
|
||||
4: "SIGILL",
|
||||
5: "SIGTRAP", # Breakpoint
|
||||
6: "SIGABRT",
|
||||
7: "SIGBUS",
|
||||
8: "SIGFPE",
|
||||
9: "SIGKILL",
|
||||
10: "SIGUSR1",
|
||||
11: "SIGSEGV",
|
||||
}
|
||||
|
||||
|
||||
def signal_name(num: int) -> str:
|
||||
"""Get the name of a signal number."""
|
||||
return SIGNALS.get(num, f"SIG{num}")
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for DOSBox-X MCP Server."""
|
||||
191
tests/test_types.py
Normal file
191
tests/test_types.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Tests for type definitions."""
|
||||
|
||||
import pytest
|
||||
|
||||
from dosbox_mcp.types import (
|
||||
Breakpoint,
|
||||
DisassemblyLine,
|
||||
DOSBoxStatus,
|
||||
MemoryRegion,
|
||||
Registers,
|
||||
StopEvent,
|
||||
StopReason,
|
||||
)
|
||||
|
||||
|
||||
class TestRegisters:
|
||||
"""Tests for Registers type."""
|
||||
|
||||
def test_16bit_aliases(self):
|
||||
"""Test 16-bit register aliases."""
|
||||
regs = Registers(eax=0x12345678, ebx=0xAABBCCDD)
|
||||
assert regs.ax == 0x5678
|
||||
assert regs.bx == 0xCCDD
|
||||
|
||||
def test_physical_address_calculation(self):
|
||||
"""Test segment:offset to physical address."""
|
||||
regs = Registers(cs=0x1000, eip=0x0100, ss=0x2000, esp=0x0200)
|
||||
|
||||
assert regs.cs_ip == 0x10100 # (0x1000 << 4) + 0x100
|
||||
assert regs.ss_sp == 0x20200 # (0x2000 << 4) + 0x200
|
||||
|
||||
def test_flag_checking(self):
|
||||
"""Test CPU flag checking."""
|
||||
# EFLAGS with zero flag (bit 6) and carry flag (bit 0) set
|
||||
regs = Registers(eflags=0x41) # bits 0 and 6
|
||||
|
||||
assert regs.flag_set('cf') is True
|
||||
assert regs.flag_set('carry') is True
|
||||
assert regs.flag_set('zf') is True
|
||||
assert regs.flag_set('zero') is True
|
||||
assert regs.flag_set('sf') is False
|
||||
assert regs.flag_set('sign') is False
|
||||
|
||||
def test_flag_unknown(self):
|
||||
"""Test unknown flag name."""
|
||||
regs = Registers()
|
||||
with pytest.raises(ValueError):
|
||||
regs.flag_set('unknown_flag')
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test dictionary serialization."""
|
||||
regs = Registers(eax=0x1234, cs=0x100, eip=0x200, eflags=0x41)
|
||||
d = regs.to_dict()
|
||||
|
||||
assert d['eax'] == '00001234'
|
||||
assert d['ax'] == '1234'
|
||||
assert d['cs'] == '0100'
|
||||
assert d['cs:ip'] == '0100:0200'
|
||||
assert d['flags']['carry'] is True
|
||||
assert d['flags']['zero'] is True
|
||||
|
||||
|
||||
class TestBreakpoint:
|
||||
"""Tests for Breakpoint type."""
|
||||
|
||||
def test_creation(self):
|
||||
"""Test breakpoint creation."""
|
||||
bp = Breakpoint(id=1, address=0x10100, original="1000:0100")
|
||||
assert bp.id == 1
|
||||
assert bp.address == 0x10100
|
||||
assert bp.enabled is True
|
||||
assert bp.hit_count == 0
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test dictionary serialization."""
|
||||
bp = Breakpoint(id=1, address=0x10100, hit_count=5, original="1000:0100")
|
||||
d = bp.to_dict()
|
||||
|
||||
assert d['id'] == 1
|
||||
assert d['address'] == '10100'
|
||||
assert d['hit_count'] == 5
|
||||
|
||||
|
||||
class TestStopEvent:
|
||||
"""Tests for StopEvent type."""
|
||||
|
||||
def test_breakpoint_event(self):
|
||||
"""Test breakpoint stop event."""
|
||||
event = StopEvent(
|
||||
reason=StopReason.BREAKPOINT,
|
||||
address=0x10100,
|
||||
signal=5,
|
||||
breakpoint_id=1
|
||||
)
|
||||
|
||||
d = event.to_dict()
|
||||
assert d['reason'] == 'breakpoint'
|
||||
assert d['address'] == '10100'
|
||||
assert d['breakpoint_id'] == 1
|
||||
|
||||
def test_step_event(self):
|
||||
"""Test step stop event."""
|
||||
event = StopEvent(reason=StopReason.STEP, address=0x10100)
|
||||
|
||||
d = event.to_dict()
|
||||
assert d['reason'] == 'step'
|
||||
|
||||
|
||||
class TestMemoryRegion:
|
||||
"""Tests for MemoryRegion type."""
|
||||
|
||||
def test_hex_format(self):
|
||||
"""Test hex format output."""
|
||||
mem = MemoryRegion(address=0x100, data=b"\x90\x90\xcc")
|
||||
assert mem.to_hex() == "9090cc"
|
||||
|
||||
def test_ascii_format(self):
|
||||
"""Test ASCII format output."""
|
||||
mem = MemoryRegion(address=0x100, data=b"Hello\x00World")
|
||||
assert mem.to_ascii() == "Hello.World"
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test dictionary serialization."""
|
||||
mem = MemoryRegion(address=0x100, data=b"AB")
|
||||
d = mem.to_dict(format="both")
|
||||
|
||||
assert d['address'] == '00100'
|
||||
assert d['length'] == 2
|
||||
assert d['hex'] == '4142'
|
||||
assert d['ascii'] == 'AB'
|
||||
|
||||
|
||||
class TestDisassemblyLine:
|
||||
"""Tests for DisassemblyLine type."""
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test dictionary serialization."""
|
||||
line = DisassemblyLine(
|
||||
address=0x10100,
|
||||
bytes_hex="90",
|
||||
mnemonic="NOP",
|
||||
operands=""
|
||||
)
|
||||
d = line.to_dict()
|
||||
|
||||
assert d['address'] == '10100'
|
||||
assert d['bytes'] == '90'
|
||||
assert d['instruction'] == 'NOP'
|
||||
|
||||
def test_instruction_with_operands(self):
|
||||
"""Test instruction with operands."""
|
||||
line = DisassemblyLine(
|
||||
address=0x10100,
|
||||
bytes_hex="b80100",
|
||||
mnemonic="MOV",
|
||||
operands="AX, 0001"
|
||||
)
|
||||
d = line.to_dict()
|
||||
|
||||
assert d['instruction'] == 'MOV AX, 0001'
|
||||
|
||||
|
||||
class TestDOSBoxStatus:
|
||||
"""Tests for DOSBoxStatus type."""
|
||||
|
||||
def test_default_status(self):
|
||||
"""Test default status values."""
|
||||
status = DOSBoxStatus()
|
||||
assert status.running is False
|
||||
assert status.connected is False
|
||||
assert status.breakpoints == []
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test dictionary serialization."""
|
||||
bp = Breakpoint(id=1, address=0x100)
|
||||
status = DOSBoxStatus(
|
||||
running=True,
|
||||
connected=True,
|
||||
host="localhost",
|
||||
port=1234,
|
||||
pid=12345,
|
||||
breakpoints=[bp]
|
||||
)
|
||||
d = status.to_dict()
|
||||
|
||||
assert d['running'] is True
|
||||
assert d['connected'] is True
|
||||
assert d['host'] == "localhost"
|
||||
assert d['port'] == 1234
|
||||
assert d['pid'] == 12345
|
||||
assert d['breakpoint_count'] == 1
|
||||
286
tests/test_utils.py
Normal file
286
tests/test_utils.py
Normal file
@ -0,0 +1,286 @@
|
||||
"""Tests for utility functions."""
|
||||
|
||||
import pytest
|
||||
|
||||
from dosbox_mcp.utils import (
|
||||
calculate_checksum,
|
||||
decode_hex,
|
||||
encode_hex,
|
||||
escape_binary,
|
||||
format_address,
|
||||
hexdump,
|
||||
parse_address,
|
||||
parse_registers_x86,
|
||||
parse_stop_reply,
|
||||
signal_name,
|
||||
unescape_binary,
|
||||
)
|
||||
|
||||
|
||||
class TestParseAddress:
|
||||
"""Tests for parse_address function."""
|
||||
|
||||
def test_segment_offset_format(self):
|
||||
"""Test segment:offset address parsing."""
|
||||
# Standard segment:offset
|
||||
assert parse_address("1000:0100") == 0x10100
|
||||
assert parse_address("F000:FFF0") == 0xFFFF0 # BIOS reset vector
|
||||
assert parse_address("0000:0000") == 0x00000
|
||||
|
||||
def test_segment_offset_lowercase(self):
|
||||
"""Test lowercase segment:offset."""
|
||||
assert parse_address("a000:0000") == 0xA0000 # Video memory
|
||||
|
||||
def test_flat_hex_with_prefix(self):
|
||||
"""Test 0x prefixed addresses."""
|
||||
assert parse_address("0x12345") == 0x12345
|
||||
assert parse_address("0xFFFF0") == 0xFFFF0
|
||||
|
||||
def test_flat_hex_with_suffix(self):
|
||||
"""Test h-suffixed addresses."""
|
||||
assert parse_address("12345h") == 0x12345
|
||||
assert parse_address("FFFF0h") == 0xFFFF0
|
||||
|
||||
def test_plain_hex(self):
|
||||
"""Test plain hex (assumed)."""
|
||||
assert parse_address("12345") == 0x12345
|
||||
assert parse_address("100") == 0x100
|
||||
|
||||
def test_decimal_format(self):
|
||||
"""Test decimal addresses with # prefix."""
|
||||
assert parse_address("#65536") == 65536
|
||||
assert parse_address("#1048576") == 1048576 # 1MB
|
||||
|
||||
def test_whitespace_handling(self):
|
||||
"""Test that whitespace is stripped."""
|
||||
assert parse_address(" 1000:0100 ") == 0x10100
|
||||
|
||||
def test_invalid_segment_offset(self):
|
||||
"""Test invalid segment:offset format."""
|
||||
with pytest.raises(ValueError):
|
||||
parse_address("1000:2000:3000")
|
||||
|
||||
|
||||
class TestFormatAddress:
|
||||
"""Tests for format_address function."""
|
||||
|
||||
def test_flat_format(self):
|
||||
"""Test flat hex format."""
|
||||
assert format_address(0x10100, "flat") == "10100"
|
||||
assert format_address(0x00100, "flat") == "00100"
|
||||
|
||||
def test_segoff_format(self):
|
||||
"""Test segment:offset format."""
|
||||
# Note: This uses canonical form with minimal offset
|
||||
result = format_address(0x10100, "segoff")
|
||||
assert ":" in result
|
||||
|
||||
def test_both_format(self):
|
||||
"""Test combined format."""
|
||||
result = format_address(0x10100, "both")
|
||||
assert "10100" in result
|
||||
assert ":" in result
|
||||
|
||||
|
||||
class TestChecksum:
|
||||
"""Tests for GDB checksum calculation."""
|
||||
|
||||
def test_simple_checksum(self):
|
||||
"""Test checksum of simple strings."""
|
||||
# 'g' = 0x67 = 103
|
||||
assert calculate_checksum("g") == "67"
|
||||
|
||||
def test_command_checksum(self):
|
||||
"""Test checksum of actual GDB commands."""
|
||||
# "?" = 0x3F = 63
|
||||
assert calculate_checksum("?") == "3f"
|
||||
|
||||
# "c" = 0x63 = 99
|
||||
assert calculate_checksum("c") == "63"
|
||||
|
||||
# "s" = 0x73 = 115
|
||||
assert calculate_checksum("s") == "73"
|
||||
|
||||
def test_checksum_wrapping(self):
|
||||
"""Test checksum modulo 256."""
|
||||
# Create string that wraps
|
||||
long_str = "A" * 300 # 65 * 300 = 19500, mod 256 = 60 = 0x3c
|
||||
result = calculate_checksum(long_str)
|
||||
expected = (65 * 300) % 256
|
||||
assert result == f"{expected:02x}"
|
||||
|
||||
|
||||
class TestHexEncoding:
|
||||
"""Tests for hex encoding/decoding."""
|
||||
|
||||
def test_encode_hex(self):
|
||||
"""Test bytes to hex encoding."""
|
||||
assert encode_hex(b"\x00\x01\x02") == "000102"
|
||||
assert encode_hex(b"ABC") == "414243"
|
||||
|
||||
def test_decode_hex(self):
|
||||
"""Test hex to bytes decoding."""
|
||||
assert decode_hex("000102") == b"\x00\x01\x02"
|
||||
assert decode_hex("414243") == b"ABC"
|
||||
|
||||
def test_roundtrip(self):
|
||||
"""Test encode/decode roundtrip."""
|
||||
original = b"\x90\x90\xcc\xcd\x21" # NOP NOP INT3 INT 21
|
||||
assert decode_hex(encode_hex(original)) == original
|
||||
|
||||
|
||||
class TestBinaryEscaping:
|
||||
"""Tests for GDB binary escaping."""
|
||||
|
||||
def test_escape_special_chars(self):
|
||||
"""Test that special characters are escaped."""
|
||||
# $ (0x24), # (0x23), } (0x7d), * (0x2a)
|
||||
data = bytes([0x24, 0x23, 0x7d, 0x2a])
|
||||
escaped = escape_binary(data)
|
||||
|
||||
# Each byte should become 0x7d followed by XOR with 0x20
|
||||
assert escaped == bytes([
|
||||
0x7d, 0x24 ^ 0x20, # $
|
||||
0x7d, 0x23 ^ 0x20, # #
|
||||
0x7d, 0x7d ^ 0x20, # }
|
||||
0x7d, 0x2a ^ 0x20, # *
|
||||
])
|
||||
|
||||
def test_escape_normal_chars(self):
|
||||
"""Test that normal characters are not escaped."""
|
||||
data = b"ABC123"
|
||||
assert escape_binary(data) == data
|
||||
|
||||
def test_unescape(self):
|
||||
"""Test unescaping."""
|
||||
escaped = bytes([0x7d, 0x04]) # Escaped 0x24 ($)
|
||||
assert unescape_binary(escaped) == bytes([0x24])
|
||||
|
||||
def test_escape_unescape_roundtrip(self):
|
||||
"""Test escape/unescape roundtrip."""
|
||||
original = bytes([0x24, 0x23, 0x7d, 0x2a, 0x41, 0x42])
|
||||
assert unescape_binary(escape_binary(original)) == original
|
||||
|
||||
|
||||
class TestParseStopReply:
|
||||
"""Tests for parsing GDB stop replies."""
|
||||
|
||||
def test_signal_reply(self):
|
||||
"""Test simple signal reply."""
|
||||
stop_type, info = parse_stop_reply("S05")
|
||||
assert stop_type == "signal"
|
||||
assert info["signal"] == 5 # SIGTRAP
|
||||
|
||||
def test_signal_with_info(self):
|
||||
"""Test signal reply with additional info."""
|
||||
stop_type, info = parse_stop_reply("T05thread:01;")
|
||||
assert stop_type == "signal"
|
||||
assert info["signal"] == 5
|
||||
assert info["thread"] == "01"
|
||||
|
||||
def test_exit_reply(self):
|
||||
"""Test exit reply."""
|
||||
stop_type, info = parse_stop_reply("W00")
|
||||
assert stop_type == "exit"
|
||||
assert info["code"] == 0
|
||||
|
||||
def test_terminated_reply(self):
|
||||
"""Test terminated by signal."""
|
||||
stop_type, info = parse_stop_reply("X09")
|
||||
assert stop_type == "terminated"
|
||||
assert info["signal"] == 9 # SIGKILL
|
||||
|
||||
def test_empty_reply(self):
|
||||
"""Test empty reply."""
|
||||
stop_type, info = parse_stop_reply("")
|
||||
assert stop_type == "unknown"
|
||||
|
||||
def test_unknown_reply(self):
|
||||
"""Test unknown reply format."""
|
||||
stop_type, info = parse_stop_reply("QQQ")
|
||||
assert stop_type == "unknown"
|
||||
assert "raw" in info
|
||||
|
||||
|
||||
class TestParseRegisters:
|
||||
"""Tests for parsing x86 register dump."""
|
||||
|
||||
def test_parse_registers(self):
|
||||
"""Test parsing register hex dump."""
|
||||
# Create a mock register dump
|
||||
# EAX=12345678, ECX=0, EDX=0, EBX=0, ESP=0, EBP=0, ESI=0, EDI=0
|
||||
# EIP=00001000, EFLAGS=00000202
|
||||
# CS=0100, SS=0200, DS=0300, ES=0400, FS=0, GS=0
|
||||
|
||||
# Little-endian hex for each register
|
||||
hex_data = (
|
||||
"78563412" # EAX = 0x12345678
|
||||
"00000000" # ECX = 0
|
||||
"00000000" # EDX = 0
|
||||
"00000000" # EBX = 0
|
||||
"00100000" # ESP = 0x1000
|
||||
"00000000" # EBP = 0
|
||||
"00000000" # ESI = 0
|
||||
"00000000" # EDI = 0
|
||||
"00100000" # EIP = 0x1000
|
||||
"02020000" # EFLAGS = 0x202
|
||||
"00010000" # CS = 0x100
|
||||
"00020000" # SS = 0x200
|
||||
"00030000" # DS = 0x300
|
||||
"00040000" # ES = 0x400
|
||||
"00000000" # FS = 0
|
||||
"00000000" # GS = 0
|
||||
)
|
||||
|
||||
regs = parse_registers_x86(hex_data)
|
||||
|
||||
assert regs["eax"] == 0x12345678
|
||||
assert regs["ecx"] == 0
|
||||
assert regs["esp"] == 0x1000
|
||||
assert regs["eip"] == 0x1000
|
||||
assert regs["eflags"] == 0x202
|
||||
assert regs["cs"] == 0x100
|
||||
assert regs["ds"] == 0x300
|
||||
|
||||
|
||||
class TestSignalNames:
|
||||
"""Tests for signal name lookup."""
|
||||
|
||||
def test_known_signals(self):
|
||||
"""Test known signal names."""
|
||||
assert signal_name(5) == "SIGTRAP"
|
||||
assert signal_name(11) == "SIGSEGV"
|
||||
assert signal_name(9) == "SIGKILL"
|
||||
|
||||
def test_unknown_signal(self):
|
||||
"""Test unknown signal."""
|
||||
assert signal_name(99) == "SIG99"
|
||||
|
||||
|
||||
class TestHexdump:
|
||||
"""Tests for hexdump formatting."""
|
||||
|
||||
def test_simple_hexdump(self):
|
||||
"""Test basic hexdump output."""
|
||||
data = b"Hello, World!"
|
||||
dump = hexdump(data, address=0x100)
|
||||
|
||||
assert "00100" in dump
|
||||
assert "48 65 6c 6c" in dump # "Hell"
|
||||
assert "|Hello, World!|" in dump
|
||||
|
||||
def test_hexdump_with_unprintable(self):
|
||||
"""Test hexdump with unprintable characters."""
|
||||
data = b"\x00\x01\x02ABC\xff"
|
||||
dump = hexdump(data, address=0)
|
||||
|
||||
assert "00 01 02" in dump
|
||||
assert "|...ABC.|" in dump
|
||||
|
||||
def test_hexdump_multiline(self):
|
||||
"""Test multiline hexdump."""
|
||||
data = bytes(range(32))
|
||||
dump = hexdump(data, width=16)
|
||||
|
||||
lines = dump.strip().split('\n')
|
||||
assert len(lines) == 2
|
||||
Loading…
x
Reference in New Issue
Block a user