gemba 0.1.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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/THIRD_PARTY_NOTICES +113 -0
  3. data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
  4. data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
  5. data/bin/gemba +14 -0
  6. data/ext/gemba/extconf.rb +185 -0
  7. data/ext/gemba/gemba_ext.c +1051 -0
  8. data/ext/gemba/gemba_ext.h +15 -0
  9. data/gemba.gemspec +38 -0
  10. data/lib/gemba/child_window.rb +62 -0
  11. data/lib/gemba/cli.rb +384 -0
  12. data/lib/gemba/config.rb +621 -0
  13. data/lib/gemba/core.rb +121 -0
  14. data/lib/gemba/headless.rb +12 -0
  15. data/lib/gemba/headless_player.rb +206 -0
  16. data/lib/gemba/hotkey_map.rb +202 -0
  17. data/lib/gemba/input_mappings.rb +214 -0
  18. data/lib/gemba/locale.rb +92 -0
  19. data/lib/gemba/locales/en.yml +157 -0
  20. data/lib/gemba/locales/ja.yml +157 -0
  21. data/lib/gemba/method_coverage_service.rb +265 -0
  22. data/lib/gemba/overlay_renderer.rb +109 -0
  23. data/lib/gemba/player.rb +1515 -0
  24. data/lib/gemba/recorder.rb +156 -0
  25. data/lib/gemba/recorder_decoder.rb +325 -0
  26. data/lib/gemba/rom_info_window.rb +346 -0
  27. data/lib/gemba/rom_loader.rb +100 -0
  28. data/lib/gemba/runtime.rb +39 -0
  29. data/lib/gemba/save_state_manager.rb +155 -0
  30. data/lib/gemba/save_state_picker.rb +199 -0
  31. data/lib/gemba/settings_window.rb +1173 -0
  32. data/lib/gemba/tip_service.rb +133 -0
  33. data/lib/gemba/toast_overlay.rb +128 -0
  34. data/lib/gemba/version.rb +5 -0
  35. data/lib/gemba.rb +17 -0
  36. data/test/fixtures/test.gba +0 -0
  37. data/test/fixtures/test.sav +0 -0
  38. data/test/shared/screenshot_helper.rb +113 -0
  39. data/test/shared/simplecov_config.rb +59 -0
  40. data/test/shared/teek_test_worker.rb +388 -0
  41. data/test/shared/tk_test_helper.rb +354 -0
  42. data/test/support/input_mocks.rb +61 -0
  43. data/test/support/player_helpers.rb +77 -0
  44. data/test/test_cli.rb +281 -0
  45. data/test/test_config.rb +897 -0
  46. data/test/test_core.rb +401 -0
  47. data/test/test_gamepad_map.rb +116 -0
  48. data/test/test_headless_player.rb +205 -0
  49. data/test/test_helper.rb +19 -0
  50. data/test/test_hotkey_map.rb +396 -0
  51. data/test/test_keyboard_map.rb +108 -0
  52. data/test/test_locale.rb +159 -0
  53. data/test/test_mgba.rb +26 -0
  54. data/test/test_overlay_renderer.rb +199 -0
  55. data/test/test_player.rb +903 -0
  56. data/test/test_recorder.rb +180 -0
  57. data/test/test_rom_loader.rb +149 -0
  58. data/test/test_save_state_manager.rb +289 -0
  59. data/test/test_settings_hotkeys.rb +434 -0
  60. data/test/test_settings_window.rb +1039 -0
  61. data/test/test_tip_service.rb +138 -0
  62. data/test/test_toast_overlay.rb +216 -0
  63. data/test/test_virtual_keyboard.rb +39 -0
  64. data/test/test_xor_delta.rb +61 -0
  65. metadata +234 -0
