// Display: I2C-driven debug HUD on a Waveshare 172x320 TFT.
//
// Panel:  Waveshare 1.47" IPS LCD, ST7789(V3) controller, 4-wire SPI.
// Board:  RP2350, compiled with the Arduino-Pico (Earle Philhower) core.
//         (Tools > Board > Raspberry Pi Pico/RP2040/RP2350 > your RP2350 board)
//
// This device acts as an I2C SLAVE: a master writes register+value packets that
// drive a graphical HUD drawn over a full-screen background image (which bakes in
// the static housing/trapezoid art): a small battery percent readout, a rotating
// compass arrow, a big ammo number on a teal panel, and a thin load
// timeout/cooldown bar. The display only redraws the regions whose values change.
// The I2C protocol is unchanged from the prior text HUD.
//
// Wiring (RP2350 GPIO -> display pin), validated by display_test:
//   GP1  -> CS
//   GP2  -> SCLK   (SPI0 SCK)
//   GP3  -> MOSI   (SPI0 TX)
//   GP4  -> DC
//   GP5  -> RST
//   GP24 -> backlight PWM
//   GP12 -> I2C SDA (I2C0 / Wire, slave to the master MCU)
//   GP13 -> I2C SCL (I2C0 / Wire)
//   GP21 -> button 1, brightness + (to GND, internal pull-up)
//   GP20 -> button 2, brightness - (to GND, internal pull-up)
//   GP19 -> button 3, action button (to GND, internal pull-up)
// See the wiring table at the bottom.
//
// Libraries (install via Library Manager):
//   - Adafruit GFX Library
//   - Adafruit ST7735 and ST7789 Library
//   - Adafruit BusIO (pulled in automatically)

#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>
#include <SPI.h>
#include <Wire.h>
#include <math.h>
#include <Fonts/FreeSans24pt7b.h>   // large number font for the ammo readout
#include "background.h"             // full-screen RGB565 backdrop (bgimage/convert.py)

// --- Display wiring (RP2350 GPIO -> display pin) ---
#define TFT_CS    1   // display CS
#define TFT_SCK   2   // display CLK/SCLK (SPI0 SCK)
#define TFT_MOSI  3   // display DIN/MOSI (SPI0 TX)
#define TFT_DC    4   // display DC
#define TFT_RST   5   // display RST (set to -1 if your module auto-resets)
#define TFT_BL    24  // backlight, PWM-capable

// --- Brightness buttons ---
// Active-low pushbuttons to GND, using the internal pull-ups. Pressing steps
// the backlight PWM; holding auto-repeats so a full sweep doesn't take
// dozens of clicks.
#define BTN_BRIGHT_UP        21    // button 1: brightness +
#define BTN_BRIGHT_DOWN      20    // button 2: brightness -
#define BRIGHT_STEP          16    // PWM change per press / repeat tick
#define BRIGHT_MIN           15    // floor, so the panel never looks dead
#define BRIGHT_MAX           255
#define BTN_DEBOUNCE_MS      30    // ignore contact bounce shorter than this
#define BTN_REPEAT_DELAY_MS  400   // hold this long before auto-repeat starts
#define BTN_REPEAT_MS        100   // repeat interval while held

// --- Action button ---
// Button 3, same active-low wiring as the brightness buttons. A short click's
// local behavior differs depending on whether a magazine is inserted; for now
// that handler only logs which branch it took (see onActionButtonClick).
// Short clicks AND long presses are also queued as events the I2C master reads
// back (see onI2CRequest); the master uses a long press to enter/exit magazine
// calibration mode and a short click to save the calibration.
#define BTN_ACTION           19    // button 3
#define BTN_LONGPRESS_MS     1200  // hold this long for a "long press"

// Button event bits reported to the master when it does a read (requestFrom)
// on this slave. Clear-on-read.
#define BTN_EV_SHORT  0x01
#define BTN_EV_LONG   0x02

// --- I2C slave config ---
// GP12/GP13 map to the I2C0 controller, which the Arduino-Pico core exposes as
// Wire. The pins must be routed with setSDA()/setSCL() before begin().
#define I2C_BUS   Wire   // I2C0
#define I2C_ADDR  0x42   // this device's address on the bus
#define I2C_SDA   12     // GP12, I2C0 SDA
#define I2C_SCL   13     // GP13, I2C0 SCL

// I2C register map. Master writes: [reg][payload...], little-endian (LSB first).
//   REG_BATTERY     2 bytes  uint16  millivolts        (3850 -> 3.85 V)
//   REG_FLASHLIGHT  1 byte   uint8   0 = off, else on
//   REG_AMMO        2 bytes  uint16  rounds remaining
//   REG_HEADING     2 bytes  uint16  degrees 0..359
//   REG_BATTERY_PCT 1 byte   uint8   charge 0..100 %
//   REG_LOAD_TIMER  2 bytes  [state][percent]
//                            state 0 = ready, 1 = on (timeout), 2 = cooldown
//                            percent 0..100 = time remaining in the phase
//   REG_CAL         2 bytes  [phase][value]  magazine calibration UI
//                            phase 0 = off (normal HUD), 1 = prompt to load
//                            and insert the mag, 2 = recording (value = rounds
//                            fired so far), 3 = saved (value = capacity),
//                            4 = failed/aborted
#define REG_BATTERY      0x01
#define REG_FLASHLIGHT   0x02
#define REG_AMMO         0x03
#define REG_HEADING      0x04
#define REG_BATTERY_PCT  0x05
#define REG_LOAD_TIMER   0x06
#define REG_CAL          0x07

// Calibration UI phases sent in REG_CAL (must match the master).
#define CAL_PHASE_OFF        0
#define CAL_PHASE_PROMPT     1
#define CAL_PHASE_RECORDING  2
#define CAL_PHASE_SAVED      3
#define CAL_PHASE_ERROR      4

// Sentinel ammo value meaning "sensor disconnected" -> shown as "--".
#define AMMO_DISCONNECTED 0xFFFF

// Load-timer phase values (must match the master).
#define LOAD_TIMER_READY     0
#define LOAD_TIMER_ACTIVE    1
#define LOAD_TIMER_COOLDOWN  2

// --- Panel geometry ---
#define TFT_WIDTH   172
#define TFT_HEIGHT  320

// Colors (ported from old_display's palette). 565 values precomputed from the
// original color565() calls, shown in the comments.
#define COLOR_BG      ST77XX_BLACK
#define COLOR_ACCENT  0x07FF   // cyan   color565(0x00, 0xFF, 0xFF)
#define COLOR_DIM     0x02AB   // teal   color565(0x00, 0x57, 0x5F)
#define COLOR_HUD_BG  0x10A3   // #10161F -- solid bg behind the compass in bg.png
#define COLOR_YELLOW  0xFFE2   //        color565(0xFF, 0xFF, 0x11)
#define COLOR_RED     0xF882   //        color565(0xFF, 0x11, 0x11)
#define COLOR_RED_DIM 0x5841   // dark red, for error-screen accents  color565(0x5F, 0x08, 0x08)

