From 9d82d47d11e763bd00c0bb72c8e8237992fb0e4e Mon Sep 17 00:00:00 2001 From: Vladimir Smitka Date: Sat, 19 Sep 2026 21:17:50 +0000 Subject: [PATCH 1/3] picogame: Canvas.text raises NotImplementedError without fontio fontio is off when terminalio is off (the ja/ko/ru builds of boards that drop the terminal font), and picogame_canvas_text then fails to link. --- shared-bindings/picogame/Canvas.c | 9 ++++++++- shared-module/picogame/Canvas.c | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/shared-bindings/picogame/Canvas.c b/shared-bindings/picogame/Canvas.c index 675826936ff..a3efbdc0312 100644 --- a/shared-bindings/picogame/Canvas.c +++ b/shared-bindings/picogame/Canvas.c @@ -8,7 +8,9 @@ #include "shared-module/picogame/pg_compat.h" #include "shared-bindings/picogame/Canvas.h" #include "shared-bindings/picogame/Bitmap.h" +#if CIRCUITPY_FONTIO #include "shared-bindings/fontio/BuiltinFont.h" +#endif #include "shared-module/picogame/Canvas.h" //| class Canvas: @@ -512,10 +514,12 @@ static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_frame3d_obj, 7, 7, canvas_fram //| is drawn; no memory is retained between calls. Only ASCII characters are //| supported. If ``bg`` is given the glyph background is filled with it, //| otherwise it is transparent. Inside a `StripDraw` callback the view is a -//| Canvas, so ``view.text(...)`` draws text directly into the frame.""" +//| Canvas, so ``view.text(...)`` draws text directly into the frame. +//| Raises :py:class:`NotImplementedError` in builds without `fontio`.""" //| ... //| static mp_obj_t canvas_text(size_t n, const mp_obj_t *a) { + #if CIRCUITPY_FONTIO const char *s = mp_obj_str_get_str(a[3]); mp_int_t fg = mp_obj_get_int(a[4]); const void *font = MP_OBJ_TO_PTR(mp_arg_validate_type(a[5], &fontio_builtinfont_type, MP_QSTR_font)); @@ -524,6 +528,9 @@ static mp_obj_t canvas_text(size_t n, const mp_obj_t *a) { picogame_canvas_text(cv_self(a[0]), mp_obj_get_int(a[1]), mp_obj_get_int(a[2]), s, (uint16_t)fg, bg, has_bg, font); return mp_const_none; + #else + mp_raise_NotImplementedError(MP_ERROR_TEXT("Operation or feature not supported")); + #endif } static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_text_obj, 6, 7, canvas_text); diff --git a/shared-module/picogame/Canvas.c b/shared-module/picogame/Canvas.c index 5021dbc95f9..b51ea7084d6 100644 --- a/shared-module/picogame/Canvas.c +++ b/shared-module/picogame/Canvas.c @@ -9,7 +9,9 @@ #include "shared-module/picogame/Canvas.h" #include "shared-module/picogame/Bitmap.h" #include "shared-module/picogame/__init__.h" +#if CIRCUITPY_FONTIO #include "shared-module/fontio/BuiltinFont.h" +#endif #include "shared-bindings/displayio/Bitmap.h" // Thin wrappers over the shared int32 accumulator (dx1,dy1,dx2,dy2 are contiguous int32 at the @@ -558,6 +560,7 @@ void picogame_blit_canvas( // font's 1-bit atlas on the fly (no Python glyph cache, no per-call Bitmap/Sprite). Because the // StripDraw `view` is a Canvas pointing at the live strip buffer, view.text() draws immediate-mode // text into the frame with zero retained RAM - the same primitive serves retained Canvas screens. +#if CIRCUITPY_FONTIO void picogame_canvas_text(picogame_canvas_obj_t *cv, int x, int y, const char *text, uint16_t fg, uint16_t bg, bool has_bg, const void *font) { const fontio_builtinfont_t *f = font; @@ -609,6 +612,7 @@ void picogame_canvas_text(picogame_canvas_obj_t *cv, int x, int y, const char *t } mark(cv, x0, y, x, y + fh); } +#endif // Fill a screen-space triangle batch with per-triangle band reject - shared by the // Canvas.fill_triangles binding and the compositor's Triangles layer (one loop, one place). From fcdbbf96e5be585924ac43fae35c63a1ee1d36cb Mon Sep 17 00:00:00 2001 From: Vladimir Smitka Date: Sat, 19 Sep 2026 17:49:56 +0000 Subject: [PATCH 2/3] atmel-samd: picogame fast Display backend on the SERCOM SPI DMA Same shape as the raspberrypi backend: the first strip of a frame goes through the portable bus.send path (which sets the window), later strips are handed to shared_dma_transfer_start() on the display's SERCOM and the engine renders the next strip while the DMA drains the previous one. Strips over 65535 bytes (the DMAC transfer count) fall back to a blocking send. shared_dma_transfer_* carries the SAMD51 DMAC details a hand-rolled descriptor got wrong the first time: the "transfer never starts" errata kick, and draining the RX overflow left behind by a TX-only job (without that the next bus.send saw a stale SPI state and the board froze after a few frames). Measured on a PyBadge (SAMD51, 160x128 ST7735 at 24 MHz) with the picowing game: portable 55.2 fps, this backend 65.3 fps, +RGB444 82.3 fps. Cost +1604 B text with RGB444. --- .../atmel-samd/common-hal/picogame/Display.c | 153 ++++++++++++++++++ .../atmel-samd/common-hal/picogame/Display.h | 37 +++++ 2 files changed, 190 insertions(+) create mode 100644 ports/atmel-samd/common-hal/picogame/Display.c create mode 100644 ports/atmel-samd/common-hal/picogame/Display.h diff --git a/ports/atmel-samd/common-hal/picogame/Display.c b/ports/atmel-samd/common-hal/picogame/Display.c new file mode 100644 index 00000000000..b3a1e328069 --- /dev/null +++ b/ports/atmel-samd/common-hal/picogame/Display.c @@ -0,0 +1,153 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#include "common-hal/picogame/Display.h" + +#include "py/runtime.h" +#include "shared-module/picogame/__init__.h" +#include "shared-module/displayio/display_core.h" +#include "shared-bindings/displayio/__init__.h" +#include "shared-bindings/fourwire/FourWire.h" + +#include "peripherals/samd/dma.h" + +// shared_dma_transfer_start truncates the beat count to 16 bits, so one descriptor tops out here. +#define PICOGAME_DMA_MAX_BYTES 65535 + +void common_hal_picogame_display_construct(picogame_display_obj_t *self, + busdisplay_busdisplay_obj_t *display, bool rgb444) { + self->display = display; + #if CIRCUITPY_PICOGAME_RGB444 + self->rgb444 = rgb444; + #else + if (rgb444) { + mp_raise_NotImplementedError(MP_ERROR_TEXT("Operation or feature not supported")); + } + self->rgb444 = false; + #endif + + // The fast path needs the raw SERCOM; only FourWire SPI buses are supported. + if (!mp_obj_is_type(display->bus.bus, &fourwire_fourwire_type)) { + mp_raise_TypeError_varg(MP_ERROR_TEXT("%q must be of type %q, not %q"), + MP_QSTR_display, MP_QSTR_FourWire, mp_obj_get_type(display->bus.bus)->name); + } + fourwire_fourwire_obj_t *fw = MP_OBJ_TO_PTR(display->bus.bus); + self->sercom = (Sercom *)fw->bus->spi_desc.dev.prvt; + + #if CIRCUITPY_PICOGAME_RGB444 + // COLMOD; also resets a panel left in the other format by a previous program. + picogame_set_pixel_format(display, rgb444); + #endif + +} + +// Strips go through shared_dma_transfer_*, which handles the DMAC start errata and the RX +// overflow a TX-only transfer leaves on the SERCOM. +static void dma_finish(dma_transfer_t *xfer) { + // Bounded wait: a strip takes well under 1 ms, so a timeout tears one frame instead of + // hanging the board. close() releases the channel either way. + for (uint32_t spins = 0; spins < 2000000; spins++) { + if (shared_dma_transfer_finished(xfer)) { + break; + } + } + shared_dma_transfer_close(xfer); +} + +void common_hal_picogame_display_render(picogame_display_obj_t *self, + mp_obj_t *items, uint8_t *kinds, size_t n, + uint16_t *buf_a, uint16_t *buf_b, size_t buf_pixels, + int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t background, + int ox, int oy) { + + busdisplay_busdisplay_obj_t *display = self->display; + Sercom *sercom = self->sercom; + + // RGB444 packs 2 px into 3 bytes, so widen the region to even x bounds. + #if CIRCUITPY_PICOGAME_RGB444 + if (self->rgb444) { + x0 &= ~1; + x1 = (x1 + 1) & ~1; + if (x1 > display->core.width) { + x1 = display->core.width; + } + } + #endif + + // Sets the window and starts the transaction; the strips follow as data. + int region_w, strip_h; + int cx0 = x0, cy0 = y0, cx1 = x1, cy1 = y1; // clamped to the panel by strip_begin + if (!picogame_strip_begin(display, &cx0, &cy0, &cx1, &cy1, buf_pixels, ®ion_w, &strip_h)) { + return; + } + + uint16_t *bufs[2] = { buf_a, buf_b }; + int cur = 0; + bool first = true; + dma_transfer_t xfer; + bool xfer_active = false; + #if CIRCUITPY_PICOGAME_RGB444 + const bool rgb444 = self->rgb444; + #endif + + // An exception from a StripDraw callback is re-raised after the DMA finishes and the + // transaction ends. + mp_obj_t pending = MP_OBJ_NULL; + + for (int sy = cy0; sy < cy1; sy += strip_h) { + int sh = picogame_imin(strip_h, cy1 - sy); + uint16_t *buf = bufs[cur]; + + // Composes into the buffer the in-flight DMA is not reading. + pending = picogame_blit_strip_layers(buf, region_w, sy, sh, cx0, items, kinds, n, background, ox, oy); + + // Packed in place, 3/4 the bytes. + #if CIRCUITPY_PICOGAME_RGB444 + size_t nbytes = rgb444 + ? picogame_pack_rgb444(buf, (size_t)region_w * sh) + : (size_t)region_w * sh * 2; + #else + size_t nbytes = (size_t)region_w * sh * 2; + #endif + + if (xfer_active) { + dma_finish(&xfer); + xfer_active = false; + } + + if (first || nbytes > PICOGAME_DMA_MAX_BYTES) { + // The first strip sets DC for data through busdisplay; the DMA strips that follow + // leave it. Strips over the descriptor limit also go this way (busio splits them). + display->bus.send(display->bus.bus, DISPLAY_DATA, + CHIP_SELECT_UNTOUCHED, (uint8_t *)buf, nbytes); + first = false; + } else { + shared_dma_transfer_start(&xfer, sercom, (const uint8_t *)buf, + &sercom->SPI.DATA.reg, NULL, NULL, nbytes, 0); + if (xfer.failure != 0) { // no channel free + shared_dma_transfer_close(&xfer); + display->bus.send(display->bus.bus, DISPLAY_DATA, + CHIP_SELECT_UNTOUCHED, (uint8_t *)buf, nbytes); + } else { + xfer_active = true; + } + } + cur ^= 1; + if (pending != MP_OBJ_NULL) { + break; + } + } + + if (xfer_active) { + dma_finish(&xfer); + } + + displayio_display_bus_end_transaction(&display->bus); + + if (pending != MP_OBJ_NULL) { + nlr_raise(MP_OBJ_TO_PTR(pending)); + } +} diff --git a/ports/atmel-samd/common-hal/picogame/Display.h b/ports/atmel-samd/common-hal/picogame/Display.h new file mode 100644 index 00000000000..89546e2c7f0 --- /dev/null +++ b/ports/atmel-samd/common-hal/picogame/Display.h @@ -0,0 +1,37 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT +// +// Fast display backend (atmel-samd): wraps an existing busdisplay and streams pixels with a DMAC +// channel of our own, double-buffered so the CPU blits the next strip while the current one is on +// the wire. Reuses the busdisplay's SERCOM, window opcodes and dimensions -- controller and +// resolution agnostic. +// +// Why this pays on a SAMD51: busio already sends through the DMAC, but it busy-waits for each +// transfer to land, so the compose time is added to the wire time instead of hidden under it. + +#pragma once + +#include "py/obj.h" + +#include "include/sam.h" +#include "shared-bindings/busdisplay/BusDisplay.h" +#include "shared-module/picogame/Sprite.h" + +typedef struct { + mp_obj_base_t base; + busdisplay_busdisplay_obj_t *display; + Sercom *sercom; // the busdisplay's SERCOM; strips go out on the port's shared DMA + bool rgb444; +} picogame_display_obj_t; + +void common_hal_picogame_display_construct(picogame_display_obj_t *self, + busdisplay_busdisplay_obj_t *display, bool rgb444); + +void common_hal_picogame_display_render(picogame_display_obj_t *self, + mp_obj_t *items, uint8_t *kinds, size_t n, + uint16_t *buf_a, uint16_t *buf_b, size_t buf_pixels, + int16_t x0, int16_t y0, int16_t x1, int16_t y1, uint16_t background, + int ox, int oy); From ea51b2fc6d725871f223e64483d1ee006cc9dddb Mon Sep 17 00:00:00 2001 From: Vladimir Smitka Date: Sat, 19 Sep 2026 21:18:05 +0000 Subject: [PATCH 3/3] atmel-samd: enable picogame on pybadge picogame with the DMA backend and RGB444 is 28356 B; the stock build has 5436 B free. Turn off bleio, frequencyio, i2ctarget, ps2io, pulseio and sdcardio, which are only reachable through the Feather header. Free after: 7716 B (en_US), 4840 B (fr), 10900 B (ja). --- ports/atmel-samd/boards/pybadge/mpconfigboard.mk | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ports/atmel-samd/boards/pybadge/mpconfigboard.mk b/ports/atmel-samd/boards/pybadge/mpconfigboard.mk index 86ce2d47e8c..773c7709c00 100644 --- a/ports/atmel-samd/boards/pybadge/mpconfigboard.mk +++ b/ports/atmel-samd/boards/pybadge/mpconfigboard.mk @@ -22,6 +22,18 @@ CIRCUITPY_PARALLELDISPLAYBUS= 0 CIRCUITPY_SPITARGET = 0 CIRCUITPY_STAGE = 1 +CIRCUITPY_PICOGAME = 1 +CIRCUITPY_PICOGAME_FAST_DISPLAY = 1 +CIRCUITPY_PICOGAME_RGB444 = 1 + +# Not enough room for picogame and these; all are only reachable through the Feather header. +CIRCUITPY_BLEIO_HCI = 0 +CIRCUITPY_FREQUENCYIO = 0 +CIRCUITPY_I2CTARGET = 0 +CIRCUITPY_PS2IO = 0 +CIRCUITPY_PULSEIO = 0 +CIRCUITPY_SDCARDIO = 0 + FROZEN_MPY_DIRS += $(TOP)/frozen/circuitpython-stage/pybadge # We don't have room for the fonts for terminalio for certain languages,