Skip to content

perf: internal RAM, display-aware frame skip, 60 Hz pacing, Sonic 3 Z80 V-int fix, PICO-8 fast paths - #128

Merged
finger563 merged 11 commits into
mainfrom
feat/perf-internal-ram
Oct 12, 2026
Merged

finger563 merged 11 commits into
mainfrom
feat/perf-internal-ram

Conversation

@finger563

@finger563 finger563 commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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.py and checked by ear/eye.

Results

Sonic 2 (NTSC), in play main this PR
emulated frame rate 49–51 fps 60.1–60.4 fps
frames the panel discarded ~37% 0 (unrenderable frames are skipped instead)
audio stutters (under-produced samples) clean
Sonic 3, in play main this PR
launch hung twice at init runs
Z80 music ticks taken ~150 / 300 298–301 / 300
audio slow, stuttery "the best it has ever sounded" (special stages still stutter slightly, see below)
PICO-8 Celeste, heavier levels main this PR
frame rate 10–20 fps locked 30 fps
jump taps missed at low fps works
API share of frame 37% (rectfill 27%, map 9%) 8%

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_INTERNAL 256 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. Plain malloc() already goes PSRAM-first and every internal buffer here is requested explicitly, so nothing relied on it.
  • esp_timer stack 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).
  • Genesis allocation order: YM2612 tables (lfo_pm_table, tl_tab, sin_tab) right after M68K_RAM, VRAM last. From PSRAM the tables' per-sample random reads thrash the cache both cores share (measured: sound unit +0.5 ms/frame, audible crackle); VRAM internal measured no VDP speed-up, so it is the right buffer to spill. lfo_pm_table is now internal.
  • Tried and reverted with numbers: LCD line buffers 30 → 20 rows (−3 fps: the extra SPI transactions cost core 1 ~2 ms/frame), sound task below the audio task (+0.4 ms barrier, no gain).

Phase 3 — the display ceiling, and spending frames where they count

  • The panel is a hardware ceiling. The BSP asks for 60 MHz SPI but the S3 SPI master only divides 80 MHz by integers, so the ILI9341 runs at 40 MHz (verified from 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.
  • Display-aware frame skip: if the video slot is still occupied at frame start, the frame cannot be shown — run the 68000/Z80/sound as usual but skip the VDP render + RGB565 convert. Time-aware skip: also skip after a frame that ended past its deadline (max 2 in a row), so heavy games keep emulation and audio at 60 Hz.
  • Running-deadline pacing: rendered (~19 ms) and skipped (~10 ms) frames alternate; per-frame pacing slept the slack away and averaged ~54 fps. A running deadline lets early frames repay the overrun, so the average — what keeps YM2612 sample production matched to the I2S clock — holds 60 Hz; more than a frame behind resyncs (no bursts).
  • Z80 V-int held until taken (the Sonic 3 fix). In the dual-core split the 68000 runs each scanline before the Z80, 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 vblank, and the V-int — previously deasserted at end of frame — was withdrawn before the bus came back: the driver lost every other tick. On hardware the Z80 wins that race. The line now stays asserted until the Z80 takes it (IAutoReset clears it; a new assert replaces a pending one, never queues).
  • PICO-8: femto8's main loop skips _draw (as PICO-8 itself does under load) for frames the panel cannot show, still taking a full frame period — only _update60 carts ever skip. Gamepad presses are latched between the cart's polls (BoxEmu::gamepad_state_latched): femto8 only sees btnp() 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 found rectfill at 27% (per-pixel, column-major, unclipped — ~7 ms per full-screen fill) and map() at 9% (per-pixel sprites); draw_rectfill is now one direct-write scanline per visible row and draw_sprite copies nibbles through the draw palette in the common case → locked 30 fps.
  • Diagnostics: [genesis perf] reports vdp/convert separately, skipped=, dropped=, blit=, and Z80 counters (irq= busreq= halted= wto= ymw=); [PICO8] reports skipped frames; boot logs the hardware reset reason via espp::SystemInfo (new espp/system dependency); with CONFIG_ESP_TASK_WDT_EN in a local sdkconfig the main task is watchdog-fed per frame so a silent freeze prints a backtrace.

Known remaining

  • Sonic 3's 3D special stages run 56–59 fps with the core-1 sound unit as the bottleneck (~3× the YM2612 writes per frame, core 0 waits up to 4 ms at the barrier) and stutter slightly.
  • Per rendered Genesis frame the VDP render is ~8 ms and the RGB565 convert ~3.2 ms; PICO-8 carts that are interpreter-bound (Cattle Crisis, Mossmoss) are unchanged.
  • Silent hangs (the box freezes, no panic) were seen several times this week on both main and 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

@finger563
finger563 force-pushed the feat/perf-internal-ram branch from 5f376e1 to faff2fd Compare October 11, 2026 19:52
@finger563
finger563 force-pushed the feat/perf-instrumentation branch from 1953029 to 6243b83 Compare October 11, 2026 19:59
@finger563
finger563 force-pushed the feat/perf-internal-ram branch from faff2fd to aa18926 Compare October 11, 2026 19:59
@finger563 finger563 changed the title perf: reclaim internal RAM for the emulator cores perf: reclaim internal RAM, display-aware frame skip, 60 Hz pacing, Sonic 3 Z80 V-int fix Oct 12, 2026
Base automatically changed from feat/perf-instrumentation to main October 12, 2026 15:03
finger563 and others added 5 commits October 12, 2026 10:05
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
@finger563
finger563 force-pushed the feat/perf-internal-ram branch from 0e1129d to f3483d5 Compare October 12, 2026 15:05
…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
@github-actions

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

ESP-IDF Size Report for 'Esp Box Emu'

Metric Base PR Delta
FLASH 4,034,260 bytes (64.12%) 4,054,004 bytes (64.44%) 🔺 +19,744 bytes (+0.32%)
DRAM 147,096 bytes (43.04%) 130,069 bytes (38.06%) ⬇️ -17,027 bytes (-4.98%)
IRAM 0 bytes 0 bytes 0 bytes
RAM (DRAM+IRAM) 147,096 bytes 130,069 bytes ⬇️ -17,027 bytes (-4.98%)

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 3 commits October 12, 2026 12:32
…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
Copilot AI balanced review requested due to automatic review settings October 12, 2026 18:16
@finger563 finger563 changed the title perf: reclaim internal RAM, display-aware frame skip, 60 Hz pacing, Sonic 3 Z80 V-int fix perf: internal RAM, display-aware frame skip, 60 Hz pacing, Sonic 3 Z80 V-int fix, PICO-8 fast paths Oct 12, 2026

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 input, sprite-memory, VDP-state, and linker-placement regressions can affect correctness and performance.

5 open findings
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.

Comment thread components/pico8/femto8/src/p8_lua_helper.h Outdated
Comment thread components/doom/linker.lf
Comment thread components/genesis/src/genesis.cpp
Comment thread components/pico8/src/pico8.cpp
Comment thread components/genesis/src/genesis_shared_memory.cpp
finger563 and others added 2 commits October 12, 2026 13:41
…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
@finger563
finger563 merged commit d650298 into main Oct 12, 2026
3 checks passed
@finger563
finger563 deleted the feat/perf-internal-ram branch October 12, 2026 19:21
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