You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Launcher features, USB link (console, test control, SD drive, OTA), emulink - #133
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.
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.
…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
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.
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.
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.
…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
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
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Unresolved critical defects can leave fresh installs without recovery, corrupt saved previews, cause unsynchronized LVGL updates, launch unavailable ROMs, and flash the wrong connected device.
- 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
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.
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.
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.
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.
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.
- 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
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.
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.
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.
$ 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
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.
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.
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.
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.
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
espp::UsbDevicecomposite device.stderr(espp routes onlystdout).ota_1.bootloader_components/updater_switchboots the updater.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).FEMTO8_FREE_LOAD_BUFFERS(Cart buffers only while needed, so a cart with a big PNG fits femto8#1, pinned on its branch).frame_buffer0.tools/emulink: the same Python client as tab5-emu.espp/usb_deviceis 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
🤖 Generated with Claude Code
https://claude.ai/code/session_01SFCMzomCZ5wRHJN4sxwkvY