gemba 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. checksums.yaml +4 -4
  2. data/THIRD_PARTY_NOTICES +37 -2
  3. data/assets/placeholder_boxart.png +0 -0
  4. data/bin/gemba +2 -2
  5. data/ext/gemba/extconf.rb +23 -1
  6. data/ext/gemba/gemba_ext.c +436 -2
  7. data/ext/gemba/gemba_ext.h +2 -0
  8. data/gemba.gemspec +5 -3
  9. data/lib/gemba/achievements/achievement.rb +23 -0
  10. data/lib/gemba/achievements/backend.rb +190 -0
  11. data/lib/gemba/achievements/cache.rb +70 -0
  12. data/lib/gemba/achievements/credentials_presenter.rb +142 -0
  13. data/lib/gemba/achievements/fake_backend.rb +205 -0
  14. data/lib/gemba/achievements/null_backend.rb +11 -0
  15. data/lib/gemba/achievements/offline_backend.rb +168 -0
  16. data/lib/gemba/achievements/retro_achievements/backend.rb +511 -0
  17. data/lib/gemba/achievements/retro_achievements/cli_sync_requester.rb +64 -0
  18. data/lib/gemba/achievements/retro_achievements/ping_worker.rb +28 -0
  19. data/lib/gemba/achievements/retro_achievements/unlock_retry_worker.rb +35 -0
  20. data/lib/gemba/achievements.rb +19 -0
  21. data/lib/gemba/achievements_window.rb +556 -0
  22. data/lib/gemba/app_controller.rb +1036 -0
  23. data/lib/gemba/bios.rb +54 -0
  24. data/lib/gemba/boxart_fetcher/libretro_backend.rb +39 -0
  25. data/lib/gemba/boxart_fetcher/null_backend.rb +12 -0
  26. data/lib/gemba/boxart_fetcher.rb +79 -0
  27. data/lib/gemba/bus_emitter.rb +13 -0
  28. data/lib/gemba/child_window.rb +24 -1
  29. data/lib/gemba/cli/commands/config_cmd.rb +83 -0
  30. data/lib/gemba/cli/commands/decode.rb +154 -0
  31. data/lib/gemba/cli/commands/patch.rb +78 -0
  32. data/lib/gemba/cli/commands/play.rb +78 -0
  33. data/lib/gemba/cli/commands/record.rb +114 -0
  34. data/lib/gemba/cli/commands/replay.rb +161 -0
  35. data/lib/gemba/cli/commands/retro_achievements.rb +213 -0
  36. data/lib/gemba/cli/commands/version.rb +22 -0
  37. data/lib/gemba/cli.rb +52 -364
  38. data/lib/gemba/config.rb +154 -1
  39. data/lib/gemba/data/gb_games.json +1 -0
  40. data/lib/gemba/data/gb_md5.json +1 -0
  41. data/lib/gemba/data/gba_games.json +1 -0
  42. data/lib/gemba/data/gba_md5.json +1 -0
  43. data/lib/gemba/data/gbc_games.json +1 -0
  44. data/lib/gemba/data/gbc_md5.json +1 -0
  45. data/lib/gemba/emulator_frame.rb +1084 -0
  46. data/lib/gemba/event_bus.rb +48 -0
  47. data/lib/gemba/frame_stack.rb +70 -0
  48. data/lib/gemba/game_index.rb +84 -0
  49. data/lib/gemba/game_picker_frame.rb +309 -0
  50. data/lib/gemba/gamepad_map.rb +103 -0
  51. data/lib/gemba/headless.rb +6 -5
  52. data/lib/gemba/headless_player.rb +33 -3
  53. data/lib/gemba/help_window.rb +61 -0
  54. data/lib/gemba/hotkey_map.rb +3 -1
  55. data/lib/gemba/input_recorder.rb +107 -0
  56. data/lib/gemba/input_replayer.rb +119 -0
  57. data/lib/gemba/keyboard_map.rb +90 -0
  58. data/lib/gemba/list_picker_frame.rb +271 -0
  59. data/lib/gemba/locales/en.yml +109 -5
  60. data/lib/gemba/locales/ja.yml +109 -5
  61. data/lib/gemba/main_window.rb +56 -0
  62. data/lib/gemba/modal_stack.rb +81 -0
  63. data/lib/gemba/patcher_window.rb +223 -0
  64. data/lib/gemba/platform/gb.rb +21 -0
  65. data/lib/gemba/platform/gba.rb +21 -0
  66. data/lib/gemba/platform/gbc.rb +23 -0
  67. data/lib/gemba/platform.rb +20 -0
  68. data/lib/gemba/platform_open.rb +19 -0
  69. data/lib/gemba/recorder.rb +4 -3
  70. data/lib/gemba/replay_player.rb +691 -0
  71. data/lib/gemba/rom_info.rb +57 -0
  72. data/lib/gemba/rom_info_window.rb +16 -3
  73. data/lib/gemba/rom_library.rb +106 -0
  74. data/lib/gemba/rom_overrides.rb +47 -0
  75. data/lib/gemba/rom_patcher/bps.rb +161 -0
  76. data/lib/gemba/rom_patcher/ips.rb +101 -0
  77. data/lib/gemba/rom_patcher/ups.rb +119 -0
  78. data/lib/gemba/rom_patcher.rb +109 -0
  79. data/lib/gemba/{rom_loader.rb → rom_resolver.rb} +7 -6
  80. data/lib/gemba/runtime.rb +59 -26
  81. data/lib/gemba/save_state_manager.rb +4 -7
  82. data/lib/gemba/save_state_picker.rb +17 -4
  83. data/lib/gemba/session_logger.rb +64 -0
  84. data/lib/gemba/settings/audio_tab.rb +77 -0
  85. data/lib/gemba/settings/gamepad_tab.rb +351 -0
  86. data/lib/gemba/settings/hotkeys_tab.rb +259 -0
  87. data/lib/gemba/settings/paths.rb +11 -0
  88. data/lib/gemba/settings/recording_tab.rb +83 -0
  89. data/lib/gemba/settings/save_states_tab.rb +91 -0
  90. data/lib/gemba/settings/system_tab.rb +377 -0
  91. data/lib/gemba/settings/video_tab.rb +318 -0
  92. data/lib/gemba/settings_window.rb +162 -1036
  93. data/lib/gemba/version.rb +1 -1
  94. data/lib/gemba/virtual_keyboard.rb +19 -0
  95. data/lib/gemba.rb +2 -12
  96. data/test/achievements_window/test_bulk_sync.rb +218 -0
  97. data/test/achievements_window/test_bus_events.rb +125 -0
  98. data/test/achievements_window/test_close_confirmation.rb +201 -0
  99. data/test/achievements_window/test_initial_state.rb +164 -0
  100. data/test/achievements_window/test_sorting.rb +227 -0
  101. data/test/achievements_window/test_tree_rendering.rb +133 -0
  102. data/test/fixtures/fake_bios.bin +0 -0
  103. data/test/fixtures/pong.gba +0 -0
  104. data/test/fixtures/test.gb +0 -0
  105. data/test/fixtures/test.gbc +0 -0
  106. data/test/fixtures/test_quicksave.ss +0 -0
  107. data/test/screenshots/no_focus.png +0 -0
  108. data/test/shared/teek_test_worker.rb +17 -1
  109. data/test/shared/tk_test_helper.rb +92 -4
  110. data/test/support/achievements_window_helpers.rb +18 -0
  111. data/test/support/fake_core.rb +25 -0
  112. data/test/support/fake_ra_runtime.rb +74 -0
  113. data/test/support/fake_requester.rb +78 -0
  114. data/test/support/player_helpers.rb +20 -5
  115. data/test/test_achievement.rb +32 -0
  116. data/test/{test_player.rb → test_app_controller.rb} +353 -85
  117. data/test/test_bios.rb +123 -0
  118. data/test/test_boxart_fetcher.rb +150 -0
  119. data/test/test_cli.rb +17 -265
  120. data/test/test_cli_config.rb +64 -0
  121. data/test/test_cli_decode.rb +97 -0
  122. data/test/test_cli_patch.rb +58 -0
  123. data/test/test_cli_play.rb +213 -0
  124. data/test/test_cli_ra.rb +175 -0
  125. data/test/test_cli_record.rb +69 -0
  126. data/test/test_cli_replay.rb +72 -0
  127. data/test/test_cli_sync_requester.rb +152 -0
  128. data/test/test_cli_version.rb +27 -0
  129. data/test/test_config.rb +3 -3
  130. data/test/test_config_ra.rb +69 -0
  131. data/test/test_core.rb +62 -1
  132. data/test/test_credentials_presenter.rb +192 -0
  133. data/test/test_event_bus.rb +100 -0
  134. data/test/test_fake_backend_achievements.rb +130 -0
  135. data/test/test_fake_backend_auth.rb +68 -0
  136. data/test/test_game_index.rb +77 -0
  137. data/test/test_game_picker_frame.rb +310 -0
  138. data/test/test_gamepad_map.rb +1 -3
  139. data/test/test_headless_player.rb +17 -3
  140. data/test/test_help_window.rb +82 -0
  141. data/test/test_hotkey_map.rb +22 -1
  142. data/test/test_input_recorder.rb +179 -0
  143. data/test/test_input_replay_determinism.rb +113 -0
  144. data/test/test_input_replayer.rb +162 -0
  145. data/test/test_keyboard_map.rb +1 -3
  146. data/test/test_libretro_backend.rb +41 -0
  147. data/test/test_list_picker_frame.rb +391 -0
  148. data/test/test_locale.rb +1 -1
  149. data/test/test_logging.rb +123 -0
  150. data/test/test_null_backend.rb +42 -0
  151. data/test/test_offline_backend.rb +116 -0
  152. data/test/test_overlay_renderer.rb +1 -1
  153. data/test/test_platform.rb +149 -0
  154. data/test/test_ra_backend.rb +313 -0
  155. data/test/test_ra_backend_unlock_gate.rb +56 -0
  156. data/test/test_ra_backend_unlock_retry.rb +123 -0
  157. data/test/test_recorder.rb +0 -3
  158. data/test/test_replay_player.rb +316 -0
  159. data/test/test_rom_info.rb +149 -0
  160. data/test/test_rom_overrides.rb +86 -0
  161. data/test/test_rom_patcher.rb +383 -0
  162. data/test/{test_rom_loader.rb → test_rom_resolver.rb} +25 -26
  163. data/test/test_save_state_manager.rb +2 -4
  164. data/test/test_settings_audio.rb +107 -0
  165. data/test/test_settings_hotkeys.rb +83 -66
  166. data/test/test_settings_recording.rb +49 -0
  167. data/test/test_settings_save_states.rb +97 -0
  168. data/test/test_settings_system.rb +133 -0
  169. data/test/test_settings_video.rb +450 -0
  170. data/test/test_settings_window.rb +76 -507
  171. data/test/test_tip_service.rb +6 -6
  172. data/test/test_toast_overlay.rb +1 -1
  173. data/test/test_virtual_events.rb +221 -0
  174. data/test/test_virtual_keyboard.rb +1 -1
  175. data/vendor/rcheevos/CHANGELOG.md +495 -0
  176. data/vendor/rcheevos/LICENSE +21 -0
  177. data/vendor/rcheevos/Package.swift +33 -0
  178. data/vendor/rcheevos/README.md +67 -0
  179. data/vendor/rcheevos/include/module.modulemap +70 -0
  180. data/vendor/rcheevos/include/rc_api_editor.h +296 -0
  181. data/vendor/rcheevos/include/rc_api_info.h +280 -0
  182. data/vendor/rcheevos/include/rc_api_request.h +77 -0
  183. data/vendor/rcheevos/include/rc_api_runtime.h +417 -0
  184. data/vendor/rcheevos/include/rc_api_user.h +262 -0
  185. data/vendor/rcheevos/include/rc_client.h +877 -0
  186. data/vendor/rcheevos/include/rc_client_raintegration.h +101 -0
  187. data/vendor/rcheevos/include/rc_consoles.h +138 -0
  188. data/vendor/rcheevos/include/rc_error.h +59 -0
  189. data/vendor/rcheevos/include/rc_export.h +100 -0
  190. data/vendor/rcheevos/include/rc_hash.h +200 -0
  191. data/vendor/rcheevos/include/rc_runtime.h +148 -0
  192. data/vendor/rcheevos/include/rc_runtime_types.h +452 -0
  193. data/vendor/rcheevos/include/rc_util.h +51 -0
  194. data/vendor/rcheevos/include/rcheevos.h +8 -0
  195. data/vendor/rcheevos/src/rapi/rc_api_common.c +1379 -0
  196. data/vendor/rcheevos/src/rapi/rc_api_common.h +88 -0
  197. data/vendor/rcheevos/src/rapi/rc_api_editor.c +625 -0
  198. data/vendor/rcheevos/src/rapi/rc_api_info.c +587 -0
  199. data/vendor/rcheevos/src/rapi/rc_api_runtime.c +901 -0
  200. data/vendor/rcheevos/src/rapi/rc_api_user.c +483 -0
  201. data/vendor/rcheevos/src/rc_client.c +6941 -0
  202. data/vendor/rcheevos/src/rc_client_external.c +281 -0
  203. data/vendor/rcheevos/src/rc_client_external.h +177 -0
  204. data/vendor/rcheevos/src/rc_client_external_versions.h +171 -0
  205. data/vendor/rcheevos/src/rc_client_internal.h +409 -0
  206. data/vendor/rcheevos/src/rc_client_raintegration.c +566 -0
  207. data/vendor/rcheevos/src/rc_client_raintegration_internal.h +61 -0
  208. data/vendor/rcheevos/src/rc_client_types.natvis +396 -0
  209. data/vendor/rcheevos/src/rc_compat.c +251 -0
  210. data/vendor/rcheevos/src/rc_compat.h +121 -0
  211. data/vendor/rcheevos/src/rc_libretro.c +915 -0
  212. data/vendor/rcheevos/src/rc_libretro.h +98 -0
  213. data/vendor/rcheevos/src/rc_util.c +199 -0
  214. data/vendor/rcheevos/src/rc_version.c +11 -0
  215. data/vendor/rcheevos/src/rc_version.h +32 -0
  216. data/vendor/rcheevos/src/rcheevos/alloc.c +312 -0
  217. data/vendor/rcheevos/src/rcheevos/condition.c +754 -0
  218. data/vendor/rcheevos/src/rcheevos/condset.c +777 -0
  219. data/vendor/rcheevos/src/rcheevos/consoleinfo.c +1215 -0
  220. data/vendor/rcheevos/src/rcheevos/format.c +330 -0
  221. data/vendor/rcheevos/src/rcheevos/lboard.c +287 -0
  222. data/vendor/rcheevos/src/rcheevos/memref.c +805 -0
  223. data/vendor/rcheevos/src/rcheevos/operand.c +607 -0
  224. data/vendor/rcheevos/src/rcheevos/rc_internal.h +390 -0
  225. data/vendor/rcheevos/src/rcheevos/rc_runtime_types.natvis +541 -0
  226. data/vendor/rcheevos/src/rcheevos/rc_validate.c +1406 -0
  227. data/vendor/rcheevos/src/rcheevos/rc_validate.h +18 -0
  228. data/vendor/rcheevos/src/rcheevos/richpresence.c +922 -0
  229. data/vendor/rcheevos/src/rcheevos/runtime.c +852 -0
  230. data/vendor/rcheevos/src/rcheevos/runtime_progress.c +1073 -0
  231. data/vendor/rcheevos/src/rcheevos/trigger.c +344 -0
  232. data/vendor/rcheevos/src/rcheevos/value.c +935 -0
  233. data/vendor/rcheevos/src/rhash/aes.c +480 -0
  234. data/vendor/rcheevos/src/rhash/aes.h +49 -0
  235. data/vendor/rcheevos/src/rhash/cdreader.c +838 -0
  236. data/vendor/rcheevos/src/rhash/hash.c +1402 -0
  237. data/vendor/rcheevos/src/rhash/hash_disc.c +1340 -0
  238. data/vendor/rcheevos/src/rhash/hash_encrypted.c +566 -0
  239. data/vendor/rcheevos/src/rhash/hash_rom.c +426 -0
  240. data/vendor/rcheevos/src/rhash/hash_zip.c +460 -0
  241. data/vendor/rcheevos/src/rhash/md5.c +382 -0
  242. data/vendor/rcheevos/src/rhash/md5.h +91 -0
  243. data/vendor/rcheevos/src/rhash/rc_hash_internal.h +116 -0
  244. data/vendor/rcheevos/test/libretro.h +205 -0
  245. data/vendor/rcheevos/test/rapi/test_rc_api_common.c +941 -0
  246. data/vendor/rcheevos/test/rapi/test_rc_api_editor.c +931 -0
  247. data/vendor/rcheevos/test/rapi/test_rc_api_info.c +545 -0
  248. data/vendor/rcheevos/test/rapi/test_rc_api_runtime.c +2213 -0
  249. data/vendor/rcheevos/test/rapi/test_rc_api_user.c +998 -0
  250. data/vendor/rcheevos/test/rcheevos/mock_memory.h +32 -0
  251. data/vendor/rcheevos/test/rcheevos/test_condition.c +570 -0
  252. data/vendor/rcheevos/test/rcheevos/test_condset.c +5170 -0
  253. data/vendor/rcheevos/test/rcheevos/test_consoleinfo.c +203 -0
  254. data/vendor/rcheevos/test/rcheevos/test_format.c +112 -0
  255. data/vendor/rcheevos/test/rcheevos/test_lboard.c +746 -0
  256. data/vendor/rcheevos/test/rcheevos/test_memref.c +520 -0
  257. data/vendor/rcheevos/test/rcheevos/test_operand.c +692 -0
  258. data/vendor/rcheevos/test/rcheevos/test_rc_validate.c +502 -0
  259. data/vendor/rcheevos/test/rcheevos/test_richpresence.c +1564 -0
  260. data/vendor/rcheevos/test/rcheevos/test_runtime.c +1667 -0
  261. data/vendor/rcheevos/test/rcheevos/test_runtime_progress.c +1821 -0
  262. data/vendor/rcheevos/test/rcheevos/test_timing.c +166 -0
  263. data/vendor/rcheevos/test/rcheevos/test_trigger.c +2521 -0
  264. data/vendor/rcheevos/test/rcheevos/test_value.c +870 -0
  265. data/vendor/rcheevos/test/rcheevos-test.sln +46 -0
  266. data/vendor/rcheevos/test/rcheevos-test.vcxproj +239 -0
  267. data/vendor/rcheevos/test/rcheevos-test.vcxproj.filters +335 -0
  268. data/vendor/rcheevos/test/rhash/data.c +657 -0
  269. data/vendor/rcheevos/test/rhash/data.h +32 -0
  270. data/vendor/rcheevos/test/rhash/mock_filereader.c +236 -0
  271. data/vendor/rcheevos/test/rhash/mock_filereader.h +31 -0
  272. data/vendor/rcheevos/test/rhash/test_cdreader.c +920 -0
  273. data/vendor/rcheevos/test/rhash/test_hash.c +310 -0
  274. data/vendor/rcheevos/test/rhash/test_hash_disc.c +1450 -0
  275. data/vendor/rcheevos/test/rhash/test_hash_rom.c +899 -0
  276. data/vendor/rcheevos/test/rhash/test_hash_zip.c +551 -0
  277. data/vendor/rcheevos/test/test.c +113 -0
  278. data/vendor/rcheevos/test/test_framework.h +205 -0
  279. data/vendor/rcheevos/test/test_rc_client.c +10509 -0
  280. data/vendor/rcheevos/test/test_rc_client_external.c +2197 -0
  281. data/vendor/rcheevos/test/test_rc_client_raintegration.c +441 -0
  282. data/vendor/rcheevos/test/test_rc_libretro.c +952 -0
  283. data/vendor/rcheevos/test/test_types.natvis +9 -0
  284. data/vendor/rcheevos/validator/validator.c +658 -0
  285. data/vendor/rcheevos/validator/validator.vcxproj +152 -0
  286. data/vendor/rcheevos/validator/validator.vcxproj.filters +82 -0
  287. metadata +277 -10
  288. data/lib/gemba/input_mappings.rb +0 -214
  289. data/lib/gemba/player.rb +0 -1525
