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
data/lib/gemba/bios.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Immutable value object representing a GBA BIOS file.
5
+ # The stored config value is just the filename; this object resolves
6
+ # it to a full path and computes metadata on demand (memoized).
7
+ class Bios
8
+ EXPECTED_SIZE = 16_384
9
+
10
+ attr_reader :path
11
+
12
+ def initialize(path:)
13
+ @path = path
14
+ end
15
+
16
+ # Build a Bios from a bare filename stored in config.
17
+ def self.from_config_name(name)
18
+ return nil if name.nil? || name.empty?
19
+ new(path: File.join(Config.bios_dir, name))
20
+ end
21
+
22
+ def filename = File.basename(@path)
23
+ def exists? = File.exist?(@path)
24
+
25
+ def size
26
+ @size ||= exists? ? File.size(@path) : 0
27
+ end
28
+
29
+ def valid?
30
+ exists? && size == EXPECTED_SIZE
31
+ end
32
+
33
+ def checksum
34
+ return @checksum if defined?(@checksum)
35
+ @checksum = valid? ? Gemba.gba_bios_checksum(File.binread(@path)) : nil
36
+ end
37
+
38
+ def official? = checksum == GBA_BIOS_CHECKSUM
39
+ def ds_mode? = checksum == GBA_DS_BIOS_CHECKSUM
40
+ def known? = official? || ds_mode?
41
+
42
+ def label
43
+ return "Official GBA BIOS" if official?
44
+ return "NDS GBA Mode BIOS" if ds_mode?
45
+ "Unknown BIOS"
46
+ end
47
+
48
+ def status_text
49
+ return "File not found (#{@path})" unless exists?
50
+ return "Invalid size (#{size} bytes, expected #{EXPECTED_SIZE})" unless valid?
51
+ "#{label} · #{size} bytes"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Gemba
6
+ class BoxartFetcher
7
+ # Resolves box art URLs from the LibRetro thumbnails CDN.
8
+ #
9
+ # URL pattern:
10
+ # https://thumbnails.libretro.com/{system}/Named_Boxarts/{encoded_name}.png
11
+ #
12
+ # Requires game_code → canonical name mapping via GameIndex.
13
+ class LibretroBackend
14
+ SYSTEMS = {
15
+ "AGB" => "Nintendo - Game Boy Advance",
16
+ "CGB" => "Nintendo - Game Boy Color",
17
+ "DMG" => "Nintendo - Game Boy",
18
+ }.freeze
19
+
20
+ BASE_URL = "https://thumbnails.libretro.com"
21
+
22
+ # @param game_code [String] e.g. "AGB-BPEE"
23
+ # @return [String, nil] full URL to the box art PNG, or nil if unknown
24
+ def url_for(game_code)
25
+ platform = game_code.split("-", 2).first
26
+ system = SYSTEMS[platform]
27
+ return nil unless system
28
+
29
+ name = GameIndex.lookup(game_code)
30
+ return nil unless name
31
+
32
+ encoded_system = URI.encode_www_form_component(system).gsub("+", "%20")
33
+ encoded_name = URI.encode_www_form_component(name).gsub("+", "%20")
34
+
35
+ "#{BASE_URL}/#{encoded_system}/Named_Boxarts/#{encoded_name}.png"
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ class BoxartFetcher
5
+ # No-op backend that never resolves URLs. Used in tests and offline mode.
6
+ class NullBackend
7
+ def url_for(_game_code)
8
+ nil
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "fileutils"
5
+
6
+ module Gemba
7
+ # Fetches and caches box art images for ROMs.
8
+ #
9
+ # Delegates URL resolution to a pluggable backend (anything responding to
10
+ # +#url_for(game_code)+). Downloads happen off the main thread via
11
+ # +Teek::BackgroundWork+ so the UI stays responsive.
12
+ #
13
+ # Cache layout:
14
+ # {cache_dir}/{game_code}/boxart.png
15
+ #
16
+ # Usage:
17
+ # fetcher = BoxartFetcher.new(app: app, cache_dir: Config.boxart_dir, backend: backend)
18
+ # fetcher.fetch("AGB-BPEE") { |path| update_card_image(path) }
19
+ #
20
+ class BoxartFetcher
21
+ attr_reader :cache_dir
22
+
23
+ def initialize(app:, cache_dir:, backend:)
24
+ @app = app
25
+ @cache_dir = cache_dir
26
+ @backend = backend
27
+ @in_flight = {} # game_code => true, prevents duplicate fetches
28
+ end
29
+
30
+ # Fetch box art for a game code. If cached, yields the path immediately.
31
+ # Otherwise kicks off an async download and yields the path on completion.
32
+ #
33
+ # @param game_code [String] e.g. "AGB-BPEE"
34
+ # @yield [path] called on the main thread with the cached file path
35
+ # @yieldparam path [String] absolute path to the cached PNG
36
+ def fetch(game_code, &on_fetched)
37
+ return unless on_fetched
38
+
39
+ cached = cached_path(game_code)
40
+ if File.exist?(cached)
41
+ on_fetched.call(cached)
42
+ return
43
+ end
44
+
45
+ url = @backend.url_for(game_code)
46
+ return unless url
47
+ return if @in_flight[game_code]
48
+
49
+ @in_flight[game_code] = true
50
+
51
+ Teek::BackgroundWork.new(@app, { url: url, dest: cached, game_code: game_code }, mode: :thread) do |t, data|
52
+ uri = URI(data[:url])
53
+ response = Net::HTTP.get_response(uri)
54
+ if response.is_a?(Net::HTTPSuccess)
55
+ FileUtils.mkdir_p(File.dirname(data[:dest]))
56
+ File.binwrite(data[:dest], response.body)
57
+ t.yield(data[:dest])
58
+ else
59
+ t.yield(nil)
60
+ end
61
+ end.on_progress do |path|
62
+ @in_flight.delete(game_code)
63
+ on_fetched.call(path) if path
64
+ end.on_done do
65
+ @in_flight.delete(game_code)
66
+ end
67
+ end
68
+
69
+ # @return [String] path where box art would be cached for this game code
70
+ def cached_path(game_code)
71
+ File.join(@cache_dir, game_code, "boxart.png")
72
+ end
73
+
74
+ # @return [Boolean] whether box art is already cached
75
+ def cached?(game_code)
76
+ File.exist?(cached_path(game_code))
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Include in any class that emits events via Gemba.bus.
5
+ # No constructor changes needed — just include and call emit.
6
+ module BusEmitter
7
+ private
8
+
9
+ def emit(event, *args, **kwargs)
10
+ Gemba.bus.emit(event, *args, **kwargs)
11
+ end
12
+ end
13
+ end
@@ -27,7 +27,10 @@ module Gemba
27
27
  parent_menu = @app.command('.', :cget, '-menu') rescue nil
