Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<model>.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_<device>_*`), 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
Expand Down
34 changes: 33 additions & 1 deletion Core/Inc/game_select.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,45 @@
#include <stdbool.h>
#include <stdint.h>

#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`.
Expand Down
12 changes: 3 additions & 9 deletions Core/Inc/main.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions Core/Src/game_select.c
Original file line number Diff line number Diff line change
@@ -1,21 +1,39 @@
/**
* 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
* list of entries shown through a scrolling window of 3 slots at the stock
* 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/<device>.py; the reverse-engineered stock UI is mapped in
* tools/re/<device>.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;
Expand Down
97 changes: 49 additions & 48 deletions Core/Src/game_select_mario.c
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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

Expand All @@ -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);
}

/**
Expand All @@ -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;
Expand All @@ -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);
Expand All @@ -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));
}
Expand Down
Loading
Loading