8000 vecx/NOTES.md at master · pomettini/vecx · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
531 lines (426 loc) · 105 KB

NOTES.md

File metadata and controls

531 lines (426 loc) · 105 KB

vecx Playdate Port Notes

Current Status

  • Goal: port vecx to a Playdate-only, C-only project.
  • Priority: smoothness and speed on real Playdate hardware before emulation accuracy.
  • Initial Playdate C build succeeds locally and produces vecx.pdx.
  • The first Playdate milestone has visible vector rendering, D-pad/A/B input, FPS display, and low-frequency benchmark logs.
  • Audio is deferred. e8910.c is currently a no-op sound stub so the project has no SDL dependency.
  • Build 7 sets the Playdate update callback and core vector redraw cadence to 60 Hz (VECTREX_MHZ / 60 cycles per update) as the next smoothness/speed experiment.
  • Real-device testing shows the first build is CPU/emulation-bound, not render-bound.
  • The inverted-color build was pushed to PDU1-Y024621 on 2026-05-31 03:43 CEST with mine_storm.vec packaged.
  • Build 2 confirmed mine_storm.vec loads, but performance remains CPU/emulation-bound at about 5.8 FPS.
  • Build 3 tested instruction-batched analog stepping plus -O3 device compilation. It regressed performance, so build 4 disabled the batching path and added better emulator counters.
  • Build 4 shows about 14k 6809 instructions and 50k emulated cycles per attempted update. Build 5 will try corrected full instruction batching across VIA timers and analog stepping.
  • Build 5 improved to about 7.1 FPS.
  • Build 6 regressed to about 5.9 FPS, likely because forced inline/direct CPU memory access increased code size and hurt instruction cache behavior.
  • Build 7 returns to the build 5 CPU shape and raises the vector redraw cadence to 60 Hz, halving the emulated cycles per rendered frame as an explicit speed/smoothness tradeoff.
  • Build 7 was pushed to PDU1-Y024621 on 2026-05-31 17:19 CEST. It reaches about 14.3 FPS by halving emulated cycles per rendered vector frame, but actual emulated throughput is still about 360k cycles/sec.
  • Build 8 enables LTO for the Playdate device build. It reaches about 15.9-16.1 FPS and about 400k emulated cycles/sec; pdex.bin shrank from about 19.7 KB to 15.8 KB.
  • Build 9 tests direct, non-inlined vecx memory calls from the 6809 core. This removes function-pointer dispatch overhead without allowing LTO to inline the whole memory mapper into the CPU switch. It was pushed to PDU1-Y024621 on 2026-05-31 17:28 CEST.
  • Build 9 is effectively neutral versus build 8: about 15.9-16.3 FPS and 61.1-62.7 ms updates, so direct noinline memory calls are not a major standalone win.
  • Build 10 tests cheaper CPU condition-code helpers and zero tests only. It regressed to about 15.1-15.3 FPS and 65-66 ms updates, so the original flag helper shape is better for this compiler/core.
  • Build 11 reverts Build 10's flag-helper regression and tests a CrankBoy-style layout pass: local linker map, 32-byte hot-section fences, hot/cold function sections, and -falign-loops=32. It regressed hard to about 12.4-12.6 FPS and 79-80 ms updates, so this simple layout pass should be reverted.
  • Build 12 returns to the Build 8/9 code-generation shape and tests a targeted fast PC fetch path for opcode/immediate reads from cart/BIOS ROM. It regressed to about 13.0 FPS and 76 ms updates; the extra code/cache cost outweighed the saved memory-map branches.
  • Build 13 reverts the fast PC fetch path and adds low-frequency opcode histogram logging so the next CPU work is based on Mine Storm's actual hot instruction mix. It was pushed to PDU1-Y024621 on 2026-05-31 18:06 CEST.
  • Build 13 shows the diagnostic counter costs about 2.5 FPS versus build 8/9, but the hot instruction mix is clear: BEQ (0x27) and BITA direct (0x95) account for roughly 40% of executed instructions, followed by STA direct (0x97), NOP (0x12), and LDA indexed (0xa6).
  • Build 14 adds targeted hot-op profiling for BEQ taken/not-taken counts, BITA direct operands, STA direct operands, and LDA indexed postbytes. It was pushed to PDU1-Y024621 on 2026-05-31 18:10 CEST and should identify whether the hotspot is a single VIA polling loop.
  • Build 14 confirms the hottest loop is VIA polling: BITA <$0d is the only hot BITA direct operand and maps to VIA IFR at $D00D; BEQ is taken roughly 97.5% of the time. Hot STA direct operands are $0a, $00, and $01, which map to the VIA shift register and output ports.
  • Build 15 removes the profiling counters and tests two small hot-op fast paths: branchful BNE/BEQ, and a BITA <$0d shortcut that reads VIA IFR directly when DP is $D0. It was pushed to PDU1-Y024621 on 2026-05-31 18:15 CEST.
  • Build 15 regressed versus the build 8/9 no-profiler baseline, reaching about 15.4-15.6 FPS and 64-65 ms updates. Build 16 removes the VIA IFR shortcut and keeps only the branchful BNE/BEQ handling to isolate which part caused the regression.
  • Build 16 regressed further to about 12.0 FPS and 82-84 ms updates, so branchful BNE/BEQ is the bad shape on this compiler/core. Build 17 reverts that path back to the original branch helper.
  • Build 17 recovers the build 8/9 baseline at about 15.9-16.3 FPS and 61-63 ms updates.
  • Build 18 tests a tiny LDA indexed specialization for Mine Storm's hot A6 postbytes: $c0 (LDA ,U+) and $86 (LDA A,X), bypassing the large indexed-addressing switch for those two cases.
  • Build 18 regressed to about 12.7-12.9 FPS and 77-79 ms updates, confirming that adding opcode-specific branches inside the 6809 switch is usually worse than the work it saves.
  • Build 19 restores the recovered CPU shape and tests a 75 Hz Vectrex/update cadence, reducing cycles per visual update from 25,000 to 20,000 as a deliberate smoothness-over-accuracy tradeoff.
  • Build 19 improves visible cadence to about 19.3-19.6 FPS and 50-52 ms updates. Render cost stays around 1 ms and emulated throughput remains around 390k cycles/sec, so the gain comes from fewer emulated cycles per visual update rather than a faster CPU core.
  • Build 20 tests the next cadence step at 90 Hz, reducing cycles per visual update to about 16,666.
  • Build 20 improves visible cadence to about 22.2-22.7 FPS and 44 ms updates. Render cost drops below 1 ms with about 84-87 vectors/render; emulated throughput is about 373k-379k cycles/sec.
  • Build 21 tests a 120 Hz Vectrex/update cadence, reducing cycles per visual update to 12,500.
  • Build 21 improves visible cadence to about 27.4-27.8 FPS and 35-36 ms updates. Render cost remains below 1 ms with about 63-65 vectors/render.
  • Build 22 rotates the Vectrex output 90 degrees counter-clockwise for a Playdate landscape fill and remaps the D-pad axes to match the rotated display. It was pushed to PDU1-Y024621 on 2026-06-01 00:24 CEST.
  • Build 22's direction was wrong for the desired handheld orientation: it used +90 degrees instead of -90 degrees.
  • Build 23 flips the landscape transform to -90 degrees and adds a short vector-history persistence pass: draw the three previous vector frames, then draw the current frame. It was pushed to PDU1-Y024621 on 2026-06-01 00:30 CEST.
  • Build 23 mostly removes the visible flicker, but text strokes are still hard to read on the Playdate screen.
  • Build 24 keeps build 23's rotation and persistence, then tests 2-pixel vector line width for readability. It was pushed to PDU1-Y024621 on 2026-06-01 00:35 CEST.
  • Build 24 made readability worse and raised render cost to about 3.2-3.4 ms, so the 2-pixel stroke should be reverted.
  • Build 25 reverts to 1-pixel vectors and changes rotated rendering from independent X/Y scaling to one uniform scale factor. This preserves the emulator coordinate aspect ratio instead of stretching the image to the Playdate's wider screen. It was pushed to PDU1-Y024621 on 2026-06-01 00:40 CEST.
  • Build 25 preserves aspect ratio and lowers render cost versus build 24, but text is still not readable enough. Readability is now deferred; full-speed emulation is the priority.
  • Build 26 tests a conservative VIA IFR polling-loop skip. It detects the exact BITA <$0d + self-branching BEQ loop while DP is $D0, then fast-forwards VIA/analog time to the next relevant timer interrupt instead of executing repeated polling instructions. It was pushed to PDU1-Y024621 on 2026-06-01 00:47 CEST.
  • Build 26 works: wait_skips is about 99-104 per 5-second window, wait_skip_cycles is about 568k-598k cycles/window, instruction count drops from about 3.5k to about 2.2k instructions/update, and update time drops from about 44-46 ms to about 37 ms.
  • ESPboy Vectrex findings: it preserves aspect ratio with one scale factor, uses a 128x128 unrotated framebuffer, draws points for degenerate vectors, keeps one-pixel lines, and does not appear to use persistence. Its main performance knobs are lower render cadence (PDECAY_INIT 27), 12,500-cycle emulation chunks (VECTREX_MHZ_DIV 120), smaller vector/hash buffers (VECTOR_CNT_INIT 600), disabled sound by default, and narrower integer types suited to the ESP8266 toolchain.
  • Build 27 decouples emulation update cadence from vector render cadence: update chunks remain 120 Hz / 12,500 cycles, while vector redraw cadence changes to 27 Hz / about 55,555 cycles like ESPboy. It also changes vector storage from cycle-derived capacity to fixed caps: 4096 vectors and an 8191-entry hash. It was pushed to PDU1-Y024621 on 2026-06-01 04:15 CEST.
  • Build 27 is a bad visual tradeoff on Playdate: because the emulator only reaches about 0.41M emulated cycles/sec, the 27 Hz emulated-time render cadence becomes only about 7.4 real redraws/sec. It raises update FPS to about 33, but each redraw contains about 700-800 vectors and the beam/dotted-line feel changes.
  • Build 28 restores vector render cadence to the 120 Hz update cadence while keeping the useful avg_render_fps benchmark field and the smaller fixed vector/hash buffers. It was pushed to PDU1-Y024621 on 2026-06-01 04:20 CEST.
  • Build 28 recovers the expected dotted-line visual character: avg_render_fps matches avg_fps at about 26.2-26.5, render cost stays about 2.4-2.5 ms, and the VIA wait-loop skip still saves about 580k-605k cycles per 5-second window.
  • Build 29 tests a direct $D0xx VIA read/write fast path in vecx_read8() and vecx_write8(). Mine Storm's remaining hot direct I/O traffic is mostly VIA register access, so this avoids the full memory-map branch ladder without changing render cadence. It was pushed to PDU1-Y024621 on 2026-06-01 04:26 CEST.
  • Build 30 changes the Playdate button mapping: A now presses Vectrex button 3, and B now presses Vectrex button 4. It was pushed to PDU1-Y024621 on 2026-06-01 04:29 CEST.
  • Build 30 benchmarks at about 26.8-27.1 FPS with render FPS matching update FPS. The direct $D0xx VIA fast path from build 29 appears to be a small win over build 28, but render cost also rose slightly to about 2.6-2.8 ms.
  • ESPboy comparison: its biggest apparent speed trick is not a faster CPU core. It runs 12,500-cycle emulation slices, but renders only every 1500000 / 27 emulated cycles. On Playdate, build 27 proved that transplant directly gives only about 7.4 real render FPS at our current emulated throughput and changes the dotted-line visual feel. The remaining useful ESPboy ideas are smaller vector/hash buffers, point drawing for degenerate vectors, no persistence, no sound, and narrower integer state; each needs hardware benchmarking because ARM codegen/cache behavior has already made several "obvious" optimizations regress.
  • Build 31 tests the Playdate optimization thread's instruction-cache advice by compiling only e6809.c with -Os while leaving the rest of the project on -O3. The goal is to shrink the large 6809 interpreter hot path without changing emulation/render behavior. It was pushed to PDU1-Y024621 on 2026-06-01 04:41 CEST.
  • Build 31 successfully shrinks code: pdex.bin drops from 17249 to 12811 bytes, ARM .text drops from 31887 to 20292 bytes, and e6809_sstep drops from about 20 KB (0x4f08) to about 8.5 KB (0x2164).
  • Build 31 regresses badly on hardware: despite the smaller interpreter, FPS drops to about 18.1-19.4 and update time rises to about 50.7-54.5 ms. The -Os rule should be removed; smaller code alone is not enough if the generated interpreter path is slower.
  • Build 32 removes the e6809.c -Os rule and tests the safer ESPboy-style data-footprint change: reduce vector capacity/hash from 4096/8191 to 768/1021 and store hash indices as int16_t. It was pushed to PDU1-Y024621 on 2026-06-01 04:44 CEST.
  • Build 32 restores e6809_sstep to the -O3 size (0x4f08) while cutting ARM BSS from 269724 to 105884 bytes. pdex.bin is 17315 bytes.
  • Build 32 is neutral to slightly worse versus build 30: about 26.2-26.6 FPS and 36.6-37.3 ms/update. Keep the smaller vector/hash storage for now because it greatly reduces BSS and does not obviously damage visuals, but it is not a speed win.
  • Build 33 adds low-overhead Playdate DWT cycle-counter profiling inside vecx_emu(). It reports the relative core time spent in 6809 instruction stepping, VIA/analog machine advancement, and wait-loop detection. This is a diagnostic build; compare proportions first, FPS second. It was pushed to PDU1-Y024621 on 2026-06-01 04:49 CEST.
  • Build 33 crashes before logging on hardware. Likely cause: direct DWT/debug register access hard-faults on Playdate firmware. Remove the DWT profiling path and do not use raw Cortex-M debug registers for device profiling.
  • Build 34 is a recovery build: remove build 33's DWT profiling and restore the build 32 benchmark format/behavior while keeping the smaller vector/hash storage. It was pushed to PDU1-Y024621 on 2026-06-01 04:52 CEST.

