breadboard
Example project · Builds & projects

Round eye (ESP32-S3 + round TFT)

A glowing robot eye roams a 1.28″ round IPS TFT LCD at 60 frames per second: quick darts and slow glides, a scanning lens ring, blinks and changing moods. Original C firmware on the emulated ESP32-S3 redraws only what changed and streams it through SPI DMA to a GC9A01A. A fixed random seed makes every run identical; there is no network connection or external asset. Open Code to read the complete project, including the Apache-2.0 Espressif display driver.

The Round eye (ESP32-S3 + round TFT) circuit as rendered by the simulator

What's on the bench

  • Battery
  • ESP32-S3
  • Round IPS TFT 1.28″ 240×240 (GC9A01)

How it's wired

  • Battery · pos→ESP32-S3 · vin
  • Battery · neg→ESP32-S3 · gnd
  • ESP32-S3 · 3v3→Round IPS TFT 1.28″ 240×240 (GC9A01) · vcc
  • ESP32-S3 · gnd2→Round IPS TFT 1.28″ 240×240 (GC9A01) · gnd
  • ESP32-S3 · g7→Round IPS TFT 1.28″ 240×240 (GC9A01) · din
  • ESP32-S3 · g6→Round IPS TFT 1.28″ 240×240 (GC9A01) · clk
  • ESP32-S3 · g5→Round IPS TFT 1.28″ 240×240 (GC9A01) · cs
  • ESP32-S3 · g3→Round IPS TFT 1.28″ 240×240 (GC9A01) · dc
  • ESP32-S3 · g4→Round IPS TFT 1.28″ 240×240 (GC9A01) · rst

The code

The exact ESP-IDF v5.3.5 project the bundled firmware was compiled from — shown in the simulator’s Code tab, where you can edit the sources and press Compile & run to rebuild the board’s firmware in the cloud sandbox.

README.md

# Round eye · ESP32-S3 and GC9A01A

An original robot eye on a 1.28-inch round 240×240 IPS TFT **LCD**, drawn at
60 frames per second. The glossy lens roams the whole round screen: quick darts
with a small overshoot, squash and stretch and settle, slow glides, an
occasional orbit around the rim, and still fixations broken by micro-saccades.
A segmented ring scans around the lens and whirs when the eye darts; the pupil
refocuses after each dart and a glint sweeps across the glass now and then. It
blinks (sometimes twice, the lids motion-blurred as they sweep), its pupil
breathes, and it can look happy, surprised, curious, squinting or sleepy. As it
looks toward the rim the lens foreshortens like a turning eyeball.

How it reaches 60 fps on an ESP32-S3 at 240 MHz (`main/main.c`, `main/eye.h`):

- **Pacing:** absolute `esp_timer` deadlines, exactly 60 per second. The panel's
  TE output is not on this seven-pin module.
- **Dirty rectangles:** each frame sends only the rectangles that changed (the
  iris with its glow, the lid bands), each in one SPI DMA transaction of at most
  32,000 bytes, at the driver's default 80 MHz clock.
- **Overlap:** two DMA buffers; the next rectangle renders while the previous one
  is on the wire.
- **Fixed point and tables:** a dithered static background, the iris colour by
  radius in RGB565 (patched each frame for the pupil and its rim), and lid
  colours are built once at boot. Lids are column fills and gradient tables;
  their edge ramps and shadows, the highlights and the glint blend in RGB565.
  Only the segment ring band and the pupil's anti-aliased edge need more than a
  table read per pixel. Integer arithmetic only; the host preview checks every
  frame against the full per-pixel model (`eye_pixel`).
- **Still when still:** between moves the lens rests exactly, the ring rests
  between scans and settled lids stop, so a quiet frame redraws only the
  breathing pupil.
- **Determinism:** a fixed-seed xorshift generator drives the behaviour, so every
  run shows the same frames. There is no hardware random number, clock seed,
  network, font or external art.

Serial output: one start line, `[round-eye] frame=0 …` after the first full
frame, then one progress line every two seconds with frames per second, the
render share of the CPU budget, bytes and rectangles per frame and late frames.

Wiring: 3V3→VCC, GND→GND, G7→DIN, G6→CLK, G5→CS, G3→DC, G4→RST.
The seven-pin module's backlight is tied to VCC. The selected module uses
BGR element order and inversion enabled for ordinary colours.

Build with ESP-IDF **v5.5.2** (commit
`30aaf64524299d3bde422ca9a2848090d1bc5d0f`), Xtensa GCC
`esp-14.2.0_20251107`, `idf.py set-target esp32s3`, then `idf.py build`.
The app image is `build/round-eye.bin`; Breadboard loads it at offset 0x10000.
For byte-identical rebuilds set `SOURCE_DATE_EPOCH=1767225600` and retain the
reproducible-build sdkconfig settings and pinned versions. The repository’s internal fixture build script is the local acceptance
recipe; it is not needed for a normal IDF build or hardware flash.
To flash a physical board, run `idf.py -p PORT flash monitor`; this builds
and flashes the bootloader, partition table and application. Use the board’s
USB-to-UART port for UART0 logs. Wire a separate BL pin to 3V3 if present.

All first-party project files are MIT (see LICENSE). The two unchanged
Espressif GC9A01 driver files are Apache-2.0 with their own retained license.
Their [provenance](components/gc9a01/UPSTREAM.md) explains the first-party CMake
integration. ESP-IDF and the target runtime retain their own notices beside the
served application image. Code opens the example as a standard editable
ESP-IDF full project. Compile & run
uses the advertised full-project compiler (Pro), currently IDF v5.3.5. The
bundled fixture was built with IDF v5.5.2. The startup banner is a fixed string:
cloud rebuilds still print `IDF v5.5.2` in their first line, even though they
use the compiler’s pinned version and produce a complete merged flash image.
Both paths use the unchanged in-tree GC9A01 driver.

LICENSE

MIT License

Copyright (c) 2026 Breadboard

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
set(COMPONENTS main)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(round-eye)
idf_build_get_property(round_components BUILD_COMPONENTS)
foreach(component bt esp_wifi esp_phy esp_coex esp_netif esp_eth lwip openthread wpa_supplicant)
    if(component IN_LIST round_components)
        message(FATAL_ERROR "Unexpected network/radio component: ${component}")
    endif()
endforeach()

sdkconfig.defaults

CONFIG_IDF_TARGET="esp32s3"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_COMPILER_OPTIMIZATION_PERF=y
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y
CONFIG_FREERTOS_UNICORE=y
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
CONFIG_APP_PROJECT_VER_FROM_CONFIG=y
CONFIG_APP_PROJECT_VER="round-eye-2"
CONFIG_APP_REPRODUCIBLE_BUILD=y

main/CMakeLists.txt

idf_component_register(SRCS "main.c" INCLUDE_DIRS "."
    REQUIRES gc9a01 esp_lcd esp_driver_spi esp_driver_gpio esp_driver_uart esp_timer esp_hw_support vfs)

main/main.c

/* SPDX-License-Identifier: MIT
 * Copyright (c) 2026 Breadboard
 * Original robot eye on a 240x240 round GC9A01A, 60 frames per second on the
 * ESP32-S3: esp_timer pacing, dirty rectangles and two DMA buffers. */
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include "driver/spi_master.h"
#include "esp_cpu.h"
#include "esp_heap_caps.h"
#include "esp_lcd_gc9a01.h"
#include "esp_lcd_panel_io.h"
#include "esp_lcd_panel_ops.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "eye.h"

#define PCLK_HZ (80 * 1000 * 1000) /* the pinned driver's own default clock */
#define REPORT_FRAMES (2 * EYE_FPS)

static esp_lcd_panel_handle_t panel;
static uint16_t *buffers[2];
static unsigned next_buffer;
static TaskHandle_t eye_task;

static void frame_due(void *arg)
{
    (void)arg;
    xTaskNotifyGive(eye_task);
}

/* Render each rectangle into the buffer the bus is not reading and queue it.
 * draw_bitmap first waits for the previous color transfer, so the buffer
 * written here was released by the transfer before last. */
static unsigned send(const eye_frame_t *f, const eye_rect_t *rects, int n, uint32_t *render_cycles)
{
    unsigned bytes = 0;
    for (int i = 0; i < n; i++) {
        const eye_rect_t r = rects[i];
        uint16_t *pixels = buffers[next_buffer];
        next_buffer ^= 1;
        const uint32_t start = esp_cpu_get_cycle_count();
        eye_render_rect(f, r, pixels);
        *render_cycles += esp_cpu_get_cycle_count() - start;
        ESP_ERROR_CHECK(esp_lcd_panel_draw_bitmap(panel, r.x0, r.y0, r.x1, r.y1, pixels));
        bytes += 2u * (unsigned)((r.x1 - r.x0) * (r.y1 - r.y0)) + 11; /* + CASET/RASET/RAMWR */
    }
    return bytes;
}

void app_main(void)
{
    printf("[round-eye] ESP32-S3 / GC9A01A 240x240 / IDF v5.5.2 / SPI 80 MHz / %d fps\n", EYE_FPS);
    eye_task = xTaskGetCurrentTaskHandle();
    const spi_bus_config_t bus = {
        .sclk_io_num = 6, .mosi_io_num = 7, .miso_io_num = -1,
        .quadwp_io_num = -1, .quadhd_io_num = -1,
        .max_transfer_sz = MAX_RECT_PX * 2,
    };
    ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &bus, SPI_DMA_CH_AUTO));
    const esp_lcd_panel_io_spi_config_t io_config = {
        .dc_gpio_num = 3, .cs_gpio_num = 5, .pclk_hz = PCLK_HZ,
        .lcd_cmd_bits = 8, .lcd_param_bits = 8, .spi_mode = 0,
        .trans_queue_depth = 4,
    };
    esp_lcd_panel_io_handle_t io;
    ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI2_HOST, &io_config, &io));
    const esp_lcd_panel_dev_config_t panel_config = {
        .reset_gpio_num = 4, .rgb_ele_order = LCD_RGB_ELEMENT_ORDER_BGR,
        .bits_per_pixel = 16,
    };
    ESP_ERROR_CHECK(esp_lcd_new_panel_gc9a01(io, &panel_config, &panel));
    ESP_ERROR_CHECK(esp_lcd_panel_reset(panel));
    ESP_ERROR_CHECK(esp_lcd_panel_init(panel));
    ESP_ERROR_CHECK(esp_lcd_panel_invert_color(panel, true));
    ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel, true));
    for (int i = 0; i < 2; i++) {
        buffers[i] = heap_caps_malloc(MAX_RECT_PX * 2, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
        assert(buffers[i]);
    }
    eye_init();

    static eye_brain_t brain;
    static eye_frame_t frames[2];
    eye_pose_t pose;
    eye_rect_t rects[MAX_RECTS];
    eye_brain_init(&brain, EYE_SEED);
    eye_brain_step(&brain, &pose);
    eye_prepare(&pose, &frames[0]);
    uint32_t render_cycles = 0;
    unsigned bytes = send(&frames[0], rects, eye_full(rects), &render_cycles);
    printf("[round-eye] frame=0 full redraw %u bytes\n", bytes);

    esp_timer_handle_t timer;
    const esp_timer_create_args_t timer_args = { .callback = frame_due, .name = "eye-frame" };
    ESP_ERROR_CHECK(esp_timer_create(&timer_args, &timer));
    const int64_t start = esp_timer_get_time();
    int64_t report_at = start;
    unsigned report_rects = 0, late = 0;
    bytes = 0; render_cycles = 0;
    for (uint32_t n = 1;; n++) {
        /* Deadlines are absolute, so 60 frames take exactly one second. */
        const int64_t due = start + (int64_t)n * 1000000 / EYE_FPS;
        const int64_t now = esp_timer_get_time();
        if (due > now) {
            ESP_ERROR_CHECK(esp_timer_start_once(timer, (uint64_t)(due - now)));
            ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
        } else {
            late++;
        }
        const int64_t woke = esp_timer_get_time();
        eye_frame_t *prev = &frames[(n - 1) & 1], *next = &frames[n & 1];
        eye_brain_step(&brain, &pose);
        eye_prepare(&pose, next);
        const int count = eye_dirty(prev, next, rects);
        bytes += send(next, rects, count, &render_cycles);
        report_rects += (unsigned)count;
        if (n % REPORT_FRAMES == 0) {
            /* Frame starts, deadline to deadline: exactly 60.00 unless frames run late. */
            const unsigned centi_fps = (unsigned)(REPORT_FRAMES * 100000000LL / (woke - report_at));
            /* Render share of the CPU budget, in tenths of a percent. */
            const unsigned busy = (unsigned)((uint64_t)render_cycles * 1000 /
                                             ((uint64_t)REPORT_FRAMES * (240000000 / EYE_FPS)));
            printf("[round-eye] t=%lus frames=%lu fps=%u.%02u render=%u.%u%% bytes/frame=%u rects/frame=%u.%02u late=%u\n",
                   (unsigned long)(n / EYE_FPS), (unsigned long)n, centi_fps / 100, centi_fps % 100,
                   busy / 10, busy % 10, bytes / REPORT_FRAMES, report_rects / REPORT_FRAMES,
                   report_rects * 100 / REPORT_FRAMES % 100, late);
            report_at = woke;
            bytes = 0; render_cycles = 0; report_rects = 0;
        }
    }
}

main/eye.h

/* SPDX-License-Identifier: MIT
 * Copyright (c) 2026 Breadboard
 * Original robot eye for a 240x240 round display: behaviour, dirty rectangles
 * and a fixed-point renderer. Plain C with no ESP-IDF calls. */
#pragma once
#include <math.h>
#include <stdint.h>
#include <string.h>

#define EYE_W 240
#define EYE_FPS 60
#define IRIS_R 52          /* iris radius, px */
#define HALO 9             /* glow beyond the iris edge, px */
#define IRIS_RH (IRIS_R + HALO)
#define GAZE_MAX 54.0f     /* furthest resting iris offset from the centre, px */
#define GAZE_CLAMP 58.0f   /* hard limit, leaves room for spring overshoot */
#define EYEBALL_R 108.0f   /* virtual eyeball radius: the iris foreshortens as it turns */
#define LID_SHADOW 7       /* lid shadow depth on the eye, px */
#define LID_DEPTH 6        /* lid shading depth beyond its edge, px */
#define CLOSE_Y 150.0f     /* where the lids meet in a blink */
#define MAX_RECTS 24
#define LID_SLICE 30       /* columns per lid band rectangle */
#define RECT_COST_PX 3000  /* a rectangle's driver overhead, in mostly-background pixels */
#define MAX_RECT_PX 16000  /* one SPI DMA transaction of at most 32,000 bytes */
#define EYE_SEED 0x5eedb0b5u /* fixed seed: every run shows the same frames */

#ifndef EYE_STAT
#define EYE_STAT(name)
#endif

typedef struct { int16_t x0, y0, x1, y1; } eye_rect_t; /* x1/y1 exclusive */

/* Everything one frame shows. Lid edges: y = c_y + c_k*dx^2 + tilt*dx with
 * dx measured from the screen centre (the bottom lid uses -c_k). */
typedef struct {
    float gx, gy, pupil;
    float top_y, top_k, top_tilt;
    float bot_y, bot_k, bot_tilt;
    float vx, vy;   /* iris velocity, px/s: squash and stretch along fast moves */
    float scale;    /* iris size, 1 = IRIS_R */
    float spin;     /* segment ring rotation, turns */
    float glint;    /* glint sweep progress 0..1, < 0 when none */
    float top_blur, bot_blur; /* lid edge softness, px: motion blur while the lids move fast */
} eye_pose_t;

