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/__init__.py +134 -0
- dbxdebug/capture_io.py +210 -0
- dbxdebug/cli.py +618 -0
- dbxdebug/dbx_kbd.py +392 -0
- dbxdebug/gdb.py +297 -0
- dbxdebug/html.py +559 -0
- dbxdebug/keyboard.py +205 -0
- dbxdebug/qmp.py +239 -0
- dbxdebug/utils.py +92 -0
- dbxdebug/video.py +239 -0
- dbxdebug-0.2.1.dist-info/METADATA +164 -0
- dbxdebug-0.2.1.dist-info/RECORD +14 -0
- dbxdebug-0.2.1.dist-info/WHEEL +4 -0
- dbxdebug-0.2.1.dist-info/entry_points.txt +2 -0
dbxdebug/cli.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for DOSBox-X remote debugging.
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
dbxdebug mem <command> - Memory operations (via GDB)
|
|
6
|
+
dbxdebug cpu <command> - CPU state and execution control (via GDB)
|
|
7
|
+
dbxdebug key <command> - Keyboard input (via QMP)
|
|
8
|
+
dbxdebug screen <command> - Screen capture and recording
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
from loguru import logger
|
|
16
|
+
|
|
17
|
+
from .capture_io import ScreenRecorder, load_capture
|
|
18
|
+
from .gdb import GDBClient
|
|
19
|
+
from .html import analyze_dos_video_colors, dos_video_to_html
|
|
20
|
+
from .qmp import QMPClient, QMPError
|
|
21
|
+
from .utils import hexdump, parse_x86_address
|
|
22
|
+
from .video import DOSVideoTools
|
|
23
|
+
|
|
24
|
+
# Configure loguru
|
|
25
|
+
logger.remove()
|
|
26
|
+
logger.add(sys.stderr, level="WARNING")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.group()
|
|
30
|
+
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output")
|
|
31
|
+
@click.option("--debug", is_flag=True, help="Enable debug logging")
|
|
32
|
+
@click.version_option(version="0.1.0")
|
|
33
|
+
def main(verbose: bool, debug: bool):
|
|
34
|
+
"""DOSBox-X remote debug client.
|
|
35
|
+
|
|
36
|
+
Connects to DOSBox-X GDB server (port 2159) for debugging and memory access,
|
|
37
|
+
and QMP server (port 4444) for keyboard input.
|
|
38
|
+
"""
|
|
39
|
+
if debug:
|
|
40
|
+
logger.remove()
|
|
41
|
+
logger.add(sys.stderr, level="DEBUG")
|
|
42
|
+
elif verbose:
|
|
43
|
+
logger.remove()
|
|
44
|
+
logger.add(sys.stderr, level="INFO")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# =============================================================================
|
|
48
|
+
# Memory Commands (GDB)
|
|
49
|
+
# =============================================================================
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@main.group()
|
|
53
|
+
@click.option("--host", default="localhost", help="GDB server hostname")
|
|
54
|
+
@click.option("--port", default=2159, help="GDB server port (default: 2159)")
|
|
55
|
+
@click.pass_context
|
|
56
|
+
def mem(ctx, host: str, port: int):
|
|
57
|
+
"""Memory read/write operations via GDB protocol."""
|
|
58
|
+
ctx.ensure_object(dict)
|
|
59
|
+
ctx.obj["gdb_host"] = host
|
|
60
|
+
ctx.obj["gdb_port"] = port
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@mem.command("read")
|
|
64
|
+
@click.argument("address", metavar="ADDRESS")
|
|
65
|
+
@click.argument("length", type=int, metavar="LENGTH")
|
|
66
|
+
@click.option("--hex", "output_hex", is_flag=True, help="Format output as hex dump")
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def mem_read(ctx, address: str, length: int, output_hex: bool):
|
|
69
|
+
"""Read LENGTH bytes from ADDRESS.
|
|
70
|
+
|
|
71
|
+
ADDRESS can be segment:offset (e.g., b800:0000) or linear (e.g., 0xb8000).
|
|
72
|
+
|
|
73
|
+
Examples:
|
|
74
|
+
dbxdebug mem read b800:0000 4000 --hex
|
|
75
|
+
dbxdebug mem read 0x1000 256
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
|
|
79
|
+
data = gdb.read_memory(address, length)
|
|
80
|
+
if output_hex:
|
|
81
|
+
linear = parse_x86_address(address)
|
|
82
|
+
for line in hexdump(data, start_addr=linear):
|
|
83
|
+
click.echo(line)
|
|
84
|
+
else:
|
|
85
|
+
sys.stdout.buffer.write(data)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
click.echo(f"Error: {e}", err=True)
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@mem.command("write")
|
|
92
|
+
@click.argument("address", metavar="ADDRESS")
|
|
93
|
+
@click.argument("hexdata", metavar="HEXDATA")
|
|
94
|
+
@click.pass_context
|
|
95
|
+
def mem_write(ctx, address: str, hexdata: str):
|
|
96
|
+
"""Write HEXDATA bytes to ADDRESS.
|
|
97
|
+
|
|
98
|
+
HEXDATA is a hex string without spaces (e.g., 'deadbeef').
|
|
99
|
+
|
|
100
|
+
Examples:
|
|
101
|
+
dbxdebug mem write 0x1000 90909090
|
|
102
|
+
dbxdebug mem write b800:0000 4142
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
|
|
106
|
+
data = bytes.fromhex(hexdata)
|
|
107
|
+
gdb.write_memory(address, data)
|
|
108
|
+
click.echo(f"Wrote {len(data)} bytes to {address}")
|
|
109
|
+
except Exception as e:
|
|
110
|
+
click.echo(f"Error: {e}", err=True)
|
|
111
|
+
sys.exit(1)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# =============================================================================
|
|
115
|
+
# CPU Commands (GDB)
|
|
116
|
+
# =============================================================================
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@main.group()
|
|
120
|
+
@click.option("--host", default="localhost", help="GDB server hostname")
|
|
121
|
+
@click.option("--port", default=2159, help="GDB server port (default: 2159)")
|
|
122
|
+
@click.pass_context
|
|
123
|
+
def cpu(ctx, host: str, port: int):
|
|
124
|
+
"""CPU registers and execution control via GDB protocol."""
|
|
125
|
+
ctx.ensure_object(dict)
|
|
126
|
+
ctx.obj["gdb_host"] = host
|
|
127
|
+
ctx.obj["gdb_port"] = port
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@cpu.command("regs")
|
|
131
|
+
@click.pass_context
|
|
132
|
+
def cpu_regs(ctx):
|
|
133
|
+
"""Display all CPU registers."""
|
|
134
|
+
try:
|
|
135
|
+
with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
|
|
136
|
+
regs = gdb.read_registers()
|
|
137
|
+
click.echo("General Purpose:")
|
|
138
|
+
click.echo(f" EAX={regs['eax']:08X} ECX={regs['ecx']:08X}")
|
|
139
|
+
click.echo(f" EDX={regs['edx']:08X} EBX={regs['ebx']:08X}")
|
|
140
|
+
click.echo(f" ESP={regs['esp']:08X} EBP={regs['ebp']:08X}")
|
|
141
|
+
click.echo(f" ESI={regs['esi']:08X} EDI={regs['edi']:08X}")
|
|
142
|
+
click.echo()
|
|
143
|
+
click.echo("Instruction Pointer:")
|
|
144
|
+
click.echo(f" EIP={regs['eip']:08X} EFLAGS={regs['eflags']:08X}")
|
|
145
|
+
click.echo()
|
|
146
|
+
click.echo("Segment Registers:")
|
|
147
|
+
click.echo(f" CS={regs['cs']:04X} SS={regs['ss']:04X} DS={regs['ds']:04X}")
|
|
148
|
+
click.echo(f" ES={regs['es']:04X} FS={regs['fs']:04X} GS={regs['gs']:04X}")
|
|
149
|
+
except Exception as e:
|
|
150
|
+
click.echo(f"Error: {e}", err=True)
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@cpu.command("break")
|
|
155
|
+
@click.argument("address", metavar="ADDRESS")
|
|
156
|
+
@click.pass_context
|
|
157
|
+
def cpu_break(ctx, address: str):
|
|
158
|
+
"""Set software breakpoint at ADDRESS.
|
|
159
|
+
|
|
160
|
+
Example:
|
|
161
|
+
dbxdebug cpu break 0x1000
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
|
|
165
|
+
if gdb.set_breakpoint(address):
|
|
166
|
+
linear = parse_x86_address(address)
|
|
167
|
+
click.echo(f"Breakpoint set at 0x{linear:X}")
|
|
168
|
+
else:
|
|
169
|
+
click.echo(f"Failed to set breakpoint at {address}", err=True)
|
|
170
|
+
sys.exit(1)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
click.echo(f"Error: {e}", err=True)
|
|
173
|
+
sys.exit(1)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@cpu.command("delete")
|
|
177
|
+
@click.argument("address", metavar="ADDRESS")
|
|
178
|
+
@click.pass_context
|
|
179
|
+
def cpu_delete(ctx, address: str):
|
|
180
|
+
"""Remove breakpoint at ADDRESS.
|
|
181
|
+
|
|
182
|
+
Example:
|
|
183
|
+
dbxdebug cpu delete 0x1000
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
|
|
187
|
+
if gdb.remove_breakpoint(address):
|
|
188
|
+
linear = parse_x86_address(address)
|
|
189
|
+
click.echo(f"Breakpoint removed at 0x{linear:X}")
|
|
190
|
+
else:
|
|
191
|
+
click.echo(f"No breakpoint at {address}", err=True)
|
|
192
|
+
sys.exit(1)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
click.echo(f"Error: {e}", err=True)
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@cpu.command("step")
|
|
199
|
+
@click.pass_context
|
|
200
|
+
def cpu_step(ctx):
|
|
201
|
+
"""Execute single instruction and stop."""
|
|
202
|
+
try:
|
|
203
|
+
with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
|
|
204
|
+
response = gdb.step()
|
|
205
|
+
click.echo(f"Stopped: {response.decode()}")
|
|
206
|
+
except Exception as e:
|
|
207
|
+
click.echo(f"Error: {e}", err=True)
|
|
208
|
+
sys.exit(1)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@cpu.command("cont")
|
|
212
|
+
@click.pass_context
|
|
213
|
+
def cpu_cont(ctx):
|
|
214
|
+
"""Continue execution until breakpoint or Ctrl+C."""
|
|
215
|
+
try:
|
|
216
|
+
with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
|
|
217
|
+
click.echo("Continuing... (Ctrl+C to interrupt)")
|
|
218
|
+
response = gdb.continue_execution()
|
|
219
|
+
click.echo(f"Stopped: {response.decode()}")
|
|
220
|
+
except KeyboardInterrupt:
|
|
221
|
+
click.echo("\nInterrupted")
|
|
222
|
+
except Exception as e:
|
|
223
|
+
click.echo(f"Error: {e}", err=True)
|
|
224
|
+
sys.exit(1)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@cpu.command("halt")
|
|
228
|
+
@click.pass_context
|
|
229
|
+
def cpu_halt(ctx):
|
|
230
|
+
"""Break into debugger (stop execution)."""
|
|
231
|
+
try:
|
|
232
|
+
with GDBClient(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as gdb:
|
|
233
|
+
response = gdb.halt()
|
|
234
|
+
click.echo(f"Halted: {response.decode()}")
|
|
235
|
+
except Exception as e:
|
|
236
|
+
click.echo(f"Error: {e}", err=True)
|
|
237
|
+
sys.exit(1)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# =============================================================================
|
|
241
|
+
# Keyboard Commands (QMP)
|
|
242
|
+
# =============================================================================
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@main.group()
|
|
246
|
+
@click.option("--host", default="localhost", help="QMP server hostname")
|
|
247
|
+
@click.option("--port", default=4444, help="QMP server port (default: 4444)")
|
|
248
|
+
@click.pass_context
|
|
249
|
+
def key(ctx, host: str, port: int):
|
|
250
|
+
"""Keyboard input via QMP protocol."""
|
|
251
|
+
ctx.ensure_object(dict)
|
|
252
|
+
ctx.obj["qmp_host"] = host
|
|
253
|
+
ctx.obj["qmp_port"] = port
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@key.command("send")
|
|
257
|
+
@click.argument("keys", nargs=-1, required=True, metavar="KEY...")
|
|
258
|
+
@click.option("--hold", default=100, help="Hold time in milliseconds (default: 100)")
|
|
259
|
+
@click.pass_context
|
|
260
|
+
def key_send(ctx, keys: tuple[str, ...], hold: int):
|
|
261
|
+
"""Send key chord (simultaneous key presses).
|
|
262
|
+
|
|
263
|
+
KEYS are QMP qcode names. Multiple keys are pressed together.
|
|
264
|
+
|
|
265
|
+
Common qcodes: a-z, 0-9, f1-f12, ctrl, shift, alt, ret (Enter),
|
|
266
|
+
spc (Space), esc, tab, backspace, delete, insert, home, end,
|
|
267
|
+
pgup, pgdn, left, right, up, down.
|
|
268
|
+
|
|
269
|
+
Examples:
|
|
270
|
+
dbxdebug key send a
|
|
271
|
+
dbxdebug key send ctrl c
|
|
272
|
+
dbxdebug key send ctrl alt delete
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
with QMPClient(ctx.obj["qmp_host"], ctx.obj["qmp_port"]) as qmp:
|
|
276
|
+
qmp.send_key(list(keys), hold)
|
|
277
|
+
click.echo(f"Sent: {' + '.join(keys)}")
|
|
278
|
+
except QMPError as e:
|
|
279
|
+
click.echo(f"QMP error: {e}", err=True)
|
|
280
|
+
sys.exit(1)
|
|
281
|
+
except Exception as e:
|
|
282
|
+
click.echo(f"Error: {e}", err=True)
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@key.command("type")
|
|
287
|
+
@click.argument("text", metavar="TEXT")
|
|
288
|
+
@click.option("--delay", default=0.05, help="Delay between keys in seconds (default: 0.05)")
|
|
289
|
+
@click.pass_context
|
|
290
|
+
def key_type(ctx, text: str, delay: float):
|
|
291
|
+
"""Type a text string character by character.
|
|
292
|
+
|
|
293
|
+
Automatically handles shift for uppercase letters and symbols.
|
|
294
|
+
|
|
295
|
+
Example:
|
|
296
|
+
dbxdebug key type "Hello World!"
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
with QMPClient(ctx.obj["qmp_host"], ctx.obj["qmp_port"]) as qmp:
|
|
300
|
+
qmp.type_text(text, delay)
|
|
301
|
+
click.echo(f"Typed: {text}")
|
|
302
|
+
except QMPError as e:
|
|
303
|
+
click.echo(f"QMP error: {e}", err=True)
|
|
304
|
+
sys.exit(1)
|
|
305
|
+
except Exception as e:
|
|
306
|
+
click.echo(f"Error: {e}", err=True)
|
|
307
|
+
sys.exit(1)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@key.command("down")
|
|
311
|
+
@click.argument("keycode", metavar="KEY")
|
|
312
|
+
@click.pass_context
|
|
313
|
+
def key_down(ctx, keycode: str):
|
|
314
|
+
"""Press and hold a key (no release).
|
|
315
|
+
|
|
316
|
+
Use 'key up' to release. Useful for modifier keys.
|
|
317
|
+
|
|
318
|
+
Example:
|
|
319
|
+
dbxdebug key down shift
|
|
320
|
+
dbxdebug key send a
|
|
321
|
+
dbxdebug key up shift
|
|
322
|
+
"""
|
|
323
|
+
try:
|
|
324
|
+
with QMPClient(ctx.obj["qmp_host"], ctx.obj["qmp_port"]) as qmp:
|
|
325
|
+
qmp.key_down(keycode)
|
|
326
|
+
click.echo(f"Key down: {keycode}")
|
|
327
|
+
except QMPError as e:
|
|
328
|
+
click.echo(f"QMP error: {e}", err=True)
|
|
329
|
+
sys.exit(1)
|
|
330
|
+
except Exception as e:
|
|
331
|
+
click.echo(f"Error: {e}", err=True)
|
|
332
|
+
sys.exit(1)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@key.command("up")
|
|
336
|
+
@click.argument("keycode", metavar="KEY")
|
|
337
|
+
@click.pass_context
|
|
338
|
+
def key_up(ctx, keycode: str):
|
|
339
|
+
"""Release a held key.
|
|
340
|
+
|
|
341
|
+
Example:
|
|
342
|
+
dbxdebug key up shift
|
|
343
|
+
"""
|
|
344
|
+
try:
|
|
345
|
+
with QMPClient(ctx.obj["qmp_host"], ctx.obj["qmp_port"]) as qmp:
|
|
346
|
+
qmp.key_up(keycode)
|
|
347
|
+
click.echo(f"Key up: {keycode}")
|
|
348
|
+
except QMPError as e:
|
|
349
|
+
click.echo(f"QMP error: {e}", err=True)
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
except Exception as e:
|
|
352
|
+
click.echo(f"Error: {e}", err=True)
|
|
353
|
+
sys.exit(1)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@key.command("list")
|
|
357
|
+
@click.pass_context
|
|
358
|
+
def key_list_cmd(ctx):
|
|
359
|
+
"""List available QMP commands on server."""
|
|
360
|
+
try:
|
|
361
|
+
with QMPClient(ctx.obj["qmp_host"], ctx.obj["qmp_port"]) as qmp:
|
|
362
|
+
commands = qmp.query_commands()
|
|
363
|
+
click.echo("Available QMP commands:")
|
|
364
|
+
for cmd in sorted(commands):
|
|
365
|
+
click.echo(f" {cmd}")
|
|
366
|
+
except QMPError as e:
|
|
367
|
+
click.echo(f"QMP error: {e}", err=True)
|
|
368
|
+
sys.exit(1)
|
|
369
|
+
except Exception as e:
|
|
370
|
+
click.echo(f"Error: {e}", err=True)
|
|
371
|
+
sys.exit(1)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
# =============================================================================
|
|
375
|
+
# Screen Commands (GDB memory access)
|
|
376
|
+
# =============================================================================
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@main.group()
|
|
380
|
+
@click.option("--host", default="localhost", help="GDB server hostname")
|
|
381
|
+
@click.option("--port", default=2159, help="GDB server port (default: 2159)")
|
|
382
|
+
@click.pass_context
|
|
383
|
+
def screen(ctx, host: str, port: int):
|
|
384
|
+
"""Screen capture and video memory access."""
|
|
385
|
+
ctx.ensure_object(dict)
|
|
386
|
+
ctx.obj["gdb_host"] = host
|
|
387
|
+
ctx.obj["gdb_port"] = port
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
@screen.command("show")
|
|
391
|
+
@click.pass_context
|
|
392
|
+
def screen_show(ctx):
|
|
393
|
+
"""Display current DOS text screen (80x25) to stdout."""
|
|
394
|
+
try:
|
|
395
|
+
with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
|
|
396
|
+
lines = video.screen_dump()
|
|
397
|
+
if lines:
|
|
398
|
+
for line in lines:
|
|
399
|
+
click.echo(line)
|
|
400
|
+
else:
|
|
401
|
+
click.echo("Failed to read screen", err=True)
|
|
402
|
+
sys.exit(1)
|
|
403
|
+
except Exception as e:
|
|
404
|
+
click.echo(f"Error: {e}", err=True)
|
|
405
|
+
sys.exit(1)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@screen.command("capture")
|
|
409
|
+
@click.option("-o", "--output", default="screen", help="Output filename (without extension)")
|
|
410
|
+
@click.option(
|
|
411
|
+
"-f",
|
|
412
|
+
"--format",
|
|
413
|
+
"fmt",
|
|
414
|
+
type=click.Choice(["raw", "html", "text"]),
|
|
415
|
+
default="raw",
|
|
416
|
+
help="Output format (default: raw)",
|
|
417
|
+
)
|
|
418
|
+
@click.pass_context
|
|
419
|
+
def screen_capture(ctx, output: str, fmt: str):
|
|
420
|
+
"""Save single screen frame to file.
|
|
421
|
+
|
|
422
|
+
Formats:
|
|
423
|
+
raw - Binary video memory with attributes (.bin)
|
|
424
|
+
html - Rendered HTML with VGA colors (.html)
|
|
425
|
+
text - Plain text, 80x25 characters (.txt)
|
|
426
|
+
|
|
427
|
+
Examples:
|
|
428
|
+
dbxdebug screen capture -o snapshot
|
|
429
|
+
dbxdebug screen capture -f html -o pretty
|
|
430
|
+
"""
|
|
431
|
+
try:
|
|
432
|
+
with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
|
|
433
|
+
if fmt == "raw":
|
|
434
|
+
data = video.screen_raw()
|
|
435
|
+
if not data:
|
|
436
|
+
click.echo("Failed to read screen", err=True)
|
|
437
|
+
sys.exit(1)
|
|
438
|
+
filename = f"{output}.bin" if not output.endswith(".bin") else output
|
|
439
|
+
with open(filename, "wb") as f:
|
|
440
|
+
f.write(data)
|
|
441
|
+
click.echo(f"Saved raw video memory to {filename}")
|
|
442
|
+
|
|
443
|
+
elif fmt == "html":
|
|
444
|
+
data = video.screen_raw()
|
|
445
|
+
if not data:
|
|
446
|
+
click.echo("Failed to read screen", err=True)
|
|
447
|
+
sys.exit(1)
|
|
448
|
+
filename = f"{output}.html" if not output.endswith(".html") else output
|
|
449
|
+
html = dos_video_to_html(data)
|
|
450
|
+
with open(filename, "w", encoding="utf-8") as f:
|
|
451
|
+
f.write(html)
|
|
452
|
+
click.echo(f"Saved HTML to {filename}")
|
|
453
|
+
|
|
454
|
+
elif fmt == "text":
|
|
455
|
+
lines = video.screen_dump()
|
|
456
|
+
if not lines:
|
|
457
|
+
click.echo("Failed to read screen", err=True)
|
|
458
|
+
sys.exit(1)
|
|
459
|
+
filename = f"{output}.txt" if not output.endswith(".txt") else output
|
|
460
|
+
with open(filename, "w", encoding="utf-8") as f:
|
|
461
|
+
f.write("\n".join(lines))
|
|
462
|
+
click.echo(f"Saved text to {filename}")
|
|
463
|
+
|
|
464
|
+
except Exception as e:
|
|
465
|
+
click.echo(f"Error: {e}", err=True)
|
|
466
|
+
sys.exit(1)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@screen.command("info")
|
|
470
|
+
@click.pass_context
|
|
471
|
+
def screen_info(ctx):
|
|
472
|
+
"""Show video mode and BIOS timer info."""
|
|
473
|
+
try:
|
|
474
|
+
with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
|
|
475
|
+
mode = video.read_video_mode()
|
|
476
|
+
ticks = video.read_timer_ticks()
|
|
477
|
+
click.echo(f"Video mode: {mode} (0x{mode:02X})" if mode else "Video mode: unknown")
|
|
478
|
+
click.echo(f"BIOS ticks: {ticks}" if ticks else "BIOS ticks: unknown")
|
|
479
|
+
if ticks:
|
|
480
|
+
seconds = ticks / 18.2065
|
|
481
|
+
click.echo(f"Uptime: {seconds:.1f}s ({seconds/60:.1f}m)")
|
|
482
|
+
except Exception as e:
|
|
483
|
+
click.echo(f"Error: {e}", err=True)
|
|
484
|
+
sys.exit(1)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@screen.command("watch")
|
|
488
|
+
@click.option("-i", "--interval", default=0.5, help="Update interval in seconds")
|
|
489
|
+
@click.pass_context
|
|
490
|
+
def screen_watch(ctx, interval: float):
|
|
491
|
+
"""Watch screen in real-time. Press Ctrl+C to stop."""
|
|
492
|
+
try:
|
|
493
|
+
with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
|
|
494
|
+
click.echo("Watching screen... (Ctrl+C to stop)\n")
|
|
495
|
+
while True:
|
|
496
|
+
click.echo("\033[2J\033[H", nl=False) # Clear and home
|
|
497
|
+
lines, ticks = video.screen_dump_with_ticks()
|
|
498
|
+
if lines:
|
|
499
|
+
for line in lines:
|
|
500
|
+
click.echo(line)
|
|
501
|
+
if ticks is not None:
|
|
502
|
+
click.echo(f"\n[Ticks: {ticks}]")
|
|
503
|
+
else:
|
|
504
|
+
click.echo("Failed to read screen")
|
|
505
|
+
time.sleep(interval)
|
|
506
|
+
except KeyboardInterrupt:
|
|
507
|
+
click.echo("\nStopped")
|
|
508
|
+
except Exception as e:
|
|
509
|
+
click.echo(f"Error: {e}", err=True)
|
|
510
|
+
sys.exit(1)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@screen.command("record")
|
|
514
|
+
@click.option("-o", "--output", default="capture.capture.gz", help="Output filename")
|
|
515
|
+
@click.option("-d", "--duration", default=10.0, help="Duration in seconds")
|
|
516
|
+
@click.option("-r", "--rate", default=50.0, help="Sample rate in Hz")
|
|
517
|
+
@click.option("--raw", is_flag=True, help="Capture raw bytes (with attributes)")
|
|
518
|
+
@click.pass_context
|
|
519
|
+
def screen_record(ctx, output: str, duration: float, rate: float, raw: bool):
|
|
520
|
+
"""Record screen captures to a .capture.gz file.
|
|
521
|
+
|
|
522
|
+
Creates a timestamped capture file for later analysis.
|
|
523
|
+
|
|
524
|
+
Example:
|
|
525
|
+
dbxdebug screen record -d 60 -r 30 -o session.capture.gz
|
|
526
|
+
"""
|
|
527
|
+
try:
|
|
528
|
+
with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
|
|
529
|
+
recorder = ScreenRecorder({"duration": duration, "sample_rate": rate})
|
|
530
|
+
total_samples = int(duration * rate)
|
|
531
|
+
|
|
532
|
+
click.echo(f"Recording {duration}s at {rate}Hz ({total_samples} frames)...")
|
|
533
|
+
|
|
534
|
+
with click.progressbar(length=total_samples, label="Recording") as bar:
|
|
535
|
+
sample_interval = 1.0 / rate
|
|
536
|
+
start_time = time.time()
|
|
537
|
+
capture_fn = recorder.capture_raw if raw else recorder.capture
|
|
538
|
+
|
|
539
|
+
for i in range(total_samples):
|
|
540
|
+
capture_fn(video)
|
|
541
|
+
bar.update(1)
|
|
542
|
+
|
|
543
|
+
elapsed = time.time() - start_time
|
|
544
|
+
next_sample = (i + 1) * sample_interval
|
|
545
|
+
if (sleep_time := next_sample - elapsed) > 0:
|
|
546
|
+
time.sleep(sleep_time)
|
|
547
|
+
|
|
548
|
+
recorder.save(output)
|
|
549
|
+
click.echo(f"Saved {len(recorder)} frames to {output}")
|
|
550
|
+
except Exception as e:
|
|
551
|
+
click.echo(f"Error: {e}", err=True)
|
|
552
|
+
sys.exit(1)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@screen.command("colors")
|
|
556
|
+
@click.argument("capture_file", type=click.Path(exists=True), required=False)
|
|
557
|
+
@click.pass_context
|
|
558
|
+
def screen_colors(ctx, capture_file: str | None):
|
|
559
|
+
"""Analyze colors in current screen or capture file.
|
|
560
|
+
|
|
561
|
+
Without argument, analyzes live screen. With file, analyzes capture.
|
|
562
|
+
"""
|
|
563
|
+
try:
|
|
564
|
+
if capture_file:
|
|
565
|
+
data = load_capture(capture_file)
|
|
566
|
+
screens = data.get("screens", data)
|
|
567
|
+
# Get first raw screen from capture
|
|
568
|
+
if isinstance(screens, dict):
|
|
569
|
+
first_key = next(iter(screens))
|
|
570
|
+
video_data = screens[first_key]
|
|
571
|
+
if isinstance(video_data, list):
|
|
572
|
+
click.echo("Capture contains text-only screens (no color data)")
|
|
573
|
+
return
|
|
574
|
+
pages = [video_data]
|
|
575
|
+
else:
|
|
576
|
+
pages = [screens]
|
|
577
|
+
else:
|
|
578
|
+
with DOSVideoTools(ctx.obj["gdb_host"], ctx.obj["gdb_port"]) as video:
|
|
579
|
+
raw = video.screen_raw()
|
|
580
|
+
if not raw:
|
|
581
|
+
click.echo("Failed to read screen", err=True)
|
|
582
|
+
sys.exit(1)
|
|
583
|
+
pages = [raw]
|
|
584
|
+
|
|
585
|
+
analysis = analyze_dos_video_colors(pages)
|
|
586
|
+
summary = analysis["summary"]
|
|
587
|
+
|
|
588
|
+
click.echo(f"Cells analyzed: {summary['total_cells']}")
|
|
589
|
+
click.echo(f"Content cells: {summary['content_cells']}")
|
|
590
|
+
click.echo(f"Foreground colors: {summary['unique_fg_colors']}")
|
|
591
|
+
click.echo(f"Background colors: {summary['unique_bg_colors']}")
|
|
592
|
+
click.echo(f"Color combinations: {summary['unique_combinations']}")
|
|
593
|
+
click.echo(f"Blink used: {'Yes' if summary['blink_used'] else 'No'}")
|
|
594
|
+
|
|
595
|
+
click.echo("\nForeground colors:")
|
|
596
|
+
for c in analysis["foreground_colors"]:
|
|
597
|
+
click.echo(f" {c['id']:2d} {c['name']:<14} {c['count']:5d} ({c['percentage']:5.1f}%)")
|
|
598
|
+
|
|
599
|
+
click.echo("\nBackground colors:")
|
|
600
|
+
for c in analysis["background_colors"]:
|
|
601
|
+
click.echo(f" {c['id']:2d} {c['name']:<14} {c['count']:5d} ({c['percentage']:5.1f}%)")
|
|
602
|
+
|
|
603
|
+
except Exception as e:
|
|
604
|
+
click.echo(f"Error: {e}", err=True)
|
|
605
|
+
sys.exit(1)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# =============================================================================
|
|
609
|
+
# Backwards compatibility aliases
|
|
610
|
+
# =============================================================================
|
|
611
|
+
|
|
612
|
+
# Keep old 'gdb' and 'qmp' as aliases
|
|
613
|
+
main.add_command(mem, name="gdb") # gdb is alias for mem
|
|
614
|
+
main.add_command(key, name="qmp") # qmp is alias for key
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
if __name__ == "__main__":
|
|
618
|
+
main()
|