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