Repository Findings

  • vecx.c contains the Vectrex machine/VIA/analog/vector core.
  • e6809.c and e6809.h contain the Motorola 6809 CPU emulator.
  • playdate_main.c owns the Playdate platform layer: ROM/cart loading, fixed update timing, input, vector rendering, FPS display, and benchmark logging.
  • e8910.c is a temporary no-op sound stub.
  • osint.c was removed. osint.h remains only as the render hook declaration used by vecx.c.
  • vecx.c calls osint_render() whenever the emulated vector frame is ready, and the Playdate platform layer now implements it.
  • Makefile now uses the Playdate C SDK build support instead of SDL.

Porting Plan

  1. Done: replace the SDL Makefile with a Playdate C SDK Makefile.
  2. Done: create Source/pdxinfo Playdate metadata.
  3. Done: move the BIOS ROM into Source/rom.dat so it is packaged into the .pdx.
  4. Done: add a Playdate C entry point that:
    • loads rom.dat with pd->file,
    • optionally loads a local cart.vec or mine_storm.vec if present in the PDX,
    • resets the emulator,
    • sets a C update callback,
    • runs fixed Vectrex cycles per Playdate update.
  5. Done: implement osint_render() with Playdate graphics calls.
  6. Done: map Playdate controls:
    • D-pad to Vectrex joystick X/Y,
    • A to Vectrex button 3,
    • B to Vectrex button 4.
  7. Done: show FPS with pd->system->drawFPS(0, 0).
  8. Done: add low-frequency benchmark logging through pd->system->logToConsole.
  9. Done: remove SDL source/build code.
  10. Next: run on real hardware and iterate using benchmark logs.

Playdate Rendering Strategy

  • Start with Playdate graphics->clear() and graphics->drawLine().
  • Render through Playdate display inversion so the device shows white vectors and FPS text on a black background.
  • Rotate the original portrait Vectrex coordinate space into the Playdate's 400x240 landscape screen.
  • Build 23 changes the rotation to -90 degrees for the desired device orientation.
  • Build 23 adds vector persistence by caching the previous three vector lists and redrawing them before the current vector list. This should make text and sparse strokes easier to see, at the cost of more drawLine() calls per render.
  • Build 24 drew vectors with a 2-pixel stroke, but that made readability worse and increased render cost.
  • Build 25 uses one uniform integer scale factor after rotation. The fitted Vectrex field is about 297x239 pixels, centered horizontally, preserving the ALG_MAX_Y:ALG_MAX_X ratio instead of stretching to 400x240.
  • Keep pd->system->drawFPS(0, 0) in the Playdate screen's top-left corner.
  • ESPboy's renderer also uses a single aspect-preserving scale factor and one-pixel lines. Its text likely benefits from preserving the source geometry; it does not contain a special text reconstruction path.
  • Build 39 tests a raw framebuffer renderer for one-pixel vectors, clearing the 1-bit framebuffer directly and plotting Bresenham lines instead of calling pd->graphics->drawLine() per vector.
  • Later benchmark alternatives:
    • rotated landscape layout,
    • direct framebuffer drawing,
    • line thinning or intensity thresholding,
    • frame skipping if real hardware cannot keep pace.

Benchmark Plan

  • Log once every 5 seconds, not every frame.
  • Track:
    • build label,
    • update count,
    • rendered vector frame count,
    • average FPS,
    • average update/emulation time,
    • average render time,
    • min/max update frame time,
    • skipped frame count.
  • Keep benchmark output concise enough to paste back between device builds.
  • Current log format is one line every 5 seconds: vecx bench build="..." window_ms=... updates=... renders=... avg_fps=... avg_render_fps=... avg_update_ms=... avg_render_ms=... min_update_ms=... max_update_ms=... emu_cycles=... emu_instr=... avg_instr_update=... avg_cpi=... avg_vectors=... wait_skips=... wait_skip_cycles=... delay_skips=... delay_skip_cycles=... skipped=...
  • Build 13 adds one companion line every benchmark window: vecx opcodes build="..." top=xx:n,xx:n,xx:n,xx:n,xx:n
  • Build 14 adds one more diagnostic line every benchmark window: vecx hotops build="..." beq_taken=... beq_not=... dir95=xx:n,... dir97=xx:n,... a6pb=xx:n,...

Known Limitations

  • Audio is disabled for the first Playdate milestone.
  • Only two of four Vectrex buttons are mapped because the Playdate has A/B plus the D-pad. Current mapping uses buttons 3 and 4.
  • The first renderer is correctness-oriented and simple, not the final fast path.
  • Real-device benchmark data from PDU1-Y024621 shows roughly 5.8 FPS, with rendering around 3 ms and emulation/update around 171 ms.
  • Local build warnings:
    • Simulator linking warns about reducing __DATA,__common alignment because the emulator keeps large static vector buffers.

Benchmark History