// If the "Initializing" splash sits with no valid I2C packets for this long,
// swap it for the red SYSTEM ERROR screen. Recovers to the HUD automatically
// if packets show up later.
#define INIT_TIMEOUT_MS  5000
#define ERR_BLINK_MS     600   // warning-triangle blink period on that screen

// All the live state the HUD renders, updated over I2C.
struct DebugInfo {
  float batteryVolts;     // battery voltage
  int   batteryPct;       // battery charge, 0..100 %
  bool  flashlightOn;     // flashlight status
  int   ammoCount;        // rounds remaining
  int   compassHeading;   // degrees, 0..359
  int   loadTimerState;   // 0 ready, 1 on (timeout), 2 cooldown
  int   loadTimerPct;     // 0..100 time remaining in the active phase
  int   calPhase;         // magazine calibration UI phase (CAL_PHASE_*)
  int   calValue;         // phase-specific value (rounds fired / capacity)
};

// Hardware SPI on SPI0; CS/DC/RST are handled by the library. SCK/MOSI are
// routed to the wired pins in setup() via SPI.setSCK()/setTX().
Adafruit_ST7789 tft = Adafruit_ST7789(&SPI, TFT_CS, TFT_DC, TFT_RST);

// Shared state between the I2C receive callback and loop(). The callback runs
// in interrupt context on the Arduino-Pico core, so loop() masks interrupts
// (noInterrupts()/interrupts() nest and restore state on this core) while it
// snapshots the struct.
volatile DebugInfo g_info = { 3.85f, 100, true, 30, 273, 0, 0, CAL_PHASE_OFF, 0 };  // defaults until first update
volatile bool g_dirty = false;                         // set when a value changes
volatile bool g_gotFirstPacket = false;                // becomes true on first valid packet

// Last values actually painted to the screen, so we only redraw the regions that
// changed (the load-timer bar updates several times a second).
DebugInfo g_shown;

// --- Debug logging state ---------------------------------------------------
// The I2C callback runs in the Wire driver's context, so it only records what
// it saw into these volatiles; loop() does the actual Serial printing.
volatile uint32_t g_rxCount   = 0;     // total packets received
volatile uint8_t  g_lastReg   = 0;     // register byte of last packet
volatile int32_t  g_lastValue = -1;    // parsed value of last packet (-1 = n/a)
volatile int      g_lastBytes = 0;     // numBytes reported for last packet
volatile bool     g_lastKnown = false; // was the register recognized?
volatile bool     g_logPending = false;// set when there's a new packet to log
unsigned long g_lastRxMillis = 0;      // when we last saw any packet (loop-only)

// Pending button-3 events for the master, set in loop() under an interrupt
// mask and consumed (clear-on-read) by the I2C request ISR.
volatile uint8_t g_btnEvents = 0;

// While true, the calibration UI owns the ammo panel and compass regions, so
// updateDisplay() must not paint those two (everything else stays live).
// Loop-only, no ISR access.
bool g_calOverlay = false;

// --- Backlight brightness state (loop-only, no ISR access) -----------------
uint8_t g_brightness = BRIGHT_MAX;     // current backlight PWM level

// Per-button debounce/repeat tracking for the brightness buttons.
struct BrightBtn {
  uint8_t pin;
  int8_t  dir;            // +1 = brighter, -1 = dimmer
  bool    pressed;        // debounced state
  unsigned long lastEdge;   // when the debounced state last changed
  unsigned long nextRepeat; // when the next auto-repeat step fires
};
BrightBtn g_brightBtns[2] = {
  { BTN_BRIGHT_UP,   +1, false, 0, 0 },
  { BTN_BRIGHT_DOWN, -1, false, 0, 0 },
};

void onI2CReceive(int numBytes);
void onI2CRequest();
void handleBrightnessButtons();
void applyBrightnessStep(int dir);
uint8_t pollActionButton();
void onActionButtonClick(bool magInserted);
void updateDisplay(const DebugInfo &info, bool forceFull);
void drawInitScreen();
void drawErrorScreen();
void drawErrorTriangle(bool lit);
void drawCalCompass(int phase, int value);
void drawCalAmmo(int phase, int value);
void restoreBgRegion(int16_t rx, int16_t ry, int16_t rw, int16_t rh);

void drawBatteryPct(int pct);
void drawBatteryVolts(float volts);
void drawLoadBar(int state, int pct);
void drawCompass(int heading);
void drawAmmo(int ammo);
void drawNoMag(bool show);
void drawThinBar(int16_t y, float widthFrac, int pct, uint16_t fillColor);
void drawCompassArrow(GFXcanvas16 &c, int cx, int cy, int base, int height, float angle, uint16_t color);

// --- HUD geometry (portrait 172x320), ported from old_display ---
#define COMPASS_CX      86     // compass arrow center
#define COMPASS_CY      76     // (raised 10px)
#define COMPASS_BOX     70     // off-screen canvas size for the arrow region
#define LOAD_BAR_Y      147    // load timeout/cooldown bar
#define AMMO_PANEL_X    20     // dim-teal ammo panel
#define AMMO_PANEL_Y    160
#define AMMO_PANEL_W    132
#define AMMO_PANEL_H    100
#define BATT_PCT_X      30     // small "NN%" battery readout, bottom-left
#define BATT_PCT_Y      293
#define BATT_PCT_W      56
#define BATT_PCT_H      20
#define BATT_V_X        86     // battery voltage readout, bottom-right (mirrors %)
#define BATT_V_Y        293
#define BATT_V_W        56
#define BATT_V_H        20
#define NOMAG_X         0      // "No Magazine" warning strip, just above the bar
#define NOMAG_W         172
#define NOMAG_H         20
#define NOMAG_Y         (LOAD_BAR_Y - NOMAG_H - 3)
#define NOMAG_BLINK_MS  500    // pulse period (on/off) while disconnected
#define AMMO_Y_NUDGE    3      // raise the ammo number this many px

// Calibration instruction area: bigger than the compass box, but still inside
// the solid dark trapezoid of the background art (narrowest at the top, where
// the cyan borders sit at ~x42/x130 around y=16..20). Centered on the compass.
#define CAL_TEXT_X      46
#define CAL_TEXT_Y      20
#define CAL_TEXT_W      80
#define CAL_TEXT_H      100

