1#!/usr/bin/env python 2# 3# Hatari console: 4# Allows using Hatari shortcuts & debugger, changing paths, toggling 5# devices and changing Hatari command line options (even for things you 6# cannot change from the UI) from the console while Hatari is running. 7# 8# Copyright (C) 2008-2014 by Eero Tamminen 9# 10# This program is free software; you can redistribute it and/or modify 11# it under the terms of the GNU General Public License as published by 12# the Free Software Foundation; either version 2 of the License, or 13# (at your option) any later version. 14# 15# This program is distributed in the hope that it will be useful, 16# but WITHOUT ANY WARRANTY; without even the implied warranty of 17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18# GNU General Public License for more details. 19 20import os 21import sys 22import time 23import signal 24import socket 25import readline 26 27# Python v2: 28# - lacks Python v3 encoding arg for bytes() 29# - input() evaluates given string and fails on empty one 30if str is bytes: 31 def bytes(s, encoding): 32 return s 33 def input(prompt): 34 return raw_input(prompt) 35 36class Scancode: 37 "Atari scancodes for keys without alphanumeric characters" 38 # US keyboard scancode mapping for characters which need shift 39 Shifted = { 40 '!': "0x2", 41 '@': "0x3", 42 '#': "0x4", 43 '$': "0x5", 44 '%': "0x6", 45 '^': "0x7", 46 '&': "0x8", 47 '*': "0x9", 48 '(': "10", 49 ')': "11", 50 '_': "12", 51 '+': "13", 52 '~': "41", 53 '{': "26", 54 '}': "27", 55 ':': "39", 56 '"': "40", 57 '|': "43", 58 '<': "51", 59 '>': "52", 60 '?': "53" 61 } 62 # US keyboard scancode mapping for characters which don't need shift 63 UnShifted = { 64 '-': "12", 65 '=': "13", 66 '[': "26", 67 ']': "27", 68 ';': "39", 69 "'": "40", 70 '\\': "43", 71 '",': "51", 72 '.': "52", 73 '/': "53" 74 } 75 # special keys without corresponding character 76 Tab = "15" 77 Return = "28" 78 Enter = "114" 79 Space = "57" 80 Delete = "83" 81 Backspace = "14" 82 Escape = "0x1" 83 Control = "29" 84 Alternate = "56" 85 LeftShift = "42" 86 RightShift = "54" 87 CapsLock = "53" 88 Insert = "82" 89 Home = "71" 90 Help = "98" 91 Undo = "97" 92 CursorUp = "72" 93 CursorDown = "80" 94 CursorLeft = "75" 95 CursorRight = "77" 96 97 98# running Hatari instance 99class Hatari: 100 controlpath = "/tmp/hatari-console-" + str(os.getpid()) + ".socket" 101 hataribin = "hatari" 102 103 def __init__(self, args): 104 # collect hatari process zombies without waitpid() 105 signal.signal(signal.SIGCHLD, signal.SIG_IGN) 106 self._assert_hatari_compatibility() 107 self.server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 108 if os.path.exists(self.controlpath): 109 os.unlink(self.controlpath) 110 self.server.bind(self.controlpath) 111 self.server.listen(1) 112 self.control = None 113 self.paused = False 114 self.interval = 0.2 115 self.pid = 0 116 if not self.run_hatari(args): 117 print("ERROR: failed to run Hatari") 118 sys.exit(1) 119 self.shiftdown = False 120 self.verbose = False 121 122 def _assert_hatari_compatibility(self): 123 "check Hatari compatibility and return error string if it's not" 124 error = True 125 pipe = os.popen(self.hataribin + " -h") 126 for line in pipe.readlines(): 127 if line.find("--control-socket") >= 0: 128 error = False 129 break 130 try: 131 pipe.close() 132 except IOError: 133 pass 134 if error: 135 print("ERROR: %s" % error) 136 sys.exit(-1) 137 138 def is_running(self): 139 if not self.pid: 140 return False 141 try: 142 os.waitpid(self.pid, os.WNOHANG) 143 except OSError as value: 144 print("Hatari PID %d had exited in the meanwhile:\n\t%s" % (self.pid, value)) 145 self.pid = 0 146 if self.control: 147 self.control.close() 148 self.control = None 149 return False 150 return True 151 152 def run_hatari(self, args): 153 if self.control: 154 print("ERROR: Hatari is already running, stop it first") 155 return 156 pid = os.fork() 157 if pid < 0: 158 print("ERROR: fork()ing Hatari failed!") 159 return 160 if pid: 161 # in parent 162 self.pid = pid 163 print("WAIT hatari to connect to control socket...") 164 (self.control, addr) = self.server.accept() 165 print("connected!") 166 return self.control 167 else: 168 # child runs Hatari 169 allargs = [self.hataribin, "--control-socket", self.controlpath] + args 170 print("RUN:", allargs) 171 os.execvp(self.hataribin, allargs) 172 173 def send_message(self, msg, fast = False): 174 if self.control: 175 if self.verbose: 176 print("-> '%s'" % msg) 177 self.control.sendall(bytes(msg + "\n", "ASCII")) 178 # KLUDGE: wait so that Hatari output comes before next prompt 179 if fast: 180 interval = self.interval/4 181 else: 182 interval = self.interval 183 time.sleep(interval) 184 return True 185 else: 186 print("ERROR: no Hatari (control socket)") 187 return False 188 189 def change_option(self, option): 190 return self.send_message("hatari-option %s" % option) 191 192 def trigger_shortcut(self, shortcut): 193 return self.send_message("hatari-shortcut %s" % shortcut) 194 195 def _shift_up(self): 196 if self.shiftdown: 197 self.shiftdown = False 198 return self.send_message("hatari-event keyup %s" % Scancode.LeftShift, True) 199 return True 200 201 def _unshifted_keypress(self, key): 202 self._shift_up() 203 if key == ' ': 204 # white space gets stripped, use scancode instead 205 key = Scancode.Space 206 return self.send_message("hatari-event keypress %s" % key, True) 207 208 def _shifted_keypress(self, key): 209 if not self.shiftdown: 210 self.shiftdown = True 211 self.send_message("hatari-event keydown %s" % Scancode.LeftShift, True) 212 return self.send_message("hatari-event keypress %s" % key, True) 213 214 def send_string(self, text): 215 print("string:", text) 216 for key in text: 217 if key in Scancode.Shifted: 218 ok = self._shifted_keypress(Scancode.Shifted[key]) 219 elif key in Scancode.UnShifted: 220 ok = self._unshifted_keypress(Scancode.UnShifted[key]) 221 else: 222 ok = self._unshifted_keypress(key) 223 if not ok: 224 return False 225 return self._shift_up() 226 227 def insert_event(self, event): 228 if event.startswith("text "): 229 cmd, value = event.split(None, 1) 230 if value: 231 return self.send_string(value) 232 return self.send_message("hatari-event %s" % event, True) 233 234 def debug_command(self, cmd): 235 return self.send_message("hatari-debug %s" % cmd) 236 237 def change_path(self, path): 238 return self.send_message("hatari-path %s" % path) 239 240 def toggle_device(self, device): 241 return self.send_message("hatari-toggle %s" % device) 242 243 def toggle_pause(self): 244 self.paused = not self.paused 245 if self.paused: 246 return self.send_message("hatari-stop") 247 else: 248 return self.send_message("hatari-cont") 249 250 def toggle_verbose(self): 251 self.verbose = not self.verbose 252 print("debug output", self.verbose) 253 254 def kill_hatari(self): 255 if self.is_running(): 256 os.kill(self.pid, signal.SIGKILL) 257 print("killed hatari with PID %d" % self.pid) 258 self.pid = 0 259 if self.control: 260 self.control.close() 261 self.control = None 262 263 264# command line parsing with readline 265class CommandInput: 266 prompt = "hatari-command: " 267 historysize = 99 268 269 def __init__(self, commands): 270 readline.set_history_length(self.historysize) 271 readline.parse_and_bind("tab: complete") 272 readline.set_completer_delims(" \t\r\n") 273 readline.set_completer(self.complete) 274 self.commands = commands 275 276 def complete(self, text, state): 277 idx = 0 278 #print "text: '%s', state '%d'" % (text, state) 279 for cmd in self.commands: 280 if cmd.startswith(text): 281 idx += 1 282 if idx > state: 283 return cmd 284 285 def loop(self): 286 try: 287 rawline = input(self.prompt) 288 return rawline 289 except EOFError: 290 return "" 291 292 293class Tokens: 294 # update with: hatari -h|grep -- --|sed 's/^ *\(--[^ ]*\).*$/ "\1",/'|grep -v -e control-socket -e 'joy<' 295 option_tokens = [ 296 "--help", 297 "--version", 298 "--confirm-quit", 299 "--configfile", 300 "--keymap", 301 "--fast-forward", 302 "--mono", 303 "--monitor", 304 "--fullscreen", 305 "--window", 306 "--grab", 307 "--frameskips", 308 "--statusbar", 309 "--drive-led", 310 "--bpp", 311 "--borders", 312 "--desktop-st", 313 "--spec512", 314 "--zoom", 315 "--desktop", 316 "--max-width", 317 "--max-height", 318 "--force-max", 319 "--aspect", 320 "--vdi", 321 "--vdi-planes", 322 "--vdi-width", 323 "--vdi-height", 324 "--crop", 325 "--avirecord", 326 "--avi-vcodec", 327 "--avi-fps", 328 "--avi-file", 329 "--joy0", 330 "--joy1", 331 "--joy2", 332 "--joy3", 333 "--joy4", 334 "--joy5", 335 "--joystick", 336 "--printer", 337 "--midi-in", 338 "--midi-out", 339 "--rs232-in", 340 "--rs232-out", 341 "--disk-a", 342 "--disk-b", 343 "--fastfdc", 344 "--protect-floppy", 345 "--protect-hd", 346 "--harddrive", 347 "--acsi", 348 "--ide-master", 349 "--ide-slave", 350 "--memsize", 351 "--memstate", 352 "--tos", 353 "--patch-tos", 354 "--cartridge", 355 "--cpulevel", 356 "--cpuclock", 357 "--compatible", 358 "--machine", 359 "--blitter", 360 "--dsp", 361 "--timer-d", 362 "--fast-boot", 363 "--rtc", 364 "--mic", 365 "--sound", 366 "--sound-buffer-size", 367 "--ym-mixing", 368 "--debug", 369 "--bios-intercept", 370 "--conout", 371 "--trace", 372 "--trace-file", 373 "--parse", 374 "--saveconfig", 375 "--no-parachute", 376 "--log-file", 377 "--log-level", 378 "--alert-level", 379 "--run-vbls" 380 ] 381 shortcut_tokens = [ 382 "mousegrab", 383 "coldreset", 384 "warmreset", 385 "screenshot", 386 "bosskey", 387 "recanim", 388 "recsound", 389 "savemem" 390 ] 391 event_tokens = [ 392 "doubleclick", 393 "rightdown", 394 "rightup", 395 "keypress", 396 "keydown", 397 "keyup", 398 "text" # simulated with keypresses 399 ] 400 device_tokens = [ 401 "printer", 402 "rs232", 403 "midi", 404 ] 405 path_tokens = [ 406 "memauto", 407 "memsave", 408 "midiout", 409 "printout", 410 "soundout", 411 "rs232in", 412 "rs232out" 413 ] 414 # use the long variants of the commands for clarity 415 debugger_tokens = [ 416 "address", 417 "breakpoint", 418 "cd", 419 "cont", 420 "cpureg", 421 "disasm", 422 "dspaddress", 423 "dspbreak", 424 "dspcont", 425 "dspdisasm", 426 "dspmemdump", 427 "dspreg", 428 "dspsymbols", 429 "evaluate", 430 "help", 431 "history", 432 "info", 433 "loadbin", 434 "lock", 435 "logfile", 436 "memdump", 437 "memwrite", 438 "parse", 439 "profile", 440 "quit", 441 "savebin", 442 "setopt", 443 "stateload", 444 "statesave", 445 "symbols", 446 "trace" 447 ] 448 449 def __init__(self, hatari, do_exit = True): 450 self.process_tokens = { 451 "kill": hatari.kill_hatari, 452 "pause": hatari.toggle_pause 453 } 454 self.script_tokens = { 455 "script": self.do_script, 456 "sleep": self.do_sleep 457 } 458 self.help_tokens = { 459 "usage": self.show_help, 460 "verbose": hatari.toggle_verbose 461 } 462 self.hatari = hatari 463 # whether to exit when Hatari disappears 464 self.do_exit = do_exit 465 466 def get_tokens(self): 467 tokens = [] 468 for items in [self.option_tokens, self.shortcut_tokens, 469 self.event_tokens, self.debugger_tokens, self.device_tokens, 470 self.path_tokens, list(self.process_tokens.keys()), 471 list(self.script_tokens.keys()), list(self.help_tokens.keys())]: 472 for token in items: 473 if token in tokens: 474 print("ERROR: token '%s' already in tokens" % token) 475 sys.exit(1) 476 tokens += items 477 return tokens 478 479 def show_help(self): 480 print(""" 481Hatari-console help 482------------------- 483 484Hatari-console allows you to control Hatari through its control socket 485from the provided console prompt, while Hatari is running. All control 486commands support TAB completion on their names and options. 487 488The supported control facilities are:""") 489 self.list_items("Command line options", self.option_tokens) 490 self.list_items("Keyboard shortcuts", self.shortcut_tokens) 491 self.list_items("Event invocation", self.event_tokens) 492 self.list_items("Device toggling", self.device_tokens) 493 self.list_items("Path setting", self.path_tokens) 494 self.list_items("Debugger commands", self.debugger_tokens) 495 print(""" 496"pause" toggles Hatari paused state on/off. 497"kill" will terminate Hatari. 498 499"script" command reads commands from the given file. 500"sleep" command can be used in script to wait given number of seconds. 501"verbose" command toggles commands debug output on/off. 502 503For command line options you can get further help with "--help" 504and for debugger commands with "help". Some of the other facilities 505give help when you give them invalid input. 506""") 507 508 def list_items(self, title, items): 509 print("\n%s:" % title) 510 for item in items: 511 print("*", item) 512 513 def do_sleep(self, line): 514 items = line.split()[1:] 515 try: 516 secs = int(items[0]) 517 except: 518 secs = 0 519 if secs > 0: 520 print("Sleeping for %d secs..." % secs) 521 time.sleep(secs) 522 else: 523 print("usage: sleep <seconds>") 524 525 def do_script(self, line): 526 try: 527 filename = line.split()[1] 528 f = open(filename) 529 except: 530 print("usage: script <filename>") 531 return 532 533 for line in f.readlines(): 534 line = line.strip() 535 if not line or line[0] == '#': 536 continue 537 print(">", line) 538 self.process_command(line) 539 540 def process_command(self, line): 541 if not self.hatari.is_running(): 542 print("There's no Hatari (anymore)!") 543 if not self.do_exit: 544 return False 545 print("Exiting...") 546 sys.exit(0) 547 if not line: 548 return False 549 550 first = line.split()[0] 551 # multiple items 552 if first in self.event_tokens: 553 self.hatari.insert_event(line) 554 elif first in self.debugger_tokens: 555 self.hatari.debug_command(line) 556 elif first in self.option_tokens: 557 self.hatari.change_option(line) 558 elif first in self.path_tokens: 559 self.hatari.change_path(line) 560 elif first in self.script_tokens: 561 self.script_tokens[first](line) 562 # single item 563 elif line in self.device_tokens: 564 self.hatari.toggle_device(line) 565 elif line in self.shortcut_tokens: 566 self.hatari.trigger_shortcut(line) 567 elif line in self.process_tokens: 568 self.process_tokens[line]() 569 elif line in self.help_tokens: 570 self.help_tokens[line]() 571 else: 572 print("ERROR: unknown hatari-console command:", line) 573 return False 574 return True 575 576class Main: 577 def __init__(self, options, do_exit=True): 578 args, self.file, self.exit = self.parse_args(options) 579 hatari = Hatari(args) 580 self.tokens = Tokens(hatari, do_exit) 581 self.command = CommandInput(self.tokens.get_tokens()) 582 583 def parse_args(self, args): 584 if "-h" in args or "--help" in args: 585 self.usage() 586 587 file = [] 588 exit = False 589 if "--" not in args: 590 return (args[1:], file, exit) 591 592 for arg in args: 593 if arg == "--": 594 return (args[args.index("--")+1:], file, exit) 595 if arg == "--exit": 596 exit = True 597 continue 598 if os.path.exists(arg): 599 file = arg 600 else: 601 self.usage("file '%s' not found" % arg) 602 603 def usage(self, msg=None): 604 name = os.path.basename(sys.argv[0]) 605 print("\n%s" % name) 606 print("=" * len(name)) 607 print(""" 608Usage: %s [<console options/args> --] [<hatari options>] 609 610Hatari console options/args: 611\t<file>\t\tread commands from given file 612\t--exit\t\texit after executing the commands in the file 613\t-h, --help\t\tthis help 614 615Except for help, console options/args will be interpreted 616only if '--' is given as one of the arguments. Otherwise 617all arguments are given to Hatari. 618 619For example: 620 %s --monitor mono test.prg 621 %s commands.txt -- --monitor mono 622 %s commands.txt --exit -- 623""" % (name, name, name, name)) 624 if msg: 625 print("ERROR: %s!\n" % msg) 626 sys.exit(1) 627 628 def loop(self): 629 print(""" 630********************************************************* 631* To see available commands, use the TAB key or 'usage' * 632********************************************************* 633""") 634 if self.file: 635 self.script(self.file) 636 if self.exit: 637 sys.exit(0) 638 639 while 1: 640 line = self.command.loop().strip() 641 self.tokens.process_command(line) 642 643 def script(self, filename): 644 self.tokens.do_script("script " + filename) 645 646 def run(self, line): 647 "helper method for running Hatari commands with hatari-console, returns False on error" 648 return self.tokens.process_command(line) 649 650 651if __name__ == "__main__": 652 Main(sys.argv).loop() 653