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,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,70 @@
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
+ # Replace the current frame in-place without changing the stack depth.
52
+ #
53
+ # The existing frame is hidden; the new one is shown under the same name.
54
+ def replace_current(frame)
55
+ return unless (entry = @stack.last)
56
+ entry.frame.hide
57
+ @stack[-1] = Entry.new(name: entry.name, frame: frame)
58
+ frame.show
59
+ end
60
+
61
+ # Pop the current frame off the stack.
62
+ #
63
+ # The popped frame is hidden. If there's a previous frame, it is re-shown.
64
+ def pop
65
+ return unless (entry = @stack.pop)
66
+ entry.frame.hide
67
+ @stack.last&.frame&.show
68
+ end
69
+ end
70
+ 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,309 @@
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 default_geometry = [PICKER_DEFAULT_W, PICKER_DEFAULT_H]
34
+ def min_geometry = [PICKER_MIN_W, PICKER_MIN_H]
35
+
36
+ def initialize(app:, rom_library:, boxart_fetcher: nil, rom_overrides: nil)
37
+ @app = app
38
+ @rom_library = rom_library
39
+ @fetcher = boxart_fetcher
40
+ @overrides = rom_overrides
41
+ @built = false
42
+ @cards = {} # index => { frame:, image:, title:, platform:, photo: }
43
+ @photos = {} # key => Tk image name (kept alive to prevent GC)
44
+ end
45
+
46
+ def show
47
+ build_ui unless @built
48
+ refresh
49
+ @app.command(:pack, @outer, fill: :both, expand: 1)
50
+ end
51
+
52
+ def hide
53
+ @app.command(:pack, :forget, @outer) rescue nil
54
+ end
55
+
56
+ def cleanup
57
+ @photos&.each_value { |name| @app.command(:image, :delete, name) rescue nil }
58
+ @photos&.clear
59
+ end
60
+
61
+ def receive(event, **args)
62
+ case event
63
+ when :refresh then refresh
64
+ end
65
+ end
66
+
67
+ def aspect_ratio = [PICKER_ASPECT_W, PICKER_ASPECT_H]
68
+ def rom_loaded? = false
69
+ def sdl2_ready? = false
70
+ def paused? = false
71
+
72
+ private
73
+
74
+ def build_ui
75
+ @outer = '.game_picker'
76
+ @app.command('ttk::frame', @outer, padding: 0)
77
+
78
+ @cards_frame = "#{@outer}.cards"
79
+ @app.command('ttk::frame', @cards_frame, padding: 16)
80
+ @app.command(:pack, @cards_frame, fill: :both, expand: 1)
81
+
82
+ # Capture the system window background color so hollow cards blend in
83
+ # rather than appearing as stark black rectangles.
84
+ @empty_bg = @app.tcl_eval(". cget -background")
85
+
86
+ # Load a transparent 128×128 placeholder once — gives all image labels
87
+ # a fixed pixel size whether or not box art has been fetched yet.
88
+ @app.command(:image, :create, :photo, 'boxart_placeholder', file: PLACEHOLDER_PNG)
89
+
90
+ SLOTS.times do |i|
91
+ row = i / COLS
92
+ col = i % COLS
93
+
94
+ cell = "#{@cards_frame}.card#{i}"
95
+ @app.command(:frame, cell, relief: :groove, borderwidth: 1,
96
+ padx: 4, pady: 4, bg: '#2a2a2a')
97
+ @app.command(:grid, cell, row: row, column: col, padx: 6, pady: 6, sticky: :nsew)
98
+
99
+ img_lbl = "#{cell}.img"
100
+ @app.command(:label, img_lbl, bg: '#2a2a2a', anchor: :center, image: 'boxart_placeholder')
101
+ @app.command(:pack, img_lbl, fill: :x)
102
+
103
+ title_lbl = "#{cell}.title"
104
+ @app.command(:label, title_lbl, text: '', anchor: :center,
105
+ bg: '#2a2a2a', fg: '#cccccc',
106
+ font: '{TkDefaultFont} 10',
107
+ justify: :center, wraplength: IMG_SIZE)
108
+ @app.command(:bind, title_lbl, '<Configure>', proc {
109
+ w = @app.tcl_eval("winfo width #{title_lbl}").to_i
110
+ @app.command(title_lbl, :configure, wraplength: w - 8) if w > 8
111
+ })
112
+ @app.command(:pack, title_lbl, fill: :x, pady: [4, 2])
113
+
114
+ plat_lbl = "#{cell}.plat"
115
+ @app.command(:label, plat_lbl, text: '', anchor: :center,
116
+ bg: '#2a2a2a', fg: '#888888',
117
+ font: '{TkDefaultFont} 8')
118
+ @app.command(:pack, plat_lbl, fill: :x, pady: [0, 4])
119
+
120
+ @cards[i] = { frame: cell, image: img_lbl, title: title_lbl, platform: plat_lbl }
121
+ end
122
+
123
+ # Make columns and rows expand evenly
124
+ COLS.times { |c| @app.command(:grid, :columnconfigure, @cards_frame, c, weight: 1) }
125
+ ROWS.times { |r| @app.command(:grid, :rowconfigure, @cards_frame, r, weight: 1) }
126
+
127
+ build_toolbar
128
+
129
+ @built = true
130
+ end
131
+
132
+ def build_toolbar
133
+ sep = "#{@outer}.sep"
134
+ @app.command('ttk::separator', sep, orient: :horizontal)
135
+ @app.command(:pack, sep, fill: :x)
136
+
137
+ toolbar = "#{@outer}.toolbar"
138
+ @app.command('ttk::frame', toolbar, padding: [4, 2])
139
+ @app.command(:pack, toolbar, fill: :x)
140
+
141
+ gear_btn = "#{toolbar}.gear"
142
+ gear_menu = "#{toolbar}.gearmenu"
143
+ @app.command('ttk::button', gear_btn, text: "\u2699", width: 1,
144
+ command: proc { post_view_menu(gear_menu, gear_btn) })
145
+ @app.command(:pack, gear_btn, side: :right)
146
+ end
147
+
148
+ def post_view_menu(menu, btn)
149
+ @app.command(:menu, menu, tearoff: 0) unless @app.tcl_eval("winfo exists #{menu}") == '1'
150
+ @app.command(menu, :delete, 0, :end)
151
+ current = Gemba.user_config.picker_view
152
+ @app.command(menu, :add, :command,
153
+ label: "#{current == 'grid' ? "\u2713 " : ' '}#{translate('picker.toolbar.boxart_view')}",
154
+ command: proc { emit(:picker_view_changed, view: 'grid') })
155
+ @app.command(menu, :add, :command,
156
+ label: "#{current == 'list' ? "\u2713 " : ' '}#{translate('picker.toolbar.list_view')}",
157
+ command: proc { emit(:picker_view_changed, view: 'list') })
158
+ x = @app.tcl_eval("winfo rootx #{btn}").to_i
159
+ y = @app.tcl_eval("winfo rooty #{btn}").to_i
160
+ h = @app.tcl_eval("winfo height #{btn}").to_i
161
+ @app.tcl_eval("tk_popup #{menu} #{x} #{y + h}")
162
+ end
163
+
164
+ def refresh
165
+ roms = @rom_library.all.first(SLOTS)
166
+
167
+ SLOTS.times do |i|
168
+ card = @cards[i]
169
+ rom = roms[i]
170
+
171
+ if rom
172
+ rom_info = RomInfo.from_rom(rom, fetcher: @fetcher, overrides: @overrides)
173
+ populate_card(card, rom_info)
174
+ else
175
+ hollow_card(card)
176
+ end
177
+ end
178
+ end
179
+
180
+ def populate_card(card, rom_info)
181
+ @app.command(card[:image], :configure, bg: '#2a2a2a')
182
+ @app.command(card[:title], :configure, text: rom_info.title, fg: '#cccccc', bg: '#2a2a2a')
183
+ @app.command(card[:platform], :configure, text: rom_info.platform, fg: '#888888', bg: '#2a2a2a')
184
+ @app.command(card[:frame], :configure, relief: :groove, bg: '#2a2a2a')
185
+
186
+ # Determine which image to show
187
+ key = rom_info.rom_id || rom_info.game_code
188
+
189
+ if rom_info.boxart_path
190
+ # Custom override or cached art — load immediately
191
+ if @photos.key?(key)
192
+ @app.command(card[:image], :configure, image: @photos[key])
193
+ else
194
+ set_card_image(card, key, rom_info.boxart_path)
195
+ end
196
+ elsif rom_info.has_official_entry && @fetcher && rom_info.game_code
197
+ # No art yet but libretro has an entry — kick off async fetch
198
+ @app.command(card[:image], :configure, image: 'boxart_placeholder')
199
+ @fetcher.fetch(rom_info.game_code) { |path| set_card_image(card, key, path) }
200
+ else
201
+ @app.command(card[:image], :configure, image: 'boxart_placeholder')
202
+ end
203
+
204
+ # Left-click → play
205
+ click = proc { emit(:rom_selected, rom_info.path) }
206
+ @app.command(:bind, card[:frame], '<Button-1>', click)
207
+ @app.command(:bind, card[:image], '<Button-1>', click)
208
+ @app.command(:bind, card[:title], '<Button-1>', click)
209
+ @app.command(:bind, card[:platform], '<Button-1>', click)
210
+
211
+ # Right-click → context menu
212
+ bind_context_menu(card, rom_info)
213
+ end
214
+
215
+ def hollow_card(card)
216
+ @app.command(card[:image], :configure, image: 'boxart_placeholder', bg: @empty_bg)
217
+ @app.command(card[:title], :configure, text: '', fg: @empty_bg, bg: @empty_bg)
218
+ @app.command(card[:platform], :configure, text: '', bg: @empty_bg)
219
+ @app.command(card[:frame], :configure, relief: :ridge, bg: @empty_bg)
220
+
221
+ [:frame, :image, :title, :platform].each do |k|
222
+ @app.command(:bind, card[k], '<Button-1>', '')
223
+ @app.command(:bind, card[k], '<Button-3>', '')
224
+ end
225
+ end
226
+
227
+ def bind_context_menu(card, rom_info)
228
+ handler = proc { post_card_menu(card, rom_info) }
229
+ @app.command(:bind, card[:frame], '<Button-3>', handler)
230
+ @app.command(:bind, card[:image], '<Button-3>', handler)
231
+ @app.command(:bind, card[:title], '<Button-3>', handler)
232
+ @app.command(:bind, card[:platform], '<Button-3>', handler)
233
+ end
234
+
235
+ def post_card_menu(card, rom_info)
236
+ menu = "#{card[:frame]}.ctx"
237
+ exists = @app.tcl_eval("winfo exists #{menu}") == '1'
238
+ @app.command(:menu, menu, tearoff: 0) unless exists
239
+ @app.command(menu, :delete, 0, :end)
240
+ @app.command(menu, :add, :command,
241
+ label: translate('game_picker.menu.play'),
242
+ command: proc { emit(:rom_selected, rom_info.path) })
243
+ qs_slot = quick_save_slot
244
+ qs_state = quick_save_exists?(rom_info, qs_slot)
245
+ @app.command(menu, :add, :command,
246
+ label: translate('game_picker.menu.quick_load'),
247
+ state: qs_state ? :normal : :disabled,
248
+ command: proc { emit(:rom_quick_load, path: rom_info.path, slot: qs_slot) })
249
+ @app.command(menu, :add, :command,
250
+ label: translate('game_picker.menu.set_boxart'),
251
+ command: proc { pick_custom_boxart(card, rom_info) })
252
+ @app.command(menu, :add, :separator)
253
+ @app.command(menu, :add, :command,
254
+ label: translate('game_picker.menu.remove'),
255
+ command: proc { remove_rom(rom_info) })
256
+ @app.tcl_eval("tk_popup #{menu} [winfo pointerx .] [winfo pointery .]")
257
+ end
258
+
259
+ def quick_save_slot
260
+ Gemba.user_config.quick_save_slot
261
+ end
262
+
263
+ def quick_save_exists?(rom_info, slot)
264
+ return false unless rom_info.rom_id
265
+ state_file = File.join(Gemba.user_config.states_dir, rom_info.rom_id, "state#{slot}.ss")
266
+ File.exist?(state_file)
267
+ end
268
+
269
+ def remove_rom(rom_info)
270
+ @rom_library.remove(rom_info.rom_id)
271
+ @rom_library.save!
272
+ refresh
273
+ end
274
+
275
+ def pick_custom_boxart(card, rom_info)
276
+ return unless @overrides
277
+ filetypes = '{{PNG Images} {.png}}'
278
+ path = @app.tcl_eval("tk_getOpenFile -filetypes {#{filetypes}}")
279
+ return if path.to_s.strip.empty?
280
+ dest = @overrides.set_custom_boxart(rom_info.rom_id, path)
281
+ key = rom_info.rom_id || rom_info.game_code
282
+ set_card_image(card, key, dest)
283
+ end
284
+
285
+ def set_card_image(card, key, path)
286
+ # Load full-size photo, scale to fit within IMG_SIZE, delete the original.
287
+ # Subsample factor is computed from actual dimensions so arbitrary-sized
288
+ # user images (e.g. custom boxart) don't break the card layout.
289
+ full_name = "boxart_full_#{key}"
290
+ small_name = "boxart_#{key}"
291
+
292
+ @app.command(:image, :create, :photo, full_name, file: path)
293
+ w = @app.tcl_eval("image width #{full_name}").to_i
294
+ h = @app.tcl_eval("image height #{full_name}").to_i
295
+ factor = [[(w.to_f / IMG_SIZE).ceil, (h.to_f / IMG_SIZE).ceil].max, 1].max
296
+
297
+ @app.command(:image, :create, :photo, small_name)
298
+ @app.command(small_name, :copy, full_name, subsample: factor)
299
+ @app.command(:image, :delete, full_name)
300
+
301
+ old = @photos[key]
302
+ @photos[key] = small_name
303
+ @app.command(card[:image], :configure, image: small_name)
304
+ @app.command(:image, :delete, old) if old && old != small_name
305
+ rescue => e
306
+ Gemba.log(:warn) { "BoxArt image load failed for #{key}: #{e.message}" }
307
+ end
308
+ end
309
+ 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?