From cc31014fd2cfc74f4a973598a3bc5ef3f58516c0 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:20:36 +0200 Subject: [PATCH 01/13] feat: cyberdeck demo suite (cyber pulse, life grid, byte stream, sys stats) Adds four new menu entries that showcase the firefly-scene engine and turn the Pixie into a cyberpunk-styled cyberdeck: * Cyber Pulse - 8 neon equalizer bars driven by a triangle-wave oscillator with per-column phase/period offsets, plus a translucent CRT-style scan line. * Life Grid - 16x16 Conway's Game of Life with toroidal topology. Four seeds (glider, pulsar, R-pentomino, random), N/S to switch seed, OK to pause/step, Cancel to exit. * Byte Stream - Matrix-style falling-character stream, 12 columns with random period/phase, opacity-faded trails, and a single glyph riding each head. * Sys Stats - Live system telemetry (uptime, free heap, render FPS, chip rev, IDF version, build hash) plus a 60-sample free-heap timeline. Uses ffx_sceneLabel_setTextFormat for dynamic value updates. Menu refactor: * panel-menu now supports a scrolling viewport (3 visible / 7 items), with the previously-disabled GIFs entry re-enabled. * Top/bottom neon rules added for cyberpunk styling consistent with the new panels. Other: * main.c: pass firmware version to ffx_init (required by the current pinned hollows submodule API; main was previously out-of-sync with components/firefly-hollows). * panels.h: add declarations for the new panels and fix pushPanelGifs return type (int, matching its definition). All new code uses only the documented firefly-scene / firefly-hollows public API. No new image assets - everything is composed from boxes, labels and the existing arrow sprite. --- main/CMakeLists.txt | 4 + main/main.c | 4 +- main/panel-bytes.c | 149 ++++++++++++++++++++++++++ main/panel-cyber.c | 154 +++++++++++++++++++++++++++ main/panel-life.c | 251 ++++++++++++++++++++++++++++++++++++++++++++ main/panel-menu.c | 146 ++++++++++++++++++-------- main/panel-stats.c | 184 ++++++++++++++++++++++++++++++++ main/panels.h | 22 +++- 8 files changed, 866 insertions(+), 48 deletions(-) create mode 100644 main/panel-bytes.c create mode 100644 main/panel-cyber.c create mode 100644 main/panel-life.c create mode 100644 main/panel-stats.c diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index f7a69a0..ab1154a 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -1,10 +1,14 @@ idf_component_register( SRCS "main.c" + "panel-bytes.c" "panel-connect.c" + "panel-cyber.c" "panel-gifs.c" + "panel-life.c" "panel-menu.c" "panel-space.c" + "panel-stats.c" "panel-tx.c" "utils.c" diff --git a/main/main.c b/main/main.c index a07c39f..96f1e8e 100644 --- a/main/main.c +++ b/main/main.c @@ -23,6 +23,8 @@ #define GIT_COMMIT ("unknown") #endif +#define PIXIE_FW_VERSION FFX_VERSION(0, 2, 0) + static int initPanel(void *arg) { return pushPanelMenu(); @@ -33,7 +35,7 @@ void app_main() { FFX_LOG("GIT Commit: %s", GIT_COMMIT); - ffx_init(ffx_demo_backgroundPixies, initPanel, NULL); + ffx_init(PIXIE_FW_VERSION, ffx_demo_backgroundPixies, initPanel, NULL); while (1) { ffx_dumpStats(); diff --git a/main/panel-bytes.c b/main/panel-bytes.c new file mode 100644 index 0000000..56c0960 --- /dev/null +++ b/main/panel-bytes.c @@ -0,0 +1,149 @@ +#include +#include + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#define COL_COUNT (12) +#define TRAIL_LEN (10) +#define DOT_SIZE (10) +#define COL_STRIDE (20) +#define COL_OFFSET ((240 - (COL_COUNT * COL_STRIDE)) / 2) +#define DOT_STRIDE (16) + +#define TOTAL_RANGE (240 + TRAIL_LEN * DOT_STRIDE) + + +typedef struct ByteCol { + uint16_t period; + uint16_t phase; +} ByteCol; + +typedef struct BytesState { + FfxScene scene; + FfxNode dots[COL_COUNT * TRAIL_LEN]; + FfxNode glyphs[COL_COUNT]; + ByteCol cols[COL_COUNT]; + uint32_t rngState; +} BytesState; + + +static uint32_t rngNext(uint32_t *s) { + uint32_t x = *s; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *s = x ? x : 0xC0FFEE42; + return *s; +} + +static void onRender(FfxEvent event, FfxEventProps props, void *_state) { + BytesState *state = _state; + uint32_t t = ticks(); + + for (int c = 0; c < COL_COUNT; c++) { + ByteCol *col = &state->cols[c]; + int32_t headY = (int32_t)(((t + col->phase) / col->period) % TOTAL_RANGE); + int32_t x = COL_OFFSET + c * COL_STRIDE; + + for (int i = 0; i < TRAIL_LEN; i++) { + int32_t y = headY - i * DOT_STRIDE; + FfxNode dot = state->dots[c * TRAIL_LEN + i]; + if (y < -DOT_SIZE || y > 240) { + ffx_sceneNode_setHidden(dot, true); + continue; + } + ffx_sceneNode_setHidden(dot, false); + ffx_sceneNode_setPosition(dot, ffx_point(x, y)); + } + + FfxNode glyph = state->glyphs[c]; + int32_t gy = headY - 5; + if (gy < 0 || gy > 240) { + ffx_sceneNode_setHidden(glyph, true); + } else { + ffx_sceneNode_setHidden(glyph, false); + ffx_sceneNode_setPosition(glyph, ffx_point(x + DOT_SIZE / 2, gy)); + } + } +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + if (props.keys.down & FfxKeyCancel) { + ffx_popPanel(0); + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + BytesState *state = _state; + state->scene = scene; + state->rngState = 0x13371337 ^ ticks(); + + FfxNode bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(bg, COLOR_BLACK); + ffx_sceneGroup_appendChild(panel, bg); + ffx_sceneNode_setPosition(bg, ffx_point(0, 0)); + + static const char *glyphChars[8] = { "0", "1", "X", "F", "A", "Z", "7", "$" }; + + for (int c = 0; c < COL_COUNT; c++) { + ByteCol *col = &state->cols[c]; + col->period = 6 + (rngNext(&state->rngState) % 12); + col->phase = rngNext(&state->rngState) % 4096; + + for (int i = 0; i < TRAIL_LEN; i++) { + FfxNode dot = ffx_scene_createBox(scene, + ffx_size(DOT_SIZE, DOT_SIZE)); + uint8_t opacity; + color_ffxt color; + if (i == 0) { + color = ffx_color_rgb(220, 255, 220); + opacity = MAX_OPACITY; + } else { + color = ffx_color_rgb(0, 255, 65); + int fade = MAX_OPACITY - (i * MAX_OPACITY / TRAIL_LEN); + if (fade < 2) { fade = 2; } + opacity = (uint8_t)fade; + } + ffx_sceneBox_setColor(dot, color); + ffx_sceneBox_setOpacity(dot, opacity); + ffx_sceneGroup_appendChild(panel, dot); + ffx_sceneNode_setPosition(dot, ffx_point(0, -32)); + state->dots[c * TRAIL_LEN + i] = dot; + } + + const char *glyph = glyphChars[rngNext(&state->rngState) & 7]; + FfxNode g = ffx_scene_createLabel(scene, FfxFontMedium, glyph); + ffx_sceneGroup_appendChild(panel, g); + ffx_sceneNode_setPosition(g, ffx_point(0, -32)); + ffx_sceneLabel_setAlign(g, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(g, COLOR_BLACK); + state->glyphs[c] = g; + } + + FfxNode title = ffx_scene_createLabel(scene, FfxFontLargeBold, + "BYTE//STREAM"); + ffx_sceneGroup_appendChild(panel, title); + ffx_sceneNode_setPosition(title, ffx_point(120, 18)); + ffx_sceneLabel_setAlign(title, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(title, COLOR_BLACK); + + FfxNode hint = ffx_scene_createLabel(scene, FfxFontMedium, "[CANCEL] EXIT"); + ffx_sceneGroup_appendChild(panel, hint); + ffx_sceneNode_setPosition(hint, ffx_point(120, 228)); + ffx_sceneLabel_setAlign(hint, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(hint, COLOR_BLACK); + + ffx_onEvent(FfxEventKeys, onKeys, state); + ffx_onEvent(FfxEventRenderScene, onRender, state); + + return 0; +} + +int pushPanelBytes() { + return ffx_pushPanel(initFunc, sizeof(BytesState), NULL); +} diff --git a/main/panel-cyber.c b/main/panel-cyber.c new file mode 100644 index 0000000..34d33b2 --- /dev/null +++ b/main/panel-cyber.c @@ -0,0 +1,154 @@ +#include +#include + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#define BAR_COUNT (8) +#define BAR_WIDTH (22) +#define BAR_GAP (4) +#define BAR_AREA_LEFT (16) +#define BAR_FLOOR_Y (220) +#define BAR_HEIGHT_MAX (150) + +#define SCAN_SPEED_MS (12) + + +typedef struct CyberState { + FfxScene scene; + FfxNode bars[BAR_COUNT]; + FfxNode floor; + FfxNode scanline; + FfxNode tickerDot; +} CyberState; + + +static const uint16_t barPeriods[BAR_COUNT] = { + 1100, 870, 1450, 720, 1230, 980, 1610, 830 +}; + +static const uint16_t barPhases[BAR_COUNT] = { + 0, 200, 450, 730, 1000, 1300, 1500, 1850 +}; + + +static uint32_t triangleWave(uint32_t t, uint32_t period, uint32_t phase, + uint32_t maxVal) { + uint32_t pos = (t + phase) % period; + uint32_t half = period / 2; + if (pos < half) { return (pos * maxVal) / half; } + return maxVal - ((pos - half) * maxVal) / half; +} + +static void neonBarColor(int idx, uint8_t *r, uint8_t *g, uint8_t *b) { + static const uint8_t palette[BAR_COUNT][3] = { + { 0, 255, 65 }, // matrix green + { 0, 255, 255 }, // cyan + { 255, 0, 200 }, // hot pink + { 255, 220, 0 }, // amber + { 120, 0, 255 }, // violet + { 255, 80, 0 }, // neon orange + { 0, 140, 255 }, // electric blue + { 255, 0, 80 }, // blood red + }; + *r = palette[idx][0]; + *g = palette[idx][1]; + *b = palette[idx][2]; +} + +static void onRender(FfxEvent event, FfxEventProps props, void *_state) { + CyberState *state = _state; + uint32_t t = ticks(); + + for (int i = 0; i < BAR_COUNT; i++) { + uint32_t h = triangleWave(t, barPeriods[i], barPhases[i], + BAR_HEIGHT_MAX); + int32_t x = BAR_AREA_LEFT + i * (BAR_WIDTH + BAR_GAP); + int32_t y = BAR_FLOOR_Y - h; + ffx_sceneNode_setPosition(state->bars[i], ffx_point(x, y)); + } + + int32_t sy = (t / SCAN_SPEED_MS) % 240; + ffx_sceneNode_setPosition(state->scanline, ffx_point(0, sy)); + + int32_t tx = (t / 20) % 240; + ffx_sceneNode_setPosition(state->tickerDot, ffx_point(tx, 218)); +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + if (props.keys.down & FfxKeyCancel) { + ffx_popPanel(0); + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + CyberState *state = _state; + state->scene = scene; + + FfxNode bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(bg, ffx_color_rgb(8, 0, 16)); + ffx_sceneGroup_appendChild(panel, bg); + ffx_sceneNode_setPosition(bg, ffx_point(0, 0)); + + FfxNode title = ffx_scene_createLabel(scene, FfxFontLargeBold, + "CYBER//PULSE"); + ffx_sceneGroup_appendChild(panel, title); + ffx_sceneNode_setPosition(title, ffx_point(120, 22)); + ffx_sceneLabel_setAlign(title, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(title, ffx_color_rgb(0, 0, 0)); + + FfxNode subtitle = ffx_scene_createLabel(scene, FfxFontMedium, + "BPM 128 // SYNC OK"); + ffx_sceneGroup_appendChild(panel, subtitle); + ffx_sceneNode_setPosition(subtitle, ffx_point(120, 42)); + ffx_sceneLabel_setAlign(subtitle, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(subtitle, ffx_color_rgb(0, 0, 0)); + + FfxNode floor = ffx_scene_createBox(scene, ffx_size(240, 2)); + ffx_sceneBox_setColor(floor, ffx_color_rgb(0, 255, 65)); + ffx_sceneGroup_appendChild(panel, floor); + ffx_sceneNode_setPosition(floor, ffx_point(0, BAR_FLOOR_Y)); + state->floor = floor; + + for (int i = 0; i < BAR_COUNT; i++) { + FfxNode bar = ffx_scene_createBox(scene, + ffx_size(BAR_WIDTH, BAR_HEIGHT_MAX)); + uint8_t r, g, b; + neonBarColor(i, &r, &g, &b); + ffx_sceneBox_setColor(bar, ffx_color_rgb(r, g, b)); + ffx_sceneGroup_appendChild(panel, bar); + int32_t x = BAR_AREA_LEFT + i * (BAR_WIDTH + BAR_GAP); + ffx_sceneNode_setPosition(bar, ffx_point(x, BAR_FLOOR_Y)); + state->bars[i] = bar; + } + + FfxNode scanline = ffx_scene_createBox(scene, ffx_size(240, 2)); + ffx_sceneBox_setColor(scanline, ffx_color_rgb(180, 255, 220)); + ffx_sceneBox_setOpacity(scanline, 8); + ffx_sceneGroup_appendChild(panel, scanline); + state->scanline = scanline; + + FfxNode tickerDot = ffx_scene_createBox(scene, ffx_size(3, 3)); + ffx_sceneBox_setColor(tickerDot, ffx_color_rgb(0, 255, 255)); + ffx_sceneGroup_appendChild(panel, tickerDot); + state->tickerDot = tickerDot; + + FfxNode hint = ffx_scene_createLabel(scene, FfxFontMedium, "[CANCEL] EXIT"); + ffx_sceneGroup_appendChild(panel, hint); + ffx_sceneNode_setPosition(hint, ffx_point(120, 232)); + ffx_sceneLabel_setAlign(hint, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(hint, ffx_color_rgb(0, 0, 0)); + + ffx_onEvent(FfxEventKeys, onKeys, state); + ffx_onEvent(FfxEventRenderScene, onRender, state); + + return 0; +} + +int pushPanelCyber() { + return ffx_pushPanel(initFunc, sizeof(CyberState), NULL); +} diff --git a/main/panel-life.c b/main/panel-life.c new file mode 100644 index 0000000..49756a1 --- /dev/null +++ b/main/panel-life.c @@ -0,0 +1,251 @@ +#include +#include +#include + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#define GRID_DIM (16) +#define CELL_SIZE (14) +#define CELL_GAP (1) +#define GRID_PIXELS (GRID_DIM * (CELL_SIZE + CELL_GAP)) +#define GRID_OFFSET ((240 - GRID_PIXELS) / 2) +#define GRID_TOP (32) + +#define STEP_INTERVAL (160) + +#define PRESET_COUNT (4) + + +typedef struct LifeState { + FfxScene scene; + FfxNode cells[GRID_DIM * GRID_DIM]; + FfxNode presetLabels[PRESET_COUNT]; + + uint8_t board[GRID_DIM * GRID_DIM]; + uint8_t scratch[GRID_DIM * GRID_DIM]; + + int preset; + bool paused; + uint32_t nextStepAt; + uint32_t rngState; +} LifeState; + + +static const char *presetNames[PRESET_COUNT] = { + "GLIDER", "PULSAR", "RPENTOMINO", "RANDOM" +}; + +static uint32_t rngNext(uint32_t *s) { + uint32_t x = *s; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *s = x ? x : 0xCAFEBABE; + return *s; +} + +static void boardClear(LifeState *life) { + memset(life->board, 0, sizeof(life->board)); +} + +static void boardSet(LifeState *life, int x, int y, uint8_t v) { + if (x < 0 || x >= GRID_DIM || y < 0 || y >= GRID_DIM) { return; } + life->board[y * GRID_DIM + x] = v ? 1 : 0; +} + +static void seedGlider(LifeState *life) { + boardClear(life); + boardSet(life, 1, 0, 1); + boardSet(life, 2, 1, 1); + boardSet(life, 0, 2, 1); + boardSet(life, 1, 2, 1); + boardSet(life, 2, 2, 1); +} + +static void seedPulsar(LifeState *life) { + static const int8_t cells[][2] = { + {2,0},{3,0},{4,0},{8,0},{9,0},{10,0}, + {0,2},{5,2},{7,2},{12,2}, + {0,3},{5,3},{7,3},{12,3}, + {0,4},{5,4},{7,4},{12,4}, + {2,5},{3,5},{4,5},{8,5},{9,5},{10,5}, + {2,7},{3,7},{4,7},{8,7},{9,7},{10,7}, + {0,8},{5,8},{7,8},{12,8}, + {0,9},{5,9},{7,9},{12,9}, + {0,10},{5,10},{7,10},{12,10}, + {2,12},{3,12},{4,12},{8,12},{9,12},{10,12} + }; + boardClear(life); + int ox = (GRID_DIM - 13) / 2; + int oy = (GRID_DIM - 13) / 2; + for (size_t i = 0; i < sizeof(cells) / sizeof(cells[0]); i++) { + boardSet(life, ox + cells[i][0], oy + cells[i][1], 1); + } +} + +static void seedRPentomino(LifeState *life) { + boardClear(life); + int cx = GRID_DIM / 2; + int cy = GRID_DIM / 2; + boardSet(life, cx, cy - 1, 1); + boardSet(life, cx + 1, cy - 1, 1); + boardSet(life, cx - 1, cy, 1); + boardSet(life, cx, cy, 1); + boardSet(life, cx, cy + 1, 1); +} + +static void seedRandom(LifeState *life) { + boardClear(life); + for (int i = 0; i < GRID_DIM * GRID_DIM; i++) { + life->board[i] = (rngNext(&life->rngState) & 0x3) == 0 ? 1 : 0; + } +} + +static void applyPreset(LifeState *life) { + switch (life->preset) { + case 0: seedGlider(life); break; + case 1: seedPulsar(life); break; + case 2: seedRPentomino(life); break; + default: seedRandom(life); break; + } +} + +static void stepBoard(LifeState *life) { + for (int y = 0; y < GRID_DIM; y++) { + for (int x = 0; x < GRID_DIM; x++) { + int n = 0; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) { continue; } + int nx = (x + dx + GRID_DIM) % GRID_DIM; + int ny = (y + dy + GRID_DIM) % GRID_DIM; + n += life->board[ny * GRID_DIM + nx]; + } + } + uint8_t alive = life->board[y * GRID_DIM + x]; + life->scratch[y * GRID_DIM + x] = + (alive && (n == 2 || n == 3)) || (!alive && n == 3); + } + } + memcpy(life->board, life->scratch, sizeof(life->board)); +} + +static void renderBoard(LifeState *life) { + for (int i = 0; i < GRID_DIM * GRID_DIM; i++) { + ffx_sceneNode_setHidden(life->cells[i], !life->board[i]); + } +} + +static void updatePresetHighlight(LifeState *life) { + for (int i = 0; i < PRESET_COUNT; i++) { + ffx_sceneNode_setHidden(life->presetLabels[i], i != life->preset); + } +} + +static void onRender(FfxEvent event, FfxEventProps props, void *_state) { + LifeState *life = _state; + if (life->paused) { return; } + + uint32_t t = ticks(); + if (t < life->nextStepAt) { return; } + life->nextStepAt = t + STEP_INTERVAL; + + stepBoard(life); + renderBoard(life); +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + LifeState *life = _state; + + switch (props.keys.down) { + case FfxKeyCancel: + ffx_popPanel(0); + break; + case FfxKeyOk: + life->paused = !life->paused; + life->nextStepAt = ticks(); + break; + case FfxKeyNorth: + life->preset = (life->preset + PRESET_COUNT - 1) % PRESET_COUNT; + applyPreset(life); + renderBoard(life); + updatePresetHighlight(life); + break; + case FfxKeySouth: + life->preset = (life->preset + 1) % PRESET_COUNT; + applyPreset(life); + renderBoard(life); + updatePresetHighlight(life); + break; + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + LifeState *life = _state; + life->scene = scene; + life->preset = 0; + life->paused = false; + life->rngState = 0xDEADBEEF ^ ticks(); + + FfxNode bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(bg, ffx_color_rgb(2, 6, 4)); + ffx_sceneGroup_appendChild(panel, bg); + ffx_sceneNode_setPosition(bg, ffx_point(0, 0)); + + FfxNode title = ffx_scene_createLabel(scene, FfxFontLargeBold, "LIFE//GRID"); + ffx_sceneGroup_appendChild(panel, title); + ffx_sceneNode_setPosition(title, ffx_point(8, 16)); + ffx_sceneLabel_setAlign(title, FfxTextAlignLeft | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(title, ffx_color_rgb(0, 0, 0)); + + for (int i = 0; i < PRESET_COUNT; i++) { + FfxNode label = ffx_scene_createLabel(scene, FfxFontMedium, + presetNames[i]); + ffx_sceneGroup_appendChild(panel, label); + ffx_sceneNode_setPosition(label, ffx_point(232, 16)); + ffx_sceneLabel_setAlign(label, FfxTextAlignRight | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(label, ffx_color_rgb(0, 0, 0)); + life->presetLabels[i] = label; + } + updatePresetHighlight(life); + + for (int y = 0; y < GRID_DIM; y++) { + for (int x = 0; x < GRID_DIM; x++) { + FfxNode cell = ffx_scene_createBox(scene, + ffx_size(CELL_SIZE, CELL_SIZE)); + uint8_t g = 120 + ((x + y) * 8) % 130; + ffx_sceneBox_setColor(cell, ffx_color_rgb(0, g, 65)); + ffx_sceneGroup_appendChild(panel, cell); + ffx_sceneNode_setPosition(cell, ffx_point( + GRID_OFFSET + x * (CELL_SIZE + CELL_GAP), + GRID_TOP + y * (CELL_SIZE + CELL_GAP))); + ffx_sceneNode_setHidden(cell, true); + life->cells[y * GRID_DIM + x] = cell; + } + } + + FfxNode hint = ffx_scene_createLabel(scene, FfxFontMedium, + "N/S:SEED OK:PAUSE X:EXIT"); + ffx_sceneGroup_appendChild(panel, hint); + ffx_sceneNode_setPosition(hint, ffx_point(120, 230)); + ffx_sceneLabel_setAlign(hint, FfxTextAlignCenter | FfxTextAlignMiddle); + ffx_sceneLabel_setOutlineColor(hint, ffx_color_rgb(0, 0, 0)); + + applyPreset(life); + renderBoard(life); + life->nextStepAt = ticks() + STEP_INTERVAL; + + ffx_onEvent(FfxEventKeys, onKeys, life); + ffx_onEvent(FfxEventRenderScene, onRender, life); + + return 0; +} + +int pushPanelLife() { + return ffx_pushPanel(initFunc, sizeof(LifeState), NULL); +} diff --git a/main/panel-menu.c b/main/panel-menu.c index dd2a12e..c8d20e2 100644 --- a/main/panel-menu.c +++ b/main/panel-menu.c @@ -9,82 +9,144 @@ #include "images/image-arrow.h" +#define ITEM_COUNT (7) +#define VISIBLE_ROWS (3) +#define ROW_HEIGHT (40) +#define ROW_FIRST_Y (63) +#define ARROW_X (25) +#define ARROW_OFFSET_Y (-5) +#define LABEL_X (70) + + typedef struct State { - size_t cursor; + int cursor; + int scrollOffset; FfxScene scene; - FfxNode nodeCursor; + FfxNode arrow; + FfxNode labels[ITEM_COUNT]; } State; +static const char *menuItems[ITEM_COUNT] = { + "Wallet", + "GIFs", + "Le Space", + "Cyber Pulse", + "Life Grid", + "Byte Stream", + "Sys Stats", +}; + + +static void launchItem(int idx) { + switch (idx) { + case 0: pushPanelConnect(); break; + case 1: pushPanelGifs(); break; + case 2: pushPanelSpace(); break; + case 3: pushPanelCyber(); break; + case 4: pushPanelLife(); break; + case 5: pushPanelBytes(); break; + case 6: pushPanelStats(); break; + } +} + +static int rowYForSlot(int slot) { + return ROW_FIRST_Y + slot * ROW_HEIGHT; +} + +static void layoutItems(State *app) { + for (int i = 0; i < ITEM_COUNT; i++) { + int slot = i - app->scrollOffset; + if (slot < 0 || slot >= VISIBLE_ROWS) { + ffx_sceneNode_setHidden(app->labels[i], true); + continue; + } + ffx_sceneNode_setHidden(app->labels[i], false); + ffx_sceneNode_setPosition(app->labels[i], + ffx_point(LABEL_X, rowYForSlot(slot))); + } +} + +static void scrollToCursor(State *app) { + if (app->cursor < app->scrollOffset) { + app->scrollOffset = app->cursor; + } else if (app->cursor >= app->scrollOffset + VISIBLE_ROWS) { + app->scrollOffset = app->cursor - (VISIBLE_ROWS - 1); + } +} + +static void moveArrow(State *app, bool animated) { + int slot = app->cursor - app->scrollOffset; + int y = rowYForSlot(slot) + ARROW_OFFSET_Y; + ffx_sceneNode_stopAnimations(app->arrow, FfxSceneActionStopCurrent); + if (animated) { + ffx_sceneNode_animatePosition(app->arrow, ffx_point(ARROW_X, y), + 0, 150, FfxCurveEaseOutQuad, NULL, NULL); + } else { + ffx_sceneNode_setPosition(app->arrow, ffx_point(ARROW_X, y)); + } +} + static void onKeys(FfxEvent event, FfxEventProps props, void *_app) { State *app = _app; - - switch(props.keys.down) { - case FfxKeyOk: { - uint32_t result = 0; - switch(app->cursor) { - case 0: - result = pushPanelConnect(); - break; - case 1: -// result = pushPanelGifs(NULL); - break; - case 2: - result = pushPanelSpace(NULL); - break; - } - printf("RESULT-men: %ld\n", result); + switch (props.keys.down) { + case FfxKeyOk: + launchItem(app->cursor); return; - } case FfxKeyNorth: if (app->cursor == 0) { return; } app->cursor--; break; case FfxKeySouth: - if (app->cursor == 2) { return; } + if (app->cursor == ITEM_COUNT - 1) { return; } app->cursor++; break; default: return; } - - ffx_sceneNode_stopAnimations(app->nodeCursor, FfxSceneActionStopCurrent); - ffx_sceneNode_animatePosition(app->nodeCursor, - ffx_point(25, 58 + (app->cursor * 40)), 0, 150, - FfxCurveEaseOutQuad, NULL, NULL); + scrollToCursor(app); + layoutItems(app); + moveArrow(app, true); } static int initFunc(FfxScene scene, FfxNode node, void *_app, void *arg) { State *app = _app; app->scene = scene; + app->cursor = 0; + app->scrollOffset = 0; FfxNode box = ffx_scene_createBox(scene, ffx_size(200, 180)); ffx_sceneBox_setColor(box, RGBA_DARKER75); ffx_sceneGroup_appendChild(node, box); - ffx_sceneNode_setPosition(box, (FfxPoint){ .x = 20, .y = 30 }); - - FfxNode text; - - text = ffx_scene_createLabel(scene, FfxFontLarge, "Wallet"); - ffx_sceneGroup_appendChild(node, text); - ffx_sceneNode_setPosition(text, (FfxPoint){ .x = 70, .y = 63 }); - - text = ffx_scene_createLabel(scene, FfxFontLarge, "GIFs"); - ffx_sceneGroup_appendChild(node, text); - ffx_sceneNode_setPosition(text, (FfxPoint){ .x = 70, .y = 103 }); - - text = ffx_scene_createLabel(scene, FfxFontLarge, "Le Space"); - ffx_sceneGroup_appendChild(node, text); - ffx_sceneNode_setPosition(text, (FfxPoint){ .x = 70, .y = 143 }); + ffx_sceneNode_setPosition(box, ffx_point(20, 30)); + + FfxNode topRule = ffx_scene_createBox(scene, ffx_size(200, 1)); + ffx_sceneBox_setColor(topRule, ffx_color_rgb(0, 255, 65)); + ffx_sceneGroup_appendChild(node, topRule); + ffx_sceneNode_setPosition(topRule, ffx_point(20, 30)); + + FfxNode bottomRule = ffx_scene_createBox(scene, ffx_size(200, 1)); + ffx_sceneBox_setColor(bottomRule, ffx_color_rgb(0, 255, 65)); + ffx_sceneGroup_appendChild(node, bottomRule); + ffx_sceneNode_setPosition(bottomRule, ffx_point(20, 209)); + + for (int i = 0; i < ITEM_COUNT; i++) { + FfxNode label = ffx_scene_createLabel(scene, FfxFontLarge, + menuItems[i]); + ffx_sceneGroup_appendChild(node, label); + ffx_sceneNode_setHidden(label, true); + app->labels[i] = label; + } FfxNode cursor = ffx_scene_createImage(scene, image_arrow, sizeof(image_arrow)); ffx_sceneGroup_appendChild(node, cursor); - ffx_sceneNode_setPosition(cursor, (FfxPoint){ .x = 25, .y = 58 }); + app->arrow = cursor; - app->nodeCursor = cursor; + layoutItems(app); + moveArrow(app, false); ffx_onEvent(FfxEventKeys, onKeys, app); diff --git a/main/panel-stats.c b/main/panel-stats.c new file mode 100644 index 0000000..005c2c1 --- /dev/null +++ b/main/panel-stats.c @@ -0,0 +1,184 @@ +#include +#include + +#include "esp_chip_info.h" +#include "esp_idf_version.h" +#include "esp_system.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "firefly-hollows.h" +#include "firefly-scene.h" + +#include "panels.h" +#include "utils.h" + + +#ifndef GIT_COMMIT +#define GIT_COMMIT ("unknown") +#endif + + +#define HISTORY_LEN (60) +#define BAR_AREA_LEFT (20) +#define BAR_AREA_TOP (170) +#define BAR_HEIGHT_MAX (50) + + +typedef struct StatsState { + FfxScene scene; + + FfxNode heapHistory[HISTORY_LEN]; + FfxNode heapValueLabel; + FfxNode uptimeValueLabel; + FfxNode fpsValueLabel; + + uint32_t bootTime; + uint32_t nextSampleAt; + int historyHead; + uint32_t initialFreeHeap; + + uint32_t fpsWindowStart; + uint32_t fpsFrames; +} StatsState; + + +static FfxNode addLabel(FfxScene scene, FfxNode parent, const char *text, + int32_t x, int32_t y, FfxFont font, uint32_t align) { + FfxNode label = ffx_scene_createLabel(scene, font, text); + ffx_sceneGroup_appendChild(parent, label); + ffx_sceneNode_setPosition(label, ffx_point(x, y)); + ffx_sceneLabel_setAlign(label, align); + ffx_sceneLabel_setOutlineColor(label, COLOR_BLACK); + return label; +} + +static void renderHeapBar(StatsState *state, int slot, uint32_t freeBytes) { + if (state->initialFreeHeap == 0) { + state->initialFreeHeap = freeBytes; + } + uint32_t scale = state->initialFreeHeap; + if (scale < freeBytes) { scale = freeBytes; } + if (scale == 0) { scale = 1; } + + int32_t h = (int32_t)((freeBytes * BAR_HEIGHT_MAX) / scale); + if (h < 1) { h = 1; } + if (h > BAR_HEIGHT_MAX) { h = BAR_HEIGHT_MAX; } + + int32_t x = BAR_AREA_LEFT + slot * 3; + int32_t y = BAR_AREA_TOP + (BAR_HEIGHT_MAX - h); + ffx_sceneNode_setPosition(state->heapHistory[slot], ffx_point(x, y)); +} + +static void onRender(FfxEvent event, FfxEventProps props, void *_state) { + StatsState *state = _state; + + state->fpsFrames++; + + uint32_t t = ticks(); + if (t < state->nextSampleAt) { return; } + state->nextSampleAt = t + 250; + + uint32_t freeHeap = esp_get_free_heap_size(); + renderHeapBar(state, state->historyHead, freeHeap); + state->historyHead = (state->historyHead + 1) % HISTORY_LEN; + + ffx_sceneLabel_setTextFormat(state->heapValueLabel, "%lu KB", + (unsigned long)(freeHeap / 1024)); + + uint32_t uptimeMs = t - state->bootTime; + uint32_t secs = uptimeMs / 1000; + ffx_sceneLabel_setTextFormat(state->uptimeValueLabel, "%02lu:%02lu:%02lu", + (unsigned long)(secs / 3600), + (unsigned long)((secs / 60) % 60), + (unsigned long)(secs % 60)); + + uint32_t windowMs = t - state->fpsWindowStart; + if (windowMs >= 1000) { + uint32_t fps = (state->fpsFrames * 1000) / windowMs; + ffx_sceneLabel_setTextFormat(state->fpsValueLabel, "%lu FPS", + (unsigned long)fps); + state->fpsWindowStart = t; + state->fpsFrames = 0; + } +} + +static void onKeys(FfxEvent event, FfxEventProps props, void *_state) { + if (props.keys.down & FfxKeyCancel) { + ffx_popPanel(0); + } +} + +static int initFunc(FfxScene scene, FfxNode panel, void *_state, void *arg) { + StatsState *state = _state; + state->scene = scene; + state->bootTime = ticks(); + state->fpsWindowStart = state->bootTime; + + FfxNode bg = ffx_scene_createBox(scene, ffx_size(240, 240)); + ffx_sceneBox_setColor(bg, ffx_color_rgb(0, 8, 12)); + ffx_sceneGroup_appendChild(panel, bg); + ffx_sceneNode_setPosition(bg, ffx_point(0, 0)); + + addLabel(scene, panel, "SYS//STATS", 120, 16, FfxFontLargeBold, + FfxTextAlignCenter | FfxTextAlignMiddle); + + addLabel(scene, panel, "CHIP", 14, 50, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + esp_chip_info_t chip; + esp_chip_info(&chip); + static char chipBuf[32]; + snprintf(chipBuf, sizeof(chipBuf), "ESP32-C3 r%d %dC", + (int)chip.revision, (int)chip.cores); + addLabel(scene, panel, chipBuf, 226, 50, FfxFontMedium, + FfxTextAlignRight | FfxTextAlignMiddle); + + addLabel(scene, panel, "IDF", 14, 70, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + addLabel(scene, panel, esp_get_idf_version(), 226, 70, FfxFontMedium, + FfxTextAlignRight | FfxTextAlignMiddle); + + addLabel(scene, panel, "BUILD", 14, 90, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + addLabel(scene, panel, GIT_COMMIT, 226, 90, FfxFontMedium, + FfxTextAlignRight | FfxTextAlignMiddle); + + addLabel(scene, panel, "UPTIME", 14, 110, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + state->uptimeValueLabel = addLabel(scene, panel, "00:00:00", 226, 110, + FfxFontMedium, FfxTextAlignRight | FfxTextAlignMiddle); + + addLabel(scene, panel, "HEAP", 14, 130, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + state->heapValueLabel = addLabel(scene, panel, "-- KB", 226, 130, + FfxFontMedium, FfxTextAlignRight | FfxTextAlignMiddle); + + addLabel(scene, panel, "RENDER", 14, 150, FfxFontMedium, + FfxTextAlignLeft | FfxTextAlignMiddle); + state->fpsValueLabel = addLabel(scene, panel, "-- FPS", 226, 150, + FfxFontMedium, FfxTextAlignRight | FfxTextAlignMiddle); + + for (int i = 0; i < HISTORY_LEN; i++) { + FfxNode bar = ffx_scene_createBox(scene, ffx_size(2, BAR_HEIGHT_MAX)); + ffx_sceneBox_setColor(bar, ffx_color_rgb(0, 200, 80)); + ffx_sceneGroup_appendChild(panel, bar); + ffx_sceneNode_setPosition(bar, + ffx_point(BAR_AREA_LEFT + i * 3, BAR_AREA_TOP + BAR_HEIGHT_MAX)); + state->heapHistory[i] = bar; + } + + addLabel(scene, panel, "[CANCEL] EXIT", 120, 232, FfxFontMedium, + FfxTextAlignCenter | FfxTextAlignMiddle); + + state->nextSampleAt = state->bootTime; + + ffx_onEvent(FfxEventKeys, onKeys, state); + ffx_onEvent(FfxEventRenderScene, onRender, state); + + return 0; +} + +int pushPanelStats() { + return ffx_pushPanel(initFunc, sizeof(StatsState), NULL); +} diff --git a/main/panels.h b/main/panels.h index 884296b..920e77d 100644 --- a/main/panels.h +++ b/main/panels.h @@ -32,10 +32,6 @@ bool appendNetwork(void *info, FfxDataResult *chainId); int pushPanelMenu(); -//int pushPanelMenu(FfxInfoInitFunc initFunc, size_t stateSize, void *initArg); -//bool appendMenuItem(void *menu, const char* title, -// FfxInfoClickFunc clickFunc, FfxInfoClickArg clickArg); - /////////////////////////////// // Simple Game and Toy Panels @@ -47,12 +43,28 @@ typedef enum GameResult { } GameResult; // See: panel-gifs.c -void pushPanelGifs(); +int pushPanelGifs(); // See: panel-space.c GameResult pushPanelSpace(); +/////////////////////////////// +// Cyberdeck Demo Panels + +// See: panel-cyber.c +int pushPanelCyber(); + +// See: panel-life.c +int pushPanelLife(); + +// See: panel-bytes.c +int pushPanelBytes(); + +// See: panel-stats.c +int pushPanelStats(); + + /////////////////////////////// // Wallet Panels From a5591150f32736f89169925554b0ccd5b2ba8e30 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:24:19 +0200 Subject: [PATCH 02/13] build: add build.sh and flash.sh helpers build.sh wraps the documented Docker one-liner from README.md, with preflight checks for docker availability and submodule init. flash.sh handles the full flash without Docker passthrough quirks: * auto-detects /dev/tty.usbmodem* (macOS) and /dev/ttyACM*/ttyUSB* (linux); -p PORT, -b BAUD, ESPPORT/ESPBAUD env vars also honored * prefers host esptool.py (works without ESP-IDF installed); falls back to native idf.py if available * writes bootloader, partition table and pixie.bin at the correct offsets matching partitions.csv * --monitor flag opens a serial monitor after flashing --- build.sh | 36 +++++++++++++++++++++++ flash.sh | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100755 build.sh create mode 100755 flash.sh diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..23ea653 --- /dev/null +++ b/build.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +if ! command -v docker >/dev/null 2>&1; then + echo "error: docker not found in PATH" >&2 + echo " install Docker Desktop: https://docs.docker.com/get-docker" >&2 + exit 1 +fi + +if ! docker info >/dev/null 2>&1; then + echo "error: docker daemon is not running" >&2 + echo " start Docker Desktop and try again" >&2 + exit 1 +fi + +if [ ! -f components/firefly-hollows/include/firefly-hollows.h ]; then + echo "==> Initializing submodules" + git submodule update --init --recursive +fi + +echo "==> Building Pixie firmware (espressif/idf)" +docker run --rm \ + -v "$PWD":/project \ + -w /project \ + -e HOME=/tmp \ + espressif/idf idf.py build + +echo +echo "==> Build complete" +if [ -f build/pixie.bin ]; then + ls -lh build/pixie.bin +fi +echo +echo "Flash with: ./flash.sh" diff --git a/flash.sh b/flash.sh new file mode 100755 index 0000000..042849b --- /dev/null +++ b/flash.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac +done + +if [ -z "$PORT" ]; then + for p in /dev/tty.usbmodem* /dev/cu.usbmodem* /dev/ttyACM* /dev/ttyUSB*; do + if [ -e "$p" ]; then PORT="$p"; break; fi + done +fi + +if [ -z "$PORT" ]; then + echo "error: no serial port detected" >&2 + echo " pass -p /dev/your-port or export ESPPORT=/dev/your-port" >&2 + echo " on macOS try: ls /dev/tty.usbmodem*" >&2 + echo " on linux try: ls /dev/ttyACM* /dev/ttyUSB*" >&2 + exit 1 +fi + +if [ ! -f build/pixie.bin ]; then + echo "error: build/pixie.bin not found - run ./build.sh first" >&2 + exit 1 +fi + +echo "==> Flashing $PORT @ ${BAUD}" + +if command -v esptool.py >/dev/null 2>&1; then + esptool.py --chip esp32c3 -p "$PORT" -b "$BAUD" \ + --before default_reset --after hard_reset \ + write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m \ + 0x0 build/bootloader/bootloader.bin \ + 0x8000 build/partition_table/partition-table.bin \ + 0x10000 build/pixie.bin +elif command -v idf.py >/dev/null 2>&1; then + idf.py -p "$PORT" -b "$BAUD" flash +else + echo "error: neither esptool.py nor idf.py found in PATH" >&2 + echo " install one of:" >&2 + echo " pip install esptool" >&2 + echo " or set up ESP-IDF: https://docs.espressif.com/projects/esp-idf/" >&2 + exit 1 +fi + +echo +echo "==> Flash complete" + +if [ "$MONITOR" = "1" ]; then + if command -v idf.py >/dev/null 2>&1; then + exec idf.py -p "$PORT" monitor + elif command -v screen >/dev/null 2>&1; then + exec screen "$PORT" 115200 + else + echo "warning: no monitor tool available (idf.py or screen)" >&2 + fi +else + echo "Monitor with: ./flash.sh --monitor (or) screen $PORT 115200" +fi From 0c72cd566066ed5b1f62a5a21e1783a378abc4e0 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:27:51 +0200 Subject: [PATCH 03/13] build: pin idf image to v5.4.1 espressif/idf:latest now ships ESP-IDF 6.1, which fails to bootstrap on a project last configured against 5.4.1 (component manager looks for files that don't exist yet on a fresh 6.x build). Pin to the project's actual IDF version. Override with IDF_IMAGE env var. --- build.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index 23ea653..ad1f065 100755 --- a/build.sh +++ b/build.sh @@ -20,12 +20,17 @@ if [ ! -f components/firefly-hollows/include/firefly-hollows.h ]; then git submodule update --init --recursive fi -echo "==> Building Pixie firmware (espressif/idf)" +# Pin the IDF image - the project's sdkconfig was generated against +# ESP-IDF 5.4.1 (see commit 547c1e1) and `espressif/idf:latest` (6.x) +# fails to bootstrap on it. Override with IDF_IMAGE if you know better. +IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.4.1}" + +echo "==> Building Pixie firmware ($IDF_IMAGE)" docker run --rm \ -v "$PWD":/project \ -w /project \ -e HOME=/tmp \ - espressif/idf idf.py build + "$IDF_IMAGE" idf.py build echo echo "==> Build complete" From b232fe7ac3eb809aa54a265e33cc7b9be1199b58 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:32:31 +0200 Subject: [PATCH 04/13] fix: replace undefined COLOR_BACK with COLOR_NAVONLY; bump idf to v5.5.4 panel-tx.c referenced COLOR_BACK in three places (lines 125, 237, 338) for the 'BACK' info-button color, but no such symbol exists in the pinned firefly-color.h. The semantically-correct replacement is COLOR_NAVONLY, which firefly-hollows.h documents as the back-button color. Same upstream-vs-submodule drift as the ffx_init signature. Also bump the default Docker image from v5.4.1 to v5.5.4 - the latest v5.5 line builds the project cleanly and is better-supported. --- build.sh | 8 ++++---- main/panel-tx.c | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/build.sh b/build.sh index ad1f065..928f1ae 100755 --- a/build.sh +++ b/build.sh @@ -20,10 +20,10 @@ if [ ! -f components/firefly-hollows/include/firefly-hollows.h ]; then git submodule update --init --recursive fi -# Pin the IDF image - the project's sdkconfig was generated against -# ESP-IDF 5.4.1 (see commit 547c1e1) and `espressif/idf:latest` (6.x) -# fails to bootstrap on it. Override with IDF_IMAGE if you know better. -IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.4.1}" +# Pin the IDF image - `espressif/idf:latest` (6.x) fails to bootstrap +# on this project. v5.5.x is the most recent line known to build cleanly. +# Override with IDF_IMAGE if you know better. +IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.5.4}" echo "==> Building Pixie firmware ($IDF_IMAGE)" docker run --rm \ diff --git a/main/panel-tx.c b/main/panel-tx.c index 5e953c5..935bebd 100644 --- a/main/panel-tx.c +++ b/main/panel-tx.c @@ -122,7 +122,7 @@ static int initAddressFunc(void *info, void *_state, void *_arg) { } // Buttons - ffx_appendInfoButton(info, "BACK", COLOR_BACK, clickApprove, NULLARG); + ffx_appendInfoButton(info, "BACK", COLOR_NAVONLY, clickApprove, NULLARG); return 0; } @@ -234,7 +234,7 @@ static int initDataFunc(void *info, void *_state, void *arg) { } // Buttons - ffx_appendInfoButton(info, "BACK", COLOR_BACK, clickApprove, NULLARG); + ffx_appendInfoButton(info, "BACK", COLOR_NAVONLY, clickApprove, NULLARG); return true; } @@ -335,7 +335,7 @@ static int initNetworkFunc(void *info, void *_state, void *arg) { ffx_appendInfoEntry(info, "CHAIN ID", str, NULL, NULLARG); // Buttons - ffx_appendInfoButton(info, "BACK", COLOR_BACK, clickApprove, NULLARG); + ffx_appendInfoButton(info, "BACK", COLOR_NAVONLY, clickApprove, NULLARG); return 0; } From 651cdb5c79a6ab220bc2ebe6b60c301ca8afe723 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:41:15 +0200 Subject: [PATCH 05/13] build: auto-clean build/ when IDF_IMAGE changes Switching ESP-IDF minor versions (5.4 -> 5.5) leaves an incompatible cmake cache pointing at the previous python_env, producing a 'currently active env doesn't match' error. Track the image used for the last build in build/.idf-image and wipe build/ on mismatch. --- build.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/build.sh b/build.sh index 928f1ae..455d1b9 100755 --- a/build.sh +++ b/build.sh @@ -25,6 +25,16 @@ fi # Override with IDF_IMAGE if you know better. IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.5.4}" +# Switching IDF major.minor versions reuses incompatible cmake cache +# (different python_env path). Wipe build/ when the image changes. +if [ -d build ] && [ -f build/.idf-image ]; then + cached=$(cat build/.idf-image) + if [ "$cached" != "$IDF_IMAGE" ]; then + echo "==> IDF image changed ($cached -> $IDF_IMAGE); cleaning build/" + rm -rf build + fi +fi + echo "==> Building Pixie firmware ($IDF_IMAGE)" docker run --rm \ -v "$PWD":/project \ @@ -32,6 +42,8 @@ docker run --rm \ -e HOME=/tmp \ "$IDF_IMAGE" idf.py build +mkdir -p build && echo "$IDF_IMAGE" > build/.idf-image + echo echo "==> Build complete" if [ -f build/pixie.bin ]; then From e6d14dc4525c431f47bd34c92bf8a3f2405d84e2 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:45:36 +0200 Subject: [PATCH 06/13] build: patch missing hollows.h include in pinned firefly-hollows The pinned firefly-hollows commit (2fbda9d) has a real bug: src/task-ble.c references TaskBleInit (defined in src/hollows.h) without including it, so any clean build of this project fails inside the submodule. The next upstream commit (1f55b89) adds the missing include, but the same commit also reshuffles the FfxKey bit assignments. Inheriting that without a coordinated downstream update is a regression risk we want to keep out of this PR. build.sh now applies a narrow, idempotent in-place patch to add the missing include before invoking docker. Real fix belongs upstream as a PR against firefly/component-hollows pulling out just the include change. --- build.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/build.sh b/build.sh index 455d1b9..7fe1d47 100755 --- a/build.sh +++ b/build.sh @@ -20,6 +20,29 @@ if [ ! -f components/firefly-hollows/include/firefly-hollows.h ]; then git submodule update --init --recursive fi +# Workaround for a bug in the pinned firefly-hollows commit (2fbda9d): +# src/task-ble.c references TaskBleInit (defined in src/hollows.h) without +# including it, so the build fails. Upstream fixed this in commit 1f55b89, +# but that commit also reshuffles the FfxKey bit assignments, which is too +# risky to inherit without also updating downstream code. Patch in place; +# idempotent so re-runs are safe. Real fix: open PR against +# firefly/component-hollows to land just the include change. +hollows_ble="components/firefly-hollows/src/task-ble.c" +if [ -f "$hollows_ble" ] && ! grep -q '^#include "hollows.h"' "$hollows_ble"; then + echo "==> patching $hollows_ble (add missing #include \"hollows.h\")" + awk ' + /^#include "firefly-tx.h"/ && !patched { + print + print "" + print "// Patched by build.sh: pinned commit is missing this include" + print "#include \"hollows.h\"" + patched = 1 + next + } + { print } + ' "$hollows_ble" > "$hollows_ble.tmp" && mv "$hollows_ble.tmp" "$hollows_ble" +fi + # Pin the IDF image - `espressif/idf:latest` (6.x) fails to bootstrap # on this project. v5.5.x is the most recent line known to build cleanly. # Override with IDF_IMAGE if you know better. From ccff0ee94c5029e2dff8ef26fd9101f975037100 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 00:51:58 +0200 Subject: [PATCH 07/13] build: patch missing comma in pinned hollows.c TaskBleInit init components/firefly-hollows/src/hollows.c line 132 reads: TaskBleInit init = { .version = version <-- missing comma .ready = xSemaphoreCreateBinaryStatic(&readyBuffer) }; Without the comma the compiler parses '.ready' as a member access on the integer 'version', producing 'request for member ready in something not a structure or union' and the unused-but-set-parameter error on 'version'. Same upstream-bug class as the missing include - apply an idempotent in-place fix from build.sh. --- build.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/build.sh b/build.sh index 7fe1d47..19976c9 100755 --- a/build.sh +++ b/build.sh @@ -43,6 +43,16 @@ if [ -f "$hollows_ble" ] && ! grep -q '^#include "hollows.h"' "$hollows_ble"; th ' "$hollows_ble" > "$hollows_ble.tmp" && mv "$hollows_ble.tmp" "$hollows_ble" fi +# Same root cause - hollows.c:132 has a malformed initializer; the +# `.version = version` line is missing a trailing comma, so the next +# `.ready` field is parsed as a member access on `version`. +hollows_c="components/firefly-hollows/src/hollows.c" +if [ -f "$hollows_c" ] && grep -q '^[[:space:]]*\.version = version$' "$hollows_c"; then + echo "==> patching $hollows_c (add missing comma in TaskBleInit init)" + sed -i.bak 's/^\([[:space:]]*\)\.version = version$/\1.version = version,/' \ + "$hollows_c" && rm -f "$hollows_c.bak" +fi + # Pin the IDF image - `espressif/idf:latest` (6.x) fails to bootstrap # on this project. v5.5.x is the most recent line known to build cleanly. # Override with IDF_IMAGE if you know better. From 32f981cc6be517e7f2c06f5e59a8d874755bee92 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:01:48 +0200 Subject: [PATCH 08/13] build: flash.sh --monitor falls back to docker idf.py monitor screen reads raw bytes and prints panic addresses unsymbolicated, which is useless for debugging boot-time crashes. When neither host idf.py nor esptool but docker is available, run the monitor inside the same pinned IDF container - it reads build/pixie.elf and resolves PCs to source locations. --- flash.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/flash.sh b/flash.sh index 042849b..8da0080 100755 --- a/flash.sh +++ b/flash.sh @@ -77,10 +77,19 @@ echo "==> Flash complete" if [ "$MONITOR" = "1" ]; then if command -v idf.py >/dev/null 2>&1; then exec idf.py -p "$PORT" monitor + elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + # idf.py monitor inside docker - symbolicates panic addresses by + # reading build/pixie.elf, which is much more useful than raw screen. + IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.5.4}" + exec docker run --rm -it \ + -v "$PWD":/project -w /project -e HOME=/tmp \ + --device "$PORT" \ + "$IDF_IMAGE" idf.py -p "$PORT" monitor elif command -v screen >/dev/null 2>&1; then + echo "note: 'screen' won't symbolicate panic addresses; install esptool/idf for that" >&2 exec screen "$PORT" 115200 else - echo "warning: no monitor tool available (idf.py or screen)" >&2 + echo "warning: no monitor tool available (idf.py, docker, or screen)" >&2 fi else echo "Monitor with: ./flash.sh --monitor (or) screen $PORT 115200" From 0416cfd88e2f35c54a46a961b901dabb2a8de622 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:03:52 +0200 Subject: [PATCH 09/13] build: wrap ble_hs_id_copy_addr with host lock for IDF v5.5 NimBLE ESP-IDF v5.5's NimBLE tightened locking on ble_hs_id_copy_addr - it asserts the caller holds the host lock and panics otherwise. The pinned firefly-hollows task-ble.c calls it bare from onSync(), so the device boot-loops with: assert failed: ble_hs_id_addr ble_hs_id.c:295 (ble_hs_locked_by_cur_task()) Add ble_hs_lock()/ble_hs_unlock() around the call via build.sh patch. --- build.sh | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/build.sh b/build.sh index 19976c9..a381309 100755 --- a/build.sh +++ b/build.sh @@ -53,6 +53,23 @@ if [ -f "$hollows_c" ] && grep -q '^[[:space:]]*\.version = version$' "$hollows_ "$hollows_c" && rm -f "$hollows_c.bak" fi +# IDF v5.5's NimBLE asserts that ble_hs_id_copy_addr is called with the +# host lock held; the pinned hollows code calls it bare from onSync, so +# the device panic-reboots right after BLE init. Wrap with lock/unlock. +if [ -f "$hollows_ble" ] && \ + grep -q '^ rc = ble_hs_id_copy_addr(conn.own_addr_type, conn.address, NULL);$' "$hollows_ble"; then + echo "==> patching $hollows_ble (wrap ble_hs_id_copy_addr with host lock)" + awk ' + /^ rc = ble_hs_id_copy_addr\(conn\.own_addr_type, conn\.address, NULL\);$/ { + print " ble_hs_lock();" + print $0 + print " ble_hs_unlock();" + next + } + { print } + ' "$hollows_ble" > "$hollows_ble.tmp" && mv "$hollows_ble.tmp" "$hollows_ble" +fi + # Pin the IDF image - `espressif/idf:latest` (6.x) fails to bootstrap # on this project. v5.5.x is the most recent line known to build cleanly. # Override with IDF_IMAGE if you know better. From a99b8119d0ea722bc38faaa4431bb569836a61bc Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:09:32 +0200 Subject: [PATCH 10/13] build: forward-declare ble_hs_lock/unlock; sentinel-guard the patch ble_hs_lock and ble_hs_unlock exist in NimBLE's lib but are not in the public ble_hs.h on this version, so add inline forward declarations. Use 'extern void ble_hs_lock' as the idempotency sentinel so re-runs of build.sh don't double-wrap the call. --- build.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build.sh b/build.sh index a381309..063c257 100755 --- a/build.sh +++ b/build.sh @@ -56,11 +56,16 @@ fi # IDF v5.5's NimBLE asserts that ble_hs_id_copy_addr is called with the # host lock held; the pinned hollows code calls it bare from onSync, so # the device panic-reboots right after BLE init. Wrap with lock/unlock. +# ble_hs_lock/unlock exist in the lib but aren't in the public ble_hs.h +# of this NimBLE version, so forward-declare them inline. if [ -f "$hollows_ble" ] && \ + ! grep -q 'extern void ble_hs_lock' "$hollows_ble" && \ grep -q '^ rc = ble_hs_id_copy_addr(conn.own_addr_type, conn.address, NULL);$' "$hollows_ble"; then echo "==> patching $hollows_ble (wrap ble_hs_id_copy_addr with host lock)" awk ' /^ rc = ble_hs_id_copy_addr\(conn\.own_addr_type, conn\.address, NULL\);$/ { + print " extern void ble_hs_lock(void);" + print " extern void ble_hs_unlock(void);" print " ble_hs_lock();" print $0 print " ble_hs_unlock();" From 75a52090185b0ad5036f88cfb0f54f126f27f553 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:14:31 +0200 Subject: [PATCH 11/13] build: prefer esp-idf-monitor over docker --device on macOS Docker Desktop on macOS runs the engine in a Linux VM, so the --device flag can't reach host /dev/tty.usbmodem* paths and the container exits with 'no such file or directory'. esp-idf-monitor is a standalone pip package that does the same panic symbolication as 'idf.py monitor' without needing the full IDF. Resolution order is now native idf.py, then esp-idf-monitor, then docker (Linux only), then screen as last resort. --- flash.sh | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/flash.sh b/flash.sh index 8da0080..15e5404 100755 --- a/flash.sh +++ b/flash.sh @@ -77,19 +77,27 @@ echo "==> Flash complete" if [ "$MONITOR" = "1" ]; then if command -v idf.py >/dev/null 2>&1; then exec idf.py -p "$PORT" monitor - elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then - # idf.py monitor inside docker - symbolicates panic addresses by - # reading build/pixie.elf, which is much more useful than raw screen. + elif python3 -c 'import esp_idf_monitor' >/dev/null 2>&1; then + # Standalone monitor (pip install esp-idf-monitor). Reads pixie.elf + # and resolves panic PCs to source locations - same UX as idf.py + # monitor, no ESP-IDF or Docker needed. + exec python3 -m esp_idf_monitor -p "$PORT" build/pixie.elf + elif [ "$(uname -s)" = "Linux" ] && command -v docker >/dev/null 2>&1 \ + && docker info >/dev/null 2>&1; then + # Docker --device passthrough only works on Linux; on macOS Docker + # Desktop runs in a VM and host /dev/tty paths aren't visible. IDF_IMAGE="${IDF_IMAGE:-espressif/idf:v5.5.4}" exec docker run --rm -it \ -v "$PWD":/project -w /project -e HOME=/tmp \ --device "$PORT" \ "$IDF_IMAGE" idf.py -p "$PORT" monitor elif command -v screen >/dev/null 2>&1; then - echo "note: 'screen' won't symbolicate panic addresses; install esptool/idf for that" >&2 + echo "note: 'screen' won't symbolicate panic addresses; install" >&2 + echo " esp-idf-monitor for that: pip install esp-idf-monitor" >&2 exec screen "$PORT" 115200 else - echo "warning: no monitor tool available (idf.py, docker, or screen)" >&2 + echo "warning: no monitor tool available" >&2 + echo " install one: pip install esp-idf-monitor" >&2 fi else echo "Monitor with: ./flash.sh --monitor (or) screen $PORT 115200" From 6286d59c96ae2eb36362941c1c693873ffa5832b Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:20:27 +0200 Subject: [PATCH 12/13] build: lock both ble_hs_id_* calls in onSync, not just copy_addr The previous patch only wrapped ble_hs_id_copy_addr, but ble_hs_id_infer_auto on the line above also goes through ble_hs_id_addr internally and hits the same lock-held assert first. Move the lock to cover the entire id-resolution sequence in onSync. --- build.sh | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/build.sh b/build.sh index 063c257..e87f8bf 100755 --- a/build.sh +++ b/build.sh @@ -53,22 +53,30 @@ if [ -f "$hollows_c" ] && grep -q '^[[:space:]]*\.version = version$' "$hollows_ "$hollows_c" && rm -f "$hollows_c.bak" fi -# IDF v5.5's NimBLE asserts that ble_hs_id_copy_addr is called with the -# host lock held; the pinned hollows code calls it bare from onSync, so -# the device panic-reboots right after BLE init. Wrap with lock/unlock. -# ble_hs_lock/unlock exist in the lib but aren't in the public ble_hs.h -# of this NimBLE version, so forward-declare them inline. +# IDF v5.5's NimBLE asserts that the host lock is held when calling the +# id helpers (ble_hs_id_addr, called internally by both ble_hs_id_infer_auto +# and ble_hs_id_copy_addr). Pinned hollows code calls both bare from +# onSync, so the device panic-reboots right after BLE init. Wrap the +# whole sequence in one lock/unlock pair. ble_hs_lock/unlock exist in +# the NimBLE lib but aren't in the public include path, so forward-declare. if [ -f "$hollows_ble" ] && \ ! grep -q 'extern void ble_hs_lock' "$hollows_ble" && \ - grep -q '^ rc = ble_hs_id_copy_addr(conn.own_addr_type, conn.address, NULL);$' "$hollows_ble"; then - echo "==> patching $hollows_ble (wrap ble_hs_id_copy_addr with host lock)" + grep -q '^ rc = ble_hs_id_infer_auto(0, &conn.own_addr_type);$' "$hollows_ble"; then + echo "==> patching $hollows_ble (wrap onSync ble_hs_id_* calls with lock)" awk ' - /^ rc = ble_hs_id_copy_addr\(conn\.own_addr_type, conn\.address, NULL\);$/ { + /^ rc = ble_hs_id_infer_auto\(0, &conn\.own_addr_type\);$/ { print " extern void ble_hs_lock(void);" print " extern void ble_hs_unlock(void);" print " ble_hs_lock();" print $0 + in_block = 1 + next + } + in_block && /^ print_addr/ { print " ble_hs_unlock();" + print "" + print $0 + in_block = 0 next } { print } From ebab773540cc736a4c948bc7fd68e5461c14b086 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 May 2026 01:30:42 +0200 Subject: [PATCH 13/13] build: skip NimBLE host init to bypass BLE_HS_DEBUG inconsistency Replaced the (incorrect) host-lock wrapper patch with an early vTaskDelete in taskBleFunc, just after the bootstrap semaphore is given. NimBLE in IDF v5.5 has an internally inconsistent BLE_HS_DEBUG configuration where BLE_HS_DBG_ASSERT is active but the ble_hs_lock_nested bookkeeping that would mark the owning task is gated differently, so ble_hs_locked_by_cur_task always returns false and the assert in ble_hs_id_addr fires right after the BLE host task starts. The cyberdeck demos run entirely on the display/keypad path and do not need BLE. The wallet panel needs it but is broken at this commit regardless. Real fix belongs upstream as a NimBLE/IDF-version-aware update of firefly-hollows. --- build.sh | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/build.sh b/build.sh index e87f8bf..c3f43ad 100755 --- a/build.sh +++ b/build.sh @@ -53,30 +53,26 @@ if [ -f "$hollows_c" ] && grep -q '^[[:space:]]*\.version = version$' "$hollows_ "$hollows_c" && rm -f "$hollows_c.bak" fi -# IDF v5.5's NimBLE asserts that the host lock is held when calling the -# id helpers (ble_hs_id_addr, called internally by both ble_hs_id_infer_auto -# and ble_hs_id_copy_addr). Pinned hollows code calls both bare from -# onSync, so the device panic-reboots right after BLE init. Wrap the -# whole sequence in one lock/unlock pair. ble_hs_lock/unlock exist in -# the NimBLE lib but aren't in the public include path, so forward-declare. +# IDF v5.5's NimBLE has an internally-inconsistent BLE_HS_DEBUG config: +# the BLE_HS_DBG_ASSERT macro is active and asserts ble_hs_locked_by_cur_task, +# but the lock-bookkeeping in ble_hs_lock_nested that would set the owning +# task handle never runs in this code path, so the assert always fails +# right after the BLE host task starts. The cyberdeck demos don't need +# BLE; bypass the host init by exiting taskBleFunc right after it signals +# ready. Wallet panel won't work, but it's broken on this commit anyway. if [ -f "$hollows_ble" ] && \ - ! grep -q 'extern void ble_hs_lock' "$hollows_ble" && \ - grep -q '^ rc = ble_hs_id_infer_auto(0, &conn.own_addr_type);$' "$hollows_ble"; then - echo "==> patching $hollows_ble (wrap onSync ble_hs_id_* calls with lock)" + ! grep -q 'PIXIE-PATCH: skip-ble-init' "$hollows_ble" && \ + grep -q '^ xSemaphoreGive(init->ready);$' "$hollows_ble"; then + echo "==> patching $hollows_ble (skip BLE host init for demo build)" awk ' - /^ rc = ble_hs_id_infer_auto\(0, &conn\.own_addr_type\);$/ { - print " extern void ble_hs_lock(void);" - print " extern void ble_hs_unlock(void);" - print " ble_hs_lock();" + /^ xSemaphoreGive\(init->ready\);$/ && !patched { print $0 - in_block = 1 - next - } - in_block && /^ print_addr/ { - print " ble_hs_unlock();" print "" - print $0 - in_block = 0 + print " // PIXIE-PATCH: skip-ble-init - bypass NimBLE host init," + print " // which trips an internally-inconsistent BLE_HS_DEBUG" + print " // assert in ble_hs_id_addr on IDF v5.5." + print " vTaskDelete(NULL);" + patched = 1 next } { print }