gemba 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. checksums.yaml +4 -4
  2. data/THIRD_PARTY_NOTICES +37 -2
  3. data/assets/placeholder_boxart.png +0 -0
  4. data/bin/gemba +2 -2
  5. data/ext/gemba/extconf.rb +23 -1
  6. data/ext/gemba/gemba_ext.c +436 -2
  7. data/ext/gemba/gemba_ext.h +2 -0
  8. data/gemba.gemspec +5 -3
  9. data/lib/gemba/achievements/achievement.rb +23 -0
  10. data/lib/gemba/achievements/backend.rb +190 -0
  11. data/lib/gemba/achievements/cache.rb +70 -0
  12. data/lib/gemba/achievements/credentials_presenter.rb +142 -0
  13. data/lib/gemba/achievements/fake_backend.rb +205 -0
  14. data/lib/gemba/achievements/null_backend.rb +11 -0
  15. data/lib/gemba/achievements/offline_backend.rb +168 -0
  16. data/lib/gemba/achievements/retro_achievements/backend.rb +511 -0
  17. data/lib/gemba/achievements/retro_achievements/cli_sync_requester.rb +64 -0
  18. data/lib/gemba/achievements/retro_achievements/ping_worker.rb +28 -0
  19. data/lib/gemba/achievements/retro_achievements/unlock_retry_worker.rb +35 -0
  20. data/lib/gemba/achievements.rb +19 -0
  21. data/lib/gemba/achievements_window.rb +556 -0
  22. data/lib/gemba/app_controller.rb +1036 -0
  23. data/lib/gemba/bios.rb +54 -0
  24. data/lib/gemba/boxart_fetcher/libretro_backend.rb +39 -0
  25. data/lib/gemba/boxart_fetcher/null_backend.rb +12 -0
  26. data/lib/gemba/boxart_fetcher.rb +79 -0
  27. data/lib/gemba/bus_emitter.rb +13 -0
  28. data/lib/gemba/child_window.rb +24 -1
  29. data/lib/gemba/cli/commands/config_cmd.rb +83 -0
  30. data/lib/gemba/cli/commands/decode.rb +154 -0
  31. data/lib/gemba/cli/commands/patch.rb +78 -0
  32. data/lib/gemba/cli/commands/play.rb +78 -0
  33. data/lib/gemba/cli/commands/record.rb +114 -0
  34. data/lib/gemba/cli/commands/replay.rb +161 -0
  35. data/lib/gemba/cli/commands/retro_achievements.rb +213 -0
  36. data/lib/gemba/cli/commands/version.rb +22 -0
  37. data/lib/gemba/cli.rb +52 -364
  38. data/lib/gemba/config.rb +154 -1
  39. data/lib/gemba/data/gb_games.json +1 -0
  40. data/lib/gemba/data/gb_md5.json +1 -0
  41. data/lib/gemba/data/gba_games.json +1 -0
  42. data/lib/gemba/data/gba_md5.json +1 -0
  43. data/lib/gemba/data/gbc_games.json +1 -0
  44. data/lib/gemba/data/gbc_md5.json +1 -0
  45. data/lib/gemba/emulator_frame.rb +1084 -0
  46. data/lib/gemba/event_bus.rb +48 -0
  47. data/lib/gemba/frame_stack.rb +70 -0
  48. data/lib/gemba/game_index.rb +84 -0
  49. data/lib/gemba/game_picker_frame.rb +309 -0
  50. data/lib/gemba/gamepad_map.rb +103 -0
  51. data/lib/gemba/headless.rb +6 -5
  52. data/lib/gemba/headless_player.rb +33 -3
  53. data/lib/gemba/help_window.rb +61 -0
  54. data/lib/gemba/hotkey_map.rb +3 -1
  55. data/lib/gemba/input_recorder.rb +107 -0
  56. data/lib/gemba/input_replayer.rb +119 -0
  57. data/lib/gemba/keyboard_map.rb +90 -0
  58. data/lib/gemba/list_picker_frame.rb +271 -0
  59. data/lib/gemba/locales/en.yml +109 -5
  60. data/lib/gemba/locales/ja.yml +109 -5
  61. data/lib/gemba/main_window.rb +56 -0
  62. data/lib/gemba/modal_stack.rb +81 -0
  63. data/lib/gemba/patcher_window.rb +223 -0
  64. data/lib/gemba/platform/gb.rb +21 -0
  65. data/lib/gemba/platform/gba.rb +21 -0
  66. data/lib/gemba/platform/gbc.rb +23 -0
  67. data/lib/gemba/platform.rb +20 -0
  68. data/lib/gemba/platform_open.rb +19 -0
  69. data/lib/gemba/recorder.rb +4 -3
  70. data/lib/gemba/replay_player.rb +691 -0
  71. data/lib/gemba/rom_info.rb +57 -0
  72. data/lib/gemba/rom_info_window.rb +16 -3
  73. data/lib/gemba/rom_library.rb +106 -0
  74. data/lib/gemba/rom_overrides.rb +47 -0
  75. data/lib/gemba/rom_patcher/bps.rb +161 -0
  76. data/lib/gemba/rom_patcher/ips.rb +101 -0
  77. data/lib/gemba/rom_patcher/ups.rb +119 -0
  78. data/lib/gemba/rom_patcher.rb +109 -0
  79. data/lib/gemba/{rom_loader.rb → rom_resolver.rb} +7 -6
  80. data/lib/gemba/runtime.rb +59 -26
  81. data/lib/gemba/save_state_manager.rb +4 -7
  82. data/lib/gemba/save_state_picker.rb +17 -4
  83. data/lib/gemba/session_logger.rb +64 -0
  84. data/lib/gemba/settings/audio_tab.rb +77 -0
  85. data/lib/gemba/settings/gamepad_tab.rb +351 -0
  86. data/lib/gemba/settings/hotkeys_tab.rb +259 -0
  87. data/lib/gemba/settings/paths.rb +11 -0
  88. data/lib/gemba/settings/recording_tab.rb +83 -0
  89. data/lib/gemba/settings/save_states_tab.rb +91 -0
  90. data/lib/gemba/settings/system_tab.rb +377 -0
  91. data/lib/gemba/settings/video_tab.rb +318 -0
  92. data/lib/gemba/settings_window.rb +162 -1036
  93. data/lib/gemba/version.rb +1 -1
  94. data/lib/gemba/virtual_keyboard.rb +19 -0
  95. data/lib/gemba.rb +2 -12
  96. data/test/achievements_window/test_bulk_sync.rb +218 -0
  97. data/test/achievements_window/test_bus_events.rb +125 -0
  98. data/test/achievements_window/test_close_confirmation.rb +201 -0
  99. data/test/achievements_window/test_initial_state.rb +164 -0
  100. data/test/achievements_window/test_sorting.rb +227 -0
  101. data/test/achievements_window/test_tree_rendering.rb +133 -0
  102. data/test/fixtures/fake_bios.bin +0 -0
  103. data/test/fixtures/pong.gba +0 -0
  104. data/test/fixtures/test.gb +0 -0
  105. data/test/fixtures/test.gbc +0 -0
  106. data/test/fixtures/test_quicksave.ss +0 -0
  107. data/test/screenshots/no_focus.png +0 -0
  108. data/test/shared/teek_test_worker.rb +17 -1
  109. data/test/shared/tk_test_helper.rb +92 -4
  110. data/test/support/achievements_window_helpers.rb +18 -0
  111. data/test/support/fake_core.rb +25 -0
  112. data/test/support/fake_ra_runtime.rb +74 -0
  113. data/test/support/fake_requester.rb +78 -0
  114. data/test/support/player_helpers.rb +20 -5
  115. data/test/test_achievement.rb +32 -0
  116. data/test/{test_player.rb → test_app_controller.rb} +353 -85
  117. data/test/test_bios.rb +123 -0
  118. data/test/test_boxart_fetcher.rb +150 -0
  119. data/test/test_cli.rb +17 -265
  120. data/test/test_cli_config.rb +64 -0
  121. data/test/test_cli_decode.rb +97 -0
  122. data/test/test_cli_patch.rb +58 -0
  123. data/test/test_cli_play.rb +213 -0
  124. data/test/test_cli_ra.rb +175 -0
  125. data/test/test_cli_record.rb +69 -0
  126. data/test/test_cli_replay.rb +72 -0
  127. data/test/test_cli_sync_requester.rb +152 -0
  128. data/test/test_cli_version.rb +27 -0
  129. data/test/test_config.rb +3 -3
  130. data/test/test_config_ra.rb +69 -0
  131. data/test/test_core.rb +62 -1
  132. data/test/test_credentials_presenter.rb +192 -0
  133. data/test/test_event_bus.rb +100 -0
  134. data/test/test_fake_backend_achievements.rb +130 -0
  135. data/test/test_fake_backend_auth.rb +68 -0
  136. data/test/test_game_index.rb +77 -0
  137. data/test/test_game_picker_frame.rb +310 -0
  138. data/test/test_gamepad_map.rb +1 -3
  139. data/test/test_headless_player.rb +17 -3
  140. data/test/test_help_window.rb +82 -0
  141. data/test/test_hotkey_map.rb +22 -1
  142. data/test/test_input_recorder.rb +179 -0
  143. data/test/test_input_replay_determinism.rb +113 -0
  144. data/test/test_input_replayer.rb +162 -0
  145. data/test/test_keyboard_map.rb +1 -3
  146. data/test/test_libretro_backend.rb +41 -0
  147. data/test/test_list_picker_frame.rb +391 -0
  148. data/test/test_locale.rb +1 -1
  149. data/test/test_logging.rb +123 -0
  150. data/test/test_null_backend.rb +42 -0
  151. data/test/test_offline_backend.rb +116 -0
  152. data/test/test_overlay_renderer.rb +1 -1
  153. data/test/test_platform.rb +149 -0
  154. data/test/test_ra_backend.rb +313 -0
  155. data/test/test_ra_backend_unlock_gate.rb +56 -0
  156. data/test/test_ra_backend_unlock_retry.rb +123 -0
  157. data/test/test_recorder.rb +0 -3
  158. data/test/test_replay_player.rb +316 -0
  159. data/test/test_rom_info.rb +149 -0
  160. data/test/test_rom_overrides.rb +86 -0
  161. data/test/test_rom_patcher.rb +383 -0
  162. data/test/{test_rom_loader.rb → test_rom_resolver.rb} +25 -26
  163. data/test/test_save_state_manager.rb +2 -4
  164. data/test/test_settings_audio.rb +107 -0
  165. data/test/test_settings_hotkeys.rb +83 -66
  166. data/test/test_settings_recording.rb +49 -0
  167. data/test/test_settings_save_states.rb +97 -0
  168. data/test/test_settings_system.rb +133 -0
  169. data/test/test_settings_video.rb +450 -0
  170. data/test/test_settings_window.rb +76 -507
  171. data/test/test_tip_service.rb +6 -6
  172. data/test/test_toast_overlay.rb +1 -1
  173. data/test/test_virtual_events.rb +221 -0
  174. data/test/test_virtual_keyboard.rb +1 -1
  175. data/vendor/rcheevos/CHANGELOG.md +495 -0
  176. data/vendor/rcheevos/LICENSE +21 -0
  177. data/vendor/rcheevos/Package.swift +33 -0
  178. data/vendor/rcheevos/README.md +67 -0
  179. data/vendor/rcheevos/include/module.modulemap +70 -0
  180. data/vendor/rcheevos/include/rc_api_editor.h +296 -0
  181. data/vendor/rcheevos/include/rc_api_info.h +280 -0
  182. data/vendor/rcheevos/include/rc_api_request.h +77 -0
  183. data/vendor/rcheevos/include/rc_api_runtime.h +417 -0
  184. data/vendor/rcheevos/include/rc_api_user.h +262 -0
  185. data/vendor/rcheevos/include/rc_client.h +877 -0
  186. data/vendor/rcheevos/include/rc_client_raintegration.h +101 -0
  187. data/vendor/rcheevos/include/rc_consoles.h +138 -0
  188. data/vendor/rcheevos/include/rc_error.h +59 -0
  189. data/vendor/rcheevos/include/rc_export.h +100 -0
  190. data/vendor/rcheevos/include/rc_hash.h +200 -0
  191. data/vendor/rcheevos/include/rc_runtime.h +148 -0
  192. data/vendor/rcheevos/include/rc_runtime_types.h +452 -0
  193. data/vendor/rcheevos/include/rc_util.h +51 -0
  194. data/vendor/rcheevos/include/rcheevos.h +8 -0
  195. data/vendor/rcheevos/src/rapi/rc_api_common.c +1379 -0
  196. data/vendor/rcheevos/src/rapi/rc_api_common.h +88 -0
  197. data/vendor/rcheevos/src/rapi/rc_api_editor.c +625 -0
  198. data/vendor/rcheevos/src/rapi/rc_api_info.c +587 -0
  199. data/vendor/rcheevos/src/rapi/rc_api_runtime.c +901 -0
  200. data/vendor/rcheevos/src/rapi/rc_api_user.c +483 -0
  201. data/vendor/rcheevos/src/rc_client.c +6941 -0
  202. data/vendor/rcheevos/src/rc_client_external.c +281 -0
  203. data/vendor/rcheevos/src/rc_client_external.h +177 -0
  204. data/vendor/rcheevos/src/rc_client_external_versions.h +171 -0
  205. data/vendor/rcheevos/src/rc_client_internal.h +409 -0
  206. data/vendor/rcheevos/src/rc_client_raintegration.c +566 -0
  207. data/vendor/rcheevos/src/rc_client_raintegration_internal.h +61 -0
  208. data/vendor/rcheevos/src/rc_client_types.natvis +396 -0
  209. data/vendor/rcheevos/src/rc_compat.c +251 -0
  210. data/vendor/rcheevos/src/rc_compat.h +121 -0
  211. data/vendor/rcheevos/src/rc_libretro.c +915 -0
  212. data/vendor/rcheevos/src/rc_libretro.h +98 -0
  213. data/vendor/rcheevos/src/rc_util.c +199 -0
  214. data/vendor/rcheevos/src/rc_version.c +11 -0
  215. data/vendor/rcheevos/src/rc_version.h +32 -0
  216. data/vendor/rcheevos/src/rcheevos/alloc.c +312 -0
  217. data/vendor/rcheevos/src/rcheevos/condition.c +754 -0
  218. data/vendor/rcheevos/src/rcheevos/condset.c +777 -0
  219. data/vendor/rcheevos/src/rcheevos/consoleinfo.c +1215 -0
  220. data/vendor/rcheevos/src/rcheevos/format.c +330 -0
  221. data/vendor/rcheevos/src/rcheevos/lboard.c +287 -0
  222. data/vendor/rcheevos/src/rcheevos/memref.c +805 -0
  223. data/vendor/rcheevos/src/rcheevos/operand.c +607 -0
  224. data/vendor/rcheevos/src/rcheevos/rc_internal.h +390 -0
  225. data/vendor/rcheevos/src/rcheevos/rc_runtime_types.natvis +541 -0
  226. data/vendor/rcheevos/src/rcheevos/rc_validate.c +1406 -0
  227. data/vendor/rcheevos/src/rcheevos/rc_validate.h +18 -0
  228. data/vendor/rcheevos/src/rcheevos/richpresence.c +922 -0
  229. data/vendor/rcheevos/src/rcheevos/runtime.c +852 -0
  230. data/vendor/rcheevos/src/rcheevos/runtime_progress.c +1073 -0
  231. data/vendor/rcheevos/src/rcheevos/trigger.c +344 -0
  232. data/vendor/rcheevos/src/rcheevos/value.c +935 -0
  233. data/vendor/rcheevos/src/rhash/aes.c +480 -0
  234. data/vendor/rcheevos/src/rhash/aes.h +49 -0
  235. data/vendor/rcheevos/src/rhash/cdreader.c +838 -0
  236. data/vendor/rcheevos/src/rhash/hash.c +1402 -0
  237. data/vendor/rcheevos/src/rhash/hash_disc.c +1340 -0
  238. data/vendor/rcheevos/src/rhash/hash_encrypted.c +566 -0
  239. data/vendor/rcheevos/src/rhash/hash_rom.c +426 -0
  240. data/vendor/rcheevos/src/rhash/hash_zip.c +460 -0
  241. data/vendor/rcheevos/src/rhash/md5.c +382 -0
  242. data/vendor/rcheevos/src/rhash/md5.h +91 -0
  243. data/vendor/rcheevos/src/rhash/rc_hash_internal.h +116 -0
  244. data/vendor/rcheevos/test/libretro.h +205 -0
  245. data/vendor/rcheevos/test/rapi/test_rc_api_common.c +941 -0
  246. data/vendor/rcheevos/test/rapi/test_rc_api_editor.c +931 -0
  247. data/vendor/rcheevos/test/rapi/test_rc_api_info.c +545 -0
  248. data/vendor/rcheevos/test/rapi/test_rc_api_runtime.c +2213 -0
  249. data/vendor/rcheevos/test/rapi/test_rc_api_user.c +998 -0
  250. data/vendor/rcheevos/test/rcheevos/mock_memory.h +32 -0
  251. data/vendor/rcheevos/test/rcheevos/test_condition.c +570 -0
  252. data/vendor/rcheevos/test/rcheevos/test_condset.c +5170 -0
  253. data/vendor/rcheevos/test/rcheevos/test_consoleinfo.c +203 -0
  254. data/vendor/rcheevos/test/rcheevos/test_format.c +112 -0
  255. data/vendor/rcheevos/test/rcheevos/test_lboard.c +746 -0
  256. data/vendor/rcheevos/test/rcheevos/test_memref.c +520 -0
  257. data/vendor/rcheevos/test/rcheevos/test_operand.c +692 -0
  258. data/vendor/rcheevos/test/rcheevos/test_rc_validate.c +502 -0
  259. data/vendor/rcheevos/test/rcheevos/test_richpresence.c +1564 -0
  260. data/vendor/rcheevos/test/rcheevos/test_runtime.c +1667 -0
  261. data/vendor/rcheevos/test/rcheevos/test_runtime_progress.c +1821 -0
  262. data/vendor/rcheevos/test/rcheevos/test_timing.c +166 -0
  263. data/vendor/rcheevos/test/rcheevos/test_trigger.c +2521 -0
  264. data/vendor/rcheevos/test/rcheevos/test_value.c +870 -0
  265. data/vendor/rcheevos/test/rcheevos-test.sln +46 -0
  266. data/vendor/rcheevos/test/rcheevos-test.vcxproj +239 -0
  267. data/vendor/rcheevos/test/rcheevos-test.vcxproj.filters +335 -0
  268. data/vendor/rcheevos/test/rhash/data.c +657 -0
  269. data/vendor/rcheevos/test/rhash/data.h +32 -0
  270. data/vendor/rcheevos/test/rhash/mock_filereader.c +236 -0
  271. data/vendor/rcheevos/test/rhash/mock_filereader.h +31 -0
  272. data/vendor/rcheevos/test/rhash/test_cdreader.c +920 -0
  273. data/vendor/rcheevos/test/rhash/test_hash.c +310 -0
  274. data/vendor/rcheevos/test/rhash/test_hash_disc.c +1450 -0
  275. data/vendor/rcheevos/test/rhash/test_hash_rom.c +899 -0
  276. data/vendor/rcheevos/test/rhash/test_hash_zip.c +551 -0
  277. data/vendor/rcheevos/test/test.c +113 -0
  278. data/vendor/rcheevos/test/test_framework.h +205 -0
  279. data/vendor/rcheevos/test/test_rc_client.c +10509 -0
  280. data/vendor/rcheevos/test/test_rc_client_external.c +2197 -0
  281. data/vendor/rcheevos/test/test_rc_client_raintegration.c +441 -0
  282. data/vendor/rcheevos/test/test_rc_libretro.c +952 -0
  283. data/vendor/rcheevos/test/test_types.natvis +9 -0
  284. data/vendor/rcheevos/validator/validator.c +658 -0
  285. data/vendor/rcheevos/validator/validator.vcxproj +152 -0
  286. data/vendor/rcheevos/validator/validator.vcxproj.filters +82 -0
  287. metadata +277 -10
  288. data/lib/gemba/input_mappings.rb +0 -214
  289. data/lib/gemba/player.rb +0 -1525
