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