1import os 2import shutil 3from typing import Optional 4 5from fsgs import Option 6from fsgs.FSGSDirectories import FSGSDirectories 7from fsgs.drivers.gamedriver import GameDriver, Emulator 8from fsgs.input.mapper import InputMapper 9from fsgs.plugins.pluginmanager import PluginManager 10from fsgs.saves import SaveHandler 11 12 13class RetroArchDriver(GameDriver): 14 def __init__(self, fsgc, libretro_core, retroarch_state_dir): 15 super().__init__(fsgc) 16 self.emulator = Emulator("retroarch-fs") 17 # self.emulator.allow_system_emulator = True 18 # self.libretro_core = "no_core_specified" 19 self.system_dir = self.temp_dir("system") 20 self.save_handler = RetroArchSaveHandler( 21 self.fsgc, options=self.options, emulator=retroarch_state_dir 22 ) 23 # self.retroarch_state_dir = None 24 25 self.libretro_core = libretro_core 26 self.retroarch_state_dir = retroarch_state_dir 27 # Dictionary with key-values which will be written to retroarch.cfg 28 self.retroarch_config_file = self.temp_file("retroarch.cfg") 29 self.retroarch_config = {} 30 # FIXME 31 self.retroarch_core_config = {} 32 33 def prepare(self): 34 self.erase_old_config() 35 36 # if not os.path.exists(config_dir): 37 # os.makedirs(config_dir) 38 # config_file = os.path.join(config_dir, "retroarch.cfg") 39 40 # FIXME: Do not use /etc/retroarch.cfg as template.. how to prevent? 41 42 self.save_handler.prepare() 43 44 with open(self.retroarch_config_file.path, "w", encoding="UTF-8") as f: 45 self.write_retroarch_config(f) 46 self.write_retroarch_input_config(f) 47 self.write_retroarch_video_config(f) 48 49 self.emulator.args.append( 50 "--appendconfig=" + self.retroarch_config_file.path 51 ) 52 if self.use_fullscreen(): 53 self.emulator.args.append("--fullscreen") 54 55 libretro_core = self.find_libretro_core(self.libretro_core) 56 if not libretro_core: 57 raise Exception( 58 "Could not find libretro core {0!r}".format(self.libretro_core) 59 ) 60 self.emulator.args.extend(["-L", libretro_core]) 61 62 # Verbose logging 63 self.emulator.args.extend(["-v"]) 64 65 def run(self): 66 with open(self.retroarch_config_file.path, "a", encoding="UTF-8") as f: 67 for key, value in self.retroarch_config.items(): 68 f.write('{} = "{}"\n'.format(key, value)) 69 super().run() 70 71 def finish(self): 72 self.save_handler.finish() 73 74 @staticmethod 75 def find_libretro_core(name): 76 # return "/usr/lib/x86_64-linux-gnu/libretro/{}.so".format(name) 77 return PluginManager.instance().find_library_path(name) 78 79 @staticmethod 80 def find_retroarch_shader(name): 81 # FIXME: Better to find data file based on path/provides rather than 82 # hardcoding plugin name, but... 83 plugin = PluginManager.instance().plugin("RetroArch-FS") 84 return plugin.data_file_path("shaders/shaders_glsl/" + name + ".glslp") 85 86 def display_aspect_ratio(self) -> Optional[float]: 87 return 4 / 3 88 89 def game_video_size(self): 90 # FIXME: Dummy values 91 print("[DRIVER] Warning: Using dummy game video size (320, 240)") 92 return 320, 240 93 94 def erase_old_config(self): 95 config_dir = os.path.join(self.home.path, ".config", "retroarch") 96 if os.path.exists(config_dir): 97 shutil.rmtree(config_dir) 98 99 def open_retroarch_core_options(self): 100 config_dir = os.path.join(self.home.path, ".config", "retroarch") 101 if not os.path.exists(config_dir): 102 os.makedirs(config_dir) 103 config_file = os.path.join(config_dir, "retroarch-core-options.cfg") 104 return open(config_file, "w", encoding="UTF-8") 105 106 def write_retroarch_config(self, f): 107 """ 108 joypad_autoconfig_dir = "~/.config/retroarch/autoconfig" 109 """ 110 f.write( 111 'screenshot_directory = "{}"\n'.format( 112 FSGSDirectories.screenshots_output_dir() 113 ) 114 ) 115 f.write('system_directory = "{}"\n'.format(self.system_dir.path)) 116 # noinspection SpellCheckingInspection 117 118 f.write( 119 'savefile_directory = "{}"\n'.format( 120 self.save_handler.emulator_save_dir() 121 ) 122 ) 123 f.write( 124 'savestate_directory = "{}"\n'.format( 125 self.save_handler.emulator_state_dir() 126 ) 127 ) 128 129 # FIXME: Maybe enable autosave to save .srm while running the emulator 130 # and not only on shutdown? 131 # f.write("autosave_interval = 60\n") 132 133 f.write("pause_nonactive = false\n") 134 f.write("video_font_enable = false\n") 135 f.write("rgui_show_start_screen = false\n") 136 f.write("all_users_control_menu = true\n") 137 f.write("video_gpu_screenshot = false\n") 138 139 if self.g_sync(): 140 # Without timed frame limiting, there will be stuttering (probably) 141 # due to (some) audio driver sync not being stable enough to give 142 # a stable frame rate. 143 144 # FIXME: Implement better G-SYNC method in RetroArch 145 f.write("fastforward_ratio = 1.000000\n") 146 147 # FIXME: It's possible the above "fix" would be better for 148 # non-v-sync as well, if the audio sync is not stable. 149 150 # f.write("audio_max_timing_skew = 0.0\n") 151 # f.write("audio_rate_control_delta = 0.0\n") 152 # f.write("video_refresh_rate = 144.0\n") 153 # f.write("audio_sync = false\n") 154 155 default_buffer_size = 40 156 buffer_size = default_buffer_size 157 if self.options[Option.RETROARCH_AUDIO_BUFFER]: 158 try: 159 buffer_size = int(self.options[Option.RETROARCH_AUDIO_BUFFER]) 160 except ValueError: 161 print("WARNING: Invalid RetroArch audio buffer size specified") 162 else: 163 if buffer_size < 0 or buffer_size > 1000: 164 print("WARNING: RetroArch audio buffer size out of range") 165 buffer_size = default_buffer_size 166 f.write("audio_latency = {}\n".format(buffer_size)) 167 168 def write_retroarch_input_config(self, f): 169 f.write('input_driver = "sdl2"\n') 170 f.write('input_enable_hotkey = "alt"\n') 171 f.write('input_exit_emulator = "q"\n') 172 f.write('input_toggle_fast_forward = "w"\n') 173 f.write('input_screenshot = "s"\n') 174 # f.write("input_toggle_fullscreen = \"enter\"\n") 175 f.write('input_toggle_fullscreen = "f"\n') 176 f.write('input_audio_mute = "m"\n') 177 f.write('input_menu_toggle = "f12"\n') 178 f.write('input_pause_toggle = "p"\n') 179 180 for i, port in enumerate(self.ports): 181 if port.device is None: 182 continue 183 input_mapping = self.retroarch_input_mapping(i) 184 # FIXME: EXCLUDE DUPLICATE ITEMS IN INPUT MAPPING??? 185 mapper = RetroArchInputMapper(port, input_mapping) 186 187 f.write('input_player1_joypad_index = "0"\n') 188 f.write('input_player1_analog_dpad_mode = "0"\n') 189 if port.device.type == "joystick": 190 pass 191 else: 192 pass 193 194 for key, value in mapper.items(): 195 # print("---->", key, value) 196 postfix, value = value 197 f.write('{}{} = "{}"\n'.format(key, postfix, value)) 198 postfixes = ["", "_btn", "_axis"] 199 postfixes.remove(postfix) 200 for postfix in postfixes: 201 f.write('{}{} = "{}"\n'.format(key, postfix, "nul")) 202 203 def write_retroarch_video_config(self, f): 204 # f.write("video_driver = \"gl\"\n") 205 # FIXME 206 if self.fullscreen_window_mode(): 207 f.write("video_windowed_fullscreen = true\n") 208 else: 209 f.write("video_windowed_fullscreen = false\n") 210 211 # FIXME: 1 or 0? 212 f.write("video_max_swapchain_images = 1\n") 213 214 if self.effect() == self.CRT_EFFECT: 215 video_shader = "crt/crt-aperture" 216 video_scale = 2 217 elif self.effect() == self.DOUBLE_EFFECT: 218 if self.smoothing() == self.NO_SMOOTHING: 219 video_shader = "" 220 else: 221 video_shader = "retro/sharp-bilinear-2x-prescale" 222 video_scale = 2 223 elif self.effect() == self.HQ2X_EFFECT: 224 video_shader = "hqx/hq2x" 225 video_scale = 2 226 elif self.effect() == self.SCALE2X_EFFECT: 227 video_shader = "scalenx/scale2x" 228 video_scale = 2 229 else: 230 video_shader = "" 231 video_scale = 1 232 233 if self.smoothing() == self.NO_SMOOTHING or video_shader: 234 f.write("video_smooth = false\n") 235 236 if video_shader: 237 print("[DRIVER] Video shader:", video_shader) 238 video_shader_path = self.find_retroarch_shader(video_shader) 239 print("[DRIVER] Video shader path:", video_shader_path) 240 if video_shader_path: 241 f.write('video_shader = "{}"\n'.format(video_shader_path)) 242 f.write("video_shader_enable = true\n") 243 244 # FIXME: video_monitor_index = 0 245 # FIXME: video_disable_composition = true 246 if self.use_vsync(): 247 f.write("video_vsync = true\n") 248 else: 249 f.write("video_vsync = false\n") 250 251 # aspect_ratio_index = 22 252 # custom_viewport_width = 0 253 # custom_viewport_height = 0 254 # custom_viewport_x = 0 255 # custom_viewport_y = 0 256 257 if self.stretching() == self.STRETCH_ASPECT: 258 aspect_ratio = self.display_aspect_ratio() 259 if aspect_ratio is not None: 260 f.write("aspect_ratio_index = 19\n") 261 f.write("video_aspect_ratio = {:f}\n".format(aspect_ratio)) 262 # f.write("video_force_aspect = \"true\"\n") 263 # f.write("video_aspect_ratio_auto = \"false\"\n") 264 elif self.stretching() == self.STRETCH_FILL_SCREEN: 265 screen_w, screen_h = self.screen_size() 266 display_aspect = screen_w / screen_h 267 f.write("aspect_ratio_index = 19\n") 268 f.write("video_aspect_ratio = {:f}\n".format(display_aspect)) 269 else: 270 f.write("aspect_ratio_index = 20\n") 271 272 if self.scaling() == self.NO_SCALING: 273 # FIXME: Window size rounding issues? E.g. 897x672 for 4:3 274 # NES 3x display. Maybe set window size manually instead 275 f.write("video_scale = {}\n".format(video_scale)) 276 277 # f.write("input_osk_toggle = \"nul\"\n") 278 overlay_path = self.create_retroarch_layout() 279 if overlay_path: 280 f.write('input_overlay = "{}"\n'.format(overlay_path)) 281 f.write('input_overlay_opacity = "1.000000"\n') 282 283 def retroarch_input_mapping(self, port): 284 return {} 285 286 def display_rect_fullscreen(self): 287 # FIXME: Check square pixels option! 288 289 screen_w, screen_h = self.screen_size() 290 screen_aspect = screen_w / screen_h 291 292 if self.stretching() == self.STRETCH_ASPECT: 293 display_aspect = self.display_aspect_ratio() 294 elif self.stretching() == self.STRETCH_FILL_SCREEN: 295 screen_w, screen_h = self.screen_size() 296 display_aspect = screen_w / screen_h 297 else: 298 game_w, game_h = self.game_video_size() 299 display_aspect = game_w / game_h 300 301 # FIXME: round to nearest multiple of two? 302 if screen_aspect >= display_aspect: 303 game_h = screen_h 304 game_w = round(game_h * display_aspect) 305 else: 306 game_w = screen_w 307 game_h = round(game_w / display_aspect) 308 game_x = round((screen_w - game_w) / 2) 309 game_y = round((screen_h - game_h) / 2) 310 return game_x, game_y, game_w, game_h 311 312 def create_retroarch_layout(self): 313 if self.stretching() == self.STRETCH_FILL_SCREEN or not self.bezel(): 314 return 315 if not self.use_fullscreen(): 316 return 317 318 # FIXME: file cmp? 319 paths = self.prepare_emulator_skin() 320 print(paths) 321 322 # FIXME: SUPPORT frame = 0 ( bezel = 0) option 323 324 # FIXME: With no bezel, we should still use a black bezel to 325 # hide screen stretching 326 327 screen_width, screen_height = self.screen_size() 328 # dst_x = 0 329 dst_y = 0 330 # dst_w = 0 331 # dst_w = 160 332 dst_h = screen_height 333 334 # Bezel size is normalized against 1080 (height) 335 scale = screen_height / 1080 336 # Bezel width: 160 337 dst_w = round(160 * scale) 338 339 game_x, game_y, game_w, game_h = self.display_rect_fullscreen() 340 341 from fsui.qt import Qt, QImage, QPainter, QRect, QSize 342 343 image = QImage( 344 QSize(screen_width, screen_height), QImage.Format_RGBA8888 345 ) 346 image.fill(Qt.transparent) 347 # painter = image.paintEngine() 348 painter = QPainter(image) 349 350 dst_x = game_x - dst_w 351 left = QImage(paths["left"]) 352 painter.drawImage(QRect(dst_x, dst_y, dst_w, dst_h), left) 353 354 dst_x = game_x + game_w 355 right = QImage(paths["right"]) 356 painter.drawImage(QRect(dst_x, dst_y, dst_w, dst_h), right) 357 painter.end() 358 359 overlay_png_file = self.temp_file("overlay.png").path 360 image.save(overlay_png_file) 361 362 # noinspection SpellCheckingInspection 363 overlay_config = """overlays = 1 364overlay0_overlay = {overlay} 365overlay0_full_screen = true 366overlay0_rect = "0.0,0.0,1.0,1.0" 367overlay0_descs = 0 368""".format( 369 overlay=overlay_png_file 370 ) 371 # overlay_config = ( 372 # """overlays = 2 373 # overlay0_overlay = {left} 374 # overlay0_full_screen = true 375 # overlay0_rect = "0.0,0.0,0.12,1.0" 376 # overlay0_descs = 0 377 # overlay1_overlay = {right} 378 # overlay1_full_screen = true 379 # overlay1_rect = "0.8,0.0,0.2,1.0" 380 # overlay1_descs = 0 381 # 382 # """.format(left=paths["left"], right=paths["right"])) 383 overlay_config_file = self.temp_file("overlay.cfg") 384 with open(overlay_config_file.path, "w") as f: 385 f.write(overlay_config) 386 return overlay_config_file.path 387 388 def window_size(self): 389 return 0, 0 390 391 392class RetroArchInputMapper(InputMapper): 393 def __init__(self, port, mapping): 394 super().__init__(port, mapping, multiple=False) 395 396 def axis(self, axis, positive): 397 dir_str = "+" if positive else "-" 398 return "_axis", dir_str + str(axis) 399 400 def hat(self, hat, direction): 401 return "_btn", "h{}{}".format(hat, direction) 402 403 def button(self, button): 404 return "_btn", button 405 406 def key(self, key): 407 # FIXME: Correct RetroArch key names 408 name = key.sdl_name[5:].lower() 409 if name == "return": 410 # FIXME: HACK 411 name = "enter" 412 return "", name 413 414 415class RetroArchSaveHandler(SaveHandler): 416 def __init__(self, fsgc, options, emulator): 417 super().__init__(fsgc, options, emulator=emulator) 418 419 def prepare(self): 420 super().prepare() 421 422 def finish(self): 423 super().finish() 424