@@ -0,0 +1,383 @@
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 (additive-shift encoding with continuation bias).
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
+ n -= 1
85
+ end
86
+ out
87
+ end
88
+
89
+ # Build a UPS patch from source → target.
90
+ def build_ups(source, target)
91
+ source = source.b
92
+ target = target.b
93
+ max_size = [source.bytesize, target.bytesize].max
94
+
95
+ # Collect diff hunks: each is {start:, xor_bytes:}
96
+ hunks = []
97
+ i = 0
98
+ while i < max_size
99
+ s = source.getbyte(i) || 0
100
+ t = target.getbyte(i) || 0
101
+ if s != t
102
+ hunk_start = i
103
+ xor_bytes = "".b
104
+ while i < max_size
105
+ s = source.getbyte(i) || 0
106
+ t = target.getbyte(i) || 0
107
+ break if s == t
108
+ xor_bytes << (s ^ t).chr
109
+ i += 1
110
+ end
111
+ hunks << { start: hunk_start, xor_bytes: xor_bytes }
112
+ else
113
+ i += 1
114
+ end
115
+ end
116
+
117
+ # Build body
118
+ body = StringIO.new.tap { |s| s.binmode }
119
+ body.write("UPS1")
120
+ body.write(ups_varint(source.bytesize))
121
+ body.write(ups_varint(target.bytesize))
122
+
123
+ pos = 0
124
+ hunks.each do |h|
125
+ skip = h[:start] - pos
126
+ body.write(ups_varint(skip))
127
+ body.write(h[:xor_bytes])
128
+ body.write("\x00")
129
+ pos = h[:start] + h[:xor_bytes].bytesize + 1
130
+ end
131
+
132
+ payload = body.string.b
133
+ src_crc = Zlib.crc32(source)
134
+ tgt_crc = Zlib.crc32(target)
135
+ patch_crc = Zlib.crc32(payload)
136
+ payload + [src_crc, tgt_crc, patch_crc].pack("VVV")
137
+ end
138
+
139
+ # A small fake ROM — 64 zero bytes, like a blank cartridge header area.
140
+ def blank_rom(size = 64)
141
+ "\x00".b * size
142
+ end
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # RomPatcher (dispatcher)
146
+ # ---------------------------------------------------------------------------
147
+
148
+ def test_detect_format_ips
149
+ patch = "PATCH" + "EOF"
150
+ assert_equal :ips, Gemba::RomPatcher.detect_format(patch)
151
+ end
152
+
153
+ def test_detect_format_bps
154
+ patch = "BPS1\x00"
155
+ assert_equal :bps, Gemba::RomPatcher.detect_format(patch)
156
+ end
157
+
158
+ def test_detect_format_ups
159
+ patch = "UPS1\x00"
160
+ assert_equal :ups, Gemba::RomPatcher.detect_format(patch)
161
+ end
162
+
163
+ def test_detect_format_unknown
164
+ assert_nil Gemba::RomPatcher.detect_format("JUNK")
165
+ end
166
+
167
+ def test_safe_out_path_no_collision
168
+ path = "/tmp/nonexistent_gemba_test_#{Process.pid}.gba"
169
+ assert_equal path, Gemba::RomPatcher.safe_out_path(path)
170
+ end
171
+
172
+ def test_safe_out_path_collision
173
+ Dir.mktmpdir do |dir|
174
+ base = File.join(dir, "game.gba")
175
+ File.write(base, "x")
176
+ result = Gemba::RomPatcher.safe_out_path(base)
177
+ assert_equal File.join(dir, "game-(2).gba"), result
178
+ end
179
+ end
180
+
181
+ def test_safe_out_path_multiple_collisions
182
+ Dir.mktmpdir do |dir|
183
+ File.write(File.join(dir, "game.gba"), "x")
184
+ File.write(File.join(dir, "game-(2).gba"), "x")
185
+ result = Gemba::RomPatcher.safe_out_path(File.join(dir, "game.gba"))
186
+ assert_equal File.join(dir, "game-(3).gba"), result
187
+ end
188
+ end
189
+
190
+ def test_patch_dispatches_to_ips
191
+ Dir.mktmpdir do |dir|
192
+ rom_path = File.join(dir, "rom.gba")
193
+ patch_path = File.join(dir, "fix.ips")
194
+ out_path = File.join(dir, "rom-patched.gba")
195
+
196
+ source = blank_rom
197
+ File.binwrite(rom_path, source)
198
+ File.binwrite(patch_path, build_ips([{ offset: 0, data: "\xFF\xFE\xFD\xFC" }]))
199
+
200
+ Gemba::RomPatcher.patch(rom_path: rom_path, patch_path: patch_path, out_path: out_path)
201
+ result = File.binread(out_path)
202
+ assert_equal "\xFF".b, result[0, 1]
203
+ assert_equal "\xFE".b, result[1, 1]
204
+ end
205
+ end
206
+
207
+ def test_patch_raises_on_unknown_format
208
+ Dir.mktmpdir do |dir|
209
+ File.binwrite(File.join(dir, "rom.gba"), "X" * 16)
210
+ File.binwrite(File.join(dir, "bad.xyz"), "JUNK")
211
+ assert_raises(RuntimeError) do
212
+ Gemba::RomPatcher.patch(
213
+ rom_path: File.join(dir, "rom.gba"),
214
+ patch_path: File.join(dir, "bad.xyz"),
215
+ out_path: File.join(dir, "out.gba")
216
+ )
217
+ end
218
+ end
219
+ end
220
+
221
+ # ---------------------------------------------------------------------------
222
+ # IPS
223
+ # ---------------------------------------------------------------------------
224
+
225
+ def test_ips_overwrites_bytes_at_offset
226
+ source = blank_rom
227
+ patch = build_ips([{ offset: 4, data: "\xFF\xFE\xFD" }])
228
+ result = Gemba::RomPatcher::IPS.apply(source, patch)
229
+ assert_equal "\x00".b * 4, result[0, 4], "bytes before offset unchanged"
230
+ assert_equal "\xFF\xFE\xFD".b, result[4, 3], "patch bytes applied"
231
+ assert_equal "\x00".b, result[7, 1], "bytes after patch unchanged"
232
+ end
233
+
234
+ def test_ips_rle_record_fills_region
235
+ source = blank_rom
236
+ patch = build_ips([{ offset: 8, rle_count: 4, rle_val: 0xAB }])
237
+ result = Gemba::RomPatcher::IPS.apply(source, patch)
238
+ assert_equal "\xAB".b * 4, result[8, 4]
239
+ assert_equal "\x00".b, result[7, 1], "byte before RLE unchanged"
240
+ assert_equal "\x00".b, result[12, 1], "byte after RLE unchanged"
241
+ end
242
+
243
+ def test_ips_multiple_records
244
+ source = blank_rom
245
+ patch = build_ips([
246
+ { offset: 0, data: "\x01\x02" },
247
+ { offset: 10, data: "\x03\x04" },
248
+ ])
249
+ result = Gemba::RomPatcher::IPS.apply(source, patch)
250
+ assert_equal "\x01\x02".b, result[0, 2]
251
+ assert_equal "\x03\x04".b, result[10, 2]
252
+ end
253
+
254
+ def test_ips_extends_rom_if_patch_exceeds_size
255
+ source = "\x00".b * 4
256
+ patch = build_ips([{ offset: 8, data: "\xFF\xFF" }])
257
+ result = Gemba::RomPatcher::IPS.apply(source, patch)
258
+ assert result.bytesize >= 10, "ROM extended to fit patch"
259
+ assert_equal "\xFF\xFF".b, result[8, 2]
260
+ end
261
+
262
+ def test_ips_empty_patch_returns_rom_unchanged
263
+ source = "HELLO".b
264
+ patch = build_ips([])
265
+ result = Gemba::RomPatcher::IPS.apply(source, patch)
266
+ assert_equal source, result
267
+ end
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # BPS
271
+ # ---------------------------------------------------------------------------
272
+
273
+ def test_bps_target_read_produces_correct_output
274
+ source = blank_rom(8)
275
+ target = "\x11\x22\x33\x44\x55\x66\x77\x88".b
276
+ patch = build_bps(source, target)
277
+ result = Gemba::RomPatcher::BPS.apply(source, patch)
278
+ assert_equal target, result
279
+ end
280
+
281
+ def test_bps_crc_mismatch_raises
282
+ source = blank_rom(8)
283
+ target = "\xDE\xAD\xBE\xEF\x00\x00\x00\x00".b
284
+ patch = build_bps(source, target)
285
+ # Corrupt the source CRC (bytes -12..-9)
286
+ bad_patch = patch.dup.b
287
+ bad_patch[-12] = "\xFF".b
288
+ err = assert_raises(RuntimeError) { Gemba::RomPatcher::BPS.apply(source, bad_patch) }
289
+ assert_match(/CRC32/, err.message)
290
+ end
291
+
292
+ def test_bps_identical_source_and_target
293
+ source = "GEMBA".b
294
+ target = "GEMBA".b
295
+ patch = build_bps(source, target)
296
+ result = Gemba::RomPatcher::BPS.apply(source, patch)
297
+ assert_equal target, result
298
+ end
299
+
300
+ # ---------------------------------------------------------------------------
301
+ # UPS
302
+ # ---------------------------------------------------------------------------
303
+
304
+ def test_ups_xors_differing_bytes
305
+ source = "\x00\x00\x00\x00".b
306
+ target = "\xFF\x00\xFF\x00".b
307
+ patch = build_ups(source, target)
308
+ result = Gemba::RomPatcher::UPS.apply(source, patch)
309
+ assert_equal target, result
310
+ end
311
+
312
+ def test_ups_multiple_hunks
313
+ source = "\x00" * 16
314
+ target = source.dup.b
315
+ target.setbyte(0, 0xAA)
316
+ target.setbyte(8, 0xBB)
317
+ target.setbyte(15, 0xCC)
318
+ patch = build_ups(source.b, target)
319
+ result = Gemba::RomPatcher::UPS.apply(source.b, patch)
320
+ assert_equal target, result
321
+ end
322
+
323
+ def test_ups_crc_mismatch_raises
324
+ source = blank_rom(8)
325
+ target = "\xCA\xFE\xBA\xBE\x00\x00\x00\x00".b
326
+ patch = build_ups(source, target)
327
+ bad_patch = patch.dup.b
328
+ bad_patch[-12] = "\x00".b
329
+ bad_patch[-11] = "\x00".b
330
+ err = assert_raises(RuntimeError) { Gemba::RomPatcher::UPS.apply(source, bad_patch) }
331
+ assert_match(/CRC32/, err.message)
332
+ end
333
+
334
+ def test_ups_identical_source_and_target
335
+ source = "GEMBA\x00\x00\x00".b
336
+ target = source.dup
337
+ patch = build_ups(source, target)
338
+ result = Gemba::RomPatcher::UPS.apply(source, patch)
339
+ assert_equal target, result
340
+ end
341
+
342
+ def test_ups_pads_target_when_source_is_shorter
343
+ # target_size > source_size — result zero-pads to target_size
344
+ source = "\x01\x02".b
345
+ target = "\x01\x03\x00\x00".b # byte 1 differs; bytes 2-3 are 0 (matching padding)
346
+ patch = build_ups(source, target)
347
+ result = Gemba::RomPatcher::UPS.apply(source, patch)
348
+ assert_equal target, result
349
+ end
350
+
351
+ # ---------------------------------------------------------------------------
352
+ # ZIP ROM input
353
+ # ---------------------------------------------------------------------------
354
+
355
+ def test_patch_with_zip_rom_produces_gba_output
356
+ require 'zip'
357
+ dir = Dir.mktmpdir
358
+ begin
359
+ # Build a tiny ROM and wrap it in a zip
360
+ rom_data = blank_rom
361
+ zip_path = File.join(dir, "game.zip")
362
+ patch_path = File.join(dir, "fix.ips")
363
+ out_path = File.join(dir, "game-patched.gba")
364
+
365
+ Zip::OutputStream.open(zip_path) do |zos|
366
+ zos.put_next_entry("game.gba")
367
+ zos.write(rom_data)
368
+ end
369
+
370
+ File.binwrite(patch_path, build_ips([{ offset: 0, data: "\xFF\xFE" }]))
371
+
372
+ resolved = Gemba::RomResolver.resolve(zip_path)
373
+ Gemba::RomPatcher.patch(rom_path: resolved, patch_path: patch_path, out_path: out_path)
374
+
375
+ assert File.exist?(out_path), "expected output at #{out_path}"
376
+ assert_equal ".gba", File.extname(out_path)
377
+ assert_equal "\xFF".b, File.binread(out_path, 1)
378
+ ensure
379
+ # Windows may still hold the zip file handle until GC — ignore EACCES on cleanup
380
+ FileUtils.remove_entry(dir) rescue nil
381
+ end
382
+ end
383
+ end
@@ -5,40 +5,39 @@ require "gemba"
5
5
  require "tmpdir"
