diff --git a/.github/workflows/windows-test.yml b/.github/workflows/windows-test.yml index 0a3c8d4..c6cf07f 100644 --- a/.github/workflows/windows-test.yml +++ b/.github/workflows/windows-test.yml @@ -24,7 +24,6 @@ jobs: uses: ruby/setup-ruby@v1 with: ruby-version: ${{ github.event.inputs.ruby_version }} - bundler-cache: true - name: Locate MSYS2 id: msys2 @@ -70,6 +69,29 @@ jobs: mingw-w64-ucrt-x86_64-mgba \ mingw-w64-ucrt-x86_64-imagemagick + - name: Restore Bundler cache + id: bundler-cache + uses: actions/cache/restore@v5 + with: + path: vendor/bundle + key: bundler-windows-ruby${{ github.event.inputs.ruby_version }}-${{ hashFiles('Gemfile.lock') }} + + - name: Bundle install + if: steps.bundler-cache.outputs.cache-hit != 'true' + shell: bash + run: | + MSYS2_ROOT="${{ steps.msys2.outputs.root }}" + export PATH="$MSYS2_ROOT/ucrt64/bin:$PATH" + bundle config set path vendor/bundle + bundle install + + - name: Save Bundler cache + if: always() && steps.bundler-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: vendor/bundle + key: bundler-windows-ruby${{ github.event.inputs.ruby_version }}-${{ hashFiles('Gemfile.lock') }} + - name: Restore compiled extension id: ext-cache uses: actions/cache/restore@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 941d931..bad7f65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,38 @@ All notable changes to gemba will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Added + +- Game Boy and Game Boy Color ROM support (160×144 resolution, correct aspect ratio) +- Input recording and replay — record button inputs to `.gir` files and replay them deterministically +- Video/audio capture to `.grec` files (F10 hotkey) +- Input replay player with dedicated window (open via menu or Cmd/Ctrl+O) +- Open ROM hotkey (Ctrl+O / Cmd+O), context-sensitive when replay player is active +- Settings tabs for save states, recording, and hotkey customization +- Rewind support +- ROM Info window showing title, game code, publisher, platform, resolution +- Session logging +- CLI subcommands for decoding `.grec` and `.gir` files +- ROM Patcher — apply IPS, BPS, and UPS patch files via GUI (View > Patch ROM…) or CLI (`gemba patch`) +- ZIP ROM support in patcher — drag in a zipped ROM and the output is a plain `.gba` +- Mouse cursor auto-hides after 2 seconds of inactivity while a game is playing; restores on movement or pause +- `?` hotkey toggles a floating hotkey reference panel beside the emulator window; emulation auto-pauses while it is open +- Help window auto-pauses emulation while open +- BIOS loading — configure a GBA BIOS file via Settings > System; gemba validates the file size (16 384 bytes), identifies Official GBA BIOS and NDS GBA Mode BIOS by checksum, and copies it to the gemba data directory; "Skip BIOS intro" option available for supported files + +### Changed + +- `.grec` header FPS field now varies by platform (was always GBA; existing files decode fine) +- Clearer UI labels: video/audio capture ("Capture") vs input recording ("Record Inputs") +- Hotkeys for pause, screenshot, and open ROM now work without a ROM loaded + +### Fixed + +- Games no longer start paused on Linux/Windows when window doesn't have focus at startup +- Opening the Logs Directory menu item no longer crashes (platform_open was not loaded by Zeitwerk) + ## [0.1.1] — 2026-02-17 ### Changed diff --git a/assets/placeholder_boxart.png b/assets/placeholder_boxart.png new file mode 100644 index 0000000..cbf5dc3 Binary files /dev/null and b/assets/placeholder_boxart.png differ diff --git a/bin/gemba b/bin/gemba index 241c53c..e5ca250 100755 --- a/bin/gemba +++ b/bin/gemba @@ -1,9 +1,9 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# When running from a development checkout (not installed as a gem), +# When running from a development checkout (not installed as a gem or packed by premo), # add lib/ to the load path and activate Bundler for gem dependencies. -if File.exist?(File.expand_path("../lib/gemba.rb", __dir__)) +if File.exist?(File.expand_path("../.git", __dir__)) require "bundler/setup" path = File.expand_path("../lib", __dir__) $LOAD_PATH.unshift(path) unless $LOAD_PATH.include?(path) diff --git a/ext/gemba/gemba_ext.c b/ext/gemba/gemba_ext.c index 91bb537..8203ff9 100644 --- a/ext/gemba/gemba_ext.c +++ b/ext/gemba/gemba_ext.c @@ -259,8 +259,8 @@ get_mgba_core(VALUE self) static VALUE mgba_core_initialize(int argc, VALUE *argv, VALUE self) { - VALUE rom_path, save_dir; - rb_scan_args(argc, argv, "11", &rom_path, &save_dir); + VALUE rom_path, save_dir, bios_path; + rb_scan_args(argc, argv, "12", &rom_path, &save_dir, &bios_path); struct mgba_core *mc; TypedData_Get_Struct(self, struct mgba_core, &mgba_core_type, mc); @@ -324,9 +324,53 @@ mgba_core_initialize(int argc, VALUE *argv, VALUE self) mDirectorySetMapOptions(&core->dirs, &opts); } + /* 7b. Load BIOS if provided (must be before reset) */ + if (!NIL_P(bios_path)) { + Check_Type(bios_path, T_STRING); + struct VFile *bvf = VFileOpen(StringValueCStr(bios_path), O_RDONLY); + if (bvf) { + if (!core->loadBIOS(core, bvf, 0)) { + bvf->close(bvf); + } + } + } + /* 8. Reset */ core->reset(core); + /* 8b. Re-query dimensions now that the ROM is loaded and the board + * pointer is populated. For GB/GBC, the pre-load query returns the + * SGB frame size (256x224) because core->board is NULL at that point. + * After reset the real model is known, so desiredVideoDimensions + * returns the correct 160x144 for non-SGB games. When the + * dimensions shrink we must reallocate and call setVideoBuffer so + * the stride matches the actual width. */ + { + unsigned w2, h2; + core->desiredVideoDimensions(core, &w2, &h2); + if (w2 != w || h2 != h) { + color_t *new_vbuf = calloc((size_t)w2 * h2, sizeof(color_t)); + uint32_t *new_prev = calloc((size_t)w2 * h2, sizeof(uint32_t)); + if (!new_vbuf || !new_prev) { + free(new_vbuf); + free(new_prev); + free(mc->video_buffer); + mc->video_buffer = NULL; + free(mc->prev_frame); + mc->prev_frame = NULL; + core->deinit(core); + rb_raise(rb_eNoMemError, "failed to reallocate video buffer"); + } + free(mc->video_buffer); + free(mc->prev_frame); + mc->video_buffer = new_vbuf; + mc->prev_frame = new_prev; + core->setVideoBuffer(core, mc->video_buffer, w2); + } + mc->width = (int)w2; + mc->height = (int)h2; + } + /* 9. Autoload save file (.sav alongside ROM, or in save_dir). * Creates the .sav if it doesn't exist yet. */ mCoreAutoloadSave(core); @@ -985,10 +1029,67 @@ mgba_count_changed_pixels(VALUE mod, VALUE delta) return LONG2NUM(changed); } +/* --------------------------------------------------------- */ +/* Core#bios_loaded? */ +/* Returns true if a BIOS VFile is attached to this core. */ +/* GBA only; returns false for other platforms. */ +/* --------------------------------------------------------- */ + +static VALUE +mgba_core_bios_loaded_p(VALUE self) +{ + struct mgba_core *mc = get_mgba_core(self); + if (mc->core->platform(mc->core) != mPLATFORM_GBA) { + return Qfalse; + } + struct GBA *gba = (struct GBA *)mc->core->board; + return gba->biosVf ? Qtrue : Qfalse; +} + /* --------------------------------------------------------- */ /* Init */ /* --------------------------------------------------------- */ +/* --------------------------------------------------------- */ +/* Core#load_bios(path) */ +/* Load a BIOS file from path. Must be called before reset. */ +/* Returns true on success, false on failure. */ +/* --------------------------------------------------------- */ + +static VALUE +mgba_core_load_bios(VALUE self, VALUE rb_path) +{ + struct mgba_core *mc = get_mgba_core(self); + Check_Type(rb_path, T_STRING); + const char *path = StringValueCStr(rb_path); + + struct VFile *vf = VFileOpen(path, O_RDONLY); + if (!vf) { + return Qfalse; + } + + bool ok = mc->core->loadBIOS(mc->core, vf, 0); + if (!ok) { + vf->close(vf); + } + /* mGBA takes ownership of vf on success; do not close */ + return ok ? Qtrue : Qfalse; +} + +/* --------------------------------------------------------- */ +/* Gemba.gba_bios_checksum(bytes) */ +/* Compute GBA BIOS checksum (mGBA algorithm) on raw bytes. */ +/* --------------------------------------------------------- */ + +static VALUE +mgba_gba_bios_checksum(VALUE self, VALUE rb_bytes) +{ + Check_Type(rb_bytes, T_STRING); + long len = RSTRING_LEN(rb_bytes); + uint32_t result = GBAChecksum((uint32_t *)RSTRING_PTR(rb_bytes), (size_t)(len / 4)); + return UINT2NUM(result); +} + void Init_gemba_ext(void) { @@ -1029,6 +1130,13 @@ Init_gemba_ext(void) rb_define_method(cCore, "rewind_count", mgba_core_rewind_count, 0); rb_define_method(cCore, "destroy", mgba_core_destroy, 0); rb_define_method(cCore, "destroyed?", mgba_core_destroyed_p, 0); + rb_define_method(cCore, "load_bios", mgba_core_load_bios, 1); + rb_define_method(cCore, "bios_loaded?", mgba_core_bios_loaded_p, 0); + + /* BIOS checksum utility */ + rb_define_module_function(mGemba, "gba_bios_checksum", mgba_gba_bios_checksum, 1); + rb_define_const(mGemba, "GBA_BIOS_CHECKSUM", UINT2NUM(GBA_BIOS_CHECKSUM)); + rb_define_const(mGemba, "GBA_DS_BIOS_CHECKSUM", UINT2NUM(GBA_DS_BIOS_CHECKSUM)); /* GBA key constants (bitmask values for set_keys) */ rb_define_const(mGemba, "KEY_A", INT2NUM(1 << GEMBA_KEY_A)); @@ -1042,6 +1150,21 @@ Init_gemba_ext(void) rb_define_const(mGemba, "KEY_R", INT2NUM(1 << GEMBA_KEY_R)); rb_define_const(mGemba, "KEY_L", INT2NUM(1 << GEMBA_KEY_L)); + /* GBA button name → bitmask hash (shared by KeyboardMap and GamepadMap) */ + VALUE btn_bits = rb_hash_new(); + rb_hash_aset(btn_bits, ID2SYM(rb_intern("a")), INT2NUM(1 << GEMBA_KEY_A)); + rb_hash_aset(btn_bits, ID2SYM(rb_intern("b")), INT2NUM(1 << GEMBA_KEY_B)); + rb_hash_aset(btn_bits, ID2SYM(rb_intern("l")), INT2NUM(1 << GEMBA_KEY_L)); + rb_hash_aset(btn_bits, ID2SYM(rb_intern("r")), INT2NUM(1 << GEMBA_KEY_R)); + rb_hash_aset(btn_bits, ID2SYM(rb_intern("up")), INT2NUM(1 << GEMBA_KEY_UP)); + rb_hash_aset(btn_bits, ID2SYM(rb_intern("down")), INT2NUM(1 << GEMBA_KEY_DOWN)); + rb_hash_aset(btn_bits, ID2SYM(rb_intern("left")), INT2NUM(1 << GEMBA_KEY_LEFT)); + rb_hash_aset(btn_bits, ID2SYM(rb_intern("right")), INT2NUM(1 << GEMBA_KEY_RIGHT)); + rb_hash_aset(btn_bits, ID2SYM(rb_intern("start")), INT2NUM(1 << GEMBA_KEY_START)); + rb_hash_aset(btn_bits, ID2SYM(rb_intern("select")), INT2NUM(1 << GEMBA_KEY_SELECT)); + OBJ_FREEZE(btn_bits); + rb_define_const(mGemba, "GBA_BTN_BITS", btn_bits); + /* Toast background generator */ rb_define_module_function(mGemba, "toast_background", mgba_toast_background, 3); diff --git a/ext/gemba/gemba_ext.h b/ext/gemba/gemba_ext.h index 729b374..86d1232 100644 --- a/ext/gemba/gemba_ext.h +++ b/ext/gemba/gemba_ext.h @@ -7,6 +7,8 @@ #include #include #include +#include +#include extern VALUE mGemba; diff --git a/gemba.gemspec b/gemba.gemspec index 165cd67..24693b1 100644 --- a/gemba.gemspec +++ b/gemba.gemspec @@ -21,8 +21,9 @@ Gem::Specification.new do |spec| spec.extensions = ["ext/gemba/extconf.rb"] spec.required_ruby_version = ">= 3.2" - spec.add_dependency "teek", ">= 0.1.2" - spec.add_dependency "teek-sdl2", ">= 0.1.3" + spec.add_dependency "teek", ">= 0.1.5" + spec.add_dependency "teek-sdl2", ">= 0.2.1" + spec.add_dependency "zeitwerk", "~> 2.7" spec.add_development_dependency "rake", "~> 13.0" spec.add_development_dependency "rake-compiler", "~> 1.0" @@ -32,6 +33,7 @@ Gem::Specification.new do |spec| spec.add_development_dependency "listen", "~> 3.0" spec.requirements << "libmgba development headers" + spec.add_development_dependency "webmock", "~> 3.0" spec.add_development_dependency "rubyzip", ">= 2.4" spec.requirements << "rubyzip gem >= 2.4 (optional, for loading ROMs from .zip files)" diff --git a/lib/gemba.rb b/lib/gemba.rb index 939e19c..61c221e 100644 --- a/lib/gemba.rb +++ b/lib/gemba.rb @@ -3,18 +3,4 @@ require "teek" require "teek/sdl2" require_relative "gemba/runtime" -require_relative "gemba/child_window" -require_relative "gemba/tip_service" -require_relative "gemba/settings_window" -require_relative "gemba/rom_info_window" -require_relative "gemba/save_state_picker" -require_relative "gemba/save_state_manager" -require_relative "gemba/toast_overlay" -require_relative "gemba/overlay_renderer" -require_relative "gemba/input_mappings" -require_relative "gemba/hotkey_map" -require_relative "gemba/recorder" -require_relative "gemba/input_recorder" -require_relative "gemba/input_replayer" -require_relative "gemba/player" -require_relative "gemba/replay_player" +require_relative "gemba/platform_open" diff --git a/lib/gemba/app_controller.rb b/lib/gemba/app_controller.rb new file mode 100644 index 0000000..19f9200 --- /dev/null +++ b/lib/gemba/app_controller.rb @@ -0,0 +1,906 @@ +# frozen_string_literal: true + +require 'fileutils' + +module Gemba + # Application controller — the brain of the app. + # + # Owns menus, hotkeys, modals, config, rom library, input maps, frame + # lifecycle, and mode tracking. MainWindow is a pure Tk shell that this + # controller drives. + # + # This is what CLI instantiates. + class AppController + include Gemba + include Locale::Translatable + include BusEmitter + + DEFAULT_SCALE = 3 + EVENT_LOOP_FAST_MS = 1 + EVENT_LOOP_IDLE_MS = 50 + GAMEPAD_PROBE_MS = 2000 + GAMEPAD_LISTEN_MS = 50 + + MODAL_LABELS = { + settings: 'menu.settings', + picker: 'menu.save_states', + rom_info: 'menu.rom_info', + replay_player: 'replay.replay_player', + }.freeze + + attr_reader :app, :config, :settings_window, :kb_map, :gp_map, :running, :scale + + def initialize(rom_path = nil, sound: true, fullscreen: false, frames: nil) + @window = MainWindow.new + @app = @window.app + @frame_stack = @window.frame_stack + + Gemba.bus = EventBus.new + + @sound = sound + @config = Gemba.user_config + @scale = @config.scale + @fullscreen = fullscreen + @frame_limit = frames + @platform = Platform.default + @initial_rom = rom_path + @running = true + @rom_path = nil + @gamepad = nil + @rom_library = RomLibrary.new + + @kb_map = KeyboardMap.new(@config) + @gp_map = GamepadMap.new(@config) + @keyboard = VirtualKeyboard.new + @kb_map.device = @keyboard + @hotkeys = HotkeyMap.new(@config) + + check_writable_dirs + + @window.set_timer_speed(EVENT_LOOP_IDLE_MS) + @window.set_geometry(@platform.width * @scale, @platform.height * @scale) + @window.set_title("gemba") + + build_menu + + @modal_stack = ModalStack.new( + on_enter: method(:modal_entered), + on_exit: method(:modal_exited), + on_focus_change: method(:modal_focus_changed), + ) + + dismiss = proc { @modal_stack.pop } + + @rom_info_window = RomInfoWindow.new(@app, callbacks: { + on_dismiss: dismiss, on_close: dismiss, + }) + @state_picker = SaveStatePicker.new(@app, callbacks: { + on_dismiss: dismiss, on_close: dismiss, + }) + @settings_window = SettingsWindow.new(@app, tip_dismiss_ms: @config.tip_dismiss_ms, callbacks: { + on_validate_hotkey: method(:validate_hotkey), + on_validate_kb_mapping: method(:validate_kb_mapping), + on_dismiss: dismiss, on_close: dismiss, + }) + + @settings_window.refresh_gamepad(@kb_map.labels, @kb_map.dead_zone_pct) + @settings_window.refresh_hotkeys(@hotkeys.labels) + push_settings_to_ui + + boxart_backend = BoxartFetcher::LibretroBackend.new + @boxart_fetcher = BoxartFetcher.new(app: @app, cache_dir: Config.boxart_dir, backend: boxart_backend) + @rom_overrides = RomOverrides.new + @game_picker = GamePickerFrame.new(app: @app, rom_library: @rom_library, + boxart_fetcher: @boxart_fetcher, rom_overrides: @rom_overrides) + @frame_stack.push(:picker, @game_picker) + @window.set_geometry(GamePickerFrame::PICKER_DEFAULT_W, GamePickerFrame::PICKER_DEFAULT_H) + @window.set_minsize(GamePickerFrame::PICKER_MIN_W, GamePickerFrame::PICKER_MIN_H) + apply_frame_aspect(@game_picker) + + @help_auto_paused = false + @cursor_hidden = false + @cursor_hide_job = nil + + setup_drop_target + setup_global_hotkeys + setup_bus_subscriptions + setup_cursor_autohide + end + + def run + @app.after(1) { load_rom(@initial_rom) } if @initial_rom + @app.mainloop + ensure + cleanup + end + + def ready? = @initial_rom ? frame&.rom_loaded? : true + + # Current active frame (for tests and external access) + def frame = @frame_stack.current_frame + def current_view = @frame_stack.current + + def running=(val) + @running = val + @emulator_frame&.running = val + return if val + cleanup + @app.command(:destroy, '.') + end + + def disable_confirmations! + @disable_confirmations = true + end + + private + + def confirm(title:, message:) + return true if @disable_confirmations + result = @app.command('tk_messageBox', + parent: '.', + title: title, + message: message, + type: :okcancel, + icon: :warning) + result == 'ok' + end + + # ── Bus subscriptions ────────────────────────────────────────────── + + def setup_bus_subscriptions + bus = Gemba.bus + + # Window-level + bus.on(:scale_changed) { |val| apply_scale(val) } + + # Input maps + bus.on(:gamepad_map_changed) { |btn, gp| active_input.set(btn, gp) } + bus.on(:keyboard_map_changed) { |btn, key| active_input.set(btn, key) } + bus.on(:deadzone_changed) { |val| active_input.set_dead_zone(val) } + bus.on(:gamepad_reset) { active_input.reset! } + bus.on(:keyboard_reset) { active_input.reset! } + bus.on(:undo_gamepad) { undo_mappings } + + # Hotkeys + bus.on(:hotkey_changed) { |action, key| @hotkeys.set(action, key) } + bus.on(:hotkey_reset) { @hotkeys.reset! } + bus.on(:undo_hotkeys) { undo_hotkeys } + + # Settings window actions + bus.on(:settings_save) { save_config } + bus.on(:per_game_toggled) { |val| toggle_per_game(val) } + bus.on(:open_config_dir) { open_config_dir } + bus.on(:open_recordings_dir) { open_recordings_dir } + bus.on(:open_replay_player) { show_replay_player } + + # Frame → controller events + bus.on(:pause_changed) do |paused| + label = paused ? translate('menu.resume') : translate('menu.pause') + @app.command(@emu_menu, :entryconfigure, 0, label: label) + show_cursor if paused + end + bus.on(:recording_changed) { update_recording_menu } + bus.on(:input_recording_changed) { update_input_recording_menu } + bus.on(:request_quit) { self.running = false } + bus.on(:request_escape) { @fullscreen ? toggle_fullscreen : (self.running = false) } + bus.on(:request_fullscreen) { toggle_fullscreen } + bus.on(:request_save_states) { show_state_picker } + bus.on(:request_open_rom) { handle_open_rom } + bus.on(:rom_selected) { |path| load_rom(path) } + bus.on(:rom_quick_load) { |path:, slot:| load_rom(path); frame&.receive(:load_state, slot: slot) } + bus.on(:request_show_fps_toggle) do + frame&.receive(:toggle_show_fps) + show = frame&.show_fps? || false + @app.set_variable(SettingsWindow::VAR_SHOW_FPS, show ? '1' : '0') + end + + # ── ROM loaded reactions ────────────────────────────────────────── + # Config, RomLibrary, and SettingsWindow each subscribe themselves + # via subscribe_to_bus. AppController only handles what it owns. + + bus.on(:rom_loaded) do |**| + refresh_from_config + end + + bus.on(:rom_loaded) do |title:, path:, saves_dir:, **| + @window.set_title("gemba \u2014 #{title}") + @app.command(@view_menu, :entryconfigure, 0, state: :normal) # Game Library + @app.command(@view_menu, :entryconfigure, 2, state: :normal) # ROM Info + [3, 4, 6, 8, 9].each { |i| @app.command(@emu_menu, :entryconfigure, i, state: :normal) } + rebuild_recent_menu + + sav_name = File.basename(path, File.extname(path)) + '.sav' + sav_path = File.join(saves_dir, sav_name) + if File.exist?(sav_path) + @emulator_frame.receive(:show_toast, message: translate('toast.loaded_sav', name: sav_name)) + else + @emulator_frame.receive(:show_toast, message: translate('toast.created_sav', name: sav_name)) + end + end + end + + # ── Menu ─────────────────────────────────────────────────────────── + + def build_menu + menubar = '.menubar' + @app.command(:menu, menubar) + @app.command('.', :configure, menu: menubar) + + # File menu + @app.command(:menu, "#{menubar}.file", tearoff: 0) + @app.command(menubar, :add, :cascade, label: translate('menu.file'), menu: "#{menubar}.file") + + @app.command("#{menubar}.file", :add, :command, + label: translate('menu.open_rom'), accelerator: 'Cmd+O', + command: proc { open_rom_dialog }) + + @recent_menu = "#{menubar}.file.recent" + @app.command(:menu, @recent_menu, tearoff: 0) + @app.command("#{menubar}.file", :add, :cascade, + label: translate('menu.recent'), menu: @recent_menu) + rebuild_recent_menu + + @app.command("#{menubar}.file", :add, :separator) + @app.command("#{menubar}.file", :add, :command, + label: translate('menu.quit'), accelerator: 'Cmd+Q', + command: proc { self.running = false }) + + @app.command(:bind, '.', '', proc { handle_open_rom }) + @app.command(:bind, '.', '', proc { show_settings }) + + # Settings menu + settings_menu = "#{menubar}.settings" + @app.command(:menu, settings_menu, tearoff: 0) + @app.command(menubar, :add, :cascade, label: translate('menu.settings'), menu: settings_menu) + + SettingsWindow::TABS.each do |locale_key, tab_path| + display = translate(locale_key) + accel = locale_key == 'settings.video' ? 'Cmd+,' : nil + opts = { label: "#{display}\u2026", command: proc { show_settings(tab: tab_path) } } + opts[:accelerator] = accel if accel + @app.command(settings_menu, :add, :command, **opts) + end + + # View menu + view_menu = "#{menubar}.view" + @app.command(:menu, view_menu, tearoff: 0) + @app.command(menubar, :add, :cascade, label: translate('menu.view'), menu: view_menu) + + @app.command(view_menu, :add, :command, + label: translate('menu.game_library'), state: :disabled, + command: proc { show_game_library }) + @app.command(view_menu, :add, :command, + label: translate('menu.fullscreen'), accelerator: 'F11', + command: proc { toggle_fullscreen }) + @app.command(view_menu, :add, :command, + label: translate('menu.rom_info'), state: :disabled, + command: proc { show_rom_info }) + @app.command(view_menu, :add, :command, + label: translate('menu.patch_rom'), + command: proc { show_patcher }) + @app.command(view_menu, :add, :separator) + @app.command(view_menu, :add, :command, + label: translate('menu.open_logs_dir'), + command: proc { open_logs_dir }) + @view_menu = view_menu + + # Emulation menu + @emu_menu = "#{menubar}.emu" + @app.command(:menu, @emu_menu, tearoff: 0) + @app.command(menubar, :add, :cascade, label: translate('menu.emulation'), menu: @emu_menu) + + @app.command(@emu_menu, :add, :command, + label: translate('menu.pause'), accelerator: 'P', + command: proc { frame&.receive(:pause) }) + @app.command(@emu_menu, :add, :command, + label: translate('menu.reset'), accelerator: 'Cmd+R', + command: proc { reset_core }) + @app.command(@emu_menu, :add, :separator) + @app.command(@emu_menu, :add, :command, + label: translate('menu.quick_save'), accelerator: 'F5', state: :disabled, + command: proc { frame&.receive(:quick_save) }) + @app.command(@emu_menu, :add, :command, + label: translate('menu.quick_load'), accelerator: 'F8', state: :disabled, + command: proc { frame&.receive(:quick_load) }) + @app.command(@emu_menu, :add, :separator) + @app.command(@emu_menu, :add, :command, + label: translate('menu.save_states'), accelerator: 'F6', state: :disabled, + command: proc { show_state_picker }) + @app.command(@emu_menu, :add, :separator) + @app.command(@emu_menu, :add, :command, + label: translate('menu.start_recording'), accelerator: 'F10', state: :disabled, + command: proc { frame&.receive(:toggle_recording) }) + @app.command(@emu_menu, :add, :command, + label: translate('menu.start_input_recording'), accelerator: 'F4', state: :disabled, + command: proc { frame&.receive(:toggle_input_recording) }) + + @app.command(:bind, '.', '', proc { reset_core }) + end + + def update_recording_menu + recording = frame&.recording? || false + label = recording ? translate('menu.stop_recording') : translate('menu.start_recording') + @app.command(@emu_menu, :entryconfigure, 8, label: label) + end + + def update_input_recording_menu + recording = frame&.input_recording? || false + label = recording ? translate('menu.stop_input_recording') : translate('menu.start_input_recording') + @app.command(@emu_menu, :entryconfigure, 9, label: label) + end + + def rebuild_recent_menu + @app.command(@recent_menu, :delete, 0, :end) rescue nil + + roms = @config.recent_roms + if roms.empty? + @app.command(@recent_menu, :add, :command, + label: translate('player.none'), state: :disabled) + else + roms.each do |rom_path| + label = File.basename(rom_path) + @app.command(@recent_menu, :add, :command, + label: label, + command: proc { open_recent_rom(rom_path) }) + end + @app.command(@recent_menu, :add, :separator) + @app.command(@recent_menu, :add, :command, + label: translate('player.clear'), + command: proc { clear_recent_roms }) + end + end + + def clear_recent_roms + @config.clear_recent_roms + @config.save! + rebuild_recent_menu + end + + # ── Modals ───────────────────────────────────────────────────────── + + def show_game_library + return if @frame_stack.current == :picker + return bell if @modal_stack.active? + + if frame&.rom_loaded? + return unless confirm( + title: translate('dialog.return_to_library_title'), + message: translate('dialog.return_to_library_msg'), + ) + end + + @emulator_frame&.running = false + @emulator_frame&.cleanup + @frame_stack.pop + @emulator_frame = nil + @rom_path = nil + @window.set_title("gemba") + @window.set_geometry(GamePickerFrame::PICKER_DEFAULT_W, GamePickerFrame::PICKER_DEFAULT_H) + @window.set_minsize(GamePickerFrame::PICKER_MIN_W, GamePickerFrame::PICKER_MIN_H) + apply_frame_aspect(@game_picker) + @app.command(@view_menu, :entryconfigure, 0, state: :disabled) + set_event_loop_speed(:idle) + end + + def show_settings(tab: nil) + return bell if @modal_stack.active? + @modal_stack.push(:settings, @settings_window, show_args: { tab: tab }) + end + + def show_state_picker + return unless frame&.save_mgr&.state_dir + return bell if @modal_stack.active? + @modal_stack.push(:picker, @state_picker, + show_args: { state_dir: frame.save_mgr.state_dir, quick_slot: @config.quick_save_slot }) + end + + def show_rom_info + return unless frame&.rom_loaded? + return bell if @modal_stack.active? + saves = @config.saves_dir + sav_name = File.basename(@rom_path, File.extname(@rom_path)) + '.sav' + sav_path = File.join(saves, sav_name) + @modal_stack.push(:rom_info, @rom_info_window, + show_args: { core: frame.core, rom_path: @rom_path, save_path: sav_path }) + end + + def show_replay_player + @replay_player ||= ReplayPlayer.new( + app: @app, + sound: true, + callbacks: { + on_dismiss: proc { @modal_stack.pop }, + on_request_speed: method(:set_event_loop_speed), + } + ) + @modal_stack.push(:replay_player, @replay_player) + end + + def modal_entered(_name) + @was_paused_before_modal = frame&.paused? || false + frame&.receive(:modal_entered) + end + + def modal_exited + frame&.receive(:modal_exited, was_paused: @was_paused_before_modal) + end + + def modal_focus_changed(name) + locale_key = MODAL_LABELS[name] || name.to_s + label = translate(locale_key) + frame&.receive(:modal_focus_changed, message: translate('toast.waiting_for', label: label)) + end + + # ── File handling ────────────────────────────────────────────────── + + def load_rom(path) + rom_path = begin + RomResolver.resolve(path) + rescue RomResolver::NoRomInZip => e + show_rom_error(translate('dialog.no_rom_in_zip', name: e.message)) + return + rescue RomResolver::MultipleRomsInZip => e + show_rom_error(translate('dialog.multiple_roms_in_zip', name: e.message)) + return + rescue RomResolver::UnsupportedFormat => e + show_rom_error(translate('dialog.drop_unsupported_type', ext: e.message)) + return + rescue RomResolver::ZipReadError => e + show_rom_error(translate('dialog.zip_read_error', detail: e.message)) + return + end + + # One-time gamepad subsystem init + unless @gamepad_inited + @gamepad_inited = true + Teek::SDL2::Gamepad.init_subsystem + Teek::SDL2::Gamepad.on_added { |_| refresh_gamepads } + Teek::SDL2::Gamepad.on_removed { |_| @gamepad = nil; @gp_map.device = nil; refresh_gamepads } + refresh_gamepads + start_gamepad_probe + end + + # Create EmulatorFrame (fresh each time after returning from game library) + unless @emulator_frame + @emulator_frame = create_emulator_frame + @emulator_frame.init_sdl2 + @window.fullscreen = true if @fullscreen + end + + # Push emulator onto frame stack (hides picker automatically) + if @frame_stack.current != :emulator + @frame_stack.push(:emulator, @emulator_frame) + @window.reset_minsize + apply_frame_aspect(@emulator_frame) + end + + saves = @config.saves_dir + bios_path = resolve_bios_path + loaded_core = @emulator_frame.load_core(rom_path, saves_dir: saves, bios_path: bios_path, rom_source_path: path) + @rom_path = path + + new_platform = @emulator_frame.platform + @platform = new_platform + apply_scale(@scale) + Gemba.log(:info) { "ROM loaded: #{loaded_core.title} (#{loaded_core.game_code}) [#{@platform.short_name}]" } + + rom_id = Config.rom_id(loaded_core.game_code, loaded_core.checksum) + + emit(:rom_loaded, + rom_id: rom_id, + path: path, + title: loaded_core.title, + game_code: loaded_core.game_code, + platform: @platform.short_name, + saves_dir: saves, + ) + + @emulator_frame.start_animate + end + + def open_rom_dialog + filetypes = '{{GBA ROMs} {.gba}} {{GB ROMs} {.gb .gbc}} {{ZIP Archives} {.zip}} {{All Files} {*}}' + title = translate('menu.open_rom').delete("\u2026") + initial = @rom_path ? File.dirname(@rom_path) : Dir.home + path = @app.tcl_eval("tk_getOpenFile -title {#{title}} -filetypes {#{filetypes}} -initialdir {#{initial}}") + return if path.empty? + return unless confirm_rom_change(path) + load_rom(path) + end + + def handle_open_rom + if @modal_stack.current == :replay_player + open_recordings_dir + else + open_rom_dialog + end + end + + def open_recent_rom(path) + unless File.exist?(path) + @app.command('tk_messageBox', + parent: '.', + title: translate('dialog.rom_not_found_title'), + message: translate('dialog.rom_not_found_msg', path: path), + type: :ok, + icon: :error) + @config.remove_recent_rom(path) + @config.save! + rebuild_recent_menu + return + end + return unless confirm_rom_change(path) + load_rom(path) + end + + def confirm_rom_change(new_path) + return true unless frame&.rom_loaded? + name = File.basename(new_path) + confirm( + title: translate('dialog.game_running_title'), + message: translate('dialog.game_running_msg', name: name), + ) + end + + def setup_drop_target + @app.register_drop_target('.') + @app.bind('.', '<>', :data) do |data| + paths = @app.split_list(data) + handle_dropped_files(paths) + end + end + + def handle_dropped_files(paths) + if paths.length != 1 + @app.command('tk_messageBox', + parent: '.', + title: translate('dialog.drop_error_title'), + message: translate('dialog.drop_single_file_only'), + type: :ok, + icon: :warning) + return + end + + path = paths.first + ext = File.extname(path).downcase + unless RomResolver::SUPPORTED_EXTENSIONS.include?(ext) + @app.command('tk_messageBox', + parent: '.', + title: translate('dialog.drop_error_title'), + message: translate('dialog.drop_unsupported_type', ext: ext), + type: :ok, + icon: :warning) + return + end + + return unless confirm_rom_change(path) + load_rom(path) + end + + def reset_core + return unless @rom_path + load_rom(@rom_path) + end + + # ── Config ───────────────────────────────────────────────────────── + + def resolve_bios_path + name = @config.bios_path + return nil if name.nil? || name.empty? + # Absolute path (e.g. from --bios CLI flag) used directly; + # bare filename looked up in bios_dir (set via Settings UI). + full = File.absolute_path?(name) ? name : File.join(Config.bios_dir, name) + if File.exist?(full) + Gemba.log(:info) { "BIOS: #{File.basename(full)}" } + full + else + Gemba.log(:warn) { "BIOS configured but file not found: #{full}" } + nil + end + end + + def save_config + @config.scale = @scale + frame&.receive(:write_config) + @kb_map.save_to_config + @gp_map.save_to_config + @hotkeys.save_to_config + bios_name = @app.get_variable(SettingsWindow::VAR_BIOS_PATH).to_s.strip + @config.bios_path = bios_name.empty? ? nil : bios_name + @config.skip_bios = @app.get_variable(SettingsWindow::VAR_SKIP_BIOS) == '1' + @config.save! + end + + def push_settings_to_ui + emit(:config_loaded, config: @config) + end + + def refresh_from_config + @scale = @config.scale + apply_scale(@scale) if frame&.sdl2_ready? + frame&.receive(:refresh_from_config) + push_settings_to_ui + end + + def toggle_per_game(enabled) + if enabled + @config.enable_per_game + else + @config.disable_per_game + end + refresh_from_config + end + + # ── Window ───────────────────────────────────────────────────────── + + def toggle_fullscreen + @fullscreen = !@fullscreen + @window.fullscreen = @fullscreen + end + + def create_emulator_frame + EmulatorFrame.new( + app: @app, config: @config, platform: @platform, sound: @sound, + scale: @scale, kb_map: @kb_map, gp_map: @gp_map, + keyboard: @keyboard, hotkeys: @hotkeys, + frame_limit: @frame_limit, + volume: @config.volume / 100.0, + muted: @config.muted?, + turbo_speed: @config.turbo_speed, + turbo_volume: @config.turbo_volume_pct / 100.0, + keep_aspect_ratio: @config.keep_aspect_ratio?, + show_fps: @config.show_fps?, + pixel_filter: @config.pixel_filter, + integer_scale: @config.integer_scale?, + color_correction: @config.color_correction?, + frame_blending: @config.frame_blending?, + rewind_enabled: @config.rewind_enabled?, + rewind_seconds: @config.rewind_seconds, + quick_save_slot: @config.quick_save_slot, + save_state_backup: @config.save_state_backup?, + recording_compression: @config.recording_compression, + pause_on_focus_loss: @config.pause_on_focus_loss?, + ) + end + + def apply_frame_aspect(frame) + if (ratio = frame.aspect_ratio) + @window.set_aspect(*ratio) + else + @window.reset_aspect_ratio + end + end + + def apply_scale(new_scale) + @scale = new_scale.clamp(1, 4) + w = @platform.width * @scale + h = @platform.height * @scale + @window.set_geometry(w, h) + end + + def set_event_loop_speed(mode) + ms = mode == :fast ? EVENT_LOOP_FAST_MS : EVENT_LOOP_IDLE_MS + @window.set_timer_speed(ms) + end + + # ── Gamepad ──────────────────────────────────────────────────────── + + def start_gamepad_probe + @app.after(GAMEPAD_PROBE_MS) { gamepad_probe_tick } + end + + def gamepad_probe_tick + return unless @running + has_gp = @gamepad && !@gamepad.closed? + settings_visible = @app.command(:wm, 'state', SettingsWindow::TOP) != 'withdrawn' rescue false + + if settings_visible && has_gp + Teek::SDL2::Gamepad.update_state + + if @settings_window.listening_for + Teek::SDL2::Gamepad.buttons.each do |btn| + if @gamepad.button?(btn) + @settings_window.capture_mapping(btn) + break + end + end + end + + @app.after(GAMEPAD_LISTEN_MS) { gamepad_probe_tick } + return + end + + unless frame&.rom_loaded? + Teek::SDL2::Gamepad.poll_events rescue nil + end + @app.after(GAMEPAD_PROBE_MS) { gamepad_probe_tick } + end + + def refresh_gamepads + names = [translate('settings.keyboard_only')] + prev_gp = @gamepad + 8.times do |i| + gp = begin; Teek::SDL2::Gamepad.open(i); rescue; nil; end + next unless gp + names << gp.name + @gamepad ||= gp + gp.close unless gp == @gamepad + end + @settings_window&.update_gamepad_list(names) + if @gamepad && @gamepad != prev_gp + Gemba.log(:info) { "Gamepad detected: #{@gamepad.name}" } + @gp_map.device = @gamepad + @gp_map.load_config + end + end + + # ── Input maps ───────────────────────────────────────────────────── + + def active_input + @settings_window.keyboard_mode? ? @kb_map : @gp_map + end + + def undo_mappings + input = active_input + input.reload! + @settings_window.refresh_gamepad(input.labels, input.dead_zone_pct) + end + + def undo_hotkeys + @hotkeys.reload! + @settings_window.refresh_hotkeys(@hotkeys.labels) + end + + def validate_hotkey(hotkey) + return nil if hotkey.is_a?(Array) + @kb_map.labels.each do |gba_btn, key| + return "\"#{hotkey}\" is mapped to GBA button #{gba_btn.upcase}" if key == hotkey + end + nil + end + + def validate_kb_mapping(keysym) + action = @hotkeys.action_for(keysym) + if action + label = action.to_s.tr('_', ' ').capitalize + return "\"#{keysym}\" is assigned to hotkey: #{label}" + end + nil + end + + # ── Global hotkeys (pre-SDL2) ────────────────────────────────────── + + CURSOR_HIDE_MS = 2000 + + def setup_cursor_autohide + @app.bind('.', '') { on_cursor_motion } + end + + def on_cursor_motion + show_cursor + return unless frame&.rom_loaded? && !frame&.paused? + @cursor_hide_job = @app.after(CURSOR_HIDE_MS) { hide_cursor } + end + + def hide_cursor + return if @cursor_hidden + @cursor_hidden = true + Teek::SDL2.hide_cursor if Teek::SDL2.respond_to?(:hide_cursor) + end + + def show_cursor + @app.after_cancel(@cursor_hide_job) if @cursor_hide_job + @cursor_hide_job = nil + return unless @cursor_hidden + @cursor_hidden = false + Teek::SDL2.show_cursor if Teek::SDL2.respond_to?(:show_cursor) + end + + def setup_global_hotkeys + # '?' toggles the hotkey reference panel. Bound on 'all' so it fires even + # when the help window itself has focus after being shown. + @app.bind('all', 'KeyPress-question') { toggle_help } + + @app.bind('.', 'KeyPress', :keysym, '%s') do |k, state_str| + next if frame&.sdl2_ready? || @modal_stack.active? + + if k == 'Escape' + self.running = false + else + mods = HotkeyMap.modifiers_from_state(state_str.to_i) + case @hotkeys.action_for(k, modifiers: mods) + when :quit then self.running = false + when :open_rom then handle_open_rom + end + end + end + end + + # ── Helpers ──────────────────────────────────────────────────────── + + def toggle_help + return if @fullscreen + return if @modal_stack.active? + + @help_window ||= HelpWindow.new(app: @app, hotkeys: @hotkeys) + + if @help_window.visible? + @help_window.hide + frame&.receive(:pause) if @help_auto_paused # toggle back to playing + @help_auto_paused = false + else + @help_auto_paused = frame&.rom_loaded? && !frame&.paused? + frame&.receive(:pause) if @help_auto_paused # pause while reading + @help_window.show + end + end + + def show_patcher + @patcher_window ||= PatcherWindow.new(app: @app) + @patcher_window.show + end + + def bell + @app.command(:bell) + end + + def show_rom_error(message) + @app.command('tk_messageBox', + parent: '.', + title: translate('dialog.drop_error_title'), + message: message, + type: :ok, + icon: :error) + end + + def check_writable_dirs + dirs = { + 'Config' => Config.config_dir, + 'Saves' => @config.saves_dir, + 'Save States' => Config.default_states_dir, + } + + problems = [] + dirs.each do |label, dir| + begin + FileUtils.mkdir_p(dir) + rescue SystemCallError => e + problems << "#{label}: #{dir}\n #{e.message}" + next + end + unless File.writable?(dir) + problems << "#{label}: #{dir}\n Not writable" + end + end + + return if problems.empty? + + msg = "Cannot write to required directories:\n\n#{problems.join("\n\n")}\n\n" \ + "Check file permissions or set a custom path in config." + @app.command(:tk_messageBox, icon: :error, type: :ok, + title: 'gemba', message: msg) + @app.destroy('.') + exit 1 + end + + def open_config_dir + Gemba.open_directory(Config.config_dir) + end + + def open_recordings_dir + Gemba.open_directory(@config.recordings_dir) + end + + def open_logs_dir + Gemba.open_directory(Config.default_logs_dir) + end + + def cleanup + return if @cleaned_up + @cleaned_up = true + @emulator_frame&.cleanup + @game_picker&.cleanup + RomResolver.cleanup_temp + end + end +end diff --git a/lib/gemba/bios.rb b/lib/gemba/bios.rb new file mode 100644 index 0000000..7453337 --- /dev/null +++ b/lib/gemba/bios.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Gemba + # Immutable value object representing a GBA BIOS file. + # The stored config value is just the filename; this object resolves + # it to a full path and computes metadata on demand (memoized). + class Bios + EXPECTED_SIZE = 16_384 + + attr_reader :path + + def initialize(path:) + @path = path + end + + # Build a Bios from a bare filename stored in config. + def self.from_config_name(name) + return nil if name.nil? || name.empty? + new(path: File.join(Config.bios_dir, name)) + end + + def filename = File.basename(@path) + def exists? = File.exist?(@path) + + def size + @size ||= exists? ? File.size(@path) : 0 + end + + def valid? + exists? && size == EXPECTED_SIZE + end + + def checksum + return @checksum if defined?(@checksum) + @checksum = valid? ? Gemba.gba_bios_checksum(File.binread(@path)) : nil + end + + def official? = checksum == GBA_BIOS_CHECKSUM + def ds_mode? = checksum == GBA_DS_BIOS_CHECKSUM + def known? = official? || ds_mode? + + def label + return "Official GBA BIOS" if official? + return "NDS GBA Mode BIOS" if ds_mode? + "Unknown BIOS" + end + + def status_text + return "File not found (#{@path})" unless exists? + return "Invalid size (#{size} bytes, expected #{EXPECTED_SIZE})" unless valid? + "#{label} · #{size} bytes" + end + end +end diff --git a/lib/gemba/boxart_fetcher.rb b/lib/gemba/boxart_fetcher.rb new file mode 100644 index 0000000..3fc2123 --- /dev/null +++ b/lib/gemba/boxart_fetcher.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "net/http" +require "fileutils" + +module Gemba + # Fetches and caches box art images for ROMs. + # + # Delegates URL resolution to a pluggable backend (anything responding to + # +#url_for(game_code)+). Downloads happen off the main thread via + # +Teek::BackgroundWork+ so the UI stays responsive. + # + # Cache layout: + # {cache_dir}/{game_code}/boxart.png + # + # Usage: + # fetcher = BoxartFetcher.new(app: app, cache_dir: Config.boxart_dir, backend: backend) + # fetcher.fetch("AGB-BPEE") { |path| update_card_image(path) } + # + class BoxartFetcher + attr_reader :cache_dir + + def initialize(app:, cache_dir:, backend:) + @app = app + @cache_dir = cache_dir + @backend = backend + @in_flight = {} # game_code => true, prevents duplicate fetches + end + + # Fetch box art for a game code. If cached, yields the path immediately. + # Otherwise kicks off an async download and yields the path on completion. + # + # @param game_code [String] e.g. "AGB-BPEE" + # @yield [path] called on the main thread with the cached file path + # @yieldparam path [String] absolute path to the cached PNG + def fetch(game_code, &on_fetched) + return unless on_fetched + + cached = cached_path(game_code) + if File.exist?(cached) + on_fetched.call(cached) + return + end + + url = @backend.url_for(game_code) + return unless url + return if @in_flight[game_code] + + @in_flight[game_code] = true + + Teek::BackgroundWork.new(@app, { url: url, dest: cached, game_code: game_code }, mode: :thread) do |t, data| + uri = URI(data[:url]) + response = Net::HTTP.get_response(uri) + if response.is_a?(Net::HTTPSuccess) + FileUtils.mkdir_p(File.dirname(data[:dest])) + File.binwrite(data[:dest], response.body) + t.yield(data[:dest]) + else + t.yield(nil) + end + end.on_progress do |path| + @in_flight.delete(game_code) + on_fetched.call(path) if path + end.on_done do + @in_flight.delete(game_code) + end + end + + # @return [String] path where box art would be cached for this game code + def cached_path(game_code) + File.join(@cache_dir, game_code, "boxart.png") + end + + # @return [Boolean] whether box art is already cached + def cached?(game_code) + File.exist?(cached_path(game_code)) + end + end +end diff --git a/lib/gemba/boxart_fetcher/libretro_backend.rb b/lib/gemba/boxart_fetcher/libretro_backend.rb new file mode 100644 index 0000000..a148e97 --- /dev/null +++ b/lib/gemba/boxart_fetcher/libretro_backend.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "uri" + +module Gemba + class BoxartFetcher + # Resolves box art URLs from the LibRetro thumbnails CDN. + # + # URL pattern: + # https://thumbnails.libretro.com/{system}/Named_Boxarts/{encoded_name}.png + # + # Requires game_code → canonical name mapping via GameIndex. + class LibretroBackend + SYSTEMS = { + "AGB" => "Nintendo - Game Boy Advance", + "CGB" => "Nintendo - Game Boy Color", + "DMG" => "Nintendo - Game Boy", + }.freeze + + BASE_URL = "https://thumbnails.libretro.com" + + # @param game_code [String] e.g. "AGB-BPEE" + # @return [String, nil] full URL to the box art PNG, or nil if unknown + def url_for(game_code) + platform = game_code.split("-", 2).first + system = SYSTEMS[platform] + return nil unless system + + name = GameIndex.lookup(game_code) + return nil unless name + + encoded_system = URI.encode_www_form_component(system).gsub("+", "%20") + encoded_name = URI.encode_www_form_component(name).gsub("+", "%20") + + "#{BASE_URL}/#{encoded_system}/Named_Boxarts/#{encoded_name}.png" + end + end + end +end diff --git a/lib/gemba/boxart_fetcher/null_backend.rb b/lib/gemba/boxart_fetcher/null_backend.rb new file mode 100644 index 0000000..e331879 --- /dev/null +++ b/lib/gemba/boxart_fetcher/null_backend.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Gemba + class BoxartFetcher + # No-op backend that never resolves URLs. Used in tests and offline mode. + class NullBackend + def url_for(_game_code) + nil + end + end + end +end diff --git a/lib/gemba/bus_emitter.rb b/lib/gemba/bus_emitter.rb new file mode 100644 index 0000000..73c17e9 --- /dev/null +++ b/lib/gemba/bus_emitter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Gemba + # Include in any class that emits events via Gemba.bus. + # No constructor changes needed — just include and call emit. + module BusEmitter + private + + def emit(event, *args, **kwargs) + Gemba.bus.emit(event, *args, **kwargs) + end + end +end diff --git a/lib/gemba/cli.rb b/lib/gemba/cli.rb index 9b4682c..19872df 100644 --- a/lib/gemba/cli.rb +++ b/lib/gemba/cli.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true require 'optparse' -require_relative 'version' module Gemba class CLI - SUBCOMMANDS = %w[play record decode replay config version].freeze + SUBCOMMANDS = %w[play record decode replay config version patch].freeze # Entry point: dispatch to subcommand or default to play. # @param argv [Array] @@ -34,6 +33,7 @@ def self.main_help record Record video+audio to .grec (headless) decode Encode .grec to video via ffmpeg (--stats for info) replay Replay a .gir input recording + patch Apply an IPS/BPS/UPS patch to a ROM config Show or reset configuration version Show version @@ -80,6 +80,10 @@ def self.parse_play(argv) options[:turbo_speed] = v.clamp(0, 4) end + o.on("--bios PATH", "Path to GBA BIOS file (overrides saved setting)") do |v| + options[:bios] = File.expand_path(v) + end + o.on("--locale LANG", "Language (en, ja, auto)") do |v| options[:locale] = v end @@ -105,6 +109,7 @@ def self.apply(config, options) config.show_fps = true if options[:show_fps] config.turbo_speed = options[:turbo_speed] if options[:turbo_speed] config.locale = options[:locale] if options[:locale] + config.bios_path = options[:bios] if options[:bios] end def self.run_play(argv, dry_run: false) @@ -129,7 +134,7 @@ def self.run_play(argv, dry_run: false) apply(Gemba.user_config, options) Gemba.load_locale if options[:locale] - Player.new(result[:rom], sound: result[:sound], fullscreen: result[:fullscreen]).run + Gemba::AppController.new(result[:rom], sound: result[:sound], fullscreen: result[:fullscreen]).run end # --- record subcommand --- @@ -502,9 +507,9 @@ def self.run_config(argv, dry_run: false) config = Gemba.user_config puts " Scale: #{config.scale}" puts " Volume: #{config.volume}" - puts " Muted: #{config.muted}" + puts " Muted: #{config.muted?}" puts " Locale: #{config.locale}" - puts " Show FPS: #{config.show_fps}" + puts " Show FPS: #{config.show_fps?}" puts " Turbo speed: #{config.turbo_speed}" end end @@ -518,6 +523,74 @@ def self.run_version(_argv, dry_run: false) puts "gemba #{Gemba::VERSION}" end + # --- patch subcommand --- + + def self.parse_patch(argv) + options = {} + + parser = OptionParser.new do |o| + o.banner = "Usage: gemba patch [options] ROM_FILE PATCH_FILE" + o.separator "" + o.separator "Apply an IPS, BPS, or UPS patch to a ROM file." + o.separator "" + o.separator "The output file is written to --output or, by default, next to the ROM." + o.separator "If the output path already exists, -(2), -(3) etc. are appended." + o.separator "" + + o.on("-o", "--output PATH", "Output ROM path") do |v| + options[:output] = File.expand_path(v) + end + + o.on("-h", "--help", "Show this help") do + options[:help] = true + end + end + + parser.parse!(argv) + options[:rom] = File.expand_path(argv[0]) if argv[0] + options[:patch] = File.expand_path(argv[1]) if argv[1] + options[:parser] = parser + options + end + + def self.run_patch(argv, dry_run: false) + options = parse_patch(argv) + + if options[:help] + puts options[:parser] unless dry_run + return { command: :patch, help: true } + end + + unless options[:rom] && options[:patch] + $stderr.puts "gemba patch: ROM_FILE and PATCH_FILE are required" + $stderr.puts options[:parser] + return { command: :patch, error: :missing_args } + end + + rom_path = options[:rom] + patch_path = options[:patch] + out_path = if options[:output] + options[:output] + else + ext = File.extname(rom_path) + base = rom_path.chomp(ext) + "#{base}-patched#{ext}" + end + + result = { command: :patch, rom: rom_path, patch: patch_path, out: out_path } + return result if dry_run + + require_relative "rom_patcher" + require_relative "rom_patcher/ips" + require_relative "rom_patcher/bps" + require_relative "rom_patcher/ups" + + safe_out = RomPatcher.safe_out_path(out_path) + puts "Patching #{File.basename(rom_path)} with #{File.basename(patch_path)}…" + RomPatcher.patch(rom_path: rom_path, patch_path: patch_path, out_path: safe_out) + puts "Written: #{safe_out}" + end + # --- helpers --- def self.run_replay_headless(gir_path, options) diff --git a/lib/gemba/config.rb b/lib/gemba/config.rb index d14617b..7c4bcec 100644 --- a/lib/gemba/config.rb +++ b/lib/gemba/config.rb @@ -50,6 +50,8 @@ class Config 'recording_compression' => 1, 'pause_on_focus_loss' => true, 'log_level' => 'info', + 'bios_path' => nil, + 'skip_bios' => false, }.freeze # Settings that can be overridden per ROM. Maps config key → locale key. @@ -124,9 +126,10 @@ def []=(key, val) }, }.freeze - def initialize(path: nil) + def initialize(path: nil, subscribe: true) @path = path || self.class.default_path @data = load_file + subscribe_to_bus if subscribe end # @return [String] path to the config file @@ -402,6 +405,23 @@ def pause_on_focus_loss=(val) global['pause_on_focus_loss'] = !!val end + # @return [String, nil] BIOS filename (relative to Config.bios_dir), or nil for HLE + def bios_path + global['bios_path'] + end + + def bios_path=(val) + global['bios_path'] = val.nil? ? nil : val.to_s + end + + def skip_bios? + !!global['skip_bios'] + end + + def skip_bios=(val) + global['skip_bios'] = !!val + end + # @return [String] log level (debug, info, warn, error) def log_level global['log_level'] @@ -583,8 +603,36 @@ def self.default_logs_dir File.join(config_dir, 'logs') end + # @return [String] default directory for cached box art images + def self.boxart_dir + File.join(config_dir, 'boxart') + end + + # @return [String] default directory for patched ROMs + def self.default_patches_dir + File.join(config_dir, 'patches') + end + + # @return [String] directory for BIOS files + def self.bios_dir + File.join(config_dir, 'bios') + end + + # @return [String] path to the per-ROM overrides JSON file + def self.rom_overrides_path + File.join(config_dir, 'rom_overrides.json') + end + private + def subscribe_to_bus + Gemba.bus.on(:rom_loaded) do |rom_id:, path:, **| + activate_game(rom_id) + add_recent_rom(path) + save! + end + end + def global @proxy || global_base end diff --git a/lib/gemba/data/gb_games.json b/lib/gemba/data/gb_games.json new file mode 100644 index 0000000..12f4915 --- /dev/null +++ b/lib/gemba/data/gb_games.json @@ -0,0 +1 @@ +{"DMG-APAU":"Pokemon - Red Version (USA, Europe) (SGB Enhanced)","DMG-APEE":"Pokemon - Blue Version (USA, Europe) (SGB Enhanced)","DMG-APSU":"Pokemon - Yellow Version - Special Pikachu Edition (USA, Europe) (CGB+SGB Enhanced)"} \ No newline at end of file diff --git a/lib/gemba/data/gba_games.json b/lib/gemba/data/gba_games.json new file mode 100644 index 0000000..8352b02 --- /dev/null +++ b/lib/gemba/data/gba_games.json @@ -0,0 +1 @@ +{"AGB-0000":"Motocross Challenge (USA) (Proto)","AGB-1234":"Iridion II (USA) (Beta)","AGB-A22J":"EZ-Talk - Shokyuu Hen 1 (Japan)","AGB-A23J":"EZ-Talk - Shokyuu Hen 2 (Japan)","AGB-A24J":"EZ-Talk - Shokyuu Hen 3 (Japan)","AGB-A25J":"EZ-Talk - Shokyuu Hen 4 (Japan)","AGB-A26J":"EZ-Talk - Shokyuu Hen 5 (Japan)","AGB-A27J":"EZ-Talk - Shokyuu Hen 6 (Japan)","AGB-A29J":"Mickey to Minnie no Magical Quest 2 (Japan)","AGB-A2AE":"Disney Sports - Basketball (USA)","AGB-A2AJ":"Disney Sports - Basketball (Japan)","AGB-A2AP":"Disney Sports - Basketball (Europe) (En,Fr,De,Es,It)","AGB-A2BJ":"Bubble Bobble - Old & New (Japan)","AGB-A2CE":"Castlevania - Aria of Sorrow (USA)","AGB-A2CJ":"Castlevania - Akatsuki no Minuet (Japan)","AGB-A2CP":"Castlevania - Aria of Sorrow (Europe) (En,Fr,De)","AGB-A2DJ":"Darius R (Japan) (En)","AGB-A2FE":"Defender (USA)","AGB-A2FP":"Defender - For All Mankind (Europe) (En,Fr,De,Es,It)","AGB-A2GE":"GT Advance 3 - Pro Concept Racing (USA)","AGB-A2GJ":"Advance GT2 (Japan) (En)","AGB-A2GP":"GT Advance 3 - Pro Concept Racing (Europe)","AGB-A2HJ":"Hajime no Ippo - The Fighting! (Japan)","AGB-A2IJ":"Magi Nation (Japan)","AGB-A2JJ":"J.League Winning Eleven Advance 2002 (Japan)","AGB-A2KE":"Spy Kids Challenger (USA)","AGB-A2LE":"Legends of Wrestling II (USA, Europe)","AGB-A2ME":"Madden NFL 2002 (USA)","AGB-A2NE":"Sonic Advance 2 (USA) (Beta)","AGB-A2NJ":"Sonic Advance 2 (Japan) (En,Ja,Fr,De,Es,It)","AGB-A2NP":"Sonic Advance 2 (Europe) (En,Ja,Fr,De,Es,It)","AGB-A2OJ":"K-1 Pocket Grand Prix 2 (Japan)","AGB-A2PJ":"Bouken Yuuki Pluster World - Pluston GP (Japan)","AGB-A2QE":"Monster Rancher Advance 2 (USA)","AGB-A2QJ":"Monster Farm Advance 2 (Japan)","AGB-A2RE":"Bratz (USA) (En,Fr,Es)","AGB-A2RP":"Bratz (Europe) (En,Fr,De,Es,It)","AGB-A2SE":"Spyro 2 - Season of Flame (USA)","AGB-A2SP":"Spyro 2 - Season of Flame (Europe) (En,Fr,De,Es,It)","AGB-A2TE":"Pinball Tycoon (USA)","AGB-A2TP":"Pinball Tycoon (Europe)","AGB-A2UJ":"Mother 1+2 (Japan)","AGB-A2VJ":"Kisekko Gurumii - Chesty to Nuigurumi-tachi no Mahou no Bouken (Japan)","AGB-A2WE":"Star Wars - The New Droid Army (USA)","AGB-A2WP":"Star Wars - The New Droid Army (Europe) (En,Fr,De,Es)","AGB-A2XE":"MX 2002 Featuring Ricky Carmichael (USA, Europe)","AGB-A2YE":"Top Gun - Combat Zones (USA) (En,Fr,De,Es,It)","AGB-A2ZJ":"Zen-Nihon Shounen Soccer Taikai 2 - Mezase Nihon-ichi! (Japan)","AGB-A39J":"Lode Runner (Japan)","AGB-A3AC":"Yaoxi Dao (China)","AGB-A3AE":"Super Mario Advance 3 - Yoshi's Island (USA)","AGB-A3AJ":"Super Mario Advance 3 - Yoshi's Island + Mario Brothers (Japan)","AGB-A3AP":"Super Mario Advance 3 - Yoshi's Island (Europe) (En,Fr,De,Es,It)","AGB-A3BE":"Sabrina the Teenage Witch - Potion Commotion (USA) (En,Fr,Es)","AGB-A3BP":"Sabrina the Teenage Witch - Potion Commotion (Europe) (En,Fr,De,Es,It,Nl)","AGB-A3CE":"Crazy Taxi - Catch a Ride (USA)","AGB-A3CP":"Crazy Taxi - Catch a Ride (Europe) (En,Fr,De,Es,It)","AGB-A3DE":"Disney Sports - Football (USA)","AGB-A3DJ":"Disney Sports - American Football (Japan)","AGB-A3EJ":"Bakuten Shoot Beyblade 2002 - Gekisen! Team Battle!! Kouryuu no Shou - Daichi Hen (Japan)","AGB-A3GJ":"Gyakuten Saiban 2 (Japan)","AGB-A3HJ":"Hime Kishi Monogatari - Princess Blue (Japan)","AGB-A3IJ":"Boktai - The Sun Is in Your Hand (USA) (Sample)","AGB-A3JJ":"Gyakuten Saiban 3 (Japan)","AGB-A3KE":"IK+ (USA)","AGB-A3KP":"IK+ (Europe)","AGB-A3ME":"Magical Quest Starring Mickey & Minnie (USA)","AGB-A3MJ":"Mickey to Minnie no Magical Quest (Japan)","AGB-A3MP":"Magical Quest Starring Mickey & Minnie (Europe) (En,Fr,De,Es,It)","AGB-A3NJ":"Monster Summoner (Japan)","AGB-A3OJ":"Di Gi Charat - DigiCommunication (Japan)","AGB-A3PE":"SEGA Smash Pack (USA)","AGB-A3PP":"SEGA Smash Pack (Europe)","AGB-A3QE":"Sound of Thunder, A (USA) (En,Fr,De,Es,It)","AGB-A3QP":"Sound of Thunder, A (Europe) (En,Fr,De,Es,It)","AGB-A3RE":"Revenge of Shinobi, The (USA)","AGB-A3RP":"Revenge of Shinobi, The (Europe) (En,Fr,De,Es,It)","AGB-A3TE":"Three Stooges, The (USA)","AGB-A3UJ":"Mother 3 (Japan)","AGB-A3VE":"Sonic Pinball Party (USA) (En,Ja,Fr,De,Es,It)","AGB-A3VP":"Sonic Pinball Party (Europe) (En,Ja,Fr,De,Es,It)","AGB-A3WJ":"Bakuten Shoot Beyblade 2002 - Gekisen! Team Battle!! Seiryuu no Shou - Takao Hen (Japan)","AGB-A3XE":"Megaman - Battle Network 3 - Blue Version (USA)","AGB-A3XJ":"Battle Network - Rockman EXE 3 - Black (Japan) (Promo)","AGB-A3XP":"Megaman - Battle Network 3 - Blue Version (Europe)","AGB-A3ZE":"Street Jam Basketball (USA, Europe)","AGB-A4AE":"Simpsons, The - Road Rage (USA, Europe)","AGB-A4AX":"Simpsons, The - Road Rage (Europe) (En,Fr,De,Es,It)","AGB-A4BE":"Monster! Bass Fishing (USA)","AGB-A4BP":"Monster! Bass Fishing (Europe)","AGB-A4CE":"I Spy Challenger! (USA)","AGB-A4DE":"Disney Sports - Skateboarding (USA)","AGB-A4DJ":"Disney Sports - Skateboarding (Japan)","AGB-A4DP":"Disney Sports - Skateboarding (Europe) (En,Fr,De,Es,It)","AGB-A4GE":"Zatchbell! - Electric Arena (USA)","AGB-A4GJ":"Konjiki no Gashbell!! - Unare! Yuujou no Zakeru (Japan)","AGB-A4KJ":"Hamster Club 4 - Shigetchi Daidassou (Japan)","AGB-A4LJ":"Sylvanian Families 4 - Meguru Kisetsu no Tapestry (Japan)","AGB-A4MP":"Manic Miner (Europe) (En,Fr,De,Es,It)","AGB-A4ND":"Harvest Moon - Friends of Mineral Town (Germany)","AGB-A4NE":"Harvest Moon - Friends of Mineral Town (USA)","AGB-A4NJ":"Bokujou Monogatari - Mineral Town no Nakama-tachi (Japan)","AGB-A4NP":"Harvest Moon - Friends of Mineral Town (Europe)","AGB-A4PJ":"Sister Princess - RePure (Japan)","AGB-A4RE":"Rock 'N Roll Racing (USA)","AGB-A4RP":"Rock 'N Roll Racing (Europe)","AGB-A4SJ":"Spyro Advance (Japan)","AGB-A4VJ":"Yuujou no Victory Goal 4v4 Arashi - Get the Goal!! (Japan)","AGB-A4WJ":"Gachasute! Dino Device 2 - Phoenix (Japan)","AGB-A4XJ":"Gachasute! Dino Device 2 - Dragon (Japan)","AGB-A52J":"Koukou Juken Advance Series Eitango Hen - 2000 Words Shuuroku (Japan)","AGB-A53J":"Koukou Juken Advance Series Eijukugo Hen - 650 Phrases Shuuroku (Japan)","AGB-A54J":"Koukou Juken Advance Series Eigo Koubun Hen - 26 Units Shuuroku (Japan)","AGB-A55D":"Wer Wird Millionaer (Germany)","AGB-A55F":"Qui Veut Gagner des Millions (France)","AGB-A55H":"Weekend Miljonairs (Netherlands)","AGB-A55I":"Chi Vuol Essere Milionario (Italy)","AGB-A55P":"Who Wants to Be a Millionaire (Europe)","AGB-A55S":"Quiere Ser Millonario (Spain)","AGB-A55U":"Who Wants to Be a Millionaire (Australia)","AGB-A56J":"Dokidoki Cooking Series 1 - Komugi-chan no Happy Cake (Japan)","AGB-A57J":"Scan Hunter - Sennen Kaigyo o Oe! (Japan)","AGB-A59J":"Toukon Heat (Japan)","AGB-A5AE":"Bionicle - Matoran Adventures (USA, Europe) (En,Fr,De,Es,It,Nl,Sv,Da)","AGB-A5BJ":"Chocobo Land - A Game of Dice (Japan)","AGB-A5CE":"SimCity 2000 (USA)","AGB-A5CP":"SimCity 2000 (Europe) (En,Fr,De,Es,It)","AGB-A5DE":"Disney Sports - Snowboarding (USA)","AGB-A5DJ":"Disney Sports - Snowboarding (Japan)","AGB-A5DP":"Disney Sports - Snowboarding (Europe) (En,Fr,De,Es,It)","AGB-A5GJ":"Dragon Drive - World D Break (Japan)","AGB-A5KJ":"Medarot Ni Core - Kabuto (Japan)","AGB-A5ME":"MLB SlugFest 2004 (USA)","AGB-A5NE":"Donkey Kong Country (USA)","AGB-A5NJ":"Super Donkey Kong (Japan)","AGB-A5NP":"Donkey Kong Country (Europe) (En,Fr,De,Es,It)","AGB-A5PJ":"Power Pro Kun Pocket 5 (Japan)","AGB-A5QJ":"Medarot Ni Core - Kuwagata (Japan)","AGB-A5SJ":"Oshare Wanko (Japan)","AGB-A5TJ":"Shin Megami Tensei II (Japan)","AGB-A5UE":"Space Channel 5 - Ulala's Cosmic Attack (USA)","AGB-A5UP":"Space Channel 5 - Ulala's Cosmic Attack (Europe)","AGB-A5WE":"Rugrats - Go Wild (USA, Europe)","AGB-A5WF":"Razmoket Rencontrent les Delajungle, Les (France)","AGB-A62E":"Megaman Zero 2 (USA)","AGB-A62J":"Rockman Zero 2 (Japan)","AGB-A62P":"Megaman Zero 2 (Europe)","AGB-A63J":"Kawaii Pet Shop Monogatari 3 (Japan)","AGB-A64J":"Shimura Ken no Baka Tonosama - Bakushou Tenka Touitsu Game (Japan)","AGB-A6BE":"Megaman - Battle Network 3 - White Version (USA)","AGB-A6BJ":"Battle Network - Rockman EXE 3 (Japan)","AGB-A6BP":"Megaman - Battle Network 3 - White Version (Europe)","AGB-A6CJ":"Croket! - Yume no Banker Survival! (Japan)","AGB-A6DE":"Disney Sports - Soccer (USA)","AGB-A6DJ":"Disney Sports - Soccer (Japan)","AGB-A6DP":"Disney Sports - Football (Soccer) (Europe) (En,Fr,De,Es,It)","AGB-A6GJ":"Monster Gate - Ooinaru Dungeon - Fuuin no Orb (Japan)","AGB-A6ME":"Megaman & Bass (USA)","AGB-A6MP":"Megaman & Bass (Europe)","AGB-A6OE":"Onimusha Tactics (USA)","AGB-A6OJ":"Onimusha Tactics (Japan)","AGB-A6OP":"Onimusha Tactics (Europe)","AGB-A6RE":"Shifting Gears - Road Trip (USA)","AGB-A6SJ":"Super Robot Taisen D (Japan)","AGB-A6TP":"Dr. Muto (Europe) (En,Fr,De,Es,It)","AGB-A73J":"Whistle! - Dai-37-kai Tokyo-to Chuugakkou Sougou Taiiku Soccer Taikai (Japan)","AGB-A7AE":"Naruto - Ninja Council (USA)","AGB-A7AJ":"Naruto - Ninjutsu Zenkai! Saikyou Ninja Daikesshuu (Japan)","AGB-A7CE":"Dog Trainer 2 (Europe) (DS Cheat Cartridge)","AGB-A7GJ":"Sengoku Kakumei Gaiden (Japan)","AGB-A7HE":"Harry Potter and the Chamber of Secrets (USA, Europe) (En,Fr,De,Es,It,Nl,Pt,Sv,No,Da)","AGB-A7HJ":"Harry Potter to Himitsu no Heya (Japan)","AGB-A7IJ":"Silk to Cotton (Japan)","AGB-A7KE":"Kirby - Nightmare in Dream Land (USA)","AGB-A7KJ":"Hoshi no Kirby - Yume no Izumi Deluxe (Japan)","AGB-A7KP":"Kirby - Nightmare in Dream Land (Europe) (En,Fr,De,Es,It)","AGB-A7ME":"Amazing Virtual Sea-Monkeys, The (USA)","AGB-A7OE":"007 - NightFire (USA, Europe) (En,Fr,De)","AGB-A7SP":"Revenge of the Smurfs, The (Europe) (En,Fr,De,Es,It,Nl)","AGB-A82J":"Hamster Paradise - Pure Heart (Japan)","AGB-A83J":"Hamster Monogatari 3 GBA (Japan)","AGB-A84E":"Hamtaro - Rainbow Rescue (USA) (Proto)","AGB-A84J":"Tottoko Hamutarou 4 - Nijiiro Daikoushin Dechu (Japan)","AGB-A84P":"Hamtaro - Rainbow Rescue (Europe) (En,Fr,De,Es,It)","AGB-A85J":"Sanrio Puroland - All Characters (Japan)","AGB-A86J":"Sonic Pinball Party (Japan) (En,Ja,Fr,De,Es,It)","AGB-A87J":"Ohanaya-san Monogatari GBA - Iyashikei Ohanaya-san Ikusei Game (Japan)","AGB-A88C":"Maliou Yu Luyiji RPG (China) (Proto)","AGB-A88E":"Mario & Luigi - Superstar Saga (USA)","AGB-A88J":"Mario & Luigi RPG (Japan)","AGB-A88P":"Mario & Luigi - Superstar Saga (Europe) (En,Fr,De,Es,It)","AGB-A89E":"Megaman - Battle Chip Challenge (USA)","AGB-A89J":"Rockman EXE - Battle Chip GP (Japan)","AGB-A89P":"Megaman - Battle Chip Challenge (Europe)","AGB-A8BE":"Medabots - Metabee (USA)","AGB-A8BP":"Medabots - Metabee (Europe)","AGB-A8BS":"Medabots - Metabee (Spain)","AGB-A8CJ":"Card Party (Japan)","AGB-A8DJ":"Doubutsu-jima no Chobigurumi (Japan)","AGB-A8EJ":"Hachiemon (Japan)","AGB-A8GJ":"GetBackers Dakkanya - Metropolis Dakkan Sakusen! (Japan)","AGB-A8HE":"Cabela's Big Game Hunter (USA)","AGB-A8LJ":"BB Ball (Japan)","AGB-A8MJ":"Kotoba no Puzzle - Mojipittan Advance (Japan)","AGB-A8NJ":"Hunter X Hunter - Minna Tomodachi Daisakusen!! (Japan)","AGB-A8OJ":"Dokidoki Cooking Series 2 - Gourmet Kitchen - Suteki na Obentou (Japan)","AGB-A8PJ":"Derby Stallion Advance (Japan)","AGB-A8QE":"Pirates of the Caribbean - The Curse of the Black Pearl (USA) (En,Fr,De,Es,It)","AGB-A8QP":"Pirates of the Caribbean - The Curse of the Black Pearl (Europe) (En,Fr,De,Es,It)","AGB-A8RJ":"Tennis no Ouji-sama 2003 - Passion Red (Japan)","AGB-A8SE":"Digimon - Battle Spirit (USA)","AGB-A8SP":"Digimon - Battle Spirit (Europe) (En,Fr,De,Es,It)","AGB-A8TJ":"RPG Tsukuru Advance (Japan)","AGB-A8VJ":"Boboboubo Boubobo - Ougi 87.5 Bakuretsu Hanage Shinken (Japan)","AGB-A8YJ":"Best Play Pro Yakyuu (Japan)","AGB-A8ZJ":"Shin Megami Tensei - Devil Children - Puzzle de Call! (Japan)","AGB-A9AE":"Demon Driver - Time to Burn Rubber! (USA)","AGB-A9AP":"Demon Driver - Time to Burn Rubber! (Europe)","AGB-A9BE":"Medabots - Rokusho (USA)","AGB-A9BP":"Medabots - Rokusho (Europe)","AGB-A9BS":"Medabots - Rokusho (Spain)","AGB-A9CE":"CT Special Forces 2 - Back in the Trenches (USA) (En,Fr,De,Es,It,Nl)","AGB-A9CP":"CT Special Forces - Back to Hell (Europe) (En,Fr,De,Es,It,Nl)","AGB-A9DE":"Doom II (USA)","AGB-A9DP":"Doom II (Europe)","AGB-A9GE":"Stadium Games (USA)","AGB-A9GP":"Stadium Games (Europe)","AGB-A9HJ":"Dragon Quest Monsters - Caravan Heart (Japan)","AGB-A9KJ":"Slime Morimori Dragon Quest - Shougeki no Shippo Dan (Japan)","AGB-A9LJ":"Tennis no Ouji-sama 2003 - Cool Blue (Japan)","AGB-A9ME":"Moto Racer Advance (USA) (En,Fr,De,Es,It)","AGB-A9MP":"Moto Racer Advance (Europe) (En,Fr,De,Es,It)","AGB-A9NE":"Piglet's Big Game (USA)","AGB-A9NX":"Piglet's Big Game (Europe) (En,Fr,De,Es,It,Nl)","AGB-A9PJ":"Tales of the World - Summoner's Lineage (Japan)","AGB-A9QC":"Zhuanzhuanbang Tiantang (China) (Proto)","AGB-A9QJ":"Kururin Paradise (Japan)","AGB-A9RE":"Road Rash - Jailbreak (USA)","AGB-A9RP":"Road Rash - Jailbreak (Europe) (En,Fr,De,Es,It)","AGB-A9SJ":"Dancing Sword - Senkou (Japan)","AGB-A9TJ":"Metal Max 2 Kai (Japan)","AGB-AA2C":"Chaoji Maliou Shijie (China)","AGB-AA2E":"Super Mario Advance 2 - Super Mario World (USA, Australia)","AGB-AA2J":"Super Mario Advance 2 - Super Mario World + Mario Brothers (Japan)","AGB-AA2P":"Super Mario Advance 2 - Super Mario World (Europe) (En,Fr,De,Es)","AGB-AA3E":"All-Star Baseball 2003 (USA)","AGB-AA4J":"Monster Maker 4 - Flash Card (Japan)","AGB-AA5J":"Monster Maker 4 - Killer Dice (Japan)","AGB-AA6E":"Sum of All Fears, The (USA) (En,Fr,De,Es,It)","AGB-AA6P":"Sum of All Fears, The (Europe) (En,Fr,De,Es,It)","AGB-AA7E":"All-Star Baseball 2004 Featuring Derek Jeter (USA)","AGB-AA9E":"Duel Masters - Sempai Legends (USA)","AGB-AA9J":"Duel Masters (Japan)","AGB-AA9P":"Duel Masters - Sempai Legends (Europe) (En,Fr,De,Es,It)","AGB-AABE":"American Bass Challenge (USA)","AGB-AABJ":"Super Black Bass Advance (Japan)","AGB-AABP":"Super Black Bass Advance (Europe)","AGB-AADJ":"Donald Duck Advance (Japan)","AGB-AAEE":"Hey Arnold! - The Movie (USA)","AGB-AAEP":"Hey Arnold! - The Movie (Europe) (En,Fr,De)","AGB-AAGJ":"Angelique (Japan)","AGB-AAHE":"Secret Agent Barbie - Royal Jewels Mission (USA)","AGB-AAHP":"Secret Agent Barbie - Royal Jewels Mission (Europe) (En,Fr,De,Es,It)","AGB-AAIJ":"Gachasute! Dino Device - Red (Japan)","AGB-AAJJ":"Shin Kisekae Monogatari (Japan)","AGB-AAKE":"AirForce Delta Storm (USA) (En,Ja,Fr,De)","AGB-AAKJ":"AirForce Delta II (Japan) (En,Ja,Fr,De)","AGB-AAKP":"Deadly Skies (Europe) (En,Ja,Fr,De)","AGB-AALJ":"Kidou Tenshi Angelic Layer - Misaki to Yume no Tenshi-tachi (Japan)","AGB-AAME":"Castlevania - Circle of the Moon (USA)","AGB-AAMJ":"Akumajou Dracula - Circle of the Moon (China)","AGB-AAMP":"Castlevania (Europe)","AGB-AANJ":"Animal Mania - Dokidoki Aishou Check (Japan)","AGB-AAOE":"Aero the Acro-Bat (USA)","AGB-AAOJ":"Acrobat Kid (Japan)","AGB-AAOP":"Aero the Acro-Bat (Europe)","AGB-AAPJ":"Metalgun Slinger (Japan)","AGB-AAQE":"Animal Snap - Rescue Them 2 by 2 (USA)","AGB-AAQP":"Animal Snap - Rescue Them 2 by 2 (Europe)","AGB-AARE":"Altered Beast - Guardian of the Realms (USA)","AGB-AARP":"Altered Beast - Guardian of the Realms (Europe) (En,Fr,De,Es,It)","AGB-AASJ":"World Advance Soccer - Shouri e no Michi (Japan)","AGB-AATJ":"Family Tennis Advance (Japan)","AGB-AAUJ":"Shin Megami Tensei (Japan)","AGB-AAVE":"Atari Anniversary Advance (USA)","AGB-AAVP":"Atari Anniversary Advance (Europe)","AGB-AAWE":"Contra Advance - The Alien Wars EX (USA)","AGB-AAWJ":"Contra Hard Spirits (Japan) (En)","AGB-AAWP":"Contra Advance - The Alien Wars EX (Europe)","AGB-AAXJ":"Fantastic Maerchen - Cake-ya-san Monogatari + Doubutsu Chara Navi Uranai - Kosei Shinri Gaku (Japan)","AGB-AAYE":"Mary-Kate and Ashley - Sweet 16 - Licensed to Drive (USA, Europe)","AGB-AAZJ":"Ao-Zora to Nakama-tachi - Yume no Bouken (Japan)","AGB-AB2E":"Breath of Fire II (USA)","AGB-AB2J":"Breath of Fire II - Shimei no Ko (Japan)","AGB-AB2P":"Breath of Fire II (Europe)","AGB-AB3E":"Dave Mirra Freestyle BMX 3 (USA, Europe)","AGB-AB4E":"Summon Night - Swordcraft Story (USA)","AGB-AB4J":"Summon Night - Craft Sword Monogatari (Japan)","AGB-AB6P":"Black Belt Challenge (Europe)","AGB-AB7J":"Minna no Shiiku Series 1 - Boku no Kabutomushi (Japan)","AGB-AB8J":"Bakuten Shoot Beyblade 2002 - Ikuze! Bakutou! Chou Jiryoku Battle!! (Japan)","AGB-AB9E":"Dual Blades (USA)","AGB-AB9J":"Dual Blades (Japan)","AGB-ABCJ":"Boku wa Koukuu Kanseikan (Japan) (Rev 1)","AGB-ABDE":"Boulder Dash EX (USA)","AGB-ABDJ":"Boulder Dash EX (Japan)","AGB-ABDP":"Boulder Dash EX (Europe) (En,Fr,De)","AGB-ABEE":"BattleBots - Beyond the BattleBox (USA)","AGB-ABEP":"BattleBots - Beyond the BattleBox (Europe) (En,Fr,De)","AGB-ABFE":"Breath of Fire (USA)","AGB-ABFJ":"Breath of Fire - Ryuu no Senshi (Japan)","AGB-ABFP":"Breath of Fire (Europe)","AGB-ABFX":"Breath of Fire (Europe) (En,Fr,De)","AGB-ABGJ":"Sweet Cookie Pie (Japan)","AGB-ABIJ":"Gachasute! Dino Device - Blue (Japan)","AGB-ABJE":"Broken Sword - The Shadow of the Templars (USA) (En,Fr,De,Es,It)","AGB-ABJP":"Broken Sword - The Shadow of the Templars (Europe) (En,Fr,De,Es,It)","AGB-ABKE":"Back Track (USA, Europe)","AGB-ABME":"Super Bust-A-Move (USA) (En,Fr,Es)","AGB-ABMJ":"Super Puzzle Bobble Advance (Japan) (En)","AGB-ABMP":"Get's!! Loto Club (Japan) (Proto)","AGB-ABNE":"NBA Jam 2002 (USA, Europe)","AGB-ABOE":"Boxing Fever (USA, Europe)","AGB-ABPE":"Baseball Advance (USA)","AGB-ABQE":"David Beckham Soccer (USA) (En,Es)","AGB-ABQP":"David Beckham Soccer (Europe) (En,Fr,De,Es,It)","AGB-ABRE":"Blender Bros. (USA)","AGB-ABSE":"Bomberman Tournament (USA, Europe)","AGB-ABSJ":"Bomberman Story (Japan)","AGB-ABTE":"Batman - Vengeance (USA) (En,Fr,Es)","AGB-ABTP":"Batman - Vengeance (Europe) (En,Fr,De,Es,It,Nl)","AGB-ABUE":"Ultimate Brain Games (USA, Europe)","AGB-ABVP":"Maya the Bee - The Great Adventure (Europe) (En,Fr,De,Es,It)","AGB-ABWE":"Butt-Ugly Martians - B.K.M. Battles (USA)","AGB-ABYE":"Britney's Dance Beat (USA)","AGB-ABYP":"Britney's Dance Beat (Europe)","AGB-ABYX":"Britney's Dance Beat (Europe) (En,Fr)","AGB-ABYY":"Britney's Dance Beat (Europe) (En,De)","AGB-ABZE":"NFL Blitz 2002 (USA)","AGB-AC2J":"J.League Pro Soccer Club o Tsukurou! Advance (Japan)","AGB-AC4J":"Meitantei Conan - Nerawareta Tantei (Japan)","AGB-AC5E":"DemiKids - Dark Version (USA)","AGB-AC5J":"Shin Megami Tensei Devil Children - Yami no Sho (Japan)","AGB-AC6D":"Spirit - Der Wilde Mustang - Auf der Suche nach Homeland (Germany)","AGB-AC6E":"Spirit - Stallion of the Cimarron - Search for Homeland (USA)","AGB-AC6F":"Spirit - L'Etalon des Plaines - A la Recherche de la Terre Natale (France)","AGB-AC6P":"Spirit - Stallion of the Cimarron - Search for Homeland (Europe)","AGB-AC7E":"CT Special Forces (USA) (En,Fr,De,Es,It,Nl)","AGB-AC7P":"CT Special Forces (Europe) (En,Fr,De,Es,It,Nl)","AGB-AC8E":"Crash Bandicoot 2 - N-Tranced (USA)","AGB-AC8J":"Crash Bandicoot Advance 2 - Guruguru Saimin Dai-panic! (Japan)","AGB-AC8P":"Crash Bandicoot 2 - N-Tranced (Europe) (En,Fr,De,Es,It,Nl)","AGB-AC9E":"Cartoon Network Block Party (USA)","AGB-ACAE":"GT Advance - Championship Racing (USA, Europe)","AGB-ACBE":"Car Battler Joe (USA)","AGB-ACBJ":"Gekitou! Car Battler Go!! (Japan)","AGB-ACCE":"Crazy Chase (USA)","AGB-ACCP":"Crazy Chase (Europe) (En,Fr,De)","AGB-ACEE":"Agassi Tennis Generation (USA)","AGB-ACEP":"Agassi Tennis Generation (Europe) (En,Fr,De,Es,It)","AGB-ACFE":"Cruis'n Velocity (USA, Europe)","AGB-ACGE":"Columns Crown (USA)","AGB-ACGJ":"Columns Crown (Japan)","AGB-ACGP":"Columns Crown (Europe)","AGB-ACHE":"Castlevania - Harmony of Dissonance (USA)","AGB-ACHJ":"Castlevania - Byakuya no Concerto (Japan)","AGB-ACHP":"Castlevania - Harmony of Dissonance (Europe)","AGB-ACIJ":"WTA Tour Tennis Pocket (Japan)","AGB-ACJJ":"Chou Makaimura R (Japan)","AGB-ACKE":"Backyard Baseball (USA)","AGB-ACLJ":"Sakura Momoko no UkiUki Carnival (Japan)","AGB-ACME":"Colin McRae Rally 2.0 (USA) (En,Fr,De)","AGB-ACMP":"Colin McRae Rally 2.0 (Europe) (En,Fr,De)","AGB-ACOJ":"Manga-ka Debut Monogatari - Akogare! Manga Ka Ikusei Game! (Japan)","AGB-ACPE":"Caesars Palace Advance - Millennium Gold Edition (USA, Europe)","AGB-ACQE":"Crash Bandicoot - The Huge Adventure (USA)","AGB-ACQP":"Crash Bandicoot XS (Europe) (En,Fr,De,Es,It,Nl)","AGB-ACRE":"ChuChu Rocket! (USA) (En,Ja,Fr,De,Es)","AGB-ACRJ":"ChuChu Rocket! (Japan) (En,Ja,Fr,De,Es)","AGB-ACRP":"ChuChu Rocket! (Europe) (En,Ja,Fr,De,Es)","AGB-ACSE":"Casper (USA) (En,Fr,Es)","AGB-ACSP":"Casper (Europe) (En,Fr,De,Es,It,Nl,Pt)","AGB-ACTX":"Creatures (Europe) (En,Fr,De)","AGB-ACTY":"Creatures (Europe) (En,Es,It)","AGB-ACUJ":"Crash Bandicoot Advance (Japan)","AGB-ACVE":"Robopon 2 - Cross Version (USA)","AGB-ACVJ":"Robot Poncots 2 - Cross Version (Japan)","AGB-ACXE":"Cubix - Robots for Everyone - Clash 'N Bash (USA)","AGB-ACYD":"Chessmaster (Germany)","AGB-ACYE":"Chessmaster (USA)","AGB-ACYF":"Chessmaster (France)","AGB-ACYP":"Chessmaster (Europe)","AGB-ACZP":"Comix Zone (Europe) (En,Fr,De,Es,It)","AGB-AD2J":"Mr. Driller 2 (Japan)","AGB-AD2P":"Mr. Driller 2 (Europe)","AGB-AD3E":"Dinotopia - The Timestone Pirates (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-AD4E":"Dungeons & Dragons - Eye of the Beholder (USA)","AGB-AD4P":"Dungeons & Dragons - Eye of the Beholder (Europe) (En,Fr,De,Es,It)","AGB-AD5J":"Mr. Driller A - Fushigi na Pacteria (Japan)","AGB-AD6E":"Davis Cup (USA) (En,Fr,De,Es,It)","AGB-AD6P":"Davis Cup (Europe) (En,Fr,De,Es,It)","AGB-AD7P":"Droopy's Tennis Open (Europe) (En,Fr,De,Es,It,Nl)","AGB-AD9E":"Duke Nukem Advance (USA)","AGB-AD9P":"Duke Nukem Advance (Europe) (En,Fr,De,It)","AGB-ADAE":"Dark Arena (USA, Europe)","AGB-ADBE":"Denki Blocks! (USA) (En,Es)","AGB-ADBJ":"Denki Blocks! (Japan)","AGB-ADBP":"Denki Blocks! (Europe) (En,Fr,De,Es,It)","AGB-ADDJ":"Diadroids World - Evil Teikoku no Yabou (Japan)","AGB-ADEJ":"Adventure of Tokyo Disney Sea (Japan)","AGB-ADFE":"Super Dodge Ball Advance (USA)","AGB-ADFJ":"Bakunetsu Dodge Ball Fighters (Japan)","AGB-ADFP":"Super Dodge Ball Advance (Europe)","AGB-ADHE":"Defender of the Crown (USA)","AGB-ADHP":"Defender of the Crown (Europe)","AGB-ADIE":"Desert Strike Advance (USA)","AGB-ADKE":"Donald Duck Advance (USA)","AGB-ADKP":"Donald Duck Advance (Europe) (En,Fr,De,Es,It)","AGB-ADLE":"Dexter's Laboratory - Deesaster Strikes! (USA) (En,Fr,De,Es,It)","AGB-ADLP":"Dexter's Laboratory - Deesaster Strikes! (Europe) (En,Fr,De,Es,It)","AGB-ADME":"Doom (USA, Europe)","AGB-ADNE":"Jurassic Park III - The DNA Factor (USA)","AGB-ADNJ":"Jurassic Park III - Ushinawareta Idenshi (Japan)","AGB-ADNP":"Jurassic Park III - The DNA Factor (Europe) (En,Fr,De,Es,It)","AGB-ADOJ":"Domo-kun no Fushigi Television (Japan)","AGB-ADPJ":"Doraemon - Dokodemo Walker (Japan)","AGB-ADQE":"Dokapon - Monster Hunter (USA)","AGB-ADQJ":"Dokapon Q - Monster Hunter! (Japan)","AGB-ADQP":"Dokapon - Monster Hunter! (Europe) (En,Fr,De)","AGB-ADRJ":"Doraemon - Midori no Wakusei Dokidoki Daikyuushutsu! (Japan)","AGB-ADSJ":"Daisenryaku for Game Boy Advance (Japan)","AGB-ADUE":"Driver 2 Advance (USA)","AGB-ADUP":"Driver 2 Advance (Europe) (En,Fr,De,Es,It)","AGB-ADVE":"Driven (USA) (En,Fr,De,Es,It)","AGB-ADVP":"Driven (Europe) (En,Fr,De,Es,It)","AGB-ADWE":"Downforce (Europe) (En,Fr,De,Es,It)","AGB-ADXE":"Dexter's Laboratory - Chess Challenge (USA)","AGB-ADXP":"Dexter's Laboratory - Chess Challenge (Europe) (En,Fr,De,Es)","AGB-ADYJ":"Hanafuda Trump Mahjong - Depachika Wayouchuu (Japan)","AGB-ADZE":"Dragon Ball Z - Collectible Card Game (USA)","AGB-AE2E":"Megaman - Battle Network 2 (USA)","AGB-AE2J":"Battle Network - Rockman EXE 2 (Japan)","AGB-AE3E":"Ed, Edd n Eddy - Jawbreakers! (USA)","AGB-AE3P":"Ed, Edd n Eddy - Jawbreakers! (Europe) (En,Fr,De,Es,It)","AGB-AE7E":"Fire Emblem (USA) (Virtual Console)","AGB-AE7J":"Fire Emblem - Rekka no Ken (Japan)","AGB-AE7X":"Fire Emblem (Europe) (En,Fr,De)","AGB-AE7Y":"Fire Emblem (Europe) (En,Es,It)","AGB-AEAJ":"Snap Kid's (Japan)","AGB-AECJ":"Samurai Evolution - Oukoku Geist (Japan)","AGB-AEDP":"Carrera Power Slide (Europe) (En,Fr,De,Es,It,Nl)","AGB-AEEE":"Ballistic - Ecks vs. Sever (USA)","AGB-AEEP":"Ecks vs. Sever II - Ballistic (Europe) (En,Fr,De,Es,It)","AGB-AEGE":"Extreme Ghostbusters - Code Ecto-1 (USA)","AGB-AEGP":"Extreme Ghostbusters - Code Ecto-1 (Europe) (En,Fr,De,Es,It,Pt)","AGB-AEHJ":"Puzzle & Tantei Collection (Japan)","AGB-AEJE":"Aero the Acro-Bat (USA) (Beta 1)","AGB-AEKJ":"Elemix! (Japan)","AGB-AELP":"European Super League (Europe) (En,Fr,De,Es,It)","AGB-AEME":"Egg Mania (USA) (En,Fr,Es)","AGB-AEMJ":"Egg Mania - Tsukande! Mawashite! Dossun Puzzle!! (Japan)","AGB-AEMP":"Eggo Mania (Europe) (En,Fr,De,Es,It,Nl)","AGB-AENE":"Serious Sam Advance (USA) (En,Fr,De)","AGB-AENP":"Serious Sam Advance (Europe) (En,Fr,De)","AGB-AEPP":"Sheep (Europe) (En,Fr,De,Es,It)","AGB-AERE":"Dora the Explorer - The Search for the Pirate Pig's Treasure (USA)","AGB-AESE":"Ecks vs Sever (USA) (En,Fr,De,Es,It)","AGB-AESP":"Ecks V Sever (Europe) (En,Fr,De,Es,It)","AGB-AETE":"E.T. - The Extra-Terrestrial (USA)","AGB-AETP":"E.T. - The Extra-Terrestrial (Europe) (En,Fr,De,Es,It,Nl)","AGB-AEVE":"Alienators - Evolution Continues (USA, Europe)","AGB-AEWJ":"Ui-Ire - World Soccer Winning Eleven (Japan)","AGB-AEXE":"King of Fighters EX 2, The - Howling Blood (USA)","AGB-AEXJ":"King of Fighters EX 2, The - Howling Blood (Japan) (Rev 1)","AGB-AEYE":"Kim Possible - Revenge of Monkey Fist (USA)","AGB-AEYP":"Kim Possible (Europe) (En,Fr,De,Es)","AGB-AF3J":"Zero One (Japan)","AGB-AF4J":"Fushigi no Kuni no Alice (Japan)","AGB-AF5E":"Shining Force - Resurrection of the Dark Dragon (USA)","AGB-AF5J":"Shining Force - Kuroki Ryuu no Fukkatsu (Japan)","AGB-AF5P":"Shining Force - Resurrection of the Dark Dragon (Europe) (En,Fr,De,Es,It)","AGB-AF6E":"Fairly OddParents!, The - Breakin' da Rules (USA)","AGB-AF7J":"Tokimeki Yume Series 1 - Ohanaya-san ni Narou! (Japan)","AGB-AF8E":"F1 2002 (USA, Europe)","AGB-AF8X":"F1 2002 (Europe) (En,Fr,De,Es,It)","AGB-AF9J":"Field of Nine - Digital Edition 2001 (Japan)","AGB-AFAJ":"Fushigi no Kuni no Angelique (Japan)","AGB-AFBE":"Frogger's Adventures 2 - The Lost Wand (USA) (En,Es)","AGB-AFBJ":"Frogger - Mahou no Kuni no Daibouken (Japan)","AGB-AFBP":"Frogger's Adventures 2 - The Lost Wand (Europe) (En,Fr,De,Es,It)","AGB-AFCJ":"Rockman & Forte (Japan)","AGB-AFEC":"Huo-Wen Zhanji - Fengyin Zhi Jian (China) (Proto)","AGB-AFEJ":"Fire Emblem - Fuuin no Tsurugi (Japan)","AGB-AFFE":"Final Fight One (USA)","AGB-AFFJ":"Final Fight One (Japan)","AGB-AFFP":"Final Fight One (Europe)","AGB-AFGE":"American Tail, An - Fievel's Gold Rush (USA) (En,Es)","AGB-AFGP":"American Tail, An - Fievel's Gold Rush (Europe) (En,Fr,De,Es,It)","AGB-AFHE":"Strike Force Hydra (USA)","AGB-AFHP":"Strike Force Hydra (Europe)","AGB-AFJE":"FIFA Soccer 2003 (USA, Europe) (En,Fr,De,Es,It)","AGB-AFLP":"FILA Decathlon (Europe) (En,Fr,De,Es,It,Sv)","AGB-AFMJ":"Formation Soccer 2002 (Japan)","AGB-AFNJ":"Angel Collection - Mezase! Gakuen no Fashion Leader (Japan)","AGB-AFOE":"Fortress (USA, Europe)","AGB-AFPE":"Fire Pro Wrestling (USA, Europe)","AGB-AFPJ":"Fire Pro Wrestling A (Japan)","AGB-AFQE":"Frogger Advance - The Great Quest (USA)","AGB-AFQP":"Frogger Advance - The Great Quest (Europe) (En,Fr,De,Es,It)","AGB-AFRE":"Frogger's Adventures - Temple of the Frog (USA) (En,Fr,De,Es,It)","AGB-AFRP":"Frogger's Adventures - Temple of the Frog (Europe) (En,Fr,De,Es,It)","AGB-AFSE":"Flintstones, The - Big Trouble in Bedrock (USA)","AGB-AFSP":"Flintstones, The - Big Trouble in Bedrock (Europe) (En,Fr,De,Es,It)","AGB-AFTE":"F-14 Tomcat (USA, Europe)","AGB-AFUJ":"Youkaidou (Japan)","AGB-AFVE":"Fairly OddParents!, The - Enter the Cleft (USA)","AGB-AFWJ":"Final Fire Pro Wrestling - Yume no Dantai Unei! (Japan)","AGB-AFXE":"Final Fantasy Tactics Advance (USA)","AGB-AFXJ":"Final Fantasy Tactics Advance (Japan)","AGB-AFXP":"Final Fantasy Tactics Advance (Europe) (En,Fr,De,Es,It)","AGB-AFYE":"Fire Pro Wrestling 2 (USA)","AGB-AFZC":"Jisu F-Zero Weilai Saiche (China)","AGB-AFZE":"F-Zero - Maximum Velocity (USA, Europe)","AGB-AFZJ":"F-Zero for Game Boy Advance (Japan)","AGB-AG2J":"Kami no Kijutsu - Illusion of the Evil Eyes (Japan)","AGB-AG4E":"Godzilla - Domination! (USA)","AGB-AG4J":"Gojira - Kaijuu Dairantou Advance (Japan)","AGB-AG4P":"Godzilla - Domination! (Europe) (En,Fr,De,Es,It)","AGB-AG5E":"Super Ghouls'n Ghosts (USA) (Virtual Console)","AGB-AG6J":"Mugenborg (Japan)","AGB-AG7J":"Advance GTA (Japan) (En)","AGB-AG8E":"Galidor - Defenders of the Outer Dimension (USA) (En,Fr,De,Es,It,Nl,Sv,Da)","AGB-AG9J":"Greatest Nine (Japan)","AGB-AGAE":"Gradius Galaxies (USA)","AGB-AGAJ":"Gradius Generation (Japan)","AGB-AGAP":"Gradius Advance (Europe)","AGB-AGBJ":"Grim Adventures of Billy & Mandy, The (USA) (Beta)","AGB-AGCJ":"Guru Logichamp (Japan)","AGB-AGDE":"Lufia - The Ruins of Lore (USA)","AGB-AGDJ":"Chinmoku no Iseki - Estpolis Gaiden (Japan)","AGB-AGEE":"Gekido Advance - Kintaro's Revenge (USA)","AGB-AGEP":"Gekido Advance - Kintaro's Revenge (Europe) (En,Fr,De,Es,It)","AGB-AGFD":"Golden Sun - Die Vergessene Epoche (Germany)","AGB-AGFE":"Golden Sun - The Lost Age (USA, Europe)","AGB-AGFF":"Golden Sun - L'Age Perdu (France)","AGB-AGFI":"Golden Sun - L'Era Perduta (Italy)","AGB-AGFJ":"Ougon no Taiyou - Ushinawareshi Toki (Japan)","AGB-AGFS":"Golden Sun - La Edad Perdida (Spain)","AGB-AGGE":"Gremlins - Stripe vs Gizmo (USA)","AGB-AGGP":"Gremlins - Stripe vs Gizmo (Europe) (En,Fr,De,Es,It,Pt)","AGB-AGHJ":"Medarot G - Kabuto (Japan)","AGB-AGIJ":"Medarot G - Kuwagata (Japan)","AGB-AGKJ":"Gensou Suikoden - Card Stories (Japan)","AGB-AGLC":"Fanqiejiang Wangguo Da Maoxian (China) (Proto)","AGB-AGLJ":"Tomato Adventure (Japan)","AGB-AGMJ":"JGTO Kounin Golf Master Mobile - Japan Golf Tour Game (Japan)","AGB-AGNJ":"Goemon - New Age Shutsudou! (Japan)","AGB-AGOJ":"Kurohige no Golf Shiyouyo (Japan)","AGB-AGPE":"No Rules - Get Phat (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-AGQP":"Go! Go! Beckham! - Adventure on Soccer Island (Europe) (En,Fr,De,Es,It)","AGB-AGRE":"ESPN Final Round Golf 2002 (USA)","AGB-AGRJ":"JGTO Kounin Golf Master - Japan Golf Tour Game (Japan)","AGB-AGRP":"ESPN Final Round Golf (Europe)","AGB-AGSD":"Golden Sun (Germany)","AGB-AGSE":"Golden Sun (USA, Europe)","AGB-AGSF":"Golden Sun (France)","AGB-AGSI":"Golden Sun (Italy)","AGB-AGSJ":"Ougon no Taiyou - Hirakareshi Fuuin (Japan)","AGB-AGSS":"Golden Sun (Spain)","AGB-AGTJ":"Zen-Nihon GT Senshuken (Japan)","AGB-AGUE":"Masters of the Universe - He-Man - Power of Grayskull (USA)","AGB-AGVJ":"Ghost Trap (Japan)","AGB-AGWE":"GT Advance 2 - Rally Racing (USA)","AGB-AGWP":"GT Advance 2 - Rally Racing (Europe)","AGB-AGXE":"Guilty Gear X - Advance Edition (USA)","AGB-AGXJ":"Guilty Gear X - Advance Edition (Japan)","AGB-AGXP":"Guilty Gear X - Advance Edition (Europe)","AGB-AGZJ":"Galaxy Angel Game Boy Advance - Moridakusan Tenshi no Full-Course - Okawari Jiyuu (Japan)","AGB-AH2E":"Mat Hoffman's Pro BMX 2 (USA, Europe)","AGB-AH3E":"Hamtaro - Ham-Ham Heartbreak (USA)","AGB-AH3J":"Tottoko Hamutarou 3 - Love Love Daibouken Dechu (Japan)","AGB-AH3P":"Hamtaro - Ham-Ham Heartbreak (Europe) (En,Fr,De,Es,It)","AGB-AH4E":"Shrek - Hassle at the Castle (USA) (En,Fr,De,Es,It,Nl)","AGB-AH4P":"Shrek - Hassle at the Castle (Europe) (En,Fr,De,Es,It,Nl)","AGB-AH5J":"Beast Shooter - Mezase Beast King! (Japan)","AGB-AH6E":"Hardcore Pinball (USA, Europe)","AGB-AH7J":"Nakayoshi Pet Advance Series 1 - Kawaii Hamster (Japan)","AGB-AH8E":"Hot Wheels - Velocity X (USA)","AGB-AH8P":"Hot Wheels - Velocity X (Europe)","AGB-AH9E":"Hobbit, The - The Prelude to the Lord of the Rings (USA)","AGB-AH9J":"Hobbit no Bouken - Lord of the Rings - Hajimari no Monogatari (Japan)","AGB-AH9P":"Hobbit, The - The Prelude to the Lord of the Rings (Europe) (En,Fr,De,Es,It)","AGB-AHAJ":"Hamster Paradise Advanchu (Japan)","AGB-AHBJ":"Hamster Monogatari 2 GBA (Japan)","AGB-AHCJ":"Mini Moni. - Mika no Happy Morning Chatty (Japan)","AGB-AHEJ":"Bakuten Shoot Beyblade - Gekitou! Saikyou Blader (Japan)","AGB-AHHE":"High Heat Major League Baseball 2003 (USA)","AGB-AHIJ":"Hitsuji no Kimochi. (Japan)","AGB-AHJE":"Hugo - The Evil Mirror (USA) (En,Fr,Es)","AGB-AHJP":"Hugo - The Evil Mirror (Europe) (En,Fr,De,Es,It,Nl,Pt,Sv,No,Da,Fi,Pl)","AGB-AHKJ":"Hikaru no Go (Japan)","AGB-AHLE":"Incredible Hulk, The (USA)","AGB-AHLP":"Incredible Hulk, The (Europe) (En,Fr,De,Es,It)","AGB-AHMJ":"Dai-mahjong. (Japan)","AGB-AHNE":"Spy Hunter (USA) (En,Ja,Fr,De,Es)","AGB-AHNP":"Spy Hunter (Europe) (En,Ja,Fr,De,Es)","AGB-AHOE":"Mat Hoffman's Pro BMX (USA, Europe)","AGB-AHOX":"Mat Hoffman's Pro BMX (Europe) (Fr,De)","AGB-AHPE":"Hot Potato! (USA)","AGB-AHPP":"Hot Potato! (Europe)","AGB-AHPX":"Hot Potato! (Europe) (En,Fr,De)","AGB-AHQJ":"Harobots - Robo Hero Battling!! (Japan)","AGB-AHRE":"Harry Potter and the Sorcerer's Stone (USA, Europe) (En,Fr,De,Es,It,Nl,Pt,Sv,No,Da)","AGB-AHRJ":"Harry Potter to Kenja no Ishi (Japan)","AGB-AHSJ":"Hatena Satena (Japan)","AGB-AHUE":"Shining Soul (USA)","AGB-AHUJ":"Shining Soul (Japan)","AGB-AHUP":"Shining Soul (Europe) (En,Fr,De,Es,It)","AGB-AHVJ":"Nakayoshi Youchien - Sukoyaka Enji Ikusei Game (Japan)","AGB-AHWE":"Hot Wheels - Burnin' Rubber (USA)","AGB-AHWJ":"Hot Wheels Advance (Japan) (En)","AGB-AHWP":"Hot Wheels - Burnin' Rubber (Europe) (En,Fr)","AGB-AHXJ":"High Heat Major League Baseball 2003 (Japan) (En)","AGB-AHYE":"Cartoon Network Block Party (USA) (Beta)","AGB-AHZJ":"Higanbana (Japan) (Rev 1)","AGB-AI2E":"Iridion II (USA)","AGB-AI2P":"Iridion II (Europe) (En,Fr,De)","AGB-AI3E":"Iridion 3D (USA, Europe)","AGB-AI7J":"Nakayoshi Pet Advance Series 2 - Kawaii Koinu (Japan)","AGB-AI8E":"Barbie Horse Adventures - Blue Ribbon Race (USA)","AGB-AI8P":"Barbie Horse Adventures (Europe) (En,Fr,De,Es,It,Nl)","AGB-AI9J":"Inukko Club (Japan)","AGB-AIAE":"Ice Age (USA) (En,Fr,Es)","AGB-AIAJ":"Ice Age (Japan)","AGB-AIAP":"Ice Age (Europe) (En,Fr,De,Es,It)","AGB-AIBJ":"Guranbo (Japan)","AGB-AICJ":"Oshaberi Inko Club (Japan)","AGB-AIDE":"Space Invaders (USA, Europe)","AGB-AIDF":"Space Invaders (France)","AGB-AIDJ":"Space Invaders EX (Japan) (En)","AGB-AIEJ":"Isseki Hatchou - Kore 1ppon de 8shurui! (Japan)","AGB-AIFE":"Tom and Jerry in Infurnal Escape (USA)","AGB-AIFP":"Tom and Jerry in Infurnal Escape (Europe) (En,Fr,De,Es,It)","AGB-AIGE":"Inspector Gadget - Advance Mission (USA)","AGB-AIGP":"Inspector Gadget - Advance Mission (Europe) (En,Fr,De,Es,It,Nl)","AGB-AIHE":"Mission Impossible - Operation Surma (USA) (En,Fr,Es)","AGB-AIHP":"Mission Impossible - Operation Surma (Europe) (En,Fr,De,Es,It)","AGB-AIKP":"International Karate Advanced (Europe)","AGB-AILE":"Aggressive Inline (USA)","AGB-AILP":"Aggressive Inline (Europe) (En,Fr,De)","AGB-AINJ":"Initial D - Another Stage (Japan)","AGB-AIOE":"Invincible Iron Man, The (USA, Europe)","AGB-AIPE":"Silent Scope (USA) (En,Fr,De,Es,It)","AGB-AIPJ":"Silent Scope (Japan)","AGB-AIPP":"Silent Scope (Europe) (En,Fr,De,Es,It)","AGB-AIRP":"Inspector Gadget Racing (Europe) (En,Fr,De,Es,It,Nl)","AGB-AISP":"International Superstar Soccer (Europe)","AGB-AIVP":"Invader (Europe)","AGB-AIYJ":"Inuyasha - Naraku no Wana! Mayoi no Mori no Shoutaijou (Japan)","AGB-AJ2J":"J.League Pocket 2 (Japan)","AGB-AJ3E":"Jurassic Park III - Park Builder (USA)","AGB-AJ3J":"Jurassic Park III - Kyouryuu ni Ainiikou! (Japan)","AGB-AJ3P":"Jurassic Park III - Park Builder (Europe) (En,Fr,De,Es,It)","AGB-AJ4E":"Earthworm Jim 2 (USA)","AGB-AJ4P":"Earthworm Jim 2 (Europe) (En,Fr,De,Es,It)","AGB-AJ6J":"Aladdin (Japan)","AGB-AJ8J":"Jurassic Park Institute Tour - Dinosaur Rescue (Japan)","AGB-AJ9J":"Super Robot Taisen R (Japan)","AGB-AJAE":"Monster Jam - Maximum Destruction (USA)","AGB-AJAP":"Monster Jam - Maximum Destruction (Europe)","AGB-AJCE":"Jackie Chan Adventures - Legend of the Dark Hand (USA, Europe)","AGB-AJCF":"Aventures de Jackie Chan, Les - La Legende de la Main Noire (France)","AGB-AJDE":"James Pond - Codename Robocod (USA) (En,Fr,Es,Pt)","AGB-AJDP":"James Pond - Codename Robocod (Europe) (En,Fr,De,Es,It,Nl,Pt)","AGB-AJEJ":"Fancy Pocket (Japan)","AGB-AJFE":"Jungle Book, The (USA) (En,Fr,De,Es,It,Nl)","AGB-AJFP":"Jungle Book 2, The (Europe) (En,Fr,De,Es,It,Nl)","AGB-AJGD":"Tarzan - Rueckkehr in den Dschungel (Germany)","AGB-AJGE":"Tarzan - Return to the Jungle (USA, Europe)","AGB-AJGF":"Tarzan - L'Appel de la Jungle (France)","AGB-AJHE":"Petz - Hamsterz Life 2 (USA)","AGB-AJHJ":"Hamster Club 3 (Japan)","AGB-AJJE":"Jazz Jackrabbit (USA, Europe)","AGB-AJKJ":"Jikkyou World Soccer Pocket 2 (Japan)","AGB-AJLE":"Justice League - Injustice for All (USA)","AGB-AJLP":"Justice League - Injustice for All (Europe) (En,Fr,De,Es,It)","AGB-AJME":"Jonny Moseley Mad Trix (USA) (En,Fr,De,Es,It)","AGB-AJNE":"Jimmy Neutron - Boy Genius (USA)","AGB-AJNX":"Jimmy Neutron - Boy Genius (Europe) (En,Fr,De,Es)","AGB-AJOJ":"Magical Houshin (Japan)","AGB-AJPJ":"J.League Pocket (Japan)","AGB-AJQE":"Jurassic Park III - Island Attack (USA)","AGB-AJQJ":"Jurassic Park III - Advanced Action (Japan)","AGB-AJQP":"Jurassic Park III - Dino Attack (Europe) (En,Fr,De,Es,It)","AGB-AJRE":"Jet Grind Radio (USA)","AGB-AJRP":"Jet Set Radio (Europe)","AGB-AJSJ":"Space Hexcite - Maetel Legend EX (Japan)","AGB-AJTE":"Samurai Jack - The Amulet of Time (USA, Europe)","AGB-AJUJ":"Jissen Pachi-Slot Hisshouhou! - Juuou Advance (Japan)","AGB-AJWJ":"Jikkyou World Soccer Pocket (Japan)","AGB-AJXD":"Adventures of Jimmy Neutron Boy Genius vs. Jimmy Negatron, The (Germany)","AGB-AJXE":"Adventures of Jimmy Neutron Boy Genius vs. Jimmy Negatron, The (USA, Europe)","AGB-AJYE":"Drake & Josh (USA) (En,Fr)","AGB-AJZJ":"Bomberman Jetters - Densetsu no Bomberman (Japan)","AGB-AK2E":"Ultimate Muscle - The Kinnikuman Legacy - The Path of the Superhero (USA)","AGB-AK2J":"Kinnikuman II-Sei - Seigi Choujin e no Michi (Japan)","AGB-AK3E":"Turbo Turtle Adventure (USA)","AGB-AK4J":"Kinniku Banzuke - Kongou-kun no Daibouken! (Japan)","AGB-AK5J":"Kinniku Banzuke - Kimero! Kiseki no Kanzen Seiha (Japan)","AGB-AK6E":"Soccer Kid (USA, Europe)","AGB-AK7J":"Klonoa Heroes - Densetsu no Star Medal (Japan)","AGB-AK8E":"Medabots AX - Metabee Ver. (USA)","AGB-AK8P":"Medabots AX - Metabee Ver. (Europe) (En,Fr,De,Es,It)","AGB-AK9E":"Medabots AX - Rokusho Ver. (USA)","AGB-AK9P":"Medabots AX - Rokusho Ver. (Europe) (En,Fr,De,Es,It)","AGB-AKAJ":"Shaman King Card Game - Chou Senjiryakketsu 2 (Japan)","AGB-AKBE":"Sports Illustrated for Kids - Baseball (USA)","AGB-AKCE":"Konami Collector's Series - Arcade Advanced (USA)","AGB-AKCJ":"Konami Arcade Game Collection (Japan)","AGB-AKCP":"Konami Collector's Series - Arcade Classics (Europe) (En,Fr,De,Es,It)","AGB-AKDJ":"Kaeru B Back (Japan)","AGB-AKEJ":"Hikaru no Go 2 (Japan)","AGB-AKFE":"Sports Illustrated for Kids - Football (USA)","AGB-AKGE":"Mech Platoon (USA)","AGB-AKGJ":"Kikaika Guntai - Mech Platoon (Japan)","AGB-AKGP":"Mech Platoon (Europe) (En,Fr,De,Es,It)","AGB-AKIJ":"Kiki Kaikai Advance (Japan)","AGB-AKKE":"KAO the Kangaroo (USA) (En,Fr,De,Es,It,Nl)","AGB-AKKP":"KAO the Kangaroo (Europe) (En,Fr,De,Es,It,Nl)","AGB-AKLE":"Klonoa - Empire of Dreams (USA)","AGB-AKLJ":"Kaze no Klonoa - Yumemiru Teikoku (Japan)","AGB-AKLP":"Klonoa - Empire of Dreams (Europe)","AGB-AKMJ":"Kiwame Mahjong Deluxe - Mirai Senshi 21 (Japan)","AGB-AKOE":"King of Fighters EX, The - NeoBlood (USA)","AGB-AKOJ":"King of Fighters EX, The - NeoBlood (Japan)","AGB-AKOP":"King of Fighters EX, The - NeoBlood (Europe)","AGB-AKPJ":"Nakayoshi Mahjong - KabuReach (Japan)","AGB-AKQE":"Kong - The Animated Series (USA) (En,Fr,De,Es,It,Nl)","AGB-AKQP":"Kong - The Animated Series (Europe) (En,Fr,De,Es,It,Nl)","AGB-AKRC":"Zhuanzhuanbang (China) (Proto)","AGB-AKRJ":"Kurukuru Kururin (Japan)","AGB-AKRP":"Kurukuru Kururin (Europe)","AGB-AKSE":"Mary-Kate and Ashley - Girls Night Out (USA, Europe)","AGB-AKTJ":"Hello Kitty Collection - Miracle Fashion Maker (Japan)","AGB-AKUJ":"Kurohige no Kurutto Jintori (Japan)","AGB-AKVJ":"K-1 Pocket Grand Prix (Japan)","AGB-AKWE":"Konami Krazy Racers (USA)","AGB-AKWJ":"Konami Wai Wai Racing Advance (Japan)","AGB-AKWP":"Konami Krazy Racers (Europe)","AGB-AKXD":"Spider-Man (Germany)","AGB-AKXE":"Spider-Man (USA, Europe)","AGB-AKXF":"Spider-Man (France)","AGB-AKYJ":"Captain Tsubasa - Eikou no Kiseki (Japan)","AGB-AKZJ":"Kamaitachi no Yoru Advance (Japan)","AGB-AL2E":"LEGO Island 2 - The Brickster's Revenge (USA) (En,Fr)","AGB-AL2P":"LEGO Island 2 - The Brickster's Revenge (Europe) (En,Fr,De,Es,It,Nl,Sv,Da)","AGB-AL3J":"Shaman King Card Game - Chou Senjiryakketsu 3 (Japan)","AGB-AL4E":"DemiKids - Light Version (USA)","AGB-AL4J":"Shin Megami Tensei Devil Children - Hikari no Sho (Japan)","AGB-AL9E":"Lara Croft Tomb Raider - The Prophecy (USA) (En,Fr,De,Es,It)","AGB-AL9P":"Lara Croft Tomb Raider - The Prophecy (Europe) (En,Fr,De,Es,It)","AGB-ALAE":"Land Before Time, The (USA) (En,Es)","AGB-ALAP":"Land Before Time, The (Europe) (En,Fr,De,Es,It)","AGB-ALBE":"LEGO Bionicle (USA) (En,Fr)","AGB-ALBP":"LEGO Bionicle (Europe) (En,Fr,De,Es,It,Nl,Sv,Da)","AGB-ALCE":"Little League Baseball 2002 (USA) (En,Es)","AGB-ALDE":"Lady Sia (USA) (En,Fr,De,Es,It,Nl)","AGB-ALDP":"Lady Sia (Europe) (En,Fr,De,Es,It,Nl)","AGB-ALEE":"Bruce Lee - Return of the Legend (USA)","AGB-ALEP":"Bruce Lee - Return of the Legend (Europe) (En,Fr,De,Es,It)","AGB-ALFE":"Dragon Ball Z - The Legacy of Goku II (USA)","AGB-ALFJ":"Dragon Ball Z - The Legacy of Goku II International (Japan)","AGB-ALFP":"Dragon Ball Z - The Legacy of Goku II (Europe) (En,Fr,De,Es,It)","AGB-ALGE":"Dragon Ball Z - The Legacy of Goku (USA)","AGB-ALGP":"Dragon Ball Z - The Legacy of Goku (Europe) (En,Fr,De,Es,It)","AGB-ALHJ":"Dark Empire (Japan) (Proto)","AGB-ALIE":"Haunted Mansion, The (USA) (Proto)","AGB-ALJE":"Sea Trader - Rise of Taipan (USA)","AGB-ALLP":"Lucky Luke - Wanted! (Europe) (En,Fr,De,Es,It,Nl)","AGB-ALMJ":"Pro Yakyuu Team o Tsukurou! Advance (Japan)","AGB-ALNE":"Lunar Legend (USA)","AGB-ALNJ":"Lunar Legend (Japan)","AGB-ALOE":"Lord of the Rings, The - The Fellowship of the Ring (USA)","AGB-ALOP":"Lord of the Rings, The - The Fellowship of the Ring (Europe) (En,Fr,De,Es,It)","AGB-ALPE":"Lord of the Rings, The - The Two Towers (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-ALPJ":"Lord of the Rings, The - Futatsu no Tou (Japan)","AGB-ALQJ":"Little Buster Q (Japan)","AGB-ALRE":"LEGO Racers 2 (USA) (En,Fr)","AGB-ALRP":"LEGO Racers 2 (Europe) (En,Fr,De,Es,It,Nl,Sv,Da)","AGB-ALSE":"Soccer Mania (USA, Europe) (En,Fr,De,Es,It,Nl,Sv,Da)","AGB-ALTE":"Lilo & Stitch (USA)","AGB-ALTP":"Lilo & Stitch (Europe) (En,Fr,De,Es,It,Nl)","AGB-ALUE":"Super Monkey Ball Jr. (USA)","AGB-ALUP":"Super Monkey Ball Jr. (Europe) (En,Fr,De,Es,It)","AGB-ALVE":"Lost Vikings, The (USA)","AGB-ALVP":"Lost Vikings, The (Europe) (En,Fr,De,Es)","AGB-ALXP":"Ace Lightning (Europe)","AGB-AM2P":"Megaman - Battle Network 2 (Europe)","AGB-AM3E":"Midway's Greatest Arcade Hits (USA, Europe)","AGB-AM4E":"MotoGP (USA) (En,Fr,De,Es,It)","AGB-AM4J":"MotoGP (Japan) (En)","AGB-AM4P":"MotoGP (Europe) (En,Fr,De,Es,It)","AGB-AM5E":"Mortal Kombat Advance (USA)","AGB-AM5P":"Mortal Kombat Advance (Europe)","AGB-AM6E":"Mike Tyson Boxing (USA) (En,Fr,De,Es,It)","AGB-AM7J":"Hamepane - Tokyo Mew Mew (Japan)","AGB-AM8E":"Monster Force (USA)","AGB-AM8P":"Monster Force (Europe) (En,Fr,De,Es,It)","AGB-AM9P":"Mike Tyson Boxing (Europe) (En,Fr,De,Es,It)","AGB-AMAC":"Chaoji Maliou 2 (China)","AGB-AMAE":"Super Mario Advance (USA, Europe)","AGB-AMAJ":"GBA Movie Player - 2nd Version (World) (V2.00)","AGB-AMBJ":"Mobile Pro Yakyuu - Kantoku no Saihai (Japan)","AGB-AMCJ":"Mail de Cute (Japan)","AGB-AMEE":"Army Men - Operation Green (USA) (En,Fr,De,Es,It)","AGB-AMFE":"Monster Rancher Advance (USA)","AGB-AMFJ":"Monster Farm Advance (Japan)","AGB-AMGE":"ESPN Great Outdoor Games - Bass 2002 (USA)","AGB-AMGJ":"Exciting Bass (Japan)","AGB-AMGP":"ESPN Great Outdoor Games - Bass Tournament (Europe)","AGB-AMHE":"Bomberman Max 2 - Blue Advance (USA)","AGB-AMHJ":"Bomberman Max 2 - Bomberman Version (Japan)","AGB-AMHP":"Bomberman Max 2 - Blue Advance (Europe) (En,Fr,De)","AGB-AMIE":"Men in Black - The Series (USA)","AGB-AMIP":"Men in Black - The Series (Europe)","AGB-AMJJ":"Tweety no Hearty Party (Japan)","AGB-AMKC":"Maliou Kadingche - Chaoji Saidao (China) (Proto)","AGB-AMKE":"Mario Kart - Super Circuit (USA)","AGB-AMKJ":"Mario Kart Advance (Japan)","AGB-AMKP":"Mario Kart - Super Circuit (Europe)","AGB-AMLE":"M&M's - Blast! (USA)","AGB-AMMJ":"Momotarou Matsuri (Japan) (Rev 1)","AGB-AMNJ":"Monster Guardians (Japan)","AGB-AMOJ":"EX Monopoly (Japan)","AGB-AMPJ":"Mahjong Keiji (Japan)","AGB-AMQE":"Midnight Club - Street Racing (USA)","AGB-AMQP":"Midnight Club - Street Racing (Europe) (En,Fr,De,Es,It)","AGB-AMRE":"Motocross Maniacs Advance (USA) (En,Es)","AGB-AMRJ":"Motocross Maniacs Advance (Japan)","AGB-AMRP":"Maniac Racers Advance (Europe) (En,Fr,De,Es,It)","AGB-AMSJ":"Morita Shougi Advance (Japan)","AGB-AMTC":"Miteluode Ronghe (China)","AGB-AMTE":"Metroid Fusion (USA)","AGB-AMTJ":"Metroid Fusion (Japan)","AGB-AMTP":"Metroid Fusion (Europe) (En,Fr,De,Es,It)","AGB-AMUJ":"Mutsu - Water Looper Mutsu (Japan)","AGB-AMVJ":"Magical Vacation (Japan)","AGB-AMWE":"Muppet Pinball Mayhem (USA)","AGB-AMWP":"Muppet Pinball Mayhem (Europe)","AGB-AMXD":"Monster AG, Die (Germany)","AGB-AMXE":"Monsters, Inc. (USA, Europe)","AGB-AMXJ":"Monsters, Inc. (Japan)","AGB-AMXX":"Monsters, Inc. (Europe) (En,Es,Nl)","AGB-AMXY":"Monsters, Inc. (Europe) (En,Fr,It)","AGB-AMYE":"Bomberman Max 2 - Red Advance (USA)","AGB-AMYJ":"Bomberman Max 2 - Max Version (Japan)","AGB-AMYP":"Bomberman Max 2 - Red Advance (Europe) (En,Fr,De)","AGB-AN2J":"Natural 2 - Duo (Japan)","AGB-AN3E":"Catz (USA, Europe)","AGB-AN3J":"Nakayoshi Pet Advance Series 3 - Kawaii Koneko (Japan)","AGB-AN3X":"Catz (Europe) (En,Fr,De,It)","AGB-AN4E":"NHL Hitz 2003 (USA)","AGB-AN5J":"Kawa no Nushi Tsuri 5 - Fushigi no Mori kara (Japan)","AGB-AN6E":"Klonoa 2 - Dream Champ Tournament (USA)","AGB-AN6J":"Kaze no Klonoa G2 - Dream Champ Tournament (Japan)","AGB-AN7J":"Famista Advance (Japan)","AGB-AN8E":"Tales of Phantasia (USA, Australia)","AGB-AN8J":"Tales of Phantasia (Japan)","AGB-AN8P":"Tales of Phantasia (Europe) (En,Fr,De,Es,It)","AGB-AN9J":"Tales of the World - Narikiri Dungeon 2 (Japan)","AGB-ANAJ":"Medarot Navi - Kabuto (Japan)","AGB-ANBJ":"Nobunaga no Yabou (Japan)","AGB-ANCE":"ZooCube (USA)","AGB-ANCJ":"ZooCube (Japan)","AGB-ANCP":"ZooCube (Europe) (En,Fr,De,Es,It)","AGB-ANDE":"Nancy Drew - Message in a Haunted Mansion (USA)","AGB-ANFJ":"Monster Gate (Japan)","AGB-ANHE":"NASCAR Heat 2002 (USA)","AGB-ANIP":"Animaniacs - Lights, Camera, Action! (Europe) (En,Fr,De,Es,It)","AGB-ANJE":"Madden NFL 2003 (USA)","AGB-ANKE":"NFL Blitz 2003 (USA)","AGB-ANLE":"NHL 2002 (USA)","AGB-ANME":"Namco Museum (USA)","AGB-ANMJ":"Namco Museum (Japan) (En)","AGB-ANMP":"Namco Museum (Europe)","AGB-ANNJ":"Gekitou Densetsu Noah - Dream Management (Japan)","AGB-ANOJ":"Nobunaga Ibun (Japan)","AGB-ANPF":"Aigle de Guerre, L' (France)","AGB-ANPJ":"Napoleon (Japan)","AGB-ANQE":"Nicktoons Racing (USA)","AGB-ANQP":"Nicktoons Racing (USA) (Beta)","AGB-ANRE":"Cartoon Network Speedway (USA)","AGB-ANSJ":"Marie, Elie & Anis no Atelier - Soyokaze kara no Dengon (Japan)","AGB-ANTJ":"Nihon Pro Mahjong Renmei Kounin - Tetsuman Advance - Menkyo Kaiden Series (Japan)","AGB-ANUE":"Antz - Extreme Racing (USA)","AGB-ANVJ":"Shiren Monsters - Netsal (Japan)","AGB-ANWJ":"Elevator Action - Old & New (Japan)","AGB-ANXE":"Ninja Five-O (USA)","AGB-ANXP":"Ninja Cop (Europe)","AGB-ANYJ":"Gachinko Pro Yakyuu (Japan)","AGB-ANZP":"Antz - Extreme Racing (Europe) (En,Fr,De,Es,It,Nl)","AGB-AO2J":"Oshare Princess 2 + Doubutsu Kyaranabi Uranai (Japan)","AGB-AO3E":"Terminator 3 - Rise of the Machines (USA)","AGB-AO3P":"Terminator 3 - Rise of the Machines (Europe) (En,Fr,De,Es,It)","AGB-AO4E":"Tom Clancy's Splinter Cell (USA) (En,Fr,Es)","AGB-AO4P":"Tom Clancy's Splinter Cell (Europe) (En,Fr,De,Es,It,Nl)","AGB-AO7J":"From TV Animation One Piece - Nanatsu-jima no Daihihou (Japan)","AGB-AO7K":"One Piece - Ilgop Seomui Debomool (Korea)","AGB-AOBP":"Asterix & Obelix - Bash Them All! (Europe) (En,Fr,De,Es,It,Nl)","AGB-AOCJ":"Chobits for Game Boy Advance - Atashi Dake no Hito (Japan)","AGB-AODJ":"Minami no Umi no Odyssey (Japan)","AGB-AOEE":"Drome Racers (USA)","AGB-AOEX":"Drome Racers (Europe) (En,Fr,De,Da)","AGB-AOGE":"Super Robot Taisen - Original Generation (USA)","AGB-AOGJ":"Super Robot Taisen - Original Generation (Japan)","AGB-AOGP":"Super Robot Taisen - Original Generation (Europe)","AGB-AOHJ":"Mini Moni. - Onegai Ohoshi-sama! (Japan)","AGB-AOIE":"Shrek - Reekin' Havoc (USA) (En,Fr,De,Es,It,Nl)","AGB-AOIP":"Shrek - Reekin' Havoc (Europe) (En,Fr,De,Es,It,Nl)","AGB-AOKJ":"Okumanchouja Game - Nottori Daisakusen! (Japan)","AGB-AOME":"Disney Sports - Motocross (USA)","AGB-AOMJ":"Disney Sports - Motocross (Japan)","AGB-AOMP":"Disney Sports - Motocross (Europe) (En,Fr,De,Es,It)","AGB-AONE":"Bubble Bobble - Old & New (USA)","AGB-AONP":"Bubble Bobble - Old & New (Europe) (En,Fr,De,Es,It)","AGB-AOPJ":"Oshare Princess (Japan)","AGB-AORJ":"Oriental Blue - Ao no Tengai (Japan)","AGB-AOSE":"Samurai Deeper Kyo (USA)","AGB-AOSJ":"Samurai Deeper Kyo (Japan)","AGB-AOTE":"Polly Pocket! - Super Splash Island (USA) (Vivendi)","AGB-AOTP":"Polly Pocket! - Super Splash Island (Europe) (En,Fr,De,Es,It) (Vivendi)","AGB-AOWE":"Spyro - Attack of the Rhynocs (USA)","AGB-AOWP":"Spyro Adventure (Europe) (En,Fr,De,Es,It,Nl)","AGB-AP3J":"Power Pro Kun Pocket 3 (Japan)","AGB-AP4J":"Power Pro Kun Pocket 4 (Japan)","AGB-AP5E":"Powerpuff Girls, The - Him and Seek (USA)","AGB-AP5P":"Powerpuff Girls, The - Him and Seek (Europe) (En,Fr,De,Es)","AGB-AP6J":"Pinobee & Phoebee (Japan)","AGB-AP7P":"Pink Panther - Pinkadelic Pursuit (Europe) (En,Fr,De,Es,It)","AGB-AP8D":"Scooby-Doo (Germany)","AGB-AP8E":"Scooby-Doo (USA)","AGB-AP8F":"Scooby-Doo (France)","AGB-AP8P":"Scooby-Doo (Europe)","AGB-AP8S":"Scooby-Doo (Spain)","AGB-AP9P":"Pocket Music (Europe) (En,Fr,De,Es,It)","AGB-APBE":"Pinobee - Wings of Adventure (USA, Europe)","AGB-APBJ":"Pinobee no Daibouken (Japan)","AGB-APCE":"Pac-Man Collection (USA)","AGB-APCJ":"Pac-Man Collection (Japan) (En)","AGB-APCP":"Pac-Man Collection (Europe)","AGB-APDE":"Pinball of the Dead, The (USA)","AGB-APDP":"Pinball of the Dead, The (Europe) (En,Fr,De,Es,It)","AGB-APEE":"Pink Panther - Pinkadelic Pursuit (USA)","AGB-APFE":"Pitfall - The Mayan Adventure (USA, Europe)","AGB-APGE":"Punch King - Arcade Boxing (USA)","AGB-APGP":"Punch King - Arcade Boxing (Europe) (En,Fr,Es)","AGB-APHE":"Prehistorik Man (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-APIP":"Pinky and the Brain - The Master Plan (Europe) (En,Fr,De,Es,It)","AGB-APJJ":"Bouken Yuuki Pluster World - Densetsu no Plust Gate (Japan)","AGB-APKE":"Pocky & Rocky with Becky (USA)","AGB-APLP":"Pinball Challenge Deluxe (Europe)","AGB-APME":"Planet Monsters (USA) (En,Fr,De,Es,It,Nl)","AGB-APMP":"Planet Monsters (Europe) (En,Fr,De,Es,It,Nl)","AGB-APNJ":"Pinky Monkey Town (Japan)","AGB-APOE":"Popeye - Rush for Spinach (USA, Europe) (En,Fr,De,Es,It)","AGB-APPE":"Peter Pan - Return to Neverland (USA)","AGB-APPP":"Peter Pan - Return to Neverland (Europe) (En,Fr,De,Es,It,Nl)","AGB-APRD":"Power Rangers - Time Force (Germany)","AGB-APRE":"Power Rangers - Time Force (USA, Europe)","AGB-APRF":"Power Rangers - La Force du Temps (France)","AGB-APTE":"Powerpuff Girls, The - Mojo Jojo A-Go-Go (USA) (En,Fr,De,Es,It,Nl)","AGB-APTP":"Powerpuff Girls, The - Mojo Jojo A-Go-Go (Europe) (En,Fr,De,Es,It,Nl)","AGB-APUJ":"PukuPuku Tennen Kairanban (Japan)","AGB-APWE":"Power Rangers - Wild Force (USA, Europe)","AGB-APXE":"Phalanx (USA)","AGB-APXJ":"Crazy Chase (USA) (Beta)","AGB-APXP":"Phalanx (Europe) (En,Fr,De,Es,It,Nl)","AGB-APYE":"Puyo Pop (USA) (En,Ja)","AGB-APYJ":"Minna de Puyo Puyo (Japan) (En,Ja)","AGB-APYP":"Puyo Pop (Europe) (En,Ja)","AGB-APZP":"Pinball Advance (Europe) (En,Fr,De,Es,It)","AGB-AQ2J":"Choro Q Advance 2 (Japan)","AGB-AQ2P":"Gadget Racers (Europe) (En,Fr,De)","AGB-AQ3E":"SpongeBob SquarePants - Revenge of the Flying Dutchman (USA, Europe)","AGB-AQAE":"Gadget Racers (USA)","AGB-AQAJ":"Choro Q Advance (Japan)","AGB-AQAP":"Penny Racers (Europe)","AGB-AQBP":"Scrabble (Europe) (En,Fr,De,Es)","AGB-AQCJ":"Combat Choro Q - Advance Daisakusen (Japan)","AGB-AQDE":"Crouching Tiger, Hidden Dragon (USA) (En,Fr,Es)","AGB-AQDP":"Crouching Tiger, Hidden Dragon (Europe) (En,Fr,De,Es,It)","AGB-AQDS":"Disney Princesas (Spain)","AGB-AQHE":"Rescue Heroes - Billy Blazes! (USA)","AGB-AQME":"Magical Quest 2 Starring Mickey & Minnie (USA) (En,Fr,De)","AGB-AQMP":"Magical Quest 2 Starring Mickey & Minnie (Europe) (En,Fr,De)","AGB-AQPD":"Disneys Prinzessinnen (Germany)","AGB-AQPE":"Disney Princess (USA, Europe)","AGB-AQPF":"Disney Princesse (France)","AGB-AQPI":"Disney Principesse (Italy)","AGB-AQRE":"ATV - Quad Power Racing (USA, Europe)","AGB-AQRP":"ATV - Quad Power Racing (Europe) (En,Fr,De,Es,It) (Rev 1)","AGB-AQTE":"Dr. Seuss' The Cat in the Hat (USA)","AGB-AQTP":"Cat in the Hat, The (Europe) (En,Fr,De,Es,It)","AGB-AQWE":"Game & Watch Gallery 4 (USA)","AGB-AQWJ":"Game Boy Gallery 4 (Japan) (Virtual Console)","AGB-AQWP":"Game & Watch Gallery Advance (Europe)","AGB-AQXE":"Blackthorne (USA)","AGB-AQXP":"Blackthorne (Europe)","AGB-AR2E":"Ready 2 Rumble Boxing - Round 2 (USA)","AGB-AR2P":"Ready 2 Rumble Boxing - Round 2 (Europe) (En,Fr,De)","AGB-AR3E":"Ice Nine (USA, Europe) (En,Fr,De,Es,It)","AGB-AR4E":"Rocket Power - Beach Bandits (USA, Europe)","AGB-AR5E":"Rugrats - I Gotta Go Party (USA, Europe)","AGB-AR5F":"Razmoket, Les - A Moi la Fiesta (France)","AGB-AR6E":"Tom Clancy's Rainbow Six - Rogue Spear (USA) (En,Fr,De,Es,It)","AGB-AR6P":"Tom Clancy's Rainbow Six - Rogue Spear (Europe) (En,Fr,De,Es,It)","AGB-AR7J":"Advance Rally (Japan) (En)","AGB-AR8E":"Rocky (USA) (En,Fr,De,Es,It)","AGB-AR9E":"Reign of Fire (USA) (En,Fr,De,Es,It)","AGB-AR9P":"Reign of Fire (Europe) (En,Fr,De,Es,It)","AGB-ARAJ":"Shin Nihon Pro Wrestling - Toukon Retsuden Advance (Japan)","AGB-ARBE":"Robotech - The Macross Saga (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-ARCE":"GP-1 Racing (USA) (Proto)","AGB-ARDE":"Ripping Friends, The - The World's Most Manly Men! (USA, Europe)","AGB-AREE":"Megaman - Battle Network (USA)","AGB-AREJ":"Battle Network - Rockman EXE (Japan)","AGB-AREP":"Megaman - Battle Network (Europe)","AGB-ARFE":"Razor Freestyle Scooter (USA)","AGB-ARFP":"Freestyle Scooter (Europe)","AGB-ARGE":"Rugrats - Castle Capers (USA, Europe)","AGB-ARGF":"Razmoket, Les - Voler N'Est Pas Jouer (France)","AGB-ARGS":"Rugrats - Travesuras en el Castillo (Spain)","AGB-ARHJ":"Recca no Honoo - The Game (Japan)","AGB-ARIJ":"Groove Adventure Rave - Hikari to Yami no Daikessen 2 (Japan)","AGB-ARJJ":"Custom Robo GX (Japan)","AGB-ARKE":"Rocket Power - Dream Scheme (USA, Europe)","AGB-ARKF":"Rocket Power - Le Cauchemar d'Otto (France)","AGB-ARME":"Minority Report - Everybody Runs (USA, Europe)","AGB-ARNJ":"Neoromance Game - Harukanaru Toki no Naka de (Japan)","AGB-AROP":"Rocky (Europe) (En,Fr,De,Es,It)","AGB-ARPE":"Robopon 2 - Ring Version (USA)","AGB-ARPJ":"Robot Poncots 2 - Ring Version (Japan)","AGB-ARQE":"Cross Town Heroes (USA)","AGB-ARQP":"Cross Town Heroes (Europe)","AGB-ARSP":"Robot Wars - Extreme Destruction (Europe) (En,Fr,De,Es,It,Nl)","AGB-ARUE":"Robot Wars - Advanced Destruction (USA)","AGB-ARVJ":"Groove Adventure Rave - Hikari to Yami no Daikessen (Japan)","AGB-ARWP":"Robot Wars - Advanced Destruction (Europe) (En,Fr,De,Es,It)","AGB-ARXE":"Rampage - Puzzle Attack (USA, Europe)","AGB-ARYE":"Rayman Advance (USA) (En,Fr,De,Es,It)","AGB-ARYP":"Rayman Advance (Europe) (En,Fr,De,Es,It)","AGB-ARZJ":"Rockman Zero (Japan)","AGB-AS2E":"Star Wars - Episode II - Attack of the Clones (USA)","AGB-AS2X":"Star Wars - Episode II - Attack of the Clones (Europe) (En,Fr,De,Es,It)","AGB-AS3E":"Kelly Slater's Pro Surfer (USA, Europe)","AGB-AS4E":"Shrek - Swamp Kart Speedway (USA) (En,Fr,De,Es,It,Nl) (Rev 1)","AGB-AS5E":"Salt Lake 2002 (USA) (En,Fr,De,Es,It,Nl)","AGB-AS6P":"Speedball 2 (Europe) (En,Fr,De,Es,It)","AGB-AS8E":"Star X (USA) (En,Fr,De,Es,It,Nl)","AGB-AS8P":"Star X (Europe) (En,Fr,De,Es,It,Nl)","AGB-ASAE":"Army Men Advance (USA, Europe) (En,Fr,De,Es,It)","AGB-ASBJ":"Gyakuten Saiban (Japan)","AGB-ASCD":"Shaun Palmer's Pro Snowboarder (Germany)","AGB-ASCE":"Shaun Palmer's Pro Snowboarder (USA, Europe)","AGB-ASDE":"Scooby-Doo and the Cyber Chase (USA, Europe)","AGB-ASDX":"Scooby-Doo and the Cyber Chase (Europe) (En,Fr,De)","AGB-ASEE":"Spider-Man - Mysterio's Menace (USA, Europe)","AGB-ASEJ":"Spider-Man - Mysterio no Kyoui (Japan)","AGB-ASFJ":"Slot! Pro Advance - Takarabune & Ooedo Sakurafubuki 2 (Japan)","AGB-ASGE":"Smuggler's Run (USA)","AGB-ASGP":"Smuggler's Run (Europe) (En,Fr,De,Es,It)","AGB-ASHJ":"Play Novel - Silent Hill (Japan)","AGB-ASIE":"Sims, The - Bustin' Out (USA) (En,Fr,De,Es,It,Nl) (Rev 1)","AGB-ASKJ":"Sutakomi - Star Communicator (Japan)","AGB-ASLE":"Stuart Little 2 (USA, Europe)","AGB-ASLF":"Stuart Little 2 (France)","AGB-ASLP":"Stuart Little 2 (Europe) (Rev 1)","AGB-ASMJ":"Saibara Rieko no Dendou Mahjong (Japan)","AGB-ASNB":"All-Star Baseball 2004 (USA) (Beta 1)","AGB-ASNC":"Dog Trainer (Europe) (DS Cheat Cartridge)","AGB-ASNJ":"Samsara Naga 1x2 (Japan)","AGB-ASOE":"Sonic Advance (USA) (En,Ja)","AGB-ASOJ":"Sonic Advance (Japan) (En,Ja)","AGB-ASOP":"Sonic Advance (Europe) (En,Ja,Fr,De,Es)","AGB-ASPE":"SpongeBob SquarePants - SuperSponge (USA, Europe)","AGB-ASQE":"Snood (USA)","AGB-ASQP":"Snood (Europe) (En,Fr,De,Es,It)","AGB-ASRJ":"Super Robot Taisen A (Japan)","AGB-ASSE":"High Heat Major League Baseball 2002 (USA, Europe)","AGB-ASTC":"Sitafei De Chuanshuo (China) (Proto)","AGB-ASTJ":"Densetsu no Stafy (Japan)","AGB-ASUE":"Superman - Countdown to Apokolips (USA)","AGB-ASUP":"Superman - Countdown to Apokolips (Europe) (En,Fr,De,Es,It)","AGB-ASVJ":"Shanghai Advance (Japan)","AGB-ASWE":"Star Wars - Jedi Power Battles (USA)","AGB-ASWX":"Star Wars - Jedi Power Battles (Europe) (En,Fr,De,Es)","AGB-ASXJ":"Sangokushi (Japan)","AGB-ASYE":"Spyro - Season of Ice (USA)","AGB-ASYP":"Spyro - Season of Ice (Europe) (En,Fr,De,Es,It)","AGB-ASZE":"Scorpion King, The - Sword of Osiris (USA)","AGB-ASZP":"Scorpion King, The - Sword of Osiris (Europe) (En,Fr,De,Es,It)","AGB-AT2J":"Dragon Quest Characters - Torneko no Daibouken 2 Advance - Fushigi no Dungeon (Japan)","AGB-AT3D":"Tony Hawk's Pro Skater 3 (Germany)","AGB-AT3E":"Tony Hawk's Pro Skater 3 (USA, Europe)","AGB-AT3F":"Tony Hawk's Pro Skater 3 (France)","AGB-AT4E":"Turok - Evolution (USA)","AGB-AT4P":"Turok - Evolution (Europe) (En,Fr,De,Es,It)","AGB-AT5E":"Tiger Woods PGA Tour Golf (USA, Europe)","AGB-AT5X":"Tiger Woods PGA Tour Golf (Europe) (En,Fr,De,Es,It)","AGB-AT6E":"Tony Hawk's Pro Skater 4 (USA, Europe)","AGB-AT7F":"Titeuf - Ze Gag Machine (France)","AGB-AT7P":"Tootuff - The Gag Machine (Europe) (En,Fr,De,Es,It) (Proto)","AGB-AT8P":"Tennis Masters Series 2003 (Europe) (En,Fr,De,Es,It,Pt)","AGB-AT9E":"BMX Trick Racer (USA)","AGB-ATAE":"Tang Tang (USA)","AGB-ATAP":"Tang Tang (Europe) (En,Fr,De,Es,It,Fi)","AGB-ATBJ":"Slot! Pro 2 Advance - GoGo Juggler & New Tairyou (Japan)","AGB-ATCE":"Top Gear GT Championship (USA)","AGB-ATCP":"Top Gear GT Championship (Europe)","AGB-ATCX":"GT Championship (Europe)","AGB-ATDJ":"Daisuki Teddy (Japan)","AGB-ATEE":"WTA Tour Tennis (USA)","AGB-ATEP":"Pro Tennis WTA Tour (Europe)","AGB-ATFP":"Total Soccer Manager (Europe) (En,Fr,De,Es,It,Nl)","AGB-ATGE":"Top Gun - Firestorm Advance (USA, Europe) (En,Fr,De,Es,It)","AGB-ATHD":"Tony Hawk's Pro Skater 2 (Germany)","AGB-ATHE":"Tony Hawk's Pro Skater 2 (USA, Europe)","AGB-ATHF":"Tony Hawk's Pro Skater 2 (France)","AGB-ATHJ":"SK8 - Tony Hawk's Pro Skater 2 (Japan)","AGB-ATIJ":"Tennis no Ouji-sama - Genius Boys Academy (Japan)","AGB-ATJE":"Tom and Jerry - The Magic Ring (USA) (En,Fr,De,Es,It)","AGB-ATJP":"Tom and Jerry - The Magic Ring (Europe) (En,Fr,De,Es,It)","AGB-ATKE":"Tekken Advance (USA)","AGB-ATKJ":"Tekken Advance (Japan)","AGB-ATKP":"Tekken Advance (Europe)","AGB-ATLE":"Atlantis - The Lost Empire (USA, Europe)","AGB-ATLX":"Atlantis - The Lost Empire (Europe) (En,Fr,De,Es,It,Nl)","AGB-ATMD":"Tweety and the Magic Gems (Germany)","AGB-ATME":"Tweety and the Magic Gems (USA)","AGB-ATMF":"Titi et les Bijoux Magiques (France)","AGB-ATMH":"Tweety and the Magic Gems (Netherlands)","AGB-ATMP":"Tweety and the Magic Gems (Europe)","AGB-ATNP":"Thunderbirds - International Rescue (Europe)","AGB-ATOE":"Tactics Ogre - The Knight of Lodis (USA)","AGB-ATOJ":"Tactics Ogre Gaiden - The Knight of Lodis (Japan)","AGB-ATPJ":"Keitai Denjuu Telefang 2 - Power (Japan)","AGB-ATQP":"TOCA World Touring Cars (Europe)","AGB-ATRJ":"Toy Robo Force (Japan)","AGB-ATSJ":"Keitai Denjuu Telefang 2 - Speed (Japan)","AGB-ATTE":"Tiny Toon Adventures - Scary Dreams (USA)","AGB-ATTP":"Tiny Toon Adventures - Buster's Bad Dream (Europe) (En,Fr,De,Es,It)","AGB-ATUJ":"Total Soccer Advance (Japan)","AGB-ATUP":"Total Soccer 2002 (Europe) (En,Fr,De,Es,It,Nl)","AGB-ATVP":"Tir et But - Edition Champions du Monde (France)","AGB-ATWE":"Tetris Worlds (USA)","AGB-ATWJ":"Tetris Worlds (Japan)","AGB-ATWX":"Tetris Worlds (Europe) (En,Fr,De,Nl)","AGB-ATWY":"Tetris Worlds (Europe) (En,Es,It)","AGB-ATXP":"Next Generation Tennis (Europe) (En,Fr,De,Es,It,Pt)","AGB-ATYJ":"Gambler Densetsu Tetsuya - Yomigaeru Densetsu (Japan)","AGB-ATZJ":"Zoids Saga (Japan)","AGB-AU2E":"Shining Soul II (USA)","AGB-AU2J":"Shining Soul II (Japan)","AGB-AU2P":"Shining Soul II (Europe) (En,Fr,De,Es,It)","AGB-AU3P":"Moorhen 3 - The Chicken Chase! (Europe) (En,Fr,De,Es,It)","AGB-AUCJ":"Uchuu Daisakusen Choco Vader - Uchuu kara no Shinryakusha (Japan)","AGB-AUEJ":"Naruto - Konoha Senki (Japan)","AGB-AUGE":"Medal of Honor - Underground (USA)","AGB-AUGP":"Medal of Honor - Underground (Europe) (En,Fr,Es,It) (Zoo Digital)","AGB-AUGX":"Medal of Honor - Underground (Europe) (En,Fr,Es,It) (Ubi Soft)","AGB-AUME":"Mummy, The (USA) (En,Fr,De,Es,It)","AGB-AUMP":"Mummy, The (Europe) (En,Fr,De,Es,It)","AGB-AUQP":"Butt-Ugly Martians - B.K.M. Battles (Europe) (En,Fr,De,Es,It)","AGB-AUSJ":"From TV Animation One Piece - Mezase! King of Berry (Japan)","AGB-AUTJ":"Lara Croft Tomb Raider - The Prophecy (Japan)","AGB-AUXE":"Stuntman (USA) (En,Fr,Es)","AGB-AUXP":"Stuntman (Europe) (En,Fr,De,Es,It)","AGB-AUYJ":"Yuureiyashiki no Nijuuyojikan (Japan)","AGB-AUZE":"Santa Claus Saves the Earth (Europe)","AGB-AV3E":"Spy Kids 3-D - Game Over (USA)","AGB-AV3P":"Spy Kids 3-D - Game Over (Europe)","AGB-AVAJ":"Tennis no Ouji-sama - Aim at the Victory! (Japan)","AGB-AVBE":"Barbie Software - Groovy Games (USA)","AGB-AVBP":"Barbie Software - Groovy Games (Europe) (En,Fr,De,Es,It)","AGB-AVCE":"Corvette (USA) (En,Fr,De,Es,It)","AGB-AVDJ":"Legend of Dynamic - Goushouden - Houkai no Rondo (Japan)","AGB-AVEE":"Ultimate Beach Soccer (USA)","AGB-AVEP":"Pro Beach Soccer (Europe) (En,Fr,De,Es,It,Pt)","AGB-AVFC":"Sitafei De Chuanshuo 2 (China) (Proto)","AGB-AVFJ":"Densetsu no Stafy 2 (Japan)","AGB-AVIJ":"Medarot Navi - Kuwagata (Japan)","AGB-AVKE":"Virtual Kasparov (USA) (En,Fr,De,Es,It)","AGB-AVKP":"Virtual Kasparov (Europe) (En,Fr,De,Es,It)","AGB-AVLD":"Daredevil (Germany)","AGB-AVLE":"Daredevil (USA, Europe)","AGB-AVLX":"Daredevil (Europe) (En,Fr,Es,It)","AGB-AVMJ":"V-Master Cross (Japan)","AGB-AVPP":"V.I.P. (Europe) (En,Fr,De,Es,It,Nl)","AGB-AVRE":"V-Rally 3 (USA) (En,Fr,Es)","AGB-AVRJ":"V-Rally 3 (Japan)","AGB-AVRP":"V-Rally 3 (Europe) (En,Fr,De,Es,It)","AGB-AVSE":"Sword of Mana (USA, Australia)","AGB-AVSJ":"Shinyaku Seiken Densetsu (Japan)","AGB-AVSP":"Sword of Mana (Europe)","AGB-AVSX":"Sword of Mana (Europe) (Fr,De)","AGB-AVSY":"Sword of Mana (Europe) (Es,It)","AGB-AVTE":"Virtua Tennis (USA)","AGB-AVTP":"Virtua Tennis (Europe) (En,Fr,De,Es,It)","AGB-AVYD":"Buffy - Im Bann der Daemonen - Koenig Darkhuls Zorn (Germany)","AGB-AVYE":"Buffy the Vampire Slayer - Wrath of the Darkhul King (USA, Europe)","AGB-AVYF":"Buffy contre les Vampires - La Colere de Darkhul (France)","AGB-AVZE":"Super Bubble Pop (USA)","AGB-AVZP":"Super Bubble Pop (Europe) (En,Fr,De,Es,It,Nl,Pt,Sv)","AGB-AW2E":"Advance Wars 2 - Black Hole Rising (USA)","AGB-AW2P":"Advance Wars 2 - Black Hole Rising (Europe) (En,Fr,De,Es,It)","AGB-AW3J":"Yumemi-chan no Naritai Series 3 - Watashi no Makesalon (Japan)","AGB-AW4E":"Mortal Kombat - Tournament Edition (USA) (En,Fr,De,Es,It)","AGB-AW7J":"Minna no Shiiku Series 2 - Boku no Kuwagata (Japan)","AGB-AW8E":"WWE - Road to WrestleMania X8 (USA, Europe)","AGB-AW9E":"Wing Commander - Prophecy (USA)","AGB-AW9P":"Wing Commander - Prophecy (Europe) (En,Fr,De,Es,It)","AGB-AWAC":"Waliou Xunbao Ji (China)","AGB-AWAE":"Wario Land 4 (USA, Europe)","AGB-AWAJ":"Wario Land Advance - Youki no Otakara (Japan)","AGB-AWBP":"Worms Blast (Europe) (En,Fr,De,Es,It)","AGB-AWCE":"World Tennis Stars (USA)","AGB-AWCP":"World Tennis Stars (Europe)","AGB-AWDE":"Wakeboarding Unleashed Featuring Shaun Murray (USA)","AGB-AWEJ":"Black Black - Bura Bura (Japan)","AGB-AWFE":"WWF - Road to WrestleMania (USA, Europe)","AGB-AWGP":"Salt Lake 2002 (Europe) (En,Fr,De,Es,It,Nl)","AGB-AWIE":"ESPN International Winter Sports 2002 (USA)","AGB-AWIJ":"Hyper Sports 2002 Winter (Japan)","AGB-AWIP":"ESPN International Winter Sports (Europe)","AGB-AWKJ":"Wagamama Fairy Mirumo de Pon! - Ougon Maracas no Densetsu (Japan)","AGB-AWLE":"Wild Thornberrys Movie, The (USA, Europe)","AGB-AWLF":"Famille Delajungle, La - Le Film (France)","AGB-AWNE":"Spirits & Spells (USA)","AGB-AWNJ":"Mahou no Pumpkin - Ann to Greg no Daibouken (Japan)","AGB-AWNP":"Castleween (Europe) (En,Fr,De,Es,It)","AGB-AWOE":"Wolfenstein 3D (USA, Europe)","AGB-AWPJ":"Winning Post for Game Boy Advance (Japan)","AGB-AWQE":"Wings (USA)","AGB-AWQP":"Wings (Europe)","AGB-AWRC":"Lu-Hai-Kong Dazhan (China) (Proto)","AGB-AWRE":"Advance Wars (USA)","AGB-AWRP":"Advance Wars (Europe) (En,Fr,De,Es)","AGB-AWSE":"Tiny Toon Adventures - Wacky Stackers (USA)","AGB-AWSP":"Tiny Toon Adventures - Wacky Stackers (Europe) (En,Fr,De,Es,It)","AGB-AWTD":"Expedition der Stachelbeeren - Zoff im Zoo (Germany)","AGB-AWTE":"Wild Thornberrys, The - Chimp Chase (USA, Europe)","AGB-AWTF":"Famille Delajungle, La - A la Poursuite de Darwin (France)","AGB-AWUE":"Sabre Wulf (USA)","AGB-AWUP":"Sabre Wulf (Europe) (En,Fr,De)","AGB-AWVE":"X2 - Wolverine's Revenge (USA, Europe)","AGB-AWVF":"X-Men 2 - La Vengeance de Wolverine (France)","AGB-AWWE":"Woody Woodpecker in Crazy Castle 5 (USA)","AGB-AWWJ":"Woody Woodpecker - Crazy Castle 5 (Japan)","AGB-AWWP":"Woody Woodpecker in Crazy Castle 5 (Europe) (En,Fr,De)","AGB-AWXE":"ESPN Winter X-Games Snowboarding 2002 (USA)","AGB-AWXJ":"ESPN Winter X-Games Snowboarding 2002 (Japan) (En)","AGB-AWXP":"ESPN Winter X-Games Snowboarding 2 (Europe)","AGB-AWYE":"Worms - World Party (USA) (En,Fr,De,Es,It)","AGB-AWYP":"Worms - World Party (Europe) (En,Fr,De,Es,It)","AGB-AWZJ":"Wizardry Summoner (Japan)","AGB-AX2E":"Dave Mirra Freestyle BMX 2 (USA)","AGB-AX2P":"Dave Mirra Freestyle BMX 2 (Europe) (En,Fr,De,Es,It)","AGB-AX3E":"xXx (USA, Europe)","AGB-AX3F":"xXx (France)","AGB-AX4E":"Super Mario Advance 4 - Super Mario Bros. 3 (USA)","AGB-AX4J":"Super Mario Advance 4 - Super Mario 3 + Mario Brothers (Japan)","AGB-AX4P":"Super Mario Advance 4 - Super Mario Bros. 3 (Europe) (En,Fr,De,Es,It)","AGB-AXBJ":"Black Matrix Zero (Japan)","AGB-AXDE":"Mortal Kombat - Deadly Alliance (USA) (En,Fr,De,Es,It)","AGB-AXDP":"Mortal Kombat - Deadly Alliance (Europe) (En,Fr,De,Es,It)","AGB-AXHJ":"Dan Doh!! Xi (Japan)","AGB-AXIE":"X-Bladez - Inline Skater (USA)","AGB-AXIP":"X-Bladez - Inline Skater (Europe)","AGB-AXME":"X-Men - Reign of Apocalypse (USA, Europe)","AGB-AXPD":"Pokemon - Saphir-Edition (Germany)","AGB-AXPE":"Pokemon - Sapphire Version (USA, Europe)","AGB-AXPF":"Pokemon - Version Saphir (France)","AGB-AXPI":"Pokemon - Versione Zaffiro (Italy)","AGB-AXPJ":"Pocket Monsters - Sapphire (Japan)","AGB-AXPS":"Pokemon - Edicion Zafiro (Spain)","AGB-AXQF":"Taxi 3 (France)","AGB-AXRE":"Super Street Fighter II Turbo - Revival (USA)","AGB-AXRJ":"Super Street Fighter II X - Revival (Japan)","AGB-AXRP":"Super Street Fighter II Turbo - Revival (Europe)","AGB-AXSE":"ESPN X-Games Skateboarding (USA)","AGB-AXSJ":"ESPN X-Games Skateboarding (Japan) (En)","AGB-AXSP":"ESPN X-Games Skateboarding (Europe) (En,Fr,De,Es,It)","AGB-AXTE":"Island Xtreme Stunts (USA, Europe) (En,Fr,De,Es,It,Nl,Sv,Da)","AGB-AXVD":"Pokemon - Rubin-Edition (Germany)","AGB-AXVE":"Pokemon - Ruby Version (USA, Europe)","AGB-AXVF":"Pokemon - Version Rubis (France)","AGB-AXVI":"Pokemon - Versione Rubino (Italy)","AGB-AXVJ":"Pocket Monsters - Ruby (Japan)","AGB-AXVS":"Pokemon - Edicion Rubi (Spain)","AGB-AXXP":"Santa Claus Jr. Advance (Europe)","AGB-AXYE":"SSX Tricky (USA, Europe) (En,Fr,De)","AGB-AXZP":"Micro Machines (Europe) (En,Fr,De,Es,It)","AGB-AY2P":"International Superstar Soccer Advance (Europe)","AGB-AY3E":"Army Men - Turf Wars (USA)","AGB-AY5E":"Yu-Gi-Oh! - The Eternal Duelist Soul (USA)","AGB-AY5J":"Yu-Gi-Oh! Duel Monsters 5 Expert 1 (Japan)","AGB-AY6J":"Yu-Gi-Oh! Duel Monsters 6 Expert 2 (Japan)","AGB-AY7E":"Yu-Gi-Oh! - The Sacred Cards (USA)","AGB-AY7J":"Yu-Gi-Oh! Duel Monsters 7 - Kettou Toshi Densetsu (Japan)","AGB-AY7P":"Yu-Gi-Oh! - The Sacred Cards (Europe) (En,Fr,De,Es,It)","AGB-AY8E":"Yu-Gi-Oh! - Reshef of Destruction (USA)","AGB-AY8J":"Yu-Gi-Oh! Duel Monsters 8 - Hametsu no Daijashin (Japan)","AGB-AY8P":"Yu-Gi-Oh! - Reshef of Destruction (Europe) (En,Fr,De,Es,It)","AGB-AYAJ":"Dokodemo Taikyoku - Yakuman Advance (Japan)","AGB-AYBE":"Backyard Basketball (USA)","AGB-AYCE":"Phantasy Star Collection (USA)","AGB-AYCP":"Phantasy Star Collection (Europe)","AGB-AYDE":"Yu-Gi-Oh! - Dungeon Dice Monsters (USA) (En,Es)","AGB-AYDJ":"Yu-Gi-Oh! - Dungeon Dice Monsters (Japan)","AGB-AYDP":"Yu-Gi-Oh! - Dungeon Dice Monsters (Europe) (En,Fr,De,Es,It)","AGB-AYEJ":"Top Gear Rally (Japan)","AGB-AYFE":"Backyard Football (USA)","AGB-AYGE":"Gauntlet - Dark Legacy (USA)","AGB-AYHE":"Starsky & Hutch (USA)","AGB-AYHP":"Starsky & Hutch (Europe) (En,Fr,De,Es,It)","AGB-AYIE":"Urban Yeti! (USA, Europe)","AGB-AYKE":"Karnaaj Rally (USA, Europe)","AGB-AYLE":"SEGA Rally Championship (USA)","AGB-AYLJ":"SEGA Rally Championship (Japan) (En)","AGB-AYLP":"SEGA Rally Championship (Europe)","AGB-AYMJ":"Tanbi Musou - Meine Liebe (Japan)","AGB-AYNE":"Planet of the Apes (USA) (En,Fr,De,Es,It,Nl)","AGB-AYNP":"Planet of the Apes (Europe) (En,Fr,De,Es,It,Nl)","AGB-AYPE":"SEGA Arcade Gallery (USA)","AGB-AYPP":"SEGA Arcade Gallery (Europe) (En,Fr,De,Es,It)","AGB-AYRJ":"Narikiri Jockey Game - Yuushun Rhapsody (Japan)","AGB-AYSJ":"Gakkou o Tsukurou!! Advance (Japan)","AGB-AYWE":"Yu-Gi-Oh! - Worldwide Edition - Stairway to the Destined Duel (USA) (En,Ja,Fr,De,Es,It)","AGB-AYWJ":"Yu-Gi-Oh! Duel Monsters International - Worldwide Edition (Japan) (En,Ja,Fr,De,Es,It)","AGB-AYWP":"Yu-Gi-Oh! - Worldwide Edition - Stairway to the Destined Duel (Europe) (En,Ja,Fr,De,Es,It)","AGB-AYZE":"Rayman 3 (USA) (Beta)","AGB-AYZP":"Rayman 3 (Europe) (En,Fr,De,Es,It,Nl,Sv,No,Da,Fi)","AGB-AZ2E":"Zoids - Legacy (USA)","AGB-AZ2J":"Zoids Saga II (Japan)","AGB-AZ3J":"Cyberdrive Zoids - Kijuu no Senshi Hyuu (Japan)","AGB-AZ8E":"Super Puzzle Fighter II (USA)","AGB-AZ8P":"Super Puzzle Fighter II (Europe)","AGB-AZ9J":"Simple 2960 Tomodachi Series Vol. 2 - The Block Kuzushi (Japan)","AGB-AZAJ":"Azumanga Daiou Advance (Japan)","AGB-AZBJ":"Bass Tsuri Shiyouze! - Tournament wa Senryaku da! (Japan)","AGB-AZCE":"Megaman Zero (USA) (Virtual Console)","AGB-AZDP":"Zidane - Football Generation 2002 (Europe) (En,Fr,De,Es,It)","AGB-AZEE":"Zone of the Enders - The Fist of Mars (USA)","AGB-AZEJ":"Z.O.E. 2173 - Testament (Japan)","AGB-AZEP":"Zone of the Enders - The Fist of Mars (Europe) (En,Fr,De)","AGB-AZFE":"Need for Speed - Porsche Unleashed (USA)","AGB-AZFP":"Need for Speed - Porsche Unleashed (Europe) (En,Fr,De,Es,It)","AGB-AZGJ":"Jinsei Game Advance (Japan)","AGB-AZHP":"Hugo - Bukkazoom! (Europe) (En,Fr,De,Es,It,Nl,Pt,Sv,No,Da,Fi,Pl)","AGB-AZID":"Findet Nemo (Germany)","AGB-AZIE":"Finding Nemo (USA, Europe)","AGB-AZIX":"Finding Nemo (Europe) (Fr,Nl)","AGB-AZIY":"Finding Nemo (Europe) (Es,It)","AGB-AZJE":"Dragon Ball Z - Supersonic Warriors (USA)","AGB-AZJJ":"Dragon Ball Z - Bukuu Tougeki (Japan)","AGB-AZJK":"Dragon Ball Z - Moogongtoogeuk (Korea)","AGB-AZJP":"Dragon Ball Z - Supersonic Warriors (Europe) (En,Fr,De,Es,It)","AGB-AZKJ":"Simple 2960 Tomodachi Series Vol. 1 - The Table Game Collection - Mahjong, Shougi, Hanafuda, Reversi (Japan)","AGB-AZLE":"Legend of Zelda, The - A Link to the Past & Four Swords (USA)","AGB-AZLJ":"Zelda no Densetsu - Kamigami no Triforce & 4tsu no Tsurugi (Japan)","AGB-AZLP":"Legend of Zelda, The - A Link to the Past & Four Swords (Europe) (En,Fr,De,Es,It)","AGB-AZME":"Muppets, The - On with the Show! (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-AZNE":"Super Dropzone - Intergalactic Rescue Mission (USA)","AGB-AZNP":"Super Dropzone - Intergalactic Rescue Mission (Europe)","AGB-AZOJ":"Pinball of the Dead, The (Japan)","AGB-AZPE":"Zapper - One Wicked Cricket! (USA)","AGB-AZPP":"Zapper (Europe) (En,Fr,De,Es,It)","AGB-AZQE":"Treasure Planet (USA)","AGB-AZQP":"Treasure Planet (Europe) (En,Fr,De,Es,It,Nl)","AGB-AZRP":"Mr Nutz (Europe) (En,Fr,De,Es,It)","AGB-AZSE":"Gem Smashers (USA)","AGB-AZTJ":"Zero-Tours (Japan)","AGB-AZUE":"Street Fighter Alpha 3 (USA)","AGB-AZUJ":"Street Fighter Zero 3 Upper (Japan)","AGB-AZUP":"Street Fighter Alpha 3 (Europe)","AGB-AZWC":"Waliou Zhizao (China)","AGB-AZWE":"WarioWare, Inc. - Mega Microgame$! (USA)","AGB-AZWJ":"Made in Wario (Japan)","AGB-AZWP":"WarioWare, Inc. - Minigame Mania (Europe) (En,Fr,De,Es,It)","AGB-AZZE":"Rocket Power - Zero Gravity Zone (USA)","AGB-B08J":"One Piece - Going Baseball - Kaizoku Yakyuu (Japan)","AGB-B23E":"Sportsmans Pack 2 in 1 - Cabela's Big Game Hunter + Rapala Pro Fishing (USA)","AGB-B24E":"Pokemon Mystery Dungeon - Red Rescue Team (USA, Australia)","AGB-B24J":"Pokemon Fushigi no Dungeon - Aka no Kyuujotai (Japan)","AGB-B24P":"Pokemon Mystery Dungeon - Red Rescue Team (Europe) (En,Fr,De,Es,It)","AGB-B25E":"Scooby-Doo! - Unmasked (USA) (En,Fr)","AGB-B25X":"Scooby-Doo! - Unmasked (Europe) (En,Fr)","AGB-B25Y":"Scooby-Doo! - Unmasked (Europe) (Es,It)","AGB-B26E":"World Poker Tour (USA)","AGB-B27E":"Top Spin 2 (USA) (En,Fr,De,Es,It)","AGB-B27P":"Top Spin 2 (Europe) (En,Fr,De,Es,It)","AGB-B2AP":"2 in 1 - Asterix & Obelix - Bash Them All! + Asterix & Obelix XXL (Europe) (En,Fr,De,Es,It,Nl)","AGB-B2BP":"2 Games in 1 - The SpongeBob SquarePants Movie + SpongeBob SquarePants and Friends in Freeze Frame Frenzy (Europe) (En,Fr,De,Es,It,Nl+En,Fr,De,Es,Nl)","AGB-B2CE":"Pac-Man World 2 (USA)","AGB-B2CP":"Pac-Man World 2 (Europe) (En,Fr,De,Es,It)","AGB-B2DE":"Donkey Kong Country 2 (USA)","AGB-B2DJ":"Super Donkey Kong 2 (Japan)","AGB-B2DP":"Donkey Kong Country 2 (Europe) (En,Fr,De,Es,It)","AGB-B2EE":"Dora the Explorer Double Pack (USA)","AGB-B2FE":"Family Feud (USA)","AGB-B2HP":"Hugo 2 in 1 (Europe) (En,Fr,De,Es,It,Nl,Pt,Sv,No,Da,Fi,Pl)","AGB-B2KJ":"Kiss x Kiss - Seirei Gakuen (Japan)","AGB-B2LE":"Totally Spies! 2 - Undercover (USA) (En,Fr)","AGB-B2LP":"Totally Spies! 2 - Undercover (Europe) (En,Fr,De,Es,It,Nl)","AGB-B2ME":"Shaman King - Master of Spirits 2 (USA)","AGB-B2MP":"Shaman King - Master of Spirits 2 (Europe) (En,Fr,De)","AGB-B2NE":"Arthur and the Invisibles (USA) (En,Fr,Es)","AGB-B2NP":"Arthur and the Minimoys (Europe) (En,Fr,De,Es,It,Nl)","AGB-B2OJ":"Pro Mahjong Tsuwamono GBA (Japan)","AGB-B2PJ":"Twin Series 7 - Twin Puzzle - Kisekae Wanko EX + Nyaa to Chuu no Rainbow Magic 2 (Japan)","AGB-B2QP":"Prince of Persia - The Sands of Time & Lara Croft Tomb Raider - The Prophecy (Europe) (En,Fr,De,Es,It,Nl+En,Fr,De,Es,It)","AGB-B2RE":"Super Robot Taisen - Original Generation 2 (USA)","AGB-B2RJ":"Super Robot Taisen - Original Generation 2 (Japan)","AGB-B2SJ":"Cinnamon - Yume no Daibouken (Japan)","AGB-B2TE":"Tony Hawk's Underground 2 (USA, Europe)","AGB-B2VE":"Snood 2 - On Vacation (USA)","AGB-B2VP":"Snood 2 - On Vacation (Europe) (En,Fr,De,Es,It)","AGB-B2WE":"Chronicles of Narnia, The - The Lion, the Witch and the Wardrobe (USA, Europe) (En,Fr,De,Es,It,Nl,Sv,Da)","AGB-B2YE":"2K Sports - Major League Baseball 2K7 (USA)","AGB-B33E":"Santa Clause 3, The - The Escape Clause (USA)","AGB-B34E":"Let's Ride! - Sunshine Stables (USA)","AGB-B35E":"Strawberry Shortcake - Summertime Adventure (USA)","AGB-B35P":"Strawberry Shortcake - Ice Cream Island - Riding Camp (Europe) (En,Fr,De,Es,It,Nl,Pt,Da)","AGB-B36E":"Dynasty Warriors Advance (USA)","AGB-B36J":"Shin Sangoku Musou Advance (Japan)","AGB-B36P":"Dynasty Warriors Advance (Europe) (En,Fr,De,Es,It)","AGB-B3AE":"Lord of the Rings, The - The Third Age (USA, Europe) (En,Fr,De,Es,It)","AGB-B3AJ":"Lord of the Rings, The - Nakatsukuni Daisanki (Japan)","AGB-B3BE":"ATV - Thunder Ridge Riders (USA)","AGB-B3BP":"ATV - Thunder Ridge Riders (Europe) (En,Fr,De,Es,It)","AGB-B3CJ":"Summon Night - Craft Sword Monogatari - Hajimari no Ishi (Japan)","AGB-B3DJ":"Densetsu no Stafy 3 (Japan)","AGB-B3EJ":"Sangokushi - Eiketsuden (Japan)","AGB-B3FE":"Polly Pocket! - Super Splash Island (USA) (DSI)","AGB-B3FP":"Polly Pocket! - Super Splash Island (Europe) (En,Fr,De,Es,It) (DSI)","AGB-B3GE":"Sigma Star Saga (USA, Europe)","AGB-B3HE":"Shrek the Third (USA)","AGB-B3HP":"Shrek the Third (Europe) (En,Fr,De,Es,It,Nl)","AGB-B3IJ":"Mirakuru! Panzou - 7-tsu no Hoshi no Uchuu Kaizoku (Japan)","AGB-B3JE":"Curious George (USA)","AGB-B3JP":"Curious George (Europe) (En,Fr,De,Es,It,Sv,Da)","AGB-B3KJ":"Croket! 3 - Granu Oukoku no Nazo (Japan)","AGB-B3LE":"Killer 3D Pool (USA)","AGB-B3LP":"Killer 3D Pool (Europe) (En,Fr,De,Es,It)","AGB-B3MJ":"Mermaid Melody - Pichi Pichi Pitch - Pichi Pichitto Live Start! (Japan)","AGB-B3NE":"Majesco's Sports Pack (USA)","AGB-B3NP":"Majesco's Sports Pack (Europe)","AGB-B3OE":"3 Game Pack! - Mouse Trap + Simon + Operation (USA)","AGB-B3PJ":"PukuPuku Tennen Kairanban - Youkoso! Illusion Land he (Japan)","AGB-B3QJ":"Sangokushi - Koumeiden (Japan)","AGB-B3RE":"Driv3r (USA)","AGB-B3RP":"Driv3r (Europe) (En,Fr,De,Es,It)","AGB-B3SE":"Sonic Advance 3 (USA) (En,Ja,Fr,De,Es,It)","AGB-B3SJ":"Sonic Advance 3 (Japan) (En,Ja,Fr,De,Es,It)","AGB-B3SP":"Sonic Advance 3 (Europe) (En,Ja,Fr,De,Es,It)","AGB-B3TJ":"Tales of the World - Narikiri Dungeon 3 (Japan)","AGB-B3UE":"3 Game Pack! - The Game of Life + Payday + Yahtzee (USA)","AGB-B3XE":"X-Men - The Official Game (USA)","AGB-B3XP":"X-Men - The Official Game (Europe) (En,Fr,Es,It)","AGB-B3YE":"Legend of Spyro, The - A New Beginning (USA)","AGB-B3YP":"Legend of Spyro, The - A New Beginning (Europe) (En,Fr,De,Es,It,Nl)","AGB-B3ZE":"Global Star - Sudoku Fever (USA)","AGB-B3ZP":"Global Star - Sudoku Fever (Europe) (En,Fr,De,Es,It)","AGB-B42J":"Kidou Senshi Gundam - Seed Destiny (Japan)","AGB-B43J":"Cinnamon - Fuwafuwa Daisakusen (Japan)","AGB-B44P":"3 Games in 1 - Rugrats - I Gotta Go Party + SpongeBob SquarePants - SuperSponge + Tak and the Power of Juju (Europe) (En+En+En,Fr,De)","AGB-B46E":"Sims 2, The (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-B4BE":"Megaman - Battle Network 4 - Blue Moon (USA)","AGB-B4BJ":"Rockman EXE 4 - Tournament Blue Moon (Japan)","AGB-B4BP":"Megaman - Battle Network 4 - Blue Moon (Europe)","AGB-B4DE":"Sky Dancers - They Magically Fly! (USA)","AGB-B4DP":"Sky Dancers - They Magically Fly! (Europe) (En,Fr,De,Es,It)","AGB-B4GJ":"Tennis no Ouji-sama 2004 - Glorious Gold (Japan)","AGB-B4IE":"Shrek - Smash n' Crash Racing (USA)","AGB-B4IP":"Shrek - Smash n' Crash Racing (Europe) (En,Fr,De,Es,It)","AGB-B4KJ":"Shikakui Atama o Maruku Suru. Advance - Kanji, Keisan (Japan)","AGB-B4LJ":"Sugar Sugar Rune - Heart Ga Ippai! Moegi Gakuen (Japan)","AGB-B4ME":"Marvel - Ultimate Alliance (USA)","AGB-B4MP":"Marvel - Ultimate Alliance (Europe) (En,It)","AGB-B4OE":"Sims 2, The - Pets (USA, Europe)","AGB-B4OX":"Sims 2, The - Pets (Europe) (En,Fr,De,Es,It,Nl)","AGB-B4PJ":"Sims, The (Japan)","AGB-B4RJ":"Shikakui Atama o Maruku Suru. Advance - Kokugo, Sansuu, Shakai, Rika (Japan)","AGB-B4SJ":"Tennis no Ouji-sama 2004 - Stylish Silver (Japan)","AGB-B4TE":"Strawberry Shortcake - Sweet Dreams (USA)","AGB-B4UE":"Shrek - Super Slam (USA)","AGB-B4UP":"Shrek - Super Slam (Europe) (En,Fr,De,Es,It,Nl)","AGB-B4WE":"Megaman - Battle Network 4 - Red Sun (USA)","AGB-B4WJ":"Rockman EXE 4 - Tournament Red Sun (Japan)","AGB-B4WP":"Megaman - Battle Network 4 - Red Sun (Europe)","AGB-B4ZE":"Megaman Zero 4 (USA)","AGB-B4ZJ":"Rockman Zero 4 (Japan)","AGB-B4ZP":"Megaman Zero 4 (Europe)","AGB-B52P":"Crash & Spyro Super Pack Volume 2 (Europe) (En,Fr,De,Es,It,Nl)","AGB-B53P":"Crash & Spyro Super Pack Volume 3 (Europe) (En,Fr,De,Es,It)","AGB-B54E":"Crash & Spyro Superpack - Spyro - Season of Ice + Crash Bandicoot - The Huge Adventure (USA)","AGB-B55P":"Who Wants to Be a Millionaire - 2nd Edition (Europe)","AGB-B5AP":"Crash & Spyro Super Pack Volume 1 (Europe) (En,Fr,De,Es,It,Nl)","AGB-B5BJ":"Pocket Monsters Diamond - Pocket Monsters Pearl - Manaphy Present Campaign Senyou Cartridge (Japan) (En)","AGB-B5NE":"Namco Museum - 50th Anniversary (USA)","AGB-B5NP":"Namco Museum - 50th Anniversary (Europe) (En,Fr,De,Es,It)","AGB-B62E":"3 Games in One! - Super Breakout + Millipede + Lunar Lander (USA)","AGB-B62P":"3 Games in One! - Super Breakout + Millipede + Lunar Lander (Europe) (En,Fr,De,Es,It)","AGB-B63E":"Big Mutha Truckers (USA)","AGB-B63P":"Big Mutha Truckers (Europe) (En,Fr,De,Es,It)","AGB-B64E":"3 Games in One! - Yars' Revenge + Asteroids + Pong (USA)","AGB-B64P":"3 Games in One! - Yars' Revenge + Asteroids + Pong (Europe) (En,Fr,De,Es,It)","AGB-B65E":"Three-in-One Pack - Connect Four + Perfection + Trouble (USA)","AGB-B66E":"Three-in-One Pack - Risk + Battleship + Clue (USA)","AGB-B67E":"Three-in-One Pack - Sorry! + Aggravation + Scrabble Junior (USA)","AGB-B68E":"2 Games in One! - Marble Madness + Klax (USA)","AGB-B68P":"2 Games in One! - Marble Madness + Klax (Europe) (En,Fr,De,Es,It)","AGB-B69E":"2 Games in One! - Gauntlet + Rampart (USA)","AGB-B69P":"2 Games in One! - Gauntlet + Rampart (Europe) (En,Fr,De,Es,It)","AGB-B6AE":"2 Games in One! - Spy Hunter + Super Sprint (USA)","AGB-B6AP":"2 Games in One! - Spy Hunter + Super Sprint (Europe) (En,Fr,De,Es,It)","AGB-B6BE":"2 Games in One! - Paperboy + Rampage (USA)","AGB-B6BP":"2 Games in One! - Paperboy + Rampage (Europe) (En,Fr,De,Es,It)","AGB-B6EE":"Board Game Classics (USA)","AGB-B6EP":"Board Game Classics - Backgammon & Chess & Draughts (Europe) (En,Fr,De,Es,It)","AGB-B6FE":"Chicken Shoot (USA)","AGB-B6FP":"Chicken Shoot (Europe) (En,Fr,De,Es,It)","AGB-B6GE":"Chicken Shoot 2 (USA)","AGB-B6GP":"Chicken Shoot 2 (Europe) (En,Fr,De,Es,It)","AGB-B6JJ":"Super Robot Taisen J (Japan)","AGB-B6ME":"Madden NFL 06 (USA)","AGB-B6PE":"2 Great Games! - Pac-Man World + Ms. Pac-Man - Maze Madness (USA)","AGB-B6PP":"Pac-Man World & Ms. Pac-Man - Maze Madness (Europe) (En,Fr,De,Es,It)","AGB-B6WE":"2006 FIFA World Cup - Germany 2006 (USA, Europe) (En,Fr,De,Es,It)","AGB-B6ZE":"3 Games in One! - Breakout + Centipede + Warlords (USA)","AGB-B6ZP":"3 Games in One! - Breakout + Centipede + Warlords (Europe) (En,Fr,De,Es,It)","AGB-B72J":"Hudson Best Collection Vol. 2 - Lode Runner Collection (Japan)","AGB-B73J":"Hudson Best Collection Vol. 3 - Action Collection (Japan)","AGB-B74J":"Hudson Best Collection Vol. 4 - Nazotoki Collection (Japan)","AGB-B75J":"Hudson Best Collection Vol. 5 - Shooting Collection (Japan)","AGB-B76J":"Hudson Best Collection Vol. 6 - Bouken-jima Collection (Japan)","AGB-B7FE":"FIFA Soccer 07 (USA, Europe) (En,Fr,De,Es)","AGB-B7IJ":"Hudson Best Collection Vol. 1 - Bomberman Collection (Japan)","AGB-B7ME":"Madden NFL 07 (USA)","AGB-B82E":"Dogz (USA)","AGB-B82J":"Kawaii Koinu Wonderful (Japan)","AGB-B82P":"Dogz (Europe)","AGB-B82X":"Dogz (France)","AGB-B82Y":"Dogz (Europe) (En,Fr,De,It)","AGB-B85A":"Hamtaro - Ham-Ham Games (Japan, USA) (En,Ja)","AGB-B85P":"Hamtaro - Ham-Ham Games (Europe) (En,Fr,De,Es,It)","AGB-B86E":"Hello Kitty - Happy Party Pals (USA)","AGB-B86P":"Hello Kitty - Happy Party Pals (Europe)","AGB-B86X":"Hello Kitty - Happy Party Pals (Europe) (En,Fr,De,Es)","AGB-B8AE":"Crash Superpack - Crash Bandicoot 2 - N-Tranced + Crash Nitro Kart (USA)","AGB-B8CE":"Kingdom Hearts - Chain of Memories (USA)","AGB-B8CJ":"Kingdom Hearts - Chain of Memories (Japan)","AGB-B8CP":"Kingdom Hearts - Chain of Memories (Europe) (En,Fr,De,Es,It)","AGB-B8DE":"Around the World in 80 Days (USA)","AGB-B8DP":"Around the World in 80 Days (Europe) (En,Fr,De,Es,It,Nl)","AGB-B8FE":"Herbie - Fully Loaded (USA)","AGB-B8FP":"Herbie - Fully Loaded (Europe) (En,Fr,De)","AGB-B8KE":"Kirby & The Amazing Mirror (USA)","AGB-B8KJ":"Hoshi no Kirby - Kagami no Daimeikyuu (Japan) (Rev 1)","AGB-B8KP":"Kirby & The Amazing Mirror (Europe) (En,Fr,De,Es,It)","AGB-B8ME":"Mario Party Advance (USA)","AGB-B8MJ":"Mario Party Advance (Japan)","AGB-B8MP":"Mario Party Advance (Europe) (En,Fr,De,Es,It)","AGB-B8PJ":"Power Pro Kun Pocket 1, 2 (Japan)","AGB-B8QE":"Pirates of the Caribbean - Dead Man's Chest (USA, Europe) (En,Fr,De,Es,It)","AGB-B8SE":"Spyro Superpack - Spyro - Season of Ice + Spyro 2 - Season of Flame (USA)","AGB-B94D":"Pferd & Pony - Mein Pferdehof & Pferd and Pony - Lass Uns Reiten 2 (Germany)","AGB-B9AJ":"Kunio-kun Nekketsu Collection 1 (Japan)","AGB-B9BJ":"Kunio-kun Nekketsu Collection 2 (Japan)","AGB-B9CJ":"Kunio-kun Nekketsu Collection 3 (Japan)","AGB-B9SP":"Trick Star (Europe) (En,Fr,De,Es,It)","AGB-B9TJ":"Shark Tale (Japan)","AGB-BAAE":"Operation Armored Liberty (USA)","AGB-BABJ":"Aleck Bordon Adventure - Tower & Shaft Advance (Japan)","AGB-BACP":"Action Man - Robot Atak (Europe) (En,Fr,De,Es,It)","AGB-BADE":"Aladdin (USA) (En,Fr,De,Es)","AGB-BADP":"Aladdin (Europe) (En,Fr,De,Es)","AGB-BAEE":"Ace Combat Advance (USA, Europe)","AGB-BAGJ":"Advance Guardian Heroes (Japan)","AGB-BAHP":"Alien Hominid (Europe) (En,Fr,De,Es,It)","AGB-BAJE":"Banjo-Pilot (USA)","AGB-BAJP":"Banjo-Pilot (Europe) (En,Fr,De,Es,It)","AGB-BAKE":"Koala Brothers - Outback Adventures (USA) (En,Fr,De,Es,It,Nl,Pt,Da)","AGB-BAKP":"Koala Brothers - Outback Adventures (Europe) (En,Fr,De,Es,It,Nl,Pt,Da)","AGB-BALE":"All Grown Up! - Express Yourself (USA, Europe)","AGB-BALX":"Razbitume! - Restez Branches! (Europe) (En,Fr)","AGB-BAMJ":"Ashita no Joe - Makka ni Moeagare! (Japan)","AGB-BANE":"Van Helsing (USA)","AGB-BANP":"Van Helsing (Europe) (En,Fr,De,Es,It)","AGB-BAPE":"American Dragon - Jake Long - Rise of the Huntsclan (USA, Europe) (En,Fr,De,Es,It)","AGB-BAQP":"Premier Action Soccer (Europe) (En,Fr,De,Es,It)","AGB-BARP":"2 Games in 1 - Moto GP + GT Advance 3 - Pro Concept Racing (Europe) (En,Fr,De,Es,It+En)","AGB-BASJ":"Gakuen Alice - Dokidoki Fushigi Taiken (Japan)","AGB-BATE":"Batman - Rise of Sin Tzu (USA) (En,Fr,Es)","AGB-BAUE":"Barbie - The Princess and the Pauper (USA)","AGB-BAUP":"Barbie - The Princess and the Pauper (Europe) (En,Fr,De,Es,It,Nl)","AGB-BAVE":"Activision Anthology (USA)","AGB-BAWE":"Alex Rider - Stormbreaker (USA)","AGB-BAWX":"Alex Rider - Stormbreaker (Europe) (En,Fr,De,Es)","AGB-BAXJ":"Animal Yokochou - Doki Doki Shinkyuu Shiken! no Maki (Japan)","AGB-BAYJ":"Animal Yokochou - Doki Doki Kyuushutsu Daisakusen! no Maki (Japan)","AGB-BAZJ":"Aka-chan Doubutsuen (Japan)","AGB-BB2E":"Beyblade G-Revolution (USA)","AGB-BB2P":"Beyblade G-Revolution (Europe) (En,De,Es,It)","AGB-BB3E":"Barbie in the 12 Dancing Princesses (USA)","AGB-BB3P":"Barbie in the 12 Dancing Princesses (Europe) (En,Fr,De,Es,It)","AGB-BB4E":"2 Game Pack! - Matchbox Missions - Emergency Response + Air, Land and Sea Rescue (USA)","AGB-BB4P":"2 Game Pack! - Matchbox Missions - Emergency Response & Air, Land and Sea Rescue (Europe) (En,Fr,De,Es,It)","AGB-BB5E":"Arctic Tale (USA)","AGB-BB7E":"Backyard Sports - Basketball 2007 (USA)","AGB-BB8E":"Word Safari - The Friendship Totems (USA)","AGB-BB9J":"Boboboubo Boubobo - 9 Kyoku Senshi Gag Yuugou (Japan)","AGB-BBAP":"Shamu's Deep Sea Adventures (Europe)","AGB-BBCE":"Back to Stone (USA) (En,Fr)","AGB-BBCP":"Back to Stone (Europe) (En,Fr)","AGB-BBDE":"BattleBots - Design & Destroy (USA)","AGB-BBEE":"Barbie Superpack - Secret Agent + Groovy Games (USA)","AGB-BBEP":"Barbie Superpack - Secret Agent + Groovy Games (Europe) (En,Fr,De,Es,It)","AGB-BBFJ":"Battle X Battle - Kyodai Gyo Densetsu (Japan)","AGB-BBGE":"Batman Begins (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-BBHE":"Blades of Thunder (USA)","AGB-BBIE":"Barbie Diaries, The - High School Mystery (USA)","AGB-BBIP":"Barbie Diaries, The - High School Mystery (Europe)","AGB-BBJP":"2 Games in 1 - SpongeBob SquarePants - Battle for Bikini Bottom + Jimmy Neutron Boy Genius (Europe) (En,Fr,De+En,Fr,De,Es)","AGB-BBKC":"Yaobai Senxigang (China) (Proto)","AGB-BBKE":"DK - King of Swing (USA)","AGB-BBKJ":"Bura Bura Donkey (Japan)","AGB-BBKP":"DK - King of Swing (Europe) (En,Fr,De,Es,It)","AGB-BBLE":"Teen Titans (USA) (En,Fr)","AGB-BBME":"Battle B-Daman - Fire Spirits! (USA)","AGB-BBMJ":"B-Densetsu! Battle B-Daman - Fire Spirits! (Japan)","AGB-BBNE":"Barbie as The Island Princess (USA)","AGB-BBOE":"Berenstain Bears and the Spooky Old Tree, The (USA)","AGB-BBQJ":"Power Poke Dash (Japan)","AGB-BBRE":"Brother Bear (USA)","AGB-BBRP":"Brother Bear (Europe)","AGB-BBRX":"Brother Bear (Europe) (Fr,De,Es,It,Nl,Sv,Da)","AGB-BBSJ":"Boukyaku no Senritsu (Japan)","AGB-BBUD":"Bratz - The Movie (Germany)","AGB-BBUE":"Bratz - The Movie (USA)","AGB-BBUP":"Bratz - The Movie (Europe)","AGB-BBUX":"Bratz - The Movie (Europe) (Es,It)","AGB-BBVE":"Babar to the Rescue (USA) (En,Fr,Es)","AGB-BBVP":"Babar to the Rescue (Europe) (En,Fr,De,Da)","AGB-BBWE":"Avatar - The Last Airbender - The Burning Earth (USA)","AGB-BBWP":"Avatar - The Legend of Aang - The Burning Earth (Europe) (En,De)","AGB-BBXD":"Bibi Blocksberg - Der Magische Hexenkreis (Germany)","AGB-BBYE":"Barnyard (USA)","AGB-BBYX":"Barnyard (Europe) (En,Fr,De,Es,It,Nl)","AGB-BBZE":"Bratz - Babyz (USA)","AGB-BBZP":"Bratz - Babyz (Europe) (En,Es,It)","AGB-BC2S":"Shin chan contra los Munecos de Shock Gahn (Spain)","AGB-BC3P":"CT Special Forces 3 - Bioterror (Europe) (En,Fr,De,Es,It,Nl)","AGB-BC4E":"3 Game Pack! - Candy Land + Chutes and Ladders + Original Memory Game (USA)","AGB-BC5P":"Cocoto - Kart Racer (Europe) (En,Fr,De,Es,It)","AGB-BC6E":"Capcom Classics Mini Mix (USA)","AGB-BC7E":"Backyard Sports - Baseball 2007 (USA)","AGB-BC8P":"Cocoto - Platform Jumper (Europe) (En,Fr,De,Es,It)","AGB-BC9E":"Spider-Man - Battle for New York (USA)","AGB-BC9P":"Spider-Man - Battle for New York (Europe) (En,Fr,De,Es,It)","AGB-BCAD":"Cars (Germany)","AGB-BCAE":"Cars (USA, Europe)","AGB-BCAI":"Cars - Motori Ruggenti (Italy)","AGB-BCAJ":"Cars (Japan)","AGB-BCAX":"Cars (Europe) (Fr,Nl)","AGB-BCAY":"Cars (Europe) (Es,Pt)","AGB-BCAZ":"Cars (Europe) (Sv,No,Da,Fi)","AGB-BCBE":"Crushed Baseball (USA)","AGB-BCCE":"Nicktoons - Freeze Frame Frenzy (USA)","AGB-BCCX":"SpongeBob SquarePants and Friends in Freeze Frame Frenzy (Europe) (En,Fr,De,Es,Nl)","AGB-BCDE":"Cinderella - Magical Dreams (USA) (En,Fr,De,Es,It)","AGB-BCDP":"Cinderella - Magical Dreams (Europe) (En,Fr,De,Es,It)","AGB-BCFE":"Charlie and the Chocolate Factory (USA) (En,Fr,Es,Nl)","AGB-BCFP":"Charlie and the Chocolate Factory (Europe) (En,Fr,Es,Nl)","AGB-BCGE":"Cabbage Patch Kids - The Patch Puppy Rescue (USA)","AGB-BCGP":"Cabbage Patch Kids - The Patch Puppy Rescue (Europe)","AGB-BCHE":"Chicken Little (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-BCHJ":"Chicken Little (Japan)","AGB-BCJE":"Charlotte's Web (USA) (En,Fr,De,Es,It)","AGB-BCJP":"Charlotte's Web (Europe) (En,Fr,De,Es,It)","AGB-BCKE":"Star Wars Trilogy - Apprentice of the Force (USA) (En,Fr,Es)","AGB-BCKP":"Star Wars Trilogy - Apprentice of the Force (Europe) (En,Fr,De,Es,It,Nl)","AGB-BCLE":"Super Collapse! II (USA)","AGB-BCME":"CIMA - The Enemy (USA)","AGB-BCMJ":"Frontier Stories (Japan)","AGB-BCNE":"Crash Nitro Kart (USA)","AGB-BCNJ":"Crash Bandicoot Bakusou! Nitro Cart (Japan)","AGB-BCNP":"Crash Nitro Kart (Europe) (En,Fr,De,Es,It,Nl)","AGB-BCPE":"Cars - Mater-National Championship (USA) (En,Fr)","AGB-BCPP":"Cars - Mater-National Championship (Europe) (En,Fr,De,Es,It,Nl)","AGB-BCQE":"Cheetah Girls, The (USA)","AGB-BCRP":"Crazy Frog Racer (Europe) (En,Fr,De,Nl)","AGB-BCSP":"2 in 1 - V-Rally 3 + Stuntman (Europe) (En,Fr,De,Es,It)","AGB-BCTE":"Cat in the Hat, The (USA)","AGB-BCUJ":"Ochaken no Yume Bouken (Japan)","AGB-BCVE":"2 Games in 1 - Scooby-Doo! - Mystery Mayhem + Scooby-Doo and the Cyber Chase (USA) (En,Fr,De+En)","AGB-BCVP":"2 Games in 1 - Scooby-Doo! - Mystery Mayhem + Scooby-Doo and the Cyber Chase (Europe) (En,Fr,De+En)","AGB-BCWE":"Catwoman (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-BCXE":"Kid's Cards (USA)","AGB-BCYE":"Backyard Baseball 2006 (USA)","AGB-BCZE":"Street Racing Syndicate (USA)","AGB-BCZP":"Street Racing Syndicate (Europe) (En,Fr,De,Es,It)","AGB-BD2J":"Duel Masters 2 - Invincible Advance (Japan)","AGB-BD3J":"Dragon Quest Characters - Torneko no Daibouken 3 Advance - Fushigi no Dungeon (Japan)","AGB-BD4E":"Crash Bandicoot Purple - Ripto's Rampage (USA)","AGB-BD4P":"Crash Bandicoot Fusion (Europe) (En,Fr,De,Es,It)","AGB-BD5J":"Duel Masters 2 - Kirifuda Shoubu Ver. (Japan)","AGB-BD6E":"Duel Masters - Kaijudo Showdown (USA)","AGB-BD6P":"Duel Masters - Kaijudo Showdown (Europe) (En,Fr,De,Es,It)","AGB-BD7E":"Proud Family, The (USA)","AGB-BD8E":"Disney's Party (USA, Europe) (En,Fr,De,Es,It)","AGB-BD9E":"Dragon Tales - Dragon Adventures (USA)","AGB-BDAJ":"Don-chan Puzzle - Hanabi de Doon! Advance (Japan)","AGB-BDBE":"Dragon Ball Z - Taiketsu (USA)","AGB-BDBP":"Dragon Ball Z - Taiketsu (Europe) (En,Fr,De,Es,It)","AGB-BDCJ":"Doubutsu-jima no Chobigurumi 2 - Tama-chan Monogatari (Japan)","AGB-BDDE":"Double Dragon Advance (USA)","AGB-BDDJ":"Double Dragon Advance (Japan)","AGB-BDEE":"Dead to Rights (USA)","AGB-BDEP":"Dead to Rights (Europe) (En,Fr,De,Es,It)","AGB-BDFE":"2 Games in 1 - SpongeBob SquarePants - Revenge of the Flying Dutchman + SpongeBob SquarePants - SuperSponge (USA)","AGB-BDFP":"2 Games in 1 - SpongeBob SquarePants - Revenge of the Flying Dutchman + SpongeBob SquarePants - SuperSponge (Europe)","AGB-BDGE":"Digimon Racing (USA) (En,Fr,De,Es,It)","AGB-BDGP":"Digimon Racing (Europe) (En,Fr,De,Es,It)","AGB-BDHJ":"Shin Megami Tensei - Devil Children - Honoo no Sho (Japan)","AGB-BDIJ":"Koinu to Issho - Aijou Monogatari (Japan)","AGB-BDJJ":"Digimon Racing (Japan)","AGB-BDKJ":"DigiCommunication Nyo - Datou! Black Gemagema Dan (Japan)","AGB-BDLJ":"Shin Megami Tensei - Devil Children - Messiah Riser (Japan)","AGB-BDMJ":"Super Real Mahjong Dousoukai (Japan)","AGB-BDNJ":"Dan Doh!! Tobase Shouri no Smile Shot (Japan)","AGB-BDOE":"Dora the Explorer - Super Star Adventures! (USA)","AGB-BDOP":"Dora the Explorer - Super Star Adventures! (Europe) (En,Fr,Nl)","AGB-BDPE":"Super Duper Sumos (USA)","AGB-BDQE":"Donkey Kong Country 3 (USA)","AGB-BDQJ":"Super Donkey Kong 3 (Japan)","AGB-BDQP":"Donkey Kong Country 3 (Europe) (En,Fr,De,Es,It)","AGB-BDRJ":"Ochaken no Heya (Japan)","AGB-BDSE":"Digimon - Battle Spirit 2 (USA) (En,Fr,De,Es,It)","AGB-BDSP":"Digimon - Battle Spirit 2 (Europe) (En,Fr,De,Es,It)","AGB-BDTE":"River City Ransom EX (USA)","AGB-BDTJ":"Downtown - Nekketsu Monogatari EX (Japan)","AGB-BDUE":"Duel Masters - Shadow of the Code (USA)","AGB-BDUJ":"Duel Masters 3 (Japan)","AGB-BDUP":"Duel Masters - Shadow of the Code (Europe) (En,Fr,De,Es,It)","AGB-BDVE":"Dragon Ball - Advanced Adventure (USA)","AGB-BDVJ":"Dragon Ball - Advance Adventure (Japan)","AGB-BDVK":"Dragon Ball - Advance Adventure (Korea)","AGB-BDVP":"Dragon Ball - Advanced Adventure (Europe) (En,Fr,De,Es,It)","AGB-BDXE":"Battle B-Daman (USA)","AGB-BDXJ":"B-Densetsu! Battle B-Daman - Moero! B-Damashii!! (Japan)","AGB-BDYJ":"Shin Megami Tensei - Devil Children - Koori no Sho (Japan)","AGB-BDZD":"2 Games in 1 - Die Monster AG + Findet Nemo (Germany)","AGB-BDZE":"2 Games in 1 - Monsters, Inc. + Finding Nemo (USA)","AGB-BDZF":"2 Games in 1 - Monstres & Cie + Le Monde de Nemo (France) (En,Fr,It+Fr,Nl)","AGB-BDZH":"2 Games in 1 - Monsters en Co. + Finding Nemo (Netherlands) (En,Es,Nl+Fr,Nl)","AGB-BDZI":"2 Games in 1 - Monsters & Co. + Alla Ricerca di Nemo (Italy) (En,Fr,It+Es,It)","AGB-BDZP":"2 Games in 1 - Monsters, Inc. + Finding Nemo (Europe)","AGB-BDZS":"2 Games in 1 - Monstruos, S.A. + Buscando a Nemo (Spain) (En,Es,Nl+Es,It)","AGB-BE2J":"Majokko Cream-chan no Gokko Series 2 - Kisekae Angel (Japan)","AGB-BE3E":"Star Wars - Episode III - Revenge of the Sith (USA) (En,Fr,Es)","AGB-BE3P":"Star Wars - Episode III - Revenge of the Sith (Europe) (En,Fr,De,Es,It,Nl)","AGB-BE4J":"Eyeshield 21 - DevilBats DevilDays (Japan)","AGB-BE5E":"Barbie and the Magic of Pegasus (USA)","AGB-BE5P":"Barbie and the Magic of Pegasus (Europe) (En,Fr,De,Es,It,Nl)","AGB-BE8E":"Fire Emblem - The Sacred Stones (USA) (Virtual Console)","AGB-BE8J":"Fire Emblem - Seima no Kouseki (Japan)","AGB-BE8P":"Fire Emblem - The Sacred Stones (Europe) (En,Fr,De,Es,It)","AGB-BEAE":"Care Bears - The Care Quest (USA) (En,Fr,Es)","AGB-BEAP":"Care Bears - The Care Quest (Europe) (En,Fr,De,Es,It,Nl,Pt,Da)","AGB-BEBE":"Elf Bowling 1 & 2 (USA)","AGB-BECJ":"Angel Collection 2 - Pichimo ni Narou (Japan)","AGB-BEDE":"Ed, Edd n Eddy - The Mis-Edventures (USA) (En,Fr)","AGB-BEDP":"Ed, Edd n Eddy - The Mis-Edventures (Europe) (En,Fr)","AGB-BEEP":"Maya the Bee - Sweet Gold (Europe) (En,Fr,De,Es,It)","AGB-BEFE":"Let's Ride! - Friends Forever (USA)","AGB-BEFP":"Pferd & Pony - Best Friends - Mein Pferd (Germany) (En,De)","AGB-BEIE":"Little Einsteins (USA)","AGB-BEJJ":"Erementar Gerad - Tozasareshi Uta (Japan)","AGB-BELE":"Elf - The Movie (USA) (En,Fr,De,Es,It)","AGB-BELP":"Elf - The Movie (Europe) (En,Fr,De,Es,It)","AGB-BEME":"M&M's - Break' Em (USA) (Rev 1)","AGB-BENE":"Eragon (USA)","AGB-BENP":"Eragon (Europe) (En,Fr,De,Es,It)","AGB-BERE":"Dora the Explorer - Super Spies (USA)","AGB-BESE":"Extreme Skate Adventure (USA) (Rev 1)","AGB-BESX":"Extreme Skate Adventure (Europe) (Fr,De)","AGB-BETE":"Atomic Betty (USA, Europe)","AGB-BEVE":"everGirl (USA)","AGB-BEXE":"TMNT (USA) (En,Fr,Es)","AGB-BEXP":"TMNT - Teenage Mutant Ninja Turtles (Europe) (En,Fr,De,Es,It)","AGB-BEYE":"Beyblade V-Force - Ultimate Blader Jam (USA)","AGB-BEYP":"Beyblade V-Force - Ultimate Blader Jam (Europe) (En,Fr,De,Es,It)","AGB-BF2D":"Cosmo & Wanda - Wenn Elfen Helfen! - Das Schattenduell (Germany)","AGB-BF2E":"Fairly OddParents!, The - Shadow Showdown (USA)","AGB-BF2P":"Fairly OddParents!, The - Shadow Showdown (Europe)","AGB-BF3E":"Ford Racing 3 (USA)","AGB-BF3P":"Ford Racing 3 (Europe) (En,Fr,De,Es,It)","AGB-BF4E":"Fantastic 4 (USA)","AGB-BF4I":"Fantastici 4, I (Italy)","AGB-BF4P":"Fantastic 4 (Europe)","AGB-BF4X":"Fantastic 4 (Europe) (Fr,De,Es,Nl)","AGB-BF5E":"FIFA Soccer 2005 (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-BF6E":"FIFA Soccer 06 (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-BF7E":"Backyard Sports - Football 2007 (USA)","AGB-BF8E":"Super Hornet FA 18F (USA, Europe)","AGB-BFBP":"2 Disney Games - Disney Sports - Football + Disney Sports - Skateboarding (Europe) (En,Fr,De,Es,It)","AGB-BFCJ":"Fantastic Children (Japan)","AGB-BFDJ":"Fruits Mura no Doubutsu-tachi (Japan)","AGB-BFEE":"Dogz - Fashion (USA)","AGB-BFEP":"Dogz - Fashion (Europe)","AGB-BFFE":"Final Fantasy I & II - Dawn of Souls (USA)","AGB-BFFJ":"Final Fantasy I, II Advance (Japan) (Rev 1)","AGB-BFFP":"Final Fantasy I & II - Dawn of Souls (Europe) (En,Fr,De,Es,It)","AGB-BFGE":"Harvest Moon - More Friends of Mineral Town (USA)","AGB-BFGJ":"Bokujou Monogatari - Mineral Town no Nakama-tachi for Girl (Japan)","AGB-BFIE":"FIFA Soccer 2004 (USA, Europe) (En,Fr,De,Es,It)","AGB-BFJE":"Frogger's Journey - The Forgotten Relic (USA)","AGB-BFJJ":"Frogger - Kodaibunmei no Nazo (Japan)","AGB-BFKE":"Franklin the Turtle (USA) (En,Fr,De,Es,It,Sv,No,Da,Fi)","AGB-BFKP":"Franklin the Turtle (Europe) (En,Fr,De,Es,It,Sv,No,Da,Fi) (Rev 2)","AGB-BFLE":"Franklin's Great Adventures (USA) (En,Fr,Es)","AGB-BFLP":"Franklin's Great Adventures (Europe) (En,Fr,De,Es,It,Nl,Pt,Da)","AGB-BFMJ":"Futari wa Pretty Cure Max Heart - Maji Maji! Fight de IN Janai (Japan)","AGB-BFNJ":"Finding Nemo (Japan)","AGB-BFOE":"Fairly OddParents!, The - Clash with the Anti-World (USA)","AGB-BFOP":"Fairly OddParents!, The - Clash with the Anti-World (Europe) (En,De,Es,Nl)","AGB-BFPJ":"Futari wa Pretty Cure - Arienaai! Yume no Sono wa Daimeikyuu (Japan)","AGB-BFQE":"Mazes of Fate (USA) (En,Fr,De,Es,It)","AGB-BFSE":"Freekstyle (USA)","AGB-BFSP":"Freekstyle (Europe) (En,Fr,De,Es,It)","AGB-BFTJ":"F-Zero - Climax (Japan)","AGB-BFUE":"Fear Factor - Unleashed (USA)","AGB-BFVJ":"Twin Series 1 - Mezase Debut! - Fashion Designer Monogatari + Kawaii Pet Game Gallery 2 (Japan)","AGB-BFWP":"2 Games in 1 - Finding Nemo + Finding Nemo - The Continuing Adventures (Europe) (En+En,Es,It,Sv,Da)","AGB-BFWX":"2 Games in 1 - Finding Nemo + Finding Nemo - The Continuing Adventures (Europe) (Fr,Nl+Fr,De,Nl)","AGB-BFWY":"2 Games in 1 - Findet Nemo + Findet Nemo - Das Abenteuer Geht Weiter (Germany) (De+Fr,De,Nl)","AGB-BFWZ":"2 Games in 1 - Finding Nemo + Finding Nemo - The Continuing Adventures (Europe) (Es,It+En,Es,It,Sv,Da)","AGB-BFXE":"Phil of the Future (USA)","AGB-BFYE":"Foster's Home for Imaginary Friends (USA)","AGB-BFYP":"Foster's Home for Imaginary Friends (Europe)","AGB-BFZE":"F-Zero - GP Legend (USA)","AGB-BFZJ":"F-Zero - Falcon Densetsu (Japan)","AGB-BFZP":"F-Zero - GP Legend (Europe) (En,Fr,De,Es,It)","AGB-BG2J":"Kessaku Sen! - Ganbare Goemon 1, 2 - Yuki Hime to Magginesu (Japan)","AGB-BG3E":"Dragon Ball Z - Buu's Fury (USA)","AGB-BG4J":"SD Gundam Force (Japan) (En)","AGB-BG5E":"Cabela's Big Game Hunter - 2005 Adventures (USA, Europe)","AGB-BG6E":"Super Army War (USA)","AGB-BG6P":"Essence of War, The - Glory Days (Europe) (En,Fr,De,Es,It)","AGB-BG7E":"Games Explosion! (USA)","AGB-BG8J":"Ganbare! Dodge Fighters (Japan)","AGB-BG9E":"Garfield and His Nine Lives (USA) (En,Fr,Es)","AGB-BG9P":"Garfield and His Nine Lives (Europe) (En,Fr,De,Es,It,Nl)","AGB-BGAJ":"SD Gundam G Generation Advance (Japan)","AGB-BGBJ":"Get! - Boku no Mushi Tsukamaete (Japan)","AGB-BGCE":"Advance Guardian Heroes (USA)","AGB-BGCP":"Advance Guardian Heroes (Europe) (En,Fr)","AGB-BGDE":"Baldur's Gate - Dark Alliance (USA)","AGB-BGDP":"Baldur's Gate - Dark Alliance (Europe) (En,Fr,De,Es,It)","AGB-BGEE":"SD Gundam Force (USA)","AGB-BGFJ":"GetBackers Dakkanya - Jagan Fuuin! (Japan)","AGB-BGGE":"Golden Nugget Casino (USA, Europe)","AGB-BGHJ":"Gakkou no Kaidan - Hyakuyoubako no Fuuin (Japan)","AGB-BGIJ":"Get Ride! Amdriver - Senkou no Hero Tanjou! (Japan)","AGB-BGJJ":"Genseishin Justirisers - Souchaku! Hoshi no Senshi-tachi (Japan)","AGB-BGKJ":"Gegege no Kitarou - Kikiippatsu! Youkai Rettou (Japan)","AGB-BGMJ":"Gensou Maden Saiyuuki - Hangyaku no Toushin-taishi (Japan)","AGB-BGNE":"Mobile Suit Gundam Seed - Battle Assault (USA)","AGB-BGNJ":"Kidou Senshi Gundam Seed - Tomo to Kimi to Koko de. (Japan)","AGB-BGOE":"Garfield - The Search for Pooky (USA) (En,Fr,De,Es,It)","AGB-BGOP":"Garfield - The Search for Pooky (Europe) (En,Fr,De,Es,It) (Rev 2)","AGB-BGPJ":"Get Ride! Amdriver - Shutsugeki! Battle Party (Japan)","AGB-BGQE":"Greg Hastings' Tournament Paintball Max'd (USA)","AGB-BGSJ":"Gakuen Senki Muryou (Japan)","AGB-BGTE":"Grand Theft Auto (USA)","AGB-BGTP":"Grand Theft Auto (Europe) (En,Fr,De,Es,It)","AGB-BGVE":"Gumby vs. the Astrobots (USA)","AGB-BGWJ":"Game Boy Wars Advance 1+2 (Japan)","AGB-BGXJ":"Gunstar Super Heroes (Japan)","AGB-BGYJ":"Konjiki no Gashbell!! - Unare! Yuujou no Zakeru 2 (Japan)","AGB-BGZE":"Madagascar (USA)","AGB-BGZH":"Madagascar (Netherlands)","AGB-BGZI":"Madagascar (Italy)","AGB-BGZJ":"Madagascar (Japan)","AGB-BGZP":"Madagascar (Europe)","AGB-BGZS":"Madagascar (Spain)","AGB-BGZX":"Madagascar (Europe) (Fr,De,Pt)","AGB-BH2J":"Hagane no Renkinjutsushi - Omoide no Sonata (Japan)","AGB-BH4E":"Fantastic 4 - Flame On (USA)","AGB-BH4P":"Fantastic 4 - Flame On (Europe) (En,Fr,Es,It)","AGB-BH5D":"Ab durch die Hecke (Germany)","AGB-BH5E":"Over the Hedge (USA)","AGB-BH5F":"Nos Voisins, les Hommes (France)","AGB-BH5H":"Over the Hedge - Beesten bij de Buren (Netherlands)","AGB-BH5I":"Gang del Bosco, La (Italy)","AGB-BH5P":"Over the Hedge (Europe)","AGB-BH5S":"Vecinos Invasores (Spain)","AGB-BH6J":"Kidou Gekidan Haro Ichiza - Haro no Puyo Puyo (Japan)","AGB-BH7E":"Over the Hedge - Hammy Goes Nuts! (USA)","AGB-BH7P":"Over the Hedge - Hammy Goes Nuts! (Europe) (En,Fr,De,Es,It,Nl)","AGB-BH8E":"Harry Potter and the Goblet of Fire (USA, Europe) (En,Fr,De,Es,It,Nl,Da)","AGB-BH9E":"Tony Hawk's American Sk8land (USA)","AGB-BH9P":"Tony Hawk's American Sk8land (Europe)","AGB-BH9X":"Tony Hawk's American Sk8land (Europe) (Fr,De,Es,It)","AGB-BHAJ":"Hanabi Hyakkei Advance (Japan)","AGB-BHBE":"Paws & Claws - Best Friends - Dogs & Cats (USA)","AGB-BHBP":"Best Friends - Hunde & Katzen (Germany) (En,De)","AGB-BHCJ":"Hamster Monogatari Collection (Japan)","AGB-BHDJ":"Hello! Idol Debut - Kids Idol Ikusei Game (Japan)","AGB-BHEE":"Hot Wheels - Stunt Track Challenge (USA, Europe)","AGB-BHFJ":"Twin Series 4 - Hamu Hamu Monster EX - Hamster Monogatari RPG + Fantasy Puzzle - Hamster Monogatari - Mahou no Meikyuu 1.2.3 (Japan)","AGB-BHGE":"Gunstar Super Heroes (USA)","AGB-BHGP":"Gunstar Future Heroes (Europe) (En,Ja,Fr,De,Es,It)","AGB-BHHE":"Hi Hi Puffy AmiYumi - Kaznapped! (USA)","AGB-BHHJ":"Hi Hi Puffy AmiYumi (Japan)","AGB-BHHP":"Hi Hi Puffy AmiYumi - Kaznapped! (Europe) (En,De)","AGB-BHJP":"Heidi - The Game (Europe) (En,Fr,De,Es,It)","AGB-BHLE":"Shaman King - Legacy of the Spirits - Soaring Hawk (USA)","AGB-BHME":"Home on the Range (USA) (En,Fr)","AGB-BHMP":"Home on the Range (Europe) (En,Fr,De,Es)","AGB-BHNE":"Harlem Globetrotters - World Tour (USA)","AGB-BHNP":"Harlem Globetrotters - World Tour (Europe) (En,Fr,De,Es,It)","AGB-BHOF":"Hardcore Pool (France)","AGB-BHOP":"Hardcore Pool (Europe) (En,De,Es,It)","AGB-BHPE":"Harry Potter - Quidditch World Cup (USA, Europe) (En,Fr,De,Es,It,Nl,Da)","AGB-BHPJ":"Harry Potter - Quidditch World Cup (Japan)","AGB-BHQP":"Agent Hugo - Roborumble (Europe) (En,Fr,De,Es,It,Nl,Pt,Sv,No,Da,Fi)","AGB-BHRJ":"Hagane no Renkinjutsushi - Meisou no Rondo (Japan)","AGB-BHSJ":"Hamster Monogatari 3EX 4 Special (Japan)","AGB-BHTE":"Harry Potter and the Prisoner of Azkaban (USA, Europe) (En,Fr,De,Es,It,Nl,Da)","AGB-BHTJ":"Harry Potter to Azkaban no Shuujin (Japan)","AGB-BHUE":"Horsez (USA)","AGB-BHUP":"Pferd & Pony - Mein Gestuet (Germany) (En,De)","AGB-BHVE":"Scurge - Hive (USA) (En,Fr,Es)","AGB-BHVP":"Scurge - Hive (Europe) (En,Fr,De,Es,It)","AGB-BHWE":"Hot Wheels - World Race (USA)","AGB-BHWP":"Hot Wheels - World Race (Europe)","AGB-BHXE":"Hot Wheels - All Out (USA)","AGB-BHXP":"Hot Wheels - All Out (Europe) (En,Fr,De,Es,It)","AGB-BHYJ":"Minna no Soft Series - Hyokkori Hyoutan-jima - Don Gabacho Daikatsuyaku no Maki (Japan)","AGB-BHZE":"2 Games in 1 - Hot Wheels - Velocity X + Hot Wheels - World Race (USA)","AGB-BHZP":"2 Games in 1 - Hot Wheels - Velocity X + Hot Wheels - World Race (Europe)","AGB-BI2J":"Koinu to Issho 2 (Japan)","AGB-BI3D":"Spider-Man 3 (Germany)","AGB-BI3E":"Spider-Man 3 (USA)","AGB-BI3F":"Spider-Man 3 (France)","AGB-BI3I":"Spider-Man 3 (Italy)","AGB-BI3P":"Spider-Man 3 (Europe)","AGB-BI3S":"Spider-Man 3 (Spain)","AGB-BI4E":"4 Games on One Game Pak (Racing) (USA) (En,Fr,De,Es,It)","AGB-BI6E":"4 Games on One Game Pak (Nickelodeon Movies) (USA)","AGB-BI7E":"4 Games on One Game Pak (Nicktoons) (USA)","AGB-BIAE":"Ice Age 2 - The Meltdown (USA)","AGB-BIAP":"Ice Age 2 - The Meltdown (Europe) (En,Fr,De,Es,It,Nl)","AGB-BIBE":"Bible Game, The (USA)","AGB-BICD":"Unglaublichen, Die (Germany)","AGB-BICE":"Incredibles, The (USA, Europe)","AGB-BICI":"Incredibili, Gli - Una 'Normale' Famiglia di Supereroi (Italy)","AGB-BICJ":"Mr. Incredible (Japan)","AGB-BICS":"Increibles, Los (Spain)","AGB-BICX":"Incredibles, The (Europe) (Fr,Nl)","AGB-BIDD":"Deutschland Sucht den Superstar (Germany)","AGB-BIDE":"American Idol (USA)","AGB-BIDP":"Pop Idol (Europe)","AGB-BIEE":"Grim Adventures of Billy & Mandy, The (USA)","AGB-BIHE":"Bionicle Heroes (USA) (En,Fr,De,Es,It,Da)","AGB-BIHP":"Bionicle Heroes (Europe) (En,Fr,De,Es,It,Da)","AGB-BIIC":"Tongqin Yi Bi (China) (Proto)","AGB-BIIE":"Polarium Advance (USA)","AGB-BIIJ":"Tsuukin Hitofude (Japan)","AGB-BIIP":"Polarium Advance (Europe) (En,Fr,De,Es,It)","AGB-BIJE":"Sonic The Hedgehog - Genesis (USA)","AGB-BIKJ":"Ochaken Kururin - Honwaka Puzzle de Hotto Shiyo (Japan)","AGB-BILE":"Bionicle - Maze of Shadows (USA)","AGB-BILP":"Bionicle - Maze of Shadows (Europe) (En,De)","AGB-BIME":"Dogz 2 (USA)","AGB-BIMP":"Dogz 2 (Europe)","AGB-BIMX":"Dogz 2 (Europe) (En,Fr,De,It)","AGB-BIND":"2 Games in 1 - Findet Nemo + Die Unglaublichen (Germany)","AGB-BINP":"2 Games in 1 - Finding Nemo + The Incredibles (Europe)","AGB-BINX":"2 Games in 1 - Finding Nemo + The Incredibles (Europe) (Fr,Nl)","AGB-BINY":"2 Games in 1 - Alla Ricerca di Nemo + Gli Incredibili - Una 'Normale' Famiglia di Supereroi (Italy) (Es,It+It)","AGB-BINZ":"2 Games in 1 - Buscando a Nemo + Los Increibles (Spain) (Es,It+Es)","AGB-BIOE":"Bionicle (USA)","AGB-BIOP":"Bionicle (Europe) (En,Fr,De,Da)","AGB-BIPJ":"One Piece - Dragon Dream (Japan)","AGB-BIQE":"Incredibles, The - Rise of the Underminer (USA, Europe)","AGB-BIQJ":"Mr. Incredible - Kyouteki Underminer Toujou (Japan)","AGB-BIQX":"Incredibles, The - Rise of the Underminer (Europe) (En,Fr,De,Es,It,Nl,Pt)","AGB-BIRK":"Iron Kid (Korea)","AGB-BISJ":"Koinu-chan no Hajimete no Osanpo - Koinu no Kokoro Ikusei Game (Japan)","AGB-BITJ":"Onmyou Taisenki - Zeroshiki (Japan)","AGB-BIVP":"Ignition Collection - Volume 1 (Europe)","AGB-BIWE":"Holy Bible, The - World English Bible (USA) (Proto)","AGB-BIXJ":"Calciobit (Japan)","AGB-BIYE":"Math Patrol - The Kleptoid Threat (USA)","AGB-BJ2E":"High School Musical - Livin' the Dream (USA)","AGB-BJ3J":"Zettaizetsumei Dangerous Jiisan 3 - Hateshinaki Mamonogatari (Japan)","AGB-BJAP":"GT Racers (Europe) (En,Fr,De,Es,It)","AGB-BJBE":"007 - Everything or Nothing (USA, Europe) (En,Fr,De)","AGB-BJBJ":"007 - Everything or Nothing (Japan)","AGB-BJCJ":"Moero!! Jaleco Collection (Japan)","AGB-BJDP":"Dragon's Rock (Europe) (En,Fr,De,Es,It)","AGB-BJGE":"Glucoboy (Australia)","AGB-BJHE":"Justice League Heroes - The Flash (USA)","AGB-BJHP":"Justice League Heroes - The Flash (Europe) (En,Fr,De,Es,It)","AGB-BJKE":"Juka and the Monophonic Menace (USA) (En,Fr,Es)","AGB-BJKP":"Juka and the Monophonic Menace (Europe) (En,Fr,De,Es,It)","AGB-BJLE":"Justice League - Chronicles (USA)","AGB-BJNE":"Adventures of Jimmy Neutron Boy Genius, The - Jet Fusion (USA, Europe)","AGB-BJTE":"Tom and Jerry Tales (USA) (En,Fr,Es)","AGB-BJTP":"Tom and Jerry Tales (Europe) (En,Fr,De,Es,It)","AGB-BJUE":"Tak and the Power of Juju (USA)","AGB-BJUP":"Tak and the Power of Juju (Europe) (En,Fr,De)","AGB-BJUX":"Tak and the Power of Juju (Europe) (Es,It)","AGB-BJWE":"American Dragon - Jake Long - Rise of the Huntsclan (USA, Europe) (Beta 1)","AGB-BJWX":"Tak - The Great Juju Challenge (Europe) (En,Fr,De,Nl)","AGB-BJXE":"Harry Potter and the Order of the Phoenix (USA, Europe) (En,Fr,De,Es,It,Nl,Da)","AGB-BJYE":"Adventures of Jimmy Neutron Boy Genius, The - Attack of the Twonkies (USA, Europe)","AGB-BJYF":"Jimmy Neutron un Garcon Genial - L'Attaque des Twonkies (France)","AGB-BK2J":"Croket! 2 - Yami no Bank to Banqueen (Japan)","AGB-BK3J":"Cardcaptor Sakura - Sakura Card de Mini Game (Japan)","AGB-BK4J":"Croket! 4 - Bank no Mori no Mamorigami (Japan)","AGB-BK5J":"Croket! Great - Toki no Boukensha (Japan)","AGB-BK6J":"Kouchuu Ouja Mushiking - Greatest Champion e no Michi (Japan)","AGB-BK7E":"Kong - King of Atlantis (USA)","AGB-BK7P":"Kong - King of Atlantis (Europe)","AGB-BK8J":"Kappa no Kai-kata - Kaatan Daibouken! (Japan)","AGB-BKAJ":"Sennen Kazoku (Japan)","AGB-BKBJ":"Konjiki no Gashbell!! - Makai no Bookmark (Japan)","AGB-BKCJ":"Crayon Shin-chan - Arashi o Yobu Cinemaland no Daibouken! (Japan)","AGB-BKCS":"Shin chan - Aventuras en Cineland (Spain)","AGB-BKDJ":"Crash Bandicoot Advance - Wakuwaku Tomodachi Daisakusen! (Japan)","AGB-BKEJ":"Konjiki no Gashbell!! The Card Battle for GBA (Japan)","AGB-BKFE":"Bee Game, The (USA)","AGB-BKFX":"Biene Maja, Die - Klatschmohnwiese in Gefahr (Germany)","AGB-BKGJ":"Kawaii Pet Game Gallery (Japan)","AGB-BKHE":"Kill Switch (USA)","AGB-BKHP":"Kill Switch (Europe) (En,Fr,De,Es,It)","AGB-BKIJ":"Nakayoshi Pet Advance Series 4 - Kawaii Koinu Mini - Wanko to Asobou!! Kogata-ken (Japan)","AGB-BKJJ":"Keroro Gunsou - Taiketsu! Gekisou Keronprix Daisakusen de Arimasu!! (Japan)","AGB-BKKJ":"Minna no Shiiku Series - Boku no Kabuto, Kuwagata (Japan)","AGB-BKME":"Kim Possible 2 - Drakken's Demise (USA) (En,Fr)","AGB-BKMJ":"Kim Possible (Japan)","AGB-BKMP":"Kim Possible 2 - Drakken's Demise (Europe) (En,Fr,De,Es)","AGB-BKNE":"Knights' Kingdom (USA)","AGB-BKNP":"Knights' Kingdom (Europe) (En,De)","AGB-BKOJ":"Kaiketsu Zorori to Mahou no Yuuenchi - Ohimesama o Sukue! (Japan)","AGB-BKPJ":"Kawaii Pet Game Gallery 2 (Japan)","AGB-BKQE":"Kong - The 8th Wonder of the World (USA) (En,Fr,Es)","AGB-BKQP":"King Kong - The Official Game of the Movie (Europe) (En,Fr,De,Es,It,Nl)","AGB-BKQX":"King Kong - The Official Game of the Movie (Europe) (En,Sv,No,Da,Fi)","AGB-BKRJ":"No No No Puzzle Chailien (Japan)","AGB-BKSJ":"Cardcaptor Sakura - Sakura Card Hen - Sakura to Card to Otomodachi (Japan)","AGB-BKTJ":"Koutetsu Teikoku (Japan)","AGB-BKTP":"Steel Empire (Europe)","AGB-BKUJ":"Shingata Medarot - Kuwagata Version (Japan)","AGB-BKVJ":"Shingata Medarot - Kabuto Version (Japan)","AGB-BKWE":"Bookworm (USA)","AGB-BKZE":"Banjo-Kazooie - Grunty's Revenge (USA, Europe)","AGB-BKZI":"Banjo-Kazooie - La Vendetta di Grunty (Italy)","AGB-BKZS":"Banjo-Kazooie - La Venganza de Grunty (Spain)","AGB-BKZX":"Banjo-Kazooie - Grunty's Revenge (Europe) (En,Fr,De)","AGB-BL2E":"Lizzie McGuire 2 - Lizzie Diaries (USA) (En,Fr)","AGB-BL3E":"Lizzie McGuire 3 - Homecoming Havoc (USA)","AGB-BL4E":"Disney's Game + TV Episode - Lizzie McGuire 2 - Lizzie Diaries (USA) (En,Fr)","AGB-BL5P":"2 Games in 1 - Bionicle + Knights' Kingdom (Europe) (En,Fr,De,Da+En,De)","AGB-BL6E":"My Little Pony - Crystal Princess - The Runaway Rainbow (USA)","AGB-BL7E":"LEGO Star Wars II - The Original Trilogy (USA)","AGB-BL7P":"LEGO Star Wars II - The Original Trilogy (Europe) (En,Fr,De,Es,It,Da)","AGB-BL8E":"Lara Croft Tomb Raider - Legend (USA) (En,Fr,De,Es,It)","AGB-BL8P":"Lara Croft Tomb Raider - Legend (Europe) (En,Fr,De,Es,It)","AGB-BL9E":"Let's Ride! - Dreamer (USA)","AGB-BLAE":"Scrabble Blast! (USA)","AGB-BLAP":"Scrabble Scramble! (Europe)","AGB-BLAX":"Scrabble Scramble! (Europe) (En,Fr,De,Es,It,Nl)","AGB-BLBX":"2 Games in 1 - Brother Bear + The Lion King (Europe) (En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BLCE":"Camp Lazlo - Leaky Lake Games (USA)","AGB-BLCP":"Camp Lazlo - Leaky Lake Games (Europe)","AGB-BLDP":"2 Games in 1 - Disney Princess + Lizzie McGuire (Europe)","AGB-BLDS":"2 Games in 1 - Disney Princesas + Lizzie McGuire (Spain)","AGB-BLEJ":"Bleach Advance - Kurenai ni Somaru Soul Society (Japan)","AGB-BLFE":"2 Games in 1 - Dragon Ball Z - The Legacy of Goku I & II (USA)","AGB-BLHE":"Flushed Away (USA)","AGB-BLHP":"Flushed Away (Europe) (En,Fr,De,Es,It)","AGB-BLIJ":"Little Patissier - Cake no Oshiro (Japan)","AGB-BLJJ":"Legendz - Yomigaeru Shiren no Shima (Japan)","AGB-BLJK":"Legendz - Buhwarhaneun Siryeonyi Seom (Korea)","AGB-BLKE":"Lion King 1 1-2, The (USA)","AGB-BLKP":"Lion King, The (Europe) (En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BLME":"Lizzie McGuire - On the Go! (USA)","AGB-BLMP":"Lizzie McGuire (Europe) (En,Fr,De,Es)","AGB-BLNE":"Looney Tunes Double Pack (USA)","AGB-BLNP":"Looney Tunes Double Pack (Europe)","AGB-BLOE":"Land Before Time, The - Into the Mysterious Beyond (USA) (En,Fr,Es)","AGB-BLOP":"Land Before Time, The - Into the Mysterious Beyond (Europe) (En,Fr,De,Es,It,Nl,Pt,Da)","AGB-BLPD":"2 Games in 1 - Disneys Prinzessinnen + Der Koenig der Loewen (Germany) (De+En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BLPF":"2 Games in 1 - Disney Princesse + Le Roi Lion (France) (Fr+En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BLPI":"2 Games in 1 - Disney Principesse + Il Re Leone (Italy) (It+En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BLPP":"2 Games in 1 - The Lion King + Disney Princess (Europe) (En,Fr,De,Es,It,Nl,Sv,Da+En)","AGB-BLPS":"2 Games in 1 - Disney Princesas + El Rey Leon (Spain) (Es+En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BLQP":"2 Disney Games - Lilo & Stitch 2 + Peter Pan - Return to Neverland (Europe) (En,Fr,De,Es+En,Fr,De,Es,It,Nl)","AGB-BLRE":"Lord of the Rings, The - The Return of the King (USA, Europe) (En,Fr,De,Es,It)","AGB-BLRJ":"Lord of the Rings, The - Ou no Kikan (Japan)","AGB-BLSE":"Lilo & Stitch 2 - Haemsterviel Havoc (USA)","AGB-BLSJ":"Lilo & Stitch (Japan)","AGB-BLSP":"Lilo & Stitch 2 (Europe) (En,Fr,De,Es)","AGB-BLTE":"Looney Tunes - Back in Action (USA, Europe) (En,Fr,De,Es,It)","AGB-BLVJ":"Legendz - Sign of Nekuromu (Japan)","AGB-BLWE":"LEGO Star Wars - The Video Game (USA, Europe) (En,Fr,De,Es,It,Nl,Da)","AGB-BLWJ":"LEGO Star Wars - The Video Game (Japan)","AGB-BLXP":"Asterix & Obelix XXL (Europe) (En,Fr,De,Es,It,Nl)","AGB-BLYD":"Lemony Snicket - Raetselhafte Ereignisse (Germany)","AGB-BLYE":"Lemony Snicket's A Series of Unfortunate Events (USA, Europe)","AGB-BLYI":"Lemony Snicket - Una Serie di Sfortunati Eventi (Italy)","AGB-BLYX":"Lemony Snicket's A Series of Unfortunate Events (Europe) (Fr,Es)","AGB-BM2J":"Momotarou Dentetsu G - Gold Deck o Tsukure! (Japan)","AGB-BM3J":"Mickey to Donald no Magical Quest 3 (Japan)","AGB-BM4J":"Mickey no Pocket Resort (Japan)","AGB-BM5E":"Mario vs. Donkey Kong (USA, Australia)","AGB-BM5J":"Mario vs. Donkey Kong (Japan)","AGB-BM5P":"Mario vs. Donkey Kong (Europe) (En,Fr,De,Es,It)","AGB-BM7E":"Madagascar - Operation Penguin (USA)","AGB-BM7P":"Madagascar - Operation Penguin (Europe)","AGB-BM7S":"Madagascar - Operacion Pinguino (Spain)","AGB-BM7X":"Madagascar - Operation Penguin (Europe) (Fr,De)","AGB-BM7Y":"Madagascar - Operation Penguin (Europe) (It,Nl)","AGB-BM8J":"Mermaid Melody - Pichi Pichi Pitch - Pichi Pichi Party (Japan)","AGB-BM9J":"MAER Heaven - Knockin' on Heaven's Door (Japan)","AGB-BMAJ":"Mermaid Melody - Pichi Pichi Pitch (Japan)","AGB-BMBE":"Mighty Beanz - Pocket Puzzles (USA)","AGB-BMCE":"Monster Trucks (USA, Europe)","AGB-BMDE":"Madden NFL 2004 (USA)","AGB-BMEE":"Max Payne (USA)","AGB-BMEP":"Max Payne Advance (Europe) (En,Fr,De)","AGB-BMFE":"Madden NFL 2005 (USA)","AGB-BMGD":"Mario Golf - Advance Tour (Germany)","AGB-BMGE":"Mario Golf - Advance Tour (USA)","AGB-BMGF":"Mario Golf - Advance Tour (France)","AGB-BMGI":"Mario Golf - Advance Tour (Italy)","AGB-BMGJ":"Mario Golf - GBA Tour (Japan)","AGB-BMGP":"Mario Golf - Advance Tour (Europe)","AGB-BMGS":"Mario Golf - Advance Tour (Spain)","AGB-BMGU":"Mario Golf - Advance Tour (Australia)","AGB-BMHE":"Medal of Honor - Infiltrator (USA, Europe) (En,Fr,De)","AGB-BMHJ":"Medal of Honor Advance (Japan)","AGB-BMIJ":"Wagamama Fairy Mirumo de Pon! - Dokidoki Memorial Panic (Japan)","AGB-BMJJ":"Minna no Soft Series - Minna no Mahjong (Japan)","AGB-BMKJ":"Mezase! Koushien (Japan)","AGB-BMLE":"Mucha Lucha! - Mascaritas of the Lost Code (USA) (En,Fr,Es)","AGB-BMME":"Scooby-Doo! - Mystery Mayhem (USA) (En,Fr)","AGB-BMMP":"Scooby-Doo! - Mystery Mayhem (Europe) (En,Fr,De)","AGB-BMNS":"Morning Adventure, The (Spain) (Promo)","AGB-BMOJ":"Minna no Ouji-sama (Japan)","AGB-BMPJ":"Wagamama Fairy Mirumo de Pon! - Taisen Mahoudama (Japan)","AGB-BMQE":"Magical Quest 3 Starring Mickey & Donald (USA) (En,Fr,De)","AGB-BMQP":"Magical Quest 3 Starring Mickey & Donald (Europe) (En,Fr,De)","AGB-BMRJ":"Matantei Loki Ragnarok - Gensou no Labyrinth (Japan)","AGB-BMTE":"Monster Truck Madness (USA, Europe)","AGB-BMUE":"Scooby-Doo 2 - Monsters Unleashed (USA, Europe)","AGB-BMUX":"Scooby-Doo 2 - Monsters Unleashed (Europe) (En,Fr,De,Es,It)","AGB-BMVE":"Mario Pinball Land (USA) (Virtual Console)","AGB-BMVE01":"Mario Pinball Land (USA) (Kiosk, GameCube)","AGB-BMVJ":"Super Mario Ball (Japan)","AGB-BMVP":"Super Mario Ball (Europe)","AGB-BMWJ":"Twin Series 5 - Mahou no Kuni no Cake-ya-san Monogatari + Wanwan Meitantei EX (Japan)","AGB-BMXC":"Miteluode - Lingdian Renwu (China)","AGB-BMXE":"Metroid - Zero Mission (USA)","AGB-BMXJ":"Metroid - Zero Mission (Japan)","AGB-BMXP":"Metroid - Zero Mission (Europe) (En,Fr,De,Es,It)","AGB-BMYJ":"Wagamama Fairy Mirumo de Pon! - 8 Nin no Toki no Yousei (Japan)","AGB-BMZJ":"Minna no Soft Series - Zooo (Japan)","AGB-BMZP":"Zooo - Action Puzzle Game (Europe) (En,Fr,De,Es,It)","AGB-BN2E":"Naruto - Ninja Council 2 (USA)","AGB-BN2J":"Naruto - Saikyou Ninja Daikesshuu 2 (Japan)","AGB-BN4J":"Kawa no Nushi Tsuri 3 & 4 (Japan)","AGB-BN7E":"Need for Speed - Carbon - Own the City (USA, Europe) (En,Fr,De,Es,It)","AGB-BN9E":"Little Mermaid, The - Magic in Two Kingdoms (USA, Europe) (En,Fr,De,Es,It)","AGB-BNBE":"Petz Vet (USA)","AGB-BNBJ":"Himawari Doubutsu Byouin - Pet no Oishasan Ikusei Game (Japan)","AGB-BNCE":"Tim Burton's The Nightmare Before Christmas - The Pumpkin King (USA, Europe) (En,Fr,De,Es,It)","AGB-BNCJ":"Tim Burton's The Nightmare Before Christmas - The Pumpkin King (Japan)","AGB-BNDE":"Codename - Kids Next Door - Operation S.O.D.A. (USA)","AGB-BNEE":"2 Games in 1 - Finding Nemo - The Continuing Adventures + The Incredibles (USA)","AGB-BNFE":"Need for Speed - Underground 2 (USA, Europe) (En,Fr,De,It)","AGB-BNGJ":"Mahou Sensei Negima! - Private Lesson - Damedesuu Toshokan-jima (Japan)","AGB-BNJJ":"Jajamaru Jr. Denshouki - Jalecolle mo Arisourou (Japan)","AGB-BNKE":"Noddy - A Day in Toyland (USA) (En,Fr,Es)","AGB-BNKP":"Noddy - A Day in Toyland (Europe) (En,Fr,Es,It,Nl,Pt,Da)","AGB-BNLE":"Ratatouille (USA)","AGB-BNLP":"Ratatouille (Europe) (En,It,Sv,No,Da)","AGB-BNLU":"Ratatouille (Australia, Greece) (En)","AGB-BNLX":"Ratatouille (Europe) (Fr,De,Nl)","AGB-BNLY":"Ratatouille (Europe) (Es,Pt)","AGB-BNMJ":"Mahou Sensei Negima! - Private Lesson 2 - Ojamashimasuu Parasite de Chuu (Japan)","AGB-BNPE":"Princess Natasha - Student, Secret Agent, Princess (USA)","AGB-BNPP":"Princess Natasha - Student, Secret Agent, Princess (Europe) (En,Fr,De,Es,It)","AGB-BNRJ":"Naruto RPG - Uketsugareshi Hi no Ishi (Japan)","AGB-BNSE":"Need for Speed - Underground (USA, Europe) (En,Fr,De,It)","AGB-BNTE":"Teenage Mutant Ninja Turtles (USA)","AGB-BNTP":"Teenage Mutant Ninja Turtles (Europe) (En,Fr,De,Es,It)","AGB-BNUE":"Nicktoons Unite! (USA)","AGB-BNUP":"SpongeBob SquarePants and Friends Unite! (Europe) (En,Fr,De,Es,Nl)","AGB-BNVE":"Nicktoons - Battle for Volcano Island (USA)","AGB-BNVP":"SpongeBob SquarePants and Friends - Battle for Volcano Island (Europe) (En,Fr,De,Es,It,Nl)","AGB-BNWE":"Need for Speed - Most Wanted (USA, Europe) (En,Fr,De,It)","AGB-BNYJ":"Nyan Nyan Nyanko no NyanCollection (Japan)","AGB-BO2J":"Ochaken no Bouken-jima - Honwaka Yume no Island (Japan)","AGB-BO3J":"Oshare Princess 3 (Japan)","AGB-BO5J":"Oshare Princess 5 (Japan)","AGB-BO8K":"One Piece - Going Baseball - Haejeok Yaku (Korea)","AGB-BOAE":"Open Season (USA) (En,Fr,Es)","AGB-BOAP":"Open Season (Europe) (En,Fr,Es,Nl)","AGB-BOAX":"Open Season (Europe) (En,Fr,De,Es,It,Sv,No,Da,Fi)","AGB-BOBJ":"Boboboubo Boubobo - Maji de!! Shinken Battle (Japan)","AGB-BOCE":"Urbz, The - Sims in the City (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-BOCJ":"Urbz, The - Sims in the City (Japan)","AGB-BODD":"Oddworld - Munch's Oddysee (Germany)","AGB-BODE":"Oddworld - Munch's Oddysee (USA, Europe)","AGB-BOFD":"Ottifanten Pinball (Germany)","AGB-BOJJ":"Ojarumaru - Gekkouchou Sanpo de Ojaru (Japan)","AGB-BOMJ":"Bomberman Jetters - Game Collection (Japan)","AGB-BONE":"One Piece (USA)","AGB-BOPJ":"Twin Series 2 - Oshare Princess 4 + Renai Uranai Daisakusen! + Renai Party Game - Sweet Heart (Japan)","AGB-BOSJ":"Boboboubo Boubobo - Bakutou Hajike Taisen (Japan)","AGB-BOVJ":"Bouken-ou Beet - Busters Road (Japan)","AGB-BOXP":"FightBox (Europe)","AGB-BOZE":"Ozzy & Drix (USA)","AGB-BP3J":"Pia Carrot e Youkoso!! 3.3 (Japan)","AGB-BP4P":"Premier Manager 2004-2005 (Europe) (En,Fr,De,It)","AGB-BP5P":"Premier Manager 2005-2006 (Europe) (En,Fr,De,It)","AGB-BP6J":"Power Pro Kun Pocket 6 (Japan)","AGB-BP7J":"Power Pro Kun Pocket 7 (Japan)","AGB-BP8E":"Pac-Man Pinball Advance (USA)","AGB-BP8P":"Pac-Man Pinball Advance (Europe) (En,Fr,De,Es,It)","AGB-BP9E":"World Championship Poker (USA)","AGB-BP9P":"World Championship Poker (Europe) (En,Fr,De,Es,It)","AGB-BPAE":"Pac-Man World (USA)","AGB-BPAP":"Pac-Man World (Europe) (En,Fr,De,Es,It)","AGB-BPBJ":"Pyuu to Fuku! Jaguar - Byoo to Deru! Megane-kun (Japan)","AGB-BPCE":"Ms. Pac-Man - Maze Madness (USA)","AGB-BPCP":"Ms. Pac-Man - Maze Madness (Europe) (En,Fr,De,Es,It)","AGB-BPDJ":"Bouken Yuuki Pluster World - Densetsu no Plust Gate EX (Japan)","AGB-BPED":"Pokemon - Smaragd-Edition (Germany)","AGB-BPEE":"Pokemon - Emerald Version (USA, Europe)","AGB-BPEF":"Pokemon - Version Emeraude (France)","AGB-BPEI":"Pokemon - Versione Smeraldo (Italy)","AGB-BPEJ":"Pocket Monsters - Emerald (Japan)","AGB-BPES":"Pokemon - Edicion Esmeralda (Spain)","AGB-BPFJ":"Puyo Puyo Fever (Japan) (En,Ja,Fr,De,Es,It)","AGB-BPFP":"Puyo Pop Fever (Europe) (En,Fr,De,Es,It)","AGB-BPGD":"Pokemon - Blattgruene Edition (Germany)","AGB-BPGE":"Pokemon - LeafGreen Version (USA, Europe)","AGB-BPGF":"Pokemon - Version Vert Feuille (France)","AGB-BPGI":"Pokemon - Versione Verde Foglia (Italy)","AGB-BPGJ":"Pocket Monsters - LeafGreen (Japan)","AGB-BPGS":"Pokemon - Edicion Verde Hoja (Spain)","AGB-BPHE":"Pitfall - The Lost Expedition (USA)","AGB-BPHF":"Pitfall - L'Expedition Perdue (France)","AGB-BPHP":"Pitfall - The Lost Expedition (Europe)","AGB-BPIE":"It's Mr. Pants (USA, Europe)","AGB-BPJE":"Pocket Professor - Kwik Notes - Volume One (USA)","AGB-BPKP":"Payback (Europe) (En,Fr,De,Es,It)","AGB-BPLE":"Archer Maclean's 3D Pool (USA)","AGB-BPMP":"Premier Manager 2003-04 (Europe) (En,Fr,De,It)","AGB-BPNJ":"Pikapika Nurse Monogatari - Nurse Ikusei Game (Japan)","AGB-BPOE":"Power Rangers - Dino Thunder (USA, Europe)","AGB-BPOX":"Power Rangers - Dino Thunder (Europe) (Fr,De)","AGB-BPPE":"Pokemon Pinball - Ruby & Sapphire (USA)","AGB-BPPJ":"Pokemon Pinball - Ruby & Sapphire (Japan)","AGB-BPPP":"Pokemon Pinball - Ruby & Sapphire (Europe) (En,Fr,De,Es,It)","AGB-BPQJ":"PukuPuku Tennen Kairanban - Koi no Cupid Daisakusen (Japan)","AGB-BPRD":"Pokemon - Feuerrote Edition (Germany)","AGB-BPRE":"Pokemon - FireRed Version (USA, Europe)","AGB-BPRF":"Pokemon - Version Rouge Feu (France)","AGB-BPRI":"Pokemon - Versione Rosso Fuoco (Italy)","AGB-BPRJ":"Pocket Monsters - FireRed (Japan)","AGB-BPRS":"Pokemon - Edicion Rojo Fuego (Spain)","AGB-BPSJ":"Cinnamoroll - Koko ni Iru yo (Japan)","AGB-BPTE":"Peter Pan - The Motion Picture Event (USA)","AGB-BPTP":"Peter Pan - The Motion Picture Event (Europe) (En,Fr,De,Es,It)","AGB-BPUE":"2 Games in 1 - Scooby-Doo + Scooby-Doo 2 - Monsters Unleashed (USA)","AGB-BPUF":"2 Games in 1 - Scooby-Doo + Scooby-Doo 2 - Les Monstres Se Dechainent (France) (Fr+En,Fr,De,Es,It)","AGB-BPUP":"2 Games in 1 - Scooby-Doo + Scooby-Doo 2 - Monsters Unleashed (Europe)","AGB-BPUS":"2 Games in 1 - Scooby-Doo + Scooby-Doo 2 - Desatado (Spain) (Es+En,Fr,De,Es,It)","AGB-BPVP":"Pferd & Pony - Mein Pferdehof (Germany) (En,De)","AGB-BPVX":"Pippa Funnell - Stable Adventure (Europe) (En,Fr)","AGB-BPVY":"Paard & Pony - Mijn Manege (Netherlands) (En,Nl)","AGB-BPWE":"Power Rangers - Ninja Storm (USA)","AGB-BPWP":"Power Rangers - Ninja Storm (Europe) (En,Fr,De)","AGB-BPXE":"Polar Express, The (USA, Europe) (En,Fr,De,Es,It)","AGB-BPYE":"Prince of Persia - The Sands of Time (USA) (En,Fr,Es)","AGB-BPYP":"Prince of Persia - The Sands of Time (Europe) (En,Fr,De,Es,It,Nl)","AGB-BPZJ":"Pazuninn - Umininn no Puzzle de Nimu (Japan)","AGB-BQ3E":"Rayman - Raving Rabbids (USA) (En,Fr,Es)","AGB-BQ3P":"Rayman - Raving Rabbids (Europe) (En,Fr,De,Es,It,Nl)","AGB-BQ4E":"SpongeBob SquarePants - Creature from the Krusty Krab (USA)","AGB-BQ4P":"SpongeBob SquarePants - Creature from the Krusty Krab (Europe) (En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BQ7E":"Drake & Josh (USA) (En,Fr) (Beta)","AGB-BQ7P":"Monster House (Europe)","AGB-BQ7X":"Monster House (Europe) (En,Fr,De,Es)","AGB-BQ9Q":"Pixeline i Pixieland (Denmark)","AGB-BQAJ":"Meitantei Conan - Akatsuki no Monument (Japan)","AGB-BQBJ":"Konchuu Monster - Battle Master (Japan)","AGB-BQCE":"Crash of the Titans (USA) (En,Fr)","AGB-BQCP":"Crash of the Titans (Europe) (En,Fr,De,Es,It,Nl)","AGB-BQDE":"Quad Desert Fury (USA, Europe)","AGB-BQJE":"2 Game Pack! - Hot Wheels - Stunt Track Challenge + Hot Wheels - World Race (USA, Europe)","AGB-BQKJ":"Konchuu no Mori no Daibouken - Fushigi na Sekai no Juunin-tachi (Japan)","AGB-BQLE":"March of the Penguins (USA)","AGB-BQLP":"March of the Penguins (Europe) (En,Fr,De,Es,It)","AGB-BQMJ":"Twin Series 3 - Konchuu Monster - Ouja Ketteisen + Super Chinese Labyrinth (Japan)","AGB-BQNE":"Disney Princess - Royal Adventure (USA)","AGB-BQNP":"Disney Princess - Royal Adventure (Europe) (En,Fr,De)","AGB-BQPE":"Kim Possible 3 - Team Possible (USA) (En,Fr)","AGB-BQQE":"SpongeBob SquarePants - Lights, Camera, Pants! (USA)","AGB-BQQX":"SpongeBob SquarePants - Lights, Camera, Pants! (Europe) (En,Fr,De,Es,It,Nl,Sv)","AGB-BQSJ":"Konchuu Monster - Battle Stadium (Japan)","AGB-BQTF":"Lea - Passion Veterinaire (France) (En,Fr)","AGB-BQTP":"Meine Tierpension (Germany) (En,De)","AGB-BQTX":"Mijn Dierenpension (Netherlands) (En,Nl)","AGB-BQVE":"Paws & Claws - Pet Vet (USA)","AGB-BQVP":"Meine Tierarztpraxis (Germany) (En,De)","AGB-BQVX":"Mijn Dierenpraktijk (Netherlands) (En,Nl)","AGB-BQWE":"Strawberry Shortcake - Summertime Adventure - Special Edition (USA)","AGB-BQXE":"Superman Returns - Fortress of Solitude (USA, Europe) (En,Fr,De,Es,It)","AGB-BQYD":"Danny Phantom - Dschungelstadt (Germany)","AGB-BQYE":"Danny Phantom - Urban Jungle (USA)","AGB-BQZE":"Avatar - The Last Airbender (USA)","AGB-BQZP":"Avatar - The Legend of Aang (Europe) (En,Fr,De,Nl)","AGB-BR2E":"Mr. Driller 2 (USA)","AGB-BR3E":"R-Type III - The Third Lightning (USA)","AGB-BR3P":"R-Type III - The Third Lightning (Europe) (En,Fr,De,Es,It)","AGB-BR4J":"Rockman EXE 4.5 - Real Operation (Japan)","AGB-BR5E":"Megaman - Battle Network 6 - Cybeast Gregar (USA)","AGB-BR5J":"Rockman EXE 6 - Dennoujuu Gregar (Japan)","AGB-BR5P":"Megaman - Battle Network 6 - Cybeast Gregar (Europe)","AGB-BR6E":"Megaman - Battle Network 6 - Cybeast Falzar (USA)","AGB-BR6J":"Rockman EXE 6 - Dennoujuu Falzar (Japan)","AGB-BR6P":"Megaman - Battle Network 6 - Cybeast Falzar (Europe)","AGB-BR7E":"Rock'em Sock'em Robots (USA)","AGB-BR7P":"Rock'em Sock'em Robots (Europe) (En,Fr,De,Es,It)","AGB-BR8E":"Ghost Rider (USA, Europe) (En,Fr,De,Es,It,Nl)","AGB-BR9J":"Relaxuma na Mainichi (Japan)","AGB-BRAE":"Racing Gears Advance (USA)","AGB-BRAP":"Racing Gears Advance (Europe) (En,Fr,De,Es,It)","AGB-BRBE":"Megaman - Battle Network 5 - Team Protoman (USA)","AGB-BRBJ":"Rockman EXE 5 - Team of Blues (Japan)","AGB-BRBP":"Megaman - Battle Network 5 - Team Protoman (Europe)","AGB-BRDE":"Power Rangers S.P.D. (USA, Europe)","AGB-BRDX":"Power Rangers S.P.D. (Europe) (En,Fr,De)","AGB-BRDY":"Power Rangers S.P.D. (Europe) (En,Es,It)","AGB-BREE":"Riviera - The Promised Land (USA)","AGB-BREJ":"Riviera - Yakusoku no Chi Riviera (Japan)","AGB-BRFE":"Rapala Pro Fishing (USA, Europe)","AGB-BRGE":"Yu Yu Hakusho - Ghostfiles - Tournament Tactics (USA, Europe)","AGB-BRHE":"Meet the Robinsons (USA)","AGB-BRHP":"Meet the Robinsons (Europe) (En,Fr,De,Es,It,Nl)","AGB-BRIJ":"Rhythm Tengoku (Japan)","AGB-BRKE":"Megaman - Battle Network 5 - Team Colonel (USA)","AGB-BRKJ":"Rockman EXE 5 - Team of Colonel (Japan)","AGB-BRKP":"Megaman - Battle Network 5 - Team Colonel (Europe)","AGB-BRLE":"Rebelstar - Tactical Command (USA)","AGB-BRLP":"Rebelstar - Tactical Command (Europe) (En,Fr,De,Es,It)","AGB-BRME":"Rave Master - Special Attack Force! (USA)","AGB-BRNJ":"Licca-chan no Oshare Nikki (Japan)","AGB-BROP":"Postman Pat and the Greendale Rocket (Europe) (En,No,Da)","AGB-BRPJ":"Lilliput Oukoku - Lillimoni to Issho Puni! (Japan)","AGB-BRQE":"3 Games in One - Darts + Roll-a-Ball + Shuffle Bowl (USA)","AGB-BRQP":"3 Games in One - Darts + Roll-a-Ball + Shuffle Bowl (Europe)","AGB-BRRD":"Bratz - Rock Angelz (Germany)","AGB-BRRE":"Bratz - Rock Angelz (USA, Europe)","AGB-BRRF":"Bratz - Rock Angelz (France)","AGB-BRRS":"Bratz - Rock Angelz (Spain)","AGB-BRSF":"2 Games in 1 - Les Razmoket Rencontrent les Delajungle + SpongeBob SquarePants - SuperSponge (France) (Fr+En)","AGB-BRSP":"2 Games in 1 - Rugrats - Go Wild + SpongeBob SquarePants - SuperSponge (Europe)","AGB-BRTE":"Robots (USA)","AGB-BRTJ":"Robots (Japan)","AGB-BRTP":"Robots (Europe) (En,Fr,De,Es,It)","AGB-BRVE":"That's So Raven (USA)","AGB-BRWF":"Racing Fever (France)","AGB-BRWP":"Racing Fever (Europe) (En,De,Es,It)","AGB-BRXJ":"Vattroller X (Japan)","AGB-BRYE":"Rayman - Hoodlum's Revenge (USA) (En,Fr,Es)","AGB-BRYP":"Rayman - Hoodlums' Revenge (Europe) (En,Fr,De,Es,It,Nl)","AGB-BRZD":"2 Games in 1 - Power Rangers - Ninja Storm + Power Rangers - Time Force (Germany) (En,Fr,De+De)","AGB-BRZE":"2 Games in 1 - Power Rangers - Ninja Storm + Power Rangers - Time Force (USA) (En,Fr,De+En)","AGB-BRZF":"2 Games in 1 - Power Rangers - Ninja Storm + Power Rangers - La Force du Temps (France) (En,Fr,De+Fr)","AGB-BRZP":"2 Games in 1 - Power Rangers - Ninja Storm + Power Rangers - Time Force (Europe) (En,Fr,De+En)","AGB-BS3J":"Simple 2960 Tomodachi Series Vol. 3 - The Itsudemo Puzzle - Massugu Soroete Straws (Japan)","AGB-BS4J":"Simple 2960 Tomodachi Series Vol. 4 - The Trump - Minna de Asoberu 12 Shurui no Trump Game (Japan)","AGB-BS5J":"Sylvanian Families - Yousei no Stick to Fushigi no Ki - Marron-inu no Onnanoko (Japan)","AGB-BS6E":"Backyard Skateboarding (USA)","AGB-BS7E":"2 in 1 Game Pack - Shrek 2 + Shark Tale (USA)","AGB-BS7P":"2 in 1 Game Pack - Shrek 2 & Shark Tale (Europe) (En,Fr,De,Es,It,Sv+En,Fr,De,Es,It)","AGB-BS8J":"Spyro Advance - Wakuwaku Tomodachi Daisakusen! (Japan)","AGB-BSAJ":"Super Chinese 1, 2 Advance (Japan)","AGB-BSBE":"Sonic Battle (USA) (En,Ja,Fr,De,Es,It)","AGB-BSBJ":"Sonic Battle (Japan) (En,Ja)","AGB-BSBP":"Sonic Battle (Europe) (En,Ja,Fr,De,Es,It)","AGB-BSDE":"Sitting Ducks (USA) (En,Fr,De,Es,It,Nl)","AGB-BSDP":"Sitting Ducks (Europe) (En,Fr,De,Es,It,Nl)","AGB-BSEE":"Shrek 2 (USA, Europe)","AGB-BSEX":"Shrek 2 (Europe) (Fr,De,Es,It,Sv)","AGB-BSFJ":"Sylvanian Families - Fashion Designer ni Naritai! - Kurumi-risu no Onnanoko (Japan)","AGB-BSGJ":"Minna no Soft Series - Minna no Shougi (Japan)","AGB-BSHJ":"Minna no Soft Series - Shanghai (Japan)","AGB-BSIE":"Shrek 2 - Beg for Mercy (USA, Europe)","AGB-BSIX":"Shrek 2 - Beg for Mercy (Europe) (Fr,De,Es,It)","AGB-BSKE":"Summon Night - Swordcraft Story 2 (USA)","AGB-BSKJ":"Summon Night - Craft Sword Monogatari 2 (Japan)","AGB-BSLE":"Tom Clancy's Splinter Cell - Pandora Tomorrow (USA) (En,Fr,Es)","AGB-BSLP":"Tom Clancy's Splinter Cell - Pandora Tomorrow (Europe) (En,Fr,De,Es,It,Nl)","AGB-BSME":"Metal Slug Advance (USA)","AGB-BSMJ":"Metal Slug Advance (Japan)","AGB-BSMP":"Metal Slug Advance (Europe)","AGB-BSNE":"SpongeBob SquarePants Movie, The (USA)","AGB-BSNX":"SpongeBob SquarePants Movie, The (Europe) (En,Fr,De,Es,It,Nl)","AGB-BSOE":"Shaman King - Master of Spirits (USA)","AGB-BSOP":"Shaman King - Master of Spirits (Europe) (En,Fr,De)","AGB-BSPE":"Spider-Man 2 (USA, Europe)","AGB-BSPI":"Spider-Man 2 (Italy)","AGB-BSPX":"Spider-Man 2 (Europe) (En,Fr,De,Es)","AGB-BSQE":"SpongeBob SquarePants - Battle for Bikini Bottom (USA)","AGB-BSQP":"SpongeBob SquarePants - Battle for Bikini Bottom (Europe) (En,Fr,De)","AGB-BSRE":"Wade Hixton's Counter Punch (USA, Europe)","AGB-BSSE":"Spy Muppets - License to Croak (USA) (En,Fr,De,Es,It,Nl)","AGB-BSTE":"Spyro Orange - The Cortex Conspiracy (USA)","AGB-BSTP":"Spyro Fusion (Europe) (En,Fr,De,Es,It)","AGB-BSUE":"Shark Tale (USA, Europe)","AGB-BSUI":"Shark Tale (Italy)","AGB-BSUX":"Shark Tale (Europe) (Fr,De,Es)","AGB-BSVE":"Smashing Drive (USA)","AGB-BSVP":"Smashing Drive (Europe) (En,Fr,De,Es,It)","AGB-BSWE":"Star Wars - Flight of the Falcon (USA)","AGB-BSWP":"Star Wars - Flight of the Falcon (Europe) (En,Fr,De)","AGB-BSXE":"SSX 3 (USA, Europe)","AGB-BSYJ":"Sentouin Yamada Hajime (Japan)","AGB-BSZP":"2 Games in 1 - SpongeBob SquarePants - SuperSponge + SpongeBob SquarePants - Battle for Bikini Bottom (Europe)","AGB-BSZX":"2 Games in 1 - SpongeBob SquarePants - SuperSponge + SpongeBob SquarePants - Battle for Bikini Bottom (Europe) (En+En,Fr,De)","AGB-BT2E":"Teenage Mutant Ninja Turtles 2 - Battle Nexus (USA)","AGB-BT2P":"Teenage Mutant Ninja Turtles 2 - Battle Nexus (Europe) (En,Fr,De,Es,It)","AGB-BT3J":"Tantei Jinguuji Saburou - Shiroi Kage no Shoujo (Japan)","AGB-BT4E":"Dragon Ball GT - Transformation (USA)","AGB-BT5F":"2 Jeux en 1 - Titeuf - Ze Gag Machine + Titeuf - Mega Compet (France)","AGB-BT6E":"Trollz - Hair Affair! (USA)","AGB-BT6P":"Trollz - Hair Affair! (Europe)","AGB-BT6X":"Trollz - Hair Affair! (Europe) (En,Fr,Es)","AGB-BT6Y":"Trollz - Hair Affair! (Europe) (En,De,It)","AGB-BT7E":"Tonka - On the Job (USA)","AGB-BT8E":"Teenage Mutant Ninja Turtles Double Pack (USA) (En,Fr,De,Es,It)","AGB-BT8P":"Teenage Mutant Ninja Turtles Double Pack (Europe) (En,Fr,De,Es,It)","AGB-BT9E":"Tak 2 - The Staff of Dreams (USA)","AGB-BT9X":"Tak 2 - The Staff of Dreams (Europe) (En,Fr,De,Es,It)","AGB-BTAE":"Astro Boy - Omega Factor (USA) (En,Ja,Fr,De,Es,It)","AGB-BTAJ":"Astro Boy - Tetsuwan Atom - Atom Heart no Himitsu (Japan)","AGB-BTAP":"Astro Boy - Omega Factor (Europe) (En,Ja,Fr,De,Es,It)","AGB-BTBE":"Thunderbirds (USA, Europe)","AGB-BTCF":"Titeuf - Mega Compet (France)","AGB-BTDE":"Pocket Dogs (USA)","AGB-BTDJ":"Poke Inu (Japan)","AGB-BTFJ":"Tokyo Majin Gakuen - Fuju Houroku (Japan)","AGB-BTGE":"Top Gear Rally (USA)","AGB-BTGP":"TG Rally (Europe)","AGB-BTGX":"Top Gear Rally (Europe) (En,Fr,De,Es,It)","AGB-BTHE":"Thunder Alley (USA)","AGB-BTIJ":"Tantei Gakuen Q - Kyuukyoku Trick ni Idome! (Japan)","AGB-BTJE":"Tringo (USA)","AGB-BTJP":"Tringo (Europe) (En,Fr,De,Es,It)","AGB-BTLJ":"Minna no Soft Series - Happy Trump 20 (Japan)","AGB-BTME":"Mario Tennis - Power Tour (USA) (En,Fr,De,Es,It) (Virtual Console)","AGB-BTMJ":"Mario Tennis Advance (Japan)","AGB-BTMP":"Mario Power Tennis (Europe) (En,Fr,De,Es,It)","AGB-BTNE":"Tron 2.0 - Killer App (USA)","AGB-BTNP":"Tron 2.0 - Killer App (Europe)","AGB-BTOE":"Tony Hawk's Underground (USA, Europe)","AGB-BTPE":"Ten Pin Alley 2 (USA)","AGB-BTQJ":"Tantei Gakuen Q - Meitantei wa Kimi da! (Japan)","AGB-BTRE":"Tower SP, The (USA)","AGB-BTRJ":"Tower SP, The (Japan)","AGB-BTTJ":"Minna no Soft Series - Tetris Advance (Japan)","AGB-BTUE":"Totally Spies! (USA)","AGB-BTUP":"Totally Spies! (Europe) (En,Fr,De,Es,It,Nl)","AGB-BTVE":"Ty the Tasmanian Tiger 3 - Night of the Quinkan (USA)","AGB-BTWE":"Tiger Woods PGA Tour 2004 (USA, Europe)","AGB-BTYE":"Ty the Tasmanian Tiger 2 - Bush Rescue (USA, Europe) (En,Fr,De)","AGB-BTZE":"Tokyo Xtreme Racer Advance (USA)","AGB-BTZP":"Tokyo Xtreme Racer Advance (Europe) (En,Fr,De,Es,It)","AGB-BU2E":"2 Games in 1 - SpongeBob SquarePants - Battle for Bikini Bottom + Nicktoons - Freeze Frame Frenzy (USA)","AGB-BU4E":"Unfabulous (USA)","AGB-BU5E":"Uno 52 (USA)","AGB-BU5P":"Uno 52 (Europe) (En,Fr,De,Es,It)","AGB-BU6J":"Taiketsu! Ultra Hero (Japan)","AGB-BUAE":"Ultimate Puzzle Games (USA)","AGB-BUCE":"Ultimate Card Games (USA) (Rev 1)","AGB-BUDJ":"Konjiki no Gashbell!! Yuujou no Zakeru - Dream Tag Tournament (Japan)","AGB-BUEE":"Danny Phantom - The Ultimate Enemy (USA)","AGB-BUEP":"Danny Phantom - The Ultimate Enemy (Europe) (En,Fr,Nl)","AGB-BUEX":"Danny Phantom - The Ultimate Enemy (Europe) (En,De,Es)","AGB-BUFE":"2 Games in 1! - Dragon Ball Z - Buu's Fury + Dragon Ball GT - Transformation (USA)","AGB-BUHJ":"Ueki no Housoku - Jingi Sakuretsu! Nouryokusha Battle (Japan)","AGB-BUIE":"Uno - Free Fall (USA)","AGB-BUIP":"Uno - Free Fall (Europe) (En,Fr,De,Es,It)","AGB-BUJE":"Nicktoons - Attack of the Toybots (USA)","AGB-BUJX":"SpongeBob and Friends - Attack of the Toybots (Europe) (En,De)","AGB-BULE":"Ultimate Spider-Man (USA)","AGB-BULP":"Ultimate Spider-Man (Europe)","AGB-BULX":"Ultimate Spider-Man (Europe) (Fr,De,Es,It)","AGB-BUME":"Monopoly (USA)","AGB-BUMP":"Monopoly (Europe) (En,Fr,De,Es)","AGB-BUOE":"Dr. Sudoku (USA)","AGB-BUOJ":"Minna no Soft Series - Numpla Advance (Japan)","AGB-BUOP":"Dr. Sudoku (Europe)","AGB-BUQE":"2 Game Pack! - Uno + Skip-Bo (USA)","AGB-BUQP":"2 Game Pack! - Uno & Skip-Bo (Europe) (En,Fr,De,Es,It)","AGB-BURE":"Paws & Claws - Pet Resort (USA)","AGB-BUSE":"Green Eggs and Ham by Dr. Seuss (USA)","AGB-BUTJ":"Ultra Keibitai - Monster Attack (Japan)","AGB-BUUJ":"Pokemon - 10th Anniversary Distribution (Europe) (Kiosk)","AGB-BUVJ":"Uchuu no Stellvia (Japan)","AGB-BUWE":"Ultimate Winter Games (USA)","AGB-BUXD":"Bibi und Tina - Ferien auf dem Martinshof (Germany)","AGB-BUYE":"Ant Bully, The (USA) (En,Fr)","AGB-BUYP":"Ant Bully, The (Europe) (En,Fr,De,Es,It)","AGB-BUZE":"Ultimate Arcade Games (USA)","AGB-BVAJ":"bit Generations - Coloris (Japan) (En)","AGB-BVBJ":"bit Generations - Dialhex (Japan) (En)","AGB-BVCJ":"bit Generations - Dotstream (Japan) (En)","AGB-BVDJ":"bit Generations - Boundish (Japan) (En)","AGB-BVEJ":"bit Generations - Orbital (Japan) (En)","AGB-BVGJ":"bit Generations - Soundvoyager (Japan) (En)","AGB-BVHJ":"bit Generations - Digidrive (Japan) (En)","AGB-BW2E":"2 Games in 1 - Cartoon Network Block Party + Cartoon Network Speedway (USA)","AGB-BW2P":"Double Game! - Cartoon Network Block Party & Cartoon Network Speedway (Europe)","AGB-BW3J":"Double Pack - Sonic Advance & ChuChu Rocket! (Japan) (En,Ja,Fr,De,Es)","AGB-BW3P":"2 Games in 1 - Sonic Advance + ChuChu Rocket! (Europe) (En,Ja,Fr,De,Es)","AGB-BW4J":"Double Pack - Sonic Battle & Sonic Advance (Japan) (En,Ja,Fr,De,Es+En,Ja)","AGB-BW4P":"2 Games in 1 - Sonic Advance + Sonic Battle (Europe) (En,Ja,Fr,De,Es+En,Ja,Fr,De,Es,It)","AGB-BW5E":"Combo Pack - Sonic Advance + Sonic Pinball Party (USA) (En,Ja,Fr,De,Es+En,Ja,Fr,De,Es,It)","AGB-BW5P":"2 Games in 1 - Sonic Advance + Sonic Pinball Party (Europe) (En,Ja,Fr,De,Es+En,Ja,Fr,De,Es,It)","AGB-BW6J":"Double Pack - Sonic Pinball Party & Sonic Battle (Japan) (En,Ja+En,Ja,Fr,De,Es,It)","AGB-BW6P":"2 Games in 1 - Sonic Battle + Sonic Pinball Party (Europe) (En,Ja,Fr,De,Es,It)","AGB-BW7P":"2 Games in 1 - Sonic Battle + ChuChu Rocket! (Europe) (En,Ja,Fr,De,Es,It+En,Ja,Fr,De,Es)","AGB-BW8P":"2 Games in 1 - Sonic Pinball Party + Columns Crown (Europe) (En,Ja,Fr,De,Es,It+En)","AGB-BW9P":"2 Games in 1 - Columns Crown + ChuChu Rocket! (Europe) (En+En,Ja,Fr,De,Es)","AGB-BWAJ":"Majokko Cream-chan no Gokko Series 1 - Wannyan Idol Gakuen (Japan)","AGB-BWBD":"2 Games in 1 - Disneys Prinzessinnen + Baerenbrueder (Germany) (De+En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BWBF":"2 Games in 1 - Disney Princesse + Frere des Ours (France) (Fr+En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BWBI":"2 Games in 1 - Disney Principesse + Koda, Fratello Orso (Italy) (It+En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BWBP":"2 Games in 1 - Disney Princess + Brother Bear (Europe) (En+En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BWBS":"2 Games in 1 - Disney Princesas + Hermano Oso (Spain) (Es+En,Fr,De,Es,It,Nl,Sv,Da)","AGB-BWCE":"2 Games in 1 - Golden Nugget Casino + Texas Hold 'em Poker (USA)","AGB-BWCP":"Double Game! - Golden Nugget Casino & Texas Hold 'em Poker (Europe)","AGB-BWDJ":"Wannyan Doubutsu Byouin - Doubutsu no Oishasan Ikusei Game (Japan)","AGB-BWEE":"Whac-A-Mole (USA)","AGB-BWFJ":"Wagamama Fairy Mirumo de Pon! - Yume no Kakera (Japan)","AGB-BWHE":"Winnie the Pooh's Rumbly Tumbly Adventure (USA) (En,Fr,Es)","AGB-BWHP":"Winnie the Pooh's Rumbly Tumbly Adventure (Europe) (En,Fr,De,Es,It,Nl)","AGB-BWIE":"WinX Club (USA)","AGB-BWIP":"WinX Club (Europe) (En,Fr,De,Es,It)","AGB-BWJP":"Who Wants to Be a Millionaire Junior (Europe)","AGB-BWKJ":"Wanko de Kururin! Wancle (Japan)","AGB-BWLE":"Wild, The (USA, Europe) (En,Fr,De,Es)","AGB-BWMJ":"Wanwan Meitantei (Japan)","AGB-BWNJ":"Twin Series 6 - Wannyan Idol Gakuen + Koinu to Issho Special (Japan)","AGB-BWOP":"World Poker Tour (Europe) (En,Fr,De)","AGB-BWPJ":"Wagamama Fairy Mirumo de Pon! - Nazo no Kagi to Shinjitsu no Tobira (Japan)","AGB-BWQE":"2 Games in 1 - Quad Desert Fury + Monster Trucks (USA)","AGB-BWQP":"Double Game! - Quad Desert Fury & Monster Trucks (Europe)","AGB-BWRE":"World Reborn (USA)","AGB-BWSE":"Shaman King - Legacy of the Spirits - Sprinting Wolf (USA)","AGB-BWTP":"W.i.t.c.h. (Europe) (En,Fr,De,Es,It)","AGB-BWUD":"Wilden Fussball-Kerle, Die - Entscheidung im Teufelstopf (Germany)","AGB-BWVE":"WinX Club - Quest for the Codex (USA)","AGB-BWVP":"WinX Club - Quest for the Codex (Europe) (En,Fr,De,Es,It)","AGB-BWWE":"WWE - Survivor Series (USA, Europe)","AGB-BWXJ":"Wanko Mix Chiwanko World (Japan)","AGB-BWYP":"Winter Sports (Europe) (En,Fr,De,Es,It)","AGB-BWZP":"Winnie the Pooh's Rumbly Tumbly Adventure & Rayman 3 (Europe) (En,Fr,De,Es,It,Nl+En,Fr,De,Es,It,Nl,Sv,No,Da,Fi)","AGB-BX2E":"2 in 1 Game Pack - Spider-Man - Mysterio's Menace + X2 - Wolverine's Revenge (USA, Europe)","AGB-BX3P":"2 in 1 Game Pack - Spider-Man & Spider-Man 2 (Europe) (En,Fr,De+En,Fr,De,Es,It)","AGB-BX4E":"2 in 1 Game Pack - Tony Hawk's Underground + Kelly Slater's Pro Surfer (USA, Europe)","AGB-BX5E":"Rayman - 10th Anniversary (USA) (En,Fr,De,Es,It)","AGB-BX5P":"Rayman - 10th Anniversary (Europe) (En,Fr,De,Es,It,Nl,Sv,No,Da,Fi)","AGB-BX6E":"2 Games in 1 - SpongeBob SquarePants - Battle for Bikini Bottom + The Fairly OddParents! - Breakin' da Rules (USA)","AGB-BXAE":"Texas Hold 'em Poker (USA)","AGB-BXCE":"3 Game Pack! - Ker Plunk! + Toss Across + Tip It (USA)","AGB-BXFD":"Bratz - Forever Diamondz (Germany)","AGB-BXFE":"Bratz - Forever Diamondz (USA)","AGB-BXFP":"Bratz - Forever Diamondz (Europe) (En,Fr,Es,It)","AGB-BXGE":"2-in-1 Fun Pack - Shrek 2 + Madagascar (USA)","AGB-BXGP":"2-in-1 Fun Pack - Shrek 2 + Madagascar (Europe)","AGB-BXHE":"2-in-1 Fun Pack - Shrek 2 + Madagascar - Operation Penguin (USA)","AGB-BXHP":"2-in-1 Fun Pack - Shrek 2 + Madagascar - Operation Penguin (Europe)","AGB-BXKE":"Castlevania Double Pack (USA)","AGB-BXKP":"Castlevania Double Pack (Europe) (En,Fr,De)","AGB-BXME":"XS Moto (USA)","AGB-BXPE":"Dora the Explorer - Dora's World Adventure! (USA)","AGB-BXSE":"Tony Hawk's Downhill Jam (USA)","AGB-BXSP":"Tony Hawk's Downhill Jam (Europe) (En,Fr,De,Es,It)","AGB-BXUE":"Surf's Up (USA) (En,Fr,Es)","AGB-BXUP":"Surf's Up (Europe) (En,Fr,Es)","AGB-BXUX":"Surf's Up (Europe) (En,Fr,De,Es,It)","AGB-BXWD":"Wilden Fussball-Kerle, Die - Gefahr im Wilde Kerle Land (Germany)","AGB-BY2E":"Yu-Gi-Oh! Double Pack (USA)","AGB-BY2P":"Yu-Gi-Oh! Double Pack (Europe) (En,Fr,De,Es,It)","AGB-BY3J":"Yu-Gi-Oh! Duel Monsters Expert 3 (Japan) (En,Ja,Fr,De,Es,It)","AGB-BY6E":"Yu-Gi-Oh! - Ultimate Masters - World Championship Tournament 2006 (USA) (En,Ja,Fr,De,Es,It)","AGB-BY6J":"Yu-Gi-Oh! Duel Monsters Expert 2006 (Japan) (En,Ja,Fr,De,Es,It)","AGB-BY6P":"Yu-Gi-Oh! - Ultimate Masters Edition - World Championship Tournament 2006 (Europe) (En,Ja,Fr,De,Es,It)","AGB-BY7E":"Yu-Gi-Oh! - 7 Trials to Glory - World Championship Tournament 2005 (USA) (En,Ja,Fr,De,Es,It)","AGB-BYAE":"F24 Stealth Fighter (USA)","AGB-BYDE":"Yu-Gi-Oh! - Destiny Board Traveler (USA)","AGB-BYDP":"Yu-Gi-Oh! - Destiny Board Traveler (Europe) (En,Fr,De,Es,It)","AGB-BYFE":"Backyard Football 2006 (USA)","AGB-BYGE":"Yu-Gi-Oh! GX - Duel Academy (USA)","AGB-BYGJ":"Yu-Gi-Oh! Duel Monsters GX - Mezase Duel King! (Japan)","AGB-BYGP":"Yu-Gi-Oh! GX - Duel Academy (Europe)","AGB-BYHE":"Backyard Hockey (USA)","AGB-BYIJ":"Yu-Gi-Oh! Duel Monsters International 2 (Japan) (En,Ja)","AGB-BYLP":"Kid Paddle (Europe) (Fr,Nl)","AGB-BYME":"Monster Trucks Mayhem (USA)","AGB-BYMP":"2K Sports - Major League Baseball 2K7 (USA) (Beta)","AGB-BYOP":"Yu-Gi-Oh! - Day of the Duelist - World Championship Tournament 2005 (Europe) (En,Ja,Fr,De,Es,It)","AGB-BYPP":"Pferd & Pony - Lass Uns Reiten 2 (Germany) (En,De)","AGB-BYPX":"Pippa Funnell 2 (Europe) (En,Fr)","AGB-BYPY":"Paard & Pony - Paard in Galop (Netherlands) (En,Nl)","AGB-BYSJ":"Yu-Gi-Oh! - Sugoroku no Sugoroku (Japan)","AGB-BYVE":"Yu-Gi-Oh! Double Pack 2 (USA) (En,Fr,De,Es,It)","AGB-BYWE":"Yu-Gi-Oh! - World Championship Tournament 2004 (USA) (En,Ja,Fr,De,Es,It)","AGB-BYWP":"Yu-Gi-Oh! - World Championship Tournament 2004 (Europe) (En,Ja,Fr,De,Es,It)","AGB-BYXE":"Puppy Luv - Spa and Resort (USA)","AGB-BYYE":"Yu Yu Hakusho - Ghostfiles - Spirit Detective (USA)","AGB-BYYP":"Yu Yu Hakusho - Ghostfiles - Spirit Detective (Europe) (En,Fr,De,Es,It)","AGB-BZ2J":"Zettaizetsumei Dangerous Jiisan Tsuu - Ikari no Oshioki Blues (Japan)","AGB-BZ3E":"Megaman Zero 3 (USA)","AGB-BZ3J":"Rockman Zero 3 (Japan)","AGB-BZ3P":"Megaman Zero 3 (Europe)","AGB-BZ4E":"Final Fantasy IV Advance (USA)","AGB-BZ4J":"Final Fantasy IV Advance (Japan)","AGB-BZ4P":"Final Fantasy IV Advance (Europe) (En,Fr,De,Es,It)","AGB-BZ5E":"Final Fantasy V Advance (USA)","AGB-BZ5J":"Final Fantasy V Advance (Japan)","AGB-BZ5P":"Final Fantasy V Advance (Europe) (En,Fr,De,Es,It)","AGB-BZ6E":"Final Fantasy VI Advance (USA)","AGB-BZ6J":"Final Fantasy VI Advance (Japan)","AGB-BZ6P":"Final Fantasy VI Advance (Europe) (En,Fr,De,Es,It)","AGB-BZCE":"Suite Life of Zack & Cody, The - Tipton Caper (USA) (En,Fr)","AGB-BZDJ":"Zettaizetsumei Dangerous Jiisan - Shijou Saikyou no Dogeza (Japan)","AGB-BZFJ":"Zoids Saga - Fuzors (Japan)","AGB-BZGJ":"Zettaizetsumei Dangerous Jiisan - Naki no 1-kai - Zettaifukujuu Violence Kouchou - Wagahai ga 1-ban Erainjai!! (Japan)","AGB-BZIE":"Finding Nemo - The Continuing Adventures (USA, Europe)","AGB-BZIJ":"Finding Nemo - Aratanaru Bouken (Japan)","AGB-BZIX":"Finding Nemo - The Continuing Adventures (Europe) (En,Es,It,Sv,Da)","AGB-BZIY":"Finding Nemo - The Continuing Adventures (Europe) (Fr,De,Nl)","AGB-BZME":"Legend of Zelda, The - The Minish Cap (USA)","AGB-BZMJ":"Zelda no Densetsu - Fushigi no Boushi (Japan)","AGB-BZMP":"Legend of Zelda, The - The Minish Cap (Europe) (En,Fr,De,Es,It)","AGB-BZNE":"Deal or No Deal (USA)","AGB-BZOJ":"Zero One SP (Japan)","AGB-BZPE":"2 Games in One! - Dr. Mario + Puzzle League (USA)","AGB-BZPJ":"Dr. Mario & Panel de Pon (Japan)","AGB-BZPP":"2 Games in 1 - Dr. Mario + Puzzle League (Europe) (En,Fr,De,Es,It)","AGB-BZRE":"Enchanted - Once Upon Andalasia (USA) (En,Fr)","AGB-BZSE":"That's So Raven 2 - Supernatural Style (USA) (En,Fr)","AGB-BZTE":"VeggieTales - LarryBoy and the Bad Apple (USA)","AGB-BZUE":"Teen Titans 2 (USA) (En,Fr)","AGB-BZWJ":"Touhai Densetsu Akagi - Yami ni Mai Orita Tensai (Japan)","AGB-BZXE":"SpongeBob's Atlantis SquarePantis (USA)","AGB-BZYE":"Zoey 101 (USA)","AGB-CUNE":"Inky and the Alien Aquarium (World) (Demo 1)","AGB-FADE":"Classic NES Series - Castlevania (USA)","AGB-FADJ":"Famicom Mini 29 - Akumajou Dracula (Japan)","AGB-FADP":"NES Classics - Castlevania (Europe)","AGB-FBFJ":"Famicom Mini 13 - Balloon Fight (Japan)","AGB-FBME":"Classic NES Series - Bomberman (USA, Europe)","AGB-FBMJ":"Famicom Mini 09 - Bomberman (Japan) (En)","AGB-FCLJ":"Famicom Mini 12 - Clu Clu Land (Japan)","AGB-FDDJ":"Famicom Mini 16 - Dig Dug (Japan)","AGB-FDKE":"Classic NES Series - Donkey Kong (USA, Europe)","AGB-FDKJ":"Famicom Mini 02 - Donkey Kong (Japan) (En)","AGB-FDME":"Classic NES Series - Dr. Mario (USA, Europe)","AGB-FDMJ":"Famicom Mini 15 - Dr. Mario (Japan)","AGB-FEBE":"Classic NES Series - Excitebike (USA, Europe)","AGB-FEBJ":"Famicom Mini 04 - Excitebike (Japan) (En)","AGB-FFMJ":"Famicom Mini 26 - Famicom Mukashibanashi - Shin Onigashima - Zen, Kouhen (Japan)","AGB-FGGJ":"Famicom Mini 20 - Ganbare Goemon! - Karakuri Douchuu (Japan)","AGB-FGZJ":"Famicom Mini - Kidou Senshi Z Gundam - Hot Scramble (Japan) (Promo)","AGB-FICC":"Famicom Mini Collection (China) (En) (Proto)","AGB-FICE":"Classic NES Series - Ice Climber (USA, Europe)","AGB-FICJ":"Famicom Mini 03 - Ice Climber (Japan) (En)","AGB-FLBE":"Classic NES Series - Zelda II - The Adventure of Link (USA, Europe)","AGB-FLBJ":"Famicom Mini 25 - The Legend of Zelda 2 - Link no Bouken (Japan)","AGB-FM2J":"Famicom Mini 21 - Super Mario Bros. 2 (Japan)","AGB-FMBJ":"Famicom Mini 11 - Mario Bros. (Japan)","AGB-FMKJ":"Famicom Mini 18 - Makaimura (Japan)","AGB-FMPJ":"Famicom Mini 08 - Mappy (Japan) (En)","AGB-FMRE":"Classic NES Series - Metroid (USA, Europe)","AGB-FMRJ":"Famicom Mini 23 - Metroid (Japan)","AGB-FNMJ":"Famicom Mini 22 - Nazo no Murasame Jou (Japan)","AGB-FP7E":"Classic NES Series - Pac-Man (USA, Europe)","AGB-FPMJ":"Famicom Mini 06 - Pac-Man (Japan) (En)","AGB-FPTJ":"Famicom Mini 24 - Hikari Shinwa - Palthena no Kagami (Japan)","AGB-FSDJ":"Famicom Mini 30 - SD Gundam World - Gachapon Senshi Scramble Wars (Japan)","AGB-FSME":"Classic NES Series - Super Mario Bros. (USA, Europe)","AGB-FSMJ":"Famicom Mini 01 - Super Mario Bros. (Japan) (En) (Rev 1)","AGB-FSOJ":"Famicom Mini 10 - Star Soldier (Japan) (En)","AGB-FSRJ":"Famicom Mini - Dai-2-ji Super Robot Taisen (Japan) (Promo)","AGB-FTBJ":"Famicom Mini 17 - Takahashi Meijin no Bouken-jima (Japan)","AGB-FTKJ":"Famicom Mini 27 - Famicom Tantei Club - Kieta Koukeisha - Zen, Kouhen (Japan)","AGB-FTUJ":"Famicom Mini 28 - Famicom Tantei Club Part II - Ushiro ni Tatsu Shoujo - Zen, Kouhen (Japan)","AGB-FTWJ":"Famicom Mini 19 - Twin Bee (Japan)","AGB-FWCJ":"Famicom Mini 14 - Wrecking Crew (Japan)","AGB-FXVE":"Classic NES Series - Xevious (USA, Europe)","AGB-FXVJ":"Famicom Mini 07 - Xevious (Japan) (En)","AGB-FZLE":"Classic NES Series - The Legend of Zelda (USA, Europe)","AGB-FZLJ":"Famicom Mini 05 - Zelda no Densetsu 1 - The Hyrule Fantasy (Japan)","AGB-GHTJ":"Hikaru no Go 3 - Senyou Joy Carry Cartridge (Japan) (Rewritable Cartridge)","AGB-JPAJ":"Pokemon - Aurora Ticket Distribution (USA) (Kiosk)","AGB-KHPJ":"Korokoro Puzzle - Happy Panecchu! (Japan)","AGB-KYGE":"Yoshi Topsy-Turvy (USA)","AGB-KYGJ":"Yoshi no Banyuuinryoku (Japan)","AGB-KYGP":"Yoshi's Universal Gravitation (Europe) (En,Fr,De,Es,It)","AGB-MCAT":"Mooncat's Trio (World)","AGB-METJ":"Metroid Fusion (Europe) (En,Fr,De,Es,It) (Beta 1)","AGB-PDA1":"GBA Personal Organizer (USA)","AGB-PEAJ":"Card e-Reader (Japan)","AGB-PSAE":"e-Reader (USA)","AGB-PSAJ":"Card e-Reader+ (Japan)","AGB-RARE":"Battletoads (USA) (Proto)","AGB-RZWE":"WarioWare - Twisted! (USA, Australia)","AGB-RZWJ":"Mawaru - Made in Wario (Japan)","AGB-SBFP":"Tremblay Island (World) (En) (v1.1)","AGB-SBSP":"SpongeBob SquarePants - SuperSponge (USA, Europe) (Beta 16)","AGB-U32E":"Boktai 2 - Solar Boy Django (USA)","AGB-U32J":"Zoku Bokura no Taiyou - Taiyou Shounen Django (Japan)","AGB-U32P":"Boktai 2 - Solar Boy Django (Europe) (En,Fr,De,Es,It)","AGB-U33J":"Shin Bokura no Taiyou - Gyakushuu no Sabata (Japan)","AGB-U3IE":"Boktai - The Sun Is in Your Hand (USA)","AGB-U3IJ":"Bokura no Taiyou - Taiyou Action RPG (Japan)","AGB-U3IP":"Boktai - The Sun Is in Your Hand (Europe) (En,Fr,De,Es,It)","AGB-V49E":"Drill Dozer (USA)","AGB-V49J":"Screw Breaker - Goushin DoriRureRo (Japan)","AGB-V49P":"Drill Dozer (Europe) (En,Fr,De,Es,It) (Virtual Console)","AGB-XXXE":"Kien (USA) (Proto)","AGB-XXXX":"Aero the Acro-Bat - Rascal Rival Revenge (USA) (Beta 2)","AGB-ZBBJ":"Daigassou! Band-Brothers - Request Selection (Japan) (DS Expansion Cartridge)","AGB-ZMBJ":"Play-Yan Micro (Japan)","AGB-ZMDE":"Nintendo MP3 Player (Europe) (En,Fr,De,Es,It)","AGB-ZMPE":"GBA Jukebox (USA)","AGB-ZMPJ":"Music Recorder MP3 (Japan)"} \ No newline at end of file diff --git a/lib/gemba/data/gbc_games.json b/lib/gemba/data/gbc_games.json new file mode 100644 index 0000000..342f620 --- /dev/null +++ b/lib/gemba/data/gbc_games.json @@ -0,0 +1 @@ +{"CGB-AAXD":"Pokemon - Silberne Edition (Germany) (Beta) (SGB Enhanced) (GB Compatible)","CGB-ADVJ":"Granduel - Shinki Dungeon no Hihou (Japan)","CGB-AEBE":"Earthworm Jim - Menace 2 the Galaxy (USA, Europe) (GB Compatible)","CGB-AEDJ":"Dragon Quest I & II (Japan) (SGB Enhanced) (GB Compatible)","CGB-AF2J":"Konchuu Hakase 3 (Japan) (Beta)","CGB-AFRE":"Frogger (USA) (Rev 2) (GB Compatible)","CGB-AH9E":"Chase H.Q. - Secret Police (USA) (SGB Enhanced) (GB Compatible)","CGB-AHYE":"Super Mario Bros. Deluxe (USA, Europe) (Rev 1)","CGB-AIVE":"Space Invaders (USA, Europe) (GB Compatible)","CGB-AJUE":"Jeremy McGrath Supercross 2000 (USA, Europe)","CGB-AKVJ":"Pokemon Picross (Japan) (Proto) (SGB Enhanced) (GB Compatible)","CGB-AM7J":"Cardcaptor Sakura - Itsumo Sakura-chan to Issho (Japan) (Rev 2) (GB Compatible)","CGB-APJJ":"Pocket Cooking (Japan) (Beta)","CGB-AQCE":"Ms. Pac-Man - Special Color Edition (USA) (SGB Enhanced) (GB Compatible)","CGB-ASXE":"San Francisco Rush 2049 (USA, Europe)","CGB-AT9E":"Tomb Raider (USA, Europe) (En,Fr,De,Es,It) (Beta)","CGB-AVMU":"Rugrats - Time Travelers (USA, Europe) (GB Compatible)","CGB-AW2J":"Wario Land 2 (Japan) (SGB Enhanced) (GB Compatible)","CGB-AW8A":"Wario Land 3 (World) (En,Ja)","CGB-AWXE":"Mario Golf (USA)","CGB-AYKJ":"Yu-Gi-Oh! Duel Monsters II - Dark Duel Stories (Japan) (SGB Enhanced) (GB Compatible)","CGB-AZLE":"Legend of Zelda, The - Link's Awakening DX (USA, Europe) (Rev 2) (Beta) (SGB Enhanced) (GB Compatible)","CGB-AZLJ":"Zelda no Densetsu - Yume o Miru Shima DX (Japan) (Rev 1) (SGB Enhanced) (GB Compatible)","CGB-AZLP":"Legend of Zelda, The - Link's Awakening DX (Europe) (Rev 2) (Beta) (SGB Enhanced) (GB Compatible)","CGB-AZTE":"All Star Tennis '99 (USA) (Proto) (GB Compatible)","CGB-B3AE":"Shantae (World) (GBA Enhanced) (Switch)","CGB-B7IJ":"Nakayoshi Pet Series 3 - Kawaii Koinu (Japan)","CGB-B7NJ":"Nakayoshi Pet Series 4 - Kawaii Koneko (Japan)","CGB-BB3J":"Bakuten Shoot Beyblade (Japan)","CGB-BBZS":"Dragon Ball Z - Guerreros de Leyenda (Spain)","CGB-BCUJ":"Hamster Paradise 4 (Japan)","CGB-BDDE":"Donkey Kong Country (USA, Europe) (En,Fr,De,Es,It)","CGB-BDUF":"Doug - La Grande Aventure (France)","CGB-BEDE":"Gold and Glory - The Road to El Dorado (USA)","CGB-BFUU":"Shrek - Fairy Tale Freakdown (USA, Europe) (En,Fr,De,Es,It)","CGB-BGCJ":"Get!! Loto Club (Japan) (Proto) (GB Compatible)","CGB-BGKJ":"Doraemon no Study Boy - Gakushuu Kanji Game (Japan)","CGB-BHMJ":"Hamster Paradise 2 (Japan)","CGB-BIGP":"Inspector Gadget - Operation Madkactus (Europe) (Fr) (Beta)","CGB-BJAE":"NBA Jam 2001 (USA, Europe) (Beta)","CGB-BK7J":"Hello Kitty no Happy House (Japan)","CGB-BKHJ":"Kisekae Series 3 - Kisekae Hamster (Japan)","CGB-BKIE":"Kelly Club (USA)","CGB-BMHP":"Zidane - Football Generation (Europe) (En,Fr,De,Es,It) (Beta)","CGB-BO7E":"007 - The World Is Not Enough (USA, Europe)","CGB-BP7J":"Pokemon Card GB 2 - GR Dan Sanjou! (Japan)","CGB-BPNE":"Pokemon Puzzle Challenge (USA, Australia)","CGB-BQQJ":"Doraemon no Study Boy - Kuku Game (Japan)","CGB-BRZP":"Freestyle Scooter (Europe)","CGB-BT5E":"Toy Story Racer (USA, Europe)","CGB-BTFE":"Tony Hawk's Pro Skater (USA, Europe)","CGB-BTMJ":"Metamode (Japan)","CGB-BTOE":"Towers II - Plight of the Stargazer (USA) (Proto 1)","CGB-BTQE":"Tom and Jerry in - Mouse Attacks! (USA) (Rev 1)","CGB-BTXJ":"Keitai Denjuu Telefang - Power Version (Japan) (GB Compatible)","CGB-BVPP":"3D Pocket Pool (Europe) (En,Fr,De,Es,It,Nl)","CGB-BXGE":"Xtreme Sports (World) (Switch)","CGB-BY3J":"Yu-Gi-Oh! Duel Monsters III - Tri Holy God Advant (Japan)","CGB-BY4J":"Yu-Gi-Oh! Duel Monsters 4 - Battle of Great Duelist - Yuugi Deck (Japan)","CGB-BY6J":"Yu-Gi-Oh! Duel Monsters 4 - Battle of Great Duelist - Jounouchi Deck (Japan)","CGB-BYTE":"Pokemon - Crystal Version (USA)","CGB-BZSP":"Zidane - Football Generation (Europe) (En,Fr,De,Es,It)","CGB-CGB-ENIN":"Nyghtmare - The Ninth King (World) (Free Version) (GB Compatible)","CGB-DMG-BTSJ":"Taisen Tsume Shougi (Japan) (NP) (GB Compatible)","CGB-DX":"Super JetPak DX (World) (GB Compatible)","CGB-KTNE":"Kirby - Tilt 'n' Tumble (USA)","CGB-VPSE":"Polaris SnoCross (USA) (Beta) (Rumble Version)"} \ No newline at end of file diff --git a/lib/gemba/emulator_frame.rb b/lib/gemba/emulator_frame.rb new file mode 100644 index 0000000..37ebe55 --- /dev/null +++ b/lib/gemba/emulator_frame.rb @@ -0,0 +1,1010 @@ +# frozen_string_literal: true + +require 'fileutils' + +module Gemba + # SDL2 emulation frame — owns the mGBA core, viewport, audio stream, + # frame loop, and all rendering. Designed to be packed/unpacked inside + # a host window (AppController) so it can coexist with other "frames" like + # a game picker or replay viewer. + # + # Communication: + # AppController → EmulatorFrame: @frame.receive(:event_name, **args) + # EmulatorFrame → AppController: EventBus events (pause_changed, request_quit, etc.) + # Settings → EmulatorFrame: bus events subscribed directly + class EmulatorFrame + include Locale::Translatable + include BusEmitter + + # mGBA outputs at 44100 Hz (stereo int16) + AUDIO_FREQ = 44100 + MAX_DELTA = 0.005 # ±0.5% max adjustment (dynamic rate control) + FF_MAX_FRAMES = 10 # cap for uncapped turbo to avoid locking event loop + FADE_IN_FRAMES = (AUDIO_FREQ * 0.02).to_i # ~20ms = 882 samples + REWIND_PUSH_INTERVAL = 60 # ~1 second at ~60 fps + FOCUS_POLL_MS = 200 + + # @param app [Teek::App] the Tk application + # @param config [Config] configuration object + # @param platform [Platform] initial platform (GBA default) + # @param sound [Boolean] whether audio is enabled + # @param scale [Integer] video scale multiplier + # @param kb_map [KeyboardMap] keyboard input mapping (shared reference) + # @param gp_map [GamepadMap] gamepad input mapping (shared reference) + # @param keyboard [VirtualKeyboard] virtual keyboard state (shared reference) + # @param hotkeys [HotkeyMap] hotkey bindings (shared reference) + # @param frame_limit [Integer, nil] stop after this many frames (testing) + def initialize(app:, config:, platform:, sound:, scale:, + kb_map:, gp_map:, keyboard:, hotkeys:, + frame_limit: nil, + volume:, muted:, turbo_speed:, turbo_volume:, + keep_aspect_ratio:, show_fps:, pixel_filter:, + integer_scale:, color_correction:, frame_blending:, + rewind_enabled:, rewind_seconds:, + quick_save_slot:, save_state_backup:, + recording_compression:, pause_on_focus_loss:) + @app = app + @config = config + @platform = platform + @sound = sound + @scale = scale + @kb_map = kb_map + @gp_map = gp_map + @keyboard = keyboard + @hotkeys = hotkeys + @frame_limit = frame_limit + + # Emulation config state + @volume = volume + @muted = muted + @turbo_speed = turbo_speed + @turbo_volume = turbo_volume + @keep_aspect_ratio = keep_aspect_ratio + @show_fps = show_fps + @pixel_filter = pixel_filter + @integer_scale = integer_scale + @color_correction = color_correction + @frame_blending = frame_blending + @rewind_enabled = rewind_enabled + @rewind_seconds = rewind_seconds + @quick_save_slot = quick_save_slot + @save_state_backup = save_state_backup + @recording_compression = recording_compression + @pause_on_focus_loss = pause_on_focus_loss + + setup_bus_subscriptions + + # Runtime state + @audio_fade_in = 0 + @total_frames = 0 + @fast_forward = false + @paused = false + @core = nil + @sdl2_ready = false + @animate_started = false + @running = true + @cleaned_up = false + @recorder = nil + @input_recorder = nil + @save_mgr = nil + @rewind_frame_counter = 0 + end + + # -- Public accessors ------------------------------------------------------- + + # @return [Teek::SDL2::Viewport, nil] + attr_reader :viewport + + # @return [Core, nil] + attr_reader :core + + # @return [SaveStateManager, nil] + attr_reader :save_mgr + + # @return [Recorder, nil] + attr_reader :recorder + + # @return [Platform] + attr_reader :platform + + # @return [Float] current volume 0.0–1.0 + attr_reader :volume + + # @return [Integer] turbo speed multiplier (0 = uncapped) + attr_reader :turbo_speed + + # @return [Boolean] + def muted? = @muted + + # @return [Boolean] + def aspect_ratio = nil # emulator drives its own geometry via apply_scale + def sdl2_ready? = @sdl2_ready + + # @return [Boolean] + def paused? = @paused + + # @return [Boolean] + def fast_forward? = @fast_forward + + # @return [Boolean] + def recording? = @recorder&.recording? || false + + # @return [Boolean] + def input_recording? = @input_recorder&.recording? || false + + # @return [Boolean] + def rom_loaded? = !!@core + + # @return [Boolean] + def show_fps? = @show_fps + + # Allow AppController to control the animate loop + attr_writer :running + + # Allow AppController to update scale (for screenshots) + attr_writer :scale + + # FrameStack protocol + def show + return unless @sdl2_ready && @viewport + @app.command(:pack, @viewport.frame.path, fill: :both, expand: 1) + end + + def hide + return unless @sdl2_ready && @viewport + @app.command(:pack, :forget, @viewport.frame.path) rescue nil + end + + # Single entry point for AppController → EmulatorFrame communication. + # AppController calls @frame.receive(:event_name, **args) instead of + # knowing about individual methods. + def receive(event, **args) + case event + when :pause then toggle_pause + when :fast_forward then toggle_fast_forward + when :rewind then do_rewind + when :quick_save then quick_save + when :quick_load then quick_load + when :save_state then save_state(args[:slot]) + when :load_state then load_state(args[:slot]) + when :screenshot then take_screenshot + when :toggle_recording then toggle_recording + when :toggle_input_recording then toggle_input_recording + when :toggle_show_fps + @show_fps = !@show_fps + @hud&.set_fps(nil) unless @show_fps + when :show_toast + show_toast(args[:message], permanent: args[:permanent] || false) + when :dismiss_toast + dismiss_toast + when :modal_entered + toggle_fast_forward if fast_forward? + toggle_pause if rom_loaded? && !paused? + when :modal_exited + dismiss_toast + toggle_pause if rom_loaded? && !args[:was_paused] + when :modal_focus_changed + dismiss_toast + show_toast(args[:message], permanent: true) + when :write_config then write_config + when :refresh_from_config then refresh_from_config(@config) + end + end + + # -- SDL2 lifecycle --------------------------------------------------------- + + # Create the SDL2 viewport, audio stream, fonts, and input bindings. + # Must be called once before load_core. + def init_sdl2 + return if @sdl2_ready + + @app.command('tk', 'busy', '.') + + win_w = @platform.width * @scale + win_h = @platform.height * @scale + + @viewport = Teek::SDL2::Viewport.new(@app, width: win_w, height: win_h, vsync: false) + @viewport.pack(fill: :both, expand: true) + + # Streaming texture at native resolution + @texture = @viewport.renderer.create_texture(@platform.width, @platform.height, :streaming) + @texture.scale_mode = @pixel_filter.to_sym + + # Font for on-screen indicators (FPS, fast-forward label) + font_path = File.join(ASSETS_DIR, 'JetBrainsMonoNL-Regular.ttf') + @overlay_font = File.exist?(font_path) ? @viewport.renderer.load_font(font_path, 14) : nil + + # CJK-capable font for toast notifications and translated UI text + toast_font_path = File.join(ASSETS_DIR, 'ark-pixel-12px-monospaced-ja.ttf') + toast_font = File.exist?(toast_font_path) ? @viewport.renderer.load_font(toast_font_path, 12) : @overlay_font + + @toast = ToastOverlay.new( + renderer: @viewport.renderer, + font: toast_font || @overlay_font, + duration: @config.toast_duration + ) + + # Custom blend mode: white text inverts the background behind it. + inverse_blend = Teek::SDL2.compose_blend_mode( + :one_minus_dst_color, :one_minus_src_alpha, :add, + :zero, :one, :add + ) + + @hud = OverlayRenderer.new(font: @overlay_font, blend_mode: inverse_blend) + + # Audio stream — stereo int16. + if @sound && Teek::SDL2::AudioStream.available? + @stream = Teek::SDL2::AudioStream.new( + frequency: AUDIO_FREQ, + format: :s16, + channels: 2 + ) + @stream.resume + else + if @sound + Gemba.log(:warn) { "No audio device found, continuing without sound" } + warn "gemba: no audio device found, continuing without sound" + end + @stream = Teek::SDL2::NullAudioStream.new + end + + setup_input + + @sdl2_ready = true + + # Unblock interaction now that SDL2 is ready + @app.command('tk', 'busy', 'forget', '.') + + # Auto-focus viewport for keyboard input + @app.tcl_eval("focus -force #{@viewport.frame.path}") + @app.update + rescue => e + Gemba.log(:error) { "init_sdl2 failed: #{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}" } + $stderr.puts "FATAL: init_sdl2 failed: #{e.class}: #{e.message}" + $stderr.puts e.backtrace.first(10).map { |l| " #{l}" }.join("\n") + @app.command('tk', 'busy', 'forget', '.') rescue nil + emit(:request_quit) + end + + # Load (or reload) a ROM core. Creates Core + SaveStateManager. + # @param rom_path [String] resolved path to the ROM file + # @param saves_dir [String] directory for .sav files + # @param bios_path [String, nil] full path to BIOS file (loaded before reset) + # @param rom_source_path [String] original path (for input recorder) + # @return [Core] the new core + def load_core(rom_path, saves_dir:, bios_path: nil, rom_source_path: nil) + stop_recording if @recorder&.recording? + stop_input_recording if @input_recorder&.recording? + + if @core && !@core.destroyed? + @core.destroy + end + @stream.clear + + FileUtils.mkdir_p(saves_dir) unless File.directory?(saves_dir) + @core = Core.new(rom_path, saves_dir, bios_path) + @rom_source_path = rom_source_path || rom_path + + new_platform = Platform.for(@core) + if new_platform != @platform + @platform = new_platform + recreate_texture + end + + @save_mgr = SaveStateManager.new(core: @core, config: @config, app: @app, platform: @platform) + @save_mgr.state_dir = @save_mgr.state_dir_for_rom(@core) + @save_mgr.quick_save_slot = @quick_save_slot + @save_mgr.backup = @save_state_backup + @core.color_correction = @color_correction if @color_correction + @core.frame_blending = @frame_blending if @frame_blending + @core.rewind_init(@rewind_seconds) if @rewind_enabled + @rewind_frame_counter = 0 + @paused = false + @stream.resume + set_event_loop_speed(:fast) + @fps_count = 0 + @fps_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @next_frame = @fps_time + @audio_samples_produced = 0 + + @core + end + + # Start the emulation animate loop. Call once after first load_core. + def start_animate + return if @animate_started + @animate_started = true + animate + end + + # -- Toast helpers (called by AppController via receive) ---------------------- + + def show_toast(msg, permanent: false) + @toast&.show(msg, permanent: permanent) + render_if_paused + end + + def dismiss_toast + @toast&.destroy + end + + # -- Cleanup ---------------------------------------------------------------- + + def cleanup + return if @cleaned_up + @cleaned_up = true + + stop_recording if @recorder&.recording? + stop_input_recording if @input_recorder&.recording? + @stream&.pause unless @stream&.destroyed? + @hud&.destroy + @toast&.destroy + @overlay_font&.destroy unless @overlay_font&.destroyed? + @stream&.destroy unless @stream&.destroyed? + @texture&.destroy unless @texture&.destroyed? + @core&.destroy unless @core&.destroyed? + if @viewport + @app.command(:destroy, @viewport.frame.path) rescue nil + @viewport.destroy rescue nil + end + @sdl2_ready = false + RomResolver.cleanup_temp + end + + # -- Emulation control ------------------------------------------------------ + + def toggle_pause + return unless @core + @paused = !@paused + if @paused + @stream.clear + @stream.pause + @toast&.show(translate('toast.paused'), permanent: true) + render_frame + set_event_loop_speed(:idle) + else + set_event_loop_speed(:fast) + @toast&.destroy + @stream.clear + @audio_fade_in = FADE_IN_FRAMES + @stream.resume + @next_frame = Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + emit(:pause_changed, @paused) + end + + def toggle_fast_forward + return unless @core + @fast_forward = !@fast_forward + if @fast_forward + @hud.set_ff_label(ff_label_text) + else + @hud.set_ff_label(nil) + @next_frame = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @stream.clear + end + end + + def do_rewind + return unless @core && !@core.destroyed? + unless @rewind_enabled + @toast&.show(translate('toast.no_rewind')) + render_if_paused + return + end + if @core.rewind_pop == true + @core.run_frame + @stream.clear + @audio_fade_in = FADE_IN_FRAMES + @rewind_frame_counter = 0 + @toast&.show(translate('toast.rewound')) + render_frame + else + @toast&.show(translate('toast.no_rewind')) + render_if_paused + end + end + + # -- Save states (delegated to SaveStateManager) ---------------------------- + + def save_state(slot) + return unless @save_mgr + _ok, msg = @save_mgr.save_state(slot) + @toast&.show(msg) if msg + end + + def load_state(slot) + return unless @save_mgr + _ok, msg = @save_mgr.load_state(slot) + @toast&.show(msg) if msg + end + + def quick_save + return unless @save_mgr + _ok, msg = @save_mgr.quick_save + @toast&.show(msg) if msg + end + + def quick_load + return unless @save_mgr + _ok, msg = @save_mgr.quick_load + @toast&.show(msg) if msg + end + + # -- Screenshot ------------------------------------------------------------- + + def take_screenshot + return unless @core && !@core.destroyed? + + dir = Config.default_screenshots_dir + FileUtils.mkdir_p(dir) + + title = @core.title.strip.gsub(/[^a-zA-Z0-9_\-]/, '_') + stamp = Time.now.strftime('%Y%m%d_%H%M%S') + name = "#{title}_#{stamp}.png" + path = File.join(dir, name) + + pixels = @core.video_buffer_argb + photo_name = "__gemba_ss_#{object_id}" + out_w = @platform.width * @scale + out_h = @platform.height * @scale + @app.command(:image, :create, :photo, photo_name, + width: out_w, height: out_h) + @app.interp.photo_put_zoomed_block(photo_name, pixels, @platform.width, @platform.height, + zoom_x: @scale, zoom_y: @scale, format: :argb) + @app.command(photo_name, :write, path, format: :png) + @app.command(:image, :delete, photo_name) + @toast&.show(translate('toast.screenshot_saved', name: name)) + rescue StandardError => e + warn "gemba: screenshot failed: #{e.message} (#{e.class})" + @app.command(:image, :delete, photo_name) rescue nil + @toast&.show(translate('toast.screenshot_failed')) + end + + # -- Recording -------------------------------------------------------------- + + def toggle_recording + return unless @core + @recorder&.recording? ? stop_recording : start_recording + end + + def start_recording + dir = @config.recordings_dir + FileUtils.mkdir_p(dir) unless File.directory?(dir) + timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%L') + title = @core.title.strip.gsub(/[^a-zA-Z0-9_.-]/, '_') + filename = "#{title}_#{timestamp}.grec" + path = File.join(dir, filename) + @recorder = Recorder.new(path, width: @platform.width, height: @platform.height, + fps_fraction: @platform.fps_fraction, + compression: @recording_compression) + @recorder.start + Gemba.log(:info) { "Recording started: #{path}" } + @toast&.show(translate('toast.recording_started')) + emit(:recording_changed) + end + + def stop_recording + return unless @recorder&.recording? + @recorder.stop + count = @recorder.frame_count + Gemba.log(:info) { "Recording stopped: #{count} frames" } + @toast&.show(translate('toast.recording_stopped', frames: count)) + @recorder = nil + emit(:recording_changed) + end + + # -- Input recording -------------------------------------------------------- + + def toggle_input_recording + return unless @core + @input_recorder&.recording? ? stop_input_recording : start_input_recording + end + + def start_input_recording + dir = @config.recordings_dir + FileUtils.mkdir_p(dir) unless File.directory?(dir) + timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%L') + title = @core.title.strip.gsub(/[^a-zA-Z0-9_.-]/, '_') + filename = "#{title}_#{timestamp}.gir" + path = File.join(dir, filename) + @input_recorder = InputRecorder.new(path, core: @core, rom_path: @rom_source_path) + @input_recorder.start + @toast&.show(translate('toast.input_recording_started')) + emit(:input_recording_changed) + end + + def stop_input_recording + return unless @input_recorder&.recording? + @input_recorder.stop + count = @input_recorder.frame_count + @toast&.show(translate('toast.input_recording_stopped', frames: count)) + @input_recorder = nil + emit(:input_recording_changed) + end + + # -- Config appliers -------------------------------------------------------- + + def apply_volume(vol) + @volume = vol.to_f.clamp(0.0, 1.0) + end + + def apply_mute(muted) + @muted = !!muted + end + + def apply_turbo_speed(speed) + @turbo_speed = speed + @hud.set_ff_label(ff_label_text) if @fast_forward + end + + def apply_aspect_ratio(keep) + @keep_aspect_ratio = keep + end + + def apply_show_fps(show) + @show_fps = show + @hud.set_fps(nil) unless @show_fps + end + + def apply_toast_duration(secs) + @config.toast_duration = secs + @toast.duration = secs + end + + def apply_pixel_filter(filter) + @pixel_filter = filter + @texture.scale_mode = filter.to_sym if @texture + end + + def apply_integer_scale(enabled) + @integer_scale = !!enabled + end + + def apply_color_correction(enabled) + @color_correction = !!enabled + if @core && !@core.destroyed? + @core.color_correction = @color_correction + render_if_paused + end + end + + def apply_frame_blending(enabled) + @frame_blending = !!enabled + if @core && !@core.destroyed? + @core.frame_blending = @frame_blending + render_if_paused + end + end + + def apply_rewind_toggle(enabled) + @rewind_enabled = !!enabled + if @core && !@core.destroyed? + if @rewind_enabled + @core.rewind_init(@rewind_seconds) + @rewind_frame_counter = 0 + else + @core.rewind_deinit + end + end + end + + def apply_recording_compression(val) + @recording_compression = val.to_i.clamp(1, 9) + end + + def apply_pause_on_focus_loss(val) + @pause_on_focus_loss = val + @was_paused_before_focus_loss = false unless val + end + + def apply_quick_slot(slot) + @quick_save_slot = slot.to_i.clamp(1, 10) + @save_mgr.quick_save_slot = @quick_save_slot if @save_mgr + end + + def apply_backup(enabled) + @save_state_backup = !!enabled + @save_mgr.backup = @save_state_backup if @save_mgr + end + + # Sync all config-derived state from a config object after per-game switch. + def refresh_from_config(config) + @pixel_filter = config.pixel_filter + @integer_scale = config.integer_scale? + @color_correction = config.color_correction? + @frame_blending = config.frame_blending? + @rewind_enabled = config.rewind_enabled? + @rewind_seconds = config.rewind_seconds + @quick_save_slot = config.quick_save_slot + @save_state_backup = config.save_state_backup? + @recording_compression = config.recording_compression + @volume = config.volume / 100.0 + @muted = config.muted? + @turbo_speed = config.turbo_speed + + @texture.scale_mode = @pixel_filter.to_sym if @texture + if @core && !@core.destroyed? + @core.color_correction = @color_correction + @core.frame_blending = @frame_blending + render_if_paused + end + @save_mgr.quick_save_slot = @quick_save_slot if @save_mgr + @save_mgr.backup = @save_state_backup if @save_mgr + end + + # Write all config-derived state back to the config object. + # Called by AppController before config.save! + def write_config + @config.volume = (@volume * 100).round + @config.muted = @muted + @config.turbo_speed = @turbo_speed + @config.keep_aspect_ratio = @keep_aspect_ratio + @config.show_fps = @show_fps + @config.pixel_filter = @pixel_filter + @config.integer_scale = @integer_scale + @config.color_correction = @color_correction + @config.frame_blending = @frame_blending + @config.rewind_enabled = @rewind_enabled + @config.rewind_seconds = @rewind_seconds + @config.quick_save_slot = @quick_save_slot + @config.save_state_backup = @save_state_backup + @config.recording_compression = @recording_compression + @config.pause_on_focus_loss = @pause_on_focus_loss + end + + # -- Class methods ---------------------------------------------------------- + + # Apply a linear fade-in ramp to int16 stereo PCM data. + # Pure function: takes remaining/total counters, returns [pcm, new_remaining]. + # @param pcm [String] packed int16 stereo PCM + # @param remaining [Integer] fade samples remaining (counts down to 0) + # @param total [Integer] total fade length in samples + # @return [Array(String, Integer)] modified PCM and updated remaining count + def self.apply_fade_ramp(pcm, remaining, total) + samples = pcm.unpack('s*') + i = 0 + while i < samples.length && remaining > 0 + gain = 1.0 - (remaining.to_f / total) + samples[i] = (samples[i] * gain).round.clamp(-32768, 32767) + samples[i + 1] = (samples[i + 1] * gain).round.clamp(-32768, 32767) if i + 1 < samples.length + remaining -= 1 + i += 2 + end + [samples.pack('s*'), remaining] + end + + private + + def setup_bus_subscriptions + bus = Gemba.bus + + # Video/rendering + bus.on(:filter_changed) { |val| apply_pixel_filter(val) } + bus.on(:integer_scale_changed) { |val| apply_integer_scale(val) } + bus.on(:color_correction_changed) { |val| apply_color_correction(val) } + bus.on(:frame_blending_changed) { |val| apply_frame_blending(val) } + bus.on(:aspect_ratio_changed) { |val| apply_aspect_ratio(val) } + bus.on(:show_fps_changed) { |val| apply_show_fps(val) } + bus.on(:toast_duration_changed) { |val| apply_toast_duration(val) } + bus.on(:turbo_speed_changed) { |val| apply_turbo_speed(val) } + bus.on(:rewind_toggled) { |val| apply_rewind_toggle(val) } + bus.on(:pause_on_focus_loss_changed) { |val| apply_pause_on_focus_loss(val) } + + # Audio + bus.on(:volume_changed) { |vol| apply_volume(vol) } + bus.on(:mute_changed) { |val| apply_mute(val) } + + # Recording / save states + bus.on(:compression_changed) { |val| apply_recording_compression(val) } + bus.on(:quick_slot_changed) { |val| apply_quick_slot(val) } + bus.on(:backup_changed) { |val| apply_backup(val) } + + # Save state picker events + bus.on(:state_save_requested) { |slot| save_state(slot) } + bus.on(:state_load_requested) { |slot| load_state(slot) } + end + + # -- Frame loop ------------------------------------------------------------- + + def animate + return unless @running + tick + delay = (@core && !@paused) ? 1 : 100 + @app.after(delay) { animate } + end + + def tick + unless @core + @viewport.render { |r| r.clear(0, 0, 0) } + return + end + + return if @paused + + now = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @next_frame ||= now + + if @fast_forward + tick_fast_forward(now) + else + tick_normal(now) + end + end + + def tick_normal(now) + frames = 0 + while @next_frame <= now && frames < 4 + run_one_frame + rec_pcm = capture_frame + queue_audio(raw_pcm: rec_pcm) + + fill = (@stream.queued_samples.to_f / audio_buf_capacity).clamp(0.0, 1.0) + ratio = (1.0 - MAX_DELTA) + 2.0 * fill * MAX_DELTA + @next_frame += frame_period * ratio + frames += 1 + end + + @next_frame = now if now - @next_frame > 0.1 + return if frames == 0 + + render_frame + update_fps(frames, now) + end + + def tick_fast_forward(now) + if @turbo_speed == 0 + keys = poll_input + FF_MAX_FRAMES.times do |i| + @core.set_keys(keys) + @core.run_frame + rec_pcm = capture_frame + if i == 0 + queue_audio(volume_override: @turbo_volume, raw_pcm: rec_pcm) + elsif !rec_pcm + @core.audio_buffer + end + end + @next_frame = now + render_frame(ff_indicator: true) + update_fps(FF_MAX_FRAMES, now) + return + end + + frames = 0 + while @next_frame <= now && frames < @turbo_speed * 4 + @turbo_speed.times do + run_one_frame + rec_pcm = capture_frame + if frames == 0 + queue_audio(volume_override: @turbo_volume, raw_pcm: rec_pcm) + elsif !rec_pcm + @core.audio_buffer + end + frames += 1 + end + @next_frame += frame_period + end + @next_frame = now if now - @next_frame > 0.1 + return if frames == 0 + + render_frame(ff_indicator: true) + update_fps(frames, now) + end + + def run_one_frame + mask = poll_input + @input_recorder&.capture(mask) if @input_recorder&.recording? + @core.set_keys(mask) + @core.run_frame + @total_frames += 1 + @running = false if @frame_limit && @total_frames >= @frame_limit + if @rewind_enabled + @rewind_frame_counter += 1 + if @rewind_frame_counter >= REWIND_PUSH_INTERVAL + @core.rewind_push + @rewind_frame_counter = 0 + end + end + end + + # -- Input ------------------------------------------------------------------ + + def setup_input + @viewport.bind('KeyPress', :keysym, '%s') do |k, state_str| + if k == 'Escape' + emit(:request_escape) + else + mods = HotkeyMap.modifiers_from_state(state_str.to_i) + case @hotkeys.action_for(k, modifiers: mods) + when :quit then emit(:request_quit) + when :pause then toggle_pause + when :fast_forward then toggle_fast_forward + when :fullscreen then emit(:request_fullscreen) + when :show_fps then emit(:request_show_fps_toggle) + when :quick_save then quick_save + when :quick_load then quick_load + when :save_states then emit(:request_save_states) + when :screenshot then take_screenshot + when :rewind then do_rewind + when :record then toggle_recording + when :input_record then toggle_input_recording + when :open_rom then emit(:request_open_rom) + else @keyboard.press(k) + end + end + end + + @viewport.bind('KeyRelease', :keysym) do |k| + @keyboard.release(k) + end + + @viewport.bind('FocusIn') { @has_focus = true } + @viewport.bind('FocusOut') { @has_focus = false } + + start_focus_poll + + # Alt+Return fullscreen toggle (emulator convention) + @app.command(:bind, @viewport.frame.path, '', proc { emit(:request_fullscreen) }) + end + + # Read keyboard + gamepad state, return combined bitmask. + def poll_input + begin + Teek::SDL2::Gamepad.update_state + rescue StandardError + @gp_map.device = nil + end + @kb_map.mask | @gp_map.mask + end + + # -- Rendering -------------------------------------------------------------- + + def render_frame(ff_indicator: false) + pixels = @core.video_buffer_argb + @texture.update(pixels) + dest = compute_dest_rect + @viewport.render do |r| + r.clear(0, 0, 0) + r.copy(@texture, nil, dest) + if @recorder&.recording? || @input_recorder&.recording? + bx = (dest ? dest[0] : 0) + 12 + by = (dest ? dest[1] : 0) + 12 + if @recorder&.recording? + draw_filled_circle(r, bx, by, 5, 220, 30, 30, 200) + bx += 14 + end + if @input_recorder&.recording? + draw_filled_circle(r, bx, by, 5, 30, 180, 30, 200) + end + end + @hud.draw(r, dest, show_fps: @show_fps, show_ff: ff_indicator) + @toast&.draw(r, dest) + end + end + + def render_if_paused + render_frame if @paused && @core && @texture + end + + def compute_dest_rect + return nil unless @keep_aspect_ratio + + out_w, out_h = @viewport.renderer.output_size + scale_x = out_w.to_f / @platform.width + scale_y = out_h.to_f / @platform.height + scale = [scale_x, scale_y].min + scale = scale.floor if @integer_scale && scale >= 1.0 + + dest_w = (@platform.width * scale).to_i + dest_h = (@platform.height * scale).to_i + dest_x = (out_w - dest_w) / 2 + dest_y = (out_h - dest_h) / 2 + + [dest_x, dest_y, dest_w, dest_h] + end + + def draw_filled_circle(renderer, cx, cy, radius, r, g, b, a) + r2 = radius * radius + (-radius..radius).each do |dy| + dx = Math.sqrt(r2 - dy * dy).to_i + renderer.fill_rect(cx - dx, cy + dy, dx * 2 + 1, 1, r, g, b, a) + end + end + + def update_fps(frames, now) + @fps_count += frames + elapsed = now - @fps_time + if elapsed >= 1.0 + fps = (@fps_count / elapsed).round(1) + @hud.set_fps(translate('player.fps', fps: fps)) if @show_fps + @audio_samples_produced = 0 + @fps_count = 0 + @fps_time = now + end + end + + # -- Audio ------------------------------------------------------------------ + + def queue_audio(volume_override: nil, raw_pcm: nil) + pcm = raw_pcm || @core.audio_buffer + return if pcm.empty? + + @audio_samples_produced += pcm.bytesize / 4 + if @muted + @audio_fade_in = 0 + else + vol = volume_override || @volume + pcm = apply_volume_to_pcm(pcm, vol) if vol < 1.0 + if @audio_fade_in > 0 + pcm, @audio_fade_in = self.class.apply_fade_ramp(pcm, @audio_fade_in, FADE_IN_FRAMES) + end + @stream.queue(pcm) + end + end + + def apply_volume_to_pcm(pcm, gain = @volume) + samples = pcm.unpack('s*') + samples.map! { |s| (s * gain).round.clamp(-32768, 32767) } + samples.pack('s*') + end + + # Capture current frame for recording. + def capture_frame + return nil unless @recorder&.recording? + pcm = @core.audio_buffer + @recorder.capture(@core.video_buffer_argb, pcm) + pcm + end + + # -- Focus polling ---------------------------------------------------------- + + def start_focus_poll + @had_focus = @viewport.renderer.input_focus? + @app.after(FOCUS_POLL_MS) { focus_poll_tick } + end + + def focus_poll_tick + return unless @running + + has_focus = @viewport.renderer.input_focus? + + if @had_focus && !has_focus + if @pause_on_focus_loss && @core && !@paused + @was_paused_before_focus_loss = true + toggle_pause + end + elsif !@had_focus && has_focus + if @was_paused_before_focus_loss && @paused + @was_paused_before_focus_loss = false + toggle_pause + end + end + + @had_focus = has_focus + @app.after(FOCUS_POLL_MS) { focus_poll_tick } + rescue StandardError + nil + end + + # -- Helpers ---------------------------------------------------------------- + + def frame_period = 1.0 / @platform.fps + def audio_buf_capacity = (AUDIO_FREQ / @platform.fps * 6).to_i + + def recreate_texture + @texture&.destroy + @texture = @viewport.renderer.create_texture(@platform.width, @platform.height, :streaming) + @texture.scale_mode = @pixel_filter.to_sym + end + + def ff_label_text + @turbo_speed == 0 ? translate('player.ff_max') : translate('player.ff', speed: @turbo_speed) + end + + def set_event_loop_speed(mode) + ms = mode == :fast ? 1 : 50 + @app.interp.thread_timer_ms = ms + end + end +end diff --git a/lib/gemba/event_bus.rb b/lib/gemba/event_bus.rb index b58e9ca..3f8ef17 100644 --- a/lib/gemba/event_bus.rb +++ b/lib/gemba/event_bus.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require_relative "logging" module Gemba # Publish/subscribe event bus for decoupled communication. @@ -46,24 +45,4 @@ def off(event, block) end end - # Module-level bus accessor. Auto-creates a default bus on first access - # so tests and standalone classes don't need explicit setup. - # Player replaces it with a fresh bus at startup. - class << self - def bus - @bus ||= EventBus.new - end - - attr_writer :bus - end - - # Include in any class that emits events via Gemba.bus. - # No constructor changes needed — just include and call emit. - module BusEmitter - private - - def emit(event, *args, **kwargs) - Gemba.bus.emit(event, *args, **kwargs) - end - end end diff --git a/lib/gemba/frame_stack.rb b/lib/gemba/frame_stack.rb new file mode 100644 index 0000000..14f1157 --- /dev/null +++ b/lib/gemba/frame_stack.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Gemba + # Push/pop stack for content frames inside the main window. + # + # Mirrors the ModalStack pattern. When a new frame is pushed, the + # previous frame is hidden and the new one is shown. Popping reverses + # the transition. + # + # Frames must implement the FrameStack protocol: + # show — pack/display the frame + # hide — unpack/remove the frame from view + # cleanup — release resources (SDL2, etc.) + # + # @example + # stack = FrameStack.new + # stack.push(:picker, game_picker_frame) + # stack.push(:emulator, emulator_frame) # picker auto-hidden + # stack.pop # emulator hidden, picker re-shown + class FrameStack + Entry = Data.define(:name, :frame) + + def initialize + @stack = [] + end + + # @return [Boolean] true if any frame is on the stack + def active? = !@stack.empty? + + # @return [Symbol, nil] name of the topmost frame + def current = @stack.last&.name + + # @return [Object, nil] the topmost frame object + def current_frame = @stack.last&.frame + + # @return [Integer] number of frames on the stack + def size = @stack.length + + # Push a frame onto the stack. + # + # The previous frame (if any) is hidden before the new one is shown. + # + # @param name [Symbol] identifier (e.g. :picker, :emulator) + # @param frame [#show, #hide] the frame object + def push(name, frame) + @stack.last&.frame&.hide + @stack.push(Entry.new(name: name, frame: frame)) + frame.show + end + + # Pop the current frame off the stack. + # + # The popped frame is hidden. If there's a previous frame, it is re-shown. + def pop + return unless (entry = @stack.pop) + entry.frame.hide + @stack.last&.frame&.show + end + end +end diff --git a/lib/gemba/game_index.rb b/lib/gemba/game_index.rb new file mode 100644 index 0000000..9830350 --- /dev/null +++ b/lib/gemba/game_index.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "json" + +module Gemba + # Lookup table mapping ROM serial codes to canonical game names. + # + # Data is pre-baked from No-Intro DAT files via script/bake_game_index.rb + # and stored as JSON in lib/gemba/data/{platform}_games.json. + # + # Loaded lazily on first lookup per platform. + # + # GameIndex.lookup("AGB-AXVE") # => "Pokemon - Ruby Version (USA)" + # GameIndex.lookup("CGB-BYTE") # => nil (unknown) + # + class GameIndex + DATA_DIR = File.expand_path("data", __dir__) + + PLATFORM_FILES = { + "AGB" => "gba_games.json", + "CGB" => "gbc_games.json", + "DMG" => "gb_games.json", + }.freeze + + class << self + # Look up a canonical game name by serial code. + # @param game_code [String] e.g. "AGB-AXVE", "CGB-BYTE", "DMG-XXXX" + # @return [String, nil] canonical name or nil if not found + def lookup(game_code) + return nil unless game_code && !game_code.empty? + + platform = game_code.split("-", 2).first + index = index_for(platform) + return nil unless index + + index[game_code] + end + + # Force-reload all indexes (useful after re-baking). + def reset! + @indexes = {} + end + + private + + def index_for(platform) + @indexes ||= {} + return @indexes[platform] if @indexes.key?(platform) + + file = PLATFORM_FILES[platform] + return(@indexes[platform] = nil) unless file + + path = File.join(DATA_DIR, file) + return(@indexes[platform] = nil) unless File.exist?(path) + + @indexes[platform] = JSON.parse(File.read(path)) + end + end + end +end diff --git a/lib/gemba/game_picker_frame.rb b/lib/gemba/game_picker_frame.rb new file mode 100644 index 0000000..bab5725 --- /dev/null +++ b/lib/gemba/game_picker_frame.rb @@ -0,0 +1,263 @@ +# frozen_string_literal: true + + +module Gemba + # Startup frame showing a 4×4 grid of ROM cards. + # + # Each card displays box art (if available), ROM title, and platform. + # Clicking a populated card emits :rom_selected on the bus. + # Right-clicking a populated card shows a context menu (Play / Set Boxart). + # Pure Tk — no SDL2. + class GamePickerFrame + include BusEmitter + include Locale::Translatable + + COLS = 4 + ROWS = 4 + SLOTS = COLS * ROWS + IMG_SUBSAMPLE = 4 # 512px ÷ 4 = 128px per card + IMG_SIZE = 128 # height/width of the scaled image in pixels + PLACEHOLDER_PNG = File.expand_path("../../assets/placeholder_boxart.png", __dir__) + + # Aspect ratio for wm aspect lock when picker is visible (width:height). + # 3:4 gives enough vertical room for image + title + platform label. + PICKER_ASPECT_W = 3 + PICKER_ASPECT_H = 4 + + # Default and minimum picker window dimensions (must satisfy PICKER_ASPECT ratio) + PICKER_DEFAULT_W = 640 + PICKER_DEFAULT_H = 854 # 640 * 4/3, rounded up + PICKER_MIN_W = 480 + PICKER_MIN_H = 640 + + def initialize(app:, rom_library:, boxart_fetcher: nil, rom_overrides: nil) + @app = app + @rom_library = rom_library + @fetcher = boxart_fetcher + @overrides = rom_overrides + @built = false + @cards = {} # index => { frame:, image:, title:, platform:, photo: } + @photos = {} # key => Tk image name (kept alive to prevent GC) + end + + def show + build_ui unless @built + refresh + @app.command(:pack, @grid, fill: :both, expand: 1) + end + + def hide + @app.command(:pack, :forget, @grid) rescue nil + end + + def cleanup + @photos&.each_value { |name| @app.command(:image, :delete, name) rescue nil } + @photos&.clear + end + + def receive(event, **args) + case event + when :refresh then refresh + end + end + + def aspect_ratio = [PICKER_ASPECT_W, PICKER_ASPECT_H] + def rom_loaded? = false + def sdl2_ready? = false + def paused? = false + + private + + def build_ui + @grid = '.game_picker' + @app.command('ttk::frame', @grid, padding: 16) + + # Capture the system window background color so hollow cards blend in + # rather than appearing as stark black rectangles. + @empty_bg = @app.tcl_eval(". cget -background") + + # Load a transparent 128×128 placeholder once — gives all image labels + # a fixed pixel size whether or not box art has been fetched yet. + @app.command(:image, :create, :photo, 'boxart_placeholder', file: PLACEHOLDER_PNG) + + SLOTS.times do |i| + row = i / COLS + col = i % COLS + + cell = "#{@grid}.card#{i}" + @app.command(:frame, cell, relief: :groove, borderwidth: 2, + padx: 4, pady: 4, bg: '#2a2a2a') + @app.command(:grid, cell, row: row, column: col, padx: 6, pady: 6, sticky: :nsew) + + img_lbl = "#{cell}.img" + @app.command(:label, img_lbl, bg: '#2a2a2a', anchor: :center, image: 'boxart_placeholder') + @app.command(:pack, img_lbl, fill: :x) + + title_lbl = "#{cell}.title" + @app.command(:label, title_lbl, text: '', anchor: :center, + bg: '#2a2a2a', fg: '#cccccc', + font: '{TkDefaultFont} 10') + @app.command(:pack, title_lbl, fill: :x, pady: [4, 2]) + + plat_lbl = "#{cell}.plat" + @app.command(:label, plat_lbl, text: '', anchor: :center, + bg: '#2a2a2a', fg: '#888888', + font: '{TkDefaultFont} 8') + @app.command(:pack, plat_lbl, fill: :x, pady: [0, 4]) + + @cards[i] = { frame: cell, image: img_lbl, title: title_lbl, platform: plat_lbl } + end + + # Make columns and rows expand evenly + COLS.times { |c| @app.command(:grid, :columnconfigure, @grid, c, weight: 1) } + ROWS.times { |r| @app.command(:grid, :rowconfigure, @grid, r, weight: 1) } + + @built = true + end + + def refresh + roms = @rom_library.all.first(SLOTS) + + SLOTS.times do |i| + card = @cards[i] + rom = roms[i] + + if rom + rom_info = RomInfo.from_rom(rom, fetcher: @fetcher, overrides: @overrides) + populate_card(card, rom_info) + else + hollow_card(card) + end + end + end + + def populate_card(card, rom_info) + @app.command(card[:image], :configure, bg: '#2a2a2a') + @app.command(card[:title], :configure, text: rom_info.title, fg: '#cccccc', bg: '#2a2a2a') + @app.command(card[:platform], :configure, text: rom_info.platform, fg: '#888888', bg: '#2a2a2a') + @app.command(card[:frame], :configure, relief: :groove, bg: '#2a2a2a') + + # Determine which image to show + key = rom_info.rom_id || rom_info.game_code + + if rom_info.boxart_path + # Custom override or cached art — load immediately + if @photos.key?(key) + @app.command(card[:image], :configure, image: @photos[key]) + else + set_card_image(card, key, rom_info.boxart_path) + end + elsif rom_info.has_official_entry && @fetcher && rom_info.game_code + # No art yet but libretro has an entry — kick off async fetch + @app.command(card[:image], :configure, image: 'boxart_placeholder') + @fetcher.fetch(rom_info.game_code) { |path| set_card_image(card, key, path) } + else + @app.command(card[:image], :configure, image: 'boxart_placeholder') + end + + # Left-click → play + click = proc { emit(:rom_selected, rom_info.path) } + @app.command(:bind, card[:frame], '', click) + @app.command(:bind, card[:image], '', click) + @app.command(:bind, card[:title], '', click) + @app.command(:bind, card[:platform], '', click) + + # Right-click → context menu + bind_context_menu(card, rom_info) + end + + def hollow_card(card) + @app.command(card[:image], :configure, image: 'boxart_placeholder', bg: @empty_bg) + @app.command(card[:title], :configure, text: '', fg: @empty_bg, bg: @empty_bg) + @app.command(card[:platform], :configure, text: '', bg: @empty_bg) + @app.command(card[:frame], :configure, relief: :ridge, bg: @empty_bg) + + [:frame, :image, :title, :platform].each do |k| + @app.command(:bind, card[k], '', '') + @app.command(:bind, card[k], '', '') + end + end + + def bind_context_menu(card, rom_info) + handler = proc { post_card_menu(card, rom_info) } + @app.command(:bind, card[:frame], '', handler) + @app.command(:bind, card[:image], '', handler) + @app.command(:bind, card[:title], '', handler) + @app.command(:bind, card[:platform], '', handler) + end + + def post_card_menu(card, rom_info) + menu = "#{card[:frame]}.ctx" + exists = @app.tcl_eval("winfo exists #{menu}") == '1' + @app.command(:menu, menu, tearoff: 0) unless exists + @app.command(menu, :delete, 0, :end) + @app.command(menu, :add, :command, + label: translate('game_picker.menu.play'), + command: proc { emit(:rom_selected, rom_info.path) }) + qs_slot = quick_save_slot + qs_state = quick_save_exists?(rom_info, qs_slot) + @app.command(menu, :add, :command, + label: translate('game_picker.menu.quick_load'), + state: qs_state ? :normal : :disabled, + command: proc { emit(:rom_quick_load, path: rom_info.path, slot: qs_slot) }) + @app.command(menu, :add, :command, + label: translate('game_picker.menu.set_boxart'), + command: proc { pick_custom_boxart(card, rom_info) }) + @app.command(menu, :add, :separator) + @app.command(menu, :add, :command, + label: translate('game_picker.menu.remove'), + command: proc { remove_rom(rom_info) }) + @app.tcl_eval("tk_popup #{menu} [winfo pointerx .] [winfo pointery .]") + end + + def quick_save_slot + Gemba.user_config.quick_save_slot + end + + def quick_save_exists?(rom_info, slot) + return false unless rom_info.rom_id + state_file = File.join(Gemba.user_config.states_dir, rom_info.rom_id, "state#{slot}.ss") + File.exist?(state_file) + end + + def remove_rom(rom_info) + @rom_library.remove(rom_info.rom_id) + @rom_library.save! + refresh + end + + def pick_custom_boxart(card, rom_info) + return unless @overrides + filetypes = '{{PNG Images} {.png}}' + path = @app.tcl_eval("tk_getOpenFile -filetypes {#{filetypes}}") + return if path.to_s.strip.empty? + dest = @overrides.set_custom_boxart(rom_info.rom_id, path) + key = rom_info.rom_id || rom_info.game_code + set_card_image(card, key, dest) + end + + def set_card_image(card, key, path) + # Load full-size photo, scale to fit within IMG_SIZE, delete the original. + # Subsample factor is computed from actual dimensions so arbitrary-sized + # user images (e.g. custom boxart) don't break the card layout. + full_name = "boxart_full_#{key}" + small_name = "boxart_#{key}" + + @app.command(:image, :create, :photo, full_name, file: path) + w = @app.tcl_eval("image width #{full_name}").to_i + h = @app.tcl_eval("image height #{full_name}").to_i + factor = [[(w.to_f / IMG_SIZE).ceil, (h.to_f / IMG_SIZE).ceil].max, 1].max + + @app.command(:image, :create, :photo, small_name) + @app.command(small_name, :copy, full_name, subsample: factor) + @app.command(:image, :delete, full_name) + + old = @photos[key] + @photos[key] = small_name + @app.command(card[:image], :configure, image: small_name) + @app.command(:image, :delete, old) if old && old != small_name + rescue => e + Gemba.log(:warn) { "BoxArt image load failed for #{key}: #{e.message}" } + end + end +end diff --git a/lib/gemba/gamepad_map.rb b/lib/gemba/gamepad_map.rb new file mode 100644 index 0000000..6628f9b --- /dev/null +++ b/lib/gemba/gamepad_map.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Gemba + # Manages SDL gamepad button → GBA bitmask mappings. + # + # Shares the same interface as {KeyboardMap} so that Player can + # delegate to either without knowing which device type is active. + class GamepadMap + DEFAULT_MAP = { + a: KEY_A, + b: KEY_B, + back: KEY_SELECT, + start: KEY_START, + dpad_up: KEY_UP, + dpad_down: KEY_DOWN, + dpad_left: KEY_LEFT, + dpad_right: KEY_RIGHT, + left_shoulder: KEY_L, + right_shoulder: KEY_R, + }.freeze + + DEFAULT_DEAD_ZONE = 8000 + + def initialize(config) + @config = config + @map = DEFAULT_MAP.dup + @device = nil + @dead_zone = DEFAULT_DEAD_ZONE + end + + attr_accessor :device + attr_reader :dead_zone + + def mask + return 0 unless @device && !@device.closed? + m = 0 + @map.each { |btn, bit| m |= bit if @device.button?(btn) } + m + end + + def set(gba_btn, gp_btn) + bit = GBA_BTN_BITS[gba_btn] or return + @map.delete_if { |_, v| v == bit } + @map[gp_btn] = bit + end + + def reset! + @map = DEFAULT_MAP.dup + @dead_zone = DEFAULT_DEAD_ZONE + end + + def load_config + return unless @device + guid = @device.guid rescue return + gp_cfg = @config.gamepad(guid, name: @device.name) + + @map = {} + gp_cfg['mappings'].each do |gba_str, gp_str| + bit = GBA_BTN_BITS[gba_str.to_sym] + next unless bit + @map[gp_str.to_sym] = bit + end + + pct = gp_cfg['dead_zone'] + @dead_zone = (pct / 100.0 * 32767).round + end + + def reload! + @config.reload! + load_config + end + + def labels + result = {} + @map.each do |input, bit| + gba_btn = GBA_BTN_BITS.key(bit) + result[gba_btn] = input.to_s if gba_btn + end + result + end + + def save_to_config + return unless @device + guid = @device.guid rescue return + @config.gamepad(guid, name: @device.name) + @config.set_dead_zone(guid, dead_zone_pct) + @map.each do |gp_btn, bit| + gba_btn = GBA_BTN_BITS.key(bit) + @config.set_mapping(guid, gba_btn, gp_btn) if gba_btn + end + end + + def supports_deadzone? = true + + def dead_zone_pct + (@dead_zone.to_f / 32767 * 100).round + end + + def set_dead_zone(threshold) + @dead_zone = threshold.to_i + end + end +end diff --git a/lib/gemba/headless.rb b/lib/gemba/headless.rb index 121df74..495cb7c 100644 --- a/lib/gemba/headless.rb +++ b/lib/gemba/headless.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -# Lightweight entry point for headless (no GUI) usage of gemba. -# Loads only the C extension and pure-Ruby modules — no Tk, no SDL2. +# Lightweight entry point — Tk and SDL2 are NOT loaded. # # require "gemba/headless" # Gemba::HeadlessPlayer.open("game.gba") { |p| p.step(60) } require_relative "runtime" -require_relative "recorder" -require_relative "recorder_decoder" -require_relative "input_replayer" -require_relative "headless_player" + +module Gemba + # Marker — signals the headless stack is loaded without Tk/SDL2. + module Headless; end +end diff --git a/lib/gemba/headless_player.rb b/lib/gemba/headless_player.rb index 35fb461..afc16c3 100644 --- a/lib/gemba/headless_player.rb +++ b/lib/gemba/headless_player.rb @@ -15,13 +15,13 @@ module Gemba class HeadlessPlayer # @param rom_path [String] path to ROM file (.gba, .gb, .gbc, .zip) # @param config [Config, nil] config object (uses default if nil) - def initialize(rom_path, config: nil) + def initialize(rom_path, config: nil, bios_path: nil) @config = config || Gemba.user_config - rom_path = RomLoader.resolve(rom_path) + rom_path = RomResolver.resolve(rom_path) saves = @config.saves_dir FileUtils.mkdir_p(saves) unless File.directory?(saves) - @core = Core.new(rom_path, saves) + @core = Core.new(rom_path, saves, bios_path) @keys = 0 end @@ -169,7 +169,9 @@ def rewind_deinit def start_recording(path, compression: Zlib::BEST_SPEED) check_open! raise "Already recording" if recording? + platform = Platform.for(@core) @recorder = Recorder.new(path, width: @core.width, height: @core.height, + fps_fraction: platform.fps_fraction, compression: compression) @recorder.start end diff --git a/lib/gemba/help_window.rb b/lib/gemba/help_window.rb new file mode 100644 index 0000000..176d81d --- /dev/null +++ b/lib/gemba/help_window.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Gemba + # Floating hotkey reference panel toggled by pressing '?'. + # + # Non-modal — no grab, no focus steal. Positioned to the right of the + # main window via ChildWindow#position_near_parent. AppController pauses + # emulation while the panel is visible and restores play on close. + class HelpWindow + include ChildWindow + include Locale::Translatable + + TOP = '.help_window' + + def initialize(app:, hotkeys:) + @app = app + @hotkeys = hotkeys + build_toplevel(translate('settings.hotkeys'), geometry: '220x400') { build_ui } + end + + def show = show_window(modal: false) + def hide = hide_window(modal: false) + def visible? = @app.tcl_eval("wm state #{TOP}") == 'normal' + + private + + def build_ui + f = "#{TOP}.f" + @app.command('ttk::frame', f, padding: 8) + @app.command(:pack, f, fill: :both, expand: 1) + + @app.command('ttk::label', "#{f}.title", + text: translate('settings.hotkeys'), + font: '{TkDefaultFont} 11 bold') + @app.command(:pack, "#{f}.title", pady: [0, 4]) + + @app.command('ttk::separator', "#{f}.sep", orient: :horizontal) + @app.command(:pack, "#{f}.sep", fill: :x, pady: [0, 6]) + + Settings::HotkeysTab::LOCALE_KEYS.each do |action, locale_key| + row = "#{f}.row_#{action}" + @app.command('ttk::frame', row) + @app.command(:pack, row, fill: :x, pady: 1) + + act_lbl = "#{row}.act" + key_lbl = "#{row}.key" + + key_text = HotkeyMap.display_name(@hotkeys.key_for(action)) + + @app.command('ttk::label', act_lbl, text: translate(locale_key), anchor: :w) + @app.command('ttk::label', key_lbl, text: key_text, anchor: :e, + font: '{TkFixedFont} 9') + + @app.command(:grid, act_lbl, row: 0, column: 0, sticky: :w) + @app.command(:grid, key_lbl, row: 0, column: 1, sticky: :e) + @app.command(:grid, :columnconfigure, row, 0, weight: 1) + @app.command(:grid, :columnconfigure, row, 1, weight: 0) + end + end + end +end diff --git a/lib/gemba/input_mappings.rb b/lib/gemba/input_mappings.rb deleted file mode 100644 index c1196c4..0000000 --- a/lib/gemba/input_mappings.rb +++ /dev/null @@ -1,214 +0,0 @@ -# frozen_string_literal: true - -require 'set' - -module Gemba - # Virtual keyboard device that tracks key press/release state. - # Presents the same interface as an SDL gamepad: +button?+ and +closed?+. - class VirtualKeyboard - def initialize - @held = Set.new - end - - def press(keysym) = @held.add(keysym) - def release(keysym) = @held.delete(keysym) - def button?(keysym) = @held.include?(keysym) - def closed? = false - end - - # GBA button label → bitmask (shared by KeyboardMap and GamepadMap) - GBA_BTN_BITS = { - a: KEY_A, b: KEY_B, - l: KEY_L, r: KEY_R, - up: KEY_UP, down: KEY_DOWN, - left: KEY_LEFT, right: KEY_RIGHT, - start: KEY_START, select: KEY_SELECT, - }.freeze - - # Manages keyboard keysym → GBA bitmask mappings. - # - # Shares the same interface as {GamepadMap} so that Player can - # delegate to either without knowing which device type is active. - class KeyboardMap - DEFAULT_MAP = { - 'z' => KEY_A, - 'x' => KEY_B, - 'BackSpace' => KEY_SELECT, - 'Return' => KEY_START, - 'Right' => KEY_RIGHT, - 'Left' => KEY_LEFT, - 'Up' => KEY_UP, - 'Down' => KEY_DOWN, - 'a' => KEY_L, - 's' => KEY_R, - }.freeze - - def initialize(config) - @config = config - @map = DEFAULT_MAP.dup - @device = nil - load_config - end - - attr_writer :device - - def mask - return 0 unless @device - m = 0 - @map.each { |key, bit| m |= bit if @device.button?(key) } - m - end - - def set(gba_btn, input_key) - bit = GBA_BTN_BITS[gba_btn] or return - @map.delete_if { |_, v| v == bit } - @map[input_key.to_s] = bit - end - - def reset! - @map = DEFAULT_MAP.dup - end - - def load_config - cfg = @config.mappings(Config::KEYBOARD_GUID) - if cfg.empty? - @map = DEFAULT_MAP.dup - else - @map = {} - cfg.each do |gba_str, keysym| - bit = GBA_BTN_BITS[gba_str.to_sym] - next unless bit - @map[keysym] = bit - end - end - end - - def reload! - @config.reload! - load_config - end - - def labels - result = {} - @map.each do |input, bit| - gba_btn = GBA_BTN_BITS.key(bit) - result[gba_btn] = input if gba_btn - end - result - end - - def save_to_config - @map.each do |input, bit| - gba_btn = GBA_BTN_BITS.key(bit) - @config.set_mapping(Config::KEYBOARD_GUID, gba_btn, input) if gba_btn - end - end - - def supports_deadzone? = false - def dead_zone_pct = 0 - - def set_dead_zone(_) - raise NotImplementedError, "keyboard does not support dead zones" - end - end - - # Manages SDL gamepad button → GBA bitmask mappings. - # - # Shares the same interface as {KeyboardMap} so that Player can - # delegate to either without knowing which device type is active. - class GamepadMap - DEFAULT_MAP = { - a: KEY_A, - b: KEY_B, - back: KEY_SELECT, - start: KEY_START, - dpad_up: KEY_UP, - dpad_down: KEY_DOWN, - dpad_left: KEY_LEFT, - dpad_right: KEY_RIGHT, - left_shoulder: KEY_L, - right_shoulder: KEY_R, - }.freeze - - DEFAULT_DEAD_ZONE = 8000 - - def initialize(config) - @config = config - @map = DEFAULT_MAP.dup - @device = nil - @dead_zone = DEFAULT_DEAD_ZONE - end - - attr_accessor :device - attr_reader :dead_zone - - def mask - return 0 unless @device && !@device.closed? - m = 0 - @map.each { |btn, bit| m |= bit if @device.button?(btn) } - m - end - - def set(gba_btn, gp_btn) - bit = GBA_BTN_BITS[gba_btn] or return - @map.delete_if { |_, v| v == bit } - @map[gp_btn] = bit - end - - def reset! - @map = DEFAULT_MAP.dup - @dead_zone = DEFAULT_DEAD_ZONE - end - - def load_config - return unless @device - guid = @device.guid rescue return - gp_cfg = @config.gamepad(guid, name: @device.name) - - @map = {} - gp_cfg['mappings'].each do |gba_str, gp_str| - bit = GBA_BTN_BITS[gba_str.to_sym] - next unless bit - @map[gp_str.to_sym] = bit - end - - pct = gp_cfg['dead_zone'] - @dead_zone = (pct / 100.0 * 32767).round - end - - def reload! - @config.reload! - load_config - end - - def labels - result = {} - @map.each do |input, bit| - gba_btn = GBA_BTN_BITS.key(bit) - result[gba_btn] = input.to_s if gba_btn - end - result - end - - def save_to_config - return unless @device - guid = @device.guid rescue return - @config.gamepad(guid, name: @device.name) - @config.set_dead_zone(guid, dead_zone_pct) - @map.each do |gp_btn, bit| - gba_btn = GBA_BTN_BITS.key(bit) - @config.set_mapping(guid, gba_btn, gp_btn) if gba_btn - end - end - - def supports_deadzone? = true - - def dead_zone_pct - (@dead_zone.to_f / 32767 * 100).round - end - - def set_dead_zone(threshold) - @dead_zone = threshold.to_i - end - end -end diff --git a/lib/gemba/keyboard_map.rb b/lib/gemba/keyboard_map.rb new file mode 100644 index 0000000..28ca91a --- /dev/null +++ b/lib/gemba/keyboard_map.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Gemba + # Manages keyboard keysym → GBA bitmask mappings. + # + # Shares the same interface as {GamepadMap} so that Player can + # delegate to either without knowing which device type is active. + class KeyboardMap + DEFAULT_MAP = { + 'z' => KEY_A, + 'x' => KEY_B, + 'BackSpace' => KEY_SELECT, + 'Return' => KEY_START, + 'Right' => KEY_RIGHT, + 'Left' => KEY_LEFT, + 'Up' => KEY_UP, + 'Down' => KEY_DOWN, + 'a' => KEY_L, + 's' => KEY_R, + }.freeze + + def initialize(config) + @config = config + @map = DEFAULT_MAP.dup + @device = nil + load_config + end + + attr_writer :device + + def mask + return 0 unless @device + m = 0 + @map.each { |key, bit| m |= bit if @device.button?(key) } + m + end + + def set(gba_btn, input_key) + bit = GBA_BTN_BITS[gba_btn] or return + @map.delete_if { |_, v| v == bit } + @map[input_key.to_s] = bit + end + + def reset! + @map = DEFAULT_MAP.dup + end + + def load_config + cfg = @config.mappings(Config::KEYBOARD_GUID) + if cfg.empty? + @map = DEFAULT_MAP.dup + else + @map = {} + cfg.each do |gba_str, keysym| + bit = GBA_BTN_BITS[gba_str.to_sym] + next unless bit + @map[keysym] = bit + end + end + end + + def reload! + @config.reload! + load_config + end + + def labels + result = {} + @map.each do |input, bit| + gba_btn = GBA_BTN_BITS.key(bit) + result[gba_btn] = input if gba_btn + end + result + end + + def save_to_config + @map.each do |input, bit| + gba_btn = GBA_BTN_BITS.key(bit) + @config.set_mapping(Config::KEYBOARD_GUID, gba_btn, input) if gba_btn + end + end + + def supports_deadzone? = false + def dead_zone_pct = 0 + + def set_dead_zone(_) + raise NotImplementedError, "keyboard does not support dead zones" + end + end +end diff --git a/lib/gemba/locales/en.yml b/lib/gemba/locales/en.yml index cdce388..31bb4f8 100644 --- a/lib/gemba/locales/en.yml +++ b/lib/gemba/locales/en.yml @@ -5,9 +5,11 @@ menu: quit: "Quit" settings: "Settings" view: "View" + game_library: "Game Library" fullscreen: "Fullscreen" rom_info: "ROM Info…" open_logs_dir: "Open Logs Directory" + patch_rom: "Patch ROM…" emulation: "Emulation" pause: "Pause" resume: "Resume" @@ -43,6 +45,8 @@ toast: dialog: game_running_title: "Game Running" game_running_msg: "Another game is running. Switch to {name}?" + return_to_library_title: "Return to Game Library" + return_to_library_msg: "Return to the Game Library? Unsaved progress will be lost." drop_error_title: "Drop Error" drop_single_file_only: "Please drop a single ROM file." drop_unsupported_type: "Unsupported file type: {ext}" @@ -118,6 +122,15 @@ settings: tip_per_game: "Save separate video, audio, and save state settings for each ROM." tip_turbo_speed: "Fast-forward speed when holding the turbo hotkey." tip_toast_duration: "How long on-screen notifications stay visible." + system: "System" + bios_header: "GBA BIOS" + bios_path: "BIOS file:" + bios_browse: "Browse…" + bios_clear: "Clear" + bios_not_set: "Not set — using built-in HLE (recommended for most games)" + bios_not_found: "File not found" + skip_bios: "Skip boot animation" + tip_skip_bios: "Jump straight to the game, skipping the Game Boy Advance logo screen.\nOnly applies when a real BIOS file is loaded." recording: "Recording" recording_compression: "Compression:" tip_recording_compression: "Zlib level for .grec files.\n1 = fastest (default), 6+ has diminishing returns." @@ -142,6 +155,13 @@ picker: slot: "Slot {n}" close: "Close" +game_picker: + menu: + play: "Play" + quick_load: "Quick Load" + set_boxart: "Set Boxart" + remove: "Remove from Library" + rom_info: title: "ROM Info" field_title: "Title:" @@ -162,6 +182,23 @@ replay: ended: "Replay complete ({frames} frames)" empty_hint: "Open Input Recording (Cmd+O)" +patcher: + title: "Patch ROM" + rom_label: "ROM file:" + patch_label: "Patch file:" + outdir_label: "Output dir:" + browse: "Browse…" + apply: "Apply Patch" + working: "Applying patch…" + done: "Done →" + err_missing_fields: "Please fill in all fields." + err_rom_not_found: "ROM file not found." + err_patch_not_found: "Patch file not found." + err_failed: "Patch failed:" + overwrite_title: "File Exists" + overwrite_msg: "{path} already exists. Overwrite it?" + thread_mode_warn: "Note: Progress may appear stuck on Ruby < 4" + player: open_rom_hint: "File > Open ROM…" fps: "{fps} fps" diff --git a/lib/gemba/locales/ja.yml b/lib/gemba/locales/ja.yml index 6b0a7e5..1a6dbac 100644 --- a/lib/gemba/locales/ja.yml +++ b/lib/gemba/locales/ja.yml @@ -5,9 +5,11 @@ menu: quit: "終了" settings: "設定" view: "表示" + game_library: "ゲームライブラリ" fullscreen: "フルスクリーン" rom_info: "ROM情報…" open_logs_dir: "ログフォルダを開く" + patch_rom: "ROMをパッチ…" emulation: "エミュレーション" pause: "一時停止" resume: "再開" @@ -43,6 +45,8 @@ toast: dialog: game_running_title: "ゲーム実行中" game_running_msg: "別のゲームが実行中です。{name}に切り替えますか?" + return_to_library_title: "ゲームライブラリに戻る" + return_to_library_msg: "ゲームライブラリに戻りますか?保存されていない進行状況は失われます。" drop_error_title: "ドロップエラー" drop_single_file_only: "ROMファイルを1つだけドロップしてください。" drop_unsupported_type: "対応していないファイル形式: {ext}" @@ -118,6 +122,15 @@ settings: tip_per_game: "ROM毎に映像・音声・ステートセーブの設定を個別に保存します。" tip_turbo_speed: "早送りホットキー使用時の速度。" tip_toast_duration: "画面上の通知の表示時間。" + system: "システム" + bios_header: "GBA BIOS" + bios_path: "BIOSファイル:" + bios_browse: "参照…" + bios_clear: "クリア" + bios_not_set: "未設定 — 内蔵HLEを使用(ほとんどのゲームに推奨)" + bios_not_found: "ファイルが見つかりません" + skip_bios: "起動アニメーションをスキップ" + tip_skip_bios: "ゲームボーイアドバンスのロゴ画面をスキップして\n直接ゲームを開始します。実BIOSが必要です。" recording: "録画" recording_compression: "圧縮レベル:" tip_recording_compression: ".grecファイルのzlib圧縮レベル。\n1 = 最速(デフォルト)、6以上は効果が小さくなります。" @@ -142,6 +155,13 @@ picker: slot: "スロット{n}" close: "閉じる" +game_picker: + menu: + play: "プレイ" + quick_load: "クイックロード" + set_boxart: "カスタム画像を設定" + remove: "ライブラリから削除" + rom_info: title: "ROM情報" field_title: "タイトル:" @@ -162,6 +182,23 @@ replay: ended: "リプレイ完了({frames}フレーム)" empty_hint: "入力記録を開く (Cmd+O)" +patcher: + title: "ROMをパッチ" + rom_label: "ROMファイル:" + patch_label: "パッチファイル:" + outdir_label: "出力フォルダ:" + browse: "参照…" + apply: "パッチを適用" + working: "パッチ適用中…" + done: "完了 →" + err_missing_fields: "すべての項目を入力してください。" + err_rom_not_found: "ROMファイルが見つかりません。" + err_patch_not_found: "パッチファイルが見つかりません。" + err_failed: "パッチ失敗:" + overwrite_title: "ファイルが存在します" + overwrite_msg: "{path} は既に存在します。上書きしますか?" + thread_mode_warn: "注意: Ruby < 4 ではプログレスが止まって見える場合があります" + player: open_rom_hint: "ファイル > ROMを開く…" fps: "{fps} fps" diff --git a/lib/gemba/main_window.rb b/lib/gemba/main_window.rb new file mode 100644 index 0000000..ff5a0a3 --- /dev/null +++ b/lib/gemba/main_window.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + + +module Gemba + # Pure Tk shell — creates the app window and hosts a FrameStack. + # + # MainWindow knows nothing about ROMs, emulation, menus, or config. + # It provides geometry/title/fullscreen primitives that the AppController + # drives. Its only structural contribution is the FrameStack, which + # manages show/hide transitions to prevent visual flash (FOUC). + class MainWindow + attr_reader :app, :frame_stack + + def initialize + @app = Teek::App.new + @app.show + @frame_stack = FrameStack.new + end + + def set_title(title) + @app.set_window_title(title) + end + + def set_geometry(w, h) + @app.set_window_geometry("#{w}x#{h}") + end + + def set_aspect(numer, denom) + @app.command(:wm, 'aspect', '.', numer, denom, numer, denom) + end + + def set_minsize(w, h) + @app.command(:wm, 'minsize', '.', w, h) + end + + def reset_minsize + @app.command(:wm, 'minsize', '.', 0, 0) + end + + def reset_aspect_ratio + @app.command(:wm, 'aspect', '.', '', '', '', '') + end + + def set_timer_speed(ms) + @app.interp.thread_timer_ms = ms + end + + def fullscreen=(val) + @app.command(:wm, 'attributes', '.', '-fullscreen', val ? 1 : 0) + end + + def mainloop + @app.mainloop + end + end +end diff --git a/lib/gemba/patcher_window.rb b/lib/gemba/patcher_window.rb new file mode 100644 index 0000000..4c3488b --- /dev/null +++ b/lib/gemba/patcher_window.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +module Gemba + # Floating window for applying IPS/BPS/UPS patches to ROM files. + # + # Non-modal — no grab. Shows three file pickers (ROM, patch, output dir) + # and runs the patch in a background thread so the UI stays responsive. + class PatcherWindow + include ChildWindow + include Locale::Translatable + + # Worker class for Ractor-based patching. + # Defined as a class so Ractor.shareable_proc never sees nested closures — + # the lambda and rescue are created at runtime inside the Ractor. + class PatchWorker + def call(t, d) + RomPatcher.patch( + rom_path: d[:rom], + patch_path: d[:patch], + out_path: d[:out], + on_progress: ->(pct) { t.yield(pct) } + ) + t.yield({ ok: true, path: d[:out] }) + rescue => e + t.yield({ ok: false, error: e.message }) + end + end + + TOP = '.patcher_window' + BG_MODE = (RUBY_VERSION >= '4.0' ? :ractor : :thread).freeze + + VAR_ROM = '::gemba_patcher_rom' + VAR_PATCH = '::gemba_patcher_patch' + VAR_OUTDIR = '::gemba_patcher_outdir' + VAR_STATUS = '::gemba_patcher_status' + + def initialize(app:) + @app = app + @callbacks = {} + build_toplevel(translate('patcher.title'), geometry: '540x220') { build_ui } + end + + def show = show_window(modal: false) + def hide = hide_window(modal: false) + def visible? = @app.tcl_eval("wm state #{TOP}") == 'normal' + + private + + def build_ui + f = "#{TOP}.f" + @app.command('ttk::frame', f, padding: 12) + @app.command(:pack, f, fill: :both, expand: 1) + + @app.set_variable(VAR_ROM, '') + @app.set_variable(VAR_PATCH, '') + @app.set_variable(VAR_OUTDIR, Config.default_patches_dir) + @app.set_variable(VAR_STATUS, '') + + build_file_row(f, 'rom', translate('patcher.rom_label'), + "{{GBA ROMs} {.gba .zip}} {{All Files} *}") + build_file_row(f, 'patch', translate('patcher.patch_label'), + "{{Patch Files} {.ips .bps .ups}} {{All Files} *}") + build_dir_row(f, 'outdir', translate('patcher.outdir_label'), VAR_OUTDIR) + + btn_row = "#{f}.btn_row" + @app.command('ttk::frame', btn_row) + @app.command(:pack, btn_row, fill: :x, pady: [10, 0]) + + @apply_btn = "#{btn_row}.apply" + @app.command('ttk::button', @apply_btn, + text: translate('patcher.apply'), + command: proc { apply_patch }) + @app.command(:pack, @apply_btn, side: :left) + + @progress_bar = "#{btn_row}.pb" + @app.command('ttk::progressbar', @progress_bar, + orient: :horizontal, length: 200, + mode: :determinate, maximum: 100) + @app.command(:pack, @progress_bar, side: :left, padx: [8, 0]) + + if BG_MODE == :thread + @app.command('ttk::label', "#{btn_row}.ruby_warn", + text: translate('patcher.thread_mode_warn'), + foreground: 'gray') + @app.command(:pack, "#{btn_row}.ruby_warn", side: :left, padx: [10, 0]) + end + + @app.command('ttk::label', "#{f}.status", + textvariable: VAR_STATUS, + wraplength: 500) + @app.command(:pack, "#{f}.status", fill: :x, pady: [6, 0]) + end + + def build_file_row(parent, name, label_text, filetypes_tcl) + row = "#{parent}.#{name}_row" + var = case name + when 'rom' then VAR_ROM + when 'patch' then VAR_PATCH + end + + @app.command('ttk::frame', row) + @app.command(:pack, row, fill: :x, pady: 2) + + @app.command('ttk::label', "#{row}.lbl", text: label_text, width: 10, anchor: :w) + @app.command(:grid, "#{row}.lbl", row: 0, column: 0, sticky: :w) + + @app.command('ttk::entry', "#{row}.ent", textvariable: var, width: 48) + @app.command(:grid, "#{row}.ent", row: 0, column: 1, sticky: :ew, padx: [4, 4]) + + @app.command('ttk::button', "#{row}.btn", + text: translate('patcher.browse'), + command: proc { browse_file(var, filetypes_tcl) }) + @app.command(:grid, "#{row}.btn", row: 0, column: 2) + @app.command(:grid, :columnconfigure, row, 1, weight: 1) + end + + def build_dir_row(parent, name, label_text, var) + row = "#{parent}.#{name}_row" + @app.command('ttk::frame', row) + @app.command(:pack, row, fill: :x, pady: 2) + + @app.command('ttk::label', "#{row}.lbl", text: label_text, width: 10, anchor: :w) + @app.command(:grid, "#{row}.lbl", row: 0, column: 0, sticky: :w) + + @app.command('ttk::entry', "#{row}.ent", textvariable: var, width: 48) + @app.command(:grid, "#{row}.ent", row: 0, column: 1, sticky: :ew, padx: [4, 4]) + + @app.command('ttk::button', "#{row}.btn", + text: translate('patcher.browse'), + command: proc { browse_dir(var) }) + @app.command(:grid, "#{row}.btn", row: 0, column: 2) + @app.command(:grid, :columnconfigure, row, 1, weight: 1) + end + + def browse_file(var, filetypes_tcl) + path = @app.tcl_eval("tk_getOpenFile -filetypes {#{filetypes_tcl}}") + @app.set_variable(var, path) unless path.to_s.strip.empty? + end + + def browse_dir(var) + dir = @app.tcl_eval("tk_chooseDirectory") + @app.set_variable(var, dir) unless dir.to_s.strip.empty? + end + + def apply_patch + rom = @app.get_variable(VAR_ROM).strip + patch = @app.get_variable(VAR_PATCH).strip + outdir = @app.get_variable(VAR_OUTDIR).strip + + if rom.empty? || patch.empty? || outdir.empty? + set_status(translate('patcher.err_missing_fields')) + return + end + + unless File.exist?(rom) + set_status(translate('patcher.err_rom_not_found')) + return + end + + unless File.exist?(patch) + set_status(translate('patcher.err_patch_not_found')) + return + end + + resolved_rom = begin + RomResolver.resolve(rom) + rescue => e + set_status("#{translate('patcher.err_failed')} #{e.message}") + return + end + + rom_ext = File.extname(resolved_rom) + basename = File.basename(rom, '.*') + '-patched' + rom_ext + desired_out = File.join(outdir, basename) + + out_path = if File.exist?(desired_out) + msg = translate('patcher.overwrite_msg').gsub('{path}', File.basename(desired_out)) + answer = @app.command('tk_messageBox', + parent: TOP, + title: translate('patcher.overwrite_title'), + message: msg, + type: :yesnocancel, + icon: :question) + case answer + when 'yes' then desired_out + when 'no' then RomPatcher.safe_out_path(desired_out) + else return # cancel — abort silently + end + else + desired_out + end + + set_status(translate('patcher.working')) + @app.command(@apply_btn, :configure, state: :disabled) + @app.command(@progress_bar, :configure, value: 0) + + data = Ractor.make_shareable({ rom: resolved_rom.freeze, patch: patch.freeze, out: out_path.freeze }) + Teek::BackgroundWork.drop_intermediate = false + Teek::BackgroundWork.new(@app, data, mode: BG_MODE, worker: PatchWorker).on_progress do |result| + case result + when Float + @app.command(@progress_bar, :configure, value: (result * 100).round) + @app.update + when Hash + @app.command(@apply_btn, :configure, state: :normal) + @app.command(@progress_bar, :configure, value: result[:ok] ? 100 : 0) + @app.update + if result[:ok] + set_status("#{translate('patcher.done')} #{File.basename(result[:path])}") + else + set_status("#{translate('patcher.err_failed')} #{result[:error]}") + end + end + end.on_done do + @app.command(@apply_btn, :configure, state: :normal) + end + end + + def set_status(msg) + @app.set_variable(VAR_STATUS, msg) + end + end +end diff --git a/lib/gemba/platform.rb b/lib/gemba/platform.rb new file mode 100644 index 0000000..19063b6 --- /dev/null +++ b/lib/gemba/platform.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + + +module Gemba + module Platform + # Build a Platform from a loaded Core. + # @param core [Gemba::Core] initialized core with ROM loaded + # @return [GBA, GB, GBC] + def self.for(core) + case core.platform + when "GBA" then GBA.new + when "GBC" then GBC.new + else GB.new + end + end + + # Default platform before any ROM is loaded (most common case). + def self.default = GBA.new + end +end diff --git a/lib/gemba/platform/gb.rb b/lib/gemba/platform/gb.rb new file mode 100644 index 0000000..673aa87 --- /dev/null +++ b/lib/gemba/platform/gb.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Gemba + module Platform + class GB + def width = 160 + def height = 144 + def fps = 59.7275 + def fps_fraction = [4194304, 70224] + def aspect = [10, 9] + def name = "Game Boy" + def short_name = "GB" + def buttons = %i[a b start select up down left right] + def thumb_size = [80, 72] + + def ==(other) = other.is_a?(Platform::GB) + def eql?(other) = self == other + def hash = self.class.hash + end + end +end diff --git a/lib/gemba/platform/gba.rb b/lib/gemba/platform/gba.rb new file mode 100644 index 0000000..128c7d7 --- /dev/null +++ b/lib/gemba/platform/gba.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Gemba + module Platform + class GBA + def width = 240 + def height = 160 + def fps = 59.7272 + def fps_fraction = [262144, 4389] + def aspect = [3, 2] + def name = "Game Boy Advance" + def short_name = "GBA" + def buttons = %i[a b l r start select up down left right] + def thumb_size = [120, 80] + + def ==(other) = other.is_a?(Platform::GBA) + def eql?(other) = self == other + def hash = self.class.hash + end + end +end diff --git a/lib/gemba/platform/gbc.rb b/lib/gemba/platform/gbc.rb new file mode 100644 index 0000000..d511910 --- /dev/null +++ b/lib/gemba/platform/gbc.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gemba + module Platform + # Same hardware specs as GB (resolution, FPS, buttons). + # Separate class for distinct name and future color-specific behavior. + class GBC + def width = 160 + def height = 144 + def fps = 59.7275 + def fps_fraction = [4194304, 70224] + def aspect = [10, 9] + def name = "Game Boy Color" + def short_name = "GBC" + def buttons = %i[a b start select up down left right] + def thumb_size = [80, 72] + + def ==(other) = other.is_a?(Platform::GBC) + def eql?(other) = self == other + def hash = self.class.hash + end + end +end diff --git a/lib/gemba/platform_open.rb b/lib/gemba/platform_open.rb index b73e70c..c13ca72 100644 --- a/lib/gemba/platform_open.rb +++ b/lib/gemba/platform_open.rb @@ -11,7 +11,7 @@ def self.open_directory(dir) if p.darwin? system('open', dir) elsif p.windows? - system('explorer.exe', dir) + system('explorer.exe', dir.tr('/', '\\')) else system('xdg-open', dir) end diff --git a/lib/gemba/player.rb b/lib/gemba/player.rb deleted file mode 100644 index 08d351c..0000000 --- a/lib/gemba/player.rb +++ /dev/null @@ -1,1651 +0,0 @@ -# frozen_string_literal: true - -require 'fileutils' -require_relative 'event_bus' -require_relative 'locale' -require_relative 'modal_stack' - -module Gemba - # Full-featured GBA player with SDL2 video/audio rendering, - # keyboard and gamepad input, save states, and recording. - # - # @example Launch with a ROM - # Gemba::Player.new("pokemon.gba").run - # - # @example Launch without a ROM (use File > Open ROM...) - # Gemba::Player.new.run - # - # @see file:INTERNALS.md for frame pacing, audio sync, and rendering details - class Player - include Gemba - include Locale::Translatable - - GBA_W = 240 - GBA_H = 160 - DEFAULT_SCALE = 3 - - # GBA audio: mGBA outputs at 44100 Hz (stereo int16) - AUDIO_FREQ = 44100 - GBA_FPS = 59.7272 - FRAME_PERIOD = 1.0 / GBA_FPS - - # Dynamic rate control constants (see tick_normal for the math) - AUDIO_BUF_CAPACITY = (AUDIO_FREQ / GBA_FPS * 6).to_i # ~6 frames (~100ms) - MAX_DELTA = 0.005 # ±0.5% max adjustment - FF_MAX_FRAMES = 10 # cap for uncapped turbo to avoid locking event loop - SAVE_STATE_DEBOUNCE_DEFAULT = 3.0 # seconds; overridden by config - SAVE_STATE_SLOTS = 10 - FADE_IN_FRAMES = (AUDIO_FREQ * 0.02).to_i # ~20ms = 882 samples - GAMEPAD_PROBE_MS = 2000 - GAMEPAD_LISTEN_MS = 50 - EVENT_LOOP_FAST_MS = 1 # 1ms — needed for smooth emulation frame pacing - EVENT_LOOP_IDLE_MS = 50 # 50ms — sufficient for UI interaction when idle/paused - - # Modal child window types → locale keys for the window title overlay - MODAL_LABELS = { - settings: 'menu.settings', - picker: 'menu.save_states', - rom_info: 'menu.rom_info', - replay_player: 'replay.replay_player', - }.freeze - - def initialize(rom_path = nil, sound: true, fullscreen: false, frames: nil) - @app = Teek::App.new - @app.interp.thread_timer_ms = EVENT_LOOP_IDLE_MS - @app.show - - Gemba.bus = EventBus.new - setup_bus_subscriptions - - @sound = sound - @config = Gemba.user_config - @scale = @config.scale - @volume = @config.volume / 100.0 - @muted = @config.muted? - @kb_map = KeyboardMap.new(@config) - @gp_map = GamepadMap.new(@config) - @keyboard = VirtualKeyboard.new - @kb_map.device = @keyboard - @hotkeys = HotkeyMap.new(@config) - @turbo_speed = @config.turbo_speed - @turbo_volume = @config.turbo_volume_pct / 100.0 - @keep_aspect_ratio = @config.keep_aspect_ratio? - @show_fps = @config.show_fps? - @pixel_filter = @config.pixel_filter - @integer_scale = @config.integer_scale? - @color_correction = @config.color_correction? - @frame_blending = @config.frame_blending? - @rewind_enabled = @config.rewind_enabled? - @rewind_seconds = @config.rewind_seconds - @rewind_frame_counter = 0 - @audio_fade_in = 0 - @frame_limit = frames - @total_frames = 0 - @fast_forward = false - @fullscreen = fullscreen - @quick_save_slot = @config.quick_save_slot - @save_state_backup = @config.save_state_backup? - @save_mgr = nil # created when ROM loaded - @recorder = nil - @recording_compression = @config.recording_compression - @pause_on_focus_loss = @config.pause_on_focus_loss? - check_writable_dirs - - win_w = GBA_W * @scale - win_h = GBA_H * @scale - @app.set_window_title("mGBA Player") - @app.set_window_geometry("#{win_w}x#{win_h}") - - build_menu - - @modal_stack = ModalStack.new( - on_enter: method(:modal_entered), - on_exit: method(:modal_exited), - on_focus_change: method(:modal_focus_changed), - ) - - dismiss = proc { @modal_stack.pop } - - @rom_info_window = RomInfoWindow.new(@app, callbacks: { - on_dismiss: dismiss, on_close: dismiss, - }) - @state_picker = SaveStatePicker.new(@app, callbacks: { - on_dismiss: dismiss, on_close: dismiss, - }) - - @settings_window = SettingsWindow.new(@app, tip_dismiss_ms: @config.tip_dismiss_ms, callbacks: { - on_validate_hotkey: method(:validate_hotkey), - on_validate_kb_mapping: method(:validate_kb_mapping), - on_dismiss: dismiss, on_close: dismiss, - }) - - # Push loaded config into the settings UI - @settings_window.refresh_gamepad(@kb_map.labels, @kb_map.dead_zone_pct) - @settings_window.refresh_hotkeys(@hotkeys.labels) - push_settings_to_ui - - # Input/emulation state (initialized before SDL2) - @gamepad = nil - @running = true - @paused = false - @core = nil - @rom_path = nil - @initial_rom = rom_path - # Modal child windows tracked by @modal_stack (created above) - @sdl2_ready = false - @animate_started = false - - # Status label (shown when no ROM loaded) - @status_label = '.status_overlay' - @app.command(:label, @status_label, - text: translate('player.open_rom_hint'), - fg: '#888888', bg: '#000000', - font: '{TkDefaultFont} 11') - @app.command(:place, @status_label, - relx: 0.5, rely: 0.85, anchor: :center) - - setup_drop_target - setup_global_hotkeys - end - - # @return [Teek::App] - attr_reader :app - - # @return [Gemba::Config] - attr_reader :config - - # @return [Gemba::Viewport, nil] nil until SDL2 init - attr_reader :viewport - - # @return [Gemba::Core, nil] nil until ROM loaded - attr_reader :core - - # @return [Gemba::Recorder, nil] nil when not recording - attr_reader :recorder - - # @return [Boolean] whether the main loop is running - attr_reader :running - - # @return [Boolean] true after SDL2 viewport/audio/renderer are initialized - def sdl2_ready? = @sdl2_ready - - # @return [Boolean] true when the player is ready for interaction. - # With a ROM: waits for SDL2 init and ROM load. Without: immediately ready. - def ready? = @initial_rom ? !!@core : true - - def running=(val) - @running = val - return if val - # Without the animate loop (no SDL2 yet), exit mainloop directly - unless @sdl2_ready - cleanup - @app.command(:destroy, '.') - end - end - - # @return [Integer] current video scale multiplier - attr_reader :scale - - # @return [Float] current audio volume (0.0-1.0) - attr_reader :volume - - # @return [Boolean] whether audio is muted - def muted? - @muted - end - - # @return [Boolean] whether currently recording - def recording? - @recorder&.recording? || false - end - - # @return [Gemba::SettingsWindow] - attr_reader :settings_window - - # @return [Gemba::SaveStateManager, nil] nil until ROM loaded - attr_reader :save_mgr - - # @return [Gemba::KeyboardMap] - attr_reader :kb_map - - # @return [Gemba::GamepadMap] - attr_reader :gp_map - - def run - if @initial_rom - @app.after(1) { load_rom(@initial_rom) } - end - @app.mainloop - ensure - cleanup - end - - private - - def setup_bus_subscriptions - bus = Gemba.bus - - # Video settings - bus.on(:scale_changed) { |val| apply_scale(val) } - bus.on(:turbo_speed_changed) { |val| apply_turbo_speed(val) } - bus.on(:aspect_ratio_changed) { |val| apply_aspect_ratio(val) } - bus.on(:show_fps_changed) { |val| apply_show_fps(val) } - bus.on(:pause_on_focus_loss_changed) { |val| apply_pause_on_focus_loss(val) } - bus.on(:toast_duration_changed) { |val| apply_toast_duration(val) } - bus.on(:filter_changed) { |val| apply_pixel_filter(val) } - bus.on(:integer_scale_changed) { |val| apply_integer_scale(val) } - bus.on(:color_correction_changed) { |val| apply_color_correction(val) } - bus.on(:frame_blending_changed) { |val| apply_frame_blending(val) } - bus.on(:rewind_toggled) { |val| apply_rewind_toggle(val) } - - # Audio settings - bus.on(:volume_changed) { |vol| apply_volume(vol) } - bus.on(:mute_changed) { |val| apply_mute(val) } - - # Gamepad/keyboard mappings - bus.on(:gamepad_map_changed) { |btn, gp| active_input.set(btn, gp) } - bus.on(:keyboard_map_changed) { |btn, key| active_input.set(btn, key) } - bus.on(:deadzone_changed) { |val| active_input.set_dead_zone(val) } - bus.on(:gamepad_reset) { active_input.reset! } - bus.on(:keyboard_reset) { active_input.reset! } - bus.on(:undo_gamepad) { undo_mappings } - - # Hotkeys - bus.on(:hotkey_changed) { |action, key| @hotkeys.set(action, key) } - bus.on(:hotkey_reset) { @hotkeys.reset! } - bus.on(:undo_hotkeys) { undo_hotkeys } - - # Recording settings - bus.on(:compression_changed) { |val| apply_recording_compression(val) } - bus.on(:open_recordings_dir) { open_recordings_dir } - bus.on(:open_replay_player) { show_replay_player } - - # Save state settings - bus.on(:quick_slot_changed) { |val| apply_quick_slot(val) } - bus.on(:backup_changed) { |val| apply_backup(val) } - bus.on(:open_config_dir) { open_config_dir } - - # Settings window events - bus.on(:settings_save) { save_config } - bus.on(:per_game_toggled) { |val| toggle_per_game(val) } - - # Save state picker events - bus.on(:state_save_requested) { |slot| save_state(slot) } - bus.on(:state_load_requested) { |slot| load_state(slot) } - end - - # Deferred SDL2 initialization — runs inside the event loop so the - # window is already painted and responsive. Without this, the heavy - # SDL2 C calls (renderer, audio device, gamepad IOKit) block the - # main thread before macOS has a chance to display the window, - # causing a brief spinning beach ball. - def init_sdl2 - return if @sdl2_ready - - @app.command('tk', 'busy', '.') - - win_w = GBA_W * @scale - win_h = GBA_H * @scale - - @viewport = Teek::SDL2::Viewport.new(@app, width: win_w, height: win_h, vsync: false) - @viewport.pack(fill: :both, expand: true) - - # Reposition status label onto viewport frame - @app.command(:place, @status_label, - in: @viewport.frame.path, - relx: 0.5, rely: 0.85, anchor: :center) - - # Streaming texture at native GBA resolution - @texture = @viewport.renderer.create_texture(GBA_W, GBA_H, :streaming) - @texture.scale_mode = @pixel_filter.to_sym - - # Font for on-screen indicators (FPS, fast-forward label) - font_path = File.join(ASSETS_DIR, 'JetBrainsMonoNL-Regular.ttf') - @overlay_font = File.exist?(font_path) ? @viewport.renderer.load_font(font_path, 14) : nil - - # CJK-capable font for toast notifications and translated UI text - toast_font_path = File.join(ASSETS_DIR, 'ark-pixel-12px-monospaced-ja.ttf') - toast_font = File.exist?(toast_font_path) ? @viewport.renderer.load_font(toast_font_path, 12) : @overlay_font - - @toast = ToastOverlay.new( - renderer: @viewport.renderer, - font: toast_font || @overlay_font, - duration: @config.toast_duration - ) - - # Custom blend mode: white text inverts the background behind it. - # dstRGB = (1 - dstRGB) * srcRGB + dstRGB * (1 - srcA) - # Where srcA=1 (opaque text): result = 1 - dst (inverted) - # Where srcA=0 (transparent): result = dst (unchanged) - inverse_blend = Teek::SDL2.compose_blend_mode( - :one_minus_dst_color, :one_minus_src_alpha, :add, - :zero, :one, :add - ) - - @hud = OverlayRenderer.new(font: @overlay_font, blend_mode: inverse_blend) - - # Audio stream — stereo int16 at GBA sample rate. - # Falls back to a silent no-op stream when sound is disabled or - # no audio device is available (e.g. CI servers, headless). - if @sound && Teek::SDL2::AudioStream.available? - @stream = Teek::SDL2::AudioStream.new( - frequency: AUDIO_FREQ, - format: :s16, - channels: 2 - ) - @stream.resume - else - if @sound - Gemba.log(:warn) { "No audio device found, continuing without sound" } - warn "mGBA Player: no audio device found, continuing without sound" - end - @stream = Teek::SDL2::NullAudioStream.new - end - - # Initialize gamepad subsystem for hot-plug detection - Teek::SDL2::Gamepad.init_subsystem - Teek::SDL2::Gamepad.on_added { |_| refresh_gamepads } - Teek::SDL2::Gamepad.on_removed { |_| @gamepad = nil; @gp_map.device = nil; refresh_gamepads } - refresh_gamepads - start_gamepad_probe - - setup_input - - # Apply fullscreen before unblocking (set via CLI --fullscreen) - @app.command(:wm, 'attributes', '.', '-fullscreen', 1) if @fullscreen - - @sdl2_ready = true - - # Unblock interaction now that SDL2 is ready - @app.command('tk', 'busy', 'forget', '.') - - # Auto-focus viewport for keyboard input - @app.tcl_eval("focus -force #{@viewport.frame.path}") - @app.update - rescue => e - # Surface init failures visibly — Tk's event loop can swallow - # exceptions from `after` callbacks, causing silent hangs. - Gemba.log(:error) { "init_sdl2 failed: #{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}" } - $stderr.puts "FATAL: init_sdl2 failed: #{e.class}: #{e.message}" - $stderr.puts e.backtrace.first(10).map { |l| " #{l}" }.join("\n") - @app.command('tk', 'busy', 'forget', '.') rescue nil - @running = false - end - - def show_rom_info - return unless @core && !@core.destroyed? - return bell if @modal_stack.active? - saves = @config.saves_dir - sav_name = File.basename(@rom_path, File.extname(@rom_path)) + '.sav' - sav_path = File.join(saves, sav_name) - @modal_stack.push(:rom_info, @rom_info_window, - show_args: { core: @core, rom_path: @rom_path, save_path: sav_path }) - end - - # -- Save states (delegated to SaveStateManager) ------------------------- - - def save_state(slot) - return unless @save_mgr - _ok, msg = @save_mgr.save_state(slot) - @toast&.show(msg) if msg - end - - def load_state(slot) - return unless @save_mgr - _ok, msg = @save_mgr.load_state(slot) - @toast&.show(msg) if msg - end - - def quick_save - return unless @save_mgr - _ok, msg = @save_mgr.quick_save - @toast&.show(msg) if msg - end - - def quick_load - return unless @save_mgr - _ok, msg = @save_mgr.quick_load - @toast&.show(msg) if msg - end - - def take_screenshot - return unless @core && !@core.destroyed? - - dir = Config.default_screenshots_dir - FileUtils.mkdir_p(dir) - - title = @core.title.strip.gsub(/[^a-zA-Z0-9_\-]/, '_') - stamp = Time.now.strftime('%Y%m%d_%H%M%S') - name = "#{title}_#{stamp}.png" - path = File.join(dir, name) - - pixels = @core.video_buffer_argb - photo_name = "__gemba_ss_#{object_id}" - out_w = GBA_W * @scale - out_h = GBA_H * @scale - @app.command(:image, :create, :photo, photo_name, - width: out_w, height: out_h) - @app.interp.photo_put_zoomed_block(photo_name, pixels, GBA_W, GBA_H, - zoom_x: @scale, zoom_y: @scale, format: :argb) - @app.command(photo_name, :write, path, format: :png) - @app.command(:image, :delete, photo_name) - @toast&.show(translate('toast.screenshot_saved', name: name)) - rescue StandardError => e - warn "gemba: screenshot failed: #{e.message} (#{e.class})" - @app.command(:image, :delete, photo_name) rescue nil - @toast&.show(translate('toast.screenshot_failed')) - end - - def show_settings(tab: nil) - return bell if @modal_stack.active? - @modal_stack.push(:settings, @settings_window, show_args: { tab: tab }) - end - - def show_replay_player - # Can push on top of settings — stack auto-withdraws it - @replay_player ||= ReplayPlayer.new( - app: @app, - sound: true, - callbacks: { - on_dismiss: proc { @modal_stack.pop }, - on_request_speed: method(:set_event_loop_speed), - } - ) - @modal_stack.push(:replay_player, @replay_player) - end - - def show_state_picker - return unless @save_mgr&.state_dir - return bell if @modal_stack.active? - @modal_stack.push(:picker, @state_picker, - show_args: { state_dir: @save_mgr.state_dir, quick_slot: @quick_save_slot }) - end - - # ── ModalStack callbacks ─────────────────────────────────────── - - def modal_entered(name) - @was_paused_before_modal = @paused - toggle_fast_forward if @fast_forward - toggle_pause if @core && !@paused - end - - def modal_exited - @toast&.destroy - toggle_pause if @core && !@was_paused_before_modal - end - - def modal_focus_changed(name) - @toast&.destroy - locale_key = MODAL_LABELS[name] || name.to_s - label = translate(locale_key) - @toast&.show(translate('toast.waiting_for', label: label), permanent: true) - end - - def bell - @app.command(:bell) - end - - def show_rom_error(message) - @app.command('tk_messageBox', - parent: '.', - title: translate('dialog.drop_error_title'), - message: message, - type: :ok, - icon: :error) - end - - def save_config - @config.scale = @scale - @config.volume = (@volume * 100).round - @config.muted = @muted - @config.turbo_speed = @turbo_speed - @config.keep_aspect_ratio = @keep_aspect_ratio - @config.show_fps = @show_fps - @config.pixel_filter = @pixel_filter - @config.integer_scale = @integer_scale - @config.color_correction = @color_correction - @config.frame_blending = @frame_blending - @config.rewind_enabled = @rewind_enabled - @config.rewind_seconds = @rewind_seconds - @config.quick_save_slot = @quick_save_slot - @config.save_state_backup = @save_state_backup - @config.recording_compression = @recording_compression - @config.pause_on_focus_loss = @pause_on_focus_loss - - @kb_map.save_to_config - @gp_map.save_to_config - @hotkeys.save_to_config - @config.save! - end - - def apply_scale(new_scale) - @scale = new_scale.clamp(1, 4) - w = GBA_W * @scale - h = GBA_H * @scale - @app.set_window_geometry("#{w}x#{h}") - end - - def apply_volume(vol) - @volume = vol.to_f.clamp(0.0, 1.0) - end - - def apply_mute(muted) - @muted = !!muted - end - - def apply_pixel_filter(filter) - @pixel_filter = filter - @texture.scale_mode = filter.to_sym if @texture - end - - def apply_integer_scale(enabled) - @integer_scale = !!enabled - end - - def apply_color_correction(enabled) - @color_correction = !!enabled - if @core && !@core.destroyed? - @core.color_correction = @color_correction - render_frame if @texture - end - end - - def apply_frame_blending(enabled) - @frame_blending = !!enabled - if @core && !@core.destroyed? - @core.frame_blending = @frame_blending - render_frame if @texture - end - end - - def apply_rewind_toggle(enabled) - @rewind_enabled = !!enabled - if @core && !@core.destroyed? - if @rewind_enabled - @core.rewind_init(@rewind_seconds) - @rewind_frame_counter = 0 - else - @core.rewind_deinit - end - end - end - - def do_rewind - return unless @core && !@core.destroyed? - unless @rewind_enabled - @toast&.show(translate('toast.no_rewind')) - render_if_paused - return - end - if @core.rewind_pop == true - @core.run_frame # refresh video buffer from restored state - @stream.clear - @audio_fade_in = FADE_IN_FRAMES - @rewind_frame_counter = 0 - @toast&.show(translate('toast.rewound')) - render_frame - else - @toast&.show(translate('toast.no_rewind')) - render_if_paused - end - end - - def toggle_per_game(enabled) - if enabled - @config.enable_per_game - else - @config.disable_per_game - end - refresh_from_config - end - - # Re-read per-game-eligible settings from config and apply them. - def refresh_from_config - @scale = @config.scale - @volume = @config.volume / 100.0 - @muted = @config.muted? - @turbo_speed = @config.turbo_speed - @pixel_filter = @config.pixel_filter - @integer_scale = @config.integer_scale? - @color_correction = @config.color_correction? - @frame_blending = @config.frame_blending? - @rewind_enabled = @config.rewind_enabled? - @rewind_seconds = @config.rewind_seconds - @quick_save_slot = @config.quick_save_slot - @save_state_backup = @config.save_state_backup? - @recording_compression = @config.recording_compression - - push_settings_to_ui - - # Apply runtime effects - apply_scale(@scale) if @viewport - @texture.scale_mode = @pixel_filter.to_sym if @texture - if @core && !@core.destroyed? - @core.color_correction = @color_correction - @core.frame_blending = @frame_blending - render_frame if @texture - end - @save_mgr.quick_save_slot = @quick_save_slot if @save_mgr - @save_mgr.backup = @save_state_backup if @save_mgr - end - - # Push current instance vars to settings window UI variables. - def push_settings_to_ui - @app.set_variable(SettingsWindow::VAR_SCALE, "#{@scale}x") - turbo_label = @turbo_speed == 0 ? 'Uncapped' : "#{@turbo_speed}x" - @app.set_variable(SettingsWindow::VAR_TURBO, turbo_label) - @app.set_variable(SettingsWindow::VAR_ASPECT_RATIO, @keep_aspect_ratio ? '1' : '0') - @app.set_variable(SettingsWindow::VAR_SHOW_FPS, @show_fps ? '1' : '0') - toast_label = "#{@config.toast_duration}s" - @app.set_variable(SettingsWindow::VAR_TOAST_DURATION, toast_label) - filter_label = @pixel_filter == 'nearest' ? @settings_window.send(:translate, 'settings.filter_nearest') : @settings_window.send(:translate, 'settings.filter_linear') - @app.set_variable(SettingsWindow::VAR_FILTER, filter_label) - @app.set_variable(SettingsWindow::VAR_INTEGER_SCALE, @integer_scale ? '1' : '0') - @app.set_variable(SettingsWindow::VAR_COLOR_CORRECTION, @color_correction ? '1' : '0') - @app.set_variable(SettingsWindow::VAR_FRAME_BLENDING, @frame_blending ? '1' : '0') - @app.set_variable(SettingsWindow::VAR_REWIND_ENABLED, @rewind_enabled ? '1' : '0') - @app.set_variable(SettingsWindow::VAR_VOLUME, (@volume * 100).round.to_s) - @app.set_variable(SettingsWindow::VAR_MUTE, @muted ? '1' : '0') - @app.set_variable(SettingsWindow::VAR_QUICK_SLOT, @quick_save_slot.to_s) - @app.set_variable(SettingsWindow::VAR_SS_BACKUP, @save_state_backup ? '1' : '0') - @app.set_variable(SettingsWindow::VAR_REC_COMPRESSION, @recording_compression.to_s) - @app.set_variable(SettingsWindow::VAR_PAUSE_FOCUS, @pause_on_focus_loss ? '1' : '0') - end - - # Returns the currently active input map based on settings window mode. - def active_input - @settings_window.keyboard_mode? ? @kb_map : @gp_map - end - - # Undo: reload mappings from disk for the active input device. - def undo_mappings - input = active_input - input.reload! - @settings_window.refresh_gamepad(input.labels, input.dead_zone_pct) - end - - # Undo: reload hotkeys from disk. - def undo_hotkeys - @hotkeys.reload! - @settings_window.refresh_hotkeys(@hotkeys.labels) - end - - # Validate a hotkey against keyboard gamepad mappings. - # Combo hotkeys (Array) never conflict with plain key gamepad mappings. - # @param hotkey [String, Array] plain keysym or modifier combo - # @return [String, nil] error message if conflict, nil if ok - def validate_hotkey(hotkey) - return nil if hotkey.is_a?(Array) - - @kb_map.labels.each do |gba_btn, key| - if key == hotkey - return "\"#{hotkey}\" is mapped to GBA button #{gba_btn.upcase}" - end - end - nil - end - - # Validate a keyboard gamepad mapping against hotkeys. - # Only plain-key hotkeys conflict — combo hotkeys (Ctrl+K) are fine. - # @return [String, nil] error message if conflict, nil if ok - def validate_kb_mapping(keysym) - action = @hotkeys.action_for(keysym) - if action - label = action.to_s.tr('_', ' ').capitalize - return "\"#{keysym}\" is assigned to hotkey: #{label}" - end - nil - end - - # Verify config/saves/states directories are writable. - # Shows a Tk dialog and aborts if any are not. - def check_writable_dirs - dirs = { - 'Config' => Config.config_dir, - 'Saves' => @config.saves_dir, - 'Save States' => Config.default_states_dir, - } - - problems = [] - dirs.each do |label, dir| - begin - FileUtils.mkdir_p(dir) - rescue SystemCallError => e - problems << "#{label}: #{dir}\n #{e.message}" - next - end - unless File.writable?(dir) - problems << "#{label}: #{dir}\n Not writable" - end - end - - return if problems.empty? - - msg = "Cannot write to required directories:\n\n#{problems.join("\n\n")}\n\n" \ - "Check file permissions or set a custom path in config." - @app.command(:tk_messageBox, icon: :error, type: :ok, - title: 'mGBA Player', message: msg) - @app.destroy('.') - exit 1 - end - - FOCUS_POLL_MS = 200 - - def start_focus_poll - @had_focus = @viewport.renderer.input_focus? - @app.after(FOCUS_POLL_MS) { focus_poll_tick } - end - - def focus_poll_tick - return unless @running - - has_focus = @viewport.renderer.input_focus? - - if @had_focus && !has_focus - # Lost focus - if @pause_on_focus_loss && @core && !@paused - @was_paused_before_focus_loss = true - toggle_pause - end - elsif !@had_focus && has_focus - # Gained focus - if @was_paused_before_focus_loss && @paused - @was_paused_before_focus_loss = false - toggle_pause - end - end - - @had_focus = has_focus - @app.after(FOCUS_POLL_MS) { focus_poll_tick } - rescue StandardError - # Renderer may be destroyed during shutdown - nil - end - - def start_gamepad_probe - @app.after(GAMEPAD_PROBE_MS) { gamepad_probe_tick } - end - - def gamepad_probe_tick - return unless @running - has_gp = @gamepad && !@gamepad.closed? - settings_visible = @app.command(:wm, 'state', SettingsWindow::TOP) != 'withdrawn' rescue false - - # When settings is visible, use update_state (SDL_GameControllerUpdate) - # instead of poll_events (SDL_PollEvent) to avoid pumping the Cocoa - # run loop, which steals events from Tk's native widgets. - # Background events hint ensures update_state gets fresh data even - # when the SDL window doesn't have focus. - if settings_visible && has_gp - Teek::SDL2::Gamepad.update_state - - # Listen mode: capture first pressed button for remap - if @settings_window.listening_for - Teek::SDL2::Gamepad.buttons.each do |btn| - if @gamepad.button?(btn) - @settings_window.capture_mapping(btn) - break - end - end - end - - @app.after(GAMEPAD_LISTEN_MS) { gamepad_probe_tick } - return - end - - # Settings closed: use poll_events for hot-plug callbacks - unless @core - Teek::SDL2::Gamepad.poll_events rescue nil - end - @app.after(GAMEPAD_PROBE_MS) { gamepad_probe_tick } - end - - def refresh_gamepads - names = [translate('settings.keyboard_only')] - prev_gp = @gamepad - 8.times do |i| - gp = begin; Teek::SDL2::Gamepad.open(i); rescue; nil; end - next unless gp - names << gp.name - @gamepad ||= gp - gp.close unless gp == @gamepad - end - @settings_window&.update_gamepad_list(names) - update_status_label - if @gamepad && @gamepad != prev_gp - Gemba.log(:info) { "Gamepad detected: #{@gamepad.name}" } - @gp_map.device = @gamepad - @gp_map.load_config - end - end - - def update_status_label - return if @core # hidden during gameplay - gp_text = @gamepad ? @gamepad.name : translate('settings.no_gamepad') - @app.command(@status_label, :configure, - text: "#{translate('player.open_rom_hint')}\n#{gp_text}") - end - - # Hotkeys that work without a ROM loaded (before SDL2/viewport exist). - # Once SDL2 is ready, the viewport's KeyPress binding handles everything. - GLOBAL_HOTKEY_ACTIONS = %i[quit open_rom].freeze - - def setup_global_hotkeys - @app.bind('.', 'KeyPress', :keysym, '%s') do |k, state_str| - next if @sdl2_ready || @modal_stack.active? - - if k == 'Escape' - self.running = false - else - mods = HotkeyMap.modifiers_from_state(state_str.to_i) - case @hotkeys.action_for(k, modifiers: mods) - when :quit then self.running = false - when :open_rom then handle_open_rom - end - end - end - end - - def setup_input - @viewport.bind('KeyPress', :keysym, '%s') do |k, state_str| - if k == 'Escape' - @fullscreen ? toggle_fullscreen : (@running = false) - else - mods = HotkeyMap.modifiers_from_state(state_str.to_i) - case @hotkeys.action_for(k, modifiers: mods) - when :quit then @running = false - when :pause then toggle_pause - when :fast_forward then toggle_fast_forward - when :fullscreen then toggle_fullscreen - when :show_fps then toggle_show_fps - when :quick_save then quick_save - when :quick_load then quick_load - when :save_states then show_state_picker - when :screenshot then take_screenshot - when :rewind then do_rewind - when :record then toggle_recording - when :input_record then toggle_input_recording - when :open_rom then handle_open_rom - else @keyboard.press(k) - end - end - end - - @viewport.bind('KeyRelease', :keysym) do |k| - @keyboard.release(k) - end - - @viewport.bind('FocusIn') { @has_focus = true } - @viewport.bind('FocusOut') { @has_focus = false } - - start_focus_poll - - # Alt+Return fullscreen toggle (emulator convention) - @app.command(:bind, @viewport.frame.path, '', proc { toggle_fullscreen }) - end - - def build_menu - menubar = '.menubar' - @app.command(:menu, menubar) - @app.command('.', :configure, menu: menubar) - - # File menu - @app.command(:menu, "#{menubar}.file", tearoff: 0) - @app.command(menubar, :add, :cascade, label: translate('menu.file'), menu: "#{menubar}.file") - - @app.command("#{menubar}.file", :add, :command, - label: translate('menu.open_rom'), accelerator: 'Cmd+O', - command: proc { open_rom_dialog }) - - # Recent ROMs submenu - @recent_menu = "#{menubar}.file.recent" - @app.command(:menu, @recent_menu, tearoff: 0) - @app.command("#{menubar}.file", :add, :cascade, - label: translate('menu.recent'), menu: @recent_menu) - rebuild_recent_menu - - @app.command("#{menubar}.file", :add, :separator) - @app.command("#{menubar}.file", :add, :command, - label: translate('menu.quit'), accelerator: 'Cmd+Q', - command: proc { @running = false }) - - @app.command(:bind, '.', '', proc { handle_open_rom }) - @app.command(:bind, '.', '', proc { show_settings }) - - # Settings menu — one entry per settings tab - settings_menu = "#{menubar}.settings" - @app.command(:menu, settings_menu, tearoff: 0) - @app.command(menubar, :add, :cascade, label: translate('menu.settings'), menu: settings_menu) - - SettingsWindow::TABS.each do |locale_key, tab_path| - display = translate(locale_key) - accel = locale_key == 'settings.video' ? 'Cmd+,' : nil - opts = { label: "#{display}…", command: proc { show_settings(tab: tab_path) } } - opts[:accelerator] = accel if accel - @app.command(settings_menu, :add, :command, **opts) - end - - # View menu - view_menu = "#{menubar}.view" - @app.command(:menu, view_menu, tearoff: 0) - @app.command(menubar, :add, :cascade, label: translate('menu.view'), menu: view_menu) - - @app.command(view_menu, :add, :command, - label: translate('menu.fullscreen'), accelerator: 'F11', - command: proc { toggle_fullscreen }) - @app.command(view_menu, :add, :command, - label: translate('menu.rom_info'), state: :disabled, - command: proc { show_rom_info }) - @app.command(view_menu, :add, :separator) - @app.command(view_menu, :add, :command, - label: translate('menu.open_logs_dir'), - command: proc { open_logs_dir }) - @view_menu = view_menu - - # Emulation menu - @emu_menu = "#{menubar}.emu" - @app.command(:menu, @emu_menu, tearoff: 0) - @app.command(menubar, :add, :cascade, label: translate('menu.emulation'), menu: @emu_menu) - - @app.command(@emu_menu, :add, :command, - label: translate('menu.pause'), accelerator: 'P', - command: proc { toggle_pause }) - @app.command(@emu_menu, :add, :command, - label: translate('menu.reset'), accelerator: 'Cmd+R', - command: proc { reset_core }) - @app.command(@emu_menu, :add, :separator) - @app.command(@emu_menu, :add, :command, - label: translate('menu.quick_save'), accelerator: 'F5', state: :disabled, - command: proc { quick_save }) - @app.command(@emu_menu, :add, :command, - label: translate('menu.quick_load'), accelerator: 'F8', state: :disabled, - command: proc { quick_load }) - @app.command(@emu_menu, :add, :separator) - @app.command(@emu_menu, :add, :command, - label: translate('menu.save_states'), accelerator: 'F6', state: :disabled, - command: proc { show_state_picker }) - @app.command(@emu_menu, :add, :separator) - @app.command(@emu_menu, :add, :command, - label: translate('menu.start_recording'), accelerator: 'F10', state: :disabled, - command: proc { toggle_recording }) - @app.command(@emu_menu, :add, :command, - label: translate('menu.start_input_recording'), accelerator: 'F4', state: :disabled, - command: proc { toggle_input_recording }) - - @app.command(:bind, '.', '', proc { reset_core }) - end - - def toggle_pause - return unless @core - @paused = !@paused - if @paused - @stream.clear - @stream.pause - @toast&.show(translate('toast.paused'), permanent: true) - @app.command(@emu_menu, :entryconfigure, 0, label: translate('menu.resume')) - render_frame # show paused toast on screen - set_event_loop_speed(:idle) - else - set_event_loop_speed(:fast) - @toast&.destroy - @stream.clear - @audio_fade_in = FADE_IN_FRAMES - @stream.resume - @next_frame = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @app.command(@emu_menu, :entryconfigure, 0, label: translate('menu.pause')) - end - end - - def toggle_fast_forward - return unless @core - @fast_forward = !@fast_forward - if @fast_forward - @hud.set_ff_label(ff_label_text) - else - @hud.set_ff_label(nil) - @next_frame = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @stream.clear - end - end - - def apply_turbo_speed(speed) - @turbo_speed = speed - @hud.set_ff_label(ff_label_text) if @fast_forward - end - - def ff_label_text - @turbo_speed == 0 ? translate('player.ff_max') : translate('player.ff', speed: @turbo_speed) - end - - def apply_aspect_ratio(keep) - @keep_aspect_ratio = keep - end - - def toggle_fullscreen - @fullscreen = !@fullscreen - @app.command(:wm, 'attributes', '.', '-fullscreen', @fullscreen ? 1 : 0) - end - - def apply_show_fps(show) - @show_fps = show - @hud.set_fps(nil) unless @show_fps - end - - def apply_toast_duration(secs) - @config.toast_duration = secs - @toast.duration = secs - end - - def apply_quick_slot(slot) - @quick_save_slot = slot.to_i.clamp(1, 10) - @save_mgr.quick_save_slot = @quick_save_slot if @save_mgr - end - - def apply_backup(enabled) - @save_state_backup = !!enabled - @save_mgr.backup = @save_state_backup if @save_mgr - end - - def open_config_dir - Gemba.open_directory(Config.config_dir) - end - - def toggle_show_fps - @show_fps = !@show_fps - @hud.set_fps(nil) unless @show_fps - @app.set_variable(SettingsWindow::VAR_SHOW_FPS, @show_fps ? '1' : '0') - end - - # -- Recording ----------------------------------------------------------- - - def toggle_recording - return unless @core - @recorder&.recording? ? stop_recording : start_recording - end - - def start_recording - dir = @config.recordings_dir - FileUtils.mkdir_p(dir) unless File.directory?(dir) - timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%L') - title = @core.title.strip.gsub(/[^a-zA-Z0-9_.-]/, '_') - filename = "#{title}_#{timestamp}.grec" - path = File.join(dir, filename) - @recorder = Recorder.new(path, width: GBA_W, height: GBA_H, - compression: @recording_compression) - @recorder.start - Gemba.log(:info) { "Recording started: #{path}" } - @toast&.show(translate('toast.recording_started')) - update_recording_menu - end - - def stop_recording - return unless @recorder&.recording? - @recorder.stop - count = @recorder.frame_count - Gemba.log(:info) { "Recording stopped: #{count} frames" } - @toast&.show(translate('toast.recording_stopped', frames: count)) - @recorder = nil - update_recording_menu - end - - # Capture current frame for recording. Reads audio_buffer (destructive) - # and returns the raw PCM so the caller can pass it to queue_audio. - # Returns nil when not recording. - def capture_frame - return nil unless @recorder&.recording? - pcm = @core.audio_buffer - @recorder.capture(@core.video_buffer_argb, pcm) - pcm - end - - def update_recording_menu - label = @recorder&.recording? ? translate('menu.stop_recording') : translate('menu.start_recording') - @app.command(@emu_menu, :entryconfigure, 8, label: label) - end - - # -- Input recording ------------------------------------------------------- - - def toggle_input_recording - return unless @core - @input_recorder&.recording? ? stop_input_recording : start_input_recording - end - - def start_input_recording - dir = @config.recordings_dir - FileUtils.mkdir_p(dir) unless File.directory?(dir) - timestamp = Time.now.strftime('%Y%m%d_%H%M%S_%L') - title = @core.title.strip.gsub(/[^a-zA-Z0-9_.-]/, '_') - filename = "#{title}_#{timestamp}.gir" - path = File.join(dir, filename) - @input_recorder = InputRecorder.new(path, core: @core, rom_path: @rom_path) - @input_recorder.start - @toast&.show(translate('toast.input_recording_started')) - update_input_recording_menu - end - - def stop_input_recording - return unless @input_recorder&.recording? - @input_recorder.stop - count = @input_recorder.frame_count - @toast&.show(translate('toast.input_recording_stopped', frames: count)) - @input_recorder = nil - update_input_recording_menu - end - - def update_input_recording_menu - label = @input_recorder&.recording? ? translate('menu.stop_input_recording') : translate('menu.start_input_recording') - @app.command(@emu_menu, :entryconfigure, 9, label: label) - end - - def apply_recording_compression(val) - @recording_compression = val.to_i.clamp(1, 9) - end - - def apply_pause_on_focus_loss(val) - @pause_on_focus_loss = val - @was_paused_before_focus_loss = false unless val - end - - def open_recordings_dir - Gemba.open_directory(@config.recordings_dir) - end - - def open_logs_dir - Gemba.open_directory(Config.default_logs_dir) - end - - # -- End recording ------------------------------------------------------- - - def reset_core - return unless @rom_path - load_rom(@rom_path) - end - - def confirm_rom_change(new_path) - return true unless @core && !@core.destroyed? - - name = File.basename(new_path) - result = @app.command('tk_messageBox', - parent: '.', - title: translate('dialog.game_running_title'), - message: translate('dialog.game_running_msg', name: name), - type: :okcancel, - icon: :warning) - result == 'ok' - end - - def setup_drop_target - @app.register_drop_target('.') - @app.bind('.', '<>', :data) do |data| - paths = @app.split_list(data) - handle_dropped_files(paths) - end - end - - def handle_dropped_files(paths) - if paths.length != 1 - @app.command('tk_messageBox', - parent: '.', - title: translate('dialog.drop_error_title'), - message: translate('dialog.drop_single_file_only'), - type: :ok, - icon: :warning) - return - end - - path = paths.first - ext = File.extname(path).downcase - unless RomLoader::SUPPORTED_EXTENSIONS.include?(ext) - @app.command('tk_messageBox', - parent: '.', - title: translate('dialog.drop_error_title'), - message: translate('dialog.drop_unsupported_type', ext: ext), - type: :ok, - icon: :warning) - return - end - - return unless confirm_rom_change(path) - load_rom(path) - end - - def handle_open_rom - if @modal_stack.current == :replay_player - open_recordings_dir - else - open_rom_dialog - end - end - - def open_rom_dialog - filetypes = '{{GBA ROMs} {.gba}} {{GB ROMs} {.gb .gbc}} {{ZIP Archives} {.zip}} {{All Files} {*}}' - title = translate('menu.open_rom').delete('…') - initial = @rom_path ? File.dirname(@rom_path) : Dir.home - path = @app.tcl_eval("tk_getOpenFile -title {#{title}} -filetypes {#{filetypes}} -initialdir {#{initial}}") - return if path.empty? - return unless confirm_rom_change(path) - - load_rom(path) - end - - def load_rom(path) - # Lazy-init SDL2 on first ROM load. Before this, the window shows - # only Tk widgets (menu bar, status label) — no black viewport. - init_sdl2 unless @sdl2_ready - - # Resolve ZIP archives to a bare ROM path - rom_path = begin - RomLoader.resolve(path) - rescue RomLoader::NoRomInZip => e - show_rom_error(translate('dialog.no_rom_in_zip', name: e.message)) - return - rescue RomLoader::MultipleRomsInZip => e - show_rom_error(translate('dialog.multiple_roms_in_zip', name: e.message)) - return - rescue RomLoader::UnsupportedFormat => e - show_rom_error(translate('dialog.drop_unsupported_type', ext: e.message)) - return - rescue RomLoader::ZipReadError => e - show_rom_error(translate('dialog.zip_read_error', detail: e.message)) - return - end - - stop_recording if @recorder&.recording? - stop_input_recording if @input_recorder&.recording? - - if @core && !@core.destroyed? - @core.destroy - end - @stream.clear - - saves = @config.saves_dir - FileUtils.mkdir_p(saves) unless File.directory?(saves) - @core = Core.new(rom_path, saves) - @rom_path = path - Gemba.log(:info) { "ROM loaded: #{@core.title} (#{@core.game_code})" } - - # Activate per-game config overlay (before reading settings) - rom_id = Config.rom_id(@core.game_code, @core.checksum) - @config.activate_game(rom_id) - refresh_from_config - @settings_window.set_per_game_available(true) - @settings_window.set_per_game_active(@config.per_game_settings?) - @save_mgr = SaveStateManager.new(core: @core, config: @config, app: @app) - @save_mgr.state_dir = @save_mgr.state_dir_for_rom(@core) - @save_mgr.quick_save_slot = @quick_save_slot - @save_mgr.backup = @save_state_backup - @core.rewind_init(@rewind_seconds) if @rewind_enabled - @rewind_frame_counter = 0 - @paused = false - @stream.resume - set_event_loop_speed(:fast) - @app.command(:place, :forget, @status_label) rescue nil - @app.set_window_title("mGBA \u2014 #{@core.title}") - @app.command(@view_menu, :entryconfigure, 1, state: :normal) - # Enable save state + recording menu entries - # Quick Save=3, Quick Load=4, Save States=6, Record=8, Input Record=9 - [3, 4, 6, 8, 9].each { |i| @app.command(@emu_menu, :entryconfigure, i, state: :normal) } - @fps_count = 0 - @fps_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @next_frame = @fps_time - @audio_samples_produced = 0 - - @config.add_recent_rom(path) - @config.save! - rebuild_recent_menu - - sav_name = File.basename(path, File.extname(path)) + '.sav' - sav_path = File.join(saves, sav_name) - if File.exist?(sav_path) - @toast&.show(translate('toast.loaded_sav', name: sav_name)) - else - @toast&.show(translate('toast.created_sav', name: sav_name)) - end - - # Start the emulation loop (first ROM load only). - # Subsequent load_rom calls just swap the core — animate is already running. - animate unless @animate_started - @animate_started = true - end - - def open_recent_rom(path) - unless File.exist?(path) - @app.command('tk_messageBox', - parent: '.', - title: translate('dialog.rom_not_found_title'), - message: translate('dialog.rom_not_found_msg', path: path), - type: :ok, - icon: :error) - @config.remove_recent_rom(path) - @config.save! - rebuild_recent_menu - return - end - return unless confirm_rom_change(path) - - load_rom(path) - end - - def rebuild_recent_menu - # Clear all existing entries - @app.command(@recent_menu, :delete, 0, :end) rescue nil - - roms = @config.recent_roms - if roms.empty? - @app.command(@recent_menu, :add, :command, - label: translate('player.none'), state: :disabled) - else - roms.each do |rom_path| - label = File.basename(rom_path) - @app.command(@recent_menu, :add, :command, - label: label, - command: proc { open_recent_rom(rom_path) }) - end - @app.command(@recent_menu, :add, :separator) - @app.command(@recent_menu, :add, :command, - label: translate('player.clear'), - command: proc { clear_recent_roms }) - end - end - - def clear_recent_roms - @config.clear_recent_roms - @config.save! - rebuild_recent_menu - end - - def tick - unless @core - @viewport.render { |r| r.clear(0, 0, 0) } - return - end - - if @paused - # No-op: the last frame is already on screen (rendered on pause - # entry or by render_if_paused). The animate loop keeps running - # at 100ms just to check @running. - return - end - - now = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @next_frame ||= now - - if @fast_forward - tick_fast_forward(now) - else - tick_normal(now) - end - end - - def tick_normal(now) - frames = 0 - while @next_frame <= now && frames < 4 - run_one_frame - rec_pcm = capture_frame - queue_audio(raw_pcm: rec_pcm) - - # Dynamic rate control — proportional feedback on audio buffer fill. - # Based on Near/byuu's algorithm for emulator A/V sync: - # https://docs.libretro.com/guides/ratecontrol.pdf - # - # fill = how full the audio buffer is (0.0 .. 1.0) - # ratio = (1 - MAX_DELTA) + 2 * fill * MAX_DELTA - # - # fill=0.0 (starving) → ratio=0.995 → shorter wait → emu speeds up - # fill=0.5 (target) → ratio=1.000 → no change - # fill=1.0 (overfull) → ratio=1.005 → longer wait → emu slows down - # - # The buffer naturally settles around 50% full. The ±0.5% limit - # keeps pitch/speed shifts imperceptible. - fill = (@stream.queued_samples.to_f / AUDIO_BUF_CAPACITY).clamp(0.0, 1.0) - ratio = (1.0 - MAX_DELTA) + 2.0 * fill * MAX_DELTA - @next_frame += FRAME_PERIOD * ratio - frames += 1 - end - - @next_frame = now if now - @next_frame > 0.1 - return if frames == 0 - - render_frame - update_fps(frames, now) - end - - def tick_fast_forward(now) - if @turbo_speed == 0 - # Uncapped: poll input once per tick to avoid flooding the Cocoa - # event loop (SDL_PollEvent pumps it), then blast through frames. - keys = poll_input - FF_MAX_FRAMES.times do |i| - @core.set_keys(keys) - @core.run_frame - rec_pcm = capture_frame - if i == 0 - queue_audio(volume_override: @turbo_volume, raw_pcm: rec_pcm) - elsif !rec_pcm - @core.audio_buffer # discard when not recording - end - end - @next_frame = now - render_frame(ff_indicator: true) - update_fps(FF_MAX_FRAMES, now) - return - end - - # Paced turbo (2x, 3x, 4x): run @turbo_speed frames per FRAME_PERIOD. - # Same timing gate as tick_normal so 2x ≈ 120 fps, not 2000 fps. - frames = 0 - while @next_frame <= now && frames < @turbo_speed * 4 - @turbo_speed.times do - run_one_frame - rec_pcm = capture_frame - if frames == 0 - queue_audio(volume_override: @turbo_volume, raw_pcm: rec_pcm) - elsif !rec_pcm - @core.audio_buffer # discard when not recording - end - frames += 1 - end - @next_frame += FRAME_PERIOD - end - @next_frame = now if now - @next_frame > 0.1 - return if frames == 0 - - render_frame(ff_indicator: true) - update_fps(frames, now) - end - - # Read keyboard + gamepad state, return combined bitmask. - # Uses SDL_GameControllerUpdate (not SDL_PollEvent) to read gamepad - # state without pumping the Cocoa event loop on macOS — SDL_PollEvent - # steals NSKeyDown events from Tk, making quit/escape unresponsive. - # Hot-plug detection is handled separately by start_gamepad_probe. - def poll_input - begin - Teek::SDL2::Gamepad.update_state - rescue StandardError - @gamepad = nil - @gp_map.device = nil - end - @kb_map.mask | @gp_map.mask - end - - REWIND_PUSH_INTERVAL = 60 # ~1 second at GBA framerate - - def run_one_frame - mask = poll_input - @input_recorder&.capture(mask) if @input_recorder&.recording? - @core.set_keys(mask) - @core.run_frame - @total_frames += 1 - @running = false if @frame_limit && @total_frames >= @frame_limit - if @rewind_enabled - @rewind_frame_counter += 1 - if @rewind_frame_counter >= REWIND_PUSH_INTERVAL - @core.rewind_push - @rewind_frame_counter = 0 - end - end - end - - def queue_audio(volume_override: nil, raw_pcm: nil) - pcm = raw_pcm || @core.audio_buffer - return if pcm.empty? - - @audio_samples_produced += pcm.bytesize / 4 - if @muted - @audio_fade_in = 0 - else - vol = volume_override || @volume - pcm = apply_volume_to_pcm(pcm, vol) if vol < 1.0 - if @audio_fade_in > 0 - pcm, @audio_fade_in = self.class.apply_fade_ramp(pcm, @audio_fade_in, FADE_IN_FRAMES) - end - @stream.queue(pcm) - end - end - - # Re-render while paused (e.g. after rewind, toast, or settings change). - # No-op when running — the animate loop handles rendering. - def render_if_paused - render_frame if @paused && @core && @texture - end - - # Switch Tcl event loop polling rate. - # :fast — 1ms, needed for smooth emulation frame pacing - # :idle — 50ms, sufficient for UI when paused or no ROM loaded - def set_event_loop_speed(mode) - ms = mode == :fast ? EVENT_LOOP_FAST_MS : EVENT_LOOP_IDLE_MS - @app.interp.thread_timer_ms = ms - end - - def render_frame(ff_indicator: false) - pixels = @core.video_buffer_argb - @texture.update(pixels) - dest = compute_dest_rect - @viewport.render do |r| - r.clear(0, 0, 0) - r.copy(@texture, nil, dest) - if @recorder&.recording? || @input_recorder&.recording? - bx = (dest ? dest[0] : 0) + 12 - by = (dest ? dest[1] : 0) + 12 - if @recorder&.recording? - draw_filled_circle(r, bx, by, 5, 220, 30, 30, 200) # red = video - bx += 14 - end - if @input_recorder&.recording? - draw_filled_circle(r, bx, by, 5, 30, 180, 30, 200) # green = input - end - end - @hud.draw(r, dest, show_fps: @show_fps, show_ff: ff_indicator) - @toast&.draw(r, dest) - end - end - - # Draw a filled circle using horizontal scanlines via fill_rect. - # Replaces SDL2_gfx's filledCircleRGBA so we don't need that dependency. - def draw_filled_circle(renderer, cx, cy, radius, r, g, b, a) - r2 = radius * radius - (-radius..radius).each do |dy| - dx = Math.sqrt(r2 - dy * dy).to_i - renderer.fill_rect(cx - dx, cy + dy, dx * 2 + 1, 1, r, g, b, a) - end - end - - # Calculate a centered destination rectangle that preserves the GBA's 3:2 - # aspect ratio within the current renderer output. Returns nil when - # stretching is preferred (keep_aspect_ratio off). - # - # Example — fullscreen on a 1920x1080 (16:9) monitor: - # scale_x = 1920 / 240 = 8.0 - # scale_y = 1080 / 160 = 6.75 - # scale = min(8.0, 6.75) = 6.75 (height is the constraint) - # dest = [150, 0, 1620, 1080] (pillarboxed: 150px black bars L+R) - # - # Example — fullscreen on a 2560x1600 (16:10) monitor: - # scale_x = 2560 / 240 ≈ 10.67 - # scale_y = 1600 / 160 = 10.0 - # scale = 10.0 - # dest = [80, 0, 2400, 1600] (pillarboxed: 80px bars L+R) - def compute_dest_rect - return nil unless @keep_aspect_ratio - - out_w, out_h = @viewport.renderer.output_size - scale_x = out_w.to_f / GBA_W - scale_y = out_h.to_f / GBA_H - scale = [scale_x, scale_y].min - scale = scale.floor if @integer_scale && scale >= 1.0 - - dest_w = (GBA_W * scale).to_i - dest_h = (GBA_H * scale).to_i - dest_x = (out_w - dest_w) / 2 - dest_y = (out_h - dest_h) / 2 - - [dest_x, dest_y, dest_w, dest_h] - end - - def update_fps(frames, now) - @fps_count += frames - elapsed = now - @fps_time - if elapsed >= 1.0 - fps = (@fps_count / elapsed).round(1) - @hud.set_fps(translate('player.fps', fps: fps)) if @show_fps - @audio_samples_produced = 0 - @fps_count = 0 - @fps_time = now - end - end - - def animate - if @running - tick - delay = (@core && !@paused) ? 1 : 100 - @app.after(delay) { animate } - else - cleanup - @app.command(:destroy, '.') - end - end - - # Apply software volume to int16 stereo PCM data. - def apply_volume_to_pcm(pcm, gain = @volume) - samples = pcm.unpack('s*') - samples.map! { |s| (s * gain).round.clamp(-32768, 32767) } - samples.pack('s*') - end - - # Apply a linear fade-in ramp to int16 stereo PCM data. - # Pure function: takes remaining/total counters, returns [pcm, new_remaining]. - # @param pcm [String] packed int16 stereo PCM - # @param remaining [Integer] fade samples remaining (counts down to 0) - # @param total [Integer] total fade length in samples - # @return [Array(String, Integer)] modified PCM and updated remaining count - def self.apply_fade_ramp(pcm, remaining, total) - samples = pcm.unpack('s*') - i = 0 - while i < samples.length && remaining > 0 - gain = 1.0 - (remaining.to_f / total) - samples[i] = (samples[i] * gain).round.clamp(-32768, 32767) - samples[i + 1] = (samples[i + 1] * gain).round.clamp(-32768, 32767) if i + 1 < samples.length - remaining -= 1 - i += 2 - end - [samples.pack('s*'), remaining] - end - - def cleanup - return if @cleaned_up - @cleaned_up = true - - stop_recording if @recorder&.recording? - stop_input_recording if @input_recorder&.recording? - @stream&.pause unless @stream&.destroyed? - @hud&.destroy - @toast&.destroy - @overlay_font&.destroy unless @overlay_font&.destroyed? - @stream&.destroy unless @stream&.destroyed? - @texture&.destroy unless @texture&.destroyed? - @core&.destroy unless @core&.destroyed? - RomLoader.cleanup_temp - end - end -end diff --git a/lib/gemba/recorder.rb b/lib/gemba/recorder.rb index cb468e8..58a15dd 100644 --- a/lib/gemba/recorder.rb +++ b/lib/gemba/recorder.rb @@ -30,13 +30,14 @@ class Recorder # @param audio_rate [Integer] audio sample rate (default 44100) # @param audio_channels [Integer] audio channels (default 2) # @param compression [Integer] zlib compression level 1-9 (default 1 = fastest) - def initialize(path, width:, height:, audio_rate: 44100, audio_channels: 2, - compression: Zlib::BEST_SPEED) + def initialize(path, width:, height:, fps_fraction:, audio_rate: 44100, + audio_channels: 2, compression: Zlib::BEST_SPEED) @path = path @width = width @height = height @audio_rate = audio_rate @audio_channels = audio_channels + @fps_fraction = fps_fraction @compression = compression @frame_size = width * height * 4 @recording = false @@ -127,7 +128,7 @@ def build_header h << MAGIC # 8 bytes h << [VERSION].pack('C') # 1 byte h << [@width, @height].pack('v2') # 4 bytes - h << [262_144, 4389].pack('V2') # 8 bytes (fps = 262144/4389 ≈ 59.7272) + h << @fps_fraction.pack('V2') # 8 bytes (fps as numerator/denominator) h << [@audio_rate].pack('V') # 4 bytes h << [@audio_channels, 16].pack('C2') # 2 bytes h << ("\0" * 5) # 5 bytes reserved diff --git a/lib/gemba/replay_player.rb b/lib/gemba/replay_player.rb index abc4624..a71db3d 100644 --- a/lib/gemba/replay_player.rb +++ b/lib/gemba/replay_player.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fileutils' -require_relative 'locale' module Gemba # Non-interactive GBA replay viewer with SDL2 video/audio. @@ -17,16 +16,10 @@ class ReplayPlayer include Gemba include Locale::Translatable - GBA_W = 240 - GBA_H = 160 DEFAULT_SCALE = 3 AUDIO_FREQ = 44100 - GBA_FPS = 59.7272 - FRAME_PERIOD = 1.0 / GBA_FPS - - AUDIO_BUF_CAPACITY = (AUDIO_FREQ / GBA_FPS * 6).to_i - MAX_DELTA = 0.005 + MAX_DELTA = 0.005 FF_MAX_FRAMES = 10 FADE_IN_FRAMES = (AUDIO_FREQ * 0.02).to_i EVENT_LOOP_FAST_MS = 1 @@ -82,6 +75,7 @@ def initialize(gir_path = nil, sound: true, fullscreen: false, app: nil, callbac @pixel_filter = @config.pixel_filter @integer_scale = @config.integer_scale? @hotkeys = HotkeyMap.new(@config) + @platform = Platform.default if app # Child mode: use parent's app, build in a Toplevel @@ -99,8 +93,8 @@ def initialize(gir_path = nil, sound: true, fullscreen: false, app: nil, callbac @callbacks = {} @top = '.' - win_w = GBA_W * @scale - win_h = GBA_H * @scale + win_w = @platform.width * @scale + win_h = @platform.height * @scale @app.set_window_title("[REPLAY]") @app.set_window_geometry("#{win_w}x#{win_h}") @@ -151,6 +145,15 @@ def withdraw private + def frame_period = 1.0 / @platform.fps + def audio_buf_capacity = (AUDIO_FREQ / @platform.fps * 6).to_i + + def recreate_texture + @texture&.destroy + @texture = @viewport.renderer.create_texture(@platform.width, @platform.height, :streaming) + @texture.scale_mode = @pixel_filter.to_sym + end + def start_replay_or_idle if @gir_path && !@animate_started load_replay(@gir_path) @@ -166,8 +169,8 @@ def start_replay_or_idle def build_child_toplevel @app.command(:toplevel, @top) @app.command(:wm, 'title', @top, translate('replay.replay_player')) - win_w = GBA_W * @scale - win_h = GBA_H * @scale + win_w = @platform.width * @scale + win_h = @platform.height * @scale @app.command(:wm, 'geometry', @top, "#{win_w}x#{win_h}") @app.command(:wm, 'transient', @top, '.') on_close = @callbacks[:on_dismiss] || proc { hide } @@ -204,6 +207,11 @@ def load_replay(gir_path) @core&.destroy unless @core&.destroyed? @core = Core.new(rom_path, @config.saves_dir) + new_platform = Platform.for(@core) + if new_platform != @platform + @platform = new_platform + recreate_texture + end @replayer.validate!(@core) @core.load_state_from_file(@replayer.anchor_state_path) @@ -242,15 +250,15 @@ def switch_replay(gir_path) load_replay(gir_path) end - # ── SDL2 init (stripped-down from Player) ───────────────────────── + # ── SDL2 init ──────────────────────────────────────────────────── def init_sdl2 return if @sdl2_ready @app.command('tk', 'busy', @top) - win_w = GBA_W * @scale - win_h = GBA_H * @scale + win_w = @platform.width * @scale + win_h = @platform.height * @scale @top_frame = child_path('replay_frame') unless @standalone if @top_frame @@ -262,7 +270,7 @@ def init_sdl2 @viewport = Teek::SDL2::Viewport.new(@app, width: win_w, height: win_h, vsync: false, **parent_opts) @viewport.pack(fill: :both, expand: true) - @texture = @viewport.renderer.create_texture(GBA_W, GBA_H, :streaming) + @texture = @viewport.renderer.create_texture(@platform.width, @platform.height, :streaming) @texture.scale_mode = @pixel_filter.to_sym font_path = File.join(ASSETS_DIR, 'JetBrainsMonoNL-Regular.ttf') @@ -420,9 +428,9 @@ def tick_normal(now) run_one_frame queue_audio - fill = (@stream.queued_samples.to_f / AUDIO_BUF_CAPACITY).clamp(0.0, 1.0) + fill = (@stream.queued_samples.to_f / audio_buf_capacity).clamp(0.0, 1.0) ratio = (1.0 - MAX_DELTA) + 2.0 * fill * MAX_DELTA - @next_frame += FRAME_PERIOD * ratio + @next_frame += frame_period * ratio frames += 1 end @@ -462,7 +470,7 @@ def tick_fast_forward(now) end frames += 1 end - @next_frame += FRAME_PERIOD + @next_frame += frame_period end @next_frame = now if now - @next_frame > 0.1 return if frames == 0 @@ -501,7 +509,7 @@ def queue_audio(volume_override: nil) vol = volume_override || @volume pcm = apply_volume_to_pcm(pcm, vol) if vol < 1.0 if @audio_fade_in > 0 - pcm, @audio_fade_in = Player.apply_fade_ramp(pcm, @audio_fade_in, FADE_IN_FRAMES) + pcm, @audio_fade_in = EmulatorFrame.apply_fade_ramp(pcm, @audio_fade_in, FADE_IN_FRAMES) end @stream.queue(pcm) end @@ -544,13 +552,13 @@ def compute_dest_rect return nil unless @keep_aspect_ratio out_w, out_h = @viewport.renderer.output_size - scale_x = out_w.to_f / GBA_W - scale_y = out_h.to_f / GBA_H + scale_x = out_w.to_f / @platform.width + scale_y = out_h.to_f / @platform.height scale = [scale_x, scale_y].min scale = scale.floor if @integer_scale && scale >= 1.0 - dest_w = (GBA_W * scale).to_i - dest_h = (GBA_H * scale).to_i + dest_w = (@platform.width * scale).to_i + dest_h = (@platform.height * scale).to_i dest_x = (out_w - dest_w) / 2 dest_y = (out_h - dest_h) / 2 @@ -632,11 +640,11 @@ def take_screenshot pixels = @core.video_buffer_argb photo_name = "__gemba_rp_ss_#{object_id}" - out_w = GBA_W * @scale - out_h = GBA_H * @scale + out_w = @platform.width * @scale + out_h = @platform.height * @scale @app.command(:image, :create, :photo, photo_name, width: out_w, height: out_h) - @app.interp.photo_put_zoomed_block(photo_name, pixels, GBA_W, GBA_H, + @app.interp.photo_put_zoomed_block(photo_name, pixels, @platform.width, @platform.height, zoom_x: @scale, zoom_y: @scale, format: :argb) @app.command(photo_name, :write, path, format: :png) @app.command(:image, :delete, photo_name) diff --git a/lib/gemba/rom_info.rb b/lib/gemba/rom_info.rb new file mode 100644 index 0000000..c6ff7a4 --- /dev/null +++ b/lib/gemba/rom_info.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + + +module Gemba + # Immutable snapshot of everything known about a single ROM. + # + # Aggregates data from multiple sources: + # - RomLibrary entry (title, path, game_code, platform, rom_id) + # - GameIndex (has_official_entry — whether libretro knows about it) + # - BoxartFetcher (cached_boxart_path — auto-fetched cover art) + # - RomOverrides (custom_boxart_path — user-chosen cover art) + # + # Use RomInfo.from_rom to construct from a raw library entry hash. + # Use #boxart_path to get the effective cover image (custom beats cache). + RomInfo = Data.define( + :rom_id, # String — unique ROM identifier (game_code + CRC32) + :title, # String — display name + :platform, # String — uppercased, e.g. "GBA" + :game_code, # String? — 4-char code e.g. "AGB-AXVE", or nil + :path, # String — absolute path to the ROM file + :has_official_entry, # Boolean — GameIndex has an entry for this game_code + :cached_boxart_path, # String? — auto-fetched cover from libretro CDN, or nil + :custom_boxart_path # String? — user-set cover image path, or nil + ) do + # Effective cover image path: custom override wins, then fetched cache, then nil. + def boxart_path + return custom_boxart_path if custom_boxart_path && File.exist?(custom_boxart_path) + return cached_boxart_path if cached_boxart_path && File.exist?(cached_boxart_path) + nil + end + + # Build a RomInfo from a raw rom_library entry hash. + # + # @param rom [Hash] entry from RomLibrary#all + # @param fetcher [BoxartFetcher, nil] + # @param overrides [RomOverrides, nil] + def self.from_rom(rom, fetcher: nil, overrides: nil) + game_code = rom['game_code'] + rom_id = rom['rom_id'] + + new( + rom_id: rom_id, + title: rom['title'] || rom['rom_id'] || '???', + platform: (rom['platform'] || 'gba').upcase, + game_code: game_code, + path: rom['path'], + has_official_entry: game_code ? !GameIndex.lookup(game_code).nil? : false, + cached_boxart_path: (fetcher.cached_path(game_code) if fetcher&.cached?(game_code)), + custom_boxart_path: overrides&.custom_boxart(rom_id), + ) + end + end +end diff --git a/lib/gemba/rom_info_window.rb b/lib/gemba/rom_info_window.rb index 085b5d0..f2ef1aa 100644 --- a/lib/gemba/rom_info_window.rb +++ b/lib/gemba/rom_info_window.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "child_window" -require_relative "locale" module Gemba # Displays ROM metadata in a read-only window. @@ -333,7 +331,7 @@ def populate(core, rom_path, save_path) publisher = maker.empty? ? na : "#{self.class.publisher_name(maker)} (#{maker})" set_field('publisher', publisher) - set_field('platform', core.platform) + set_field('platform', Platform.for(core).name) set_field('rom_size', format_size(core.rom_size)) set_field('checksum', "0x%08X" % core.checksum) set_field('rom_path', rom_path || na) diff --git a/lib/gemba/rom_library.rb b/lib/gemba/rom_library.rb new file mode 100644 index 0000000..822e450 --- /dev/null +++ b/lib/gemba/rom_library.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'json' +require 'fileutils' +require 'time' + +module Gemba + # Persistent catalog of known ROMs. + # + # Stored as JSON at Config.config_dir/rom_library.json. Each entry records + # the ROM's path, title, game code, rom_id, platform, and timestamps. + # The library is loaded once on boot and updated whenever a ROM is loaded. + class RomLibrary + FILENAME = 'rom_library.json' + + def initialize(path = self.class.default_path, subscribe: true) + @path = path + @roms = [] + load! + subscribe_to_bus if subscribe + end + + def self.default_path + File.join(Config.config_dir, FILENAME) + end + + # All known ROMs, sorted by last_played descending (most recent first). + # @return [Array] + def all + @roms.sort_by { |r| r['last_played'] || r['added_at'] || '' }.reverse + end + + # Add or update a ROM entry. Upserts by rom_id. + # @param attrs [Hash] must include 'rom_id'; other keys merged in + def add(attrs) + rom_id = attrs['rom_id'] || attrs[:rom_id] + raise ArgumentError, 'rom_id is required' unless rom_id + + attrs = stringify_keys(attrs) + existing = @roms.find { |r| r['rom_id'] == rom_id } + if existing + existing.merge!(attrs) + else + attrs['added_at'] ||= Time.now.utc.iso8601 + @roms << attrs + end + end + + # Remove a ROM entry by rom_id. + def remove(rom_id) + @roms.reject! { |r| r['rom_id'] == rom_id } + end + + # Update last_played timestamp for a ROM. + def touch(rom_id) + entry = find(rom_id) + entry['last_played'] = Time.now.utc.iso8601 if entry + end + + # Find a ROM entry by rom_id. + # @return [Hash, nil] + def find(rom_id) + @roms.find { |r| r['rom_id'] == rom_id } + end + + # @return [Integer] + def size + @roms.size + end + + # Persist to disk. + def save! + FileUtils.mkdir_p(File.dirname(@path)) + File.write(@path, JSON.pretty_generate({ 'roms' => @roms })) + end + + private + + def subscribe_to_bus + Gemba.bus.on(:rom_loaded) do |rom_id:, path:, title:, game_code:, platform:, **| + add( + 'rom_id' => rom_id, + 'path' => path, + 'title' => title, + 'game_code' => game_code, + 'platform' => platform.downcase, + ) + touch(rom_id) + save! + end + end + + def load! + return unless File.exist?(@path) + data = JSON.parse(File.read(@path)) + @roms = data['roms'] || [] + rescue JSON::ParserError + @roms = [] + end + + def stringify_keys(hash) + hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v } + end + end +end diff --git a/lib/gemba/rom_overrides.rb b/lib/gemba/rom_overrides.rb new file mode 100644 index 0000000..a8cfaa8 --- /dev/null +++ b/lib/gemba/rom_overrides.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'json' +require 'fileutils' + +module Gemba + # Persists per-ROM user overrides to config_dir/rom_overrides.json. + # + # Keyed by rom_id (game_code + CRC32 checksum) — the most stable + # identifier for a ROM across renames or moves. + # + # Currently tracks: + # custom_boxart — absolute path to a user-chosen cover image + # + # Custom images are copied into config_dir/boxart/{rom_id}/custom.{ext} + # so they remain accessible even if the original file is moved or deleted. + class RomOverrides + def initialize(path = Config.rom_overrides_path) + @path = path + @data = File.exist?(path) ? JSON.parse(File.read(path)) : {} + end + + # @return [String, nil] absolute path to the custom boxart, or nil + def custom_boxart(rom_id) + @data.dig(rom_id.to_s, 'custom_boxart') + end + + # Copies src_path into the gemba boxart cache and records the dest path. + # @return [String] the destination path + def set_custom_boxart(rom_id, src_path) + ext = File.extname(src_path) + dest = File.join(Config.boxart_dir, rom_id.to_s, "custom#{ext}") + FileUtils.mkdir_p(File.dirname(dest)) + FileUtils.cp(src_path, dest) + (@data[rom_id.to_s] ||= {})['custom_boxart'] = dest + save + dest + end + + private + + def save + FileUtils.mkdir_p(File.dirname(@path)) + File.write(@path, JSON.pretty_generate(@data)) + end + end +end diff --git a/lib/gemba/rom_patcher.rb b/lib/gemba/rom_patcher.rb new file mode 100644 index 0000000..c9bbd31 --- /dev/null +++ b/lib/gemba/rom_patcher.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'fileutils' + +module Gemba + # Applies IPS, BPS, or UPS patch files to GBA ROM files. + # + # Format support: + # IPS — simplest; no checksums; RLE support + # BPS — Beat Patch System; delta encoding with CRC32 verification + # UPS — Universal Patching System; XOR hunks with CRC32 verification + # + # Usage: + # RomPatcher.patch(rom_path: "game.gba", patch_path: "fix.ips", out_path: "patched.gba") + # # or invoke a format class directly: + # RomPatcher::IPS.apply(rom_bytes, patch_bytes) # => patched_bytes + # + class RomPatcher + CHUNK = 256 * 1024 # 256 KB + + # Auto-detect format, apply patch, write output file. + # + # Progress budget: + # 0–15% read ROM + # 15–25% read patch + # 25–90% format apply (IPS/BPS/UPS) + # 90–100% write output + # + # @param rom_path [String] source ROM (read-only) + # @param patch_path [String] patch file (.ips / .bps / .ups) + # @param out_path [String] where to write the result + # @param on_progress [Proc, nil] called with a Float (0.0..1.0) + # @return [String] out_path + # @raise [RuntimeError] on unknown format or checksum failure + def self.patch(rom_path:, patch_path:, out_path:, on_progress: nil) + rom = read_chunked(rom_path, 0.0, 0.15, on_progress) + patch = read_chunked(patch_path, 0.15, 0.25, on_progress) + + klass = case detect_format(patch) + when :ips then IPS + when :bps then BPS + when :ups then UPS + else raise "Unknown patch format (expected IPS/BPS/UPS magic)" + end + + apply_cb = on_progress && ->(pct) { on_progress.call(0.25 + pct * 0.65) } + result = klass.apply(rom, patch, on_progress: apply_cb) + on_progress&.call(0.90) + + FileUtils.mkdir_p(File.dirname(out_path)) + write_chunked(out_path, result, 0.90, 1.0, on_progress) + on_progress&.call(1.0) + out_path + end + + # @return [:ips, :bps, :ups, nil] + def self.detect_format(patch_data) + return :ips if patch_data.start_with?("PATCH") + return :bps if patch_data.start_with?("BPS1") + return :ups if patch_data.start_with?("UPS1") + nil + end + + # Return a path that does not collide with existing files. + # If +path+ exists, appends -(2), -(3), ... before the extension. + def self.safe_out_path(path) + return path unless File.exist?(path) + ext = File.extname(path) + base = path.chomp(ext) + n = 2 + loop do + candidate = "#{base}-(#{n})#{ext}" + return candidate unless File.exist?(candidate) + n += 1 + end + end + + # Read a file in chunks, reporting progress from +pct_start+ to +pct_end+. + def self.read_chunked(path, pct_start, pct_end, on_progress) + size = File.size(path).to_f + buf = String.new(encoding: 'BINARY') + read = 0 + File.open(path, 'rb') do |f| + while (chunk = f.read(CHUNK)) + buf << chunk + read += chunk.bytesize + on_progress&.call(pct_start + (read / size) * (pct_end - pct_start)) + end + end + buf + end + private_class_method :read_chunked + + # Write a string to a file in chunks, reporting progress from +pct_start+ to +pct_end+. + def self.write_chunked(path, data, pct_start, pct_end, on_progress) + size = data.bytesize.to_f + written = 0 + File.open(path, 'wb') do |f| + while written < data.bytesize + n = [CHUNK, data.bytesize - written].min + f.write(data.byteslice(written, n)) + written += n + on_progress&.call(pct_start + (written / size) * (pct_end - pct_start)) + end + end + end + private_class_method :write_chunked + end +end diff --git a/lib/gemba/rom_patcher/bps.rb b/lib/gemba/rom_patcher/bps.rb new file mode 100644 index 0000000..315a6cb --- /dev/null +++ b/lib/gemba/rom_patcher/bps.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'zlib' +require 'stringio' + +module Gemba + class RomPatcher + # Applies a BPS (Beat Patch System) patch. + # + # File layout: + # + # ┌──────────────────────────────────────────────────────┐ + # │ "BPS1" (4 bytes, magic) │ + # ├──────────────────────────────────────────────────────┤ + # │ source_size (varint) │ + # │ target_size (varint) │ + # │ metadata_size (varint) │ + # │ metadata ( bytes, skipped) │ + # ├──────────────────────────────────────────────────────┤ + # │ actions … (repeated until patch.size - 12) │ + # ├──────────────────────────────────────────────────────┤ + # │ src_crc32 4B LE │ tgt_crc32 4B LE │ patch │ ← footer + # └──────────────────────────────────────────────────────┘ + # + # Each action word (varint): word = (length - 1) << 2 | mode + # + # mode 0 SourceRead ┌────────┐ copy `length` bytes from source + # │ word │ at current output offset + # └────────┘ + # + # mode 1 TargetRead ┌────────┬──────────────────────┐ + # │ word │ data ( B) │ + # └────────┴──────────────────────┘ + # + # mode 2 SourceCopy ┌────────┬───────────┐ seek src by signed delta, + # │ word │ delta(v) │ copy `length` bytes + # └────────┴───────────┘ + # + # mode 3 TargetCopy ┌────────┬───────────┐ seek already-written target + # │ word │ delta(v) │ by signed delta, copy + # └────────┴───────────┘ + # + # BPS varint encoding (7-bit groups, additive-shift, differs from UPS): + # Each byte holds 7 data bits (b & 0x7f = 0b01111111) and one flag bit + # (b & 0x80). Flag=1 means last byte; flag=0 means more follow. + # Unlike UPS (bitwise OR + left-shift), BPS uses multiplication and adds + # an extra `shift` after each non-terminal byte so that 0x00 is never a + # valid single-byte encoding — this lets the format distinguish "no data" + # from an actual zero value. + # value = 0, shift = 1 + # per byte: value += (b & 0x7f) * shift + # if bit7 set → done; else shift <<= 7; value += shift + # + # Example: value 300 decoded from bytes [0x2C, 0x81] + # raw byte │ & 0x7f │ shift │ value after │ bit7 │ action + # ──────────┼──────────┼─────────┼─────────────────────┼────────┼─────────────────────────── + # 0x2C │ 44 │ 1 │ 0 + 44×1 = 44 │ 0 │ shift<<=7 (→128); value+=128 (→172) + # 0x81 │ 1 │ 128 │ 172 + 1×128 = 300 │ 1 │ break + class BPS + # @param rom [String] binary ROM data + # @param patch [String] binary BPS patch data + # @return [String] patched ROM (binary) + # @raise [RuntimeError] on CRC32 mismatch + def self.apply(rom, patch, on_progress: nil) + raise "BPS patch too small to be valid" if patch.bytesize < 16 + rom = rom.b + patch = patch.b + io = StringIO.new(patch) + io.read(4) # "BPS1" + + read_varint(io) # source_size — not used; target_size drives allocation + target_size = read_varint(io) + metadata_size = read_varint(io) + skip = io.read(metadata_size) + raise "Truncated BPS metadata" if skip&.bytesize != metadata_size + + target = "\x00".b * target_size + out_offset = 0 + src_offset = 0 + tgt_offset = 0 + patch_end = patch.bytesize - 12 + + last_pct = -1 + + while io.pos < patch_end + if on_progress + pct = (io.pos / patch_end.to_f * 100).floor + if pct != last_pct + on_progress.call(pct / 100.0) + last_pct = pct + end + end + + word = read_varint(io) + mode = word & 3 + length = (word >> 2) + 1 + + case mode + when 0 # SourceRead — copy from rom at current out position + length.times do + target.setbyte(out_offset, rom.getbyte(out_offset) || 0) + out_offset += 1 + end + when 1 # TargetRead — literal data + data = io.read(length) + target[out_offset, length] = data + out_offset += length + when 2 # SourceCopy — relative seek in source + src_offset += read_signed_varint(io) + length.times do + target.setbyte(out_offset, rom.getbyte(src_offset) || 0) + out_offset += 1 + src_offset += 1 + end + when 3 # TargetCopy — relative seek in target + tgt_offset += read_signed_varint(io) + length.times do + target.setbyte(out_offset, target.getbyte(tgt_offset) || 0) + out_offset += 1 + tgt_offset += 1 + end + end + end + + raise "BPS patch too small to contain footer" if patch.bytesize < 12 + src_crc, tgt_crc = patch[-12..].unpack("VV") + raise "BPS source CRC32 mismatch" unless Zlib.crc32(rom) == src_crc + raise "BPS target CRC32 mismatch" unless Zlib.crc32(target) == tgt_crc + + target + end + + # BPS varint: low 7 bits per byte; bit7=1 terminates; additive shift encoding. + # Decoder: value = 0, shift = 1; per byte: value += (b & 0x7f) * shift; + # if bit7: break; else: shift <<= 7; value += shift. + def self.read_varint(io) + value = 0 + shift = 1 + loop do + byte = io.read(1) + raise "Truncated BPS patch (varint read past end)" if byte.nil? + b = byte.getbyte(0) + value += (b & 0x7f) * shift + break if (b & 0x80) != 0 + shift <<= 7 + value += shift + end + value + end + private_class_method :read_varint + + def self.read_signed_varint(io) + v = read_varint(io) + negative = (v & 1) != 0 + v >>= 1 + negative ? -v : v + end + private_class_method :read_signed_varint + end + end +end diff --git a/lib/gemba/rom_patcher/ips.rb b/lib/gemba/rom_patcher/ips.rb new file mode 100644 index 0000000..6e37bfd --- /dev/null +++ b/lib/gemba/rom_patcher/ips.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'stringio' + +module Gemba + class RomPatcher + # Applies an IPS (International Patching System) patch. + # + # File layout: + # + # ┌─────────────────────────────────────────────┐ + # │ "PATCH" (5 bytes, magic) │ + # ├─────────────────────────────────────────────┤ + # │ Record 1 │ + # │ Record 2 │ + # │ ... │ + # ├─────────────────────────────────────────────┤ + # │ "EOF" (3 bytes, terminator) │ + # └─────────────────────────────────────────────┘ + # + # Record formats: + # + # Normal record: + # ┌────────────────────┬───────────────────┬─────────────────────┐ + # │ offset │ size │ data │ + # │ 3 bytes, big-endian│ 2 bytes, big-endian│ bytes │ + # └────────────────────┴───────────────────┴─────────────────────┘ + # + # RLE record — size field is 0x0000 (zero), which is the signal that + # this is NOT a normal data record. Instead of inline bytes, the next + # two fields say "repeat one byte N times": + # ┌────────────────────┬──────────┬────────────────────┬──────────┐ + # │ offset │ 0x0000 │ count │ value │ + # │ 3 bytes, big-endian│ 2 bytes │ 2 bytes, big-endian│ 1 byte │ + # └────────────────────┴──────────┴────────────────────┴──────────┘ + # Writes `value` repeated `count` times starting at `offset`. + # e.g. offset=0x100, count=8, value=0xFF → fills 8 bytes with 0xFF. + # + # No checksums — no integrity verification. + class IPS + EOF_MARKER = "EOF".b.freeze + + # @param rom [String] binary ROM data + # @param patch [String] binary IPS patch data + # @return [String] patched ROM (binary) + def self.apply(rom, patch, on_progress: nil) + rom = rom.b + patch = patch.b + result = rom.dup + io = StringIO.new(patch) + read!(io, 5) # "PATCH" + + total = patch.bytesize.to_f + last_pct = -1 + + loop do + offset_bytes = io.read(3) + break if offset_bytes.nil? || offset_bytes == EOF_MARKER + raise "Truncated patch: incomplete offset record" if offset_bytes.bytesize < 3 + + offset = (offset_bytes.getbyte(0) << 16) | + (offset_bytes.getbyte(1) << 8) | + offset_bytes.getbyte(2) + + size = read!(io, 2).unpack1("n") # "n" = 16-bit unsigned big-endian + + data = if size == 0 + count = read!(io, 2).unpack1("n") # "n" = 16-bit unsigned big-endian + value = read!(io, 1) + value * count + else + read!(io, size) + end + + # Extend ROM if patch writes past current end + needed = offset + data.bytesize + result << "\x00".b * (needed - result.bytesize) if needed > result.bytesize + result[offset, data.bytesize] = data + + if on_progress + pct = (io.pos / total * 100).floor + if pct != last_pct + on_progress.call(pct / 100.0) + last_pct = pct + end + end + end + + result + end + + def self.read!(io, n) + data = io.read(n) + raise "Truncated IPS patch (expected #{n} bytes, got #{data&.bytesize || 0})" \ + if data.nil? || data.bytesize < n + data + end + private_class_method :read! + end + end +end diff --git a/lib/gemba/rom_patcher/ups.rb b/lib/gemba/rom_patcher/ups.rb new file mode 100644 index 0000000..968e094 --- /dev/null +++ b/lib/gemba/rom_patcher/ups.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'zlib' +require 'stringio' + +module Gemba + class RomPatcher + # Applies a UPS (Universal Patching System) patch. + # + # File layout: + # + # ┌──────────────────────────────────────────────────────┐ + # │ "UPS1" (4 bytes, magic) │ + # ├──────────────────────────────────────────────────────┤ + # │ source_size (varint) │ + # │ target_size (varint) │ + # ├──────────────────────────────────────────────────────┤ + # │ hunks … (repeated until patch.size - 12) │ + # ├──────────────────────────────────────────────────────┤ + # │ src_crc32 4B LE │ tgt_crc32 4B LE │ patch │ ← footer + # └──────────────────────────────────────────────────────┘ + # + # Each hunk: + # ┌──────────────────┬──────────────────────────────────┐ + # │ skip (varint) │ xor_data … 0x00 │ + # └──────────────────┴──────────────────────────────────┘ + # + # skip — advance output position by this many bytes (unchanged bytes) + # xor_data — each byte XOR'd with the corresponding output byte; 0x00 ends the run + # + # Example: source = [AA BB CC DD EE FF] + # hunk 1: skip=1, xor=[11 22], 0x00 + # result: [AA (BB^11) (CC^22) DD EE FF] + # ↑ changed ↑ changed + # + # UPS varint encoding (7-bit groups, LSB first): + # Each byte holds 7 data bits (b & 0x7f = 0b01111111) and one flag bit + # (b & 0x80). Flag=1 means this is the last byte; flag=0 means more follow. + # value = 0, shift = 0 + # per byte: value |= (b & 0x7f) << shift + # if bit7 set → done; else shift += 7 + # + # Example: value 300 decoded from bytes [0x2C, 0x82] + # raw byte │ & 0x7f │ shift │ value after │ bit7 │ action + # ──────────┼──────────┼─────────┼───────────────────────┼────────┼────────── + # 0x2C │ 0x2C │ 0 │ 0x2C (44) │ 0 │ shift += 7 + # 0x82 │ 0x02 │ 7 │ 0x2C│0x100 (300) │ 1 │ break + class UPS + # @param rom [String] binary ROM data + # @param patch [String] binary UPS patch data + # @return [String] patched ROM (binary) + # @raise [RuntimeError] on CRC32 mismatch + def self.apply(rom, patch, on_progress: nil) + rom = rom.b + patch = patch.b + io = StringIO.new(patch) + io.read(4) # "UPS1" + + read_varint(io) # source_size — not needed; we derive target from target_size + target_size = read_varint(io) + + result = if rom.bytesize >= target_size + rom[0, target_size].dup + else + rom + "\x00".b * (target_size - rom.bytesize) + end + + pos = 0 + patch_end = patch.bytesize - 12 + last_pct = -1 + + while io.pos < patch_end + if on_progress + pct = (io.pos / patch_end.to_f * 100).floor + if pct != last_pct + on_progress.call(pct / 100.0) + last_pct = pct + end + end + + pos += read_varint(io) + + while io.pos < patch_end + b = io.read(1).getbyte(0) + break if b == 0x00 + result.setbyte(pos, (result.getbyte(pos) || 0) ^ b) if pos < result.bytesize + pos += 1 + end + pos += 1 # advance past the matching byte at the hunk boundary + end + + src_crc, tgt_crc = patch[-12..].unpack("VV") + raise "UPS source CRC32 mismatch" unless Zlib.crc32(rom) == src_crc + raise "UPS target CRC32 mismatch" unless Zlib.crc32(result) == tgt_crc + + result + end + + # UPS varint: low 7 bits per byte; bit7=1 terminates; simple bitshift accumulation. + # Decoder: value = 0, shift = 0; per byte: value |= (b & 0x7f) << shift; + # if bit7: break; else: shift += 7. + def self.read_varint(io) + value = 0 + shift = 0 + loop do + byte = io.read(1) + raise "Truncated UPS patch (varint read past end)" if byte.nil? + b = byte.getbyte(0) + value |= (b & 0x7f) << shift + break if (b & 0x80) != 0 + shift += 7 + end + value + end + private_class_method :read_varint + end + end +end diff --git a/lib/gemba/rom_loader.rb b/lib/gemba/rom_resolver.rb similarity index 91% rename from lib/gemba/rom_loader.rb rename to lib/gemba/rom_resolver.rb index 827f9b0..3bed615 100644 --- a/lib/gemba/rom_loader.rb +++ b/lib/gemba/rom_resolver.rb @@ -6,14 +6,14 @@ module Gemba # Resolves ROM paths for the player. Handles both bare ROM files # and .zip archives containing a single ROM at the zip root. # - # @example Load a bare ROM - # path = RomLoader.resolve("/path/to/game.gba") + # @example Resolve a bare ROM + # path = RomResolver.resolve("/path/to/game.gba") # # => "/path/to/game.gba" # - # @example Load from a zip - # path = RomLoader.resolve("/path/to/game.zip") + # @example Resolve from a zip + # path = RomResolver.resolve("/path/to/game.zip") # # => "/Users/you/.config/gemba/tmp/game.gba" - class RomLoader + class RomResolver ROM_EXTENSIONS = %w[.gba .gb .gbc].freeze ZIP_EXTENSIONS = %w[.zip].freeze SUPPORTED_EXTENSIONS = (ROM_EXTENSIONS + ZIP_EXTENSIONS).freeze @@ -85,7 +85,7 @@ def self.extract_from_zip(zip_path) dir = tmp_dir FileUtils.mkdir_p(dir) out_path = File.join(dir, File.basename(rom_entry.name)) - File.binwrite(out_path, rom_entry.get_input_stream.read) + rom_entry.get_input_stream { |s| File.binwrite(out_path, s.read) } out_path end rescue NoRomInZip, MultipleRomsInZip @@ -97,4 +97,5 @@ def self.extract_from_zip(zip_path) end private_class_method :extract_from_zip end + end diff --git a/lib/gemba/runtime.rb b/lib/gemba/runtime.rb index 3911264..7e5d502 100644 --- a/lib/gemba/runtime.rb +++ b/lib/gemba/runtime.rb @@ -1,41 +1,71 @@ # frozen_string_literal: true -# Shared runtime for gemba — loads the C extension, config, locale, -# core, and ROM loader. Both the full GUI and headless entry points -# require this. +# Shared bootstrap — explicitly required by lib/gemba.rb (full GUI) and +# lib/gemba/headless.rb (no Tk/SDL2). Sets up Zeitwerk autoloading, +# loads the C extension, and initializes the locale. +require "zeitwerk" require "teek/platform" require "gemba_ext" -require_relative "version" -require_relative "config" -require_relative "locale" -require_relative "core" -require_relative "rom_loader" -require_relative "logging" -require_relative "platform_open" +# Define the Gemba module before loader.setup so Zeitwerk can register +# autoloads directly on it (e.g. Gemba.autoload(:ChildWindow, ...)). +# Without this, Gemba doesn't exist yet and Zeitwerk proxies through +# lib/gemba.rb — which is never loaded in the headless path. module Gemba ASSETS_DIR = File.expand_path('../../assets', __dir__).freeze - # Lazily loaded user config — shared across the application. - # @return [Gemba::Config] - def self.user_config - @user_config ||= Config.new - end + class << self + # Lazily loaded user config — shared across the application. + # @return [Gemba::Config] + def user_config + @user_config ||= Config.new + end - # Override the user config (useful for tests). - # @param config [Gemba::Config, nil] pass nil to reset to default - def self.user_config=(config) - @user_config = config - end + # Override the user config (useful for tests). + # @param config [Gemba::Config, nil] pass nil to reset to default + attr_writer :user_config - # Load translations based on the config locale setting. - def self.load_locale - lang = user_config.locale - lang = nil if lang == 'auto' - Locale.load(lang) - end + # Load translations based on the config locale setting. + def load_locale + lang = user_config.locale + lang = nil if lang == 'auto' + Locale.load(lang) + end + + # Event bus — auto-created on first access. + # AppController replaces it with a fresh bus at startup. + def bus + @bus ||= EventBus.new + end + + attr_writer :bus - # Initialize locale on require - load_locale + # Session logger — lazily initialized on first write. + def logger + @logger ||= SessionLogger.new + end + + attr_writer :logger + + # Log a message at the given level. + # @example Gemba.log(:warn) { "something went wrong" } + def log(level = :info, &block) + logger.log(level, &block) + end + end end + +loader = Zeitwerk::Loader.new +loader.push_dir(File.expand_path("../..", __FILE__)) # lib/ as root +loader.inflector.inflect( + "gba" => "GBA", "gb" => "GB", "gbc" => "GBC", "cli" => "CLI", + "ips" => "IPS", "bps" => "BPS", "ups" => "UPS" +) +loader.ignore(__FILE__) # bootstrap file — not a constant +loader.ignore(File.expand_path("../../gemba.rb", __FILE__)) # entry point, not a constant +loader.ignore(File.expand_path("../platform_open.rb", __FILE__)) # module method, not a constant +loader.setup + +# Initialize locale on require +Gemba.load_locale diff --git a/lib/gemba/save_state_manager.rb b/lib/gemba/save_state_manager.rb index b15e739..9ed7e5b 100644 --- a/lib/gemba/save_state_manager.rb +++ b/lib/gemba/save_state_manager.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'fileutils' -require_relative 'locale' module Gemba # Manages save state persistence: save, load, screenshot capture, @@ -22,13 +21,11 @@ module Gemba class SaveStateManager include Locale::Translatable - GBA_W = 240 - GBA_H = 160 - - def initialize(core:, config:, app:) + def initialize(core:, config:, app:, platform:) @core = core @config = config @app = app + @platform = platform @last_save_time = 0 @state_dir = nil @quick_save_slot = config.quick_save_slot @@ -143,8 +140,8 @@ def save_screenshot(path) photo_name = "__gemba_ss_#{object_id}" @app.command(:image, :create, :photo, photo_name, - width: GBA_W, height: GBA_H) - @app.interp.photo_put_block(photo_name, pixels, GBA_W, GBA_H, format: :argb) + width: @platform.width, height: @platform.height) + @app.interp.photo_put_block(photo_name, pixels, @platform.width, @platform.height, format: :argb) @app.command(photo_name, :write, path, format: :png) @app.command(:image, :delete, photo_name) rescue StandardError => e diff --git a/lib/gemba/save_state_picker.rb b/lib/gemba/save_state_picker.rb index d5f034d..e98f22f 100644 --- a/lib/gemba/save_state_picker.rb +++ b/lib/gemba/save_state_picker.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "child_window" -require_relative "locale" module Gemba # Grid picker window for save state slots. diff --git a/lib/gemba/logging.rb b/lib/gemba/session_logger.rb similarity index 78% rename from lib/gemba/logging.rb rename to lib/gemba/session_logger.rb index 11a9b4c..2d45d22 100644 --- a/lib/gemba/logging.rb +++ b/lib/gemba/session_logger.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require 'logger' -require_relative 'config' module Gemba # Session logger that writes to the user config logs/ directory. @@ -62,21 +61,4 @@ def prune end end - # Log a message. Lazily initializes the session logger. - # @param level [Symbol] :debug, :info, :warn, :error - # @example Gemba.log(:info) { "ROM loaded" } - def self.log(level = :info, &block) - logger.log(level, &block) - end - - # @return [Gemba::SessionLogger] - def self.logger - @logger ||= SessionLogger.new - end - - # Override the logger (useful for tests). - # @param val [Gemba::SessionLogger, nil] - def self.logger=(val) - @logger = val - end end diff --git a/lib/gemba/settings/audio_tab.rb b/lib/gemba/settings/audio_tab.rb index 81639fc..039cfaa 100644 --- a/lib/gemba/settings/audio_tab.rb +++ b/lib/gemba/settings/audio_tab.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require_relative "paths" module Gemba module Settings @@ -20,6 +19,12 @@ def initialize(app, tips:, mark_dirty:) @mark_dirty = mark_dirty end + def load_from_config(config) + @app.set_variable(VAR_VOLUME, config.volume.to_s) + @app.command(@vol_val_label, 'configure', text: "#{config.volume}%") + @app.set_variable(VAR_MUTE, config.muted? ? '1' : '0') + end + def build @app.command('ttk::frame', FRAME) @app.command(Paths::NB, 'add', FRAME, text: translate('settings.audio')) diff --git a/lib/gemba/settings/gamepad_tab.rb b/lib/gemba/settings/gamepad_tab.rb index 7fd2e0c..4fd2207 100644 --- a/lib/gemba/settings/gamepad_tab.rb +++ b/lib/gemba/settings/gamepad_tab.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require_relative "paths" module Gemba module Settings diff --git a/lib/gemba/settings/hotkeys_tab.rb b/lib/gemba/settings/hotkeys_tab.rb index 5b5bdb7..8171991 100644 --- a/lib/gemba/settings/hotkeys_tab.rb +++ b/lib/gemba/settings/hotkeys_tab.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require_relative "paths" module Gemba module Settings diff --git a/lib/gemba/settings/recording_tab.rb b/lib/gemba/settings/recording_tab.rb index 6d0b15f..a02615c 100644 --- a/lib/gemba/settings/recording_tab.rb +++ b/lib/gemba/settings/recording_tab.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require_relative "paths" module Gemba module Settings @@ -20,6 +19,10 @@ def initialize(app, tips:, mark_dirty:) @mark_dirty = mark_dirty end + def load_from_config(config) + @app.set_variable(VAR_COMPRESSION, config.recording_compression.to_s) + end + def build @app.command('ttk::frame', FRAME) @app.command(Paths::NB, 'add', FRAME, text: translate('settings.recording')) diff --git a/lib/gemba/settings/save_states_tab.rb b/lib/gemba/settings/save_states_tab.rb index 5ee8dc2..0f6de69 100644 --- a/lib/gemba/settings/save_states_tab.rb +++ b/lib/gemba/settings/save_states_tab.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require_relative "paths" module Gemba module Settings @@ -22,6 +21,11 @@ def initialize(app, tips:, mark_dirty:) @mark_dirty = mark_dirty end + def load_from_config(config) + @app.set_variable(VAR_QUICK_SLOT, config.quick_save_slot.to_s) + @app.set_variable(VAR_BACKUP, config.save_state_backup? ? '1' : '0') + end + def build @app.command('ttk::frame', FRAME) @app.command(Paths::NB, 'add', FRAME, text: translate('settings.save_states')) diff --git a/lib/gemba/settings/system_tab.rb b/lib/gemba/settings/system_tab.rb new file mode 100644 index 0000000..0560eff --- /dev/null +++ b/lib/gemba/settings/system_tab.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module Gemba + module Settings + class SystemTab + include Locale::Translatable + include BusEmitter + + FRAME = "#{Paths::NB}.system" + BIOS_ENTRY = "#{FRAME}.bios_row.entry" + BIOS_BROWSE = "#{FRAME}.bios_row.browse" + BIOS_CLEAR = "#{FRAME}.bios_row.clear" + BIOS_STATUS = "#{FRAME}.bios_status" + SKIP_BIOS_CHECK = "#{FRAME}.skip_row.check" + + VAR_BIOS_PATH = '::gemba_bios_path' + VAR_SKIP_BIOS = '::gemba_skip_bios' + + def initialize(app, tips:, mark_dirty:) + @app = app + @tips = tips + @mark_dirty = mark_dirty + end + + def build + @app.command('ttk::frame', FRAME) + @app.command(Paths::NB, 'add', FRAME, text: translate('settings.system')) + + build_bios_section + build_skip_bios_row + end + + # Called by SettingsWindow via :config_loaded bus event + def load_from_config(config) + name = config.bios_path + @app.set_variable(VAR_BIOS_PATH, name.to_s) + @app.set_variable(VAR_SKIP_BIOS, config.skip_bios? ? '1' : '0') + if name && !name.empty? + bios = Bios.from_config_name(name) + update_status(bios) + else + @app.command(BIOS_STATUS, :configure, text: translate('settings.bios_not_set')) + end + end + + private + + def build_bios_section + # Header label + hdr = "#{FRAME}.bios_hdr" + @app.command('ttk::label', hdr, text: translate('settings.bios_header')) + @app.command(:pack, hdr, anchor: :w, padx: 10, pady: [15, 2]) + + # Path row: readonly entry + Browse + Clear + row = "#{FRAME}.bios_row" + @app.command('ttk::frame', row) + @app.command(:pack, row, fill: :x, padx: 10, pady: [0, 4]) + + @app.set_variable(VAR_BIOS_PATH, '') + @app.command('ttk::entry', BIOS_ENTRY, + textvariable: VAR_BIOS_PATH, + state: :readonly, + width: 42) + @app.command(:pack, BIOS_ENTRY, side: :left, fill: :x, expand: 1, padx: [0, 4]) + + @app.command('ttk::button', BIOS_BROWSE, + text: translate('settings.bios_browse'), + command: proc { browse_bios }) + @app.command(:pack, BIOS_BROWSE, side: :left, padx: [0, 4]) + + @app.command('ttk::button', BIOS_CLEAR, + text: translate('settings.bios_clear'), + command: proc { clear_bios }) + @app.command(:pack, BIOS_CLEAR, side: :left) + + # Status label + @app.command('ttk::label', BIOS_STATUS, text: translate('settings.bios_not_set')) + @app.command(:pack, BIOS_STATUS, anchor: :w, padx: 14, pady: [0, 10]) + end + + def build_skip_bios_row + row = "#{FRAME}.skip_row" + @app.command('ttk::frame', row) + @app.command(:pack, row, fill: :x, padx: 10, pady: [0, 5]) + + @app.set_variable(VAR_SKIP_BIOS, '0') + @app.command('ttk::checkbutton', SKIP_BIOS_CHECK, + text: translate('settings.skip_bios'), + variable: VAR_SKIP_BIOS, + command: proc { @mark_dirty.call }) + @app.command(:pack, SKIP_BIOS_CHECK, side: :left) + + tip_lbl = "#{row}.tip" + @app.command('ttk::label', tip_lbl, text: '(?)') + @app.command(:pack, tip_lbl, side: :left, padx: [4, 0]) + @tips.register(tip_lbl, translate('settings.tip_skip_bios')) + end + + def browse_bios + path = @app.tcl_eval("tk_getOpenFile -filetypes {{{BIOS Files} {.bin}} {{All Files} *}}") + return if path.to_s.strip.empty? + + FileUtils.mkdir_p(Config.bios_dir) + dest = File.join(Config.bios_dir, File.basename(path)) + FileUtils.cp(path, dest) unless File.expand_path(path) == File.expand_path(dest) + + bios = Bios.new(path: dest) + @app.set_variable(VAR_BIOS_PATH, bios.filename) + update_status(bios) + @mark_dirty.call + emit(:bios_changed, filename: bios.filename) + end + + def clear_bios + @app.set_variable(VAR_BIOS_PATH, '') + @app.command(BIOS_STATUS, :configure, text: translate('settings.bios_not_set')) + @mark_dirty.call + emit(:bios_changed, filename: nil) + end + + def update_status(bios) + text = if !bios.exists? + translate('settings.bios_not_found') + else + bios.status_text + end + @app.command(BIOS_STATUS, :configure, text: text) + end + + end + end +end diff --git a/lib/gemba/settings/video_tab.rb b/lib/gemba/settings/video_tab.rb index d164746..86409b6 100644 --- a/lib/gemba/settings/video_tab.rb +++ b/lib/gemba/settings/video_tab.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require_relative "paths" module Gemba module Settings @@ -38,6 +37,22 @@ def initialize(app, tips:, mark_dirty:) @mark_dirty = mark_dirty end + def load_from_config(config) + @app.set_variable(VAR_SCALE, "#{config.scale}x") + turbo_label = config.turbo_speed == 0 ? translate('settings.uncapped') : "#{config.turbo_speed}x" + @app.set_variable(VAR_TURBO, turbo_label) + @app.set_variable(VAR_ASPECT_RATIO, config.keep_aspect_ratio? ? '1' : '0') + @app.set_variable(VAR_SHOW_FPS, config.show_fps? ? '1' : '0') + @app.set_variable(VAR_TOAST_DURATION, "#{config.toast_duration}s") + filter_label = config.pixel_filter == 'nearest' ? translate('settings.filter_nearest') : translate('settings.filter_linear') + @app.set_variable(VAR_FILTER, filter_label) + @app.set_variable(VAR_INTEGER_SCALE, config.integer_scale? ? '1' : '0') + @app.set_variable(VAR_COLOR_CORRECTION, config.color_correction? ? '1' : '0') + @app.set_variable(VAR_FRAME_BLENDING, config.frame_blending? ? '1' : '0') + @app.set_variable(VAR_REWIND_ENABLED, config.rewind_enabled? ? '1' : '0') + @app.set_variable(VAR_PAUSE_FOCUS, config.pause_on_focus_loss? ? '1' : '0') + end + def build @app.command('ttk::frame', FRAME) @app.command(Paths::NB, 'add', FRAME, text: translate('settings.video')) diff --git a/lib/gemba/settings_window.rb b/lib/gemba/settings_window.rb index 5ea4f29..8a8abec 100644 --- a/lib/gemba/settings_window.rb +++ b/lib/gemba/settings_window.rb @@ -1,17 +1,5 @@ # frozen_string_literal: true -require_relative "child_window" -require_relative "event_bus" -require_relative "hotkey_map" -require_relative "locale" -require_relative "tip_service" -require_relative "settings/paths" -require_relative "settings/audio_tab" -require_relative "settings/video_tab" -require_relative "settings/recording_tab" -require_relative "settings/save_states_tab" -require_relative "settings/gamepad_tab" -require_relative "settings/hotkeys_tab" module Gemba # Settings window for the mGBA Player. @@ -84,6 +72,15 @@ class SettingsWindow SS_BACKUP_CHECK = Settings::SaveStatesTab::BACKUP_CHECK SS_OPEN_DIR_BTN = Settings::SaveStatesTab::OPEN_DIR_BTN + # System tab widget paths (re-exported from Settings::SystemTab) + SYSTEM_TAB = Settings::SystemTab::FRAME + BIOS_ENTRY = Settings::SystemTab::BIOS_ENTRY + BIOS_STATUS = Settings::SystemTab::BIOS_STATUS + SKIP_BIOS_CHECK = Settings::SystemTab::SKIP_BIOS_CHECK + + VAR_BIOS_PATH = Settings::SystemTab::VAR_BIOS_PATH + VAR_SKIP_BIOS = Settings::SystemTab::VAR_SKIP_BIOS + # Bottom bar SAVE_BTN = "#{TOP}.save_btn" @@ -124,7 +121,8 @@ def initialize(app, callbacks: {}, tip_dismiss_ms: TipService::DEFAULT_DISMISS_M @tip_dismiss_ms = tip_dismiss_ms @per_game_enabled = false - build_toplevel(translate('menu.settings'), geometry: '700x560') { setup_ui } + build_toplevel(translate('menu.settings'), geometry: '780x600') { setup_ui } + subscribe_to_bus end # Delegates to GamepadTab @@ -160,10 +158,11 @@ def show_modal(tab: nil, **_) 'settings.hotkeys' => HK_TAB, 'settings.recording' => REC_TAB, 'settings.save_states' => SS_TAB, + 'settings.system' => SYSTEM_TAB, }.freeze # Tabs that show the per-game settings checkbox - PER_GAME_TABS = Set.new(["#{NB}.video", "#{NB}.audio", SS_TAB]).freeze + PER_GAME_TABS = Set.new(["#{NB}.video", "#{NB}.audio", SS_TAB, SYSTEM_TAB]).freeze def hide @tips&.hide @@ -175,6 +174,19 @@ def mark_dirty @app.command(SAVE_BTN, 'configure', state: :normal) end + # Subscribe to bus events this object cares about. + def subscribe_to_bus + Gemba.bus.on(:rom_loaded) do |**| + set_per_game_available(true) + set_per_game_active(Gemba.user_config.per_game_settings?) + end + Gemba.bus.on(:config_loaded) do |config:| + [@video_tab, @audio_tab, @recording_tab, @save_states_tab, @system_tab].each do |tab| + tab.load_from_config(config) + end + end + end + # Enable/disable the per-game checkbox (called when ROM loads/unloads). def set_per_game_available(enabled) @per_game_enabled = enabled @@ -266,6 +278,8 @@ def setup_ui @recording_tab.build @save_states_tab = Settings::SaveStatesTab.new(@app, tips: @tips, mark_dirty: method(:mark_dirty)) @save_states_tab.build + @system_tab = Settings::SystemTab.new(@app, tips: @tips, mark_dirty: method(:mark_dirty)) + @system_tab.build # Show/hide per-game bar based on active tab @app.command(:bind, NB, '<>', proc { update_per_game_bar }) diff --git a/lib/gemba/virtual_keyboard.rb b/lib/gemba/virtual_keyboard.rb new file mode 100644 index 0000000..a62eadb --- /dev/null +++ b/lib/gemba/virtual_keyboard.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'set' + +module Gemba + # Virtual keyboard device that tracks key press/release state. + # Presents the same interface as an SDL gamepad: +button?+ and +closed?+. + class VirtualKeyboard + def initialize + @held = Set.new + end + + def press(keysym) = @held.add(keysym) + def release(keysym) = @held.delete(keysym) + def button?(keysym) = @held.include?(keysym) + def closed? = false + end + +end diff --git a/script/bake_game_index.rb b/script/bake_game_index.rb new file mode 100644 index 0000000..48bd50c --- /dev/null +++ b/script/bake_game_index.rb @@ -0,0 +1,106 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Parse No-Intro DAT files (from metadat/no-intro/) and produce a game index +# mapping game_code → canonical game name as JSON. +# +# The no-intro DAT format uses bare 4-char serials (e.g. "BPEE"), while mGBA's +# core.game_code returns platform-prefixed codes (e.g. "AGB-BPEE"). This script +# adds the appropriate prefix. +# +# Prerequisites: +# ruby script/fetch_nointro_dat.rb +# +# Usage: +# ruby script/bake_game_index.rb +# +# Output: +# lib/gemba/data/gba_games.json +# lib/gemba/data/gb_games.json +# lib/gemba/data/gbc_games.json + +require "json" +require "fileutils" + +DAT_DIR = File.expand_path("../../tmp/dat", __FILE__) +DATA_DIR = File.expand_path("../../lib/gemba/data", __FILE__) + +# Parse a no-intro DAT file. +# Format: +# game ( +# name "Pokemon - Emerald Version (USA, Europe)" +# region "USA" +# serial "BPEE" +# rom ( ... ) +# ) +def parse_nointro_dat(path) + entries = [] + current_name = nil + current_region = nil + + File.foreach(path) do |line| + line.strip! + if line =~ /^name "(.+)"$/ + current_name = $1 + elsif line =~ /^region "(.+)"$/ + current_region = $1 + elsif line =~ /^serial "(.+)"$/ + serial = $1 + if current_name && !serial.empty? + entries << { serial: serial, name: current_name, region: current_region } + end + elsif line == ")" + current_name = nil + current_region = nil + end + end + + entries +end + +FileUtils.mkdir_p(DATA_DIR) + +# platform prefix that mGBA uses, and the DAT filename +systems = { + gba: { prefix: "AGB", dat: "Nintendo - Game Boy Advance.dat" }, + gb: { prefix: "DMG", dat: "Nintendo - Game Boy.dat" }, + gbc: { prefix: "CGB", dat: "Nintendo - Game Boy Color.dat" }, +} + +systems.each do |platform, info| + dat_path = File.join(DAT_DIR, info[:dat]) + + unless File.exist?(dat_path) + warn "SKIP: #{dat_path} not found (run script/fetch_nointro_dat.rb first)" + next + end + + puts "Parsing #{info[:dat]} ..." + raw_entries = parse_nointro_dat(dat_path) + puts " #{raw_entries.size} raw entries" + + # Build map: "AGB-BPEE" => "Pokemon - Emerald Version (USA, Europe)" + # When multiple regions share a serial, prefer USA > World > Europe > first seen. + by_code = {} + raw_entries.each do |entry| + key = "#{info[:prefix]}-#{entry[:serial]}" + existing = by_code[key] + region = entry[:region] || "" + + if existing.nil? + by_code[key] = entry[:name] + elsif region.include?("USA") && !existing.include?("(USA") + by_code[key] = entry[:name] + elsif region.include?("World") && !existing.include?("(USA") && !existing.include?("(World") + by_code[key] = entry[:name] + end + end + + puts " #{by_code.size} unique game codes" + + json_path = File.join(DATA_DIR, "#{platform}_games.json") + File.write(json_path, JSON.generate(by_code.sort.to_h)) + puts " #{json_path} (#{File.size(json_path)} bytes)" +end + +puts "\nDone." diff --git a/script/fetch_nointro_dat.rb b/script/fetch_nointro_dat.rb new file mode 100644 index 0000000..48442c8 --- /dev/null +++ b/script/fetch_nointro_dat.rb @@ -0,0 +1,50 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Fetch No-Intro DAT files from libretro-database for GBA/GB/GBC. +# Uses the metadat/no-intro/ path which has better coverage than metadat/serial/. +# +# Usage: +# ruby script/fetch_nointro_dat.rb +# +# Output: +# tmp/dat/Nintendo - Game Boy Advance.dat +# tmp/dat/Nintendo - Game Boy.dat +# tmp/dat/Nintendo - Game Boy Color.dat + +require "net/http" +require "uri" +require "fileutils" + +REPO = "libretro/libretro-database" +BRANCH = "master" +DAT_DIR = File.expand_path("../../tmp/dat", __FILE__) + +SYSTEMS = { + "Nintendo - Game Boy Advance" => "metadat/no-intro/Nintendo - Game Boy Advance.dat", + "Nintendo - Game Boy" => "metadat/no-intro/Nintendo - Game Boy.dat", + "Nintendo - Game Boy Color" => "metadat/no-intro/Nintendo - Game Boy Color.dat", +} + +FileUtils.mkdir_p(DAT_DIR) + +SYSTEMS.each do |label, path| + puts "Downloading #{label} ..." + + encoded = path.split("/").map { |seg| URI.encode_www_form_component(seg).gsub("+", "%20") }.join("/") + url = "https://raw.githubusercontent.com/#{REPO}/#{BRANCH}/#{encoded}" + uri = URI(url) + response = Net::HTTP.get_response(uri) + + if response.is_a?(Net::HTTPSuccess) + out_path = File.join(DAT_DIR, File.basename(path)) + File.write(out_path, response.body) + lines = response.body.lines.size + bytes = response.body.bytesize + puts " Saved #{out_path} (#{lines} lines, #{bytes} bytes)" + else + warn " FAILED: HTTP #{response.code} for #{url}" + end +end + +puts "\nDone. DAT files in #{DAT_DIR}" diff --git a/scripts/generate_test_gb_rom.rb b/scripts/generate_test_gb_rom.rb new file mode 100644 index 0000000..b437d45 --- /dev/null +++ b/scripts/generate_test_gb_rom.rb @@ -0,0 +1,66 @@ +#!/usr/bin/env ruby + +# Generates a minimal valid GB ROM for testing gemba. +# +# The ROM contains a valid Game Boy header (entry point, Nintendo logo, +# title, header checksum) and a HALT loop. mGBA will load it and detect +# it as a GB ROM (160x144, no color). +# +# GB cartridge header reference: +# https://gbdev.io/pandocs/The_Cartridge_Header.html +# +# Usage: +# ruby scripts/generate_test_gb_rom.rb +# +# Output: +# test/fixtures/test.gb + +rom = "\x00".b * 32768 # 32 KB minimum ROM + +# 0x100..0x103: Entry point — NOP then JP 0x0150 +rom[0x100] = "\x00".b # NOP +rom[0x101, 3] = "\xC3\x50\x01".b # JP 0x0150 + +# 0x104..0x133: Nintendo logo (required for boot validation) +nintendo_logo = [ + 0xCE, 0xED, 0x66, 0x66, 0xCC, 0x0D, 0x00, 0x0B, + 0x03, 0x73, 0x00, 0x83, 0x00, 0x0C, 0x00, 0x0D, + 0x00, 0x08, 0x11, 0x1F, 0x88, 0x89, 0x00, 0x0E, + 0xDC, 0xCC, 0x6E, 0xE6, 0xDD, 0xDD, 0xD9, 0x99, + 0xBB, 0xBB, 0x67, 0x63, 0x6E, 0x0E, 0xEC, 0xCC, + 0xDD, 0xDC, 0x99, 0x9F, 0xBB, 0xB9, 0x33, 0x3E, +].pack("C*") +rom[0x104, 48] = nintendo_logo + +# 0x134..0x143: Title (16 bytes, padded with NUL) +rom[0x134, 16] = "GEMBAGB\x00\x00\x00\x00\x00\x00\x00\x00\x00".b + +# 0x143: CGB flag — 0x00 = GB only (not GBC) +rom.setbyte(0x143, 0x00) + +# 0x147: Cartridge type — 0x00 = ROM ONLY +rom.setbyte(0x147, 0x00) + +# 0x148: ROM size — 0x00 = 32 KB (2 banks) +rom.setbyte(0x148, 0x00) + +# 0x149: RAM size — 0x00 = no RAM +rom.setbyte(0x149, 0x00) + +# 0x14A: Destination code — 0x01 = non-Japanese +rom.setbyte(0x14A, 0x01) + +# 0x14D: Header checksum — sum of bytes 0x134..0x14C +# chk = 0; for i in 0x134..0x14C: chk = chk - rom[i] - 1 +chk = 0 +(0x134..0x14C).each { |i| chk = (chk - rom.getbyte(i) - 1) & 0xFF } +rom.setbyte(0x14D, chk) + +# 0x150: Program start — HALT loop +rom[0x150] = "\x76".b # HALT +rom[0x151] = "\x18".b # JR +rom[0x152] = "\xFC".b # offset -4 (back to HALT) + +out = File.expand_path("../test/fixtures/test.gb", __dir__) +File.binwrite(out, rom) +puts "Wrote #{rom.bytesize} bytes to #{out}" diff --git a/scripts/generate_test_gbc_rom.rb b/scripts/generate_test_gbc_rom.rb new file mode 100644 index 0000000..b9f3597 --- /dev/null +++ b/scripts/generate_test_gbc_rom.rb @@ -0,0 +1,65 @@ +#!/usr/bin/env ruby + +# Generates a minimal valid GBC ROM for testing gemba. +# +# Identical to the GB ROM except the CGB flag at 0x143 is set to 0xC0 +# (GBC only), causing mGBA to detect it as a Game Boy Color ROM. +# +# GBC cartridge header reference: +# https://gbdev.io/pandocs/The_Cartridge_Header.html +# +# Usage: +# ruby scripts/generate_test_gbc_rom.rb +# +# Output: +# test/fixtures/test.gbc + +rom = "\x00".b * 32768 # 32 KB minimum ROM + +# 0x100..0x103: Entry point — NOP then JP 0x0150 +rom[0x100] = "\x00".b # NOP +rom[0x101, 3] = "\xC3\x50\x01".b # JP 0x0150 + +# 0x104..0x133: Nintendo logo (required for boot validation) +nintendo_logo = [ + 0xCE, 0xED, 0x66, 0x66, 0xCC, 0x0D, 0x00, 0x0B, + 0x03, 0x73, 0x00, 0x83, 0x00, 0x0C, 0x00, 0x0D, + 0x00, 0x08, 0x11, 0x1F, 0x88, 0x89, 0x00, 0x0E, + 0xDC, 0xCC, 0x6E, 0xE6, 0xDD, 0xDD, 0xD9, 0x99, + 0xBB, 0xBB, 0x67, 0x63, 0x6E, 0x0E, 0xEC, 0xCC, + 0xDD, 0xDC, 0x99, 0x9F, 0xBB, 0xB9, 0x33, 0x3E, +].pack("C*") +rom[0x104, 48] = nintendo_logo + +# 0x134..0x142: Title (15 bytes, padded with NUL) +rom[0x134, 15] = "GEMBAGBC\x00\x00\x00\x00\x00\x00\x00".b + +# 0x143: CGB flag — 0xC0 = GBC only +rom.setbyte(0x143, 0xC0) + +# 0x147: Cartridge type — 0x00 = ROM ONLY +rom.setbyte(0x147, 0x00) + +# 0x148: ROM size — 0x00 = 32 KB (2 banks) +rom.setbyte(0x148, 0x00) + +# 0x149: RAM size — 0x00 = no RAM +rom.setbyte(0x149, 0x00) + +# 0x14A: Destination code — 0x01 = non-Japanese +rom.setbyte(0x14A, 0x01) + +# 0x14D: Header checksum — sum of bytes 0x134..0x14C +# chk = 0; for i in 0x134..0x14C: chk = chk - rom[i] - 1 +chk = 0 +(0x134..0x14C).each { |i| chk = (chk - rom.getbyte(i) - 1) & 0xFF } +rom.setbyte(0x14D, chk) + +# 0x150: Program start — HALT loop +rom[0x150] = "\x76".b # HALT +rom[0x151] = "\x18".b # JR +rom[0x152] = "\xFC".b # offset -4 (back to HALT) + +out = File.expand_path("../test/fixtures/test.gbc", __dir__) +File.binwrite(out, rom) +puts "Wrote #{rom.bytesize} bytes to #{out}" diff --git a/test/fixtures/fake_bios.bin b/test/fixtures/fake_bios.bin new file mode 100644 index 0000000..6d464b7 Binary files /dev/null and b/test/fixtures/fake_bios.bin differ diff --git a/test/fixtures/test.gb b/test/fixtures/test.gb new file mode 100644 index 0000000..1928995 Binary files /dev/null and b/test/fixtures/test.gb differ diff --git a/test/fixtures/test.gbc b/test/fixtures/test.gbc new file mode 100644 index 0000000..7561aa9 Binary files /dev/null and b/test/fixtures/test.gbc differ diff --git a/test/fixtures/test_quicksave.ss b/test/fixtures/test_quicksave.ss new file mode 100644 index 0000000..1f64bdb Binary files /dev/null and b/test/fixtures/test_quicksave.ss differ diff --git a/test/shared/teek_test_worker.rb b/test/shared/teek_test_worker.rb index c8b5554..21260be 100644 --- a/test/shared/teek_test_worker.rb +++ b/test/shared/teek_test_worker.rb @@ -67,7 +67,7 @@ def stop end # Default timeout for test execution (can be overridden via TK_TEST_TIMEOUT env var) - DEFAULT_TIMEOUT = 60 + DEFAULT_TIMEOUT = 10 def run_test(code, pipe_capture: false, timeout: nil, source_file: nil, source_line: nil) start unless running? @@ -171,6 +171,22 @@ def wait_until(timeout: 1.0) end result end + + # Suppress tk_popup for the duration of the block so right-click bindings + # can build and configure menus without posting them. This avoids the + # platform grab that would block app.update on macOS and Windows. + # + # Uses Tcl `catch` so setup and teardown are idempotent — safe even if a + # previous test left _orig_tk_popup stranded due to an error. + def override_tk_popup + @app.tcl_eval("rename tk_popup _orig_tk_popup; proc tk_popup {args} {}") + yield + ensure + # Delete the no-op proc first (rename to {} = delete in Tcl), then + # restore the real tk_popup. Both wrapped in catch so a double-ensure + # or other edge case never raises. + @app.tcl_eval("catch {rename tk_popup {}}; catch {rename _orig_tk_popup tk_popup}") + end end # Server-side: runs in subprocess diff --git a/test/shared/tk_test_helper.rb b/test/shared/tk_test_helper.rb index fb1885d..84a0d83 100644 --- a/test/shared/tk_test_helper.rb +++ b/test/shared/tk_test_helper.rb @@ -49,6 +49,33 @@ def self.simplecov_preamble # Default timeout for subprocess tests (can be overridden via TK_TEST_TIMEOUT env var) DEFAULT_SUBPROCESS_TIMEOUT = 5 + # Tcl bgerror capture preamble — prepended to subprocess code so that + # any unhandled Tcl background error is written to a log file instead + # of vanishing into the void. tk_subprocess reads the log after the + # subprocess exits and appends it to stderr. + BGERROR_PREAMBLE = <<~'RUBY' + require 'teek' + module BgerrorCapture + def initialize(*) + super + if (path = ENV['GEMBA_BGERROR_LOG']) + tcl_eval(%Q{ + proc bgerror {msg} { + set fd [open {#{path}} a] + puts $fd "bgerror: $msg" + if {[info exists ::errorInfo]} { + puts $fd $::errorInfo + } + puts $fd "---" + close $fd + } + }) + end + end + end + Teek::App.prepend(BgerrorCapture) + RUBY + def tk_subprocess(code, coverage: true, timeout: nil) timeout ||= Integer(ENV['TK_TEST_TIMEOUT'] || DEFAULT_SUBPROCESS_TIMEOUT) @@ -57,18 +84,31 @@ def tk_subprocess(code, coverage: true, timeout: nil) load_paths = $LOAD_PATH.select { |p| p.start_with?(project_root) } load_path_args = load_paths.flat_map { |p| ["-I", p] } - # Prepend SimpleCov setup for coverage merging - full_code = coverage ? "#{TeekTestHelper.simplecov_preamble}\n#{code}" : code + # Temp file for capturing Tcl bgerror output + require 'tempfile' + bgerror_file = Tempfile.new(['bgerror', '.log']) + bgerror_path = bgerror_file.path + bgerror_file.close + + # Prepend SimpleCov + bgerror capture + preamble = coverage ? "#{TeekTestHelper.simplecov_preamble}\n" : "" + full_code = "#{preamble}#{BGERROR_PREAMBLE}\n#{code}" + + # Write code to temp file so backtraces show real line numbers + code_file = Tempfile.new(['tk_test', '.rb']) + code_file.write(full_code) + code_file.close # Pass env vars to subprocess env = {} env['VISUAL'] = '1' if ENV['VISUAL'] env['COVERAGE'] = '1' if ENV['COVERAGE'] + env['GEMBA_BGERROR_LOG'] = bgerror_path # -rbundler/setup activates Bundler in the subprocess so path: gems # (e.g. teek, teek-sdl2 from sibling repos) are on the load path. stdin, stdout, stderr, wait_thr = Open3.popen3( - env, RbConfig.ruby, "-rbundler/setup", *load_path_args, "-e", full_code + env, RbConfig.ruby, "-rbundler/setup", *load_path_args, code_file.path ) stdin.close @@ -84,6 +124,10 @@ def tk_subprocess(code, coverage: true, timeout: nil) end end + # Read bgerror log before cleanup + bgerrors = File.read(bgerror_path).strip rescue "" + File.delete(bgerror_path) rescue nil + unless status # Timed out - capture any output before killing so errors are visible out = stdout.read_nonblock(64 * 1024) rescue "" @@ -92,7 +136,10 @@ def tk_subprocess(code, coverage: true, timeout: nil) wait_thr.join stdout.close stderr.close - return [false, out, "Test timed out after #{timeout}s\n#{err}", nil] + err = "Test timed out after #{timeout}s\n#{err}" + err = "#{err}\nTcl bgerror log:\n#{bgerrors}" unless bgerrors.empty? + code_file.unlink rescue nil + return [false, out, err, nil] end out = stdout.read @@ -100,6 +147,10 @@ def tk_subprocess(code, coverage: true, timeout: nil) stdout.close stderr.close + # Append bgerrors to stderr so they surface in test output + err = "#{err}\nTcl bgerror log:\n#{bgerrors}" unless bgerrors.empty? + code_file.unlink rescue nil + [status.success?, out, err, status] end @@ -283,6 +334,23 @@ def process_alive?(pid) # The test code has access to `app` (a Teek::App instance) and minitest assertions. # Do NOT create your own TkRoot or call root.destroy - worker manages this. # + # == event generate gotcha: mouse button events == + # + # The TestWorker calls `app.hide` (wm withdraw .) after every test. + # `event generate ` silently does nothing when the root window is + # withdrawn because widgets are not mapped/viewable without a visible ancestor. + # Always call `app.show` + `app.update` before generating mouse button events: + # + # picker.show + # app.show # ← required: deiconify root so widgets are viewable + # app.update # ← let Tk map all windows + # app.tcl_eval("event generate .widget -x 10 -y 10") + # app.update + # + # Key events additionally require focus: + # app.tcl_eval("focus -force .widget") + # app.update + # # Example: # def test_something # assert_tk_app("should work") do diff --git a/test/support/player_helpers.rb b/test/support/player_helpers.rb index fa1df4d..743e78d 100644 --- a/test/support/player_helpers.rb +++ b/test/support/player_helpers.rb @@ -36,7 +36,7 @@ def xvfb_screenshot(name = "debug") # Falls back to xdotool to force focus if polling alone doesn't work. def poll_until_focused(player, timeout_ms: 2_000, &block) app = player.app - renderer = player.viewport.renderer + renderer = player.frame.viewport.renderer deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_ms / 1000.0 tried_xdotool = false check = proc do diff --git a/test/test_player.rb b/test/test_app_controller.rb similarity index 77% rename from test/test_player.rb rename to test/test_app_controller.rb index d2dcdb6..0a5c8c8 100644 --- a/test/test_player.rb +++ b/test/test_app_controller.rb @@ -16,7 +16,7 @@ def test_exit_with_rom_loaded_does_not_hang require "gemba" require "support/player_helpers" - player = Gemba::Player.new("#{TEST_ROM}") + player = Gemba::AppController.new("#{TEST_ROM}") app = player.app poll_until_ready(player) { player.running = false } @@ -38,7 +38,7 @@ def test_quit_hotkey_without_rom code = <<~RUBY require "gemba" - player = Gemba::Player.new + player = Gemba::AppController.new app = player.app app.after(100) do @@ -60,12 +60,73 @@ def test_quit_hotkey_without_rom assert_includes stdout, "PASS" end + # View > Game Library returns to picker after loading a ROM. + def test_view_menu_game_library_returns_to_picker + code = <<~'RUBY'.sub('ROM_PATH', TEST_ROM) + require "gemba" + require "support/player_helpers" + + player = Gemba::AppController.new("ROM_PATH") + player.disable_confirmations! + app = player.app + + poll_until_ready(player) do + puts "BEFORE=#{player.current_view}" + + app.command('.menubar.view', 'invoke', 0) + + app.after(100) do + puts "AFTER=#{player.current_view}" + player.running = false + end + end + + player.run + RUBY + + success, stdout, stderr, _status = tk_subprocess(code) + + output = [] + output << "STDOUT:\n#{stdout}" unless stdout.empty? + output << "STDERR:\n#{stderr}" unless stderr.empty? + + assert success, "View > Game Library should return to picker\n#{output.join("\n")}" + assert_includes stdout, "BEFORE=emulator" + assert_includes stdout, "AFTER=picker" + end + + # File > Quit menu item exits cleanly (invokes the actual menu command). + def test_file_menu_quit_without_rom + code = <<~RUBY + require "gemba" + + player = Gemba::AppController.new + app = player.app + + app.after(100) do + app.command('.menubar.file', 'invoke', 'last') + end + + player.run + puts "PASS" + RUBY + + success, stdout, stderr, _status = tk_subprocess(code) + + output = [] + output << "STDOUT:\n#{stdout}" unless stdout.empty? + output << "STDERR:\n#{stderr}" unless stderr.empty? + + assert success, "File > Quit should exit cleanly\n#{output.join("\n")}" + assert_includes stdout, "PASS" + end + # Escape key quits without a ROM loaded. def test_escape_quits_without_rom code = <<~RUBY require "gemba" - player = Gemba::Player.new + player = Gemba::AppController.new app = player.app app.after(100) do @@ -95,11 +156,11 @@ def test_fullscreen_toggle_does_not_hang require "gemba" require "support/player_helpers" - player = Gemba::Player.new("#{TEST_ROM}") + player = Gemba::AppController.new("#{TEST_ROM}") app = player.app poll_until_ready(player) do - vp = player.viewport + vp = player.frame.viewport frame = vp.frame.path # User presses F11 → fullscreen on @@ -139,11 +200,11 @@ def test_exit_during_turbo_does_not_hang require "gemba" require "support/player_helpers" - player = Gemba::Player.new("#{TEST_ROM}") + player = Gemba::AppController.new("#{TEST_ROM}") app = player.app poll_until_ready(player) do - vp = player.viewport + vp = player.frame.viewport frame = vp.frame.path # User presses Tab → enable turbo (2x default) @@ -181,7 +242,7 @@ def test_quick_save_and_load_creates_files_and_restores_state # Use a temp dir for all config/states so we don't pollute the real one states_dir = Dir.mktmpdir("gemba-states-test") - player = Gemba::Player.new("#{TEST_ROM}") + player = Gemba::AppController.new("#{TEST_ROM}") app = player.app config = player.config @@ -190,9 +251,11 @@ def test_quick_save_and_load_creates_files_and_restores_state config.save_state_debounce = 0.1 poll_until_ready(player) do - core = player.save_mgr.core - state_dir = player.save_mgr.state_dir - vp = player.viewport + core = player.frame.save_mgr.core + # Recompute state_dir after overriding config.states_dir + player.frame.save_mgr.state_dir = player.frame.save_mgr.state_dir_for_rom(core) + state_dir = player.frame.save_mgr.state_dir + vp = player.frame.viewport frame_path = vp.frame.path # Quick save (F5) @@ -301,7 +364,7 @@ def test_quick_save_debounce_blocks_rapid_fire states_dir = Dir.mktmpdir("gemba-debounce-test") - player = Gemba::Player.new("#{TEST_ROM}") + player = Gemba::AppController.new("#{TEST_ROM}") app = player.app config = player.config @@ -309,7 +372,9 @@ def test_quick_save_debounce_blocks_rapid_fire config.save_state_debounce = 5.0 # long debounce poll_until_ready(player) do - vp = player.viewport + # Recompute state_dir after overriding config.states_dir + player.frame.save_mgr.state_dir = player.frame.save_mgr.state_dir_for_rom(player.frame.save_mgr.core) + vp = player.frame.viewport frame_path = vp.frame.path # First save should succeed @@ -317,7 +382,7 @@ def test_quick_save_debounce_blocks_rapid_fire app.update app.after(50) do - state_dir = player.save_mgr.state_dir + state_dir = player.frame.save_mgr.state_dir ss_path = File.join(state_dir, "state1.ss") first_exists = File.exist?(ss_path) @@ -373,7 +438,7 @@ def test_settings_change_quick_slot_and_save config_dir = Dir.mktmpdir("gemba-settings-test") config_path = File.join(config_dir, "settings.json") - player = Gemba::Player.new("#{TEST_ROM}") + player = Gemba::AppController.new("#{TEST_ROM}") app = player.app config = player.config @@ -445,11 +510,11 @@ def test_settings_change_quick_slot_and_save # -- Audio fade ramp (pure function, no Tk/SDL2 needed) -------------------- def test_fade_ramp_attenuates_first_samples - require "gemba/player" + require "gemba/headless" # 10 stereo frames of max-amplitude int16 pcm = ([32767, 32767] * 10).pack('s*') total = 10 - result, remaining = Gemba::Player.apply_fade_ramp(pcm, total, total) + result, remaining = Gemba::EmulatorFrame.apply_fade_ramp(pcm, total, total) samples = result.unpack('s*') # First stereo pair: gain = 1 - 10/10 = 0.0 → should be 0 @@ -464,17 +529,17 @@ def test_fade_ramp_attenuates_first_samples end def test_fade_ramp_returns_remaining_when_pcm_shorter_than_fade - require "gemba/player" + require "gemba/headless" # Only 2 stereo frames but fade wants 10 pcm = ([20000, 20000] * 2).pack('s*') - _result, remaining = Gemba::Player.apply_fade_ramp(pcm, 10, 10) + _result, remaining = Gemba::EmulatorFrame.apply_fade_ramp(pcm, 10, 10) assert_equal 8, remaining, "should have 8 fade samples remaining" end def test_fade_ramp_noop_when_remaining_zero - require "gemba/player" + require "gemba/headless" pcm = ([10000, -10000] * 4).pack('s*') - result, remaining = Gemba::Player.apply_fade_ramp(pcm, 0, 10) + result, remaining = Gemba::EmulatorFrame.apply_fade_ramp(pcm, 0, 10) assert_equal pcm, result, "should not modify samples when remaining is 0" assert_equal 0, remaining end @@ -491,11 +556,11 @@ def test_modal_child_blocks_concurrent_windows sw_top = Gemba::SettingsWindow::TOP sp_top = Gemba::SaveStatePicker::TOP - player = Gemba::Player.new("#{TEST_ROM}") + player = Gemba::AppController.new("#{TEST_ROM}") app = player.app poll_until_ready(player) do - vp = player.viewport + vp = player.frame.viewport frame = vp.frame.path # 1. Open Settings via menu (Settings > Video = index 0) @@ -589,7 +654,7 @@ def test_drop_rom_file_loads_game require "gemba" require "support/player_helpers" - player = Gemba::Player.new + player = Gemba::AppController.new app = player.app # Stub tk_messageBox so it never blocks @@ -601,7 +666,7 @@ def test_drop_rom_file_loads_game app.update app.after(50) do - core = player.core + core = player.frame.core if core && !core.destroyed? $stdout.puts "TITLE=\#{core.title}" else @@ -629,7 +694,7 @@ def test_drop_unsupported_file_shows_error require "gemba" require "support/player_helpers" - player = Gemba::Player.new + player = Gemba::AppController.new app = player.app # Capture tk_messageBox calls instead of blocking @@ -670,7 +735,7 @@ def test_drop_multiple_files_shows_error require "gemba" require "support/player_helpers" - player = Gemba::Player.new + player = Gemba::AppController.new app = player.app # Capture tk_messageBox calls instead of blocking @@ -718,13 +783,13 @@ def test_recording_toggle_creates_trec_file rec_dir = Dir.mktmpdir("gemba-rec-test") begin - player = Gemba::Player.new("#{TEST_ROM}") + player = Gemba::AppController.new("#{TEST_ROM}") app = player.app config = player.config config.recordings_dir = rec_dir poll_until_ready(player) do - vp = player.viewport + vp = player.frame.viewport frame = vp.frame.path # Press F10 → start recording @@ -733,7 +798,7 @@ def test_recording_toggle_creates_trec_file # Let a few frames render with the recording indicator (red dot) app.after(50) do - unless player.recording? + unless player.frame.recording? puts "FAIL: recording never started" player.running = false next @@ -776,12 +841,58 @@ def test_recording_toggle_creates_trec_file assert_includes stdout, "PASS", "Expected .grec file to be created\n#{output.join("\n")}" end + # -- Frame stack show/hide --------------------------------------------------- + + # Loads a ROM, verifies the emulator viewport is visible (packed), + # then hides it and confirms it's gone. Catches missing show/hide on frames. + def test_emulator_frame_show_hide_round_trip + code = <<~RUBY + require "gemba" + require "support/player_helpers" + + player = Gemba::AppController.new("#{TEST_ROM}") + app = player.app + + poll_until_ready(player) do + vp_path = player.frame.viewport.frame.path + + # Viewport should be visible after ROM load + info = app.tcl_eval("pack info \#{vp_path}") rescue "" + abort "FAIL: viewport not packed after load" if info.empty? + + # Hide the frame, viewport should disappear + player.frame.hide + info_after = app.tcl_eval("pack info \#{vp_path}") rescue "" + abort "FAIL: viewport still packed after hide" unless info_after.empty? + + # Show it again + player.frame.show + info_restored = app.tcl_eval("pack info \#{vp_path}") rescue "" + abort "FAIL: viewport not packed after show" if info_restored.empty? + + puts "PASS" + player.running = false + end + + player.run + RUBY + + success, stdout, stderr, _status = tk_subprocess(code) + + output = [] + output << "STDOUT:\n#{stdout}" unless stdout.empty? + output << "STDERR:\n#{stderr}" unless stderr.empty? + + assert success, "Frame show/hide round-trip failed\n#{output.join("\n")}" + assert_includes stdout, "PASS" + end + # -- Pause CPU optimization (thread_timer_ms) -------------------------------- def test_event_loop_constants - require "gemba/player" - assert_equal 1, Gemba::Player::EVENT_LOOP_FAST_MS, "fast loop should be 1ms" - assert_equal 50, Gemba::Player::EVENT_LOOP_IDLE_MS, "idle loop should be 50ms" + require "gemba/headless" + assert_equal 1, Gemba::AppController::EVENT_LOOP_FAST_MS, "fast loop should be 1ms" + assert_equal 50, Gemba::AppController::EVENT_LOOP_IDLE_MS, "idle loop should be 50ms" end # E2E: verify thread_timer_ms switches between idle (50ms) and fast (1ms) @@ -793,13 +904,13 @@ def test_pause_switches_event_loop_speed require "gemba" require "support/player_helpers" - player = Gemba::Player.new("#{TEST_ROM}") + player = Gemba::AppController.new("#{TEST_ROM}") app = player.app poll_until_ready(player) do # Wait for focus so focus_poll_tick won't interfere with pause/unpause poll_until_focused(player) do - vp = player.viewport + vp = player.frame.viewport frame = vp.frame.path # Before pause: should be fast (1ms) since ROM is running @@ -830,8 +941,8 @@ def test_pause_switches_event_loop_speed unless ms_resumed == 1 xvfb_screenshot("pause_resume_fail") $stderr.puts "FAIL: expected thread_timer_ms=1 after resume, got \#{ms_resumed}" - $stderr.puts "input_focus?=\#{player.viewport.renderer.input_focus?}" - $stderr.puts "paused=\#{player.instance_variable_get(:@paused)}" + $stderr.puts "input_focus?=\#{player.frame.viewport.renderer.input_focus?}" + $stderr.puts "paused=\#{player.frame.paused?}" exit 1 end @@ -866,11 +977,11 @@ def test_pause_on_focus_loss require "gemba" require "support/player_helpers" - player = Gemba::Player.new("#{TEST_ROM}") + player = Gemba::AppController.new("#{TEST_ROM}") app = player.app poll_until_ready(player) do - renderer = player.viewport.renderer + renderer = player.frame.viewport.renderer # Ensure the window has focus before testing focus *loss* poll_until_focused(player) do @@ -903,8 +1014,8 @@ def test_pause_on_focus_loss # production (Tk's mainloop pumps Cocoa) but is untested in CI. renderer.show_window renderer.raise_window - app.command(:event, 'generate', player.viewport.frame.path, '', keysym: 'p') - app.command(:event, 'generate', player.viewport.frame.path, '', keysym: 'p') + app.command(:event, 'generate', player.frame.viewport.frame.path, '', keysym: 'p') + app.command(:event, 'generate', player.frame.viewport.frame.path, '', keysym: 'p') app.after(100) do ms_regained = app.interp.thread_timer_ms @@ -941,14 +1052,14 @@ def test_rom_does_not_start_paused require "gemba" require "support/player_helpers" - player = Gemba::Player.new("#{TEST_ROM}") + player = Gemba::AppController.new("#{TEST_ROM}") app = player.app poll_until_ready(player) do # Give focus poll a chance to fire (polls every 200ms) app.after(400) do ms = app.interp.thread_timer_ms - paused = player.instance_variable_get(:@paused) + paused = player.frame.paused? if paused $stderr.puts "FAIL: ROM started paused (thread_timer_ms=\#{ms})" exit 1 @@ -970,4 +1081,103 @@ def test_rom_does_not_start_paused assert success, "ROM started paused\n#{output.join("\n")}" assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}" end + + def test_question_hotkey_shows_help_window + code = <<~RUBY + require "gemba" + + player = Gemba::AppController.new + app = player.app + + app.after(100) do + app.tcl_eval("focus -force .") + app.update + app.tcl_eval("event generate . ") + app.update + state = app.tcl_eval("wm state .help_window") + puts state == 'normal' ? "PASS" : "FAIL: help window not visible (state=\#{state})" + player.running = false + end + + player.run + RUBY + + success, stdout, stderr, _status = tk_subprocess(code) + + output = [] + output << "STDOUT:\n#{stdout}" unless stdout.empty? + output << "STDERR:\n#{stderr}" unless stderr.empty? + + assert success, "? hotkey should show help window\n#{output.join("\n")}" + assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}" + end + + def test_question_hotkey_toggles_help_window + code = <<~RUBY + require "gemba" + + player = Gemba::AppController.new + app = player.app + + app.after(100) do + app.tcl_eval("focus -force .") + app.update + # First press — show + app.tcl_eval("event generate . ") + app.update + # Second press — hide + app.tcl_eval("event generate . ") + app.update + state = app.tcl_eval("wm state .help_window") + puts state == 'withdrawn' ? "PASS" : "FAIL: help window still visible after second ? press (state=\#{state})" + player.running = false + end + + player.run + RUBY + + success, stdout, stderr, _status = tk_subprocess(code) + + output = [] + output << "STDOUT:\n#{stdout}" unless stdout.empty? + output << "STDERR:\n#{stderr}" unless stderr.empty? + + assert success, "second ? press should hide help window\n#{output.join("\n")}" + assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}" + end + + def test_help_window_hidden_in_fullscreen + code = <<~RUBY + require "gemba" + + player = Gemba::AppController.new + app = player.app + + app.after(100) do + app.tcl_eval("focus -force .") + app.update + # Go fullscreen via bus + Gemba.bus.emit(:request_fullscreen) + app.update + # Press ? — should be suppressed + app.tcl_eval("event generate . ") + app.update + exists = app.tcl_eval("winfo exists .help_window") + state = exists == '1' ? app.tcl_eval("wm state .help_window") : 'withdrawn' + puts state != 'normal' ? "PASS" : "FAIL: help window visible in fullscreen" + player.running = false + end + + player.run + RUBY + + success, stdout, stderr, _status = tk_subprocess(code) + + output = [] + output << "STDOUT:\n#{stdout}" unless stdout.empty? + output << "STDERR:\n#{stderr}" unless stderr.empty? + + assert success, "? hotkey should be suppressed in fullscreen\n#{output.join("\n")}" + assert_includes stdout, "PASS", "Expected PASS in output\n#{output.join("\n")}" + end end diff --git a/test/test_bios.rb b/test/test_bios.rb new file mode 100644 index 0000000..6cf7af2 --- /dev/null +++ b/test/test_bios.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "tmpdir" +require "fileutils" +require "gemba/headless" + +class TestBios < Minitest::Test + FAKE_BIOS = File.expand_path("fixtures/fake_bios.bin", __dir__) + + def setup + @dir = Dir.mktmpdir("gemba-bios-test") + end + + def teardown + FileUtils.rm_rf(@dir) + end + + # -- exists? / valid? ------------------------------------------------------- + + def test_exists_true_for_real_file + bios = Gemba::Bios.new(path: FAKE_BIOS) + assert bios.exists? + end + + def test_exists_false_for_missing_file + bios = Gemba::Bios.new(path: File.join(@dir, "no_such.bin")) + refute bios.exists? + end + + def test_valid_for_correct_size + bios = Gemba::Bios.new(path: FAKE_BIOS) + assert_equal Gemba::Bios::EXPECTED_SIZE, bios.size + assert bios.valid? + end + + def test_invalid_for_wrong_size + path = File.join(@dir, "small.bin") + File.binwrite(path, "\x00" * 100) + bios = Gemba::Bios.new(path: path) + refute bios.valid? + end + + def test_invalid_for_missing_file + bios = Gemba::Bios.new(path: File.join(@dir, "missing.bin")) + refute bios.valid? + end + + # -- filename --------------------------------------------------------------- + + def test_filename_returns_basename + bios = Gemba::Bios.new(path: FAKE_BIOS) + assert_equal "fake_bios.bin", bios.filename + end + + # -- checksum --------------------------------------------------------------- + + def test_checksum_nil_for_invalid_file + bios = Gemba::Bios.new(path: File.join(@dir, "missing.bin")) + assert_nil bios.checksum + end + + def test_checksum_returns_integer_for_valid_file + bios = Gemba::Bios.new(path: FAKE_BIOS) + assert_kind_of Integer, bios.checksum + end + + def test_checksum_is_memoized + bios = Gemba::Bios.new(path: FAKE_BIOS) + c1 = bios.checksum + c2 = bios.checksum + assert_equal c1, c2 + assert_same c1, c2 # same object, not just equal + end + + # -- known? / official? / label --------------------------------------------- + + def test_fake_bios_is_not_official + bios = Gemba::Bios.new(path: FAKE_BIOS) + refute bios.official? + refute bios.ds_mode? + refute bios.known? + end + + def test_label_unknown_for_fake_bios + bios = Gemba::Bios.new(path: FAKE_BIOS) + assert_equal "Unknown BIOS", bios.label + end + + # -- status_text ------------------------------------------------------------ + + def test_status_text_includes_size_for_valid_file + bios = Gemba::Bios.new(path: FAKE_BIOS) + assert_includes bios.status_text, "16384" + end + + def test_status_text_not_found_for_missing_file + bios = Gemba::Bios.new(path: File.join(@dir, "gone.bin")) + assert_includes bios.status_text, "not found" + end + + def test_status_text_invalid_size_for_wrong_size + path = File.join(@dir, "wrong.bin") + File.binwrite(path, "\x00" * 512) + bios = Gemba::Bios.new(path: path) + assert_includes bios.status_text, "Invalid size" + end + + # -- from_config_name ------------------------------------------------------- + + def test_from_config_name_nil_returns_nil + assert_nil Gemba::Bios.from_config_name(nil) + end + + def test_from_config_name_empty_returns_nil + assert_nil Gemba::Bios.from_config_name("") + end + + def test_from_config_name_builds_path_under_bios_dir + bios = Gemba::Bios.from_config_name("gba_bios.bin") + assert_equal File.join(Gemba::Config.bios_dir, "gba_bios.bin"), bios.path + end +end diff --git a/test/test_boxart_fetcher.rb b/test/test_boxart_fetcher.rb new file mode 100644 index 0000000..3a9f5bd --- /dev/null +++ b/test/test_boxart_fetcher.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "shared/tk_test_helper" +require "tmpdir" +require "fileutils" +require "gemba/headless" +require "gemba/headless" + +class TestBoxartFetcher < Minitest::Test + include TeekTestHelper + + FAKE_PNG = "\x89PNG\r\n\x1a\n fake image data".b + + # Minimal backend that returns a fixed URL for known codes + class StubBackend + def url_for(game_code) + case game_code + when "AGB-AXVE" + "https://example.com/boxart/pokemon_ruby.png" + when "AGB-BPEE" + "https://example.com/boxart/pokemon_emerald.png" + else + nil + end + end + end + + def setup + @tmpdir = Dir.mktmpdir("boxart_test") + @backend = StubBackend.new + end + + def teardown + FileUtils.rm_rf(@tmpdir) + end + + def test_cached_path + fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: @tmpdir, backend: @backend) + expected = File.join(@tmpdir, "AGB-AXVE", "boxart.png") + assert_equal expected, fetcher.cached_path("AGB-AXVE") + end + + def test_cached_returns_false_when_not_cached + fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: @tmpdir, backend: @backend) + refute fetcher.cached?("AGB-AXVE") + end + + def test_cached_returns_true_when_file_exists + dir = File.join(@tmpdir, "AGB-AXVE") + FileUtils.mkdir_p(dir) + File.binwrite(File.join(dir, "boxart.png"), FAKE_PNG) + + fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: @tmpdir, backend: @backend) + assert fetcher.cached?("AGB-AXVE") + end + + def test_fetch_returns_cached_path_immediately_when_cached + dir = File.join(@tmpdir, "AGB-AXVE") + FileUtils.mkdir_p(dir) + cached = File.join(dir, "boxart.png") + File.binwrite(cached, FAKE_PNG) + + fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: @tmpdir, backend: @backend) + result = nil + fetcher.fetch("AGB-AXVE") { |path| result = path } + + assert_equal cached, result + end + + def test_fetch_does_nothing_for_unknown_game_code + fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: @tmpdir, backend: @backend) + called = false + fetcher.fetch("AGB-ZZZZ") { |_| called = true } + refute called + end + + def test_fetch_does_nothing_with_null_backend + null_fetcher = Gemba::BoxartFetcher.new( + app: nil, cache_dir: @tmpdir, + backend: Gemba::BoxartFetcher::NullBackend.new + ) + called = false + null_fetcher.fetch("AGB-AXVE") { |_| called = true } + refute called + end + + def test_fetch_does_nothing_without_block + fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: @tmpdir, backend: @backend) + fetcher.fetch("AGB-AXVE") + end + + def test_fetch_downloads_and_caches + assert_tk_app("boxart fetch downloads and caches") do + require "tmpdir" + require "webmock" + require "gemba/headless" + WebMock.enable! + WebMock.stub_request(:get, "https://example.com/boxart/pokemon_ruby.png") + .to_return(status: 200, body: "\x89PNG fake".b, headers: { "Content-Type" => "image/png" }) + + tmpdir = Dir.mktmpdir("boxart_test") + backend = Struct.new(:url) { def url_for(_) = url }.new("https://example.com/boxart/pokemon_ruby.png") + fetcher = Gemba::BoxartFetcher.new(app: app, cache_dir: tmpdir, backend: backend) + result = nil + done = false + + fetcher.fetch("AGB-AXVE") do |path| + result = path + done = true + end + + wait_until(timeout: 5.0) { done } + + assert done, "Fetch did not complete" + assert_equal fetcher.cached_path("AGB-AXVE"), result + assert File.exist?(result), "Cached file should exist" + assert fetcher.cached?("AGB-AXVE") + + FileUtils.rm_rf(tmpdir) + WebMock.disable! + end + end + + def test_fetch_handles_404 + assert_tk_app("boxart fetch handles 404 gracefully") do + require "tmpdir" + require "webmock" + require "gemba/headless" + WebMock.enable! + WebMock.stub_request(:get, "https://example.com/boxart/pokemon_ruby.png") + .to_return(status: 404, body: "Not Found") + + tmpdir = Dir.mktmpdir("boxart_test") + backend = Struct.new(:url) { def url_for(_) = url }.new("https://example.com/boxart/pokemon_ruby.png") + fetcher = Gemba::BoxartFetcher.new(app: app, cache_dir: tmpdir, backend: backend) + called = false + + fetcher.fetch("AGB-AXVE") { |_| called = true } + + wait_until(timeout: 2.0) { false } # let background thread finish + + refute called, "Callback should not fire on 404" + refute fetcher.cached?("AGB-AXVE") + + FileUtils.rm_rf(tmpdir) + WebMock.disable! + end + end +end diff --git a/test/test_cli.rb b/test/test_cli.rb index 9d54e9a..368215b 100644 --- a/test/test_cli.rb +++ b/test/test_cli.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true require "minitest/autorun" -require "gemba" -require_relative "../lib/gemba/version" -require_relative "../lib/gemba/cli" +require "gemba/headless" class TestCLI < Minitest::Test def parse_play(args) diff --git a/test/test_config.rb b/test/test_config.rb index a49ed25..151cccc 100644 --- a/test/test_config.rb +++ b/test/test_config.rb @@ -3,8 +3,7 @@ require "minitest/autorun" require "tmpdir" require "json" -require_relative "../lib/gemba/config" -require_relative "../lib/gemba/version" +require "gemba/headless" class TestMGBAConfig < Minitest::Test def setup diff --git a/test/test_core.rb b/test/test_core.rb index af73701..c91ffdb 100644 --- a/test/test_core.rb +++ b/test/test_core.rb @@ -384,6 +384,68 @@ def test_rewind_init_raises_on_invalid_capacity assert_raises(ArgumentError) { @core.rewind_init(-1) } end + # -- GB / GBC ROM loading ---------------------------------------------------- + + GB_ROM = File.expand_path("fixtures/test.gb", __dir__) + GBC_ROM = File.expand_path("fixtures/test.gbc", __dir__) + + def test_gb_rom_dimensions + core = Gemba::Core.new(GB_ROM) + assert_equal 160, core.width + assert_equal 144, core.height + assert_equal "GB", core.platform + core.destroy + end + + def test_gb_rom_video_buffer_size + core = Gemba::Core.new(GB_ROM) + core.run_frame + buf = core.video_buffer_argb + assert_equal 160 * 144 * 4, buf.bytesize + core.destroy + end + + def test_gb_rom_title + core = Gemba::Core.new(GB_ROM) + assert_equal "GEMBAGB", core.title + core.destroy + end + + def test_gb_rom_runs_frames + core = Gemba::Core.new(GB_ROM) + 10.times { core.run_frame } + assert_equal 160 * 144 * 4, core.video_buffer.bytesize + core.destroy + end + + def test_gbc_rom_dimensions + core = Gemba::Core.new(GBC_ROM) + assert_equal 160, core.width + assert_equal 144, core.height + core.destroy + end + + def test_gbc_rom_video_buffer_size + core = Gemba::Core.new(GBC_ROM) + core.run_frame + buf = core.video_buffer_argb + assert_equal 160 * 144 * 4, buf.bytesize + core.destroy + end + + def test_gbc_rom_title + core = Gemba::Core.new(GBC_ROM) + assert_equal "GEMBAGBC", core.title + core.destroy + end + + def test_gbc_rom_runs_frames + core = Gemba::Core.new(GBC_ROM) + 10.times { core.run_frame } + assert_equal 160 * 144 * 4, core.video_buffer.bytesize + core.destroy + end + # -- Error handling ---------------------------------------------------------- def test_nonexistent_file diff --git a/test/test_event_bus.rb b/test/test_event_bus.rb new file mode 100644 index 0000000..94fb91b --- /dev/null +++ b/test/test_event_bus.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "gemba/headless" + +class TestEventBus < Minitest::Test + def setup + @bus = Gemba::EventBus.new + end + + def test_on_and_emit + received = nil + @bus.on(:ping) { |val| received = val } + @bus.emit(:ping, 42) + assert_equal 42, received + end + + def test_emit_with_no_subscribers_is_noop + @bus.emit(:ghost, 1, 2, 3) # should not raise + end + + def test_multiple_subscribers + results = [] + @bus.on(:tick) { |v| results << "a:#{v}" } + @bus.on(:tick) { |v| results << "b:#{v}" } + @bus.emit(:tick, 7) + assert_equal ["a:7", "b:7"], results + end + + def test_different_events_are_independent + a = nil + b = nil + @bus.on(:foo) { |v| a = v } + @bus.on(:bar) { |v| b = v } + @bus.emit(:foo, 1) + assert_equal 1, a + assert_nil b + end + + def test_emit_multiple_args + received = nil + @bus.on(:multi) { |x, y| received = [x, y] } + @bus.emit(:multi, :a, :b) + assert_equal [:a, :b], received + end + + def test_emit_with_kwargs + received = nil + @bus.on(:kw) { |name:, val:| received = { name: name, val: val } } + @bus.emit(:kw, name: "scale", val: 3) + assert_equal({ name: "scale", val: 3 }, received) + end + + def test_off_removes_subscriber + received = [] + block = @bus.on(:evt) { |v| received << v } + @bus.emit(:evt, 1) + @bus.off(:evt, block) + @bus.emit(:evt, 2) + assert_equal [1], received + end + + def test_on_returns_block_for_later_off + block = @bus.on(:x) { } + assert_instance_of Proc, block + end + + # -- Module-level accessor ------------------------------------------------ + + def test_gemba_bus_auto_creates + Gemba.bus = nil + bus = Gemba.bus + assert_instance_of Gemba::EventBus, bus + ensure + Gemba.bus = nil + end + + def test_gemba_bus_setter + custom = Gemba::EventBus.new + Gemba.bus = custom + assert_same custom, Gemba.bus + ensure + Gemba.bus = nil + end + + # -- BusEmitter mixin ----------------------------------------------------- + + def test_bus_emitter_emits_to_gemba_bus + Gemba.bus = @bus + klass = Class.new { include Gemba::BusEmitter; public :emit } + obj = klass.new + + received = nil + @bus.on(:test_event) { |v| received = v } + obj.emit(:test_event, 99) + assert_equal 99, received + ensure + Gemba.bus = nil + end +end diff --git a/test/test_game_index.rb b/test/test_game_index.rb new file mode 100644 index 0000000..e9a8ab0 --- /dev/null +++ b/test/test_game_index.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "gemba/headless" + +class TestGameIndex < Minitest::Test + def setup + Gemba::GameIndex.reset! + end + + def test_lookup_known_gba_game + name = Gemba::GameIndex.lookup("AGB-AXVE") + assert_includes name, "Pokemon" + assert_includes name, "Ruby" + end + + def test_lookup_returns_nil_for_unknown_code + assert_nil Gemba::GameIndex.lookup("AGB-ZZZZ") + end + + def test_lookup_returns_nil_for_nil_input + assert_nil Gemba::GameIndex.lookup(nil) + end + + def test_lookup_returns_nil_for_empty_string + assert_nil Gemba::GameIndex.lookup("") + end + + def test_lookup_returns_nil_for_unknown_platform + assert_nil Gemba::GameIndex.lookup("XYZ-AAAA") + end + + def test_lookup_gb_game + # GB has very few entries but Pokemon Red should be there + name = Gemba::GameIndex.lookup("DMG-APAU") + assert_includes name, "Pokemon" if name + end + + def test_reset_clears_cache + Gemba::GameIndex.lookup("AGB-AXVE") + Gemba::GameIndex.reset! + # Should still work after reset (reloads lazily) + name = Gemba::GameIndex.lookup("AGB-AXVE") + assert_includes name, "Pokemon" + end +end diff --git a/test/test_game_picker_frame.rb b/test/test_game_picker_frame.rb new file mode 100644 index 0000000..27e06a9 --- /dev/null +++ b/test/test_game_picker_frame.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "shared/tk_test_helper" + +class TestGamePickerFrame < Minitest::Test + include TeekTestHelper + + # Each test gets a fresh picker. Since Teek::TestWorker persists across tests, + # we must destroy .game_picker at the end of each test so the next test can + # recreate it cleanly (ttk::frame fails if the path already exists). + def cleanup_picker(app) + app.command(:destroy, '.game_picker') rescue nil + end + + def test_empty_library_shows_all_hollow_cards + assert_tk_app("empty library shows all hollow cards") do + require "gemba/headless" + + lib = Struct.new(:roms) { def all = roms }.new([]) + picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib) + picker.show + + 16.times do |i| + title = app.command(".game_picker.card#{i}.title", :cget, '-text') + assert_equal '', title, "Card #{i} title should be empty" + + img = app.command(".game_picker.card#{i}.img", :cget, '-image') + assert_equal 'boxart_placeholder', img, "Card #{i} should show placeholder image" + + bg = app.command(".game_picker.card#{i}", :cget, '-bg') + refute_equal '#2a2a2a', bg, "Card #{i} should not have populated background" + end + + picker.cleanup + app.command(:destroy, '.game_picker') rescue nil + end + end + + def test_populated_card_shows_title_and_platform + assert_tk_app("populated card shows title and platform text") do + require "gemba/headless" + + rom = { 'title' => 'Pokemon Ruby', 'platform' => 'gba', + 'game_code' => 'AGB-AXVE', 'path' => '/games/ruby.gba' } + lib = Struct.new(:roms) { def all = roms }.new([rom]) + picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib) + picker.show + + assert_equal 'Pokemon Ruby', app.command('.game_picker.card0.title', :cget, '-text') + assert_equal 'GBA', app.command('.game_picker.card0.plat', :cget, '-text') + + picker.cleanup + app.command(:destroy, '.game_picker') rescue nil + end + end + + def test_platform_is_uppercased + assert_tk_app("platform label is uppercased") do + require "gemba/headless" + + rom = { 'title' => 'Tetris', 'platform' => 'gbc', 'path' => '/games/tetris.gbc' } + lib = Struct.new(:roms) { def all = roms }.new([rom]) + picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib) + picker.show + + assert_equal 'GBC', app.command('.game_picker.card0.plat', :cget, '-text') + + picker.cleanup + app.command(:destroy, '.game_picker') rescue nil + end + end + + def test_title_falls_back_to_rom_id_when_title_missing + assert_tk_app("title falls back to rom_id when title key absent") do + require "gemba/headless" + + rom = { 'rom_id' => 'MY-ROM', 'platform' => 'gba', 'path' => '/games/x.gba' } + lib = Struct.new(:roms) { def all = roms }.new([rom]) + picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib) + picker.show + + assert_equal 'MY-ROM', app.command('.game_picker.card0.title', :cget, '-text') + + picker.cleanup + app.command(:destroy, '.game_picker') rescue nil + end + end + + def test_populated_card_background_differs_from_hollow + assert_tk_app("populated card has different background color than hollow card") do + require "gemba/headless" + + rom = { 'title' => 'Test Game', 'platform' => 'gba', 'path' => '/games/test.gba' } + lib = Struct.new(:roms) { def all = roms }.new([rom]) + picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib) + picker.show + + pop_bg = app.command('.game_picker.card0', :cget, '-bg') + hollow_bg = app.command('.game_picker.card1', :cget, '-bg') + + assert_equal '#2a2a2a', pop_bg, "Populated card background" + refute_equal pop_bg, hollow_bg, "Hollow card should differ from populated" + + picker.cleanup + app.command(:destroy, '.game_picker') rescue nil + end + end + + def test_multiple_roms_populate_correct_cards_in_order + assert_tk_app("multiple ROMs fill cards in order; remainder are hollow") do + require "gemba/headless" + + roms = [ + { 'title' => 'Alpha', 'platform' => 'gba', 'path' => '/a.gba' }, + { 'title' => 'Beta', 'platform' => 'gbc', 'path' => '/b.gbc' }, + ] + lib = Struct.new(:roms) { def all = roms }.new(roms) + picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib) + picker.show + + assert_equal 'Alpha', app.command('.game_picker.card0.title', :cget, '-text') + assert_equal 'GBA', app.command('.game_picker.card0.plat', :cget, '-text') + assert_equal 'Beta', app.command('.game_picker.card1.title', :cget, '-text') + assert_equal 'GBC', app.command('.game_picker.card1.plat', :cget, '-text') + assert_equal '', app.command('.game_picker.card2.title', :cget, '-text') + + picker.cleanup + app.command(:destroy, '.game_picker') rescue nil + end + end + + def test_pre_cached_boxart_shown_immediately_without_network + assert_tk_app("pre-cached boxart image is set on the card without any network fetch") do + require "gemba/headless" + require "gemba/headless" + require "tmpdir" + require "fileutils" + + game_code = 'AGB-AXVE' + tmpdir = Dir.mktmpdir('picker_test') + cache_dir = File.join(tmpdir, game_code) + FileUtils.mkdir_p(cache_dir) + # Re-use the placeholder PNG — it's a real PNG Tk already loads successfully + FileUtils.cp(Gemba::GamePickerFrame::PLACEHOLDER_PNG, File.join(cache_dir, 'boxart.png')) + + # Backend that would return a URL, but the cache hit means it's never called + backend = Struct.new(:url) { def url_for(_) = url }.new('https://example.com/fake.png') + fetcher = Gemba::BoxartFetcher.new(app: app, cache_dir: tmpdir, backend: backend) + + rom = { 'title' => 'Pokemon Ruby', 'platform' => 'gba', + 'game_code' => game_code, 'path' => '/fake.gba' } + lib = Struct.new(:roms) { def all = roms }.new([rom]) + picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib, boxart_fetcher: fetcher) + picker.show + + img_name = app.command('.game_picker.card0.img', :cget, '-image') + assert_equal "boxart_#{game_code}", img_name, + "Card should display cached boxart image, not the placeholder" + + FileUtils.rm_rf(tmpdir) + picker.cleanup + app.command(:destroy, '.game_picker') rescue nil + end + end + + def test_no_fetcher_leaves_placeholder_on_card + assert_tk_app("card with game_code but no fetcher stays on placeholder") do + require "gemba/headless" + + rom = { 'title' => 'Some Game', 'platform' => 'gba', + 'game_code' => 'AGB-TEST', 'path' => '/games/some.gba' } + lib = Struct.new(:roms) { def all = roms }.new([rom]) + # No boxart_fetcher passed + picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib) + picker.show + + img_name = app.command('.game_picker.card0.img', :cget, '-image') + assert_equal 'boxart_placeholder', img_name + + picker.cleanup + app.command(:destroy, '.game_picker') rescue nil + end + end + + # -- Quick Load context menu ------------------------------------------------ + # Menu entry indices: 0=Play, 1=Quick Load, 2=Set Boxart, 3=separator, 4=Remove + + def test_quick_load_disabled_when_no_save_state + assert_tk_app("quick load menu entry is disabled when no .ss file exists") do + require "gemba/headless" + require "tmpdir" + + Dir.mktmpdir("picker_qs_test") do |tmpdir| + rom_id = "AGB-TEST-DEADBEEF" + rom = { 'title' => 'Test Game', 'platform' => 'gba', + 'rom_id' => rom_id, 'game_code' => 'AGB-TEST', 'path' => '/games/test.gba' } + lib = Struct.new(:roms) { def all = roms }.new([rom]) + + Gemba.user_config.states_dir = tmpdir + picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib) + picker.show + app.show + app.update + + # Suppress tk_popup so the right-click binding configures menu entries + # without posting the menu (no platform grab → no blocking app.update). + override_tk_popup do + app.tcl_eval("event generate .game_picker.card0 -x 10 -y 10") + app.update + end + + # index 1 = Quick Load + state = app.tcl_eval(".game_picker.card0.ctx entrycget 1 -state") + assert_equal 'disabled', state + + picker.cleanup + app.command(:destroy, '.game_picker') rescue nil + end + end + end + + def test_quick_load_enabled_when_save_state_exists + assert_tk_app("quick load menu entry is enabled when .ss file exists") do + require "gemba/headless" + require "tmpdir" + require "fileutils" + + fixture = File.expand_path("test/fixtures/test_quicksave.ss") + + Dir.mktmpdir("picker_qs_test") do |tmpdir| + rom_id = "AGB-TEST-DEADBEEF" + slot = Gemba.user_config.quick_save_slot + state_dir = File.join(tmpdir, rom_id) + FileUtils.mkdir_p(state_dir) + FileUtils.cp(fixture, File.join(state_dir, "state#{slot}.ss")) + + rom = { 'title' => 'Test Game', 'platform' => 'gba', + 'rom_id' => rom_id, 'game_code' => 'AGB-TEST', 'path' => '/games/test.gba' } + lib = Struct.new(:roms) { def all = roms }.new([rom]) + + Gemba.user_config.states_dir = tmpdir + picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib) + picker.show + app.show + app.update + + override_tk_popup do + app.tcl_eval("event generate .game_picker.card0 -x 10 -y 10") + app.update + end + + # index 1 = Quick Load + state = app.tcl_eval(".game_picker.card0.ctx entrycget 1 -state") + assert_equal 'normal', state + + picker.cleanup + app.command(:destroy, '.game_picker') rescue nil + end + end + end + + def test_quick_load_emits_rom_quick_load_event + assert_tk_app("clicking quick load emits :rom_quick_load with path and slot") do + require "gemba/headless" + require "tmpdir" + require "fileutils" + + fixture = File.expand_path("test/fixtures/test_quicksave.ss") + + Dir.mktmpdir("picker_qs_test") do |tmpdir| + rom_id = "AGB-TEST-DEADBEEF" + slot = Gemba.user_config.quick_save_slot + state_dir = File.join(tmpdir, rom_id) + FileUtils.mkdir_p(state_dir) + FileUtils.cp(fixture, File.join(state_dir, "state#{slot}.ss")) + + rom_path = '/games/test.gba' + rom = { 'title' => 'Test Game', 'platform' => 'gba', + 'rom_id' => rom_id, 'game_code' => 'AGB-TEST', 'path' => rom_path } + lib = Struct.new(:roms) { def all = roms }.new([rom]) + + received = nil + Gemba.bus.on(:rom_quick_load) { |**kwargs| received = kwargs } + + Gemba.user_config.states_dir = tmpdir + picker = Gemba::GamePickerFrame.new(app: app, rom_library: lib) + picker.show + app.show + app.update + + override_tk_popup do + app.tcl_eval("event generate .game_picker.card0 -x 10 -y 10") + app.update + end + + # Menu was built but not shown — invoke the Quick Load entry directly. + # index 1 = Quick Load + app.tcl_eval(".game_picker.card0.ctx invoke 1") + app.update + + assert_equal rom_path, received[:path] + assert_equal slot, received[:slot] + + picker.cleanup + app.command(:destroy, '.game_picker') rescue nil + end + end + end +end diff --git a/test/test_gamepad_map.rb b/test/test_gamepad_map.rb index 1caaf31..60ba3c8 100644 --- a/test/test_gamepad_map.rb +++ b/test/test_gamepad_map.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true require "minitest/autorun" -require "gemba" -require_relative "../lib/gemba/config" -require_relative "../lib/gemba/input_mappings" +require "gemba/headless" require_relative "support/input_mocks" class TestGamepadMap < Minitest::Test diff --git a/test/test_headless_player.rb b/test/test_headless_player.rb index a1b57c2..8a115d0 100644 --- a/test/test_headless_player.rb +++ b/test/test_headless_player.rb @@ -199,4 +199,21 @@ def test_rewind_deinit assert_equal 0, player.rewind_count end end + + # -- BIOS loading ----------------------------------------------------------- + + FAKE_BIOS = File.expand_path("fixtures/fake_bios.bin", __dir__) + + def test_bios_not_loaded_by_default + Gemba::HeadlessPlayer.open(TEST_ROM) do |player| + refute player.core.bios_loaded? + end + end + + def test_bios_loaded_when_path_given + skip "fake_bios.bin not present" unless File.exist?(FAKE_BIOS) + Gemba::HeadlessPlayer.open(TEST_ROM, bios_path: FAKE_BIOS) do |player| + assert player.core.bios_loaded? + end + end end diff --git a/test/test_help_window.rb b/test/test_help_window.rb new file mode 100644 index 0000000..b3cf2e6 --- /dev/null +++ b/test/test_help_window.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "shared/tk_test_helper" + +class TestHelpWindow < Minitest::Test + include TeekTestHelper + + # HelpWindow is wm transient to '.'. The TestWorker withdraws '.' after each + # test, so a transient child can't be shown unless we deiconify '.' first. + # app.show deiconifies the root window for tests that check visibility. + + def test_visible_after_show + assert_tk_app("help window is visible after show") do + require "gemba/headless" + + hotkeys = Struct.new(:m) { def key_for(a) = Gemba::HotkeyMap::DEFAULTS[a] }.new(nil) + win = Gemba::HelpWindow.new(app: app, hotkeys: hotkeys) + app.show + win.show + + assert win.visible?, "help window should be visible after show" + + win.hide + app.command(:destroy, Gemba::HelpWindow::TOP) rescue nil + end + end + + def test_hidden_after_hide + assert_tk_app("help window is hidden after hide") do + require "gemba/headless" + + hotkeys = Struct.new(:m) { def key_for(a) = Gemba::HotkeyMap::DEFAULTS[a] }.new(nil) + win = Gemba::HelpWindow.new(app: app, hotkeys: hotkeys) + app.show + win.show + win.hide + + refute win.visible?, "help window should not be visible after hide" + + app.command(:destroy, Gemba::HelpWindow::TOP) rescue nil + end + end + + def test_rows_show_action_labels + assert_tk_app("help window rows show translated action labels") do + require "gemba/headless" + + hotkeys = Struct.new(:m) { def key_for(a) = Gemba::HotkeyMap::DEFAULTS[a] }.new(nil) + win = Gemba::HelpWindow.new(app: app, hotkeys: hotkeys) + win.show + + text = app.command("#{Gemba::HelpWindow::TOP}.f.row_pause.act", :cget, '-text') + assert_equal 'Pause', text, "pause row should show 'Pause' label" + + win.hide + app.command(:destroy, Gemba::HelpWindow::TOP) rescue nil + end + end + + def test_rows_show_key_display + assert_tk_app("help window rows show formatted key names") do + require "gemba/headless" + + hotkeys = Struct.new(:m) { def key_for(a) = Gemba::HotkeyMap::DEFAULTS[a] }.new(nil) + win = Gemba::HelpWindow.new(app: app, hotkeys: hotkeys) + win.show + + # pause default is 'p' + text = app.command("#{Gemba::HelpWindow::TOP}.f.row_pause.key", :cget, '-text') + assert_equal 'p', text, "pause row key should show 'p'" + + # quick_save default is 'F5' + text = app.command("#{Gemba::HelpWindow::TOP}.f.row_quick_save.key", :cget, '-text') + assert_equal 'F5', text, "quick_save row key should show 'F5'" + + win.hide + app.command(:destroy, Gemba::HelpWindow::TOP) rescue nil + end + end +end diff --git a/test/test_hotkey_map.rb b/test/test_hotkey_map.rb index 9c77e33..37a5ed4 100644 --- a/test/test_hotkey_map.rb +++ b/test/test_hotkey_map.rb @@ -29,7 +29,7 @@ def reload! class TestHotkeyMap < Minitest::Test def setup - require "gemba/hotkey_map" + require "gemba/headless" end def make_map(hotkey_data = {}) diff --git a/test/test_input_recorder.rb b/test/test_input_recorder.rb index f8e2459..7ad6fb8 100644 --- a/test/test_input_recorder.rb +++ b/test/test_input_recorder.rb @@ -2,7 +2,7 @@ require "minitest/autorun" require "gemba/headless" -require "gemba/input_recorder" +require "gemba/headless" require "tmpdir" class TestInputRecorder < Minitest::Test diff --git a/test/test_input_replay_determinism.rb b/test/test_input_replay_determinism.rb index b989425..e922291 100644 --- a/test/test_input_replay_determinism.rb +++ b/test/test_input_replay_determinism.rb @@ -2,7 +2,7 @@ require "minitest/autorun" require "gemba/headless" -require "gemba/input_recorder" +require "gemba/headless" require "tmpdir" class TestInputReplayDeterminism < Minitest::Test diff --git a/test/test_input_replayer.rb b/test/test_input_replayer.rb index 9b99d0a..d99e133 100644 --- a/test/test_input_replayer.rb +++ b/test/test_input_replayer.rb @@ -2,7 +2,7 @@ require "minitest/autorun" require "gemba/headless" -require "gemba/input_recorder" +require "gemba/headless" require "tmpdir" class TestInputReplayer < Minitest::Test diff --git a/test/test_keyboard_map.rb b/test/test_keyboard_map.rb index 251f1d7..bb49b3c 100644 --- a/test/test_keyboard_map.rb +++ b/test/test_keyboard_map.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true require "minitest/autorun" -require "gemba" -require_relative "../lib/gemba/config" -require_relative "../lib/gemba/input_mappings" +require "gemba/headless" require_relative "support/input_mocks" class TestKeyboardMap < Minitest::Test diff --git a/test/test_libretro_backend.rb b/test/test_libretro_backend.rb new file mode 100644 index 0000000..f523066 --- /dev/null +++ b/test/test_libretro_backend.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "gemba/headless" + +class TestLibretroBackend < Minitest::Test + def setup + Gemba::GameIndex.reset! + @backend = Gemba::BoxartFetcher::LibretroBackend.new + end + + def test_url_for_known_gba_game + url = @backend.url_for("AGB-AXVE") + assert_match %r{thumbnails\.libretro\.com}, url + assert_match %r{Nintendo%20-%20Game%20Boy%20Advance}, url + assert_match %r{Named_Boxarts}, url + assert_match %r{Pokemon}, url + assert url.end_with?(".png") + end + + def test_url_for_unknown_game_returns_nil + assert_nil @backend.url_for("AGB-ZZZZ") + end + + def test_url_for_unknown_platform_returns_nil + assert_nil @backend.url_for("XYZ-AAAA") + end + + def test_url_encodes_special_characters + # Games with special chars (parentheses, ampersands, etc.) should be encoded + url = @backend.url_for("AGB-AXVE") + refute_includes url, " " # no raw spaces + end + + def test_url_for_gb_game + url = @backend.url_for("DMG-APAU") + if url # GB data is sparse + assert_match %r{Nintendo%20-%20Game%20Boy/}, url + end + end +end diff --git a/test/test_locale.rb b/test/test_locale.rb index 4e3fd96..76812b4 100644 --- a/test/test_locale.rb +++ b/test/test_locale.rb @@ -2,7 +2,7 @@ require "minitest/autorun" require "yaml" -require_relative "../lib/gemba/locale" +require "gemba/headless" class TestMGBALocale < Minitest::Test # -- Loading --------------------------------------------------------------- diff --git a/test/test_logging.rb b/test/test_logging.rb index 36fc319..173959e 100644 --- a/test/test_logging.rb +++ b/test/test_logging.rb @@ -3,7 +3,7 @@ require "minitest/autorun" require "tmpdir" require "fileutils" -require_relative "../lib/gemba/logging" +require "gemba/headless" class TestLogging < Minitest::Test def setup diff --git a/test/test_overlay_renderer.rb b/test/test_overlay_renderer.rb index 115b2df..e195d1f 100644 --- a/test/test_overlay_renderer.rb +++ b/test/test_overlay_renderer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "minitest/autorun" -require_relative "../lib/gemba/overlay_renderer" +require "gemba/headless" class TestOverlayRenderer < Minitest::Test class MockTexture diff --git a/test/test_platform.rb b/test/test_platform.rb new file mode 100644 index 0000000..d632e50 --- /dev/null +++ b/test/test_platform.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "gemba/headless" + +class TestPlatform < Minitest::Test + # -- Factory --------------------------------------------------------------- + + def test_for_gba + core = MockCore.new("GBA") + platform = Gemba::Platform.for(core) + assert_instance_of Gemba::Platform::GBA, platform + end + + def test_for_gb + core = MockCore.new("GB") + platform = Gemba::Platform.for(core) + assert_instance_of Gemba::Platform::GB, platform + end + + def test_for_gbc + core = MockCore.new("GBC") + platform = Gemba::Platform.for(core) + assert_instance_of Gemba::Platform::GBC, platform + end + + def test_for_unknown_defaults_to_gb + core = MockCore.new("Unknown") + platform = Gemba::Platform.for(core) + assert_instance_of Gemba::Platform::GB, platform + end + + def test_default_is_gba + platform = Gemba::Platform.default + assert_instance_of Gemba::Platform::GBA, platform + end + + # -- GBA ------------------------------------------------------------------- + + def test_gba_resolution + p = Gemba::Platform::GBA.new + assert_equal 240, p.width + assert_equal 160, p.height + end + + def test_gba_fps + p = Gemba::Platform::GBA.new + assert_in_delta 59.7272, p.fps, 0.001 + end + + def test_gba_fps_fraction + num, den = Gemba::Platform::GBA.new.fps_fraction + assert_in_delta 59.7272, num.to_f / den, 0.001 + end + + def test_gba_aspect + assert_equal [3, 2], Gemba::Platform::GBA.new.aspect + end + + def test_gba_name + assert_equal "Game Boy Advance", Gemba::Platform::GBA.new.name + assert_equal "GBA", Gemba::Platform::GBA.new.short_name + end + + def test_gba_buttons_include_lr + buttons = Gemba::Platform::GBA.new.buttons + assert_includes buttons, :l + assert_includes buttons, :r + assert_equal 10, buttons.size + end + + def test_gba_thumb_size + assert_equal [120, 80], Gemba::Platform::GBA.new.thumb_size + end + + # -- GB -------------------------------------------------------------------- + + def test_gb_resolution + p = Gemba::Platform::GB.new + assert_equal 160, p.width + assert_equal 144, p.height + end + + def test_gb_fps + assert_in_delta 59.7275, Gemba::Platform::GB.new.fps, 0.001 + end + + def test_gb_fps_fraction + num, den = Gemba::Platform::GB.new.fps_fraction + assert_in_delta 59.7275, num.to_f / den, 0.001 + end + + def test_gb_aspect + assert_equal [10, 9], Gemba::Platform::GB.new.aspect + end + + def test_gb_name + assert_equal "Game Boy", Gemba::Platform::GB.new.name + assert_equal "GB", Gemba::Platform::GB.new.short_name + end + + def test_gb_buttons_no_lr + buttons = Gemba::Platform::GB.new.buttons + refute_includes buttons, :l + refute_includes buttons, :r + assert_equal 8, buttons.size + end + + def test_gb_thumb_size + assert_equal [80, 72], Gemba::Platform::GB.new.thumb_size + end + + # -- GBC ------------------------------------------------------------------- + + def test_gbc_resolution_same_as_gb + p = Gemba::Platform::GBC.new + assert_equal 160, p.width + assert_equal 144, p.height + end + + def test_gbc_name_differs_from_gb + assert_equal "Game Boy Color", Gemba::Platform::GBC.new.name + assert_equal "GBC", Gemba::Platform::GBC.new.short_name + end + + def test_gbc_buttons_no_lr + buttons = Gemba::Platform::GBC.new.buttons + refute_includes buttons, :l + refute_includes buttons, :r + end + + # -- Equality -------------------------------------------------------------- + + def test_same_platform_equal + assert_equal Gemba::Platform::GBA.new, Gemba::Platform::GBA.new + assert_equal Gemba::Platform::GB.new, Gemba::Platform::GB.new + assert_equal Gemba::Platform::GBC.new, Gemba::Platform::GBC.new + end + + def test_different_platforms_not_equal + refute_equal Gemba::Platform::GBA.new, Gemba::Platform::GB.new + refute_equal Gemba::Platform::GBA.new, Gemba::Platform::GBC.new + refute_equal Gemba::Platform::GB.new, Gemba::Platform::GBC.new + end + + private + + MockCore = Struct.new(:platform) +end diff --git a/test/test_replay_player.rb b/test/test_replay_player.rb index 6c3c40c..1665f51 100644 --- a/test/test_replay_player.rb +++ b/test/test_replay_player.rb @@ -17,7 +17,7 @@ def self.gir_fixture_dir at_exit { FileUtils.rm_rf(dir) } require "gemba/headless" - require "gemba/input_recorder" + require "gemba/headless" gir_path = File.join(dir, "pong_test.gir") Gemba::HeadlessPlayer.open(PONG_ROM) do |player| diff --git a/test/test_rom_info.rb b/test/test_rom_info.rb new file mode 100644 index 0000000..3d5efc0 --- /dev/null +++ b/test/test_rom_info.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "tmpdir" +require "fileutils" +require "gemba/headless" +require "gemba/headless" +require "gemba/headless" +require "gemba/headless" +require "gemba/headless" +require "gemba/headless" + +class TestRomInfo < Minitest::Test + ROM = { + 'rom_id' => 'AGB_AXVE-DEADBEEF', + 'title' => 'Pokemon Ruby', + 'platform' => 'gba', + 'game_code' => 'AGB-AXVE', + 'path' => '/games/ruby.gba', + }.freeze + + def test_from_rom_sets_basic_fields + info = Gemba::RomInfo.from_rom(ROM) + assert_equal 'AGB_AXVE-DEADBEEF', info.rom_id + assert_equal 'Pokemon Ruby', info.title + assert_equal 'GBA', info.platform + assert_equal 'AGB-AXVE', info.game_code + assert_equal '/games/ruby.gba', info.path + end + + def test_platform_is_uppercased + info = Gemba::RomInfo.from_rom(ROM.merge('platform' => 'gbc')) + assert_equal 'GBC', info.platform + end + + def test_title_falls_back_to_rom_id + rom = ROM.merge('title' => nil) + info = Gemba::RomInfo.from_rom(rom) + assert_equal 'AGB_AXVE-DEADBEEF', info.title + end + + def test_no_fetcher_or_overrides_yields_nil_boxart_fields + info = Gemba::RomInfo.from_rom(ROM) + assert_nil info.cached_boxart_path + assert_nil info.custom_boxart_path + assert_nil info.boxart_path + end + + def test_has_official_entry_true_for_known_game_code + # AGB-AXVE is Pokemon Ruby — present in gba_games.json + info = Gemba::RomInfo.from_rom(ROM) + assert info.has_official_entry, "Known game_code should have official entry" + end + + def test_has_official_entry_false_for_unknown_game_code + rom = ROM.merge('game_code' => 'AGB-ZZZZ') + info = Gemba::RomInfo.from_rom(rom) + refute info.has_official_entry + end + + def test_has_official_entry_false_when_no_game_code + rom = ROM.merge('game_code' => nil) + info = Gemba::RomInfo.from_rom(rom) + refute info.has_official_entry + end + + def test_boxart_path_returns_custom_when_file_exists + Dir.mktmpdir do |dir| + ENV['GEMBA_CONFIG_DIR'] = dir + custom = File.join(dir, "custom.png") + File.write(custom, "fake") + overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json")) + overrides.set_custom_boxart('AGB_AXVE-DEADBEEF', custom) + + info = Gemba::RomInfo.from_rom(ROM, overrides: overrides) + assert_equal File.join(dir, 'boxart', 'AGB_AXVE-DEADBEEF', 'custom.png'), info.boxart_path + ensure + ENV.delete('GEMBA_CONFIG_DIR') + end + end + + def test_boxart_path_falls_back_to_cache_when_no_custom + Dir.mktmpdir do |dir| + ENV['GEMBA_CONFIG_DIR'] = dir + cache_dir = File.join(dir, "boxart") + fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: cache_dir, + backend: Gemba::BoxartFetcher::NullBackend.new) + overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json")) + + # Pre-populate the cache + cached = fetcher.cached_path('AGB-AXVE') + FileUtils.mkdir_p(File.dirname(cached)) + File.write(cached, "fake") + + info = Gemba::RomInfo.from_rom(ROM, fetcher: fetcher, overrides: overrides) + assert_equal cached, info.boxart_path + ensure + ENV.delete('GEMBA_CONFIG_DIR') + end + end + + def test_boxart_path_nil_when_neither_present + Dir.mktmpdir do |dir| + ENV['GEMBA_CONFIG_DIR'] = dir + fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: File.join(dir, "boxart"), + backend: Gemba::BoxartFetcher::NullBackend.new) + overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json")) + + info = Gemba::RomInfo.from_rom(ROM, fetcher: fetcher, overrides: overrides) + assert_nil info.boxart_path + ensure + ENV.delete('GEMBA_CONFIG_DIR') + end + end + + def test_custom_beats_cache_in_boxart_path + Dir.mktmpdir do |dir| + ENV['GEMBA_CONFIG_DIR'] = dir + cache_dir = File.join(dir, "boxart") + fetcher = Gemba::BoxartFetcher.new(app: nil, cache_dir: cache_dir, + backend: Gemba::BoxartFetcher::NullBackend.new) + overrides = Gemba::RomOverrides.new(File.join(dir, "overrides.json")) + + # Pre-populate both cache and custom + cached = fetcher.cached_path('AGB-AXVE') + FileUtils.mkdir_p(File.dirname(cached)) + File.write(cached, "cached") + + src = File.join(dir, "my_cover.png") + File.write(src, "custom") + overrides.set_custom_boxart('AGB_AXVE-DEADBEEF', src) + + info = Gemba::RomInfo.from_rom(ROM, fetcher: fetcher, overrides: overrides) + assert_match %r{custom\.png$}, info.boxart_path, "Custom should beat cached" + ensure + ENV.delete('GEMBA_CONFIG_DIR') + end + end +end diff --git a/test/test_rom_overrides.rb b/test/test_rom_overrides.rb new file mode 100644 index 0000000..34266e5 --- /dev/null +++ b/test/test_rom_overrides.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "tmpdir" +require "fileutils" +require "gemba/headless" +require "gemba/headless" + +class TestRomOverrides < Minitest::Test + def setup + @tmpdir = Dir.mktmpdir("rom_overrides_test") + @json = File.join(@tmpdir, "rom_overrides.json") + @boxart = File.join(@tmpdir, "boxart") + # Point Config.boxart_dir at our tmpdir so copies land there + @orig_env = ENV['GEMBA_CONFIG_DIR'] + ENV['GEMBA_CONFIG_DIR'] = @tmpdir + end + + def teardown + ENV['GEMBA_CONFIG_DIR'] = @orig_env + FileUtils.rm_rf(@tmpdir) + end + + def test_custom_boxart_returns_nil_when_nothing_set + overrides = Gemba::RomOverrides.new(@json) + assert_nil overrides.custom_boxart("AGB_AXVE-DEADBEEF") + end + + def test_set_custom_boxart_copies_file_and_returns_dest + src = File.join(@tmpdir, "cover.png") + File.write(src, "fake png") + + overrides = Gemba::RomOverrides.new(@json) + dest = overrides.set_custom_boxart("AGB_AXVE-DEADBEEF", src) + + assert File.exist?(dest), "Copied file should exist at dest" + assert_equal "fake png", File.read(dest) + assert_match %r{/AGB_AXVE-DEADBEEF/custom\.png$}, dest + end + + def test_set_custom_boxart_persists_across_reload + src = File.join(@tmpdir, "cover.png") + File.write(src, "fake png") + + Gemba::RomOverrides.new(@json).set_custom_boxart("AGB_AXVE-DEADBEEF", src) + + reloaded = Gemba::RomOverrides.new(@json) + stored = reloaded.custom_boxart("AGB_AXVE-DEADBEEF") + refute_nil stored + assert File.exist?(stored) + end + + def test_set_custom_boxart_preserves_extension + src = File.join(@tmpdir, "cover.jpg") + File.write(src, "fake jpg") + + overrides = Gemba::RomOverrides.new(@json) + dest = overrides.set_custom_boxart("AGB_AXVE-DEADBEEF", src) + + assert dest.end_with?(".jpg"), "Extension should be preserved" + end + + def test_multiple_rom_ids_stored_independently + src1 = File.join(@tmpdir, "a.png"); File.write(src1, "a") + src2 = File.join(@tmpdir, "b.png"); File.write(src2, "b") + + overrides = Gemba::RomOverrides.new(@json) + overrides.set_custom_boxart("AGB_AXVE-AAAAAAAA", src1) + overrides.set_custom_boxart("AGB_BPEE-BBBBBBBB", src2) + + assert_match %r{AAAAAAAA}, overrides.custom_boxart("AGB_AXVE-AAAAAAAA") + assert_match %r{BBBBBBBB}, overrides.custom_boxart("AGB_BPEE-BBBBBBBB") + assert_nil overrides.custom_boxart("AGB_ZZZZ-ZZZZZZZZ") + end + + def test_json_file_is_valid_json + src = File.join(@tmpdir, "cover.png") + File.write(src, "fake") + + Gemba::RomOverrides.new(@json).set_custom_boxart("AGB_AXVE-DEADBEEF", src) + + parsed = JSON.parse(File.read(@json)) + assert_instance_of Hash, parsed + assert parsed.key?("AGB_AXVE-DEADBEEF") + end +end diff --git a/test/test_rom_patcher.rb b/test/test_rom_patcher.rb new file mode 100644 index 0000000..617f80d --- /dev/null +++ b/test/test_rom_patcher.rb @@ -0,0 +1,382 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require "zlib" +require "stringio" + +# Bootstrap Zeitwerk autoloading without Tk/SDL2. +require_relative "../lib/gemba/headless" + +class TestRomPatcher < Minitest::Test + + # --------------------------------------------------------------------------- + # Fixture helpers — build valid binary patch data in-memory + # --------------------------------------------------------------------------- + + # Build a minimal IPS patch that applies the given records. + # records: [{offset:, data:}] or [{offset:, rle_count:, rle_val:}] + def build_ips(records) + io = StringIO.new.tap { |s| s.binmode } + io.write("PATCH") + records.each do |rec| + off = rec[:offset] + io.write([off >> 16, (off >> 8) & 0xFF, off & 0xFF].pack("CCC")) + if rec[:rle_count] + io.write([0, rec[:rle_count]].pack("nn")) + io.write([rec[:rle_val]].pack("C")) + else + io.write([rec[:data].bytesize].pack("n")) + io.write(rec[:data].b) + end + end + io.write("EOF") + io.string.b + end + + # Encode a BPS varint (byuu's additive-shift encoding). + def bps_varint(n) + out = "".b + loop do + x = n & 0x7f + n >>= 7 + if n == 0 + out << (0x80 | x).chr + break + end + out << x.chr + n -= 1 + end + out + end + + # Build a BPS patch using only TargetRead records (writes literal target data). + # Simplest valid BPS: ignores source entirely, just emits target bytes. + def build_bps(source, target) + source = source.b + target = target.b + body = StringIO.new.tap { |s| s.binmode } + body.write("BPS1") + body.write(bps_varint(source.bytesize)) + body.write(bps_varint(target.bytesize)) + body.write(bps_varint(0)) # metadata_size = 0 + # One TargetRead record covering the entire target + word = ((target.bytesize - 1) << 2) | 1 + body.write(bps_varint(word)) + body.write(target) + payload = body.string.b + src_crc = Zlib.crc32(source) + tgt_crc = Zlib.crc32(target) + patch_crc = Zlib.crc32(payload) + payload + [src_crc, tgt_crc, patch_crc].pack("VVV") + end + + # Encode a UPS varint (simple bitshift encoding). + def ups_varint(n) + out = "".b + loop do + x = n & 0x7f + n >>= 7 + if n == 0 + out << (0x80 | x).chr + break + end + out << x.chr + end + out + end + + # Build a UPS patch from source → target. + def build_ups(source, target) + source = source.b + target = target.b + max_size = [source.bytesize, target.bytesize].max + + # Collect diff hunks: each is {start:, xor_bytes:} + hunks = [] + i = 0 + while i < max_size + s = source.getbyte(i) || 0 + t = target.getbyte(i) || 0 + if s != t + hunk_start = i + xor_bytes = "".b + while i < max_size + s = source.getbyte(i) || 0 + t = target.getbyte(i) || 0 + break if s == t + xor_bytes << (s ^ t).chr + i += 1 + end + hunks << { start: hunk_start, xor_bytes: xor_bytes } + else + i += 1 + end + end + + # Build body + body = StringIO.new.tap { |s| s.binmode } + body.write("UPS1") + body.write(ups_varint(source.bytesize)) + body.write(ups_varint(target.bytesize)) + + pos = 0 + hunks.each do |h| + skip = h[:start] - pos + body.write(ups_varint(skip)) + body.write(h[:xor_bytes]) + body.write("\x00") + pos = h[:start] + h[:xor_bytes].bytesize + 1 + end + + payload = body.string.b + src_crc = Zlib.crc32(source) + tgt_crc = Zlib.crc32(target) + patch_crc = Zlib.crc32(payload) + payload + [src_crc, tgt_crc, patch_crc].pack("VVV") + end + + # A small fake ROM — 64 zero bytes, like a blank cartridge header area. + def blank_rom(size = 64) + "\x00".b * size + end + + # --------------------------------------------------------------------------- + # RomPatcher (dispatcher) + # --------------------------------------------------------------------------- + + def test_detect_format_ips + patch = "PATCH" + "EOF" + assert_equal :ips, Gemba::RomPatcher.detect_format(patch) + end + + def test_detect_format_bps + patch = "BPS1\x00" + assert_equal :bps, Gemba::RomPatcher.detect_format(patch) + end + + def test_detect_format_ups + patch = "UPS1\x00" + assert_equal :ups, Gemba::RomPatcher.detect_format(patch) + end + + def test_detect_format_unknown + assert_nil Gemba::RomPatcher.detect_format("JUNK") + end + + def test_safe_out_path_no_collision + path = "/tmp/nonexistent_gemba_test_#{Process.pid}.gba" + assert_equal path, Gemba::RomPatcher.safe_out_path(path) + end + + def test_safe_out_path_collision + Dir.mktmpdir do |dir| + base = File.join(dir, "game.gba") + File.write(base, "x") + result = Gemba::RomPatcher.safe_out_path(base) + assert_equal File.join(dir, "game-(2).gba"), result + end + end + + def test_safe_out_path_multiple_collisions + Dir.mktmpdir do |dir| + File.write(File.join(dir, "game.gba"), "x") + File.write(File.join(dir, "game-(2).gba"), "x") + result = Gemba::RomPatcher.safe_out_path(File.join(dir, "game.gba")) + assert_equal File.join(dir, "game-(3).gba"), result + end + end + + def test_patch_dispatches_to_ips + Dir.mktmpdir do |dir| + rom_path = File.join(dir, "rom.gba") + patch_path = File.join(dir, "fix.ips") + out_path = File.join(dir, "rom-patched.gba") + + source = blank_rom + File.binwrite(rom_path, source) + File.binwrite(patch_path, build_ips([{ offset: 0, data: "\xFF\xFE\xFD\xFC" }])) + + Gemba::RomPatcher.patch(rom_path: rom_path, patch_path: patch_path, out_path: out_path) + result = File.binread(out_path) + assert_equal "\xFF".b, result[0, 1] + assert_equal "\xFE".b, result[1, 1] + end + end + + def test_patch_raises_on_unknown_format + Dir.mktmpdir do |dir| + File.binwrite(File.join(dir, "rom.gba"), "X" * 16) + File.binwrite(File.join(dir, "bad.xyz"), "JUNK") + assert_raises(RuntimeError) do + Gemba::RomPatcher.patch( + rom_path: File.join(dir, "rom.gba"), + patch_path: File.join(dir, "bad.xyz"), + out_path: File.join(dir, "out.gba") + ) + end + end + end + + # --------------------------------------------------------------------------- + # IPS + # --------------------------------------------------------------------------- + + def test_ips_overwrites_bytes_at_offset + source = blank_rom + patch = build_ips([{ offset: 4, data: "\xFF\xFE\xFD" }]) + result = Gemba::RomPatcher::IPS.apply(source, patch) + assert_equal "\x00".b * 4, result[0, 4], "bytes before offset unchanged" + assert_equal "\xFF\xFE\xFD".b, result[4, 3], "patch bytes applied" + assert_equal "\x00".b, result[7, 1], "bytes after patch unchanged" + end + + def test_ips_rle_record_fills_region + source = blank_rom + patch = build_ips([{ offset: 8, rle_count: 4, rle_val: 0xAB }]) + result = Gemba::RomPatcher::IPS.apply(source, patch) + assert_equal "\xAB".b * 4, result[8, 4] + assert_equal "\x00".b, result[7, 1], "byte before RLE unchanged" + assert_equal "\x00".b, result[12, 1], "byte after RLE unchanged" + end + + def test_ips_multiple_records + source = blank_rom + patch = build_ips([ + { offset: 0, data: "\x01\x02" }, + { offset: 10, data: "\x03\x04" }, + ]) + result = Gemba::RomPatcher::IPS.apply(source, patch) + assert_equal "\x01\x02".b, result[0, 2] + assert_equal "\x03\x04".b, result[10, 2] + end + + def test_ips_extends_rom_if_patch_exceeds_size + source = "\x00".b * 4 + patch = build_ips([{ offset: 8, data: "\xFF\xFF" }]) + result = Gemba::RomPatcher::IPS.apply(source, patch) + assert result.bytesize >= 10, "ROM extended to fit patch" + assert_equal "\xFF\xFF".b, result[8, 2] + end + + def test_ips_empty_patch_returns_rom_unchanged + source = "HELLO".b + patch = build_ips([]) + result = Gemba::RomPatcher::IPS.apply(source, patch) + assert_equal source, result + end + + # --------------------------------------------------------------------------- + # BPS + # --------------------------------------------------------------------------- + + def test_bps_target_read_produces_correct_output + source = blank_rom(8) + target = "\x11\x22\x33\x44\x55\x66\x77\x88".b + patch = build_bps(source, target) + result = Gemba::RomPatcher::BPS.apply(source, patch) + assert_equal target, result + end + + def test_bps_crc_mismatch_raises + source = blank_rom(8) + target = "\xDE\xAD\xBE\xEF\x00\x00\x00\x00".b + patch = build_bps(source, target) + # Corrupt the source CRC (bytes -12..-9) + bad_patch = patch.dup.b + bad_patch[-12] = "\xFF".b + err = assert_raises(RuntimeError) { Gemba::RomPatcher::BPS.apply(source, bad_patch) } + assert_match(/CRC32/, err.message) + end + + def test_bps_identical_source_and_target + source = "GEMBA".b + target = "GEMBA".b + patch = build_bps(source, target) + result = Gemba::RomPatcher::BPS.apply(source, patch) + assert_equal target, result + end + + # --------------------------------------------------------------------------- + # UPS + # --------------------------------------------------------------------------- + + def test_ups_xors_differing_bytes + source = "\x00\x00\x00\x00".b + target = "\xFF\x00\xFF\x00".b + patch = build_ups(source, target) + result = Gemba::RomPatcher::UPS.apply(source, patch) + assert_equal target, result + end + + def test_ups_multiple_hunks + source = "\x00" * 16 + target = source.dup.b + target.setbyte(0, 0xAA) + target.setbyte(8, 0xBB) + target.setbyte(15, 0xCC) + patch = build_ups(source.b, target) + result = Gemba::RomPatcher::UPS.apply(source.b, patch) + assert_equal target, result + end + + def test_ups_crc_mismatch_raises + source = blank_rom(8) + target = "\xCA\xFE\xBA\xBE\x00\x00\x00\x00".b + patch = build_ups(source, target) + bad_patch = patch.dup.b + bad_patch[-12] = "\x00".b + bad_patch[-11] = "\x00".b + err = assert_raises(RuntimeError) { Gemba::RomPatcher::UPS.apply(source, bad_patch) } + assert_match(/CRC32/, err.message) + end + + def test_ups_identical_source_and_target + source = "GEMBA\x00\x00\x00".b + target = source.dup + patch = build_ups(source, target) + result = Gemba::RomPatcher::UPS.apply(source, patch) + assert_equal target, result + end + + def test_ups_pads_target_when_source_is_shorter + # target_size > source_size — result zero-pads to target_size + source = "\x01\x02".b + target = "\x01\x03\x00\x00".b # byte 1 differs; bytes 2-3 are 0 (matching padding) + patch = build_ups(source, target) + result = Gemba::RomPatcher::UPS.apply(source, patch) + assert_equal target, result + end + + # --------------------------------------------------------------------------- + # ZIP ROM input + # --------------------------------------------------------------------------- + + def test_patch_with_zip_rom_produces_gba_output + require 'zip' + dir = Dir.mktmpdir + begin + # Build a tiny ROM and wrap it in a zip + rom_data = blank_rom + zip_path = File.join(dir, "game.zip") + patch_path = File.join(dir, "fix.ips") + out_path = File.join(dir, "game-patched.gba") + + Zip::OutputStream.open(zip_path) do |zos| + zos.put_next_entry("game.gba") + zos.write(rom_data) + end + + File.binwrite(patch_path, build_ips([{ offset: 0, data: "\xFF\xFE" }])) + + resolved = Gemba::RomResolver.resolve(zip_path) + Gemba::RomPatcher.patch(rom_path: resolved, patch_path: patch_path, out_path: out_path) + + assert File.exist?(out_path), "expected output at #{out_path}" + assert_equal ".gba", File.extname(out_path) + assert_equal "\xFF".b, File.binread(out_path, 1) + ensure + # Windows may still hold the zip file handle until GC — ignore EACCES on cleanup + FileUtils.remove_entry(dir) rescue nil + end + end +end diff --git a/test/test_rom_loader.rb b/test/test_rom_resolver.rb similarity index 67% rename from test/test_rom_loader.rb rename to test/test_rom_resolver.rb index 7e47b30..b613b47 100644 --- a/test/test_rom_loader.rb +++ b/test/test_rom_resolver.rb @@ -5,7 +5,7 @@ require "tmpdir" require "zip" -class TestRomLoader < Minitest::Test +class TestRomResolver < Minitest::Test TEST_ROM = File.expand_path("fixtures/test.gba", __dir__) def setup @@ -14,30 +14,30 @@ def setup def teardown FileUtils.rm_rf(@tmpdir) if @tmpdir && File.directory?(@tmpdir) - Gemba::RomLoader.cleanup_temp + Gemba::RomResolver.cleanup_temp end # -- resolve passthrough -- def test_resolve_gba_returns_path_unchanged - assert_equal TEST_ROM, Gemba::RomLoader.resolve(TEST_ROM) + assert_equal TEST_ROM, Gemba::RomResolver.resolve(TEST_ROM) end def test_resolve_gb_returns_path_unchanged path = "/some/game.gb" - assert_equal path, Gemba::RomLoader.resolve(path) + assert_equal path, Gemba::RomResolver.resolve(path) end def test_resolve_gbc_returns_path_unchanged path = "/some/game.gbc" - assert_equal path, Gemba::RomLoader.resolve(path) + assert_equal path, Gemba::RomResolver.resolve(path) end # -- resolve from zip -- def test_resolve_zip_extracts_rom zip_path = create_zip("game.zip", "game.gba" => File.binread(TEST_ROM)) - result = Gemba::RomLoader.resolve(zip_path) + result = Gemba::RomResolver.resolve(zip_path) assert File.exist?(result), "extracted ROM should exist" assert_equal ".gba", File.extname(result).downcase @@ -46,7 +46,7 @@ def test_resolve_zip_extracts_rom def test_resolve_zip_loads_in_core zip_path = create_zip("game.zip", "game.gba" => File.binread(TEST_ROM)) - rom_path = Gemba::RomLoader.resolve(zip_path) + rom_path = Gemba::RomResolver.resolve(zip_path) core = Gemba::Core.new(rom_path) assert_equal "GEMBATEST", core.title @@ -59,8 +59,8 @@ def test_resolve_zip_loads_in_core def test_resolve_zip_no_rom_raises zip_path = create_zip("empty.zip", "readme.txt" => "hello") - err = assert_raises(Gemba::RomLoader::NoRomInZip) do - Gemba::RomLoader.resolve(zip_path) + err = assert_raises(Gemba::RomResolver::NoRomInZip) do + Gemba::RomResolver.resolve(zip_path) end assert_includes err.message, "empty.zip" end @@ -71,15 +71,15 @@ def test_resolve_zip_multiple_roms_raises "game1.gba" => rom_data, "game2.gba" => rom_data) - err = assert_raises(Gemba::RomLoader::MultipleRomsInZip) do - Gemba::RomLoader.resolve(zip_path) + err = assert_raises(Gemba::RomResolver::MultipleRomsInZip) do + Gemba::RomResolver.resolve(zip_path) end assert_includes err.message, "multi.zip" end def test_resolve_unsupported_extension_raises - assert_raises(Gemba::RomLoader::UnsupportedFormat) do - Gemba::RomLoader.resolve("/some/file.rar") + assert_raises(Gemba::RomResolver::UnsupportedFormat) do + Gemba::RomResolver.resolve("/some/file.rar") end end @@ -87,8 +87,8 @@ def test_resolve_corrupt_zip_raises corrupt = File.join(@tmpdir, "corrupt.zip") File.binwrite(corrupt, "this is not a zip file") - assert_raises(Gemba::RomLoader::ZipReadError) do - Gemba::RomLoader.resolve(corrupt) + assert_raises(Gemba::RomResolver::ZipReadError) do + Gemba::RomResolver.resolve(corrupt) end end @@ -101,32 +101,32 @@ def test_resolve_zip_ignores_roms_in_subdirectories zos.write(File.binread(TEST_ROM)) end - assert_raises(Gemba::RomLoader::NoRomInZip) do - Gemba::RomLoader.resolve(zip_path) + assert_raises(Gemba::RomResolver::NoRomInZip) do + Gemba::RomResolver.resolve(zip_path) end end # -- constants -- def test_rom_extensions - assert_includes Gemba::RomLoader::ROM_EXTENSIONS, ".gba" - assert_includes Gemba::RomLoader::ROM_EXTENSIONS, ".gb" - assert_includes Gemba::RomLoader::ROM_EXTENSIONS, ".gbc" + assert_includes Gemba::RomResolver::ROM_EXTENSIONS, ".gba" + assert_includes Gemba::RomResolver::ROM_EXTENSIONS, ".gb" + assert_includes Gemba::RomResolver::ROM_EXTENSIONS, ".gbc" end def test_supported_extensions_includes_zip - assert_includes Gemba::RomLoader::SUPPORTED_EXTENSIONS, ".zip" + assert_includes Gemba::RomResolver::SUPPORTED_EXTENSIONS, ".zip" end # -- cleanup -- def test_cleanup_temp_removes_directory zip_path = create_zip("game.zip", "game.gba" => File.binread(TEST_ROM)) - Gemba::RomLoader.resolve(zip_path) - assert File.directory?(Gemba::RomLoader.tmp_dir) + Gemba::RomResolver.resolve(zip_path) + assert File.directory?(Gemba::RomResolver.tmp_dir) - Gemba::RomLoader.cleanup_temp - refute File.directory?(Gemba::RomLoader.tmp_dir) + Gemba::RomResolver.cleanup_temp + refute File.directory?(Gemba::RomResolver.tmp_dir) end private diff --git a/test/test_save_state_manager.rb b/test/test_save_state_manager.rb index e626341..55da77b 100644 --- a/test/test_save_state_manager.rb +++ b/test/test_save_state_manager.rb @@ -3,9 +3,7 @@ require "minitest/autorun" require "tmpdir" require "json" -require_relative "../lib/gemba/config" -require_relative "../lib/gemba/locale" -require_relative "../lib/gemba/save_state_manager" +require "gemba/headless" class TestSaveStateManager < Minitest::Test # Recording mock for the mGBA Core. @@ -92,7 +90,7 @@ def teardown end def new_manager(core: @core, config: @config, app: @app) - mgr = Gemba::SaveStateManager.new(core: core, config: config, app: app) + mgr = Gemba::SaveStateManager.new(core: core, config: config, app: app, platform: Gemba::Platform.default) mgr.state_dir = mgr.state_dir_for_rom(core) mgr end diff --git a/test/test_settings_audio.rb b/test/test_settings_audio.rb index d01add3..7aecf47 100644 --- a/test/test_settings_audio.rb +++ b/test/test_settings_audio.rb @@ -10,7 +10,7 @@ class TestSettingsAudioTab < Minitest::Test def test_volume_defaults_to_100 assert_tk_app("volume defaults to 100") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -21,7 +21,7 @@ def test_volume_defaults_to_100 def test_dragging_volume_to_50_fires_callback assert_tk_app("dragging volume to 50 fires on_volume_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:volume_changed) { |v| received = v } @@ -39,7 +39,7 @@ def test_dragging_volume_to_50_fires_callback def test_volume_at_zero assert_tk_app("volume at zero") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:volume_changed) { |v| received = v } @@ -58,7 +58,7 @@ def test_volume_at_zero def test_mute_defaults_to_off assert_tk_app("mute defaults to off") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -69,7 +69,7 @@ def test_mute_defaults_to_off def test_clicking_mute_fires_callback assert_tk_app("clicking mute fires on_mute_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:mute_changed) { |m| received = m } @@ -87,7 +87,7 @@ def test_clicking_mute_fires_callback def test_clicking_mute_twice_unmutes assert_tk_app("clicking mute twice unmutes") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:mute_changed) { |m| received = m } diff --git a/test/test_settings_hotkeys.rb b/test/test_settings_hotkeys.rb index c62e899..7f01b77 100644 --- a/test/test_settings_hotkeys.rb +++ b/test/test_settings_hotkeys.rb @@ -8,8 +8,8 @@ class TestMGBASettingsHotkeys < Minitest::Test def test_hotkeys_tab_exists assert_tk_app("hotkeys tab exists in notebook") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -21,8 +21,8 @@ def test_hotkeys_tab_exists def test_hotkey_buttons_show_default_keysyms assert_tk_app("hotkey buttons show default keysyms") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -40,8 +40,8 @@ def test_hotkey_buttons_show_default_keysyms def test_clicking_hotkey_button_enters_listen_mode assert_tk_app("clicking hotkey button enters listen mode") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -57,8 +57,8 @@ def test_clicking_hotkey_button_enters_listen_mode def test_capture_updates_label_and_fires_callback assert_tk_app("capturing hotkey updates label and fires callback") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" received_action = nil received_key = nil Gemba.bus = Gemba::EventBus.new @@ -85,8 +85,8 @@ def test_capture_updates_label_and_fires_callback def test_capture_enables_undo_button assert_tk_app("capturing hotkey enables undo button") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -108,8 +108,8 @@ def test_capture_enables_undo_button def test_undo_fires_callback_and_disables assert_tk_app("undo fires on_undo_hotkeys and disables button") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" undo_called = false Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:undo_hotkeys) { undo_called = true } @@ -135,8 +135,8 @@ def test_undo_fires_callback_and_disables def test_reset_restores_defaults assert_tk_app("reset restores default hotkey labels") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" reset_called = false Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:hotkey_reset) { reset_called = true } @@ -169,8 +169,8 @@ def test_reset_restores_defaults def test_refresh_hotkeys_updates_labels assert_tk_app("refresh_hotkeys updates button labels") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -188,8 +188,8 @@ def test_refresh_hotkeys_updates_labels def test_cancel_listen_restores_label assert_tk_app("canceling listen restores original label") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -211,8 +211,8 @@ def test_cancel_listen_restores_label def test_capture_without_listen_is_noop assert_tk_app("capture without listen mode is a no-op") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" received = false Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:hotkey_changed) { |*| received = true } @@ -234,8 +234,8 @@ def test_capture_without_listen_is_noop def test_hotkey_rejected_when_conflicting_with_gamepad_key assert_tk_app("hotkey rejected when key conflicts with gamepad mapping") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" received = false conflict_msg = nil Gemba.bus = Gemba::EventBus.new @@ -267,8 +267,8 @@ def test_hotkey_rejected_when_conflicting_with_gamepad_key def test_hotkey_accepted_when_no_conflict assert_tk_app("hotkey accepted when no conflict") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" received_action = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:hotkey_changed) { |a, _| received_action = a } @@ -293,8 +293,8 @@ def test_hotkey_accepted_when_no_conflict def test_capture_modifier_then_key_produces_combo assert_tk_app("modifier + key produces combo hotkey") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" received_action = nil received_hk = nil Gemba.bus = Gemba::EventBus.new @@ -322,8 +322,8 @@ def test_capture_modifier_then_key_produces_combo def test_capture_multi_modifier_combo assert_tk_app("multi-modifier combo (Ctrl+Shift+S)") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" received_hk = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:hotkey_changed) { |_, hk| received_hk = hk } @@ -347,8 +347,8 @@ def test_capture_multi_modifier_combo def test_combo_hotkey_skips_gamepad_conflict_validation assert_tk_app("combo hotkey skips gamepad conflict validation") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" received_hk = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:hotkey_changed) { |_, hk| received_hk = hk } @@ -374,8 +374,8 @@ def test_combo_hotkey_skips_gamepad_conflict_validation def test_refresh_hotkeys_shows_combo_display_name assert_tk_app("refresh_hotkeys shows combo display name") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -391,8 +391,8 @@ def test_refresh_hotkeys_shows_combo_display_name def test_bind_script_modifier_combo_roundtrip assert_tk_app("Tcl bind script round-trip with modifier+key combo") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" received_hk = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:hotkey_changed) { |_, hk| received_hk = hk } @@ -425,8 +425,8 @@ def test_bind_script_modifier_combo_roundtrip def test_record_hotkey_button_shows_default assert_tk_app("record hotkey button shows F10") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -438,8 +438,8 @@ def test_record_hotkey_button_shows_default def test_open_rom_hotkey_button_shows_default assert_tk_app("open rom hotkey button shows Ctrl+O") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update diff --git a/test/test_settings_recording.rb b/test/test_settings_recording.rb index 6632e18..8cb7142 100644 --- a/test/test_settings_recording.rb +++ b/test/test_settings_recording.rb @@ -8,7 +8,7 @@ class TestSettingsRecordingTab < Minitest::Test def test_recording_tab_exists assert_tk_app("recording tab exists in notebook") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -20,7 +20,7 @@ def test_recording_tab_exists def test_compression_combobox_defaults_to_1 assert_tk_app("compression combobox defaults to 1") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -31,7 +31,7 @@ def test_compression_combobox_defaults_to_1 def test_selecting_compression_fires_callback assert_tk_app("selecting compression fires on_compression_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:compression_changed) { |v| received = v } diff --git a/test/test_settings_save_states.rb b/test/test_settings_save_states.rb index 0399af4..60141c3 100644 --- a/test/test_settings_save_states.rb +++ b/test/test_settings_save_states.rb @@ -8,7 +8,7 @@ class TestSettingsSaveStatesTab < Minitest::Test def test_save_states_tab_exists assert_tk_app("save states tab exists in notebook") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -20,7 +20,7 @@ def test_save_states_tab_exists def test_quick_slot_defaults_to_1 assert_tk_app("quick slot defaults to 1") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -31,7 +31,7 @@ def test_quick_slot_defaults_to_1 def test_selecting_slot_fires_callback assert_tk_app("selecting slot fires on_quick_slot_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:quick_slot_changed) { |v| received = v } @@ -49,7 +49,7 @@ def test_selecting_slot_fires_callback def test_backup_defaults_to_on assert_tk_app("backup checkbox defaults to on") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -60,7 +60,7 @@ def test_backup_defaults_to_on def test_clicking_backup_fires_callback assert_tk_app("clicking backup fires on_backup_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:backup_changed) { |v| received = v } @@ -77,7 +77,7 @@ def test_clicking_backup_fires_callback def test_clicking_backup_twice_re_enables assert_tk_app("clicking backup twice re-enables") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:backup_changed) { |v| received = v } diff --git a/test/test_settings_system.rb b/test/test_settings_system.rb new file mode 100644 index 0000000..8b0291b --- /dev/null +++ b/test/test_settings_system.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "minitest/autorun" +require_relative "shared/tk_test_helper" + +class TestSettingsSystem < Minitest::Test + include TeekTestHelper + + FAKE_BIOS = File.expand_path("fixtures/fake_bios.bin", __dir__) + + # -- tab presence ----------------------------------------------------------- + + def test_system_tab_exists_in_notebook + assert_tk_app("system tab exists in notebook") do + require "gemba/headless" + Gemba.bus = Gemba::EventBus.new + sw = Gemba::SettingsWindow.new(app) + sw.show + app.update + + tabs = app.command(Gemba::SettingsWindow::NB, 'tabs').split + assert_includes tabs, Gemba::SettingsWindow::SYSTEM_TAB + end + end + + # -- initial state ---------------------------------------------------------- + + def test_bios_path_empty_initially + assert_tk_app("bios path var is empty initially") do + require "gemba/headless" + Gemba.bus = Gemba::EventBus.new + sw = Gemba::SettingsWindow.new(app) + sw.show + app.update + + val = app.get_variable(Gemba::SettingsWindow::VAR_BIOS_PATH) + assert_equal '', val + end + end + + def test_skip_bios_unchecked_initially + assert_tk_app("skip bios checkbox is unchecked initially") do + require "gemba/headless" + Gemba.bus = Gemba::EventBus.new + sw = Gemba::SettingsWindow.new(app) + sw.show + app.update + + val = app.get_variable(Gemba::SettingsWindow::VAR_SKIP_BIOS) + assert_equal '0', val + end + end + + # -- skip_bios checkbox marks dirty ---------------------------------------- + + def test_skip_bios_toggle_marks_save_dirty + assert_tk_app("toggling skip bios enables the save button") do + require "gemba/headless" + Gemba.bus = Gemba::EventBus.new + sw = Gemba::SettingsWindow.new(app) + sw.show + + # Navigate to system tab + app.command(Gemba::SettingsWindow::NB, 'select', Gemba::SettingsWindow::SYSTEM_TAB) + app.update + + app.command(Gemba::Settings::SystemTab::SKIP_BIOS_CHECK, 'invoke') + app.update + + state = app.command(Gemba::SettingsWindow::SAVE_BTN, :cget, '-state').to_s + assert_equal 'normal', state + end + end + + # -- clear button ----------------------------------------------------------- + + def test_clear_button_empties_bios_path + assert_tk_app("clear button empties the bios path variable") do + require "gemba/headless" + Gemba.bus = Gemba::EventBus.new + sw = Gemba::SettingsWindow.new(app) + sw.show + app.update + + # Seed a value + app.set_variable(Gemba::SettingsWindow::VAR_BIOS_PATH, 'gba_bios.bin') + app.command(Gemba::Settings::SystemTab::BIOS_CLEAR, 'invoke') + app.update + + assert_equal '', app.get_variable(Gemba::SettingsWindow::VAR_BIOS_PATH) + end + end + + def test_clear_button_marks_save_dirty + assert_tk_app("clear button enables the save button") do + require "gemba/headless" + Gemba.bus = Gemba::EventBus.new + sw = Gemba::SettingsWindow.new(app) + sw.show + app.update + + app.command(Gemba::Settings::SystemTab::BIOS_CLEAR, 'invoke') + app.update + + state = app.command(Gemba::SettingsWindow::SAVE_BTN, :cget, '-state').to_s + assert_equal 'normal', state + end + end + + def test_clear_resets_status_label + assert_tk_app("clear button resets status label to not-set text") do + require "gemba/headless" + Gemba::Locale.load('en') + Gemba.bus = Gemba::EventBus.new + sw = Gemba::SettingsWindow.new(app) + sw.show + app.update + + app.command(Gemba::Settings::SystemTab::BIOS_CLEAR, 'invoke') + app.update + + text = app.command(Gemba::Settings::SystemTab::BIOS_STATUS, :cget, '-text').to_s + assert_includes text, 'Not set' + end + end + + # -- system tab is per-game eligible ---------------------------------------- + + def test_system_tab_in_per_game_tabs + require "gemba/headless" + assert Gemba::SettingsWindow::PER_GAME_TABS.include?(Gemba::SettingsWindow::SYSTEM_TAB) + end +end diff --git a/test/test_settings_video.rb b/test/test_settings_video.rb index f7a74a0..ee71413 100644 --- a/test/test_settings_video.rb +++ b/test/test_settings_video.rb @@ -8,7 +8,7 @@ class TestSettingsVideoTab < Minitest::Test def test_video_tab_exists assert_tk_app("video tab exists in notebook") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -22,7 +22,7 @@ def test_video_tab_exists def test_scale_defaults_to_3x assert_tk_app("scale combobox defaults to 3x") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -33,7 +33,7 @@ def test_scale_defaults_to_3x def test_selecting_2x_scale_fires_callback assert_tk_app("selecting 2x scale fires on_scale_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:scale_changed) { |s| received = s } @@ -51,7 +51,7 @@ def test_selecting_2x_scale_fires_callback def test_selecting_4x_scale_fires_callback assert_tk_app("selecting 4x scale fires on_scale_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:scale_changed) { |s| received = s } @@ -71,7 +71,7 @@ def test_selecting_4x_scale_fires_callback def test_turbo_defaults_to_2x assert_tk_app("turbo speed defaults to 2x") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -82,7 +82,7 @@ def test_turbo_defaults_to_2x def test_selecting_4x_turbo_fires_callback assert_tk_app("selecting 4x turbo fires on_turbo_speed_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:turbo_speed_changed) { |s| received = s } @@ -102,7 +102,7 @@ def test_selecting_4x_turbo_fires_callback def test_aspect_ratio_defaults_to_on assert_tk_app("aspect ratio checkbox defaults to on") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -113,7 +113,7 @@ def test_aspect_ratio_defaults_to_on def test_unchecking_aspect_ratio_fires_callback assert_tk_app("unchecking aspect ratio fires on_aspect_ratio_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:aspect_ratio_changed) { |keep| received = keep } @@ -130,7 +130,7 @@ def test_unchecking_aspect_ratio_fires_callback def test_checking_aspect_ratio_fires_callback assert_tk_app("re-checking aspect ratio fires callback with true") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:aspect_ratio_changed) { |keep| received = keep } @@ -151,7 +151,7 @@ def test_checking_aspect_ratio_fires_callback def test_show_fps_defaults_to_on assert_tk_app("show fps checkbox defaults to on") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -162,7 +162,7 @@ def test_show_fps_defaults_to_on def test_unchecking_show_fps_fires_callback assert_tk_app("unchecking show fps fires on_show_fps_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:show_fps_changed) { |show| received = show } @@ -181,7 +181,7 @@ def test_unchecking_show_fps_fires_callback def test_pause_focus_defaults_to_on assert_tk_app("pause on focus loss defaults to on") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -192,7 +192,7 @@ def test_pause_focus_defaults_to_on def test_unchecking_pause_focus_fires_callback assert_tk_app("unchecking pause on focus loss fires callback") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:pause_on_focus_loss_changed) { |v| received = v } @@ -213,7 +213,7 @@ def test_unchecking_pause_focus_fires_callback def test_toast_duration_defaults_to_1_5s assert_tk_app("toast duration defaults to 1.5s") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -224,7 +224,7 @@ def test_toast_duration_defaults_to_1_5s def test_selecting_3s_toast_fires_callback assert_tk_app("selecting 3s toast fires on_toast_duration_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:toast_duration_changed) { |s| received = s } @@ -244,7 +244,7 @@ def test_selecting_3s_toast_fires_callback def test_pixel_filter_defaults_to_nearest assert_tk_app("pixel filter defaults to Nearest Neighbor") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -255,7 +255,7 @@ def test_pixel_filter_defaults_to_nearest def test_selecting_bilinear_fires_callback assert_tk_app("selecting Bilinear fires on_filter_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:filter_changed) { |f| received = f } @@ -275,7 +275,7 @@ def test_selecting_bilinear_fires_callback def test_integer_scale_defaults_to_off assert_tk_app("integer scale checkbox defaults to off") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -286,7 +286,7 @@ def test_integer_scale_defaults_to_off def test_clicking_integer_scale_fires_callback assert_tk_app("clicking integer scale fires on_integer_scale_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:integer_scale_changed) { |v| received = v } @@ -305,7 +305,7 @@ def test_clicking_integer_scale_fires_callback def test_color_correction_defaults_to_off assert_tk_app("color correction checkbox defaults to off") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -316,7 +316,7 @@ def test_color_correction_defaults_to_off def test_clicking_color_correction_fires_callback assert_tk_app("clicking color correction fires on_color_correction_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:color_correction_changed) { |v| received = v } @@ -333,7 +333,7 @@ def test_clicking_color_correction_fires_callback def test_unchecking_color_correction_fires_false assert_tk_app("unchecking color correction fires callback with false") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:color_correction_changed) { |v| received = v } @@ -354,7 +354,7 @@ def test_unchecking_color_correction_fires_false def test_frame_blending_defaults_to_off assert_tk_app("frame blending checkbox defaults to off") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -365,7 +365,7 @@ def test_frame_blending_defaults_to_off def test_clicking_frame_blending_fires_callback assert_tk_app("clicking frame blending fires on_frame_blending_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:frame_blending_changed) { |v| received = v } @@ -382,7 +382,7 @@ def test_clicking_frame_blending_fires_callback def test_unchecking_frame_blending_fires_false assert_tk_app("unchecking frame blending fires callback with false") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:frame_blending_changed) { |v| received = v } @@ -403,7 +403,7 @@ def test_unchecking_frame_blending_fires_false def test_rewind_defaults_to_on assert_tk_app("rewind checkbox defaults to on") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -414,7 +414,7 @@ def test_rewind_defaults_to_on def test_clicking_rewind_fires_callback assert_tk_app("clicking rewind fires on_rewind_toggle") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:rewind_toggled) { |v| received = v } @@ -431,7 +431,7 @@ def test_clicking_rewind_fires_callback def test_rechecking_rewind_fires_true assert_tk_app("re-checking rewind fires callback with true") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:rewind_toggled) { |v| received = v } diff --git a/test/test_settings_window.rb b/test/test_settings_window.rb index be56f79..c1165dc 100644 --- a/test/test_settings_window.rb +++ b/test/test_settings_window.rb @@ -10,7 +10,7 @@ class TestMGBASettingsWindow < Minitest::Test def test_settings_starts_hidden assert_tk_app("settings starts hidden") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) app.update @@ -20,7 +20,7 @@ def test_settings_starts_hidden def test_show_and_hide assert_tk_app("show makes window visible, hide withdraws it") do - require "gemba/settings_window" + require "gemba/headless" app.show app.update sw = Gemba::SettingsWindow.new(app) @@ -40,7 +40,7 @@ def test_show_and_hide def test_gamepad_tab_exists assert_tk_app("gamepad tab exists in notebook") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -52,7 +52,7 @@ def test_gamepad_tab_exists def test_deadzone_defaults_to_25 assert_tk_app("dead zone defaults to 25") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -63,7 +63,7 @@ def test_deadzone_defaults_to_25 def test_deadzone_change_fires_callback assert_tk_app("dead zone change fires on_deadzone_change") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:deadzone_changed) { |t| received = t } @@ -87,7 +87,7 @@ def test_deadzone_change_fires_callback def test_clicking_gba_button_enters_listen_mode assert_tk_app("clicking GBA button enters listen mode") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -104,7 +104,7 @@ def test_clicking_gba_button_enters_listen_mode def test_capture_mapping_updates_button_label assert_tk_app("capture_mapping updates button label") do - require "gemba/settings_window" + require "gemba/headless" received_gba = nil received_key = nil Gemba.bus = Gemba::EventBus.new @@ -131,7 +131,7 @@ def test_capture_mapping_updates_button_label def test_gamepad_selector_defaults_to_keyboard_only assert_tk_app("gamepad selector defaults to Keyboard Only") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -144,7 +144,7 @@ def test_gamepad_selector_defaults_to_keyboard_only def test_undo_starts_disabled assert_tk_app("undo button starts disabled") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -156,7 +156,7 @@ def test_undo_starts_disabled def test_undo_enabled_after_remap assert_tk_app("undo enabled after capturing a mapping") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -173,7 +173,7 @@ def test_undo_enabled_after_remap def test_undo_fires_callback_and_disables assert_tk_app("undo fires on_undo_gamepad and disables itself") do - require "gemba/settings_window" + require "gemba/headless" undo_called = false Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:undo_gamepad) { undo_called = true } @@ -199,7 +199,7 @@ def test_undo_fires_callback_and_disables def test_reset_disables_undo assert_tk_app("reset to defaults disables undo button") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -227,7 +227,7 @@ def test_reset_disables_undo def test_starts_in_keyboard_mode assert_tk_app("starts in keyboard mode") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -238,7 +238,7 @@ def test_starts_in_keyboard_mode def test_keyboard_mode_labels_show_keysyms assert_tk_app("keyboard mode shows keysym labels") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -252,7 +252,7 @@ def test_keyboard_mode_labels_show_keysyms def test_switching_to_gamepad_mode_changes_labels assert_tk_app("switching to gamepad shows gamepad labels") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -272,7 +272,7 @@ def test_switching_to_gamepad_mode_changes_labels def test_deadzone_disabled_in_keyboard_mode assert_tk_app("dead zone slider disabled in keyboard mode") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -284,7 +284,7 @@ def test_deadzone_disabled_in_keyboard_mode def test_deadzone_enabled_in_gamepad_mode assert_tk_app("dead zone slider enabled in gamepad mode") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -301,7 +301,7 @@ def test_deadzone_enabled_in_gamepad_mode def test_keyboard_capture_fires_keyboard_callback assert_tk_app("keyboard capture fires on_keyboard_map_change") do - require "gemba/settings_window" + require "gemba/headless" received_gba = nil received_key = nil Gemba.bus = Gemba::EventBus.new @@ -322,7 +322,7 @@ def test_keyboard_capture_fires_keyboard_callback def test_switching_mode_cancels_listen assert_tk_app("switching input mode cancels active listen") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -348,7 +348,7 @@ def test_switching_mode_cancels_listen def test_virtual_gamepad_listen_and_capture assert_tk_app("virtual gamepad button press captured in listen mode") do - require "gemba/settings_window" + require "gemba/headless" require "teek/sdl2" gp_cls = Teek::SDL2::Gamepad @@ -407,8 +407,8 @@ def test_virtual_gamepad_listen_and_capture def test_kb_mapping_rejected_when_conflicting_with_hotkey assert_tk_app("keyboard mapping rejected when key conflicts with hotkey") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" received = false conflict_msg = nil Gemba.bus = Gemba::EventBus.new @@ -440,8 +440,8 @@ def test_kb_mapping_rejected_when_conflicting_with_hotkey def test_kb_mapping_accepted_when_no_conflict assert_tk_app("keyboard mapping accepted when no conflict") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" received_gba = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:keyboard_map_changed) { |g, _| received_gba = g } @@ -464,8 +464,8 @@ def test_kb_mapping_accepted_when_no_conflict def test_gamepad_mapping_skips_validation assert_tk_app("gamepad mode skips keyboard validation") do - require "gemba/settings_window" - require "gemba/hotkey_map" + require "gemba/headless" + require "gemba/headless" require "teek/sdl2" gp_cls = Teek::SDL2::Gamepad gp_cls.init_subsystem @@ -505,7 +505,7 @@ def test_gamepad_mapping_skips_validation def test_per_game_checkbox_defaults_disabled assert_tk_app("per-game checkbox defaults disabled") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -517,7 +517,7 @@ def test_per_game_checkbox_defaults_disabled def test_set_per_game_available_enables_checkbox assert_tk_app("set_per_game_available enables checkbox") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -532,7 +532,7 @@ def test_set_per_game_available_enables_checkbox def test_per_game_toggle_fires_callback assert_tk_app("per-game toggle fires on_per_game_toggle") do - require "gemba/settings_window" + require "gemba/headless" received = nil Gemba.bus = Gemba::EventBus.new Gemba.bus.on(:per_game_toggled) { |v| received = v } @@ -551,7 +551,7 @@ def test_per_game_toggle_fires_callback def test_set_per_game_active_syncs_variable assert_tk_app("set_per_game_active syncs variable") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -566,7 +566,7 @@ def test_set_per_game_active_syncs_variable def test_per_game_disabled_on_gamepad_tab assert_tk_app("per-game disabled on gamepad tab") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update @@ -586,7 +586,7 @@ def test_per_game_disabled_on_gamepad_tab def test_per_game_re_enabled_on_video_tab assert_tk_app("per-game re-enabled on video tab") do - require "gemba/settings_window" + require "gemba/headless" sw = Gemba::SettingsWindow.new(app) sw.show app.update diff --git a/test/test_tip_service.rb b/test/test_tip_service.rb index b70f9b8..e173dc4 100644 --- a/test/test_tip_service.rb +++ b/test/test_tip_service.rb @@ -8,7 +8,7 @@ class TestTipService < Minitest::Test def test_register_sets_underline_font assert_tk_app("register sets underlined font") do - require "gemba/tip_service" + require "gemba/headless" tips = Gemba::TipService.new(app) lbl = ".test_lbl" @@ -27,7 +27,7 @@ def test_register_sets_underline_font def test_show_creates_tooltip assert_tk_app("show creates a tooltip toplevel") do - require "gemba/tip_service" + require "gemba/headless" tips = Gemba::TipService.new(app) lbl = ".test_lbl" @@ -47,7 +47,7 @@ def test_show_creates_tooltip def test_hide_destroys_tooltip assert_tk_app("hide destroys tooltip") do - require "gemba/tip_service" + require "gemba/headless" tips = Gemba::TipService.new(app) lbl = ".test_lbl" @@ -69,7 +69,7 @@ def test_hide_destroys_tooltip def test_toggle_behavior assert_tk_app("clicking same label toggles tooltip") do - require "gemba/tip_service" + require "gemba/headless" tips = Gemba::TipService.new(app) lbl = ".test_lbl" @@ -96,7 +96,7 @@ def test_toggle_behavior def test_only_one_tooltip_at_a_time assert_tk_app("showing a second tooltip hides the first") do - require "gemba/tip_service" + require "gemba/headless" tips = Gemba::TipService.new(app) lbl1 = ".test_lbl1" @@ -126,7 +126,7 @@ def test_only_one_tooltip_at_a_time def test_dismiss_ms_is_configurable assert_tk_app("dismiss_ms is configurable") do - require "gemba/tip_service" + require "gemba/headless" tips = Gemba::TipService.new(app, dismiss_ms: 2000) assert_equal 2000, tips.dismiss_ms diff --git a/test/test_toast_overlay.rb b/test/test_toast_overlay.rb index aaa34fe..fdd4347 100644 --- a/test/test_toast_overlay.rb +++ b/test/test_toast_overlay.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "minitest/autorun" -require_relative "../lib/gemba/toast_overlay" +require "gemba/headless" class TestToastOverlay < Minitest::Test # Minimal texture mock — records destroy calls. diff --git a/test/test_virtual_keyboard.rb b/test/test_virtual_keyboard.rb index c4cbb94..8407522 100644 --- a/test/test_virtual_keyboard.rb +++ b/test/test_virtual_keyboard.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require "minitest/autorun" -require_relative "../lib/gemba/input_mappings" +require "gemba/headless" class TestVirtualKeyboard < Minitest::Test def setup