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,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Floating window for applying IPS/BPS/UPS patches to ROM files.
5
+ #
6
+ # Non-modal — no grab. Shows three file pickers (ROM, patch, output dir)
7
+ # and runs the patch in a background thread so the UI stays responsive.
8
+ class PatcherWindow
9
+ include ChildWindow
10
+ include Locale::Translatable
11
+
12
+ # Worker class for Ractor-based patching.
13
+ # Defined as a class so Ractor.shareable_proc never sees nested closures —
14
+ # the lambda and rescue are created at runtime inside the Ractor.
15
+ class PatchWorker
16
+ def call(t, d)
17
+ RomPatcher.patch(
18
+ rom_path: d[:rom],
19
+ patch_path: d[:patch],
20
+ out_path: d[:out],
21
+ on_progress: ->(pct) { t.yield(pct) }
22
+ )
23
+ t.yield({ ok: true, path: d[:out] })
24
+ rescue => e
25
+ t.yield({ ok: false, error: e.message })
26
+ end
27
+ end
28
+
29
+ TOP = '.patcher_window'
30
+ BG_MODE = (RUBY_VERSION >= '4.0' ? :ractor : :thread).freeze
31
+
32
+ VAR_ROM = '::gemba_patcher_rom'
33
+ VAR_PATCH = '::gemba_patcher_patch'
34
+ VAR_OUTDIR = '::gemba_patcher_outdir'
35
+ VAR_STATUS = '::gemba_patcher_status'
36
+
37
+ def initialize(app:)
38
+ @app = app
39
+ @callbacks = {}
40
+ build_toplevel(translate('patcher.title'), geometry: '540x220') { build_ui }
41
+ end
42
+
43
+ def show = show_window(modal: false)
44
+ def hide = hide_window(modal: false)
45
+ def visible? = @app.tcl_eval("wm state #{TOP}") == 'normal'
46
+
47
+ private
48
+
49
+ def build_ui
50
+ f = "#{TOP}.f"
51
+ @app.command('ttk::frame', f, padding: 12)
52
+ @app.command(:pack, f, fill: :both, expand: 1)
53
+
54
+ @app.set_variable(VAR_ROM, '')
55
+ @app.set_variable(VAR_PATCH, '')
56
+ @app.set_variable(VAR_OUTDIR, Config.default_patches_dir)
57
+ @app.set_variable(VAR_STATUS, '')
58
+
59
+ build_file_row(f, 'rom', translate('patcher.rom_label'),
60
+ "{{GBA ROMs} {.gba .zip}} {{All Files} *}")
61
+ build_file_row(f, 'patch', translate('patcher.patch_label'),
62
+ "{{Patch Files} {.ips .bps .ups}} {{All Files} *}")
63
+ build_dir_row(f, 'outdir', translate('patcher.outdir_label'), VAR_OUTDIR)
64
+
65
+ btn_row = "#{f}.btn_row"
66
+ @app.command('ttk::frame', btn_row)
67
+ @app.command(:pack, btn_row, fill: :x, pady: [10, 0])
68
+
69
+ @apply_btn = "#{btn_row}.apply"
70
+ @app.command('ttk::button', @apply_btn,
71
+ text: translate('patcher.apply'),
72
+ command: proc { apply_patch })
73
+ @app.command(:pack, @apply_btn, side: :left)
74
+
75
+ @progress_bar = "#{btn_row}.pb"
76
+ @app.command('ttk::progressbar', @progress_bar,
77
+ orient: :horizontal, length: 200,
78
+ mode: :determinate, maximum: 100)
79
+ @app.command(:pack, @progress_bar, side: :left, padx: [8, 0])
80
+
81
+ if BG_MODE == :thread
82
+ @app.command('ttk::label', "#{btn_row}.ruby_warn",
83
+ text: translate('patcher.thread_mode_warn'),
84
+ foreground: 'gray')
85
+ @app.command(:pack, "#{btn_row}.ruby_warn", side: :left, padx: [10, 0])
86
+ end
87
+
88
+ @app.command('ttk::label', "#{f}.status",
89
+ textvariable: VAR_STATUS,
90
+ wraplength: 500)
91
+ @app.command(:pack, "#{f}.status", fill: :x, pady: [6, 0])
92
+ end
93
+
94
+ def build_file_row(parent, name, label_text, filetypes_tcl)
95
+ row = "#{parent}.#{name}_row"
96
+ var = case name
97
+ when 'rom' then VAR_ROM
98
+ when 'patch' then VAR_PATCH
99
+ end
100
+
101
+ @app.command('ttk::frame', row)
102
+ @app.command(:pack, row, fill: :x, pady: 2)
103
+
104
+ @app.command('ttk::label', "#{row}.lbl", text: label_text, width: 10, anchor: :w)
105
+ @app.command(:grid, "#{row}.lbl", row: 0, column: 0, sticky: :w)
106
+
107
+ @app.command('ttk::entry', "#{row}.ent", textvariable: var, width: 48)
108
+ @app.command(:grid, "#{row}.ent", row: 0, column: 1, sticky: :ew, padx: [4, 4])
109
+
110
+ @app.command('ttk::button', "#{row}.btn",
111
+ text: translate('patcher.browse'),
112
+ command: proc { browse_file(var, filetypes_tcl) })
113
+ @app.command(:grid, "#{row}.btn", row: 0, column: 2)
114
+ @app.command(:grid, :columnconfigure, row, 1, weight: 1)
115
+ end
116
+
117
+ def build_dir_row(parent, name, label_text, var)
118
+ row = "#{parent}.#{name}_row"
119
+ @app.command('ttk::frame', row)
120
+ @app.command(:pack, row, fill: :x, pady: 2)
121
+
122
+ @app.command('ttk::label', "#{row}.lbl", text: label_text, width: 10, anchor: :w)
123
+ @app.command(:grid, "#{row}.lbl", row: 0, column: 0, sticky: :w)
124
+
125
+ @app.command('ttk::entry', "#{row}.ent", textvariable: var, width: 48)
126
+ @app.command(:grid, "#{row}.ent", row: 0, column: 1, sticky: :ew, padx: [4, 4])
127
+
128
+ @app.command('ttk::button', "#{row}.btn",
129
+ text: translate('patcher.browse'),
130
+ command: proc { browse_dir(var) })
131
+ @app.command(:grid, "#{row}.btn", row: 0, column: 2)
132
+ @app.command(:grid, :columnconfigure, row, 1, weight: 1)
133
+ end
134
+
135
+ def browse_file(var, filetypes_tcl)
136
+ path = @app.tcl_eval("tk_getOpenFile -filetypes {#{filetypes_tcl}}")
137
+ @app.set_variable(var, path) unless path.to_s.strip.empty?
138
+ end
139
+
140
+ def browse_dir(var)
141
+ dir = @app.tcl_eval("tk_chooseDirectory")
142
+ @app.set_variable(var, dir) unless dir.to_s.strip.empty?
143
+ end
144
+
145
+ def apply_patch
146
+ rom = @app.get_variable(VAR_ROM).strip
147
+ patch = @app.get_variable(VAR_PATCH).strip
148
+ outdir = @app.get_variable(VAR_OUTDIR).strip
149
+
150
+ if rom.empty? || patch.empty? || outdir.empty?
151
+ set_status(translate('patcher.err_missing_fields'))
152
+ return
153
+ end
154
+
155
+ unless File.exist?(rom)
156
+ set_status(translate('patcher.err_rom_not_found'))
157
+ return
158
+ end
159
+
160
+ unless File.exist?(patch)
161
+ set_status(translate('patcher.err_patch_not_found'))
162
+ return
163
+ end
164
+
165
+ resolved_rom = begin
166
+ RomResolver.resolve(rom)
167
+ rescue => e
168
+ set_status("#{translate('patcher.err_failed')} #{e.message}")
169
+ return
170
+ end
171
+
172
+ rom_ext = File.extname(resolved_rom)
173
+ basename = File.basename(rom, '.*') + '-patched' + rom_ext
174
+ desired_out = File.join(outdir, basename)
175
+
176
+ out_path = if File.exist?(desired_out)
177
+ msg = translate('patcher.overwrite_msg').gsub('{path}', File.basename(desired_out))
178
+ answer = @app.command('tk_messageBox',
179
+ parent: TOP,
180
+ title: translate('patcher.overwrite_title'),
181
+ message: msg,
182
+ type: :yesnocancel,
183
+ icon: :question)
184
+ case answer
185
+ when 'yes' then desired_out
186
+ when 'no' then RomPatcher.safe_out_path(desired_out)
187
+ else return # cancel — abort silently
188
+ end
189
+ else
190
+ desired_out
191
+ end
192
+
193
+ set_status(translate('patcher.working'))
194
+ @app.command(@apply_btn, :configure, state: :disabled)
195
+ @app.command(@progress_bar, :configure, value: 0)
196
+
197
+ data = Ractor.make_shareable({ rom: resolved_rom.freeze, patch: patch.freeze, out: out_path.freeze })
198
+ Teek::BackgroundWork.drop_intermediate = false
199
+ Teek::BackgroundWork.new(@app, data, mode: BG_MODE, worker: PatchWorker).on_progress do |result|
200
+ case result
201
+ when Float
202
+ @app.command(@progress_bar, :configure, value: (result * 100).round)
203
+ @app.update
204
+ when Hash
205
+ @app.command(@apply_btn, :configure, state: :normal)
206
+ @app.command(@progress_bar, :configure, value: result[:ok] ? 100 : 0)
207
+ @app.update
208
+ if result[:ok]
209
+ set_status("#{translate('patcher.done')} #{File.basename(result[:path])}")
210
+ else
211
+ set_status("#{translate('patcher.err_failed')} #{result[:error]}")
212
+ end
213
+ end
214
+ end.on_done do
215
+ @app.command(@apply_btn, :configure, state: :normal)
216
+ end
217
+ end
218
+
219
+ def set_status(msg)
220
+ @app.set_variable(VAR_STATUS, msg)
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ module Platform
5
+ class GB
6
+ def width = 160
7
+ def height = 144
8
+ def fps = 59.7275
9
+ def fps_fraction = [4194304, 70224]
10
+ def aspect = [10, 9]
11
+ def name = "Game Boy"
12
+ def short_name = "GB"
13
+ def buttons = %i[a b start select up down left right]
14
+ def thumb_size = [80, 72]
15
+
16
+ def ==(other) = other.is_a?(Platform::GB)
17
+ def eql?(other) = self == other
18
+ def hash = self.class.hash
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ module Platform
5
+ class GBA
6
+ def width = 240
7
+ def height = 160
8
+ def fps = 59.7272
9
+ def fps_fraction = [262144, 4389]
10
+ def aspect = [3, 2]
11
+ def name = "Game Boy Advance"
12
+ def short_name = "GBA"
13
+ def buttons = %i[a b l r start select up down left right]
14
+ def thumb_size = [120, 80]
15
+
16
+ def ==(other) = other.is_a?(Platform::GBA)
17
+ def eql?(other) = self == other
18
+ def hash = self.class.hash
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ module Platform
5
+ # Same hardware specs as GB (resolution, FPS, buttons).
6
+ # Separate class for distinct name and future color-specific behavior.
7
+ class GBC
8
+ def width = 160
9
+ def height = 144
10
+ def fps = 59.7275
11
+ def fps_fraction = [4194304, 70224]
12
+ def aspect = [10, 9]
13
+ def name = "Game Boy Color"
14
+ def short_name = "GBC"
15
+ def buttons = %i[a b start select up down left right]
16
+ def thumb_size = [80, 72]
17
+
18
+ def ==(other) = other.is_a?(Platform::GBC)
19
+ def eql?(other) = self == other
20
+ def hash = self.class.hash
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+
4
+ module Gemba
5
+ module Platform
6
+ # Build a Platform from a loaded Core.
7
+ # @param core [Gemba::Core] initialized core with ROM loaded
8
+ # @return [GBA, GB, GBC]
9
+ def self.for(core)
10
+ case core.platform
11
+ when "GBA" then GBA.new
12
+ when "GBC" then GBC.new
13
+ else GB.new
14
+ end
15
+ end
16
+
17
+ # Default platform before any ROM is loaded (most common case).
18
+ def self.default = GBA.new
19
+ end
20
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Gemba
6
+ # Open a directory in the platform's file manager.
7
+ # @param dir [String] directory path (created if missing)
8
+ def self.open_directory(dir)
9
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
10
+ p = Teek.platform
11
+ if p.darwin?
12
+ system('open', dir)
13
+ elsif p.windows?
14
+ system('explorer.exe', dir.tr('/', '\\'))
15
+ else
16
+ system('xdg-open', dir)
17
+ end
18
+ end
19
+ end
@@ -30,13 +30,14 @@ module Gemba
30
30
  # @param audio_rate [Integer] audio sample rate (default 44100)