6
6
  require "zip"
7
7
 
8
- class TestRomLoader < Minitest::Test
8
+ class TestRomResolver < Minitest::Test
9
9
  TEST_ROM = File.expand_path("fixtures/test.gba", __dir__)
10
10
 
11
11
  def setup
12
- skip "Run: ruby gemba/scripts/generate_test_rom.rb" unless File.exist?(TEST_ROM)
13
12
  @tmpdir = Dir.mktmpdir("rom_loader_test")
14
13
  end
15
14
 
16
15
  def teardown
17
16
  FileUtils.rm_rf(@tmpdir) if @tmpdir && File.directory?(@tmpdir)
18
- Gemba::RomLoader.cleanup_temp
17
+ Gemba::RomResolver.cleanup_temp
19
18
  end
20
19
 
21
20
  # -- resolve passthrough --
22
21
 
23
22
  def test_resolve_gba_returns_path_unchanged
24
- assert_equal TEST_ROM, Gemba::RomLoader.resolve(TEST_ROM)
23
+ assert_equal TEST_ROM, Gemba::RomResolver.resolve(TEST_ROM)
25
24
  end
26
25
 
27
26
  def test_resolve_gb_returns_path_unchanged
28
27
  path = "/some/game.gb"
29
- assert_equal path, Gemba::RomLoader.resolve(path)
28
+ assert_equal path, Gemba::RomResolver.resolve(path)
30
29
  end
