Skip to content

Launcher features, USB link (console, test control, SD drive, OTA), emulink - #133

Merged
finger563 merged 18 commits into
mainfrom
feat/launcher-features
Nov 13, 2026
Merged

finger563 merged 18 commits into
mainfrom
feat/launcher-features

Conversation

@finger563

Copy link
Copy Markdown
Contributor

Summary

  • Launcher: games grouped by console, with a console badge; settings are saved, and the last game can be resumed.
  • USB link: the USB-C port is now an espp::UsbDevice composite device.
    • CDC console: the logs, plus stderr (espp routes only stdout).
    • Vendor interface: framed services.
      • OTA, through a small updater app in ota_1.
      • Core dump, system info and reboot, and a log monitor.
      • Test control: status, launch and quit, press and hold buttons, save and load states, screenshots, and SD card ls, push, pull, rm and mkdir.
    • MSC: the uSD card as a USB drive, on request. It uses 4 KB transfers; with 512 B, macOS gives up mounting a 32 GB card.
  • Updater switch in the bootloader: with flash and PSRAM at 120 MHz, the app's own flash writes report OK but don't land. So the app leaves a request in an RTC register and resets, and bootloader_components/updater_switch boots the updater.
  • Screenshots: read straight from the game's frame buffer while the game is held still (Cart::capture_hold()), with no SD card and no allocation, in 0.2–0.5 s. All cores render little-endian RGB565, so emulink no longer byte-swaps the box's frames (that had turned blues green).
  • Memory, measured over the link:
    • The link's code costs about 68 KB of PSRAM, because code runs from PSRAM.
    • PICO-8 carts failed to load because of that. femto8 now allocates its cart buffers only while they're needed, using FEMTO8_FREE_LOAD_BUFFERS (Cart buffers only while needed, so a cart with a big PNG fits femto8#1, pinned on its branch).
    • NES rendered into a separate 65 KB buffer, and leaked 6.6 KB per launch on top. It now draws into the unused part of frame_buffer0.
    • NES's audio buffer was used after it had been freed; fixed.
    • SMS and Game Gear leaked about 10 KB per launch through their sound buffers; fixed.
  • Link status: frames presented and dropped, and the heaps' low-water marks.
  • tools/emulink: the same Python client as tab5-emu.

espp/usb_device is pinned to the esp-cpp/espp#898 merge commit until the next release. The femto8 submodule points at its PR branch; bump it once that PR is merged.

Test plan

  • OTA over the link (about 54 s), with the bootloader switch to the updater at 120 MHz.
  • Screenshots from NES, GB, GBC, SMS, GG, Genesis, MSX, Doom, Dark Forces and PICO-8: correct colours.
  • Sweep over NES, GBC, SMS, GG, Genesis, MSX, Doom, Dark Forces and 5 PICO-8 carts. Menu PSRAM stays steady (988 to 941 KB, of which Doom keeps 45 KB once), and the PICO-8 carts still run at the end. No resets.
  • The uSD drive mounts on macOS, and the eject hands the card back.
  • Still open:
    • Cattle Crisis can't get enough PSRAM after a long session.
    • Mossmoss and Praxis Fighter X run out of Lua memory.
    • Genesis leaves only about 12 KB of internal RAM free while it runs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY

finger563 and others added 6 commits November 10, 2026 21:04
…sume

- The rom roller is grouped by console (NES, SNES, Game Boy, Game Boy
  Color, Master System, Game Gear, Genesis, MSX, PC, PICO-8) with a header
  row per console that selection steps over; metadata.csv order within one.
- The selected game's console replaces the title in the launcher's header:
  boxart/consoles/<key>.jpg from the SD card if present, else a badge drawn
  from LVGL objects (a generic icon and the short name, no logos).
- Settings survive a power cycle in /sdcard/settings.bin (espp
  persistent_data, versioned): volume, mute, brightness, video scaling,
  haptic effect, resume, the last game started (selected again at boot) and
  the save slot last used per game.
- "Resume last save" (settings screen, on by default): starting a game loads
  the slot it was last saved to / loaded from, for the emulator cores (NES,
  Game Boy, Master System, Genesis, MSX, PICO-8 after its first second); the
  native ports start fresh.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
