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,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Gemba
6
+ # Applies IPS, BPS, or UPS patch files to GBA ROM files.
7
+ #
8
+ # Format support:
9
+ # IPS — simplest; no checksums; RLE support
10
+ # BPS — Beat Patch System; delta encoding with CRC32 verification
11
+ # UPS — Universal Patching System; XOR hunks with CRC32 verification
12
+ #
13
+ # Usage:
14
+ # RomPatcher.patch(rom_path: "game.gba", patch_path: "fix.ips", out_path: "patched.gba")
15
+ # # or invoke a format class directly:
16
+ # RomPatcher::IPS.apply(rom_bytes, patch_bytes) # => patched_bytes
17
+ #
18
+ class RomPatcher
19
+ CHUNK = 256 * 1024 # 256 KB
20
+
21
+ # Auto-detect format, apply patch, write output file.
22
+ #
23
+ # Progress budget:
24
+ # 0–15% read ROM
25
+ # 15–25% read patch
26
+ # 25–90% format apply (IPS/BPS/UPS)
27
+ # 90–100% write output
28
+ #
29
+ # @param rom_path [String] source ROM (read-only)
30
+ # @param patch_path [String] patch file (.ips / .bps / .ups)
31
+ # @param out_path [String] where to write the result
32
+ # @param on_progress [Proc, nil] called with a Float (0.0..1.0)
33
+ # @return [String] out_path
34
+ # @raise [RuntimeError] on unknown format or checksum failure
35
+ def self.patch(rom_path:, patch_path:, out_path:, on_progress: nil)
36
+ rom = read_chunked(rom_path, 0.0, 0.15, on_progress)
37
+ patch = read_chunked(patch_path, 0.15, 0.25, on_progress)
38
+
39
+ klass = case detect_format(patch)
40
+ when :ips then IPS
41
+ when :bps then BPS
42
+ when :ups then UPS
43
+ else raise "Unknown patch format (expected IPS/BPS/UPS magic)"
44
+ end
45
+
46
+ apply_cb = on_progress && ->(pct) { on_progress.call(0.25 + pct * 0.65) }
47
+ result = klass.apply(rom, patch, on_progress: apply_cb)
48
+ on_progress&.call(0.90)
49
+
50
+ FileUtils.mkdir_p(File.dirname(out_path))
51
+ write_chunked(out_path, result, 0.90, 1.0, on_progress)
52
+ on_progress&.call(1.0)
53
+ out_path
54
+ end
55
+
56
+ # @return [:ips, :bps, :ups, nil]
57
+ def self.detect_format(patch_data)
58
+ return :ips if patch_data.start_with?("PATCH")
59
+ return :bps if patch_data.start_with?("BPS1")
60
+ return :ups if patch_data.start_with?("UPS1")
61
+ nil
62
+ end
63
+
64
+ # Return a path that does not collide with existing files.
65
+ # If +path+ exists, appends -(2), -(3), ... before the extension.
66
+ def self.safe_out_path(path)
67
+ return path unless File.exist?(path)
68
+ ext = File.extname(path)
69
+ base = path.chomp(ext)
70
+ n = 2
71
+ loop do
72
+ candidate = "#{base}-(#{n})#{ext}"
73
+ return candidate unless File.exist?(candidate)
74
+ n += 1
75
+ end
76
+ end
77
+
78
+ # Read a file in chunks, reporting progress from +pct_start+ to +pct_end+.
79
+ def self.read_chunked(path, pct_start, pct_end, on_progress)
80
+ size = File.size(path).to_f
81
+ buf = String.new(encoding: 'BINARY')
82
+ read = 0
83
+ File.open(path, 'rb') do |f|
84
+ while (chunk = f.read(CHUNK))
85
+ buf << chunk
86
+ read += chunk.bytesize
87
+ on_progress&.call(pct_start + (read / size) * (pct_end - pct_start))
88
+ end
89
+ end
90
+ buf
91
+ end
92
+ private_class_method :read_chunked
93
+
94
+ # Write a string to a file in chunks, reporting progress from +pct_start+ to +pct_end+.
95
+ def self.write_chunked(path, data, pct_start, pct_end, on_progress)
96
+ size = data.bytesize.to_f
97
+ written = 0
98
+ File.open(path, 'wb') do |f|
99
+ while written < data.bytesize
100
+ n = [CHUNK, data.bytesize - written].min
101
+ f.write(data.byteslice(written, n))
102
+ written += n
103
+ on_progress&.call(pct_start + (written / size) * (pct_end - pct_start))
104
+ end
105
+ end
106
+ end
107
+ private_class_method :write_chunked
108
+ end
109
+ end
@@ -6,14 +6,14 @@ module Gemba
6
6
  # Resolves ROM paths for the player. Handles both bare ROM files
