onlyn00bs-badge

OnlyN00bs: a DEF CON 34 friend-finder badge. ESP32 firmware, Web Bluetooth setup app, printable case
git clone https://git.virtualshack.io/onlyn00bs-badge.git
Log | Files | Refs | README | LICENSE

display_oled.cpp (39743B)


      1 // Monochrome OLED implementation of the display interface — SSD1309 2.42"
      2 // (128×64) over I2C, via U8g2. This is the chosen badge display: self-lit
      3 // (readable in a dark venue), dirt-simple, and a single 18650 runs it for days.
      4 //
      5 // 128×64 is tight, so layouts are deliberately terse: a header line + a handful
      6 // of rows. Handles are truncated to fit. Pixels are the only power cost, so we
      7 // keep the screen mostly dark.
      8 
      9 #if defined(BADGE_DISPLAY_OLED)
     10 
     11 #include <Arduino.h>
     12 #include <U8g2lib.h>
     13 #include <math.h>
     14 #include "display.h"
     15 #include "config.h"
     16 #include "logos.h"       // GENERATED — boot-card bitmaps
     17 
     18 // Hardware-I2C SSD1309. Last two args are SCL/SDA for ESP32 (see config.h).
     19 static U8G2_SSD1309_128X64_NONAME0_F_HW_I2C
     20     u8g2(U8G2_R0, U8X8_PIN_NONE, OLED_SCL_PIN, OLED_SDA_PIN);
     21 
     22 #define ROW_FONT  u8g2_font_6x10_tf      // ~6px wide → ~21 chars, ~6 rows tall
     23 #define ROW_CHARS 21
     24 
     25 static void truncate(char* dst, const char* src, int maxch) {
     26   int i = 0;
     27   for (; src[i] && i < maxch; i++) dst[i] = src[i];
     28   dst[i] = '\0';
     29 }
     30 
     31 // Inverse-video multiplier chip ("x2"/"x3") — the group-bonus indicator on the
     32 // FINDER and POINTS headers. Draws right-aligned ending at `right_x` in the
     33 // header band (y 0..8); nothing at mult<2. Returns the horizontal space taken
     34 // (width + gap) so callers can keep right-stacking header elements.
     35 static int draw_mult_chip(int right_x, uint8_t mult) {
     36   if (mult < 2) return 0;
     37   char t[4];
     38   snprintf(t, sizeof(t), "x%u", (unsigned)mult);
     39   int w = u8g2.getStrWidth(t) + 4;          // 2px pad each side
     40   u8g2.drawRBox(right_x - w, 0, w, 9, 1);
     41   u8g2.setDrawColor(0);                     // cut the label out of the box
     42   u8g2.drawStr(right_x - w + 2, 7, t);
     43   u8g2.setDrawColor(1);
     44   return w + 3;
     45 }
     46 
     47 // 4-segment signal bar (like a phone), filled to the RSSI level. Inactive slots
     48 // render as a 1px base stub, NOT an outline: the bars are only 2px wide, and a
     49 // 2px-wide drawFrame() is just its left+right edges — i.e. both columns — so it
     50 // is indistinguishable from a filled box. (That bug made every bar look full.)
     51 static void draw_bars(int x, int baseline, int8_t rssi) {
     52   int lvl = rssi >= -55 ? 4 : rssi >= -65 ? 3 : rssi >= -75 ? 2 : rssi >= -85 ? 1 : 0;
     53   for (int i = 0; i < 4; i++) {
     54     int h = 2 + i * 2;
     55     int bx = x + i * 3;
     56     if (i < lvl) u8g2.drawBox(bx, baseline - h, 2, h);  // filled to level
     57     else         u8g2.drawBox(bx, baseline - 1, 2, 1);  // empty: base stub
     58   }
     59 }
     60 
     61 // Line plot of the points samples in the box (x,y)-(x+w,y+h). Y auto-scales to
     62 // the window's [min,max] points (flat series → baseline). X is real wall-clock
     63 // over the fixed POINTS_GRAPH_SPAN_S window (anchored at the oldest sample) when
     64 // the clock is synced; otherwise it falls back to uniform spacing. A sample
     65 // flagged seg_start (the first after a reboot) breaks the line — the badge has
     66 // no RTC so we mark the discontinuity without sizing the off-period.
     67 // Plots samples within a time window: span_s wide, anchored at `anchor` (epoch).
     68 // 5-day view → span=5d, anchor=oldest sample (all samples shown). 24h view →
     69 // span=24h, anchor=newest−24h (only the last day shown, Y rescaled to it). When
     70 // the clock was never synced, falls back to uniform spacing over all samples.
     71 static void draw_sparkline(int x, int y, int w, int h, const PointSample* v, int n,
     72                            uint32_t span_s, uint32_t anchor, bool synced) {
     73   if (n < 1) return;
     74   // First pass: in-window count + Y range (in-window = epoch ≥ anchor, or all if unsynced).
     75   int inwin = 0; bool any = false; uint16_t mn = 0, mx = 0;
     76   for (int i = 0; i < n; i++) {
     77     if (synced && v[i].epoch < anchor) continue;
     78     inwin++;
     79     if (!any) { mn = mx = v[i].points; any = true; }
     80     else { if (v[i].points < mn) mn = v[i].points; if (v[i].points > mx) mx = v[i].points; }
     81   }
     82   if (!any) return;
     83   uint32_t yspan = (uint32_t)mx - mn;
     84   int prevx = 0, prevy = 0; bool first = true; int idx = 0;
     85   for (int i = 0; i < n; i++) {
     86     if (synced && v[i].epoch < anchor) continue;
     87     int px;
     88     if (synced) {
     89       uint32_t dt = (v[i].epoch > anchor) ? (v[i].epoch - anchor) : 0;
     90       if (dt > span_s) dt = span_s;
     91       px = x + (int)(dt * (uint32_t)(w - 1) / span_s);
     92     } else {
     93       px = x + (inwin == 1 ? 0 : (idx * (w - 1)) / (inwin - 1));
     94     }
     95     int py = yspan == 0 ? (y + h - 1)
     96                         : (y + (h - 1) - (int)(((uint32_t)(v[i].points - mn) * (h - 1)) / yspan));
     97     if (first || v[i].seg_start) u8g2.drawPixel(px, py);   // segment break
     98     else                         u8g2.drawLine(prevx, prevy, px, py);
     99     prevx = px; prevy = py; first = false; idx++;
    100   }
    101 }
    102 
    103 // I2C bus recovery — un-stick a wedged bus before init. If the ESP32 was reset
    104 // via EN mid-transaction (a serial-monitor/OTA reset, or the physical RST button:
    105 // EN resets the MCU but NOT the OLED, which stays powered on 3V3), the SSD1309 can
    106 // be left holding SDA low. The hardware-I2C driver then returns ESP_ERR_INVALID_STATE
    107 // on every write and the panel stays dark until a full power-cycle. Clock SCL up to
    108 // 9 times to let the slave finish its hung byte + ACK and release SDA, then issue a
    109 // STOP. Bit-banged before u8g2.begin() claims the pins. No-op on a healthy bus.
    110 static void i2c_bus_recover() {
    111   pinMode(OLED_SDA_PIN, INPUT_PULLUP);
    112   pinMode(OLED_SCL_PIN, OUTPUT);
    113   for (int i = 0; i < 9 && digitalRead(OLED_SDA_PIN) == LOW; i++) {
    114     digitalWrite(OLED_SCL_PIN, HIGH); delayMicroseconds(5);   // ~100kHz, matches the driver
    115     digitalWrite(OLED_SCL_PIN, LOW);  delayMicroseconds(5);
    116   }
    117   // STOP: SDA rises while SCL is high.
    118   pinMode(OLED_SDA_PIN, OUTPUT);
    119   digitalWrite(OLED_SDA_PIN, LOW);  delayMicroseconds(5);
    120   digitalWrite(OLED_SCL_PIN, HIGH); delayMicroseconds(5);
    121   digitalWrite(OLED_SDA_PIN, HIGH); delayMicroseconds(5);
    122   // Release both lines; u8g2.begin() re-inits the hardware I2C peripheral on these pins.
    123   pinMode(OLED_SDA_PIN, INPUT_PULLUP);
    124   pinMode(OLED_SCL_PIN, INPUT_PULLUP);
    125 }
    126 
    127 void display_init() {
    128   i2c_bus_recover();     // un-wedge a bus left stuck by an EN-only reset before init
    129   u8g2.begin();
    130   u8g2.setFont(ROW_FONT);
    131 }
    132 
    133 // Deep-sleep companion: SSD1309 display-off (sleep mode). Blanking the buffer would NOT
    134 // do — an all-black frame still runs the panel's charge pump and draws nearly full
    135 // current. setPowerSave(1) is the register write that actually stops it.
    136 void display_sleep() {
    137   u8g2.setPowerSave(1);
    138 }
    139 
    140 // Level index → SSD1309 contrast register. u8g2's begin() already programs
    141 // OLED_BRIGHT_MED as part of the SSD1309 init sequence, so an unset/invalid level
    142 // lands on the panel's pre-existing look rather than a surprise.
    143 void display_set_brightness(uint8_t level) {
    144   static const uint8_t kContrast[SCREEN_BRIGHT_LEVELS] =
    145       { OLED_BRIGHT_LOW, OLED_BRIGHT_MED, OLED_BRIGHT_HIGH };
    146   if (level >= SCREEN_BRIGHT_LEVELS) level = SCREEN_BRIGHT_DEFAULT;
    147   u8g2.setContrast(kContrast[level]);
    148 }
    149 
    150 void display_boot(const char* line) {
    151   u8g2.clearBuffer();
    152   u8g2.setFont(ROW_FONT);
    153   u8g2.drawStr(0, 12, "friend-finder");
    154   u8g2.drawStr(0, 28, line);
    155   u8g2.sendBuffer();
    156 }
    157 
    158 // Heat + LiPo-safety disclaimer, shown once at boot (after the splash). A static text
    159 // card; blocking — it holds itself on screen for DISCLAIMER_HOLD_MS × BOOT_TIMING_SCALE
    160 // (boot only) then returns. Header in the 6x10 row font; the body uses the smaller 5x7
    161 // font (25 chars/line vs 21) so the full note fits the 128px width. ASCII-only.
    162 void display_disclaimer() {
    163   u8g2.clearBuffer();
    164   u8g2.setFont(ROW_FONT);
    165   u8g2.drawStr(0, 8, "// WARNING");
    166   u8g2.drawHLine(0, 11, 128);
    167   u8g2.setFont(u8g2_font_5x7_tf);                    // body: 5px/char → 25 fit vs 21
    168   u8g2.drawStr(0, 22, "This badge runs on a LIPO");
    169   u8g2.drawStr(0, 32, "battery. We're in Vegas.");
    170   u8g2.drawStr(0, 42, "Keep an eye on how hot");
    171   u8g2.drawStr(0, 52, "it gets. Spicy pillow?");
    172   u8g2.drawStr(0, 62, "Discontinue use.");
    173   u8g2.sendBuffer();
    174   u8g2.setFont(ROW_FONT);                            // restore the default for later screens
    175   delay((uint32_t)DISCLAIMER_HOLD_MS * BOOT_TIMING_SCALE);
    176 }
    177 
    178 // ─── Boot splash animations ──────────────────────────────────────────────────
    179 // One of the BOOT_SPLASH_COUNT styles plays once at power-on (BOOT_SPLASH_* in
    180 // config.h). All are blocking — they run in setup() before any mode screen.
    181 
    182 // Final resting frame shared by radar + glitch: wordmark + signal-bar flourish.
    183 static void splash_wordmark() {
    184   u8g2.clearBuffer();
    185   u8g2.setFont(u8g2_font_7x13B_tf);
    186   const char* t = "FRIEND-FINDER";
    187   u8g2.drawStr((128 - u8g2.getStrWidth(t)) / 2, 24, t);
    188   const int bx = 40, by = 46;                     // rising bars, centered
    189   for (int i = 0; i < 8; i++) { int h = 2 + i; u8g2.drawBox(bx + i * 6, by - h, 4, h); }
    190   u8g2.setFont(ROW_FONT);
    191   char v[ROW_CHARS + 1];
    192   snprintf(v, sizeof(v), "%s ch%d", FW_BUILD_STR, ESPNOW_CHANNEL);   // version + rev + channel
    193   u8g2.drawStr((128 - u8g2.getStrWidth(v)) / 2, 62, v);
    194   u8g2.sendBuffer();
    195   delay(500 * BOOT_TIMING_SCALE);
    196 }
    197 
    198 // Style 0 — sonar sweep around a scope; crew "blips" ping in as the sweep passes
    199 // them, then it resolves to the wordmark. Mirrors what the badge does.
    200 static void splash_radar() {
    201   const int cx = 64, cy = 31, R = 30;
    202   const float D2R = 0.017453293f;
    203   struct { int deg, r; } blip[] = { {55, 18}, {140, 27}, {235, 12}, {310, 23} };
    204   const int NB = 4;
    205   bool found[NB] = { false, false, false, false };
    206   for (int s = 0; s <= 540; s += 12) {            // 1.5 sweeps
    207     float a = (s - 90) * D2R;
    208     u8g2.clearBuffer();
    209     u8g2.drawCircle(cx, cy, R);
    210     u8g2.drawCircle(cx, cy, R * 2 / 3);
    211     u8g2.drawPixel(cx, cy);
    212     u8g2.drawLine(cx, cy, cx + (int)(R * cosf(a)), cy + (int)(R * sinf(a)));
    213     for (int i = 0; i < NB; i++) {
    214       if (s >= blip[i].deg) found[i] = true;      // discovered as the sweep crosses it
    215       if (found[i]) {
    216         float ba = (blip[i].deg - 90) * D2R;
    217         u8g2.drawDisc(cx + (int)(blip[i].r * cosf(ba)), cy + (int)(blip[i].r * sinf(ba)), 2);
    218       }
    219     }
    220     u8g2.sendBuffer();
    221     delay(22 * BOOT_TIMING_SCALE);
    222   }
    223   splash_wordmark();
    224 }
    225 
    226 // Style 1 — POST-style typewriter self-test, revealed line by line, char by char.
    227 static void splash_terminal() {
    228   char l0[32];
    229   snprintf(l0, sizeof(l0), "./friend-finder_%d_%d.sh",           // banner = "run" the versioned binary
    230            FW_VERSION_MAJOR, FW_VERSION_MINOR);
    231   char lb[32];
    232   snprintf(lb, sizeof(lb), "> beacon  ... ch %d", ESPNOW_CHANNEL);  // read the define, never hardcode it:
    233                                                                     //   the POST used to claim "ch 1" no
    234                                                                     //   matter what the radio actually did
    235   const char* L[] = {
    236     l0, "> radio   ..... OK", "> crypto  ..... OK",
    237     "> fs      ..... OK", "> clock   .... SET", lb,
    238     "badge online",
    239   };
    240   const int NL = 7;
    241   u8g2.setFont(u8g2_font_5x8_tf);
    242   char shown[8][24];
    243   for (int i = 0; i < NL; i++) shown[i][0] = '\0';
    244   for (int i = 0; i < NL; i++) {
    245     int len = (int)strlen(L[i]);
    246     for (int c = 1; c <= len; c++) {
    247       strncpy(shown[i], L[i], c); shown[i][c] = '\0';
    248       u8g2.clearBuffer();
    249       for (int j = 0; j <= i; j++) u8g2.drawStr(2, 9 + j * 8, shown[j]);
    250       u8g2.drawBox(2 + c * 5, 9 + i * 8 - 7, 4, 8);   // blinking-ish cursor block
    251       u8g2.sendBuffer();
    252       delay(7 * BOOT_TIMING_SCALE);
    253     }
    254   }
    255   delay(350 * BOOT_TIMING_SCALE);
    256 }
    257 
    258 // Style 2 — wordmark assembles out of scanline noise, then the bars sweep in.
    259 static void splash_glitch() {
    260   randomSeed(micros());
    261   const char* t1 = "FRIEND";
    262   const char* t2 = "FINDER";
    263   u8g2.setFont(u8g2_font_8x13B_tf);
    264   for (int f = 0; f < 22; f++) {
    265     u8g2.clearBuffer();
    266     int jx = (f < 16) ? (int)random(-3, 4) : 0;       // jitter settles late
    267     u8g2.drawStr((128 - u8g2.getStrWidth(t1)) / 2 + jx, 24, t1);
    268     u8g2.drawStr((128 - u8g2.getStrWidth(t2)) / 2 - jx, 40, t2);
    269     int noise = 120 - f * 6; if (noise < 0) noise = 0; // thinning static
    270     for (int i = 0; i < noise; i++) u8g2.drawPixel(random(0, 128), random(0, 64));
    271     u8g2.sendBuffer();
    272     delay(28 * BOOT_TIMING_SCALE);
    273   }
    274   for (int k = 1; k <= 8; k++) {                       // bars sweep in
    275     u8g2.clearBuffer();
    276     u8g2.setFont(u8g2_font_8x13B_tf);
    277     u8g2.drawStr((128 - u8g2.getStrWidth(t1)) / 2, 24, t1);
    278     u8g2.drawStr((128 - u8g2.getStrWidth(t2)) / 2, 40, t2);
    279     for (int i = 0; i < k; i++) { int h = 2 + i; u8g2.drawBox(40 + i * 6, 60 - h, 4, h); }
    280     u8g2.sendBuffer();
    281     delay(35 * BOOT_TIMING_SCALE);
    282   }
    283   delay(250 * BOOT_TIMING_SCALE);
    284 }
    285 
    286 // Style 3 — glyph rain: columns of falling characters, each with a bright head and a
    287 // trail that fades out behind it, resolving to the wordmark. The alphabet is just the
    288 // letters of "onlyn00bs" (so 'n' and '0' fall twice as often, which suits the wordmark).
    289 //
    290 // The panel is 1-bit, so a real brightness ramp down the trail isn't available. Depth is
    291 // faked with three tiers instead: the head is drawn INVERSE (a filled cell with the glyph
    292 // knocked out of it, the brightest thing on screen), the body is plain glyphs, and the
    293 // last two cells get alternate scanlines knocked out — a 50% dither that reads as dim.
    294 static void splash_matrix() {
    295   static const char GLYPHS[] = "onlyn00bs";
    296   const int NG   = (int)sizeof(GLYPHS) - 1;
    297   const int CW   = 5, CH = 8;               // 5x7 font in a 5x8 cell
    298   const int COLS = 128 / CW;                // 25 columns (125px; 3px slack at the right)
    299   const int ROWS = 64 / CH;                 // 8 rows
    300 
    301   // Position is 1/16th-row fixed point so columns can fall at different sub-row speeds;
    302   // stepping in whole rows would make every column move in lockstep. static, not stack:
    303   // this is ~250B and setup()'s stack has better uses.
    304   static int16_t pos[COLS];                 // head position, 1/16 row units (negative = above screen)
    305   static uint8_t vel[COLS];                 // fall rate in the same units per frame
    306   static uint8_t len[COLS];                 // trail length, cells
    307   static uint8_t cell[COLS][ROWS];          // which glyph sits in each grid cell
    308 
    309   randomSeed(micros());
    310   for (int c = 0; c < COLS; c++) {
    311     pos[c] = -(int16_t)random(0, ROWS * 16 * 2);   // stagger entry so they don't arrive as a wall
    312     vel[c] = 3 + random(0, 8);                     // ~0.2–0.6 rows/frame
    313     len[c] = 3 + random(0, ROWS - 2);
    314     for (int r = 0; r < ROWS; r++) cell[c][r] = random(0, NG);
    315   }
    316 
    317   u8g2.setFont(u8g2_font_5x7_tf);
    318   for (int f = 0; f < 46; f++) {
    319     u8g2.clearBuffer();
    320     for (int c = 0; c < COLS; c++) {
    321       int head = pos[c] >> 4;
    322       for (int t = 0; t < len[c]; t++) {
    323         int r = head - t;
    324         if (r < 0 || r >= ROWS) continue;
    325         int x = c * CW, ytop = r * CH;
    326         char s[2] = { GLYPHS[cell[c][r]], '\0' };
    327         if (t == 0) {                              // head — inverse cell
    328           u8g2.drawBox(x, ytop, CW, CH);
    329           u8g2.setDrawColor(0);
    330           u8g2.drawStr(x, ytop + 7, s);
    331           u8g2.setDrawColor(1);
    332         } else {
    333           u8g2.drawStr(x, ytop + 7, s);
    334           if (t >= len[c] - 2) {                   // tail — dither to half density
    335             u8g2.setDrawColor(0);
    336             for (int yy = ytop; yy < ytop + CH; yy += 2) u8g2.drawHLine(x, yy, CW);
    337             u8g2.setDrawColor(1);
    338           }
    339         }
    340       }
    341       pos[c] += vel[c];
    342       if ((pos[c] >> 4) - len[c] >= ROWS) {        // whole trail cleared the bottom → respawn
    343         pos[c] = -(int16_t)random(0, ROWS * 16);
    344         vel[c] = 3 + random(0, 8);
    345         len[c] = 3 + random(0, ROWS - 2);
    346       }
    347       if (random(0, 6) == 0)                       // shimmer: without this the columns just
    348         cell[c][random(0, ROWS)] = random(0, NG);  //   scroll rigidly and it reads as a texture
    349     }
    350     u8g2.sendBuffer();
    351     delay(22 * BOOT_TIMING_SCALE);                 // same frame budget as the radar sweep
    352   }
    353   splash_wordmark();
    354 }
    355 
    356 void display_boot_splash(int style) {
    357   switch (style) {
    358     case BOOT_SPLASH_TERMINAL: splash_terminal(); break;
    359     case BOOT_SPLASH_GLITCH:   splash_glitch();   break;
    360     case BOOT_SPLASH_MATRIX:   splash_matrix();   break;
    361     case BOOT_SPLASH_RADAR:
    362     default:                   splash_radar();    break;
    363   }
    364   delay(1000);                                     // hold the final splash frame 1s longer
    365   u8g2.setFont(ROW_FONT);                          // restore default for later screens
    366 }
    367 
    368 // The boot logo card: full-screen frames, each mark centred on the whole 128×64 panel.
    369 // That's the only arrangement where these marks stay legible — a denser single-frame
    370 // layout was tried on glass and rejected (see config.h). Geometry comes from the W/H that
    371 // logos.h carries, so re-baking at a different size needs no change here.
    372 //
    373 // The set is currently the wordmark alone. LOGO_EMBLEM_* and LOGO_FOOT_* are still baked
    374 // into logos.h, so restoring either is one more entry in this array — the per-frame hold
    375 // divides the fixed budget, so boot time won't move when you do.
    376 void display_boot_logos() {
    377   static const struct { const unsigned char* bits; uint8_t w, h; } card[] = {
    378     { LOGO_ONLYNOOBS_BITS, LOGO_ONLYNOOBS_W, LOGO_ONLYNOOBS_H },
    379   };
    380   const uint8_t n = sizeof(card) / sizeof(card[0]);
    381   for (uint8_t i = 0; i < n; i++) {
    382     u8g2.clearBuffer();
    383     u8g2.drawXBMP((128 - card[i].w) / 2, (64 - card[i].h) / 2,
    384                   card[i].w, card[i].h, card[i].bits);
    385     u8g2.sendBuffer();
    386     delay(BOOT_LOGO_TOTAL_MS / n);
    387   }
    388   u8g2.setFont(ROW_FONT);                          // restore default for later screens
    389 }
    390 
    391 void display_provisioning(const char* device_name, const char* code) {
    392   char buf[32];
    393   u8g2.clearBuffer();
    394   u8g2.setFont(ROW_FONT);
    395   u8g2.drawStr(0, 10, "SETUP MODE");
    396   u8g2.setFont(u8g2_font_5x8_tf);            // 5px wide → the full badge-setup-<MAC> name fits one line
    397   truncate(buf, device_name, 25);            // 25 * 5px = 125 < 128
    398   u8g2.drawStr(0, 21, buf);
    399   // pairing code — big, the web app needs it to unlock provisioning
    400   u8g2.setFont(ROW_FONT);
    401   u8g2.drawStr(0, 41, "code:");
    402   u8g2.setFont(u8g2_font_9x18B_tf);
    403   u8g2.drawStr(36, 43, code);
    404   u8g2.setFont(u8g2_font_5x8_tf);
    405   // The escape hatch wins this line over the old "enter code in the app". The code is
    406   // already labelled above and the web app tells you what to do with it, but nothing
    407   // else on the device tells you setup mode can be left — and it never times out.
    408   // 25 chars exactly: 25 * 5px = 125 < 128.
    409   u8g2.drawStr(0, 60, "hold BTN 5s to exit setup");
    410   u8g2.sendBuffer();
    411 }
    412 
    413 void display_peers(const char* my_handle, const Peer* peers, int n, bool show_rssi,
    414                    uint8_t mult, int page) {
    415   (void)my_handle;                        // OLED header is a static title, like the other views
    416   char buf[ROW_CHARS + 1];
    417   u8g2.clearBuffer();
    418   u8g2.setFont(ROW_FONT);
    419 
    420   // Paginate: FINDER_PAGE_ROWS closest peers per screen. Clamp defensively (main.cpp
    421   // already clamps as peers leave, but never trust the caller for indexing).
    422   int pages = (n + FINDER_PAGE_ROWS - 1) / FINDER_PAGE_ROWS;
    423   if (pages < 1) pages = 1;
    424   if (page >= pages) page = pages - 1;
    425   if (page < 0)     page = 0;
    426   int start = page * FINDER_PAGE_ROWS;
    427   int end   = n < start + FINDER_PAGE_ROWS ? n : start + FINDER_PAGE_ROWS;
    428 
    429   // header: "// finder" title + right-aligned "(N)" peer count, plus a "p/P" page tag
    430   // (only when there's more than one page). The inverse multiplier chip sits left of it
    431   // when a group bonus is active. (Column mode lives in User Settings → Finder View.)
    432   u8g2.drawStr(0, 8, "// finder");
    433   if (pages > 1) snprintf(buf, sizeof(buf), "(%d) %d/%d", n, page + 1, pages);
    434   else           snprintf(buf, sizeof(buf), "(%d)", n);
    435   int rw = u8g2.getStrWidth(buf);
    436   u8g2.drawStr(128 - rw, 8, buf);
    437   draw_mult_chip(128 - rw - 3, mult);
    438   u8g2.drawHLine(0, 11, 128);
    439 
    440   if (n == 0) {
    441     u8g2.drawStr(0, 30, "nobody in range");
    442     u8g2.sendBuffer();
    443     return;
    444   }
    445 
    446   for (int i = start; i < end; i++) {     // this page's slice (≤ FINDER_PAGE_ROWS rows)
    447     int y = 23 + (i - start) * 10;        // baselines: 23,33,43,53,63
    448     draw_bars(0, y, peers[i].rssi);
    449     // right column toggles between the peer's pet face and signed dBm; the bars
    450     // always show signal, so the column is free to carry the pet by default.
    451     char rcol[PET_FACE_MAX + 1];
    452     if (show_rssi) snprintf(rcol, sizeof(rcol), "%d", peers[i].rssi);
    453     else           truncate(rcol, peers[i].pet_face, 8);
    454     int rcol_w = u8g2.getStrWidth(rcol);
    455     u8g2.drawStr(128 - rcol_w, y, rcol);
    456     // handle fills whatever's left between the bars and the right column
    457     int maxch = (128 - rcol_w - 2 - 16) / 6;
    458     if (maxch > ROW_CHARS) maxch = ROW_CHARS;
    459     if (maxch < 0) maxch = 0;
    460     truncate(buf, peers[i].handle, maxch);
    461     u8g2.drawStr(16, y, buf);
    462   }
    463   u8g2.sendBuffer();
    464 }
    465 
    466 static void fmt_dur(uint32_t s, char* out, size_t n) {
    467   uint32_t h = s / 3600, m = (s % 3600) / 60;
    468   if (h) snprintf(out, n, "%uh%02um", (unsigned)h, (unsigned)m);
    469   else   snprintf(out, n, "%um", (unsigned)m);
    470 }
    471 
    472 void display_report(const char* my_handle, const Report& r) {
    473   char buf[ROW_CHARS + 1], dur[16];
    474   (void)my_handle;
    475   u8g2.clearBuffer();
    476   u8g2.setFont(ROW_FONT);
    477 
    478   u8g2.drawStr(0, 8, "// con report");
    479   if (!r.time_synced) {                       // relative times → nudge a clock re-sync
    480     const char* tag = "resync";
    481     u8g2.drawStr(128 - u8g2.getStrWidth(tag), 8, tag);
    482   }
    483   u8g2.drawHLine(0, 11, 128);
    484 
    485   fmt_dur(r.total_seconds, dur, sizeof(dur));
    486   snprintf(buf, sizeof(buf), "%df %dx %s", r.unique, r.total_encounters, dur);
    487   u8g2.drawStr(0, 23, buf);
    488 
    489   int shown = r.n < 4 ? r.n : 4;          // top 4 friends
    490   for (int i = 0; i < shown; i++) {
    491     int y = 35 + i * 10;                  // 35,45,55,(63)
    492     if (y > 63) break;
    493     fmt_dur(r.friends[i].total_seconds, dur, sizeof(dur));
    494     char name[15];
    495     truncate(name, r.friends[i].handle, 11);
    496     snprintf(buf, sizeof(buf), "%-11s x%u %s", name,
    497              (unsigned)r.friends[i].encounters, dur);
    498     u8g2.drawStr(0, y, buf);
    499   }
    500   u8g2.sendBuffer();
    501 }
    502 
    503 void display_points(const char* my_handle, uint32_t points,
    504                     uint32_t prox_seconds, int peers_now, bool earning,
    505                     const PointSample* hist, int hist_n,
    506                     uint32_t graph_span_s, const char* window_label, bool anchor_newest,
    507                     uint8_t mult, bool stealth) {
    508   char buf[ROW_CHARS + 1];
    509   (void)my_handle;
    510   u8g2.clearBuffer();
    511   u8g2.setFont(ROW_FONT);
    512 
    513   // header: "// points" + window tag + [x2 chip] + right-aligned score. The graph
    514   // owns the body now, so the score lives up here instead of as the big number.
    515   u8g2.drawStr(0, 8, "// points");
    516   u8g2.drawStr(60, 8, window_label);
    517   snprintf(buf, sizeof(buf), "%up", (unsigned)points);
    518   int sw = u8g2.getStrWidth(buf);
    519   u8g2.drawStr(128 - sw, 8, buf);
    520   draw_mult_chip(128 - sw - 3, mult);
    521   u8g2.drawHLine(0, 11, 128);
    522 
    523   // points-over-time line graph fills the middle band (y 14..50)
    524   if (hist_n >= 2) {
    525     bool synced = (hist[hist_n - 1].epoch != 0);
    526     uint32_t anchor = (anchor_newest && synced && hist[hist_n - 1].epoch > graph_span_s)
    527                         ? hist[hist_n - 1].epoch - graph_span_s : hist[0].epoch;
    528     draw_sparkline(0, 14, 128, 37, hist, hist_n, graph_span_s, anchor, synced);
    529   } else {
    530     u8g2.drawStr(0, 34, "collecting graph...");
    531   }
    532 
    533   // status line: live countdown when earning, else why we're paused. With a
    534   // group bonus the tally ticks ×mult per wall second, so the wall-clock wait
    535   // to the next point is the remainder ÷ mult (rounded up).
    536   if (earning) {
    537     uint32_t rem = 60 - prox_seconds % 60;
    538     if (mult > 1) rem = (rem + mult - 1) / mult;
    539     snprintf(buf, sizeof(buf), "+1 pt in %us", (unsigned)rem);
    540   }
    541   else if (stealth)                      // check first: crew can be right here and in-gate,
    542     snprintf(buf, sizeof(buf), "paused  stealth on");   // so "too far" would be a lie
    543   else if (peers_now > 0)
    544     snprintf(buf, sizeof(buf), "paused  crew too far");
    545   else
    546     snprintf(buf, sizeof(buf), "paused  no crew");
    547   u8g2.drawStr(0, 62, buf);
    548 
    549   u8g2.sendBuffer();
    550 }
    551 
    552 void display_battery(const char* my_handle, uint16_t mv, uint8_t pct, bool low) {
    553   char buf[ROW_CHARS + 1];
    554   (void)my_handle;
    555   u8g2.clearBuffer();
    556   u8g2.setFont(ROW_FONT);
    557 
    558   // header: "// battery               LOW!"
    559   u8g2.drawStr(0, 8, "// battery");
    560   if (low) {
    561     const char* w = "LOW!";
    562     u8g2.drawStr(128 - u8g2.getStrWidth(w), 8, w);
    563   }
    564   u8g2.drawHLine(0, 11, 128);
    565 
    566   // big percentage, centered (digit font for the number + small "%" after it)
    567   snprintf(buf, sizeof(buf), "%u", (unsigned)pct);
    568   u8g2.setFont(u8g2_font_logisoso28_tn);
    569   int pw = u8g2.getStrWidth(buf);
    570   u8g2.setFont(ROW_FONT);
    571   int sw = u8g2.getStrWidth("%");
    572   int px = (128 - (pw + 3 + sw)) / 2;                 // center the "XX%" block
    573   u8g2.setFont(u8g2_font_logisoso28_tn);
    574   u8g2.drawStr(px, 44, buf);
    575   u8g2.setFont(ROW_FONT);
    576   u8g2.drawStr(px + pw + 3, 44, "%");
    577 
    578   // voltage on the right, e.g. "3.85V"
    579   snprintf(buf, sizeof(buf), "%u.%02uV", mv / 1000, (mv % 1000) / 10);
    580   u8g2.drawStr(128 - u8g2.getStrWidth(buf), 30, buf);
    581 
    582   // full-width charge bar along the bottom
    583   const int bx = 0, by = 52, bw = 128, bh = 11;
    584   u8g2.drawFrame(bx, by, bw, bh);
    585   int fill = (int)((long)(bw - 2) * pct / 100);
    586   if (fill > 0) u8g2.drawBox(bx + 1, by + 1, fill, bh - 2);
    587 
    588   u8g2.sendBuffer();
    589 }
    590 
    591 // "742K" / "1.2M" / "60B" — a compact size for the 128px-wide readout.
    592 static void fmt_bytes(size_t b, char* out, size_t n) {
    593   if      (b >= 1024UL * 1024UL) snprintf(out, n, "%u.%uM", (unsigned)(b >> 20),
    594                                           (unsigned)((((b >> 10) & 1023) * 10) >> 10));
    595   else if (b >= 1024UL)          snprintf(out, n, "%uK", (unsigned)(b >> 10));
    596   else                           snprintf(out, n, "%uB", (unsigned)b);
    597 }
    598 
    599 // Deliberately the same furniture as display_battery (header + big centred %, a figure on
    600 // the right, a full-width bar) — it's the same view under a single tap, so it should read
    601 // as one. It does invert the battery's polarity, though: a disk bar fills as the disk
    602 // fills, per convention, so the number and the bar both report USED while the battery face
    603 // reports what's left. Free space is still carried by the "871K free" label on the right.
    604 void display_storage(const char* my_handle, size_t used, size_t total, bool full) {
    605   char buf[ROW_CHARS + 1], sz[12];
    606   (void)my_handle;
    607   size_t freeb = (total > used) ? total - used : 0;
    608   // Drives BOTH the big number and the bar, so they can never disagree.
    609   uint8_t pct  = total ? (uint8_t)((uint64_t)used * 100 / total) : 0;    // % USED
    610 
    611   u8g2.clearBuffer();
    612   u8g2.setFont(ROW_FONT);
    613 
    614   // header: "// storage               FULL!"
    615   u8g2.drawStr(0, 8, "// storage");
    616   if (full) {
    617     const char* w = "FULL!";
    618     u8g2.drawStr(128 - u8g2.getStrWidth(w), 8, w);
    619   }
    620   u8g2.drawHLine(0, 11, 128);
    621 
    622   // big used percentage, centered (digit font for the number + small "%" after it)
    623   snprintf(buf, sizeof(buf), "%u", (unsigned)pct);
    624   u8g2.setFont(u8g2_font_logisoso28_tn);
    625   int pw = u8g2.getStrWidth(buf);
    626   u8g2.setFont(ROW_FONT);
    627   int sw = u8g2.getStrWidth("%");
    628   int px = (128 - (pw + 3 + sw)) / 2;                 // center the "XX%" block
    629   u8g2.setFont(u8g2_font_logisoso28_tn);
    630   u8g2.drawStr(px, 44, buf);
    631   u8g2.setFont(ROW_FONT);
    632   u8g2.drawStr(px + pw + 3, 44, "%");
    633 
    634   // free space on the right, e.g. "742K free" — the remaining-space figure the % no longer carries
    635   fmt_bytes(freeb, sz, sizeof(sz));
    636   snprintf(buf, sizeof(buf), "%s free", sz);
    637   u8g2.drawStr(128 - u8g2.getStrWidth(buf), 30, buf);
    638 
    639   // full-width usage bar along the bottom — fills as the disk fills
    640   const int bx = 0, by = 52, bw = 128, bh = 11;
    641   u8g2.drawFrame(bx, by, bw, bh);
    642   int fill = (int)((long)(bw - 2) * pct / 100);
    643   if (fill > 0) u8g2.drawBox(bx + 1, by + 1, fill, bh - 2);
    644 
    645   u8g2.sendBuffer();
    646 }
    647 
    648 void display_friends(const char* my_handle, const PointFriend* top, int n,
    649                      uint32_t total_points) {
    650   char buf[ROW_CHARS + 1], name[16];
    651   (void)my_handle;
    652   u8g2.clearBuffer();
    653   u8g2.setFont(ROW_FONT);
    654 
    655   u8g2.drawStr(0, 8, "// best friends");
    656   u8g2.drawHLine(0, 11, 128);
    657 
    658   if (n == 0) {
    659     u8g2.drawStr(0, 30, "no points yet");
    660     u8g2.sendBuffer();
    661     return;
    662   }
    663 
    664   int shown = n < 3 ? n : 3;              // podium: top 3
    665   for (int i = 0; i < shown; i++) {
    666     int y = 24 + i * 12;                  // baselines 24,36,48
    667     truncate(name, top[i].handle, 14);
    668     snprintf(buf, sizeof(buf), "%d %s", i + 1, name);
    669     u8g2.drawStr(0, y, buf);
    670     snprintf(buf, sizeof(buf), "%u", (unsigned)(top[i].seconds / 60));
    671     u8g2.drawStr(128 - u8g2.getStrWidth(buf), y, buf);   // points, right-aligned
    672   }
    673 
    674   snprintf(buf, sizeof(buf), "of %u pts total", (unsigned)total_points);
    675   u8g2.drawStr(0, 62, buf);
    676   u8g2.sendBuffer();
    677 }
    678 
    679 void display_pet(const char* name, const char* face) {
    680   char buf[ROW_CHARS + 1];
    681   u8g2.clearBuffer();
    682   u8g2.setFont(ROW_FONT);
    683   snprintf(buf, sizeof(buf), "// %s", name);
    684   u8g2.drawStr(0, 8, buf);
    685   u8g2.drawHLine(0, 11, 128);
    686   // big ASCII face, centered in the body
    687   u8g2.setFont(u8g2_font_9x18B_tf);
    688   u8g2.drawStr((128 - u8g2.getStrWidth(face)) / 2, 40, face);
    689   // "tap to swap" hint (points/peers readout removed)
    690   u8g2.setFont(ROW_FONT);
    691   u8g2.drawStr(128 - u8g2.getStrWidth("tap:swap"), 62, "tap:swap");
    692   u8g2.sendBuffer();
    693 }
    694 
    695 // Tiny battery glyph (11×7 body + 2px terminal nub); inner fill ∝ pct. Top-left at (x,y).
    696 static void draw_batt_glyph(int x, int y, uint8_t pct) {
    697   u8g2.drawFrame(x, y, 11, 7);                    // body outline
    698   u8g2.drawBox(x + 11, y + 2, 2, 3);              // + terminal nub on the right
    699   uint8_t fw = (uint8_t)((pct * 9 + 50) / 100);   // inner fill width 0..9 (rounded)
    700   if (fw) u8g2.drawBox(x + 1, y + 1, fw, 5);      // fill from the left
    701 }
    702 
    703 // Tiny floppy-disk glyph — 9×7 static, matching the battery glyph's height. Shutter on top,
    704 // label below, both permanent:
    705 //     #########
    706 //     #..###..#   shutter
    707 //     #..###..#
    708 //     #.......#
    709 //     #.#####.#   label
    710 //     #.#####.#
    711 //     #########
    712 // Deliberately NOT a fill gauge. A proportional fill failed at both ends: at a realistic 3%
    713 // used it left the label blank — an empty box with a slot, unreadable as a disk — and near
    714 // full it swallowed the icon into a blob. There are only ~35 interior pixels here, not enough
    715 // to be an icon AND a gauge, and the adjacent "%" text already carries the number. So the
    716 // glyph's one job is to say "disk". Hand-drawn rather than Open Iconic: the smallest iconic
    717 // size (1x) is 8×8 and overflows this 7px band. Top-left at (x,y).
    718 static void draw_disk_glyph(int x, int y) {
    719   u8g2.drawFrame(x, y, 9, 7);                     // disk body
    720   u8g2.drawBox(x + 3, y + 1, 3, 2);               // shutter, top-centre
    721   u8g2.drawBox(x + 2, y + 4, 5, 2);               // label
    722 }
    723 
    724 void display_home(const char* handle, const char* face, uint32_t points,
    725                   uint8_t batt_pct, bool batt_low, uint8_t disk_pct, const char* clock_str,
    726                   bool radio_on) {
    727   char buf[ROW_CHARS + 1], name[20];
    728   u8g2.clearBuffer();
    729   u8g2.setFont(ROW_FONT);
    730 
    731   // header: "// home" (left), then disk glyph + % and battery glyph + % (right). Clock retired.
    732   (void)clock_str;   // wall-clock display being retired
    733   u8g2.drawStr(0, 8, "// home");
    734   // battery: glyph + %, right-aligned. When low: a steady "!" after the % + a slow glyph blink.
    735   snprintf(buf, sizeof(buf), "%u%%%s", (unsigned)batt_pct, batt_low ? "!" : "");
    736   int pw = u8g2.getStrWidth(buf);
    737   u8g2.drawStr(128 - pw, 8, buf);                      // percentage (+ "!" when low) at the far right
    738   int batt_x = 128 - pw - 15;                          // 13px glyph + 2px gap. Computed even while the
    739                                                        //   glyph is blinked off, so storage never shifts.
    740   if (!batt_low || (millis() / BATT_LOW_BLINK_MS) % 2 == 0)
    741     draw_batt_glyph(batt_x, 2, batt_pct);              // blinks while low
    742   // storage: glyph + % used, parked left of the battery block. Worst realistic case (both
    743   // reading 100%) still clears the "// home" title by ~8px.
    744   snprintf(buf, sizeof(buf), "%u%%", (unsigned)disk_pct);
    745   int dw = u8g2.getStrWidth(buf);
    746   u8g2.drawStr(batt_x - 4 - dw, 8, buf);               // 4px gap between the two blocks
    747   draw_disk_glyph(batt_x - 4 - dw - 11, 2);            // 9px glyph + 2px gap; the % text is the readout
    748   u8g2.drawHLine(0, 11, 128);
    749 
    750   // username — prominent, centered
    751   truncate(name, handle, 18);
    752   u8g2.setFont(u8g2_font_7x13B_tf);
    753   u8g2.drawStr((128 - u8g2.getStrWidth(name)) / 2, 28, name);
    754 
    755   // pet face (left) + total points (right), sharing the middle band
    756   u8g2.setFont(u8g2_font_9x18B_tf);
    757   u8g2.drawStr(4, 48, face);
    758   u8g2.setFont(ROW_FONT);
    759   snprintf(buf, sizeof(buf), "%u pts", (unsigned)points);
    760   u8g2.drawStr(128 - u8g2.getStrWidth(buf), 46, buf);
    761 
    762   // Stealth tell, in the free band under the face/points row. Inverse so it can't be
    763   // mistaken for a stat, and named for the mode rather than the failure so it reads as
    764   // deliberate. The header is already full (title + disk + battery) and its worst case
    765   // leaves ~6px, so this lives down here where nothing can collide with it.
    766   if (!radio_on) {
    767     const char* lbl = "STEALTH";
    768     int tw = u8g2.getStrWidth(lbl);
    769     int bw = tw + 6;                                   // 3px pad each side
    770     u8g2.drawRBox((128 - bw) / 2, 53, bw, 11, 1);
    771     u8g2.setDrawColor(0);                              // cut the label out of the box
    772     u8g2.drawStr((128 - tw) / 2, 62, lbl);
    773     u8g2.setDrawColor(1);
    774   }
    775 
    776   u8g2.sendBuffer();
    777 }
    778 
    779 // Factory-reset confirmation. Body in the 5x7 font (25 chars/line vs 21) so the list of
    780 // what actually gets destroyed fits without abbreviating it into uselessness.
    781 void display_reset_confirm(int cursor) {
    782   u8g2.clearBuffer();
    783   u8g2.setFont(ROW_FONT);
    784   u8g2.drawStr(0, 8, "// factory reset");
    785   u8g2.drawHLine(0, 11, 128);
    786   u8g2.setFont(u8g2_font_5x7_tf);
    787   u8g2.drawStr(0, 21, "Erases your handle,");
    788   u8g2.drawStr(0, 30, "avatar, points, log");
    789   u8g2.drawStr(0, 39, "and settings. Undo: no.");
    790   u8g2.setFont(ROW_FONT);
    791   static const char* opt[2] = { "Cancel", "ERASE" };     // Cancel first = the default row
    792   for (int i = 0; i < 2; i++) {
    793     int y = 50 + i * 11;                                 // 50 → 43..52, 61 → 54..63; fits 64px
    794     if (i == cursor) u8g2.drawStr(2, y, ">");
    795     u8g2.drawStr(14, y, opt[i]);
    796   }
    797   u8g2.sendBuffer();
    798 }
    799 
    800 void display_menu(int splash_sel) {
    801   static const char* names[BOOT_SPLASH_COUNT] = { "radar", "terminal", "glitch", "matrix" };
    802   u8g2.clearBuffer();
    803   u8g2.setFont(ROW_FONT);
    804   u8g2.drawStr(0, 8, "// boot splash");
    805   u8g2.drawHLine(0, 11, 128);
    806   // 22/10 rather than the old 24/11: with a 4th style the last row's baseline landed at 57
    807   // (glyphs 50..59) and collided with the "tap: change" hint at 62 (55..64). This clears it.
    808   for (int i = 0; i < BOOT_SPLASH_COUNT; i++) {
    809     int y = 22 + i * 10;
    810     if (i == splash_sel) u8g2.drawStr(2, y, ">");
    811     u8g2.drawStr(14, y, names[i]);
    812   }
    813   u8g2.drawStr(0, 62, "tap: change");
    814   u8g2.sendBuffer();
    815 }
    816 
    817 void display_whoami(const char* handle, const char* mac) {
    818   char buf[ROW_CHARS + 1], line[ROW_CHARS + 1];
    819   u8g2.clearBuffer();
    820   u8g2.setFont(ROW_FONT);
    821   u8g2.drawStr(0, 10, "// whoami");
    822   u8g2.drawHLine(0, 13, 128);
    823   truncate(buf, (handle && handle[0]) ? handle : "(unset)", ROW_CHARS - 6);
    824   snprintf(line, sizeof(line), "user: %s", buf);
    825   u8g2.drawStr(0, 30, line);
    826   u8g2.drawStr(0, 46, "mac:");
    827   u8g2.drawStr(0, 61, mac);                       // "14:33:5C:xx:xx:xx" — 17 chars fits one line
    828   u8g2.sendBuffer();
    829 }
    830 
    831 void display_pet_popup(const char* face, const char* line1, const char* line2, uint16_t hold_ms) {
    832   u8g2.clearBuffer();
    833   u8g2.setFont(u8g2_font_9x18B_tf);
    834   u8g2.drawStr((128 - u8g2.getStrWidth(face)) / 2, line2 ? 30 : 36, face);  // lift the face for 2 lines
    835   u8g2.setFont(ROW_FONT);
    836   u8g2.drawStr((128 - u8g2.getStrWidth(line1)) / 2, line2 ? 44 : 52, line1);
    837   if (line2) u8g2.drawStr((128 - u8g2.getStrWidth(line2)) / 2, 54, line2);
    838   u8g2.sendBuffer();
    839   delay(hold_ms);
    840   u8g2.setFont(ROW_FONT);
    841 }
    842 
    843 // Same face + 1–2 lines as display_pet_popup, but NON-BLOCKING (no delay). The caller
    844 // keeps it on screen and dismisses it (button / timeout) by redrawing the live view.
    845 void display_toast(const char* face, const char* line1, const char* line2) {
    846   u8g2.clearBuffer();
    847   u8g2.setFont(u8g2_font_9x18B_tf);
    848   u8g2.drawStr((128 - u8g2.getStrWidth(face)) / 2, line2 ? 30 : 36, face);
    849   u8g2.setFont(ROW_FONT);
    850   u8g2.drawStr((128 - u8g2.getStrWidth(line1)) / 2, line2 ? 44 : 52, line1);
    851   if (line2) u8g2.drawStr((128 - u8g2.getStrWidth(line2)) / 2, 54, line2);
    852   u8g2.sendBuffer();                 // no delay — caller owns the frame's lifetime
    853 }
    854 
    855 void display_options_menu(int screen, int cursor, const OptView& v) {
    856   char r1[ROW_CHARS + 1], r2[ROW_CHARS + 1];
    857   const char* title;
    858   const char* rows[5];                             // Back first = the default row, every screen
    859   int nrows;
    860   if (screen == OPTM_USER) {
    861     title = "// user settings";
    862     // "Avatar: " is the same 8 chars as the old "Pet:    ", so it keeps the value column
    863     // aligned with Splash:/Bright:/Debug:/Global:/Shower:. At 8 + an 11-char name it is
    864     // exactly 19 chars = 114px at x=14 — the full width, with nothing to spare.
    865     snprintf(r1, sizeof(r1), "Avatar: %s", v.pet);
    866     snprintf(r2, sizeof(r2), "Finder View: %s", v.finder_mode);
    867     rows[0] = "Back"; rows[1] = r1; rows[2] = r2; rows[3] = "Reminders"; nrows = 4;
    868   } else if (screen == OPTM_DISPLAY) {
    869     title = "// display";
    870     snprintf(r1, sizeof(r1), "Splash: %s", v.splash);
    871     snprintf(r2, sizeof(r2), "Bright: %s", v.bright);
    872     rows[0] = "Back"; rows[1] = r1; rows[2] = r2; nrows = 3;
    873   } else if (screen == OPTM_DEVOPTS) {
    874     title = "// developer options";
    875     snprintf(r1, sizeof(r1), "Debug:  %s", v.test_state);
    876     rows[0] = "Back"; rows[1] = "Whoami"; rows[2] = r1;
    877     rows[3] = "Provisioning Mode"; rows[4] = "Factory Reset"; nrows = 5;   // 5 = the rows[] cap
    878   } else if (screen == OPTM_REMIND) {
    879     title = "// reminders";
    880     snprintf(r1, sizeof(r1), "Global: %s", v.remind_global);
    881     snprintf(r2, sizeof(r2), "Shower: %s", v.remind_shower);
    882     rows[0] = "Back"; rows[1] = r1; rows[2] = r2; nrows = 3;
    883   } else if (screen == OPTM_BADGE) {
    884     title = "// badge settings";
    885     snprintf(r1, sizeof(r1), "Stealth: %s", v.stealth);
    886     rows[0] = "Back"; rows[1] = "Display"; rows[2] = r1;
    887     rows[3] = "Developer Options"; rows[4] = "Power"; nrows = 5;    // 5 = the rows[] cap
    888   } else if (screen == OPTM_POWER) {
    889     title = "// power";
    890     rows[0] = "Back"; rows[1] = "Reboot"; rows[2] = "Deep sleep"; nrows = 3;
    891   } else {  // OPTM_ROOT
    892     title = "// options";
    893     rows[0] = "Back"; rows[1] = "User Settings"; rows[2] = "Badge Settings"; nrows = 3;
    894   }
    895   u8g2.clearBuffer();
    896   u8g2.setFont(ROW_FONT);
    897   u8g2.drawStr(0, 8, title);
    898   u8g2.drawHLine(0, 11, 128);
    899   int y0 = (nrows >= 5) ? 21 : (nrows == 4) ? 22 : 24;           // tighten as rows grow
    900   int dy = (nrows >= 5) ? 10 : (nrows == 4) ? 12 : 14;
    901   for (int i = 0; i < nrows; i++) {
    902     int y = y0 + i * dy;
    903     if (i == cursor) u8g2.drawStr(2, y, ">");
    904     u8g2.drawStr(14, y, rows[i]);
    905   }
    906   u8g2.sendBuffer();
    907 }
    908 
    909 #endif  // BADGE_DISPLAY_OLED