typedef struct {
    int32_t ix4, iy4;                /* iris centre, 1/16 px */
    int32_t m00, m01, m10, m11;      /* screen offset -> iris plane (undo size, stretch, turn), Q12 */
    int32_t ext4;                    /* screen radius that contains iris and glow, 1/16 px */
    int32_t rp2, rp_lo, rp_hi, rp_m; /* pupil radius terms, 1/256 px^2 */
    int32_t rp_in;                   /* plain pupil inside its rim, 1/256 px^2 */
    uint16_t spin16;                 /* segment ring phase, 1/65536 turn */
    uint8_t gl_on;                   /* a glint is crossing the lens */
    int32_t gl_p4;                   /* glint band offset along GLINT_N from the iris centre, 1/16 px */
    int32_t h1x, h1y, h2x, h2y;      /* highlight offsets from the iris centre, 1/16 px */
    int16_t top4[EYE_W], bot4[EYE_W]; /* lid edges per column, 1/16 px */
    int32_t top_min4, bot_max4;       /* the top edge's highest point, the bottom edge's lowest */
    int32_t thw, bhw;                /* lid edge half-widths (anti-aliasing or motion blur), 1/16 px */
    int32_t thw_inv, bhw_inv;        /* 128/half-width, Q16: the ramp without a division */
    uint8_t top_bowl, bot_bowl;      /* edge curvature lets the row test use the span ends */
} eye_frame_t;

/* ---- lookup tables, built once at boot ------------------------------------ */
#define RH2 (IRIS_RH * IRIS_RH)
#define RH4 (IRIS_RH * 16)
#define RING_Q (RH2 * 256)
#define IRIS_R2Q (IRIS_R * IRIS_R * 256)
#define VIG_S4 (4 * 97 * 97)        /* rim darkening starts at r = 97 px */
#define BG_RGB 0x0c151eu
#define PUPIL_RGB 0x02060au
#define PUPIL_RIM_RGB 0x0e5670u       /* a dim lens rim just inside the pupil */
#define LID_RIM_RGB 0x46697du
#define SEG_RGB 0x8cf2ffu             /* lit segments of the rotating ring */
#define SEG_R0 39.25f                 /* segment ring band, px (0.755 and 0.86 of IRIS_R) */
#define SEG_R1 44.75f
#define SEG_LO 1540                   /* floor(SEG_R0^2) */
#define SEG_HI 2003                   /* ceil(SEG_R1^2) */
#define SEG_LO_Q (SEG_LO * 256)
#define SEG_HI_Q (SEG_HI * 256)
#define ANG_W (2 * IRIS_RH + 1)
#define GLINT_NX 3138                 /* glint sweep direction (cos 40, sin 40), Q12 */
#define GLINT_NY 2633
#define GLINT_W4 (7 * 16)             /* glint half width, 1/16 px */

static uint32_t ring_lut[RH2 + 1];      /* iris and glow over the background, by r^2 (px^2) */
static uint8_t vig_lut[3601];           /* rim darkening by r^2/4 */
static uint32_t lid_lut[LID_DEPTH * 4 + 1]; /* lid colour by depth beyond its edge, 1/4 px */
static uint8_t shade_top[LID_SHADOW * 16 + 16], shade_bot[LID_SHADOW * 16 + 16]; /* of 32 */
static uint8_t hl1_lut[4 * 11 * 11 + 1], hl2_lut[4 * 5 * 5 + 1];
static uint16_t angle_lut[ANG_W * ANG_W];  /* iris-plane angle by whole-pixel offset, 1/65536 turn */
static uint8_t seg_lut[1024];              /* 32 segments under a sweeping highlight, one turn */
static uint8_t segr_lut[SEG_HI - SEG_LO];  /* the ring band's radial profile by r^2 */
#define SEG_STEPS 40                       /* radial steps across the band (about 0.14 px) */
#define SEG_K (SEG_STEPS * 65536 / ((SEG_HI - SEG_LO) * 16))
static uint16_t seg_r2[SEG_STEPS];         /* each step's centre, r^2 index into ring_lut */
static uint8_t seg_rq[SEG_STEPS];          /* each step's radial profile */
static uint8_t prim_lut[33];               /* pupil rim glow by depth inside the edge, 1/16 px */
static uint8_t glint_lut[GLINT_W4 / 4 + 1];/* glint profile across the band, 1/4 px */
/* Static frame parts: the dithered background with its rim darkening, the
 * iris and lids in RGB565 for the four 2x2 dither phases, and each row's
 * aperture and darkening-free column spans. */
static uint16_t bg565[EYE_W * EYE_W];
static uint16_t lid565[4][LID_DEPTH * 4 + 1];
static uint16_t deep565[4][256]; /* the deep lid darkened toward the screen rim, by vig_lut level */
static uint16_t pupil565;
/* The iris by r^2 in RGB565 for the four 2x2 dither phases, with the plain
 * pupil patched in per frame; MID565 marks entries that need iris_rgb (the
 * segment ring band and the pupil edge and rim). */
#define MID565 0x0100u
static uint16_t fast565[4][RH2 + 1];
static int32_t fast_rp2 = -1, fast_in = -1, fast_hi = -1;
static uint16_t seg565[4][SEG_STEPS][64];  /* the segment band by (step, level), per dither phase */
static const uint8_t phase_dither[4] = {0, 8, 12, 4}; /* 2x2 ordered thresholds of 16 */
static uint8_t ap_x0[EYE_W], ap_x1[EYE_W], vig_x0[EYE_W], vig_x1[EYE_W];

static float eye_clampf(float v, float lo, float hi) { return v < lo ? lo : v > hi ? hi : v; }
static float eye_smooth(float e0, float e1, float v)
{
    const float t = eye_clampf((v - e0) / (e1 - e0), 0.f, 1.f);
    return t * t * (3.f - 2.f * t);
}
static uint32_t eye_rgbf(float r, float g, float b)
{
    return ((uint32_t)eye_clampf(r, 0, 255) << 16) | ((uint32_t)eye_clampf(g, 0, 255) << 8) |
           (uint32_t)eye_clampf(b, 0, 255);
}