7
7
  # and .zip archives containing a single ROM at the zip root.
8
8
  #
9
- # @example Load a bare ROM
10
- # path = RomLoader.resolve("/path/to/game.gba")
9
+ # @example Resolve a bare ROM
10
+ # path = RomResolver.resolve("/path/to/game.gba")
11
11
  # # => "/path/to/game.gba"
12
12
  #
13
- # @example Load from a zip
14
- # path = RomLoader.resolve("/path/to/game.zip")
13
+ # @example Resolve from a zip
14
+ # path = RomResolver.resolve("/path/to/game.zip")
15
15
  # # => "/Users/you/.config/gemba/tmp/game.gba"
16
- class RomLoader
16
+ class RomResolver
17
17
  ROM_EXTENSIONS = %w[.gba .gb .gbc].freeze
18
18
  ZIP_EXTENSIONS = %w[.zip].freeze
19
19
  SUPPORTED_EXTENSIONS = (ROM_EXTENSIONS + ZIP_EXTENSIONS).freeze
@@ -85,7 +85,7 @@ module Gemba
85
85
  dir = tmp_dir
86
86
  FileUtils.mkdir_p(dir)
87
87
  out_path = File.join(dir, File.basename(rom_entry.name))
88
- File.binwrite(out_path, rom_entry.get_input_stream.read)
88
+ rom_entry.get_input_stream { |s| File.binwrite(out_path, s.read) }
89
89
  out_path
90
90
  end
91
91
  rescue NoRomInZip, MultipleRomsInZip
@@ -97,4 +97,5 @@ module Gemba
97
97
  end
98
98
  private_class_method :extract_from_zip
99
99
  end
100
+
100
101
  end
data/lib/gemba/runtime.rb CHANGED
@@ -1,39 +1,72 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Shared runtime for gemba loads the C extension, config, locale,
4
- # core, and ROM loader. Both the full GUI and headless entry points
5
- # require this.
3
+ # Shared bootstrap explicitly required by lib/gemba.rb (full GUI) and
4
+ # lib/gemba/headless.rb (no Tk/SDL2). Sets up Zeitwerk autoloading,
5
+ # loads the C extension, and initializes the locale.
6
6
 
7
+ require "zeitwerk"
7
8
  require "teek/platform"
8
9
  require "gemba_ext"
9
- require_relative "version"
10
- require_relative "config"
11
- require_relative "locale"
12
- require_relative "core"
13
- require_relative "rom_loader"
14
10
 
11
+ # Define the Gemba module before loader.setup so Zeitwerk can register
12
+ # autoloads directly on it (e.g. Gemba.autoload(:ChildWindow, ...)).
13
+ # Without this, Gemba doesn't exist yet and Zeitwerk proxies through
14
+ # lib/gemba.rb — which is never loaded in the headless path.
15
15
  module Gemba
16
16
  ASSETS_DIR = File.expand_path('../../assets', __dir__).freeze
17
17
 
18
- # Lazily loaded user config — shared across the application.
19
- # @return [Gemba::Config]
20
- def self.user_config
21
- @user_config ||= Config.new
22
- end
18
+ class << self
19
+ # Lazily loaded user config — shared across the application.
20
+ # @return [Gemba::Config]
21
+ def user_config
22
+ @user_config ||= Config.new
23
+ end
23
24
 
24
- # Override the user config (useful for tests).
25
- # @param config [Gemba::Config, nil] pass nil to reset to default
26
- def self.user_config=(config)
27
- @user_config = config
28
- end
25
+ # Override the user config (useful for tests).
26
+ # @param config [Gemba::Config, nil] pass nil to reset to default
27
+ attr_writer :user_config
29
28
 
30
- # Load translations based on the config locale setting.
31
- def self.load_locale
32
- lang = user_config.locale
33
- lang = nil if lang == 'auto'
34
- Locale.load(lang)
35
- end
29
+ # Load translations based on the config locale setting.
30
+ def load_locale
31
+ lang = user_config.locale
32
+ lang = nil if lang == 'auto'
33
+ Locale.load(lang)
34
+ end
35
+
36
+ # Event bus — auto-created on first access.
37
+ # AppController replaces it with a fresh bus at startup.
38
+ def bus
39
+ @bus ||= EventBus.new
40
+ end
41
+
42
+ attr_writer :bus
36
43
 
37
- # Initialize locale on require
38
- load_locale
44
+ # Session logger — lazily initialized on first write.
45
+ def logger
46
+ @logger ||= SessionLogger.new
47
+ end
48
+
49
+ attr_writer :logger
50
+
51
+ # Log a message at the given level.
52
+ # @example Gemba.log(:warn) { "something went wrong" }
53
+ def log(level = :info, &block)
54
+ logger.log(level, &block)
55
+ end
56
+ end
39
57
  end
