From 9a53c9e01d5e3d01b288cbfa9412c1f464a4e244 Mon Sep 17 00:00:00 2001 From: raffmont Date: Fri, 12 Jun 2026 23:26:47 +0200 Subject: [PATCH 1/5] Add 24x24 sprite support --- README.md | 1 + components/prg32/include/prg32.h | 3 +- components/prg32/include/prg32_abi_hash.h | 4 +- components/prg32/include/prg32_abi_index.h | 3 +- components/prg32/prg32_abi_exports.c | 1 + components/prg32/prg32_abi_table.c | 1 + components/prg32/prg32_sprite.c | 14 +- docs/abi.md | 4 +- docs/api.md | 2 +- docs/assets.md | 4 + docs/cartridge-format.md | 2 + docs/examples.md | 2 +- docs/framework_manual.md | 9 +- docs/qemu.md | 1 + docs/tutorial.md | 1 + docs/tutorial_c_game.md | 4 + docs/tutorial_graphic_game.md | 2 + examples/games/README.md | 1 + examples/games/frogger/README.md | 24 ++++ examples/games/frogger/ascii/game.S | 116 +++++++++++++++++ examples/games/frogger/c/game.c | 144 +++++++++++++++++++++ examples/games/frogger/graphics/game.S | 129 ++++++++++++++++++ main/prg32_config.h | 3 + tools/prg32_abi.json | 9 +- tools/prg32_abi_generated.py | 5 +- 25 files changed, 469 insertions(+), 20 deletions(-) create mode 100644 examples/games/frogger/README.md create mode 100644 examples/games/frogger/ascii/game.S create mode 100644 examples/games/frogger/c/game.c create mode 100644 examples/games/frogger/graphics/game.S diff --git a/README.md b/README.md index 271d2be..0e8f6a0 100644 --- a/README.md +++ b/README.md @@ -525,6 +525,7 @@ C versions of: - `platformer` - `raycaster` - `wing_commander` +- `frogger` See [examples/games/README.md](examples/games/README.md) for step-by-step instructions to run each game embedded in firmware or as an uploadable diff --git a/components/prg32/include/prg32.h b/components/prg32/include/prg32.h index 83d96ac..11aa4cb 100644 --- a/components/prg32/include/prg32.h +++ b/components/prg32/include/prg32.h @@ -84,7 +84,7 @@ extern "C" { #define PRG32_CART_MAGIC "PRG2" #define PRG32_CART_ABI_MAJOR 1 -#define PRG32_CART_ABI_MINOR 0 +#define PRG32_CART_ABI_MINOR 1 #define PRG32_CART_FLAG_AUDIO_BLOCK (1u << 0) #define PRG32_CART_FLAG_MULTIPLAYER (1u << 1) #define PRG32_CART_FLAG_ABI_TABLE (1u << 2) @@ -428,6 +428,7 @@ void prg32_platform_camera_follow(const prg32_platform_actor_t *actor, int prg32_sprite_hitbox(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh); void prg32_sprite_draw_8x8(int x, int y, const uint8_t *bits, uint16_t fg, uint16_t bg); void prg32_sprite_draw_16x16(int x, int y, const uint16_t *rgb565); +void prg32_sprite_draw_24x24(int x, int y, const uint16_t *rgb565); uint32_t prg32_sprite_anim_frame(uint32_t now_ms, uint32_t frame_count, uint32_t frame_ms); diff --git a/components/prg32/include/prg32_abi_hash.h b/components/prg32/include/prg32_abi_hash.h index acd5864..0ac198f 100644 --- a/components/prg32/include/prg32_abi_hash.h +++ b/components/prg32/include/prg32_abi_hash.h @@ -3,5 +3,5 @@ /* 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 +#define PRG32_ABI_MINOR 1u +#define PRG32_ABI_HASH 0xafee0856u diff --git a/components/prg32/include/prg32_abi_index.h b/components/prg32/include/prg32_abi_index.h index f278b40..c151672 100644 --- a/components/prg32/include/prg32_abi_index.h +++ b/components/prg32/include/prg32_abi_index.h @@ -116,5 +116,6 @@ enum { 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 + PRG32_ABI_FN_PRG32_SPRITE_DRAW_24X24 = 113, + PRG32_ABI_FN_COUNT = 114 }; diff --git a/components/prg32/prg32_abi_exports.c b/components/prg32/prg32_abi_exports.c index 989f59a..24491e3 100644 --- a/components/prg32/prg32_abi_exports.c +++ b/components/prg32/prg32_abi_exports.c @@ -142,6 +142,7 @@ static const prg32_any_fn_t g_prg32_cart_abi_exports[] = { (prg32_any_fn_t)prg32_sprite_hitbox, (prg32_any_fn_t)prg32_sprite_draw_8x8, (prg32_any_fn_t)prg32_sprite_draw_16x16, + (prg32_any_fn_t)prg32_sprite_draw_24x24, (prg32_any_fn_t)prg32_sprite_anim_frame, (prg32_any_fn_t)prg32_sprite_draw_frame, (prg32_any_fn_t)prg32_sprite_anim_init, diff --git a/components/prg32/prg32_abi_table.c b/components/prg32/prg32_abi_table.c index a878371..8cad10c 100644 --- a/components/prg32/prg32_abi_table.c +++ b/components/prg32/prg32_abi_table.c @@ -129,5 +129,6 @@ const prg32_abi_table_t prg32_abi_table = { [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, + [PRG32_ABI_FN_PRG32_SPRITE_DRAW_24X24] = (const void *)prg32_sprite_draw_24x24, }, }; diff --git a/components/prg32/prg32_sprite.c b/components/prg32/prg32_sprite.c index e993b87..b38f000 100644 --- a/components/prg32/prg32_sprite.c +++ b/components/prg32/prg32_sprite.c @@ -46,14 +46,14 @@ void prg32_sprite_draw_16x16(int x, int y, const uint16_t *rgb565) { if (!rgb565) { return; } - for (int row = 0; row < 16; ++row) { - for (int col = 0; col < 16; ++col) { - uint16_t color = rgb565[row * 16 + col]; - if (color != PRG32_COLOR_WHITE) { - prg32_gfx_pixel(x + col, y + row, color); - } - } + prg32_sprite_draw_frame(x, y, 16, 16, rgb565, 0, PRG32_COLOR_WHITE); +} + +void prg32_sprite_draw_24x24(int x, int y, const uint16_t *rgb565) { + if (!rgb565) { + return; } + prg32_sprite_draw_frame(x, y, 24, 24, rgb565, 0, PRG32_COLOR_WHITE); } uint32_t prg32_sprite_anim_frame(uint32_t now_ms, diff --git a/docs/abi.md b/docs/abi.md index 5ebd24d..2165b2a 100644 --- a/docs/abi.md +++ b/docs/abi.md @@ -46,12 +46,14 @@ keyboard, tilemap, platformer, and sprites. ## Cartridge Package ABI -The executable cartridge ABI remains `PRG2` major `1`, minor `0`. Header v2 +The executable cartridge ABI remains `PRG2` major `1`, minor `1`. 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. +ABI minor `1` adds `prg32_sprite_draw_24x24` as an append-only sprite helper. + 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`. diff --git a/docs/api.md b/docs/api.md index 257d963..eb5019a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -118,7 +118,7 @@ Typical response fields: "firmware_version": "1.0.0", "cart_magic": "PRG32CART", "cart_abi_major": 1, - "cart_abi_minor": 0, + "cart_abi_minor": 1, "cart_abi_hash": 3117075842, "cart_abi_features": 511, "cart_load_addr": 1107296256, diff --git a/docs/assets.md b/docs/assets.md index 2e8dbfe..77ffd72 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -16,6 +16,10 @@ python3 tools/prg32_image_convert.py player.png \ --out build/player_sprite.c ``` +For a 24x24 multicolor sprite, use `--width 24 --height 24` and draw the +generated RGB565 array with `prg32_sprite_draw_24x24(x, y, sprite)`. +White pixels (`0xffff`) are transparent in the fixed-size sprite helpers. + Convert an animated GIF to assembly frames: ```bash diff --git a/docs/cartridge-format.md b/docs/cartridge-format.md index 7a03965..06b5de2 100644 --- a/docs/cartridge-format.md +++ b/docs/cartridge-format.md @@ -19,6 +19,8 @@ The `.prg32` header starts with magic `PRG2` and stores: - code payload CRC32 - cartridge name +Current executable cartridge ABI version: major `1`, minor `1`. + `PRG32_CART_FLAG_AUDIO_BLOCK` marks a cartridge that has a trailing AUDIO block. `PRG32_CART_FLAG_MULTIPLAYER` marks a cartridge that intentionally uses the multiplayer service. The game still calls `prg32_multiplayer_join()` at runtime diff --git a/docs/examples.md b/docs/examples.md index f83984c..cb8a1fa 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -31,7 +31,7 @@ examples/games// Use these for Computing Architecture and C Programming classes. -The platformer, raycaster, and wing commander C examples are the fuller +The platformer, raycaster, wing commander, and frogger C examples are the fuller 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. diff --git a/docs/framework_manual.md b/docs/framework_manual.md index a823ea1..bdebd03 100644 --- a/docs/framework_manual.md +++ b/docs/framework_manual.md @@ -450,13 +450,16 @@ Useful calls: - `prg32_sprite_draw_8x8(x, y, bits, fg, bg)`: draw a monochrome sprite. - `prg32_sprite_draw_16x16(x, y, rgb565)`: draw a 16x16 RGB565 sprite. +- `prg32_sprite_draw_24x24(x, y, rgb565)`: draw a 24x24 RGB565 sprite. - `prg32_sprite_hitbox(...)`: test two axis-aligned rectangles. - `prg32_sprite_anim_frame(now_ms, frame_count, frame_ms)`: compute a frame. - `prg32_sprite_draw_frame(...)`: draw one frame from a sprite sheet. -`prg32_sprite_draw_frame` accepts width, height, a pointer to contiguous RGB565 -frames, the frame index, and a transparent color. This keeps animated sprites -usable from assembly without requiring a C object. +The 16x16 and 24x24 helpers treat `PRG32_COLOR_WHITE` as transparent. For other +sizes or another transparency key, `prg32_sprite_draw_frame` accepts width, +height, a pointer to contiguous RGB565 frames, the frame index, and a +transparent color. This keeps animated sprites usable from assembly without +requiring a C object. ## Audio diff --git a/docs/qemu.md b/docs/qemu.md index 09ee37f..45f0dc2 100644 --- a/docs/qemu.md +++ b/docs/qemu.md @@ -19,6 +19,7 @@ The assembly ABI does not change. A game still calls: - `prg32_gfx_text8` - `prg32_gfx_present` - `prg32_playfield_draw_dual` +- `prg32_sprite_draw_24x24` - `prg32_sprite_draw_frame` Only the display backend changes. diff --git a/docs/tutorial.md b/docs/tutorial.md index 7607963..4e5e5c9 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -180,6 +180,7 @@ assembly lessons to: renderer on RISC-V. - `examples/games/wing_commander/c/game.c` for a playable dual-playfield cockpit with starfield, enemies, shield, and score. +- `examples/games/frogger/c/game.c` for 24x24 multicolor sprites and hitboxes. Suggested order: diff --git a/docs/tutorial_c_game.md b/docs/tutorial_c_game.md index c652073..f393965 100644 --- a/docs/tutorial_c_game.md +++ b/docs/tutorial_c_game.md @@ -184,6 +184,10 @@ Use `examples/games/wing_commander/c/game.c` when the course reaches layered rendering. It keeps the starfield and cockpit in separate playfields, then adds enemies, laser input, score, and shield state in C. +Use `examples/games/frogger/c/game.c` when the course reaches multicolor sprite +assets. It draws a 24x24 RGB565 player sprite and uses simple rectangle +hitboxes for traffic collision. + ## Break and Fix Exercise Break it: diff --git a/docs/tutorial_graphic_game.md b/docs/tutorial_graphic_game.md index 051d9d1..09a1479 100644 --- a/docs/tutorial_graphic_game.md +++ b/docs/tutorial_graphic_game.md @@ -19,6 +19,7 @@ on the physical ILI9341 display and on the QEMU virtual RGB screen. - `prg32_gfx_text8(x, y, text, fg, bg)` - `prg32_gfx_present()` - `prg32_sprite_hitbox(ax, ay, aw, ah, bx, by, bw, bh)` +- `prg32_sprite_draw_24x24(x, y, rgb565)` - `prg32_sprite_anim_frame(now_ms, frame_count, frame_ms)` - `prg32_sprite_draw_frame(x, y, w, h, frames, frame, transparent)` - `prg32_playfield_scroll(layer, x, y)` @@ -207,6 +208,7 @@ After the focused demos, try the fuller game examples: - `examples/games/raycaster/c/game.c` for the playable fixed-point raycaster. - `examples/games/wing_commander/graphics/game.S` for a dual-playfield cockpit with a scrolling starfield and fixed foreground dashboard. +- `examples/games/frogger/c/game.c` for 24x24 multicolor sprites and hitboxes. ## Break and Fix Exercise diff --git a/examples/games/README.md b/examples/games/README.md index a2a8d98..7332d30 100644 --- a/examples/games/README.md +++ b/examples/games/README.md @@ -27,6 +27,7 @@ examples/games// | `platformer` | `platformer_ascii` | `platformer_graphics` | `platformer_c` | | `raycaster` | `raycaster_ascii` | `raycaster_graphics` | `raycaster_c` | | `wing_commander` | `wing_commander_ascii` | `wing_commander_graphics` | `wing_commander_c` | +| `frogger` | `frogger_ascii` | `frogger_graphics` | `frogger_c` | Use the prefix to find the three exported symbols: diff --git a/examples/games/frogger/README.md b/examples/games/frogger/README.md new file mode 100644 index 0000000..a4b86cb --- /dev/null +++ b/examples/games/frogger/README.md @@ -0,0 +1,24 @@ +# Frogger Inspired Crossing + +This example uses 24x24 multicolor RGB565 sprites in a simple road-crossing +game. It is inspired by the arcade crossing pattern, but all names, graphics, +and rules are original classroom material. + +## Controls + +- Left and right: move one lane cell. +- Up and down: move between lanes. +- A: restart after a crash or a successful crossing. + +## Files + +- `ascii/game.S`: compact register-and-grid version for early assembly labs. +- `graphics/game.S`: assembly version that draws the 24x24 sprite helper. +- `c/game.c`: fuller sprite game with moving cars, score, and restart state. + +## Learning Goals + +- Store a 24x24 RGB565 sprite as `24 * 24` halfwords. +- Draw a multicolor sprite with `prg32_sprite_draw_24x24`. +- Use `prg32_sprite_hitbox` for rectangle collision. +- Keep movement snapped to visible lanes so game state is easy to inspect. diff --git a/examples/games/frogger/ascii/game.S b/examples/games/frogger/ascii/game.S new file mode 100644 index 0000000..d5bf5d0 --- /dev/null +++ b/examples/games/frogger/ascii/game.S @@ -0,0 +1,116 @@ +/* + * FROGGER ASCII for PRG32. + * + * A tiny grid-crossing exercise. It keeps the state readable for register + * tracing before students move to the sprite versions. + */ +.option norelax + +.section .data +frogger_ascii_x: .word 19 +frogger_ascii_y: .word 10 +frogger_ascii_car: .word 0 +frogger_ascii_title: .asciz "FROGGER ASCII" +frogger_ascii_win: .asciz "SAFE" + +.section .text +.global frogger_ascii_init +.global frogger_ascii_update +.global frogger_ascii_draw + +frogger_ascii_init: + la t0, frogger_ascii_x + li t1, 19 + sw t1, 0(t0) + la t0, frogger_ascii_y + li t1, 10 + sw t1, 0(t0) + la t0, frogger_ascii_car + sw zero, 0(t0) + ret + +frogger_ascii_update: + addi sp, sp, -16 + sw ra, 12(sp) + + call prg32_input_read + mv t2, a0 + + la t0, frogger_ascii_x + lw t1, 0(t0) + andi t3, t2, 1 + beqz t3, .Lfrogger_ascii_no_left + addi t1, t1, -1 +.Lfrogger_ascii_no_left: + andi t3, t2, 2 + beqz t3, .Lfrogger_ascii_no_right + addi t1, t1, 1 +.Lfrogger_ascii_no_right: + bltz t1, .Lfrogger_ascii_x_low + li t3, 39 + bgt t1, t3, .Lfrogger_ascii_x_high + j .Lfrogger_ascii_store_x +.Lfrogger_ascii_x_low: + li t1, 0 + j .Lfrogger_ascii_store_x +.Lfrogger_ascii_x_high: + li t1, 39 +.Lfrogger_ascii_store_x: + sw t1, 0(t0) + + la t0, frogger_ascii_y + lw t1, 0(t0) + andi t3, t2, 4 + beqz t3, .Lfrogger_ascii_no_up + addi t1, t1, -1 +.Lfrogger_ascii_no_up: + andi t3, t2, 8 + beqz t3, .Lfrogger_ascii_no_down + addi t1, t1, 1 +.Lfrogger_ascii_no_down: + bltz t1, .Lfrogger_ascii_y_low + li t3, 23 + bgt t1, t3, .Lfrogger_ascii_y_high + j .Lfrogger_ascii_store_y +.Lfrogger_ascii_y_low: + li t1, 0 + j .Lfrogger_ascii_store_y +.Lfrogger_ascii_y_high: + li t1, 23 +.Lfrogger_ascii_store_y: + sw t1, 0(t0) + + la t0, frogger_ascii_car + lw t1, 0(t0) + addi t1, t1, 1 + li t2, 40 + blt t1, t2, .Lfrogger_ascii_car_ok + li t1, 0 +.Lfrogger_ascii_car_ok: + sw t1, 0(t0) + + lw ra, 12(sp) + addi sp, sp, 16 + ret + +frogger_ascii_draw: + addi sp, sp, -16 + sw ra, 12(sp) + + call prg32_console_clear + la a0, frogger_ascii_title + call prg32_console_write + li a0, 10 + call prg32_console_putc + + la t0, frogger_ascii_y + lw t1, 0(t0) + bnez t1, .Lfrogger_ascii_no_win + la a0, frogger_ascii_win + call prg32_console_write + li a0, 10 + call prg32_console_putc +.Lfrogger_ascii_no_win: + lw ra, 12(sp) + addi sp, sp, 16 + ret diff --git a/examples/games/frogger/c/game.c b/examples/games/frogger/c/game.c new file mode 100644 index 0000000..0be308e --- /dev/null +++ b/examples/games/frogger/c/game.c @@ -0,0 +1,144 @@ +#include "prg32.h" +#include + +#define FROG_W 24 +#define FROG_H 24 +#define CELL 24 +#define START_X 144 +#define START_Y 168 + +typedef struct { + int x; + int y; + int speed; + uint16_t color; +} car_t; + +static int frog_x; +static int frog_y; +static uint32_t last_input; +static uint32_t score; +static int state; +static car_t cars[4]; + +static const uint16_t frog_sprite[FROG_W * FROG_H] = { + 0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff, + 0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff, + 0xffff,0x07e0,0x07e0,0x07e0,0x0000,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x0000,0x07e0,0x07e0,0x07e0,0x07e0,0xffff, + 0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff, + 0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff, + 0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0xffe0,0xffe0,0x07e0,0x07e0,0x07e0,0x07e0,0xffe0,0xffe0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffe0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffe0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff, + 0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff, + 0xffff,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0xffff, + 0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff, + 0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff, + 0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff, + 0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff, +}; + +static void reset_frog(void) { + frog_x = START_X; + frog_y = START_Y; +} + +void frogger_c_init(void) { + reset_frog(); + score = 0; + state = 0; + last_input = 0; + cars[0] = (car_t){-40, 60, 2, PRG32_COLOR_RED}; + cars[1] = (car_t){270, 84, -3, PRG32_COLOR_YELLOW}; + cars[2] = (car_t){40, 108, 4, PRG32_COLOR_BLUE}; + cars[3] = (car_t){210, 132, -2, 0xf81f}; +} + +void frogger_c_update(void) { + uint32_t input = prg32_input_read(); + uint32_t pressed = input & ~last_input; + last_input = input; + + if (state != 0) { + if (pressed & PRG32_BTN_A) { + state = 0; + reset_frog(); + } + return; + } + + if ((pressed & PRG32_BTN_LEFT) && frog_x > 0) { + frog_x -= CELL; + } + if ((pressed & PRG32_BTN_RIGHT) && frog_x < PRG32_GAME_W - FROG_W) { + frog_x += CELL; + } + if ((pressed & PRG32_BTN_UP) && frog_y > 0) { + frog_y -= CELL; + } + if ((pressed & PRG32_BTN_DOWN) && frog_y < PRG32_GAME_H - FROG_H) { + frog_y += CELL; + } + + for (size_t i = 0; i < sizeof(cars) / sizeof(cars[0]); ++i) { + cars[i].x += cars[i].speed; + if (cars[i].speed > 0 && cars[i].x > PRG32_GAME_W) { + cars[i].x = -56; + } + if (cars[i].speed < 0 && cars[i].x < -56) { + cars[i].x = PRG32_GAME_W; + } + if (prg32_sprite_hitbox(frog_x + 4, frog_y + 4, 16, 16, + cars[i].x, cars[i].y, 48, 18)) { + state = -1; + prg32_audio_beep(120, 80); + } + } + + if (frog_y <= 24) { + state = 1; + score++; + prg32_audio_beep(880, 80); + } +} + +void frogger_c_draw(void) { + char text[24]; + + prg32_gfx_clear(0x03ef); + prg32_gfx_rect(0, 0, PRG32_GAME_W, 32, 0x05e0); + prg32_gfx_rect(0, 56, PRG32_GAME_W, 100, 0x4208); + prg32_gfx_rect(0, 176, PRG32_GAME_W, 24, 0x07e0); + + for (int y = 80; y <= 128; y += 24) { + for (int x = 0; x < PRG32_GAME_W; x += 32) { + prg32_gfx_rect(x, y, 16, 2, PRG32_COLOR_WHITE); + } + } + + for (size_t i = 0; i < sizeof(cars) / sizeof(cars[0]); ++i) { + prg32_gfx_rect(cars[i].x, cars[i].y, 48, 18, cars[i].color); + prg32_gfx_rect(cars[i].x + 6, cars[i].y + 3, 12, 4, PRG32_COLOR_WHITE); + prg32_gfx_rect(cars[i].x + 30, cars[i].y + 3, 12, 4, PRG32_COLOR_WHITE); + } + + prg32_sprite_draw_24x24(frog_x, frog_y, frog_sprite); + + snprintf(text, sizeof(text), "SCORE %lu", (unsigned long)score); + prg32_gfx_text8(8, 8, text, PRG32_COLOR_WHITE, 0x05e0); + if (state < 0) { + prg32_gfx_text8(96, 92, "CRASH - A", PRG32_COLOR_WHITE, PRG32_COLOR_RED); + } else if (state > 0) { + prg32_gfx_text8(100, 92, "SAFE - A", PRG32_COLOR_BLACK, PRG32_COLOR_YELLOW); + } +} diff --git a/examples/games/frogger/graphics/game.S b/examples/games/frogger/graphics/game.S new file mode 100644 index 0000000..3e9b503 --- /dev/null +++ b/examples/games/frogger/graphics/game.S @@ -0,0 +1,129 @@ +/* + * FROGGER GRAPHICS for PRG32. + * + * Minimal assembly demo for the 24x24 multicolor sprite helper. + */ +.option norelax + +.section .data +frogger_graphics_x: .word 148 +frogger_graphics_y: .word 168 +frogger_graphics_car_x: .word 0 +frogger_graphics_title: .asciz "24X24 SPRITE" + +.section .rodata +.balign 2 +frogger_graphics_sprite: + .rept 3 + .half 0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff + .endr + .rept 4 + .half 0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff + .endr + .rept 4 + .half 0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff + .endr + .rept 4 + .half 0xffff,0x07e0,0x07e0,0x07e0,0x0000,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x0000,0x07e0,0x07e0,0x07e0,0xffff + .endr + .rept 4 + .half 0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0x07e0,0xffff,0xffff + .endr + .rept 5 + .half 0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0xffff,0x07e0,0x07e0,0xffff,0xffff,0xffff,0xffff,0xffff + .endr + +.section .text +.global frogger_graphics_init +.global frogger_graphics_update +.global frogger_graphics_draw + +frogger_graphics_init: + la t0, frogger_graphics_x + li t1, 148 + sw t1, 0(t0) + la t0, frogger_graphics_y + li t1, 168 + sw t1, 0(t0) + la t0, frogger_graphics_car_x + sw zero, 0(t0) + ret + +frogger_graphics_update: + addi sp, sp, -16 + sw ra, 12(sp) + + call prg32_input_read + mv t2, a0 + la t0, frogger_graphics_x + lw t1, 0(t0) + andi t3, t2, 1 + beqz t3, .Lfrogger_graphics_no_left + addi t1, t1, -24 +.Lfrogger_graphics_no_left: + andi t3, t2, 2 + beqz t3, .Lfrogger_graphics_no_right + addi t1, t1, 24 +.Lfrogger_graphics_no_right: + sw t1, 0(t0) + + la t0, frogger_graphics_y + lw t1, 0(t0) + andi t3, t2, 4 + beqz t3, .Lfrogger_graphics_no_up + addi t1, t1, -24 +.Lfrogger_graphics_no_up: + andi t3, t2, 8 + beqz t3, .Lfrogger_graphics_no_down + addi t1, t1, 24 +.Lfrogger_graphics_no_down: + sw t1, 0(t0) + + la t0, frogger_graphics_car_x + lw t1, 0(t0) + addi t1, t1, 4 + li t2, 320 + blt t1, t2, .Lfrogger_graphics_car_ok + li t1, -48 +.Lfrogger_graphics_car_ok: + sw t1, 0(t0) + + lw ra, 12(sp) + addi sp, sp, 16 + ret + +frogger_graphics_draw: + addi sp, sp, -16 + sw ra, 12(sp) + + li a0, 0x03ef + call prg32_gfx_clear + li a0, 0 + li a1, 56 + li a2, 320 + li a3, 88 + li a4, 0x4208 + call prg32_gfx_rect + la t0, frogger_graphics_car_x + lw a0, 0(t0) + li a1, 96 + li a2, 48 + li a3, 18 + li a4, 0xf800 + call prg32_gfx_rect + la t0, frogger_graphics_x + lw a0, 0(t0) + la t0, frogger_graphics_y + lw a1, 0(t0) + la a2, frogger_graphics_sprite + call prg32_sprite_draw_24x24 + li a0, 8 + li a1, 8 + la a2, frogger_graphics_title + li a3, 0xffff + li a4, 0x03ef + call prg32_gfx_text8 + + lw ra, 12(sp) + addi sp, sp, 16 + ret diff --git a/main/prg32_config.h b/main/prg32_config.h index 5b79f71..2ca516f 100644 --- a/main/prg32_config.h +++ b/main/prg32_config.h @@ -31,6 +31,9 @@ #define PRG32_GAME_WING_ASCII 25 #define PRG32_GAME_WING_GRAPHICS 26 #define PRG32_GAME_WING_C 27 +#define PRG32_GAME_FROGGER_ASCII 28 +#define PRG32_GAME_FROGGER_GRAPHICS 29 +#define PRG32_GAME_FROGGER_C 30 /* Runtime console mode. */ #define PRG32_MODE_UART_ONLY 0 diff --git a/tools/prg32_abi.json b/tools/prg32_abi.json index c7a4a3e..11d6541 100644 --- a/tools/prg32_abi.json +++ b/tools/prg32_abi.json @@ -1,7 +1,7 @@ { "name": "prg32-cart-abi", "major": 1, - "minor": 0, + "minor": 1, "functions": [ { "name": "prg32_ticks_ms", @@ -793,6 +793,13 @@ "args": [], "feature": "core", "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_sprite_draw_24x24", + "return": "void", + "args": [], + "feature": "sprites", + "notes": "PRG32 cartridge ABI entry point." } ] } diff --git a/tools/prg32_abi_generated.py b/tools/prg32_abi_generated.py index f0a3f18..a798ab6 100644 --- a/tools/prg32_abi_generated.py +++ b/tools/prg32_abi_generated.py @@ -1,8 +1,8 @@ # Generated by tools/prg32_abi_gen.py; do not edit manually. ABI_MAJOR = 1 -ABI_MINOR = 0 -ABI_HASH = 0xb9cadd82 +ABI_MINOR = 1 +ABI_HASH = 0xafee0856 IMPORT_NAMES = [ 'prg32_ticks_ms', 'prg32_input_read', @@ -117,6 +117,7 @@ 'prg32_sprite_anim_update', 'prg32_sprite_anim_draw', 'prg32_score_submit', + 'prg32_sprite_draw_24x24', ] FEATURE_BITS = { From 0b3b843e36a120fae9598e1a766fa121723f941f Mon Sep 17 00:00:00 2001 From: raffmont Date: Sat, 13 Jun 2026 22:08:00 +0200 Subject: [PATCH 2/5] Update cartridge and sprite docs --- components/prg32/include/prg32.h | 2 +- components/prg32/prg32_abi_exports.c | 2 +- docs/api.md | 4 ++-- docs/assets.md | 11 +++++++---- docs/cartridges.md | 2 +- docs/framework_manual.md | 9 +++++++-- docs/tutorial.md | 5 ++++- docs/tutorial_c_game.md | 4 +++- docs/tutorial_graphic_game.md | 5 ++++- examples/games/README.md | 1 + examples/games/frogger/graphics/game.S | 20 ++++++++++++++++++++ main/prg32_config.h | 2 +- tools/prg32_game.py | 2 +- 13 files changed, 53 insertions(+), 16 deletions(-) diff --git a/components/prg32/include/prg32.h b/components/prg32/include/prg32.h index 11aa4cb..ad5cd87 100644 --- a/components/prg32/include/prg32.h +++ b/components/prg32/include/prg32.h @@ -103,7 +103,7 @@ extern "C" { #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) +#define PRG32_CART_MAX_SIZE (64u * 1024u) #ifndef CONFIG_PRG32_CART_RAM_KIB #define CONFIG_PRG32_CART_RAM_KIB 32 #endif diff --git a/components/prg32/prg32_abi_exports.c b/components/prg32/prg32_abi_exports.c index 24491e3..a3ac733 100644 --- a/components/prg32/prg32_abi_exports.c +++ b/components/prg32/prg32_abi_exports.c @@ -142,13 +142,13 @@ static const prg32_any_fn_t g_prg32_cart_abi_exports[] = { (prg32_any_fn_t)prg32_sprite_hitbox, (prg32_any_fn_t)prg32_sprite_draw_8x8, (prg32_any_fn_t)prg32_sprite_draw_16x16, - (prg32_any_fn_t)prg32_sprite_draw_24x24, (prg32_any_fn_t)prg32_sprite_anim_frame, (prg32_any_fn_t)prg32_sprite_draw_frame, (prg32_any_fn_t)prg32_sprite_anim_init, (prg32_any_fn_t)prg32_sprite_anim_update, (prg32_any_fn_t)prg32_sprite_anim_draw, (prg32_any_fn_t)prg32_score_submit, + (prg32_any_fn_t)prg32_sprite_draw_24x24, }; void prg32_abi_exports_keep(void) { diff --git a/docs/api.md b/docs/api.md index eb5019a..ebc4af0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -122,7 +122,7 @@ Typical response fields: "cart_abi_hash": 3117075842, "cart_abi_features": 511, "cart_load_addr": 1107296256, - "cart_max_size": 32768, + "cart_max_size": 65536, "cart_ram_size": 65536, "cart_loaded": true, "qemu": false, @@ -258,7 +258,7 @@ Success response: Expected behavior: - upload is accepted only when `PRG32_GAME_UPLOAD_ENABLE` is enabled; -- the request body must fit in the 32 KiB cartridge package limit; +- the request body must fit in the 64 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 77ffd72..f0407f1 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -16,9 +16,12 @@ python3 tools/prg32_image_convert.py player.png \ --out build/player_sprite.c ``` -For a 24x24 multicolor sprite, use `--width 24 --height 24` and draw the -generated RGB565 array with `prg32_sprite_draw_24x24(x, y, sprite)`. -White pixels (`0xffff`) are transparent in the fixed-size sprite helpers. +For a 24x24 multicolor sprite, use `--width 24 --height 24`. The generated +array must contain `24 * 24` RGB565 halfwords and can be drawn with +`prg32_sprite_draw_24x24(x, y, sprite)`. White pixels (`0xffff`, +`PRG32_COLOR_WHITE`) are transparent in the fixed-size 16x16 and 24x24 sprite +helpers, which lets simple classroom assets keep a visible background without a +separate alpha mask. Convert an animated GIF to assembly frames: @@ -74,7 +77,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 32 KiB cartridge package limit. +uploadable cartridges when they fit inside the 64 KiB cartridge package limit. ## Cartridge AUDIO Blocks diff --git a/docs/cartridges.md b/docs/cartridges.md index 0d50a9d..66c68a0 100644 --- a/docs/cartridges.md +++ b/docs/cartridges.md @@ -427,7 +427,7 @@ 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 package size 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. diff --git a/docs/framework_manual.md b/docs/framework_manual.md index bdebd03..9ef428d 100644 --- a/docs/framework_manual.md +++ b/docs/framework_manual.md @@ -231,7 +231,7 @@ 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_MAX_SIZE`: maximum `.prg32` package size, currently 32 KiB. +- `PRG32_CART_MAX_SIZE`: maximum `.prg32` package size, currently 64 KiB. - `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 @@ -450,7 +450,8 @@ Useful calls: - `prg32_sprite_draw_8x8(x, y, bits, fg, bg)`: draw a monochrome sprite. - `prg32_sprite_draw_16x16(x, y, rgb565)`: draw a 16x16 RGB565 sprite. -- `prg32_sprite_draw_24x24(x, y, rgb565)`: draw a 24x24 RGB565 sprite. +- `prg32_sprite_draw_24x24(x, y, rgb565)`: draw a 24x24 RGB565 sprite from + `24 * 24` contiguous halfwords. - `prg32_sprite_hitbox(...)`: test two axis-aligned rectangles. - `prg32_sprite_anim_frame(now_ms, frame_count, frame_ms)`: compute a frame. - `prg32_sprite_draw_frame(...)`: draw one frame from a sprite sheet. @@ -461,6 +462,10 @@ height, a pointer to contiguous RGB565 frames, the frame index, and a transparent color. This keeps animated sprites usable from assembly without requiring a C object. +See `examples/games/frogger/graphics/game.S` for the assembly call sequence and +`examples/games/frogger/c/game.c` for a fuller game that pairs the 24x24 sprite +with `prg32_sprite_hitbox`. + ## Audio PRG32 has two audio layers. diff --git a/docs/tutorial.md b/docs/tutorial.md index 4e5e5c9..f01e242 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -180,7 +180,10 @@ assembly lessons to: renderer on RISC-V. - `examples/games/wing_commander/c/game.c` for a playable dual-playfield cockpit with starfield, enemies, shield, and score. -- `examples/games/frogger/c/game.c` for 24x24 multicolor sprites and hitboxes. +- `examples/games/frogger/graphics/game.S` for the 24x24 multicolor sprite + helper from assembly. +- `examples/games/frogger/c/game.c` for the same sprite pattern with hitboxes + and restart state. Suggested order: diff --git a/docs/tutorial_c_game.md b/docs/tutorial_c_game.md index f393965..7735201 100644 --- a/docs/tutorial_c_game.md +++ b/docs/tutorial_c_game.md @@ -186,7 +186,9 @@ enemies, laser input, score, and shield state in C. Use `examples/games/frogger/c/game.c` when the course reaches multicolor sprite assets. It draws a 24x24 RGB565 player sprite and uses simple rectangle -hitboxes for traffic collision. +hitboxes for traffic collision. The matching +`examples/games/frogger/graphics/game.S` file shows the same +`prg32_sprite_draw_24x24` call from RISC-V assembly. ## Break and Fix Exercise diff --git a/docs/tutorial_graphic_game.md b/docs/tutorial_graphic_game.md index 09a1479..c98c9f5 100644 --- a/docs/tutorial_graphic_game.md +++ b/docs/tutorial_graphic_game.md @@ -208,7 +208,10 @@ After the focused demos, try the fuller game examples: - `examples/games/raycaster/c/game.c` for the playable fixed-point raycaster. - `examples/games/wing_commander/graphics/game.S` for a dual-playfield cockpit with a scrolling starfield and fixed foreground dashboard. -- `examples/games/frogger/c/game.c` for 24x24 multicolor sprites and hitboxes. +- `examples/games/frogger/graphics/game.S` for the assembly 24x24 multicolor + sprite helper. +- `examples/games/frogger/c/game.c` for the same 24x24 sprite pattern with + hitboxes and restart state. ## Break and Fix Exercise diff --git a/examples/games/README.md b/examples/games/README.md index 7332d30..c831494 100644 --- a/examples/games/README.md +++ b/examples/games/README.md @@ -68,6 +68,7 @@ C versions are best for: - 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 +- studying 24x24 multicolor sprite assets and hitboxes with `frogger_c` The same source can be used in two ways: diff --git a/examples/games/frogger/graphics/game.S b/examples/games/frogger/graphics/game.S index 3e9b503..05eccb9 100644 --- a/examples/games/frogger/graphics/game.S +++ b/examples/games/frogger/graphics/game.S @@ -65,6 +65,16 @@ frogger_graphics_update: beqz t3, .Lfrogger_graphics_no_right addi t1, t1, 24 .Lfrogger_graphics_no_right: + blt t1, zero, .Lfrogger_graphics_min_x + li t3, 296 + bgt t1, t3, .Lfrogger_graphics_max_x + j .Lfrogger_graphics_store_x +.Lfrogger_graphics_min_x: + li t1, 0 + j .Lfrogger_graphics_store_x +.Lfrogger_graphics_max_x: + li t1, 296 +.Lfrogger_graphics_store_x: sw t1, 0(t0) la t0, frogger_graphics_y @@ -77,6 +87,16 @@ frogger_graphics_update: beqz t3, .Lfrogger_graphics_no_down addi t1, t1, 24 .Lfrogger_graphics_no_down: + blt t1, zero, .Lfrogger_graphics_min_y + li t3, 176 + bgt t1, t3, .Lfrogger_graphics_max_y + j .Lfrogger_graphics_store_y +.Lfrogger_graphics_min_y: + li t1, 0 + j .Lfrogger_graphics_store_y +.Lfrogger_graphics_max_y: + li t1, 176 +.Lfrogger_graphics_store_y: sw t1, 0(t0) la t0, frogger_graphics_car_x diff --git a/main/prg32_config.h b/main/prg32_config.h index 2ca516f..733c61d 100644 --- a/main/prg32_config.h +++ b/main/prg32_config.h @@ -83,7 +83,7 @@ #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 diff --git a/tools/prg32_game.py b/tools/prg32_game.py index b4b0e85..af19cf2 100755 --- a/tools/prg32_game.py +++ b/tools/prg32_game.py @@ -57,7 +57,7 @@ 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_MAX_SIZE = 64 * 1024 FALLBACK_CART_LOAD_ADDR = 0x40800000 STORE_DISCOVERY_ABI = "prg32-store-discovery-1.0" STORE_METADATA_ABI = "prg32-metadata-1.0" From efe2767445d2cfc9a1f462b4fe49bf970f43fa84 Mon Sep 17 00:00:00 2001 From: raffmont Date: Sat, 13 Jun 2026 22:46:45 +0200 Subject: [PATCH 3/5] Add cartridge scoreboard helpers --- components/prg32/include/prg32.h | 7 + components/prg32/include/prg32_abi_hash.h | 4 +- components/prg32/include/prg32_abi_index.h | 9 +- components/prg32/prg32_abi_exports.c | 7 + components/prg32/prg32_abi_table.c | 7 + components/prg32/prg32_http_scores.c | 219 ++++++++++++++++++--- docs/abi.md | 7 + docs/api.md | 6 + docs/score_api.md | 34 +++- docs/tutorial.md | 8 + tools/prg32_abi.json | 51 ++++- tools/prg32_abi_generated.py | 11 +- 12 files changed, 330 insertions(+), 40 deletions(-) diff --git a/components/prg32/include/prg32.h b/components/prg32/include/prg32.h index ad5cd87..bc27c05 100644 --- a/components/prg32/include/prg32.h +++ b/components/prg32/include/prg32.h @@ -274,7 +274,14 @@ const char *prg32_wifi_current_ssid(void); int prg32_wifi_setup_requested(void); int prg32_wifi_setup_run(void); void prg32_scores_api_start(void); +int prg32_score_player_get(char *out_player, size_t max_len); +int prg32_score_player_set(const char *player); +int prg32_score_player_prompt(void); int prg32_score_submit(const char *game, const char *player, uint32_t score); +int prg32_score_submit_current_player(const char *game, uint32_t score); +int prg32_score_count(const char *game); +int prg32_score_get(const char *game, int index, prg32_score_t *out_score); +int prg32_scoreboard_show(const char *game, const char *title); int prg32_score_submit_remote(const char *base_url, const char *game, const char *player, diff --git a/components/prg32/include/prg32_abi_hash.h b/components/prg32/include/prg32_abi_hash.h index 0ac198f..153a0e9 100644 --- a/components/prg32/include/prg32_abi_hash.h +++ b/components/prg32/include/prg32_abi_hash.h @@ -3,5 +3,5 @@ /* Generated by tools/prg32_abi_gen.py; do not edit manually. */ #define PRG32_ABI_MAJOR 1u -#define PRG32_ABI_MINOR 1u -#define PRG32_ABI_HASH 0xafee0856u +#define PRG32_ABI_MINOR 2u +#define PRG32_ABI_HASH 0x23fced32u diff --git a/components/prg32/include/prg32_abi_index.h b/components/prg32/include/prg32_abi_index.h index c151672..b0cfcb5 100644 --- a/components/prg32/include/prg32_abi_index.h +++ b/components/prg32/include/prg32_abi_index.h @@ -117,5 +117,12 @@ enum { PRG32_ABI_FN_PRG32_SPRITE_ANIM_DRAW = 111, PRG32_ABI_FN_PRG32_SCORE_SUBMIT = 112, PRG32_ABI_FN_PRG32_SPRITE_DRAW_24X24 = 113, - PRG32_ABI_FN_COUNT = 114 + PRG32_ABI_FN_PRG32_SCORE_PLAYER_GET = 114, + PRG32_ABI_FN_PRG32_SCORE_PLAYER_SET = 115, + PRG32_ABI_FN_PRG32_SCORE_PLAYER_PROMPT = 116, + PRG32_ABI_FN_PRG32_SCORE_SUBMIT_CURRENT_PLAYER = 117, + PRG32_ABI_FN_PRG32_SCORE_COUNT = 118, + PRG32_ABI_FN_PRG32_SCORE_GET = 119, + PRG32_ABI_FN_PRG32_SCOREBOARD_SHOW = 120, + PRG32_ABI_FN_COUNT = 121 }; diff --git a/components/prg32/prg32_abi_exports.c b/components/prg32/prg32_abi_exports.c index a3ac733..0a16885 100644 --- a/components/prg32/prg32_abi_exports.c +++ b/components/prg32/prg32_abi_exports.c @@ -149,6 +149,13 @@ static const prg32_any_fn_t g_prg32_cart_abi_exports[] = { (prg32_any_fn_t)prg32_sprite_anim_draw, (prg32_any_fn_t)prg32_score_submit, (prg32_any_fn_t)prg32_sprite_draw_24x24, + (prg32_any_fn_t)prg32_score_player_get, + (prg32_any_fn_t)prg32_score_player_set, + (prg32_any_fn_t)prg32_score_player_prompt, + (prg32_any_fn_t)prg32_score_submit_current_player, + (prg32_any_fn_t)prg32_score_count, + (prg32_any_fn_t)prg32_score_get, + (prg32_any_fn_t)prg32_scoreboard_show, }; void prg32_abi_exports_keep(void) { diff --git a/components/prg32/prg32_abi_table.c b/components/prg32/prg32_abi_table.c index 8cad10c..74711e2 100644 --- a/components/prg32/prg32_abi_table.c +++ b/components/prg32/prg32_abi_table.c @@ -130,5 +130,12 @@ const prg32_abi_table_t prg32_abi_table = { [PRG32_ABI_FN_PRG32_SPRITE_ANIM_DRAW] = (const void *)prg32_sprite_anim_draw, [PRG32_ABI_FN_PRG32_SCORE_SUBMIT] = (const void *)prg32_score_submit, [PRG32_ABI_FN_PRG32_SPRITE_DRAW_24X24] = (const void *)prg32_sprite_draw_24x24, + [PRG32_ABI_FN_PRG32_SCORE_PLAYER_GET] = (const void *)prg32_score_player_get, + [PRG32_ABI_FN_PRG32_SCORE_PLAYER_SET] = (const void *)prg32_score_player_set, + [PRG32_ABI_FN_PRG32_SCORE_PLAYER_PROMPT] = (const void *)prg32_score_player_prompt, + [PRG32_ABI_FN_PRG32_SCORE_SUBMIT_CURRENT_PLAYER] = (const void *)prg32_score_submit_current_player, + [PRG32_ABI_FN_PRG32_SCORE_COUNT] = (const void *)prg32_score_count, + [PRG32_ABI_FN_PRG32_SCORE_GET] = (const void *)prg32_score_get, + [PRG32_ABI_FN_PRG32_SCOREBOARD_SHOW] = (const void *)prg32_scoreboard_show, }, }; diff --git a/components/prg32/prg32_http_scores.c b/components/prg32/prg32_http_scores.c index a874593..3cc169a 100644 --- a/components/prg32/prg32_http_scores.c +++ b/components/prg32/prg32_http_scores.c @@ -1,17 +1,36 @@ #include "prg32.h" #include "prg32_config.h" -#if PRG32_WIFI_ENABLE +#include +#include +#if PRG32_WIFI_ENABLE #include "cJSON.h" -#include "esp_err.h" #include "esp_http_client.h" #include "esp_http_server.h" -#include -#include +#endif + +#if __has_include("esp_err.h") +#include "esp_err.h" +#else +#define ESP_OK 0 +#define ESP_FAIL -1 +#define ESP_ERR_INVALID_ARG -2 +#endif + +#if __has_include("freertos/FreeRTOS.h") +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#else +#define pdMS_TO_TICKS(ms) (ms) +static void vTaskDelay(int ticks) { + (void)ticks; +} +#endif static prg32_score_t scores[PRG32_SCORE_MAX]; static int score_count; +static char current_player[sizeof(scores[0].player)] = "PLAYER"; static void copy_cstr(char *dst, size_t dst_size, const char *src) { if (!dst || dst_size == 0) { @@ -24,6 +43,168 @@ static void copy_cstr(char *dst, size_t dst_size, const char *src) { dst[dst_size - 1] = '\0'; } +static int score_matches_game(const prg32_score_t *record, const char *game) { + return record && (!game || !game[0] || strcmp(record->game, game) == 0); +} + +static int score_visible_index(const char *game, int visible_index) { + int seen = 0; + for (int i = 0; i < score_count; ++i) { + if (!score_matches_game(&scores[i], game)) { + continue; + } + if (seen == visible_index) { + return i; + } + seen++; + } + return -1; +} + +int prg32_score_player_get(char *out_player, size_t max_len) { + if (!out_player || max_len == 0) { + return ESP_ERR_INVALID_ARG; + } + copy_cstr(out_player, max_len, current_player); + return ESP_OK; +} + +int prg32_score_player_set(const char *player) { + if (!player || !player[0]) { + return ESP_ERR_INVALID_ARG; + } + copy_cstr(current_player, sizeof(current_player), player); + return ESP_OK; +} + +int prg32_score_player_prompt(void) { + char player[sizeof(current_player)]; + copy_cstr(player, sizeof(player), current_player); + int len = prg32_text_input(player, sizeof(player), "PLAYER NAME"); + if (len < 0) { + return len; + } + if (player[0]) { + prg32_score_player_set(player); + } + return (int)strlen(current_player); +} + +int prg32_score_submit(const char *game, const char *player, uint32_t score) { + if (!game || !game[0] || !player || !player[0]) { + return ESP_ERR_INVALID_ARG; + } + if (score_count >= PRG32_SCORE_MAX) { + score_count = PRG32_SCORE_MAX - 1; + } + memmove(&scores[1], &scores[0], sizeof(scores[0]) * score_count); + copy_cstr(scores[0].game, sizeof(scores[0].game), game); + copy_cstr(scores[0].player, sizeof(scores[0].player), player); + scores[0].score = score; + if (score_count < PRG32_SCORE_MAX) { + score_count++; + } + return ESP_OK; +} + +int prg32_score_submit_current_player(const char *game, uint32_t score) { + return prg32_score_submit(game, current_player, score); +} + +int prg32_score_count(const char *game) { + int count = 0; + for (int i = 0; i < score_count; ++i) { + if (score_matches_game(&scores[i], game)) { + count++; + } + } + return count; +} + +int prg32_score_get(const char *game, int index, prg32_score_t *out_score) { + if (!out_score || index < 0) { + return ESP_ERR_INVALID_ARG; + } + int raw = score_visible_index(game, index); + if (raw < 0) { + return ESP_FAIL; + } + *out_score = scores[raw]; + return ESP_OK; +} + +static int top_score_index(const char *game, const int *used, int used_count) { + int best = -1; + for (int i = 0; i < score_count; ++i) { + if (!score_matches_game(&scores[i], game)) { + continue; + } + int already_used = 0; + for (int j = 0; j < used_count; ++j) { + if (used[j] == i) { + already_used = 1; + break; + } + } + if (already_used) { + continue; + } + if (best < 0 || + scores[i].score > scores[best].score || + (scores[i].score == scores[best].score && i < best)) { + best = i; + } + } + return best; +} + +int prg32_scoreboard_show(const char *game, const char *title) { + int used[PRG32_SCORE_MAX]; + + 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_A | PRG32_BTN_B | PRG32_BTN_SELECT)) { + prg32_input_wait_released(PRG32_BTN_A | PRG32_BTN_B | PRG32_BTN_SELECT); + return 0; + } + + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, title ? title : "SCOREBOARD", PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, + 24, + game && game[0] ? game : "ALL GAMES", + PRG32_COLOR_CYAN, + 0); + + int used_count = 0; + for (int row = 0; row < 8; ++row) { + int index = top_score_index(game, used, used_count); + if (index < 0) { + if (row == 0) { + prg32_gfx_text8(8, 64, "NO SCORES YET", PRG32_COLOR_YELLOW, 0); + } + break; + } + used[used_count++] = index; + char line[48]; + snprintf(line, + sizeof(line), + "%2d %-16s %lu", + row + 1, + scores[index].player, + (unsigned long)scores[index].score); + prg32_gfx_text8(8, 48 + row * 18, line, PRG32_COLOR_WHITE, 0); + } + + prg32_gfx_text8(8, 224, "A / B / SELECT BACK", PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); + vTaskDelay(pdMS_TO_TICKS(80)); + } +} + +#if PRG32_WIFI_ENABLE + static int copy_json_string(char *dst, size_t dst_size, const char *src) { if (!dst || dst_size == 0 || !src) { return ESP_ERR_INVALID_ARG; @@ -54,23 +235,6 @@ static int copy_json_string(char *dst, size_t dst_size, const char *src) { return ESP_OK; } -int prg32_score_submit(const char *game, const char *player, uint32_t score) { - if (!game || !player) { - return ESP_ERR_INVALID_ARG; - } - if (score_count >= PRG32_SCORE_MAX) { - score_count = PRG32_SCORE_MAX - 1; - } - memmove(&scores[1], &scores[0], sizeof(scores[0]) * score_count); - copy_cstr(scores[0].game, sizeof(scores[0].game), game); - copy_cstr(scores[0].player, sizeof(scores[0].player), player); - scores[0].score = score; - if (score_count < PRG32_SCORE_MAX) { - score_count++; - } - return ESP_OK; -} - int prg32_score_submit_remote(const char *base_url, const char *game, const char *player, @@ -106,7 +270,7 @@ int prg32_score_submit_remote(const char *base_url, esp_http_client_config_t cfg = { .url = url, .method = HTTP_METHOD_POST, - .timeout_ms = 3000 + .timeout_ms = 3000, }; esp_http_client_handle_t client = esp_http_client_init(&cfg); if (!client) { @@ -226,12 +390,12 @@ void prg32_http_register_score_handlers(httpd_handle_t server) { httpd_uri_t gs = { .uri = "/api/scores", .method = HTTP_GET, - .handler = get_scores + .handler = get_scores, }; httpd_uri_t ps = { .uri = "/api/scores", .method = HTTP_POST, - .handler = post_score + .handler = post_score, }; ESP_ERROR_CHECK(httpd_register_uri_handler(server, &gs)); ESP_ERROR_CHECK(httpd_register_uri_handler(server, &ps)); @@ -242,13 +406,6 @@ void prg32_http_register_score_handlers(httpd_handle_t server) { #else -int prg32_score_submit(const char *game, const char *player, uint32_t score) { - (void)game; - (void)player; - (void)score; - return 0; -} - int prg32_score_submit_remote(const char *base_url, const char *game, const char *player, diff --git a/docs/abi.md b/docs/abi.md index 2165b2a..3a927a3 100644 --- a/docs/abi.md +++ b/docs/abi.md @@ -231,6 +231,13 @@ 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_score_player_get` | copy the current scoreboard player name | +| `prg32_score_player_set` | set the current scoreboard player name | +| `prg32_score_player_prompt` | show the on-screen player-name entry UI | +| `prg32_score_submit_current_player` | submit a score for the current player | +| `prg32_score_count` | count local scoreboard records, optionally by game | +| `prg32_score_get` | copy one local scoreboard record | +| `prg32_scoreboard_show` | show the built-in local scoreboard screen | | `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 ebc4af0..f16a2c9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -360,6 +360,10 @@ GET /api/scores Returns the board-local in-RAM scoreboard. +Games can also access the same in-RAM records directly through +`prg32_score_count` and `prg32_score_get`, or show the built-in on-device +scoreboard with `prg32_scoreboard_show`. + Example: ```bash @@ -408,6 +412,8 @@ Success response: Expected behavior: - scores are stored in RAM by the board-local API; +- each score record associates a short game identifier, player name, and + numeric score; - rebooting the board clears board-local scores; - use the external ScoreServer for persistent classroom leaderboards. diff --git a/docs/score_api.md b/docs/score_api.md index 575f1f7..4de80df 100644 --- a/docs/score_api.md +++ b/docs/score_api.md @@ -5,6 +5,10 @@ standalone [ScoreServer](https://github.com/riscv-prg32/ScoreServer). This is useful for competitions and for teaching the boundary between assembly, C, and network services. +Games can use the score feature without Wi-Fi. The local firmware keeps an +in-RAM scoreboard, includes an on-screen player-name prompt, and provides a +simple scoreboard screen that cartridges can call directly. + ## Enable Wi-Fi Edit `main/prg32_config.h`: @@ -50,7 +54,7 @@ Response: ## Assembly usage -Assembly code should normally call a C helper at the end of a game: +Assembly code can submit an explicit player name at the end of a game: ```asm la a0, game_name # const char *game @@ -61,6 +65,29 @@ call prg32_score_submit This is a clean ABI example: arguments in `a0`, `a1`, `a2`, return value in `a0`. +For a cartridge that lets the runtime remember the current player name, call +the prompt once from C or assembly glue before play starts: + +```c +prg32_score_player_prompt(); +``` + +Then submit the score with the remembered player: + +```c +prg32_score_submit_current_player("breakout", score); +``` + +To show the local scoreboard from a game: + +```c +prg32_scoreboard_show("breakout", "BREAKOUT SCORES"); +``` + +The lower-level helpers `prg32_score_player_get`, +`prg32_score_player_set`, `prg32_score_count`, and `prg32_score_get` are also +available to cartridges that want to draw their own scoreboard UI. + ## Remote classroom server Clone and run the standalone Flask + SQLite server: @@ -93,8 +120,9 @@ prg32_score_submit_remote("http://192.168.1.20:5000", ## Current implementation -The board-local implementation stores a small in-RAM scoreboard. The standalone -ScoreServer persists records in SQLite. +The board-local implementation stores a small in-RAM scoreboard. It is cleared +when the firmware restarts. The standalone ScoreServer and Cartridge Store +persist records in SQLite. The same board HTTP server also hosts the cartridge upload API when `PRG32_GAME_UPLOAD_ENABLE` is enabled. See `docs/cartridges.md`. diff --git a/docs/tutorial.md b/docs/tutorial.md index f01e242..bcba7a3 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -127,6 +127,14 @@ li a2, 1200 call prg32_score_submit ``` +Games that want the player to type a name can use the firmware UI from C glue: + +```c +prg32_score_player_prompt(); +prg32_score_submit_current_player("pong", score); +prg32_scoreboard_show("pong", "PONG SCORES"); +``` + For a classroom server, run [ScoreServer](https://github.com/riscv-prg32/ScoreServer) and call `prg32_score_submit_remote` from C glue code or a wrapper. diff --git a/tools/prg32_abi.json b/tools/prg32_abi.json index 11d6541..a8a2179 100644 --- a/tools/prg32_abi.json +++ b/tools/prg32_abi.json @@ -1,7 +1,7 @@ { "name": "prg32-cart-abi", "major": 1, - "minor": 1, + "minor": 2, "functions": [ { "name": "prg32_ticks_ms", @@ -800,6 +800,55 @@ "args": [], "feature": "sprites", "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_score_player_get", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_score_player_set", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_score_player_prompt", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_score_submit_current_player", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_score_count", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_score_get", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, + { + "name": "prg32_scoreboard_show", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." } ] } diff --git a/tools/prg32_abi_generated.py b/tools/prg32_abi_generated.py index a798ab6..f01e37c 100644 --- a/tools/prg32_abi_generated.py +++ b/tools/prg32_abi_generated.py @@ -1,8 +1,8 @@ # Generated by tools/prg32_abi_gen.py; do not edit manually. ABI_MAJOR = 1 -ABI_MINOR = 1 -ABI_HASH = 0xafee0856 +ABI_MINOR = 2 +ABI_HASH = 0x23fced32 IMPORT_NAMES = [ 'prg32_ticks_ms', 'prg32_input_read', @@ -118,6 +118,13 @@ 'prg32_sprite_anim_draw', 'prg32_score_submit', 'prg32_sprite_draw_24x24', + 'prg32_score_player_get', + 'prg32_score_player_set', + 'prg32_score_player_prompt', + 'prg32_score_submit_current_player', + 'prg32_score_count', + 'prg32_score_get', + 'prg32_scoreboard_show', ] FEATURE_BITS = { From 02d75cbcb260844d4c4c7e22f52a72b239dadf1d Mon Sep 17 00:00:00 2001 From: raffmont Date: Sun, 14 Jun 2026 20:15:14 +0200 Subject: [PATCH 4/5] Keep local top scores before sync --- components/prg32/include/prg32.h | 2 + components/prg32/include/prg32_abi_hash.h | 2 +- components/prg32/include/prg32_abi_index.h | 9 +- components/prg32/prg32_abi_exports.c | 1 + components/prg32/prg32_abi_table.c | 1 + components/prg32/prg32_http_scores.c | 190 +++++++++++++++------ docs/abi.md | 1 + docs/api.md | 7 +- docs/score_api.md | 18 +- tools/prg32_abi.json | 7 + tools/prg32_abi_generated.py | 3 +- 11 files changed, 177 insertions(+), 64 deletions(-) diff --git a/components/prg32/include/prg32.h b/components/prg32/include/prg32.h index bc27c05..b6538ac 100644 --- a/components/prg32/include/prg32.h +++ b/components/prg32/include/prg32.h @@ -19,6 +19,7 @@ extern "C" { #define PRG32_LCD_H 240 #define PRG32_GAME_W 320 #define PRG32_GAME_H 200 +#define PRG32_SCOREBOARD_TOP_MAX 5 #define PRG32_TEXT_COLS 40 #define PRG32_TEXT_ROWS 25 #define PRG32_TILE_W 8 @@ -279,6 +280,7 @@ int prg32_score_player_set(const char *player); int prg32_score_player_prompt(void); int prg32_score_submit(const char *game, const char *player, uint32_t score); int prg32_score_submit_current_player(const char *game, uint32_t score); +int prg32_score_sync_remote(void); int prg32_score_count(const char *game); int prg32_score_get(const char *game, int index, prg32_score_t *out_score); int prg32_scoreboard_show(const char *game, const char *title); diff --git a/components/prg32/include/prg32_abi_hash.h b/components/prg32/include/prg32_abi_hash.h index 153a0e9..033c302 100644 --- a/components/prg32/include/prg32_abi_hash.h +++ b/components/prg32/include/prg32_abi_hash.h @@ -4,4 +4,4 @@ #define PRG32_ABI_MAJOR 1u #define PRG32_ABI_MINOR 2u -#define PRG32_ABI_HASH 0x23fced32u +#define PRG32_ABI_HASH 0xec21efe2u diff --git a/components/prg32/include/prg32_abi_index.h b/components/prg32/include/prg32_abi_index.h index b0cfcb5..88db5b6 100644 --- a/components/prg32/include/prg32_abi_index.h +++ b/components/prg32/include/prg32_abi_index.h @@ -121,8 +121,9 @@ enum { PRG32_ABI_FN_PRG32_SCORE_PLAYER_SET = 115, PRG32_ABI_FN_PRG32_SCORE_PLAYER_PROMPT = 116, PRG32_ABI_FN_PRG32_SCORE_SUBMIT_CURRENT_PLAYER = 117, - PRG32_ABI_FN_PRG32_SCORE_COUNT = 118, - PRG32_ABI_FN_PRG32_SCORE_GET = 119, - PRG32_ABI_FN_PRG32_SCOREBOARD_SHOW = 120, - PRG32_ABI_FN_COUNT = 121 + PRG32_ABI_FN_PRG32_SCORE_SYNC_REMOTE = 118, + PRG32_ABI_FN_PRG32_SCORE_COUNT = 119, + PRG32_ABI_FN_PRG32_SCORE_GET = 120, + PRG32_ABI_FN_PRG32_SCOREBOARD_SHOW = 121, + PRG32_ABI_FN_COUNT = 122 }; diff --git a/components/prg32/prg32_abi_exports.c b/components/prg32/prg32_abi_exports.c index 0a16885..1938582 100644 --- a/components/prg32/prg32_abi_exports.c +++ b/components/prg32/prg32_abi_exports.c @@ -153,6 +153,7 @@ static const prg32_any_fn_t g_prg32_cart_abi_exports[] = { (prg32_any_fn_t)prg32_score_player_set, (prg32_any_fn_t)prg32_score_player_prompt, (prg32_any_fn_t)prg32_score_submit_current_player, + (prg32_any_fn_t)prg32_score_sync_remote, (prg32_any_fn_t)prg32_score_count, (prg32_any_fn_t)prg32_score_get, (prg32_any_fn_t)prg32_scoreboard_show, diff --git a/components/prg32/prg32_abi_table.c b/components/prg32/prg32_abi_table.c index 74711e2..a9f0478 100644 --- a/components/prg32/prg32_abi_table.c +++ b/components/prg32/prg32_abi_table.c @@ -134,6 +134,7 @@ const prg32_abi_table_t prg32_abi_table = { [PRG32_ABI_FN_PRG32_SCORE_PLAYER_SET] = (const void *)prg32_score_player_set, [PRG32_ABI_FN_PRG32_SCORE_PLAYER_PROMPT] = (const void *)prg32_score_player_prompt, [PRG32_ABI_FN_PRG32_SCORE_SUBMIT_CURRENT_PLAYER] = (const void *)prg32_score_submit_current_player, + [PRG32_ABI_FN_PRG32_SCORE_SYNC_REMOTE] = (const void *)prg32_score_sync_remote, [PRG32_ABI_FN_PRG32_SCORE_COUNT] = (const void *)prg32_score_count, [PRG32_ABI_FN_PRG32_SCORE_GET] = (const void *)prg32_score_get, [PRG32_ABI_FN_PRG32_SCOREBOARD_SHOW] = (const void *)prg32_scoreboard_show, diff --git a/components/prg32/prg32_http_scores.c b/components/prg32/prg32_http_scores.c index 3cc169a..508d454 100644 --- a/components/prg32/prg32_http_scores.c +++ b/components/prg32/prg32_http_scores.c @@ -18,6 +18,10 @@ #define ESP_ERR_INVALID_ARG -2 #endif +#ifndef CONFIG_PRG32_STORE_URL +#define CONFIG_PRG32_STORE_URL "" +#endif + #if __has_include("freertos/FreeRTOS.h") #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -28,9 +32,18 @@ static void vTaskDelay(int ticks) { } #endif -static prg32_score_t scores[PRG32_SCORE_MAX]; +typedef struct { + prg32_score_t score; + uint8_t pending_remote; +} score_entry_t; + +static score_entry_t scores[PRG32_SCORE_MAX]; static int score_count; -static char current_player[sizeof(scores[0].player)] = "PLAYER"; +static char current_player[sizeof(scores[0].score.player)] = "PLAYER"; + +#if PRG32_WIFI_ENABLE +static int resolve_configured_store_url(char *out_url, size_t max_len); +#endif static void copy_cstr(char *dst, size_t dst_size, const char *src) { if (!dst || dst_size == 0) { @@ -47,10 +60,14 @@ static int score_matches_game(const prg32_score_t *record, const char *game) { return record && (!game || !game[0] || strcmp(record->game, game) == 0); } +static int score_entry_matches_game(const score_entry_t *entry, const char *game) { + return entry && score_matches_game(&entry->score, game); +} + static int score_visible_index(const char *game, int visible_index) { int seen = 0; for (int i = 0; i < score_count; ++i) { - if (!score_matches_game(&scores[i], game)) { + if (!score_entry_matches_game(&scores[i], game)) { continue; } if (seen == visible_index) { @@ -94,16 +111,71 @@ int prg32_score_submit(const char *game, const char *player, uint32_t score) { if (!game || !game[0] || !player || !player[0]) { return ESP_ERR_INVALID_ARG; } - if (score_count >= PRG32_SCORE_MAX) { - score_count = PRG32_SCORE_MAX - 1; + + int insert_at = score_count; + int game_seen = 0; + int game_insert_rank = 0; + for (int i = 0; i < score_count; ++i) { + if (!score_entry_matches_game(&scores[i], game)) { + continue; + } + if (score <= scores[i].score.score) { + game_insert_rank++; + game_seen++; + continue; + } + insert_at = i; + break; } - memmove(&scores[1], &scores[0], sizeof(scores[0]) * score_count); - copy_cstr(scores[0].game, sizeof(scores[0].game), game); - copy_cstr(scores[0].player, sizeof(scores[0].player), player); - scores[0].score = score; + if (insert_at == score_count) { + game_insert_rank = game_seen; + } + + if (game_insert_rank >= PRG32_SCOREBOARD_TOP_MAX) { + return ESP_OK; + } + + int drop_at = -1; + int game_rank = 0; + for (int i = 0; i < score_count; ++i) { + if (!score_entry_matches_game(&scores[i], game)) { + continue; + } + if (game_rank == PRG32_SCOREBOARD_TOP_MAX - 1) { + drop_at = i; + break; + } + game_rank++; + } + + if (drop_at >= 0) { + memmove(&scores[drop_at], + &scores[drop_at + 1], + sizeof(scores[0]) * (score_count - drop_at - 1)); + score_count--; + if (insert_at > drop_at) { + insert_at--; + } + } else if (score_count >= PRG32_SCORE_MAX) { + score_count--; + if (insert_at > score_count) { + insert_at = score_count; + } + } + + memmove(&scores[insert_at + 1], + &scores[insert_at], + sizeof(scores[0]) * (score_count - insert_at)); + copy_cstr(scores[insert_at].score.game, sizeof(scores[insert_at].score.game), game); + copy_cstr(scores[insert_at].score.player, + sizeof(scores[insert_at].score.player), + player); + scores[insert_at].score.score = score; + scores[insert_at].pending_remote = 1; if (score_count < PRG32_SCORE_MAX) { score_count++; } + prg32_score_sync_remote(); return ESP_OK; } @@ -111,10 +183,36 @@ int prg32_score_submit_current_player(const char *game, uint32_t score) { return prg32_score_submit(game, current_player, score); } +int prg32_score_sync_remote(void) { +#if PRG32_WIFI_ENABLE + char store_url[128]; + if (resolve_configured_store_url(store_url, sizeof(store_url)) != 0) { + return 0; + } + + int synced = 0; + for (int i = 0; i < score_count; ++i) { + if (!scores[i].pending_remote) { + continue; + } + if (prg32_score_submit_remote(store_url, + scores[i].score.game, + scores[i].score.player, + scores[i].score.score) == ESP_OK) { + scores[i].pending_remote = 0; + synced++; + } + } + return synced; +#else + return 0; +#endif +} + int prg32_score_count(const char *game) { int count = 0; for (int i = 0; i < score_count; ++i) { - if (score_matches_game(&scores[i], game)) { + if (score_entry_matches_game(&scores[i], game)) { count++; } } @@ -129,38 +227,11 @@ int prg32_score_get(const char *game, int index, prg32_score_t *out_score) { if (raw < 0) { return ESP_FAIL; } - *out_score = scores[raw]; + *out_score = scores[raw].score; return ESP_OK; } -static int top_score_index(const char *game, const int *used, int used_count) { - int best = -1; - for (int i = 0; i < score_count; ++i) { - if (!score_matches_game(&scores[i], game)) { - continue; - } - int already_used = 0; - for (int j = 0; j < used_count; ++j) { - if (used[j] == i) { - already_used = 1; - break; - } - } - if (already_used) { - continue; - } - if (best < 0 || - scores[i].score > scores[best].score || - (scores[i].score == scores[best].score && i < best)) { - best = i; - } - } - return best; -} - int prg32_scoreboard_show(const char *game, const char *title) { - int used[PRG32_SCORE_MAX]; - prg32_input_wait_released(PRG32_BTN_A | PRG32_BTN_B | PRG32_BTN_SELECT); while (1) { uint32_t input = prg32_input_read_menu(); @@ -177,23 +248,25 @@ int prg32_scoreboard_show(const char *game, const char *title) { PRG32_COLOR_CYAN, 0); - int used_count = 0; - for (int row = 0; row < 8; ++row) { - int index = top_score_index(game, used, used_count); - if (index < 0) { - if (row == 0) { - prg32_gfx_text8(8, 64, "NO SCORES YET", PRG32_COLOR_YELLOW, 0); - } + int shown = prg32_score_count(game); + if (shown == 0) { + prg32_gfx_text8(8, 64, "NO SCORES YET", PRG32_COLOR_YELLOW, 0); + } + if (shown > PRG32_SCOREBOARD_TOP_MAX) { + shown = PRG32_SCOREBOARD_TOP_MAX; + } + for (int row = 0; row < shown; ++row) { + prg32_score_t score; + if (prg32_score_get(game, row, &score) != ESP_OK) { break; } - used[used_count++] = index; char line[48]; snprintf(line, sizeof(line), "%2d %-16s %lu", row + 1, - scores[index].player, - (unsigned long)scores[index].score); + score.player, + (unsigned long)score.score); prg32_gfx_text8(8, 48 + row * 18, line, PRG32_COLOR_WHITE, 0); } @@ -205,6 +278,21 @@ int prg32_scoreboard_show(const char *game, const char *title) { #if PRG32_WIFI_ENABLE +static int resolve_configured_store_url(char *out_url, size_t max_len) { + if (!out_url || max_len == 0) { + return ESP_ERR_INVALID_ARG; + } + if (prg32_store_url_get(out_url, max_len) == 0) { + return ESP_OK; + } + if (CONFIG_PRG32_STORE_URL[0]) { + copy_cstr(out_url, max_len, CONFIG_PRG32_STORE_URL); + return ESP_OK; + } + out_url[0] = '\0'; + return ESP_FAIL; +} + static int copy_json_string(char *dst, size_t dst_size, const char *src) { if (!dst || dst_size == 0 || !src) { return ESP_ERR_INVALID_ARG; @@ -303,9 +391,9 @@ static esp_err_t get_scores(httpd_req_t *req) { httpd_resp_send_err(req, 500, "out of memory"); return ESP_ERR_NO_MEM; } - cJSON_AddStringToObject(o, "game", scores[i].game); - cJSON_AddStringToObject(o, "player", scores[i].player); - cJSON_AddNumberToObject(o, "score", scores[i].score); + cJSON_AddStringToObject(o, "game", scores[i].score.game); + cJSON_AddStringToObject(o, "player", scores[i].score.player); + cJSON_AddNumberToObject(o, "score", scores[i].score.score); cJSON_AddItemToArray(root, o); } char *json = cJSON_PrintUnformatted(root); diff --git a/docs/abi.md b/docs/abi.md index 3a927a3..5549a28 100644 --- a/docs/abi.md +++ b/docs/abi.md @@ -235,6 +235,7 @@ Setup screens and cartridge programs use the same button bitmasks: | `prg32_score_player_set` | set the current scoreboard player name | | `prg32_score_player_prompt` | show the on-screen player-name entry UI | | `prg32_score_submit_current_player` | submit a score for the current player | +| `prg32_score_sync_remote` | retry pending local scores against the configured Cartridge Store | | `prg32_score_count` | count local scoreboard records, optionally by game | | `prg32_score_get` | copy one local scoreboard record | | `prg32_scoreboard_show` | show the built-in local scoreboard screen | diff --git a/docs/api.md b/docs/api.md index f16a2c9..741c03e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -358,7 +358,9 @@ Expected behavior: GET /api/scores ``` -Returns the board-local in-RAM scoreboard. +Returns the board-local in-RAM scoreboard. The firmware keeps the five best +local records for each game even when no Cartridge Store URL has been +configured. Games can also access the same in-RAM records directly through `prg32_score_count` and `prg32_score_get`, or show the built-in on-device @@ -412,8 +414,11 @@ Success response: Expected behavior: - scores are stored in RAM by the board-local API; +- only the five best local records for each game are kept; - each score record associates a short game identifier, player name, and numeric score; +- pending local records can be retried with `prg32_score_sync_remote` once a + Cartridge Store URL is configured; - rebooting the board clears board-local scores; - use the external ScoreServer for persistent classroom leaderboards. diff --git a/docs/score_api.md b/docs/score_api.md index 4de80df..d4247ef 100644 --- a/docs/score_api.md +++ b/docs/score_api.md @@ -5,9 +5,9 @@ standalone [ScoreServer](https://github.com/riscv-prg32/ScoreServer). This is useful for competitions and for teaching the boundary between assembly, C, and network services. -Games can use the score feature without Wi-Fi. The local firmware keeps an -in-RAM scoreboard, includes an on-screen player-name prompt, and provides a -simple scoreboard screen that cartridges can call directly. +Games can use the score feature without Wi-Fi. The local firmware keeps the +five best in-RAM scores for each game, includes an on-screen player-name prompt, +and provides a simple scoreboard screen that cartridges can call directly. ## Enable Wi-Fi @@ -88,6 +88,12 @@ The lower-level helpers `prg32_score_player_get`, `prg32_score_player_set`, `prg32_score_count`, and `prg32_score_get` are also available to cartridges that want to draw their own scoreboard UI. +When a Cartridge Store URL has been saved in setup or compiled with +`CONFIG_PRG32_STORE_URL`, local submissions are also marked for remote upload. +`prg32_score_sync_remote()` retries pending local top-five records without +requiring the game to know whether a store has been configured yet. If no store +URL exists, the local scoreboard still works normally. + ## Remote classroom server Clone and run the standalone Flask + SQLite server: @@ -120,9 +126,9 @@ prg32_score_submit_remote("http://192.168.1.20:5000", ## Current implementation -The board-local implementation stores a small in-RAM scoreboard. It is cleared -when the firmware restarts. The standalone ScoreServer and Cartridge Store -persist records in SQLite. +The board-local implementation stores the top five in-RAM scores per game. It is +cleared when the firmware restarts. The standalone ScoreServer and Cartridge +Store persist uploaded records in SQLite. The same board HTTP server also hosts the cartridge upload API when `PRG32_GAME_UPLOAD_ENABLE` is enabled. See `docs/cartridges.md`. diff --git a/tools/prg32_abi.json b/tools/prg32_abi.json index a8a2179..6796bb9 100644 --- a/tools/prg32_abi.json +++ b/tools/prg32_abi.json @@ -829,6 +829,13 @@ "feature": "core", "notes": "PRG32 cartridge ABI entry point." }, + { + "name": "prg32_score_sync_remote", + "return": "void", + "args": [], + "feature": "core", + "notes": "PRG32 cartridge ABI entry point." + }, { "name": "prg32_score_count", "return": "void", diff --git a/tools/prg32_abi_generated.py b/tools/prg32_abi_generated.py index f01e37c..d02137c 100644 --- a/tools/prg32_abi_generated.py +++ b/tools/prg32_abi_generated.py @@ -2,7 +2,7 @@ ABI_MAJOR = 1 ABI_MINOR = 2 -ABI_HASH = 0x23fced32 +ABI_HASH = 0xec21efe2 IMPORT_NAMES = [ 'prg32_ticks_ms', 'prg32_input_read', @@ -122,6 +122,7 @@ 'prg32_score_player_set', 'prg32_score_player_prompt', 'prg32_score_submit_current_player', + 'prg32_score_sync_remote', 'prg32_score_count', 'prg32_score_get', 'prg32_scoreboard_show', From 14420683c12d0467aa8a0127e41e1d700d3edbec Mon Sep 17 00:00:00 2001 From: raffmont Date: Mon, 15 Jun 2026 07:25:58 +0200 Subject: [PATCH 5/5] Update cartridge slots and setup actions --- README.md | 7 ++ components/prg32/Kconfig | 5 +- components/prg32/include/prg32.h | 6 +- components/prg32/prg32_cart.c | 35 ++++++ components/prg32/prg32_http_scores.c | 132 +++++++++++++++++++++++ components/prg32/prg32_system.c | 116 +++++++++++++++++++- docs/api.md | 18 ++-- docs/assets.md | 2 +- docs/cartridges.md | 64 ++++++----- docs/deployment.md | 10 +- docs/framework_manual.md | 15 +-- docs/getting_started_game_development.md | 4 +- docs/score_api.md | 14 ++- docs/setup_mode_cartridge_store.md | 2 +- partitions_prg32.csv | 7 +- sdkconfig.defaults.qemu | 1 + tools/prg32_game.py | 4 +- 17 files changed, 372 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 0e8f6a0..95cbda3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ PRG32 is an educational runtime for RISC-V assembly and C games. +The resident firmware keeps games decoupled from the default app: cartridges +are stored in four 128 KiB flash slots, one selected cartridge is copied into +the configured executable RAM window, and local scoreboard records persist in a +dedicated flash partition until they can be synchronized with Cartridge Store or +ScoreServer APIs. Physical classroom builds default to a smaller RAM window to +preserve setup/Wi-Fi heap; QEMU keeps the 64 KiB extended profile. + ## Academic Profile - Project domain: Embedded Systems and Computer Architecture Education diff --git a/components/prg32/Kconfig b/components/prg32/Kconfig index 57b1b74..bd10bdc 100644 --- a/components/prg32/Kconfig +++ b/components/prg32/Kconfig @@ -84,8 +84,9 @@ config PRG32_CART_RAM_CLASSROOM 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. + Reserve a larger cartridge execution window. QEMU uses this profile by + default; physical builds can enable it for labs that intentionally need + larger linked cartridge images. config PRG32_CART_RAM_CUSTOM bool "Custom profile" diff --git a/components/prg32/include/prg32.h b/components/prg32/include/prg32.h index b6538ac..db4ed95 100644 --- a/components/prg32/include/prg32.h +++ b/components/prg32/include/prg32.h @@ -104,13 +104,13 @@ extern "C" { #define PRG32_CART_ARCH_ESP32C6 "esp32c6" #define PRG32_CART_ARCH_QEMU "qemu" #define PRG32_CART_LOAD_ADDR 0x40800000u -#define PRG32_CART_MAX_SIZE (64u * 1024u) +#define PRG32_CART_MAX_SIZE (128u * 1024u) #ifndef CONFIG_PRG32_CART_RAM_KIB #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 -#define PRG32_CART_SLOT_COUNT 2 +#define PRG32_CART_SLOT_COUNT 4 #ifndef PRG32_FIRMWARE_VERSION #define PRG32_FIRMWARE_VERSION "dev" #endif @@ -281,6 +281,7 @@ int prg32_score_player_prompt(void); int prg32_score_submit(const char *game, const char *player, uint32_t score); int prg32_score_submit_current_player(const char *game, uint32_t score); int prg32_score_sync_remote(void); +int prg32_score_reset_local(const char *game); int prg32_score_count(const char *game); int prg32_score_get(const char *game, int index, prg32_score_t *out_score); int prg32_scoreboard_show(const char *game, const char *title); @@ -311,6 +312,7 @@ 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); +int prg32_cart_erase_slot(uint8_t slot); 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); diff --git a/components/prg32/prg32_cart.c b/components/prg32/prg32_cart.c index f8a05a7..00a51c3 100644 --- a/components/prg32/prg32_cart.c +++ b/components/prg32/prg32_cart.c @@ -41,10 +41,14 @@ uint8_t prg32_cart_exec[PRG32_CART_RAM_SIZE] __attribute__((section(".iram1.data static const char *const g_cart_labels[PRG32_CART_SLOT_COUNT] = { "cart0", "cart1", + "cart2", + "cart3", }; static const uint8_t g_cart_subtypes[PRG32_CART_SLOT_COUNT] = { 0x40, 0x41, + 0x42, + 0x43, }; static const esp_partition_t *g_cart_partitions[PRG32_CART_SLOT_COUNT]; @@ -613,6 +617,37 @@ int prg32_cart_store_slot(uint8_t slot, const void *image, size_t image_size) { return 0; } +int prg32_cart_erase_slot(uint8_t slot) { + 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 (lock_cart() != 0) { + set_error("failed to lock cartridge storage"); + return -1; + } + esp_err_t err = esp_partition_erase_range(part, 0, part->size); + if (err == ESP_OK && g_current_slot == slot) { + g_stored = false; + } + unlock_cart(); + if (err != ESP_OK) { + set_errorf("failed to erase cartridge slot: %s", esp_err_to_name(err)); + return -1; + } + if (prg32_cart_default_slot() == (int)slot) { + prg32_cart_set_default_slot(-1); + } + set_error("ok"); + ESP_LOGI(TAG, "cart erase: done %s", slot_name(slot)); + 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; diff --git a/components/prg32/prg32_http_scores.c b/components/prg32/prg32_http_scores.c index 508d454..59a0af0 100644 --- a/components/prg32/prg32_http_scores.c +++ b/components/prg32/prg32_http_scores.c @@ -9,6 +9,8 @@ #include "esp_http_client.h" #include "esp_http_server.h" #endif +#include "nvs.h" +#include "nvs_flash.h" #if __has_include("esp_err.h") #include "esp_err.h" @@ -37,9 +39,25 @@ typedef struct { uint8_t pending_remote; } score_entry_t; +#define SCORE_NVS_PARTITION "scores" +#define SCORE_NVS_NAMESPACE "prg32score" +#define SCORE_NVS_MAGIC 0x50533332u +#define SCORE_NVS_VERSION 1u + +typedef struct { + uint32_t magic; + uint16_t version; + uint16_t count; + char current_player[sizeof(((prg32_score_t *)0)->player)]; + score_entry_t entries[PRG32_SCORE_MAX]; +} score_store_t; + static score_entry_t scores[PRG32_SCORE_MAX]; static int score_count; static char current_player[sizeof(scores[0].score.player)] = "PLAYER"; +static int scores_loaded; +static int scores_persistence_available = 1; +static int scores_nvs_ready; #if PRG32_WIFI_ENABLE static int resolve_configured_store_url(char *out_url, size_t max_len); @@ -78,10 +96,91 @@ static int score_visible_index(const char *game, int visible_index) { return -1; } +static esp_err_t scores_nvs_init(void) { + if (scores_nvs_ready) { + return ESP_OK; + } + if (!scores_persistence_available) { + return ESP_FAIL; + } + esp_err_t err = nvs_flash_init_partition(SCORE_NVS_PARTITION); + if (err == ESP_ERR_NVS_NO_FREE_PAGES || + err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase_partition(SCORE_NVS_PARTITION)); + err = nvs_flash_init_partition(SCORE_NVS_PARTITION); + } + if (err != ESP_OK) { + scores_persistence_available = 0; + } else { + scores_nvs_ready = 1; + } + return err; +} + +static void scores_load_once(void) { + if (scores_loaded) { + return; + } + scores_loaded = 1; + if (scores_nvs_init() != ESP_OK) { + return; + } + + nvs_handle_t nvs; + if (nvs_open_from_partition(SCORE_NVS_PARTITION, + SCORE_NVS_NAMESPACE, + NVS_READONLY, + &nvs) != ESP_OK) { + return; + } + score_store_t store; + size_t size = sizeof(store); + esp_err_t err = nvs_get_blob(nvs, "scores", &store, &size); + nvs_close(nvs); + if (err != ESP_OK || + size != sizeof(store) || + store.magic != SCORE_NVS_MAGIC || + store.version != SCORE_NVS_VERSION || + store.count > PRG32_SCORE_MAX) { + return; + } + + score_count = (int)store.count; + memcpy(scores, store.entries, sizeof(scores)); + copy_cstr(current_player, sizeof(current_player), store.current_player); +} + +static void scores_save(void) { + if (!scores_loaded || scores_nvs_init() != ESP_OK) { + return; + } + + score_store_t store; + memset(&store, 0, sizeof(store)); + store.magic = SCORE_NVS_MAGIC; + store.version = SCORE_NVS_VERSION; + store.count = (uint16_t)score_count; + copy_cstr(store.current_player, sizeof(store.current_player), current_player); + memcpy(store.entries, scores, sizeof(scores)); + + nvs_handle_t nvs; + if (nvs_open_from_partition(SCORE_NVS_PARTITION, + SCORE_NVS_NAMESPACE, + NVS_READWRITE, + &nvs) != ESP_OK) { + return; + } + if (nvs_set_blob(nvs, "scores", &store, sizeof(store)) == ESP_OK) { + nvs_commit(nvs); + } + nvs_close(nvs); +} + int prg32_score_player_get(char *out_player, size_t max_len) { if (!out_player || max_len == 0) { return ESP_ERR_INVALID_ARG; } + scores_load_once(); copy_cstr(out_player, max_len, current_player); return ESP_OK; } @@ -90,7 +189,9 @@ int prg32_score_player_set(const char *player) { if (!player || !player[0]) { return ESP_ERR_INVALID_ARG; } + scores_load_once(); copy_cstr(current_player, sizeof(current_player), player); + scores_save(); return ESP_OK; } @@ -111,6 +212,7 @@ int prg32_score_submit(const char *game, const char *player, uint32_t score) { if (!game || !game[0] || !player || !player[0]) { return ESP_ERR_INVALID_ARG; } + scores_load_once(); int insert_at = score_count; int game_seen = 0; @@ -175,6 +277,7 @@ int prg32_score_submit(const char *game, const char *player, uint32_t score) { if (score_count < PRG32_SCORE_MAX) { score_count++; } + scores_save(); prg32_score_sync_remote(); return ESP_OK; } @@ -184,6 +287,7 @@ int prg32_score_submit_current_player(const char *game, uint32_t score) { } int prg32_score_sync_remote(void) { + scores_load_once(); #if PRG32_WIFI_ENABLE char store_url[128]; if (resolve_configured_store_url(store_url, sizeof(store_url)) != 0) { @@ -203,13 +307,39 @@ int prg32_score_sync_remote(void) { synced++; } } + if (synced > 0) { + scores_save(); + } return synced; #else return 0; #endif } +int prg32_score_reset_local(const char *game) { + scores_load_once(); + int write = 0; + int removed = 0; + for (int read = 0; read < score_count; ++read) { + if (score_entry_matches_game(&scores[read], game)) { + removed++; + continue; + } + if (write != read) { + scores[write] = scores[read]; + } + write++; + } + if (removed > 0) { + memset(&scores[write], 0, sizeof(scores[0]) * (PRG32_SCORE_MAX - write)); + score_count = write; + scores_save(); + } + return removed; +} + int prg32_score_count(const char *game) { + scores_load_once(); int count = 0; for (int i = 0; i < score_count; ++i) { if (score_entry_matches_game(&scores[i], game)) { @@ -223,6 +353,7 @@ int prg32_score_get(const char *game, int index, prg32_score_t *out_score) { if (!out_score || index < 0) { return ESP_ERR_INVALID_ARG; } + scores_load_once(); int raw = score_visible_index(game, index); if (raw < 0) { return ESP_FAIL; @@ -232,6 +363,7 @@ int prg32_score_get(const char *game, int index, prg32_score_t *out_score) { } int prg32_scoreboard_show(const char *game, const char *title) { + scores_load_once(); prg32_input_wait_released(PRG32_BTN_A | PRG32_BTN_B | PRG32_BTN_SELECT); while (1) { uint32_t input = prg32_input_read_menu(); diff --git a/components/prg32/prg32_system.c b/components/prg32/prg32_system.c index 25eddd7..ea77346 100644 --- a/components/prg32/prg32_system.c +++ b/components/prg32/prg32_system.c @@ -195,6 +195,96 @@ static void draw_cartridge_status(int y) { prg32_gfx_text8(8, y + 16, line, PRG32_COLOR_CYAN, 0); } +typedef enum { + CART_ACTION_NONE, + CART_ACTION_RUN, + CART_ACTION_REFRESH, +} cart_action_result_t; + +static cart_action_result_t cartridge_context_menu(uint8_t slot) { + static const char *const actions[] = { + "RUN CARTRIDGE", + "RESET LOCAL SCOREBOARD", + "REMOVE FROM SLOT", + }; + const int action_count = (int)(sizeof(actions) / sizeof(actions[0])); + int choice = 0; + prg32_cart_info_t info; + prg32_cart_get_slot_info(slot, &info); + + prg32_input_wait_released(SETUP_KEYS); + uint32_t last = 0; + 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 + 1 < action_count) { + choice++; + } + if ((input & PRG32_BTN_A) && !(last & PRG32_BTN_A)) { + prg32_input_wait_released(SETUP_CANCEL); + return CART_ACTION_NONE; + } + if (((input & PRG32_BTN_SELECT) && !(last & PRG32_BTN_SELECT)) || + ((input & PRG32_BTN_B) && !(last & PRG32_BTN_B))) { + prg32_input_wait_released(SETUP_ACCEPT); + if (choice == 0) { + return CART_ACTION_RUN; + } + if (choice == 1) { + int removed = prg32_score_reset_local(info.name[0] ? info.name : NULL); + char line[48]; + snprintf(line, + sizeof(line), + removed == 1 ? "1 SCORE REMOVED" : "%d SCORES REMOVED", + removed); + show_setup_message("LOCAL SCOREBOARD", + line, + PRG32_COLOR_GREEN, + 1000); + return CART_ACTION_NONE; + } + if (prg32_cart_erase_slot(slot) == 0) { + show_setup_message("RUN CARTRIDGE", + "CARTRIDGE REMOVED", + PRG32_COLOR_GREEN, + 1000); + return CART_ACTION_REFRESH; + } + show_setup_message("RUN CARTRIDGE", + prg32_cart_last_error(), + PRG32_COLOR_RED, + 1400); + return CART_ACTION_NONE; + } + + prg32_gfx_clear(PRG32_COLOR_BLACK); + prg32_gfx_text8(8, 8, "CARTRIDGE MENU", PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, 28, info.slot_name, PRG32_COLOR_CYAN, 0); + prg32_gfx_text8(72, + 28, + info.name[0] ? info.name : "(unnamed)", + PRG32_COLOR_WHITE, + 0); + for (int i = 0; i < action_count; ++i) { + int y = 64 + i * 22; + prg32_gfx_text8(24, y, actions[i], PRG32_COLOR_WHITE, 0); + prg32_gfx_text8(8, + y, + i == choice ? ">" : " ", + PRG32_COLOR_GREEN, + 0); + } + prg32_gfx_text8(8, 216, "UP/DOWN CHOOSE SELECT/B OK A BACK", PRG32_COLOR_CYAN, 0); + prg32_gfx_present(); + last = input; + vTaskDelay(pdMS_TO_TICKS(80)); + } +} + static int cartridge_picker(const char *title, bool force_choice) { uint8_t slots[PRG32_CART_SLOT_COUNT]; int count = stored_slots(slots, PRG32_CART_SLOT_COUNT); @@ -226,12 +316,28 @@ static int cartridge_picker(const char *title, bool force_choice) { choice + 1 < count) { choice++; } - if (((input & PRG32_BTN_SELECT) && !(last & PRG32_BTN_SELECT)) || - ((input & PRG32_BTN_B) && !(last & PRG32_BTN_B))) { - prg32_input_wait_released(SETUP_ACCEPT); + if ((input & PRG32_BTN_SELECT) && !(last & PRG32_BTN_SELECT)) { + cart_action_result_t action = cartridge_context_menu(slots[choice]); + if (action == CART_ACTION_RUN) { + return prg32_cart_select_slot(slots[choice]); + } + if (action == CART_ACTION_REFRESH) { + count = stored_slots(slots, PRG32_CART_SLOT_COUNT); + if (count <= 0) { + return -1; + } + if (choice >= count) { + choice = count - 1; + } + } + last = 0; + continue; + } + if ((input & PRG32_BTN_B) && !(last & PRG32_BTN_B)) { + prg32_input_wait_released(PRG32_BTN_B); return prg32_cart_select_slot(slots[choice]); } - if (!force_choice && (input & PRG32_BTN_A) && !(last & PRG32_BTN_A)) { + if ((input & PRG32_BTN_A) && !(last & PRG32_BTN_A)) { prg32_input_wait_released(SETUP_CANCEL); return -1; } @@ -255,7 +361,7 @@ static int cartridge_picker(const char *title, bool force_choice) { 0); } draw_setup_status(144); - prg32_gfx_text8(8, 216, "UP/DOWN CHOOSE SELECT/B RUN A BACK", PRG32_COLOR_CYAN, 0); + prg32_gfx_text8(8, 216, "B RUN SELECT MENU A BACK", PRG32_COLOR_CYAN, 0); prg32_gfx_present(); last = input; vTaskDelay(pdMS_TO_TICKS(80)); diff --git a/docs/api.md b/docs/api.md index 741c03e..8f456b6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -123,7 +123,7 @@ Typical response fields: "cart_abi_features": 511, "cart_load_addr": 1107296256, "cart_max_size": 65536, - "cart_ram_size": 65536, + "cart_ram_size": 32768, "cart_loaded": true, "qemu": false, "cart": { @@ -224,7 +224,7 @@ Parameters: | Parameter | Required | Meaning | |---|---:|---| -| `slot` | no | Cartridge slot name such as `cart0` or `cart1`; defaults to `cart0` | +| `slot` | no | Cartridge slot name from `cart0` through `cart3`; defaults to `cart0` | Example with the host tool: @@ -258,7 +258,7 @@ Success response: Expected behavior: - upload is accepted only when `PRG32_GAME_UPLOAD_ENABLE` is enabled; -- the request body must fit in the 64 KiB cartridge package limit; +- the request body must fit in the 128 KiB cartridge package limit; - invalid cartridge images return `400` with the cartridge validation error; - disabled upload support returns `403`. @@ -358,11 +358,11 @@ Expected behavior: GET /api/scores ``` -Returns the board-local in-RAM scoreboard. The firmware keeps the five best +Returns the board-local persistent scoreboard. The firmware keeps the five best local records for each game even when no Cartridge Store URL has been configured. -Games can also access the same in-RAM records directly through +Games can also access the same local records directly through `prg32_score_count` and `prg32_score_get`, or show the built-in on-device scoreboard with `prg32_scoreboard_show`. @@ -413,14 +413,14 @@ Success response: Expected behavior: -- scores are stored in RAM by the board-local API; +- scores are stored in a dedicated local NVS partition by the board-local API; - only the five best local records for each game are kept; - each score record associates a short game identifier, player name, and numeric score; - pending local records can be retried with `prg32_score_sync_remote` once a Cartridge Store URL is configured; -- rebooting the board clears board-local scores; -- use the external ScoreServer for persistent classroom leaderboards. +- board-local scores survive reboot and cartridge replacement; +- use the external ScoreServer for shared classroom leaderboards. ## ScoreServer API @@ -929,7 +929,7 @@ python3 tools/prg32_metrics_paper.py prg32_performance.json \ | `404` on a board endpoint | Wi-Fi API not enabled or wrong base URL | Check firmware config and board IP | | `403` on `POST /api/games` | Upload support disabled | Enable `PRG32_GAME_UPLOAD_ENABLE` | | `400` during upload | Invalid image, oversized image, or bad slot | Rebuild the cartridge and check `slot` | -| Empty board score list after reboot | Board-local scores are RAM-only | Use ScoreServer for persistence | +| Empty board score list after reflashing with an erased score partition | Local score partition was erased | Re-submit scores or sync from the classroom service | | Store discovery finds nothing | mDNS blocked or QEMU build | Enter the store URL manually | | Store download missing architecture | Only another target was published | Publish `esp32c6` or `qemu` as needed | | `401` on publish | Missing or invalid Bearer token | Add `--token` or `store_token` | diff --git a/docs/assets.md b/docs/assets.md index f0407f1..c1b6ab8 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -77,7 +77,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 64 KiB cartridge package limit. +uploadable cartridges when they fit inside the 128 KiB cartridge package limit. ## Cartridge AUDIO Blocks diff --git a/docs/cartridges.md b/docs/cartridges.md index 66c68a0..8ec1e16 100644 --- a/docs/cartridges.md +++ b/docs/cartridges.md @@ -17,7 +17,9 @@ PRG32 firmware |-- cartridge loader |-- executable cartridge RAM |-- cart0 flash partition -`-- cart1 flash partition +|-- cart1 flash partition +|-- cart2 flash partition +`-- cart3 flash partition game.prg32 |-- PRG32 cartridge header @@ -44,6 +46,9 @@ and calls: factory: resident PRG32 firmware cart0: uploaded cartridge slot 0 cart1: uploaded cartridge slot 1 +cart2: uploaded cartridge slot 2 +cart3: uploaded cartridge slot 3 +scores: persistent local scoreboard storage ``` This partition table is selected by `sdkconfig.defaults` and @@ -51,17 +56,17 @@ This partition table is selected by `sdkconfig.defaults` and ## Slot Count -The checked-in classroom firmware supports two persistent slots: -`cart0` and `cart1`. Only one cartridge is loaded into executable RAM at a time, -so additional slots cost flash space, not runtime RAM. +The checked-in classroom firmware supports four persistent slots: +`cart0`, `cart1`, `cart2`, and `cart3`. Each slot stores a `.prg32` package up +to 128 KiB. Only one cartridge is loaded into executable RAM at a time, so +additional slots cost flash space, not runtime RAM. -With the current 4 MB flash image layout, a third 512 KiB slot can fit after -`cart1` if the partition table and loader slot metadata are extended. On an -8 MB ESP32-C6 module, the same 512 KiB slot size can support about eleven total -slots after updating the ESP-IDF flash size, `partitions_prg32.csv`, -`PRG32_CART_SLOT_COUNT`, the slot labels/subtypes in the cartridge loader, and -the host tooling/docs. Keep fewer slots for labs unless the course explicitly -needs a cartridge library; two slots are easier for students to reason about. +The resident firmware reserves one executable cartridge RAM window. Physical +classroom builds default to 32 KiB to preserve setup/Wi-Fi heap, while QEMU +uses the 64 KiB extended profile. Portable cartridges built for the existing +ABI table continue to run without recompilation as long as they fit in the +runtime's executable RAM window and require only ABI features provided by the +firmware. ## Hardware Workflow @@ -104,16 +109,19 @@ 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: +from the main loop. Upload to another slot with: ```bash -python3 tools/prg32_game.py upload build-esp32c6/asteroids.prg32 --slot cart1 --url http://192.168.4.1 +python3 tools/prg32_game.py upload build-esp32c6/asteroids.prg32 --slot cart2 --url http://192.168.4.1 ``` -After reset, one stored cartridge starts automatically. When both slots contain -games, PRG32 enters setup unless a default cartridge has been saved. Use +After reset, one stored cartridge starts automatically. When multiple slots +contain games, PRG32 enters setup unless a default cartridge has been saved. Use `DEFAULT CARTRIDGE` in setup to choose the slot that should boot automatically, -or `RUN CARTRIDGE` to launch a slot immediately. +or `RUN CARTRIDGE` to inspect slots. In the run-cartridge picker, B launches the +highlighted cartridge, SELECT opens the slot context menu, and A returns to +setup. The context menu can run the cartridge, reset that cartridge's local +scoreboard, or erase the cartridge from its slot. ## Multiplayer Cartridges @@ -198,8 +206,8 @@ idf.py -B build-qemu -D SDKCONFIG=build-qemu/sdkconfig -D SDKCONFIG_DEFAULTS=sdk ``` QEMU does not emulate the classroom Wi-Fi upload path. Instead, it uses the same -cartridge package and writes it into `cart0` by default, or `cart1` when -`upload-qemu --slot cart1` is used. +cartridge package and writes it into `cart0` by default, or another slot when +`upload-qemu --slot cart1`, `cart2`, or `cart3` is used. ## Store-Ready Metadata @@ -271,7 +279,7 @@ troubleshooting details. Two installation paths are available: - On-device: enter setup, open `BROWSE STORE`, choose a compatible game, and - download it into `cart0` or `cart1`. + download it into `cart0`, `cart1`, `cart2`, or `cart3`. - Host tool: run `python3 tools/prg32_game.py store-download ...` and then upload the downloaded `.prg32` with `python3 tools/prg32_game.py upload ...`. @@ -302,7 +310,7 @@ Returns: GET /api/games ``` -Returns the `cart0` and `cart1` cartridge states. +Returns the `cart0` through `cart3` cartridge states. ### Upload Game @@ -313,8 +321,8 @@ Content-Type: application/octet-stream ``` -Validates and stores the cartridge without starting it. Use `slot=cart1` for -the second slot. +Validates and stores the cartridge without starting it. Use `slot=cart1`, +`slot=cart2`, or `slot=cart3` for alternate slots. ### Select Stored Game @@ -427,12 +435,14 @@ 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 64 KiB. +- Cartridge package size is 128 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. + classroom builds default to 32 KiB, while QEMU uses the 64 KiB extended + profile 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 - loaded into executable RAM at a time. +- Four flash slots, `cart0` through `cart3`, are available. Only one cartridge + is loaded into executable RAM at a time. +- Local scoreboard records persist in the dedicated `scores` NVS partition and + are not erased when a cartridge slot is replaced. - QEMU staging requires QEMU to be stopped before patching `qemu_flash.bin`. diff --git a/docs/deployment.md b/docs/deployment.md index 373c1b9..a611f64 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -32,9 +32,9 @@ ILI9341 pins before the splash screen is drawn. The default firmware is a resident PRG32 runtime. On the physical ESP32-C6 build it shows the full 320x240 splash screen, samples the joystick, and enters setup mode when A and B are held or when no cartridge is stored. If one -cartridge is stored, it starts automatically. If both `cart0` and `cart1` -contain cartridges, setup lets the user run one immediately or save a default -boot cartridge. +cartridge is stored, it starts automatically. If multiple slots among `cart0` +through `cart3` contain cartridges, setup lets the user run one immediately or +save a default 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 @@ -69,10 +69,10 @@ python3 tools/prg32_game.py build \ python3 tools/prg32_game.py upload build-esp32c6/asteroids.prg32 --url http://192.168.4.1 ``` -Use `--slot cart1` to upload a second cartridge: +Use `--slot` to upload another cartridge into one of the four persistent slots: ```bash -python3 tools/prg32_game.py upload build-esp32c6/asteroids.prg32 --slot cart1 --url http://192.168.4.1 +python3 tools/prg32_game.py upload build-esp32c6/asteroids.prg32 --slot cart3 --url http://192.168.4.1 ``` See `docs/cartridges.md`. diff --git a/docs/framework_manual.md b/docs/framework_manual.md index 9ef428d..52de044 100644 --- a/docs/framework_manual.md +++ b/docs/framework_manual.md @@ -231,13 +231,13 @@ 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_MAX_SIZE`: maximum `.prg32` package size, currently 64 KiB. +- `PRG32_CART_MAX_SIZE`: maximum `.prg32` package size, currently 128 KiB. - `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. + configured by `CONFIG_PRG32_CART_RAM_PROFILE`. Physical ESP32-C6 classroom + builds default to 32 KiB to preserve setup/Wi-Fi heap, while QEMU defaults to + the 64 KiB extended profile for desktop experiments. 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: @@ -245,7 +245,8 @@ Important functions: - `prg32_cart_load_addr()`: runtime address used by the host linker. - `prg32_cart_install(image, size, persist)`: validate, load, and optionally store. - `prg32_cart_store_slot(slot, image, size)`: validate and store an image without running it. -- `prg32_cart_install_slot(slot, image, size, persist)`: install to `cart0` or `cart1`. +- `prg32_cart_install_slot(slot, image, size, persist)`: install to one of + `cart0` through `cart3`. - `prg32_cart_select_slot(slot)`: load a stored cartridge from one slot. - `prg32_cart_default_slot()`: read the saved default boot cartridge. - `prg32_cart_set_default_slot(slot)`: save a default slot, or pass `-1` to clear it. diff --git a/docs/getting_started_game_development.md b/docs/getting_started_game_development.md index 589e5fa..0da68fa 100644 --- a/docs/getting_started_game_development.md +++ b/docs/getting_started_game_development.md @@ -471,7 +471,7 @@ python3 tools/prg32_game.py upload \ --url http://192.168.4.1 ``` -Upload to the second slot with: +Upload to another slot with: ```bash python3 tools/prg32_game.py upload \ @@ -480,7 +480,7 @@ python3 tools/prg32_game.py upload \ --url http://192.168.4.1 ``` -The firmware stores the cartridge and can run it immediately. If both slots +The firmware stores the cartridge and can run it immediately. If multiple slots contain cartridges, use setup to run a slot or save a default cartridge. Useful runtime checks: diff --git a/docs/score_api.md b/docs/score_api.md index d4247ef..4cc0167 100644 --- a/docs/score_api.md +++ b/docs/score_api.md @@ -6,8 +6,9 @@ useful for competitions and for teaching the boundary between assembly, C, and network services. Games can use the score feature without Wi-Fi. The local firmware keeps the -five best in-RAM scores for each game, includes an on-screen player-name prompt, -and provides a simple scoreboard screen that cartridges can call directly. +five best persistent local scores for each game, includes an on-screen +player-name prompt, and provides a simple scoreboard screen that cartridges can +call directly. ## Enable Wi-Fi @@ -126,9 +127,12 @@ prg32_score_submit_remote("http://192.168.1.20:5000", ## Current implementation -The board-local implementation stores the top five in-RAM scores per game. It is -cleared when the firmware restarts. The standalone ScoreServer and Cartridge -Store persist uploaded records in SQLite. +The board-local implementation stores the top five scores per game in a +dedicated local NVS partition. Local scores survive shutdown, reboot, and +cartridge replacement. Pending records remain marked for retry until +`prg32_score_sync_remote()` can submit them to the configured Cartridge Store +score API. The standalone ScoreServer and Cartridge Store persist uploaded +records in SQLite. The same board HTTP server also hosts the cartridge upload API when `PRG32_GAME_UPLOAD_ENABLE` is enabled. See `docs/cartridges.md`. diff --git a/docs/setup_mode_cartridge_store.md b/docs/setup_mode_cartridge_store.md index 833bb8d..33de7fe 100644 --- a/docs/setup_mode_cartridge_store.md +++ b/docs/setup_mode_cartridge_store.md @@ -56,7 +56,7 @@ A complete setup page should provide: - connection test; - browsing games after connection; - viewing metadata and colophon; -- selecting `cart0` or `cart1`; +- selecting `cart0`, `cart1`, `cart2`, or `cart3`; - downloading and installing the chosen game/version/architecture. Firmware-side sketch: diff --git a/partitions_prg32.csv b/partitions_prg32.csv index f447685..c660310 100644 --- a/partitions_prg32.csv +++ b/partitions_prg32.csv @@ -2,5 +2,8 @@ nvs, data, nvs, 0x9000, 0x6000, phy_init, data, phy, 0xf000, 0x1000, factory, app, factory, 0x10000, 2M, -cart0, data, 0x40, 0x210000, 512K, -cart1, data, 0x41, 0x290000, 512K, +cart0, data, 0x40, 0x210000, 128K, +cart1, data, 0x41, 0x230000, 128K, +cart2, data, 0x42, 0x250000, 128K, +cart3, data, 0x43, 0x270000, 128K, +scores, data, nvs, 0x290000, 128K, diff --git a/sdkconfig.defaults.qemu b/sdkconfig.defaults.qemu index 653d901..e542075 100644 --- a/sdkconfig.defaults.qemu +++ b/sdkconfig.defaults.qemu @@ -4,6 +4,7 @@ CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_prg32.csv" CONFIG_PRG32_DISPLAY_QEMU_RGB=y CONFIG_PRG32_QEMU_FAIL_ON_REAL_HW=y CONFIG_PRG32_QEMU_UART_INPUT=y +CONFIG_PRG32_CART_RAM_EXTENDED=y CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y CONFIG_PRG32_AUDIO_ENABLED=y CONFIG_PRG32_AUDIO_SAMPLE_RATE=22050 diff --git a/tools/prg32_game.py b/tools/prg32_game.py index af19cf2..27da2fb 100755 --- a/tools/prg32_game.py +++ b/tools/prg32_game.py @@ -56,8 +56,8 @@ 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 = 64 * 1024 +FALLBACK_CART_RAM_SIZE = 32 * 1024 +FALLBACK_CART_MAX_SIZE = 128 * 1024 FALLBACK_CART_LOAD_ADDR = 0x40800000 STORE_DISCOVERY_ABI = "prg32-store-discovery-1.0" STORE_METADATA_ABI = "prg32-metadata-1.0"