diff --git a/locale/circuitpython.pot b/locale/circuitpython.pot index 38d498312ad..9a080633d34 100644 --- a/locale/circuitpython.pot +++ b/locale/circuitpython.pot @@ -1041,7 +1041,7 @@ msgstr "" #: ports/espressif/common-hal/sdioio/SDCard.c #: ports/raspberrypi/common-hal/sdioio/SDCard.c #: ports/stm/common-hal/sdioio/SDCard.c shared-bindings/floppyio/__init__.c -#: shared-module/sdcardio/SDCard.c +#: shared-bindings/picogame/Canvas.c shared-module/sdcardio/SDCard.c #, c-format msgid "Buffer must be a multiple of %d bytes" msgstr "" @@ -1476,6 +1476,7 @@ msgid "Requested resource not found" msgstr "" #: ports/espressif/common-hal/espidf/__init__.c +#: ports/raspberrypi/common-hal/picogame/Display.c msgid "Operation or feature not supported" msgstr "" @@ -1570,6 +1571,14 @@ msgstr "" msgid "Number of data_pins must be %d or %d, not %d" msgstr "" +#: ports/espressif/common-hal/picogame/Display.c +msgid "rgb444 fast Display not supported on this port yet" +msgstr "" + +#: ports/espressif/common-hal/picogame/Display.c +msgid "fast Display needs a FourWire SPI display" +msgstr "" + #: ports/espressif/common-hal/pulseio/PulseIn.c msgid "pop from an empty PulseIn" msgstr "" @@ -1682,7 +1691,8 @@ msgstr "" msgid "Only IPv4 addresses supported" msgstr "" -#: ports/mimxrt10xx/common-hal/busio/SPI.c shared-bindings/busio/SPI.c +#: ports/mimxrt10xx/common-hal/busio/SPI.c +#: ports/raspberrypi/bindings/wiznet/PIO_SPI.c shared-bindings/busio/SPI.c msgid "Must provide MISO or MOSI pin" msgstr "" @@ -1813,9 +1823,9 @@ msgstr "" #: ports/raspberrypi/bindings/cyw43/__init__.c py/argcheck.c py/objexcept.c #: shared-bindings/bitmapfilter/__init__.c shared-bindings/canio/CAN.c -#: shared-bindings/digitalio/Pull.c shared-bindings/supervisor/__init__.c -#: shared-module/audiofilters/Filter.c shared-module/displayio/__init__.c -#: shared-module/synthio/Synthesizer.c +#: shared-bindings/digitalio/Pull.c shared-bindings/picogame/__init__.c +#: shared-bindings/supervisor/__init__.c shared-module/audiofilters/Filter.c +#: shared-module/displayio/__init__.c shared-module/synthio/Synthesizer.c msgid "%q must be of type %q or %q, not %q" msgstr "" @@ -1844,6 +1854,17 @@ msgstr "" msgid "In-buffer elements must be <= 4 bytes long" msgstr "" +#: ports/raspberrypi/bindings/wiznet/PIO_SPI.c shared-bindings/bitbangio/I2C.c +#: shared-bindings/bitbangio/SPI.c shared-bindings/busio/I2C.c +#: shared-bindings/busio/SPI.c +msgid "Function requires lock" +msgstr "" + +#: ports/raspberrypi/bindings/wiznet/PIO_SPI.c shared-bindings/bitbangio/SPI.c +#: shared-bindings/busio/SPI.c +msgid "buffer slices must be of equal length" +msgstr "" + #: ports/raspberrypi/common-hal/alarm/touch/TouchAlarm.c #: ports/stm/common-hal/alarm/touch/TouchAlarm.c msgid "Touch alarms not available" @@ -1958,6 +1979,13 @@ msgstr "" msgid "All timers for this pin are in use" msgstr "" +#: ports/raspberrypi/common-hal/picogame/Display.c py/argcheck.c py/runtime.c +#: shared-bindings/bitmapfilter/__init__.c +#: shared-module/audiodelays/MultiTapDelay.c shared-module/synthio/Note.c +#: shared-module/synthio/__init__.c +msgid "%q must be of type %q, not %q" +msgstr "" + #: ports/raspberrypi/common-hal/rotaryio/IncrementalEncoder.c msgid "Pins must be sequential GPIO pins" msgstr "" @@ -2292,7 +2320,7 @@ msgid "extra keyword arguments given" msgstr "" #: py/argcheck.c shared-bindings/_stage/__init__.c -#: shared-bindings/digitalio/DigitalInOut.c +#: shared-bindings/digitalio/DigitalInOut.c shared-bindings/picogame/__init__.c msgid "argument num/types mismatch" msgstr "" @@ -2313,12 +2341,6 @@ msgstr "" msgid "%q must be <= %d" msgstr "" -#: py/argcheck.c py/runtime.c shared-bindings/bitmapfilter/__init__.c -#: shared-module/audiodelays/MultiTapDelay.c shared-module/synthio/Note.c -#: shared-module/synthio/__init__.c -msgid "%q must be of type %q, not %q" -msgstr "" - #: py/argcheck.c msgid "%q length must be %d-%d" msgstr "" @@ -2868,7 +2890,9 @@ msgstr "" msgid "schedule queue full" msgstr "" -#: py/modstruct.c shared-module/struct/__init__.c +#: py/modstruct.c shared-bindings/picogame/Canvas.c +#: shared-bindings/picogame/__init__.c shared-module/picogame/__init__.c +#: shared-module/struct/__init__.c msgid "buffer too small" msgstr "" @@ -3732,6 +3756,7 @@ msgid "file must be a file opened in byte mode" msgstr "" #: shared-bindings/audiodelays/Chorus.c shared-bindings/audiodelays/Echo.c +#: shared-bindings/audiodelays/Flanger.c #: shared-bindings/audiodelays/GranularPitchShift.c #: shared-bindings/audiodelays/MultiTapDelay.c #: shared-bindings/audiodelays/PitchShift.c @@ -3749,10 +3774,6 @@ msgstr "" msgid "bits_per_sample must be 16" msgstr "" -#: shared-bindings/audioi2sin/I2SIn.c -msgid "%q requires %q" -msgstr "" - #: shared-bindings/audioi2sin/I2SIn.c #, c-format msgid "invalid destination buffer, must be an array of type: %c" @@ -3762,15 +3783,6 @@ msgstr "" msgid "%q and %q must be different" msgstr "" -#: shared-bindings/bitbangio/I2C.c shared-bindings/bitbangio/SPI.c -#: shared-bindings/busio/I2C.c shared-bindings/busio/SPI.c -msgid "Function requires lock" -msgstr "" - -#: shared-bindings/bitbangio/SPI.c shared-bindings/busio/SPI.c -msgid "buffer slices must be of equal length" -msgstr "" - #: shared-bindings/bitmapfilter/__init__.c msgid "" "weights must be a sequence with an odd square number of elements (usually 9 " @@ -4088,6 +4100,36 @@ msgstr "" msgid "Specify exactly one of data0 or data_pins" msgstr "" +#: shared-bindings/picogame/Display.c shared-bindings/picogame/Scene.c +#: shared-bindings/picogame/__init__.c +msgid "expected a BusDisplay" +msgstr "" + +#: shared-bindings/picogame/Scene.c +msgid "scene full" +msgstr "" + +#: shared-bindings/picogame/Scene.c +msgid "item not in scene" +msgstr "" + +#: shared-bindings/picogame/__init__.c shared-module/msgpack/__init__.c +#: supervisor/shared/settings.c +msgid "Invalid format" +msgstr "" + +#: shared-bindings/picogame/__init__.c +msgid "PAL8 needs a palette" +msgstr "" + +#: shared-bindings/picogame/__init__.c +msgid "palette is empty" +msgstr "" + +#: shared-bindings/picogame/__init__.c +msgid "expected a Sprite, Tilemap, Particles, Canvas, StripDraw or Triangles" +msgstr "" + #: shared-bindings/ps2io/Ps2.c msgid "Failed sending command." msgstr "" @@ -4407,10 +4449,6 @@ msgstr "" msgid "no default packer" msgstr "" -#: shared-module/msgpack/__init__.c supervisor/shared/settings.c -msgid "Invalid format" -msgstr "" - #: shared-module/paralleldisplaybus/ParallelBus.c msgid "" "This microcontroller only supports data0=, not data_pins=, because it " diff --git a/ports/espressif/common-hal/picogame/Display.c b/ports/espressif/common-hal/picogame/Display.c new file mode 100644 index 00000000000..1c7f834f76d --- /dev/null +++ b/ports/espressif/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 + +#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 "common-hal/busio/SPI.h" + +#include "driver/spi_master.h" + +// The esp-idf SPI device queue holds MAX_SPI_TRANSACTIONS (10) outstanding +// transactions. We keep up to two strips in flight (current transferring while +// the next is blitted), so each strip may use at most this many DMA chunks and +// still leave room: 2 * 5 <= 10. Strips needing more chunks fall back to a +// blocking send (correct, just no overlap for that strip). +#define PICOGAME_MAX_STRIP_CHUNKS 5 + +void common_hal_picogame_display_construct(picogame_display_obj_t *self, + busdisplay_busdisplay_obj_t *display, bool rgb444) { + self->display = display; + // RGB444 strip packing isn't implemented on this backend yet. Raise rather than silently + // ignore it: a no-op would leave the panel in RGB565 while the caller expects 444 (garbled + // output / wrong byte count). The rpi backend implements it; until this one does, fail loud. + if (rgb444) { + mp_raise_NotImplementedError(MP_ERROR_TEXT("rgb444 fast Display not supported on this port yet")); + } + self->rgb444 = false; + + // The fast path queues raw DMA on the display's SPI device; only FourWire + // SPI buses expose one. + if (!mp_obj_is_type(display->bus.bus, &fourwire_fourwire_type)) { + mp_raise_ValueError(MP_ERROR_TEXT("fast Display needs a FourWire SPI display")); + } + fourwire_fourwire_obj_t *fw = MP_OBJ_TO_PTR(display->bus.bus); + self->spi = common_hal_busio_spi_get_device_handle(fw->bus); +} + +// Retrieve all outstanding results for *count queued chunks, then zero the count. +static void drain(spi_device_handle_t spi, int *count) { + spi_transaction_t *rtrans; + while (*count > 0) { + spi_device_get_trans_result(spi, &rtrans, portMAX_DELAY); + (*count)--; + } +} + +// Queue one strip (nbytes from buf) as up to PICOGAME_MAX_STRIP_CHUNKS DMA +// transactions. Returns the chunk count, or -1 if it would need more chunks. +static int queue_strip(spi_device_handle_t spi, spi_transaction_t *trans, + const uint16_t *buf, size_t nbytes) { + int needed = (int)((nbytes + SPI_MAX_DMA_LEN - 1) / SPI_MAX_DMA_LEN); + if (needed > PICOGAME_MAX_STRIP_CHUNKS) { + return -1; + } + const uint8_t *p = (const uint8_t *)buf; + size_t off = 0; + int n = 0; + while (off < nbytes) { + size_t chunk = nbytes - off; + if (chunk > SPI_MAX_DMA_LEN) { + chunk = SPI_MAX_DMA_LEN; + } + memset(&trans[n], 0, sizeof(spi_transaction_t)); + trans[n].length = chunk * 8; // in bits + trans[n].tx_buffer = p + off; + spi_device_queue_trans(spi, &trans[n], portMAX_DELAY); + off += chunk; + n++; + } + return n; +} + +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; + spi_device_handle_t spi = self->spi; + + // Open the GRAM window (set region, begin transaction, RAMWR). After the + // first DATA send raises DC, DC stays high for the raw queued strips that + // follow, and CS stays low until end_transaction. + int region_w, strip_h; + int cx0 = x0, cy0 = y0, cx1 = x1, cy1 = y1; // strip_begin clamps these to the panel in place + if (!picogame_strip_begin(display, &cx0, &cy0, &cx1, &cy1, buf_pixels, ®ion_w, &strip_h)) { + return; + } + + uint16_t *bufs[2] = { buf_a, buf_b }; + spi_transaction_t trans[2][PICOGAME_MAX_STRIP_CHUNKS]; + int inflight[2] = { 0, 0 }; // chunks queued from bufs[i], awaiting result + + // A StripDraw callback may latch a BaseException (Ctrl-C / auto-reload). Like the portable + // renderer and the RP backend, re-raise it -- but only AFTER the queued transfers drain and + // the bus transaction closes, so hold it here and propagate below. + mp_obj_t pending = MP_OBJ_NULL; + + int cur = 0; + bool first = true; + for (int sy = cy0; sy < cy1; sy += strip_h) { + int sh = picogame_imin(strip_h, cy1 - sy); + size_t nbytes = (size_t)region_w * sh * 2; + uint16_t *buf = bufs[cur]; + + // This buffer must be free before we overwrite it. + drain(spi, &inflight[cur]); + + // Blit this strip. If the OTHER buffer has a strip in flight, its DMA + // transfer overlaps this CPU work -- the whole point of the fast path. + pending = picogame_blit_strip_layers(buf, region_w, sy, sh, cx0, items, kinds, n, + background, ox, oy); + + int nch; + if (first) { + // First DATA send goes through the busdisplay so it raises DC. + display->bus.send(display->bus.bus, DISPLAY_DATA, + CHIP_SELECT_UNTOUCHED, (uint8_t *)buf, nbytes); + first = false; + } else if ((nch = queue_strip(spi, trans[cur], buf, nbytes)) >= 0) { + inflight[cur] = nch; + } else { + // Strip too large to keep two in flight: drain everything and send + // it blocking (DC already high, CS untouched). + drain(spi, &inflight[cur ^ 1]); + display->bus.send(display->bus.bus, DISPLAY_DATA, + CHIP_SELECT_UNTOUCHED, (uint8_t *)buf, nbytes); + } + cur ^= 1; + if (pending != MP_OBJ_NULL) { // callback interrupted: this strip is queued, now stop + flush + break; + } + } + + drain(spi, &inflight[0]); + drain(spi, &inflight[1]); + + displayio_display_bus_end_transaction(&display->bus); + + if (pending != MP_OBJ_NULL) { // bus now closed -> safe to re-raise (Ctrl-C / reload) + nlr_raise(MP_OBJ_TO_PTR(pending)); + } +} diff --git a/ports/espressif/common-hal/picogame/Display.h b/ports/espressif/common-hal/picogame/Display.h new file mode 100644 index 00000000000..b4487e5ee55 --- /dev/null +++ b/ports/espressif/common-hal/picogame/Display.h @@ -0,0 +1,33 @@ +// 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 (espressif): wraps an existing busdisplay and streams +// pixels with the esp-idf SPI master's queued DMA, double-buffered so the CPU +// blits the next strip while the current one transfers. Reuses the busdisplay's +// SPI device, window opcodes and dimensions -- controller/resolution agnostic. + +#pragma once + +#include "py/obj.h" +#include "driver/spi_master.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; + spi_device_handle_t spi; // the busdisplay's SPI device (raw DMA queueing) + bool rgb444; // RGB444 strip packing (not yet implemented on this backend) +} 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); diff --git a/ports/raspberrypi/Makefile b/ports/raspberrypi/Makefile index 603121e059f..1c13d538e51 100755 --- a/ports/raspberrypi/Makefile +++ b/ports/raspberrypi/Makefile @@ -507,6 +507,13 @@ endif SRC_C += shared/runtime/gchelper_native.c +ifeq ($(CIRCUITPY_PICOGAME),1) +# SIO interpolator fast path for the picogame mode7 inner row (RP2040 + RP2350). +# Portable C remains the fallback on other ports. +SRC_C += common-hal/picogame/interp.c +CFLAGS += -DPICOGAME_HAS_INTERP=1 +endif + SRC_SDK := \ src/common/hardware_claim/claim.c \ src/common/pico_sync/critical_section.c \ diff --git a/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk b/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk index 086c823adb4..8c46cc0713b 100644 --- a/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk +++ b/ports/raspberrypi/boards/adafruit_fruit_jam/mpconfigboard.mk @@ -11,5 +11,8 @@ EXTERNAL_FLASH_DEVICES = "W25Q128JVxQ" CIRCUITPY_SDIOIO = 1 +CIRCUITPY_PICOGAME = 1 +CIRCUITPY_PICOGAME_FRAMEBUFFER = 1 + # CIRCUITPY_DISPLAY_FONT = $(TOP)/tools/fonts/unifont-16.0.02-all.bdf # CIRCUITPY_FONT_EXTRA_CHARACTERS = "🖮🖱️" diff --git a/ports/raspberrypi/boards/pajenicko_picopad/mpconfigboard.mk b/ports/raspberrypi/boards/pajenicko_picopad/mpconfigboard.mk index 8a364e4b840..b5df0e6ecb5 100644 --- a/ports/raspberrypi/boards/pajenicko_picopad/mpconfigboard.mk +++ b/ports/raspberrypi/boards/pajenicko_picopad/mpconfigboard.mk @@ -13,10 +13,17 @@ CIRCUITPY_USB_HOST = 0 CIRCUITPY_KEYPAD = 1 CIRCUITPY_STAGE = 1 +CIRCUITPY_PICOGAME = 1 +CIRCUITPY_PICOGAME_FAST_DISPLAY = 1 +CIRCUITPY_PICOGAME_RGB444 = 1 CIRCUITPY_AUDIOIO = 1 CIRCUITPY_AUDIOEFFECTS = 0 -CIRCUITPY__EVE = 1 +# Peripherals this board physically lacks: no FT8xx EVE display, no camera for the +# qrio QR *decoder* (QR generation = pure-Python adafruit_miniqr, unaffected), and no +# DVI/HDMI connector. Dropping them frees flash for the picogame engine. +CIRCUITPY__EVE = 0 +CIRCUITPY_QRIO = 0 CIRCUITPY_CYW43 = 1 CIRCUITPY_SSL = 1 @@ -26,7 +33,6 @@ CIRCUITPY_MDNS = 1 CIRCUITPY_SOCKETPOOL = 1 CIRCUITPY_WIFI = 1 -CIRCUITPY_PICODVI = 1 # Pimoroni PicoSystem peripherals are compatible, we can use of existing ugame.py FROZEN_MPY_DIRS += $(TOP)/frozen/circuitpython-stage/picosystem @@ -44,3 +50,8 @@ CFLAGS += \ # Must be accompanied by a linker script change CFLAGS += -DCIRCUITPY_FIRMWARE_SIZE='(1536 * 1024)' + +# The rp2 port default is -O3; on this Cortex-M0+ (no SIMD/FPU, 16 KB XIP cache) -O2 plus +# these five loop passes measures within +-1% of -O3 across the picogame render kernels +# while using ~150 KB less flash (gc.o/vm.o stay -O3 via SUPEROPT regardless). +OPTIMIZATION_FLAGS = -O2 -funswitch-loops -fpredictive-commoning -fgcse-after-reload -ftree-partial-pre -fsplit-paths diff --git a/ports/raspberrypi/common-hal/picogame/Display.c b/ports/raspberrypi/common-hal/picogame/Display.c new file mode 100644 index 00000000000..9c0fcd17011 --- /dev/null +++ b/ports/raspberrypi/common-hal/picogame/Display.c @@ -0,0 +1,165 @@ +// 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 "hardware/spi.h" +#include "hardware/dma.h" + +// Claimed once and REUSED across every Display construct (incl. across soft resets / each game +// launched via supervisor.set_next_code_file). We claim via the raw pico-sdk, which CircuitPython +// does NOT release on a soft reset -- so claiming per-construct leaked a channel every game until +// "*** PANIC *** No DMA channels available" (and starved board.DISPLAY's own DMA, halving FPS). +// A C static survives a soft reset, so we remember and reuse our one channel; a hard reset frees it. +static int s_picogame_dma_chan = -1; + +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 raw SPI access; 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->spi = fw->bus->peripheral; + + #if CIRCUITPY_PICOGAME_RGB444 + // Tell the panel which pixel format we'll send (COLMOD). Asserting it here also recovers from + // a previous program that left the panel in the other format (the setting survives soft reset). + picogame_set_pixel_format(display, rgb444); + #endif + + if (s_picogame_dma_chan < 0) { + s_picogame_dma_chan = dma_claim_unused_channel(true); + } + self->dma_chan = s_picogame_dma_chan; + + // Configure the channel ONCE (dreq, 8-bit, read-incr, write addr = SPI data reg). Per-strip we + // then only set the read address + transfer count and trigger -- no per-strip reconfiguration. + dma_channel_config c = dma_channel_get_default_config(self->dma_chan); + channel_config_set_transfer_data_size(&c, DMA_SIZE_8); + channel_config_set_dreq(&c, spi_get_dreq((spi_inst_t *)self->spi, true)); + channel_config_set_read_increment(&c, true); + channel_config_set_write_increment(&c, false); + dma_channel_configure(self->dma_chan, &c, + &spi_get_hw((spi_inst_t *)self->spi)->dr, NULL, 0, false); +} + +// Kick off an asynchronous TX-only DMA of `nbytes` from `buf` to the SPI FIFO (channel already +// configured in construct; just point it at `buf`, set the count, and trigger). +static void dma_start(int chan, const uint16_t *buf, size_t nbytes) { + dma_channel_set_read_addr(chan, buf, false); + dma_channel_set_trans_count(chan, nbytes, true); // true = trigger +} + +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; + spi_inst_t *spi = (spi_inst_t *)self->spi; + + // RGB444 packs 2 px into 3 bytes, so each strip row must be an even number of pixels + // (whole bytes). Widen the region to even bounds; the extra <=1 px per side just repaints. + #if CIRCUITPY_PICOGAME_RGB444 + if (self->rgb444) { + x0 &= ~1; + x1 = (x1 + 1) & ~1; + if (x1 > display->core.width) { + x1 = display->core.width; + } + } + #endif + + // Compute geometry + open the GRAM window (set region, begin transaction, + // RAMWR). DC stays high for data after the first DATA send below, so the + // raw-DMA strips that follow need no DC toggling. + int region_w, strip_h; + int cx0 = x0, cy0 = y0, cx1 = x1, cy1 = y1; // strip_begin clamps these to the panel in place + 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; + bool dma_inflight = false; + #if CIRCUITPY_PICOGAME_RGB444 + const bool rgb444 = self->rgb444; // hoist: invariant across all strips + #endif + + // A StripDraw callback may latch a BaseException (Ctrl-C / auto-reload). Like the portable + // picogame_render_region, we must re-raise it -- but only AFTER the in-flight DMA finishes and + // the bus transaction closes, so hold it here and propagate below (see the end of the function). + 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]; + + // Blit this strip. When a DMA is in flight it transfers the *other* + // buffer, so this CPU work overlaps the SPI transfer. + pending = picogame_blit_strip_layers(buf, region_w, sy, sh, cx0, items, kinds, n, background, ox, oy); + + // RGB444: pack the just-blitted RGB565 strip in place (2 px -> 3 bytes) before sending. + // The pack overlaps the previous strip's DMA (we're transfer-bound), so it's ~free. + #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 (first) { + // First strip goes through busdisplay: it drives DC high for data, + // which then stays high for the subsequent raw-DMA strips. + display->bus.send(display->bus.bus, DISPLAY_DATA, + CHIP_SELECT_UNTOUCHED, (uint8_t *)buf, nbytes); + first = false; + } else { + if (dma_inflight) { + dma_channel_wait_for_finish_blocking(self->dma_chan); + } + dma_start(self->dma_chan, buf, nbytes); + dma_inflight = true; + } + cur ^= 1; + if (pending != MP_OBJ_NULL) { // callback interrupted: this strip is queued, now stop + flush + break; + } + } + + if (dma_inflight) { + dma_channel_wait_for_finish_blocking(self->dma_chan); + } + while (spi_is_busy(spi)) { + // wait for the last bytes to leave the shift register before releasing CS + } + displayio_display_bus_end_transaction(&display->bus); + + if (pending != MP_OBJ_NULL) { // bus now closed -> safe to re-raise (Ctrl-C / reload) + nlr_raise(MP_OBJ_TO_PTR(pending)); + } +} diff --git a/ports/raspberrypi/common-hal/picogame/Display.h b/ports/raspberrypi/common-hal/picogame/Display.h new file mode 100644 index 00000000000..aaca229260f --- /dev/null +++ b/ports/raspberrypi/common-hal/picogame/Display.h @@ -0,0 +1,33 @@ +// 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 (RP2040): wraps an existing busdisplay and pushes pixels +// with asynchronous, double-buffered DMA so the CPU can blit the next strip +// while the current strip transfers over SPI. Reuses the busdisplay's SPI +// peripheral, window opcodes and dimensions - controller/resolution agnostic. + +#pragma once + +#include "py/obj.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; + void *spi; // spi_inst_t* (kept opaque to avoid pico-sdk in this header) + int dma_chan; + bool rgb444; // pack strips to 12-bit RGB444 before sending (~25% less SPI traffic) +} 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); diff --git a/ports/raspberrypi/common-hal/picogame/interp.c b/ports/raspberrypi/common-hal/picogame/interp.c new file mode 100644 index 00000000000..78cc8581cbb --- /dev/null +++ b/ports/raspberrypi/common-hal/picogame/interp.c @@ -0,0 +1,54 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +// Mode-7 inner row via the SIO INTERPOLATOR (RP2040 + RP2350). The interpolator does the +// whole per-pixel texture walk in hardware: lane0 = (fx >> shx) & (tw-1), lane1 = +// ((fy >> shy) & (th-1)) << log2(tw), POP_FULL = texture_base + lane0 + lane1 = the texel +// ADDRESS, and (ADD_RAW) both accumulators advance by their full-precision steps on the +// same pop. That replaces ~8 shift/mask/mul/add ops per pixel with one SIO read. +// +// Per-CORE hardware: each core has its own interp0, so this stays safe even if rows are +// ever split across cores (each configures its own). The engine claims no SDK +// lane locks: CircuitPython core does not use the interpolators, and the config is +// rewritten per row call anyway. +// +// Fast path constraints (the caller guards): PAL8 texture, no transparency, stride == tw, +// log2(tw)+log2(th) <= 16 (mode7 textures are 64..256 pow2 - always true there). + +#include "py/mpconfig.h" + +#if CIRCUITPY_PICOGAME + +#include +#include "hardware/interp.h" +#include "shared-module/picogame/__init__.h" // the prototype (PICOGAME_HAS_INTERP) + +void picogame_mode7_row_interp(uint16_t *dst, int n, + const uint8_t *tex, const uint16_t *pal, + uint32_t fx, uint32_t fy, int32_t stepx, int32_t stepy, + int shx, int shy, int ltw, int lth) { + interp_config c0 = interp_default_config(); + interp_config_set_shift(&c0, (uint)shx); + interp_config_set_mask(&c0, 0, (uint)(ltw - 1)); + interp_config_set_add_raw(&c0, true); // accumulator advances by the RAW step + interp_set_config(interp0, 0, &c0); + interp_config c1 = interp_default_config(); + interp_config_set_shift(&c1, (uint)(shy - ltw)); + interp_config_set_mask(&c1, (uint)ltw, (uint)(ltw + lth - 1)); + interp_config_set_add_raw(&c1, true); + interp_set_config(interp0, 1, &c1); + interp0->base[0] = (uint32_t)stepx; + interp0->base[1] = (uint32_t)stepy; + interp0->base[2] = (uint32_t)(uintptr_t)tex; + interp0->accum[0] = fx; + interp0->accum[1] = fy; + for (int i = 0; i < n; i++) { + const uint8_t *p = (const uint8_t *)interp0->pop[2]; + dst[i] = pal[*p]; + } +} + +#endif diff --git a/py/circuitpy_defns.mk b/py/circuitpy_defns.mk index b50ab1ee633..bf24b737c19 100755 --- a/py/circuitpy_defns.mk +++ b/py/circuitpy_defns.mk @@ -405,6 +405,9 @@ endif ifeq ($(CIRCUITPY_STAGE),1) SRC_PATTERNS += _stage/% endif +ifeq ($(CIRCUITPY_PICOGAME),1) +SRC_PATTERNS += picogame/% +endif ifeq ($(CIRCUITPY_STORAGE),1) SRC_PATTERNS += storage/% endif @@ -606,6 +609,12 @@ SRC_COMMON_HAL_ALL = \ wifi/ScannedNetworks.c \ wifi/__init__.c \ +# The fast Display backend is the only common-hal picogame source; include it +# only on ports that provide it (others use the portable bus.send renderer). +ifeq ($(CIRCUITPY_PICOGAME_FAST_DISPLAY),1) +SRC_COMMON_HAL_ALL += picogame/Display.c +endif + SRC_COMMON_HAL = $(filter $(SRC_PATTERNS), $(SRC_COMMON_HAL_ALL)) ifeq ($(CIRCUITPY_BLEIO_HCI),1) @@ -704,6 +713,11 @@ SRC_SHARED_MODULE_ALL = \ _stage/Layer.c \ _stage/Text.c \ _stage/__init__.c \ + picogame/__init__.c \ + picogame/Scene.c \ + picogame/Tilemap.c \ + picogame/Particles.c \ + picogame/Canvas.c \ aesio/__init__.c \ aesio/aes.c \ atexit/__init__.c \ diff --git a/py/circuitpy_mpconfig.mk b/py/circuitpy_mpconfig.mk index e4e01fcecc8..855d40de40e 100755 --- a/py/circuitpy_mpconfig.mk +++ b/py/circuitpy_mpconfig.mk @@ -583,6 +583,28 @@ CFLAGS += -DCIRCUITPY_SSL_MBEDTLS=$(CIRCUITPY_SSL_MBEDTLS) CIRCUITPY_STAGE ?= 0 CFLAGS += -DCIRCUITPY_STAGE=$(CIRCUITPY_STAGE) +# PicoPad 2D game engine (off by default). +CIRCUITPY_PICOGAME ?= 0 +CFLAGS += -DCIRCUITPY_PICOGAME=$(CIRCUITPY_PICOGAME) +# Fast async-DMA Display backend: needs a port-specific common-hal (the raspberrypi and +# espressif ports provide one). Boards without it use the portable bus.send renderer. +CIRCUITPY_PICOGAME_FAST_DISPLAY ?= 0 +CFLAGS += -DCIRCUITPY_PICOGAME_FAST_DISPLAY=$(CIRCUITPY_PICOGAME_FAST_DISPLAY) +# Does this board's panel controller support 12-bit RGB444 (COLMOD)? A capability flag the +# board declares (ST7789/ST7735 = 1, ILI9341 = 0); exposed as picogame.RGB444_SUPPORTED so +# a game can enable Display(rgb444=...) only where it works. Default 0 (safe RGB565). +CIRCUITPY_PICOGAME_RGB444 ?= 0 +CFLAGS += -DCIRCUITPY_PICOGAME_RGB444=$(CIRCUITPY_PICOGAME_RGB444) +# Full-frame RAM-framebuffer render backend for scanout-buffer platforms (RP2350 DVI/HSTX +# boards like the Fruit Jam). Off by default so flash-tight SPI-only boards don't carry it. +CIRCUITPY_PICOGAME_FRAMEBUFFER ?= 0 +CFLAGS += -DCIRCUITPY_PICOGAME_FRAMEBUFFER=$(CIRCUITPY_PICOGAME_FRAMEBUFFER) +# Float vs 16.16 fixed path for the 3D helpers: the default follows the architecture +# (see shared-module/picogame/__init__.h); set 0/1 here only to override for a board. +ifneq ($(CIRCUITPY_PICOGAME_FPU),) +CFLAGS += -DCIRCUITPY_PICOGAME_FPU=$(CIRCUITPY_PICOGAME_FPU) +endif + CIRCUITPY_STATUS_BAR ?= 1 CFLAGS += -DCIRCUITPY_STATUS_BAR=$(CIRCUITPY_STATUS_BAR) diff --git a/shared-bindings/picogame/Bitmap.h b/shared-bindings/picogame/Bitmap.h new file mode 100644 index 00000000000..44e2a2fb1aa --- /dev/null +++ b/shared-bindings/picogame/Bitmap.h @@ -0,0 +1,11 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "shared-module/picogame/Bitmap.h" + +extern const mp_obj_type_t picogame_bitmap_type; diff --git a/shared-bindings/picogame/Canvas.c b/shared-bindings/picogame/Canvas.c new file mode 100644 index 00000000000..eaad4232901 --- /dev/null +++ b/shared-bindings/picogame/Canvas.c @@ -0,0 +1,541 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#include "py/runtime.h" +#include "shared-module/picogame/pg_compat.h" +#include "shared-bindings/picogame/Canvas.h" +#include "shared-bindings/picogame/Bitmap.h" +#include "shared-bindings/fontio/BuiltinFont.h" +#include "shared-module/picogame/Canvas.h" + +//| class Canvas: +//| """A RAM drawing surface (any size) composited as a Scene layer. Draw +//| primitives into it; only redrawn areas repaint. Colors are wire-order +//| (use picogame.rgb565).""" +//| +//| def __init__( +//| self, +//| width: int, +//| height: int, +//| *, +//| transparent: Optional[int] = None, +//| buffer: Optional[WriteableBuffer] = None, +//| ) -> None: +//| """If ``buffer`` is given (>= width*height*2 bytes, e.g. a memoryview from +//| picogame_arena), the Canvas draws into it instead of allocating its own - +//| lets you pre-allocate big surfaces once and dodge heap fragmentation.""" +//| ... +//| +static mp_obj_t picogame_canvas_make_new(const mp_obj_type_t *type, size_t n_args, + size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_width, ARG_height, ARG_transparent, ARG_buffer }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_width, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_height, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_transparent, MP_ARG_KW_ONLY | MP_ARG_OBJ, {.u_obj = mp_const_none} }, + { MP_QSTR_buffer, MP_ARG_KW_ONLY | MP_ARG_OBJ, {.u_obj = mp_const_none} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + mp_int_t w = mp_arg_validate_int_range(args[ARG_width].u_int, 1, 1024, MP_QSTR_width); + mp_int_t h = mp_arg_validate_int_range(args[ARG_height].u_int, 1, 1024, MP_QSTR_height); + + picogame_canvas_obj_t *self = mp_obj_malloc(picogame_canvas_obj_t, type); + self->w = w; + self->h = h; + self->x = 0; + self->y = 0; + if (args[ARG_buffer].u_obj != mp_const_none) { + // external buffer (e.g. an arena slice) - draw into it, don't allocate/own it + mp_buffer_info_t bi; + mp_get_buffer_raise(args[ARG_buffer].u_obj, &bi, MP_BUFFER_RW); + if (bi.len < (size_t)w * h * 2) { + mp_raise_ValueError(MP_ERROR_TEXT("buffer too small")); + } + if ((uintptr_t)bi.buf & 1) { // odd byte address -> uint16 pixel stores fault on Cortex-M0+ + mp_raise_ValueError_varg(MP_ERROR_TEXT("Buffer must be a multiple of %d bytes"), 2); + } + self->data = bi.buf; + self->data_obj = args[ARG_buffer].u_obj; // keep the backing object alive (GC-traced) + } else { + // Pure pixel data, no Python pointers -> exempt from the conservative GC scan + // (shorter gc.collect() pauses; a 150x60 canvas is 18 KB the mark phase can skip). + self->data = m_malloc_without_collect((size_t)w * h * sizeof(uint16_t)); + self->data_obj = MP_OBJ_NULL; + } + if (args[ARG_transparent].u_obj != mp_const_none) { + self->transparent = mp_obj_get_int(args[ARG_transparent].u_obj); + self->has_transparent = true; + } else { + self->transparent = 0; + self->has_transparent = false; + } + uint16_t fill = self->has_transparent ? self->transparent : 0; + for (size_t i = 0; i < (size_t)w * h; i++) { + self->data[i] = fill; + } + picogame_canvas_dirty_reset(self); + return MP_OBJ_FROM_PTR(self); +} + +static picogame_canvas_obj_t *cv_self(mp_obj_t o) { + return MP_OBJ_TO_PTR(o); +} + +// Shared int-arg unpacker for the plain drawing trampolines below: every one of them is +// "self + N ints -> void", and the inlined per-wrapper mp_obj_get_int runs cost ~70-120 B +// each at -Os. One loop here + a tiny per-wrapper stub keeps the flash cost per method at +// ~2 calls. n comes from the VAR_BETWEEN exact arity, so v[] is always fully written. +static picogame_canvas_obj_t *cv_args(const mp_obj_t *a, size_t n, int *v) { + for (size_t i = 1; i < n; i++) { + v[i - 1] = mp_obj_get_int(a[i]); + } + return cv_self(a[0]); +} + +//| def clear(self, color: int) -> None: ... +//| +static mp_obj_t canvas_clear(mp_obj_t self_in, mp_obj_t color) { + picogame_canvas_clear(cv_self(self_in), mp_obj_get_int(color)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(canvas_clear_obj, canvas_clear); + +//| +//| def pixel(self, x: int, y: int, color: int) -> None: ... +//| +static mp_obj_t canvas_pixel(size_t n, const mp_obj_t *a) { + int v[3]; + picogame_canvas_pixel(cv_args(a, n, v), v[0], v[1], v[2]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_pixel_obj, 4, 4, canvas_pixel); + +//| +//| def fill_rect(self, x: int, y: int, w: int, h: int, color: int) -> None: ... +//| +static mp_obj_t canvas_fill_rect(size_t n, const mp_obj_t *a) { + int v[5]; + picogame_canvas_fill_rect(cv_args(a, n, v), v[0], v[1], v[2], v[3], v[4]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_fill_rect_obj, 6, 6, canvas_fill_rect); + +//| +//| def blit( +//| self, +//| bitmap: Bitmap, +//| x: int, +//| y: int, +//| frame: int = 0, +//| flip_x: bool = False, +//| flip_y: bool = False, +//| ) -> None: +//| """Stamp frame ``frame`` of ``bitmap`` into the canvas at (x, y), honouring its transparent +//| key. The retained way to bake an image (icon, portrait, rendered text) into a panel.""" +//| ... +//| +static mp_obj_t canvas_blit(size_t n, const mp_obj_t *a) { + picogame_bitmap_obj_t *bm = MP_OBJ_TO_PTR( + mp_arg_validate_type(a[1], &picogame_bitmap_type, MP_QSTR_bitmap)); + // Same domain as Sprite.frame (uint8): a negative frame would survive the blitter's + // wrap (C % keeps the sign) and read before the sheet data. + int frame = (n > 4) ? (int)mp_arg_validate_int_range(mp_obj_get_int(a[4]), 0, 255, MP_QSTR_frame) : 0; + bool fx = (n > 5) ? mp_obj_is_true(a[5]) : false; + bool fy = (n > 6) ? mp_obj_is_true(a[6]) : false; + picogame_canvas_blit(cv_self(a[0]), bm, mp_obj_get_int(a[2]), mp_obj_get_int(a[3]), frame, fx, fy); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_blit_obj, 4, 7, canvas_blit); + +//| def mode7( +//| self, +//| texture: Bitmap, +//| horizon: int, +//| y_off: int, +//| z: int, +//| rx0: int, +//| ry0: int, +//| rsx: int, +//| rsy: int, +//| cam_x: int, +//| cam_y: int, +//| ) -> None: +//| """Fill rows below ``horizon`` with a perspective ground plane (Mode-7) of +//| ``texture`` (power-of-2 dims). The int args are 16.16 fixed-point camera +//| terms; use the picogame_mode7 helper to compute them from angle/pos/fov.""" +//| ... +//| +static mp_obj_t canvas_mode7(size_t n, const mp_obj_t *a) { + picogame_bitmap_obj_t *tex = MP_OBJ_TO_PTR( + mp_arg_validate_type(a[1], &picogame_bitmap_type, MP_QSTR_texture)); + picogame_canvas_mode7(cv_self(a[0]), tex, + mp_obj_get_int(a[2]), mp_obj_get_int(a[3]), mp_obj_get_int(a[4]), + mp_obj_get_int(a[5]), mp_obj_get_int(a[6]), mp_obj_get_int(a[7]), + mp_obj_get_int(a[8]), mp_obj_get_int(a[9]), mp_obj_get_int(a[10])); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_mode7_obj, 11, 11, canvas_mode7); + +//| def fill_triangles( +//| self, +//| verts: ReadableBuffer, +//| colors: ReadableBuffer, +//| n: int, +//| x_off: int = 0, +//| y_off: int = 0, +//| ) -> None: +//| """Fill ``n`` triangles in ONE call: ``verts`` = int16 x0,y0,x1,y1,x2,y2 per triangle, +//| ``colors`` = uint16 wire RGB565 per triangle. Same rasteriser as fill_triangle, but the +//| whole batch crosses the Python/C boundary once - the win for many small triangles +//| (blocky 3D, low-poly meshes) where the ~10 us per-call overhead otherwise dominates. +//| ``x_off``/``y_off`` translate every vertex before clipping - pass the negated strip +//| origin (``y_off=-vy``) to replay one screen-space batch into each StripDraw view; +//| triangles fully outside the band are rejected with three compares, so the +//| per-strip re-submission stays cheap.""" +//| ... +//| +static mp_obj_t canvas_fill_triangles(size_t na, const mp_obj_t *a) { + picogame_canvas_obj_t *cv = cv_self(a[0]); + mp_buffer_info_t vi, ci; + mp_get_buffer_raise(a[1], &vi, MP_BUFFER_READ); + mp_get_buffer_raise(a[2], &ci, MP_BUFFER_READ); + int n = mp_obj_get_int(a[3]); + int xo = na > 4 ? mp_obj_get_int(a[4]) : 0; + int yo = na > 5 ? mp_obj_get_int(a[5]) : 0; + const int16_t *v = vi.buf; + const uint16_t *col = ci.buf; + int cap_v = (int)(vi.len / 12); // 6 int16 = 12 bytes per triangle + int cap_c = (int)(ci.len >> 1); + if (n > cap_v) { + n = cap_v; + } + if (n > cap_c) { + n = cap_c; + } + picogame_fill_triangle_batch(cv, v, col, n, xo, yo); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_fill_triangles_obj, 4, 6, canvas_fill_triangles); + +//| def vspans( +//| self, +//| x0s: ReadableBuffer, +//| x1s: ReadableBuffer, +//| tops: ReadableBuffer, +//| bots: ReadableBuffer, +//| colors: ReadableBuffer, +//| n: int, +//| x_off: int = 0, +//| y_off: int = 0, +//| ) -> None: +//| """Fill ``n`` vertical colour spans in ONE call: span i covers x0s[i]..x1s[i] (exclusive) +//| by tops[i]..bots[i] (exclusive) in colour colors[i] - all five are uint16 arrays. +//| The batch primitive for column renderers (a raycaster's merged wall runs): the whole +//| span list crosses the Python/C boundary once per strip instead of once per span. +//| ``x_off``/``y_off`` translate every span before clipping - pass the negated strip origin +//| (x_off=-vx, y_off=-vy) to replay one screen-space batch into each StripDraw view; +//| spans outside the band are rejected with two compares.""" +//| ... +//| +static mp_obj_t canvas_vspans(size_t na, const mp_obj_t *a) { + picogame_canvas_obj_t *cv = cv_self(a[0]); + // five equal-shape uint16 arrays in a row: fetch + shortest-length fold in one loop + mp_buffer_info_t bi5[5]; + size_t cap = (size_t)-1; + for (int i = 0; i < 5; i++) { + mp_get_buffer_raise(a[1 + i], &bi5[i], MP_BUFFER_READ); + if (bi5[i].len < cap) { + cap = bi5[i].len; + } + } + int n = mp_obj_get_int(a[6]); + int xo = na > 7 ? mp_obj_get_int(a[7]) : 0; + int yo = na > 8 ? mp_obj_get_int(a[8]) : 0; + if (n > (int)(cap >> 1)) { + n = (int)(cap >> 1); // never read past the shortest array + } + const uint16_t *x0s = bi5[0].buf; + const uint16_t *x1s = bi5[1].buf; + const uint16_t *tops = bi5[2].buf; + const uint16_t *bots = bi5[3].buf; + const uint16_t *cols = bi5[4].buf; + int cw = cv->w, ch = cv->h; + for (int i = 0; i < n; i++) { + int t = tops[i] + yo, b = bots[i] + yo; + if (b <= 0 || t >= ch || b <= t) { + continue; // span outside this band + } + int x0 = x0s[i] + xo, x1 = x1s[i] + xo; + if (x1 <= 0 || x0 >= cw || x1 <= x0) { + continue; + } + picogame_canvas_fill_rect(cv, x0, t, x1 - x0, b - t, cols[i]); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_vspans_obj, 7, 9, canvas_vspans); + +//| def road( +//| self, +//| ri0: int, +//| tab: ReadableBuffer, +//| rl: ReadableBuffer, +//| rr: ReadableBuffer, +//| d05_q8: int, +//| d07_q8: int, +//| colors: ReadableBuffer, +//| ) -> None: +//| """Draw one racing-road strip (OutRun-style) from precomputed tables - the whole +//| per-scanline loop in one call. ri0 = road-table row of this surface's row 0 (may be +//| negative = sky rows). tab = int16 rows of {edge_w, dash_hw, wb05_q8, wb07_q8, flags}; +//| rl/rr = int16 per-row edges (see picogame.road_edges); d05/d07 = Q8 scroll phases; +//| colors = 6x uint16 {sky, road_a, road_b, rumble_a, rumble_b, dash}.""" +//| ... +//| +static mp_obj_t canvas_road(size_t n, const mp_obj_t *a) { + mp_buffer_info_t tabi, rli, rri, coli; + mp_get_buffer_raise(a[2], &tabi, MP_BUFFER_READ); + mp_get_buffer_raise(a[3], &rli, MP_BUFFER_READ); + mp_get_buffer_raise(a[4], &rri, MP_BUFFER_READ); + mp_get_buffer_raise(a[7], &coli, MP_BUFFER_READ); + int ntab = (int)(tabi.len / (5 * 2)); + int nedge = (int)(rli.len < rri.len ? rli.len : rri.len) / 2; + if (nedge < ntab) { + ntab = nedge; // never read past the shorter per-frame arrays + } + if (ntab <= 0 || coli.len < 6 * 2) { + return mp_const_none; + } + picogame_canvas_road(cv_self(a[0]), mp_obj_get_int(a[1]), + (const int16_t *)tabi.buf, ntab, (const int16_t *)rli.buf, (const int16_t *)rri.buf, + mp_obj_get_int(a[5]), mp_obj_get_int(a[6]), (const uint16_t *)coli.buf); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_road_obj, 8, 8, canvas_road); + +//| def rect(self, x: int, y: int, w: int, h: int, color: int) -> None: ... +//| +static mp_obj_t canvas_rect(size_t n, const mp_obj_t *a) { + int v[5]; + picogame_canvas_rect(cv_args(a, n, v), v[0], v[1], v[2], v[3], v[4]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_rect_obj, 6, 6, canvas_rect); + +//| +//| def line(self, x0: int, y0: int, x1: int, y1: int, color: int) -> None: ... +//| +static mp_obj_t canvas_line(size_t n, const mp_obj_t *a) { + int v[5]; + picogame_canvas_line(cv_args(a, n, v), v[0], v[1], v[2], v[3], v[4]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_line_obj, 6, 6, canvas_line); + +//| +//| def fill_circle(self, cx: int, cy: int, r: int, color: int) -> None: ... +//| +static mp_obj_t canvas_fill_circle(size_t n, const mp_obj_t *a) { + int v[4]; + picogame_canvas_fill_circle(cv_args(a, n, v), v[0], v[1], v[2], v[3]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_fill_circle_obj, 5, 5, canvas_fill_circle); + +//| +//| def circle(self, cx: int, cy: int, r: int, color: int) -> None: ... +//| +static mp_obj_t canvas_circle(size_t n, const mp_obj_t *a) { + int v[4]; + picogame_canvas_circle(cv_args(a, n, v), v[0], v[1], v[2], v[3]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_circle_obj, 5, 5, canvas_circle); + +//| +//| def ring(self, cx: int, cy: int, r: int, thickness: int, color: int) -> None: ... +//| +static mp_obj_t canvas_ring(size_t n, const mp_obj_t *a) { + int v[5]; + picogame_canvas_ring(cv_args(a, n, v), v[0], v[1], v[2], v[3], v[4]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_ring_obj, 6, 6, canvas_ring); + +//| +//| def triangle( +//| self, x0: int, y0: int, x1: int, y1: int, x2: int, y2: int, color: int +//| ) -> None: ... +//| +static mp_obj_t canvas_triangle(size_t n, const mp_obj_t *a) { + int v[7]; + picogame_canvas_triangle(cv_args(a, n, v), v[0], v[1], v[2], v[3], v[4], v[5], v[6]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_triangle_obj, 8, 8, canvas_triangle); + +//| +//| def fill_triangle( +//| self, x0: int, y0: int, x1: int, y1: int, x2: int, y2: int, color: int +//| ) -> None: ... +//| +static mp_obj_t canvas_fill_triangle(size_t n, const mp_obj_t *a) { + int v[7]; + picogame_canvas_fill_triangle(cv_args(a, n, v), v[0], v[1], v[2], v[3], v[4], v[5], v[6]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_fill_triangle_obj, 8, 8, canvas_fill_triangle); + +//| +//| def ellipse(self, cx: int, cy: int, rx: int, ry: int, color: int) -> None: ... +//| +static mp_obj_t canvas_ellipse(size_t n, const mp_obj_t *a) { + int v[5]; + picogame_canvas_ellipse(cv_args(a, n, v), v[0], v[1], v[2], v[3], v[4]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_ellipse_obj, 6, 6, canvas_ellipse); + +//| +//| def fill_ellipse(self, cx: int, cy: int, rx: int, ry: int, color: int) -> None: ... +//| +static mp_obj_t canvas_fill_ellipse(size_t n, const mp_obj_t *a) { + int v[5]; + picogame_canvas_fill_ellipse(cv_args(a, n, v), v[0], v[1], v[2], v[3], v[4]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_fill_ellipse_obj, 6, 6, canvas_fill_ellipse); + +//| +//| def fill_round_rect(self, x: int, y: int, w: int, h: int, r: int, color: int) -> None: ... +//| +static mp_obj_t canvas_fill_round_rect(size_t n, const mp_obj_t *a) { + int v[6]; + picogame_canvas_fill_round_rect(cv_args(a, n, v), v[0], v[1], v[2], v[3], v[4], v[5]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_fill_round_rect_obj, 7, 7, canvas_fill_round_rect); + +//| +//| def frame3d(self, x: int, y: int, w: int, h: int, light: int, dark: int) -> None: ... +//| +static mp_obj_t canvas_frame3d(size_t n, const mp_obj_t *a) { + int v[6]; + picogame_canvas_frame3d(cv_args(a, n, v), v[0], v[1], v[2], v[3], v[4], v[5]); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_frame3d_obj, 7, 7, canvas_frame3d); + +//| +//| def text( +//| self, x: int, y: int, s: str, fg: int, font: fontio.BuiltinFont, bg: int | None = None +//| ) -> None: +//| """Composite ``s`` into the surface in C, rasterizing each glyph from ``font`` on the fly - +//| no Python glyph cache, no per-call Bitmap/Sprite (zero retained text RAM, no fragmentation). +//| If ``bg`` is given the glyph background is filled too; otherwise it is transparent. Inside a +//| StripDraw callback the ``view`` is a Canvas pointing at the live strip, so ``view.text(...)`` +//| draws immediate-mode HUD/screen text straight into the frame.""" +//| +static mp_obj_t canvas_text(size_t n, const mp_obj_t *a) { + 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)); + bool has_bg = (n >= 7) && (a[6] != mp_const_none); + uint16_t bg = has_bg ? (uint16_t)mp_obj_get_int(a[6]) : 0; + 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; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(canvas_text_obj, 6, 7, canvas_text); + +//| def move(self, x: int, y: int) -> None: ... +//| +static mp_obj_t canvas_move(mp_obj_t self_in, mp_obj_t x_in, mp_obj_t y_in) { + picogame_canvas_obj_t *self = cv_self(self_in); + int nx = mp_obj_get_int(x_in), ny = mp_obj_get_int(y_in); + if (nx == self->x && ny == self->y) { + return mp_const_none; // unchanged -> avoid an avoidable repaint + } + // dirty old + new extents so the move repaints both + picogame_canvas_dirty_union(self, self->x, self->y, self->x + self->w, self->y + self->h); + self->x = nx; + self->y = ny; + picogame_canvas_dirty_union(self, self->x, self->y, self->x + self->w, self->y + self->h); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_3(canvas_move_obj, canvas_move); + +//| +//| x: int +//| y: int +//| """Current pixel position of the canvas top-left (read-only; set with move()).""" +//| width: int +//| height: int +//| """Surface size in pixels (read-only).""" +//| +//| +static mp_obj_t canvas_get_x(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(cv_self(self_in)->x); +} +static MP_DEFINE_CONST_FUN_OBJ_1(canvas_get_x_obj, canvas_get_x); +MP_PROPERTY_GETTER(canvas_x_obj, (mp_obj_t)&canvas_get_x_obj); + +static mp_obj_t canvas_get_y(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(cv_self(self_in)->y); +} +static MP_DEFINE_CONST_FUN_OBJ_1(canvas_get_y_obj, canvas_get_y); +MP_PROPERTY_GETTER(canvas_y_obj, (mp_obj_t)&canvas_get_y_obj); + +static mp_obj_t canvas_get_width(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(cv_self(self_in)->w); +} +static MP_DEFINE_CONST_FUN_OBJ_1(canvas_get_width_obj, canvas_get_width); +MP_PROPERTY_GETTER(canvas_width_obj, (mp_obj_t)&canvas_get_width_obj); + +static mp_obj_t canvas_get_height(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(cv_self(self_in)->h); +} +static MP_DEFINE_CONST_FUN_OBJ_1(canvas_get_height_obj, canvas_get_height); +MP_PROPERTY_GETTER(canvas_height_obj, (mp_obj_t)&canvas_get_height_obj); + +static const mp_rom_map_elem_t picogame_canvas_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_clear), MP_ROM_PTR(&canvas_clear_obj) }, + { MP_ROM_QSTR(MP_QSTR_x), MP_ROM_PTR(&canvas_x_obj) }, + { MP_ROM_QSTR(MP_QSTR_y), MP_ROM_PTR(&canvas_y_obj) }, + { MP_ROM_QSTR(MP_QSTR_width), MP_ROM_PTR(&canvas_width_obj) }, + { MP_ROM_QSTR(MP_QSTR_height), MP_ROM_PTR(&canvas_height_obj) }, + { MP_ROM_QSTR(MP_QSTR_pixel), MP_ROM_PTR(&canvas_pixel_obj) }, + { MP_ROM_QSTR(MP_QSTR_fill_rect), MP_ROM_PTR(&canvas_fill_rect_obj) }, + { MP_ROM_QSTR(MP_QSTR_blit), MP_ROM_PTR(&canvas_blit_obj) }, + { MP_ROM_QSTR(MP_QSTR_mode7), MP_ROM_PTR(&canvas_mode7_obj) }, + { MP_ROM_QSTR(MP_QSTR_fill_triangles), MP_ROM_PTR(&canvas_fill_triangles_obj) }, + { MP_ROM_QSTR(MP_QSTR_vspans), MP_ROM_PTR(&canvas_vspans_obj) }, + { MP_ROM_QSTR(MP_QSTR_road), MP_ROM_PTR(&canvas_road_obj) }, + { MP_ROM_QSTR(MP_QSTR_rect), MP_ROM_PTR(&canvas_rect_obj) }, + { MP_ROM_QSTR(MP_QSTR_line), MP_ROM_PTR(&canvas_line_obj) }, + { MP_ROM_QSTR(MP_QSTR_fill_circle), MP_ROM_PTR(&canvas_fill_circle_obj) }, + { MP_ROM_QSTR(MP_QSTR_circle), MP_ROM_PTR(&canvas_circle_obj) }, + { MP_ROM_QSTR(MP_QSTR_ring), MP_ROM_PTR(&canvas_ring_obj) }, + { MP_ROM_QSTR(MP_QSTR_triangle), MP_ROM_PTR(&canvas_triangle_obj) }, + { MP_ROM_QSTR(MP_QSTR_fill_triangle), MP_ROM_PTR(&canvas_fill_triangle_obj) }, + { MP_ROM_QSTR(MP_QSTR_ellipse), MP_ROM_PTR(&canvas_ellipse_obj) }, + { MP_ROM_QSTR(MP_QSTR_fill_ellipse), MP_ROM_PTR(&canvas_fill_ellipse_obj) }, + { MP_ROM_QSTR(MP_QSTR_fill_round_rect), MP_ROM_PTR(&canvas_fill_round_rect_obj) }, + { MP_ROM_QSTR(MP_QSTR_frame3d), MP_ROM_PTR(&canvas_frame3d_obj) }, + { MP_ROM_QSTR(MP_QSTR_text), MP_ROM_PTR(&canvas_text_obj) }, + { MP_ROM_QSTR(MP_QSTR_move), MP_ROM_PTR(&canvas_move_obj) }, +}; +static MP_DEFINE_CONST_DICT(picogame_canvas_locals_dict, picogame_canvas_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + picogame_canvas_type, + MP_QSTR_Canvas, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, picogame_canvas_make_new, + locals_dict, &picogame_canvas_locals_dict + ); diff --git a/shared-bindings/picogame/Canvas.h b/shared-bindings/picogame/Canvas.h new file mode 100644 index 00000000000..fb37aa9f898 --- /dev/null +++ b/shared-bindings/picogame/Canvas.h @@ -0,0 +1,11 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "shared-module/picogame/Canvas.h" + +extern const mp_obj_type_t picogame_canvas_type; diff --git a/shared-bindings/picogame/Display.c b/shared-bindings/picogame/Display.c new file mode 100644 index 00000000000..276608a8c77 --- /dev/null +++ b/shared-bindings/picogame/Display.c @@ -0,0 +1,125 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#include "py/runtime.h" // brings in the board config (CIRCUITPY_PICOGAME_FAST_DISPLAY) + +// The fast DMA Display backend is port-specific (needs a common-hal). Ports without it +// (e.g. ESP32) build picogame with this whole type compiled out and use Scene's portable +// bus.send renderer instead - so picogame stays buildable on any CircuitPython port. +#if CIRCUITPY_PICOGAME_FAST_DISPLAY +#include "shared-bindings/picogame/Display.h" +#include "shared-bindings/picogame/Sprite.h" +#include "shared-bindings/busdisplay/BusDisplay.h" +#include "shared-module/picogame/__init__.h" +#include "common-hal/picogame/Display.h" + +//| class Display: +//| """Fast display backend: wraps an existing ``busdisplay.BusDisplay`` and +//| pushes pixels with asynchronous double-buffered DMA, overlapping the CPU +//| blit of the next strip with the SPI transfer of the current one. +//| +//| Controller- and resolution-agnostic: reuses the busdisplay's SPI bus, +//| window commands and dimensions.""" +//| +//| def __init__(self, display: busdisplay.BusDisplay, *, rgb444: bool = False) -> None: +//| """rgb444=True drives the panel in 12-bit RGB444 instead of 16-bit RGB565: ~25% less +//| SPI traffic (and thus more FPS on full-screen / scrolling, transfer-bound scenes), at +//| 4096 colours instead of 65536 - which PAL8 art doesn't notice. The panel controller +//| must support COLMOD 12-bit (ST7789/ST7735 do; ILI9341 does NOT).""" +//| ... +//| +static mp_obj_t picogame_display_make_new(const mp_obj_type_t *type, size_t n_args, + size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_display, ARG_rgb444 }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_display, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_rgb444, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + mp_obj_t native = mp_obj_cast_to_native_base(args[ARG_display].u_obj, &busdisplay_busdisplay_type); + if (!mp_obj_is_type(native, &busdisplay_busdisplay_type)) { + mp_raise_TypeError(MP_ERROR_TEXT("expected a BusDisplay")); + } + picogame_display_obj_t *self = mp_obj_malloc(picogame_display_obj_t, type); + common_hal_picogame_display_construct(self, MP_OBJ_TO_PTR(native), args[ARG_rgb444].u_bool); + return MP_OBJ_FROM_PTR(self); +} + +//| def render( +//| self, +//| sprites: List[Sprite], +//| buffer_a: WriteableBuffer, +//| buffer_b: WriteableBuffer, +//| x0: int, +//| y0: int, +//| x1: int, +//| y1: int, +//| *, +//| background: int = 0, +//| ) -> None: +//| """Render ``sprites`` into region [x0,x1) x [y0,y1) and push via async +//| DMA. ``buffer_a``/``buffer_b`` are two equal strip buffers used for +//| double buffering (each >= region_width*2 bytes). +//| +//| SPRITES ONLY (unlike module-level ``picogame.render()``, which also accepts +//| StripDraw/Canvas/Tilemap/Particles): this is the low-level double-buffered +//| sprite push. For mixed layer kinds use a ``Scene`` or ``picogame.render()``.""" +//| ... +//| +//| +static mp_obj_t picogame_display_render(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_sprites, ARG_buffer_a, ARG_buffer_b, ARG_x0, ARG_y0, ARG_x1, ARG_y1, ARG_background }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_sprites, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_buffer_a, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_buffer_b, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_x0, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_y0, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_x1, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_y1, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_background, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, + }; + picogame_display_obj_t *self = MP_OBJ_TO_PTR(pos_args[0]); + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + size_t n = 0; + mp_obj_t *items; + mp_obj_get_array(args[ARG_sprites].u_obj, &n, &items); + for (size_t i = 0; i < n; i++) { + mp_arg_validate_type(items[i], &picogame_sprite_type, MP_QSTR_sprite); + } + + mp_buffer_info_t ba, bb; + mp_get_buffer_raise(args[ARG_buffer_a].u_obj, &ba, MP_BUFFER_WRITE); + mp_get_buffer_raise(args[ARG_buffer_b].u_obj, &bb, MP_BUFFER_WRITE); + size_t buf_pixels = (ba.len < bb.len ? ba.len : bb.len) / 2; + + // kinds == NULL: every item is a sprite (the layered path handles this). + common_hal_picogame_display_render(self, items, NULL, n, + (uint16_t *)ba.buf, (uint16_t *)bb.buf, buf_pixels, + args[ARG_x0].u_int, args[ARG_y0].u_int, args[ARG_x1].u_int, args[ARG_y1].u_int, + args[ARG_background].u_int, 0, 0); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_display_render_obj, 8, picogame_display_render); + +static const mp_rom_map_elem_t picogame_display_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_render), MP_ROM_PTR(&picogame_display_render_obj) }, +}; +static MP_DEFINE_CONST_DICT(picogame_display_locals_dict, picogame_display_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + picogame_display_type, + MP_QSTR_Display, + MP_TYPE_FLAG_NONE, + make_new, picogame_display_make_new, + locals_dict, &picogame_display_locals_dict + ); + +#endif // CIRCUITPY_PICOGAME_FAST_DISPLAY diff --git a/shared-bindings/picogame/Display.h b/shared-bindings/picogame/Display.h new file mode 100644 index 00000000000..1edcf137d91 --- /dev/null +++ b/shared-bindings/picogame/Display.h @@ -0,0 +1,11 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "common-hal/picogame/Display.h" + +extern const mp_obj_type_t picogame_display_type; diff --git a/shared-bindings/picogame/Framebuffer.h b/shared-bindings/picogame/Framebuffer.h new file mode 100644 index 00000000000..fd248bfff13 --- /dev/null +++ b/shared-bindings/picogame/Framebuffer.h @@ -0,0 +1,13 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "shared-module/picogame/__init__.h" // picogame_framebuffer_obj_t + +#if CIRCUITPY_PICOGAME_FRAMEBUFFER +extern const mp_obj_type_t picogame_framebuffer_type; +#endif diff --git a/shared-bindings/picogame/Particles.c b/shared-bindings/picogame/Particles.c new file mode 100644 index 00000000000..9f95050506f --- /dev/null +++ b/shared-bindings/picogame/Particles.c @@ -0,0 +1,127 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#include "py/runtime.h" +#include "shared-module/picogame/pg_compat.h" +#include "shared-bindings/picogame/Particles.h" +#include "shared-module/picogame/Particles.h" + +static void reset_dirty(picogame_particles_obj_t *self) { + // INT32 sentinels (not int16): bbox fields are int32 + positions 24.8, so a big-world emitter past + // +-32767 px must still accumulate. (Matches picogame_dirty_reset; update()/clear() use it too.) + self->cx1 = self->px1 = 0x7fffffff; + self->cy1 = self->py1 = 0x7fffffff; + self->cx2 = self->px2 = -0x7fffffff - 1; + self->cy2 = self->py2 = -0x7fffffff - 1; +} + +//| class Particles: +//| """A pooled particle layer (small moving dots), drawn as one Scene layer. +//| Add it to a Scene, ``emit()`` bursts, and call ``tick()`` each frame.""" +//| +//| def __init__( +//| self, capacity: int, *, size: int = 1, gravity: float = 0.0, fade: bool = False +//| ) -> None: ... +//| +static mp_obj_t picogame_particles_make_new(const mp_obj_type_t *type, size_t n_args, + size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_capacity, ARG_size, ARG_gravity, ARG_fade }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_capacity, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_size, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 1} }, + { MP_QSTR_gravity, MP_ARG_KW_ONLY | MP_ARG_OBJ, {.u_obj = mp_const_none} }, + { MP_QSTR_fade, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + mp_int_t cap = mp_arg_validate_int_range(args[ARG_capacity].u_int, 1, 4096, MP_QSTR_capacity); + mp_int_t size = mp_arg_validate_int_range(args[ARG_size].u_int, 1, 8, MP_QSTR_size); + mp_float_t gravity = (args[ARG_gravity].u_obj == mp_const_none) + ? 0.0f : mp_obj_get_float(args[ARG_gravity].u_obj); + if (gravity > 127.99f) { // clamp to the int16 8.8 range: gravity*256 must fit + gravity = 127.99f; // [-32768, 32767]; |g|>=128 would flip the sign + } else if (gravity < -128.0f) { + gravity = -128.0f; + } + + picogame_particles_obj_t *self = mp_obj_malloc(picogame_particles_obj_t, type); + self->cap = cap; + self->count = 0; + self->size = size; + self->gravity = (int16_t)(gravity * 256); + self->fade = args[ARG_fade].u_bool; + // Pure numeric arrays, no Python pointers -> exempt from the conservative GC scan + // (shorter gc.collect() pauses; fixed at construction, never m_renew'd). + self->px = m_malloc_without_collect(cap * sizeof(int32_t)); + self->py = m_malloc_without_collect(cap * sizeof(int32_t)); + self->vx = m_malloc_without_collect(cap * sizeof(int16_t)); + self->vy = m_malloc_without_collect(cap * sizeof(int16_t)); + self->life = m_malloc_without_collect(cap * sizeof(uint16_t)); + self->life0 = m_malloc_without_collect(cap * sizeof(uint16_t)); + self->color = m_malloc_without_collect(cap * sizeof(uint16_t)); + reset_dirty(self); + return MP_OBJ_FROM_PTR(self); +} + +//| +//| def emit( +//| self, x: int, y: int, count: int, speed: int = 1, life: int = 30, color: int = 0xFFFF +//| ) -> None: +//| """Spawn ``count`` particles at (x, y) with random velocity up to ``speed`` +//| px/tick, living ``life`` ticks, in wire-order ``color``.""" +//| ... +//| +static mp_obj_t picogame_particles_emit_fun(size_t n_args, const mp_obj_t *args) { + picogame_particles_obj_t *self = MP_OBJ_TO_PTR(args[0]); + int x = mp_obj_get_int(args[1]); + int y = mp_obj_get_int(args[2]); + int count = mp_arg_validate_int_min(mp_obj_get_int(args[3]), 0, MP_QSTR_count); + // speed*256 must fit the int16_t velocity (8.8) -> cap 127; life is stored as uint16_t. + int speed = (n_args > 4) ? mp_arg_validate_int_range(mp_obj_get_int(args[4]), 0, 127, MP_QSTR_speed) : 1; + int life = (n_args > 5) ? mp_arg_validate_int_range(mp_obj_get_int(args[5]), 1, 65535, MP_QSTR_life) : 30; + uint16_t color = (n_args > 6) + ? mp_arg_validate_int_range(mp_obj_get_int(args[6]), 0, 0xFFFF, MP_QSTR_color) : 0xFFFF; + picogame_particles_emit(self, x, y, count, speed, life, color); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(picogame_particles_emit_obj, 4, 7, picogame_particles_emit_fun); + +//| def tick(self) -> None: +//| """Advance all particles one step (move, gravity, ageing).""" +//| ... +//| +static mp_obj_t picogame_particles_tick(mp_obj_t self_in) { + picogame_particles_update(MP_OBJ_TO_PTR(self_in)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(picogame_particles_tick_obj, picogame_particles_tick); + +//| def clear(self) -> None: +//| """Remove all particles.""" +//| ... +//| +//| +static mp_obj_t picogame_particles_clear_method(mp_obj_t self_in) { + picogame_particles_clear(MP_OBJ_TO_PTR(self_in)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(picogame_particles_clear_obj, picogame_particles_clear_method); + +static const mp_rom_map_elem_t picogame_particles_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_emit), MP_ROM_PTR(&picogame_particles_emit_obj) }, + { MP_ROM_QSTR(MP_QSTR_tick), MP_ROM_PTR(&picogame_particles_tick_obj) }, + { MP_ROM_QSTR(MP_QSTR_clear), MP_ROM_PTR(&picogame_particles_clear_obj) }, +}; +static MP_DEFINE_CONST_DICT(picogame_particles_locals_dict, picogame_particles_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + picogame_particles_type, + MP_QSTR_Particles, + MP_TYPE_FLAG_NONE, + make_new, picogame_particles_make_new, + locals_dict, &picogame_particles_locals_dict + ); diff --git a/shared-bindings/picogame/Particles.h b/shared-bindings/picogame/Particles.h new file mode 100644 index 00000000000..8f8b37ab819 --- /dev/null +++ b/shared-bindings/picogame/Particles.h @@ -0,0 +1,11 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "shared-module/picogame/Particles.h" + +extern const mp_obj_type_t picogame_particles_type; diff --git a/shared-bindings/picogame/Scene.c b/shared-bindings/picogame/Scene.c new file mode 100644 index 00000000000..2793e7cbe5d --- /dev/null +++ b/shared-bindings/picogame/Scene.c @@ -0,0 +1,532 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#include "py/runtime.h" +#include +#include "shared-module/picogame/pg_compat.h" +#include "py/objtuple.h" +#include "py/objlist.h" +#include "shared-bindings/picogame/Scene.h" +#include "shared-bindings/picogame/__init__.h" +#include "shared-bindings/picogame/Sprite.h" +#include "shared-bindings/picogame/Tilemap.h" +#include "shared-bindings/picogame/Particles.h" +#include "shared-bindings/picogame/Canvas.h" +#include "shared-bindings/picogame/Framebuffer.h" +#include "shared-bindings/busdisplay/BusDisplay.h" +#if CIRCUITPY_PICOGAME_FAST_DISPLAY +#include "shared-bindings/picogame/Display.h" +#endif +#include "shared-module/picogame/Scene.h" +#include "shared-module/picogame/__init__.h" +#include "shared-module/picogame/Tilemap.h" +#include "shared-module/picogame/Particles.h" +#include "shared-module/picogame/Canvas.h" +#if CIRCUITPY_PICOGAME_FAST_DISPLAY +#include "common-hal/picogame/Display.h" +#endif + +#define SCENE_INIT_CAP 8 +#define PICOGAME_MAX_DIRTY_RECTS 6 // separate regions repainted per refresh + +// Classify the `display` arg into a render backend and return the object Scene should +// store: a fast picogame.Display (DMA), a picogame.Framebuffer (RAM scanout buffer, when +// built in), or a plain busdisplay (portable bus.send). Sets *fast / *fb_target +// accordingly; for a busdisplay it accepts a SUBCLASS (e.g. adafruit_st7789.ST7789) by +// casting to its native base and returns that. Raises TypeError otherwise. This is where +// the flag-gated type checks live, so the constructor body stays clean. +static mp_obj_t scene_resolve_target(mp_obj_t disp, bool *fast, bool *fb_target) { + *fast = false; + *fb_target = false; + #if CIRCUITPY_PICOGAME_FAST_DISPLAY + if (mp_obj_is_type(disp, &picogame_display_type)) { + *fast = true; + return disp; + } + #endif + #if CIRCUITPY_PICOGAME_FRAMEBUFFER + if (mp_obj_is_type(disp, &picogame_framebuffer_type)) { + *fb_target = true; // refresh() composites dirty rects straight into its RAM buffer + return disp; + } + #endif + // Plain busdisplay: accept a subclass by casting to its native base; the portable + // renderer treats self->display as a busdisplay_busdisplay_obj_t directly. + mp_obj_t native = mp_obj_cast_to_native_base(disp, &busdisplay_busdisplay_type); + if (!mp_obj_is_type(native, &busdisplay_busdisplay_type)) { + mp_raise_TypeError(MP_ERROR_TEXT("expected a BusDisplay")); + } + return native; +} + +//| class Scene: +//| """Retained-mode scene with dirty-rectangle rendering. Add sprites and +//| tilemaps once (tilemaps first = bottom layer), mutate them each frame, +//| then call :py:meth:`refresh` - only the changed region is repainted. +//| Backed by a fast :py:class:`Display`.""" +//| +//| def __init__( +//| self, +//| display: Display, +//| buffer_a: WriteableBuffer, +//| buffer_b: WriteableBuffer, +//| *, +//| background: int = 0, +//| ) -> None: ... +//| +static mp_obj_t picogame_scene_make_new(const mp_obj_type_t *type, size_t n_args, + size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_display, ARG_buffer_a, ARG_buffer_b, ARG_background, + ARG_top, ARG_bottom, ARG_left, ARG_right }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_display, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_buffer_a, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_buffer_b, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_background, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, + { MP_QSTR_top, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, // reserved border insets: + { MP_QSTR_bottom, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, // the scene renders only the + { MP_QSTR_left, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, // inner play rect; the app + { MP_QSTR_right, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, // owns the border around it + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + // Resolve the render backend (fast Display / Framebuffer / busdisplay) - all the + // flag-gated type checks are concentrated in scene_resolve_target(). + bool fast, fb_target; + mp_obj_t disp = scene_resolve_target(args[ARG_display].u_obj, &fast, &fb_target); + + // The framebuffer path composites directly into the target, so it needs no strip + // buffers; the SPI/DMA paths do. Validate them only when they'll be used. (When + // CIRCUITPY_PICOGAME_FRAMEBUFFER is off, fb_target is always false -> always validated, + // as before.) + if (!fb_target) { + mp_buffer_info_t tmp; + mp_get_buffer_raise(args[ARG_buffer_a].u_obj, &tmp, MP_BUFFER_WRITE); + mp_get_buffer_raise(args[ARG_buffer_b].u_obj, &tmp, MP_BUFFER_WRITE); + } + + picogame_scene_obj_t *self = mp_obj_malloc(picogame_scene_obj_t, type); + self->display = disp; + self->fast = fast; + #if CIRCUITPY_PICOGAME_FRAMEBUFFER + self->fb_target = fb_target; + #endif + self->buf_a = args[ARG_buffer_a].u_obj; + self->buf_b = args[ARG_buffer_b].u_obj; + self->background = args[ARG_background].u_int; + self->count = 0; + self->cap = SCENE_INIT_CAP; + self->items = m_new(mp_obj_t, SCENE_INIT_CAP); + self->kinds = m_new(uint8_t, SCENE_INIT_CAP); + self->snap = m_new(picogame_snapshot_t, SCENE_INIT_CAP); + self->cleared = false; + self->ox = 0; + self->oy = 0; + self->top = args[ARG_top].u_int; + self->bottom = args[ARG_bottom].u_int; + self->left = args[ARG_left].u_int; + self->right = args[ARG_right].u_int; + mp_obj_t zeros[4] = { + MP_OBJ_NEW_SMALL_INT(0), MP_OBJ_NEW_SMALL_INT(0), + MP_OBJ_NEW_SMALL_INT(0), MP_OBJ_NEW_SMALL_INT(0), + }; + self->dirty_rect = mp_obj_new_list(4, zeros); // reused every refresh + return MP_OBJ_FROM_PTR(self); +} + +// On a full repaint, sync sprite snapshots to current and drain the layer +// dirties (tilemap/particles/canvas) so they don't re-report a stale region. +static void snapshot_sync(picogame_scene_obj_t *self) { + int a, b, c, d; + for (uint16_t i = 0; i < self->count; i++) { + uint8_t kind = self->kinds[i] & PICOGAME_KIND_MASK; + if (kind == PICOGAME_KIND_TILEMAP) { + picogame_tilemap_take_dirty(MP_OBJ_TO_PTR(self->items[i]), &a, &b, &c, &d); + } else if (kind == PICOGAME_KIND_PARTICLES) { + picogame_particles_take_dirty(MP_OBJ_TO_PTR(self->items[i]), &a, &b, &c, &d); + } else if (kind == PICOGAME_KIND_CANVAS) { + picogame_canvas_take_dirty(MP_OBJ_TO_PTR(self->items[i]), &a, &b, &c, &d); + } else if (kind == PICOGAME_KIND_STRIPDRAW) { + // Immediate-mode layer: no retained state to snapshot/drain. + } else if (kind == PICOGAME_KIND_TRIANGLES) { + // Screen-space batch: drain the count-set dirty so it doesn't re-report. + int e, f, g, hh; + picogame_dirty_take(&((picogame_triangles_obj_t *)MP_OBJ_TO_PTR(self->items[i]))->dx1, + &e, &f, &g, &hh); + } else { + picogame_sprite_obj_t *s = MP_OBJ_TO_PTR(self->items[i]); + picogame_bitmap_obj_t *bm = s->bitmap; + int ax1, ay1, ax2, ay2; + picogame_sprite_aabb(s, &ax1, &ay1, &ax2, &ay2); + self->snap[i].x = ax1; + self->snap[i].y = ay1; + self->snap[i].w = ax2 - ax1; + self->snap[i].h = ay2 - ay1; + self->snap[i].bitmap = (void *)bm; + self->snap[i].frame = s->frame; + self->snap[i].flags = s->flags; + self->snap[i].scale = s->scale; + self->snap[i].angle = s->angle; + self->snap[i].seq = s->seq; + self->snap[i].dither = s->dither; + self->snap[i].flash_color = s->flash_color; + } + } +} + +//| +//| def add( +//| self, item: Union[Sprite, Tilemap], *, fixed: bool = False +//| ) -> Union[Sprite, Tilemap]: +//| """Add a sprite/tilemap/particles/canvas (drawn next refresh; insertion +//| order is bottom-to-top). fixed=True pins the item to the screen (it ignores +//| the view offset) - use it for HUD / score / dialog over a scrolling world. +//| Returns the added item, so you can write ``spr = scene.add(Sprite(...))``.""" +//| ... +//| +static void scene_add_one(picogame_scene_obj_t *self, mp_obj_t item_in, bool fixed) { + uint8_t kind; + kind = picogame_kind_of(item_in); + if (fixed) { + kind |= PICOGAME_KIND_FIXED; + } + if (self->count >= self->cap) { + if (self->cap >= 0x8000) { // next doubling overflows uint16_t -> m_renew(0) shrink + mp_raise_RuntimeError(MP_ERROR_TEXT("scene full")); + } + uint16_t new_cap = self->cap * 2; + self->items = m_renew(mp_obj_t, self->items, self->cap, new_cap); + self->kinds = m_renew(uint8_t, self->kinds, self->cap, new_cap); + self->snap = m_renew(picogame_snapshot_t, self->snap, self->cap, new_cap); + self->cap = new_cap; + } + self->items[self->count] = item_in; + self->kinds[self->count] = kind; + // Snapshot starts all-zero ("invisible", nothing diffed against garbage - m_renew doesn't + // zero), so a newly added layer is detected as changed and drawn on the next refresh. + memset(&self->snap[self->count], 0, sizeof(picogame_snapshot_t)); + self->count++; + // Honour "drawn on the next refresh" for EVERY kind: the zeroed snapshot covers sprites, + // but a re-add()ed Canvas/Tilemap whose dirty flag was already consumed would otherwise + // stay invisible until some other change. add() is a cold path -> force a full repaint. + self->cleared = false; +} + +static mp_obj_t picogame_scene_add(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_item, ARG_fixed }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_item, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_fixed, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + scene_add_one(MP_OBJ_TO_PTR(pos_args[0]), args[ARG_item].u_obj, args[ARG_fixed].u_bool); + return args[ARG_item].u_obj; // constructive: return the added item for `x = scene.add(...)` +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_scene_add_obj, 2, picogame_scene_add); + +//| def add_all(self, items: Iterable[Union[Sprite, Tilemap]]) -> None: +//| """Add several sprites/tilemaps at once (bottom-to-top in order).""" +//| ... +//| +static mp_obj_t picogame_scene_add_all(mp_obj_t self_in, mp_obj_t iterable) { + picogame_scene_obj_t *self = MP_OBJ_TO_PTR(self_in); + mp_obj_t iter = mp_getiter(iterable, NULL); + mp_obj_t item; + while ((item = mp_iternext(iter)) != MP_OBJ_STOP_ITERATION) { + scene_add_one(self, item, false); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(picogame_scene_add_all_obj, picogame_scene_add_all); + +//| def remove(self, item: Union[Sprite, Tilemap]) -> None: +//| """Remove a previously add()ed item (draw order of the rest is unchanged). +//| The next refresh repaints over where it was (a full repaint, like +//| :py:meth:`invalidate`), so it leaves no ghost. The item itself is untouched - +//| keep a reference and add() it again later to bring it back. Raises +//| ValueError if the item is not in the scene (e.g. already removed).""" +//| ... +//| +static mp_obj_t picogame_scene_remove(mp_obj_t self_in, mp_obj_t item_in) { + picogame_scene_obj_t *self = MP_OBJ_TO_PTR(self_in); + for (uint16_t i = 0; i < self->count; i++) { + if (self->items[i] != item_in) { + continue; + } + self->count--; + for (uint16_t j = i; j < self->count; j++) { // keep draw order: shift the tail down + self->items[j] = self->items[j + 1]; + self->kinds[j] = self->kinds[j + 1]; + self->snap[j] = self->snap[j + 1]; + } + self->items[self->count] = mp_const_none; // release the GC reference + self->cleared = false; // full repaint next refresh: background covers where it was + return mp_const_none; + } + mp_raise_ValueError(MP_ERROR_TEXT("item not in scene")); +} +static MP_DEFINE_CONST_FUN_OBJ_2(picogame_scene_remove_obj, picogame_scene_remove); + +//| def invalidate(self) -> None: +//| """Force a full-screen repaint on the next refresh (e.g. on scene change).""" +//| ... +//| +static mp_obj_t picogame_scene_invalidate(mp_obj_t self_in) { + ((picogame_scene_obj_t *)MP_OBJ_TO_PTR(self_in))->cleared = false; + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(picogame_scene_invalidate_obj, picogame_scene_invalidate); + +//| def set_view(self, ox: int, oy: int) -> None: +//| """Set the view offset = screen position of the scene origin. Use a +//| constant offset to centre a small game, or update it each frame to +//| scroll (which repaints the whole screen).""" +//| ... +//| +static mp_obj_t picogame_scene_set_view(mp_obj_t self_in, mp_obj_t ox_in, mp_obj_t oy_in) { + picogame_scene_obj_t *self = MP_OBJ_TO_PTR(self_in); + int ox = mp_obj_get_int(ox_in); + int oy = mp_obj_get_int(oy_in); + if (ox != self->ox || oy != self->oy) { + self->ox = ox; + self->oy = oy; + self->cleared = false; // the whole view shifted -> full repaint next refresh + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_3(picogame_scene_set_view_obj, picogame_scene_set_view); + +//| view: Tuple[int, int] +//| """The current view offset (ox, oy) as set by set_view() (read-only).""" +static mp_obj_t picogame_scene_get_view(mp_obj_t self_in) { + picogame_scene_obj_t *self = MP_OBJ_TO_PTR(self_in); + mp_obj_t t[2] = { MP_OBJ_NEW_SMALL_INT(self->ox), MP_OBJ_NEW_SMALL_INT(self->oy) }; + return mp_obj_new_tuple(2, t); +} +static MP_DEFINE_CONST_FUN_OBJ_1(picogame_scene_get_view_obj, picogame_scene_get_view); +MP_PROPERTY_GETTER(picogame_scene_view_obj, (mp_obj_t)&picogame_scene_get_view_obj); + +//| display: Union[Display, busdisplay.BusDisplay] +//| """The backend this Scene was built with (a picogame.Display or a busdisplay), +//| read-only - handy for one-off picogame.render() / Display.render() calls.""" +//| +static mp_obj_t picogame_scene_get_display(mp_obj_t self_in) { + return ((picogame_scene_obj_t *)MP_OBJ_TO_PTR(self_in))->display; +} +static MP_DEFINE_CONST_FUN_OBJ_1(picogame_scene_get_display_obj, picogame_scene_get_display); +MP_PROPERTY_GETTER(picogame_scene_display_obj, (mp_obj_t)&picogame_scene_get_display_obj); + +// Compute the frame's dirty rectangles for a WxH target, clipped to the play rect +// [left, w-right) x [top, h-bottom). On the first refresh (or after invalidate) this is +// a single full-screen rect + a snapshot sync; afterwards it diffs against the previous +// frame. Returns the rect count written to `rects` (0 = nothing to repaint). +// Backend-agnostic, so the SPI/fast and framebuffer paths share the exact dirty logic. +static int scene_collect_dirty_rects(picogame_scene_obj_t *self, int w, int h, picogame_rect_t *rects) { + int nr; + if (self->cleared) { + nr = picogame_scene_compute_dirty_rects(self->items, self->kinds, self->snap, + self->count, w, h, self->ox, self->oy, rects, PICOGAME_MAX_DIRTY_RECTS); + if (nr == 0) { + return 0; + } + } else { + rects[0].x1 = 0; + rects[0].y1 = 0; + rects[0].x2 = w; + rects[0].y2 = h; + nr = 1; + snapshot_sync(self); + // NOTE: cleared flips to true only after the RENDER completes (see the callers) - + // the snapshots are already advanced here, so an exception mid-render would + // otherwise leave a partially painted frame that no later refresh repairs. + } + + // Clip every dirty rect to the play rect [left, w-right) x [top, h-bottom); the + // reserved border is the app's, so the scene never paints into it. Drop empty rects. + int pa_x1 = self->left; + int pa_x2 = w - self->right; + int pa_y1 = self->top; + int pa_y2 = h - self->bottom; + int kept = 0; + for (int i = 0; i < nr; i++) { + int rx1 = rects[i].x1 < pa_x1 ? pa_x1 : rects[i].x1; + int rx2 = rects[i].x2 > pa_x2 ? pa_x2 : rects[i].x2; + int ry1 = rects[i].y1 < pa_y1 ? pa_y1 : rects[i].y1; + int ry2 = rects[i].y2 > pa_y2 ? pa_y2 : rects[i].y2; + if (rx1 >= rx2 || ry1 >= ry2) { + continue; + } + rects[kept].x1 = rx1; + rects[kept].y1 = ry1; + rects[kept].x2 = rx2; + rects[kept].y2 = ry2; + kept++; + } + return kept; +} + +// Store the bounding union of `nr` rects into the reusable dirty_rect list (no per-frame +// tuple allocation) and return it. Shared by both refresh backends. +static mp_obj_t scene_store_dirty(picogame_scene_obj_t *self, const picogame_rect_t *rects, int nr, int w, int h) { + int ux1 = w, uy1 = h, ux2 = 0, uy2 = 0; + for (int i = 0; i < nr; i++) { + if (rects[i].x1 < ux1) { + ux1 = rects[i].x1; + } + if (rects[i].y1 < uy1) { + uy1 = rects[i].y1; + } + if (rects[i].x2 > ux2) { + ux2 = rects[i].x2; + } + if (rects[i].y2 > uy2) { + uy2 = rects[i].y2; + } + } + mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(0), MP_OBJ_NEW_SMALL_INT(ux1)); + mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(1), MP_OBJ_NEW_SMALL_INT(uy1)); + mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(2), MP_OBJ_NEW_SMALL_INT(ux2)); + mp_obj_list_store(self->dirty_rect, MP_OBJ_NEW_SMALL_INT(3), MP_OBJ_NEW_SMALL_INT(uy2)); + return self->dirty_rect; +} + +#if CIRCUITPY_PICOGAME_FRAMEBUFFER +// Retained-mode refresh against a RAM framebuffer target (scanout-buffer platforms: +// WASM playground, desktop sim, FruitJam DVI/HSTX). Same dirty-rect logic as the SPI +// path (via the shared helpers), but each rect is composited straight into the target +// with picogame_render_framebuffer - no strip buffers, no bus transaction. A latched +// StripDraw BaseException is re-raised (no bus to close). Self-contained here so +// picogame_scene_refresh() keeps its original SPI/fast shape. +static mp_obj_t scene_refresh_fb(picogame_scene_obj_t *self) { + picogame_framebuffer_obj_t *fbt = MP_OBJ_TO_PTR(self->display); + int w = fbt->width; + int h = fbt->height; + + // Emulated invert (pg.invert on a Framebuffer) just toggled -> recomposite the WHOLE frame so the + // negative flip covers the whole screen, not only this frame's dirty rects (like a panel INVON). + if (picogame_fb_take_invert_dirty()) { + self->cleared = false; + } + + picogame_rect_t rects[PICOGAME_MAX_DIRTY_RECTS]; + int nr = scene_collect_dirty_rects(self, w, h, rects); + if (nr == 0) { + return mp_const_none; + } + + // Snapshots are already advanced; stay in the needs-full-repaint state until the + // render completes, so a BaseException mid-render (Ctrl-C in a StripDraw) leaves a + // scene whose NEXT refresh repaints everything instead of keeping a torn frame. + self->cleared = false; + for (int i = 0; i < nr; i++) { + mp_obj_t exc = picogame_render_framebuffer(fbt->fb, fbt->width, fbt->height, fbt->fmt, + fbt->scratch, fbt->scratch_rows, + self->items, self->kinds, self->count, + rects[i].x1, rects[i].y1, rects[i].x2, rects[i].y2, + self->background, self->ox, self->oy); + if (exc != MP_OBJ_NULL) { + nlr_raise(MP_OBJ_TO_PTR(exc)); // cleared stays false -> full repaint next refresh + } + } + self->cleared = true; + return scene_store_dirty(self, rects, nr, w, h); +} +#endif // CIRCUITPY_PICOGAME_FRAMEBUFFER + +//| def refresh(self) -> Optional[list]: +//| """Diff against the previous frame and repaint only the changed region(s). +//| Returns the bounding dirty rect as a REUSED list [x1, y1, x2, y2] (read it +//| immediately; it's overwritten next call), or None if nothing changed.""" +//| ... +//| +//| +static mp_obj_t picogame_scene_refresh(mp_obj_t self_in) { + picogame_scene_obj_t *self = MP_OBJ_TO_PTR(self_in); + + #if CIRCUITPY_PICOGAME_FRAMEBUFFER + if (self->fb_target) { + return scene_refresh_fb(self); + } + #endif + + // Resolve the underlying busdisplay from either backend. + busdisplay_busdisplay_obj_t *bd; + #if CIRCUITPY_PICOGAME_FAST_DISPLAY + picogame_display_obj_t *disp = NULL; + if (self->fast) { + disp = MP_OBJ_TO_PTR(self->display); + bd = disp->display; + } else + #endif + { + bd = MP_OBJ_TO_PTR(self->display); + } + int w = bd->core.width; + int h = bd->core.height; + + picogame_rect_t rects[PICOGAME_MAX_DIRTY_RECTS]; + int nr = scene_collect_dirty_rects(self, w, h, rects); + if (nr == 0) { + return mp_const_none; + } + + mp_buffer_info_t a, b; + mp_get_buffer_raise(self->buf_a, &a, MP_BUFFER_WRITE); + mp_get_buffer_raise(self->buf_b, &b, MP_BUFFER_WRITE); + #if CIRCUITPY_PICOGAME_FAST_DISPLAY + size_t buf_pixels = (a.len < b.len ? a.len : b.len) / 2; // fast path double-buffers + #endif + + // Render each dirty rect independently; return their bounding union (kept for + // the existing "dirty WxH" debug prints). + // Snapshots are already advanced; stay in the needs-full-repaint state until the + // render completes, so a BaseException mid-render (Ctrl-C in a StripDraw) leaves a + // scene whose NEXT refresh repaints everything instead of keeping a torn frame. + self->cleared = false; + for (int i = 0; i < nr; i++) { + #if CIRCUITPY_PICOGAME_FAST_DISPLAY + if (self->fast) { + common_hal_picogame_display_render(disp, self->items, self->kinds, self->count, + (uint16_t *)a.buf, (uint16_t *)b.buf, buf_pixels, + rects[i].x1, rects[i].y1, rects[i].x2, rects[i].y2, + self->background, self->ox, self->oy); + } else + #endif + { + // Portable single-buffer bus.send path (any CircuitPython port). + picogame_render_region(bd, self->items, self->kinds, self->count, + (uint16_t *)a.buf, a.len / 2, + rects[i].x1, rects[i].y1, rects[i].x2, rects[i].y2, + self->background, self->ox, self->oy); + } + } + self->cleared = true; + + return scene_store_dirty(self, rects, nr, w, h); +} +static MP_DEFINE_CONST_FUN_OBJ_1(picogame_scene_refresh_obj, picogame_scene_refresh); + +static const mp_rom_map_elem_t picogame_scene_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_add), MP_ROM_PTR(&picogame_scene_add_obj) }, + { MP_ROM_QSTR(MP_QSTR_add_all), MP_ROM_PTR(&picogame_scene_add_all_obj) }, + { MP_ROM_QSTR(MP_QSTR_remove), MP_ROM_PTR(&picogame_scene_remove_obj) }, + { MP_ROM_QSTR(MP_QSTR_refresh), MP_ROM_PTR(&picogame_scene_refresh_obj) }, + { MP_ROM_QSTR(MP_QSTR_invalidate), MP_ROM_PTR(&picogame_scene_invalidate_obj) }, + { MP_ROM_QSTR(MP_QSTR_set_view), MP_ROM_PTR(&picogame_scene_set_view_obj) }, + { MP_ROM_QSTR(MP_QSTR_view), MP_ROM_PTR(&picogame_scene_view_obj) }, + { MP_ROM_QSTR(MP_QSTR_display), MP_ROM_PTR(&picogame_scene_display_obj) }, +}; +static MP_DEFINE_CONST_DICT(picogame_scene_locals_dict, picogame_scene_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + picogame_scene_type, + MP_QSTR_Scene, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, picogame_scene_make_new, + locals_dict, &picogame_scene_locals_dict + ); diff --git a/shared-bindings/picogame/Scene.h b/shared-bindings/picogame/Scene.h new file mode 100644 index 00000000000..733968b06ca --- /dev/null +++ b/shared-bindings/picogame/Scene.h @@ -0,0 +1,11 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "shared-module/picogame/Scene.h" + +extern const mp_obj_type_t picogame_scene_type; diff --git a/shared-bindings/picogame/Sprite.h b/shared-bindings/picogame/Sprite.h new file mode 100644 index 00000000000..97c38c8b762 --- /dev/null +++ b/shared-bindings/picogame/Sprite.h @@ -0,0 +1,11 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "shared-module/picogame/Sprite.h" + +extern const mp_obj_type_t picogame_sprite_type; diff --git a/shared-bindings/picogame/Tilemap.c b/shared-bindings/picogame/Tilemap.c new file mode 100644 index 00000000000..a850831032f --- /dev/null +++ b/shared-bindings/picogame/Tilemap.c @@ -0,0 +1,230 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#include "py/runtime.h" +#include "shared-module/picogame/pg_compat.h" +#include "shared-bindings/picogame/Tilemap.h" +#include "shared-bindings/picogame/Bitmap.h" +#include "shared-module/picogame/Tilemap.h" + +//| class Tilemap: +//| """A grid of tile indices into a tileset Bitmap (each frame = one tile). +//| Add it to a Scene as a background layer; setting tiles or moving the map +//| marks only the affected area dirty.""" +//| +//| def __init__(self, tileset: Bitmap, cols: int, rows: int) -> None: +//| """A map ``cols`` tiles wide by ``rows`` tiles tall (each cell indexes a +//| frame of ``tileset``).""" +//| ... +//| +static mp_obj_t picogame_tilemap_make_new(const mp_obj_type_t *type, size_t n_args, + size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_tileset, ARG_cols, ARG_rows }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_tileset, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_cols, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_rows, MP_ARG_REQUIRED | MP_ARG_INT }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + mp_obj_t tileset_obj = mp_arg_validate_type(args[ARG_tileset].u_obj, &picogame_bitmap_type, MP_QSTR_tileset); + // Cap cols/rows (not just floor): an unbounded cols*rows overflows the size_t map/orient + // allocation on a 32-bit port, desyncing alloc size from the index space (same family as the + // Bitmap width*frames guard). 1024 each -> product <= 1M, far below SIZE_MAX; oversize maps + // MemoryError at alloc (safe). + mp_int_t map_w = mp_arg_validate_int_range(args[ARG_cols].u_int, 1, 1024, MP_QSTR_cols); + mp_int_t map_h = mp_arg_validate_int_range(args[ARG_rows].u_int, 1, 1024, MP_QSTR_rows); + + mp_obj_t map_obj = mp_obj_new_bytearray_of_zeros((size_t)map_w * map_h); + mp_buffer_info_t mi; + mp_get_buffer_raise(map_obj, &mi, MP_BUFFER_RW); + + picogame_tilemap_obj_t *self = mp_obj_malloc(picogame_tilemap_obj_t, type); + self->tileset = MP_OBJ_TO_PTR(tileset_obj); + self->tileset_obj = tileset_obj; + self->map = mi.buf; + self->map_obj = map_obj; + self->orient = NULL; // orientation plane allocated lazily on first flipped/rotated tile + self->orient_obj = mp_const_none; + self->map_w = map_w; + self->map_h = map_h; + self->x = 0; + self->y = 0; + picogame_tilemap_dirty_reset(self); + return MP_OBJ_FROM_PTR(self); +} + +//| def tile( +//| self, +//| tx: int, +//| ty: int, +//| value: Optional[int] = None, +//| *, +//| flip_x: bool = False, +//| flip_y: bool = False, +//| transpose: bool = False, +//| ) -> Optional[int]: +//| """Get the tile at (tx, ty) -> int; with ``value``, set it (and mark dirty) -> None. +//| The optional keyword ``flip_x``/``flip_y``/``transpose`` flags orient the tile - together +//| they give all 8 orientations (4 rotations x mirror) for free at draw time; use them +//| with a deduplicated tileset (png2picogame --dedup REMAP). Out-of-range reads as 0, +//| ignores writes.""" +//| ... +//| +static mp_obj_t picogame_tilemap_tile(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_tx, ARG_ty, ARG_value, ARG_flip_x, ARG_flip_y, ARG_transpose }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_tx, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_ty, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_value, MP_ARG_OBJ, {.u_obj = mp_const_none} }, + { MP_QSTR_flip_x, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + { MP_QSTR_flip_y, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + { MP_QSTR_transpose, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + picogame_tilemap_obj_t *self = MP_OBJ_TO_PTR(pos_args[0]); + int tx = args[ARG_tx].u_int; + int ty = args[ARG_ty].u_int; + bool oob = (tx < 0 || ty < 0 || tx >= self->map_w || ty >= self->map_h); + if (args[ARG_value].u_obj != mp_const_none) { + if (!oob) { + size_t off = (size_t)ty * self->map_w + tx; + uint8_t v = mp_obj_get_int(args[ARG_value].u_obj) & 0xff; + uint8_t o = 0; + if (args[ARG_flip_x].u_bool) { + o |= 1; + } + if (args[ARG_flip_y].u_bool) { + o |= 2; + } + if (args[ARG_transpose].u_bool) { + o |= 4; + } + // Allocate the orientation plane lazily - only maps that actually use flips/rotation + // pay the RAM (1 byte/cell). + if (o != 0 && self->orient == NULL) { + mp_obj_t ob = mp_obj_new_bytearray_of_zeros((size_t)self->map_w * self->map_h); + mp_buffer_info_t oi; + mp_get_buffer_raise(ob, &oi, MP_BUFFER_RW); + self->orient = oi.buf; + self->orient_obj = ob; + } + uint8_t old_o = self->orient ? self->orient[off] : 0; + if (self->map[off] != v || old_o != o) { + self->map[off] = v; + if (self->orient) { + self->orient[off] = o; + } + int tw = self->tileset ? self->tileset->width : 0; + int th = self->tileset ? self->tileset->height : 0; + int sx = self->x + tx * tw; + int sy = self->y + ty * th; + picogame_tilemap_dirty_union(self, sx, sy, sx + tw, sy + th); + } + } + return mp_const_none; + } + if (oob) { + return MP_OBJ_NEW_SMALL_INT(0); + } + return MP_OBJ_NEW_SMALL_INT(self->map[(size_t)ty * self->map_w + tx]); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_tilemap_tile_obj, 3, picogame_tilemap_tile); + +//| def move(self, x: int, y: int) -> None: +//| """Move the whole map to pixel (x, y).""" +//| ... +//| +static mp_obj_t picogame_tilemap_move(mp_obj_t self_in, mp_obj_t x_in, mp_obj_t y_in) { + picogame_tilemap_obj_t *self = MP_OBJ_TO_PTR(self_in); + int nx = mp_obj_get_int(x_in), ny = mp_obj_get_int(y_in); + if (nx == self->x && ny == self->y) { + return mp_const_none; // unchanged -> avoid a full-tilemap repaint + } + int ox1, oy1, ox2, oy2; + picogame_tilemap_extent(self, &ox1, &oy1, &ox2, &oy2); + self->x = nx; + self->y = ny; + int nx1, ny1, nx2, ny2; + picogame_tilemap_extent(self, &nx1, &ny1, &nx2, &ny2); + picogame_tilemap_dirty_union(self, ox1, oy1, ox2, oy2); + picogame_tilemap_dirty_union(self, nx1, ny1, nx2, ny2); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_3(picogame_tilemap_move_obj, picogame_tilemap_move); + +//| def fill(self, value: int) -> None: +//| """Set every tile to ``value``.""" +//| ... +//| +static mp_obj_t picogame_tilemap_fill(mp_obj_t self_in, mp_obj_t value_in) { + picogame_tilemap_obj_t *self = MP_OBJ_TO_PTR(self_in); + uint8_t v = mp_obj_get_int(value_in) & 0xff; + size_t total = (size_t)self->map_w * self->map_h; + for (size_t i = 0; i < total; i++) { + self->map[i] = v; + if (self->orient) { + self->orient[i] = 0; // a plain fill clears any per-cell orientation + } + } + int x1, y1, x2, y2; + picogame_tilemap_extent(self, &x1, &y1, &x2, &y2); + picogame_tilemap_dirty_union(self, x1, y1, x2, y2); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(picogame_tilemap_fill_obj, picogame_tilemap_fill); + +//| x: int +//| y: int +//| """Current pixel position of the map's top-left (read-only; set with move()).""" +//| cols: int +//| rows: int +//| """Map size in tiles (read-only).""" +//| +//| +static mp_obj_t tilemap_get_x(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_tilemap_obj_t *)MP_OBJ_TO_PTR(self_in))->x); +} +static MP_DEFINE_CONST_FUN_OBJ_1(tilemap_get_x_obj, tilemap_get_x); +MP_PROPERTY_GETTER(tilemap_x_obj, (mp_obj_t)&tilemap_get_x_obj); + +static mp_obj_t tilemap_get_y(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_tilemap_obj_t *)MP_OBJ_TO_PTR(self_in))->y); +} +static MP_DEFINE_CONST_FUN_OBJ_1(tilemap_get_y_obj, tilemap_get_y); +MP_PROPERTY_GETTER(tilemap_y_obj, (mp_obj_t)&tilemap_get_y_obj); + +static mp_obj_t tilemap_get_cols(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_tilemap_obj_t *)MP_OBJ_TO_PTR(self_in))->map_w); +} +static MP_DEFINE_CONST_FUN_OBJ_1(tilemap_get_cols_obj, tilemap_get_cols); +MP_PROPERTY_GETTER(tilemap_cols_obj, (mp_obj_t)&tilemap_get_cols_obj); + +static mp_obj_t tilemap_get_rows(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_tilemap_obj_t *)MP_OBJ_TO_PTR(self_in))->map_h); +} +static MP_DEFINE_CONST_FUN_OBJ_1(tilemap_get_rows_obj, tilemap_get_rows); +MP_PROPERTY_GETTER(tilemap_rows_obj, (mp_obj_t)&tilemap_get_rows_obj); + +static const mp_rom_map_elem_t picogame_tilemap_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_tile), MP_ROM_PTR(&picogame_tilemap_tile_obj) }, + { MP_ROM_QSTR(MP_QSTR_move), MP_ROM_PTR(&picogame_tilemap_move_obj) }, + { MP_ROM_QSTR(MP_QSTR_fill), MP_ROM_PTR(&picogame_tilemap_fill_obj) }, + { MP_ROM_QSTR(MP_QSTR_x), MP_ROM_PTR(&tilemap_x_obj) }, + { MP_ROM_QSTR(MP_QSTR_y), MP_ROM_PTR(&tilemap_y_obj) }, + { MP_ROM_QSTR(MP_QSTR_cols), MP_ROM_PTR(&tilemap_cols_obj) }, + { MP_ROM_QSTR(MP_QSTR_rows), MP_ROM_PTR(&tilemap_rows_obj) }, +}; +static MP_DEFINE_CONST_DICT(picogame_tilemap_locals_dict, picogame_tilemap_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + picogame_tilemap_type, + MP_QSTR_Tilemap, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, picogame_tilemap_make_new, + locals_dict, &picogame_tilemap_locals_dict + ); diff --git a/shared-bindings/picogame/Tilemap.h b/shared-bindings/picogame/Tilemap.h new file mode 100644 index 00000000000..9c1308f9eaa --- /dev/null +++ b/shared-bindings/picogame/Tilemap.h @@ -0,0 +1,11 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "shared-module/picogame/Tilemap.h" + +extern const mp_obj_type_t picogame_tilemap_type; diff --git a/shared-bindings/picogame/__init__.c b/shared-bindings/picogame/__init__.c new file mode 100644 index 00000000000..273439468de --- /dev/null +++ b/shared-bindings/picogame/__init__.c @@ -0,0 +1,1940 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT +// +// picogame: 2D game engine bindings for the PicoPad and similar boards. +// Type definitions are consolidated here so the module has a single +// shared-bindings/shared-module .c pair (CircuitPython build convention). + +#include "py/runtime.h" +#include "shared-module/picogame/pg_compat.h" +#include "shared-bindings/busdisplay/BusDisplay.h" +#include "shared-bindings/picogame/__init__.h" +#include "shared-bindings/picogame/Bitmap.h" +#include "shared-bindings/picogame/Sprite.h" +#if CIRCUITPY_PICOGAME_FAST_DISPLAY +#include "shared-bindings/picogame/Display.h" // fast DMA backend; absent on portable ports +#include "common-hal/picogame/Display.h" // its struct (pg_get_display unwraps the wrapper) +#endif +#include "shared-bindings/picogame/Scene.h" +#include "shared-bindings/picogame/Tilemap.h" +#include "shared-bindings/picogame/Particles.h" +#include "shared-bindings/picogame/Canvas.h" +#include "shared-bindings/picogame/Framebuffer.h" +#include "shared-module/picogame/__init__.h" +#include "shared-module/picogame/Bitmap.h" +#include "shared-module/picogame/Sprite.h" + +// --------------------------------------------------------------------------- +// Bitmap +// --------------------------------------------------------------------------- + +//| class Bitmap: +//| """An image atlas of one or more equal-size frames, of arbitrary size. +//| +//| Unlike ``_stage`` (fixed 16x16 tiles), frames may be any width/height. +//| Pixel data and palette entries must be in the display's wire byte order +//| (use :py:func:`picogame.rgb565` to build colors).""" +//| +//| def __init__( +//| self, +//| data: ReadableBuffer, +//| width: int, +//| height: int, +//| *, +//| format: int = RGB565, +//| palette: Optional[ReadableBuffer] = None, +//| frames: int = 1, +//| stride: int = 0, +//| transparent: Optional[int] = None, +//| ) -> None: ... +//| +// Int pixel/scale -> 24.8 fixed-point. Shift through unsigned so a wild coordinate (e.g. 100_000_000, +// in 32-bit mp_int range but not after <<8) wraps modularly instead of hitting signed-overflow UB. +// Costs nothing over a plain shift; for any real on-screen coordinate the result is identical. +static int32_t pg_int_to_fp8(mp_int_t v) { + return (int32_t)((uint32_t)v << 8); +} + +static mp_obj_t picogame_bitmap_make_new(const mp_obj_type_t *type, size_t n_args, + size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_data, ARG_width, ARG_height, ARG_format, ARG_palette, ARG_frames, ARG_stride, ARG_transparent }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_data, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_width, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_height, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_format, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = PICOGAME_FMT_RGB565} }, + { MP_QSTR_palette, MP_ARG_KW_ONLY | MP_ARG_OBJ, {.u_obj = mp_const_none} }, + { MP_QSTR_frames, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 1} }, + { MP_QSTR_stride, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_transparent, MP_ARG_KW_ONLY | MP_ARG_OBJ, {.u_obj = mp_const_none} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + // bound BEFORE any arithmetic: an unbounded width could overflow width*frames (int32) and wrap + // small, slipping past the size guards below into an undersized buffer (OOB read in the blitter). + mp_int_t width = mp_arg_validate_int_range(args[ARG_width].u_int, 1, 65535, MP_QSTR_width); + mp_int_t height = mp_arg_validate_int_range(args[ARG_height].u_int, 1, 65535, MP_QSTR_height); + mp_int_t frames = mp_arg_validate_int_range(args[ARG_frames].u_int, 1, 255, MP_QSTR_frames); + mp_int_t format = args[ARG_format].u_int; + if (format != PICOGAME_FMT_RGB565 && format != PICOGAME_FMT_PAL8) { + mp_raise_ValueError(MP_ERROR_TEXT("Invalid format")); + } + mp_int_t stride = args[ARG_stride].u_int; + if (stride <= 0) { + stride = width * frames; + } + // stride must hold the whole horizontal atlas, and the dims must fit the uint16_t fields, + // or blits index past a row / the stored stride truncates. + mp_arg_validate_int_max(width * frames, 65535, MP_QSTR_width); + mp_arg_validate_int_max(stride, 65535, MP_QSTR_stride); + mp_arg_validate_int_min(stride, width * frames, MP_QSTR_stride); + + mp_buffer_info_t data_info; + mp_get_buffer_raise(args[ARG_data].u_obj, &data_info, MP_BUFFER_READ); + + const uint16_t *palette = NULL; + mp_obj_t palette_obj = MP_OBJ_NULL; + size_t pal_len = 0; + if (format == PICOGAME_FMT_PAL8) { + if (args[ARG_palette].u_obj == mp_const_none) { + mp_raise_ValueError(MP_ERROR_TEXT("PAL8 needs a palette")); + } + mp_buffer_info_t pal_info; + mp_get_buffer_raise(args[ARG_palette].u_obj, &pal_info, MP_BUFFER_READ); + palette = pal_info.buf; + palette_obj = args[ARG_palette].u_obj; + pal_len = pal_info.len; + } + + size_t bpp = (format == PICOGAME_FMT_PAL8) ? 1 : 2; + // 64-bit: stride*height*bpp can exceed 32 bits (stride,height <= 65535) and wrap small in a 32-bit + // size_t, letting a tiny buffer pass this check -> OOB read in the blitter. Compute + compare wide. + uint64_t need = (uint64_t)stride * (uint64_t)height * (uint64_t)bpp; + if ((uint64_t)data_info.len < need) { + mp_raise_ValueError(MP_ERROR_TEXT("buffer too small")); + } + if (format == PICOGAME_FMT_PAL8 && pal_len < 2) { + // Need >=1 entry so pal[0] is valid. Contract (see blitter): PAL8 indices MUST be < palette + // length. The caller may write indices into the (mutable) `data` buffer directly, so we cannot + // validate once here. An out-of-range index is undefined behaviour (reads past the palette: + // usually a garbage colour, but it may fault on some platforms). Per-pixel clamping was dropped + // for speed - see the "restore full bounds-safety" note in the PAL8 blit loop if it's needed. + mp_raise_ValueError(MP_ERROR_TEXT("palette is empty")); + } + + picogame_bitmap_obj_t *self = mp_obj_malloc(picogame_bitmap_obj_t, type); + self->data_obj = args[ARG_data].u_obj; + self->palette_obj = palette_obj; + self->data = data_info.buf; + self->palette = palette; + self->width = width; + self->height = height; + self->stride = stride; + self->frames = frames; + self->format = format; + // palette length in entries (informational; blitter assumes indices < this - see blit contract). + self->pal_entries = (uint16_t)((pal_len / 2) > 65535 ? 65535 : (pal_len / 2)); + if (args[ARG_transparent].u_obj != mp_const_none) { + self->transparent = mp_obj_get_int(args[ARG_transparent].u_obj); + self->has_transparent = true; + } else { + self->transparent = 0; + self->has_transparent = false; + } + return MP_OBJ_FROM_PTR(self); +} + +//| +//| width: int +//| height: int +//| frames: int +//| """Frame dimensions and frame count (read-only).""" +static mp_obj_t bitmap_get_width(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_bitmap_obj_t *)MP_OBJ_TO_PTR(self_in))->width); +} +static MP_DEFINE_CONST_FUN_OBJ_1(bitmap_get_width_obj, bitmap_get_width); +MP_PROPERTY_GETTER(bitmap_width_obj, (mp_obj_t)&bitmap_get_width_obj); + +static mp_obj_t bitmap_get_height(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_bitmap_obj_t *)MP_OBJ_TO_PTR(self_in))->height); +} +static MP_DEFINE_CONST_FUN_OBJ_1(bitmap_get_height_obj, bitmap_get_height); +MP_PROPERTY_GETTER(bitmap_height_obj, (mp_obj_t)&bitmap_get_height_obj); + +static mp_obj_t bitmap_get_frames(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_bitmap_obj_t *)MP_OBJ_TO_PTR(self_in))->frames); +} +static MP_DEFINE_CONST_FUN_OBJ_1(bitmap_get_frames_obj, bitmap_get_frames); +MP_PROPERTY_GETTER(bitmap_frames_obj, (mp_obj_t)&bitmap_get_frames_obj); + +//| format: int +//| """RGB565 or PAL8 (read-only).""" +static mp_obj_t bitmap_get_format(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_bitmap_obj_t *)MP_OBJ_TO_PTR(self_in))->format); +} +static MP_DEFINE_CONST_FUN_OBJ_1(bitmap_get_format_obj, bitmap_get_format); +MP_PROPERTY_GETTER(bitmap_format_obj, (mp_obj_t)&bitmap_get_format_obj); + +//| stride: int +//| """Row stride in pixels (read-only).""" +static mp_obj_t bitmap_get_stride(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_bitmap_obj_t *)MP_OBJ_TO_PTR(self_in))->stride); +} +static MP_DEFINE_CONST_FUN_OBJ_1(bitmap_get_stride_obj, bitmap_get_stride); +MP_PROPERTY_GETTER(bitmap_stride_obj, (mp_obj_t)&bitmap_get_stride_obj); + +//| palette: Optional[ReadableBuffer] +//| """The PAL8 palette buffer this Bitmap was built with, or None for RGB565 +//| (read-only). Lets palette helpers read it back instead of holding a sidecar ref.""" +static mp_obj_t bitmap_get_palette(mp_obj_t self_in) { + picogame_bitmap_obj_t *self = MP_OBJ_TO_PTR(self_in); + return (self->palette_obj == MP_OBJ_NULL) ? mp_const_none : self->palette_obj; +} +static MP_DEFINE_CONST_FUN_OBJ_1(bitmap_get_palette_obj, bitmap_get_palette); +MP_PROPERTY_GETTER(bitmap_palette_obj, (mp_obj_t)&bitmap_get_palette_obj); + +//| transparent: Optional[int] +//| """The transparent color/index, or None if the Bitmap is fully opaque (read-only).""" +//| +//| +static mp_obj_t bitmap_get_transparent(mp_obj_t self_in) { + picogame_bitmap_obj_t *self = MP_OBJ_TO_PTR(self_in); + return self->has_transparent ? MP_OBJ_NEW_SMALL_INT(self->transparent) : mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(bitmap_get_transparent_obj, bitmap_get_transparent); +MP_PROPERTY_GETTER(bitmap_transparent_obj, (mp_obj_t)&bitmap_get_transparent_obj); + +static const mp_rom_map_elem_t picogame_bitmap_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_width), MP_ROM_PTR(&bitmap_width_obj) }, + { MP_ROM_QSTR(MP_QSTR_height), MP_ROM_PTR(&bitmap_height_obj) }, + { MP_ROM_QSTR(MP_QSTR_frames), MP_ROM_PTR(&bitmap_frames_obj) }, + { MP_ROM_QSTR(MP_QSTR_format), MP_ROM_PTR(&bitmap_format_obj) }, + { MP_ROM_QSTR(MP_QSTR_stride), MP_ROM_PTR(&bitmap_stride_obj) }, + { MP_ROM_QSTR(MP_QSTR_palette), MP_ROM_PTR(&bitmap_palette_obj) }, + { MP_ROM_QSTR(MP_QSTR_transparent), MP_ROM_PTR(&bitmap_transparent_obj) }, +}; +static MP_DEFINE_CONST_DICT(picogame_bitmap_locals_dict, picogame_bitmap_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + picogame_bitmap_type, + MP_QSTR_Bitmap, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, picogame_bitmap_make_new, + locals_dict, &picogame_bitmap_locals_dict + ); + +// --------------------------------------------------------------------------- +// Sprite +// --------------------------------------------------------------------------- + +//| class Sprite: +//| """A positioned, animatable instance of a :py:class:`Bitmap`.""" +//| +//| def __init__( +//| self, +//| bitmap: Bitmap, +//| x: int = 0, +//| y: int = 0, +//| *, +//| frame: int = 0, +//| visible: bool = True, +//| flip_x: bool = False, +//| flip_y: bool = False, +//| ) -> None: ... +//| +static mp_obj_t picogame_sprite_make_new(const mp_obj_type_t *type, size_t n_args, + size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_bitmap, ARG_x, ARG_y, ARG_frame, ARG_visible, ARG_flip_x, ARG_flip_y }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_bitmap, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_x, MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_y, MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_frame, MP_ARG_KW_ONLY | MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_visible, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = true} }, + { MP_QSTR_flip_x, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + { MP_QSTR_flip_y, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + mp_obj_t bitmap_obj = mp_arg_validate_type(args[ARG_bitmap].u_obj, &picogame_bitmap_type, MP_QSTR_bitmap); + + picogame_sprite_obj_t *self = mp_obj_malloc(picogame_sprite_obj_t, type); + self->bitmap = MP_OBJ_TO_PTR(bitmap_obj); + self->x = pg_int_to_fp8(args[ARG_x].u_int); // pixel -> 24.8 fixed-point (overflow-clamped) + self->y = pg_int_to_fp8(args[ARG_y].u_int); + self->frame = args[ARG_frame].u_int; + self->flags = (args[ARG_visible].u_bool ? PICOGAME_SPR_VISIBLE : 0) + | (args[ARG_flip_x].u_bool ? PICOGAME_SPR_FLIP_X : 0) + | (args[ARG_flip_y].u_bool ? PICOGAME_SPR_FLIP_Y : 0); + self->anchor_x = 0; // default pivot = top-left (0, 0) + self->anchor_y = 0; + self->scale = 256; // 8.8 fixed-point: 256 = 1.0x (no scaling) + self->angle = 0; // no rotation -> axis-aligned fast path + self->flash_color = 0; + self->dither = 0; + self->data = mp_const_none; + self->seq = 0; // dirty-rect change counter (mp_obj_malloc zeroes anyway; explicit) + return MP_OBJ_FROM_PTR(self); +} + +static void set_flag(picogame_sprite_obj_t *self, uint8_t flag, bool on) { + if (on) { + self->flags |= flag; + } else { + self->flags &= ~flag; + } +} + +// shadow / flash / dither are mutually exclusive (one blit effect at a time); setting one ON +// clears the others, so "the last effect you set wins". Turning one OFF clears ONLY its own flag, +// so clearing an effect you never enabled (e.g. spr.flash = 0) can't wipe a different active one. +#define PICOGAME_SPR_FX_MASK (PICOGAME_SPR_SHADOW | PICOGAME_SPR_FLASH | PICOGAME_SPR_DITHER | PICOGAME_SPR_TINT) +static void set_effect(picogame_sprite_obj_t *self, uint8_t flag, bool on) { + if (on) { + self->flags = (uint8_t)((self->flags & ~PICOGAME_SPR_FX_MASK) | flag); + } else { + self->flags &= (uint8_t) ~flag; + } +} + +// Round a float to 24.8 fixed-point (shared by the position/scale/anchor setters so the soft-float +// round sequence is emitted once, not per call site). +static int32_t pg_round_fp8(mp_float_t f) { + return (int32_t)(f * 256 + (f >= 0 ? (mp_float_t)0.5 : (mp_float_t)-0.5)); +} + +// Cast a (possibly subclassed) BusDisplay arg to its native object, raising if it isn't one. +// Also accepts the pg.Display fast-DMA wrapper (unwrapped to its underlying busdisplay - the +// portable send path): any handle that identifies the panel works wherever a display is +// expected, so the same object a Scene renders through also works for render()/invert(). +// Without this, code holding the wrapper (custom setup, rgb444) worked on ports WITHOUT the +// fast backend and TypeError'd on ports WITH it. +static busdisplay_busdisplay_obj_t *pg_get_display(mp_obj_t obj) { + #if CIRCUITPY_PICOGAME_FAST_DISPLAY + if (mp_obj_is_type(obj, &picogame_display_type)) { + return ((picogame_display_obj_t *)MP_OBJ_TO_PTR(obj))->display; + } + #endif + mp_obj_t native = mp_obj_cast_to_native_base(obj, &busdisplay_busdisplay_type); + if (!mp_obj_is_type(native, &busdisplay_busdisplay_type)) { + mp_raise_TypeError(MP_ERROR_TEXT("expected a BusDisplay")); + } + return MP_OBJ_TO_PTR(native); +} + +// Accept an int or float and store as 24.8 fixed-point (rounded). Integer fast path avoids +// software float on RP2040 - game code sets x/y (via move/setters) every frame, usually with ints. +static int32_t obj_to_fp(mp_obj_t o) { + if (mp_obj_is_int(o)) { + return pg_int_to_fp8(mp_obj_get_int(o)); + } + mp_float_t f = mp_obj_get_float(o); + return pg_round_fp8(f); +} + +//| +//| x: int +//| y: int +//| """Integer pixel position (scene coords). Setting accepts a float for +//| sub-pixel placement; reading returns the floored pixel.""" +//| fx: float +//| fy: float +//| """Sub-pixel position (use for smooth physics: e.g. ``sprite.fx += 2.4``).""" +// FLASH NOTE (property objects vs. a single `attr` handler): Sprite (and the other property-dense +// types) expose each attribute as its own getter/setter + MP_PROPERTY object below - the standard +// CircuitPython shared-bindings idiom (~423 such uses across CP). Collapsing these onto ONE `attr` +// load/store function per type (a qstr switch, like py/objcomplex.c's complex_attr for .real/.imag) +// would save ~3-4 KB of flash across the whole binding layer (Sprite alone ~1.5-2.5 KB). It is a +// valid MicroPython mechanism and would NOT break the .pyi stubs (those are generated from the //| +// comments, not the C property objects). We deliberately KEEP property objects: they are the +// idiomatic, most readable shared-bindings form and keep every type's definition uniform. Revisit +// only if flash becomes critical (then convert Sprite first - the densest cluster - not every type). +static mp_obj_t sprite_get_x(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->x >> 8); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_x_obj, sprite_get_x); +static mp_obj_t sprite_set_x(mp_obj_t self_in, mp_obj_t v) { + ((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->x = obj_to_fp(v); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_x_obj, sprite_set_x); +MP_PROPERTY_GETSET(sprite_x_obj, (mp_obj_t)&sprite_get_x_obj, (mp_obj_t)&sprite_set_x_obj); + +static mp_obj_t sprite_get_y(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->y >> 8); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_y_obj, sprite_get_y); +static mp_obj_t sprite_set_y(mp_obj_t self_in, mp_obj_t v) { + ((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->y = obj_to_fp(v); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_y_obj, sprite_set_y); +MP_PROPERTY_GETSET(sprite_y_obj, (mp_obj_t)&sprite_get_y_obj, (mp_obj_t)&sprite_set_y_obj); + +static mp_obj_t sprite_get_fx(mp_obj_t self_in) { + return mp_obj_new_float(((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->x * (mp_float_t)(1.0 / 256.0)); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_fx_obj, sprite_get_fx); +// set_fx is byte-identical to set_x (both store obj_to_fp(v) into ->x) -> reuse set_x's fun obj. +MP_PROPERTY_GETSET(sprite_fx_obj, (mp_obj_t)&sprite_get_fx_obj, (mp_obj_t)&sprite_set_x_obj); + +static mp_obj_t sprite_get_fy(mp_obj_t self_in) { + return mp_obj_new_float(((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->y * (mp_float_t)(1.0 / 256.0)); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_fy_obj, sprite_get_fy); +// set_fy is byte-identical to set_y -> reuse set_y's fun obj. +MP_PROPERTY_GETSET(sprite_fy_obj, (mp_obj_t)&sprite_get_fy_obj, (mp_obj_t)&sprite_set_y_obj); + +static mp_obj_t sprite_get_frame(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->frame); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_frame_obj, sprite_get_frame); +static mp_obj_t sprite_set_frame(mp_obj_t self_in, mp_obj_t v) { + ((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->frame = mp_obj_get_int(v); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_frame_obj, sprite_set_frame); +MP_PROPERTY_GETSET(sprite_frame_obj, (mp_obj_t)&sprite_get_frame_obj, (mp_obj_t)&sprite_set_frame_obj); + +static mp_obj_t sprite_get_visible(mp_obj_t self_in) { + return mp_obj_new_bool((((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->flags & PICOGAME_SPR_VISIBLE) != 0); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_visible_obj, sprite_get_visible); +static mp_obj_t sprite_set_visible(mp_obj_t self_in, mp_obj_t v) { + set_flag(MP_OBJ_TO_PTR(self_in), PICOGAME_SPR_VISIBLE, mp_obj_is_true(v)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_visible_obj, sprite_set_visible); +MP_PROPERTY_GETSET(sprite_visible_obj, (mp_obj_t)&sprite_get_visible_obj, (mp_obj_t)&sprite_set_visible_obj); + +static mp_obj_t sprite_get_flip_x(mp_obj_t self_in) { + return mp_obj_new_bool((((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->flags & PICOGAME_SPR_FLIP_X) != 0); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_flip_x_obj, sprite_get_flip_x); +static mp_obj_t sprite_set_flip_x(mp_obj_t self_in, mp_obj_t v) { + set_flag(MP_OBJ_TO_PTR(self_in), PICOGAME_SPR_FLIP_X, mp_obj_is_true(v)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_flip_x_obj, sprite_set_flip_x); +MP_PROPERTY_GETSET(sprite_flip_x_obj, (mp_obj_t)&sprite_get_flip_x_obj, (mp_obj_t)&sprite_set_flip_x_obj); + +static mp_obj_t sprite_get_flip_y(mp_obj_t self_in) { + return mp_obj_new_bool((((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->flags & PICOGAME_SPR_FLIP_Y) != 0); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_flip_y_obj, sprite_get_flip_y); +static mp_obj_t sprite_set_flip_y(mp_obj_t self_in, mp_obj_t v) { + set_flag(MP_OBJ_TO_PTR(self_in), PICOGAME_SPR_FLIP_Y, mp_obj_is_true(v)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_flip_y_obj, sprite_set_flip_y); +MP_PROPERTY_GETSET(sprite_flip_y_obj, (mp_obj_t)&sprite_get_flip_y_obj, (mp_obj_t)&sprite_set_flip_y_obj); + +//| scale: float +//| """Uniform draw scale (nearest-neighbour). 1.0 = native (fast path); 2.0 = double +//| size, fractional values are allowed (e.g. a powerup grow tween). Anchor stays put.""" +static mp_obj_t sprite_get_scale(mp_obj_t self_in) { + return mp_obj_new_float(((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->scale * (mp_float_t)(1.0 / 256.0)); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_scale_obj, sprite_get_scale); +static mp_obj_t sprite_set_scale(mp_obj_t self_in, mp_obj_t v) { + int q; + if (mp_obj_is_int(v)) { // int fast path (no software float on RP2040) + q = pg_int_to_fp8(mp_obj_get_int(v)); // overflow-clamped <<8 + } else { + mp_float_t f = mp_obj_get_float(v); + q = pg_round_fp8(f); + } + if (q < 1) { + q = 1; + } + if (q > 65535) { + q = 65535; + } + picogame_sprite_obj_t *self = MP_OBJ_TO_PTR(self_in); + self->scale = (uint16_t)q; + self->xf_valid = 0; // affine cache depends on scale + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_scale_obj, sprite_set_scale); +MP_PROPERTY_GETSET(sprite_scale_obj, (mp_obj_t)&sprite_get_scale_obj, (mp_obj_t)&sprite_set_scale_obj); + +//| angle: float +//| """Rotation in degrees about the anchor (0 = none, the fast path). Nearest-neighbour, +//| so integer scales stay crisp; rotation shimmers slightly (pixel-art trade-off).""" +static mp_obj_t sprite_get_angle(mp_obj_t self_in) { + return mp_obj_new_float((mp_float_t)((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->angle); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_angle_obj, sprite_get_angle); +static mp_obj_t sprite_set_angle(mp_obj_t self_in, mp_obj_t v) { + int a; + if (mp_obj_is_int(v)) { // int fast path (no software float on RP2040) + a = mp_obj_get_int(v); + } else { + mp_float_t f = mp_obj_get_float(v); + a = (int)(f + (f >= 0 ? (mp_float_t)0.5 : (mp_float_t)-0.5)); + } + a %= 360; + picogame_sprite_obj_t *self = MP_OBJ_TO_PTR(self_in); + self->angle = (int16_t)a; + self->xf_valid = 0; // affine cache depends on angle + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_angle_obj, sprite_set_angle); +MP_PROPERTY_GETSET(sprite_angle_obj, (mp_obj_t)&sprite_get_angle_obj, (mp_obj_t)&sprite_set_angle_obj); + +//| shadow: bool +//| """Draw opaque pixels as a darkened destination instead of colour - a drop-shadow +//| silhouette (offset copy below the sprite) or a dim overlay (a solid sprite scaled +//| over a dialog/pause area).""" +static mp_obj_t sprite_get_shadow(mp_obj_t self_in) { + return mp_obj_new_bool((((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->flags & PICOGAME_SPR_SHADOW) != 0); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_shadow_obj, sprite_get_shadow); +static mp_obj_t sprite_set_shadow(mp_obj_t self_in, mp_obj_t v) { + set_effect(MP_OBJ_TO_PTR(self_in), PICOGAME_SPR_SHADOW, mp_obj_is_true(v)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_shadow_obj, sprite_set_shadow); +MP_PROPERTY_GETSET(sprite_shadow_obj, (mp_obj_t)&sprite_get_shadow_obj, (mp_obj_t)&sprite_set_shadow_obj); + +//| flash: int +//| """Draw opaque pixels as a solid colour (a wire-order RGB565 int from rgb565) instead +//| of their own colour - a hit-flash or tint. Set to a colour to enable, 0/False to turn +//| off. Pulse it for 1-3 frames on impact. Mutually exclusive with shadow/dither.""" +static mp_obj_t sprite_get_flash(mp_obj_t self_in) { + picogame_sprite_obj_t *s = MP_OBJ_TO_PTR(self_in); + return MP_OBJ_NEW_SMALL_INT((s->flags & PICOGAME_SPR_FLASH) ? s->flash_color : 0); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_flash_obj, sprite_get_flash); +static mp_obj_t sprite_set_flash(mp_obj_t self_in, mp_obj_t v) { + picogame_sprite_obj_t *s = MP_OBJ_TO_PTR(self_in); + // Falsy (None / False / 0) turns flash OFF. A non-zero colour enables it. (You can't flash + // pure black - use a near-black colour if you ever need that; off is the common case.) + if (!mp_obj_is_true(v)) { + set_effect(s, PICOGAME_SPR_FLASH, false); + } else { + s->flash_color = (uint16_t)mp_obj_get_int(v); + set_effect(s, PICOGAME_SPR_FLASH, true); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_flash_obj, sprite_set_flash); +MP_PROPERTY_GETSET(sprite_flash_obj, (mp_obj_t)&sprite_get_flash_obj, (mp_obj_t)&sprite_set_flash_obj); + +//| dither: int +//| """Fake transparency via an ordered (Bayer) dither, no alpha blending: 0 = opaque +//| (off), 8 = ~50% see-through, 16 = invisible. A classic 1-bit look - for ghosts, +//| fading/spawning enemies, fog, force fields. Mutually exclusive with shadow/flash.""" +static mp_obj_t sprite_get_dither(mp_obj_t self_in) { + picogame_sprite_obj_t *s = MP_OBJ_TO_PTR(self_in); + return MP_OBJ_NEW_SMALL_INT((s->flags & PICOGAME_SPR_DITHER) ? s->dither : 0); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_dither_obj, sprite_get_dither); +static mp_obj_t sprite_set_dither(mp_obj_t self_in, mp_obj_t v) { + picogame_sprite_obj_t *s = MP_OBJ_TO_PTR(self_in); + int lv = mp_obj_get_int(v); + if (lv < 0) { + lv = 0; + } + if (lv > 16) { + lv = 16; + } + s->dither = (uint8_t)lv; + set_effect(s, PICOGAME_SPR_DITHER, lv > 0); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_dither_obj, sprite_set_dither); +MP_PROPERTY_GETSET(sprite_dither_obj, (mp_obj_t)&sprite_get_dither_obj, (mp_obj_t)&sprite_set_dither_obj); + +//| tint: int +//| """Multiply opaque pixels by a colour (wire-order RGB565 from rgb565), keeping the +//| sprite's shading - coloured lighting, a red damage flush, a blue freeze, a power-up +//| glow. Unlike ``flash`` (flat replace) ``tint`` preserves detail. 0/False = off. Mutually +//| exclusive with shadow/flash/dither.""" +static mp_obj_t sprite_get_tint(mp_obj_t self_in) { + picogame_sprite_obj_t *s = MP_OBJ_TO_PTR(self_in); + return MP_OBJ_NEW_SMALL_INT((s->flags & PICOGAME_SPR_TINT) ? s->flash_color : 0); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_tint_obj, sprite_get_tint); +static mp_obj_t sprite_set_tint(mp_obj_t self_in, mp_obj_t v) { + picogame_sprite_obj_t *s = MP_OBJ_TO_PTR(self_in); + if (!mp_obj_is_true(v)) { + set_effect(s, PICOGAME_SPR_TINT, false); + } else { + s->flash_color = (uint16_t)mp_obj_get_int(v); // shared colour field with flash + set_effect(s, PICOGAME_SPR_TINT, true); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_tint_obj, sprite_set_tint); +MP_PROPERTY_GETSET(sprite_tint_obj, (mp_obj_t)&sprite_get_tint_obj, (mp_obj_t)&sprite_set_tint_obj); + +//| transpose: bool +//| """Swap the sprite's X/Y axes - a cheap 90deg turn (no shimmer, unlike ``angle``). +//| Combined with ``flip_x``/``flip_y`` it gives all 8 orientations for free. Only on the fast +//| path (scale 1.0, angle 0); for rotation WITH scaling use ``angle``. The drawn footprint +//| swaps width/height.""" +static mp_obj_t sprite_get_transpose(mp_obj_t self_in) { + return mp_obj_new_bool((((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->flags & PICOGAME_SPR_TRANSPOSE) != 0); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_transpose_obj, sprite_get_transpose); +static mp_obj_t sprite_set_transpose(mp_obj_t self_in, mp_obj_t v) { + set_flag(MP_OBJ_TO_PTR(self_in), PICOGAME_SPR_TRANSPOSE, mp_obj_is_true(v)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_transpose_obj, sprite_set_transpose); +MP_PROPERTY_GETSET(sprite_transpose_obj, (mp_obj_t)&sprite_get_transpose_obj, (mp_obj_t)&sprite_set_transpose_obj); + +//| data: Any +//| """Arbitrary per-sprite user payload for game state (default None).""" +static mp_obj_t sprite_get_data(mp_obj_t self_in) { + return ((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->data; +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_data_obj, sprite_get_data); +static mp_obj_t sprite_set_data(mp_obj_t self_in, mp_obj_t v) { + ((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->data = v; + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_data_obj, sprite_set_data); +MP_PROPERTY_GETSET(sprite_data_obj, (mp_obj_t)&sprite_get_data_obj, (mp_obj_t)&sprite_set_data_obj); + +//| bitmap: Bitmap +//| """The sprite's source bitmap. Assigning a new one swaps graphics and may +//| change size; the scene repaints both the old and new bounds next refresh +//| (e.g. powerups, resizable HUD bars, text labels).""" +static mp_obj_t sprite_get_bitmap(mp_obj_t self_in) { + picogame_sprite_obj_t *self = MP_OBJ_TO_PTR(self_in); + return self->bitmap != NULL ? MP_OBJ_FROM_PTR(self->bitmap) : mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_bitmap_obj, sprite_get_bitmap); +static mp_obj_t sprite_set_bitmap(mp_obj_t self_in, mp_obj_t v) { + picogame_sprite_obj_t *self = MP_OBJ_TO_PTR(self_in); + mp_obj_t bm = mp_arg_validate_type(v, &picogame_bitmap_type, MP_QSTR_bitmap); + self->bitmap = MP_OBJ_TO_PTR(bm); + self->xf_valid = 0; // affine cache depends on bitmap dims + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_bitmap_obj, sprite_set_bitmap); +MP_PROPERTY_GETSET(sprite_bitmap_obj, (mp_obj_t)&sprite_get_bitmap_obj, (mp_obj_t)&sprite_set_bitmap_obj); + +//| anchor: Tuple[float, float] +//| """Pivot as fractions of the bitmap size: ``(0, 0)`` = top-left (default), +//| ``(0.5, 0.5)`` = center, ``(0.5, 1.0)`` = bottom-center. ``x``/``y`` then +//| refer to this point, so rotating frames or swapping to a different size +//| stays aligned. Stored in 1/256 steps.""" +//| +static mp_obj_t sprite_get_anchor(mp_obj_t self_in) { + picogame_sprite_obj_t *self = MP_OBJ_TO_PTR(self_in); + mp_obj_t t[2] = { + mp_obj_new_float(self->anchor_x * (mp_float_t)(1.0 / 256.0)), + mp_obj_new_float(self->anchor_y * (mp_float_t)(1.0 / 256.0)), + }; + return mp_obj_new_tuple(2, t); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_get_anchor_obj, sprite_get_anchor); +static int anchor_to_fp(mp_obj_t o) { + mp_float_t f = mp_obj_get_float(o); + int v = pg_round_fp8(f); + return v < 0 ? 0 : (v > 256 ? 256 : v); +} +static mp_obj_t sprite_set_anchor(mp_obj_t self_in, mp_obj_t v) { + picogame_sprite_obj_t *self = MP_OBJ_TO_PTR(self_in); + size_t len; + mp_obj_t *items; + mp_obj_get_array(v, &len, &items); + mp_arg_validate_length(len, 2, MP_QSTR_anchor); + self->anchor_x = anchor_to_fp(items[0]); + self->anchor_y = anchor_to_fp(items[1]); + self->xf_valid = 0; // affine cache depends on the pivot + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sprite_set_anchor_obj, sprite_set_anchor); +MP_PROPERTY_GETSET(sprite_anchor_obj, (mp_obj_t)&sprite_get_anchor_obj, (mp_obj_t)&sprite_set_anchor_obj); + +//| def move(self, x: int, y: int) -> None: +//| """Set the sprite position.""" +//| ... +//| +static mp_obj_t sprite_move(mp_obj_t self_in, mp_obj_t x_in, mp_obj_t y_in) { + picogame_sprite_obj_t *self = MP_OBJ_TO_PTR(self_in); + self->x = obj_to_fp(x_in); + self->y = obj_to_fp(y_in); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_3(sprite_move_obj, sprite_move); + +//| def touch(self) -> None: +//| """Force this sprite to repaint on the next ``Scene.refresh()`` even though none +//| of its tracked properties (position, frame, scale, angle, bitmap) changed. Call +//| it after mutating the sprite's bitmap pixels IN PLACE (e.g. streaming a new frame +//| into the same buffer), which the dirty-rect tracker can't otherwise detect.""" +//| ... +//| +static mp_obj_t sprite_touch(mp_obj_t self_in) { + ((picogame_sprite_obj_t *)MP_OBJ_TO_PTR(self_in))->seq++; + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_1(sprite_touch_obj, sprite_touch); + +// Fill (x1,y1,x2,y2) from a Sprite (its drawn aabb), a (x,y) point, or a (x1,y1,x2,y2) rect. +static void pg_obj_to_box(mp_obj_t o, int *x1, int *y1, int *x2, int *y2) { + if (mp_obj_is_type(o, &picogame_sprite_type)) { + picogame_sprite_aabb(MP_OBJ_TO_PTR(o), x1, y1, x2, y2); + return; + } + if (mp_obj_is_type(o, &mp_type_tuple) || mp_obj_is_type(o, &mp_type_list)) { + size_t len; + mp_obj_t *items; + mp_obj_get_array(o, &len, &items); + if (len == 2) { // a point -> a zero-size box + *x1 = *x2 = mp_obj_get_int(items[0]); + *y1 = *y2 = mp_obj_get_int(items[1]); + return; + } + if (len == 4) { // a rect + *x1 = mp_obj_get_int(items[0]); + *y1 = mp_obj_get_int(items[1]); + *x2 = mp_obj_get_int(items[2]); + *y2 = mp_obj_get_int(items[3]); + return; + } + } + mp_raise_msg_varg(&mp_type_TypeError, MP_ERROR_TEXT("%q must be of type %q or %q, not %q"), + MP_QSTR_other, MP_QSTR_Sprite, MP_QSTR_tuple, mp_obj_get_type(o)->name); +} + +//| def overlaps(self, other: "Sprite | tuple", inset: int = 0) -> bool: +//| """True if this sprite's drawn box overlaps ``other`` - an inclusive AABB, so they +//| collide the moment they touch. ``other`` may be another Sprite, a point ``(x, y)``, +//| or a rect ``(x1, y1, x2, y2)`` (e.g. a trigger zone or the screen for culling). +//| The box is anchor/scale/rotation aware. ``inset`` shrinks THIS sprite's box by N px +//| on each side, for a fair hitbox smaller than the art.""" +//| ... +//| +static mp_obj_t sprite_overlaps(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_other, ARG_inset }; + static const mp_arg_t allowed[] = { + { MP_QSTR_other, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_inset, MP_ARG_INT, {.u_int = 0} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed)]; + mp_arg_parse_all(n_args - 1, pos_args + 1, kw_args, MP_ARRAY_SIZE(allowed), allowed, args); + int ax1, ay1, ax2, ay2, bx1, by1, bx2, by2; + picogame_sprite_aabb(MP_OBJ_TO_PTR(pos_args[0]), &ax1, &ay1, &ax2, &ay2); + pg_obj_to_box(args[ARG_other].u_obj, &bx1, &by1, &bx2, &by2); + int in = args[ARG_inset].u_int; // inset shrinks the CALLER's box (kw or positional) + bool hit = ((ax1 + in) <= bx2) && ((ax2 - in) >= bx1) && + ((ay1 + in) <= by2) && ((ay2 - in) >= by1); + return mp_obj_new_bool(hit); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(sprite_overlaps_obj, 2, sprite_overlaps); + +//| def near(self, other: "Sprite | tuple", r: int) -> bool: +//| """True if this sprite's centre is within ``r`` pixels of ``other``'s centre (squared +//| distance, no sqrt) - the round/forgiving test for bullets, pickups, explosions. +//| ``other`` may be a Sprite or a point ``(x, y)``. Centres come from the drawn box, so +//| it is anchor aware.""" +//| ... +//| +//| +static mp_obj_t sprite_near(mp_obj_t self_in, mp_obj_t other_in, mp_obj_t r_in) { + int ax1, ay1, ax2, ay2; + picogame_sprite_aabb(MP_OBJ_TO_PTR(self_in), &ax1, &ay1, &ax2, &ay2); + int acx = (ax1 + ax2) / 2, acy = (ay1 + ay2) / 2, bcx, bcy; + if (mp_obj_is_type(other_in, &picogame_sprite_type)) { + int bx1, by1, bx2, by2; + picogame_sprite_aabb(MP_OBJ_TO_PTR(other_in), &bx1, &by1, &bx2, &by2); + bcx = (bx1 + bx2) / 2; + bcy = (by1 + by2) / 2; + } else { + size_t len; + mp_obj_t *items; + mp_obj_get_array(other_in, &len, &items); + if (len != 2) { + mp_raise_msg_varg(&mp_type_TypeError, MP_ERROR_TEXT("%q must be of type %q or %q, not %q"), + MP_QSTR_other, MP_QSTR_Sprite, MP_QSTR_tuple, mp_obj_get_type(other_in)->name); + } + bcx = mp_obj_get_int(items[0]); + bcy = mp_obj_get_int(items[1]); + } + mp_int_t r = mp_obj_get_int(r_in), dx = acx - bcx, dy = acy - bcy; + return mp_obj_new_bool(dx * dx + dy * dy < r * r); +} +static MP_DEFINE_CONST_FUN_OBJ_3(sprite_near_obj, sprite_near); + +static const mp_rom_map_elem_t picogame_sprite_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_x), MP_ROM_PTR(&sprite_x_obj) }, + { MP_ROM_QSTR(MP_QSTR_y), MP_ROM_PTR(&sprite_y_obj) }, + { MP_ROM_QSTR(MP_QSTR_fx), MP_ROM_PTR(&sprite_fx_obj) }, + { MP_ROM_QSTR(MP_QSTR_fy), MP_ROM_PTR(&sprite_fy_obj) }, + { MP_ROM_QSTR(MP_QSTR_frame), MP_ROM_PTR(&sprite_frame_obj) }, + { MP_ROM_QSTR(MP_QSTR_visible), MP_ROM_PTR(&sprite_visible_obj) }, + { MP_ROM_QSTR(MP_QSTR_flip_x), MP_ROM_PTR(&sprite_flip_x_obj) }, + { MP_ROM_QSTR(MP_QSTR_flip_y), MP_ROM_PTR(&sprite_flip_y_obj) }, + { MP_ROM_QSTR(MP_QSTR_scale), MP_ROM_PTR(&sprite_scale_obj) }, + { MP_ROM_QSTR(MP_QSTR_angle), MP_ROM_PTR(&sprite_angle_obj) }, + { MP_ROM_QSTR(MP_QSTR_shadow), MP_ROM_PTR(&sprite_shadow_obj) }, + { MP_ROM_QSTR(MP_QSTR_flash), MP_ROM_PTR(&sprite_flash_obj) }, + { MP_ROM_QSTR(MP_QSTR_dither), MP_ROM_PTR(&sprite_dither_obj) }, + { MP_ROM_QSTR(MP_QSTR_tint), MP_ROM_PTR(&sprite_tint_obj) }, + { MP_ROM_QSTR(MP_QSTR_transpose), MP_ROM_PTR(&sprite_transpose_obj) }, + { MP_ROM_QSTR(MP_QSTR_data), MP_ROM_PTR(&sprite_data_obj) }, + { MP_ROM_QSTR(MP_QSTR_bitmap), MP_ROM_PTR(&sprite_bitmap_obj) }, + { MP_ROM_QSTR(MP_QSTR_anchor), MP_ROM_PTR(&sprite_anchor_obj) }, + { MP_ROM_QSTR(MP_QSTR_overlaps), MP_ROM_PTR(&sprite_overlaps_obj) }, + { MP_ROM_QSTR(MP_QSTR_near), MP_ROM_PTR(&sprite_near_obj) }, + { MP_ROM_QSTR(MP_QSTR_move), MP_ROM_PTR(&sprite_move_obj) }, + { MP_ROM_QSTR(MP_QSTR_touch), MP_ROM_PTR(&sprite_touch_obj) }, +}; +static MP_DEFINE_CONST_DICT(picogame_sprite_locals_dict, picogame_sprite_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + picogame_sprite_type, + MP_QSTR_Sprite, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, picogame_sprite_make_new, + locals_dict, &picogame_sprite_locals_dict + ); + +// --------------------------------------------------------------------------- +// StripDraw (immediate-mode draw layer; struct in shared-module/picogame/__init__.h) +// --------------------------------------------------------------------------- + +//| class StripDraw: +//| """An immediate-mode draw layer that holds NO pixel buffer. Each refresh, for +//| every render strip overlapping its rect, ``callback(view, vx, vy, vw, vh)`` is +//| called with a :py:class:`Canvas` ``view`` pointing straight at the live strip +//| buffer - so you draw primitives directly into the frame (zero RAM, vs a Canvas +//| which costs width*height*2 bytes). The view's local (0, 0) is screen pixel +//| (vx, vy); (vw, vh) is the strip size. Draw only the rows in [vy, vy+vh) for +//| speed (anything outside the view is clipped anyway). The rect is repainted every +//| frame, so use it for animated / scanline content (pseudo-3D, gradients, +//| procedural backgrounds), not static art (use Canvas for that). +//| +//| COORDINATE CONTRACT: ``vx`` is the RENDER REGION's origin (NOT this layer's x), and the +//| view spans the FULL region WIDTH (the layer's rect only gates which ROWS run). So draw at +//| ABSOLUTE screen coords minus (vx, vy), and fill only your own rect with ``fill_rect`` - +//| ``view.clear()`` fills the whole region width. (When you render a StripDraw via +//| ``picogame.render([sd], buf, x,y,x+w,y+h)`` the region == the rect, so vx == x.) +//| Text via ``Canvas.text`` is ASCII (the built-in font); non-ASCII has no glyph.""" +//| +//| def __init__( +//| self, +//| callback: Callable[[Canvas, int, int, int, int], None], +//| x: int = 0, +//| y: int = 0, +//| width: int = 0, +//| height: int = 0, +//| ) -> None: ... +//| +static mp_obj_t picogame_stripdraw_make_new(const mp_obj_type_t *type, size_t n_args, + size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_callback, ARG_x, ARG_y, ARG_width, ARG_height, ARG_always_dirty }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_callback, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_x, MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_y, MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_width, MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_height, MP_ARG_INT, {.u_int = 0} }, + { MP_QSTR_always_dirty, MP_ARG_BOOL | MP_ARG_KW_ONLY, {.u_bool = true} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + picogame_stripdraw_obj_t *self = mp_obj_malloc(picogame_stripdraw_obj_t, type); + self->callback = args[ARG_callback].u_obj; + self->x = args[ARG_x].u_int; + self->y = args[ARG_y].u_int; + self->w = args[ARG_width].u_int; + self->h = args[ARG_height].u_int; + self->faulted = false; + self->always_dirty = args[ARG_always_dirty].u_bool; + picogame_dirty_reset(&self->dx1); // render once on first refresh (even when always_dirty=False) + picogame_dirty_union(&self->dx1, self->x, self->y, self->x + self->w, self->y + self->h); + // A buffer-less Canvas reused as the per-strip drawing view: its `data` is + // repointed at the live strip each blit, so no surface RAM is allocated here. + picogame_canvas_obj_t *view = mp_obj_malloc(picogame_canvas_obj_t, &picogame_canvas_type); + view->data = NULL; + view->data_obj = MP_OBJ_NULL; + view->w = 0; + view->h = 0; + view->x = 0; + view->y = 0; + view->transparent = 0; + view->has_transparent = false; + picogame_canvas_dirty_reset(view); + self->view = MP_OBJ_FROM_PTR(view); + return MP_OBJ_FROM_PTR(self); +} + +//| +//| x: int +//| y: int +//| width: int +//| height: int +//| """The screen rect repainted each refresh (read/write). Move or resize the layer +//| by assigning these. Shrinking the rect leaves stale pixels behind - follow a +//| shrink with ``scene.invalidate()`` for a clean repaint (as the fx helpers do).""" +static mp_obj_t sd_get_x(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_stripdraw_obj_t *)MP_OBJ_TO_PTR(self_in))->x); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sd_get_x_obj, sd_get_x); +static mp_obj_t sd_set_x(mp_obj_t self_in, mp_obj_t v) { + ((picogame_stripdraw_obj_t *)MP_OBJ_TO_PTR(self_in))->x = mp_obj_get_int(v); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sd_set_x_obj, sd_set_x); +MP_PROPERTY_GETSET(sd_x_obj, (mp_obj_t)&sd_get_x_obj, (mp_obj_t)&sd_set_x_obj); + +static mp_obj_t sd_get_y(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_stripdraw_obj_t *)MP_OBJ_TO_PTR(self_in))->y); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sd_get_y_obj, sd_get_y); +static mp_obj_t sd_set_y(mp_obj_t self_in, mp_obj_t v) { + ((picogame_stripdraw_obj_t *)MP_OBJ_TO_PTR(self_in))->y = mp_obj_get_int(v); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sd_set_y_obj, sd_set_y); +MP_PROPERTY_GETSET(sd_y_obj, (mp_obj_t)&sd_get_y_obj, (mp_obj_t)&sd_set_y_obj); + +static mp_obj_t sd_get_width(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_stripdraw_obj_t *)MP_OBJ_TO_PTR(self_in))->w); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sd_get_width_obj, sd_get_width); +static mp_obj_t sd_set_width(mp_obj_t self_in, mp_obj_t v) { + ((picogame_stripdraw_obj_t *)MP_OBJ_TO_PTR(self_in))->w = mp_obj_get_int(v); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sd_set_width_obj, sd_set_width); +MP_PROPERTY_GETSET(sd_width_obj, (mp_obj_t)&sd_get_width_obj, (mp_obj_t)&sd_set_width_obj); + +static mp_obj_t sd_get_height(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_stripdraw_obj_t *)MP_OBJ_TO_PTR(self_in))->h); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sd_get_height_obj, sd_get_height); +static mp_obj_t sd_set_height(mp_obj_t self_in, mp_obj_t v) { + ((picogame_stripdraw_obj_t *)MP_OBJ_TO_PTR(self_in))->h = mp_obj_get_int(v); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sd_set_height_obj, sd_set_height); +MP_PROPERTY_GETSET(sd_height_obj, (mp_obj_t)&sd_get_height_obj, (mp_obj_t)&sd_set_height_obj); + +//| always_dirty: bool +//| """True (default): repaint every frame - for animated content (pseudo-3D, gradients). False: +//| repaint only after an ``invalidate()`` call (or when overlapped by another dirty layer) - for on-change UI, +//| so a static panel doesn't re-rasterize+re-push every frame. With False you MUST invalidate() on +//| every content/visibility change (it's invisible until you do).""" +//| +static mp_obj_t sd_get_always_dirty(mp_obj_t self_in) { + return mp_obj_new_bool(((picogame_stripdraw_obj_t *)MP_OBJ_TO_PTR(self_in))->always_dirty); +} +static MP_DEFINE_CONST_FUN_OBJ_1(sd_get_always_dirty_obj, sd_get_always_dirty); +static mp_obj_t sd_set_always_dirty(mp_obj_t self_in, mp_obj_t v) { + ((picogame_stripdraw_obj_t *)MP_OBJ_TO_PTR(self_in))->always_dirty = mp_obj_is_true(v); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(sd_set_always_dirty_obj, sd_set_always_dirty); +MP_PROPERTY_GETSET(sd_always_dirty_obj, (mp_obj_t)&sd_get_always_dirty_obj, (mp_obj_t)&sd_set_always_dirty_obj); + +//| def invalidate(self, x: int = 0, y: int = 0, w: int = 0, h: int = 0) -> None: +//| """Mark dirty so the layer repaints on the next refresh (only needed when +//| ``always_dirty=False``). With no args, the whole layer repaints. Pass a rect in +//| VIEW-LOCAL coordinates (the same (0,0)-at-``(vx, vy)`` space the draw callback uses) to +//| repaint only that region - like Canvas/Tilemap, the Scene then recomposites and pushes just +//| those rows. Repeated calls union; the rect is clamped to the layer.""" +//| +//| +static mp_obj_t sd_invalidate(size_t n_args, const mp_obj_t *args) { + picogame_stripdraw_obj_t *self = MP_OBJ_TO_PTR(args[0]); + if (n_args >= 5) { // (x, y, w, h) in view-local coords -> clamped scene rect + int x1 = self->x + mp_obj_get_int(args[1]); + int y1 = self->y + mp_obj_get_int(args[2]); + int x2 = x1 + mp_obj_get_int(args[3]); + int y2 = y1 + mp_obj_get_int(args[4]); + if (x1 < self->x) { + x1 = self->x; + } + if (y1 < self->y) { + y1 = self->y; + } + if (x2 > self->x + self->w) { + x2 = self->x + self->w; + } + if (y2 > self->y + self->h) { + y2 = self->y + self->h; + } + if (x2 > x1 && y2 > y1) { + picogame_dirty_union(&self->dx1, x1, y1, x2, y2); + } + } else { // whole layer + picogame_dirty_union(&self->dx1, self->x, self->y, self->x + self->w, self->y + self->h); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(sd_invalidate_obj, 1, 5, sd_invalidate); + +static const mp_rom_map_elem_t picogame_stripdraw_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_x), MP_ROM_PTR(&sd_x_obj) }, + { MP_ROM_QSTR(MP_QSTR_y), MP_ROM_PTR(&sd_y_obj) }, + { MP_ROM_QSTR(MP_QSTR_width), MP_ROM_PTR(&sd_width_obj) }, + { MP_ROM_QSTR(MP_QSTR_height), MP_ROM_PTR(&sd_height_obj) }, + { MP_ROM_QSTR(MP_QSTR_always_dirty), MP_ROM_PTR(&sd_always_dirty_obj) }, + { MP_ROM_QSTR(MP_QSTR_invalidate), MP_ROM_PTR(&sd_invalidate_obj) }, +}; +static MP_DEFINE_CONST_DICT(picogame_stripdraw_locals_dict, picogame_stripdraw_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + picogame_stripdraw_type, + MP_QSTR_StripDraw, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, picogame_stripdraw_make_new, + locals_dict, &picogame_stripdraw_locals_dict + ); + +//| class Triangles: +//| def __init__(self, verts: ReadableBuffer, colors: ReadableBuffer) -> None: +//| """A retained SCREEN-SPACE triangle batch drawn entirely in C by the compositor: +//| ``verts`` = int16 x0,y0,x1,y1,x2,y2 per triangle, ``colors`` = uint16 wire RGB565 per +//| triangle - both CALLER-OWNED (fill them in place each frame). Set ``count`` to how +//| many triangles should draw; the assignment marks the layer dirty (full screen). +//| Unlike a StripDraw callback this runs no Python per strip, and unlike a Canvas it +//| holds no pixel buffer - the batch rasterises straight into each render strip with +//| a cheap band reject. The 3D-scene layer: pg.project into the arrays, painter's-order +//| the faces, set count, scene.refresh().""" +//| ... +//| +static mp_obj_t picogame_triangles_make_new(const mp_obj_type_t *type, size_t n_args, + size_t n_kw, const mp_obj_t *all_args) { + mp_arg_check_num(n_args, n_kw, 2, 2, false); + picogame_triangles_obj_t *self = mp_obj_malloc(picogame_triangles_obj_t, type); + mp_buffer_info_t vi, ci; + mp_get_buffer_raise(all_args[0], &vi, MP_BUFFER_READ); + mp_get_buffer_raise(all_args[1], &ci, MP_BUFFER_READ); + self->verts_obj = all_args[0]; + self->colors_obj = all_args[1]; + self->verts = (const int16_t *)vi.buf; + self->colors = (const uint16_t *)ci.buf; + size_t cap_v = vi.len / 12; // 6 int16 = 12 bytes per triangle + size_t cap_c = ci.len >> 1; + self->cap = (uint16_t)(cap_v < cap_c ? cap_v : cap_c); + self->count = 0; + picogame_dirty_reset(&self->dx1); + return MP_OBJ_FROM_PTR(self); +} + +//| count: int +//| """How many triangles of the batch draw next refresh (clamped to the buffer +//| capacity). Assigning marks the layer dirty for a full repaint.""" +//| +//| +static mp_obj_t tri_get_count(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_triangles_obj_t *)MP_OBJ_TO_PTR(self_in))->count); +} +static MP_DEFINE_CONST_FUN_OBJ_1(tri_get_count_obj, tri_get_count); +static mp_obj_t tri_set_count(mp_obj_t self_in, mp_obj_t v) { + picogame_triangles_obj_t *self = MP_OBJ_TO_PTR(self_in); + int n = mp_obj_get_int(v); + if (n < 0) { + n = 0; + } + if (n > self->cap) { + n = self->cap; + } + self->count = (uint16_t)n; + picogame_dirty_union(&self->dx1, 0, 0, 32767, 32767); // clipped to the play rect later + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(tri_set_count_obj, tri_set_count); +MP_PROPERTY_GETSET(tri_count_obj, (mp_obj_t)&tri_get_count_obj, (mp_obj_t)&tri_set_count_obj); + +static const mp_rom_map_elem_t picogame_triangles_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_count), MP_ROM_PTR(&tri_count_obj) }, +}; +static MP_DEFINE_CONST_DICT(picogame_triangles_locals_dict, picogame_triangles_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + picogame_triangles_type, + MP_QSTR_Triangles, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, picogame_triangles_make_new, + locals_dict, &picogame_triangles_locals_dict + ); + +// --------------------------------------------------------------------------- +// Module-level functions +// --------------------------------------------------------------------------- + +//| """2D game engine for the PicoPad and similar boards. +//| +//| Draws arbitrary-size sprites (unlike ``_stage``'s fixed 16x16 tiles) to a +//| ``busdisplay`` through a reusable strip buffer, with a dirty-rect scene, +//| tilemaps, particles, a drawing canvas and camera/effects.""" +//| +//| RGB565: int +//| """16-bit color bitmap format (wire byte order).""" +//| PAL8: int +//| """8-bit paletted bitmap format.""" +//| +//| +//| def rgb565(r: int, g: int, b: int) -> int: +//| """Build a display wire-order RGB565 color from 8-bit components.""" +//| ... +//| +//| +static mp_obj_t picogame_rgb565(mp_obj_t r_in, mp_obj_t g_in, mp_obj_t b_in) { + int r = mp_obj_get_int(r_in) & 0xff; + int g = mp_obj_get_int(g_in) & 0xff; + int b = mp_obj_get_int(b_in) & 0xff; + uint16_t c = ((r & 0xf8) << 8) | ((g & 0xfc) << 3) | (b >> 3); + uint16_t wire = (uint16_t)((c >> 8) | (c << 8)); + return MP_OBJ_NEW_SMALL_INT(wire); +} +static MP_DEFINE_CONST_FUN_OBJ_3(picogame_rgb565_obj, picogame_rgb565); + +// raycast(map, mw, mh, posx, posy, lrx, lry, srx, sry, sh, stride, ncols, wcolors, top, bot, col, dist) +// C DDA wall raycaster for picogame_ray.Raycaster - INTEGER ONLY (16.16 fixed-point, no FPU; the paint, +// temporal invalidate, pose-cache and billboard math stay in Python; Python does the once-per-frame +// trig and passes Q16 ray params). map: read-only bytes, mw*mh wall types (0 = empty). pos*, l*x/l*y +// (leftRay, column 0), s*x/s*y (rayStep per column) are all 16.16. wcolors: uint16[(maxtype+1)*2] - +// [t*2] near, [t*2+1] side colour. top/bot/col: uint16 write buffers (len>=ncols); dist: int32 write +// buffer (perpendicular distance, 16.16). The int64 divides/muls are ONLY the per-column setup +// (O(ncols)); the DDA step loop is pure 32-bit. Mirrors the Python float fallback closely. +// Optional arg 17 (runs - ONE uint16 write buffer, len>=5*ncols, laid out as five ncols-long +// planes [x0s | x1s | tops | bots | colors]): also emit the RLE-MERGED wall runs (adjacent equal +// columns fused; x in PIXELS = column*stride) and return the run count. The planes feed +// Canvas.vspans directly as memoryview slices. This hoists picogame_ray's per-frame Python merge +// loop into the same C pass (measured 2-6.5 ms/frame of interpreted merge at stride=1 on RP2040). +// Callers clamp the LAST run's x1 to the screen width (stride rounding can overshoot by = 18) { // run outputs requested + mp_buffer_info_t q; + mp_get_buffer_raise(args[17], &q, MP_BUFFER_WRITE); + int cap = (int)(q.len / 10); // five uint16 planes + if (ncols > cap) { + ncols = cap; // never write past the run planes + } + r0 = q.buf; + r1 = r0 + cap; + rt = r1 + cap; + rb = rt + cap; + rcol = rb + cap; + } + const uint8_t *map = mi.buf; + const uint16_t *wc = wi.buf; + int wc_types = (int)(wi.len >> 2); + uint16_t *top = ti.buf; + uint16_t *bot = bi.buf; + uint16_t *col = ci.buf; + int32_t *dist_out = di.buf; // perpendicular distance, 16.16 + int half = sh >> 1; + int imapx0 = posx >> 16; + int imapy0 = posy >> 16; + int32_t fracx = posx & 0xFFFF; // fractional part of pos, 16.16 + int32_t fracy = posy & 0xFFFF; + const int32_t DD_CAP = (int32_t)1 << 24; // cap deltaDist so a 64-step accumulation stays in int32 + if (ncols > (int)(ti.len >> 1)) { + ncols = (int)(ti.len >> 1); + } + for (int c = 0; c < ncols; c++) { + int mapx = imapx0; + int mapy = imapy0; + int32_t ax = rdx < 0 ? -rdx : rdx; + int32_t ay = rdy < 0 ? -rdy : rdy; + // deltaDist = |1/rayDir| in 16.16 = (1<<32)/|rayDir_q16| (int64; per-column setup, not per-step) + int64_t ddx64 = ax ? (((int64_t)1 << 32) / ax) : (int64_t)DD_CAP; + int64_t ddy64 = ay ? (((int64_t)1 << 32) / ay) : (int64_t)DD_CAP; + int32_t ddx = ddx64 > DD_CAP ? DD_CAP : (int32_t)ddx64; + int32_t ddy = ddy64 > DD_CAP ? DD_CAP : (int32_t)ddy64; + int stepx, stepy; + int32_t sidex, sidey; + // sideDist to the first grid line = (fractional distance) * deltaDist, 16.16 (int64 mul, setup only) + if (rdx < 0) { + stepx = -1; + sidex = (int32_t)(((int64_t)fracx * ddx) >> 16); + } else { + stepx = 1; + sidex = (int32_t)(((int64_t)(65536 - fracx) * ddx) >> 16); + } + if (rdy < 0) { + stepy = -1; + sidey = (int32_t)(((int64_t)fracy * ddy) >> 16); + } else { + stepy = 1; + sidey = (int32_t)(((int64_t)(65536 - fracy) * ddy) >> 16); + } + int side = 0; + int cell = 1; + for (int i = 0; i < 64; i++) { // DDA - pure 32-bit + if (sidex < sidey) { + sidex += ddx; + mapx += stepx; + side = 0; + } else { + sidey += ddy; + mapy += stepy; + side = 1; + } + cell = (mapx >= 0 && mapx < mw && mapy >= 0 && mapy < mh) ? map[mapy * mw + mapx] : 1; + if (cell) { + break; + } + } + int32_t perp = (side == 0) ? (sidex - ddx) : (sidey - ddy); // perpWallDist, 16.16 + if (perp < 655) { + perp = 655; // ~0.01 in 16.16 + } + int lh = (int)(((int32_t)sh << 16) / perp); // sh / perpWallDist (px); 32-bit (sh<<16 <= ~15.7M) + int t = half - (lh >> 1); + int b = t + lh; + if (t < 0) { + t = 0; + } + if (b > sh) { + b = sh; + } + top[c] = (uint16_t)t; + bot[c] = (uint16_t)b; + int ct = (cell < wc_types) ? cell : 1; // unknown type -> type 1 (matches Python default) + col[c] = wc[ct * 2 + side]; + dist_out[c] = perp; + rdx += srx; // accumulate ray direction for the next column (no overflow) + rdy += sry; + } + if (r0 && ncols > 0) { + // post-pass RLE over the just-written (cache-hot) column arrays: one flush point + int nr = 0; + int rstart = 0; + for (int c = 1; c <= ncols; c++) { + if (c == ncols || top[c] != top[rstart] || bot[c] != bot[rstart] || col[c] != col[rstart]) { + r0[nr] = (uint16_t)(rstart * stride); + r1[nr] = (uint16_t)(c * stride); + rt[nr] = top[rstart]; + rb[nr] = bot[rstart]; + rcol[nr] = col[rstart]; + nr++; + rstart = c; + } + } + return MP_OBJ_NEW_SMALL_INT(nr); + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(picogame_raycast_obj, 17, 18, picogame_raycast); + +// road_edges(rl, rr, hw, n, cx0, dist, cfg) - one racing-road frame's curve accumulator + integer +// edges in one call (the OutRun-genre compute_road loop; core + cfg layout documented in +// shared-module). rl/rr = int16 out, hw = int32 Q16 half-widths, cx0 = Q16 screen centre +// (incl. lateral), dist = integer world distance, cfg = int32[7]. +static mp_obj_t picogame_road_edges_fn(size_t n_args, const mp_obj_t *args) { + mp_buffer_info_t rli, rri, hwi, cfgi; + mp_get_buffer_raise(args[0], &rli, MP_BUFFER_WRITE); + mp_get_buffer_raise(args[1], &rri, MP_BUFFER_WRITE); + mp_get_buffer_raise(args[2], &hwi, MP_BUFFER_READ); + mp_get_buffer_raise(args[6], &cfgi, MP_BUFFER_READ); + int n = mp_obj_get_int(args[3]); + int cap = (int)(rli.len < rri.len ? rli.len : rri.len) / 2; + if (n > cap) { + n = cap; + } + if (n > (int)(hwi.len / 4)) { + n = (int)(hwi.len / 4); + } + if (n <= 0 || cfgi.len < 7 * 4) { + return mp_const_none; + } + picogame_road_edges((int16_t *)rli.buf, (int16_t *)rri.buf, (const int32_t *)hwi.buf, n, + mp_obj_get_int(args[4]), mp_obj_get_int(args[5]), (const int32_t *)cfgi.buf); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(picogame_road_edges_obj, 7, 7, picogame_road_edges_fn); + +// project(cam, pts, n, out_sx, out_sy) - batch perspective projection of `n` 3D points to screen. +// cam = 15 camera params: ex,ey,ez, rx,rz, ux,uy,uz, fx,fy,fz, focal, cx0, cy0, near +// pts = n*3 world coords (x,y,z per point) +// out_sx/out_sy = int16 screen coords; a point behind the near plane gets sentinel -32768 +// On an FPU board (CIRCUITPY_PICOGAME_FPU) cam/pts are float32; else they are 16.16 fixed int32. +// This is the shared hot path for blocky pseudo-3D (project the 8 corners of each box, then fill). +static mp_obj_t picogame_project(size_t n_args, const mp_obj_t *args) { + mp_buffer_info_t ci, pi, xi, yi; + mp_get_buffer_raise(args[0], &ci, MP_BUFFER_READ); + mp_get_buffer_raise(args[1], &pi, MP_BUFFER_READ); + int n = mp_obj_get_int(args[2]); + mp_get_buffer_raise(args[3], &xi, MP_BUFFER_WRITE); + mp_get_buffer_raise(args[4], &yi, MP_BUFFER_WRITE); + int16_t *osx = xi.buf; + int16_t *osy = yi.buf; + if (n > (int)(xi.len >> 1)) { + n = (int)(xi.len >> 1); + } + #if CIRCUITPY_PICOGAME_FPU + const float *cam = ci.buf; + const float *pts = pi.buf; + float ex = cam[0], ey = cam[1], ez = cam[2]; + float rx = cam[3], rz = cam[4]; + float ux = cam[5], uy = cam[6], uz = cam[7]; + float fx = cam[8], fy = cam[9], fz = cam[10]; + float focal = cam[11], cx0 = cam[12], cy0 = cam[13], near = cam[14]; + for (int i = 0; i < n; i++) { + float X = pts[i * 3] - ex, Y = pts[i * 3 + 1] - ey, Z = pts[i * 3 + 2] - ez; + float cz = X * fx + Y * fy + Z * fz; + if (cz < near) { + osx[i] = -32768; + osy[i] = -32768; + continue; + } + float k = focal / cz; // hardware divide on an FPU part + osx[i] = (int16_t)(cx0 + (X * rx + Z * rz) * k); + osy[i] = (int16_t)(cy0 - (X * ux + Y * uy + Z * uz) * k); + } + #else + const int32_t *cam = ci.buf; // all values 16.16 + const int32_t *pts = pi.buf; + int32_t ex = cam[0], ey = cam[1], ez = cam[2]; + int32_t rx = cam[3], rz = cam[4]; + int32_t ux = cam[5], uy = cam[6], uz = cam[7]; + int32_t fx = cam[8], fy = cam[9], fz = cam[10]; + int32_t focal = cam[11], cx0 = cam[12], cy0 = cam[13], near = cam[14]; + // Full-precision Q16 dot products (int64 mul per term). A Q8-prescaled-basis/MULS variant was + // ~30% faster, but its error grows with |coord| (~0.2%/axis) and k = focal/cz AMPLIFIES it near + // the near plane - host-measured 23-34 px warps on close fly-bys at a file-browser world scale + // (walls visibly broke). Correctness first: Q16 keeps the worst error a few px at any cz >= near, + // for coords up to +-32k units; still ~4-5x faster than the same math in Python on the M0+. + #define FMUL(a, b) ((int32_t)(((int64_t)(a) * (b)) >> 16)) + for (int i = 0; i < n; i++) { + int32_t X = pts[i * 3] - ex, Y = pts[i * 3 + 1] - ey, Z = pts[i * 3 + 2] - ez; + int32_t cz = FMUL(X, fx) + FMUL(Y, fy) + FMUL(Z, fz); + if (cz < near) { + osx[i] = -32768; + osy[i] = -32768; + continue; + } + // focal/cz in 16.16. A 32-bit divide (focal<<8 = Q24, cz>>8 = Q8 -> Q16) is ~4x cheaper than + // an int64 divide on the M0+ (no HW divide) and the lost cz precision costs <0.02 px (host- + // measured). Needs FOCAL < ~250 (focal<<8 in uint32) and near >= 1/256 (cz>>8 nonzero). + int32_t k = (int32_t)(((uint32_t)focal << 8) / (uint32_t)(cz >> 8)); + int32_t rr = FMUL(X, rx) + FMUL(Z, rz); + int32_t uu = FMUL(X, ux) + FMUL(Y, uy) + FMUL(Z, uz); + osx[i] = (int16_t)((cx0 + FMUL(rr, k)) >> 16); + osy[i] = (int16_t)((cy0 - FMUL(uu, k)) >> 16); + } +#undef FMUL + #endif + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(picogame_project_obj, 5, 5, picogame_project); + +//| def invert(display: busdisplay.BusDisplay, on: bool) -> None: +//| """Toggle the panel's hardware colour inversion (INVON/INVOFF). Instant and sends NO +//| pixel data, so a brief invert is a FREE full-screen flash (a 1-bit negative 'hit' look) +//| - cheaper than a Fade overlay. ST7789/ST7735 support it.""" +//| ... +//| +//| +static mp_obj_t picogame_invert(mp_obj_t display_in, mp_obj_t on_in) { + #if CIRCUITPY_PICOGAME_FRAMEBUFFER + // A Framebuffer target (RP2350 DVI, the WASM playground) has no hardware INVON/INVOFF - + // emulate the flash by XORing the composite (mirrors the Scene/render Framebuffer handling). + if (mp_obj_is_type(display_in, &picogame_framebuffer_type)) { + picogame_fb_set_invert(mp_obj_is_true(on_in)); + return mp_const_none; + } + #endif + picogame_set_invert(pg_get_display(display_in), mp_obj_is_true(on_in)); + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_2(picogame_invert_obj, picogame_invert); + +//| def render( +//| display: busdisplay.BusDisplay, +//| sprites: List[Sprite], +//| buffer: WriteableBuffer, +//| x0: int, +//| y0: int, +//| x1: int, +//| y1: int, +//| *, +//| background: int = 0, +//| ) -> None: +//| """Render ``sprites`` into the screen region [x0,x1) x [y0,y1) and push it +//| to ``display``. ``buffer`` is a reusable strip buffer (>= region_width*2 bytes).""" +//| ... +//| +//| + +// Map a layer object to its PICOGAME_KIND_*, or raise the one shared TypeError. Both +// Scene.add() and pg.render() classify through here (one type chain, one message). +uint8_t picogame_kind_of(mp_obj_t o) { + if (mp_obj_is_type(o, &picogame_sprite_type)) { + return PICOGAME_KIND_SPRITE; + } + if (mp_obj_is_type(o, &picogame_stripdraw_type)) { + return PICOGAME_KIND_STRIPDRAW; + } + if (mp_obj_is_type(o, &picogame_tilemap_type)) { + return PICOGAME_KIND_TILEMAP; + } + if (mp_obj_is_type(o, &picogame_particles_type)) { + return PICOGAME_KIND_PARTICLES; + } + if (mp_obj_is_type(o, &picogame_canvas_type)) { + return PICOGAME_KIND_CANVAS; + } + if (mp_obj_is_type(o, &picogame_triangles_type)) { + return PICOGAME_KIND_TRIANGLES; + } + mp_raise_TypeError(MP_ERROR_TEXT("expected a Sprite, Tilemap, Particles, Canvas, StripDraw or Triangles")); +} + +static mp_obj_t picogame_render_fun(size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args) { + enum { ARG_display, ARG_sprites, ARG_buffer, ARG_x0, ARG_y0, ARG_x1, ARG_y1, ARG_background }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_display, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_sprites, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_buffer, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_x0, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_y0, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_x1, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_y1, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_background, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + + // Accept a picogame.Framebuffer (RAM scanout buffer) as the target too, when built + // in: immediate render composites straight into it (no strip buffer, no bus), so + // pg.render(board.DISPLAY, ...) works when board.DISPLAY is a Framebuffer - the HUD / + // HudBar / immediate-mode path on scanout-buffer platforms. Mirrors the Scene change. + #if CIRCUITPY_PICOGAME_FRAMEBUFFER + picogame_framebuffer_obj_t *fbt = + mp_obj_is_type(args[ARG_display].u_obj, &picogame_framebuffer_type) + ? MP_OBJ_TO_PTR(args[ARG_display].u_obj) : NULL; + busdisplay_busdisplay_obj_t *display = fbt ? NULL : pg_get_display(args[ARG_display].u_obj); + #else + busdisplay_busdisplay_obj_t *display = pg_get_display(args[ARG_display].u_obj); + #endif + + size_t n = 0; + mp_obj_t *items; + mp_obj_get_array(args[ARG_sprites].u_obj, &n, &items); + + // Classify items into layer kinds. All-Sprite lists stay on the NULL-kinds fast path (no alloc - + // the common case). Any non-Sprite layer (StripDraw/Canvas/Tilemap/Particles) builds a small kinds + // array so immediate render uses the SAME multi-layer blitter the Scene does - e.g. a StripDraw + // composited straight into the strip with `view.text()` = 0-RAM immediate HUD / text screen. + uint8_t kbuf[16]; + uint8_t *kinds = NULL; + for (size_t i = 0; i < n; i++) { + if (!mp_obj_is_type(items[i], &picogame_sprite_type)) { + kinds = (n <= MP_ARRAY_SIZE(kbuf)) ? kbuf : m_new(uint8_t, n); + break; + } + } + if (kinds != NULL) { + for (size_t i = 0; i < n; i++) { + // (an unknown type raises from kind_of; the GC reclaims a heap `kinds`) + kinds[i] = picogame_kind_of(items[i]); + } + } + + #if CIRCUITPY_PICOGAME_FRAMEBUFFER + if (fbt != NULL) { + // Framebuffer target: composite the region straight into it (no strip buffer, no + // bus). Same compositor as the SPI path; re-raise a latched StripDraw exception. + mp_obj_t exc = picogame_render_framebuffer(fbt->fb, fbt->width, fbt->height, fbt->fmt, + fbt->scratch, fbt->scratch_rows, + items, kinds, n, + args[ARG_x0].u_int, args[ARG_y0].u_int, args[ARG_x1].u_int, args[ARG_y1].u_int, + args[ARG_background].u_int, 0, 0); + if (kinds != NULL && n > MP_ARRAY_SIZE(kbuf)) { + m_del(uint8_t, kinds, n); + } + if (exc != MP_OBJ_NULL) { + nlr_raise(MP_OBJ_TO_PTR(exc)); + } + return mp_const_none; + } + #endif + + mp_buffer_info_t bufinfo; + mp_get_buffer_raise(args[ARG_buffer].u_obj, &bufinfo, MP_BUFFER_WRITE); + + if (kinds == NULL) { + picogame_render(display, items, n, + (uint16_t *)bufinfo.buf, bufinfo.len / 2, + args[ARG_x0].u_int, args[ARG_y0].u_int, args[ARG_x1].u_int, args[ARG_y1].u_int, + args[ARG_background].u_int); + } else { + picogame_render_region(display, items, kinds, n, + (uint16_t *)bufinfo.buf, bufinfo.len / 2, + args[ARG_x0].u_int, args[ARG_y0].u_int, args[ARG_x1].u_int, args[ARG_y1].u_int, + args[ARG_background].u_int, 0, 0); + if (n > MP_ARRAY_SIZE(kbuf)) { + m_del(uint8_t, kinds, n); + } + } + return mp_const_none; +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_render_obj, 7, picogame_render_fun); + +//| def collide( +//| x1: int, y1: int, x2: int, y2: int, ax1: int, ay1: int, ax2: int = ..., ay2: int = ... +//| ) -> bool: +//| """AABB overlap test with INCLUSIVE bounds - both corners are part of the box, so two +//| boxes collide the moment they TOUCH (no visible overlap, no gap). Pass sprite hitboxes +//| as (x, y, x+w, y+h): collision fires on contact, the usual game feel. With 8 args: box +//| (x1,y1,x2,y2) vs box (ax1,ay1,ax2,ay2). With 6 args: box vs point (ax1, ay1). +//| NOTE: this is intentionally inclusive, unlike render's half-open [x0,x1) pixel ranges - +//| render is about pixels, collide is about game hitboxes (touch = hit).""" +//| ... +//| +//| +static mp_obj_t picogame_collide(size_t n_args, const mp_obj_t *args) { + int x1 = mp_obj_get_int(args[0]); + int y1 = mp_obj_get_int(args[1]); + int x2 = mp_obj_get_int(args[2]); + int y2 = mp_obj_get_int(args[3]); + bool hit; + if (n_args == 8) { + int bx1 = mp_obj_get_int(args[4]); + int by1 = mp_obj_get_int(args[5]); + int bx2 = mp_obj_get_int(args[6]); + int by2 = mp_obj_get_int(args[7]); + hit = (x1 <= bx2) && (x2 >= bx1) && (y1 <= by2) && (y2 >= by1); + } else if (n_args == 6) { + int px = mp_obj_get_int(args[4]); + int py = mp_obj_get_int(args[5]); + hit = (px >= x1) && (px <= x2) && (py >= y1) && (py <= y2); + } else { + mp_raise_TypeError(MP_ERROR_TEXT("argument num/types mismatch")); + } + return mp_obj_new_bool(hit); +} +static MP_DEFINE_CONST_FUN_OBJ_VAR_BETWEEN(picogame_collide_obj, 6, 8, picogame_collide); + +// ---- procedural value-noise in C (the desktop simulator sim/picogame.py mirrors it) ---- +// The CANONICAL implementation is FIXED-POINT (Q16.16 coords, Q0.16 values), exposed +// under the plain names value2d/value1d/fbm2d/fbm1d (see further down). It benchmarked +// ~1.8x faster than float on-device (0.649 s vs 1.186 s / 5000 fbm2d), so the float +// version was retired (2026-06-18) to free flash for future engine features. +// The float reference is preserved but DISABLED in the `#if 0` below (cf. PicoLibSDK's +// own Noise2D, which is likewise float) - revive by flipping it to `#if 1` and pointing +// the module table at the *_obj names instead of the *_fx_obj ones. +#if 0 // float reference implementation - superseded by the fixed-point path below +static inline float pg_nhash(int32_t x, int32_t y, int32_t seed) { + uint32_t h = (uint32_t)x * 374761393u + (uint32_t)y * 668265263u + (uint32_t)seed * 362437u; + h = (h ^ (h >> 13)) * 1274126177u; + h = h ^ (h >> 16); + return (float)(h & 0xFFFFu) / 65535.0f; +} +static inline float pg_nsmooth(float t) { + return t * t * (3.0f - 2.0f * t); +} +static inline int32_t pg_ifloor(float x) { + int32_t i = (int32_t)x; + return (x < (float)i) ? i - 1 : i; +} +static float pg_value2d(float x, float y, int32_t seed) { + int32_t xi = pg_ifloor(x), yi = pg_ifloor(y); + float xf = x - (float)xi, yf = y - (float)yi; + float a = pg_nhash(xi, yi, seed), b = pg_nhash(xi + 1, yi, seed); + float c = pg_nhash(xi, yi + 1, seed), d = pg_nhash(xi + 1, yi + 1, seed); + float u = pg_nsmooth(xf), v = pg_nsmooth(yf); + return (a * (1.0f - u) + b * u) * (1.0f - v) + (c * (1.0f - u) + d * u) * v; +} +static float pg_value1d(float x, int32_t seed) { + int32_t xi = pg_ifloor(x); + float xf = x - (float)xi; + float a = pg_nhash(xi, 0, seed), b = pg_nhash(xi + 1, 0, seed); + return a + (b - a) * pg_nsmooth(xf); +} + +//| def value2d(x: float, y: float, *, seed: int = 0) -> float: +//| """Smooth 2-D value noise in 0..1 (fast C).""" +//| ... +//| +//| +static mp_obj_t picogame_value2d(size_t n_args, const mp_obj_t *pos, mp_map_t *kw) { + static const mp_arg_t spec[] = { {MP_QSTR_x, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}}, + {MP_QSTR_y, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}}, {MP_QSTR_seed, MP_ARG_INT, {.u_int = 0}} }; + mp_arg_val_t a[3]; + mp_arg_parse_all(n_args, pos, kw, 3, spec, a); + return mp_obj_new_float(pg_value2d(mp_obj_get_float(a[0].u_obj), mp_obj_get_float(a[1].u_obj), a[2].u_int)); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_value2d_obj, 2, picogame_value2d); + +//| def value1d(x: float, *, seed: int = 0) -> float: ... +static mp_obj_t picogame_value1d(size_t n_args, const mp_obj_t *pos, mp_map_t *kw) { + static const mp_arg_t spec[] = { {MP_QSTR_x, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}}, + {MP_QSTR_seed, MP_ARG_INT, {.u_int = 0}} }; + mp_arg_val_t a[2]; + mp_arg_parse_all(n_args, pos, kw, 2, spec, a); + return mp_obj_new_float(pg_value1d(mp_obj_get_float(a[0].u_obj), a[1].u_int)); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_value1d_obj, 1, picogame_value1d); +#endif // float value2d / value1d + +// Shared arg spec for both fbm2d (disabled float) and fbm2d_fx (active fixed-point). +static const mp_arg_t pg_fbm2d_args[] = { + { MP_QSTR_x, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_y, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_octaves, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 4} }, + { MP_QSTR_seed, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0} }, + { MP_QSTR_lacunarity, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = MP_OBJ_NULL} }, + { MP_QSTR_gain, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = MP_OBJ_NULL} }, +}; + +#if 0 // float reference fbm - superseded by the fixed-point path below +//| def fbm2d(x, y, *, octaves=4, seed=0, lacunarity=2.0, gain=0.5) -> float: ... +static mp_obj_t picogame_fbm2d(size_t n_args, const mp_obj_t *pos, mp_map_t *kw) { + mp_arg_val_t a[6]; + mp_arg_parse_all(n_args, pos, kw, 6, pg_fbm2d_args, a); + float x = mp_obj_get_float(a[0].u_obj), y = mp_obj_get_float(a[1].u_obj); + int octaves = a[2].u_int; + int32_t seed = a[3].u_int; + float lac = (a[4].u_obj == MP_OBJ_NULL) ? 2.0f : mp_obj_get_float(a[4].u_obj); + float gain = (a[5].u_obj == MP_OBJ_NULL) ? 0.5f : mp_obj_get_float(a[5].u_obj); + float total = 0.0f, amp = 1.0f, freq = 1.0f, norm = 0.0f; + for (int i = 0; i < octaves; i++) { + total += amp * pg_value2d(x * freq, y * freq, seed); + norm += amp; + amp *= gain; + freq *= lac; + } + return mp_obj_new_float(norm > 0.0f ? total / norm : 0.0f); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_fbm2d_obj, 2, picogame_fbm2d); + +//| def fbm1d(x, *, octaves=4, seed=0, lacunarity=2.0, gain=0.5) -> float: ... +//| +//| +static mp_obj_t picogame_fbm1d(size_t n_args, const mp_obj_t *pos, mp_map_t *kw) { + static const mp_arg_t spec[] = { {MP_QSTR_x, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}}, + {MP_QSTR_octaves, MP_ARG_INT, {.u_int = 4}}, {MP_QSTR_seed, MP_ARG_INT, {.u_int = 0}}, + {MP_QSTR_lacunarity, MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}}, {MP_QSTR_gain, MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}} }; + mp_arg_val_t a[5]; + mp_arg_parse_all(n_args, pos, kw, 5, spec, a); + float x = mp_obj_get_float(a[0].u_obj); + int octaves = a[1].u_int; + int32_t seed = a[2].u_int; + float lac = (a[3].u_obj == MP_OBJ_NULL) ? 2.0f : mp_obj_get_float(a[3].u_obj); + float gain = (a[4].u_obj == MP_OBJ_NULL) ? 0.5f : mp_obj_get_float(a[4].u_obj); + float total = 0.0f, amp = 1.0f, freq = 1.0f, norm = 0.0f; + for (int i = 0; i < octaves; i++) { + total += amp * pg_value1d(x * freq, seed); + norm += amp; + amp *= gain; + freq *= lac; + } + return mp_obj_new_float(norm > 0.0f ? total / norm : 0.0f); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_fbm1d_obj, 1, picogame_fbm1d); +#endif // float fbm2d / fbm1d + +// ---- fixed-point (Q16.16 coords, Q0.16 values) noise: the CANONICAL value-noise impl, +// exposed under the plain names value2d/value1d/fbm2d/fbm1d. The inner math is integer +// (float only at the Python boundary); ~1.8x faster than the retired float path. ---- +static inline uint32_t pg_nhash_raw(int32_t x, int32_t y, int32_t seed) { + uint32_t h = (uint32_t)x * 374761393u + (uint32_t)y * 668265263u + (uint32_t)seed * 362437u; + h = (h ^ (h >> 13)) * 1274126177u; + return (h ^ (h >> 16)) & 0xFFFFu; // Q0.16 in [0,1) +} +static inline uint32_t pg_smooth16(uint32_t t) { // t,result Q0.16: t*t*(3-2t) + uint32_t t2 = (t * t) >> 16; + uint32_t e = (3u << 16) - 2u * t; + return (uint32_t)(((uint64_t)t2 * e) >> 16); +} +static inline uint32_t pg_lerp16(uint32_t a, uint32_t b, uint32_t u) { + return (uint32_t)((int32_t)a + (int32_t)(((int64_t)((int32_t)b - (int32_t)a) * (int32_t)u) >> 16)); +} +static uint32_t pg_value2d_fx(int32_t X, int32_t Y, int32_t seed) { // X,Y Q16.16 -> Q0.16 + int32_t xi = X >> 16, yi = Y >> 16; + uint32_t xf = (uint32_t)(X - (xi << 16)), yf = (uint32_t)(Y - (yi << 16)); + uint32_t a = pg_nhash_raw(xi, yi, seed), b = pg_nhash_raw(xi + 1, yi, seed); + uint32_t c = pg_nhash_raw(xi, yi + 1, seed), d = pg_nhash_raw(xi + 1, yi + 1, seed); + uint32_t u = pg_smooth16(xf), v = pg_smooth16(yf); + return pg_lerp16(pg_lerp16(a, b, u), pg_lerp16(c, d, u), v); +} +// (1-D value noise == the 2-D sampler at Y=0, bit for bit: v = smooth16(0) = 0 makes the +// outer lerp return its first argument, which is exactly lerp(hash(xi,0), hash(xi+1,0), u). +// So the 1-D entry points below just call pg_value2d_fx(X, 0, seed) - no separate kernel.) +#define PG_Q16(f) ((int32_t)((f) * 65536.0f)) + +static mp_obj_t picogame_value2d_fx(size_t n_args, const mp_obj_t *pos, mp_map_t *kw) { + static const mp_arg_t spec[] = { {MP_QSTR_x, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}}, + {MP_QSTR_y, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}}, {MP_QSTR_seed, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0}} }; + mp_arg_val_t a[3]; + mp_arg_parse_all(n_args, pos, kw, 3, spec, a); + int32_t v = pg_value2d_fx(PG_Q16(mp_obj_get_float(a[0].u_obj)), PG_Q16(mp_obj_get_float(a[1].u_obj)), a[2].u_int); + return mp_obj_new_float((float)v * (1.0f / 65536.0f)); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_value2d_fx_obj, 2, picogame_value2d_fx); + +static mp_obj_t picogame_value1d_fx(size_t n_args, const mp_obj_t *pos, mp_map_t *kw) { + static const mp_arg_t spec[] = { {MP_QSTR_x, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}}, + {MP_QSTR_seed, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0}} }; + mp_arg_val_t a[2]; + mp_arg_parse_all(n_args, pos, kw, 2, spec, a); + int32_t v = pg_value2d_fx(PG_Q16(mp_obj_get_float(a[0].u_obj)), 0, a[1].u_int); + return mp_obj_new_float((float)v * (1.0f / 65536.0f)); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_value1d_fx_obj, 1, picogame_value1d_fx); + +// Shared fBm octave accumulator (the 1-D entry passes Y=0; sy is then 0 every octave, +// which the value sampler maps to the exact 1-D lattice - see the note above). +static mp_obj_t pg_fbm_eval(int32_t X, int32_t Y, int octaves, int32_t seed, + const mp_arg_val_t *lac, const mp_arg_val_t *gain) { + int32_t lacq = (lac->u_obj == MP_OBJ_NULL) ? (2 << 16) : PG_Q16(mp_obj_get_float(lac->u_obj)); + int32_t gainq = (gain->u_obj == MP_OBJ_NULL) ? (1 << 15) : PG_Q16(mp_obj_get_float(gain->u_obj)); + int32_t amp = 1 << 16, freq = 1 << 16; + int64_t total = 0, norm = 0; + for (int i = 0; i < octaves; i++) { + int32_t sx = (int32_t)(((int64_t)X * freq) >> 16), sy = (int32_t)(((int64_t)Y * freq) >> 16); + total += ((int64_t)amp * pg_value2d_fx(sx, sy, seed)) >> 16; + norm += amp; + amp = (int32_t)(((int64_t)amp * gainq) >> 16); + freq = (int32_t)(((int64_t)freq * lacq) >> 16); + } + return mp_obj_new_float(norm ? (float)total / (float)norm : 0.0f); +} + +static mp_obj_t picogame_fbm2d_fx(size_t n_args, const mp_obj_t *pos, mp_map_t *kw) { + mp_arg_val_t a[6]; + mp_arg_parse_all(n_args, pos, kw, 6, pg_fbm2d_args, a); + return pg_fbm_eval(PG_Q16(mp_obj_get_float(a[0].u_obj)), PG_Q16(mp_obj_get_float(a[1].u_obj)), + a[2].u_int, a[3].u_int, &a[4], &a[5]); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_fbm2d_fx_obj, 2, picogame_fbm2d_fx); + +static mp_obj_t picogame_fbm1d_fx(size_t n_args, const mp_obj_t *pos, mp_map_t *kw) { + static const mp_arg_t spec[] = { {MP_QSTR_x, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}}, + {MP_QSTR_octaves, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 4}}, {MP_QSTR_seed, MP_ARG_INT | MP_ARG_KW_ONLY, {.u_int = 0}}, + {MP_QSTR_lacunarity, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = MP_OBJ_NULL}}, {MP_QSTR_gain, MP_ARG_OBJ | MP_ARG_KW_ONLY, {.u_obj = MP_OBJ_NULL}} }; + mp_arg_val_t a[5]; + mp_arg_parse_all(n_args, pos, kw, 5, spec, a); + return pg_fbm_eval(PG_Q16(mp_obj_get_float(a[0].u_obj)), 0, + a[1].u_int, a[2].u_int, &a[3], &a[4]); +} +static MP_DEFINE_CONST_FUN_OBJ_KW(picogame_fbm1d_fx_obj, 1, picogame_fbm1d_fx); + +#if CIRCUITPY_PICOGAME_FRAMEBUFFER +// --------------------------------------------------------------------------- +// Framebuffer (a RAM render target used in place of a BusDisplay; scanout-buffer +// platforms - WASM playground, desktop sim, FruitJam DVI/HSTX) +// --------------------------------------------------------------------------- +//| class Framebuffer: +//| """A RAM framebuffer render target that a Scene or :py:func:`render` can draw +//| into instead of a BusDisplay. ``buffer`` must be a writable buffer of at least +//| ``width*height*2`` bytes (``width*height`` for ``rgb332=True``); the caller owns it +//| (a ``bytearray`` in the browser, the DVI scanout buffer on FruitJam). By default the +//| pixels are wire-order RGB565 (the engine's internal format); ``native_rgb565=True`` +//| byte-swaps each finished region to NATIVE RGB565 - the format 16-bit picodvi / +//| canvas scanout targets expect; ``rgb332=True`` quantizes each finished region to +//| RGB332 bytes - the format of 8-bit picodvi scanout (FruitJam 640x480, which the +//| hardware only offers at 8bpp). Assets, palettes and ``rgb565()`` stay wire-order +//| RGB565 throughout regardless of the output format.""" +//| +//| def __init__( +//| self, +//| buffer: WriteableBuffer, +//| width: int, +//| height: int, +//| *, +//| native_rgb565: bool = False, +//| rgb332: bool = False, +//| ) -> None: ... +//| +//| +// Static SRAM compose strip (see the scratch comment in make_new). 640*16*2 = 20 KB .bss, +// only on CIRCUITPY_PICOGAME_FRAMEBUFFER builds (fb boards have the SRAM to spare). +#define PICOGAME_FB_SCRATCH_MAX_W 640 +static uint16_t picogame_fb_scratch_sram[PICOGAME_FB_SCRATCH_MAX_W * PICOGAME_FB_SCRATCH_H]; + +static mp_obj_t picogame_framebuffer_make_new(const mp_obj_type_t *type, size_t n_args, + size_t n_kw, const mp_obj_t *all_args) { + enum { ARG_buffer, ARG_width, ARG_height, ARG_native_rgb565, ARG_rgb332 }; + static const mp_arg_t allowed_args[] = { + { MP_QSTR_buffer, MP_ARG_REQUIRED | MP_ARG_OBJ }, + { MP_QSTR_width, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_height, MP_ARG_REQUIRED | MP_ARG_INT }, + { MP_QSTR_native_rgb565, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + { MP_QSTR_rgb332, MP_ARG_KW_ONLY | MP_ARG_BOOL, {.u_bool = false} }, + }; + mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)]; + mp_arg_parse_all_kw_array(n_args, n_kw, all_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args); + mp_int_t width = mp_arg_validate_int_range(args[ARG_width].u_int, 1, 4096, MP_QSTR_width); + mp_int_t height = mp_arg_validate_int_range(args[ARG_height].u_int, 1, 4096, MP_QSTR_height); + if (args[ARG_native_rgb565].u_bool && args[ARG_rgb332].u_bool) { + mp_arg_error_invalid(MP_QSTR_format); // native_rgb565 and rgb332 are exclusive + } + bool rgb332 = args[ARG_rgb332].u_bool; + + mp_buffer_info_t bi; + mp_get_buffer_raise(args[ARG_buffer].u_obj, &bi, MP_BUFFER_WRITE); + uint64_t need = (uint64_t)width * (uint64_t)height * (rgb332 ? 1u : 2u); + if ((uint64_t)bi.len < need) { + mp_raise_ValueError(MP_ERROR_TEXT("buffer too small")); + } + + picogame_framebuffer_obj_t *self = mp_obj_malloc(picogame_framebuffer_obj_t, type); + self->buffer = args[ARG_buffer].u_obj; + self->fb = (uint16_t *)bi.buf; + self->width = width; + self->height = height; + self->fmt = rgb332 ? PICOGAME_FB_RGB332 + : (args[ARG_native_rgb565].u_bool ? PICOGAME_FB_NATIVE565 : PICOGAME_FB_WIRE565); + // A LIVE scanout buffer (picodvi/HDMI) is read continuously, so picogame_render_framebuffer + // composes each band into this PRIVATE strip and only memcpys the FINISHED band into the fb. + // That serves BOTH targets: (a) native -> also byte-swap the strip so the fb never holds wire + // (no pink); (b) wire -> no swap, but the off-screen compose still stops the beam from sampling + // a half-composited region (background filled, sprite not yet drawn) = no sprite/HUD flicker. + // Always allocated for the FB target; the WASM/sim path (read out after present, not live) just + // pays a small strip + one memcpy. See PICOGAME_FB_SCRATCH_H. + // + // The scratch must be FAST memory: on a PSRAM-heap board (Fruit Jam) a heap bytearray + // lands in external PSRAM and every compose write pays QSPI latency (measured 8.7 vs + // 64+ MB/s SRAM; a full-res StripDraw frame ballooned refresh to ~30-38 ms). One static + // SRAM strip serves every Framebuffer (compose is synchronous) up to 640 px wide; wider + // targets fall back to the heap. + self->scratch_buf = mp_const_none; + self->scratch = NULL; + self->scratch_rows = 0; + { + int rows = PICOGAME_FB_SCRATCH_H; + if (rows > height) { + rows = height; + } + if (width <= PICOGAME_FB_SCRATCH_MAX_W) { + self->scratch = picogame_fb_scratch_sram; + } else { + mp_obj_t sb = mp_obj_new_bytearray_of_zeros((size_t)width * (size_t)rows * 2u); + mp_buffer_info_t sbi; + mp_get_buffer_raise(sb, &sbi, MP_BUFFER_WRITE); + self->scratch_buf = sb; + self->scratch = (uint16_t *)sbi.buf; + } + self->scratch_rows = rows; + } + return MP_OBJ_FROM_PTR(self); +} + +static mp_obj_t picogame_framebuffer_get_width(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_framebuffer_obj_t *)MP_OBJ_TO_PTR(self_in))->width); +} +static MP_DEFINE_CONST_FUN_OBJ_1(picogame_framebuffer_get_width_obj, picogame_framebuffer_get_width); +MP_PROPERTY_GETTER(picogame_framebuffer_width_obj, (mp_obj_t)&picogame_framebuffer_get_width_obj); + +static mp_obj_t picogame_framebuffer_get_height(mp_obj_t self_in) { + return MP_OBJ_NEW_SMALL_INT(((picogame_framebuffer_obj_t *)MP_OBJ_TO_PTR(self_in))->height); +} +static MP_DEFINE_CONST_FUN_OBJ_1(picogame_framebuffer_get_height_obj, picogame_framebuffer_get_height); +MP_PROPERTY_GETTER(picogame_framebuffer_height_obj, (mp_obj_t)&picogame_framebuffer_get_height_obj); + +static const mp_rom_map_elem_t picogame_framebuffer_locals_dict_table[] = { + { MP_ROM_QSTR(MP_QSTR_width), MP_ROM_PTR(&picogame_framebuffer_width_obj) }, + { MP_ROM_QSTR(MP_QSTR_height), MP_ROM_PTR(&picogame_framebuffer_height_obj) }, +}; +static MP_DEFINE_CONST_DICT(picogame_framebuffer_locals_dict, picogame_framebuffer_locals_dict_table); + +MP_DEFINE_CONST_OBJ_TYPE( + picogame_framebuffer_type, + MP_QSTR_Framebuffer, + MP_TYPE_FLAG_HAS_SPECIAL_ACCESSORS, + make_new, picogame_framebuffer_make_new, + locals_dict, &picogame_framebuffer_locals_dict + ); +#endif // CIRCUITPY_PICOGAME_FRAMEBUFFER + +static const mp_rom_map_elem_t picogame_module_globals_table[] = { + { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_picogame) }, + { MP_ROM_QSTR(MP_QSTR_Bitmap), MP_ROM_PTR(&picogame_bitmap_type) }, + { MP_ROM_QSTR(MP_QSTR_Sprite), MP_ROM_PTR(&picogame_sprite_type) }, + #if CIRCUITPY_PICOGAME_FAST_DISPLAY + { MP_ROM_QSTR(MP_QSTR_Display), MP_ROM_PTR(&picogame_display_type) }, + #endif + { MP_ROM_QSTR(MP_QSTR_Scene), MP_ROM_PTR(&picogame_scene_type) }, + { MP_ROM_QSTR(MP_QSTR_Tilemap), MP_ROM_PTR(&picogame_tilemap_type) }, + { MP_ROM_QSTR(MP_QSTR_Particles), MP_ROM_PTR(&picogame_particles_type) }, + { MP_ROM_QSTR(MP_QSTR_Canvas), MP_ROM_PTR(&picogame_canvas_type) }, + { MP_ROM_QSTR(MP_QSTR_StripDraw), MP_ROM_PTR(&picogame_stripdraw_type) }, + { MP_ROM_QSTR(MP_QSTR_Triangles), MP_ROM_PTR(&picogame_triangles_type) }, + #if CIRCUITPY_PICOGAME_FRAMEBUFFER + { MP_ROM_QSTR(MP_QSTR_Framebuffer), MP_ROM_PTR(&picogame_framebuffer_type) }, + #endif + { MP_ROM_QSTR(MP_QSTR_render), MP_ROM_PTR(&picogame_render_obj) }, + { MP_ROM_QSTR(MP_QSTR_raycast), MP_ROM_PTR(&picogame_raycast_obj) }, + { MP_ROM_QSTR(MP_QSTR_road_edges), MP_ROM_PTR(&picogame_road_edges_obj) }, + { MP_ROM_QSTR(MP_QSTR_project), MP_ROM_PTR(&picogame_project_obj) }, + // True when the pseudo-3D/math primitives use the hardware-float path (FPU board). Python packs + // camera/point buffers as float32 when this is set, else as 16.16 fixed int32. + { MP_ROM_QSTR(MP_QSTR_FPU), MP_ROM_INT(CIRCUITPY_PICOGAME_FPU) }, + { MP_ROM_QSTR(MP_QSTR_invert), MP_ROM_PTR(&picogame_invert_obj) }, + { MP_ROM_QSTR(MP_QSTR_collide), MP_ROM_PTR(&picogame_collide_obj) }, + // Canonical noise = the fixed-point implementation (float retired; see `#if 0` above). + { MP_ROM_QSTR(MP_QSTR_value2d), MP_ROM_PTR(&picogame_value2d_fx_obj) }, + { MP_ROM_QSTR(MP_QSTR_value1d), MP_ROM_PTR(&picogame_value1d_fx_obj) }, + { MP_ROM_QSTR(MP_QSTR_fbm2d), MP_ROM_PTR(&picogame_fbm2d_fx_obj) }, + { MP_ROM_QSTR(MP_QSTR_fbm1d), MP_ROM_PTR(&picogame_fbm1d_fx_obj) }, + { MP_ROM_QSTR(MP_QSTR_rgb565), MP_ROM_PTR(&picogame_rgb565_obj) }, + { MP_ROM_QSTR(MP_QSTR_RGB565), MP_ROM_INT(PICOGAME_FMT_RGB565) }, + { MP_ROM_QSTR(MP_QSTR_PAL8), MP_ROM_INT(PICOGAME_FMT_PAL8) }, + // Engine API level: bump by 1 whenever the PYTHON-VISIBLE surface grows (new method/property/ + // module function/constant), so picogame-libs can diagnose a too-old firmware up front + // ("needs API_LEVEL >= N") instead of failing later with a random missing attribute. + // Level 1 = the 2026-07 surface (post API-freeze + Canvas.text/Framebuffer/StripDraw + // always_dirty). Older firmwares have no attribute at all -> getattr(pg, "API_LEVEL", 0). + { MP_ROM_QSTR(MP_QSTR_API_LEVEL), MP_ROM_INT(1) }, + // Build-time capability flag: does THIS board's panel controller support 12-bit RGB444 + // (COLMOD)? The board declares it (it knows its controller); a game reads it to enable + // Display(rgb444=...) only where it works - one codebase runs on ST7789 AND ILI9341. + #if CIRCUITPY_PICOGAME_RGB444 + { MP_ROM_QSTR(MP_QSTR_RGB444_SUPPORTED), MP_ROM_TRUE }, + #else + { MP_ROM_QSTR(MP_QSTR_RGB444_SUPPORTED), MP_ROM_FALSE }, + #endif + // Build-time default render-strip height (rows). picogame_game.setup() uses it when strip_h is + // None; games can override per call; a board can override the default in mpconfigboard.h. + // MEASURED (RP2040): with async DMA double-buffering, SMALL strips overlap render+transfer best -> + // 8 is both fastest and least RAM (the two w*strip_h*2 buffers shrink). WITHOUT the DMA backend + // there's no overlap, so a blocking send per strip makes LARGER strips win -> 24. + #ifndef PICOGAME_STRIP_H + #if CIRCUITPY_PICOGAME_FAST_DISPLAY + #define PICOGAME_STRIP_H 8 + #else + #define PICOGAME_STRIP_H 24 + #endif + #endif + { MP_ROM_QSTR(MP_QSTR_STRIP_H), MP_ROM_INT(PICOGAME_STRIP_H) }, +}; +static MP_DEFINE_CONST_DICT(picogame_module_globals, picogame_module_globals_table); + +const mp_obj_module_t picogame_module = { + .base = { &mp_type_module }, + .globals = (mp_obj_dict_t *)&picogame_module_globals, +}; + +MP_REGISTER_MODULE(MP_QSTR_picogame, picogame_module); diff --git a/shared-bindings/picogame/__init__.h b/shared-bindings/picogame/__init__.h new file mode 100644 index 00000000000..8b8047f44dc --- /dev/null +++ b/shared-bindings/picogame/__init__.h @@ -0,0 +1,15 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include "py/obj.h" + +// Defined in shared-bindings/picogame/__init__.c (consolidated with Bitmap/Sprite). +extern const mp_obj_type_t picogame_stripdraw_type; +extern const mp_obj_type_t picogame_triangles_type; + +uint8_t picogame_kind_of(mp_obj_t o); diff --git a/shared-module/picogame/Bitmap.h b/shared-module/picogame/Bitmap.h new file mode 100644 index 00000000000..887d904b203 --- /dev/null +++ b/shared-module/picogame/Bitmap.h @@ -0,0 +1,35 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT +// +// picogame: 2D game engine for the PicoPad and similar boards. +// Bitmap = an image atlas of one or more equal-size frames, arbitrary width/height. + +#pragma once + +#include +#include +#include "py/obj.h" + +enum { + PICOGAME_FMT_RGB565 = 0, // 2 bytes/pixel, values in display wire order + PICOGAME_FMT_PAL8 = 1, // 1 byte/pixel index into palette (wire-order RGB565) +}; + +typedef struct { + mp_obj_base_t base; + mp_obj_t data_obj; // keep the source buffer alive + mp_obj_t palette_obj; // keep the palette buffer alive (MP_OBJ_NULL for RGB565) + const uint8_t *data; // pixel data + const uint16_t *palette; // wire-order RGB565 entries (PAL8), else NULL + uint16_t width, height; // size of a single frame + uint16_t stride; // atlas width in pixels (>= width * frames for a horizontal atlas) + uint16_t transparent; // transparent key: palette index (PAL8) or wire color (RGB565) + uint16_t pal_entries; // palette length in entries (PAL8; 0 for RGB565). Informational: the + // blitter does NOT clamp - indices must be < this (see PAL8 blit contract). + uint8_t format; // PICOGAME_FMT_* + uint8_t frames; + bool has_transparent; +} picogame_bitmap_obj_t; diff --git a/shared-module/picogame/Canvas.c b/shared-module/picogame/Canvas.c new file mode 100644 index 00000000000..87482192124 --- /dev/null +++ b/shared-module/picogame/Canvas.c @@ -0,0 +1,623 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#include + +#include "shared-module/picogame/Canvas.h" +#include "shared-module/picogame/Bitmap.h" +#include "shared-module/picogame/__init__.h" +#include "shared-module/fontio/BuiltinFont.h" +#include "shared-bindings/displayio/Bitmap.h" + +// Thin wrappers over the shared int32 accumulator (dx1,dy1,dx2,dy2 are contiguous int32 at the +// struct tail). See picogame_dirty_* in __init__.c. +void picogame_canvas_dirty_reset(picogame_canvas_obj_t *cv) { + picogame_dirty_reset(&cv->dx1); +} + +void picogame_canvas_dirty_union(picogame_canvas_obj_t *cv, int x1, int y1, int x2, int y2) { + picogame_dirty_union(&cv->dx1, x1, y1, x2, y2); +} + +bool picogame_canvas_take_dirty(picogame_canvas_obj_t *cv, int *x1, int *y1, int *x2, int *y2) { + return picogame_dirty_take(&cv->dx1, x1, y1, x2, y2); +} + +// Union a canvas-local rect (clamped to the surface) into the dirty rect (scene coords). +static void mark(picogame_canvas_obj_t *cv, int lx1, int ly1, int lx2, int ly2) { + if (lx1 < 0) { + lx1 = 0; + } + if (ly1 < 0) { + ly1 = 0; + } + if (lx2 > cv->w) { + lx2 = cv->w; + } + if (ly2 > cv->h) { + ly2 = cv->h; + } + if (lx1 >= lx2 || ly1 >= ly2) { + return; + } + picogame_dirty_union(&cv->dx1, cv->x + lx1, cv->y + ly1, cv->x + lx2, cv->y + ly2); +} + +// NOT inlined on purpose: the shape primitives call put() many times (circle = +// 8 calls/iteration). Inlining bloated them (circle was ~1.4 KB); a real call keeps +// them small. Shapes aren't the hot path (the sprite/tilemap blits don't use put). +static __attribute__((noinline)) void put(picogame_canvas_obj_t *cv, int x, int y, uint16_t c) { + if (x >= 0 && y >= 0 && x < cv->w && y < cv->h) { + cv->data[y * cv->w + x] = c; + } +} + +// Fill `n` RGB565 pixels at `p` with `color`, word-filling two pixels per store (half the writes of +// a 16-bit loop); memset for the common 0 case. Handles a leading odd (2-byte-but-not-4-byte) address +// so it stays safe on Cortex-M0+ (RP2040), which faults on an unaligned 32-bit access - a StripDraw +// view's rows into the render strip can start on an odd pixel. This is the per-frame path for +// view.clear / Sky / HUD-bar / Fade fills, so the word-fill is worth it. +static void fill565(uint16_t *p, int n, uint16_t color) { + if (n <= 0) { + return; + } + if (color == 0) { + memset(p, 0, (size_t)n * 2); + return; + } + if ((uintptr_t)p & 3) { // align to 4 bytes: one leading pixel + *p++ = color; + n--; + } + uint32_t w = (uint32_t)color | ((uint32_t)color << 16); + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wcast-align" + uint32_t *w32 = (uint32_t *)p; // now 4-byte aligned + #pragma GCC diagnostic pop + int nw = n >> 1; + for (int i = 0; i < nw; i++) { + w32[i] = w; + } + if (n & 1) { // trailing odd pixel + p[n - 1] = color; + } +} + +// Defined with the filled shapes below; forward-declared for picogame_canvas_road. +static void span565(picogame_canvas_obj_t *cv, int y, int xs, int xe, uint16_t color); + +void picogame_canvas_clear(picogame_canvas_obj_t *cv, uint16_t color) { + fill565(cv->data, cv->w * cv->h, color); + mark(cv, 0, 0, cv->w, cv->h); +} + +void picogame_canvas_pixel(picogame_canvas_obj_t *cv, int x, int y, uint16_t color) { + put(cv, x, y, color); + mark(cv, x, y, x + 1, y + 1); +} + +void picogame_canvas_fill_rect(picogame_canvas_obj_t *cv, int x, int y, int w, int h, uint16_t color) { + int x2 = x + w, y2 = y + h; + int cx1 = x < 0 ? 0 : x, cy1 = y < 0 ? 0 : y; + int cx2 = x2 > cv->w ? cv->w : x2, cy2 = y2 > cv->h ? cv->h : y2; + for (int yy = cy1; yy < cy2; yy++) { + fill565(cv->data + yy * cv->w + cx1, cx2 - cx1, color); + } + mark(cv, x, y, x2, y2); +} + +void picogame_canvas_blit(picogame_canvas_obj_t *cv, picogame_bitmap_obj_t *bm, + int x, int y, int frame, bool flip_x, bool flip_y) { + // Composite a bitmap FRAME into the canvas buffer (honours the bitmap's transparent key). + // Reuses the sprite blit path, targeting the canvas's own RGB565 surface instead of a strip. + picogame_blit_bitmap(cv->data, cv->w, cv->h, 0, 0, bm, x, y, frame, flip_x, flip_y, false, NULL); + mark(cv, x, y, x + bm->width, y + bm->height); +} + +// Number of low zero bits (log2 for a power of 2; 0 for non-pow2, caught by caller). +static int log2_pow2(unsigned v) { + int n = 0; + while ((v & 1) == 0 && v > 1) { + v >>= 1; + n++; + } + return n; +} + +// One racing-road strip, all rows in one call (the OutRun-genre "draw_road" scanline loop - profiled +// at ~20-25 ms of Python on picobike: ~4 fill_rect boundary crossings + an int(float) phase per row). +// ri0 = the road-table row of THIS surface's row 0 (vy - horizon_base); negative rows are sky. +// tab = int16[ntab][5]: {edge_w, dash_hw, wb05_q8, wb07_q8, flags(bit0 = dashes allowed)} - static per +// game. rl/rr = per-frame integer road edges (road_edges output). d05/d07 = the frame's scrolling +// stripe/dash phases in Q8; the row's band parity is ((d05+wb05)>>8)&1, matching the Python +// int(d05f + wb05f) & 1 (both non-negative). colors = uint16[6]: {sky, road_a, road_b, rumble_a, +// rumble_b, dash}. Grass underneath and the finish-line chequer stay the caller's job (one fill_rect +// per strip / a few rows near the lap line - no reason to carry them in C). +void picogame_canvas_road(picogame_canvas_obj_t *cv, int ri0, + const int16_t *tab, int ntab, const int16_t *rl, const int16_t *rr, + int32_t d05_q8, int32_t d07_q8, const uint16_t *colors) { + int w = cv->w; + for (int ly = 0; ly < cv->h; ly++) { + int ri = ri0 + ly; + if (ri < 0) { // above the horizon: sky + fill565(&cv->data[ly * w], w, colors[0]); + continue; + } + if (ri >= ntab) { + ri = ntab - 1; + } + const int16_t *t = tab + ri * 5; + int band = (int)(((d05_q8 + t[2]) >> 8) & 1); + uint16_t road = band ? colors[1] : colors[2]; + uint16_t rumble = band ? colors[3] : colors[4]; + int l = rl[ri], r = rr[ri]; + if (r <= l) { + continue; + } + span565(cv, ly, l, r - 1, road); // fill_rect(l, w=r-l) covers l..r-1 + int ew = t[0]; + span565(cv, ly, l, l + ew - 1, rumble); + span565(cv, ly, r - ew, r - 1, rumble); + if ((t[4] & 1) && (((d07_q8 + t[3]) >> 8) & 1)) { + int mid = (l + r) >> 1, dw = t[1]; + span565(cv, ly, mid - dw, mid + dw - 1, colors[5]); + } + } + mark(cv, 0, 0, w, cv->h); +} + +// Context + row walker for the mode7 loop (each row derives rowdist/steps from its own sy). +typedef struct { + picogame_canvas_obj_t *cv; + const uint8_t *data; + const uint16_t *pal; + int fmt, stride, shx, shy, mx, my, horizon, y_off; + int32_t z, rx0, ry0, rsx, rsy, cam_x, cam_y; + bool transp; + uint16_t key; +} mode7_ctx_t; + +static void mode7_rows(void *arg, int lo, int hi) { + mode7_ctx_t *c = arg; + picogame_canvas_obj_t *cv = c->cv; + int w = cv->w; + #if defined(PICOGAME_HAS_INTERP) + int ltw = 16 - c->shx; + int lth = 16 - c->shy; + bool use_interp = (c->fmt == PICOGAME_FMT_PAL8) && !c->transp && c->pal != NULL + && c->stride == c->mx + 1 + && ltw >= 1 && lth >= 1 && ltw + lth <= 16; // lane1 shift = shy-ltw must be >= 0 + #endif + for (int sy = lo; sy < hi; sy++) { + int denom = (sy + c->y_off) - c->horizon; + if (denom <= 0) { + continue; + } + // 32-bit throughout (no 64-bit mul helper on the M0+): rowdist*coeff stays + // within int32 for sane camera params - the Python helper keeps z and the + // ray deltas small; extreme values degrade to wrong pixels, never a crash. + int32_t rowdist = c->z / denom; + int32_t stepx = (rowdist * c->rsx) >> 16; + int32_t stepy = (rowdist * c->rsy) >> 16; + int32_t fx = c->cam_x + ((rowdist * c->rx0) >> 16); + int32_t fy = c->cam_y + ((rowdist * c->ry0) >> 16); + uint16_t *drow = cv->data + sy * w; + #if defined(PICOGAME_HAS_INTERP) + if (use_interp) { + picogame_mode7_row_interp(drow, w, c->data, c->pal, + (uint32_t)fx, (uint32_t)fy, stepx, stepy, c->shx, c->shy, ltw, lth); + continue; + } + #endif + for (int sx = 0; sx < w; sx++) { + int tx = (fx >> c->shx) & c->mx, ty = (fy >> c->shy) & c->my; + uint16_t val; + if (src_pixel_s(c->fmt, c->data, c->pal, c->transp, c->key, ty * c->stride + tx, &val)) { + drow[sx] = val; + } + fx += stepx; + fy += stepy; + } + } +} + +void picogame_canvas_mode7(picogame_canvas_obj_t *cv, picogame_bitmap_obj_t *tex, + int horizon, int y_off, int32_t z, int32_t rx0, int32_t ry0, int32_t rsx, int32_t rsy, + int32_t cam_x, int32_t cam_y) { + // Perspective ground plane (Mode-7 / floorcaster). For each screen row below + // `horizon`, distance = z / (row - horizon) (16.16); the texture-space coord + // of the left edge is cam + distance*ray0, stepping by distance*rayDelta per + // pixel; sample `tex` (power-of-2, so wrap = a mask, and world 1.0 = one tile + // via a shift, no multiply). Integer throughout - no FPU needed (RP2040). + if (tex == NULL) { + return; + } + int tw = tex->width, th = tex->height; + if ((tw & (tw - 1)) || (th & (th - 1))) { // require power-of-2 dims + return; + } + int shx = 16 - log2_pow2((unsigned)tw); // world(1.0) -> one full tile + int shy = 16 - log2_pow2((unsigned)th); + int mx = tw - 1, my = th - 1, stride = tex->stride; + int fmt = tex->format; + const uint8_t *data = tex->data; + const uint16_t *pal = tex->palette; + bool transp = tex->has_transparent; + uint16_t key = tex->transparent; + // sy is a row WITHIN this surface (a StripDraw view is a Canvas onto one strip); + // the absolute screen row is sy + y_off, so the horizon test uses that. y_off = 0 + // for a full-screen Canvas, = the strip's screen y for a StripDraw view (0-RAM floor). + int y0 = horizon - y_off + 1; + if (y0 < 0) { + y0 = 0; + } + mode7_ctx_t ctx = { + cv, data, pal, fmt, stride, shx, shy, mx, my, horizon, y_off, + z, rx0, ry0, rsx, rsy, cam_x, cam_y, transp, key + }; + mode7_rows(&ctx, y0, cv->h); + mark(cv, 0, y0, cv->w, cv->h); +} + +void picogame_canvas_rect(picogame_canvas_obj_t *cv, int x, int y, int w, int h, uint16_t color) { + picogame_canvas_fill_rect(cv, x, y, w, 1, color); + picogame_canvas_fill_rect(cv, x, y + h - 1, w, 1, color); + picogame_canvas_fill_rect(cv, x, y, 1, h, color); + picogame_canvas_fill_rect(cv, x + w - 1, y, 1, h, color); +} + +void picogame_canvas_line(picogame_canvas_obj_t *cv, int x0, int y0, int x1, int y1, uint16_t color) { + int dx = x1 - x0, dy = y1 - y0; + int adx = dx < 0 ? -dx : dx; + int ady = dy < 0 ? -dy : dy; + int sx = dx < 0 ? -1 : 1; + int sy = dy < 0 ? -1 : 1; + int err = adx - ady; + int x = x0, y = y0; + while (true) { + put(cv, x, y, color); + if (x == x1 && y == y1) { + break; + } + int e2 = 2 * err; + if (e2 > -ady) { + err -= ady; + x += sx; + } + if (e2 < adx) { + err += adx; + y += sy; + } + } + int lx1 = x0 < x1 ? x0 : x1, ly1 = y0 < y1 ? y0 : y1; + int lx2 = (x0 > x1 ? x0 : x1) + 1, ly2 = (y0 > y1 ? y0 : y1) + 1; + mark(cv, lx1, ly1, lx2, ly2); +} + +// Clamp a row span to the surface and word-fill it (the span-pass idiom shared by the filled +// shapes; the per-pixel put() loops it replaced clipped and indexed every pixel). +static void span565(picogame_canvas_obj_t *cv, int y, int xs, int xe, uint16_t color) { + if (y < 0 || y >= cv->h) { + return; + } + if (xs < 0) { + xs = 0; + } + if (xe >= cv->w) { + xe = cv->w - 1; + } + if (xs <= xe) { + fill565(&cv->data[y * cv->w + xs], xe - xs + 1, color); + } +} + +void picogame_canvas_fill_circle(picogame_canvas_obj_t *cv, int cx, int cy, int r, uint16_t color) { + // A filled circle IS fill_ellipse(r, r) - the ellipse row condition s^2*ry2 <= rr - dy^2*rx2 + // collapses to s^2 <= r^2 - dy^2 (host-proven byte-exact over 36k cases). Same delegation the + // OUTLINE circle already does; only r == 0 needs care (the ellipse rejects rx <= 0, a zero-radius + // circle is one pixel). Flash: this replaced a ~320 B twin of the ellipse body. + if (r < 0) { + return; + } + if (r == 0) { + picogame_canvas_pixel(cv, cx, cy, color); + return; + } + picogame_canvas_fill_ellipse(cv, cx, cy, r, r, color); +} + +void picogame_canvas_circle(picogame_canvas_obj_t *cv, int cx, int cy, int r, uint16_t color) { + picogame_canvas_ellipse(cv, cx, cy, r, r, color); // a circle is an ellipse with rx == ry +} + +void picogame_canvas_ring(picogame_canvas_obj_t *cv, int cx, int cy, int r, int thick, uint16_t color) { + if (r < 0) { + return; + } + int inner = r - thick; + if (inner < 0) { + inner = 0; + } + // Mirrored rows + decremental outer/inner widths, two word-filled spans per row - see fill_circle. + int out = r, ins = inner; + for (int dy = 0; dy <= r; dy++) { + long rr = (long)r * r - (long)dy * dy; // long (like ellipse): int r*r overflows at big radii + while ((long)out * out > rr) { + out--; + } + int two_seg = dy <= inner; + if (two_seg) { + int ri = inner * inner - dy * dy; + while (ins * ins > ri) { + ins--; + } + } + for (int half = 0; half < (dy ? 2 : 1); half++) { + int y = half ? cy - dy : cy + dy; + if (two_seg) { + span565(cv, y, cx - out, cx - ins - 1, color); + span565(cv, y, cx + ins + 1, cx + out, color); + } else { + span565(cv, y, cx - out, cx + out, color); + } + } + } + mark(cv, cx - r, cy - r, cx + r + 1, cy + r + 1); +} + +void picogame_canvas_triangle(picogame_canvas_obj_t *cv, + int x0, int y0, int x1, int y1, int x2, int y2, uint16_t color) { + picogame_canvas_line(cv, x0, y0, x1, y1, color); + picogame_canvas_line(cv, x1, y1, x2, y2, color); + picogame_canvas_line(cv, x2, y2, x0, y0, color); +} + +void picogame_canvas_fill_triangle(picogame_canvas_obj_t *cv, + int x0, int y0, int x1, int y1, int x2, int y2, uint16_t color) { + int X[3] = { x0, x1, x2 }, Y[3] = { y0, y1, y2 }; + for (int i = 0; i < 2; i++) { + for (int j = i + 1; j < 3; j++) { + if (Y[j] < Y[i]) { + int t = Y[i]; + Y[i] = Y[j]; + Y[j] = t; + t = X[i]; + X[i] = X[j]; + X[j] = t; + } + } + } + // Scanline fill via 16.16 edge DDA + word-filled spans. One 64-bit divide per EDGE replaces two + // 32-bit divides per ROW (M0+ has no HW divide, ~75 cyc each; a 20-row wall paid ~40 divides), + // and each row goes through fill565 (word stores) instead of a per-pixel clipped put(). Rows and + // spans clamp to the canvas up front, so a mostly off-screen triangle costs only its visible rows + // (the old loop walked EVERY row of huge triangles and clipped per pixel - quadratic blowup). + // Edge x differs from the old divide by at most 1 px (trunc vs floor; host-verified over 100k + // triangles), and convex quads - the box faces the 3D demos draw - stay seam-hole-free. + int w = cv->w, h = cv->h; + if (Y[0] < h && Y[2] >= 0) { + int64_t sAC = (Y[2] != Y[0]) ? (((int64_t)(X[2] - X[0]) << 16) / (Y[2] - Y[0])) : 0; + int64_t sAB = (Y[1] != Y[0]) ? (((int64_t)(X[1] - X[0]) << 16) / (Y[1] - Y[0])) : 0; + int64_t sBC = (Y[2] != Y[1]) ? (((int64_t)(X[2] - X[1]) << 16) / (Y[2] - Y[1])) : 0; + uint16_t *data = cv->data; + // top half: rows [Y0, Y1) walk edges A->C and A->B + int ys = Y[0] < 0 ? 0 : Y[0]; + int ye = (Y[1] - 1) < (h - 1) ? (Y[1] - 1) : (h - 1); + int64_t accAC = ((int64_t)X[0] << 16) + sAC * (ys - Y[0]); + int64_t acc2 = ((int64_t)X[0] << 16) + sAB * (ys - Y[0]); + for (int y = ys; y <= ye; y++) { + int xac = (int)(accAC >> 16); + int xsh = (int)(acc2 >> 16); + int xs = xac < xsh ? xac : xsh, xe = xac < xsh ? xsh : xac; + if (xs < 0) { + xs = 0; + } + if (xe >= w) { + xe = w - 1; + } + if (xs <= xe) { + fill565(&data[y * w + xs], xe - xs + 1, color); + } + accAC += sAC; + acc2 += sAB; + } + // bottom half: rows [Y1, Y2] walk edges A->C and B->C (a flat bottom degenerates to sBC=0) + ys = Y[1] < 0 ? 0 : Y[1]; + ye = Y[2] < (h - 1) ? Y[2] : (h - 1); + accAC = ((int64_t)X[0] << 16) + sAC * (ys - Y[0]); + acc2 = ((int64_t)X[1] << 16) + sBC * (ys - Y[1]); + for (int y = ys; y <= ye; y++) { + int xac = (int)(accAC >> 16); + int xsh = (int)(acc2 >> 16); + int xs = xac < xsh ? xac : xsh, xe = xac < xsh ? xsh : xac; + if (xs < 0) { + xs = 0; + } + if (xe >= w) { + xe = w - 1; + } + if (xs <= xe) { + fill565(&data[y * w + xs], xe - xs + 1, color); + } + accAC += sAC; + acc2 += sBC; + } + } + int mnx = X[0] < X[1] ? X[0] : X[1]; + mnx = mnx < X[2] ? mnx : X[2]; + int mxx = X[0] > X[1] ? X[0] : X[1]; + mxx = mxx > X[2] ? mxx : X[2]; + mark(cv, mnx, Y[0], mxx + 1, Y[2] + 1); +} + +void picogame_canvas_ellipse(picogame_canvas_obj_t *cv, int cx, int cy, int rx, int ry, uint16_t color) { + if (rx <= 0 || ry <= 0) { + return; + } + // 32-bit `long`: rx2*ry2 stays in range while rx*ry <= 46340 (both radii <~210 px). That covers any + // canvas that fits in RAM on this target. A larger ellipse (only reachable on a big-RAM board with an + // oversized canvas) renders a wrong shape - never a fault, since put() clips every pixel to the canvas. + long rx2 = (long)rx * rx, ry2 = (long)ry * ry, rr = rx2 * ry2; + for (int dy = -ry; dy <= ry; dy++) { + int s = 0; + while ((long)(s + 1) * (s + 1) * ry2 + (long)dy * dy * rx2 <= rr) { + s++; + } + put(cv, cx - s, cy + dy, color); + put(cv, cx + s, cy + dy, color); + } + for (int dx = -rx; dx <= rx; dx++) { + int s = 0; + while ((long)(s + 1) * (s + 1) * rx2 + (long)dx * dx * ry2 <= rr) { + s++; + } + put(cv, cx + dx, cy - s, color); + put(cv, cx + dx, cy + s, color); + } + mark(cv, cx - rx, cy - ry, cx + rx + 1, cy + ry + 1); +} + +void picogame_canvas_fill_ellipse(picogame_canvas_obj_t *cv, int cx, int cy, int rx, int ry, uint16_t color) { + if (rx <= 0 || ry <= 0) { + return; + } + // 32-bit `long`: rx2*ry2 stays in range while rx*ry <= 46340 (both radii <~210 px). That covers any + // canvas that fits in RAM on this target. A larger ellipse (only reachable on a big-RAM board with an + // oversized canvas) renders a wrong shape - never a fault, since put() clips every pixel to the canvas. + long rx2 = (long)rx * rx, ry2 = (long)ry * ry, rr = rx2 * ry2; + // Mirrored rows + decremental width, spans word-filled - see fill_circle. + int s = rx; + for (int dy = 0; dy <= ry; dy++) { + long lim = rr - (long)dy * dy * rx2; // s*s*ry2 <= lim <=> the old (s+1)-increment bound + while ((long)s * s * ry2 > lim) { + s--; + } + span565(cv, cy + dy, cx - s, cx + s, color); + if (dy) { + span565(cv, cy - dy, cx - s, cx + s, color); + } + } + mark(cv, cx - rx, cy - ry, cx + rx + 1, cy + ry + 1); +} + +void picogame_canvas_fill_round_rect(picogame_canvas_obj_t *cv, int x, int y, int w, int h, int r, uint16_t color) { + if (r > w / 2) { + r = w / 2; + } + if (r > h / 2) { + r = h / 2; + } + if (r < 0) { + r = 0; + } + picogame_canvas_fill_rect(cv, x + r, y, w - 2 * r, h, color); + picogame_canvas_fill_rect(cv, x, y + r, r, h - 2 * r, color); + picogame_canvas_fill_rect(cv, x + w - r, y + r, r, h - 2 * r, color); + picogame_canvas_fill_circle(cv, x + r, y + r, r, color); + picogame_canvas_fill_circle(cv, x + w - r - 1, y + r, r, color); + picogame_canvas_fill_circle(cv, x + r, y + h - r - 1, r, color); + picogame_canvas_fill_circle(cv, x + w - r - 1, y + h - r - 1, r, color); +} + +void picogame_canvas_frame3d(picogame_canvas_obj_t *cv, int x, int y, int w, int h, uint16_t light, uint16_t dark) { + picogame_canvas_fill_rect(cv, x, y, w, 1, light); + picogame_canvas_fill_rect(cv, x, y, 1, h, light); + picogame_canvas_fill_rect(cv, x, y + h - 1, w, 1, dark); + picogame_canvas_fill_rect(cv, x + w - 1, y, 1, h, dark); +} + +void picogame_blit_canvas( + uint16_t *buf, int region_w, int strip_top, int strip_h, int x0, + picogame_canvas_obj_t *cv, int ox, int oy) { + // Reuse the bitmap blitter by viewing the canvas as a 1-frame RGB565 bitmap. + picogame_bitmap_obj_t bm; + bm.data = (const uint8_t *)cv->data; + bm.palette = NULL; + bm.width = cv->w; + bm.height = cv->h; + bm.stride = cv->w; + bm.transparent = cv->transparent; + bm.format = PICOGAME_FMT_RGB565; + bm.frames = 1; + bm.has_transparent = cv->has_transparent; + picogame_blit_bitmap(buf, region_w, strip_h, x0, strip_top, &bm, + cv->x + ox, cv->y + oy, 0, false, false, false, NULL); +} + +// Composite a string's glyphs straight into the surface in C: rasterize each glyph from the +// 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. +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; + displayio_bitmap_t *sheet = (displayio_bitmap_t *)f->bitmap; + int fw = f->width, fh = f->height; + int tpr = sheet->width / fw; // glyph tiles per atlas row + int x0 = x; + // Hoist the canvas target + read the 1-bpp glyph atlas DIRECTLY (no per-pixel get_pixel/put calls). + // Clip each glyph rect to the canvas ONCE, then the inner loop is atlas-bit -> direct store. This is + // the per-frame path for StripDraw HUD text (repainted every frame), so it's worth the directness. + uint16_t *cdata = cv->data; + int cw = cv->w, ch = cv->h; + bool onebit = (sheet->bits_per_value == 1); // terminalio.FONT is 1-bpp; other fonts take the fallback + const uint8_t *sdata = (const uint8_t *)sheet->data; + int sstride_b = sheet->stride * 4; // atlas row stride in BYTES (stride counts uint32) + for (const uint8_t *p = (const uint8_t *)text; *p; p++) { + uint8_t gi = fontio_builtinfont_get_glyph_index(f, *p); + if (gi != 0xff) { // 0xff = no glyph -> blank advance + int tx = (gi % tpr) * fw, ty = (gi / tpr) * fh; + int gx0 = (x < 0) ? -x : 0; // clip the glyph rect to the canvas once + int gx1 = (x + fw > cw) ? cw - x : fw; + int gy0 = (y < 0) ? -y : 0; + int gy1 = (y + fh > ch) ? ch - y : fh; + for (int gy = gy0; gy < gy1; gy++) { + uint16_t *drow = cdata + (y + gy) * cw + x; // dst; index by gx (x+gx is in-bounds) + int sy = ty + gy; + if (onebit) { + const uint8_t *srow = sdata + (size_t)sy * sstride_b; + for (int gx = gx0; gx < gx1; gx++) { + int sx = tx + gx; + if ((srow[sx >> sheet->x_shift] >> (sheet->x_mask - (sx & sheet->x_mask))) & sheet->bitmask) { + drow[gx] = fg; + } else if (has_bg) { + drow[gx] = bg; + } + } + } else { + for (int gx = gx0; gx < gx1; gx++) { + if (common_hal_displayio_bitmap_get_pixel(sheet, tx + gx, sy)) { + drow[gx] = fg; + } else if (has_bg) { + drow[gx] = bg; + } + } + } + } + } + x += fw; + } + mark(cv, x0, y, x, y + fh); +} + +// 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). +void picogame_fill_triangle_batch(picogame_canvas_obj_t *cv, const int16_t *v, + const uint16_t *col, int n, int xo, int yo) { + int cw = cv->w, ch = cv->h; + for (int i = 0; i < n; i++) { + const int16_t *p = v + i * 6; + int y0 = p[1] + yo, y1 = p[3] + yo, y2 = p[5] + yo; + if ((y0 < 0 && y1 < 0 && y2 < 0) || (y0 >= ch && y1 >= ch && y2 >= ch)) { + continue; + } + int x0 = p[0] + xo, x1 = p[2] + xo, x2 = p[4] + xo; + if ((x0 < 0 && x1 < 0 && x2 < 0) || (x0 >= cw && x1 >= cw && x2 >= cw)) { + continue; + } + picogame_canvas_fill_triangle(cv, x0, y0, x1, y1, x2, y2, col[i]); + } +} diff --git a/shared-module/picogame/Canvas.h b/shared-module/picogame/Canvas.h new file mode 100644 index 00000000000..45c73e43326 --- /dev/null +++ b/shared-module/picogame/Canvas.h @@ -0,0 +1,69 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT +// +// picogame Canvas: a RAM RGB565 surface (any size) you draw primitives into, +// composited as a Scene layer. The general home for shapes (fill_rect, line, +// circle, pixel) - accumulates a dirty rect (scene coords) so only redrawn +// areas repaint. Colors are wire-order (picogame.rgb565). + +#pragma once + +#include +#include +#include "py/obj.h" +#include "shared-module/picogame/Bitmap.h" + +typedef struct { + mp_obj_base_t base; + uint16_t *data; // w*h wire-order RGB565 + mp_obj_t data_obj; // backing buffer kept alive (MP_OBJ_NULL if m_new'd) + uint16_t w, h; + int32_t x, y; // scene position (int32: a canvas can sit past +-32767 px in a big world) + uint16_t transparent; + bool has_transparent; + int32_t dx1, dy1, dx2, dy2; // accumulated dirty rect (scene coords; int32, see x/y) +} picogame_canvas_obj_t; + +void picogame_canvas_dirty_reset(picogame_canvas_obj_t *cv); +// Grow the dirty rect to also cover a scene-coord rect (no surface clamping). +void picogame_canvas_dirty_union(picogame_canvas_obj_t *cv, int x1, int y1, int x2, int y2); +bool picogame_canvas_take_dirty(picogame_canvas_obj_t *cv, int *x1, int *y1, int *x2, int *y2); + +void picogame_canvas_clear(picogame_canvas_obj_t *cv, uint16_t color); +void picogame_canvas_pixel(picogame_canvas_obj_t *cv, int x, int y, uint16_t color); +void picogame_canvas_fill_rect(picogame_canvas_obj_t *cv, int x, int y, int w, int h, uint16_t color); +void picogame_canvas_blit(picogame_canvas_obj_t *cv, picogame_bitmap_obj_t *bm, int x, int y, int frame, bool flip_x, bool flip_y); +void picogame_canvas_rect(picogame_canvas_obj_t *cv, int x, int y, int w, int h, uint16_t color); +void picogame_canvas_line(picogame_canvas_obj_t *cv, int x0, int y0, int x1, int y1, uint16_t color); +void picogame_canvas_fill_circle(picogame_canvas_obj_t *cv, int cx, int cy, int r, uint16_t color); +void picogame_canvas_circle(picogame_canvas_obj_t *cv, int cx, int cy, int r, uint16_t color); +void picogame_canvas_ring(picogame_canvas_obj_t *cv, int cx, int cy, int r, int thick, uint16_t color); +void picogame_canvas_triangle(picogame_canvas_obj_t *cv, int x0, int y0, int x1, int y1, int x2, int y2, uint16_t color); +void picogame_canvas_fill_triangle(picogame_canvas_obj_t *cv, int x0, int y0, int x1, int y1, int x2, int y2, uint16_t color); +void picogame_canvas_ellipse(picogame_canvas_obj_t *cv, int cx, int cy, int rx, int ry, uint16_t color); +void picogame_canvas_fill_ellipse(picogame_canvas_obj_t *cv, int cx, int cy, int rx, int ry, uint16_t color); +void picogame_canvas_fill_round_rect(picogame_canvas_obj_t *cv, int x, int y, int w, int h, int r, uint16_t color); +void picogame_canvas_frame3d(picogame_canvas_obj_t *cv, int x, int y, int w, int h, uint16_t light, uint16_t dark); +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); +// Mode-7 perspective ground plane: fill rows below `horizon` with a receding view +// of `tex` (power-of-2 dims). Args are 16.16 fixed-point (a Python helper computes +// them from camera angle/pos/fov). See picogame_canvas_mode7 for the exact math. +// One racing-road strip: per-row spans (road/rumbles/dashes/sky) from precomputed tables. +void picogame_canvas_road(picogame_canvas_obj_t *cv, int ri0, + const int16_t *tab, int ntab, const int16_t *rl, const int16_t *rr, + int32_t d05_q8, int32_t d07_q8, const uint16_t *colors); + +void picogame_canvas_mode7(picogame_canvas_obj_t *cv, picogame_bitmap_obj_t *tex, + int horizon, int y_off, int32_t z, int32_t rx0, int32_t ry0, int32_t rsx, int32_t rsy, + int32_t cam_x, int32_t cam_y); + +void picogame_blit_canvas( + uint16_t *buf, int region_w, int strip_top, int strip_h, int x0, + picogame_canvas_obj_t *cv, int ox, int oy); + +void picogame_fill_triangle_batch(picogame_canvas_obj_t *cv, const int16_t *v, + const uint16_t *col, int n, int xo, int yo); diff --git a/shared-module/picogame/Particles.c b/shared-module/picogame/Particles.c new file mode 100644 index 00000000000..87e6917f479 --- /dev/null +++ b/shared-module/picogame/Particles.c @@ -0,0 +1,163 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#include "shared-module/picogame/Particles.h" +#include "shared-module/picogame/__init__.h" + +static uint32_t s_prng = 0x1234abcdu; + +static int32_t prng_range(int32_t lo, int32_t hi) { + s_prng ^= s_prng << 13; + s_prng ^= s_prng >> 17; + s_prng ^= s_prng << 5; + if (hi <= lo) { + return lo; + } + return lo + (int32_t)(s_prng % (uint32_t)(hi - lo + 1)); +} + +static void swap_remove(picogame_particles_obj_t *ps, int i) { + int last = ps->count - 1; + ps->px[i] = ps->px[last]; + ps->py[i] = ps->py[last]; + ps->vx[i] = ps->vx[last]; + ps->vy[i] = ps->vy[last]; + ps->life[i] = ps->life[last]; + ps->life0[i] = ps->life0[last]; + ps->color[i] = ps->color[last]; + ps->count--; +} + +// Dim a wire-order RGB565 color to num/den of its brightness (per channel). +static inline uint16_t scale_wire565(uint16_t wire, int num, int den) { + uint16_t c = (uint16_t)((wire >> 8) | (wire << 8)); // wire -> native + int r = ((c >> 11) & 0x1F) * num / den; + int g = ((c >> 5) & 0x3F) * num / den; + int b = (c & 0x1F) * num / den; + uint16_t out = (uint16_t)((r << 11) | (g << 5) | b); + return (uint16_t)((out >> 8) | (out << 8)); // native -> wire +} + +void picogame_particles_emit(picogame_particles_obj_t *ps, int x, int y, + int count, int speed, int life, uint16_t color) { + int sp = speed * 256; // px/tick -> 8.8 + for (int k = 0; k < count && ps->count < ps->cap; k++) { + int i = ps->count++; + ps->px[i] = x << 8; + ps->py[i] = y << 8; + ps->vx[i] = (int16_t)prng_range(-sp, sp); + ps->vy[i] = (int16_t)prng_range(-sp, sp); + ps->life[i] = life; + ps->life0[i] = (uint16_t)(life > 0 ? life : 1); + ps->color[i] = color; + } +} + +void picogame_particles_update(picogame_particles_obj_t *ps) { + int16_t g = ps->gravity; + int x1 = 0x7fffffff, y1 = 0x7fffffff, x2 = -0x7fffffff - 1, y2 = -0x7fffffff - 1; // INT32: big-world + int sz = ps->size; + int i = 0; + while (i < ps->count) { + ps->px[i] += ps->vx[i]; + ps->py[i] += ps->vy[i]; + ps->vy[i] += g; + if (ps->life[i] == 0) { + swap_remove(ps, i); + continue; // re-process the swapped-in particle + } + ps->life[i]--; + int sx = ps->px[i] >> 8; + int sy = ps->py[i] >> 8; + if (sx < x1) { + x1 = sx; + } + if (sy < y1) { + y1 = sy; + } + if (sx + sz > x2) { + x2 = sx + sz; + } + if (sy + sz > y2) { + y2 = sy + sz; + } + i++; + } + // current-frame bbox becomes "previous" on the next take_dirty + ps->px1 = ps->cx1; + ps->py1 = ps->cy1; + ps->px2 = ps->cx2; + ps->py2 = ps->cy2; + if (ps->count > 0) { + ps->cx1 = x1; + ps->cy1 = y1; + ps->cx2 = x2; + ps->cy2 = y2; + } else { + picogame_dirty_reset(&ps->cx1); // empty -> INT32 sentinels (cx1,cy1,cx2,cy2 are contiguous) + } +} + +bool picogame_particles_take_dirty(picogame_particles_obj_t *ps, + int *x1, int *y1, int *x2, int *y2) { + int ax1 = ps->cx1 < ps->px1 ? ps->cx1 : ps->px1; + int ay1 = ps->cy1 < ps->py1 ? ps->cy1 : ps->py1; + int ax2 = ps->cx2 > ps->px2 ? ps->cx2 : ps->px2; + int ay2 = ps->cy2 > ps->py2 ? ps->cy2 : ps->py2; + // consume the previous box so a static system stops reporting dirty + ps->px1 = ps->cx1; + ps->py1 = ps->cy1; + ps->px2 = ps->cx2; + ps->py2 = ps->cy2; + if (ax1 >= ax2 || ay1 >= ay2) { + return false; + } + *x1 = ax1; + *y1 = ay1; + *x2 = ax2; + *y2 = ay2; + return true; +} + +void picogame_particles_clear(picogame_particles_obj_t *ps) { + // Move the currently-drawn region into "previous" and empty "current", so the next + // take_dirty reports the old pixels ONCE (erasing the cleared particles) then goes quiet. + ps->px1 = ps->cx1; + ps->py1 = ps->cy1; + ps->px2 = ps->cx2; + ps->py2 = ps->cy2; + picogame_dirty_reset(&ps->cx1); // empty -> INT32 sentinels (cx1,cy1,cx2,cy2 are contiguous) + ps->count = 0; +} + +void picogame_blit_particles( + uint16_t *buf, int region_w, int strip_top, int strip_h, int x0, + picogame_particles_obj_t *ps, int ox, int oy) { + int sz = ps->size; + int rx2 = x0 + region_w; + int ry2 = strip_top + strip_h; + for (int i = 0; i < ps->count; i++) { + int sx = (ps->px[i] >> 8) + ox; + int sy = (ps->py[i] >> 8) + oy; + int xs = picogame_imax(sx, x0); + int ys = picogame_imax(sy, strip_top); + int xe = picogame_imin(sx + sz, rx2); + int ye = picogame_imin(sy + sz, ry2); + if (xs >= xe || ys >= ye) { + continue; + } + uint16_t c = ps->color[i]; + if (ps->fade) { + c = scale_wire565(c, ps->life[i], ps->life0[i]); + } + for (int y = ys; y < ye; y++) { + uint16_t *dst = buf + (y - strip_top) * region_w + (xs - x0); + for (int x = xs; x < xe; x++) { + *dst++ = c; + } + } + } +} diff --git a/shared-module/picogame/Particles.h b/shared-module/picogame/Particles.h new file mode 100644 index 00000000000..faaf7a4989b --- /dev/null +++ b/shared-module/picogame/Particles.h @@ -0,0 +1,45 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT +// +// picogame particle system: a pooled set of small moving dots rendered as one +// Scene layer (individual sprites would be far too heavy). Positions/velocities +// are 24.8 / 8.8 fixed-point for sub-pixel motion. Tracks a dirty rect spanning +// the previous and current frames so moving particles leave no trails. + +#pragma once + +#include +#include +#include "py/obj.h" + +typedef struct { + mp_obj_base_t base; + int32_t *px, *py; // position, 24.8 fixed-point, scene coords + int16_t *vx, *vy; // velocity, 8.8 fixed-point per tick + uint16_t *life; // ticks remaining + uint16_t *life0; // life at spawn (for the fade ramp) + uint16_t *color; // wire-order RGB565 + uint16_t cap, count; + int16_t gravity; // 8.8, added to vy each tick + uint8_t size; // particle size in pixels + bool fade; // dim each particle toward black as it ages + // dirty bounding boxes (scene coords): previous frame and current frame. + // int32 (not int16): positions are 24.8 in int32, so emitters past +-32767 px would truncate. + int32_t cx1, cy1, cx2, cy2; + int32_t px1, py1, px2, py2; +} picogame_particles_obj_t; + +void picogame_particles_emit(picogame_particles_obj_t *ps, int x, int y, + int count, int speed, int life, uint16_t color); +void picogame_particles_update(picogame_particles_obj_t *ps); +// Remove all particles, marking their last-drawn region dirty once so they get erased. +void picogame_particles_clear(picogame_particles_obj_t *ps); +// Returns true + the dirty rect (scene coords) spanning last+current frames. +bool picogame_particles_take_dirty(picogame_particles_obj_t *ps, + int *x1, int *y1, int *x2, int *y2); +void picogame_blit_particles( + uint16_t *buf, int region_w, int strip_top, int strip_h, int x0, + picogame_particles_obj_t *ps, int ox, int oy); diff --git a/shared-module/picogame/Scene.c b/shared-module/picogame/Scene.c new file mode 100644 index 00000000000..0d9e31c6fbc --- /dev/null +++ b/shared-module/picogame/Scene.c @@ -0,0 +1,226 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#include "shared-module/picogame/Scene.h" +#include "shared-module/picogame/__init__.h" +#include "shared-module/picogame/Bitmap.h" +#include "shared-module/picogame/Tilemap.h" +#include "shared-module/picogame/Particles.h" +#include "shared-module/picogame/Canvas.h" + +// Raw change rects collected before merging. Caps how many disjoint changes we +// track in one frame; on overflow we safely fall back to a full-screen repaint. +#define PICOGAME_RAW_RECTS 64 + +static inline bool rects_overlap(const picogame_rect_t *a, const picogame_rect_t *b) { + return a->x1 < b->x2 && b->x1 < a->x2 && a->y1 < b->y2 && b->y1 < a->y2; +} + +static inline void rect_merge(picogame_rect_t *a, const picogame_rect_t *b) { + if (b->x1 < a->x1) { + a->x1 = b->x1; + } + if (b->y1 < a->y1) { + a->y1 = b->y1; + } + if (b->x2 > a->x2) { + a->x2 = b->x2; + } + if (b->y2 > a->y2) { + a->y2 = b->y2; + } +} + +static inline long rect_area(const picogame_rect_t *r) { + return (long)(r->x2 - r->x1) * (long)(r->y2 - r->y1); +} + +// Compute up to `max_rects` mostly-disjoint dirty rectangles (screen coords) and +// update the per-sprite snapshots. Returns the rect count (0 = nothing changed). +// Collecting per-change rects (instead of one union) means scattered movers no +// longer inflate the repaint to the whole screen. +// Out-of-line rect append for compute_dirty_rects below: the old macro expanded ~30 B +// four times; a real (noinline) call keeps each site at argument setup only. +static __attribute__((noinline)) void add_rect(picogame_rect_t *raw, int *nr, bool *overflow, + int x1, int y1, int x2, int y2) { + if (*nr < PICOGAME_RAW_RECTS) { + raw[*nr].x1 = x1; + raw[*nr].y1 = y1; + raw[*nr].x2 = x2; + raw[*nr].y2 = y2; + (*nr)++; + } else { + *overflow = true; + } +} + +int picogame_scene_compute_dirty_rects( + mp_obj_t *items, uint8_t *kinds, picogame_snapshot_t *snap, size_t n, + int screen_w, int screen_h, int ox, int oy, + picogame_rect_t *out, int max_rects) { + + picogame_rect_t raw[PICOGAME_RAW_RECTS]; + int nr = 0; + bool overflow = false; + + // Rects are stored in SCREEN coords: non-fixed items get the view offset added + // here (per item), fixed (HUD) items don't - so no uniform offset at the end. + #define ADD_RECT(ax, ay, bx, by) \ + add_rect(raw, &nr, &overflow, (ax) + iox, (ay) + ioy, (bx) + iox, (by) + ioy) + + for (size_t i = 0; i < n; i++) { + uint8_t rawk = kinds[i]; + uint8_t kind = rawk & PICOGAME_KIND_MASK; + int iox = (rawk & PICOGAME_KIND_FIXED) ? 0 : ox; + int ioy = (rawk & PICOGAME_KIND_FIXED) ? 0 : oy; + if (kind != PICOGAME_KIND_SPRITE) { + int tx1, ty1, tx2, ty2; + bool d = false; + if (kind == PICOGAME_KIND_TILEMAP) { + d = picogame_tilemap_take_dirty(MP_OBJ_TO_PTR(items[i]), &tx1, &ty1, &tx2, &ty2); + } else if (kind == PICOGAME_KIND_PARTICLES) { + d = picogame_particles_take_dirty(MP_OBJ_TO_PTR(items[i]), &tx1, &ty1, &tx2, &ty2); + } else if (kind == PICOGAME_KIND_CANVAS) { + d = picogame_canvas_take_dirty(MP_OBJ_TO_PTR(items[i]), &tx1, &ty1, &tx2, &ty2); + } else if (kind == PICOGAME_KIND_STRIPDRAW) { + // No retained pixels to diff. always_dirty -> repaint the whole rect every frame + // (animated content). Otherwise repaint only the accumulated invalidate() rect - the + // same take_dirty contract as Canvas/Tilemap, so on-change UI repaints just its region + // (and still re-runs when another layer's dirty rect overlaps it). + picogame_stripdraw_obj_t *sd = MP_OBJ_TO_PTR(items[i]); + if (sd->always_dirty) { + tx1 = sd->x; + ty1 = sd->y; + tx2 = sd->x + sd->w; + ty2 = sd->y + sd->h; + d = true; + } else { + d = picogame_dirty_take(&sd->dx1, &tx1, &ty1, &tx2, &ty2); + } + } else if (kind == PICOGAME_KIND_TRIANGLES) { + // Screen-space batch: count-set marked a full-screen dirty (clipped later). + picogame_triangles_obj_t *t = MP_OBJ_TO_PTR(items[i]); + d = picogame_dirty_take(&t->dx1, &tx1, &ty1, &tx2, &ty2); + } + if (d) { + ADD_RECT(tx1, ty1, tx2, ty2); + } + continue; + } + + picogame_sprite_obj_t *s = MP_OBJ_TO_PTR(items[i]); + picogame_snapshot_t *sn = &snap[i]; + + // Snapshot tracks the drawn screen AABB (already includes scale + rotation), + // so position/anchor/size/scale/angle/bitmap changes are all detected. + int ax1, ay1, ax2, ay2; + picogame_sprite_aabb(s, &ax1, &ay1, &ax2, &ay2); + picogame_bitmap_obj_t *bm = s->bitmap; + bool changed = (ax1 != sn->x) || (ay1 != sn->y) || + ((ax2 - ax1) != sn->w) || ((ay2 - ay1) != sn->h) || + (s->frame != sn->frame) || (s->flags != sn->flags) || + (s->scale != sn->scale) || (s->angle != sn->angle) || + (s->seq != sn->seq) || // touch(): in-place bitmap content change + (s->dither != sn->dither) || // translucency level animation + (s->flash_color != sn->flash_color) || // flash/tint colour-only change (flag stays set) + ((void *)bm != sn->bitmap); + if (!changed) { + continue; + } + + // Old rect = previous AABB (snapshot); new rect = current AABB. + if (sn->flags & PICOGAME_SPR_VISIBLE) { + ADD_RECT(sn->x, sn->y, sn->x + sn->w, sn->y + sn->h); + } + if (s->flags & PICOGAME_SPR_VISIBLE) { + ADD_RECT(ax1, ay1, ax2, ay2); + } + + sn->x = ax1; + sn->y = ay1; + sn->w = ax2 - ax1; + sn->h = ay2 - ay1; + sn->bitmap = (void *)bm; + sn->frame = s->frame; + sn->flags = s->flags; + sn->scale = s->scale; + sn->angle = s->angle; + sn->seq = s->seq; + sn->dither = s->dither; + sn->flash_color = s->flash_color; + } +#undef ADD_RECT + + if (nr == 0) { + return 0; + } + // Too many changes to track individually -> one full-screen repaint is both + // correct and likely cheaper than dozens of windows. + if (overflow) { + out[0].x1 = 0; + out[0].y1 = 0; + out[0].x2 = screen_w; + out[0].y2 = screen_h; + return 1; + } + + // Merge overlapping rects (avoids painting the same pixels twice) until stable. + bool again = true; + while (again) { + again = false; + for (int i = 0; i < nr; i++) { + for (int j = i + 1; j < nr; j++) { + if (rects_overlap(&raw[i], &raw[j])) { + rect_merge(&raw[i], &raw[j]); + raw[j] = raw[nr - 1]; + nr--; + again = true; + j--; + } + } + } + } + + // Cap the count by repeatedly merging the pair that wastes the fewest pixels. + while (nr > max_rects) { + int bi = 0, bj = 1; + long best = -1; + for (int i = 0; i < nr; i++) { + long area_i = rect_area(&raw[i]); // loop-invariant in j -> hoist out + for (int j = i + 1; j < nr; j++) { + picogame_rect_t u = raw[i]; + rect_merge(&u, &raw[j]); + long waste = rect_area(&u) - area_i - rect_area(&raw[j]); + if (best < 0 || waste < best) { + best = waste; + bi = i; + bj = j; + } + } + } + rect_merge(&raw[bi], &raw[bj]); + raw[bj] = raw[nr - 1]; + nr--; + } + + // Rects are already in screen coords (offset applied per item). Clip + drop empties. + int outn = 0; + for (int i = 0; i < nr; i++) { + int x1 = picogame_imax(raw[i].x1, 0); + int y1 = picogame_imax(raw[i].y1, 0); + int x2 = picogame_imin(raw[i].x2, screen_w); + int y2 = picogame_imin(raw[i].y2, screen_h); + if (x1 >= x2 || y1 >= y2) { + continue; + } + out[outn].x1 = x1; + out[outn].y1 = y1; + out[outn].x2 = x2; + out[outn].y2 = y2; + outn++; + } + return outn; +} diff --git a/shared-module/picogame/Scene.h b/shared-module/picogame/Scene.h new file mode 100644 index 00000000000..bd1e83937e3 --- /dev/null +++ b/shared-module/picogame/Scene.h @@ -0,0 +1,80 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT +// +// picogame retained-mode scene with dirty-rectangle tracking. The scene owns a +// list of sprites and a snapshot of their state from the previous frame; each +// refresh diffs against the snapshot to compute the changed regions and repaints +// only those: up to PICOGAME_MAX_DIRTY_RECTS (6) mostly-disjoint rectangles, each +// rendered into its own clamped SPI window, so several separated moving objects +// stay cheap; beyond that they merge toward a fuller redraw. + +#pragma once + +#include +#include +#include +#include "py/obj.h" +#include "shared-module/picogame/Sprite.h" + +typedef struct { + int32_t x, y; + int32_t w, h; // drawn SCENE-space AABB (top-left + size) when last drawn - already accounts + // for scale + rotation, so it is the old-rect on any change. int32 (not int16): + // sprite coords are 24.8 in int32, so a big scrolling world exceeds +-32767 px + void *bitmap; // bitmap identity when last drawn (detect graphic swaps); never + // dereferenced - compared for inequality only, so it is safe + // even if the previous bitmap has since been freed + uint16_t scale; // draw scale (8.8) when last drawn - detect scale changes + int16_t angle; // rotation when last drawn - detect rotation changes + uint8_t frame; + uint8_t flags; + uint8_t seq; // sprite.seq when last drawn - detect touch() (in-place content change) + uint8_t dither; // dither level when last drawn - detect translucency animation + uint16_t flash_color; // flash/tint colour when last drawn - detect a colour-only change + // (the effect flag stays set, so without this red->blue wouldn't repaint) +} picogame_snapshot_t; + +typedef struct { + int x1, y1, x2, y2; // screen-space dirty rectangle [x1,x2) x [y1,y2) +} picogame_rect_t; + +typedef struct { + mp_obj_base_t base; + mp_obj_t display; // picogame.Display (transport; also kept alive) + mp_obj_t buf_a; // strip buffer A (kept alive) + mp_obj_t buf_b; // strip buffer B (kept alive) + mp_obj_t *items; // sprite / tilemap objects (GC-scanned -> stay alive) + uint8_t *kinds; // PICOGAME_KIND_* per item + picogame_snapshot_t *snap; // previous-frame state; snap[i] is unused when + // kinds[i] == TILEMAP (tilemaps track their own dirty rect) + uint16_t count; + uint16_t cap; + uint16_t background; + int32_t ox, oy; // view offset: screen position of scene origin (camera/centering); int32 so a + // large-world camera can scroll past +-32767 px without truncating the offset + int16_t top, bottom, left, right; // reserved insets (px): the scene renders only the + // play rect [left, w-right) x [top, h-bottom) and never touches the + // border around it - the app owns it (HUD bars, side panels, frame). + bool cleared; // false until the first full-screen paint (covers stale pixels) + bool fast; // true: display is a fast picogame.Display (DMA); false: a plain + // busdisplay rendered via the portable bus.send fallback + #if CIRCUITPY_PICOGAME_FRAMEBUFFER + bool fb_target; // true: self->display is a picogame.Framebuffer (RAM scanout buffer); + // refresh() composites dirty rects straight into it, no bus. Mutually + // exclusive with `fast` and the busdisplay path. + #endif + mp_obj_t dirty_rect; // reusable [x1,y1,x2,y2] list returned by refresh() (no + // per-frame tuple allocation / GC churn) +} picogame_scene_obj_t; + +// Diff items against snapshots and produce up to `max_rects` mostly-disjoint +// dirty rectangles (screen coords), updating snapshots / draining layer dirties. +// Returns the rect count (0 = nothing changed). Scattered movers yield several +// small rects instead of one screen-spanning union. +int picogame_scene_compute_dirty_rects( + mp_obj_t *items, uint8_t *kinds, picogame_snapshot_t *snap, size_t n, + int screen_w, int screen_h, int ox, int oy, + picogame_rect_t *out, int max_rects); diff --git a/shared-module/picogame/Sprite.h b/shared-module/picogame/Sprite.h new file mode 100644 index 00000000000..157b94e652b --- /dev/null +++ b/shared-module/picogame/Sprite.h @@ -0,0 +1,48 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT +// +// picogame: a positioned, animatable instance of a Bitmap. + +#pragma once + +#include +#include "py/obj.h" +#include "shared-module/picogame/Bitmap.h" + +enum { + PICOGAME_SPR_VISIBLE = 1 << 0, + PICOGAME_SPR_FLIP_X = 1 << 1, + PICOGAME_SPR_FLIP_Y = 1 << 2, + PICOGAME_SPR_SHADOW = 1 << 3, // draw opaque pixels as a darkened destination (shadow/dim) + PICOGAME_SPR_FLASH = 1 << 4, // draw opaque pixels as a solid colour (hit-flash) + PICOGAME_SPR_DITHER = 1 << 5, // skip pixels via a Bayer pattern -> fake transparency + PICOGAME_SPR_TINT = 1 << 6, // multiply opaque pixels by a colour (keeps shading) + PICOGAME_SPR_TRANSPOSE = 1 << 7, // swap x/y -> cheap 90deg (with flips = all 8 orientations) +}; + +typedef struct { + mp_obj_base_t base; + picogame_bitmap_obj_t *bitmap; + mp_obj_t data; // arbitrary user payload (game state); GC-scanned + int32_t x, y; // position, 24.8 fixed-point (1/256 px), scene coords + uint16_t anchor_x; // pivot as a 1/256 fraction of width: 0=left, 128~=center, 256=right + uint16_t anchor_y; // pivot as a 1/256 fraction of height: 0=top, 128~=center, 256=bottom + uint16_t scale; // uniform draw scale, 8.8 fixed-point (256 = 1.0x); nearest-neighbour + int16_t angle; // rotation about the anchor, whole degrees (0 = axis-aligned fast path) + uint16_t flash_color; // FLASH mode: wire-order RGB565 that replaces opaque pixels + uint8_t frame; + uint8_t flags; + uint8_t seq; // bumped by touch() to force a repaint after an in-place bitmap mutation + uint8_t dither; // DITHER mode: transparency level 0..16 (0=opaque, 16=invisible) + // ---- affine transform cache (angle != 0 path). Filled lazily on first use, invalidated + // by the scale/angle/bitmap/anchor setters (xf_valid = 0). POSITION-INDEPENDENT: the bbox + // is relative to the sprite's integer position, ic/is are the 16.16 inverse-map steps. + // Saves the trig LUT + 4-corner bbox + two software divides that otherwise re-run once per + // STRIP the sprite touches (~6x/frame at strip_h=8) plus once for the dirty-rect AABB. + uint8_t xf_valid; + int16_t xf_minx, xf_miny, xf_maxx, xf_maxy; // corners bbox relative to (x>>8, y>>8) + int32_t xf_ic, xf_is; // inverse-map steps (16.16) +} picogame_sprite_obj_t; diff --git a/shared-module/picogame/Tilemap.c b/shared-module/picogame/Tilemap.c new file mode 100644 index 00000000000..5be8efb1c37 --- /dev/null +++ b/shared-module/picogame/Tilemap.c @@ -0,0 +1,100 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#include "shared-module/picogame/Tilemap.h" +#include "shared-module/picogame/__init__.h" + +// Floor division for b > 0. +static inline int floordiv(int a, int b) { + return (a >= 0) ? (a / b) : -(((-a) + b - 1) / b); +} + +// log2 of a power-of-2 (small tile dims), without a __ctzsi2 lib call on the M0+ (no CLZ/RBIT). +static inline int pow2_shift(unsigned v) { + int n = 0; + while (v > 1u) { + v >>= 1; + n++; + } + return n; +} + +// Thin wrappers over the shared int32 accumulator (dx1,dy1,dx2,dy2 are contiguous int32 at the +// struct tail). See picogame_dirty_* in __init__.c. +void picogame_tilemap_dirty_reset(picogame_tilemap_obj_t *tm) { + picogame_dirty_reset(&tm->dx1); +} + +void picogame_tilemap_dirty_union(picogame_tilemap_obj_t *tm, int x1, int y1, int x2, int y2) { + picogame_dirty_union(&tm->dx1, x1, y1, x2, y2); +} + +bool picogame_tilemap_take_dirty(picogame_tilemap_obj_t *tm, int *x1, int *y1, int *x2, int *y2) { + return picogame_dirty_take(&tm->dx1, x1, y1, x2, y2); +} + +void picogame_tilemap_extent(picogame_tilemap_obj_t *tm, int *x1, int *y1, int *x2, int *y2) { + int tw = tm->tileset ? tm->tileset->width : 0; + int th = tm->tileset ? tm->tileset->height : 0; + *x1 = tm->x; + *y1 = tm->y; + *x2 = tm->x + (int)tm->map_w * tw; + *y2 = tm->y + (int)tm->map_h * th; +} + +void picogame_blit_tilemap( + uint16_t *buf, int region_w, int strip_top, int strip_h, int x0, + picogame_tilemap_obj_t *tm, int ox, int oy) { + picogame_bitmap_obj_t *ts = tm->tileset; + if (ts == NULL) { + return; + } + int tw = ts->width; + int th = ts->height; + int nframes = ts->frames; + + // Tilemap origin in screen coords (scene position + view offset). + int tmx = tm->x + ox; + int tmy = tm->y + oy; + + // Region in screen coords. + int rx1 = x0, ry1 = strip_top; + int rx2 = x0 + region_w, ry2 = strip_top + strip_h; + + // Tile index range overlapping the region. Tile dims are almost always powers of two (8, 16), + // where floor division is an arithmetic shift (signed >> floors toward -inf, exactly what + // floordiv does) - which skips 4 idiv per strip on the tilemap background. Non-pow2 tiles fall + // back to floordiv. (shx>=0 signals the pow2 fast path; the ctz runs twice per call, not per tile.) + int shx = (tw & (tw - 1)) ? -1 : pow2_shift((unsigned)tw); + int shy = (th & (th - 1)) ? -1 : pow2_shift((unsigned)th); + int tx_lo = (shx >= 0) ? ((rx1 - tmx) >> shx) : floordiv(rx1 - tmx, tw); + int tx_hi = (shx >= 0) ? ((rx2 - 1 - tmx) >> shx) : floordiv(rx2 - 1 - tmx, tw); + int ty_lo = (shy >= 0) ? ((ry1 - tmy) >> shy) : floordiv(ry1 - tmy, th); + int ty_hi = (shy >= 0) ? ((ry2 - 1 - tmy) >> shy) : floordiv(ry2 - 1 - tmy, th); + tx_lo = picogame_imax(tx_lo, 0); + ty_lo = picogame_imax(ty_lo, 0); + tx_hi = picogame_imin(tx_hi, (int)tm->map_w - 1); + ty_hi = picogame_imin(ty_hi, (int)tm->map_h - 1); + + const uint8_t *map = tm->map; // hoist per-tile decode invariants out of the inner loop + const uint8_t *orient = tm->orient; + int map_w = (int)tm->map_w; + for (int ty = ty_lo; ty <= ty_hi; ty++) { + int dy = tmy + ty * th; + size_t row = (size_t)ty * map_w; + for (int tx = tx_lo; tx <= tx_hi; tx++) { + size_t cell = row + tx; + uint8_t idx = map[cell]; + if (idx >= nframes) { + continue; + } + uint8_t o = orient ? orient[cell] : 0; + int dx = tmx + tx * tw; + picogame_blit_bitmap(buf, region_w, strip_h, x0, strip_top, + ts, dx, dy, idx, (o & 1) != 0, (o & 2) != 0, (o & 4) != 0, NULL); + } + } +} diff --git a/shared-module/picogame/Tilemap.h b/shared-module/picogame/Tilemap.h new file mode 100644 index 00000000000..e06e0a626b1 --- /dev/null +++ b/shared-module/picogame/Tilemap.h @@ -0,0 +1,44 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT +// +// picogame Tilemap: a grid of tile indices into a tileset Bitmap (each tile = one +// frame of the bitmap). Rendered as a Scene layer; maintains an accumulated dirty +// rectangle (screen coords) so only changed tiles/areas are repainted. + +#pragma once + +#include +#include +#include "py/obj.h" +#include "shared-module/picogame/Bitmap.h" + +typedef struct { + mp_obj_base_t base; + picogame_bitmap_obj_t *tileset; // each frame is a tile (tileset->width x height) + mp_obj_t tileset_obj; // keep alive + uint8_t *map; // map_w*map_h tile indices + mp_obj_t map_obj; // keep alive + uint8_t *orient; // map_w*map_h orientation bits (bit0 flipX, bit1 flipY, + // bit2 transpose); NULL until a tile sets an orientation + mp_obj_t orient_obj; // keep alive (lazily allocated) + uint16_t map_w, map_h; + int32_t x, y; // pixel position of tile (0,0) (int32: big maps scroll past +-32767) + int32_t dx1, dy1, dx2, dy2; // accumulated dirty rect (scene coords; int32, see x/y); x1>=x2 => empty +} picogame_tilemap_obj_t; + +void picogame_tilemap_dirty_reset(picogame_tilemap_obj_t *tm); +void picogame_tilemap_dirty_union(picogame_tilemap_obj_t *tm, int x1, int y1, int x2, int y2); +// Returns true and fills the dirty rect if non-empty, then resets it. +bool picogame_tilemap_take_dirty(picogame_tilemap_obj_t *tm, int *x1, int *y1, int *x2, int *y2); + +// On-screen bounding box of the whole map. +void picogame_tilemap_extent(picogame_tilemap_obj_t *tm, int *x1, int *y1, int *x2, int *y2); + +// Blit the tiles intersecting the strip region into buf. (ox, oy) is the view +// offset added to the tilemap position (scene space -> screen space). +void picogame_blit_tilemap( + uint16_t *buf, int region_w, int strip_top, int strip_h, int x0, + picogame_tilemap_obj_t *tm, int ox, int oy); diff --git a/shared-module/picogame/__init__.c b/shared-module/picogame/__init__.c new file mode 100644 index 00000000000..282c4d0b64f --- /dev/null +++ b/shared-module/picogame/__init__.c @@ -0,0 +1,1254 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT +// +// picogame portable render core: strip-based, arbitrary-size blitting. +// Painter's order: clear strip to background, draw layers/sprites bottom-to-top. + +#include +#include +#include "py/runtime.h" +#include "shared-module/picogame/pg_compat.h" +#include "shared-module/picogame/__init__.h" +#include "shared-module/picogame/Tilemap.h" +#include "shared-module/picogame/Particles.h" +#include "shared-module/picogame/Canvas.h" +#include "shared-bindings/busdisplay/BusDisplay.h" +#include "shared-module/displayio/display_core.h" +#include "shared-bindings/displayio/__init__.h" + +// Shared dirty-rect accumulator over a contiguous int32 [x1,y1,x2,y2]. Canvas and Tilemap both end in +// `int32_t dx1,dy1,dx2,dy2`, so their public dirty fns are thin wrappers passing &self->dx1 here. +// Sentinels are the INT32 extremes (not int16) so a big-world scene coord past +-32767 px still +// accumulates correctly. +void picogame_dirty_reset(int32_t *r) { + r[0] = 0x7fffffff; + r[1] = 0x7fffffff; + r[2] = -0x7fffffff - 1; + r[3] = -0x7fffffff - 1; +} + +void picogame_dirty_union(int32_t *r, int x1, int y1, int x2, int y2) { + if (x1 < r[0]) { + r[0] = x1; + } + if (y1 < r[1]) { + r[1] = y1; + } + if (x2 > r[2]) { + r[2] = x2; + } + if (y2 > r[3]) { + r[3] = y2; + } +} + +bool picogame_dirty_take(int32_t *r, int *x1, int *y1, int *x2, int *y2) { + bool dirty = (r[0] < r[2]) && (r[1] < r[3]); + if (dirty) { + *x1 = r[0]; + *y1 = r[1]; + *x2 = r[2]; + *y2 = r[3]; + } + picogame_dirty_reset(r); + return dirty; +} + +// Halve each RGB565 channel of a wire-order pixel (50% darken, for shadow mode). +static inline uint16_t picogame_darken(uint16_t wire) { + uint16_t c = (uint16_t)((wire >> 8) | (wire << 8)); // wire -> native RGB565 + // Halve all three channels at once: >>1 shifts every channel right; the 0x7BEF mask clears the two + // bits that would bleed across channel boundaries (R's LSB into G's MSB, G's LSB into B's MSB) + + // R's now-0 top bit. Bit-identical to the per-channel r>>1/g>>1/b>>1 above, ~half the instructions. + uint16_t o = (uint16_t)((c >> 1) & 0x7BEF); + return (uint16_t)((o >> 8) | (o << 8)); // native -> wire +} + +// 4x4 ordered (Bayer) dither thresholds, 0..15. +static const uint8_t picogame_bayer4[4][4] = { + { 0, 8, 2, 10 }, + { 12, 4, 14, 6 }, + { 3, 11, 1, 9 }, + { 15, 7, 13, 5 }, +}; + +// Multiply two wire-order RGB565 pixels per channel (TINT: colour the source, keep its shading). +static inline uint16_t picogame_mul565(uint16_t a, uint16_t b) { + uint16_t ca = (uint16_t)((a >> 8) | (a << 8)); + uint16_t cb = (uint16_t)((b >> 8) | (b << 8)); + // /31 and /63 via reciprocal-multiply (bit-identical over the full product domain 0..961 / 0..3969): + // avoids a soft-divide per tinted pixel on M0+ (no HW divide); pure integer, fine on every MCU. + uint16_t r = (uint16_t)((((ca >> 11) & 0x1f) * ((cb >> 11) & 0x1f) * 529) >> 14); + uint16_t g = (uint16_t)((((ca >> 5) & 0x3f) * ((cb >> 5) & 0x3f) * 2081) >> 17); + uint16_t bl = (uint16_t)(((ca & 0x1f) * (cb & 0x1f) * 529) >> 14); + uint16_t o = (uint16_t)((r << 11) | (g << 5) | bl); + return (uint16_t)((o >> 8) | (o << 8)); +} + +// Write one opaque source pixel through the effect. (x, y) are screen coords (for DITHER). +static inline void picogame_fx_put(uint16_t *dst, uint16_t src, int x, int y, const picogame_fx_t *fx) { + if (fx == NULL) { + *dst = src; + return; + } + switch (fx->mode) { + case PICOGAME_FX_SHADOW: + *dst = picogame_darken(*dst); + break; + case PICOGAME_FX_FLASH: + *dst = fx->color; + break; + case PICOGAME_FX_TINT: + *dst = picogame_mul565(src, fx->color); // colour the sprite, keep its shading + break; + case PICOGAME_FX_DITHER: + if (picogame_bayer4[y & 3][x & 3] >= fx->level) { + *dst = src; // else: pixel skipped -> shows through + } + break; + default: + *dst = src; + break; + } +} + +// Fetch one source pixel from HOISTED scalars: the caller lifts format/data/palette/transparency +// out of the bitmap struct ONCE before its loop, so this does no per-pixel reload of bm fields (a +// `*dst` uint16_t store would otherwise force GCC to reload bm's uint16_t members every pixel). `idx` +// is the linear source offset (srow + sx). Returns false on the transparent key. Used by the scaled / +// affine / transpose paths; the unscaled fast path inlines the read directly. + +void picogame_blit_bitmap( + uint16_t *buf, int bw, int bh, int ox, int oy, + picogame_bitmap_obj_t *bm, int dx0, int dy0, int frame, bool fx, bool fy, + bool transpose, const picogame_fx_t *fxm) { + if (bm == NULL) { + return; + } + // Point fxm at a stack copy: its fields (uint16_t color) can't then alias the *dst stores, so the + // effect params stay in registers across the pixel loop instead of reloading every pixel. + picogame_fx_t fxl; + if (fxm != NULL) { + fxl = *fxm; + fxm = &fxl; + } + int sw = bm->width; + int sh = bm->height; + + // Guard the frame index: sprite.frame is a free uint8_t, so an out-of-range + // value (bad wrap / overflow in game code) would read past the sheet data. + // Wrap into [0, frames) - cheap and animation-friendly. + if (bm->frames > 1) { + if (frame >= bm->frames) { // common case is in range: pay a compare, not a divide + frame %= bm->frames; + } + } else { + frame = 0; + } + + int frame_col0 = frame * sw; + int stride0 = bm->stride; + + // Transpose path: swap source x/y (a cheap 90deg rotate when combined with flips -> all 8 + // orientations, no cos/sin/affine). The drawn footprint swaps to sh x sw. Per-pixel sampling + // (no per-row srow precompute), used only when requested; flips + fx still apply. + if (transpose) { + int dw = sh, dh = sw; // footprint swaps + int xs = picogame_imax(dx0, ox), ys = picogame_imax(dy0, oy); + int xe = picogame_imin(dx0 + dw, ox + bw), ye = picogame_imin(dy0 + dh, oy + bh); + if (xs >= xe || ys >= ye) { + return; + } + int t_fmt = bm->format; // hoist bm fields once (see src_pixel_s) + const uint8_t *t_data = bm->data; + const uint16_t *t_pal = bm->palette; + bool t_transp = bm->has_transparent; + uint16_t t_key = bm->transparent; + for (int y = ys; y < ye; y++) { + int ly = y - dy0; // -> source X (0..sw-1) + int su = fx ? sw - 1 - ly : ly; // per-row: source column is constant across the row + uint16_t *dst = buf + (y - oy) * bw + (xs - ox); + int lx = xs - dx0; + int sv = fy ? sh - 1 - lx : lx; // source row: step it, no per-pixel ternary + int svstep = fy ? -1 : 1; + for (int x = xs; x < xe; x++) { + uint16_t val; + if (src_pixel_s(t_fmt, t_data, t_pal, t_transp, t_key, + sv * stride0 + frame_col0 + su, &val)) { + picogame_fx_put(dst, val, x, y, fxm); + } + dst++; + sv += svstep; + } + } + return; + } + + int x_start = picogame_imax(dx0, ox); + int y_start = picogame_imax(dy0, oy); + int x_end = picogame_imin(dx0 + sw, ox + bw); + int y_end = picogame_imin(dy0 + sh, oy + bh); + if (x_start >= x_end || y_start >= y_end) { + return; + } + + int frame_col = frame * sw; + int stride = bm->stride; + bool transp = bm->has_transparent; + + if (bm->format == PICOGAME_FMT_PAL8) { + const uint8_t *data = bm->data; + const uint16_t *pal = bm->palette; + uint8_t key = (uint8_t)bm->transparent; + // Contract: PAL8 indices MUST be < palette length (caller's responsibility). An out-of-range + // index is undefined behaviour: it reads past the palette - usually a garbage colour, but on + // some heap layouts / platforms (e.g. ESP32-S3 heap_caps regions) it CAN fault. We deliberately + // do NOT clamp per pixel: that cost ~3 cyc/px (cmp+sbcs+ands) here, ~8% on blit-bound frames. + // + // TO RESTORE FULL BOUNDS-SAFETY (at that cost) reinstate the clamp - add `unsigned pe = + // bm->pal_entries;` here and `if (idx >= pe) { idx = 0; }` after each `idx = data[...]` in BOTH + // loops below, and the matching guard in src_pixel() (search "blit contract"). + for (int y = y_start; y < y_end; y++) { + int sy = y - dy0; + if (fy) { + sy = sh - 1 - sy; + } + int srow = sy * stride + frame_col; + uint16_t *dst = buf + (y - oy) * bw + (x_start - ox); + int sx = x_start - dx0, xstep = 1; // hoist flip_x: walk sx +/-1, no per-pixel test + if (fx) { + sx = sw - 1 - sx; + xstep = -1; + } + if (fxm == NULL) { // plain copy (most sprites): no per-pixel fx branch/call + #pragma GCC unroll 4 // hot path: unrolling the plain sprite blit is ~6% faster on M0+ (measured), +0.6KB + for (int x = x_start; x < x_end; x++) { + uint8_t idx = data[srow + sx]; + if (!transp || idx != key) { + *dst = pal[idx]; + } + dst++; + sx += xstep; + } + } else { + for (int x = x_start; x < x_end; x++) { + uint8_t idx = data[srow + sx]; + if (!transp || idx != key) { + picogame_fx_put(dst, pal[idx], x, y, fxm); + } + dst++; + sx += xstep; + } + } + } + } else { // PICOGAME_FMT_RGB565 + // bm->data is a GC-allocated Python buffer (>=4-byte aligned), so the 16-bit + // view is safe; xtensa's -Wcast-align can't see that, so silence it here. + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wcast-align" + const uint16_t *data = (const uint16_t *)bm->data; + #pragma GCC diagnostic pop + uint16_t key = bm->transparent; + for (int y = y_start; y < y_end; y++) { + int sy = y - dy0; + if (fy) { + sy = sh - 1 - sy; + } + int srow = sy * stride + frame_col; + uint16_t *dst = buf + (y - oy) * bw + (x_start - ox); + int sx = x_start - dx0, xstep = 1; // hoist flip_x: walk sx +/-1, no per-pixel test + if (fx) { + sx = sw - 1 - sx; + xstep = -1; + } + if (fxm == NULL) { // plain copy (most sprites): no per-pixel fx branch/call + if (!transp && !fx) { // opaque + not x-flipped: the row is contiguous in + // both src and dst -> one memcpy (dst may be 2-byte aligned; memcpy handles that). + memcpy(dst, &data[srow + sx], (size_t)(x_end - x_start) * 2u); + continue; + } + #pragma GCC unroll 4 // hot path: unrolling the plain sprite blit is ~6% faster on M0+ (measured), +0.6KB + for (int x = x_start; x < x_end; x++) { + uint16_t v = data[srow + sx]; + if (!transp || v != key) { + *dst = v; + } + dst++; + sx += xstep; + } + } else { + for (int x = x_start; x < x_end; x++) { + uint16_t v = data[srow + sx]; + if (!transp || v != key) { + picogame_fx_put(dst, v, x, y, fxm); + } + dst++; + sx += xstep; + } + } + } + } +} + + +void picogame_blit_bitmap_scaled( + uint16_t *buf, int bw, int bh, int ox, int oy, + picogame_bitmap_obj_t *bm, int dx0, int dy0, int frame, bool fx, bool fy, + uint16_t scale, const picogame_fx_t *fxm) { + if (bm == NULL || scale == 0) { + return; + } + picogame_fx_t fxl; // stack copy: fields don't alias *dst (see blit_bitmap) + if (fxm != NULL) { + fxl = *fxm; + fxm = &fxl; + } + int sw = bm->width, sh = bm->height; + if (bm->frames > 1) { + if (frame >= bm->frames) { // common case is in range: pay a compare, not a divide + frame %= bm->frames; + } + } else { + frame = 0; + } + int dw = (sw * scale) >> 8, dh = (sh * scale) >> 8; + if (dw <= 0 || dh <= 0) { + return; + } + int x_start = picogame_imax(dx0, ox), y_start = picogame_imax(dy0, oy); + int x_end = picogame_imin(dx0 + dw, ox + bw), y_end = picogame_imin(dy0 + dh, oy + bh); + if (x_start >= x_end || y_start >= y_end) { + return; + } + int frame_col = frame * sw, stride = bm->stride; + int s_fmt = bm->format; // hoist bm fields once (see src_pixel_s) + const uint8_t *s_data = bm->data; + const uint16_t *s_pal = bm->palette; + bool s_transp = bm->has_transparent; + uint16_t s_key = bm->transparent; + if (scale == 512 && !fx && !fy && fxm == NULL && !s_transp && s_fmt == PICOGAME_FMT_RGB565 + && (((uintptr_t)s_data & 1) == 0)) { + // 2x integer upscale fast path - the half-res-canvas genre's per-frame blit (a full-screen + // RGB565 bitmap shown through a scale-2 sprite). At scale 512 the DDA step is exactly 2^15, + // so sampling collapses to (rel >> 1): write each source pixel twice, and when two dest rows + // share a source row, memcpy the second from the first (half the reads). Byte-exact vs the + // generic loop (host-verified over 300k random windows/clips/parities). + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wcast-align" + const uint16_t *sd16 = (const uint16_t *)s_data; + #pragma GCC diagnostic pop + int prev_sy = -1; + uint16_t *prev_row = NULL; + int nwin = x_end - x_start; + for (int y = y_start; y < y_end; y++) { + int sy = (y - dy0) >> 1; + uint16_t *drow = buf + (y - oy) * bw + (x_start - ox); + if (sy == prev_sy && prev_row != NULL) { + memcpy(drow, prev_row, (size_t)nwin * 2); + } else { + const uint16_t *srow = sd16 + sy * stride + frame_col; + int rel = x_start - dx0; + uint16_t *d = drow; + int n = nwin; + if (rel & 1) { // leading odd dest column + *d++ = srow[rel >> 1]; + rel++; + n--; + } + const uint16_t *sp = srow + (rel >> 1); + while (n >= 2) { + uint16_t v = *sp++; + d[0] = v; + d[1] = v; + d += 2; + n -= 2; + } + if (n) { + *d = *sp; + } + } + prev_sy = sy; + prev_row = drow; + } + return; + } + uint32_t step = ((uint32_t)1 << 24) / scale; // source px per dest px, 16.16 + // No per-row sy>=sh / per-pixel sx>=sw clamp: with dw=(sd*scale)>>8 and step=floor(2^24/scale), + // the sampled index ((dw-1)*step)>>16 provably never reaches the source dimension (exhaustively + // verified over the ENTIRE uint16 x uint16 (scale, dim) domain, 0 violations), and xacc> 16); + if (fy) { + sy = sh - 1 - sy; + } + int srow = sy * stride + frame_col; + uint16_t *drow = buf + (y - oy) * bw; + uint32_t xacc = (uint32_t)(x_start - dx0) * step; + for (int x = x_start; x < x_end; x++) { + int sx = (int)(xacc >> 16); + xacc += step; + if (fx) { + sx = sw - 1 - sx; + } + uint16_t val; + if (src_pixel_s(s_fmt, s_data, s_pal, s_transp, s_key, srow + sx, &val)) { + picogame_fx_put(&drow[x - ox], val, x, y, fxm); + } + } + } +} + +// Quarter-wave Q15 sine table (0..90 deg) -> fixed-point trig for the rotation setup, so the +// affine path needs no float `sinf`/`cosf` (RP2040 has no FPU). cos(d) = sin(d+90). +static const int16_t pg_sin_q15_quad[91] = { + 0, 572, 1144, 1715, 2286, 2856, 3425, 3993, 4560, 5126, + 5690, 6252, 6813, 7371, 7927, 8481, 9032, 9580, 10126, 10668, + 11207, 11743, 12275, 12803, 13328, 13848, 14364, 14876, 15383, 15886, + 16383, 16876, 17364, 17846, 18323, 18794, 19260, 19720, 20173, 20621, + 21062, 21497, 21925, 22347, 22762, 23170, 23571, 23964, 24351, 24730, + 25101, 25465, 25821, 26169, 26509, 26841, 27165, 27481, 27788, 28087, + 28377, 28659, 28932, 29196, 29451, 29697, 29934, 30162, 30381, 30591, + 30791, 30982, 31163, 31335, 31498, 31650, 31794, 31927, 32051, 32165, + 32269, 32364, 32448, 32523, 32587, 32642, 32687, 32722, 32747, 32762, + 32767, +}; +static int32_t pg_sin_q15(int deg) { + deg %= 360; + if (deg < 0) { + deg += 360; + } + if (deg <= 90) { + return pg_sin_q15_quad[deg]; + } + if (deg <= 180) { + return pg_sin_q15_quad[180 - deg]; + } + if (deg <= 270) { + return -pg_sin_q15_quad[deg - 180]; + } + return -pg_sin_q15_quad[360 - deg]; +} +static int32_t pg_cos_q15(int deg) { + return pg_sin_q15(deg + 90); +} + +// sin of a Q16 degree angle: lerp between whole-degree LUT entries. The racing-road curvature is +// DOUBLE-integrated over ~170 rows, which amplifies whole-degree quantization into visible pixels +// (host-measured 9 px); one lerp per curvature eval brings the road within 1 px of the float original. +static int32_t pg_sin_q15_lerp(int64_t deg_q16) { + int d0 = (int)(deg_q16 >> 16); + int32_t frac = (int32_t)(deg_q16 & 0xFFFF); + int32_t a = pg_sin_q15(d0); + return a + (int32_t)(((int64_t)(pg_sin_q15(d0 + 1) - a) * frac) >> 16); +} + +// One racing-road frame's curve pass: the bottom-up curvature accumulator + per-row integer edges +// (the OutRun-genre "compute_road" loop - profiled at ~8-10 ms of Python on picobike; this is the +// batch-boundary rule in action: one C call does the frame's whole row loop). Fixed-point throughout: +// cx/ddx accumulate in Q16, curvature = two LUT sines of the world distance. cfg (int32[7]): +// [f1_q20, f2_q20, amp1k_q16, amp2k_q16, world_step, curve_step, d_row_off] - frequencies in +// Q20 degrees/world-unit (Q16 phase-drifts over a long run), amplitudes premultiplied by the +// per-row gain k, curvature re-evaluated every curve_step rows (it varies slowly). Edge stores use +// trunc-toward-zero to match the Python original's int(). Host-proven <=1 px absolute AND row-delta +// smoothness vs the float reference over 8k+ frames (road_edges_test.c). +void picogame_road_edges(int16_t *rl, int16_t *rr, const int32_t *hw_q16, int n, + int32_t cx_q16, int32_t dist, const int32_t *cfg) { + int32_t f1 = cfg[0], f2 = cfg[1], a1k = cfg[2], a2k = cfg[3]; + int32_t wstep = cfg[4], cstep = cfg[5], drow = cfg[6]; + int32_t cx = cx_q16, ddx = 0, ck = 0; + int cnt = 0; + for (int i = n - 1; i >= 0; i--) { + if (cnt == 0) { + int32_t d = dist + (drow - i) * wstep; + ck = (int32_t)(((int64_t)pg_sin_q15_lerp(((int64_t)d * f1) >> 4) * a1k) >> 15) + + (int32_t)(((int64_t)pg_sin_q15_lerp(((int64_t)d * f2) >> 4) * a2k) >> 15); + cnt = cstep; + } + cnt--; + ddx += ck; + cx += ddx; + int32_t vl = cx - hw_q16[i], vr = cx + hw_q16[i]; + rl[i] = (int16_t)(vl >= 0 ? (vl >> 16) : -((-vl) >> 16)); + rr[i] = (int16_t)(vr >= 0 ? (vr >> 16) : -((-vr) >> 16)); + } +} + + +// Forward-transform a w*h rect's 4 corners through (integer pivot, 8.8 scale, Q15 rotation) and +// return the screen-space AABB. INTEGER (Q16) - measured ~40x faster than the old float version on +// the M0+ flagship (soft-float), and faster on every target (measured affine_q16 vs affine_float). +// Runs once per rotated sprite (not per pixel). Each corner is FLOORED (arithmetic >>23 = /(256*32768)), +// so the returned box is <= the true min and the callers' +1/+2 keep the box CONTAINING the sprite +// (the affine blitter clips per pixel anyway, so a >=1px-too-large box only repaints, never clips). +static void corners_bbox(int sw, int sh, int px, int py, int pivx, int pivy, + int32_t scale, int32_t cos_q, int32_t sin_q, int *minx, int *miny, int *maxx, int *maxy) { + int nx = 1 << 30, xx = -(1 << 30), ny = 1 << 30, xy = -(1 << 30); + int cxs[4] = { 0, sw, 0, sw }, cys[4] = { 0, 0, sh, sh }; + for (int k = 0; k < 4; k++) { + int64_t du = (int64_t)(cxs[k] - pivx) * scale; // (corner - pivot) in pixels<<8 + int64_t dv = (int64_t)(cys[k] - pivy) * scale; + int X = px + (int)((du * cos_q - dv * sin_q) >> 23); // >>23 = /(256 * 32768): drop the 8.8 + Q15 + int Y = py + (int)((du * sin_q + dv * cos_q) >> 23); + if (X < nx) { + nx = X; + } + if (X > xx) { + xx = X; + } + if (Y < ny) { + ny = Y; + } + if (Y > xy) { + xy = Y; + } + } + *minx = nx; + *miny = ny; + *maxx = xx; + *maxy = xy; +} + +// Fill the sprite's affine cache (position-relative bbox + 16.16 inverse-map steps) if stale. +// The trig LUT, the 4-corner bbox and the TWO SOFTWARE DIVIDES below used to re-run once per +// strip a rotated sprite touched (~6x/frame at strip_h=8) plus once for the dirty-rect AABB; +// now once per angle/scale/bitmap/anchor change (those setters clear xf_valid). +static void sprite_xform_fill(picogame_sprite_obj_t *s) { + if (s->xf_valid) { + return; + } + int w = (s->bitmap != NULL) ? s->bitmap->width : 0; + int h = (s->bitmap != NULL) ? s->bitmap->height : 0; + int pivx = ((int)s->anchor_x * w) >> 8, pivy = ((int)s->anchor_y * h) >> 8; + int32_t cos_q = pg_cos_q15(s->angle), sin_q = pg_sin_q15(s->angle); // Q15 LUT trig + int minx, miny, maxx, maxy; + corners_bbox(w, h, 0, 0, pivx, pivy, (int32_t)s->scale, cos_q, sin_q, + &minx, &miny, &maxx, &maxy); + // Saturate into the int16 cache fields: an extreme scale x size (public scale allows + // ~256x) could exceed +-32767; a saturated bbox only over/under-covers the clip - the + // blitter clips per strip anyway - instead of wrapping into a wrong-sign rect. + s->xf_minx = (int16_t)(minx < -32768 ? -32768 : (minx > 32767 ? 32767 : minx)); + s->xf_miny = (int16_t)(miny < -32768 ? -32768 : (miny > 32767 ? 32767 : miny)); + s->xf_maxx = (int16_t)(maxx < -32768 ? -32768 : (maxx > 32767 ? 32767 : maxx)); + s->xf_maxy = (int16_t)(maxy < -32768 ? -32768 : (maxy > 32767 ? 32767 : maxy)); + // inverse map (16.16 fixed-point): u = pivx + (ic*(X-px) + is*(Y-py)); + // v = pivy + (-is*(X-px) + ic*(Y-py)) + // ic = (cs/sf)*65536 = cos_q15 * 512 / scale - computed in pure integer (no float). + int32_t nic = cos_q * 512, nis = sin_q * 512; + int sc = (int)s->scale; + if (sc < 1) { + sc = 1; // scale is setter-clamped >= 1; belt for a zeroed struct + } + s->xf_ic = (nic >= 0 ? nic + sc / 2 : nic - sc / 2) / sc; + s->xf_is = (nis >= 0 ? nis + sc / 2 : nis - sc / 2) / sc; + s->xf_valid = 1; +} + +void picogame_blit_bitmap_affine( + uint16_t *buf, int bw, int bh, int ox, int oy, + picogame_bitmap_obj_t *bm, int px, int py, int pivx, int pivy, + int frame, bool fx, bool fy, + int minx, int miny, int maxx, int maxy, int32_t ic, int32_t is, + const picogame_fx_t *fxm) { + if (bm == NULL) { + return; + } + picogame_fx_t fxl; // stack copy: fields don't alias *dst (see blit_bitmap) + if (fxm != NULL) { + fxl = *fxm; + fxm = &fxl; + } + int sw = bm->width, sh = bm->height; + if (bm->frames > 1) { + if (frame >= bm->frames) { // common case is in range: pay a compare, not a divide + frame %= bm->frames; + } + } else { + frame = 0; + } + int frame_col = frame * sw, stride = bm->stride; + int a_fmt = bm->format; // hoist bm fields once (see src_pixel_s) + const uint8_t *a_data = bm->data; + const uint16_t *a_pal = bm->palette; + bool a_transp = bm->has_transparent; + uint16_t a_key = bm->transparent; + int x_start = picogame_imax(minx, ox), y_start = picogame_imax(miny, oy); + int x_end = picogame_imin(maxx + 1, ox + bw), y_end = picogame_imin(maxy + 1, oy + bh); + if (x_start >= x_end || y_start >= y_end) { + return; + } + // Fold flip_x/flip_y into the inverse map ONCE, so the inner loop samples the FINAL source coord + // directly - no per-pixel `sw-1-iu`/`sh-1-iv` and no fx/fy branch (one loop variant, fewer live + // values -> fewer register spills). Exact: mirroring u is u'=(sw-1)-u; through the 16.16 floor that + // is `((sw-1-pivx)<<16 + 0xFFFF) - (ic*dxf + is*dyf)`, i.e. negate the u steps + bias the pivot by + // 0xFFFF (so floor(u') == (sw-1) - floor(u) for every in-range pixel). Bit-identical output. + int32_t uxc = ic, uyc = is, upiv = (int32_t)pivx << 16; // u = upiv + uxc*dxf + uyc*dyf + int32_t vxc = -is, vyc = ic, vpiv = (int32_t)pivy << 16; // v = vpiv + vxc*dxf + vyc*dyf + if (fx) { + uxc = -ic; + uyc = -is; + upiv = ((int32_t)(sw - 1 - pivx) << 16) + 0xFFFF; + } + if (fy) { + vxc = is; + vyc = -ic; + vpiv = ((int32_t)(sh - 1 - pivy) << 16) + 0xFFFF; + } + // 32-bit 16.16 accumulators (cheaper than 64-bit on the M0+). Peak magnitude stays well within + // int32 for any sane sprite on a handheld screen. The bounds check keeps an out-of-range sample + // memory-safe (at worst a skipped pixel), never a crash. + for (int y = y_start; y < y_end; y++) { + int dyf = y - py; + int dxf = x_start - px; + int32_t uacc = upiv + uxc * dxf + uyc * dyf; + int32_t vacc = vpiv + vxc * dxf + vyc * dyf; + uint16_t *drow = buf + (y - oy) * bw; + for (int x = x_start; x < x_end; x++) { + int su = uacc >> 16, sv = vacc >> 16; // already the flipped source coords + uacc += uxc; + vacc += vxc; + if (su >= 0 && su < sw && sv >= 0 && sv < sh) { + uint16_t val; + if (src_pixel_s(a_fmt, a_data, a_pal, a_transp, a_key, + sv * stride + frame_col + su, &val)) { + picogame_fx_put(&drow[x - ox], val, x, y, fxm); + } + } + } + } +} + +void picogame_sprite_aabb(const picogame_sprite_obj_t *s, int *x1, int *y1, int *x2, int *y2) { + int w = (s->bitmap != NULL) ? s->bitmap->width : 0; + int h = (s->bitmap != NULL) ? s->bitmap->height : 0; + if (s->angle == 0) { + int sw = (w * s->scale) >> 8, sh = (h * s->scale) >> 8; + if ((s->flags & PICOGAME_SPR_TRANSPOSE) && s->scale == 256) { // transpose only on the fast + int t = sw; // path (scale==256); scaled blitter ignores it, so + sw = sh; // swapping here for scale!=256 would mistrack -> trail + sh = t; + } + int tx = (s->x >> 8) - ((int)s->anchor_x * sw >> 8); + int ty = (s->y >> 8) - ((int)s->anchor_y * sh >> 8); + *x1 = tx; + *y1 = ty; + *x2 = tx + sw; + *y2 = ty + sh; + return; + } + // cache-fill mutates only the derived xf_* fields - logically const for callers + sprite_xform_fill((picogame_sprite_obj_t *)s); + int px = s->x >> 8, py = s->y >> 8; + *x1 = px + s->xf_minx - 1; + *y1 = py + s->xf_miny - 1; + *x2 = px + s->xf_maxx + 2; + *y2 = py + s->xf_maxy + 2; +} + +// TINT on a PAL8 sprite is baked into a stack palette (see blit_sprite) when the palette fits this +// cap, else it falls back to the per-pixel tint. 64 entries = 128 B of transient stack. +#define PICOGAME_TINT_PAL_CAP 64 + +// clip_x/clip_y = the strip buffer's screen origin; vx/vy = view offset added to +// the sprite's scene position to get its screen position. +static void blit_sprite(uint16_t *buf, int bw, int bh, int clip_x, int clip_y, + picogame_sprite_obj_t *spr, int vx, int vy) { + bool fx = (spr->flags & PICOGAME_SPR_FLIP_X) != 0; + bool fy = (spr->flags & PICOGAME_SPR_FLIP_Y) != 0; + bool tr = (spr->flags & PICOGAME_SPR_TRANSPOSE) != 0; // 90deg transpose (fast path only) + // One effect at a time (dither > flash > tint > shadow priority); NULL = no effect (fast path). + picogame_fx_t fxm = { PICOGAME_FX_NONE, 0, 0 }; + if (spr->flags & PICOGAME_SPR_DITHER) { + fxm.mode = PICOGAME_FX_DITHER; + fxm.level = spr->dither; + } else if (spr->flags & PICOGAME_SPR_FLASH) { + fxm.mode = PICOGAME_FX_FLASH; + fxm.color = spr->flash_color; + } else if (spr->flags & PICOGAME_SPR_TINT) { + fxm.mode = PICOGAME_FX_TINT; + fxm.color = spr->flash_color; // shared colour field (flash/tint exclusive) + } else if (spr->flags & PICOGAME_SPR_SHADOW) { + fxm.mode = PICOGAME_FX_SHADOW; + } + const picogame_fx_t *fxp = (fxm.mode == PICOGAME_FX_NONE) ? NULL : &fxm; + // TINT of a PAL8 sprite is a pure function of the palette index -> bake it into a stack palette + // ONCE and blit plain, instead of picogame_mul565 per pixel (~4x on tinted PAL8 sprites; the + // dominant sprite format). Small palettes only; larger ones keep the per-pixel tint. Transparency + // is keyed on the INDEX before the palette lookup, so the (unused) tinted key entry is harmless. + picogame_bitmap_obj_t *bmuse = spr->bitmap; + picogame_bitmap_obj_t bmtint; + uint16_t tpal[PICOGAME_TINT_PAL_CAP]; + if (fxp != NULL && fxm.mode == PICOGAME_FX_TINT && bmuse != NULL && + bmuse->format == PICOGAME_FMT_PAL8 && bmuse->pal_entries <= PICOGAME_TINT_PAL_CAP) { + for (int i = 0; i < bmuse->pal_entries; i++) { + tpal[i] = picogame_mul565(bmuse->palette[i], fxm.color); + } + bmtint = *bmuse; // shallow copy; override only the palette + bmtint.palette = tpal; + bmuse = &bmtint; + fxp = NULL; // tint now baked in -> plain (fast) blit + } + if (spr->angle == 0 && spr->scale == 256) { + int tx, ty; + picogame_sprite_topleft(spr, &tx, &ty); + picogame_blit_bitmap(buf, bw, bh, clip_x, clip_y, bmuse, + tx + vx, ty + vy, spr->frame, fx, fy, tr, fxp); + } else if (spr->angle == 0) { + int tx, ty; + picogame_sprite_topleft(spr, &tx, &ty); + picogame_blit_bitmap_scaled(buf, bw, bh, clip_x, clip_y, bmuse, + tx + vx, ty + vy, spr->frame, fx, fy, spr->scale, fxp); + } else { + sprite_xform_fill(spr); // once per angle/scale change, not per strip + int w = (spr->bitmap != NULL) ? spr->bitmap->width : 0; + int h = (spr->bitmap != NULL) ? spr->bitmap->height : 0; + int pivx = ((int)spr->anchor_x * w) >> 8, pivy = ((int)spr->anchor_y * h) >> 8; + int px = (spr->x >> 8) + vx, py = (spr->y >> 8) + vy; + picogame_blit_bitmap_affine(buf, bw, bh, clip_x, clip_y, bmuse, + px, py, pivx, pivy, spr->frame, fx, fy, + px + spr->xf_minx, py + spr->xf_miny, px + spr->xf_maxx, py + spr->xf_maxy, + spr->xf_ic, spr->xf_is, fxp); + } +} + +mp_obj_t picogame_blit_strip_layers( + uint16_t *buf, int region_w, int strip_top, int strip_h, int x0, + mp_obj_t *items, uint8_t *kinds, size_t n, uint16_t background, int ox, int oy) { + mp_obj_t pending = MP_OBJ_NULL; // a BaseException latched from a StripDraw callback + int npix = region_w * strip_h; + if (background == 0) { + memset(buf, 0, (size_t)npix * 2); // common case (black/clear) -> fast bulk clear + } else { + // word-fill: two packed pixels per uint32 (half the stores). CAUTION: this composites into + // BOTH a GC strip buffer (SPI path, >=4-byte aligned) AND, on framebuffer targets, a raw row + // pointer fb + sy*stride + x0 that starts at an ODD pixel when x0 is odd -> only 2-byte + // aligned. GCC lowers the fill to STRD/STM, which raise an unaligned UsageFault on Cortex-M + // even with CCR.UNALIGN_TRP clear (STRD/STM always require word alignment). So peel one + // leading pixel to reach 4-byte alignment (mirrors picogame_fb_to_native), bulk word-fill, + // then an odd trailing pixel. + uint32_t w = (uint32_t)background | ((uint32_t)background << 16); + int i = 0; + if (npix > 0 && ((uintptr_t)buf & 2u) != 0) { + buf[0] = background; + i = 1; + } + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wcast-align" + uint32_t *w32 = (uint32_t *)(buf + i); // now 4-byte aligned + #pragma GCC diagnostic pop + int nw = (npix - i) >> 1; + for (int k = 0; k < nw; k++) { + w32[k] = w; + } + i += nw << 1; + if (i < npix) { // odd trailing pixel + buf[i] = background; + } + } + for (size_t i = 0; i < n; i++) { + uint8_t raw = (kinds != NULL) ? kinds[i] : PICOGAME_KIND_SPRITE; + uint8_t kind = raw & PICOGAME_KIND_MASK; + // Fixed (HUD) items are drawn in screen space -> no view offset. + int iox = (raw & PICOGAME_KIND_FIXED) ? 0 : ox; + int ioy = (raw & PICOGAME_KIND_FIXED) ? 0 : oy; + if (kind == PICOGAME_KIND_TILEMAP) { + picogame_blit_tilemap(buf, region_w, strip_top, strip_h, x0, + MP_OBJ_TO_PTR(items[i]), iox, ioy); + } else if (kind == PICOGAME_KIND_PARTICLES) { + picogame_blit_particles(buf, region_w, strip_top, strip_h, x0, + MP_OBJ_TO_PTR(items[i]), iox, ioy); + } else if (kind == PICOGAME_KIND_CANVAS) { + picogame_blit_canvas(buf, region_w, strip_top, strip_h, x0, + MP_OBJ_TO_PTR(items[i]), iox, ioy); + } else if (kind == PICOGAME_KIND_STRIPDRAW) { + // Immediate mode: hand the callback a view of the part of THIS strip that + // overlaps the layer's rect (so it can only paint within its rect - a + // full-view fill stays inside it). Skip strips the rect doesn't touch. + picogame_stripdraw_obj_t *sd = MP_OBJ_TO_PTR(items[i]); + int ry0 = sd->y, ry1 = sd->y + sd->h; + int s0 = strip_top > ry0 ? strip_top : ry0; // first screen row to draw + int st_end = strip_top + strip_h; + int s1 = st_end < ry1 ? st_end : ry1; // one past last screen row + if (s0 >= s1) { + continue; // strip is outside the layer + } + picogame_canvas_obj_t *v = MP_OBJ_TO_PTR(sd->view); + v->data = buf + (s0 - strip_top) * region_w; // view row 0 == screen row s0 + v->w = region_w; + v->h = s1 - s0; + v->x = 0; + v->y = 0; + v->has_transparent = false; + mp_obj_t cbargs[5] = { + sd->view, + MP_OBJ_NEW_SMALL_INT(x0), + MP_OBJ_NEW_SMALL_INT(s0), + MP_OBJ_NEW_SMALL_INT(region_w), + MP_OBJ_NEW_SMALL_INT(s1 - s0), + }; + // We're inside an open display bus transaction here, so a raised + // exception must NOT unwind past it (that would wedge the bus / leave a + // DMA running). Catch it, keep the transaction intact, and latch so the + // traceback prints once instead of every strip every frame. + nlr_buf_t nlr; + if (nlr_push(&nlr) == 0) { + mp_call_function_n_kw(sd->callback, 5, 0, cbargs); + nlr_pop(); + } else { + mp_obj_t exc = MP_OBJ_FROM_PTR(nlr.ret_val); + if (!mp_obj_is_subclass_fast(MP_OBJ_FROM_PTR(mp_obj_get_type(exc)), + MP_OBJ_FROM_PTR(&mp_type_Exception))) { + // A BaseException (KeyboardInterrupt / ReloadException / SystemExit) must reach the + // supervisor - latch it and stop; the caller re-raises it once the display + // transaction has safely closed, so Ctrl-C and USB auto-reload actually work. + pending = exc; + break; + } + if (!sd->faulted) { + sd->faulted = true; + mp_obj_print_exception(&mp_plat_print, exc); + } + } + } else if (kind == PICOGAME_KIND_TRIANGLES) { + // Retained screen-space triangle batch: pure C per strip (no Python callback, + // so this path needs no Python re-entry mid-compose). Cheap band + // reject vs THIS strip, then the Canvas rasteriser through a stack view over + // the strip buffer. Screen-space by design: the view offset is not applied. + picogame_triangles_obj_t *t = MP_OBJ_TO_PTR(items[i]); + picogame_canvas_obj_t v; + v.data = buf; + v.data_obj = MP_OBJ_NULL; + v.w = region_w; + v.h = strip_h; + v.x = 0; + v.y = 0; + v.transparent = 0; + v.has_transparent = false; + picogame_canvas_dirty_reset(&v); + int xo = -x0; + int yo = -strip_top; + picogame_fill_triangle_batch(&v, t->verts, t->colors, t->count, xo, yo); + } else { + picogame_sprite_obj_t *spr = MP_OBJ_TO_PTR(items[i]); + if (!(spr->flags & PICOGAME_SPR_VISIBLE)) { + continue; + } + blit_sprite(buf, region_w, strip_h, x0, strip_top, spr, iox, ioy); + } + } + return pending; +} + +bool picogame_strip_begin( + picogame_output_t *display, + int *x0p, int *y0p, int *x1p, int *y1p, size_t buffer_pixels, + int *region_w, int *strip_h) { + // Clamp the window to the panel (post-rotation w/h): an out-of-range region makes the controller + // wrap/garble rows. Callers usually pass clamped dirty rects, but StripDraw / direct render_region + // can hand us a rect that runs off the panel. Clamp AND write back through the pointers so the + // caller's strip loop + blit origin use the SAME clamped bounds (else only the GRAM window is + // clamped while the data loop still pushes off-panel rows -> the wrap/garble we're preventing). + int pw = display->core.width, ph = display->core.height; + int x0 = *x0p, y0 = *y0p, x1 = *x1p, y1 = *y1p; + if (x0 < 0) { + x0 = 0; + } + if (y0 < 0) { + y0 = 0; + } + if (x1 > pw) { + x1 = pw; + } + if (y1 > ph) { + y1 = ph; + } + *x0p = x0; + *y0p = y0; + *x1p = x1; + *y1p = y1; + int rw = x1 - x0; + int rh = y1 - y0; + if (rw <= 0 || rh <= 0) { + return false; + } + int sh = (int)(buffer_pixels / (size_t)rw); + if (sh < 1) { + mp_raise_ValueError(MP_ERROR_TEXT("buffer too small")); + } + if (sh > rh) { + sh = rh; + } + + displayio_area_t area; + area.x1 = x0; + area.y1 = y0; + area.x2 = x1; + area.y2 = y1; + area.next = NULL; + displayio_display_bus_set_region_to_update(&display->bus, &display->core, &area); + + while (!displayio_display_bus_begin_transaction(&display->bus)) { + RUN_BACKGROUND_TASKS; + } + display->bus.send(display->bus.bus, DISPLAY_COMMAND, + CHIP_SELECT_TOGGLE_EVERY_BYTE, &display->write_ram_command, 1); + + *region_w = rw; + *strip_h = sh; + return true; +} + +// --- output transport seam (busdisplay backend): the ONLY per-strip display ops the generic +// picogame_render_region orchestrator below touches, so a non-CircuitPython port (MicroPython +// framebuf/SPI) swaps just strip_begin + these two + set_invert/set_pixel_format. See __init__.h. +static inline void picogame_out_strip_send(picogame_output_t *display, const uint8_t *data, size_t nbytes) { + display->bus.send(display->bus.bus, DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, data, nbytes); +} +static inline void picogame_out_strip_end(picogame_output_t *display) { + displayio_display_bus_end_transaction(&display->bus); +} + +void picogame_render_region( + picogame_output_t *display, + mp_obj_t *items, uint8_t *kinds, size_t n, + uint16_t *buffer, size_t buffer_pixels, + int16_t x0, int16_t y0, int16_t x1, int16_t y1, + uint16_t background, int ox, int oy) { + + int region_w, strip_h; + int cx0 = x0, cy0 = y0, cx1 = x1, cy1 = y1; // strip_begin clamps these to the panel in place + if (!picogame_strip_begin(display, &cx0, &cy0, &cx1, &cy1, buffer_pixels, ®ion_w, &strip_h)) { + return; + } + for (int sy = cy0; sy < cy1; sy += strip_h) { + int sh = picogame_imin(strip_h, cy1 - sy); + mp_obj_t exc = picogame_blit_strip_layers(buffer, region_w, sy, sh, cx0, items, kinds, n, background, ox, oy); + picogame_out_strip_send(display, (uint8_t *)buffer, region_w * sh * 2); + if (exc != MP_OBJ_NULL) { // a StripDraw callback raised a BaseException: close the + picogame_out_strip_end(display); // bus, then re-raise (Ctrl-C / reload) + nlr_raise(MP_OBJ_TO_PTR(exc)); + } + } + picogame_out_strip_end(display); +} + +// Toggle the panel's hardware colour inversion (INVON 0x21 / INVOFF 0x20). Instant, sends NO +// pixel data - a brief invert is a free full-screen "flash" (a 1-bit negative hit look). +void picogame_set_invert(picogame_output_t *display, bool on) { + uint8_t cmd = on ? 0x21 : 0x20; + while (!displayio_display_bus_begin_transaction(&display->bus)) { + RUN_BACKGROUND_TASKS; + } + display->bus.send(display->bus.bus, DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, &cmd, 1); + displayio_display_bus_end_transaction(&display->bus); +} + +#if CIRCUITPY_PICOGAME_RGB444 +// The RGB444 machinery (this + pack_rgb444 + the Display rgb444 path) is compiled in ONLY when a +// board sets CIRCUITPY_PICOGAME_RGB444=1. It is a transfer-bound-only win (on a CPU-balanced panel +// like the PicoPad's the pack ~= the SPI byte saving, so it loses); default off (port ?= 0) until +// multi-platform experience justifies flipping the default. See pack_rgb444's measured-optimal note. +// Set the panel pixel format (COLMOD 0x3A): rgb444 -> 12-bit RGB444 (0x53), else 16-bit RGB565 +// (0x55). Asserting it on every Display construct also recovers from a previous program that left +// the panel in the other format (survives soft reset). +void picogame_set_pixel_format(picogame_output_t *display, bool rgb444) { + uint8_t cmd = 0x3A; + uint8_t param = rgb444 ? 0x53 : 0x55; + while (!displayio_display_bus_begin_transaction(&display->bus)) { + RUN_BACKGROUND_TASKS; + } + display->bus.send(display->bus.bus, DISPLAY_COMMAND, CHIP_SELECT_TOGGLE_EVERY_BYTE, &cmd, 1); + display->bus.send(display->bus.bus, DISPLAY_DATA, CHIP_SELECT_UNTOUCHED, ¶m, 1); + displayio_display_bus_end_transaction(&display->bus); +} + +// Pack a strip of `npix` (must be even) WIRE-order RGB565 pixels IN-PLACE to ST7789 12-bit RGB444 +// (2 px -> 3 bytes): R0G0 / B0R1 / G1B1. Returns the packed byte count. Cuts SPI traffic ~25%. +// In-place is safe: the write offset (1.5*i) always trails the read offset (2*i). +size_t picogame_pack_rgb444(uint16_t *buf, size_t npix) { + // Pack wire RGB565 -> ST7789 12-bit RGB444 (2 px -> 3 bytes) in place; the write offset (1.5*i) + // always trails the read offset (2*i). NB: the byte-swap to native order is LOAD-BEARING, not + // waste - it nibble-aligns the channels so each extracts in ~2 ops; extracting straight from the + // wire value makes GREEN (split across the byte boundary) cost ~5 ops and is SLOWER on device + // (measured, M0+). Likewise do NOT wide-unroll (8 registers -> spills). This tight form is the + // measured-optimal pack; RGB444 still loses to RGB565 on a CPU-balanced panel (pack ~= the SPI + // byte saving), so it is a transfer-bound-only option (default off). + uint8_t *out = (uint8_t *)buf; + size_t o = 0; + for (size_t i = 0; i + 1 < npix; i += 2) { + uint32_t w0 = buf[i], w1 = buf[i + 1]; + uint32_t n0 = (w0 >> 8) | (w0 << 8); // wire -> native RGB565 + uint32_t n1 = (w1 >> 8) | (w1 << 8); + uint8_t r0 = (n0 >> 12) & 0xF, g0 = (n0 >> 7) & 0xF, b0 = (n0 >> 1) & 0xF; + uint8_t r1 = (n1 >> 12) & 0xF, g1 = (n1 >> 7) & 0xF, b1 = (n1 >> 1) & 0xF; + out[o++] = (r0 << 4) | g0; + out[o++] = (b0 << 4) | r1; + out[o++] = (g1 << 4) | b1; + } + return o; +} +#endif // CIRCUITPY_PICOGAME_RGB444 + +void picogame_render( + picogame_output_t *display, + mp_obj_t *items, size_t n, + uint16_t *buffer, size_t buffer_pixels, + int16_t x0, int16_t y0, int16_t x1, int16_t y1, + uint16_t background) { + picogame_render_region(display, items, NULL, n, buffer, buffer_pixels, + x0, y0, x1, y1, background, 0, 0); +} + +#if CIRCUITPY_PICOGAME_FRAMEBUFFER +// Full-frame RAM-framebuffer backend. Same layered compositor as the SPI strip path +// (picogame_blit_strip_layers -> identical kinds dispatch, view offset, StripDraw), +// but the destination is a caller-owned framebuffer instead of a bus window - no +// transaction, no strip transfer. This is the shared render target for scanout-buffer +// platforms (RP2350 DVI/HSTX, the desktop sim, the WASM playground): the framebuffer +// IS the composite surface, so a region is composited straight into fb in place. +// +// fb : destination, wire-order RGB565, fb_stride*fb_h pixels (caller-owned). +// fb_stride : framebuffer row stride in pixels (>= x1); rows may be wider than x1. +// items/kinds/n, background, ox/oy : the scene layer list, as picogame_render_region. +// [x0,y0,x1,y1): the framebuffer region to (re)composite - a dirty rect, or the frame. +// Bounds are clamped to [0,fb_stride) x [0,fb_h); an empty region is a no-op. +// Returns a latched BaseException raised by a StripDraw callback (the caller re-raises +// it), or MP_OBJ_NULL - the SAME contract as the strip path, minus the bus to close. +// Convert a contiguous run of `n` pixels in place from wire-order to native RGB565. +// `((v&0x00ff00ff)<<8)|((v&0xff00ff00)>>8)` byte-swaps both halfwords of a 32-bit word at once +// (GCC lowers it to a single Cortex REV16), so two pixels per word; unrolled x4 (8 px/iter) so the +// ldr/str stream pipelines on zero-wait SRAM. A run may start at an odd x (partial dirty rect), so +// peel one leading pixel to keep the word accesses 4-byte aligned (unaligned faults on M0+). +// Portable + little-endian, so it also serves the WASM native565 canvas target. +// Emulated full-screen invert for framebuffer targets (RP2350 DVI, the WASM playground) that lack a +// panel's hardware INVON/INVOFF: a flag XORed into the wire->native conversion below, so composited +// pixels come out as their negative. Toggling it (picogame_fb_set_invert) latches a one-shot "the +// whole frame must recomposite" so the flip covers the ENTIRE screen, not just the current dirty +// rects - mirroring a panel INVON, which flips everything already scanned out. The XOR folds into the +// existing REV16 byte-swap for ~free (one extra op per word). +static bool s_fb_invert = false; +static bool s_fb_invert_dirty = false; + +void picogame_fb_set_invert(bool on) { + if (on != s_fb_invert) { + s_fb_invert = on; + s_fb_invert_dirty = true; // one full-frame recomposite so the flip is global, then latch off + } +} + +bool picogame_fb_take_invert_dirty(void) { + bool d = s_fb_invert_dirty; + s_fb_invert_dirty = false; + return d; +} + +static void picogame_fb_to_native(uint16_t *px, int n) { + uint32_t inv = s_fb_invert ? 0xFFFFFFFFu : 0u; // emulated negative flash (0 = normal pass) + int i = 0; + if (n > 0 && ((uintptr_t)px & 2u) != 0) { + px[0] = (uint16_t)(__builtin_bswap16(px[0]) ^ (uint16_t)inv); + i = 1; + } + int pairs = (n - i) >> 1; + uint32_t *w = (uint32_t *)(px + i); + int p = 0; + for (; p + 4 <= pairs; p += 4) { + uint32_t v0 = w[p], v1 = w[p + 1], v2 = w[p + 2], v3 = w[p + 3]; + w[p] = (((v0 & 0x00ff00ffu) << 8) | ((v0 & 0xff00ff00u) >> 8)) ^ inv; + w[p + 1] = (((v1 & 0x00ff00ffu) << 8) | ((v1 & 0xff00ff00u) >> 8)) ^ inv; + w[p + 2] = (((v2 & 0x00ff00ffu) << 8) | ((v2 & 0xff00ff00u) >> 8)) ^ inv; + w[p + 3] = (((v3 & 0x00ff00ffu) << 8) | ((v3 & 0xff00ff00u) >> 8)) ^ inv; + } + for (; p < pairs; p++) { + uint32_t v = w[p]; + w[p] = (((v & 0x00ff00ffu) << 8) | ((v & 0xff00ff00u) >> 8)) ^ inv; + } + i += pairs << 1; + if (i < n) { + px[i] = (uint16_t)(__builtin_bswap16(px[i]) ^ (uint16_t)inv); + } +} + +// Fused wire->native byte-swap AND copy in one pass: read `n` wire pixels from `src`, write them +// NATIVE to `dst` (folding in the emulated invert XOR). Saves the separate in-place swap + memcpy +// (one whole band read+write) on the full-width publish path. REQUIRES src AND dst 4-byte aligned +// (the caller uses it only for a contiguous full-width band, where both are). +static void picogame_fb_to_native_copy(uint16_t *dst, const uint16_t *src, int n) { + uint32_t inv = s_fb_invert ? 0xFFFFFFFFu : 0u; + int pairs = n >> 1; + const uint32_t *s = (const uint32_t *)src; + uint32_t *d = (uint32_t *)dst; + int p = 0; + for (; p + 4 <= pairs; p += 4) { + uint32_t v0 = s[p], v1 = s[p + 1], v2 = s[p + 2], v3 = s[p + 3]; + d[p] = (((v0 & 0x00ff00ffu) << 8) | ((v0 & 0xff00ff00u) >> 8)) ^ inv; + d[p + 1] = (((v1 & 0x00ff00ffu) << 8) | ((v1 & 0xff00ff00u) >> 8)) ^ inv; + d[p + 2] = (((v2 & 0x00ff00ffu) << 8) | ((v2 & 0xff00ff00u) >> 8)) ^ inv; + d[p + 3] = (((v3 & 0x00ff00ffu) << 8) | ((v3 & 0xff00ff00u) >> 8)) ^ inv; + } + for (; p < pairs; p++) { + uint32_t v = s[p]; + d[p] = (((v & 0x00ff00ffu) << 8) | ((v & 0xff00ff00u) >> 8)) ^ inv; + } + if (n & 1) { + dst[n - 1] = (uint16_t)(__builtin_bswap16(src[n - 1]) ^ (uint16_t)inv); + } +} + +// Wire-order RGB565 -> RGB332 publish copy for 8-bit picodvi scanout targets (RRRGGGBB, +// the same quantization displayio's ColorConverter uses), folding in the emulated invert. +// A wire pixel w holds native v byte-swapped: hi(v) = low byte of w, lo(v) = high byte. +// R3 = top 3 of R5 = hi & 0xE0; G3 = top 3 of G6 = (hi & 0x07) << 2; +// B2 = top 2 of B5 = (lo >> 3) & 0x03 +static void picogame_fb_to_rgb332_copy(uint8_t *dst, const uint16_t *src, int n) { + uint8_t inv = s_fb_invert ? 0xFF : 0x00; + for (int i = 0; i < n; i++) { + uint16_t w = src[i]; + uint8_t hi = (uint8_t)w; // wire low byte = native high byte + uint8_t lo = (uint8_t)(w >> 8); + dst[i] = (uint8_t)((hi & 0xE0u) | ((hi & 0x07u) << 2) | ((lo >> 3) & 0x03u)) ^ inv; + } +} + +// Publish-copy context for one framebuffer region: just what fb_publish_band needs. +typedef struct { + uint16_t *fb; + int fb_stride, fmt; + int x0, region_w; + bool full_width; +} fb_bands_arg_t; + +// Publish one FINISHED band from a scratch strip into the fb (wire/native/RGB332, +// full- or partial-width). Shared by the serial loop and both cores of the split. +static void fb_publish_band(const fb_bands_arg_t *a, uint16_t *scratch, int by, int bh) { + uint16_t *fb = a->fb; + int fb_stride = a->fb_stride; + int region_w = a->region_w; + int fmt = a->fmt; + if (a->full_width) { + size_t npix = (size_t)region_w * (size_t)bh; + if (fmt == PICOGAME_FB_RGB332) { + picogame_fb_to_rgb332_copy( + (uint8_t *)fb + (size_t)by * (size_t)fb_stride, scratch, (int)npix); + } else if (fmt == PICOGAME_FB_NATIVE565) { + // Fold the wire->native byte-swap INTO the publish copy (no separate in-place swap). + picogame_fb_to_native_copy(fb + (size_t)by * (size_t)fb_stride, scratch, (int)npix); + } else { + memcpy(fb + (size_t)by * (size_t)fb_stride, scratch, npix * 2u); // wire: HW reads as-is + } + } else if (fmt == PICOGAME_FB_RGB332) { + // Partial-width 8-bit: quantize row by row straight into the byte fb (strided). + for (int r = 0; r < bh; r++) { + picogame_fb_to_rgb332_copy( + (uint8_t *)fb + (size_t)(by + r) * (size_t)fb_stride + a->x0, + scratch + (size_t)r * region_w, region_w); + } + } else { + // Partial-width: swap the scratch in place (NATIVE), then copy row by row (strided). + if (fmt == PICOGAME_FB_NATIVE565) { + picogame_fb_to_native(scratch, region_w * bh); + } + for (int r = 0; r < bh; r++) { + memcpy(fb + (size_t)(by + r) * fb_stride + a->x0, + scratch + (size_t)r * region_w, (size_t)region_w * 2u); + } + } +} + +mp_obj_t picogame_render_framebuffer( + uint16_t *fb, int fb_stride, int fb_h, int fmt, + uint16_t *scratch, int scratch_rows, + mp_obj_t *items, uint8_t *kinds, size_t n, + int x0, int y0, int x1, int y1, + uint16_t background, int ox, int oy) { + if (x0 < 0) { + x0 = 0; + } + if (y0 < 0) { + y0 = 0; + } + if (x1 > fb_stride) { + x1 = fb_stride; + } + if (y1 > fb_h) { + y1 = fb_h; + } + if (x1 <= x0 || y1 <= y0) { + return MP_OBJ_NULL; + } + int region_w = x1 - x0; + + // Tear-free NATIVE path: the framebuffer is a LIVE scanout buffer (picodvi/HDMI reads it + // continuously), so it must NEVER transiently hold wire-order pixels - a beam sampling a + // half-composed region would read byte-swapped bytes (R/B swapped -> pink). Compose+byte-swap + // into a PRIVATE scratch strip, then memcpy the finished NATIVE band into the fb; the fb only + // ever receives fully-native runs. Any residual seam is old-vs-new NATIVE content (a plain + // single-buffer tear, no colour shift) - reduced by the caller's optional vblank sync. Bands of + // scratch_rows keep the scratch small. (Wire targets / no scratch fall through to the direct + // path below, which is correct because a wire fb needs no conversion.) + if (scratch != NULL && scratch_rows > 0) { + // Full-width band: rows are contiguous in BOTH scratch and fb, so the whole band publishes in + // one pass with 4-byte-aligned pointers (fb_stride even). Partial-width rows are strided. + fb_bands_arg_t a = { + fb, fb_stride, fmt, x0, region_w, + (x0 == 0 && region_w == fb_stride) + }; + int nbands = (y1 - y0 + scratch_rows - 1) / scratch_rows; + for (int b = 0; b < nbands; b++) { + int by = y0 + b * scratch_rows; + int bh = (y1 - by) < scratch_rows ? (y1 - by) : scratch_rows; + // Compose region_w x bh OFF-SCREEN into the scratch (wire order) so the beam never sees a + // half-composited region (no sprite/HUD flicker), then publish the FINISHED band into the + // fb. Published even on a latched exception so the fb is never left half-updated. + mp_obj_t exc = picogame_blit_strip_layers( + scratch, region_w, by, bh, x0, items, kinds, n, background, ox, oy); + fb_publish_band(&a, scratch, by, bh); + if (exc != MP_OBJ_NULL) { + return exc; // StripDraw raised; caller re-raises (cleared stays false -> full repaint) + } + } + return MP_OBJ_NULL; + } + // The compositor (blitters, effects, palettes) works in wire order throughout; a NATIVE + // target is converted in place only after a region is fully composed. On a latched StripDraw + // exception the region was partially composed in wire order - convert it anyway so the fb is + // never left half wire / half native (the Scene keeps cleared=false until its render loop + // finishes, so the refresh after the exception repaints the full frame). + + // Fast path: a FULL-WIDTH region has contiguous rows, so the whole rect IS one valid strip + // buffer. Composite it in a SINGLE strip_layers call (h = the whole band) instead of one call + // per row - this amortizes all the per-row/per-layer setup (tile-blit dispatch, clip tests, + // background fill, and crucially one StripDraw Python callback per band instead of per row) + // over the band, and converts the whole contiguous region in one pass. This is the + // full-repaint / camera-scroll case (set_view -> cleared=false -> a full-screen dirty rect). + // (An RGB332 target never reaches these direct paths: its constructor always allocates + // the scratch strip, and the compositor can only write 16-bit wire pixels - composing + // in place inside a byte framebuffer would corrupt it.) + if (x0 == 0 && x1 == fb_stride) { + uint16_t *base = fb + (size_t)y0 * (size_t)fb_stride; + mp_obj_t exc = picogame_blit_strip_layers( + base, fb_stride, y0, y1 - y0, 0, items, kinds, n, background, ox, oy); + if (fmt == PICOGAME_FB_NATIVE565) { + picogame_fb_to_native(base, (y1 - y0) * fb_stride); + } + return exc; // MP_OBJ_NULL on success, else a latched StripDraw exception + } + + // Partial-width region: rows are non-contiguous, so composite (and convert) row by row. + for (int sy = y0; sy < y1; sy++) { + uint16_t *row = fb + (size_t)sy * (size_t)fb_stride + x0; + mp_obj_t exc = picogame_blit_strip_layers( + row, region_w, sy, 1, x0, items, kinds, n, background, ox, oy); + if (fmt == PICOGAME_FB_NATIVE565) { + picogame_fb_to_native(row, region_w); + } + if (exc != MP_OBJ_NULL) { + return exc; // StripDraw raised a BaseException; caller re-raises (no bus open) + } + } + return MP_OBJ_NULL; +} +#endif // CIRCUITPY_PICOGAME_FRAMEBUFFER diff --git a/shared-module/picogame/__init__.h b/shared-module/picogame/__init__.h new file mode 100644 index 00000000000..a4377292219 --- /dev/null +++ b/shared-module/picogame/__init__.h @@ -0,0 +1,308 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT + +#pragma once + +#include +#include +#include +#include "py/obj.h" +#include "shared-bindings/busdisplay/BusDisplay.h" +#include "shared-module/picogame/Bitmap.h" +#include "shared-module/picogame/Sprite.h" + +// Hardware-FPU boards run the pseudo-3D/math primitives (picogame.project) on plain float32 - +// faster than the soft-float-equivalent fixed path and free of 16.16 range limits; no-FPU +// targets (Cortex-M0+) use integer 16.16. Only ONE path is compiled per board. The default +// follows the architecture; a board can override with CIRCUITPY_PICOGAME_FPU=0/1 in its +// mpconfigboard.mk. Python reads `picogame.FPU` to pack camera/point buffers to match. +#ifndef CIRCUITPY_PICOGAME_FPU +#if (defined(__ARM_FP) && (__ARM_FP != 0)) || (defined(__riscv_flen) && (__riscv_flen > 0)) +#define CIRCUITPY_PICOGAME_FPU (1) +#else +#define CIRCUITPY_PICOGAME_FPU (0) +#endif +#endif + +// Sample one texel as wire RGB565; false = transparent (skip). Shared by the sprite/canvas +// blit paths so they inline one copy (see the blit contract: PAL8 indices must be < palette len). +static inline bool src_pixel_s(int format, const uint8_t *data, const uint16_t *pal, + bool transp, uint16_t key, int idx, uint16_t *out) { + if (format == PICOGAME_FMT_PAL8) { + uint8_t i = data[idx]; + if (transp && i == (uint8_t)key) { + return false; + } + *out = pal[i]; // indices must be < palette length (see blit contract) + return true; + } + // GC buffer is >=4-byte aligned; silence xtensa -Wcast-align (see picogame_blit_bitmap). + #pragma GCC diagnostic push + #pragma GCC diagnostic ignored "-Wcast-align" + uint16_t v = ((const uint16_t *)data)[idx]; + #pragma GCC diagnostic pop + if (transp && v == key) { + return false; + } + *out = v; + return true; +} + + +// Scene layer kinds (tags stored alongside items so blit/dirty can dispatch +// without cross-referencing shared-bindings type objects). +enum { + PICOGAME_KIND_SPRITE = 0, + PICOGAME_KIND_TILEMAP = 1, + PICOGAME_KIND_PARTICLES = 2, + PICOGAME_KIND_CANVAS = 3, + PICOGAME_KIND_STRIPDRAW = 4, + PICOGAME_KIND_TRIANGLES = 5, + // High bit on a kind = "fixed": the item ignores the scene view offset + // (camera), so HUD / score / dialog stay put while the world scrolls. + PICOGAME_KIND_FIXED = 0x80, + PICOGAME_KIND_MASK = 0x7f, +}; + +// StripDraw: immediate-mode layer. Holds NO pixel buffer - instead its `callback` +// is invoked once per render strip with a Canvas "view" repointed at the live strip +// buffer, so the user draws primitives straight into the strip (zero RAM vs a Canvas, +// which costs w*h*2 bytes). Its rect is repainted every frame (it's for animated / +// scanline content: pseudo-3D, gradients, procedural backgrounds). The view's local +// (0,0) maps to screen (vx, vy) handed to the callback. `faulted` latches after the +// callback raises once, so a buggy callback prints one traceback, not one per strip. +typedef struct { + mp_obj_base_t base; + mp_obj_t callback; // draw(view, vx, vy, vw, vh): vx/vy = screen origin of view (0,0) + mp_obj_t view; // a reused picogame_canvas_obj_t (data repointed each strip) + int32_t x, y, w, h; // scene rect (int32: scene coords, big-world safe) + int32_t dx1, dy1, dx2, dy2; // accumulated dirty rect (scene coords) when !always_dirty - the same + // picogame_dirty_* accumulator Canvas/Tilemap use, so invalidate() can mark + // a sub-rect and the Scene repaints only that region (not the whole layer). + bool faulted; + bool always_dirty; // True: repaint every frame (animated). False: only the dirty rect (on-change UI). +} picogame_stripdraw_obj_t; + +// Triangles: a retained SCREEN-SPACE triangle batch the compositor rasterises entirely +// in C (per strip, band-rejected) - no Python callback per strip, so unlike StripDraw it +// stays composable without re-entering Python mid-frame. verts (int16 x0,y0,x1,y1,x2,y2 per tri) and +// colors (uint16 wire RGB565 per tri) are CALLER-OWNED arrays (refs held for GC; fill +// them in place). Setting `count` selects how many draw and marks the layer dirty +// full-screen (a 3D frame repaints everything anyway). +typedef struct { + mp_obj_base_t base; + mp_obj_t verts_obj, colors_obj; // GC anchors for the caller's arrays + const int16_t *verts; + const uint16_t *colors; + uint16_t count, cap; // cap = what the buffers can hold + int32_t dx1, dy1, dx2, dy2; // dirty accumulator (count-set -> full screen) +} picogame_triangles_obj_t; + +static inline int picogame_imin(int a, int b) { + return a < b ? a : b; +} +static inline int picogame_imax(int a, int b) { + return a > b ? a : b; +} + +// Drawn top-left in scene pixels: the logical position minus the anchor offset +// (anchor is a 1/256 fraction of the bitmap size). Used by BOTH the blitter and +// the dirty-rect tracker so they always agree on where the sprite lands. +static inline void picogame_sprite_topleft(const picogame_sprite_obj_t *s, int *tx, int *ty) { + int w = (s->bitmap != NULL) ? s->bitmap->width : 0; + int h = (s->bitmap != NULL) ? s->bitmap->height : 0; + int sw = (w * s->scale) >> 8; // anchor is a fraction of the SCALED size + int sh = (h * s->scale) >> 8; + // The blitter only honours transpose on the fast path (scale==256); the scaled blitter ignores it. + // Swap the footprint ONLY when scale==256, or aabb/topleft disagree with what's drawn (trailing). + if ((s->flags & PICOGAME_SPR_TRANSPOSE) && s->scale == 256) { // 90deg transpose swaps footprint + int t = sw; // picogame_sprite_aabb, or the blit top-left and the + sw = sh; // tracked dirty rect disagree (sprite trails) + sh = t; + } + *tx = (s->x >> 8) - ((int)s->anchor_x * sw >> 8); + *ty = (s->y >> 8) - ((int)s->anchor_y * sh >> 8); +} + +// Drawn screen-space bounding box of a sprite (accounts for scale + rotation). +// Used by the dirty-rect tracker so it always covers the transformed sprite. +void picogame_sprite_aabb(const picogame_sprite_obj_t *s, int *x1, int *y1, int *x2, int *y2); + +// Per-pixel blit effect, shared by all three blit paths. One mode at a time; a NULL +// pointer means "no effect" (the fast path). SHADOW darkens the destination, FLASH +// replaces opaque pixels with `color`, DITHER skips pixels via a Bayer pattern (0..16 +// transparency) for fake translucency without alpha. +enum { PICOGAME_FX_NONE = 0, PICOGAME_FX_SHADOW, PICOGAME_FX_FLASH, PICOGAME_FX_DITHER, PICOGAME_FX_TINT }; +typedef struct { + uint8_t mode; + uint16_t color; // FLASH: solid colour to write; TINT: colour to multiply by (wire RGB565) + uint8_t level; // DITHER: 0..16 transparency (higher = more pixels skipped) +} picogame_fx_t; + +// Shared dirty-rect accumulator over a contiguous int32 [x1,y1,x2,y2] (Canvas + Tilemap both end in +// dx1,dy1,dx2,dy2). INT32 sentinels so big-world (>32767 px) scene coords still accumulate. +void picogame_dirty_reset(int32_t *r); +void picogame_dirty_union(int32_t *r, int x1, int y1, int x2, int y2); +bool picogame_dirty_take(int32_t *r, int *x1, int *y1, int *x2, int *y2); + +// Blit one frame of a bitmap at screen (dx0, dy0) into the strip buffer that +// covers [ox, ox+bw) x [oy, oy+bh). Shared by sprites and tilemap tiles. +// fxm: per-pixel effect (NULL = plain colour copy). +void picogame_blit_bitmap( + uint16_t *buf, int bw, int bh, int ox, int oy, + picogame_bitmap_obj_t *bm, int dx0, int dy0, int frame, bool flip_x, bool flip_y, + bool transpose, const picogame_fx_t *fxm); + +// Nearest-neighbour scaled blit (axis-aligned); scale is 8.8 fixed-point. +void picogame_blit_bitmap_scaled( + uint16_t *buf, int bw, int bh, int ox, int oy, + picogame_bitmap_obj_t *bm, int dx0, int dy0, int frame, bool flip_x, bool flip_y, + uint16_t scale, const picogame_fx_t *fxm); + +// Full affine blit (scale + rotation about the anchor); (px,py)=screen anchor point, +// (pivx,pivy)=that anchor in SOURCE pixels. Nearest-neighbour inverse map. The transform is +// PRECOMPUTED by the caller (the sprite's xf_* cache): minx..maxy = the screen-space corner +// bbox, ic/is = the 16.16 inverse-map steps - so a per-strip call does no trig/divides. +void picogame_blit_bitmap_affine( + uint16_t *buf, int bw, int bh, int ox, int oy, + picogame_bitmap_obj_t *bm, int px, int py, int pivx, int pivy, + int frame, bool flip_x, bool flip_y, + int minx, int miny, int maxx, int maxy, int32_t ic, int32_t is, + const picogame_fx_t *fxm); + +// ===== OUTPUT TRANSPORT SEAM ===================================================== +// picogame's compositor (picogame_blit_strip_layers) is OUTPUT-AGNOSTIC: it composites +// scene layers into a plain wire-order RGB565 strip buffer, knowing nothing about the +// destination. A physical display is reached only through the small transport contract +// below, so a non-CircuitPython port (e.g. a MicroPython framebuf/SPI backend) can reuse +// the whole compositor AND the generic picogame_render_region orchestrator and reimplement +// ONLY these few functions. `picogame_output_t` is the opaque display handle they take. +// (A RAM-framebuffer destination is a separate backend: picogame_render_framebuffer.) +// +// Strip-path contract a backend provides: +// picogame_strip_begin - open a window for [x0,y0,x1,y1); return strip geometry +// picogame_out_strip_send - push one composited strip (region_w*sh px, wire RGB565) +// picogame_out_strip_end - close the transaction +// picogame_set_invert - panel hardware colour inversion (a free full-screen flash) +// picogame_set_pixel_format - panel COLMOD (RGB565/RGB444), when CIRCUITPY_PICOGAME_RGB444 +// +// CircuitPython backend: picogame_output_t == busdisplay; the impl lives in __init__.c. +typedef busdisplay_busdisplay_obj_t picogame_output_t; + +// Fill a strip with background, then composite items (sprites and tilemaps) in +// order (items[0] = bottom). kinds[i] selects the type; kinds == NULL means +// every item is a sprite. (ox, oy) is the view offset added to item positions +// (scene space -> screen space) for camera/centering. +// Returns a latched BaseException (Ctrl-C / ReloadException) raised by a StripDraw callback, or +// MP_OBJ_NULL. The caller must re-raise it AFTER closing the display transaction. +#if defined(PICOGAME_HAS_INTERP) +// rp2-port SIO-interpolator mode7 row walker. Fast path only: +// PAL8, opaque, stride == tw, log2(tw)+log2(th) <= 16; the caller guards and falls back. +void picogame_mode7_row_interp(uint16_t *dst, int n, + const uint8_t *tex, const uint16_t *pal, + uint32_t fx, uint32_t fy, int32_t stepx, int32_t stepy, + int shx, int shy, int ltw, int lth); +#endif + +// Racing-road curve pass (see the implementation comment in __init__.c). +void picogame_road_edges(int16_t *rl, int16_t *rr, const int32_t *hw_q16, int n, + int32_t cx_q16, int32_t dist, const int32_t *cfg); + +mp_obj_t picogame_blit_strip_layers( + uint16_t *buf, int region_w, int strip_top, int strip_h, int x0, + mp_obj_t *items, uint8_t *kinds, size_t n, uint16_t background, int ox, int oy); + +// Compute strip geometry and open a render window on the display (set region, +// begin transaction, send RAMWR). Returns false if the region is empty; raises +// if the buffer is too small for the region width. Fills *region_w and *strip_h. +bool picogame_strip_begin( + picogame_output_t *display, + int *x0, int *y0, int *x1, int *y1, size_t buffer_pixels, + int *region_w, int *strip_h); // clamps *x0..*y1 to the panel in place (caller loops on them) + +// Portable backend: strip-render a layered scene region to ANY busdisplay via +// its (blocking) bus.send - single buffer, no DMA. Same layer dispatch as the +// fast path (kinds + view offset), so it is the cross-port fallback for Scene on +// targets without the platform DMA Display. `kinds == NULL` => all sprites. +void picogame_render_region( + picogame_output_t *display, + mp_obj_t *items, uint8_t *kinds, size_t n, + uint16_t *buffer, size_t buffer_pixels, + int16_t x0, int16_t y0, int16_t x1, int16_t y1, + uint16_t background, int ox, int oy); + +// Toggle the panel's hardware colour inversion (INVON/INVOFF) - a free full-screen flash. +void picogame_set_invert(picogame_output_t *display, bool on); + +#if CIRCUITPY_PICOGAME_FRAMEBUFFER +// Emulated invert for a picogame.Framebuffer target (no hardware INVON): set the flag (XORed into the +// wire->native conversion) and, via take_invert_dirty(), force one whole-frame recomposite on toggle. +void picogame_fb_set_invert(bool on); +bool picogame_fb_take_invert_dirty(void); +#endif + +#if CIRCUITPY_PICOGAME_RGB444 // compiled in only on boards that opt into RGB444 (default off) +// Set panel pixel format (COLMOD): rgb444 -> 12-bit RGB444, else 16-bit RGB565. +void picogame_set_pixel_format(picogame_output_t *display, bool rgb444); + +// Pack `npix` (even) wire-order RGB565 pixels in `buf` IN-PLACE to 12-bit RGB444; returns bytes. +size_t picogame_pack_rgb444(uint16_t *buf, size_t npix); +#endif + +// Universal sprite-only convenience wrapper over picogame_render_region. +void picogame_render( + picogame_output_t *display, + mp_obj_t *items, size_t n, + uint16_t *buffer, size_t buffer_pixels, + int16_t x0, int16_t y0, int16_t x1, int16_t y1, + uint16_t background); + +#if CIRCUITPY_PICOGAME_FRAMEBUFFER +// Rows per compose band for the tear-free NATIVE framebuffer path: composite+byte-swap into a +// private scratch strip, then memcpy the finished NATIVE band into the live scanout buffer - so a +// picodvi/HDMI beam scanning the framebuffer never samples a half-composed WIRE-order region (which +// would read as byte-swapped / pink). Small band = small scratch (width*this*2 bytes). +#define PICOGAME_FB_SCRATCH_H 16 +// A render TARGET that is a caller-owned RAM framebuffer (wire-order RGB565), used in +// place of a BusDisplay for scanout-buffer platforms: the WASM playground (heap +// buffer read out to a canvas), the desktop sim, and FruitJam (the DVI/HSTX scanout +// buffer). Holds a WriteableBuffer alive + a typed view of it; allocation is the +// caller's (a bytearray in WASM, the DVI buffer memoryview on FruitJam), so the engine +// stays platform-neutral. Scene / render can target this instead of a display. +// Output pixel format of a picogame.Framebuffer target. The compositor always works in +// wire-order RGB565; the format only selects the publish conversion. +enum { + PICOGAME_FB_WIRE565 = 0, // no conversion (WASM playground / sim readout) + PICOGAME_FB_NATIVE565 = 1, // byte-swap to native RGB565 (picodvi 16-bit scanout) + PICOGAME_FB_RGB332 = 2, // quantize to RGB332 bytes (picodvi 8-bit scanout, e.g. + // Fruit Jam 640x480 - its max resolution is 8bpp-only) +}; + +typedef struct { + mp_obj_base_t base; + mp_obj_t buffer; // the backing WriteableBuffer (kept alive) + uint16_t *fb; // typed view of buffer.buf: width*height px - RGB565 (2 B/px) for the + // 565 formats; cast to uint8_t* per-pixel bytes for PICOGAME_FB_RGB332 + int width; + int height; + uint8_t fmt; // PICOGAME_FB_* output format (see enum above) + mp_obj_t scratch_buf; // GC-kept bytearray backing `scratch`; mp_const_none if none + uint16_t *scratch; // private compose strip: width*scratch_rows px, or NULL + int scratch_rows; // rows in `scratch` (PICOGAME_FB_SCRATCH_H), 0 if none +} picogame_framebuffer_obj_t; + +// Full-frame RAM-framebuffer backend: same layered compositor as the SPI strip path +// but composited straight into a caller-owned wire-order RGB565 framebuffer (no bus). +// The shared render target for scanout-buffer platforms (RP2350 DVI/HSTX, sim, WASM). +// [x0,y0,x1,y1) is the region to (re)composite (clamped to the framebuffer). Returns a +// latched StripDraw BaseException for the caller to re-raise, or MP_OBJ_NULL. +mp_obj_t picogame_render_framebuffer( + uint16_t *fb, int fb_stride, int fb_h, int fmt, + uint16_t *scratch, int scratch_rows, + mp_obj_t *items, uint8_t *kinds, size_t n, + int x0, int y0, int x1, int y1, + uint16_t background, int ox, int oy); +#endif // CIRCUITPY_PICOGAME_FRAMEBUFFER diff --git a/shared-module/picogame/pg_compat.h b/shared-module/picogame/pg_compat.h new file mode 100644 index 00000000000..92c4168f6d8 --- /dev/null +++ b/shared-module/picogame/pg_compat.h @@ -0,0 +1,64 @@ +// This file is part of the CircuitPython project: https://circuitpython.org +// +// SPDX-FileCopyrightText: Copyright (c) 2026 Vladimir Smitka +// +// SPDX-License-Identifier: MIT +// +// pg_compat.h — the small CircuitPython core-API delta the picogame engine relies on, +// OWNED BY THE ENGINE (not injected by a build-level force-`-include`). On CircuitPython +// this is a pure pass-through (every symbol is native); on a bare MicroPython build it +// supplies the ~9-symbol delta CP added over MicroPython. This lets the SAME engine C +// compile on both without a per-build shim impersonating CircuitPython. +// +// Engine TUs `#include "shared-module/picogame/pg_compat.h"` where they use any of these +// (it replaces the former `#include "py/objproperty.h"` in the bindings TUs). The function +// bodies for the MicroPython branch live in pg_compat_mp.c (compiled ONLY on MicroPython). +#pragma once + +#include "py/runtime.h" + +#if defined(CIRCUITPY) +// CircuitPython: everything below is native — pure pass-through. +#include "py/objproperty.h" +#else +// MicroPython: the core-API delta the engine needs that bare MicroPython lacks. +#include "py/obj.h" + +#ifndef RUN_BACKGROUND_TASKS +#define RUN_BACKGROUND_TASKS (mp_handle_pending(MP_HANDLE_PENDING_CALLBACKS_AND_EXCEPTIONS)) +#endif +#ifndef m_malloc_without_collect +#define m_malloc_without_collect(n) m_malloc(n) +#endif +#ifndef mp_raise_RuntimeError +#define mp_raise_RuntimeError(msg) mp_raise_msg(&mp_type_RuntimeError, (msg)) +#endif + +mp_int_t mp_arg_validate_int_min(mp_int_t i, mp_int_t min, qstr arg_name); +mp_int_t mp_arg_validate_int_range(mp_int_t i, mp_int_t min, mp_int_t max, qstr arg_name); +mp_obj_t mp_arg_validate_type(mp_obj_t obj, const mp_obj_type_t *type, qstr arg_name); +NORETURN void mp_raise_ValueError_varg(mp_rom_error_text_t fmt, ...); +NORETURN void mp_raise_TypeError_varg(mp_rom_error_text_t fmt, ...); +mp_obj_t mp_obj_new_bytearray_of_zeros(size_t n); + +// CircuitPython's MP_PROPERTY_GETTER / MP_PROPERTY_GETSET convenience macros. Bare +// MicroPython keeps the property object private to py/objproperty.c; redeclare the +// struct with MP's EXACT layout + the macros in the non-native form (identical to +// CircuitPython's own !MICROPY_PY_OBJ_PROPERTY_NATIVE branch), so the generated +// property objects are ABI-compatible with MP's mp_type_property. +#if MICROPY_PY_BUILTINS_PROPERTY +typedef struct _mp_obj_property_t { + mp_obj_base_t base; + mp_obj_t proxy[3]; // getter, setter, deleter +} mp_obj_property_getset_t; +#ifndef MP_PROPERTY_GETTER +#define MP_PROPERTY_GETTER(P, G) \ + const mp_obj_property_getset_t P = { .base.type = &mp_type_property, .proxy = {G, MP_ROM_NONE, MP_ROM_NONE} } +#endif +#ifndef MP_PROPERTY_GETSET +#define MP_PROPERTY_GETSET(P, G, S) \ + const mp_obj_property_getset_t P = { .base.type = &mp_type_property, .proxy = {G, S, MP_ROM_NONE} } +#endif +#endif // MICROPY_PY_BUILTINS_PROPERTY + +#endif // CIRCUITPY