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,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
module Achievements
|
|
5
|
+
# Abstract interface for achievement backends.
|
|
6
|
+
#
|
|
7
|
+
# All methods have no-op defaults so backends only need to override
|
|
8
|
+
# what they support. Concrete backends: NullBackend, FakeBackend,
|
|
9
|
+
# and the future RetroAchievements backend.
|
|
10
|
+
#
|
|
11
|
+
# Thread safety: do_frame is called from the emulation thread (Tk
|
|
12
|
+
# after-loop). on_unlock and on_auth_change callbacks fire on the
|
|
13
|
+
# same thread.
|
|
14
|
+
#
|
|
15
|
+
# Authentication lifecycle (decoupled from the real network):
|
|
16
|
+
# 1. call login(username:, token:) — may be async
|
|
17
|
+
# 2. on_auth_change callback fires with status :ok or :error
|
|
18
|
+
# 3. authenticated? reflects the current state
|
|
19
|
+
# 4. call logout to clear credentials
|
|
20
|
+
module Backend
|
|
21
|
+
# -- Authentication -------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
# Initiate login with username + password (first-time auth).
|
|
24
|
+
# On success the on_auth_change callback fires with :ok and the
|
|
25
|
+
# returned API token is yielded so the caller can persist it:
|
|
26
|
+
# on_auth_change { |status, token_or_error| ... }
|
|
27
|
+
# NullBackend ignores this call.
|
|
28
|
+
#
|
|
29
|
+
# @param username [String]
|
|
30
|
+
# @param password [String]
|
|
31
|
+
def login_with_password(username:, password:); end
|
|
32
|
+
|
|
33
|
+
# Resume a session using a previously stored API token.
|
|
34
|
+
# Called automatically at startup when credentials are already saved.
|
|
35
|
+
#
|
|
36
|
+
# @param username [String]
|
|
37
|
+
# @param token [String]
|
|
38
|
+
def login_with_token(username:, token:); end
|
|
39
|
+
|
|
40
|
+
# Clear authentication state and stored session.
|
|
41
|
+
def logout; end
|
|
42
|
+
|
|
43
|
+
# @return [Boolean] true when authenticated and ready to serve achievements
|
|
44
|
+
def authenticated?
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Register a callback invoked when authentication state changes.
|
|
49
|
+
# Fired with `status` (:ok or :error) and an optional `error` message.
|
|
50
|
+
#
|
|
51
|
+
# @yield [Symbol, String, nil] status, error (nil on success)
|
|
52
|
+
def on_auth_change(&block)
|
|
53
|
+
@auth_callbacks ||= []
|
|
54
|
+
@auth_callbacks << block
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# -- Game lifecycle -------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
# Called once per emulated frame. Evaluate achievement conditions and
|
|
60
|
+
# fire on_unlock callbacks for any newly earned achievements.
|
|
61
|
+
#
|
|
62
|
+
# @param core [Gemba::Core] the live mGBA core
|
|
63
|
+
def do_frame(core); end
|
|
64
|
+
|
|
65
|
+
# Called when a new ROM is loaded. Backend should reset per-game state
|
|
66
|
+
# and re-identify the game.
|
|
67
|
+
#
|
|
68
|
+
# @param core [Gemba::Core]
|
|
69
|
+
# @param rom_path [String, nil] path to the ROM file (used for MD5 hashing by network backends)
|
|
70
|
+
def load_game(core, rom_path = nil, md5 = nil); end
|
|
71
|
+
|
|
72
|
+
# Called when the ROM is unloaded / emulator stops.
|
|
73
|
+
def unload_game; end
|
|
74
|
+
|
|
75
|
+
# Called when a save state is loaded. Memory jumped to an arbitrary saved
|
|
76
|
+
# state; all achievements must restart their priming/waiting sequence.
|
|
77
|
+
def reset_runtime; end
|
|
78
|
+
|
|
79
|
+
# -- Rich Presence --------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
# Current Rich Presence display string for the active game, or nil if
|
|
82
|
+
# not loaded / not supported. Updated by do_frame in real backends.
|
|
83
|
+
def rich_presence_message
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Enable or disable Rich Presence evaluation for the current game.
|
|
88
|
+
# Pushed from AppController when per-game config is resolved at ROM load.
|
|
89
|
+
def rich_presence_enabled=(val); end
|
|
90
|
+
|
|
91
|
+
# Register a callback fired when the Rich Presence string changes.
|
|
92
|
+
# Called with the new message string.
|
|
93
|
+
#
|
|
94
|
+
# @yield [String] the new rich presence message
|
|
95
|
+
def on_rich_presence_changed(&block)
|
|
96
|
+
@rp_callbacks ||= []
|
|
97
|
+
@rp_callbacks << block
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# -- Achievement list -----------------------------------------------------
|
|
101
|
+
|
|
102
|
+
# Register a callback invoked when an achievement is unlocked.
|
|
103
|
+
# Multiple callbacks can be registered; all are called in order.
|
|
104
|
+
#
|
|
105
|
+
# @yield [Achievement] the newly earned achievement
|
|
106
|
+
def on_unlock(&block)
|
|
107
|
+
@unlock_callbacks ||= []
|
|
108
|
+
@unlock_callbacks << block
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# @return [Array<Achievement>] all achievements for the current game
|
|
112
|
+
def achievement_list
|
|
113
|
+
[]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @return [Integer] number of earned achievements
|
|
117
|
+
def earned_count
|
|
118
|
+
achievement_list.count(&:earned?)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [Integer] total achievements for the current game
|
|
122
|
+
def total_count
|
|
123
|
+
achievement_list.size
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Verify the stored token is still valid. Result fires on_auth_change.
|
|
127
|
+
# Used by the "Verify Token" button in settings.
|
|
128
|
+
def token_test; end
|
|
129
|
+
|
|
130
|
+
# @return [Boolean] true if this backend is active / enabled
|
|
131
|
+
def enabled?
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Fetch already-earned achievements from the server and merge them into
|
|
136
|
+
# the local earned state so the UI reflects prior progress. No-op for
|
|
137
|
+
# backends that have no server. Fires on_achievements_changed on
|
|
138
|
+
# completion.
|
|
139
|
+
def sync_unlocks; end
|
|
140
|
+
|
|
141
|
+
# Fetch the full achievement list for a given ROM (by RomInfo) purely for
|
|
142
|
+
# display — does not affect live game state. Calls the block with
|
|
143
|
+
# Array<Achievement> on success, or nil on failure/unsupported.
|
|
144
|
+
# No-op (calls block with nil) for backends without a server.
|
|
145
|
+
#
|
|
146
|
+
# @param rom_info [RomInfo]
|
|
147
|
+
# @yield [Array<Achievement>, nil]
|
|
148
|
+
def fetch_for_display(rom_info:, &callback)
|
|
149
|
+
callback&.call(nil)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Set whether unofficial (Flags=5) achievements are included in
|
|
153
|
+
# display syncs and live evaluation. No-op for backends that don't
|
|
154
|
+
# distinguish official vs unofficial.
|
|
155
|
+
def include_unofficial=(val); end
|
|
156
|
+
|
|
157
|
+
# Register a callback invoked when the achievement list changes in bulk
|
|
158
|
+
# (e.g. after a game loads or sync_unlocks completes). Use this to
|
|
159
|
+
# refresh list UI without wiring individual on_unlock callbacks.
|
|
160
|
+
#
|
|
161
|
+
# @yield called with no arguments
|
|
162
|
+
def on_achievements_changed(&block)
|
|
163
|
+
@achievements_changed_callbacks ||= []
|
|
164
|
+
@achievements_changed_callbacks << block
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private
|
|
168
|
+
|
|
169
|
+
def fire_unlock(achievement)
|
|
170
|
+
@unlock_callbacks&.each { |cb| cb.call(achievement) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def fire_auth_change(status, error = nil)
|
|
174
|
+
@auth_callbacks&.each { |cb| cb.call(status, error) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def fire_achievements_changed
|
|
178
|
+
@achievements_changed_callbacks&.each(&:call)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def fire_rich_presence_changed(message)
|
|
182
|
+
@rp_callbacks&.each { |cb| cb.call(message) }
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module Gemba
|
|
8
|
+
module Achievements
|
|
9
|
+
# Persistent per-ROM achievement cache.
|
|
10
|
+
#
|
|
11
|
+
# Stores the full achievement list (definitions + earned status) for each
|
|
12
|
+
# ROM as a JSON file under Config.achievements_cache_dir/<rom_id>.json.
|
|
13
|
+
# Written after every successful sync; read on demand by the window when
|
|
14
|
+
# a game is selected that isn't currently loaded in the emulator.
|
|
15
|
+
#
|
|
16
|
+
# Format:
|
|
17
|
+
# { "synced_at": "<iso8601>",
|
|
18
|
+
# "achievements": [ { "id":, "title":, "description":, "points":,
|
|
19
|
+
# "earned_at": "<iso8601>|null" }, … ] }
|
|
20
|
+
module Cache
|
|
21
|
+
def self.write(rom_id, achievements)
|
|
22
|
+
path = cache_path(rom_id)
|
|
23
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
24
|
+
data = {
|
|
25
|
+
'synced_at' => Time.now.utc.iso8601,
|
|
26
|
+
'achievements' => achievements.map { |a|
|
|
27
|
+
{
|
|
28
|
+
'id' => a.id,
|
|
29
|
+
'title' => a.title,
|
|
30
|
+
'description' => a.description,
|
|
31
|
+
'points' => a.points,
|
|
32
|
+
'earned_at' => a.earned_at&.utc&.iso8601,
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
File.write(path, JSON.generate(data))
|
|
37
|
+
Gemba.log(:info) { "Achievements cache written: #{rom_id} (#{achievements.size} achievements)" }
|
|
38
|
+
rescue => e
|
|
39
|
+
Gemba.log(:warn) { "Achievements cache write failed for #{rom_id}: #{e.message}" }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Array<Achievement>, nil] cached list, or nil if no cache exists
|
|
43
|
+
def self.read(rom_id)
|
|
44
|
+
path = cache_path(rom_id)
|
|
45
|
+
return nil unless File.exist?(path)
|
|
46
|
+
|
|
47
|
+
data = JSON.parse(File.read(path))
|
|
48
|
+
list = (data['achievements'] || []).map do |a|
|
|
49
|
+
Achievement.new(
|
|
50
|
+
id: a['id'].to_s,
|
|
51
|
+
title: a['title'].to_s,
|
|
52
|
+
description: a['description'].to_s,
|
|
53
|
+
points: a['points'].to_i,
|
|
54
|
+
earned_at: a['earned_at'] ? Time.iso8601(a['earned_at']) : nil,
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
Gemba.log(:info) { "Achievements cache read: #{rom_id} (#{list.size} achievements, synced #{data['synced_at']})" }
|
|
58
|
+
list
|
|
59
|
+
rescue => e
|
|
60
|
+
Gemba.log(:warn) { "Achievements cache read failed for #{rom_id}: #{e.message}" }
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.cache_path(rom_id)
|
|
65
|
+
File.join(Config.achievements_cache_dir, "#{rom_id}.json")
|
|
66
|
+
end
|
|
67
|
+
private_class_method :cache_path
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
module Achievements
|
|
5
|
+
# Presents credential state for the RetroAchievements settings UI.
|
|
6
|
+
# Read-only view of state — never writes to disk or config.
|
|
7
|
+
#
|
|
8
|
+
# Initialized from persisted config. Mutated by:
|
|
9
|
+
# - UI interactions (checkbox, keystrokes) via setters
|
|
10
|
+
# - Backend auth results via :ra_auth_result bus events
|
|
11
|
+
#
|
|
12
|
+
# Emits :credentials_changed on the bus whenever state changes.
|
|
13
|
+
# SystemTab listens and calls apply_presenter_state to refresh widgets.
|
|
14
|
+
#
|
|
15
|
+
# Call dispose when discarding the presenter to remove the bus subscription.
|
|
16
|
+
class CredentialsPresenter
|
|
17
|
+
include BusEmitter
|
|
18
|
+
|
|
19
|
+
def initialize(config)
|
|
20
|
+
@enabled = config.ra_enabled?
|
|
21
|
+
@username = config.ra_username.to_s
|
|
22
|
+
@token = config.ra_token.to_s
|
|
23
|
+
@password = ''
|
|
24
|
+
@feedback_override = nil
|
|
25
|
+
|
|
26
|
+
@auth_handler = ->(status:, token: nil, message: nil) {
|
|
27
|
+
handle_auth_result(status, token, message)
|
|
28
|
+
}
|
|
29
|
+
Gemba.bus.on(:ra_auth_result, &@auth_handler)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Remove bus subscription. Call before discarding the presenter.
|
|
33
|
+
def dispose
|
|
34
|
+
Gemba.bus.off(:ra_auth_result, @auth_handler)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# -- UI mutations ---------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
def enabled=(val)
|
|
40
|
+
@enabled = val ? true : false
|
|
41
|
+
emit(:credentials_changed)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def username=(val)
|
|
45
|
+
@username = val.to_s
|
|
46
|
+
emit(:credentials_changed)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def password=(val)
|
|
50
|
+
@password = val.to_s
|
|
51
|
+
emit(:credentials_changed)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Transient feedback (e.g. "Connection OK ✓") that disappears after a delay.
|
|
55
|
+
# Caller is responsible for scheduling clear_transient via Tk after.
|
|
56
|
+
def show_transient(key, **kwargs)
|
|
57
|
+
@feedback_override = { key: key, **kwargs }
|
|
58
|
+
emit(:credentials_changed)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def clear_transient
|
|
62
|
+
@feedback_override = nil
|
|
63
|
+
emit(:credentials_changed)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# -- Read-only accessors --------------------------------------------------
|
|
67
|
+
|
|
68
|
+
attr_reader :username, :password, :token
|
|
69
|
+
def enabled? = @enabled
|
|
70
|
+
def logged_in? = !@token.strip.empty?
|
|
71
|
+
|
|
72
|
+
# -- Widget state queries -------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def fields_state
|
|
75
|
+
return :disabled unless @enabled
|
|
76
|
+
return :readonly if logged_in?
|
|
77
|
+
:normal
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def login_button_state
|
|
81
|
+
return :disabled unless @enabled
|
|
82
|
+
return :disabled if logged_in?
|
|
83
|
+
fields_filled? ? :normal : :disabled
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def verify_button_state
|
|
87
|
+
(@enabled && logged_in?) ? :normal : :disabled
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def logout_button_state
|
|
91
|
+
(@enabled && logged_in?) ? :normal : :disabled
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reset_button_state
|
|
95
|
+
(@enabled && logged_in?) ? :normal : :disabled
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Feedback descriptor. key drives locale lookup in SystemTab.
|
|
99
|
+
# :empty → blank label
|
|
100
|
+
# :not_logged_in → "Not logged in"
|
|
101
|
+
# :logged_in_as → "Logged in as {username}" (also carries username:)
|
|
102
|
+
# :test_ok → "Connection OK ✓"
|
|
103
|
+
# :error → error text (carries message:)
|
|
104
|
+
def feedback
|
|
105
|
+
return @feedback_override if @feedback_override
|
|
106
|
+
return { key: :empty } unless @enabled
|
|
107
|
+
return { key: :logged_in_as, username: @username } if logged_in?
|
|
108
|
+
{ key: :not_logged_in }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def handle_auth_result(status, token, message)
|
|
114
|
+
case status
|
|
115
|
+
when :ok
|
|
116
|
+
if token
|
|
117
|
+
# Login success — store token, clear password
|
|
118
|
+
@token = token.to_s
|
|
119
|
+
@password = ''
|
|
120
|
+
@feedback_override = nil
|
|
121
|
+
else
|
|
122
|
+
# Ping success — show transient then let SystemTab schedule clear
|
|
123
|
+
@feedback_override = { key: :test_ok }
|
|
124
|
+
emit(:ra_token_test_ok)
|
|
125
|
+
end
|
|
126
|
+
when :error
|
|
127
|
+
@feedback_override = { key: :error, message: message.to_s }
|
|
128
|
+
when :logout
|
|
129
|
+
@token = ''
|
|
130
|
+
@password = ''
|
|
131
|
+
@feedback_override = nil
|
|
132
|
+
# username intentionally kept so user can re-enter password quickly
|
|
133
|
+
end
|
|
134
|
+
emit(:credentials_changed)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def fields_filled?
|
|
138
|
+
!@username.strip.empty? && !@password.strip.empty?
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
module Achievements
|
|
5
|
+
# Pure-Ruby achievement backend for development and testing.
|
|
6
|
+
#
|
|
7
|
+
# Achievements are defined programmatically with a condition block
|
|
8
|
+
# that receives a memory-read helper. No HTTP, no hashing, no server.
|
|
9
|
+
#
|
|
10
|
+
# Authentication behaviour:
|
|
11
|
+
# - By default any non-empty credentials succeed immediately.
|
|
12
|
+
# - Pass `valid_username:` and `valid_token:` to restrict: only that
|
|
13
|
+
# exact pair succeeds; anything else fails. Useful in tests that
|
|
14
|
+
# verify the "bad credentials" error path.
|
|
15
|
+
#
|
|
16
|
+
# Used in two ways:
|
|
17
|
+
# 1. Automated tests — add achievements, step frames, assert unlocks
|
|
18
|
+
# 2. Integration dev — iterate on UI (toasts, list) without rcheevos
|
|
19
|
+
#
|
|
20
|
+
# @example Basic usage
|
|
21
|
+
# backend = FakeBackend.new
|
|
22
|
+
# backend.add_achievement(id: 'btn_b', title: 'Press B',
|
|
23
|
+
# description: 'Press the B button',
|
|
24
|
+
# points: 5) do |mem|
|
|
25
|
+
# mem.call(0x02000000) == 0x42
|
|
26
|
+
# end
|
|
27
|
+
# backend.on_unlock { |ach| puts "Unlocked: #{ach.title}" }
|
|
28
|
+
# backend.do_frame(core) # call each frame
|
|
29
|
+
#
|
|
30
|
+
# @example Restricted credentials (for testing failure path)
|
|
31
|
+
# backend = FakeBackend.new(valid_username: 'alice', valid_token: 'secret')
|
|
32
|
+
# backend.login(username: 'bob', token: 'wrong') # → fires :error
|
|
33
|
+
# backend.login(username: 'alice', token: 'secret') # → fires :ok
|
|
34
|
+
class FakeBackend
|
|
35
|
+
include Backend
|
|
36
|
+
|
|
37
|
+
# @param valid_username [String, nil] when set, only this username passes
|
|
38
|
+
# @param valid_token [String, nil] when set, only this token passes
|
|
39
|
+
def initialize(valid_username: nil, valid_token: nil)
|
|
40
|
+
@definitions = {} # id → { achievement:, condition: }
|
|
41
|
+
@earned = {} # id → Achievement (earned copy)
|
|
42
|
+
@prev_state = {} # id → bool (condition result last frame)
|
|
43
|
+
@valid_username = valid_username
|
|
44
|
+
@valid_token = valid_token
|
|
45
|
+
@authenticated = false
|
|
46
|
+
@rich_presence_message = nil
|
|
47
|
+
@rich_presence_block = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# -- Authentication -------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
# Resolves immediately. Succeeds if credentials are non-empty and match
|
|
53
|
+
# the configured valid pair (or any non-empty creds if none configured).
|
|
54
|
+
# On success fires on_auth_change(:ok, fake_token) where fake_token is
|
|
55
|
+
# a deterministic stand-in so callers can exercise the token-persist path.
|
|
56
|
+
def login_with_password(username:, password:)
|
|
57
|
+
ok = !username.to_s.empty? && !password.to_s.empty? &&
|
|
58
|
+
(@valid_username.nil? || username == @valid_username) &&
|
|
59
|
+
(@valid_token.nil? || password == @valid_token)
|
|
60
|
+
|
|
61
|
+
if ok
|
|
62
|
+
@authenticated = true
|
|
63
|
+
fire_auth_change(:ok, "fake_token_for_#{username}")
|
|
64
|
+
else
|
|
65
|
+
@authenticated = false
|
|
66
|
+
fire_auth_change(:error, 'Invalid credentials (fake backend)')
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def login_with_token(username:, token:)
|
|
71
|
+
ok = !username.to_s.empty? && !token.to_s.empty? &&
|
|
72
|
+
(@valid_username.nil? || username == @valid_username) &&
|
|
73
|
+
(@valid_token.nil? || token == @valid_token)
|
|
74
|
+
|
|
75
|
+
if ok
|
|
76
|
+
@authenticated = true
|
|
77
|
+
fire_auth_change(:ok, token)
|
|
78
|
+
else
|
|
79
|
+
@authenticated = false
|
|
80
|
+
fire_auth_change(:error, 'Invalid credentials (fake backend)')
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def logout
|
|
85
|
+
@authenticated = false
|
|
86
|
+
fire_auth_change(:logout)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def token_test
|
|
90
|
+
if @authenticated
|
|
91
|
+
fire_auth_change(:ok, nil)
|
|
92
|
+
else
|
|
93
|
+
fire_auth_change(:error, 'Not authenticated (fake backend)')
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def authenticated?
|
|
98
|
+
@authenticated
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Define an achievement. The block receives a read_mem callable
|
|
102
|
+
# and must return truthy when the unlock condition is met.
|
|
103
|
+
#
|
|
104
|
+
# @param id [String] unique identifier
|
|
105
|
+
# @param title [String]
|
|
106
|
+
# @param description [String]
|
|
107
|
+
# @param points [Integer]
|
|
108
|
+
# @yield [read_mem] called each frame; read_mem is ->(address) { Integer }
|
|
109
|
+
# @yieldreturn [Boolean] true when condition is satisfied
|
|
110
|
+
def add_achievement(id:, title:, description:, points: 0, &condition)
|
|
111
|
+
raise ArgumentError, "condition block required" unless condition
|
|
112
|
+
ach = Achievement.new(id: id, title: title,
|
|
113
|
+
description: description,
|
|
114
|
+
points: points, earned_at: nil)
|
|
115
|
+
@definitions[id] = { achievement: ach, condition: condition }
|
|
116
|
+
@prev_state[id] = false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Evaluate all unearned achievements against current memory state.
|
|
120
|
+
# Fires on_unlock callbacks for newly met conditions (rising edge).
|
|
121
|
+
# Also evaluates the rich presence block if one is set.
|
|
122
|
+
#
|
|
123
|
+
# @param core [Gemba::Core]
|
|
124
|
+
def do_frame(core)
|
|
125
|
+
read_mem = ->(addr) { core.bus_read8(addr) }
|
|
126
|
+
@definitions.each do |id, defn|
|
|
127
|
+
next if @earned.key?(id)
|
|
128
|
+
|
|
129
|
+
current = defn[:condition].call(read_mem) ? true : false
|
|
130
|
+
if current && !@prev_state[id]
|
|
131
|
+
earned = defn[:achievement].earn
|
|
132
|
+
@earned[id] = earned
|
|
133
|
+
fire_unlock(earned)
|
|
134
|
+
end
|
|
135
|
+
@prev_state[id] = current
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
@rich_presence_message = @rich_presence_block.call if @rich_presence_block
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def load_game(_core, rom_path = nil, md5 = nil)
|
|
142
|
+
@rich_presence_message = nil
|
|
143
|
+
@rich_presence_block = nil
|
|
144
|
+
reset_earned
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def unload_game
|
|
148
|
+
reset_earned
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# @return [Array<Achievement>] all achievements, earned ones updated
|
|
152
|
+
def achievement_list
|
|
153
|
+
@definitions.map do |id, defn|
|
|
154
|
+
@earned[id] || defn[:achievement]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def enabled?
|
|
159
|
+
true
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Clear earned state (for test reuse).
|
|
163
|
+
def reset_earned
|
|
164
|
+
@earned = {}
|
|
165
|
+
@prev_state = @prev_state.transform_values { false }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Set a Rich Presence message for testing.
|
|
169
|
+
#
|
|
170
|
+
# Pass a static string to return a fixed message:
|
|
171
|
+
# backend.set_rich_presence("Playing World 1-1")
|
|
172
|
+
#
|
|
173
|
+
# Pass a block for dynamic messages evaluated each do_frame:
|
|
174
|
+
# backend.set_rich_presence { "Frame #{frame_count}" }
|
|
175
|
+
#
|
|
176
|
+
# Call with no arguments to clear.
|
|
177
|
+
def set_rich_presence(string = nil, &block)
|
|
178
|
+
@rich_presence_block = block || (string ? -> { string } : nil)
|
|
179
|
+
@rich_presence_message = string unless block
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def rich_presence_message
|
|
183
|
+
@rich_presence_message
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Configure what fetch_for_display returns. Pass an Array (returned for
|
|
187
|
+
# every rom_info) or a block that receives rom_info and returns an Array
|
|
188
|
+
# or nil. Without calling this, fetch_for_display always calls back with nil.
|
|
189
|
+
#
|
|
190
|
+
# @example Always return a fixed list
|
|
191
|
+
# backend.stub_fetch_for_display([ach1, ach2])
|
|
192
|
+
#
|
|
193
|
+
# @example Vary by rom_info
|
|
194
|
+
# backend.stub_fetch_for_display { |rom_info| rom_info.rom_id == 'X' ? [ach1] : [] }
|
|
195
|
+
def stub_fetch_for_display(list = nil, &block)
|
|
196
|
+
@fetch_display_stub = block || ->(_) { list }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def fetch_for_display(rom_info:, &callback)
|
|
200
|
+
result = @fetch_display_stub ? @fetch_display_stub.call(rom_info) : nil
|
|
201
|
+
callback&.call(result)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|