void setup() {
  Serial.begin(115200);
  delay(200);
  Serial.println();
  Serial.println("=== display booting ===");
  Serial.print("I2C slave address: 0x");
  Serial.println(I2C_ADDR, HEX);

  // Backlight at the default level; adjustable at runtime via the buttons.
  pinMode(TFT_BL, OUTPUT);
  analogWrite(TFT_BL, g_brightness);

  // Buttons: active-low, internal pull-ups.
  pinMode(BTN_BRIGHT_UP, INPUT_PULLUP);
  pinMode(BTN_BRIGHT_DOWN, INPUT_PULLUP);
  pinMode(BTN_ACTION, INPUT_PULLUP);

  // Route SPI0 to the pins the display is actually wired to. Must happen
  // before tft.init() calls SPI.begin().
  SPI.setSCK(TFT_SCK);
  SPI.setTX(TFT_MOSI);

  // init(172, 320) tells the library the panel size; it applies the required
  // column offset (34) so the image lands correctly on this cut of the ST7789.
  tft.init(TFT_WIDTH, TFT_HEIGHT);
  tft.setSPISpeed(40000000);  // 40 MHz, validated by display_test; raise if clean
  tft.setRotation(2);          // 2 = portrait flipped 180 deg, 172 wide x 320 tall
  tft.fillScreen(COLOR_BG);

  // Start I2C in slave mode on I2C0 (GP12/GP13) and register the receive
  // callback. Pins must be set before begin(); the master drives the clock.
  I2C_BUS.setSDA(I2C_SDA);
  I2C_BUS.setSCL(I2C_SCL);
  I2C_BUS.begin((uint8_t)I2C_ADDR);
  I2C_BUS.onReceive(onI2CReceive);
  I2C_BUS.onRequest(onI2CRequest);   // master reads back queued button events

  Serial.println("I2C slave ready on Wire (GP12/GP13), waiting for packets...");
}

void loop() {
  handleBrightnessButtons();

  // Snapshot shared state (and any pending log info) under the lock, then do
  // the slow stuff (Serial, TFT) outside the critical section.
  DebugInfo snapshot;
  bool dirty;
  bool gotFirstPacket;
  bool logPending;
  uint32_t rxCount;
  uint8_t  lastReg;
  int32_t  lastValue;
  int      lastBytes;
  bool     lastKnown;

  noInterrupts();
  snapshot = *(DebugInfo *)&g_info;
  dirty = g_dirty;
  g_dirty = false;
  gotFirstPacket = g_gotFirstPacket;
  logPending = g_logPending;
  g_logPending = false;
  rxCount   = g_rxCount;
  lastReg   = g_lastReg;
  lastValue = g_lastValue;
  lastBytes = g_lastBytes;
  lastKnown = g_lastKnown;
  interrupts();

  // Button 3: short clicks run the local handler; both shorts and longs are
  // queued for the master, which polls them over I2C and uses them to drive
  // magazine calibration (long = enter/cancel, short = save).
  uint8_t btnEvent = pollActionButton();
  if (btnEvent == BTN_EV_SHORT) {
    noInterrupts();
    g_btnEvents |= BTN_EV_SHORT;
    interrupts();
    onActionButtonClick((uint16_t)snapshot.ammoCount != AMMO_DISCONNECTED);
  } else if (btnEvent == BTN_EV_LONG) {
    noInterrupts();
    g_btnEvents |= BTN_EV_LONG;
    interrupts();
    Serial.println("[button3] long press -- queued for master (calibration)");
  }

  if (logPending) {
    g_lastRxMillis = millis();
    Serial.print("[RX #");
    Serial.print(rxCount);
    Serial.print("] bytes=");
    Serial.print(lastBytes);
    Serial.print(" reg=0x");
    if (lastReg < 16) Serial.print("0");
    Serial.print(lastReg, HEX);
    if (!lastKnown) {
      Serial.println("  -> UNKNOWN REGISTER");
    } else {
      Serial.print(" raw=");
      Serial.print(lastValue);
      switch (lastReg) {
        case REG_BATTERY:
          Serial.print("  -> BATTERY ");
          Serial.print(lastValue / 1000.0f, 2);
          Serial.println(" V");
          break;
        case REG_FLASHLIGHT:
          Serial.print("  -> FLASHLIGHT ");
          Serial.println(lastValue ? "ON" : "OFF");
          break;
        case REG_AMMO:
          Serial.print("  -> AMMO ");
          Serial.println(lastValue);
          break;
        case REG_HEADING:
          Serial.print("  -> HEADING ");
          Serial.print(lastValue);
          Serial.println(" deg");
          break;
        case REG_BATTERY_PCT:
          Serial.print("  -> BATTERY ");
          Serial.print(lastValue);
          Serial.println(" %");
          break;
        case REG_LOAD_TIMER:
          Serial.print("  -> LOAD state=");
          Serial.print((lastValue >> 8) & 0xFF);
          Serial.print(" pct=");
          Serial.println(lastValue & 0xFF);
          break;
        case REG_CAL:
          Serial.print("  -> CAL phase=");
          Serial.print((lastValue >> 8) & 0xFF);
          Serial.print(" value=");
          Serial.println(lastValue & 0xFF);
          break;
      }
    }
  }

  // Show a centered "Initializing" splash until the first valid packet arrives,
  // then switch to the HUD. hudStarted forces a full redraw on the transition so
  // the splash is cleared and every field is drawn at least once. If nothing
  // arrives within INIT_TIMEOUT_MS the splash escalates to the SYSTEM ERROR
  // screen; a late first packet still transitions to the HUD normally, since
  // that path repaints the whole screen from the background image.
  static bool splashShown = false;
  static bool hudStarted = false;
  static bool errorShown = false;
  static unsigned long splashSince = 0;
  static unsigned long lastErrBlink = 0;
  static bool errBlinkOn = true;

  // Magazine calibration tracking: while the master holds REG_CAL in a non-off
  // phase, the calibration UI overlays just the ammo panel and compass regions;
  // the background image and the rest of the HUD stay live underneath. On exit
  // a forced updateDisplay() repaints the two borrowed regions.
  static bool calActive = false;
  static int  lastCalPhase = -1;
  static int  lastCalValue = -1;

  if (!gotFirstPacket) {
    if (!splashShown) {
      drawInitScreen();
      splashShown = true;
      splashSince = millis();
    } else if (!errorShown && millis() - splashSince >= INIT_TIMEOUT_MS) {
      Serial.println("No valid I2C packets in 5s -- showing SYSTEM ERROR screen.");
      drawErrorScreen();
      errorShown = true;
      lastErrBlink = millis();
    } else if (errorShown && millis() - lastErrBlink >= ERR_BLINK_MS) {
      lastErrBlink = millis();
      errBlinkOn = !errBlinkOn;
      drawErrorTriangle(errBlinkOn);
    }
  } else if (snapshot.calPhase != CAL_PHASE_OFF) {
    // If calibration starts before the HUD has ever been painted, lay down the
    // backdrop first so the overlay has a HUD to sit on.
    bool firstHudPaint = !hudStarted;
    if (firstHudPaint) {
      tft.drawRGBBitmap(0, 0, background, BG_WIDTH, BG_HEIGHT);
      hudStarted = true;
    }

    g_calOverlay = true;   // keep updateDisplay off the ammo/compass regions
    if (firstHudPaint) {
      updateDisplay(snapshot, true);
    } else if (dirty) {
      updateDisplay(snapshot, false);  // battery/load bar etc. stay live
    }

    if (!calActive) {
      drawNoMag(false);   // don't leave a stale/frozen warning during cal
    }
    if (!calActive || snapshot.calPhase != lastCalPhase) {
      drawCalCompass(snapshot.calPhase, snapshot.calValue);
      drawCalAmmo(snapshot.calPhase, snapshot.calValue);
    } else if (snapshot.calValue != lastCalValue) {
      drawCalAmmo(snapshot.calPhase, snapshot.calValue);  // rounds-fired count
    }
    calActive = true;
    lastCalPhase = snapshot.calPhase;
    lastCalValue = snapshot.calValue;
  } else if (!hudStarted) {
    tft.drawRGBBitmap(0, 0, background, BG_WIDTH, BG_HEIGHT);  // backdrop (static housing/border baked in)
    Serial.println("First packet received -- drawing HUD.");
    updateDisplay(snapshot, true);   // live elements composite on top
    hudStarted = true;
  } else if (calActive) {
    // Calibration just ended: hand the borrowed regions back to the HUD. The
    // text area is larger than the compass box, so restore it from the
    // background art first; the full repaint then redraws the compass on top.
    calActive = false;
    lastCalPhase = -1;
    lastCalValue = -1;
    g_calOverlay = false;
    restoreBgRegion(CAL_TEXT_X, CAL_TEXT_Y, CAL_TEXT_W, CAL_TEXT_H);
    updateDisplay(snapshot, true);
  } else if (dirty) {
    updateDisplay(snapshot, false);  // redraw only changed regions
  }

  // Pulse the "No Magazine" warning on/off while the ammo sensor is disconnected.
  static unsigned long lastBlink = 0;
  static bool blinkOn = true;
  if (hudStarted && !calActive && (uint16_t)snapshot.ammoCount == AMMO_DISCONNECTED) {
    if (millis() - lastBlink >= NOMAG_BLINK_MS) {
      lastBlink = millis();
      blinkOn = !blinkOn;
      drawNoMag(blinkOn);
    }
  } else {
    blinkOn = true;   // reset so it reappears immediately when next disconnected
  }

  // Heartbeat: if no packets have arrived for a while, say so periodically.
  static unsigned long lastHeartbeat = 0;
  if (millis() - lastHeartbeat > 3000) {
    lastHeartbeat = millis();
    if (g_lastRxMillis == 0) {
      Serial.println("(heartbeat) no I2C packets received yet.");
    } else if (millis() - g_lastRxMillis > 3000) {
      Serial.print("(heartbeat) no I2C packets for ");
      Serial.print((millis() - g_lastRxMillis) / 1000);
      Serial.println("s.");
    }
  }
}

