gemba 0.1.1 → 0.2.1

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 (289) 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 +190 -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 +511 -0
  17. data/lib/gemba/achievements/retro_achievements/cli_sync_requester.rb +64 -0
  18. data/lib/gemba/achievements/retro_achievements/ping_worker.rb +28 -0
  19. data/lib/gemba/achievements/retro_achievements/unlock_retry_worker.rb +35 -0
  20. data/lib/gemba/achievements.rb +19 -0
  21. data/lib/gemba/achievements_window.rb +556 -0
  22. data/lib/gemba/app_controller.rb +1036 -0
  23. data/lib/gemba/bios.rb +54 -0
  24. data/lib/gemba/boxart_fetcher/libretro_backend.rb +39 -0
  25. data/lib/gemba/boxart_fetcher/null_backend.rb +12 -0
  26. data/lib/gemba/boxart_fetcher.rb +79 -0
  27. data/lib/gemba/bus_emitter.rb +13 -0
  28. data/lib/gemba/child_window.rb +24 -1
  29. data/lib/gemba/cli/commands/config_cmd.rb +83 -0
  30. data/lib/gemba/cli/commands/decode.rb +154 -0
  31. data/lib/gemba/cli/commands/patch.rb +78 -0
  32. data/lib/gemba/cli/commands/play.rb +78 -0
  33. data/lib/gemba/cli/commands/record.rb +114 -0
  34. data/lib/gemba/cli/commands/replay.rb +161 -0
  35. data/lib/gemba/cli/commands/retro_achievements.rb +213 -0
  36. data/lib/gemba/cli/commands/version.rb +22 -0
  37. data/lib/gemba/cli.rb +52 -364
  38. data/lib/gemba/config.rb +154 -1
  39. data/lib/gemba/data/gb_games.json +1 -0
  40. data/lib/gemba/data/gb_md5.json +1 -0
  41. data/lib/gemba/data/gba_games.json +1 -0
  42. data/lib/gemba/data/gba_md5.json +1 -0
  43. data/lib/gemba/data/gbc_games.json +1 -0
  44. data/lib/gemba/data/gbc_md5.json +1 -0
  45. data/lib/gemba/emulator_frame.rb +1084 -0
  46. data/lib/gemba/event_bus.rb +48 -0
  47. data/lib/gemba/frame_stack.rb +70 -0
  48. data/lib/gemba/game_index.rb +84 -0
  49. data/lib/gemba/game_picker_frame.rb +309 -0
  50. data/lib/gemba/gamepad_map.rb +103 -0
  51. data/lib/gemba/headless.rb +6 -5
  52. data/lib/gemba/headless_player.rb +33 -3
  53. data/lib/gemba/help_window.rb +61 -0
  54. data/lib/gemba/hotkey_map.rb +3 -1
  55. data/lib/gemba/input_recorder.rb +107 -0
  56. data/lib/gemba/input_replayer.rb +119 -0
  57. data/lib/gemba/keyboard_map.rb +90 -0
  58. data/lib/gemba/list_picker_frame.rb +271 -0
  59. data/lib/gemba/locales/en.yml +109 -5
  60. data/lib/gemba/locales/ja.yml +109 -5
  61. data/lib/gemba/main_window.rb +56 -0
  62. data/lib/gemba/modal_stack.rb +81 -0
  63. data/lib/gemba/patcher_window.rb +223 -0
  64. data/lib/gemba/platform/gb.rb +21 -0
  65. data/lib/gemba/platform/gba.rb +21 -0
  66. data/lib/gemba/platform/gbc.rb +23 -0
  67. data/lib/gemba/platform.rb +20 -0
  68. data/lib/gemba/platform_open.rb +19 -0
  69. data/lib/gemba/recorder.rb +4 -3
  70. data/lib/gemba/replay_player.rb +691 -0
  71. data/lib/gemba/rom_info.rb +57 -0
  72. data/lib/gemba/rom_info_window.rb +16 -3
  73. data/lib/gemba/rom_library.rb +106 -0
  74. data/lib/gemba/rom_overrides.rb +47 -0
  75. data/lib/gemba/rom_patcher/bps.rb +161 -0
  76. data/lib/gemba/rom_patcher/ips.rb +101 -0
  77. data/lib/gemba/rom_patcher/ups.rb +119 -0
  78. data/lib/gemba/rom_patcher.rb +109 -0
  79. data/lib/gemba/{rom_loader.rb → rom_resolver.rb} +7 -6
  80. data/lib/gemba/runtime.rb +59 -26
  81. data/lib/gemba/save_state_manager.rb +4 -7
  82. data/lib/gemba/save_state_picker.rb +17 -4
  83. data/lib/gemba/session_logger.rb +64 -0
  84. data/lib/gemba/settings/audio_tab.rb +77 -0
  85. data/lib/gemba/settings/gamepad_tab.rb +351 -0
  86. data/lib/gemba/settings/hotkeys_tab.rb +259 -0
  87. data/lib/gemba/settings/paths.rb +11 -0
  88. data/lib/gemba/settings/recording_tab.rb +83 -0
  89. data/lib/gemba/settings/save_states_tab.rb +91 -0
  90. data/lib/gemba/settings/system_tab.rb +377 -0
  91. data/lib/gemba/settings/video_tab.rb +318 -0
  92. data/lib/gemba/settings_window.rb +162 -1036
  93. data/lib/gemba/version.rb +1 -1
  94. data/lib/gemba/virtual_keyboard.rb +19 -0
  95. data/lib/gemba.rb +2 -12
  96. data/test/achievements_window/test_bulk_sync.rb +218 -0
  97. data/test/achievements_window/test_bus_events.rb +125 -0
  98. data/test/achievements_window/test_close_confirmation.rb +201 -0
  99. data/test/achievements_window/test_initial_state.rb +164 -0
  100. data/test/achievements_window/test_sorting.rb +227 -0
  101. data/test/achievements_window/test_tree_rendering.rb +133 -0
  102. data/test/fixtures/fake_bios.bin +0 -0
  103. data/test/fixtures/pong.gba +0 -0
  104. data/test/fixtures/test.gb +0 -0
  105. data/test/fixtures/test.gbc +0 -0
  106. data/test/fixtures/test_quicksave.ss +0 -0
  107. data/test/screenshots/no_focus.png +0 -0
  108. data/test/shared/teek_test_worker.rb +17 -1
  109. data/test/shared/tk_test_helper.rb +92 -4
  110. data/test/support/achievements_window_helpers.rb +18 -0
  111. data/test/support/fake_core.rb +25 -0
  112. data/test/support/fake_ra_runtime.rb +74 -0
  113. data/test/support/fake_requester.rb +78 -0
  114. data/test/support/player_helpers.rb +20 -5
  115. data/test/test_achievement.rb +32 -0
  116. data/test/{test_player.rb → test_app_controller.rb} +353 -85
  117. data/test/test_bios.rb +123 -0
  118. data/test/test_boxart_fetcher.rb +150 -0
  119. data/test/test_cli.rb +17 -265
  120. data/test/test_cli_config.rb +64 -0
  121. data/test/test_cli_decode.rb +97 -0
  122. data/test/test_cli_patch.rb +58 -0
  123. data/test/test_cli_play.rb +213 -0
  124. data/test/test_cli_ra.rb +175 -0
  125. data/test/test_cli_record.rb +69 -0
  126. data/test/test_cli_replay.rb +72 -0
  127. data/test/test_cli_sync_requester.rb +152 -0
  128. data/test/test_cli_version.rb +27 -0
  129. data/test/test_config.rb +3 -3
  130. data/test/test_config_ra.rb +69 -0
  131. data/test/test_core.rb +62 -1
  132. data/test/test_credentials_presenter.rb +192 -0
  133. data/test/test_event_bus.rb +100 -0
  134. data/test/test_fake_backend_achievements.rb +130 -0
  135. data/test/test_fake_backend_auth.rb +68 -0
  136. data/test/test_game_index.rb +77 -0
  137. data/test/test_game_picker_frame.rb +310 -0
  138. data/test/test_gamepad_map.rb +1 -3
  139. data/test/test_headless_player.rb +17 -3
  140. data/test/test_help_window.rb +82 -0
  141. data/test/test_hotkey_map.rb +22 -1
  142. data/test/test_input_recorder.rb +179 -0
  143. data/test/test_input_replay_determinism.rb +113 -0
  144. data/test/test_input_replayer.rb +162 -0
  145. data/test/test_keyboard_map.rb +1 -3
  146. data/test/test_libretro_backend.rb +41 -0
  147. data/test/test_list_picker_frame.rb +391 -0
  148. data/test/test_locale.rb +1 -1
  149. data/test/test_logging.rb +123 -0
  150. data/test/test_null_backend.rb +42 -0
  151. data/test/test_offline_backend.rb +116 -0
  152. data/test/test_overlay_renderer.rb +1 -1
  153. data/test/test_platform.rb +149 -0
  154. data/test/test_ra_backend.rb +313 -0
  155. data/test/test_ra_backend_unlock_gate.rb +56 -0
  156. data/test/test_ra_backend_unlock_retry.rb +123 -0
  157. data/test/test_recorder.rb +0 -3
  158. data/test/test_replay_player.rb +316 -0
  159. data/test/test_rom_info.rb +149 -0
  160. data/test/test_rom_overrides.rb +86 -0
  161. data/test/test_rom_patcher.rb +383 -0
  162. data/test/{test_rom_loader.rb → test_rom_resolver.rb} +25 -26
  163. data/test/test_save_state_manager.rb +2 -4
  164. data/test/test_settings_audio.rb +107 -0
  165. data/test/test_settings_hotkeys.rb +83 -66
  166. data/test/test_settings_recording.rb +49 -0
  167. data/test/test_settings_save_states.rb +97 -0
  168. data/test/test_settings_system.rb +133 -0
  169. data/test/test_settings_video.rb +450 -0
  170. data/test/test_settings_window.rb +76 -507
  171. data/test/test_tip_service.rb +6 -6
  172. data/test/test_toast_overlay.rb +1 -1
  173. data/test/test_virtual_events.rb +221 -0
  174. data/test/test_virtual_keyboard.rb +1 -1
  175. data/vendor/rcheevos/CHANGELOG.md +495 -0
  176. data/vendor/rcheevos/LICENSE +21 -0
  177. data/vendor/rcheevos/Package.swift +33 -0
  178. data/vendor/rcheevos/README.md +67 -0
  179. data/vendor/rcheevos/include/module.modulemap +70 -0
  180. data/vendor/rcheevos/include/rc_api_editor.h +296 -0
  181. data/vendor/rcheevos/include/rc_api_info.h +280 -0
  182. data/vendor/rcheevos/include/rc_api_request.h +77 -0
  183. data/vendor/rcheevos/include/rc_api_runtime.h +417 -0
  184. data/vendor/rcheevos/include/rc_api_user.h +262 -0
  185. data/vendor/rcheevos/include/rc_client.h +877 -0
  186. data/vendor/rcheevos/include/rc_client_raintegration.h +101 -0
  187. data/vendor/rcheevos/include/rc_consoles.h +138 -0
  188. data/vendor/rcheevos/include/rc_error.h +59 -0
  189. data/vendor/rcheevos/include/rc_export.h +100 -0
  190. data/vendor/rcheevos/include/rc_hash.h +200 -0
  191. data/vendor/rcheevos/include/rc_runtime.h +148 -0
  192. data/vendor/rcheevos/include/rc_runtime_types.h +452 -0
  193. data/vendor/rcheevos/include/rc_util.h +51 -0
  194. data/vendor/rcheevos/include/rcheevos.h +8 -0
  195. data/vendor/rcheevos/src/rapi/rc_api_common.c +1379 -0
  196. data/vendor/rcheevos/src/rapi/rc_api_common.h +88 -0
  197. data/vendor/rcheevos/src/rapi/rc_api_editor.c +625 -0
  198. data/vendor/rcheevos/src/rapi/rc_api_info.c +587 -0
  199. data/vendor/rcheevos/src/rapi/rc_api_runtime.c +901 -0
  200. data/vendor/rcheevos/src/rapi/rc_api_user.c +483 -0
  201. data/vendor/rcheevos/src/rc_client.c +6941 -0
  202. data/vendor/rcheevos/src/rc_client_external.c +281 -0
  203. data/vendor/rcheevos/src/rc_client_external.h +177 -0
  204. data/vendor/rcheevos/src/rc_client_external_versions.h +171 -0
  205. data/vendor/rcheevos/src/rc_client_internal.h +409 -0
  206. data/vendor/rcheevos/src/rc_client_raintegration.c +566 -0
  207. data/vendor/rcheevos/src/rc_client_raintegration_internal.h +61 -0
  208. data/vendor/rcheevos/src/rc_client_types.natvis +396 -0
  209. data/vendor/rcheevos/src/rc_compat.c +251 -0
  210. data/vendor/rcheevos/src/rc_compat.h +121 -0
  211. data/vendor/rcheevos/src/rc_libretro.c +915 -0
  212. data/vendor/rcheevos/src/rc_libretro.h +98 -0
  213. data/vendor/rcheevos/src/rc_util.c +199 -0
  214. data/vendor/rcheevos/src/rc_version.c +11 -0
  215. data/vendor/rcheevos/src/rc_version.h +32 -0
  216. data/vendor/rcheevos/src/rcheevos/alloc.c +312 -0
  217. data/vendor/rcheevos/src/rcheevos/condition.c +754 -0
  218. data/vendor/rcheevos/src/rcheevos/condset.c +777 -0
  219. data/vendor/rcheevos/src/rcheevos/consoleinfo.c +1215 -0
  220. data/vendor/rcheevos/src/rcheevos/format.c +330 -0
  221. data/vendor/rcheevos/src/rcheevos/lboard.c +287 -0
  222. data/vendor/rcheevos/src/rcheevos/memref.c +805 -0
  223. data/vendor/rcheevos/src/rcheevos/operand.c +607 -0
  224. data/vendor/rcheevos/src/rcheevos/rc_internal.h +390 -0
  225. data/vendor/rcheevos/src/rcheevos/rc_runtime_types.natvis +541 -0
  226. data/vendor/rcheevos/src/rcheevos/rc_validate.c +1406 -0
  227. data/vendor/rcheevos/src/rcheevos/rc_validate.h +18 -0
  228. data/vendor/rcheevos/src/rcheevos/richpresence.c +922 -0
  229. data/vendor/rcheevos/src/rcheevos/runtime.c +852 -0
  230. data/vendor/rcheevos/src/rcheevos/runtime_progress.c +1073 -0
  231. data/vendor/rcheevos/src/rcheevos/trigger.c +344 -0
  232. data/vendor/rcheevos/src/rcheevos/value.c +935 -0
  233. data/vendor/rcheevos/src/rhash/aes.c +480 -0
  234. data/vendor/rcheevos/src/rhash/aes.h +49 -0
  235. data/vendor/rcheevos/src/rhash/cdreader.c +838 -0
  236. data/vendor/rcheevos/src/rhash/hash.c +1402 -0
  237. data/vendor/rcheevos/src/rhash/hash_disc.c +1340 -0
  238. data/vendor/rcheevos/src/rhash/hash_encrypted.c +566 -0
  239. data/vendor/rcheevos/src/rhash/hash_rom.c +426 -0
  240. data/vendor/rcheevos/src/rhash/hash_zip.c +460 -0
  241. data/vendor/rcheevos/src/rhash/md5.c +382 -0
  242. data/vendor/rcheevos/src/rhash/md5.h +91 -0
  243. data/vendor/rcheevos/src/rhash/rc_hash_internal.h +116 -0
  244. data/vendor/rcheevos/test/libretro.h +205 -0
  245. data/vendor/rcheevos/test/rapi/test_rc_api_common.c +941 -0
  246. data/vendor/rcheevos/test/rapi/test_rc_api_editor.c +931 -0
  247. data/vendor/rcheevos/test/rapi/test_rc_api_info.c +545 -0
  248. data/vendor/rcheevos/test/rapi/test_rc_api_runtime.c +2213 -0
  249. data/vendor/rcheevos/test/rapi/test_rc_api_user.c +998 -0
  250. data/vendor/rcheevos/test/rcheevos/mock_memory.h +32 -0
  251. data/vendor/rcheevos/test/rcheevos/test_condition.c +570 -0
  252. data/vendor/rcheevos/test/rcheevos/test_condset.c +5170 -0
  253. data/vendor/rcheevos/test/rcheevos/test_consoleinfo.c +203 -0
  254. data/vendor/rcheevos/test/rcheevos/test_format.c +112 -0
  255. data/vendor/rcheevos/test/rcheevos/test_lboard.c +746 -0
  256. data/vendor/rcheevos/test/rcheevos/test_memref.c +520 -0
  257. data/vendor/rcheevos/test/rcheevos/test_operand.c +692 -0
  258. data/vendor/rcheevos/test/rcheevos/test_rc_validate.c +502 -0
  259. data/vendor/rcheevos/test/rcheevos/test_richpresence.c +1564 -0
  260. data/vendor/rcheevos/test/rcheevos/test_runtime.c +1667 -0
  261. data/vendor/rcheevos/test/rcheevos/test_runtime_progress.c +1821 -0
  262. data/vendor/rcheevos/test/rcheevos/test_timing.c +166 -0
  263. data/vendor/rcheevos/test/rcheevos/test_trigger.c +2521 -0
  264. data/vendor/rcheevos/test/rcheevos/test_value.c +870 -0
  265. data/vendor/rcheevos/test/rcheevos-test.sln +46 -0
  266. data/vendor/rcheevos/test/rcheevos-test.vcxproj +239 -0
  267. data/vendor/rcheevos/test/rcheevos-test.vcxproj.filters +335 -0
  268. data/vendor/rcheevos/test/rhash/data.c +657 -0
  269. data/vendor/rcheevos/test/rhash/data.h +32 -0
  270. data/vendor/rcheevos/test/rhash/mock_filereader.c +236 -0
  271. data/vendor/rcheevos/test/rhash/mock_filereader.h +31 -0
  272. data/vendor/rcheevos/test/rhash/test_cdreader.c +920 -0
  273. data/vendor/rcheevos/test/rhash/test_hash.c +310 -0
  274. data/vendor/rcheevos/test/rhash/test_hash_disc.c +1450 -0
  275. data/vendor/rcheevos/test/rhash/test_hash_rom.c +899 -0
  276. data/vendor/rcheevos/test/rhash/test_hash_zip.c +551 -0
  277. data/vendor/rcheevos/test/test.c +113 -0
  278. data/vendor/rcheevos/test/test_framework.h +205 -0
  279. data/vendor/rcheevos/test/test_rc_client.c +10509 -0
  280. data/vendor/rcheevos/test/test_rc_client_external.c +2197 -0
  281. data/vendor/rcheevos/test/test_rc_client_raintegration.c +441 -0
  282. data/vendor/rcheevos/test/test_rc_libretro.c +952 -0
  283. data/vendor/rcheevos/test/test_types.natvis +9 -0
  284. data/vendor/rcheevos/validator/validator.c +658 -0
  285. data/vendor/rcheevos/validator/validator.vcxproj +152 -0
  286. data/vendor/rcheevos/validator/validator.vcxproj.filters +82 -0
  287. metadata +277 -10
  288. data/lib/gemba/input_mappings.rb +0 -214
  289. data/lib/gemba/player.rb +0 -1525
