1# FSGS - Common functionality for FS Game System.
2# Copyright (C) 2013-2019  Frode Solheim <frode@solheim.dev>
3#
4# This program is free software; you can redistribute it and/or modify it
5# under the terms of the GNU General Public License as published by the Free
6# Software Foundation; either version 2 of the License, or (at your option)
7# any later version.
8#
9# This program is distributed in the hope that it will be useful, but WITHOUT
10# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
12# more details.
13#
14# You should have received a copy of the GNU General Public License along
15# with this program; if not, write to the Free Software Foundation, Inc.,
16# 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
17"""
18FSGS Game Driver for Commodore 64 (C64).
19
20TODO:
21
22* Handle V-SYNC
23* Gamepad select button -> toggle status bar?
24* Cleanup temp files
25* Consider gamepad mappings for tape control
26* Ability to select input devices, and control device type via database config
27* Config key to set C64 model
28* Gamepad button to open attack disk / tape menu?
29* Handle save games properly (did C64 games save to tape? disk only?)
30* Alt+S for screenshot and store screenshots to Screenshots dir
31* Mouse support (need a use case / test game)
32
33FIXME:
34
35* FIXME: Positional keyboard support
36  Keyboard: Error - Cannot load keymap `sdl_pos.vkm'.
37
38"""
39import json
40import os
41
42import shutil
43
44from fsbc.system import windows, macosx
45from fsgs.drivers.gamedriver import GameDriver
46from fsgs.input.mapper import InputMapper
47from fsgs.util import sdl2
48from fsgs.option import Option
49from fsgs.platform import Platform
50from fsgs.platforms.loader import SimpleLoader
51
52C64_MODEL_C64 = "c64"
53C64_MODEL_C64C = "c64c"
54C64_MODEL_C64C_1541_II = "c64c/1541-ii"
55C64_JOYSTICK = {
56    "type": "joystick",
57    "description": "Joystick",
58    "mapping_name": "c64",
59}
60C64_PORTS = [
61    {
62        "description": "Port 2",
63        "types": [C64_JOYSTICK],
64        "type_option": "c64_port_2_type",
65        "device_option": "c64_port_2",
66    },
67    {
68        "description": "Port 1",
69        "types": [C64_JOYSTICK],
70        "type_option": "c64_port_1_type",
71        "device_option": "c64_port_1",
72    },
73]
74C64_VIDEO_WIDTH = 384
75C64_VIDEO_HEIGHT = 272
76
77VICE_KEY_SET_A = 2
78VICE_KEY_SET_B = 3
79VICE_JOYSTICK = 4
80
81
82class Commodore64Platform(Platform):
83    PLATFORM_NAME = "Commodore 64"
84
85    def __init__(self):
86        super().__init__(Commodore64Loader, Commodore64ViceDriver)
87
88    def driver(self, fsgc):
89        return Commodore64ViceDriver(fsgc)
90
91    def loader(self, fsgc):
92        return Commodore64Loader(fsgc)
93
94
95class Commodore64Loader(SimpleLoader):
96    def load_files(self, values):
97        file_list = json.loads(values["file_list"])
98        # assert len(file_list) == 1
99        for i, item in enumerate(file_list):
100            _, ext = os.path.splitext(item["name"])
101            ext = ext.upper()
102            if ext in [".TAP", ".T64"]:
103                if i == 0:
104                    self.config["tape_drive_0"] = "sha1://{}/{}".format(
105                        item["sha1"], item["name"]
106                    )
107                self.config[
108                    "tape_image_{0}".format(i)
109                ] = "sha1://{}/{}".format(item["sha1"], item["name"])
110            elif ext in [".D64"]:
111                if i == 0:
112                    self.config["floppy_drive_0"] = "sha1://{}/{}".format(
113                        item["sha1"], item["name"]
114                    )
115                self.config[
116                    "floppy_image_{}".format(i)
117                ] = "sha1://{0}/{1}".format(item["sha1"], item["name"])
118
119    def load_extra(self, values):
120        # FIXME: Replace with c64_model?
121        self.config[Option.C64_MODEL] = values["model"]
122        if not self.config[Option.C64_MODEL]:
123            self.config[Option.C64_MODEL] = C64_MODEL_C64C
124        self.config["c64_port_1_type"] = values["c64_port_1_type"]
125        self.config["c64_port_2_type"] = values["c64_port_2_type"]
126        # FIXME: Remove?
127        self.config["model"] = ""
128
129
130class Commodore64ViceDriver(GameDriver):
131    PORTS = C64_PORTS
132
133    def __init__(self, fsgs):
134        super().__init__(fsgs)
135        self.emulator.name = "x64sc-fs"
136        self.helper = Commodore64Helper(self.options)
137
138    def prepare(self):
139        dot_vice_dir = os.path.join(self.home.path, ".vice")
140        if os.path.exists(dot_vice_dir):
141            shutil.rmtree(dot_vice_dir)
142        os.makedirs(dot_vice_dir)
143        dot_vice_dir = os.path.join(self.home.path, ".config", ".vice")
144        if os.path.exists(dot_vice_dir):
145            shutil.rmtree(dot_vice_dir)
146        os.makedirs(dot_vice_dir)
147
148        # noinspection SpellCheckingInspection
149        joymap_file = self.temp_file("joymap.vjm").path
150        with open(joymap_file, "w", encoding="UTF-8") as f:
151            self.create_joymap_file(f)
152        # noinspection SpellCheckingInspection
153        if windows:
154            # Not using normpath because os.sep can be "/" on MSYS2,
155            # and Vice on Windows really requires backslashes here.
156            joymap_file = joymap_file.replace("/", "\\")
157        self.emulator.args.extend(["-joymap", joymap_file])
158
159        hotkey_file = self.temp_file("hotkey.vkm").path
160        with open(hotkey_file, "w", encoding="UTF-8") as f:
161            self.create_hotkey_file(f)
162        # noinspection SpellCheckingInspection
163        if windows:
164            hotkey_file = hotkey_file.replace("/", "\\")
165        self.emulator.args.extend(["-hotkeyfile", hotkey_file])
166
167        config_file = self.temp_file("vice.cfg").path
168        with open(config_file, "w", encoding="UTF-8") as f:
169            self.create_vice_cfg(f)
170            self.configure_audio(f)
171            self.configure_input(f)
172            self.configure_video(f)
173        self.emulator.args.extend(["-config", config_file])
174
175        # self.emulator.args.extend(["-model", "c64"])
176        if self.helper.model() == C64_MODEL_C64:
177            self.set_model_name("Commodore C64")
178            self.emulator.args.extend(["-model", "c64"])
179        elif self.helper.model() == C64_MODEL_C64C_1541_II:
180            self.set_model_name("Commodore C64C 1541-II")
181            self.emulator.args.extend(["-model", "c64c"])
182        else:
183            self.set_model_name("Commodore C64C")
184            self.emulator.args.extend(["-model", "c64c"])
185
186        media_keys = ["floppy_drive_0", "tape_drive_0"]
187        for i in range(20):
188            media_keys.append("floppy_image_{0}".format(i))
189            media_keys.append("tape_image_{0}".format(i))
190        unique_uris = set()
191        for key in media_keys:
192            if self.options[key]:
193                unique_uris.add(self.options[key])
194        # media_dir = self.temp_dir("media")
195        # VICE uses CWD as default directory for media files
196        media_dir = self.cwd
197        for file_uri in unique_uris:
198            input_stream = self.fsgc.file.open(file_uri)
199            game_file = os.path.join(media_dir.path, file_uri.split("/")[-1])
200            with open(game_file, "wb") as f:
201                f.write(input_stream.read())
202
203        if self.options[Option.TAPE_DRIVE_0]:
204            file_uri = self.options[Option.TAPE_DRIVE_0]
205        else:
206            file_uri = self.options[Option.FLOPPY_DRIVE_0]
207
208        autostart_file = os.path.join(media_dir.path, file_uri.split("/")[-1])
209        self.emulator.args.extend(["-autostart", autostart_file])
210
211    def finish(self):
212        pass
213
214    def create_vice_cfg(self, f):
215        f.write("[C64SC]\n")
216        f.write("ConfirmOnExit=0\n")
217        # f.write("KeepAspectRatio=1\n")
218        f.write("VICIIDoubleScan=1\n")
219        f.write("HwScalePossible=1\n")
220        # 0 means SDL_FULLSCREEN_DESKTOP - 1 means SDL_FULLSCREEN
221        f.write("VICIISDLFullscreenMode=0\n")
222        f.write("AutostartWarp=0\n")
223        f.write("\n")
224
225        if self.helper.has_floppy_drive():
226            f.write("Drive8Type=1541\n")
227        else:
228            # f.write("FileSystemDevice8=0\n")
229            f.write("Drive8Type=None\n")
230        f.write("\n")
231
232        # Virtual device traps?
233        # f.write("VirtualDevices=1\n")
234
235        # print("set_media_options")
236        # media_list = self.create_media_list()
237        # print("sort media list")
238        # # FIXME: SORT ON name (lowercase) ONLY, not whole path, because
239        # # some files may have moved to another dir (temp dir)
240        # media_list = sorted(media_list)
241        # print(media_list)
242        # if media_list[0].lower()[-4:] == ".crt":
243        #     f.write("CartridgeFile=\"{path}\"\n".format(path=media_list[0]))
244        #     f.write("CartridgeType={type}\n".format(type=0))
245        #     f.write("CartridgeMode={mode}\n".format(mode=0))
246        #     f.write("CartridgeReset={reset}\n".format(reset=1))
247        # #    #self.args.extend(["-autostart", media_list[0]])
248        # else:
249        #     #f.write('AutostartPrgMode=2\n') # disk image
250        #     #f.write('AutostartPrgDiskImage="{path}"\n'.format(
251        #     #        path=media_list[0]))
252        #     self.add_arg("-autostart", media_list[0])
253
254        # # FIXME: Floppies?
255        # print(media_list)
256        # if media_list[0].lower()[-4:] == ".d64":
257        #     f = self.context.temp.file('fliplist')
258        #     f.write("# Vice fliplist file\n\n")
259        #     f.write("UNIT 8\n")
260        #     print("FLIP LIST:")
261        #     # Make sure first disk is added to the end of the fliplist
262        #     for floppy in (media_list[1:] + [media_list[:1]]):
263        #         print("%s\n" % (floppy,))
264        #         print
265        #         f.write("%s\n" % (floppy,))
266        #     f.close()
267        #     self.add_arg("-flipname", self.context.temp.file("fliplist"))
268        #
269        # media_dir = os.path.dirname(media_list[0])
270        # print(media_dir)
271        # f.write("InitialDefaultDir=\"{dir}\"\n".format(dir=media_dir))
272        # f.write("InitialTapeDir=\"{dir}\"\n".format(dir=media_dir))
273        # f.write("InitialCartDir=\"{dir}\"\n".format(dir=media_dir))
274        # f.write("InitialDiskDir=\"{dir}\"\n".format(dir=media_dir))
275        # f.write("InitialAutostartDir=\"{dir}\"".format(dir=media_dir))
276
277    # def vice_prepare_floppies(self):
278    #     floppies = []
279    #     #media_dir = os.path.dirname(self.context.game.file)
280    #     #base_match = self.extract_match_name(os.path.basename(
281    #     #        self.context.game.file))
282    #     #for name in os.listdir(media_dir):
283    #     #    dummy, ext = os.path.splitext(name)
284    #     #    if ext.lower() not in ['.st']:
285    #     #        continue
286    #     #    match = self.extract_match_name(name)
287    #     #    if base_match == match:
288    #     #        floppies.append(os.path.join(media_dir, name))
289    #     #        #floppies.append(name)
290    #     if self.config["floppy_drive_0"]:
291    #         floppies.append(self.config["floppy_drive_0"])
292    #     return floppies
293
294    def configure_audio(self, f):
295        # Seems to be some issues with sound in VICE (buffers filling up,
296        # slowing down the emulation?). Using defaults for now (uses big
297        # buffers by default, only seems to delay the problem).
298        # return
299        audio_driver = self.options[Option.VICE_AUDIO_DRIVER]
300        audio_driver = "sdl"
301        f.write("SoundBufferSize={0}\n".format(50))
302        if audio_driver:
303            print("[VICE] Using audio driver", repr(audio_driver))
304            f.write("SoundDeviceName={0}\n".format(audio_driver))
305        # else:
306        #     audio_driver = self.config.get("audio_driver", "")
307        #     if audio_driver == "sdl":
308        #         f.write("SoundDeviceName={0}\n".format(audio_driver))
309
310        if self.use_audio_frequency():
311            f.write("SoundSampleRate={0}\n".format(self.use_audio_frequency()))
312        # default buffer size for vice is 100ms, that's far too much...
313        # EDIT: Lowering the sound buffer size seems to cause FPS problems
314        # Maybe due to buffer filling up and emu running slower (??)
315        # f.write("SoundBufferSize={0}\n".format(50))
316        if self.options[Option.FLOPPY_DRIVE_VOLUME] == 0:
317            f.write("DriveSoundEmulation=0\n")
318        else:
319            # f.write("DriveSoundEmulationVolume=1200\n")
320            f.write("DriveSoundEmulation=1\n")
321        f.write("\n")
322
323    def configure_input(self, f):
324        # FIXME: Enable when ready
325        # f.write("KeymapIndex=1\n")  # Use positional keys
326
327        for i, port in enumerate(self.ports):
328            vice_port = [2, 1, 3, 4][i]
329            # vice_port = i + 1
330            if port.device is None:
331                vice_port_type = 0
332            elif port.device.type == "joystick":
333                vice_port_type = VICE_JOYSTICK
334            elif port.device.type == "keyboard":
335                vice_port_type = VICE_KEY_SET_A + i
336                # assert False
337            else:
338                vice_port_type = 0
339            f.write("JoyDevice{0}={1}\n".format(vice_port, vice_port_type))
340            print(
341                "[INPUT] Port",
342                port.type_option,
343                "VicePort",
344                vice_port,
345                port.type,
346                port.device,
347            )
348
349            if port.device is None:
350                continue
351            if port.device.type != "keyboard":
352                continue
353            input_mapping = {
354                "1": "KeySet{0}Fire".format(i + 1),
355                "UP": "KeySet{0}North".format(i + 1),
356                "DOWN": "KeySet{0}South".format(i + 1),
357                "LEFT": "KeySet{0}West".format(i + 1),
358                "RIGHT": "KeySet{0}East".format(i + 1),
359            }
360            mapper = ViceInputMapper(port, input_mapping)
361            for key, value in mapper.items():
362                f.write("{0}={1}\n".format(key, value))
363        f.write("\n")
364
365    def configure_video(self, f):
366        # Enable the "Pepto" palette (http://www.pepto.de/projects/colorvic/)
367        # f.write("VICIIPaletteFile=\"vice\"\n")
368        palette_file = self.options[Option.C64_PALETTE]
369        if not palette_file:
370            # palette_file = "pepto-ntsc-sony"
371            # palette_file = "vice"
372            # palette_file = "community-colors"
373            palette_file = "0"
374        if palette_file != "0":
375            f.write("VICIIExternalPalette=1\n")
376            f.write('VICIIPaletteFile="{}"\n'.format(palette_file))
377
378        f.write("VICIIAudioLeak=1\n")
379
380        if self.effect() == self.CRT_EFFECT:
381            f.write("VICIIFilter=1\n")
382        elif self.effect() == self.DOUBLE_EFFECT:
383            f.write("VICIIFilter=0\n")
384        elif self.effect() == self.HQ2X_EFFECT:
385            # HQ2X is not supported
386            f.write("VICIIFilter=0\n")
387        elif self.effect() == self.SCALE2X_EFFECT:
388            f.write("VICIIFilter=2\n")
389        else:
390            f.write("VICIIFilter=0\n")
391            f.write("VICIIDoubleSize=0\n")
392
393        screen_w, screen_h = self.screen_size()
394
395        if self.use_fullscreen():
396            f.write("VICIIFullscreen=1\n")
397
398        f.write("SDLWindowWidth={w}\n".format(w=960))
399        f.write("SDLWindowHeight={h}\n".format(h=540))
400        f.write("SDLCustomWidth={w}\n".format(w=screen_w))
401        f.write("SDLCustomHeight={h}\n".format(h=screen_h))
402
403        if self.scaling() == self.MAX_SCALING:
404            f.write("VICIIHwScale=1\n")
405        elif self.scaling() == self.INTEGER_SCALING:
406            # FIXME: Does not support this yet, disabling scaling
407            f.write("VICIIHwScale=0\n")
408        else:
409            f.write("VICIIHwScale=0\n")
410
411        if self.stretching() == self.STRETCH_FILL_SCREEN:
412            f.write("SDLGLAspectMode=0\n")
413        elif self.stretching() == self.STRETCH_ASPECT:
414            f.write("SDLGLAspectMode=2\n")
415        else:
416            f.write("SDLGLAspectMode=1\n")
417
418        # if self.border() == self.LARGE_BORDER:
419        #     f.write("VICIIBorderMode=1\n")
420        # elif self.border() == self.SMALL_BORDER:
421        #     # Value 4 is an FSGS extension in Vice-FS.
422        #     f.write("VICIIBorderMode=4\n")
423        # else:
424        #     f.write("VICIIBorderMode=3\n")
425
426        # Experimental "540 border mode"
427        f.write("VICIIBorderMode=5\n")
428
429        # # Disable scanlines in CRT mode
430        # f.write("VICIIPALScanLineShade=1000\n")
431        # Disable scanlines in CRT mode
432        f.write("VICIIPALScanLineShade=850\n")
433
434        # Can this be used to set fullscreen desktop, etc?
435        # f.write("VICIISDLFullscreenMode = 1\n")
436
437        f.write("\n")
438
439    def create_joymap_file(self, f):
440        # VICE joystick mapping file
441        #
442        # A joystick map is read in as patch to the current map.
443        #
444        # File format:
445        # - comment lines start with '#'
446        # - keyword lines start with '!keyword'
447        # - normal line has 'joynum inputtype inputindex action'
448        #
449        # Keywords and their lines are:
450        # '!CLEAR'    clear all mappings
451        #
452        # inputtype:
453        # 0      axis
454        # 1      button
455        # 2      hat
456        # 3      ball
457        #
458        # Note that each axis has 2 inputindex entries and each hat has 4.
459        #
460        # action [action_parameters]:
461        # 0               none
462        # 1 port pin      joystick (pin: 1/2/4/8/16 = u/d/l/r/fire)
463        # 2 row col       keyboard
464        # 3               map
465        # 4               UI activate
466        # 5 path&to&item  UI function
467        #
468        f.write("!CLEAR\n")
469        for i, port in enumerate(self.ports):
470            if port.device is None:
471                continue
472            if port.device.type != "joystick":
473                continue
474            if i == 0:
475                input_mapping = {
476                    "1": "1 1 16 ",
477                    "UP": "1 1 1",
478                    "DOWN": "1 1 2",
479                    "LEFT": "1 1 4",
480                    "RIGHT": "1 1 8",
481                    "MENU": "4",
482                }
483            elif i == 1:
484                input_mapping = {
485                    "1": "1 0 16 ",
486                    "UP": "1 0 1",
487                    "DOWN": "1 0 2",
488                    "LEFT": "1 0 4",
489                    "RIGHT": "1 0 8",
490                    "MENU": "4",
491                }
492            else:
493                raise Exception("Invalid port")
494            mapper = ViceInputMapper(port, input_mapping)
495            for key, value in mapper.items():
496                f.write("{0} {1}\n".format(value, key))
497
498    def create_hotkey_file(self, f):
499        # noinspection SpellCheckingInspection
500        hotkeys = [
501            (sdl2.SDLK_i, "Statusbar"),
502            # (112, "Pause"),  # Mod+Pause
503            # (113, "Quit emulator"),  # Mod+Q
504            # ALT+S
505            # (115, "Snapshot&Save snapshot image"),
506            # (115, "Screenshot&Save PNG screenshot"),  # Mod+S
507            (
508                sdl2.SDLK_s,
509                "Save media file&Create screenshot&Save PNG screenshot",
510            ),
511            (sdl2.SDLK_RETURN, "Video settings&Size settings&Fullscreen"),
512            # (119, "Speed settings&Warp mode"),  # Mod+W
513        ]
514        if macosx:
515            mod = 8  # Cmd key
516        else:
517            mod = 2  # Left alt key
518        # f.write("!CLEAR\n")
519        for key, action in hotkeys:
520            f.write(
521                "{0} {1}\n".format(sdl2.SDL_NUM_SCANCODES * mod + key, action)
522            )
523
524
525class ViceInputMapper(InputMapper):
526    def axis(self, axis, positive):
527        offset = 0 if positive else 1
528        return "{0} 0 {1}".format(self.device.index, axis * 2 + offset)
529
530    def hat(self, hat, direction):
531        offset = {"left": 2, "right": 3, "up": 0, "down": 1}[direction]
532        return "{0} 2 {1}".format(self.device.index, hat * 4 + offset)
533
534    def button(self, button):
535        return "{0} 1 {1}".format(self.device.index, button)
536
537    def key(self, key):
538        return "{0}".format(key.sdl_code)
539
540
541class Commodore64Helper:
542    def __init__(self, options):
543        self.options = options
544
545    def accuracy(self):
546        try:
547            accuracy = int(self.options.get(Option.ACCURACY, "1"))
548        except ValueError:
549            accuracy = 1
550        return accuracy
551
552    def has_floppy_drive(self):
553        if self.model() == C64_MODEL_C64C_1541_II:
554            return "1541-II"
555        return None
556
557    def model(self):
558        if self.options[Option.C64_MODEL] == "c64":
559            return C64_MODEL_C64
560        elif self.options[Option.C64_MODEL] == "c64c":
561            return C64_MODEL_C64C
562        elif self.options[Option.C64_MODEL] == "c64c/1541-ii":
563            return C64_MODEL_C64C_1541_II
564        return C64_MODEL_C64C
565