28
28
  @app.command(top, :configure, menu: parent_menu) if parent_menu && !parent_menu.empty?
29
29
  end
30
- @app.command(:wm, 'protocol', top, 'WM_DELETE_WINDOW', proc { hide })
30
+ @on_dismiss = @callbacks[:on_dismiss] if defined?(@callbacks)
31
+ @app.command(:wm, 'protocol', top, 'WM_DELETE_WINDOW', proc {
32
+ @on_dismiss ? @on_dismiss.call : hide
33
+ })
31
34
  yield if block_given?
32
35
  @app.command(:wm, 'withdraw', top)
33
36
  end
@@ -58,5 +61,25 @@ module Gemba
58
61
  @app.command(:wm, 'withdraw', top)
59
62
  @callbacks[:on_close]&.call if defined?(@callbacks)
60
63
  end
64
+
65
+ # ── ModalStack protocol ──────────────────────────────────────────
66
+
67
+ # Show the window for ModalStack (deiconify, grab, position).
68
+ # Override in subclasses to accept additional keyword arguments.
69
+ def show_modal(**_args)
70
+ top = self.class::TOP
71
+ position_near_parent
72
+ @app.command(:wm, 'deiconify', top)
73
+ @app.command(:raise, top)
74
+ @app.command(:grab, :set, top)
75
+ @app.command(:focus, top)
76
+ end
77
+
78
+ # Withdraw the window for ModalStack (release grab, withdraw — NO callback).
79
+ def withdraw
80
+ top = self.class::TOP
81
+ @app.command(:grab, :release, top)
82
+ @app.command(:wm, 'withdraw', top)
83
+ end
61
84
  end