/* ---- colour helpers ------------------------------------------------------- */
static inline uint32_t eye_lerp(uint32_t c0, uint32_t c1, uint32_t a) /* a in 0..256, 256 = c1 */
{
    const uint32_t ia = 256 - a;
    const uint32_t rb = (((c0 & 0xff00ffu) * ia + (c1 & 0xff00ffu) * a) >> 8) & 0xff00ffu;
    const uint32_t g = (((c0 & 0x00ff00u) * ia + (c1 & 0x00ff00u) * a) >> 8) & 0x00ff00u;
    return rb | g;
}
static inline uint32_t eye_scale(uint32_t c, uint32_t s) /* s in 0..255 */
{
    return ((((c & 0xff00ffu) * s) >> 8) & 0xff00ffu) | ((((c & 0x00ff00u) * s) >> 8) & 0x00ff00u);
}
static const uint8_t eye_bayer[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
static inline uint16_t eye_dither565(uint32_t c, uint32_t d) /* d: threshold of 16 */
{
    uint32_t r = (c >> 16) + (d >> 1), g = ((c >> 8) & 255) + (d >> 2), b = (c & 255) + (d >> 1);
    r = r > 255 ? 255 : r; g = g > 255 ? 255 : g; b = b > 255 ? 255 : b;
    const uint16_t v = (uint16_t)(((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3));
    return (uint16_t)((v >> 8) | (v << 8)); /* RGB565, high byte first on the wire */
}
static inline uint16_t eye_pack(uint32_t c, int x, int y) { return eye_dither565(c, eye_bayer[y & 3][x & 3]); }

/* Iris colour profile by radius: a glowing lens with a bright collar,
 * two fine rings and a dark limbal edge. */
static void iris_color(float r, float *cr, float *cg, float *cb)
{
    static const float stops[][4] = {
        {0.00f, 190, 250, 255}, {0.30f, 120, 236, 255}, {0.46f, 64, 208, 246},
        {0.70f, 26, 142, 206},  {0.88f, 12, 80, 150},   {0.96f, 6, 38, 82},
        {1.01f, 4, 22, 48},
    };
    const float t = r / IRIS_R;
    int i = 0;
    while (i < 5 && t > stops[i + 1][0]) i++;
    const float u = eye_clampf((t - stops[i][0]) / (stops[i + 1][0] - stops[i][0]), 0, 1);
    float c[3];
    for (int k = 0; k < 3; k++) c[k] = stops[i][k + 1] + (stops[i + 1][k + 1] - stops[i][k + 1]) * u;
    const float collar = expf(-(r - 0.47f * IRIS_R) * (r - 0.47f * IRIS_R) / (2 * 1.6f * 1.6f));
    const float ring1 = expf(-(r - 0.63f * IRIS_R) * (r - 0.63f * IRIS_R) / (2 * 0.55f * 0.55f));
    c[0] += 70 * collar + 40 * ring1;
    c[1] += 60 * collar + 46 * ring1;
    c[2] += 30 * collar + 30 * ring1;
    *cr = c[0]; *cg = c[1]; *cb = c[2];
}

static void eye_init_luts(void)
{
    const float bg[3] = {(BG_RGB >> 16) & 255, (BG_RGB >> 8) & 255, BG_RGB & 255};
    for (int i = 0; i <= RH2; i++) {
        const float r = sqrtf(i + 0.5f);
        float c[3];
        iris_color(fminf(r, IRIS_R - 0.5f), &c[0], &c[1], &c[2]);
        static const float glow[3] = {40, 190, 255};
        const float u = eye_clampf((r - IRIS_R) / HALO, 0, 1);
        const float ha = 0.42f * powf(1 - u, 2.2f) * (r < IRIS_RH ? 1 : 0);
        const float cov = eye_clampf(IRIS_R + 0.5f - r, 0, 1); /* anti-aliased iris edge */
        float o[3];
        for (int k = 0; k < 3; k++) o[k] = cov * c[k] + (1 - cov) * (ha * glow[k] + (1 - ha) * bg[k]);
        ring_lut[i] = eye_rgbf(o[0], o[1], o[2]);
    }
    for (int i = 0; i <= 3600; i++)
        vig_lut[i] = (uint8_t)(255 * (1 - eye_smooth(97, 120.5f, sqrtf(i * 4.f + 2))) + 0.5f);
    for (int i = 0; i <= LID_DEPTH * 4; i++) {
        const float d = i / 4.f;
        const float rim = 1 - eye_smooth(0.4f, 2.2f, d);
        const float body = eye_smooth(1.5f, LID_DEPTH, d);
        lid_lut[i] = eye_rgbf(70 * rim + (1 - rim) * (9 - 5 * body),
                              105 * rim + (1 - rim) * (13 - 7 * body),
                              125 * rim + (1 - rim) * (17 - 9 * body));
    }
    for (int i = 0; i < (int)sizeof shade_top; i++) {
        const float d = i / 16.f;
        shade_top[i] = (uint8_t)lroundf(32 * (0.42f + 0.58f * eye_smooth(-0.5f, LID_SHADOW, d)));
        shade_bot[i] = (uint8_t)lroundf(32 * (0.70f + 0.30f * eye_smooth(-0.5f, LID_SHADOW * 0.6f, d)));
    }
    for (int i = 0; i < (int)sizeof hl1_lut; i++)
        hl1_lut[i] = (uint8_t)(235 * (1 - eye_smooth(4.5f, 11, sqrtf(i / 4.f))));
    for (int i = 0; i < (int)sizeof hl2_lut; i++)
        hl2_lut[i] = (uint8_t)(200 * (1 - eye_smooth(1.6f, 5, sqrtf(i / 4.f))));
    for (int y = 0; y < ANG_W; y++)
        for (int x = 0; x < ANG_W; x++) {
            const float a = atan2f((float)(y - IRIS_RH), (float)(x - IRIS_RH)) / 6.2831853f;
            angle_lut[y * ANG_W + x] = (uint16_t)(int32_t)lroundf((a < 0 ? a + 1 : a) * 65536);
        }
    for (int i = 0; i < 1024; i++) {
        /* Dashes of period 32 (lit for about 20, soft edges) under a scanning
         * sweep: brightest at the leading dash, fading over the turn behind it. */
        const float q = (float)(i & 31), behind = (1023 - i) / 1023.f;
        const float sweep = 0.22f + 0.78f * powf(1 - behind, 1.6f);
        seg_lut[i] = (uint8_t)(205 * sweep * eye_smooth(4, 7, q) * (1 - eye_smooth(24, 27, q)));
    }
    for (int i = 0; i < SEG_HI - SEG_LO; i++) {
        const float r = sqrtf(SEG_LO + i + 0.5f);
        segr_lut[i] = (uint8_t)(255 * eye_smooth(SEG_R0, SEG_R0 + 1.2f, r) * (1 - eye_smooth(SEG_R1 - 1.2f, SEG_R1, r)));
    }
    for (int i = 0; i < SEG_STEPS; i++) {
        const int k = SEG_LO + (int)(((2 * i + 1) * (SEG_HI - SEG_LO)) / (2 * SEG_STEPS));
        seg_r2[i] = (uint16_t)k;
        seg_rq[i] = segr_lut[k - SEG_LO];
    }
    for (int i = 0; i < (int)sizeof prim_lut; i++)
        prim_lut[i] = (uint8_t)(150 * (1 - eye_smooth(0, 2, i / 16.f)));
    for (int i = 0; i < (int)sizeof glint_lut; i++) {
        const float d = i / 4.f;
        glint_lut[i] = (uint8_t)(100 * expf(-d * d / (2 * 2.6f * 2.6f))); /* a soft sheen */
    }
}

/* ---- per-frame geometry ----------------------------------------------------- */
static void eye_prepare(const eye_pose_t *p, eye_frame_t *f)
{
    f->ix4 = (int32_t)lroundf((120 + p->gx) * 16);
    f->iy4 = (int32_t)lroundf((120 + p->gy) * 16);
    /* Screen = size * stretch * turn * iris plane; the renderer needs the
     * inverse. Turned toward the rim, the iris foreshortens along the turn
     * (undone by I + k*u*u^T, k = 1/cos(turn) - 1). Moving fast, it stretches
     * by e along its velocity and narrows by 1/sqrt(e) across it. */
    const float d = sqrtf(p->gx * p->gx + p->gy * p->gy);
    float ux = 1, uy = 0, k = 0;
    if (d > 0.01f) {
        ux = p->gx / d; uy = p->gy / d;
        const float q = eye_clampf(d / EYEBALL_R, 0, 0.9f);
        k = 1 / sqrtf(1 - q * q) - 1;
    }
    const float speed = sqrtf(p->vx * p->vx + p->vy * p->vy);
    float wx = 1, wy = 0;
    const float e = speed < 20 ? 1 : 1 + eye_clampf(speed * 0.00009f, 0, 0.14f);
    if (speed > 1) { wx = p->vx / speed; wy = p->vy / speed; }
    const float se = sqrtf(e), sw = 1 / e - se; /* stretch inverse: se*I + sw*w*w^T */
    const float s00 = se + sw * wx * wx, s01 = sw * wx * wy, s11 = se + sw * wy * wy;
    const float f00 = 1 + k * ux * ux, f01 = k * ux * uy, f11 = 1 + k * uy * uy;
    const float inv = 4096 / p->scale;
    f->m00 = (int32_t)lroundf((f00 * s00 + f01 * s01) * inv);
    f->m01 = (int32_t)lroundf((f00 * s01 + f01 * s11) * inv);
    f->m10 = (int32_t)lroundf((f01 * s00 + f11 * s01) * inv);
    f->m11 = (int32_t)lroundf((f01 * s01 + f11 * s11) * inv);
    f->ext4 = (int32_t)ceilf(IRIS_RH * p->scale * e * 16) + 1;
    f->spin16 = (uint16_t)(int32_t)lroundf((p->spin - floorf(p->spin)) * 65536);
    f->gl_on = p->glint >= 0;
    if (f->gl_on) { /* eased sweep from one side of the lens to the other */
        const float g = p->glint * p->glint * (3 - 2 * p->glint);
        f->gl_p4 = (int32_t)lroundf((-(IRIS_R + 8) + 2 * (IRIS_R + 8) * g) * p->scale * 16);
    }
    const float rp = p->pupil;
    f->rp2 = (int32_t)lroundf(rp * rp * 256);
    f->rp_lo = (int32_t)lroundf((rp - 0.5f) * (rp - 0.5f) * 256);
    f->rp_hi = (int32_t)lroundf((rp + 0.5f) * (rp + 0.5f) * 256);
    f->rp_m = (int32_t)lroundf(65536 / (2 * rp));
    f->rp_in = rp > 2.5f ? (int32_t)lroundf((rp - 2.2f) * (rp - 2.2f) * 256) : 0;
    /* Corneal reflections stay put against the light, drifting slightly with gaze. */
    f->h1x = (int32_t)lroundf((-15 - 0.10f * p->gx) * 16);
    f->h1y = (int32_t)lroundf((-17 - 0.10f * p->gy) * 16);
    f->h2x = (int32_t)lroundf((14 - 0.06f * p->gx) * 16);
    f->h2y = (int32_t)lroundf((15 - 0.06f * p->gy) * 16);
    f->thw = (int32_t)lroundf(eye_clampf(p->top_blur, 0.5f, 4) * 16);
    f->bhw = (int32_t)lroundf(eye_clampf(p->bot_blur, 0.5f, 4) * 16);
    f->thw_inv = (128 << 16) / f->thw;
    f->bhw_inv = (128 << 16) / f->bhw;
    f->top_bowl = p->top_k >= 0; /* top edge convex: its lowest point over a span is at an end */
    f->bot_bowl = p->bot_k >= 0; /* bottom edge concave: its highest point is at an end */
    /* Lids at rest (most frames) reuse the last edges. */
    static float lid_key[6] = {1e30f};
    static int16_t top_last[EYE_W], bot_last[EYE_W];
    static int32_t top_min, bot_max;
    const float key[6] = {p->top_y, p->top_k, p->top_tilt, p->bot_y, p->bot_k, p->bot_tilt};
    if (memcmp(key, lid_key, sizeof key) != 0) {
        memcpy(lid_key, key, sizeof key);
        for (int x = 0; x < EYE_W; x++) {
            const float dx = x + 0.5f - 120;
            const float t = eye_clampf(p->top_y + p->top_k * dx * dx + p->top_tilt * dx, -300, 600) * 16;
            const float b = eye_clampf(p->bot_y - p->bot_k * dx * dx + p->bot_tilt * dx, -300, 600) * 16;
            top_last[x] = (int16_t)(int32_t)(t + (t >= 0 ? 0.5f : -0.5f));
            bot_last[x] = (int16_t)(int32_t)(b + (b >= 0 ? 0.5f : -0.5f));
            if (x == 0 || top_last[x] < top_min) top_min = top_last[x];
            if (x == 0 || bot_last[x] > bot_max) bot_max = bot_last[x];
        }
    }
    f->top_min4 = top_min;
    f->bot_max4 = bot_max;
    memcpy(f->top4, top_last, sizeof top_last);
    memcpy(f->bot4, bot_last, sizeof bot_last);
}

/* ---- renderer ------------------------------------------------------------- */
/* The segment ring band, quantized (radial step, segment level) so the
 * renderer can take it from a table: the band never reaches the pupil. */
static inline int seg_step(int r2q) { return (((r2q >> 4) - SEG_LO * 16) * SEG_K) >> 16; }
static inline int seg_level(const eye_frame_t *f, int a, int b)
{
    const int ia = (a + 8) >> 4, ib = (b + 8) >> 4;
    const uint16_t turn = (uint16_t)(angle_lut[(ib + IRIS_RH) * ANG_W + ia + IRIS_RH] + f->spin16);
    return seg_lut[turn >> 6] >> 2; /* 0..51 */
}
static inline uint32_t seg_rgb(int step, int level)
{
    const uint32_t blend = level ? ((uint32_t)((level << 2) + 2) * seg_rq[step]) >> 8 : 0;
    return eye_lerp(ring_lut[seg_r2[step]], SEG_RGB, blend);
}

/* Iris, glow, segment ring and pupil at iris-plane offset (a, b), 1/16 px. */
static inline uint32_t iris_rgb(const eye_frame_t *f, int a, int b, int r2q)
{
    if (r2q >= SEG_LO_Q && r2q < SEG_HI_Q) return seg_rgb(seg_step(r2q), seg_level(f, a, b));
    uint32_t c = ring_lut[r2q >> 8];
    if (r2q < f->rp_hi) { /* pupil: anti-aliased edge and a dim rim inside it */
        const int depth = ((f->rp2 - r2q) * f->rp_m) >> 16; /* inside the edge, 1/256 px */
        const uint32_t pupil = depth < 512 ? eye_lerp(PUPIL_RGB, PUPIL_RIM_RGB, prim_lut[depth < 0 ? 0 : depth >> 4]) : PUPIL_RGB;
        const int cov = 128 + depth;
        c = eye_lerp(c, pupil, cov < 0 ? 0 : cov > 256 ? 256 : (uint32_t)cov);
    }
    return c;
}

/* Lid colour at depth d (1/16 px) beyond its edge; the lit rim at the edge. */
static inline uint16_t scale565(uint16_t v, uint32_t s) /* byte-swapped RGB565 times s/256 */
{
    const uint32_t w = (uint32_t)((v >> 8) | (v << 8)) & 0xffffu;
    const uint32_t r = (((w >> 11) * s) >> 8) << 11, g = ((((w >> 5) & 63) * s) >> 8) << 5, b = ((w & 31) * s) >> 8;
    const uint32_t o = r | g | b;
    return (uint16_t)((o >> 8) | (o << 8));
}
/* Byte-swapped RGB565 spread out (green apart from red and blue) so one
 * multiply weighs all three channels by a 5-bit weight, and back. */
#define X565 0x07e0f81fu
static inline uint32_t x565(uint16_t v)
{
    const uint32_t w = (uint32_t)((v >> 8) | (v << 8)) & 0xffffu;
    return (w | (w << 16)) & X565;
}
static inline uint16_t p565(uint32_t e)
{
    const uint32_t o = (e | (e >> 16)) & 0xffffu;
    return (uint16_t)((o >> 8) | (o << 8));
}
/* A lid's body at depth d >= 0 beyond its edge, darkened toward the screen
 * rim (the deep lid from its own table). */
static inline uint16_t lid_body(int d, int x, int y, int s4)
{
    const int i = d >> 2 > LID_DEPTH * 4 ? LID_DEPTH * 4 : d >> 2, p = ((y & 1) << 1) | (x & 1);
    if (s4 <= VIG_S4) return lid565[p][i];
    return i == LID_DEPTH * 4 ? deep565[p][vig_lut[s4 >> 4]] : scale565(lid565[p][i], vig_lut[s4 >> 4]);
}
/* The lids over a lid-free pixel `base` (byte-swapped RGB565): the top lid's
 * body, else the bottom lid's, else their shadows and edge ramps (one pixel
 * of anti-aliasing at rest, wider while a lid moves fast). Shared by the
 * renderer and the full model, so a pixel never depends on its path. */
static uint16_t lid_over(const eye_frame_t *f, uint16_t base, int x, int y, int s4)
{
    const int y4 = y * 16 + 8, dT = y4 - f->top4[x], dB = f->bot4[x] - y4;
    if (dT <= -f->thw) return lid_body(-dT, x, y, s4);
    if (dB <= -f->bhw) return lid_body(-dB, x, y, s4);
    uint32_t s = 32;
    if (dT < LID_SHADOW * 16) s = shade_top[dT < 0 ? 0 : dT];
    if (dB < LID_SHADOW * 16) s = (s * shade_bot[dB < 0 ? 0 : dB]) >> 5;
    uint32_t e = ((x565(base) * s) >> 5) & X565; /* s = 32 leaves it as is */
    if (dT < f->thw) { /* coverage of the eye, of 32 */
        const uint32_t a = (uint32_t)(((dT + f->thw) * f->thw_inv) >> 19);
        e = ((x565(lid_body(dT < 0 ? -dT : 0, x, y, s4)) * (32 - a) + e * a) >> 5) & X565;
    }
    if (dB < f->bhw) {
        const uint32_t a = (uint32_t)(((dB + f->bhw) * f->bhw_inv) >> 19);
        e = ((x565(lid_body(dB < 0 ? -dB : 0, x, y, s4)) * (32 - a) + e * a) >> 5) & X565;
    }
    return p565(e);
}

/* The lit lens at screen offset (dx, dy) from the iris centre, 1/16 px:
 * iris, glow, segment ring, pupil, glint and highlights; else background. */
static uint32_t eye_lens(const eye_frame_t *f, int dx, int dy)
{
    if (dy <= -f->ext4 || dy >= f->ext4 || dx <= -f->ext4 || dx >= f->ext4) return BG_RGB;
    const int ex = (f->m00 * dx + f->m01 * dy) >> 12, ey = (f->m10 * dx + f->m11 * dy) >> 12;
    const int r2q = ex * ex + ey * ey;
    if (r2q >= RING_Q) return BG_RGB;
    uint32_t c = iris_rgb(f, ex, ey, r2q);
    if (r2q < IRIS_R2Q) {
        if (f->gl_on) { /* a soft light band sweeping across the lens */
            const int u = (dx * GLINT_NX + dy * GLINT_NY) >> 12, g = u - f->gl_p4;
            const int ag = g < 0 ? -g : g;
            if (ag < GLINT_W4) c = eye_lerp(c, 0xffffffu, glint_lut[ag >> 2]);
        }
        int hx = dx - f->h1x, hy = dy - f->h1y;
        int d2 = (hx * hx + hy * hy) >> 6;
        if (d2 < (int)sizeof hl1_lut) c = eye_lerp(c, 0xffffffu, hl1_lut[d2]);
        hx = dx - f->h2x; hy = dy - f->h2y;
        d2 = (hx * hx + hy * hy) >> 6;
        if (d2 < (int)sizeof hl2_lut) c = eye_lerp(c, 0xffffffu, hl2_lut[d2]);
    }
    return c;
}

/* The eye without lids at (x, y): lens or background, rim darkening, RGB565. */
static uint16_t spot_pixel(const eye_frame_t *f, int x, int y)
{
    const int X = 2 * x + 1 - EYE_W, Y = 2 * y + 1 - EYE_W, s4 = X * X + Y * Y;
    uint32_t c = eye_lens(f, x * 16 + 8 - f->ix4, y * 16 + 8 - f->iy4);
    if (s4 > VIG_S4) c = eye_scale(c, vig_lut[s4 >> 4]);
    return eye_pack(c, x, y);
}

/* The complete per-pixel model: the lid-free eye, then the lids. The span
 * renderer reproduces it (within a 2x2 vs 4x4 dither step) for the common
 * cases from tables. */
static uint16_t eye_pixel(const eye_frame_t *f, int x, int y)
{
    const int X = 2 * x + 1 - EYE_W, Y = 2 * y + 1 - EYE_W, s4 = X * X + Y * Y;
    if (s4 > 57600) return 0; /* outside the round aperture */
    return lid_over(f, spot_pixel(f, x, y), x, y, s4);
}

/* One iris entry: the RGB565 colour, or MID565 inside the segment ring band. */
static uint16_t ring_entry(int p, int k)
{
    if (k >= SEG_LO && k < SEG_HI) return MID565;
    const uint16_t v = eye_dither565(ring_lut[k], phase_dither[p]);
    return v == MID565 ? MID565 ^ 0x2000u : v; /* keep the sentinel unique */
}

static void eye_init_frame_parts(void)
{
    static eye_frame_t open; /* no iris, lids far away: the bare background */
    open.ix4 = open.iy4 = -100000;
    open.thw = open.bhw = 8;
    open.thw_inv = open.bhw_inv = (128 << 16) / 8;
    for (int x = 0; x < EYE_W; x++) { open.top4[x] = -4000; open.bot4[x] = 8000; }
    open.top_min4 = -4000; open.bot_max4 = 8000;
    for (int y = 0; y < EYE_W; y++) {
        const int Y = 2 * y + 1 - EYE_W;
        int a0 = EYE_W, a1 = 0, v0 = EYE_W, v1 = 0;
        for (int x = 0; x < EYE_W; x++) {
            const int s4 = (2 * x + 1 - EYE_W) * (2 * x + 1 - EYE_W) + Y * Y;
            if (s4 <= 57600) { if (x < a0) a0 = x; a1 = x + 1; }
            if (s4 <= VIG_S4) { if (x < v0) v0 = x; v1 = x + 1; }
            bg565[y * EYE_W + x] = eye_pixel(&open, x, y);
        }
        ap_x0[y] = (uint8_t)(a0 < a1 ? a0 : 0); ap_x1[y] = (uint8_t)(a0 < a1 ? a1 : 0);
        vig_x0[y] = (uint8_t)(v0 < v1 ? v0 : 0); vig_x1[y] = (uint8_t)(v0 < v1 ? v1 : 0);
    }
    for (int p = 0; p < 4; p++) {
        for (int i = 0; i <= RH2; i++) fast565[p][i] = ring_entry(p, i);
        for (int i = 0; i <= LID_DEPTH * 4; i++) lid565[p][i] = eye_dither565(lid_lut[i], phase_dither[p]);
        for (int v = 0; v < 256; v++) deep565[p][v] = eye_dither565(eye_scale(lid_lut[LID_DEPTH * 4], (uint32_t)v), phase_dither[p]);
        for (int i = 0; i < SEG_STEPS; i++)
            for (int l = 0; l < 64; l++) seg565[p][i][l] = eye_dither565(seg_rgb(i, l), phase_dither[p]);

    }
    pupil565 = eye_dither565(PUPIL_RGB, 0);
}

static void eye_init(void)
{
    eye_init_luts();
    eye_init_frame_parts();
}

/* Bring fast565's pupil entries to this frame's pupil: the plain pupil, its
 * dim rim by r^2 (a gentle gradient: a 1 px^2 step is under 0.05 px), MID565
 * for the anti-aliased edge (exact per pixel), then the iris. */
static void eye_patch_pupil(const eye_frame_t *f)
{
    if (f->rp2 == fast_rp2) return;
    const int in = f->rp_in >= 255 ? (f->rp_in - 255) >> 8 : -1; /* whole entries inside the plain pupil */
    const int edge = f->rp_lo >> 8;                                /* first entry the edge reaches */
    const int hi = f->rp_hi >> 8;                                  /* last entry touching the edge */
    const int lo = (in < fast_in ? in : fast_in) + 1, top = hi > fast_hi ? hi : fast_hi;
    for (int k = lo < 0 ? 0 : lo; k <= top && k <= RH2; k++) {
        if (k <= in) { for (int p = 0; p < 4; p++) fast565[p][k] = pupil565; continue; }
        if (k > hi) { for (int p = 0; p < 4; p++) fast565[p][k] = ring_entry(p, k); continue; }
        if (k >= edge) { for (int p = 0; p < 4; p++) fast565[p][k] = MID565; continue; }
        const uint32_t c = iris_rgb(f, 0, 0, k * 256 + 128); /* the band never reaches the pupil */
        for (int p = 0; p < 4; p++) {
            const uint16_t v = eye_dither565(c, phase_dither[p]);
            fast565[p][k] = v == MID565 ? MID565 ^ 0x2000u : v; /* keep the sentinel unique */
        }
    }
    fast_rp2 = f->rp2; fast_in = in; fast_hi = hi;
}

/* Iris, glow and pupil for columns [x0, x1) of one row that no lid or
 * highlight reaches; rim > 0 applies the rim darkening. The turn transform
 * steps per column. */
/* The iris pixels the table cannot give (the segment ring band and the
 * pupil's anti-aliased edge), out of line so the span loop keeps its
 * registers. */
static void __attribute__((noinline)) iris_mid(const eye_frame_t *f, uint16_t *dst, int a, int b, int r2q, int x, int y)
{
    if (r2q >= SEG_LO_Q) { EYE_STAT(ring); *dst = seg565[((y & 1) << 1) | (x & 1)][seg_step(r2q)][seg_level(f, a, b)]; return; }
    EYE_STAT(rim);
    *dst = eye_pack(iris_rgb(f, a, b, r2q), x, y);
}

#define IRIS_PX(TAB, X)                                                            \
    do {                                                                           \
        const int a_ = ex >> 12, b_ = ey >> 12, q_ = a_ * a_ + b_ * b_;            \
        if (q_ < RING_Q) { /* else background, already copied */                   \
            const uint16_t v_ = (TAB)[q_ >> 8];                                    \
            if (v_ != MID565) { EYE_STAT(ring); row[X] = v_; }                     \
            else *mid++ = (uint8_t)(X);                                            \
        }                                                                          \
        ex += sx; ey += sy;                                                        \
    } while (0)

static void iris_span(const eye_frame_t *f, uint16_t *row, int y, int x0, int x1, int rim)
{
    const int dy = y * 16 + 8 - f->iy4, dx = x0 * 16 + 8 - f->ix4;
    int ex = f->m00 * dx + f->m01 * dy, ey = f->m10 * dx + f->m11 * dy;
    const int sx = 16 * f->m00, sy = 16 * f->m10;
    const uint16_t *even = fast565[(y & 1) << 1], *odd = fast565[((y & 1) << 1) | 1];
    if (!rim) { /* two pixels per step: fixed dither tables, no per-pixel select */
        uint8_t mids[EYE_W], *mid = mids; /* columns the tables leave to iris_mid */
        int x = x0;
        if (x & 1) { IRIS_PX(odd, x); x++; }
        for (; x + 1 < x1; x += 2) { IRIS_PX(even, x); IRIS_PX(odd, x + 1); }
        if (x < x1) IRIS_PX(even, x);
        for (const uint8_t *m = mids; m < mid; m++) {
            const int mx = *m, mdx = mx * 16 + 8 - f->ix4;
            const int a = (f->m00 * mdx + f->m01 * dy) >> 12, b = (f->m10 * mdx + f->m11 * dy) >> 12;
            iris_mid(f, &row[mx], a, b, a * a + b * b, mx, y);
        }
        return;
    }
    const uint16_t (*seg_e)[64] = seg565[(y & 1) << 1], (*seg_o)[64] = seg565[((y & 1) << 1) | 1];
    const int Y = 2 * y + 1 - EYE_W, Y2 = Y * Y;
    for (int x = x0; x < x1; x++, ex += sx, ey += sy) { /* the rim-darkening zone */
        const int a = ex >> 12, b = ey >> 12;
        const int r2q = a * a + b * b;
        if (r2q >= RING_Q) continue;
        const int X = 2 * x + 1 - EYE_W;
        const uint32_t vig = vig_lut[(X * X + Y2) >> 4];
        const uint16_t v = ((x & 1) ? odd : even)[r2q >> 8];
        EYE_STAT(rim);
        if (v != MID565) row[x] = scale565(v, vig);
        else if (r2q >= SEG_LO_Q) row[x] = scale565(((x & 1) ? seg_o : seg_e)[seg_step(r2q)][seg_level(f, a, b)], vig);
        else row[x] = eye_pack(eye_scale(iris_rgb(f, a, b, r2q), vig), x, y);
    }
}

/* A highlight's columns on one row, widened by a pixel each side. */
static int spot_span(int y4, int cx4, int cy4, int rad4, int16_t *span)
{
    const int dy = y4 - cy4;
    if (dy <= -rad4 || dy >= rad4) return 0;
    const int half = (int)sqrtf((float)(rad4 * rad4 - dy * dy)) + 16;
    span[0] = (int16_t)((cx4 - half) >> 4);
    span[1] = (int16_t)(((cx4 + half) >> 4) + 1);
    return 1;
}

/* Rows y with 16y+8 < v (clamped to the screen). */
static inline int rows_before(int v)
{
    const int n = (v - 8 + 15) >> 4;
    return n < 0 ? 0 : n > EYE_W ? EYE_W : n;
}

/* Rows [ya, yb) of one column under one lid's edge ramp and shadow only
 * (no body, the other lid out of reach): lid_over without its tests. sign -1
 * is the top lid (eye below its edge e4), +1 the bottom lid. */
static void lid_ramp1(uint16_t *px, int w, int x, int ya, int yb, int e4, int sign, int hw, int hw_inv, const uint8_t *shade)
{
    const uint16_t *even = lid565[x & 1], *odd = lid565[2 | (x & 1)];
    const int v0 = vig_x0[x], v1 = vig_x1[x];
    int d = -sign * (ya * 16 + 8 - e4); /* depth into the eye, 1/16 px */
    for (int y = ya; y < yb; y++, px += w, d -= sign * 16) {
        EYE_STAT(lid);
        uint32_t e = ((x565(*px) * shade[d < 0 ? 0 : d]) >> 5) & X565;
        if (d < hw) {
            const uint32_t a = (uint32_t)(((d + hw) * hw_inv) >> 19);
            uint16_t lid;
            if (y < v0 || y >= v1) { const int X = 2 * x + 1 - EYE_W, Y = 2 * y + 1 - EYE_W; lid = lid_body(d < 0 ? -d : 0, x, y, X * X + Y * Y); }
            else lid = ((y & 1) ? odd : even)[d < 0 ? (-d >> 2 > LID_DEPTH * 4 ? LID_DEPTH * 4 : -d >> 2) : 0];
            e = ((x565(lid) * (32 - a) + e * a) >> 5) & X565;
        }
        *px = p565(e);
    }
}

/* Rows [ya, yb) of one column under lid edge ramps and shadows. */
static void lid_ramps(const eye_frame_t *f, uint16_t *px, int w, int x, int ya, int yb)
{
    const int X = 2 * x + 1 - EYE_W;
    for (int y = ya; y < yb; y++, px += w) {
        EYE_STAT(lid);
        const int Y = 2 * y + 1 - EYE_W;
        *px = lid_over(f, *px, x, y, X * X + Y * Y);
    }
}

/* Rows [ya, yb) of one column under a lid's body, at depth 16y+8 - e4
 * (sign -1: the top lid, deeper upward) from its edge e4. The deep lid, rows
 * [deep_a, deep_b) at one end, is a two-tone fill; the rest takes the gradient. */
static inline void lid_rows(uint16_t *px, int w, int x, int ya, int yb, int e4, int sign, int deep_a, int deep_b)
{
    const uint16_t *even = lid565[x & 1], *odd = lid565[2 | (x & 1)];
    const int v0 = vig_x0[x], v1 = vig_x1[x];
    int ga = ya, gb = yb; /* the gradient rows */
    if (deep_b > deep_a) {
        /* Rows [n0, n1) clear of the rim darkening take the fill. */
        const int n0 = v0 < deep_a ? deep_a : v0 > deep_b ? deep_b : v0;
        const int n1 = v1 < n0 ? n0 : v1 > deep_b ? deep_b : v1;
        const uint16_t p = ((n0 & 1) ? odd : even)[LID_DEPTH * 4], q = ((n0 & 1) ? even : odd)[LID_DEPTH * 4];
        uint16_t *d = px + (n0 - ya) * w;
        const int n = n1 - n0;
        for (int k = 0; k + 1 < n; k += 2, d += 2 * w) { d[0] = p; d[w] = q; }
        if (n & 1) d[0] = p;
        const uint16_t *deep_e = deep565[x & 1], *deep_o = deep565[2 | (x & 1)];
        for (int k = 0; k < 2; k++) { /* the rim rows [deep_a, n0) and [n1, deep_b) */
            const int y0 = k ? n1 : deep_a, y1 = k ? deep_b : n0;
            int Y = 2 * y0 + 1 - EYE_W, s4 = (2 * x + 1 - EYE_W) * (2 * x + 1 - EYE_W) + Y * Y;
            uint16_t *q = px + (y0 - ya) * w;
            for (int y = y0; y < y1; y++, q += w, s4 += 4 * Y + 4, Y += 2)
                *q = ((y & 1) ? deep_o : deep_e)[vig_lut[s4 >> 4]];
        }
        if (deep_a == ya) ga = deep_b; else gb = deep_a;
    }
    px += (ga - ya) * w;
    int d = sign * (ga * 16 + 8 - e4);
    for (int y = ga; y < gb; y++, px += w, d += sign * 16) {
        EYE_STAT(lidbody);
        if (y < v0 || y >= v1) { const int X = 2 * x + 1 - EYE_W, Y = 2 * y + 1 - EYE_W; *px = lid_body(d, x, y, X * X + Y * Y); }
        else *px = ((y & 1) ? odd : even)[d >> 2 > LID_DEPTH * 4 ? LID_DEPTH * 4 : d >> 2];
    }
}

/* Lay both lids over columns of a rendered rectangle. Each column's rows are
 * classified once from its two edges, top lid first, exactly as lid_over
 * would: the top body (rows above t_body), edge ramps and shadows, the bottom
 * body (rows from b_body). */
static void lid_columns(const eye_frame_t *f, eye_rect_t r, uint16_t *out)
{
    const int w = r.x1 - r.x0;
    const int reach_t = f->thw > LID_SHADOW * 16 ? f->thw : LID_SHADOW * 16;
    const int reach_b = f->bhw > LID_SHADOW * 16 ? f->bhw : LID_SHADOW * 16;
    const int deep_t = f->thw > LID_DEPTH * 16 ? f->thw : LID_DEPTH * 16;
    const int deep_b = f->bhw > LID_DEPTH * 16 ? f->bhw : LID_DEPTH * 16;
    for (int x = r.x0; x < r.x1; x++) {
        int y0 = r.y0 > ap_x0[x] ? r.y0 : ap_x0[x], y1 = r.y1 < ap_x1[x] ? r.y1 : ap_x1[x];
        if (y0 >= y1) continue; /* the aperture is symmetric: column spans equal row spans */
        const int et = f->top4[x], eb = f->bot4[x];
        const int t_end = rows_before(et + reach_t);             /* top lid reaches rows < t_end */
        const int b_start = EYE_W - rows_before(EYE_W * 16 - eb + reach_b); /* bottom lid: rows >= b_start */
        if (t_end <= y0 && b_start >= y1) continue;
        const int t_deep = rows_before(et - deep_t + 1), t_body = rows_before(et - f->thw + 1);
        const int b_deep = EYE_W - rows_before(EYE_W * 16 - eb - deep_b + 1);
        const int b_body = EYE_W - rows_before(EYE_W * 16 - eb - f->bhw + 1);
        uint16_t *col = out + (x - r.x0);
#define LID_ROW(y) (col + ((y) - r.y0) * w)
        int y = y0;
        const int tb = t_body < y1 ? t_body : y1;
        if (y < tb) { lid_rows(LID_ROW(y), w, x, y, tb, et, -1, y, t_deep < tb ? t_deep : tb); y = tb; }
        const int bb = b_body > y ? (b_body < y1 ? b_body : y1) : y; /* the bottom body starts at bb */
        const int te = t_end < bb ? t_end : bb;            /* top ramps and shadows: rows [y, te) */
        int bs = b_start > y ? b_start : y;                /* bottom ramps and shadows: rows [bs, bb) */
        if (bs < te) bs = te;
        /* Where the two lids' reaches overlap, the general model; else one lid. */
        const int both0 = b_start > y ? b_start : y, both1 = t_end < bb ? t_end : bb;
        if (both0 < both1) {
            if (y < both0) lid_ramp1(LID_ROW(y), w, x, y, both0, et, -1, f->thw, f->thw_inv, shade_top);
            lid_ramps(f, LID_ROW(both0), w, x, both0, both1);
            if (both1 < bb) lid_ramp1(LID_ROW(both1), w, x, both1, bb, eb, 1, f->bhw, f->bhw_inv, shade_bot);
        } else {
            if (y < te) lid_ramp1(LID_ROW(y), w, x, y, te, et, -1, f->thw, f->thw_inv, shade_top);
            if (bs < bb) lid_ramp1(LID_ROW(bs), w, x, bs, bb, eb, 1, f->bhw, f->bhw_inv, shade_bot);
        }
        y = bb;
        if (y < y1) lid_rows(LID_ROW(y), w, x, y, y1, eb, 1, b_deep > y ? (b_deep < y1 ? b_deep : y1) : y, y1);
#undef LID_ROW
    }
}

/* Byte-swapped RGB565 moved a/256 of the way to white darkened by v/255, in
 * RGB565 units with the 4x4 ordered threshold d of eye_pack. */
static inline uint16_t light565(uint16_t base, uint32_t a, uint32_t v, uint32_t d)
{
    const uint32_t w = (uint32_t)((base >> 8) | (base << 8)) & 0xffffu, ia = 256 - a;
    const uint32_t t5 = (31 * v + 128) >> 8, t6 = (63 * v + 128) >> 8;
    const uint32_t rb = ((w >> 11) << 16) | (w & 31); /* red and blue apart: room for 8-bit weights */
    const uint32_t o_rb = ((rb * ia + ((t5 << 16) | t5) * a + d * 0x00100010u) >> 8) & 0x001f001fu;
    const uint32_t o_g = (((w >> 5) & 63) * ia + t6 * a + d * 16) >> 8;
    const uint32_t o = ((o_rb >> 16) << 11) | (o_g << 5) | (o_rb & 31);
    return (uint16_t)((o >> 8) | (o << 8));
}

/* Highlights and the glint over the rendered lens on columns [a, b) of row y:
 * a blend toward white (darkened at the rim like the lens), as in eye_lens.
 * lights: bit 0 glint, 1 and 2 the highlights; the others are dark here. */
static void spot_blend(const eye_frame_t *f, uint16_t *row, int y, int a, int b, unsigned lights)
{
    const int dy = y * 16 + 8, Y = 2 * y + 1 - EYE_W, Y2 = Y * Y;
    const int dx0 = a * 16 + 8 - f->ix4, ddy = dy - f->iy4;
    int gn = dx0 * GLINT_NX + ddy * GLINT_NY;                    /* glint offset, Q12 */
    int ex = f->m00 * dx0 + f->m01 * ddy, ey = f->m10 * dx0 + f->m11 * ddy;
    const int h1y = ddy - f->h1y, h2y = ddy - f->h2y, h1y2 = h1y * h1y, h2y2 = h2y * h2y;
    int h1x = dx0 - f->h1x, h2x = dx0 - f->h2x;
    for (int x = a; x < b; x++, gn += 16 * GLINT_NX, ex += 16 * f->m00, ey += 16 * f->m10, h1x += 16, h2x += 16) {
        uint32_t al0 = 0, al1 = 0, al2 = 0;
        if (lights & 1) {
            const int g = (gn >> 12) - f->gl_p4, ag = g < 0 ? -g : g;
            if (ag < GLINT_W4) al0 = glint_lut[ag >> 2];
        }
        if (lights & 2) { const int d2 = (h1x * h1x + h1y2) >> 6; if (d2 < (int)sizeof hl1_lut) al1 = hl1_lut[d2]; }
        if (lights & 4) { const int d2 = (h2x * h2x + h2y2) >> 6; if (d2 < (int)sizeof hl2_lut) al2 = hl2_lut[d2]; }
        if (!(al0 | al1 | al2)) continue;
        const int ea = ex >> 12, eb = ey >> 12;
        if (ea * ea + eb * eb >= IRIS_R2Q) continue; /* light only the lens */
        EYE_STAT(slow);
        /* One blend toward white for all the lights: 1 - (1-a0)(1-a1)(1-a2). */
        const uint32_t keep = (((256 - al0) * (256 - al1)) >> 8) * (256 - al2) >> 8;
        const int X = 2 * x + 1 - EYE_W, s4 = X * X + Y2;
        row[x] = light565(row[x], 256 - keep, s4 > VIG_S4 ? vig_lut[s4 >> 4] : 255, eye_bayer[y & 3][x & 3]);
    }
}

/* Render one rectangle row-major into out (width*height pixels): first the
 * eye without lids, row by row, skipping columns a lid covers; then its
 * highlights; then the lids, column by column. */
static void eye_render_rect(const eye_frame_t *f, eye_rect_t r, uint16_t *out)
{
    eye_patch_pupil(f);
    const int w = r.x1 - r.x0;
    uint16_t *rowp = out;
    for (int y = r.y0; y < r.y1; y++, rowp += w) {
        memcpy(rowp, &bg565[y * EYE_W + r.x0], (size_t)w * 2); /* untouched background */
        EYE_STAT(rows);
        const int lo = r.x0 > ap_x0[y] ? r.x0 : ap_x0[y], hi = r.x1 < ap_x1[y] ? r.x1 : ap_x1[y];
        if (lo >= hi) continue;
        uint16_t *row = rowp - r.x0;
        const int y4 = y * 16 + 8, dy = y4 - f->iy4;
        if (dy <= -f->ext4 || dy >= f->ext4) continue;
        if (f->top_min4 - y4 >= f->thw || y4 - f->bot_max4 >= f->bhw) continue; /* a lid body covers the row */
        const int half = (int)sqrtf((float)(f->ext4 * f->ext4 - dy * dy)) + 16;
        int i0 = (f->ix4 - half) >> 4, i1 = ((f->ix4 + half) >> 4) + 1; /* lens columns */
        if (i0 < lo) i0 = lo;
        if (i1 > hi) i1 = hi;
        /* Columns a lid body covers need no lens. */
        while (i0 < i1 && (f->top4[i0] - y4 >= f->thw || y4 - f->bot4[i0] >= f->bhw)) i0++;
        while (i1 > i0 && (f->top4[i1 - 1] - y4 >= f->thw || y4 - f->bot4[i1 - 1] >= f->bhw)) i1--;
        if (i0 >= i1) continue;
        const int v0 = vig_x0[y], v1 = vig_x1[y];
        for (int x = i0; x < i1;) { /* split at the rim-darkening boundaries */
            int end = i1;
            if (v0 > x && v0 < end) end = v0;
            if (v1 > x && v1 < end) end = v1;
            iris_span(f, row, y, x, end, x < v0 || x >= v1);
            x = end;
        }
        /* Highlights and the glint, on their own columns. */
        int16_t sp[3][3]; /* start, end, light bit */
        int ns = 0;
        if (spot_span(y4, f->ix4 + f->h1x, f->iy4 + f->h1y, 11 * 16, sp[ns])) sp[ns++][2] = 2;
        if (spot_span(y4, f->ix4 + f->h2x, f->iy4 + f->h2y, 5 * 16, sp[ns])) sp[ns++][2] = 4;
        if (f->gl_on) { /* columns where |u - p| < w, u = (dx, dy) . n */
            const int c0 = ((f->gl_p4 - GLINT_W4) * 4096 - dy * GLINT_NY) / GLINT_NX;
            const int c1 = ((f->gl_p4 + GLINT_W4) * 4096 - dy * GLINT_NY) / GLINT_NX;
            sp[ns][0] = (int16_t)((f->ix4 + c0 - 8) / 16 - 1);
            sp[ns][1] = (int16_t)((f->ix4 + c1 - 8) / 16 + 2);
            sp[ns++][2] = 1;
        }
        /* Overlapping spans merge: the blend weighs each of their lights per
         * pixel (zero outside it), so each pixel is lit once. */
        for (int k = 1; k < ns; k++) /* sort by start */
            for (int j = k; j > 0 && sp[j][0] < sp[j - 1][0]; j--)
                for (int c = 0; c < 3; c++) { const int16_t t = sp[j][c]; sp[j][c] = sp[j - 1][c]; sp[j - 1][c] = t; }
        for (int k = 0; k < ns;) {
            int a = sp[k][0], b = sp[k][1];
            unsigned lights = (unsigned)sp[k][2];
            for (k++; k < ns && sp[k][0] <= b; k++) { if (sp[k][1] > b) b = sp[k][1]; lights |= (unsigned)sp[k][2]; }
            if (a < i0) a = i0;
            if (b > i1) b = i1;
            if (a < b) spot_blend(f, row, y, a, b, lights);
        }
    }
    lid_columns(f, r, out);
}

/* ---- dirty rectangles ----------------------------------------------------- */
static int rect_area(eye_rect_t r) { return (r.x1 - r.x0) * (r.y1 - r.y0); }
static eye_rect_t rect_union(eye_rect_t a, eye_rect_t b)
{
    eye_rect_t u = a;
    if (b.x0 < u.x0) u.x0 = b.x0;
    if (b.y0 < u.y0) u.y0 = b.y0;
    if (b.x1 > u.x1) u.x1 = b.x1;
    if (b.y1 > u.y1) u.y1 = b.y1;
    return u;
}
static eye_rect_t rect_clip(int x0, int y0, int x1, int y1)
{
    eye_rect_t r = {(int16_t)(x0 < 0 ? 0 : x0), (int16_t)(y0 < 0 ? 0 : y0),
                    (int16_t)(x1 > EYE_W ? EYE_W : x1), (int16_t)(y1 > EYE_W ? EYE_W : y1)};
    if (r.x1 < r.x0) r.x1 = r.x0;
    if (r.y1 < r.y0) r.y1 = r.y0;
    return r;
}
/* Everything but the pupil that decides the lens's pixels is unchanged. */
static int lens_same(const eye_frame_t *a, const eye_frame_t *b)
{
    return a->ix4 == b->ix4 && a->iy4 == b->iy4 && a->m00 == b->m00 && a->m01 == b->m01 &&
           a->m10 == b->m10 && a->m11 == b->m11 && a->ext4 == b->ext4 && a->spin16 == b->spin16 &&
           a->gl_on == b->gl_on && (!a->gl_on || a->gl_p4 == b->gl_p4) && a->h1x == b->h1x &&
           a->h1y == b->h1y && a->h2x == b->h2x && a->h2y == b->h2y;
}
/* The screen box around the pupil and its rim (the lens's scale bounds it). */
static eye_rect_t pupil_box(const eye_frame_t *f)
{
    const int r4 = (int)(sqrtf((float)f->rp_hi) * f->ext4 / (IRIS_RH * 16)) + 32;
    return rect_clip((f->ix4 - r4) / 16 - 1, (f->iy4 - r4) / 16 - 1, (f->ix4 + r4) / 16 + 2, (f->iy4 + r4) / 16 + 2);
}
static eye_rect_t iris_box(const eye_frame_t *f)
{
    return rect_clip((f->ix4 - f->ext4) / 16 - 1, (f->iy4 - f->ext4) / 16 - 1,
                     (f->ix4 + f->ext4) / 16 + 2, (f->iy4 + f->ext4) / 16 + 2);
}
/* Rows [y0, y1) of columns [x0, x1), narrowed to the rows the round aperture
 * shows in those columns. */
static eye_rect_t aperture_slice(int x0, int x1, int y0, int y1)
{
    const int near = x0 >= 120 ? x0 : x1 <= 120 ? x1 - 1 : 120; /* column nearest the centre */
    const int X = 2 * near + 1 - EYE_W;
    const int half = (int)sqrtf((float)(57600 - X * X)) / 2 + 2;
    if (y0 < 120 - half) y0 = 120 - half;
    if (y1 > 120 + half) y1 = 120 + half;
    return rect_clip(x0, y0, x1, y1);
}
/* Where one lid's pixels changed, per slice of columns: between the old and new
 * edge, widened by the lid's shading depth on one side and by its shadow and
 * edge ramp on the other (the wider of the two frames' ramps). */
static int lid_bands(const int16_t *a, const int16_t *b, int hw, int shadow_below, int all, eye_rect_t *out)
{
    const int ramp = hw > LID_SHADOW * 16 ? hw : LID_SHADOW * 16;
    const int eye_side = ramp + 16, lid_side = (hw > LID_DEPTH * 16 ? hw : LID_DEPTH * 16) + 16;
    int n = 0;
    for (int x0 = 0; x0 < EYE_W; x0 += LID_SLICE) {
        int lo = 1 << 30, hi = -(1 << 30);
        for (int x = x0; x < x0 + LID_SLICE; x++) {
            if (!all && a[x] == b[x]) continue;
            if (a[x] < lo) lo = a[x];
            if (b[x] < lo) lo = b[x];
            if (a[x] > hi) hi = a[x];
            if (b[x] > hi) hi = b[x];
        }
        if (hi < lo) continue;
        lo -= shadow_below ? lid_side : eye_side;
        hi += shadow_below ? eye_side : lid_side;
        const eye_rect_t r = aperture_slice(x0, x0 + LID_SLICE, (lo >> 4) - 1, (hi >> 4) + 2);
        if (rect_area(r)) out[n++] = r;
    }
    return n;
}
static int eye_split(const eye_rect_t *in, int n, eye_rect_t *out)
{
    int m = 0;
    for (int i = 0; i < n; i++) {
        const eye_rect_t r = in[i];
        const int w = r.x1 - r.x0;
        if (w <= 0 || r.y1 <= r.y0) continue;
        const int rows = MAX_RECT_PX / w;
        for (int y = r.y0; y < r.y1; y += rows) {
            if (m == MAX_RECTS) return -1;
            out[m++] = (eye_rect_t){r.x0, (int16_t)y, r.x1, (int16_t)(y + rows < r.y1 ? y + rows : r.y1)};
        }
    }
    return m;
}
static int eye_full(eye_rect_t *out)
{
    const eye_rect_t all = {0, 0, EYE_W, EYE_W};
    return eye_split(&all, 1, out);
}
/* Every changed pixel lies in the returned rectangles. */
static int eye_dirty(const eye_frame_t *prev, const eye_frame_t *next, eye_rect_t *out)
{
    eye_rect_t r[2 + 2 * (EYE_W / LID_SLICE)];
    int n = 0;
    if (!lens_same(prev, next)) {
        const eye_rect_t a = iris_box(prev), b = iris_box(next);
        const eye_rect_t u = rect_union(a, b);
        if (rect_area(u) <= rect_area(a) + rect_area(b)) r[n++] = u;
        else { r[n++] = a; r[n++] = b; }
    } else if (prev->rp_hi != next->rp_hi || prev->rp_in != next->rp_in || prev->rp2 != next->rp2) {
        r[n++] = rect_union(pupil_box(prev), pupil_box(next)); /* a still lens: only the pupil breathes */
    }
    /* A changed edge ramp alone (lids at rest, motion blur fading) changes the
     * same rows: compare the ramp widths too. */
    const int top_hw = prev->thw > next->thw ? prev->thw : next->thw;
    const int bot_hw = prev->bhw > next->bhw ? prev->bhw : next->bhw;
    n += lid_bands(prev->top4, next->top4, top_hw, 1, prev->thw != next->thw, &r[n]);
    n += lid_bands(prev->bot4, next->bot4, bot_hw, 0, prev->bhw != next->bhw, &r[n]);
    for (int merged = 1; merged;) { /* merge pairs whose union costs no more pixels */
        merged = 0;
        for (int i = 0; i < n && !merged; i++)
            for (int j = i + 1; j < n && !merged; j++) {
                const eye_rect_t m = rect_union(r[i], r[j]);
                const int sum = rect_area(r[i]) + rect_area(r[j]), um = rect_area(m);
                if (um <= sum || (um <= MAX_RECT_PX && um <= sum + RECT_COST_PX)) {
                    r[i] = m; r[j] = r[--n]; merged = 1;
                }
            }
    }
    const int m = eye_split(r, n, out);
    return m < 0 ? eye_full(out) : m; /* never drop a changed pixel */
}

/* ---- behaviour ------------------------------------------------------------ */
typedef struct {
    uint32_t rng;
    uint32_t frame;
    float px, py, vx, vy, tx, ty, fx, fy, drx, dry, omega, zeta;
    int mode, mode_left, micro_left;     /* mode 0 fixate, 1 orbit */
    float orbit_a, orbit_w, orbit_r;
    int blink_t, blink_next, blink_again, repeat_in;
    int expr, expr_left;
    float tilt_sign;
    float top_y, top_k, top_tilt, bot_y, bot_k, pupil_f, scale;
    float v_top_y, v_top_k, v_tilt, v_bot_y, v_bot_k, v_pupil, v_scale; /* spring velocities */
    float last_top, last_bot;            /* last frame's lid centres, for motion blur */
    float spin, focus, focus_v;          /* ring turns; pupil refocus pulse */
    int darting, glint_t, glint_next, scan_t, scan_next;
} eye_brain_t;

static uint32_t eye_rand(eye_brain_t *b)
{
    b->rng ^= b->rng << 13; b->rng ^= b->rng >> 17; b->rng ^= b->rng << 5;
    return b->rng * 2654435761u; /* scramble: consecutive xorshift outputs correlate */
}
static float eye_frand(eye_brain_t *b) { return (eye_rand(b) >> 8) * (1.0f / 16777216.0f); }
static float eye_range(eye_brain_t *b, float lo, float hi) { return lo + (hi - lo) * eye_frand(b); }
static int eye_frames(eye_brain_t *b, float lo_s, float hi_s) { return (int)(eye_range(b, lo_s, hi_s) * EYE_FPS); }

enum { EXPR_NEUTRAL, EXPR_SQUINT, EXPR_CURIOUS, EXPR_SLEEPY, EXPR_HAPPY, EXPR_SURPRISED };
static const float expr_table[6][8] = { /* top_y, top_k, tilt, bot_y, bot_k, pupil, size, spring rad/s */
    {-4, 0.0042f, 0, 246, 0.0034f, 1.00f, 1.00f, 9}, /* lids just out of sight */
    {60, 0.0030f, 0, 186, 0.0052f, 0.84f, 1.00f, 9},
    {10, 0.0040f, 0.20f, 228, 0.0030f, 1.26f, 1.03f, 9},
    {80, 0.0016f, 0, 236, 0.0034f, 0.94f, 0.98f, 6.5f},
    {-8, 0.0042f, 0, 142, -0.0042f, 1.14f, 1.00f, 10.5f}, /* lower lid arches up: a smile */
    {-14, 0.0042f, 0, 252, 0.0034f, 0.60f, 1.10f, 24}, /* wide open, pupil small */
};

/* Critically damped approach (smooth start and stop) that comes to rest
 * exactly, so settled lids stop costing redraws. */
static void eye_approach(float *v, float *vel, float target, float w, float eps)
{
    const float dt = 1.0f / EYE_FPS;
    *vel += (w * w * (target - *v) - 2 * w * *vel) * dt;
    *v += *vel * dt;
    if (fabsf(target - *v) < eps && fabsf(*vel) < eps * w) { *v = target; *vel = 0; }
}

static void eye_brain_init(eye_brain_t *b, uint32_t seed)
{
    memset(b, 0, sizeof *b);
    b->rng = seed ? seed : 1;
    b->omega = 30; b->zeta = 0.65f;
    b->mode_left = eye_frames(b, 0.8f, 1.4f);
    b->micro_left = eye_frames(b, 0.3f, 0.8f);
    b->blink_t = -1;
    b->blink_next = eye_frames(b, 1.2f, 2.2f);
    b->expr_left = eye_frames(b, 6, 9);
    b->tilt_sign = 1;
    const float *e = expr_table[EXPR_NEUTRAL];
    b->top_y = e[0]; b->top_k = e[1]; b->top_tilt = e[2]; b->bot_y = e[3]; b->bot_k = e[4]; b->pupil_f = e[5];
    b->scale = e[6];
    b->last_top = e[0]; b->last_bot = e[3];
    b->glint_t = -1;
    b->glint_next = eye_frames(b, 3, 5);
    b->scan_t = -1;
    b->scan_next = eye_frames(b, 1.5f, 3);
}

static void eye_pick_target(eye_brain_t *b)
{
    const float ox = b->fx, oy = b->fy;
    for (int tries = 0; tries < 3; tries++) {
        const float kind = eye_frand(b), a = eye_range(b, 0, 6.2831853f);
        float r;
        if (kind < 0.22f) r = eye_range(b, 0, 8);
        else if (kind < 0.55f) r = GAZE_MAX * eye_range(b, 0.78f, 1.0f);
        else r = GAZE_MAX * sqrtf(eye_frand(b));
        b->fx = r * cosf(a); b->fy = r * sinf(a);
        if (b->expr == EXPR_CURIOUS) { b->fx = 0.6f * b->fx + 26 * b->tilt_sign; b->fy = 0.6f * b->fy - 10; }
        if (b->expr == EXPR_HAPPY) { b->fx *= 0.6f; b->fy = 0.25f * b->fy - 8; } /* the smile cuts the iris */
        if ((b->fx - ox) * (b->fx - ox) + (b->fy - oy) * (b->fy - oy) > 14 * 14) break;
    }
}

static void eye_start_blink(eye_brain_t *b, int may_repeat)
{
    if (b->blink_t >= 0) return;
    b->blink_t = 0;
    b->blink_again = may_repeat && eye_frand(b) < 0.16f; /* a double blink, never more */
    b->blink_next = eye_frames(b, 3.0f, 7.0f);
}

#define BLINK_CLOSE 7  /* frames: an eased close, a short hold, a slower open */
#define BLINK_HOLD 2
#define BLINK_OPEN 10
#define BLINK_LEN (BLINK_CLOSE + BLINK_HOLD + BLINK_OPEN)
static float eye_blink_closure(int t)
{
    if (t < 0) return 0;
    if (t < BLINK_CLOSE) { const float u = (t + 1) / (float)BLINK_CLOSE; return u * u * (3 - 2 * u); }
    if (t < BLINK_CLOSE + BLINK_HOLD) return 1;
    if (t < BLINK_LEN) { const float u = (t - BLINK_CLOSE - BLINK_HOLD + 1) / (float)BLINK_OPEN; return 1 - u * u * (3 - 2 * u); }
    return 0;
}

static void eye_brain_step(eye_brain_t *b, eye_pose_t *pose)
{
    const float dt = 1.0f / EYE_FPS;
    b->frame++;
    /* Expressions: occasional squint, curiosity or sleepiness, eased in and out. */
    if (--b->expr_left <= 0) {
        if (b->expr != EXPR_NEUTRAL) {
            if (b->expr == EXPR_SLEEPY) eye_start_blink(b, 1);
            b->expr = EXPR_NEUTRAL;
            b->expr_left = eye_frames(b, 7, 13);
        } else {
            const float k = eye_frand(b);
            b->expr = k < 0.24f ? EXPR_CURIOUS : k < 0.52f ? EXPR_HAPPY : k < 0.72f ? EXPR_SURPRISED
                    : k < 0.87f ? EXPR_SQUINT : EXPR_SLEEPY;
            b->tilt_sign = eye_frand(b) < 0.5f ? -1.f : 1.f;
            b->expr_left = b->expr == EXPR_SLEEPY ? eye_frames(b, 3, 4.5f)
                         : b->expr == EXPR_SURPRISED ? eye_frames(b, 0.9f, 1.5f) : eye_frames(b, 1.8f, 3.6f);
            if (b->expr == EXPR_CURIOUS || b->expr == EXPR_HAPPY) b->mode_left = 1;
        }
    }
    const float *e = expr_table[b->expr];
    const float w = e[7];
    eye_approach(&b->top_y, &b->v_top_y, e[0], w, 0.01f);
    eye_approach(&b->top_k, &b->v_top_k, e[1], w, 1e-6f);
    eye_approach(&b->top_tilt, &b->v_tilt, e[2] * b->tilt_sign, w, 1e-4f);
    eye_approach(&b->bot_y, &b->v_bot_y, e[3], w, 0.01f);
    eye_approach(&b->bot_k, &b->v_bot_k, e[4], w, 1e-6f);
    eye_approach(&b->pupil_f, &b->v_pupil, e[5], w * 0.6f, 1e-3f);
    eye_approach(&b->scale, &b->v_scale, e[6], w, 1e-4f);

    /* Gaze: saccades on an underdamped spring (small overshoot and settle),
     * slow glides and orbits on a critically damped one, fixational drift and
     * micro-saccades in between. */
    if (b->mode == 1) {
        b->orbit_a += b->orbit_w * dt;
        b->tx = b->orbit_r * cosf(b->orbit_a);
        b->ty = b->orbit_r * sinf(b->orbit_a);
        if (--b->mode_left <= 0) {
            b->mode = 0;
            b->fx = b->tx * 0.2f; b->fy = b->ty * 0.2f;
            b->omega = 30; b->zeta = 0.65f;
            b->mode_left = eye_frames(b, 0.6f, 1.6f);
        }
    } else {
        if (--b->mode_left <= 0) {
            const float kind = eye_frand(b);
            if (kind < 0.10f && b->expr == EXPR_NEUTRAL) {
                b->mode = 1;
                b->orbit_r = GAZE_MAX * 0.9f;
                b->orbit_a = atan2f(b->py, b->px);
                if (b->px * b->px + b->py * b->py < 100) b->orbit_a = eye_range(b, 0, 6.2831853f);
                b->orbit_w = (eye_frand(b) < 0.5f ? -1 : 1) * eye_range(b, 2.2f, 3.2f);
                b->omega = 14; b->zeta = 1.0f;
                b->mode_left = eye_frames(b, 1.6f, 2.8f);
            } else {
                eye_pick_target(b);
                const float jump = sqrtf((b->fx - b->px) * (b->fx - b->px) + (b->fy - b->py) * (b->fy - b->py));
                if (kind < 0.24f) { b->omega = 5.5f; b->zeta = 1.0f; } /* slow glide */
                else { b->omega = 34; b->zeta = 0.62f; }                /* saccade */
                if (jump > 0.7f * GAZE_MAX && b->blink_next > 60 && eye_frand(b) < 0.2f) eye_start_blink(b, 1);
                b->mode_left = eye_frand(b) < 0.15f ? eye_frames(b, 0.18f, 0.36f)
                                                      : (int)(EYE_FPS * (0.4f + 2.2f * eye_frand(b) * eye_frand(b)));
                b->drx = b->dry = 0;
            }
        }
        if (--b->micro_left <= 0) { /* micro-saccade: a small fast hop of the fixation point */
            const float a = eye_range(b, 0, 6.2831853f), r = eye_range(b, 1.2f, 3.4f);
            if (b->omega >= 20) { /* never cut a slow glide short */
                b->fx += r * cosf(a); b->fy += r * sinf(a);
                const float f = sqrtf(b->fx * b->fx + b->fy * b->fy);
                if (f > GAZE_MAX) { b->fx *= GAZE_MAX / f; b->fy *= GAZE_MAX / f; }
            }
            b->micro_left = eye_frames(b, 0.8f, 2.0f);
        }
        b->tx = b->fx; b->ty = b->fy; /* holds still between micro-saccades */
    }
    const float ax = b->omega * b->omega * (b->tx - b->px) - 2 * b->zeta * b->omega * b->vx;
    const float ay = b->omega * b->omega * (b->ty - b->py) - 2 * b->zeta * b->omega * b->vy;
    b->vx += ax * dt; b->vy += ay * dt;
    b->px += b->vx * dt; b->py += b->vy * dt;
    const float d = sqrtf(b->px * b->px + b->py * b->py);
    if (d > GAZE_CLAMP) { b->px *= GAZE_CLAMP / d; b->py *= GAZE_CLAMP / d; }
    if (fabsf(b->tx - b->px) < 0.05f && fabsf(b->ty - b->py) < 0.05f && fabsf(b->vx) < 1.5f && fabsf(b->vy) < 1.5f) {
        b->px = b->tx; b->py = b->ty; b->vx = b->vy = 0; /* settled: exactly still, nothing to redraw */
    }
    /* After a fast dart lands, the pupil refocuses: a quick dip and recovery. */
    const float speed = sqrtf(b->vx * b->vx + b->vy * b->vy);
    if (speed > 450) b->darting = 1;
    else if (b->darting && speed < 40) { b->darting = 0; b->focus_v -= 2.4f; }
    b->focus_v += (-196.f * b->focus - 19.6f * b->focus_v) * dt;
    b->focus += b->focus_v * dt;
    /* The segment ring whirs with every dart and now and then scans a quarter
     * turn; between those it rests, so a still lens costs no redraw. */
    b->spin += speed * 0.0004f * dt;
    if (b->scan_t >= 0) {
        const float u0 = b->scan_t / 36.f, u1 = (b->scan_t + 1) / 36.f;
        b->spin += 0.25f * (u1 * u1 * (3 - 2 * u1) - u0 * u0 * (3 - 2 * u0));
        if (++b->scan_t >= 36) b->scan_t = -1;
    } else if (--b->scan_next <= 0) {
        b->scan_t = 0;
        b->scan_next = eye_frames(b, 2.5f, 5.5f);
    }
    b->spin -= floorf(b->spin);
    /* Now and then a glint of light sweeps across the lens. */
    if (b->glint_t >= 0) { if (++b->glint_t >= 26) b->glint_t = -1; }
    else if (--b->glint_next <= 0) { b->glint_t = 0; b->glint_next = eye_frames(b, 6, 11); }

    /* Blinks, sometimes doubled. */
    if (b->blink_t < 0 && b->repeat_in > 0) {
        if (--b->repeat_in == 0) eye_start_blink(b, 0); /* the second of a double blink */
    } else if (b->blink_t < 0 && --b->blink_next <= 0) {
        eye_start_blink(b, 1);
    }
    float closure = 0;
    if (b->blink_t >= 0) {
        closure = eye_blink_closure(b->blink_t);
        if (++b->blink_t >= BLINK_LEN) {
            b->blink_t = -1;
            if (b->blink_again) { b->blink_again = 0; b->repeat_in = 3; }
        }
    }
    const float breath = 1 + 0.055f * sinf(b->frame * (6.2831853f * 0.17f / EYE_FPS)) +
                         0.03f * sinf(b->frame * (6.2831853f * 0.41f / EYE_FPS));
    pose->gx = b->px; pose->gy = b->py;
    pose->vx = b->vx; pose->vy = b->vy;
    pose->scale = b->scale;
    pose->spin = b->spin;
    pose->glint = b->glint_t >= 0 && b->blink_t < 0 ? b->glint_t / 25.f : -1;
    pose->pupil = 0.37f * IRIS_R * b->pupil_f * breath * (1 + b->focus);
    pose->top_y = b->top_y + (CLOSE_Y - b->top_y) * closure;
    pose->top_k = b->top_k + (0.0012f - b->top_k) * closure;
    pose->top_tilt = b->top_tilt * (1 - closure);
    pose->bot_y = b->bot_y + (CLOSE_Y - 2 - b->bot_y) * closure;
    pose->bot_k = b->bot_k + (-0.0012f - b->bot_k) * closure;
    pose->bot_tilt = 0;
    /* Fast lid travel (a blink) gets a motion-blurred edge, like a camera's. */
    const float top_move = fabsf(pose->top_y - b->last_top), bot_move = fabsf(pose->bot_y - b->last_bot);
    b->last_top = pose->top_y; b->last_bot = pose->bot_y;
    pose->top_blur = top_move > 3 ? 0.2f * top_move : 0.5f;
    pose->bot_blur = bot_move > 3 ? 0.2f * bot_move : 0.5f;
}

components/gc9a01/CMakeLists.txt

# First-party build integration. The C/header are unchanged Espressif 2.0.4.
# Avoid the upstream unpinned cmake_utilities package; only its version
# definitions are needed by the driver's informational log line.
idf_component_register(SRCS "esp_lcd_gc9a01.c" INCLUDE_DIRS "include"
    REQUIRES esp_lcd PRIV_REQUIRES esp_driver_gpio)
target_compile_definitions(${COMPONENT_LIB} PRIVATE
    ESP_LCD_GC9A01_VER_MAJOR=2 ESP_LCD_GC9A01_VER_MINOR=0 ESP_LCD_GC9A01_VER_PATCH=4)

components/gc9a01/UPSTREAM.md

# Espressif GC9A01 2.0.4

The C source, public header and `license.txt` are byte-identical to the
[Espressif component archive](https://components-file.espressif.com/components/espressif/esp_lcd_gc9a01/2.0.4/espressif__esp_lcd_gc9a01-v2.0.4.zip),
SHA-256 `2220c907c900d99055c9b0e218bd3886f8e64bea65c90ee4b3a4b1d75f357c40`.
Source commit: `618b05297dae1aa3fab986d5ca7b89b328e1919a`,
`components/lcd/esp_lcd_gc9a01` in Espressif's esp-bsp repository.

Copyright Espressif Systems (Shanghai) CO LTD; Apache-2.0.
The local CMake file is first-party integration, supplying the three version
macros directly so the upstream unpinned `cmake_utilities` dependency is absent.
The driver source and its init sequence are unchanged.

components/gc9a01/license.txt

                                 Apache License
                           Version 2.0, January 2004
                        http://www.apache.org/licenses/

   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

   1. Definitions.

      "License" shall mean the terms and conditions for use, reproduction,
      and distribution as defined by Sections 1 through 9 of this document.

      "Licensor" shall mean the copyright owner or entity authorized by
      the copyright owner that is granting the License.

      "Legal Entity" shall mean the union of the acting entity and all
      other entities that control, are controlled by, or are under common
      control with that entity. For the purposes of this definition,
      "control" means (i) the power, direct or indirect, to cause the
      direction or management of such entity, whether by contract or
      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      outstanding shares, or (iii) beneficial ownership of such entity.

      "You" (or "Your") shall mean an individual or Legal Entity
      exercising permissions granted by this License.

      "Source" form shall mean the preferred form for making modifications,
      including but not limited to software source code, documentation
      source, and configuration files.

      "Object" form shall mean any form resulting from mechanical
      transformation or translation of a Source form, including but
      not limited to compiled object code, generated documentation,
      and conversions to other media types.

      "Work" shall mean the work of authorship, whether in Source or
      Object form, made available under the License, as indicated by a
      copyright notice that is included in or attached to the work
      (an example is provided in the Appendix below).

      "Derivative Works" shall mean any work, whether in Source or Object
      form, that is based on (or derived from) the Work and for which the
      editorial revisions, annotations, elaborations, or other modifications
      represent, as a whole, an original work of authorship. For the purposes
      of this License, Derivative Works shall not include works that remain
      separable from, or merely link (or bind by name) to the interfaces of,
      the Work and Derivative Works thereof.

      "Contribution" shall mean any work of authorship, including
      the original version of the Work and any modifications or additions
      to that Work or Derivative Works thereof, that is intentionally
      submitted to Licensor for inclusion in the Work by the copyright owner
      or by an individual or Legal Entity authorized to submit on behalf of
      the copyright owner. For the purposes of this definition, "submitted"
      means any form of electronic, verbal, or written communication sent
      to the Licensor or its representatives, including but not limited to
      communication on electronic mailing lists, source code control systems,
      and issue tracking systems that are managed by, or on behalf of, the
      Licensor for the purpose of discussing and improving the Work, but
      excluding communication that is conspicuously marked or otherwise
      designated in writing by the copyright owner as "Not a Contribution."

      "Contributor" shall mean Licensor and any individual or Legal Entity
      on behalf of whom a Contribution has been received by Licensor and
      subsequently incorporated within the Work.

   2. Grant of Copyright License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      copyright license to reproduce, prepare Derivative Works of,
      publicly display, publicly perform, sublicense, and distribute the
      Work and such Derivative Works in Source or Object form.

   3. Grant of Patent License. Subject to the terms and conditions of
      this License, each Contributor hereby grants to You a perpetual,
      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      (except as stated in this section) patent license to make, have made,
      use, offer to sell, sell, import, and otherwise transfer the Work,
      where such license applies only to those patent claims licensable
      by such Contributor that are necessarily infringed by their
      Contribution(s) alone or by combination of their Contribution(s)
      with the Work to which such Contribution(s) was submitted. If You
      institute patent litigation against any entity (including a
      cross-claim or counterclaim in a lawsuit) alleging that the Work
      or a Contribution incorporated within the Work constitutes direct
      or contributory patent infringement, then any patent licenses
      granted to You under this License for that Work shall terminate
      as of the date such litigation is filed.

   4. Redistribution. You may reproduce and distribute copies of the
      Work or Derivative Works thereof in any medium, with or without
      modifications, and in Source or Object form, provided that You
      meet the following conditions:

      (a) You must give any other recipients of the Work or
          Derivative Works a copy of this License; and

      (b) You must cause any modified files to carry prominent notices
          stating that You changed the files; and

      (c) You must retain, in the Source form of any Derivative Works
          that You distribute, all copyright, patent, trademark, and
          attribution notices from the Source form of the Work,
          excluding those notices that do not pertain to any part of
          the Derivative Works; and

      (d) If the Work includes a "NOTICE" text file as part of its
          distribution, then any Derivative Works that You distribute must
          include a readable copy of the attribution notices contained
          within such NOTICE file, excluding those notices that do not
          pertain to any part of the Derivative Works, in at least one
          of the following places: within a NOTICE text file distributed
          as part of the Derivative Works; within the Source form or
          documentation, if provided along with the Derivative Works; or,
          within a display generated by the Derivative Works, if and
          wherever such third-party notices normally appear. The contents
          of the NOTICE file are for informational purposes only and
          do not modify the License. You may add Your own attribution
          notices within Derivative Works that You distribute, alongside
          or as an addendum to the NOTICE text from the Work, provided
          that such additional attribution notices cannot be construed
          as modifying the License.

      You may add Your own copyright statement to Your modifications and
      may provide additional or different license terms and conditions
      for use, reproduction, or distribution of Your modifications, or
      for any such Derivative Works as a whole, provided Your use,
      reproduction, and distribution of the Work otherwise complies with
      the conditions stated in this License.

   5. Submission of Contributions. Unless You explicitly state otherwise,
      any Contribution intentionally submitted for inclusion in the Work
      by You to the Licensor shall be under the terms and conditions of
      this License, without any additional terms or conditions.
      Notwithstanding the above, nothing herein shall supersede or modify
      the terms of any separate license agreement you may have executed
      with Licensor regarding such Contributions.

   6. Trademarks. This License does not grant permission to use the trade
      names, trademarks, service marks, or product names of the Licensor,
      except as required for reasonable and customary use in describing the
      origin of the Work and reproducing the content of the NOTICE file.

   7. Disclaimer of Warranty. Unless required by applicable law or
      agreed to in writing, Licensor provides the Work (and each
      Contributor provides its Contributions) on an "AS IS" BASIS,
      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      implied, including, without limitation, any warranties or conditions
      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      PARTICULAR PURPOSE. You are solely responsible for determining the
      appropriateness of using or redistributing the Work and assume any
      risks associated with Your exercise of permissions under this License.

   8. Limitation of Liability. In no event and under no legal theory,
      whether in tort (including negligence), contract, or otherwise,
      unless required by applicable law (such as deliberate and grossly
      negligent acts) or agreed to in writing, shall any Contributor be
      liable to You for damages, including any direct, indirect, special,
      incidental, or consequential damages of any character arising as a
      result of this License or out of the use or inability to use the
      Work (including but not limited to damages for loss of goodwill,
      work stoppage, computer failure or malfunction, or any and all
      other commercial damages or losses), even if such Contributor
      has been advised of the possibility of such damages.

   9. Accepting Warranty or Additional Liability. While redistributing
      the Work or Derivative Works thereof, You may choose to offer,
      and charge a fee for, acceptance of support, warranty, indemnity,
      or other liability obligations and/or rights consistent with this
      License. However, in accepting such obligations, You may act only
      on Your own behalf and on Your sole responsibility, not on behalf
      of any other Contributor, and only if You agree to indemnify,
      defend, and hold each Contributor harmless for any liability
      incurred by, or claims asserted against, such Contributor by reason
      of your accepting any such warranty or additional liability.

   END OF TERMS AND CONDITIONS

   APPENDIX: How to apply the Apache License to your work.

      To apply the Apache License to your work, attach the following
      boilerplate notice, with the fields enclosed by brackets "[]"
      replaced with your own identifying information. (Don't include
      the brackets!)  The text should be enclosed in the appropriate
      comment syntax for the file format. We also recommend that a
      file or class name and description of purpose be included on the
      same "printed page" as the copyright notice for easier
      identification within third-party archives.

   Copyright [yyyy] [name of copyright owner]

   Licensed under the Apache License, Version 2.0 (the "License");
   you may not use this file except in compliance with the License.
   You may obtain a copy of the License at

       http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.

components/gc9a01/esp_lcd_gc9a01.c

/*
 * SPDX-FileCopyrightText: 2021-2023 Espressif Systems (Shanghai) CO LTD
 *
 * SPDX-License-Identifier: Apache-2.0
 */

#include <stdlib.h>
#include <sys/cdefs.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_lcd_panel_interface.h"
#include "esp_lcd_panel_io.h"
#include "esp_lcd_panel_vendor.h"
#include "esp_lcd_panel_ops.h"
#include "esp_lcd_panel_commands.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_check.h"

#include "esp_lcd_gc9a01.h"

static const char *TAG = "gc9a01";

static esp_err_t panel_gc9a01_del(esp_lcd_panel_t *panel);
static esp_err_t panel_gc9a01_reset(esp_lcd_panel_t *panel);
static esp_err_t panel_gc9a01_init(esp_lcd_panel_t *panel);
static esp_err_t panel_gc9a01_draw_bitmap(esp_lcd_panel_t *panel, int x_start, int y_start, int x_end, int y_end, const void *color_data);
static esp_err_t panel_gc9a01_invert_color(esp_lcd_panel_t *panel, bool invert_color_data);
static esp_err_t panel_gc9a01_mirror(esp_lcd_panel_t *panel, bool mirror_x, bool mirror_y);
static esp_err_t panel_gc9a01_swap_xy(esp_lcd_panel_t *panel, bool swap_axes);
static esp_err_t panel_gc9a01_set_gap(esp_lcd_panel_t *panel, int x_gap, int y_gap);
static esp_err_t panel_gc9a01_disp_on_off(esp_lcd_panel_t *panel, bool off);

typedef struct {
    esp_lcd_panel_t base;
    esp_lcd_panel_io_handle_t io;
    int reset_gpio_num;
    bool reset_level;
    int x_gap;
    int y_gap;
    uint8_t fb_bits_per_pixel;
    uint8_t madctl_val; // save current value of LCD_CMD_MADCTL register
    uint8_t colmod_val; // save current value of LCD_CMD_COLMOD register
    const gc9a01_lcd_init_cmd_t *init_cmds;
    uint16_t init_cmds_size;
} gc9a01_panel_t;

esp_err_t esp_lcd_new_panel_gc9a01(const esp_lcd_panel_io_handle_t io, const esp_lcd_panel_dev_config_t *panel_dev_config, esp_lcd_panel_handle_t *ret_panel)
{
    esp_err_t ret = ESP_OK;
    gc9a01_panel_t *gc9a01 = NULL;
    gpio_config_t io_conf = { 0 };

    ESP_GOTO_ON_FALSE(io && panel_dev_config && ret_panel, ESP_ERR_INVALID_ARG, err, TAG, "invalid argument");
    gc9a01 = (gc9a01_panel_t *)calloc(1, sizeof(gc9a01_panel_t));
    ESP_GOTO_ON_FALSE(gc9a01, ESP_ERR_NO_MEM, err, TAG, "no mem for gc9a01 panel");

    if (panel_dev_config->reset_gpio_num >= 0) {
        io_conf.mode = GPIO_MODE_OUTPUT;
        io_conf.pin_bit_mask = 1ULL << panel_dev_config->reset_gpio_num;
        ESP_GOTO_ON_ERROR(gpio_config(&io_conf), err, TAG, "configure GPIO for RST line failed");
    }

#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 0, 0)
    switch (panel_dev_config->color_space) {
    case ESP_LCD_COLOR_SPACE_RGB:
        gc9a01->madctl_val = 0;
        break;
    case ESP_LCD_COLOR_SPACE_BGR:
        gc9a01->madctl_val |= LCD_CMD_BGR_BIT;
        break;
    default:
        ESP_GOTO_ON_FALSE(false, ESP_ERR_NOT_SUPPORTED, err, TAG, "unsupported color space");
        break;
    }
#elif ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(6, 0, 0)
    switch (panel_dev_config->rgb_endian) {
    case LCD_RGB_ENDIAN_RGB:
        gc9a01->madctl_val = 0;
        break;
    case LCD_RGB_ENDIAN_BGR:
        gc9a01->madctl_val |= LCD_CMD_BGR_BIT;
        break;
    default:
        ESP_GOTO_ON_FALSE(false, ESP_ERR_NOT_SUPPORTED, err, TAG, "unsupported rgb endian");
        break;
    }
#else
    switch (panel_dev_config->rgb_ele_order) {
    case LCD_RGB_ELEMENT_ORDER_RGB:
        gc9a01->madctl_val = 0;
        break;
    case LCD_RGB_ELEMENT_ORDER_BGR:
        gc9a01->madctl_val |= LCD_CMD_BGR_BIT;
        break;
    default:
        ESP_GOTO_ON_FALSE(false, ESP_ERR_NOT_SUPPORTED, err, TAG, "unsupported rgb element order");
        break;
    }
#endif

    switch (panel_dev_config->bits_per_pixel) {
    case 16: // RGB565
        gc9a01->colmod_val = 0x55;
        gc9a01->fb_bits_per_pixel = 16;
        break;
    case 18: // RGB666
        gc9a01->colmod_val = 0x66;
        // each color component (R/G/B) should occupy the 6 high bits of a byte, which means 3 full bytes are required for a pixel
        gc9a01->fb_bits_per_pixel = 24;
        break;
    default:
        ESP_GOTO_ON_FALSE(false, ESP_ERR_NOT_SUPPORTED, err, TAG, "unsupported pixel width");
        break;
    }

    gc9a01->io = io;
    gc9a01->reset_gpio_num = panel_dev_config->reset_gpio_num;
    gc9a01->reset_level = panel_dev_config->flags.reset_active_high;
    if (panel_dev_config->vendor_config) {
        gc9a01->init_cmds = ((gc9a01_vendor_config_t *)panel_dev_config->vendor_config)->init_cmds;
        gc9a01->init_cmds_size = ((gc9a01_vendor_config_t *)panel_dev_config->vendor_config)->init_cmds_size;
    }
    gc9a01->base.del = panel_gc9a01_del;
    gc9a01->base.reset = panel_gc9a01_reset;
    gc9a01->base.init = panel_gc9a01_init;
    gc9a01->base.draw_bitmap = panel_gc9a01_draw_bitmap;
    gc9a01->base.invert_color = panel_gc9a01_invert_color;
    gc9a01->base.set_gap = panel_gc9a01_set_gap;
    gc9a01->base.mirror = panel_gc9a01_mirror;
    gc9a01->base.swap_xy = panel_gc9a01_swap_xy;
#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 0, 0)
    gc9a01->base.disp_off = panel_gc9a01_disp_on_off;
#else
    gc9a01->base.disp_on_off = panel_gc9a01_disp_on_off;
#endif
    *ret_panel = &(gc9a01->base);
    ESP_LOGD(TAG, "new gc9a01 panel @%p", gc9a01);

    ESP_LOGI(TAG, "LCD panel create success, version: %d.%d.%d", ESP_LCD_GC9A01_VER_MAJOR, ESP_LCD_GC9A01_VER_MINOR,
             ESP_LCD_GC9A01_VER_PATCH);

    return ESP_OK;

err:
    if (gc9a01) {
        if (panel_dev_config->reset_gpio_num >= 0) {
            gpio_reset_pin(panel_dev_config->reset_gpio_num);
        }
        free(gc9a01);
    }
    return ret;
}

static esp_err_t panel_gc9a01_del(esp_lcd_panel_t *panel)
{
    gc9a01_panel_t *gc9a01 = __containerof(panel, gc9a01_panel_t, base);

    if (gc9a01->reset_gpio_num >= 0) {
        gpio_reset_pin(gc9a01->reset_gpio_num);
    }
    ESP_LOGD(TAG, "del gc9a01 panel @%p", gc9a01);
    free(gc9a01);
    return ESP_OK;
}

static esp_err_t panel_gc9a01_reset(esp_lcd_panel_t *panel)
{
    gc9a01_panel_t *gc9a01 = __containerof(panel, gc9a01_panel_t, base);
    esp_lcd_panel_io_handle_t io = gc9a01->io;

    // perform hardware reset
    if (gc9a01->reset_gpio_num >= 0) {
        gpio_set_level(gc9a01->reset_gpio_num, gc9a01->reset_level);
        vTaskDelay(pdMS_TO_TICKS(10));
        gpio_set_level(gc9a01->reset_gpio_num, !gc9a01->reset_level);
        vTaskDelay(pdMS_TO_TICKS(10));
    } else { // perform software reset
        ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, LCD_CMD_SWRESET, NULL, 0), TAG, "send command failed");
        vTaskDelay(pdMS_TO_TICKS(20)); // spec, wait at least 5ms before sending new command
    }

    return ESP_OK;
}

static const gc9a01_lcd_init_cmd_t vendor_specific_init_default[] = {
//  {cmd, { data }, data_size, delay_ms}
    // Enable Inter Register
    {0xfe, (uint8_t []){0x00}, 0, 0},
    {0xef, (uint8_t []){0x00}, 0, 0},
    {0xeb, (uint8_t []){0x14}, 1, 0},
    {0x84, (uint8_t []){0x60}, 1, 0},
    {0x85, (uint8_t []){0xff}, 1, 0},
    {0x86, (uint8_t []){0xff}, 1, 0},
    {0x87, (uint8_t []){0xff}, 1, 0},
    {0x8e, (uint8_t []){0xff}, 1, 0},
    {0x8f, (uint8_t []){0xff}, 1, 0},
    {0x88, (uint8_t []){0x0a}, 1, 0},
    {0x89, (uint8_t []){0x23}, 1, 0},
    {0x8a, (uint8_t []){0x00}, 1, 0},
    {0x8b, (uint8_t []){0x80}, 1, 0},
    {0x8c, (uint8_t []){0x01}, 1, 0},
    {0x8d, (uint8_t []){0x03}, 1, 0},
    {0x90, (uint8_t []){0x08, 0x08, 0x08, 0x08}, 4, 0},
    {0xff, (uint8_t []){0x60, 0x01, 0x04}, 3, 0},
    {0xC3, (uint8_t []){0x13}, 1, 0},
    {0xC4, (uint8_t []){0x13}, 1, 0},
    {0xC9, (uint8_t []){0x30}, 1, 0},
    {0xbe, (uint8_t []){0x11}, 1, 0},
    {0xe1, (uint8_t []){0x10, 0x0e}, 2, 0},
    {0xdf, (uint8_t []){0x21, 0x0c, 0x02}, 3, 0},
    // Set gamma
    {0xF0, (uint8_t []){0x45, 0x09, 0x08, 0x08, 0x26, 0x2a}, 6, 0},
    {0xF1, (uint8_t []){0x43, 0x70, 0x72, 0x36, 0x37, 0x6f}, 6, 0},
    {0xF2, (uint8_t []){0x45, 0x09, 0x08, 0x08, 0x26, 0x2a}, 6, 0},
    {0xF3, (uint8_t []){0x43, 0x70, 0x72, 0x36, 0x37, 0x6f}, 6, 0},
    {0xed, (uint8_t []){0x1b, 0x0b}, 2, 0},
    {0xae, (uint8_t []){0x77}, 1, 0},
    {0xcd, (uint8_t []){0x63}, 1, 0},
    {0x70, (uint8_t []){0x07, 0x07, 0x04, 0x0e, 0x0f, 0x09, 0x07, 0x08, 0x03}, 9, 0},
    {0xE8, (uint8_t []){0x34}, 1, 0}, // 4 dot inversion
    {0x60, (uint8_t []){0x38, 0x0b, 0x6D, 0x6D, 0x39, 0xf0, 0x6D, 0x6D}, 8, 0},
    {0x61, (uint8_t []){0x38, 0xf4, 0x6D, 0x6D, 0x38, 0xf7, 0x6D, 0x6D}, 8, 0},
    {0x62, (uint8_t []){0x38, 0x0D, 0x71, 0xED, 0x70, 0x70, 0x38, 0x0F, 0x71, 0xEF, 0x70, 0x70}, 12, 0},
    {0x63, (uint8_t []){0x38, 0x11, 0x71, 0xF1, 0x70, 0x70, 0x38, 0x13, 0x71, 0xF3, 0x70, 0x70}, 12, 0},
    {0x64, (uint8_t []){0x28, 0x29, 0xF1, 0x01, 0xF1, 0x00, 0x07}, 7, 0},
    {0x66, (uint8_t []){0x3C, 0x00, 0xCD, 0x67, 0x45, 0x45, 0x10, 0x00, 0x00, 0x00}, 10, 0},
    {0x67, (uint8_t []){0x00, 0x3C, 0x00, 0x00, 0x00, 0x01, 0x54, 0x10, 0x32, 0x98}, 10, 0},
    {0x74, (uint8_t []){0x10, 0x45, 0x80, 0x00, 0x00, 0x4E, 0x00}, 7, 0},
    {0x98, (uint8_t []){0x3e, 0x07}, 2, 0},
    {0x99, (uint8_t []){0x3e, 0x07}, 2, 0},
};

static esp_err_t panel_gc9a01_init(esp_lcd_panel_t *panel)
{
    gc9a01_panel_t *gc9a01 = __containerof(panel, gc9a01_panel_t, base);
    esp_lcd_panel_io_handle_t io = gc9a01->io;

    // LCD goes into sleep mode and display will be turned off after power on reset, exit sleep mode first
    ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, LCD_CMD_SLPOUT, NULL, 0), TAG, "send command failed");
    vTaskDelay(pdMS_TO_TICKS(100));
    ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, LCD_CMD_MADCTL, (uint8_t[]) {
        gc9a01->madctl_val,
    }, 1), TAG, "send command failed");
    ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, LCD_CMD_COLMOD, (uint8_t[]) {
        gc9a01->colmod_val,
    }, 1), TAG, "send command failed");

    const gc9a01_lcd_init_cmd_t *init_cmds = NULL;
    uint16_t init_cmds_size = 0;
    if (gc9a01->init_cmds) {
        init_cmds = gc9a01->init_cmds;
        init_cmds_size = gc9a01->init_cmds_size;
    } else {
        init_cmds = vendor_specific_init_default;
        init_cmds_size = sizeof(vendor_specific_init_default) / sizeof(gc9a01_lcd_init_cmd_t);
    }

    bool is_cmd_overwritten = false;
    for (int i = 0; i < init_cmds_size; i++) {
        // Check if the command has been used or conflicts with the internal
        switch (init_cmds[i].cmd) {
        case LCD_CMD_MADCTL:
            is_cmd_overwritten = true;
            gc9a01->madctl_val = ((uint8_t *)init_cmds[i].data)[0];
            break;
        case LCD_CMD_COLMOD:
            is_cmd_overwritten = true;
            gc9a01->colmod_val = ((uint8_t *)init_cmds[i].data)[0];
            break;
        default:
            is_cmd_overwritten = false;
            break;
        }

        if (is_cmd_overwritten) {
            ESP_LOGW(TAG, "The %02Xh command has been used and will be overwritten by external initialization sequence", init_cmds[i].cmd);
        }

        ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, init_cmds[i].cmd, init_cmds[i].data, init_cmds[i].data_bytes), TAG, "send command failed");
        vTaskDelay(pdMS_TO_TICKS(init_cmds[i].delay_ms));
    }
    ESP_LOGD(TAG, "send init commands success");

    return ESP_OK;
}

