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,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "gemba/headless"
7
+
8
+ class TestRomInfo < Minitest::Test
9
+ # Stub that returns nil for every lookup — isolates RomInfo from real DAT data.
10
+ NULL_INDEX = Struct.new(:_) {
11
+ def lookup(_) = nil
12
+ def lookup_by_md5(*) = nil
13
+ }.new(nil)
14
+
15
+ ROM = {
16
+ 'rom_id' => 'AGB_AXVE-DEADBEEF',
17
+ 'title' => 'Pokemon Ruby',
18
+ 'platform' => 'gba',
19
+ 'game_code' => 'AGB-AXVE',
20
+ 'path' => '/games/ruby.gba',
21
+ }.freeze
22
+
23
+ def test_from_rom_sets_basic_fields
24
+ info = Gemba::RomInfo.from_rom(ROM, game_index: NULL_INDEX)
25
+ assert_equal 'AGB_AXVE-DEADBEEF', info.rom_id
26
+ assert_equal 'Pokemon Ruby', info.title
27
+ assert_equal 'GBA', info.platform
28
+ assert_equal 'AGB-AXVE', info.game_code
29
+ assert_equal '/games/ruby.gba', info.path
30
+ end
31
+
32
+ def test_platform_is_uppercased
33
+ info = Gemba::RomInfo.from_rom(ROM.merge('platform' => 'gbc'), game_index: NULL_INDEX)
34
+ assert_equal 'GBC', info.platform
35
+ end
36
+
37
+ def test_title_falls_back_to_rom_id
38
+ rom = ROM.merge('title' => nil)
39
+ info = Gemba::RomInfo.from_rom(rom, game_index: NULL_INDEX)
40
+ assert_equal 'AGB_AXVE-DEADBEEF', info.title
41
+ end
42
+
43
+ def test_title_from_game_index_wins_over_stored_title
44
+ index = Struct.new(:_) {
45
+ def lookup(_) = 'Index Title'
46
+ def lookup_by_md5(*) = nil
47
+ }.new(nil)
48
+ info = Gemba::RomInfo.from_rom(ROM, game_index: index)
49
+ assert_equal 'Index Title', info.title
50
+ end
51
+
52
+ def test_no_fetcher_or_overrides_yields_nil_boxart_fields
53
+ info = Gemba::RomInfo.from_rom(ROM, game_index: NULL_INDEX)
54
+ assert_nil info.cached_boxart_path
55
+ assert_nil info.custom_boxart_path
56
+ assert_nil info.boxart_path
57
+ end
58
+
59
+ def test_has_official_entry_true_when_index_returns_title
60
+ index = Struct.new(:_) {
61
+ def lookup(_) = 'Some Game'
62
+ def lookup_by_md5(*) = nil
63
+ }.new(nil)
64
+ info = Gemba::RomInfo.from_rom(ROM, game_index: index)
65
+ assert info.has_official_entry
66
+ end
67
+
68
+ def test_has_official_entry_false_when_index_returns_nil
69
+ info = Gemba::RomInfo.from_rom(ROM, game_index: NULL_INDEX)
70
+ refute info.has_official_entry
71
+ end
72
+
73
+ def test_has_official_entry_false_when_no_game_code
74
+ rom = ROM.merge('game_code' => nil)
75
+ info = Gemba::RomInfo.from_rom(rom, game_index: NULL_INDEX)
76
+ refute info.has_official_entry
77
+ end
78
+
79
+ def test_boxart_path_returns_custom_when_file_exists
80
+ Dir.mktmpdir do |dir|
81
+ ENV['GEMBA_CONFIG_DIR'] = dir
82
+ custom = File.join(dir, "custom.png")
83
+ File.write(custom, "fake")
84
+ overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json"))
85
+ overrides.set_custom_boxart('AGB_AXVE-DEADBEEF', custom)
86
+
87
+ info = Gemba::RomInfo.from_rom(ROM, overrides: overrides, game_index: NULL_INDEX)
88
+ assert_equal File.join(dir, 'boxart', 'AGB_AXVE-DEADBEEF', 'custom.png'), info.boxart_path
89
+ ensure
90
+ ENV.delete('GEMBA_CONFIG_DIR')
91
+ end
92
+ end
93
+
94
+ def test_boxart_path_falls_back_to_cache_when_no_custom
95
+ Dir.mktmpdir do |dir|
96
+ ENV['GEMBA_CONFIG_DIR'] = dir
97
+ cache_dir = File.join(dir, "boxart")
98
+ fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: cache_dir,
99
+ backend: Gemba::BoxartFetcher::NullBackend.new)
100
+ overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json"))
101
+
102
+ cached = fetcher.cached_path('AGB-AXVE')
103
+ FileUtils.mkdir_p(File.dirname(cached))
104
+ File.write(cached, "fake")
105
+
106
+ info = Gemba::RomInfo.from_rom(ROM, fetcher: fetcher, overrides: overrides, game_index: NULL_INDEX)
107
+ assert_equal cached, info.boxart_path
108
+ ensure
109
+ ENV.delete('GEMBA_CONFIG_DIR')
110
+ end
111
+ end
112
+
113
+ def test_boxart_path_nil_when_neither_present
114
+ Dir.mktmpdir do |dir|
115
+ ENV['GEMBA_CONFIG_DIR'] = dir
116
+ fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: File.join(dir, "boxart"),
117
+ backend: Gemba::BoxartFetcher::NullBackend.new)
118
+ overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json"))
119
+
120
+ info = Gemba::RomInfo.from_rom(ROM, fetcher: fetcher, overrides: overrides, game_index: NULL_INDEX)
121
+ assert_nil info.boxart_path
122
+ ensure
123
+ ENV.delete('GEMBA_CONFIG_DIR')
124
+ end
125
+ end
126
+
127
+ def test_custom_beats_cache_in_boxart_path
128
+ Dir.mktmpdir do |dir|
129
+ ENV['GEMBA_CONFIG_DIR'] = dir
130
+ cache_dir = File.join(dir, "boxart")
131
+ fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: cache_dir,
132
+ backend: Gemba::BoxartFetcher::NullBackend.new)
133
+ overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json"))
134
+
135
+ cached = fetcher.cached_path('AGB-AXVE')
136
+ FileUtils.mkdir_p(File.dirname(cached))
137
+ File.write(cached, "cached")
138
+
139
+ src = File.join(dir, "my_cover.png")
140
+ File.write(src, "custom")
141
+ overrides.set_custom_boxart('AGB_AXVE-DEADBEEF', src)
142
+
143
+ info = Gemba::RomInfo.from_rom(ROM, fetcher: fetcher, overrides: overrides, game_index: NULL_INDEX)
144
+ assert_match %r{custom\.png$}, info.boxart_path, "Custom should beat cached"
145
+ ensure
146
+ ENV.delete('GEMBA_CONFIG_DIR')
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "gemba/headless"
7
+ require "gemba/headless"
8
+
9
+ class TestRomOverrides < Minitest::Test
10
+ def setup
11
+ @tmpdir = Dir.mktmpdir("rom_overrides_test")
12
+ @json = File.join(@tmpdir, "rom_overrides.json")
13
+ @boxart = File.join(@tmpdir, "boxart")
14
+ # Point Config.boxart_dir at our tmpdir so copies land there
15
+ @orig_env = ENV['GEMBA_CONFIG_DIR']
16
+ ENV['GEMBA_CONFIG_DIR'] = @tmpdir
17
+ end
18
+
19
+ def teardown
20
+ ENV['GEMBA_CONFIG_DIR'] = @orig_env
21
+ FileUtils.rm_rf(@tmpdir)
22
+ end
23
+
24
+ def test_custom_boxart_returns_nil_when_nothing_set
25
+ overrides = Gemba::RomOverrides.new(@json)
26
+ assert_nil overrides.custom_boxart("AGB_AXVE-DEADBEEF")
27
+ end
28
+
29
+ def test_set_custom_boxart_copies_file_and_returns_dest
30
+ src = File.join(@tmpdir, "cover.png")
31
+ File.write(src, "fake png")
32
+
33
+ overrides = Gemba::RomOverrides.new(@json)
34
+ dest = overrides.set_custom_boxart("AGB_AXVE-DEADBEEF", src)
35
+
36
+ assert File.exist?(dest), "Copied file should exist at dest"
37
+ assert_equal "fake png", File.read(dest)
38
+ assert_match %r{/AGB_AXVE-DEADBEEF/custom\.png$}, dest
39
+ end
40
+
41
+ def test_set_custom_boxart_persists_across_reload
42
+ src = File.join(@tmpdir, "cover.png")
43
+ File.write(src, "fake png")
44
+
45
+ Gemba::RomOverrides.new(@json).set_custom_boxart("AGB_AXVE-DEADBEEF", src)
46
+
47
+ reloaded = Gemba::RomOverrides.new(@json)
48
+ stored = reloaded.custom_boxart("AGB_AXVE-DEADBEEF")
49
+ refute_nil stored
50
+ assert File.exist?(stored)
51
+ end
52
+
53
+ def test_set_custom_boxart_preserves_extension
54
+ src = File.join(@tmpdir, "cover.jpg")
55
+ File.write(src, "fake jpg")
56
+
57
+ overrides = Gemba::RomOverrides.new(@json)
58
+ dest = overrides.set_custom_boxart("AGB_AXVE-DEADBEEF", src)
59
+
60
+ assert dest.end_with?(".jpg"), "Extension should be preserved"
61
+ end
62
+
63
+ def test_multiple_rom_ids_stored_independently
64
+ src1 = File.join(@tmpdir, "a.png"); File.write(src1, "a")
65
+ src2 = File.join(@tmpdir, "b.png"); File.write(src2, "b")
66
+
67
+ overrides = Gemba::RomOverrides.new(@json)
68
+ overrides.set_custom_boxart("AGB_AXVE-AAAAAAAA", src1)
69
+ overrides.set_custom_boxart("AGB_BPEE-BBBBBBBB", src2)
70
+
71
+ assert_match %r{AAAAAAAA}, overrides.custom_boxart("AGB_AXVE-AAAAAAAA")
72
+ assert_match %r{BBBBBBBB}, overrides.custom_boxart("AGB_BPEE-BBBBBBBB")
73
+ assert_nil overrides.custom_boxart("AGB_ZZZZ-ZZZZZZZZ")
74
+ end
75
+
76
+ def test_json_file_is_valid_json
77
+ src = File.join(@tmpdir, "cover.png")
78
+ File.write(src, "fake")
79
+
80
+ Gemba::RomOverrides.new(@json).set_custom_boxart("AGB_AXVE-DEADBEEF", src)
81
+
82
+ parsed = JSON.parse(File.read(@json))
83
+ assert_instance_of Hash, parsed
84
+ assert parsed.key?("AGB_AXVE-DEADBEEF")
85
+ end
86
+ end
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require "zlib"
5
+ require "stringio"
6
+
7
+ # Bootstrap Zeitwerk autoloading without Tk/SDL2.
8
+ require_relative "../lib/gemba/headless"
9
+
10
+ class TestRomPatcher < Minitest::Test
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Fixture helpers — build valid binary patch data in-memory
14
+ # ---------------------------------------------------------------------------
15
+
16
+ # Build a minimal IPS patch that applies the given records.
17
+ # records: [{offset:, data:}] or [{offset:, rle_count:, rle_val:}]
18
+ def build_ips(records)
19
+ io = StringIO.new.tap { |s| s.binmode }
20
+ io.write("PATCH")
21
+ records.each do |rec|
22
+ off = rec[:offset]
23
+ io.write([off >> 16, (off >> 8) & 0xFF, off & 0xFF].pack("CCC"))
24
+ if rec[:rle_count]
25
+ io.write([0, rec[:rle_count]].pack("nn"))
26
+ io.write([rec[:rle_val]].pack("C"))
27
+ else
28
+ io.write([rec[:data].bytesize].pack("n"))
29
+ io.write(rec[:data].b)
30
+ end
31
+ end
32
+ io.write("EOF")
33
+ io.string.b
34
+ end
35
+
36
+ # Encode a BPS varint (byuu's additive-shift encoding).
37
+ def bps_varint(n)
38
+ out = "".b
39
+ loop do
40
+ x = n & 0x7f
41
+ n >>= 7
42
+ if n == 0
43
+ out << (0x80 | x).chr
44
+ break
45
+ end
46
+ out << x.chr
47
+ n -= 1
48
+ end
49
+ out
50
+ end
51
+
52
+ # Build a BPS patch using only TargetRead records (writes literal target data).
53
+ # Simplest valid BPS: ignores source entirely, just emits target bytes.
54
+ def build_bps(source, target)
55
+ source = source.b
56
+ target = target.b
57
+ body = StringIO.new.tap { |s| s.binmode }
58
+ body.write("BPS1")
59
+ body.write(bps_varint(source.bytesize))
60
+ body.write(bps_varint(target.bytesize))
61
+ body.write(bps_varint(0)) # metadata_size = 0
62
+ # One TargetRead record covering the entire target
63
+ word = ((target.bytesize - 1) << 2) | 1
64
+ body.write(bps_varint(word))
65
+ body.write(target)
66
+ payload = body.string.b
67
+ src_crc = Zlib.crc32(source)
68
+ tgt_crc = Zlib.crc32(target)
69
+ patch_crc = Zlib.crc32(payload)
70
+ payload + [src_crc, tgt_crc, patch_crc].pack("VVV")
71
+ end
72
+
73
+ # Encode a UPS varint (simple bitshift encoding).
74
+ def ups_varint(n)
75
+ out = "".b
76
+ loop do
77
+ x = n & 0x7f
78
+ n >>= 7
79
+ if n == 0
80
+ out << (0x80 | x).chr
81
+ break
82
+ end
83
+ out << x.chr
84
+ end
85
+ out
86
+ end
87
+
88
+ # Build a UPS patch from source → target.
89
+ def build_ups(source, target)
90
+ source = source.b
91
+ target = target.b
92
+ max_size = [source.bytesize, target.bytesize].max
93
+
94
+ # Collect diff hunks: each is {start:, xor_bytes:}
95
+ hunks = []
96
+ i = 0
97
+ while i < max_size
98
+ s = source.getbyte(i) || 0
99
+ t = target.getbyte(i) || 0
100
+ if s != t
101
+ hunk_start = i
102
+ xor_bytes = "".b
103
+ while i < max_size
104
+ s = source.getbyte(i) || 0
105
+ t = target.getbyte(i) || 0
106
+ break if s == t
107
+ xor_bytes << (s ^ t).chr
108
+ i += 1
109
+ end
110
+ hunks << { start: hunk_start, xor_bytes: xor_bytes }
111
+ else
112
+ i += 1
113
+ end
114
+ end
115
+
116
+ # Build body
117
+ body = StringIO.new.tap { |s| s.binmode }
118
+ body.write("UPS1")
119
+ body.write(ups_varint(source.bytesize))
120
+ body.write(ups_varint(target.bytesize))
121
+
122
+ pos = 0
123
+ hunks.each do |h|
124
+ skip = h[:start] - pos
125
+ body.write(ups_varint(skip))
126
+ body.write(h[:xor_bytes])
127
+ body.write("\x00")
128
+ pos = h[:start] + h[:xor_bytes].bytesize + 1
129
+ end
130
+
131
+ payload = body.string.b
132
+ src_crc = Zlib.crc32(source)
133
+ tgt_crc = Zlib.crc32(target)
134
+ patch_crc = Zlib.crc32(payload)
135
+ payload + [src_crc, tgt_crc, patch_crc].pack("VVV")
136
+ end
137
+
138
+ # A small fake ROM — 64 zero bytes, like a blank cartridge header area.
139
+ def blank_rom(size = 64)
140
+ "\x00".b * size
141
+ end
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # RomPatcher (dispatcher)
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def test_detect_format_ips
148
+ patch = "PATCH" + "EOF"
149
+ assert_equal :ips, Gemba::RomPatcher.detect_format(patch)
150
+ end
151
+
152
+ def test_detect_format_bps
153
+ patch = "BPS1\x00"
154
+ assert_equal :bps, Gemba::RomPatcher.detect_format(patch)
155
+ end
156
+
157
+ def test_detect_format_ups
158
+ patch = "UPS1\x00"
159
+ assert_equal :ups, Gemba::RomPatcher.detect_format(patch)
160
+ end
161
+
162
+ def test_detect_format_unknown
163
+ assert_nil Gemba::RomPatcher.detect_format("JUNK")
164
+ end
165
+
166
+ def test_safe_out_path_no_collision
167
+ path = "/tmp/nonexistent_gemba_test_#{Process.pid}.gba"
168
+ assert_equal path, Gemba::RomPatcher.safe_out_path(path)
169
+ end
170
+
171
+ def test_safe_out_path_collision
172
+ Dir.mktmpdir do |dir|
173
+ base = File.join(dir, "game.gba")
174
+ File.write(base, "x")
175
+ result = Gemba::RomPatcher.safe_out_path(base)
176
+ assert_equal File.join(dir, "game-(2).gba"), result
177
+ end
178
+ end
179
+
180
+ def test_safe_out_path_multiple_collisions
181
+ Dir.mktmpdir do |dir|
182
+ File.write(File.join(dir, "game.gba"), "x")
183
+ File.write(File.join(dir, "game-(2).gba"), "x")
184
+ result = Gemba::RomPatcher.safe_out_path(File.join(dir, "game.gba"))
185
+ assert_equal File.join(dir, "game-(3).gba"), result
186
+ end
187
+ end
188
+
189
+ def test_patch_dispatches_to_ips
190
+ Dir.mktmpdir do |dir|
191
+ rom_path = File.join(dir, "rom.gba")
192
+ patch_path = File.join(dir, "fix.ips")
193
+ out_path = File.join(dir, "rom-patched.gba")
194
+
195
+ source = blank_rom
196
+ File.binwrite(rom_path, source)
197
+ File.binwrite(patch_path, build_ips([{ offset: 0, data: "\xFF\xFE\xFD\xFC" }]))
198
+
199
+ Gemba::RomPatcher.patch(rom_path: rom_path, patch_path: patch_path, out_path: out_path)
200
+ result = File.binread(out_path)
201
+ assert_equal "\xFF".b, result[0, 1]
202
+ assert_equal "\xFE".b, result[1, 1]
203
+ end
204
+ end
205
+
206
+ def test_patch_raises_on_unknown_format
207
+ Dir.mktmpdir do |dir|
208
+ File.binwrite(File.join(dir, "rom.gba"), "X" * 16)
209
+ File.binwrite(File.join(dir, "bad.xyz"), "JUNK")
210
+ assert_raises(RuntimeError) do
211
+ Gemba::RomPatcher.patch(
212
+ rom_path: File.join(dir, "rom.gba"),
213
+ patch_path: File.join(dir, "bad.xyz"),
214
+ out_path: File.join(dir, "out.gba")
215
+ )
216
+ end
217
+ end
218
+ end
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # IPS
222
+ # ---------------------------------------------------------------------------
223
+
224
+ def test_ips_overwrites_bytes_at_offset
225
+ source = blank_rom
226
+ patch = build_ips([{ offset: 4, data: "\xFF\xFE\xFD" }])
227
+ result = Gemba::RomPatcher::IPS.apply(source, patch)
228
+ assert_equal "\x00".b * 4, result[0, 4], "bytes before offset unchanged"
229
+ assert_equal "\xFF\xFE\xFD".b, result[4, 3], "patch bytes applied"
230
+ assert_equal "\x00".b, result[7, 1], "bytes after patch unchanged"
231
+ end
232
+
233
+ def test_ips_rle_record_fills_region
234
+ source = blank_rom
235
+ patch = build_ips([{ offset: 8, rle_count: 4, rle_val: 0xAB }])
236
+ result = Gemba::RomPatcher::IPS.apply(source, patch)
237
+ assert_equal "\xAB".b * 4, result[8, 4]
238
+ assert_equal "\x00".b, result[7, 1], "byte before RLE unchanged"
239
+ assert_equal "\x00".b, result[12, 1], "byte after RLE unchanged"
240
+ end
241
+
242
+ def test_ips_multiple_records
243
+ source = blank_rom
244
+ patch = build_ips([
245
+ { offset: 0, data: "\x01\x02" },
246
+ { offset: 10, data: "\x03\x04" },
247
+ ])
248
+ result = Gemba::RomPatcher::IPS.apply(source, patch)
249
+ assert_equal "\x01\x02".b, result[0, 2]
250
+ assert_equal "\x03\x04".b, result[10, 2]
251
+ end
252
+
253
+ def test_ips_extends_rom_if_patch_exceeds_size
254
+ source = "\x00".b * 4
255
+ patch = build_ips([{ offset: 8, data: "\xFF\xFF" }])
256
+ result = Gemba::RomPatcher::IPS.apply(source, patch)
257
+ assert result.bytesize >= 10, "ROM extended to fit patch"
258
+ assert_equal "\xFF\xFF".b, result[8, 2]
259
+ end
260
+
261
+ def test_ips_empty_patch_returns_rom_unchanged
262
+ source = "HELLO".b
263
+ patch = build_ips([])
264
+ result = Gemba::RomPatcher::IPS.apply(source, patch)
265
+ assert_equal source, result
266
+ end
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # BPS
270
+ # ---------------------------------------------------------------------------
271
+
272
+ def test_bps_target_read_produces_correct_output
273
+ source = blank_rom(8)
274
+ target = "\x11\x22\x33\x44\x55\x66\x77\x88".b
275
+ patch = build_bps(source, target)
276
+ result = Gemba::RomPatcher::BPS.apply(source, patch)
277
+ assert_equal target, result
278
+ end
279
+
280
+ def test_bps_crc_mismatch_raises
281
+ source = blank_rom(8)
282
+ target = "\xDE\xAD\xBE\xEF\x00\x00\x00\x00".b
283
+ patch = build_bps(source, target)
284
+ # Corrupt the source CRC (bytes -12..-9)
285
+ bad_patch = patch.dup.b
286
+ bad_patch[-12] = "\xFF".b
287
+ err = assert_raises(RuntimeError) { Gemba::RomPatcher::BPS.apply(source, bad_patch) }
288
+ assert_match(/CRC32/, err.message)
289
+ end
290
+
291
+ def test_bps_identical_source_and_target
292
+ source = "GEMBA".b
293
+ target = "GEMBA".b
294
+ patch = build_bps(source, target)
295
+ result = Gemba::RomPatcher::BPS.apply(source, patch)
296
+ assert_equal target, result
297
+ end
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # UPS
301
+ # ---------------------------------------------------------------------------
302
+
303
+ def test_ups_xors_differing_bytes
304
+ source = "\x00\x00\x00\x00".b
305
+ target = "\xFF\x00\xFF\x00".b
306
+ patch = build_ups(source, target)
307
+ result = Gemba::RomPatcher::UPS.apply(source, patch)
308
+ assert_equal target, result
309
+ end
310
+
311
+ def test_ups_multiple_hunks
312
+ source = "\x00" * 16
313
+ target = source.dup.b
314
+ target.setbyte(0, 0xAA)
315
+ target.setbyte(8, 0xBB)
316
+ target.setbyte(15, 0xCC)
317
+ patch = build_ups(source.b, target)
318
+ result = Gemba::RomPatcher::UPS.apply(source.b, patch)
319
+ assert_equal target, result
320
+ end
321
+
322
+ def test_ups_crc_mismatch_raises
323
+ source = blank_rom(8)
324
+ target = "\xCA\xFE\xBA\xBE\x00\x00\x00\x00".b
325
+ patch = build_ups(source, target)
326
+ bad_patch = patch.dup.b
327
+ bad_patch[-12] = "\x00".b
328
+ bad_patch[-11] = "\x00".b
329
+ err = assert_raises(RuntimeError) { Gemba::RomPatcher::UPS.apply(source, bad_patch) }
330
+ assert_match(/CRC32/, err.message)
331
+ end
332
+
333
+ def test_ups_identical_source_and_target
334
+ source = "GEMBA\x00\x00\x00".b
335
+ target = source.dup
336
+ patch = build_ups(source, target)
337
+ result = Gemba::RomPatcher::UPS.apply(source, patch)
338
+ assert_equal target, result
339
+ end
340
+
341
+ def test_ups_pads_target_when_source_is_shorter
342
+ # target_size > source_size — result zero-pads to target_size
343
+ source = "\x01\x02".b
344
+ target = "\x01\x03\x00\x00".b # byte 1 differs; bytes 2-3 are 0 (matching padding)
345
+ patch = build_ups(source, target)
346
+ result = Gemba::RomPatcher::UPS.apply(source, patch)
347
+ assert_equal target, result
348
+ end
349
+
350
+ # ---------------------------------------------------------------------------
351
+ # ZIP ROM input
352
+ # ---------------------------------------------------------------------------
353
+
354
+ def test_patch_with_zip_rom_produces_gba_output
355
+ require 'zip'
356
+ dir = Dir.mktmpdir
357
+ begin
358
+ # Build a tiny ROM and wrap it in a zip
359
+ rom_data = blank_rom
360
+ zip_path = File.join(dir, "game.zip")
361
+ patch_path = File.join(dir, "fix.ips")
362
+ out_path = File.join(dir, "game-patched.gba")
363
+
364
+ Zip::OutputStream.open(zip_path) do |zos|
365
+ zos.put_next_entry("game.gba")
366
+ zos.write(rom_data)
367
+ end
368
+
369
+ File.binwrite(patch_path, build_ips([{ offset: 0, data: "\xFF\xFE" }]))
370
+
371
+ resolved = Gemba::RomResolver.resolve(zip_path)
372
+ Gemba::RomPatcher.patch(rom_path: resolved, patch_path: patch_path, out_path: out_path)
373
+
374
+ assert File.exist?(out_path), "expected output at #{out_path}"
375
+ assert_equal ".gba", File.extname(out_path)
376
+ assert_equal "\xFF".b, File.binread(out_path, 1)
377
+ ensure
378
+ # Windows may still hold the zip file handle until GC — ignore EACCES on cleanup
379
+ FileUtils.remove_entry(dir) rescue nil
380
+ end
381
+ end
382
+ end