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.
- checksums.yaml +7 -0
- data/THIRD_PARTY_NOTICES +113 -0
- data/assets/JetBrainsMonoNL-Regular.ttf +0 -0
- data/assets/ark-pixel-12px-monospaced-ja.ttf +0 -0
- data/bin/gemba +14 -0
- data/ext/gemba/extconf.rb +185 -0
- data/ext/gemba/gemba_ext.c +1051 -0
- data/ext/gemba/gemba_ext.h +15 -0
- data/gemba.gemspec +38 -0
- data/lib/gemba/child_window.rb +62 -0
- data/lib/gemba/cli.rb +384 -0
- data/lib/gemba/config.rb +621 -0
- data/lib/gemba/core.rb +121 -0
- data/lib/gemba/headless.rb +12 -0
- data/lib/gemba/headless_player.rb +206 -0
- data/lib/gemba/hotkey_map.rb +202 -0
- data/lib/gemba/input_mappings.rb +214 -0
- data/lib/gemba/locale.rb +92 -0
- data/lib/gemba/locales/en.yml +157 -0
- data/lib/gemba/locales/ja.yml +157 -0
- data/lib/gemba/method_coverage_service.rb +265 -0
- data/lib/gemba/overlay_renderer.rb +109 -0
- data/lib/gemba/player.rb +1515 -0
- data/lib/gemba/recorder.rb +156 -0
- data/lib/gemba/recorder_decoder.rb +325 -0
- data/lib/gemba/rom_info_window.rb +346 -0
- data/lib/gemba/rom_loader.rb +100 -0
- data/lib/gemba/runtime.rb +39 -0
- data/lib/gemba/save_state_manager.rb +155 -0
- data/lib/gemba/save_state_picker.rb +199 -0
- data/lib/gemba/settings_window.rb +1173 -0
- data/lib/gemba/tip_service.rb +133 -0
- data/lib/gemba/toast_overlay.rb +128 -0
- data/lib/gemba/version.rb +5 -0
- data/lib/gemba.rb +17 -0
- data/test/fixtures/test.gba +0 -0
- data/test/fixtures/test.sav +0 -0
- data/test/shared/screenshot_helper.rb +113 -0
- data/test/shared/simplecov_config.rb +59 -0
- data/test/shared/teek_test_worker.rb +388 -0
- data/test/shared/tk_test_helper.rb +354 -0
- data/test/support/input_mocks.rb +61 -0
- data/test/support/player_helpers.rb +77 -0
- data/test/test_cli.rb +281 -0
- data/test/test_config.rb +897 -0
- data/test/test_core.rb +401 -0
- data/test/test_gamepad_map.rb +116 -0
- data/test/test_headless_player.rb +205 -0
- data/test/test_helper.rb +19 -0
- data/test/test_hotkey_map.rb +396 -0
- data/test/test_keyboard_map.rb +108 -0
- data/test/test_locale.rb +159 -0
- data/test/test_mgba.rb +26 -0
- data/test/test_overlay_renderer.rb +199 -0
- data/test/test_player.rb +903 -0
- data/test/test_recorder.rb +180 -0
- data/test/test_rom_loader.rb +149 -0
- data/test/test_save_state_manager.rb +289 -0
- data/test/test_settings_hotkeys.rb +434 -0
- data/test/test_settings_window.rb +1039 -0
- data/test/test_tip_service.rb +138 -0
- data/test/test_toast_overlay.rb +216 -0
- data/test/test_virtual_keyboard.rb +39 -0
- data/test/test_xor_delta.rb +61 -0
- metadata +234 -0
data/lib/gemba/player.rb
ADDED
|
@@ -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
|