// Poll the brightness buttons (called every loop pass). Debounced edges: a
// press steps immediately, then auto-repeats while held after a short delay.
void handleBrightnessButtons() {
  unsigned long now = millis();
  for (auto &b : g_brightBtns) {
    bool down = (digitalRead(b.pin) == LOW);
    if (down != b.pressed) {
      if (now - b.lastEdge < BTN_DEBOUNCE_MS) continue;  // bounce, ignore
      b.pressed = down;
      b.lastEdge = now;
      if (down) {
        applyBrightnessStep(b.dir);
        b.nextRepeat = now + BTN_REPEAT_DELAY_MS;
      }
    } else if (down && (long)(now - b.nextRepeat) >= 0) {
      applyBrightnessStep(b.dir);
      b.nextRepeat = now + BTN_REPEAT_MS;
    }
  }
}

// Step the backlight PWM up or down one increment, clamped to
// [BRIGHT_MIN, BRIGHT_MAX], and apply it.
void applyBrightnessStep(int dir) {
  int level = (int)g_brightness + dir * BRIGHT_STEP;
  if (level < BRIGHT_MIN) level = BRIGHT_MIN;
  if (level > BRIGHT_MAX) level = BRIGHT_MAX;
  if ((uint8_t)level == g_brightness) return;  // already at the limit

  g_brightness = (uint8_t)level;
  analogWrite(TFT_BL, g_brightness);
  Serial.print("brightness -> ");
  Serial.println(g_brightness);
}

// Poll the action button (button 3). Returns BTN_EV_SHORT once per short
// click (on the debounced release), BTN_EV_LONG once when a press has been
// held for BTN_LONGPRESS_MS (fires while still held; the eventual release is
// swallowed), or 0. Short clicks report on release rather than press so a
// long press doesn't also emit a short click.
uint8_t pollActionButton() {
  static bool pressed = false;
  static bool longFired = false;
  static unsigned long lastEdge = 0;
  static unsigned long pressStart = 0;

  bool down = (digitalRead(BTN_ACTION) == LOW);
  unsigned long now = millis();

  if (down != pressed) {
    if (now - lastEdge < BTN_DEBOUNCE_MS) return 0;  // bounce, ignore
    pressed = down;
    lastEdge = now;
    if (down) {
      pressStart = now;
      longFired = false;
    } else if (!longFired) {
      return BTN_EV_SHORT;   // released before the long-press threshold
    }
  } else if (down && !longFired && now - pressStart >= BTN_LONGPRESS_MS) {
    longFired = true;
    return BTN_EV_LONG;
  }
  return 0;
}

// Button 3 click handler, scaffolded: the real behavior depends on whether a
// magazine is inserted (ammo sensor connected). For now each branch just logs.
void onActionButtonClick(bool magInserted) {
  if (magInserted) {
    Serial.println("[button3] click with magazine inserted -- TODO: implement");
  } else {
    Serial.println("[button3] click with NO magazine -- TODO: implement");
  }
}

