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,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Gemba
|
|
6
|
+
class CLI
|
|
7
|
+
module Commands
|
|
8
|
+
class Record
|
|
9
|
+
def initialize(argv, dry_run: false)
|
|
10
|
+
@argv = argv
|
|
11
|
+
@dry_run = dry_run
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
options = parse
|
|
16
|
+
|
|
17
|
+
if options[:help]
|
|
18
|
+
puts options[:parser] unless @dry_run
|
|
19
|
+
return { command: :record, help: true }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
unless options[:frames] && options[:rom]
|
|
23
|
+
$stderr.puts "Error: record requires --frames N and a ROM file"
|
|
24
|
+
$stderr.puts "Run 'gemba record --help' for usage"
|
|
25
|
+
exit 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
result = {
|
|
29
|
+
command: :record,
|
|
30
|
+
rom: options[:rom],
|
|
31
|
+
frames: options[:frames],
|
|
32
|
+
output: options[:output],
|
|
33
|
+
compression: options[:compression],
|
|
34
|
+
progress: options[:progress],
|
|
35
|
+
options: options.except(:parser)
|
|
36
|
+
}
|
|
37
|
+
return result if @dry_run
|
|
38
|
+
|
|
39
|
+
require "gemba/headless"
|
|
40
|
+
|
|
41
|
+
total = options[:frames]
|
|
42
|
+
|
|
43
|
+
HeadlessPlayer.open(options[:rom]) do |player|
|
|
44
|
+
rec_path = options[:output] ||
|
|
45
|
+
"#{Config.rom_id(player.game_code, player.checksum)}.grec"
|
|
46
|
+
|
|
47
|
+
rec_opts = {}
|
|
48
|
+
rec_opts[:compression] = options[:compression] if options[:compression]
|
|
49
|
+
player.start_recording(rec_path, **rec_opts)
|
|
50
|
+
|
|
51
|
+
if options[:progress]
|
|
52
|
+
last_print = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
53
|
+
player.step(total) do |frame|
|
|
54
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
55
|
+
if frame == total || now - last_print >= 0.5
|
|
56
|
+
pct = frame * 100.0 / total
|
|
57
|
+
$stderr.print "\rRecording: #{frame}/#{total} (#{'%.1f' % pct}%)\e[K"
|
|
58
|
+
last_print = now
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
$stderr.print "\r\e[K"
|
|
62
|
+
else
|
|
63
|
+
player.step(total)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
player.stop_recording
|
|
67
|
+
|
|
68
|
+
info = RecorderDecoder.stats(rec_path)
|
|
69
|
+
puts "Recorded #{info[:frame_count]} frames to #{rec_path}"
|
|
70
|
+
puts " Duration: #{'%.1f' % info[:duration]}s"
|
|
71
|
+
puts " Avg change: #{'%.1f' % info[:avg_change_pct]}%/frame"
|
|
72
|
+
puts " Uncompressed: #{format_size(info[:raw_video_size])} (encode input)"
|
|
73
|
+
puts " .grec size: #{format_size(File.size(rec_path))}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse
|
|
78
|
+
options = {}
|
|
79
|
+
argv = @argv.dup
|
|
80
|
+
|
|
81
|
+
parser = OptionParser.new do |o|
|
|
82
|
+
o.banner = "Usage: gemba record [options] ROM_FILE"
|
|
83
|
+
o.separator ""
|
|
84
|
+
o.separator "Record video+audio to a .grec file (headless, no GUI)."
|
|
85
|
+
o.separator ""
|
|
86
|
+
|
|
87
|
+
o.on("--frames N", Integer, "Number of frames to record (required)") { |v| options[:frames] = v }
|
|
88
|
+
o.on("-o", "--output PATH", "Output .grec path (default: ROM_ID.grec)") { |v| options[:output] = v }
|
|
89
|
+
o.on("-c", "--compression N", Integer, "Zlib level 1-9 (default: 1)") { |v| options[:compression] = v.clamp(1, 9) }
|
|
90
|
+
o.on("--progress", "Show recording progress") { options[:progress] = true }
|
|
91
|
+
o.on("-h", "--help", "Show this help") { options[:help] = true }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
parser.parse!(argv)
|
|
95
|
+
options[:rom] = File.expand_path(argv.first) if argv.first
|
|
96
|
+
options[:parser] = parser
|
|
97
|
+
options
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def format_size(bytes)
|
|
103
|
+
if bytes >= 1_073_741_824
|
|
104
|
+
"#{'%.1f' % (bytes / 1_073_741_824.0)} GB"
|
|
105
|
+
elsif bytes >= 1_048_576
|
|
106
|
+
"#{'%.1f' % (bytes / 1_048_576.0)} MB"
|
|
107
|
+
else
|
|
108
|
+
"#{'%.1f' % (bytes / 1024.0)} KB"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Gemba
|
|
6
|
+
class CLI
|
|
7
|
+
module Commands
|
|
8
|
+
class Replay
|
|
9
|
+
def initialize(argv, dry_run: false)
|
|
10
|
+
@argv = argv
|
|
11
|
+
@dry_run = dry_run
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
options = parse
|
|
16
|
+
|
|
17
|
+
if options[:help]
|
|
18
|
+
puts options[:parser] unless @dry_run
|
|
19
|
+
return { command: :replay, help: true }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if options[:list]
|
|
23
|
+
list_recordings unless @dry_run
|
|
24
|
+
return { command: :replay_list }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
unless options[:gir]
|
|
28
|
+
$stderr.puts "Error: replay requires a .gir file"
|
|
29
|
+
$stderr.puts "Run 'gemba replay --help' for usage"
|
|
30
|
+
exit 1
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
gir_path = File.expand_path(options[:gir])
|
|
34
|
+
|
|
35
|
+
result = {
|
|
36
|
+
command: options[:headless] ? :replay_headless : :replay,
|
|
37
|
+
gir: gir_path,
|
|
38
|
+
rom: options[:rom],
|
|
39
|
+
sound: options.fetch(:sound, true),
|
|
40
|
+
fullscreen: options[:fullscreen],
|
|
41
|
+
headless: options[:headless],
|
|
42
|
+
progress: options[:progress],
|
|
43
|
+
options: options.except(:parser)
|
|
44
|
+
}
|
|
45
|
+
return result if @dry_run
|
|
46
|
+
|
|
47
|
+
if options[:headless]
|
|
48
|
+
run_headless(gir_path, options)
|
|
49
|
+
else
|
|
50
|
+
run_gui(gir_path, options)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse
|
|
55
|
+
options = {}
|
|
56
|
+
argv = @argv.dup
|
|
57
|
+
|
|
58
|
+
parser = OptionParser.new do |o|
|
|
59
|
+
o.banner = "Usage: gemba replay [options] GIR_FILE [ROM_FILE]"
|
|
60
|
+
o.separator ""
|
|
61
|
+
o.separator "Replay a .gir input recording."
|
|
62
|
+
o.separator "ROM is read from the .gir header; override with ROM_FILE."
|
|
63
|
+
o.separator ""
|
|
64
|
+
|
|
65
|
+
o.on("-l", "--list", "List available .gir recordings") { options[:list] = true }
|
|
66
|
+
o.on("--headless", "Run without GUI (print summary and exit)") { options[:headless] = true }
|
|
67
|
+
o.on("--progress", "Show progress (headless only)") { options[:progress] = true }
|
|
68
|
+
o.on("-f", "--fullscreen", "Start in fullscreen") { options[:fullscreen] = true }
|
|
69
|
+
o.on("--no-sound", "Disable audio") { options[:sound] = false }
|
|
70
|
+
o.on("-h", "--help", "Show this help") { options[:help] = true }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
parser.parse!(argv)
|
|
74
|
+
options[:gir] = argv.shift
|
|
75
|
+
options[:rom] = argv.shift
|
|
76
|
+
options[:parser] = parser
|
|
77
|
+
options
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def run_headless(gir_path, options)
|
|
83
|
+
require "gemba/headless"
|
|
84
|
+
|
|
85
|
+
rom_path = options[:rom]
|
|
86
|
+
unless rom_path
|
|
87
|
+
replayer = Gemba::InputReplayer.new(gir_path)
|
|
88
|
+
rom_path = replayer.rom_path
|
|
89
|
+
unless rom_path
|
|
90
|
+
$stderr.puts "Error: .gir has no rom_path in header; pass ROM_FILE explicitly"
|
|
91
|
+
exit 1
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
rom_path = File.expand_path(rom_path)
|
|
95
|
+
|
|
96
|
+
Gemba::HeadlessPlayer.open(rom_path) do |player|
|
|
97
|
+
if options[:progress]
|
|
98
|
+
replayer = Gemba::InputReplayer.new(gir_path)
|
|
99
|
+
total = replayer.frame_count
|
|
100
|
+
last_print = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
101
|
+
|
|
102
|
+
player.replay(gir_path) do |_mask, idx|
|
|
103
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
104
|
+
frame = idx + 1
|
|
105
|
+
if frame == total || now - last_print >= 0.5
|
|
106
|
+
pct = frame * 100.0 / total
|
|
107
|
+
$stderr.print "\rReplaying: #{frame}/#{total} (#{'%.1f' % pct}%)\e[K"
|
|
108
|
+
last_print = now
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
$stderr.print "\r\e[K"
|
|
112
|
+
else
|
|
113
|
+
player.replay(gir_path)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
puts "Replayed #{gir_path} (#{Gemba::InputReplayer.new(gir_path).frame_count} frames)"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_gui(gir_path, options)
|
|
121
|
+
require "gemba"
|
|
122
|
+
|
|
123
|
+
sound = options.fetch(:sound, true)
|
|
124
|
+
ReplayPlayer.new(gir_path,
|
|
125
|
+
sound: sound,
|
|
126
|
+
fullscreen: options[:fullscreen]).run
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def list_recordings
|
|
130
|
+
require "gemba/headless"
|
|
131
|
+
|
|
132
|
+
dir = Config.default_recordings_dir
|
|
133
|
+
unless File.directory?(dir)
|
|
134
|
+
puts "No recordings directory found at #{dir}"
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
gir_files = Dir.glob(File.join(dir, '*.gir')).sort
|
|
139
|
+
if gir_files.empty?
|
|
140
|
+
puts "No .gir recordings in #{dir}"
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
by_rom = {}
|
|
145
|
+
gir_files.each do |path|
|
|
146
|
+
replayer = InputReplayer.new(path)
|
|
147
|
+
key = replayer.game_code || "unknown"
|
|
148
|
+
(by_rom[key] ||= []) << { path: path, frames: replayer.frame_count }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
by_rom.each do |game_code, entries|
|
|
152
|
+
puts "#{game_code}:"
|
|
153
|
+
entries.each do |entry|
|
|
154
|
+
puts " #{entry[:path]} (#{entry[:frames]} frames)"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Gemba
|
|
6
|
+
class CLI
|
|
7
|
+
module Commands
|
|
8
|
+
class RetroAchievements
|
|
9
|
+
RA_SUBCOMMANDS = %w[login verify logout achievements].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(argv, dry_run: false, config: nil, requester: nil)
|
|
12
|
+
@argv = argv
|
|
13
|
+
@dry_run = dry_run
|
|
14
|
+
@config = config
|
|
15
|
+
@requester = requester
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
options = parse
|
|
20
|
+
|
|
21
|
+
if options[:help] || options[:subcommand].nil?
|
|
22
|
+
puts options[:parser] unless @dry_run
|
|
23
|
+
return { command: :ra, help: true, subcommand: options[:subcommand] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
result = { command: :"ra_#{options[:subcommand]}", **options.except(:parser) }
|
|
27
|
+
return result if @dry_run
|
|
28
|
+
|
|
29
|
+
require "gemba"
|
|
30
|
+
require "gemba/achievements/retro_achievements/cli_sync_requester"
|
|
31
|
+
|
|
32
|
+
config = @config || Gemba.user_config
|
|
33
|
+
requester = @requester || Gemba::Achievements::RetroAchievements::CliSyncRequester.new
|
|
34
|
+
|
|
35
|
+
case options[:subcommand]
|
|
36
|
+
when :login then login(options, config: config, requester: requester)
|
|
37
|
+
when :verify then verify( config: config, requester: requester)
|
|
38
|
+
when :logout then logout( config: config)
|
|
39
|
+
when :achievements then achievements(options, config: config, requester: requester)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse
|
|
44
|
+
argv = @argv.dup
|
|
45
|
+
sub = RA_SUBCOMMANDS.include?(argv.first) ? argv.shift.to_sym : nil
|
|
46
|
+
|
|
47
|
+
options = { subcommand: sub }
|
|
48
|
+
|
|
49
|
+
parser = OptionParser.new do |o|
|
|
50
|
+
case sub
|
|
51
|
+
when :login
|
|
52
|
+
o.banner = "Usage: gemba ra login --username USER [--password PASS]"
|
|
53
|
+
o.separator ""
|
|
54
|
+
o.separator "Log in to RetroAchievements and save credentials."
|
|
55
|
+
o.separator "Prompts for password if --password is not given."
|
|
56
|
+
o.separator ""
|
|
57
|
+
o.on("--username USER", "RetroAchievements username") { |v| options[:username] = v }
|
|
58
|
+
o.on("--password PASS", "Password (prompts if omitted)") { |v| options[:password] = v }
|
|
59
|
+
when :verify
|
|
60
|
+
o.banner = "Usage: gemba ra verify"
|
|
61
|
+
o.separator ""
|
|
62
|
+
o.separator "Verify stored RetroAchievements credentials are still valid."
|
|
63
|
+
o.separator ""
|
|
64
|
+
when :logout
|
|
65
|
+
o.banner = "Usage: gemba ra logout"
|
|
66
|
+
o.separator ""
|
|
67
|
+
o.separator "Clear stored RetroAchievements credentials."
|
|
68
|
+
o.separator ""
|
|
69
|
+
when :achievements
|
|
70
|
+
o.banner = "Usage: gemba ra achievements --rom PATH [--json]"
|
|
71
|
+
o.separator ""
|
|
72
|
+
o.separator "List achievements for a ROM."
|
|
73
|
+
o.separator ""
|
|
74
|
+
o.on("--rom PATH", "Path to the GBA ROM file") { |v| options[:rom] = File.expand_path(v) }
|
|
75
|
+
o.on("--json", "Output as JSON") { options[:json] = true }
|
|
76
|
+
else
|
|
77
|
+
o.banner = "Usage: gemba ra <subcommand> [options]"
|
|
78
|
+
o.separator ""
|
|
79
|
+
o.separator "Subcommands: #{RA_SUBCOMMANDS.join(', ')}"
|
|
80
|
+
o.separator ""
|
|
81
|
+
end
|
|
82
|
+
o.on("-h", "--help", "Show this help") { options[:help] = true }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
parser.parse!(argv)
|
|
86
|
+
options[:parser] = parser
|
|
87
|
+
options
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def login(options, config:, requester:)
|
|
93
|
+
username = options[:username]
|
|
94
|
+
unless username
|
|
95
|
+
$stderr.puts "Error: --username USER is required"
|
|
96
|
+
$stderr.puts "Run 'gemba ra login --help' for usage"
|
|
97
|
+
exit 1
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
password = options[:password] || begin
|
|
101
|
+
require "io/console"
|
|
102
|
+
$stderr.print "Password: "
|
|
103
|
+
pwd = $stdin.noecho(&:gets)&.chomp
|
|
104
|
+
$stderr.puts
|
|
105
|
+
pwd
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
backend = Gemba::Achievements::RetroAchievements::Backend.new(
|
|
109
|
+
app: nil, requester: requester
|
|
110
|
+
)
|
|
111
|
+
result = nil
|
|
112
|
+
backend.on_auth_change { |status, payload| result = [status, payload] }
|
|
113
|
+
backend.login_with_password(username: username, password: password)
|
|
114
|
+
|
|
115
|
+
if result&.first == :ok
|
|
116
|
+
config.ra_username = username
|
|
117
|
+
config.ra_token = result[1]
|
|
118
|
+
config.ra_enabled = true
|
|
119
|
+
config.save!
|
|
120
|
+
puts "Logged in as #{username}"
|
|
121
|
+
else
|
|
122
|
+
$stderr.puts "Login failed: #{result&.[](1) || 'unknown error'}"
|
|
123
|
+
exit 1
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def verify(config:, requester:)
|
|
128
|
+
username = config.ra_username
|
|
129
|
+
token = config.ra_token
|
|
130
|
+
|
|
131
|
+
if username.empty? || token.empty?
|
|
132
|
+
$stderr.puts "Not logged in. Run: gemba ra login --username USER"
|
|
133
|
+
exit 1
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
backend = Gemba::Achievements::RetroAchievements::Backend.new(
|
|
137
|
+
app: nil, requester: requester
|
|
138
|
+
)
|
|
139
|
+
result = nil
|
|
140
|
+
backend.on_auth_change { |status, _| result = status }
|
|
141
|
+
backend.login_with_token(username: username, token: token)
|
|
142
|
+
|
|
143
|
+
if result == :ok
|
|
144
|
+
puts "Token valid for #{username}"
|
|
145
|
+
else
|
|
146
|
+
$stderr.puts "Token invalid or expired. Run: gemba ra login --username USER"
|
|
147
|
+
exit 1
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def logout(config:)
|
|
152
|
+
config.ra_username = ""
|
|
153
|
+
config.ra_token = ""
|
|
154
|
+
config.ra_enabled = false
|
|
155
|
+
config.save!
|
|
156
|
+
puts "Logged out"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def achievements(options, config:, requester:)
|
|
160
|
+
username = config.ra_username
|
|
161
|
+
token = config.ra_token
|
|
162
|
+
|
|
163
|
+
if username.empty? || token.empty?
|
|
164
|
+
$stderr.puts "Not logged in. Run: gemba ra login --username USER"
|
|
165
|
+
exit 1
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
unless options[:rom]
|
|
169
|
+
$stderr.puts "Error: --rom PATH is required"
|
|
170
|
+
$stderr.puts "Run 'gemba ra achievements --help' for usage"
|
|
171
|
+
exit 1
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
require "digest"
|
|
175
|
+
|
|
176
|
+
backend = Gemba::Achievements::RetroAchievements::Backend.new(
|
|
177
|
+
app: nil, requester: requester
|
|
178
|
+
)
|
|
179
|
+
backend.login_with_token(username: username, token: token)
|
|
180
|
+
|
|
181
|
+
rom_path = options[:rom]
|
|
182
|
+
md5 = Digest::MD5.file(rom_path).hexdigest
|
|
183
|
+
rom_info = Struct.new(:md5, :title).new(md5, File.basename(rom_path, ".*"))
|
|
184
|
+
|
|
185
|
+
list = nil
|
|
186
|
+
backend.fetch_for_display(rom_info: rom_info) { |result| list = result }
|
|
187
|
+
|
|
188
|
+
if list.nil?
|
|
189
|
+
$stderr.puts "No achievements found (game not recognized by RetroAchievements)"
|
|
190
|
+
exit 1
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
if options[:json]
|
|
194
|
+
require "json"
|
|
195
|
+
puts JSON.generate(list.map { |a|
|
|
196
|
+
{ id: a.id, title: a.title, description: a.description,
|
|
197
|
+
points: a.points, earned: a.earned?, earned_at: a.earned_at }
|
|
198
|
+
})
|
|
199
|
+
else
|
|
200
|
+
earned = list.count(&:earned?)
|
|
201
|
+
puts "#{earned}/#{list.size} achievements — #{rom_info.title}"
|
|
202
|
+
puts
|
|
203
|
+
list.each do |a|
|
|
204
|
+
mark = a.earned? ? "X" : " "
|
|
205
|
+
puts " [#{mark}] #{a.title} (#{a.points}pts)"
|
|
206
|
+
puts " #{a.description}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'gemba/version'
|
|
4
|
+
|
|
5
|
+
module Gemba
|
|
6
|
+
class CLI
|
|
7
|
+
module Commands
|
|
8
|
+
class Version
|
|
9
|
+
def initialize(argv, dry_run: false)
|
|
10
|
+
@dry_run = dry_run
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
result = { command: :version, version: Gemba::VERSION }
|
|
15
|
+
return result if @dry_run
|
|
16
|
+
|
|
17
|
+
puts "gemba #{Gemba::VERSION}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|