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,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
module Gemba
|
|
5
|
+
# Publish/subscribe event bus for decoupled communication.
|
|
6
|
+
#
|
|
7
|
+
# Emitters fire named events, subscribers listen — no intermediaries.
|
|
8
|
+
#
|
|
9
|
+
# Lives at Gemba.bus (module-level). Player creates it at startup;
|
|
10
|
+
# any class does Gemba.bus.emit / Gemba.bus.on. For tests, replace
|
|
11
|
+
# with Gemba.bus = EventBus.new (or a mock).
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# Gemba.bus.on(:scale_changed) { |val| apply_scale(val) }
|
|
15
|
+
# Gemba.bus.emit(:scale_changed, 3)
|
|
16
|
+
class EventBus
|
|
17
|
+
def initialize
|
|
18
|
+
@listeners = Hash.new { |h, k| h[k] = [] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Subscribe to a named event.
|
|
22
|
+
# @param event [Symbol]
|
|
23
|
+
# @return [Proc] the block (for later #off)
|
|
24
|
+
def on(event, &block)
|
|
25
|
+
@listeners[event] << block
|
|
26
|
+
block
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Emit a named event to all subscribers.
|
|
30
|
+
# @param event [Symbol]
|
|
31
|
+
def emit(event, *args, **kwargs)
|
|
32
|
+
Gemba.log(:debug) { "bus: #{event}(#{[*args, *kwargs.map { |k,v| "#{k}: #{v}" }].join(', ')})" }
|
|
33
|
+
if kwargs.empty?
|
|
34
|
+
@listeners[event].each { |cb| cb.call(*args) }
|
|
35
|
+
else
|
|
36
|
+
@listeners[event].each { |cb| cb.call(*args, **kwargs) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Unsubscribe a specific block.
|
|
41
|
+
# @param event [Symbol]
|
|
42
|
+
# @param block [Proc] the block returned by #on
|
|
43
|
+
def off(event, block)
|
|
44
|
+
@listeners[event].delete(block)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# Push/pop stack for content frames inside the main window.
|
|
5
|
+
#
|
|
6
|
+
# Mirrors the ModalStack pattern. When a new frame is pushed, the
|
|
7
|
+
# previous frame is hidden and the new one is shown. Popping reverses
|
|
8
|
+
# the transition.
|
|
9
|
+
#
|
|
10
|
+
# Frames must implement the FrameStack protocol:
|
|
11
|
+
# show — pack/display the frame
|
|
12
|
+
# hide — unpack/remove the frame from view
|
|
13
|
+
# cleanup — release resources (SDL2, etc.)
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# stack = FrameStack.new
|
|
17
|
+
# stack.push(:picker, game_picker_frame)
|
|
18
|
+
# stack.push(:emulator, emulator_frame) # picker auto-hidden
|
|
19
|
+
# stack.pop # emulator hidden, picker re-shown
|
|
20
|
+
class FrameStack
|
|
21
|
+
Entry = Data.define(:name, :frame)
|
|
22
|
+
|
|
23
|
+
def initialize
|
|
24
|
+
@stack = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Boolean] true if any frame is on the stack
|
|
28
|
+
def active? = !@stack.empty?
|
|
29
|
+
|
|
30
|
+
# @return [Symbol, nil] name of the topmost frame
|
|
31
|
+
def current = @stack.last&.name
|
|
32
|
+
|
|
33
|
+
# @return [Object, nil] the topmost frame object
|
|
34
|
+
def current_frame = @stack.last&.frame
|
|
35
|
+
|
|
36
|
+
# @return [Integer] number of frames on the stack
|
|
37
|
+
def size = @stack.length
|
|
38
|
+
|
|
39
|
+
# Push a frame onto the stack.
|
|
40
|
+
#
|
|
41
|
+
# The previous frame (if any) is hidden before the new one is shown.
|
|
42
|
+
#
|
|
43
|
+
# @param name [Symbol] identifier (e.g. :picker, :emulator)
|
|
44
|
+
# @param frame [#show, #hide] the frame object
|
|
45
|
+
def push(name, frame)
|
|
46
|
+
@stack.last&.frame&.hide
|
|
47
|
+
@stack.push(Entry.new(name: name, frame: frame))
|
|
48
|
+
frame.show
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Pop the current frame off the stack.
|
|
52
|
+
#
|
|
53
|
+
# The popped frame is hidden. If there's a previous frame, it is re-shown.
|
|
54
|
+
def pop
|
|
55
|
+
return unless (entry = @stack.pop)
|
|
56
|
+
entry.frame.hide
|
|
57
|
+
@stack.last&.frame&.show
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Gemba
|
|
6
|
+
# Lookup table mapping ROM serial codes to canonical game names.
|
|
7
|
+
#
|
|
8
|
+
# Data is pre-baked from No-Intro DAT files via script/bake_game_index.rb
|
|
9
|
+
# and stored as JSON in lib/gemba/data/{platform}_games.json.
|
|
10
|
+
#
|
|
11
|
+
# Loaded lazily on first lookup per platform.
|
|
12
|
+
#
|
|
13
|
+
# GameIndex.lookup("AGB-AXVE") # => "Pokemon - Ruby Version (USA)"
|
|
14
|
+
# GameIndex.lookup("CGB-BYTE") # => nil (unknown)
|
|
15
|
+
#
|
|
16
|
+
class GameIndex
|
|
17
|
+
DATA_DIR = File.expand_path("data", __dir__)
|
|
18
|
+
|
|
19
|
+
PLATFORM_FILES = {
|
|
20
|
+
"AGB" => "gba_games.json",
|
|
21
|
+
"CGB" => "gbc_games.json",
|
|
22
|
+
"DMG" => "gb_games.json",
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
MD5_FILES = {
|
|
26
|
+
"AGB" => "gba_md5.json",
|
|
27
|
+
"CGB" => "gbc_md5.json",
|
|
28
|
+
"DMG" => "gb_md5.json",
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Maps RomLibrary platform short names → GameIndex prefixes
|
|
32
|
+
PLATFORM_PREFIX = { "gba" => "AGB", "gbc" => "CGB", "gb" => "DMG" }.freeze
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# Look up a canonical game name by serial code.
|
|
36
|
+
# @param game_code [String] e.g. "AGB-AXVE", "CGB-BYTE", "DMG-XXXX"
|
|
37
|
+
# @return [String, nil] canonical name or nil if not found
|
|
38
|
+
def lookup(game_code)
|
|
39
|
+
return nil unless game_code && !game_code.empty?
|
|
40
|
+
|
|
41
|
+
platform = game_code.split("-", 2).first
|
|
42
|
+
index = index_for(platform, PLATFORM_FILES)
|
|
43
|
+
return nil unless index
|
|
44
|
+
|
|
45
|
+
index[game_code]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Look up a canonical game name by MD5 hex digest.
|
|
49
|
+
# @param md5 [String] hex MD5 of ROM content (any case)
|
|
50
|
+
# @param platform [String] short name from RomLibrary — "gba", "gbc", or "gb"
|
|
51
|
+
# @return [String, nil]
|
|
52
|
+
def lookup_by_md5(md5, platform)
|
|
53
|
+
return nil unless md5 && !md5.empty?
|
|
54
|
+
|
|
55
|
+
prefix = PLATFORM_PREFIX[platform.to_s.downcase]
|
|
56
|
+
return nil unless prefix
|
|
57
|
+
|
|
58
|
+
idx = index_for(prefix, MD5_FILES)
|
|
59
|
+
idx&.[](md5.downcase)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Force-reload all indexes (useful after re-baking).
|
|
63
|
+
def reset!
|
|
64
|
+
@indexes = {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def index_for(platform, files)
|
|
70
|
+
@indexes ||= {}
|
|
71
|
+
key = "#{platform}:#{files.object_id}"
|
|
72
|
+
return @indexes[key] if @indexes.key?(key)
|
|
73
|
+
|
|
74
|
+
file = files[platform]
|
|
75
|
+
return(@indexes[key] = nil) unless file
|
|
76
|
+
|
|
77
|
+
path = File.join(DATA_DIR, file)
|
|
78
|
+
return(@indexes[key] = nil) unless File.exist?(path)
|
|
79
|
+
|
|
80
|
+
@indexes[key] = JSON.parse(File.read(path))
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
module Gemba
|
|
5
|
+
# Startup frame showing a 4×4 grid of ROM cards.
|
|
6
|
+
#
|
|
7
|
+
# Each card displays box art (if available), ROM title, and platform.
|
|
8
|
+
# Clicking a populated card emits :rom_selected on the bus.
|
|
9
|
+
# Right-clicking a populated card shows a context menu (Play / Set Boxart).
|
|
10
|
+
# Pure Tk — no SDL2.
|
|
11
|
+
class GamePickerFrame
|
|
12
|
+
include BusEmitter
|
|
13
|
+
include Locale::Translatable
|
|
14
|
+
|
|
15
|
+
COLS = 4
|
|
16
|
+
ROWS = 4
|
|
17
|
+
SLOTS = COLS * ROWS
|
|
18
|
+
IMG_SUBSAMPLE = 4 # 512px ÷ 4 = 128px per card
|
|
19
|
+
IMG_SIZE = 128 # height/width of the scaled image in pixels
|
|
20
|
+
PLACEHOLDER_PNG = File.expand_path("../../assets/placeholder_boxart.png", __dir__)
|
|
21
|
+
|
|
22
|
+
# Aspect ratio for wm aspect lock when picker is visible (width:height).
|
|
23
|
+
# 3:4 gives enough vertical room for image + title + platform label.
|
|
24
|
+
PICKER_ASPECT_W = 3
|
|
25
|
+
PICKER_ASPECT_H = 4
|
|
26
|
+
|
|
27
|
+
# Default and minimum picker window dimensions (must satisfy PICKER_ASPECT ratio)
|
|
28
|
+
PICKER_DEFAULT_W = 768
|
|
29
|
+
PICKER_DEFAULT_H = 1024 # 768 * 4/3
|
|
30
|
+
PICKER_MIN_W = 576
|
|
31
|
+
PICKER_MIN_H = 768
|
|
32
|
+
|
|
33
|
+
def initialize(app:, rom_library:, boxart_fetcher: nil, rom_overrides: nil)
|
|
34
|
+
@app = app
|
|
35
|
+
@rom_library = rom_library
|
|
36
|
+
@fetcher = boxart_fetcher
|
|
37
|
+
@overrides = rom_overrides
|
|
38
|
+
@built = false
|
|
39
|
+
@cards = {} # index => { frame:, image:, title:, platform:, photo: }
|
|
40
|
+
@photos = {} # key => Tk image name (kept alive to prevent GC)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def show
|
|
44
|
+
build_ui unless @built
|
|
45
|
+
refresh
|
|
46
|
+
@app.command(:pack, @grid, fill: :both, expand: 1)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def hide
|
|
50
|
+
@app.command(:pack, :forget, @grid) rescue nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def cleanup
|
|
54
|
+
@photos&.each_value { |name| @app.command(:image, :delete, name) rescue nil }
|
|
55
|
+
@photos&.clear
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def receive(event, **args)
|
|
59
|
+
case event
|
|
60
|
+
when :refresh then refresh
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def aspect_ratio = [PICKER_ASPECT_W, PICKER_ASPECT_H]
|
|
65
|
+
def rom_loaded? = false
|
|
66
|
+
def sdl2_ready? = false
|
|
67
|
+
def paused? = false
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def build_ui
|
|
72
|
+
@grid = '.game_picker'
|
|
73
|
+
@app.command('ttk::frame', @grid, padding: 16)
|
|
74
|
+
|
|
75
|
+
# Capture the system window background color so hollow cards blend in
|
|
76
|
+
# rather than appearing as stark black rectangles.
|
|
77
|
+
@empty_bg = @app.tcl_eval(". cget -background")
|
|
78
|
+
|
|
79
|
+
# Load a transparent 128×128 placeholder once — gives all image labels
|
|
80
|
+
# a fixed pixel size whether or not box art has been fetched yet.
|
|
81
|
+
@app.command(:image, :create, :photo, 'boxart_placeholder', file: PLACEHOLDER_PNG)
|
|
82
|
+
|
|
83
|
+
SLOTS.times do |i|
|
|
84
|
+
row = i / COLS
|
|
85
|
+
col = i % COLS
|
|
86
|
+
|
|
87
|
+
cell = "#{@grid}.card#{i}"
|
|
88
|
+
@app.command(:frame, cell, relief: :groove, borderwidth: 2,
|
|
89
|
+
padx: 4, pady: 4, bg: '#2a2a2a')
|
|
90
|
+
@app.command(:grid, cell, row: row, column: col, padx: 6, pady: 6, sticky: :nsew)
|
|
91
|
+
|
|
92
|
+
img_lbl = "#{cell}.img"
|
|
93
|
+
@app.command(:label, img_lbl, bg: '#2a2a2a', anchor: :center, image: 'boxart_placeholder')
|
|
94
|
+
@app.command(:pack, img_lbl, fill: :x)
|
|
95
|
+
|
|
96
|
+
title_lbl = "#{cell}.title"
|
|
97
|
+
@app.command(:label, title_lbl, text: '', anchor: :center,
|
|
98
|
+
bg: '#2a2a2a', fg: '#cccccc',
|
|
99
|
+
font: '{TkDefaultFont} 10',
|
|
100
|
+
justify: :center, wraplength: IMG_SIZE)
|
|
101
|
+
@app.command(:bind, title_lbl, '<Configure>', proc {
|
|
102
|
+
w = @app.tcl_eval("winfo width #{title_lbl}").to_i
|
|
103
|
+
@app.command(title_lbl, :configure, wraplength: w - 8) if w > 8
|
|
104
|
+
})
|
|
105
|
+
@app.command(:pack, title_lbl, fill: :x, pady: [4, 2])
|
|
106
|
+
|
|
107
|
+
plat_lbl = "#{cell}.plat"
|
|
108
|
+
@app.command(:label, plat_lbl, text: '', anchor: :center,
|
|
109
|
+
bg: '#2a2a2a', fg: '#888888',
|
|
110
|
+
font: '{TkDefaultFont} 8')
|
|
111
|
+
@app.command(:pack, plat_lbl, fill: :x, pady: [0, 4])
|
|
112
|
+
|
|
113
|
+
@cards[i] = { frame: cell, image: img_lbl, title: title_lbl, platform: plat_lbl }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Make columns and rows expand evenly
|
|
117
|
+
COLS.times { |c| @app.command(:grid, :columnconfigure, @grid, c, weight: 1) }
|
|
118
|
+
ROWS.times { |r| @app.command(:grid, :rowconfigure, @grid, r, weight: 1) }
|
|
119
|
+
|
|
120
|
+
@built = true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def refresh
|
|
124
|
+
roms = @rom_library.all.first(SLOTS)
|
|
125
|
+
|
|
126
|
+
SLOTS.times do |i|
|
|
127
|
+
card = @cards[i]
|
|
128
|
+
rom = roms[i]
|
|
129
|
+
|
|
130
|
+
if rom
|
|
131
|
+
rom_info = RomInfo.from_rom(rom, fetcher: @fetcher, overrides: @overrides)
|
|
132
|
+
populate_card(card, rom_info)
|
|
133
|
+
else
|
|
134
|
+
hollow_card(card)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def populate_card(card, rom_info)
|
|
140
|
+
@app.command(card[:image], :configure, bg: '#2a2a2a')
|
|
141
|
+
@app.command(card[:title], :configure, text: rom_info.title, fg: '#cccccc', bg: '#2a2a2a')
|
|
142
|
+
@app.command(card[:platform], :configure, text: rom_info.platform, fg: '#888888', bg: '#2a2a2a')
|
|
143
|
+
@app.command(card[:frame], :configure, relief: :groove, bg: '#2a2a2a')
|
|
144
|
+
|
|
145
|
+
# Determine which image to show
|
|
146
|
+
key = rom_info.rom_id || rom_info.game_code
|
|
147
|
+
|
|
148
|
+
if rom_info.boxart_path
|
|
149
|
+
# Custom override or cached art — load immediately
|
|
150
|
+
if @photos.key?(key)
|
|
151
|
+
@app.command(card[:image], :configure, image: @photos[key])
|
|
152
|
+
else
|
|
153
|
+
set_card_image(card, key, rom_info.boxart_path)
|
|
154
|
+
end
|
|
155
|
+
elsif rom_info.has_official_entry && @fetcher && rom_info.game_code
|
|
156
|
+
# No art yet but libretro has an entry — kick off async fetch
|
|
157
|
+
@app.command(card[:image], :configure, image: 'boxart_placeholder')
|
|
158
|
+
@fetcher.fetch(rom_info.game_code) { |path| set_card_image(card, key, path) }
|
|
159
|
+
else
|
|
160
|
+
@app.command(card[:image], :configure, image: 'boxart_placeholder')
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Left-click → play
|
|
164
|
+
click = proc { emit(:rom_selected, rom_info.path) }
|
|
165
|
+
@app.command(:bind, card[:frame], '<Button-1>', click)
|
|
166
|
+
@app.command(:bind, card[:image], '<Button-1>', click)
|
|
167
|
+
@app.command(:bind, card[:title], '<Button-1>', click)
|
|
168
|
+
@app.command(:bind, card[:platform], '<Button-1>', click)
|
|
169
|
+
|
|
170
|
+
# Right-click → context menu
|
|
171
|
+
bind_context_menu(card, rom_info)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def hollow_card(card)
|
|
175
|
+
@app.command(card[:image], :configure, image: 'boxart_placeholder', bg: @empty_bg)
|
|
176
|
+
@app.command(card[:title], :configure, text: '', fg: @empty_bg, bg: @empty_bg)
|
|
177
|
+
@app.command(card[:platform], :configure, text: '', bg: @empty_bg)
|
|
178
|
+
@app.command(card[:frame], :configure, relief: :ridge, bg: @empty_bg)
|
|
179
|
+
|
|
180
|
+
[:frame, :image, :title, :platform].each do |k|
|
|
181
|
+
@app.command(:bind, card[k], '<Button-1>', '')
|
|
182
|
+
@app.command(:bind, card[k], '<Button-3>', '')
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def bind_context_menu(card, rom_info)
|
|
187
|
+
handler = proc { post_card_menu(card, rom_info) }
|
|
188
|
+
@app.command(:bind, card[:frame], '<Button-3>', handler)
|
|
189
|
+
@app.command(:bind, card[:image], '<Button-3>', handler)
|
|
190
|
+
@app.command(:bind, card[:title], '<Button-3>', handler)
|
|
191
|
+
@app.command(:bind, card[:platform], '<Button-3>', handler)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def post_card_menu(card, rom_info)
|
|
195
|
+
menu = "#{card[:frame]}.ctx"
|
|
196
|
+
exists = @app.tcl_eval("winfo exists #{menu}") == '1'
|
|
197
|
+
@app.command(:menu, menu, tearoff: 0) unless exists
|
|
198
|
+
@app.command(menu, :delete, 0, :end)
|
|
199
|
+
@app.command(menu, :add, :command,
|
|
200
|
+
label: translate('game_picker.menu.play'),
|
|
201
|
+
command: proc { emit(:rom_selected, rom_info.path) })
|
|
202
|
+
qs_slot = quick_save_slot
|
|
203
|
+
qs_state = quick_save_exists?(rom_info, qs_slot)
|
|
204
|
+
@app.command(menu, :add, :command,
|
|
205
|
+
label: translate('game_picker.menu.quick_load'),
|
|
206
|
+
state: qs_state ? :normal : :disabled,
|
|
207
|
+
command: proc { emit(:rom_quick_load, path: rom_info.path, slot: qs_slot) })
|
|
208
|
+
@app.command(menu, :add, :command,
|
|
209
|
+
label: translate('game_picker.menu.set_boxart'),
|
|
210
|
+
command: proc { pick_custom_boxart(card, rom_info) })
|
|
211
|
+
@app.command(menu, :add, :separator)
|
|
212
|
+
@app.command(menu, :add, :command,
|
|
213
|
+
label: translate('game_picker.menu.remove'),
|
|
214
|
+
command: proc { remove_rom(rom_info) })
|
|
215
|
+
@app.tcl_eval("tk_popup #{menu} [winfo pointerx .] [winfo pointery .]")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def quick_save_slot
|
|
219
|
+
Gemba.user_config.quick_save_slot
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def quick_save_exists?(rom_info, slot)
|
|
223
|
+
return false unless rom_info.rom_id
|
|
224
|
+
state_file = File.join(Gemba.user_config.states_dir, rom_info.rom_id, "state#{slot}.ss")
|
|
225
|
+
File.exist?(state_file)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def remove_rom(rom_info)
|
|
229
|
+
@rom_library.remove(rom_info.rom_id)
|
|
230
|
+
@rom_library.save!
|
|
231
|
+
refresh
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def pick_custom_boxart(card, rom_info)
|
|
235
|
+
return unless @overrides
|
|
236
|
+
filetypes = '{{PNG Images} {.png}}'
|
|
237
|
+
path = @app.tcl_eval("tk_getOpenFile -filetypes {#{filetypes}}")
|
|
238
|
+
return if path.to_s.strip.empty?
|
|
239
|
+
dest = @overrides.set_custom_boxart(rom_info.rom_id, path)
|
|
240
|
+
key = rom_info.rom_id || rom_info.game_code
|
|
241
|
+
set_card_image(card, key, dest)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def set_card_image(card, key, path)
|
|
245
|
+
# Load full-size photo, scale to fit within IMG_SIZE, delete the original.
|
|
246
|
+
# Subsample factor is computed from actual dimensions so arbitrary-sized
|
|
247
|
+
# user images (e.g. custom boxart) don't break the card layout.
|
|
248
|
+
full_name = "boxart_full_#{key}"
|
|
249
|
+
small_name = "boxart_#{key}"
|
|
250
|
+
|
|
251
|
+
@app.command(:image, :create, :photo, full_name, file: path)
|
|
252
|
+
w = @app.tcl_eval("image width #{full_name}").to_i
|
|
253
|
+
h = @app.tcl_eval("image height #{full_name}").to_i
|
|
254
|
+
factor = [[(w.to_f / IMG_SIZE).ceil, (h.to_f / IMG_SIZE).ceil].max, 1].max
|
|
255
|
+
|
|
256
|
+
@app.command(:image, :create, :photo, small_name)
|
|
257
|
+
@app.command(small_name, :copy, full_name, subsample: factor)
|
|
258
|
+
@app.command(:image, :delete, full_name)
|
|
259
|
+
|
|
260
|
+
old = @photos[key]
|
|
261
|
+
@photos[key] = small_name
|
|
262
|
+
@app.command(card[:image], :configure, image: small_name)
|
|
263
|
+
@app.command(:image, :delete, old) if old && old != small_name
|
|
264
|
+
rescue => e
|
|
265
|
+
Gemba.log(:warn) { "BoxArt image load failed for #{key}: #{e.message}" }
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
# Manages SDL gamepad button → GBA bitmask mappings.
|
|
5
|
+
#
|
|
6
|
+
# Shares the same interface as {KeyboardMap} so that Player can
|
|
7
|
+
# delegate to either without knowing which device type is active.
|
|
8
|
+
class GamepadMap
|
|
9
|
+
DEFAULT_MAP = {
|
|
10
|
+
a: KEY_A,
|
|
11
|
+
b: KEY_B,
|
|
12
|
+
back: KEY_SELECT,
|
|
13
|
+
start: KEY_START,
|
|
14
|
+
dpad_up: KEY_UP,
|
|
15
|
+
dpad_down: KEY_DOWN,
|
|
16
|
+
dpad_left: KEY_LEFT,
|
|
17
|
+
dpad_right: KEY_RIGHT,
|
|
18
|
+
left_shoulder: KEY_L,
|
|
19
|
+
right_shoulder: KEY_R,
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
DEFAULT_DEAD_ZONE = 8000
|
|
23
|
+
|
|
24
|
+
def initialize(config)
|
|
25
|
+
@config = config
|
|
26
|
+
@map = DEFAULT_MAP.dup
|
|
27
|
+
@device = nil
|
|
28
|
+
@dead_zone = DEFAULT_DEAD_ZONE
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_accessor :device
|
|
32
|
+
attr_reader :dead_zone
|
|
33
|
+
|
|
34
|
+
def mask
|
|
35
|
+
return 0 unless @device && !@device.closed?
|
|
36
|
+
m = 0
|
|
37
|
+
@map.each { |btn, bit| m |= bit if @device.button?(btn) }
|
|
38
|
+
m
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def set(gba_btn, gp_btn)
|
|
42
|
+
bit = GBA_BTN_BITS[gba_btn] or return
|
|
43
|
+
@map.delete_if { |_, v| v == bit }
|
|
44
|
+
@map[gp_btn] = bit
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def reset!
|
|
48
|
+
@map = DEFAULT_MAP.dup
|
|
49
|
+
@dead_zone = DEFAULT_DEAD_ZONE
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def load_config
|
|
53
|
+
return unless @device
|
|
54
|
+
guid = @device.guid rescue return
|
|
55
|
+
gp_cfg = @config.gamepad(guid, name: @device.name)
|
|
56
|
+
|
|
57
|
+
@map = {}
|
|
58
|
+
gp_cfg['mappings'].each do |gba_str, gp_str|
|
|
59
|
+
bit = GBA_BTN_BITS[gba_str.to_sym]
|
|
60
|
+
next unless bit
|
|
61
|
+
@map[gp_str.to_sym] = bit
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
pct = gp_cfg['dead_zone']
|
|
65
|
+
@dead_zone = (pct / 100.0 * 32767).round
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def reload!
|
|
69
|
+
@config.reload!
|
|
70
|
+
load_config
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def labels
|
|
74
|
+
result = {}
|
|
75
|
+
@map.each do |input, bit|
|
|
76
|
+
gba_btn = GBA_BTN_BITS.key(bit)
|
|
77
|
+
result[gba_btn] = input.to_s if gba_btn
|
|
78
|
+
end
|
|
79
|
+
result
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def save_to_config
|
|
83
|
+
return unless @device
|
|
84
|
+
guid = @device.guid rescue return
|
|
85
|
+
@config.gamepad(guid, name: @device.name)
|
|
86
|
+
@config.set_dead_zone(guid, dead_zone_pct)
|
|
87
|
+
@map.each do |gp_btn, bit|
|
|
88
|
+
gba_btn = GBA_BTN_BITS.key(bit)
|
|
89
|
+
@config.set_mapping(guid, gba_btn, gp_btn) if gba_btn
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def supports_deadzone? = true
|
|
94
|
+
|
|
95
|
+
def dead_zone_pct
|
|
96
|
+
(@dead_zone.to_f / 32767 * 100).round
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def set_dead_zone(threshold)
|
|
100
|
+
@dead_zone = threshold.to_i
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/gemba/headless.rb
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Lightweight entry point
|
|
4
|
-
# Loads only the C extension and pure-Ruby modules — no Tk, no SDL2.
|
|
3
|
+
# Lightweight entry point — Tk and SDL2 are NOT loaded.
|
|
5
4
|
#
|
|
6
5
|
# require "gemba/headless"
|
|
7
6
|
# Gemba::HeadlessPlayer.open("game.gba") { |p| p.step(60) }
|
|
8
7
|
|
|
9
8
|
require_relative "runtime"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
|
|
10
|
+
module Gemba
|
|
11
|
+
# Marker — signals the headless stack is loaded without Tk/SDL2.
|
|
12
|
+
module Headless; end
|
|
13
|
+
end
|
|
@@ -15,16 +15,19 @@ module Gemba
|
|
|
15
15
|
class HeadlessPlayer
|
|
16
16
|
# @param rom_path [String] path to ROM file (.gba, .gb, .gbc, .zip)
|
|
17
17
|
# @param config [Config, nil] config object (uses default if nil)
|
|
18
|
-
def initialize(rom_path, config: nil)
|
|
18
|
+
def initialize(rom_path, config: nil, bios_path: nil)
|
|
19
19
|
@config = config || Gemba.user_config
|
|
20
|
-
rom_path =
|
|
20
|
+
rom_path = RomResolver.resolve(rom_path)
|
|
21
21
|
|
|
22
22
|
saves = @config.saves_dir
|
|
23
23
|
FileUtils.mkdir_p(saves) unless File.directory?(saves)
|
|
24
|
-
@core = Core.new(rom_path, saves)
|
|
24
|
+
@core = Core.new(rom_path, saves, bios_path)
|
|
25
25
|
@keys = 0
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# @return [Core] the underlying mGBA core
|
|
29
|
+
attr_reader :core
|
|
30
|
+
|
|
28
31
|
# Open a HeadlessPlayer, yield it, and close when done.
|
|
29
32
|
# @param rom_path [String]
|
|
30
33
|
# @param opts [Hash] passed to {#initialize}
|
|
@@ -166,7 +169,9 @@ module Gemba
|
|
|
166
169
|
def start_recording(path, compression: Zlib::BEST_SPEED)
|
|
167
170
|
check_open!
|
|
168
171
|
raise "Already recording" if recording?
|
|
172
|
+
platform = Platform.for(@core)
|
|
169
173
|
@recorder = Recorder.new(path, width: @core.width, height: @core.height,
|
|
174
|
+
fps_fraction: platform.fps_fraction,
|
|
170
175
|
compression: compression)
|
|
171
176
|
@recorder.start
|
|
172
177
|
end
|
|
@@ -184,6 +189,31 @@ module Gemba
|
|
|
184
189
|
|
|
185
190
|
# @!endgroup
|
|
186
191
|
|
|
192
|
+
# @!group Input replay
|
|
193
|
+
|
|
194
|
+
# Replay a .gir input recording. Loads the anchor save state, validates
|
|
195
|
+
# the ROM checksum, then feeds each recorded bitmask to the core.
|
|
196
|
+
#
|
|
197
|
+
# @param gir_path [String] path to .gir file
|
|
198
|
+
# @yield [Integer, Integer] bitmask and zero-based frame index after each frame
|
|
199
|
+
# @return [Integer] number of frames replayed
|
|
200
|
+
def replay(gir_path)
|
|
201
|
+
check_open!
|
|
202
|
+
replayer = InputReplayer.new(gir_path)
|
|
203
|
+
replayer.validate!(@core)
|
|
204
|
+
@core.load_state_from_file(replayer.anchor_state_path)
|
|
205
|
+
|
|
206
|
+
replayer.each_bitmask do |mask, idx|
|
|
207
|
+
@core.set_keys(mask)
|
|
208
|
+
@core.run_frame
|
|
209
|
+
yield mask, idx if block_given?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
replayer.frame_count
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# @!endgroup
|
|
216
|
+
|
|
187
217
|
# Shut down the core and free resources.
|
|
188
218
|
def close
|
|
189
219
|
return if closed?
|