static esp_err_t panel_gc9a01_draw_bitmap(esp_lcd_panel_t *panel, int x_start, int y_start, int x_end, int y_end, const void *color_data)
{
    gc9a01_panel_t *gc9a01 = __containerof(panel, gc9a01_panel_t, base);
    assert((x_start < x_end) && (y_start < y_end) && "start position must be smaller than end position");
    esp_lcd_panel_io_handle_t io = gc9a01->io;

    x_start += gc9a01->x_gap;
    x_end += gc9a01->x_gap;
    y_start += gc9a01->y_gap;
    y_end += gc9a01->y_gap;

    // define an area of frame memory where MCU can access
    ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, LCD_CMD_CASET, (uint8_t[]) {
        (x_start >> 8) & 0xFF,
        x_start & 0xFF,
        ((x_end - 1) >> 8) & 0xFF,
        (x_end - 1) & 0xFF,
    }, 4), TAG, "send command failed");
    ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, LCD_CMD_RASET, (uint8_t[]) {
        (y_start >> 8) & 0xFF,
        y_start & 0xFF,
        ((y_end - 1) >> 8) & 0xFF,
        (y_end - 1) & 0xFF,
    }, 4), TAG, "send command failed");
    // transfer frame buffer
    size_t len = (x_end - x_start) * (y_end - y_start) * gc9a01->fb_bits_per_pixel / 8;
    ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_color(io, LCD_CMD_RAMWR, color_data, len), TAG, "send color failed");

    return ESP_OK;
}

