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,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
module Gemba
|
|
5
|
+
# Immutable snapshot of everything known about a single ROM.
|
|
6
|
+
#
|
|
7
|
+
# Aggregates data from multiple sources:
|
|
8
|
+
# - RomLibrary entry (title, path, game_code, platform, rom_id)
|
|
9
|
+
# - GameIndex (has_official_entry — whether libretro knows about it)
|
|
10
|
+
# - BoxartFetcher (cached_boxart_path — auto-fetched cover art)
|
|
11
|
+
# - RomOverrides (custom_boxart_path — user-chosen cover art)
|
|
12
|
+
#
|
|
13
|
+
# Use RomInfo.from_rom to construct from a raw library entry hash.
|
|
14
|
+
# Use #boxart_path to get the effective cover image (custom beats cache).
|
|
15
|
+
RomInfo = Data.define(
|
|
16
|
+
:rom_id, # String — unique ROM identifier (game_code + CRC32)
|
|
17
|
+
:title, # String — display name
|
|
18
|
+
:platform, # String — uppercased, e.g. "GBA"
|
|
19
|
+
:game_code, # String? — 4-char code e.g. "AGB-AXVE", or nil
|
|
20
|
+
:path, # String — absolute path to the ROM file
|
|
21
|
+
:md5, # String? — MD5 hex digest of ROM content, or nil (lazy)
|
|
22
|
+
:has_official_entry, # Boolean — GameIndex has an entry for this game_code
|
|
23
|
+
:cached_boxart_path, # String? — auto-fetched cover from libretro CDN, or nil
|
|
24
|
+
:custom_boxart_path # String? — user-set cover image path, or nil
|
|
25
|
+
) do
|
|
26
|
+
# Effective cover image path: custom override wins, then fetched cache, then nil.
|
|
27
|
+
def boxart_path
|
|
28
|
+
return custom_boxart_path if custom_boxart_path && File.exist?(custom_boxart_path)
|
|
29
|
+
return cached_boxart_path if cached_boxart_path && File.exist?(cached_boxart_path)
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Build a RomInfo from a raw rom_library entry hash.
|
|
34
|
+
#
|
|
35
|
+
# @param rom [Hash] entry from RomLibrary#all
|
|
36
|
+
# @param fetcher [BoxartFetcher, nil]
|
|
37
|
+
# @param overrides [RomOverrides, nil]
|
|
38
|
+
def self.from_rom(rom, fetcher: nil, overrides: nil, game_index: GameIndex)
|
|
39
|
+
game_code = rom['game_code']
|
|
40
|
+
rom_id = rom['rom_id']
|
|
41
|
+
|
|
42
|
+
new(
|
|
43
|
+
rom_id: rom_id,
|
|
44
|
+
title: game_index.lookup(game_code) ||
|
|
45
|
+
game_index.lookup_by_md5(rom['md5'], rom['platform'] || 'gba') ||
|
|
46
|
+
rom['title'] || rom['rom_id'] || '???',
|
|
47
|
+
platform: (rom['platform'] || 'gba').upcase,
|
|
48
|
+
game_code: game_code,
|
|
49
|
+
path: rom['path'],
|
|
50
|
+
md5: rom['md5'],
|
|
51
|
+
has_official_entry: game_code ? !game_index.lookup(game_code).nil? : false,
|
|
52
|
+
cached_boxart_path: (fetcher.cached_path(game_code) if fetcher&.cached?(game_code)),
|
|
53
|
+
custom_boxart_path: overrides&.custom_boxart(rom_id),
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -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
|
# Displays ROM metadata in a read-only window.
|
|
@@ -259,6 +257,21 @@ module Gemba
|
|
|
259
257
|
hide_window(modal: false)
|
|
260
258
|
end
|
|
261
259
|
|
|
260
|
+
# ModalStack protocol (non-modal — no grab)
|
|
261
|
+
def show_modal(core: nil, rom_path: nil, save_path: nil, **_)
|
|
262
|
+
build_ui unless @built
|
|
263
|
+
populate(core, rom_path, save_path) if core
|
|
264
|
+
position_near_parent
|
|
265
|
+
top = self.class::TOP
|
|
266
|
+
@app.command(:wm, 'deiconify', top)
|
|
267
|
+
@app.command(:raise, top)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def withdraw
|
|
271
|
+
top = self.class::TOP
|
|
272
|
+
@app.command(:wm, 'withdraw', top)
|
|
273
|
+
end
|
|
274
|
+
|
|
262
275
|
private
|
|
263
276
|
|
|
264
277
|
def build_ui
|
|
@@ -318,7 +331,7 @@ module Gemba
|
|
|
318
331
|
publisher = maker.empty? ? na : "#{self.class.publisher_name(maker)} (#{maker})"
|
|
319
332
|
set_field('publisher', publisher)
|
|
320
333
|
|
|
321
|
-
set_field('platform', core.
|
|
334
|
+
set_field('platform', Platform.for(core).name)
|
|
322
335
|
set_field('rom_size', format_size(core.rom_size))
|
|
323
336
|
set_field('checksum', "0x%08X" % core.checksum)
|
|
324
337
|
set_field('rom_path', rom_path || na)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'time'
|
|
6
|
+
|
|
7
|
+
module Gemba
|
|
8
|
+
# Persistent catalog of known ROMs.
|
|
9
|
+
#
|
|
10
|
+
# Stored as JSON at Config.config_dir/rom_library.json. Each entry records
|
|
11
|
+
# the ROM's path, title, game code, rom_id, platform, and timestamps.
|
|
12
|
+
# The library is loaded once on boot and updated whenever a ROM is loaded.
|
|
13
|
+
class RomLibrary
|
|
14
|
+
FILENAME = 'rom_library.json'
|
|
15
|
+
|
|
16
|
+
def initialize(path = self.class.default_path, subscribe: true)
|
|
17
|
+
@path = path
|
|
18
|
+
@roms = []
|
|
19
|
+
load!
|
|
20
|
+
subscribe_to_bus if subscribe
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.default_path
|
|
24
|
+
File.join(Config.config_dir, FILENAME)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# All known ROMs, sorted by last_played descending (most recent first).
|
|
28
|
+
# @return [Array<Hash>]
|
|
29
|
+
def all
|
|
30
|
+
@roms.sort_by { |r| r['last_played'] || r['added_at'] || '' }.reverse
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Add or update a ROM entry. Upserts by rom_id.
|
|
34
|
+
# @param attrs [Hash] must include 'rom_id'; other keys merged in
|
|
35
|
+
def add(attrs)
|
|
36
|
+
rom_id = attrs['rom_id'] || attrs[:rom_id]
|
|
37
|
+
raise ArgumentError, 'rom_id is required' unless rom_id
|
|
38
|
+
|
|
39
|
+
attrs = stringify_keys(attrs)
|
|
40
|
+
existing = @roms.find { |r| r['rom_id'] == rom_id }
|
|
41
|
+
if existing
|
|
42
|
+
existing.merge!(attrs)
|
|
43
|
+
else
|
|
44
|
+
attrs['added_at'] ||= Time.now.utc.iso8601
|
|
45
|
+
@roms << attrs
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Remove a ROM entry by rom_id.
|
|
50
|
+
def remove(rom_id)
|
|
51
|
+
@roms.reject! { |r| r['rom_id'] == rom_id }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Update last_played timestamp for a ROM.
|
|
55
|
+
def touch(rom_id)
|
|
56
|
+
entry = find(rom_id)
|
|
57
|
+
entry['last_played'] = Time.now.utc.iso8601 if entry
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Find a ROM entry by rom_id.
|
|
61
|
+
# @return [Hash, nil]
|
|
62
|
+
def find(rom_id)
|
|
63
|
+
@roms.find { |r| r['rom_id'] == rom_id }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Integer]
|
|
67
|
+
def size
|
|
68
|
+
@roms.size
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Persist to disk.
|
|
72
|
+
def save!
|
|
73
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
74
|
+
File.write(@path, JSON.pretty_generate({ 'roms' => @roms }))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def subscribe_to_bus
|
|
80
|
+
Gemba.bus.on(:rom_loaded) do |rom_id:, path:, title:, game_code:, platform:, md5: nil, **|
|
|
81
|
+
add(
|
|
82
|
+
'rom_id' => rom_id,
|
|
83
|
+
'path' => path,
|
|
84
|
+
'title' => title,
|
|
85
|
+
'game_code' => game_code,
|
|
86
|
+
'platform' => platform.downcase,
|
|
87
|
+
'md5' => md5,
|
|
88
|
+
)
|
|
89
|
+
touch(rom_id)
|
|
90
|
+
save!
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def load!
|
|
95
|
+
return unless File.exist?(@path)
|
|
96
|
+
data = JSON.parse(File.read(@path))
|
|
97
|
+
@roms = data['roms'] || []
|
|
98
|
+
rescue JSON::ParserError
|
|
99
|
+
@roms = []
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def stringify_keys(hash)
|
|
103
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Gemba
|
|
7
|
+
# Persists per-ROM user overrides to config_dir/rom_overrides.json.
|
|
8
|
+
#
|
|
9
|
+
# Keyed by rom_id (game_code + CRC32 checksum) — the most stable
|
|
10
|
+
# identifier for a ROM across renames or moves.
|
|
11
|
+
#
|
|
12
|
+
# Currently tracks:
|
|
13
|
+
# custom_boxart — absolute path to a user-chosen cover image
|
|
14
|
+
#
|
|
15
|
+
# Custom images are copied into config_dir/boxart/{rom_id}/custom.{ext}
|
|
16
|
+
# so they remain accessible even if the original file is moved or deleted.
|
|
17
|
+
class RomOverrides
|
|
18
|
+
def initialize(path = Config.rom_overrides_path)
|
|
19
|
+
@path = path
|
|
20
|
+
@data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [String, nil] absolute path to the custom boxart, or nil
|
|
24
|
+
def custom_boxart(rom_id)
|
|
25
|
+
@data.dig(rom_id.to_s, 'custom_boxart')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Copies src_path into the gemba boxart cache and records the dest path.
|
|
29
|
+
# @return [String] the destination path
|
|
30
|
+
def set_custom_boxart(rom_id, src_path)
|
|
31
|
+
ext = File.extname(src_path)
|
|
32
|
+
dest = File.join(Config.boxart_dir, rom_id.to_s, "custom#{ext}")
|
|
33
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
34
|
+
FileUtils.cp(src_path, dest)
|
|
35
|
+
(@data[rom_id.to_s] ||= {})['custom_boxart'] = dest
|
|
36
|
+
save
|
|
37
|
+
dest
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def save
|
|
43
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
44
|
+
File.write(@path, JSON.pretty_generate(@data))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
6
|
+
module Gemba
|
|
7
|
+
class RomPatcher
|
|
8
|
+
# Applies a BPS (Beat Patch System) patch.
|
|
9
|
+
#
|
|
10
|
+
# File layout:
|
|
11
|
+
#
|
|
12
|
+
# ┌──────────────────────────────────────────────────────┐
|
|
13
|
+
# │ "BPS1" (4 bytes, magic) │
|
|
14
|
+
# ├──────────────────────────────────────────────────────┤
|
|
15
|
+
# │ source_size (varint) │
|
|
16
|
+
# │ target_size (varint) │
|
|
17
|
+
# │ metadata_size (varint) │
|
|
18
|
+
# │ metadata (<metadata_size> bytes, skipped) │
|
|
19
|
+
# ├──────────────────────────────────────────────────────┤
|
|
20
|
+
# │ actions … (repeated until patch.size - 12) │
|
|
21
|
+
# ├──────────────────────────────────────────────────────┤
|
|
22
|
+
# │ src_crc32 4B LE │ tgt_crc32 4B LE │ patch │ ← footer
|
|
23
|
+
# └──────────────────────────────────────────────────────┘
|
|
24
|
+
#
|
|
25
|
+
# Each action word (varint): word = (length - 1) << 2 | mode
|
|
26
|
+
#
|
|
27
|
+
# mode 0 SourceRead ┌────────┐ copy `length` bytes from source
|
|
28
|
+
# │ word │ at current output offset
|
|
29
|
+
# └────────┘
|
|
30
|
+
#
|
|
31
|
+
# mode 1 TargetRead ┌────────┬──────────────────────┐
|
|
32
|
+
# │ word │ data (<length> B) │
|
|
33
|
+
# └────────┴──────────────────────┘
|
|
34
|
+
#
|
|
35
|
+
# mode 2 SourceCopy ┌────────┬───────────┐ seek src by signed delta,
|
|
36
|
+
# │ word │ delta(v) │ copy `length` bytes
|
|
37
|
+
# └────────┴───────────┘
|
|
38
|
+
#
|
|
39
|
+
# mode 3 TargetCopy ┌────────┬───────────┐ seek already-written target
|
|
40
|
+
# │ word │ delta(v) │ by signed delta, copy
|
|
41
|
+
# └────────┴───────────┘
|
|
42
|
+
#
|
|
43
|
+
# BPS varint encoding (7-bit groups, additive-shift, differs from UPS):
|
|
44
|
+
# Each byte holds 7 data bits (b & 0x7f = 0b01111111) and one flag bit
|
|
45
|
+
# (b & 0x80). Flag=1 means last byte; flag=0 means more follow.
|
|
46
|
+
# Unlike UPS (bitwise OR + left-shift), BPS uses multiplication and adds
|
|
47
|
+
# an extra `shift` after each non-terminal byte so that 0x00 is never a
|
|
48
|
+
# valid single-byte encoding — this lets the format distinguish "no data"
|
|
49
|
+
# from an actual zero value.
|
|
50
|
+
# value = 0, shift = 1
|
|
51
|
+
# per byte: value += (b & 0x7f) * shift
|
|
52
|
+
# if bit7 set → done; else shift <<= 7; value += shift
|
|
53
|
+
#
|
|
54
|
+
# Example: value 300 decoded from bytes [0x2C, 0x81]
|
|
55
|
+
# raw byte │ & 0x7f │ shift │ value after │ bit7 │ action
|
|
56
|
+
# ──────────┼──────────┼─────────┼─────────────────────┼────────┼───────────────────────────
|
|
57
|
+
# 0x2C │ 44 │ 1 │ 0 + 44×1 = 44 │ 0 │ shift<<=7 (→128); value+=128 (→172)
|
|
58
|
+
# 0x81 │ 1 │ 128 │ 172 + 1×128 = 300 │ 1 │ break
|
|
59
|
+
class BPS
|
|
60
|
+
# @param rom [String] binary ROM data
|
|
61
|
+
# @param patch [String] binary BPS patch data
|
|
62
|
+
# @return [String] patched ROM (binary)
|
|
63
|
+
# @raise [RuntimeError] on CRC32 mismatch
|
|
64
|
+
def self.apply(rom, patch, on_progress: nil)
|
|
65
|
+
raise "BPS patch too small to be valid" if patch.bytesize < 16
|
|
66
|
+
rom = rom.b
|
|
67
|
+
patch = patch.b
|
|
68
|
+
io = StringIO.new(patch)
|
|
69
|
+
io.read(4) # "BPS1"
|
|
70
|
+
|
|
71
|
+
read_varint(io) # source_size — not used; target_size drives allocation
|
|
72
|
+
target_size = read_varint(io)
|
|
73
|
+
metadata_size = read_varint(io)
|
|
74
|
+
skip = io.read(metadata_size)
|
|
75
|
+
raise "Truncated BPS metadata" if skip&.bytesize != metadata_size
|
|
76
|
+
|
|
77
|
+
target = "\x00".b * target_size
|
|
78
|
+
out_offset = 0
|
|
79
|
+
src_offset = 0
|
|
80
|
+
tgt_offset = 0
|
|
81
|
+
patch_end = patch.bytesize - 12
|
|
82
|
+
|
|
83
|
+
last_pct = -1
|
|
84
|
+
|
|
85
|
+
while io.pos < patch_end
|
|
86
|
+
if on_progress
|
|
87
|
+
pct = (io.pos / patch_end.to_f * 100).floor
|
|
88
|
+
if pct != last_pct
|
|
89
|
+
on_progress.call(pct / 100.0)
|
|
90
|
+
last_pct = pct
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
word = read_varint(io)
|
|
95
|
+
mode = word & 3
|
|
96
|
+
length = (word >> 2) + 1
|
|
97
|
+
|
|
98
|
+
case mode
|
|
99
|
+
when 0 # SourceRead — copy from rom at current out position
|
|
100
|
+
length.times do
|
|
101
|
+
target.setbyte(out_offset, rom.getbyte(out_offset) || 0)
|
|
102
|
+
out_offset += 1
|
|
103
|
+
end
|
|
104
|
+
when 1 # TargetRead — literal data
|
|
105
|
+
data = io.read(length)
|
|
106
|
+
target[out_offset, length] = data
|
|
107
|
+
out_offset += length
|
|
108
|
+
when 2 # SourceCopy — relative seek in source
|
|
109
|
+
src_offset += read_signed_varint(io)
|
|
110
|
+
length.times do
|
|
111
|
+
target.setbyte(out_offset, rom.getbyte(src_offset) || 0)
|
|
112
|
+
out_offset += 1
|
|
113
|
+
src_offset += 1
|
|
114
|
+
end
|
|
115
|
+
when 3 # TargetCopy — relative seek in target
|
|
116
|
+
tgt_offset += read_signed_varint(io)
|
|
117
|
+
length.times do
|
|
118
|
+
target.setbyte(out_offset, target.getbyte(tgt_offset) || 0)
|
|
119
|
+
out_offset += 1
|
|
120
|
+
tgt_offset += 1
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
raise "BPS patch too small to contain footer" if patch.bytesize < 12
|
|
126
|
+
src_crc, tgt_crc = patch[-12..].unpack("VV")
|
|
127
|
+
raise "BPS source CRC32 mismatch" unless Zlib.crc32(rom) == src_crc
|
|
128
|
+
raise "BPS target CRC32 mismatch" unless Zlib.crc32(target) == tgt_crc
|
|
129
|
+
|
|
130
|
+
target
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# BPS varint: low 7 bits per byte; bit7=1 terminates; additive shift encoding.
|
|
134
|
+
# Decoder: value = 0, shift = 1; per byte: value += (b & 0x7f) * shift;
|
|
135
|
+
# if bit7: break; else: shift <<= 7; value += shift.
|
|
136
|
+
def self.read_varint(io)
|
|
137
|
+
value = 0
|
|
138
|
+
shift = 1
|
|
139
|
+
loop do
|
|
140
|
+
byte = io.read(1)
|
|
141
|
+
raise "Truncated BPS patch (varint read past end)" if byte.nil?
|
|
142
|
+
b = byte.getbyte(0)
|
|
143
|
+
value += (b & 0x7f) * shift
|
|
144
|
+
break if (b & 0x80) != 0
|
|
145
|
+
shift <<= 7
|
|
146
|
+
value += shift
|
|
147
|
+
end
|
|
148
|
+
value
|
|
149
|
+
end
|
|
150
|
+
private_class_method :read_varint
|
|
151
|
+
|
|
152
|
+
def self.read_signed_varint(io)
|
|
153
|
+
v = read_varint(io)
|
|
154
|
+
negative = (v & 1) != 0
|
|
155
|
+
v >>= 1
|
|
156
|
+
negative ? -v : v
|
|
157
|
+
end
|
|
158
|
+
private_class_method :read_signed_varint
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
5
|
+
module Gemba
|
|
6
|
+
class RomPatcher
|
|
7
|
+
# Applies an IPS (International Patching System) patch.
|
|
8
|
+
#
|
|
9
|
+
# File layout:
|
|
10
|
+
#
|
|
11
|
+
# ┌─────────────────────────────────────────────┐
|
|
12
|
+
# │ "PATCH" (5 bytes, magic) │
|
|
13
|
+
# ├─────────────────────────────────────────────┤
|
|
14
|
+
# │ Record 1 │
|
|
15
|
+
# │ Record 2 │
|
|
16
|
+
# │ ... │
|
|
17
|
+
# ├─────────────────────────────────────────────┤
|
|
18
|
+
# │ "EOF" (3 bytes, terminator) │
|
|
19
|
+
# └─────────────────────────────────────────────┘
|
|
20
|
+
#
|
|
21
|
+
# Record formats:
|
|
22
|
+
#
|
|
23
|
+
# Normal record:
|
|
24
|
+
# ┌────────────────────┬───────────────────┬─────────────────────┐
|
|
25
|
+
# │ offset │ size │ data │
|
|
26
|
+
# │ 3 bytes, big-endian│ 2 bytes, big-endian│ <size> bytes │
|
|
27
|
+
# └────────────────────┴───────────────────┴─────────────────────┘
|
|
28
|
+
#
|
|
29
|
+
# RLE record — size field is 0x0000 (zero), which is the signal that
|
|
30
|
+
# this is NOT a normal data record. Instead of inline bytes, the next
|
|
31
|
+
# two fields say "repeat one byte N times":
|
|
32
|
+
# ┌────────────────────┬──────────┬────────────────────┬──────────┐
|
|
33
|
+
# │ offset │ 0x0000 │ count │ value │
|
|
34
|
+
# │ 3 bytes, big-endian│ 2 bytes │ 2 bytes, big-endian│ 1 byte │
|
|
35
|
+
# └────────────────────┴──────────┴────────────────────┴──────────┘
|
|
36
|
+
# Writes `value` repeated `count` times starting at `offset`.
|
|
37
|
+
# e.g. offset=0x100, count=8, value=0xFF → fills 8 bytes with 0xFF.
|
|
38
|
+
#
|
|
39
|
+
# No checksums — no integrity verification.
|
|
40
|
+
class IPS
|
|
41
|
+
EOF_MARKER = "EOF".b.freeze
|
|
42
|
+
|
|
43
|
+
# @param rom [String] binary ROM data
|
|
44
|
+
# @param patch [String] binary IPS patch data
|
|
45
|
+
# @return [String] patched ROM (binary)
|
|
46
|
+
def self.apply(rom, patch, on_progress: nil)
|
|
47
|
+
rom = rom.b
|
|
48
|
+
patch = patch.b
|
|
49
|
+
result = rom.dup
|
|
50
|
+
io = StringIO.new(patch)
|
|
51
|
+
read!(io, 5) # "PATCH"
|
|
52
|
+
|
|
53
|
+
total = patch.bytesize.to_f
|
|
54
|
+
last_pct = -1
|
|
55
|
+
|
|
56
|
+
loop do
|
|
57
|
+
offset_bytes = io.read(3)
|
|
58
|
+
break if offset_bytes.nil? || offset_bytes == EOF_MARKER
|
|
59
|
+
raise "Truncated patch: incomplete offset record" if offset_bytes.bytesize < 3
|
|
60
|
+
|
|
61
|
+
offset = (offset_bytes.getbyte(0) << 16) |
|
|
62
|
+
(offset_bytes.getbyte(1) << 8) |
|
|
63
|
+
offset_bytes.getbyte(2)
|
|
64
|
+
|
|
65
|
+
size = read!(io, 2).unpack1("n") # "n" = 16-bit unsigned big-endian
|
|
66
|
+
|
|
67
|
+
data = if size == 0
|
|
68
|
+
count = read!(io, 2).unpack1("n") # "n" = 16-bit unsigned big-endian
|
|
69
|
+
value = read!(io, 1)
|
|
70
|
+
value * count
|
|
71
|
+
else
|
|
72
|
+
read!(io, size)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Extend ROM if patch writes past current end
|
|
76
|
+
needed = offset + data.bytesize
|
|
77
|
+
result << "\x00".b * (needed - result.bytesize) if needed > result.bytesize
|
|
78
|
+
result[offset, data.bytesize] = data
|
|
79
|
+
|
|
80
|
+
if on_progress
|
|
81
|
+
pct = (io.pos / total * 100).floor
|
|
82
|
+
if pct != last_pct
|
|
83
|
+
on_progress.call(pct / 100.0)
|
|
84
|
+
last_pct = pct
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.read!(io, n)
|
|
93
|
+
data = io.read(n)
|
|
94
|
+
raise "Truncated IPS patch (expected #{n} bytes, got #{data&.bytesize || 0})" \
|
|
95
|
+
if data.nil? || data.bytesize < n
|
|
96
|
+
data
|
|
97
|
+
end
|
|
98
|
+
private_class_method :read!
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'zlib'
|
|
4
|
+
require 'stringio'
|
|
5
|
+
|
|
6
|
+
module Gemba
|
|
7
|
+
class RomPatcher
|
|
8
|
+
# Applies a UPS (Universal Patching System) patch.
|
|
9
|
+
#
|
|
10
|
+
# File layout:
|
|
11
|
+
#
|
|
12
|
+
# ┌──────────────────────────────────────────────────────┐
|
|
13
|
+
# │ "UPS1" (4 bytes, magic) │
|
|
14
|
+
# ├──────────────────────────────────────────────────────┤
|
|
15
|
+
# │ source_size (varint) │
|
|
16
|
+
# │ target_size (varint) │
|
|
17
|
+
# ├──────────────────────────────────────────────────────┤
|
|
18
|
+
# │ hunks … (repeated until patch.size - 12) │
|
|
19
|
+
# ├──────────────────────────────────────────────────────┤
|
|
20
|
+
# │ src_crc32 4B LE │ tgt_crc32 4B LE │ patch │ ← footer
|
|
21
|
+
# └──────────────────────────────────────────────────────┘
|
|
22
|
+
#
|
|
23
|
+
# Each hunk:
|
|
24
|
+
# ┌──────────────────┬──────────────────────────────────┐
|
|
25
|
+
# │ skip (varint) │ xor_data … 0x00 │
|
|
26
|
+
# └──────────────────┴──────────────────────────────────┘
|
|
27
|
+
#
|
|
28
|
+
# skip — advance output position by this many bytes (unchanged bytes)
|
|
29
|
+
# xor_data — each byte XOR'd with the corresponding output byte; 0x00 ends the run
|
|
30
|
+
#
|
|
31
|
+
# Example: source = [AA BB CC DD EE FF]
|
|
32
|
+
# hunk 1: skip=1, xor=[11 22], 0x00
|
|
33
|
+
# result: [AA (BB^11) (CC^22) DD EE FF]
|
|
34
|
+
# ↑ changed ↑ changed
|
|
35
|
+
#
|
|
36
|
+
# UPS varint encoding (7-bit groups, LSB first):
|
|
37
|
+
# Each byte holds 7 data bits (b & 0x7f = 0b01111111) and one flag bit
|
|
38
|
+
# (b & 0x80). Flag=1 means this is the last byte; flag=0 means more follow.
|
|
39
|
+
# value = 0, shift = 0
|
|
40
|
+
# per byte: value |= (b & 0x7f) << shift
|
|
41
|
+
# if bit7 set → done; else shift += 7
|
|
42
|
+
#
|
|
43
|
+
# Example: value 300 decoded from bytes [0x2C, 0x82]
|
|
44
|
+
# raw byte │ & 0x7f │ shift │ value after │ bit7 │ action
|
|
45
|
+
# ──────────┼──────────┼─────────┼───────────────────────┼────────┼──────────
|
|
46
|
+
# 0x2C │ 0x2C │ 0 │ 0x2C (44) │ 0 │ shift += 7
|
|
47
|
+
# 0x82 │ 0x02 │ 7 │ 0x2C│0x100 (300) │ 1 │ break
|
|
48
|
+
class UPS
|
|
49
|
+
# @param rom [String] binary ROM data
|
|
50
|
+
# @param patch [String] binary UPS patch data
|
|
51
|
+
# @return [String] patched ROM (binary)
|
|
52
|
+
# @raise [RuntimeError] on CRC32 mismatch
|
|
53
|
+
def self.apply(rom, patch, on_progress: nil)
|
|
54
|
+
rom = rom.b
|
|
55
|
+
patch = patch.b
|
|
56
|
+
io = StringIO.new(patch)
|
|
57
|
+
io.read(4) # "UPS1"
|
|
58
|
+
|
|
59
|
+
read_varint(io) # source_size — not needed; we derive target from target_size
|
|
60
|
+
target_size = read_varint(io)
|
|
61
|
+
|
|
62
|
+
result = if rom.bytesize >= target_size
|
|
63
|
+
rom[0, target_size].dup
|
|
64
|
+
else
|
|
65
|
+
rom + "\x00".b * (target_size - rom.bytesize)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
pos = 0
|
|
69
|
+
patch_end = patch.bytesize - 12
|
|
70
|
+
last_pct = -1
|
|
71
|
+
|
|
72
|
+
while io.pos < patch_end
|
|
73
|
+
if on_progress
|
|
74
|
+
pct = (io.pos / patch_end.to_f * 100).floor
|
|
75
|
+
if pct != last_pct
|
|
76
|
+
on_progress.call(pct / 100.0)
|
|
77
|
+
last_pct = pct
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
pos += read_varint(io)
|
|
82
|
+
|
|
83
|
+
while io.pos < patch_end
|
|
84
|
+
b = io.read(1).getbyte(0)
|
|
85
|
+
break if b == 0x00
|
|
86
|
+
result.setbyte(pos, (result.getbyte(pos) || 0) ^ b) if pos < result.bytesize
|
|
87
|
+
pos += 1
|
|
88
|
+
end
|
|
89
|
+
pos += 1 # advance past the matching byte at the hunk boundary
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
src_crc, tgt_crc = patch[-12..].unpack("VV")
|
|
93
|
+
raise "UPS source CRC32 mismatch" unless Zlib.crc32(rom) == src_crc
|
|
94
|
+
raise "UPS target CRC32 mismatch" unless Zlib.crc32(result) == tgt_crc
|
|
95
|
+
|
|
96
|
+
result
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# UPS varint: low 7 bits per byte; bit7=1 terminates; simple bitshift accumulation.
|
|
100
|
+
# Decoder: value = 0, shift = 0; per byte: value |= (b & 0x7f) << shift;
|
|
101
|
+
# if bit7: break; else: shift += 7.
|
|
102
|
+
def self.read_varint(io)
|
|
103
|
+
value = 0
|
|
104
|
+
shift = 0
|
|
105
|
+
loop do
|
|
106
|
+
byte = io.read(1)
|
|
107
|
+
raise "Truncated UPS patch (varint read past end)" if byte.nil?
|
|
108
|
+
b = byte.getbyte(0)
|
|
109
|
+
value |= (b & 0x7f) << shift
|
|
110
|
+
break if (b & 0x80) != 0
|
|
111
|
+
shift += 7
|
|
112
|
+
end
|
|
113
|
+
value
|
|
114
|
+
end
|
|
115
|
+
private_class_method :read_varint
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|