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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Gemba
5
+ # Immutable snapshot of everything known about a single ROM.
6
+ #
7
+ # Aggregates data from multiple sources:
8
+ # - RomLibrary entry (title, path, game_code, platform, rom_id)
9
+ # - GameIndex (has_official_entry — whether libretro knows about it)
10
+ # - BoxartFetcher (cached_boxart_path — auto-fetched cover art)
11
+ # - RomOverrides (custom_boxart_path — user-chosen cover art)
12
+ #
13
+ # Use RomInfo.from_rom to construct from a raw library entry hash.
14
+ # Use #boxart_path to get the effective cover image (custom beats cache).
15
+ RomInfo = Data.define(
16
+ :rom_id, # String — unique ROM identifier (game_code + CRC32)
17
+ :title, # String — display name
18
+ :platform, # String — uppercased, e.g. "GBA"
19
+ :game_code, # String? — 4-char code e.g. "AGB-AXVE", or nil
20
+ :path, # String — absolute path to the ROM file
21
+ :md5, # String? — MD5 hex digest of ROM content, or nil (lazy)
22
+ :has_official_entry, # Boolean — GameIndex has an entry for this game_code
23
+ :cached_boxart_path, # String? — auto-fetched cover from libretro CDN, or nil
24
+ :custom_boxart_path # String? — user-set cover image path, or nil
25
+ ) do
26
+ # Effective cover image path: custom override wins, then fetched cache, then nil.
27
+ def boxart_path
28
+ return custom_boxart_path if custom_boxart_path && File.exist?(custom_boxart_path)
29
+ return cached_boxart_path if cached_boxart_path && File.exist?(cached_boxart_path)
30
+ nil
31
+ end
32
+
33
+ # Build a RomInfo from a raw rom_library entry hash.
34
+ #
35
+ # @param rom [Hash] entry from RomLibrary#all
36
+ # @param fetcher [BoxartFetcher, nil]
37
+ # @param overrides [RomOverrides, nil]
38
+ def self.from_rom(rom, fetcher: nil, overrides: nil, game_index: GameIndex)
39
+ game_code = rom['game_code']
40
+ rom_id = rom['rom_id']
41
+
42
+ new(
43
+ rom_id: rom_id,
44
+ title: game_index.lookup(game_code) ||
45
+ game_index.lookup_by_md5(rom['md5'], rom['platform'] || 'gba') ||
46
+ rom['title'] || rom['rom_id'] || '???',
47
+ platform: (rom['platform'] || 'gba').upcase,
48
+ game_code: game_code,
49
+ path: rom['path'],
50
+ md5: rom['md5'],
51
+ has_official_entry: game_code ? !game_index.lookup(game_code).nil? : false,
52
+ cached_boxart_path: (fetcher.cached_path(game_code) if fetcher&.cached?(game_code)),
53
+ custom_boxart_path: overrides&.custom_boxart(rom_id),
54
+ )
55
+ end
56
+ end
57
+ end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "child_window"
4
- require_relative "locale"
5
3
 
6
4
  module Gemba
7
5
  # Displays ROM metadata in a read-only window.
@@ -259,6 +257,21 @@ module Gemba
259
257
  hide_window(modal: false)
260
258
  end
261
259
 
260
+ # ModalStack protocol (non-modal — no grab)
261
+ def show_modal(core: nil, rom_path: nil, save_path: nil, **_)
262
+ build_ui unless @built
263
+ populate(core, rom_path, save_path) if core
264
+ position_near_parent
265
+ top = self.class::TOP
266
+ @app.command(:wm, 'deiconify', top)
267
+ @app.command(:raise, top)
268
+ end
269
+
270
+ def withdraw
271
+ top = self.class::TOP
272
+ @app.command(:wm, 'withdraw', top)
273
+ end
274
+
262
275
  private
263
276
 
264
277
  def build_ui
@@ -318,7 +331,7 @@ module Gemba
318
331
  publisher = maker.empty? ? na : "#{self.class.publisher_name(maker)} (#{maker})"
319
332
  set_field('publisher', publisher)
320
333
 
321
- set_field('platform', core.platform)
334
+ set_field('platform', Platform.for(core).name)
322
335
  set_field('rom_size', format_size(core.rom_size))
323
336
  set_field('checksum', "0x%08X" % core.checksum)