@@ -0,0 +1,1515 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require_relative 'locale'
5
+
6
+ module Gemba
7
+ # Full-featured GBA player with SDL2 video/audio rendering,
8
+ # keyboard and gamepad input, save states, and recording.
9
+ #
10
+ # @example Launch with a ROM
11
+ # Gemba::Player.new("pokemon.gba").run
12
+ #
13
+ # @example Launch without a ROM (use File > Open ROM...)
14
+ # Gemba::Player.new.run
15
+ #
16
+ # @see file:INTERNALS.md for frame pacing, audio sync, and rendering details
17
+ class Player
18
+ include Gemba
19
+ include Locale::Translatable
20
+
21
+ GBA_W = 240
22
+ GBA_H = 160
23
+ DEFAULT_SCALE = 3
24
+
25
+ # GBA audio: mGBA outputs at 44100 Hz (stereo int16)
26
+ AUDIO_FREQ = 44100
27
+ GBA_FPS = 59.7272
28
+ FRAME_PERIOD = 1.0 / GBA_FPS
29
+
30
+ # Dynamic rate control constants (see tick_normal for the math)
31
+ AUDIO_BUF_CAPACITY = (AUDIO_FREQ / GBA_FPS * 6).to_i # ~6 frames (~100ms)
32
+ MAX_DELTA = 0.005 # ±0.5% max adjustment
33
+ FF_MAX_FRAMES = 10 # cap for uncapped turbo to avoid locking event loop
34
+ SAVE_STATE_DEBOUNCE_DEFAULT = 3.0 # seconds; overridden by config
35
+ SAVE_STATE_SLOTS = 10
36
+ FADE_IN_FRAMES = (AUDIO_FREQ * 0.02).to_i # ~20ms = 882 samples
37
+ GAMEPAD_PROBE_MS = 2000
38
+ GAMEPAD_LISTEN_MS = 50
39
+ EVENT_LOOP_FAST_MS = 1 # 1ms — needed for smooth emulation frame pacing
40
+ EVENT_LOOP_IDLE_MS = 50 # 50ms — sufficient for UI interaction when idle/paused
41
+
42
+ # Modal child window types → locale keys for the window title overlay
43
+ MODAL_LABELS = {
44
+ settings: 'menu.settings',
45
+ picker: 'menu.save_states',
46
+ rom_info: 'menu.rom_info',
47
+ }.freeze
48
+
49
+ def initialize(rom_path = nil, sound: true, fullscreen: false, frames: nil)
50
+ @app = Teek::App.new
51
+ @app.interp.thread_timer_ms = EVENT_LOOP_IDLE_MS
52
+ @app.show
53
+
54
+ @sound = sound
55
+ @config = Gemba.user_config
56
+ @scale = @config.scale
57
+ @volume = @config.volume / 100.0
58
+ @muted = @config.muted?
59
+ @kb_map = KeyboardMap.new(@config)
60
+ @gp_map = GamepadMap.new(@config)
61
+ @keyboard = VirtualKeyboard.new
62
+ @kb_map.device = @keyboard
63
+ @hotkeys = HotkeyMap.new(@config)
64
+ @turbo_speed = @config.turbo_speed
65
+ @turbo_volume = @config.turbo_volume_pct / 100.0
66
+ @keep_aspect_ratio = @config.keep_aspect_ratio?
67
+ @show_fps = @config.show_fps?
68
+ @pixel_filter = @config.pixel_filter
69
+ @integer_scale = @config.integer_scale?
70
+ @color_correction = @config.color_correction?
71
+ @frame_blending = @config.frame_blending?
72
+ @rewind_enabled = @config.rewind_enabled?
73
+ @rewind_seconds = @config.rewind_seconds
74
+ @rewind_frame_counter = 0
75
+ @audio_fade_in = 0
76
+ @frame_limit = frames
77
+ @total_frames = 0
78
+ @fast_forward = false
79
+ @fullscreen = fullscreen
80
+ @quick_save_slot = @config.quick_save_slot
81
+ @save_state_backup = @config.save_state_backup?
82
+ @save_mgr = nil # created when ROM loaded
83
+ @recorder = nil
84
+ @recording_compression = @config.recording_compression
85
+ @pause_on_focus_loss = @config.pause_on_focus_loss?
86
+ check_writable_dirs
87
+
88
+ win_w = GBA_W * @scale
89
+ win_h = GBA_H * @scale
90
+ @app.set_window_title("mGBA Player")
91
+ @app.set_window_geometry("#{win_w}x#{win_h}")
92
+
93
+ build_menu
94
+
95
+ @rom_info_window = RomInfoWindow.new(@app, callbacks: {
96
+ on_close: method(:on_child_window_close),
97
+ })
98
+ @state_picker = SaveStatePicker.new(@app, callbacks: {
99
+ on_save: method(:save_state),
100
+ on_load: method(:load_state),
101
+ on_close: method(:on_child_window_close),
102
+ })
103
+
104
+ @settings_window = SettingsWindow.new(@app, tip_dismiss_ms: @config.tip_dismiss_ms, callbacks: {
105
+ on_scale_change: method(:apply_scale),
106
+ on_volume_change: method(:apply_volume),
107
+ on_mute_change: method(:apply_mute),
108
+ on_gamepad_map_change: ->(btn, gp) { active_input.set(btn, gp) },
109
+ on_keyboard_map_change: ->(btn, key) { active_input.set(btn, key) },
110
+ on_deadzone_change: ->(val) { active_input.set_dead_zone(val) },
111
+ on_gamepad_reset: -> { active_input.reset! },
112
+ on_keyboard_reset: -> { active_input.reset! },
113
+ on_undo_gamepad: method(:undo_mappings),
114
+ on_validate_hotkey: method(:validate_hotkey),
115
+ on_validate_kb_mapping: method(:validate_kb_mapping),
116
+ on_hotkey_change: ->(action, key) { @hotkeys.set(action, key) },
117
+ on_hotkey_reset: -> { @hotkeys.reset! },
118
+ on_undo_hotkeys: method(:undo_hotkeys),
119
+ on_turbo_speed_change: method(:apply_turbo_speed),
120
+ on_aspect_ratio_change: method(:apply_aspect_ratio),
121
+ on_show_fps_change: method(:apply_show_fps),
122
+ on_filter_change: method(:apply_pixel_filter),
123
+ on_integer_scale_change: method(:apply_integer_scale),
124
+ on_color_correction_change: method(:apply_color_correction),
125
+ on_frame_blending_change: method(:apply_frame_blending),
126
+ on_rewind_toggle: method(:apply_rewind_toggle),
127
+ on_per_game_toggle: method(:toggle_per_game),
128
+ on_toast_duration_change: method(:apply_toast_duration),
129
+ on_quick_slot_change: method(:apply_quick_slot),
130
+ on_backup_change: method(:apply_backup),
131
+ on_compression_change: method(:apply_recording_compression),
132
+ on_pause_on_focus_loss_change: method(:apply_pause_on_focus_loss),
133
+ on_open_config_dir: method(:open_config_dir),
134
+ on_open_recordings_dir: method(:open_recordings_dir),
135
+ on_close: method(:on_child_window_close),
136
+ on_save: method(:save_config),
137
+ })
138
+
139
+ # Push loaded config into the settings UI
140
+ @settings_window.refresh_gamepad(@kb_map.labels, @kb_map.dead_zone_pct)
141
+ @settings_window.refresh_hotkeys(@hotkeys.labels)
142
+ push_settings_to_ui
143
+
144
+ # Input/emulation state (initialized before SDL2)
145
+ @gamepad = nil
146
+ @running = true
147
+ @paused = false
148
+ @core = nil
149
+ @rom_path = nil
150
+ @initial_rom = rom_path
151
+ @modal_child = nil # tracks which child window is open
152
+ @sdl2_ready = false
153
+ @animate_started = false
154
+
155
+ # Status label (shown when no ROM loaded)
156
+ @status_label = '.status_overlay'
157
+ @app.command(:label, @status_label,
158
+ text: translate('player.open_rom_hint'),
159
+ fg: '#888888', bg: '#000000',
160
+ font: '{TkDefaultFont} 11')
161
+ @app.command(:place, @status_label,
162
+ relx: 0.5, rely: 0.85, anchor: :center)
163
+
164
+ setup_drop_target
165
+ end
166
+
167
+ # @return [Teek::App]
168
+ attr_reader :app
169
+
170
+ # @return [Gemba::Config]
171
+ attr_reader :config
172
+
173
+ # @return [Gemba::Viewport, nil] nil until SDL2 init
174
+ attr_reader :viewport
175
+
176
+ # @return [Gemba::Core, nil] nil until ROM loaded
177
+ attr_reader :core
178
+
179
+ # @return [Gemba::Recorder, nil] nil when not recording
180
+ attr_reader :recorder
181
+
182
+ # @return [Boolean] whether the main loop is running
183
+ attr_reader :running
184
+
185
+ # @return [Boolean] true after SDL2 viewport/audio/renderer are initialized
186
+ def sdl2_ready? = @sdl2_ready
187
+
188
+ # @return [Boolean] true when the player is ready for interaction.
189
+ # With a ROM: waits for SDL2 init and ROM load. Without: immediately ready.
190
+ def ready? = @initial_rom ? !!@core : true
191
+
192
+ def running=(val)
193
+ @running = val
194
+ return if val
195
+ # Without the animate loop (no SDL2 yet), exit mainloop directly
196
+ unless @sdl2_ready
197
+ cleanup
198
+ @app.command(:destroy, '.')
199
+ end
200
+ end
201
+
202
+ # @return [Integer] current video scale multiplier
203
+ attr_reader :scale
204
+
205
+ # @return [Float] current audio volume (0.0-1.0)
206
+ attr_reader :volume
207
+
208
+ # @return [Boolean] whether audio is muted
209
+ def muted?
210
+ @muted
211
+ end
212
+
213
+ # @return [Boolean] whether currently recording
214
+ def recording?
215
+ @recorder&.recording? || false
216
+ end
217
+
218
+ # @return [Gemba::SettingsWindow]
219
+ attr_reader :settings_window
220
+
221
+ # @return [Gemba::SaveStateManager, nil] nil until ROM loaded
222
+ attr_reader :save_mgr
223
+
224
+ # @return [Gemba::KeyboardMap]
225
+ attr_reader :kb_map
226
+
227
+ # @return [Gemba::GamepadMap]
228
+ attr_reader :gp_map
229
+
230
+ def run
231
+ if @initial_rom
232
+ @app.after(1) { load_rom(@initial_rom) }
233
+ end
234
+ @app.mainloop
235
+ ensure
236
+ cleanup
237
+ end
238
+
239
+ private
240
+
241
+ # Deferred SDL2 initialization — runs inside the event loop so the
242
+ # window is already painted and responsive. Without this, the heavy
243
+ # SDL2 C calls (renderer, audio device, gamepad IOKit) block the
244
+ # main thread before macOS has a chance to display the window,
245
+ # causing a brief spinning beach ball.
246
+ def init_sdl2
247
+ return if @sdl2_ready
248
+
249
+ @app.command('tk', 'busy', '.')
250
+
251
+ win_w = GBA_W * @scale
252
+ win_h = GBA_H * @scale
253
+
254
+ @viewport = Teek::SDL2::Viewport.new(@app, width: win_w, height: win_h, vsync: false)
255
+ @viewport.pack(fill: :both, expand: true)
256
+
257
+ # Reposition status label onto viewport frame
258
+ @app.command(:place, @status_label,
259
+ in: @viewport.frame.path,
260
+ relx: 0.5, rely: 0.85, anchor: :center)
261
+
262
+ # Streaming texture at native GBA resolution
263
+ @texture = @viewport.renderer.create_texture(GBA_W, GBA_H, :streaming)
264
+ @texture.scale_mode = @pixel_filter.to_sym
265
+
266
+ # Font for on-screen indicators (FPS, fast-forward label)
267
+ font_path = File.join(ASSETS_DIR, 'JetBrainsMonoNL-Regular.ttf')
268
+ @overlay_font = File.exist?(font_path) ? @viewport.renderer.load_font(font_path, 14) : nil
269
+
270
+ # CJK-capable font for toast notifications and translated UI text
271
+ toast_font_path = File.join(ASSETS_DIR, 'ark-pixel-12px-monospaced-ja.ttf')
272
+ toast_font = File.exist?(toast_font_path) ? @viewport.renderer.load_font(toast_font_path, 12) : @overlay_font
273
+
274
+ @toast = ToastOverlay.new(
275
+ renderer: @viewport.renderer,
276
+ font: toast_font || @overlay_font,
277
+ duration: @config.toast_duration
278
+ )
279
+
280
+ # Custom blend mode: white text inverts the background behind it.
281
+ # dstRGB = (1 - dstRGB) * srcRGB + dstRGB * (1 - srcA)
282
+ # Where srcA=1 (opaque text): result = 1 - dst (inverted)
283
+ # Where srcA=0 (transparent): result = dst (unchanged)
284
+ inverse_blend = Teek::SDL2.compose_blend_mode(
285
+ :one_minus_dst_color, :one_minus_src_alpha, :add,
286
+ :zero, :one, :add
287
+ )
288
+
289
+ @hud = OverlayRenderer.new(font: @overlay_font, blend_mode: inverse_blend)
290
+
291
+ # Audio stream — stereo int16 at GBA sample rate.
292
+ # Falls back to a silent no-op stream when sound is disabled or
293
+ # no audio device is available (e.g. CI servers, headless).
294
+ if @sound && Teek::SDL2::AudioStream.available?
295
+ @stream = Teek::SDL2::AudioStream.new(
296
+ frequency: AUDIO_FREQ,
297
+ format: :s16,
298
+ channels: 2
299
+ )
300
+ @stream.resume
301
+ else
302
+ warn "mGBA Player: no audio device found, continuing without sound" if @sound
303
+ @stream = Teek::SDL2::NullAudioStream.new
304
+ end
305
+
306
+ # Initialize gamepad subsystem for hot-plug detection
307
+ Teek::SDL2::Gamepad.init_subsystem
308
+ Teek::SDL2::Gamepad.on_added { |_| refresh_gamepads }
309
+ Teek::SDL2::Gamepad.on_removed { |_| @gamepad = nil; @gp_map.device = nil; refresh_gamepads }
310
+ refresh_gamepads
311
+ start_gamepad_probe
312
+
313
+ setup_input
314
+
315
+ # Apply fullscreen before unblocking (set via CLI --fullscreen)
316
+ @app.command(:wm, 'attributes', '.', '-fullscreen', 1) if @fullscreen
317
+
318
+ @sdl2_ready = true
319
+
320
+ # Unblock interaction now that SDL2 is ready
321
+ @app.command('tk', 'busy', 'forget', '.')
322
+
323
+ # Auto-focus viewport for keyboard input
324
+ @app.tcl_eval("focus -force #{@viewport.frame.path}")
325
+ @app.update
326
+ rescue => e
327
+ # Surface init failures visibly — Tk's event loop can swallow
328
+ # exceptions from `after` callbacks, causing silent hangs.
329
+ $stderr.puts "FATAL: init_sdl2 failed: #{e.class}: #{e.message}"
330
+ $stderr.puts e.backtrace.first(10).map { |l| " #{l}" }.join("\n")
331
+ @app.command('tk', 'busy', 'forget', '.') rescue nil
332
+ @running = false
333
+ end
334
+
335
+ def show_rom_info
336
+ return unless @core && !@core.destroyed?
337
+ return bell if @modal_child
338
+ @modal_child = :rom_info
339
+ enter_modal
340
+ saves = @config.saves_dir
341
+ sav_name = File.basename(@rom_path, File.extname(@rom_path)) + '.sav'
342
+ sav_path = File.join(saves, sav_name)
343
+ @rom_info_window.show(@core, rom_path: @rom_path, save_path: sav_path)
344
+ end
345
+
346
+ # -- Save states (delegated to SaveStateManager) -------------------------
347
+
348
+ def save_state(slot)
349
+ return unless @save_mgr
350
+ _ok, msg = @save_mgr.save_state(slot)
351
+ @toast&.show(msg) if msg
352
+ end
353
+
354
+ def load_state(slot)
355
+ return unless @save_mgr
356
+ _ok, msg = @save_mgr.load_state(slot)
357
+ @toast&.show(msg) if msg
358
+ end
359
+
360
+ def quick_save
361
+ return unless @save_mgr
362
+ _ok, msg = @save_mgr.quick_save
363
+ @toast&.show(msg) if msg
364
+ end
365
+
366
+ def quick_load
367
+ return unless @save_mgr
368
+ _ok, msg = @save_mgr.quick_load
369
+ @toast&.show(msg) if msg
370
+ end
371
+
372
+ def take_screenshot
373
+ return unless @core && !@core.destroyed?
374
+
375
+ dir = Config.default_screenshots_dir
376
+ FileUtils.mkdir_p(dir)
377
+
378
+ title = @core.title.strip.gsub(/[^a-zA-Z0-9_\-]/, '_')
379
+ stamp = Time.now.strftime('%Y%m%d_%H%M%S')
380
+ name = "#{title}_#{stamp}.png"
381
+ path = File.join(dir, name)
382
+
383
+ pixels = @core.video_buffer_argb
384
+ photo_name = "__gemba_ss_#{object_id}"
385
+ out_w = GBA_W * @scale
386
+ out_h = GBA_H * @scale
387
+ @app.command(:image, :create, :photo, photo_name,
388
+ width: out_w, height: out_h)
389
+ @app.interp.photo_put_zoomed_block(photo_name, pixels, GBA_W, GBA_H,
390
+ zoom_x: @scale, zoom_y: @scale, format: :argb)
391
+ @app.command(photo_name, :write, path, format: :png)
392
+ @app.command(:image, :delete, photo_name)
393
+ @toast&.show(translate('toast.screenshot_saved', name: name))
394
+ rescue StandardError => e
395
+ warn "gemba: screenshot failed: #{e.message} (#{e.class})"
396
+ @app.command(:image, :delete, photo_name) rescue nil
397
+ @toast&.show(translate('toast.screenshot_failed'))
398
+ end
399
+
400
+ def show_settings(tab: nil)
401
+ return bell if @modal_child
402
+ @modal_child = :settings
403
+ enter_modal
404
+ @settings_window.show(tab: tab)
405
+ end
406
+
407
+ def show_state_picker
408
+ return unless @save_mgr&.state_dir
409
+ return bell if @modal_child
410
+ @modal_child = :picker
411
+ enter_modal
412
+ @state_picker.show(state_dir: @save_mgr.state_dir, quick_slot: @quick_save_slot)
413
+ end
414
+
415
+ def on_child_window_close
416
+ @toast&.destroy
417
+ toggle_pause if @core && !@was_paused_before_modal
418
+ @modal_child = nil
419
+ end
420
+
421
+ def enter_modal
422
+ @was_paused_before_modal = @paused
423
+ toggle_fast_forward if @fast_forward
424
+ toggle_pause if @core && !@paused
425
+ locale_key = MODAL_LABELS[@modal_child] || @modal_child.to_s
426
+ label = translate(locale_key)
427
+ @toast&.show(translate('toast.waiting_for', label: label), permanent: true)
428
+ end
429
+
430
+ def bell
431
+ @app.command(:bell)
432
+ end
433
+
434
+ def show_rom_error(message)
435
+ @app.command('tk_messageBox',
436
+ parent: '.',
437
+ title: translate('dialog.drop_error_title'),
438
+ message: message,
439
+ type: :ok,
440
+ icon: :error)
441
+ end
442
+
443
+ def save_config
444
+ @config.scale = @scale
445
+ @config.volume = (@volume * 100).round
446
+ @config.muted = @muted
447
+ @config.turbo_speed = @turbo_speed
448
+ @config.keep_aspect_ratio = @keep_aspect_ratio
449
+ @config.show_fps = @show_fps
450
+ @config.pixel_filter = @pixel_filter
451
+ @config.integer_scale = @integer_scale
452
+ @config.color_correction = @color_correction
453
+ @config.frame_blending = @frame_blending
454
+ @config.rewind_enabled = @rewind_enabled
455
+ @config.rewind_seconds = @rewind_seconds
456
+ @config.quick_save_slot = @quick_save_slot
457
+ @config.save_state_backup = @save_state_backup
458
+ @config.recording_compression = @recording_compression
459
+ @config.pause_on_focus_loss = @pause_on_focus_loss
460
+
461
+ @kb_map.save_to_config
462
+ @gp_map.save_to_config
463
+ @hotkeys.save_to_config
464
+ @config.save!
465
+ end
466
+
467
+ def apply_scale(new_scale)
468
+ @scale = new_scale.clamp(1, 4)
469
+ w = GBA_W * @scale
470
+ h = GBA_H * @scale
471
+ @app.set_window_geometry("#{w}x#{h}")
472
+ end
473
+
474
+ def apply_volume(vol)
475
+ @volume = vol.to_f.clamp(0.0, 1.0)
476
+ end
477
+
478
+ def apply_mute(muted)
479
+ @muted = !!muted
480
+ end
481
+
482
+ def apply_pixel_filter(filter)
483
+ @pixel_filter = filter
484
+ @texture.scale_mode = filter.to_sym if @texture
485
+ end
486
+
487
+ def apply_integer_scale(enabled)
488
+ @integer_scale = !!enabled
489
+ end
490
+
491
+ def apply_color_correction(enabled)
492
+ @color_correction = !!enabled
493
+ if @core && !@core.destroyed?
494
+ @core.color_correction = @color_correction
495
+ render_frame if @texture
496
+ end
497
+ end
498
+
499
+ def apply_frame_blending(enabled)
500
+ @frame_blending = !!enabled
501
+ if @core && !@core.destroyed?
502
+ @core.frame_blending = @frame_blending
503
+ render_frame if @texture
504
+ end
505
+ end
506
+
507
+ def apply_rewind_toggle(enabled)
508
+ @rewind_enabled = !!enabled
509
+ if @core && !@core.destroyed?
510
+ if @rewind_enabled
511
+ @core.rewind_init(@rewind_seconds)
512
+ @rewind_frame_counter = 0
513
+ else
514
+ @core.rewind_deinit
515
+ end
516
+ end
517
+ end
518
+
519
+ def do_rewind
520
+ return unless @core && !@core.destroyed?
521
+ unless @rewind_enabled
522
+ @toast&.show(translate('toast.no_rewind'))
523
+ render_if_paused
524
+ return
525
+ end
526
+ if @core.rewind_pop == true
527
+ @core.run_frame # refresh video buffer from restored state
528
+ @stream.clear
529
+ @audio_fade_in = FADE_IN_FRAMES
530
+ @rewind_frame_counter = 0
531
+ @toast&.show(translate('toast.rewound'))
532
+ render_frame
533
+ else
534
+ @toast&.show(translate('toast.no_rewind'))
535
+ render_if_paused
536
+ end
537
+ end
538
+
539
+ def toggle_per_game(enabled)
540
+ if enabled
541
+ @config.enable_per_game
542
+ else
543
+ @config.disable_per_game
544
+ end
545
+ refresh_from_config
546
+ end
547
+
548
+ # Re-read per-game-eligible settings from config and apply them.
549
+ def refresh_from_config
550
+ @scale = @config.scale
551
+ @volume = @config.volume / 100.0
552
+ @muted = @config.muted?
553
+ @turbo_speed = @config.turbo_speed
554
+ @pixel_filter = @config.pixel_filter
555
+ @integer_scale = @config.integer_scale?
556
+ @color_correction = @config.color_correction?
557
+ @frame_blending = @config.frame_blending?
558
+ @rewind_enabled = @config.rewind_enabled?
559
+ @rewind_seconds = @config.rewind_seconds
560
+ @quick_save_slot = @config.quick_save_slot
561
+ @save_state_backup = @config.save_state_backup?
562
+ @recording_compression = @config.recording_compression
563
+
564
+ push_settings_to_ui
565
+
566
+ # Apply runtime effects
567
+ apply_scale(@scale) if @viewport
568
+ @texture.scale_mode = @pixel_filter.to_sym if @texture
569
+ if @core && !@core.destroyed?
570
+ @core.color_correction = @color_correction
571
+ @core.frame_blending = @frame_blending
572
+ render_frame if @texture
573
+ end
574
+ @save_mgr.quick_save_slot = @quick_save_slot if @save_mgr
575
+ @save_mgr.backup = @save_state_backup if @save_mgr
576
+ end
577
+
578
+ # Push current instance vars to settings window UI variables.
579
+ def push_settings_to_ui
580
+ @app.set_variable(SettingsWindow::VAR_SCALE, "#{@scale}x")
581
+ turbo_label = @turbo_speed == 0 ? 'Uncapped' : "#{@turbo_speed}x"
582
+ @app.set_variable(SettingsWindow::VAR_TURBO, turbo_label)
583
+ @app.set_variable(SettingsWindow::VAR_ASPECT_RATIO, @keep_aspect_ratio ? '1' : '0')
584
+ @app.set_variable(SettingsWindow::VAR_SHOW_FPS, @show_fps ? '1' : '0')
585
+ toast_label = "#{@config.toast_duration}s"
586
+ @app.set_variable(SettingsWindow::VAR_TOAST_DURATION, toast_label)
587
+ filter_label = @pixel_filter == 'nearest' ? @settings_window.send(:translate, 'settings.filter_nearest') : @settings_window.send(:translate, 'settings.filter_linear')
588
+ @app.set_variable(SettingsWindow::VAR_FILTER, filter_label)
589
+ @app.set_variable(SettingsWindow::VAR_INTEGER_SCALE, @integer_scale ? '1' : '0')
590
+ @app.set_variable(SettingsWindow::VAR_COLOR_CORRECTION, @color_correction ? '1' : '0')
591
+ @app.set_variable(SettingsWindow::VAR_FRAME_BLENDING, @frame_blending ? '1' : '0')
592
+ @app.set_variable(SettingsWindow::VAR_REWIND_ENABLED, @rewind_enabled ? '1' : '0')
593
+ @app.set_variable(SettingsWindow::VAR_VOLUME, (@volume * 100).round.to_s)
594
+ @app.set_variable(SettingsWindow::VAR_MUTE, @muted ? '1' : '0')
595
+ @app.set_variable(SettingsWindow::VAR_QUICK_SLOT, @quick_save_slot.to_s)
596
+ @app.set_variable(SettingsWindow::VAR_SS_BACKUP, @save_state_backup ? '1' : '0')
597
+ @app.set_variable(SettingsWindow::VAR_REC_COMPRESSION, @recording_compression.to_s)
598
+ @app.set_variable(SettingsWindow::VAR_PAUSE_FOCUS, @pause_on_focus_loss ? '1' : '0')
599
+ end
600
+
601
+ # Returns the currently active input map based on settings window mode.
602
+ def active_input
603
+ @settings_window.keyboard_mode? ? @kb_map : @gp_map
604
+ end
605
+
606
+ # Undo: reload mappings from disk for the active input device.
607
+ def undo_mappings
608
+ input = active_input
609
+ input.reload!
610
+ @settings_window.refresh_gamepad(input.labels, input.dead_zone_pct)
611
+ end
612
+
613
+ # Undo: reload hotkeys from disk.
614
+ def undo_hotkeys
615
+ @hotkeys.reload!
616
+ @settings_window.refresh_hotkeys(@hotkeys.labels)
617
+ end
618
+
619
+ # Validate a hotkey against keyboard gamepad mappings.
620
+ # Combo hotkeys (Array) never conflict with plain key gamepad mappings.
621
+ # @param hotkey [String, Array] plain keysym or modifier combo
622
+ # @return [String, nil] error message if conflict, nil if ok
623
+ def validate_hotkey(hotkey)
624
+ return nil if hotkey.is_a?(Array)
625
+
626
+ @kb_map.labels.each do |gba_btn, key|
627
+ if key == hotkey
628
+ return "\"#{hotkey}\" is mapped to GBA button #{gba_btn.upcase}"
629
+ end
630
+ end
631
+ nil
632
+ end
633
+
634
+ # Validate a keyboard gamepad mapping against hotkeys.
635
+ # Only plain-key hotkeys conflict — combo hotkeys (Ctrl+K) are fine.
636
+ # @return [String, nil] error message if conflict, nil if ok
637
+ def validate_kb_mapping(keysym)
638
+ action = @hotkeys.action_for(keysym)
639
+ if action
640
+ label = action.to_s.tr('_', ' ').capitalize
641
+ return "\"#{keysym}\" is assigned to hotkey: #{label}"
642
+ end
643
+ nil
644
+ end
645
+
646
+ # Verify config/saves/states directories are writable.
647
+ # Shows a Tk dialog and aborts if any are not.
648
+ def check_writable_dirs
649
+ dirs = {
650
+ 'Config' => Config.config_dir,
651
+ 'Saves' => @config.saves_dir,
652
+ 'Save States' => Config.default_states_dir,
653
+ }
654
+
655
+ problems = []
656
+ dirs.each do |label, dir|
657
+ begin
658
+ FileUtils.mkdir_p(dir)
659
+ rescue SystemCallError => e
660
+ problems << "#{label}: #{dir}\n #{e.message}"
661
+ next
662
+ end
663
+ unless File.writable?(dir)
664
+ problems << "#{label}: #{dir}\n Not writable"
665
+ end
666
+ end
667
+
668
+ return if problems.empty?
669
+
670
+ msg = "Cannot write to required directories:\n\n#{problems.join("\n\n")}\n\n" \
671
+ "Check file permissions or set a custom path in config."
672
+ @app.command(:tk_messageBox, icon: :error, type: :ok,
673
+ title: 'mGBA Player', message: msg)
674
+ @app.destroy('.')
675
+ exit 1
676
+ end
677
+
678
+ FOCUS_POLL_MS = 200
679
+
680
+ def start_focus_poll
681
+ @had_focus = true
682
+ @app.after(FOCUS_POLL_MS) { focus_poll_tick }
683
+ end
684
+
685
+ def focus_poll_tick
686
+ return unless @running
687
+
688
+ has_focus = @viewport.renderer.input_focus?
689
+
690
+ if @had_focus && !has_focus
691
+ # Lost focus
692
+ if @pause_on_focus_loss && @core && !@paused
693
+ @was_paused_before_focus_loss = true
694
+ toggle_pause
695
+ end
696
+ elsif !@had_focus && has_focus
697
+ # Gained focus
698
+ if @was_paused_before_focus_loss && @paused
699
+ @was_paused_before_focus_loss = false
700
+ toggle_pause
701
+ end
702
+ end
703
+
704
+ @had_focus = has_focus
705
+ @app.after(FOCUS_POLL_MS) { focus_poll_tick }
706
+ rescue StandardError
707
+ # Renderer may be destroyed during shutdown
708
+ nil
709
+ end
710
+
711
+ def start_gamepad_probe
712
+ @app.after(GAMEPAD_PROBE_MS) { gamepad_probe_tick }
713
+ end
714
+
715
+ def gamepad_probe_tick
716
+ return unless @running
717
+ has_gp = @gamepad && !@gamepad.closed?
718
+ settings_visible = @app.command(:wm, 'state', SettingsWindow::TOP) != 'withdrawn' rescue false
719
+
720
+ # When settings is visible, use update_state (SDL_GameControllerUpdate)
721
+ # instead of poll_events (SDL_PollEvent) to avoid pumping the Cocoa
722
+ # run loop, which steals events from Tk's native widgets.
723
+ # Background events hint ensures update_state gets fresh data even
724
+ # when the SDL window doesn't have focus.
725
+ if settings_visible && has_gp
726
+ Teek::SDL2::Gamepad.update_state
727
+
728
+ # Listen mode: capture first pressed button for remap
729
+ if @settings_window.listening_for
730
+ Teek::SDL2::Gamepad.buttons.each do |btn|
731
+ if @gamepad.button?(btn)
732
+ @settings_window.capture_mapping(btn)
733
+ break
734
+ end
735
+ end
736
+ end
737
+
738
+ @app.after(GAMEPAD_LISTEN_MS) { gamepad_probe_tick }
739
+ return
740
+ end
741
+
742
+ # Settings closed: use poll_events for hot-plug callbacks
743
+ unless @core
744
+ Teek::SDL2::Gamepad.poll_events rescue nil
745
+ end
746
+ @app.after(GAMEPAD_PROBE_MS) { gamepad_probe_tick }
747
+ end
748
+
749
+ def refresh_gamepads
750
+ names = [translate('settings.keyboard_only')]
751
+ prev_gp = @gamepad
752
+ 8.times do |i|
753
+ gp = begin; Teek::SDL2::Gamepad.open(i); rescue; nil; end
754
+ next unless gp
755
+ names << gp.name
756
+ @gamepad ||= gp
757
+ gp.close unless gp == @gamepad
758
+ end
759
+ @settings_window&.update_gamepad_list(names)
760
+ update_status_label
761
+ if @gamepad && @gamepad != prev_gp
762
+ @gp_map.device = @gamepad
763
+ @gp_map.load_config
764
+ end
765
+ end
766
+
767
+ def update_status_label
768
+ return if @core # hidden during gameplay
769
+ gp_text = @gamepad ? @gamepad.name : translate('settings.no_gamepad')
770
+ @app.command(@status_label, :configure,
771
+ text: "#{translate('player.open_rom_hint')}\n#{gp_text}")
772
+ end
773
+
774
+ def setup_input
775
+ @viewport.bind('KeyPress', :keysym, '%s') do |k, state_str|
776
+ if k == 'Escape'
777
+ @fullscreen ? toggle_fullscreen : (@running = false)
778
+ else
779
+ mods = HotkeyMap.modifiers_from_state(state_str.to_i)
780
+ case @hotkeys.action_for(k, modifiers: mods)
781
+ when :quit then @running = false
782
+ when :pause then toggle_pause
783
+ when :fast_forward then toggle_fast_forward
784
+ when :fullscreen then toggle_fullscreen
785
+ when :show_fps then toggle_show_fps
786
+ when :quick_save then quick_save
787
+ when :quick_load then quick_load
788
+ when :save_states then show_state_picker
789
+ when :screenshot then take_screenshot
790
+ when :rewind then do_rewind
791
+ when :record then toggle_recording
792
+ else @keyboard.press(k)
793
+ end
794
+ end
795
+ end
796
+
797
+ @viewport.bind('KeyRelease', :keysym) do |k|
798
+ @keyboard.release(k)
799
+ end
800
+
801
+ @viewport.bind('FocusIn') { @has_focus = true }
802
+ @viewport.bind('FocusOut') { @has_focus = false }
803
+
804
+ start_focus_poll
805
+
806
+ # Alt+Return fullscreen toggle (emulator convention)
807
+ @app.command(:bind, @viewport.frame.path, '<Alt-Return>', proc { toggle_fullscreen })
808
+ end
809
+
810
+ def build_menu
811
+ menubar = '.menubar'
812
+ @app.command(:menu, menubar)
813
+ @app.command('.', :configure, menu: menubar)
814
+
815
+ # File menu
816
+ @app.command(:menu, "#{menubar}.file", tearoff: 0)
817
+ @app.command(menubar, :add, :cascade, label: translate('menu.file'), menu: "#{menubar}.file")
818
+
819
+ @app.command("#{menubar}.file", :add, :command,
820
+ label: translate('menu.open_rom'), accelerator: 'Cmd+O',
821
+ command: proc { open_rom_dialog })
822
+
823
+ # Recent ROMs submenu
824
+ @recent_menu = "#{menubar}.file.recent"
825
+ @app.command(:menu, @recent_menu, tearoff: 0)
826
+ @app.command("#{menubar}.file", :add, :cascade,
827
+ label: translate('menu.recent'), menu: @recent_menu)
828
+ rebuild_recent_menu
829
+
830
+ @app.command("#{menubar}.file", :add, :separator)
831
+ @app.command("#{menubar}.file", :add, :command,
832
+ label: translate('menu.quit'), accelerator: 'Cmd+Q',
833
+ command: proc { @running = false })
834
+
835
+ @app.command(:bind, '.', '<Command-o>', proc { open_rom_dialog })
836
+ @app.command(:bind, '.', '<Command-comma>', proc { show_settings })
837
+
838
+ # Settings menu — one entry per settings tab
839
+ settings_menu = "#{menubar}.settings"
840
+ @app.command(:menu, settings_menu, tearoff: 0)
841
+ @app.command(menubar, :add, :cascade, label: translate('menu.settings'), menu: settings_menu)
842
+
843
+ SettingsWindow::TABS.each do |locale_key, tab_path|
844
+ display = translate(locale_key)
845
+ accel = locale_key == 'settings.video' ? 'Cmd+,' : nil
846
+ opts = { label: "#{display}…", command: proc { show_settings(tab: tab_path) } }
847
+ opts[:accelerator] = accel if accel
848
+ @app.command(settings_menu, :add, :command, **opts)
849
+ end
850
+
851
+ # View menu
852
+ view_menu = "#{menubar}.view"
853
+ @app.command(:menu, view_menu, tearoff: 0)
854
+ @app.command(menubar, :add, :cascade, label: translate('menu.view'), menu: view_menu)
855
+
856
+ @app.command(view_menu, :add, :command,
857
+ label: translate('menu.fullscreen'), accelerator: 'F11',
858
+ command: proc { toggle_fullscreen })
859
+ @app.command(view_menu, :add, :command,
860
+ label: translate('menu.rom_info'), state: :disabled,
861
+ command: proc { show_rom_info })
862
+ @view_menu = view_menu
863
+
864
+ # Emulation menu
865
+ @emu_menu = "#{menubar}.emu"
866
+ @app.command(:menu, @emu_menu, tearoff: 0)
867
+ @app.command(menubar, :add, :cascade, label: translate('menu.emulation'), menu: @emu_menu)
868
+
869
+ @app.command(@emu_menu, :add, :command,
870
+ label: translate('menu.pause'), accelerator: 'P',
871
+ command: proc { toggle_pause })
872
+ @app.command(@emu_menu, :add, :command,
873
+ label: translate('menu.reset'), accelerator: 'Cmd+R',
874
+ command: proc { reset_core })
875
+ @app.command(@emu_menu, :add, :separator)
876
+ @app.command(@emu_menu, :add, :command,
877
+ label: translate('menu.quick_save'), accelerator: 'F5', state: :disabled,
878
+ command: proc { quick_save })
879
+ @app.command(@emu_menu, :add, :command,
880
+ label: translate('menu.quick_load'), accelerator: 'F8', state: :disabled,
881
+ command: proc { quick_load })
882
+ @app.command(@emu_menu, :add, :separator)
883
+ @app.command(@emu_menu, :add, :command,
884
+ label: translate('menu.save_states'), accelerator: 'F6', state: :disabled,
885
+ command: proc { show_state_picker })
886
+ @app.command(@emu_menu, :add, :separator)
887
+ @app.command(@emu_menu, :add, :command,
888
+ label: translate('menu.start_recording'), accelerator: 'F10', state: :disabled,
889
+ command: proc { toggle_recording })
890
+
891
+ @app.command(:bind, '.', '<Command-r>', proc { reset_core })
892
+ end
893
+
894
+ def toggle_pause
895
+ return unless @core
896
+ @paused = !@paused
897
+ if @paused
898
+ @stream.clear
899
+ @stream.pause
900
+ @toast&.show(translate('toast.paused'), permanent: true)
901
+ @app.command(@emu_menu, :entryconfigure, 0, label: translate('menu.resume'))
902
+ render_frame # show paused toast on screen
903
+ set_event_loop_speed(:idle)
904
+ else
905
+ set_event_loop_speed(:fast)
906
+ @toast&.destroy
907
+ @stream.clear
908
+ @audio_fade_in = FADE_IN_FRAMES
909
+ @stream.resume
910
+ @next_frame = Process.clock_gettime(Process::CLOCK_MONOTONIC)
911
+ @app.command(@emu_menu, :entryconfigure, 0, label: translate('menu.pause'))
912
+ end
913
+ end
914
+
915
+ def toggle_fast_forward
916
+ return unless @core
917
+ @fast_forward = !@fast_forward
918
+ if @fast_forward
919
+ @hud.set_ff_label(ff_label_text)
920
+ else
921
+ @hud.set_ff_label(nil)
922
+ @next_frame = Process.clock_gettime(Process::CLOCK_MONOTONIC)
923
+ @stream.clear
924
+ end
925
+ end
926
+
927
+ def apply_turbo_speed(speed)
928
+ @turbo_speed = speed
929
+ @hud.set_ff_label(ff_label_text) if @fast_forward
930
+ end
931
+
932
+ def ff_label_text
933
+ @turbo_speed == 0 ? translate('player.ff_max') : translate('player.ff', speed: @turbo_speed)
934
+ end
935
+
936
+ def apply_aspect_ratio(keep)
937
+ @keep_aspect_ratio = keep
938
+ end
939
+
940
+ def toggle_fullscreen
941
+ @fullscreen = !@fullscreen
942
+ @app.command(:wm, 'attributes', '.', '-fullscreen', @fullscreen ? 1 : 0)
943
+ end
944
+
945
+ def apply_show_fps(show)
946
+ @show_fps = show
947
+ @hud.set_fps(nil) unless @show_fps
948
+ end
949
+
950
+ def apply_toast_duration(secs)
951
+ @config.toast_duration = secs
952
+ @toast.duration = secs
953
+ end
954
+
955
+ def apply_quick_slot(slot)
956
+ @quick_save_slot = slot.to_i.clamp(1, 10)
957
+ @save_mgr.quick_save_slot = @quick_save_slot if @save_mgr
958
+ end
959
+
960
+ def apply_backup(enabled)
961
+ @save_state_backup = !!enabled
962
+ @save_mgr.backup = @save_state_backup if @save_mgr
963
+ end
964
+
965
+ def open_config_dir
966
+ dir = Config.config_dir
967
+ FileUtils.mkdir_p(dir)
968
+ p = Teek.platform
969
+ if p.darwin?
970
+ system('open', dir)
971
+ elsif p.windows?
972
+ system('explorer.exe', dir)
973
+ else
974
+ system('xdg-open', dir)
975
+ end
976
+ end
977
+
978
+ def toggle_show_fps
979
+ @show_fps = !@show_fps
980
+ @hud.set_fps(nil) unless @show_fps
981
+ @app.set_variable(SettingsWindow::VAR_SHOW_FPS, @show_fps ? '1' : '0')
982
+ end
983
+
984
+ # -- Recording -----------------------------------------------------------
985
+
986
+ def toggle_recording
987
+ return unless @core
988
+ @recorder&.recording? ? stop_recording : start_recording
989
+ end
990
+
991
+ def start_recording
992
+ dir = @config.recordings_dir
993
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
994
+ timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%L')
995
+ title = @core.title.strip.gsub(/[^a-zA-Z0-9_.-]/, '_')
996
+ filename = "#{title}_#{timestamp}.grec"
997
+ path = File.join(dir, filename)
998
+ @recorder = Recorder.new(path, width: GBA_W, height: GBA_H,
999
+ compression: @recording_compression)
1000
+ @recorder.start
1001
+ @toast&.show(translate('toast.recording_started'))
1002
+ update_recording_menu
1003
+ end
1004
+
1005
+ def stop_recording
1006
+ return unless @recorder&.recording?
1007
+ @recorder.stop
1008
+ count = @recorder.frame_count
1009
+ @toast&.show(translate('toast.recording_stopped', frames: count))
1010
+ @recorder = nil
1011
+ update_recording_menu
1012
+ end
1013
+
1014
+ # Capture current frame for recording. Reads audio_buffer (destructive)
1015
+ # and returns the raw PCM so the caller can pass it to queue_audio.
1016
+ # Returns nil when not recording.
1017
+ def capture_frame
1018
+ return nil unless @recorder&.recording?
1019
+ pcm = @core.audio_buffer
1020
+ @recorder.capture(@core.video_buffer_argb, pcm)
1021
+ pcm
1022
+ end
1023
+
1024
+ def update_recording_menu
1025
+ label = @recorder&.recording? ? translate('menu.stop_recording') : translate('menu.start_recording')
1026
+ @app.command(@emu_menu, :entryconfigure, 8, label: label)
1027
+ end
1028
+
1029
+ def apply_recording_compression(val)
1030
+ @recording_compression = val.to_i.clamp(1, 9)
1031
+ end
1032
+
1033
+ def apply_pause_on_focus_loss(val)
1034
+ @pause_on_focus_loss = val
1035
+ @was_paused_before_focus_loss = false unless val
1036
+ end
1037
+
1038
+ def open_recordings_dir
1039
+ dir = @config.recordings_dir
1040
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
1041
+ p = Teek.platform
1042
+ if p.darwin?
1043
+ system('open', dir)
1044
+ elsif p.windows?
1045
+ system('explorer.exe', dir)
1046
+ else
1047
+ system('xdg-open', dir)
1048
+ end
1049
+ end
1050
+
1051
+ # -- End recording -------------------------------------------------------
1052
+
1053
+ def reset_core
1054
+ return unless @rom_path
1055
+ load_rom(@rom_path)
1056
+ end
1057
+
1058
+ def confirm_rom_change(new_path)
1059
+ return true unless @core && !@core.destroyed?
1060
+
1061
+ name = File.basename(new_path)
1062
+ result = @app.command('tk_messageBox',
1063
+ parent: '.',
1064
+ title: translate('dialog.game_running_title'),
1065
+ message: translate('dialog.game_running_msg', name: name),
1066
+ type: :okcancel,
1067
+ icon: :warning)
1068
+ result == 'ok'
1069
+ end
1070
+
1071
+ def setup_drop_target
1072
+ @app.register_drop_target('.')
1073
+ @app.bind('.', '<<DropFile>>', :data) do |data|
1074
+ paths = @app.split_list(data)
1075
+ handle_dropped_files(paths)
1076
+ end
1077
+ end
1078
+
1079
+ def handle_dropped_files(paths)
1080
+ if paths.length != 1
1081
+ @app.command('tk_messageBox',
1082
+ parent: '.',
1083
+ title: translate('dialog.drop_error_title'),
1084
+ message: translate('dialog.drop_single_file_only'),
1085
+ type: :ok,
1086
+ icon: :warning)
1087
+ return
1088
+ end
1089
+
1090
+ path = paths.first
1091
+ ext = File.extname(path).downcase
1092
+ unless RomLoader::SUPPORTED_EXTENSIONS.include?(ext)
1093
+ @app.command('tk_messageBox',
1094
+ parent: '.',
1095
+ title: translate('dialog.drop_error_title'),
1096
+ message: translate('dialog.drop_unsupported_type', ext: ext),
1097
+ type: :ok,
1098
+ icon: :warning)
1099
+ return
1100
+ end
1101
+
1102
+ return unless confirm_rom_change(path)
1103
+ load_rom(path)
1104
+ end
1105
+
1106
+ def open_rom_dialog
1107
+ filetypes = '{{GBA ROMs} {.gba}} {{GB ROMs} {.gb .gbc}} {{ZIP Archives} {.zip}} {{All Files} {*}}'
1108
+ title = translate('menu.open_rom').delete('…')
1109
+ path = @app.tcl_eval("tk_getOpenFile -title {#{title}} -filetypes {#{filetypes}}")
1110
+ return if path.empty?
1111
+ return unless confirm_rom_change(path)
1112
+
1113
+ load_rom(path)
1114
+ end
1115
+
1116
+ def load_rom(path)
1117
+ # Lazy-init SDL2 on first ROM load. Before this, the window shows
1118
+ # only Tk widgets (menu bar, status label) — no black viewport.
1119
+ init_sdl2 unless @sdl2_ready
1120
+
1121
+ # Resolve ZIP archives to a bare ROM path
1122
+ rom_path = begin
1123
+ RomLoader.resolve(path)
1124
+ rescue RomLoader::NoRomInZip => e
1125
+ show_rom_error(translate('dialog.no_rom_in_zip', name: e.message))
1126
+ return
1127
+ rescue RomLoader::MultipleRomsInZip => e
1128
+ show_rom_error(translate('dialog.multiple_roms_in_zip', name: e.message))
1129
+ return
1130
+ rescue RomLoader::UnsupportedFormat => e
1131
+ show_rom_error(translate('dialog.drop_unsupported_type', ext: e.message))
1132
+ return
1133
+ rescue RomLoader::ZipReadError => e
1134
+ show_rom_error(translate('dialog.zip_read_error', detail: e.message))
1135
+ return
1136
+ end
1137
+
1138
+ stop_recording if @recorder&.recording?
1139
+
1140
+ if @core && !@core.destroyed?
1141
+ @core.destroy
1142
+ end
1143
+ @stream.clear
1144
+
1145
+ saves = @config.saves_dir
1146
+ FileUtils.mkdir_p(saves) unless File.directory?(saves)
1147
+ @core = Core.new(rom_path, saves)
1148
+ @rom_path = path
1149
+
1150
+ # Activate per-game config overlay (before reading settings)
1151
+ rom_id = Config.rom_id(@core.game_code, @core.checksum)
1152
+ @config.activate_game(rom_id)
1153
+ refresh_from_config
1154
+ @settings_window.set_per_game_available(true)
1155
+ @settings_window.set_per_game_active(@config.per_game_settings?)
1156
+ @save_mgr = SaveStateManager.new(core: @core, config: @config, app: @app)
1157
+ @save_mgr.state_dir = @save_mgr.state_dir_for_rom(@core)
1158
+ @save_mgr.quick_save_slot = @quick_save_slot
1159
+ @save_mgr.backup = @save_state_backup
1160
+ @core.rewind_init(@rewind_seconds) if @rewind_enabled
1161
+ @rewind_frame_counter = 0
1162
+ @paused = false
1163
+ @stream.resume
1164
+ set_event_loop_speed(:fast)
1165
+ @app.command(:place, :forget, @status_label) rescue nil
1166
+ @app.set_window_title("mGBA \u2014 #{@core.title}")
1167
+ @app.command(@view_menu, :entryconfigure, 1, state: :normal)
1168
+ # Enable save state + recording menu entries
1169
+ # Quick Save=3, Quick Load=4, Save States=6, Record=8
1170
+ [3, 4, 6, 8].each { |i| @app.command(@emu_menu, :entryconfigure, i, state: :normal) }
1171
+ @fps_count = 0
1172
+ @fps_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1173
+ @next_frame = @fps_time
1174
+ @audio_samples_produced = 0
1175
+
1176
+ @config.add_recent_rom(path)
1177
+ @config.save!
1178
+ rebuild_recent_menu
1179
+
1180
+ sav_name = File.basename(path, File.extname(path)) + '.sav'
1181
+ sav_path = File.join(saves, sav_name)
1182
+ if File.exist?(sav_path)
1183
+ @toast&.show(translate('toast.loaded_sav', name: sav_name))
1184
+ else
1185
+ @toast&.show(translate('toast.created_sav', name: sav_name))
1186
+ end
1187
+
1188
+ # Start the emulation loop (first ROM load only).
1189
+ # Subsequent load_rom calls just swap the core — animate is already running.
1190
+ animate unless @animate_started
1191
+ @animate_started = true
1192
+ end
1193
+
1194
+ def open_recent_rom(path)
1195
+ unless File.exist?(path)
1196
+ @app.command('tk_messageBox',
1197
+ parent: '.',
1198
+ title: translate('dialog.rom_not_found_title'),
1199
+ message: translate('dialog.rom_not_found_msg', path: path),
1200
+ type: :ok,
1201
+ icon: :error)
1202
+ @config.remove_recent_rom(path)
1203
+ @config.save!
1204
+ rebuild_recent_menu
1205
+ return
1206
+ end
1207
+ return unless confirm_rom_change(path)
1208
+
1209
+ load_rom(path)
1210
+ end
1211
+
1212
+ def rebuild_recent_menu
1213
+ # Clear all existing entries
1214
+ @app.command(@recent_menu, :delete, 0, :end) rescue nil
1215
+
1216
+ roms = @config.recent_roms
1217
+ if roms.empty?
1218
+ @app.command(@recent_menu, :add, :command,
1219
+ label: translate('player.none'), state: :disabled)
1220
+ else
1221
+ roms.each do |rom_path|
1222
+ label = File.basename(rom_path)
1223
+ @app.command(@recent_menu, :add, :command,
1224
+ label: label,
1225
+ command: proc { open_recent_rom(rom_path) })
1226
+ end
1227
+ @app.command(@recent_menu, :add, :separator)
1228
+ @app.command(@recent_menu, :add, :command,
1229
+ label: translate('player.clear'),
1230
+ command: proc { clear_recent_roms })
1231
+ end
1232
+ end
1233
+
1234
+ def clear_recent_roms
1235
+ @config.clear_recent_roms
1236
+ @config.save!
1237
+ rebuild_recent_menu
1238
+ end
1239
+
1240
+ def tick
1241
+ unless @core
1242
+ @viewport.render { |r| r.clear(0, 0, 0) }
1243
+ return
1244
+ end
1245
+
1246
+ if @paused
1247
+ # No-op: the last frame is already on screen (rendered on pause
1248
+ # entry or by render_if_paused). The animate loop keeps running
1249
+ # at 100ms just to check @running.
1250
+ return
1251
+ end
1252
+
1253
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1254
+ @next_frame ||= now
1255
+
1256
+ if @fast_forward
1257
+ tick_fast_forward(now)
1258
+ else
1259
+ tick_normal(now)
1260
+ end
1261
+ end
1262
+
1263
+ def tick_normal(now)
1264
+ frames = 0
1265
+ while @next_frame <= now && frames < 4
1266
+ run_one_frame
1267
+ rec_pcm = capture_frame
1268
+ queue_audio(raw_pcm: rec_pcm)
1269
+
1270
+ # Dynamic rate control — proportional feedback on audio buffer fill.
1271
+ # Based on Near/byuu's algorithm for emulator A/V sync:
1272
+ # https://docs.libretro.com/guides/ratecontrol.pdf
1273
+ #
1274
+ # fill = how full the audio buffer is (0.0 .. 1.0)
1275
+ # ratio = (1 - MAX_DELTA) + 2 * fill * MAX_DELTA
1276
+ #
1277
+ # fill=0.0 (starving) → ratio=0.995 → shorter wait → emu speeds up
1278
+ # fill=0.5 (target) → ratio=1.000 → no change
1279
+ # fill=1.0 (overfull) → ratio=1.005 → longer wait → emu slows down
1280
+ #
1281
+ # The buffer naturally settles around 50% full. The ±0.5% limit
1282
+ # keeps pitch/speed shifts imperceptible.
1283
+ fill = (@stream.queued_samples.to_f / AUDIO_BUF_CAPACITY).clamp(0.0, 1.0)
1284
+ ratio = (1.0 - MAX_DELTA) + 2.0 * fill * MAX_DELTA
1285
+ @next_frame += FRAME_PERIOD * ratio
1286
+ frames += 1
1287
+ end
1288
+
1289
+ @next_frame = now if now - @next_frame > 0.1
1290
+ return if frames == 0
1291
+
1292
+ render_frame
1293
+ update_fps(frames, now)
1294
+ end
1295
+
1296
+ def tick_fast_forward(now)
1297
+ if @turbo_speed == 0
1298
+ # Uncapped: poll input once per tick to avoid flooding the Cocoa
1299
+ # event loop (SDL_PollEvent pumps it), then blast through frames.
1300
+ keys = poll_input
1301
+ FF_MAX_FRAMES.times do |i|
1302
+ @core.set_keys(keys)
1303
+ @core.run_frame
1304
+ rec_pcm = capture_frame
1305
+ if i == 0
1306
+ queue_audio(volume_override: @turbo_volume, raw_pcm: rec_pcm)
1307
+ elsif !rec_pcm
1308
+ @core.audio_buffer # discard when not recording
1309
+ end
1310
+ end
1311
+ @next_frame = now
1312
+ render_frame(ff_indicator: true)
1313
+ update_fps(FF_MAX_FRAMES, now)
1314
+ return
1315
+ end
1316
+
1317
+ # Paced turbo (2x, 3x, 4x): run @turbo_speed frames per FRAME_PERIOD.
1318
+ # Same timing gate as tick_normal so 2x ≈ 120 fps, not 2000 fps.
1319
+ frames = 0
1320
+ while @next_frame <= now && frames < @turbo_speed * 4
1321
+ @turbo_speed.times do
1322
+ run_one_frame
1323
+ rec_pcm = capture_frame
1324
+ if frames == 0
1325
+ queue_audio(volume_override: @turbo_volume, raw_pcm: rec_pcm)
1326
+ elsif !rec_pcm
1327
+ @core.audio_buffer # discard when not recording
1328
+ end
1329
+ frames += 1
1330
+ end
1331
+ @next_frame += FRAME_PERIOD
1332
+ end
1333
+ @next_frame = now if now - @next_frame > 0.1
1334
+ return if frames == 0
1335
+
1336
+ render_frame(ff_indicator: true)
1337
+ update_fps(frames, now)
1338
+ end
1339
+
1340
+ # Read keyboard + gamepad state, return combined bitmask.
1341
+ # Uses SDL_GameControllerUpdate (not SDL_PollEvent) to read gamepad
1342
+ # state without pumping the Cocoa event loop on macOS — SDL_PollEvent
1343
+ # steals NSKeyDown events from Tk, making quit/escape unresponsive.
1344
+ # Hot-plug detection is handled separately by start_gamepad_probe.
1345
+ def poll_input
1346
+ begin
1347
+ Teek::SDL2::Gamepad.update_state
1348
+ rescue StandardError
1349
+ @gamepad = nil
1350
+ @gp_map.device = nil
1351
+ end
1352
+ @kb_map.mask | @gp_map.mask
1353
+ end
1354
+
1355
+ REWIND_PUSH_INTERVAL = 60 # ~1 second at GBA framerate
1356
+
1357
+ def run_one_frame
1358
+ @core.set_keys(poll_input)
1359
+ @core.run_frame
1360
+ @total_frames += 1
1361
+ @running = false if @frame_limit && @total_frames >= @frame_limit
1362
+ if @rewind_enabled
1363
+ @rewind_frame_counter += 1
1364
+ if @rewind_frame_counter >= REWIND_PUSH_INTERVAL
1365
+ @core.rewind_push
1366
+ @rewind_frame_counter = 0
1367
+ end
1368
+ end
1369
+ end
1370
+
1371
+ def queue_audio(volume_override: nil, raw_pcm: nil)
1372
+ pcm = raw_pcm || @core.audio_buffer
1373
+ return if pcm.empty?
1374
+
1375
+ @audio_samples_produced += pcm.bytesize / 4
1376
+ if @muted
1377
+ @audio_fade_in = 0
1378
+ else
1379
+ vol = volume_override || @volume
1380
+ pcm = apply_volume_to_pcm(pcm, vol) if vol < 1.0
1381
+ if @audio_fade_in > 0
1382
+ pcm, @audio_fade_in = self.class.apply_fade_ramp(pcm, @audio_fade_in, FADE_IN_FRAMES)
1383
+ end
1384
+ @stream.queue(pcm)
1385
+ end
1386
+ end
1387
+
1388
+ # Re-render while paused (e.g. after rewind, toast, or settings change).
1389
+ # No-op when running — the animate loop handles rendering.
1390
+ def render_if_paused
1391
+ render_frame if @paused && @core && @texture
1392
+ end
1393
+
1394
+ # Switch Tcl event loop polling rate.
1395
+ # :fast — 1ms, needed for smooth emulation frame pacing
1396
+ # :idle — 50ms, sufficient for UI when paused or no ROM loaded
1397
+ def set_event_loop_speed(mode)
1398
+ ms = mode == :fast ? EVENT_LOOP_FAST_MS : EVENT_LOOP_IDLE_MS
1399
+ @app.interp.thread_timer_ms = ms
1400
+ end
1401
+
1402
+ def render_frame(ff_indicator: false)
1403
+ pixels = @core.video_buffer_argb
1404
+ @texture.update(pixels)
1405
+ dest = compute_dest_rect
1406
+ @viewport.render do |r|
1407
+ r.clear(0, 0, 0)
1408
+ r.copy(@texture, nil, dest)
1409
+ if @recorder&.recording?
1410
+ rx = (dest ? dest[0] : 0) + 12
1411
+ ry = (dest ? dest[1] : 0) + 12
1412
+ r.fill_circle(rx, ry, 5, 220, 30, 30, 200)
1413
+ end
1414
+ @hud.draw(r, dest, show_fps: @show_fps, show_ff: ff_indicator)
1415
+ @toast&.draw(r, dest)
1416
+ end
1417
+ end
1418
+
1419
+ # Calculate a centered destination rectangle that preserves the GBA's 3:2
1420
+ # aspect ratio within the current renderer output. Returns nil when
1421
+ # stretching is preferred (keep_aspect_ratio off).
1422
+ #
1423
+ # Example — fullscreen on a 1920x1080 (16:9) monitor:
1424
+ # scale_x = 1920 / 240 = 8.0
1425
+ # scale_y = 1080 / 160 = 6.75
1426
+ # scale = min(8.0, 6.75) = 6.75 (height is the constraint)
1427
+ # dest = [150, 0, 1620, 1080] (pillarboxed: 150px black bars L+R)
1428
+ #
1429
+ # Example — fullscreen on a 2560x1600 (16:10) monitor:
1430
+ # scale_x = 2560 / 240 ≈ 10.67
1431
+ # scale_y = 1600 / 160 = 10.0
1432
+ # scale = 10.0
1433
+ # dest = [80, 0, 2400, 1600] (pillarboxed: 80px bars L+R)
1434
+ def compute_dest_rect
1435
+ return nil unless @keep_aspect_ratio
1436
+
1437
+ out_w, out_h = @viewport.renderer.output_size
1438
+ scale_x = out_w.to_f / GBA_W
1439
+ scale_y = out_h.to_f / GBA_H
1440
+ scale = [scale_x, scale_y].min
1441
+ scale = scale.floor if @integer_scale && scale >= 1.0
1442
+
1443
+ dest_w = (GBA_W * scale).to_i
1444
+ dest_h = (GBA_H * scale).to_i
1445
+ dest_x = (out_w - dest_w) / 2
1446
+ dest_y = (out_h - dest_h) / 2
1447
+
1448
+ [dest_x, dest_y, dest_w, dest_h]
1449
+ end
1450
+
1451
+ def update_fps(frames, now)
1452
+ @fps_count += frames
1453
+ elapsed = now - @fps_time
1454
+ if elapsed >= 1.0
1455
+ fps = (@fps_count / elapsed).round(1)
1456
+ @hud.set_fps(translate('player.fps', fps: fps)) if @show_fps
1457
+ @audio_samples_produced = 0
1458
+ @fps_count = 0
1459
+ @fps_time = now
1460
+ end
1461
+ end
1462
+
1463
+ def animate
1464
+ if @running
1465
+ tick
1466
+ delay = (@core && !@paused) ? 1 : 100
1467
+ @app.after(delay) { animate }
1468
+ else
1469
+ cleanup
1470
+ @app.command(:destroy, '.')
1471
+ end
1472
+ end
1473
+
1474
+ # Apply software volume to int16 stereo PCM data.
1475
+ def apply_volume_to_pcm(pcm, gain = @volume)
1476
+ samples = pcm.unpack('s*')
1477
+ samples.map! { |s| (s * gain).round.clamp(-32768, 32767) }
1478
+ samples.pack('s*')
1479
+ end
1480
+
1481
+ # Apply a linear fade-in ramp to int16 stereo PCM data.
1482
+ # Pure function: takes remaining/total counters, returns [pcm, new_remaining].
1483
+ # @param pcm [String] packed int16 stereo PCM
1484
+ # @param remaining [Integer] fade samples remaining (counts down to 0)
1485
+ # @param total [Integer] total fade length in samples
1486
+ # @return [Array(String, Integer)] modified PCM and updated remaining count
1487
+ def self.apply_fade_ramp(pcm, remaining, total)
1488
+ samples = pcm.unpack('s*')
1489
+ i = 0
1490
+ while i < samples.length && remaining > 0
1491
+ gain = 1.0 - (remaining.to_f / total)
1492
+ samples[i] = (samples[i] * gain).round.clamp(-32768, 32767)
1493
+ samples[i + 1] = (samples[i + 1] * gain).round.clamp(-32768, 32767) if i + 1 < samples.length
1494
+ remaining -= 1
1495
+ i += 2
1496
+ end
1497
+ [samples.pack('s*'), remaining]
1498
+ end
1499
+
1500
+ def cleanup
1501
+ return if @cleaned_up
1502
+ @cleaned_up = true
1503
+
1504
+ stop_recording if @recorder&.recording?
1505
+ @stream&.pause unless @stream&.destroyed?
1506
+ @hud&.destroy
1507
+ @toast&.destroy
1508
+ @overlay_font&.destroy unless @overlay_font&.destroyed?
1509
+ @stream&.destroy unless @stream&.destroyed?
1510
+ @texture&.destroy unless @texture&.destroyed?
1511
+ @core&.destroy unless @core&.destroyed?
1512
+ RomLoader.cleanup_temp
1513
+ end
1514
+ end
1515
+ end