58
+
59
+ loader = Zeitwerk::Loader.new
60
+ loader.push_dir(File.expand_path("../..", __FILE__)) # lib/ as root
61
+ loader.inflector.inflect(
62
+ "gba" => "GBA", "gb" => "GB", "gbc" => "GBC", "cli" => "CLI",
63
+ "ips" => "IPS", "bps" => "BPS", "ups" => "UPS"
64
+ )
65
+ loader.ignore(__FILE__) # bootstrap file — not a constant
66
+ loader.ignore(File.expand_path("../../gemba.rb", __FILE__)) # entry point, not a constant
67
+ loader.ignore(File.expand_path("../platform_open.rb", __FILE__)) # module method, not a constant
68
+ loader.ignore(File.expand_path("../version.rb", __FILE__)) # defines VERSION (all-caps), not Zeitwerk-compatible
69
+ loader.setup
70
+
71
+ # Initialize locale on require
72
+ Gemba.load_locale
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
- require_relative 'locale'
5
4
 
6
5
  module Gemba
7
6
  # Manages save state persistence: save, load, screenshot capture,
@@ -22,13 +21,11 @@ module Gemba
22
21
  class SaveStateManager
23
22
  include Locale::Translatable
24
23
 
25
- GBA_W = 240
26
- GBA_H = 160
27
-
28
- def initialize(core:, config:, app:)
24
+ def initialize(core:, config:, app:, platform:)
29
25
  @core = core
30
26
  @config = config
31
27
  @app = app
28
+ @platform = platform
32
29
  @last_save_time = 0
33
30
  @state_dir = nil
34
31
  @quick_save_slot = config.quick_save_slot
@@ -143,8 +140,8 @@ module Gemba
143
140
  photo_name = "__gemba_ss_#{object_id}"
144
141
 
145
142
  @app.command(:image, :create, :photo, photo_name,
146
- width: GBA_W, height: GBA_H)
147
- @app.interp.photo_put_block(photo_name, pixels, GBA_W, GBA_H, format: :argb)
143
+ width: @platform.width, height: @platform.height)
144
+ @app.interp.photo_put_block(photo_name, pixels, @platform.width, @platform.height, format: :argb)
148
145
  @app.command(photo_name, :write, path, format: :png)
149
146
  @app.command(:image, :delete, photo_name)
150
147
  rescue StandardError => e
@@ -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
  # Grid picker window for save state slots.
@@ -17,6 +15,7 @@ module Gemba
17
15
  class SaveStatePicker
18
16
  include ChildWindow
19
17
  include Locale::Translatable
18
+ include BusEmitter
20
19
 
21
20
  TOP = ".mgba_state_picker"
22
21
 
@@ -53,6 +52,20 @@ module Gemba
53
52
  cleanup_photos
54
53
  end
55
54
 
55
+ # ModalStack protocol
56
+ def show_modal(state_dir: nil, quick_slot: 1, **_)
57
+ @state_dir = state_dir
58
+ @quick_slot = quick_slot
59
+ build_ui unless @built
60
+ refresh
61
+ super()
62
+ end
63
+
64
+ def withdraw
65
+ super
66
+ cleanup_photos
67
+ end
68
+
56
69
  private
57
70
 
58
71
  def build_ui
@@ -189,9 +202,9 @@ module Gemba
189
202
  def on_slot_click(slot)
190
203
  ss_path = File.join(@state_dir, "state#{slot}.ss")
191
204
  if File.exist?(ss_path)
192
- @callbacks[:on_load]&.call(slot)
205
+ emit(:state_load_requested, slot)
193
206
  else
194
- @callbacks[:on_save]&.call(slot)
207
+ emit(:state_save_requested, slot)
195
208
  end
196
209
  hide