// I2C receive callback: parse [reg][payload] packets into the shared struct.
// Keep this short -- it runs in the Wire driver's context. Reads happen first,
// then the struct is updated inside a brief critical section.
void onI2CReceive(int numBytes) {
  if (numBytes < 1) return;

  uint8_t reg = I2C_BUS.read();
  int32_t parsedValue = -1;  // for logging; -1 means "not parsed"
  bool    known = true;      // was the register recognized + payload complete?

  switch (reg) {
    case REG_BATTERY: {
      if (I2C_BUS.available() < 2) { known = false; break; }
      uint16_t mv = (uint16_t)I2C_BUS.read() | ((uint16_t)I2C_BUS.read() << 8);
      parsedValue = mv;
      noInterrupts();
      g_info.batteryVolts = mv / 1000.0f;
      g_dirty = true;
      interrupts();
      break;
    }
    case REG_FLASHLIGHT: {
      if (I2C_BUS.available() < 1) { known = false; break; }
      bool on = I2C_BUS.read() != 0;
      parsedValue = on ? 1 : 0;
      noInterrupts();
      g_info.flashlightOn = on;
      g_dirty = true;
      interrupts();
      break;
    }
    case REG_AMMO: {
      if (I2C_BUS.available() < 2) { known = false; break; }
      uint16_t ammo = (uint16_t)I2C_BUS.read() | ((uint16_t)I2C_BUS.read() << 8);
      parsedValue = ammo;
      noInterrupts();
      g_info.ammoCount = ammo;
      g_dirty = true;
      interrupts();
      break;
    }
    case REG_HEADING: {
      if (I2C_BUS.available() < 2) { known = false; break; }
      uint16_t hdg = (uint16_t)I2C_BUS.read() | ((uint16_t)I2C_BUS.read() << 8);
      parsedValue = hdg % 360;
      noInterrupts();
      g_info.compassHeading = hdg % 360;
      g_dirty = true;
      interrupts();
      break;
    }
    case REG_BATTERY_PCT: {
      if (I2C_BUS.available() < 1) { known = false; break; }
      uint8_t pct = I2C_BUS.read();
      if (pct > 100) pct = 100;
      parsedValue = pct;
      noInterrupts();
      g_info.batteryPct = pct;
      g_dirty = true;
      interrupts();
      break;
    }
    case REG_LOAD_TIMER: {
      if (I2C_BUS.available() < 2) { known = false; break; }
      uint8_t state = I2C_BUS.read();
      uint8_t pct = I2C_BUS.read();
      if (pct > 100) pct = 100;
      parsedValue = ((int32_t)state << 8) | pct;  // packed for logging
      noInterrupts();
      g_info.loadTimerState = state;
      g_info.loadTimerPct = pct;
      g_dirty = true;
      interrupts();
      break;
    }
    case REG_CAL: {
      if (I2C_BUS.available() < 2) { known = false; break; }
      uint8_t phase = I2C_BUS.read();
      uint8_t value = I2C_BUS.read();
      parsedValue = ((int32_t)phase << 8) | value;  // packed for logging
      noInterrupts();
      g_info.calPhase = phase;
      g_info.calValue = value;
      g_dirty = true;
      interrupts();
      break;
    }
    default:
      known = false;
      break;
  }

  // Record packet metadata so loop() can log it (Serial isn't safe here).
  noInterrupts();
  g_rxCount++;
  g_lastReg    = reg;
  g_lastValue  = parsedValue;
  g_lastBytes  = numBytes;
  g_lastKnown  = known;
  g_logPending = true;
  if (known) g_gotFirstPacket = true;  // valid packet -> leave the splash screen
  interrupts();

  // Drain anything left so the next packet starts clean.
  while (I2C_BUS.available()) I2C_BUS.read();
}

// I2C request callback: the master reads 1 byte of queued button events
// (BTN_EV_* bits). Clear-on-read; runs in the Wire driver's context.
void onI2CRequest() {
  uint8_t ev = g_btnEvents;
  g_btnEvents = 0;
  I2C_BUS.write(ev);
}

// Splash shown until the first valid I2C packet arrives. Draws "Initializing"
// centered on the panel. getTextBounds() gives the rendered size so the text
// lands dead center regardless of font metrics.
void drawInitScreen() {
  tft.fillScreen(COLOR_BG);

  const char *msg = "Initializing";
  tft.setTextSize(2);
  tft.setTextColor(COLOR_ACCENT, COLOR_BG);

  int16_t x1, y1;
  uint16_t w, h;
  tft.getTextBounds(msg, 0, 0, &x1, &y1, &w, &h);
  int16_t x = (TFT_WIDTH - (int16_t)w) / 2 - x1;
  int16_t y = (TFT_HEIGHT - (int16_t)h) / 2 - y1;

  tft.setCursor(x, y);
  tft.print(msg);
}

// Horizontally centered line of built-in-font text; y is the top of the text.
static void drawCenteredText(const char *msg, int16_t y, uint8_t size, uint16_t color) {
  tft.setFont();
  tft.setTextSize(size);
  tft.setTextColor(color);
  int16_t x1, y1;
  uint16_t w, h;
  tft.getTextBounds(msg, 0, 0, &x1, &y1, &w, &h);
  tft.setCursor((TFT_WIDTH - (int16_t)w) / 2 - x1, y);
  tft.print(msg);
}

// Blinking warning triangle for the error screen: lit = solid red with a black
// "!", unlit = red outline with a red "!". Repainted on its own so the blink
// doesn't require redrawing the whole screen.
void drawErrorTriangle(bool lit) {
  const int16_t cx = TFT_WIDTH / 2;   // 86
  const int16_t topY = 48, baseY = 100, half = 30;

  if (lit) {
    tft.fillTriangle(cx, topY, cx - half, baseY, cx + half, baseY, COLOR_RED);
    tft.setTextColor(COLOR_BG);
  } else {
    tft.fillTriangle(cx, topY, cx - half, baseY, cx + half, baseY, COLOR_BG);
    tft.drawTriangle(cx, topY, cx - half, baseY, cx + half, baseY, COLOR_RED);
    tft.setTextColor(COLOR_RED);
  }

  tft.setFont();
  tft.setTextSize(2);
  tft.setCursor(cx - 5, 72);   // "!" roughly centered in the triangle
  tft.print('!');
}

// Full-screen SYSTEM ERROR state, shown when no I2C packets arrive within
// INIT_TIMEOUT_MS of boot. Red-on-black with some minimal sci-fi dressing:
// corner targeting brackets, a warning triangle (blinked by loop()), separator
// rules with end ticks, and a row of hazard slashes.
void drawErrorScreen() {
  tft.fillScreen(COLOR_BG);

  // Corner targeting brackets.
  const int16_t L = 16, inset = 8;
  const int16_t xr = TFT_WIDTH - 1 - inset;    // right bracket edge
  const int16_t yb = TFT_HEIGHT - 1 - inset;   // bottom bracket edge
  tft.drawFastHLine(inset, inset, L, COLOR_RED);
  tft.drawFastVLine(inset, inset, L, COLOR_RED);
  tft.drawFastHLine(xr - L + 1, inset, L, COLOR_RED);
  tft.drawFastVLine(xr, inset, L, COLOR_RED);
  tft.drawFastHLine(inset, yb, L, COLOR_RED);
  tft.drawFastVLine(inset, yb - L + 1, L, COLOR_RED);
  tft.drawFastHLine(xr - L + 1, yb, L, COLOR_RED);
  tft.drawFastVLine(xr, yb - L + 1, L, COLOR_RED);

  drawErrorTriangle(true);

  // Separator rules with bright end ticks, boxing the headline.
  const int16_t ruleYs[] = { 124, 212 };
  for (int16_t y : ruleYs) {
    tft.drawFastHLine(20, y, TFT_WIDTH - 40, COLOR_RED_DIM);
    tft.drawFastVLine(20, y - 3, 7, COLOR_RED);
    tft.drawFastVLine(TFT_WIDTH - 21, y - 3, 7, COLOR_RED);
  }

  drawCenteredText("SYSTEM", 140, 3, COLOR_RED);
  drawCenteredText("ERROR", 172, 3, COLOR_RED);

  drawCenteredText("NO I2C SIGNAL", 228, 1, COLOR_RED_DIM);

  // Hazard slashes along the bottom.
  for (int16_t x = 26; x + 14 <= TFT_WIDTH - 26; x += 20) {
    tft.drawLine(x, 268, x + 14, 252, COLOR_RED_DIM);
  }
}