324
337
  set_field('rom_path', rom_path || na)
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+
7
+ module Gemba
8
+ # Persistent catalog of known ROMs.
9
+ #
10
+ # Stored as JSON at Config.config_dir/rom_library.json. Each entry records
11
+ # the ROM's path, title, game code, rom_id, platform, and timestamps.
12
+ # The library is loaded once on boot and updated whenever a ROM is loaded.
13
+ class RomLibrary
14
+ FILENAME = 'rom_library.json'
15
+
16
+ def initialize(path = self.class.default_path, subscribe: true)
17
+ @path = path
18
+ @roms = []
19
+ load!
20
+ subscribe_to_bus if subscribe
21
+ end
22
+
23
+ def self.default_path
24
+ File.join(Config.config_dir, FILENAME)
25
+ end
26
+
27
+ # All known ROMs, sorted by last_played descending (most recent first).
28
+ # @return [Array<Hash>]
29
+ def all
30
+ @roms.sort_by { |r| r['last_played'] || r['added_at'] || '' }.reverse
31
+ end
32
+
33
+ # Add or update a ROM entry. Upserts by rom_id.
34
+ # @param attrs [Hash] must include 'rom_id'; other keys merged in
35
+ def add(attrs)
36
+ rom_id = attrs['rom_id'] || attrs[:rom_id]
37
+ raise ArgumentError, 'rom_id is required' unless rom_id
38
+
39
+ attrs = stringify_keys(attrs)
40
+ existing = @roms.find { |r| r['rom_id'] == rom_id }
41
+ if existing
42
+ existing.merge!(attrs)
43
+ else
44
+ attrs['added_at'] ||= Time.now.utc.iso8601
45
+ @roms << attrs
46
+ end
47
+ end
48
+
49
+ # Remove a ROM entry by rom_id.
50
+ def remove(rom_id)
51
+ @roms.reject! { |r| r['rom_id'] == rom_id }
52
+ end
53
+
54
+ # Update last_played timestamp for a ROM.
55
+ def touch(rom_id)
56
+ entry = find(rom_id)
57
+ entry['last_played'] = Time.now.utc.iso8601 if entry
58
+ end
59
+
60
+ # Find a ROM entry by rom_id.
61
+ # @return [Hash, nil]
62
+ def find(rom_id)
63
+ @roms.find { |r| r['rom_id'] == rom_id }
64
+ end
65
+
66
+ # @return [Integer]
67
+ def size
68
+ @roms.size
69
+ end
70
+
71
+ # Persist to disk.
72
+ def save!
73
+ FileUtils.mkdir_p(File.dirname(@path))
74
+ File.write(@path, JSON.pretty_generate({ 'roms' => @roms }))
75
+ end
76
+
77
+ private
78
+
79
+ def subscribe_to_bus
80
+ Gemba.bus.on(:rom_loaded) do |rom_id:, path:, title:, game_code:, platform:, md5: nil, **|
81
+ add(
82
+ 'rom_id' => rom_id,
83
+ 'path' => path,
84
+ 'title' => title,
85
+ 'game_code' => game_code,
86
+ 'platform' => platform.downcase,
87
+ 'md5' => md5,
88
+ )
89
+ touch(rom_id)
90
+ save!
91
+ end
92
+ end
93
+
94
+ def load!
95
+ return unless File.exist?(@path)
96
+ data = JSON.parse(File.read(@path))
97
+ @roms = data['roms'] || []
98
+ rescue JSON::ParserError
99
+ @roms = []
100
+ end
101
+
102
+ def stringify_keys(hash)
103
+ hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module Gemba
7
+ # Persists per-ROM user overrides to config_dir/rom_overrides.json.
8
+ #
9
+ # Keyed by rom_id (game_code + CRC32 checksum) — the most stable
10
+ # identifier for a ROM across renames or moves.
11
+ #
12
+ # Currently tracks:
13
+ # custom_boxart — absolute path to a user-chosen cover image
14
+ #
15
+ # Custom images are copied into config_dir/boxart/{rom_id}/custom.{ext}
16
+ # so they remain accessible even if the original file is moved or deleted.
17
+ class RomOverrides
18
+ def initialize(path = Config.rom_overrides_path)
19
+ @path = path
20
+ @data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
21
+ end
22
+
23
+ # @return [String, nil] absolute path to the custom boxart, or nil
24
+ def custom_boxart(rom_id)
25
+ @data.dig(rom_id.to_s, 'custom_boxart')
26
+ end
27
+
28
+ # Copies src_path into the gemba boxart cache and records the dest path.
29
+ # @return [String] the destination path
30
+ def set_custom_boxart(rom_id, src_path)
31
+ ext = File.extname(src_path)
32
+ dest = File.join(Config.boxart_dir, rom_id.to_s, "custom#{ext}")
33
+ FileUtils.mkdir_p(File.dirname(dest))
34
+ FileUtils.cp(src_path, dest)
35
+ (@data[rom_id.to_s] ||= {})['custom_boxart'] = dest
36
+ save
37
+ dest
38
+ end
39
+
40
+ private
41
+
42
+ def save
43
+ FileUtils.mkdir_p(File.dirname(@path))
44
+ File.write(@path, JSON.pretty_generate(@data))
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require 'stringio'
5
+
6
+ module Gemba
7
+ class RomPatcher
8
+ # Applies a BPS (Beat Patch System) patch.
9
+ #
10
+ # File layout:
11
+ #
12
+ # ┌──────────────────────────────────────────────────────┐
13
+ # │ "BPS1" (4 bytes, magic) │
14
+ # ├──────────────────────────────────────────────────────┤
15
+ # │ source_size (varint) │
16
+ # │ target_size (varint) │
17
+ # │ metadata_size (varint) │
18
+ # │ metadata (<metadata_size> bytes, skipped) │
19
+ # ├──────────────────────────────────────────────────────┤
20
+ # │ actions … (repeated until patch.size - 12) │
21
+ # ├──────────────────────────────────────────────────────┤
22
+ # │ src_crc32 4B LE │ tgt_crc32 4B LE │ patch │ ← footer
23
+ # └──────────────────────────────────────────────────────┘
24
+ #
25
+ # Each action word (varint): word = (length - 1) << 2 | mode
26
+ #
27
+ # mode 0 SourceRead ┌────────┐ copy `length` bytes from source
28
+ # │ word │ at current output offset
29
+ # └────────┘
30
+ #
31
+ # mode 1 TargetRead ┌────────┬──────────────────────┐
32
+ # │ word │ data (<length> B) │
33
+ # └────────┴──────────────────────┘
34
+ #
35
+ # mode 2 SourceCopy ┌────────┬───────────┐ seek src by signed delta,
36
+ # │ word │ delta(v) │ copy `length` bytes
37
+ # └────────┴───────────┘
38
+ #
39
+ # mode 3 TargetCopy ┌────────┬───────────┐ seek already-written target
40
+ # │ word │ delta(v) │ by signed delta, copy
41
+ # └────────┴───────────┘
42
+ #
43
+ # BPS varint encoding (7-bit groups, additive-shift, differs from UPS):
44
+ # Each byte holds 7 data bits (b & 0x7f = 0b01111111) and one flag bit
45
+ # (b & 0x80). Flag=1 means last byte; flag=0 means more follow.
46
+ # Unlike UPS (bitwise OR + left-shift), BPS uses multiplication and adds
47
+ # an extra `shift` after each non-terminal byte so that 0x00 is never a
48
+ # valid single-byte encoding — this lets the format distinguish "no data"
49
+ # from an actual zero value.
50
+ # value = 0, shift = 1
51
+ # per byte: value += (b & 0x7f) * shift
52
+ # if bit7 set → done; else shift <<= 7; value += shift
53
+ #
54
+ # Example: value 300 decoded from bytes [0x2C, 0x81]
55
+ # raw byte │ & 0x7f │ shift │ value after │ bit7 │ action
56
+ # ──────────┼──────────┼─────────┼─────────────────────┼────────┼───────────────────────────
57
+ # 0x2C │ 44 │ 1 │ 0 + 44×1 = 44 │ 0 │ shift<<=7 (→128); value+=128 (→172)
58
+ # 0x81 │ 1 │ 128 │ 172 + 1×128 = 300 │ 1 │ break
59
+ class BPS
60
+ # @param rom [String] binary ROM data
61
+ # @param patch [String] binary BPS patch data
62
+ # @return [String] patched ROM (binary)
63
+ # @raise [RuntimeError] on CRC32 mismatch
64
+ def self.apply(rom, patch, on_progress: nil)
65
+ raise "BPS patch too small to be valid" if patch.bytesize < 16
66
+ rom = rom.b
67
+ patch = patch.b
68
+ io = StringIO.new(patch)
69
+ io.read(4) # "BPS1"
70
+
71
+ read_varint(io) # source_size — not used; target_size drives allocation
72
+ target_size = read_varint(io)
73
+ metadata_size = read_varint(io)
74
+ skip = io.read(metadata_size)
75
+ raise "Truncated BPS metadata" if skip&.bytesize != metadata_size
76
+
77
+ target = "\x00".b * target_size
78
+ out_offset = 0
79
+ src_offset = 0
80
+ tgt_offset = 0
81
+ patch_end = patch.bytesize - 12
82
+
83
+ last_pct = -1
84
+
85
+ while io.pos < patch_end
86
+ if on_progress
87
+ pct = (io.pos / patch_end.to_f * 100).floor
88
+ if pct != last_pct
89
+ on_progress.call(pct / 100.0)
90
+ last_pct = pct
91
+ end
92
+ end
93
+
94
+ word = read_varint(io)
95
+ mode = word & 3
96
+ length = (word >> 2) + 1
97
+
98
+ case mode
99
+ when 0 # SourceRead — copy from rom at current out position
100
+ length.times do
101
+ target.setbyte(out_offset, rom.getbyte(out_offset) || 0)
102
+ out_offset += 1
103
+ end
104
+ when 1 # TargetRead — literal data
105
+ data = io.read(length)
106
+ target[out_offset, length] = data
107
+ out_offset += length
108
+ when 2 # SourceCopy — relative seek in source
109
+ src_offset += read_signed_varint(io)
110
+ length.times do
111
+ target.setbyte(out_offset, rom.getbyte(src_offset) || 0)
112
+ out_offset += 1
113
+ src_offset += 1
114
+ end
115
+ when 3 # TargetCopy — relative seek in target
116
+ tgt_offset += read_signed_varint(io)
117
+ length.times do
118
+ target.setbyte(out_offset, target.getbyte(tgt_offset) || 0)
119
+ out_offset += 1
120
+ tgt_offset += 1
121
+ end
122
+ end
123
+ end
124
+
125
+ raise "BPS patch too small to contain footer" if patch.bytesize < 12
126
+ src_crc, tgt_crc = patch[-12..].unpack("VV")
127
+ raise "BPS source CRC32 mismatch" unless Zlib.crc32(rom) == src_crc
128
+ raise "BPS target CRC32 mismatch" unless Zlib.crc32(target) == tgt_crc
129
+
130
+ target
131
+ end
132
+
133
+ # BPS varint: low 7 bits per byte; bit7=1 terminates; additive shift encoding.
134
+ # Decoder: value = 0, shift = 1; per byte: value += (b & 0x7f) * shift;
135
+ # if bit7: break; else: shift <<= 7; value += shift.
136
+ def self.read_varint(io)
137
+ value = 0
138
+ shift = 1
139
+ loop do
140
+ byte = io.read(1)
141
+ raise "Truncated BPS patch (varint read past end)" if byte.nil?
142
+ b = byte.getbyte(0)
143
+ value += (b & 0x7f) * shift
144
+ break if (b & 0x80) != 0
145
+ shift <<= 7
146
+ value += shift
147
+ end
148
+ value
149
+ end
150
+ private_class_method :read_varint
151
+
152
+ def self.read_signed_varint(io)
153
+ v = read_varint(io)
154
+ negative = (v & 1) != 0
155
+ v >>= 1
156
+ negative ? -v : v
157
+ end
158
+ private_class_method :read_signed_varint
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+
5
+ module Gemba
6
+ class RomPatcher
7
+ # Applies an IPS (International Patching System) patch.
8
+ #
9
+ # File layout:
10
+ #
11
+ # ┌─────────────────────────────────────────────┐
12
+ # │ "PATCH" (5 bytes, magic) │
13
+ # ├─────────────────────────────────────────────┤
14
+ # │ Record 1 │
15
+ # │ Record 2 │
16
+ # │ ... │
17
+ # ├─────────────────────────────────────────────┤
18
+ # │ "EOF" (3 bytes, terminator) │
19
+ # └─────────────────────────────────────────────┘
20
+ #
21
+ # Record formats:
22
+ #
23
+ # Normal record:
24
+ # ┌────────────────────┬───────────────────┬─────────────────────┐
25
+ # │ offset │ size │ data │
26
+ # │ 3 bytes, big-endian│ 2 bytes, big-endian│ <size> bytes │
27
+ # └────────────────────┴───────────────────┴─────────────────────┘
28
+ #
29
+ # RLE record — size field is 0x0000 (zero), which is the signal that
30
+ # this is NOT a normal data record. Instead of inline bytes, the next
31
+ # two fields say "repeat one byte N times":
32
+ # ┌────────────────────┬──────────┬────────────────────┬──────────┐
33
+ # │ offset │ 0x0000 │ count │ value │
34
+ # │ 3 bytes, big-endian│ 2 bytes │ 2 bytes, big-endian│ 1 byte │
35
+ # └────────────────────┴──────────┴────────────────────┴──────────┘
36
+ # Writes `value` repeated `count` times starting at `offset`.
37
+ # e.g. offset=0x100, count=8, value=0xFF → fills 8 bytes with 0xFF.
38
+ #
39
+ # No checksums — no integrity verification.
40
+ class IPS
41
+ EOF_MARKER = "EOF".b.freeze
42
+
43
+ # @param rom [String] binary ROM data
44
+ # @param patch [String] binary IPS patch data
45
+ # @return [String] patched ROM (binary)
46
+ def self.apply(rom, patch, on_progress: nil)
47
+ rom = rom.b
48
+ patch = patch.b
49
+ result = rom.dup
50
+ io = StringIO.new(patch)
51
+ read!(io, 5) # "PATCH"
52
+
53
+ total = patch.bytesize.to_f
54
+ last_pct = -1
55
+
56
+ loop do
57
+ offset_bytes = io.read(3)
58
+ break if offset_bytes.nil? || offset_bytes == EOF_MARKER
59
+ raise "Truncated patch: incomplete offset record" if offset_bytes.bytesize < 3
60
+
61
+ offset = (offset_bytes.getbyte(0) << 16) |
62
+ (offset_bytes.getbyte(1) << 8) |
63
+ offset_bytes.getbyte(2)
64
+
65
+ size = read!(io, 2).unpack1("n") # "n" = 16-bit unsigned big-endian
66
+
67
+ data = if size == 0
68
+ count = read!(io, 2).unpack1("n") # "n" = 16-bit unsigned big-endian
69
+ value = read!(io, 1)
70
+ value * count
71
+ else
72
+ read!(io, size)
73
+ end
74
+
75
+ # Extend ROM if patch writes past current end
76
+ needed = offset + data.bytesize
77
+ result << "\x00".b * (needed - result.bytesize) if needed > result.bytesize
78
+ result[offset, data.bytesize] = data
79
+
80
+ if on_progress
81
+ pct = (io.pos / total * 100).floor
82
+ if pct != last_pct
83
+ on_progress.call(pct / 100.0)
84
+ last_pct = pct
85
+ end
86
+ end
87
+ end
88
+
89
+ result
90
+ end
91
+
92
+ def self.read!(io, n)
93
+ data = io.read(n)
94
+ raise "Truncated IPS patch (expected #{n} bytes, got #{data&.bytesize || 0})" \
95
+ if data.nil? || data.bytesize < n
96
+ data
97
+ end
98
+ private_class_method :read!
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+ require 'stringio'
5
+
6
+ module Gemba
7
+ class RomPatcher
8
+ # Applies a UPS (Universal Patching System) patch.
9
+ #
10
+ # File layout:
11
+ #
12
+ # ┌──────────────────────────────────────────────────────┐
13
+ # │ "UPS1" (4 bytes, magic) │
14
+ # ├──────────────────────────────────────────────────────┤
15
+ # │ source_size (varint) │
16
+ # │ target_size (varint) │
17
+ # ├──────────────────────────────────────────────────────┤
18
+ # │ hunks … (repeated until patch.size - 12) │
19
+ # ├──────────────────────────────────────────────────────┤
20
+ # │ src_crc32 4B LE │ tgt_crc32 4B LE │ patch │ ← footer
21
+ # └──────────────────────────────────────────────────────┘
22
+ #
23
+ # Each hunk:
24
+ # ┌──────────────────┬──────────────────────────────────┐
25
+ # │ skip (varint) │ xor_data … 0x00 │
26
+ # └──────────────────┴──────────────────────────────────┘
27
+ #
28
+ # skip — advance output position by this many bytes (unchanged bytes)
29
+ # xor_data — each byte XOR'd with the corresponding output byte; 0x00 ends the run
30
+ #
31
+ # Example: source = [AA BB CC DD EE FF]
32
+ # hunk 1: skip=1, xor=[11 22], 0x00
33
+ # result: [AA (BB^11) (CC^22) DD EE FF]
34
+ # ↑ changed ↑ changed
35
+ #
36
+ # UPS varint encoding (7-bit groups, LSB first):
37
+ # Each byte holds 7 data bits (b & 0x7f = 0b01111111) and one flag bit
38
+ # (b & 0x80). Flag=1 means this is the last byte; flag=0 means more follow.
39
+ # value = 0, shift = 0
40
+ # per byte: value |= (b & 0x7f) << shift
41
+ # if bit7 set → done; else shift += 7
42
+ #
43
+ # Example: value 300 decoded from bytes [0x2C, 0x82]
44
+ # raw byte │ & 0x7f │ shift │ value after │ bit7 │ action
45
+ # ──────────┼──────────┼─────────┼───────────────────────┼────────┼──────────
46
+ # 0x2C │ 0x2C │ 0 │ 0x2C (44) │ 0 │ shift += 7
47
+ # 0x82 │ 0x02 │ 7 │ 0x2C│0x100 (300) │ 1 │ break
48
+ class UPS
49
+ # @param rom [String] binary ROM data
50
+ # @param patch [String] binary UPS patch data
51
+ # @return [String] patched ROM (binary)
52
+ # @raise [RuntimeError] on CRC32 mismatch
53
+ def self.apply(rom, patch, on_progress: nil)
54
+ rom = rom.b
55
+ patch = patch.b
56
+ io = StringIO.new(patch)
57
+ io.read(4) # "UPS1"
58
+
59
+ read_varint(io) # source_size — not needed; we derive target from target_size
60
+ target_size = read_varint(io)
61
+
62
+ result = if rom.bytesize >= target_size
63
+ rom[0, target_size].dup
64
+ else
65
+ rom + "\x00".b * (target_size - rom.bytesize)
66
+ end
67
+
68
+ pos = 0
69
+ patch_end = patch.bytesize - 12
70
+ last_pct = -1
71
+
72
+ while io.pos < patch_end
73
+ if on_progress
74
+ pct = (io.pos / patch_end.to_f * 100).floor
75
+ if pct != last_pct
76
+ on_progress.call(pct / 100.0)
77
+ last_pct = pct
78
+ end
79
+ end
80
+
81
+ pos += read_varint(io)
82
+
83
+ while io.pos < patch_end
84
+ b = io.read(1).getbyte(0)
85
+ break if b == 0x00
86
+ result.setbyte(pos, (result.getbyte(pos) || 0) ^ b) if pos < result.bytesize
87
+ pos += 1
88
+ end
89
+ pos += 1 # advance past the matching byte at the hunk boundary
90
+ end
91
+
92
+ src_crc, tgt_crc = patch[-12..].unpack("VV")
93
+ raise "UPS source CRC32 mismatch" unless Zlib.crc32(rom) == src_crc
94
+ raise "UPS target CRC32 mismatch" unless Zlib.crc32(result) == tgt_crc
95
+
96
+ result
97
+ end
98
+
99
+ # UPS varint: low 7 bits per byte; bit7=1 terminates; simple bitshift accumulation.
100
+ # Decoder: value = 0, shift = 0; per byte: value |= (b & 0x7f) << shift;
101
+ # if bit7: break; else: shift += 7.
102
+ def self.read_varint(io)
103
+ value = 0
104
+ shift = 0
105
+ loop do
106
+ byte = io.read(1)
107
+ raise "Truncated UPS patch (varint read past end)" if byte.nil?
108
+ b = byte.getbyte(0)
109
+ value |= (b & 0x7f) << shift
110
+ break if (b & 0x80) != 0
111
+ shift += 7
112
+ end
113
+ value
114
+ end
115
+ private_class_method :read_varint
116
+ end
117
+ end
118
+ end