diff --git a/CLAUDE.md b/CLAUDE.md index 96a3365..4b2710d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,13 +68,13 @@ Configuration: `.flake8` (max-line-length=120, extend-ignore=E203/W503), `.pre-c The Makefile auto-detects the device from `PATCH_PARAMS` and generates `build/device.ld` with device-specific linker variables (via `scripts/device_from_patch_params.py`). The main linker script `STM32H7B0VBTx_FLASH.ld` includes this generated file. ### Amalgam Build (Mario + Zelda in One Bank) -`--device=amalgam` builds one 256 KB bank-1 image and one external image for both firmwares (unencrypted; `--encrypt`, `--triple-boot`, `--relocate-stock` and `--internal-only` are rejected). The bootloader boots Zelda (through its relocated vector table, setting VTOR, since Zelda's SystemInit no longer does) if TAMP backup register 31 holds `"ZELD"`, else Mario; it survives power-off (standby wakes through reset), and holding GAME at boot clears it. Each model's game-select DUAL BOOT entry is always on there and labeled with the other firmware: it writes the register and resets (`game_select_launch`, `main.c`). LEFT+GAME still boots bank 2 (0x08032000 with `--sd-bootloader`). +`--device=amalgam` builds one 256 KB bank-1 image and one external image for both firmwares (unencrypted; `--encrypt`, `--triple-boot`, `--relocate-stock` and `--internal-only` are rejected). The bootloader boots Zelda (through its relocated vector table, setting VTOR, since Zelda's SystemInit no longer does) if TAMP backup register 31 holds `"ZELD"`, else Mario; it survives power-off (standby wakes through reset), and holding GAME at boot clears it. Each model's game-select menu always has a switch entry labeled with the other firmware (`args.game_select_switch_label`): it writes the register and resets (`game_select_switch`, `main.c`). `--game-select-dual-boot` adds the DUAL BOOT entry after it, as in single-model builds (the custom entries are `custom[]` in `game_select_.c`, icons from `Device.build_game_select_icons`). LEFT+GAME still boots bank 2 (0x08032000 with `--sd-bootloader`). C build: both models' sources with `-DGNW_DEVICE_MARIO=1 -DGNW_DEVICE_ZELDA=1`, linked with `STM32H7B0VBTx_FLASH_amalgam.ld`; our code starts past both firmwares' code (`OUR_CODE`, 0x08031CD8; `build/device.ld` also defines `__zelda_vector_table__`). Each firmware owns all RAM while it runs, so `*mario.o` keep their RAM in Mario's window and `*zelda.o` in Zelda's. Code both run (`main.o`, `game_select.o`, `ips.o`, `LzmaDec.o`) can't have RAM (a linker `ASSERT`); bootloader-only HAL/CMSIS RAM sits in Mario's window. Shared code picks the running firmware's symbol with `MODEL_SELECT(mario, zelda)` (`Core/Inc/model.h`: `SCB->VTOR` in the amalgam, compile-time otherwise), and each firmware's `bss_rwdata_init` initializes only its own RAM. Hooks both models define carry a model prefix (`mario_game_select_draw`). `tests/test_amalgam.py` checks the placement (toolchain only, no backups). Patching (`patches/amalgam.py`): each device is patched in its own buffers at stock addresses, both allocating from one bank-1 `Region` (the map is in `patches/amalgam.py`'s docstring). The amalgam implies `--no-factory-test`: Mario's code stays at 0x08000000, Zelda's code from the end of its factory test (0x08018154: reset handler, init and rwdata tables) stays in place, and Zelda's code below the factory test (vector table included) moves to 0x0801B400 (`ZELDA_DST`, 1 KB aligned for VTOR), compacted: the stock code Zelda frees there is left out (`Relocation(compact=True)`; `free_int` calls `add_hole`, `finalize` packs the kept runs before Zelda's data moves and frees the rest up to our code), so each run moves by its own offset (`Relocation.final`), and 32-bit branches and PC-relative loads between runs are re-encoded (16-bit ones that would need it fail). A hole a device frees under the other's code is clipped (`Region.free`). Zelda goes first, then Mario's external data starts past Zelda's (`Device.set_ext_base`: every Mario byte moves, so `fix_ext_refs` fixes every pointer; its NVRAM offsets switch to `mov.w` + `addne.w` at an encodable address). Each device parses the command line with its own defaults (shared option names apply to both). `merge_internal` takes each allocation from its owner's buffer and our code from whichever device changed it; a write into the other device's range, or both changing a byte differently, fails. A device's freed holes are handed out at their final place (`Device.freed_int_ranges`), so either device's data can go there, with one limit: Zelda's data can't use Mario's holes below 0x080168D8 while Zelda is patched (its buffer holds its stock code there until `Relocation.apply` copies it out at the end), so running both devices' code phases first doesn't help; only `layout.backfill` (after both) reaches them. `Relocation.apply` re-targets our calls and pointers by symbol (its own device's `stock__*`), not by address. Both run in GWemu from the amalgam, switching through the menu (`zelda_menu_test.py --amalgam`, `tools/emu/README.md`); GWemu clears the TAMP registers on reset, so the harness re-writes the selection at the bootloader and persistence needs hardware. -`--sd-bootloader` (Sylverb's bootloader at 0x08032000, which his SD updater writes there): the bank-1 image ends at 0x08032000, and so does our code. `--xip-minigame` is implied: with `--no-factory-test` it frees enough of Zelda's relocated code that the compaction makes room for ours, and Mario's freed code holds data. The Makefile links twice: the plain amalgam layout first (`build/pass1/`; the amalgam linker script INCLUDEs `device.ld`/`amalgam_text.ld` from the `-L` path), whose size of our code `scripts/amalgam_sd.py` turns into `build/amalgam_text.ld`'s start (`__STOCK_ROM_END__`) for the second. If our code grows past what the compaction frees, `Relocation.finalize` fails ("the compacted stock code ends at ..., past our code"); free more of Zelda's code below 0x080168D8 (another XIP unit). The compressed rwdata blocks go into holes one by one (`Region.pending_in_holes`: an allocation must leave every pending blob a place). GWemu: `shift_test.py --delta 0` and `zelda_menu_test.py --amalgam` with a 256 KB bank 1 (the image plus `build/gnw_bootloader_0x08032000.bin`, which `make flash` downloads, at 0x32000); the harness injects buttons after our `read_buttons` wrapper, so a LEFT+GAME check must inject at its call to the stock one. +`--sd-bootloader` (Sylverb's bootloader at 0x08032000, which his SD updater writes there): the bank-1 image ends at 0x08032000, and so does our code. `--xip-minigame` is implied: with `--no-factory-test` it frees enough of Zelda's relocated code that the compaction makes room for ours, and Mario's freed code holds data. The Makefile links twice: the plain amalgam layout first (`build/pass1/`; the amalgam linker script INCLUDEs `device.ld`/`amalgam_text.ld` from the `-L` path), whose size of our code `scripts/amalgam_sd.py` turns into `build/amalgam_text.ld`'s start (`__STOCK_ROM_END__`) for the second. If our code grows past what the compaction frees, `Relocation.finalize` fails ("the compacted stock code ends at ..., past our code"); free more of Zelda's code below 0x080168D8 (another XIP unit). The compressed rwdata blocks go into holes one by one (`Region.pending_in_holes`: an allocation must leave every pending blob a place). GWemu: `shift_test.py --delta 0` and `zelda_menu_test.py --amalgam` (`--sd-bootloader --dual-boot spin` puts a spin image at 0x08032000) with a 256 KB bank 1 (the image plus `build/gnw_bootloader_0x08032000.bin`, which `make flash` downloads, at 0x32000); the harness injects buttons after our `read_buttons` wrapper, so a LEFT+GAME check must inject at its call to the stock one. ### Key Directories - `Core/Src/` - Custom firmware C code: `main.c` (bootloader, LZMA, hooks both models share), `mario.c`/`zelda.c` (model-only code), `game_select*.c` (the dual-boot menu entry: shared drawing in `game_select.c`, one file per model), ips.c, system_stm32h7xx.c. The Makefile compiles a model's sources (names ending in the model) only for that model diff --git a/Core/Inc/game_select.h b/Core/Inc/game_select.h index 832d293..87c985f 100644 --- a/Core/Inc/game_select.h +++ b/Core/Inc/game_select.h @@ -3,13 +3,45 @@ #include #include +#include "model.h" + /** - * Helpers shared by both models' game-select DUAL BOOT entry + * Helpers shared by both models' game-select custom entries * (game_select_mario.c, game_select_zelda.c). */ +// The custom entries: the amalgam's switch to the other firmware (always), +// then the dual-boot target (--game-select-dual-boot). +#if GNW_AMALGAM || GAME_SELECT_DUAL_BOOT +#define GAME_SELECT_MENU 1 +#endif + #define GAME_SELECT_FB_WIDTH 320 +typedef struct { + // Our pointer to the LZMA'd linear 4bpp (high nibble first) icon, + // wherever the patcher put it: internal flash if it fits, else external. + const uint8_t * const volatile *icon; + // Where game_select_inflate_icons() inflates it. + uint8_t *pixels; + // Returns only if the target is invalid. + void (*launch)(void); +} game_select_custom_t; + +/** + * How many of the `n` custom entries to show: the dual-boot target's (the + * last) only if there's something to boot. + */ +uint8_t game_select_n_custom(uint8_t n); + +/** + * Inflates the `n` entries' icons (`size` bytes each) on the first call, + * from the menu's first draw (it only draws while external flash is mapped). + * The streams end with an end marker, so the input bound only has to be + * large enough. + */ +void game_select_inflate_icons(const game_select_custom_t *custom, uint8_t n, uint32_t size, bool *ready); + /** * Moves the first visible entry `*top` of a `visible`-entry window over `n` * entries so it shows `selected`. diff --git a/Core/Inc/main.h b/Core/Inc/main.h index be839f4..9c5f95d 100644 --- a/Core/Inc/main.h +++ b/Core/Inc/main.h @@ -126,17 +126,11 @@ bool dual_boot_target_valid(); */ void launch_dual_boot(); +#if GNW_AMALGAM /** - * The game-select menu's DUAL BOOT entry: the other firmware in the amalgam - * (always there), else the dual-boot target. game_select_launch() returns - * only if the target is invalid. + * Reset into the other firmware (the amalgam's game-select switch entry). */ -#if GNW_AMALGAM -bool game_select_target_valid(); -void game_select_launch(); -#else -#define game_select_target_valid dual_boot_target_valid -#define game_select_launch launch_dual_boot +void game_select_switch(); #endif #ifdef __cplusplus diff --git a/Core/Src/game_select.c b/Core/Src/game_select.c index 19ecc30..cc69e2f 100644 --- a/Core/Src/game_select.c +++ b/Core/Src/game_select.c @@ -1,5 +1,7 @@ /** - * A DUAL BOOT entry in the game-select menu (press GAME). + * Custom entries in the game-select menu (press GAME): in the amalgam, a + * switch to the other firmware, and a DUAL BOOT entry + * (--game-select-dual-boot). * * Mario (game_select_mario.c): the stock menu shows 3 hard-coded icons (SMB1, * SMB2, BALL). Hooks replace its next/prev, input, and draw functions with a @@ -7,15 +9,31 @@ * positions. * * Zelda (game_select_zelda.c): the stock menu fills the screen with 5 rows. - * The entry follows the stock rows (a 6th row, or the 5th with --no-la), and - * the stock draw function shows a window of 5 rows. + * The entries follow the stock rows (from the 6th row, or the 5th with + * --no-la), and the stock draw function shows a window of 5 rows. * * This file holds the drawing and scrolling both share. Hook sites are in * patches/.py; the reverse-engineered stock UI is mapped in * tools/re/.syms. */ +#include "main.h" #include "game_select.h" +uint8_t game_select_n_custom(uint8_t n){ +#if GAME_SELECT_DUAL_BOOT + if(!dual_boot_target_valid()) return n - 1; +#endif + return n; +} + +void game_select_inflate_icons(const game_select_custom_t *custom, uint8_t n, uint32_t size, bool *ready){ + if(*ready) return; + for(uint8_t i = 0; i < n; i++){ + memcpy_inflate(custom[i].pixels, *custom[i].icon, 2 * size); + } + *ready = true; +} + void game_select_scroll(uint8_t *top, uint8_t selected, uint8_t n, uint8_t visible){ if(selected < *top) *top = selected; else if(selected >= *top + visible) *top = selected - visible + 1; diff --git a/Core/Src/game_select_mario.c b/Core/Src/game_select_mario.c index 521e46a..f7b8736 100644 --- a/Core/Src/game_select_mario.c +++ b/Core/Src/game_select_mario.c @@ -1,5 +1,5 @@ /** - * Mario's game-select DUAL BOOT entry (overview in game_select.c). + * Mario's game-select custom entries (overview in game_select.c). * * Custom entries use ids that are not gnw_mode values. Stock code copies the * selected id into gnw_mode only after an A-press on an entry that differs @@ -10,7 +10,7 @@ #include "stock_firmware.h" #include "game_select.h" -#if GAME_SELECT_DUAL_BOOT +#if GAME_SELECT_MENU #define FB_WIDTH GAME_SELECT_FB_WIDTH @@ -33,52 +33,50 @@ #define GAME_SELECT_ID_SMB1 0x10 #define GAME_SELECT_ID_SMB2 0x20 #define GAME_SELECT_ID_BALL 0x08 -// Not a gnw_mode bit; must never reach gnw_mode. -#define GAME_SELECT_ID_DUAL_BOOT 0x0100 +// Custom entry i's id. Not gnw_mode bits; must never reach gnw_mode. +#define GAME_SELECT_ID_CUSTOM(i) (0x0100 << (i)) -/** - * LZMA'd linear 4bpp (high nibble first) icon, wherever the patcher put it: - * internal flash if it fits, else external flash. volatile, so reads aren't - * folded to the NULL initializer. - */ +#define ICON_SIZE (ICON_W * ICON_H / 2) + +// Our pointers to the icons (volatile, so reads aren't folded to the NULL +// initializer). +#if GNW_AMALGAM +const uint8_t * const volatile MARIO_GAME_SELECT_SWITCH_ICON = NULL; +static uint8_t switch_icon[ICON_SIZE]; +#endif +#if GAME_SELECT_DUAL_BOOT const uint8_t * const volatile MARIO_GAME_SELECT_DUAL_BOOT_ICON = NULL; +static uint8_t dual_boot_icon[ICON_SIZE]; +#endif -static uint8_t dual_boot_icon[ICON_W * ICON_H / 2]; -static bool dual_boot_icon_ready; +static const game_select_custom_t custom[] = { +#if GNW_AMALGAM + { &MARIO_GAME_SELECT_SWITCH_ICON, switch_icon, game_select_switch }, +#endif +#if GAME_SELECT_DUAL_BOOT + { &MARIO_GAME_SELECT_DUAL_BOOT_ICON, dual_boot_icon, launch_dual_boot }, +#endif +}; -/** - * The inflated icon; inflated on the first draw (the menu only draws while - * external flash is mapped). The stream ends with an end marker, so the - * input bound only has to be large enough. - */ -static const uint8_t *dual_boot_icon_pixels(void){ - if(!dual_boot_icon_ready){ - memcpy_inflate(dual_boot_icon, MARIO_GAME_SELECT_DUAL_BOOT_ICON, 2 * sizeof(dual_boot_icon)); - dual_boot_icon_ready = true; - } - return dual_boot_icon; -} +#define N_CUSTOM (sizeof(custom) / sizeof(custom[0])) -typedef struct { +static const struct { uint16_t id; - uint8_t tile; // Stock iconset tile index, used if `icon` is NULL. - // Returns a linear 4bpp ICON_W x ICON_H image. - const uint8_t *(*icon)(void); -} game_select_entry_t; - -static const game_select_entry_t entries[] = { - { GAME_SELECT_ID_SMB1, 0x41, NULL }, - { GAME_SELECT_ID_SMB2, 0x47, NULL }, - { GAME_SELECT_ID_BALL, 0x71, NULL }, - { GAME_SELECT_ID_DUAL_BOOT, 0, dual_boot_icon_pixels }, + uint8_t tile; // Stock iconset tile index. +} stock_entries[] = { + { GAME_SELECT_ID_SMB1, 0x41 }, + { GAME_SELECT_ID_SMB2, 0x47 }, + { GAME_SELECT_ID_BALL, 0x71 }, }; -#define N_STOCK_ENTRIES 3 -#define N_ENTRIES (sizeof(entries) / sizeof(entries[0])) +#define N_STOCK_ENTRIES (sizeof(stock_entries) / sizeof(stock_entries[0])) static uint8_t n_visible_entries(){ - // Only offer the dual-boot entry if there's something to boot. - return game_select_target_valid() ? N_ENTRIES : N_STOCK_ENTRIES; + return N_STOCK_ENTRIES + game_select_n_custom(N_CUSTOM); +} + +static uint16_t entry_id(uint8_t i){ + return i < N_STOCK_ENTRIES ? stock_entries[i].id : GAME_SELECT_ID_CUSTOM(i - N_STOCK_ENTRIES); } /** @@ -87,24 +85,25 @@ static uint8_t n_visible_entries(){ */ static uint8_t entry_index(uint16_t id){ for(uint8_t i = 0; i < n_visible_entries(); i++){ - if(entries[i].id == id) return i; + if(entry_id(i) == id) return i; } return 0; } uint16_t mario_game_select_next(uint16_t choice){ uint8_t n = n_visible_entries(); - return entries[(entry_index(choice) + 1) % n].id; + return entry_id((entry_index(choice) + 1) % n); } uint16_t mario_game_select_prev(uint16_t choice){ uint8_t n = n_visible_entries(); - return entries[(entry_index(choice) + n - 1) % n].id; + return entry_id((entry_index(choice) + n - 1) % n); } void mario_game_select_handle_input(gamepad_t held, gamepad_t pressed){ - if(pressed == GAMEPAD_A && held == pressed && *ui_game_select_choice == GAME_SELECT_ID_DUAL_BOOT){ - game_select_launch(); + uint8_t i = entry_index(*ui_game_select_choice); + if(pressed == GAMEPAD_A && held == pressed && i >= N_STOCK_ENTRIES){ + custom[i - N_STOCK_ENTRIES].launch(); // Target invalid; fall back to the current mode so the stock A // handler just closes the menu instead of switching to a bogus mode. *ui_game_select_choice = *ui_mode_on_open; @@ -114,7 +113,9 @@ void mario_game_select_handle_input(gamepad_t held, gamepad_t pressed){ void mario_game_select_draw(uint8_t *fb, ui_rect_t *rects, uint8_t color){ static uint8_t top = 0; + static bool icons_ready; + game_select_inflate_icons(custom, N_CUSTOM, ICON_SIZE, &icons_ready); uint8_t n = n_visible_entries(); uint8_t selected = entry_index(*ui_game_select_choice); game_select_scroll(&top, selected, n, VISIBLE_ENTRIES); @@ -123,15 +124,15 @@ void mario_game_select_draw(uint8_t *fb, ui_rect_t *rects, uint8_t color){ stock_mario_ui_draw_panel(fb + PANEL_Y * FB_WIDTH + PANEL_X, PANEL_W, PANEL_H, color); for(uint8_t slot = 0; slot < VISIBLE_ENTRIES && top + slot < n; slot++){ - const game_select_entry_t *entry = &entries[top + slot]; + uint8_t i = top + slot; uint8_t *dst = fb + (ICON_Y + slot * ICON_PITCH) * FB_WIDTH + ICON_X; - if(entry->icon){ - game_select_blit_icon(dst, entry->icon(), ICON_W, ICON_H, UI_PALETTE); + if(i >= N_STOCK_ENTRIES){ + game_select_blit_icon(dst, custom[i - N_STOCK_ENTRIES].pixels, ICON_W, ICON_H, UI_PALETTE); } else{ - stock_mario_ui_blit_4bpp(dst, stock_mario_ui_tile_ptr(entry->tile), ICON_W, ICON_H, UI_PALETTE >> 4); + stock_mario_ui_blit_4bpp(dst, stock_mario_ui_tile_ptr(stock_entries[i].tile), ICON_W, ICON_H, UI_PALETTE >> 4); } - if(top + slot == selected && !((*ui_blink_counter >> 5) & 1)){ + if(i == selected && !((*ui_blink_counter >> 5) & 1)){ // Stock cursor position: left of the icon, vertically centered. stock_mario_ui_draw_tile16(dst + 16 * FB_WIDTH - 16, stock_mario_ui_tile_ptr(CURSOR_TILE)); } diff --git a/Core/Src/game_select_zelda.c b/Core/Src/game_select_zelda.c index dd92b4e..297cd4f 100644 --- a/Core/Src/game_select_zelda.c +++ b/Core/Src/game_select_zelda.c @@ -1,11 +1,11 @@ /** - * Zelda's game-select DUAL BOOT row (overview in game_select.c). + * Zelda's game-select custom rows (overview in game_select.c). */ #include "main.h" #include "stock_firmware.h" #include "game_select.h" -#if GAME_SELECT_DUAL_BOOT +#if GAME_SELECT_MENU #define FB_WIDTH GAME_SELECT_FB_WIDTH @@ -27,28 +27,29 @@ #define UI_ROW 0x2a #define UI_VARIANTS 0x2d // 2 bytes per row. -/** - * LZMA'd linear 4bpp (high nibble first) icon, wherever the patcher put it: - * internal flash if it fits, else external flash. volatile, so reads aren't - * folded to the NULL initializer. - */ +#define ICON_SIZE (ICON_W * ICON_H / 2) + +// Our pointers to the icons (volatile, so reads aren't folded to the NULL +// initializer). +#if GNW_AMALGAM +const uint8_t * const volatile ZELDA_GAME_SELECT_SWITCH_ICON = NULL; +static uint8_t switch_icon[ICON_SIZE]; +#endif +#if GAME_SELECT_DUAL_BOOT const uint8_t * const volatile ZELDA_GAME_SELECT_DUAL_BOOT_ICON = NULL; +static uint8_t dual_boot_icon[ICON_SIZE]; +#endif -static uint8_t dual_boot_icon[ICON_W * ICON_H / 2]; -static bool dual_boot_icon_ready; +static const game_select_custom_t custom[] = { +#if GNW_AMALGAM + { &ZELDA_GAME_SELECT_SWITCH_ICON, switch_icon, game_select_switch }, +#endif +#if GAME_SELECT_DUAL_BOOT + { &ZELDA_GAME_SELECT_DUAL_BOOT_ICON, dual_boot_icon, launch_dual_boot }, +#endif +}; -/** - * The inflated icon; inflated on the first draw (the menu only draws while - * external flash is mapped). The stream ends with an end marker, so the - * input bound only has to be large enough. - */ -static const uint8_t *dual_boot_icon_pixels(void){ - if(!dual_boot_icon_ready){ - memcpy_inflate(dual_boot_icon, ZELDA_GAME_SELECT_DUAL_BOOT_ICON, 2 * sizeof(dual_boot_icon)); - dual_boot_icon_ready = true; - } - return dual_boot_icon; -} +#define N_CUSTOM (sizeof(custom) / sizeof(custom[0])) /** * menu_rows as the stock draw function sees it (the patcher points its @@ -59,7 +60,8 @@ uint8_t zelda_game_select_view_rows[VISIBLE_ROWS * MENU_ROW_SIZE]; /** * The stock rows in use: the patcher may drop some (--no-la) and moves the - * rest up, leaving a count of 0 in the unused rows. DUAL BOOT follows them. + * rest up, leaving a count of 0 in the unused rows. The custom rows follow + * them. */ static uint8_t n_stock_rows(){ const uint8_t *menu_rows = *stock_menu_rows_literal; @@ -69,8 +71,7 @@ static uint8_t n_stock_rows(){ } static uint8_t n_rows(){ - // Only offer the dual-boot entry if there's something to boot. - return game_select_target_valid() ? n_stock_rows() + 1 : n_stock_rows(); + return n_stock_rows() + game_select_n_custom(N_CUSTOM); } /** @@ -82,15 +83,16 @@ uint32_t zelda_game_select_row_step(volatile uint8_t *row, uint32_t n, gamepad_t } /** - * The DUAL BOOT row has no variants and no app: A boots the target, and - * the stock handler only sees UP/DOWN/B there (its LEFT/RIGHT and A paths - * would act on an empty stock row, or index past menu_rows and the per-row - * state). + * The custom rows have no variants and no app: A boots the target, and the + * stock handler only sees UP/DOWN/B there (its LEFT/RIGHT and A paths would + * act on an empty stock row, or index past menu_rows and the per-row state). */ uint32_t zelda_game_select_handle_input(gamepad_t held, gamepad_t pressed){ - if(stock_ui_state[UI_ROW] == n_stock_rows()){ + uint8_t n_stock = n_stock_rows(); + uint8_t row = stock_ui_state[UI_ROW]; + if(row >= n_stock){ if(pressed == GAMEPAD_A && held == pressed){ - game_select_launch(); + custom[row - n_stock].launch(); } held &= ~(GAMEPAD_A | GAMEPAD_LEFT | GAMEPAD_RIGHT); pressed &= ~(GAMEPAD_A | GAMEPAD_LEFT | GAMEPAD_RIGHT); @@ -104,9 +106,10 @@ uint32_t zelda_game_select_handle_input(gamepad_t held, gamepad_t pressed){ */ void zelda_game_select_draw(uint8_t *fb, void *rects, uint32_t color){ static uint8_t top = 0; + static bool icons_ready; + game_select_inflate_icons(custom, N_CUSTOM, ICON_SIZE, &icons_ready); uint8_t n_stock = n_stock_rows(); - uint8_t dual_boot_row = n_stock; uint8_t n = n_rows(); uint8_t selected = stock_ui_state[UI_ROW]; game_select_scroll(&top, selected, n, VISIBLE_ROWS); @@ -128,9 +131,9 @@ void zelda_game_select_draw(uint8_t *fb, void *rects, uint32_t color){ stock_ui_state[UI_ROW] = selected; for(uint8_t i = 0; i < sizeof(saved); i++) variants[i] = saved[i]; - if(dual_boot_row < n && dual_boot_row - top < VISIBLE_ROWS){ - uint32_t y = ROW_Y + (dual_boot_row - top) * ROW_PITCH; - game_select_blit_icon(fb + y * FB_WIDTH + ICON_X, dual_boot_icon_pixels(), ICON_W, ICON_H, UI_PALETTE); + for(uint8_t row = n_stock > top ? n_stock : top; row < n && row < top + VISIBLE_ROWS; row++){ + uint32_t y = ROW_Y + (row - top) * ROW_PITCH; + game_select_blit_icon(fb + y * FB_WIDTH + ICON_X, custom[row - n_stock].pixels, ICON_W, ICON_H, UI_PALETTE); } // Scroll hints between the panel border and the first/last icon. diff --git a/Core/Src/main.c b/Core/Src/main.c index 281b6e8..9cdd88e 100644 --- a/Core/Src/main.c +++ b/Core/Src/main.c @@ -156,11 +156,7 @@ void launch_dual_boot(){ } #if GNW_AMALGAM -bool game_select_target_valid(){ - return true; -} - -void game_select_launch(){ +void game_select_switch(){ // Reset into the other firmware. PWR->CR1 |= PWR_CR1_DBP; while(!(PWR->CR1 & PWR_CR1_DBP)); diff --git a/Makefile b/Makefile index ced167a..af411c1 100644 --- a/Makefile +++ b/Makefile @@ -95,8 +95,6 @@ ZELDA_SOURCES = Core/Src/game_select_zelda.c Core/Src/zelda.c # --device=amalgam: one image for both firmwares (patches/amalgam.py). ifeq ($(GNW_DEVICE),AMALGAM) C_DEFS += -DGNW_DEVICE_MARIO=1 -DGNW_DEVICE_ZELDA=1 -# Its game-select DUAL BOOT entry is how you switch firmware; always on. -C_DEFS += -DGAME_SELECT_DUAL_BOOT C_SOURCES += $(MARIO_SOURCES) $(ZELDA_SOURCES) LDSCRIPT = STM32H7B0VBTx_FLASH_amalgam.ld # Its linker script INCLUDEs the generated device.ld and amalgam_text.ld. @@ -218,11 +216,13 @@ LDFLAGS = $(MCU) -specs=nano.specs -T$(LDSCRIPT) $(LIBDIR) $(LIBS) -Wl,-Map=$(BU -Wl,--undefined=tick_draw \ -Wl,--undefined=tick_sound \ -Wl,--undefined=MARIO_GAME_SELECT_DUAL_BOOT_ICON \ + -Wl,--undefined=MARIO_GAME_SELECT_SWITCH_ICON \ -Wl,--undefined=mario_game_select_draw \ -Wl,--undefined=mario_game_select_handle_input \ -Wl,--undefined=mario_game_select_next \ -Wl,--undefined=mario_game_select_prev \ -Wl,--undefined=ZELDA_GAME_SELECT_DUAL_BOOT_ICON \ + -Wl,--undefined=ZELDA_GAME_SELECT_SWITCH_ICON \ -Wl,--undefined=zelda_game_select_draw \ -Wl,--undefined=zelda_game_select_handle_input \ -Wl,--undefined=zelda_game_select_row_step \ diff --git a/README.md b/README.md index ff0f40c..a741d84 100644 --- a/README.md +++ b/README.md @@ -164,13 +164,13 @@ Options go in `PATCH_PARAMS`, e.g. `make PATCH_PARAMS="--device=zelda --no-facto | `--relocate-stock=ADDR` | Run the stock code from ADDR. For testing stock-code relocation; see `tools/emu/README.md`. | ### Amalgam (`--device=amalgam`) -Accepts both Mario's and Zelda's options. An option that both devices have (e.g. `--no-sleep-images`) applies to both firmwares. It always uses `--extended`, `--no-factory-test` and `--game-select-dual-boot` (an entry labeled with the other firmware that switches to it; holding GAME at boot returns to Mario), and `--sd-bootloader` adds `--xip-minigame`. It rejects `--encrypt`, `--triple-boot`, `--relocate-stock` and `--internal-only`. LEFT+GAME still boots the dual-boot target. +Accepts both Mario's and Zelda's options. An option that both devices have (e.g. `--no-sleep-images`) applies to both firmwares. It always uses `--extended` and `--no-factory-test`, and `--sd-bootloader` adds `--xip-minigame`. Each firmware's GAME menu always has an entry labeled with the other firmware that switches to it (holding GAME at boot returns to Mario); `--game-select-dual-boot` adds the dual-boot target's entry after it (shown only if one is flashed), and its `-icon`/`-label` options style that entry. It rejects `--encrypt`, `--triple-boot`, `--relocate-stock` and `--internal-only`. LEFT+GAME still boots the dual-boot target. Suggested command, with retro-go-sd (`make flash` also writes Sylverb's SD bootloader to `0x08032000`; leave out `--sd-bootloader` to boot bank 2 instead): ``` make clean -make PATCH_PARAMS="--device=amalgam --sd-bootloader" flash +make PATCH_PARAMS="--device=amalgam --sd-bootloader --game-select-dual-boot" flash ``` ## Build and flash using Docker diff --git a/patches/amalgam.py b/patches/amalgam.py index 7d8bf02..59e27b9 100644 --- a/patches/amalgam.py +++ b/patches/amalgam.py @@ -64,7 +64,7 @@ SD_BOOTLOADER = 0x0803_2000 # Shared by our code and data, no device owns these bytes. SHARED = "both" -# Default label of each device's game-select entry: the firmware it boots. +# Label of each device's game-select switch entry: the firmware it boots. OTHER_LABEL = {"mario": "ZELDA", "zelda": "MARIO"} @@ -101,15 +101,10 @@ def configure(zelda, mario, parser): if args.sd_bootloader: # Our code needs the room it frees in Zelda's code. args.xip_minigame = True - # The game-select DUAL BOOT entry switches to the other firmware - # (main.c), and it's the only way to; label it with that firmware. - args.game_select_dual_boot = True - if ( - args.game_select_dual_boot_label is None - and args.game_select_dual_boot_icon - == p.get_default("game_select_dual_boot_icon") - ): - args.game_select_dual_boot_label = OTHER_LABEL[device.name] + # A game-select entry, labeled with the other firmware, switches to + # it (main.c), and it's the only way to; --game-select-dual-boot adds + # the dual-boot target's entry after it. + args.game_select_switch_label = OTHER_LABEL[device.name] device.amalgam = True device.set_args(args, p) if unknown: diff --git a/patches/firmware.py b/patches/firmware.py index 669446e..2202599 100644 --- a/patches/firmware.py +++ b/patches/firmware.py @@ -14,6 +14,7 @@ from .exception import InvalidStockRomError, MissingSymbolError, NotEnoughSpaceError from .layout import ExtRegion, Region from .patch import FirmwarePatchMixin +from .tileset import png_to_icon_4bpp, render_label_icon from .utils import printd, printi, round_down_word, round_up_page, round_up_word @@ -608,6 +609,31 @@ def __init__(self, internal_bin, internal_elf, external_bin): self.amalgam = False self.ext_base = 0 + def build_game_select_icons(self, width, height, palette, roles): + """The game-select menu's custom entries' icons in menu order, LZMA'd, + as [(symbol, data, label)]: the amalgam's switch to the other firmware, + then the dual-boot target (--game-select-dual-boot).""" + prefix = f"{self.name.upper()}_GAME_SELECT" + icons = [] + if self.amalgam: + icon = render_label_icon( + self.args.game_select_switch_label, width, height, roles + ) + icons.append((f"{prefix}_SWITCH_ICON", icon, "game-select switch icon")) + if self.args.game_select_dual_boot: + if self.args.game_select_dual_boot_label: + icon = render_label_icon( + self.args.game_select_dual_boot_label, width, height, roles + ) + else: + icon = png_to_icon_4bpp( + self.args.game_select_dual_boot_icon, width, height, palette + ) + icons.append( + (f"{prefix}_DUAL_BOOT_ICON", icon, "game-select DUAL BOOT icon") + ) + return [(s, lzma_compress(i), f"{label} (LZMA)") for s, i, label in icons] + @property def stock_map(self): """``patches.xrefs.Analysis`` of the stock firmware (from the backups).""" diff --git a/patches/mario.py b/patches/mario.py index 942bc37..cc55b08 100644 --- a/patches/mario.py +++ b/patches/mario.py @@ -10,10 +10,10 @@ from .firmware import Device, ExtFirmware, Firmware, IntFirmware from .thumb_asm import ThumbAssemblyError, encode_modified_immediate from .tileset import ( + GAME_SELECT_PALETTE, + MARIO_ICON_ROLES, bytes_to_tilemap, decode_backdrop, - png_to_icon_4bpp, - render_label_icon, tilemap_to_bytes, ) from .utils import ( @@ -29,9 +29,6 @@ # BALL's code: its API (called from main_loop_tick), game, sound and save. # Its static constructor (zeroes 3 halfwords of ball_ctx) stays internal. BALL_UNIT = (0x0800_3350, 0x0800_45D2) -# Our pointer to the game-select DUAL BOOT icon (game_select_mario.c). -ICON_SYMBOL = "MARIO_GAME_SELECT_DUAL_BOOT_ICON" - build_dir = Path("build") # TODO: expose this properly or put in better location @@ -51,7 +48,8 @@ class MarioGnW(Device, name="mario"): # included. STOCK_RELOCATE_END = 0x0801_807C xip_ball = None - dual_boot_icon = None + # The game-select menu's custom entries' icons (Device.build_game_select_icons). + game_select_icons = [] class Int(IntFirmware): STOCK_ROM_SHA1_HASH = "efa04c387ad7b40549e15799b471a6e1cd234c76" @@ -401,8 +399,12 @@ def patch(self): # Its vsnprintf and helpers, down to memchr. self.free_int(0x1_73EA, 0x1_7A30) - if self.args.game_select_dual_boot: - printi("Add dual-boot entry to the game-select menu.") + # Placed after the stock data moves, in whatever room they leave. + self.game_select_icons = self.build_game_select_icons( + 96, 48, GAME_SELECT_PALETTE, MARIO_ICON_ROLES + ) + if self.game_select_icons: + printi("Add custom entries to the game-select menu.") # Each hooked function has exactly one call site. self.internal.bl(0xC894, "mario_game_select_prev", expect="4:f87db677") self.internal.bl(0xC89C, "mario_game_select_next", expect="4:f2501abf") @@ -410,12 +412,6 @@ def patch(self): 0xCB18, "mario_game_select_handle_input", expect="4:fa0dc437" ) self.internal.bl(0xD236, "mario_game_select_draw", expect="4:37ecc1ee") - if self.args.game_select_dual_boot_label: - icon = render_label_icon(self.args.game_select_dual_boot_label) - else: - icon = png_to_icon_4bpp(self.args.game_select_dual_boot_icon) - # Placed after the stock data moves, in whatever room they leave. - self.dual_boot_icon = lzma_compress(icon) printi("Mute clock audio on first boot.") self.internal.asm(0x49E0, "mov.w r1, #0x00000", expect="4:42e9afc9") @@ -935,21 +931,19 @@ def patch(self): # The TIME graphic of the first-boot PRESS TIME BUTTON screen. self.move_to_compressed_memory(0xF4D18, 2880, 0x10960) - icon_ext = None - if self.dual_boot_icon is not None: - printi("Place the game-select DUAL BOOT icon.") - if not self.place_int( - ICON_SYMBOL, self.dual_boot_icon, "game-select DUAL BOOT icon (LZMA)" - ): - icon_ext = self.dual_boot_icon + icons_ext = [] + for symbol, icon, label in self.game_select_icons: + printi(f"Place the {label}.") + if not self.place_int(symbol, icon, label): + icons_ext.append((symbol, icon, label)) # An ITCM overlay that game_reload copies with length 0 (literal at # 0x08007210), so it's never used. self.external.replace(0xF5858, b"\x00" * 34728) xip_size = round_up_word(self.xip_ball.ext_size) if self.xip_ball else 0 - icon_size = round_up_word(len(icon_ext)) if icon_ext else 0 + icon_size = sum(round_up_word(len(icon)) for _, icon, _ in icons_ext) if xip_size or icon_size: - # Room for BALL's code and the icon; written once pointers are fixed. + # Room for BALL's code and the icons; written once pointers are fixed. self.ext_space.keep(0xF5858) dead_offset = 0xF5858 + self.ext_space.shift self.ext_space.cursor += xip_size + icon_size @@ -958,13 +952,9 @@ def patch(self): self.fix_ext_refs() if self.xip_ball: self.xip_ball.write(dead_offset) - if icon_ext: - self.place_ext( - ICON_SYMBOL, - dead_offset + xip_size, - icon_ext, - "game-select DUAL BOOT icon (LZMA)", - ) + for symbol, icon, label in icons_ext: + self.place_ext(symbol, dead_offset + xip_size, icon, label) + xip_size += round_up_word(len(icon)) if self.relocation: self.relocation.fix_ram_images() diff --git a/patches/zelda.py b/patches/zelda.py index c0a0242..b1dc1f7 100644 --- a/patches/zelda.py +++ b/patches/zelda.py @@ -62,13 +62,7 @@ from .exception import InvalidStockRomError from .firmware import Device, ExtFirmware, Firmware, IntFirmware from .thumb_asm import assemble as thumb_assemble -from .tileset import ( - ZELDA_GAME_SELECT_PALETTE, - ZELDA_ICON_ROLES, - decode_backdrop, - png_to_icon_4bpp, - render_label_icon, -) +from .tileset import ZELDA_GAME_SELECT_PALETTE, ZELDA_ICON_ROLES, decode_backdrop from .utils import fds_remove_crc_gaps, printd, printi, round_up_page, round_up_word from .xip import XipUnit @@ -105,8 +99,6 @@ # Vermin's code: its API (called from main_loop_tick and app_load), game, # sound and high-score save. VERMIN_UNIT = (0x0801_4938, 0x0801_6268) -# Our pointer to the game-select DUAL BOOT icon (game_select_zelda.c). -ICON_SYMBOL = "ZELDA_GAME_SELECT_DUAL_BOOT_ICON" # The last copy is the menu UI, linked at ITCM 0x9C7C. ITCM past its end is # never loaded, so veneers from ITCM to our code are appended to it. ITCM_MENU, ITCM_MENU_SRC, ITCM_MENU_SIZE = 0x9C7C, 0x31_56EC, 0x1B44 @@ -123,8 +115,9 @@ class ZeldaGnW(Device, name="zelda"): AMALGAM_RELOCATE_END = 0x0801_68D8 AMALGAM_KEPT_START = 0x0801_8154 xip_vermin = None - # The game-select DUAL BOOT icon if internal flash had no room. - dual_boot_icon_ext = None + # The game-select menu's custom entries' icons that internal flash had + # no room for, as (symbol, data, label). + game_select_icons_ext = [] class Int(IntFirmware): STOCK_ROM_SHA1_HASH = "ac14bcea6e4ff68c88fd2302c021025a2fb47940" @@ -303,8 +296,8 @@ def _game_select_drop_la_row(self): """Remove LA's game-select row; VERMIN and TIMER move up into its place. The 5th row is left empty (a count of 0, which game_select_draw skips), - and the UP/DOWN cursor wraps over 4 rows. With --game-select-dual-boot, - DUAL BOOT takes the empty row (game_select_zelda.c counts the rows). + and the UP/DOWN cursor wraps over 4 rows. Custom rows (the amalgam's switch, + --game-select-dual-boot) follow (game_select_zelda.c counts the rows). """ printi("Remove the LA row from the game-select menu.") # menu_rows: 5 rows x 11 bytes, [count, variant ids...]; LA's are 4-7. @@ -325,13 +318,14 @@ def _game_select_drop_la_row(self): # replaces the call and counts the rows itself). self._itcm_menu_patch(0xA572, "movs r1, #4", expect="2:70c7f6e4") - def _game_select_dual_boot(self): - """DUAL BOOT after the stock game-select rows, in a scrolling 5-row window. + def _game_select_menu(self, icons): + """Custom rows (the amalgam's switch, DUAL BOOT) after the stock + game-select rows, in a scrolling 5-row window. See game_select_zelda.c. Runs after fix_ext_refs(), so the menu_rows literals hold their final address. """ - printi("Add dual-boot entry to the game-select menu.") + printi("Add custom entries to the game-select menu.") # game_select_handle_input's UP/DOWN step over the 5 rows. veneer = self._itcm_veneer("zelda_game_select_row_step") self._itcm_menu_patch(0xA576, f"bl #{veneer:#x}", expect="4:9be285a5") @@ -352,18 +346,12 @@ def _game_select_dual_boot(self): literal, self.internal.address("zelda_game_select_view_rows"), size=4 ) - if self.args.game_select_dual_boot_label: - icon = render_label_icon( - self.args.game_select_dual_boot_label, 96, 32, ZELDA_ICON_ROLES - ) - else: - icon = png_to_icon_4bpp( - self.args.game_select_dual_boot_icon, 96, 32, ZELDA_GAME_SELECT_PALETTE - ) # After the stock data moves, in whatever room they leave. - icon = lzma_compress(icon) - if not self.place_int(ICON_SYMBOL, icon, "game-select DUAL BOOT icon (LZMA)"): - self.dual_boot_icon_ext = icon + self.game_select_icons_ext = [ + (symbol, icon, label) + for symbol, icon, label in icons + if not self.place_int(symbol, icon, label) + ] def _flash_roms(self): # English Zelda 1 @@ -701,12 +689,9 @@ def _finish_ext(self): data_end += round_up_word(len(blob)) if self.xip_vermin: data_end += round_up_word(self.xip_vermin.write(data_end)) - if self.dual_boot_icon_ext: - printi("Put the game-select DUAL BOOT icon in external flash.") - icon = self.dual_boot_icon_ext - self.place_ext( - ICON_SYMBOL, data_end, icon, "game-select DUAL BOOT icon (LZMA)" - ) + for symbol, icon, label in self.game_select_icons_ext: + printi(f"Put the {label} in external flash.") + self.place_ext(symbol, data_end, icon, label) data_end += round_up_word(len(icon)) # The first 4 copies are bl memcpy, the last a tail call. self.internal.bl(0xF7A2, "memcpy_inflate", expect="4:d2879707") @@ -923,8 +908,11 @@ def patch(self): self._compact_ext() self.fix_ext_refs() - if self.args.game_select_dual_boot: - self._game_select_dual_boot() + icons = self.build_game_select_icons( + 96, 32, ZELDA_GAME_SELECT_PALETTE, ZELDA_ICON_ROLES + ) + if icons: + self._game_select_menu(icons) if self.relocation: # Before _finish_ext() compresses the ITCM overlay. self.relocation.fix_ram_images() diff --git a/tests/golden.json b/tests/golden.json index d2e7848..6fa3066 100644 --- a/tests/golden.json +++ b/tests/golden.json @@ -1,16 +1,16 @@ { "configs": { "amalgam": { - "external_flash_patched.bin": "8553e79dc99b5ed93b33d2408bfb8b66decf117e", - "internal_flash_patched.bin": "e831b624b2d9c44849ab9a5e13b50d27925397e2" + "external_flash_patched.bin": "ce089513fdd4f86f4ba32eab1c351084ae0270e0", + "internal_flash_patched.bin": "e1a89994ae1973dbaceadab83d51f8f2e507d129" }, "amalgam_sd": { - "external_flash_patched.bin": "247e0c5cabde5b08ac6aee48f456c262ac6ac39b", - "internal_flash_patched.bin": "edb71cec7d08359288f5dbc26ee1b18349e51b10" + "external_flash_patched.bin": "1418c6c6b4415589c5fb5433fac1709fd8b23846", + "internal_flash_patched.bin": "42422b2a3db0bdc8898a1dd32c8e27ea7c8ce905" }, "amalgam_xip": { - "external_flash_patched.bin": "fb0ab041d498602874d659417fd6b78c0432740c", - "internal_flash_patched.bin": "34f624e186d9da377095978c827debb1cc446f6a" + "external_flash_patched.bin": "e193a30922cdbdef7cf8a71973e5bf8be907bb00", + "internal_flash_patched.bin": "bc2f6ebf18e47d426dfc98cc93b72247d89f2037" }, "mario": { "external_flash_patched.bin": "418ee5121bdadb652dc663e1e19e374d6013a246", @@ -29,8 +29,8 @@ "internal_flash_patched.bin": "f3c5fcdb3f00aee6c24ea772871ba041efd1d83b" }, "mario_options": { - "external_flash_patched.bin": "1903287b90e71f3d54d3f7546e4bceb3a482b111", - "internal_flash_patched.bin": "1053ab017f2d51f91e938dfcad6ac9d50baf760d" + "external_flash_patched.bin": "f18a3a01586192af953204de64ae33fb8ebb3517", + "internal_flash_patched.bin": "b9ea5fe3d0aca9e00a38fe343ab8c0d251dca485" }, "mario_relocate": { "external_flash_patched.bin": "63f58c707901db5645c71be52bdd4ad053e63806", @@ -46,8 +46,8 @@ "internal_flash_patched.bin": "842998032a6d683a85c3b1611c801710c19d07ee" }, "zelda_options": { - "external_flash_patched.bin": "96256696fa909432b5fdc6b2d9e7098699aa062f", - "internal_flash_patched.bin": "25acd50f39012e969714af35e58b27c483382002" + "external_flash_patched.bin": "2b4c51b97b64fffeb86883ff38806e161e50f75d", + "internal_flash_patched.bin": "86adb4bdbea749f2ee6b978f00b3ab96ae0ea98a" }, "zelda_relocate": { "external_flash_patched.bin": "e71d1931a6726a25e4e4c89d61268170777d3450", diff --git a/tools/emu/README.md b/tools/emu/README.md index fec263e..370bee8 100644 --- a/tools/emu/README.md +++ b/tools/emu/README.md @@ -164,7 +164,7 @@ build/gwprov-venv/bin/python tools/emu/shift_test.py --gwemu-bin /path/to/gwemu ## Amalgam (Mario + Zelda in one image) -`--device=amalgam` boots Mario until a game-select DUAL BOOT entry (always on in +`--device=amalgam` boots Mario until a game-select switch entry (always on in the amalgam, labeled ZELDA/MARIO) switches firmware with a reset. Mario's stock code stays in place (no `--delta`), and its external data starts after Zelda's: @@ -181,5 +181,10 @@ Zelda checks on the firmware it resets into (Zelda's code below its factory test ends with Zelda's MARIO row resetting back into Mario. The selection lives in TAMP backup register 31, which GWemu clears on reset; the harness checks the value written before the reset and writes it back when the bootloader starts. -Persistence across power-off needs hardware. Mario comes back at its PRESS +Persistence across power-off needs hardware. For a `--game-select-dual-boot` +amalgam, add `--dual-boot spin` (both menus must list DUAL BOOT after the +switch, and the run ends with A on Zelda's DUAL BOOT row spinning in the +target instead of switching back) or `--dual-boot erased` (hidden); add +`--sd-bootloader` for an `--sd-bootloader` build (the spin image goes at +0x08032000). Mario comes back at its PRESS TIME screen in GWemu (check on hardware whether it keeps the time). `shift_test.py` covers Mario only. diff --git a/tools/emu/emu_driver.py b/tools/emu/emu_driver.py index 54d2076..2c4c69f 100644 --- a/tools/emu/emu_driver.py +++ b/tools/emu/emu_driver.py @@ -93,17 +93,20 @@ def make_profile( gwprov("profile", profile, "edit", "add", bank, name, image, "--offset", 0) -def make_bank2(path: Path, kind: str, base: Path = None, spin_pc: int = 0x0810_0008): - """Bank 2: ``base`` (or erased flash), with a tiny image or erased header at 0.""" +def make_bank2( + path: Path, kind: str, base: Path = None, spin_pc: int = 0x0810_0008, at: int = 0 +): + """Bank 2: ``base`` (or erased flash), with a tiny image or erased header at + ``at`` (bank 1 with ``at=0x32000`` for the SD bootloader's place).""" data = bytearray(b"\xff" * 0x40000) if base is not None: image = base.read_bytes() data[: len(image)] = image - data[:16] = b"\xff" * 16 + data[at : at + 16] = b"\xff" * 16 if kind == "spin": # Initial SP, reset vector -> spin_pc (Thumb), then `b .`. - data[0:8] = struct.pack(" list: - """Open the menu and press DOWN 6 times from the first row; returns the rows - the cursor visited, which should wrap after ``n``.""" +# DOWN presses in row_wrap: past the most rows (5 stock and 2 custom). +WRAP_PRESSES = 8 + + +def row_wrap(d: Driver) -> list: + """Open the menu and press DOWN WRAP_PRESSES times from the first row; + returns the rows the cursor visited.""" d.press_until("GAME", lambda: app(d) == APP_MENU, "the game-select menu") while d.read8(UI_ROW) != 0: step(d, "UP") rows = [] - for _ in range(6): + for _ in range(WRAP_PRESSES): step(d, "DOWN") rows.append(d.read8(UI_ROW)) return rows def wraps(n: int) -> list: - return [(i + 1) % n for i in range(6)] + return [(i + 1) % n for i in range(WRAP_PRESSES)] -def dual_boot(d: Driver, bank2: str, n_stock: int) -> bool: +def dual_boot(d: Driver, bank2: str, n_stock: int, amalgam: bool, spin_pc: int) -> bool: + """The custom rows after the stock ones: the amalgam's switch, then DUAL + BOOT (``bank2`` "spin"; "erased" hides it, None: not built).""" ok = True - dual_boot_row = n_stock - rows = row_wrap(d, n_stock) - if bank2 == "erased": + switch_row = n_stock if amalgam else None + dual_boot_row = n_stock + amalgam if bank2 == "spin" else None + n = n_stock + amalgam + (bank2 == "spin") + rows = row_wrap(d) + if n == n_stock: d.shot("90_menu_no_dual_boot") return check(rows == wraps(n_stock), f"DUAL BOOT row hidden (DOWN: {rows})") - n = n_stock + 1 ok &= check(rows == wraps(n), f"{n} rows, DOWN wraps (DOWN: {rows})") while d.read8(UI_ROW) != 0: step(d, "UP") step(d, "UP") - ok &= check( - d.read8(UI_ROW) == dual_boot_row, "UP from the first row wraps to DUAL BOOT" - ) - d.shot("90_menu_dual_boot") + ok &= check(d.read8(UI_ROW) == n - 1, "UP from the first row wraps to the last") + d.shot("90_menu_custom") # Everything from the slide offset on, past the blink counter (+0x08). before = d.read(UI_STATE + 0x28, 0x28) @@ -167,10 +183,10 @@ def dual_boot(d: Driver, bank2: str, n_stock: int) -> bool: d.tap("RIGHT") after = d.read(UI_STATE + 0x28, 0x28) ok &= check( - before == after and app(d) == APP_MENU, "LEFT/RIGHT do nothing on DUAL BOOT" + before == after and app(d) == APP_MENU, "LEFT/RIGHT do nothing on the last row" ) - # Slide a carousel; with 6 rows, LA's, with the window scrolled down by a row. + # Slide a carousel; past 5 rows, LA's, with the window scrolled down. row = 2 if n > 5 else 0 while d.read8(UI_ROW) != row: step(d, "UP") @@ -184,17 +200,19 @@ def dual_boot(d: Driver, bank2: str, n_stock: int) -> bool: d.shot("91_menu_carousel") ok &= check(app(d) == APP_MENU, f"carousel slides ({n} rows)") - while d.read8(UI_ROW) != dual_boot_row: - step(d, "DOWN") - if bank2 == "amalgam": + if dual_boot_row is None: + while d.read8(UI_ROW) != switch_row: + step(d, "DOWN") ok &= amalgam_switch(d, BOOT_MARIO, mario.READ_BUTTONS_RETURN, 0x0800_0000) d.frames("", 30) d.shot("92_mario_again") return ok + while d.read8(UI_ROW) != dual_boot_row: + step(d, "DOWN") d.last_frame("A") # The firmware resets during this frame. time.sleep(1) pc = d.pc() - ok &= check(pc & ~1 == BANK2_SPIN_PC, f"A on DUAL BOOT runs bank 2 (pc=0x{pc:08X})") + ok &= check(pc & ~1 == spin_pc, f"A on DUAL BOOT runs the target (pc=0x{pc:08X})") return ok @@ -238,8 +256,9 @@ def amalgam_switch(d: Driver, boot: int, poll_pc: int, vtor: int) -> bool: return ok & check(got == vtor, f"booted with VTOR=0x{got:08X}") -def amalgam_to_zelda(d: Driver) -> bool: - """Boot Mario and switch to Zelda from its game-select menu.""" +def amalgam_to_zelda(d: Driver, bank2: str) -> bool: + """Boot Mario, check its game-select entries (DUAL BOOT per ``bank2``, as + in ``dual_boot``), and switch to Zelda from the menu.""" d.frames("", 30) d.tap("TIME", settle=30) # Mario's first-boot PRESS TIME screen. d.wait_until( @@ -249,16 +268,41 @@ def amalgam_to_zelda(d: Driver) -> bool: d.press_until("GAME", lambda: d.read8(mario.UI_STATE) != 0, "Mario's overlay") d.wait_until(lambda: d.read8(mario.UI_STATE) == 5, "Mario's game-select menu") choice = mario.UI_GAME_SELECT_CHOICE - while d.read16(choice) != mario.CHOICE_DUAL_BOOT: + + def down(): before = d.read16(choice) d.press_until("DOWN", lambda: d.read16(choice) != before, "the cursor to move") + + entries = [MARIO_SMB1, MARIO_SMB2, MARIO_BALL, MARIO_SWITCH] + if bank2 == "spin": + entries.append(MARIO_DUAL_BOOT) + while d.read16(choice) != MARIO_SMB1: + down() + seen = [] + for _ in range(len(entries)): + down() + seen.append(d.read16(choice)) + ok = check( + seen == entries[1:] + entries[:1], + f"Mario's {len(entries)} entries wrap (DOWN: {[hex(c) for c in seen]})", + ) + while d.read16(choice) != MARIO_SWITCH: + down() d.shot("00_mario_menu_zelda") - return amalgam_switch( + return ok & amalgam_switch( d, BOOT_ZELDA, final_address(READ_BUTTONS_RETURN), 0x0801_B400 ) -def run(d: Driver, games, run_frames: int, bank2: str = None, n_stock: int = 5) -> bool: +def run( + d: Driver, + games, + run_frames: int, + bank2: str = None, + n_stock: int = 5, + amalgam: bool = False, + spin_pc: int = BANK2_SPIN_PC, +) -> bool: ok = True d.frames("", 30) ok &= check(app(d) == APP_FIRST_BOOT, f"first-boot screen (g_app_id={app(d)})") @@ -284,10 +328,10 @@ def run(d: Driver, games, run_frames: int, bank2: str = None, n_stock: int = 5) ok &= check(False, f"{name}: {e}") return ok # The firmware is likely stuck; later checks would only time out. try: - if bank2: - ok &= dual_boot(d, bank2, n_stock) + if bank2 or amalgam: + ok &= dual_boot(d, bank2, n_stock, amalgam, spin_pc) else: - rows = row_wrap(d, n_stock) + rows = row_wrap(d) d.shot("90_menu") ok &= check(rows == wraps(n_stock), f"{n_stock} rows (DOWN: {rows})") except RuntimeError as e: @@ -320,6 +364,11 @@ def main(): action="store_true", help="Check a --device=amalgam build, booting Zelda from Mario (see above).", ) + parser.add_argument( + "--sd-bootloader", + action="store_true", + help="The dual-boot target is at 0x08032000 (an --sd-bootloader build).", + ) parser.add_argument( "--no-la", action="store_true", @@ -358,15 +407,17 @@ def main(): out_dir = work / "zelda_shots" out_dir.mkdir(parents=True, exist_ok=True) profile = work / "zelda-profile" - bank2 = work / f"bank2_{args.dual_boot or 'erased'}.bin" - make_bank2(bank2, args.dual_boot or "erased", args.bank2_image, BANK2_SPIN_PC) - make_profile( - profile, - bank2, - args.internal.resolve(), - args.external.resolve(), - model="zelda", - ) + internal = args.internal.resolve() + spin_pc = BANK2_SPIN_PC + bank2_kind = args.dual_boot or "erased" + if args.sd_bootloader: + spin_pc = SD_SPIN_PC + sd_bank1 = work / f"bank1_sd_{bank2_kind}.bin" + make_bank2(sd_bank1, bank2_kind, internal, spin_pc, at=SD_BOOTLOADER_OFFSET) + internal, bank2_kind = sd_bank1, "erased" + bank2 = work / f"bank2_{bank2_kind}.bin" + make_bank2(bank2, bank2_kind, args.bank2_image, BANK2_SPIN_PC) + make_profile(profile, bank2, internal, args.external.resolve(), model="zelda") start_instance(str(profile), headless=True, gwemu_bin=str(args.gwemu_bin.resolve())) pid = next(r["pid"] for r in instances() if r["profile"] == str(profile)) @@ -374,13 +425,17 @@ def main(): time.sleep(3) if args.amalgam: d = Driver(pid, out_dir, mario.READ_BUTTONS_RETURN) - ok = amalgam_to_zelda(d) + ok = amalgam_to_zelda(d, args.dual_boot) if ok: - ok &= run(d, games, args.run_frames, "amalgam", n_stock) + ok &= run( + d, games, args.run_frames, args.dual_boot, n_stock, True, spin_pc + ) else: poll_pc = READ_BUTTONS_RETURN + args.stock_offset d = Driver(pid, out_dir, poll_pc) - ok = run(d, games, args.run_frames, args.dual_boot, n_stock) + ok = run( + d, games, args.run_frames, args.dual_boot, n_stock, spin_pc=spin_pc + ) finally: stop_instance(pid=pid) print(f"Screenshots: {out_dir}")