1from binascii import hexlify
2import hashlib
3import json
4import os
5import struct
6from base64 import b64decode
7from functools import lru_cache
8
9import shutil
10
11from fsbc.paths import Paths
12from fsbc.system import System
13from fsgs.archive import Archive
14from fsgs.drivers.gamedriver import GameDriver, Emulator
15from fsgs.input.enumeratehelper import EnumerateHelper
16from fsgs.input.mapper import InputMapper
17from fsgs.option import Option
18from fsgs.saves import SaveHandler
19
20
21class MednafenDriver(GameDriver):
22    def __init__(self, fsgc, vanilla=False):
23        super().__init__(fsgc)
24        if vanilla:
25            self.emulator = Emulator("mednafen")
26        else:
27            self.emulator = Emulator("mednafen-fs")
28        self.save_handler = MednafenSaveHandler(
29            self.fsgc, options=self.options
30        )
31        # self._game_files_added = False
32
33    def prepare(self):
34        if os.path.exists(os.path.join(self.home.path, ".mednafen")):
35            shutil.rmtree(os.path.join(self.home.path, ".mednafen"))
36        self.save_handler.prepare()
37        # self.temp_home = self.create_temp_dir("mednafen-home")
38        with open(self.mednafen_cfg_path(), "w", encoding="UTF-8") as f:
39            self.mednafen_configure(f)
40        # if not self._game_files_added:
41        game_file = self.get_game_file()
42        if game_file is not None:
43            self.emulator.args.append(game_file)
44
45    def finish(self):
46        self.save_handler.finish()
47
48    def set_mednafen_aspect(self, h, v):
49        if self.stretching() == self.NO_STRETCHING:
50            h, v = self.game_video_size()
51        self.emulator.env["FSGS_ASPECT"] = "{}/{}".format(h, v)
52
53    # FIXME: REPLACE BAD IMPLEMENTATION OF prepare cd images
54
55    @staticmethod
56    def expand_default_path(src, default_dir):
57        if "://" in src:
58            return src, None
59        src = Paths.expand_path(src, default_dir)
60        archive = Archive(src)
61        return src, archive
62
63    def prepare_mednafen_cd_images(self):
64        # self._game_files_added = True
65
66        temp_dir = self.temp_dir("media").path
67        game_file = None
68        # cdrom_drive_0 = self.config.get("cdrom_drive_0", "")
69        # if cdrom_drive_0.startswith("game:"):
70        if True:
71            # scheme, dummy, game_uuid, name = cdrom_drive_0.split("/")
72            # file_list = self.get_file_list_for_game_uuid(game_uuid)
73            file_list = json.loads(self.options[Option.FILE_LIST])
74            for file_item in file_list:
75                src = self.fsgc.file.find_by_sha1(file_item["sha1"])
76
77                src, archive = self.expand_default_path(src, None)
78                dst_name = file_item["name"]
79                # current_task.set_progress(dst_name)
80
81                dst = os.path.join(temp_dir, dst_name)
82                self.fsgc.file.copy_game_file(src, dst)
83
84            # cue_sheets = self.get_cue_sheets_for_game_uuid(game_uuid)
85            cue_sheets = json.loads(self.options[Option.CUE_SHEETS])
86            for i, cue_sheet in enumerate(cue_sheets):
87                # FIXME: Try to get this to work with the PyCharm type checker
88                path = os.path.join(temp_dir, cue_sheet["name"])
89                if i == 0:
90                    game_file = path
91                # noinspection PyTypeChecker
92                with open(path, "wb") as f:
93                    # noinspection PyTypeChecker
94                    f.write(cue_sheet["data"].encode("UTF-8"))
95
96            if self.options[Option.SBI_DATA]:
97                sbi_data = json.loads(self.options[Option.SBI_DATA])
98                for i, sbi_file in enumerate(sbi_data):
99                    path = os.path.join(temp_dir, sbi_file["name"])
100                    with open(path, "wb") as f:
101                        f.write(b64decode(sbi_file["base64"]))
102
103        self.emulator.args.append(game_file)
104
105    def prepare_mednafen_bios(self, known_file, name):
106        bios_path = os.path.join(self.home.path, ".mednafen", name)
107        if not os.path.exists(os.path.dirname(bios_path)):
108            os.makedirs(os.path.dirname(bios_path))
109        src = self.fsgc.file.find_by_sha1(known_file.sha1)
110        if not src:
111            raise Exception(
112                "Could not find {} (SHA-1: {}".format(
113                    known_file.name, known_file.sha1
114                )
115            )
116        self.fsgc.file.copy_game_file(src, bios_path)
117
118    def get_supported_filters(self):
119        supported = [
120            {"name": "2x", "special": "nn2x"},
121            {"name": "none", "special": "none"},
122            {"name": "scale2x", "special": "scale2x"},
123            {"name": "hq2x", "special": "hq2x"},
124        ]
125        return supported
126
127    def mednafen_input_mapping(self, port):
128        raise NotImplementedError()
129
130    def mednafen_system_prefix(self):
131        raise NotImplementedError()
132
133    def game_video_par(self):
134        return 1.0
135
136    def game_video_size(self):
137        raise NotImplementedError("game_video_size must be implemented")
138
139    def mednafen_rom_extensions(self):
140        return []
141
142    def mednafen_scanlines_setting(self):
143        return None
144
145    def mednafen_special_filter(self):
146        return None
147
148    def mednafen_viewport(self):
149        viewport = self.options["viewport"]
150        if viewport:
151            src, dst = viewport.split("=")
152            src = src.strip()
153            dst = dst.strip()
154            src_x, src_y, src_w, src_h = [int(v) for v in src.split(" ")]
155            dst_x, dst_y, dst_w, dst_h = [int(v) for v in dst.split(" ")]
156            return (src_x, src_y, src_w, src_h), (dst_x, dst_y, dst_w, dst_h)
157        return None, None
158
159    def mednafen_configure(self, f):
160        pfx = self.mednafen_system_prefix()
161        self.configure_audio(f)
162        self.configure_input(f)
163        self.configure_video(f)
164
165        cheats_file_name = self.mednafen_system_prefix() + ".cht"
166        cheats_file_path = self.cheats_file(cheats_file_name)
167        if cheats_file_path:
168            self.emulator.args.extend(["-cheats", "0"])
169            cheats_dir = self.temp_dir("cheats").path
170            shutil.copy(
171                cheats_file_path, os.path.join(cheats_dir, cheats_file_name)
172            )
173            # self.emulator.args.extend(["-filesys.path_cheat", cheats_dir])
174            self.emulator.args.extend(["-path_cheat", cheats_dir])
175
176        print("\n" + "-" * 79 + "\n" + "CONFIGURE DIRS")
177
178        self.emulator.args.extend(
179            ["-path_sav", self.save_handler.emulator_save_dir()]
180        )
181        self.emulator.args.extend(
182            ["-path_state", self.save_handler.emulator_state_dir()]
183        )
184
185        # self.emulator.args.extend(["-filesys.fname_state", "%M%X"])
186        # self.emulator.args.extend(["-filesys.fname_sav", "%M%x"])
187
188        self.emulator.args.extend(["-filesys.fname_state", "%f.%X"])
189        self.emulator.args.extend(["-filesys.fname_sav", "%f.%x"])
190
191        # docdir = pyapp.user.documents_dir()
192        self.doc_dir = self.temp_dir("mednafen-docs")
193        self.emulator.args.extend(
194            [
195                "-path_movie",
196                self.doc_dir.path,
197                # "-path_cheat", self.doc_dir.path,
198                "-path_palette",
199                self.home.path,
200            ]
201        )
202
203        self.emulator.args.extend(["-path_snap", self.screenshots_dir()])
204        self.emulator.args.extend(
205            [
206                "-filesys.fname_snap",
207                "{0}-%p.%x".format(self.screenshots_name()),
208            ]
209        )
210
211        self.mednafen_post_configure()
212
213    def configure_audio(self, _):
214        # pfx = self.mednafen_system_prefix()
215        audio_driver = self.options[Option.MEDNAFEN_AUDIO_DRIVER]
216        if audio_driver in ["", "auto"]:
217            if System.windows:
218                pass
219            elif System.macos:
220                pass
221            else:
222                audio_driver = "sdl"
223        if audio_driver == "sdl":
224            # Mednafen does not support PulseAudio directly, but using the
225            # sdl driver will "often" result in PulseAudio being used
226            # indirectly.
227            self.emulator.args.extend(["-sound.driver", "sdl"])
228        elif audio_driver == "alsa":
229            self.emulator.args.extend(
230                ["-sound.device", "sexyal-literal-default"]
231            )
232        else:
233            # Use Mednafen default selection
234            # self.emulator.args.extend(["-sound.device", "default"])
235            pass
236
237        default_buffer_size = 40
238        buffer_size = default_buffer_size
239        if self.options[Option.MEDNAFEN_AUDIO_BUFFER]:
240            try:
241                buffer_size = int(self.options[Option.MEDNAFEN_AUDIO_BUFFER])
242            except ValueError:
243                print("WARNING: Invalid Mednafen audio buffer size specified")
244            else:
245                if buffer_size < 0 or buffer_size > 1000:
246                    print("WARNING: Mednafen audio buffer size out of range")
247                    buffer_size = default_buffer_size
248        self.emulator.args.extend(["-sound.buffer_time", str(buffer_size)])
249
250    def configure_input(self, f):
251        print("\n" + "-" * 79 + "\n" + "CONFIGURE PORTS")
252        for i, port in enumerate(self.ports):
253            input_mapping = self.mednafen_input_mapping(i)
254            mapper = MednafenInputMapper(port, input_mapping)
255            keys = {}
256            for key, value in mapper.items():
257                keys.setdefault(key, []).append(value)
258            for key, values in keys.items():
259                print(repr(key), repr(values))
260                f.write(
261                    "{key} {value}\n".format(
262                        key=key, value=" || ".join(values)
263                    )
264                )
265
266    def configure_video(self, f):
267        pfx = self.mednafen_system_prefix()
268        self.emulator.args.extend(["-video.driver", "opengl"])
269
270        screen_w, screen_h = self.screen_size()
271        # screen_a = screen_w / screen_h
272        border_w, border_h = 0, 0
273
274        # if self.border() == self.NO_BORDER:
275        #     pass
276        # else:
277        #     border_w = screen_h * 32 / 1080
278        #     border_h = screen_h * 32 / 1080
279
280        dest_w, dest_h = screen_w - border_w, screen_h - border_h
281        if not self.use_fullscreen():
282            dest_w, dest_h = 960, 540
283        # dest_a = dest_w /dest_h
284
285        viewport_src, viewport = self.mednafen_viewport()
286        if viewport is None:
287            game_w, game_h = self.game_video_size()
288        else:
289            game_w, game_h = viewport[2], viewport[3]
290        assert game_w
291        assert game_h
292        # game_a = game_w / game_h
293        pixel_a = self.game_video_par()
294
295        if self.scaling() == self.MAX_SCALING:
296            if self.stretching() == self.STRETCH_FILL_SCREEN:
297                scale_y = dest_h / game_h
298                scale_x = dest_w / game_w
299            elif self.stretching() == self.STRETCH_ASPECT:
300                # FIXME: HANDLE TALL RESOLUTIONS
301                scale_y = dest_h / game_h
302                # scale_x = dest_w / (game_w * pixel_a)
303                scale_x = scale_y * pixel_a
304            else:  # Square pixels
305                scale_y = dest_h / game_h
306                scale_x = scale_y
307        elif self.scaling() == self.INTEGER_SCALING:
308            if self.stretching() == self.STRETCH_FILL_SCREEN:
309                scale_y = dest_h // game_h
310                scale_x = dest_w // game_w
311            elif self.stretching() == self.STRETCH_ASPECT:
312                # FIXME: HANDLE TALL RESOLUTIONS
313                scale_y = dest_h // game_h
314                scale_x = scale_y * pixel_a
315            else:  # Square pixels
316                scale_y = dest_h // game_h
317                scale_x = scale_y
318        else:  # NO SCALING
319            scale_x = 1
320            scale_y = 1
321
322        if self.use_doubling():
323            if scale_x == 1 and scale_y == 1:
324                scale_x = 2
325                scale_y = 2
326
327        # self.emulator.args.extend(["-{}.special".format(pfx), "nn2x"])
328
329        if self.smoothing() == self.SMOOTHING:
330            videoip = "1"
331        elif self.smoothing() == self.NON_INTEGER_SMOOTHING:
332            if abs(scale_x - scale_x // 1) < 0.01:
333                videoip = "y"
334            else:
335                videoip = "1"
336            if abs(scale_y - scale_y // 1) < 0.01:
337                if videoip == "y":
338                    videoip = "0"
339                else:
340                    videoip = "x"
341        else:
342            videoip = "0"
343        self.emulator.args.extend(["-{}.videoip".format(pfx), videoip])
344
345        self.emulator.args.extend(["-{}.xscale".format(pfx), str(scale_x)])
346        self.emulator.args.extend(["-{}.yscale".format(pfx), str(scale_y)])
347        self.emulator.args.extend(["-{}.xscalefs".format(pfx), str(scale_x)])
348        self.emulator.args.extend(["-{}.yscalefs".format(pfx), str(scale_y)])
349        self.emulator.args.extend(["-{}.stretch".format(pfx), "0"])
350
351        # Only enable v-sync if game refresh matches screen refresh.
352        if self.configure_vsync():
353            self.emulator.args.extend(["-video.glvsync", "1"])
354        else:
355            self.emulator.args.extend(["-video.glvsync", "0"])
356
357        # Specify fullscreen size and conditionally enable fullscreen mode.
358        self.emulator.args.extend(["-{}.xres".format(pfx), str(screen_w)])
359        self.emulator.args.extend(["-{}.yres".format(pfx), str(screen_h)])
360        if self.use_fullscreen():
361            self.emulator.args.extend(["-fs", "1"])
362        else:
363            self.emulator.args.extend(["-fs", "0"])
364
365        if self.effect() == self.CRT_EFFECT:
366            self.emulator.args.extend(["-{}.shader".format(pfx), "goat"])
367            self.emulator.args.extend(
368                ["-{}.shader.goat.slen".format(pfx), "1"]
369            )
370            special = "none"
371            video_scale = 2
372            min_video_scale = 2
373        elif self.effect() == self.DOUBLE_EFFECT:
374            special = "nn2x"
375            video_scale = 2
376            min_video_scale = 1
377        elif self.effect() == self.HQ2X_EFFECT:
378            special = "hq2x"
379            video_scale = 2
380            min_video_scale = 2
381        elif self.effect() == self.SCALE2X_EFFECT:
382            special = "scale2x"
383            video_scale = 2
384            min_video_scale = 2
385        else:
386            special = "none"
387            video_scale = 1
388            min_video_scale = 1
389
390        if self.scaling() == self.MAX_SCALING:
391            window_w, window_h = 960, 540
392        elif self.scaling() == self.INTEGER_SCALING:
393            window_w = game_w * min_video_scale
394            window_h = game_h * min_video_scale
395            s = min_video_scale + 1
396            print(window_w, window_h)
397            while game_w * s <= 900 and game_h * s <= 700:
398                window_w = game_w * s
399                window_h = game_h * s
400                s += 1
401        else:
402            window_w = game_w * video_scale
403            window_h = game_h * video_scale
404        self.emulator.env["FSGS_WINDOW_SIZE"] = "{},{}".format(
405            window_w, window_h
406        )
407
408        deinterlacer = self.options[Option.MEDNAFEN_DEINTERLACER]
409        if deinterlacer:
410            self.emulator.args.extend(["-video.deinterlacer", deinterlacer])
411
412        temporal_blur = self.options[Option.MEDNAFEN_TEMPORAL_BLUR] == "1"
413        if temporal_blur:
414            self.emulator.args.extend(["-{}.tblur".format(pfx), "1"])
415
416        self.emulator.args.extend(["-{}.special".format(pfx), special])
417        self.emulator.args.extend(self.mednafen_extra_graphics_options())
418
419    def set_mednafen_input_order(self):
420        if System.windows:
421            self.input_device_order = "DINPUT8"
422        self.input_mapping_multiple = False
423
424    def mednafen_extra_graphics_options(self):
425        return []
426
427    def mednafen_post_configure(self):
428        # can be overridden by subclasses
429        pass
430
431    def mednafen_cfg_path(self):
432        if not os.path.exists(os.path.join(self.home.path, ".mednafen")):
433            os.makedirs(os.path.join(self.home.path, ".mednafen"))
434        # return os.path.join(self.home.path, ".mednafen", "mednafen-09x.cfg")
435        return os.path.join(self.home.path, ".mednafen", "mednafen.cfg")
436
437    def is_pal(self):
438        # return self.config.get("ntsc_mode") != "1"
439        # return False
440        refresh_rate = self.get_game_refresh_rate()
441        if refresh_rate:
442            return int(round(refresh_rate)) == 50
443
444
445class MednafenInputMapper(InputMapper):
446    def __init__(self, port, mapping):
447        InputMapper.__init__(self, port, mapping)
448        helper = EnumerateHelper()
449        helper.init()
450        seen_ids = set()
451        self.id_map = {}
452        for device in helper.devices:
453            uid = self.calculate_unique_id(device)
454            while uid in seen_ids:
455                uid += 1
456            seen_ids.add(uid)
457            self.id_map[device.id] = uid
458        print("MednafenInputMapper device map")
459        for id, uid in self.id_map.items():
460            print(uid, id)
461
462    def axis(self, axis, positive):
463        if positive:
464            sign = "+"
465        else:
466            sign = "-"
467        joystick_id = self.unique_id(self.device, self.device.id)
468        return "joystick 0x{:032x} abs_{}{}".format(joystick_id, axis, sign)
469
470    def hat(self, hat, direction):
471        offset = {"left": 3, "right": 1, "up": 0, "down": 2}[direction]
472        joystick_id = self.unique_id(self.device, self.device.id)
473        # Hats after buttons, order: up, right, down, left
474        return "joystick 0x{:032x} button_{}".format(
475            joystick_id, self.device.buttons + hat * 4 + offset
476        )
477
478    def button(self, button):
479        joystick_id = self.unique_id(self.device, self.device.id)
480        return "joystick 0x{:032x} button_{}".format(joystick_id, button)
481
482    def key(self, key):
483        # FIXME: Need other key codes on Windows ... ?
484        # print(key)
485        return "keyboard 0x0 {}".format(key.sdl2_scan_code)
486
487    @lru_cache()
488    def unique_id(self, device, _):
489        try:
490            return self.id_map[device.id]
491        except KeyError:
492            print("id_map:", self.id_map)
493            raise
494
495    @lru_cache()
496    def calculate_unique_id(self, device, version=2):
497        """Implements the joystick ID algorithm in mednafen.
498        Was src/drivers/joystick.cpp:GetJoystickUniqueID
499        Now src/drivers/Joystick.cpp:Calc09xID.
500        """
501        print("get_unique_id for", device.id)
502        if version == 2:
503            print(device)
504            m = hashlib.md5()
505            print("--------------{}----------------".format(device.sdl_name))
506            m.update((device.sdl_name).encode("UTF-8"))
507            # print(m.hexdigest()[:16], device.axes, device.buttons, device.hats, device.balls)
508
509            buffer = struct.pack(
510                ">HHHH", device.axes, device.buttons, device.hats, device.balls
511            )
512            return int(
513                "{}{}".format(
514                    hexlify(m.digest()[:8]).decode("ASCII"),
515                    hexlify(buffer).decode("ASCII"),
516                ),
517                16,
518            )
519            # return "0x{}{:02x}{:02x}{:02x}{:02x}".format(
520            #     m.hexdigest()[:16], device.axes, device.buttons, device.hats, device.balls)
521        else:
522            m = hashlib.md5()
523            print(device.axes, device.balls, device.hats, device.buttons)
524            # noinspection SpellCheckingInspection
525            buffer = struct.pack(
526                "iiii", device.axes, device.balls, device.hats, device.buttons
527            )
528            m.update(buffer)
529            digest = m.digest()
530            ret = 0
531            for x in range(16):
532                # ret ^= ord(digest[x]) << ((x & 7) * 8)
533                ret ^= digest[x] << ((x & 7) * 8)
534            # return "{:x}".format(ret)
535            return ret
536
537
538class MednafenSaveHandler(SaveHandler):
539    def __init__(self, fsgc, options):
540        super().__init__(fsgc, options, emulator="Mednafen")
541        self._srm_alias = ""
542
543    def prepare(self):
544        self.copy_to_srm_alias()
545        super().prepare()
546
547    def finish(self):
548        self.move_from_srm_alias()
549        super().finish()
550
551    def set_srm_alias(self, alias):
552        assert alias.startswith(".")
553        self._srm_alias = alias
554
555    def copy_to_srm_alias(self):
556        if not self._srm_alias:
557            return
558        save_dir = self.save_dir()
559        if not os.path.exists(save_dir):
560            return
561        for full_name in os.listdir(save_dir):
562            src = os.path.join(save_dir, full_name)
563            if not os.path.isfile(src):
564                continue
565            name, ext = os.path.splitext(full_name)
566            if ext not in [".srm"]:
567                continue
568            dst = os.path.join(save_dir, name + self._srm_alias)
569            print("MednafenSaveHandler:", src, "->", dst)
570            shutil.copy(src, dst)
571
572    def move_from_srm_alias(self):
573        if not self._srm_alias:
574            return
575        save_dir = self.save_dir()
576        for full_name in os.listdir(save_dir):
577            src = os.path.join(save_dir, full_name)
578            if not os.path.isfile(src):
579                continue
580            name, ext = os.path.splitext(full_name)
581            if ext != self._srm_alias:
582                continue
583            dst = os.path.join(save_dir, name + ".srm")
584            print("MednafenSaveHandler:", dst, "<-", src)
585            shutil.copy(src, dst)
586            os.remove(src)
587