Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
255c4cd
launcher: games grouped by console, console badge, saved settings, re…
finger563 Nov 11, 2026
03200cb
USB link: composite device (console, test-control link, SD card drive…
finger563 Nov 11, 2026
c359e22
Screenshots straight from the game's frame buffer; usb_device at the …
finger563 Nov 11, 2026
408b79a
PICO-8 carts load again with the USB link; stderr on the USB console
finger563 Nov 11, 2026
222199f
NES and SMS stop losing PSRAM; NES no longer plays into a freed buffer
finger563 Nov 12, 2026
38895f7
Link status: frames presented / dropped and the heaps' low-water marks
finger563 Nov 12, 2026
389e912
Bump femto8: release load buffers on cstore() errors
finger563 Nov 12, 2026
b827d8b
Box art for the PICO-8 carts, MGS, Batman & Robin and THPS2; safe JPE…
finger563 Nov 12, 2026
f783008
Untrack the boxart images committed by mistake in b827d8b
finger563 Nov 12, 2026
df97559
Dark Forces loads its levels again: IDF flash driver, not the ROM's
finger563 Nov 12, 2026
d8e1105
Doom II no longer crashes precaching: Doom's overflow stays in PSRAM
finger563 Nov 12, 2026
a8e7f26
Every core gives back what it allocates
finger563 Nov 12, 2026
401acc1
Bump femto8: wait out a draw before deleting its semaphore
finger563 Nov 12, 2026
81c1f84
Bump femto8 to the merged draw-semaphore fix (esp-cpp/femto8#2)
finger563 Nov 12, 2026
eefd1e3
Address the Copilot reviews: SD ownership, transfers, updater, slots
finger563 Nov 12, 2026
43d54bd
Copilot round 3: save/load results, exact launch match, locking
finger563 Nov 12, 2026
ce8a249
Copilot round 5: files in use by the game, SMS screenshots, SMS/NES s…
finger563 Nov 13, 2026
ff2dda7
Fix the cppcheck findings
finger563 Nov 13, 2026
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
9 changes: 6 additions & 3 deletions .github/workflows/package_main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@ jobs:
esp_idf_version: ${{ env.IDF_VERSION }}
target: ${{ env.IDF_TARGET }}
path: '.'
# the second command records the esptool version this build used, so
# the updater (ota_1) is built first: the emulator's build adds it to
# flash_args / flasher_args.json. The last command records the esptool version this build used, so
# the packaging job packages the same one (its flash arguments follow it)
command: './patches.sh && idf.py build && python -m esptool version | tail -1 > build/esptool_version.txt'
command: './patches.sh && idf.py -C updater build && idf.py build && python -m esptool version | tail -1 > build/esptool_version.txt'

- name: Zip up firmware binaries
run: |
# zip the firmware bin files and flash args
zip -j -r firmware-binaries.zip build/esp-box-emu.bin build/bootloader/bootloader.bin build/partition_table/partition-table.bin build/flash_args build/flasher_args.json build/esptool_version.txt
zip -j -r firmware-binaries.zip build/esp-box-emu.bin build/bootloader/bootloader.bin build/partition_table/partition-table.bin build/ota_data_initial.bin build/updater/esp-box-emu-updater.bin build/flash_args build/flasher_args.json build/esptool_version.txt

- name: Upload Build Outputs
uses: actions/upload-artifact@v4
Expand All @@ -53,6 +54,8 @@ jobs:
build/bootloader/bootloader.bin
build/partition_table/partition-table.bin
build/esp-box-emu.bin
build/ota_data_initial.bin
build/updater/esp-box-emu-updater.bin
build/flasher_args.json
build/flash_args
build/esptool_version.txt
Expand Down
18 changes: 17 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,27 @@ set(EMULATOR_COMPONENTS

set(
COMPONENTS
"main esptool_py esp_lcd esp_psram task format display display_drivers monitor timer ${EMULATOR_COMPONENTS} box-emu gui menu"
"main esptool_py esp_lcd esp_psram task format display display_drivers monitor timer ${EMULATOR_COMPONENTS} box-emu gui menu settings devlink"
CACHE STRING
"List of components to include"
FORCE
)

project(esp-box-emu)

set(CMAKE_CXX_STANDARD 20)

# The updater app (updater/) belongs in ota_1 (see partitions.csv): build it
# first (idf.py -C updater build) and `idf.py flash`, flash_args and
# flasher_args.json (the release package) put it there with the emulator.
set(UPDATER_BIN "${CMAKE_CURRENT_LIST_DIR}/updater/build/esp-box-emu-updater.bin")
if(EXISTS "${UPDATER_BIN}")
# (a copy in the build dir: flash_args lists it next to the others, and a
# rebuilt updater reconfigures this)
configure_file("${UPDATER_BIN}" "${CMAKE_BINARY_DIR}/updater/esp-box-emu-updater.bin" COPYONLY)
partition_table_get_partition_info(updater_offset "--partition-type app --partition-subtype ota_1" "offset")
esptool_py_flash_target_image(flash updater "${updater_offset}" "${CMAKE_BINARY_DIR}/updater/esp-box-emu-updater.bin")
else()
message(WARNING "no updater image (${UPDATER_BIN}): build it with idf.py -C updater build, then reconfigure, "
"or OTA updates and rollback have no updater to boot")
endif()
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,15 @@ git submodule update --init --recursive
Then build, flash, and open the serial monitor with [ESP-IDF](https://docs.espressif.com/projects/esp-idf/en/latest/esp32s3/get-started/index.html):

```sh
idf.py -C updater build # once: the updater app, which goes in ota_1
idf.py -p PORT flash monitor
```

The updater (`updater/`) is what over-USB updates (`tools/emulink`) reboot
into, and what a bad update rolls back to, so build it before the first
flash: the emulator's `idf.py flash` then writes both. After that, updates
can go over USB with `emulink ota`.

Replace `PORT` with your board's serial port. To exit the monitor, type
`Ctrl-]`.

Expand Down
2 changes: 2 additions & 0 deletions bootloader_components/updater_switch/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
idf_component_register(SRCS "updater_switch.c"
PRIV_REQUIRES bootloader_support esp_hw_support soc spi_flash)
85 changes: 85 additions & 0 deletions bootloader_components/updater_switch/updater_switch.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Bootloader hook: boot the updater app (ota_1) once, when the app asked for
// it through an RTC register.
//
// The box's app cannot write its own flash: with the flash and the octal PSRAM
// at 120 MHz its writes report success but do not land (they do at 80 MHz,
// which Sonic 3 cannot afford). The bootloader runs the flash at the image
// header's speed, so it writes the otadata entry instead, before the usual
// boot partition selection reads it.
#include <string.h>

#include "bootloader_common.h"
#include "bootloader_flash_priv.h"
#include "esp_flash_partitions.h"
#include "esp_log.h"
#include "soc/rtc_cntl_reg.h"
#include "soc/soc.h"

// Shared with main/remote_control.hpp (BootSwitch). RTC_CNTL_STORE0 is
// reserved for the application; it survives a software reset.
#define UPDATER_SWITCH_REG RTC_CNTL_STORE0_REG
#define UPDATER_SWITCH_MAGIC 0x55504454u // "UPDT"

static const char *TAG = "updater_switch";

// keep this file in the bootloader link (its hooks are weak symbols)
void bootloader_hooks_include(void) {}

static bool find_partitions(esp_partition_pos_t *otadata, int *app_slots) {
esp_partition_info_t entry;
bool found = false;
*app_slots = 0;
for (int i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) {
if (bootloader_flash_read(ESP_PARTITION_TABLE_OFFSET + i * sizeof(entry), &entry, sizeof(entry), false) != ESP_OK ||
entry.magic != ESP_PARTITION_MAGIC) {
break;
}
if (entry.type == PART_TYPE_DATA && entry.subtype == PART_SUBTYPE_DATA_OTA) {
*otadata = entry.pos;
found = true;
} else if (entry.type == PART_TYPE_APP && (entry.subtype & ~PART_SUBTYPE_OTA_MASK) == PART_SUBTYPE_OTA_FLAG) {
(*app_slots)++;
}
}
return found && *app_slots >= 2;
}

void bootloader_after_init(void) {
if (REG_READ(UPDATER_SWITCH_REG) != UPDATER_SWITCH_MAGIC) {
return;
}
REG_WRITE(UPDATER_SWITCH_REG, 0); // once: a failed updater boots the app again
esp_partition_pos_t otadata;
int slots;
if (!find_partitions(&otadata, &slots)) {
ESP_LOGE(TAG, "no otadata / ota_1 partition");
return;
}
const uint32_t sector = 0x1000;
esp_ota_select_entry_t e[2];
for (int i = 0; i < 2; i++) {
if (bootloader_flash_read(otadata.offset + i * sector, &e[i], sizeof(e[i]), false) != ESP_OK) {
return;
}
}
const bool v0 = bootloader_common_ota_select_valid(&e[0]), v1 = bootloader_common_ota_select_valid(&e[1]);
uint32_t seq = 0;
if (v0 && e[0].ota_seq > seq) seq = e[0].ota_seq;
if (v1 && e[1].ota_seq > seq) seq = e[1].ota_seq;
uint32_t next = seq + 1;
while ((next - 1) % slots != 1) next++; // slot 1: the updater
// overwrite the older (or an invalid) entry, so the current one survives a failure
const int target = !v0 ? 0 : !v1 ? 1 : (e[0].ota_seq <= e[1].ota_seq ? 0 : 1);
esp_ota_select_entry_t entry;
memset(&entry, 0xFF, sizeof(entry));
entry.ota_seq = next;
entry.ota_state = ESP_OTA_IMG_VALID;
entry.crc = bootloader_common_ota_select_crc(&entry);
const size_t addr = otadata.offset + target * sector;
if (bootloader_flash_erase_sector(addr / sector) != ESP_OK ||
bootloader_flash_write(addr, &entry, sizeof(entry), false) != ESP_OK) {
ESP_LOGE(TAG, "could not write otadata");
return;
}
ESP_LOGI(TAG, "booting the updater (otadata seq %u)", (unsigned)next);
}
Binary file added boxart/adventures-of-batman-and-robin-snes.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added boxart/cattlecrisis-1-pico8.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added boxart/celeste-pico8.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added boxart/just-one-boss-pico8.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added boxart/kalikan_menu-6-pico8.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added boxart/mgs.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added boxart/mossmoss-17-pico8.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added boxart/pico_ball-5-pico8.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added boxart/praxis_fighter_x-2-pico8.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added boxart/tony-hawks-pro-skater-2.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 6 additions & 1 deletion components/box-emu/idf_component.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@ dependencies:
espp/serialization: '>=1.3.4'
espp/task: '>=1.3.4'
espp/timer: '>=1.3.4'
espp/usb_device: '>=1.3.4'
espp/usb_device:
# TEMP: take_msc_from_ejected_host() (esp-cpp/espp#898, merged as
# 08295c0); back to the registry once it is released
git: https://github.com/esp-cpp/espp.git
path: components/usb_device
version: 08295c0904224f25a42a518093021ee68c755ff2
47 changes: 39 additions & 8 deletions components/box-emu/include/box-emu.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

#include <sdkconfig.h>

#include <functional>
#include <mutex>
#include <span>

#include <esp_err.h>
#include <esp_partition.h>
Expand Down Expand Up @@ -211,16 +213,42 @@ class BoxEmu : public espp::BaseComponent {
// USB
/////////////////////////////////////////////////////////////////////////////

/// Expose the uSD card to a USB host as a mass storage device.
/// \note The card's volume is unmounted from the application while USB is
/// enabled (the emulator cannot read roms until it is disabled again);
/// the USB-Serial-JTAG console is disconnected as well, since it shares
/// the USB port.
/// \return True if USB mass storage was started.
// The composite USB device on the USB-C port (started at boot):
// - CDC: the console (stdout is routed there, teed to UART0); the
// USB-Serial-JTAG console is gone while it runs (it shares the PHY).
// - vendor (WebUSB): a framed byte stream for the link services (OTA, core
// dump, system, test control): set_vendor_rx() / vendor_write().
// - MSC: the uSD card as a USB drive, ours (mounted at mount_point) until
// handed to the PC with initialize_usb().

/// Start the composite device (after initialize_sdcard()).
bool initialize_usb_device();
bool is_usb_device_enabled() const { return usb_device_ != nullptr; }
/// Bytes received on the vendor interface (TinyUSB task context: do not
/// block; hand them to a DispatcherWorker). Set before the host talks.
void set_vendor_rx(std::function<void(std::span<const uint8_t>)> fn) { vendor_rx_ = std::move(fn); }
/// Send one frame on the vendor interface (all or nothing; serialized).
bool vendor_write(std::span<const uint8_t> frame);
/// Before a reboot into the ROM download mode.
void usb_prepare_for_rom_download();
/// Hand the uSD card to the USB host as a mass storage device (its volume
/// is unmounted here until deinitialize_usb()).
bool initialize_usb();
/// Stop USB mass storage, reconnect the console and mount the card again.
/// Take the uSD card back from the USB host (mounted at mount_point again).
bool deinitialize_usb();
/// True while the USB host has the uSD card.
bool is_usb_enabled() const;
// the names the shared link code uses (tab5-emu's)
bool initialize_usb_msc() { return initialize_usb(); }
void deinitialize_usb_msc() { deinitialize_usb(); }
bool usb_msc_host_has_card() const { return is_usb_enabled(); }
/// Called (under lvgl_mutex()) just before the card goes to the PC, from
/// whichever task hands it over: the USB link closes a file it is writing.
void set_before_sd_handover(std::function<void()> fn) { before_sd_handover_ = std::move(fn); }

/// Buttons held by the USB link's test control (scripted input), merged
/// into gamepad_state() like any other input (GamepadState::buttons bits).
void set_remote_buttons(uint16_t buttons);

protected:
BoxEmu();
Expand Down Expand Up @@ -433,8 +461,11 @@ class BoxEmu : public espp::BaseComponent {
std::shared_ptr<espp::Drv2605> haptic_motor_{nullptr};

// usb
std::atomic<bool> usb_enabled_{false};
std::unique_ptr<espp::UsbDevice> usb_device_{nullptr};
std::function<void()> before_sd_handover_;
std::function<void(std::span<const uint8_t>)> vendor_rx_;
std::mutex vendor_tx_mutex_;
uint16_t remote_buttons_{0}; // guarded by gamepad_state_mutex_
// The USB-Serial-JTAG console PHY, re-created after USB mass storage is stopped
// so the console comes back (the OTG stack owns the PHY while it runs).
usb_phy_handle_t jtag_phy_{nullptr};
Expand Down
Loading
Loading