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,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
@@ -5,8 +5,12 @@ menu:
5
5
  quit: "Quit"
6
6
  settings: "Settings"
7
7
  view: "View"
8
+ game_library: "Game Library"
8
9
  fullscreen: "Fullscreen"
9
10
  rom_info: "ROM Info…"
11
+ achievements: "Achievements…"
12
+ open_logs_dir: "Open Logs Directory"
13
+ patch_rom: "Patch ROM…"
10
14
  emulation: "Emulation"
11
15
  pause: "Pause"
12
16
  resume: "Resume"
@@ -14,8 +18,10 @@ menu:
14
18
  quick_save: "Quick Save"
15
19
  quick_load: "Quick Load"
16
20
  save_states: "Save States…"
17
- start_recording: "Start Recording"
18
- stop_recording: "Stop Recording"
21
+ start_recording: "Start Capture"
22
+ stop_recording: "Stop Capture"
23
+ start_input_recording: "Start Recording Inputs"
24
+ stop_input_recording: "Stop Recording Inputs"
19
25
 
20
26
  toast:
21
27
  save_blocked: "Save blocked (too fast)"
@@ -32,12 +38,18 @@ toast:
32
38
  no_rewind: "No rewind data"
33
39
  paused: "Paused"
34
40
  waiting_for: "Waiting for {label}…"
35
- recording_started: "Recording started"
36
- recording_stopped: "Recorded {frames} frames"
41
+ recording_started: "Capture started"
42
+ recording_stopped: "Captured {frames} frames"
43
+ input_recording_started: "Recording inputs"
44
+ input_recording_stopped: "Inputs recorded ({frames} frames)"
37
45
 
38
46
  dialog:
39
47
  game_running_title: "Game Running"
40
48
  game_running_msg: "Another game is running. Switch to {name}?"
49
+ return_to_library_title: "Return to Game Library"
50
+ return_to_library_msg: "Return to the Game Library? Unsaved progress will be lost."
51
+ quit_title: "Quit Gemba"
52
+ quit_msg: "A game is running. Quit anyway? Unsaved progress will be lost."
41
53
  drop_error_title: "Drop Error"
42
54
  drop_single_file_only: "Please drop a single ROM file."
43
55
  drop_unsupported_type: "Unsupported file type: {ext}"
@@ -51,6 +63,8 @@ dialog:
51
63
  reset_hotkeys_title: "Reset Hotkeys"
52
64
  reset_hotkeys_msg: "Reset all hotkey bindings to defaults?"
53
65
  key_conflict_title: "Key Conflict"
66
+ cancel_bulk_sync_title: "Cancel Sync?"
67
+ cancel_bulk_sync_msg: "A bulk sync is in progress. Closing will cancel it — games will need to be re-synced manually."
54
68
 
55
69
  settings:
56
70
  video: "Video"
@@ -91,7 +105,9 @@ settings:
91
105
  hk_save_states: "Save States"
92
106
  hk_screenshot: "Screenshot"
93
107
  hk_rewind: "Rewind"
94
- hk_record: "Record"
108
+ hk_record: "Capture"
109
+ hk_input_record: "Record Inputs"
110
+ hk_open_rom: "Open ROM"
95
111
  hk_reset_defaults: "Reset to Defaults"
96
112
  pixel_filter: "Pixel Filter:"
97
113
  filter_nearest: "Nearest Neighbor"
@@ -111,11 +127,39 @@ settings:
111
127
  tip_per_game: "Save separate video, audio, and save state settings for each ROM."
112
128
  tip_turbo_speed: "Fast-forward speed when holding the turbo hotkey."
113
129
  tip_toast_duration: "How long on-screen notifications stay visible."