Build Device Avg FPS Avg Update Avg Render Min/Max Update Notes
Initial SDL code n/a n/a n/a n/a n/a Desktop-only baseline before Playdate port.
Playdate C build 1 local build only n/a n/a n/a n/a make succeeds and packages vecx.pdx; no hardware benchmark yet.
Playdate C build 1 PDU1-Y024621 5.82-5.87 170.06-172.72 ms 2.76-3.03 ms 137-182 ms BIOS only; no cart found. Renderer is cheap, emulator stepping is the bottleneck.
Playdate C build 2 PDU1-Y024621 5.76-5.91 168.86-173.03 ms 2.90-3.03 ms 128-189 ms mine_storm.vec loaded; inverted colors; performance unchanged, so the bottleneck is emulator stepping.
Playdate C build 3 PDU1-Y024621 4.73-4.88 204.44-210.83 ms 2.48-2.66 ms 158-224 ms Instruction-batched analog stepping regressed update time, despite slightly lower render cost. Disable it.
Playdate C build 4 PDU1-Y024621 5.59-5.73 174.13-178.44 ms 4.55-4.86 ms 133-191 ms -O3 plus counters, no batching. About 14k 6809 instructions/update, 50k cycles/update, 500 vectors/render.
Playdate C build 5 PDU1-Y024621 7.12-7.19 138.40-140.11 ms 1.83-2.13 ms 114-157 ms Corrected full instruction batching improved speed; about 14k instructions/update, 180 vectors/render. Visual quality still needs confirmation.
Playdate C build 6 PDU1-Y024621 5.89-5.93 168.13-169.36 ms 1.86-2.10 ms 143-180 ms CPU direct-call/forced-inline experiment regressed badly; larger code likely hurt cache. Revert this direction.
Playdate C build 7 PDU1-Y024621 14.23-14.43 68.95-70.02 ms 1.41-1.55 ms 40-79 ms 60 Hz redraw cadence doubled visible FPS, with about 7k instructions/update and 125-131 vectors/render. Total emulated throughput remains about 360k cycles/sec.
Playdate C build 8 PDU1-Y024621 15.83-16.05 61.88-62.93 ms 0.98-1.10 ms 34-72 ms LTO helped: about 400k emulated cycles/sec, 7k instructions/update, and 124-130 vectors/render. pdex.bin is about 15.8 KB.
Playdate C build 9 PDU1-Y024621 15.90-16.26 61.06-62.65 ms 1.12-1.24 ms 18-78 ms Direct noinline memory calls were neutral/slightly noisy versus build 8. About 400-406k emulated cycles/sec.
Playdate C build 10 PDU1-Y024621 15.07-15.32 64.93-66.07 ms 1.41-1.50 ms 29-77 ms Cheaper condition-code helpers regressed versus build 8/9. Revert this direction.
Playdate C build 11 PDU1-Y024621 12.41-12.61 79.00-80.25 ms 1.12-1.34 ms 31-94 ms CrankBoy-style layout pass regressed badly. About 315k emulated cycles/sec. Revert this direction.
Playdate C build 12 PDU1-Y024621 13.02-13.09 76.01-76.56 ms 1.45-1.56 ms 53-84 ms Fast PC fetch path regressed. About 327k emulated cycles/sec; pdex.bin grew to about 17.3 KB. Revert this direction.
Playdate C build 13 PDU1-Y024621 13.45-13.58 73.13-74.01 ms 1.38-1.50 ms 28-90 ms Opcode profiling shows top opcodes 27, 95, 97, 12, a6; profiler overhead drops runtime versus build 8/9. pdex.bin is 16134 bytes.
Playdate C build 14 PDU1-Y024621 13.30-13.42 74.07-74.89 ms 1.32-1.49 ms 27-87 ms Hot-op profile: BEQ taken about 97.5%; BITA direct only targets $0d; hot STA direct targets $0a, $00, $01; hot LDA indexed postbytes are $c0, $86, $c4.
Playdate C build 15 PDU1-Y024621 15.35-15.55 63.97-64.88 ms 1.49-1.53 ms 31-75 ms Branchful BNE/BEQ plus direct VIA IFR fast path regressed versus build 8/9. pdex.bin is 15880 bytes.
Playdate C build 16 PDU1-Y024621 11.83-12.17 81.90-84.23 ms 1.03-1.25 ms 29-101 ms Branchful BNE/BEQ alone regressed badly. Revert this direction. pdex.bin is 15758 bytes.
Playdate C build 17 PDU1-Y024621 15.90-16.26 61.13-62.60 ms 1.09-1.28 ms 18-78 ms Recovery confirmed: back to build 8/9 baseline. Reverted branch handling to original inst_bra8 helper. pdex.bin is 15833 bytes.
Playdate C build 18 PDU1-Y024621 12.68-12.93 77.03-78.60 ms 1.09-1.27 ms 30-94 ms Direct handling for hot LDA indexed postbytes $c0 and $86 regressed badly. Revert this direction. pdex.bin is 15815 bytes.
Playdate C build 19 PDU1-Y024621 19.25-19.64 50.49-51.58 ms 0.92-1.04 ms 16-67 ms 75 Hz cadence, 20,000 cycles/update. About 390k emulated cycles/sec and 100-104 vectors/render. pdex.bin is 15833 bytes.
Playdate C build 20 PDU1-Y024621 22.16-22.72 43.59-44.78 ms 0.78-0.93 ms 15-63 ms 90 Hz cadence, about 16,666 cycles/update. About 373k-379k emulated cycles/sec and 84-87 vectors/render. pdex.bin is 15833 bytes.
Playdate C build 21 PDU1-Y024621 27.37-27.82 35.43-36.16 ms 0.66-0.80 ms 13-51 ms 120 Hz cadence, 12,500 cycles/update. About 342k-348k emulated cycles/sec and 63-65 vectors/render. pdex.bin is 15834 bytes.
Playdate C build 22 PDU1-Y024621 pending pending pending pending Rotates output 90 degrees counter-clockwise and uses non-uniform integer scaling to fill about 398x239 pixels. D-pad axes are remapped for the rotated view. Pushed 2026-06-01 00:24 CEST; pdex.bin is 15868 bytes.
Playdate C build 23 PDU1-Y024621 22.39-23.03 43.09-44.36 ms 2.10-2.21 ms 14-62 ms Correct rotation and three-frame persistence. Flicker is mostly gone; text remains hard to read. About 250-259 vectors/render. pdex.bin is 16077 bytes.
Playdate C build 24 PDU1-Y024621 21.74-22.51 44.14-45.68 ms 3.18-3.41 ms 14-64 ms 2-pixel vector strokes made readability worse. Render cost rose by about 1.1 ms versus build 23. About 249-260 vectors/render. pdex.bin is 16076 bytes.
Playdate C build 25 PDU1-Y024621 21.79-22.64 43.93-45.59 ms 2.47-2.61 ms 15-63 ms Aspect-preserving 1-pixel rendering. Render cost improved versus build 24, but text remains unreadable. About 249-265 vectors/render. pdex.bin is 16062 bytes.
Playdate C build 26 PDU1-Y024621 25.98-26.34 36.96-37.79 ms 2.41-2.53 ms 1-55 ms VIA IFR wait-loop skip is active. About 2.2k-2.3k instructions/update, 99-104 skips/window, and 568k-598k skipped cycles/window. pdex.bin is 16759 bytes.
Playdate C build 27 PDU1-Y024621 32.59-33.20 28.01-29.02 ms 6.05-6.91 ms 0-51 ms ESPboy-style decoupled cadence reached only 7.35-7.39 render FPS, with about 694-794 vectors/render. Faster updates, but visually strange and no longer shows the expected beam/dotted-line character. pdex.bin is 16784 bytes.
Playdate C build 28 PDU1-Y024621 26.21-26.53 36.67-37.34 ms 2.37-2.51 ms 1-54 ms Render FPS matches update FPS again and dotted lines are visible. About 2.2k-2.3k instructions/update, 99-104 skips/window, and 579k-605k skipped cycles/window. pdex.bin is 16783 bytes.
Playdate C build 29 PDU1-Y024621 pending pending pending pending Test direct $D0xx VIA fast path while preserving build 28 render cadence and visual behavior. Pushed 2026-06-01 04:26 CEST; pdex.bin is 17250 bytes.
Playdate C build 30 PDU1-Y024621 26.78-27.08 35.84-36.56 ms 2.61-2.76 ms 1-53 ms A/B now map to buttons 3/4. Direct $D0xx VIA path is a small speed win versus build 28; dotted-line cadence is preserved. About 2.2k-2.3k instructions/update, 98-107 skips/window, and 587k-610k skipped cycles/window. pdex.bin is 17249 bytes.
Playdate C build 31 PDU1-Y024621 18.14-19.41 50.67-54.50 ms 2.95-3.17 ms 1-79 ms e6809.c -Os is a clear runtime regression despite shrinking pdex.bin to 12811 bytes and e6809_sstep to 0x2164; remove this rule.
Playdate C build 32 PDU1-Y024621 26.21-26.59 36.58-37.28 ms 2.36-2.50 ms 1-54 ms Smaller vector/hash storage is neutral/slightly worse versus build 30, but BSS drops to 105884 bytes. About 2.2k-2.3k instructions/update, 99-104 skips/window, and 580k-605k skipped cycles/window. pdex.bin is 17315 bytes.
Playdate C build 33 PDU1-Y024621 crash crash crash crash DWT cycle-counter profiling build crashes before logs; likely raw debug-register access hard-faults on Playdate firmware. Do not use this profiling path. pdex.bin was 17727 bytes.
Playdate C build 34 PDU1-Y024621 26.17-26.53 36.54-37.27 ms 2.39-2.50 ms 1-54 ms Recovery build removing DWT profiling while keeping build 32's smaller vector/hash storage. Crash is gone; performance matches build 32. About 2.2k-2.3k instructions/update, 100-104 wait skips/window, and 571k-601k skipped cycles/window. pdex.bin is 17316 bytes; BSS is 105884 bytes.
Playdate C build 35 PDU1-Y024621 25.12-25.75 37.87-39.00 ms 2.39-2.52 ms 1-56 ms Sampled profiler costs about 0.8-1.4 FPS versus build 34 but is stable. Samples cluster in BIOS ROM around f4c7-f4ef; hot opcodes are mostly 97, a6, 12, 26, 86/27/5a. About 97-99 wait skips/window and 547k-597k skipped cycles/window. pdex.bin is 18349 bytes; BSS is 107168 bytes.
Playdate C build 36 PDU1-Y024621 24.33-24.94 39.21-40.40 ms 2.37-2.56 ms 1-59 ms BIOS delay skip fires, but regresses real speed. It skips 1366-1410 loops/window and 163k-168k cycles/window, reducing instructions/update to about 1.6k-1.7k, but the guard overhead costs more than it saves. Move the expensive interrupt-limit guard behind a cheap PC check before judging this path. pdex.bin is 18532 bytes; BSS is 107184 bytes.
Playdate C build 37 PDU1-Y024621 25.35-25.96 37.62-38.80 ms 2.37-2.45 ms 1-55 ms Moving the BIOS delay guard behind PC == f4eb improves build 36 but still regresses versus build 34. The skip fires 1426-1463 times/window and skips 170k-174k cycles/window, but wall time remains worse. Disable this small-grain skip and look for a larger BIOS routine or machine-level batch instead. pdex.bin is 18576 bytes; BSS is 107184 bytes.
Playdate C build 38 PDU1-Y024621 26.37-26.73 36.35-37.10 ms 2.43-2.58 ms 1-54 ms Recovery confirmed. BIOS f4eb skip is disabled (delay_skips=0) and the proven VIA IFR wait-loop skip remains active. Performance is back in the build 30/34 band, with 100-105 wait skips/window and 580k-604k skipped cycles/window. pdex.bin is 18203 bytes; BSS is 107184 bytes.
Playdate C build 39 PDU1-Y024621 23.10-23.89 40.65-42.50 ms 0.67-0.72 ms 1-63 ms Direct framebuffer drawing cuts measured render time from about 2.5 ms to about 0.7 ms, but total FPS regresses badly. Likely code/layout/cache effects or framebuffer-write side effects slow the emulator core more than the renderer saves. Revert this path for now. pdex.bin is 18352 bytes; BSS is 107188 bytes.
Playdate C build 40 PDU1-Y024621 26.38-26.75 36.37-37.08 ms 2.47-2.59 ms 1-53 ms Recovery confirmed. Direct framebuffer renderer is disabled and Playdate drawLine() is restored. Performance is back in the build 38 band, with 100-105 wait skips/window and 580k-604k skipped cycles/window. pdex.bin is 18202 bytes; BSS is 107184 bytes.
Playdate C build 41 PDU1-Y024621 25.47-26.50 37.08-38.93 ms 0.84-0.93 ms 1-48 ms Machine-advance batching is a visual regression. Rendered vectors collapse to about 51-58/render instead of ~250, wait skips drop sharply, and lines render incorrectly. Revert to per-instruction machine advancement despite similar FPS. pdex.bin is 18253 bytes; BSS is 107184 bytes.
Playdate C build 42 PDU1-Y024621 pending pending pending pending Reverts VECX_MACHINE_ADVANCE_BATCH to 1, restoring the accurate per-instruction VIA/analog advancement path from build 40. Pushed 2026-06-01 13:16 CEST; pdex.bin is 18202 bytes; BSS is 107184 bytes.
Playdate C build 43 PDU1-Y024621 22.23-23.13 42.39-44.45 ms 3.62-3.73 ms 1-66 ms Cold-splitting 0x10/0x11 page handlers shrinks the binary, but badly regresses real-device speed and render time. Smaller code is not automatically better here; the noinline/cold layout likely hurts cache/branch locality. Revert this direction. pdex.bin was 17563 bytes; text was 31056 bytes.
Playdate C build 44 PDU1-Y024621 24.05-24.86 39.23-40.77 ms 2.38-2.52 ms 1-59 ms Forced-inline page helpers are still a regression versus build 40/42. The source split itself changes optimizer/layout behavior enough to hurt real-device performance. Restore the original in-switch page opcode blocks. pdex.bin was 17983 bytes; text was 33013 bytes.
Playdate C build 45 PDU1-Y024621 26.38-26.73 36.41-37.09 ms 2.50-2.55 ms 1-53 ms Recovery confirmed. Page opcode handlers are restored inside e6809_sstep() and performance is back to the build 40/42 baseline. pdex.bin is 18202 bytes; text is 33317 bytes.
Playdate C build 46 PDU1-Y024621 28.54-29.13 33.13-33.98 ms 2.36-2.57 ms 1-49 ms -falign-functions=32 is a clear win over the 16-byte baseline. No emulator logic changed; FPS improves by about 8-10%, with similar vector counts and render cost. Keep this unless a nearby alignment test beats it. pdex.bin is 18240 bytes; text is 33469 bytes.
Playdate C build 47 PDU1-Y024621 26.87-27.13 35.81-36.42 ms 3.20-3.37 ms 1-55 ms -falign-functions=64 is a regression versus build 46. FPS falls back near the pre-align32 baseline and render cost rises by about 0.7-1.0 ms. Return to build 46's 32-byte function alignment. pdex.bin is 18237 bytes, text is 33725 bytes.
Playdate C build 48 PDU1-Y024621 28.44-29.12 32.98-34.19 ms 2.40-2.59 ms 1-49 ms Recovery confirmed. -falign-functions=32 returns to the build 46 performance band and remains the best baseline. pdex.bin is 18240 bytes, text is 33469 bytes.
Playdate C build 49 PDU1-Y024621 27.92-28.58 33.85-35.01 ms 3.36-3.55 ms 1-51 ms -falign-functions=32 -falign-jumps=32 regresses versus build 48. FPS drops slightly and render time rises by about 0.8-1.1 ms, likely from extra code size/cache pressure. Revert to plain -falign-functions=32. pdex.bin is 18580 bytes, text is 35881 bytes.
Playdate C build 50 PDU1-Y024621 28.43-29.09 33.06-34.06 ms 2.43-2.55 ms 1-50 ms Recovery confirmed. Plain -falign-functions=32 returns to the build 48 performance band and remains the current baseline. pdex.bin is 18240 bytes, text is 33469 bytes.
Playdate C build 51 PDU1-Y024621 27.24-27.84 34.77-35.82 ms 2.47-2.62 ms 1-52 ms -falign-functions=32 -falign-loops=32 regresses versus build 50. Loop alignment adds little code size, but it still worsens update time by about 1.7-2.0 ms. Revert to plain -falign-functions=32. pdex.bin is 18274 bytes, text is 33625 bytes.
Playdate C build 52 PDU1-Y024621 28.54-29.13 33.13-34.04 ms 2.40-2.59 ms 1-50 ms Recovery confirmed. Plain -falign-functions=32 returns to the build 50 performance band. pdex.bin is 18240 bytes, text is 33469 bytes.
Playdate C build 53 PDU1-Y024621 25.15-25.76 37.93-39.08 ms 3.05-3.25 ms 1-57 ms Fast 6809 PC fetch is a clear regression. It saves vecx_read8() calls for opcode/immediate fetches, but the extra inline address tests and larger interpreter hurt cache/layout badly. Revert to the baseline pc_read8() path. pdex.bin is 19621 bytes, text is 34945 bytes.
Playdate C build 54 PDU1-Y024621 28.46-29.08 33.07-34.16 ms 2.43-2.60 ms 1-49 ms Recovery confirmed. Baseline pc_read8() plus plain -falign-functions=32 returns to the build 52 performance band after the PC-fetch regression. pdex.bin is 18240 bytes, text is 33469 bytes.
Playdate C build 55 PDU1-Y024621 30.50-31.10 30.05-31.16 ms 2.43-2.55 ms 0-51 ms Render every second Vectrex frame. This improves emulation update throughput by about 6-8% over build 54 while render FPS drops to about 15.25-15.55. renders is about half of updates, and skipped matches the omitted renders. Useful as an optional speed/visual tradeoff, but not enough to reach native speed by itself. pdex.bin is 18205 bytes, text is 33277 bytes.
Playdate C build 56 PDU1-Y024621 pending pending pending pending Tests deeper render frame skipping: emulate every 120 Hz slice, but draw only every third Vectrex frame. This is a ceiling test for render savings, not a likely final visual cadence. Local build succeeds and was pushed on 2026-06-02 01:04 CEST; pdex.bin is 18204 bytes, text is 33277 bytes. Device copy verified by cmp, with no ._* files found.
Playdate C build 57 PDU1-Y024621 21.58-22.54 43.45-45.76 ms 2.38-2.49 ms 1-66 ms VIA diagnostic build is too intrusive for performance, but the counters are useful: mach_calls/mach_chunks are about 246k-250k/window and shift_steps is about 145k/window across about 42k shift batches. That is enough VIA shift work to justify a targeted batch optimization. pdex.bin is 18054 bytes, text is 33325 bytes.
Playdate C build 58 PDU1-Y024621 30.51-31.16 30.72-31.77 ms 3.29-3.41 ms 1-46 ms VIA shift fast path is a real full-cadence win: update throughput moves from build 54's ~28.5-29.1 FPS to ~30.5-31.2 FPS. Render time rises to ~3.3-3.4 ms, likely from code/layout side effects, but total update time still improves. Keep this if visuals look normal. pdex.bin is 18434 bytes, text is 33733 bytes.
Playdate C build 59 PDU1-Y024621 27.66-28.13 34.37-35.17 ms 3.49-3.71 ms 1-52 ms T2-controlled VIA shift batching is a regression versus build 58. FPS falls by about 2.4-3.5 and render cost rises to about 3.5-3.7 ms. Revert this path and keep only the simpler disabled/CB1 skip plus system-clock shift batching from build 58. pdex.bin was 18670 bytes, text was 34113 bytes.
Playdate C build 60 PDU1-Y024621 30.52-31.15 30.69-31.76 ms 3.28-3.40 ms 1-46 ms Recovery confirmed. Reverting T2 shift batching returns to the build 58 align32shiftfast band with full render cadence. Keep this as the current baseline: disabled/CB1 shift modes return immediately, system-clock shift modes batch directly, and T2-controlled shift modes use the original per-cycle fallback. pdex.bin is 18435 bytes, text is 33733 bytes.
Playdate C build 61 PDU1-Y024621 21.99-22.90 42.68-44.74 ms 2.33-2.53 ms 1-65 ms Wait-PC diagnostic regressed badly, likely from code/layout/cache effects. It still answered the question: every successful IFR wait skip in the test windows is at BIOS PC f19e (BITA <$0d; BEQ f19e). Remove this diagnostic and try a direct f19e recognizer. pdex.bin was 18596 bytes, text was 34136 bytes, BSS was 107248 bytes.
Playdate C build 62 PDU1-Y024621 32.83-33.37 28.35-29.36 ms 2.48-2.58 ms 1-42 ms Direct BIOS f19e wait-loop recognizer is a clear win over build 60. FPS rises by about 2.3, max update time drops to about 41-42 ms, and wait_skip_cycles rises to about 703k-754k/window. This is the new baseline. pdex.bin is 18213 bytes, text is 33445 bytes, BSS is 107184 bytes.
Playdate C build 63 PDU1-Y024621 32.31-32.75 29.02-29.90 ms 2.65-2.79 ms 1-43 ms Hard-coding the f19e IFR poll mask to $20 regressed versus build 62 despite shrinking the binary. Revert this specialization and keep build 62's A-register poll mask path. pdex.bin was 18053 bytes, text was 33253 bytes, BSS was 107184 bytes.
Playdate C build 64 PDU1-Y024621 pending pending pending pending Recovery build returning to build 62's direct BIOS f19e recognizer with the poll mask read from A. Preserves full render cadence, build 60's VIA shift fast path, and removes the rejected build 63 $20 mask specialization. Local build succeeds and was pushed on 2026-06-02 19:28 CEST; pdex.bin is 18214 bytes, text is 33445 bytes, BSS is 107184 bytes. Device copy verified by cmp, with no ._* files found.
Playdate C build 65 PDU1-Y024621 23.62-24.33 40.31-41.68 ms 3.53-3.64 ms 1-61 ms Page-pointer memory model with the fast path INLINED into read8/write8. Clear regression versus build 64 (~33 -> ~24 FPS, update ~29 -> ~41 ms). Behavior is identical (avg_instr_update ~2.2k, avg_cpi ~5.6, wait_skips ~90-97), so it is purely a layout/i-cache loss: text grew +5KB and even render time rose to ~3.6 ms despite the renderer being unchanged. Confirms the builds 6/12/53 inlining failure mode. Revert the inline path.
Playdate C build 66 PDU1-Y024621 28.78-29.38 32.72-33.76 ms 2.77-2.93 ms 1-49 ms Table-driven vecx_read8/vecx_write8, no inlining; text 32776 bytes (below baseline). STILL a regression (~33 -> ~29 FPS) despite smaller code. Confirms the memory branch ladder was never the bottleneck: Mine Storm's accesses are branch-predictable, so the ladder (predicted compares + one arithmetic-address load) beats the table's dependent load chain (load base, load mask, load data) plus 4KB of new table data competing in the 16KB D-cache. Lever 1 (page-table memory model) is a dead end on this hardware; revert it.
Playdate C build 67 PDU1-Y024621 32.54-33.14 28.63-29.61 ms 2.50-2.56 ms 1-42 ms Recovery confirmed. Fully reverted the page-table direction (builds 65/66); only the build label differs from build 64. pdex.bin is 18214 bytes (identical to build 64). Back in the build-62 align32f19ewait band (~33 FPS, ~29 ms, render ~2.5 ms, ~125-130 wait skips / ~720-750k cycles). This is the current baseline.
Playdate C build 68 PDU1-Y024621 12.95-13.98 70.72-76.40 ms 2.43-2.62 ms 0-115 ms DIAGNOSTIC (not shippable). Splits vecx_emu time into CPU stepping vs VIA/analog machine advancement via a high-res getElapsedTime() hook (safe SDK call, not DWT; resetElapsedTime() per update keeps float precision). Behavior unchanged but FPS drops from timer overhead. Emits a second line vecx profile ... avg_emu_us avg_cpu_us avg_mach_us mach_pct. mach_pct is a slight UNDER-estimate (timer overhead is attributed to the CPU side). Purpose: decide whether the next optimization targets the interpreter (lever 2/3) or the machine model. pdex.bin is 19500 bytes. RESULT: raw mach_pct reads ~36%, but that is contaminated because getElapsedTime() costs ~10us/call (4400 calls/update inflated update time from ~29ms to ~73ms). Correcting for one timer-overhead per machine delta gives the real split per ~29ms update: CPU interpreter ~24ms (~80%), VIA/analog ~2.5ms (~9%), render ~2.5ms (~9%). Key number: ~24ms / ~2200 instr = ~11us/instr = ~2000 host cycles per 6809 instruction (~10x a cache-resident interpreter). CPU core is THE bottleneck; machine model and memory dispatch are not.
Playdate C build 75 PDU1-Y024621 crash crash crash crash Isolation diag (ITCM_RELOCATE_SSTEP=0): step1 logged (frame=0x20009b90, pool=0x20002850) then crashed at the memcpy. So the 20KB block does NOT fit the small persistent deep-stack DTCM pool; pool bottom 0x20002850 is below the usable stack. The TCM technique works (build 73) but the pool is only a few KB - the full 20KB interpreter is too big, exactly why the devforum author shrank their core to ~2KB.
Playdate C build 80 PDU1-Y024621 n/a (probe) n/a n/a n/a MAJOR: stack-floor probe (descending single-word writes from frame-0x2180) reached ~29KB down (0x20007a00 -> 0x20000600) with NO FAULT, then the game ran normally. The fast DTCM pool is ~29KB - far bigger than the few-KB I assumed. So the "pool too small" conclusion (builds 75/76) was WRONG: the full 20KB interpreter FITS. Also note: single-word writes work everywhere, but memcpy FAULTED on the same region (builds 74/75/79). The crash was memcpy, not pool size. Use a manual word-copy loop instead. Adding a 150ms itcm_drain() busy-wait after each log fixed the lost-log problem (serial drops buffered output on fault).
Playdate C build 82 PDU1-Y024621 crash crash crash crash Manual-copy of the 20KB DUMMY crashed reading the source ~6KB in (around 0x6000a000). KEY INSIGHT: the dummy is 20KB of EXTRA code on top of the ~33KB baseline, pushing the image past the ~40KB the loader maps, so the dummy's tail isn't resident. The REAL interpreter relocation MOVES e6809_sstep into .itcm (not added), so image size stays ~constant and the source stays mapped. So builds 74/75 reached the copy and crashed there only because memcpy faults; manual copy fixes it.
Playdate C build 115 (+ stack ops) PDU1-Y024621 37.5 ~24.7 ~256 5.56 +13% over baseline. Added JSR/BSR/LBSR/RTS/PSHS/PULS/PSHU/PULU (subroutine + register save = frequent). inst_psh/inst_pul got inlined -> core 4172B (still fits the gap). bit-exact.
Playdate C build 117 (noinline psh/pul) PDU1-Y024621 37.7 ~24.5 ~256 5.55 Marked the bulky inst_psh/inst_pul/inst_exg/inst_tfr noinline (their cost is the write8/read8 calls = PSRAM either way, so inlining them only bloated the relocated core). Core 4660->3388B, FPS held/nudged up (smaller = tighter footprint). Footprint rule: keep cold/bulky helpers out-of-line.
Playdate C build 119 (inline operand READ fast-path) PDU1-Y024621 38.1 ~24.3 ~255 5.55 +15% over baseline. Added hot_read8: inlines cart/rom/ram operand reads in the hot core so dir/idx/ext ALU + RMW reads SKIP the vecx_read8 dispatch CALL (only via/io still dispatch). +0.4 FPS over 37.7. KEY CONTRAST with build 118: same data location, but removing a CALL helps (+0.4) whereas adding a pointer INDIRECTION hurt (-1.7). ram exposed as a plain extern array (NOT a pointer) so the inline is a fixed-address access, no indirection. bit-exact.
Playdate C build 120 (inline operand WRITE fast-path) PDU1-Y024621 38.2 ~24.2 ~255 5.56 hot_write8: inlines the RAM-only write range ($C800-$CFFF, bit11 set/bit12 clear = ram, no io overlap) for STA/RMW/CLR. NEUTRAL (~0 vs 38.1): writes are rarer than reads, and the frequent stack writes (PSH/PUL/JSR) go through push8 not the hot core's direct write8. Kept anyway (correct/symmetric). Lesson: operand-READ inlining is the lever (reads dominate); write inlining is marginal. FINAL: 38.2 FPS = +15% over 33.2 baseline.
Playdate C builds 121-130 (consolidation REGRESSED then RECOVERED) PDU1-Y024621 35.0 -> 38.2 - - - "Cleaning up" the working build (removing the D1-D6 probes from e6809.c + shrinking itcm_relocate) dropped 38.2 -> 35.0 (-8%) with NO change to the relocated DTCM core. NOT thermal (Playdate F746 underclocked, no DVFS, no heat - confirmed by recovering 38.2 on the same warm device). NOT the DTCM core alignment (a runtime 4-byte base-alignment sweep moved it only +-0.5). ROOT CAUSE = whole-binary PSRAM I-cache packing: the relocated core CALLS helpers (ea_indexed, inst_*) that stay in PSRAM/I-cache; whether the hot ones conflict in cache sets is set by the EXACT sizes+order of every function across ALL TUs. Removing the probes (e6809.c) + shrinking itcm_relocate (playdate_main.c) shifted the later object files -> different conflicts -> -8%. RECOVERED by reproducing build 120's exact binary (re-add probes + restore the full itcm_relocate + 16-align): 38.2 back, pool address 0x20007bf0 identical. The probes + full itcm_relocate are now LOAD-BEARING layout (documented in code); re-measure after any edit to those TUs. Robust alt (not done): relocate the helpers into TCM too to kill the I-cache dependency. See PLAYDATE_ITCM_GUIDE.md.
Playdate C build 118 (DATA-side: ram->DTCM = REGRESSED) PDU1-Y024621 36.0 ~26.1 ~256 5.57 NEGATIVE RESULT (-4.5% from 37.7). Relocated the 1KB 6809 ram into a fast DTCM buffer (below the code pool). It got SLOWER. Why: (1) the 1KB ram was ALREADY D-cache-resident (D-hit ~= DTCM speed) so DTCM bought ~0; (2) a runtime-placed DTCM buffer forces ram to be a POINTER, adding a load-the-pointer indirection on EVERY ram access (LDA/STA $C8xx + the 6809 stack under PSH/PUL/JSR/RTS), which cost more than the zero cache benefit. LESSON for future emulators: relocating CODE helps (working set > 16KB I-cache) but relocating hot DATA that already fits the 16KB D-cache does NOT - the indirection outweighs the (nonexistent) cache win. Reverted. The 37.7 (build 117) ceiling stands; remaining cost is diffuse (cart/rom fetch working set + compute), not addressable by code or data relocation here.
Playdate C build 116 (+ LEA/misc = PLATEAU) PDU1-Y024621 37.5 ~24.7 ~256 5.56 PLATEAU: LEA + ABX/MUL/SEX/EXG/TFR/ORCC/ANDCC added ZERO FPS. These opcodes are COLD in Mine Storm, so relocating them to TCM is correctness-only, not speed. CONCLUSION: the "more hot opcodes in TCM" lever is TAPPED at ~37.5 (+13%); the hot path (8-bit ALU/branches/stack) was already fully covered by build 115. Footprint lesson: GCC inlined the big inst_exg/inst_tfr (+2.4KB) and the core blew past the floor (6636B, bottom 0x20007010 < 0x200074d0); marked them noinline to keep cold/bulky helpers out-of-line (4660B fits). REMAINING BOTTLENECK is now DATA-side, not code: cart/rom/ram/registers all live in .bss = PSRAM (0x60000000), accessed through the 16KB D-cache. The +13% came from removing the hot-code I-cache miss (now in TCM); the rest is diffuse data-cache + compute that code relocation cannot touch. Next lever would be DATA relocation (hot ram/regs -> DTCM), uncertain since 1KB ram likely already D-cache-resident.
Playdate C build 114 (FULL-ish core, ceiling SOLVED) PDU1-Y024621 36.5 ~25.6 ~255 5.56 +10% over baseline. 8-bit ALU all 4 modes (imm/dir/idx/ext) + STA(dir/idx/ext) + 0x00-0x7f RMW group, relocated to DTCM. 1756 bytes. bit-exact. The "size ceiling" was SOLVED (see below) = pool placement, so ext+RMW could be added (they were never the problem). Pool placed in the firmware-floor..stack-ceiling gap via POOL_MARGIN 0x1180.
Playdate C builds 111-113 (SIZE CEILING -> SOLVED: pool placement) PDU1-Y024621 n/a probe - - - The "~1.4KB size ceiling" was NOT a size limit. Build 111: 300B of INERT .space padding (no code) crashed too -> not the RMW/ext code, purely footprint. Build 112: the EXACT working 1328B function placed ~0x180 LOWER also crashed -> not size, it's the LOWEST ADDRESS the pool reaches. Root cause: pool = [frame-margin-size, frame-margin]; original margin 0x2180 pins the TOP at frame-0x2180 so a bigger function grows DOWNWARD, and below ~0x200074d0 it overwrites LIVE firmware/SDK data resident in DTCM (copy succeeds, entry/probes run, but faults LATER when that data is used). Build 113: raising the top to frame-0x1180 (0x20008a00) with the 1328B fn ran clean -> the real safe GAP is [firmware floor ~0x200074d0, stack ceiling >0x20008a00] = ~5+KB; 0x2180 was just very conservative. FIX: shrink margin so the pool TOP rises into the gap and BOTTOM stays >= ~0x200074d0. Reusable method: padding test (separate code from footprint) + shift test (separate size from address).
Playdate C build 108/110 (COMPACT CORE IN TCM - first win) PDU1-Y024621 35.5 ~26.7 ~252 5.6 First build to BEAT baseline (35.5 vs 33.2, +7%). Compact decoded 6809 core (imm/dir/idx 8-bit ALU + STA dir/idx) relocated to DTCM; rarer ops fall back. bit-exact. Superseded by build 114 once the pool-placement ceiling was solved (RMW+ext added).
Playdate C build 98-109 (compact rewrite dev + RELOCATED-SIZE CEILING) PDU1-Y024621 see notes - - - FULL COMPACT REWRITE. e6809_hotcore grown into a decoded interpreter (0x80-0xff 8-bit ALU + 0x00-0x7f RMW + branches), relocated to TCM, e6809_sstep fallback. Builds 98/99 IN PLACE: bit-exact (instr/cpi/vectors match baseline). Builds 100-104 RELOCATED: crashed mid-game despite D-probes proving entry-exec + EVERY helper call works from relocated code (vecx_read8, ea_indexed, write8, inst_sub8, read8/write8 of VIA all return correct), and objdump proving the .itcm block is structurally PERFECT (all relocs R_ARM_ABS32 in .rel.text, NO escaping branches, no clones, no pc-rel refs leaving the block). Bisected by selectively falling back addressing modes: imm(105)=29 OK, +dir(106)=28 OK, +STA-dir(107)=33.4 OK, +idx(108)=35.5 OK. First looked like "extended addressing is cursed" (every crashing build had ext active) BUT build 109 (RMW on, ext guarded OFF, 1592B) ALSO crashes -> the real variable is RELOCATED FUNCTION SIZE: works <=1328B (108), faults >=1592B (109). A ~1.4KB ceiling on the relocated e6809_hotcore. Mechanism UNKNOWN/open: not relocation (probes+objdump clean), not XN (entry execs, build-80 single-word writes reach 29KB down), not stack (pool top = frame-0x2180 constant for all sizes; bigger funcs extend LOWER = further from stack), not clones. Hypotheses to test: literal-pool/veneer placement past some offset, tbb/tbh table reach, or DTCM exec quirk for larger spans. WORKAROUND: keep e6809_hotcore <=~1.3KB. See PLAYDATE_ITCM_GUIDE.md.
Playdate C builds 90-97 PDU1-Y024621 see notes see notes see notes see notes Hot-core-in-TCM payoff test + the breakthrough. Build 90 = clean baseline (33 FPS). Build 91 = compact hot-opcode core (NOP/branches/immediate-ALU, bit-exact copies with INLINED cart/rom fetch, cold ops fall back to e6809_sstep) running IN PLACE: ~29 FPS (-12%, the double-dispatch overhead for cold ops in slow memory; matching instr/CPI confirmed bit-exact). Builds 92-96 = relocate the hot core to the fast pool; crashed EXECUTING it. Isolation probes (self-contained entry / global-read / call, keyed by magic irq_i) found: entry executes (NOT XN, ruling out the address), but reading a global faults. ROOT CAUSE: a separate .itcm OUTPUT section's relocations go in .rel.itcm which the Playdate loader does NOT apply, so the hot core's absolute address literals (&reg_a, &e6809_sstep) keep link-time garbage. Build 97 FIX: collect .itcm INPUT inside the .text OUTPUT section (bracketed by __itcm_start/end) so relocations land in .rel.text and ARE applied -> globals + calls now resolve from the relocated copy. Relocation works end-to-end. (full writeup in PLAYDATE_ITCM_GUIDE.md). RESULT build 97 (relocated hot core): ~30.2-30.8 FPS, render dropped to ~2.1. vs baseline 33.2 and in-place 29.1. So fast-memory execution gave a REAL +1.4 FPS (29.1->30.5) but the hot-core+fallback architecture's cold-op overhead (~65% of ops pay hotcore dispatch + e6809_sstep re-fetch, two calls vs baseline's one) keeps it 2.7 below baseline. PAYOFF VERDICT: TCM code relocation helps but is masked here by the fallback double-handling. A FULL compact ~4KB interpreter in TCM (no fallback, every op handled once in fast memory) would avoid that overhead and could beat baseline, but it is a large rewrite bounded by the irreducible data-side cost (cart/rom/ram fetches stay in slow RAM). The hot-core test under-estimates the full-rewrite benefit; it does not cleanly prove a big win. Decision point: commit to the full compact rewrite (uncertain ~10-20% upside, big effort) or stop here.
Playdate C builds 83-89 PDU1-Y024621 n/a (diag) n/a n/a n/a Real-interpreter TCM relocation debugging. Findings (see PLAYDATE_ITCM_GUIDE.md): memcpy into the DTCM pool hard-faults -> use a manual word-copy with volatile dst; volatile (uncached) source reads from 0x60000000 fault after a burst -> read cacheable (const, non-volatile) src; MOVE the interpreter into .itcm (not add) so the image stays mapped; logToConsole output is lost on fault -> add a 150ms busy-wait drain after each log. KEY LIMIT (build 89): the DTCM pool (unused game stack) is full of 64-byte-granular firmware holes (e.g. 0x20003cc0, 0x20006980); the largest CONTIGUOUS hole-free pool from frame-0x2180 down is only ~4.2KB. So neither the -O3 (20KB) nor the -Os (8.9KB) interpreter fits a single block. The core must be rewritten compact to <=~4KB (the devforum author's ~2KB core).
Playdate C build 83 PDU1-Y024621 n/a (diag) n/a n/a n/a THE REAL ATTEMPT: e6809_sstep moved into .itcm (VECX_ITCM), e6809.c -mlong-calls -fno-lto (calls absolute, verified no direct branch leaves .itcm), relocated to the fast pool via MANUAL word-copy, dispatched through e6809_sstep_p. Image 34152 (~baseline, so .itcm source mapped), .itcm 20.4KB, pool ~0x20002868 (in the 29KB writable region). Watch correctness (Mine Storm) + FPS vs 33 baseline.
Playdate C build 81 PDU1-Y024621 crash crash crash crash Relocate the 20KB dummy via MANUAL word-copy (not memcpy); crashed reading source ~6KB in (see build 82 - dummy exceeds mapped image).
Playdate C build 78 PDU1-Y024621 crash crash crash crash TCM foundation probe (confound-free): relocate a self-contained 20.1KB dummy fn (exceeds the 16KB I-cache, interpreter-sized) via the proven build-73 mechanism (no e6809 changes, no -mlong-calls). Verifies output orig==reloc and times orig(PSRAM) vs reloc(SRAM). Decisive: if the 20KB memcpy succeeds + reloc is faster, the pool DOES hold interpreter-sized code (build 75's crash was e6809-specific) and fast memory helps >I-cache code -> relocate the full interpreter, no shrink needed. If memcpy crashes, pool is genuinely too small -> must shrink the core.
Playdate C build 77 PDU1-Y024621 recovery recovery recovery recovery Full revert of the TCM experiment (builds 73-76): removed the .itcm machinery, local link_map.ld, e6809_sstep_p indirection, and the e6809.o -Os/-mlong-calls rule. text 33441, pdex.bin 18212 = baseline ~33 FPS.
Playdate C build 76 PDU1-Y024621 crash crash crash crash -Os shrank the relocated block to 8.9KB (pool 0x200056a0) but it STILL crashed at/around the memcpy. Combined with build 75 (20KB, pool 0x20002850 faulted), this confirms the persistent deep-stack DTCM pool is only a few KB - too small even for the -Os interpreter. TCM Stage 2 retry with -Os: e6809.c at -Os -mlong-calls -fno-lto shrinks the relocated e6809_sstep to 8.9KB (.itcm) so it fits the small pool (now at 0x200056a0, ~12KB above the build-75 fault). -Os's worse codegen is harmless from fast single-cycle SRAM. ITCM_RELOCATE_SSTEP=1. Logs distinguish outcomes: step1- too big; step1-5+label+no bench=execution fault; step1-5+bench=WORKS (measure FPS vs 33).
Playdate C build 74 PDU1-Y024621 crash crash crash crash TCM Stage 2 (20KB, -O3): crashed at memcpy - block too big for pool (see build 75 isolation). TCM Stage 2: relocate the 19KB e6809_sstep switch (the dominant I-cache-miss source) into the fast SRAM pool. e6809.c compiled non-LTO + -mlong-calls so all its calls become absolute and correctly reach the original (slow) helpers after relocation; verified by disassembly that NO direct branch leaves the .itcm cluster. vecx_emu calls via e6809_sstep_p, repointed to the relocated copy. .itcm = 20.4KB, pool ~0x20002840 (above safety floor). Watch correctness + FPS vs build 73's 29.5 (same linker layout) and the 33 baseline.
Playdate C build 73 PDU1-Y024621 28.97-29.70 32.36-33.54 ms 2.40-2.61 ms 1-48 ms TCM Stage 1 SUCCESS: runtime code relocation works on SDK 3.0.6. All steps logged, no crash, relocated selftest(5)=22 OK and persists across frames. Addresses confirm the model: code runs from 0x60000000 (slow external PSRAM = the ~265-cyc miss source), pool is at 0x20000000 (fast internal SRAM/DTCM). NOTE: FPS dropped to ~29.5 (from 33) purely from the local linker-script layout shift (the "lottery"), even though only a trivial fn was relocated - this is the comparison baseline for Stage 2, a confound not a real cost. TCM Stage 1 (mechanism validation, from the devforum "dirty optimization secrets" thread). Adds a local link_map.ld with an .itcm section (__itcm_start/__itcm_end), relocates a trivial _itcm function into a persistent DTCM-backed stack pool at frame-0x2180, clearICache(), and calls the relocated copy. Logs step1-4 + a persist recheck. Question answered: does runtime code relocation run on SDK 3.0.6 without hard-faulting (thread warns it "will likely crash")? Verified locally: .itcm section populated, itcm_selftest placed at 0x83c0. NOT a speed build.
Playdate C build 72 PDU1-Y024621 recovery recovery recovery recovery Set E6809_HOT_CORE to 0 to revert build 71. text 33445, pdex.bin 18215 = baseline. Back to ~33 FPS.
Playdate C build 71 PDU1-Y024621 25.95-26.35 37.07-37.95 ms 2.54-2.68 ms 1-54 ms Hot-opcode core REGRESSED ~21% (33 -> 26 FPS) despite shrinking total code 6.8KB and being functionally correct (game looks/plays right). 7th interpreter-restructuring attempt to regress (after 15/16/18/31/43/44). Confirms: smaller/restructured interpreter code reliably regresses on this compiler+hardware; GCC's monolithic -O3 switch layout is a fragile local optimum that any perturbation makes worse for I-cache/branch behaviour. Reverted in build 72. First raw-speed attempt from the build-70 finding: a compact hot-opcode core (E6809_HOT_CORE) handling NOP, BRA/BRN, BNE/BEQ, LDA imm/dir, BITA dir, STA dir in a prelude that returns before the ~19KB main switch is fetched, keeping the common path I-cache-resident. Handlers are byte-exact copies of their main-switch cases. Removed the build-70 microbench. SIZE: text dropped 33437 -> 26652 (-6.8KB; flag-off confirmed = baseline), as the optimizer DCE's the now-unreachable hot cases from the main switch and reorganizes. Watch BOTH correctness (Mine Storm must look/play right) and FPS vs the ~33 baseline. Set E6809_HOT_CORE to 0 to revert.
Playdate C build 70 PDU1-Y024621 n/a (diag) n/a n/a n/a RESULT: alu=18 ns/op (core healthy, full-clock, ~3 cyc), hot4k=57 ns/op (D-cache hit cheap), cold256k=1474 ns/op (~265 cyc per cache MISS to external PSRAM). The wall is now quantified: a miss costs ~265 cyc, and ~2000 cyc/instr / 265 = ~7-8 misses per emulated instruction. The interpreter is 100% memory-latency-bound (19KB switch does not fit 16KB I-cache + scattered data on slow PSRAM). Lever: keep the hot ~90% of opcode handlers in a compact cache-resident core to avoid ~265-cyc I-cache misses per hot instruction. CAUTION: 6 prior interpreter-restructuring attempts (builds 15/16/18/31/43/44) all regressed - layout is a performance lottery here. DIAGNOSTIC (not shippable). One-shot device microbenchmark logged on the first frame (vecx ubench ...): raw ALU throughput (no memory), cache-hot random reads (4KB working set), and cache-cold random reads (256KB, exceeds 16KB D-cache). Quantifies the memory wall to aim the raw-speed effort. Rationale: at ~2000 host cyc/instr, dispatch/branch/flag levers are <1% each and thus futile (matches the builds 15/16/18 regressions); ESPboy running this on a slower ESP8266 implies a memory-system pathology worth measuring.
Playdate C build 69 PDU1-Y024621 33.04-33.48 28.27-29.15 ms 2.46-2.58 ms 1-42 ms Confirmed neutral (= baseline, maybe a hair at the top of the band). Removed the build-68 profiler and compiled only e6809.c at -O2 (Makefile per-file $(OBJDIR)/e6809.o: OPT). LOCAL FINDING: -O2 does NOT shrink the interpreter (e6809_sstep 19066 B vs -O3's 19032 B); only -Os shrinks it (8004 B, would fit the 16KB I-cache) but -Os already regressed in build 31. So this build should be ~baseline; flashing is an empirical confirmation. Crucially, -Os fitting in I-cache yet regressing partially falsifies the pure-I-cache theory: the ~2000 cyc/instr is diffuse (D-cache on data + indirect-branch mispredict + compute), not one flag-fixable cause. pdex.bin is 17664 bytes.

Next Steps

  • Build 62 is the current baseline. Treat T2 shift batching, wait-PC diagnostics, and hard-coded $20 mask specialization as rejected for runtime builds.
  • Test build 64 to confirm it recovers build 62's align32f19ewait performance band around 32.8-33.4 FPS.
  • Full-speed emulation target is still far away: build 34 gets through roughly 327k-333k emulated cycles/sec of 1.5MHz Vectrex time, so we still need about a 4.5x gain.
  • Lever 1 (page-pointer memory model) was implemented and tested in builds 65 (inlined) and 66 (table-driven, no inline). BOTH regressed: build 65 to ~24 FPS (i-cache, +5KB text), build 66 to ~29 FPS despite shrinking code below baseline. Conclusion: the memory branch ladder is NOT the bottleneck. Mine Storm's memory accesses are branch-predictable, so the existing ladder beats any table (dependent load chain + D-cache pressure from the 4KB tables). Lever 1 is rejected; reverted in build 67.
  • Re-orientation: since memory dispatch is not the hotspot, the cost must be in the interpreter dispatch/flags (lever 2) and/or the per-instruction VIA+analog machine advancement. We still lack a clean profile breakdown (build 33 DWT crashed; build 35 sampling added overhead). The next useful step is a diagnostic A/B that isolates CPU-step time from machine-advance time (e.g. a non-shippable build that neuters vecx_machine_advance to reveal its share), so the next real optimization targets the actual hotspot instead of another guess.
  • Build 68 diagnostic answered it: the CPU interpreter is ~80% of emulation time at ~2000 host cycles per 6809 instruction. The VIA/analog machine model (~9%) and render (~9%) are not worth chasing. Memory dispatch is already proven a dead end (builds 65/66).
  • Build 69 closed the compiler-flag lever: -O2 does not shrink the interpreter (only -Os does, and -Os regressed in build 31). Because -Os fits the interpreter in I-cache yet still regressed, the ~2000 cyc/instr is NOT purely I-cache misses; it is diffuse (D-cache on emulator data + indirect-branch misprediction through the giant switch + raw per-instruction work). There is no cheap flag fix.
  • State as of build 69: the port is in the ~33 FPS baseline band and the cheap levers are exhausted. Reaching native (1.5MHz, ~3x faster) would require a substantial interpreter rewrite (a small hot-opcode core, or a computed-goto/threaded interpreter, or partial dynarec) with uncertain returns on this Cortex-M7, OR accept "feels native" via the proven display/cadence cheats (frameskip, render decoupling, lower update cadence). This is a strategic fork, not another quick experiment.
  • Builds 70/71 resolved the raw-speed question definitively. Build 70 microbench: a cache miss to external PSRAM costs ~265 cyc, and the interpreter takes ~2000 cyc/instr = ~7-8 misses/instr; it is 100% memory-latency-bound. Build 71 (hot-opcode core) was the mechanistically-correct fix (keep hot handlers I-cache-resident) and still regressed ~21%, making it the 7th straight interpreter-restructuring regression.
  • TCM relocation investigation (builds 73-77, from the devforum "dirty optimization secrets" thread): the runtime code-relocation mechanism WORKS on SDK 3.0.6 (build 73 relocated a trivial fn into fast 0x20000000 SRAM and it ran + persisted). Code runs from slow 0x60000000 PSRAM; the fast pool is the DTCM-backed stack. BUT the persistent deep-stack pool is only a few KB - the full interpreter does not fit: 20KB (build 75) and even the -Os 8.9KB (build 76) both crashed at the memcpy. The thread's author had to shrink their emulator core to ~2KB to fit; our 6809 interpreter can't shrink that far without a major rewrite. Remaining TCM option: a TINY (~2-4KB) hot-opcode core in fast memory + slow fallback for cold opcodes (build-71 hot-core idea but in fast memory where it isn't subject to the cache lottery). Not yet attempted; speculative and more work. Reverted to baseline in build 77.
  • CONCLUSION (raw speed): C-level interpreter restructuring does not work on this compiler+hardware - GCC's monolithic -O3 switch is a fragile local optimum and every perturbation (smaller code included) regresses the cache/branch lottery. The wall is hardware: slow PSRAM (~265 cyc/miss), 16KB caches that the working set overflows, and no TCM placement via the standard SDK link map (CrankBoy reaches fast memory via firmware-specific tricks not exposed here). Remaining raw-speed levers are essentially: (a) more algorithmic idle-loop skip/batch wins like the f19e wait-skip (game-specific, the big ones are already done; f4eb delay-skip was tried builds 36/37 and regressed), or (b) a from-scratch dynarec (huge, and translated code still runs from PSRAM unless TCM-placed). Practical recommendation: treat ~33 FPS as the raw-speed ceiling for this architecture and pursue "feels native" via display/cadence instead.

CrankBoy/Playdate Optimization Notes

  • CrankBoy reaches full-speed Game Boy emulation through a heavily modified Peanut-GB core plus Playdate-specific memory/layout work, not through a stock portable emulator core.
  • Relevant techniques to evaluate for vecx:
    • custom linker map with 32-byte and 0x1000 alignment fences to reduce cache/branch-predictor performance lottery,
    • split hot/cold code into explicit sections such as core, main, framebuffer, and rare paths,
    • TCM relocation/allocation for very hot core code or small hot state,
    • fast-path memory access using direct region/page pointers with fallback only for I/O/special cases,
    • specialized opcode/immediate fetch paths that bypass full memory-map decoding for ROM/cart fetches,
    • direct framebuffer writes and dirty-row marking instead of high-level draw calls when rendering becomes relevant,
    • dynamic frame pacing/interlace/frame-skip style options that keep emulation speed separate from display refresh where possible.
  • The most promising vecx transfer is a small Playdate-specific 6809 memory/fetch layer plus linker/section placement. Rendering tricks are lower priority because current render time is only about 1-1.5 ms.

Native-Speed Performance Assessment

Goal

Reach native Vectrex emulation speed: 1,500,000 emulated 6809 cycles/sec, displayed at the Vectrex's ~50 Hz refresh with correct vector/beam character.

Where the port actually is

  • Baseline (build 62): about 410k emulated cycles/sec, and the wait-loop skip already "cheats" about 120k of those.
  • That is roughly 27% of native, a 3.5-4.5x gap (consistent with the Next Steps section above).
  • Builds 8-64 show that micro-tuning has plateaued: the last ~20 builds mostly oscillate around 26-33 FPS through cadence tradeoffs and small fast-paths, with frequent regressions.

The hardware reality that frames everything

The Playdate is a 180 MHz Cortex-M7 (STM32F746), and the .pdx runs from external RAM through a 16 KB instruction cache. e6809_sstep alone is about 20 KB (see build 31), so the hot interpreter does not fit in I-cache.

This single fact explains the whole build history:

  • -Os shrank the binary but ran slower (worse codegen on an already cache-bound loop).
  • -falign-functions=32 was a real 8-10% win (instruction-cache line behavior).
  • Per-opcode fast paths (builds 12, 16, 18, 53) regressed: they added hot code, evicting more from the cache than they saved.

This is no longer a tuning problem. The remaining wins are structural.

Honest verdict

  • True, continuous 1.5 MHz emulated throughput: unlikely with a portable switch-interpreter on this hardware. The budget is about 120 host cycles per Vectrex cycle; the port currently spends about 440.
  • "Feels native" (stable 50 Hz display, correct beam character, smooth Mine Storm): achievable, using structural changes plus the frameskip / wait-skip cheats already in place.
  • Realistic landing zone with the plan below: about 60-90% of native throughput, which combined with render decoupling is enough to look right.

The three structural levers (in ROI order)

  1. Replace the memory dispatch with a page-pointer table - biggest single win. Every byte fetch currently goes pc_read8 -> read8 -> vecx_read8 (vecx.c:549), and vecx_read8 / vecx_write8 are VECX_NOINLINE with a 5-way address branch ladder. A typical instruction does 2-4 of these, a non-inlined call plus serial branch decode per byte. The fix is the canonical interpreter memory model: a 256-entry pointer table indexed by addr >> 8. ROM/cart/RAM pages hold a direct base pointer (inlinable single load); only $Cxxx / $Dxxx I/O pages hold a sentinel that falls back to the slow VIA path. This replaces the branch ladder rather than adding to it, so net hot-code size drops, which is the key lesson from the failed builds 12/53. Those regressed because they bolted extra if tests inside the giant switch; a table keeps the switch bodies untouched and shrinks the hot path.

  2. Audit the condition-code path. get_cc does (reg_cc / flag) & 1, an integer divide (e6809.c:84), and set_cc does value * flag (e6809.c:93). These are on the hottest path (every BEQ / BITA tests flags, and those are ~40% of executed instructions per build 13). When inlined with a compile-time-constant flag, the compiler should strip these to shifts/masks, but given how hot they are this is worth confirming in the disassembly. A surviving divide here would be very expensive.

  3. Shrink the hot working set to fit I-cache. The conceptually-correct version of what builds 11/31/43 attempted: identify the ~30 opcodes that are 90% of execution (plus the machine-advance path), get that set to fit in 16 KB, and push genuinely-cold opcodes (page-2/3, rare ALU forms) out-of-line. Past splits regressed because they were mechanical (-Os, blanket section attributes). This needs to be data-driven from the build-35 PC-sample data and measured per step.

What to explicitly stop doing

Per-opcode fast paths inside the switch, alignment roulette, and cadence tradeoffs are exhausted. The Vectrex-specific analog stepping (vecx.c:994, alg_sstep) is irreducible per-cycle work and is already batched well, so leave it alone.

Recommended next step

Implement lever 1 (the page-pointer memory model) as an isolated change against the build-62 baseline and benchmark it as build 65. It is the highest ROI, it reduces hot-code size (working with the cache constraint instead of against it), and it is the one structural lever the build history has not genuinely tried.

Rendering / legible text (2026-06-06)

After the perf work, focus shifted to making small Vectrex text (e.g. the boot screen, menus) legible on the 400x240 1-bit display. It looked like a rendering problem; it was not.

The font bug was VECX_FAST_BATCH, not the renderer

Symptom: text rendered as illegible dashes/sparse ticks at any size. Every renderer lever was tried and ALL failed to change it: orientation/transpose, 2x supersampling, persistence accumulation (3->10 frames), render-rate, crank zoom. That this was unaffected by 2x supersampling proved the missing detail was not in the pixels but in the vector list - the emulation was producing the wrong strokes.

Root cause: vecx.c #define VECX_FAST_BATCH 1 used alg_sstep_batch / via_sstep0_batch to step the analog beam + VIA timers over many cycles at once. The VIA T1 timer sets the beam-ramp duration (= stroke length); batching approximates its mid-batch expiry, so stroke lengths come out wrong and the BIOS vector font is mangled. Fix: VECX_FAST_BATCH 0 (per-cycle alg_sstep/via_sstep0) -> correct strokes, legible text. VECX_WAIT_LOOP_SKIP stays 1 (it skips only the idle wait, never touches the beam, and is the big speed lever). Cost: per-cycle stepping lowers throughput vs the 38.2 layout, but the user prioritized correct/legible rendering and smoothness over the FPS ceiling.

Methodology lesson: when every rendering change leaves the output identical, stop touching the renderer - the defect is upstream in the data. The user's "it's a float/approximation problem, not resolution" instinct pointed straight at the batched integration.

Render decoupled from the update rate (complete frames)

vecx_finish_vector_frame renders + resets the vector list every FCYCLES_INIT = VECTREX_MHZ / VECTREX_PDECAY cycles. With PDECAY = RENDER_HZ = UPDATE_HZ = 120 that fired every 12500 cycles - mid-redraw, since a full Vectrex screen is ~30000 cycles - so each frame held only ~42% of the screen's strokes (matched the measured ~140 of ~330 vectors). Persistence could not recover it (the wait-skip resyncs the same partial slice each frame). Fix: decouple VECTREX_RENDER_HZ = 50 (keep UPDATE_HZ = 120) so one full ~30000-cycle redraw accumulates per render = complete frames. Side benefit: rendering less often left more time for emulation (~37 -> ~41 updates/sec).

Renderer (render.c, linked last = FPS-safe per the Makefile note)

  • Direct mapping (no transpose, no flips) = upright, matches a real Vectrex and the ESPboy port. Getting here took a flip saga; the key realization was that "180 degree rotation" = mirror X + flip Y, so the correct upright view is plain x=vx, y=vy.
  • System-menu Rotation item: -90 / 0 / 90 (0 default) for left/upright/right; rotated modes transpose the content.
  • FIT scaling: fill the limiting screen axis keeping the Vectrex aspect ratio, bars on the other axis, no cropping.
  • Removed after they didn't pan out: crank zoom, 2x supersampling, the direct-framebuffer path, and the vector-length diagnostic. Persistence kept but #if-gated off (it doubles moving objects at the 50Hz render rate).

Recovering the FAST_BATCH cost without breaking the font (perf phase)

Going per-cycle for a correct font cost ~22% (32 FPS vs the broken-font batched ~41). Recovered most of it by re-enabling VECX_FAST_BATCH 1 and capping each batch in vecx_machine_advance at the next beam-parameter change (so the batched beam never spans a change it samples only once):

  1. Timer-1 PB7 toggle (flips the beam ramp = stroke length): cap step at (via_t1c & 0xffff) when via_t1on && (via_acr & 0x80) && ((via_acr & 0x40) || via_t1int). The T1 period is a whole stroke, far longer than an instruction, so this almost never shortens a batch.
  2. Shift-register CB2 blank (beam on/off): step = 1 while (via_acr & 0x10) && via_srb < 8. The SR is idle (batchable) most of the time; only the beam-on pulses pay per-cycle.

Result: 36 FPS with a correct, complete font (avg_vectors ~465, same as the per-cycle build; the broken T1-cap-only build was 154). A further attempt to batch inside the SR window by predicting the next CB2 flip from the SR bit pattern regressed to 32 — Mine Storm's blank flips too often, so the prediction loop costs more than the batched cycles buy. Reverted; step = 1 is optimal. The remaining 36-vs-41 gap is irreducible: the beam-on pulses are genuinely per-cycle. Net for the perf phase: 32 -> 36 FPS (+12.5%) with the font intact. Also added a runtime Frameskip menu (0/1/2) for render-side relief.

CPU side: profiled, and it's the wall (36 FPS is the ceiling)

PC-sample profiler (VECX_SAMPLE_PROFILE) on Mine Storm gameplay: a light/idle regime (47-50 FPS, hot at the f4eb BIOS delay loop) and a heavy game-logic loop at f33d-f347 / f425-f427 (30-32 FPS = the floor) running opcodes 27/d5/97/26/d7/a6 — all already covered by the TCM hot core. Per-instruction cost is ~1800 host-cyc in both regimes; phases differ only in instruction count, so the heavy phase is irreducible real work.

Two CPU levers tried, both lose:

  • VECX_BIOS_DELAY_SKIP=1 (skip the hot f4eb loop): regressed (heavy 30-32→28, normal 36→34.5). The per-instruction pc==0xf4eb probe (a non-inlined e6809_get_pc call every instruction) costs more than the skip saves — the wait-skip only survives because its skipped region is huge.
  • Force-inlining the hot per-instruction helpers (hot_pc_read8/read8/write8/ea_*/bra8 via always_inline): neutral-to-slightly-negative. objdump shows the core does ~84 blx into them, but they're small + I-cache-resident (cheap), and growing e6809.c (.itcm 3580→3944) reshuffled the fragile whole-binary I-cache packing.

Conclusion: the DTCM hot core is already optimal; 36 FPS (correct font) is the architectural ceiling, and the remaining ~3.5× to native is diffuse compute + D-cache that only a from-scratch dynarec could touch (still PSRAM-bound). Don't re-chase delay-skip or helper-inlining.

JIT (dynamic recompiler) — foundation built, in jit.c (linked LAST, FPS-safe)

Decision: attempt a 6809→ARM JIT (the only lever with real headroom), built in milestones with a go/no-go gate. jit.c is its own TU linked last so it can't perturb the hot-core layout; a jit_selftest(pd) runs once in eventInit.

  • M1 DONE — runtime codegen works. Emitted movs r0,#0x42; bx lr into a static uint16_t buffer, clearICache(), called it via (ptr|1) (Thumb bit) → returned 0x42. The device executes code we generate at runtime. NOTE: the buffer landed in PSRAM (0x60000000), so JIT'd code is I-cache-bound just like the interpreter; for M3 to actually beat the DTCM hot core it likely needs the code buffer in DTCM (but the DTCM relocation pool is only ~2KB free) — an open question that decides the whole effort.
  • M2 DONE — Thumb emitter + load/modify/store. Minimal emitter (emit_movs_imm8/ldrb/strb/adds_imm3/bx_lr, low regs only); emitted (*p)++ and verified (0x10→0x12). These primitives are the shape every 6809 register+memory op compiles to.
  • M3 DONE — translator + go/no-go. A 6809 kernel (loads/stores/AND, N/Z flags) translated to a native block, verified byte-exact vs a switch interpreter, then timed: 1.86× (177ms→95ms, both PSRAM). Real but optimistic (light kernel, naive reference); real ops + DTCM placement bring it down. User chose to commit to the full JIT.
  • M4 DONE — full-flag ALU codegen (the biggest risk eliminated). ADDB #imm with H/N/Z/V/C by branchless bit-math (Thumb-1 has no flag-read/IT). Exhaustively verified 1280/1280 (5 imms × 256 b). The hard part — flags — is proven.
  • M5 DONE — DTCM placement not needed. A small hot block is I-cache-resident either way, so PSRAM == DTCM (jit 100ms vs 95ms). The JIT can use a big PSRAM code cache; the ~2KB-pool crux dissolved. Bonus: the JIT block is layout-stable (95-100ms) while the interpreter swings with the I-cache lottery.
  • M6 DONE — VERDICT: NO-GO. A load/store kernel calling the real vecx_read8/write8 measured 0.56× — the JIT is ~2× slower than the interpreter on real memory ops. M3's 2.4× was a fake-memory artifact; real 6809 ops are memory-bound, the memory access dominates, and the dispatch-elimination the JIT buys is a small slice. Worse, the hot core inlines the memory fast path while the JIT calls out. A JIT can't meaningfully beat this interpreter — the bottleneck is memory, already handled optimally. The reassess gate did its job: decisive answer for ~2 sessions instead of weeks. jit.c (M1–M6, all correct) stays as a documented reference; the boot-time self-test is removed for release.

Flicker fix: framebuffer phosphor decay (PHOSPHOR_DECAY, render.c)

Vectrex games multiplex entities (draw some only every other frame to share beam time); real phosphor hides it, but rendering each frame hard at ~17 Hz made multiplexed/moving entities blink. Re-drawing old frames (the old RENDER_PERSISTENCE) fixed flicker but caused hard doubles. Fix: instead of clearing, OR an alternating dither mask ({0xAA,0x55}) into the framebuffer each render so un-redrawn pixels fade to white over ~2 frames, then draw the new vectors solid over it — phosphor emulation that hides the flicker and turns motion into a faint short glow, not a double. Cost is ~free (one framebuffer OR pass replacing the clear + markUpdatedRows); FPS stayed 36. A longer mask table = slower decay / longer glow if more smoothing is wanted.

Sound: AY-3-8910 (e8910.c, restored from git) + a Sound toggle

The Playdate port had stubbed e8910.c; recovered the full MAME-based AY core from history (git show bbd8314:e8910.c) and replaced the SDL backend with pd->sound->addSource (44100 Hz, int16, mono). Findings: (1) init hang — the source fires before the game programs any register, so all periods are 0 and the envelope/noise loops spin forever; fixed by seeding Period*/Count* = 1 and Holding = 1. (2) The distortion was the ~30%-real-time emulation, not the output stage — a clean test tone proved playback is fine. At 30% speed you can't have smooth + clean + correct-pitch together; per the user we keep smooth + correct-pitch and accept distortion. (3) "White noise after a while" = the audio thread free-running ahead of the lagging register feed; fixed by generating on the main thread. Architecture: e8910_generate(n) tops a SPSC int16 ring up to ~2048 samples each update (DC-block + gain 8), the audio callback drains it 1:1 (hold-last on underrun).

Sound toggle (off by default) — CPU saver

Audio generation costs CPU we want for emulation, so Sound is OFF by default. The 3-item system menu shares one slot: "Sound" (checkbox) while in the picker, "ROM Picker" while playing — rebuilt on each transition (rebuild_menu + render_refresh_menu) so the swappable item stays first and Rotation/Frameskip keep their values. When off, e8910_generate is skipped each update.

Performance plan (forward, 2026-06-10): aim +1.3–1.6× (≈36 → 47–58 FPS)

Native (3.3×) is out — it needs custom TCM linking the SDK loader blocks. The wall is memory latency (~2000 host-cyc/instr, ~7–8 PSRAM misses each); only fast-memory placement and executing fewer instructions help.

  • Tier 0 (done): audio off by default; frameskip exists.
  • Tier 1 — TRIED + REVERTED (blocked by the DTCM pool size). Relocate ea_indexed (the only hot out-of-line callee) into the cluster: drop -mlong-calls so the hot core's call to it is PC-relative and moves with the cluster, route the real out-calls (vecx_read8/write8, e6809_sstep, inst_psh/pul/exg/tfr) through volatile fn pointers. The mechanism worked — D1–D6 all passed, objdump confirmed the cluster self-contained. But the cluster is hot_core 2968B + ea_indexed 1416B = 4432B, and the safe DTCM region is only ~3.6 KB (measured: size 3600 runs the whole baseline; size 4432 crashes during emulation — boot D-probes pass because they run before the clobbered DTCM is used). The whole safe region is smaller than the cluster, so no margin tweak helps; shrinking the hot core to fit would push RMW ops to PSRAM (net-neutral), and the payoff was already uncertain (helpers are I-cache-cheap). Verdict: the ~3.6 KB pool is the hard wall — ea_indexed relocation is infeasible. The layout lottery stands.
  • Tier 2a (fuller compact core): blocked by the same ~3.6 KB pool wall (the core already fills it).
  • Tier 2b (more wait-skip) — TRIED + REVERTED: the Wait_Recal IFR-poll probe already runs every instruction; gating it to every 8th measured −6.5% (wait_skips 84→35 — the wait loop is often short, so missed short waits cost more than the probe). The per-instruction probe is already optimal.
  • Tier 3 (coarser VIA batching): untried; MACHINE_ADVANCE_BATCH must stay 1 for font correctness, so the per-instruction analog step (already FAST_BATCH event-capped) has little headroom.
  • ITCM as a code target — PROVEN CLOSED (2026-06-10): probed the F746's 16 KB ITCM to escape the 3.6 KB DTCM pool. Reading an ITCM address and the privileged SCB/MPU registers both hard-faulted; MRS CONTROL returned 0x7 → nPRIV=1, the game runs unprivileged. The sandbox forbids ITCM access, enabling ITCM, and MPU/TCM reconfiguration. The SDK-mapped DTCM stack-pool is the only fast memory we get — this is the structural root of the ceiling.
  • Dead ends (don't revisit): JIT (0.56×), page-table memory model, threaded dispatch, flag micro-opts, -Os, per-opcode switch fast paths, helper-relocation (pool wall), wait-skip gating (−6.5%), ITCM relocation (unprivileged sandbox).

HLE scoping (2026-06-10): where the cycles actually go

Enabled the dormant PC profiler (VECX_SAMPLE_PROFILE, vecx.c:21) with a region split + BIOS 256-byte bucket histogram (logged as vecx region …). Mine Storm, all phases:

  • ~100% of executed instructions are in the system ROM 0xE000–0xFFFF (cart=0%, ram=0%). NB Mine Storm is the built-in ROM game so its logic is here too; for normal cartridges the game code would show as cart%.
  • Concentrated in a few 256-byte windows: f400 (30–78%, tracks vector count), f300 (18–30%), f200 (9–15%) + f500/f600/f900; e500/eb00 = Mine Storm's own logic. These f200–f600 windows are the shared BIOS draw/math library (fixed addresses every game calls), so HLE-ing them generalizes.
  • Hottest inner loops: f4eb/f4ed/f4ee/f4ef (intro), f33d/f33f, f425/f427 — the per-vector beam-draw/timing spins.
  • The cheap skip is spent: VECX_BIOS_DELAY_SKIP (vecx.c:1390) already targets 0xf4eb and regressed — during the draw loop the analog beam is integrated per cycle to make the vector, so you can't skip cycles, only the cheap CPU spin, and the per-instruction probe costs more than it saves.
  • HLE verdict: the only version with a payoff is the hard one — intercept the BIOS draw routines (Draw_VL/Mov_Draw_VL, f300/f400) at entry and synthesize their vector output + cycle cost directly, bypassing both the CPU spin and the per-cycle analog integration. Real reimplementation, real accuracy risk, but the one credible path to 1.3–1.6×. Profiler left in, gated off.

HLE milestone 1 (2026-06-10): F410 geometry PROVEN bit-exact

Built a 6809 disassembler (tools/dis6809.py) + an on-device ground-truth capture + shadow validator (VECX_HLE_CAPTURE in vecx.c). Findings for Mine Storm's hot gameplay draw routine Mov_Draw_VL @ F410 (3-byte entries [mode, dy, dx], terminate when (signed)mode > 0, exit F430→F34F):

  • Geometry (verified bit-exact): dx = dx_signed × scale, dy = −dy_signed × scale, where scale = via_t1ll (the $D004 T1 latch). mode==0=move (reposition, emits an invisible zero-length dot), mode<0=draw (line). The real alg path also emits zero-length junction dots between drawn lines — filter degenerate vectors and the visible lines match 100% when in-bounds (OK==calls, many windows).
  • Only divergence: boundary-crossing vectors — alg clips the recorded endpoint where the beam leaves the [0,33000]×[0,41000] box (alg_sstep:1124); the predictor doesn't. Sub-pixel, edge-only, non-cascading (~2–5% of calls).
  • Active-intercept design: HLE in-bounds draws (provably exact), fall back to the real routine for edge-crossers. F410 ≈ gameplay only (the intro title uses F4C0); est. gameplay gain ~+25–30%.

HLE milestone 2 (2026-06-10): active F410 intercept — works, +17-25%, one residual crash

VECX_HLE_ACTIVE (vecx.c:23) intercepts F410 in the emu loop: bounds-check (fall back to the real routine on any OOB endpoint), emit the predicted lines via alg_addline (no analog cycle-sensitivity → no glitch), then replicate the per-entry Timer-1 restart (CLR $D005) with the beam force-blanked so machine_advance advances timers/sound/frame without re-emitting. CPU resumes at F430.

  • Measured: +17% at ~160 vectors (33.5 vs ~28.5), +24-25% at ~80-100 vectors (42-47 vs ~33-38). Rendering correct, wait_skips healthy. Better than the +12-15% first estimate (that was lighter/intro-phase data).
  • The freeze (solved 2026-06-10, see milestone 3): after sustained play the game froze with a watchdog reset in crashlog.txt. A bisection session chased it as an emulated-state desync (emission off, RAM-list bails, frame-boundary guard, +65 setup-cycle accounting — none fixed it) and the build shipped experimental-off. The real cause turned out to be a trivial host-side livelock in the hook, not the emulation — see below.
  • Fixes in the code from this phase: process-first-entry-always (matches BIOS), scale==0 / beam-OOB / >48-entry bails, frame-boundary guard, per-entry T1 restart + setup-cycle accounting, predictor-based emission (glitch-free).
  • Tooling kept: tools/dis6809.py, shadow validator (VECX_HLE_CAPTURE), e6809_get/set_x/pc/b/u.

HLE milestone 3 (2026-06-10): freeze SOLVED — it was a livelock in the hook, not a timer desync

Root cause. vecx_hle_f410_exec() returns 0 to decline a call (scale==0, beam off-screen, any OOB list endpoint, >48 entries, frame-boundary guard) so the real BIOS routine can handle it. But the emu-loop hook did continue unconditionally:

unsigned hc = vecx_hle_f410_exec ();
cycle_count += hc;
cycles -= (long) hc;   /* hc == 0 on decline! */
continue;              /* PC still F410 -> re-probe -> decline -> forever */

On a decline nothing executed and no cycles advanced, so the loop re-entered the same check with identical state: an infinite loop inside update() → the Playdate firmware watchdog reset the device. That is exactly the crashlog signature (watchdog, firmware PC, not a memory fault).

Why it masqueraded as a state desync. Declines only happen when a vector list touches the screen-bounds box (or the rarer guards fire), so calm play ran clean for minutes:

  • ship explosion → fragments fly to the edges → first OOB endpoint → instant hang;
  • rotating near the screen edge → same;
  • "eventually, even during the intro" → the frame-boundary guard — added during bisection as a fix — was itself a new decline path, so it created new freezes;
  • emission-off still froze → of course: the bug was in the hook, which bisection never varied.

Fix (vecx.c:1796): only continue when hc > 0; a decline falls through so the interpreter executes the real F410 routine for that call. All decline paths return before any state mutation (pass 1 is read-only), so the fallback is clean. Added a vecx_hle_declines counter to the hle-active log line as on-device proof.

Device-confirmed (Jun 10 2026 22:43 build, sustained play, no crash): declined= ran 0–1159 per 5s window — every single one of those would have been a watchdog freeze before — while the game kept playing. Performance held: heavy waves (~150–172 vectors) at 33.4–35.4 FPS vs the 28.5 real-routine floor (**+19%**), mid-gameplay (~90–120 vectors) at 38–46 FPS (+17–25%). VECX_HLE_ACTIVE is now 1 (on by default).

Lesson. The watchdog signature said "host C code stopped making progress," which points at the hook/loop plumbing, not the emulated machine. The bisection burned ~12 device cycles varying the emulation (emission, cycle accounting, guards) when the one thing every freeze shared — the decline path — was never under test. When an intercept has a fallback contract ("return 0 → caller runs the real thing"), verify the caller actually honors it.

Unified BIOS-spin skip (2026-06-10): wait-skip extended to the T1 draw spins (+1-1.5 FPS) and the Print_Str delay

Extending HLE coverage turned out NOT to need new intercepts: disassembly (tools/dis6809.py) showed the remaining hot loops are all the same BITA/BITB <$0D; BEQ self IFR poll that the proven f19e recognizer (build 62) already handles, just at other PCs with the mask in B instead of A. The recognizer (vecx.c:1552) now matches:

  • f19e Wait_Recal frame wait (mask in A) — the original;
  • f33d/f345 Moveto_d T1 spins (mask in B) — every entity Movetos before drawing; this was the hot f300 window (18–30% of samples);
  • f3f4 Draw_VLcs T1 spin (other carts);
  • f425 Mov_Draw_VL's internal spin — covers the F410 HLE's decline fallback path; Correctness-safe by construction: vecx_machine_advance still integrates the analog beam over the skipped cycles (strokes land exactly); only the CPU spin is collapsed. Measured (T1-spin step, device): heavy-wave wait_skips went 0–50 → 2,400–3,000/window, executed instructions −12% (513–536k → 450–464k/window), FPS 33.4–35.4 → 34.3–36.0 heavy / 39.6–41.0 mid (+1–1.5 FPS on top of the HLE). Rendering identical, no crashes. The gain is diluted because spin instructions are I-cache-cheap.

f4eb Print_Str delay skip — TRIED + REVERTED (third strike). Folded the f4eb raster delay (LDA #$81; NOP; DECB; BNE, via e6809_skip_bios_delay_f4eb) into the unified probe, expecting the unified PC fetch to fix the builds-36/37 regression cause (a second per-instruction probe). The skip worked (title-screen instructions −28%, 2,100→1,500/update; gameplay −10%) but wall time regressed ~1–1.5 FPS in every regime (title 32.9→32.1, heavy 34.3–36.0→33.1–34.3, mid 39.6–41.0→37.6–39.3). The delay spin is I-cache-resident and nearly free, so there is no real win to offset the code-layout shift. Conclusion: f4eb is structurally not worth skipping on this hardware — three independent attempts, three regressions. Do not retry. Reverted to the T1-spin recognizer (code-identical to the measured iteration-1 build).

Adaptive speed (2026-06-11): per-scene cycle budget — light games approach native pacing

The display caps updates at ~50 Hz, so the fixed 12,500-cycle budget topped out at 42% of the real 1.5 MHz machine (~21 complete Vectrex frames/s) no matter how light the game — Rip-Off left the device >50% idle. Fix (playdate_main.c, adaptive_emu_cycles): each update measures its wall time and a controller adjusts the next budget between 12,500 (the old floor) and 30,000 (EMU_CYCLES_NATIVE = true native pacing at 50 Hz), ±1,000/update. Budget resets on game switch; logged as adaptive= in the bench line.

  • Controller lesson: v1 compared the RAW per-update time against the thresholds and stalled near the floor — isolated 20–30 ms spikes (frame flips, the benchmark log itself) kept tripping the asymmetric fast back-off (−2,500 vs +500) while the average sat at 9–13 ms. v2 controls on an EMA (~8-update horizon, ±1,000 symmetric, dead zone 15–16 ms): spikes are absorbed, only sustained load backs off.
  • Measured (Rip-Off): emulated speed 42% → ~57–61% of native (emu_cycles 3.1M → 4.0–4.6M/window), real render rate ~21 → 25–31 fps, updates pinned at ~16 ms = the genuine host-capacity equilibrium (full 30,000 would cost ~21 ms and miss the 50 Hz deadline). Heavy scenes (Mine Storm waves, boot screen) stay pinned at 12,500 = exactly the old behavior. User verdict: "the smoothest game I've ever played on any emulator on this device."
  • Side benefit: at higher emulated speed the AY register feed lags real time less, so sound distortion shrinks on light games.

D-pad follows screen rotation (2026-06-11)

The D-pad mapping was hardwired to a transposed orientation (physical Right drove Vectrex Y-down — in Berzerk, moving right shot DOWN) and ignored the Rotation menu. update_input now reads render_rotation() (new accessor, render.c) and counter-rotates the physical direction into game space, so directions are screen-relative in all three orientations. Vectrex pot polarity: jch0 = X (0x00 left / 0xff right), jch1 = Y (0x00 down / 0xff up). Verified on device (Rip-Off).

Compatibility pass (2026-06-11): 5 games, no rendering issues

Game Rendering Speed Notes
Mine Storm (built-in) OK 34–46 FPS HLE validated bit-exact; the dev baseline
Armor Attack (1982) OK 30 41.5–48 FPS (F437 HLE, 2026-06-11) maze = patterned lines (Draw_Pat_VL). NB the game has an INVISIBLE-WALLS menu option (button 2) — not a bug
Bedlam (1983) OK 35–39 FPS logic-heavy (CPI ~1.5–2.5, 5–8k instr/update); HLE scale=4 clean
Berzerk (1982) OK 26–28 33.5–37 FPS (F3DD HLE, 2026-06-11) rooms = solid-line lists (Draw_VL/F3DA loop, was 48% of its instructions)
Rip-Off (1982) OK ~46–50 FPS, ~57–61% native pacing w/ adaptive the light-game showcase

Takeaways: the F410 HLE generalizes (3 very different list shapes, zero artifacts, decline/fallback exercised); Draw_Pat_VL/Draw_VL_mode (dashed lines) never pass through F410 so they're structurally untouched by the HLE; the games that lag (~28–30 FPS) are the ones drawing big pattern/wall scenery — the remaining perf lever for them would be an HLE of Draw_Pat_VL (predict the dash on/off segments; same recipe, moderate effort, unscheduled).

HLE milestone 4 (2026-06-11): Draw_Pat_VL (F437) intercept — Armor Attack +40-60%

The pattern-line path was the last expensive BIOS draw routine: while the shift register clocks the dash pattern onto the beam blank, machine_advance must step per-cycle AND the CPU runs an 18-cycle reload loop. VECX_HLE_PAT (vecx.c) intercepts one stroke per call (PC==F437, DP=D0) and resumes at F463 so the BIOS keeps its own count/exit logic — which sidesteps the routine's dual exits (RTS vs JMP $F34F) entirely.

  • Geometry validated bit-exact FIRST TRY (shadow validator, ~50k strokes, geommiss=0): dash schedule in stroke-relative cycles u (0 = the F44A T1 restart): SR writes complete at u=-4 (F448, bits 7..4 consumed pre-stroke) then u=14+18k (the F45C/F459 reload loop); 8 bits at 1 bit/cycle (bit 7 first), then CB2 holds bit 0; lit runs map linearly onto the stroke (endpoint = start + (dx,-dy)·scale, the F410 rule). The instruction-timing first guess was exact — deriving constants from cycle counts beats guessing.
  • Declines (return 0 → real routine runs; same fall-through contract as F410): scale<24 (the F44C early-expired/RTS path becomes reachable), start/end OOB, frame-boundary, segment-cap overflow.
  • Measured: Armor Attack gameplay ~30 → 41.5–48.2 FPS (+40–60%), ~7–9k strokes HLE'd/window, declines ticking, rendering identical, no freezes. The BIOS startup logo also uses F437 (boot screen 32.3→33.8). Mine Storm regression-clean (unchanged band). Berzerk: NOT a customer — its gameplay windows show zero F437 calls; its rooms cost ~2,700 instr/update at CPI 4.5 = compute-heavy cart code, not pattern drawing. Berzerk (~26–28 FPS) would need its own profiling session; out of scope.

HLE milestone 5 (2026-06-11): Draw_VL/Draw_VLc (F3DD) intercept — Berzerk +30%; hook overhead lesson

Profiling Berzerk gameplay (region split) showed its rooms are NOT pattern-drawn: ~48% of executed instructions sat in f300 = the F3DA solid-line list loop (Draw_VL/Draw_VLc: per entry [dy,dx], SR=$FF for the stroke, T1 spin, count in $C823), with cart logic only 20–28%. VECX_HLE_VL (vecx.c) intercepts ONE stroke per call (PC==F3DD, DP=D0), emits one alg_addline, resumes at F3FB (BIOS keeps count/loop/exit; the F3DA loop has no early-exit path, so no tiny-scale case).

  • Geometry validated bit-exact first try, third time running (~50k strokes, geommiss=0): one line, start -> start + (dx,-dy)·scale.
  • Measured: Berzerk rooms ~25–27 → 33.5–37.3 FPS (+30%), 6–7.5k strokes/window, declines healthy, no freezes. "First level done."
  • Hook-overhead regression + fix: the third hook initially cost Mine Storm ~10% — every instruction paid FOUR separate e6809_get_pc() calls (wait-skip probe + 3 hooks; e6809.c builds -fno-lto, so each is a real call) plus a playdate_main.c layout roll. Fix: vecx_emu now fetches PC once per iteration, passes it to the probe, and gates all three hooks behind a single range check (F3DD <= pc <= F437 — the three entry PCs are adjacent by design). Mine Storm recovered ~+2 FPS (residual delta vs older logs is play-regime difference: idle play sustains the full minefield and is HARSHER than active play, where destroyed rocks lighten the scene — compare regimes, not sessions). Rule: anything probing per-instruction must share ONE pc fetch; never add a second.

Performance verdict (2026-06-10, updated): HLE shipped ON; gameplay now 33-46 FPS

The 1.3–1.6× generic-lever target is not reachable with this SDK — the structural reason stands: the game runs unprivileged (CONTROL nPRIV=1), so the only fast memory available is the SDK-mapped ~3.6 KB DTCM stack-pool. Tier 1 (relocate ea_indexed) and Tier 2a (fuller core) are walled by that pool; ITCM (4× bigger) is forbidden by the sandbox; Tier 2b (wait-skip) is already optimal and regressed when touched. But the HLE lever landed: with the F410 intercept fixed and enabled, the heavy Mine Storm regime moved from ~28.5 to ~33.4–35.4 FPS and mid-gameplay runs 38–46 FPS. Banked wins: TCM hot core (+15% over the 33.2 baseline), F410 HLE (+17–25% gameplay), FAST_BATCH font fix, render decouple, phosphor flicker fix, audio + ROM picker as features. Possible follow-up if more speed is ever wanted: HLE the remaining hot BIOS draw windows (F4C0 intro draw, F3DD Draw_VL at f300) using the same validated recipe.

Hardware comparison (2026-06-11): Playdate Rev A (F746) vs Rev B (H7)

The original dev device (Rev A) failed its input digitizer and was replaced with a Rev B, which uses a different SoC. Re-ran the benchmark suite on the identical .pdx (build Jun 11 2026 15:30:21) to compare. Regimes matched by vector count + game state, not just title (idle vs active Mine Storm differ; compared like-for-like). Figures are representative steady-state windows from the on-device 5-second bench log.

Device identity (from the serial preamble):

  • Rev A: target=dvt1, STM32F746, pcbver=0x01, code runs from 0x60000000 (PSRAM), ~180 MHz. Serial PDU1-Y024621.
  • Rev B: target=h7d1, STM32H7, pcbver=0x13, code runs from 0x90000000 (QSPI flash), higher clock + bigger cache. Serial PDU4-Y017929.

Frame rate by game (Rev A → Rev B), same build:

Game / regime Rev A Rev B ratio
Mine Storm idle (~90 vec) 36–37 48–49 1.34×
Mine Storm heavy (~145 vec) ~31.5 43.7 1.39×
Berzerk room 33.5–37 48.5–49.5 1.41×
Armor Attack (maze) 41.5–48 49.9 (50 Hz cap) cap-bound
Rip-Off ~48 (adaptive-driven) ~50 (cap) see pacing
raw throughput @ adaptive floor (12,500) ~460k cyc/s ~611k cyc/s 1.33×
render_fps (complete Vectrex frames/s) ~14–30 20–41 ~1.5–2×

Key findings:

  • Rev B is ~1.33–1.41× the raw interpreter throughput of Rev A, and the ratio rises with load (idle 1.33× → heavy MS 1.39× → Berzerk room 1.41×). That upward slope is the signature of a memory-latency-bound core meeting a bigger cache: the tighter the game-logic loop, the more of each frame is the interpreter grinding cache misses, exactly where the H7's larger cache pays off.
  • NOT ~2×. The H7's much higher clock alone would predict ~2×; the actual ~1.35× confirms the whole-port thesis — the bottleneck is memory latency, not compute, so a faster clock can't help beyond what the extra cache buys. The memory wall from [[project-vecx-perf-plan]] is still the limit, just higher.
  • The DTCM hot-core relocation survived the SoC change untouched. All six itcm D1–D6 probes pass on the H7; the core copies from 0x90000000 flash into DTCM (pool 0x20007c40–0x20008a40, ~= Rev A's 0x20007bf0) and runs. The most fragile piece of the port — hand-fitted to Rev A's memory map — needed zero edits. Whatever the H7's unprivileged/pool constraints are, they match Rev A closely enough that nothing broke.
  • render_fps roughly doubled — the perceptual win is bigger than the FPS number: far more complete Vectrex frames actually reach the screen (Mine Storm ~14–15 → 18–41). Peak emulated throughput reached ~82% of native 1.5 MHz on Rev B (6.15M cyc / 5 s window) vs ~60% on Rev A.
  • The marginal games are now solved: heavy Mine Storm and Berzerk rooms sit at the 50 Hz display cap; Armor Attack is cap-bound (was compute-bound at 41–48). The three BIOS-draw HLEs (F410/F437/F3DD) fire identically — Armor Attack still HLEs ~11k F437 + ~1.5k F3DD + ~600 F410 strokes/window; the faster core just clears what's left sooner.

Follow-up surfaced by the data — RETUNE THE ADAPTIVE CONTROLLER FOR REV B. On Rev B the adaptive cycle budget equilibrates at ~25,000–28,000 and never pins EMU_CYCLES_NATIVE (30,000 = true 1.5 MHz pacing), even on sparse Rip-Off. The controller (playdate_main.c adaptive_emu_cycles) was tuned to Rev A: it eases off at ~16–17 ms EMA update time. On Rev B a full 30,000-cycle slice costs ~17 ms — still under the 20 ms / 50 Hz deadline — so widening the grow threshold (or making it device-aware) would let light scenes reach genuine native pacing. Unscheduled; the obvious next experiment if native pacing on Rev B is wanted.

0