From 1459b612ca21e4be9f2fcb2f629c3a7054d16644 Mon Sep 17 00:00:00 2001 From: Simone Boscaglia Date: Mon, 8 Jun 2026 14:41:13 +0200 Subject: [PATCH 01/24] fix: restored screen working configurations --- main/prg32_config.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/main/prg32_config.h b/main/prg32_config.h index e370c45..f9036d9 100644 --- a/main/prg32_config.h +++ b/main/prg32_config.h @@ -90,11 +90,11 @@ #define PRG32_PIN_LCD_MISO 2 #define PRG32_PIN_LCD_SCLK 6 #define PRG32_PIN_LCD_CS 10 -#define PRG32_PIN_LCD_DC 1 -#define PRG32_PIN_LCD_RST 0 +#define PRG32_PIN_LCD_DC 8 +#define PRG32_PIN_LCD_RST 9 #define PRG32_PIN_LCD_BL 5 -#define PRG32_LCD_SPI_CLOCK_HZ 40000000 +#define PRG32_LCD_SPI_CLOCK_HZ 32000000 #define PRG32_LCD_BACKLIGHT_ACTIVE_LEVEL 1 #define PRG32_LCD_BOOT_TEST_MS 0 #define PRG32_LCD_SOFT_SPI 0 From e07d7267e1f3f6d4a21f0d597bc7649e4cfbfaf4 Mon Sep 17 00:00:00 2001 From: Simone Boscaglia Date: Mon, 8 Jun 2026 16:01:49 +0200 Subject: [PATCH 02/24] fix: diffs with old working repo --- components/prg32/prg32_controller.c | 6 +++++- components/prg32/prg32_input.c | 16 ++++++++++++++-- main/prg32_config.h | 15 +++++++-------- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/components/prg32/prg32_controller.c b/components/prg32/prg32_controller.c index fd9e884..5921774 100644 --- a/components/prg32/prg32_controller.c +++ b/components/prg32/prg32_controller.c @@ -111,9 +111,11 @@ void prg32_controller_bridge_init(void) { } static uint32_t read_bridge(void) { + printf("read_bridge => uart_read_bytes(PRG32_CONTROLLER_BRIDGE_UART)"); uint8_t b[16]; int n = uart_read_bytes(PRG32_CONTROLLER_BRIDGE_UART, b, sizeof(b), 0); for (int i = 0; i < n; ++i) { + printf("bridge_feed()"); bridge_feed(b[i]); } return bridge_state; @@ -184,7 +186,9 @@ uint32_t prg32_controller_read(void) { #if PRG32_RESTART_HOTKEY_ENABLE if ((v & PRG32_RESTART_HOTKEY_P1) == PRG32_RESTART_HOTKEY_P1 || (v & PRG32_RESTART_HOTKEY_P2) == PRG32_RESTART_HOTKEY_P2) { - esp_restart(); + ESP_LOGE(TAG, "ABOUT TO esp_restart() from %s:%d\n", __FILE__, __LINE__); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); } #endif return v; diff --git a/components/prg32/prg32_input.c b/components/prg32/prg32_input.c index a0628e5..41c0cc5 100644 --- a/components/prg32/prg32_input.c +++ b/components/prg32/prg32_input.c @@ -43,17 +43,21 @@ static void pin_in(const char *name, int p) { void prg32_controller_bridge_init(void); void prg32_input_init(void) { + printf("prg32_input_init START\n"); pin_in("LEFT", PRG32_PIN_BTN_LEFT); pin_in("RIGHT", PRG32_PIN_BTN_RIGHT); pin_in("UP", PRG32_PIN_BTN_UP); pin_in("DOWN", PRG32_PIN_BTN_DOWN); pin_in("A", PRG32_PIN_BTN_A); pin_in("B", PRG32_PIN_BTN_B); + printf("prg32_input_init PIN_BTN_START\n"); pin_in("START", PRG32_PIN_BTN_START); + printf("prg32_input_init PIN_BTN_SELECT\n"); if (PRG32_PIN_BTN_SELECT != PRG32_PIN_BTN_START) { pin_in("SELECT", PRG32_PIN_BTN_SELECT); } + printf("prg32_input_init P2\n"); pin_in("P2 LEFT", PRG32_PIN_P2_LEFT); pin_in("P2 RIGHT", PRG32_PIN_P2_RIGHT); pin_in("P2 UP", PRG32_PIN_P2_UP); @@ -61,11 +65,14 @@ void prg32_input_init(void) { pin_in("P2 A", PRG32_PIN_P2_A); pin_in("P2 B", PRG32_PIN_P2_B); pin_in("P2 START", PRG32_PIN_P2_START); + printf("prg32_input_init P2_SELECT\n"); if (PRG32_PIN_P2_SELECT != PRG32_PIN_P2_START) { pin_in("P2 SELECT", PRG32_PIN_P2_SELECT); } pin_in("SETUP", PRG32_PIN_SETUP); - prg32_controller_bridge_init(); + printf("prg32_input_init END\n"); + //vTaskDelay(pdMS_TO_TICKS(1)); + //prg32_controller_bridge_init(); } uint32_t prg32_input_read(void) { @@ -86,9 +93,14 @@ uint32_t prg32_input_read_menu(void) { } void prg32_input_wait_released(uint32_t mask) { + printf("prg32_input_wait_released START\n"); + printf("prg32_input_wait_released mask=%ld\n", mask); int stable = 0; while (stable < 2) { - if ((prg32_input_read_menu() & mask) == 0) { + printf("prg32_input_wait_released => prg32_input_read_menu()\n"); + uint32_t input_read_menu = prg32_input_read_menu(); + printf("prg32_input_read_menu() = %ld\n", input_read_menu); + if ((input_read_menu & mask) == 0) { stable++; } else { stable = 0; diff --git a/main/prg32_config.h b/main/prg32_config.h index e370c45..9bd186e 100644 --- a/main/prg32_config.h +++ b/main/prg32_config.h @@ -60,10 +60,6 @@ #define PRG32_PIN_BTN_A -1 #define PRG32_PIN_BTN_B -1 #define PRG32_PIN_BTN_START -1 -#define PRG32_PIN_SETUP -1 -#define PRG32_PIN_BUZZER -1 -#define PRG32_BOOT_DIAGNOSTIC_DELAY_MS 0 - #define PRG32_PIN_P2_LEFT -1 #define PRG32_PIN_P2_RIGHT -1 #define PRG32_PIN_P2_UP -1 @@ -71,13 +67,15 @@ #define PRG32_PIN_P2_A -1 #define PRG32_PIN_P2_B -1 #define PRG32_PIN_P2_START -1 +#define PRG32_PIN_SETUP -1 +#define PRG32_PIN_BUZZER -1 +#define PRG32_BOOT_DIAGNOSTIC_DELAY_MS 0 #define PRG32_CONTROLLER_BRIDGE_ENABLE 0 #define PRG32_CONTROLLER_BRIDGE_UART 1 #define PRG32_CONTROLLER_BRIDGE_BAUD 115200 #define PRG32_PIN_CONTROLLER_TX -1 #define PRG32_PIN_CONTROLLER_RX -1 - #define PRG32_PIN_RGB_LED -1 #define PRG32_GAME_UPLOAD_ENABLE 0 @@ -90,8 +88,8 @@ #define PRG32_PIN_LCD_MISO 2 #define PRG32_PIN_LCD_SCLK 6 #define PRG32_PIN_LCD_CS 10 -#define PRG32_PIN_LCD_DC 1 -#define PRG32_PIN_LCD_RST 0 +#define PRG32_PIN_LCD_DC 8 +#define PRG32_PIN_LCD_RST 9 #define PRG32_PIN_LCD_BL 5 #define PRG32_LCD_SPI_CLOCK_HZ 40000000 @@ -134,7 +132,7 @@ * is disabled by default. Set this to the board LED GPIO only when that pin is * free in your hardware variant. */ -#define PRG32_PIN_RGB_LED 8 +#define PRG32_PIN_RGB_LED -1 #endif /* @@ -205,5 +203,6 @@ #else #define PRG32_PIN_BTN_SELECT PRG32_PIN_BTN_START #endif +#define PRG32_PIN_P2_SELECT PRG32_PIN_P2_START #endif From 8d43d09f62d222d9034e9fcd8a88470abf31f832 Mon Sep 17 00:00:00 2001 From: Simone Boscaglia Date: Mon, 8 Jun 2026 18:50:35 +0200 Subject: [PATCH 03/24] fix(audio): implement software I2S fallback for buzzer-less targets When PRG32_PIN_BUZZER is not defined (< 0, e.g., in QEMU/desktop targets), calls to prg32_audio_tone() and prg32_audio_sample_u8() would previously return instantly. This not only prevented audio playback but completely bypassed vTaskDelay(), causing severe timing issues in game loops (such as infinite firing speed in wing_commander). This commit resolves both issues by routing tones to the I2S synth engine: - A new `hz_to_midi_note()` helper converts requested frequencies to the closest matching MIDI note. - The fallback exactly mimics the setup menu's audio test tune, registering the `setup_audio_wave` sine wave to sample ID 63 and instrument ID 31. - Tones are then played via `prg32_audio_note_on` and `prg32_audio_note_off`. - `vTaskDelay()` is now strictly enforced for both tone and sample playback, guaranteeing that games maintain correct timing even if audio is disabled. Additionally, a basic square wave (`builtin_beep_wave`) was included under an `#if 0` block as an optional reference for a traditional "buzzer" sound. --- components/prg32/prg32_audio.c | 224 ++++++++++++++++++++++----------- main/prg32_config.h | 10 +- 2 files changed, 153 insertions(+), 81 deletions(-) diff --git a/components/prg32/prg32_audio.c b/components/prg32/prg32_audio.c index 643796c..04d9ea6 100644 --- a/components/prg32/prg32_audio.c +++ b/components/prg32/prg32_audio.c @@ -1,105 +1,177 @@ -#include "prg32.h" -#include "prg32_config.h" #include "driver/ledc.h" #include "esp_rom_sys.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "prg32.h" +#include "prg32_config.h" void prg32_audio_pwm_init(void) { - if (PRG32_PIN_BUZZER < 0) { - return; - } - ledc_timer_config_t timer = { - .speed_mode = LEDC_LOW_SPEED_MODE, - .timer_num = LEDC_TIMER_0, - .duty_resolution = LEDC_TIMER_10_BIT, - .freq_hz = 1000, - .clk_cfg = LEDC_AUTO_CLK - }; - ledc_channel_config_t ch = { - .gpio_num = PRG32_PIN_BUZZER, - .speed_mode = LEDC_LOW_SPEED_MODE, - .channel = LEDC_CHANNEL_0, - .timer_sel = LEDC_TIMER_0, - .duty = 0, - .hpoint = 0 - }; - ledc_timer_config(&timer); - ledc_channel_config(&ch); + if (PRG32_PIN_BUZZER < 0) { + return; + } + ledc_timer_config_t timer = {.speed_mode = LEDC_LOW_SPEED_MODE, + .timer_num = LEDC_TIMER_0, + .duty_resolution = LEDC_TIMER_10_BIT, + .freq_hz = 1000, + .clk_cfg = LEDC_AUTO_CLK}; + ledc_channel_config_t ch = {.gpio_num = PRG32_PIN_BUZZER, + .speed_mode = LEDC_LOW_SPEED_MODE, + .channel = LEDC_CHANNEL_0, + .timer_sel = LEDC_TIMER_0, + .duty = 0, + .hpoint = 0}; + ledc_timer_config(&timer); + ledc_channel_config(&ch); } void prg32_audio_beep(uint32_t hz, uint32_t ms) { - prg32_audio_tone(hz, ms, 512); + prg32_audio_tone(hz, ms, 512); } -void prg32_audio_tone(uint32_t hz, uint32_t ms, uint16_t duty) { - if (PRG32_PIN_BUZZER < 0) { - return; - } - if (!hz || !ms) { - return; - } - if (duty > 1023) { - duty = 1023; - } - prg32_audio_led_vu_level((uint8_t)(duty / 4u)); - ledc_set_freq(LEDC_LOW_SPEED_MODE, LEDC_TIMER_0, hz); - ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty); - ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0); - vTaskDelay(pdMS_TO_TICKS(ms)); - ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 0); - ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0); - prg32_audio_led_vu_level(0); -} +#if 0 +// Kept for reference (square wave / "buzzer") +static const uint8_t builtin_beep_wave[32] = { + 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, + 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, +}; +#endif -void prg32_audio_note(uint8_t midi_note, uint32_t ms) { +static const uint8_t setup_audio_wave[] = { + 128, 176, 218, 245, 255, 245, 218, 176, 128, 80, 38, 11, 1, 11, 38, 80, + 128, 176, 218, 245, 255, 245, 218, 176, 128, 80, 38, 11, 1, 11, 38, 80, +}; + +static uint8_t hz_to_midi_note(uint32_t hz) { + if (hz < 16) + return 0; + uint8_t best_note = 60; + uint32_t best_diff = 999999; + for (uint8_t note = 12; note < 120; ++note) { static const uint16_t octave4[12] = { 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, }; - uint32_t freq = octave4[midi_note % 12u]; - int octave = (int)(midi_note / 12u) - 1; + uint32_t freq = octave4[note % 12u]; + int octave = (int)(note / 12u) - 1; while (octave > 4) { - freq *= 2u; - octave--; + freq *= 2u; + octave--; } while (octave < 4 && freq > 1u) { - freq /= 2u; - octave++; + freq /= 2u; + octave++; + } + uint32_t diff = (hz > freq) ? (hz - freq) : (freq - hz); + if (diff < best_diff) { + best_diff = diff; + best_note = note; } - prg32_audio_tone(freq, ms, 512); + } + return best_note; } -void prg32_audio_play_notes(const prg32_note_t *notes, size_t count) { - if (!notes) { - return; +void prg32_audio_tone(uint32_t hz, uint32_t ms, uint16_t duty) { + if (PRG32_PIN_BUZZER < 0) { +#if CONFIG_PRG32_AUDIO_ENABLED + if (prg32_audio_is_ready() && hz > 0 && ms > 0) { + prg32_instrument_desc_t inst = { + .sample_id = 63, + .default_volume = 192, + .default_pan = PRG32_AUDIO_PAN_CENTER, + .attack = 0, + .decay = 0, + .sustain = 255, + .release = 0, + }; + prg32_audio_register_sample( + 63, setup_audio_wave, sizeof(setup_audio_wave), 60, + PRG32_AUDIO_SAMPLE_LOOP, 0, sizeof(setup_audio_wave)); + prg32_audio_register_instrument(31, &inst); + + uint8_t midi_note = hz_to_midi_note(hz); + uint8_t vol = (duty > 1023) ? 255 : (uint8_t)(duty / 4u); + prg32_audio_led_vu_level(vol); + prg32_audio_note_on(0, 31, midi_note, vol); + vTaskDelay(pdMS_TO_TICKS(ms)); + prg32_audio_note_off(0); + prg32_audio_led_vu_level(0); + } else if (ms > 0) { + vTaskDelay(pdMS_TO_TICKS(ms)); } - for (size_t i = 0; i < count; ++i) { - if (notes[i].frequency_hz == 0) { - vTaskDelay(pdMS_TO_TICKS(notes[i].duration_ms)); - } else { - prg32_audio_tone(notes[i].frequency_hz, - notes[i].duration_ms, - 512); - } +#else + if (ms > 0) { + vTaskDelay(pdMS_TO_TICKS(ms)); } +#endif + return; + } + if (!hz || !ms) { + return; + } + if (duty > 1023) { + duty = 1023; + } + prg32_audio_led_vu_level((uint8_t)(duty / 4u)); + ledc_set_freq(LEDC_LOW_SPEED_MODE, LEDC_TIMER_0, hz); + ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty); + ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0); + vTaskDelay(pdMS_TO_TICKS(ms)); + ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 0); + ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0); + prg32_audio_led_vu_level(0); } -void prg32_audio_sample_u8(const uint8_t *samples, - size_t count, - uint32_t sample_rate) { - if (PRG32_PIN_BUZZER < 0 || !samples || count == 0 || sample_rate == 0) { - return; - } - uint32_t delay_us = 1000000u / sample_rate; - if (delay_us == 0) { - delay_us = 1; +void prg32_audio_note(uint8_t midi_note, uint32_t ms) { + static const uint16_t octave4[12] = { + 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, + }; + uint32_t freq = octave4[midi_note % 12u]; + int octave = (int)(midi_note / 12u) - 1; + while (octave > 4) { + freq *= 2u; + octave--; + } + while (octave < 4 && freq > 1u) { + freq /= 2u; + octave++; + } + prg32_audio_tone(freq, ms, 512); +} + +void prg32_audio_play_notes(const prg32_note_t *notes, size_t count) { + if (!notes) { + return; + } + for (size_t i = 0; i < count; ++i) { + if (notes[i].frequency_hz == 0) { + vTaskDelay(pdMS_TO_TICKS(notes[i].duration_ms)); + } else { + prg32_audio_tone(notes[i].frequency_hz, notes[i].duration_ms, 512); } - ledc_set_freq(LEDC_LOW_SPEED_MODE, LEDC_TIMER_0, sample_rate); - for (size_t i = 0; i < count; ++i) { - ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, samples[i] * 4u); - ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0); - esp_rom_delay_us(delay_us); + } +} + +void prg32_audio_sample_u8(const uint8_t *samples, size_t count, + uint32_t sample_rate) { + if (PRG32_PIN_BUZZER < 0 || !samples || count == 0 || sample_rate == 0) { + if (PRG32_PIN_BUZZER < 0 && sample_rate > 0 && count > 0) { + uint32_t ms = (count * 1000u) / sample_rate; + if (ms > 0) { + vTaskDelay(pdMS_TO_TICKS(ms)); + } } - ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 0); + return; + } + uint32_t delay_us = 1000000u / sample_rate; + if (delay_us == 0) { + delay_us = 1; + } + ledc_set_freq(LEDC_LOW_SPEED_MODE, LEDC_TIMER_0, sample_rate); + for (size_t i = 0; i < count; ++i) { + ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, samples[i] * 4u); ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0); + esp_rom_delay_us(delay_us); + } + ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 0); + ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0); } diff --git a/main/prg32_config.h b/main/prg32_config.h index 9bd186e..88e3c80 100644 --- a/main/prg32_config.h +++ b/main/prg32_config.h @@ -88,11 +88,11 @@ #define PRG32_PIN_LCD_MISO 2 #define PRG32_PIN_LCD_SCLK 6 #define PRG32_PIN_LCD_CS 10 -#define PRG32_PIN_LCD_DC 8 -#define PRG32_PIN_LCD_RST 9 +#define PRG32_PIN_LCD_DC 1 +#define PRG32_PIN_LCD_RST 0 #define PRG32_PIN_LCD_BL 5 -#define PRG32_LCD_SPI_CLOCK_HZ 40000000 +#define PRG32_LCD_SPI_CLOCK_HZ 32000000 #define PRG32_LCD_BACKLIGHT_ACTIVE_LEVEL 1 #define PRG32_LCD_BOOT_TEST_MS 0 #define PRG32_LCD_SOFT_SPI 0 @@ -103,7 +103,7 @@ #define PRG32_PIN_BTN_DOWN 13 #define PRG32_PIN_BTN_LEFT 18 #define PRG32_PIN_BTN_RIGHT 19 -#define PRG32_PIN_BTN_START 14 +#define PRG32_PIN_BTN_START 20 #define PRG32_PIN_BTN_A 21 #define PRG32_PIN_BTN_B 22 @@ -117,7 +117,7 @@ #define PRG32_PIN_P2_A -1 #define PRG32_PIN_P2_B -1 #define PRG32_PIN_P2_START -1 -#define PRG32_PIN_BUZZER 15 +#define PRG32_PIN_BUZZER -1 /* Optional USB-controller support via an external USB HID host bridge. */ #define PRG32_CONTROLLER_BRIDGE_ENABLE -1 From 298de0166e708a47e76b65fec64bb99c1e281fe4 Mon Sep 17 00:00:00 2001 From: Simone Boscaglia Date: Mon, 8 Jun 2026 19:04:42 +0200 Subject: [PATCH 04/24] fix: set PRG32_PIN_RGB_LED to 8 --- main/prg32_config.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/prg32_config.h b/main/prg32_config.h index 88e3c80..ea3e501 100644 --- a/main/prg32_config.h +++ b/main/prg32_config.h @@ -132,7 +132,7 @@ * is disabled by default. Set this to the board LED GPIO only when that pin is * free in your hardware variant. */ -#define PRG32_PIN_RGB_LED -1 +#define PRG32_PIN_RGB_LED 8 #endif /* From 49b2ecaff05fde92e73a636ed46d46adc5d24899 Mon Sep 17 00:00:00 2001 From: raffmont Date: Mon, 8 Jun 2026 19:27:46 +0200 Subject: [PATCH 05/24] Update cartridge store integration --- .vscode/c_cpp_properties.json | 26 +- .vscode/launch.json | 6 +- CMakeLists.txt | 23 ++ README.md | 4 +- components/prg32/include/prg32.h | 5 + components/prg32/prg32_cart.c | 70 ++++ components/prg32/prg32_gfx_sync.c | 18 + components/prg32/prg32_http_games.c | 493 ++++++++++------------ components/prg32/prg32_setup_store.c | 504 ++++++++++++++++++++++- components/prg32/prg32_system.c | 61 ++- components/prg32/prg32_wifi.c | 403 +++++++++++++++++- dependencies.lock | 2 +- docs/api.md | 76 +++- docs/cartridge_store.md | 12 +- docs/getting_started_game_development.md | 16 +- main/prg32_config.h | 4 +- platformio.ini | 2 - tools/prg32_game.py | 9 +- 18 files changed, 1382 insertions(+), 352 deletions(-) diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json index b5bd2a1..fa53470 100644 --- a/.vscode/c_cpp_properties.json +++ b/.vscode/c_cpp_properties.json @@ -9,7 +9,7 @@ "name": "PlatformIO", "includePath": [ "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/riscv/include", - "/Users/raffaelemontella/Documents/devel/PRG32/.pio/build/prg32-esp32c6/config", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/.pio/build/prg32-esp32c6/config", "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/newlib/platform_include", "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/freertos/config/include", "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/freertos/config/include/freertos", @@ -166,18 +166,18 @@ "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/rt/include", "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/spiffs/include", "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/wifi_provisioning/include", - "/Users/raffaelemontella/Documents/devel/PRG32/components/prg32_audio/include", - "/Users/raffaelemontella/Documents/devel/PRG32/managed_components/espressif__esp_websocket_client/include", - "/Users/raffaelemontella/Documents/devel/PRG32/managed_components/espressif__mdns/include", - "/Users/raffaelemontella/Documents/devel/PRG32/components/prg32/include", - "/Users/raffaelemontella/Documents/devel/PRG32/main", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/components/prg32_audio/include", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/managed_components/espressif__esp_websocket_client/include", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/managed_components/espressif__mdns/include", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/components/prg32/include", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/main", "" ], "browse": { "limitSymbolsToIncludedHeaders": true, "path": [ "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/riscv/include", - "/Users/raffaelemontella/Documents/devel/PRG32/.pio/build/prg32-esp32c6/config", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/.pio/build/prg32-esp32c6/config", "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/newlib/platform_include", "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/freertos/config/include", "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/freertos/config/include/freertos", @@ -334,11 +334,11 @@ "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/rt/include", "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/spiffs/include", "/Users/raffaelemontella/.platformio/packages/framework-espidf/components/wifi_provisioning/include", - "/Users/raffaelemontella/Documents/devel/PRG32/components/prg32_audio/include", - "/Users/raffaelemontella/Documents/devel/PRG32/managed_components/espressif__esp_websocket_client/include", - "/Users/raffaelemontella/Documents/devel/PRG32/managed_components/espressif__mdns/include", - "/Users/raffaelemontella/Documents/devel/PRG32/components/prg32/include", - "/Users/raffaelemontella/Documents/devel/PRG32/main", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/components/prg32_audio/include", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/managed_components/espressif__esp_websocket_client/include", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/managed_components/espressif__mdns/include", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/components/prg32/include", + "/Users/raffaelemontella/PlatformioIOProjects/PRG32/main", "" ] }, @@ -349,10 +349,10 @@ "_GLIBCXX_HAVE_POSIX_SEMAPHORE", "SOC_XTAL_FREQ_MHZ=CONFIG_XTAL_FREQ", "SOC_MMU_PAGE_SIZE=CONFIG_MMU_PAGE_SIZE", + "PRG32_FIRMWARE_VERSION=\"ab088fd-dirty\"", "IDF_VER=\"5.4.1\"", "ESP_PLATFORM", "PLATFORMIO=60119", - "PRG32_FIRMWARE_VERSION=\"pio-dev\"", "" ], "cStandard": "gnu17", diff --git a/.vscode/launch.json b/.vscode/launch.json index ec641f5..7526738 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -21,7 +21,7 @@ "type": "platformio-debug", "request": "launch", "name": "PIO Debug", - "executable": "/Users/raffaelemontella/Documents/devel/PRG32/.pio/build/prg32-esp32c6/firmware.elf", + "executable": "/Users/raffaelemontella/PlatformioIOProjects/PRG32/.pio/build/prg32-esp32c6/firmware.elf", "projectEnvName": "prg32-esp32c6", "toolchainBinDir": "/Users/raffaelemontella/.platformio/packages/toolchain-riscv32-esp/bin", "internalConsoleOptions": "openOnSessionStart", @@ -34,7 +34,7 @@ "type": "platformio-debug", "request": "launch", "name": "PIO Debug (skip Pre-Debug)", - "executable": "/Users/raffaelemontella/Documents/devel/PRG32/.pio/build/prg32-esp32c6/firmware.elf", + "executable": "/Users/raffaelemontella/PlatformioIOProjects/PRG32/.pio/build/prg32-esp32c6/firmware.elf", "projectEnvName": "prg32-esp32c6", "toolchainBinDir": "/Users/raffaelemontella/.platformio/packages/toolchain-riscv32-esp/bin", "internalConsoleOptions": "openOnSessionStart" @@ -43,7 +43,7 @@ "type": "platformio-debug", "request": "launch", "name": "PIO Debug (without uploading)", - "executable": "/Users/raffaelemontella/Documents/devel/PRG32/.pio/build/prg32-esp32c6/firmware.elf", + "executable": "/Users/raffaelemontella/PlatformioIOProjects/PRG32/.pio/build/prg32-esp32c6/firmware.elf", "projectEnvName": "prg32-esp32c6", "toolchainBinDir": "/Users/raffaelemontella/.platformio/packages/toolchain-riscv32-esp/bin", "internalConsoleOptions": "openOnSessionStart", diff --git a/CMakeLists.txt b/CMakeLists.txt index 64616f4..6db6c01 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,3 +1,26 @@ cmake_minimum_required(VERSION 3.16) + +set(PRG32_FIRMWARE_VERSION_FALLBACK "0.0.0-dev") +find_package(Git QUIET) +if(GIT_FOUND) + execute_process( + COMMAND "${GIT_EXECUTABLE}" describe --tags --dirty --always + WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}" + OUTPUT_VARIABLE PRG32_FIRMWARE_VERSION + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) +endif() +if(NOT PRG32_FIRMWARE_VERSION) + set(PRG32_FIRMWARE_VERSION "${PRG32_FIRMWARE_VERSION_FALLBACK}") +endif() +set(PROJECT_VER "${PRG32_FIRMWARE_VERSION}") + include($ENV{IDF_PATH}/tools/cmake/project.cmake) project(PRG32) + +idf_build_set_property( + COMPILE_DEFINITIONS + "PRG32_FIRMWARE_VERSION=\"${PRG32_FIRMWARE_VERSION}\"" + APPEND +) diff --git a/README.md b/README.md index 5632d3c..8d1c743 100644 --- a/README.md +++ b/README.md @@ -544,9 +544,9 @@ for a step-by-step scientific-paper measurement workflow with screenshots. - `tools/prg32_game.py store-discover`: find CartridgeStore instances via mDNS. - `tools/prg32_game.py store-list`: print a CartridgeStore catalog table. - `tools/prg32_game.py store-download`: download a `.prg32` from a store. -- `tools/prg32_game.py publish`: build a cartridge and publish a store bundle. +- `tools/prg32_game.py publish`: build a cartridge and submit a store bundle. - `tools/prg32_game.py pack-bundle`: create a flat multi-architecture zip. -- `tools/prg32_game.py publish-bundle`: upload a prepared bundle. +- `tools/prg32_game.py publish-bundle`: submit a prepared bundle. See [docs/assets.md](docs/assets.md). diff --git a/components/prg32/include/prg32.h b/components/prg32/include/prg32.h index a0f8999..504832c 100644 --- a/components/prg32/include/prg32.h +++ b/components/prg32/include/prg32.h @@ -266,6 +266,10 @@ int prg32_cart_install_slot(uint8_t slot, size_t image_size, int persist); int prg32_cart_store_slot(uint8_t slot, const void *image, size_t image_size); +size_t prg32_cart_slot_size(uint8_t slot); +int prg32_cart_stream_begin(uint8_t slot, size_t image_size); +int prg32_cart_stream_write(uint8_t slot, size_t offset, const void *data, size_t len); +int prg32_cart_stream_end(uint8_t slot, size_t image_size); int prg32_cart_select_stored(void); int prg32_cart_select_slot(uint8_t slot); int prg32_cart_default_slot(void); @@ -287,6 +291,7 @@ void prg32_console_hex32(uint32_t value); void prg32_gfx_clear(uint16_t color); void prg32_gfx_present(void); void prg32_gfx_lock(void); +int prg32_gfx_try_lock(uint32_t timeout_ms); void prg32_gfx_unlock(void); void prg32_gfx_set_fullscreen(int enabled); int prg32_gfx_fullscreen_enabled(void); diff --git a/components/prg32/prg32_cart.c b/components/prg32/prg32_cart.c index 644dff8..98eaeaf 100644 --- a/components/prg32/prg32_cart.c +++ b/components/prg32/prg32_cart.c @@ -570,6 +570,76 @@ int prg32_cart_store_slot(uint8_t slot, const void *image, size_t image_size) { return 0; } +size_t prg32_cart_slot_size(uint8_t slot) { + const esp_partition_t *part = cart_partition_by_slot(slot); + return part ? part->size : 0; +} + +int prg32_cart_stream_begin(uint8_t slot, size_t image_size) { + if (slot >= PRG32_CART_SLOT_COUNT) { + set_error("invalid cartridge slot"); + return -1; + } + const esp_partition_t *part = cart_partition_by_slot(slot); + if (!part) { + set_errorf("%s partition not found", slot_name(slot)); + return -1; + } + if (image_size == 0 || image_size > part->size) { + set_errorf("cartridge is larger than %s partition", slot_name(slot)); + return -1; + } + if (lock_cart() != 0) { + set_error("failed to lock cartridge storage"); + return -1; + } + esp_err_t err = esp_partition_erase_range(part, 0, part->size); + unlock_cart(); + if (err != ESP_OK) { + set_errorf("failed to erase cartridge slot: %s", esp_err_to_name(err)); + return -1; + } + set_error("ok"); + return 0; +} + +int prg32_cart_stream_write(uint8_t slot, size_t offset, const void *data, size_t len) { + if (slot >= PRG32_CART_SLOT_COUNT || !data || len == 0) { + set_error("invalid cartridge stream write"); + return -1; + } + const esp_partition_t *part = cart_partition_by_slot(slot); + if (!part || offset > part->size || len > part->size - offset) { + set_error("cartridge stream write outside slot"); + return -1; + } + if (lock_cart() != 0) { + set_error("failed to lock cartridge storage"); + return -1; + } + esp_err_t err = esp_partition_write(part, offset, data, len); + unlock_cart(); + if (err != ESP_OK) { + set_errorf("failed to write cartridge chunk: %s", esp_err_to_name(err)); + return -1; + } + set_error("ok"); + return 0; +} + +int prg32_cart_stream_end(uint8_t slot, size_t image_size) { + prg32_cart_header_t stored_header; + size_t stored_size = 0; + if (read_stored_header(slot, &stored_header, &stored_size, NULL) != 0 || + stored_size != image_size) { + set_error("failed to verify streamed cartridge"); + return -1; + } + set_error("ok"); + ESP_LOGI(TAG, "cart stream: done %s size=%lu", slot_name(slot), (unsigned long)image_size); + return 0; +} + int prg32_cart_select_stored(void) { for (uint8_t slot = 0; slot < PRG32_CART_SLOT_COUNT; ++slot) { prg32_cart_header_t header; diff --git a/components/prg32/prg32_gfx_sync.c b/components/prg32/prg32_gfx_sync.c index a88f241..38cb144 100644 --- a/components/prg32/prg32_gfx_sync.c +++ b/components/prg32/prg32_gfx_sync.c @@ -28,6 +28,24 @@ void prg32_gfx_lock(void) { } } +int prg32_gfx_try_lock(uint32_t timeout_ms) { + prg32_gfx_lock_init(); + if (!g_gfx_lock) { + return -1; + } + TaskHandle_t self = xTaskGetCurrentTaskHandle(); + if (g_gfx_owner == self) { + g_gfx_depth++; + return 0; + } + if (xSemaphoreTake(g_gfx_lock, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) { + return -1; + } + g_gfx_owner = self; + g_gfx_depth = 1; + return 0; +} + void prg32_gfx_unlock(void) { TaskHandle_t self = xTaskGetCurrentTaskHandle(); if (g_gfx_lock && g_gfx_owner == self) { diff --git a/components/prg32/prg32_http_games.c b/components/prg32/prg32_http_games.c index 4bf93f7..944b34a 100644 --- a/components/prg32/prg32_http_games.c +++ b/components/prg32/prg32_http_games.c @@ -16,254 +16,122 @@ void prg32_http_register_score_handlers(httpd_handle_t server); static const char *TAG = "prg32_wifi"; static httpd_handle_t server; +enum { + BMP_HEADER_SIZE = 54, + BMP_DIB_SIZE = 40, + BMP_BPP = 24, + BMP_ROW_SIZE = ((PRG32_LCD_W * 3 + 3) & ~3), + BMP_IMAGE_SIZE = BMP_ROW_SIZE * PRG32_LCD_H, + BMP_FILE_SIZE = BMP_HEADER_SIZE + BMP_IMAGE_SIZE, + SCREENSHOT_ROWS = 1, +}; +static uint16_t screenshot_rgb[PRG32_LCD_W * SCREENSHOT_ROWS]; +static uint8_t screenshot_band[BMP_ROW_SIZE * SCREENSHOT_ROWS]; static void add_json_u32(cJSON *obj, const char *name, uint32_t value) { cJSON_AddNumberToObject(obj, name, (double)value); } -static void add_import(cJSON *imports, const char *name, uintptr_t addr) { - add_json_u32(imports, name, (uint32_t)addr); +static esp_err_t send_api_index(httpd_req_t *req) { + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, + "{\"ok\":true," + "\"service\":\"PRG32\"," + "\"endpoints\":[" + "{\"method\":\"GET\",\"path\":\"/api\",\"available\":true}," + "{\"method\":\"GET\",\"path\":\"/api/\",\"available\":true}," + "{\"method\":\"GET\",\"path\":\"/api/runtime\",\"available\":true}," + "{\"method\":\"GET\",\"path\":\"/api/games\",\"available\":true}," + "{\"method\":\"POST\",\"path\":\"/api/games\",\"available\":" +#if PRG32_GAME_UPLOAD_ENABLE + "true" +#else + "false" +#endif + "}," + "{\"method\":\"POST\",\"path\":\"/api/games/select\",\"available\":true}," + "{\"method\":\"GET\",\"path\":\"/api/screenshot.bmp\",\"available\":true}," + "{\"method\":\"GET\",\"path\":\"/api/performance.json\",\"available\":true}," + "{\"method\":\"GET\",\"path\":\"/api/scores\",\"available\":" +#if PRG32_WIFI_SCORES_ENABLE + "true" +#else + "false" +#endif + "}," + "{\"method\":\"POST\",\"path\":\"/api/scores\",\"available\":" +#if PRG32_WIFI_SCORES_ENABLE + "true" +#else + "false" +#endif + "}" + "]}"); + return ESP_OK; } static esp_err_t send_runtime(httpd_req_t *req) { - cJSON *root = cJSON_CreateObject(); - if (!root) { - httpd_resp_send_err(req, 500, "out of memory"); - return ESP_ERR_NO_MEM; - } - cJSON_AddStringToObject(root, "name", "PRG32"); - cJSON_AddStringToObject(root, "firmware_version", PRG32_FIRMWARE_VERSION); - cJSON_AddStringToObject(root, "cart_magic", PRG32_CART_MAGIC); - cJSON_AddNumberToObject(root, "cart_abi_major", PRG32_CART_ABI_MAJOR); - cJSON_AddNumberToObject(root, "cart_abi_minor", PRG32_CART_ABI_MINOR); - add_json_u32(root, "cart_load_addr", (uint32_t)prg32_cart_load_addr()); - add_json_u32(root, "cart_ram_size", (uint32_t)prg32_cart_ram_size()); - cJSON_AddBoolToObject(root, "cart_loaded", false); + char json[768]; + prg32_cart_info_t info; + bool have_cart = prg32_cart_get_info(&info) == 0; + bool qemu = #if CONFIG_PRG32_DISPLAY_QEMU_RGB - cJSON_AddBoolToObject(root, "qemu", true); + true; #else - cJSON_AddBoolToObject(root, "qemu", false); + false; #endif - prg32_cart_info_t info; - if (prg32_cart_get_info(&info) == 0) { - cJSON *cart = cJSON_AddObjectToObject(root, "cart"); - cJSON_AddStringToObject(cart, "name", info.name); - cJSON_AddBoolToObject(cart, "loaded", info.loaded != 0); - cJSON_AddBoolToObject(cart, "stored", info.stored != 0); - add_json_u32(cart, "code_size", info.code_size); - add_json_u32(cart, "mem_size", info.mem_size); - add_json_u32(cart, "audio_size", info.audio_size); - cJSON_AddBoolToObject(cart, "audio", info.audio != 0); - add_json_u32(cart, "generation", info.generation); - cJSON_AddBoolToObject(root, "cart_loaded", info.loaded != 0); + int written = snprintf(json, + sizeof(json), + "{" + "\"name\":\"PRG32\"," + "\"firmware_version\":\"%s\"," + "\"cart_magic\":\"%s\"," + "\"cart_abi_major\":%u," + "\"cart_abi_minor\":%u," + "\"cart_load_addr\":%lu," + "\"cart_ram_size\":%lu," + "\"cart_loaded\":%s," + "\"qemu\":%s," + "\"cart\":{" + "\"name\":\"%s\"," + "\"loaded\":%s," + "\"stored\":%s," + "\"code_size\":%lu," + "\"mem_size\":%lu," + "\"audio_size\":%lu," + "\"audio\":%s," + "\"generation\":%lu" + "}," + "\"diag\":{" + "\"frame_count\":%lu," + "\"input_state\":%lu" + "}" + "}", + PRG32_FIRMWARE_VERSION, + PRG32_CART_MAGIC, + (unsigned)PRG32_CART_ABI_MAJOR, + (unsigned)PRG32_CART_ABI_MINOR, + (unsigned long)(uint32_t)prg32_cart_load_addr(), + (unsigned long)(uint32_t)prg32_cart_ram_size(), + have_cart && info.loaded ? "true" : "false", + qemu ? "true" : "false", + have_cart ? info.name : "", + have_cart && info.loaded ? "true" : "false", + have_cart && info.stored ? "true" : "false", + (unsigned long)(have_cart ? info.code_size : 0), + (unsigned long)(have_cart ? info.mem_size : 0), + (unsigned long)(have_cart ? info.audio_size : 0), + have_cart && info.audio ? "true" : "false", + (unsigned long)(have_cart ? info.generation : 0), + (unsigned long)prg32_diag_frame_count(), + (unsigned long)prg32_diag_input_state()); + if (written < 0 || (size_t)written >= sizeof(json)) { + httpd_resp_send_err(req, 500, "runtime json too large"); + return ESP_FAIL; } - - cJSON *diag = cJSON_AddObjectToObject(root, "diag"); - add_json_u32(diag, "frame_count", prg32_diag_frame_count()); - add_json_u32(diag, "input_state", prg32_diag_input_state()); - - cJSON *imports = cJSON_AddObjectToObject(root, "imports"); - add_import(imports, "prg32_ticks_ms", (uintptr_t)prg32_ticks_ms); - add_import(imports, "prg32_input_read", (uintptr_t)prg32_input_read); - add_import(imports, "prg32_input_read_player", - (uintptr_t)prg32_input_read_player); - add_import(imports, "prg32_input_read_menu", - (uintptr_t)prg32_input_read_menu); - add_import(imports, "prg32_controller_read", (uintptr_t)prg32_controller_read); - add_import(imports, "prg32_audio_beep", (uintptr_t)prg32_audio_beep); - add_import(imports, "prg32_audio_tone", (uintptr_t)prg32_audio_tone); - add_import(imports, "prg32_audio_note", (uintptr_t)prg32_audio_note); - add_import(imports, "prg32_audio_play_notes", - (uintptr_t)prg32_audio_play_notes); - add_import(imports, "prg32_audio_sample_u8", - (uintptr_t)prg32_audio_sample_u8); - add_import(imports, "prg32_rgb_led_init", (uintptr_t)prg32_rgb_led_init); - add_import(imports, "prg32_rgb_led_available", - (uintptr_t)prg32_rgb_led_available); - add_import(imports, "prg32_rgb_led_set", (uintptr_t)prg32_rgb_led_set); - add_import(imports, "prg32_rgb_led_off", (uintptr_t)prg32_rgb_led_off); - add_import(imports, "prg32_rgb_led_vu", (uintptr_t)prg32_rgb_led_vu); - add_import(imports, "prg32_audio_led_vu_enable", - (uintptr_t)prg32_audio_led_vu_enable); - add_import(imports, "prg32_audio_led_vu_enabled", - (uintptr_t)prg32_audio_led_vu_enabled); - add_import(imports, "prg32_audio_led_vu_level", - (uintptr_t)prg32_audio_led_vu_level); - add_import(imports, "prg32_metrics_init", (uintptr_t)prg32_metrics_init); - add_import(imports, "prg32_metrics_start_run", - (uintptr_t)prg32_metrics_start_run); - add_import(imports, "prg32_metrics_stop_run", - (uintptr_t)prg32_metrics_stop_run); - add_import(imports, "prg32_metrics_is_enabled", - (uintptr_t)prg32_metrics_is_enabled); - add_import(imports, "prg32_metrics_record", (uintptr_t)prg32_metrics_record); - add_import(imports, "prg32_metrics_run_id", (uintptr_t)prg32_metrics_run_id); - add_import(imports, "prg32_performance_test_run", - (uintptr_t)prg32_performance_test_run); - add_import(imports, "prg32_performance_has_results", - (uintptr_t)prg32_performance_has_results); - add_import(imports, "prg32_performance_summary", - (uintptr_t)prg32_performance_summary); - add_import(imports, "prg32_performance_json_alloc", - (uintptr_t)prg32_performance_json_alloc); - add_import(imports, "prg32_performance_json_free", - (uintptr_t)prg32_performance_json_free); - add_import(imports, "prg32_audio_init", (uintptr_t)prg32_audio_init); - add_import(imports, "prg32_audio_shutdown", (uintptr_t)prg32_audio_shutdown); - add_import(imports, "prg32_audio_get_mode", (uintptr_t)prg32_audio_get_mode); - add_import(imports, "prg32_audio_play_sample", - (uintptr_t)prg32_audio_play_sample); - add_import(imports, "prg32_audio_play_sample_pan", - (uintptr_t)prg32_audio_play_sample_pan); - add_import(imports, "prg32_audio_stop_channel", - (uintptr_t)prg32_audio_stop_channel); - add_import(imports, "prg32_audio_stop_all", (uintptr_t)prg32_audio_stop_all); - add_import(imports, "prg32_audio_note_on", (uintptr_t)prg32_audio_note_on); - add_import(imports, "prg32_audio_note_on_pan", - (uintptr_t)prg32_audio_note_on_pan); - add_import(imports, "prg32_audio_note_off", (uintptr_t)prg32_audio_note_off); - add_import(imports, "prg32_audio_play_track", - (uintptr_t)prg32_audio_play_track); - add_import(imports, "prg32_audio_stop_track", - (uintptr_t)prg32_audio_stop_track); - add_import(imports, "prg32_audio_set_tempo", - (uintptr_t)prg32_audio_set_tempo); - add_import(imports, "prg32_audio_set_master_volume", - (uintptr_t)prg32_audio_set_master_volume); - add_import(imports, "prg32_audio_set_channel_volume", - (uintptr_t)prg32_audio_set_channel_volume); - add_import(imports, "prg32_audio_set_channel_pan", - (uintptr_t)prg32_audio_set_channel_pan); - add_import(imports, "prg32_wifi_start_mode", - (uintptr_t)prg32_wifi_start_mode); - add_import(imports, "prg32_wifi_current_mode", - (uintptr_t)prg32_wifi_current_mode); - add_import(imports, "prg32_wifi_current_ip", - (uintptr_t)prg32_wifi_current_ip); - add_import(imports, "prg32_wifi_current_ssid", - (uintptr_t)prg32_wifi_current_ssid); - add_import(imports, "prg32_wifi_setup_requested", - (uintptr_t)prg32_wifi_setup_requested); - add_import(imports, "prg32_wifi_setup_run", - (uintptr_t)prg32_wifi_setup_run); - add_import(imports, "prg32_cart_stored_count", - (uintptr_t)prg32_cart_stored_count); - add_import(imports, "prg32_cart_get_slot_info", - (uintptr_t)prg32_cart_get_slot_info); - add_import(imports, "prg32_cart_store_slot", - (uintptr_t)prg32_cart_store_slot); - add_import(imports, "prg32_cart_select_slot", - (uintptr_t)prg32_cart_select_slot); - add_import(imports, "prg32_console_clear", (uintptr_t)prg32_console_clear); - add_import(imports, "prg32_console_putc", (uintptr_t)prg32_console_putc); - add_import(imports, "prg32_console_write", (uintptr_t)prg32_console_write); - add_import(imports, "prg32_console_hex32", (uintptr_t)prg32_console_hex32); - add_import(imports, "prg32_gfx_clear", (uintptr_t)prg32_gfx_clear); - add_import(imports, "prg32_gfx_present", (uintptr_t)prg32_gfx_present); - add_import(imports, "prg32_gfx_lock", (uintptr_t)prg32_gfx_lock); - add_import(imports, "prg32_gfx_unlock", (uintptr_t)prg32_gfx_unlock); - add_import(imports, "prg32_gfx_set_fullscreen", - (uintptr_t)prg32_gfx_set_fullscreen); - add_import(imports, "prg32_gfx_fullscreen_enabled", - (uintptr_t)prg32_gfx_fullscreen_enabled); - add_import(imports, "prg32_gfx_set_band_color", - (uintptr_t)prg32_gfx_set_band_color); - add_import(imports, "prg32_gfx_use_background_bands", - (uintptr_t)prg32_gfx_use_background_bands); - add_import(imports, "prg32_band_set_mode", (uintptr_t)prg32_band_set_mode); - add_import(imports, "prg32_band_mode", (uintptr_t)prg32_band_mode); - add_import(imports, "prg32_band_mode_name", (uintptr_t)prg32_band_mode_name); - add_import(imports, "prg32_band_set_text", (uintptr_t)prg32_band_set_text); - add_import(imports, "prg32_band_set_game_info", - (uintptr_t)prg32_band_set_game_info); - add_import(imports, "prg32_band_log", (uintptr_t)prg32_band_log); - add_import(imports, "prg32_band_set_colors", - (uintptr_t)prg32_band_set_colors); - add_import(imports, "prg32_band_use_default_colors", - (uintptr_t)prg32_band_use_default_colors); - add_import(imports, "prg32_band_load_config", - (uintptr_t)prg32_band_load_config); - add_import(imports, "prg32_band_save_config", - (uintptr_t)prg32_band_save_config); - add_import(imports, "prg32_gfx_pixel", (uintptr_t)prg32_gfx_pixel); - add_import(imports, "prg32_gfx_rect", (uintptr_t)prg32_gfx_rect); - add_import(imports, "prg32_gfx_text8", (uintptr_t)prg32_gfx_text8); - add_import(imports, "prg32_gfx_snapshot_row_rgb565", - (uintptr_t)prg32_gfx_snapshot_row_rgb565); - add_import(imports, "prg32_splash_draw", (uintptr_t)prg32_splash_draw); - add_import(imports, "prg32_splash_show", (uintptr_t)prg32_splash_show); - add_import(imports, "prg32_splash_draw_game", - (uintptr_t)prg32_splash_draw_game); - add_import(imports, "prg32_splash_show_game", - (uintptr_t)prg32_splash_show_game); - add_import(imports, "prg32_splash_show_default", - (uintptr_t)prg32_splash_show_default); - add_import(imports, "prg32_debug_overlay_draw", (uintptr_t)prg32_debug_overlay_draw); - add_import(imports, "prg32_keyboard_init", (uintptr_t)prg32_keyboard_init); - add_import(imports, "prg32_keyboard_update", (uintptr_t)prg32_keyboard_update); - add_import(imports, "prg32_keyboard_draw", (uintptr_t)prg32_keyboard_draw); - add_import(imports, "prg32_text_input", (uintptr_t)prg32_text_input); - add_import(imports, "prg32_tile_clear", (uintptr_t)prg32_tile_clear); - add_import(imports, "prg32_tile_define", (uintptr_t)prg32_tile_define); - add_import(imports, "prg32_tile_put", (uintptr_t)prg32_tile_put); - add_import(imports, "prg32_tile_present", (uintptr_t)prg32_tile_present); - add_import(imports, "prg32_playfield_clear", (uintptr_t)prg32_playfield_clear); - add_import(imports, "prg32_playfield_put", (uintptr_t)prg32_playfield_put); - add_import(imports, "prg32_playfield_get", (uintptr_t)prg32_playfield_get); - add_import(imports, "prg32_playfield_scroll", (uintptr_t)prg32_playfield_scroll); - add_import(imports, "prg32_playfield_scroll_by", - (uintptr_t)prg32_playfield_scroll_by); - add_import(imports, "prg32_playfield_parallax", - (uintptr_t)prg32_playfield_parallax); - add_import(imports, "prg32_playfield_camera", (uintptr_t)prg32_playfield_camera); - add_import(imports, "prg32_playfield_camera_x", - (uintptr_t)prg32_playfield_camera_x); - add_import(imports, "prg32_playfield_camera_y", - (uintptr_t)prg32_playfield_camera_y); - add_import(imports, "prg32_playfield_draw", (uintptr_t)prg32_playfield_draw); - add_import(imports, "prg32_playfield_draw_dual", - (uintptr_t)prg32_playfield_draw_dual); - add_import(imports, "prg32_playfield_present", (uintptr_t)prg32_playfield_present); - add_import(imports, "prg32_platform_tile_flags", - (uintptr_t)prg32_platform_tile_flags); - add_import(imports, "prg32_platform_tile_flags_get", - (uintptr_t)prg32_platform_tile_flags_get); - add_import(imports, "prg32_platform_tile_at", - (uintptr_t)prg32_platform_tile_at); - add_import(imports, "prg32_platform_solid_at", - (uintptr_t)prg32_platform_solid_at); - add_import(imports, "prg32_platform_actor_init", - (uintptr_t)prg32_platform_actor_init); - add_import(imports, "prg32_platform_actor_move", - (uintptr_t)prg32_platform_actor_move); - add_import(imports, "prg32_platform_actor_step", - (uintptr_t)prg32_platform_actor_step); - add_import(imports, "prg32_platform_camera_follow", - (uintptr_t)prg32_platform_camera_follow); - add_import(imports, "prg32_sprite_hitbox", (uintptr_t)prg32_sprite_hitbox); - add_import(imports, "prg32_sprite_draw_8x8", (uintptr_t)prg32_sprite_draw_8x8); - add_import(imports, "prg32_sprite_draw_16x16", (uintptr_t)prg32_sprite_draw_16x16); - add_import(imports, "prg32_sprite_anim_frame", - (uintptr_t)prg32_sprite_anim_frame); - add_import(imports, "prg32_sprite_draw_frame", - (uintptr_t)prg32_sprite_draw_frame); - add_import(imports, "prg32_sprite_anim_init", (uintptr_t)prg32_sprite_anim_init); - add_import(imports, "prg32_sprite_anim_update", - (uintptr_t)prg32_sprite_anim_update); - add_import(imports, "prg32_sprite_anim_draw", (uintptr_t)prg32_sprite_anim_draw); - add_import(imports, "prg32_score_submit", (uintptr_t)prg32_score_submit); - - char *json = cJSON_PrintUnformatted(root); - cJSON_Delete(root); httpd_resp_set_type(req, "application/json"); - if (!json) { - httpd_resp_sendstr(req, "{}"); - return ESP_OK; - } - httpd_resp_sendstr(req, json); - cJSON_free(json); - return ESP_OK; + return httpd_resp_sendstr(req, json); } static esp_err_t get_games(httpd_req_t *req) { @@ -348,24 +216,31 @@ static void rgb565_to_bgr888(uint16_t color, uint8_t *bgr) { bgr[2] = (uint8_t)((r5 << 3) | (r5 >> 2)); } +static esp_err_t screenshot_send_all(httpd_req_t *req, int sock, const void *data, size_t length) { + const uint8_t *bytes = (const uint8_t *)data; + while (length > 0) { + size_t chunk = length > 64 ? 64 : length; + int sent = httpd_socket_send(req->handle, sock, (const char *)bytes, chunk, 0); + if (sent <= 0) { + return ESP_FAIL; + } + bytes += sent; + length -= (size_t)sent; + } + return ESP_OK; +} + static esp_err_t get_screenshot_bmp(httpd_req_t *req) { - enum { - BMP_HEADER_SIZE = 54, - BMP_BPP = 24, - BMP_ROW_SIZE = ((PRG32_LCD_W * 3 + 3) & ~3), - BMP_IMAGE_SIZE = BMP_ROW_SIZE * PRG32_LCD_H, - BMP_FILE_SIZE = BMP_HEADER_SIZE + BMP_IMAGE_SIZE, - }; uint8_t header[BMP_HEADER_SIZE] = {0}; - uint16_t rgb[PRG32_LCD_W]; - uint8_t row[BMP_ROW_SIZE]; + uint16_t *rgb = screenshot_rgb; + uint8_t *band = screenshot_band; esp_err_t err; header[0] = 'B'; header[1] = 'M'; put_le32(&header[2], BMP_FILE_SIZE); put_le32(&header[10], BMP_HEADER_SIZE); - put_le32(&header[14], 40); + put_le32(&header[14], BMP_DIB_SIZE); put_le32(&header[18], PRG32_LCD_W); put_le32(&header[22], PRG32_LCD_H); put_le16(&header[26], 1); @@ -374,53 +249,119 @@ static esp_err_t get_screenshot_bmp(httpd_req_t *req) { put_le32(&header[38], 2835); put_le32(&header[42], 2835); - prg32_gfx_lock(); - prg32_gfx_present(); + int sock = httpd_req_to_sockfd(req); + if (sock < 0) { + return ESP_FAIL; + } - httpd_resp_set_type(req, "image/bmp"); - httpd_resp_set_hdr(req, "Content-Disposition", "inline; filename=\"screenshot.bmp\""); - httpd_resp_set_hdr(req, "Cache-Control", "no-store"); + uint8_t first_packet[256]; + int http_header_len = snprintf((char *)first_packet, + sizeof(first_packet) - BMP_HEADER_SIZE, + "HTTP/1.1 200 OK\r\n" + "Content-Type: image/bmp\r\n" + "Content-Length: %u\r\n" + "Content-Disposition: inline; filename=\"screenshot.bmp\"\r\n" + "Cache-Control: no-store\r\n" + "Connection: close\r\n" + "\r\n", + (unsigned)BMP_FILE_SIZE); + if (http_header_len < 0 || + (size_t)http_header_len >= sizeof(first_packet) - BMP_HEADER_SIZE) { + return ESP_FAIL; + } + memcpy(&first_packet[http_header_len], header, BMP_HEADER_SIZE); - err = httpd_resp_send_chunk(req, (const char *)header, sizeof(header)); + err = screenshot_send_all(req, sock, first_packet, (size_t)http_header_len + BMP_HEADER_SIZE); if (err != ESP_OK) { goto out; } - for (int y = PRG32_LCD_H - 1; y >= 0; --y) { - if (prg32_gfx_snapshot_row_rgb565(y, rgb, PRG32_LCD_W) < 0) { - httpd_resp_sendstr_chunk(req, NULL); - err = ESP_FAIL; - goto out; + for (int y = PRG32_LCD_H - 1; y >= 0;) { + int first_y = y - SCREENSHOT_ROWS + 1; + if (first_y < 0) { + first_y = 0; } - for (int x = 0; x < PRG32_LCD_W; ++x) { - rgb565_to_bgr888(rgb[x], &row[x * 3]); + int rows = y - first_y + 1; + + for (int snap_y = first_y; snap_y <= y; ++snap_y) { + uint16_t *dst = &rgb[(snap_y - first_y) * PRG32_LCD_W]; + if (prg32_gfx_snapshot_row_rgb565(snap_y, dst, PRG32_LCD_W) < 0) { + err = ESP_FAIL; + goto out; + } + } + + for (int row_index = rows - 1; row_index >= 0; --row_index) { + const uint16_t *src = &rgb[row_index * PRG32_LCD_W]; + uint8_t *dst = &band[(rows - 1 - row_index) * BMP_ROW_SIZE]; + for (int x = 0; x < PRG32_LCD_W; ++x) { + rgb565_to_bgr888(src[x], &dst[x * 3]); + } } - err = httpd_resp_send_chunk(req, (const char *)row, BMP_ROW_SIZE); + err = screenshot_send_all(req, sock, band, (size_t)rows * BMP_ROW_SIZE); if (err != ESP_OK) { goto out; } + y = first_y - 1; } - err = httpd_resp_sendstr_chunk(req, NULL); out: - prg32_gfx_unlock(); return err; } -static esp_err_t get_performance_json(httpd_req_t *req) { - char *json = prg32_performance_json_alloc(); - if (!json) { - httpd_resp_send_err(req, 500, "out of memory"); - return ESP_ERR_NO_MEM; +typedef struct { + httpd_req_t *req; + char data[1024]; + size_t used; +} performance_http_stream_t; + +static int performance_http_flush(performance_http_stream_t *stream) { + if (!stream || !stream->req || stream->used == 0) { + return 0; + } + esp_err_t err = httpd_resp_send_chunk(stream->req, stream->data, stream->used); + stream->used = 0; + return err == ESP_OK ? 0 : -1; +} + +static int performance_http_writer(const char *chunk, void *ctx) { + performance_http_stream_t *stream = (performance_http_stream_t *)ctx; + if (!stream || !chunk) { + return -1; } + const char *p = chunk; + size_t remaining = strlen(chunk); + while (remaining > 0) { + size_t space = sizeof(stream->data) - stream->used; + if (space == 0 && performance_http_flush(stream) != 0) { + return -1; + } + space = sizeof(stream->data) - stream->used; + size_t n = remaining < space ? remaining : space; + memcpy(&stream->data[stream->used], p, n); + stream->used += n; + p += n; + remaining -= n; + } + return 0; +} + +static esp_err_t get_performance_json(httpd_req_t *req) { + performance_http_stream_t stream = { + .req = req, + .used = 0, + }; httpd_resp_set_type(req, "application/json"); httpd_resp_set_hdr(req, "Content-Disposition", "attachment; filename=\"prg32_performance.json\""); httpd_resp_set_hdr(req, "Cache-Control", "no-store"); - esp_err_t err = httpd_resp_sendstr(req, json); - prg32_performance_json_free(json); - return err; + int rc = prg32_performance_json_write(performance_http_writer, &stream); + if (rc != 0 || performance_http_flush(&stream) != 0) { + httpd_resp_sendstr_chunk(req, NULL); + return ESP_FAIL; + } + return httpd_resp_sendstr_chunk(req, NULL); } static esp_err_t post_game(httpd_req_t *req) { @@ -531,8 +472,10 @@ void prg32_scores_api_start(void) { return; } httpd_config_t cfg = HTTPD_DEFAULT_CONFIG(); - cfg.max_uri_handlers = 10; + cfg.max_uri_handlers = 12; cfg.recv_wait_timeout = 10; + cfg.send_wait_timeout = 60; + cfg.stack_size = 8192; esp_err_t err = httpd_start(&server, &cfg); if (err != ESP_OK) { ESP_LOGE(TAG, "HTTP server start failed: %s", esp_err_to_name(err)); @@ -544,6 +487,16 @@ void prg32_scores_api_start(void) { .method = HTTP_GET, .handler = send_runtime }; + httpd_uri_t api_root = { + .uri = "/api/", + .method = HTTP_GET, + .handler = send_api_index + }; + httpd_uri_t api = { + .uri = "/api", + .method = HTTP_GET, + .handler = send_api_index + }; httpd_uri_t games_get = { .uri = "/api/games", .method = HTTP_GET, @@ -569,6 +522,8 @@ void prg32_scores_api_start(void) { .method = HTTP_GET, .handler = get_performance_json }; + ESP_ERROR_CHECK(httpd_register_uri_handler(server, &api_root)); + ESP_ERROR_CHECK(httpd_register_uri_handler(server, &api)); ESP_ERROR_CHECK(httpd_register_uri_handler(server, &rt)); ESP_ERROR_CHECK(httpd_register_uri_handler(server, &games_get)); ESP_ERROR_CHECK(httpd_register_uri_handler(server, &games_post)); diff --git a/components/prg32/prg32_setup_store.c b/components/prg32/prg32_setup_store.c index 08b6d82..3c52d2c 100644 --- a/components/prg32/prg32_setup_store.c +++ b/components/prg32/prg32_setup_store.c @@ -1,12 +1,36 @@ #include "prg32.h" #include "prg32_config.h" +#include "esp_http_client.h" +#include "esp_log.h" +#include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include #include #include #define STORE_ACCEPT (PRG32_BTN_SELECT | PRG32_BTN_B) #define STORE_CANCEL PRG32_BTN_A +#if CONFIG_PRG32_DISPLAY_QEMU_RGB +#define STORE_MAX_GAMES 40 +#else +#define STORE_MAX_GAMES 64 +#endif +#define STORE_PAGE_SIZE 8 + +typedef struct { + char id[48]; + char title[32]; + char version[16]; + char summary[96]; + char arch[32]; + char tags[48]; +} store_game_t; + +static const char *TAG = "prg32_setup_store"; +static char catalog_body[PRG32_STORE_CATALOG_MAX_BYTES]; +static store_game_t games[STORE_MAX_GAMES]; +static int game_count; static void wait_and_show(const char *line, uint32_t ms) { prg32_gfx_clear(PRG32_COLOR_BLACK); @@ -25,6 +49,402 @@ static void normalize_store_url(char *url, size_t cap) { snprintf(url, cap, "%s", tmp); } +static int json_string_after(const char *start, const char *end, const char *key, char *out, size_t cap) { + char needle[24]; + snprintf(needle, sizeof(needle), "\"%s\"", key); + const char *p = start; + out[0] = '\0'; + while (p && p < end) { + p = strstr(p, needle); + if (!p || p >= end) { + return -1; + } + p = strchr(p + strlen(needle), ':'); + if (!p || p >= end) { + return -1; + } + p++; + while (p < end && isspace((unsigned char)*p)) { + p++; + } + if (p < end && *p == '"') { + p++; + size_t i = 0; + while (p < end && *p && *p != '"' && i + 1 < cap) { + out[i++] = *p++; + } + out[i] = '\0'; + return out[0] ? 0 : -1; + } + } + return -1; +} + +static int json_arches_after(const char *start, const char *end, char *out, size_t cap) { + const char *p = strstr(start, "\"architectures\""); + if (!p || p >= end) { + return -1; + } + p = strchr(p, '['); + const char *q = p ? strchr(p, ']') : NULL; + if (!p || !q || q >= end) { + return -1; + } + size_t i = 0; + while (p < q && i + 1 < cap) { + if (*p == '"') { + p++; + if (i && i + 2 < cap) { + out[i++] = ','; + out[i++] = ' '; + } + while (p < q && *p != '"' && i + 1 < cap) { + out[i++] = *p++; + } + } + p++; + } + out[i] = '\0'; + return out[0] ? 0 : -1; +} + +static int json_array_strings_after(const char *start, const char *end, const char *key, char *out, size_t cap) { + char needle[24]; + snprintf(needle, sizeof(needle), "\"%s\"", key); + const char *p = strstr(start, needle); + if (!p || p >= end) { + return -1; + } + p = strchr(p, '['); + const char *q = p ? strchr(p, ']') : NULL; + if (!p || !q || q >= end) { + return -1; + } + size_t i = 0; + while (p < q && i + 1 < cap) { + if (*p == '"') { + p++; + if (i && i + 2 < cap) { + out[i++] = ','; + out[i++] = ' '; + } + while (p < q && *p != '"' && i + 1 < cap) { + out[i++] = *p++; + } + } + p++; + } + out[i] = '\0'; + return out[0] ? 0 : -1; +} + +static int parse_catalog(const char *json) { + game_count = 0; + const char *p = json; + while ((p = strchr(p, '{')) != NULL && game_count < STORE_MAX_GAMES) { + const char *end = strchr(p, '}'); + if (!end) { + break; + } + store_game_t *g = &games[game_count]; + memset(g, 0, sizeof(*g)); + if (json_string_after(p, end, "id", g->id, sizeof(g->id)) == 0 && + json_string_after(p, end, "title", g->title, sizeof(g->title)) == 0) { + json_string_after(p, end, "version", g->version, sizeof(g->version)); + json_string_after(p, end, "summary", g->summary, sizeof(g->summary)); + json_arches_after(p, end, g->arch, sizeof(g->arch)); + json_array_strings_after(p, end, "tags", g->tags, sizeof(g->tags)); + game_count++; + } + p = end + 1; + } + return game_count; +} + +static int contains_casefold(const char *text, const char *needle) { + if (!needle || !needle[0]) { + return 1; + } + if (!text) { + return 0; + } + for (const char *p = text; *p; ++p) { + const char *a = p; + const char *b = needle; + while (*a && *b && + tolower((unsigned char)*a) == tolower((unsigned char)*b)) { + a++; + b++; + } + if (!*b) { + return 1; + } + } + return 0; +} + +static int filter_catalog(const char *query) { + store_game_t all_games[STORE_MAX_GAMES]; + int all_count = parse_catalog(catalog_body); + memcpy(all_games, games, sizeof(all_games)); + if (!query || !query[0]) { + return all_count; + } + game_count = 0; + for (int i = 0; i < all_count && game_count < STORE_MAX_GAMES; ++i) { + if (contains_casefold(all_games[i].title, query) || + contains_casefold(all_games[i].tags, query) || + contains_casefold(all_games[i].id, query)) { + games[game_count++] = all_games[i]; + } + } + return game_count; +} + +static int fetch_catalog(const char *base_url, char *status, size_t status_len) { + char url[PRG32_STORE_URL_MAX_LEN + 16]; + snprintf(url, sizeof(url), "%s/api/games", base_url); + ESP_LOGI(TAG, + "fetch catalog: %s heap=%lu", + url, + (unsigned long)esp_get_free_heap_size()); + memset(catalog_body, 0, sizeof(catalog_body)); + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_GET, + .timeout_ms = PRG32_STORE_HTTP_TIMEOUT_MS, + .keep_alive_enable = false, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { + ESP_LOGI(TAG, + "catalog client init failed heap=%lu", + (unsigned long)esp_get_free_heap_size()); + snprintf(status, status_len, "NO MEM"); + return -1; + } + esp_err_t err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + ESP_LOGI(TAG, "catalog open failed: %s", esp_err_to_name(err)); + snprintf(status, status_len, "%s", esp_err_to_name(err)); + esp_http_client_cleanup(client); + return -1; + } + int content_len = esp_http_client_fetch_headers(client); + int http_status = esp_http_client_get_status_code(client); + if (http_status < 200 || http_status >= 300) { + ESP_LOGI(TAG, "catalog fetch HTTP status=%d", http_status); + snprintf(status, status_len, "%d", http_status); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + bool truncated = false; + size_t len = 0; + while (len + 1 < sizeof(catalog_body)) { + int got = esp_http_client_read(client, + catalog_body + len, + sizeof(catalog_body) - len - 1); + if (got < 0) { + ESP_LOGI(TAG, "catalog read failed"); + snprintf(status, status_len, "READ"); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + if (got == 0) { + break; + } + len += (size_t)got; + catalog_body[len] = '\0'; + vTaskDelay(pdMS_TO_TICKS(1)); + } + if (content_len > 0 && (size_t)content_len > len) { + truncated = true; + } + esp_http_client_close(client); + esp_http_client_cleanup(client); + parse_catalog(catalog_body); + ESP_LOGI(TAG, + "catalog fetch ok: status=%d content_len=%d bytes=%lu games=%d", + http_status, + content_len, + (unsigned long)len, + game_count); + snprintf(status, status_len, truncated ? "first %d shown" : "OK", STORE_MAX_GAMES); + return 0; +} + +static const char *current_arch(void) { +#if CONFIG_PRG32_DISPLAY_QEMU_RGB + return PRG32_CART_ARCH_QEMU; +#else + return PRG32_CART_ARCH_ESP32C6; +#endif +} + +static int game_is_compatible(const store_game_t *game) { + return game && strstr(game->arch, current_arch()) != NULL; +} + +static int stream_download(const char *base_url, const store_game_t *game, uint8_t slot, char *status, size_t status_len) { + char url[256]; + snprintf(url, + sizeof(url), + "%s/api/games/%s/download?architecture=%s&version=%s", + base_url, + game->id, + current_arch(), + game->version[0] ? game->version : "latest"); + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_GET, + .timeout_ms = PRG32_STORE_HTTP_TIMEOUT_MS, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { + snprintf(status, status_len, "CLIENT"); + return -1; + } + if (esp_http_client_open(client, 0) != ESP_OK) { + esp_http_client_cleanup(client); + snprintf(status, status_len, "TIMEOUT"); + return -1; + } + int content_len = esp_http_client_fetch_headers(client); + int http_status = esp_http_client_get_status_code(client); + size_t slot_size = prg32_cart_slot_size(slot); + if (http_status < 200 || http_status >= 300) { + snprintf(status, status_len, "%d", http_status); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + if (content_len <= 0 || (size_t)content_len > slot_size) { + snprintf(status, status_len, "TOO LARGE"); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + if (prg32_cart_stream_begin(slot, (size_t)content_len) != 0) { + snprintf(status, status_len, "%s", prg32_cart_last_error()); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + uint8_t chunk[PRG32_STORE_CHUNK_BYTES]; + size_t offset = 0; + while (offset < (size_t)content_len) { + int read = esp_http_client_read(client, (char *)chunk, sizeof(chunk)); + if (read <= 0) { + snprintf(status, status_len, "TIMEOUT"); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + if (prg32_cart_stream_write(slot, offset, chunk, (size_t)read) != 0) { + snprintf(status, status_len, "%s", prg32_cart_last_error()); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + offset += (size_t)read; + vTaskDelay(pdMS_TO_TICKS(1)); + } + esp_http_client_close(client); + esp_http_client_cleanup(client); + if (prg32_cart_stream_end(slot, offset) != 0) { + snprintf(status, status_len, "%s", prg32_cart_last_error()); + return -1; + } + snprintf(status, status_len, "OK"); + ESP_LOGI(TAG, "downloaded %s to cart%u", game->id, (unsigned)slot); + return 0; +} + +static void draw_store_list(int selected, int page, const char *note) { + int pages = game_count > 0 ? (game_count + STORE_PAGE_SIZE - 1) / STORE_PAGE_SIZE : 1; + char line[48]; + prg32_gfx_clear(PRG32_COLOR_BLACK); + snprintf(line, sizeof(line), "BROWSE STORE [page %d/%d]", page + 1, pages); + prg32_gfx_text8(8, 8, line, PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 26, "SEARCH", selected == -1 ? PRG32_COLOR_GREEN : PRG32_COLOR_CYAN, 0); + int start = page * STORE_PAGE_SIZE; + for (int i = 0; i < STORE_PAGE_SIZE && start + i < game_count; ++i) { + const store_game_t *g = &games[start + i]; + int idx = start + i; + snprintf(line, sizeof(line), "%c %-24s %.8s", idx == selected ? '>' : ' ', g->title, g->version); + prg32_gfx_text8(8, 46 + i * 18, line, game_is_compatible(g) ? PRG32_COLOR_WHITE : PRG32_COLOR_YELLOW, 0); + } + if (note && note[0]) { + prg32_gfx_text8(8, 190, note, PRG32_COLOR_YELLOW, 0); + } + prg32_gfx_text8(8, 216, "U/D SCROLL L/R PAGE SELECT DETAILS A BACK", PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); +} + +static int run_detail(const char *base_url, int index) { + uint8_t slot = 0; + const store_game_t *g = &games[index]; + prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); + while (1) { + char line[48]; + uint16_t action_color = game_is_compatible(g) ? PRG32_COLOR_CYAN : PRG32_COLOR_YELLOW; + prg32_gfx_clear(PRG32_COLOR_BLACK); + snprintf(line, sizeof(line), "%.24s v%.10s", g->title, g->version); + prg32_gfx_text8(8, 8, line, PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 36, g->summary, PRG32_COLOR_CYAN, 0); + snprintf(line, sizeof(line), "Architectures: %s", game_is_compatible(g) ? current_arch() : "(none)"); + prg32_gfx_text8(8, 96, line, action_color, 0); + snprintf(line, sizeof(line), "Slot: cart%u", (unsigned)slot); + prg32_gfx_text8(8, 126, line, PRG32_COLOR_WHITE, 0); + if (!game_is_compatible(g)) { + prg32_gfx_text8(8, 154, "NOT COMPATIBLE WITH THIS FIRMWARE", PRG32_COLOR_YELLOW, 0); + } + prg32_gfx_text8(8, 216, "U/D SLOT SELECT DOWNLOAD A BACK", action_color, 0); + prg32_gfx_present(); + uint32_t input = prg32_input_read_menu(); + if (input & PRG32_BTN_UP) { + slot = (slot + PRG32_CART_SLOT_COUNT - 1) % PRG32_CART_SLOT_COUNT; + prg32_input_wait_released(PRG32_BTN_UP); + } else if (input & PRG32_BTN_DOWN) { + slot = (slot + 1) % PRG32_CART_SLOT_COUNT; + prg32_input_wait_released(PRG32_BTN_DOWN); + } else if (input & STORE_CANCEL) { + prg32_input_wait_released(STORE_CANCEL); + return 0; + } else if ((input & STORE_ACCEPT) && game_is_compatible(g)) { + char status[48]; + wait_and_show("DOWNLOADING...", 10); + if (stream_download(base_url, g, slot, status, sizeof(status)) == 0) { + wait_and_show("INSTALLED", 2000); + while (1) { + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "INSTALLED", PRG32_COLOR_GREEN, 0); + prg32_gfx_text8(8, 216, "SELECT RUN NOW A BACK", PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); + uint32_t run_input = prg32_input_read_menu(); + if (run_input & PRG32_BTN_SELECT) { + prg32_cart_select_slot(slot); + prg32_input_wait_released(PRG32_BTN_SELECT); + return 1; + } + if (run_input & STORE_CANCEL) { + prg32_input_wait_released(STORE_CANCEL); + break; + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "FAILED: %s", status); + wait_and_show(msg, 3000); + } + } + vTaskDelay(pdMS_TO_TICKS(10)); + } +} + void prg32_setup_store_run(void) { int choice = 0; static const char *items[] = { @@ -119,26 +539,82 @@ void prg32_setup_store_run(void) { } void prg32_setup_store_browse_run(void) { + prg32_wifi_scores_init(); + prg32_scores_api_start(); char url[PRG32_STORE_URL_MAX_LEN]; if (prg32_store_url_resolve(url, sizeof(url)) != 0) { wait_and_show("CONFIGURE STORE FIRST", 2000); return; } - char name[64] = ""; + char status[32]; wait_and_show("CONNECTING...", 10); - if (prg32_store_ping(url, name, sizeof(name)) == 0) { - prg32_gfx_clear(PRG32_COLOR_BLACK); - prg32_gfx_text8(8, 8, "BROWSE STORE", PRG32_COLOR_WHITE, 0); - prg32_gfx_text8(8, 40, name[0] ? name : "STORE AVAILABLE", PRG32_COLOR_GREEN, 0); - prg32_gfx_text8(8, 64, "CATALOG BROWSER TODO", PRG32_COLOR_YELLOW, 0); - prg32_gfx_text8(8, 216, "A / SELECT / B BACK", PRG32_COLOR_CYAN, 0); - prg32_gfx_present(); - prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); - while (!(prg32_input_read_menu() & (STORE_ACCEPT | STORE_CANCEL))) { - vTaskDelay(pdMS_TO_TICKS(10)); + if (fetch_catalog(url, status, sizeof(status)) != 0) { + char msg[48]; + snprintf(msg, sizeof(msg), "UNAVAILABLE: %s", status); + wait_and_show(msg, 2000); + return; + } + if (game_count == 0) { + wait_and_show("NO GAMES", 2000); + return; + } + int selected = 0; + int page = 0; + const char *note = strcmp(status, "OK") == 0 ? "" : status; + prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); + while (1) { + draw_store_list(selected, page, note); + uint32_t input = prg32_input_read_menu(); + if (input & STORE_CANCEL) { + prg32_input_wait_released(STORE_CANCEL); + return; + } + if (input & PRG32_BTN_UP) { + if (selected > 0) { + selected--; + page = selected / STORE_PAGE_SIZE; + } else if (selected == 0) { + selected = -1; + } + prg32_input_wait_released(PRG32_BTN_UP); + } else if (input & PRG32_BTN_DOWN) { + if (selected < 0 && game_count > 0) { + selected = 0; + page = 0; + } else if (selected + 1 < game_count) { + selected++; + page = selected / STORE_PAGE_SIZE; + } + prg32_input_wait_released(PRG32_BTN_DOWN); + } else if (input & PRG32_BTN_LEFT) { + if (page > 0) { + page--; + selected = page * STORE_PAGE_SIZE; + } + prg32_input_wait_released(PRG32_BTN_LEFT); + } else if (input & PRG32_BTN_RIGHT) { + int pages = (game_count + STORE_PAGE_SIZE - 1) / STORE_PAGE_SIZE; + if (page + 1 < pages) { + page++; + selected = page * STORE_PAGE_SIZE; + } + prg32_input_wait_released(PRG32_BTN_RIGHT); + } else if (input & STORE_ACCEPT) { + if (selected < 0) { + char query[40] = ""; + if (prg32_text_input(query, sizeof(query), "SEARCH STORE") >= 0) { + filter_catalog(query); + selected = game_count > 0 ? 0 : -1; + page = 0; + note = query[0] ? "filtered" : ""; + } + prg32_input_wait_released(STORE_ACCEPT); + } else if (run_detail(url, selected)) { + return; + } else { + prg32_input_wait_released(STORE_ACCEPT); + } } - prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); - } else { - wait_and_show("UNAVAILABLE", 2000); + vTaskDelay(pdMS_TO_TICKS(10)); } } diff --git a/components/prg32/prg32_system.c b/components/prg32/prg32_system.c index 1e92566..05bcd1e 100644 --- a/components/prg32/prg32_system.c +++ b/components/prg32/prg32_system.c @@ -46,6 +46,8 @@ typedef enum { SETUP_OPTION_RUN_CART, SETUP_OPTION_DEFAULT_CART, SETUP_OPTION_WIFI, + SETUP_OPTION_STORE_CONFIG, + SETUP_OPTION_STORE_BROWSE, SETUP_OPTION_AUDIO, SETUP_OPTION_DEVELOPER, SETUP_OPTION_DEMO, @@ -113,6 +115,28 @@ static void show_setup_message(const char *title, } } +static void start_performance_http_api(void) { + if (prg32_wifi_current_mode() != PRG32_WIFI_MODE_OFF) { + prg32_scores_api_start(); + return; + } + +#if PRG32_WIFI_AP_ENABLE + prg32_wifi_config_t config = { + .mode = PRG32_WIFI_MODE_AP, + }; + snprintf(config.ap_ssid, sizeof(config.ap_ssid), "%s", PRG32_WIFI_AP_SSID); + snprintf(config.ap_password, + sizeof(config.ap_password), + "%s", + PRG32_WIFI_AP_PASSWORD); + prg32_wifi_start_mode(&config); +#else + prg32_wifi_scores_init(); +#endif + prg32_scores_api_start(); +} + static void draw_cartridge_status(int y) { uint8_t slots[PRG32_CART_SLOT_COUNT]; int count = stored_slots(slots, PRG32_CART_SLOT_COUNT); @@ -633,6 +657,8 @@ static void developer_menu(void) { static void about_menu(void) { prg32_input_wait_released(SETUP_KEYS); uint32_t last = 0; + char version_line[40]; + snprintf(version_line, sizeof(version_line), "FIRMWARE %s", PRG32_FIRMWARE_VERSION); while (1) { uint32_t input = prg32_input_read_menu(); if (((input & PRG32_BTN_A) && !(last & PRG32_BTN_A)) || @@ -645,6 +671,7 @@ static void about_menu(void) { prg32_gfx_clear(PRG32_COLOR_BLACK); prg32_gfx_text8(8, 8, "ABOUT PRG32", PRG32_COLOR_WHITE, 0); prg32_gfx_text8(8, 32, "RETRO GAMING & CODING", PRG32_COLOR_CYAN, 0); + prg32_gfx_text8(8, 48, version_line, PRG32_COLOR_GREEN, 0); prg32_gfx_text8(8, 64, "AUTHORS AND CONTRIBUTORS", PRG32_COLOR_YELLOW, 0); prg32_gfx_text8(8, 88, "RAFFAELE MONTELLA", PRG32_COLOR_WHITE, 0); prg32_gfx_text8(8, 104, "UNIPARTHENOPE", PRG32_COLOR_GREEN, 0); @@ -665,7 +692,7 @@ static int setup_menu(void) { printf("setup_menu => input_wait_released(SETUP_KEYS)\n"); prg32_input_wait_released(SETUP_KEYS); while (1) { - setup_option_t options[9]; + setup_option_t options[11]; int option_count = 0; printf("setup_menu => prg32_cart_stored_count\n"); int cart_count = prg32_cart_stored_count(); @@ -683,6 +710,14 @@ static int setup_menu(void) { SETUP_OPTION_WIFI, "WIFI SETUP", }; + options[option_count++] = (setup_option_t){ + SETUP_OPTION_STORE_CONFIG, + "CARTRIDGE STORE", + }; + options[option_count++] = (setup_option_t){ + SETUP_OPTION_STORE_BROWSE, + "BROWSE STORE", + }; options[option_count++] = (setup_option_t){ SETUP_OPTION_AUDIO, "AUDIO SETUP", @@ -753,6 +788,17 @@ static int setup_menu(void) { prg32_scores_api_start(); break; } + if (selected == SETUP_OPTION_STORE_CONFIG) { + prg32_setup_store_run(); + break; + } + if (selected == SETUP_OPTION_STORE_BROWSE) { + prg32_setup_store_browse_run(); + if (prg32_cart_is_loaded()) { + return 0; + } + break; + } if (selected == SETUP_OPTION_AUDIO) { audio_menu(); break; @@ -766,8 +812,7 @@ static int setup_menu(void) { break; } if (selected == SETUP_OPTION_PERFORMANCE) { - prg32_wifi_scores_init(); - prg32_scores_api_start(); + start_performance_http_api(); prg32_performance_test_run(); break; } @@ -842,12 +887,10 @@ void prg32_init(void) { } if (setup_requested) { prg32_gfx_set_fullscreen(1); - if (stored_count == 0) { - printf("prg32_init => wifi_scores_init()\n"); - prg32_wifi_scores_init(); - printf("prg32_init => scores_api_start()\n"); - prg32_scores_api_start(); - } + printf("prg32_init => wifi_scores_init()\n"); + prg32_wifi_scores_init(); + printf("prg32_init => scores_api_start()\n"); + prg32_scores_api_start(); printf("prg32_init => setup_menu()\n"); setup_menu(); } diff --git a/components/prg32/prg32_wifi.c b/components/prg32/prg32_wifi.c index 0bd55d4..ecc3f81 100644 --- a/components/prg32/prg32_wifi.c +++ b/components/prg32/prg32_wifi.c @@ -24,6 +24,10 @@ #define PRG32_WIFI_SCAN_VISIBLE 8 #endif +#ifndef PRG32_WIFI_STA_LEGACY_PROTOCOLS +#define PRG32_WIFI_STA_LEGACY_PROTOCOLS 0 +#endif + static const char *TAG = "prg32_wifi"; static bool wifi_started; static bool wifi_initialized; @@ -33,6 +37,13 @@ static esp_netif_t *sta_netif; static esp_netif_t *ap_netif; static char active_ssid[32]; static char active_ip[16] = "-"; +static char active_status[32] = "idle"; +static wifi_auth_mode_t selected_authmode = WIFI_AUTH_OPEN; +static char selected_ssid[32]; +static uint8_t selected_bssid[6]; +static uint8_t selected_channel; +static bool selected_ap_valid; +static bool selected_ap_locked; static void copy_cstr(char *dst, size_t dst_size, const char *src) { if (!dst || dst_size == 0) { @@ -45,17 +56,116 @@ static void copy_cstr(char *dst, size_t dst_size, const char *src) { dst[dst_size - 1] = '\0'; } +static const char *disconnect_reason_name(uint8_t reason) { + switch (reason) { + case WIFI_REASON_AUTH_EXPIRE: + return "AUTH EXPIRED"; + case WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT: + case WIFI_REASON_HANDSHAKE_TIMEOUT: + return "HANDSHAKE TIMEOUT"; + case WIFI_REASON_802_1X_AUTH_FAILED: + case WIFI_REASON_AUTH_FAIL: + return "AUTH FAILED"; + case WIFI_REASON_ASSOC_FAIL: + return "ASSOC FAILED"; + case WIFI_REASON_NO_AP_FOUND: + return "NO AP FOUND"; + case WIFI_REASON_NO_AP_FOUND_W_COMPATIBLE_SECURITY: + return "SECURITY MISMATCH"; + case WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD: + return "AUTHMODE MISMATCH"; + case WIFI_REASON_NO_AP_FOUND_IN_RSSI_THRESHOLD: + return "RSSI TOO LOW"; + case WIFI_REASON_BEACON_TIMEOUT: + return "BEACON TIMEOUT"; + case WIFI_REASON_CONNECTION_FAIL: + return "CONNECTION FAILED"; + default: + return "DISCONNECTED"; + } +} + +static const char *auth_mode_name(wifi_auth_mode_t mode) { + switch (mode) { + case WIFI_AUTH_OPEN: + return "OPEN"; + case WIFI_AUTH_WEP: + return "WEP"; + case WIFI_AUTH_WPA_PSK: + return "WPA"; + case WIFI_AUTH_WPA2_PSK: + return "WPA2"; + case WIFI_AUTH_WPA_WPA2_PSK: + return "WPA/WPA2"; + case WIFI_AUTH_WPA3_PSK: + return "WPA3"; + case WIFI_AUTH_WPA2_WPA3_PSK: + return "WPA2/WPA3"; + case WIFI_AUTH_WPA2_ENTERPRISE: + return "WPA2-ENT"; + case WIFI_AUTH_WPA3_ENTERPRISE: + return "WPA3-ENT"; + case WIFI_AUTH_WPA2_WPA3_ENTERPRISE: + return "ENT"; + default: + return "SECURE"; + } +} + +static bool auth_mode_is_enterprise(wifi_auth_mode_t mode) { + return mode == WIFI_AUTH_WPA2_ENTERPRISE || + mode == WIFI_AUTH_WPA3_ENTERPRISE || + mode == WIFI_AUTH_WPA2_WPA3_ENTERPRISE; +} + +static const char *auth_mode_short_name(wifi_auth_mode_t mode) { + switch (mode) { + case WIFI_AUTH_OPEN: + return "OPEN"; + case WIFI_AUTH_WEP: + return "WEP"; + case WIFI_AUTH_WPA_PSK: + return "WPA"; + case WIFI_AUTH_WPA2_PSK: + return "WPA2"; + case WIFI_AUTH_WPA_WPA2_PSK: + return "WPA/W2"; + case WIFI_AUTH_WPA3_PSK: + return "WPA3"; + case WIFI_AUTH_WPA2_WPA3_PSK: + return "W2/W3"; + case WIFI_AUTH_WPA2_ENTERPRISE: + case WIFI_AUTH_WPA3_ENTERPRISE: + case WIFI_AUTH_WPA2_WPA3_ENTERPRISE: + return "ENT"; + default: + return "SEC"; + } +} + static void wifi_event(void *arg, esp_event_base_t base, int32_t id, void *data) { (void)arg; if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START && sta_autoconnect) { + copy_cstr(active_status, sizeof(active_status), "CONNECTING"); esp_wifi_connect(); } if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED && sta_autoconnect) { + wifi_event_sta_disconnected_t *event = (wifi_event_sta_disconnected_t *)data; + snprintf(active_ip, sizeof(active_ip), "connecting"); + copy_cstr(active_status, + sizeof(active_status), + disconnect_reason_name(event ? event->reason : 0)); + ESP_LOGW(TAG, + "Wi-Fi disconnected reason=%u (%s), reconnecting", + event ? (unsigned)event->reason : 0, + active_status); + vTaskDelay(pdMS_TO_TICKS(1000)); esp_wifi_connect(); } if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) { ip_event_got_ip_t *event = (ip_event_got_ip_t *)data; snprintf(active_ip, sizeof(active_ip), IPSTR, IP2STR(&event->ip_info.ip)); + copy_cstr(active_status, sizeof(active_status), "CONNECTED"); ESP_LOGI(TAG, "Wi-Fi connected ip=%s", active_ip); } } @@ -87,6 +197,15 @@ static esp_err_t wifi_stack_init(void) { wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); ESP_RETURN_ON_ERROR(esp_wifi_init(&cfg), TAG, "wifi init failed"); +#ifdef PRG32_WIFI_COUNTRY_CODE + esp_err_t country_err = esp_wifi_set_country_code(PRG32_WIFI_COUNTRY_CODE, true); + if (country_err != ESP_OK) { + ESP_LOGW(TAG, + "Wi-Fi country %s failed: %s", + PRG32_WIFI_COUNTRY_CODE, + esp_err_to_name(country_err)); + } +#endif ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event, @@ -99,11 +218,21 @@ static esp_err_t wifi_stack_init(void) { return ESP_OK; } +static void configure_sta_protocols(void) { +#if PRG32_WIFI_STA_LEGACY_PROTOCOLS + esp_err_t err = esp_wifi_set_protocol(WIFI_IF_STA, + WIFI_PROTOCOL_11B | + WIFI_PROTOCOL_11G | + WIFI_PROTOCOL_11N); + if (err != ESP_OK) { + ESP_LOGW(TAG, "STA protocol set failed: %s", esp_err_to_name(err)); + } +#endif +} + static void default_config(prg32_wifi_config_t *config) { memset(config, 0, sizeof(*config)); -#if PRG32_WIFI_STA_ENABLE && PRG32_WIFI_AP_ENABLE - config->mode = PRG32_WIFI_MODE_APSTA; -#elif PRG32_WIFI_STA_ENABLE +#if PRG32_WIFI_STA_ENABLE config->mode = PRG32_WIFI_MODE_STA; #elif PRG32_WIFI_AP_ENABLE config->mode = PRG32_WIFI_MODE_AP; @@ -128,6 +257,15 @@ static void save_config(const prg32_wifi_config_t *config) { nvs_set_str(nvs, "password", config->password); nvs_set_str(nvs, "ap_ssid", config->ap_ssid); nvs_set_str(nvs, "ap_password", config->ap_password); + if (config->mode == PRG32_WIFI_MODE_STA && + selected_ap_valid && + strcmp(config->ssid, selected_ssid) == 0) { + nvs_set_blob(nvs, "bssid", selected_bssid, sizeof(selected_bssid)); + nvs_set_u8(nvs, "channel", selected_channel); + } else { + nvs_erase_key(nvs, "bssid"); + nvs_erase_key(nvs, "channel"); + } nvs_commit(nvs); nvs_close(nvs); } @@ -142,28 +280,54 @@ static bool load_config(prg32_wifi_config_t *config) { size_t password_len = sizeof(config->password); size_t ap_ssid_len = sizeof(config->ap_ssid); size_t ap_password_len = sizeof(config->ap_password); + uint8_t stored_bssid[6]; + uint8_t stored_channel = 0; + size_t stored_bssid_len = sizeof(stored_bssid); esp_err_t err = nvs_get_u8(nvs, "mode", &mode); err |= nvs_get_str(nvs, "ssid", config->ssid, &ssid_len); err |= nvs_get_str(nvs, "password", config->password, &password_len); err |= nvs_get_str(nvs, "ap_ssid", config->ap_ssid, &ap_ssid_len); err |= nvs_get_str(nvs, "ap_password", config->ap_password, &ap_password_len); + esp_err_t bssid_err = nvs_get_blob(nvs, "bssid", stored_bssid, &stored_bssid_len); + esp_err_t channel_err = nvs_get_u8(nvs, "channel", &stored_channel); nvs_close(nvs); if (err != ESP_OK) { return false; } config->mode = (prg32_wifi_mode_t)mode; + if (config->mode == PRG32_WIFI_MODE_APSTA) { + config->mode = PRG32_WIFI_MODE_STA; + } + selected_ap_valid = false; + selected_ap_locked = false; + selected_ssid[0] = '\0'; + selected_channel = 0; + if (config->mode == PRG32_WIFI_MODE_STA && + bssid_err == ESP_OK && + channel_err == ESP_OK && + stored_bssid_len == sizeof(stored_bssid) && + stored_channel > 0) { + copy_cstr(selected_ssid, sizeof(selected_ssid), config->ssid); + memcpy(selected_bssid, stored_bssid, sizeof(selected_bssid)); + selected_channel = stored_channel; + selected_ap_valid = true; + selected_ap_locked = false; + } return true; } int prg32_wifi_start_mode(const prg32_wifi_config_t *config) { if (!config || config->mode == PRG32_WIFI_MODE_OFF) { active_mode = PRG32_WIFI_MODE_OFF; + copy_cstr(active_status, sizeof(active_status), "OFF"); return 0; } if (wifi_started) { esp_wifi_stop(); wifi_started = false; active_mode = PRG32_WIFI_MODE_OFF; + copy_cstr(active_status, sizeof(active_status), "RESTARTING"); + vTaskDelay(pdMS_TO_TICKS(150)); } if (wifi_stack_init() != ESP_OK) { return -1; @@ -174,6 +338,15 @@ int prg32_wifi_start_mode(const prg32_wifi_config_t *config) { copy_cstr((char *)sta.sta.password, sizeof(sta.sta.password), config->password); + if (selected_ap_valid && + selected_ap_locked && + strcmp(config->ssid, selected_ssid) == 0) { + memcpy(sta.sta.bssid, selected_bssid, sizeof(sta.sta.bssid)); + sta.sta.bssid_set = true; + sta.sta.channel = selected_channel; + } + sta.sta.threshold.authmode = WIFI_AUTH_OPEN; + sta.sta.pmf_cfg.required = false; wifi_config_t ap = {0}; copy_cstr((char *)ap.ap.ssid, sizeof(ap.ap.ssid), config->ap_ssid); @@ -199,7 +372,15 @@ int prg32_wifi_start_mode(const prg32_wifi_config_t *config) { ESP_ERROR_CHECK(esp_wifi_set_mode(esp_mode)); if (config->mode == PRG32_WIFI_MODE_STA || config->mode == PRG32_WIFI_MODE_APSTA) { + configure_sta_protocols(); + ESP_ERROR_CHECK(esp_wifi_set_ps(WIFI_PS_NONE)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &sta)); + ESP_LOGI(TAG, + "STA connecting: ssid=%s password_len=%u channel=%u bssid_set=%d", + config->ssid, + (unsigned)strlen(config->password), + (unsigned)sta.sta.channel, + sta.sta.bssid_set ? 1 : 0); } if (config->mode == PRG32_WIFI_MODE_AP || config->mode == PRG32_WIFI_MODE_APSTA) { @@ -212,9 +393,11 @@ int prg32_wifi_start_mode(const prg32_wifi_config_t *config) { config->mode == PRG32_WIFI_MODE_APSTA) { copy_cstr(active_ssid, sizeof(active_ssid), config->ap_ssid); copy_cstr(active_ip, sizeof(active_ip), "192.168.4.1"); + copy_cstr(active_status, sizeof(active_status), "READY"); } else { copy_cstr(active_ssid, sizeof(active_ssid), config->ssid); copy_cstr(active_ip, sizeof(active_ip), "connecting"); + copy_cstr(active_status, sizeof(active_status), "CONNECTING"); } if (config->mode == PRG32_WIFI_MODE_AP || @@ -278,6 +461,8 @@ static void draw_status(const prg32_wifi_config_t *config) { prg32_gfx_text8(8, 160, "SSID:", PRG32_COLOR_GREEN, 0); prg32_gfx_text8(56, 160, active_ssid, PRG32_COLOR_GREEN, 0); } + prg32_gfx_text8(8, 176, "STATUS:", PRG32_COLOR_YELLOW, 0); + prg32_gfx_text8(72, 176, active_status, PRG32_COLOR_YELLOW, 0); } static int choose_mode(prg32_wifi_config_t *config) { @@ -322,7 +507,7 @@ static int choose_mode(prg32_wifi_config_t *config) { PRG32_COLOR_WHITE, 0); draw_status(config); - prg32_gfx_text8(8, 184, "UP/DOWN CHOOSE SELECT/B OK A BACK", PRG32_COLOR_CYAN, 0); + prg32_gfx_text8(8, 204, "UP/DOWN CHOOSE SELECT/B OK A BACK", PRG32_COLOR_CYAN, 0); prg32_gfx_present(); last = input; vTaskDelay(pdMS_TO_TICKS(80)); @@ -356,6 +541,7 @@ static int scan_networks(wifi_ap_record_t **records_out, sta_autoconnect = true; return 0; } + configure_sta_protocols(); err = esp_wifi_start(); if (err != ESP_OK) { @@ -413,6 +599,127 @@ static int scan_networks(wifi_ap_record_t **records_out, return (int)found_count; } +static int compare_ap_rssi_desc(const void *lhs, const void *rhs) { + const wifi_ap_record_t *a = (const wifi_ap_record_t *)lhs; + const wifi_ap_record_t *b = (const wifi_ap_record_t *)rhs; + return (int)b->rssi - (int)a->rssi; +} + +static void select_ap_record(const wifi_ap_record_t *record) { + if (!record) { + selected_ap_valid = false; + selected_ap_locked = false; + selected_ssid[0] = '\0'; + selected_channel = 0; + return; + } + selected_authmode = record->authmode; + copy_cstr(selected_ssid, sizeof(selected_ssid), (const char *)record->ssid); + memcpy(selected_bssid, record->bssid, sizeof(selected_bssid)); + selected_channel = record->primary; + selected_ap_valid = true; + selected_ap_locked = true; +} + +static void select_ap_record_for_boot(const wifi_ap_record_t *record) { + select_ap_record(record); + selected_ap_locked = false; +} + +static void refresh_stored_sta_ap(const prg32_wifi_config_t *config) { + if (!config || + config->mode != PRG32_WIFI_MODE_STA || + config->ssid[0] == '\0') { + return; + } + + wifi_ap_record_t *records = NULL; + char scan_status[32] = ""; + int count = scan_networks(&records, scan_status, sizeof(scan_status)); + if (count <= 0) { + ESP_LOGW(TAG, + "stored SSID refresh scan failed for %s: %s", + config->ssid, + scan_status[0] ? scan_status : "not found"); + free(records); + return; + } + + const wifi_ap_record_t *best = NULL; + for (int i = 0; i < count; ++i) { + if (strcmp((const char *)records[i].ssid, config->ssid) != 0) { + continue; + } + if (!best || records[i].rssi > best->rssi) { + best = &records[i]; + } + } + if (best) { + select_ap_record_for_boot(best); + ESP_LOGI(TAG, + "refreshed stored SSID %s channel=%u rssi=%d", + config->ssid, + (unsigned)selected_channel, + (int)best->rssi); + } else { + ESP_LOGW(TAG, "stored SSID %s not found in refresh scan", config->ssid); + } + free(records); +} + +static int confirm_sta_credentials(const prg32_wifi_config_t *config) { + uint32_t last = 0; + if (config && auth_mode_is_enterprise(selected_authmode)) { + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "CONFIRM WIFI", PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 40, "ENTERPRISE WIFI IS NOT SUPPORTED", PRG32_COLOR_YELLOW, 0); + prg32_gfx_text8(8, 64, "USE WPA/WPA2 PERSONAL", PRG32_COLOR_CYAN, 0); + prg32_gfx_text8(8, 204, "A BACK", PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); + prg32_input_wait_released(PRG32_BTN_A | PRG32_BTN_B | PRG32_BTN_SELECT); + while ((prg32_input_read_menu() & PRG32_BTN_A) == 0) { + vTaskDelay(pdMS_TO_TICKS(80)); + } + prg32_input_wait_released(PRG32_BTN_A); + return -1; + } + prg32_input_wait_released(PRG32_BTN_A | PRG32_BTN_B | PRG32_BTN_SELECT); + while (1) { + uint32_t input = prg32_input_read_menu(); + if (((input & PRG32_BTN_SELECT) && !(last & PRG32_BTN_SELECT)) || + ((input & PRG32_BTN_B) && !(last & PRG32_BTN_B))) { + prg32_input_wait_released(PRG32_BTN_B | PRG32_BTN_SELECT); + return 0; + } + if ((input & PRG32_BTN_A) && !(last & PRG32_BTN_A)) { + prg32_input_wait_released(PRG32_BTN_A); + return -1; + } + + char line[48]; + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "CONFIRM WIFI", PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 40, "SSID:", PRG32_COLOR_GREEN, 0); + prg32_gfx_text8(56, 40, config ? config->ssid : "", PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 64, "PASSWORD:", PRG32_COLOR_GREEN, 0); + prg32_gfx_text8(8, 82, config ? config->password : "", PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 104, "SECURITY:", PRG32_COLOR_GREEN, 0); + prg32_gfx_text8(88, 104, auth_mode_name(selected_authmode), PRG32_COLOR_WHITE, 0); + snprintf(line, + sizeof(line), + "PASSWORD LENGTH: %u", + config ? (unsigned)strlen(config->password) : 0); + prg32_gfx_text8(8, 128, line, PRG32_COLOR_CYAN, 0); + if (auth_mode_is_enterprise(selected_authmode)) { + prg32_gfx_text8(8, 152, "ENTERPRISE WIFI IS NOT SUPPORTED", PRG32_COLOR_YELLOW, 0); + } + prg32_gfx_text8(8, 204, "SELECT/B CONNECT A EDIT", PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); + last = input; + vTaskDelay(pdMS_TO_TICKS(80)); + } +} + static int choose_ssid(char *ssid, size_t ssid_size) { prg32_gfx_clear(PRG32_COLOR_BLACK); prg32_gfx_text8(8, 8, "SCANNING WIFI", PRG32_COLOR_WHITE, 0); @@ -426,6 +733,9 @@ static int choose_ssid(char *ssid, size_t ssid_size) { wifi_ap_record_t *records = NULL; char scan_status[32] = ""; int count = scan_networks(&records, scan_status, sizeof(scan_status)); + if (count > 1) { + qsort(records, (size_t)count, sizeof(*records), compare_ap_rssi_desc); + } if (count <= 0) { prg32_gfx_clear(PRG32_COLOR_BLACK); prg32_gfx_text8(8, 8, "INFRASTRUCTURE WIFI", PRG32_COLOR_WHITE, 0); @@ -473,6 +783,7 @@ static int choose_ssid(char *ssid, size_t ssid_size) { if (((input & PRG32_BTN_SELECT) && !(last & PRG32_BTN_SELECT)) || ((input & PRG32_BTN_B) && !(last & PRG32_BTN_B))) { copy_cstr(ssid, ssid_size, (const char *)records[choice].ssid); + select_ap_record(&records[choice]); free(records); prg32_input_wait_released(PRG32_BTN_B | PRG32_BTN_SELECT); return 0; @@ -498,15 +809,31 @@ static int choose_ssid(char *ssid, size_t ssid_size) { } int y = 48 + row * 15; const char *name = (const char *)records[i].ssid; + char detail[24]; if (!name || name[0] == '\0') { name = "(hidden)"; } + snprintf(detail, + sizeof(detail), + "C%u %d", + (unsigned)records[i].primary, + (int)records[i].rssi); prg32_gfx_text8(8, y, i == choice ? ">" : " ", PRG32_COLOR_GREEN, 0); prg32_gfx_text8(24, y, name, PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(184, + y, + detail, + PRG32_COLOR_CYAN, + 0); + prg32_gfx_text8(256, + y, + auth_mode_short_name(records[i].authmode), + PRG32_COLOR_YELLOW, + 0); } prg32_gfx_text8(8, 184, "UP/DOWN SELECT/B OK A BACK", PRG32_COLOR_CYAN, 0); prg32_gfx_present(); @@ -544,28 +871,56 @@ int prg32_wifi_setup_run(void) { } } else { config.mode = PRG32_WIFI_MODE_STA; - if (choose_ssid(config.ssid, sizeof(config.ssid)) != 0 || - prg32_text_input(config.password, + if (choose_ssid(config.ssid, sizeof(config.ssid)) != 0) { + continue; + } + if (strcmp(config.ssid, stored.ssid) == 0) { + copy_cstr(config.password, + sizeof(config.password), + stored.password); + } else { + config.password[0] = '\0'; + } + if (prg32_text_input(config.password, sizeof(config.password), "WIFI PASSWORD") < 0) { continue; } + if (confirm_sta_credentials(&config) != 0) { + continue; + } } if (wifi_stack_init() == ESP_OK) { save_config(&config); } int rc = prg32_wifi_start_mode(&config); - prg32_gfx_clear(PRG32_COLOR_BLACK); - prg32_gfx_text8(8, 8, "WIFI MODE", PRG32_COLOR_WHITE, 0); - draw_status(&config); - prg32_gfx_text8(8, - 184, - rc == 0 ? "WIFI READY" : "WIFI FAILED", - PRG32_COLOR_CYAN, - 0); - prg32_gfx_present(); - vTaskDelay(pdMS_TO_TICKS(900)); + uint32_t start = prg32_ticks_ms(); + while (1) { + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "WIFI MODE", PRG32_COLOR_WHITE, 0); + draw_status(&config); + prg32_gfx_text8(8, + 204, + rc == 0 ? "A BACK" : "WIFI FAILED", + PRG32_COLOR_CYAN, + 0); + prg32_gfx_present(); + if (rc != 0 || + strcmp(active_status, "CONNECTED") == 0 || + (prg32_input_read_menu() & PRG32_BTN_A)) { + break; + } + if (prg32_ticks_ms() - start > 30000) { + if (strcmp(active_status, "CONNECTING") == 0) { + copy_cstr(active_status, + sizeof(active_status), + "CONNECT TIMEOUT"); + } + break; + } + vTaskDelay(pdMS_TO_TICKS(200)); + } prg32_gfx_set_fullscreen(was_fullscreen); return rc; } @@ -574,7 +929,7 @@ int prg32_wifi_setup_run(void) { } void prg32_wifi_scores_init(void) { - if (wifi_started) { + if (wifi_started && active_mode == PRG32_WIFI_MODE_STA) { return; } @@ -587,7 +942,21 @@ void prg32_wifi_scores_init(void) { config = stored; } } + refresh_stored_sta_ap(&config); prg32_wifi_start_mode(&config); + if (config.mode == PRG32_WIFI_MODE_STA) { + uint32_t start = prg32_ticks_ms(); + while (strcmp(active_status, "CONNECTED") != 0 && + prg32_ticks_ms() - start < 10000) { + vTaskDelay(pdMS_TO_TICKS(250)); + } + if (strcmp(active_status, "CONNECTED") != 0) { + ESP_LOGW(TAG, "STA boot connect timed out, retrying SSID-only"); + selected_ap_locked = false; + selected_ap_valid = false; + prg32_wifi_start_mode(&config); + } + } } #else diff --git a/dependencies.lock b/dependencies.lock index a5c79d4..56063d2 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -26,6 +26,6 @@ dependencies: direct_dependencies: - espressif/esp_websocket_client - espressif/mdns -manifest_hash: 3077b00f38d784d3f7751d22524ee50a8c5481b70adc876cf6869ba0f8653801 +manifest_hash: e776f823eddf9d7253cdc2e18b5ac78521cdfe0b4c1938ee91bbc14fb0c180db target: esp32c6 version: 2.0.0 diff --git a/docs/api.md b/docs/api.md index f3c4f41..2f523c5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -49,6 +49,51 @@ The board can also run as an access point. In that mode the usual URL is: http://192.168.4.1 ``` +### List Device Endpoints + +```http +GET /api +GET /api/ +``` + +Returns a compact JSON index of the board-local device API. This endpoint is the +first endpoint clients should call when discovering what the running firmware +can serve. + +Example: + +```bash +curl http://192.168.4.1/api +``` + +Response: + +```json +{ + "ok": true, + "service": "PRG32", + "endpoints": [ + {"method":"GET","path":"/api","available":true}, + {"method":"GET","path":"/api/runtime","available":true}, + {"method":"GET","path":"/api/games","available":true}, + {"method":"POST","path":"/api/games","available":true}, + {"method":"POST","path":"/api/games/select","available":true}, + {"method":"GET","path":"/api/screenshot.bmp","available":true}, + {"method":"GET","path":"/api/performance.json","available":true}, + {"method":"GET","path":"/api/scores","available":false}, + {"method":"POST","path":"/api/scores","available":false} + ] +} +``` + +Expected behavior: + +- `/api` and `/api/` return the same shape; +- endpoints compiled into the firmware are always listed consistently; +- `available:false` means the route exists in the API model but the current + build/configuration does not serve it, for example score routes when + `PRG32_WIFI_SCORES_ENABLE` is disabled. + ### Get Runtime Information ```http @@ -93,21 +138,20 @@ Typical response fields: "diag": { "frame_count": 1294, "input_state": 0 - }, - "imports": { - "prg32_ticks_ms": 1100197840, - "prg32_input_read": 1100200144 } } ``` Expected behavior: +- runtime returns a compact single `application/json` response with firmware, + cartridge, display-backend, and diagnostic status; +- runtime does not include the full cartridge import-address table, because that + table is too large for a reliable board-local status response while Wi-Fi and + display services are active; - `cart_loaded` is `false` when no cartridge is active. - `qemu` is `true` for QEMU RGB builds and `false` for physical ESP32-C6 builds. -- `imports` contains numeric firmware function addresses. Host tools use these - values to link uploadable cartridges for the resident runtime. Main use cases: @@ -265,9 +309,15 @@ curl http://192.168.4.1/api/screenshot.bmp --output screenshot.bmp Expected behavior: -- the firmware presents the latest frame before reading pixels; +- the firmware snapshots the current framebuffer without forcing a display + flush from the HTTP request; - the response uses `image/bmp`; -- the response is marked `Cache-Control: no-store`. +- the response includes a fixed `Content-Length`; +- the bitmap is encoded as a conventional 24-bit BMP for broad client + compatibility; +- the response is marked `Cache-Control: no-store`; +- screenshot transfer is larger than JSON endpoints, so clients should use a + timeout of at least 30 seconds on weak Wi-Fi links. Main use cases: @@ -597,7 +647,7 @@ Command-line `--store-url` and `--token` values take precedence. ### Publish A Bundle ```http -POST /api/publish +POST /api/publish/bundle Authorization: Bearer Content-Type: multipart/form-data @@ -651,8 +701,8 @@ Expected behavior: - missing or invalid tokens commonly return `401`; - invalid bundles return `400`; -- successful responses are JSON and may include the published game record or a - submitted/published summary. +- successful responses are JSON and normally create a pending submission; +- the game appears in the public catalog after an editor verifies it. ### Publish A Prebuilt Bundle @@ -678,6 +728,10 @@ python3 tools/prg32_game.py publish-bundle tetris.zip \ Use this endpoint when the build artifacts already exist or when publishing a multi-architecture bundle. +`POST /api/publish` remains a compatibility alias for the same zip-bundle +upload shape. The Cartridge Store no longer accepts the old loose multipart +`.prg32` upload fields. + ## MetricsServer API MetricsServer receives streaming frame metrics from firmware and serves run diff --git a/docs/cartridge_store.md b/docs/cartridge_store.md index cdb2cce..38e5b0f 100644 --- a/docs/cartridge_store.md +++ b/docs/cartridge_store.md @@ -4,7 +4,10 @@ ```text Developer machine - prg32_game.py publish ------------------> CartridgeStore /api/publish + prg32_game.py publish -------------> Cartridge Store /api/publish/bundle + | + v + pending editor review | v catalog @@ -13,7 +16,7 @@ PRG32 device (Setup -> download) <-- GET /api/games//download prg32_game.py store-download <-- GET /api/games//download ``` -CartridgeStore is the companion catalog service for PRG32 cartridges. It +Cartridge Store is the companion catalog service for PRG32 cartridges. It publishes architecture-specific `.prg32` artifacts for physical ESP32-C6 boards and QEMU desktop builds. @@ -89,6 +92,10 @@ python3 tools/prg32_game.py publish \ --store-url http://192.168.1.42:5080 ``` +Current Cartridge Store deployments accept zip bundles at +`/api/publish/bundle`. Uploads may require a session or Bearer token and are +submitted for editor review before they appear in the public catalog. + Pack and publish a multi-architecture bundle: ```bash @@ -139,4 +146,5 @@ for QEMU builds, or write the URL to NVS from firmware setup. Host-side | `NOT COMPATIBLE WITH THIS FIRMWARE` | No matching architecture in catalog | Publish the matching architecture variant first | | `TOO LARGE` during download | Cartridge exceeds slot partition | Re-flash with a larger partition, or use a smaller cartridge | | `401` from `prg32_game.py publish` | Missing or invalid API token | Add `--token` or set `store_token` in `~/.prg32/config.json` | +| Published game is not visible | Upload is pending editor review | Ask an editor to verify the submission in Cartridge Store | | QEMU build shows `NOT FOUND` for mDNS | Expected: mDNS is unavailable in QEMU | Set `CONFIG_PRG32_STORE_URL` in `sdkconfig.defaults.qemu` | diff --git a/docs/getting_started_game_development.md b/docs/getting_started_game_development.md index c17ce05..937016b 100644 --- a/docs/getting_started_game_development.md +++ b/docs/getting_started_game_development.md @@ -496,9 +496,10 @@ curl http://192.168.4.1/api/screenshot.bmp --output hello_world.bmp ## 14. Create A Cartridge Store Publishing Package The current checked-in cartridge tool builds and uploads board/QEMU cartridges. -For store publishing, create the metadata bundle explicitly. A store may accept -this zip at `POST /api/publish` or `POST /api/publish/bundle`; check the store -administrator's policy and token requirements. +For store publishing, create the metadata bundle explicitly. Cartridge Store +accepts this zip at `POST /api/publish/bundle`; `POST /api/publish` is a +compatibility alias for the same zip-bundle shape. Check the store +administrator's token and editor-review policy. Create a bundle directory: @@ -576,15 +577,15 @@ $env:PRG32_STORE_TOKEN = "replace-with-classroom-token" Publish with `curl`: ```bash -curl -X POST "$PRG32_STORE_URL/api/publish" \ +curl -X POST "$PRG32_STORE_URL/api/publish/bundle" \ -H "Authorization: Bearer $PRG32_STORE_TOKEN" \ -F "bundle=@build/store/hello_world-1.0.0.zip" ``` -Some stores expose `/api/publish/bundle` for prebuilt bundles: +The compatibility alias accepts the same bundle: ```bash -curl -X POST "$PRG32_STORE_URL/api/publish/bundle" \ +curl -X POST "$PRG32_STORE_URL/api/publish" \ -H "Authorization: Bearer $PRG32_STORE_TOKEN" \ -F "bundle=@build/store/hello_world-1.0.0.zip" ``` @@ -598,6 +599,9 @@ curl "$PRG32_STORE_URL/api/games" curl "$PRG32_STORE_URL/api/games/org.uniparthenope.hello-world" ``` +If the upload response says `status: pending`, an editor must verify the +submission before these catalog requests show the new cartridge. + Download the published physical artifact for a final smoke test: ```bash diff --git a/main/prg32_config.h b/main/prg32_config.h index ea3e501..ea0ab21 100644 --- a/main/prg32_config.h +++ b/main/prg32_config.h @@ -107,7 +107,7 @@ #define PRG32_PIN_BTN_A 21 #define PRG32_PIN_BTN_B 22 -#define PRG32_PIN_SETUP -1 +#define PRG32_PIN_SETUP 14 /* Optional second digital joystick. Leave pins at -1 when not mounted. */ #define PRG32_PIN_P2_LEFT -1 @@ -174,6 +174,8 @@ #define PRG32_WIFI_AP_PASSWORD "prg32game" #define PRG32_WIFI_AP_CHANNEL 6 #define PRG32_WIFI_AP_MAX_CONN 4 +#define PRG32_WIFI_COUNTRY_CODE "IT" +#define PRG32_WIFI_STA_LEGACY_PROTOCOLS 1 /* CartridgeStore integration constants. */ #define PRG32_STORE_URL_MAX_LEN 128 diff --git a/platformio.ini b/platformio.ini index ef7b439..5831db3 100644 --- a/platformio.ini +++ b/platformio.ini @@ -22,5 +22,3 @@ monitor_dtr = 0 monitor_rts = 0 upload_speed = 460800 upload_protocol = esptool -build_flags = - -DPRG32_FIRMWARE_VERSION=\"pio-dev\" diff --git a/tools/prg32_game.py b/tools/prg32_game.py index 10052e7..903bb3e 100755 --- a/tools/prg32_game.py +++ b/tools/prg32_game.py @@ -744,13 +744,16 @@ def publish(args: argparse.Namespace) -> None: if args.colophon: zf.write(args.colophon, Path(args.colophon).name) response = post_multipart( - store_url(args) + "/api/publish", + store_url(args) + "/api/publish/bundle", {}, {"bundle": (bundle.name, bundle.read_bytes(), "application/zip")}, store_token(args), ) print(json.dumps(response, indent=2, sort_keys=True)) - print("✓ Published") + if response.get("status") == "pending" or response.get("review_required"): + print("Submitted for review") + else: + print("Published") def pack_bundle(args: argparse.Namespace) -> None: @@ -796,6 +799,8 @@ def publish_bundle(args: argparse.Namespace) -> None: ) published = response.get("published") or response.get("submitted") or response print(json.dumps(published, indent=2, sort_keys=True)) + if response.get("status") == "pending" or response.get("review_required"): + print("Submitted for review") def upload_qemu(args: argparse.Namespace) -> None: From 674c0435ae3fedbc59c4eb3bafc5ffe83f11d363 Mon Sep 17 00:00:00 2001 From: raffmont Date: Mon, 8 Jun 2026 20:25:03 +0200 Subject: [PATCH 06/24] Increase cartridge RAM budget --- AGENTS.md | 14 +- README.md | 12 +- components/prg32/CMakeLists.txt | 1 - components/prg32/include/prg32.h | 4 +- components/prg32/prg32_abi_exports.c | 1 - components/prg32/prg32_cart.c | 9 +- components/prg32/prg32_controller.c | 138 +- components/prg32/prg32_device_demo.c | 1657 ---------------------- components/prg32/prg32_display_ili9341.c | 2 +- components/prg32/prg32_http_games.c | 6 +- components/prg32/prg32_input.c | 27 +- components/prg32/prg32_performance.c | 45 +- components/prg32/prg32_setup_store.c | 76 +- components/prg32/prg32_system.c | 51 +- docs/abi.md | 1 - docs/api.md | 5 +- docs/assets.md | 2 +- docs/cartridge_store.md | 5 + docs/cartridges.md | 7 +- docs/deployment.md | 8 +- docs/examples.md | 14 +- docs/external_controllers.md | 99 +- docs/framework_manual.md | 18 +- docs/labs/lab_01_hello_world.md | 4 +- examples/features/README.md | 5 +- examples/games/README.md | 2 +- main/prg32_config.h | 28 - tools/prg32_game.py | 36 +- 28 files changed, 270 insertions(+), 2007 deletions(-) delete mode 100644 components/prg32/prg32_device_demo.c diff --git a/AGENTS.md b/AGENTS.md index 38dec69..95d129f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,13 +223,13 @@ Important implementation details: - Setup mode is entered by holding A+B at boot, by holding `PRG32_PIN_SETUP` low when that optional pin is wired, when no cartridge is present, or when multiple cartridges exist without a saved default cartridge. -- Keep `prg32_device_demo_run()` current whenever framework capabilities are - added or changed. The setup-launched device demo should remain a quick - hardware/classroom smoke test covering display, input, audio, Wi-Fi status, - cartridge state, sprites, scrolling/playfields, status bands, RGB LED/audio - VU behavior, arcade-inspired viewport sketches, the tile-engine platformer, - the fixed-point raycaster, the dual-playfield space cockpit, and any new - framework feature. +- Keep the external `riscv-prg32/DeviceDemo` cartridge current whenever + cartridge-facing framework capabilities are added or changed. It should + remain a quick hardware/classroom smoke test covering display, input, audio, + Wi-Fi status, cartridge state, sprites, scrolling/playfields, status bands, + RGB LED/audio VU behavior, arcade-inspired viewport sketches, the tile-engine + platformer, the fixed-point raycaster, the dual-playfield space cockpit, and + any new framework feature. ## Assembly Example Guidelines diff --git a/README.md b/README.md index 8d1c743..2e303a0 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,7 @@ download server is the standalone **Cartridge Store** in ## Learning Path 1. Build and flash the resident firmware. -2. Open setup with A+B at boot, run the device demo, and upload a cartridge. +2. Open setup with A+B at boot, configure Wi-Fi or CartridgeStore, and upload a cartridge. 3. Read `docs/tutorial.md` for assembly or `docs/tutorial_c_game.md` for C. 4. Complete the labs in `docs/labs`. 5. Modify one example game under `examples/games`. @@ -455,13 +455,13 @@ cartridge is stored, or when multiple cartridges exist without a default. The setup menu can run a cartridge, set the default boot cartridge, configure Wi-Fi, configure CartridgeStore access, browse the store, open the audio setup menu, open the developer status-band menu, launch the unattended performance test, -show the about screen, or launch the device demo. +or show the about screen. Setup screens show the active Wi-Fi mode and current IP address, and the local joystick can navigate them with SELECT/B to confirm and A to go -back. The device demo includes 320x200 sketches inspired by Pong, Breakout, -Space Invaders, Pacman, Tetris, Pole Position, Asteroids, a side-scrolling -platform game, a Doom-style raycaster, and a space cockpit that demonstrates -dual playfields. When the audio configuration is usable for the current board, +back. The former setup device demo now lives as the +[DeviceDemo cartridge](https://github.com/riscv-prg32/DeviceDemo), which can be +built, uploaded, and published through CartridgeStore like the teaching games. +When the audio configuration is usable for the current board, the splash plays a short welcome sound; otherwise it falls back to the passive buzzer when configured. diff --git a/components/prg32/CMakeLists.txt b/components/prg32/CMakeLists.txt index acd431e..9485f82 100644 --- a/components/prg32/CMakeLists.txt +++ b/components/prg32/CMakeLists.txt @@ -34,7 +34,6 @@ idf_component_register( "prg32_console.c" "prg32_controller.c" "prg32_debug_overlay.c" - "prg32_device_demo.c" "prg32_diag.c" "prg32_http_games.c" "prg32_http_scores.c" diff --git a/components/prg32/include/prg32.h b/components/prg32/include/prg32.h index 504832c..09cc8d0 100644 --- a/components/prg32/include/prg32.h +++ b/components/prg32/include/prg32.h @@ -94,7 +94,8 @@ extern "C" { #define PRG32_CART_META_BLOCK_COLOPHON "COLO" #define PRG32_CART_ARCH_ESP32C6 "esp32c6" #define PRG32_CART_ARCH_QEMU "qemu" -#define PRG32_CART_RAM_SIZE (32u * 1024u) +#define PRG32_CART_MAX_SIZE (32u * 1024u) +#define PRG32_CART_RAM_SIZE (64u * 1024u) #define PRG32_CART_NAME_LEN 32 #define PRG32_CART_SLOT_COUNT 2 #ifndef PRG32_FIRMWARE_VERSION @@ -339,7 +340,6 @@ void prg32_debug_overlay_draw(int enabled, int y, uint32_t input_mask, uint32_t frame); -void prg32_device_demo_run(void); void prg32_keyboard_init(prg32_keyboard_t *keyboard, char *buffer, diff --git a/components/prg32/prg32_abi_exports.c b/components/prg32/prg32_abi_exports.c index e7bbdea..989f59a 100644 --- a/components/prg32/prg32_abi_exports.c +++ b/components/prg32/prg32_abi_exports.c @@ -111,7 +111,6 @@ static const prg32_any_fn_t g_prg32_cart_abi_exports[] = { (prg32_any_fn_t)prg32_splash_show_game, (prg32_any_fn_t)prg32_splash_show_default, (prg32_any_fn_t)prg32_debug_overlay_draw, - (prg32_any_fn_t)prg32_device_demo_run, (prg32_any_fn_t)prg32_keyboard_init, (prg32_any_fn_t)prg32_keyboard_update, (prg32_any_fn_t)prg32_keyboard_draw, diff --git a/components/prg32/prg32_cart.c b/components/prg32/prg32_cart.c index 98eaeaf..bf702a9 100644 --- a/components/prg32/prg32_cart.c +++ b/components/prg32/prg32_cart.c @@ -269,6 +269,12 @@ static int validate_header(const prg32_cart_header_t *h, set_error("cartridge linked for a different runtime address"); return -1; } + if (image_size > PRG32_CART_MAX_SIZE) { + set_errorf("cartridge image is too large image=%lu max=%lu", + (unsigned long)image_size, + (unsigned long)PRG32_CART_MAX_SIZE); + return -1; + } if (h->code_size == 0 || h->code_size > h->mem_size || h->mem_size > PRG32_CART_RAM_SIZE) { set_errorf("invalid cartridge size code=%lu mem=%lu ram=%lu", @@ -585,7 +591,8 @@ int prg32_cart_stream_begin(uint8_t slot, size_t image_size) { set_errorf("%s partition not found", slot_name(slot)); return -1; } - if (image_size == 0 || image_size > part->size) { + if (image_size == 0 || image_size > PRG32_CART_MAX_SIZE || + image_size > part->size) { set_errorf("cartridge is larger than %s partition", slot_name(slot)); return -1; } diff --git a/components/prg32/prg32_controller.c b/components/prg32/prg32_controller.c index 5921774..4d10e39 100644 --- a/components/prg32/prg32_controller.c +++ b/components/prg32/prg32_controller.c @@ -1,8 +1,6 @@ #include "prg32.h" #include "prg32_config.h" #include "driver/gpio.h" -#include "driver/uart.h" -#include "esp_err.h" #include "esp_log.h" #include "esp_system.h" #include "freertos/FreeRTOS.h" @@ -16,14 +14,8 @@ #define PRG32_PIN_BTN_SELECT PRG32_PIN_BTN_START #endif -#ifndef PRG32_PIN_P2_SELECT -#define PRG32_PIN_P2_SELECT PRG32_PIN_P2_START -#endif - #define PRG32_RESTART_HOTKEY_P1 \ (PRG32_BTN_A | PRG32_BTN_B | PRG32_BTN_DOWN) -#define PRG32_RESTART_HOTKEY_P2 \ - (PRG32_P2_BTN_A | PRG32_P2_BTN_B | PRG32_P2_BTN_DOWN) static const char *TAG = "prg32_controller"; @@ -32,99 +24,14 @@ uint32_t prg32_qemu_input_read(void); /* * PRG32 controller layer. * - * ESP32-C6 has a USB Serial/JTAG device controller, but it is not a normal - * USB host port for plugging in arbitrary USB HID gamepads. Therefore this - * file supports three classroom-friendly input backends: - * 1. GPIO buttons on the reference breadboard/PCB. - * 2. A USB-HID-host bridge, e.g. RP2040/CH559/ESP32-S3, connected by UART. - * 3. A host-terminal keyboard/debug mode through the same UART protocol. + * The runtime reads the reference GPIO joystick on hardware, QEMU keyboard + * input on desktop builds, and diagnostic input used by tests. * * The game sees only the stable PRG32 bitmask: LEFT/RIGHT/UP/DOWN/SELECT/A/B. * This is intentionally similar to memory-mapped input registers on 1980s * consoles, which makes it a useful Computer Architecture teaching example. */ -#if PRG32_CONTROLLER_BRIDGE_ENABLE > 0 -static uint32_t bridge_state; -static uint8_t bridge_packet[4]; -static int bridge_packet_len; - -static void bridge_feed(uint8_t byte) { - /* Packet format: 'U' 'G' lo hi, where lo/hi are the PRG32 bitmask. */ - if (bridge_packet_len == 0) { - if (byte == 'U') { - bridge_packet[bridge_packet_len++] = byte; - } - return; - } - if (bridge_packet_len == 1) { - if (byte == 'G') { - bridge_packet[bridge_packet_len++] = byte; - } else if (byte != 'U') { - bridge_packet_len = 0; - } - return; - } - bridge_packet[bridge_packet_len++] = byte; - if (bridge_packet_len == 4) { - bridge_state = - (uint32_t)bridge_packet[2] | ((uint32_t)bridge_packet[3] << 8); - bridge_packet_len = 0; - } -} - -void prg32_controller_bridge_init(void) { - const uart_config_t cfg = { - .baud_rate = PRG32_CONTROLLER_BRIDGE_BAUD, - .data_bits = UART_DATA_8_BITS, - .parity = UART_PARITY_DISABLE, - .stop_bits = UART_STOP_BITS_1, - .flow_ctrl = UART_HW_FLOWCTRL_DISABLE, - .source_clk = UART_SCLK_DEFAULT, - }; - esp_err_t err = - uart_driver_install(PRG32_CONTROLLER_BRIDGE_UART, 256, 0, 0, NULL, 0); - if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) { - ESP_LOGW(TAG, - "controller bridge UART driver install failed: %s", - esp_err_to_name(err)); - return; - } - err = uart_param_config(PRG32_CONTROLLER_BRIDGE_UART, &cfg); - if (err != ESP_OK) { - ESP_LOGW(TAG, - "controller bridge UART config failed: %s", - esp_err_to_name(err)); - return; - } - err = uart_set_pin(PRG32_CONTROLLER_BRIDGE_UART, - PRG32_PIN_CONTROLLER_TX, - PRG32_PIN_CONTROLLER_RX, - UART_PIN_NO_CHANGE, - UART_PIN_NO_CHANGE); - if (err != ESP_OK) { - ESP_LOGW(TAG, - "controller bridge UART pin setup failed: %s", - esp_err_to_name(err)); - return; - } -} - -static uint32_t read_bridge(void) { - printf("read_bridge => uart_read_bytes(PRG32_CONTROLLER_BRIDGE_UART)"); - uint8_t b[16]; - int n = uart_read_bytes(PRG32_CONTROLLER_BRIDGE_UART, b, sizeof(b), 0); - for (int i = 0; i < n; ++i) { - printf("bridge_feed()"); - bridge_feed(b[i]); - } - return bridge_state; -} -#else -void prg32_controller_bridge_init(void) {} -static uint32_t read_bridge(void) { return 0; } -#endif - static uint32_t read_gpio_buttons(void) { uint32_t v = 0; if (PRG32_PIN_BTN_LEFT >= 0 && !gpio_get_level(PRG32_PIN_BTN_LEFT)) { @@ -151,44 +58,18 @@ static uint32_t read_gpio_buttons(void) { if (PRG32_PIN_BTN_SELECT >= 0 && !gpio_get_level(PRG32_PIN_BTN_SELECT)) { v |= PRG32_BTN_SELECT; } - if (PRG32_PIN_P2_LEFT >= 0 && !gpio_get_level(PRG32_PIN_P2_LEFT)) { - v |= PRG32_P2_BTN_LEFT; - } - if (PRG32_PIN_P2_RIGHT >= 0 && !gpio_get_level(PRG32_PIN_P2_RIGHT)) { - v |= PRG32_P2_BTN_RIGHT; - } - if (PRG32_PIN_P2_UP >= 0 && !gpio_get_level(PRG32_PIN_P2_UP)) { - v |= PRG32_P2_BTN_UP; - } - if (PRG32_PIN_P2_DOWN >= 0 && !gpio_get_level(PRG32_PIN_P2_DOWN)) { - v |= PRG32_P2_BTN_DOWN; - } - if (PRG32_PIN_P2_A >= 0 && !gpio_get_level(PRG32_PIN_P2_A)) { - v |= PRG32_P2_BTN_A; - } - if (PRG32_PIN_P2_B >= 0 && !gpio_get_level(PRG32_PIN_P2_B)) { - v |= PRG32_P2_BTN_B; - } - if (PRG32_PIN_P2_START >= 0 && !gpio_get_level(PRG32_PIN_P2_START)) { - v |= PRG32_P2_BTN_SELECT; - } - if (PRG32_PIN_P2_SELECT >= 0 && !gpio_get_level(PRG32_PIN_P2_SELECT)) { - v |= PRG32_P2_BTN_SELECT; - } return v; } uint32_t prg32_controller_read(void) { uint32_t v = read_gpio_buttons(); - v |= read_bridge(); v |= prg32_qemu_input_read(); v |= prg32_diag_input_state(); #if PRG32_RESTART_HOTKEY_ENABLE - if ((v & PRG32_RESTART_HOTKEY_P1) == PRG32_RESTART_HOTKEY_P1 || - (v & PRG32_RESTART_HOTKEY_P2) == PRG32_RESTART_HOTKEY_P2) { - ESP_LOGE(TAG, "ABOUT TO esp_restart() from %s:%d\n", __FILE__, __LINE__); - vTaskDelay(pdMS_TO_TICKS(500)); - esp_restart(); + if ((v & PRG32_RESTART_HOTKEY_P1) == PRG32_RESTART_HOTKEY_P1) { + ESP_LOGE(TAG, "ABOUT TO esp_restart() from %s:%d\n", __FILE__, __LINE__); + vTaskDelay(pdMS_TO_TICKS(500)); + esp_restart(); } #endif return v; @@ -203,13 +84,6 @@ const char *prg32_controller_name(uint32_t bit) { case PRG32_BTN_A: return "A"; case PRG32_BTN_B: return "B / BACK"; case PRG32_BTN_START: return "SELECT"; - case PRG32_P2_BTN_LEFT: return "P2 LEFT"; - case PRG32_P2_BTN_RIGHT: return "P2 RIGHT"; - case PRG32_P2_BTN_UP: return "P2 UP"; - case PRG32_P2_BTN_DOWN: return "P2 DOWN"; - case PRG32_P2_BTN_A: return "P2 A"; - case PRG32_P2_BTN_B: return "P2 B"; - case PRG32_P2_BTN_START: return "P2 SELECT"; default: return "UNKNOWN"; } } diff --git a/components/prg32/prg32_device_demo.c b/components/prg32/prg32_device_demo.c deleted file mode 100644 index e29a22e..0000000 --- a/components/prg32/prg32_device_demo.c +++ /dev/null @@ -1,1657 +0,0 @@ -#include "prg32.h" -#include "freertos/FreeRTOS.h" -#include "freertos/task.h" -#include - -#define DEMO_PAGE_COUNT 13 -#define DEMO_FIELD_TOP 40 -#define DEMO_FIELD_BOTTOM 184 -#define DEMO_FRAME_MS 33 - -static const uint8_t tile_grid[8] = { - 0xff, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xff, -}; - -static const uint8_t tile_diag[8] = { - 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01, -}; - -static const uint8_t sprite_bits[8] = { - 0x18, 0x3c, 0x7e, 0xdb, 0xff, 0x24, 0x5a, 0xa5, -}; - -static const uint8_t tile_cloud_small[8] = { - 0x00, 0x18, 0x3c, 0x7e, 0xff, 0x7e, 0x3c, 0x00, -}; - -static const uint8_t tile_grass_top[8] = { - 0xff, 0xff, 0x81, 0xbd, 0xa5, 0xbd, 0x81, 0xff, -}; - -static const uint8_t tile_dirt_block[8] = { - 0xff, 0x81, 0xbd, 0xa5, 0xa5, 0xbd, 0x81, 0xff, -}; - -static const uint8_t tile_coin_bits[8] = { - 0x18, 0x3c, 0x66, 0x5a, 0x5a, 0x66, 0x3c, 0x18, -}; - -static const uint8_t tile_pipe_bits[8] = { - 0xff, 0x99, 0xff, 0x81, 0xbd, 0xbd, 0xbd, 0xff, -}; - -static const uint8_t tile_star_bits[8] = { - 0x00, 0x10, 0x38, 0x10, 0x00, 0x00, 0x00, 0x00, -}; - -static const uint8_t tile_cockpit_panel[8] = { - 0xff, 0xbd, 0x81, 0xa5, 0x81, 0xbd, 0x81, 0xff, -}; - -static const uint8_t tile_cockpit_edge[8] = { - 0x18, 0x3c, 0x7e, 0xff, 0xff, 0x7e, 0x3c, 0x18, -}; - -static void demo_prepare_playfields(void) { - prg32_tile_define(0, NULL, PRG32_COLOR_BLACK, PRG32_COLOR_BLACK); - prg32_tile_define(1, tile_grid, PRG32_COLOR_BLUE, PRG32_COLOR_BLACK); - prg32_tile_define(2, tile_diag, PRG32_COLOR_MAGENTA, PRG32_COLOR_BLACK); - prg32_tile_define(3, tile_grid, PRG32_COLOR_CYAN, PRG32_COLOR_BLACK); - prg32_playfield_clear(0, 1); - prg32_playfield_clear(1, 0); - for (uint8_t y = 0; y < PRG32_PLAYFIELD_ROWS; ++y) { - for (uint8_t x = 0; x < PRG32_PLAYFIELD_COLS; ++x) { - if (((x + y) & 7u) == 0u) { - prg32_playfield_put(0, x, y, 2); - } - if (((x * 3u + y) & 15u) == 0u) { - prg32_playfield_put(1, x, y, 3); - } - } - } - prg32_playfield_parallax(0, PRG32_PARALLAX_1X / 2, PRG32_PARALLAX_1X / 2); - prg32_playfield_parallax(1, PRG32_PARALLAX_1X, PRG32_PARALLAX_1X); -} - -static void draw_line(int x0, int y0, int x1, int y1, uint16_t color) { - int dx = x1 > x0 ? x1 - x0 : x0 - x1; - int sx = x0 < x1 ? 1 : -1; - int dy = y1 > y0 ? y0 - y1 : y1 - y0; - int sy = y0 < y1 ? 1 : -1; - int err = dx + dy; - while (1) { - prg32_gfx_pixel(x0, y0, color); - if (x0 == x1 && y0 == y1) { - break; - } - int e2 = err * 2; - if (e2 >= dy) { - err += dy; - x0 += sx; - } - if (e2 <= dx) { - err += dx; - y0 += sy; - } - } -} - -static int demo_abs(int value) { - return value < 0 ? -value : value; -} - -static int demo_clamp(int value, int lo, int hi) { - if (value < lo) { - return lo; - } - if (value > hi) { - return hi; - } - return value; -} - -static int rect_hit(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) { - return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; -} - -static int tri_wave(uint32_t frame, int period, int amplitude) { - int p = (int)(frame % (uint32_t)period); - int half = period / 2; - if (p >= half) { - p = period - p; - } - return (p * amplitude) / half; -} - -static void wait_for_frame_target(uint32_t *next_ms) { - uint32_t now = prg32_ticks_ms(); - if (!next_ms) { - return; - } - if (*next_ms == 0) { - *next_ms = now; - } - int32_t late_ms = (int32_t)(now - *next_ms); - if (late_ms > (int32_t)(DEMO_FRAME_MS * 4u)) { - *next_ms = now; - } else { - *next_ms += DEMO_FRAME_MS; - } - now = prg32_ticks_ms(); - if ((int32_t)(*next_ms - now) > 0) { - vTaskDelay(pdMS_TO_TICKS(*next_ms - now)); - } -} - -static void draw_title(const char *title, const char *subtitle) { - prg32_gfx_text8(8, 8, title, PRG32_COLOR_WHITE, 0); - if (subtitle) { - prg32_gfx_text8(8, 24, subtitle, PRG32_COLOR_CYAN, 0); - } -} - -static void draw_footer(void) { - prg32_gfx_text8(76, 188, "A BACK SELECT/B NEXT", PRG32_COLOR_WHITE, 0); -} - -static void draw_overview(uint32_t frame) { - prg32_gfx_clear(PRG32_COLOR_BLACK); - draw_title("PRG32 DEVICE DEMO", "GAME CONTENT IS 320x200"); - prg32_gfx_text8(8, 48, "SPLASH + SETUP USE 320x240", PRG32_COLOR_GREEN, 0); - prg32_gfx_text8(8, 64, "STATUS BANDS ARE ABI OVERLAYS", PRG32_COLOR_GREEN, 0); - for (int i = 0; i < 8; ++i) { - int x = 24 + i * 34; - int h = 20 + (int)((frame + (uint32_t)i * 9u) % 42u); - uint16_t color = (i & 1) ? PRG32_COLOR_MAGENTA : PRG32_COLOR_BLUE; - prg32_gfx_rect(x, 160 - h, 20, h, color); - } - prg32_sprite_draw_8x8(156, 92, sprite_bits, PRG32_COLOR_YELLOW, PRG32_COLOR_BLACK); - draw_footer(); -} - -static void draw_graphics(uint32_t frame) { - prg32_playfield_camera((int)(frame * 2u), (int)frame); - prg32_playfield_draw_dual(); - int x = 20 + (int)((frame * 3u) % 260u); - int y = 56 + (int)((frame * 2u) % 76u); - prg32_sprite_draw_8x8(x, y, sprite_bits, PRG32_COLOR_YELLOW, PRG32_COLOR_BLACK); - draw_title("SCROLLING + PLAYFIELDS", "SPRITES STAY INSIDE VIEWPORT"); - draw_footer(); -} - -static void draw_system(uint32_t frame) { - char line[48]; - prg32_gfx_clear(PRG32_COLOR_BLACK); - draw_title("FRAMEWORK CHECKLIST", "INPUT AUDIO WIFI CARTS"); - prg32_gfx_text8(8, 56, "TILES SPRITES PLATFORM API", PRG32_COLOR_GREEN, 0); - snprintf(line, sizeof(line), "DEFAULT CART SLOT: %d", prg32_cart_default_slot()); - prg32_gfx_text8(8, 88, line, PRG32_COLOR_CYAN, 0); - snprintf(line, sizeof(line), "TICKS: %lu", (unsigned long)prg32_ticks_ms()); - prg32_gfx_text8(8, 104, line, PRG32_COLOR_CYAN, 0); - int pulse = 20 + (int)(frame % 80u); - prg32_gfx_rect(44, 146, pulse, 14, PRG32_COLOR_MAGENTA); - prg32_gfx_rect(44 + pulse, 146, 100 - pulse, 14, PRG32_COLOR_BLUE); - draw_footer(); -} - -typedef struct { - int initialized; - int ball_x; - int ball_y; - int ball_dx; - int ball_dy; - int left_y; - int right_y; - int left_score; - int right_score; -} pong_demo_t; - -static pong_demo_t pong_demo; - -static void reset_pong_demo(void) { - pong_demo.initialized = 1; - pong_demo.ball_x = 156; - pong_demo.ball_y = 104; - pong_demo.ball_dx = 3; - pong_demo.ball_dy = 2; - pong_demo.left_y = 82; - pong_demo.right_y = 82; - pong_demo.left_score = 0; - pong_demo.right_score = 0; -} - -static void draw_pong(uint32_t frame) { - if (!pong_demo.initialized) { - reset_pong_demo(); - } - - int target_left = pong_demo.ball_y - 18; - int target_right = pong_demo.ball_y - 18; - if (pong_demo.left_y < target_left) pong_demo.left_y += 2; - if (pong_demo.left_y > target_left) pong_demo.left_y -= 2; - if (pong_demo.right_y < target_right) pong_demo.right_y += 2; - if (pong_demo.right_y > target_right) pong_demo.right_y -= 2; - pong_demo.left_y = demo_clamp(pong_demo.left_y, DEMO_FIELD_TOP + 8, DEMO_FIELD_BOTTOM - 52); - pong_demo.right_y = demo_clamp(pong_demo.right_y, DEMO_FIELD_TOP + 8, DEMO_FIELD_BOTTOM - 52); - - pong_demo.ball_x += pong_demo.ball_dx; - pong_demo.ball_y += pong_demo.ball_dy; - if (pong_demo.ball_y <= DEMO_FIELD_TOP + 8 || - pong_demo.ball_y + 8 >= DEMO_FIELD_BOTTOM) { - pong_demo.ball_dy = -pong_demo.ball_dy; - pong_demo.ball_y = demo_clamp(pong_demo.ball_y, - DEMO_FIELD_TOP + 8, - DEMO_FIELD_BOTTOM - 8); - } - if (pong_demo.ball_dx < 0 && - rect_hit(pong_demo.ball_x, pong_demo.ball_y, 8, 8, 20, pong_demo.left_y, 8, 42)) { - int hit = pong_demo.ball_y + 4 - (pong_demo.left_y + 21); - pong_demo.ball_dx = 3; - pong_demo.ball_dy = demo_clamp(hit / 7, -3, 3); - if (pong_demo.ball_dy == 0) pong_demo.ball_dy = (frame & 1u) ? 1 : -1; - pong_demo.ball_x = 28; - } - if (pong_demo.ball_dx > 0 && - rect_hit(pong_demo.ball_x, pong_demo.ball_y, 8, 8, 292, pong_demo.right_y, 8, 42)) { - int hit = pong_demo.ball_y + 4 - (pong_demo.right_y + 21); - pong_demo.ball_dx = -3; - pong_demo.ball_dy = demo_clamp(hit / 7, -3, 3); - if (pong_demo.ball_dy == 0) pong_demo.ball_dy = (frame & 1u) ? 1 : -1; - pong_demo.ball_x = 284; - } - if (pong_demo.ball_x < 0 || pong_demo.ball_x > 312) { - if (pong_demo.ball_x < 0) { - pong_demo.right_score++; - pong_demo.ball_dx = 3; - } else { - pong_demo.left_score++; - pong_demo.ball_dx = -3; - } - pong_demo.ball_x = 156; - pong_demo.ball_y = 104; - pong_demo.ball_dy = (frame & 1u) ? 2 : -2; - } - - char score[16]; - prg32_gfx_clear(PRG32_COLOR_BLACK); - draw_title("PONG-INSPIRED DEMO", "BALL BOUNCES ON PADDLES + WALLS"); - for (int y = 44; y < 174; y += 16) { - prg32_gfx_rect(158, y, 4, 8, PRG32_COLOR_BLUE); - } - snprintf(score, sizeof(score), "%02d %02d", pong_demo.left_score % 100, pong_demo.right_score % 100); - prg32_gfx_text8(128, 36, score, PRG32_COLOR_CYAN, 0); - prg32_gfx_rect(20, pong_demo.left_y, 8, 42, PRG32_COLOR_WHITE); - prg32_gfx_rect(292, pong_demo.right_y, 8, 42, PRG32_COLOR_WHITE); - prg32_gfx_rect(pong_demo.ball_x, pong_demo.ball_y, 8, 8, PRG32_COLOR_YELLOW); - draw_footer(); -} - -#define BREAKOUT_ROWS 5 -#define BREAKOUT_COLS 10 - -typedef struct { - int initialized; - uint8_t bricks[BREAKOUT_ROWS][BREAKOUT_COLS]; - int ball_x; - int ball_y; - int ball_dx; - int ball_dy; - int paddle_x; -} breakout_demo_t; - -static breakout_demo_t breakout_demo; - -static void reset_breakout_demo(void) { - breakout_demo.initialized = 1; - for (int row = 0; row < BREAKOUT_ROWS; ++row) { - for (int col = 0; col < BREAKOUT_COLS; ++col) { - breakout_demo.bricks[row][col] = 1; - } - } - breakout_demo.ball_x = 154; - breakout_demo.ball_y = 132; - breakout_demo.ball_dx = 3; - breakout_demo.ball_dy = -3; - breakout_demo.paddle_x = 124; -} - -static void draw_breakout(uint32_t frame) { - if (!breakout_demo.initialized) { - reset_breakout_demo(); - } - breakout_demo.paddle_x = 84 + tri_wave(frame, 96, 112); - int old_y = breakout_demo.ball_y; - breakout_demo.ball_x += breakout_demo.ball_dx; - breakout_demo.ball_y += breakout_demo.ball_dy; - if (breakout_demo.ball_x <= 8 || breakout_demo.ball_x + 8 >= 312) { - breakout_demo.ball_dx = -breakout_demo.ball_dx; - breakout_demo.ball_x = demo_clamp(breakout_demo.ball_x, 8, 304); - } - if (breakout_demo.ball_y <= DEMO_FIELD_TOP + 4) { - breakout_demo.ball_dy = demo_abs(breakout_demo.ball_dy); - breakout_demo.ball_y = DEMO_FIELD_TOP + 4; - } - if (rect_hit(breakout_demo.ball_x, - breakout_demo.ball_y, - 8, - 8, - breakout_demo.paddle_x, - 170, - 72, - 8) && - old_y + 8 <= 170) { - int hit = breakout_demo.ball_x + 4 - (breakout_demo.paddle_x + 36); - breakout_demo.ball_dy = -demo_abs(breakout_demo.ball_dy); - breakout_demo.ball_dx = demo_clamp(hit / 9, -4, 4); - if (breakout_demo.ball_dx == 0) breakout_demo.ball_dx = (frame & 1u) ? 2 : -2; - breakout_demo.ball_y = 162; - } - - int bricks_left = 0; - for (int row = 0; row < BREAKOUT_ROWS; ++row) { - for (int col = 0; col < BREAKOUT_COLS; ++col) { - if (!breakout_demo.bricks[row][col]) { - continue; - } - bricks_left++; - int bx = 14 + col * 30; - int by = 48 + row * 12; - if (rect_hit(breakout_demo.ball_x, breakout_demo.ball_y, 8, 8, bx, by, 26, 8)) { - breakout_demo.bricks[row][col] = 0; - if (old_y + 8 <= by || old_y >= by + 8) { - breakout_demo.ball_dy = -breakout_demo.ball_dy; - } else { - breakout_demo.ball_dx = -breakout_demo.ball_dx; - } - bricks_left--; - row = BREAKOUT_ROWS; - break; - } - } - } - if (breakout_demo.ball_y > 188 || bricks_left <= 0) { - reset_breakout_demo(); - } - - prg32_gfx_clear(PRG32_COLOR_BLACK); - draw_title("BREAKOUT-INSPIRED DEMO", "BRICKS DISAPPEAR ON HIT"); - for (int row = 0; row < BREAKOUT_ROWS; ++row) { - uint16_t color = row & 1 ? PRG32_COLOR_MAGENTA : PRG32_COLOR_CYAN; - for (int col = 0; col < BREAKOUT_COLS; ++col) { - if (breakout_demo.bricks[row][col]) { - prg32_gfx_rect(14 + col * 30, 48 + row * 12, 26, 8, color); - prg32_gfx_rect(16 + col * 30, 50 + row * 12, 22, 4, PRG32_COLOR_WHITE); - } - } - } - prg32_gfx_rect(breakout_demo.paddle_x, 170, 72, 8, PRG32_COLOR_WHITE); - prg32_gfx_rect(breakout_demo.ball_x, breakout_demo.ball_y, 8, 8, PRG32_COLOR_YELLOW); - draw_footer(); -} - -#define INV_ROWS 4 -#define INV_COLS 9 -#define INV_ROCKETS 4 - -typedef struct { - int x; - int y; - int active; -} rocket_t; - -typedef struct { - int initialized; - uint8_t alive[INV_ROWS][INV_COLS]; - uint8_t shield[3][8]; - rocket_t alien_rocket[INV_ROCKETS]; - rocket_t player_rocket; - int formation_x; - int formation_y; - int formation_dx; - int player_x; - int player_alive; - int player_respawn; -} invaders_demo_t; - -static invaders_demo_t invaders_demo; - -static void reset_invaders_demo(void) { - invaders_demo.initialized = 1; - for (int row = 0; row < INV_ROWS; ++row) { - for (int col = 0; col < INV_COLS; ++col) { - invaders_demo.alive[row][col] = 1; - } - } - for (int shield = 0; shield < 3; ++shield) { - for (int cell = 0; cell < 8; ++cell) { - invaders_demo.shield[shield][cell] = cell == 1 || cell == 6 ? 1 : 2; - } - } - for (int i = 0; i < INV_ROCKETS; ++i) { - invaders_demo.alien_rocket[i].active = 0; - } - invaders_demo.player_rocket.active = 0; - invaders_demo.formation_x = 28; - invaders_demo.formation_y = 48; - invaders_demo.formation_dx = 2; - invaders_demo.player_x = 146; - invaders_demo.player_alive = 1; - invaders_demo.player_respawn = 0; -} - -static int invader_rect(int row, int col, int *x, int *y) { - if (!invaders_demo.alive[row][col]) { - return 0; - } - *x = invaders_demo.formation_x + col * 28; - *y = invaders_demo.formation_y + row * 18; - return 1; -} - -static int shield_hit(int x, int y, int w, int h) { - for (int shield = 0; shield < 3; ++shield) { - int sx = 52 + shield * 88; - int sy = 138; - for (int cell = 0; cell < 8; ++cell) { - if (!invaders_demo.shield[shield][cell]) { - continue; - } - int cx = sx + (cell & 3) * 10; - int cy = sy + (cell >> 2) * 8; - if (rect_hit(x, y, w, h, cx, cy, 8, 6)) { - invaders_demo.shield[shield][cell]--; - return 1; - } - } - } - return 0; -} - -static void draw_alien(int x, int y, uint16_t color) { - prg32_gfx_rect(x + 2, y, 12, 4, color); - prg32_gfx_rect(x, y + 4, 16, 6, color); - prg32_gfx_rect(x + 3, y + 10, 3, 3, color); - prg32_gfx_rect(x + 10, y + 10, 3, 3, color); -} - -static void draw_player_ship(int x, int y, uint16_t color) { - prg32_gfx_rect(x + 12, y, 8, 6, color); - prg32_gfx_rect(x + 4, y + 6, 24, 6, color); - prg32_gfx_rect(x, y + 12, 32, 4, color); -} - -static void draw_space_invaders(uint32_t frame) { - if (!invaders_demo.initialized) { - reset_invaders_demo(); - } - invaders_demo.player_x = 40 + tri_wave(frame, 150, 220); - if ((frame & 3u) == 0u) { - invaders_demo.formation_x += invaders_demo.formation_dx; - int min_x = 320; - int max_x = 0; - int alive_count = 0; - for (int row = 0; row < INV_ROWS; ++row) { - for (int col = 0; col < INV_COLS; ++col) { - int x; - int y; - if (invader_rect(row, col, &x, &y)) { - if (x < min_x) min_x = x; - if (x + 16 > max_x) max_x = x + 16; - alive_count++; - } - } - } - if (alive_count == 0) { - reset_invaders_demo(); - } else if (min_x <= 12 || max_x >= 308) { - invaders_demo.formation_dx = -invaders_demo.formation_dx; - invaders_demo.formation_y += 4; - } - } - - if (!invaders_demo.player_rocket.active && invaders_demo.player_alive && (frame % 24u) == 0u) { - invaders_demo.player_rocket.active = 1; - invaders_demo.player_rocket.x = invaders_demo.player_x + 15; - invaders_demo.player_rocket.y = 156; - } - if (invaders_demo.player_rocket.active) { - invaders_demo.player_rocket.y -= 5; - if (shield_hit(invaders_demo.player_rocket.x, invaders_demo.player_rocket.y, 2, 8) || - invaders_demo.player_rocket.y < DEMO_FIELD_TOP) { - invaders_demo.player_rocket.active = 0; - } - for (int row = 0; row < INV_ROWS && invaders_demo.player_rocket.active; ++row) { - for (int col = 0; col < INV_COLS; ++col) { - int x; - int y; - if (invader_rect(row, col, &x, &y) && - rect_hit(invaders_demo.player_rocket.x, - invaders_demo.player_rocket.y, - 2, - 8, - x, - y, - 16, - 13)) { - invaders_demo.alive[row][col] = 0; - invaders_demo.player_rocket.active = 0; - break; - } - } - } - } - - if ((frame % 18u) == 0u) { - int col = (int)((frame / 18u) % INV_COLS); - for (int slot = 0; slot < INV_ROCKETS; ++slot) { - if (invaders_demo.alien_rocket[slot].active) { - continue; - } - for (int row = INV_ROWS - 1; row >= 0; --row) { - int x; - int y; - if (invader_rect(row, col, &x, &y)) { - invaders_demo.alien_rocket[slot].active = 1; - invaders_demo.alien_rocket[slot].x = x + 7; - invaders_demo.alien_rocket[slot].y = y + 14; - row = -1; - } - } - break; - } - } - - for (int i = 0; i < INV_ROCKETS; ++i) { - rocket_t *rocket = &invaders_demo.alien_rocket[i]; - if (!rocket->active) { - continue; - } - rocket->y += 3; - if (shield_hit(rocket->x, rocket->y, 2, 8) || rocket->y > 190) { - rocket->active = 0; - continue; - } - if (invaders_demo.player_alive && - rect_hit(rocket->x, rocket->y, 2, 8, invaders_demo.player_x, 160, 32, 16)) { - invaders_demo.player_alive = 0; - invaders_demo.player_respawn = 36; - rocket->active = 0; - } - } - if (!invaders_demo.player_alive && invaders_demo.player_respawn-- <= 0) { - invaders_demo.player_alive = 1; - } - if (invaders_demo.formation_y > 90) { - reset_invaders_demo(); - } - - prg32_gfx_clear(PRG32_COLOR_BLACK); - draw_title("SPACE INVADERS DEMO", "ALIEN ROCKETS, SHIELDS, HITS"); - for (int row = 0; row < INV_ROWS; ++row) { - for (int col = 0; col < INV_COLS; ++col) { - int x; - int y; - if (invader_rect(row, col, &x, &y)) { - draw_alien(x, y, row & 1 ? PRG32_COLOR_GREEN : PRG32_COLOR_CYAN); - } - } - } - for (int shield = 0; shield < 3; ++shield) { - int sx = 52 + shield * 88; - for (int cell = 0; cell < 8; ++cell) { - if (invaders_demo.shield[shield][cell]) { - uint16_t color = invaders_demo.shield[shield][cell] > 1 - ? PRG32_COLOR_GREEN - : PRG32_COLOR_YELLOW; - prg32_gfx_rect(sx + (cell & 3) * 10, 138 + (cell >> 2) * 8, 8, 6, color); - } - } - } - if (invaders_demo.player_rocket.active) { - prg32_gfx_rect(invaders_demo.player_rocket.x, - invaders_demo.player_rocket.y, - 2, - 8, - PRG32_COLOR_WHITE); - } - for (int i = 0; i < INV_ROCKETS; ++i) { - if (invaders_demo.alien_rocket[i].active) { - prg32_gfx_rect(invaders_demo.alien_rocket[i].x, - invaders_demo.alien_rocket[i].y, - 2, - 8, - PRG32_COLOR_RED); - } - } - if (invaders_demo.player_alive) { - draw_player_ship(invaders_demo.player_x, 160, PRG32_COLOR_CYAN); - } else { - prg32_gfx_rect(invaders_demo.player_x + 8, 160, 16, 16, PRG32_COLOR_RED); - prg32_gfx_rect(invaders_demo.player_x + 2, 166, 28, 4, PRG32_COLOR_YELLOW); - } - draw_footer(); -} - -#define PAC_ROWS 11 -#define PAC_COLS 21 -#define PAC_CELL 12 - -static const char pac_maze[PAC_ROWS][PAC_COLS + 1] = { - "#####################", - "#.........#.........#", - "#.###.###.#.###.###.#", - "#o#.....#...#.....#o#", - "#.###.#.#####.#.###.#", - "#.....#...#...#.....#", - "#####.### # ###.#####", - "#.........P.........#", - "#.###.#.#####.#.###.#", - "#o....#.......#....o#", - "#####################", -}; - -typedef struct { - int initialized; - int full_redraw; - uint8_t dots[PAC_ROWS][PAC_COLS]; - uint8_t power[PAC_ROWS][PAC_COLS]; - int path_index; - int super_timer; -} pac_demo_t; - -typedef struct { - uint8_t col; - uint8_t row; -} pac_cell_t; - -static const pac_cell_t pac_path[] = { - {1,1},{2,1},{3,1},{4,1},{5,1},{6,1},{7,1},{8,1},{9,1}, - {9,3},{10,3},{11,3},{12,3},{13,3},{14,3},{15,3},{16,3},{17,3},{18,3},{19,3}, - {19,5},{18,5},{17,5},{16,5},{15,5},{14,5},{13,5},{12,5},{11,5},{10,5},{9,5}, - {8,5},{7,5},{6,5},{5,5},{4,5},{3,5},{2,5},{1,5}, - {1,7},{2,7},{3,7},{4,7},{5,7},{6,7},{7,7},{8,7},{9,7},{10,7},{11,7},{12,7}, - {13,7},{14,7},{15,7},{16,7},{17,7},{18,7},{19,7}, - {19,9},{18,9},{17,9},{16,9},{15,9},{14,9},{13,9},{12,9},{11,9},{10,9}, - {9,9},{8,9},{7,9},{6,9},{5,9},{4,9},{3,9},{2,9},{1,9}, -}; - -static pac_demo_t pac_demo; - -static void reset_pac_demo(void) { - pac_demo.initialized = 1; - pac_demo.full_redraw = 1; - pac_demo.path_index = 0; - pac_demo.super_timer = 0; - for (int row = 0; row < PAC_ROWS; ++row) { - for (int col = 0; col < PAC_COLS; ++col) { - pac_demo.dots[row][col] = pac_maze[row][col] == '.'; - pac_demo.power[row][col] = pac_maze[row][col] == 'o'; - } - } -} - -static void draw_ghost(int x, int y, uint16_t color) { - prg32_gfx_rect(x + 2, y, 12, 4, color); - prg32_gfx_rect(x, y + 4, 16, 12, color); - prg32_gfx_rect(x + 3, y + 7, 3, 3, PRG32_COLOR_WHITE); - prg32_gfx_rect(x + 10, y + 7, 3, 3, PRG32_COLOR_WHITE); - prg32_gfx_rect(x, y + 16, 4, 3, color); - prg32_gfx_rect(x + 6, y + 16, 4, 3, color); - prg32_gfx_rect(x + 12, y + 16, 4, 3, color); -} - -static void draw_pacman(uint32_t frame) { - if (!pac_demo.initialized) { - reset_pac_demo(); - } - if ((frame & 3u) == 0u) { - pac_demo.path_index = (pac_demo.path_index + 1) % - (int)(sizeof(pac_path) / sizeof(pac_path[0])); - } - pac_cell_t cell = pac_path[pac_demo.path_index]; - if (pac_demo.dots[cell.row][cell.col]) { - pac_demo.dots[cell.row][cell.col] = 0; - } - if (pac_demo.power[cell.row][cell.col]) { - pac_demo.power[cell.row][cell.col] = 0; - pac_demo.super_timer = 150; - } - if (pac_demo.super_timer > 0) { - pac_demo.super_timer--; - } - - int x0 = 34; - int y0 = 44; - if (pac_demo.full_redraw) { - prg32_gfx_clear(PRG32_COLOR_BLACK); - draw_title("PACMAN-INSPIRED DEMO", "DOTS, POWER DOTS, MOVING GHOSTS"); - draw_footer(); - pac_demo.full_redraw = 0; - } else { - prg32_gfx_rect(x0, y0, PAC_COLS * PAC_CELL, PAC_ROWS * PAC_CELL, PRG32_COLOR_BLACK); - } - for (int row = 0; row < PAC_ROWS; ++row) { - for (int col = 0; col < PAC_COLS; ++col) { - int x = x0 + col * PAC_CELL; - int y = y0 + row * PAC_CELL; - if (pac_maze[row][col] == '#') { - prg32_gfx_rect(x, y, PAC_CELL - 1, PAC_CELL - 1, PRG32_COLOR_BLUE); - } else if (pac_demo.power[row][col]) { - prg32_gfx_rect(x + 4, y + 4, 5, 5, PRG32_COLOR_RED); - } else if (pac_demo.dots[row][col]) { - prg32_gfx_rect(x + 5, y + 5, 3, 3, PRG32_COLOR_YELLOW); - } - } - } - - int px = x0 + cell.col * PAC_CELL + 1; - int py = y0 + cell.row * PAC_CELL + 1; - int mouth = (frame / 5u) & 1u; - uint16_t pac_color = pac_demo.super_timer > 0 ? PRG32_COLOR_WHITE : PRG32_COLOR_YELLOW; - prg32_gfx_rect(px, py, 10, 10, pac_color); - if (mouth) { - prg32_gfx_rect(px + 7, py + 3, 4, 4, PRG32_COLOR_BLACK); - } - for (int i = 0; i < 4; ++i) { - int idx = (pac_demo.path_index + 12 + i * 17) % - (int)(sizeof(pac_path) / sizeof(pac_path[0])); - pac_cell_t ghost = pac_path[idx]; - uint16_t color = pac_demo.super_timer > 0 - ? PRG32_COLOR_CYAN - : (i == 0 ? PRG32_COLOR_RED : - i == 1 ? PRG32_COLOR_MAGENTA : - i == 2 ? PRG32_COLOR_GREEN : PRG32_COLOR_CYAN); - draw_ghost(x0 + ghost.col * PAC_CELL - 2, - y0 + ghost.row * PAC_CELL - 4, - color); - } -} - -#define TETRIS_W 10 -#define TETRIS_H 14 -#define TETRIS_BLOCK 9 - -typedef struct { - int initialized; - uint8_t board[TETRIS_H][TETRIS_W]; - int piece; - int rot; - int x; - int y; - int fall_px; - int next[3]; -} tetris_demo_t; - -static const uint16_t tetris_colors[] = { - PRG32_COLOR_BLACK, - PRG32_COLOR_CYAN, - PRG32_COLOR_BLUE, - PRG32_COLOR_YELLOW, - PRG32_COLOR_GREEN, - PRG32_COLOR_MAGENTA, - PRG32_COLOR_RED, - PRG32_COLOR_WHITE, -}; - -static const int8_t tetris_shape[7][4][4][2] = { - {{{0,1},{1,1},{2,1},{3,1}},{{2,0},{2,1},{2,2},{2,3}},{{0,2},{1,2},{2,2},{3,2}},{{1,0},{1,1},{1,2},{1,3}}}, - {{{0,0},{0,1},{1,1},{2,1}},{{1,0},{2,0},{1,1},{1,2}},{{0,1},{1,1},{2,1},{2,2}},{{1,0},{1,1},{0,2},{1,2}}}, - {{{2,0},{0,1},{1,1},{2,1}},{{1,0},{1,1},{1,2},{2,2}},{{0,1},{1,1},{2,1},{0,2}},{{0,0},{1,0},{1,1},{1,2}}}, - {{{1,0},{2,0},{1,1},{2,1}},{{1,0},{2,0},{1,1},{2,1}},{{1,0},{2,0},{1,1},{2,1}},{{1,0},{2,0},{1,1},{2,1}}}, - {{{1,0},{2,0},{0,1},{1,1}},{{1,0},{1,1},{2,1},{2,2}},{{1,1},{2,1},{0,2},{1,2}},{{0,0},{0,1},{1,1},{1,2}}}, - {{{1,0},{0,1},{1,1},{2,1}},{{1,0},{1,1},{2,1},{1,2}},{{0,1},{1,1},{2,1},{1,2}},{{1,0},{0,1},{1,1},{1,2}}}, - {{{0,0},{1,0},{1,1},{2,1}},{{2,0},{1,1},{2,1},{1,2}},{{0,1},{1,1},{1,2},{2,2}},{{1,0},{0,1},{1,1},{0,2}}}, -}; - -static tetris_demo_t tetris_demo; - -static int tetris_fits(int piece, int rot, int x, int y) { - for (int i = 0; i < 4; ++i) { - int px = x + tetris_shape[piece][rot][i][0]; - int py = y + tetris_shape[piece][rot][i][1]; - if (px < 0 || px >= TETRIS_W || py >= TETRIS_H) { - return 0; - } - if (py >= 0 && tetris_demo.board[py][px]) { - return 0; - } - } - return 1; -} - -static void tetris_spawn(void) { - tetris_demo.piece = tetris_demo.next[0]; - tetris_demo.next[0] = tetris_demo.next[1]; - tetris_demo.next[1] = tetris_demo.next[2]; - tetris_demo.next[2] = (tetris_demo.next[2] + 3) % 7; - tetris_demo.rot = 0; - tetris_demo.x = 3; - tetris_demo.y = -1; - tetris_demo.fall_px = 0; - if (!tetris_fits(tetris_demo.piece, tetris_demo.rot, tetris_demo.x, tetris_demo.y)) { - for (int row = 0; row < TETRIS_H; ++row) { - for (int col = 0; col < TETRIS_W; ++col) { - tetris_demo.board[row][col] = 0; - } - } - } -} - -static void reset_tetris_demo(void) { - tetris_demo.initialized = 1; - for (int row = 0; row < TETRIS_H; ++row) { - for (int col = 0; col < TETRIS_W; ++col) { - tetris_demo.board[row][col] = row > 10 && ((row + col) % 5) == 0 - ? (uint8_t)((col % 7) + 1) - : 0; - } - } - tetris_demo.next[0] = 0; - tetris_demo.next[1] = 3; - tetris_demo.next[2] = 5; - tetris_spawn(); -} - -static void tetris_lock_piece(void) { - for (int i = 0; i < 4; ++i) { - int px = tetris_demo.x + tetris_shape[tetris_demo.piece][tetris_demo.rot][i][0]; - int py = tetris_demo.y + tetris_shape[tetris_demo.piece][tetris_demo.rot][i][1]; - if (py >= 0 && py < TETRIS_H && px >= 0 && px < TETRIS_W) { - tetris_demo.board[py][px] = (uint8_t)(tetris_demo.piece + 1); - } - } - for (int row = TETRIS_H - 1; row >= 0; --row) { - int full = 1; - for (int col = 0; col < TETRIS_W; ++col) { - if (!tetris_demo.board[row][col]) { - full = 0; - break; - } - } - if (full) { - for (int y = row; y > 0; --y) { - for (int col = 0; col < TETRIS_W; ++col) { - tetris_demo.board[y][col] = tetris_demo.board[y - 1][col]; - } - } - for (int col = 0; col < TETRIS_W; ++col) { - tetris_demo.board[0][col] = 0; - } - row++; - } - } - tetris_spawn(); -} - -static void draw_tetris_block(int x, int y, uint16_t color) { - prg32_gfx_rect(x, y, TETRIS_BLOCK - 1, TETRIS_BLOCK - 1, color); - prg32_gfx_rect(x + 2, y + 2, TETRIS_BLOCK - 5, 2, PRG32_COLOR_WHITE); -} - -static void draw_tetris(uint32_t frame) { - if (!tetris_demo.initialized) { - reset_tetris_demo(); - } - if ((frame % 24u) == 0u) { - int next_rot = (tetris_demo.rot + 1) & 3; - if (tetris_fits(tetris_demo.piece, next_rot, tetris_demo.x, tetris_demo.y)) { - tetris_demo.rot = next_rot; - } - } - if ((frame % 32u) == 0u) { - int dx = ((frame / 32u) & 1u) ? 1 : -1; - if (tetris_fits(tetris_demo.piece, tetris_demo.rot, tetris_demo.x + dx, tetris_demo.y)) { - tetris_demo.x += dx; - } - } - tetris_demo.fall_px += 2; - if (tetris_demo.fall_px >= TETRIS_BLOCK) { - tetris_demo.fall_px -= TETRIS_BLOCK; - if (tetris_fits(tetris_demo.piece, tetris_demo.rot, tetris_demo.x, tetris_demo.y + 1)) { - tetris_demo.y++; - } else { - tetris_lock_piece(); - } - } - - int bx = 102; - int by = 42; - prg32_gfx_clear(PRG32_COLOR_BLACK); - draw_title("TETRIS-INSPIRED DEMO", "NEXT PIECES, ROTATION, GRAVITY"); - prg32_gfx_rect(bx - 4, by - 4, TETRIS_W * TETRIS_BLOCK + 8, TETRIS_H * TETRIS_BLOCK + 8, PRG32_COLOR_BLUE); - prg32_gfx_rect(bx, by, TETRIS_W * TETRIS_BLOCK, TETRIS_H * TETRIS_BLOCK, PRG32_COLOR_BLACK); - for (int row = 0; row < TETRIS_H; ++row) { - for (int col = 0; col < TETRIS_W; ++col) { - if (tetris_demo.board[row][col]) { - draw_tetris_block(bx + col * TETRIS_BLOCK, - by + row * TETRIS_BLOCK, - tetris_colors[tetris_demo.board[row][col]]); - } - } - } - for (int i = 0; i < 4; ++i) { - int px = tetris_demo.x + tetris_shape[tetris_demo.piece][tetris_demo.rot][i][0]; - int py = tetris_demo.y + tetris_shape[tetris_demo.piece][tetris_demo.rot][i][1]; - if (py >= 0) { - draw_tetris_block(bx + px * TETRIS_BLOCK, - by + py * TETRIS_BLOCK + tetris_demo.fall_px, - tetris_colors[tetris_demo.piece + 1]); - } - } - prg32_gfx_text8(220, 48, "NEXT", PRG32_COLOR_CYAN, 0); - for (int n = 0; n < 3; ++n) { - int piece = tetris_demo.next[n]; - for (int i = 0; i < 4; ++i) { - int px = tetris_shape[piece][0][i][0]; - int py = tetris_shape[piece][0][i][1]; - draw_tetris_block(224 + px * 8, - 66 + n * 34 + py * 8, - tetris_colors[piece + 1]); - } - } - draw_footer(); -} - -static void draw_cloud(int x, int y) { - prg32_gfx_rect(x, y + 6, 30, 6, PRG32_COLOR_WHITE); - prg32_gfx_rect(x + 6, y + 2, 10, 8, PRG32_COLOR_WHITE); - prg32_gfx_rect(x + 17, y, 12, 10, PRG32_COLOR_WHITE); -} - -static void draw_car_sprite(int x, int y) { - prg32_gfx_rect(x + 7, y, 20, 8, PRG32_COLOR_RED); - prg32_gfx_rect(x + 2, y + 8, 30, 10, PRG32_COLOR_RED); - prg32_gfx_rect(x + 10, y + 2, 14, 6, PRG32_COLOR_CYAN); - prg32_gfx_rect(x, y + 16, 8, 6, PRG32_COLOR_BLACK); - prg32_gfx_rect(x + 26, y + 16, 8, 6, PRG32_COLOR_BLACK); - prg32_gfx_rect(x + 3, y + 17, 4, 3, PRG32_COLOR_WHITE); - prg32_gfx_rect(x + 27, y + 17, 4, 3, PRG32_COLOR_WHITE); -} - -static void draw_pole_position(uint32_t frame) { - prg32_gfx_clear(PRG32_COLOR_CYAN); - draw_title("POLE POSITION DEMO", "CLOUD PLAYFIELD + CURVED ROAD"); - for (int i = 0; i < 4; ++i) { - int x = (int)((frame / 2u + (uint32_t)i * 82u) % 380u) - 50; - draw_cloud(x, 54 + (i & 1) * 16); - } - prg32_gfx_rect(0, 92, PRG32_GAME_W, 108, PRG32_COLOR_GREEN); - int curve = tri_wave(frame, 180, 80) - 40; - for (int y = 94; y < 200; y += 2) { - int t = y - 94; - int center = 160 + (curve * t * t) / (106 * 106); - int road_w = 36 + t * 2; - int left = center - road_w / 2; - int right = center + road_w / 2; - prg32_gfx_rect(left, y, right - left, 2, 0x7bef); - prg32_gfx_rect(left - 4, y, 4, 2, (y / 8) & 1 ? PRG32_COLOR_RED : PRG32_COLOR_WHITE); - prg32_gfx_rect(right, y, 4, 2, (y / 8) & 1 ? PRG32_COLOR_RED : PRG32_COLOR_WHITE); - if (((y - (int)(frame * 2u)) / 14) & 1) { - int lane_w = 3 + t / 18; - prg32_gfx_rect(center - lane_w / 2, y, lane_w, 2, PRG32_COLOR_YELLOW); - } - } - int car_x = 142 + curve / 5; - draw_car_sprite(car_x, 160); - draw_footer(); -} - -#define AST_COUNT 6 -#define BULLET_COUNT 3 - -typedef struct { - int x; - int y; - int vx; - int vy; - int size; - int alive; -} asteroid_t; - -typedef struct { - int x; - int y; - int vx; - int vy; - int life; -} bullet_t; - -typedef struct { - int initialized; - asteroid_t asteroids[AST_COUNT]; - bullet_t bullets[BULLET_COUNT]; - int ship_angle; -} asteroids_demo_t; - -static asteroids_demo_t asteroids_demo; - -static void reset_asteroids_demo(void) { - asteroids_demo.initialized = 1; - asteroids_demo.ship_angle = 0; - for (int i = 0; i < AST_COUNT; ++i) { - asteroids_demo.asteroids[i].x = 22 + i * 47; - asteroids_demo.asteroids[i].y = 52 + (i * 31) % 112; - asteroids_demo.asteroids[i].vx = (i & 1) ? 2 : -2; - asteroids_demo.asteroids[i].vy = (i & 2) ? 1 : -1; - asteroids_demo.asteroids[i].size = 8 + (i % 3) * 4; - asteroids_demo.asteroids[i].alive = 1; - } - for (int i = 0; i < BULLET_COUNT; ++i) { - asteroids_demo.bullets[i].life = 0; - } -} - -static void wrap_point(int *x, int *y) { - if (*x < 0) *x += PRG32_GAME_W; - if (*x >= PRG32_GAME_W) *x -= PRG32_GAME_W; - if (*y < DEMO_FIELD_TOP) *y = DEMO_FIELD_BOTTOM - 1; - if (*y >= DEMO_FIELD_BOTTOM) *y = DEMO_FIELD_TOP; -} - -static void draw_asteroid_shape(int x, int y, int s, uint16_t color) { - draw_line(x - s, y - 2, x - s / 2, y - s, color); - draw_line(x - s / 2, y - s, x + s / 2, y - s + 2, color); - draw_line(x + s / 2, y - s + 2, x + s, y - 1, color); - draw_line(x + s, y - 1, x + s / 2, y + s, color); - draw_line(x + s / 2, y + s, x - s / 2, y + s - 1, color); - draw_line(x - s / 2, y + s - 1, x - s, y - 2, color); -} - -static void draw_asteroids(uint32_t frame) { - static const int dirs[8][2] = { - {0,-12},{8,-8},{12,0},{8,8},{0,12},{-8,8},{-12,0},{-8,-8}, - }; - if (!asteroids_demo.initialized) { - reset_asteroids_demo(); - } - asteroids_demo.ship_angle = (asteroids_demo.ship_angle + 1) & 7; - int sx = 160; - int sy = 112; - int dx = dirs[asteroids_demo.ship_angle][0]; - int dy = dirs[asteroids_demo.ship_angle][1]; - if ((frame % 22u) == 0u) { - for (int i = 0; i < BULLET_COUNT; ++i) { - if (asteroids_demo.bullets[i].life <= 0) { - asteroids_demo.bullets[i].x = sx + dx; - asteroids_demo.bullets[i].y = sy + dy; - asteroids_demo.bullets[i].vx = dx / 2; - asteroids_demo.bullets[i].vy = dy / 2; - asteroids_demo.bullets[i].life = 34; - break; - } - } - } - for (int i = 0; i < AST_COUNT; ++i) { - asteroid_t *asteroid = &asteroids_demo.asteroids[i]; - if (!asteroid->alive) { - if ((frame % 90u) == (uint32_t)(i * 7)) { - asteroid->alive = 1; - asteroid->x = 24 + i * 43; - asteroid->y = DEMO_FIELD_TOP + 12; - asteroid->size = 8 + (i % 3) * 4; - } - continue; - } - asteroid->x += asteroid->vx; - asteroid->y += asteroid->vy; - wrap_point(&asteroid->x, &asteroid->y); - } - for (int b = 0; b < BULLET_COUNT; ++b) { - bullet_t *bullet = &asteroids_demo.bullets[b]; - if (bullet->life <= 0) { - continue; - } - bullet->x += bullet->vx; - bullet->y += bullet->vy; - wrap_point(&bullet->x, &bullet->y); - bullet->life--; - for (int i = 0; i < AST_COUNT; ++i) { - asteroid_t *asteroid = &asteroids_demo.asteroids[i]; - if (asteroid->alive && - demo_abs(bullet->x - asteroid->x) < asteroid->size && - demo_abs(bullet->y - asteroid->y) < asteroid->size) { - bullet->life = 0; - asteroid->size -= 4; - asteroid->vx = -asteroid->vx; - if (asteroid->size < 6) { - asteroid->alive = 0; - } - break; - } - } - } - - prg32_gfx_clear(PRG32_COLOR_BLACK); - draw_title("ASTEROIDS-INSPIRED DEMO", "WRAP, ROTATE, SHOOT, SPLIT"); - for (int i = 0; i < 36; ++i) { - int x = (i * 53 + (int)frame) % PRG32_GAME_W; - int y = DEMO_FIELD_TOP + (i * 29) % (DEMO_FIELD_BOTTOM - DEMO_FIELD_TOP); - prg32_gfx_pixel(x, y, PRG32_COLOR_WHITE); - } - draw_line(sx + dx, sy + dy, sx - dy / 2, sy + dx / 2, PRG32_COLOR_CYAN); - draw_line(sx + dx, sy + dy, sx + dy / 2, sy - dx / 2, PRG32_COLOR_CYAN); - draw_line(sx - dy / 2, sy + dx / 2, sx + dy / 2, sy - dx / 2, PRG32_COLOR_CYAN); - for (int i = 0; i < AST_COUNT; ++i) { - if (asteroids_demo.asteroids[i].alive) { - draw_asteroid_shape(asteroids_demo.asteroids[i].x, - asteroids_demo.asteroids[i].y, - asteroids_demo.asteroids[i].size, - PRG32_COLOR_WHITE); - } - } - for (int i = 0; i < BULLET_COUNT; ++i) { - if (asteroids_demo.bullets[i].life > 0) { - prg32_gfx_rect(asteroids_demo.bullets[i].x, - asteroids_demo.bullets[i].y, - 2, - 2, - PRG32_COLOR_YELLOW); - } - } - draw_footer(); -} - -#define PLATFORM_TILE_CLOUD 20 -#define PLATFORM_TILE_GRASS 21 -#define PLATFORM_TILE_DIRT 22 -#define PLATFORM_TILE_COIN 23 -#define PLATFORM_TILE_PIPE 24 - -typedef struct { - int initialized; - prg32_platform_actor_t player; - int coins; - int finish_x; -} platform_demo_t; - -static platform_demo_t platform_demo; - -static void platform_put_run(uint8_t layer, uint8_t x0, uint8_t x1, uint8_t y, uint8_t tile) { - for (uint8_t x = x0; x <= x1 && x < PRG32_PLAYFIELD_COLS; ++x) { - prg32_playfield_put(layer, x, y, tile); - } -} - -static void platform_put_column(uint8_t x, uint8_t y0, uint8_t y1, uint8_t tile) { - for (uint8_t y = y0; y <= y1 && y < PRG32_PLAYFIELD_ROWS; ++y) { - prg32_playfield_put(1, x, y, tile); - } -} - -static void reset_platform_demo(void) { - platform_demo.initialized = 1; - platform_demo.coins = 0; - platform_demo.finish_x = 448; - - prg32_tile_define(0, NULL, PRG32_COLOR_BLACK, PRG32_COLOR_BLACK); - prg32_tile_define(PLATFORM_TILE_CLOUD, tile_cloud_small, PRG32_COLOR_WHITE, PRG32_COLOR_CYAN); - prg32_tile_define(PLATFORM_TILE_GRASS, tile_grass_top, PRG32_COLOR_WHITE, PRG32_COLOR_GREEN); - prg32_tile_define(PLATFORM_TILE_DIRT, tile_dirt_block, 0x9a60, 0x52a0); - prg32_tile_define(PLATFORM_TILE_COIN, tile_coin_bits, PRG32_COLOR_YELLOW, PRG32_COLOR_BLACK); - prg32_tile_define(PLATFORM_TILE_PIPE, tile_pipe_bits, PRG32_COLOR_GREEN, 0x03c0); - - prg32_platform_tile_flags(0, 0); - prg32_platform_tile_flags(PLATFORM_TILE_CLOUD, 0); - prg32_platform_tile_flags(PLATFORM_TILE_GRASS, PRG32_TILE_FLAG_SOLID); - prg32_platform_tile_flags(PLATFORM_TILE_DIRT, PRG32_TILE_FLAG_SOLID); - prg32_platform_tile_flags(PLATFORM_TILE_COIN, PRG32_TILE_FLAG_COLLECT); - prg32_platform_tile_flags(PLATFORM_TILE_PIPE, PRG32_TILE_FLAG_SOLID); - - prg32_playfield_clear(0, 0); - prg32_playfield_clear(1, 0); - prg32_playfield_parallax(0, PRG32_PARALLAX_1X / 3, PRG32_PARALLAX_1X / 2); - prg32_playfield_camera(0, 0); - - for (uint8_t x = 3; x < PRG32_PLAYFIELD_COLS; x += 9) { - prg32_playfield_put(0, x, 5 + (x & 3), PLATFORM_TILE_CLOUD); - } - - for (uint8_t x = 0; x < 60; ++x) { - if ((x >= 15 && x <= 17) || (x >= 35 && x <= 37) || (x >= 51 && x <= 52)) { - continue; - } - prg32_playfield_put(1, x, 22, PLATFORM_TILE_GRASS); - prg32_playfield_put(1, x, 23, PLATFORM_TILE_DIRT); - prg32_playfield_put(1, x, 24, PLATFORM_TILE_DIRT); - } - - platform_put_run(1, 7, 13, 17, PLATFORM_TILE_GRASS); - platform_put_run(1, 21, 27, 15, PLATFORM_TILE_GRASS); - platform_put_run(1, 40, 46, 18, PLATFORM_TILE_GRASS); - platform_put_run(1, 55, 60, 14, PLATFORM_TILE_GRASS); - platform_put_column(31, 19, 22, PLATFORM_TILE_PIPE); - platform_put_column(32, 19, 22, PLATFORM_TILE_PIPE); - platform_put_column(47, 17, 22, PLATFORM_TILE_PIPE); - platform_put_column(48, 17, 22, PLATFORM_TILE_PIPE); - - for (uint8_t x = 8; x <= 58; x += 5) { - uint8_t y = (x & 1) ? 12 : 14; - prg32_playfield_put(1, x, y, PLATFORM_TILE_COIN); - } - - prg32_platform_actor_init(&platform_demo.player, 1, 24, 128, 12, 16); -} - -static void platform_collect_overlaps(void) { - int x0 = platform_demo.player.x / PRG32_TILE_W; - int y0 = platform_demo.player.y / PRG32_TILE_H; - int x1 = (platform_demo.player.x + (int)platform_demo.player.w - 1) / PRG32_TILE_W; - int y1 = (platform_demo.player.y + (int)platform_demo.player.h - 1) / PRG32_TILE_H; - - for (int y = y0; y <= y1; ++y) { - for (int x = x0; x <= x1; ++x) { - if (x < 0 || y < 0 || - x >= PRG32_PLAYFIELD_COLS || - y >= PRG32_PLAYFIELD_ROWS) { - continue; - } - if (prg32_playfield_get(1, (uint8_t)x, (uint8_t)y) == PLATFORM_TILE_COIN) { - prg32_playfield_put(1, (uint8_t)x, (uint8_t)y, 0); - platform_demo.coins++; - } - } - } -} - -static void draw_platform_player(int x, int y, uint32_t frame) { - prg32_gfx_rect(x + 3, y, 6, 5, PRG32_COLOR_RED); - prg32_gfx_rect(x + 2, y + 5, 8, 7, PRG32_COLOR_BLUE); - prg32_gfx_rect(x, y + 7, 3, 4, 0xffde); - prg32_gfx_rect(x + 9, y + 7, 3, 4, 0xffde); - if ((frame / 6u) & 1u) { - prg32_gfx_rect(x + 2, y + 12, 3, 4, PRG32_COLOR_WHITE); - prg32_gfx_rect(x + 8, y + 12, 3, 4, PRG32_COLOR_WHITE); - } else { - prg32_gfx_rect(x, y + 12, 4, 4, PRG32_COLOR_WHITE); - prg32_gfx_rect(x + 8, y + 12, 4, 4, PRG32_COLOR_WHITE); - } -} - -static void draw_platformer(uint32_t frame) { - if (!platform_demo.initialized) { - reset_platform_demo(); - } - - uint32_t demo_input = PRG32_BTN_RIGHT; - int ahead_x = platform_demo.player.x + 22; - int foot_y = platform_demo.player.y + (int)platform_demo.player.h + 5; - if ((platform_demo.player.state & PRG32_PLATFORM_ON_GROUND) && - ((frame % 58u) == 4u || - !prg32_platform_solid_at(1, ahead_x, foot_y) || - prg32_platform_solid_at(1, ahead_x, platform_demo.player.y + 4))) { - demo_input |= PRG32_BTN_A; - } - prg32_platform_actor_step(&platform_demo.player, demo_input, 3, -10, 1, 7); - platform_collect_overlaps(); - - if (platform_demo.player.y > 210 || platform_demo.player.x > platform_demo.finish_x) { - reset_platform_demo(); - } - prg32_platform_camera_follow(&platform_demo.player, 88, 42); - - int camera_x = prg32_playfield_camera_x(); - int camera_y = prg32_playfield_camera_y(); - char line[40]; - - prg32_gfx_clear(PRG32_COLOR_CYAN); - prg32_playfield_draw(0, 1); - prg32_playfield_draw(1, 1); - draw_platform_player(platform_demo.player.x - camera_x, - platform_demo.player.y - camera_y, - frame); - draw_title("PLATFORM TILE DEMO", "SCROLLING MAP, COINS, COLLISION"); - snprintf(line, sizeof(line), "COINS %02d", platform_demo.coins % 100); - prg32_gfx_text8(226, 8, line, PRG32_COLOR_YELLOW, PRG32_COLOR_BLACK); - draw_footer(); -} - -#define RAY_MAP_W 16 -#define RAY_MAP_H 12 -#define RAY_CELL 64 -#define RAY_STRIPS 40 - -typedef struct { - int initialized; - int x; - int y; - int angle; -} raycaster_demo_t; - -static const char ray_map[RAY_MAP_H][RAY_MAP_W + 1] = { - "1111111111111111", - "1000000000000001", - "1022200011110201", - "1000200000010001", - "1000202200010001", - "1000000000010001", - "1110111020010001", - "1000100000010001", - "1000102222010001", - "1000000000000001", - "1000001111000001", - "1111111111111111", -}; - -static const int16_t ray_dir[32][2] = { - {256,0},{251,50},{237,98},{213,142},{181,181},{142,213},{98,237},{50,251}, - {0,256},{-50,251},{-98,237},{-142,213},{-181,181},{-213,142},{-237,98},{-251,50}, - {-256,0},{-251,-50},{-237,-98},{-213,-142},{-181,-181},{-142,-213},{-98,-237},{-50,-251}, - {0,-256},{50,-251},{98,-237},{142,-213},{181,-181},{213,-142},{237,-98},{251,-50}, -}; - -static raycaster_demo_t raycaster_demo; - -static void reset_raycaster_demo(void) { - raycaster_demo.initialized = 1; - raycaster_demo.x = RAY_CELL * 2 + RAY_CELL / 2; - raycaster_demo.y = RAY_CELL * 8 + RAY_CELL / 2; - raycaster_demo.angle = 30; -} - -static int ray_cell_at(int x, int y) { - if (x < 0 || y < 0) { - return '1'; - } - int tx = x / RAY_CELL; - int ty = y / RAY_CELL; - if (tx < 0 || ty < 0 || tx >= RAY_MAP_W || ty >= RAY_MAP_H) { - return '1'; - } - return ray_map[ty][tx]; -} - -static void ray_try_move(int dx, int dy) { - int nx = raycaster_demo.x + dx; - int ny = raycaster_demo.y + dy; - if (ray_cell_at(nx, raycaster_demo.y) == '0') { - raycaster_demo.x = nx; - } - if (ray_cell_at(raycaster_demo.x, ny) == '0') { - raycaster_demo.y = ny; - } -} - -static uint16_t ray_color_for_cell(int cell, int dist) { - if (dist > 360) { - return cell == '2' ? 0x780f : 0x528a; - } - if (dist > 220) { - return cell == '2' ? 0xb81f : 0x7bef; - } - return cell == '2' ? PRG32_COLOR_MAGENTA : PRG32_COLOR_CYAN; -} - -static void ray_update_auto(uint32_t frame) { - if ((frame % 96u) > 62u) { - raycaster_demo.angle = (raycaster_demo.angle + 1) & 31; - return; - } - - int dx = (ray_dir[raycaster_demo.angle][0] * 7) / 256; - int dy = (ray_dir[raycaster_demo.angle][1] * 7) / 256; - if (ray_cell_at(raycaster_demo.x + dx * 5, raycaster_demo.y + dy * 5) != '0') { - raycaster_demo.angle = (raycaster_demo.angle + 5) & 31; - } else { - ray_try_move(dx, dy); - } -} - -static void draw_ray_minimap(void) { - int ox = 8; - int oy = 52; - for (int y = 0; y < RAY_MAP_H; ++y) { - for (int x = 0; x < RAY_MAP_W; ++x) { - uint16_t color = ray_map[y][x] == '0' ? 0x2104 : PRG32_COLOR_WHITE; - prg32_gfx_rect(ox + x * 4, oy + y * 4, 3, 3, color); - } - } - prg32_gfx_rect(ox + (raycaster_demo.x / RAY_CELL) * 4, - oy + (raycaster_demo.y / RAY_CELL) * 4, - 4, - 4, - PRG32_COLOR_YELLOW); -} - -static void draw_raycaster(uint32_t frame) { - if (!raycaster_demo.initialized) { - reset_raycaster_demo(); - } - ray_update_auto(frame); - - prg32_gfx_rect(0, 0, PRG32_GAME_W, PRG32_GAME_H / 2, 0x18e3); - prg32_gfx_rect(0, PRG32_GAME_H / 2, PRG32_GAME_W, PRG32_GAME_H / 2, 0x4208); - - for (int strip = 0; strip < RAY_STRIPS; ++strip) { - int angle = (raycaster_demo.angle + 32 + (strip - RAY_STRIPS / 2) / 3) & 31; - int dist = 8; - int hit = '1'; - for (; dist < 640; dist += 8) { - int hx = raycaster_demo.x + (ray_dir[angle][0] * dist) / 256; - int hy = raycaster_demo.y + (ray_dir[angle][1] * dist) / 256; - hit = ray_cell_at(hx, hy); - if (hit != '0') { - break; - } - } - int height = 9800 / demo_clamp(dist, 24, 640); - height = demo_clamp(height, 10, 136); - int y = demo_clamp(102 - height / 2, 40, 184 - height); - uint16_t color = ray_color_for_cell(hit, dist); - prg32_gfx_rect(strip * 8, y, 8, height, color); - if ((strip & 1) == 0) { - prg32_gfx_rect(strip * 8, y, 1, height, 0x2104); - } - } - - draw_ray_minimap(); - draw_title("DOOM-INSPIRED RAYCASTER", "FIXED-POINT WALL CASTING"); - prg32_gfx_text8(8, 104, "RUNS ON RISC-V", PRG32_COLOR_YELLOW, PRG32_COLOR_BLACK); - draw_footer(); -} - -#define WING_TILE_STAR 40 -#define WING_TILE_PANEL 41 -#define WING_TILE_EDGE 42 - -typedef struct { - int initialized; - int cross_x; - int cross_y; - int enemy_x; - int enemy_y; - int enemy_dx; - int score; - int shot_timer; -} wing_demo_t; - -static wing_demo_t wing_demo; - -static void reset_wing_demo(void) { - wing_demo.initialized = 1; - wing_demo.cross_x = 160; - wing_demo.cross_y = 96; - wing_demo.enemy_x = 258; - wing_demo.enemy_y = 72; - wing_demo.enemy_dx = -3; - wing_demo.score = 0; - wing_demo.shot_timer = 0; - - prg32_tile_define(0, NULL, PRG32_COLOR_BLACK, PRG32_COLOR_BLACK); - prg32_tile_define(WING_TILE_STAR, tile_star_bits, PRG32_COLOR_WHITE, PRG32_COLOR_BLACK); - prg32_tile_define(WING_TILE_PANEL, tile_cockpit_panel, 0x7bef, 0x2104); - prg32_tile_define(WING_TILE_EDGE, tile_cockpit_edge, PRG32_COLOR_CYAN, 0x2104); - prg32_playfield_clear(0, 0); - prg32_playfield_clear(1, 0); - prg32_playfield_parallax(0, PRG32_PARALLAX_1X, PRG32_PARALLAX_1X); - prg32_playfield_parallax(1, 0, 0); - - for (uint8_t i = 0; i < 70; ++i) { - uint8_t x = (uint8_t)((i * 17u + 5u) % PRG32_PLAYFIELD_COLS); - uint8_t y = (uint8_t)((i * 23u + 3u) % PRG32_PLAYFIELD_ROWS); - prg32_playfield_put(0, x, y, WING_TILE_STAR); - } - - for (uint8_t x = 0; x < PRG32_PLAYFIELD_COLS; ++x) { - if (x < 7 || x > 32) { - prg32_playfield_put(1, x, 7, WING_TILE_EDGE); - } - for (uint8_t y = 22; y < PRG32_PLAYFIELD_ROWS; ++y) { - prg32_playfield_put(1, x, y, WING_TILE_PANEL); - } - } - for (uint8_t y = 8; y < 22; ++y) { - for (uint8_t x = 0; x < 4; ++x) { - prg32_playfield_put(1, x, y, WING_TILE_PANEL); - prg32_playfield_put(1, 39 - x, y, WING_TILE_PANEL); - } - } - prg32_playfield_put(1, 4, 8, WING_TILE_EDGE); - prg32_playfield_put(1, 35, 8, WING_TILE_EDGE); -} - -static void draw_wing_enemy(int x, int y, uint16_t color) { - draw_line(x, y + 10, x + 18, y, color); - draw_line(x + 18, y, x + 36, y + 10, color); - draw_line(x + 36, y + 10, x + 18, y + 20, color); - draw_line(x + 18, y + 20, x, y + 10, color); - prg32_gfx_rect(x + 14, y + 8, 8, 5, PRG32_COLOR_RED); -} - -static void draw_wing_commander(uint32_t frame) { - if (!wing_demo.initialized) { - reset_wing_demo(); - } - - wing_demo.cross_x = 86 + tri_wave(frame, 112, 150); - wing_demo.cross_y = 64 + tri_wave(frame + 31u, 84, 60); - wing_demo.enemy_x += wing_demo.enemy_dx; - if (wing_demo.enemy_x < 60 || wing_demo.enemy_x > 260) { - wing_demo.enemy_dx = -wing_demo.enemy_dx; - wing_demo.enemy_y = 58 + (int)((frame * 7u) % 64u); - } - if ((frame % 44u) == 4u) { - wing_demo.shot_timer = 8; - if (demo_abs(wing_demo.cross_x - (wing_demo.enemy_x + 18)) < 28 && - demo_abs(wing_demo.cross_y - (wing_demo.enemy_y + 10)) < 20) { - wing_demo.score++; - wing_demo.enemy_x = 240; - wing_demo.enemy_y = 54 + (int)((frame * 11u) % 72u); - } - } - if (wing_demo.shot_timer > 0) { - wing_demo.shot_timer--; - } - - prg32_playfield_scroll(0, 0, -(int)(frame * 3u)); - prg32_playfield_scroll(1, 0, 0); - prg32_playfield_draw_dual(); - draw_title("SPACE COCKPIT DEMO", "DUAL PLAYFIELD: STARS + DASH"); - - for (int i = 0; i < 8; ++i) { - int sx = 36 + (int)((frame * (uint32_t)(i + 3) + (uint32_t)i * 47u) % 248u); - int sy = 48 + (int)(((frame * 4u) + (uint32_t)i * 19u) % 82u); - prg32_gfx_rect(sx, sy, 2 + i / 3, 1, PRG32_COLOR_WHITE); - } - - draw_wing_enemy(wing_demo.enemy_x, wing_demo.enemy_y, PRG32_COLOR_MAGENTA); - if (wing_demo.shot_timer > 0) { - draw_line(160, 168, wing_demo.cross_x, wing_demo.cross_y, PRG32_COLOR_YELLOW); - draw_line(162, 168, wing_demo.cross_x, wing_demo.cross_y, PRG32_COLOR_YELLOW); - } - draw_line(wing_demo.cross_x - 12, wing_demo.cross_y, wing_demo.cross_x - 4, wing_demo.cross_y, PRG32_COLOR_GREEN); - draw_line(wing_demo.cross_x + 4, wing_demo.cross_y, wing_demo.cross_x + 12, wing_demo.cross_y, PRG32_COLOR_GREEN); - draw_line(wing_demo.cross_x, wing_demo.cross_y - 12, wing_demo.cross_x, wing_demo.cross_y - 4, PRG32_COLOR_GREEN); - draw_line(wing_demo.cross_x, wing_demo.cross_y + 4, wing_demo.cross_x, wing_demo.cross_y + 12, PRG32_COLOR_GREEN); - - char line[40]; - snprintf(line, sizeof(line), "SCORE %02d", wing_demo.score % 100); - prg32_gfx_text8(24, 176, line, PRG32_COLOR_YELLOW, PRG32_COLOR_BLACK); - prg32_gfx_text8(190, 176, "SHIELD 100", PRG32_COLOR_CYAN, PRG32_COLOR_BLACK); - draw_footer(); -} - -static void reset_demo_page(int page) { - if (page == 1) demo_prepare_playfields(); - if (page == 3) reset_pong_demo(); - if (page == 4) reset_breakout_demo(); - if (page == 5) reset_invaders_demo(); - if (page == 6) reset_pac_demo(); - if (page == 7) reset_tetris_demo(); - if (page == 9) reset_asteroids_demo(); - if (page == 10) reset_platform_demo(); - if (page == 11) reset_raycaster_demo(); - if (page == 12) reset_wing_demo(); -} - -void prg32_device_demo_run(void) { - int was_fullscreen = prg32_gfx_fullscreen_enabled(); - prg32_band_mode_t top_mode = prg32_band_mode(PRG32_BAND_TOP); - prg32_band_mode_t bottom_mode = prg32_band_mode(PRG32_BAND_BOTTOM); - - prg32_gfx_set_fullscreen(0); - prg32_band_set_mode(PRG32_BAND_TOP, PRG32_BAND_MODE_FPS); - prg32_band_set_mode(PRG32_BAND_BOTTOM, PRG32_BAND_MODE_CUSTOM); - prg32_band_set_game_info("PRG32 DEVICE DEMO"); - demo_prepare_playfields(); - prg32_input_wait_released(PRG32_BTN_A | PRG32_BTN_B | PRG32_BTN_SELECT); - - int page = 0; - uint32_t frame = 0; - uint32_t last = 0; - uint32_t next_frame_ms = prg32_ticks_ms(); - while (1) { - uint32_t input = prg32_input_read_menu(); - if ((input & PRG32_BTN_A) && !(last & PRG32_BTN_A)) { - prg32_audio_beep(330, 50); - prg32_input_wait_released(PRG32_BTN_A); - break; - } - if (((input & PRG32_BTN_SELECT) && !(last & PRG32_BTN_SELECT)) || - ((input & PRG32_BTN_B) && !(last & PRG32_BTN_B))) { - page = (page + 1) % DEMO_PAGE_COUNT; - reset_demo_page(page); - prg32_audio_beep(880, 40); - prg32_input_wait_released(PRG32_BTN_B | PRG32_BTN_SELECT); - next_frame_ms = prg32_ticks_ms(); - } - - prg32_gfx_lock(); - switch (page) { - case 0: draw_overview(frame); break; - case 1: draw_graphics(frame); break; - case 2: draw_system(frame); break; - case 3: draw_pong(frame); break; - case 4: draw_breakout(frame); break; - case 5: draw_space_invaders(frame); break; - case 6: draw_pacman(frame); break; - case 7: draw_tetris(frame); break; - case 8: draw_pole_position(frame); break; - case 9: draw_asteroids(frame); break; - case 10: draw_platformer(frame); break; - case 11: draw_raycaster(frame); break; - default: draw_wing_commander(frame); break; - } - - char band[56]; - snprintf(band, - sizeof(band), - "PAGE %d/%d INPUT 0x%04lx", - page + 1, - DEMO_PAGE_COUNT, - (unsigned long)input); - prg32_band_set_text(PRG32_BAND_BOTTOM, band); - prg32_gfx_present(); - prg32_gfx_unlock(); - last = input; - frame++; - wait_for_frame_target(&next_frame_ms); - } - - prg32_band_set_game_info(""); - prg32_band_set_mode(PRG32_BAND_TOP, top_mode); - prg32_band_set_mode(PRG32_BAND_BOTTOM, bottom_mode); - prg32_tile_clear(PRG32_COLOR_BLACK); - prg32_playfield_clear(0, 0); - prg32_playfield_clear(1, 0); - prg32_gfx_set_fullscreen(was_fullscreen); -} diff --git a/components/prg32/prg32_display_ili9341.c b/components/prg32/prg32_display_ili9341.c index fcd9472..6b99fa5 100644 --- a/components/prg32/prg32_display_ili9341.c +++ b/components/prg32/prg32_display_ili9341.c @@ -35,7 +35,7 @@ static const char *TAG = "prg32_lcd"; #endif #define PRG32_VIEWPORT_Y ((PRG32_LCD_H - PRG32_GAME_H) / 2) -#define PRG32_LCD_FLUSH_ROWS 20 +#define PRG32_LCD_FLUSH_ROWS 8 #if !PRG32_LCD_SOFT_SPI static spi_device_handle_t g_lcd; diff --git a/components/prg32/prg32_http_games.c b/components/prg32/prg32_http_games.c index 944b34a..fb676c8 100644 --- a/components/prg32/prg32_http_games.c +++ b/components/prg32/prg32_http_games.c @@ -90,6 +90,7 @@ static esp_err_t send_runtime(httpd_req_t *req) { "\"cart_abi_major\":%u," "\"cart_abi_minor\":%u," "\"cart_load_addr\":%lu," + "\"cart_max_size\":%lu," "\"cart_ram_size\":%lu," "\"cart_loaded\":%s," "\"qemu\":%s," @@ -113,6 +114,7 @@ static esp_err_t send_runtime(httpd_req_t *req) { (unsigned)PRG32_CART_ABI_MAJOR, (unsigned)PRG32_CART_ABI_MINOR, (unsigned long)(uint32_t)prg32_cart_load_addr(), + (unsigned long)(uint32_t)PRG32_CART_MAX_SIZE, (unsigned long)(uint32_t)prg32_cart_ram_size(), have_cart && info.loaded ? "true" : "false", qemu ? "true" : "false", @@ -368,13 +370,13 @@ static esp_err_t post_game(httpd_req_t *req) { #if PRG32_GAME_UPLOAD_ENABLE ESP_LOGI(TAG, "POST /api/games content_len=%u", (unsigned)req->content_len); if (req->content_len == 0 || - (size_t)req->content_len > PRG32_CART_RAM_SIZE + sizeof(prg32_cart_header_t)) { + (size_t)req->content_len > PRG32_CART_MAX_SIZE) { char msg[96]; snprintf(msg, sizeof(msg), "invalid cartridge size %u (max %lu)", (unsigned)req->content_len, - (unsigned long)(PRG32_CART_RAM_SIZE + sizeof(prg32_cart_header_t))); + (unsigned long)PRG32_CART_MAX_SIZE); httpd_resp_send_err(req, 400, msg); return ESP_FAIL; } diff --git a/components/prg32/prg32_input.c b/components/prg32/prg32_input.c index 41c0cc5..b96c3fc 100644 --- a/components/prg32/prg32_input.c +++ b/components/prg32/prg32_input.c @@ -10,10 +10,6 @@ #define PRG32_PIN_BTN_SELECT PRG32_PIN_BTN_START #endif -#ifndef PRG32_PIN_P2_SELECT -#define PRG32_PIN_P2_SELECT PRG32_PIN_P2_START -#endif - static const char *TAG = "prg32_input"; static void pin_in(const char *name, int p) { @@ -40,8 +36,6 @@ static void pin_in(const char *name, int p) { vTaskDelay(pdMS_TO_TICKS(1)); } -void prg32_controller_bridge_init(void); - void prg32_input_init(void) { printf("prg32_input_init START\n"); pin_in("LEFT", PRG32_PIN_BTN_LEFT); @@ -57,22 +51,9 @@ void prg32_input_init(void) { pin_in("SELECT", PRG32_PIN_BTN_SELECT); } - printf("prg32_input_init P2\n"); - pin_in("P2 LEFT", PRG32_PIN_P2_LEFT); - pin_in("P2 RIGHT", PRG32_PIN_P2_RIGHT); - pin_in("P2 UP", PRG32_PIN_P2_UP); - pin_in("P2 DOWN", PRG32_PIN_P2_DOWN); - pin_in("P2 A", PRG32_PIN_P2_A); - pin_in("P2 B", PRG32_PIN_P2_B); - pin_in("P2 START", PRG32_PIN_P2_START); - printf("prg32_input_init P2_SELECT\n"); - if (PRG32_PIN_P2_SELECT != PRG32_PIN_P2_START) { - pin_in("P2 SELECT", PRG32_PIN_P2_SELECT); - } pin_in("SETUP", PRG32_PIN_SETUP); printf("prg32_input_init END\n"); //vTaskDelay(pdMS_TO_TICKS(1)); - //prg32_controller_bridge_init(); } uint32_t prg32_input_read(void) { @@ -80,16 +61,14 @@ uint32_t prg32_input_read(void) { } uint32_t prg32_input_read_player(uint8_t player) { - uint32_t input = prg32_input_read(); if (player == 2) { - return (input >> 8) & 0x7fu; + return 0; } - return input & 0x7fu; + return prg32_input_read() & 0x7fu; } uint32_t prg32_input_read_menu(void) { - uint32_t input = prg32_input_read(); - return (input & 0x7fu) | ((input >> 8) & 0x7fu); + return prg32_input_read() & 0x7fu; } void prg32_input_wait_released(uint32_t mask) { diff --git a/components/prg32/prg32_performance.c b/components/prg32/prg32_performance.c index 4297725..72bd3f3 100644 --- a/components/prg32/prg32_performance.c +++ b/components/prg32/prg32_performance.c @@ -11,6 +11,7 @@ #include #include #include +#include #include #ifndef CONFIG_PRG32_METRICS_BOARD_ID @@ -109,8 +110,8 @@ typedef struct { uint32_t stars; } prg32_perf_scene_t; -static prg32_perf_sample_t g_samples[PRG32_PERF_MAX_SAMPLES]; -static prg32_perf_window_t g_windows[PRG32_PERF_MAX_WINDOWS]; +static prg32_perf_sample_t *g_samples; +static prg32_perf_window_t *g_windows; static prg32_perf_screen_result_t g_screen_results[PRG32_PERF_SCREEN_COUNT]; static prg32_performance_summary_t g_summary; static uint32_t g_sample_count; @@ -148,6 +149,22 @@ static const prg32_perf_screen_def_t g_perf_screens[PRG32_PERF_SCREEN_COUNT] = { }, }; +static int perf_buffers_alloc(void) { + if (g_samples && g_windows) { + return 0; + } + g_samples = calloc(PRG32_PERF_MAX_SAMPLES, sizeof(prg32_perf_sample_t)); + g_windows = calloc(PRG32_PERF_MAX_WINDOWS, sizeof(prg32_perf_window_t)); + if (!g_samples || !g_windows) { + free(g_samples); + free(g_windows); + g_samples = NULL; + g_windows = NULL; + return -1; + } + return 0; +} + static void copy_text(char *dst, size_t dst_size, const char *src) { if (!dst || dst_size == 0) { return; @@ -487,7 +504,7 @@ static void scene_draw_screen(uint8_t screen_index, } static void store_sample(const prg32_perf_sample_t *sample) { - if (!sample || g_sample_count >= PRG32_PERF_MAX_SAMPLES) { + if (!sample || !g_samples || g_sample_count >= PRG32_PERF_MAX_SAMPLES) { return; } g_samples[g_sample_count++] = *sample; @@ -550,7 +567,7 @@ static void compute_range_stats(prg32_perf_window_t *w, } static void compute_window(uint32_t window_index, uint32_t first, uint32_t last) { - if (g_window_count >= PRG32_PERF_MAX_WINDOWS || first >= last) { + if (!g_windows || g_window_count >= PRG32_PERF_MAX_WINDOWS || first >= last) { return; } compute_range_stats(&g_windows[g_window_count++], window_index, first, last); @@ -558,6 +575,9 @@ static void compute_window(uint32_t window_index, uint32_t first, uint32_t last) static void compute_screen_results(void) { g_screen_result_count = 0; + if (!g_samples) { + return; + } for (uint8_t screen_index = 0; screen_index < PRG32_PERF_SCREEN_COUNT; ++screen_index) { uint32_t first = UINT32_MAX; uint32_t last = 0; @@ -589,7 +609,7 @@ static void compute_results(uint64_t started_us, uint64_t finished_us) { g_window_count = 0; g_screen_result_count = 0; memset(&g_summary, 0, sizeof(g_summary)); - if (g_sample_count == 0) { + if (!g_samples || !g_windows || g_sample_count == 0) { return; } @@ -734,6 +754,17 @@ static void wait_for_summary_exit(void) { } int prg32_performance_test_run(void) { + if (perf_buffers_alloc() != 0) { + memset(&g_summary, 0, sizeof(g_summary)); + g_perf_running = 0; + g_perf_has_results = 0; + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "PERFORMANCE TEST", PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 48, "NOT ENOUGH RAM", PRG32_COLOR_YELLOW, 0); + prg32_gfx_present(); + wait_for_summary_exit(); + return -1; + } prg32_perf_scene_t scene; scene_init(&scene); g_perf_running = 1; @@ -741,8 +772,8 @@ int prg32_performance_test_run(void) { g_sample_count = 0; g_window_count = 0; g_screen_result_count = 0; - memset(g_samples, 0, sizeof(g_samples)); - memset(g_windows, 0, sizeof(g_windows)); + memset(g_samples, 0, PRG32_PERF_MAX_SAMPLES * sizeof(g_samples[0])); + memset(g_windows, 0, PRG32_PERF_MAX_WINDOWS * sizeof(g_windows[0])); memset(g_screen_results, 0, sizeof(g_screen_results)); memset(&g_summary, 0, sizeof(g_summary)); diff --git a/components/prg32/prg32_setup_store.c b/components/prg32/prg32_setup_store.c index 3c52d2c..45b916a 100644 --- a/components/prg32/prg32_setup_store.c +++ b/components/prg32/prg32_setup_store.c @@ -7,6 +7,7 @@ #include "freertos/task.h" #include #include +#include #include #define STORE_ACCEPT (PRG32_BTN_SELECT | PRG32_BTN_B) @@ -28,10 +29,35 @@ typedef struct { } store_game_t; static const char *TAG = "prg32_setup_store"; -static char catalog_body[PRG32_STORE_CATALOG_MAX_BYTES]; -static store_game_t games[STORE_MAX_GAMES]; +static char *catalog_body; +static store_game_t *games; static int game_count; +static int store_buffers_alloc(char *status, size_t status_len) { + if (catalog_body && games) { + return 0; + } + catalog_body = calloc(1, PRG32_STORE_CATALOG_MAX_BYTES); + games = calloc(STORE_MAX_GAMES, sizeof(store_game_t)); + if (!catalog_body || !games) { + free(catalog_body); + free(games); + catalog_body = NULL; + games = NULL; + snprintf(status, status_len, "NO MEM"); + return -1; + } + return 0; +} + +static void store_buffers_free(void) { + free(catalog_body); + free(games); + catalog_body = NULL; + games = NULL; + game_count = 0; +} + static void wait_and_show(const char *line, uint32_t ms) { prg32_gfx_clear(PRG32_COLOR_BLACK); prg32_gfx_text8(8, 8, "CARTRIDGE STORE", PRG32_COLOR_WHITE, 0); @@ -140,6 +166,9 @@ static int json_array_strings_after(const char *start, const char *end, const ch static int parse_catalog(const char *json) { game_count = 0; + if (!json || !games) { + return 0; + } const char *p = json; while ((p = strchr(p, '{')) != NULL && game_count < STORE_MAX_GAMES) { const char *end = strchr(p, '}'); @@ -184,10 +213,18 @@ static int contains_casefold(const char *text, const char *needle) { } static int filter_catalog(const char *query) { - store_game_t all_games[STORE_MAX_GAMES]; + if (!catalog_body || !games) { + return 0; + } + store_game_t *all_games = malloc(STORE_MAX_GAMES * sizeof(store_game_t)); + if (!all_games) { + game_count = 0; + return 0; + } int all_count = parse_catalog(catalog_body); - memcpy(all_games, games, sizeof(all_games)); + memcpy(all_games, games, STORE_MAX_GAMES * sizeof(store_game_t)); if (!query || !query[0]) { + free(all_games); return all_count; } game_count = 0; @@ -198,6 +235,7 @@ static int filter_catalog(const char *query) { games[game_count++] = all_games[i]; } } + free(all_games); return game_count; } @@ -208,7 +246,11 @@ static int fetch_catalog(const char *base_url, char *status, size_t status_len) "fetch catalog: %s heap=%lu", url, (unsigned long)esp_get_free_heap_size()); - memset(catalog_body, 0, sizeof(catalog_body)); + if (!catalog_body || !games) { + snprintf(status, status_len, "NO MEM"); + return -1; + } + memset(catalog_body, 0, PRG32_STORE_CATALOG_MAX_BYTES); esp_http_client_config_t config = { .url = url, .method = HTTP_METHOD_GET, @@ -241,10 +283,10 @@ static int fetch_catalog(const char *base_url, char *status, size_t status_len) } bool truncated = false; size_t len = 0; - while (len + 1 < sizeof(catalog_body)) { + while (len + 1 < PRG32_STORE_CATALOG_MAX_BYTES) { int got = esp_http_client_read(client, catalog_body + len, - sizeof(catalog_body) - len - 1); + PRG32_STORE_CATALOG_MAX_BYTES - len - 1); if (got < 0) { ESP_LOGI(TAG, "catalog read failed"); snprintf(status, status_len, "READ"); @@ -320,7 +362,8 @@ static int stream_download(const char *base_url, const store_game_t *game, uint8 esp_http_client_cleanup(client); return -1; } - if (content_len <= 0 || (size_t)content_len > slot_size) { + if (content_len <= 0 || (size_t)content_len > PRG32_CART_MAX_SIZE || + (size_t)content_len > slot_size) { snprintf(status, status_len, "TOO LARGE"); esp_http_client_close(client); esp_http_client_cleanup(client); @@ -547,16 +590,22 @@ void prg32_setup_store_browse_run(void) { return; } char status[32]; + if (store_buffers_alloc(status, sizeof(status)) != 0) { + char msg[48]; + snprintf(msg, sizeof(msg), "UNAVAILABLE: %s", status); + wait_and_show(msg, 2000); + return; + } wait_and_show("CONNECTING...", 10); if (fetch_catalog(url, status, sizeof(status)) != 0) { char msg[48]; snprintf(msg, sizeof(msg), "UNAVAILABLE: %s", status); wait_and_show(msg, 2000); - return; + goto done; } if (game_count == 0) { wait_and_show("NO GAMES", 2000); - return; + goto done; } int selected = 0; int page = 0; @@ -567,7 +616,7 @@ void prg32_setup_store_browse_run(void) { uint32_t input = prg32_input_read_menu(); if (input & STORE_CANCEL) { prg32_input_wait_released(STORE_CANCEL); - return; + goto done; } if (input & PRG32_BTN_UP) { if (selected > 0) { @@ -610,11 +659,14 @@ void prg32_setup_store_browse_run(void) { } prg32_input_wait_released(STORE_ACCEPT); } else if (run_detail(url, selected)) { - return; + goto done; } else { prg32_input_wait_released(STORE_ACCEPT); } } vTaskDelay(pdMS_TO_TICKS(10)); } + +done: + store_buffers_free(); } diff --git a/components/prg32/prg32_system.c b/components/prg32/prg32_system.c index 05bcd1e..6768995 100644 --- a/components/prg32/prg32_system.c +++ b/components/prg32/prg32_system.c @@ -1,5 +1,6 @@ #include "prg32.h" #include "prg32_config.h" +#include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include @@ -10,7 +11,6 @@ void prg32_display_init(void); void prg32_input_init(void); void prg32_audio_pwm_init(void); void prg32_abi_exports_keep(void); -void prg32_device_demo_run(void); #ifndef PRG32_BOOT_SETUP_MODE #define PRG32_BOOT_SETUP_MODE 0 @@ -50,7 +50,6 @@ typedef enum { SETUP_OPTION_STORE_BROWSE, SETUP_OPTION_AUDIO, SETUP_OPTION_DEVELOPER, - SETUP_OPTION_DEMO, SETUP_OPTION_PERFORMANCE, SETUP_OPTION_ABOUT, SETUP_OPTION_EXIT, @@ -87,6 +86,41 @@ static void draw_setup_status(int y) { } } +static unsigned long bytes_to_kib(size_t bytes) { + return (unsigned long)((bytes + 1023u) / 1024u); +} + +static size_t setup_available_cart_flash(void) { + size_t available = 0; + for (uint8_t slot = 0; slot < PRG32_CART_SLOT_COUNT; ++slot) { + size_t slot_size = prg32_cart_slot_size(slot); + prg32_cart_info_t info; + if (slot_size == 0 || + prg32_cart_get_slot_info(slot, &info) != 0) { + continue; + } + if (!info.stored) { + available += slot_size; + continue; + } + size_t used = (size_t)info.code_size + (size_t)info.audio_size; + if (used < slot_size) { + available += slot_size - used; + } + } + return available; +} + +static void draw_setup_resources(int y) { + char line[48]; + snprintf(line, + sizeof(line), + "RAM: %luK CART FLASH: %luK", + bytes_to_kib(esp_get_free_heap_size()), + bytes_to_kib(setup_available_cart_flash())); + prg32_gfx_text8(8, y, line, PRG32_COLOR_YELLOW, 0); +} + static int stored_slots(uint8_t *slots, int max_slots) { int count = 0; for (uint8_t slot = 0; slot < PRG32_CART_SLOT_COUNT; ++slot) { @@ -726,10 +760,6 @@ static int setup_menu(void) { SETUP_OPTION_DEVELOPER, "DEVELOPER MENU", }; - options[option_count++] = (setup_option_t){ - SETUP_OPTION_DEMO, - "DEVICE DEMO", - }; options[option_count++] = (setup_option_t){ SETUP_OPTION_PERFORMANCE, "PERFORMANCE TEST", @@ -807,10 +837,6 @@ static int setup_menu(void) { developer_menu(); break; } - if (selected == SETUP_OPTION_DEMO) { - prg32_device_demo_run(); - break; - } if (selected == SETUP_OPTION_PERFORMANCE) { start_performance_http_api(); prg32_performance_test_run(); @@ -831,9 +857,10 @@ static int setup_menu(void) { prg32_gfx_clear(PRG32_COLOR_BLACK); prg32_gfx_text8(8, 8, "PRG32 SETUP", PRG32_COLOR_WHITE, 0); draw_setup_status(28); - draw_cartridge_status(76); + draw_setup_resources(76); + draw_cartridge_status(92); for (int i = 0; i < option_count; ++i) { - int y = 102 + i * 14; + int y = 120 + i * 11; prg32_gfx_text8(8, y, i == choice ? ">" : " ", PRG32_COLOR_GREEN, 0); prg32_gfx_text8(24, y, options[i].label, PRG32_COLOR_WHITE, 0); } diff --git a/docs/abi.md b/docs/abi.md index ee384f3..bb665bb 100644 --- a/docs/abi.md +++ b/docs/abi.md @@ -199,7 +199,6 @@ Setup screens and cartridge programs use the same button bitmasks: | `prg32_cart_default_slot` | return the saved default cartridge slot, or `-1` | | `prg32_cart_set_default_slot` | save a default cartridge slot, or clear with `-1` | | `prg32_cart_select_default` | load the saved default cartridge | -| `prg32_device_demo_run` | run the firmware-owned device demo from setup or a lab | | `prg32_performance_test_run` | run the unattended multi-screen setup benchmark | | `prg32_performance_has_results` | return nonzero when onboard benchmark results are available | | `prg32_performance_summary` | copy the latest benchmark summary into a caller-provided struct | diff --git a/docs/api.md b/docs/api.md index 2f523c5..0a95ad5 100644 --- a/docs/api.md +++ b/docs/api.md @@ -120,7 +120,8 @@ Typical response fields: "cart_abi_major": 1, "cart_abi_minor": 0, "cart_load_addr": 1107296256, - "cart_ram_size": 98304, + "cart_max_size": 32768, + "cart_ram_size": 65536, "cart_loaded": true, "qemu": false, "cart": { @@ -253,7 +254,7 @@ Success response: Expected behavior: - upload is accepted only when `PRG32_GAME_UPLOAD_ENABLE` is enabled; -- the request body must fit in the cartridge RAM limit plus header; +- the request body must fit in the 32 KiB cartridge package limit; - invalid cartridge images return `400` with the cartridge validation error; - disabled upload support returns `403`. diff --git a/docs/assets.md b/docs/assets.md index 3be36ba..2e8dbfe 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -70,7 +70,7 @@ python3 -m pip install pillow mido ``` Generated arrays can be included in firmware examples or packaged into -uploadable cartridges when they fit inside the cartridge RAM limit. +uploadable cartridges when they fit inside the 32 KiB cartridge package limit. ## Cartridge AUDIO Blocks diff --git a/docs/cartridge_store.md b/docs/cartridge_store.md index 38e5b0f..553db0d 100644 --- a/docs/cartridge_store.md +++ b/docs/cartridge_store.md @@ -107,6 +107,11 @@ python3 tools/prg32_game.py publish-bundle tetris.zip \ --store-url http://192.168.1.42:5080 ``` +The PRG32 hardware smoke test is published from the external +[DeviceDemo repository](https://github.com/riscv-prg32/DeviceDemo). That +repository contains its own cartridge metadata, colophon, Store bundle manifest, +and build/publish instructions for the `esp32c6` and `qemu` variants. + Download a cartridge from the host and upload it to the board: ```bash diff --git a/docs/cartridges.md b/docs/cartridges.md index d60cef5..b194f95 100644 --- a/docs/cartridges.md +++ b/docs/cartridges.md @@ -332,7 +332,7 @@ The existing graphics examples already follow the right shape: - use normal RV32 calling convention - save `ra` before calling PRG32 C helpers - keep stack alignment at 16 bytes around C calls -- keep code/data small enough for the 32 KiB cartridge RAM window +- keep code/data small enough for the 64 KiB cartridge RAM window The cartridge linker resolves normal calls such as: @@ -408,9 +408,10 @@ This is intentionally a classroom loader, not a general dynamic linker. - Cartridges are linked for one PRG32 firmware build. - If the firmware is rebuilt, rebuild the cartridges. -- Cartridge RAM is 32 KiB. +- Cartridge package size is 32 KiB. +- Cartridge RAM is 64 KiB. - AUDIO blocks are stored after the code payload and count against cartridge - partition size, not cartridge executable RAM. + package size and partition size, not cartridge executable RAM. - Two flash slots, `cart0` and `cart1`, are available. Only one cartridge is loaded into executable RAM at a time. - QEMU staging requires QEMU to be stopped before patching `flash_image.bin`. diff --git a/docs/deployment.md b/docs/deployment.md index bdb70dc..4dfc2eb 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -39,8 +39,12 @@ boot cartridge. In setup mode, choose access-point mode for the standard cartridge upload workflow, or infrastructure mode to scan nearby SSIDs and connect the board to an existing network. The same setup menu can launch the audio setup page, the -developer band menu, the about screen, and the device demo. With no cartridge -installed, the firmware starts the upload AP and waits in setup. +developer band menu, the performance test, and the about screen. With no +cartridge installed, the firmware starts the upload AP and waits in setup. + +The device smoke test is maintained as the external +[DeviceDemo cartridge](https://github.com/riscv-prg32/DeviceDemo). Build and +upload that cartridge when a classroom deployment needs the former setup demo. ## Flash once, upload games diff --git a/docs/examples.md b/docs/examples.md index 5f55d4e..f83984c 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -32,7 +32,7 @@ examples/games// Use these for Computing Architecture and C Programming classes. The platformer, raycaster, and wing commander C examples are the fuller -playable companions to the setup-launched demo pages. The assembly versions +playable companions to the DeviceDemo cartridge pages. The assembly versions remain compact so students can trace registers, stack frames, and ABI calls without losing the main idea. @@ -52,9 +52,9 @@ Feature demos live under `examples/features` and isolate one framework topic: - audio synthesis - onboard RGB LED and audio VU meter -The setup-launched device demo is not a cartridge example, but it is useful for -trainers: it checks framework capabilities and includes small 320x200 sketches -inspired by Pong, Breakout, Space Invaders, Pacman, Tetris, Pole Position, and -Asteroids, plus a tile-engine platformer and a Doom-style fixed-point -raycaster. It also includes a space-cockpit sketch that demonstrates dual -playfields with a scrolling starfield and fixed cockpit overlay. +The fuller hardware smoke test lives in the external +[DeviceDemo cartridge](https://github.com/riscv-prg32/DeviceDemo). It checks +framework capabilities and includes small 320x200 sketches inspired by Pong, +Breakout, Space Invaders, Pacman, Tetris, Pole Position, and Asteroids, plus a +tile-engine platformer, a Doom-style fixed-point raycaster, and a space-cockpit +sketch that demonstrates dual playfields. diff --git a/docs/external_controllers.md b/docs/external_controllers.md index 99aceca..6cb4897 100644 --- a/docs/external_controllers.md +++ b/docs/external_controllers.md @@ -1,86 +1,21 @@ -# PRG32 USB Game Controller Support +# PRG32 Controller Support -## Important ESP32-C6 limitation - -ESP32-C6 provides USB Serial/JTAG functionality for connection to a host PC. It -is not a general-purpose USB host port for directly plugging in standard USB HID -game controllers. PRG32 therefore supports USB controllers through an -**external USB-host bridge**. - -Recommended bridge choices: - -- ESP32-S3 running a USB HID host bridge firmware. -- RP2040 with USB host capability or a USB-host shield. -- CH559/CH554 USB-host microcontroller. -- A PC-side helper during laboratories, forwarding keyboard/gamepad state over serial. - -The C6 game code does not change. It reads the same PRG32 button bitmask. - -## PRG32 button map - -| PRG32 bit | Meaning | Gamepad mapping | -|---|---|---| -| `PRG32_BTN_LEFT` | Move left | D-pad left / left stick X negative | -| `PRG32_BTN_RIGHT` | Move right | D-pad right / left stick X positive | -| `PRG32_BTN_UP` | Move up | D-pad up / left stick Y negative | -| `PRG32_BTN_DOWN` | Move down | D-pad down / left stick Y positive | -| `PRG32_BTN_A` | Primary action | South button, often A/Cross | -| `PRG32_BTN_B` | Back / secondary action | East button, often B/Circle | -| `PRG32_BTN_SELECT` / `PRG32_BTN_START` | Select / pause | Start/Menu button | - -Player 2 uses the same layout shifted to bits 8-14: +PRG32 uses one local digital joystick on the ESP32-C6 board. The input API keeps +the small register-like button mask used by the teaching examples: | PRG32 bit | Meaning | |---|---| -| `PRG32_P2_BTN_LEFT` | Player 2 left | -| `PRG32_P2_BTN_RIGHT` | Player 2 right | -| `PRG32_P2_BTN_UP` | Player 2 up | -| `PRG32_P2_BTN_DOWN` | Player 2 down | -| `PRG32_P2_BTN_A` | Player 2 confirm/action | -| `PRG32_P2_BTN_B` | Player 2 back/secondary action | -| `PRG32_P2_BTN_SELECT` / `PRG32_P2_BTN_START` | Player 2 select | - -## UART bridge packet - -The bridge sends a compact packet to the ESP32-C6: - -```text -byte 0: 'U' -byte 1: 'G' -byte 2: low byte of button mask -byte 3: high byte of button mask -``` - -Example: LEFT + A = `0x0001 | 0x0010 = 0x0011`, so the packet is: - -```text -'U' 'G' 0x11 0x00 -``` - -Example: player 2 LEFT is bit 8, so the packet is: - -```text -'U' 'G' 0x00 0x01 -``` - -## Framework configuration - -In `main/prg32_config.h`: - -```c -#define PRG32_CONTROLLER_BRIDGE_ENABLE 0 -#define PRG32_CONTROLLER_BRIDGE_UART 1 -#define PRG32_CONTROLLER_BRIDGE_BAUD 115200 -#define PRG32_PIN_CONTROLLER_TX 16 -#define PRG32_PIN_CONTROLLER_RX 17 -``` - -The function `prg32_input_read()` merges GPIO buttons and the bridge state, so -local arcade buttons and external USB controllers can be used at the same time. -The bridge is disabled in the default physical joystick build to avoid a -floating UART RX pin in student wiring; enable it only when a bridge is wired. - -## Teaching note - -This layer is deliberately similar to memory-mapped console input: the assembly -game sees one integer register-like value and tests bits with `andi`. +| `PRG32_BTN_LEFT` | Move left | +| `PRG32_BTN_RIGHT` | Move right | +| `PRG32_BTN_UP` | Move up | +| `PRG32_BTN_DOWN` | Move down | +| `PRG32_BTN_A` | Primary action | +| `PRG32_BTN_B` | Back / secondary action | +| `PRG32_BTN_SELECT` / `PRG32_BTN_START` | Select / pause | + +The firmware no longer supports a UART controller bridge or a second local +digital joystick. `prg32_input_read_player(1)` returns the local joystick mask, +and `prg32_input_read_player(2)` returns `0` for source compatibility. + +Multiplayer cartridges should exchange remote player input through the PRG32 +multiplayer API instead of wiring a second local controller. diff --git a/docs/framework_manual.md b/docs/framework_manual.md index 9a9de40..36d11ca 100644 --- a/docs/framework_manual.md +++ b/docs/framework_manual.md @@ -227,7 +227,8 @@ Important constants: - `PRG32_CART_META_MAGIC`: optional metadata trailer magic, `PRG32META`. - `PRG32_CART_META_ABI`: metadata JSON ABI, `prg32-metadata-1.0`. - `PRG32_CART_COLOPHON_ABI`: colophon JSON ABI, `prg32-colophon-1.0`. -- `PRG32_CART_RAM_SIZE`: executable cartridge RAM window, currently 32 KiB. +- `PRG32_CART_MAX_SIZE`: maximum `.prg32` package size, currently 32 KiB. +- `PRG32_CART_RAM_SIZE`: executable cartridge RAM window, currently 64 KiB. - `PRG32_CART_SLOT_COUNT`: number of persistent flash cartridge slots. Important functions: @@ -270,17 +271,16 @@ has been saved, that cartridge starts automatically even when multiple slots are filled. The setup main menu contains cartridge launch, default cartridge selection, -Wi-Fi setup, audio setup, the developer band menu, the device demo, the -performance test, the about screen, and exit. The Cartridge Store integration +Wi-Fi setup, Cartridge Store configuration and browsing, audio setup, the +developer band menu, the performance test, the about screen, and exit. The Cartridge Store integration contract adds manual/discovered store URL entry, browsing, colophon preview, and download-to-slot behavior for future firmware work. Use UP/DOWN to choose, SELECT or B to confirm, and A to cancel/back. The -device demo is a firmware-owned smoke test for display, input, audio, Wi-Fi -status, cartridges, sprites, scrolling, playfield rendering, status bands, and -small sketches inspired by Pong, Breakout, Space Invaders, Pacman, Tetris, -Pole Position, Asteroids, a tile-engine platformer, and a Doom-style -raycaster. It also includes a space-cockpit sketch where the starfield and -cockpit are separate playfields. +device smoke test is now the external +[DeviceDemo cartridge](https://github.com/riscv-prg32/DeviceDemo), which +exercises display, input, audio, sprites, scrolling, playfield rendering, +status bands, and small classroom sketches through the same cartridge ABI used +by student games. The Wi-Fi setup screen lets the user choose access-point mode or infrastructure mode. Infrastructure mode scans for nearby SSIDs, lists them on screen, and diff --git a/docs/labs/lab_01_hello_world.md b/docs/labs/lab_01_hello_world.md index a7f1f1e..515eb6e 100644 --- a/docs/labs/lab_01_hello_world.md +++ b/docs/labs/lab_01_hello_world.md @@ -12,7 +12,7 @@ Build, flash, and inspect the resident PRG32 runtime. 4. Run task `PRG32: flash monitor`. 5. Confirm the monitor reaches `PRG32 runtime initialized`. 6. Hold A+B while resetting the board and confirm setup mode appears. -7. Run `DEVICE DEMO` from setup. +7. Upload and run the external DeviceDemo cartridge. ## Checkpoint @@ -26,7 +26,7 @@ If no board is available, run: 2. `PRG32: qemu screen` The QEMU monitor should reach the PRG32 runtime, and the virtual screen should -show the 320x240 setup/device-demo display. +show the 320x240 setup display and the 320x200 cartridge viewport. ## Reflection diff --git a/examples/features/README.md b/examples/features/README.md index 025f890..1f12aca 100644 --- a/examples/features/README.md +++ b/examples/features/README.md @@ -3,10 +3,11 @@ These demos isolate framework rendering features without full game rules. Use them when teaching one graphics concept at a time. -The resident firmware also includes a setup-launched `DEVICE DEMO` that checks +The fuller hardware smoke test is maintained as the external +[DeviceDemo cartridge](https://github.com/riscv-prg32/DeviceDemo). It checks the physical display, joystick input, audio beep, Wi-Fi status, cartridge state, sprites, scrolling, status bands, dual playfields, and arcade-inspired 320x200 -sketches without rebuilding a cartridge. +sketches through the same cartridge workflow students use for games. | Demo | Assembly source | C source | Entry prefixes | Shows | |---|---|---|---|---| diff --git a/examples/games/README.md b/examples/games/README.md index eab1897..0f822d3 100644 --- a/examples/games/README.md +++ b/examples/games/README.md @@ -64,7 +64,7 @@ C versions are best for: - first C programming labs - comparing assembly and C implementations - showing that PRG32 is a small API, not an assembly-only runtime -- playing the fuller versions of the device-demo game ideas, especially the +- playing the fuller versions of the DeviceDemo cartridge ideas, especially the platformer tile-engine course, the fixed-point raycaster, and the dual-playfield space cockpit diff --git a/main/prg32_config.h b/main/prg32_config.h index ea0ab21..78c78f1 100644 --- a/main/prg32_config.h +++ b/main/prg32_config.h @@ -60,22 +60,10 @@ #define PRG32_PIN_BTN_A -1 #define PRG32_PIN_BTN_B -1 #define PRG32_PIN_BTN_START -1 -#define PRG32_PIN_P2_LEFT -1 -#define PRG32_PIN_P2_RIGHT -1 -#define PRG32_PIN_P2_UP -1 -#define PRG32_PIN_P2_DOWN -1 -#define PRG32_PIN_P2_A -1 -#define PRG32_PIN_P2_B -1 -#define PRG32_PIN_P2_START -1 #define PRG32_PIN_SETUP -1 #define PRG32_PIN_BUZZER -1 #define PRG32_BOOT_DIAGNOSTIC_DELAY_MS 0 -#define PRG32_CONTROLLER_BRIDGE_ENABLE 0 -#define PRG32_CONTROLLER_BRIDGE_UART 1 -#define PRG32_CONTROLLER_BRIDGE_BAUD 115200 -#define PRG32_PIN_CONTROLLER_TX -1 -#define PRG32_PIN_CONTROLLER_RX -1 #define PRG32_PIN_RGB_LED -1 #define PRG32_GAME_UPLOAD_ENABLE 0 @@ -109,23 +97,8 @@ #define PRG32_PIN_SETUP 14 -/* Optional second digital joystick. Leave pins at -1 when not mounted. */ -#define PRG32_PIN_P2_LEFT -1 -#define PRG32_PIN_P2_RIGHT -1 -#define PRG32_PIN_P2_UP -1 -#define PRG32_PIN_P2_DOWN -1 -#define PRG32_PIN_P2_A -1 -#define PRG32_PIN_P2_B -1 -#define PRG32_PIN_P2_START -1 #define PRG32_PIN_BUZZER -1 -/* Optional USB-controller support via an external USB HID host bridge. */ -#define PRG32_CONTROLLER_BRIDGE_ENABLE -1 -#define PRG32_CONTROLLER_BRIDGE_UART 1 -#define PRG32_CONTROLLER_BRIDGE_BAUD 115200 -#define PRG32_PIN_CONTROLLER_TX 16 -#define PRG32_PIN_CONTROLLER_RX 17 - /* * Many ESP32-C6 boards route the onboard addressable RGB LED to GPIO8. The * reference PRG32 ILI9341 harness also uses GPIO8 for LCD D/C, so the RGB LED @@ -205,6 +178,5 @@ #else #define PRG32_PIN_BTN_SELECT PRG32_PIN_BTN_START #endif -#define PRG32_PIN_P2_SELECT PRG32_PIN_P2_START #endif diff --git a/tools/prg32_game.py b/tools/prg32_game.py index 903bb3e..d7f0fa6 100755 --- a/tools/prg32_game.py +++ b/tools/prg32_game.py @@ -45,7 +45,8 @@ AUDIO_BLOCK_MAGIC = b"AUD0" DEFAULT_PARTITION_TABLE = ROOT / "partitions_prg32.csv" DEFAULT_CART_SLOT = "cart0" -FALLBACK_CART_RAM_SIZE = 32 * 1024 +FALLBACK_CART_RAM_SIZE = 64 * 1024 +FALLBACK_CART_MAX_SIZE = 32 * 1024 STORE_DISCOVERY_ABI = "prg32-store-discovery-1.0" STORE_METADATA_ABI = "prg32-metadata-1.0" STORE_CONFIG = Path.home() / ".prg32" / "config.json" @@ -95,19 +96,38 @@ "prg32_cart_stored_count", "prg32_cart_get_slot_info", "prg32_cart_select_slot", + "prg32_cart_default_slot", + "prg32_cart_set_default_slot", + "prg32_cart_select_default", "prg32_console_clear", "prg32_console_putc", "prg32_console_write", "prg32_console_hex32", "prg32_gfx_clear", "prg32_gfx_present", + "prg32_gfx_set_fullscreen", + "prg32_gfx_fullscreen_enabled", "prg32_gfx_pixel", "prg32_gfx_rect", "prg32_gfx_text8", + "prg32_gfx_snapshot_row_rgb565", + "prg32_band_set_mode", + "prg32_band_mode", + "prg32_band_mode_name", + "prg32_band_set_text", + "prg32_band_set_game_info", + "prg32_band_log", + "prg32_band_set_colors", + "prg32_band_use_default_colors", + "prg32_band_load_config", + "prg32_band_save_config", "prg32_splash_draw", "prg32_splash_show", + "prg32_splash_draw_game", + "prg32_splash_show_game", "prg32_splash_show_default", "prg32_debug_overlay_draw", + "prg32_input_wait_released", "prg32_keyboard_init", "prg32_keyboard_update", "prg32_keyboard_draw", @@ -357,6 +377,7 @@ def runtime_from_elf(path: Path, tool_prefix: str) -> dict: ) return { "cart_load_addr": nm["prg32_cart_exec"], + "cart_max_size": FALLBACK_CART_MAX_SIZE, "cart_ram_size": ram_size, "imports": {name: nm[name] for name in IMPORT_NAMES}, } @@ -371,6 +392,11 @@ def write_imports(path: Path, imports: dict[str, int]) -> None: path.write_text("\n".join(lines) + "\n", encoding="utf-8") +def ensure_cart_max_size(data: bytes, max_size: int = FALLBACK_CART_MAX_SIZE) -> None: + if len(data) > max_size: + raise SystemExit(f"cartridge image is {len(data)} bytes, max is {max_size}") + + def write_linker(path: Path, load_addr: int, init_symbol: str) -> None: path.write_text( f"""/* Generated by tools/prg32_game.py. */ @@ -457,6 +483,7 @@ def build(args: argparse.Namespace) -> None: file=sys.stderr, ) ram_size = int(runtime.get("cart_ram_size", FALLBACK_CART_RAM_SIZE)) + max_size = int(runtime.get("cart_max_size", FALLBACK_CART_MAX_SIZE)) imports = {k: int(v) for k, v in runtime["imports"].items()} source = Path(args.source) @@ -561,8 +588,11 @@ def build(args: argparse.Namespace) -> None: crc, name_bytes + b"\0" * (32 - len(name_bytes)), ) + image = header + code + audio_block + if len(image) > max_size: + raise SystemExit(f"cartridge image is {len(image)} bytes, max is {max_size}") out.parent.mkdir(parents=True, exist_ok=True) - out.write_bytes(header + code + audio_block) + out.write_bytes(image) print( f"built {out} name={name} load=0x{load_addr:08x} " @@ -572,6 +602,7 @@ def build(args: argparse.Namespace) -> None: def upload(args: argparse.Namespace) -> None: data = Path(args.cartridge).read_bytes() + ensure_cart_max_size(data) endpoint = args.url.rstrip("/") + "/api/games?slot=" + args.slot request = urllib.request.Request( endpoint, @@ -806,6 +837,7 @@ def publish_bundle(args: argparse.Namespace) -> None: def upload_qemu(args: argparse.Namespace) -> None: flash = Path(args.flash) data = Path(args.cartridge).read_bytes() + ensure_cart_max_size(data) partitions = Path(args.partitions) cart_offset, cart_size = read_partition_slot(partitions, args.slot) if len(data) > cart_size: From 4b4d48fc647276377c0bc304829b565c5fd46252 Mon Sep 17 00:00:00 2001 From: Raffaele Montella Date: Mon, 8 Jun 2026 20:27:59 +0200 Subject: [PATCH 07/24] Update ESP-IDF version from v5.3 to v5.4 --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2e303a0..c381c5e 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ brew install git cmake ninja dfu-util ccache libusb python # 2) ESP-IDF cd $HOME -git clone -b v5.3 --recursive https://github.com/espressif/esp-idf.git +git clone -b v5.4 --recursive https://github.com/espressif/esp-idf.git cd esp-idf ./install.sh esp32c3,esp32c6 . ./export.sh @@ -161,7 +161,7 @@ Recommended reading paths: ```bash brew install git cmake ninja dfu-util ccache libusb python cd $HOME -git clone -b v5.3 --recursive https://github.com/espressif/esp-idf.git +git clone -b v5.4 --recursive https://github.com/espressif/esp-idf.git cd esp-idf ./install.sh esp32c3,esp32c6 . ./export.sh @@ -251,7 +251,7 @@ Install ESP-IDF: ```bash cd $HOME -git clone -b v5.3 --recursive https://github.com/espressif/esp-idf.git +git clone -b v5.4 --recursive https://github.com/espressif/esp-idf.git cd esp-idf ./install.sh esp32c3,esp32c6 . ./export.sh @@ -309,7 +309,7 @@ Linux notes: Open the repository root in PlatformIO. The checked-in `platformio.ini` default environment is `prg32-esp32c6`, which targets the ESP32-C6 DevKitC-1 with -ESP-IDF, reuses the standard `main` component, and applies +ESP-IDF, reuses the standard `main` component and applies `partitions_prg32.csv` plus `sdkconfig.defaults`. CLI equivalents: @@ -321,7 +321,7 @@ pio device monitor -b 115200 ``` The ESP32-C6 build keeps UART0 as the primary ESP-IDF console and enables -native USB Serial/JTAG as secondary output for PlatformIO Monitor. A healthy +native USB Serial/JTAG as a secondary output for PlatformIO Monitor. A healthy boot logs the configured `prg32_lcd` ILI9341 pins before drawing the splash. The PlatformIO environment is for the physical ESP32-C6 classroom board. Keep From 81904db988f900ce64b275f54bd8e4e330965493 Mon Sep 17 00:00:00 2001 From: raffmont Date: Tue, 9 Jun 2026 01:16:45 +0200 Subject: [PATCH 08/24] Tune store paging and font storage --- components/prg32/prg32_display_ili9341.c | 3 ++- components/prg32/prg32_display_qemu_rgb.c | 3 ++- components/prg32/prg32_setup_store.c | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/components/prg32/prg32_display_ili9341.c b/components/prg32/prg32_display_ili9341.c index 6b99fa5..71480c4 100644 --- a/components/prg32/prg32_display_ili9341.c +++ b/components/prg32/prg32_display_ili9341.c @@ -36,6 +36,7 @@ static const char *TAG = "prg32_lcd"; #define PRG32_VIEWPORT_Y ((PRG32_LCD_H - PRG32_GAME_H) / 2) #define PRG32_LCD_FLUSH_ROWS 8 +#define PRG32_FLASH_RODATA __attribute__((section(".rodata"))) #if !PRG32_LCD_SOFT_SPI static spi_device_handle_t g_lcd; @@ -114,7 +115,7 @@ static esp_err_t lcd_prepare_input_pin(int pin, const char *name) { return err; } -static const uint8_t g_font8[96][8] = { +static const uint8_t g_font8[96][8] PRG32_FLASH_RODATA = { [' ' - 32] = {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, ['!' - 32] = {0x18,0x18,0x18,0x18,0x18,0x00,0x18,0x00}, ['"' - 32] = {0x24,0x24,0x24,0x00,0x00,0x00,0x00,0x00}, diff --git a/components/prg32/prg32_display_qemu_rgb.c b/components/prg32/prg32_display_qemu_rgb.c index 954de0d..d4dd0a9 100644 --- a/components/prg32/prg32_display_qemu_rgb.c +++ b/components/prg32/prg32_display_qemu_rgb.c @@ -24,6 +24,7 @@ static uint16_t g_band_fg_cache[2]; static uint16_t g_band_bg_cache[2]; static int g_band_cache_valid[2]; static int g_dirty_x0, g_dirty_y0, g_dirty_x1, g_dirty_y1; +#define PRG32_FLASH_RODATA __attribute__((section(".rodata"))) void prg32_band_note_frame(uint32_t now_ms); int prg32_band_visible(uint8_t band); @@ -32,7 +33,7 @@ uint16_t prg32_band_bg(uint8_t band, uint16_t fallback); const char *prg32_band_render_text(uint8_t band, uint32_t now_ms); void prg32_gfx_lock_init(void); -static const uint8_t g_font8[96][8] = { +static const uint8_t g_font8[96][8] PRG32_FLASH_RODATA = { [' ' - 32] = {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00}, ['!' - 32] = {0x18,0x18,0x18,0x18,0x18,0x00,0x18,0x00}, ['"' - 32] = {0x24,0x24,0x24,0x00,0x00,0x00,0x00,0x00}, diff --git a/components/prg32/prg32_setup_store.c b/components/prg32/prg32_setup_store.c index 45b916a..81eda7d 100644 --- a/components/prg32/prg32_setup_store.c +++ b/components/prg32/prg32_setup_store.c @@ -17,7 +17,7 @@ #else #define STORE_MAX_GAMES 64 #endif -#define STORE_PAGE_SIZE 8 +#define STORE_PAGE_SIZE 4 typedef struct { char id[48]; From 9185cc459d9dd543fa26d2169e6384e2a9513f9d Mon Sep 17 00:00:00 2001 From: Simone Boscaglia Date: Tue, 9 Jun 2026 09:40:29 +0200 Subject: [PATCH 09/24] Aligned entire repository to new QEMU flash image 'qemu_flash.bin' --- .vscode/tasks.json | 2 +- README.md | 4 ++-- docs/cartridges.md | 6 +++--- docs/deployment.md | 2 +- docs/getting_started_game_development.md | 4 ++-- docs/qemu.md | 4 ++-- docs/scientific_measurement_tutorial.md | 2 +- examples/features/README.md | 2 +- examples/games/README.md | 4 ++-- scripts/qemu/run_qemu.sh | 4 ++-- scripts/qemu/run_qemu_demo.sh | 2 +- scripts/qemu/smoke_test.sh | 4 ++-- scripts/utilities/env_variables.sh | 2 +- tests/test_prg32_game.py | 2 +- tools/prg32_game.py | 2 +- 15 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b175d51..888ebf1 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -218,7 +218,7 @@ "upload-qemu", "${input:prg32QemuCartOut}", "--flash", - "build-qemu/flash_image.bin" + "build-qemu/qemu_flash.bin" ], "problemMatcher": [] }, diff --git a/README.md b/README.md index c381c5e..050053c 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ python3 tools/prg32_game.py build \ --entry-prefix asteroids_graphics \ --name asteroids \ --out build-qemu/asteroids.prg32 -python3 tools/prg32_game.py upload-qemu build-qemu/asteroids.prg32 --flash build-qemu/flash_image.bin +python3 tools/prg32_game.py upload-qemu build-qemu/asteroids.prg32 --flash build-qemu/qemu_flash.bin ``` Run everything with one command next time: @@ -386,7 +386,7 @@ download server is the standalone **Cartridge Store** in - QEMU runs but the game does not move: focus the terminal running QEMU. Use arrow keys or `W`/`A`/`S`/`D` for joystick 1, `Enter`/`Space` for SELECT, `J`/`Z` for A, and `K`/`X` for B. -- Cartridge upload fails: `build-qemu/flash_image.bin` is missing/invalid, or the +- Cartridge upload fails: `build-qemu/qemu_flash.bin` is missing/invalid, or the cartridge is too large. Run QEMU once, then rerun `upload-qemu`. - `riscv32-esp-elf-gcc` missing: re-run `./install.sh esp32c3,esp32c6` and source the ESP-IDF export script. diff --git a/docs/cartridges.md b/docs/cartridges.md index b194f95..bd8a820 100644 --- a/docs/cartridges.md +++ b/docs/cartridges.md @@ -181,10 +181,10 @@ Stage it into the QEMU flash image: ```bash python3 tools/prg32_game.py upload-qemu \ build-qemu/asteroids.prg32 \ - --flash build-qemu/flash_image.bin + --flash build-qemu/qemu_flash.bin ``` -If `build-qemu/flash_image.bin` is missing, start QEMU once so ESP-IDF creates +If `build-qemu/qemu_flash.bin` is missing, start QEMU once so ESP-IDF creates the flash image, quit QEMU, and run the staging command again. Then start QEMU: @@ -414,4 +414,4 @@ This is intentionally a classroom loader, not a general dynamic linker. package size and partition size, not cartridge executable RAM. - Two flash slots, `cart0` and `cart1`, are available. Only one cartridge is loaded into executable RAM at a time. -- QEMU staging requires QEMU to be stopped before patching `flash_image.bin`. +- QEMU staging requires QEMU to be stopped before patching `qemu_flash.bin`. diff --git a/docs/deployment.md b/docs/deployment.md index 4dfc2eb..b0bcf23 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -110,7 +110,7 @@ the 320x200 viewport. The normal hardware build keeps the ESP32-C6 target and ILI9341 SPI display backend. QEMU cartridge testing uses the same `.prg32` package but stages it into -`build-qemu/flash_image.bin`: +`build-qemu/qemu_flash.bin`: ```bash python3 tools/prg32_game.py upload-qemu build-qemu/asteroids.prg32 diff --git a/docs/getting_started_game_development.md b/docs/getting_started_game_development.md index 937016b..21bf19b 100644 --- a/docs/getting_started_game_development.md +++ b/docs/getting_started_game_development.md @@ -352,7 +352,7 @@ If this fails with `missing tool: riscv32-esp-elf-gcc`, source ESP-IDF again. ## 10. Stage And Run The Cartridge In QEMU -First start QEMU once so ESP-IDF creates `build-qemu/flash_image.bin`: +First start QEMU once so ESP-IDF creates `build-qemu/qemu_flash.bin`: ```bash idf.py -B build-qemu \ @@ -366,7 +366,7 @@ Quit QEMU with `Ctrl+]`, then stage the cartridge: ```bash python3 tools/prg32_game.py upload-qemu \ build-qemu/hello_world.prg32 \ - --flash build-qemu/flash_image.bin + --flash build-qemu/qemu_flash.bin ``` Start QEMU again: diff --git a/docs/qemu.md b/docs/qemu.md index 15e4091..b320a23 100644 --- a/docs/qemu.md +++ b/docs/qemu.md @@ -148,7 +148,7 @@ python3 tools/prg32_game.py build \ --name asteroids \ --out build-qemu/asteroids.prg32 ``` -If `build-qemu/flash_image.bin` does not exist yet, start QEMU once so ESP-IDF +If `build-qemu/qemu_flash.bin` does not exist yet, start QEMU once so ESP-IDF generates the flash image, quit QEMU, then run: ```bash @@ -159,7 +159,7 @@ Then run `PRG32: qemu screen`. On Linux or MacOS: -If `build-qemu/flash_image.bin` does not exist yet, start QEMU once so ESP-IDF +If `build-qemu/qemu_flash.bin` does not exist yet, start QEMU once so ESP-IDF generates the flash image: ```bash diff --git a/docs/scientific_measurement_tutorial.md b/docs/scientific_measurement_tutorial.md index bad2d79..0e1fd38 100644 --- a/docs/scientific_measurement_tutorial.md +++ b/docs/scientific_measurement_tutorial.md @@ -166,7 +166,7 @@ On Windows: ```bash python3 tools/prg32_game.py upload-qemu \ build-qemu/pong.prg32 \ - --flash build-qemu/flash_image.bin + --flash build-qemu/qemu_flash.bin ``` On Linux or MacOS: diff --git a/examples/features/README.md b/examples/features/README.md index 1f12aca..fc3d6da 100644 --- a/examples/features/README.md +++ b/examples/features/README.md @@ -141,5 +141,5 @@ For QEMU, build against `build-qemu/PRG32.elf` and stage the cartridge: ```bash python3 tools/prg32_game.py upload-qemu \ build-qemu/animated-sprites.prg32 \ - --flash build-qemu/flash_image.bin + --flash build-qemu/qemu_flash.bin ``` diff --git a/examples/games/README.md b/examples/games/README.md index 0f822d3..63212e4 100644 --- a/examples/games/README.md +++ b/examples/games/README.md @@ -252,7 +252,7 @@ idf.py -B build-qemu -D SDKCONFIG=build-qemu/sdkconfig -D SDKCONFIG_DEFAULTS=sdk ``` Stop QEMU after the first successful launch. This creates -`build-qemu/flash_image.bin`. +`build-qemu/qemu_flash.bin`. ### 3. Build a QEMU Cartridge @@ -279,7 +279,7 @@ name. ```bash python3 tools/prg32_game.py upload-qemu \ build-qemu/tetris-graphics.prg32 \ - --flash build-qemu/flash_image.bin + --flash build-qemu/qemu_flash.bin ``` ### 5. Run QEMU Again diff --git a/scripts/qemu/run_qemu.sh b/scripts/qemu/run_qemu.sh index c33736e..3ef77f4 100755 --- a/scripts/qemu/run_qemu.sh +++ b/scripts/qemu/run_qemu.sh @@ -6,7 +6,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" BUILD_DIR="build-qemu" SDKCONFIG="$BUILD_DIR/sdkconfig" SDKCONFIG_DEFAULTS="sdkconfig.defaults.qemu" -FLASH_IMAGE="$BUILD_DIR/flash_image.bin" +FLASH_IMAGE="$BUILD_DIR/qemu_flash.bin" QEMU_EFUSE="$BUILD_DIR/qemu_efuse.bin" FLASH_SIZE=$((4 * 1024 * 1024)) @@ -97,7 +97,7 @@ generate_flash_image() { step "Generating QEMU flash image" ( cd "$BUILD_DIR" - python3 -m esptool --chip=esp32c3 merge_bin --output=flash_image.bin --fill-flash-size=4MB @flash_args + python3 -m esptool --chip=esp32c3 merge_bin --output=qemu_flash.bin --fill-flash-size=4MB @flash_args ) || fail "Failed to generate $FLASH_IMAGE" } diff --git a/scripts/qemu/run_qemu_demo.sh b/scripts/qemu/run_qemu_demo.sh index 00dbe59..2bf09e5 100755 --- a/scripts/qemu/run_qemu_demo.sh +++ b/scripts/qemu/run_qemu_demo.sh @@ -8,7 +8,7 @@ QEMU_DEFAULTS="sdkconfig.defaults.qemu" DEMO_SOURCE="examples/games/asteroids/graphics/game.S" DEMO_PREFIX="asteroids_graphics" DEMO_CART="$QEMU_BUILD_DIR/asteroids.prg32" -DEMO_FLASH="$QEMU_BUILD_DIR/flash_image.bin" +DEMO_FLASH="$QEMU_BUILD_DIR/qemu_flash.bin" fail() { echo "[FAIL] $1" >&2 diff --git a/scripts/qemu/smoke_test.sh b/scripts/qemu/smoke_test.sh index 6e82da7..fe93739 100755 --- a/scripts/qemu/smoke_test.sh +++ b/scripts/qemu/smoke_test.sh @@ -103,7 +103,7 @@ else warn "qemu-launch-check failed (non-blocking)" fi -if [[ ! -f "$QEMU_BUILD_DIR/flash_image.bin" ]]; then +if [[ ! -f "$QEMU_BUILD_DIR/qemu_flash.bin" ]]; then fail "qemu-flash-image missing (run QEMU once with build-qemu/sdkconfig)" fi ok "qemu-flash-image" @@ -111,7 +111,7 @@ ok "qemu-flash-image" run_step "stage-cartridge-qemu" \ python3 tools/prg32_game.py upload-qemu \ "$QEMU_BUILD_DIR/pong.prg32" \ - --flash "$QEMU_BUILD_DIR/flash_image.bin" + --flash "$QEMU_BUILD_DIR/qemu_flash.bin" ok "smoke-test-complete" echo "=== SMOKE TEST PASSED ===" diff --git a/scripts/utilities/env_variables.sh b/scripts/utilities/env_variables.sh index 284a2c3..754008b 100644 --- a/scripts/utilities/env_variables.sh +++ b/scripts/utilities/env_variables.sh @@ -12,7 +12,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" export BUILD_DIR="build-qemu" export GAMES_DIR="examples/games" export GAME_TOOL="tools/prg32_game.py" -export QEMU_IMAGE="$BUILD_DIR/flash_image.bin" +export QEMU_IMAGE="$BUILD_DIR/qemu_flash.bin" export QEMU_EFUSE="$BUILD_DIR/qemu_efuse.bin" export QEMU_ELF="$BUILD_DIR/PRG32.elf" export SDKCONFIG="$BUILD_DIR/sdkconfig" diff --git a/tests/test_prg32_game.py b/tests/test_prg32_game.py index 51f244c..3da0382 100644 --- a/tests/test_prg32_game.py +++ b/tests/test_prg32_game.py @@ -98,7 +98,7 @@ class QemuUploadTests(unittest.TestCase): def test_upload_qemu_stages_cartridge_at_partition_offset(self) -> None: with tempfile.TemporaryDirectory() as tmp: tmp_path = Path(tmp) - flash = tmp_path / "flash_image.bin" + flash = tmp_path / "qemu_flash.bin" cart = tmp_path / "game.prg32" partitions = tmp_path / "partitions.csv" diff --git a/tools/prg32_game.py b/tools/prg32_game.py index d7f0fa6..efe9edb 100755 --- a/tools/prg32_game.py +++ b/tools/prg32_game.py @@ -986,7 +986,7 @@ def main(argv: list[str]) -> int: p = sub.add_parser("upload-qemu", help="stage a cartridge into QEMU flash") p.add_argument("cartridge") - p.add_argument("--flash", default="build-qemu/flash_image.bin") + p.add_argument("--flash", default="build-qemu/qemu_flash.bin") p.add_argument("--partitions", default=str(DEFAULT_PARTITION_TABLE)) p.add_argument("--slot", default=DEFAULT_CART_SLOT) p.set_defaults(func=upload_qemu) From fe8ab72aa3b63c5d27e2e9af772bfead97135123 Mon Sep 17 00:00:00 2001 From: Simone Boscaglia Date: Tue, 9 Jun 2026 13:07:08 +0200 Subject: [PATCH 10/24] Fixed store api parsing --- components/prg32/prg32_setup_store.c | 1169 +++++++++++++------------- 1 file changed, 597 insertions(+), 572 deletions(-) diff --git a/components/prg32/prg32_setup_store.c b/components/prg32/prg32_setup_store.c index 81eda7d..9c47010 100644 --- a/components/prg32/prg32_setup_store.c +++ b/components/prg32/prg32_setup_store.c @@ -1,10 +1,10 @@ -#include "prg32.h" -#include "prg32_config.h" #include "esp_http_client.h" #include "esp_log.h" #include "esp_system.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "prg32.h" +#include "prg32_config.h" #include #include #include @@ -15,17 +15,17 @@ #if CONFIG_PRG32_DISPLAY_QEMU_RGB #define STORE_MAX_GAMES 40 #else -#define STORE_MAX_GAMES 64 +#define STORE_MAX_GAMES 4 #endif #define STORE_PAGE_SIZE 4 typedef struct { - char id[48]; - char title[32]; - char version[16]; - char summary[96]; - char arch[32]; - char tags[48]; + char id[48]; + char title[32]; + char version[16]; + char summary[96]; + char arch[32]; + char tags[48]; } store_game_t; static const char *TAG = "prg32_setup_store"; @@ -34,639 +34,664 @@ static store_game_t *games; static int game_count; static int store_buffers_alloc(char *status, size_t status_len) { - if (catalog_body && games) { - return 0; - } - catalog_body = calloc(1, PRG32_STORE_CATALOG_MAX_BYTES); - games = calloc(STORE_MAX_GAMES, sizeof(store_game_t)); - if (!catalog_body || !games) { - free(catalog_body); - free(games); - catalog_body = NULL; - games = NULL; - snprintf(status, status_len, "NO MEM"); - return -1; - } + if (catalog_body && games) { return 0; -} - -static void store_buffers_free(void) { + } + catalog_body = calloc(1, PRG32_STORE_CATALOG_MAX_BYTES); + games = calloc(STORE_MAX_GAMES, sizeof(store_game_t)); + if (!catalog_body || !games) { free(catalog_body); free(games); catalog_body = NULL; games = NULL; - game_count = 0; + snprintf(status, status_len, "NO MEM"); + return -1; + } + return 0; +} + +static void store_buffers_free(void) { + free(catalog_body); + free(games); + catalog_body = NULL; + games = NULL; + game_count = 0; } static void wait_and_show(const char *line, uint32_t ms) { - prg32_gfx_clear(PRG32_COLOR_BLACK); - prg32_gfx_text8(8, 8, "CARTRIDGE STORE", PRG32_COLOR_WHITE, 0); - prg32_gfx_text8(8, 48, line, PRG32_COLOR_CYAN, 0); - prg32_gfx_present(); - vTaskDelay(pdMS_TO_TICKS(ms)); + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "CARTRIDGE STORE", PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 48, line, PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); + vTaskDelay(pdMS_TO_TICKS(ms)); } static void normalize_store_url(char *url, size_t cap) { - if (!url || cap == 0 || strstr(url, "://")) { - return; - } - char tmp[PRG32_STORE_URL_MAX_LEN]; - snprintf(tmp, sizeof(tmp), "http://%s:%d", url, PRG32_STORE_DEFAULT_PORT); - snprintf(url, cap, "%s", tmp); + if (!url || cap == 0 || strstr(url, "://")) { + return; + } + char tmp[PRG32_STORE_URL_MAX_LEN]; + snprintf(tmp, sizeof(tmp), "http://%s:%d", url, PRG32_STORE_DEFAULT_PORT); + snprintf(url, cap, "%s", tmp); } -static int json_string_after(const char *start, const char *end, const char *key, char *out, size_t cap) { - char needle[24]; - snprintf(needle, sizeof(needle), "\"%s\"", key); - const char *p = start; - out[0] = '\0'; - while (p && p < end) { - p = strstr(p, needle); - if (!p || p >= end) { - return -1; - } - p = strchr(p + strlen(needle), ':'); - if (!p || p >= end) { - return -1; - } - p++; - while (p < end && isspace((unsigned char)*p)) { - p++; - } - if (p < end && *p == '"') { - p++; - size_t i = 0; - while (p < end && *p && *p != '"' && i + 1 < cap) { - out[i++] = *p++; - } - out[i] = '\0'; - return out[0] ? 0 : -1; - } +static int json_string_after(const char *start, const char *end, + const char *key, char *out, size_t cap) { + char needle[24]; + snprintf(needle, sizeof(needle), "\"%s\"", key); + const char *p = start; + out[0] = '\0'; + while (p && p < end) { + p = strstr(p, needle); + if (!p || p >= end) { + return -1; } - return -1; + p = strchr(p + strlen(needle), ':'); + if (!p || p >= end) { + return -1; + } + p++; + while (p < end && isspace((unsigned char)*p)) { + p++; + } + if (p < end && *p == '"') { + p++; + size_t i = 0; + while (p < end && *p && *p != '"' && i + 1 < cap) { + out[i++] = *p++; + } + out[i] = '\0'; + return out[0] ? 0 : -1; + } + } + return -1; } -static int json_arches_after(const char *start, const char *end, char *out, size_t cap) { - const char *p = strstr(start, "\"architectures\""); - if (!p || p >= end) { - return -1; - } - p = strchr(p, '['); - const char *q = p ? strchr(p, ']') : NULL; - if (!p || !q || q >= end) { - return -1; - } - size_t i = 0; - while (p < q && i + 1 < cap) { - if (*p == '"') { - p++; - if (i && i + 2 < cap) { - out[i++] = ','; - out[i++] = ' '; - } - while (p < q && *p != '"' && i + 1 < cap) { - out[i++] = *p++; - } - } - p++; - } - out[i] = '\0'; - return out[0] ? 0 : -1; +static int json_arches_after(const char *start, const char *end, char *out, + size_t cap) { + const char *p = strstr(start, "\"architectures\""); + if (!p || p >= end) { + return -1; + } + p = strchr(p, '['); + const char *q = p ? strchr(p, ']') : NULL; + if (!p || !q || q >= end) { + return -1; + } + size_t i = 0; + while (p < q && i + 1 < cap) { + if (*p == '"') { + p++; + if (i && i + 2 < cap) { + out[i++] = ','; + out[i++] = ' '; + } + while (p < q && *p != '"' && i + 1 < cap) { + out[i++] = *p++; + } + } + p++; + } + out[i] = '\0'; + return out[0] ? 0 : -1; } -static int json_array_strings_after(const char *start, const char *end, const char *key, char *out, size_t cap) { - char needle[24]; - snprintf(needle, sizeof(needle), "\"%s\"", key); - const char *p = strstr(start, needle); - if (!p || p >= end) { - return -1; - } - p = strchr(p, '['); - const char *q = p ? strchr(p, ']') : NULL; - if (!p || !q || q >= end) { - return -1; - } - size_t i = 0; - while (p < q && i + 1 < cap) { - if (*p == '"') { - p++; - if (i && i + 2 < cap) { - out[i++] = ','; - out[i++] = ' '; - } - while (p < q && *p != '"' && i + 1 < cap) { - out[i++] = *p++; - } - } - p++; - } - out[i] = '\0'; - return out[0] ? 0 : -1; +static int json_array_strings_after(const char *start, const char *end, + const char *key, char *out, size_t cap) { + char needle[24]; + snprintf(needle, sizeof(needle), "\"%s\"", key); + const char *p = strstr(start, needle); + if (!p || p >= end) { + return -1; + } + p = strchr(p, '['); + const char *q = p ? strchr(p, ']') : NULL; + if (!p || !q || q >= end) { + return -1; + } + size_t i = 0; + while (p < q && i + 1 < cap) { + if (*p == '"') { + p++; + if (i && i + 2 < cap) { + out[i++] = ','; + out[i++] = ' '; + } + while (p < q && *p != '"' && i + 1 < cap) { + out[i++] = *p++; + } + } + p++; + } + out[i] = '\0'; + return out[0] ? 0 : -1; } static int parse_catalog(const char *json) { - game_count = 0; - if (!json || !games) { - return 0; - } - const char *p = json; - while ((p = strchr(p, '{')) != NULL && game_count < STORE_MAX_GAMES) { - const char *end = strchr(p, '}'); - if (!end) { - break; - } - store_game_t *g = &games[game_count]; - memset(g, 0, sizeof(*g)); - if (json_string_after(p, end, "id", g->id, sizeof(g->id)) == 0 && - json_string_after(p, end, "title", g->title, sizeof(g->title)) == 0) { - json_string_after(p, end, "version", g->version, sizeof(g->version)); - json_string_after(p, end, "summary", g->summary, sizeof(g->summary)); - json_arches_after(p, end, g->arch, sizeof(g->arch)); - json_array_strings_after(p, end, "tags", g->tags, sizeof(g->tags)); - game_count++; - } - p = end + 1; - } - return game_count; + game_count = 0; + if (!json || !games) { + return 0; + } + const char *p = strstr(json, "\"games\""); + if (p) + p = strchr(p, '['); + if (!p) + p = json; + + while ((p = strchr(p, '{')) != NULL && game_count < STORE_MAX_GAMES) { + const char *end = p; + int depth = 0; + while (*end) { + if (*end == '{') + depth++; + else if (*end == '}') { + depth--; + if (depth == 0) + break; + } + end++; + } + if (!*end) { + break; + } + store_game_t *g = &games[game_count]; + memset(g, 0, sizeof(*g)); + if (json_string_after(p, end, "id", g->id, sizeof(g->id)) == 0 && + json_string_after(p, end, "title", g->title, sizeof(g->title)) == 0) { + json_string_after(p, end, "version", g->version, sizeof(g->version)); + json_string_after(p, end, "summary", g->summary, sizeof(g->summary)); + json_arches_after(p, end, g->arch, sizeof(g->arch)); + json_array_strings_after(p, end, "tags", g->tags, sizeof(g->tags)); + game_count++; + } + p = end + 1; + } + return game_count; } static int contains_casefold(const char *text, const char *needle) { - if (!needle || !needle[0]) { - return 1; - } - if (!text) { - return 0; - } - for (const char *p = text; *p; ++p) { - const char *a = p; - const char *b = needle; - while (*a && *b && - tolower((unsigned char)*a) == tolower((unsigned char)*b)) { - a++; - b++; - } - if (!*b) { - return 1; - } - } + if (!needle || !needle[0]) { + return 1; + } + if (!text) { return 0; + } + for (const char *p = text; *p; ++p) { + const char *a = p; + const char *b = needle; + while (*a && *b && + tolower((unsigned char)*a) == tolower((unsigned char)*b)) { + a++; + b++; + } + if (!*b) { + return 1; + } + } + return 0; } static int filter_catalog(const char *query) { - if (!catalog_body || !games) { - return 0; - } - store_game_t *all_games = malloc(STORE_MAX_GAMES * sizeof(store_game_t)); - if (!all_games) { - game_count = 0; - return 0; - } - int all_count = parse_catalog(catalog_body); - memcpy(all_games, games, STORE_MAX_GAMES * sizeof(store_game_t)); - if (!query || !query[0]) { - free(all_games); - return all_count; - } + if (!catalog_body || !games) { + return 0; + } + store_game_t *all_games = malloc(STORE_MAX_GAMES * sizeof(store_game_t)); + if (!all_games) { game_count = 0; - for (int i = 0; i < all_count && game_count < STORE_MAX_GAMES; ++i) { - if (contains_casefold(all_games[i].title, query) || - contains_casefold(all_games[i].tags, query) || - contains_casefold(all_games[i].id, query)) { - games[game_count++] = all_games[i]; - } - } + return 0; + } + int all_count = parse_catalog(catalog_body); + memcpy(all_games, games, STORE_MAX_GAMES * sizeof(store_game_t)); + if (!query || !query[0]) { free(all_games); - return game_count; + return all_count; + } + game_count = 0; + for (int i = 0; i < all_count && game_count < STORE_MAX_GAMES; ++i) { + if (contains_casefold(all_games[i].title, query) || + contains_casefold(all_games[i].tags, query) || + contains_casefold(all_games[i].id, query)) { + games[game_count++] = all_games[i]; + } + } + free(all_games); + return game_count; } -static int fetch_catalog(const char *base_url, char *status, size_t status_len) { - char url[PRG32_STORE_URL_MAX_LEN + 16]; - snprintf(url, sizeof(url), "%s/api/games", base_url); - ESP_LOGI(TAG, - "fetch catalog: %s heap=%lu", - url, +static int fetch_catalog(const char *base_url, char *status, + size_t status_len) { + char url[PRG32_STORE_URL_MAX_LEN + 16]; + snprintf(url, sizeof(url), "%s/api/games", base_url); + ESP_LOGI(TAG, "fetch catalog: %s heap=%lu", url, + (unsigned long)esp_get_free_heap_size()); + if (!catalog_body || !games) { + snprintf(status, status_len, "NO MEM"); + return -1; + } + memset(catalog_body, 0, PRG32_STORE_CATALOG_MAX_BYTES); + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_GET, + .timeout_ms = PRG32_STORE_HTTP_TIMEOUT_MS, + .keep_alive_enable = false, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { + ESP_LOGI(TAG, "catalog client init failed heap=%lu", (unsigned long)esp_get_free_heap_size()); - if (!catalog_body || !games) { - snprintf(status, status_len, "NO MEM"); - return -1; - } - memset(catalog_body, 0, PRG32_STORE_CATALOG_MAX_BYTES); - esp_http_client_config_t config = { - .url = url, - .method = HTTP_METHOD_GET, - .timeout_ms = PRG32_STORE_HTTP_TIMEOUT_MS, - .keep_alive_enable = false, - }; - esp_http_client_handle_t client = esp_http_client_init(&config); - if (!client) { - ESP_LOGI(TAG, - "catalog client init failed heap=%lu", - (unsigned long)esp_get_free_heap_size()); - snprintf(status, status_len, "NO MEM"); - return -1; - } - esp_err_t err = esp_http_client_open(client, 0); - if (err != ESP_OK) { - ESP_LOGI(TAG, "catalog open failed: %s", esp_err_to_name(err)); - snprintf(status, status_len, "%s", esp_err_to_name(err)); - esp_http_client_cleanup(client); - return -1; - } - int content_len = esp_http_client_fetch_headers(client); - int http_status = esp_http_client_get_status_code(client); - if (http_status < 200 || http_status >= 300) { - ESP_LOGI(TAG, "catalog fetch HTTP status=%d", http_status); - snprintf(status, status_len, "%d", http_status); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return -1; - } - bool truncated = false; - size_t len = 0; - while (len + 1 < PRG32_STORE_CATALOG_MAX_BYTES) { - int got = esp_http_client_read(client, - catalog_body + len, - PRG32_STORE_CATALOG_MAX_BYTES - len - 1); - if (got < 0) { - ESP_LOGI(TAG, "catalog read failed"); - snprintf(status, status_len, "READ"); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return -1; - } - if (got == 0) { - break; - } - len += (size_t)got; - catalog_body[len] = '\0'; - vTaskDelay(pdMS_TO_TICKS(1)); - } - if (content_len > 0 && (size_t)content_len > len) { - truncated = true; - } + snprintf(status, status_len, "NO MEM"); + return -1; + } + esp_err_t err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + ESP_LOGI(TAG, "catalog open failed: %s", esp_err_to_name(err)); + snprintf(status, status_len, "%s", esp_err_to_name(err)); + esp_http_client_cleanup(client); + return -1; + } + int content_len = esp_http_client_fetch_headers(client); + int http_status = esp_http_client_get_status_code(client); + if (http_status < 200 || http_status >= 300) { + ESP_LOGI(TAG, "catalog fetch HTTP status=%d", http_status); + snprintf(status, status_len, "%d", http_status); esp_http_client_close(client); esp_http_client_cleanup(client); - parse_catalog(catalog_body); - ESP_LOGI(TAG, - "catalog fetch ok: status=%d content_len=%d bytes=%lu games=%d", - http_status, - content_len, - (unsigned long)len, - game_count); - snprintf(status, status_len, truncated ? "first %d shown" : "OK", STORE_MAX_GAMES); - return 0; + return -1; + } + bool truncated = false; + size_t len = 0; + while (len + 1 < PRG32_STORE_CATALOG_MAX_BYTES) { + int got = esp_http_client_read(client, catalog_body + len, + PRG32_STORE_CATALOG_MAX_BYTES - len - 1); + if (got < 0) { + ESP_LOGI(TAG, "catalog read failed"); + snprintf(status, status_len, "READ"); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + if (got == 0) { + break; + } + len += (size_t)got; + catalog_body[len] = '\0'; + vTaskDelay(pdMS_TO_TICKS(1)); + } + if (content_len > 0 && (size_t)content_len > len) { + truncated = true; + } + esp_http_client_close(client); + esp_http_client_cleanup(client); + parse_catalog(catalog_body); + ESP_LOGI(TAG, "catalog fetch ok: status=%d content_len=%d bytes=%lu games=%d", + http_status, content_len, (unsigned long)len, game_count); + snprintf(status, status_len, truncated ? "first %d shown" : "OK", + STORE_MAX_GAMES); + return 0; } static const char *current_arch(void) { #if CONFIG_PRG32_DISPLAY_QEMU_RGB - return PRG32_CART_ARCH_QEMU; + return PRG32_CART_ARCH_QEMU; #else - return PRG32_CART_ARCH_ESP32C6; + return PRG32_CART_ARCH_ESP32C6; #endif } static int game_is_compatible(const store_game_t *game) { - return game && strstr(game->arch, current_arch()) != NULL; + return game && strstr(game->arch, current_arch()) != NULL; } -static int stream_download(const char *base_url, const store_game_t *game, uint8_t slot, char *status, size_t status_len) { - char url[256]; - snprintf(url, - sizeof(url), - "%s/api/games/%s/download?architecture=%s&version=%s", - base_url, - game->id, - current_arch(), - game->version[0] ? game->version : "latest"); - esp_http_client_config_t config = { - .url = url, - .method = HTTP_METHOD_GET, - .timeout_ms = PRG32_STORE_HTTP_TIMEOUT_MS, - }; - esp_http_client_handle_t client = esp_http_client_init(&config); - if (!client) { - snprintf(status, status_len, "CLIENT"); - return -1; - } - if (esp_http_client_open(client, 0) != ESP_OK) { - esp_http_client_cleanup(client); - snprintf(status, status_len, "TIMEOUT"); - return -1; - } - int content_len = esp_http_client_fetch_headers(client); - int http_status = esp_http_client_get_status_code(client); - size_t slot_size = prg32_cart_slot_size(slot); - if (http_status < 200 || http_status >= 300) { - snprintf(status, status_len, "%d", http_status); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return -1; - } - if (content_len <= 0 || (size_t)content_len > PRG32_CART_MAX_SIZE || - (size_t)content_len > slot_size) { - snprintf(status, status_len, "TOO LARGE"); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return -1; - } - if (prg32_cart_stream_begin(slot, (size_t)content_len) != 0) { - snprintf(status, status_len, "%s", prg32_cart_last_error()); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return -1; - } - uint8_t chunk[PRG32_STORE_CHUNK_BYTES]; - size_t offset = 0; - while (offset < (size_t)content_len) { - int read = esp_http_client_read(client, (char *)chunk, sizeof(chunk)); - if (read <= 0) { - snprintf(status, status_len, "TIMEOUT"); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return -1; - } - if (prg32_cart_stream_write(slot, offset, chunk, (size_t)read) != 0) { - snprintf(status, status_len, "%s", prg32_cart_last_error()); - esp_http_client_close(client); - esp_http_client_cleanup(client); - return -1; - } - offset += (size_t)read; - vTaskDelay(pdMS_TO_TICKS(1)); - } +static int stream_download(const char *base_url, const store_game_t *game, + uint8_t slot, char *status, size_t status_len) { + char url[256]; + snprintf(url, sizeof(url), + "%s/api/games/%s/download?architecture=%s&version=%s", base_url, + game->id, current_arch(), + game->version[0] ? game->version : "latest"); + esp_http_client_config_t config = { + .url = url, + .method = HTTP_METHOD_GET, + .timeout_ms = PRG32_STORE_HTTP_TIMEOUT_MS, + }; + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) { + snprintf(status, status_len, "CLIENT"); + return -1; + } + if (esp_http_client_open(client, 0) != ESP_OK) { + esp_http_client_cleanup(client); + snprintf(status, status_len, "TIMEOUT"); + return -1; + } + int content_len = esp_http_client_fetch_headers(client); + int http_status = esp_http_client_get_status_code(client); + size_t slot_size = prg32_cart_slot_size(slot); + if (http_status < 200 || http_status >= 300) { + snprintf(status, status_len, "%d", http_status); esp_http_client_close(client); esp_http_client_cleanup(client); - if (prg32_cart_stream_end(slot, offset) != 0) { - snprintf(status, status_len, "%s", prg32_cart_last_error()); - return -1; - } - snprintf(status, status_len, "OK"); - ESP_LOGI(TAG, "downloaded %s to cart%u", game->id, (unsigned)slot); - return 0; + return -1; + } + if (content_len <= 0 || (size_t)content_len > PRG32_CART_MAX_SIZE || + (size_t)content_len > slot_size) { + snprintf(status, status_len, "TOO LARGE"); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + if (prg32_cart_stream_begin(slot, (size_t)content_len) != 0) { + snprintf(status, status_len, "%s", prg32_cart_last_error()); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + uint8_t chunk[PRG32_STORE_CHUNK_BYTES]; + size_t offset = 0; + while (offset < (size_t)content_len) { + int read = esp_http_client_read(client, (char *)chunk, sizeof(chunk)); + if (read <= 0) { + snprintf(status, status_len, "TIMEOUT"); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + if (prg32_cart_stream_write(slot, offset, chunk, (size_t)read) != 0) { + snprintf(status, status_len, "%s", prg32_cart_last_error()); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + offset += (size_t)read; + vTaskDelay(pdMS_TO_TICKS(1)); + } + esp_http_client_close(client); + esp_http_client_cleanup(client); + if (prg32_cart_stream_end(slot, offset) != 0) { + snprintf(status, status_len, "%s", prg32_cart_last_error()); + return -1; + } + snprintf(status, status_len, "OK"); + ESP_LOGI(TAG, "downloaded %s to cart%u", game->id, (unsigned)slot); + return 0; } static void draw_store_list(int selected, int page, const char *note) { - int pages = game_count > 0 ? (game_count + STORE_PAGE_SIZE - 1) / STORE_PAGE_SIZE : 1; + int pages = + game_count > 0 ? (game_count + STORE_PAGE_SIZE - 1) / STORE_PAGE_SIZE : 1; + char line[48]; + prg32_gfx_clear(PRG32_COLOR_BLACK); + snprintf(line, sizeof(line), "BROWSE STORE [page %d/%d]", page + 1, pages); + prg32_gfx_text8(8, 8, line, PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 26, "SEARCH", + selected == -1 ? PRG32_COLOR_GREEN : PRG32_COLOR_CYAN, 0); + int start = page * STORE_PAGE_SIZE; + for (int i = 0; i < STORE_PAGE_SIZE && start + i < game_count; ++i) { + const store_game_t *g = &games[start + i]; + int idx = start + i; + snprintf(line, sizeof(line), "%c %-24s %.8s", idx == selected ? '>' : ' ', + g->title, g->version); + prg32_gfx_text8( + 8, 46 + i * 18, line, + game_is_compatible(g) ? PRG32_COLOR_WHITE : PRG32_COLOR_YELLOW, 0); + } + if (note && note[0]) { + prg32_gfx_text8(8, 190, note, PRG32_COLOR_YELLOW, 0); + } + prg32_gfx_text8(8, 216, "U/D SCROLL L/R PAGE SELECT DETAILS A BACK", + PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); +} + +static int run_detail(const char *base_url, int index) { + uint8_t slot = 0; + const store_game_t *g = &games[index]; + prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); + while (1) { char line[48]; + uint16_t action_color = + game_is_compatible(g) ? PRG32_COLOR_CYAN : PRG32_COLOR_YELLOW; prg32_gfx_clear(PRG32_COLOR_BLACK); - snprintf(line, sizeof(line), "BROWSE STORE [page %d/%d]", page + 1, pages); + snprintf(line, sizeof(line), "%.24s v%.10s", g->title, g->version); prg32_gfx_text8(8, 8, line, PRG32_COLOR_WHITE, 0); - prg32_gfx_text8(8, 26, "SEARCH", selected == -1 ? PRG32_COLOR_GREEN : PRG32_COLOR_CYAN, 0); - int start = page * STORE_PAGE_SIZE; - for (int i = 0; i < STORE_PAGE_SIZE && start + i < game_count; ++i) { - const store_game_t *g = &games[start + i]; - int idx = start + i; - snprintf(line, sizeof(line), "%c %-24s %.8s", idx == selected ? '>' : ' ', g->title, g->version); - prg32_gfx_text8(8, 46 + i * 18, line, game_is_compatible(g) ? PRG32_COLOR_WHITE : PRG32_COLOR_YELLOW, 0); - } - if (note && note[0]) { - prg32_gfx_text8(8, 190, note, PRG32_COLOR_YELLOW, 0); - } - prg32_gfx_text8(8, 216, "U/D SCROLL L/R PAGE SELECT DETAILS A BACK", PRG32_COLOR_CYAN, 0); + prg32_gfx_text8(8, 36, g->summary, PRG32_COLOR_CYAN, 0); + snprintf(line, sizeof(line), "Architectures: %s", + game_is_compatible(g) ? current_arch() : "(none)"); + prg32_gfx_text8(8, 96, line, action_color, 0); + snprintf(line, sizeof(line), "Slot: cart%u", (unsigned)slot); + prg32_gfx_text8(8, 126, line, PRG32_COLOR_WHITE, 0); + if (!game_is_compatible(g)) { + prg32_gfx_text8(8, 154, "NOT COMPATIBLE WITH THIS FIRMWARE", + PRG32_COLOR_YELLOW, 0); + } + prg32_gfx_text8(8, 216, "U/D SLOT SELECT DOWNLOAD A BACK", action_color, + 0); prg32_gfx_present(); -} - -static int run_detail(const char *base_url, int index) { - uint8_t slot = 0; - const store_game_t *g = &games[index]; - prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); - while (1) { - char line[48]; - uint16_t action_color = game_is_compatible(g) ? PRG32_COLOR_CYAN : PRG32_COLOR_YELLOW; - prg32_gfx_clear(PRG32_COLOR_BLACK); - snprintf(line, sizeof(line), "%.24s v%.10s", g->title, g->version); - prg32_gfx_text8(8, 8, line, PRG32_COLOR_WHITE, 0); - prg32_gfx_text8(8, 36, g->summary, PRG32_COLOR_CYAN, 0); - snprintf(line, sizeof(line), "Architectures: %s", game_is_compatible(g) ? current_arch() : "(none)"); - prg32_gfx_text8(8, 96, line, action_color, 0); - snprintf(line, sizeof(line), "Slot: cart%u", (unsigned)slot); - prg32_gfx_text8(8, 126, line, PRG32_COLOR_WHITE, 0); - if (!game_is_compatible(g)) { - prg32_gfx_text8(8, 154, "NOT COMPATIBLE WITH THIS FIRMWARE", PRG32_COLOR_YELLOW, 0); - } - prg32_gfx_text8(8, 216, "U/D SLOT SELECT DOWNLOAD A BACK", action_color, 0); - prg32_gfx_present(); - uint32_t input = prg32_input_read_menu(); - if (input & PRG32_BTN_UP) { - slot = (slot + PRG32_CART_SLOT_COUNT - 1) % PRG32_CART_SLOT_COUNT; - prg32_input_wait_released(PRG32_BTN_UP); - } else if (input & PRG32_BTN_DOWN) { - slot = (slot + 1) % PRG32_CART_SLOT_COUNT; - prg32_input_wait_released(PRG32_BTN_DOWN); - } else if (input & STORE_CANCEL) { + uint32_t input = prg32_input_read_menu(); + if (input & PRG32_BTN_UP) { + slot = (slot + PRG32_CART_SLOT_COUNT - 1) % PRG32_CART_SLOT_COUNT; + prg32_input_wait_released(PRG32_BTN_UP); + } else if (input & PRG32_BTN_DOWN) { + slot = (slot + 1) % PRG32_CART_SLOT_COUNT; + prg32_input_wait_released(PRG32_BTN_DOWN); + } else if (input & STORE_CANCEL) { + prg32_input_wait_released(STORE_CANCEL); + return 0; + } else if ((input & STORE_ACCEPT) && game_is_compatible(g)) { + char status[48]; + wait_and_show("DOWNLOADING...", 10); + if (stream_download(base_url, g, slot, status, sizeof(status)) == 0) { + wait_and_show("INSTALLED", 2000); + while (1) { + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "INSTALLED", PRG32_COLOR_GREEN, 0); + prg32_gfx_text8(8, 216, "SELECT RUN NOW A BACK", PRG32_COLOR_CYAN, + 0); + prg32_gfx_present(); + uint32_t run_input = prg32_input_read_menu(); + if (run_input & PRG32_BTN_SELECT) { + prg32_cart_select_slot(slot); + prg32_input_wait_released(PRG32_BTN_SELECT); + return 1; + } + if (run_input & STORE_CANCEL) { prg32_input_wait_released(STORE_CANCEL); - return 0; - } else if ((input & STORE_ACCEPT) && game_is_compatible(g)) { - char status[48]; - wait_and_show("DOWNLOADING...", 10); - if (stream_download(base_url, g, slot, status, sizeof(status)) == 0) { - wait_and_show("INSTALLED", 2000); - while (1) { - prg32_gfx_clear(PRG32_COLOR_BLACK); - prg32_gfx_text8(8, 8, "INSTALLED", PRG32_COLOR_GREEN, 0); - prg32_gfx_text8(8, 216, "SELECT RUN NOW A BACK", PRG32_COLOR_CYAN, 0); - prg32_gfx_present(); - uint32_t run_input = prg32_input_read_menu(); - if (run_input & PRG32_BTN_SELECT) { - prg32_cart_select_slot(slot); - prg32_input_wait_released(PRG32_BTN_SELECT); - return 1; - } - if (run_input & STORE_CANCEL) { - prg32_input_wait_released(STORE_CANCEL); - break; - } - vTaskDelay(pdMS_TO_TICKS(10)); - } - } else { - char msg[64]; - snprintf(msg, sizeof(msg), "FAILED: %s", status); - wait_and_show(msg, 3000); - } + break; + } + vTaskDelay(pdMS_TO_TICKS(10)); } - vTaskDelay(pdMS_TO_TICKS(10)); - } + } else { + char msg[64]; + snprintf(msg, sizeof(msg), "FAILED: %s", status); + wait_and_show(msg, 3000); + } + } + vTaskDelay(pdMS_TO_TICKS(10)); + } } void prg32_setup_store_run(void) { - int choice = 0; - static const char *items[] = { - "AUTO-DISCOVER", - "MANUAL ENTRY", - "CLEAR SAVED URL", - "BACK", - }; - prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); + int choice = 0; + static const char *items[] = { + "AUTO-DISCOVER", + "MANUAL ENTRY", + "CLEAR SAVED URL", + "BACK", + }; + prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); + while (1) { + char current[PRG32_STORE_URL_MAX_LEN]; + int has_current = prg32_store_url_resolve(current, sizeof(current)) == 0; + uint32_t last = prg32_input_read_menu(); while (1) { - char current[PRG32_STORE_URL_MAX_LEN]; - int has_current = prg32_store_url_resolve(current, sizeof(current)) == 0; - uint32_t last = prg32_input_read_menu(); - while (1) { - uint32_t input = prg32_input_read_menu(); - if ((input & PRG32_BTN_UP) && !(last & PRG32_BTN_UP) && choice > 0) { - choice--; - } - if ((input & PRG32_BTN_DOWN) && !(last & PRG32_BTN_DOWN) && choice < 3) { - choice++; - } - if ((input & STORE_CANCEL) && !(last & STORE_CANCEL)) { - prg32_input_wait_released(STORE_CANCEL); - return; - } - if (((input & PRG32_BTN_SELECT) && !(last & PRG32_BTN_SELECT)) || - ((input & PRG32_BTN_B) && !(last & PRG32_BTN_B))) { + uint32_t input = prg32_input_read_menu(); + if ((input & PRG32_BTN_UP) && !(last & PRG32_BTN_UP) && choice > 0) { + choice--; + } + if ((input & PRG32_BTN_DOWN) && !(last & PRG32_BTN_DOWN) && choice < 3) { + choice++; + } + if ((input & STORE_CANCEL) && !(last & STORE_CANCEL)) { + prg32_input_wait_released(STORE_CANCEL); + return; + } + if (((input & PRG32_BTN_SELECT) && !(last & PRG32_BTN_SELECT)) || + ((input & PRG32_BTN_B) && !(last & PRG32_BTN_B))) { + prg32_input_wait_released(STORE_ACCEPT); + if (choice == 0) { + char found[PRG32_STORE_URL_MAX_LEN]; + wait_and_show("SCANNING...", 10); + if (prg32_store_discover(found, sizeof(found)) == 0) { + while (1) { + uint32_t save_input = prg32_input_read_menu(); + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "CARTRIDGE STORE", PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 40, "FOUND:", PRG32_COLOR_GREEN, 0); + prg32_gfx_text8(8, 58, found, PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 216, "SELECT SAVE A DISCARD", + PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); + if (save_input & PRG32_BTN_SELECT) { + prg32_store_url_set(found); prg32_input_wait_released(STORE_ACCEPT); - if (choice == 0) { - char found[PRG32_STORE_URL_MAX_LEN]; - wait_and_show("SCANNING...", 10); - if (prg32_store_discover(found, sizeof(found)) == 0) { - while (1) { - uint32_t save_input = prg32_input_read_menu(); - prg32_gfx_clear(PRG32_COLOR_BLACK); - prg32_gfx_text8(8, 8, "CARTRIDGE STORE", PRG32_COLOR_WHITE, 0); - prg32_gfx_text8(8, 40, "FOUND:", PRG32_COLOR_GREEN, 0); - prg32_gfx_text8(8, 58, found, PRG32_COLOR_WHITE, 0); - prg32_gfx_text8(8, 216, "SELECT SAVE A DISCARD", PRG32_COLOR_CYAN, 0); - prg32_gfx_present(); - if (save_input & PRG32_BTN_SELECT) { - prg32_store_url_set(found); - prg32_input_wait_released(STORE_ACCEPT); - wait_and_show("SAVED", 1000); - break; - } - if (save_input & STORE_CANCEL) { - prg32_input_wait_released(STORE_CANCEL); - break; - } - vTaskDelay(pdMS_TO_TICKS(10)); - } - } else { - wait_and_show("NOT FOUND", 2000); - } - break; - } - if (choice == 1) { - char url[PRG32_STORE_URL_MAX_LEN] = ""; - if (prg32_text_input(url, sizeof(url), "STORE URL") >= 0 && url[0]) { - normalize_store_url(url, sizeof(url)); - if (prg32_store_url_set(url) == 0) { - wait_and_show("SAVED", 1000); - } else { - wait_and_show("SAVE FAILED", 1000); - } - } - break; - } - if (choice == 2) { - prg32_store_url_clear(); - wait_and_show("CLEARED", 1000); - break; - } - return; + wait_and_show("SAVED", 1000); + break; + } + if (save_input & STORE_CANCEL) { + prg32_input_wait_released(STORE_CANCEL); + break; + } + vTaskDelay(pdMS_TO_TICKS(10)); } - prg32_gfx_clear(PRG32_COLOR_BLACK); - prg32_gfx_text8(8, 8, "CARTRIDGE STORE", PRG32_COLOR_WHITE, 0); - for (int i = 0; i < 4; ++i) { - int y = 42 + i * 18; - prg32_gfx_text8(8, y, i == choice ? ">" : " ", PRG32_COLOR_GREEN, 0); - prg32_gfx_text8(24, y, items[i], PRG32_COLOR_WHITE, 0); + } else { + wait_and_show("NOT FOUND", 2000); + } + break; + } + if (choice == 1) { + char url[PRG32_STORE_URL_MAX_LEN] = ""; + if (prg32_text_input(url, sizeof(url), "STORE URL") >= 0 && url[0]) { + normalize_store_url(url, sizeof(url)); + if (prg32_store_url_set(url) == 0) { + wait_and_show("SAVED", 1000); + } else { + wait_and_show("SAVE FAILED", 1000); } - prg32_gfx_text8(8, 144, "CURRENT:", PRG32_COLOR_YELLOW, 0); - prg32_gfx_text8(8, 162, has_current ? current : "(not configured)", PRG32_COLOR_CYAN, 0); - prg32_gfx_text8(8, 216, "UP/DOWN MOVE SELECT/B OK A BACK", PRG32_COLOR_CYAN, 0); - prg32_gfx_present(); - last = input; - vTaskDelay(pdMS_TO_TICKS(10)); + } + break; } - } + if (choice == 2) { + prg32_store_url_clear(); + wait_and_show("CLEARED", 1000); + break; + } + return; + } + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "CARTRIDGE STORE", PRG32_COLOR_WHITE, 0); + for (int i = 0; i < 4; ++i) { + int y = 42 + i * 18; + prg32_gfx_text8(8, y, i == choice ? ">" : " ", PRG32_COLOR_GREEN, 0); + prg32_gfx_text8(24, y, items[i], PRG32_COLOR_WHITE, 0); + } + prg32_gfx_text8(8, 144, "CURRENT:", PRG32_COLOR_YELLOW, 0); + prg32_gfx_text8(8, 162, has_current ? current : "(not configured)", + PRG32_COLOR_CYAN, 0); + prg32_gfx_text8(8, 216, "UP/DOWN MOVE SELECT/B OK A BACK", + PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); + last = input; + vTaskDelay(pdMS_TO_TICKS(10)); + } + } } void prg32_setup_store_browse_run(void) { - prg32_wifi_scores_init(); - prg32_scores_api_start(); - char url[PRG32_STORE_URL_MAX_LEN]; - if (prg32_store_url_resolve(url, sizeof(url)) != 0) { - wait_and_show("CONFIGURE STORE FIRST", 2000); - return; - } - char status[32]; - if (store_buffers_alloc(status, sizeof(status)) != 0) { - char msg[48]; - snprintf(msg, sizeof(msg), "UNAVAILABLE: %s", status); - wait_and_show(msg, 2000); - return; - } - wait_and_show("CONNECTING...", 10); - if (fetch_catalog(url, status, sizeof(status)) != 0) { - char msg[48]; - snprintf(msg, sizeof(msg), "UNAVAILABLE: %s", status); - wait_and_show(msg, 2000); - goto done; - } - if (game_count == 0) { - wait_and_show("NO GAMES", 2000); - goto done; - } - int selected = 0; - int page = 0; - const char *note = strcmp(status, "OK") == 0 ? "" : status; - prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); - while (1) { - draw_store_list(selected, page, note); - uint32_t input = prg32_input_read_menu(); - if (input & STORE_CANCEL) { - prg32_input_wait_released(STORE_CANCEL); - goto done; + prg32_wifi_scores_init(); + prg32_scores_api_start(); + char url[PRG32_STORE_URL_MAX_LEN]; + if (prg32_store_url_resolve(url, sizeof(url)) != 0) { + wait_and_show("CONFIGURE STORE FIRST", 2000); + return; + } + char status[32]; + if (store_buffers_alloc(status, sizeof(status)) != 0) { + char msg[48]; + snprintf(msg, sizeof(msg), "UNAVAILABLE: %s", status); + wait_and_show(msg, 2000); + return; + } + wait_and_show("CONNECTING...", 10); + if (fetch_catalog(url, status, sizeof(status)) != 0) { + char msg[48]; + snprintf(msg, sizeof(msg), "UNAVAILABLE: %s", status); + wait_and_show(msg, 2000); + goto done; + } + if (game_count == 0) { + wait_and_show("NO GAMES", 2000); + goto done; + } + int selected = 0; + int page = 0; + const char *note = strcmp(status, "OK") == 0 ? "" : status; + prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); + while (1) { + draw_store_list(selected, page, note); + uint32_t input = prg32_input_read_menu(); + if (input & STORE_CANCEL) { + prg32_input_wait_released(STORE_CANCEL); + goto done; + } + if (input & PRG32_BTN_UP) { + if (selected > 0) { + selected--; + page = selected / STORE_PAGE_SIZE; + } else if (selected == 0) { + selected = -1; + } + prg32_input_wait_released(PRG32_BTN_UP); + } else if (input & PRG32_BTN_DOWN) { + if (selected < 0 && game_count > 0) { + selected = 0; + page = 0; + } else if (selected + 1 < game_count) { + selected++; + page = selected / STORE_PAGE_SIZE; + } + prg32_input_wait_released(PRG32_BTN_DOWN); + } else if (input & PRG32_BTN_LEFT) { + if (page > 0) { + page--; + selected = page * STORE_PAGE_SIZE; + } + prg32_input_wait_released(PRG32_BTN_LEFT); + } else if (input & PRG32_BTN_RIGHT) { + int pages = (game_count + STORE_PAGE_SIZE - 1) / STORE_PAGE_SIZE; + if (page + 1 < pages) { + page++; + selected = page * STORE_PAGE_SIZE; + } + prg32_input_wait_released(PRG32_BTN_RIGHT); + } else if (input & STORE_ACCEPT) { + if (selected < 0) { + char query[40] = ""; + if (prg32_text_input(query, sizeof(query), "SEARCH STORE") >= 0) { + filter_catalog(query); + selected = game_count > 0 ? 0 : -1; + page = 0; + note = query[0] ? "filtered" : ""; } - if (input & PRG32_BTN_UP) { - if (selected > 0) { - selected--; - page = selected / STORE_PAGE_SIZE; - } else if (selected == 0) { - selected = -1; - } - prg32_input_wait_released(PRG32_BTN_UP); - } else if (input & PRG32_BTN_DOWN) { - if (selected < 0 && game_count > 0) { - selected = 0; - page = 0; - } else if (selected + 1 < game_count) { - selected++; - page = selected / STORE_PAGE_SIZE; - } - prg32_input_wait_released(PRG32_BTN_DOWN); - } else if (input & PRG32_BTN_LEFT) { - if (page > 0) { - page--; - selected = page * STORE_PAGE_SIZE; - } - prg32_input_wait_released(PRG32_BTN_LEFT); - } else if (input & PRG32_BTN_RIGHT) { - int pages = (game_count + STORE_PAGE_SIZE - 1) / STORE_PAGE_SIZE; - if (page + 1 < pages) { - page++; - selected = page * STORE_PAGE_SIZE; - } - prg32_input_wait_released(PRG32_BTN_RIGHT); - } else if (input & STORE_ACCEPT) { - if (selected < 0) { - char query[40] = ""; - if (prg32_text_input(query, sizeof(query), "SEARCH STORE") >= 0) { - filter_catalog(query); - selected = game_count > 0 ? 0 : -1; - page = 0; - note = query[0] ? "filtered" : ""; - } - prg32_input_wait_released(STORE_ACCEPT); - } else if (run_detail(url, selected)) { - goto done; - } else { - prg32_input_wait_released(STORE_ACCEPT); - } - } - vTaskDelay(pdMS_TO_TICKS(10)); + prg32_input_wait_released(STORE_ACCEPT); + } else if (run_detail(url, selected)) { + goto done; + } else { + prg32_input_wait_released(STORE_ACCEPT); + } } + vTaskDelay(pdMS_TO_TICKS(10)); + } done: - store_buffers_free(); + store_buffers_free(); } From a58a848f48cd7fbf8e52daabad76f310458ccf78 Mon Sep 17 00:00:00 2001 From: raffmont Date: Tue, 9 Jun 2026 12:37:07 +0200 Subject: [PATCH 11/24] Make cartridge RAM window configurable --- components/prg32/Kconfig | 13 +++++++++++++ components/prg32/include/prg32.h | 6 +++++- docs/framework_manual.md | 5 ++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/components/prg32/Kconfig b/components/prg32/Kconfig index 60548cf..5c17b4c 100644 --- a/components/prg32/Kconfig +++ b/components/prg32/Kconfig @@ -65,6 +65,19 @@ config PRG32_SPLASH_SOUND_ENABLED When the PRG32 I2S audio subsystem is enabled, initialize it and play a short welcome tone sequence while the firmware splash is visible. +config PRG32_CART_RAM_KIB + int "Executable cartridge RAM window in KiB" + default 64 if PRG32_DISPLAY_QEMU_RGB + default 48 + range 16 64 + help + Size of the internal executable RAM window reserved for uploadable + cartridges. The physical ESP32-C6 build defaults to 48 KiB to leave + more SRAM available to the resident runtime, Wi-Fi, setup screens, and + classroom diagnostics. Raise this only for cartridge labs that need a + larger linked image, then rebuild both the resident firmware and the + cartridge. + menu "Performance metrics" config PRG32_METRICS_ENABLE diff --git a/components/prg32/include/prg32.h b/components/prg32/include/prg32.h index 09cc8d0..f63f511 100644 --- a/components/prg32/include/prg32.h +++ b/components/prg32/include/prg32.h @@ -3,6 +3,7 @@ #include #include +#include "sdkconfig.h" #include "prg32_audio.h" #include "prg32_metrics.h" #include "prg32_multiplayer.h" @@ -95,7 +96,10 @@ extern "C" { #define PRG32_CART_ARCH_ESP32C6 "esp32c6" #define PRG32_CART_ARCH_QEMU "qemu" #define PRG32_CART_MAX_SIZE (32u * 1024u) -#define PRG32_CART_RAM_SIZE (64u * 1024u) +#ifndef CONFIG_PRG32_CART_RAM_KIB +#define CONFIG_PRG32_CART_RAM_KIB 48 +#endif +#define PRG32_CART_RAM_SIZE ((uint32_t)CONFIG_PRG32_CART_RAM_KIB * 1024u) #define PRG32_CART_NAME_LEN 32 #define PRG32_CART_SLOT_COUNT 2 #ifndef PRG32_FIRMWARE_VERSION diff --git a/docs/framework_manual.md b/docs/framework_manual.md index 36d11ca..34a29f4 100644 --- a/docs/framework_manual.md +++ b/docs/framework_manual.md @@ -228,7 +228,10 @@ Important constants: - `PRG32_CART_META_ABI`: metadata JSON ABI, `prg32-metadata-1.0`. - `PRG32_CART_COLOPHON_ABI`: colophon JSON ABI, `prg32-colophon-1.0`. - `PRG32_CART_MAX_SIZE`: maximum `.prg32` package size, currently 32 KiB. -- `PRG32_CART_RAM_SIZE`: executable cartridge RAM window, currently 64 KiB. +- `PRG32_CART_RAM_SIZE`: executable cartridge RAM window, configured by + `CONFIG_PRG32_CART_RAM_KIB`. Physical ESP32-C6 builds default to 48 KiB to + leave more SRAM to the resident runtime; QEMU defaults to 64 KiB for desktop + compatibility. - `PRG32_CART_SLOT_COUNT`: number of persistent flash cartridge slots. Important functions: From 9d0b41b682a86e2db32dde17cbcb9fdd2a2b5f19 Mon Sep 17 00:00:00 2001 From: raffmont Date: Tue, 9 Jun 2026 12:46:19 +0200 Subject: [PATCH 12/24] Allocate tile helpers lazily --- components/prg32/include/prg32.h | 2 + components/prg32/prg32_platform.c | 23 +++++++++- components/prg32/prg32_tile.c | 72 +++++++++++++++++++++++++++---- 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/components/prg32/include/prg32.h b/components/prg32/include/prg32.h index f63f511..3f16635 100644 --- a/components/prg32/include/prg32.h +++ b/components/prg32/include/prg32.h @@ -3,7 +3,9 @@ #include #include +#if __has_include("sdkconfig.h") #include "sdkconfig.h" +#endif #include "prg32_audio.h" #include "prg32_metrics.h" #include "prg32_multiplayer.h" diff --git a/components/prg32/prg32_platform.c b/components/prg32/prg32_platform.c index 3466475..a0188d2 100644 --- a/components/prg32/prg32_platform.c +++ b/components/prg32/prg32_platform.c @@ -1,8 +1,9 @@ #include "prg32.h" #include +#include #include -static uint8_t g_tile_flags[256]; +static uint8_t *g_tile_flags; typedef char actor_size_must_match_abi[ sizeof(prg32_platform_actor_t) == PRG32_PLATFORM_ACTOR_SIZE ? 1 : -1 @@ -22,11 +23,25 @@ static int clamp_int(int value, int lo, int hi) { return value; } +static int tile_flags_ready(void) { + if (g_tile_flags) { + return 1; + } + g_tile_flags = calloc(256, sizeof(g_tile_flags[0])); + return g_tile_flags != NULL; +} + void prg32_platform_tile_flags(uint8_t tile_id, uint8_t flags) { + if (!tile_flags_ready()) { + return; + } g_tile_flags[tile_id] = flags; } uint8_t prg32_platform_tile_flags_get(uint8_t tile_id) { + if (!g_tile_flags) { + return 0; + } return g_tile_flags[tile_id]; } @@ -46,6 +61,9 @@ uint8_t prg32_platform_tile_at(uint8_t layer, int pixel_x, int pixel_y) { int prg32_platform_solid_at(uint8_t layer, int pixel_x, int pixel_y) { uint8_t tile = prg32_platform_tile_at(layer, pixel_x, pixel_y); + if (!g_tile_flags) { + return 0; + } return (g_tile_flags[tile] & PRG32_TILE_FLAG_SOLID) != 0; } @@ -68,6 +86,9 @@ void prg32_platform_actor_init(prg32_platform_actor_t *actor, static uint8_t tile_flags_at(uint8_t layer, int pixel_x, int pixel_y) { uint8_t tile = prg32_platform_tile_at(layer, pixel_x, pixel_y); + if (!g_tile_flags) { + return 0; + } return g_tile_flags[tile]; } diff --git a/components/prg32/prg32_tile.c b/components/prg32/prg32_tile.c index 7128a23..d7b9716 100644 --- a/components/prg32/prg32_tile.c +++ b/components/prg32/prg32_tile.c @@ -1,4 +1,5 @@ #include "prg32.h" +#include #include typedef struct { @@ -7,12 +8,10 @@ typedef struct { uint16_t bg; } tile_t; -static tile_t g_tiles[256]; -static uint8_t g_map[PRG32_TILE_ROWS][PRG32_TILE_COLS]; -static uint8_t g_dirty[PRG32_TILE_ROWS][PRG32_TILE_COLS]; -static uint8_t g_playfield[PRG32_PLAYFIELD_LAYERS] - [PRG32_PLAYFIELD_ROWS] - [PRG32_PLAYFIELD_COLS]; +static tile_t *g_tiles; +static uint8_t (*g_map)[PRG32_TILE_COLS]; +static uint8_t (*g_dirty)[PRG32_TILE_COLS]; +static uint8_t (*g_playfield)[PRG32_PLAYFIELD_ROWS][PRG32_PLAYFIELD_COLS]; static int g_scroll_x[PRG32_PLAYFIELD_LAYERS]; static int g_scroll_y[PRG32_PLAYFIELD_LAYERS]; static int g_parallax_x_q8[PRG32_PLAYFIELD_LAYERS] = { @@ -30,6 +29,35 @@ static int valid_layer(uint8_t layer) { return layer < PRG32_PLAYFIELD_LAYERS; } +static int tile_grid_ready(void) { + if (g_tiles && g_map && g_dirty) { + return 1; + } + tile_t *tiles = calloc(256, sizeof(tile_t)); + uint8_t (*map)[PRG32_TILE_COLS] = + calloc(PRG32_TILE_ROWS, sizeof(*map)); + uint8_t (*dirty)[PRG32_TILE_COLS] = + calloc(PRG32_TILE_ROWS, sizeof(*dirty)); + if (!tiles || !map || !dirty) { + free(tiles); + free(map); + free(dirty); + return 0; + } + g_tiles = tiles; + g_map = map; + g_dirty = dirty; + return 1; +} + +static int playfield_ready(void) { + if (g_playfield) { + return 1; + } + g_playfield = calloc(PRG32_PLAYFIELD_LAYERS, sizeof(*g_playfield)); + return g_playfield != NULL; +} + static int floor_div_int(int value, int divisor) { if (divisor <= 0) { return 0; @@ -89,8 +117,11 @@ static void draw_tile_pixels(uint8_t id, } void prg32_tile_clear(uint16_t color) { - memset(g_map, 0, sizeof(g_map)); - memset(g_dirty, 1, sizeof(g_dirty)); + if (!tile_grid_ready()) { + return; + } + memset(g_map, 0, PRG32_TILE_ROWS * sizeof(g_map[0])); + memset(g_dirty, 1, PRG32_TILE_ROWS * sizeof(g_dirty[0])); memset(g_tiles[0].bits, 0, sizeof(g_tiles[0].bits)); g_tiles[0].fg = color; g_tiles[0].bg = color; @@ -100,6 +131,9 @@ void prg32_tile_define(uint8_t id, const uint8_t *bitmap8x8, uint16_t fg, uint16_t bg) { + if (!tile_grid_ready()) { + return; + } if (bitmap8x8) { memcpy(g_tiles[id].bits, bitmap8x8, 8); } else { @@ -120,6 +154,9 @@ void prg32_tile_put(uint8_t tx, uint8_t ty, uint8_t id) { if (tx >= PRG32_TILE_COLS || ty >= PRG32_TILE_ROWS) { return; } + if (!tile_grid_ready()) { + return; + } if (g_map[ty][tx] == id) { return; } @@ -128,6 +165,9 @@ void prg32_tile_put(uint8_t tx, uint8_t ty, uint8_t id) { } void prg32_tile_present(void) { + if (!tile_grid_ready()) { + return; + } for (int ty = 0; ty < PRG32_TILE_ROWS; ++ty) { for (int tx = 0; tx < PRG32_TILE_COLS; ++tx) { if (!g_dirty[ty][tx]) { @@ -144,7 +184,12 @@ void prg32_playfield_clear(uint8_t layer, uint8_t tile_id) { if (!valid_layer(layer)) { return; } - memset(g_playfield[layer], tile_id, sizeof(g_playfield[layer])); + if (!playfield_ready()) { + return; + } + memset(g_playfield[layer], + tile_id, + PRG32_PLAYFIELD_ROWS * sizeof(g_playfield[layer][0])); g_scroll_x[layer] = 0; g_scroll_y[layer] = 0; g_parallax_x_q8[layer] = PRG32_PARALLAX_1X; @@ -157,6 +202,9 @@ void prg32_playfield_put(uint8_t layer, uint8_t tx, uint8_t ty, uint8_t id) { ty >= PRG32_PLAYFIELD_ROWS) { return; } + if (!playfield_ready()) { + return; + } g_playfield[layer][ty][tx] = id; } @@ -166,6 +214,9 @@ uint8_t prg32_playfield_get(uint8_t layer, uint8_t tx, uint8_t ty) { ty >= PRG32_PLAYFIELD_ROWS) { return 0; } + if (!g_playfield) { + return 0; + } return g_playfield[layer][ty][tx]; } @@ -210,6 +261,9 @@ void prg32_playfield_draw(uint8_t layer, int transparent_zero) { if (!valid_layer(layer)) { return; } + if (!g_playfield || !tile_grid_ready()) { + return; + } int origin_x = g_scroll_x[layer] + camera_scaled(g_camera_x, g_parallax_x_q8[layer]); From c2a4af78f0c67784f24a7c2971727231a148046b Mon Sep 17 00:00:00 2001 From: raffmont Date: Tue, 9 Jun 2026 13:12:53 +0200 Subject: [PATCH 13/24] Keep constant assets in flash --- components/prg32/assets/prg32_splash_logo.c | 8 +- components/prg32/prg32_audio.c | 12 +- components/prg32/prg32_keyboard.c | 7 +- components/prg32/prg32_performance.c | 2 +- components/prg32/prg32_setup_store.c | 171 ++++++++++---------- components/prg32/prg32_splash.c | 9 +- 6 files changed, 114 insertions(+), 95 deletions(-) diff --git a/components/prg32/assets/prg32_splash_logo.c b/components/prg32/assets/prg32_splash_logo.c index a02d2fa..5807372 100644 --- a/components/prg32/assets/prg32_splash_logo.c +++ b/components/prg32/assets/prg32_splash_logo.c @@ -1,7 +1,13 @@ #include #define PRG32_SPLASH_LOGO_W 320 #define PRG32_SPLASH_LOGO_H 200 -const uint16_t prg32_splash_logo[] = { +#ifdef __ELF__ +#define PRG32_FLASH_RODATA __attribute__((section(".rodata"))) +#else +#define PRG32_FLASH_RODATA +#endif + +const uint16_t prg32_splash_logo[] PRG32_FLASH_RODATA = { 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, diff --git a/components/prg32/prg32_audio.c b/components/prg32/prg32_audio.c index 04d9ea6..a9c5fc1 100644 --- a/components/prg32/prg32_audio.c +++ b/components/prg32/prg32_audio.c @@ -5,6 +5,12 @@ #include "prg32.h" #include "prg32_config.h" +#ifdef __ELF__ +#define PRG32_FLASH_RODATA __attribute__((section(".rodata"))) +#else +#define PRG32_FLASH_RODATA +#endif + void prg32_audio_pwm_init(void) { if (PRG32_PIN_BUZZER < 0) { return; @@ -37,7 +43,7 @@ static const uint8_t builtin_beep_wave[32] = { }; #endif -static const uint8_t setup_audio_wave[] = { +static const uint8_t setup_audio_wave[] PRG32_FLASH_RODATA = { 128, 176, 218, 245, 255, 245, 218, 176, 128, 80, 38, 11, 1, 11, 38, 80, 128, 176, 218, 245, 255, 245, 218, 176, 128, 80, 38, 11, 1, 11, 38, 80, }; @@ -48,7 +54,7 @@ static uint8_t hz_to_midi_note(uint32_t hz) { uint8_t best_note = 60; uint32_t best_diff = 999999; for (uint8_t note = 12; note < 120; ++note) { - static const uint16_t octave4[12] = { + static const uint16_t octave4[12] PRG32_FLASH_RODATA = { 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, }; uint32_t freq = octave4[note % 12u]; @@ -122,7 +128,7 @@ void prg32_audio_tone(uint32_t hz, uint32_t ms, uint16_t duty) { } void prg32_audio_note(uint8_t midi_note, uint32_t ms) { - static const uint16_t octave4[12] = { + static const uint16_t octave4[12] PRG32_FLASH_RODATA = { 262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494, }; uint32_t freq = octave4[midi_note % 12u]; diff --git a/components/prg32/prg32_keyboard.c b/components/prg32/prg32_keyboard.c index d6ede6c..4489c02 100644 --- a/components/prg32/prg32_keyboard.c +++ b/components/prg32/prg32_keyboard.c @@ -17,6 +17,11 @@ static void vTaskDelay(int ticks) { #define ASCII_LAST 126 #define ASCII_COUNT (ASCII_LAST - ASCII_FIRST + 1) #define ASCII_COMMAND_COUNT 4 +#ifdef __ELF__ +#define PRG32_FLASH_RODATA __attribute__((section(".rodata"))) +#else +#define PRG32_FLASH_RODATA +#endif typedef enum { KEY_CHAR, @@ -48,7 +53,7 @@ typedef struct { key_type_t type; } key_view_t; -static const qwerty_key_t qwerty_keys[] = { +static const qwerty_key_t qwerty_keys[] PRG32_FLASH_RODATA = { {"q", "Q", 'q', 'Q', 4, 28, 28, KEY_CHAR}, {"w", "W", 'w', 'W', 34, 28, 28, KEY_CHAR}, {"e", "E", 'e', 'E', 64, 28, 28, KEY_CHAR}, diff --git a/components/prg32/prg32_performance.c b/components/prg32/prg32_performance.c index 72bd3f3..6768829 100644 --- a/components/prg32/prg32_performance.c +++ b/components/prg32/prg32_performance.c @@ -354,7 +354,7 @@ static void scene_draw_text_overlay(uint8_t screen_index, const prg32_perf_scene_t *scene, uint32_t global_frame, uint32_t local_frame) { - static const char *rows[] = { + static const char *const rows[] = { "REGISTER TRACE A0 A1 A2 A3", "MEMORY VIEW 0000 1000 2000", "STACK RA SP S0 S1", diff --git a/components/prg32/prg32_setup_store.c b/components/prg32/prg32_setup_store.c index 9c47010..2523e72 100644 --- a/components/prg32/prg32_setup_store.c +++ b/components/prg32/prg32_setup_store.c @@ -511,99 +511,96 @@ static int run_detail(const char *base_url, int index) { } void prg32_setup_store_run(void) { - int choice = 0; - static const char *items[] = { - "AUTO-DISCOVER", - "MANUAL ENTRY", - "CLEAR SAVED URL", - "BACK", - }; - prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); - while (1) { - char current[PRG32_STORE_URL_MAX_LEN]; - int has_current = prg32_store_url_resolve(current, sizeof(current)) == 0; - uint32_t last = prg32_input_read_menu(); + int choice = 0; + static const char *const items[] = { + "AUTO-DISCOVER", + "MANUAL ENTRY", + "CLEAR SAVED URL", + "BACK", + }; + prg32_input_wait_released(STORE_ACCEPT | STORE_CANCEL); while (1) { - uint32_t input = prg32_input_read_menu(); - if ((input & PRG32_BTN_UP) && !(last & PRG32_BTN_UP) && choice > 0) { - choice--; - } - if ((input & PRG32_BTN_DOWN) && !(last & PRG32_BTN_DOWN) && choice < 3) { - choice++; - } - if ((input & STORE_CANCEL) && !(last & STORE_CANCEL)) { - prg32_input_wait_released(STORE_CANCEL); - return; - } - if (((input & PRG32_BTN_SELECT) && !(last & PRG32_BTN_SELECT)) || - ((input & PRG32_BTN_B) && !(last & PRG32_BTN_B))) { - prg32_input_wait_released(STORE_ACCEPT); - if (choice == 0) { - char found[PRG32_STORE_URL_MAX_LEN]; - wait_and_show("SCANNING...", 10); - if (prg32_store_discover(found, sizeof(found)) == 0) { - while (1) { - uint32_t save_input = prg32_input_read_menu(); - prg32_gfx_clear(PRG32_COLOR_BLACK); - prg32_gfx_text8(8, 8, "CARTRIDGE STORE", PRG32_COLOR_WHITE, 0); - prg32_gfx_text8(8, 40, "FOUND:", PRG32_COLOR_GREEN, 0); - prg32_gfx_text8(8, 58, found, PRG32_COLOR_WHITE, 0); - prg32_gfx_text8(8, 216, "SELECT SAVE A DISCARD", - PRG32_COLOR_CYAN, 0); - prg32_gfx_present(); - if (save_input & PRG32_BTN_SELECT) { - prg32_store_url_set(found); - prg32_input_wait_released(STORE_ACCEPT); - wait_and_show("SAVED", 1000); - break; - } - if (save_input & STORE_CANCEL) { + char current[PRG32_STORE_URL_MAX_LEN]; + int has_current = prg32_store_url_resolve(current, sizeof(current)) == 0; + uint32_t last = prg32_input_read_menu(); + while (1) { + uint32_t input = prg32_input_read_menu(); + if ((input & PRG32_BTN_UP) && !(last & PRG32_BTN_UP) && choice > 0) { + choice--; + } + if ((input & PRG32_BTN_DOWN) && !(last & PRG32_BTN_DOWN) && choice < 3) { + choice++; + } + if ((input & STORE_CANCEL) && !(last & STORE_CANCEL)) { prg32_input_wait_released(STORE_CANCEL); - break; - } - vTaskDelay(pdMS_TO_TICKS(10)); + return; } - } else { - wait_and_show("NOT FOUND", 2000); - } - break; - } - if (choice == 1) { - char url[PRG32_STORE_URL_MAX_LEN] = ""; - if (prg32_text_input(url, sizeof(url), "STORE URL") >= 0 && url[0]) { - normalize_store_url(url, sizeof(url)); - if (prg32_store_url_set(url) == 0) { - wait_and_show("SAVED", 1000); - } else { - wait_and_show("SAVE FAILED", 1000); + if (((input & PRG32_BTN_SELECT) && !(last & PRG32_BTN_SELECT)) || + ((input & PRG32_BTN_B) && !(last & PRG32_BTN_B))) { + prg32_input_wait_released(STORE_ACCEPT); + if (choice == 0) { + char found[PRG32_STORE_URL_MAX_LEN]; + wait_and_show("SCANNING...", 10); + if (prg32_store_discover(found, sizeof(found)) == 0) { + while (1) { + uint32_t save_input = prg32_input_read_menu(); + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "CARTRIDGE STORE", PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 40, "FOUND:", PRG32_COLOR_GREEN, 0); + prg32_gfx_text8(8, 58, found, PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 216, "SELECT SAVE A DISCARD", PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); + if (save_input & PRG32_BTN_SELECT) { + prg32_store_url_set(found); + prg32_input_wait_released(STORE_ACCEPT); + wait_and_show("SAVED", 1000); + break; + } + if (save_input & STORE_CANCEL) { + prg32_input_wait_released(STORE_CANCEL); + break; + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + } else { + wait_and_show("NOT FOUND", 2000); + } + break; + } + if (choice == 1) { + char url[PRG32_STORE_URL_MAX_LEN] = ""; + if (prg32_text_input(url, sizeof(url), "STORE URL") >= 0 && url[0]) { + normalize_store_url(url, sizeof(url)); + if (prg32_store_url_set(url) == 0) { + wait_and_show("SAVED", 1000); + } else { + wait_and_show("SAVE FAILED", 1000); + } + } + break; + } + if (choice == 2) { + prg32_store_url_clear(); + wait_and_show("CLEARED", 1000); + break; + } + return; } - } - break; - } - if (choice == 2) { - prg32_store_url_clear(); - wait_and_show("CLEARED", 1000); - break; + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "CARTRIDGE STORE", PRG32_COLOR_WHITE, 0); + for (int i = 0; i < 4; ++i) { + int y = 42 + i * 18; + prg32_gfx_text8(8, y, i == choice ? ">" : " ", PRG32_COLOR_GREEN, 0); + prg32_gfx_text8(24, y, items[i], PRG32_COLOR_WHITE, 0); + } + prg32_gfx_text8(8, 144, "CURRENT:", PRG32_COLOR_YELLOW, 0); + prg32_gfx_text8(8, 162, has_current ? current : "(not configured)", PRG32_COLOR_CYAN, 0); + prg32_gfx_text8(8, 216, "UP/DOWN MOVE SELECT/B OK A BACK", PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); + last = input; + vTaskDelay(pdMS_TO_TICKS(10)); } - return; - } - prg32_gfx_clear(PRG32_COLOR_BLACK); - prg32_gfx_text8(8, 8, "CARTRIDGE STORE", PRG32_COLOR_WHITE, 0); - for (int i = 0; i < 4; ++i) { - int y = 42 + i * 18; - prg32_gfx_text8(8, y, i == choice ? ">" : " ", PRG32_COLOR_GREEN, 0); - prg32_gfx_text8(24, y, items[i], PRG32_COLOR_WHITE, 0); - } - prg32_gfx_text8(8, 144, "CURRENT:", PRG32_COLOR_YELLOW, 0); - prg32_gfx_text8(8, 162, has_current ? current : "(not configured)", - PRG32_COLOR_CYAN, 0); - prg32_gfx_text8(8, 216, "UP/DOWN MOVE SELECT/B OK A BACK", - PRG32_COLOR_CYAN, 0); - prg32_gfx_present(); - last = input; - vTaskDelay(pdMS_TO_TICKS(10)); } - } } void prg32_setup_store_browse_run(void) { diff --git a/components/prg32/prg32_splash.c b/components/prg32/prg32_splash.c index f767af1..59ff3a6 100644 --- a/components/prg32/prg32_splash.c +++ b/components/prg32/prg32_splash.c @@ -49,10 +49,15 @@ #define PRG32_SPLASH_LOGO_H 200 #define PRG32_SPLASH_WELCOME_SAMPLE_ID 63 #define PRG32_SPLASH_WELCOME_INSTRUMENT_ID 31 +#ifdef __ELF__ +#define PRG32_FLASH_RODATA __attribute__((section(".rodata"))) +#else +#define PRG32_FLASH_RODATA +#endif extern const uint16_t prg32_splash_logo[]; -static const uint8_t welcome_wave[] = { +static const uint8_t welcome_wave[] PRG32_FLASH_RODATA = { 128, 166, 202, 231, 250, 255, 246, 224, 192, 154, 114, 76, 44, 20, 6, 0, 6, 20, 44, 76, 114, 154, 192, 224, @@ -98,7 +103,7 @@ static int pin_matches(int pin, const int *reserved, size_t count) { } static int splash_i2s_pins_safe(void) { - const int reserved[] = { + static const int reserved[] PRG32_FLASH_RODATA = { PRG32_PIN_LCD_MOSI, PRG32_PIN_LCD_MISO, PRG32_PIN_LCD_SCLK, From f6985bfb9849d4fa8670f7c322f5f54d301c7560 Mon Sep 17 00:00:00 2001 From: raffmont Date: Tue, 9 Jun 2026 13:17:52 +0200 Subject: [PATCH 14/24] Add cartridge RAM profiles --- components/prg32/Kconfig | 51 ++++++++++++++++++++++++++------ components/prg32/include/prg32.h | 2 +- docs/cartridges.md | 6 ++-- docs/framework_manual.md | 10 ++++--- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/components/prg32/Kconfig b/components/prg32/Kconfig index 5c17b4c..91514c4 100644 --- a/components/prg32/Kconfig +++ b/components/prg32/Kconfig @@ -65,18 +65,51 @@ config PRG32_SPLASH_SOUND_ENABLED When the PRG32 I2S audio subsystem is enabled, initialize it and play a short welcome tone sequence while the firmware splash is visible. -config PRG32_CART_RAM_KIB - int "Executable cartridge RAM window in KiB" - default 64 if PRG32_DISPLAY_QEMU_RGB +choice PRG32_CART_RAM_PROFILE + prompt "Executable cartridge RAM profile" + default PRG32_CART_RAM_EXTENDED if PRG32_DISPLAY_QEMU_RGB + default PRG32_CART_RAM_CLASSROOM + help + Choose how much internal executable RAM the resident firmware reserves + for uploadable cartridges. This memory has a fixed exported address so + host tools can link cartridges against prg32_cart_exec. + +config PRG32_CART_RAM_CLASSROOM + bool "Classroom profile, 32 KiB" + help + Reserve a smaller cartridge window for normal labs and examples. This + leaves more SRAM available to the resident runtime, Wi-Fi, setup menus, + diagnostics, and framebuffer work. + +config PRG32_CART_RAM_EXTENDED + bool "Extended profile, 64 KiB" + help + Reserve the historical large cartridge window. Use this only for labs + or demos that intentionally need larger linked cartridge images. + +config PRG32_CART_RAM_CUSTOM + bool "Custom profile" + help + Select a custom cartridge RAM window. Keep the value small for + classroom firmware unless a lab explicitly needs more executable RAM. + +endchoice + +config PRG32_CART_RAM_CUSTOM_KIB + int "Custom executable cartridge RAM window in KiB" + depends on PRG32_CART_RAM_CUSTOM default 48 range 16 64 help - Size of the internal executable RAM window reserved for uploadable - cartridges. The physical ESP32-C6 build defaults to 48 KiB to leave - more SRAM available to the resident runtime, Wi-Fi, setup screens, and - classroom diagnostics. Raise this only for cartridge labs that need a - larger linked image, then rebuild both the resident firmware and the - cartridge. + Custom size for the statically placed cartridge execution window. + Rebuild both the resident firmware and cartridges after changing it. + +config PRG32_CART_RAM_KIB + int + default 32 if PRG32_CART_RAM_CLASSROOM + default 64 if PRG32_CART_RAM_EXTENDED + default PRG32_CART_RAM_CUSTOM_KIB if PRG32_CART_RAM_CUSTOM + default 32 menu "Performance metrics" diff --git a/components/prg32/include/prg32.h b/components/prg32/include/prg32.h index 3f16635..f2c65ea 100644 --- a/components/prg32/include/prg32.h +++ b/components/prg32/include/prg32.h @@ -99,7 +99,7 @@ extern "C" { #define PRG32_CART_ARCH_QEMU "qemu" #define PRG32_CART_MAX_SIZE (32u * 1024u) #ifndef CONFIG_PRG32_CART_RAM_KIB -#define CONFIG_PRG32_CART_RAM_KIB 48 +#define CONFIG_PRG32_CART_RAM_KIB 32 #endif #define PRG32_CART_RAM_SIZE ((uint32_t)CONFIG_PRG32_CART_RAM_KIB * 1024u) #define PRG32_CART_NAME_LEN 32 diff --git a/docs/cartridges.md b/docs/cartridges.md index bd8a820..23a8dcf 100644 --- a/docs/cartridges.md +++ b/docs/cartridges.md @@ -332,7 +332,7 @@ The existing graphics examples already follow the right shape: - use normal RV32 calling convention - save `ra` before calling PRG32 C helpers - keep stack alignment at 16 bytes around C calls -- keep code/data small enough for the 64 KiB cartridge RAM window +- keep code/data small enough for the configured cartridge RAM profile The cartridge linker resolves normal calls such as: @@ -409,7 +409,9 @@ This is intentionally a classroom loader, not a general dynamic linker. - Cartridges are linked for one PRG32 firmware build. - If the firmware is rebuilt, rebuild the cartridges. - Cartridge package size is 32 KiB. -- Cartridge RAM is 64 KiB. +- Cartridge RAM is selected by `CONFIG_PRG32_CART_RAM_PROFILE`: physical + classroom builds default to 32 KiB, while QEMU and extended builds use + 64 KiB unless a custom profile is selected. - AUDIO blocks are stored after the code payload and count against cartridge package size and partition size, not cartridge executable RAM. - Two flash slots, `cart0` and `cart1`, are available. Only one cartridge is diff --git a/docs/framework_manual.md b/docs/framework_manual.md index 34a29f4..9bccae1 100644 --- a/docs/framework_manual.md +++ b/docs/framework_manual.md @@ -228,10 +228,12 @@ Important constants: - `PRG32_CART_META_ABI`: metadata JSON ABI, `prg32-metadata-1.0`. - `PRG32_CART_COLOPHON_ABI`: colophon JSON ABI, `prg32-colophon-1.0`. - `PRG32_CART_MAX_SIZE`: maximum `.prg32` package size, currently 32 KiB. -- `PRG32_CART_RAM_SIZE`: executable cartridge RAM window, configured by - `CONFIG_PRG32_CART_RAM_KIB`. Physical ESP32-C6 builds default to 48 KiB to - leave more SRAM to the resident runtime; QEMU defaults to 64 KiB for desktop - compatibility. +- `PRG32_CART_RAM_SIZE`: statically placed executable cartridge RAM window, + configured by `CONFIG_PRG32_CART_RAM_PROFILE`. Physical ESP32-C6 builds + default to the 32 KiB classroom profile to leave more SRAM to the resident + runtime; QEMU defaults to the 64 KiB extended profile for desktop + compatibility. The window remains static because cartridges are linked to + the exported `prg32_cart_exec` address. - `PRG32_CART_SLOT_COUNT`: number of persistent flash cartridge slots. Important functions: From 3a75283246d7ded44b799ea42f11153c940075f3 Mon Sep 17 00:00:00 2001 From: raffmont Date: Tue, 9 Jun 2026 13:23:11 +0200 Subject: [PATCH 15/24] Lazy allocate optional runtime subsystems --- components/prg32/Kconfig | 10 +++--- components/prg32/prg32_metrics.c | 48 ++++++++++++++++++++++--- components/prg32/prg32_multiplayer.c | 33 +++++++++++++++-- components/prg32/prg32_system.c | 1 - docs/framework_manual.md | 4 +++ docs/metrics_api.md | 2 +- docs/scientific_measurement_tutorial.md | 2 +- main/main.c | 1 - 8 files changed, 87 insertions(+), 14 deletions(-) diff --git a/components/prg32/Kconfig b/components/prg32/Kconfig index 91514c4..57b1b74 100644 --- a/components/prg32/Kconfig +++ b/components/prg32/Kconfig @@ -155,11 +155,13 @@ config PRG32_METRICS_UPLOAD_PERIOD_MS config PRG32_METRICS_QUEUE_LEN int "Metrics upload queue length" depends on PRG32_METRICS_ENABLE - default 512 - range 8 4096 + default 128 + range 8 1024 help - Number of samples kept in RAM before upload. If the queue fills, PRG32 - drops new samples and reports the drop count with the next batch. + Number of samples allocated when a metrics run starts. If the queue + fills, PRG32 drops new samples and reports the drop count with the next + batch. Keep this modest for normal gameplay; recording remains + non-blocking and never allocates from the frame path. endmenu diff --git a/components/prg32/prg32_metrics.c b/components/prg32/prg32_metrics.c index 67783bc..5669ee5 100644 --- a/components/prg32/prg32_metrics.c +++ b/components/prg32/prg32_metrics.c @@ -4,6 +4,7 @@ #include #include #include +#include #include #include "esp_log.h" @@ -24,7 +25,7 @@ #include "freertos/task.h" #ifndef CONFIG_PRG32_METRICS_QUEUE_LEN -#define CONFIG_PRG32_METRICS_QUEUE_LEN 512 +#define CONFIG_PRG32_METRICS_QUEUE_LEN 128 #endif #ifndef CONFIG_PRG32_METRICS_SAMPLE_PERIOD_FRAMES @@ -83,7 +84,7 @@ static prg32_metrics_state_config_t g_config = { }; static portMUX_TYPE g_lock = portMUX_INITIALIZER_UNLOCKED; -static prg32_metric_sample_t g_queue[PRG32_METRICS_QUEUE_CAP]; +static prg32_metric_sample_t *g_queue; static uint16_t g_head; static uint16_t g_tail; static uint16_t g_count; @@ -135,6 +136,37 @@ static void queue_clear_locked(void) { g_dropped_pending = 0; } +static bool queue_alloc(void) { + if (g_queue) { + return true; + } + g_queue = calloc(PRG32_METRICS_QUEUE_CAP, sizeof(g_queue[0])); + if (!g_queue) { + ESP_LOGW(TAG, + "could not allocate metrics queue (%u samples)", + (unsigned)PRG32_METRICS_QUEUE_CAP); + return false; + } + return true; +} + +static void queue_free(void) { + prg32_metric_sample_t *queue = NULL; + taskENTER_CRITICAL(&g_lock); + queue = g_queue; + g_queue = NULL; + queue_clear_locked(); + taskEXIT_CRITICAL(&g_lock); + free(queue); +} + +static bool queue_empty(void) { + taskENTER_CRITICAL(&g_lock); + bool empty = g_count == 0 && g_dropped_pending == 0; + taskEXIT_CRITICAL(&g_lock); + return empty; +} + static void make_run_id(void) { int64_t now_us = esp_timer_get_time(); snprintf(g_run_id, @@ -337,11 +369,15 @@ static void metrics_upload_task(void *arg) { } } - if (!g_running && g_finish_pending && g_run_registered) { + if (!g_running && g_finish_pending && g_run_registered && queue_empty()) { if (post_run_finish()) { g_finish_pending = false; g_run_registered = false; + queue_free(); } + } else if (!g_running && g_finish_pending && !g_run_registered) { + g_finish_pending = false; + queue_free(); } } } @@ -399,6 +435,10 @@ int prg32_metrics_start_run(void) { if (!g_config.enabled) { return 0; } + if (!queue_alloc()) { + g_config.enabled = 0; + return -1; + } taskENTER_CRITICAL(&g_lock); queue_clear_locked(); @@ -430,7 +470,7 @@ int prg32_metrics_is_enabled(void) { } int prg32_metrics_record(const prg32_metric_sample_t *sample) { - if (!sample || !g_config.enabled || !g_running) { + if (!sample || !g_config.enabled || !g_running || !g_queue) { return 0; } diff --git a/components/prg32/prg32_multiplayer.c b/components/prg32/prg32_multiplayer.c index a4c8562..7f23788 100644 --- a/components/prg32/prg32_multiplayer.c +++ b/components/prg32/prg32_multiplayer.c @@ -32,6 +32,7 @@ #define PRG32_MP_SIGNATURE_LEN 48 typedef struct { + uint8_t initialized; uint8_t joined; uint32_t flags; uint32_t player_id; @@ -44,6 +45,8 @@ typedef struct { static prg32_mp_state_t g_mp; +static void ensure_mp_state(void); + static void copy_text(char *dst, size_t dst_size, const char *src) { if (!dst || dst_size == 0) { return; @@ -75,11 +78,13 @@ static int valid_signature(const char *signature) { } static void clear_peers(void) { + ensure_mp_state(); memset(g_mp.peers, 0, sizeof(g_mp.peers)); g_mp.peer_count = 0; } static void prune_peers(uint32_t now_ms) { + ensure_mp_state(); int out = 0; for (int i = 0; i < g_mp.peer_count; ++i) { prg32_player_state_t peer = g_mp.peers[i]; @@ -119,13 +124,18 @@ static void remove_peer(uint32_t player_id) { #if CONFIG_PRG32_DISPLAY_QEMU_RGB || !PRG32_MULTIPLAYER_TRANSPORT_ENABLE void prg32_multiplayer_init(void) { - if (g_mp.player_id == 0) { + if (!g_mp.initialized) { memset(&g_mp, 0, sizeof(g_mp)); + g_mp.initialized = 1; g_mp.player_id = 1; g_mp.local.player_id = g_mp.player_id; } } +static void ensure_mp_state(void) { + prg32_multiplayer_init(); +} + bool prg32_multiplayer_available(void) { #if CONFIG_PRG32_DISPLAY_QEMU_RGB return true; @@ -135,6 +145,7 @@ bool prg32_multiplayer_available(void) { } int prg32_multiplayer_join(const char *cartridge_signature, uint32_t flags) { + ensure_mp_state(); #if CONFIG_PRG32_DISPLAY_QEMU_RGB if (!valid_signature(cartridge_signature)) { return -1; @@ -152,12 +163,14 @@ int prg32_multiplayer_join(const char *cartridge_signature, uint32_t flags) { } int prg32_multiplayer_leave(void) { + ensure_mp_state(); g_mp.joined = 0; clear_peers(); return 0; } void prg32_multiplayer_tick(void) { + ensure_mp_state(); prune_peers(prg32_ticks_ms()); } @@ -165,6 +178,7 @@ int prg32_multiplayer_set_local_state(int16_t x, int16_t y, uint16_t sprite, uint16_t flags) { + ensure_mp_state(); if (!g_mp.joined) { return -1; } @@ -179,6 +193,7 @@ int prg32_multiplayer_set_local_state(int16_t x, } int prg32_multiplayer_set_input(uint32_t input) { + ensure_mp_state(); if (!g_mp.joined) { return -1; } @@ -187,11 +202,13 @@ int prg32_multiplayer_set_input(uint32_t input) { } int prg32_multiplayer_get_peer_count(void) { + ensure_mp_state(); prune_peers(prg32_ticks_ms()); return g_mp.peer_count; } int prg32_multiplayer_get_peer(int index, prg32_player_state_t *out) { + ensure_mp_state(); prune_peers(prg32_ticks_ms()); if (!out || index < 0 || index >= g_mp.peer_count) { return -1; @@ -421,8 +438,9 @@ void prg32_multiplayer_init(void) { if (!g_mp_lock) { g_mp_lock = xSemaphoreCreateMutex(); } - if (g_mp.player_id == 0) { + if (!g_mp.initialized) { memset(&g_mp, 0, sizeof(g_mp)); + g_mp.initialized = 1; g_mp.player_id = esp_random(); if (g_mp.player_id == 0) { g_mp.player_id = 1; @@ -431,11 +449,16 @@ void prg32_multiplayer_init(void) { } } +static void ensure_mp_state(void) { + prg32_multiplayer_init(); +} + bool prg32_multiplayer_available(void) { return PRG32_MULTIPLAYER_ENABLE != 0; } int prg32_multiplayer_join(const char *cartridge_signature, uint32_t flags) { + ensure_mp_state(); if (!valid_signature(cartridge_signature) || !prg32_multiplayer_available()) { return -1; } @@ -455,6 +478,7 @@ int prg32_multiplayer_join(const char *cartridge_signature, uint32_t flags) { } int prg32_multiplayer_leave(void) { + ensure_mp_state(); send_leave(); if (lock_mp() == 0) { g_mp.joined = 0; @@ -465,6 +489,7 @@ int prg32_multiplayer_leave(void) { } void prg32_multiplayer_tick(void) { + ensure_mp_state(); uint32_t now = prg32_ticks_ms(); if (lock_mp() == 0) { prune_peers(now); @@ -482,6 +507,7 @@ int prg32_multiplayer_set_local_state(int16_t x, int16_t y, uint16_t sprite, uint16_t flags) { + ensure_mp_state(); if (lock_mp() != 0) { return -1; } @@ -501,6 +527,7 @@ int prg32_multiplayer_set_local_state(int16_t x, } int prg32_multiplayer_set_input(uint32_t input) { + ensure_mp_state(); if (lock_mp() != 0) { return -1; } @@ -514,6 +541,7 @@ int prg32_multiplayer_set_input(uint32_t input) { } int prg32_multiplayer_get_peer_count(void) { + ensure_mp_state(); int count = 0; if (lock_mp() == 0) { prune_peers(prg32_ticks_ms()); @@ -524,6 +552,7 @@ int prg32_multiplayer_get_peer_count(void) { } int prg32_multiplayer_get_peer(int index, prg32_player_state_t *out) { + ensure_mp_state(); if (!out || lock_mp() != 0) { return -1; } diff --git a/components/prg32/prg32_system.c b/components/prg32/prg32_system.c index 6768995..481c364 100644 --- a/components/prg32/prg32_system.c +++ b/components/prg32/prg32_system.c @@ -889,7 +889,6 @@ void prg32_init(void) { prg32_splash_show_default(); printf("prg32_init => prg32_input_init()\n"); prg32_input_init(); - prg32_multiplayer_init(); printf("prg32_init => prg32_abi_exports_keep()\n"); prg32_abi_exports_keep(); printf("prg32_init => prg32_cart_init()\n"); diff --git a/docs/framework_manual.md b/docs/framework_manual.md index 9bccae1..a823ea1 100644 --- a/docs/framework_manual.md +++ b/docs/framework_manual.md @@ -192,6 +192,10 @@ default and controlled through Kconfig: - `CONFIG_PRG32_METRICS_UPLOAD_PERIOD_MS` - `CONFIG_PRG32_METRICS_QUEUE_LEN` +The metrics upload queue is allocated only when a metrics run starts. Recording +remains non-blocking; if the queue fills, new samples are dropped and reported +with the next uploaded batch. + The public API is in `prg32_metrics.h`: - `prg32_metrics_init(config)` diff --git a/docs/metrics_api.md b/docs/metrics_api.md index cf55268..db83e8a 100644 --- a/docs/metrics_api.md +++ b/docs/metrics_api.md @@ -120,7 +120,7 @@ Useful options: | `CONFIG_PRG32_METRICS_BOARD_ID` | `prg32-board` | Board name stored with each run | | `CONFIG_PRG32_METRICS_SAMPLE_PERIOD_FRAMES` | `1` | Record one sample every N frames | | `CONFIG_PRG32_METRICS_UPLOAD_PERIOD_MS` | `5000` | Background upload period | -| `CONFIG_PRG32_METRICS_QUEUE_LEN` | `512` | In-RAM sample queue length | +| `CONFIG_PRG32_METRICS_QUEUE_LEN` | `128` | Sample queue length allocated when a metrics run starts | The runtime starts a metrics run when a cartridge starts and stops the run when the cartridge is replaced or unloaded. diff --git a/docs/scientific_measurement_tutorial.md b/docs/scientific_measurement_tutorial.md index 0e1fd38..5bdd46c 100644 --- a/docs/scientific_measurement_tutorial.md +++ b/docs/scientific_measurement_tutorial.md @@ -96,7 +96,7 @@ CONFIG_PRG32_METRICS_SERVER_URL="http://:8080" CONFIG_PRG32_METRICS_BOARD_ID="lab-board-01" CONFIG_PRG32_METRICS_SAMPLE_PERIOD_FRAMES=1 CONFIG_PRG32_METRICS_UPLOAD_PERIOD_MS=5000 -CONFIG_PRG32_METRICS_QUEUE_LEN=512 +CONFIG_PRG32_METRICS_QUEUE_LEN=128 ``` For QEMU, use the QEMU build directory and decide whether the experiment is a diff --git a/main/main.c b/main/main.c index abbb9ae..251292a 100644 --- a/main/main.c +++ b/main/main.c @@ -213,7 +213,6 @@ void app_main(void) { #endif prg32_init(); - prg32_configure_metrics("idle"); if (!prg32_cart_is_loaded()) { prg32_console_clear(); prg32_console_write("PRG32 READY: use setup to upload a cartridge\n"); From 233c315d16a5ef70403aea25da0b1b024dde1be5 Mon Sep 17 00:00:00 2001 From: raffmont Date: Tue, 9 Jun 2026 13:30:13 +0200 Subject: [PATCH 16/24] Use capability-specific heap allocations --- components/prg32/prg32_cart.c | 9 +-- components/prg32/prg32_http_games.c | 10 ++-- components/prg32/prg32_metrics.c | 7 ++- components/prg32/prg32_performance.c | 13 ++-- components/prg32/prg32_platform.c | 6 +- components/prg32/prg32_setup_store.c | 89 +++++++++++++++------------- components/prg32/prg32_tile.c | 18 +++--- components/prg32/prg32_wifi.c | 15 +++-- 8 files changed, 97 insertions(+), 70 deletions(-) diff --git a/components/prg32/prg32_cart.c b/components/prg32/prg32_cart.c index bf702a9..cc1e2b7 100644 --- a/components/prg32/prg32_cart.c +++ b/components/prg32/prg32_cart.c @@ -11,6 +11,7 @@ #include "esp_log.h" #include "nvs.h" #include "nvs_flash.h" +#include "esp_heap_caps.h" #include "esp_partition.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" @@ -425,20 +426,20 @@ int prg32_cart_load_stored(void) { set_errorf("no stored cartridge in %s", slot_name(g_current_slot)); return -1; } - uint8_t *image = malloc(image_size); + uint8_t *image = heap_caps_malloc(image_size, MALLOC_CAP_8BIT); if (!image) { set_error("out of memory reading cartridge"); return -1; } esp_err_t err = esp_partition_read(part, 0, image, image_size); if (err != ESP_OK) { - free(image); + heap_caps_free(image); set_error("failed to read stored cartridge"); return -1; } if (lock_cart() != 0) { - free(image); + heap_caps_free(image); set_error("failed to lock cartridge runtime"); return -1; } @@ -447,7 +448,7 @@ int prg32_cart_load_stored(void) { g_stored = true; } unlock_cart(); - free(image); + heap_caps_free(image); return rc; } diff --git a/components/prg32/prg32_http_games.c b/components/prg32/prg32_http_games.c index fb676c8..75566db 100644 --- a/components/prg32/prg32_http_games.c +++ b/components/prg32/prg32_http_games.c @@ -5,6 +5,7 @@ #include "cJSON.h" #include "esp_err.h" +#include "esp_heap_caps.h" #include "esp_http_server.h" #include "esp_log.h" #include @@ -380,7 +381,8 @@ static esp_err_t post_game(httpd_req_t *req) { httpd_resp_send_err(req, 400, msg); return ESP_FAIL; } - uint8_t *body = malloc((size_t)req->content_len); + uint8_t *body = heap_caps_malloc((size_t)req->content_len, + MALLOC_CAP_8BIT); if (!body) { httpd_resp_send_err(req, 500, "out of memory"); return ESP_ERR_NO_MEM; @@ -394,7 +396,7 @@ static esp_err_t post_game(httpd_req_t *req) { continue; } if (n <= 0) { - free(body); + heap_caps_free(body); httpd_resp_send_err(req, 400, "failed to read cartridge"); return ESP_FAIL; } @@ -403,14 +405,14 @@ static esp_err_t post_game(httpd_req_t *req) { ESP_LOGI(TAG, "POST /api/games received=%u", (unsigned)received); uint8_t slot = 0; if (request_slot(req, &slot) != 0) { - free(body); + heap_caps_free(body); httpd_resp_send_err(req, 400, "invalid cartridge slot"); return ESP_FAIL; } ESP_LOGI(TAG, "POST /api/games slot=%u", (unsigned)slot); ESP_LOGI(TAG, "POST /api/games storing slot=%u", (unsigned)slot); int err = prg32_cart_store_slot(slot, body, received); - free(body); + heap_caps_free(body); if (err != 0) { httpd_resp_send_err(req, 400, prg32_cart_last_error()); return ESP_FAIL; diff --git a/components/prg32/prg32_metrics.c b/components/prg32/prg32_metrics.c index 5669ee5..469c9c3 100644 --- a/components/prg32/prg32_metrics.c +++ b/components/prg32/prg32_metrics.c @@ -19,6 +19,7 @@ #include "cJSON.h" #include "esp_err.h" +#include "esp_heap_caps.h" #include "esp_http_client.h" #include "freertos/FreeRTOS.h" #include "freertos/portmacro.h" @@ -140,7 +141,9 @@ static bool queue_alloc(void) { if (g_queue) { return true; } - g_queue = calloc(PRG32_METRICS_QUEUE_CAP, sizeof(g_queue[0])); + g_queue = heap_caps_calloc(PRG32_METRICS_QUEUE_CAP, + sizeof(g_queue[0]), + MALLOC_CAP_8BIT); if (!g_queue) { ESP_LOGW(TAG, "could not allocate metrics queue (%u samples)", @@ -157,7 +160,7 @@ static void queue_free(void) { g_queue = NULL; queue_clear_locked(); taskEXIT_CRITICAL(&g_lock); - free(queue); + heap_caps_free(queue); } static bool queue_empty(void) { diff --git a/components/prg32/prg32_performance.c b/components/prg32/prg32_performance.c index 6768829..ad22b89 100644 --- a/components/prg32/prg32_performance.c +++ b/components/prg32/prg32_performance.c @@ -2,6 +2,7 @@ #include "prg32_config.h" #include "cJSON.h" +#include "esp_heap_caps.h" #include "esp_system.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" @@ -153,11 +154,15 @@ static int perf_buffers_alloc(void) { if (g_samples && g_windows) { return 0; } - g_samples = calloc(PRG32_PERF_MAX_SAMPLES, sizeof(prg32_perf_sample_t)); - g_windows = calloc(PRG32_PERF_MAX_WINDOWS, sizeof(prg32_perf_window_t)); + g_samples = heap_caps_calloc(PRG32_PERF_MAX_SAMPLES, + sizeof(prg32_perf_sample_t), + MALLOC_CAP_8BIT); + g_windows = heap_caps_calloc(PRG32_PERF_MAX_WINDOWS, + sizeof(prg32_perf_window_t), + MALLOC_CAP_8BIT); if (!g_samples || !g_windows) { - free(g_samples); - free(g_windows); + heap_caps_free(g_samples); + heap_caps_free(g_windows); g_samples = NULL; g_windows = NULL; return -1; diff --git a/components/prg32/prg32_platform.c b/components/prg32/prg32_platform.c index a0188d2..b148fd1 100644 --- a/components/prg32/prg32_platform.c +++ b/components/prg32/prg32_platform.c @@ -1,7 +1,7 @@ #include "prg32.h" #include -#include #include +#include "esp_heap_caps.h" static uint8_t *g_tile_flags; @@ -27,7 +27,9 @@ static int tile_flags_ready(void) { if (g_tile_flags) { return 1; } - g_tile_flags = calloc(256, sizeof(g_tile_flags[0])); + g_tile_flags = heap_caps_calloc(256, + sizeof(g_tile_flags[0]), + MALLOC_CAP_8BIT); return g_tile_flags != NULL; } diff --git a/components/prg32/prg32_setup_store.c b/components/prg32/prg32_setup_store.c index 2523e72..b4b09f7 100644 --- a/components/prg32/prg32_setup_store.c +++ b/components/prg32/prg32_setup_store.c @@ -1,3 +1,6 @@ +#include "prg32.h" +#include "prg32_config.h" +#include "esp_heap_caps.h" #include "esp_http_client.h" #include "esp_log.h" #include "esp_system.h" @@ -34,28 +37,32 @@ static store_game_t *games; static int game_count; static int store_buffers_alloc(char *status, size_t status_len) { - if (catalog_body && games) { + if (catalog_body && games) { + return 0; + } + catalog_body = heap_caps_calloc(1, + PRG32_STORE_CATALOG_MAX_BYTES, + MALLOC_CAP_8BIT); + games = heap_caps_calloc(STORE_MAX_GAMES, + sizeof(store_game_t), + MALLOC_CAP_8BIT); + if (!catalog_body || !games) { + heap_caps_free(catalog_body); + heap_caps_free(games); + catalog_body = NULL; + games = NULL; + snprintf(status, status_len, "NO MEM"); + return -1; + } return 0; - } - catalog_body = calloc(1, PRG32_STORE_CATALOG_MAX_BYTES); - games = calloc(STORE_MAX_GAMES, sizeof(store_game_t)); - if (!catalog_body || !games) { - free(catalog_body); - free(games); - catalog_body = NULL; - games = NULL; - snprintf(status, status_len, "NO MEM"); - return -1; - } - return 0; } static void store_buffers_free(void) { - free(catalog_body); - free(games); - catalog_body = NULL; - games = NULL; - game_count = 0; + heap_caps_free(catalog_body); + heap_caps_free(games); + catalog_body = NULL; + games = NULL; + game_count = 0; } static void wait_and_show(const char *line, uint32_t ms) { @@ -232,30 +239,32 @@ static int contains_casefold(const char *text, const char *needle) { } static int filter_catalog(const char *query) { - if (!catalog_body || !games) { - return 0; - } - store_game_t *all_games = malloc(STORE_MAX_GAMES * sizeof(store_game_t)); - if (!all_games) { + if (!catalog_body || !games) { + return 0; + } + store_game_t *all_games = heap_caps_malloc( + STORE_MAX_GAMES * sizeof(store_game_t), + MALLOC_CAP_8BIT); + if (!all_games) { + game_count = 0; + return 0; + } + int all_count = parse_catalog(catalog_body); + memcpy(all_games, games, STORE_MAX_GAMES * sizeof(store_game_t)); + if (!query || !query[0]) { + heap_caps_free(all_games); + return all_count; + } game_count = 0; - return 0; - } - int all_count = parse_catalog(catalog_body); - memcpy(all_games, games, STORE_MAX_GAMES * sizeof(store_game_t)); - if (!query || !query[0]) { - free(all_games); - return all_count; - } - game_count = 0; - for (int i = 0; i < all_count && game_count < STORE_MAX_GAMES; ++i) { - if (contains_casefold(all_games[i].title, query) || - contains_casefold(all_games[i].tags, query) || - contains_casefold(all_games[i].id, query)) { - games[game_count++] = all_games[i]; + for (int i = 0; i < all_count && game_count < STORE_MAX_GAMES; ++i) { + if (contains_casefold(all_games[i].title, query) || + contains_casefold(all_games[i].tags, query) || + contains_casefold(all_games[i].id, query)) { + games[game_count++] = all_games[i]; + } } - } - free(all_games); - return game_count; + heap_caps_free(all_games); + return game_count; } static int fetch_catalog(const char *base_url, char *status, diff --git a/components/prg32/prg32_tile.c b/components/prg32/prg32_tile.c index d7b9716..d37a5cb 100644 --- a/components/prg32/prg32_tile.c +++ b/components/prg32/prg32_tile.c @@ -1,6 +1,6 @@ #include "prg32.h" -#include #include +#include "esp_heap_caps.h" typedef struct { uint8_t bits[8]; @@ -33,15 +33,15 @@ static int tile_grid_ready(void) { if (g_tiles && g_map && g_dirty) { return 1; } - tile_t *tiles = calloc(256, sizeof(tile_t)); + tile_t *tiles = heap_caps_calloc(256, sizeof(tile_t), MALLOC_CAP_8BIT); uint8_t (*map)[PRG32_TILE_COLS] = - calloc(PRG32_TILE_ROWS, sizeof(*map)); + heap_caps_calloc(PRG32_TILE_ROWS, sizeof(*map), MALLOC_CAP_8BIT); uint8_t (*dirty)[PRG32_TILE_COLS] = - calloc(PRG32_TILE_ROWS, sizeof(*dirty)); + heap_caps_calloc(PRG32_TILE_ROWS, sizeof(*dirty), MALLOC_CAP_8BIT); if (!tiles || !map || !dirty) { - free(tiles); - free(map); - free(dirty); + heap_caps_free(tiles); + heap_caps_free(map); + heap_caps_free(dirty); return 0; } g_tiles = tiles; @@ -54,7 +54,9 @@ static int playfield_ready(void) { if (g_playfield) { return 1; } - g_playfield = calloc(PRG32_PLAYFIELD_LAYERS, sizeof(*g_playfield)); + g_playfield = heap_caps_calloc(PRG32_PLAYFIELD_LAYERS, + sizeof(*g_playfield), + MALLOC_CAP_8BIT); return g_playfield != NULL; } diff --git a/components/prg32/prg32_wifi.c b/components/prg32/prg32_wifi.c index ecc3f81..3f0da46 100644 --- a/components/prg32/prg32_wifi.c +++ b/components/prg32/prg32_wifi.c @@ -10,6 +10,7 @@ #include "esp_log.h" #include "esp_netif.h" #include "esp_netif_ip_addr.h" +#include "esp_heap_caps.h" #include "esp_wifi.h" #include "nvs.h" #include "nvs_flash.h" @@ -562,7 +563,9 @@ static int scan_networks(wifi_ap_record_t **records_out, uint16_t count = 0; err = esp_wifi_scan_get_ap_num(&count); if (err == ESP_OK && count > 0) { - wifi_ap_record_t *records = calloc(count, sizeof(*records)); + wifi_ap_record_t *records = heap_caps_calloc(count, + sizeof(*records), + MALLOC_CAP_8BIT); if (!records) { err = ESP_ERR_NO_MEM; } else { @@ -572,7 +575,7 @@ static int scan_networks(wifi_ap_record_t **records_out, found_count = count; records = NULL; } - free(records); + heap_caps_free(records); } } } @@ -641,7 +644,7 @@ static void refresh_stored_sta_ap(const prg32_wifi_config_t *config) { "stored SSID refresh scan failed for %s: %s", config->ssid, scan_status[0] ? scan_status : "not found"); - free(records); + heap_caps_free(records); return; } @@ -664,7 +667,7 @@ static void refresh_stored_sta_ap(const prg32_wifi_config_t *config) { } else { ESP_LOGW(TAG, "stored SSID %s not found in refresh scan", config->ssid); } - free(records); + heap_caps_free(records); } static int confirm_sta_credentials(const prg32_wifi_config_t *config) { @@ -776,7 +779,7 @@ static int choose_ssid(char *ssid, size_t ssid_size) { choice++; } if ((input & PRG32_BTN_A) && !(last & PRG32_BTN_A)) { - free(records); + heap_caps_free(records); prg32_input_wait_released(PRG32_BTN_A); return -1; } @@ -784,7 +787,7 @@ static int choose_ssid(char *ssid, size_t ssid_size) { ((input & PRG32_BTN_B) && !(last & PRG32_BTN_B))) { copy_cstr(ssid, ssid_size, (const char *)records[choice].ssid); select_ap_record(&records[choice]); - free(records); + heap_caps_free(records); prg32_input_wait_released(PRG32_BTN_B | PRG32_BTN_SELECT); return 0; } From bfc06d63cf196a2cf2813063c2e96866bc389ef1 Mon Sep 17 00:00:00 2001 From: Simone Boscaglia Date: Tue, 9 Jun 2026 16:34:42 +0200 Subject: [PATCH 17/24] fix(wifi): Fix boot connect timeouts by using active scan and locking to BSSID This commit resolves an issue where the ESP32-C6 was caught in a loop of connection timeouts (Reason 201 NO AP FOUND) on borderline weak signals because it was scanning blindly too fast and failing to lock onto the AP. Changes included: - Increased active scan dwell time (min=100ms, max=300ms) in the initial `scan_networks` pass to reliably discover borderline networks. - Removed the strict `selected_ap_locked` condition in `prg32_wifi_start_mode` so the connection sequence correctly utilizes the target channel and BSSID explicitly discovered during the boot scan. - Explicitly set `WIFI_ALL_CHANNEL_SCAN` and `WIFI_CONNECT_AP_BY_SIGNAL` in the connection configuration to ensure maximum reliability. These changes allow the framework to isolate the router's beacons out of the noise floor, skipping blind channel hopping and connecting instantly. --- components/prg32/prg32_wifi.c | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/components/prg32/prg32_wifi.c b/components/prg32/prg32_wifi.c index 3f0da46..61b2d77 100644 --- a/components/prg32/prg32_wifi.c +++ b/components/prg32/prg32_wifi.c @@ -334,13 +334,17 @@ int prg32_wifi_start_mode(const prg32_wifi_config_t *config) { return -1; } - wifi_config_t sta = {0}; + wifi_config_t sta = { + .sta = { + .scan_method = WIFI_ALL_CHANNEL_SCAN, + .sort_method = WIFI_CONNECT_AP_BY_SIGNAL + } + }; copy_cstr((char *)sta.sta.ssid, sizeof(sta.sta.ssid), config->ssid); copy_cstr((char *)sta.sta.password, sizeof(sta.sta.password), config->password); if (selected_ap_valid && - selected_ap_locked && strcmp(config->ssid, selected_ssid) == 0) { memcpy(sta.sta.bssid, selected_bssid, sizeof(sta.sta.bssid)); sta.sta.bssid_set = true; @@ -556,6 +560,13 @@ static int scan_networks(wifi_ap_record_t **records_out, wifi_scan_config_t scan = { .show_hidden = true, + .scan_type = WIFI_SCAN_TYPE_ACTIVE, + .scan_time = { + .active = { + .min = 100, + .max = 300 + } + } }; uint16_t found_count = 0; err = esp_wifi_scan_start(&scan, true); @@ -626,7 +637,8 @@ static void select_ap_record(const wifi_ap_record_t *record) { static void select_ap_record_for_boot(const wifi_ap_record_t *record) { select_ap_record(record); - selected_ap_locked = false; + // explicitly allow using the locked BSSID from refresh scan + selected_ap_locked = true; } static void refresh_stored_sta_ap(const prg32_wifi_config_t *config) { From aec88035c3e68b45f31ead2256045f3e9d5d3f06 Mon Sep 17 00:00:00 2001 From: Simone Boscaglia Date: Tue, 9 Jun 2026 16:49:21 +0200 Subject: [PATCH 18/24] feat(store): Set default Cartridge Store URL at boot This commit ensures the firmware falls back to a default, known Cartridge Store server address if no mDNS service or saved URL is available. Changes included: - Added `PRG32_STORE_SERVER_URL` macro definition in `prg32_config.h`. - Initialized NVS explicitly in `prg32_init()` to allow writing the default Store URL into the flash storage immediately upon boot. - Added a fallback in `prg32_setup_store.c` to gracefully use the default URL if the user actively clears the saved NVS configuration and proceeds straight to the browse menu. --- components/prg32/prg32_setup_store.c | 18 ++++++++++++++++-- components/prg32/prg32_system.c | 16 ++++++++++++++-- main/prg32_config.h | 1 + 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/components/prg32/prg32_setup_store.c b/components/prg32/prg32_setup_store.c index b4b09f7..6c93093 100644 --- a/components/prg32/prg32_setup_store.c +++ b/components/prg32/prg32_setup_store.c @@ -395,18 +395,26 @@ static int stream_download(const char *base_url, const store_game_t *game, esp_http_client_cleanup(client); return -1; } - uint8_t chunk[PRG32_STORE_CHUNK_BYTES]; + uint8_t *chunk = heap_caps_malloc(PRG32_STORE_CHUNK_BYTES, MALLOC_CAP_8BIT); + if (!chunk) { + snprintf(status, status_len, "NO MEM"); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } size_t offset = 0; while (offset < (size_t)content_len) { - int read = esp_http_client_read(client, (char *)chunk, sizeof(chunk)); + int read = esp_http_client_read(client, (char *)chunk, PRG32_STORE_CHUNK_BYTES); if (read <= 0) { snprintf(status, status_len, "TIMEOUT"); + heap_caps_free(chunk); esp_http_client_close(client); esp_http_client_cleanup(client); return -1; } if (prg32_cart_stream_write(slot, offset, chunk, (size_t)read) != 0) { snprintf(status, status_len, "%s", prg32_cart_last_error()); + heap_caps_free(chunk); esp_http_client_close(client); esp_http_client_cleanup(client); return -1; @@ -414,6 +422,7 @@ static int stream_download(const char *base_url, const store_game_t *game, offset += (size_t)read; vTaskDelay(pdMS_TO_TICKS(1)); } + heap_caps_free(chunk); esp_http_client_close(client); esp_http_client_cleanup(client); if (prg32_cart_stream_end(slot, offset) != 0) { @@ -617,8 +626,13 @@ void prg32_setup_store_browse_run(void) { prg32_scores_api_start(); char url[PRG32_STORE_URL_MAX_LEN]; if (prg32_store_url_resolve(url, sizeof(url)) != 0) { +#ifdef PRG32_STORE_SERVER_URL + snprintf(url, sizeof(url), "%s", PRG32_STORE_SERVER_URL); + prg32_store_url_set(url); +#else wait_and_show("CONFIGURE STORE FIRST", 2000); return; +#endif } char status[32]; if (store_buffers_alloc(status, sizeof(status)) != 0) { diff --git a/components/prg32/prg32_system.c b/components/prg32/prg32_system.c index 481c364..25eddd7 100644 --- a/components/prg32/prg32_system.c +++ b/components/prg32/prg32_system.c @@ -12,6 +12,8 @@ void prg32_input_init(void); void prg32_audio_pwm_init(void); void prg32_abi_exports_keep(void); +#include "nvs_flash.h" + #ifndef PRG32_BOOT_SETUP_MODE #define PRG32_BOOT_SETUP_MODE 0 #endif @@ -889,6 +891,17 @@ void prg32_init(void) { prg32_splash_show_default(); printf("prg32_init => prg32_input_init()\n"); prg32_input_init(); + esp_err_t nvs_err = nvs_flash_init(); + if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + nvs_flash_erase(); + nvs_flash_init(); + } +#ifdef PRG32_STORE_SERVER_URL + char current_url[PRG32_STORE_URL_MAX_LEN]; + if (prg32_store_url_get(current_url, sizeof(current_url)) != 0) { + prg32_store_url_set(PRG32_STORE_SERVER_URL); + } +#endif printf("prg32_init => prg32_abi_exports_keep()\n"); prg32_abi_exports_keep(); printf("prg32_init => prg32_cart_init()\n"); @@ -927,12 +940,11 @@ void prg32_init(void) { prg32_wifi_scores_init(); prg32_scores_api_start(); #endif - printf("prg32_init => cart_is_loaded()\n"); printf("prg32_init => cart_stored_count()\n"); if (!prg32_cart_is_loaded() && prg32_cart_stored_count() > 0) { printf("prg32_init => autoload_cartridge()\n"); - autoload_cartridge(); + //autoload_cartridge(); } prg32_gfx_set_fullscreen(0); prg32_gfx_clear(PRG32_COLOR_BLACK); diff --git a/main/prg32_config.h b/main/prg32_config.h index 78c78f1..ad13eab 100644 --- a/main/prg32_config.h +++ b/main/prg32_config.h @@ -152,6 +152,7 @@ /* CartridgeStore integration constants. */ #define PRG32_STORE_URL_MAX_LEN 128 +#define PRG32_STORE_SERVER_URL "http://193.205.230.7:5080/" #define PRG32_STORE_MDNS_SERVICE "_prg32store" #define PRG32_STORE_MDNS_PROTO "_tcp" #define PRG32_STORE_MDNS_TIMEOUT_MS 3000 From a5f40a190387404d34c3ee034204c4d29431ff0d Mon Sep 17 00:00:00 2001 From: Simone Boscaglia Date: Tue, 9 Jun 2026 18:25:33 +0200 Subject: [PATCH 19/24] fix: re bumped up the store max games to 64 --- components/prg32/prg32_setup_store.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/prg32/prg32_setup_store.c b/components/prg32/prg32_setup_store.c index 6c93093..2517dab 100644 --- a/components/prg32/prg32_setup_store.c +++ b/components/prg32/prg32_setup_store.c @@ -18,7 +18,7 @@ #if CONFIG_PRG32_DISPLAY_QEMU_RGB #define STORE_MAX_GAMES 40 #else -#define STORE_MAX_GAMES 4 +#define STORE_MAX_GAMES 64 #endif #define STORE_PAGE_SIZE 4 From a6fb2fcb736280c53d4051221a63967ced027798 Mon Sep 17 00:00:00 2001 From: Simone Boscaglia Date: Tue, 9 Jun 2026 19:36:58 +0200 Subject: [PATCH 20/24] feat: add local environment file support for WiFi credentials - Wraps WiFi credentials in `prg32_config.h` with `#ifndef` and conditionally includes `prg32_env.h` - Adds `prg32_env.h` to `.gitignore` to prevent committing secrets - Provides `prg32_env.h.example` as a template for local overrides --- .gitignore | 3 +++ main/prg32_config.h | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/.gitignore b/.gitignore index c4acd6c..72ac1f2 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ Testing/ .cache/ *.prg32 +# Local environment configuration +prg32_env.h + # Local Python tooling .venv/ __pycache__/ diff --git a/main/prg32_config.h b/main/prg32_config.h index ad13eab..d3963a1 100644 --- a/main/prg32_config.h +++ b/main/prg32_config.h @@ -122,8 +122,17 @@ /* Optional Wi-Fi score REST API. Fill credentials before flashing. */ #define PRG32_WIFI_SCORES_ENABLE 0 + +#if __has_include("prg32_env.h") +#include "prg32_env.h" +#endif + +#ifndef PRG32_WIFI_SSID #define PRG32_WIFI_SSID "YOUR_WIFI_SSID" +#endif +#ifndef PRG32_WIFI_PASSWORD #define PRG32_WIFI_PASSWORD "YOUR_WIFI_PASSWORD" +#endif #define PRG32_SCORE_MAX 16 #define PRG32_IDLE_HEARTBEAT_MS 5000 From 44bb955a1e6998f34fc6f98ff79b559d532c610f Mon Sep 17 00:00:00 2001 From: raffmont Date: Wed, 10 Jun 2026 20:10:36 +0200 Subject: [PATCH 21/24] Add portable cartridge ABI table --- .github/workflows/ci.yml | 3 + AGENTS.md | 25 + README.md | 30 +- components/prg32/CMakeLists.txt | 1 + components/prg32/include/prg32.h | 29 + components/prg32/include/prg32_abi.h | 30 + components/prg32/include/prg32_abi_hash.h | 7 + components/prg32/include/prg32_abi_index.h | 120 ++++ components/prg32/include/prg32_cart_api.h | 15 + components/prg32/prg32_abi_table.c | 133 ++++ components/prg32/prg32_cart.c | 45 +- docs/abi.md | 46 +- docs/api.md | 6 +- docs/audio.md | 2 +- docs/cartridge-format.md | 2 +- docs/cartridge_metadata.md | 10 +- docs/cartridge_store.md | 2 +- docs/cartridges.md | 29 +- docs/deployment.md | 2 +- docs/getting_started_game_development.md | 18 +- docs/labs/break_fix_assignments.md | 4 +- docs/qemu.md | 2 +- docs/scientific_measurement_tutorial.md | 2 +- docs/teaching_with_prg32.md | 4 +- docs/tutorial.md | 2 +- docs/tutorial_c_game.md | 2 +- examples/features/README.md | 4 +- examples/features/splash_screen/README.md | 4 +- examples/games/README.md | 8 +- tests/test_prg32_abi_gen.py | 24 + tests/test_prg32_game.py | 38 + tools/prg32_abi.json | 798 +++++++++++++++++++++ tools/prg32_abi_gen.py | 178 +++++ tools/prg32_abi_generated.py | 132 ++++ tools/prg32_game.py | 326 +++++---- 35 files changed, 1885 insertions(+), 198 deletions(-) create mode 100644 components/prg32/include/prg32_abi.h create mode 100644 components/prg32/include/prg32_abi_hash.h create mode 100644 components/prg32/include/prg32_abi_index.h create mode 100644 components/prg32/include/prg32_cart_api.h create mode 100644 components/prg32/prg32_abi_table.c create mode 100644 tests/test_prg32_abi_gen.py create mode 100644 tools/prg32_abi.json create mode 100644 tools/prg32_abi_gen.py create mode 100644 tools/prg32_abi_generated.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbb7ad8..66e6eb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,9 @@ jobs: - name: Run host smoke test run: bash scripts/ci_smoke_test.sh + - name: Check portable ABI generated files + run: python3 tools/prg32_abi_gen.py --check + esp-idf-build: name: ESP-IDF firmware build runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 95d129f..e9c1482 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -428,6 +428,31 @@ Companion repository: https://github.com/riscv-prg32/CartridgeStore | `tools/prg32_game.py` | `publish`, `pack-bundle`, `publish-bundle`, `store-*` | | `docs/cartridge_store.md` | End-user integration guide | +## PRG32 Cartridge ABI Compatibility + +- Treat `tools/prg32_abi.json` as the single source of truth for the portable + cartridge ABI. +- Do not edit generated ABI files manually. Regenerate or check them with + `python3 tools/prg32_abi_gen.py` and `python3 tools/prg32_abi_gen.py --check`. +- ABI function indices are append-only. Never reorder, remove, or change + existing function prototypes within the same ABI major version. +- Any incompatible ABI change requires increasing `PRG32_ABI_MAJOR`. +- Additive functions or optional feature bits may increase `PRG32_ABI_MINOR`. +- Portable cartridges must not link against firmware-specific symbol addresses. +- Legacy absolute-import cartridges may remain supported, but new examples and + documentation should prefer portable ABI-table cartridges. + +For cartridge/ABI work, run the relevant available checks before finishing: + +```bash +python3 tools/prg32_abi_gen.py --check +python3 tools/prg32_game.py summary build/.prg32 +git diff --check +``` + +If `pytest`, ESP-IDF, or the RISC-V toolchain is unavailable, state exactly +which checks could not be run. + ## Metadata Consistency Rules When changing authorship or academic attribution, keep these files in sync: diff --git a/README.md b/README.md index 050053c..9ccf74f 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ cd . $HOME/esp-idf/export.sh python3 tools/prg32_game.py build \ examples/games/asteroids/graphics/game.S \ - --firmware-elf build-qemu/PRG32.elf \ + --portable \ --entry-prefix asteroids_graphics \ --name asteroids \ --out build-qemu/asteroids.prg32 @@ -353,6 +353,34 @@ Flow: .S source -> riscv toolchain -> .prg32 cartridge -> PRG32 runtime -> init/update/draw loop ``` +## Portable Cartridges + +PRG32 cartridges are portable across firmware builds that implement the same +cartridge ABI. Portable cartridges call firmware services through a versioned +ABI table instead of absolute firmware symbol addresses. + +Build a portable cartridge: + +```bash +python3 tools/prg32_game.py build examples/games/pong/ascii/game.S \ + --entry-prefix pong_ascii \ + --portable \ + --out build/pong.prg32 +``` + +Inspect it: + +```bash +python3 tools/prg32_game.py summary build/pong.prg32 +``` + +The summary shows ABI major/minor, ABI hash, import model, and required or +optional feature bits. Legacy absolute-import cartridges are still supported for +old workflows, but they are tied to the firmware image used at build time and +are not guaranteed to run on another firmware. ABI hash mismatches, missing +required features, and incompatible legacy cartridges are rejected by the +runtime with a diagnostic message. + Cartridge metadata and store publishing are documented in [docs/cartridge_metadata.md](docs/cartridge_metadata.md), [docs/colophon_abi.md](docs/colophon_abi.md), diff --git a/components/prg32/CMakeLists.txt b/components/prg32/CMakeLists.txt index 9485f82..a5d2678 100644 --- a/components/prg32/CMakeLists.txt +++ b/components/prg32/CMakeLists.txt @@ -27,6 +27,7 @@ endif() idf_component_register( SRCS "prg32_abi_exports.c" + "prg32_abi_table.c" "prg32_audio.c" "assets/prg32_splash_logo.c" "prg32_bands.c" diff --git a/components/prg32/include/prg32.h b/components/prg32/include/prg32.h index f2c65ea..83d96ac 100644 --- a/components/prg32/include/prg32.h +++ b/components/prg32/include/prg32.h @@ -9,6 +9,7 @@ #include "prg32_audio.h" #include "prg32_metrics.h" #include "prg32_multiplayer.h" +#include "prg32_abi.h" #ifdef __cplusplus extern "C" { @@ -86,6 +87,10 @@ extern "C" { #define PRG32_CART_ABI_MINOR 0 #define PRG32_CART_FLAG_AUDIO_BLOCK (1u << 0) #define PRG32_CART_FLAG_MULTIPLAYER (1u << 1) +#define PRG32_CART_FLAG_ABI_TABLE (1u << 2) +#define PRG32_CART_FLAG_RELOCATABLE (1u << 3) +#define PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE 0u +#define PRG32_IMPORT_MODEL_ABI_TABLE 1u #define PRG32_CART_META_MAGIC "PRG32META" #define PRG32_CART_META_VERSION 1 #define PRG32_CART_META_ABI "prg32-metadata-1.0" @@ -97,6 +102,7 @@ extern "C" { #define PRG32_CART_META_BLOCK_COLOPHON "COLO" #define PRG32_CART_ARCH_ESP32C6 "esp32c6" #define PRG32_CART_ARCH_QEMU "qemu" +#define PRG32_CART_LOAD_ADDR 0x40800000u #define PRG32_CART_MAX_SIZE (32u * 1024u) #ifndef CONFIG_PRG32_CART_RAM_KIB #define CONFIG_PRG32_CART_RAM_KIB 32 @@ -124,6 +130,29 @@ typedef struct __attribute__((packed)) { char name[PRG32_CART_NAME_LEN]; } prg32_cart_header_t; +typedef struct __attribute__((packed)) { + char magic[4]; + uint16_t abi_major; + uint16_t abi_minor; + uint16_t header_size; + uint16_t flags; + uint32_t load_addr; + uint32_t code_size; + uint32_t mem_size; + uint32_t init_offset; + uint32_t update_offset; + uint32_t draw_offset; + uint32_t payload_crc32; + char name[PRG32_CART_NAME_LEN]; + uint32_t abi_hash; + uint32_t required_features; + uint32_t optional_features; + uint32_t isa_flags; + uint32_t relocation_offset; + uint32_t relocation_count; + uint32_t import_model; +} prg32_cart_header_v2_t; + typedef struct { char slot_name[8]; char name[PRG32_CART_NAME_LEN]; diff --git a/components/prg32/include/prg32_abi.h b/components/prg32/include/prg32_abi.h new file mode 100644 index 0000000..3874acc --- /dev/null +++ b/components/prg32/include/prg32_abi.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include "prg32_abi_hash.h" +#include "prg32_abi_index.h" + +#define PRG32_ABI_MAGIC 0x49424150u + +#define PRG32_FEATURE_AUDIO (1u << 0) +#define PRG32_FEATURE_WIFI (1u << 1) +#define PRG32_FEATURE_MULTIPLAYER (1u << 2) +#define PRG32_FEATURE_METRICS (1u << 3) +#define PRG32_FEATURE_AUDIO_PLUS (1u << 4) +#define PRG32_FEATURE_KEYBOARD (1u << 5) +#define PRG32_FEATURE_TILEMAP (1u << 6) +#define PRG32_FEATURE_PLATFORMER (1u << 7) +#define PRG32_FEATURE_SPRITES (1u << 8) + +typedef struct prg32_abi_table { + uint32_t magic; + uint16_t abi_major; + uint16_t abi_minor; + uint16_t struct_size; + uint16_t fn_count; + uint32_t abi_hash; + uint32_t provided_features; + const void *functions[PRG32_ABI_FN_COUNT]; +} prg32_abi_table_t; + +extern const prg32_abi_table_t prg32_abi_table; diff --git a/components/prg32/include/prg32_abi_hash.h b/components/prg32/include/prg32_abi_hash.h new file mode 100644 index 0000000..acd5864 --- /dev/null +++ b/components/prg32/include/prg32_abi_hash.h @@ -0,0 +1,7 @@ +#pragma once + +/* Generated by tools/prg32_abi_gen.py; do not edit manually. */ + +#define PRG32_ABI_MAJOR 1u +#define PRG32_ABI_MINOR 0u +#define PRG32_ABI_HASH 0xb9cadd82u diff --git a/components/prg32/include/prg32_abi_index.h b/components/prg32/include/prg32_abi_index.h new file mode 100644 index 0000000..f278b40 --- /dev/null +++ b/components/prg32/include/prg32_abi_index.h @@ -0,0 +1,120 @@ +#pragma once + +/* Generated by tools/prg32_abi_gen.py; do not edit manually. */ + +enum { + PRG32_ABI_FN_PRG32_TICKS_MS = 0, + PRG32_ABI_FN_PRG32_INPUT_READ = 1, + PRG32_ABI_FN_PRG32_INPUT_READ_PLAYER = 2, + PRG32_ABI_FN_PRG32_INPUT_READ_MENU = 3, + PRG32_ABI_FN_PRG32_CONTROLLER_READ = 4, + PRG32_ABI_FN_PRG32_AUDIO_BEEP = 5, + PRG32_ABI_FN_PRG32_AUDIO_TONE = 6, + PRG32_ABI_FN_PRG32_AUDIO_NOTE = 7, + PRG32_ABI_FN_PRG32_AUDIO_PLAY_NOTES = 8, + PRG32_ABI_FN_PRG32_AUDIO_SAMPLE_U8 = 9, + PRG32_ABI_FN_PRG32_AUDIO_INIT = 10, + PRG32_ABI_FN_PRG32_AUDIO_SHUTDOWN = 11, + PRG32_ABI_FN_PRG32_AUDIO_GET_MODE = 12, + PRG32_ABI_FN_PRG32_AUDIO_PLAY_SAMPLE = 13, + PRG32_ABI_FN_PRG32_AUDIO_PLAY_SAMPLE_PAN = 14, + PRG32_ABI_FN_PRG32_AUDIO_STOP_CHANNEL = 15, + PRG32_ABI_FN_PRG32_AUDIO_STOP_ALL = 16, + PRG32_ABI_FN_PRG32_AUDIO_NOTE_ON = 17, + PRG32_ABI_FN_PRG32_AUDIO_NOTE_ON_PAN = 18, + PRG32_ABI_FN_PRG32_AUDIO_NOTE_OFF = 19, + PRG32_ABI_FN_PRG32_AUDIO_PLAY_TRACK = 20, + PRG32_ABI_FN_PRG32_AUDIO_STOP_TRACK = 21, + PRG32_ABI_FN_PRG32_AUDIO_SET_TEMPO = 22, + PRG32_ABI_FN_PRG32_AUDIO_SET_MASTER_VOLUME = 23, + PRG32_ABI_FN_PRG32_AUDIO_SET_CHANNEL_VOLUME = 24, + PRG32_ABI_FN_PRG32_AUDIO_SET_CHANNEL_PAN = 25, + PRG32_ABI_FN_PRG32_WIFI_START_MODE = 26, + PRG32_ABI_FN_PRG32_WIFI_CURRENT_MODE = 27, + PRG32_ABI_FN_PRG32_WIFI_CURRENT_IP = 28, + PRG32_ABI_FN_PRG32_WIFI_CURRENT_SSID = 29, + PRG32_ABI_FN_PRG32_WIFI_SETUP_REQUESTED = 30, + PRG32_ABI_FN_PRG32_WIFI_SETUP_RUN = 31, + PRG32_ABI_FN_PRG32_MULTIPLAYER_INIT = 32, + PRG32_ABI_FN_PRG32_MULTIPLAYER_AVAILABLE = 33, + PRG32_ABI_FN_PRG32_MULTIPLAYER_JOIN = 34, + PRG32_ABI_FN_PRG32_MULTIPLAYER_LEAVE = 35, + PRG32_ABI_FN_PRG32_MULTIPLAYER_TICK = 36, + PRG32_ABI_FN_PRG32_MULTIPLAYER_SET_LOCAL_STATE = 37, + PRG32_ABI_FN_PRG32_MULTIPLAYER_SET_INPUT = 38, + PRG32_ABI_FN_PRG32_MULTIPLAYER_GET_PEER_COUNT = 39, + PRG32_ABI_FN_PRG32_MULTIPLAYER_GET_PEER = 40, + PRG32_ABI_FN_PRG32_CART_STORED_COUNT = 41, + PRG32_ABI_FN_PRG32_CART_GET_SLOT_INFO = 42, + PRG32_ABI_FN_PRG32_CART_SELECT_SLOT = 43, + PRG32_ABI_FN_PRG32_CART_DEFAULT_SLOT = 44, + PRG32_ABI_FN_PRG32_CART_SET_DEFAULT_SLOT = 45, + PRG32_ABI_FN_PRG32_CART_SELECT_DEFAULT = 46, + PRG32_ABI_FN_PRG32_CONSOLE_CLEAR = 47, + PRG32_ABI_FN_PRG32_CONSOLE_PUTC = 48, + PRG32_ABI_FN_PRG32_CONSOLE_WRITE = 49, + PRG32_ABI_FN_PRG32_CONSOLE_HEX32 = 50, + PRG32_ABI_FN_PRG32_GFX_CLEAR = 51, + PRG32_ABI_FN_PRG32_GFX_PRESENT = 52, + PRG32_ABI_FN_PRG32_GFX_SET_FULLSCREEN = 53, + PRG32_ABI_FN_PRG32_GFX_FULLSCREEN_ENABLED = 54, + PRG32_ABI_FN_PRG32_GFX_PIXEL = 55, + PRG32_ABI_FN_PRG32_GFX_RECT = 56, + PRG32_ABI_FN_PRG32_GFX_TEXT8 = 57, + PRG32_ABI_FN_PRG32_GFX_SNAPSHOT_ROW_RGB565 = 58, + PRG32_ABI_FN_PRG32_BAND_SET_MODE = 59, + PRG32_ABI_FN_PRG32_BAND_MODE = 60, + PRG32_ABI_FN_PRG32_BAND_MODE_NAME = 61, + PRG32_ABI_FN_PRG32_BAND_SET_TEXT = 62, + PRG32_ABI_FN_PRG32_BAND_SET_GAME_INFO = 63, + PRG32_ABI_FN_PRG32_BAND_LOG = 64, + PRG32_ABI_FN_PRG32_BAND_SET_COLORS = 65, + PRG32_ABI_FN_PRG32_BAND_USE_DEFAULT_COLORS = 66, + PRG32_ABI_FN_PRG32_BAND_LOAD_CONFIG = 67, + PRG32_ABI_FN_PRG32_BAND_SAVE_CONFIG = 68, + PRG32_ABI_FN_PRG32_SPLASH_DRAW = 69, + PRG32_ABI_FN_PRG32_SPLASH_SHOW = 70, + PRG32_ABI_FN_PRG32_SPLASH_DRAW_GAME = 71, + PRG32_ABI_FN_PRG32_SPLASH_SHOW_GAME = 72, + PRG32_ABI_FN_PRG32_SPLASH_SHOW_DEFAULT = 73, + PRG32_ABI_FN_PRG32_DEBUG_OVERLAY_DRAW = 74, + PRG32_ABI_FN_PRG32_INPUT_WAIT_RELEASED = 75, + PRG32_ABI_FN_PRG32_KEYBOARD_INIT = 76, + PRG32_ABI_FN_PRG32_KEYBOARD_UPDATE = 77, + PRG32_ABI_FN_PRG32_KEYBOARD_DRAW = 78, + PRG32_ABI_FN_PRG32_TEXT_INPUT = 79, + PRG32_ABI_FN_PRG32_TILE_CLEAR = 80, + PRG32_ABI_FN_PRG32_TILE_DEFINE = 81, + PRG32_ABI_FN_PRG32_TILE_PUT = 82, + PRG32_ABI_FN_PRG32_TILE_PRESENT = 83, + PRG32_ABI_FN_PRG32_PLAYFIELD_CLEAR = 84, + PRG32_ABI_FN_PRG32_PLAYFIELD_PUT = 85, + PRG32_ABI_FN_PRG32_PLAYFIELD_GET = 86, + PRG32_ABI_FN_PRG32_PLAYFIELD_SCROLL = 87, + PRG32_ABI_FN_PRG32_PLAYFIELD_SCROLL_BY = 88, + PRG32_ABI_FN_PRG32_PLAYFIELD_PARALLAX = 89, + PRG32_ABI_FN_PRG32_PLAYFIELD_CAMERA = 90, + PRG32_ABI_FN_PRG32_PLAYFIELD_CAMERA_X = 91, + PRG32_ABI_FN_PRG32_PLAYFIELD_CAMERA_Y = 92, + PRG32_ABI_FN_PRG32_PLAYFIELD_DRAW = 93, + PRG32_ABI_FN_PRG32_PLAYFIELD_DRAW_DUAL = 94, + PRG32_ABI_FN_PRG32_PLAYFIELD_PRESENT = 95, + PRG32_ABI_FN_PRG32_PLATFORM_TILE_FLAGS = 96, + PRG32_ABI_FN_PRG32_PLATFORM_TILE_FLAGS_GET = 97, + PRG32_ABI_FN_PRG32_PLATFORM_TILE_AT = 98, + PRG32_ABI_FN_PRG32_PLATFORM_SOLID_AT = 99, + PRG32_ABI_FN_PRG32_PLATFORM_ACTOR_INIT = 100, + PRG32_ABI_FN_PRG32_PLATFORM_ACTOR_MOVE = 101, + PRG32_ABI_FN_PRG32_PLATFORM_ACTOR_STEP = 102, + PRG32_ABI_FN_PRG32_PLATFORM_CAMERA_FOLLOW = 103, + PRG32_ABI_FN_PRG32_SPRITE_HITBOX = 104, + PRG32_ABI_FN_PRG32_SPRITE_DRAW_8X8 = 105, + PRG32_ABI_FN_PRG32_SPRITE_DRAW_16X16 = 106, + PRG32_ABI_FN_PRG32_SPRITE_ANIM_FRAME = 107, + PRG32_ABI_FN_PRG32_SPRITE_DRAW_FRAME = 108, + PRG32_ABI_FN_PRG32_SPRITE_ANIM_INIT = 109, + PRG32_ABI_FN_PRG32_SPRITE_ANIM_UPDATE = 110, + PRG32_ABI_FN_PRG32_SPRITE_ANIM_DRAW = 111, + PRG32_ABI_FN_PRG32_SCORE_SUBMIT = 112, + PRG32_ABI_FN_COUNT = 113 +}; diff --git a/components/prg32/include/prg32_cart_api.h b/components/prg32/include/prg32_cart_api.h new file mode 100644 index 0000000..a6ca376 --- /dev/null +++ b/components/prg32/include/prg32_cart_api.h @@ -0,0 +1,15 @@ +#pragma once + +#include "prg32_abi.h" + +extern const prg32_abi_table_t *__prg32_abi; + +typedef uint32_t (*prg32_ticks_ms_fn)(void); +static inline uint32_t prg32_ticks_ms(void) { + return ((prg32_ticks_ms_fn)__prg32_abi->functions[PRG32_ABI_FN_PRG32_TICKS_MS])(); +} + +typedef uint32_t (*prg32_input_read_fn)(void); +static inline uint32_t prg32_input_read(void) { + return ((prg32_input_read_fn)__prg32_abi->functions[PRG32_ABI_FN_PRG32_INPUT_READ])(); +} diff --git a/components/prg32/prg32_abi_table.c b/components/prg32/prg32_abi_table.c new file mode 100644 index 0000000..a878371 --- /dev/null +++ b/components/prg32/prg32_abi_table.c @@ -0,0 +1,133 @@ +/* Generated by tools/prg32_abi_gen.py; do not edit manually. */ + +#include "prg32.h" +#include "prg32_abi.h" + +const prg32_abi_table_t prg32_abi_table = { + .magic = PRG32_ABI_MAGIC, + .abi_major = PRG32_ABI_MAJOR, + .abi_minor = PRG32_ABI_MINOR, + .struct_size = sizeof(prg32_abi_table_t), + .fn_count = PRG32_ABI_FN_COUNT, + .abi_hash = PRG32_ABI_HASH, + .provided_features = PRG32_FEATURE_AUDIO | PRG32_FEATURE_WIFI | + PRG32_FEATURE_MULTIPLAYER | PRG32_FEATURE_METRICS | + PRG32_FEATURE_AUDIO_PLUS | PRG32_FEATURE_KEYBOARD | + PRG32_FEATURE_TILEMAP | PRG32_FEATURE_PLATFORMER | + PRG32_FEATURE_SPRITES, + .functions = { + [PRG32_ABI_FN_PRG32_TICKS_MS] = (const void *)prg32_ticks_ms, + [PRG32_ABI_FN_PRG32_INPUT_READ] = (const void *)prg32_input_read, + [PRG32_ABI_FN_PRG32_INPUT_READ_PLAYER] = (const void *)prg32_input_read_player, + [PRG32_ABI_FN_PRG32_INPUT_READ_MENU] = (const void *)prg32_input_read_menu, + [PRG32_ABI_FN_PRG32_CONTROLLER_READ] = (const void *)prg32_controller_read, + [PRG32_ABI_FN_PRG32_AUDIO_BEEP] = (const void *)prg32_audio_beep, + [PRG32_ABI_FN_PRG32_AUDIO_TONE] = (const void *)prg32_audio_tone, + [PRG32_ABI_FN_PRG32_AUDIO_NOTE] = (const void *)prg32_audio_note, + [PRG32_ABI_FN_PRG32_AUDIO_PLAY_NOTES] = (const void *)prg32_audio_play_notes, + [PRG32_ABI_FN_PRG32_AUDIO_SAMPLE_U8] = (const void *)prg32_audio_sample_u8, + [PRG32_ABI_FN_PRG32_AUDIO_INIT] = (const void *)prg32_audio_init, + [PRG32_ABI_FN_PRG32_AUDIO_SHUTDOWN] = (const void *)prg32_audio_shutdown, + [PRG32_ABI_FN_PRG32_AUDIO_GET_MODE] = (const void *)prg32_audio_get_mode, + [PRG32_ABI_FN_PRG32_AUDIO_PLAY_SAMPLE] = (const void *)prg32_audio_play_sample, + [PRG32_ABI_FN_PRG32_AUDIO_PLAY_SAMPLE_PAN] = (const void *)prg32_audio_play_sample_pan, + [PRG32_ABI_FN_PRG32_AUDIO_STOP_CHANNEL] = (const void *)prg32_audio_stop_channel, + [PRG32_ABI_FN_PRG32_AUDIO_STOP_ALL] = (const void *)prg32_audio_stop_all, + [PRG32_ABI_FN_PRG32_AUDIO_NOTE_ON] = (const void *)prg32_audio_note_on, + [PRG32_ABI_FN_PRG32_AUDIO_NOTE_ON_PAN] = (const void *)prg32_audio_note_on_pan, + [PRG32_ABI_FN_PRG32_AUDIO_NOTE_OFF] = (const void *)prg32_audio_note_off, + [PRG32_ABI_FN_PRG32_AUDIO_PLAY_TRACK] = (const void *)prg32_audio_play_track, + [PRG32_ABI_FN_PRG32_AUDIO_STOP_TRACK] = (const void *)prg32_audio_stop_track, + [PRG32_ABI_FN_PRG32_AUDIO_SET_TEMPO] = (const void *)prg32_audio_set_tempo, + [PRG32_ABI_FN_PRG32_AUDIO_SET_MASTER_VOLUME] = (const void *)prg32_audio_set_master_volume, + [PRG32_ABI_FN_PRG32_AUDIO_SET_CHANNEL_VOLUME] = (const void *)prg32_audio_set_channel_volume, + [PRG32_ABI_FN_PRG32_AUDIO_SET_CHANNEL_PAN] = (const void *)prg32_audio_set_channel_pan, + [PRG32_ABI_FN_PRG32_WIFI_START_MODE] = (const void *)prg32_wifi_start_mode, + [PRG32_ABI_FN_PRG32_WIFI_CURRENT_MODE] = (const void *)prg32_wifi_current_mode, + [PRG32_ABI_FN_PRG32_WIFI_CURRENT_IP] = (const void *)prg32_wifi_current_ip, + [PRG32_ABI_FN_PRG32_WIFI_CURRENT_SSID] = (const void *)prg32_wifi_current_ssid, + [PRG32_ABI_FN_PRG32_WIFI_SETUP_REQUESTED] = (const void *)prg32_wifi_setup_requested, + [PRG32_ABI_FN_PRG32_WIFI_SETUP_RUN] = (const void *)prg32_wifi_setup_run, + [PRG32_ABI_FN_PRG32_MULTIPLAYER_INIT] = (const void *)prg32_multiplayer_init, + [PRG32_ABI_FN_PRG32_MULTIPLAYER_AVAILABLE] = (const void *)prg32_multiplayer_available, + [PRG32_ABI_FN_PRG32_MULTIPLAYER_JOIN] = (const void *)prg32_multiplayer_join, + [PRG32_ABI_FN_PRG32_MULTIPLAYER_LEAVE] = (const void *)prg32_multiplayer_leave, + [PRG32_ABI_FN_PRG32_MULTIPLAYER_TICK] = (const void *)prg32_multiplayer_tick, + [PRG32_ABI_FN_PRG32_MULTIPLAYER_SET_LOCAL_STATE] = (const void *)prg32_multiplayer_set_local_state, + [PRG32_ABI_FN_PRG32_MULTIPLAYER_SET_INPUT] = (const void *)prg32_multiplayer_set_input, + [PRG32_ABI_FN_PRG32_MULTIPLAYER_GET_PEER_COUNT] = (const void *)prg32_multiplayer_get_peer_count, + [PRG32_ABI_FN_PRG32_MULTIPLAYER_GET_PEER] = (const void *)prg32_multiplayer_get_peer, + [PRG32_ABI_FN_PRG32_CART_STORED_COUNT] = (const void *)prg32_cart_stored_count, + [PRG32_ABI_FN_PRG32_CART_GET_SLOT_INFO] = (const void *)prg32_cart_get_slot_info, + [PRG32_ABI_FN_PRG32_CART_SELECT_SLOT] = (const void *)prg32_cart_select_slot, + [PRG32_ABI_FN_PRG32_CART_DEFAULT_SLOT] = (const void *)prg32_cart_default_slot, + [PRG32_ABI_FN_PRG32_CART_SET_DEFAULT_SLOT] = (const void *)prg32_cart_set_default_slot, + [PRG32_ABI_FN_PRG32_CART_SELECT_DEFAULT] = (const void *)prg32_cart_select_default, + [PRG32_ABI_FN_PRG32_CONSOLE_CLEAR] = (const void *)prg32_console_clear, + [PRG32_ABI_FN_PRG32_CONSOLE_PUTC] = (const void *)prg32_console_putc, + [PRG32_ABI_FN_PRG32_CONSOLE_WRITE] = (const void *)prg32_console_write, + [PRG32_ABI_FN_PRG32_CONSOLE_HEX32] = (const void *)prg32_console_hex32, + [PRG32_ABI_FN_PRG32_GFX_CLEAR] = (const void *)prg32_gfx_clear, + [PRG32_ABI_FN_PRG32_GFX_PRESENT] = (const void *)prg32_gfx_present, + [PRG32_ABI_FN_PRG32_GFX_SET_FULLSCREEN] = (const void *)prg32_gfx_set_fullscreen, + [PRG32_ABI_FN_PRG32_GFX_FULLSCREEN_ENABLED] = (const void *)prg32_gfx_fullscreen_enabled, + [PRG32_ABI_FN_PRG32_GFX_PIXEL] = (const void *)prg32_gfx_pixel, + [PRG32_ABI_FN_PRG32_GFX_RECT] = (const void *)prg32_gfx_rect, + [PRG32_ABI_FN_PRG32_GFX_TEXT8] = (const void *)prg32_gfx_text8, + [PRG32_ABI_FN_PRG32_GFX_SNAPSHOT_ROW_RGB565] = (const void *)prg32_gfx_snapshot_row_rgb565, + [PRG32_ABI_FN_PRG32_BAND_SET_MODE] = (const void *)prg32_band_set_mode, + [PRG32_ABI_FN_PRG32_BAND_MODE] = (const void *)prg32_band_mode, + [PRG32_ABI_FN_PRG32_BAND_MODE_NAME] = (const void *)prg32_band_mode_name, + [PRG32_ABI_FN_PRG32_BAND_SET_TEXT] = (const void *)prg32_band_set_text, + [PRG32_ABI_FN_PRG32_BAND_SET_GAME_INFO] = (const void *)prg32_band_set_game_info, + [PRG32_ABI_FN_PRG32_BAND_LOG] = (const void *)prg32_band_log, + [PRG32_ABI_FN_PRG32_BAND_SET_COLORS] = (const void *)prg32_band_set_colors, + [PRG32_ABI_FN_PRG32_BAND_USE_DEFAULT_COLORS] = (const void *)prg32_band_use_default_colors, + [PRG32_ABI_FN_PRG32_BAND_LOAD_CONFIG] = (const void *)prg32_band_load_config, + [PRG32_ABI_FN_PRG32_BAND_SAVE_CONFIG] = (const void *)prg32_band_save_config, + [PRG32_ABI_FN_PRG32_SPLASH_DRAW] = (const void *)prg32_splash_draw, + [PRG32_ABI_FN_PRG32_SPLASH_SHOW] = (const void *)prg32_splash_show, + [PRG32_ABI_FN_PRG32_SPLASH_DRAW_GAME] = (const void *)prg32_splash_draw_game, + [PRG32_ABI_FN_PRG32_SPLASH_SHOW_GAME] = (const void *)prg32_splash_show_game, + [PRG32_ABI_FN_PRG32_SPLASH_SHOW_DEFAULT] = (const void *)prg32_splash_show_default, + [PRG32_ABI_FN_PRG32_DEBUG_OVERLAY_DRAW] = (const void *)prg32_debug_overlay_draw, + [PRG32_ABI_FN_PRG32_INPUT_WAIT_RELEASED] = (const void *)prg32_input_wait_released, + [PRG32_ABI_FN_PRG32_KEYBOARD_INIT] = (const void *)prg32_keyboard_init, + [PRG32_ABI_FN_PRG32_KEYBOARD_UPDATE] = (const void *)prg32_keyboard_update, + [PRG32_ABI_FN_PRG32_KEYBOARD_DRAW] = (const void *)prg32_keyboard_draw, + [PRG32_ABI_FN_PRG32_TEXT_INPUT] = (const void *)prg32_text_input, + [PRG32_ABI_FN_PRG32_TILE_CLEAR] = (const void *)prg32_tile_clear, + [PRG32_ABI_FN_PRG32_TILE_DEFINE] = (const void *)prg32_tile_define, + [PRG32_ABI_FN_PRG32_TILE_PUT] = (const void *)prg32_tile_put, + [PRG32_ABI_FN_PRG32_TILE_PRESENT] = (const void *)prg32_tile_present, + [PRG32_ABI_FN_PRG32_PLAYFIELD_CLEAR] = (const void *)prg32_playfield_clear, + [PRG32_ABI_FN_PRG32_PLAYFIELD_PUT] = (const void *)prg32_playfield_put, + [PRG32_ABI_FN_PRG32_PLAYFIELD_GET] = (const void *)prg32_playfield_get, + [PRG32_ABI_FN_PRG32_PLAYFIELD_SCROLL] = (const void *)prg32_playfield_scroll, + [PRG32_ABI_FN_PRG32_PLAYFIELD_SCROLL_BY] = (const void *)prg32_playfield_scroll_by, + [PRG32_ABI_FN_PRG32_PLAYFIELD_PARALLAX] = (const void *)prg32_playfield_parallax, + [PRG32_ABI_FN_PRG32_PLAYFIELD_CAMERA] = (const void *)prg32_playfield_camera, + [PRG32_ABI_FN_PRG32_PLAYFIELD_CAMERA_X] = (const void *)prg32_playfield_camera_x, + [PRG32_ABI_FN_PRG32_PLAYFIELD_CAMERA_Y] = (const void *)prg32_playfield_camera_y, + [PRG32_ABI_FN_PRG32_PLAYFIELD_DRAW] = (const void *)prg32_playfield_draw, + [PRG32_ABI_FN_PRG32_PLAYFIELD_DRAW_DUAL] = (const void *)prg32_playfield_draw_dual, + [PRG32_ABI_FN_PRG32_PLAYFIELD_PRESENT] = (const void *)prg32_playfield_present, + [PRG32_ABI_FN_PRG32_PLATFORM_TILE_FLAGS] = (const void *)prg32_platform_tile_flags, + [PRG32_ABI_FN_PRG32_PLATFORM_TILE_FLAGS_GET] = (const void *)prg32_platform_tile_flags_get, + [PRG32_ABI_FN_PRG32_PLATFORM_TILE_AT] = (const void *)prg32_platform_tile_at, + [PRG32_ABI_FN_PRG32_PLATFORM_SOLID_AT] = (const void *)prg32_platform_solid_at, + [PRG32_ABI_FN_PRG32_PLATFORM_ACTOR_INIT] = (const void *)prg32_platform_actor_init, + [PRG32_ABI_FN_PRG32_PLATFORM_ACTOR_MOVE] = (const void *)prg32_platform_actor_move, + [PRG32_ABI_FN_PRG32_PLATFORM_ACTOR_STEP] = (const void *)prg32_platform_actor_step, + [PRG32_ABI_FN_PRG32_PLATFORM_CAMERA_FOLLOW] = (const void *)prg32_platform_camera_follow, + [PRG32_ABI_FN_PRG32_SPRITE_HITBOX] = (const void *)prg32_sprite_hitbox, + [PRG32_ABI_FN_PRG32_SPRITE_DRAW_8X8] = (const void *)prg32_sprite_draw_8x8, + [PRG32_ABI_FN_PRG32_SPRITE_DRAW_16X16] = (const void *)prg32_sprite_draw_16x16, + [PRG32_ABI_FN_PRG32_SPRITE_ANIM_FRAME] = (const void *)prg32_sprite_anim_frame, + [PRG32_ABI_FN_PRG32_SPRITE_DRAW_FRAME] = (const void *)prg32_sprite_draw_frame, + [PRG32_ABI_FN_PRG32_SPRITE_ANIM_INIT] = (const void *)prg32_sprite_anim_init, + [PRG32_ABI_FN_PRG32_SPRITE_ANIM_UPDATE] = (const void *)prg32_sprite_anim_update, + [PRG32_ABI_FN_PRG32_SPRITE_ANIM_DRAW] = (const void *)prg32_sprite_anim_draw, + [PRG32_ABI_FN_PRG32_SCORE_SUBMIT] = (const void *)prg32_score_submit, + }, +}; diff --git a/components/prg32/prg32_cart.c b/components/prg32/prg32_cart.c index cc1e2b7..cc1d363 100644 --- a/components/prg32/prg32_cart.c +++ b/components/prg32/prg32_cart.c @@ -20,6 +20,7 @@ #define PRG32_CART_META_MAGIC_LEN 9 typedef void (*prg32_cart_entry_t)(void); +typedef void (*prg32_cart_abi_entry_t)(const prg32_abi_table_t *abi); typedef struct __attribute__((packed)) { char magic[PRG32_CART_META_MAGIC_LEN]; @@ -49,6 +50,7 @@ static const uint8_t g_cart_subtypes[PRG32_CART_SLOT_COUNT] = { static const esp_partition_t *g_cart_partitions[PRG32_CART_SLOT_COUNT]; static SemaphoreHandle_t g_cart_lock; static prg32_cart_header_t g_header; +static uint32_t g_import_model; static prg32_cart_entry_t g_init; static prg32_cart_entry_t g_update; static prg32_cart_entry_t g_draw; @@ -266,8 +268,37 @@ static int validate_header(const prg32_cart_header_t *h, set_error("invalid cartridge header size"); return -1; } + uint32_t import_model = PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE; + if (h->header_size >= sizeof(prg32_cart_header_v2_t)) { + const prg32_cart_header_v2_t *v2 = (const prg32_cart_header_v2_t *)h; + import_model = v2->import_model; + if (v2->abi_hash != PRG32_ABI_HASH) { + set_errorf("cartridge ABI hash mismatch expected=0x%08lx got=0x%08lx", + (unsigned long)PRG32_ABI_HASH, + (unsigned long)v2->abi_hash); + return -1; + } + if ((v2->required_features & ~prg32_abi_table.provided_features) != 0u) { + set_errorf("cartridge requires missing feature bits 0x%08lx", + (unsigned long)(v2->required_features & ~prg32_abi_table.provided_features)); + return -1; + } + if (import_model != PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE && + import_model != PRG32_IMPORT_MODEL_ABI_TABLE) { + set_errorf("unsupported cartridge import model %lu", + (unsigned long)import_model); + return -1; + } + if (import_model == PRG32_IMPORT_MODEL_ABI_TABLE && + (h->flags & PRG32_CART_FLAG_ABI_TABLE) == 0u) { + set_error("ABI-table cartridge is missing ABI flag"); + return -1; + } + } if (h->load_addr != (uint32_t)(uintptr_t)prg32_cart_exec) { - set_error("cartridge linked for a different runtime address"); + set_error(import_model == PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE + ? "legacy cartridge uses firmware-specific absolute imports; rebuild it with --portable" + : "cartridge linked for a different runtime address"); return -1; } if (image_size > PRG32_CART_MAX_SIZE) { @@ -349,6 +380,12 @@ static int load_image_locked(const void *image, size_t image_size) { __asm__ volatile("fence.i" ::: "memory"); memcpy(&g_header, h, sizeof(g_header)); + if (h->header_size >= sizeof(prg32_cart_header_v2_t)) { + const prg32_cart_header_v2_t *v2 = (const prg32_cart_header_v2_t *)h; + g_import_model = v2->import_model; + } else { + g_import_model = PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE; + } if (audio_block && audio_size) { if (prg32_audio_load_block(audio_block, audio_size) != 0) { g_loaded = false; @@ -827,7 +864,11 @@ static int call_entry(prg32_cart_entry_t entry) { unlock_cart(); return -1; } - entry(); + if (g_import_model == PRG32_IMPORT_MODEL_ABI_TABLE) { + ((prg32_cart_abi_entry_t)entry)(&prg32_abi_table); + } else { + entry(); + } unlock_cart(); return 0; } diff --git a/docs/abi.md b/docs/abi.md index bb665bb..5ebd24d 100644 --- a/docs/abi.md +++ b/docs/abi.md @@ -1,8 +1,13 @@ # PRG32 ABI -PRG32 cartridge programs call framework functions directly through symbols -exported by the resident firmware. The host cartridge builder resolves those -symbols from `/api/runtime` or from the firmware ELF. +PRG32 portable cartridges call framework functions through a stable, versioned +ABI table supplied by the resident firmware. The table is generated from +`tools/prg32_abi.json`; generated files contain the function indices, ABI hash, +and firmware table population code. + +Legacy cartridges can still use firmware-specific absolute imports resolved +from `/api/runtime` or from a firmware ELF, but that mode is tied to one +firmware image. New cartridges should be built with `--portable`. ## Register Convention @@ -17,14 +22,39 @@ PRG32 follows the standard RISC-V calling convention: | `s0`-`s11` | callee-saved values | Assembly examples save `ra` around C calls and keep stack alignment visible. +For portable cartridges, `a0` contains a pointer to `prg32_abi_table_t` when +the runtime enters `init`, `update`, or `draw`. The cartridge-side stubs emitted +by `tools/prg32_game.py --portable` store that pointer in `__prg32_abi` and keep +the familiar `call prg32_gfx_clear` style available to examples. + +## Stable ABI Table + +The firmware exposes one `prg32_abi_table` with magic `PABI`, ABI major/minor, +the generated ABI hash, feature bits, and an indexed function pointer array. +Cartridges declare the ABI hash and required features in the v2 cartridge +header. + +Compatibility rules: + +- same ABI major and matching hash: accepted +- missing required feature bits: rejected +- newer incompatible major: rejected +- legacy absolute imports: supported only for firmware-specific workflows + +Feature bits currently cover audio, Wi-Fi, multiplayer, metrics, audio-plus, +keyboard, tilemap, platformer, and sprites. ## Cartridge Package ABI -The executable cartridge ABI remains `PRG2` major `1`, minor `0`. Store-ready -cartridges append a backward-compatible `PRG32META` trailer after the legacy -payload. The trailer does not change the imported function ABI, but it gives -host tools and setup-mode clients standard blocks for `META`, `ICON`, `SCRN`, -`SIGN`, and `COLO`. +The executable cartridge ABI remains `PRG2` major `1`, minor `0`. Header v2 +extends the original header via `header_size` with `abi_hash`, +`required_features`, `optional_features`, relocation placeholders, and +`import_model`. `import_model=abi-table` marks a portable cartridge; +`import_model=legacy-absolute` marks the older firmware-specific path. + +Store-ready cartridges append a backward-compatible `PRG32META` trailer after +the payload. The trailer gives host tools and setup-mode clients standard +blocks for `META`, `ICON`, `SCRN`, `SIGN`, and `COLO`. Metadata JSON uses `prg32-metadata-1.0`; colophon JSON uses `prg32-colophon-1.0`. The game colophon is shown after the cartridge is diff --git a/docs/api.md b/docs/api.md index 0a95ad5..d653e53 100644 --- a/docs/api.md +++ b/docs/api.md @@ -688,7 +688,7 @@ Tool example: ```bash python3 tools/prg32_game.py publish \ examples/games/tetris/c/game.c \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix tetris_c \ --name tetris-c \ --id org.uniparthenope.tetris-c \ @@ -864,7 +864,7 @@ field reference. ```bash python3 tools/prg32_game.py build examples/games/pong/c/game.c \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix pong_c \ --out build-esp32c6/pong.prg32 @@ -878,7 +878,7 @@ python3 tools/prg32_game.py upload build-esp32c6/pong.prg32 \ ```bash python3 tools/prg32_game.py publish \ examples/games/tetris/c/game.c \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix tetris_c \ --name tetris-c \ --id org.uniparthenope.tetris-c \ diff --git a/docs/audio.md b/docs/audio.md index 2ba38f2..6d2ee75 100644 --- a/docs/audio.md +++ b/docs/audio.md @@ -244,7 +244,7 @@ Pack assets: ```bash python3 tools/prg32audio_pack.py audio.json --out build/audio.block python3 tools/prg32_game.py build examples/games/asteroids/graphics/game.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix asteroids_graphics \ --audio-block build/audio.block \ --out build-esp32c6/asteroids-audio.prg32 diff --git a/docs/cartridge-format.md b/docs/cartridge-format.md index 4003a4a..7a03965 100644 --- a/docs/cartridge-format.md +++ b/docs/cartridge-format.md @@ -99,7 +99,7 @@ Commands include `NOTE_ON`, `NOTE_OFF`, `SET_VOLUME`, `SET_PAN`, `SET_TEMPO`, python3 tools/wav2prg32sample.py input.wav --rate 22050 --out build/input.raw python3 tools/prg32audio_pack.py audio.json --out build/audio.block python3 tools/prg32_game.py build game.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix mygame \ --audio-block build/audio.block \ --out build-esp32c6/mygame.prg32 diff --git a/docs/cartridge_metadata.md b/docs/cartridge_metadata.md index a2ab137..9105066 100644 --- a/docs/cartridge_metadata.md +++ b/docs/cartridge_metadata.md @@ -121,17 +121,17 @@ compatibility. ## Architecture Variants A `.prg32` file contains one linked executable image. ESP32-C6 hardware and the -QEMU graphics workflow can require different runtime addresses and import -tables, so the Cartridge Store manages them as separate architecture variants of -the same game/version: +QEMU graphics workflow can use different target metadata or assets, so the +Cartridge Store manages them as separate architecture variants of the same +game/version: | Architecture id | Build target | Typical output | | --- | --- | --- | | `esp32c6` | physical ESP32-C6 firmware | `build-esp32c6/game.prg32` | | `qemu` | ESP32-C3 QEMU graphics firmware | `build-qemu/game.prg32` | -Build each variant against the matching firmware ELF, then attach metadata with -the matching `--architecture`. +Build each variant as a portable cartridge, then attach metadata with the +matching `--architecture`. ```bash python3 tools/prg32_game.py attach-metadata \ diff --git a/docs/cartridge_store.md b/docs/cartridge_store.md index 553db0d..4afe35b 100644 --- a/docs/cartridge_store.md +++ b/docs/cartridge_store.md @@ -82,7 +82,7 @@ Build and publish a C cartridge directly: ```bash python3 tools/prg32_game.py publish \ examples/games/tetris/c/game.c \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix tetris_c \ --name tetris-c \ --id org.uniparthenope.tetris-c \ diff --git a/docs/cartridges.md b/docs/cartridges.md index 23a8dcf..391ca23 100644 --- a/docs/cartridges.md +++ b/docs/cartridges.md @@ -81,13 +81,12 @@ Password: prg32game URL: http://192.168.4.1 ``` -Build a cartridge from an assembly or C example. This example uses the firmware -ELF from the local build to obtain the runtime addresses: +Build a cartridge from an assembly or C example. This example uses the portable ABI table, so it does not need a firmware ELF: ```bash python3 tools/prg32_game.py build \ examples/games/asteroids/graphics/game.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix asteroids_graphics \ --name asteroids \ --out build-esp32c6/asteroids.prg32 @@ -120,7 +119,7 @@ can also mark the package header with `PRG32_CART_FLAG_MULTIPLAYER`: ```bash python3 tools/prg32_game.py build \ examples/games/pong/c/game.c \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix pong_c \ --multiplayer \ --name pong-mp \ @@ -136,21 +135,21 @@ On ESP32-C6, multiplayer uses Wi-Fi station mode and the standalone Node.js keeps the same API available with a local offline stub so the cartridge still builds and runs on the desktop path. -## Runtime Query Workflow +## Portable Build Workflow -If the board is already running and the host can reach its HTTP API, the build -tool can query the runtime directly: +Portable cartridges use the generated ABI table contract and do not need a +firmware ELF or runtime HTTP query at build time: ```bash python3 tools/prg32_game.py build \ examples/games/asteroids/graphics/game.S \ - --runtime-url http://192.168.4.1 \ + --portable \ --entry-prefix asteroids_graphics \ --name asteroids \ --out build-esp32c6/asteroids.prg32 ``` -Useful runtime endpoint: +Useful runtime endpoint for diagnostics: ```bash curl http://192.168.4.1/api/runtime @@ -165,12 +164,12 @@ idf.py -B build-qemu -D SDKCONFIG=build-qemu/sdkconfig -D SDKCONFIG_DEFAULTS=sdk idf.py -B build-qemu -D SDKCONFIG=build-qemu/sdkconfig -D SDKCONFIG_DEFAULTS=sdkconfig.defaults.qemu build ``` -Build a cartridge against the QEMU firmware ELF: +Build a portable cartridge for QEMU staging: ```bash python3 tools/prg32_game.py build \ examples/games/asteroids/graphics/game.S \ - --firmware-elf build-qemu/PRG32.elf \ + --portable \ --entry-prefix asteroids_graphics \ --name asteroids \ --out build-qemu/asteroids.prg32 @@ -229,12 +228,12 @@ workflow: ```bash # Physical board variant. python3 tools/prg32_game.py build ... \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --out build-esp32c6/game.prg32 # QEMU variant. python3 tools/prg32_game.py build ... \ - --firmware-elf build-qemu/PRG32.elf \ + --portable \ --out build-qemu/game.prg32 ``` @@ -358,7 +357,7 @@ them as small freestanding C modules: ```bash python3 tools/prg32_game.py build \ examples/games/platformer/c/game.c \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix platformer_c \ --name platformer-c \ --out build-esp32c6/platformer-c.prg32 @@ -391,7 +390,7 @@ Attach it to a cartridge: ```bash python3 tools/prg32_game.py build \ examples/games/asteroids/graphics/game.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix asteroids_graphics \ --audio-block build/audio.block \ --name asteroids-audio \ diff --git a/docs/deployment.md b/docs/deployment.md index b0bcf23..373c1b9 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -61,7 +61,7 @@ Build and upload a game: ```bash python3 tools/prg32_game.py build \ examples/games/asteroids/graphics/game.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix asteroids_graphics \ --name asteroids \ --out build-esp32c6/asteroids.prg32 diff --git a/docs/getting_started_game_development.md b/docs/getting_started_game_development.md index 21bf19b..589e5fa 100644 --- a/docs/getting_started_game_development.md +++ b/docs/getting_started_game_development.md @@ -9,12 +9,11 @@ PRG32 has two related development loops: - resident firmware development, where `idf.py` or PlatformIO builds the PRG32 runtime for the board or QEMU; - cartridge game development, where `tools/prg32_game.py` links a small RISC-V - assembly or C program against a resident firmware ELF and produces a + assembly or C program against the portable PRG32 ABI table and produces a `.prg32` game package. -The resident firmware must be rebuilt when the runtime ABI changes. Cartridges -must then be rebuilt against the matching `PRG32.elf` so function addresses and -the cartridge RAM address stay correct. +The resident firmware and cartridges must agree on the portable ABI major, +hash, and required feature bits. Incompatible cartridges are rejected cleanly. ## 1. Choose The Environment @@ -336,7 +335,7 @@ Every call into PRG32 C helpers saves and restores `ra`, and the stack remains ```bash python3 tools/prg32_game.py build \ work/hello_world/hello_world.S \ - --firmware-elf build-qemu/PRG32.elf \ + --portable \ --entry-prefix hello_world \ --name hello_world \ --out build-qemu/hello_world.prg32 @@ -439,20 +438,19 @@ The board should show the PRG32 splash and then setup if no cartridge is stored. ## 12. Build The Physical ESP32-C6 Cartridge -Build the same source against the physical firmware ELF: +Build the same source as a portable cartridge for the physical board: ```bash python3 tools/prg32_game.py build \ work/hello_world/hello_world.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix hello_world \ --name hello_world \ --out build-esp32c6/hello_world.prg32 ``` -Do not upload the QEMU cartridge to the ESP32-C6 board. QEMU and hardware use -the same package format, but each cartridge must be linked against the matching -resident firmware ELF. +QEMU and hardware use the same package format and portable ABI. Keep separate +outputs when the surrounding metadata or target-specific assets differ. ## 13. Upload The Cartridge To The Physical Board diff --git a/docs/labs/break_fix_assignments.md b/docs/labs/break_fix_assignments.md index 989a2ac..858d5f6 100644 --- a/docs/labs/break_fix_assignments.md +++ b/docs/labs/break_fix_assignments.md @@ -78,5 +78,5 @@ cartridge. Symptoms: `/api/games` rejects the upload with a runtime-address error, or the game does not start. -Fix it: rebuild the cartridge against the current firmware ELF or use -`--runtime-url http://192.168.4.1` so the tool queries the running board. +Fix it: rebuild the cartridge with `--portable` so it uses the generated ABI +table, then inspect the summary for ABI hash and required feature bits. diff --git a/docs/qemu.md b/docs/qemu.md index b320a23..09ee37f 100644 --- a/docs/qemu.md +++ b/docs/qemu.md @@ -143,7 +143,7 @@ On Windows: ```bash python3 tools/prg32_game.py build \ examples/games/asteroids/graphics/game.S \ - --firmware-elf build-qemu/PRG32.elf \ + --portable \ --entry-prefix asteroids_graphics \ --name asteroids \ --out build-qemu/asteroids.prg32 diff --git a/docs/scientific_measurement_tutorial.md b/docs/scientific_measurement_tutorial.md index 5bdd46c..0c419b1 100644 --- a/docs/scientific_measurement_tutorial.md +++ b/docs/scientific_measurement_tutorial.md @@ -146,7 +146,7 @@ Use the same cartridge build command for every run in the series. Example: ```bash python3 tools/prg32_game.py build \ examples/games/pong/graphics/game.S \ - --firmware-elf build-esp32c6-metrics/PRG32.elf \ + --portable \ --entry-prefix pong_graphics \ --name pong \ --out build-esp32c6-metrics/pong.prg32 diff --git a/docs/teaching_with_prg32.md b/docs/teaching_with_prg32.md index 3f49aff..fc50ab9 100644 --- a/docs/teaching_with_prg32.md +++ b/docs/teaching_with_prg32.md @@ -84,7 +84,7 @@ For assembly lessons: ```bash python3 tools/prg32_game.py build \ examples/games/platformer/graphics/game.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix platformer_graphics \ --name platformer-asm \ --out build-esp32c6/platformer-asm.prg32 @@ -95,7 +95,7 @@ For C lessons: ```bash python3 tools/prg32_game.py build \ examples/games/platformer/c/game.c \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix platformer_c \ --name platformer-c \ --out build-esp32c6/platformer-c.prg32 diff --git a/docs/tutorial.md b/docs/tutorial.md index 05c23dc..7607963 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -198,7 +198,7 @@ After the resident firmware has been flashed once, build the game as a cartridge ```bash python3 tools/prg32_game.py build \ examples/games/pong/graphics/game.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix pong_graphics \ --name pong \ --out build-esp32c6/pong.prg32 diff --git a/docs/tutorial_c_game.md b/docs/tutorial_c_game.md index e9725dc..c652073 100644 --- a/docs/tutorial_c_game.md +++ b/docs/tutorial_c_game.md @@ -127,7 +127,7 @@ After the resident firmware is built, package the same C source as a cartridge: ```bash python3 tools/prg32_game.py build \ examples/games/pong/c/game.c \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix pong_c \ --name pong-c \ --out build-esp32c6/pong-c.prg32 diff --git a/examples/features/README.md b/examples/features/README.md index fc3d6da..df688fa 100644 --- a/examples/features/README.md +++ b/examples/features/README.md @@ -111,7 +111,7 @@ prefix: ```bash python3 tools/prg32_game.py build \ examples/features/animated_sprites/demo.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix animated_sprites \ --name animated-sprites \ --out build-esp32c6/animated-sprites.prg32 @@ -122,7 +122,7 @@ For the C version: ```bash python3 tools/prg32_game.py build \ examples/features/animated_sprites/c/demo.c \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix animated_sprites_c \ --name animated-sprites-c \ --out build-esp32c6/animated-sprites-c.prg32 diff --git a/examples/features/splash_screen/README.md b/examples/features/splash_screen/README.md index 177cfb8..d6eb3d6 100644 --- a/examples/features/splash_screen/README.md +++ b/examples/features/splash_screen/README.md @@ -56,7 +56,7 @@ physical-display bands remain outside the game viewport. ```bash python3 tools/prg32_game.py build \ examples/features/splash_screen/demo.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix splash_screen \ --name splash-asm \ --out build-esp32c6/splash-asm.prg32 @@ -67,7 +67,7 @@ For the C version: ```bash python3 tools/prg32_game.py build \ examples/features/splash_screen/c/demo.c \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix splash_screen_c \ --name splash-c \ --out build-esp32c6/splash-c.prg32 diff --git a/examples/games/README.md b/examples/games/README.md index 63212e4..a2a8d98 100644 --- a/examples/games/README.md +++ b/examples/games/README.md @@ -191,7 +191,7 @@ The board starts the `PRG32` Wi-Fi access point by default for cartridge uploads ```bash python3 tools/prg32_game.py build \ examples/games/tetris/ascii/game.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix tetris_ascii \ --name tetris-ascii \ --out build-esp32c6/tetris-ascii.prg32 @@ -202,7 +202,7 @@ python3 tools/prg32_game.py build \ ```bash python3 tools/prg32_game.py build \ examples/games/tetris/graphics/game.S \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix tetris_graphics \ --name tetris-graphics \ --out build-esp32c6/tetris-graphics.prg32 @@ -213,7 +213,7 @@ python3 tools/prg32_game.py build \ ```bash python3 tools/prg32_game.py build \ examples/games/platformer/c/game.c \ - --firmware-elf build-esp32c6/PRG32.elf \ + --portable \ --entry-prefix platformer_c \ --name platformer-c \ --out build-esp32c6/platformer-c.prg32 @@ -259,7 +259,7 @@ Stop QEMU after the first successful launch. This creates ```bash python3 tools/prg32_game.py build \ examples/games/tetris/graphics/game.S \ - --firmware-elf build-qemu/PRG32.elf \ + --portable \ --entry-prefix tetris_graphics \ --name tetris-graphics \ --out build-qemu/tetris-graphics.prg32 diff --git a/tests/test_prg32_abi_gen.py b/tests/test_prg32_abi_gen.py new file mode 100644 index 0000000..56f3f1d --- /dev/null +++ b/tests/test_prg32_abi_gen.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +class AbiGeneratorTests(unittest.TestCase): + def test_generated_files_are_current(self) -> None: + result = subprocess.run( + ["python3", "tools/prg32_abi_gen.py", "--check"], + cwd=ROOT, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_prg32_game.py b/tests/test_prg32_game.py index 3da0382..96946f6 100644 --- a/tests/test_prg32_game.py +++ b/tests/test_prg32_game.py @@ -2,7 +2,9 @@ import argparse import importlib.util +import io from pathlib import Path +import contextlib import tempfile import unittest @@ -94,6 +96,42 @@ def test_detect_entries_accepts_c_prefix(self) -> None: ) +class PortableHeaderTests(unittest.TestCase): + def test_v2_header_records_abi_table_import_model(self) -> None: + header = prg32_game.CART_HEADER_V2.pack( + prg32_game.CART_MAGIC, + prg32_game.CART_ABI_MAJOR, + prg32_game.CART_ABI_MINOR, + prg32_game.CART_HEADER_V2.size, + prg32_game.PRG32_CART_FLAG_ABI_TABLE, + prg32_game.FALLBACK_CART_LOAD_ADDR, + 4, + 4, + 0, + 0, + 0, + 0, + b"test" + b"\0" * 28, + prg32_game.ABI_HASH, + prg32_game.FEATURE_BITS["audio"], + prg32_game.FEATURE_BITS["sprites"], + 0, + 0, + 0, + prg32_game.PRG32_IMPORT_MODEL_ABI_TABLE, + ) + with tempfile.TemporaryDirectory() as tmp: + cart = Path(tmp) / "portable.prg32" + cart.write_bytes(header + b"\0\0\0\0") + buf = io.StringIO() + args = argparse.Namespace(cartridge=str(cart)) + with contextlib.redirect_stdout(buf): + prg32_game.inspect_metadata(args) + text = buf.getvalue() + self.assertIn('"import_model": "abi-table"', text) + self.assertIn(f'"abi_hash": "0x{prg32_game.ABI_HASH:08x}"', text) + + class QemuUploadTests(unittest.TestCase): def test_upload_qemu_stages_cartridge_at_partition_offset(self) -> None: with tempfile.TemporaryDirectory() as tmp: diff --git a/tools/prg32_abi.json b/tools/prg32_abi.json new file mode 100644 index 0000000..c7a4a3e --- /dev/null +++ b/tools/prg32_abi.json @@ -0,0 +1,798 @@ +{ + "name": "prg32-cart-abi", + "major": 1, + "minor": 0, + "functions": [ + { + "name": "prg32_ticks_ms", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_input_read", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_input_read_player", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_input_read_menu", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_controller_read", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_beep", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_tone", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_note", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_play_notes", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_sample_u8", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_init", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_shutdown", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_get_mode", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_play_sample", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_play_sample_pan", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_stop_channel", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_stop_all", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_note_on", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_note_on_pan", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_note_off", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_play_track", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_stop_track", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_set_tempo", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_set_master_volume", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_set_channel_volume", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_audio_set_channel_pan", + "return": "void", + "args": [], + "feature": "audio", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_wifi_start_mode", + "return": "void", + "args": [], + "feature": "wifi", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_wifi_current_mode", + "return": "void", + "args": [], + "feature": "wifi", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_wifi_current_ip", + "return": "void", + "args": [], + "feature": "wifi", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_wifi_current_ssid", + "return": "void", + "args": [], + "feature": "wifi", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_wifi_setup_requested", + "return": "void", + "args": [], + "feature": "wifi", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_wifi_setup_run", + "return": "void", + "args": [], + "feature": "wifi", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_multiplayer_init", + "return": "void", + "args": [], + "feature": "multiplayer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_multiplayer_available", + "return": "void", + "args": [], + "feature": "multiplayer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_multiplayer_join", + "return": "void", + "args": [], + "feature": "multiplayer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_multiplayer_leave", + "return": "void", + "args": [], + "feature": "multiplayer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_multiplayer_tick", + "return": "void", + "args": [], + "feature": "multiplayer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_multiplayer_set_local_state", + "return": "void", + "args": [], + "feature": "multiplayer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_multiplayer_set_input", + "return": "void", + "args": [], + "feature": "multiplayer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_multiplayer_get_peer_count", + "return": "void", + "args": [], + "feature": "multiplayer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_multiplayer_get_peer", + "return": "void", + "args": [], + "feature": "multiplayer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_cart_stored_count", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_cart_get_slot_info", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_cart_select_slot", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_cart_default_slot", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_cart_set_default_slot", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_cart_select_default", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_console_clear", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_console_putc", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_console_write", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_console_hex32", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_gfx_clear", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_gfx_present", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_gfx_set_fullscreen", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_gfx_fullscreen_enabled", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_gfx_pixel", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_gfx_rect", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_gfx_text8", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_gfx_snapshot_row_rgb565", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_band_set_mode", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_band_mode", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_band_mode_name", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_band_set_text", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_band_set_game_info", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_band_log", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_band_set_colors", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_band_use_default_colors", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_band_load_config", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_band_save_config", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_splash_draw", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_splash_show", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_splash_draw_game", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_splash_show_game", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_splash_show_default", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_debug_overlay_draw", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_input_wait_released", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_keyboard_init", + "return": "void", + "args": [], + "feature": "keyboard", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_keyboard_update", + "return": "void", + "args": [], + "feature": "keyboard", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_keyboard_draw", + "return": "void", + "args": [], + "feature": "keyboard", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_text_input", + "return": "void", + "args": [], + "feature": "keyboard", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_tile_clear", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_tile_define", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_tile_put", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_tile_present", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_clear", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_put", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_get", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_scroll", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_scroll_by", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_parallax", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_camera", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_camera_x", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_camera_y", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_draw", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_draw_dual", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_playfield_present", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_platform_tile_flags", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_platform_tile_flags_get", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_platform_tile_at", + "return": "void", + "args": [], + "feature": "tilemap", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_platform_solid_at", + "return": "void", + "args": [], + "feature": "platformer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_platform_actor_init", + "return": "void", + "args": [], + "feature": "platformer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_platform_actor_move", + "return": "void", + "args": [], + "feature": "platformer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_platform_actor_step", + "return": "void", + "args": [], + "feature": "platformer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_platform_camera_follow", + "return": "void", + "args": [], + "feature": "platformer", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_sprite_hitbox", + "return": "void", + "args": [], + "feature": "sprites", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_sprite_draw_8x8", + "return": "void", + "args": [], + "feature": "sprites", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_sprite_draw_16x16", + "return": "void", + "args": [], + "feature": "sprites", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_sprite_anim_frame", + "return": "void", + "args": [], + "feature": "sprites", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_sprite_draw_frame", + "return": "void", + "args": [], + "feature": "sprites", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_sprite_anim_init", + "return": "void", + "args": [], + "feature": "sprites", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_sprite_anim_update", + "return": "void", + "args": [], + "feature": "sprites", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_sprite_anim_draw", + "return": "void", + "args": [], + "feature": "sprites", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_score_submit", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + } + ] +} diff --git a/tools/prg32_abi_gen.py b/tools/prg32_abi_gen.py new file mode 100644 index 0000000..dfdf12f --- /dev/null +++ b/tools/prg32_abi_gen.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Generate PRG32 portable cartridge ABI files.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +ABI_JSON = ROOT / "tools" / "prg32_abi.json" +WARNING = "Generated by tools/prg32_abi_gen.py; do not edit manually." + +FEATURE_BITS = { + "audio": "PRG32_FEATURE_AUDIO", + "wifi": "PRG32_FEATURE_WIFI", + "multiplayer": "PRG32_FEATURE_MULTIPLAYER", + "metrics": "PRG32_FEATURE_METRICS", + "audio_plus": "PRG32_FEATURE_AUDIO_PLUS", + "keyboard": "PRG32_FEATURE_KEYBOARD", + "tilemap": "PRG32_FEATURE_TILEMAP", + "platformer": "PRG32_FEATURE_PLATFORMER", + "sprites": "PRG32_FEATURE_SPRITES", +} + + +def load_abi(path: Path) -> dict: + abi = json.loads(path.read_text(encoding="utf-8")) + names = [fn["name"] for fn in abi["functions"]] + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise SystemExit("duplicate ABI function names: " + ", ".join(duplicates)) + return abi + + +def abi_hash(abi: dict) -> int: + canonical = { + "major": abi["major"], + "minor": abi["minor"], + "functions": [ + { + "name": fn["name"], + "return": fn.get("return", "void"), + "args": fn.get("args", []), + "feature": fn.get("feature", "core"), + } + for fn in abi["functions"] + ], + } + blob = json.dumps(canonical, sort_keys=True, separators=(",", ":")).encode() + return int.from_bytes(hashlib.sha256(blob).digest()[:4], "little") + + +def enum_name(name: str) -> str: + return "PRG32_ABI_FN_" + name.upper() + + +def generated_files(abi: dict) -> dict[Path, str]: + major = int(abi["major"]) + minor = int(abi["minor"]) + hash_value = abi_hash(abi) + functions = abi["functions"] + + enum_lines = [ + "#pragma once", + "", + f"/* {WARNING} */", + "", + "enum {", + ] + for index, fn in enumerate(functions): + enum_lines.append(f" {enum_name(fn['name'])} = {index},") + enum_lines.append(f" PRG32_ABI_FN_COUNT = {len(functions)}") + enum_lines.append("};") + enum_lines.append("") + + hash_h = "\n".join([ + "#pragma once", + "", + f"/* {WARNING} */", + "", + f"#define PRG32_ABI_MAJOR {major}u", + f"#define PRG32_ABI_MINOR {minor}u", + f"#define PRG32_ABI_HASH 0x{hash_value:08x}u", + "", + ]) + + table = [ + f"/* {WARNING} */", + "", + '#include "prg32.h"', + '#include "prg32_abi.h"', + "", + "const prg32_abi_table_t prg32_abi_table = {", + " .magic = PRG32_ABI_MAGIC,", + " .abi_major = PRG32_ABI_MAJOR,", + " .abi_minor = PRG32_ABI_MINOR,", + " .struct_size = sizeof(prg32_abi_table_t),", + " .fn_count = PRG32_ABI_FN_COUNT,", + " .abi_hash = PRG32_ABI_HASH,", + " .provided_features = PRG32_FEATURE_AUDIO | PRG32_FEATURE_WIFI |", + " PRG32_FEATURE_MULTIPLAYER | PRG32_FEATURE_METRICS |", + " PRG32_FEATURE_AUDIO_PLUS | PRG32_FEATURE_KEYBOARD |", + " PRG32_FEATURE_TILEMAP | PRG32_FEATURE_PLATFORMER |", + " PRG32_FEATURE_SPRITES,", + " .functions = {", + ] + for fn in functions: + table.append(f" [{enum_name(fn['name'])}] = (const void *){fn['name']},") + table.extend([" },", "};", ""]) + + py = [ + f"# {WARNING}", + "", + f"ABI_MAJOR = {major}", + f"ABI_MINOR = {minor}", + f"ABI_HASH = 0x{hash_value:08x}", + "IMPORT_NAMES = [", + ] + for fn in functions: + py.append(f" {fn['name']!r},") + py.extend(["]", ""]) + py.append("FEATURE_BITS = {") + for key, macro in FEATURE_BITS.items(): + py.append(f" {key!r}: {macro.replace('PRG32_FEATURE_', '1 << ') if False else feature_value(key)},") + py.extend(["}", ""]) + + return { + ROOT / "components/prg32/include/prg32_abi_index.h": "\n".join(enum_lines), + ROOT / "components/prg32/include/prg32_abi_hash.h": hash_h, + ROOT / "components/prg32/prg32_abi_table.c": "\n".join(table), + ROOT / "tools/prg32_abi_generated.py": "\n".join(py), + } + + +def feature_value(name: str) -> int: + order = [ + "audio", + "wifi", + "multiplayer", + "metrics", + "audio_plus", + "keyboard", + "tilemap", + "platformer", + "sprites", + ] + return 1 << order.index(name) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + args = parser.parse_args(argv) + + abi = load_abi(ABI_JSON) + files = generated_files(abi) + mismatches = [] + for path, content in files.items(): + content = content.rstrip() + "\n" + if args.check: + if not path.exists() or path.read_text(encoding="utf-8") != content: + mismatches.append(path) + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + if mismatches: + for path in mismatches: + print(f"out of date: {path.relative_to(ROOT)}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/prg32_abi_generated.py b/tools/prg32_abi_generated.py new file mode 100644 index 0000000..f0a3f18 --- /dev/null +++ b/tools/prg32_abi_generated.py @@ -0,0 +1,132 @@ +# Generated by tools/prg32_abi_gen.py; do not edit manually. + +ABI_MAJOR = 1 +ABI_MINOR = 0 +ABI_HASH = 0xb9cadd82 +IMPORT_NAMES = [ + 'prg32_ticks_ms', + 'prg32_input_read', + 'prg32_input_read_player', + 'prg32_input_read_menu', + 'prg32_controller_read', + 'prg32_audio_beep', + 'prg32_audio_tone', + 'prg32_audio_note', + 'prg32_audio_play_notes', + 'prg32_audio_sample_u8', + 'prg32_audio_init', + 'prg32_audio_shutdown', + 'prg32_audio_get_mode', + 'prg32_audio_play_sample', + 'prg32_audio_play_sample_pan', + 'prg32_audio_stop_channel', + 'prg32_audio_stop_all', + 'prg32_audio_note_on', + 'prg32_audio_note_on_pan', + 'prg32_audio_note_off', + 'prg32_audio_play_track', + 'prg32_audio_stop_track', + 'prg32_audio_set_tempo', + 'prg32_audio_set_master_volume', + 'prg32_audio_set_channel_volume', + 'prg32_audio_set_channel_pan', + 'prg32_wifi_start_mode', + 'prg32_wifi_current_mode', + 'prg32_wifi_current_ip', + 'prg32_wifi_current_ssid', + 'prg32_wifi_setup_requested', + 'prg32_wifi_setup_run', + 'prg32_multiplayer_init', + 'prg32_multiplayer_available', + 'prg32_multiplayer_join', + 'prg32_multiplayer_leave', + 'prg32_multiplayer_tick', + 'prg32_multiplayer_set_local_state', + 'prg32_multiplayer_set_input', + 'prg32_multiplayer_get_peer_count', + 'prg32_multiplayer_get_peer', + 'prg32_cart_stored_count', + 'prg32_cart_get_slot_info', + 'prg32_cart_select_slot', + 'prg32_cart_default_slot', + 'prg32_cart_set_default_slot', + 'prg32_cart_select_default', + 'prg32_console_clear', + 'prg32_console_putc', + 'prg32_console_write', + 'prg32_console_hex32', + 'prg32_gfx_clear', + 'prg32_gfx_present', + 'prg32_gfx_set_fullscreen', + 'prg32_gfx_fullscreen_enabled', + 'prg32_gfx_pixel', + 'prg32_gfx_rect', + 'prg32_gfx_text8', + 'prg32_gfx_snapshot_row_rgb565', + 'prg32_band_set_mode', + 'prg32_band_mode', + 'prg32_band_mode_name', + 'prg32_band_set_text', + 'prg32_band_set_game_info', + 'prg32_band_log', + 'prg32_band_set_colors', + 'prg32_band_use_default_colors', + 'prg32_band_load_config', + 'prg32_band_save_config', + 'prg32_splash_draw', + 'prg32_splash_show', + 'prg32_splash_draw_game', + 'prg32_splash_show_game', + 'prg32_splash_show_default', + 'prg32_debug_overlay_draw', + 'prg32_input_wait_released', + 'prg32_keyboard_init', + 'prg32_keyboard_update', + 'prg32_keyboard_draw', + 'prg32_text_input', + 'prg32_tile_clear', + 'prg32_tile_define', + 'prg32_tile_put', + 'prg32_tile_present', + 'prg32_playfield_clear', + 'prg32_playfield_put', + 'prg32_playfield_get', + 'prg32_playfield_scroll', + 'prg32_playfield_scroll_by', + 'prg32_playfield_parallax', + 'prg32_playfield_camera', + 'prg32_playfield_camera_x', + 'prg32_playfield_camera_y', + 'prg32_playfield_draw', + 'prg32_playfield_draw_dual', + 'prg32_playfield_present', + 'prg32_platform_tile_flags', + 'prg32_platform_tile_flags_get', + 'prg32_platform_tile_at', + 'prg32_platform_solid_at', + 'prg32_platform_actor_init', + 'prg32_platform_actor_move', + 'prg32_platform_actor_step', + 'prg32_platform_camera_follow', + 'prg32_sprite_hitbox', + 'prg32_sprite_draw_8x8', + 'prg32_sprite_draw_16x16', + 'prg32_sprite_anim_frame', + 'prg32_sprite_draw_frame', + 'prg32_sprite_anim_init', + 'prg32_sprite_anim_update', + 'prg32_sprite_anim_draw', + 'prg32_score_submit', +] + +FEATURE_BITS = { + 'audio': 1, + 'wifi': 2, + 'multiplayer': 4, + 'metrics': 8, + 'audio_plus': 16, + 'keyboard': 32, + 'tilemap': 64, + 'platformer': 128, + 'sprites': 256, +} diff --git a/tools/prg32_game.py b/tools/prg32_game.py index efe9edb..160b8be 100755 --- a/tools/prg32_game.py +++ b/tools/prg32_game.py @@ -35,138 +35,34 @@ parse_file, summary_dict, ) +from prg32_abi_generated import ( # noqa: E402 + ABI_HASH, + ABI_MAJOR, + ABI_MINOR, + FEATURE_BITS, + IMPORT_NAMES, +) CART_HEADER = struct.Struct("<4sHHHHIIIIIII32s") +CART_HEADER_V2 = struct.Struct("<4sHHHHIIIIIII32sIIIIIII") CART_MAGIC = b"PRG2" -CART_ABI_MAJOR = 1 -CART_ABI_MINOR = 0 +CART_ABI_MAJOR = ABI_MAJOR +CART_ABI_MINOR = ABI_MINOR PRG32_CART_FLAG_AUDIO_BLOCK = 1 << 0 PRG32_CART_FLAG_MULTIPLAYER = 1 << 1 +PRG32_CART_FLAG_ABI_TABLE = 1 << 2 +PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE = 0 +PRG32_IMPORT_MODEL_ABI_TABLE = 1 AUDIO_BLOCK_MAGIC = b"AUD0" DEFAULT_PARTITION_TABLE = ROOT / "partitions_prg32.csv" DEFAULT_CART_SLOT = "cart0" FALLBACK_CART_RAM_SIZE = 64 * 1024 FALLBACK_CART_MAX_SIZE = 32 * 1024 +FALLBACK_CART_LOAD_ADDR = 0x40800000 STORE_DISCOVERY_ABI = "prg32-store-discovery-1.0" STORE_METADATA_ABI = "prg32-metadata-1.0" STORE_CONFIG = Path.home() / ".prg32" / "config.json" -IMPORT_NAMES = [ - "prg32_ticks_ms", - "prg32_input_read", - "prg32_input_read_player", - "prg32_input_read_menu", - "prg32_controller_read", - "prg32_audio_beep", - "prg32_audio_tone", - "prg32_audio_note", - "prg32_audio_play_notes", - "prg32_audio_sample_u8", - "prg32_audio_init", - "prg32_audio_shutdown", - "prg32_audio_get_mode", - "prg32_audio_play_sample", - "prg32_audio_play_sample_pan", - "prg32_audio_stop_channel", - "prg32_audio_stop_all", - "prg32_audio_note_on", - "prg32_audio_note_on_pan", - "prg32_audio_note_off", - "prg32_audio_play_track", - "prg32_audio_stop_track", - "prg32_audio_set_tempo", - "prg32_audio_set_master_volume", - "prg32_audio_set_channel_volume", - "prg32_audio_set_channel_pan", - "prg32_wifi_start_mode", - "prg32_wifi_current_mode", - "prg32_wifi_current_ip", - "prg32_wifi_current_ssid", - "prg32_wifi_setup_requested", - "prg32_wifi_setup_run", - "prg32_multiplayer_init", - "prg32_multiplayer_available", - "prg32_multiplayer_join", - "prg32_multiplayer_leave", - "prg32_multiplayer_tick", - "prg32_multiplayer_set_local_state", - "prg32_multiplayer_set_input", - "prg32_multiplayer_get_peer_count", - "prg32_multiplayer_get_peer", - "prg32_cart_stored_count", - "prg32_cart_get_slot_info", - "prg32_cart_select_slot", - "prg32_cart_default_slot", - "prg32_cart_set_default_slot", - "prg32_cart_select_default", - "prg32_console_clear", - "prg32_console_putc", - "prg32_console_write", - "prg32_console_hex32", - "prg32_gfx_clear", - "prg32_gfx_present", - "prg32_gfx_set_fullscreen", - "prg32_gfx_fullscreen_enabled", - "prg32_gfx_pixel", - "prg32_gfx_rect", - "prg32_gfx_text8", - "prg32_gfx_snapshot_row_rgb565", - "prg32_band_set_mode", - "prg32_band_mode", - "prg32_band_mode_name", - "prg32_band_set_text", - "prg32_band_set_game_info", - "prg32_band_log", - "prg32_band_set_colors", - "prg32_band_use_default_colors", - "prg32_band_load_config", - "prg32_band_save_config", - "prg32_splash_draw", - "prg32_splash_show", - "prg32_splash_draw_game", - "prg32_splash_show_game", - "prg32_splash_show_default", - "prg32_debug_overlay_draw", - "prg32_input_wait_released", - "prg32_keyboard_init", - "prg32_keyboard_update", - "prg32_keyboard_draw", - "prg32_text_input", - "prg32_tile_clear", - "prg32_tile_define", - "prg32_tile_put", - "prg32_tile_present", - "prg32_playfield_clear", - "prg32_playfield_put", - "prg32_playfield_get", - "prg32_playfield_scroll", - "prg32_playfield_scroll_by", - "prg32_playfield_parallax", - "prg32_playfield_camera", - "prg32_playfield_camera_x", - "prg32_playfield_camera_y", - "prg32_playfield_draw", - "prg32_playfield_draw_dual", - "prg32_playfield_present", - "prg32_platform_tile_flags", - "prg32_platform_tile_flags_get", - "prg32_platform_tile_at", - "prg32_platform_solid_at", - "prg32_platform_actor_init", - "prg32_platform_actor_move", - "prg32_platform_actor_step", - "prg32_platform_camera_follow", - "prg32_sprite_hitbox", - "prg32_sprite_draw_8x8", - "prg32_sprite_draw_16x16", - "prg32_sprite_anim_frame", - "prg32_sprite_draw_frame", - "prg32_sprite_anim_init", - "prg32_sprite_anim_update", - "prg32_sprite_anim_draw", - "prg32_score_submit", -] - def run(cmd: list[str], cwd: Path | None = None) -> str: try: @@ -445,6 +341,64 @@ def write_linker(path: Path, load_addr: int, init_symbol: str) -> None: ) +def write_portable_stubs(path: Path, + init_sym: str, + update_sym: str, + draw_sym: str) -> None: + lines = [ + "/* Generated by tools/prg32_game.py for one portable cartridge build. */", + ".section .data.__prg32_abi,\"aw\",@progbits", + ".globl __prg32_abi", + ".align 2", + "__prg32_abi:", + " .word 0", + ".section .text.prg32_abi_stubs,\"ax\",@progbits", + ".option push", + ".option norelax", + ] + for index, name in enumerate(IMPORT_NAMES): + offset = 20 + index * 4 + lines.extend([ + f".globl {name}", + f"{name}:", + " la t0, __prg32_abi", + " lw t0, 0(t0)", + f" lw t1, {offset}(t0)", + " jr t1", + ]) + for entry_name, target in ( + ("prg32_entry_init", init_sym), + ("prg32_entry_update", update_sym), + ("prg32_entry_draw", draw_sym), + ): + lines.extend([ + f".globl {entry_name}", + f"{entry_name}:", + " addi sp, sp, -16", + " sw ra, 12(sp)", + " la t0, __prg32_abi", + " sw a0, 0(t0)", + f" call {target}", + " lw ra, 12(sp)", + " addi sp, sp, 16", + " ret", + ]) + lines.extend([".option pop", ""]) + path.write_text("\n".join(lines), encoding="utf-8") + + +def feature_mask(names: list[str]) -> int: + mask = 0 + for name in names: + key = name.lower().replace("-", "_") + if key not in FEATURE_BITS: + raise SystemExit( + f"unknown feature {name!r}; choices: {', '.join(sorted(FEATURE_BITS))}" + ) + mask |= int(FEATURE_BITS[key]) + return mask + + def detect_entries(symbols: dict[str, int], prefix: str | None) -> tuple[str, str, str]: if prefix: entries = (f"{prefix}_init", f"{prefix}_update", f"{prefix}_draw") @@ -468,12 +422,21 @@ def one_suffix(suffix: str) -> str: def build(args: argparse.Namespace) -> None: - if args.runtime_url: + if args.portable and args.legacy_absolute_imports: + raise SystemExit("--portable and --legacy-absolute-imports are mutually exclusive") + if args.portable: + runtime = { + "cart_load_addr": FALLBACK_CART_LOAD_ADDR, + "cart_ram_size": FALLBACK_CART_RAM_SIZE, + "cart_max_size": FALLBACK_CART_MAX_SIZE, + "imports": {}, + } + elif args.runtime_url: runtime = fetch_runtime(args.runtime_url) elif args.firmware_elf: runtime = runtime_from_elf(Path(args.firmware_elf), args.tool_prefix) else: - raise SystemExit("build requires --runtime-url or --firmware-elf") + raise SystemExit("build requires --portable, --runtime-url, or --firmware-elf") load_addr = int(runtime["cart_load_addr"]) if "cart_ram_size" not in runtime: @@ -484,7 +447,9 @@ def build(args: argparse.Namespace) -> None: ) ram_size = int(runtime.get("cart_ram_size", FALLBACK_CART_RAM_SIZE)) max_size = int(runtime.get("cart_max_size", FALLBACK_CART_MAX_SIZE)) - imports = {k: int(v) for k, v in runtime["imports"].items()} + imports = {k: int(v) for k, v in runtime.get("imports", {}).items()} + required_features = feature_mask(args.required_feature) + optional_features = feature_mask(args.optional_feature) source = Path(args.source) out = Path(args.out) @@ -498,6 +463,8 @@ def build(args: argparse.Namespace) -> None: raw = tmp / "game.bin" imports_ld = tmp / "imports.ld" linker_ld = tmp / "cart.ld" + stubs_s = tmp / "portable_stubs.S" + stubs_o = tmp / "portable_stubs.o" compile_cmd = [ args.tool_prefix + "gcc", @@ -524,18 +491,38 @@ def build(args: argparse.Namespace) -> None: obj_symbols = parse_nm(run([args.tool_prefix + "nm", "--defined-only", str(obj)])) init_sym, update_sym, draw_sym = detect_entries(obj_symbols, args.entry_prefix) - write_imports(imports_ld, imports) - write_linker(linker_ld, load_addr, init_sym) + link_objects = [str(obj)] + linker_scripts = ["-Wl,-T," + str(linker_ld)] + entry_init_sym = init_sym + entry_update_sym = update_sym + entry_draw_sym = draw_sym + if args.portable: + write_portable_stubs(stubs_s, init_sym, update_sym, draw_sym) + run([ + args.tool_prefix + "gcc", + "-march=" + args.march, + "-mabi=" + args.mabi, + "-x", "assembler-with-cpp", + "-c", str(stubs_s), + "-o", str(stubs_o), + ]) + link_objects.append(str(stubs_o)) + entry_init_sym = "prg32_entry_init" + entry_update_sym = "prg32_entry_update" + entry_draw_sym = "prg32_entry_draw" + else: + write_imports(imports_ld, imports) + linker_scripts.append("-Wl,-T," + str(imports_ld)) + write_linker(linker_ld, load_addr, entry_init_sym) run([ args.tool_prefix + "gcc", "-nostdlib", "-march=" + args.march, "-mabi=" + args.mabi, "-Wl,--no-relax", - "-Wl,-T," + str(linker_ld), - "-Wl,-T," + str(imports_ld), + *linker_scripts, "-Wl,-Map," + str(tmp / "game.map"), - str(obj), + *link_objects, "-o", str(elf), ]) linked_symbols = parse_nm(run([args.tool_prefix + "nm", "--defined-only", str(elf)])) @@ -559,6 +546,9 @@ def build(args: argparse.Namespace) -> None: flags |= PRG32_CART_FLAG_AUDIO_BLOCK if args.multiplayer: flags |= PRG32_CART_FLAG_MULTIPLAYER + required_features |= FEATURE_BITS["multiplayer"] + if args.portable: + flags |= PRG32_CART_FLAG_ABI_TABLE start = linked_symbols["__cart_start"] end = linked_symbols["__cart_end"] mem_size = end - start @@ -568,16 +558,17 @@ def build(args: argparse.Namespace) -> None: raise SystemExit("internal error: binary is larger than cartridge memory") entries = { - "init": linked_symbols[init_sym] - load_addr, - "update": linked_symbols[update_sym] - load_addr, - "draw": linked_symbols[draw_sym] - load_addr, + "init": linked_symbols[entry_init_sym] - load_addr, + "update": linked_symbols[entry_update_sym] - load_addr, + "draw": linked_symbols[entry_draw_sym] - load_addr, } crc = binascii.crc32(code) & 0xffffffff - header = CART_HEADER.pack( + header_struct = CART_HEADER_V2 if args.portable else CART_HEADER + header_values = [ CART_MAGIC, CART_ABI_MAJOR, CART_ABI_MINOR, - CART_HEADER.size, + header_struct.size, flags, load_addr, len(code), @@ -587,7 +578,18 @@ def build(args: argparse.Namespace) -> None: entries["draw"], crc, name_bytes + b"\0" * (32 - len(name_bytes)), - ) + ] + if args.portable: + header_values.extend([ + ABI_HASH, + required_features, + optional_features, + 0, + 0, + 0, + PRG32_IMPORT_MODEL_ABI_TABLE, + ]) + header = header_struct.pack(*header_values) image = header + code + audio_block if len(image) > max_size: raise SystemExit(f"cartridge image is {len(image)} bytes, max is {max_size}") @@ -596,7 +598,8 @@ def build(args: argparse.Namespace) -> None: print( f"built {out} name={name} load=0x{load_addr:08x} " - f"code={len(code)} mem={mem_size} audio={len(audio_block)}" + f"code={len(code)} mem={mem_size} audio={len(audio_block)} " + f"import_model={'abi-table' if args.portable else 'legacy-absolute'}" ) @@ -945,7 +948,40 @@ def attach_metadata(args: argparse.Namespace) -> None: def inspect_metadata(args: argparse.Namespace) -> None: parsed = parse_file(args.cartridge) - print(json.dumps(summary_dict(parsed), indent=2, sort_keys=True)) + summary = summary_dict(parsed) + data = Path(args.cartridge).read_bytes() + if len(data) >= CART_HEADER.size: + fields = CART_HEADER.unpack_from(data, 0) + header_size = fields[3] + flags = fields[4] + header = { + "abi_major": fields[1], + "abi_minor": fields[2], + "header_size": header_size, + "flags": flags, + "load_addr": fields[5], + "code_size": fields[6], + "mem_size": fields[7], + "import_model": "legacy-absolute", + } + if header_size >= CART_HEADER_V2.size and len(data) >= CART_HEADER_V2.size: + v2 = CART_HEADER_V2.unpack_from(data, 0) + import_model = v2[19] + header.update({ + "abi_hash": f"0x{v2[13]:08x}", + "required_features": f"0x{v2[14]:08x}", + "optional_features": f"0x{v2[15]:08x}", + "isa_flags": f"0x{v2[16]:08x}", + "relocation_offset": v2[17], + "relocation_count": v2[18], + "import_model": ( + "abi-table" + if import_model == PRG32_IMPORT_MODEL_ABI_TABLE + else "legacy-absolute" + ), + }) + summary["header"] = header + print(json.dumps(summary, indent=2, sort_keys=True)) def main(argv: list[str]) -> int: @@ -965,6 +1001,28 @@ def main(argv: list[str]) -> int: p.add_argument("--entry-prefix") p.add_argument("--runtime-url") p.add_argument("--firmware-elf") + p.add_argument( + "--portable", + action="store_true", + help="build a firmware-portable ABI-table cartridge", + ) + p.add_argument( + "--legacy-absolute-imports", + action="store_true", + help="build the legacy firmware-specific absolute-import cartridge", + ) + p.add_argument( + "--required-feature", + action="append", + default=[], + help="portable ABI feature required by this cartridge", + ) + p.add_argument( + "--optional-feature", + action="append", + default=[], + help="portable ABI feature this cartridge can use when available", + ) p.add_argument( "--audio-block", help="optional PRG32 AUDIO block produced by tools/prg32audio_pack.py", From 9932f745326f31379c43ccf64860405efb48875d Mon Sep 17 00:00:00 2001 From: raffmont Date: Thu, 11 Jun 2026 08:12:31 +0200 Subject: [PATCH 22/24] Update portable cartridge tooling --- components/prg32/prg32_cart.c | 7 +++---- dependencies.lock | 4 ++-- tests/test_prg32_cart_loader.py | 25 +++++++++++++++++++++++++ tests/test_prg32_game.py | 6 ++++++ tools/prg32_game.py | 8 ++++++++ 5 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 tests/test_prg32_cart_loader.py diff --git a/components/prg32/prg32_cart.c b/components/prg32/prg32_cart.c index cc1d363..f8a05a7 100644 --- a/components/prg32/prg32_cart.c +++ b/components/prg32/prg32_cart.c @@ -295,10 +295,9 @@ static int validate_header(const prg32_cart_header_t *h, return -1; } } - if (h->load_addr != (uint32_t)(uintptr_t)prg32_cart_exec) { - set_error(import_model == PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE - ? "legacy cartridge uses firmware-specific absolute imports; rebuild it with --portable" - : "cartridge linked for a different runtime address"); + if (import_model == PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE && + h->load_addr != (uint32_t)(uintptr_t)prg32_cart_exec) { + set_error("legacy cartridge uses firmware-specific absolute imports; rebuild it with --portable"); return -1; } if (image_size > PRG32_CART_MAX_SIZE) { diff --git a/dependencies.lock b/dependencies.lock index 56063d2..941c023 100644 --- a/dependencies.lock +++ b/dependencies.lock @@ -22,10 +22,10 @@ dependencies: idf: source: type: idf - version: 5.4.1 + version: 5.4.0 direct_dependencies: - espressif/esp_websocket_client - espressif/mdns -manifest_hash: e776f823eddf9d7253cdc2e18b5ac78521cdfe0b4c1938ee91bbc14fb0c180db +manifest_hash: 3077b00f38d784d3f7751d22524ee50a8c5481b70adc876cf6869ba0f8653801 target: esp32c6 version: 2.0.0 diff --git a/tests/test_prg32_cart_loader.py b/tests/test_prg32_cart_loader.py new file mode 100644 index 0000000..1fe884f --- /dev/null +++ b/tests/test_prg32_cart_loader.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CART_LOADER = ROOT / "components" / "prg32" / "prg32_cart.c" + + +def test_abi_table_cartridges_do_not_require_runtime_load_addr_match() -> None: + text = CART_LOADER.read_text(encoding="utf-8") + legacy_check = ( + "if (import_model == PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE &&\n" + " h->load_addr != (uint32_t)(uintptr_t)prg32_cart_exec)" + ) + + assert legacy_check in text + assert '"cartridge linked for a different runtime address"' not in text + + +def test_abi_table_entries_receive_runtime_abi_table_pointer() -> None: + text = CART_LOADER.read_text(encoding="utf-8") + + assert "typedef void (*prg32_cart_abi_entry_t)(const prg32_abi_table_t *abi);" in text + assert "((prg32_cart_abi_entry_t)entry)(&prg32_abi_table);" in text diff --git a/tests/test_prg32_game.py b/tests/test_prg32_game.py index 96946f6..b3bdc0d 100644 --- a/tests/test_prg32_game.py +++ b/tests/test_prg32_game.py @@ -97,6 +97,12 @@ def test_detect_entries_accepts_c_prefix(self) -> None: class PortableHeaderTests(unittest.TestCase): + def test_portable_build_uses_position_tolerant_riscv_flags(self) -> None: + text = TOOL_PATH.read_text(encoding="utf-8") + + self.assertIn('"-mcmodel=medany"', text) + self.assertIn('"-msmall-data-limit=0"', text) + def test_v2_header_records_abi_table_import_model(self) -> None: header = prg32_game.CART_HEADER_V2.pack( prg32_game.CART_MAGIC, diff --git a/tools/prg32_game.py b/tools/prg32_game.py index 160b8be..376ad1c 100755 --- a/tools/prg32_game.py +++ b/tools/prg32_game.py @@ -483,6 +483,11 @@ def build(args: argparse.Namespace) -> None: "-fno-builtin", "-Os", ] + if args.portable: + compile_cmd[1:1] = [ + "-mcmodel=medany", + "-msmall-data-limit=0", + ] else: compile_cmd[1:1] = ["-x", "assembler-with-cpp"] @@ -500,6 +505,8 @@ def build(args: argparse.Namespace) -> None: write_portable_stubs(stubs_s, init_sym, update_sym, draw_sym) run([ args.tool_prefix + "gcc", + "-mcmodel=medany", + "-msmall-data-limit=0", "-march=" + args.march, "-mabi=" + args.mabi, "-x", "assembler-with-cpp", @@ -519,6 +526,7 @@ def build(args: argparse.Namespace) -> None: "-nostdlib", "-march=" + args.march, "-mabi=" + args.mabi, + *(["-mcmodel=medany", "-msmall-data-limit=0"] if args.portable else []), "-Wl,--no-relax", *linker_scripts, "-Wl,-Map," + str(tmp / "game.map"), From 9fcfdd711971e3680a1a27806e4457ee22f49072 Mon Sep 17 00:00:00 2001 From: raffmont Date: Thu, 11 Jun 2026 09:01:48 +0200 Subject: [PATCH 23/24] Add cartridge ABI publishing checks --- AGENTS.md | 8 ++ README.md | 26 +++- components/prg32/prg32_http_games.c | 6 +- components/prg32/prg32_setup_store.c | 91 +++++++++++- docs/api.md | 4 + docs/cartridge_store.md | 15 ++ docs/cartridges.md | 20 +++ ...publishing_and_flashing_legacy_firmware.md | 57 ++++++++ tests/test_prg32_game.py | 32 ++++- tools/prg32_build_portable_examples.py | 135 ++++++++++++++++++ tools/prg32_flash_legacy_firmware.py | 52 +++++++ tools/prg32_game.py | 90 +++++++++++- tools/prg32_prepare_legacy_firmware.py | 93 ++++++++++++ 13 files changed, 618 insertions(+), 11 deletions(-) create mode 100644 docs/publishing_and_flashing_legacy_firmware.md create mode 100755 tools/prg32_build_portable_examples.py create mode 100755 tools/prg32_flash_legacy_firmware.py create mode 100755 tools/prg32_prepare_legacy_firmware.py diff --git a/AGENTS.md b/AGENTS.md index e9c1482..80cb85b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,11 @@ Named contributor metadata used across project docs: `riscv-prg32/MetricsServer`. - In-tree setup performance report tooling: `tools/prg32_metrics_paper.py`. - Uploadable game cartridge tool: `tools/prg32_game.py`. +- Bulk portable example publishing helper: + `tools/prg32_build_portable_examples.py`. +- Legacy resident firmware publishing/flashing helpers: + `tools/prg32_prepare_legacy_firmware.py` and + `tools/prg32_flash_legacy_firmware.py`. - Media conversion tools: `tools/prg32_image_convert.py`, `tools/prg32_image_prepare.py`, and `tools/prg32_audio_convert.py`. - Student VS Code setup: `.vscode` and `PRG32.code-workspace`. @@ -441,6 +446,9 @@ Companion repository: https://github.com/riscv-prg32/CartridgeStore - Portable cartridges must not link against firmware-specific symbol addresses. - Legacy absolute-import cartridges may remain supported, but new examples and documentation should prefer portable ABI-table cartridges. +- Upload, QEMU staging, CartridgeStore downloads, and firmware setup downloads + should reject incompatible cartridges before deployment whenever the ABI + contract can be checked. For cartridge/ABI work, run the relevant available checks before finishing: diff --git a/README.md b/README.md index 9ccf74f..271d2be 100644 --- a/README.md +++ b/README.md @@ -379,7 +379,25 @@ optional feature bits. Legacy absolute-import cartridges are still supported for old workflows, but they are tied to the firmware image used at build time and are not guaranteed to run on another firmware. ABI hash mismatches, missing required features, and incompatible legacy cartridges are rejected by the -runtime with a diagnostic message. +runtime, store download path, QEMU staging path, and HTTP upload tool with a +diagnostic message. + +Build all checked-in examples as portable cartridges and CartridgeStore bundles: + +```bash +python3 tools/prg32_build_portable_examples.py --clean +``` + +Prepare or flash a published single-file legacy firmware image: + +```bash +python3 tools/prg32_prepare_legacy_firmware.py +python3 tools/prg32_flash_legacy_firmware.py \ + publish/legacy-firmware/PRG32-legacy-esp32c6.json \ + --port /dev/cu.usbmodem5ABA0099241 +``` + +See [docs/publishing_and_flashing_legacy_firmware.md](docs/publishing_and_flashing_legacy_firmware.md). Cartridge metadata and store publishing are documented in [docs/cartridge_metadata.md](docs/cartridge_metadata.md), @@ -575,6 +593,12 @@ for a step-by-step scientific-paper measurement workflow with screenshots. - `tools/prg32_game.py publish`: build a cartridge and submit a store bundle. - `tools/prg32_game.py pack-bundle`: create a flat multi-architecture zip. - `tools/prg32_game.py publish-bundle`: submit a prepared bundle. +- `tools/prg32_build_portable_examples.py`: build every checked-in example as + portable `.prg32` cartridges and CartridgeStore bundles. +- `tools/prg32_prepare_legacy_firmware.py`: merge a physical firmware build into + one publishable binary. +- `tools/prg32_flash_legacy_firmware.py`: flash a published single-file legacy + firmware image. See [docs/assets.md](docs/assets.md). diff --git a/components/prg32/prg32_http_games.c b/components/prg32/prg32_http_games.c index 75566db..62cd900 100644 --- a/components/prg32/prg32_http_games.c +++ b/components/prg32/prg32_http_games.c @@ -72,7 +72,7 @@ static esp_err_t send_api_index(httpd_req_t *req) { } static esp_err_t send_runtime(httpd_req_t *req) { - char json[768]; + char json[896]; prg32_cart_info_t info; bool have_cart = prg32_cart_get_info(&info) == 0; bool qemu = @@ -90,6 +90,8 @@ static esp_err_t send_runtime(httpd_req_t *req) { "\"cart_magic\":\"%s\"," "\"cart_abi_major\":%u," "\"cart_abi_minor\":%u," + "\"cart_abi_hash\":%lu," + "\"cart_abi_features\":%lu," "\"cart_load_addr\":%lu," "\"cart_max_size\":%lu," "\"cart_ram_size\":%lu," @@ -114,6 +116,8 @@ static esp_err_t send_runtime(httpd_req_t *req) { PRG32_CART_MAGIC, (unsigned)PRG32_CART_ABI_MAJOR, (unsigned)PRG32_CART_ABI_MINOR, + (unsigned long)PRG32_ABI_HASH, + (unsigned long)prg32_abi_table.provided_features, (unsigned long)(uint32_t)prg32_cart_load_addr(), (unsigned long)(uint32_t)PRG32_CART_MAX_SIZE, (unsigned long)(uint32_t)prg32_cart_ram_size(), diff --git a/components/prg32/prg32_setup_store.c b/components/prg32/prg32_setup_store.c index 2517dab..c1eedb2 100644 --- a/components/prg32/prg32_setup_store.c +++ b/components/prg32/prg32_setup_store.c @@ -351,6 +351,60 @@ static int game_is_compatible(const store_game_t *game) { return game && strstr(game->arch, current_arch()) != NULL; } +static int validate_download_header(const uint8_t *data, size_t len, char *status, + size_t status_len) { + if (!data || len < sizeof(prg32_cart_header_t)) { + snprintf(status, status_len, "SHORT HEADER"); + return -1; + } + const prg32_cart_header_t *h = (const prg32_cart_header_t *)data; + if (memcmp(h->magic, PRG32_CART_MAGIC, sizeof(h->magic)) != 0) { + snprintf(status, status_len, "BAD MAGIC"); + return -1; + } + if (h->abi_major != PRG32_CART_ABI_MAJOR) { + snprintf(status, status_len, "ABI MAJOR %u!=%u", (unsigned)h->abi_major, + (unsigned)PRG32_CART_ABI_MAJOR); + return -1; + } + uint32_t import_model = PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE; + if (h->header_size >= sizeof(prg32_cart_header_v2_t)) { + if (len < sizeof(prg32_cart_header_v2_t)) { + snprintf(status, status_len, "SHORT ABI"); + return -1; + } + const prg32_cart_header_v2_t *v2 = (const prg32_cart_header_v2_t *)data; + import_model = v2->import_model; + if (import_model == PRG32_IMPORT_MODEL_ABI_TABLE) { + if (v2->abi_hash != PRG32_ABI_HASH) { + snprintf(status, status_len, "ABI HASH"); + return -1; + } + uint32_t missing = v2->required_features & ~prg32_abi_table.provided_features; + if (missing != 0u) { + snprintf(status, status_len, "ABI FEAT 0x%lx", + (unsigned long)missing); + return -1; + } + if ((h->flags & PRG32_CART_FLAG_ABI_TABLE) == 0u) { + snprintf(status, status_len, "ABI FLAG"); + return -1; + } + return 0; + } + } + if (import_model == PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE && + h->load_addr != PRG32_CART_LOAD_ADDR) { + snprintf(status, status_len, "LEGACY REBUILD"); + return -1; + } + if (import_model != PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE) { + snprintf(status, status_len, "IMPORT MODEL"); + return -1; + } + return 0; +} + static int stream_download(const char *base_url, const store_game_t *game, uint8_t slot, char *status, size_t status_len) { char url[256]; @@ -389,20 +443,49 @@ static int stream_download(const char *base_url, const store_game_t *game, esp_http_client_cleanup(client); return -1; } + uint8_t *chunk = heap_caps_malloc(PRG32_STORE_CHUNK_BYTES, MALLOC_CAP_8BIT); + if (!chunk) { + snprintf(status, status_len, "NO MEM"); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + size_t offset = 0; + size_t header_len = 0; + while (header_len < sizeof(prg32_cart_header_v2_t) && + header_len < (size_t)content_len) { + int read = esp_http_client_read(client, (char *)chunk + header_len, + PRG32_STORE_CHUNK_BYTES - header_len); + if (read <= 0) { + snprintf(status, status_len, "TIMEOUT"); + heap_caps_free(chunk); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } + header_len += (size_t)read; + } + if (validate_download_header(chunk, header_len, status, status_len) != 0) { + heap_caps_free(chunk); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return -1; + } if (prg32_cart_stream_begin(slot, (size_t)content_len) != 0) { snprintf(status, status_len, "%s", prg32_cart_last_error()); + heap_caps_free(chunk); esp_http_client_close(client); esp_http_client_cleanup(client); return -1; } - uint8_t *chunk = heap_caps_malloc(PRG32_STORE_CHUNK_BYTES, MALLOC_CAP_8BIT); - if (!chunk) { - snprintf(status, status_len, "NO MEM"); + if (prg32_cart_stream_write(slot, 0, chunk, header_len) != 0) { + snprintf(status, status_len, "%s", prg32_cart_last_error()); + heap_caps_free(chunk); esp_http_client_close(client); esp_http_client_cleanup(client); return -1; } - size_t offset = 0; + offset = header_len; while (offset < (size_t)content_len) { int read = esp_http_client_read(client, (char *)chunk, PRG32_STORE_CHUNK_BYTES); if (read <= 0) { diff --git a/docs/api.md b/docs/api.md index d653e53..257d963 100644 --- a/docs/api.md +++ b/docs/api.md @@ -119,6 +119,8 @@ Typical response fields: "cart_magic": "PRG32CART", "cart_abi_major": 1, "cart_abi_minor": 0, + "cart_abi_hash": 3117075842, + "cart_abi_features": 511, "cart_load_addr": 1107296256, "cart_max_size": 32768, "cart_ram_size": 65536, @@ -147,6 +149,8 @@ Expected behavior: - runtime returns a compact single `application/json` response with firmware, cartridge, display-backend, and diagnostic status; +- `cart_abi_hash` and `cart_abi_features` let host tools reject incompatible + portable cartridges before upload; - runtime does not include the full cartridge import-address table, because that table is too large for a reliable board-local status response while Wi-Fi and display services are active; diff --git a/docs/cartridge_store.md b/docs/cartridge_store.md index 4afe35b..6bd2f98 100644 --- a/docs/cartridge_store.md +++ b/docs/cartridge_store.md @@ -67,6 +67,21 @@ QEMU and physical firmware use different architecture strings. Publish the matching `qemu` or `esp32c6` variant before expecting it to appear as compatible. +The host tools and firmware validate the cartridge ABI before deployment. Store +downloads are rejected when the cartridge ABI major, ABI hash, required feature +bits, import model, or legacy load address are not compatible with the current +runtime. Rebuild incompatible cartridges with `--portable` from the matching +PRG32 checkout. + +To prepare all checked-in examples for store publishing: + +```bash +python3 tools/prg32_build_portable_examples.py --clean +``` + +The generated zip files under `build-portable-examples` are ready for +`tools/prg32_game.py publish-bundle` or manual CartridgeStore upload. + ## 4. Publishing from the developer machine ```bash diff --git a/docs/cartridges.md b/docs/cartridges.md index 391ca23..0d50a9d 100644 --- a/docs/cartridges.md +++ b/docs/cartridges.md @@ -98,6 +98,11 @@ Upload it to the board: python3 tools/prg32_game.py upload build-esp32c6/asteroids.prg32 --url http://192.168.4.1 ``` +The upload tool reads `/api/runtime` before deployment and rejects incompatible +cartridges before sending them. The error message names the incompatible ABI +major, ABI hash, missing feature bits, or legacy load address. The firmware +performs the same ABI contract check when it receives or loads a cartridge. + The firmware stores the cartridge in `cart0` by default and starts running it from the main loop. Upload to `cart1` with: @@ -242,6 +247,16 @@ for the emulator build. The Cartridge Store groups those artifacts by metadata `id` and `version`, then offers the correct architecture to firmware or QEMU clients. +Build every checked-in game and feature example as a portable cartridge and +prepare flat CartridgeStore bundles: + +```bash +python3 tools/prg32_build_portable_examples.py --clean +``` + +The output directory defaults to `build-portable-examples`. For each example the +script writes a `.prg32` file plus `esp32c6` and `qemu` publishing bundles. + See [cartridge_metadata.md](cartridge_metadata.md) for the binary trailer and metadata ABI, [colophon_abi.md](colophon_abi.md) for the colophon ABI, and [setup_mode_cartridge_store.md](setup_mode_cartridge_store.md) for the @@ -260,6 +275,11 @@ Two installation paths are available: - Host tool: run `python3 tools/prg32_game.py store-download ...` and then upload the downloaded `.prg32` with `python3 tools/prg32_game.py upload ...`. +Both paths validate the ABI contract. A cartridge with the wrong ABI major, +wrong ABI hash, unsupported required feature bits, unsupported import model, or +wrong legacy load address is rejected with a clear diagnostic instead of being +deployed silently. + ## HTTP API ### Runtime Information diff --git a/docs/publishing_and_flashing_legacy_firmware.md b/docs/publishing_and_flashing_legacy_firmware.md new file mode 100644 index 0000000..4542c99 --- /dev/null +++ b/docs/publishing_and_flashing_legacy_firmware.md @@ -0,0 +1,57 @@ +# Publishing And Flashing Legacy Firmware + +This workflow publishes the resident ESP32-C6 firmware as one merged binary. +It is useful for a classroom or release archive where students should flash one +file instead of tracking the bootloader, partition table, and app offsets. + +## Prepare The Single File + +Build and merge the physical firmware: + +```bash +python3 tools/prg32_prepare_legacy_firmware.py +``` + +The script runs the ESP-IDF build for `build-esp32c6`, reads +`build-esp32c6/flasher_args.json`, and writes: + +```text +publish/legacy-firmware/ +|-- PRG32-legacy-esp32c6.bin +|-- PRG32-legacy-esp32c6.json +`-- flasher_args.json +``` + +Use `--skip-build` only when `build-esp32c6/flasher_args.json` already belongs +to the exact firmware you want to publish: + +```bash +python3 tools/prg32_prepare_legacy_firmware.py --skip-build +``` + +Checkpoint: keep the `.bin` and `.json` together. The JSON records the target, +flash settings, source files, and the `0x0` write offset used by the flasher. + +## Flash The Published File + +Connect the ESP32-C6 board and flash the published image: + +```bash +python3 tools/prg32_flash_legacy_firmware.py \ + publish/legacy-firmware/PRG32-legacy-esp32c6.json \ + --port /dev/cu.usbmodem5ABA0099241 +``` + +On Linux the port is usually similar to `/dev/ttyACM0`. On Windows it is usually +similar to `COM5`. + +After flashing, reset the board and hold A+B during boot to enter setup mode. + +## Notes + +- This is a resident firmware workflow, not a cartridge workflow. +- Portable `.prg32` cartridges remain the preferred game distribution format. +- Legacy absolute-import cartridges must be rebuilt for the exact resident + firmware image. When possible, build cartridges with `--portable`. +- If `python3 -m esptool` is missing, source ESP-IDF first or install the + ESP-IDF Python environment used for the build. diff --git a/tests/test_prg32_game.py b/tests/test_prg32_game.py index b3bdc0d..9654073 100644 --- a/tests/test_prg32_game.py +++ b/tests/test_prg32_game.py @@ -146,10 +146,33 @@ def test_upload_qemu_stages_cartridge_at_partition_offset(self) -> None: cart = tmp_path / "game.prg32" partitions = tmp_path / "partitions.csv" - flash.write_bytes(b"\x00" * 64) - cart.write_bytes(b"PRG2") + payload = b"\0\0\0\0" + header = prg32_game.CART_HEADER_V2.pack( + prg32_game.CART_MAGIC, + prg32_game.CART_ABI_MAJOR, + prg32_game.CART_ABI_MINOR, + prg32_game.CART_HEADER_V2.size, + prg32_game.PRG32_CART_FLAG_ABI_TABLE, + prg32_game.FALLBACK_CART_LOAD_ADDR, + len(payload), + len(payload), + 0, + 0, + 0, + 0, + b"test" + b"\0" * 28, + prg32_game.ABI_HASH, + 0, + 0, + 0, + 0, + 0, + prg32_game.PRG32_IMPORT_MODEL_ABI_TABLE, + ) + flash.write_bytes(b"\x00" * 256) + cart.write_bytes(header + payload) partitions.write_text( - "cart0, data, 0x40, 0x10, 8,\n", + "cart0, data, 0x40, 0x10, 128,\n", encoding="utf-8", ) @@ -163,7 +186,8 @@ def test_upload_qemu_stages_cartridge_at_partition_offset(self) -> None: data = flash.read_bytes() self.assertEqual(data[0x10:0x14], b"PRG2") - self.assertEqual(data[0x14:0x18], b"\xff" * 4) + erased = data[0x10 + len(header) + len(payload):0x10 + 128] + self.assertEqual(erased, b"\xff" * len(erased)) class DoctorTests(unittest.TestCase): diff --git a/tools/prg32_build_portable_examples.py b/tools/prg32_build_portable_examples.py new file mode 100755 index 0000000..4f473fd --- /dev/null +++ b/tools/prg32_build_portable_examples.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Build all checked-in examples as portable CartridgeStore bundles.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import shutil +import subprocess +import sys +import zipfile + + +ROOT = Path(__file__).resolve().parents[1] +GAME_PREFIX_OVERRIDES = { + "space_invaders": "invaders", + "wing_commander": "wing_commander", +} + + +def run(cmd: list[str]) -> None: + print("+ " + " ".join(cmd)) + subprocess.run(cmd, cwd=ROOT, check=True) + + +def entry_prefix(source: Path) -> str: + name = source.parents[1].name if "games" in source.parts else source.parent.name + if "games" in source.parts: + base = GAME_PREFIX_OVERRIDES.get(name, name) + variant = source.parent.name + if variant == "c": + return f"{base}_c" + return f"{base}_{variant}" + base = source.parent.name + if source.parent.name == "c": + base = source.parents[1].name + return f"{base}_c" + return base + + +def title_for(source: Path) -> str: + if "games" in source.parts: + return f"{source.parents[1].name}-{source.parent.name}" + if source.parent.name == "c": + return f"{source.parents[1].name}-c" + return source.parent.name + + +def discover_examples() -> list[Path]: + patterns = [ + "examples/games/*/ascii/game.S", + "examples/games/*/graphics/game.S", + "examples/games/*/c/game.c", + "examples/features/*/demo.S", + "examples/features/*/c/demo.c", + ] + examples: list[Path] = [] + for pattern in patterns: + examples.extend(sorted(ROOT.glob(pattern))) + return examples + + +def bundle_for(cartridge: Path, out_dir: Path, architecture: str, version: str) -> Path: + stem = cartridge.stem + manifest = { + "abi": "prg32-metadata-1.0", + "id": f"org.prg32.examples.{stem}", + "title": stem, + "version": version, + "summary": "PRG32 portable teaching example.", + "tags": ["example", "portable", architecture], + "assets": {}, + "architectures": [{"id": architecture, "file": cartridge.name}], + } + manifest_path = out_dir / f"{stem}-{architecture}.manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + bundle = out_dir / f"{stem}-{architecture}.zip" + with zipfile.ZipFile(bundle, "w", compression=zipfile.ZIP_DEFLATED) as zf: + zf.write(manifest_path, "manifest.json") + zf.write(cartridge, cartridge.name) + return bundle + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out-dir", default="build-portable-examples") + parser.add_argument("--version", default="1.0.0") + parser.add_argument( + "--architecture", + action="append", + choices=["esp32c6", "qemu"], + help="architecture bundle to generate; repeatable, default is both", + ) + parser.add_argument("--clean", action="store_true") + args = parser.parse_args(argv) + + out_dir = ROOT / args.out_dir + if args.clean and out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + architectures = args.architecture or ["esp32c6", "qemu"] + examples = discover_examples() + if not examples: + raise SystemExit("no examples found") + + rows = [] + for source in examples: + name = title_for(source) + cart = out_dir / f"{name}.prg32" + run([ + sys.executable, + "tools/prg32_game.py", + "build", + str(source.relative_to(ROOT)), + "--portable", + "--entry-prefix", + entry_prefix(source), + "--name", + name, + "--out", + str(cart.relative_to(ROOT)), + ]) + for architecture in architectures: + bundle = bundle_for(cart, out_dir, architecture, args.version) + rows.append((name, architecture, cart, bundle)) + + print("\nBuilt portable examples:") + for name, architecture, cart, bundle in rows: + print(f" {name:32} {architecture:7} {cart} {bundle}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/prg32_flash_legacy_firmware.py b/tools/prg32_flash_legacy_firmware.py new file mode 100755 index 0000000..ed83412 --- /dev/null +++ b/tools/prg32_flash_legacy_firmware.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Flash a published single-file legacy PRG32 firmware image.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import subprocess +import sys + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("manifest", help="JSON produced by prg32_prepare_legacy_firmware.py") + parser.add_argument("--port", required=True) + parser.add_argument("--baud", default="460800") + args = parser.parse_args(argv) + + manifest_path = Path(args.manifest) + data = json.loads(manifest_path.read_text(encoding="utf-8")) + image = manifest_path.with_name(data["single_file"]) + if not image.exists(): + raise SystemExit(f"missing firmware image: {image}") + settings = data.get("flash_settings", {}) + cmd = [ + sys.executable, + "-m", + "esptool", + "--chip", + data.get("target", "esp32c6"), + "--port", + args.port, + "--baud", + args.baud, + "write_flash", + "--flash_mode", + settings.get("flash_mode", "dio"), + "--flash_freq", + settings.get("flash_freq", "80m"), + "--flash_size", + settings.get("flash_size", "4MB"), + data.get("write_flash_offset", "0x0"), + str(image), + ] + print("+ " + " ".join(cmd)) + subprocess.run(cmd, check=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/prg32_game.py b/tools/prg32_game.py index 376ad1c..b4b0e85 100755 --- a/tools/prg32_game.py +++ b/tools/prg32_game.py @@ -134,6 +134,81 @@ def catalog_items(body) -> list[dict]: return [] +def cartridge_contract(data: bytes) -> dict: + if len(data) < CART_HEADER.size: + raise SystemExit("cartridge is too small to contain a PRG32 header") + fields = CART_HEADER.unpack_from(data, 0) + if fields[0] != CART_MAGIC: + raise SystemExit("cartridge is not a PRG32 .prg32 image") + header = { + "abi_major": fields[1], + "abi_minor": fields[2], + "header_size": fields[3], + "flags": fields[4], + "load_addr": fields[5], + "code_size": fields[6], + "mem_size": fields[7], + "import_model": PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE, + "abi_hash": None, + "required_features": 0, + } + if header["header_size"] >= CART_HEADER_V2.size: + if len(data) < CART_HEADER_V2.size: + raise SystemExit("cartridge v2 header is truncated") + v2 = CART_HEADER_V2.unpack_from(data, 0) + header.update({ + "abi_hash": v2[13], + "required_features": v2[14], + "import_model": v2[19], + }) + return header + + +def validate_cartridge_contract( + data: bytes, + *, + runtime: dict | None = None, + context: str = "cartridge", +) -> None: + h = cartridge_contract(data) + expected_major = int((runtime or {}).get("cart_abi_major", CART_ABI_MAJOR)) + expected_hash = int((runtime or {}).get("cart_abi_hash", ABI_HASH)) + default_features = 0 + for bit in FEATURE_BITS.values(): + default_features |= int(bit) + provided_features = int((runtime or {}).get("cart_abi_features", default_features)) + load_addr = int((runtime or {}).get("cart_load_addr", FALLBACK_CART_LOAD_ADDR)) + if h["abi_major"] != expected_major: + raise SystemExit( + f"{context} rejected: cartridge ABI major {h['abi_major']} " + f"is not compatible with runtime ABI major {expected_major}" + ) + if h["import_model"] == PRG32_IMPORT_MODEL_ABI_TABLE: + if h["abi_hash"] != expected_hash: + raise SystemExit( + f"{context} rejected: portable ABI hash " + f"0x{int(h['abi_hash'] or 0):08x} does not match runtime " + f"0x{expected_hash:08x}; rebuild the cartridge with this PRG32 checkout" + ) + missing = int(h["required_features"]) & ~provided_features + if missing: + raise SystemExit( + f"{context} rejected: cartridge requires unsupported ABI " + f"feature bits 0x{missing:08x}" + ) + return + if h["import_model"] != PRG32_IMPORT_MODEL_LEGACY_ABSOLUTE: + raise SystemExit( + f"{context} rejected: unsupported cartridge import model {h['import_model']}" + ) + if h["load_addr"] != load_addr: + raise SystemExit( + f"{context} rejected: legacy cartridge was linked for " + f"0x{h['load_addr']:08x}, but this runtime loads cartridges at " + f"0x{load_addr:08x}; rebuild with --portable or against this firmware" + ) + + def multipart_request(url: str, fields: dict[str, str], files: dict[str, tuple[str, bytes, str]], @@ -614,6 +689,8 @@ def build(args: argparse.Namespace) -> None: def upload(args: argparse.Namespace) -> None: data = Path(args.cartridge).read_bytes() ensure_cart_max_size(data) + runtime = fetch_runtime(args.url) + validate_cartridge_contract(data, runtime=runtime, context="upload") endpoint = args.url.rstrip("/") + "/api/games?slot=" + args.slot request = urllib.request.Request( endpoint, @@ -728,6 +805,12 @@ def store_download(args: argparse.Namespace) -> None: raise SystemExit(f"download failed: HTTP {exc.code}: {body}") from exc except urllib.error.URLError as exc: raise SystemExit(f"download failed: {exc}") from exc + data = out.read_bytes() + try: + validate_cartridge_contract(data, context="store download") + except SystemExit: + out.unlink(missing_ok=True) + raise print(f"saved {out}") @@ -849,6 +932,7 @@ def upload_qemu(args: argparse.Namespace) -> None: flash = Path(args.flash) data = Path(args.cartridge).read_bytes() ensure_cart_max_size(data) + validate_cartridge_contract(data, context="QEMU staging") partitions = Path(args.partitions) cart_offset, cart_size = read_partition_slot(partitions, args.slot) if len(data) > cart_size: @@ -1111,9 +1195,13 @@ def main(argv: list[str]) -> int: p = sub.add_parser("publish", help="build and publish a cartridge bundle") p.add_argument("source") - p.add_argument("--firmware-elf", required=True) + p.add_argument("--firmware-elf") p.add_argument("--entry-prefix", required=True) p.add_argument("--name", required=True) + p.add_argument("--portable", action="store_true") + p.add_argument("--legacy-absolute-imports", action="store_true") + p.add_argument("--required-feature", action="append", default=[]) + p.add_argument("--optional-feature", action="append", default=[]) p.add_argument("--store-url") p.add_argument("--architecture", choices=sorted(ARCHITECTURE_PROFILES)) p.add_argument("--id") diff --git a/tools/prg32_prepare_legacy_firmware.py b/tools/prg32_prepare_legacy_firmware.py new file mode 100755 index 0000000..23c94e7 --- /dev/null +++ b/tools/prg32_prepare_legacy_firmware.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Prepare a single-file legacy PRG32 firmware image for publishing.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import shutil +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[1] + + +def run(cmd: list[str]) -> None: + print("+ " + " ".join(cmd)) + subprocess.run(cmd, cwd=ROOT, check=True) + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--build-dir", default="build-esp32c6") + parser.add_argument("--out-dir", default="publish/legacy-firmware") + parser.add_argument("--name", default="PRG32-legacy-esp32c6") + parser.add_argument("--skip-build", action="store_true") + args = parser.parse_args(argv) + + build_dir = ROOT / args.build_dir + out_dir = ROOT / args.out_dir + flasher_args = build_dir / "flasher_args.json" + if not args.skip_build: + run([ + "idf.py", + "-B", + str(build_dir.relative_to(ROOT)), + "-D", + f"SDKCONFIG={args.build_dir}/sdkconfig", + "-D", + "SDKCONFIG_DEFAULTS=sdkconfig.defaults", + "build", + ]) + if not flasher_args.exists(): + raise SystemExit(f"missing {flasher_args}; run an ESP-IDF build first") + + data = json.loads(flasher_args.read_text(encoding="utf-8")) + flash_settings = data.get("flash_settings", {}) + flash_files = data.get("flash_files", {}) + if not flash_files: + raise SystemExit(f"{flasher_args} does not contain flash_files") + + out_dir.mkdir(parents=True, exist_ok=True) + image = out_dir / f"{args.name}.bin" + cmd = [ + sys.executable, + "-m", + "esptool", + "--chip", + data.get("extra_esptool_args", {}).get("chip", "esp32c6"), + "merge_bin", + "-o", + str(image), + "--flash_mode", + flash_settings.get("flash_mode", "dio"), + "--flash_freq", + flash_settings.get("flash_freq", "80m"), + "--flash_size", + flash_settings.get("flash_size", "4MB"), + ] + for offset, filename in sorted(flash_files.items(), key=lambda item: int(item[0], 0)): + cmd.extend([offset, str(build_dir / filename)]) + run(cmd) + + manifest = { + "name": args.name, + "target": data.get("extra_esptool_args", {}).get("chip", "esp32c6"), + "single_file": image.name, + "write_flash_offset": "0x0", + "flash_settings": flash_settings, + "source_build_dir": str(build_dir), + "source_flash_files": flash_files, + } + manifest_path = out_dir / f"{args.name}.json" + manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + shutil.copy2(flasher_args, out_dir / "flasher_args.json") + print(f"prepared {image}") + print(f"manifest {manifest_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From 0b5f1084271a62feb79447dec7d4825936a19628 Mon Sep 17 00:00:00 2001 From: raffmont Date: Thu, 11 Jun 2026 22:49:24 +0200 Subject: [PATCH 24/24] Increase LCD SPI clock --- main/prg32_config.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main/prg32_config.h b/main/prg32_config.h index d3963a1..5b79f71 100644 --- a/main/prg32_config.h +++ b/main/prg32_config.h @@ -80,7 +80,7 @@ #define PRG32_PIN_LCD_RST 0 #define PRG32_PIN_LCD_BL 5 -#define PRG32_LCD_SPI_CLOCK_HZ 32000000 +#define PRG32_LCD_SPI_CLOCK_HZ 40000000 #define PRG32_LCD_BACKLIGHT_ACTIVE_LEVEL 1 #define PRG32_LCD_BOOT_TEST_MS 0 #define PRG32_LCD_SOFT_SPI 0