130
+ system: "System"
131
+ bios_header: "GBA BIOS"
132
+ bios_path: "BIOS file:"
133
+ bios_browse: "Browse…"
134
+ bios_clear: "Clear"
135
+ bios_not_set: "Not set — using built-in HLE (recommended for most games)"
136
+ bios_not_found: "File not found"
137
+ skip_bios: "Skip boot animation"
138
+ tip_skip_bios: "Jump straight to the game, skipping the Game Boy Advance logo screen.\nOnly applies when a real BIOS file is loaded."
139
+ retroachievements: "Achievements"
140
+ ra_enabled: "Enable RetroAchievements"
141
+ ra_credentials: "Account"
142
+ ra_username_placeholder: "Username:"
143
+ ra_token_placeholder: "Password:"
144
+ ra_rich_presence: "Rich Presence (per-game)"
145
+ ra_hardcore: "Hardcore mode (disables save states and rewind)"
146
+ tip_ra_password: "Your password is only used to fetch an API token and is never saved."
147
+ ra_login: "Login"
148
+ ra_verify: "Verify Token"
149
+ ra_logout: "Logout"
150
+ ra_reset: "Reset"
151
+ ra_reset_title: "Reset RetroAchievements"
152
+ ra_reset_confirm: "Clear saved credentials and log out?"
153
+ ra_test_ok: "Connection OK ✓"
154
+ ra_disabled: "RetroAchievements is disabled"
155
+ ra_not_logged_in: "Not logged in"
156
+ ra_logged_in_as: "Logged in as {username}"
114
157
  recording: "Recording"
115
158
  recording_compression: "Compression:"
116
159
  tip_recording_compression: "Zlib level for .grec files.\n1 = fastest (default), 6+ has diminishing returns."
117
160
  recordings_folder: "Recordings folder:"
118
161
  open_recordings_folder: "Open Recordings Folder…"
162
+ open_replay_player: "Open Input Replay…"
119
163
  gp_a: "A"
120
164
  gp_b: "B"
121
165
  gp_l: "L"
@@ -134,6 +178,13 @@ picker:
134
178
  slot: "Slot {n}"
135
179
  close: "Close"
136
180
 
181
+ game_picker:
182
+ menu:
183
+ play: "Play"
184
+ quick_load: "Quick Load"
185
+ set_boxart: "Set Boxart"
186
+ remove: "Remove from Library"
187
+
137
188
  rom_info:
138
189
  title: "ROM Info"
139
190
  field_title: "Title:"
@@ -148,6 +199,29 @@ rom_info:
148
199
  close: "Close"
149
200
  na: "N/A"
150
201
 
202
+ replay:
203
+ open_recording: "Open Input Recording…"
204
+ replay_player: "Input Replay"
205
+ ended: "Replay complete ({frames} frames)"
206
+ empty_hint: "Open Input Recording (Cmd+O)"
207
+
208
+ patcher:
209
+ title: "Patch ROM"
210
+ rom_label: "ROM file:"
211
+ patch_label: "Patch file:"
212
+ outdir_label: "Output dir:"
213
+ browse: "Browse…"
214
+ apply: "Apply Patch"
215
+ working: "Applying patch…"
216
+ done: "Done →"
217
+ err_missing_fields: "Please fill in all fields."
218
+ err_rom_not_found: "ROM file not found."
219
+ err_patch_not_found: "Patch file not found."
220
+ err_failed: "Patch failed:"
221
+ overwrite_title: "File Exists"
222
+ overwrite_msg: "{path} already exists. Overwrite it?"
223
+ thread_mode_warn: "Note: Progress may appear stuck on Ruby < 4"
224
+
151
225
  player:
152
226
  open_rom_hint: "File > Open ROM…"
153
227
  fps: "{fps} fps"
@@ -155,3 +229,21 @@ player:
155
229
  ff_max: ">> MAX"
156
230
  none: "(none)"
157
231
  clear: "Clear"
232
+
233
+ achievements:
234
+ title: "Achievements"
235
+ game_label: "Game:"
236
+ sync: "Sync"
237
+ name_col: "Achievement"
238
+ points_col: "Points"
239
+ earned_col: "Earned"
240
+ none: "No achievements loaded"
241
+ earned_label: "{earned} / {total} earned"
242
+ sync_pending: "Syncing…"
243
+ sync_failed: "Sync failed"
244
+ sync_no_game: "No game loaded"
245
+ sync_timeout: "Sync timed out"
246
+ not_logged_in: "Not logged in"
247
+ include_unofficial: "Include Unofficial"
248
+ bulk_syncing: "Syncing {title} ({n}/{total})…"
249
+ bulk_sync_done: "Synced {count} games"