// --- Magazine calibration overlay --------------------------------------------
// Driven by the master via REG_CAL. Rather than taking over the screen, the
// calibration UI borrows exactly two HUD regions and leaves the background
// image and every other element live:
//   - compass area: short instruction lines for the current phase
//   - ammo panel:   the phase's number (rounds fired / capacity) plus a label
// Both are canvas-composited just like their HUD counterparts, so they blend
// with the artwork and update flicker-free.

// Repaint a rectangle straight from the background image (used to hand the
// enlarged calibration text area back to the artwork on exit).
void restoreBgRegion(int16_t rx, int16_t ry, int16_t rw, int16_t rh) {
  GFXcanvas16 c(rw, rh);
  uint16_t *buf = c.getBuffer();
  for (int16_t y = 0; y < rh; y++) {
    for (int16_t x = 0; x < rw; x++) {
      buf[y * rw + x] = pgm_read_word(&background[(ry + y) * BG_WIDTH + (rx + x)]);
    }
  }
  tft.drawRGBBitmap(rx, ry, c.getBuffer(), rw, rh);
}

// Instruction text in the compass area, on a canvas seeded from the background
// so it composites onto the artwork. A bright "CALIBRATE" tag on top, then the
// phase instructions in size-2 text (max 6 chars/line at 12 px per char), with
// size-1 hint lines where needed.
void drawCalCompass(int phase, int value) {
  const int16_t cw = CAL_TEXT_W;
  const int16_t ch = CAL_TEXT_H;

  GFXcanvas16 c(cw, ch);
  uint16_t *buf = c.getBuffer();
  for (int16_t y = 0; y < ch; y++) {
    for (int16_t x = 0; x < cw; x++) {
      buf[y * cw + x] =
          pgm_read_word(&background[(CAL_TEXT_Y + y) * BG_WIDTH + (CAL_TEXT_X + x)]);
    }
  }

  struct CalLine { const char *text; uint8_t size; uint16_t color; };
  char capLine[12];
  CalLine lines[6];
  int nLines = 0;
  uint16_t tagColor = (phase == CAL_PHASE_ERROR) ? COLOR_RED : COLOR_YELLOW;

  lines[nLines++] = { "CALIBRATE", 1, tagColor };

  switch (phase) {
    case CAL_PHASE_PROMPT:
      lines[nLines++] = { "LOAD",   2, COLOR_ACCENT };
      lines[nLines++] = { "FULL",   2, COLOR_ACCENT };
      lines[nLines++] = { "THEN",   2, COLOR_ACCENT };
      lines[nLines++] = { "INSERT", 2, COLOR_ACCENT };
      break;
    case CAL_PHASE_RECORDING:
      // The magazine needs the overtravel (bolt-held-open) plateau, so the
      // gun must be fired completely dry before saving.
      lines[nLines++] = { "FIRE",       2, COLOR_ACCENT };
      lines[nLines++] = { "UNTIL",      2, COLOR_ACCENT };
      lines[nLines++] = { "EMPTY",      2, COLOR_ACCENT };
      lines[nLines++] = { "BTN: SAVE",  1, COLOR_ACCENT };
      lines[nLines++] = { "HOLD: EXIT", 1, COLOR_ACCENT };
      break;
    case CAL_PHASE_SAVED:
      snprintf(capLine, sizeof(capLine), "CAP %d", value);
      lines[nLines++] = { "SAVED!", 2, COLOR_YELLOW };
      lines[nLines++] = { capLine,  2, COLOR_ACCENT };
      break;
    case CAL_PHASE_ERROR:
      lines[nLines++] = { "FAILED",    2, COLOR_RED };
      lines[nLines++] = { "NOT SAVED", 1, COLOR_RED };
      break;
  }

  // Line pitch per text size; total height determines the vertical centering.
  int16_t totalH = 0;
  for (int i = 0; i < nLines; i++) totalH += (lines[i].size == 2) ? 18 : 11;
  int16_t y = (ch - totalH) / 2;

  c.setFont();        // built-in 5x7 font
  for (int i = 0; i < nLines; i++) {
    c.setTextSize(lines[i].size);
    c.setTextColor(lines[i].color);
    int16_t x1, y1; uint16_t w, h;
    c.getTextBounds(lines[i].text, 0, 0, &x1, &y1, &w, &h);
    c.setCursor((cw - (int16_t)w) / 2 - x1, y);
    c.print(lines[i].text);
    y += (lines[i].size == 2) ? 18 : 11;
  }

  tft.drawRGBBitmap(CAL_TEXT_X, CAL_TEXT_Y, c.getBuffer(), cw, ch);
}

// Phase number in the ammo panel (same styling as the ammo count) with a small
// label underneath saying what it means.
void drawCalAmmo(int phase, int value) {
  const int16_t cw = AMMO_PANEL_W;
  const int16_t ch = AMMO_PANEL_H;

  GFXcanvas16 c(cw, ch);
  // Seed with the matching region of the background image, same as drawAmmo.
  uint16_t *buf = c.getBuffer();
  for (int16_t y = 0; y < ch; y++) {
    for (int16_t x = 0; x < cw; x++) {
      buf[y * cw + x] =
          pgm_read_word(&background[(AMMO_PANEL_Y + y) * BG_WIDTH + (AMMO_PANEL_X + x)]);
    }
  }

  char num[8];
  const char *label;
  uint16_t numColor = COLOR_ACCENT;
  uint16_t labelColor = COLOR_ACCENT;

  switch (phase) {
    case CAL_PHASE_RECORDING:
      snprintf(num, sizeof(num), "%02d", value);
      label = "FIRED";
      break;
    case CAL_PHASE_SAVED:
      snprintf(num, sizeof(num), "%02d", value);
      numColor = COLOR_YELLOW;
      label = "CAPACITY";
      break;
    case CAL_PHASE_ERROR:
      snprintf(num, sizeof(num), "--");
      numColor = COLOR_RED;
      labelColor = COLOR_RED;
      label = "FAILED";
      break;
    case CAL_PHASE_PROMPT:
    default:
      snprintf(num, sizeof(num), "--");
      label = "";
      break;
  }

  c.setFont(&FreeSans24pt7b);
  c.setTextSize(2);

  int16_t x1, y1; uint16_t w, h;
  c.getTextBounds(num, 0, 0, &x1, &y1, &w, &h);
  int16_t cx = (cw - (int16_t)w) / 2 - x1;
  int16_t cy = (ch - (int16_t)h) / 2 - y1 - AMMO_Y_NUDGE;

  c.setTextColor(numColor);
  c.setCursor(cx, cy);
  c.print(num);

  // Small label along the bottom edge of the panel (omitted when empty).
  if (label[0] != '\0') {
    c.setFont();        // built-in 5x7 font
    c.setTextSize(1);
    c.setTextColor(labelColor);
    c.getTextBounds(label, 0, 0, &x1, &y1, &w, &h);
    c.setCursor((cw - (int16_t)w) / 2 - x1, ch - 10);
    c.print(label);
  }

  tft.drawRGBBitmap(AMMO_PANEL_X, AMMO_PANEL_Y, c.getBuffer(), cw, ch);
}