31
30
 
32
31
  def test_resolve_gbc_returns_path_unchanged
33
32
  path = "/some/game.gbc"
34
- assert_equal path, Gemba::RomLoader.resolve(path)
33
+ assert_equal path, Gemba::RomResolver.resolve(path)
35
34
  end
36
35
 
37
36
  # -- resolve from zip --
38
37
 
39
38
  def test_resolve_zip_extracts_rom
40
39
  zip_path = create_zip("game.zip", "game.gba" => File.binread(TEST_ROM))
41
- result = Gemba::RomLoader.resolve(zip_path)
40
+ result = Gemba::RomResolver.resolve(zip_path)
42
41
 
43
42
  assert File.exist?(result), "extracted ROM should exist"
44
43
  assert_equal ".gba", File.extname(result).downcase
@@ -47,7 +46,7 @@ class TestRomLoader < Minitest::Test
47
46
 
48
47
  def test_resolve_zip_loads_in_core
49
48
  zip_path = create_zip("game.zip", "game.gba" => File.binread(TEST_ROM))
50
- rom_path = Gemba::RomLoader.resolve(zip_path)
49
+ rom_path = Gemba::RomResolver.resolve(zip_path)
51
50
 
52
51
  core = Gemba::Core.new(rom_path)
53
52
  assert_equal "GEMBATEST", core.title