@@ -0,0 +1,1084 @@
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
+ def take_achievement_screenshot(achievement)
496
+ return unless @core && !@core.destroyed?
497
+
498
+ dir = Config.default_screenshots_dir
499
+ FileUtils.mkdir_p(dir)
500
+
501
+ stamp = Time.now.strftime('%Y%m%d_%H%M%S')
502
+ name = "achievement_#{achievement.id}_#{stamp}.png"
503
+ path = File.join(dir, name)
504
+
505
+ pixels = @core.video_buffer_argb
506
+ photo_name = "__gemba_ach_ss_#{object_id}"
507
+ out_w = @platform.width * @scale
508
+ out_h = @platform.height * @scale
509
+ @app.command(:image, :create, :photo, photo_name, width: out_w, height: out_h)
510
+ @app.interp.photo_put_zoomed_block(photo_name, pixels, @platform.width, @platform.height,
511
+ zoom_x: @scale, zoom_y: @scale, format: :argb)
512
+ @app.command(photo_name, :write, path, format: :png)
513
+ @app.command(:image, :delete, photo_name)
514
+ rescue StandardError => e
515
+ Gemba.log(:warn) { "achievement screenshot failed: #{e.message} (#{e.class})" }
516
+ @app.command(:image, :delete, photo_name) rescue nil
517
+ end
518
+
519
+ # -- Recording --------------------------------------------------------------
520
+
521
+ def toggle_recording
522
+ return unless @core
523
+ @recorder&.recording? ? stop_recording : start_recording
524
+ end
525
+
526
+ def start_recording
527
+ dir = @config.recordings_dir
528
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
529
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%L')
530
+ title = @core.title.strip.gsub(/[^a-zA-Z0-9_.-]/, '_')
531
+ filename = "#{title}_#{timestamp}.grec"
532
+ path = File.join(dir, filename)
533
+ @recorder = Recorder.new(path, width: @platform.width, height: @platform.height,
534
+ fps_fraction: @platform.fps_fraction,
535
+ compression: @recording_compression)
536
+ @recorder.start
537
+ Gemba.log(:info) { "Recording started: #{path}" }
538
+ @toast&.show(translate('toast.recording_started'))
539
+ emit(:recording_changed)
540
+ end
541
+
542
+ def stop_recording
543
+ return unless @recorder&.recording?
544
+ @recorder.stop
545
+ count = @recorder.frame_count
546
+ Gemba.log(:info) { "Recording stopped: #{count} frames" }
547
+ @toast&.show(translate('toast.recording_stopped', frames: count))
548
+ @recorder = nil
549
+ emit(:recording_changed)
550
+ end
551
+
552
+ # -- Input recording --------------------------------------------------------
553
+
554
+ def toggle_input_recording
555
+ return unless @core
556
+ @input_recorder&.recording? ? stop_input_recording : start_input_recording
557
+ end
558
+
559
+ def start_input_recording
560
+ dir = @config.recordings_dir
561
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
562
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%L')
563
+ title = @core.title.strip.gsub(/[^a-zA-Z0-9_.-]/, '_')
564
+ filename = "#{title}_#{timestamp}.gir"
565
+ path = File.join(dir, filename)
566
+ @input_recorder = InputRecorder.new(path, core: @core, rom_path: @rom_source_path)
567
+ @input_recorder.start
568
+ @toast&.show(translate('toast.input_recording_started'))
569
+ emit(:input_recording_changed)
570
+ end
571
+
572
+ def stop_input_recording
573
+ return unless @input_recorder&.recording?
574
+ @input_recorder.stop
575
+ count = @input_recorder.frame_count
576
+ @toast&.show(translate('toast.input_recording_stopped', frames: count))
577
+ @input_recorder = nil
578
+ emit(:input_recording_changed)
579
+ end
580
+
581
+ # -- Config appliers --------------------------------------------------------
582
+
583
+ def apply_volume(vol)
584
+ @volume = vol.to_f.clamp(0.0, 1.0)
585
+ end
586
+
587
+ def apply_mute(muted)
588
+ @muted = !!muted
589
+ end
590
+
591
+ def apply_turbo_speed(speed)
592
+ @turbo_speed = speed
593
+ @hud.set_ff_label(ff_label_text) if @fast_forward
594
+ end
595
+
596
+ def apply_aspect_ratio(keep)
597
+ @keep_aspect_ratio = keep
598
+ end
599
+
600
+ def apply_show_fps(show)
601
+ @show_fps = show
602
+ @hud.set_fps(nil) unless @show_fps
603
+ end
604
+
605
+ def apply_toast_duration(secs)
606
+ @config.toast_duration = secs
607
+ @toast.duration = secs
608
+ end
609
+
610
+ def apply_pixel_filter(filter)
611
+ @pixel_filter = filter
612
+ @texture.scale_mode = filter.to_sym if @texture
613
+ end
614
+
615
+ def apply_integer_scale(enabled)
616
+ @integer_scale = !!enabled
617
+ end
618
+
619
+ def apply_color_correction(enabled)
620
+ @color_correction = !!enabled
621
+ if @core && !@core.destroyed?
622
+ @core.color_correction = @color_correction
623
+ render_if_paused
624
+ end
625
+ end
626
+
627
+ def apply_frame_blending(enabled)
628
+ @frame_blending = !!enabled
629
+ if @core && !@core.destroyed?
630
+ @core.frame_blending = @frame_blending
631
+ render_if_paused
632
+ end
633
+ end
634
+
635
+ def apply_rewind_toggle(enabled)
636
+ @rewind_enabled = !!enabled
637
+ if @core && !@core.destroyed?
638
+ if @rewind_enabled
639
+ @core.rewind_init(@rewind_seconds)
640
+ @rewind_frame_counter = 0
641
+ else
642
+ @core.rewind_deinit
643
+ end
644
+ end
645
+ end
646
+
647
+ def apply_recording_compression(val)
648
+ @recording_compression = val.to_i.clamp(1, 9)
649
+ end
650
+
651
+ def apply_pause_on_focus_loss(val)
652
+ @pause_on_focus_loss = val
653
+ @was_paused_before_focus_loss = false unless val
654
+ end
655
+
656
+ def apply_quick_slot(slot)
657
+ @quick_save_slot = slot.to_i.clamp(1, 10)
658
+ @save_mgr.quick_save_slot = @quick_save_slot if @save_mgr
659
+ end
660
+
661
+ def apply_backup(enabled)
662
+ @save_state_backup = !!enabled
663
+ @save_mgr.backup = @save_state_backup if @save_mgr
664
+ end
665
+
666
+ # Sync all config-derived state from a config object after per-game switch.
667
+ def refresh_from_config(config)
668
+ @pixel_filter = config.pixel_filter
669
+ @integer_scale = config.integer_scale?
670
+ @color_correction = config.color_correction?
671
+ @frame_blending = config.frame_blending?
672
+ @rewind_enabled = config.rewind_enabled?
673
+ @rewind_seconds = config.rewind_seconds
674
+ @quick_save_slot = config.quick_save_slot
675
+ @save_state_backup = config.save_state_backup?
676
+ @recording_compression = config.recording_compression
677
+ @volume = config.volume / 100.0
678
+ @muted = config.muted?
679
+ @turbo_speed = config.turbo_speed
680
+
681
+ @texture.scale_mode = @pixel_filter.to_sym if @texture
682
+ if @core && !@core.destroyed?
683
+ @core.color_correction = @color_correction
684
+ @core.frame_blending = @frame_blending
685
+ render_if_paused
686
+ end
687
+ @save_mgr.quick_save_slot = @quick_save_slot if @save_mgr
688
+ @save_mgr.backup = @save_state_backup if @save_mgr
689
+ end
690
+
691
+ # Write all config-derived state back to the config object.
692
+ # Called by AppController before config.save!
693
+ def write_config
694
+ @config.volume = (@volume * 100).round
695
+ @config.muted = @muted
696
+ @config.turbo_speed = @turbo_speed
697
+ @config.keep_aspect_ratio = @keep_aspect_ratio
698
+ @config.show_fps = @show_fps
699
+ @config.pixel_filter = @pixel_filter
700
+ @config.integer_scale = @integer_scale
701
+ @config.color_correction = @color_correction
702
+ @config.frame_blending = @frame_blending
703
+ @config.rewind_enabled = @rewind_enabled
704
+ @config.rewind_seconds = @rewind_seconds
705
+ @config.quick_save_slot = @quick_save_slot
706
+ @config.save_state_backup = @save_state_backup
707
+ @config.recording_compression = @recording_compression
708
+ @config.pause_on_focus_loss = @pause_on_focus_loss
709
+ end
710
+
711
+ # -- Class methods ----------------------------------------------------------
712
+
713
+ # Apply a linear fade-in ramp to int16 stereo PCM data.
714
+ # Pure function: takes remaining/total counters, returns [pcm, new_remaining].
715
+ # @param pcm [String] packed int16 stereo PCM
716
+ # @param remaining [Integer] fade samples remaining (counts down to 0)
717
+ # @param total [Integer] total fade length in samples
718
+ # @return [Array(String, Integer)] modified PCM and updated remaining count
719
+ def self.apply_fade_ramp(pcm, remaining, total)
720
+ samples = pcm.unpack('s*')
721
+ i = 0
722
+ while i < samples.length && remaining > 0
723
+ gain = 1.0 - (remaining.to_f / total)
724
+ samples[i] = (samples[i] * gain).round.clamp(-32768, 32767)
725
+ samples[i + 1] = (samples[i + 1] * gain).round.clamp(-32768, 32767) if i + 1 < samples.length
726
+ remaining -= 1
727
+ i += 2
728
+ end
729
+ [samples.pack('s*'), remaining]
730
+ end
731
+
732
+ private
733
+
734
+ def setup_bus_subscriptions
735
+ bus = Gemba.bus
736
+
737
+ # Video/rendering
738
+ bus.on(:filter_changed) { |val| apply_pixel_filter(val) }
739
+ bus.on(:integer_scale_changed) { |val| apply_integer_scale(val) }
740
+ bus.on(:color_correction_changed) { |val| apply_color_correction(val) }
741
+ bus.on(:frame_blending_changed) { |val| apply_frame_blending(val) }
742
+ bus.on(:aspect_ratio_changed) { |val| apply_aspect_ratio(val) }
743
+ bus.on(:show_fps_changed) { |val| apply_show_fps(val) }
744
+ bus.on(:toast_duration_changed) { |val| apply_toast_duration(val) }
745
+ bus.on(:turbo_speed_changed) { |val| apply_turbo_speed(val) }
746
+ bus.on(:rewind_toggled) { |val| apply_rewind_toggle(val) }
747
+ bus.on(:pause_on_focus_loss_changed) { |val| apply_pause_on_focus_loss(val) }
748
+
749
+ # Audio
750
+ bus.on(:volume_changed) { |vol| apply_volume(vol) }
751
+ bus.on(:mute_changed) { |val| apply_mute(val) }
752
+
753
+ # Recording / save states
754
+ bus.on(:compression_changed) { |val| apply_recording_compression(val) }
755
+ bus.on(:quick_slot_changed) { |val| apply_quick_slot(val) }
756
+ bus.on(:backup_changed) { |val| apply_backup(val) }
757
+
758
+ # Save state picker events
759
+ bus.on(:state_save_requested) { |slot| save_state(slot) }
760
+ bus.on(:state_load_requested) { |slot| load_state(slot) }
761
+ end
762
+
763
+ # -- Frame loop -------------------------------------------------------------
764
+
765
+ def animate
766
+ return unless @running
767
+ tick
768
+ delay = (@core && !@paused) ? 1 : 100
769
+ @app.after(delay) { animate }
770
+ end
771
+
772
+ def tick
773
+ unless @core
774
+ @viewport.render { |r| r.clear(0, 0, 0) }
775
+ return
776
+ end
777
+
778
+ return if @paused
779
+
780
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
781
+ @next_frame ||= now
782
+
783
+ if @fast_forward
784
+ tick_fast_forward(now)
785
+ else
786
+ tick_normal(now)
787
+ end
788
+ end
789
+
790
+ def tick_normal(now)
791
+ frames = 0
792
+ while @next_frame <= now && frames < 4
793
+ run_one_frame
794
+ rec_pcm = capture_frame
795
+ queue_audio(raw_pcm: rec_pcm)
796
+
797
+ fill = (@stream.queued_samples.to_f / audio_buf_capacity).clamp(0.0, 1.0)
798
+ ratio = (1.0 - MAX_DELTA) + 2.0 * fill * MAX_DELTA
799
+ @next_frame += frame_period * ratio
800
+ frames += 1
801
+ end
802
+
803
+ @next_frame = now if now - @next_frame > 0.1
804
+ return if frames == 0
805
+
806
+ render_frame
807
+ update_fps(frames, now)
808
+ end
809
+
810
+ def tick_fast_forward(now)
811
+ if @turbo_speed == 0
812
+ keys = poll_input
813
+ FF_MAX_FRAMES.times do |i|
814
+ @core.set_keys(keys)
815
+ @core.run_frame
816
+ rec_pcm = capture_frame
817
+ if i == 0
818
+ queue_audio(volume_override: @turbo_volume, raw_pcm: rec_pcm)
819
+ elsif !rec_pcm
820
+ @core.audio_buffer
821
+ end
822
+ end
823
+ @next_frame = now
824
+ render_frame(ff_indicator: true)
825
+ update_fps(FF_MAX_FRAMES, now)
826
+ return
827
+ end
828
+
829
+ frames = 0
830
+ while @next_frame <= now && frames < @turbo_speed * 4
831
+ @turbo_speed.times do
832
+ run_one_frame
833
+ rec_pcm = capture_frame
834
+ if frames == 0
835
+ queue_audio(volume_override: @turbo_volume, raw_pcm: rec_pcm)
836
+ elsif !rec_pcm
837
+ @core.audio_buffer
838
+ end
839
+ frames += 1
840
+ end
841
+ @next_frame += frame_period
842
+ end
843
+ @next_frame = now if now - @next_frame > 0.1
844
+ return if frames == 0
845
+
846
+ render_frame(ff_indicator: true)
847
+ update_fps(frames, now)
848
+ end
849
+
850
+ def run_one_frame
851
+ mask = poll_input
852
+ @input_recorder&.capture(mask) if @input_recorder&.recording?
853
+ @core.set_keys(mask)
854
+ @core.run_frame
855
+ @total_frames += 1
856
+ @running = false if @frame_limit && @total_frames >= @frame_limit
857
+ if @rewind_enabled
858
+ @rewind_frame_counter += 1
859
+ if @rewind_frame_counter >= REWIND_PUSH_INTERVAL
860
+ @core.rewind_push
861
+ @rewind_frame_counter = 0
862
+ end
863
+ end
864
+ @achievement_backend.do_frame(@core)
865
+ end
866
+
867
+ # -- Input ------------------------------------------------------------------
868
+
869
+ def setup_input
870
+ @viewport.bind('KeyPress', :keysym, '%s') do |k, state_str|
871
+ if k == 'Escape'
872
+ emit(:request_escape)
873
+ else
874
+ mods = HotkeyMap.modifiers_from_state(state_str.to_i)
875
+ case @hotkeys.action_for(k, modifiers: mods)
876
+ when :quit then @app.command(:event, 'generate', '.', '<<Quit>>')
877
+ when :pause then toggle_pause
878
+ when :fast_forward then toggle_fast_forward
879
+ when :fullscreen then emit(:request_fullscreen)
880
+ when :show_fps then emit(:request_show_fps_toggle)
881
+ when :quick_save then @app.command(:event, 'generate', '.', '<<QuickSave>>')
882
+ when :quick_load then @app.command(:event, 'generate', '.', '<<QuickLoad>>')
883
+ when :save_states then emit(:request_save_states)
884
+ when :screenshot then take_screenshot
885
+ when :rewind then do_rewind
886
+ when :record then @app.command(:event, 'generate', '.', '<<RecordToggle>>')
887
+ when :input_record then toggle_input_recording
888
+ when :open_rom then emit(:request_open_rom)
889
+ else @keyboard.press(k)
890
+ end
891
+ end
892
+ end
893
+
894
+ @viewport.bind('KeyRelease', :keysym) do |k|
895
+ @keyboard.release(k)
896
+ end
897
+
898
+ @viewport.bind('FocusIn') { @has_focus = true }
899
+ @viewport.bind('FocusOut') { @has_focus = false }
900
+
901
+ start_focus_poll
902
+
903
+ # Virtual event bindings — bound on '.' so tests can trigger them directly
904
+ # without needing widget focus. Physical key handlers above translate to
905
+ # these virtual events so the action logic lives in one place.
906
+ @app.command(:bind, '.', '<<Quit>>', proc { emit(:request_quit) })
907
+ @app.command(:bind, '.', '<<QuickSave>>', proc { quick_save })
908
+ @app.command(:bind, '.', '<<QuickLoad>>', proc { quick_load })
909
+ @app.command(:bind, '.', '<<RecordToggle>>', proc { toggle_recording })
910
+
911
+ # Alt+Return fullscreen toggle (emulator convention)
912
+ @app.command(:bind, @viewport.frame.path, '<Alt-Return>', proc { emit(:request_fullscreen) })
913
+ end
914
+
915
+ # Read keyboard + gamepad state, return combined bitmask.
916
+ def poll_input
917
+ begin
918
+ Teek::SDL2::Gamepad.update_state
919
+ rescue StandardError
920
+ @gp_map.device = nil
921
+ end
922
+ @kb_map.mask | @gp_map.mask
923
+ end
924
+
925
+ # -- Rendering --------------------------------------------------------------
926
+
927
+ def render_frame(ff_indicator: false)
928
+ pixels = @core.video_buffer_argb
929
+ @texture.update(pixels)
930
+ dest = compute_dest_rect
931
+ @viewport.render do |r|
932
+ r.clear(0, 0, 0)
933
+ r.copy(@texture, nil, dest)
934
+ if @recorder&.recording? || @input_recorder&.recording?
935
+ bx = (dest ? dest[0] : 0) + 12
936
+ by = (dest ? dest[1] : 0) + 12
937
+ if @recorder&.recording?
938
+ draw_filled_circle(r, bx, by, 5, 220, 30, 30, 200)
939
+ bx += 14
940
+ end
941
+ if @input_recorder&.recording?
942
+ draw_filled_circle(r, bx, by, 5, 30, 180, 30, 200)
943
+ end
944
+ end
945
+ @hud.draw(r, dest, show_fps: @show_fps, show_ff: ff_indicator)
946
+ @toast&.draw(r, dest)
947
+ end
948
+ end
949
+
950
+ def render_if_paused
951
+ render_frame if @paused && @core && @texture
952
+ end
953
+
954
+ # Like render_if_paused but suppresses frame blending for one frame.
955
+ # Used after state loads: mGBA's previous-frame buffer is stale, so blending
956
+ # would show a mix of the pre-load frame and the saved state frame.
957
+ def render_clean_if_paused
958
+ return unless @paused && @core && @texture
959
+ @core.frame_blending = false if @frame_blending
960
+ render_frame
961
+ @core.frame_blending = true if @frame_blending
962
+ end
963
+
964
+ def compute_dest_rect
965
+ return nil unless @keep_aspect_ratio
966
+
967
+ out_w, out_h = @viewport.renderer.output_size
968
+ scale_x = out_w.to_f / @platform.width
969
+ scale_y = out_h.to_f / @platform.height
970
+ scale = [scale_x, scale_y].min
971
+ scale = scale.floor if @integer_scale && scale >= 1.0
972
+
973
+ dest_w = (@platform.width * scale).to_i
974
+ dest_h = (@platform.height * scale).to_i
975
+ dest_x = (out_w - dest_w) / 2
976
+ dest_y = (out_h - dest_h) / 2
977
+
978
+ [dest_x, dest_y, dest_w, dest_h]
979
+ end
980
+
981
+ def draw_filled_circle(renderer, cx, cy, radius, r, g, b, a)
982
+ r2 = radius * radius
983
+ (-radius..radius).each do |dy|
984
+ dx = Math.sqrt(r2 - dy * dy).to_i
985
+ renderer.fill_rect(cx - dx, cy + dy, dx * 2 + 1, 1, r, g, b, a)
986
+ end
987
+ end
988
+
989
+ def update_fps(frames, now)
990
+ @fps_count += frames
991
+ elapsed = now - @fps_time
992
+ if elapsed >= 1.0
993
+ fps = (@fps_count / elapsed).round(1)
994
+ @hud.set_fps(translate('player.fps', fps: fps)) if @show_fps
995
+ @audio_samples_produced = 0
996
+ @fps_count = 0
997
+ @fps_time = now
998
+ end
999
+ end
1000
+
1001
+ # -- Audio ------------------------------------------------------------------
1002
+
1003
+ def queue_audio(volume_override: nil, raw_pcm: nil)
1004
+ pcm = raw_pcm || @core.audio_buffer
1005
+ return if pcm.empty?
1006
+
1007
+ @audio_samples_produced += pcm.bytesize / 4
1008
+ if @muted
1009
+ @audio_fade_in = 0
1010
+ else
1011
+ vol = volume_override || @volume
1012
+ pcm = apply_volume_to_pcm(pcm, vol) if vol < 1.0
1013
+ if @audio_fade_in > 0
1014
+ pcm, @audio_fade_in = self.class.apply_fade_ramp(pcm, @audio_fade_in, FADE_IN_FRAMES)
1015
+ end
1016
+ @stream.queue(pcm)
1017
+ end
1018
+ end
1019
+
1020
+ def apply_volume_to_pcm(pcm, gain = @volume)
1021
+ samples = pcm.unpack('s*')
1022
+ samples.map! { |s| (s * gain).round.clamp(-32768, 32767) }
1023
+ samples.pack('s*')
1024
+ end
1025
+
1026
+ # Capture current frame for recording.
1027
+ def capture_frame
1028
+ return nil unless @recorder&.recording?
1029
+ pcm = @core.audio_buffer
1030
+ @recorder.capture(@core.video_buffer_argb, pcm)
1031
+ pcm
1032
+ end
1033
+
1034
+ # -- Focus polling ----------------------------------------------------------
1035
+
1036
+ def start_focus_poll
1037
+ @had_focus = @viewport.renderer.input_focus?
1038
+ @app.after(FOCUS_POLL_MS) { focus_poll_tick }
1039
+ end
1040
+
1041
+ def focus_poll_tick
1042
+ return unless @running
1043
+
1044
+ has_focus = @viewport.renderer.input_focus?
1045
+
1046
+ if @had_focus && !has_focus
1047
+ if @pause_on_focus_loss && @core && !@paused
1048
+ @was_paused_before_focus_loss = true
1049
+ toggle_pause
1050
+ end
1051
+ elsif !@had_focus && has_focus
1052
+ if @was_paused_before_focus_loss && @paused
1053
+ @was_paused_before_focus_loss = false
1054
+ toggle_pause
1055
+ end
1056
+ end
1057
+
1058
+ @had_focus = has_focus
1059
+ @app.after(FOCUS_POLL_MS) { focus_poll_tick }
1060
+ rescue StandardError
1061
+ nil
1062
+ end
1063
+
1064
+ # -- Helpers ----------------------------------------------------------------
1065
+
1066
+ def frame_period = 1.0 / @platform.fps
1067
+ def audio_buf_capacity = (AUDIO_FREQ / @platform.fps * 6).to_i
1068
+
1069
+ def recreate_texture
1070
+ @texture&.destroy
1071
+ @texture = @viewport.renderer.create_texture(@platform.width, @platform.height, :streaming)
1072
+ @texture.scale_mode = @pixel_filter.to_sym
1073
+ end
1074
+
1075
+ def ff_label_text
1076
+ @turbo_speed == 0 ? translate('player.ff_max') : translate('player.ff', speed: @turbo_speed)
1077
+ end
1078
+
1079
+ def set_event_loop_speed(mode)
1080
+ ms = mode == :fast ? 1 : 50
1081
+ @app.interp.thread_timer_ms = ms
1082
+ end
1083
+ end
1084
+ end