31
31
  # @param audio_channels [Integer] audio channels (default 2)
32
32
  # @param compression [Integer] zlib compression level 1-9 (default 1 = fastest)
33
- def initialize(path, width:, height:, audio_rate: 44100, audio_channels: 2,
34
- compression: Zlib::BEST_SPEED)
33
+ def initialize(path, width:, height:, fps_fraction:, audio_rate: 44100,
34
+ audio_channels: 2, compression: Zlib::BEST_SPEED)
35
35
  @path = path
36
36
  @width = width
37
37
  @height = height
38
38
  @audio_rate = audio_rate
39
39
  @audio_channels = audio_channels
40
+ @fps_fraction = fps_fraction
40
41
  @compression = compression
41
42
  @frame_size = width * height * 4
42
43
  @recording = false
@@ -127,7 +128,7 @@ module Gemba
127
128
  h << MAGIC # 8 bytes
128
129
  h << [VERSION].pack('C') # 1 byte
129
130
  h << [@width, @height].pack('v2') # 4 bytes
130
- h << [262_144, 4389].pack('V2') # 8 bytes (fps = 262144/4389 ≈ 59.7272)
131
+ h << @fps_fraction.pack('V2') # 8 bytes (fps as numerator/denominator)
131
132
  h << [@audio_rate].pack('V') # 4 bytes
132
133
  h << [@audio_channels, 16].pack('C2') # 2 bytes
133
134
  h << ("\0" * 5) # 5 bytes reserved