gemba 0.1.0 → 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 +24 -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 +135 -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 -1515
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ module Achievements
5
+ module RetroAchievements
6
+ # Background worker for the RA session ping heartbeat.
7
+ #
8
+ # Defined as a named class (not a closure) so it is Ractor-safe on
9
+ # Ruby 4+. All state is passed through the data hash; no captured
10
+ # variables.
11
+ class PingWorker
12
+ def call(t, data)
13
+ require "net/http"
14
+ uri = URI::HTTPS.build(host: data[:host], path: data[:path])
15
+ http = Net::HTTP.new(uri.host, uri.port)
16
+ http.use_ssl = true
17
+ http.read_timeout = 10
18
+ req = Net::HTTP::Post.new(uri.path)
19
+ req.set_form_data(data[:params])
20
+ t.yield(http.request(req).is_a?(Net::HTTPSuccess))
21
+ rescue
22
+ t.yield(false)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ module Achievements
5
+ # Build the appropriate backend based on config.
6
+ # Returns NullBackend if RA is disabled.
7
+ # Requires app: (Teek app) for RetroAchievements::Backend's BackgroundWork HTTP calls.
8
+ #
9
+ # @param config [Config]
10
+ # @param app [Teek::App, nil]
11
+ # @return [Backend]
12
+ def self.backend_for(config, app: nil)
13
+ return NullBackend.new unless config.ra_enabled?
14
+ return NullBackend.new unless app
15
+
16
+ RetroAchievements::Backend.new(app: app)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,556 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemba
4
+ # Displays achievements for the currently loaded GBA game.
5
+ #
6
+ # Non-modal window accessible from View > Achievements. Shows a treeview
7
+ # of all achievements with name, points, and earned date. A Sync button
8
+ # pulls the latest earned state from the RA server. Only the currently
9
+ # loaded game has live data; other GBA games in the library show empty.
10
+ class AchievementsWindow
11
+ include ChildWindow
12
+ include Locale::Translatable
13
+
14
+ TOP = '.gemba_achievements'
15
+
16
+ VAR_UNOFFICIAL = '::gemba_ach_unofficial'
17
+
18
+ def initialize(app:, rom_library:, config:, callbacks: {})
19
+ @app = app
20
+ @rom_library = rom_library
21
+ @config = config
22
+ @callbacks = callbacks
23
+ @built = false
24
+ @backend = nil
25
+ @current_rom_id = nil
26
+ @game_entries = []
27
+ @tree_items = []
28
+ @item_descriptions = {}
29
+ @tip_item = nil
30
+ @tip_timer = nil
31
+ @current_list = []
32
+ @sort_col = nil # nil = default order
33
+ @sort_asc = true
34
+ @bulk_syncing = false
35
+ @bulk_cancelled = false
36
+ end
37
+
38
+ # Called by AppController when a ROM loads or the backend is swapped.
39
+ # Updates internal state and refreshes the window if it's visible.
40
+ def update_game(rom_id:, backend:)
41
+ @current_rom_id = rom_id
42
+ @backend = backend
43
+ return unless @built
44
+
45
+ refresh_game_list
46
+ select_game(rom_id)
47
+ populate_tree
48
+ update_title
49
+ update_rich_presence
50
+ end
51
+
52
+ # Called by AppController when on_achievements_changed fires.
53
+ def refresh(backend = @backend)
54
+ @backend = backend
55
+ @display_list = nil # live backend data takes precedence over cached sync
56
+ return unless @built
57
+
58
+ populate_tree
59
+ update_rich_presence
60
+ end
61
+
62
+ def show
63
+ build_ui unless @built
64
+ refresh_game_list
65
+ select_game(@current_rom_id)
66
+ # For non-current games (or when no game is loaded), seed from cache so
67
+ # previously-synced data appears without requiring another network hit.
68
+ if @display_list.nil? && @current_rom_id
69
+ cached = Achievements::Cache.read(@current_rom_id)
70
+ @display_list = cached if cached && @backend&.achievement_list.to_a.empty?
71
+ end
72
+ populate_tree
73
+ refresh_auth_state unless @backend&.authenticated?
74
+ update_title
75
+ show_window(modal: false)
76
+ end
77
+
78
+ def hide
79
+ if @bulk_syncing
80
+ result = @app.command('tk_messageBox',
81
+ parent: TOP,
82
+ title: translate('dialog.cancel_bulk_sync_title'),
83
+ message: translate('dialog.cancel_bulk_sync_msg'),
84
+ type: :yesno,
85
+ icon: :warning)
86
+ return unless result == 'yes'
87
+ @bulk_cancelled = true
88
+ @bulk_syncing = false
89
+ unlock_ui_after_bulk_sync
90
+ end
91
+ hide_window(modal: false)
92
+ end
93
+
94
+ # ModalStack protocol (non-modal — no grab)
95
+ def show_modal(**_args)
96
+ build_ui unless @built
97
+ refresh_game_list
98
+ select_game(@current_rom_id)
99
+ populate_tree
100
+ update_title
101
+ position_near_parent
102
+ @app.command(:wm, 'deiconify', TOP)
103
+ @app.command(:raise, TOP)
104
+ end
105
+
106
+ def withdraw
107
+ @app.command(:wm, 'withdraw', TOP)
108
+ end
109
+
110
+ private
111
+
112
+ def build_ui
113
+ build_toplevel(translate('achievements.title'), geometry: '560x440') do
114
+ build_toolbar
115
+ build_tree
116
+ build_status
117
+ end
118
+ setup_bus_subscriptions
119
+ @built = true
120
+ end
121
+
122
+ def setup_bus_subscriptions
123
+ Gemba.bus.on(:ra_sync_started) do
124
+ @app.command(@sync_btn, :configure, state: :disabled)
125
+ set_status(translate('achievements.sync_pending'))
126
+ end
127
+
128
+ Gemba.bus.on(:ra_sync_done) do |ok:, reason: nil, **|
129
+ # Re-enable only if still authenticated; logout during an in-flight
130
+ # request should leave the button disabled.
131
+ refresh_auth_state
132
+ unless ok
133
+ key = case reason
134
+ when :no_game then 'achievements.sync_no_game'
135
+ when :timeout then 'achievements.sync_timeout'
136
+ else 'achievements.sync_failed'
137
+ end
138
+ set_status(translate(key))
139
+ end
140
+ end
141
+
142
+ Gemba.bus.on(:ra_auth_result) do |status:, **|
143
+ refresh_auth_state
144
+ end
145
+
146
+ Gemba.bus.on(:ra_rich_presence_changed) do |message:, **|
147
+ next unless @rp_lbl
148
+ @app.command(@rp_lbl, :configure, text: message)
149
+ end
150
+ end
151
+
152
+ def refresh_auth_state
153
+ authenticated = @backend&.authenticated?
154
+ state = authenticated ? :normal : :disabled
155
+ @app.command(@sync_btn, :configure, state: state)
156
+ @app.command(@unofficial_check, :configure, state: state) if @unofficial_check
157
+ set_status(translate('achievements.not_logged_in')) unless authenticated
158
+ end
159
+
160
+ def build_toolbar
161
+ f = "#{TOP}.toolbar"
162
+ @app.command('ttk::frame', f, padding: [8, 8, 8, 4])
163
+ @app.command(:pack, f, fill: :x)
164
+
165
+ lbl = "#{f}.lbl"
166
+ @app.command('ttk::label', lbl, text: translate('achievements.game_label'))
167
+ @app.command(:pack, lbl, side: :left, padx: [0, 4])
168
+
169
+ @combo = "#{f}.combo"
170
+ @app.command('ttk::combobox', @combo, state: :readonly, width: 36)
171
+ @app.command(:pack, @combo, side: :left)
172
+ @app.command(:bind, @combo, '<<ComboboxSelected>>', proc { |*| on_game_selected })
173
+
174
+ @sync_btn = "#{f}.sync"
175
+ @app.command('ttk::button', @sync_btn,
176
+ text: translate('achievements.sync'),
177
+ state: @backend&.authenticated? ? :normal : :disabled,
178
+ command: proc { sync })
179
+ @app.command(:pack, @sync_btn, side: :left, padx: [8, 0])
180
+
181
+ @unofficial_check = "#{f}.unofficial"
182
+ @app.set_variable(VAR_UNOFFICIAL, @config.ra_unofficial? ? '1' : '0')
183
+ @app.command('ttk::checkbutton', @unofficial_check,
184
+ text: translate('achievements.include_unofficial'),
185
+ variable: VAR_UNOFFICIAL,
186
+ command: proc { on_unofficial_toggled })
187
+ @app.command(:pack, @unofficial_check, side: :left, padx: [12, 0])
188
+ end
189
+
190
+ def build_tree
191
+ f = "#{TOP}.tf"
192
+ @app.command('ttk::frame', f, padding: [8, 0, 8, 4])
193
+ @app.command(:pack, f, fill: :both, expand: 1)
194
+
195
+ @tree = "#{f}.tree"
196
+ @scrollbar = "#{f}.sb"
197
+
198
+ @app.command('ttk::treeview', @tree,
199
+ columns: Teek.make_list('name', 'points', 'earned'),
200
+ show: :headings,
201
+ height: 16,
202
+ selectmode: :browse)
203
+
204
+ @app.command(@tree, :heading, 'name',
205
+ text: translate('achievements.name_col'), anchor: :w,
206
+ command: proc { sort_tree('name') })
207
+ @app.command(@tree, :heading, 'points',
208
+ text: translate('achievements.points_col'),
209
+ command: proc { sort_tree('points') })
210
+ @app.command(@tree, :heading, 'earned',
211
+ text: translate('achievements.earned_col'),
212
+ command: proc { sort_tree('earned') })
213
+
214
+ @app.command(@tree, :column, 'name', width: 270)
215
+ @app.command(@tree, :column, 'points', width: 55)
216
+ @app.command(@tree, :column, 'earned', width: 145)
217
+
218
+ @app.command('ttk::scrollbar', @scrollbar, orient: :vertical,
219
+ command: "#{@tree} yview")
220
+ @app.command(@tree, :configure, yscrollcommand: "#{@scrollbar} set")
221
+
222
+ @app.command(:pack, @scrollbar, side: :right, fill: :y)
223
+ @app.command(:pack, @tree, side: :left, fill: :both, expand: 1)
224
+
225
+ setup_tree_tooltip
226
+ end
227
+
228
+ def build_status
229
+ bar = "#{TOP}.status_bar"
230
+ @app.command('ttk::frame', bar)
231
+ @app.command(:pack, bar, fill: :x)
232
+
233
+ @status_lbl = "#{bar}.status"
234
+ @app.command('ttk::label', @status_lbl,
235
+ text: translate('achievements.none'),
236
+ anchor: :w, padding: [8, 2, 8, 6])
237
+ @app.command(:pack, @status_lbl, side: :left)
238
+
239
+ @rp_lbl = "#{bar}.rich_presence"
240
+ @app.command('ttk::label', @rp_lbl,
241
+ text: '',
242
+ anchor: :e, padding: [8, 2, 8, 6],
243
+ foreground: '#666666')
244
+ @app.command(:pack, @rp_lbl, side: :right)
245
+ end
246
+
247
+ def refresh_game_list
248
+ entries = @rom_library.all.select { |r| r['platform']&.downcase == 'gba' }
249
+ @game_entries = entries
250
+ titles = entries.map { |r|
251
+ GameIndex.lookup(r['game_code']) || r['title'] || File.basename(r['path'].to_s, '.*')
252
+ }
253
+ @app.command(@combo, :configure, values: Teek.make_list(*titles))
254
+ end
255
+
256
+ def select_game(rom_id)
257
+ idx = @game_entries.index { |r| r['rom_id'] == rom_id }
258
+ return unless idx
259
+
260
+ @app.command(@combo, :current, idx)
261
+ end
262
+
263
+ def on_game_selected
264
+ @display_list = nil
265
+ idx = @app.command(@combo, :current).to_i
266
+ selected_id = @game_entries.dig(idx, 'rom_id')
267
+
268
+ if selected_id == @current_rom_id
269
+ populate_tree
270
+ else
271
+ @display_list = Achievements::Cache.read(selected_id)
272
+ populate_tree
273
+ end
274
+ end
275
+
276
+ def populate_tree
277
+ @current_list = @display_list || @backend&.achievement_list || []
278
+ @sort_col = nil # reset to default order when data changes
279
+ @sort_asc = true
280
+ update_heading_indicators
281
+ render_list(default_sorted(@current_list))
282
+ end
283
+
284
+ def sort_tree(col)
285
+ return if @current_list.empty?
286
+ if @sort_col == col
287
+ @sort_asc = !@sort_asc
288
+ else
289
+ @sort_col = col
290
+ @sort_asc = col != 'earned' # earned defaults desc (newest first)
291
+ end
292
+ update_heading_indicators
293
+ render_list(apply_sort(@current_list))
294
+ end
295
+
296
+ def render_list(list)
297
+ clear_tree
298
+
299
+ if list.empty?
300
+ update_status(0, 0)
301
+ return
302
+ end
303
+
304
+ list.each do |ach|
305
+ earned_str = ach.earned? && ach.earned_at ?
306
+ ach.earned_at.strftime('%Y-%m-%d %H:%M') : ''
307
+ item_id = @app.command(@tree, :insert, '', :end,
308
+ values: Teek.make_list(ach.title, ach.points.to_s, earned_str)).to_s
309
+ @tree_items << item_id
310
+ @item_descriptions[item_id] = ach.description unless ach.description.to_s.empty?
311
+ end
312
+
313
+ earned_count = list.count(&:earned?)
314
+ update_status(earned_count, list.size)
315
+ end
316
+
317
+ def default_sorted(list)
318
+ earned, unearned = list.partition(&:earned?)
319
+ earned.sort_by! { |a| -(a.earned_at&.to_i || 0) }
320
+ unearned.sort_by!(&:title)
321
+ earned + unearned
322
+ end
323
+
324
+ def apply_sort(list)
325
+ case @sort_col
326
+ when 'name'
327
+ sorted = list.sort_by { |a| a.title.downcase }
328
+ @sort_asc ? sorted : sorted.reverse
329
+ when 'points'
330
+ sorted = list.sort_by { |a| a.points }
331
+ @sort_asc ? sorted : sorted.reverse
332
+ when 'earned'
333
+ # Nulls (unearned) always last regardless of direction
334
+ earned, unearned = list.partition(&:earned?)
335
+ sorted_earned = earned.sort_by { |a| a.earned_at.to_i }
336
+ sorted_earned.reverse! unless @sort_asc
337
+ sorted_earned + unearned
338
+ else
339
+ default_sorted(list)
340
+ end
341
+ end
342
+
343
+ SORT_ASC = ' ▲'
344
+ SORT_DESC = ' ▼'
345
+
346
+ def update_heading_indicators
347
+ {
348
+ 'name' => translate('achievements.name_col'),
349
+ 'points' => translate('achievements.points_col'),
350
+ 'earned' => translate('achievements.earned_col'),
351
+ }.each do |col, base_text|
352
+ indicator = if @sort_col == col
353
+ @sort_asc ? SORT_ASC : SORT_DESC
354
+ else
355
+ ''
356
+ end
357
+ @app.command(@tree, :heading, col, text: "#{base_text}#{indicator}")
358
+ end
359
+ end
360
+
361
+ def clear_tree
362
+ return if @tree_items.empty?
363
+
364
+ hide_tip
365
+ @app.command(@tree, :delete, Teek.make_list(*@tree_items))
366
+ @tree_items.clear
367
+ @item_descriptions.clear
368
+ end
369
+
370
+ def update_status(earned, total)
371
+ text = if total == 0
372
+ translate('achievements.none')
373
+ else
374
+ translate('achievements.earned_label', earned: earned, total: total)
375
+ end
376
+ set_status(text)
377
+ end
378
+
379
+ def set_status(text)
380
+ return unless @status_lbl
381
+ @app.command(@status_lbl, :configure, text: text)
382
+ end
383
+
384
+ def update_rich_presence
385
+ return unless @rp_lbl
386
+ msg = @backend&.rich_presence_message.to_s
387
+ @app.command(@rp_lbl, :configure, text: msg)
388
+ end
389
+
390
+ def update_title
391
+ entry = @game_entries.find { |r| r['rom_id'] == @current_rom_id }
392
+ game_title = entry && (GameIndex.lookup(entry['game_code']) || entry['title'])
393
+ window_title = if game_title
394
+ "#{translate('achievements.title')} \u2014 #{game_title}"
395
+ else
396
+ translate('achievements.title')
397
+ end
398
+ @app.command(:wm, 'title', TOP, window_title)
399
+ end
400
+
401
+ def selected_rom_info
402
+ idx = @app.command(@combo, :current).to_i
403
+ entry = @game_entries[idx]
404
+ return nil unless entry
405
+ RomInfo.from_rom(entry)
406
+ end
407
+
408
+ SYNC_TIMEOUT_MS = 60_000
409
+
410
+ def sync
411
+ return unless @backend
412
+ rom_info = selected_rom_info
413
+ return unless rom_info
414
+
415
+ Gemba.log(:info) { "Achievements: sync started for #{rom_info.title} (#{rom_info.rom_id})" }
416
+ Gemba.bus.emit(:ra_sync_started)
417
+
418
+ @sync_timeout = @app.after(SYNC_TIMEOUT_MS) do
419
+ Gemba.log(:warn) { "Achievements: sync timed out after #{SYNC_TIMEOUT_MS / 1000}s" }
420
+ @sync_timeout = nil
421
+ Gemba.bus.emit(:ra_sync_done, ok: false, reason: :timeout)
422
+ end
423
+
424
+ @backend.fetch_for_display(rom_info: rom_info) do |list|
425
+ @app.after_cancel(@sync_timeout) if @sync_timeout
426
+ @sync_timeout = nil
427
+ Gemba.log(list ? :info : :warn) {
428
+ "Achievements: fetch_for_display returned #{list ? "#{list.size} achievements" : 'nil'}"
429
+ }
430
+ Achievements::Cache.write(rom_info.rom_id, list) if list
431
+ @display_list = list
432
+ populate_tree
433
+ Gemba.bus.emit(:ra_sync_done, ok: !list.nil?)
434
+ end
435
+ end
436
+
437
+ # -- Include unofficial toggle ------------------------------------------
438
+
439
+ def on_unofficial_toggled
440
+ return unless @backend&.authenticated?
441
+
442
+ value = @app.get_variable(VAR_UNOFFICIAL) == '1'
443
+ Gemba.bus.emit(:ra_unofficial_changed, value: value)
444
+
445
+ # Bulk re-sync every library game that has an MD5
446
+ games = @rom_library.all.select { |r|
447
+ !r['md5'].to_s.empty?
448
+ }
449
+ return if games.empty?
450
+
451
+ lock_ui_for_bulk_sync
452
+ sync_games_sequentially(games, 0)
453
+ end
454
+
455
+ def lock_ui_for_bulk_sync
456
+ @bulk_syncing = true
457
+ @bulk_cancelled = false
458
+ @app.command(@sync_btn, :configure, state: :disabled)
459
+ @app.command(@unofficial_check, :configure, state: :disabled)
460
+ end
461
+
462
+ def unlock_ui_after_bulk_sync
463
+ @bulk_syncing = false
464
+ refresh_auth_state
465
+ end
466
+
467
+ def sync_games_sequentially(games, idx)
468
+ return if @bulk_cancelled
469
+
470
+ if idx >= games.size
471
+ unlock_ui_after_bulk_sync
472
+ # Refresh display for the currently selected game
473
+ on_game_selected
474
+ set_status(translate('achievements.bulk_sync_done', count: games.size))
475
+ return
476
+ end
477
+
478
+ rom = games[idx]
479
+ rom_info = RomInfo.from_rom(rom)
480
+ title = rom_info.title
481
+ set_status(translate('achievements.bulk_syncing',
482
+ title: title, n: idx + 1, total: games.size))
483
+
484
+ @backend.fetch_for_display(rom_info: rom_info) do |list|
485
+ next if @bulk_cancelled
486
+ Achievements::Cache.write(rom_info.rom_id, list) if list
487
+ sync_games_sequentially(games, idx + 1)
488
+ end
489
+ end
490
+
491
+ # -- Achievement description tooltip ------------------------------------
492
+
493
+ TIP_DELAY_MS = 500
494
+ TIP_PATH = "#{TOP}.__tip"
495
+ TIP_BG = '#FFFFEE'
496
+ TIP_FG = '#333333'
497
+ TIP_BORDER = '#999999'
498
+
499
+ def setup_tree_tooltip
500
+ @app.command(:bind, @tree, '<Motion>', proc {
501
+ # Identify treeview row under the pointer
502
+ px = @app.tcl_eval("winfo pointerx #{@tree}").to_i
503
+ py = @app.tcl_eval("winfo pointery #{@tree}").to_i
504
+ tx = @app.tcl_eval("winfo rootx #{@tree}").to_i
505
+ ty = @app.tcl_eval("winfo rooty #{@tree}").to_i
506
+ item = @app.tcl_eval("#{@tree} identify row #{px - tx} #{py - ty}").strip
507
+
508
+ next if item == @tip_item
509
+ @tip_item = item
510
+ hide_tip
511
+
512
+ next if item.empty?
513
+ desc = @item_descriptions[item]
514
+ next unless desc
515
+
516
+ cancel_tip_timer
517
+ @tip_timer = @app.after(TIP_DELAY_MS) { show_tip(desc) }
518
+ })
519
+
520
+ @app.command(:bind, @tree, '<Leave>', proc {
521
+ @tip_item = nil
522
+ cancel_tip_timer
523
+ hide_tip
524
+ })
525
+ end
526
+
527
+ def show_tip(text)
528
+ hide_tip
529
+ px = @app.tcl_eval("winfo pointerx .").to_i
530
+ py = @app.tcl_eval("winfo pointery .").to_i
531
+ wx = @app.tcl_eval("winfo rootx #{TOP}").to_i
532
+ wy = @app.tcl_eval("winfo rooty #{TOP}").to_i
533
+ rel_x = px - wx + 14
534
+ rel_y = py - wy + 18
535
+
536
+ @app.command(:frame, TIP_PATH, background: TIP_BORDER, borderwidth: 0)
537
+ @app.command(:label, "#{TIP_PATH}.l",
538
+ text: text, background: TIP_BG, foreground: TIP_FG,
539
+ padx: 8, pady: 5, justify: :left, wraplength: 320)
540
+ @app.command(:pack, "#{TIP_PATH}.l", padx: 1, pady: 1)
541
+ @app.command(:place, TIP_PATH, x: rel_x, y: rel_y)
542
+ @app.command(:raise, TIP_PATH)
543
+ end
544
+
545
+ def hide_tip
546
+ cancel_tip_timer
547
+ @app.tcl_eval("catch {destroy #{TIP_PATH}}")
548
+ end
549
+
550
+ def cancel_tip_timer
551
+ return unless @tip_timer
552
+ @app.command(:after, :cancel, @tip_timer)
553
+ @tip_timer = nil
554
+ end
555
+ end
556
+ end