// Updates the HUD, redrawing only the regions whose value changed since the last
// paint (tracked in g_shown). Pass forceFull=true to draw everything, e.g. right
// after blitting the background image. The static housing/border art lives in the
// background image now; live elements are either single-blit (compass, ammo) or
// clean overdraw (bars), so updates stay flicker-free.
void updateDisplay(const DebugInfo &info, bool forceFull) {
  if (forceFull || info.batteryPct != g_shown.batteryPct) {
    drawBatteryPct(info.batteryPct);
  }

  if (forceFull || info.batteryVolts != g_shown.batteryVolts) {
    drawBatteryVolts(info.batteryVolts);
  }

  // The calibration overlay borrows the compass and ammo regions; leave them
  // alone until it's done (the exit path forces a full repaint).
  if (!g_calOverlay && (forceFull || info.compassHeading != g_shown.compassHeading)) {
    drawCompass(info.compassHeading);
  }

  if (forceFull ||
      info.loadTimerState != g_shown.loadTimerState ||
      info.loadTimerPct   != g_shown.loadTimerPct) {
    drawLoadBar(info.loadTimerState, info.loadTimerPct);
  }

  if (!g_calOverlay && (forceFull || info.ammoCount != g_shown.ammoCount)) {
    drawAmmo(info.ammoCount);
    drawNoMag((uint16_t)info.ammoCount == AMMO_DISCONNECTED);
  }

  g_shown = info;
}

// ---------------------------------------------------------------------------
// HUD elements, ported from old_display.ino
// ---------------------------------------------------------------------------

// A thin 3px-tall bar (old_display battery style): a dim-teal track full width,
// with the leftmost pct% filled in fillColor. Redrawing the track first erases
// the previous fill, so no clear-to-black flash.
void drawThinBar(int16_t y, float widthFrac, int pct, uint16_t fillColor) {
  if (pct < 0) pct = 0;
  if (pct > 100) pct = 100;

  int barWidth = TFT_WIDTH * widthFrac;
  int inset = (TFT_WIDTH - barWidth) / 2;

  for (int i = 0; i < 3; i++) {
    tft.drawLine(inset, y + i, inset + barWidth, y + i, COLOR_DIM);
  }

  int fillWidth = barWidth * pct / 100;
  if (fillWidth > 0) {
    for (int i = 0; i < 3; i++) {
      tft.drawLine(inset, y + i, inset + fillWidth, y + i, fillColor);
    }
  }
}

// Small "NN%" battery readout at the bottom-left, in the bright accent color.
// Rendered into a canvas seeded with the background image so changing values
// don't leave ghosting and there's no color block behind the text.
void drawBatteryPct(int pct) {
  if (pct < 0) pct = 0;
  if (pct > 100) pct = 100;

  const int16_t cw = BATT_PCT_W;
  const int16_t ch = BATT_PCT_H;

  GFXcanvas16 c(cw, ch);
  uint16_t *buf = c.getBuffer();
  for (int16_t y = 0; y < ch; y++) {
    for (int16_t x = 0; x < cw; x++) {
      buf[y * cw + x] =
          pgm_read_word(&background[(BATT_PCT_Y + y) * BG_WIDTH + (BATT_PCT_X + x)]);
    }
  }

  char txt[8];
  snprintf(txt, sizeof(txt), "%d%%", pct);

  c.setFont();          // built-in 5x7 font
  c.setTextSize(1);
  c.setTextColor(COLOR_ACCENT);
  c.setCursor(0, (ch - 8) / 2);   // vertically center the smaller text
  c.print(txt);

  tft.drawRGBBitmap(BATT_PCT_X, BATT_PCT_Y, c.getBuffer(), cw, ch);
}

// Battery voltage readout at the bottom-right, mirroring the percentage. Right-
// aligned in its box, same accent color and small built-in font. Seeded from the
// background image so changing values leave no ghosting or color block.
void drawBatteryVolts(float volts) {
  const int16_t cw = BATT_V_W;
  const int16_t ch = BATT_V_H;

  GFXcanvas16 c(cw, ch);
  uint16_t *buf = c.getBuffer();
  for (int16_t y = 0; y < ch; y++) {
    for (int16_t x = 0; x < cw; x++) {
      buf[y * cw + x] =
          pgm_read_word(&background[(BATT_V_Y + y) * BG_WIDTH + (BATT_V_X + x)]);
    }
  }

  char txt[12];
  snprintf(txt, sizeof(txt), "%.1f", volts);

  c.setFont();          // built-in 5x7 font
  c.setTextSize(1);
  c.setTextColor(COLOR_ACCENT);

  int16_t x1, y1; uint16_t w, h;
  c.getTextBounds(txt, 0, 0, &x1, &y1, &w, &h);
  c.setCursor(cw - (int16_t)w - x1, (ch - 8) / 2);   // right-aligned
  c.print(txt);

  tft.drawRGBBitmap(BATT_V_X, BATT_V_Y, c.getBuffer(), cw, ch);
}

// "No Magazine" warning, red text centered just above the load bar. Drawn
// when the ammo sensor reports disconnected; otherwise the strip is repainted
// from the background image to clear it. Seeded from the background so there's no
// color block or ghosting.
void drawNoMag(bool show) {
  const int16_t cw = NOMAG_W;
  const int16_t ch = NOMAG_H;

  GFXcanvas16 c(cw, ch);
  uint16_t *buf = c.getBuffer();
  for (int16_t y = 0; y < ch; y++) {
    for (int16_t x = 0; x < cw; x++) {
      buf[y * cw + x] =
          pgm_read_word(&background[(NOMAG_Y + y) * BG_WIDTH + (NOMAG_X + x)]);
    }
  }

  if (show) {
    const char *msg = "No Magazine";
    c.setFont();        // built-in 5x7 font
    c.setTextSize(2);
    c.setTextColor(COLOR_RED);

    int16_t x1, y1; uint16_t w, h;
    c.getTextBounds(msg, 0, 0, &x1, &y1, &w, &h);
    int16_t tx = (cw - (int16_t)w) / 2 - x1;
    int16_t ty = (ch - (int16_t)h) / 2 - y1;
    c.setCursor(tx, ty);
    c.print(msg);
  }

  tft.drawRGBBitmap(NOMAG_X, NOMAG_Y, c.getBuffer(), cw, ch);
}

// Load timeout/cooldown bar (restyled into the thin-bar aesthetic): cyan while
// the load is on (timeout counting down), yellow during cooldown, empty track
// when ready.
void drawLoadBar(int state, int pct) {
  uint16_t clr;
  switch (state) {
    case LOAD_TIMER_ACTIVE:   clr = COLOR_ACCENT; break;
    case LOAD_TIMER_COOLDOWN: clr = COLOR_YELLOW; break;
    default:                  clr = COLOR_DIM; pct = 0; break;
  }
  drawThinBar(LOAD_BAR_Y, 0.50f, pct, clr);
}

