dbxdebug 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dbxdebug/keyboard.py ADDED
@@ -0,0 +1,205 @@
1
+ """
2
+ Keyboard input helpers for QMP protocol.
3
+
4
+ Provides convenient functions for building key sequences and common
5
+ keyboard operations using the QMP protocol.
6
+ """
7
+
8
+ from .dbx_kbd import DBX_KEY, DBX_KEY_TO_QCODE
9
+
10
+
11
+ def get_qcode(key: DBX_KEY) -> str:
12
+ """
13
+ Get QMP qcode for a DBX_KEY.
14
+
15
+ Args:
16
+ key: DOSBox-X key code
17
+
18
+ Returns:
19
+ QMP qcode string
20
+
21
+ Raises:
22
+ ValueError: If key has no QMP mapping
23
+ """
24
+ qcode = DBX_KEY_TO_QCODE.get(key)
25
+ if qcode is None:
26
+ raise ValueError(f"No QMP mapping for key: {key.name}")
27
+ return qcode
28
+
29
+
30
+ def key_list(*keys: DBX_KEY) -> list[str]:
31
+ """
32
+ Convert DBX_KEY values to QMP qcode list.
33
+
34
+ Args:
35
+ *keys: DBX_KEY values
36
+
37
+ Returns:
38
+ List of QMP qcode strings for use with QMPClient.send_key()
39
+
40
+ Example:
41
+ >>> key_list(DBX_KEY.KBD_leftctrl, DBX_KEY.KBD_c)
42
+ ['ctrl', 'c']
43
+ """
44
+ return [get_qcode(k) for k in keys]
45
+
46
+
47
+ def ctrl_key(key: DBX_KEY) -> list[str]:
48
+ """
49
+ Build Ctrl+key combination.
50
+
51
+ Args:
52
+ key: The key to combine with Ctrl
53
+
54
+ Returns:
55
+ List of qcodes for QMPClient.send_key()
56
+
57
+ Example:
58
+ >>> ctrl_key(DBX_KEY.KBD_c)
59
+ ['ctrl', 'c']
60
+ """
61
+ return ["ctrl", get_qcode(key)]
62
+
63
+
64
+ def alt_key(key: DBX_KEY) -> list[str]:
65
+ """
66
+ Build Alt+key combination.
67
+
68
+ Args:
69
+ key: The key to combine with Alt
70
+
71
+ Returns:
72
+ List of qcodes for QMPClient.send_key()
73
+
74
+ Example:
75
+ >>> alt_key(DBX_KEY.KBD_f4)
76
+ ['alt', 'f4']
77
+ """
78
+ return ["alt", get_qcode(key)]
79
+
80
+
81
+ def shift_key(key: DBX_KEY) -> list[str]:
82
+ """
83
+ Build Shift+key combination.
84
+
85
+ Args:
86
+ key: The key to combine with Shift
87
+
88
+ Returns:
89
+ List of qcodes for QMPClient.send_key()
90
+ """
91
+ return ["shift", get_qcode(key)]
92
+
93
+
94
+ def ctrl_alt_key(key: DBX_KEY) -> list[str]:
95
+ """
96
+ Build Ctrl+Alt+key combination.
97
+
98
+ Args:
99
+ key: The key to combine with Ctrl+Alt
100
+
101
+ Returns:
102
+ List of qcodes for QMPClient.send_key()
103
+
104
+ Example:
105
+ >>> ctrl_alt_key(DBX_KEY.KBD_delete)
106
+ ['ctrl', 'alt', 'delete']
107
+ """
108
+ return ["ctrl", "alt", get_qcode(key)]
109
+
110
+
111
+ def ctrl_shift_key(key: DBX_KEY) -> list[str]:
112
+ """
113
+ Build Ctrl+Shift+key combination.
114
+
115
+ Args:
116
+ key: The key to combine with Ctrl+Shift
117
+
118
+ Returns:
119
+ List of qcodes for QMPClient.send_key()
120
+ """
121
+ return ["ctrl", "shift", get_qcode(key)]
122
+
123
+
124
+ # Convenience constants for common keys
125
+ ENTER = ["ret"]
126
+ ESCAPE = ["esc"]
127
+ TAB = ["tab"]
128
+ BACKSPACE = ["backspace"]
129
+ SPACE = ["spc"]
130
+ DELETE = ["delete"]
131
+ INSERT = ["insert"]
132
+ HOME = ["home"]
133
+ END = ["end"]
134
+ PAGE_UP = ["pgup"]
135
+ PAGE_DOWN = ["pgdn"]
136
+ UP = ["up"]
137
+ DOWN = ["down"]
138
+ LEFT = ["left"]
139
+ RIGHT = ["right"]
140
+
141
+ # Common key combinations
142
+ CTRL_C = ["ctrl", "c"]
143
+ CTRL_V = ["ctrl", "v"]
144
+ CTRL_X = ["ctrl", "x"]
145
+ CTRL_Z = ["ctrl", "z"]
146
+ CTRL_A = ["ctrl", "a"]
147
+ CTRL_S = ["ctrl", "s"]
148
+ CTRL_ALT_DEL = ["ctrl", "alt", "delete"]
149
+ ALT_F4 = ["alt", "f4"]
150
+ ALT_TAB = ["alt", "tab"]
151
+
152
+
153
+ def function_key(n: int) -> list[str]:
154
+ """
155
+ Get function key qcode.
156
+
157
+ Args:
158
+ n: Function key number (1-24)
159
+
160
+ Returns:
161
+ List with single qcode for QMPClient.send_key()
162
+
163
+ Example:
164
+ >>> function_key(1)
165
+ ['f1']
166
+ """
167
+ if not 1 <= n <= 24:
168
+ raise ValueError(f"Function key must be 1-24, got {n}")
169
+ return [f"f{n}"]
170
+
171
+
172
+ def digit_key(n: int) -> list[str]:
173
+ """
174
+ Get digit key qcode.
175
+
176
+ Args:
177
+ n: Digit (0-9)
178
+
179
+ Returns:
180
+ List with single qcode for QMPClient.send_key()
181
+
182
+ Example:
183
+ >>> digit_key(5)
184
+ ['5']
185
+ """
186
+ if not 0 <= n <= 9:
187
+ raise ValueError(f"Digit must be 0-9, got {n}")
188
+ return [str(n)]
189
+
190
+
191
+ def number_keys(num: int) -> list[list[str]]:
192
+ """
193
+ Get key sequences to type a number.
194
+
195
+ Args:
196
+ num: Number to type
197
+
198
+ Returns:
199
+ List of key lists, each to be passed to QMPClient.send_key()
200
+
201
+ Example:
202
+ >>> number_keys(42)
203
+ [['4'], ['2']]
204
+ """
205
+ return [[d] for d in str(num)]
dbxdebug/qmp.py ADDED
@@ -0,0 +1,239 @@
1
+ """
2
+ QEMU Monitor Protocol (QMP) client for DOSBox-X keyboard input.
3
+
4
+ Provides keyboard input injection via the QMP protocol:
5
+ - send_key(): Press and release keys with timing control
6
+ - key_down()/key_up(): Explicit press/release control
7
+ - type_text(): Type a string of characters
8
+ """
9
+
10
+ import json
11
+ import socket
12
+ import time
13
+
14
+ from loguru import logger
15
+
16
+ from .dbx_kbd import DBX_KEY, DBX_KEY_TO_QCODE, char_needs_shift, char_to_qcode
17
+
18
+
19
+ class QMPError(Exception):
20
+ """QMP protocol error."""
21
+
22
+ pass
23
+
24
+
25
+ class QMPClient:
26
+ """QEMU Monitor Protocol client for DOSBox-X keyboard input."""
27
+
28
+ DEFAULT_PORT = 4444
29
+
30
+ def __init__(self, host: str = "localhost", port: int = DEFAULT_PORT):
31
+ """
32
+ Connect to DOSBox-X QMP server.
33
+
34
+ Args:
35
+ host: Server hostname
36
+ port: Server port (default 4444)
37
+ """
38
+ logger.debug(f"Connecting to QMP server at {host}:{port}")
39
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
40
+ self.sock.connect((host, port))
41
+ self.buffer = ""
42
+
43
+ # Read greeting
44
+ greeting = self._read_message()
45
+ logger.debug(f"QMP greeting: {greeting}")
46
+
47
+ if "QMP" not in greeting:
48
+ raise QMPError(f"Unexpected QMP greeting: {greeting}")
49
+
50
+ # Send capabilities negotiation
51
+ self._send_command("qmp_capabilities")
52
+ logger.debug("QMP capabilities negotiated")
53
+
54
+ def _send_raw(self, data: str) -> None:
55
+ """Send raw string to server."""
56
+ if self.sock is None:
57
+ raise ConnectionError("Socket not initialized")
58
+ self.sock.sendall((data + "\r\n").encode())
59
+
60
+ def _read_message(self) -> dict:
61
+ """Read a JSON message from the server."""
62
+ if self.sock is None:
63
+ raise ConnectionError("Socket not initialized")
64
+
65
+ while True:
66
+ # Look for complete JSON object in buffer
67
+ if self.buffer:
68
+ try:
69
+ # Try to parse what we have
70
+ msg = json.loads(self.buffer.strip())
71
+ self.buffer = ""
72
+ return msg
73
+ except json.JSONDecodeError:
74
+ pass
75
+
76
+ # Need more data
77
+ data = self.sock.recv(4096).decode()
78
+ if not data:
79
+ raise ConnectionError("Connection closed")
80
+ self.buffer += data
81
+
82
+ # Try to find complete message (newline-delimited)
83
+ if "\n" in self.buffer:
84
+ line, self.buffer = self.buffer.split("\n", 1)
85
+ if line.strip():
86
+ return json.loads(line.strip())
87
+
88
+ def _send_command(self, execute: str, arguments: dict | None = None) -> dict:
89
+ """
90
+ Send a QMP command and return the response.
91
+
92
+ Args:
93
+ execute: Command name
94
+ arguments: Optional command arguments
95
+
96
+ Returns:
97
+ Response dict
98
+
99
+ Raises:
100
+ QMPError: If command returns an error
101
+ """
102
+ cmd: dict = {"execute": execute}
103
+ if arguments:
104
+ cmd["arguments"] = arguments
105
+
106
+ self._send_raw(json.dumps(cmd))
107
+ response = self._read_message()
108
+
109
+ if "error" in response:
110
+ error = response["error"]
111
+ raise QMPError(f"{error.get('class', 'Error')}: {error.get('desc', 'Unknown error')}")
112
+
113
+ return response
114
+
115
+ def send_key(self, keys: list[str], hold_time: int = 100) -> None:
116
+ """
117
+ Send simultaneous key presses with auto-release.
118
+
119
+ All keys are pressed, held for hold_time ms, then released in reverse order.
120
+
121
+ Args:
122
+ keys: List of QMP qcode strings (e.g., ["ctrl", "alt", "delete"])
123
+ hold_time: Milliseconds to hold before releasing (default 100)
124
+
125
+ Example:
126
+ >>> qmp.send_key(["ctrl", "c"]) # Ctrl+C
127
+ >>> qmp.send_key(["a"]) # Press 'a'
128
+ """
129
+ key_objects = [{"type": "qcode", "data": k} for k in keys]
130
+ self._send_command("send-key", {"keys": key_objects, "hold-time": hold_time})
131
+
132
+ def send_key_dbx(self, keys: list[DBX_KEY], hold_time: int = 100) -> None:
133
+ """
134
+ Send keys using DBX_KEY enum values.
135
+
136
+ Args:
137
+ keys: List of DBX_KEY values
138
+ hold_time: Milliseconds to hold before releasing
139
+
140
+ Example:
141
+ >>> qmp.send_key_dbx([DBX_KEY.KBD_leftctrl, DBX_KEY.KBD_c])
142
+ """
143
+ qcodes = []
144
+ for key in keys:
145
+ qcode = DBX_KEY_TO_QCODE.get(key)
146
+ if qcode is None:
147
+ raise ValueError(f"No QMP mapping for key: {key.name}")
148
+ qcodes.append(qcode)
149
+ self.send_key(qcodes, hold_time)
150
+
151
+ def key_down(self, key: str) -> None:
152
+ """
153
+ Send a key press (down) event.
154
+
155
+ Args:
156
+ key: QMP qcode string
157
+ """
158
+ event = {
159
+ "type": "key",
160
+ "data": {"down": True, "key": {"type": "qcode", "data": key}},
161
+ }
162
+ self._send_command("input-send-event", {"events": [event]})
163
+
164
+ def key_up(self, key: str) -> None:
165
+ """
166
+ Send a key release (up) event.
167
+
168
+ Args:
169
+ key: QMP qcode string
170
+ """
171
+ event = {
172
+ "type": "key",
173
+ "data": {"down": False, "key": {"type": "qcode", "data": key}},
174
+ }
175
+ self._send_command("input-send-event", {"events": [event]})
176
+
177
+ def key_press(self, key: str, hold_time: float = 0.05) -> None:
178
+ """
179
+ Press and release a single key with timing control.
180
+
181
+ Args:
182
+ key: QMP qcode string
183
+ hold_time: Seconds to hold (default 0.05)
184
+ """
185
+ self.key_down(key)
186
+ time.sleep(hold_time)
187
+ self.key_up(key)
188
+
189
+ def type_text(self, text: str, delay: float = 0.05) -> None:
190
+ """
191
+ Type a string of text.
192
+
193
+ Handles shift for uppercase and special characters.
194
+
195
+ Args:
196
+ text: Text to type
197
+ delay: Delay between characters in seconds (default 0.05)
198
+
199
+ Example:
200
+ >>> qmp.type_text("Hello World!")
201
+ """
202
+ for char in text:
203
+ qcode = char_to_qcode(char)
204
+ if qcode is None:
205
+ logger.warning(f"Cannot type character: {repr(char)}")
206
+ continue
207
+
208
+ if char_needs_shift(char):
209
+ # Hold shift, press key, release key, release shift
210
+ self.key_down("shift")
211
+ time.sleep(0.01)
212
+ self.key_press(qcode, 0.03)
213
+ self.key_up("shift")
214
+ else:
215
+ self.key_press(qcode, 0.03)
216
+
217
+ time.sleep(delay)
218
+
219
+ def query_commands(self) -> list[str]:
220
+ """
221
+ Query available QMP commands.
222
+
223
+ Returns:
224
+ List of command names
225
+ """
226
+ response = self._send_command("query-commands")
227
+ return [cmd["name"] for cmd in response.get("return", [])]
228
+
229
+ def close(self) -> None:
230
+ """Close the connection."""
231
+ if self.sock:
232
+ self.sock.close()
233
+ self.sock = None # type: ignore
234
+
235
+ def __enter__(self) -> "QMPClient":
236
+ return self
237
+
238
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
239
+ self.close()
dbxdebug/utils.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ Utility functions for address parsing and hex dumps.
3
+ """
4
+
5
+ import re
6
+
7
+
8
+ def parse_x86_address(address_str: str | int) -> int:
9
+ """
10
+ Parse an x86 address and convert to linear address.
11
+
12
+ Supports:
13
+ - Segmented addresses: "XXXX:YYYY" or "0xXXXX:YYYY"
14
+ - Linear hex: "0x12345" or "12345"
15
+ - Integer passthrough
16
+
17
+ Args:
18
+ address_str: Address as string or int
19
+
20
+ Returns:
21
+ Linear address as integer
22
+
23
+ Raises:
24
+ ValueError: If address format is invalid
25
+ """
26
+ if isinstance(address_str, int):
27
+ return address_str
28
+
29
+ # Check if it's a segmented address
30
+ segment_match = re.match(r"(?:0x)?([0-9a-fA-F]+):([0-9a-fA-F]+)", address_str)
31
+ if segment_match:
32
+ segment = int(segment_match.group(1), 16)
33
+ offset = int(segment_match.group(2), 16)
34
+ # Linear address: segment * 16 + offset
35
+ return (segment << 4) + offset
36
+
37
+ # Try to interpret as a normal hex or decimal number
38
+ try:
39
+ return int(address_str, 0)
40
+ except ValueError as e:
41
+ raise ValueError(
42
+ f"Invalid address format: {address_str}. "
43
+ "Use segment:offset (e.g., b800:0000) or linear address."
44
+ ) from e
45
+
46
+
47
+ def hexdump(
48
+ src: bytes,
49
+ bytes_per_line: int = 32,
50
+ bytes_per_group: int = 4,
51
+ sep: str = ".",
52
+ start_addr: int = 0,
53
+ ) -> list[str]:
54
+ """
55
+ Generate a hex dump of binary data.
56
+
57
+ Args:
58
+ src: Binary data to dump
59
+ bytes_per_line: Number of bytes per line
60
+ bytes_per_group: Group bytes with spaces
61
+ sep: Character for non-printable bytes
62
+ start_addr: Starting address for display
63
+
64
+ Returns:
65
+ List of formatted hex dump lines
66
+ """
67
+ FILTER = "".join([(len(repr(chr(x))) == 3) and chr(x) or sep for x in range(256)])
68
+ lines = []
69
+ max_addr_len = max(8, len(hex(start_addr + len(src))))
70
+
71
+ for offset in range(0, len(src), bytes_per_line):
72
+ addr = start_addr + offset
73
+ chars = src[offset : offset + bytes_per_line]
74
+
75
+ # Create hex string
76
+ hex_parts = []
77
+ for i in range(0, len(chars), bytes_per_group):
78
+ group = chars[i : i + bytes_per_group]
79
+ hex_parts.append("".join(f"{b:02X}" for b in group))
80
+ hex_string = " ".join(hex_parts)
81
+
82
+ # Pad to align with full lines
83
+ full_groups = bytes_per_line // bytes_per_group
84
+ expected_len = full_groups * (bytes_per_group * 2) + (full_groups - 1)
85
+ hex_string = hex_string.ljust(expected_len)
86
+
87
+ # Create printable string
88
+ printable = "".join((x <= 127 and FILTER[x]) or sep for x in chars)
89
+
90
+ lines.append(f"{addr:0{max_addr_len}X} {hex_string} |{printable}|")
91
+
92
+ return lines