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,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Floating hotkey reference panel toggled by pressing '?'.
5
+ #
6
+ # Non-modal — no grab, no focus steal. Positioned to the right of the
7
+ # main window via ChildWindow#position_near_parent. AppController pauses
8
+ # emulation while the panel is visible and restores play on close.
9
+ class HelpWindow
10
+ include ChildWindow
11
+ include Locale::Translatable
12
+
13
+ TOP = '.help_window'
14
+
15
+ def initialize(app:, hotkeys:)
16
+ @app = app
17
+ @hotkeys = hotkeys
18
+ build_toplevel(translate('settings.hotkeys'), geometry: '220x400') { build_ui }
19
+ end
20
+
21
+ def show = show_window(modal: false)
22
+ def hide = hide_window(modal: false)
23
+ def visible? = @app.tcl_eval("wm state #{TOP}") == 'normal'
24
+
25
+ private
26
+
27
+ def build_ui
28
+ f = "#{TOP}.f"
29
+ @app.command('ttk::frame', f, padding: 8)
30
+ @app.command(:pack, f, fill: :both, expand: 1)
31
+
32
+ @app.command('ttk::label', "#{f}.title",
33
+ text: translate('settings.hotkeys'),
34
+ font: '{TkDefaultFont} 11 bold')
35
+ @app.command(:pack, "#{f}.title", pady: [0, 4])
36
+
37
+ @app.command('ttk::separator', "#{f}.sep", orient: :horizontal)
38
+ @app.command(:pack, "#{f}.sep", fill: :x, pady: [0, 6])
39
+
40
+ Settings::HotkeysTab::LOCALE_KEYS.each do |action, locale_key|
41
+ row = "#{f}.row_#{action}"
42
+ @app.command('ttk::frame', row)
43
+ @app.command(:pack, row, fill: :x, pady: 1)
44
+
45
+ act_lbl = "#{row}.act"
46
+ key_lbl = "#{row}.key"
47
+
48
+ key_text = HotkeyMap.display_name(@hotkeys.key_for(action))
49
+
50
+ @app.command('ttk::label', act_lbl, text: translate(locale_key), anchor: :w)
51
+ @app.command('ttk::label', key_lbl, text: key_text, anchor: :e,
52
+ font: '{TkFixedFont} 9')
53
+
54
+ @app.command(:grid, act_lbl, row: 0, column: 0, sticky: :w)
55
+ @app.command(:grid, key_lbl, row: 0, column: 1, sticky: :e)
56
+ @app.command(:grid, :columnconfigure, row, 0, weight: 1)
57
+ @app.command(:grid, :columnconfigure, row, 1, weight: 0)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -11,7 +11,7 @@ module Gemba
11
11
  class HotkeyMap
12
12
  ACTIONS = %i[quit pause fast_forward fullscreen show_fps
13
13
  quick_save quick_load save_states screenshot rewind
14
- record].freeze
14
+ record input_record open_rom].freeze
15
15
 
16
16
  DEFAULTS = {
17
17
  quit: 'q', pause: 'p', fast_forward: 'Tab',
@@ -20,6 +20,8 @@ module Gemba
20
20
  save_states: 'F6', screenshot: 'F9',
21
21
  rewind: ['Shift', 'Tab'],
22
22
  record: 'F10',
23
+ input_record: 'F4',
24
+ open_rom: ['Control', 'o'],
23
25
  }.freeze
24
26
 
