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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Gemba
6
+ class CLI
7
+ module Commands
8
+ class Record
9
+ def initialize(argv, dry_run: false)
10
+ @argv = argv
11
+ @dry_run = dry_run
12
+ end
13
+
14
+ def call
15
+ options = parse
16
+
17
+ if options[:help]
18
+ puts options[:parser] unless @dry_run
19
+ return { command: :record, help: true }
20
+ end
21
+
22
+ unless options[:frames] && options[:rom]
23
+ $stderr.puts "Error: record requires --frames N and a ROM file"
24
+ $stderr.puts "Run 'gemba record --help' for usage"
25
+ exit 1
26
+ end
27
+
28
+ result = {
29
+ command: :record,
30
+ rom: options[:rom],
31
+ frames: options[:frames],
32
+ output: options[:output],
33
+ compression: options[:compression],
34
+ progress: options[:progress],
35
+ options: options.except(:parser)
36
+ }
37
+ return result if @dry_run
38
+
39
+ require "gemba/headless"
40
+
41
+ total = options[:frames]
42
+
43
+ HeadlessPlayer.open(options[:rom]) do |player|
44
+ rec_path = options[:output] ||
45
+ "#{Config.rom_id(player.game_code, player.checksum)}.grec"
46
+
47
+ rec_opts = {}
48
+ rec_opts[:compression] = options[:compression] if options[:compression]
49
+ player.start_recording(rec_path, **rec_opts)
50
+
51
+ if options[:progress]
52
+ last_print = Process.clock_gettime(Process::CLOCK_MONOTONIC)
53
+ player.step(total) do |frame|
54
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
55
+ if frame == total || now - last_print >= 0.5
56
+ pct = frame * 100.0 / total
57
+ $stderr.print "\rRecording: #{frame}/#{total} (#{'%.1f' % pct}%)\e[K"
58
+ last_print = now
59
+ end
60
+ end
61
+ $stderr.print "\r\e[K"
62
+ else
63
+ player.step(total)
64
+ end
65
+
66
+ player.stop_recording
67
+
68
+ info = RecorderDecoder.stats(rec_path)
69
+ puts "Recorded #{info[:frame_count]} frames to #{rec_path}"
70
+ puts " Duration: #{'%.1f' % info[:duration]}s"
71
+ puts " Avg change: #{'%.1f' % info[:avg_change_pct]}%/frame"
72
+ puts " Uncompressed: #{format_size(info[:raw_video_size])} (encode input)"
73
+ puts " .grec size: #{format_size(File.size(rec_path))}"
74
+ end
75
+ end
76
+
77
+ def parse
78
+ options = {}
79
+ argv = @argv.dup
80
+
81
+ parser = OptionParser.new do |o|
82
+ o.banner = "Usage: gemba record [options] ROM_FILE"
83
+ o.separator ""
84
+ o.separator "Record video+audio to a .grec file (headless, no GUI)."
85
+ o.separator ""
86
+
87
+ o.on("--frames N", Integer, "Number of frames to record (required)") { |v| options[:frames] = v }
88
+ o.on("-o", "--output PATH", "Output .grec path (default: ROM_ID.grec)") { |v| options[:output] = v }
89
+ o.on("-c", "--compression N", Integer, "Zlib level 1-9 (default: 1)") { |v| options[:compression] = v.clamp(1, 9) }
90
+ o.on("--progress", "Show recording progress") { options[:progress] = true }
91
+ o.on("-h", "--help", "Show this help") { options[:help] = true }
92
+ end
93
+
94
+ parser.parse!(argv)
95
+ options[:rom] = File.expand_path(argv.first) if argv.first
96
+ options[:parser] = parser
97
+ options
98
+ end
99
+
100
+ private
101
+
102
+ def format_size(bytes)
103
+ if bytes >= 1_073_741_824
104
+ "#{'%.1f' % (bytes / 1_073_741_824.0)} GB"
105
+ elsif bytes >= 1_048_576
106
+ "#{'%.1f' % (bytes / 1_048_576.0)} MB"
107
+ else
108
+ "#{'%.1f' % (bytes / 1024.0)} KB"
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Gemba
6
+ class CLI
7
+ module Commands
8
+ class Replay
9
+ def initialize(argv, dry_run: false)
10
+ @argv = argv
11
+ @dry_run = dry_run
12
+ end
13
+
14
+ def call
15
+ options = parse
16
+
17
+ if options[:help]
18
+ puts options[:parser] unless @dry_run
19
+ return { command: :replay, help: true }
20
+ end
21
+
22
+ if options[:list]
23
+ list_recordings unless @dry_run
24
+ return { command: :replay_list }
25
+ end
26
+
27
+ unless options[:gir]
28
+ $stderr.puts "Error: replay requires a .gir file"
29
+ $stderr.puts "Run 'gemba replay --help' for usage"
30
+ exit 1
31
+ end
32
+
33
+ gir_path = File.expand_path(options[:gir])
34
+
35
+ result = {
36
+ command: options[:headless] ? :replay_headless : :replay,
37
+ gir: gir_path,
38
+ rom: options[:rom],
39
+ sound: options.fetch(:sound, true),
40
+ fullscreen: options[:fullscreen],
41
+ headless: options[:headless],
42
+ progress: options[:progress],
43
+ options: options.except(:parser)
44
+ }
45
+ return result if @dry_run
46
+
47
+ if options[:headless]
48
+ run_headless(gir_path, options)
49
+ else
50
+ run_gui(gir_path, options)
51
+ end
52
+ end
53
+
54
+ def parse
55
+ options = {}
56
+ argv = @argv.dup
57
+
58
+ parser = OptionParser.new do |o|
59
+ o.banner = "Usage: gemba replay [options] GIR_FILE [ROM_FILE]"
60
+ o.separator ""
61
+ o.separator "Replay a .gir input recording."
62
+ o.separator "ROM is read from the .gir header; override with ROM_FILE."
63
+ o.separator ""
64
+
65
+ o.on("-l", "--list", "List available .gir recordings") { options[:list] = true }
66
+ o.on("--headless", "Run without GUI (print summary and exit)") { options[:headless] = true }
67
+ o.on("--progress", "Show progress (headless only)") { options[:progress] = true }
68
+ o.on("-f", "--fullscreen", "Start in fullscreen") { options[:fullscreen] = true }
69
+ o.on("--no-sound", "Disable audio") { options[:sound] = false }
70
+ o.on("-h", "--help", "Show this help") { options[:help] = true }
71
+ end
72
+
73
+ parser.parse!(argv)
74
+ options[:gir] = argv.shift
75
+ options[:rom] = argv.shift
76
+ options[:parser] = parser
77
+ options
78
+ end
79
+
80
+ private
81
+
82
+ def run_headless(gir_path, options)
83
+ require "gemba/headless"
84
+
85
+ rom_path = options[:rom]
86
+ unless rom_path
87
+ replayer = Gemba::InputReplayer.new(gir_path)
88
+ rom_path = replayer.rom_path
89
+ unless rom_path
90
+ $stderr.puts "Error: .gir has no rom_path in header; pass ROM_FILE explicitly"
91
+ exit 1
92
+ end
93
+ end
94
+ rom_path = File.expand_path(rom_path)
95
+
96
+ Gemba::HeadlessPlayer.open(rom_path) do |player|
97
+ if options[:progress]
98
+ replayer = Gemba::InputReplayer.new(gir_path)
99
+ total = replayer.frame_count
100
+ last_print = Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
+
102
+ player.replay(gir_path) do |_mask, idx|
103
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
104
+ frame = idx + 1
105
+ if frame == total || now - last_print >= 0.5
106
+ pct = frame * 100.0 / total
107
+ $stderr.print "\rReplaying: #{frame}/#{total} (#{'%.1f' % pct}%)\e[K"
108
+ last_print = now
109
+ end
110
+ end
111
+ $stderr.print "\r\e[K"
112
+ else
113
+ player.replay(gir_path)
114
+ end
115
+
116
+ puts "Replayed #{gir_path} (#{Gemba::InputReplayer.new(gir_path).frame_count} frames)"
117
+ end
118
+ end
119
+
120
+ def run_gui(gir_path, options)
121
+ require "gemba"
122
+
123
+ sound = options.fetch(:sound, true)
124
+ ReplayPlayer.new(gir_path,
125
+ sound: sound,
126
+ fullscreen: options[:fullscreen]).run
127
+ end
128
+
129
+ def list_recordings
130
+ require "gemba/headless"
131
+
132
+ dir = Config.default_recordings_dir
133
+ unless File.directory?(dir)
134
+ puts "No recordings directory found at #{dir}"
135
+ return
136
+ end
137
+
138
+ gir_files = Dir.glob(File.join(dir, '*.gir')).sort
139
+ if gir_files.empty?
140
+ puts "No .gir recordings in #{dir}"
141
+ return
142
+ end
143
+
144
+ by_rom = {}
145
+ gir_files.each do |path|
146
+ replayer = InputReplayer.new(path)
147
+ key = replayer.game_code || "unknown"
148
+ (by_rom[key] ||= []) << { path: path, frames: replayer.frame_count }
149
+ end
150
+
151
+ by_rom.each do |game_code, entries|
152
+ puts "#{game_code}:"
153
+ entries.each do |entry|
154
+ puts " #{entry[:path]} (#{entry[:frames]} frames)"
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module Gemba
6
+ class CLI
7
+ module Commands
8
+ class RetroAchievements
9
+ RA_SUBCOMMANDS = %w[login verify logout achievements].freeze
10
+
11
+ def initialize(argv, dry_run: false, config: nil, requester: nil)
12
+ @argv = argv
13
+ @dry_run = dry_run
14
+ @config = config
15
+ @requester = requester
16
+ end
17
+
18
+ def call
19
+ options = parse
20
+
21
+ if options[:help] || options[:subcommand].nil?
22
+ puts options[:parser] unless @dry_run
23
+ return { command: :ra, help: true, subcommand: options[:subcommand] }
24
+ end
25
+
26
+ result = { command: :"ra_#{options[:subcommand]}", **options.except(:parser) }
27
+ return result if @dry_run
28
+
29
+ require "gemba"
30
+ require "gemba/achievements/retro_achievements/cli_sync_requester"
31
+
32
+ config = @config || Gemba.user_config
33
+ requester = @requester || Gemba::Achievements::RetroAchievements::CliSyncRequester.new
34
+
35
+ case options[:subcommand]
36
+ when :login then login(options, config: config, requester: requester)
37
+ when :verify then verify( config: config, requester: requester)
38
+ when :logout then logout( config: config)
39
+ when :achievements then achievements(options, config: config, requester: requester)
40
+ end
41
+ end
42
+
43
+ def parse
44
+ argv = @argv.dup
45
+ sub = RA_SUBCOMMANDS.include?(argv.first) ? argv.shift.to_sym : nil
46
+
47
+ options = { subcommand: sub }
48
+
49
+ parser = OptionParser.new do |o|
50
+ case sub
51
+ when :login
52
+ o.banner = "Usage: gemba ra login --username USER [--password PASS]"
53
+ o.separator ""
54
+ o.separator "Log in to RetroAchievements and save credentials."
55
+ o.separator "Prompts for password if --password is not given."
56
+ o.separator ""
57
+ o.on("--username USER", "RetroAchievements username") { |v| options[:username] = v }
58
+ o.on("--password PASS", "Password (prompts if omitted)") { |v| options[:password] = v }
59
+ when :verify
60
+ o.banner = "Usage: gemba ra verify"
61
+ o.separator ""
62
+ o.separator "Verify stored RetroAchievements credentials are still valid."
63
+ o.separator ""
64
+ when :logout
65
+ o.banner = "Usage: gemba ra logout"
66
+ o.separator ""
67
+ o.separator "Clear stored RetroAchievements credentials."
68
+ o.separator ""
69
+ when :achievements
70
+ o.banner = "Usage: gemba ra achievements --rom PATH [--json]"
71
+ o.separator ""
72
+ o.separator "List achievements for a ROM."
73
+ o.separator ""
74
+ o.on("--rom PATH", "Path to the GBA ROM file") { |v| options[:rom] = File.expand_path(v) }
75
+ o.on("--json", "Output as JSON") { options[:json] = true }
76
+ else
77
+ o.banner = "Usage: gemba ra <subcommand> [options]"
78
+ o.separator ""
79
+ o.separator "Subcommands: #{RA_SUBCOMMANDS.join(', ')}"
80
+ o.separator ""
81
+ end
82
+ o.on("-h", "--help", "Show this help") { options[:help] = true }
83
+ end
84
+
85
+ parser.parse!(argv)
86
+ options[:parser] = parser
87
+ options
88
+ end
89
+
90
+ private
91
+
92
+ def login(options, config:, requester:)
93
+ username = options[:username]
94
+ unless username
95
+ $stderr.puts "Error: --username USER is required"
96
+ $stderr.puts "Run 'gemba ra login --help' for usage"
97
+ exit 1
98
+ end
99
+
100
+ password = options[:password] || begin
101
+ require "io/console"
102
+ $stderr.print "Password: "
103
+ pwd = $stdin.noecho(&:gets)&.chomp
104
+ $stderr.puts
105
+ pwd
106
+ end
107
+
108
+ backend = Gemba::Achievements::RetroAchievements::Backend.new(
109
+ app: nil, requester: requester
110
+ )
111
+ result = nil
112
+ backend.on_auth_change { |status, payload| result = [status, payload] }
113
+ backend.login_with_password(username: username, password: password)
114
+
115
+ if result&.first == :ok
116
+ config.ra_username = username
117
+ config.ra_token = result[1]
118
+ config.ra_enabled = true
119
+ config.save!
120
+ puts "Logged in as #{username}"
121
+ else
122
+ $stderr.puts "Login failed: #{result&.[](1) || 'unknown error'}"
123
+ exit 1
124
+ end
125
+ end
126
+
127
+ def verify(config:, requester:)
128
+ username = config.ra_username
129
+ token = config.ra_token
130
+
131
+ if username.empty? || token.empty?
132
+ $stderr.puts "Not logged in. Run: gemba ra login --username USER"
133
+ exit 1
134
+ end
135
+
136
+ backend = Gemba::Achievements::RetroAchievements::Backend.new(
137
+ app: nil, requester: requester
138
+ )
139
+ result = nil
140
+ backend.on_auth_change { |status, _| result = status }
141
+ backend.login_with_token(username: username, token: token)
142
+
143
+ if result == :ok
144
+ puts "Token valid for #{username}"
145
+ else
146
+ $stderr.puts "Token invalid or expired. Run: gemba ra login --username USER"
147
+ exit 1
148
+ end
149
+ end
150
+
151
+ def logout(config:)
152
+ config.ra_username = ""
153
+ config.ra_token = ""
154
+ config.ra_enabled = false
155
+ config.save!
156
+ puts "Logged out"
157
+ end
158
+
159
+ def achievements(options, config:, requester:)
160
+ username = config.ra_username
161
+ token = config.ra_token
162
+
163
+ if username.empty? || token.empty?
164
+ $stderr.puts "Not logged in. Run: gemba ra login --username USER"
165
+ exit 1
166
+ end
167
+
168
+ unless options[:rom]
169
+ $stderr.puts "Error: --rom PATH is required"
170
+ $stderr.puts "Run 'gemba ra achievements --help' for usage"
171
+ exit 1
172
+ end
173
+
174
+ require "digest"
175
+
176
+ backend = Gemba::Achievements::RetroAchievements::Backend.new(
177
+ app: nil, requester: requester
178
+ )
179
+ backend.login_with_token(username: username, token: token)
180
+
181
+ rom_path = options[:rom]
182
+ md5 = Digest::MD5.file(rom_path).hexdigest
183
+ rom_info = Struct.new(:md5, :title).new(md5, File.basename(rom_path, ".*"))
184
+
185
+ list = nil
186
+ backend.fetch_for_display(rom_info: rom_info) { |result| list = result }
187
+
188
+ if list.nil?
189
+ $stderr.puts "No achievements found (game not recognized by RetroAchievements)"
190
+ exit 1
191
+ end
192
+
193
+ if options[:json]
194
+ require "json"
195
+ puts JSON.generate(list.map { |a|
196
+ { id: a.id, title: a.title, description: a.description,
197
+ points: a.points, earned: a.earned?, earned_at: a.earned_at }
198
+ })
199
+ else
200
+ earned = list.count(&:earned?)
201
+ puts "#{earned}/#{list.size} achievements — #{rom_info.title}"
202
+ puts
203
+ list.each do |a|
204
+ mark = a.earned? ? "X" : " "
205
+ puts " [#{mark}] #{a.title} (#{a.points}pts)"
206
+ puts " #{a.description}"
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gemba/version'
4
+
5
+ module Gemba
6
+ class CLI
7
+ module Commands
8
+ class Version
9
+ def initialize(argv, dry_run: false)
10
+ @dry_run = dry_run
11
+ end
12
+
13
+ def call
14
+ result = { command: :version, version: Gemba::VERSION }
15
+ return result if @dry_run
16
+
17
+ puts "gemba #{Gemba::VERSION}"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end