Fix the blank screen, correct the multiboot note, and add Minesweeper

The blank screen was self-inflicted and the earlier explanation was wrong. The FMP3 marker at 0x100000 is an ordinary state record -- generation, image_size, image_crc32, firmware_slot/slot_inv, config_bank/bank_inv and a matching state_crc32 (0x661286F1) -- not a pending flag. What actually happened: a firmware uploaded over a state that recorded a different identity made the firmware take the restore/adopt path, which draws nothing (panel 1024/1024 bytes zero) while the serial banner printed normally. Restoring the untouched dump fixed it at once (485/1024 bytes lit) and the three apps reinstalled and were confirmed by 0x0730.

Clearing the marker sectors was tried twice (a whole 8 KiB, then just the 24-byte headers) and is not a fix: it sends the firmware down MB_MARK_MISSING = fresh radio, which adopts the running firmware slowly and without drawing, and its own write-back restores the marker anyway. AGENTS.md and AGENTS.zh-CN.md now say so in place of the wrong claim.

apps/minesweeper/ adds our own 9x9 minesweeper for the 4 KiB overlay: no left/right keys exist on this radio (so the cursor walks with UP/DOWN and digits pick a row then a column), 81 cells need three 9-byte bit arrays rather than a uint16_t mask (the uint16_t version compiled fine and was wrong past cell 15), mines are placed after the first reveal so it cannot lose immediately, and the source compiles clean under gcc -Wall -Wextra -Werror against upstream's real app_api.h. It has not been built for ARM or run -- no toolchain here -- and the README says so.
This commit is contained in:
mckero committed 2026-10-01 22:29:36 +08:00
1 parent 2b3222155f
commit ff14dffb67
5 files changed
+452 -23

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
# Minesweeper — an overlay app for the F4HWN Labs edition
Our own app: a 9x9 minesweeper that runs on the radio inside the 4 KiB overlay that
`App/apps/app_overlay.h` reserves. It is written against upstream's `App/apps/app_api.h`
and built with upstream's `app.ld` — **neither is vendored here** (both are Apache-2.0
files from [armel/uv-k1-k5v3-firmware-custom](https://github.com/armel/uv-k1-k5v3-firmware-custom)),
so drop this folder into `App/apps/minesweeper/` next to them, or point `-I` at a copy.
## Why it looks the way it does
| constraint | consequence |
| --- | --- |
| the radio has **no left/right keys** (UP, DOWN, MENU, EXIT, STAR, F, 0-9 only) | the cursor walks the field with UP/DOWN and digits jump to a row then a column: `3` `5` = row 3, column 5 |
| **4 KiB** for text+rodata+data+bss together | no lookup tables, no floats, no libc; adjacency is counted on the fly and each cell is one bit |
| 81 cells do not fit a 16-bit mask | three 9-byte bit arrays addressed by `cell >> 3`, `cell & 7` — a `uint16_t` version compiled fine and was wrong past cell 15 |
| the resident pixel helpers do **not** bound-check | `put()`/`invert()` clip |
| no `rand()` in a freestanding blob | a small LCG; mines are placed **after the first reveal**, keeping the 3x3 around it clear |
Keys: UP/DOWN move, 1-9 pick row then column, MENU reveal, F flag, STAR new game,
EXIT quit. `M` in the corner is the remaining-mine count, `A1` is the cursor.
## Build
arm-none-eabi-gcc -mcpu=cortex-m0plus -mthumb -Os -std=gnu11 -ffreestanding \
-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=0x20000280 \
-o minesweeper.elf minesweeper_app.c
arm-none-eabi-objcopy -O binary minesweeper.elf minesweeper.bin
pack_app.py minesweeper.bin Minesweeper.app --name Minesweeper --ver 1.0 \
--vma 0x20000280 --api-min 1 # pack_app.py lives in App/apps/
`build.sh` does exactly that and needs the Arm GNU Toolchain on PATH.
Then install it **from the page**: *Overlay apps* → a slot → pick `Minesweeper.app` →
**Install** → **Ask the radio** should answer `Minesweeper`. On the radio press
**F** then **7** and **MENU** to run it.
## What is verified, and what is not
Verified here: the source compiles clean with `gcc -Wall -Wextra -Werror` against
upstream's real `app_api.h` (that check caught the API's actual member names —
`api->fb`, `print_tiny(s, x, y, statusbar, fill)` with **five** arguments — and the fact
that `APP_KEY_LEFT`/`APP_KEY_RIGHT` do not exist).
Not verified: it has never been built for ARM or run on the radio, because no
`arm-none-eabi-gcc` and no Docker exist on the machine it was written on. Treat the
first build and the first run as the real review.
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Build one overlay-app blob (.app) locally, without Docker.
#
# Upstream runs this same file inside the uvk1-uvk5v3 image; it already prefers
# arm-none-eabi-gcc from PATH when there is one, which is what makes a local build
# possible. Link VMA is pinned at 0x20000280 and must match the firmware's
# __mb_workspace_start -- the loader compares them and refuses with APP_VMA.
set -euo pipefail
APP="$(basename "$PWD")"
APP_NAME="Minesweeper" # <-- the only per-app line: the label in the 64-byte header
APP_VER="1.0"
APP_API_MIN=1
APP_VMA=${APP_VMA:-0x20000280}
OUT="${APP_NAME// /}"
CC=${CC:-arm-none-eabi-gcc}
OBJCOPY=${OBJCOPY:-arm-none-eabi-objcopy}
command -v "$CC" >/dev/null 2>&1 || {
echo "no $CC on PATH; install the Arm GNU Toolchain (arm-none-eabi) first" >&2; exit 2; }
CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -std=gnu11 -ffreestanding -fno-builtin -fno-common \
-fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra"
LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} \
-Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none"
rm -f ./*.app ./*.elf ./*.bin
"$CC" $CFLAGS $LDFLAGS -o "${APP}.elf" "${APP}_app.c"
"$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
python3 pack_app.py "${APP}.bin" "${OUT}.app" --name "$APP_NAME" --ver "$APP_VER" \
--vma "$APP_VMA" --api-min "$APP_API_MIN"
ls -l "${OUT}.app"
+343
View File
@@ -0,0 +1,343 @@
/* Minesweeper — overlay app for the F4HWN Labs edition.
*
* Written against App/apps/app_api.h. The constraints shaped it:
* - the radio has NO left/right keys (UP, DOWN, MENU, EXIT, STAR, F, 0-9 only), so the
* cursor walks the field with UP/DOWN and digits jump straight to a row and column
* - 4 KiB for text+rodata+data+bss together: no lookup tables, no floats, no libc,
* adjacency counted on the fly, one bit per cell
* - the resident pixel helpers do not bound-check, so put() clips
*
* Keys: UP/DOWN move the cursor, 1-9 pick row then column (3 then 5 = row 3, col 5),
* MENU reveal, F flag, STAR new game, EXIT quit. The first reveal is always safe:
* the mines are placed after it, keeping the 3x3 around it clear.
*
* The 81 cells do not fit in any single integer type this chip shifts cheaply, so the
* three cell sets are 9-byte bit arrays addressed by cell index (cell >> 3, cell & 7).
* A uint16_t version of this compiled fine and would have been wrong past cell 15.
*/
#include <stdint.h>
#include <stdbool.h>
#include "../app_api.h"
#define N 9 /* 9x9: cell pitch 6 px -> 54x54 on a 128x64 screen */
#define PITCH 6
#define FIELD_X 4
#define FIELD_Y 10
#define MINES 10
#define CELLS (N * N)
#define BYTES ((CELLS + 7) / 8)
static const app_api_t *A;
static uint8_t g_mine[BYTES];
static uint8_t g_open[BYTES];
static uint8_t g_flag[BYTES];
static uint8_t g_cursor;
static uint8_t g_pending; /* 0 = no digit typed, 1 = row typed */
static uint8_t g_row_pick;
static uint8_t g_placed;
static uint8_t g_state; /* 0 play, 1 lost, 2 won */
static int8_t g_mine_left;
static bool bit(const uint8_t *set, uint8_t cell)
{
return ((set[cell >> 3] >> (cell & 7)) & 1u) != 0;
}
static void setbit(uint8_t *set, uint8_t cell, bool on)
{
uint8_t mask = (uint8_t)(1u << (cell & 7));
if (on)
set[cell >> 3] |= mask;
else
set[cell >> 3] &= (uint8_t)~mask;
}
static void put(int16_t x, int16_t y, bool ink)
{
if (x < 0 || x >= 128 || y < 0 || y >= 64)
return; /* the API's helpers do not clip */
if (ink)
A->fb[y][x >> 3] |= (uint8_t)(1u << (7u - (x & 7)));
else
A->fb[y][x >> 3] &= (uint8_t)~(1u << (7u - (x & 7)));
}
static void invert(int16_t x, int16_t y)
{
if (x < 0 || x >= 128 || y < 0 || y >= 64)
return;
A->fb[y][x >> 3] ^= (uint8_t)(1u << (7u - (x & 7)));
}
static void box(int16_t x0, int16_t y0, int16_t x1, int16_t y1, bool ink)
{
for (int16_t x = x0; x <= x1; x++) {
put(x, y0, ink);
put(x, y1, ink);
}
for (int16_t y = y0; y <= y1; y++) {
put(x0, y, ink);
put(x1, y, ink);
}
}
static int8_t neighbours(uint8_t cell)
{
int8_t row = (int8_t)(cell / N);
int8_t col = (int8_t)(cell % N);
int8_t count = 0;
for (int8_t dy = -1; dy <= 1; dy++) {
for (int8_t dx = -1; dx <= 1; dx++) {
int8_t r = (int8_t)(row + dy);
int8_t c = (int8_t)(col + dx);
if ((dx == 0 && dy == 0) || r < 0 || r >= N || c < 0 || c >= N)
continue;
if (bit(g_mine, (uint8_t)(r * N + c)))
count++;
}
}
return count;
}
static void place_mines(uint8_t safe)
{
/* No rand() in a freestanding blob, so a cheap LCG. Seeded from the first reveal and
* a counter so two games in a row differ. */
static uint32_t seed = 1u;
int8_t want = MINES;
for (uint8_t i = 0; i < BYTES; i++)
g_mine[i] = 0;
seed += (uint32_t)safe * 2654435761u + 1u;
while (want > 0) {
seed = seed * 1103515245u + 12345u;
uint8_t cell = (uint8_t)((seed >> 16) % CELLS);
int8_t row = (int8_t)(cell / N), col = (int8_t)(cell % N);
int8_t srow = (int8_t)(safe / N), scol = (int8_t)(safe % N);
if (bit(g_mine, cell))
continue;
/* the 3x3 around the first reveal stays clear, so it cannot lose at once */
if (row >= srow - 1 && row <= srow + 1 && col >= scol - 1 && col <= scol + 1)
continue;
setbit(g_mine, cell, true);
want--;
}
g_placed = 1;
}
static void reveal(uint8_t cell)
{
/* Iterative flood fill: an explicit stack of cell indices, no recursion. */
static uint8_t stack[CELLS];
int16_t top = 0;
stack[top++] = cell;
while (top > 0) {
uint8_t cur = stack[--top];
if (bit(g_open, cur))
continue;
setbit(g_open, cur, true);
if (neighbours(cur) != 0)
continue;
int8_t row = (int8_t)(cur / N), col = (int8_t)(cur % N);
for (int8_t dy = -1; dy <= 1; dy++) {
for (int8_t dx = -1; dx <= 1; dx++) {
int8_t r = (int8_t)(row + dy), c = (int8_t)(col + dx);
if (r < 0 || r >= N || c < 0 || c >= N)
continue;
uint8_t next = (uint8_t)(r * N + c);
if (!bit(g_open, next) && !bit(g_flag, next) && top < CELLS)
stack[top++] = next;
}
}
}
}
static void new_game(void)
{
for (uint8_t i = 0; i < BYTES; i++) {
g_mine[i] = 0;
g_open[i] = 0;
g_flag[i] = 0;
}
g_cursor = (uint8_t)(4 * N + 4);
g_pending = 0;
g_row_pick = 0;
g_placed = 0;
g_state = 0;
g_mine_left = MINES;
}
static void step(int8_t delta)
{
int8_t cell = (int8_t)((int8_t)g_cursor + delta);
if (cell < 0)
cell = (int8_t)(CELLS - 1);
if (cell >= CELLS)
cell = 0;
g_cursor = (uint8_t)cell;
}
static void two_digits(char *out, int8_t value)
{
if (value < 0)
value = 0;
if (value > 99)
value = 99;
out[0] = (char)('0' + (value / 10) % 10);
out[1] = (char)('0' + value % 10);
out[2] = 0;
}
static void draw(void)
{
char text[3];
A->display_clear();
A->print_tiny("M", 0, 1, false, false);
two_digits(text, g_mine_left);
A->print_tiny(text, 8, 1, false, false);
if (g_state == 1)
A->print_tiny("BOOM", 46, 1, false, false);
else if (g_state == 2)
A->print_tiny("CLEAR", 42, 1, false, false);
else
A->print_tiny("F4HWN MINES", 34, 1, false, false);
text[0] = (char)('A' + (g_cursor / N));
text[1] = (char)('1' + (g_cursor % N));
text[2] = 0;
A->print_tiny(text, 110, 1, false, false);
if (g_pending)
A->print_tiny("-", 122, 1, false, false);
for (uint8_t cell = 0; cell < CELLS; cell++) {
int16_t x = (int16_t)(FIELD_X + (cell % N) * PITCH);
int16_t y = (int16_t)(FIELD_Y + (cell / N) * PITCH);
bool opened = bit(g_open, cell);
bool mine = bit(g_mine, cell);
/* mines show once the game is over, whether or not they were flagged */
bool show_mine = mine && (g_state != 0 || opened);
if (show_mine) {
box(x, y, (int16_t)(x + 4), (int16_t)(y + 4), true);
put((int16_t)(x + 2), (int16_t)(y + 2), false);
} else if (opened) {
int8_t n = neighbours(cell);
if (n > 0) {
text[0] = (char)('0' + n);
text[1] = 0;
A->print_tiny(text, (uint8_t)x, (uint8_t)y, false, false);
}
} else if (bit(g_flag, cell)) {
box((int16_t)(x + 1), (int16_t)(y + 1), (int16_t)(x + 3), (int16_t)(y + 3), true);
put((int16_t)(x + 2), (int16_t)(y + 4), true);
}
}
/* the cursor inverts the frame around its cell, so it shows on ink and on paper */
int16_t cx = (int16_t)(FIELD_X + (g_cursor % N) * PITCH - 1);
int16_t cy = (int16_t)(FIELD_Y + (g_cursor / N) * PITCH - 1);
for (int16_t i = 0; i <= PITCH + 1; i++) {
invert((int16_t)(cx + i), cy);
invert((int16_t)(cx + i), (int16_t)(cy + PITCH + 1));
invert(cx, (int16_t)(cy + i));
invert((int16_t)(cx + PITCH + 1), (int16_t)(cy + i));
}
A->blit_full();
}
static void check_win(void)
{
int16_t closed = 0;
for (uint8_t cell = 0; cell < CELLS; cell++)
if (!bit(g_open, cell) && !bit(g_mine, cell))
closed++;
if (closed == 0) {
g_state = 2;
A->play_tone(880, 120);
A->play_tone(1320, 160);
}
}
void app_main(const app_api_t *api)
{
A = api;
new_game();
while (true) {
draw();
uint8_t key = A->get_key();
if (key == APP_KEY_INVALID || key == APP_KEY_SAVER) {
A->delay_ms(40);
continue;
}
if (key == APP_KEY_EXIT)
return; /* the loader restores the radio */
if (key == APP_KEY_STAR) {
new_game();
continue;
}
if (g_state != 0) { /* after BOOM or CLEAR, MENU restarts */
if (key == APP_KEY_MENU)
new_game();
continue;
}
if (key == APP_KEY_UP) {
step(-1);
continue;
}
if (key == APP_KEY_DOWN) {
step(1);
continue;
}
if (key >= APP_KEY_1 && key <= APP_KEY_9) {
uint8_t digit = (uint8_t)(key - APP_KEY_0); /* 1..9 */
if (!g_pending) {
g_row_pick = digit; /* row first ... */
g_pending = 1;
} else {
g_cursor = (uint8_t)((g_row_pick - 1) * N + (digit - 1)); /* ... then column */
g_pending = 0;
}
continue;
}
if (key == APP_KEY_F) { /* flag */
if (!bit(g_open, g_cursor)) {
if (bit(g_flag, g_cursor)) {
setbit(g_flag, g_cursor, false);
g_mine_left++;
} else if (g_mine_left > 0) {
setbit(g_flag, g_cursor, true);
g_mine_left--;
}
}
continue;
}
if (key == APP_KEY_MENU) { /* reveal */
if (bit(g_flag, g_cursor))
continue;
if (!g_placed)
place_mines(g_cursor);
if (bit(g_mine, g_cursor)) {
setbit(g_open, g_cursor, true);
g_state = 1;
A->play_tone(160, 400);
continue;
}
reveal(g_cursor);
check_win();
}
}
}