@@ -60,8 +59,8 @@ class TestRomLoader < Minitest::Test
60
59
  def test_resolve_zip_no_rom_raises
61
60
  zip_path = create_zip("empty.zip", "readme.txt" => "hello")
62
61
 
63
- err = assert_raises(Gemba::RomLoader::NoRomInZip) do
64
- Gemba::RomLoader.resolve(zip_path)
62
+ err = assert_raises(Gemba::RomResolver::NoRomInZip) do
63
+ Gemba::RomResolver.resolve(zip_path)
65
64
  end
66
65
  assert_includes err.message, "empty.zip"
67
66
  end
@@ -72,15 +71,15 @@ class TestRomLoader < Minitest::Test
72
71
  "game1.gba" => rom_data,
73
72
  "game2.gba" => rom_data)
74
73
 
75
- err = assert_raises(Gemba::RomLoader::MultipleRomsInZip) do
76
- Gemba::RomLoader.resolve(zip_path)
74
+ err = assert_raises(Gemba::RomResolver::MultipleRomsInZip) do
75
+ Gemba::RomResolver.resolve(zip_path)
77
76
  end
78
77
  assert_includes err.message, "multi.zip"
79
78
  end
80
79
 
81
80
  def test_resolve_unsupported_extension_raises
82
- assert_raises(Gemba::RomLoader::UnsupportedFormat) do
83
- Gemba::RomLoader.resolve("/some/file.rar")
81
+ assert_raises(Gemba::RomResolver::UnsupportedFormat) do
82
+ Gemba::RomResolver.resolve("/some/file.rar")
84
83
  end