// Compass arrow rendered into an off-screen canvas (centered on the dark area
// between the housing edges), then blitted once -- so the rotating arrow never
// flickers and we don't need to erase the previous frame.
void drawCompass(int heading) {
  const int16_t box = COMPASS_BOX;
  const int16_t topLeftX = COMPASS_CX - box / 2;
  const int16_t topLeftY = COMPASS_CY - box / 2;

  GFXcanvas16 c(box, box);
  // Clear to the solid background color baked behind the compass in bg.png so the
  // erase/redraw blends in and leaves no visible square.
  c.fillScreen(COLOR_HUD_BG);
  // Draw into local canvas coords; center is (box/2, box/2).
  // Negate the heading so the arrow always points at true north: if the device
  // faces 90 deg (east), north is 90 deg to the left, so draw at -90 (=270).
  float northAngle = (float)((360 - (heading % 360)) % 360);
  drawCompassArrow(c, box / 2, box / 2, 30, 30, northAngle, COLOR_ACCENT);
  tft.drawRGBBitmap(topLeftX, topLeftY, c.getBuffer(), box, box);
}

// Concave-base "compass arrow" (ported). Draws into the given canvas so the
// caller can blit it flicker-free.
void drawCompassArrow(GFXcanvas16 &c, int cx, int cy, int base, int height, float angle, uint16_t color) {
  float radians = angle * M_PI / 180.0;

  int baseWidth = base / 2;
  int arrowHeight = height * 1.5;
  int concaveDepth = base / 4;

  int x1 = cx;                                  // tip
  int y1 = cy - (arrowHeight / 2);
  int x2 = cx - baseWidth;                       // bottom left
  int y2 = cy + (arrowHeight / 2);
  int x3 = cx + baseWidth;                       // bottom right
  int y3 = cy + (arrowHeight / 2);
  int x4 = cx;                                   // concave center
  int y4 = cy + (arrowHeight / 2) - concaveDepth;

  int rx1 = ceil(cx + (x1 - cx) * cos(radians) - (y1 - cy) * sin(radians));
  int ry1 = ceil(cy + (x1 - cx) * sin(radians) + (y1 - cy) * cos(radians));
  int rx2 = ceil(cx + (x2 - cx) * cos(radians) - (y2 - cy) * sin(radians));
  int ry2 = ceil(cy + (x2 - cx) * sin(radians) + (y2 - cy) * cos(radians));
  int rx3 = ceil(cx + (x3 - cx) * cos(radians) - (y3 - cy) * sin(radians));
  int ry3 = ceil(cy + (x3 - cx) * sin(radians) + (y3 - cy) * cos(radians));
  int rx4 = ceil(cx + (x4 - cx) * cos(radians) - (y4 - cy) * sin(radians));
  int ry4 = ceil(cy + (x4 - cx) * sin(radians) + (y4 - cy) * cos(radians));

  // Thicken by drawing parallel offset copies.
  for (int i = -1; i <= 0; i++) {
    c.drawLine(rx1 + i, ry1 + i, rx2 + i, ry2 + i, color);
    c.drawLine(rx1 + i, ry1 + i, rx3 + i, ry3 + i, color);
    c.drawLine(rx2 + i, ry2 + i, rx4 + i, ry4 + i, color);
    c.drawLine(rx3 + i, ry3 + i, rx4 + i, ry4 + i, color);
  }
}

// Big ammo number drawn with the FreeSans24pt font into a canvas (seeded with the
// background image so there's no solid color block behind it) and blitted in one
// pass. Centered within the panel; the single blit keeps it flicker-free even at
// high pot update rates.
void drawAmmo(int ammo) {
  const int16_t cw = AMMO_PANEL_W;
  const int16_t ch = AMMO_PANEL_H;

  GFXcanvas16 c(cw, ch);
  // Seed the canvas with the matching region of the background image (instead of
  // a solid fill) so the number composites onto the artwork with no color block.
  uint16_t *buf = c.getBuffer();
  for (int16_t y = 0; y < ch; y++) {
    for (int16_t x = 0; x < cw; x++) {
      buf[y * cw + x] =
          pgm_read_word(&background[(AMMO_PANEL_Y + y) * BG_WIDTH + (AMMO_PANEL_X + x)]);
    }
  }

  char num[8];
  if ((uint16_t)ammo == AMMO_DISCONNECTED) {
    snprintf(num, sizeof(num), "--");          // sensor disconnected
  } else {
    snprintf(num, sizeof(num), "%02d", ammo);  // leading zero for single digits
  }

  c.setFont(&FreeSans24pt7b);
  c.setTextSize(2);

  int16_t x1, y1; uint16_t w, h;
  c.getTextBounds(num, 0, 0, &x1, &y1, &w, &h);
  int16_t cx = (cw - (int16_t)w) / 2 - x1;
  int16_t cy = (ch - (int16_t)h) / 2 - y1 - AMMO_Y_NUDGE;

  c.setTextColor(COLOR_ACCENT);
  c.setCursor(cx, cy);
  c.print(num);

  tft.drawRGBBitmap(AMMO_PANEL_X, AMMO_PANEL_Y, c.getBuffer(), cw, ch);
}

// ---------------------------------------------------------------------------
// Wiring summary
//
//   Display pin   RP2350 GPIO      Notes
//   -----------   --------------   -------------------------------------------
//   VCC           3V3              3.3V power
//   GND           GND              ground
//   DIN (MOSI)    GP3              SPI0 TX (routed via SPI.setTX)
//   CLK (SCLK)    GP2              SPI0 SCK (routed via SPI.setSCK)
//   CS            GP1              chip select (matches TFT_CS)
//   DC            GP4              data/command (matches TFT_DC)
//   RST           GP5              reset (or tie to 3V3 + set TFT_RST to -1)
//   BL            GP24             backlight PWM (matches TFT_BL)
//
// I2C bus to the master (this device is the slave at I2C_ADDR), on the I2C0
// controller = Wire:
//   SDA           GP12             data  (add a pull-up if the bus lacks one)
//   SCL           GP13             clock (add a pull-up if the bus lacks one)
//   common GND between master and this board is required.
//
// Buttons (momentary pushbuttons, other leg to GND; the internal pull-ups
// are enabled so no external resistors are needed):
//   button 1      GP21             brightness + (hold to auto-repeat)
//   button 2      GP20             brightness - (hold to auto-repeat)
//   button 3      GP19             action button. Short clicks and long
//                                  presses are queued and read back by the
//                                  I2C master (long press = magazine
//                                  calibration mode, short = save). Short
//                                  clicks also run a local handler that
//                                  branches on magazine inserted vs not
//                                  (still just logs).
// ---------------------------------------------------------------------------
