From ead97cd456f59e80f211d903d8fc1c8693fdcf03 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 14:39:32 -0500 Subject: [PATCH 1/6] genesis: word-at-a-time shadow/highlight compositing; VDP render stage counters Sonic 3's special stages run the VDP in shadow/highlight mode, and gwenesis's S/H path composited the sprite line over the plane line with a per-pixel switch for every pixel of the frame -- 7.5 ms of a ~25 ms drawn frame (the actual S/H colour math in that loop was already commented out, so it is priority compositing only). The sprite buffer is cleared per line and only sprite pixels write it, so most words are zero and can never win: walk the line a word at a time, copy the plane word through when the sprite word is zero, and only resolve priority per byte where a sprite pixel exists. Semantics are unchanged (same winner rule, same operator-sprite exemption). Measured in the special stage: merge 7.5 -> 0.9 ms per drawn frame, the stage goes from 56-59 fps to a locked 60.2-60.5 fps, and 10% more frames are drawn. The [genesis perf] line now also reports the render split per drawn frame (planes= sprites= merge=) from counters in gwenesis_vdp_gfx.c, which is what found this. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- .../genesis/gwenesis/src/vdp/gwenesis_vdp.h | 4 ++ .../gwenesis/src/vdp/gwenesis_vdp_gfx.c | 65 +++++++++++++------ components/genesis/src/genesis.cpp | 18 +++++ 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/components/genesis/gwenesis/src/vdp/gwenesis_vdp.h b/components/genesis/gwenesis/src/vdp/gwenesis_vdp.h index 4b03d688..1d2a0f7d 100644 --- a/components/genesis/gwenesis/src/vdp/gwenesis_vdp.h +++ b/components/genesis/gwenesis/src/vdp/gwenesis_vdp.h @@ -140,6 +140,10 @@ void gwenesis_vdp_set_buffers(unsigned char *screen_buffer, unsigned char *scale void gwenesis_vdp_set_buffer(unsigned char *ptr_screen_buffer); void gwenesis_vdp_render_line(int line); void gwenesis_vdp_skip_line(int line); /* ESP port: status bookkeeping for undrawn frames */ +/* ESP port diagnostics: CPU cycles per render stage, monotonic (see gwenesis_vdp_gfx.c) */ +extern uint32_t vdp_stat_planes_cycles; +extern uint32_t vdp_stat_sprites_cycles; +extern uint32_t vdp_stat_merge_cycles; void gwenesis_vdp_render_config(); diff --git a/components/genesis/gwenesis/src/vdp/gwenesis_vdp_gfx.c b/components/genesis/gwenesis/src/vdp/gwenesis_vdp_gfx.c index c31a402f..10dc0ba1 100644 --- a/components/genesis/gwenesis/src/vdp/gwenesis_vdp_gfx.c +++ b/components/genesis/gwenesis/src/vdp/gwenesis_vdp_gfx.c @@ -1025,6 +1025,13 @@ blit_4to5_line(uint16_t *in, uint16_t *out) { } } +/* ESP port diagnostics: CPU cycles spent per render stage (monotonic; the + * driver reports per-window deltas in the [genesis perf] line). */ +#include +uint32_t vdp_stat_planes_cycles = 0; /* draw_line_b + draw_line_aw */ +uint32_t vdp_stat_sprites_cycles = 0; /* draw_sprites(_over_planes) */ +uint32_t vdp_stat_merge_cycles = 0; /* SHI merge or memcpy into the line buffer */ + /* ESP port: called instead of gwenesis_vdp_render_line() for the scanlines of * a frame whose pixels are not drawn (the display cannot show it). Keeps the * per-frame status bookkeeping the render would have done so the status @@ -1083,44 +1090,60 @@ void gwenesis_vdp_render_line(int line) if (MODE_SHI) memset(ps, 0, 320); + uint32_t t0 = esp_cpu_get_cycle_count(); draw_line_b(line); draw_line_aw(line); + uint32_t t1 = esp_cpu_get_cycle_count(); + vdp_stat_planes_cycles += t1 - t0; if (MODE_SHI) draw_sprites(line); else draw_sprites_over_planes(line); + uint32_t t2 = esp_cpu_get_cycle_count(); + vdp_stat_sprites_cycles += t2 - t1; /* Mode Highlight/shadow is enabled */ if (MODE_SHI) { - for (int x = 0; x < screen_width; x++) { - uint8_t plane = pb[x]; - uint8_t sprite = ps[x]; - - if ((plane & 0xC0) < (sprite & 0xC0)) { - switch (sprite & 0x3F) { - // Palette=3, Sprite=14 :> draw plane, force highlight - case 0x3E: - screen_buffer_line[x] = plane; // 0x8410 | CRAM565[plane] >> 1; - break; - // Palette=3, Sprite=15 :> draw plane, force shadow - case 0x3F: - screen_buffer_line[x] = plane; // CRAM565[plane] >> 1; - break; - // draw sprite, normal - default: - screen_buffer_line[x] = sprite; - break; - } - } else { - screen_buffer_line[x] = plane; + /* Composite the sprite line over the plane line. The highlight/shadow + * colour math is not applied (it was already commented out below), so + * this is priority compositing only: a sprite pixel wins when its + * priority bits beat the plane's, except the two S/H operator sprites + * (palette 3, colours 14/15) which leave the plane as is. + * + * ESP port: the sprite buffer is cleared per line and only sprite pixels + * write it, so most of the line is zero there -- and a zero sprite byte + * can never win. Walk the line a word at a time and copy the plane word + * straight through unless one of its four sprite bytes is non-zero. (The + * per-pixel version of this loop was 7.5 ms of a 25 ms drawn frame in + * Sonic 3's special stages.) pb/ps/screen_buffer_line are 32-bit aligned + * (cache-line-aligned shared buffers) and screen_width is 256 or 320. */ + const uint32_t *pb32 = (const uint32_t *)pb; + const uint32_t *ps32 = (const uint32_t *)ps; + uint32_t *out32 = (uint32_t *)screen_buffer_line; + const int words = screen_width >> 2; + for (int w = 0; w < words; w++) { + const uint32_t s4 = ps32[w]; + if (s4 == 0) { + out32[w] = pb32[w]; + continue; + } + uint32_t p4 = pb32[w]; + uint32_t o4 = 0; + for (int b = 0; b < 32; b += 8) { + const uint32_t plane = (p4 >> b) & 0xFF; + const uint32_t sprite = (s4 >> b) & 0xFF; + const uint32_t take = ((plane & 0xC0) < (sprite & 0xC0)) && ((sprite & 0x3F) < 0x3E); + o4 |= (take ? sprite : plane) << b; } + out32[w] = o4; } /* Normal mode*/ } else { memcpy(screen_buffer_line, pb, screen_width); } + vdp_stat_merge_cycles += esp_cpu_get_cycle_count() - t2; } void gwenesis_vdp_gfx_save_state() { diff --git a/components/genesis/src/genesis.cpp b/components/genesis/src/genesis.cpp index 58a56a42..51930655 100644 --- a/components/genesis/src/genesis.cpp +++ b/components/genesis/src/genesis.cpp @@ -73,6 +73,10 @@ struct GenesisPerf { uint32_t z80_halted_at_start{0}; uint32_t z80_wto_at_start{0}; uint32_t z80_ymw_at_start{0}; + // VDP render stage cycles (gwenesis_vdp_gfx.c counters) at the start of the window + uint32_t vdp_planes_at_start{0}; + uint32_t vdp_sprites_at_start{0}; + uint32_t vdp_merge_at_start{0}; }; static GenesisPerf perf; #define PERF_NOW() esp_cpu_get_cycle_count() @@ -108,9 +112,14 @@ static void genesis_perf_report() { const uint32_t blit_cycles_now = box.blit_cycles(); const uint32_t blitted = blitted_now - perf.blitted_at_start; const uint32_t blit_us = blitted ? (blit_cycles_now - perf.blit_cycles_at_start) / ticks_per_us / blitted : 0; + // VDP render stages, per DRAWN frame (the vdp bucket above is per frame + // including skipped ones) + const uint32_t drawn = perf.frames > perf.skipped ? perf.frames - perf.skipped : 1; + auto us_drawn = [&](uint32_t cycles) { return cycles / ticks_per_us / drawn; }; fmt::print("[genesis perf] fps={:.1f} frame={}us 68k={}us vdp={}us convert={}us barrier={}us present={}us other={}us" " sleep={}us late={}/{} skipped={}/{} dropped={}/{} blit={}us | core1 snd={}us leash={}us | q_dropped={}" " | z80 irq={} busreq={} halted={} wto={} ymw={}" + " | drawn planes={}us sprites={}us merge={}us" " | int_free={} int_largest={}\n", fps, frame_us, m68k, vdp, convert, barrier, present, other, (uint32_t)(perf.sleep_us / n), perf.late, perf.frames, perf.skipped, perf.frames, @@ -119,9 +128,15 @@ static void genesis_perf_report() { z80_stat_irq_taken - perf.z80_irq_at_start, z80_stat_busreq - perf.z80_busreq_at_start, z80_stat_halted_slices - perf.z80_halted_at_start, z80_stat_wait_timeout - perf.z80_wto_at_start, z80_stat_ym_writes - perf.z80_ymw_at_start, + us_drawn(vdp_stat_planes_cycles - perf.vdp_planes_at_start), + us_drawn(vdp_stat_sprites_cycles - perf.vdp_sprites_at_start), + us_drawn(vdp_stat_merge_cycles - perf.vdp_merge_at_start), heap_caps_get_free_size(MALLOC_CAP_INTERNAL), heap_caps_get_largest_free_block(MALLOC_CAP_INTERNAL)); perf = GenesisPerf{}; + perf.vdp_planes_at_start = vdp_stat_planes_cycles; + perf.vdp_sprites_at_start = vdp_stat_sprites_cycles; + perf.vdp_merge_at_start = vdp_stat_merge_cycles; perf.dropped_at_start = dropped_now; perf.blitted_at_start = blitted_now; perf.blit_cycles_at_start = blit_cycles_now; @@ -879,6 +894,9 @@ static void init(uint8_t *romdata, size_t rom_data_size) { perf.z80_halted_at_start = z80_stat_halted_slices; perf.z80_wto_at_start = z80_stat_wait_timeout; perf.z80_ymw_at_start = z80_stat_ym_writes; + perf.vdp_planes_at_start = vdp_stat_planes_cycles; + perf.vdp_sprites_at_start = vdp_stat_sprites_cycles; + perf.vdp_merge_at_start = vdp_stat_merge_cycles; #endif genesis_initialized = true; } From 3eb943e597dea968169c2c4d2b718cde112bc084 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 15:24:32 -0500 Subject: [PATCH 2/6] genesis: keep 16 KB of internal RAM free for the rest of the system With every hot buffer preferring internal RAM the Genesis left 36 bytes in the main internal heap at one Sonic 3 launch; whatever then needed a few hundred internal bytes in the first frame did not get them, and the box hung rather than report it. Allocation order is now explicitly the priority order (work RAM, YM2612 tables, CPU and chip state, Z80 RAM, line buffers, audio mix buffers, sound queue, VRAM) and each prefer-internal allocation spills to PSRAM once it would push free internal RAM below GENESIS_INTERNAL_RESERVE_BYTES (16 KB). On the BOX-3 that currently moves only the sound queue to PSRAM, with no measurable change in the [genesis perf] line. Also: the diagnostic task watchdog (CONFIG_ESP_TASK_WDT_EN builds) is fed from the pause-menu wait loop too -- it fired on the pause screen after 30 s -- and a launch that lands in that loop says so on the console. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- .../genesis/include/genesis_shared_memory.hpp | 12 ++++++++++++ components/genesis/src/genesis.cpp | 6 ++++-- .../genesis/src/genesis_shared_memory.cpp | 19 ++++++++++++++++--- main/cart.hpp | 8 ++++++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/components/genesis/include/genesis_shared_memory.hpp b/components/genesis/include/genesis_shared_memory.hpp index 9153c517..9b7938ef 100644 --- a/components/genesis/include/genesis_shared_memory.hpp +++ b/components/genesis/include/genesis_shared_memory.hpp @@ -9,6 +9,18 @@ extern "C" { #endif +// Internal RAM the Genesis must leave for the rest of the system. The core's +// hot buffers prefer internal RAM in priority order (work RAM, YM2612 tables, +// CPU/chip state, Z80 RAM, line buffers, audio) and spill to PSRAM once an +// allocation would push free internal RAM below this. Measured: with no rule +// the Genesis left 36 bytes in the main internal heap at launch, and drivers +// that then could not get a few hundred bytes in the first frame hung the box. +#define GENESIS_INTERNAL_RESERVE_BYTES (16 * 1024) + +// True if allocating `size` bytes of internal RAM would leave at least +// GENESIS_INTERNAL_RESERVE_BYTES free. +bool genesis_internal_has_headroom(size_t size); + // Initialize Genesis shared memory and hardware components void genesis_init_shared_memory(void); diff --git a/components/genesis/src/genesis.cpp b/components/genesis/src/genesis.cpp index 51930655..53c23d35 100644 --- a/components/genesis/src/genesis.cpp +++ b/components/genesis/src/genesis.cpp @@ -462,7 +462,9 @@ static void reset_genesis_runtime_state() { } static void *allocate_hot_memory(size_t size, const char *name = "hot") { - void *ptr = heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); + // internal only while the system keeps its headroom (GENESIS_INTERNAL_RESERVE_BYTES) + void *ptr = genesis_internal_has_headroom(size) + ? heap_caps_malloc(size, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) : nullptr; const bool internal = (ptr != nullptr); if (!ptr) { ptr = heap_caps_malloc(size, MALLOC_CAP_8BIT | MALLOC_CAP_SPIRAM); @@ -478,7 +480,7 @@ static void *allocate_shared_hot_memory(size_t size, shared_mem_region_t region .region = region, .storage = SHARED_MEM_INTERNAL, }; - void *ptr = shared_mem_allocate(&request); + void *ptr = genesis_internal_has_headroom(size) ? shared_mem_allocate(&request) : nullptr; if (!ptr) { // degrade to PSRAM rather than fail init (see genesis_shared_memory.cpp) request.storage = SHARED_MEM_PSRAM; diff --git a/components/genesis/src/genesis_shared_memory.cpp b/components/genesis/src/genesis_shared_memory.cpp index a4fb0339..9ade6b13 100644 --- a/components/genesis/src/genesis_shared_memory.cpp +++ b/components/genesis/src/genesis_shared_memory.cpp @@ -1,6 +1,7 @@ #include "genesis_shared_memory.hpp" #include "shared_memory.h" #include "esp_log.h" +#include "esp_heap_caps.h" #include #include @@ -26,6 +27,12 @@ signed int *tl_tab = nullptr; // 13*2*TL_RES_LEN (13*2*256 * sizeof(signed int)) static constexpr const char *TAG = "genesis_shared_memory"; +bool genesis_internal_has_headroom(size_t size) { + // + a little for the allocator's own overhead and alignment padding + const size_t need = size + 64 + GENESIS_INTERNAL_RESERVE_BYTES; + return heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT) >= need; +} + static void *allocate_shared(size_t size, shared_mem_storage_t storage, shared_mem_region_t region = SHARED_MEM_DEFAULT) { shared_mem_request_t request = { .size = size, @@ -36,7 +43,11 @@ static void *allocate_shared(size_t size, shared_mem_storage_t storage, shared_m } static void *allocate_shared_prefer_internal(size_t size, const char *name, shared_mem_region_t region = SHARED_MEM_DEFAULT) { - void *ptr = allocate_shared(size, SHARED_MEM_INTERNAL, region); + // Internal only while it leaves the rest of the system its headroom: the + // Genesis would otherwise take every last internal byte (36 B free was + // measured at one launch), and drivers that then fail to get a few hundred + // bytes in the first frame hang the box rather than report it. + void *ptr = genesis_internal_has_headroom(size) ? allocate_shared(size, SHARED_MEM_INTERNAL, region) : nullptr; const bool internal = (ptr != nullptr); if (!ptr) { ptr = allocate_shared(size, SHARED_MEM_PSRAM, region); @@ -66,11 +77,13 @@ void genesis_init_shared_memory(void) { // on a black screen. m68k = (m68ki_cpu_core*)allocate_shared_prefer_internal(sizeof(m68ki_cpu_core), "m68k"); - ZRAM = (uint8_t*)allocate_shared_prefer_internal(MAX_Z80_RAM_SIZE, "ZRAM"); // 0x2000 (8kB) for Z80 RAM - + // per-sample state on core 1 before the Z80's RAM (allocation order is + // priority when the headroom rule starts spilling to PSRAM) ym2612 = (YM2612*)allocate_shared_prefer_internal(sizeof(YM2612), "ym2612"); OPNREGS = (uint8_t*)allocate_shared_prefer_internal(512, "OPNREGS"); + ZRAM = (uint8_t*)allocate_shared_prefer_internal(MAX_Z80_RAM_SIZE, "ZRAM"); // 0x2000 (8kB) for Z80 RAM + render_buffer = (uint8_t*)allocate_shared_prefer_internal(SCREEN_WIDTH + PIX_OVERFLOW*2, "render_buffer", SHARED_MEM_CACHE_LINE); sprite_buffer = (uint8_t*)allocate_shared_prefer_internal(SCREEN_WIDTH + PIX_OVERFLOW*2, "sprite_buffer", SHARED_MEM_CACHE_LINE); diff --git a/main/cart.hpp b/main/cart.hpp index 072a7c38..810aa1c3 100644 --- a/main/cart.hpp +++ b/main/cart.hpp @@ -185,6 +185,11 @@ class Cart { bool show_menu = btn_state || (state.start && state.select); if (show_menu) { logger_.info("Menu pressed!"); +#if CONFIG_ESP_TASK_WDT_EN + // diagnostic build: say so at a level that is printed, since a launch + // that lands straight in this loop looks like a hang from outside + fmt::print("[cart] menu loop entered (touch={} start+select={})\n", btn_state, state.start && state.select); +#endif pre_menu(); // take a screenshot before we show the menu screenshot(get_paused_image_path()); @@ -195,6 +200,9 @@ class Cart { while (!menu_->is_paused()) { using namespace std::chrono_literals; std::this_thread::sleep_for(100ms); +#if CONFIG_ESP_TASK_WDT_EN + esp_task_wdt_reset(); // diagnostic watchdog: alive while the menu is up +#endif } // make sure to clear the screen before we resume the game BoxEmu::get().clear_screen(); From 68ffe6f52db749d6a5bc3182d2b113389fdc8b68 Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 15:37:13 -0500 Subject: [PATCH 3/6] box-emu: run the I2S feed task above the emulator tasks The BSP's audio task hands one block at a time to the I2S DMA -- microseconds of work -- but at its default priority 19 it shared core 1 with the Genesis sound task and the video task (both 20) and only ran in the gaps between frames. When core 1 was saturated (Sonic 3's special stages) the DMA underran and the audio queue overflowed: ~7% of the audio was dropped per 12 s window while the emulator was at a locked 60 fps. At priority 21 it always runs promptly; the special stage audio is now clean ('perfect' by ear) with zero drops. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/box-emu/src/box-emu.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/components/box-emu/src/box-emu.cpp b/components/box-emu/src/box-emu.cpp index 1e9e69ce..58ba4ec9 100644 --- a/components/box-emu/src/box-emu.cpp +++ b/components/box-emu/src/box-emu.cpp @@ -44,7 +44,13 @@ bool BoxEmu::initialize_box() { logger_.info("Initializing underlying BSP"); auto &box = Bsp::get(); // initialize the sound - if (!box.initialize_sound()) { + // The I2S feed task does almost no work (hand one block to the DMA) but it + // must never be starved: it shares core 1 with the emulators' sound and + // video tasks (priority 20), and at the BSP's default priority 19 it only + // ran in the gaps between frames -- when core 1 was saturated (Sonic 3's + // special stages) the DMA underran and the audio queue overflowed, dropping + // ~7% of the audio. Above both emulator tasks it always runs promptly. + if (!box.initialize_sound(48000, {.name = "audio", .stack_size_bytes = 4096, .priority = 21, .core_id = 1})) { logger_.error("Failed to initialize sound!"); return false; } From c86a9aa20a2e7b0d08a5f42d4eda6a19970e2d2d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 16:57:55 -0500 Subject: [PATCH 4/6] genesis: review fixes -- GENESIS_PERF covers the VDP counters, VRAM truly last, index buffer under the headroom rule - GENESIS_PERF is now defined in gwenesis_vdp.h (included by both genesis.cpp and gwenesis_vdp_gfx.c) and the VDP's per-stage cycle counters are guarded by it, so GENESIS_PERF=0 compiles all of the profiling out as documented (verified: both objects build warning-free with the flag off). - VRAM moves out of genesis_init_shared_memory() into genesis_init_shared_vram(), called from init() after the audio buffers, index line buffer and sound queue, so it is the last internal allocation in fact and not only in the comments. - genesis_index_buffer goes through allocate_hot_memory() like every other internal allocation, so it cannot eat into the 16 KB reserve. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- .../genesis/gwenesis/src/vdp/gwenesis_vdp.h | 10 ++++++- .../gwenesis/src/vdp/gwenesis_vdp_gfx.c | 23 ++++++++++----- .../genesis/include/genesis_shared_memory.hpp | 7 ++++- components/genesis/src/genesis.cpp | 29 +++++++++---------- .../genesis/src/genesis_shared_memory.cpp | 11 +++++-- 5 files changed, 53 insertions(+), 27 deletions(-) diff --git a/components/genesis/gwenesis/src/vdp/gwenesis_vdp.h b/components/genesis/gwenesis/src/vdp/gwenesis_vdp.h index 1d2a0f7d..0cd27dab 100644 --- a/components/genesis/gwenesis/src/vdp/gwenesis_vdp.h +++ b/components/genesis/gwenesis/src/vdp/gwenesis_vdp.h @@ -140,10 +140,18 @@ void gwenesis_vdp_set_buffers(unsigned char *screen_buffer, unsigned char *scale void gwenesis_vdp_set_buffer(unsigned char *ptr_screen_buffer); void gwenesis_vdp_render_line(int line); void gwenesis_vdp_skip_line(int line); /* ESP port: status bookkeeping for undrawn frames */ -/* ESP port diagnostics: CPU cycles per render stage, monotonic (see gwenesis_vdp_gfx.c) */ +/* ESP port diagnostics. GENESIS_PERF (default on) compiles in the per-frame + * time breakdown: the [genesis perf] report in genesis.cpp and these CPU-cycle + * counters per render stage (monotonic; see gwenesis_vdp_gfx.c). Defined here, + * in the header both files include, so GENESIS_PERF=0 removes all of it. */ +#ifndef GENESIS_PERF +#define GENESIS_PERF 1 +#endif +#if GENESIS_PERF extern uint32_t vdp_stat_planes_cycles; extern uint32_t vdp_stat_sprites_cycles; extern uint32_t vdp_stat_merge_cycles; +#endif void gwenesis_vdp_render_config(); diff --git a/components/genesis/gwenesis/src/vdp/gwenesis_vdp_gfx.c b/components/genesis/gwenesis/src/vdp/gwenesis_vdp_gfx.c index 10dc0ba1..7b61f89f 100644 --- a/components/genesis/gwenesis/src/vdp/gwenesis_vdp_gfx.c +++ b/components/genesis/gwenesis/src/vdp/gwenesis_vdp_gfx.c @@ -1026,11 +1026,19 @@ blit_4to5_line(uint16_t *in, uint16_t *out) { } /* ESP port diagnostics: CPU cycles spent per render stage (monotonic; the - * driver reports per-window deltas in the [genesis perf] line). */ + * driver reports per-window deltas in the [genesis perf] line). Compiled out + * with GENESIS_PERF=0 (gwenesis_vdp.h) along with the rest of the profiling. */ +#if GENESIS_PERF #include uint32_t vdp_stat_planes_cycles = 0; /* draw_line_b + draw_line_aw */ uint32_t vdp_stat_sprites_cycles = 0; /* draw_sprites(_over_planes) */ uint32_t vdp_stat_merge_cycles = 0; /* SHI merge or memcpy into the line buffer */ +#define VDP_STAT_MARK(t) uint32_t t = esp_cpu_get_cycle_count() +#define VDP_STAT_ADD(acc, t_end, t_start) ((acc) += (t_end) - (t_start)) +#else +#define VDP_STAT_MARK(t) ((void)0) +#define VDP_STAT_ADD(acc, t_end, t_start) ((void)0) +#endif /* ESP port: called instead of gwenesis_vdp_render_line() for the scanlines of * a frame whose pixels are not drawn (the display cannot show it). Keeps the @@ -1090,18 +1098,18 @@ void gwenesis_vdp_render_line(int line) if (MODE_SHI) memset(ps, 0, 320); - uint32_t t0 = esp_cpu_get_cycle_count(); + VDP_STAT_MARK(t0); draw_line_b(line); draw_line_aw(line); - uint32_t t1 = esp_cpu_get_cycle_count(); - vdp_stat_planes_cycles += t1 - t0; + VDP_STAT_MARK(t1); + VDP_STAT_ADD(vdp_stat_planes_cycles, t1, t0); if (MODE_SHI) draw_sprites(line); else draw_sprites_over_planes(line); - uint32_t t2 = esp_cpu_get_cycle_count(); - vdp_stat_sprites_cycles += t2 - t1; + VDP_STAT_MARK(t2); + VDP_STAT_ADD(vdp_stat_sprites_cycles, t2, t1); /* Mode Highlight/shadow is enabled */ if (MODE_SHI) { @@ -1143,7 +1151,8 @@ void gwenesis_vdp_render_line(int line) } else { memcpy(screen_buffer_line, pb, screen_width); } - vdp_stat_merge_cycles += esp_cpu_get_cycle_count() - t2; + VDP_STAT_MARK(t3); + VDP_STAT_ADD(vdp_stat_merge_cycles, t3, t2); } void gwenesis_vdp_gfx_save_state() { diff --git a/components/genesis/include/genesis_shared_memory.hpp b/components/genesis/include/genesis_shared_memory.hpp index 9b7938ef..993db10b 100644 --- a/components/genesis/include/genesis_shared_memory.hpp +++ b/components/genesis/include/genesis_shared_memory.hpp @@ -21,9 +21,14 @@ extern "C" { // GENESIS_INTERNAL_RESERVE_BYTES free. bool genesis_internal_has_headroom(size_t size); -// Initialize Genesis shared memory and hardware components +// Initialize Genesis shared memory and hardware components (everything except +// VRAM, in internal-RAM priority order) void genesis_init_shared_memory(void); +// Allocate VRAM. Called last, after the caller has placed its own hot buffers: +// allocation order is internal-RAM priority, and VRAM is the buffer to spill. +void genesis_init_shared_vram(void); + // Free Genesis shared memory void genesis_free_shared_memory(void); diff --git a/components/genesis/src/genesis.cpp b/components/genesis/src/genesis.cpp index 53c23d35..8eb662e2 100644 --- a/components/genesis/src/genesis.cpp +++ b/components/genesis/src/genesis.cpp @@ -42,10 +42,8 @@ extern "C" { // serial capture of a play session doubles as a profile (tools/perf_report.py // summarises/diffs them). Cost is one CCOUNT read per section per scanline // (~2 cycles each), i.e. well under 0.1% of a frame; set GENESIS_PERF=0 to -// compile it out entirely. -#ifndef GENESIS_PERF -#define GENESIS_PERF 1 -#endif +// compile it out entirely. The flag is defined in gwenesis_vdp.h so the VDP's +// own render-stage counters (vdp_stat_*) follow it too. #if GENESIS_PERF static constexpr uint32_t GENESIS_PERF_REPORT_FRAMES = 300; struct GenesisPerf { @@ -796,8 +794,9 @@ static void init(uint8_t *romdata, size_t rom_data_size) { // The YM2612 LFO table is next: like tl_tab/sin_tab (allocated first in // genesis_init_shared_memory()) it is read per sample on core 1, and in PSRAM // its random reads thrash the data cache both cores share. VRAM is allocated - // last and spills to PSRAM when the block is too small for everything -- - // moving it internal measured no VDP speed-up. See genesis_shared_memory.cpp. + // last (genesis_init_shared_vram(), below) and spills to PSRAM when the block + // is too small for everything -- moving it internal measured no VDP speed-up. + // See genesis_shared_memory.cpp. M68K_RAM = (uint8_t*)allocate_hot_memory(MAX_RAM_SIZE, "M68K_RAM"); lfo_pm_table = (int16_t*)allocate_hot_memory(128*8*16 * sizeof(int16_t), "lfo_pm_table"); @@ -813,13 +812,9 @@ static void init(uint8_t *romdata, size_t rom_data_size) { // the live CRAM565 immediately afterward. Because only one line is ever live it // is a tiny INTERNAL-RAM buffer instead of a ~77KB PSRAM full-frame buffer, so // both the VDP's per-line writes and the converter's reads hit fast internal - // RAM. (Try internal; in the unlikely event it can't be had, fall back to PSRAM.) - genesis_index_buffer = (uint8_t*)heap_caps_malloc( - GENESIS_SCREEN_WIDTH, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT); - if (!genesis_index_buffer) { - genesis_index_buffer = (uint8_t*)heap_caps_malloc( - GENESIS_SCREEN_WIDTH, MALLOC_CAP_8BIT | MALLOC_CAP_SPIRAM); - } + // RAM. (Same headroom rule as every other internal allocation; falls back to + // PSRAM rather than eat into the system's reserve.) + genesis_index_buffer = (uint8_t*)allocate_hot_memory(GENESIS_SCREEN_WIDTH, "index_buffer"); #if GENESIS_DUAL_CORE sound_q = (uint32_t*)allocate_hot_memory(SOUND_Q_SIZE * sizeof(uint32_t), "sound_q"); @@ -831,6 +826,11 @@ static void init(uint8_t *romdata, size_t rom_data_size) { } #endif + // VRAM last of all: every allocation above is hotter, and with the headroom + // rule allocation order is priority -- whatever no longer fits spills to + // PSRAM, and 64 kB of VRAM is the buffer measured to lose nothing there. + genesis_init_shared_vram(); + if (!gwenesis_sn76489_buffer || !gwenesis_ym2612_buffer || !M68K_RAM || !lfo_pm_table || !VRAM || !ZRAM || !ym2612 || !OPNREGS || !sin_tab || !render_buffer || !sprite_buffer || !CRAM || !SAT_CACHE || !gwenesis_vdp_regs || !fifo || !CRAM565 || !VSRAM || !tl_tab || @@ -1158,7 +1158,6 @@ void IRAM_ATTR run_genesis_rom() { // overruns the audio buffer and stutters. Pace each region at its own rate. const uint64_t max_frame_time = 1000000 / (REG1_PAL ? GWENESIS_REFRESH_RATE_PAL : GWENESIS_REFRESH_RATE_NTSC); - const bool late = elapsed >= max_frame_time; // this frame overran its period // // Pace against a running deadline rather than per frame. Since the display- // aware frame skip, frames alternate between rendered (~19 ms, overrunning) @@ -1193,7 +1192,7 @@ void IRAM_ATTR run_genesis_rom() { #if GENESIS_PERF perf.frame_us += elapsed; perf.sleep_us += esp_timer_get_time() - end; - perf.late += late; + perf.late += (elapsed >= max_frame_time); // this frame overran its period if (++perf.frames == GENESIS_PERF_REPORT_FRAMES) { genesis_perf_report(); } diff --git a/components/genesis/src/genesis_shared_memory.cpp b/components/genesis/src/genesis_shared_memory.cpp index 9ade6b13..a1b832cf 100644 --- a/components/genesis/src/genesis_shared_memory.cpp +++ b/components/genesis/src/genesis_shared_memory.cpp @@ -66,8 +66,10 @@ void genesis_init_shared_memory(void) { // then the YM2612 lookup tables -- they are read on EVERY sample by the // core-1 sound unit, and when they fall to PSRAM their random reads thrash // the data cache the two cores share (sound frame +0.5 ms, 68k slower, - // audible crackle). VRAM comes LAST: moving it internal measured no change - // in the VDP render time, so it is the right buffer to spill to PSRAM. + // audible crackle). VRAM comes LAST, in genesis_init_shared_vram() after + // init() has placed its own hot buffers (audio, index line, sound queue): + // moving it internal measured no change in the VDP render time, so it is + // the right buffer to spill to PSRAM. tl_tab = (signed int*)allocate_shared_prefer_internal(13*2*256 * sizeof(signed int), "tl_tab"); sin_tab = (unsigned int*)allocate_shared_prefer_internal(SIN_LEN * sizeof(unsigned int), "sin_tab"); @@ -93,8 +95,11 @@ void genesis_init_shared_memory(void) { fifo = (uint16_t*)allocate_shared_prefer_internal(FIFO_SIZE * sizeof(uint16_t), "fifo"); CRAM565 = (uint16_t*)allocate_shared_prefer_internal(CRAM_MAX_SIZE * 4 * sizeof(uint16_t), "CRAM565"); VSRAM = (uint16_t*)allocate_shared_prefer_internal(VSRAM_MAX_SIZE * sizeof(uint16_t), "VSRAM"); +} - // 64kB; internal only if the big block still has room (see above) +void genesis_init_shared_vram(void) { + // 64kB; internal only if the big block still has room once everything + // hotter has been placed (see genesis_init_shared_memory()) VRAM = (uint8_t*)allocate_shared_prefer_internal(VRAM_MAX_SIZE, "VRAM", SHARED_MEM_CACHE_LINE); } From aff2c2981626f4c6c8ab80b156f540ddfd3d8a8f Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 20:36:12 -0500 Subject: [PATCH 5/6] box-emu: require espp/esp-box >= 1.3.8 (I2S feed waits for a whole block) espp #845 (released in 1.3.8) changes the esp-box audio task to wait for a whole block instead of padding a partial one with silence, which was the remaining source of stutter in Sonic 3's special stages. Verified on the ESP-BOX with the released component: Sonic 3 60.1-60.5 fps, 0 dropped audio frames, "sound is still perfect". Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/box-emu/idf_component.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/components/box-emu/idf_component.yml b/components/box-emu/idf_component.yml index a2c08322..820818b7 100644 --- a/components/box-emu/idf_component.yml +++ b/components/box-emu/idf_component.yml @@ -6,7 +6,9 @@ dependencies: espp/aw9523: '>=1.3.4' espp/button: '>=1.3.4' espp/drv2605: '>=1.3.4' - espp/esp-box: '>=1.3.4' + # >=1.3.8: the I2S feed task waits for a whole block instead of padding a + # partial one with silence (espp #845) -- needed for stutter-free Genesis audio + espp/esp-box: '>=1.3.8' espp/event_manager: '>=1.3.4' espp/max1704x: '>=1.3.4' espp/mcp23x17: '>=1.3.4' From fa11f03ca5fc122ea70c255cd223a23738cdf05d Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Mon, 12 Oct 2026 21:41:35 -0500 Subject: [PATCH 6/6] genesis: take the VDP timestamp inside the render branch (cppcheck variableScope) t1 is only read when the frame is rendered; reading it there is equivalent (nothing runs between the m68k accumulation and the branch) and satisfies cppcheck's variableScope check at both call sites. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AWhoGwibqcKDhcG17mjVT1 --- components/genesis/src/genesis.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/genesis/src/genesis.cpp b/components/genesis/src/genesis.cpp index 8eb662e2..7cc5a22f 100644 --- a/components/genesis/src/genesis.cpp +++ b/components/genesis/src/genesis.cpp @@ -578,9 +578,9 @@ static IRAM_ATTR void cpu_vdp_run_frame(int screen_height, int lines_per_frame) m68k_run(system_clock); // Release this line to the sound core: its 68k->sound writes are now queued. core0_line.store(scan_line, std::memory_order_release); - uint32_t t1 = PERF_NOW(); PERF_ACC(m68k, t0); if (render_frame) { + uint32_t t1 = PERF_NOW(); gwenesis_vdp_render_line(scan_line); uint32_t t2 = PERF_NOW(); PERF_ACC(vdp, t1); @@ -1022,12 +1022,12 @@ void IRAM_ATTR run_genesis_rom() { ym2612_run(system_clock); } // (single-core path: the Z80/sound share the "m68k" bucket) - uint32_t t1 = PERF_NOW(); PERF_ACC(m68k, t0); /* Video */ if (scan_line < screen_height) { if (render_frame) { + uint32_t t1 = PERF_NOW(); gwenesis_vdp_render_line(scan_line); /* render scan_line */ uint32_t t2 = PERF_NOW(); PERF_ACC(vdp, t1);