gemba 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +190 -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 +511 -0
- data/lib/gemba/achievements/retro_achievements/cli_sync_requester.rb +64 -0
- data/lib/gemba/achievements/retro_achievements/ping_worker.rb +28 -0
- data/lib/gemba/achievements/retro_achievements/unlock_retry_worker.rb +35 -0
- data/lib/gemba/achievements.rb +19 -0
- data/lib/gemba/achievements_window.rb +556 -0
- data/lib/gemba/app_controller.rb +1036 -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 +154 -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 +1084 -0
- data/lib/gemba/event_bus.rb +48 -0
- data/lib/gemba/frame_stack.rb +70 -0
- data/lib/gemba/game_index.rb +84 -0
- data/lib/gemba/game_picker_frame.rb +309 -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/list_picker_frame.rb +271 -0
- data/lib/gemba/locales/en.yml +109 -5
- data/lib/gemba/locales/ja.yml +109 -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 +119 -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 +377 -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 +92 -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 +78 -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 +3 -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_list_picker_frame.rb +391 -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_ra_backend_unlock_retry.rb +123 -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 +383 -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 +221 -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 +277 -10
- data/lib/gemba/input_mappings.rb +0 -214
- data/lib/gemba/player.rb +0 -1525
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# Floating hotkey reference panel toggled by pressing '?'.
|
|
5
|
+
#
|
|
6
|
+
# Non-modal — no grab, no focus steal. Positioned to the right of the
|
|
7
|
+
# main window via ChildWindow#position_near_parent. AppController pauses
|
|
8
|
+
# emulation while the panel is visible and restores play on close.
|
|
9
|
+
class HelpWindow
|
|
10
|
+
include ChildWindow
|
|
11
|
+
include Locale::Translatable
|
|
12
|
+
|
|
13
|
+
TOP = '.help_window'
|
|
14
|
+
|
|
15
|
+
def initialize(app:, hotkeys:)
|
|
16
|
+
@app = app
|
|
17
|
+
@hotkeys = hotkeys
|
|
18
|
+
build_toplevel(translate('settings.hotkeys'), geometry: '220x400') { build_ui }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def show = show_window(modal: false)
|
|
22
|
+
def hide = hide_window(modal: false)
|
|
23
|
+
def visible? = @app.tcl_eval("wm state #{TOP}") == 'normal'
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def build_ui
|
|
28
|
+
f = "#{TOP}.f"
|
|
29
|
+
@app.command('ttk::frame', f, padding: 8)
|
|
30
|
+
@app.command(:pack, f, fill: :both, expand: 1)
|
|
31
|
+
|
|
32
|
+
@app.command('ttk::label', "#{f}.title",
|
|
33
|
+
text: translate('settings.hotkeys'),
|
|
34
|
+
font: '{TkDefaultFont} 11 bold')
|
|
35
|
+
@app.command(:pack, "#{f}.title", pady: [0, 4])
|
|
36
|
+
|
|
37
|
+
@app.command('ttk::separator', "#{f}.sep", orient: :horizontal)
|
|
38
|
+
@app.command(:pack, "#{f}.sep", fill: :x, pady: [0, 6])
|
|
39
|
+
|
|
40
|
+
Settings::HotkeysTab::LOCALE_KEYS.each do |action, locale_key|
|
|
41
|
+
row = "#{f}.row_#{action}"
|
|
42
|
+
@app.command('ttk::frame', row)
|
|
43
|
+
@app.command(:pack, row, fill: :x, pady: 1)
|
|
44
|
+
|
|
45
|
+
act_lbl = "#{row}.act"
|
|
46
|
+
key_lbl = "#{row}.key"
|
|
47
|
+
|
|
48
|
+
key_text = HotkeyMap.display_name(@hotkeys.key_for(action))
|
|
49
|
+
|
|
50
|
+
@app.command('ttk::label', act_lbl, text: translate(locale_key), anchor: :w)
|
|
51
|
+
@app.command('ttk::label', key_lbl, text: key_text, anchor: :e,
|
|
52
|
+
font: '{TkFixedFont} 9')
|
|
53
|
+
|
|
54
|
+
@app.command(:grid, act_lbl, row: 0, column: 0, sticky: :w)
|
|
55
|
+
@app.command(:grid, key_lbl, row: 0, column: 1, sticky: :e)
|
|
56
|
+
@app.command(:grid, :columnconfigure, row, 0, weight: 1)
|
|
57
|
+
@app.command(:grid, :columnconfigure, row, 1, weight: 0)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/gemba/hotkey_map.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Gemba
|
|
|
11
11
|
class HotkeyMap
|
|
12
12
|
ACTIONS = %i[quit pause fast_forward fullscreen show_fps
|
|
13
13
|
quick_save quick_load save_states screenshot rewind
|
|
14
|
-
record].freeze
|
|
14
|
+
record input_record open_rom].freeze
|
|
15
15
|
|
|
16
16
|
DEFAULTS = {
|
|
17
17
|
quit: 'q', pause: 'p', fast_forward: 'Tab',
|
|
@@ -20,6 +20,8 @@ module Gemba
|
|
|
20
20
|
save_states: 'F6', screenshot: 'F9',
|
|
21
21
|
rewind: ['Shift', 'Tab'],
|
|
22
22
|
record: 'F10',
|
|
23
|
+
input_record: 'F4',
|
|
24
|
+
open_rom: ['Control', 'o'],
|
|
23
25
|
}.freeze
|
|
24
26
|
|
|
25
27
|
# Tk keysyms that are modifier keys → normalized name
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# Records per-frame input bitmasks to a .gir (Gemba Input Recording) file.
|
|
5
|
+
#
|
|
6
|
+
# Each GBA frame's pressed-button bitmask is stored as a 3-char hex line.
|
|
7
|
+
# An anchor save state is written alongside so replays start from the
|
|
8
|
+
# exact same emulator state.
|
|
9
|
+
#
|
|
10
|
+
# The header's frame_count is a best-effort hint (correct on clean stop,
|
|
11
|
+
# zero on crash). The replayer counts lines for the authoritative count.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# recorder = InputRecorder.new("session.gir", core: core)
|
|
15
|
+
# recorder.start
|
|
16
|
+
# loop do
|
|
17
|
+
# mask = poll_input
|
|
18
|
+
# recorder.capture(mask)
|
|
19
|
+
# core.set_keys(mask)
|
|
20
|
+
# core.run_frame
|
|
21
|
+
# end
|
|
22
|
+
# recorder.stop
|
|
23
|
+
class InputRecorder
|
|
24
|
+
VERSION = 1
|
|
25
|
+
FLUSH_INTERVAL = 60 # frames between flushes (~1s at 59.7 fps)
|
|
26
|
+
|
|
27
|
+
# @param path [String] output .gir file path
|
|
28
|
+
# @param core [Gemba::Core] mGBA core (for ROM metadata and save state)
|
|
29
|
+
# @param rom_path [String, nil] path to the ROM file (stored in header for easy replay)
|
|
30
|
+
def initialize(path, core:, rom_path: nil)
|
|
31
|
+
@path = path
|
|
32
|
+
@core = core
|
|
33
|
+
@rom_path = rom_path
|
|
34
|
+
@recording = false
|
|
35
|
+
@frame_count = 0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Start recording. Saves an anchor save state and opens the .gir file.
|
|
39
|
+
def start
|
|
40
|
+
raise "Already recording" if @recording
|
|
41
|
+
|
|
42
|
+
@core.save_state_to_file(anchor_state_path)
|
|
43
|
+
@frame_count = 0
|
|
44
|
+
@file = File.open(@path, 'w')
|
|
45
|
+
write_header
|
|
46
|
+
@file.flush
|
|
47
|
+
@recording = true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Capture one frame's input bitmask.
|
|
51
|
+
# @param bitmask [Integer] bitwise OR of KEY_* constants (0x000–0x3FF)
|
|
52
|
+
def capture(bitmask)
|
|
53
|
+
return unless @recording
|
|
54
|
+
|
|
55
|
+
@file.puts(format('%03x', bitmask & 0x3FF))
|
|
56
|
+
@frame_count += 1
|
|
57
|
+
@file.flush if (@frame_count % FLUSH_INTERVAL).zero?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Stop recording and close the file.
|
|
61
|
+
def stop
|
|
62
|
+
return unless @recording
|
|
63
|
+
|
|
64
|
+
@recording = false
|
|
65
|
+
rewrite_frame_count
|
|
66
|
+
@file.close
|
|
67
|
+
@file = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Boolean] true if currently recording
|
|
71
|
+
def recording?
|
|
72
|
+
@recording
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Integer] number of frames captured so far
|
|
76
|
+
attr_reader :frame_count
|
|
77
|
+
|
|
78
|
+
# @return [String] path to the anchor save state file
|
|
79
|
+
def anchor_state_path
|
|
80
|
+
@path.sub(/\.gir\z/, '.state')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
FRAME_COUNT_WIDTH = 10 # zero-padded digits (covers ~5.3 years at 60fps)
|
|
86
|
+
|
|
87
|
+
def write_header
|
|
88
|
+
@file.puts "# GEMBA INPUT RECORDING v#{VERSION}"
|
|
89
|
+
@file.puts "# rom_checksum: #{@core.checksum}"
|
|
90
|
+
@file.puts "# game_code: #{@core.game_code}"
|
|
91
|
+
@file.puts "# rom_path: #{@rom_path}" if @rom_path
|
|
92
|
+
@file.write "# frame_count: "
|
|
93
|
+
@frame_count_offset = @file.pos
|
|
94
|
+
@file.puts format("%0#{FRAME_COUNT_WIDTH}d", 0)
|
|
95
|
+
@file.puts "# anchor_state: #{File.basename(anchor_state_path)}"
|
|
96
|
+
@file.puts "---"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Best-effort: seek to the frame_count field and overwrite in place.
|
|
100
|
+
def rewrite_frame_count
|
|
101
|
+
@file.flush
|
|
102
|
+
@file.seek(@frame_count_offset)
|
|
103
|
+
@file.write(format("%0#{FRAME_COUNT_WIDTH}d", @frame_count))
|
|
104
|
+
@file.seek(0, IO::SEEK_END)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# Replays a .gir (Gemba Input Recording) file by feeding recorded
|
|
5
|
+
# per-frame bitmasks back to the emulator core.
|
|
6
|
+
#
|
|
7
|
+
# The authoritative frame count comes from counting bitmask lines,
|
|
8
|
+
# not the header (which is best-effort and may be zero on crash).
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# replayer = InputReplayer.new("session.gir")
|
|
12
|
+
# replayer.validate!(core)
|
|
13
|
+
# core.load_state_from_file(replayer.anchor_state_path)
|
|
14
|
+
# replayer.each_bitmask do |mask, frame|
|
|
15
|
+
# core.set_keys(mask)
|
|
16
|
+
# core.run_frame
|
|
17
|
+
# end
|
|
18
|
+
class InputReplayer
|
|
19
|
+
class ChecksumMismatch < StandardError; end
|
|
20
|
+
|
|
21
|
+
# @param gir_path [String] path to .gir file
|
|
22
|
+
def initialize(gir_path)
|
|
23
|
+
@path = gir_path
|
|
24
|
+
@header = {}
|
|
25
|
+
@bitmasks = []
|
|
26
|
+
parse!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Integer] ROM checksum from the recording header
|
|
30
|
+
def rom_checksum
|
|
31
|
+
@header[:rom_checksum]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [String] game code from the recording header
|
|
35
|
+
def game_code
|
|
36
|
+
@header[:game_code]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [String, nil] ROM path from the recording header
|
|
40
|
+
def rom_path
|
|
41
|
+
@header[:rom_path]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Integer] number of recorded frames (counted from bitmask lines)
|
|
45
|
+
def frame_count
|
|
46
|
+
@bitmasks.length
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [String] path to the anchor save state file
|
|
50
|
+
def anchor_state_path
|
|
51
|
+
dir = File.dirname(@path)
|
|
52
|
+
File.join(dir, @header[:anchor_state])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Validate that the recording matches the loaded ROM.
|
|
56
|
+
# @param core [Gemba::Core] mGBA core to validate against
|
|
57
|
+
# @raise [ChecksumMismatch] if ROM checksum doesn't match
|
|
58
|
+
def validate!(core)
|
|
59
|
+
if rom_checksum && core.checksum != rom_checksum
|
|
60
|
+
raise ChecksumMismatch,
|
|
61
|
+
"ROM checksum mismatch: recording has #{rom_checksum}, " \
|
|
62
|
+
"loaded ROM has #{core.checksum}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param frame [Integer] zero-based frame index
|
|
67
|
+
# @return [Integer] bitmask for the given frame
|
|
68
|
+
def bitmask_at(frame)
|
|
69
|
+
@bitmasks[frame]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Iterate over all recorded bitmasks.
|
|
73
|
+
# @yield [Integer, Integer] bitmask and zero-based frame index
|
|
74
|
+
def each_bitmask(&block)
|
|
75
|
+
@bitmasks.each_with_index(&block)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def parse!
|
|
81
|
+
in_header = true
|
|
82
|
+
|
|
83
|
+
File.foreach(@path) do |line|
|
|
84
|
+
line = line.strip
|
|
85
|
+
|
|
86
|
+
if in_header
|
|
87
|
+
if line == '---'
|
|
88
|
+
in_header = false
|
|
89
|
+
elsif line.start_with?('# ')
|
|
90
|
+
parse_header_line(line)
|
|
91
|
+
end
|
|
92
|
+
else
|
|
93
|
+
next if line.empty?
|
|
94
|
+
@bitmasks << line.to_i(16)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_header_line(line)
|
|
100
|
+
# Format: "# key: value"
|
|
101
|
+
content = line.sub(/^# /, '')
|
|
102
|
+
key, _, value = content.partition(': ')
|
|
103
|
+
return if value.empty?
|
|
104
|
+
|
|
105
|
+
case key
|
|
106
|
+
when 'rom_checksum'
|
|
107
|
+
@header[:rom_checksum] = value.to_i
|
|
108
|
+
when 'game_code'
|
|
109
|
+
@header[:game_code] = value
|
|
110
|
+
when 'anchor_state'
|
|
111
|
+
@header[:anchor_state] = value
|
|
112
|
+
when 'rom_path'
|
|
113
|
+
@header[:rom_path] = value
|
|
114
|
+
when 'frame_count'
|
|
115
|
+
@header[:header_frame_count] = value.to_i
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# Manages keyboard keysym → GBA bitmask mappings.
|
|
5
|
+
#
|
|
6
|
+
# Shares the same interface as {GamepadMap} so that Player can
|
|
7
|
+
# delegate to either without knowing which device type is active.
|
|
8
|
+
class KeyboardMap
|
|
9
|
+
DEFAULT_MAP = {
|
|
10
|
+
'z' => KEY_A,
|
|
11
|
+
'x' => KEY_B,
|
|
12
|
+
'BackSpace' => KEY_SELECT,
|
|
13
|
+
'Return' => KEY_START,
|
|
14
|
+
'Right' => KEY_RIGHT,
|
|
15
|
+
'Left' => KEY_LEFT,
|
|
16
|
+
'Up' => KEY_UP,
|
|
17
|
+
'Down' => KEY_DOWN,
|
|
18
|
+
'a' => KEY_L,
|
|
19
|
+
's' => KEY_R,
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
def initialize(config)
|
|
23
|
+
@config = config
|
|
24
|
+
@map = DEFAULT_MAP.dup
|
|
25
|
+
@device = nil
|
|
26
|
+
load_config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_writer :device
|
|
30
|
+
|
|
31
|
+
def mask
|
|
32
|
+
return 0 unless @device
|
|
33
|
+
m = 0
|
|
34
|
+
@map.each { |key, bit| m |= bit if @device.button?(key) }
|
|
35
|
+
m
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def set(gba_btn, input_key)
|
|
39
|
+
bit = GBA_BTN_BITS[gba_btn] or return
|
|
40
|
+
@map.delete_if { |_, v| v == bit }
|
|
41
|
+
@map[input_key.to_s] = bit
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset!
|
|
45
|
+
@map = DEFAULT_MAP.dup
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def load_config
|
|
49
|
+
cfg = @config.mappings(Config::KEYBOARD_GUID)
|
|
50
|
+
if cfg.empty?
|
|
51
|
+
@map = DEFAULT_MAP.dup
|
|
52
|
+
else
|
|
53
|
+
@map = {}
|
|
54
|
+
cfg.each do |gba_str, keysym|
|
|
55
|
+
bit = GBA_BTN_BITS[gba_str.to_sym]
|
|
56
|
+
next unless bit
|
|
57
|
+
@map[keysym] = bit
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reload!
|
|
63
|
+
@config.reload!
|
|
64
|
+
load_config
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def labels
|
|
68
|
+
result = {}
|
|
69
|
+
@map.each do |input, bit|
|
|
70
|
+
gba_btn = GBA_BTN_BITS.key(bit)
|
|
71
|
+
result[gba_btn] = input if gba_btn
|
|
72
|
+
end
|
|
73
|
+
result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def save_to_config
|
|
77
|
+
@map.each do |input, bit|
|
|
78
|
+
gba_btn = GBA_BTN_BITS.key(bit)
|
|
79
|
+
@config.set_mapping(Config::KEYBOARD_GUID, gba_btn, input) if gba_btn
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def supports_deadzone? = false
|
|
84
|
+
def dead_zone_pct = 0
|
|
85
|
+
|
|
86
|
+
def set_dead_zone(_)
|
|
87
|
+
raise NotImplementedError, "keyboard does not support dead zones"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# Startup frame showing all library ROMs as a sortable treeview list.
|
|
5
|
+
#
|
|
6
|
+
# Alternative to GamePickerFrame (no boxart). Columns: Title, Last Played.
|
|
7
|
+
# Clicking a column header sorts; the active column shows a ▲/▼ indicator.
|
|
8
|
+
# Double-clicking a row emits :rom_selected. Right-clicking shows a context
|
|
9
|
+
# menu identical to the GamePickerFrame card menu.
|
|
10
|
+
# Pure Tk — no SDL2.
|
|
11
|
+
class ListPickerFrame
|
|
12
|
+
include BusEmitter
|
|
13
|
+
include Locale::Translatable
|
|
14
|
+
|
|
15
|
+
LIST_DEFAULT_W = 480
|
|
16
|
+
LIST_DEFAULT_H = 600
|
|
17
|
+
LIST_MIN_W = 320
|
|
18
|
+
LIST_MIN_H = 400
|
|
19
|
+
|
|
20
|
+
SORT_ASC = ' ▲'
|
|
21
|
+
SORT_DESC = ' ▼'
|
|
22
|
+
|
|
23
|
+
def default_geometry = [LIST_DEFAULT_W, LIST_DEFAULT_H]
|
|
24
|
+
def min_geometry = [LIST_MIN_W, LIST_MIN_H]
|
|
25
|
+
|
|
26
|
+
def initialize(app:, rom_library:, rom_overrides: nil)
|
|
27
|
+
@app = app
|
|
28
|
+
@rom_library = rom_library
|
|
29
|
+
@overrides = rom_overrides
|
|
30
|
+
@built = false
|
|
31
|
+
@sort_col = 'last_played'
|
|
32
|
+
@sort_asc = false # most-recent first by default
|
|
33
|
+
@row_data = {} # treeview item id => RomInfo
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show
|
|
37
|
+
build_ui unless @built
|
|
38
|
+
refresh
|
|
39
|
+
@app.command(:pack, @outer, fill: :both, expand: 1)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def hide
|
|
43
|
+
@app.command(:pack, :forget, @outer) rescue nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def cleanup; end
|
|
47
|
+
|
|
48
|
+
def receive(event, **_args)
|
|
49
|
+
case event
|
|
50
|
+
when :refresh then refresh
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def aspect_ratio = nil
|
|
55
|
+
def rom_loaded? = false
|
|
56
|
+
def sdl2_ready? = false
|
|
57
|
+
def paused? = false
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def build_ui
|
|
62
|
+
@outer = '.list_picker'
|
|
63
|
+
@app.command('ttk::frame', @outer, padding: 8)
|
|
64
|
+
|
|
65
|
+
# Treeview + scrollbar
|
|
66
|
+
@tree = "#{@outer}.tree"
|
|
67
|
+
@scrollbar = "#{@outer}.scroll"
|
|
68
|
+
|
|
69
|
+
@app.command('ttk::treeview', @tree,
|
|
70
|
+
columns: Teek.make_list('title', 'last_played'),
|
|
71
|
+
show: :headings,
|
|
72
|
+
selectmode: :browse)
|
|
73
|
+
|
|
74
|
+
@app.command('ttk::scrollbar', @scrollbar, orient: :vertical,
|
|
75
|
+
command: "#{@tree} yview")
|
|
76
|
+
@app.command(@tree, :configure, yscrollcommand: "#{@scrollbar} set")
|
|
77
|
+
|
|
78
|
+
build_columns
|
|
79
|
+
bind_events
|
|
80
|
+
|
|
81
|
+
@app.command(:grid, @tree, row: 0, column: 0, sticky: :nsew)
|
|
82
|
+
@app.command(:grid, @scrollbar, row: 0, column: 1, sticky: :ns)
|
|
83
|
+
@app.command(:grid, :columnconfigure, @outer, 0, weight: 1)
|
|
84
|
+
@app.command(:grid, :rowconfigure, @outer, 0, weight: 1)
|
|
85
|
+
|
|
86
|
+
build_toolbar
|
|
87
|
+
|
|
88
|
+
@built = true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_columns
|
|
92
|
+
@app.command(@tree, :heading, 'title',
|
|
93
|
+
text: translate('list_picker.columns.title') + (@sort_col == 'title' ? sort_indicator : ''),
|
|
94
|
+
anchor: :w,
|
|
95
|
+
command: proc { sort_by('title') })
|
|
96
|
+
@app.command(@tree, :heading, 'last_played',
|
|
97
|
+
text: translate('list_picker.columns.last_played') + (@sort_col == 'last_played' ? sort_indicator : ''),
|
|
98
|
+
anchor: :w,
|
|
99
|
+
command: proc { sort_by('last_played') })
|
|
100
|
+
@app.command(@tree, :column, 'title', width: 280, stretch: 1)
|
|
101
|
+
@app.command(@tree, :column, 'last_played', width: 120, stretch: 0)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def bind_events
|
|
105
|
+
# Physical double-click fires virtual event so tests can trigger it
|
|
106
|
+
# directly without needing event generate <Double-Button-1> (forbidden in Tk 9).
|
|
107
|
+
@app.command(:bind, @tree, '<Double-Button-1>', proc {
|
|
108
|
+
@app.tcl_eval("event generate #{@tree} <<DoubleClick>>")
|
|
109
|
+
})
|
|
110
|
+
@app.command(:bind, @tree, '<<DoubleClick>>', proc {
|
|
111
|
+
iid = @app.tcl_eval("#{@tree} focus")
|
|
112
|
+
next if iid.to_s.empty?
|
|
113
|
+
rom_info = @row_data[iid]
|
|
114
|
+
emit(:rom_selected, rom_info.path) if rom_info
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
# Physical right-click: use %x/%y (widget-relative event coords) to
|
|
118
|
+
# identify the row, select and focus it, then fire the virtual event so
|
|
119
|
+
# tests can trigger the same code path without real pointer coordinates.
|
|
120
|
+
@app.tcl_eval(<<~TCL)
|
|
121
|
+
bind #{@tree} <Button-3> {+
|
|
122
|
+
set _iid [#{@tree} identify row %x %y]
|
|
123
|
+
if {$_iid ne {}} {
|
|
124
|
+
#{@tree} selection set $_iid
|
|
125
|
+
#{@tree} focus $_iid
|
|
126
|
+
event generate #{@tree} <<RightClick>>
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
TCL
|
|
130
|
+
|
|
131
|
+
# Virtual event reads the currently focused item. Decoupled from pointer
|
|
132
|
+
# position so tests can trigger it directly after setting focus.
|
|
133
|
+
@app.command(:bind, @tree, '<<RightClick>>', proc {
|
|
134
|
+
iid = @app.tcl_eval("#{@tree} focus")
|
|
135
|
+
rom_info = @row_data[iid.to_s]
|
|
136
|
+
post_row_menu(rom_info) if rom_info
|
|
137
|
+
})
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def build_toolbar
|
|
141
|
+
sep = "#{@outer}.sep"
|
|
142
|
+
@app.command('ttk::separator', sep, orient: :horizontal)
|
|
143
|
+
@app.command(:grid, sep, row: 1, column: 0, columnspan: 2, sticky: :ew, pady: [4, 0])
|
|
144
|
+
|
|
145
|
+
toolbar = "#{@outer}.toolbar"
|
|
146
|
+
@app.command('ttk::frame', toolbar, padding: [4, 2])
|
|
147
|
+
@app.command(:grid, toolbar, row: 2, column: 0, columnspan: 2, sticky: :ew)
|
|
148
|
+
|
|
149
|
+
gear_btn = "#{toolbar}.gear"
|
|
150
|
+
gear_menu = "#{toolbar}.gearmenu"
|
|
151
|
+
@app.command('ttk::button', gear_btn, text: "\u2699", width: 1,
|
|
152
|
+
command: proc { post_view_menu(gear_menu, gear_btn) })
|
|
153
|
+
@app.command(:pack, gear_btn, side: :right)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def refresh
|
|
157
|
+
@app.tcl_eval("#{@tree} delete [#{@tree} children {}]")
|
|
158
|
+
@row_data.clear
|
|
159
|
+
|
|
160
|
+
roms = sorted(@rom_library.all)
|
|
161
|
+
roms.each do |rom|
|
|
162
|
+
rom_info = RomInfo.from_rom(rom, overrides: @overrides)
|
|
163
|
+
lp = format_last_played(rom['last_played'])
|
|
164
|
+
iid = @app.tcl_eval(
|
|
165
|
+
"#{@tree} insert {} end -values [list #{Teek.make_list(rom_info.title, lp)}]"
|
|
166
|
+
)
|
|
167
|
+
@row_data[iid] = rom_info
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def sorted(roms)
|
|
172
|
+
sorted = roms.sort_by do |r|
|
|
173
|
+
case @sort_col
|
|
174
|
+
when 'title' then r['title'].to_s.downcase
|
|
175
|
+
when 'last_played' then r['last_played'] || r['added_at'] || ''
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
@sort_asc ? sorted : sorted.reverse
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def sort_by(col)
|
|
182
|
+
if @sort_col == col
|
|
183
|
+
@sort_asc = !@sort_asc
|
|
184
|
+
else
|
|
185
|
+
@sort_col = col
|
|
186
|
+
@sort_asc = (col == 'title') # title: asc first; date: newest first
|
|
187
|
+
end
|
|
188
|
+
update_headings
|
|
189
|
+
refresh
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def update_headings
|
|
193
|
+
['title', 'last_played'].each do |col|
|
|
194
|
+
label_key = col == 'title' ? 'list_picker.columns.title' : 'list_picker.columns.last_played'
|
|
195
|
+
indicator = @sort_col == col ? sort_indicator : ''
|
|
196
|
+
@app.command(@tree, :heading, col, text: translate(label_key) + indicator)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def sort_indicator
|
|
201
|
+
@sort_asc ? SORT_ASC : SORT_DESC
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def format_last_played(iso)
|
|
205
|
+
return translate('list_picker.never_played') if iso.to_s.empty?
|
|
206
|
+
require 'time'
|
|
207
|
+
Time.parse(iso).localtime.strftime('%b %-d, %Y')
|
|
208
|
+
rescue
|
|
209
|
+
iso.to_s
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def post_view_menu(menu, btn)
|
|
213
|
+
@app.command(:menu, menu, tearoff: 0) unless @app.tcl_eval("winfo exists #{menu}") == '1'
|
|
214
|
+
@app.command(menu, :delete, 0, :end)
|
|
215
|
+
current = Gemba.user_config.picker_view
|
|
216
|
+
@app.command(menu, :add, :command,
|
|
217
|
+
label: "#{current == 'grid' ? "\u2713 " : ' '}#{translate('picker.toolbar.boxart_view')}",
|
|
218
|
+
command: proc { emit(:picker_view_changed, view: 'grid') })
|
|
219
|
+
@app.command(menu, :add, :command,
|
|
220
|
+
label: "#{current == 'list' ? "\u2713 " : ' '}#{translate('picker.toolbar.list_view')}",
|
|
221
|
+
command: proc { emit(:picker_view_changed, view: 'list') })
|
|
222
|
+
x = @app.tcl_eval("winfo rootx #{btn}").to_i
|
|
223
|
+
y = @app.tcl_eval("winfo rooty #{btn}").to_i
|
|
224
|
+
h = @app.tcl_eval("winfo height #{btn}").to_i
|
|
225
|
+
@app.tcl_eval("tk_popup #{menu} #{x} #{y + h}")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def post_row_menu(rom_info)
|
|
229
|
+
menu = "#{@tree}.ctx"
|
|
230
|
+
@app.command(:menu, menu, tearoff: 0) unless @app.tcl_eval("winfo exists #{menu}") == '1'
|
|
231
|
+
@app.command(menu, :delete, 0, :end)
|
|
232
|
+
@app.command(menu, :add, :command,
|
|
233
|
+
label: translate('game_picker.menu.play'),
|
|
234
|
+
command: proc { emit(:rom_selected, rom_info.path) })
|
|
235
|
+
qs_slot = Gemba.user_config.quick_save_slot
|
|
236
|
+
qs_state = quick_save_exists?(rom_info, qs_slot)
|
|
237
|
+
@app.command(menu, :add, :command,
|
|
238
|
+
label: translate('game_picker.menu.quick_load'),
|
|
239
|
+
state: qs_state ? :normal : :disabled,
|
|
240
|
+
command: proc { emit(:rom_quick_load, path: rom_info.path, slot: qs_slot) })
|
|
241
|
+
@app.command(menu, :add, :command,
|
|
242
|
+
label: translate('game_picker.menu.set_boxart'),
|
|
243
|
+
command: proc { pick_custom_boxart(rom_info) })
|
|
244
|
+
@app.command(menu, :add, :separator)
|
|
245
|
+
@app.command(menu, :add, :command,
|
|
246
|
+
label: translate('game_picker.menu.remove'),
|
|
247
|
+
command: proc { remove_rom(rom_info) })
|
|
248
|
+
@app.tcl_eval("tk_popup #{menu} [winfo pointerx .] [winfo pointery .]")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def quick_save_exists?(rom_info, slot)
|
|
252
|
+
return false unless rom_info.rom_id
|
|
253
|
+
state_file = File.join(Gemba.user_config.states_dir, rom_info.rom_id, "state#{slot}.ss")
|
|
254
|
+
File.exist?(state_file)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def remove_rom(rom_info)
|
|
258
|
+
@rom_library.remove(rom_info.rom_id)
|
|
259
|
+
@rom_library.save!
|
|
260
|
+
refresh
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def pick_custom_boxart(rom_info)
|
|
264
|
+
return unless @overrides
|
|
265
|
+
filetypes = '{{PNG Images} {.png}}'
|
|
266
|
+
path = @app.tcl_eval("tk_getOpenFile -filetypes {#{filetypes}}")
|
|
267
|
+
return if path.to_s.strip.empty?
|
|
268
|
+
@overrides.set_custom_boxart(rom_info.rom_id, path)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|