85
84
  end
86
85
 
@@ -88,8 +87,8 @@ class TestRomLoader < Minitest::Test
88
87
  corrupt = File.join(@tmpdir, "corrupt.zip")
89
88
  File.binwrite(corrupt, "this is not a zip file")
90
89
 
91
- assert_raises(Gemba::RomLoader::ZipReadError) do
92
- Gemba::RomLoader.resolve(corrupt)
90
+ assert_raises(Gemba::RomResolver::ZipReadError) do
91
+ Gemba::RomResolver.resolve(corrupt)
93
92
  end
94
93
  end
95
94
 
@@ -102,32 +101,32 @@ class TestRomLoader < Minitest::Test
102
101
  zos.write(File.binread(TEST_ROM))
103
102
  end
104
103
 
105
- assert_raises(Gemba::RomLoader::NoRomInZip) do
106
- Gemba::RomLoader.resolve(zip_path)
104
+ assert_raises(Gemba::RomResolver::NoRomInZip) do
105
+ Gemba::RomResolver.resolve(zip_path)
107
106
  end
108
107
  end
109
108
 
110
109
  # -- constants --
111
110
 
112
111
  def test_rom_extensions
113
- assert_includes Gemba::RomLoader::ROM_EXTENSIONS, ".gba"
114
- assert_includes Gemba::RomLoader::ROM_EXTENSIONS, ".gb"
115
- assert_includes Gemba::RomLoader::ROM_EXTENSIONS, ".gbc"
112
+ assert_includes Gemba::RomResolver::ROM_EXTENSIONS, ".gba"
113
+ assert_includes Gemba::RomResolver::ROM_EXTENSIONS, ".gb"
114
+ assert_includes Gemba::RomResolver::ROM_EXTENSIONS, ".gbc"
116
115
  end
