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,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,453 @@
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_PATH = "/dorequest.php"
52
+ PING_BG_MODE = (RUBY_VERSION >= "4.0" ? :ractor : :thread).freeze
53
+
54
+ # Frames between Rich Presence evaluations (~4 s at 60 fps).
55
+ RP_EVAL_INTERVAL = 240
56
+ # Seconds between session ping heartbeats.
57
+ PING_INTERVAL_SEC = 120
58
+
59
+ # Default requester: delegates to Teek::BackgroundWork.
60
+ # Extracted so tests can inject a synchronous fake with the same interface.
61
+ DEFAULT_REQUESTER = lambda do |app, params, mode: :thread, **opts, &block|
62
+ Teek::BackgroundWork.new(app, params, mode: mode, **opts, &block)
63
+ end.freeze
64
+
65
+ def initialize(app:, runtime: nil, requester: nil)
66
+ @app = app
67
+ @requester = requester || DEFAULT_REQUESTER
68
+ @username = nil
69
+ @token = nil
70
+ @game_id = nil
71
+ @achievements = []
72
+ @earned = {}
73
+ @authenticated = false
74
+ @include_unofficial = false
75
+ @rich_presence_enabled = false
76
+ @rich_presence_message = nil
77
+ @rp_eval_frame = 0
78
+ @ping_last_at = nil
79
+ @ra_runtime = runtime || Gemba::RARuntime.new
80
+ end
81
+
82
+ attr_writer :include_unofficial
83
+ attr_writer :rich_presence_enabled
84
+ attr_reader :rich_presence_message
85
+
86
+ # -- Authentication -------------------------------------------------------
87
+
88
+ def login_with_password(username:, password:)
89
+ ra_request(r: "login2", u: username, p: password) do |json, ok|
90
+ if ok && json&.dig("Success")
91
+ @username = username
92
+ @token = json["Token"]
93
+ @authenticated = true
94
+ Gemba.log(:info) { "RA: authenticated as #{username}" }
95
+ fire_auth_change(:ok, @token)
96
+ else
97
+ msg = json&.dig("Error") || "Login failed"
98
+ Gemba.log(:warn) { "RA: authentication failed for #{username}: #{msg}" }
99
+ fire_auth_change(:error, msg)
100
+ end
101
+ end
102
+ end
103
+
104
+ def login_with_token(username:, token:)
105
+ @username = username
106
+ @token = token
107
+ ra_request(r: "login2", u: username, t: token) do |json, ok|
108
+ if ok && json&.dig("Success")
109
+ @authenticated = true
110
+ Gemba.log(:info) { "RA: token verified for #{username}" }
111
+ fire_auth_change(:ok, nil)
112
+ else
113
+ @authenticated = false
114
+ msg = json&.dig("Error") || "Token invalid"
115
+ Gemba.log(:warn) { "RA: token verification failed for #{username}: #{msg}" }
116
+ fire_auth_change(:error, msg)
117
+ end
118
+ end
119
+ end
120
+
121
+ def token_test
122
+ ra_request(r: "login2", u: @username, t: @token) do |json, ok|
123
+ if ok && json&.dig("Success")
124
+ fire_auth_change(:ok, nil)
125
+ else
126
+ @authenticated = false
127
+ fire_auth_change(:error, json&.dig("Error") || "Token invalid")
128
+ end
129
+ end
130
+ end
131
+
132
+ def logout
133
+ @username = nil
134
+ @token = nil
135
+ @authenticated = false
136
+ @game_id = nil
137
+ @achievements = []
138
+ @earned = {}
139
+ @ra_runtime.clear
140
+ fire_auth_change(:logout)
141
+ end
142
+
143
+ def authenticated? = @authenticated
144
+ def enabled? = true
145
+
146
+ # -- Game lifecycle -------------------------------------------------------
147
+
148
+ def load_game(core, rom_path = nil, md5 = nil)
149
+ return unless @authenticated
150
+ return unless rom_path && File.exist?(rom_path.to_s)
151
+
152
+ @achievements = []
153
+ @earned = {}
154
+ @game_id = nil
155
+ @rich_presence_message = nil
156
+ @rp_eval_frame = 0
157
+ @ping_last_at = nil
158
+
159
+ # Use pre-computed digest if available (computed at ROM load time and
160
+ # cached in rom_library.json); fall back to computing it here for entries
161
+ # that pre-date MD5 storage.
162
+ md5 ||= Digest::MD5.file(rom_path).hexdigest
163
+
164
+ ra_request(r: "gameid", m: md5) do |json, ok|
165
+ next unless ok
166
+ game_id = json&.dig("GameID")&.to_i
167
+ next if !game_id || game_id == 0
168
+
169
+ @game_id = game_id
170
+ fetch_patch_data(game_id)
171
+ end
172
+ end
173
+
174
+ # Called after a save state is loaded. Memory just jumped to an arbitrary
175
+ # saved state, so every achievement must go back through the priming and
176
+ # waiting startup sequence — otherwise achievements that were already active
177
+ # fire instantly if the saved memory happens to satisfy their conditions.
178
+ def reset_runtime
179
+ @ra_runtime.reset_all
180
+ end
181
+
182
+ def unload_game
183
+ @game_id = nil
184
+ @achievements = []
185
+ @earned = {}
186
+ @rich_presence_message = nil
187
+ @rp_eval_frame = 0
188
+ @ping_last_at = nil
189
+ @ra_runtime.clear
190
+ fire_achievements_changed
191
+ end
192
+
193
+ def sync_unlocks
194
+ return unless @authenticated
195
+ Gemba.bus.emit(:ra_sync_started)
196
+ unless @game_id
197
+ Gemba.bus.emit(:ra_sync_done, ok: false, reason: :no_game)
198
+ return
199
+ end
200
+ @earned = {}
201
+ @achievements = []
202
+ @ra_runtime.reset_all
203
+ fetch_patch_data(@game_id, emit_sync_done: true)
204
+ end
205
+
206
+ def do_frame(core)
207
+ return if @achievements.empty?
208
+
209
+ triggered_ids = @ra_runtime.do_frame(core)
210
+ triggered_ids.each do |id|
211
+ next if @earned.key?(id)
212
+ ach = @achievements.find { |a| a.id == id }
213
+ next unless ach
214
+
215
+ earned = ach.earn
216
+ @earned[id] = earned
217
+ fire_unlock(earned)
218
+ submit_unlock(id)
219
+ end
220
+
221
+ return unless @rich_presence_enabled
222
+
223
+ @rp_eval_frame = (@rp_eval_frame + 1) % RP_EVAL_INTERVAL
224
+ return unless @rp_eval_frame == 0
225
+
226
+ msg = @ra_runtime.get_richpresence(core)
227
+ if msg && msg != @rich_presence_message
228
+ @rich_presence_message = msg
229
+ fire_rich_presence_changed(msg)
230
+ end
231
+
232
+ now = Time.now
233
+ if @game_id && @authenticated && (@ping_last_at.nil? || now - @ping_last_at >= PING_INTERVAL_SEC)
234
+ @ping_last_at = now
235
+ ping_game_session
236
+ end
237
+ end
238
+
239
+ # -- Achievement list -----------------------------------------------------
240
+
241
+ def achievement_list
242
+ @achievements.map { |a| @earned[a.id] || a }
243
+ end
244
+
245
+ # Fetch the full achievement list for any ROM by its RomInfo, purely for
246
+ # display. Does not touch the live game state (@achievements, @earned,
247
+ # @ra_runtime). Calls the block on the main thread with Array<Achievement>
248
+ # on success or nil on failure.
249
+ #
250
+ # Request chain (all POST to /dorequest.php):
251
+ # r=gameid m=<md5>
252
+ # r=patch u= t= g=<game_id>
253
+ # r=unlocks u= t= g=<game_id> h=0
254
+ def fetch_for_display(rom_info:, &callback)
255
+ return unless @authenticated && rom_info.md5
256
+
257
+ Gemba.log(:info) { "RA fetch_for_display: gameid lookup md5=#{rom_info.md5[0, 8]}… (#{rom_info.title})" }
258
+
259
+ ra_request(r: "gameid", m: rom_info.md5) do |json, ok|
260
+ game_id = ok ? json&.dig("GameID")&.to_i : nil
261
+ Gemba.log(game_id&.positive? ? :info : :warn) {
262
+ "RA fetch_for_display: gameid → #{game_id.inspect} ok=#{ok}"
263
+ }
264
+ unless game_id && game_id > 0
265
+ callback.call(nil)
266
+ next
267
+ end
268
+
269
+ ra_request(r: "patch", u: @username, t: @token, g: game_id) do |patch_json, patch_ok|
270
+ Gemba.log(patch_ok ? :info : :warn) {
271
+ "RA fetch_for_display: patch g=#{game_id} ok=#{patch_ok} achievements=#{patch_json&.dig("PatchData", "Achievements")&.size.inspect}"
272
+ }
273
+ unless patch_ok && patch_json
274
+ callback.call(nil)
275
+ next
276
+ end
277
+
278
+ achievements = (patch_json.dig("PatchData", "Achievements") || []).filter_map do |a|
279
+ next if a["MemAddr"].to_s.empty?
280
+ next if a["Flags"].to_i != 3 && !(a["Flags"].to_i == 5 && @include_unofficial)
281
+ next if a["ID"].to_i > 100_000_000 # skip RA-injected system messages
282
+ Achievement.new(
283
+ id: a["ID"].to_s,
284
+ title: a["Title"].to_s,
285
+ description: a["Description"].to_s,
286
+ points: a["Points"].to_i,
287
+ earned_at: nil,
288
+ )
289
+ end
290
+
291
+ ra_request(r: "unlocks", u: @username, t: @token, g: game_id, h: 0) do |ul_json, ul_ok|
292
+ earned_ids = ul_ok && ul_json&.dig("Success") ?
293
+ (ul_json.dig("UserUnlocks") || []).map(&:to_s) : []
294
+ Gemba.log(ul_ok ? :info : :warn) {
295
+ "RA fetch_for_display: unlocks g=#{game_id} ok=#{ul_ok} earned=#{earned_ids.size} total=#{achievements.size}"
296
+ }
297
+ result = achievements.map { |a| earned_ids.include?(a.id) ? a.earn : a }
298
+ callback.call(result)
299
+ end
300
+ end
301
+ end
302
+ end
303
+
304
+ private
305
+
306
+ # Fetch patch data (achievement definitions). Does NOT activate the runtime
307
+ # or populate @achievements — that happens only after unlocks are known,
308
+ # in activate_from_patch. This ensures do_frame can never evaluate and
309
+ # award achievements during the window between patch data and unlocks.
310
+ def fetch_patch_data(game_id, emit_sync_done: false)
311
+ ra_request(r: "patch", u: @username, t: @token, g: game_id) do |json, ok|
312
+ unless ok && json
313
+ Gemba.log(:warn) { "RA: failed to fetch patch data for game #{game_id}" }
314
+ Gemba.bus.emit(:ra_sync_done, ok: false) if emit_sync_done
315
+ next
316
+ end
317
+
318
+ rp_script = json.dig("PatchData", "RichPresencePatch").to_s
319
+
320
+ raw = (json.dig("PatchData", "Achievements") || []).select do |a|
321
+ !a["MemAddr"].to_s.empty? &&
322
+ (a["Flags"].to_i == 3 || (a["Flags"].to_i == 5 && @include_unofficial)) &&
323
+ a["ID"].to_i <= 100_000_000
324
+ end
325
+
326
+ fetch_unlocks(game_id, raw_ach_data: raw, rp_script: rp_script, emit_sync_done: emit_sync_done)
327
+ end
328
+ end
329
+
330
+ # Fetch already-earned achievement IDs, then activate the runtime with
331
+ # all achievements, immediately deactivating the already-earned ones.
332
+ # Only after this step is @achievements populated — so do_frame's
333
+ # `return if @achievements.empty?` guard covers the entire window.
334
+ def fetch_unlocks(game_id, raw_ach_data: nil, rp_script: nil, emit_sync_done: false)
335
+ ra_request(r: "unlocks", u: @username, t: @token, g: game_id, h: 0) do |json, ok|
336
+ unless ok && json&.dig("Success")
337
+ Gemba.log(:warn) { "RA: failed to fetch unlocks for game #{game_id}" }
338
+ Gemba.bus.emit(:ra_sync_done, ok: false) if emit_sync_done
339
+ next
340
+ end
341
+
342
+ earned_ids = (json.dig("UserUnlocks") || []).map(&:to_s)
343
+
344
+ if raw_ach_data
345
+ # Fresh load / re-sync: activate runtime now that we know earned set.
346
+ @ra_runtime.clear
347
+ @achievements = raw_ach_data.filter_map do |a|
348
+ id = a["ID"].to_s
349
+ memaddr = a["MemAddr"].to_s
350
+ begin
351
+ @ra_runtime.activate(id, memaddr)
352
+ rescue ArgumentError => e
353
+ Gemba.log(:warn) { "RA: skipping achievement #{id} — #{e.message}" }
354
+ next
355
+ end
356
+ @ra_runtime.deactivate(id) if earned_ids.include?(id)
357
+ Achievement.new(
358
+ id: id,
359
+ title: a["Title"].to_s,
360
+ description: a["Description"].to_s,
361
+ points: a["Points"].to_i,
362
+ earned_at: nil,
363
+ )
364
+ end
365
+ Gemba.log(:info) { "RA: loaded #{@achievements.size} achievements for game #{game_id}" }
366
+
367
+ if rp_script && !rp_script.empty?
368
+ ok = @ra_runtime.activate_richpresence(rp_script)
369
+ Gemba.log(ok ? :info : :warn) { "RA: rich presence script #{ok ? "activated" : "failed to parse"} for game #{game_id}" }
370
+ end
371
+ end
372
+
373
+ newly_marked = 0
374
+ earned_ids.each do |id|
375
+ next if @earned.key?(id)
376
+ ach = @achievements.find { |a| a.id == id }
377
+ next unless ach
378
+ earned = ach.earn
379
+ @earned[id] = earned
380
+ newly_marked += 1
381
+ end
382
+
383
+ Gemba.log(:info) { "RA: synced #{newly_marked} pre-earned achievements for game #{game_id}" } if newly_marked > 0
384
+ fire_achievements_changed
385
+ Gemba.bus.emit(:ra_sync_done, ok: true) if emit_sync_done
386
+ end
387
+ end
388
+
389
+ # POST r=ping heartbeat — keeps the RA session alive and records
390
+ # the current Rich Presence string on the server.
391
+ # Runs via PingWorker which is Ractor-safe on Ruby 4+.
392
+ def ping_game_session
393
+ data = {
394
+ host: RA_HOST,
395
+ path: RA_PATH,
396
+ params: {
397
+ "r" => "ping",
398
+ "u" => @username,
399
+ "t" => @token,
400
+ "g" => @game_id.to_s,
401
+ "m" => @rich_presence_message.to_s,
402
+ },
403
+ }
404
+ data = Ractor.make_shareable(data) if PING_BG_MODE == :ractor
405
+ game_id = @game_id
406
+ @requester.call(@app, data, mode: PING_BG_MODE, worker: PingWorker)
407
+ .on_progress { |ok| Gemba.log(ok ? :info : :warn) { "RA: ping g=#{game_id} ok=#{ok}" } }
408
+ end
409
+
410
+ # Best-effort unlock submission — fires and forgets, result only logged.
411
+ def submit_unlock(achievement_id, hardcore: false)
412
+ ra_request(r: "awardachievement", u: @username, t: @token,
413
+ a: achievement_id, h: hardcore ? 1 : 0) do |json, ok|
414
+ if ok && json&.dig("Success")
415
+ Gemba.log(:info) { "RA: submitted unlock for achievement #{achievement_id}" }
416
+ else
417
+ Gemba.log(:warn) { "RA: unlock submission failed for #{achievement_id}: #{json&.dig("Error")}" }
418
+ end
419
+ end
420
+ end
421
+
422
+ # POST to dorequest.php via @requester (BackgroundWork in production,
423
+ # a synchronous fake in tests). Calls on_done with (json_or_nil, ok_bool).
424
+ def ra_request(params, &on_done)
425
+ @requester.call(@app, params, mode: :thread) do |t, req_params|
426
+ uri = URI::HTTPS.build(host: RA_HOST, path: RA_PATH)
427
+ http = Net::HTTP.new(uri.host, uri.port)
428
+ http.use_ssl = true
429
+ http.read_timeout = 15
430
+ req = Net::HTTP::Post.new(uri.path)
431
+ safe = req_params.reject { |k, _| [:t, :p, "t", "p"].include?(k) }
432
+ Gemba.log(:info) { "RA request: r=#{params[:r]} #{safe.map { |k, v| "#{k}=#{v}" }.join(" ")}" }
433
+ req.set_form_data(req_params.transform_keys(&:to_s).transform_values(&:to_s))
434
+ resp = http.request(req)
435
+ if resp.is_a?(Net::HTTPSuccess)
436
+ body = resp.body
437
+ Gemba.log(:info) { "RA response: r=#{params[:r]} HTTP #{resp.code} body=#{body.length}b" }
438
+ t.yield([JSON.parse(body), true])
439
+ else
440
+ Gemba.log(:warn) { "RA response: r=#{params[:r]} HTTP #{resp.code} #{resp.message} body=#{resp.body.to_s[0, 200]}" }
441
+ t.yield([nil, false])
442
+ end
443
+ rescue => e
444
+ Gemba.log(:warn) { "RA: request error (#{params[:r]}): #{e.class} #{e.message}" }
445
+ t.yield([nil, false])
446
+ end.on_progress do |result|
447
+ on_done.call(*result) if on_done && result
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end
453
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module Gemba
7
+ module Achievements
8
+ module RetroAchievements
9
+ # Synchronous HTTP requester for CLI use.
10
+ #
11
+ # Implements the same interface as FakeRequester and the DEFAULT_REQUESTER
12
+ # lambda in Backend — call() returns a Result that fires on_progress
13
+ # synchronously — but makes a real blocking Net::HTTP POST instead of
14
+ # delegating to Teek::BackgroundWork.
15
+ #
16
+ # This means CLI commands get their result back in-line with no event loop.
17
+ class CliSyncRequester
18
+ # Mirrors FakeRequester::Result so the calling code (ra_request) is
19
+ # identical regardless of whether it's running in CLI or GUI mode.
20
+ class Result
21
+ def initialize(value)
22
+ @value = value
23
+ end
24
+
25
+ def on_progress(&block)
26
+ block.call(@value)
27
+ self
28
+ end
29
+
30
+ def on_done(&block)
31
+ self
32
+ end
33
+ end
34
+
35
+ # Called by ra_request with the same signature as the DEFAULT_REQUESTER
36
+ # lambda. Ignores the BackgroundWork block (which uses t.yield / Ractor
37
+ # protocol) and performs a direct synchronous HTTP POST instead.
38
+ def call(_app, params, mode: nil, **_opts, &_block)
39
+ Result.new(perform(params))
40
+ end
41
+
42
+ private
43
+
44
+ def perform(params)
45
+ uri = URI::HTTPS.build(host: Backend::RA_HOST, path: Backend::RA_PATH)
46
+ http = Net::HTTP.new(uri.host, uri.port)
47
+ http.use_ssl = true
48
+ http.read_timeout = 15
49
+ req = Net::HTTP::Post.new(uri.path)
50
+ req.set_form_data(params.transform_keys(&:to_s).transform_values(&:to_s))
51
+ resp = http.request(req)
52
+ if resp.is_a?(Net::HTTPSuccess)
53
+ [JSON.parse(resp.body), true]
54
+ else
55
+ [nil, false]
56
+ end
57
+ rescue => e
58
+ $stderr.puts "RA request error (#{params[:r]}): #{e.class} #{e.message}"
59
+ [nil, false]
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end