…), OTA updater, emulink

The USB-C port becomes an espp::UsbDevice with three functions:

- CDC console: the logs
- a vendor interface carrying framed services: OTA (through a separate
  updater app in ota_1), the core dump, system info / reboot, a log
  monitor, and the emulator's own test control (status, launch / quit,
  press / hold buttons, save / load states, screenshots, and SD card file
  list / push / pull / rm / mkdir)
- MSC: the uSD card as a USB drive, handed to the PC only on request (the
  launcher's button or the link) and taken back once the PC ejected it;
  4 KB MSC transfers, or macOS gives up mounting a 32 GB card

Writes from the app to flash report success but do not land with the
flash and PSRAM at 120 MHz, so the app cannot switch otadata itself: it
leaves a request in an RTC register and resets, and a bootloader hook
(bootloader_components/updater_switch) boots the updater.

tools/emulink is the Python client (python -m emulink ...), shared with
tab5-emu.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
…#898 merge

- The link's SCREENSHOT no longer goes through the SD card: the main loop
  takes the game's frame between two frames and holds the game still
  (Cart::capture_hold() pauses the cores with their own tasks: Doom, Dark
  Forces, PICO-8) while SCREENSHOT_READ reads the pixels, so nothing is
  allocated or copied. emulink converts them with a lookup table: a
  screenshot takes 0.2-0.5 s.
- Every core renders little-endian RGB565: emulink no longer byte-swaps the
  box's frames, which turned blues green.
- espp/usb_device pinned to the #898 merge commit (the branch is gone).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
- femto8 (feat/cart-buffers-on-demand): the cart buffers are taken only
  while they are needed, and with FEMTO8_FREE_LOAD_BUFFERS the 256 KB of
  file buffers go back to the Lua heap once a cart is parsed. The link's
  code runs from PSRAM and costs ~68 KB of it, which had left too little
  for decoding a .p8.png: every cart failed with "could not load".
- espp routes only stdout to the CDC console: stderr goes there too now,
  so the cores' error messages (PICO-8's among them) are on the console.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
Measured over the link (menu PSRAM free after each quit):

- NES: nofrendo drew into a 65 KB buffer of its own, allocated on the
  first launch and never freed (75 KB gone after one NES game, then 6.6 KB
  more per launch). The PPU now draws into the unused upper part of
  frame_buffer0 (64 KB in, clear of the 8 bit frame pushed from its start):
  10 KB for the first launch, ~0 after.
- NES: osd_stopsound() freed the audio buffer but kept the pointer, so the
  next launch played into freed memory.
- SMS / Game Gear: deinit cleared the shared memory holding the pointers to
  the sound buffers sound_init() mallocs, leaking ~10 KB per launch; it
  calls sound_shutdown() first now.

With these, PICO-8 still has room for its carts after a session of other
games (it had failed with "could not load" once ~140 KB had been lost).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
For performance and memory tests of any core: frames_presented and
frames_dropped (the BSP's counters: displayed fps for every emulator, not
just the ones that count their own frames), internal_min_free and
psram_min_free.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
Copilot AI balanced review requested due to automatic review settings November 12, 2026 00:06
@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical defects leave fresh installations without a usable recovery image and expose the system to JPEG heap corruption and LVGL races.

7 open issues
What changed in this PR

Expands ESP-Box-Emu’s launcher and adds USB-based control, diagnostics, file transfer, and firmware updates through a dedicated updater application.

Changes:

  • Groups games by console and persists settings, game selection, and save-slot resume preferences.
  • Adds a composite USB device, the Python emulink client, and a bootloader-assisted update path.
  • Adds direct framebuffer screenshots and reduces emulator memory usage while fixing audio-buffer lifetime issues.