@@ -0,0 +1,1036 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'digest'
5
+
6
+ module Gemba
7
+ # Application controller — the brain of the app.
8
+ #
9
+ # Owns menus, hotkeys, modals, config, rom library, input maps, frame
10
+ # lifecycle, and mode tracking. MainWindow is a pure Tk shell that this
11
+ # controller drives.
12
+ #
13
+ # This is what CLI instantiates.
14
+ class AppController
15
+ include Gemba
16
+ include Locale::Translatable
17
+ include BusEmitter
18
+
19
+ DEFAULT_SCALE = 3
20
+ EVENT_LOOP_FAST_MS = 1
21
+ EVENT_LOOP_IDLE_MS = 50
22
+ GAMEPAD_PROBE_MS = 2000
23
+ GAMEPAD_LISTEN_MS = 50
24
+
25
+ MODAL_LABELS = {
26
+ settings: 'menu.settings',
27
+ picker: 'menu.save_states',
28
+ rom_info: 'menu.rom_info',
29
+ replay_player: 'replay.replay_player',
30
+ }.freeze
31
+
32
+ attr_reader :app, :config, :settings_window, :kb_map, :gp_map, :running, :scale
33
+
34
+ def initialize(rom_path = nil, sound: true, fullscreen: false, frames: nil)
35
+ @window = MainWindow.new
36
+ @app = @window.app
37
+ @frame_stack = @window.frame_stack
38
+
39
+ Gemba.bus = EventBus.new
40
+
41
+ @sound = sound
42
+ @config = Gemba.user_config
43
+ # Config may have been created (and subscribed to the bus) before this
44
+ # point — e.g. the CLI calls Gemba.user_config before AppController runs.
45
+ # Re-subscribe now that the real bus is in place so bus events like
46
+ # :rom_loaded actually reach it (fixes recent ROMs not updating).
47
+ @config.resubscribe
48
+ @scale = @config.scale
49
+ @fullscreen = fullscreen
50
+ @frame_limit = frames
51
+ @platform = Platform.default
52
+ @initial_rom = rom_path
53
+ @running = true
54
+ @rom_path = nil
55
+ @gamepad = nil
56
+ @rom_library = RomLibrary.new
57
+
58
+ @kb_map = KeyboardMap.new(@config)
59
+ @gp_map = GamepadMap.new(@config)
60
+ @keyboard = VirtualKeyboard.new
61
+ @kb_map.device = @keyboard
62
+ @hotkeys = HotkeyMap.new(@config)
63
+
64
+ check_writable_dirs
65
+
66
+ @window.set_timer_speed(EVENT_LOOP_IDLE_MS)
67
+ @window.set_geometry(@platform.width * @scale, @platform.height * @scale)
68
+ @window.set_title("gemba")
69
+
70
+ build_menu
71
+
72
+ @modal_stack = ModalStack.new(
73
+ on_enter: method(:modal_entered),
74
+ on_exit: method(:modal_exited),
75
+ on_focus_change: method(:modal_focus_changed),
76
+ )
77
+
78
+ dismiss = proc { @modal_stack.pop }
79
+
80
+ @rom_info_window = RomInfoWindow.new(@app, callbacks: {
81
+ on_dismiss: dismiss, on_close: dismiss,
82
+ })
83
+ @state_picker = SaveStatePicker.new(@app, callbacks: {
84
+ on_dismiss: dismiss, on_close: dismiss,
85
+ })
86
+ @settings_window = SettingsWindow.new(@app, tip_dismiss_ms: @config.tip_dismiss_ms, callbacks: {
87
+ on_validate_hotkey: method(:validate_hotkey),
88
+ on_validate_kb_mapping: method(:validate_kb_mapping),
89
+ on_dismiss: dismiss, on_close: dismiss,
90
+ })
91
+
92
+ @settings_window.refresh_gamepad(@kb_map.labels, @kb_map.dead_zone_pct)
93
+ @settings_window.refresh_hotkeys(@hotkeys.labels)
94
+ push_settings_to_ui
95
+
96
+ setup_achievement_backend
97
+
98
+ boxart_backend = BoxartFetcher::LibretroBackend.new
99
+ @boxart_fetcher = BoxartFetcher.new(app: @app, cache_dir: Config.boxart_dir, backend: boxart_backend)
100
+ @rom_overrides = RomOverrides.new
101
+ @game_picker = GamePickerFrame.new(app: @app, rom_library: @rom_library,
102
+ boxart_fetcher: @boxart_fetcher, rom_overrides: @rom_overrides)
103
+ @list_picker = ListPickerFrame.new(app: @app, rom_library: @rom_library,
104
+ rom_overrides: @rom_overrides)
105
+ @active_picker = @config.picker_view == 'list' ? @list_picker : @game_picker
106
+ @frame_stack.push(:picker, @active_picker)
107
+ apply_picker_window(@active_picker)
108
+
109
+ @help_auto_paused = false
110
+ @cursor_hidden = false
111
+ @cursor_hide_job = nil
112
+
113
+ setup_drop_target
114
+ setup_global_hotkeys
115
+ setup_bus_subscriptions
116
+ setup_cursor_autohide
117
+ end
118
+
119
+ def run
120
+ @app.after(1) { load_rom(@initial_rom) } if @initial_rom
121
+ @app.mainloop
122
+ ensure
123
+ cleanup
124
+ end
125
+
126
+ def ready? = @initial_rom ? frame&.rom_loaded? : true
127
+
128
+ # Current active frame (for tests and external access)
129
+ def frame = @frame_stack.current_frame
130
+ def current_view = @frame_stack.current
131
+
132
+ def running=(val)
133
+ @running = val
134
+ @emulator_frame&.running = val
135
+ return if val
136
+ cleanup
137
+ @app.command(:destroy, '.')
138
+ end
139
+
140
+ def disable_confirmations!
141
+ @disable_confirmations = true
142
+ end
143
+
144
+ private
145
+
146
+ def confirm_quit
147
+ return true unless @rom_path
148
+ confirm(
149
+ title: translate('dialog.quit_title'),
150
+ message: translate('dialog.quit_msg'),
151
+ )
152
+ end
153
+
154
+ def confirm(title:, message:)
155
+ return true if @disable_confirmations
156
+ result = @app.command('tk_messageBox',
157
+ parent: '.',
158
+ title: title,
159
+ message: message,
160
+ type: :okcancel,
161
+ icon: :warning)
162
+ result == 'ok'
163
+ end
164
+
165
+ # ── Bus subscriptions ──────────────────────────────────────────────
166
+
167
+ def setup_bus_subscriptions
168
+ bus = Gemba.bus
169
+
170
+ # Window-level
171
+ bus.on(:scale_changed) { |val| apply_scale(val) }
172
+
173
+ # Input maps
174
+ bus.on(:gamepad_map_changed) { |btn, gp| active_input.set(btn, gp) }
175
+ bus.on(:keyboard_map_changed) { |btn, key| active_input.set(btn, key) }
176
+ bus.on(:deadzone_changed) { |val| active_input.set_dead_zone(val) }
177
+ bus.on(:gamepad_reset) { active_input.reset! }
178
+ bus.on(:keyboard_reset) { active_input.reset! }
179
+ bus.on(:undo_gamepad) { undo_mappings }
180
+
181
+ # Hotkeys
182
+ bus.on(:hotkey_changed) { |action, key| @hotkeys.set(action, key) }
183
+ bus.on(:hotkey_reset) { @hotkeys.reset! }
184
+ bus.on(:undo_hotkeys) { undo_hotkeys }
185
+
186
+ # Settings window actions
187
+ bus.on(:settings_save) { save_config }
188
+ bus.on(:per_game_toggled) { |val| toggle_per_game(val) }
189
+ bus.on(:open_config_dir) { open_config_dir }
190
+ bus.on(:open_recordings_dir) { open_recordings_dir }
191
+ bus.on(:open_replay_player) { show_replay_player }
192
+ bus.on(:picker_view_changed) { |view:| switch_picker_view(view) }
193
+
194
+ # Frame → controller events
195
+ bus.on(:pause_changed) do |paused|
196
+ label = paused ? translate('menu.resume') : translate('menu.pause')
197
+ @app.command(@emu_menu, :entryconfigure, 0, label: label)
198
+ show_cursor if paused
199
+ end
200
+ bus.on(:recording_changed) { update_recording_menu }
201
+ bus.on(:input_recording_changed) { update_input_recording_menu }
202
+ bus.on(:request_quit) { self.running = false if confirm_quit }
203
+ bus.on(:achievement_unlocked) do |achievement:|
204
+ frame&.receive(:show_toast, message: "#{achievement.title} (#{achievement.points}pts)")
205
+ frame&.receive(:take_achievement_screenshot, achievement: achievement) if @config.ra_screenshot_on_unlock?
206
+ end
207
+ bus.on(:ra_login) do |username:, password:|
208
+ achievement_backend.login_with_password(username: username, password: password)
209
+ end
210
+ bus.on(:ra_verify) do
211
+ achievement_backend.token_test
212
+ end
213
+ bus.on(:ra_logout) do
214
+ achievement_backend.logout
215
+ @config.ra_token = ''
216
+ @config.ra_username = ''
217
+ @config.save!
218
+ end
219
+ bus.on(:ra_unofficial_changed) do |value:|
220
+ @config.ra_unofficial = value
221
+ @config.save!
222
+ @achievement_backend.include_unofficial = value
223
+ end
224
+ bus.on(:request_escape) { @fullscreen ? toggle_fullscreen : (self.running = false) }
225
+ bus.on(:request_fullscreen) { toggle_fullscreen }
226
+ bus.on(:request_save_states) { show_state_picker }
227
+ bus.on(:request_open_rom) { handle_open_rom }
228
+ bus.on(:rom_selected) { |path| load_rom(path) }
229
+ bus.on(:rom_quick_load) { |path:, slot:| load_rom(path); frame&.receive(:load_state, slot: slot) }
230
+ bus.on(:request_show_fps_toggle) do
231
+ frame&.receive(:toggle_show_fps)
232
+ show = frame&.show_fps? || false
233
+ @app.set_variable(SettingsWindow::VAR_SHOW_FPS, show ? '1' : '0')
234
+ end
235
+
236
+ # ── ROM loaded reactions ──────────────────────────────────────────
237
+ # Config, RomLibrary, and SettingsWindow each subscribe themselves
238
+ # via subscribe_to_bus. AppController only handles what it owns.
239
+
240
+ bus.on(:rom_loaded) do |**|
241
+ refresh_from_config
242
+ end
243
+
244
+ bus.on(:rom_loaded) do |rom_id:, title:, path:, saves_dir:, game_code: nil, md5: nil, platform: 'gba', **|
245
+ friendly = GameIndex.lookup(game_code) ||
246
+ GameIndex.lookup_by_md5(md5, platform) ||
247
+ title
248
+ @window.set_title("gemba \u2014 #{friendly}")
249
+ @app.command(@view_menu, :entryconfigure, 0, state: :normal) # Game Library
250
+ @app.command(@view_menu, :entryconfigure, 2, state: :normal) # ROM Info
251
+ @current_rom_id = rom_id
252
+ @achievement_backend.rich_presence_enabled = @config.ra_rich_presence?
253
+ @achievements_window&.update_game(rom_id: rom_id, backend: @achievement_backend) # only if open
254
+ [3, 4, 6, 8, 9].each { |i| @app.command(@emu_menu, :entryconfigure, i, state: :normal) }
255
+ rebuild_recent_menu
256
+
257
+ sav_name = File.basename(path, File.extname(path)) + '.sav'
258
+ sav_path = File.join(saves_dir, sav_name)
259
+ if File.exist?(sav_path)
260
+ @emulator_frame.receive(:show_toast, message: translate('toast.loaded_sav', name: sav_name))
261
+ else
262
+ @emulator_frame.receive(:show_toast, message: translate('toast.created_sav', name: sav_name))
263
+ end
264
+ end
265
+ end
266
+
267
+ # ── Menu ───────────────────────────────────────────────────────────
268
+
269
+ def build_menu
270
+ menubar = '.menubar'
271
+ @app.command(:menu, menubar)
272
+ @app.command('.', :configure, menu: menubar)
273
+
274
+ # File menu
275
+ @app.command(:menu, "#{menubar}.file", tearoff: 0)
276
+ @app.command(menubar, :add, :cascade, label: translate('menu.file'), menu: "#{menubar}.file")
277
+
278
+ @app.command("#{menubar}.file", :add, :command,
279
+ label: translate('menu.open_rom'), accelerator: 'Cmd+O',
280
+ command: proc { open_rom_dialog })
281
+
282
+ @recent_menu = "#{menubar}.file.recent"
283
+ @app.command(:menu, @recent_menu, tearoff: 0)
284
+ @app.command("#{menubar}.file", :add, :cascade,
285
+ label: translate('menu.recent'), menu: @recent_menu)
286
+ rebuild_recent_menu
287
+
288
+ @app.command("#{menubar}.file", :add, :separator)
289
+ @app.command("#{menubar}.file", :add, :command,
290
+ label: translate('menu.quit'), accelerator: 'Cmd+Q',
291
+ command: proc { self.running = false if confirm_quit })
292
+
293
+ @app.command(:bind, '.', '<Command-o>', proc { handle_open_rom })
294
+ @app.command(:bind, '.', '<Command-comma>', proc { show_settings })
295
+
296
+ # Settings menu
297
+ settings_menu = "#{menubar}.settings"
298
+ @app.command(:menu, settings_menu, tearoff: 0)
299
+ @app.command(menubar, :add, :cascade, label: translate('menu.settings'), menu: settings_menu)
300
+
301
+ SettingsWindow::TABS.each do |locale_key, tab_path|
302
+ display = translate(locale_key)
303
+ accel = locale_key == 'settings.video' ? 'Cmd+,' : nil
304
+ opts = { label: "#{display}\u2026", command: proc { show_settings(tab: tab_path) } }
305
+ opts[:accelerator] = accel if accel
306
+ @app.command(settings_menu, :add, :command, **opts)
307
+ end
308
+
309
+ # View menu
310
+ view_menu = "#{menubar}.view"
311
+ @app.command(:menu, view_menu, tearoff: 0)
312
+ @app.command(menubar, :add, :cascade, label: translate('menu.view'), menu: view_menu)
313
+
314
+ @app.command(view_menu, :add, :command,
315
+ label: translate('menu.game_library'), state: :disabled,
316
+ command: proc { show_game_library })
317
+ @app.command(view_menu, :add, :command,
318
+ label: translate('menu.fullscreen'), accelerator: 'F11',
319
+ command: proc { toggle_fullscreen })
320
+ @app.command(view_menu, :add, :command,
321
+ label: translate('menu.rom_info'), state: :disabled,
322
+ command: proc { show_rom_info })
323
+ @app.command(view_menu, :add, :command,
324
+ label: translate('menu.achievements'),
325
+ command: proc { show_achievements })
326
+ @app.command(view_menu, :add, :command,
327
+ label: translate('menu.patch_rom'),
328
+ command: proc { show_patcher })
329
+ @app.command(view_menu, :add, :separator)
330
+ @app.command(view_menu, :add, :command,
331
+ label: translate('menu.open_logs_dir'),
332
+ command: proc { open_logs_dir })
333
+ @view_menu = view_menu
334
+
335
+ # Emulation menu
336
+ @emu_menu = "#{menubar}.emu"
337
+ @app.command(:menu, @emu_menu, tearoff: 0)
338
+ @app.command(menubar, :add, :cascade, label: translate('menu.emulation'), menu: @emu_menu)
339
+
340
+ @app.command(@emu_menu, :add, :command,
341
+ label: translate('menu.pause'), accelerator: 'P',
342
+ command: proc { frame&.receive(:pause) })
343
+ @app.command(@emu_menu, :add, :command,
344
+ label: translate('menu.reset'), accelerator: 'Cmd+R',
345
+ command: proc { reset_core })
346
+ @app.command(@emu_menu, :add, :separator)
347
+ @app.command(@emu_menu, :add, :command,
348
+ label: translate('menu.quick_save'), accelerator: 'F5', state: :disabled,
349
+ command: proc { frame&.receive(:quick_save) })
350
+ @app.command(@emu_menu, :add, :command,
351
+ label: translate('menu.quick_load'), accelerator: 'F8', state: :disabled,
352
+ command: proc { frame&.receive(:quick_load) })
353
+ @app.command(@emu_menu, :add, :separator)
354
+ @app.command(@emu_menu, :add, :command,
355
+ label: translate('menu.save_states'), accelerator: 'F6', state: :disabled,
356
+ command: proc { show_state_picker })
357
+ @app.command(@emu_menu, :add, :separator)
358
+ @app.command(@emu_menu, :add, :command,
359
+ label: translate('menu.start_recording'), accelerator: 'F10', state: :disabled,
360
+ command: proc { frame&.receive(:toggle_recording) })
361
+ @app.command(@emu_menu, :add, :command,
362
+ label: translate('menu.start_input_recording'), accelerator: 'F4', state: :disabled,
363
+ command: proc { frame&.receive(:toggle_input_recording) })
364
+
365
+ @app.command(:bind, '.', '<Command-r>', proc { reset_core })
366
+ end
367
+
368
+ def update_recording_menu
369
+ recording = frame&.recording? || false
370
+ label = recording ? translate('menu.stop_recording') : translate('menu.start_recording')
371
+ @app.command(@emu_menu, :entryconfigure, 8, label: label)
372
+ end
373
+
374
+ def update_input_recording_menu
375
+ recording = frame&.input_recording? || false
376
+ label = recording ? translate('menu.stop_input_recording') : translate('menu.start_input_recording')
377
+ @app.command(@emu_menu, :entryconfigure, 9, label: label)
378
+ end
379
+
380
+ def rebuild_recent_menu
381
+ @app.command(@recent_menu, :delete, 0, :end) rescue nil
382
+
383
+ roms = @config.recent_roms
384
+ if roms.empty?
385
+ @app.command(@recent_menu, :add, :command,
386
+ label: translate('player.none'), state: :disabled)
387
+ else
388
+ roms.each do |rom_path|
389
+ label = File.basename(rom_path)
390
+ @app.command(@recent_menu, :add, :command,
391
+ label: label,
392
+ command: proc { open_recent_rom(rom_path) })
393
+ end
394
+ @app.command(@recent_menu, :add, :separator)
395
+ @app.command(@recent_menu, :add, :command,
396
+ label: translate('player.clear'),
397
+ command: proc { clear_recent_roms })
398
+ end
399
+ end
400
+
401
+ def clear_recent_roms
402
+ @config.clear_recent_roms
403
+ @config.save!
404
+ rebuild_recent_menu
405
+ end
406
+
407
+ # ── Modals ─────────────────────────────────────────────────────────
408
+
409
+ def show_game_library
410
+ return if @frame_stack.current == :picker
411
+ return bell if @modal_stack.active?
412
+
413
+ if frame&.rom_loaded?
414
+ return unless confirm(
415
+ title: translate('dialog.return_to_library_title'),
416
+ message: translate('dialog.return_to_library_msg'),
417
+ )
418
+ end
419
+
420
+ @emulator_frame&.running = false
421
+ @emulator_frame&.cleanup
422
+ @frame_stack.pop
423
+ @emulator_frame = nil
424
+ @rom_path = nil
425
+ @window.set_title("gemba")
426
+ apply_picker_window(@active_picker)
427
+ @app.command(@view_menu, :entryconfigure, 0, state: :disabled)
428
+ set_event_loop_speed(:idle)
429
+ end
430
+
431
+ def show_settings(tab: nil)
432
+ return bell if @modal_stack.active?
433
+ @modal_stack.push(:settings, @settings_window, show_args: { tab: tab })
434
+ end
435
+
436
+ def show_state_picker
437
+ return unless frame&.save_mgr&.state_dir
438
+ return bell if @modal_stack.active?
439
+ @modal_stack.push(:picker, @state_picker,
440
+ show_args: { state_dir: frame.save_mgr.state_dir, quick_slot: @config.quick_save_slot })
441
+ end
442
+
443
+ def show_rom_info
444
+ return unless frame&.rom_loaded?
445
+ return bell if @modal_stack.active?
446
+ saves = @config.saves_dir
447
+ sav_name = File.basename(@rom_path, File.extname(@rom_path)) + '.sav'
448
+ sav_path = File.join(saves, sav_name)
449
+ @modal_stack.push(:rom_info, @rom_info_window,
450
+ show_args: { core: frame.core, rom_path: @rom_path, save_path: sav_path })
451
+ end
452
+
453
+ def show_achievements
454
+ achievements_window.update_game(rom_id: @current_rom_id, backend: @achievement_backend)
455
+ achievements_window.show
456
+ end
457
+
458
+ def achievements_window
459
+ @achievements_window ||= AchievementsWindow.new(
460
+ app: @app,
461
+ rom_library: @rom_library,
462
+ config: @config,
463
+ )
464
+ end
465
+
466
+ def show_replay_player
467
+ @replay_player ||= ReplayPlayer.new(
468
+ app: @app,
469
+ sound: true,
470
+ callbacks: {
471
+ on_dismiss: proc { @modal_stack.pop },
472
+ on_request_speed: method(:set_event_loop_speed),
473
+ }
474
+ )
475
+ @modal_stack.push(:replay_player, @replay_player)
476
+ end
477
+
478
+ def modal_entered(_name)
479
+ @was_paused_before_modal = frame&.paused? || false
480
+ frame&.receive(:modal_entered)
481
+ end
482
+
483
+ def modal_exited
484
+ frame&.receive(:modal_exited, was_paused: @was_paused_before_modal)
485
+ end
486
+
487
+ def modal_focus_changed(name)
488
+ locale_key = MODAL_LABELS[name] || name.to_s
489
+ label = translate(locale_key)
490
+ frame&.receive(:modal_focus_changed, message: translate('toast.waiting_for', label: label))
491
+ end
492
+
493
+ # ── File handling ──────────────────────────────────────────────────
494
+
495
+ def load_rom(path)
496
+ rom_path = begin
497
+ RomResolver.resolve(path)
498
+ rescue RomResolver::NoRomInZip => e
499
+ show_rom_error(translate('dialog.no_rom_in_zip', name: e.message))
500
+ return
501
+ rescue RomResolver::MultipleRomsInZip => e
502
+ show_rom_error(translate('dialog.multiple_roms_in_zip', name: e.message))
503
+ return
504
+ rescue RomResolver::UnsupportedFormat => e
505
+ show_rom_error(translate('dialog.drop_unsupported_type', ext: e.message))
506
+ return
507
+ rescue RomResolver::ZipReadError => e
508
+ show_rom_error(translate('dialog.zip_read_error', detail: e.message))
509
+ return
510
+ end
511
+
512
+ # One-time gamepad subsystem init
513
+ unless @gamepad_inited
514
+ @gamepad_inited = true
515
+ Teek::SDL2::Gamepad.init_subsystem
516
+ Teek::SDL2::Gamepad.on_added { |_| refresh_gamepads }
517
+ Teek::SDL2::Gamepad.on_removed { |_| @gamepad = nil; @gp_map.device = nil; refresh_gamepads }
518
+ refresh_gamepads
519
+ start_gamepad_probe
520
+ end
521
+
522
+ # Create EmulatorFrame (fresh each time after returning from game library)
523
+ unless @emulator_frame
524
+ @emulator_frame = create_emulator_frame
525
+ @emulator_frame.init_sdl2
526
+ @window.fullscreen = true if @fullscreen
527
+ end
528
+
529
+ # Push emulator onto frame stack (hides picker automatically)
530
+ if @frame_stack.current != :emulator
531
+ @frame_stack.push(:emulator, @emulator_frame)
532
+ @window.reset_minsize
533
+ apply_frame_aspect(@emulator_frame)
534
+ end
535
+
536
+ saves = @config.saves_dir
537
+ bios_path = resolve_bios_path
538
+ md5 = Digest::MD5.file(rom_path).hexdigest
539
+
540
+ # Set backend before load_core so load_game fires on the real backend, not NullBackend
541
+ @emulator_frame.achievement_backend = @achievement_backend
542
+
543
+ loaded_core = @emulator_frame.load_core(rom_path, saves_dir: saves, bios_path: bios_path,
544
+ rom_source_path: path, md5: md5)
545
+ @rom_path = path
546
+
547
+ new_platform = @emulator_frame.platform
548
+ @platform = new_platform
549
+ apply_scale(@scale)
550
+ Gemba.log(:info) { "ROM loaded: #{loaded_core.title} (#{loaded_core.game_code}) [#{@platform.short_name}]" }
551
+
552
+ rom_id = Config.rom_id(loaded_core.game_code, loaded_core.checksum)
553
+
554
+ emit(:rom_loaded,
555
+ rom_id: rom_id,
556
+ path: path,
557
+ title: loaded_core.title,
558
+ game_code: loaded_core.game_code,
559
+ platform: @platform.short_name,
560
+ saves_dir: saves,
561
+ md5: md5,
562
+ )
563
+
564
+ @emulator_frame.start_animate
565
+ end
566
+
567
+ def open_rom_dialog
568
+ filetypes = '{{GBA ROMs} {.gba}} {{GB ROMs} {.gb .gbc}} {{ZIP Archives} {.zip}} {{All Files} {*}}'
569
+ title = translate('menu.open_rom').delete("\u2026")
570
+ initial = @rom_path ? File.dirname(@rom_path) : Dir.home
571
+ path = @app.tcl_eval("tk_getOpenFile -title {#{title}} -filetypes {#{filetypes}} -initialdir {#{initial}}")
572
+ return if path.empty?
573
+ return unless confirm_rom_change(path)
574
+ load_rom(path)
575
+ end
576
+
577
+ def handle_open_rom
578
+ if @modal_stack.current == :replay_player
579
+ open_recordings_dir
580
+ else
581
+ open_rom_dialog
582
+ end
583
+ end
584
+
585
+ def open_recent_rom(path)
586
+ unless File.exist?(path)
587
+ @app.command('tk_messageBox',
588
+ parent: '.',
589
+ title: translate('dialog.rom_not_found_title'),
590
+ message: translate('dialog.rom_not_found_msg', path: path),
591
+ type: :ok,
592
+ icon: :error)
593
+ @config.remove_recent_rom(path)
594
+ @config.save!
595
+ rebuild_recent_menu
596
+ return
597
+ end
598
+ return unless confirm_rom_change(path)
599
+ load_rom(path)
600
+ end
601
+
602
+ def confirm_rom_change(new_path)
603
+ return true unless frame&.rom_loaded?
604
+ name = File.basename(new_path)
605
+ confirm(
606
+ title: translate('dialog.game_running_title'),
607
+ message: translate('dialog.game_running_msg', name: name),
608
+ )
609
+ end
610
+
611
+ def setup_drop_target
612
+ @app.register_drop_target('.')
613
+ @app.bind('.', '<<DropFile>>', :data) do |data|
614
+ paths = @app.split_list(data)
615
+ handle_dropped_files(paths)
616
+ end
617
+ end
618
+
619
+ def handle_dropped_files(paths)
620
+ if paths.length != 1
621
+ @app.command('tk_messageBox',
622
+ parent: '.',
623
+ title: translate('dialog.drop_error_title'),
624
+ message: translate('dialog.drop_single_file_only'),
625
+ type: :ok,
626
+ icon: :warning)
627
+ return
628
+ end
629
+
630
+ path = paths.first
631
+ ext = File.extname(path).downcase
632
+ unless RomResolver::SUPPORTED_EXTENSIONS.include?(ext)
633
+ @app.command('tk_messageBox',
634
+ parent: '.',
635
+ title: translate('dialog.drop_error_title'),
636
+ message: translate('dialog.drop_unsupported_type', ext: ext),
637
+ type: :ok,
638
+ icon: :warning)
639
+ return
640
+ end
641
+
642
+ return unless confirm_rom_change(path)
643
+ load_rom(path)
644
+ end
645
+
646
+ def reset_core
647
+ return unless @rom_path
648
+ load_rom(@rom_path)
649
+ end
650
+
651
+ # ── Config ─────────────────────────────────────────────────────────
652
+
653
+ def resolve_bios_path
654
+ name = @config.bios_path
655
+ return nil if name.nil? || name.empty?
656
+ # Absolute path (e.g. from --bios CLI flag) used directly;
657
+ # bare filename looked up in bios_dir (set via Settings UI).
658
+ full = File.absolute_path?(name) ? name : File.join(Config.bios_dir, name)
659
+ if File.exist?(full)
660
+ Gemba.log(:info) { "BIOS: #{File.basename(full)}" }
661
+ full
662
+ else
663
+ Gemba.log(:warn) { "BIOS configured but file not found: #{full}" }
664
+ nil
665
+ end
666
+ end
667
+
668
+ def save_config
669
+ @config.scale = @scale
670
+ frame&.receive(:write_config)
671
+ @kb_map.save_to_config
672
+ @gp_map.save_to_config
673
+ @hotkeys.save_to_config
674
+ bios_name = @app.get_variable(SettingsWindow::VAR_BIOS_PATH).to_s.strip
675
+ @config.bios_path = bios_name.empty? ? nil : bios_name
676
+ @config.skip_bios = @app.get_variable(SettingsWindow::VAR_SKIP_BIOS) == '1'
677
+ @settings_window&.system_tab&.save_to_config(@config)
678
+ @config.save!
679
+ setup_achievement_backend
680
+ end
681
+
682
+ def achievement_backend
683
+ @achievement_backend ||= Achievements::NullBackend.new
684
+ end
685
+
686
+ def setup_achievement_backend
687
+ @achievement_backend = Achievements.backend_for(@config, app: @app)
688
+ @achievement_backend.include_unofficial = @config.ra_unofficial?
689
+ @achievement_backend.on_achievements_changed do
690
+ @achievements_window&.refresh(@achievement_backend) # only if already open
691
+ end
692
+ @achievement_backend.on_unlock do |_ach|
693
+ @achievements_window&.refresh(@achievement_backend) # only if already open
694
+ end
695
+ @achievement_backend.on_rich_presence_changed do |msg|
696
+ Gemba.bus.emit(:ra_rich_presence_changed, message: msg.to_s)
697
+ end
698
+ @achievement_backend.on_auth_change do |status, token_or_error|
699
+ case status
700
+ when :ok
701
+ if token_or_error
702
+ emit(:ra_auth_result, status: :ok, token: token_or_error.to_s)
703
+ @config.ra_token = token_or_error.to_s
704
+ @config.save!
705
+ else
706
+ emit(:ra_auth_result, status: :ok)
707
+ end
708
+ when :error
709
+ emit(:ra_auth_result, status: :error, message: token_or_error.to_s)
710
+ when :logout
711
+ emit(:ra_auth_result, status: :logout)
712
+ @config.ra_token = ''
713
+ @config.save!
714
+ end
715
+ end
716
+ # Resume existing session if token already stored
717
+ if @config.ra_enabled? && !@config.ra_token.to_s.strip.empty?
718
+ @achievement_backend.login_with_token(username: @config.ra_username, token: @config.ra_token)
719
+ end
720
+ @emulator_frame&.achievement_backend = @achievement_backend
721
+ end
722
+
723
+ def push_settings_to_ui
724
+ emit(:config_loaded, config: @config)
725
+ end
726
+
727
+ def refresh_from_config
728
+ @scale = @config.scale
729
+ apply_scale(@scale) if frame&.sdl2_ready?
730
+ frame&.receive(:refresh_from_config)
731
+ push_settings_to_ui
732
+ end
733
+
734
+ def toggle_per_game(enabled)
735
+ if enabled
736
+ @config.enable_per_game
737
+ else
738
+ @config.disable_per_game
739
+ end
740
+ refresh_from_config
741
+ end
742
+
743
+ # ── Window ─────────────────────────────────────────────────────────
744
+
745
+ def toggle_fullscreen
746
+ @fullscreen = !@fullscreen
747
+ @window.fullscreen = @fullscreen
748
+ end
749
+
750
+ def create_emulator_frame
751
+ EmulatorFrame.new(
752
+ app: @app, config: @config, platform: @platform, sound: @sound,
753
+ scale: @scale, kb_map: @kb_map, gp_map: @gp_map,
754
+ keyboard: @keyboard, hotkeys: @hotkeys,
755
+ frame_limit: @frame_limit,
756
+ volume: @config.volume / 100.0,
757
+ muted: @config.muted?,
758
+ turbo_speed: @config.turbo_speed,
759
+ turbo_volume: @config.turbo_volume_pct / 100.0,
760
+ keep_aspect_ratio: @config.keep_aspect_ratio?,
761
+ show_fps: @config.show_fps?,
762
+ pixel_filter: @config.pixel_filter,
763
+ integer_scale: @config.integer_scale?,
764
+ color_correction: @config.color_correction?,
765
+ frame_blending: @config.frame_blending?,
766
+ rewind_enabled: @config.rewind_enabled?,
767
+ rewind_seconds: @config.rewind_seconds,
768
+ quick_save_slot: @config.quick_save_slot,
769
+ save_state_backup: @config.save_state_backup?,
770
+ recording_compression: @config.recording_compression,
771
+ pause_on_focus_loss: @config.pause_on_focus_loss?,
772
+ )
773
+ end
774
+
775
+ def apply_frame_aspect(frame)
776
+ if (ratio = frame.aspect_ratio)
777
+ @window.set_aspect(*ratio)
778
+ else
779
+ @window.reset_aspect_ratio
780
+ end
781
+ end
782
+
783
+ def apply_scale(new_scale)
784
+ @scale = new_scale.clamp(1, 4)
785
+ w = @platform.width * @scale
786
+ h = @platform.height * @scale
787
+ @window.set_geometry(w, h)
788
+ end
789
+
790
+ def set_event_loop_speed(mode)
791
+ ms = mode == :fast ? EVENT_LOOP_FAST_MS : EVENT_LOOP_IDLE_MS
792
+ @window.set_timer_speed(ms)
793
+ end
794
+
795
+ # ── Gamepad ────────────────────────────────────────────────────────
796
+
797
+ def start_gamepad_probe
798
+ @app.after(GAMEPAD_PROBE_MS) { gamepad_probe_tick }
799
+ end
800
+
801
+ def gamepad_probe_tick
802
+ return unless @running
803
+ has_gp = @gamepad && !@gamepad.closed?
804
+ settings_visible = @app.command(:wm, 'state', SettingsWindow::TOP) != 'withdrawn' rescue false
805
+
806
+ if settings_visible && has_gp
807
+ Teek::SDL2::Gamepad.update_state
808
+
809
+ if @settings_window.listening_for
810
+ Teek::SDL2::Gamepad.buttons.each do |btn|
811
+ if @gamepad.button?(btn)
812
+ @settings_window.capture_mapping(btn)
813
+ break
814
+ end
815
+ end
816
+ end
817
+
818
+ @app.after(GAMEPAD_LISTEN_MS) { gamepad_probe_tick }
819
+ return
820
+ end
821
+
822
+ unless frame&.rom_loaded?
823
+ Teek::SDL2::Gamepad.poll_events rescue nil
824
+ end
825
+ @app.after(GAMEPAD_PROBE_MS) { gamepad_probe_tick }
826
+ end
827
+
828
+ def refresh_gamepads
829
+ names = [translate('settings.keyboard_only')]
830
+ prev_gp = @gamepad
831
+ 8.times do |i|
832
+ gp = begin; Teek::SDL2::Gamepad.open(i); rescue; nil; end
833
+ next unless gp
834
+ names << gp.name
835
+ @gamepad ||= gp
836
+ gp.close unless gp == @gamepad
837
+ end
838
+ @settings_window&.update_gamepad_list(names)
839
+ if @gamepad && @gamepad != prev_gp
840
+ Gemba.log(:info) { "Gamepad detected: #{@gamepad.name}" }
841
+ @gp_map.device = @gamepad
842
+ @gp_map.load_config
843
+ end
844
+ end
845
+
846
+ # ── Input maps ─────────────────────────────────────────────────────
847
+
848
+ def active_input
849
+ @settings_window.keyboard_mode? ? @kb_map : @gp_map
850
+ end
851
+
852
+ def undo_mappings
853
+ input = active_input
854
+ input.reload!
855
+ @settings_window.refresh_gamepad(input.labels, input.dead_zone_pct)
856
+ end
857
+
858
+ def undo_hotkeys
859
+ @hotkeys.reload!
860
+ @settings_window.refresh_hotkeys(@hotkeys.labels)
861
+ end
862
+
863
+ def validate_hotkey(hotkey)
864
+ return nil if hotkey.is_a?(Array)
865
+ @kb_map.labels.each do |gba_btn, key|
866
+ return "\"#{hotkey}\" is mapped to GBA button #{gba_btn.upcase}" if key == hotkey
867
+ end
868
+ nil
869
+ end
870
+
871
+ def validate_kb_mapping(keysym)
872
+ action = @hotkeys.action_for(keysym)
873
+ if action
874
+ label = action.to_s.tr('_', ' ').capitalize
875
+ return "\"#{keysym}\" is assigned to hotkey: #{label}"
876
+ end
877
+ nil
878
+ end
879
+
880
+ # ── Global hotkeys (pre-SDL2) ──────────────────────────────────────
881
+
882
+ CURSOR_HIDE_MS = 2000
883
+
884
+ def setup_cursor_autohide
885
+ @app.bind('.', '<Motion>') { on_cursor_motion }
886
+ end
887
+
888
+ def on_cursor_motion
889
+ show_cursor
890
+ return unless frame&.rom_loaded? && !frame&.paused?
891
+ @cursor_hide_job = @app.after(CURSOR_HIDE_MS) { hide_cursor }
892
+ end
893
+
894
+ def hide_cursor
895
+ return if @cursor_hidden
896
+ @cursor_hidden = true
897
+ Teek::SDL2.hide_cursor if Teek::SDL2.respond_to?(:hide_cursor)
898
+ end
899
+
900
+ def show_cursor
901
+ @app.after_cancel(@cursor_hide_job) if @cursor_hide_job
902
+ @cursor_hide_job = nil
903
+ return unless @cursor_hidden
904
+ @cursor_hidden = false
905
+ Teek::SDL2.show_cursor if Teek::SDL2.respond_to?(:show_cursor)
906
+ end
907
+
908
+ def setup_global_hotkeys
909
+ # '?' toggles the hotkey reference panel. Bound on 'all' so it fires even
910
+ # when the help window itself has focus after being shown.
911
+ @app.bind('all', 'KeyPress-question') { @app.command(:event, 'generate', '.', '<<ToggleHelpWindow>>') }
912
+ @app.command(:bind, '.', '<<ToggleHelpWindow>>', proc { toggle_help })
913
+
914
+ @app.bind('.', 'KeyPress', :keysym, '%s') do |k, state_str|
915
+ next if frame&.sdl2_ready? || @modal_stack.active?
916
+
917
+ if k == 'Escape'
918
+ self.running = false if confirm_quit
919
+ else
920
+ mods = HotkeyMap.modifiers_from_state(state_str.to_i)
921
+ case @hotkeys.action_for(k, modifiers: mods)
922
+ when :quit then self.running = false if confirm_quit
923
+ when :open_rom then handle_open_rom
924
+ end
925
+ end
926
+ end
927
+ end
928
+
929
+ # ── Helpers ────────────────────────────────────────────────────────
930
+
931
+ def toggle_help
932
+ return if @fullscreen
933
+ return if @modal_stack.active?
934
+
935
+ @help_window ||= HelpWindow.new(app: @app, hotkeys: @hotkeys)
936
+
937
+ if @help_window.visible?
938
+ @help_window.hide
939
+ frame&.receive(:pause) if @help_auto_paused # toggle back to playing
940
+ @help_auto_paused = false
941
+ else
942
+ @help_auto_paused = frame&.rom_loaded? && !frame&.paused?
943
+ frame&.receive(:pause) if @help_auto_paused # pause while reading
944
+ @help_window.show
945
+ end
946
+ end
947
+
948
+ def show_patcher
949
+ @patcher_window ||= PatcherWindow.new(app: @app)
950
+ @patcher_window.show
951
+ end
952
+
953
+ def bell
954
+ @app.command(:bell)
955
+ end
956
+
957
+ def show_rom_error(message)
958
+ @app.command('tk_messageBox',
959
+ parent: '.',
960
+ title: translate('dialog.drop_error_title'),
961
+ message: message,
962
+ type: :ok,
963
+ icon: :error)
964
+ end
965
+
966
+ def check_writable_dirs
967
+ dirs = {
968
+ 'Config' => Config.config_dir,
969
+ 'Saves' => @config.saves_dir,
970
+ 'Save States' => Config.default_states_dir,
971
+ }
972
+
973
+ problems = []
974
+ dirs.each do |label, dir|
975
+ begin
976
+ FileUtils.mkdir_p(dir)
977
+ rescue SystemCallError => e
978
+ problems << "#{label}: #{dir}\n #{e.message}"
979
+ next
980
+ end
981
+ unless File.writable?(dir)
982
+ problems << "#{label}: #{dir}\n Not writable"
983
+ end
984
+ end
985
+
986
+ return if problems.empty?
987
+
988
+ msg = "Cannot write to required directories:\n\n#{problems.join("\n\n")}\n\n" \
989
+ "Check file permissions or set a custom path in config."
990
+ @app.command(:tk_messageBox, icon: :error, type: :ok,
991
+ title: 'gemba', message: msg)
992
+ @app.destroy('.')
993
+ exit 1
994
+ end
995
+
996
+ def open_config_dir
997
+ Gemba.open_directory(Config.config_dir)
998
+ end
999
+
1000
+ def open_recordings_dir
1001
+ Gemba.open_directory(@config.recordings_dir)
1002
+ end
1003
+
1004
+ def open_logs_dir
1005
+ Gemba.open_directory(Config.default_logs_dir)
1006
+ end
1007
+
1008
+ def apply_picker_window(picker)
1009
+ w, h = picker.default_geometry
1010
+ mn_w, mn_h = picker.min_geometry
1011
+ @window.set_geometry(w, h)
1012
+ @window.set_minsize(mn_w, mn_h)
1013
+ apply_frame_aspect(picker)
1014
+ end
1015
+
1016
+ def switch_picker_view(view)
1017
+ return if @config.picker_view == view
1018
+ new_picker = view == 'list' ? @list_picker : @game_picker
1019
+ @frame_stack.replace_current(new_picker)
1020
+ @active_picker = new_picker
1021
+ @config.picker_view = view
1022
+ save_config
1023
+ apply_picker_window(new_picker)
1024
+ end
1025
+
1026
+ def cleanup
1027
+ return if @cleaned_up
1028
+ @cleaned_up = true
1029
+ @emulator_frame&.cleanup
1030
+ @game_picker&.cleanup
1031
+ @list_picker&.cleanup
1032
+ RomResolver.cleanup_temp
1033
+ @achievement_backend&.shutdown
1034
+ end
1035
+ end
1036
+ end