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,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ module Achievements
5
+ # Abstract interface for achievement backends.
6
+ #
7
+ # All methods have no-op defaults so backends only need to override
8
+ # what they support. Concrete backends: NullBackend, FakeBackend,
9
+ # and the future RetroAchievements backend.
10
+ #
11
+ # Thread safety: do_frame is called from the emulation thread (Tk
12
+ # after-loop). on_unlock and on_auth_change callbacks fire on the
13
+ # same thread.
14
+ #
15
+ # Authentication lifecycle (decoupled from the real network):
16
+ # 1. call login(username:, token:) — may be async
17
+ # 2. on_auth_change callback fires with status :ok or :error
18
+ # 3. authenticated? reflects the current state
19
+ # 4. call logout to clear credentials
20
+ module Backend
21
+ # -- Authentication -------------------------------------------------------
22
+
23
+ # Initiate login with username + password (first-time auth).
24
+ # On success the on_auth_change callback fires with :ok and the
25
+ # returned API token is yielded so the caller can persist it:
26
+ # on_auth_change { |status, token_or_error| ... }
27
+ # NullBackend ignores this call.
28
+ #
29
+ # @param username [String]
30
+ # @param password [String]
31
+ def login_with_password(username:, password:); end
32
+
33
+ # Resume a session using a previously stored API token.
34
+ # Called automatically at startup when credentials are already saved.
35
+ #
36
+ # @param username [String]
37
+ # @param token [String]
38
+ def login_with_token(username:, token:); end
39
+
40
+ # Clear authentication state and stored session.
41
+ def logout; end
42
+
43
+ # @return [Boolean] true when authenticated and ready to serve achievements
44
+ def authenticated?
45
+ false
46
+ end
47
+
48
+ # Register a callback invoked when authentication state changes.
49
+ # Fired with `status` (:ok or :error) and an optional `error` message.
50
+ #
51
+ # @yield [Symbol, String, nil] status, error (nil on success)
52
+ def on_auth_change(&block)
53
+ @auth_callbacks ||= []
54
+ @auth_callbacks << block
55
+ end
56
+
57
+ # -- Game lifecycle -------------------------------------------------------
58
+
59
+ # Called once per emulated frame. Evaluate achievement conditions and
60
+ # fire on_unlock callbacks for any newly earned achievements.
61
+ #
62
+ # @param core [Gemba::Core] the live mGBA core
63
+ def do_frame(core); end
64
+
65
+ # Called when a new ROM is loaded. Backend should reset per-game state
66
+ # and re-identify the game.
67
+ #
68
+ # @param core [Gemba::Core]
69
+ # @param rom_path [String, nil] path to the ROM file (used for MD5 hashing by network backends)
70
+ def load_game(core, rom_path = nil, md5 = nil); end
71
+
72
+ # Called when the ROM is unloaded / emulator stops.
73
+ def unload_game; end
74
+
75
+ # Called when a save state is loaded. Memory jumped to an arbitrary saved
76
+ # state; all achievements must restart their priming/waiting sequence.
77
+ def reset_runtime; end
78
+
79
+ # -- Rich Presence --------------------------------------------------------
80
+
81
+ # Current Rich Presence display string for the active game, or nil if
82
+ # not loaded / not supported. Updated by do_frame in real backends.
83
+ def rich_presence_message
84
+ nil
85
+ end
86
+
87
+ # Enable or disable Rich Presence evaluation for the current game.
88
+ # Pushed from AppController when per-game config is resolved at ROM load.
89
+ def rich_presence_enabled=(val); end
90
+
91
+ # Register a callback fired when the Rich Presence string changes.
92
+ # Called with the new message string.
93
+ #
94
+ # @yield [String] the new rich presence message
95
+ def on_rich_presence_changed(&block)
96
+ @rp_callbacks ||= []
97
+ @rp_callbacks << block
98
+ end
99
+
100
+ # -- Achievement list -----------------------------------------------------
101
+
102
+ # Register a callback invoked when an achievement is unlocked.
103
+ # Multiple callbacks can be registered; all are called in order.
104
+ #
105
+ # @yield [Achievement] the newly earned achievement
106
+ def on_unlock(&block)
107
+ @unlock_callbacks ||= []
108
+ @unlock_callbacks << block
109
+ end
110
+
111
+ # @return [Array<Achievement>] all achievements for the current game
112
+ def achievement_list
113
+ []
114
+ end
115
+
116
+ # @return [Integer] number of earned achievements
117
+ def earned_count
118
+ achievement_list.count(&:earned?)
119
+ end
120
+
121
+ # @return [Integer] total achievements for the current game
122
+ def total_count
123
+ achievement_list.size
124
+ end
125
+
126
+ # Verify the stored token is still valid. Result fires on_auth_change.
127
+ # Used by the "Verify Token" button in settings.
128
+ def token_test; end
129
+
130
+ # @return [Boolean] true if this backend is active / enabled
131
+ def enabled?
132
+ false
133
+ end
134
+
135
+ # Fetch already-earned achievements from the server and merge them into
136
+ # the local earned state so the UI reflects prior progress. No-op for
137
+ # backends that have no server. Fires on_achievements_changed on
138
+ # completion.
139
+ def sync_unlocks; end
140
+
141
+ # Fetch the full achievement list for a given ROM (by RomInfo) purely for
142
+ # display — does not affect live game state. Calls the block with
143
+ # Array<Achievement> on success, or nil on failure/unsupported.
144
+ # No-op (calls block with nil) for backends without a server.
145
+ #
146
+ # @param rom_info [RomInfo]
147
+ # @yield [Array<Achievement>, nil]
148
+ def fetch_for_display(rom_info:, &callback)
149
+ callback&.call(nil)
150
+ end
151
+
152
+ # Set whether unofficial (Flags=5) achievements are included in
153
+ # display syncs and live evaluation. No-op for backends that don't
154
+ # distinguish official vs unofficial.
155
+ def include_unofficial=(val); end
156
+
157
+ # Register a callback invoked when the achievement list changes in bulk
158
+ # (e.g. after a game loads or sync_unlocks completes). Use this to
159
+ # refresh list UI without wiring individual on_unlock callbacks.
160
+ #
161
+ # @yield called with no arguments
162
+ def on_achievements_changed(&block)
163
+ @achievements_changed_callbacks ||= []
164
+ @achievements_changed_callbacks << block
165
+ end
166
+
167
+ private
168
+
169
+ def fire_unlock(achievement)
170
+ @unlock_callbacks&.each { |cb| cb.call(achievement) }
171
+ end
172
+
173
+ def fire_auth_change(status, error = nil)
174
+ @auth_callbacks&.each { |cb| cb.call(status, error) }
175
+ end
176
+
177
+ def fire_achievements_changed
178
+ @achievements_changed_callbacks&.each(&:call)
179
+ end
180
+
181
+ def fire_rich_presence_changed(message)
182
+ @rp_callbacks&.each { |cb| cb.call(message) }
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+
7
+ module Gemba
8
+ module Achievements
9
+ # Persistent per-ROM achievement cache.
10
+ #
11
+ # Stores the full achievement list (definitions + earned status) for each
12
+ # ROM as a JSON file under Config.achievements_cache_dir/<rom_id>.json.
13
+ # Written after every successful sync; read on demand by the window when
14
+ # a game is selected that isn't currently loaded in the emulator.
15
+ #
16
+ # Format:
17
+ # { "synced_at": "<iso8601>",
18
+ # "achievements": [ { "id":, "title":, "description":, "points":,
19
+ # "earned_at": "<iso8601>|null" }, … ] }
20
+ module Cache
21
+ def self.write(rom_id, achievements)
22
+ path = cache_path(rom_id)
23
+ FileUtils.mkdir_p(File.dirname(path))
24
+ data = {
25
+ 'synced_at' => Time.now.utc.iso8601,
26
+ 'achievements' => achievements.map { |a|
27
+ {
28
+ 'id' => a.id,
29
+ 'title' => a.title,
30
+ 'description' => a.description,
31
+ 'points' => a.points,
32
+ 'earned_at' => a.earned_at&.utc&.iso8601,
33
+ }
34
+ },
35
+ }
36
+ File.write(path, JSON.generate(data))
37
+ Gemba.log(:info) { "Achievements cache written: #{rom_id} (#{achievements.size} achievements)" }
38
+ rescue => e
39
+ Gemba.log(:warn) { "Achievements cache write failed for #{rom_id}: #{e.message}" }
40
+ end
41
+
42
+ # @return [Array<Achievement>, nil] cached list, or nil if no cache exists
43
+ def self.read(rom_id)
44
+ path = cache_path(rom_id)
45
+ return nil unless File.exist?(path)
46
+
47
+ data = JSON.parse(File.read(path))
48
+ list = (data['achievements'] || []).map do |a|
49
+ Achievement.new(
50
+ id: a['id'].to_s,
51
+ title: a['title'].to_s,
52
+ description: a['description'].to_s,
53
+ points: a['points'].to_i,
54
+ earned_at: a['earned_at'] ? Time.iso8601(a['earned_at']) : nil,
55
+ )
56
+ end
57
+ Gemba.log(:info) { "Achievements cache read: #{rom_id} (#{list.size} achievements, synced #{data['synced_at']})" }
58
+ list
59
+ rescue => e
60
+ Gemba.log(:warn) { "Achievements cache read failed for #{rom_id}: #{e.message}" }
61
+ nil
62
+ end
63
+
64
+ def self.cache_path(rom_id)
65
+ File.join(Config.achievements_cache_dir, "#{rom_id}.json")
66
+ end
67
+ private_class_method :cache_path
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ module Achievements
5
+ # Presents credential state for the RetroAchievements settings UI.
6
+ # Read-only view of state — never writes to disk or config.
7
+ #
8
+ # Initialized from persisted config. Mutated by:
9
+ # - UI interactions (checkbox, keystrokes) via setters
10
+ # - Backend auth results via :ra_auth_result bus events
11
+ #
12
+ # Emits :credentials_changed on the bus whenever state changes.
13
+ # SystemTab listens and calls apply_presenter_state to refresh widgets.
14
+ #
15
+ # Call dispose when discarding the presenter to remove the bus subscription.
16
+ class CredentialsPresenter
17
+ include BusEmitter
18
+
19
+ def initialize(config)
20
+ @enabled = config.ra_enabled?
21
+ @username = config.ra_username.to_s
22
+ @token = config.ra_token.to_s
23
+ @password = ''
24
+ @feedback_override = nil
25
+
26
+ @auth_handler = ->(status:, token: nil, message: nil) {
27
+ handle_auth_result(status, token, message)
28
+ }
29
+ Gemba.bus.on(:ra_auth_result, &@auth_handler)
30
+ end
31
+
32
+ # Remove bus subscription. Call before discarding the presenter.
33
+ def dispose
34
+ Gemba.bus.off(:ra_auth_result, @auth_handler)
35
+ end
36
+
37
+ # -- UI mutations ---------------------------------------------------------
38
+
39
+ def enabled=(val)
40
+ @enabled = val ? true : false
41
+ emit(:credentials_changed)
42
+ end
43
+
44
+ def username=(val)
45
+ @username = val.to_s
46
+ emit(:credentials_changed)
47
+ end
48
+
49
+ def password=(val)
50
+ @password = val.to_s
51
+ emit(:credentials_changed)
52
+ end
53
+
54
+ # Transient feedback (e.g. "Connection OK ✓") that disappears after a delay.
55
+ # Caller is responsible for scheduling clear_transient via Tk after.
56
+ def show_transient(key, **kwargs)
57
+ @feedback_override = { key: key, **kwargs }
58
+ emit(:credentials_changed)
59
+ end
60
+
61
+ def clear_transient
62
+ @feedback_override = nil
63
+ emit(:credentials_changed)
64
+ end
65
+
66
+ # -- Read-only accessors --------------------------------------------------
67
+
68
+ attr_reader :username, :password, :token
69
+ def enabled? = @enabled
70
+ def logged_in? = !@token.strip.empty?
71
+
72
+ # -- Widget state queries -------------------------------------------------
73
+
74
+ def fields_state
75
+ return :disabled unless @enabled
76
+ return :readonly if logged_in?
77
+ :normal
78
+ end
79
+
80
+ def login_button_state
81
+ return :disabled unless @enabled
82
+ return :disabled if logged_in?
83
+ fields_filled? ? :normal : :disabled
84
+ end
85
+
86
+ def verify_button_state
87
+ (@enabled && logged_in?) ? :normal : :disabled
88
+ end
89
+
90
+ def logout_button_state
91
+ (@enabled && logged_in?) ? :normal : :disabled
92
+ end
93
+
94
+ def reset_button_state
95
+ (@enabled && logged_in?) ? :normal : :disabled
96
+ end
97
+
98
+ # Feedback descriptor. key drives locale lookup in SystemTab.
99
+ # :empty → blank label
100
+ # :not_logged_in → "Not logged in"
101
+ # :logged_in_as → "Logged in as {username}" (also carries username:)
102
+ # :test_ok → "Connection OK ✓"
103
+ # :error → error text (carries message:)
104
+ def feedback
105
+ return @feedback_override if @feedback_override
106
+ return { key: :empty } unless @enabled
107
+ return { key: :logged_in_as, username: @username } if logged_in?
108
+ { key: :not_logged_in }
109
+ end
110
+
111
+ private
112
+
113
+ def handle_auth_result(status, token, message)
114
+ case status
115
+ when :ok
116
+ if token
117
+ # Login success — store token, clear password
118
+ @token = token.to_s
119
+ @password = ''
120
+ @feedback_override = nil
121
+ else
122
+ # Ping success — show transient then let SystemTab schedule clear
123
+ @feedback_override = { key: :test_ok }
124
+ emit(:ra_token_test_ok)
125
+ end
126
+ when :error
127
+ @feedback_override = { key: :error, message: message.to_s }
128
+ when :logout
129
+ @token = ''
130
+ @password = ''
131
+ @feedback_override = nil
132
+ # username intentionally kept so user can re-enter password quickly
133
+ end
134
+ emit(:credentials_changed)
135
+ end
136
+
137
+ def fields_filled?
138
+ !@username.strip.empty? && !@password.strip.empty?
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ module Achievements
5
+ # Pure-Ruby achievement backend for development and testing.
6
+ #
7
+ # Achievements are defined programmatically with a condition block
8
+ # that receives a memory-read helper. No HTTP, no hashing, no server.
9
+ #
10
+ # Authentication behaviour:
11
+ # - By default any non-empty credentials succeed immediately.
12
+ # - Pass `valid_username:` and `valid_token:` to restrict: only that
13
+ # exact pair succeeds; anything else fails. Useful in tests that
14
+ # verify the "bad credentials" error path.
15
+ #
16
+ # Used in two ways:
17
+ # 1. Automated tests — add achievements, step frames, assert unlocks
18
+ # 2. Integration dev — iterate on UI (toasts, list) without rcheevos
19
+ #
20
+ # @example Basic usage
21
+ # backend = FakeBackend.new
22
+ # backend.add_achievement(id: 'btn_b', title: 'Press B',
23
+ # description: 'Press the B button',
24
+ # points: 5) do |mem|
25
+ # mem.call(0x02000000) == 0x42
26
+ # end
27
+ # backend.on_unlock { |ach| puts "Unlocked: #{ach.title}" }
28
+ # backend.do_frame(core) # call each frame
29
+ #
30
+ # @example Restricted credentials (for testing failure path)
31
+ # backend = FakeBackend.new(valid_username: 'alice', valid_token: 'secret')
32
+ # backend.login(username: 'bob', token: 'wrong') # → fires :error
33
+ # backend.login(username: 'alice', token: 'secret') # → fires :ok
34
+ class FakeBackend
35
+ include Backend
36
+
37
+ # @param valid_username [String, nil] when set, only this username passes
38
+ # @param valid_token [String, nil] when set, only this token passes
39
+ def initialize(valid_username: nil, valid_token: nil)
40
+ @definitions = {} # id → { achievement:, condition: }
41
+ @earned = {} # id → Achievement (earned copy)
42
+ @prev_state = {} # id → bool (condition result last frame)
43
+ @valid_username = valid_username
44
+ @valid_token = valid_token
45
+ @authenticated = false
46
+ @rich_presence_message = nil
47
+ @rich_presence_block = nil
48
+ end
49
+
50
+ # -- Authentication -------------------------------------------------------
51
+
52
+ # Resolves immediately. Succeeds if credentials are non-empty and match
53
+ # the configured valid pair (or any non-empty creds if none configured).
54
+ # On success fires on_auth_change(:ok, fake_token) where fake_token is
55
+ # a deterministic stand-in so callers can exercise the token-persist path.
56
+ def login_with_password(username:, password:)
57
+ ok = !username.to_s.empty? && !password.to_s.empty? &&
58
+ (@valid_username.nil? || username == @valid_username) &&
59
+ (@valid_token.nil? || password == @valid_token)
60
+
61
+ if ok
62
+ @authenticated = true
63
+ fire_auth_change(:ok, "fake_token_for_#{username}")
64
+ else
65
+ @authenticated = false
66
+ fire_auth_change(:error, 'Invalid credentials (fake backend)')
67
+ end
68
+ end
69
+
70
+ def login_with_token(username:, token:)
71
+ ok = !username.to_s.empty? && !token.to_s.empty? &&
72
+ (@valid_username.nil? || username == @valid_username) &&
73
+ (@valid_token.nil? || token == @valid_token)
74
+
75
+ if ok
76
+ @authenticated = true
77
+ fire_auth_change(:ok, token)
78
+ else
79
+ @authenticated = false
80
+ fire_auth_change(:error, 'Invalid credentials (fake backend)')
81
+ end
82
+ end
83
+
84
+ def logout
85
+ @authenticated = false
86
+ fire_auth_change(:logout)
87
+ end
88
+
89
+ def token_test
90
+ if @authenticated
91
+ fire_auth_change(:ok, nil)
92
+ else
93
+ fire_auth_change(:error, 'Not authenticated (fake backend)')
94
+ end
95
+ end
96
+
97
+ def authenticated?
98
+ @authenticated
99
+ end
100
+
101
+ # Define an achievement. The block receives a read_mem callable
102
+ # and must return truthy when the unlock condition is met.
103
+ #
104
+ # @param id [String] unique identifier
105
+ # @param title [String]
106
+ # @param description [String]
107
+ # @param points [Integer]
108
+ # @yield [read_mem] called each frame; read_mem is ->(address) { Integer }
109
+ # @yieldreturn [Boolean] true when condition is satisfied
110
+ def add_achievement(id:, title:, description:, points: 0, &condition)
111
+ raise ArgumentError, "condition block required" unless condition
112
+ ach = Achievement.new(id: id, title: title,
113
+ description: description,
114
+ points: points, earned_at: nil)
115
+ @definitions[id] = { achievement: ach, condition: condition }
116
+ @prev_state[id] = false
117
+ end
118
+
119
+ # Evaluate all unearned achievements against current memory state.
120
+ # Fires on_unlock callbacks for newly met conditions (rising edge).
121
+ # Also evaluates the rich presence block if one is set.
122
+ #
123
+ # @param core [Gemba::Core]
124
+ def do_frame(core)
125
+ read_mem = ->(addr) { core.bus_read8(addr) }
126
+ @definitions.each do |id, defn|
127
+ next if @earned.key?(id)
128
+
129
+ current = defn[:condition].call(read_mem) ? true : false
130
+ if current && !@prev_state[id]
131
+ earned = defn[:achievement].earn
132
+ @earned[id] = earned
133
+ fire_unlock(earned)
134
+ end
135
+ @prev_state[id] = current
136
+ end
137
+
138
+ @rich_presence_message = @rich_presence_block.call if @rich_presence_block
139
+ end
140
+
141
+ def load_game(_core, rom_path = nil, md5 = nil)
142
+ @rich_presence_message = nil
143
+ @rich_presence_block = nil
144
+ reset_earned
145
+ end
146
+
147
+ def unload_game
148
+ reset_earned
149
+ end
150
+
151
+ # @return [Array<Achievement>] all achievements, earned ones updated
152
+ def achievement_list
153
+ @definitions.map do |id, defn|
154
+ @earned[id] || defn[:achievement]
155
+ end
156
+ end
157
+
158
+ def enabled?
159
+ true
160
+ end
161
+
162
+ # Clear earned state (for test reuse).
163
+ def reset_earned
164
+ @earned = {}
165
+ @prev_state = @prev_state.transform_values { false }
166
+ end
167
+
168
+ # Set a Rich Presence message for testing.
169
+ #
170
+ # Pass a static string to return a fixed message:
171
+ # backend.set_rich_presence("Playing World 1-1")
172
+ #
173
+ # Pass a block for dynamic messages evaluated each do_frame:
174
+ # backend.set_rich_presence { "Frame #{frame_count}" }
175
+ #
176
+ # Call with no arguments to clear.
177
+ def set_rich_presence(string = nil, &block)
178
+ @rich_presence_block = block || (string ? -> { string } : nil)
179
+ @rich_presence_message = string unless block
180
+ end
181
+
182
+ def rich_presence_message
183
+ @rich_presence_message
184
+ end
185
+
186
+ # Configure what fetch_for_display returns. Pass an Array (returned for
187
+ # every rom_info) or a block that receives rom_info and returns an Array
188
+ # or nil. Without calling this, fetch_for_display always calls back with nil.
189
+ #
190
+ # @example Always return a fixed list
191
+ # backend.stub_fetch_for_display([ach1, ach2])
192
+ #
193
+ # @example Vary by rom_info
194
+ # backend.stub_fetch_for_display { |rom_info| rom_info.rom_id == 'X' ? [ach1] : [] }
195
+ def stub_fetch_for_display(list = nil, &block)
196
+ @fetch_display_stub = block || ->(_) { list }
197
+ end
198
+
199
+ def fetch_for_display(rom_info:, &callback)
200
+ result = @fetch_display_stub ? @fetch_display_stub.call(rom_info) : nil
201
+ callback&.call(result)
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ module Achievements
5
+ # No-op backend used when RetroAchievements is disabled.
6
+ # All methods are inherited no-ops from Backend.
7
+ class NullBackend
8
+ include Backend
9
+ end
10
+ end
11
+ end