25
27
  # Tk keysyms that are modifier keys → normalized name
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Records per-frame input bitmasks to a .gir (Gemba Input Recording) file.
5
+ #
6
+ # Each GBA frame's pressed-button bitmask is stored as a 3-char hex line.
7
+ # An anchor save state is written alongside so replays start from the
8
+ # exact same emulator state.
9
+ #
10
+ # The header's frame_count is a best-effort hint (correct on clean stop,
11
+ # zero on crash). The replayer counts lines for the authoritative count.
12
+ #
13
+ # @example
14
+ # recorder = InputRecorder.new("session.gir", core: core)
15
+ # recorder.start
16
+ # loop do
17
+ # mask = poll_input
18
+ # recorder.capture(mask)
19
+ # core.set_keys(mask)
20
+ # core.run_frame
21
+ # end
22
+ # recorder.stop
23
+ class InputRecorder
24
+ VERSION = 1
25
+ FLUSH_INTERVAL = 60 # frames between flushes (~1s at 59.7 fps)
26
+
27
+ # @param path [String] output .gir file path
28
+ # @param core [Gemba::Core] mGBA core (for ROM metadata and save state)
29
+ # @param rom_path [String, nil] path to the ROM file (stored in header for easy replay)
30
+ def initialize(path, core:, rom_path: nil)
31
+ @path = path
32
+ @core = core
33
+ @rom_path = rom_path
34
+ @recording = false
35
+ @frame_count = 0
36
+ end
37
+
38
+ # Start recording. Saves an anchor save state and opens the .gir file.
39
+ def start
40
+ raise "Already recording" if @recording
41
+
42
+ @core.save_state_to_file(anchor_state_path)
43
+ @frame_count = 0
44
+ @file = File.open(@path, 'w')
45
+ write_header
46
+ @file.flush
47
+ @recording = true
48
+ end
49
+
50
+ # Capture one frame's input bitmask.
51
+ # @param bitmask [Integer] bitwise OR of KEY_* constants (0x000–0x3FF)
52
+ def capture(bitmask)
53
+ return unless @recording
54
+
55
+ @file.puts(format('%03x', bitmask & 0x3FF))
56
+ @frame_count += 1
57
+ @file.flush if (@frame_count % FLUSH_INTERVAL).zero?
58
+ end
59
+
60
+ # Stop recording and close the file.
61
+ def stop
62
+ return unless @recording
63
+
64
+ @recording = false
65
+ rewrite_frame_count
66
+ @file.close
67
+ @file = nil
68
+ end
69
+
70
+ # @return [Boolean] true if currently recording
71
+ def recording?
72
+ @recording
73
+ end
74
+
75
+ # @return [Integer] number of frames captured so far
76
+ attr_reader :frame_count
77
+
78
+ # @return [String] path to the anchor save state file
79
+ def anchor_state_path
80
+ @path.sub(/\.gir\z/, '.state')
81
+ end
82
+
83
+ private
84
+
85
+ FRAME_COUNT_WIDTH = 10 # zero-padded digits (covers ~5.3 years at 60fps)
86
+
87
+ def write_header
88
+ @file.puts "# GEMBA INPUT RECORDING v#{VERSION}"
89
+ @file.puts "# rom_checksum: #{@core.checksum}"
90
+ @file.puts "# game_code: #{@core.game_code}"
91
+ @file.puts "# rom_path: #{@rom_path}" if @rom_path
92
+ @file.write "# frame_count: "
93
+ @frame_count_offset = @file.pos
94
+ @file.puts format("%0#{FRAME_COUNT_WIDTH}d", 0)
95
+ @file.puts "# anchor_state: #{File.basename(anchor_state_path)}"
96
+ @file.puts "---"
97
+ end
98
+
99
+ # Best-effort: seek to the frame_count field and overwrite in place.
100
+ def rewrite_frame_count
101
+ @file.flush
102
+ @file.seek(@frame_count_offset)
103
+ @file.write(format("%0#{FRAME_COUNT_WIDTH}d", @frame_count))
104
+ @file.seek(0, IO::SEEK_END)
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Replays a .gir (Gemba Input Recording) file by feeding recorded
5
+ # per-frame bitmasks back to the emulator core.
6
+ #
7
+ # The authoritative frame count comes from counting bitmask lines,
8
+ # not the header (which is best-effort and may be zero on crash).
9
+ #
10
+ # @example
11
+ # replayer = InputReplayer.new("session.gir")
12
+ # replayer.validate!(core)
13
+ # core.load_state_from_file(replayer.anchor_state_path)
14
+ # replayer.each_bitmask do |mask, frame|
15
+ # core.set_keys(mask)
16
+ # core.run_frame
17
+ # end
18
+ class InputReplayer
19
+ class ChecksumMismatch < StandardError; end
20
+
21
+ # @param gir_path [String] path to .gir file
22
+ def initialize(gir_path)
23
+ @path = gir_path
24
+ @header = {}
25
+ @bitmasks = []
26
+ parse!
27
+ end
28
+
29
+ # @return [Integer] ROM checksum from the recording header
30
+ def rom_checksum
31
+ @header[:rom_checksum]
32
+ end
33
+
34
+ # @return [String] game code from the recording header
35
+ def game_code
36
+ @header[:game_code]
37
+ end
38
+
39
+ # @return [String, nil] ROM path from the recording header
40
+ def rom_path
41
+ @header[:rom_path]
42
+ end
43
+
44
+ # @return [Integer] number of recorded frames (counted from bitmask lines)
45
+ def frame_count
46
+ @bitmasks.length
47
+ end
48
+
49
+ # @return [String] path to the anchor save state file
50
+ def anchor_state_path
51
+ dir = File.dirname(@path)
52
+ File.join(dir, @header[:anchor_state])
53
+ end
54
+
55
+ # Validate that the recording matches the loaded ROM.
56
+ # @param core [Gemba::Core] mGBA core to validate against
57
+ # @raise [ChecksumMismatch] if ROM checksum doesn't match
58
+ def validate!(core)
59
+ if rom_checksum && core.checksum != rom_checksum
60
+ raise ChecksumMismatch,
61
+ "ROM checksum mismatch: recording has #{rom_checksum}, " \
62
+ "loaded ROM has #{core.checksum}"
63
+ end
64
+ end
65
+
66
+ # @param frame [Integer] zero-based frame index
67
+ # @return [Integer] bitmask for the given frame
68
+ def bitmask_at(frame)
69
+ @bitmasks[frame]
70
+ end
71
+
72
+ # Iterate over all recorded bitmasks.
73
+ # @yield [Integer, Integer] bitmask and zero-based frame index
74
+ def each_bitmask(&block)
75
+ @bitmasks.each_with_index(&block)
76
+ end
77
+
78
+ private
79
+
80
+ def parse!
81
+ in_header = true
82
+
83
+ File.foreach(@path) do |line|
84
+ line = line.strip
85
+
86
+ if in_header
87
+ if line == '---'
88
+ in_header = false
89
+ elsif line.start_with?('# ')
90
+ parse_header_line(line)
91
+ end
92
+ else
93
+ next if line.empty?
94
+ @bitmasks << line.to_i(16)
95
+ end
96
+ end
97
+ end
98
+
99
+ def parse_header_line(line)
100
+ # Format: "# key: value"
101
+ content = line.sub(/^# /, '')
102
+ key, _, value = content.partition(': ')
103
+ return if value.empty?
104
+
105
+ case key
106
+ when 'rom_checksum'
107
+ @header[:rom_checksum] = value.to_i
108
+ when 'game_code'
109
+ @header[:game_code] = value
110
+ when 'anchor_state'
111
+ @header[:anchor_state] = value
112
+ when 'rom_path'
113
+ @header[:rom_path] = value
114
+ when 'frame_count'
115
+ @header[:header_frame_count] = value.to_i
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Manages keyboard keysym → GBA bitmask mappings.
5
+ #
6
+ # Shares the same interface as {GamepadMap} so that Player can
7
+ # delegate to either without knowing which device type is active.
8
+ class KeyboardMap
9
+ DEFAULT_MAP = {
10
+ 'z' => KEY_A,
11
+ 'x' => KEY_B,
12
+ 'BackSpace' => KEY_SELECT,
13
+ 'Return' => KEY_START,
14
+ 'Right' => KEY_RIGHT,
15
+ 'Left' => KEY_LEFT,
16
+ 'Up' => KEY_UP,
17
+ 'Down' => KEY_DOWN,
18
+ 'a' => KEY_L,
19
+ 's' => KEY_R,
20
+ }.freeze
21
+
22
+ def initialize(config)
23
+ @config = config
24
+ @map = DEFAULT_MAP.dup
25
+ @device = nil
26
+ load_config
27
+ end
28
+
29
+ attr_writer :device
30
+
31
+ def mask
32
+ return 0 unless @device
33
+ m = 0
34
+ @map.each { |key, bit| m |= bit if @device.button?(key) }
35
+ m
36
+ end
37
+
38
+ def set(gba_btn, input_key)
39
+ bit = GBA_BTN_BITS[gba_btn] or return
40
+ @map.delete_if { |_, v| v == bit }
41
+ @map[input_key.to_s] = bit
42
+ end
43
+
44
+ def reset!
45
+ @map = DEFAULT_MAP.dup
46
+ end
47
+
48
+ def load_config
49
+ cfg = @config.mappings(Config::KEYBOARD_GUID)
50
+ if cfg.empty?
51
+ @map = DEFAULT_MAP.dup
52
+ else
53
+ @map = {}
54
+ cfg.each do |gba_str, keysym|
55
+ bit = GBA_BTN_BITS[gba_str.to_sym]
56
+ next unless bit
57
+ @map[keysym] = bit
58
+ end
59
+ end
60
+ end
61
+
62
+ def reload!
63
+ @config.reload!
64
+ load_config
65
+ end
66
+
67
+ def labels
68
+ result = {}
69
+ @map.each do |input, bit|
70
+ gba_btn = GBA_BTN_BITS.key(bit)
71
+ result[gba_btn] = input if gba_btn
72
+ end
73
+ result
74
+ end
75
+
76
+ def save_to_config
77
+ @map.each do |input, bit|
78
+ gba_btn = GBA_BTN_BITS.key(bit)
79
+ @config.set_mapping(Config::KEYBOARD_GUID, gba_btn, input) if gba_btn
80
+ end
81
+ end
82
+
83
+ def supports_deadzone? = false
84
+ def dead_zone_pct = 0
85
+
86
+ def set_dead_zone(_)
87
+ raise NotImplementedError, "keyboard does not support dead zones"
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Startup frame showing all library ROMs as a sortable treeview list.
5
+ #
6
+ # Alternative to GamePickerFrame (no boxart). Columns: Title, Last Played.
7
+ # Clicking a column header sorts; the active column shows a ▲/▼ indicator.
8
+ # Double-clicking a row emits :rom_selected. Right-clicking shows a context
9
+ # menu identical to the GamePickerFrame card menu.
10
+ # Pure Tk — no SDL2.
11
+ class ListPickerFrame
12
+ include BusEmitter
13
+ include Locale::Translatable
14
+
15
+ LIST_DEFAULT_W = 480
16
+ LIST_DEFAULT_H = 600
17
+ LIST_MIN_W = 320
18
+ LIST_MIN_H = 400
19
+
20
+ SORT_ASC = ' ▲'
21
+ SORT_DESC = ' ▼'
22
+
23
+ def default_geometry = [LIST_DEFAULT_W, LIST_DEFAULT_H]
24
+ def min_geometry = [LIST_MIN_W, LIST_MIN_H]
25
+
26
+ def initialize(app:, rom_library:, rom_overrides: nil)
27
+ @app = app
28
+ @rom_library = rom_library
29
+ @overrides = rom_overrides
30
+ @built = false
31
+ @sort_col = 'last_played'
32
+ @sort_asc = false # most-recent first by default
33
+ @row_data = {} # treeview item id => RomInfo
34
+ end
35
+
36
+ def show
37
+ build_ui unless @built
38
+ refresh
39
+ @app.command(:pack, @outer, fill: :both, expand: 1)
40
+ end
41
+
42
+ def hide
43
+ @app.command(:pack, :forget, @outer) rescue nil
44
+ end
45
+
46
+ def cleanup; end
47
+
48
+ def receive(event, **_args)
49
+ case event
50
+ when :refresh then refresh
51
+ end
52
+ end
53
+
54
+ def aspect_ratio = nil
55
+ def rom_loaded? = false
56
+ def sdl2_ready? = false
57
+ def paused? = false
58
+
59
+ private
60
+
61
+ def build_ui
62
+ @outer = '.list_picker'
63
+ @app.command('ttk::frame', @outer, padding: 8)
64
+
65
+ # Treeview + scrollbar
66
+ @tree = "#{@outer}.tree"
67
+ @scrollbar = "#{@outer}.scroll"
68
+
69
+ @app.command('ttk::treeview', @tree,
70
+ columns: Teek.make_list('title', 'last_played'),
71
+ show: :headings,
72
+ selectmode: :browse)
73
+
74
+ @app.command('ttk::scrollbar', @scrollbar, orient: :vertical,
75
+ command: "#{@tree} yview")
76
+ @app.command(@tree, :configure, yscrollcommand: "#{@scrollbar} set")
77
+
78
+ build_columns
79
+ bind_events
80
+
81
+ @app.command(:grid, @tree, row: 0, column: 0, sticky: :nsew)
82
+ @app.command(:grid, @scrollbar, row: 0, column: 1, sticky: :ns)
83
+ @app.command(:grid, :columnconfigure, @outer, 0, weight: 1)
84
+ @app.command(:grid, :rowconfigure, @outer, 0, weight: 1)
85
+
86
+ build_toolbar
87
+
88
+ @built = true
89
+ end
90
+
91
+ def build_columns
92
+ @app.command(@tree, :heading, 'title',
93
+ text: translate('list_picker.columns.title') + (@sort_col == 'title' ? sort_indicator : ''),
94
+ anchor: :w,
95
+ command: proc { sort_by('title') })
96
+ @app.command(@tree, :heading, 'last_played',
97
+ text: translate('list_picker.columns.last_played') + (@sort_col == 'last_played' ? sort_indicator : ''),
98
+ anchor: :w,
99
+ command: proc { sort_by('last_played') })
100
+ @app.command(@tree, :column, 'title', width: 280, stretch: 1)
101
+ @app.command(@tree, :column, 'last_played', width: 120, stretch: 0)
102
+ end
103
+
104
+ def bind_events
105
+ # Physical double-click fires virtual event so tests can trigger it
106
+ # directly without needing event generate <Double-Button-1> (forbidden in Tk 9).
107
+ @app.command(:bind, @tree, '<Double-Button-1>', proc {
108
+ @app.tcl_eval("event generate #{@tree} <<DoubleClick>>")
109
+ })
110
+ @app.command(:bind, @tree, '<<DoubleClick>>', proc {
111
+ iid = @app.tcl_eval("#{@tree} focus")
112
+ next if iid.to_s.empty?
113
+ rom_info = @row_data[iid]
114
+ emit(:rom_selected, rom_info.path) if rom_info
115
+ })
116
+
117
+ # Physical right-click: use %x/%y (widget-relative event coords) to
118
+ # identify the row, select and focus it, then fire the virtual event so
119
+ # tests can trigger the same code path without real pointer coordinates.
120
+ @app.tcl_eval(<<~TCL)
121
+ bind #{@tree} <Button-3> {+
122
+ set _iid [#{@tree} identify row %x %y]
123
+ if {$_iid ne {}} {
124
+ #{@tree} selection set $_iid
125
+ #{@tree} focus $_iid
126
+ event generate #{@tree} <<RightClick>>
127
+ }
128
+ }
129
+ TCL
130
+
131
+ # Virtual event reads the currently focused item. Decoupled from pointer
132
+ # position so tests can trigger it directly after setting focus.
133
+ @app.command(:bind, @tree, '<<RightClick>>', proc {
134
+ iid = @app.tcl_eval("#{@tree} focus")
135
+ rom_info = @row_data[iid.to_s]
136
+ post_row_menu(rom_info) if rom_info
137
+ })
138
+ end
139
+
140
+ def build_toolbar
141
+ sep = "#{@outer}.sep"
142
+ @app.command('ttk::separator', sep, orient: :horizontal)
143
+ @app.command(:grid, sep, row: 1, column: 0, columnspan: 2, sticky: :ew, pady: [4, 0])
144
+
145
+ toolbar = "#{@outer}.toolbar"
146
+ @app.command('ttk::frame', toolbar, padding: [4, 2])
147
+ @app.command(:grid, toolbar, row: 2, column: 0, columnspan: 2, sticky: :ew)
148
+
149
+ gear_btn = "#{toolbar}.gear"
150
+ gear_menu = "#{toolbar}.gearmenu"
151
+ @app.command('ttk::button', gear_btn, text: "\u2699", width: 1,
152
+ command: proc { post_view_menu(gear_menu, gear_btn) })
153
+ @app.command(:pack, gear_btn, side: :right)
154
+ end
155
+
156
+ def refresh
157
+ @app.tcl_eval("#{@tree} delete [#{@tree} children {}]")
158
+ @row_data.clear
159
+
160
+ roms = sorted(@rom_library.all)
161
+ roms.each do |rom|
162
+ rom_info = RomInfo.from_rom(rom, overrides: @overrides)
163
+ lp = format_last_played(rom['last_played'])
164
+ iid = @app.tcl_eval(
165
+ "#{@tree} insert {} end -values [list #{Teek.make_list(rom_info.title, lp)}]"
166
+ )
167
+ @row_data[iid] = rom_info
168
+ end
169
+ end
170
+
171
+ def sorted(roms)
172
+ sorted = roms.sort_by do |r|
173
+ case @sort_col
174
+ when 'title' then r['title'].to_s.downcase
175
+ when 'last_played' then r['last_played'] || r['added_at'] || ''
176
+ end
177
+ end
178
+ @sort_asc ? sorted : sorted.reverse
179
+ end
180
+
181
+ def sort_by(col)
182
+ if @sort_col == col
183
+ @sort_asc = !@sort_asc
184
+ else
185
+ @sort_col = col
186
+ @sort_asc = (col == 'title') # title: asc first; date: newest first
187
+ end
188
+ update_headings
189
+ refresh
190
+ end
191
+
192
+ def update_headings
193
+ ['title', 'last_played'].each do |col|
194
+ label_key = col == 'title' ? 'list_picker.columns.title' : 'list_picker.columns.last_played'
195
+ indicator = @sort_col == col ? sort_indicator : ''
196
+ @app.command(@tree, :heading, col, text: translate(label_key) + indicator)
197
+ end
198
+ end
199
+
200
+ def sort_indicator
201
+ @sort_asc ? SORT_ASC : SORT_DESC
202
+ end
203
+
204
+ def format_last_played(iso)
205
+ return translate('list_picker.never_played') if iso.to_s.empty?
206
+ require 'time'
207
+ Time.parse(iso).localtime.strftime('%b %-d, %Y')
208
+ rescue
209
+ iso.to_s
210
+ end
211
+
212
+ def post_view_menu(menu, btn)
213
+ @app.command(:menu, menu, tearoff: 0) unless @app.tcl_eval("winfo exists #{menu}") == '1'
214
+ @app.command(menu, :delete, 0, :end)
215
+ current = Gemba.user_config.picker_view
216
+ @app.command(menu, :add, :command,
217
+ label: "#{current == 'grid' ? "\u2713 " : ' '}#{translate('picker.toolbar.boxart_view')}",
218
+ command: proc { emit(:picker_view_changed, view: 'grid') })
219
+ @app.command(menu, :add, :command,
220
+ label: "#{current == 'list' ? "\u2713 " : ' '}#{translate('picker.toolbar.list_view')}",
221
+ command: proc { emit(:picker_view_changed, view: 'list') })
222
+ x = @app.tcl_eval("winfo rootx #{btn}").to_i
223
+ y = @app.tcl_eval("winfo rooty #{btn}").to_i
224
+ h = @app.tcl_eval("winfo height #{btn}").to_i
225
+ @app.tcl_eval("tk_popup #{menu} #{x} #{y + h}")
226
+ end
227
+
228
+ def post_row_menu(rom_info)
229
+ menu = "#{@tree}.ctx"
230
+ @app.command(:menu, menu, tearoff: 0) unless @app.tcl_eval("winfo exists #{menu}") == '1'
231
+ @app.command(menu, :delete, 0, :end)
232
+ @app.command(menu, :add, :command,
233
+ label: translate('game_picker.menu.play'),
234
+ command: proc { emit(:rom_selected, rom_info.path) })
235
+ qs_slot = Gemba.user_config.quick_save_slot
236
+ qs_state = quick_save_exists?(rom_info, qs_slot)
237
+ @app.command(menu, :add, :command,
238
+ label: translate('game_picker.menu.quick_load'),
239
+ state: qs_state ? :normal : :disabled,
240
+ command: proc { emit(:rom_quick_load, path: rom_info.path, slot: qs_slot) })
241
+ @app.command(menu, :add, :command,
242
+ label: translate('game_picker.menu.set_boxart'),
243
+ command: proc { pick_custom_boxart(rom_info) })
244
+ @app.command(menu, :add, :separator)
245
+ @app.command(menu, :add, :command,
246
+ label: translate('game_picker.menu.remove'),
247
+ command: proc { remove_rom(rom_info) })
248
+ @app.tcl_eval("tk_popup #{menu} [winfo pointerx .] [winfo pointery .]")
249
+ end
250
+
251
+ def quick_save_exists?(rom_info, slot)
252
+ return false unless rom_info.rom_id
253
+ state_file = File.join(Gemba.user_config.states_dir, rom_info.rom_id, "state#{slot}.ss")
254
+ File.exist?(state_file)
255
+ end
256
+
257
+ def remove_rom(rom_info)
258
+ @rom_library.remove(rom_info.rom_id)
259
+ @rom_library.save!
260
+ refresh
261
+ end
262
+
263
+ def pick_custom_boxart(rom_info)
264
+ return unless @overrides
265
+ filetypes = '{{PNG Images} {.png}}'
266
+ path = @app.tcl_eval("tk_getOpenFile -filetypes {#{filetypes}}")
267
+ return if path.to_s.strip.empty?
268
+ @overrides.set_custom_boxart(rom_info.rom_id, path)
269
+ end
270
+ end
271
+ end