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,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ module Achievements
5
+ # Achievement backend backed by a local database — no HTTP, no rcheevos.
6
+ # Auth is a no-op: always authenticated.
7
+ #
8
+ # The DB maps ROM checksum → array of achievement definition Hashes:
9
+ # {
10
+ # id:, title:, description:, points:,
11
+ # trigger: :on_load | :memory,
12
+ # condition: ->(mem) { bool } # :memory trigger only
13
+ # }
14
+ #
15
+ # :on_load achievements fire immediately in load_game.
16
+ # :memory achievements are evaluated each frame in do_frame (rising edge).
17
+ #
18
+ # Long-term this DB can be populated by RcheevosBackend after a successful
19
+ # server sync, enabling offline play. For now it ships with a small
20
+ # built-in set (one achievement for the GEMBATEST fixture ROM).
21
+ #
22
+ # Tests assign this backend directly:
23
+ # frame.achievement_backend = Achievements::OfflineBackend.new
24
+ class OfflineBackend
25
+ include Backend
26
+
27
+ # Built-in achievement definitions, keyed by ROM checksum.
28
+ BUILTIN_DB = {
29
+ # test/fixtures/test.gba — checksum 3369266971, title "GEMBATEST"
30
+ 3369266971 => [
31
+ {
32
+ id: 'gembatest_loaded',
33
+ title: 'Ready to Play',
34
+ description: 'Loaded the Gemba test ROM',
35
+ points: 1,
36
+ trigger: :on_load,
37
+ },
38
+ ],
39
+ }.freeze
40
+
41
+ # @param db [Hash, nil] fully replaces BUILTIN_DB when provided.
42
+ # Pass BUILTIN_DB.merge(extras) explicitly if you want both.
43
+ def initialize(db: nil)
44
+ @db = db || BUILTIN_DB
45
+ @achievements = []
46
+ @earned = {}
47
+ @prev_state = {}
48
+ @rp_db = {} # checksum → String
49
+ @rich_presence_message = nil
50
+ end
51
+
52
+ # -- Authentication (no-op — offline backend is always authenticated) ------
53
+
54
+ def login_with_password(username:, password:)
55
+ return fire_auth_change(:error, 'Username and password required') if username.to_s.strip.empty? || password.to_s.strip.empty?
56
+ # Offline backend accepts any credentials — real auth happens via rcheevos
57
+ fire_auth_change(:ok, "offline_token_#{username.strip}")
58
+ end
59
+
60
+ def login_with_token(username:, token:)
61
+ return if token.to_s.strip.empty?
62
+ fire_auth_change(:ok, nil)
63
+ end
64
+
65
+ def logout
66
+ fire_auth_change(:logout)
67
+ end
68
+
69
+ def authenticated? = true
70
+
71
+ def token_test
72
+ fire_auth_change(:ok, nil)
73
+ end
74
+
75
+ # -- Game lifecycle -------------------------------------------------------
76
+
77
+ def load_game(core, rom_path = nil, md5 = nil)
78
+ @achievements = []
79
+ @earned = {}
80
+ @prev_state = {}
81
+ @rich_presence_message = @rp_db[core.checksum]
82
+
83
+ (@db[core.checksum] || []).each do |defn|
84
+ ach = Achievement.new(
85
+ id: defn[:id],
86
+ title: defn[:title],
87
+ description: defn[:description],
88
+ points: defn[:points],
89
+ earned_at: nil,
90
+ )
91
+ @achievements << ach
92
+ @prev_state[ach.id] = false
93
+
94
+ if defn[:trigger] == :on_load
95
+ earned = ach.earn
96
+ @earned[ach.id] = earned
97
+ fire_unlock(earned)
98
+ end
99
+ end
100
+ end
101
+
102
+ def unload_game
103
+ @achievements = []
104
+ @earned = {}
105
+ @prev_state = {}
106
+ @rich_presence_message = nil
107
+ end
108
+
109
+ # -- Per-frame evaluation (memory-condition achievements) -----------------
110
+
111
+ def do_frame(core)
112
+ (@db[core.checksum] || []).each do |defn|
113
+ next unless defn[:trigger] == :memory
114
+ next if @earned.key?(defn[:id])
115
+
116
+ condition = defn[:condition]
117
+ next unless condition
118
+
119
+ read_mem = ->(addr) { core.bus_read8(addr) }
120
+ current = condition.call(read_mem) ? true : false
121
+
122
+ if current && !@prev_state[defn[:id]]
123
+ ach = @achievements.find { |a| a.id == defn[:id] }
124
+ if ach
125
+ earned = ach.earn
126
+ @earned[ach.id] = earned
127
+ fire_unlock(earned)
128
+ end
129
+ end
130
+
131
+ @prev_state[defn[:id]] = current
132
+ end
133
+ end
134
+
135
+ # -- Achievement list -----------------------------------------------------
136
+
137
+ def achievement_list
138
+ @achievements.map { |a| @earned[a.id] || a }
139
+ end
140
+
141
+ def enabled? = true
142
+
143
+ def rich_presence_message
144
+ @rich_presence_message
145
+ end
146
+
147
+ # -- DB management --------------------------------------------------------
148
+
149
+ # Merge achievement definitions for a ROM into the in-memory DB.
150
+ # Intended for use by RcheevosBackend to seed the offline cache.
151
+ #
152
+ # @param checksum [Integer]
153
+ # @param defs [Array<Hash>]
154
+ def store(checksum, defs)
155
+ @db = @db.merge(checksum => defs)
156
+ end
157
+
158
+ # Store a static Rich Presence message for a ROM.
159
+ # Intended for use by RcheevosBackend when caching patch data offline.
160
+ #
161
+ # @param checksum [Integer]
162
+ # @param message [String]
163
+ def store_rich_presence(checksum, message)
164
+ @rp_db = @rp_db.merge(checksum => message.to_s)
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,511 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "digest"
6
+
7
+ module Gemba
8
+ module Achievements
9
+ module RetroAchievements
10
+ # Achievement backend that talks to retroachievements.org.
11
+ #
12
+ # All requests are HTTP POSTs to /dorequest.php. The 'r' parameter tells
13
+ # the server what you want — it's RA's own naming, not ours:
14
+ #
15
+ # r=login2 authenticate (password or token)
16
+ # r=gameid "here's a ROM MD5 hash — what game ID is it?"
17
+ # r=patch "give me the achievement definitions for this game"
18
+ # (called 'patch' because RA's original concept was that
19
+ # achievements are a 'patch' bolted on top of a ROM —
20
+ # extra behaviour injected into the game. the name stuck.)
21
+ # r=unlocks "which of these achievements has the player already earned?"
22
+ # r=awardachievement "the player just earned this achievement, record it"
23
+ #
24
+ # Most HTTP is done off the main thread via Teek::BackgroundWork (thread mode).
25
+ # The ping heartbeat uses PING_BG_MODE which selects ractor mode on Ruby 4+.
26
+ #
27
+ # Authentication flow:
28
+ # login_with_password → r=login2 + password → stores token, fires :ok
29
+ # login_with_token → r=login2 + token → verifies token, fires :ok/:error
30
+ # token_test → same as login_with_token using stored creds
31
+ #
32
+ # Game load flow (all requests are chained — each fires when the previous
33
+ # HTTP response comes back):
34
+ #
35
+ # load_game(core, rom_path)
36
+ # → MD5 hash the ROM file
37
+ # → r=gameid (MD5) — server tells us the RA game ID
38
+ # → r=patch (game ID) — server sends achievement definitions
39
+ # → r=unlocks (game ID) — server says which the player already has
40
+ # → activate each un-earned achievement in the C runtime
41
+ # (parse conditions, load into hash table so do_frame checks them)
42
+ #
43
+ # Achievements are not loaded into the runtime until AFTER the unlocks
44
+ # response arrives. This means @achievements stays empty during the
45
+ # network round-trips, and do_frame's early-return guard prevents any
46
+ # evaluation before we know what the player has already earned.
47
+ class Backend
48
+ include Achievements::Backend
49
+
50
+ RA_HOST = "retroachievements.org"
51
+ RA_USER_AGENT = "gemba/#{Gemba::VERSION} (https://github.com/jamescook/gemba)"
52
+ RA_PATH = "/dorequest.php"
53
+ PING_BG_MODE = (RUBY_VERSION >= "4.0" ? :ractor : :thread).freeze
54
+ UNLOCK_RETRY_BG_MODE = (RUBY_VERSION >= "4.0" ? :ractor : :thread).freeze
55
+
56
+ # Frames between Rich Presence evaluations (~4 s at 60 fps).
57
+ RP_EVAL_INTERVAL = 240
58
+ # Seconds between session ping heartbeats.
59
+ PING_INTERVAL_SEC = 120
60
+ # Seconds between unlock retry sweeps.
61
+ UNLOCK_RETRY_INTERVAL_SEC = 30
62
+
63
+ # Default requester: delegates to Teek::BackgroundWork.
64
+ # Extracted so tests can inject a synchronous fake with the same interface.
65
+ DEFAULT_REQUESTER = lambda do |app, params, mode: :thread, **opts, &block|
66
+ Teek::BackgroundWork.new(app, params, mode: mode, **opts, &block)
67
+ end.freeze
68
+
69
+ def initialize(app:, runtime: nil, requester: nil)
70
+ @app = app
71
+ @requester = requester || DEFAULT_REQUESTER
72
+ @username = nil
73
+ @token = nil
74
+ @game_id = nil
75
+ @achievements = []
76
+ @earned = {}
77
+ @authenticated = false
78
+ @include_unofficial = false
79
+ @rich_presence_enabled = false
80
+ @rich_presence_message = nil
81
+ @rp_eval_frame = 0
82
+ @ping_last_at = nil
83
+ @ra_runtime = runtime || Gemba::RARuntime.new
84
+ @unlock_queue = []
85
+ @unlock_retry_last_at = nil
86
+ end
87
+
88
+ attr_writer :include_unofficial
89
+ attr_writer :rich_presence_enabled
90
+ attr_reader :rich_presence_message
91
+ attr_reader :unlock_queue
92
+
93
+ # -- Authentication -------------------------------------------------------
94
+
95
+ def login_with_password(username:, password:)
96
+ ra_request(r: "login2", u: username, p: password) do |json, ok|
97
+ if ok && json&.dig("Success")
98
+ @username = username
99
+ @token = json["Token"]
100
+ @authenticated = true
101
+ Gemba.log(:info) { "RA: authenticated as #{username}" }
102
+ fire_auth_change(:ok, @token)
103
+ else
104
+ msg = json&.dig("Error") || "Login failed"
105
+ Gemba.log(:warn) { "RA: authentication failed for #{username}: #{msg}" }
106
+ fire_auth_change(:error, msg)
107
+ end
108
+ end
109
+ end
110
+
111
+ def login_with_token(username:, token:)
112
+ @username = username
113
+ @token = token
114
+ ra_request(r: "login2", u: username, t: token) do |json, ok|
115
+ if ok && json&.dig("Success")
116
+ @authenticated = true
117
+ Gemba.log(:info) { "RA: token verified for #{username}" }
118
+ fire_auth_change(:ok, nil)
119
+ else
120
+ @authenticated = false
121
+ msg = json ? (json.dig("Error") || "Token invalid") : "Could not connect to RetroAchievements"
122
+ Gemba.log(:warn) { "RA: token verification failed for #{username}: #{msg}" }
123
+ fire_auth_change(:error, msg)
124
+ end
125
+ end
126
+ end
127
+
128
+ def token_test
129
+ ra_request(r: "login2", u: @username, t: @token) do |json, ok|
130
+ if ok && json&.dig("Success")
131
+ fire_auth_change(:ok, nil)
132
+ else
133
+ @authenticated = false
134
+ msg = json ? (json.dig("Error") || "Token invalid") : "Could not connect to RetroAchievements"
135
+ fire_auth_change(:error, msg)
136
+ end
137
+ end
138
+ end
139
+
140
+ def logout
141
+ @username = nil
142
+ @token = nil
143
+ @authenticated = false
144
+ @game_id = nil
145
+ @achievements = []
146
+ @earned = {}
147
+ @ra_runtime.clear
148
+ fire_auth_change(:logout)
149
+ end
150
+
151
+ def authenticated? = @authenticated
152
+ def enabled? = true
153
+
154
+ # -- Game lifecycle -------------------------------------------------------
155
+
156
+ def load_game(core, rom_path = nil, md5 = nil)
157
+ return unless @authenticated
158
+ return unless rom_path && File.exist?(rom_path.to_s)
159
+
160
+ @achievements = []
161
+ @earned = {}
162
+ @game_id = nil
163
+ @rich_presence_message = nil
164
+ @rp_eval_frame = 0
165
+ @ping_last_at = nil
166
+
167
+ # Use pre-computed digest if available (computed at ROM load time and
168
+ # cached in rom_library.json); fall back to computing it here for entries
169
+ # that pre-date MD5 storage.
170
+ md5 ||= Digest::MD5.file(rom_path).hexdigest
171
+
172
+ ra_request(r: "gameid", m: md5) do |json, ok|
173
+ next unless ok
174
+ game_id = json&.dig("GameID")&.to_i
175
+ next if !game_id || game_id == 0
176
+
177
+ @game_id = game_id
178
+ fetch_patch_data(game_id)
179
+ end
180
+ end
181
+
182
+ # Called after a save state is loaded. Memory just jumped to an arbitrary
183
+ # saved state, so every achievement must go back through the priming and
184
+ # waiting startup sequence — otherwise achievements that were already active
185
+ # fire instantly if the saved memory happens to satisfy their conditions.
186
+ def reset_runtime
187
+ @ra_runtime.reset_all
188
+ end
189
+
190
+ def unload_game
191
+ @game_id = nil
192
+ @achievements = []
193
+ @earned = {}
194
+ @rich_presence_message = nil
195
+ @rp_eval_frame = 0
196
+ @ping_last_at = nil
197
+ @ra_runtime.clear
198
+ fire_achievements_changed
199
+ end
200
+
201
+ def sync_unlocks
202
+ return unless @authenticated
203
+ Gemba.bus.emit(:ra_sync_started)
204
+ unless @game_id
205
+ Gemba.bus.emit(:ra_sync_done, ok: false, reason: :no_game)
206
+ return
207
+ end
208
+ @earned = {}
209
+ @achievements = []
210
+ @ra_runtime.reset_all
211
+ fetch_patch_data(@game_id, emit_sync_done: true)
212
+ end
213
+
214
+ def do_frame(core)
215
+ return if @achievements.empty?
216
+
217
+ triggered_ids = @ra_runtime.do_frame(core)
218
+ triggered_ids.each do |id|
219
+ next if @earned.key?(id)
220
+ ach = @achievements.find { |a| a.id == id }
221
+ next unless ach
222
+
223
+ earned = ach.earn
224
+ @earned[id] = earned
225
+ fire_unlock(earned)
226
+ submit_unlock(id)
227
+ end
228
+
229
+ if @unlock_queue.any?
230
+ now = Time.now
231
+ if @unlock_retry_last_at.nil? || now - @unlock_retry_last_at >= UNLOCK_RETRY_INTERVAL_SEC
232
+ @unlock_retry_last_at = now
233
+ drain_unlock_queue
234
+ end
235
+ end
236
+
237
+ return unless @rich_presence_enabled
238
+
239
+ @rp_eval_frame = (@rp_eval_frame + 1) % RP_EVAL_INTERVAL
240
+ return unless @rp_eval_frame == 0
241
+
242
+ msg = @ra_runtime.get_richpresence(core)
243
+ if msg && msg != @rich_presence_message
244
+ @rich_presence_message = msg
245
+ fire_rich_presence_changed(msg)
246
+ end
247
+
248
+ now = Time.now
249
+ if @game_id && @authenticated && (@ping_last_at.nil? || now - @ping_last_at >= PING_INTERVAL_SEC)
250
+ @ping_last_at = now
251
+ ping_game_session
252
+ end
253
+ end
254
+
255
+ # -- Achievement list -----------------------------------------------------
256
+
257
+ def achievement_list
258
+ @achievements.map { |a| @earned[a.id] || a }
259
+ end
260
+
261
+ # Fetch the full achievement list for any ROM by its RomInfo, purely for
262
+ # display. Does not touch the live game state (@achievements, @earned,
263
+ # @ra_runtime). Calls the block on the main thread with Array<Achievement>
264
+ # on success or nil on failure.
265
+ #
266
+ # Request chain (all POST to /dorequest.php):
267
+ # r=gameid m=<md5>
268
+ # r=patch u= t= g=<game_id>
269
+ # r=unlocks u= t= g=<game_id> h=0
270
+ def fetch_for_display(rom_info:, &callback)
271
+ return unless @authenticated && rom_info.md5
272
+
273
+ Gemba.log(:info) { "RA fetch_for_display: gameid lookup md5=#{rom_info.md5[0, 8]}… (#{rom_info.title})" }
274
+
275
+ ra_request(r: "gameid", m: rom_info.md5) do |json, ok|
276
+ game_id = ok ? json&.dig("GameID")&.to_i : nil
277
+ Gemba.log(game_id&.positive? ? :info : :warn) {
278
+ "RA fetch_for_display: gameid → #{game_id.inspect} ok=#{ok}"
279
+ }
280
+ unless game_id && game_id > 0
281
+ callback.call(nil)
282
+ next
283
+ end
284
+
285
+ ra_request(r: "patch", u: @username, t: @token, g: game_id) do |patch_json, patch_ok|
286
+ Gemba.log(patch_ok ? :info : :warn) {
287
+ "RA fetch_for_display: patch g=#{game_id} ok=#{patch_ok} achievements=#{patch_json&.dig("PatchData", "Achievements")&.size.inspect}"
288
+ }
289
+ unless patch_ok && patch_json
290
+ callback.call(nil)
291
+ next
292
+ end
293
+
294
+ achievements = (patch_json.dig("PatchData", "Achievements") || []).filter_map do |a|
295
+ next if a["MemAddr"].to_s.empty?
296
+ next if a["Flags"].to_i != 3 && !(a["Flags"].to_i == 5 && @include_unofficial)
297
+ next if a["ID"].to_i > 100_000_000 # skip RA-injected system messages
298
+ Achievement.new(
299
+ id: a["ID"].to_s,
300
+ title: a["Title"].to_s,
301
+ description: a["Description"].to_s,
302
+ points: a["Points"].to_i,
303
+ earned_at: nil,
304
+ )
305
+ end
306
+
307
+ ra_request(r: "unlocks", u: @username, t: @token, g: game_id, h: 0) do |ul_json, ul_ok|
308
+ earned_ids = ul_ok && ul_json&.dig("Success") ?
309
+ (ul_json.dig("UserUnlocks") || []).map(&:to_s) : []
310
+ Gemba.log(ul_ok ? :info : :warn) {
311
+ "RA fetch_for_display: unlocks g=#{game_id} ok=#{ul_ok} earned=#{earned_ids.size} total=#{achievements.size}"
312
+ }
313
+ result = achievements.map { |a| earned_ids.include?(a.id) ? a.earn : a }
314
+ callback.call(result)
315
+ end
316
+ end
317
+ end
318
+ end
319
+
320
+ def drain_unlock_queue
321
+ Gemba.log(:info) { "RA: retrying #{@unlock_queue.size} queued unlock(s)" }
322
+ @unlock_queue.dup.each do |entry|
323
+ data = {
324
+ r: "awardachievement", u: @username, t: @token,
325
+ a: entry[:id], h: entry[:hardcore] ? 1 : 0,
326
+ }
327
+ data = Ractor.make_shareable(data) if UNLOCK_RETRY_BG_MODE == :ractor
328
+ @requester.call(@app, data, mode: UNLOCK_RETRY_BG_MODE, worker: UnlockRetryWorker)
329
+ .on_progress do |result|
330
+ ok, id = result
331
+ if ok
332
+ Gemba.log(:info) { "RA: retry succeeded for achievement #{id}" }
333
+ @unlock_queue.delete_if { |e| e[:id] == id }
334
+ else
335
+ Gemba.log(:warn) { "RA: retry failed for achievement #{id}" }
336
+ end
337
+ end
338
+ end
339
+ end
340
+
341
+ # Submit an unlock to RA. On failure, queues for background retry.
342
+ def submit_unlock(achievement_id, hardcore: false)
343
+ do_unlock_request(achievement_id, hardcore: hardcore) do |ok|
344
+ enqueue_for_retry(achievement_id, hardcore: hardcore) unless ok
345
+ end
346
+ end
347
+
348
+ # Call on app exit — logs any unconfirmed unlocks that never drained.
349
+ def shutdown
350
+ return if @unlock_queue.empty?
351
+ ids = @unlock_queue.map { |e| e[:id] }.join(", ")
352
+ Gemba.log(:warn) { "RA: #{@unlock_queue.size} unlock(s) never confirmed — dropped on exit: #{ids}" }
353
+ end
354
+
355
+ private
356
+
357
+ # Fetch patch data (achievement definitions). Does NOT activate the runtime
358
+ # or populate @achievements — that happens only after unlocks are known,
359
+ # in activate_from_patch. This ensures do_frame can never evaluate and
360
+ # award achievements during the window between patch data and unlocks.
361
+ def fetch_patch_data(game_id, emit_sync_done: false)
362
+ ra_request(r: "patch", u: @username, t: @token, g: game_id) do |json, ok|
363
+ unless ok && json
364
+ Gemba.log(:warn) { "RA: failed to fetch patch data for game #{game_id}" }
365
+ Gemba.bus.emit(:ra_sync_done, ok: false) if emit_sync_done
366
+ next
367
+ end
368
+
369
+ rp_script = json.dig("PatchData", "RichPresencePatch").to_s
370
+
371
+ raw = (json.dig("PatchData", "Achievements") || []).select do |a|
372
+ !a["MemAddr"].to_s.empty? &&
373
+ (a["Flags"].to_i == 3 || (a["Flags"].to_i == 5 && @include_unofficial)) &&
374
+ a["ID"].to_i <= 100_000_000
375
+ end
376
+
377
+ fetch_unlocks(game_id, raw_ach_data: raw, rp_script: rp_script, emit_sync_done: emit_sync_done)
378
+ end
379
+ end
380
+
381
+ # Fetch already-earned achievement IDs, then activate the runtime with
382
+ # all achievements, immediately deactivating the already-earned ones.
383
+ # Only after this step is @achievements populated — so do_frame's
384
+ # `return if @achievements.empty?` guard covers the entire window.
385
+ def fetch_unlocks(game_id, raw_ach_data: nil, rp_script: nil, emit_sync_done: false)
386
+ ra_request(r: "unlocks", u: @username, t: @token, g: game_id, h: 0) do |json, ok|
387
+ unless ok && json&.dig("Success")
388
+ Gemba.log(:warn) { "RA: failed to fetch unlocks for game #{game_id}" }
389
+ Gemba.bus.emit(:ra_sync_done, ok: false) if emit_sync_done
390
+ next
391
+ end
392
+
393
+ earned_ids = (json.dig("UserUnlocks") || []).map(&:to_s)
394
+
395
+ if raw_ach_data
396
+ # Fresh load / re-sync: activate runtime now that we know earned set.
397
+ @ra_runtime.clear
398
+ @achievements = raw_ach_data.filter_map do |a|
399
+ id = a["ID"].to_s
400
+ memaddr = a["MemAddr"].to_s
401
+ begin
402
+ @ra_runtime.activate(id, memaddr)
403
+ rescue ArgumentError => e
404
+ Gemba.log(:warn) { "RA: skipping achievement #{id} — #{e.message}" }
405
+ next
406
+ end
407
+ @ra_runtime.deactivate(id) if earned_ids.include?(id)
408
+ Achievement.new(
409
+ id: id,
410
+ title: a["Title"].to_s,
411
+ description: a["Description"].to_s,
412
+ points: a["Points"].to_i,
413
+ earned_at: nil,
414
+ )
415
+ end
416
+ Gemba.log(:info) { "RA: loaded #{@achievements.size} achievements for game #{game_id}" }
417
+
418
+ if rp_script && !rp_script.empty?
419
+ ok = @ra_runtime.activate_richpresence(rp_script)
420
+ Gemba.log(ok ? :info : :warn) { "RA: rich presence script #{ok ? "activated" : "failed to parse"} for game #{game_id}" }
421
+ end
422
+ end
423
+
424
+ newly_marked = 0
425
+ earned_ids.each do |id|
426
+ next if @earned.key?(id)
427
+ ach = @achievements.find { |a| a.id == id }
428
+ next unless ach
429
+ earned = ach.earn
430
+ @earned[id] = earned
431
+ newly_marked += 1
432
+ end
433
+
434
+ Gemba.log(:info) { "RA: synced #{newly_marked} pre-earned achievements for game #{game_id}" } if newly_marked > 0
435
+ fire_achievements_changed
436
+ Gemba.bus.emit(:ra_sync_done, ok: true) if emit_sync_done
437
+ end
438
+ end
439
+
440
+ # POST r=ping heartbeat — keeps the RA session alive and records
441
+ # the current Rich Presence string on the server.
442
+ # Runs via PingWorker which is Ractor-safe on Ruby 4+.
443
+ def ping_game_session
444
+ data = {
445
+ host: RA_HOST,
446
+ path: RA_PATH,
447
+ params: {
448
+ "r" => "ping",
449
+ "u" => @username,
450
+ "t" => @token,
451
+ "g" => @game_id.to_s,
452
+ "m" => @rich_presence_message.to_s,
453
+ },
454
+ }
455
+ data = Ractor.make_shareable(data) if PING_BG_MODE == :ractor
456
+ game_id = @game_id
457
+ @requester.call(@app, data, mode: PING_BG_MODE, worker: PingWorker)
458
+ .on_progress { |ok| Gemba.log(ok ? :info : :warn) { "RA: ping g=#{game_id} ok=#{ok}" } }
459
+ end
460
+
461
+ # Fire the awardachievement HTTP request and yield ok (true/false) to block.
462
+ def do_unlock_request(achievement_id, hardcore:, &on_complete)
463
+ ra_request(r: "awardachievement", u: @username, t: @token,
464
+ a: achievement_id, h: hardcore ? 1 : 0) do |json, ok|
465
+ success = ok && json&.dig("Success")
466
+ Gemba.log(success ? :info : :warn) {
467
+ success ? "RA: submitted unlock for achievement #{achievement_id}" \
468
+ : "RA: unlock submission failed for #{achievement_id}: #{json&.dig("Error")}"
469
+ }
470
+ on_complete.call(success)
471
+ end
472
+ end
473
+
474
+ def enqueue_for_retry(achievement_id, hardcore:)
475
+ @unlock_queue << { id: achievement_id, hardcore: hardcore }
476
+ Gemba.log(:info) { "RA: queued achievement #{achievement_id} for retry" }
477
+ end
478
+
479
+ # POST to dorequest.php via @requester (BackgroundWork in production,
480
+ # a synchronous fake in tests). Calls on_done with (json_or_nil, ok_bool).
481
+ def ra_request(params, &on_done)
482
+ @requester.call(@app, params, mode: :thread) do |t, req_params|
483
+ uri = URI::HTTPS.build(host: RA_HOST, path: RA_PATH)
484
+ http = Net::HTTP.new(uri.host, uri.port)
485
+ http.use_ssl = true
486
+ http.read_timeout = 15
487
+ req = Net::HTTP::Post.new(uri.path)
488
+ req['User-Agent'] = RA_USER_AGENT
489
+ safe = req_params.reject { |k, _| [:t, :p, "t", "p"].include?(k) }
490
+ Gemba.log(:info) { "RA request: r=#{params[:r]} #{safe.map { |k, v| "#{k}=#{v}" }.join(" ")}" }
491
+ req.set_form_data(req_params.transform_keys(&:to_s).transform_values(&:to_s))
492
+ resp = http.request(req)
493
+ if resp.is_a?(Net::HTTPSuccess)
494
+ body = resp.body
495
+ Gemba.log(:info) { "RA response: r=#{params[:r]} HTTP #{resp.code} body=#{body.length}b" }
496
+ t.yield([JSON.parse(body), true])
497
+ else
498
+ Gemba.log(:warn) { "RA response: r=#{params[:r]} HTTP #{resp.code} #{resp.message} body=#{resp.body.to_s[0, 200]}" }
499
+ t.yield([nil, false])
500
+ end
501
+ rescue => e
502
+ Gemba.log(:warn) { "RA: request error (#{params[:r]}): #{e.class} #{e.message}" }
503
+ t.yield([nil, false])
504
+ end.on_progress do |result|
505
+ on_done.call(*result) if on_done && result
506
+ end
507
+ end
508
+ end
509
+ end
510
+ end
511
+ end