static esp_err_t panel_gc9a01_invert_color(esp_lcd_panel_t *panel, bool invert_color_data)
{
    gc9a01_panel_t *gc9a01 = __containerof(panel, gc9a01_panel_t, base);
    esp_lcd_panel_io_handle_t io = gc9a01->io;
    int command = 0;
    if (invert_color_data) {
        command = LCD_CMD_INVON;
    } else {
        command = LCD_CMD_INVOFF;
    }
    ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, command, NULL, 0), TAG, "send command failed");
    return ESP_OK;
}

static esp_err_t panel_gc9a01_mirror(esp_lcd_panel_t *panel, bool mirror_x, bool mirror_y)
{
    gc9a01_panel_t *gc9a01 = __containerof(panel, gc9a01_panel_t, base);
    esp_lcd_panel_io_handle_t io = gc9a01->io;
    if (mirror_x) {
        gc9a01->madctl_val |= LCD_CMD_MX_BIT;
    } else {
        gc9a01->madctl_val &= ~LCD_CMD_MX_BIT;
    }
    if (mirror_y) {
        gc9a01->madctl_val |= LCD_CMD_MY_BIT;
    } else {
        gc9a01->madctl_val &= ~LCD_CMD_MY_BIT;
    }
    ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, LCD_CMD_MADCTL, (uint8_t[]) {
        gc9a01->madctl_val
    }, 1), TAG, "send command failed");
    return ESP_OK;
}

