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,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module Gemba
|
|
6
|
+
# Applies IPS, BPS, or UPS patch files to GBA ROM files.
|
|
7
|
+
#
|
|
8
|
+
# Format support:
|
|
9
|
+
# IPS — simplest; no checksums; RLE support
|
|
10
|
+
# BPS — Beat Patch System; delta encoding with CRC32 verification
|
|
11
|
+
# UPS — Universal Patching System; XOR hunks with CRC32 verification
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# RomPatcher.patch(rom_path: "game.gba", patch_path: "fix.ips", out_path: "patched.gba")
|
|
15
|
+
# # or invoke a format class directly:
|
|
16
|
+
# RomPatcher::IPS.apply(rom_bytes, patch_bytes) # => patched_bytes
|
|
17
|
+
#
|
|
18
|
+
class RomPatcher
|
|
19
|
+
CHUNK = 256 * 1024 # 256 KB
|
|
20
|
+
|
|
21
|
+
# Auto-detect format, apply patch, write output file.
|
|
22
|
+
#
|
|
23
|
+
# Progress budget:
|
|
24
|
+
# 0–15% read ROM
|
|
25
|
+
# 15–25% read patch
|
|
26
|
+
# 25–90% format apply (IPS/BPS/UPS)
|
|
27
|
+
# 90–100% write output
|
|
28
|
+
#
|
|
29
|
+
# @param rom_path [String] source ROM (read-only)
|
|
30
|
+
# @param patch_path [String] patch file (.ips / .bps / .ups)
|
|
31
|
+
# @param out_path [String] where to write the result
|
|
32
|
+
# @param on_progress [Proc, nil] called with a Float (0.0..1.0)
|
|
33
|
+
# @return [String] out_path
|
|
34
|
+
# @raise [RuntimeError] on unknown format or checksum failure
|
|
35
|
+
def self.patch(rom_path:, patch_path:, out_path:, on_progress: nil)
|
|
36
|
+
rom = read_chunked(rom_path, 0.0, 0.15, on_progress)
|
|
37
|
+
patch = read_chunked(patch_path, 0.15, 0.25, on_progress)
|
|
38
|
+
|
|
39
|
+
klass = case detect_format(patch)
|
|
40
|
+
when :ips then IPS
|
|
41
|
+
when :bps then BPS
|
|
42
|
+
when :ups then UPS
|
|
43
|
+
else raise "Unknown patch format (expected IPS/BPS/UPS magic)"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
apply_cb = on_progress && ->(pct) { on_progress.call(0.25 + pct * 0.65) }
|
|
47
|
+
result = klass.apply(rom, patch, on_progress: apply_cb)
|
|
48
|
+
on_progress&.call(0.90)
|
|
49
|
+
|
|
50
|
+
FileUtils.mkdir_p(File.dirname(out_path))
|
|
51
|
+
write_chunked(out_path, result, 0.90, 1.0, on_progress)
|
|
52
|
+
on_progress&.call(1.0)
|
|
53
|
+
out_path
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @return [:ips, :bps, :ups, nil]
|
|
57
|
+
def self.detect_format(patch_data)
|
|
58
|
+
return :ips if patch_data.start_with?("PATCH")
|
|
59
|
+
return :bps if patch_data.start_with?("BPS1")
|
|
60
|
+
return :ups if patch_data.start_with?("UPS1")
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Return a path that does not collide with existing files.
|
|
65
|
+
# If +path+ exists, appends -(2), -(3), ... before the extension.
|
|
66
|
+
def self.safe_out_path(path)
|
|
67
|
+
return path unless File.exist?(path)
|
|
68
|
+
ext = File.extname(path)
|
|
69
|
+
base = path.chomp(ext)
|
|
70
|
+
n = 2
|
|
71
|
+
loop do
|
|
72
|
+
candidate = "#{base}-(#{n})#{ext}"
|
|
73
|
+
return candidate unless File.exist?(candidate)
|
|
74
|
+
n += 1
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Read a file in chunks, reporting progress from +pct_start+ to +pct_end+.
|
|
79
|
+
def self.read_chunked(path, pct_start, pct_end, on_progress)
|
|
80
|
+
size = File.size(path).to_f
|
|
81
|
+
buf = String.new(encoding: 'BINARY')
|
|
82
|
+
read = 0
|
|
83
|
+
File.open(path, 'rb') do |f|
|
|
84
|
+
while (chunk = f.read(CHUNK))
|
|
85
|
+
buf << chunk
|
|
86
|
+
read += chunk.bytesize
|
|
87
|
+
on_progress&.call(pct_start + (read / size) * (pct_end - pct_start))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
buf
|
|
91
|
+
end
|
|
92
|
+
private_class_method :read_chunked
|
|
93
|
+
|
|
94
|
+
# Write a string to a file in chunks, reporting progress from +pct_start+ to +pct_end+.
|
|
95
|
+
def self.write_chunked(path, data, pct_start, pct_end, on_progress)
|
|
96
|
+
size = data.bytesize.to_f
|
|
97
|
+
written = 0
|
|
98
|
+
File.open(path, 'wb') do |f|
|
|
99
|
+
while written < data.bytesize
|
|
100
|
+
n = [CHUNK, data.bytesize - written].min
|
|
101
|
+
f.write(data.byteslice(written, n))
|
|
102
|
+
written += n
|
|
103
|
+
on_progress&.call(pct_start + (written / size) * (pct_end - pct_start))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
private_class_method :write_chunked
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -6,14 +6,14 @@ module Gemba
|
|
|
6
6
|
# Resolves ROM paths for the player. Handles both bare ROM files
|
|
7
7
|
# and .zip archives containing a single ROM at the zip root.
|
|
8
8
|
#
|
|
9
|
-
# @example
|
|
10
|
-
# path =
|
|
9
|
+
# @example Resolve a bare ROM
|
|
10
|
+
# path = RomResolver.resolve("/path/to/game.gba")
|
|
11
11
|
# # => "/path/to/game.gba"
|
|
12
12
|
#
|
|
13
|
-
# @example
|
|
14
|
-
# path =
|
|
13
|
+
# @example Resolve from a zip
|
|
14
|
+
# path = RomResolver.resolve("/path/to/game.zip")
|
|
15
15
|
# # => "/Users/you/.config/gemba/tmp/game.gba"
|
|
16
|
-
class
|
|
16
|
+
class RomResolver
|
|
17
17
|
ROM_EXTENSIONS = %w[.gba .gb .gbc].freeze
|
|
18
18
|
ZIP_EXTENSIONS = %w[.zip].freeze
|
|
19
19
|
SUPPORTED_EXTENSIONS = (ROM_EXTENSIONS + ZIP_EXTENSIONS).freeze
|
|
@@ -85,7 +85,7 @@ module Gemba
|
|
|
85
85
|
dir = tmp_dir
|
|
86
86
|
FileUtils.mkdir_p(dir)
|
|
87
87
|
out_path = File.join(dir, File.basename(rom_entry.name))
|
|
88
|
-
File.binwrite(out_path,
|
|
88
|
+
rom_entry.get_input_stream { |s| File.binwrite(out_path, s.read) }
|
|
89
89
|
out_path
|
|
90
90
|
end
|
|
91
91
|
rescue NoRomInZip, MultipleRomsInZip
|
|
@@ -97,4 +97,5 @@ module Gemba
|
|
|
97
97
|
end
|
|
98
98
|
private_class_method :extract_from_zip
|
|
99
99
|
end
|
|
100
|
+
|
|
100
101
|
end
|
data/lib/gemba/runtime.rb
CHANGED
|
@@ -1,39 +1,72 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Shared
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# Shared bootstrap — explicitly required by lib/gemba.rb (full GUI) and
|
|
4
|
+
# lib/gemba/headless.rb (no Tk/SDL2). Sets up Zeitwerk autoloading,
|
|
5
|
+
# loads the C extension, and initializes the locale.
|
|
6
6
|
|
|
7
|
+
require "zeitwerk"
|
|
7
8
|
require "teek/platform"
|
|
8
9
|
require "gemba_ext"
|
|
9
|
-
require_relative "version"
|
|
10
|
-
require_relative "config"
|
|
11
|
-
require_relative "locale"
|
|
12
|
-
require_relative "core"
|
|
13
|
-
require_relative "rom_loader"
|
|
14
10
|
|
|
11
|
+
# Define the Gemba module before loader.setup so Zeitwerk can register
|
|
12
|
+
# autoloads directly on it (e.g. Gemba.autoload(:ChildWindow, ...)).
|
|
13
|
+
# Without this, Gemba doesn't exist yet and Zeitwerk proxies through
|
|
14
|
+
# lib/gemba.rb — which is never loaded in the headless path.
|
|
15
15
|
module Gemba
|
|
16
16
|
ASSETS_DIR = File.expand_path('../../assets', __dir__).freeze
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
class << self
|
|
19
|
+
# Lazily loaded user config — shared across the application.
|
|
20
|
+
# @return [Gemba::Config]
|
|
21
|
+
def user_config
|
|
22
|
+
@user_config ||= Config.new
|
|
23
|
+
end
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
@user_config = config
|
|
28
|
-
end
|
|
25
|
+
# Override the user config (useful for tests).
|
|
26
|
+
# @param config [Gemba::Config, nil] pass nil to reset to default
|
|
27
|
+
attr_writer :user_config
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
# Load translations based on the config locale setting.
|
|
30
|
+
def load_locale
|
|
31
|
+
lang = user_config.locale
|
|
32
|
+
lang = nil if lang == 'auto'
|
|
33
|
+
Locale.load(lang)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Event bus — auto-created on first access.
|
|
37
|
+
# AppController replaces it with a fresh bus at startup.
|
|
38
|
+
def bus
|
|
39
|
+
@bus ||= EventBus.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attr_writer :bus
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
# Session logger — lazily initialized on first write.
|
|
45
|
+
def logger
|
|
46
|
+
@logger ||= SessionLogger.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
attr_writer :logger
|
|
50
|
+
|
|
51
|
+
# Log a message at the given level.
|
|
52
|
+
# @example Gemba.log(:warn) { "something went wrong" }
|
|
53
|
+
def log(level = :info, &block)
|
|
54
|
+
logger.log(level, &block)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
39
57
|
end
|
|
58
|
+
|
|
59
|
+
loader = Zeitwerk::Loader.new
|
|
60
|
+
loader.push_dir(File.expand_path("../..", __FILE__)) # lib/ as root
|
|
61
|
+
loader.inflector.inflect(
|
|
62
|
+
"gba" => "GBA", "gb" => "GB", "gbc" => "GBC", "cli" => "CLI",
|
|
63
|
+
"ips" => "IPS", "bps" => "BPS", "ups" => "UPS"
|
|
64
|
+
)
|
|
65
|
+
loader.ignore(__FILE__) # bootstrap file — not a constant
|
|
66
|
+
loader.ignore(File.expand_path("../../gemba.rb", __FILE__)) # entry point, not a constant
|
|
67
|
+
loader.ignore(File.expand_path("../platform_open.rb", __FILE__)) # module method, not a constant
|
|
68
|
+
loader.ignore(File.expand_path("../version.rb", __FILE__)) # defines VERSION (all-caps), not Zeitwerk-compatible
|
|
69
|
+
loader.setup
|
|
70
|
+
|
|
71
|
+
# Initialize locale on require
|
|
72
|
+
Gemba.load_locale
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
|
-
require_relative 'locale'
|
|
5
4
|
|
|
6
5
|
module Gemba
|
|
7
6
|
# Manages save state persistence: save, load, screenshot capture,
|
|
@@ -22,13 +21,11 @@ module Gemba
|
|
|
22
21
|
class SaveStateManager
|
|
23
22
|
include Locale::Translatable
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
GBA_H = 160
|
|
27
|
-
|
|
28
|
-
def initialize(core:, config:, app:)
|
|
24
|
+
def initialize(core:, config:, app:, platform:)
|
|
29
25
|
@core = core
|
|
30
26
|
@config = config
|
|
31
27
|
@app = app
|
|
28
|
+
@platform = platform
|
|
32
29
|
@last_save_time = 0
|
|
33
30
|
@state_dir = nil
|
|
34
31
|
@quick_save_slot = config.quick_save_slot
|
|
@@ -143,8 +140,8 @@ module Gemba
|
|
|
143
140
|
photo_name = "__gemba_ss_#{object_id}"
|
|
144
141
|
|
|
145
142
|
@app.command(:image, :create, :photo, photo_name,
|
|
146
|
-
width:
|
|
147
|
-
@app.interp.photo_put_block(photo_name, pixels,
|
|
143
|
+
width: @platform.width, height: @platform.height)
|
|
144
|
+
@app.interp.photo_put_block(photo_name, pixels, @platform.width, @platform.height, format: :argb)
|
|
148
145
|
@app.command(photo_name, :write, path, format: :png)
|
|
149
146
|
@app.command(:image, :delete, photo_name)
|
|
150
147
|
rescue StandardError => e
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "child_window"
|
|
4
|
-
require_relative "locale"
|
|
5
3
|
|
|
6
4
|
module Gemba
|
|
7
5
|
# Grid picker window for save state slots.
|
|
@@ -17,6 +15,7 @@ module Gemba
|
|
|
17
15
|
class SaveStatePicker
|
|
18
16
|
include ChildWindow
|
|
19
17
|
include Locale::Translatable
|
|
18
|
+
include BusEmitter
|
|
20
19
|
|
|
21
20
|
TOP = ".mgba_state_picker"
|
|
22
21
|
|
|
@@ -53,6 +52,20 @@ module Gemba
|
|
|
53
52
|
cleanup_photos
|
|
54
53
|
end
|
|
55
54
|
|
|
55
|
+
# ModalStack protocol
|
|
56
|
+
def show_modal(state_dir: nil, quick_slot: 1, **_)
|
|
57
|
+
@state_dir = state_dir
|
|
58
|
+
@quick_slot = quick_slot
|
|
59
|
+
build_ui unless @built
|
|
60
|
+
refresh
|
|
61
|
+
super()
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def withdraw
|
|
65
|
+
super
|
|
66
|
+
cleanup_photos
|
|
67
|
+
end
|
|
68
|
+
|
|
56
69
|
private
|
|
57
70
|
|
|
58
71
|
def build_ui
|
|
@@ -189,9 +202,9 @@ module Gemba
|
|
|
189
202
|
def on_slot_click(slot)
|
|
190
203
|
ss_path = File.join(@state_dir, "state#{slot}.ss")
|
|
191
204
|
if File.exist?(ss_path)
|
|
192
|
-
|
|
205
|
+
emit(:state_load_requested, slot)
|
|
193
206
|
else
|
|
194
|
-
|
|
207
|
+
emit(:state_save_requested, slot)
|
|
195
208
|
end
|
|
196
209
|
hide
|
|
197
210
|
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'logger'
|
|
4
|
+
|
|
5
|
+
module Gemba
|
|
6
|
+
# Session logger that writes to the user config logs/ directory.
|
|
7
|
+
# File and directory are created lazily on first write.
|
|
8
|
+
class SessionLogger
|
|
9
|
+
MAX_LOG_FILES = 25
|
|
10
|
+
LEVELS = { debug: Logger::DEBUG, info: Logger::INFO,
|
|
11
|
+
warn: Logger::WARN, error: Logger::ERROR }.freeze
|
|
12
|
+
|
|
13
|
+
# @param dir [String] log directory (default: Config.default_logs_dir)
|
|
14
|
+
# @param level [Symbol] minimum log level (:debug, :info, :warn, :error)
|
|
15
|
+
def initialize(dir: nil, level: :info)
|
|
16
|
+
@dir = dir
|
|
17
|
+
@level = LEVELS.fetch(level, Logger::INFO)
|
|
18
|
+
@logger = nil
|
|
19
|
+
prune
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Log a message at the given level. Uses block form to avoid
|
|
23
|
+
# allocating the message string when the level is filtered.
|
|
24
|
+
# @param level [Symbol] :debug, :info, :warn, :error
|
|
25
|
+
def log(level, &block)
|
|
26
|
+
severity = LEVELS.fetch(level, Logger::INFO)
|
|
27
|
+
return if severity < @level
|
|
28
|
+
|
|
29
|
+
ensure_logger
|
|
30
|
+
@logger.add(severity, nil, 'gemba', &block)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [String] resolved log directory
|
|
34
|
+
def log_dir
|
|
35
|
+
@dir ||= Config.default_logs_dir
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def ensure_logger
|
|
41
|
+
return if @logger
|
|
42
|
+
|
|
43
|
+
FileUtils.mkdir_p(log_dir)
|
|
44
|
+
path = File.join(log_dir, "gemba-#{Time.now.strftime('%Y-%m-%d')}.log")
|
|
45
|
+
@logger = Logger.new(path)
|
|
46
|
+
@logger.level = @level
|
|
47
|
+
@logger.formatter = proc { |sev, time, _prog, msg|
|
|
48
|
+
"#{time.strftime('%H:%M:%S.%L')} [#{sev}] #{msg}\n"
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def prune
|
|
53
|
+
dir = log_dir
|
|
54
|
+
return unless File.directory?(dir)
|
|
55
|
+
|
|
56
|
+
logs = Dir.glob(File.join(dir, 'gemba-*.log')).sort
|
|
57
|
+
excess = logs.length - MAX_LOG_FILES
|
|
58
|
+
return unless excess > 0
|
|
59
|
+
|
|
60
|
+
logs.first(excess).each { |f| File.delete(f) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
module Gemba
|
|
5
|
+
module Settings
|
|
6
|
+
class AudioTab
|
|
7
|
+
include Locale::Translatable
|
|
8
|
+
include BusEmitter
|
|
9
|
+
|
|
10
|
+
FRAME = "#{Paths::NB}.audio"
|
|
11
|
+
VOLUME_SCALE = "#{FRAME}.vol_row.vol_scale"
|
|
12
|
+
MUTE_CHECK = "#{FRAME}.mute_row.mute"
|
|
13
|
+
|
|
14
|
+
VAR_VOLUME = '::mgba_volume'
|
|
15
|
+
VAR_MUTE = '::mgba_mute'
|
|
16
|
+
|
|
17
|
+
def initialize(app, tips:, mark_dirty:)
|
|
18
|
+
@app = app
|
|
19
|
+
@mark_dirty = mark_dirty
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def load_from_config(config)
|
|
23
|
+
@app.set_variable(VAR_VOLUME, config.volume.to_s)
|
|
24
|
+
@app.command(@vol_val_label, 'configure', text: "#{config.volume}%")
|
|
25
|
+
@app.set_variable(VAR_MUTE, config.muted? ? '1' : '0')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build
|
|
29
|
+
@app.command('ttk::frame', FRAME)
|
|
30
|
+
@app.command(Paths::NB, 'add', FRAME, text: translate('settings.audio'))
|
|
31
|
+
|
|
32
|
+
# Volume slider
|
|
33
|
+
vol_row = "#{FRAME}.vol_row"
|
|
34
|
+
@app.command('ttk::frame', vol_row)
|
|
35
|
+
@app.command(:pack, vol_row, fill: :x, padx: 10, pady: [15, 5])
|
|
36
|
+
|
|
37
|
+
@app.command('ttk::label', "#{vol_row}.lbl", text: translate('settings.volume'))
|
|
38
|
+
@app.command(:pack, "#{vol_row}.lbl", side: :left)
|
|
39
|
+
|
|
40
|
+
@vol_val_label = "#{vol_row}.vol_label"
|
|
41
|
+
@app.command('ttk::label', @vol_val_label, text: '100%', width: 5)
|
|
42
|
+
@app.command(:pack, @vol_val_label, side: :right)
|
|
43
|
+
|
|
44
|
+
@app.set_variable(VAR_VOLUME, '100')
|
|
45
|
+
@app.command('ttk::scale', VOLUME_SCALE,
|
|
46
|
+
orient: :horizontal,
|
|
47
|
+
from: 0,
|
|
48
|
+
to: 100,
|
|
49
|
+
length: 150,
|
|
50
|
+
variable: VAR_VOLUME,
|
|
51
|
+
command: proc { |v, *|
|
|
52
|
+
pct = v.to_f.round
|
|
53
|
+
@app.command(@vol_val_label, 'configure', text: "#{pct}%")
|
|
54
|
+
emit(:volume_changed, pct / 100.0)
|
|
55
|
+
@mark_dirty.call
|
|
56
|
+
})
|
|
57
|
+
@app.command(:pack, VOLUME_SCALE, side: :right, padx: [5, 5])
|
|
58
|
+
|
|
59
|
+
# Mute checkbox
|
|
60
|
+
mute_row = "#{FRAME}.mute_row"
|
|
61
|
+
@app.command('ttk::frame', mute_row)
|
|
62
|
+
@app.command(:pack, mute_row, fill: :x, padx: 10, pady: 5)
|
|
63
|
+
|
|
64
|
+
@app.set_variable(VAR_MUTE, '0')
|
|
65
|
+
@app.command('ttk::checkbutton', MUTE_CHECK,
|
|
66
|
+
text: translate('settings.mute'),
|
|
67
|
+
variable: VAR_MUTE,
|
|
68
|
+
command: proc { |*|
|
|
69
|
+
muted = @app.get_variable(VAR_MUTE) == '1'
|
|
70
|
+
emit(:mute_changed, muted)
|
|
71
|
+
@mark_dirty.call
|
|
72
|
+
})
|
|
73
|
+
@app.command(:pack, MUTE_CHECK, side: :left)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|