117
116
 
118
117
  def test_supported_extensions_includes_zip
119
- assert_includes Gemba::RomLoader::SUPPORTED_EXTENSIONS, ".zip"
118
+ assert_includes Gemba::RomResolver::SUPPORTED_EXTENSIONS, ".zip"
120
119
  end
121
120
 
122
121
  # -- cleanup --
123
122
 
124
123
  def test_cleanup_temp_removes_directory
125
124
  zip_path = create_zip("game.zip", "game.gba" => File.binread(TEST_ROM))
126
- Gemba::RomLoader.resolve(zip_path)
127
- assert File.directory?(Gemba::RomLoader.tmp_dir)
125
+ Gemba::RomResolver.resolve(zip_path)
126
+ assert File.directory?(Gemba::RomResolver.tmp_dir)
128
127
 
129
- Gemba::RomLoader.cleanup_temp
130
- refute File.directory?(Gemba::RomLoader.tmp_dir)
128
+ Gemba::RomResolver.cleanup_temp
129
+ refute File.directory?(Gemba::RomResolver.tmp_dir)
131
130
  end
132
131
 
133
132
  private
@@ -3,9 +3,7 @@
3
3
  require "minitest/autorun"
4
4
  require "tmpdir"
5
5
  require "json"
6
- require_relative "../lib/gemba/config"
7
- require_relative "../lib/gemba/locale"
8
- require_relative "../lib/gemba/save_state_manager"
6
+ require "gemba/headless"
9
7
 