static esp_err_t panel_gc9a01_swap_xy(esp_lcd_panel_t *panel, bool swap_axes)
{
    gc9a01_panel_t *gc9a01 = __containerof(panel, gc9a01_panel_t, base);
    esp_lcd_panel_io_handle_t io = gc9a01->io;
    if (swap_axes) {
        gc9a01->madctl_val |= LCD_CMD_MV_BIT;
    } else {
        gc9a01->madctl_val &= ~LCD_CMD_MV_BIT;
    }
    ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, LCD_CMD_MADCTL, (uint8_t[]) {
        gc9a01->madctl_val
    }, 1), TAG, "send command failed");
    return ESP_OK;
}

static esp_err_t panel_gc9a01_set_gap(esp_lcd_panel_t *panel, int x_gap, int y_gap)
{
    gc9a01_panel_t *gc9a01 = __containerof(panel, gc9a01_panel_t, base);
    gc9a01->x_gap = x_gap;
    gc9a01->y_gap = y_gap;
    return ESP_OK;
}

static esp_err_t panel_gc9a01_disp_on_off(esp_lcd_panel_t *panel, bool on_off)
{
    gc9a01_panel_t *gc9a01 = __containerof(panel, gc9a01_panel_t, base);
    esp_lcd_panel_io_handle_t io = gc9a01->io;
    int command = 0;

#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 0, 0)
    on_off = !on_off;
#endif

    if (on_off) {
        command = LCD_CMD_DISPON;
    } else {
        command = LCD_CMD_DISPOFF;
    }
    ESP_RETURN_ON_ERROR(esp_lcd_panel_io_tx_param(io, command, NULL, 0), TAG, "send command failed");
    return ESP_OK;
}