File Description
updater/​sdkconfig.defaults Configures the standalone USB updater.
updater/​main/​updater.cpp Implements USB OTA services and idle return behavior.
updater/​main/​idf_component.yml Declares updater dependencies.
updater/​main/​CMakeLists.txt Registers the updater source and required components.
updater/​CMakeLists.txt Defines the separate updater project.
tools/​emulink/​emulink/​link.py Implements USB discovery, transport, and request matching.
tools/​emulink/​emulink/​frame.py Adds framed-protocol encoding and parsing.
tools/​emulink/​emulink/​espp_tools.py Locates espp host-side utilities.
tools/​emulink/​emulink/​control.py Exposes game control, screenshots, files, and system operations.
tools/​emulink/​emulink/​cli.py Provides command-line access to USB services.
tools/​emulink/​emulink/​__main__.py Enables module-based CLI execution.
tools/​emulink/​emulink/​__init__.py Defines package exports and metadata.
sdkconfig.defaults Enables composite USB, rollback, and flash core dumps.
partitions.csv Allocates emulator, updater, OTA metadata, and core-dump partitions.
main/​sms_cart.hpp Enables save-state resume for SMS carts.
main/​remote_control.hpp Implements device-side control and file-transfer requests.
main/​pico8_cart.hpp Adds delayed resume and screenshot task suspension.
main/​nes_cart.hpp Enables save-state resume for NES carts.
main/​msx_cart.hpp Enables save-state resume for MSX carts.
main/​main.cpp Integrates settings, USB services, and main-loop request dispatch.
main/​genesis_cart.hpp Enables save-state resume for Genesis carts.
main/​gbc_cart.hpp Enables save-state resume for Game Boy carts.
main/​doom_cart.hpp Suspends Doom tasks during framebuffer capture.
main/​darkforces_cart.hpp Suspends Dark Forces tasks during framebuffer capture.
main/​cart.hpp Adds resume, host save/load, capture, and quit hooks.
components/​sms/​src/​sms.cpp Releases sound allocations during teardown.
components/​settings/​src/​settings.cpp Implements settings persistence and save-slot tracking.
components/​settings/​include/​settings.hpp Defines versioned settings and synchronized access.
components/​settings/​idf_component.yml Declares the persistence dependency.
components/​settings/​CMakeLists.txt Registers the settings component.
components/​rom_info/​src/​rom_info.cpp Maps emulator platforms to console presentation data.
components/​rom_info/​include/​rom_info.hpp Defines console grouping and artwork metadata.
components/​pico8/​CMakeLists.txt Enables temporary cart-loading buffer allocation.
components/​nes/​src/​video_audio.cpp Reuses framebuffer space and clears freed audio pointers.
components/​nes/​nofrendo/​bitmap.c Uses shared rendering storage for NES bitmaps.
components/​gui/​src/​gui.cpp Adds console grouping, badges, persistent controls, and USB synchronization.
components/​gui/​src/​console_art.cpp Implements console icons, badges, and artwork lookup.
components/​gui/​include/​gui.hpp Adds launcher selection, reload, and presentation interfaces.
components/​gui/​include/​console_art.hpp Declares console artwork helpers.
components/​gui/​CMakeLists.txt Adds the settings dependency.
components/​devlink/​src/​devlink.cpp Registers USB services and records panic details.
components/​devlink/​include/​devlink.hpp Defines service integration and flash-operation locking.
components/​devlink/​idf_component.yml Declares diagnostic service dependencies.
components/​devlink/​CMakeLists.txt Configures service builds and panic wrappers.
components/​box-emu/​src/​box-emu.cpp Implements composite USB, SD ownership transitions, and remote input.
components/​box-emu/​include/​box-emu.hpp Exposes USB transport, ownership, and input APIs.
components/​box-emu/​idf_component.yml Pins USB support for SD-card handback.
CMakeLists.txt Includes settings and device-link components.
bootloader_components/​updater_switch/​updater_switch.c Handles RTC requests to boot the updater.
bootloader_components/​updater_switch/​CMakeLists.txt Registers the updater-switch bootloader hook.

Review effort: Balanced