62
85
  end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Gemba
6
+ class CLI
7
+ module Commands
8
+ class ConfigCmd
9
+ def initialize(argv, dry_run: false)
10
+ @argv = argv
11
+ @dry_run = dry_run
12
+ end
13
+
14
+ def call
15
+ options = parse
16
+
17
+ if options[:help]
18
+ puts options[:parser] unless @dry_run
19
+ return { command: :config, help: true }
20
+ end
21
+
22
+ result = {
23
+ command: options[:reset] ? :config_reset : :config_show,
24
+ reset: options[:reset],
25
+ yes: options[:yes],
26
+ options: options.except(:parser)
27
+ }
28
+ return result if @dry_run
29
+
30
+ require "gemba"
31
+
32
+ if options[:reset]
33
+ path = Config.default_path
34
+ unless File.exist?(path)
35
+ puts "No config file found at #{path}"
36
+ return
37
+ end
38
+ unless options[:yes]
39
+ print "Delete #{path}? [y/N] "
40
+ return unless $stdin.gets&.strip&.downcase == 'y'
41
+ end
42
+ Config.reset!(path: path)
43
+ puts "Deleted #{path}"
44
+ return
45
+ end
46
+
47
+ path = Config.default_path
48
+ puts "Config: #{path}"
49
+ puts " Exists: #{File.exist?(path)}"
50
+ if File.exist?(path)
51
+ config = Gemba.user_config
52
+ puts " Scale: #{config.scale}"
53
+ puts " Volume: #{config.volume}"
54
+ puts " Muted: #{config.muted?}"
55
+ puts " Locale: #{config.locale}"
56
+ puts " Show FPS: #{config.show_fps?}"
57
+ puts " Turbo speed: #{config.turbo_speed}"
58
+ end
59
+ end
60
+
61
+ def parse
62
+ options = {}
63
+ argv = @argv.dup
64
+
65
+ parser = OptionParser.new do |o|
66
+ o.banner = "Usage: gemba config [options]"
67
+ o.separator ""
68
+ o.separator "Show or reset configuration."
69
+ o.separator ""
70
+
71
+ o.on("--reset", "Delete settings file (keeps saves)") { options[:reset] = true }
72
+ o.on("-y", "--yes", "Skip confirmation prompts") { options[:yes] = true }
73
+ o.on("-h", "--help", "Show this help") { options[:help] = true }
74
+ end
75
+
76
+ parser.parse!(argv)
77
+ options[:parser] = parser
78
+ options
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Gemba
6
+ class CLI
7
+ module Commands
8
+ class Decode
9
+ def initialize(argv, dry_run: false)
10
+ @argv = argv
11
+ @dry_run = dry_run
12
+ end
13
+
14
+ def call
15
+ options = parse
16
+
17
+ if options[:help]
18
+ puts options[:parser] unless @dry_run
19
+ return { command: :decode, help: true }
20
+ end
21
+
22
+ if options[:list]
23
+ list_grec_recordings unless @dry_run
24
+ return { command: :decode_list }
25
+ end
26
+
27
+ unless options[:grec]
28
+ list_grec_recordings unless @dry_run
29
+ return { command: :decode_list }
30
+ end
31
+
32
+ result = {
33
+ command: options[:stats] ? :decode_stats : :decode,
34
+ grec: options[:grec],
35
+ stats: options[:stats],
36
+ output: options[:output],
37
+ video_codec: options[:video_codec],
38
+ audio_codec: options[:audio_codec],
39
+ scale: options[:scale],
40
+ ffmpeg_args: options[:ffmpeg_args],
41
+ options: options.except(:parser)
42
+ }
43
+ return result if @dry_run
44
+
45
+ require "gemba/headless"
46
+
47
+ grec_path = options[:grec]
48
+
49
+ if options[:stats]
50
+ info = RecorderDecoder.stats(grec_path)
51
+ puts "Recording: #{grec_path}"
52
+ puts " Frames: #{info[:frame_count]}"
53
+ puts " Resolution: #{info[:width]}x#{info[:height]}"
54
+ puts " FPS: #{'%.2f' % info[:fps]}"
55
+ puts " Duration: #{'%.1f' % info[:duration]}s"
56
+ puts " Avg change: #{'%.1f' % info[:avg_change_pct]}%/frame"
57
+ puts " Uncompressed: #{format_size(info[:raw_video_size])} (encode input)"
58
+ puts " Audio: #{info[:audio_rate]} Hz, #{info[:audio_channels]}ch"
59
+ return
60
+ end
61
+
62
+ output_path = options[:output] || grec_path.sub(/\.grec\z/, '') + '.mp4'
63
+ codec_opts = {}
64
+ codec_opts[:video_codec] = options[:video_codec] if options[:video_codec]
65
+ codec_opts[:audio_codec] = options[:audio_codec] if options[:audio_codec]
66
+ codec_opts[:scale] = options[:scale] if options[:scale]
67
+ codec_opts[:ffmpeg_args] = options[:ffmpeg_args] if options[:ffmpeg_args]
68
+ codec_opts[:progress] = options.fetch(:progress, true)
69
+
70
+ info = RecorderDecoder.decode(grec_path, output_path, **codec_opts)
71
+ puts "Encoded #{info[:frame_count]} frames " \
72
+ "(#{info[:width]}x#{info[:height]} @ #{'%.2f' % info[:fps]} fps, " \
73
+ "avg #{'%.1f' % info[:avg_change_pct]}% change/frame)"
74
+ puts "Output: #{info[:output_path]}"
75
+ end
76
+
77
+ def parse
78
+ options = {}
79
+ argv = @argv.dup
80
+
81
+ parser = OptionParser.new do |o|
82
+ o.banner = "Usage: gemba decode [options] GREC_FILE [-- FFMPEG_ARGS...]"
83
+ o.separator ""
84
+ o.separator "Encode a .grec recording to a playable video via ffmpeg."
85
+ o.separator "Args after -- replace the default codec flags."
86
+ o.separator ""
87
+
88
+ o.on("-o", "--output PATH", "Output path (default: INPUT.mp4)") { |v| options[:output] = v }
89
+ o.on("--video-codec CODEC", "Video codec (default: libx264)") { |v| options[:video_codec] = v }
90
+ o.on("--audio-codec CODEC", "Audio codec (default: aac)") { |v| options[:audio_codec] = v }
91
+ o.on("-s", "--scale N", Integer, "Scale factor (default: native)") { |v| options[:scale] = v.clamp(1, 10) }
92
+ o.on("-l", "--list", "List available .grec recordings") { options[:list] = true }
93
+ o.on("--stats", "Show recording stats (no ffmpeg needed)") { options[:stats] = true }
94
+ o.on("--no-progress", "Disable progress indicator") { options[:progress] = false }
95
+ o.on("-h", "--help", "Show this help") { options[:help] = true }
96
+ end
97
+
98
+ parser.parse!(argv)
99
+ options[:grec] = argv.shift
100
+ options[:ffmpeg_args] = argv unless argv.empty?
101
+ options[:parser] = parser
102
+ options
103
+ end
104
+
105
+ private
106
+
107
+ def list_grec_recordings
108
+ require "gemba/headless"
109
+
110
+ dir = Config.default_recordings_dir
111
+ unless File.directory?(dir)
112
+ puts "No recordings directory found at #{dir}"
113
+ return
114
+ end
115
+
116
+ grec_files = Dir.glob(File.join(dir, '*.grec')).sort
117
+ if grec_files.empty?
118
+ puts "No .grec recordings in #{dir}"
119
+ return
120
+ end
121
+
122
+ entries = grec_files.map do |path|
123
+ info = RecorderDecoder.stats(path)
124
+ {
125
+ path: path,
126
+ frames: "#{info[:frame_count]} frames",
127
+ duration: "#{'%.1f' % info[:duration]}s",
128
+ size: format_size(File.size(path))
129
+ }
130
+ end
131
+
132
+ path_w = entries.map { |e| e[:path].length }.max
133
+ frames_w = entries.map { |e| e[:frames].length }.max
134
+ dur_w = entries.map { |e| e[:duration].length }.max
135
+ size_w = entries.map { |e| e[:size].length }.max
136
+
137
+ entries.each do |e|
138
+ puts "#{e[:path].ljust(path_w)} #{e[:frames].rjust(frames_w)} #{e[:duration].rjust(dur_w)} #{e[:size].rjust(size_w)}"
139
+ end
140
+ end
141
+
142
+ def format_size(bytes)
143
+ if bytes >= 1_073_741_824
144
+ "#{'%.1f' % (bytes / 1_073_741_824.0)} GB"
145
+ elsif bytes >= 1_048_576
146
+ "#{'%.1f' % (bytes / 1_048_576.0)} MB"
147
+ else
148
+ "#{'%.1f' % (bytes / 1024.0)} KB"
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Gemba
6
+ class CLI
7
+ module Commands
8
+ class Patch
9
+ def initialize(argv, dry_run: false)
10
+ @argv = argv
11
+ @dry_run = dry_run
12
+ end
13
+
14
+ def call
15
+ options = parse
16
+
17
+ if options[:help]
18
+ puts options[:parser] unless @dry_run
19
+ return { command: :patch, help: true }
20
+ end
21
+
22
+ unless options[:rom] && options[:patch]
23
+ $stderr.puts "gemba patch: ROM_FILE and PATCH_FILE are required"
24
+ $stderr.puts options[:parser]
25
+ return { command: :patch, error: :missing_args }
26
+ end
27
+
28
+ rom_path = options[:rom]
29
+ patch_path = options[:patch]
30
+ out_path = if options[:output]
31
+ options[:output]
32
+ else
33
+ ext = File.extname(rom_path)
34
+ base = rom_path.chomp(ext)
35
+ "#{base}-patched#{ext}"
36
+ end
37
+
38
+ result = { command: :patch, rom: rom_path, patch: patch_path, out: out_path }
39
+ return result if @dry_run
40
+
41
+ require "gemba/rom_patcher"
42
+ require "gemba/rom_patcher/ips"
43
+ require "gemba/rom_patcher/bps"
44
+ require "gemba/rom_patcher/ups"
45
+
46
+ safe_out = RomPatcher.safe_out_path(out_path)
47
+ puts "Patching #{File.basename(rom_path)} with #{File.basename(patch_path)}…"
48
+ RomPatcher.patch(rom_path: rom_path, patch_path: patch_path, out_path: safe_out)
49
+ puts "Written: #{safe_out}"
50
+ end
51
+
52
+ def parse
53
+ options = {}
54
+ argv = @argv.dup
55
+
56
+ parser = OptionParser.new do |o|
57
+ o.banner = "Usage: gemba patch [options] ROM_FILE PATCH_FILE"
58
+ o.separator ""
59
+ o.separator "Apply an IPS, BPS, or UPS patch to a ROM file."
60
+ o.separator ""
61
+ o.separator "The output file is written to --output or, by default, next to the ROM."
62
+ o.separator "If the output path already exists, -(2), -(3) etc. are appended."
63
+ o.separator ""
64
+
65
+ o.on("-o", "--output PATH", "Output ROM path") { |v| options[:output] = File.expand_path(v) }
66
+ o.on("-h", "--help", "Show this help") { options[:help] = true }
67
+ end
68
+
69
+ parser.parse!(argv)
70
+ options[:rom] = File.expand_path(argv[0]) if argv[0]
71
+ options[:patch] = File.expand_path(argv[1]) if argv[1]
72
+ options[:parser] = parser
73
+ options
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Gemba
6
+ class CLI
7
+ module Commands
8
+ class Play
9
+ def initialize(argv, dry_run: false)
10
+ @argv = argv
11
+ @dry_run = dry_run
12
+ end
13
+
14
+ def call
15
+ options = parse
16
+
17
+ if options[:help]
18
+ puts options[:parser] unless @dry_run
19
+ return { command: :play, help: true }
20
+ end
21
+
22
+ result = {
23
+ command: :play,
24
+ rom: options[:rom],
25
+ sound: options.fetch(:sound, true),
26
+ fullscreen: options[:fullscreen],
27
+ options: options.except(:parser)
28
+ }
29
+ return result if @dry_run
30
+
31
+ require "gemba"
32
+
33
+ apply(Gemba.user_config, options)
34
+ Gemba.load_locale if options[:locale]
35
+ Gemba::AppController.new(result[:rom], sound: result[:sound], fullscreen: result[:fullscreen]).run
36
+ end
37
+
38
+ def parse
39
+ options = {}
40
+ argv = @argv.dup
41
+
42
+ parser = OptionParser.new do |o|
43
+ o.banner = "Usage: gemba [play] [options] [ROM_FILE]"
44
+ o.separator ""
45
+ o.separator "Launch the GBA emulator. ROM_FILE is optional."
46
+ o.separator ""
47
+
48
+ o.on("-s", "--scale N", Integer, "Window scale (1-4)") { |v| options[:scale] = v.clamp(1, 4) }
49
+ o.on("-v", "--volume N", Integer, "Volume (0-100)") { |v| options[:volume] = v.clamp(0, 100) }
50
+ o.on("-m", "--mute", "Start muted") { options[:mute] = true }
51
+ o.on("--no-sound", "Disable audio entirely") { options[:sound] = false }
52
+ o.on("-f", "--fullscreen", "Start in fullscreen") { options[:fullscreen] = true }
53
+ o.on("--show-fps", "Show FPS counter") { options[:show_fps] = true }
54
+ o.on("--turbo-speed N", Integer, "Fast-forward speed (0=uncapped, 2-4)") { |v| options[:turbo_speed] = v.clamp(0, 4) }
55
+ o.on("--bios PATH", "Path to GBA BIOS file (overrides saved setting)") { |v| options[:bios] = File.expand_path(v) }
56
+ o.on("--locale LANG", "Language (en, ja, auto)") { |v| options[:locale] = v }
57
+ o.on("-h", "--help", "Show this help") { options[:help] = true }
58
+ end
59
+
60
+ parser.parse!(argv)
61
+ options[:rom] = File.expand_path(argv.first) if argv.first
62
+ options[:parser] = parser
63
+ options
64
+ end
65
+
66
+ def apply(config, options)
67
+ config.scale = options[:scale] if options[:scale]
68
+ config.volume = options[:volume] if options[:volume]
69
+ config.muted = true if options[:mute]
70
+ config.show_fps = true if options[:show_fps]
71
+ config.turbo_speed = options[:turbo_speed] if options[:turbo_speed]
72
+ config.locale = options[:locale] if options[:locale]
73
+ config.bios_path = options[:bios] if options[:bios]
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end