Repository navigation
perf: internal RAM, display-aware frame skip, 60 Hz pacing, Sonic 3 Z80 V-int fix, PICO-8 fast paths - #128
Merged
Conversation
finger563
force-pushed
the
feat/perf-internal-ram
branch
from
October 11, 2026 19:52
5f376e1 to
faff2fd
Compare
finger563
force-pushed
the
feat/perf-instrumentation
branch
from
October 11, 2026 19:59
1953029 to
6243b83
Compare
finger563
force-pushed
the
feat/perf-internal-ram
branch
from
October 11, 2026 19:59
faff2fd to
aa18926
Compare
Phase 2 of the Sonic performance / memory plan, driven by the Phase 1 census (PR #127). At the menu the largest contiguous internal block goes from 102 KB to 131 KB and free internal RAM from 147 KB to 174 KB: - CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL 256K -> 0. The reserve was realised by esp_psram carving the two largest free blocks (184 KB + 72 KB) out of the main heap and re-registering them as nested internal/DMA-only heaps, which capped the largest block at ~100 KB. Plain malloc() already goes to PSRAM first (ALWAYSINTERNAL=0) and every internal buffer here is requested with MALLOC_CAP_INTERNAL/DMA explicitly, so nothing relied on the reserve. As a side effect the ~110-190 B per play cycle the heap used to retain is gone (quit delta is now +0). - esp_timer task stack 16 KB -> 8 KB (5.2 KB used after a full session). - doom, msx and shared_memory .bss -> PSRAM via linker fragments (-13.6 KB of static DRAM; none of it is hot). - Genesis: allocate the YM2612 tables (lfo_pm_table, tl_tab, sin_tab) right after M68K_RAM and VRAM last. The tables are read per sample on core 1 and thrash the shared data cache from PSRAM (measured: sound unit +0.5 ms/frame, audible crackle); moving VRAM internal measured no VDP speed-up, so it is the right buffer to spill. lfo_pm_table is now internal (was PSRAM); the sound unit's compute drops from ~9.0 to ~8.7 ms/frame. Every shared buffer logs its placement in the `[genesis mem]` format. - Instrumentation: BoxEmu counts frames the video queue dropped and the video task's blit time; the `[genesis perf]` line reports `dropped=` and `blit=`. On the BOX-3 the full-frame blit is ~31 ms, i.e. the LCD path tops out around 31 fps and drops ~37% of emulated frames regardless of emulation speed. - tools/serial_capture.py reports port-open failures instead of silently writing an empty capture. Tried and reverted (documented in box-emu.hpp): LCD line buffers 30 -> 20 rows. It freed 12.8 KB but the 4 extra SPI transactions per frame cost core 1 ~2 ms (sound-unit compute 8.7 -> 10.8 ms) and Sonic 2 lost ~3 fps, while the blit time stayed ~31 ms. Running the sound task below the audio task was also measured (+0.4 ms barrier, no audible gain) and reverted. Sonic 2 (same ~1 min section): 55.7-57 fps unthrottled vs 57.5 before, within run-to-run variance; VDP ~10.5 ms and 68k ~7.5 ms per frame unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
The BSP asks for 60MHz but the ESP32-S3 SPI master only divides 80MHz by integers, so the panel runs at 40MHz: 320x224x16bpp is 28.7ms of wire time per frame, which is the display's ~31fps ceiling. 80MHz (the only faster setting) leaves the BOX-3 panel white, so the ceiling is a hardware fact; log the real clock at boot so it is not mistaken for software overhead again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
The LCD path takes ~31 ms per frame (40 MHz SPI, a hardware limit on the BOX-3: Espressif's own BSP runs the ILI9341 at 40 MHz and 80 MHz leaves the panel white), while a Genesis frame is 16.7 ms, so when the emulator runs at speed roughly every other frame was discarded at the single-slot video queue after ~10 ms of VDP render + RGB565 convert had been spent on it. If the slot is still occupied when a frame starts, the frame could not be shown: run the 68000/Z80/sound for it as before but skip the pixel work. The VDP registers, DMA, interrupts and status still advance; only the per-line sprite-overflow bookkeeping of an invisible frame is lost. The frame buffer ping-pong only toggles on pushed frames, so screenshots still see the last displayed frame. GENESIS_SKIP_UNDISPLAYABLE_FRAMES=0 restores the old path. Sonic 2 on the BOX-3: in-play 49-51 -> 52-56 fps, frame 19.3 -> 15.6 ms, dropped 37% -> 0 (skipped ~43%), unthrottled 56 -> 69 fps; the stutter in the audio is audibly reduced. The [genesis perf] line now reports vdp (render) and convert (RGB565 into PSRAM) separately -- ~8 ms and ~3.2 ms per rendered frame -- plus skipped=N/300; BoxEmu gains frame_slot_free(). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…l taken Three changes on top of the display-aware frame skip, each verified on the BOX-3 with the [genesis perf] line and by ear: - Pace against a running deadline instead of per frame. Rendered frames (~19 ms) overrun the 16.7 ms period and skipped frames (~10 ms) finish early; paced individually the early frames slept their slack away and the average sat at ~54 fps although the mean frame time fit. With a running deadline the early frames repay the overrun, so the average rate -- which is what keeps YM2612 sample production matched to the I2S clock -- holds 60 Hz whenever the mean fits. More than a frame behind resyncs to now (no bursts). Sonic 2: 52-56 -> 60.1-60.4 fps in play, skipped exactly 150/300 (a clean every-other-frame cadence on the ~31 fps panel), audio clean. - Skip the next frame's render when the previous one ended past its deadline (at most GENESIS_MAX_CONSECUTIVE_SKIPS=2 in a row), so heavier games spend core 0 on emulation + audio before pixels nobody can see. - Hold the Z80 V-int until the Z80 takes it. In the dual-core split the 68000 runs each scanline before the Z80 does, so a 68k vblank handler that BUSREQs the Z80 immediately (Sonic 3 holds the bus ~80 lines for its DMA batch) left the Z80 halted across the whole vblank, and the V-int -- deasserted at end of frame -- was withdrawn before the bus came back. The Z80 driver got ~150 of 300 ticks and the music ran slow and stuttered. On hardware the Z80 wins that race (it samples /INT within a few cycles). IAutoReset still clears the request on take, and a new assert replaces a pending one rather than queuing. Sonic 3: 298-301/300 ticks, "the best it has ever sounded"; Sonic 2 unchanged at 300/300. - Z80 diagnostics: z80inst.c counts accepted interrupts, BUSREQ assertions, slices lost to a held bus, 2 ms handshake timeouts and Z80->YM2612 writes; the [genesis perf] line reports per-window deltas (`z80 irq= busreq= halted= wto= ymw=`). These are what made the V-int loss visible. Known remaining: Sonic 3's special stages run 56-59 fps with the core-1 sound unit as the bottleneck (3x the YM2612 writes per frame, barrier wait up to 4 ms) and stutter slightly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
finger563
force-pushed
the
feat/perf-internal-ram
branch
from
October 12, 2026 15:05
0e1129d to
f3483d5
Compare
…nnected Addresses the Copilot review on #127: append mode let a reused before.log / after.log silently combine two sessions (and perf_report.py then diffed stale runs), and a bad or busy port produced an empty file with a success message and exit 0. One capture per invocation now; if no connection ever succeeded the last error is printed and the exit code is 1. (The third item -- resetting the Genesis perf window when a core starts -- is already in this branch.) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
|
✅Static analysis result - no issues found! ✅ |
ESP-IDF Size Report for 'Esp Box Emu'
FLASH uses app .bin size or json2 flash sum. RAM sums DRAM+IRAM via idf_size. Percentages shown when totals are available. |
…resses Same idea as the Genesis frame skip: femto8's main loop asks the box whether the single-slot video queue is free before calling _draw. When it is not (the LCD manages ~31-43 fps, so a 60 fps cart would have every other frame discarded after _draw and the render had been paid for) the frame skips _draw and the render -- PICO-8 itself drops _draw under load -- but still takes a full frame period (p8_pace_and_post_flip(), split out of p8_flip()). The [PICO8] report line counts them (`skipped N (display busy)`). 30 fps carts such as Celeste never skip; the saving is for _update60 carts. Gamepad presses are now latched between polls (BoxEmu::gamepad_state_latched, rising edges accumulated by the 30 ms input timer). femto8 detects btnp() only when the button is down at its once-per-update poll, so on a level that runs at ~10 fps a normal tap fell between two polls and Celeste could not jump. The SDL build has the same latch; the box build lacked it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…> 30 fps) Profiling (PICO8_PROFILE) showed Celeste's heavier levels at 10-20 fps with rectfill at 27% of the frame: femto8 fills rectangles one pixel_set() at a time, column-major and without clipping, so a full-screen background fill is 16k calls (~7 ms). draw_rectfill is now one draw_hline per visible row, which clips once and writes screen nibbles directly in the common case (no fill pattern, default masks) -- the path circfill/ovalfill already use. The other hot entry was map() at 9%: 256 draw_sprite() calls per map(), each 64 pixels through gfx_get + color_get + pixel_set (camera, clip, fill-pattern decode, gfx_set per pixel). draw_sprite now resolves camera, clip and the draw palette once and copies nibbles directly when no fill pattern applies to sprites and the read/write mask is default; transparency comes from the draw palette's high nibble exactly as pixel_set() applies it. Flips, clipping and the fill-pattern / rw-mask cases fall through to the original code. Measured on the BOX-3, same level: 10-20 fps -> locked 30 fps; API share of the frame 37% -> 8% (rectfill 27% -> 2%, map 9% -> 6%). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
Silent hangs (the box freezes, no panic output) are indistinguishable from crashes without this: the boot line now reports the hardware reset reason via espp::SystemInfo (new espp/system dependency). When CONFIG_ESP_TASK_WDT_EN is enabled in a local sdkconfig (it is off in the defaults), the main task subscribes to the task watchdog and feeds it once per frame from Cart::run() and from the menu wait loop, so a frozen main task prints its backtrace instead of leaving a blank screen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved input, sprite-memory, VDP-state, and linker-placement regressions can affect correctness and performance.
5 open findings
Validate sprite IDs before direct sprite-sheet access · New Preserve Doom IRAM mappings when relocating BSS · New Preserve VDP status updates when skipping line rendering · New Clear stale input latch state before PICO-8 input begins · New Report failed placement when both memory allocations fail · New
What changed in this PR
Improves emulator performance, memory availability, display pacing, diagnostics, and input responsiveness.
Changes:
- Reclaims internal RAM and prioritizes Genesis hot buffers.
- Adds display-aware frame skipping, deadline pacing, and Z80 interrupt fixes.
- Optimizes PICO-8 rendering and input handling.
| File | Description |
|---|---|
README.md |
Documents new diagnostics. |
sdkconfig.defaults |
Reduces reserved internal RAM and timer stack. |
tools/serial_capture.py |
Improves capture reliability. |
main/main.cpp |
Adds reset and watchdog diagnostics. |
main/idf_component.yml |
Adds system dependency. |
main/cart.hpp |
Feeds the task watchdog. |
components/shared_memory/linker.lf |
Moves bookkeeping BSS to PSRAM. |
components/shared_memory/CMakeLists.txt |
Registers linker fragment. |
components/pico8/VENDOR.md |
Documents PICO-8 customizations. |
components/pico8/src/platform/p8_espbox.h |
Exposes display readiness. |
components/pico8/src/pico8.cpp |
Adds skipping, counters, and latched input. |
components/pico8/femto8/src/p8_lua_helper.h |
Adds graphics fast paths. |
components/pico8/femto8/src/p8_emu.c |
Skips unshowable draws while pacing. |
components/msx/linker.lf |
Moves MSX BSS to PSRAM. |
components/msx/CMakeLists.txt |
Registers linker fragment. |
components/genesis/src/genesis.cpp |
Adds skipping, pacing, diagnostics, and Z80 fix. |
components/genesis/src/genesis_shared_memory.cpp |
Reorders hot-buffer allocation. |
components/genesis/gwenesis/src/sound/z80inst.h |
Declares diagnostic counters. |
components/genesis/gwenesis/src/sound/z80inst.c |
Records Z80 diagnostics. |
components/genesis/gwenesis/src/cpus/Z80/Z80.c |
Counts accepted interrupts. |
components/doom/linker.lf |
Moves Doom BSS to PSRAM. |
components/doom/CMakeLists.txt |
Registers linker fragment. |
components/box-emu/src/box-emu.cpp |
Adds queue, input, display, and blit metrics. |
components/box-emu/include/box-emu.hpp |
Exposes new input and display APIs. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…n a failed init A 64 KB data-cache experiment (reverted: no gain for the PICO-8 interpreter, whose rate stayed at ~1.4 M instr/s) left the Genesis 32 KB short of internal RAM: one of the small INTERNAL-only shared buffers returned NULL, init bailed with 'Failed to allocate Genesis runtime memory', and the cart loop kept running an uninitialised core -- a black screen the player read as a freeze. The small state structs and line buffers (m68k core, YM2612, OPNREGS, render/ sprite line buffers, CRAM, SAT cache, VDP regs, FIFO, CRAM565, VSRAM) and the audio mix buffers now prefer internal RAM and fall back to PSRAM, each logging its placement; and GenesisCart::run() returns to the menu when init failed (genesis_is_initialized()). Placement on the BOX-3 is unchanged by this. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
…keep VDP status on skipped frames, report failed placements Copilot review of #128: - draw_sprite's direct path only handles sprite ids 0..255 (inside the 128x128 sheet); other values take the bounds-checked slow path as before. - The gamepad press latch is global and accumulated since boot, so the cart's first poll could see menu-navigation presses: PICO-8 drains it at launch, and the existing release gate holds the launch press. - Scanlines of skipped frames call gwenesis_vdp_skip_line(), which does the per-frame status bookkeeping (sprite-overflow / collision reset on line 0) the render would have done, so the 68000 never reads a stale overflow flag from the last drawn frame. - [genesis mem] placement lines print FAILED when both allocations fail, instead of claiming a PSRAM placement for a null pointer. - doom/linker.lf: note that the IRAM mappings it used to contain were never registered (no LDFRAGMENTS), so dropping them is not a regression. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.



Phases 2 and 3 of the Sonic performance / memory plan (built on the Phase 1 instrumentation merged in #127), plus the same display-aware treatment and two profiler-driven fixes for PICO-8. Every step was measured on the BOX-3 with
tools/serial_capture.py+tools/perf_report.pyand checked by ear/eye.Results
mainmainmainrectfill27%,map9%)Internal RAM at the menu: free 146.6 → 174.3 KB, largest contiguous block 102.4 → 131.1 KB, per-launch heap retention 112–192 B → 0.
Phase 2 — reclaim internal RAM
CONFIG_SPIRAM_MALLOC_RESERVE_INTERNAL256 KB → 0. esp_psram implemented the reserve by carving the two largest free blocks (184 + 72 KB) out of the main heap and re-registering them as nested internal/DMA-only heaps, which capped the largest block at ~100 KB. Plainmalloc()already goes PSRAM-first and every internal buffer here is requested explicitly, so nothing relied on it.esp_timerstack 16 → 8 KB (5.2 KB used after a full session); doom, msx, shared_memory.bss→ PSRAM via linker fragments (−13.6 KB static DRAM, nothing hot).lfo_pm_table,tl_tab,sin_tab) right afterM68K_RAM,VRAMlast. From PSRAM the tables' per-sample random reads thrash the cache both cores share (measured: sound unit +0.5 ms/frame, audible crackle);VRAMinternal measured no VDP speed-up, so it is the right buffer to spill.lfo_pm_tableis now internal.Phase 3 — the display ceiling, and spending frames where they count
GPSPI2.clock, now logged at boot as[display] lcd spi clock): 320×224×16 bpp = 28.7 ms of wire time per frame → ~31 fps. 80 MHz (the only faster setting) leaves the BOX-3 panel white; Espressif's own BSP uses 40 MHz; the ILI9341 has no 12-bit mode. So ~37% of rendered frames were being discarded at the single-slot video queue after ~10 ms of VDP work each.IAutoResetclears it; a new assert replaces a pending one, never queues)._draw(as PICO-8 itself does under load) for frames the panel cannot show, still taking a full frame period — only_update60carts ever skip. Gamepad presses are latched between the cart's polls (BoxEmu::gamepad_state_latched): femto8 only seesbtnp()if the button is down at its once-per-update poll, so on a 10 fps level a normal tap was missed and Celeste could not jump. Profiling that level foundrectfillat 27% (per-pixel, column-major, unclipped — ~7 ms per full-screen fill) andmap()at 9% (per-pixel sprites);draw_rectfillis now one direct-write scanline per visible row anddraw_spritecopies nibbles through the draw palette in the common case → locked 30 fps.[genesis perf]reportsvdp/convertseparately,skipped=,dropped=,blit=, and Z80 counters (irq= busreq= halted= wto= ymw=);[PICO8]reports skipped frames; boot logs the hardware reset reason viaespp::SystemInfo(newespp/systemdependency); withCONFIG_ESP_TASK_WDT_ENin a local sdkconfig the main task is watchdog-fed per frame so a silent freeze prints a backtrace.Known remaining
mainand this branch, always around a launch or the menu, on a box running without its battery; not attributed. The watchdog hooks above exist to catch the next one.🤖 Generated with Claude Code
https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1