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,1060 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Gemba
6
+ # SDL2 emulation frame — owns the mGBA core, viewport, audio stream,
7
+ # frame loop, and all rendering. Designed to be packed/unpacked inside
8
+ # a host window (AppController) so it can coexist with other "frames" like
9
+ # a game picker or replay viewer.
10
+ #
11
+ # Communication:
12
+ # AppController → EmulatorFrame: @frame.receive(:event_name, **args)
13
+ # EmulatorFrame → AppController: EventBus events (pause_changed, request_quit, etc.)
14
+ # Settings → EmulatorFrame: bus events subscribed directly
15
+ class EmulatorFrame
16
+ include Locale::Translatable
17
+ include BusEmitter
18
+
19
+ # mGBA outputs at 44100 Hz (stereo int16)
20
+ AUDIO_FREQ = 44100
21
+ MAX_DELTA = 0.005 # ±0.5% max adjustment (dynamic rate control)
22
+ FF_MAX_FRAMES = 10 # cap for uncapped turbo to avoid locking event loop
23
+ FADE_IN_FRAMES = (AUDIO_FREQ * 0.02).to_i # ~20ms = 882 samples
24
+ REWIND_PUSH_INTERVAL = 60 # ~1 second at ~60 fps
25
+ FOCUS_POLL_MS = 200
26
+
27
+ # @param app [Teek::App] the Tk application
28
+ # @param config [Config] configuration object
29
+ # @param platform [Platform] initial platform (GBA default)
30
+ # @param sound [Boolean] whether audio is enabled
31
+ # @param scale [Integer] video scale multiplier
32
+ # @param kb_map [KeyboardMap] keyboard input mapping (shared reference)
33
+ # @param gp_map [GamepadMap] gamepad input mapping (shared reference)
34
+ # @param keyboard [VirtualKeyboard] virtual keyboard state (shared reference)
35
+ # @param hotkeys [HotkeyMap] hotkey bindings (shared reference)
36
+ # @param frame_limit [Integer, nil] stop after this many frames (testing)
37
+ def initialize(app:, config:, platform:, sound:, scale:,
38
+ kb_map:, gp_map:, keyboard:, hotkeys:,
39
+ frame_limit: nil,
40
+ volume:, muted:, turbo_speed:, turbo_volume:,
41
+ keep_aspect_ratio:, show_fps:, pixel_filter:,
42
+ integer_scale:, color_correction:, frame_blending:,
43
+ rewind_enabled:, rewind_seconds:,
44
+ quick_save_slot:, save_state_backup:,
45
+ recording_compression:, pause_on_focus_loss:)
46
+ @app = app
47
+ @config = config
48
+ @platform = platform
49
+ @sound = sound
50
+ @scale = scale
51
+ @kb_map = kb_map
52
+ @gp_map = gp_map
53
+ @keyboard = keyboard
54
+ @hotkeys = hotkeys
55
+ @frame_limit = frame_limit
56
+
57
+ # Emulation config state
58
+ @volume = volume
59
+ @muted = muted
60
+ @turbo_speed = turbo_speed
61
+ @turbo_volume = turbo_volume
62
+ @keep_aspect_ratio = keep_aspect_ratio
63
+ @show_fps = show_fps
64
+ @pixel_filter = pixel_filter
65
+ @integer_scale = integer_scale
66
+ @color_correction = color_correction
67
+ @frame_blending = frame_blending
68
+ @rewind_enabled = rewind_enabled
69
+ @rewind_seconds = rewind_seconds
70
+ @quick_save_slot = quick_save_slot
71
+ @save_state_backup = save_state_backup
72
+ @recording_compression = recording_compression
73
+ @pause_on_focus_loss = pause_on_focus_loss
74
+
75
+ setup_bus_subscriptions
76
+
77
+ # Runtime state
78
+ @audio_fade_in = 0
79
+ @total_frames = 0
80
+ @fast_forward = false
81
+ @paused = false
82
+ @core = nil
83
+ @sdl2_ready = false
84
+ @animate_started = false
85
+ @running = true
86
+ @cleaned_up = false
87
+ @recorder = nil
88
+ @input_recorder = nil
89
+ @save_mgr = nil
90
+ @rewind_frame_counter = 0
91
+ @achievement_backend = Achievements::NullBackend.new
92
+ end
93
+
94
+ # -- Public accessors -------------------------------------------------------
95
+
96
+ # @return [Teek::SDL2::Viewport, nil]
97
+ attr_reader :viewport
98
+
99
+ # @return [Core, nil]
100
+ attr_reader :core
101
+
102
+ # @return [SaveStateManager, nil]
103
+ attr_reader :save_mgr
104
+
105
+ # @return [Recorder, nil]
106
+ attr_reader :recorder
107
+
108
+ # @return [Platform]
109
+ attr_reader :platform
110
+
111
+ # @return [Float] current volume 0.0–1.0
112
+ attr_reader :volume
113
+
114
+ # @return [Integer] turbo speed multiplier (0 = uncapped)
115
+ attr_reader :turbo_speed
116
+
117
+ # @return [Achievements::Backend]
118
+ attr_reader :achievement_backend
119
+
120
+ # Swap in a new achievement backend. Registers callbacks that forward
121
+ # unlock events through EventBus for any UI consumer to handle.
122
+ # @param backend [Achievements::Backend]
123
+ def achievement_backend=(backend)
124
+ @achievement_backend = backend
125
+ backend.on_unlock { |ach| emit(:achievement_unlocked, achievement: ach) }
126
+ end
127
+
128
+ # @return [Boolean]
129
+ def muted? = @muted
130
+
131
+ # @return [Boolean]
132
+ def aspect_ratio = nil # emulator drives its own geometry via apply_scale
133
+ def sdl2_ready? = @sdl2_ready
134
+
135
+ # @return [Boolean]
136
+ def paused? = @paused
137
+
138
+ # @return [Boolean]
139
+ def fast_forward? = @fast_forward
140
+
141
+ # @return [Boolean]
142
+ def recording? = @recorder&.recording? || false
143
+
144
+ # @return [Boolean]
145
+ def input_recording? = @input_recorder&.recording? || false
146
+
147
+ # @return [Boolean]
148
+ def rom_loaded? = !!@core
149
+
150
+ # @return [Boolean]
151
+ def show_fps? = @show_fps
152
+
153
+ # Allow AppController to control the animate loop
154
+ attr_writer :running
155
+
156
+ # Allow AppController to update scale (for screenshots)
157
+ attr_writer :scale
158
+
159
+ # FrameStack protocol
160
+ def show
161
+ return unless @sdl2_ready && @viewport
162
+ @app.command(:pack, @viewport.frame.path, fill: :both, expand: 1)
163
+ end
164
+
165
+ def hide
166
+ return unless @sdl2_ready && @viewport
167
+ @app.command(:pack, :forget, @viewport.frame.path) rescue nil
168
+ end
169
+
170
+ # Single entry point for AppController → EmulatorFrame communication.
171
+ # AppController calls @frame.receive(:event_name, **args) instead of
172
+ # knowing about individual methods.
173
+ def receive(event, **args)
174
+ case event
175
+ when :pause then toggle_pause
176
+ when :fast_forward then toggle_fast_forward
177
+ when :rewind then do_rewind
178
+ when :quick_save then quick_save
179
+ when :quick_load then quick_load
180
+ when :save_state then save_state(args[:slot])
181
+ when :load_state then load_state(args[:slot])
182
+ when :screenshot then take_screenshot
183
+ when :toggle_recording then toggle_recording
184
+ when :toggle_input_recording then toggle_input_recording
185
+ when :toggle_show_fps
186
+ @show_fps = !@show_fps
187
+ @hud&.set_fps(nil) unless @show_fps
188
+ when :show_toast
189
+ show_toast(args[:message], permanent: args[:permanent] || false)
190
+ when :dismiss_toast
191
+ dismiss_toast
192
+ when :modal_entered
193
+ toggle_fast_forward if fast_forward?
194
+ toggle_pause if rom_loaded? && !paused?
195
+ when :modal_exited
196
+ dismiss_toast
197
+ toggle_pause if rom_loaded? && !args[:was_paused]
198
+ when :modal_focus_changed
199
+ dismiss_toast
200
+ show_toast(args[:message], permanent: true)
201
+ when :write_config then write_config
202
+ when :refresh_from_config then refresh_from_config(@config)
203
+ end
204
+ end
205
+
206
+ # -- SDL2 lifecycle ---------------------------------------------------------
207
+
208
+ # Create the SDL2 viewport, audio stream, fonts, and input bindings.
209
+ # Must be called once before load_core.
210
+ def init_sdl2
211
+ return if @sdl2_ready
212
+
213
+ @app.command('tk', 'busy', '.')
214
+
215
+ win_w = @platform.width * @scale
216
+ win_h = @platform.height * @scale
217
+
218
+ @viewport = Teek::SDL2::Viewport.new(@app, width: win_w, height: win_h, vsync: false)
219
+ @viewport.pack(fill: :both, expand: true)
220
+
221
+ # Streaming texture at native resolution
222
+ @texture = @viewport.renderer.create_texture(@platform.width, @platform.height, :streaming)
223
+ @texture.scale_mode = @pixel_filter.to_sym
224
+
225
+ # Font for on-screen indicators (FPS, fast-forward label)
226
+ font_path = File.join(ASSETS_DIR, 'JetBrainsMonoNL-Regular.ttf')
227
+ @overlay_font = File.exist?(font_path) ? @viewport.renderer.load_font(font_path, 14) : nil
228
+
229
+ # CJK-capable font for toast notifications and translated UI text
230
+ toast_font_path = File.join(ASSETS_DIR, 'ark-pixel-12px-monospaced-ja.ttf')
231
+ toast_font = File.exist?(toast_font_path) ? @viewport.renderer.load_font(toast_font_path, 12) : @overlay_font
232
+
233
+ @toast = ToastOverlay.new(
234
+ renderer: @viewport.renderer,
235
+ font: toast_font || @overlay_font,
236
+ duration: @config.toast_duration
237
+ )
238
+
239
+ # Custom blend mode: white text inverts the background behind it.
240
+ inverse_blend = Teek::SDL2.compose_blend_mode(
241
+ :one_minus_dst_color, :one_minus_src_alpha, :add,
242
+ :zero, :one, :add
243
+ )
244
+
245
+ @hud = OverlayRenderer.new(font: @overlay_font, blend_mode: inverse_blend)
246
+
247
+ # Audio stream — stereo int16.
248
+ if @sound && Teek::SDL2::AudioStream.available?
249
+ @stream = Teek::SDL2::AudioStream.new(
250
+ frequency: AUDIO_FREQ,
251
+ format: :s16,
252
+ channels: 2
253
+ )
254
+ @stream.resume
255
+ else
256
+ if @sound
257
+ Gemba.log(:warn) { "No audio device found, continuing without sound" }
258
+ warn "gemba: no audio device found, continuing without sound"
259
+ end
260
+ @stream = Teek::SDL2::NullAudioStream.new
261
+ end
262
+
263
+ setup_input
264
+
265
+ @sdl2_ready = true
266
+
267
+ # Unblock interaction now that SDL2 is ready
268
+ @app.command('tk', 'busy', 'forget', '.')
269
+
270
+ # Auto-focus viewport for keyboard input
271
+ @app.tcl_eval("focus -force #{@viewport.frame.path}")
272
+ @app.update
273
+ rescue => e
274
+ Gemba.log(:error) { "init_sdl2 failed: #{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}" }
275
+ $stderr.puts "FATAL: init_sdl2 failed: #{e.class}: #{e.message}"
276
+ $stderr.puts e.backtrace.first(10).map { |l| " #{l}" }.join("\n")
277
+ @app.command('tk', 'busy', 'forget', '.') rescue nil
278
+ emit(:request_quit)
279
+ end
280
+
281
+ # Load (or reload) a ROM core. Creates Core + SaveStateManager.
282
+ # @param rom_path [String] resolved path to the ROM file
283
+ # @param saves_dir [String] directory for .sav files
284
+ # @param bios_path [String, nil] full path to BIOS file (loaded before reset)
285
+ # @param rom_source_path [String] original path (for input recorder)
286
+ # @return [Core] the new core
287
+ def load_core(rom_path, saves_dir:, bios_path: nil, rom_source_path: nil, md5: nil)
288
+ stop_recording if @recorder&.recording?
289
+ stop_input_recording if @input_recorder&.recording?
290
+
291
+ if @core && !@core.destroyed?
292
+ @core.destroy
293
+ end
294
+ @stream.clear
295
+
296
+ FileUtils.mkdir_p(saves_dir) unless File.directory?(saves_dir)
297
+ @core = Core.new(rom_path, saves_dir, bios_path)
298
+ @rom_source_path = rom_source_path || rom_path
299
+ @rom_md5 = md5
300
+
301
+ new_platform = Platform.for(@core)
302
+ if new_platform != @platform
303
+ @platform = new_platform
304
+ recreate_texture
305
+ end
306
+
307
+ @save_mgr = SaveStateManager.new(core: @core, config: @config, app: @app, platform: @platform)
308
+ @save_mgr.state_dir = @save_mgr.state_dir_for_rom(@core)
309
+ @save_mgr.quick_save_slot = @quick_save_slot
310
+ @save_mgr.backup = @save_state_backup
311
+ @core.color_correction = @color_correction if @color_correction
312
+ @core.frame_blending = @frame_blending if @frame_blending
313
+ @core.rewind_init(@rewind_seconds) if @rewind_enabled
314
+ @rewind_frame_counter = 0
315
+ @paused = false
316
+ @stream.resume
317
+ set_event_loop_speed(:fast)
318
+ @fps_count = 0
319
+ @fps_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
320
+ @next_frame = @fps_time
321
+ @audio_samples_produced = 0
322
+
323
+ @achievement_backend.load_game(@core, @rom_source_path, @rom_md5)
324
+
325
+ @core
326
+ end
327
+
328
+ # Start the emulation animate loop. Call once after first load_core.
329
+ def start_animate
330
+ return if @animate_started
331
+ @animate_started = true
332
+ animate
333
+ end
334
+
335
+ # -- Toast helpers (called by AppController via receive) ----------------------
336
+
337
+ def show_toast(msg, permanent: false)
338
+ @toast&.show(msg, permanent: permanent)
339
+ render_if_paused
340
+ end
341
+
342
+ def dismiss_toast
343
+ @toast&.destroy
344
+ end
345
+
346
+ # -- Cleanup ----------------------------------------------------------------
347
+
348
+ def cleanup
349
+ return if @cleaned_up
350
+ @cleaned_up = true
351
+
352
+ stop_recording if @recorder&.recording?
353
+ stop_input_recording if @input_recorder&.recording?
354
+ @stream&.pause unless @stream&.destroyed?
355
+ @hud&.destroy
356
+ @toast&.destroy
357
+ @overlay_font&.destroy unless @overlay_font&.destroyed?
358
+ @stream&.destroy unless @stream&.destroyed?
359
+ @texture&.destroy unless @texture&.destroyed?
360
+ @core&.destroy unless @core&.destroyed?
361
+ if @viewport
362
+ @app.command(:destroy, @viewport.frame.path) rescue nil
363
+ @viewport.destroy rescue nil
364
+ end
365
+ @sdl2_ready = false
366
+ RomResolver.cleanup_temp
367
+ end
368
+
369
+ # -- Emulation control ------------------------------------------------------
370
+
371
+ def toggle_pause
372
+ return unless @core
373
+ @paused = !@paused
374
+ if @paused
375
+ @stream.clear
376
+ @stream.pause
377
+ @toast&.show(translate('toast.paused'), permanent: true)
378
+ render_frame
379
+ set_event_loop_speed(:idle)
380
+ else
381
+ set_event_loop_speed(:fast)
382
+ @toast&.destroy
383
+ @stream.clear
384
+ @audio_fade_in = FADE_IN_FRAMES
385
+ @stream.resume
386
+ @next_frame = Process.clock_gettime(Process::CLOCK_MONOTONIC)
387
+ end
388
+ emit(:pause_changed, @paused)
389
+ end
390
+
391
+ def toggle_fast_forward
392
+ return unless @core
393
+ @fast_forward = !@fast_forward
394
+ if @fast_forward
395
+ @hud.set_ff_label(ff_label_text)
396
+ else
397
+ @hud.set_ff_label(nil)
398
+ @next_frame = Process.clock_gettime(Process::CLOCK_MONOTONIC)
399
+ @stream.clear
400
+ end
401
+ end
402
+
403
+ def do_rewind
404
+ return unless @core && !@core.destroyed?
405
+ unless @rewind_enabled
406
+ @toast&.show(translate('toast.no_rewind'))
407
+ render_if_paused
408
+ return
409
+ end
410
+ if @core.rewind_pop == true
411
+ @core.run_frame
412
+ @stream.clear
413
+ @audio_fade_in = FADE_IN_FRAMES
414
+ @rewind_frame_counter = 0
415
+ @toast&.show(translate('toast.rewound'))
416
+ render_frame
417
+ else
418
+ @toast&.show(translate('toast.no_rewind'))
419
+ render_if_paused
420
+ end
421
+ end
422
+
423
+ # -- Save states (delegated to SaveStateManager) ----------------------------
424
+
425
+ def save_state(slot)
426
+ return unless @save_mgr
427
+ _ok, msg = @save_mgr.save_state(slot)
428
+ @toast&.show(msg) if msg
429
+ end
430
+
431
+ def load_state(slot)
432
+ return unless @save_mgr
433
+ ok, msg = @save_mgr.load_state(slot)
434
+ @toast&.show(msg) if msg
435
+ # After a save state loads, memory jumps abruptly to whatever it was when
436
+ # the state was saved. Achievements that were already in stage 3 (active)
437
+ # would fire immediately if the saved memory happens to satisfy their
438
+ # conditions. Reset all achievements back through the priming/waiting
439
+ # startup sequence — same as what rcheevos does on state load.
440
+ if ok
441
+ Gemba.log(:info) { "save state loaded (slot #{slot}) — resetting achievement runtime" }
442
+ @achievement_backend.reset_runtime
443
+ render_clean_if_paused
444
+ end
445
+ end
446
+
447
+ def quick_save
448
+ return unless @save_mgr
449
+ _ok, msg = @save_mgr.quick_save
450
+ @toast&.show(msg) if msg
451
+ end
452
+
453
+ def quick_load
454
+ return unless @save_mgr
455
+ ok, msg = @save_mgr.quick_load
456
+ @toast&.show(msg) if msg
457
+ # Same as load_state — memory jumped, reset achievement runtime.
458
+ if ok
459
+ Gemba.log(:info) { "quick save state loaded — resetting achievement runtime" }
460
+ @achievement_backend.reset_runtime
461
+ render_clean_if_paused
462
+ end
463
+ end
464
+
465
+ # -- Screenshot -------------------------------------------------------------
466
+
467
+ def take_screenshot
468
+ return unless @core && !@core.destroyed?
469
+
470
+ dir = Config.default_screenshots_dir
471
+ FileUtils.mkdir_p(dir)
472
+
473
+ title = @core.title.strip.gsub(/[^a-zA-Z0-9_\-]/, '_')
474
+ stamp = Time.now.strftime('%Y%m%d_%H%M%S')
475
+ name = "#{title}_#{stamp}.png"
476
+ path = File.join(dir, name)
477
+
478
+ pixels = @core.video_buffer_argb
479
+ photo_name = "__gemba_ss_#{object_id}"
480
+ out_w = @platform.width * @scale
481
+ out_h = @platform.height * @scale
482
+ @app.command(:image, :create, :photo, photo_name,
483
+ width: out_w, height: out_h)
484
+ @app.interp.photo_put_zoomed_block(photo_name, pixels, @platform.width, @platform.height,
485
+ zoom_x: @scale, zoom_y: @scale, format: :argb)
486
+ @app.command(photo_name, :write, path, format: :png)
487
+ @app.command(:image, :delete, photo_name)
488
+ @toast&.show(translate('toast.screenshot_saved', name: name))
489
+ rescue StandardError => e
490
+ warn "gemba: screenshot failed: #{e.message} (#{e.class})"
491
+ @app.command(:image, :delete, photo_name) rescue nil
492
+ @toast&.show(translate('toast.screenshot_failed'))
493
+ end
494
+
495
+ # -- Recording --------------------------------------------------------------
496
+
497
+ def toggle_recording
498
+ return unless @core
499
+ @recorder&.recording? ? stop_recording : start_recording
500
+ end
501
+
502
+ def start_recording
503
+ dir = @config.recordings_dir
504
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
505
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%L')
506
+ title = @core.title.strip.gsub(/[^a-zA-Z0-9_.-]/, '_')
507
+ filename = "#{title}_#{timestamp}.grec"
508
+ path = File.join(dir, filename)
509
+ @recorder = Recorder.new(path, width: @platform.width, height: @platform.height,
510
+ fps_fraction: @platform.fps_fraction,
511
+ compression: @recording_compression)
512
+ @recorder.start
513
+ Gemba.log(:info) { "Recording started: #{path}" }
514
+ @toast&.show(translate('toast.recording_started'))
515
+ emit(:recording_changed)
516
+ end
517
+
518
+ def stop_recording
519
+ return unless @recorder&.recording?
520
+ @recorder.stop
521
+ count = @recorder.frame_count
522
+ Gemba.log(:info) { "Recording stopped: #{count} frames" }
523
+ @toast&.show(translate('toast.recording_stopped', frames: count))
524
+ @recorder = nil
525
+ emit(:recording_changed)
526
+ end
527
+
528
+ # -- Input recording --------------------------------------------------------
529
+
530
+ def toggle_input_recording
531
+ return unless @core
532
+ @input_recorder&.recording? ? stop_input_recording : start_input_recording
533
+ end
534
+
535
+ def start_input_recording
536
+ dir = @config.recordings_dir
537
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
538
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%L')
539
+ title = @core.title.strip.gsub(/[^a-zA-Z0-9_.-]/, '_')
540
+ filename = "#{title}_#{timestamp}.gir"
541
+ path = File.join(dir, filename)
542
+ @input_recorder = InputRecorder.new(path, core: @core, rom_path: @rom_source_path)
543
+ @input_recorder.start
544
+ @toast&.show(translate('toast.input_recording_started'))
545
+ emit(:input_recording_changed)
546
+ end
547
+
548
+ def stop_input_recording
549
+ return unless @input_recorder&.recording?
550
+ @input_recorder.stop
551
+ count = @input_recorder.frame_count
552
+ @toast&.show(translate('toast.input_recording_stopped', frames: count))
553
+ @input_recorder = nil
554
+ emit(:input_recording_changed)
555
+ end
556
+
557
+ # -- Config appliers --------------------------------------------------------
558
+
559
+ def apply_volume(vol)
560
+ @volume = vol.to_f.clamp(0.0, 1.0)
561
+ end
562
+
563
+ def apply_mute(muted)
564
+ @muted = !!muted
565
+ end
566
+
567
+ def apply_turbo_speed(speed)
568
+ @turbo_speed = speed
569
+ @hud.set_ff_label(ff_label_text) if @fast_forward
570
+ end
571
+
572
+ def apply_aspect_ratio(keep)
573
+ @keep_aspect_ratio = keep
574
+ end
575
+
576
+ def apply_show_fps(show)
577
+ @show_fps = show
578
+ @hud.set_fps(nil) unless @show_fps
579
+ end
580
+
581
+ def apply_toast_duration(secs)
582
+ @config.toast_duration = secs
583
+ @toast.duration = secs
584
+ end
585
+
586
+ def apply_pixel_filter(filter)
587
+ @pixel_filter = filter
588
+ @texture.scale_mode = filter.to_sym if @texture
589
+ end
590
+
591
+ def apply_integer_scale(enabled)
592
+ @integer_scale = !!enabled
593
+ end
594
+
595
+ def apply_color_correction(enabled)
596
+ @color_correction = !!enabled
597
+ if @core && !@core.destroyed?
598
+ @core.color_correction = @color_correction
599
+ render_if_paused
600
+ end
601
+ end
602
+
603
+ def apply_frame_blending(enabled)
604
+ @frame_blending = !!enabled
605
+ if @core && !@core.destroyed?
606
+ @core.frame_blending = @frame_blending
607
+ render_if_paused
608
+ end
609
+ end
610
+
611
+ def apply_rewind_toggle(enabled)
612
+ @rewind_enabled = !!enabled
613
+ if @core && !@core.destroyed?
614
+ if @rewind_enabled
615
+ @core.rewind_init(@rewind_seconds)
616
+ @rewind_frame_counter = 0
617
+ else
618
+ @core.rewind_deinit
619
+ end
620
+ end
621
+ end
622
+
623
+ def apply_recording_compression(val)
624
+ @recording_compression = val.to_i.clamp(1, 9)
625
+ end
626
+
627
+ def apply_pause_on_focus_loss(val)
628
+ @pause_on_focus_loss = val
629
+ @was_paused_before_focus_loss = false unless val
630
+ end
631
+
632
+ def apply_quick_slot(slot)
633
+ @quick_save_slot = slot.to_i.clamp(1, 10)
634
+ @save_mgr.quick_save_slot = @quick_save_slot if @save_mgr
635
+ end
636
+
637
+ def apply_backup(enabled)
638
+ @save_state_backup = !!enabled
639
+ @save_mgr.backup = @save_state_backup if @save_mgr
640
+ end
641
+
642
+ # Sync all config-derived state from a config object after per-game switch.
643
+ def refresh_from_config(config)
644
+ @pixel_filter = config.pixel_filter
645
+ @integer_scale = config.integer_scale?
646
+ @color_correction = config.color_correction?
647
+ @frame_blending = config.frame_blending?
648
+ @rewind_enabled = config.rewind_enabled?
649
+ @rewind_seconds = config.rewind_seconds
650
+ @quick_save_slot = config.quick_save_slot
651
+ @save_state_backup = config.save_state_backup?
652
+ @recording_compression = config.recording_compression
653
+ @volume = config.volume / 100.0
654
+ @muted = config.muted?
655
+ @turbo_speed = config.turbo_speed
656
+
657
+ @texture.scale_mode = @pixel_filter.to_sym if @texture
658
+ if @core && !@core.destroyed?
659
+ @core.color_correction = @color_correction
660
+ @core.frame_blending = @frame_blending
661
+ render_if_paused
662
+ end
663
+ @save_mgr.quick_save_slot = @quick_save_slot if @save_mgr
664
+ @save_mgr.backup = @save_state_backup if @save_mgr
665
+ end
666
+
667
+ # Write all config-derived state back to the config object.
668
+ # Called by AppController before config.save!
669
+ def write_config
670
+ @config.volume = (@volume * 100).round
671
+ @config.muted = @muted
672
+ @config.turbo_speed = @turbo_speed
673
+ @config.keep_aspect_ratio = @keep_aspect_ratio
674
+ @config.show_fps = @show_fps
675
+ @config.pixel_filter = @pixel_filter
676
+ @config.integer_scale = @integer_scale
677
+ @config.color_correction = @color_correction
678
+ @config.frame_blending = @frame_blending
679
+ @config.rewind_enabled = @rewind_enabled
680
+ @config.rewind_seconds = @rewind_seconds
681
+ @config.quick_save_slot = @quick_save_slot
682
+ @config.save_state_backup = @save_state_backup
683
+ @config.recording_compression = @recording_compression
684
+ @config.pause_on_focus_loss = @pause_on_focus_loss
685
+ end
686
+
687
+ # -- Class methods ----------------------------------------------------------
688
+
689
+ # Apply a linear fade-in ramp to int16 stereo PCM data.
690
+ # Pure function: takes remaining/total counters, returns [pcm, new_remaining].
691
+ # @param pcm [String] packed int16 stereo PCM
692
+ # @param remaining [Integer] fade samples remaining (counts down to 0)
693
+ # @param total [Integer] total fade length in samples
694
+ # @return [Array(String, Integer)] modified PCM and updated remaining count
695
+ def self.apply_fade_ramp(pcm, remaining, total)
696
+ samples = pcm.unpack('s*')
697
+ i = 0
698
+ while i < samples.length && remaining > 0
699
+ gain = 1.0 - (remaining.to_f / total)
700
+ samples[i] = (samples[i] * gain).round.clamp(-32768, 32767)
701
+ samples[i + 1] = (samples[i + 1] * gain).round.clamp(-32768, 32767) if i + 1 < samples.length
702
+ remaining -= 1
703
+ i += 2
704
+ end
705
+ [samples.pack('s*'), remaining]
706
+ end
707
+
708
+ private
709
+
710
+ def setup_bus_subscriptions
711
+ bus = Gemba.bus
712
+
713
+ # Video/rendering
714
+ bus.on(:filter_changed) { |val| apply_pixel_filter(val) }
715
+ bus.on(:integer_scale_changed) { |val| apply_integer_scale(val) }
716
+ bus.on(:color_correction_changed) { |val| apply_color_correction(val) }
717
+ bus.on(:frame_blending_changed) { |val| apply_frame_blending(val) }
718
+ bus.on(:aspect_ratio_changed) { |val| apply_aspect_ratio(val) }
719
+ bus.on(:show_fps_changed) { |val| apply_show_fps(val) }
720
+ bus.on(:toast_duration_changed) { |val| apply_toast_duration(val) }
721
+ bus.on(:turbo_speed_changed) { |val| apply_turbo_speed(val) }
722
+ bus.on(:rewind_toggled) { |val| apply_rewind_toggle(val) }
723
+ bus.on(:pause_on_focus_loss_changed) { |val| apply_pause_on_focus_loss(val) }
724
+
725
+ # Audio
726
+ bus.on(:volume_changed) { |vol| apply_volume(vol) }
727
+ bus.on(:mute_changed) { |val| apply_mute(val) }
728
+
729
+ # Recording / save states
730
+ bus.on(:compression_changed) { |val| apply_recording_compression(val) }
731
+ bus.on(:quick_slot_changed) { |val| apply_quick_slot(val) }
732
+ bus.on(:backup_changed) { |val| apply_backup(val) }
733
+
734
+ # Save state picker events
735
+ bus.on(:state_save_requested) { |slot| save_state(slot) }
736
+ bus.on(:state_load_requested) { |slot| load_state(slot) }
737
+ end
738
+
739
+ # -- Frame loop -------------------------------------------------------------
740
+
741
+ def animate
742
+ return unless @running
743
+ tick
744
+ delay = (@core && !@paused) ? 1 : 100
745
+ @app.after(delay) { animate }
746
+ end
747
+
748
+ def tick
749
+ unless @core
750
+ @viewport.render { |r| r.clear(0, 0, 0) }
751
+ return
752
+ end
753
+
754
+ return if @paused
755
+
756
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
757
+ @next_frame ||= now
758
+
759
+ if @fast_forward
760
+ tick_fast_forward(now)
761
+ else
762
+ tick_normal(now)
763
+ end
764
+ end
765
+
766
+ def tick_normal(now)
767
+ frames = 0
768
+ while @next_frame <= now && frames < 4
769
+ run_one_frame
770
+ rec_pcm = capture_frame
771
+ queue_audio(raw_pcm: rec_pcm)
772
+
773
+ fill = (@stream.queued_samples.to_f / audio_buf_capacity).clamp(0.0, 1.0)
774
+ ratio = (1.0 - MAX_DELTA) + 2.0 * fill * MAX_DELTA
775
+ @next_frame += frame_period * ratio
776
+ frames += 1
777
+ end
778
+
779
+ @next_frame = now if now - @next_frame > 0.1
780
+ return if frames == 0
781
+
782
+ render_frame
783
+ update_fps(frames, now)
784
+ end
785
+
786
+ def tick_fast_forward(now)
787
+ if @turbo_speed == 0
788
+ keys = poll_input
789
+ FF_MAX_FRAMES.times do |i|
790
+ @core.set_keys(keys)
791
+ @core.run_frame
792
+ rec_pcm = capture_frame
793
+ if i == 0
794
+ queue_audio(volume_override: @turbo_volume, raw_pcm: rec_pcm)
795
+ elsif !rec_pcm
796
+ @core.audio_buffer
797
+ end
798
+ end
799
+ @next_frame = now
800
+ render_frame(ff_indicator: true)
801
+ update_fps(FF_MAX_FRAMES, now)
802
+ return
803
+ end
804
+
805
+ frames = 0
806
+ while @next_frame <= now && frames < @turbo_speed * 4
807
+ @turbo_speed.times do
808
+ run_one_frame
809
+ rec_pcm = capture_frame
810
+ if frames == 0
811
+ queue_audio(volume_override: @turbo_volume, raw_pcm: rec_pcm)
812
+ elsif !rec_pcm
813
+ @core.audio_buffer
814
+ end
815
+ frames += 1
816
+ end
817
+ @next_frame += frame_period
818
+ end
819
+ @next_frame = now if now - @next_frame > 0.1
820
+ return if frames == 0
821
+
822
+ render_frame(ff_indicator: true)
823
+ update_fps(frames, now)
824
+ end
825
+
826
+ def run_one_frame
827
+ mask = poll_input
828
+ @input_recorder&.capture(mask) if @input_recorder&.recording?
829
+ @core.set_keys(mask)
830
+ @core.run_frame
831
+ @total_frames += 1
832
+ @running = false if @frame_limit && @total_frames >= @frame_limit
833
+ if @rewind_enabled
834
+ @rewind_frame_counter += 1
835
+ if @rewind_frame_counter >= REWIND_PUSH_INTERVAL
836
+ @core.rewind_push
837
+ @rewind_frame_counter = 0
838
+ end
839
+ end
840
+ @achievement_backend.do_frame(@core)
841
+ end
842
+
843
+ # -- Input ------------------------------------------------------------------
844
+
845
+ def setup_input
846
+ @viewport.bind('KeyPress', :keysym, '%s') do |k, state_str|
847
+ if k == 'Escape'
848
+ emit(:request_escape)
849
+ else
850
+ mods = HotkeyMap.modifiers_from_state(state_str.to_i)
851
+ case @hotkeys.action_for(k, modifiers: mods)
852
+ when :quit then @app.command(:event, 'generate', '.', '<<Quit>>')
853
+ when :pause then toggle_pause
854
+ when :fast_forward then toggle_fast_forward
855
+ when :fullscreen then emit(:request_fullscreen)
856
+ when :show_fps then emit(:request_show_fps_toggle)
857
+ when :quick_save then @app.command(:event, 'generate', '.', '<<QuickSave>>')
858
+ when :quick_load then @app.command(:event, 'generate', '.', '<<QuickLoad>>')
859
+ when :save_states then emit(:request_save_states)
860
+ when :screenshot then take_screenshot
861
+ when :rewind then do_rewind
862
+ when :record then @app.command(:event, 'generate', '.', '<<RecordToggle>>')
863
+ when :input_record then toggle_input_recording
864
+ when :open_rom then emit(:request_open_rom)
865
+ else @keyboard.press(k)
866
+ end
867
+ end
868
+ end
869
+
870
+ @viewport.bind('KeyRelease', :keysym) do |k|
871
+ @keyboard.release(k)
872
+ end
873
+
874
+ @viewport.bind('FocusIn') { @has_focus = true }
875
+ @viewport.bind('FocusOut') { @has_focus = false }
876
+
877
+ start_focus_poll
878
+
879
+ # Virtual event bindings — bound on '.' so tests can trigger them directly
880
+ # without needing widget focus. Physical key handlers above translate to
881
+ # these virtual events so the action logic lives in one place.
882
+ @app.command(:bind, '.', '<<Quit>>', proc { emit(:request_quit) })
883
+ @app.command(:bind, '.', '<<QuickSave>>', proc { quick_save })
884
+ @app.command(:bind, '.', '<<QuickLoad>>', proc { quick_load })
885
+ @app.command(:bind, '.', '<<RecordToggle>>', proc { toggle_recording })
886
+
887
+ # Alt+Return fullscreen toggle (emulator convention)
888
+ @app.command(:bind, @viewport.frame.path, '<Alt-Return>', proc { emit(:request_fullscreen) })
889
+ end
890
+
891
+ # Read keyboard + gamepad state, return combined bitmask.
892
+ def poll_input
893
+ begin
894
+ Teek::SDL2::Gamepad.update_state
895
+ rescue StandardError
896
+ @gp_map.device = nil
897
+ end
898
+ @kb_map.mask | @gp_map.mask
899
+ end
900
+
901
+ # -- Rendering --------------------------------------------------------------
902
+
903
+ def render_frame(ff_indicator: false)
904
+ pixels = @core.video_buffer_argb
905
+ @texture.update(pixels)
906
+ dest = compute_dest_rect
907
+ @viewport.render do |r|
908
+ r.clear(0, 0, 0)
909
+ r.copy(@texture, nil, dest)
910
+ if @recorder&.recording? || @input_recorder&.recording?
911
+ bx = (dest ? dest[0] : 0) + 12
912
+ by = (dest ? dest[1] : 0) + 12
913
+ if @recorder&.recording?
914
+ draw_filled_circle(r, bx, by, 5, 220, 30, 30, 200)
915
+ bx += 14
916
+ end
917
+ if @input_recorder&.recording?
918
+ draw_filled_circle(r, bx, by, 5, 30, 180, 30, 200)
919
+ end
920
+ end
921
+ @hud.draw(r, dest, show_fps: @show_fps, show_ff: ff_indicator)
922
+ @toast&.draw(r, dest)
923
+ end
924
+ end
925
+
926
+ def render_if_paused
927
+ render_frame if @paused && @core && @texture
928
+ end
929
+
930
+ # Like render_if_paused but suppresses frame blending for one frame.
931
+ # Used after state loads: mGBA's previous-frame buffer is stale, so blending
932
+ # would show a mix of the pre-load frame and the saved state frame.
933
+ def render_clean_if_paused
934
+ return unless @paused && @core && @texture
935
+ @core.frame_blending = false if @frame_blending
936
+ render_frame
937
+ @core.frame_blending = true if @frame_blending
938
+ end
939
+
940
+ def compute_dest_rect
941
+ return nil unless @keep_aspect_ratio
942
+
943
+ out_w, out_h = @viewport.renderer.output_size
944
+ scale_x = out_w.to_f / @platform.width
945
+ scale_y = out_h.to_f / @platform.height
946
+ scale = [scale_x, scale_y].min
947
+ scale = scale.floor if @integer_scale && scale >= 1.0
948
+
949
+ dest_w = (@platform.width * scale).to_i
950
+ dest_h = (@platform.height * scale).to_i
951
+ dest_x = (out_w - dest_w) / 2
952
+ dest_y = (out_h - dest_h) / 2
953
+
954
+ [dest_x, dest_y, dest_w, dest_h]
955
+ end
956
+
957
+ def draw_filled_circle(renderer, cx, cy, radius, r, g, b, a)
958
+ r2 = radius * radius
959
+ (-radius..radius).each do |dy|
960
+ dx = Math.sqrt(r2 - dy * dy).to_i
961
+ renderer.fill_rect(cx - dx, cy + dy, dx * 2 + 1, 1, r, g, b, a)
962
+ end
963
+ end
964
+
965
+ def update_fps(frames, now)
966
+ @fps_count += frames
967
+ elapsed = now - @fps_time
968
+ if elapsed >= 1.0
969
+ fps = (@fps_count / elapsed).round(1)
970
+ @hud.set_fps(translate('player.fps', fps: fps)) if @show_fps
971
+ @audio_samples_produced = 0
972
+ @fps_count = 0
973
+ @fps_time = now
974
+ end
975
+ end
976
+
977
+ # -- Audio ------------------------------------------------------------------
978
+
979
+ def queue_audio(volume_override: nil, raw_pcm: nil)
980
+ pcm = raw_pcm || @core.audio_buffer
981
+ return if pcm.empty?
982
+
983
+ @audio_samples_produced += pcm.bytesize / 4
984
+ if @muted
985
+ @audio_fade_in = 0
986
+ else
987
+ vol = volume_override || @volume
988
+ pcm = apply_volume_to_pcm(pcm, vol) if vol < 1.0
989
+ if @audio_fade_in > 0
990
+ pcm, @audio_fade_in = self.class.apply_fade_ramp(pcm, @audio_fade_in, FADE_IN_FRAMES)
991
+ end
992
+ @stream.queue(pcm)
993
+ end
994
+ end
995
+
996
+ def apply_volume_to_pcm(pcm, gain = @volume)
997
+ samples = pcm.unpack('s*')
998
+ samples.map! { |s| (s * gain).round.clamp(-32768, 32767) }
999
+ samples.pack('s*')
1000
+ end
1001
+
1002
+ # Capture current frame for recording.
1003
+ def capture_frame
1004
+ return nil unless @recorder&.recording?
1005
+ pcm = @core.audio_buffer
1006
+ @recorder.capture(@core.video_buffer_argb, pcm)
1007
+ pcm
1008
+ end
1009
+
1010
+ # -- Focus polling ----------------------------------------------------------
1011
+
1012
+ def start_focus_poll
1013
+ @had_focus = @viewport.renderer.input_focus?
1014
+ @app.after(FOCUS_POLL_MS) { focus_poll_tick }
1015
+ end
1016
+
1017
+ def focus_poll_tick
1018
+ return unless @running
1019
+
1020
+ has_focus = @viewport.renderer.input_focus?
1021
+
1022
+ if @had_focus && !has_focus
1023
+ if @pause_on_focus_loss && @core && !@paused
1024
+ @was_paused_before_focus_loss = true
1025
+ toggle_pause
1026
+ end
1027
+ elsif !@had_focus && has_focus
1028
+ if @was_paused_before_focus_loss && @paused
1029
+ @was_paused_before_focus_loss = false
1030
+ toggle_pause
1031
+ end
1032
+ end
1033
+
1034
+ @had_focus = has_focus
1035
+ @app.after(FOCUS_POLL_MS) { focus_poll_tick }
1036
+ rescue StandardError
1037
+ nil
1038
+ end
1039
+
1040
+ # -- Helpers ----------------------------------------------------------------
1041
+
1042
+ def frame_period = 1.0 / @platform.fps
1043
+ def audio_buf_capacity = (AUDIO_FREQ / @platform.fps * 6).to_i
1044
+
1045
+ def recreate_texture
1046
+ @texture&.destroy
1047
+ @texture = @viewport.renderer.create_texture(@platform.width, @platform.height, :streaming)
1048
+ @texture.scale_mode = @pixel_filter.to_sym
1049
+ end
1050
+
1051
+ def ff_label_text
1052
+ @turbo_speed == 0 ? translate('player.ff_max') : translate('player.ff', speed: @turbo_speed)
1053
+ end
1054
+
1055
+ def set_event_loop_speed(mode)
1056
+ ms = mode == :fast ? 1 : 50
1057
+ @app.interp.thread_timer_ms = ms
1058
+ end
1059
+ end
1060
+ end