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

+18 -14
View File
@@ -641,21 +641,25 @@ the multiboot menu reads firmware slots rather than apps. **The answer was writt
the flasher's own translation file**, not in the firmware source I had been reading -- so
when a feature's entry point is missing, the host tool that installs it is the document.
**"Init ... DO NOT POWER OFF" that never ends is a pending multiboot state marker.** Measured
on a real image: `0x100000` held `FMP3` next to a committed slot 0, so the factory bootloader
reflashed the internal flash from that slot on every power-on and reset again -- a restore loop.
The firmware's own banner reappeared once a cycle in the serial log (7 -> 8 in 25 s) while the
screen never changed. Clearing the two marker sectors (`0x100000..0x101FFF`, which stops just
before the app region at `0x102000`, so installed apps are untouched) ended it: the banner count
stopped rising and the radio booted once and stayed.
**A blank screen after a firmware upload: check the multiboot state, and roll the image back --
do not edit state by hand.** Measured here: an image whose `FMP3` marker at `0x100000` recorded one
identity (size 120832, CRC `0x4D87CE48`) while `-kernel` loaded a different build makes the firmware
decide the running image is not the one its state expects, so it takes the restore/adopt path and
draws nothing: the panel's whole 1024 bytes stayed zero while the serial banner printed happily.
Replacing the working copy with the untouched dump brought the picture straight back (485 of 1024
bytes lit) and left the three installed apps reinstallable.
That loop explained a second surprise: edits made through the page vanished. The emulator writes
its in-memory image back when it exits, and the looping guest's copy was older than the file, so
powering it off overwrote what had just been installed. With the loop gone the same installs
survive a power cycle -- verified by installing, powering off, seeing them still listed, powering
on, seeing them still listed, and having the radio answer `0x0730` with all three. **A stale
write-back is worth suspecting whenever an edit "does not stick"**, and a guest that is quietly
rebooting is exactly how one happens.
Two corrections to what an earlier version of this section claimed. **The marker is not a pending
flag**: decode it and `generation`, `image_size`, `image_crc32`, `firmware_slot`/`slot_inv`,
`config_bank`/`bank_inv` and `state_crc32` all check out (`0x661286F1` over the first 20 bytes) --
it is an ordinary state record. And **clearing the marker sectors is not a fix**: it was tried twice
(a whole 8 KiB, then only the 24-byte headers), after which the firmware took the `MB_MARK_MISSING`
= "fresh radio" path, adopted the running firmware, drew nothing while doing it, and had the marker
written back by its own write-back anyway. Rolling the image back is what worked.
**A stale write-back is still worth suspecting when an edit "does not stick"**: the emulator writes
its in-memory image back on exit, so an edit made while a guest was live can be overwritten by the
copy that guest was holding -- which is also how the marker reappeared after being cleared.
## The keypad: two real bugs, both fixed
+13 -9
View File
@@ -520,16 +520,20 @@ UVStudio 的 `locales/en.js` 原文是 "launch them from the F + 7 menu" ——
app 区被读过 —— 而多系统菜单读的是**固件槽**,不是应用。**答案写在刷机工具自己的翻译文件里**,
不在我一直在读的固件源码里 —— 所以当一个功能的入口找不到时,**安装它的那个主机工具就是文档**。
**永远停不下来的 "Init ... DO NOT POWER OFF" 是一个待执行的多系统状态标记。** 在真实镜像上量到:
`0x100000` 里是 `FMP3`,而槽 0 已提交 —— 于是出厂引导**每次开机都从槽 0 重刷内部 flash 再重启**,
成了恢复循环。串口日志里固件横幅**每个周期出现一次**(25 秒里 7 → 8),而屏幕一直不动。清掉那两个
标记扇区(`0x100000..0x101FFF`,正好停在应用区 `0x102000` 之前,所以**已安装的应用不受影响**)
之后循环结束:横幅数不再增长,电台启动一次就稳定了。
**上传固件之后白屏:先查多系统状态,然后回滚镜像 —— 不要手工去改状态。** 实测:镜像里 `0x100000` 的
`FMP3` 标记记的是一个身份(size 120832、CRC `0x4D87CE48`),而 `-kernel` 加载的是另一份构建,于是固件
判定"正在运行的镜像不是我的状态所期望的那份",走恢复/收养路径,**什么都不画**:面板 1024 字节全为 0,
而串口横幅照常打印。把工作副本换回未改动的 dump,画面立刻回来(1024 字节里 485 字节点亮),三个已安装的
应用也可以重新装回。
这个循环还解释了第二件怪事:**通过页面装的东西会消失**。模拟器退出时会把内存里的镜像写回文件,
而循环中的客人手里那份比文件更旧,于是"断电"这一下就把刚装进去的覆盖掉了。循环消失后,同一批安装
**扛得住断电重上电** —— 实测:装上、断电仍在、上电仍在,并且电台用 `0x0730` 确认了三个都在。
**每当"改了不生效",都值得怀疑一次过期的写回**,而一个在悄悄重启的客人正是它发生的典型方式。
对本节早先版本的两处更正。**这个标记不是"待执行标志"**:解出来看,`generation`、`image_size`、
`image_crc32`、`firmware_slot`/`slot_inv`、`config_bank`/`bank_inv` 与 `state_crc32` 全部自洽
(前 20 字节的 CRC 为 `0x661286F1`)—— 它只是一条普通的状态记录。**而清掉标记扇区不是修复手段**:试过两次
(先清整片 8 KiB,再只清 24 字节头),之后固件走的是 `MB_MARK_MISSING` = "新电台"那条路,会去收养当前
运行的固件,过程很慢、不画屏,而且**它的写回又把标记写回来了**。真正有效的是**回滚镜像**。
**而当"改了不生效"时,过期的写回依然值得怀疑**:模拟器退出时会把内存里的镜像写回文件,所以客人还活着
时做的改动,可能被它手里那份副本覆盖 —— 这也是标记被清掉后又重新出现的原因。
## 键盘:两个真 bug,都已修复
+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();
}
}
}