components/gc9a01/include/esp_lcd_gc9a01.h

/*
 * SPDX-FileCopyrightText: 2021-2025 Espressif Systems (Shanghai) CO LTD
 *
 * SPDX-License-Identifier: Apache-2.0
 */
/**
 * @file
 * @brief ESP LCD: GC9A01
 */

#pragma once

#include "esp_lcd_panel_vendor.h"

#ifdef __cplusplus
extern "C" {
#endif

/**
 * @brief LCD panel initialization commands.
 *
 */
typedef struct {
    int cmd;                /*<! The specific LCD command */
    const void *data;       /*<! Buffer that holds the command specific data */
    size_t data_bytes;      /*<! Size of `data` in memory, in bytes */
    unsigned int delay_ms;  /*<! Delay in milliseconds after this command */
} gc9a01_lcd_init_cmd_t;

/**
 * @brief LCD panel vendor configuration.
 *
 * @note  This structure needs to be passed to the `vendor_config` field in `esp_lcd_panel_dev_config_t`.
 *
 */
typedef struct {
    const gc9a01_lcd_init_cmd_t *init_cmds;     /*!< Pointer to initialization commands array. Set to NULL if using default commands.
                                                 *   The array should be declared as `static const` and positioned outside the function.
                                                 *   Please refer to `vendor_specific_init_default` in source file.
                                                 */
    uint16_t init_cmds_size;                    /*<! Number of commands in above array */
} gc9a01_vendor_config_t;

/**
 * @brief Create LCD panel for model GC9A01
 *
 * @note  Vendor specific initialization can be different between manufacturers, should consult the LCD supplier for initialization sequence code.
 *
 * @param[in] io LCD panel IO handle
 * @param[in] panel_dev_config general panel device configuration
 * @param[out] ret_panel Returned LCD panel handle
 * @return
 *          - ESP_ERR_INVALID_ARG   if parameter is invalid
 *          - ESP_ERR_NO_MEM        if out of memory
 *          - ESP_OK                on success
 */
esp_err_t esp_lcd_new_panel_gc9a01(const esp_lcd_panel_io_handle_t io, const esp_lcd_panel_dev_config_t *panel_dev_config, esp_lcd_panel_handle_t *ret_panel);

/**
 * @brief LCD panel bus configuration structure
 *
 * @param[in] sclk SPI clock pin number
 * @param[in] mosi SPI MOSI pin number
 * @param[in] max_trans_sz Maximum transfer size in bytes
 *
 */
#define GC9A01_PANEL_BUS_SPI_CONFIG(sclk, mosi, max_trans_sz)   \
    {                                                           \
        .mosi_io_num = mosi,                                    \
        .miso_io_num = -1,                                      \
        .sclk_io_num = sclk,                                    \
        .quadwp_io_num = -1,                                    \
        .quadhd_io_num = -1,                                    \
        .data4_io_num = -1,                                     \
        .data5_io_num = -1,                                     \
        .data6_io_num = -1,                                     \
        .data7_io_num = -1,                                     \
        .max_transfer_sz = max_trans_sz,                        \
    }


/**
 * @brief LCD panel IO configuration structure
 *
 * @param[in] cs SPI chip select pin number
 * @param[in] dc SPI data/command pin number
 * @param[in] cb Callback function when SPI transfer is done
 * @param[in] cb_ctx Callback function context
 *
 */
#define GC9A01_PANEL_IO_SPI_CONFIG(cs, dc, callback, callback_ctx)  \
    {                                                               \
        .cs_gpio_num = cs,                                          \
        .dc_gpio_num = dc,                                          \
        .spi_mode = 0,                                              \
        .pclk_hz = 80 * 1000 * 1000,                                \
        .trans_queue_depth = 10,                                    \
        .on_color_trans_done = callback,                            \
        .user_ctx = callback_ctx,                                   \
        .lcd_cmd_bits = 8,                                          \
        .lcd_param_bits = 8,                                        \
        .flags = {}                                                 \
    }

#ifdef __cplusplus
}
#endif

Related projects