197
210
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module Gemba
6
+ # Session logger that writes to the user config logs/ directory.
7
+ # File and directory are created lazily on first write.
8
+ class SessionLogger
9
+ MAX_LOG_FILES = 25
10
+ LEVELS = { debug: Logger::DEBUG, info: Logger::INFO,
11
+ warn: Logger::WARN, error: Logger::ERROR }.freeze
12
+
13
+ # @param dir [String] log directory (default: Config.default_logs_dir)
14
+ # @param level [Symbol] minimum log level (:debug, :info, :warn, :error)
15
+ def initialize(dir: nil, level: :info)
16
+ @dir = dir
17
+ @level = LEVELS.fetch(level, Logger::INFO)
18
+ @logger = nil
19
+ prune
20
+ end
21
+
22
+ # Log a message at the given level. Uses block form to avoid
23
+ # allocating the message string when the level is filtered.
24
+ # @param level [Symbol] :debug, :info, :warn, :error
25
+ def log(level, &block)
26
+ severity = LEVELS.fetch(level, Logger::INFO)
27
+ return if severity < @level
28
+
29
+ ensure_logger
30
+ @logger.add(severity, nil, 'gemba', &block)
31
+ end
32
+
33
+ # @return [String] resolved log directory
34
+ def log_dir
35
+ @dir ||= Config.default_logs_dir
36
+ end
37
+
38
+ private
39
+
40
+ def ensure_logger
41
+ return if @logger
42
+
43
+ FileUtils.mkdir_p(log_dir)
44
+ path = File.join(log_dir, "gemba-#{Time.now.strftime('%Y-%m-%d')}.log")
45
+ @logger = Logger.new(path)
46
+ @logger.level = @level
47
+ @logger.formatter = proc { |sev, time, _prog, msg|
48
+ "#{time.strftime('%H:%M:%S.%L')} [#{sev}] #{msg}\n"
49
+ }
50
+ end
51
+
52
+ def prune
53
+ dir = log_dir
54
+ return unless File.directory?(dir)
55
+
56
+ logs = Dir.glob(File.join(dir, 'gemba-*.log')).sort
57
+ excess = logs.length - MAX_LOG_FILES
58
+ return unless excess > 0
59
+
60
+ logs.first(excess).each { |f| File.delete(f) }
61
+ end
62
+ end
63
+
64
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Gemba
5
+ module Settings
6
+ class AudioTab
7
+ include Locale::Translatable
8
+ include BusEmitter
9
+
10
+ FRAME = "#{Paths::NB}.audio"
11
+ VOLUME_SCALE = "#{FRAME}.vol_row.vol_scale"
12
+ MUTE_CHECK = "#{FRAME}.mute_row.mute"
13
+
14
+ VAR_VOLUME = '::mgba_volume'
15
+ VAR_MUTE = '::mgba_mute'
16
+
17
+ def initialize(app, tips:, mark_dirty:)
18
+ @app = app
19
+ @mark_dirty = mark_dirty
20
+ end
21
+
22
+ def load_from_config(config)
23
+ @app.set_variable(VAR_VOLUME, config.volume.to_s)
24
+ @app.command(@vol_val_label, 'configure', text: "#{config.volume}%")
25
+ @app.set_variable(VAR_MUTE, config.muted? ? '1' : '0')
26
+ end
27
+
28
+ def build
29
+ @app.command('ttk::frame', FRAME)
30
+ @app.command(Paths::NB, 'add', FRAME, text: translate('settings.audio'))
31
+
32
+ # Volume slider
33
+ vol_row = "#{FRAME}.vol_row"
34
+ @app.command('ttk::frame', vol_row)
35
+ @app.command(:pack, vol_row, fill: :x, padx: 10, pady: [15, 5])
36
+
37
+ @app.command('ttk::label', "#{vol_row}.lbl", text: translate('settings.volume'))
38
+ @app.command(:pack, "#{vol_row}.lbl", side: :left)
39
+
40
+ @vol_val_label = "#{vol_row}.vol_label"
41
+ @app.command('ttk::label', @vol_val_label, text: '100%', width: 5)
42
+ @app.command(:pack, @vol_val_label, side: :right)
43
+
44
+ @app.set_variable(VAR_VOLUME, '100')
45
+ @app.command('ttk::scale', VOLUME_SCALE,
46
+ orient: :horizontal,
47
+ from: 0,
48
+ to: 100,
49
+ length: 150,
50
+ variable: VAR_VOLUME,
51
+ command: proc { |v, *|
52
+ pct = v.to_f.round
53
+ @app.command(@vol_val_label, 'configure', text: "#{pct}%")
54
+ emit(:volume_changed, pct / 100.0)
55
+ @mark_dirty.call
56
+ })
57
+ @app.command(:pack, VOLUME_SCALE, side: :right, padx: [5, 5])
58
+
59
+ # Mute checkbox
60
+ mute_row = "#{FRAME}.mute_row"
61
+ @app.command('ttk::frame', mute_row)
62
+ @app.command(:pack, mute_row, fill: :x, padx: 10, pady: 5)
63
+
64
+ @app.set_variable(VAR_MUTE, '0')
65
+ @app.command('ttk::checkbutton', MUTE_CHECK,
66
+ text: translate('settings.mute'),
67
+ variable: VAR_MUTE,
68
+ command: proc { |*|
69
+ muted = @app.get_variable(VAR_MUTE) == '1'
70
+ emit(:mute_changed, muted)
71
+ @mark_dirty.call
72
+ })
73
+ @app.command(:pack, MUTE_CHECK, side: :left)
74
+ end
75
+ end
76
+ end
77
+ end