lv_obj_clean(console_area_);
const auto &info = console_info(console);
if (const auto art = console_art_path(BoxEmu::mount_point, info); !art.empty()) {
decoder_.decode(art.c_str());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already fixed in b827d8b: Jpeg::decode() rejects files larger than the 100 KB encoded buffer before reading, images whose decoded size exceeds the pixel buffer before decoding, and failed openRAM() calls; the draw callback also clips to the image size.

Comment thread main/cart.hpp Resolved
Comment thread partitions.csv
phy_init, data, phy, 0xf000, 0x1000
otadata, data, ota, 0x10000, 0x2000
ota_0, app, ota_0, 0x20000, 12M
ota_1, app, ota_1, , 2M

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in eefd1e3: the root CMakeLists adds the built updater to the flash target at ota_1's offset (esptool_py_flash_target_image), so idf.py flash, flash_args and flasher_args.json include it. The release workflow builds the updater first and zips it (plus ota_data_initial.bin, which was also missing), and the README says to build it before the first flash.

Comment thread main/cart.hpp Resolved
Comment thread main/remote_control.hpp Resolved
Comment thread tools/emulink/emulink/control.py Resolved
Comment thread updater/main/updater.cpp Resolved
@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

ESP-IDF Size Report for 'Esp Box Emu'

Metric Base PR Delta
FLASH 4,077,688 bytes (64.81%) 4,215,224 bytes (67.00%) 🔺 +137,536 bytes (+2.19%)
DRAM 130,741 bytes (38.26%) 170,501 bytes (49.89%) 🔺 +39,760 bytes (+11.63%)
IRAM 0 bytes 0 bytes 0 bytes
RAM (DRAM+IRAM) 130,741 bytes 170,501 bytes 🔺 +39,760 bytes (+11.63%)

FLASH uses app .bin size or json2 flash sum. RAM sums DRAM+IRAM via idf_size. Percentages shown when totals are available.
DRAM/IRAM usage does not include memory used by the heap allocator at runtime.
This report was generated by esp-idf-size-delta.

finger563 and others added 8 commits November 11, 2026 19:15
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
…G decode; paged ls

- boxart/: the seven PICO-8 carts (from each cart's label), MGS, The
  Adventures of Batman & Robin (SNES) and Tony Hawk's Pro Skater 2, which
  metadata.csv listed but the card lacked.
- Jpeg: reject a file over the 100 KB buffer or an image over the 40 KB
  decode buffer instead of writing past them, and copy each block from the
  start of its own pixels.
- FILE_LIST pages like LIST_ROMS (path\0offset, reply "next"), so emulink
  ls lists a whole directory instead of stopping at ~69 entries.
- femto8 at the merge of esp-cpp/femto8#1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
They were untracked files in the working copy, not part of that change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
The ROM's spi_flash_mmap() picks virtual pages from its own table, which
counts the PSRAM heap's pages as free. Since the USB link, core dump init
reads the partition table at boot; that flash mapping took heap page
0x3c810000 and its unmap left the page unmapped. Dark Forces' 4 MB block
covers it, so loading Secret Base (or a save) died with "Cache error (MMU
entry fault)". CONFIG_SPI_FLASH_ROM_IMPL=n maps through esp_mm instead
(~8 KB less internal RAM in the menu; Genesis keeps its 12 KB in game).

The last-panic record (emulink status) now also keeps an exception's PC,
its backtrace and, for a cache error, the S3 MMU fault's address and source.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
Past its 4 MB pool, Z_Malloc() fell back to plain malloc(), so Doom II's
precache (2177 sprites) used up the PSRAM heap and then the internal RAM;
the SD card driver then could not get a DMA bounce buffer, a WAD read
failed and R_CachePatchNum() crashed. The overflow now comes from PSRAM
only and leaves 128 KB of it: a failure makes Z_Malloc() purge its cache
and retry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
Found by tracing each cart's lifetime (new: build with
CONFIG_HEAP_TRACING_STANDALONE=y and app_main lists what a cart leaves,
with its callers; Dark Forces' own tracer, which clashed with it, is gone):

- Doom: the OPL music mix buffer (44 KB) and its queue stayed allocated
  until the next launch; deinit frees them (music still plays on relaunch).
- In-game menu: menu_ui_init() makes an empty screen every time; it was
  never deleted (~90 B per launch, every core).
- NES: the 3.2 KB audio frame is freed on quit.
- MSX: the .sta/.cht/.mcf/.pal names come from malloc() but went to
  FreeMemory(), which only frees GetMemory() chunks, and the pool's
  my_free() dropped the getcwd() WorkDir: ~130 B per launch.
- SMS / Game Gear: sound_shutdown() frees the sample position table (1 KB).
- PICO-8: femto8 deletes its draw semaphore (92 B internal per launch).

After a first launch the menu's free PSRAM and internal RAM now stay flat
over repeated launches of every core.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread tools/emulink/emulink/cli.py Resolved
Comment thread components/gui/src/gui.cpp Resolved
Comment thread main/remote_control.hpp Resolved
Comment thread tools/emulink/emulink/control.py Resolved
Comment thread updater/main/updater.cpp Resolved
- Link file requests hold the lock the card is handed over under, and are
  refused while the PC has the card; a file being written is closed before
  any hand-over (also the settings screen's). LAUNCH and the launcher's Play
  refuse while the PC has it.
- FILE_READ fails on a seek or read error, FILE_CLOSE (and an implicit close)
  on a write that did not land; emulink pull fails on a short transfer.
- LIST_ROMS budgets its trailer and status byte; SAVE/LOAD reject slots past
  Menu::MAX_SLOT instead of wrapping to 0.
- The host's save/load pick the slot without the menu's LVGL update or its
  preview read into frame_buffer0 (which the core draws in).
- emulink: reconnects (ota, drive off) stay on the device first resolved;
  log --until matches across reads; add-rom rejects values metadata.csv
  cannot hold. The metadata parser keeps commas in names (as tab5-emu's).
- Updater: the idle timeout counts from the last request instead of being
  disabled by any packet, and it restarts only if the emulator verifies.
- The updater is part of the flash set (idf.py flash, flash_args, the
  release zip, which was also missing ota_data_initial.bin); README says to
  build it first.
- The game list's header skip goes the shorter way round (two games).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved SD-card ownership and LVGL locking races, ambiguous ROM selection, and stale USB parser state risk writes during card handover, incorrect launches, and reconnect timeouts.

6 open issues
4 previously missed issues

In code that hasn't changed since last review

Medium severity Substring matching can launch the wrong ROM

components/​gui/​src/​gui.cpp:134

An explicit launch path can select a different ROM because this function returns the first substring match: nes/game.nes.hack.nes wins over nes/game.nes if listed first. Prefer an exact rom_path match before falling back to name/path substrings.

Medium severity Settings saves race with SD-card unmount

components/​settings/​src/​settings.cpp:39

A delayed settings save can overlap the GUI's SD-card unmount because save() holds only the settings mutex, while handover holds BoxEmu::lvgl_mutex(). Changes made while the host owns the card also trigger repeated writes to the unavailable volume. Acquire the ownership lock before the settings mutex, and defer saves while the host owns the card without clearing the dirty state.

Medium severity Haptic label update races with LVGL task

main/​main.cpp:142

The Gui constructor has already started its periodic LVGL task, but set_haptic_waveform() calls update_haptic_waveform_label(), which invokes lv_label_set_text() without taking the shared mutex. This startup call can therefore race with Gui::update()/lv_task_handler(). Hold BoxEmu::lvgl_mutex() around this call, as required by the LVGL locking contract in box-emu.hpp.

Medium severity USB reconnect retains stale parser state and replies

tools/​emulink/​emulink/​link.py:115

Closing/reopening the USB interface retains the previous stream's parser buffer and pending replies. If a reset interrupts a reply, the stale header can make the parser wait for the old payload length instead of parsing the new device's reply, causing a timeout. Clear the parser and pending frames when closing the connection.

6 resolved since last review

Review effort: Balanced

- SAVE/LOAD (link and pause menu) report a core that rejected the state,
  and only a state that landed becomes the resume slot: the base
  save()/load(), which a failing override skips, records success.
- A file-backed screenshot holds cores that draw on their own tasks.
- LAUNCH prefers an exact path or name over the first substring match.
- The menu loop's settings save holds the hand-over lock and waits while
  the PC has the card; the boot-time haptic label update holds the LVGL
  lock.
- emulink: add-rom only starts a new metadata.csv if there is none (a
  failed read raises instead of rewriting it); a closed link drops a
  half-parsed reply.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
@finger563

Copy link
Copy Markdown
Contributor Author

Copilot round 3's previously missed issues, fixed in 43d54bd:

  • Substring launch match: select_rom_matching() tries an exact path / name first.
  • Settings save vs. unmount: the menu loop's save_if_idle() holds BoxEmu::lvgl_mutex() (the hand-over lock) and is skipped while the PC has the card, so the dirty state is kept for later.
  • Boot-time haptic label: set_haptic_waveform() runs under the LVGL lock.
  • emulink reconnect: Link.close() resets the frame parser and drops pending frames.
    Also: SAVE/LOAD now report a core that rejected the state, and only a successful one becomes the resume slot; file-backed screenshots hold cores on their own tasks; add-rom only starts a new metadata.csv when FILE_STAT says there is none.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed because unpublished critical findings raise concerns about false save/load success reports, updater activation failures, and unintended OTA rollback.

2 open issues
4 resolved since last review

Review effort: Balanced

@finger563

finger563 commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Hardware test: ESP-Box-Emu (ESP32-S3) over the USB link (43d54bd)

I ran this on the ESP-Box-Emu (ESP32-S3) through the USB link with emulink. The firmware went on with emulink ota, and the updater with emulink reboot --bootloader plus idf.py flash. The screenshots come from emulink screenshot and are the game's own frame buffer.

What the session shows:

  • A 1 MB random file goes up with push and comes back with pull with the same SHA-1.
  • pull of a missing file fails.
  • While drive on has given the card to the PC, ls, pull and launch are refused, and all work again after drive off.
  • save 1 → move on → load 1 comes back to the saved frame.
  • Out-of-range slots are refused, and loading an empty slot reports an error instead of passing.
  • The updater boots back into the emulator about 95 s after it was contacted without an update, and a normal OTA still works.

Save/load (screenshots over the link)

NES: title (state saved to slot 1) NES: moved on to the map NES: load 1, back at the title
Genesis: Sonic 2 PICO-8: Celeste
emulink session (files, drive hand-over, launch, save/load)
$ emulink mkdir /emulink_demo
$ emulink push ./rand_esp-box-emu.bin /emulink_demo/rand.bin
/sdcard/emulink_demo/rand.bin
$ emulink ls /emulink_demo
-    1000000 rand.bin
$ emulink pull /emulink_demo/rand.bin ./back_esp-box-emu.bin
./back_esp-box-emu.bin
$ shasum sent / received
cde52ab50db458532b1eb409213a9580e04171cb
cde52ab50db458532b1eb409213a9580e04171cb
$ emulink pull /emulink_demo/missing.bin ./missing.bin
emulink: /sdcard/emulink_demo/missing.bin does not exist
(exit 1)
$ emulink drive on
the PC has the SD card (/Volumes/ROMS 1)
$ emulink ls /
emulink: the PC has the SD card (emulink drive off)
(exit 1)
$ emulink launch /sdcard/nes/super_mario_3.nes
emulink: the PC has the SD card (emulink drive off)
(exit 1)
$ emulink drive off
the device has the SD card again
$ emulink rm /emulink_demo
$ emulink launch /sdcard/nes/super_mario_3.nes
/sdcard/nes/super_mario_3.nes
$ emulink press start
$ emulink press a
$ emulink screenshot -o ./game1_a.png
./game1_a.png
$ emulink save 1
$ emulink press right
$ emulink press start
$ emulink screenshot -o ./game1_b.png
./game1_b.png
$ emulink load 1
$ emulink screenshot -o ./game1_c_loaded.png
./game1_c_loaded.png
$ emulink save 99
emulink: slot 99 out of range (0-5)
(exit 1)
$ emulink load 5
emulink: slot 5 has no state, or the game could not load it
(exit 1)
$ emulink quit
updater: USB flash, idle timeout, OTA
# new updater flashed over USB (no BOOT button): emulink reboot --bootloader, then idf.py flash
$ emulink --serial esp-box-emu reboot --bootloader      # ROM download port /dev/cu.usbmodem2101 appears
$ idf.py -p /dev/cu.usbmodem2101 flash
Wrote 4268032 bytes (2064356 compressed) at 0x00020000 in 22.3 seconds (1529.5 kbit/s).
Hash of data verified.
Wrote 445264 bytes (252285 compressed) at 0x00c20000 in 2.3 seconds (1527.5 kbit/s).
Hash of data verified.

# updater idle test: enter the updater, contact it once, start no update, leave it alone
  0.0s in ESP-Box-Emu updater (link open = contact, no update)
 95.1s back in ESP-Box-Emu: v1.1.0-18-g43d54bd on ota_0

# a normal OTA through the new updater
$ emulink --serial esp-box-emu ota build/esp-box-emu.bin
rebooting into the updater ...
flashing ../../build/esp-box-emu.bin (4265520 bytes) via ESP-Box-Emu updater ...
done: the device restarts into the new firmware
up: v1.1.0-18-g43d54bd (Nov 12 2026 18:02:22, elf efe982f13) on ota_0

Full files: transcript · console log (captured over the CDC console with emulink log during the session; no errors or panics) · updater

🤖 Generated with Claude Code

https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical issues allow screenshots to overwrite in-flight display frames and file operations to mutate active Doom archives, risking invalid reads or crashes.

5 open issues

Review effort: Balanced

Comment thread main/remote_control.hpp
if (cart) {
cart->capture_hold(true);
auto [w, h] = cart->frame_size();
const auto pixels = cart->frame_pixels();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ce8a249: the SMS / Game Gear conversion now goes into its own PSRAM buffer (made on first use, freed in deinit_sms()), so neither frame buffer is written. It also reads the frame that was just pushed: the old code paired the latest palette with indices from the buffer two frames back. Checked on the box with an emulink screenshot of Sonic (SMS). The other cores' screenshots were already safe: NES converts into frame_buffer1, which it doesn't display from, and the RGB565 cores are read in place.

Comment thread main/remote_control.hpp
const std::string path(reinterpret_cast<const char *>(p.data() + 6), path_len);
const uint8_t *data = p.data() + 6 + path_len;
const size_t n = p.size() - 6 - path_len;
if (!open_for_write(path, offset)) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ce8a249 (and the Tab5's dabb0a8). While a game runs, FILE_WRITE and FILE_DELETE now refuse the game's ROM and any folder holding it. They also refuse anything in its data folder for the ports that read as they run: Doom and Dark Forces here, and JK, MGS and TPM on the Tab5. Paths are compared normalized and case-insensitively, since the card is FAT. On the box, a push into /doom with Doom running now fails with "in use by the running game" and works after quit; other folders still work. One gap is left: the check uses the cart registered with set_cart(), so the window while a game's constructor opens its files isn't covered. Closing it would mean creating carts under the LVGL lock, which risks deadlocking cores that start their tasks in the constructor.

Comment thread main/cart.hpp

virtual void load() {
logger_.info("Base loading...");
state_done_ = true; // (an override that fails does not get here)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ce8a249 for SMS and NES. save_sms() / load_sms() now return whether the file opened and, for a save, whether the write and the close succeeded. load_nes() checks that the state file opens before deferring the load to the next frame. The SMS and NES carts only call the base save() / load(), which marks the state done and copies the preview, once the core succeeds, the same as GBA and SNES on the Tab5. On the box, load 4 on NES and load 5 on SMS with no state now report "slot N has no state". A deferred NES load of a corrupt file still can't report failure, because nofrendo doesn't return a result there.

finger563 and others added 2 commits November 12, 2026 20:23
…tate results

- The USB link refuses to write or delete the running game's ROM, a
  folder holding it, or (Doom, Dark Forces) the data folder the game
  reads as it runs.
- SMS / Game Gear screenshots convert into a buffer of their own (the
  frame buffers belong to the video task) and show the last frame drawn.
- SMS save / load and NES load report failure, so a state that did not
  land is not acknowledged or remembered.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY
- remote_control: ENTER_UPDATER's in-place-OTA branch is an #else, not
  code after a break; narrower SCREENSHOT variables; std::transform /
  std::find_if for the path and exact-match loops.
- Cart::remember_slot() is const.
- gui: the roller only drops a trailing newline it has.
- devlink: std::fill for the panic backtrace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Human assessment is needed because critical data-loss, concurrency, and running-ROM protection risks were omitted from the published review.

5 open issues

Review effort: Balanced

@finger563
finger563 merged commit ff24c1d into main Nov 13, 2026
4 checks passed
@finger563
finger563 deleted the feat/launcher-features branch November 13, 2026 04:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants