gemba 0.1.1 → 0.2.0

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