10
8
  class TestSaveStateManager < Minitest::Test
11
9
  # Recording mock for the mGBA Core.
@@ -92,7 +90,7 @@ class TestSaveStateManager < Minitest::Test
92
90
  end
93
91
 
94
92
  def new_manager(core: @core, config: @config, app: @app)
95
- mgr = Gemba::SaveStateManager.new(core: core, config: config, app: app)
93
+ mgr = Gemba::SaveStateManager.new(core: core, config: config, app: app, platform: Gemba::Platform.default)
96
94
  mgr.state_dir = mgr.state_dir_for_rom(core)
97
95
  mgr
98
96
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest/autorun"
4
+ require_relative "shared/tk_test_helper"
5
+
6
+ class TestSettingsAudioTab < Minitest::Test
7
+ include TeekTestHelper
8
+
9
+ # -- Volume slider ------------------------------------------------------
10
+
11
+ def test_volume_defaults_to_100
12
+ assert_tk_app("volume defaults to 100") do
13
+ require "gemba/headless"
14
+ sw = Gemba::SettingsWindow.new(app)
15
+ sw.show
16
+ app.update
17
+
18
+ assert_equal '100', app.get_variable(Gemba::Settings::AudioTab::VAR_VOLUME)
19
+ end
20
+ end
21
+
22
+ def test_dragging_volume_to_50_fires_callback
23
+ assert_tk_app("dragging volume to 50 fires on_volume_change") do
24
+ require "gemba/headless"
25
+ received = nil
26
+ Gemba.bus = Gemba::EventBus.new
27
+ Gemba.bus.on(:volume_changed) { |v| received = v }
28
+ sw = Gemba::SettingsWindow.new(app)
29
+ sw.show
30
+ app.update
31
+
32
+ # Simulate user dragging volume slider to 50
33
+ app.command(Gemba::Settings::AudioTab::VOLUME_SCALE, 'set', 50)
34
+ app.update
35
+
36
+ assert_in_delta 0.5, received, 0.01
37
+ end
38
+ end
39
+
40
+ def test_volume_at_zero
41
+ assert_tk_app("volume at zero") do
42
+ require "gemba/headless"
43
+ received = nil
44
+ Gemba.bus = Gemba::EventBus.new
45
+ Gemba.bus.on(:volume_changed) { |v| received = v }
46
+ sw = Gemba::SettingsWindow.new(app)
47
+ sw.show
48
+ app.update
49
+
50
+ app.command(Gemba::Settings::AudioTab::VOLUME_SCALE, 'set', 0)
51
+ app.update
52
+
53
+ assert_in_delta 0.0, received, 0.01
54
+ end
55
+ end
56
+
57
+ # -- Mute checkbox ------------------------------------------------------
58
+
59
+ def test_mute_defaults_to_off
60
+ assert_tk_app("mute defaults to off") do
61
+ require "gemba/headless"
62
+ sw = Gemba::SettingsWindow.new(app)
63
+ sw.show
64
+ app.update
65
+
66
+ assert_equal '0', app.get_variable(Gemba::Settings::AudioTab::VAR_MUTE)
67
+ end
68
+ end
69
+
70
+ def test_clicking_mute_fires_callback
71
+ assert_tk_app("clicking mute fires on_mute_change") do
72
+ require "gemba/headless"
73
+ received = nil
74
+ Gemba.bus = Gemba::EventBus.new
75
+ Gemba.bus.on(:mute_changed) { |m| received = m }
76
+ sw = Gemba::SettingsWindow.new(app)
77
+ sw.show
78
+ app.update
79
+
80
+ # Simulate user clicking the mute checkbox
81
+ app.command(Gemba::Settings::AudioTab::MUTE_CHECK, 'invoke')
82
+ app.update
83
+
84
+ assert_equal true, received
85
+ end
86
+ end
87
+
88
+ def test_clicking_mute_twice_unmutes
89
+ assert_tk_app("clicking mute twice unmutes") do
90
+ require "gemba/headless"
91
+ received = nil
92
+ Gemba.bus = Gemba::EventBus.new
93
+ Gemba.bus.on(:mute_changed) { |m| received = m }
94
+ sw = Gemba::SettingsWindow.new(app)
95
+ sw.show
96
+ app.update
97
+
98
+ app.command(Gemba::Settings::AudioTab::MUTE_CHECK, 'invoke')
99
+ app.update
100
+ assert_equal true, received
101
+
102
+ app.command(Gemba::Settings::AudioTab::MUTE_CHECK, 'invoke')
103
+ app.update
104
+ assert_equal false, received
105
+ end
106
+ end
107
+ end