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,691 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Gemba
6
+ # Non-interactive GBA replay viewer with SDL2 video/audio.
7
+ #
8
+ # Opens a window to play back a .gir input recording with full audio and
9
+ # video rendering. No game input is accepted — the bitmasks come from the
10
+ # .gir file. Supports pause, fast-forward, fullscreen, and screenshot
11
+ # hotkeys. Pauses on the last frame when the replay ends.
12
+ #
13
+ # @example
14
+ # Gemba::ReplayPlayer.new("session.gir").run
15
+ class ReplayPlayer
16
+ include Gemba
17
+ include Locale::Translatable
18
+
19
+ DEFAULT_SCALE = 3
20
+
21
+ AUDIO_FREQ = 44100
22
+ MAX_DELTA = 0.005
23
+ FF_MAX_FRAMES = 10
24
+ FADE_IN_FRAMES = (AUDIO_FREQ * 0.02).to_i
25
+ EVENT_LOOP_FAST_MS = 1
26
+ EVENT_LOOP_IDLE_MS = 50
27
+
28
+ attr_reader :app, :viewport
29
+ attr_writer :running
30
+
31
+ # @return [Boolean] true once the core is loaded and ready
32
+ def ready? = !!@core
33
+
34
+ # @return [Boolean] true when paused
35
+ def paused? = @paused
36
+
37
+ # @return [Boolean] true when replay has finished all frames
38
+ def replay_ended? = @replay_ended
39
+
40
+ # @return [Integer] current frame index during replay
41
+ def frame_index = @frame_index || 0
42
+
43
+ # Pause the replay (no-op if already paused).
44
+ def pause
45
+ toggle_pause unless @paused
46
+ end
47
+
48
+ # Resume the replay (no-op if not paused).
49
+ def resume
50
+ toggle_pause if @paused
51
+ end
52
+
53
+ def initialize(gir_path = nil, sound: true, fullscreen: false, app: nil, callbacks: {})
54
+ @gir_path = gir_path
55
+ @sound = sound
56
+ @fullscreen = fullscreen
57
+ @running = true
58
+ @paused = false
59
+ @fast_forward = false
60
+ @replay_ended = false
61
+ @cleaned_up = false
62
+ @sdl2_ready = false
63
+ @audio_fade_in = 0
64
+ @fps_count = 0
65
+ @fps_time = 0.0
66
+
67
+ @config = Gemba.user_config
68
+ @scale = @config.scale
69
+ @volume = @config.volume / 100.0
70
+ @muted = @config.muted?
71
+ @turbo_speed = @config.turbo_speed
72
+ @turbo_volume = @config.turbo_volume_pct / 100.0
73
+ @keep_aspect_ratio = @config.keep_aspect_ratio?
74
+ @show_fps = @config.show_fps?
75
+ @pixel_filter = @config.pixel_filter
76
+ @integer_scale = @config.integer_scale?
77
+ @hotkeys = HotkeyMap.new(@config)
78
+ @platform = Platform.default
79
+
80
+ if app
81
+ # Child mode: use parent's app, build in a Toplevel
82
+ @app = app
83
+ @standalone = false
84
+ @callbacks = callbacks
85
+ @top = '.replay_player'
86
+ build_child_toplevel
87
+ else
88
+ # Standalone mode: own App (current behavior)
89
+ @app = Teek::App.new
90
+ @app.interp.thread_timer_ms = EVENT_LOOP_IDLE_MS
91
+ @app.show
92
+ @standalone = true
93
+ @callbacks = {}
94
+ @top = '.'
95
+
96
+ win_w = @platform.width * @scale
97
+ win_h = @platform.height * @scale
98
+ @app.set_window_title("[REPLAY]")
99
+ @app.set_window_geometry("#{win_w}x#{win_h}")
100
+
101
+ build_menu
102
+ end
103
+ end
104
+
105
+ def run
106
+ @app.after(1) { load_replay(@gir_path) }
107
+ @app.mainloop
108
+ ensure
109
+ cleanup
110
+ end
111
+
112
+ # Show the child window (child mode only).
113
+ def show
114
+ return if @standalone
115
+
116
+ @app.command(:wm, 'deiconify', @top)
117
+ @app.command(:raise, @top)
118
+ start_replay_or_idle
119
+ end
120
+
121
+ # Hide the child window (child mode only).
122
+ def hide
123
+ return if @standalone
124
+
125
+ cleanup_replay
126
+ @app.command(:wm, 'withdraw', @top)
127
+ @callbacks[:on_close]&.call
128
+ end
129
+
130
+ # ModalStack protocol
131
+ def show_modal(**)
132
+ return if @standalone
133
+
134
+ @app.command(:wm, 'deiconify', @top)
135
+ @app.command(:raise, @top)
136
+ start_replay_or_idle
137
+ end
138
+
139
+ def withdraw
140
+ return if @standalone
141
+
142
+ cleanup_replay
143
+ @app.command(:wm, 'withdraw', @top)
144
+ end
145
+
146
+ private
147
+
148
+ def frame_period = 1.0 / @platform.fps
149
+ def audio_buf_capacity = (AUDIO_FREQ / @platform.fps * 6).to_i
150
+
151
+ def recreate_texture
152
+ @texture&.destroy
153
+ @texture = @viewport.renderer.create_texture(@platform.width, @platform.height, :streaming)
154
+ @texture.scale_mode = @pixel_filter.to_sym
155
+ end
156
+
157
+ def start_replay_or_idle
158
+ if @gir_path && !@animate_started
159
+ load_replay(@gir_path)
160
+ elsif !@gir_path && !@animate_started
161
+ init_sdl2 unless @sdl2_ready
162
+ animate
163
+ @animate_started = true
164
+ end
165
+ end
166
+
167
+ # ── Child window setup ──────────────────────────────────────────
168
+
169
+ def build_child_toplevel
170
+ @app.command(:toplevel, @top)
171
+ @app.command(:wm, 'title', @top, translate('replay.replay_player'))
172
+ win_w = @platform.width * @scale
173
+ win_h = @platform.height * @scale
174
+ @app.command(:wm, 'geometry', @top, "#{win_w}x#{win_h}")
175
+ @app.command(:wm, 'transient', @top, '.')
176
+ on_close = @callbacks[:on_dismiss] || proc { hide }
177
+ @app.command(:wm, 'protocol', @top, 'WM_DELETE_WINDOW', on_close)
178
+ build_menu
179
+ @app.command(:wm, 'withdraw', @top)
180
+ end
181
+
182
+ # Stop core and audio without destroying the viewport (child mode).
183
+ def cleanup_replay
184
+ @stream&.pause unless @stream&.destroyed?
185
+ @stream&.clear unless @stream&.destroyed?
186
+ @core&.destroy unless @core&.destroyed?
187
+ @core = nil
188
+ @replay_ended = false
189
+ @paused = false
190
+ @animate_started = false
191
+ @running = true
192
+ end
193
+
194
+ # ── Load / switch replay ──────────────────────────────────────────
195
+
196
+ def load_replay(gir_path)
197
+ init_sdl2 unless @sdl2_ready
198
+
199
+ @replayer = InputReplayer.new(gir_path)
200
+
201
+ rom_path = @replayer.rom_path
202
+ unless rom_path && File.exist?(rom_path)
203
+ show_error("ROM not found",
204
+ "The ROM referenced by this .gir no longer exists:\n#{rom_path || '(none)'}")
205
+ return
206
+ end
207
+
208
+ @core&.destroy unless @core&.destroyed?
209
+ @core = Core.new(rom_path, @config.saves_dir)
210
+ new_platform = Platform.for(@core)
211
+ if new_platform != @platform
212
+ @platform = new_platform
213
+ recreate_texture
214
+ end
215
+ @replayer.validate!(@core)
216
+ @core.load_state_from_file(@replayer.anchor_state_path)
217
+
218
+ @frame_index = 0
219
+ @total_frames = @replayer.frame_count
220
+ @replay_ended = false
221
+ @paused = false
222
+ @fast_forward = false
223
+ @hud.set_ff_label(nil)
224
+
225
+ Gemba.log(:info) { "Replay started: #{gir_path} (#{@total_frames} frames)" }
226
+ if @standalone
227
+ @app.set_window_title("[REPLAY] #{@core.title}")
228
+ else
229
+ @app.command(:wm, 'title', @top, "[REPLAY] #{@core.title}")
230
+ end
231
+ @stream.clear unless @stream.destroyed?
232
+ @stream.resume unless @stream.destroyed?
233
+
234
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
235
+ @next_frame = now
236
+ @fps_count = 0
237
+ @fps_time = now
238
+
239
+ set_event_loop_speed(:fast)
240
+ animate unless @animate_started
241
+ @animate_started = true
242
+ rescue InputReplayer::ChecksumMismatch => e
243
+ show_error("Checksum Mismatch", e.message)
244
+ rescue => e
245
+ show_error("Replay Error", "#{e.class}: #{e.message}")
246
+ end
247
+
248
+ def switch_replay(gir_path)
249
+ @gir_path = gir_path
250
+ load_replay(gir_path)
251
+ end
252
+
253
+ # ── SDL2 init ────────────────────────────────────────────────────
254
+
255
+ def init_sdl2
256
+ return if @sdl2_ready
257
+
258
+ @app.command('tk', 'busy', @top)
259
+
260
+ win_w = @platform.width * @scale
261
+ win_h = @platform.height * @scale
262
+
263
+ @top_frame = child_path('replay_frame') unless @standalone
264
+ if @top_frame
265
+ @app.command('ttk::frame', @top_frame)
266
+ @app.command(:pack, @top_frame, fill: :both, expand: true, in: @top)
267
+ end
268
+
269
+ parent_opts = @top_frame ? { parent: @top_frame } : {}
270
+ @viewport = Teek::SDL2::Viewport.new(@app, width: win_w, height: win_h, vsync: false, **parent_opts)
271
+ @viewport.pack(fill: :both, expand: true)
272
+
273
+ @texture = @viewport.renderer.create_texture(@platform.width, @platform.height, :streaming)
274
+ @texture.scale_mode = @pixel_filter.to_sym
275
+
276
+ font_path = File.join(ASSETS_DIR, 'JetBrainsMonoNL-Regular.ttf')
277
+ @overlay_font = File.exist?(font_path) ? @viewport.renderer.load_font(font_path, 14) : nil
278
+
279
+ toast_font_path = File.join(ASSETS_DIR, 'ark-pixel-12px-monospaced-ja.ttf')
280
+ toast_font = File.exist?(toast_font_path) ? @viewport.renderer.load_font(toast_font_path, 12) : @overlay_font
281
+
282
+ @toast = ToastOverlay.new(
283
+ renderer: @viewport.renderer,
284
+ font: toast_font || @overlay_font,
285
+ duration: @config.toast_duration
286
+ )
287
+
288
+ inverse_blend = Teek::SDL2.compose_blend_mode(
289
+ :one_minus_dst_color, :one_minus_src_alpha, :add,
290
+ :zero, :one, :add
291
+ )
292
+ @hud = OverlayRenderer.new(font: @overlay_font, blend_mode: inverse_blend)
293
+
294
+ if @sound && Teek::SDL2::AudioStream.available?
295
+ @stream = Teek::SDL2::AudioStream.new(
296
+ frequency: AUDIO_FREQ,
297
+ format: :s16,
298
+ channels: 2
299
+ )
300
+ else
301
+ @stream = Teek::SDL2::NullAudioStream.new
302
+ end
303
+
304
+ setup_input
305
+
306
+ @app.command(:wm, 'attributes', @top, '-fullscreen', 1) if @fullscreen
307
+ @sdl2_ready = true
308
+
309
+ @app.command('tk', 'busy', 'forget', @top)
310
+ @app.tcl_eval("focus -force #{@viewport.frame.path}")
311
+ @app.update
312
+ rescue => e
313
+ Gemba.log(:error) { "init_sdl2 failed: #{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}" }
314
+ $stderr.puts "FATAL: init_sdl2 failed: #{e.class}: #{e.message}"
315
+ $stderr.puts e.backtrace.first(10).map { |l| " #{l}" }.join("\n")
316
+ @app.command('tk', 'busy', 'forget', @top) rescue nil
317
+ @running = false
318
+ end
319
+
320
+ # ── Input (hotkey-only, no game buttons) ──────────────────────────
321
+
322
+ def setup_input
323
+ @viewport.bind('KeyPress', :keysym, '%s') do |k, state_str|
324
+ if k == 'Escape'
325
+ if @fullscreen
326
+ toggle_fullscreen
327
+ elsif @standalone
328
+ @running = false
329
+ else
330
+ hide
331
+ end
332
+ else
333
+ mods = HotkeyMap.modifiers_from_state(state_str.to_i)
334
+ case @hotkeys.action_for(k, modifiers: mods)
335
+ when :quit then @standalone ? (@running = false) : hide
336
+ when :pause then toggle_pause
337
+ when :fast_forward then toggle_fast_forward
338
+ when :fullscreen then toggle_fullscreen
339
+ when :screenshot then take_screenshot
340
+ when :show_fps then toggle_show_fps
341
+ end
342
+ end
343
+ end
344
+
345
+ @app.command(:bind, @viewport.frame.path, '<Alt-Return>', proc { toggle_fullscreen })
346
+ end
347
+
348
+ # ── Menu (minimal) ────────────────────────────────────────────────
349
+
350
+ # Build a Tk child widget path under @top.
351
+ # Tk root '.' uses '.child', Toplevels use '.top.child'.
352
+ def child_path(name)
353
+ @top == '.' ? ".#{name}" : "#{@top}.#{name}"
354
+ end
355
+
356
+ def build_menu
357
+ menubar = child_path('menubar')
358
+ @app.command(:menu, menubar)
359
+ @app.command(@top, :configure, menu: menubar)
360
+
361
+ @app.command(:menu, "#{menubar}.file", tearoff: 0)
362
+ @app.command(menubar, :add, :cascade, label: translate('menu.file'), menu: "#{menubar}.file")
363
+
364
+ @app.command("#{menubar}.file", :add, :command,
365
+ label: translate('replay.open_recording'),
366
+ accelerator: 'Cmd+O',
367
+ command: proc { open_replay_dialog })
368
+ @app.command("#{menubar}.file", :add, :separator)
369
+ @app.command("#{menubar}.file", :add, :command,
370
+ label: translate('menu.quit'),
371
+ accelerator: 'Cmd+Q',
372
+ command: proc { @running = false })
373
+
374
+ @app.command(:bind, @top, '<Command-o>', proc { open_replay_dialog })
375
+ end
376
+
377
+ def open_replay_dialog
378
+ filetypes = '{{Input Recordings} {.gir}} {{All Files} {*}}'
379
+ title = translate('replay.open_recording').delete("\u2026")
380
+ initial_dir = @config.recordings_dir
381
+ cmd = "tk_getOpenFile -title {#{title}} -filetypes {#{filetypes}}"
382
+ cmd << " -initialdir {#{initial_dir}}" if initial_dir && File.directory?(initial_dir)
383
+ path = @app.tcl_eval(cmd)
384
+ return if path.empty?
385
+
386
+ switch_replay(path)
387
+ end
388
+
389
+ # ── Frame loop ────────────────────────────────────────────────────
390
+
391
+ def animate
392
+ if @running
393
+ tick
394
+ delay = (@core && !@paused) ? 1 : 100
395
+ @app.after(delay) { animate }
396
+ else
397
+ if @standalone
398
+ cleanup
399
+ @app.command(:destroy, '.')
400
+ else
401
+ hide
402
+ end
403
+ end
404
+ end
405
+
406
+ def tick
407
+ unless @core
408
+ render_empty_hint if @sdl2_ready
409
+ return
410
+ end
411
+ return if @paused
412
+
413
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
414
+ @next_frame ||= now
415
+
416
+ if @fast_forward
417
+ tick_fast_forward(now)
418
+ else
419
+ tick_normal(now)
420
+ end
421
+ end
422
+
423
+ def tick_normal(now)
424
+ frames = 0
425
+ while @next_frame <= now && frames < 4
426
+ break if @replay_ended
427
+
428
+ run_one_frame
429
+ queue_audio
430
+
431
+ fill = (@stream.queued_samples.to_f / audio_buf_capacity).clamp(0.0, 1.0)
432
+ ratio = (1.0 - MAX_DELTA) + 2.0 * fill * MAX_DELTA
433
+ @next_frame += frame_period * ratio
434
+ frames += 1
435
+ end
436
+
437
+ @next_frame = now if now - @next_frame > 0.1
438
+ return if frames == 0
439
+
440
+ render_frame
441
+ update_fps(frames, now)
442
+ end
443
+
444
+ def tick_fast_forward(now)
445
+ if @turbo_speed == 0
446
+ FF_MAX_FRAMES.times do |i|
447
+ break if @replay_ended
448
+ run_one_frame
449
+ if i == 0
450
+ queue_audio(volume_override: @turbo_volume)
451
+ else
452
+ @core.audio_buffer # discard
453
+ end
454
+ end
455
+ @next_frame = now
456
+ render_frame(ff_indicator: true)
457
+ update_fps(FF_MAX_FRAMES, now)
458
+ return
459
+ end
460
+
461
+ frames = 0
462
+ while @next_frame <= now && frames < @turbo_speed * 4
463
+ @turbo_speed.times do
464
+ break if @replay_ended
465
+ run_one_frame
466
+ if frames == 0
467
+ queue_audio(volume_override: @turbo_volume)
468
+ else
469
+ @core.audio_buffer # discard
470
+ end
471
+ frames += 1
472
+ end
473
+ @next_frame += frame_period
474
+ end
475
+ @next_frame = now if now - @next_frame > 0.1
476
+ return if frames == 0
477
+
478
+ render_frame(ff_indicator: true)
479
+ update_fps(frames, now)
480
+ end
481
+
482
+ def run_one_frame
483
+ if @frame_index < @total_frames
484
+ mask = @replayer.bitmask_at(@frame_index)
485
+ @core.set_keys(mask)
486
+ @core.run_frame
487
+ @frame_index += 1
488
+ else
489
+ on_replay_end unless @replay_ended
490
+ end
491
+ end
492
+
493
+ def on_replay_end
494
+ @replay_ended = true
495
+ toggle_pause unless @paused
496
+ @toast&.show(translate('replay.ended', frames: @total_frames), permanent: true)
497
+ render_frame
498
+ end
499
+
500
+ # ── Audio ─────────────────────────────────────────────────────────
501
+
502
+ def queue_audio(volume_override: nil)
503
+ pcm = @core.audio_buffer
504
+ return if pcm.empty?
505
+
506
+ if @muted
507
+ @audio_fade_in = 0
508
+ else
509
+ vol = volume_override || @volume
510
+ pcm = apply_volume_to_pcm(pcm, vol) if vol < 1.0
511
+ if @audio_fade_in > 0
512
+ pcm, @audio_fade_in = EmulatorFrame.apply_fade_ramp(pcm, @audio_fade_in, FADE_IN_FRAMES)
513
+ end
514
+ @stream.queue(pcm)
515
+ end
516
+ end
517
+
518
+ def apply_volume_to_pcm(pcm, gain)
519
+ samples = pcm.unpack('s*')
520
+ samples.map! { |s| (s * gain).round.clamp(-32768, 32767) }
521
+ samples.pack('s*')
522
+ end
523
+
524
+ # ── Rendering ─────────────────────────────────────────────────────
525
+
526
+ def render_frame(ff_indicator: false)
527
+ pixels = @core.video_buffer_argb
528
+ @texture.update(pixels)
529
+ dest = compute_dest_rect
530
+ @viewport.render do |r|
531
+ r.clear(0, 0, 0)
532
+ r.copy(@texture, nil, dest)
533
+ @hud.draw(r, dest, show_fps: @show_fps, show_ff: ff_indicator)
534
+ @toast&.draw(r, dest)
535
+ end
536
+ end
537
+
538
+ def render_empty_hint
539
+ @empty_hint_tex ||= @overlay_font&.render_text(translate('replay.empty_hint'), 180, 180, 180)
540
+ @viewport.render do |r|
541
+ r.clear(0, 0, 0)
542
+ if @empty_hint_tex
543
+ out_w, out_h = @viewport.renderer.output_size
544
+ x = (out_w - @empty_hint_tex.width) / 2
545
+ y = (out_h - @empty_hint_tex.height) / 2
546
+ r.copy(@empty_hint_tex, nil, [x, y, @empty_hint_tex.width, @empty_hint_tex.height])
547
+ end
548
+ end
549
+ end
550
+
551
+ def compute_dest_rect
552
+ return nil unless @keep_aspect_ratio
553
+
554
+ out_w, out_h = @viewport.renderer.output_size
555
+ scale_x = out_w.to_f / @platform.width
556
+ scale_y = out_h.to_f / @platform.height
557
+ scale = [scale_x, scale_y].min
558
+ scale = scale.floor if @integer_scale && scale >= 1.0
559
+
560
+ dest_w = (@platform.width * scale).to_i
561
+ dest_h = (@platform.height * scale).to_i
562
+ dest_x = (out_w - dest_w) / 2
563
+ dest_y = (out_h - dest_h) / 2
564
+
565
+ [dest_x, dest_y, dest_w, dest_h]
566
+ end
567
+
568
+ def update_fps(frames, now)
569
+ @fps_count += frames
570
+ elapsed = now - @fps_time
571
+ if elapsed >= 1.0
572
+ fps = (@fps_count / elapsed).round(1)
573
+ @hud.set_fps(translate('player.fps', fps: fps)) if @show_fps
574
+ @fps_count = 0
575
+ @fps_time = now
576
+ end
577
+ end
578
+
579
+ # ── Hotkey actions ────────────────────────────────────────────────
580
+
581
+ def toggle_pause
582
+ return unless @core
583
+ @paused = !@paused
584
+ if @paused
585
+ @stream.clear
586
+ @stream.pause
587
+ unless @replay_ended
588
+ @toast&.show(translate('toast.paused'), permanent: true)
589
+ end
590
+ render_frame
591
+ set_event_loop_speed(:idle)
592
+ else
593
+ set_event_loop_speed(:fast)
594
+ @toast&.destroy unless @replay_ended
595
+ @stream.clear
596
+ @audio_fade_in = FADE_IN_FRAMES
597
+ @stream.resume
598
+ @next_frame = Process.clock_gettime(Process::CLOCK_MONOTONIC)
599
+ end
600
+ end
601
+
602
+ def toggle_fast_forward
603
+ return unless @core
604
+ return if @replay_ended
605
+
606
+ @fast_forward = !@fast_forward
607
+ if @fast_forward
608
+ @hud.set_ff_label(ff_label_text)
609
+ else
610
+ @hud.set_ff_label(nil)
611
+ @next_frame = Process.clock_gettime(Process::CLOCK_MONOTONIC)
612
+ @stream.clear
613
+ end
614
+ end
615
+
616
+ def ff_label_text
617
+ @turbo_speed == 0 ? translate('player.ff_max') : translate('player.ff', speed: @turbo_speed)
618
+ end
619
+
620
+ def toggle_fullscreen
621
+ @fullscreen = !@fullscreen
622
+ @app.command(:wm, 'attributes', @top, '-fullscreen', @fullscreen ? 1 : 0)
623
+ end
624
+
625
+ def toggle_show_fps
626
+ @show_fps = !@show_fps
627
+ @hud.set_fps(nil) unless @show_fps
628
+ end
629
+
630
+ def take_screenshot
631
+ return unless @core && !@core.destroyed?
632
+
633
+ dir = Config.default_screenshots_dir
634
+ FileUtils.mkdir_p(dir)
635
+
636
+ title = @core.title.strip.gsub(/[^a-zA-Z0-9_\-]/, '_')
637
+ stamp = Time.now.strftime('%Y%m%d_%H%M%S')
638
+ name = "replay_#{title}_#{stamp}.png"
639
+ path = File.join(dir, name)
640
+
641
+ pixels = @core.video_buffer_argb
642
+ photo_name = "__gemba_rp_ss_#{object_id}"
643
+ out_w = @platform.width * @scale
644
+ out_h = @platform.height * @scale
645
+ @app.command(:image, :create, :photo, photo_name,
646
+ width: out_w, height: out_h)
647
+ @app.interp.photo_put_zoomed_block(photo_name, pixels, @platform.width, @platform.height,
648
+ zoom_x: @scale, zoom_y: @scale, format: :argb)
649
+ @app.command(photo_name, :write, path, format: :png)
650
+ @app.command(:image, :delete, photo_name)
651
+ @toast&.show(translate('toast.screenshot_saved', name: name))
652
+ rescue StandardError => e
653
+ warn "gemba: screenshot failed: #{e.message} (#{e.class})"
654
+ @app.command(:image, :delete, photo_name) rescue nil
655
+ @toast&.show(translate('toast.screenshot_failed'))
656
+ end
657
+
658
+ # ── Helpers ───────────────────────────────────────────────────────
659
+
660
+ def set_event_loop_speed(mode)
661
+ if @standalone
662
+ ms = mode == :fast ? EVENT_LOOP_FAST_MS : EVENT_LOOP_IDLE_MS
663
+ @app.interp.thread_timer_ms = ms
664
+ else
665
+ @callbacks[:on_request_speed]&.call(mode)
666
+ end
667
+ end
668
+
669
+ def show_error(title, message)
670
+ @app.command('tk_messageBox',
671
+ parent: @top,
672
+ title: title,
673
+ message: message,
674
+ type: :ok,
675
+ icon: :error)
676
+ end
677
+
678
+ def cleanup
679
+ return if @cleaned_up
680
+ @cleaned_up = true
681
+
682
+ @stream&.pause unless @stream&.destroyed?
683
+ @hud&.destroy
684
+ @toast&.destroy
685
+ @overlay_font&.destroy unless @overlay_font&.destroyed?
686
+ @stream&.destroy unless @stream&.destroyed?
687
+ @texture&.destroy unless @texture&.destroyed?
688
+ @core&.destroy unless @core&.destroyed?
689
+ end
690
+ end
691
+ end