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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Gemba
5
+ # Publish/subscribe event bus for decoupled communication.
6
+ #
7
+ # Emitters fire named events, subscribers listen — no intermediaries.
8
+ #
9
+ # Lives at Gemba.bus (module-level). Player creates it at startup;
10
+ # any class does Gemba.bus.emit / Gemba.bus.on. For tests, replace
11
+ # with Gemba.bus = EventBus.new (or a mock).
12
+ #
13
+ # @example
14
+ # Gemba.bus.on(:scale_changed) { |val| apply_scale(val) }
15
+ # Gemba.bus.emit(:scale_changed, 3)
16
+ class EventBus
17
+ def initialize
18
+ @listeners = Hash.new { |h, k| h[k] = [] }
19
+ end
20
+
21
+ # Subscribe to a named event.
22
+ # @param event [Symbol]
23
+ # @return [Proc] the block (for later #off)
24
+ def on(event, &block)
25
+ @listeners[event] << block
26
+ block
27
+ end
28
+
29
+ # Emit a named event to all subscribers.
30
+ # @param event [Symbol]
31
+ def emit(event, *args, **kwargs)
32
+ Gemba.log(:debug) { "bus: #{event}(#{[*args, *kwargs.map { |k,v| "#{k}: #{v}" }].join(', ')})" }
33
+ if kwargs.empty?
34
+ @listeners[event].each { |cb| cb.call(*args) }
35
+ else
36
+ @listeners[event].each { |cb| cb.call(*args, **kwargs) }
37
+ end
38
+ end
39
+
40
+ # Unsubscribe a specific block.
41
+ # @param event [Symbol]
42
+ # @param block [Proc] the block returned by #on
43
+ def off(event, block)
44
+ @listeners[event].delete(block)
45
+ end
46
+ end
47
+
48
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Push/pop stack for content frames inside the main window.
5
+ #
6
+ # Mirrors the ModalStack pattern. When a new frame is pushed, the
7
+ # previous frame is hidden and the new one is shown. Popping reverses
8
+ # the transition.
9
+ #
10
+ # Frames must implement the FrameStack protocol:
11
+ # show — pack/display the frame
12
+ # hide — unpack/remove the frame from view
13
+ # cleanup — release resources (SDL2, etc.)
14
+ #
15
+ # @example
16
+ # stack = FrameStack.new
17
+ # stack.push(:picker, game_picker_frame)
18
+ # stack.push(:emulator, emulator_frame) # picker auto-hidden
19
+ # stack.pop # emulator hidden, picker re-shown
20
+ class FrameStack
21
+ Entry = Data.define(:name, :frame)
22
+
23
+ def initialize
24
+ @stack = []
25
+ end
26
+
27
+ # @return [Boolean] true if any frame is on the stack
28
+ def active? = !@stack.empty?
29
+
30
+ # @return [Symbol, nil] name of the topmost frame
31
+ def current = @stack.last&.name
32
+
33
+ # @return [Object, nil] the topmost frame object
34
+ def current_frame = @stack.last&.frame
35
+
36
+ # @return [Integer] number of frames on the stack
37
+ def size = @stack.length
38
+
39
+ # Push a frame onto the stack.
40
+ #
41
+ # The previous frame (if any) is hidden before the new one is shown.
42
+ #
43
+ # @param name [Symbol] identifier (e.g. :picker, :emulator)
44
+ # @param frame [#show, #hide] the frame object
45
+ def push(name, frame)
46
+ @stack.last&.frame&.hide
47
+ @stack.push(Entry.new(name: name, frame: frame))
48
+ frame.show
49
+ end
50
+
51
+ # Pop the current frame off the stack.
52
+ #
53
+ # The popped frame is hidden. If there's a previous frame, it is re-shown.
54
+ def pop
55
+ return unless (entry = @stack.pop)
56
+ entry.frame.hide
57
+ @stack.last&.frame&.show
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Gemba
6
+ # Lookup table mapping ROM serial codes to canonical game names.
7
+ #
8
+ # Data is pre-baked from No-Intro DAT files via script/bake_game_index.rb
9
+ # and stored as JSON in lib/gemba/data/{platform}_games.json.
10
+ #
11
+ # Loaded lazily on first lookup per platform.
12
+ #
13
+ # GameIndex.lookup("AGB-AXVE") # => "Pokemon - Ruby Version (USA)"
14
+ # GameIndex.lookup("CGB-BYTE") # => nil (unknown)
15
+ #
16
+ class GameIndex
17
+ DATA_DIR = File.expand_path("data", __dir__)
18
+
19
+ PLATFORM_FILES = {
20
+ "AGB" => "gba_games.json",
21
+ "CGB" => "gbc_games.json",
22
+ "DMG" => "gb_games.json",
23
+ }.freeze
24
+
25
+ MD5_FILES = {
26
+ "AGB" => "gba_md5.json",
27
+ "CGB" => "gbc_md5.json",
28
+ "DMG" => "gb_md5.json",
29
+ }.freeze
30
+
31
+ # Maps RomLibrary platform short names → GameIndex prefixes
32
+ PLATFORM_PREFIX = { "gba" => "AGB", "gbc" => "CGB", "gb" => "DMG" }.freeze
33
+
34
+ class << self
35
+ # Look up a canonical game name by serial code.
36
+ # @param game_code [String] e.g. "AGB-AXVE", "CGB-BYTE", "DMG-XXXX"
37
+ # @return [String, nil] canonical name or nil if not found
38
+ def lookup(game_code)
39
+ return nil unless game_code && !game_code.empty?
40
+
41
+ platform = game_code.split("-", 2).first
42
+ index = index_for(platform, PLATFORM_FILES)
43
+ return nil unless index
44
+
45
+ index[game_code]
46
+ end
47
+
48
+ # Look up a canonical game name by MD5 hex digest.
49
+ # @param md5 [String] hex MD5 of ROM content (any case)
50
+ # @param platform [String] short name from RomLibrary — "gba", "gbc", or "gb"
51
+ # @return [String, nil]
52
+ def lookup_by_md5(md5, platform)
53
+ return nil unless md5 && !md5.empty?
54
+
55
+ prefix = PLATFORM_PREFIX[platform.to_s.downcase]
56
+ return nil unless prefix
57
+
58
+ idx = index_for(prefix, MD5_FILES)
59
+ idx&.[](md5.downcase)
60
+ end
61
+
62
+ # Force-reload all indexes (useful after re-baking).
63
+ def reset!
64
+ @indexes = {}
65
+ end
66
+
67
+ private
68
+
69
+ def index_for(platform, files)
70
+ @indexes ||= {}
71
+ key = "#{platform}:#{files.object_id}"
72
+ return @indexes[key] if @indexes.key?(key)
73
+
74
+ file = files[platform]
75
+ return(@indexes[key] = nil) unless file
76
+
77
+ path = File.join(DATA_DIR, file)
78
+ return(@indexes[key] = nil) unless File.exist?(path)
79
+
80
+ @indexes[key] = JSON.parse(File.read(path))
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Gemba
5
+ # Startup frame showing a 4×4 grid of ROM cards.
6
+ #
7
+ # Each card displays box art (if available), ROM title, and platform.
8
+ # Clicking a populated card emits :rom_selected on the bus.
9
+ # Right-clicking a populated card shows a context menu (Play / Set Boxart).
10
+ # Pure Tk — no SDL2.
11
+ class GamePickerFrame
12
+ include BusEmitter
13
+ include Locale::Translatable
14
+
15
+ COLS = 4
16
+ ROWS = 4
17
+ SLOTS = COLS * ROWS
18
+ IMG_SUBSAMPLE = 4 # 512px ÷ 4 = 128px per card
19
+ IMG_SIZE = 128 # height/width of the scaled image in pixels
20
+ PLACEHOLDER_PNG = File.expand_path("../../assets/placeholder_boxart.png", __dir__)
21
+
22
+ # Aspect ratio for wm aspect lock when picker is visible (width:height).
23
+ # 3:4 gives enough vertical room for image + title + platform label.
24
+ PICKER_ASPECT_W = 3
25
+ PICKER_ASPECT_H = 4
26
+
27
+ # Default and minimum picker window dimensions (must satisfy PICKER_ASPECT ratio)
28
+ PICKER_DEFAULT_W = 768
29
+ PICKER_DEFAULT_H = 1024 # 768 * 4/3
30
+ PICKER_MIN_W = 576
31
+ PICKER_MIN_H = 768
32
+
33
+ def initialize(app:, rom_library:, boxart_fetcher: nil, rom_overrides: nil)
34
+ @app = app
35
+ @rom_library = rom_library
36
+ @fetcher = boxart_fetcher
37
+ @overrides = rom_overrides
38
+ @built = false
39
+ @cards = {} # index => { frame:, image:, title:, platform:, photo: }
40
+ @photos = {} # key => Tk image name (kept alive to prevent GC)
41
+ end
42
+
43
+ def show
44
+ build_ui unless @built
45
+ refresh
46
+ @app.command(:pack, @grid, fill: :both, expand: 1)
47
+ end
48
+
49
+ def hide
50
+ @app.command(:pack, :forget, @grid) rescue nil
51
+ end
52
+
53
+ def cleanup
54
+ @photos&.each_value { |name| @app.command(:image, :delete, name) rescue nil }
55
+ @photos&.clear
56
+ end
57
+
58
+ def receive(event, **args)
59
+ case event
60
+ when :refresh then refresh
61
+ end
62
+ end
63
+
64
+ def aspect_ratio = [PICKER_ASPECT_W, PICKER_ASPECT_H]
65
+ def rom_loaded? = false
66
+ def sdl2_ready? = false
67
+ def paused? = false
68
+
69
+ private
70
+
71
+ def build_ui
72
+ @grid = '.game_picker'
73
+ @app.command('ttk::frame', @grid, padding: 16)
74
+
75
+ # Capture the system window background color so hollow cards blend in
76
+ # rather than appearing as stark black rectangles.
77
+ @empty_bg = @app.tcl_eval(". cget -background")
78
+
79
+ # Load a transparent 128×128 placeholder once — gives all image labels
80
+ # a fixed pixel size whether or not box art has been fetched yet.
81
+ @app.command(:image, :create, :photo, 'boxart_placeholder', file: PLACEHOLDER_PNG)
82
+
83
+ SLOTS.times do |i|
84
+ row = i / COLS
85
+ col = i % COLS
86
+
87
+ cell = "#{@grid}.card#{i}"
88
+ @app.command(:frame, cell, relief: :groove, borderwidth: 2,
89
+ padx: 4, pady: 4, bg: '#2a2a2a')
90
+ @app.command(:grid, cell, row: row, column: col, padx: 6, pady: 6, sticky: :nsew)
91
+
92
+ img_lbl = "#{cell}.img"
93
+ @app.command(:label, img_lbl, bg: '#2a2a2a', anchor: :center, image: 'boxart_placeholder')
94
+ @app.command(:pack, img_lbl, fill: :x)
95
+
96
+ title_lbl = "#{cell}.title"
97
+ @app.command(:label, title_lbl, text: '', anchor: :center,
98
+ bg: '#2a2a2a', fg: '#cccccc',
99
+ font: '{TkDefaultFont} 10',
100
+ justify: :center, wraplength: IMG_SIZE)
101
+ @app.command(:bind, title_lbl, '<Configure>', proc {
102
+ w = @app.tcl_eval("winfo width #{title_lbl}").to_i
103
+ @app.command(title_lbl, :configure, wraplength: w - 8) if w > 8
104
+ })
105
+ @app.command(:pack, title_lbl, fill: :x, pady: [4, 2])
106
+
107
+ plat_lbl = "#{cell}.plat"
108
+ @app.command(:label, plat_lbl, text: '', anchor: :center,
109
+ bg: '#2a2a2a', fg: '#888888',
110
+ font: '{TkDefaultFont} 8')
111
+ @app.command(:pack, plat_lbl, fill: :x, pady: [0, 4])
112
+
113
+ @cards[i] = { frame: cell, image: img_lbl, title: title_lbl, platform: plat_lbl }
114
+ end
115
+
116
+ # Make columns and rows expand evenly
117
+ COLS.times { |c| @app.command(:grid, :columnconfigure, @grid, c, weight: 1) }
118
+ ROWS.times { |r| @app.command(:grid, :rowconfigure, @grid, r, weight: 1) }
119
+
120
+ @built = true
121
+ end
122
+
123
+ def refresh
124
+ roms = @rom_library.all.first(SLOTS)
125
+
126
+ SLOTS.times do |i|
127
+ card = @cards[i]
128
+ rom = roms[i]
129
+
130
+ if rom
131
+ rom_info = RomInfo.from_rom(rom, fetcher: @fetcher, overrides: @overrides)
132
+ populate_card(card, rom_info)
133
+ else
134
+ hollow_card(card)
135
+ end
136
+ end
137
+ end
138
+
139
+ def populate_card(card, rom_info)
140
+ @app.command(card[:image], :configure, bg: '#2a2a2a')
141
+ @app.command(card[:title], :configure, text: rom_info.title, fg: '#cccccc', bg: '#2a2a2a')
142
+ @app.command(card[:platform], :configure, text: rom_info.platform, fg: '#888888', bg: '#2a2a2a')
143
+ @app.command(card[:frame], :configure, relief: :groove, bg: '#2a2a2a')
144
+
145
+ # Determine which image to show
146
+ key = rom_info.rom_id || rom_info.game_code
147
+
148
+ if rom_info.boxart_path
149
+ # Custom override or cached art — load immediately
150
+ if @photos.key?(key)
151
+ @app.command(card[:image], :configure, image: @photos[key])
152
+ else
153
+ set_card_image(card, key, rom_info.boxart_path)
154
+ end
155
+ elsif rom_info.has_official_entry && @fetcher && rom_info.game_code
156
+ # No art yet but libretro has an entry — kick off async fetch
157
+ @app.command(card[:image], :configure, image: 'boxart_placeholder')
158
+ @fetcher.fetch(rom_info.game_code) { |path| set_card_image(card, key, path) }
159
+ else
160
+ @app.command(card[:image], :configure, image: 'boxart_placeholder')
161
+ end
162
+
163
+ # Left-click → play
164
+ click = proc { emit(:rom_selected, rom_info.path) }
165
+ @app.command(:bind, card[:frame], '<Button-1>', click)
166
+ @app.command(:bind, card[:image], '<Button-1>', click)
167
+ @app.command(:bind, card[:title], '<Button-1>', click)
168
+ @app.command(:bind, card[:platform], '<Button-1>', click)
169
+
170
+ # Right-click → context menu
171
+ bind_context_menu(card, rom_info)
172
+ end
173
+
174
+ def hollow_card(card)
175
+ @app.command(card[:image], :configure, image: 'boxart_placeholder', bg: @empty_bg)
176
+ @app.command(card[:title], :configure, text: '', fg: @empty_bg, bg: @empty_bg)
177
+ @app.command(card[:platform], :configure, text: '', bg: @empty_bg)
178
+ @app.command(card[:frame], :configure, relief: :ridge, bg: @empty_bg)
179
+
180
+ [:frame, :image, :title, :platform].each do |k|
181
+ @app.command(:bind, card[k], '<Button-1>', '')
182
+ @app.command(:bind, card[k], '<Button-3>', '')
183
+ end
184
+ end
185
+
186
+ def bind_context_menu(card, rom_info)
187
+ handler = proc { post_card_menu(card, rom_info) }
188
+ @app.command(:bind, card[:frame], '<Button-3>', handler)
189
+ @app.command(:bind, card[:image], '<Button-3>', handler)
190
+ @app.command(:bind, card[:title], '<Button-3>', handler)
191
+ @app.command(:bind, card[:platform], '<Button-3>', handler)
192
+ end
193
+
194
+ def post_card_menu(card, rom_info)
195
+ menu = "#{card[:frame]}.ctx"
196
+ exists = @app.tcl_eval("winfo exists #{menu}") == '1'
197
+ @app.command(:menu, menu, tearoff: 0) unless exists
198
+ @app.command(menu, :delete, 0, :end)
199
+ @app.command(menu, :add, :command,
200
+ label: translate('game_picker.menu.play'),
201
+ command: proc { emit(:rom_selected, rom_info.path) })
202
+ qs_slot = quick_save_slot
203
+ qs_state = quick_save_exists?(rom_info, qs_slot)
204
+ @app.command(menu, :add, :command,
205
+ label: translate('game_picker.menu.quick_load'),
206
+ state: qs_state ? :normal : :disabled,
207
+ command: proc { emit(:rom_quick_load, path: rom_info.path, slot: qs_slot) })
208
+ @app.command(menu, :add, :command,
209
+ label: translate('game_picker.menu.set_boxart'),
210
+ command: proc { pick_custom_boxart(card, rom_info) })
211
+ @app.command(menu, :add, :separator)
212
+ @app.command(menu, :add, :command,
213
+ label: translate('game_picker.menu.remove'),
214
+ command: proc { remove_rom(rom_info) })
215
+ @app.tcl_eval("tk_popup #{menu} [winfo pointerx .] [winfo pointery .]")
216
+ end
217
+
218
+ def quick_save_slot
219
+ Gemba.user_config.quick_save_slot
220
+ end
221
+
222
+ def quick_save_exists?(rom_info, slot)
223
+ return false unless rom_info.rom_id
224
+ state_file = File.join(Gemba.user_config.states_dir, rom_info.rom_id, "state#{slot}.ss")
225
+ File.exist?(state_file)
226
+ end
227
+
228
+ def remove_rom(rom_info)
229
+ @rom_library.remove(rom_info.rom_id)
230
+ @rom_library.save!
231
+ refresh
232
+ end
233
+
234
+ def pick_custom_boxart(card, rom_info)
235
+ return unless @overrides
236
+ filetypes = '{{PNG Images} {.png}}'
237
+ path = @app.tcl_eval("tk_getOpenFile -filetypes {#{filetypes}}")
238
+ return if path.to_s.strip.empty?
239
+ dest = @overrides.set_custom_boxart(rom_info.rom_id, path)
240
+ key = rom_info.rom_id || rom_info.game_code
241
+ set_card_image(card, key, dest)
242
+ end
243
+
244
+ def set_card_image(card, key, path)
245
+ # Load full-size photo, scale to fit within IMG_SIZE, delete the original.
246
+ # Subsample factor is computed from actual dimensions so arbitrary-sized
247
+ # user images (e.g. custom boxart) don't break the card layout.
248
+ full_name = "boxart_full_#{key}"
249
+ small_name = "boxart_#{key}"
250
+
251
+ @app.command(:image, :create, :photo, full_name, file: path)
252
+ w = @app.tcl_eval("image width #{full_name}").to_i
253
+ h = @app.tcl_eval("image height #{full_name}").to_i
254
+ factor = [[(w.to_f / IMG_SIZE).ceil, (h.to_f / IMG_SIZE).ceil].max, 1].max
255
+
256
+ @app.command(:image, :create, :photo, small_name)
257
+ @app.command(small_name, :copy, full_name, subsample: factor)
258
+ @app.command(:image, :delete, full_name)
259
+
260
+ old = @photos[key]
261
+ @photos[key] = small_name
262
+ @app.command(card[:image], :configure, image: small_name)
263
+ @app.command(:image, :delete, old) if old && old != small_name
264
+ rescue => e
265
+ Gemba.log(:warn) { "BoxArt image load failed for #{key}: #{e.message}" }
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Manages SDL gamepad button → GBA bitmask mappings.
5
+ #
6
+ # Shares the same interface as {KeyboardMap} so that Player can
7
+ # delegate to either without knowing which device type is active.
8
+ class GamepadMap
9
+ DEFAULT_MAP = {
10
+ a: KEY_A,
11
+ b: KEY_B,
12
+ back: KEY_SELECT,
13
+ start: KEY_START,
14
+ dpad_up: KEY_UP,
15
+ dpad_down: KEY_DOWN,
16
+ dpad_left: KEY_LEFT,
17
+ dpad_right: KEY_RIGHT,
18
+ left_shoulder: KEY_L,
19
+ right_shoulder: KEY_R,
20
+ }.freeze
21
+
22
+ DEFAULT_DEAD_ZONE = 8000
23
+
24
+ def initialize(config)
25
+ @config = config
26
+ @map = DEFAULT_MAP.dup
27
+ @device = nil
28
+ @dead_zone = DEFAULT_DEAD_ZONE
29
+ end
30
+
31
+ attr_accessor :device
32
+ attr_reader :dead_zone
33
+
34
+ def mask
35
+ return 0 unless @device && !@device.closed?
36
+ m = 0
37
+ @map.each { |btn, bit| m |= bit if @device.button?(btn) }
38
+ m
39
+ end
40
+
41
+ def set(gba_btn, gp_btn)
42
+ bit = GBA_BTN_BITS[gba_btn] or return
43
+ @map.delete_if { |_, v| v == bit }
44
+ @map[gp_btn] = bit
45
+ end
46
+
47
+ def reset!
48
+ @map = DEFAULT_MAP.dup
49
+ @dead_zone = DEFAULT_DEAD_ZONE
50
+ end
51
+
52
+ def load_config
53
+ return unless @device
54
+ guid = @device.guid rescue return
55
+ gp_cfg = @config.gamepad(guid, name: @device.name)
56
+
57
+ @map = {}
58
+ gp_cfg['mappings'].each do |gba_str, gp_str|
59
+ bit = GBA_BTN_BITS[gba_str.to_sym]
60
+ next unless bit
61
+ @map[gp_str.to_sym] = bit
62
+ end
63
+
64
+ pct = gp_cfg['dead_zone']
65
+ @dead_zone = (pct / 100.0 * 32767).round
66
+ end
67
+
68
+ def reload!
69
+ @config.reload!
70
+ load_config
71
+ end
72
+
73
+ def labels
74
+ result = {}
75
+ @map.each do |input, bit|
76
+ gba_btn = GBA_BTN_BITS.key(bit)
77
+ result[gba_btn] = input.to_s if gba_btn
78
+ end
79
+ result
80
+ end
81
+
82
+ def save_to_config
83
+ return unless @device
84
+ guid = @device.guid rescue return
85
+ @config.gamepad(guid, name: @device.name)
86
+ @config.set_dead_zone(guid, dead_zone_pct)
87
+ @map.each do |gp_btn, bit|
88
+ gba_btn = GBA_BTN_BITS.key(bit)
89
+ @config.set_mapping(guid, gba_btn, gp_btn) if gba_btn
90
+ end
91
+ end
92
+
93
+ def supports_deadzone? = true
94
+
95
+ def dead_zone_pct
96
+ (@dead_zone.to_f / 32767 * 100).round
97
+ end
98
+
99
+ def set_dead_zone(threshold)
100
+ @dead_zone = threshold.to_i
101
+ end
102
+ end
103
+ end
@@ -1,12 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Lightweight entry point for headless (no GUI) usage of gemba.
4
- # Loads only the C extension and pure-Ruby modules — no Tk, no SDL2.
3
+ # Lightweight entry point Tk and SDL2 are NOT loaded.
5
4
  #
6
5
  # require "gemba/headless"
7
6
  # Gemba::HeadlessPlayer.open("game.gba") { |p| p.step(60) }
8
7
 
9
8
  require_relative "runtime"
10
- require_relative "recorder"
11
- require_relative "recorder_decoder"
12
- require_relative "headless_player"
9
+
10
+ module Gemba
11
+ # Marker — signals the headless stack is loaded without Tk/SDL2.
12
+ module Headless; end
13
+ end
@@ -15,16 +15,19 @@ module Gemba
15
15
  class HeadlessPlayer
16
16
  # @param rom_path [String] path to ROM file (.gba, .gb, .gbc, .zip)
17
17
  # @param config [Config, nil] config object (uses default if nil)
18
- def initialize(rom_path, config: nil)
18
+ def initialize(rom_path, config: nil, bios_path: nil)
19
19
  @config = config || Gemba.user_config
20
- rom_path = RomLoader.resolve(rom_path)
20
+ rom_path = RomResolver.resolve(rom_path)
21
21
 
22
22
  saves = @config.saves_dir
23
23
  FileUtils.mkdir_p(saves) unless File.directory?(saves)
24
- @core = Core.new(rom_path, saves)
24
+ @core = Core.new(rom_path, saves, bios_path)
25
25
  @keys = 0
26
26
  end
27
27
 
28
+ # @return [Core] the underlying mGBA core
29
+ attr_reader :core
30
+
28
31
  # Open a HeadlessPlayer, yield it, and close when done.
29
32
  # @param rom_path [String]
30
33
  # @param opts [Hash] passed to {#initialize}
@@ -166,7 +169,9 @@ module Gemba
166
169
  def start_recording(path, compression: Zlib::BEST_SPEED)
167
170
  check_open!
168
171
  raise "Already recording" if recording?
172
+ platform = Platform.for(@core)
169
173
  @recorder = Recorder.new(path, width: @core.width, height: @core.height,
174
+ fps_fraction: platform.fps_fraction,
170
175
  compression: compression)
171
176
  @recorder.start
172
177
  end
@@ -184,6 +189,31 @@ module Gemba
184
189
 
185
190
  # @!endgroup
186
191
 
192
+ # @!group Input replay
193
+
194
+ # Replay a .gir input recording. Loads the anchor save state, validates
195
+ # the ROM checksum, then feeds each recorded bitmask to the core.
196
+ #
197
+ # @param gir_path [String] path to .gir file
198
+ # @yield [Integer, Integer] bitmask and zero-based frame index after each frame
199
+ # @return [Integer] number of frames replayed
200
+ def replay(gir_path)
201
+ check_open!
202
+ replayer = InputReplayer.new(gir_path)
203
+ replayer.validate!(@core)
204
+ @core.load_state_from_file(replayer.anchor_state_path)
205
+
206
+ replayer.each_bitmask do |mask, idx|
207
+ @core.set_keys(mask)
208
+ @core.run_frame
209
+ yield mask, idx if block_given?
210
+ end
211
+
212
+ replayer.frame_count
213
+ end
214
+
215
+ # @!endgroup
216
+
187
217
  # Shut down the core and free resources.
188
218
  def close
189
219
  return if closed?