diff --git a/docs/lilygo-t5s3-support.md b/docs/lilygo-t5s3-support.md index 54778f0d..3023e67d 100644 --- a/docs/lilygo-t5s3-support.md +++ b/docs/lilygo-t5s3-support.md @@ -22,6 +22,20 @@ PSRAM: - The 16-gray path (`displayGray` + `copyGrayscaleLsb/Msb` + `writeGrayscalePlaneStrip`) combines the B/W base with the LSB/MSB planes into four gray levels in the canvas, then `pushSprite`s at the quality waveform. +- `displayGrayscaleFrame()` sends a page and its anti-aliasing greys as **one** + waveform, on boards whose fast bank carries grey columns + (`LgfxEpdConfig::grayNudgeInFastBank`). The two-push flow it replaces existed + to normalize fringe pixels to black before a from-black grey nudge; grey + columns are self-normalizing, so the page arrives finished rather than showing + an intermediate state. + +This board supplies its own ED047TC2 waveform (`BoardT5S3/ED047TC2Waveform`, +generated by `tools/gen_ed047tc2_waveform.py` and selected against the panel +temperature) rather than running on LovyanGFX's stock LUTs, whose fast bank +drives only the two rails — there the anti-aliasing greys get Bayer-dithered to +black. The canvas byte to write for each grey is a property of that LUT, so it +comes from the board via `LgfxEpdConfig::grayDark`/`grayLight`; use +`grayLevelByte()` to build one that quantises cleanly for every Bayer cell. `BoardConfig::LILYGO_T5S3` carries the geometry, `DisplayController::LgfxEpd`, the GT911 touch config, the PWM backlight, and the I²C battery gauge. @@ -59,7 +73,23 @@ reference port at **`lib/Board_T5S3/FreeInkLgfxConfig.cpp`**. - **Touch** — GT911, handled by `InputManager` (polled, reset/address dance). The profile uses `BoardConfig::LILYGO_T5_PRO_GT911`. - **Backlight** — PWM `FrontlightConfig` on BL_EN (GPIO11), driven by - `FrontlightManager`; `CAP_FRONTLIGHT` is on for this device. + `FrontlightManager`; `CAP_FRONTLIGHT` is on for this device. The PWM gates the + EN pin of a PT4103 boost converter rather than driving the LEDs, so the + profile sets `minHoldPermille`/`minStartPermille`: a boost emits no light + below its start-up window, and it sustains below the duty it can ignite from. + `prepareForDeepSleep()` drives and latches the pin off on the way into sleep — + a zero duty alone leaves the pad to `esp_sleep_config_gpio_isolate()`, which + floats it. +- **RTC** — PCF8563 at 0x51 on the shared SDA39/SCL40 bus. The vendor schematic + shows a PCF8563TS; the product table's "PCF85063" is a misnomer. +- **Home key** — the board has a capacitive key below the panel, absent from the + vendor wiki's button list and found by tracing the GT911 status bit (`0x10`). + The profile sets `touch.hasHomeKey`, without which `wasHomeGesture()` and + `wasHomeKeyHold()` discard every press. +- **No power latch** — ESP32 IO2 is RTC_INT (the PCF8563's open-drain alarm, + pulled up through 10K), not a soft power latch; power sequencing is the + BQ25896's job. A stale `power.latch0 = GPIO2` entry had `holdPowerRails()` + fighting the RTC's output. - **Battery** — `BatteryMonitor`'s I²C fuel-gauge backend (`FREEINK_BATTERY_I2C_GAUGE`) reads SoC/voltage from the BQ27220 and charge status from the BQ25896, with addresses/pins from `BoardProfile.batteryGauge`. It @@ -69,7 +99,11 @@ reference port at **`lib/Board_T5S3/FreeInkLgfxConfig.cpp`**. direct RTC-capable GPIO); the profile sets `input.power = GPIO0`. `InputManager` reads it, and `PowerManager::armPowerButtonWakeup()` arms deep-sleep wake on it with the per-SoC source (`ext1` on the S3). This matches the reference port, - which wakes on the same BOOT button. + which wakes on the same BOOT button. Nothing gates the GT911's power here, so + the profile opts into `TouchConfig::holdResetInSleep` and deep sleep parks the + digitizer in reset on T_RST (GPIO9) instead — a GT911 left scanning costs + several mA, which on its own separates a milliamp-class sleep from a + microamp-class one. - **PCA9535 user button** — a second button behind the I²C expander. `InputManager::setButtonHook()` takes a board callback that reads the PCA9535 and returns a `BTN_*` bitmask, so `InputManager` carries no expander code. This @@ -80,6 +114,4 @@ reference port at **`lib/Board_T5S3/FreeInkLgfxConfig.cpp`**. - **PCA9535 expander and TPS65185 PMIC** — the EPD power sequence (via `LgfxEpdConfig::power`) and the user-button read (via `setButtonHook`) both drive the same PCA9535, so the board owns the expander and feeds both seams. -- **GT911 home key** — the GT911 backend surfaces the capacitive home-key bit - (status `0x10`) directly via `InputManager::wasHomeKeyPressed()`. -- **PCF85063 RTC, LoRa, GPS** — board peripherals the SDK does not cover. +- **LoRa and GPS** — board peripherals the SDK does not cover. diff --git a/libs/display/FreeInkDisplay/include/FreeInkDisplay.h b/libs/display/FreeInkDisplay/include/FreeInkDisplay.h index 873f8ca1..21edcfb1 100644 --- a/libs/display/FreeInkDisplay/include/FreeInkDisplay.h +++ b/libs/display/FreeInkDisplay/include/FreeInkDisplay.h @@ -250,6 +250,12 @@ class FreeInkDisplay { // EXPERIMENTAL: Windowed update - display only a rectangular region void displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t h, bool turnOffScreen = false); void displayGrayBuffer(bool turnOffScreen = false, const unsigned char* lut = nullptr, bool factoryMode = false); + // Single-push grayscale: composes the framebuffer (B/W) with the staged + // LSB/MSB planes and displays them as one waveform. Falls back to a plain + // B/W displayBuffer() when the driver cannot (or the output is inverted -- + // the planes' meaning does not survive inversion). + bool supportsGrayFrame() const; + void displayGrayscaleFrame(RefreshMode mode, bool turnOffScreen = false); void displayGrayCalibration(uint16_t customX, uint16_t customY, uint16_t customW, uint16_t customH); void refreshDisplay(RefreshMode mode = FAST_REFRESH, bool turnOffScreen = false); diff --git a/libs/display/FreeInkDisplay/include/LgfxEpdConfig.h b/libs/display/FreeInkDisplay/include/LgfxEpdConfig.h index 337beafc..10e2be27 100644 --- a/libs/display/FreeInkDisplay/include/LgfxEpdConfig.h +++ b/libs/display/FreeInkDisplay/include/LgfxEpdConfig.h @@ -36,6 +36,39 @@ struct LgfxEpdConfig { size_t lutFastStep = 0; const uint32_t* lutFastest = nullptr; size_t lutFastestStep = 0; + + // The 8-bit canvas values the driver writes for the two anti-aliasing greys. + // Panel_EPD quantises a canvas byte to a 4-bit level as (v + bayer - 8) >> 4, + // so only v == (level << 4) | 8 lands on one level for every cell of the Bayer + // matrix; anything else alternates between two levels and shows up as speckle + // along glyph edges. grayLevelByte() builds a safe value from a level. + // + // Which level to ask for is a property of the board's waveform: the LUT drives + // grey destinations by column, so a canvas level the LUT has no column for + // simply stays at whatever the B/W base left it on. + // + // The defaults are the even thirds this driver has always written. They are not + // Bayer-exact, which does not matter on a board that leaves + // grayNudgeInFastBank false: that path dithers the canvas anyway, and these are + // the densities it has been dithering since the driver was written. + uint8_t grayDark = 0x55; + uint8_t grayLight = 0xAA; + + // True when the board's epd_fast LUT carries grey columns as well as the two + // B/W rails, so the grayscale push can go out through the differential bank: + // no lut_eraser flash, and the same bank the B/W base used, which is what lets + // Panel_EPD's per-pixel diff keep skipping everything that did not change. + // + // Left false for a board on LovyanGFX's stock LUTs, whose fast bank drives only + // the rails. Those boards keep the plain epd_fast push they have always had -- + // the greys still come out dithered there, which is a waveform gap on that + // board, not something this driver can paper over. + bool grayNudgeInFastBank = false; }; +// Canvas byte that quantises to exactly `level` for every Bayer cell. +constexpr uint8_t grayLevelByte(uint8_t level) { + return static_cast((level << 4) | 8); +} + } // namespace freeink diff --git a/libs/display/FreeInkDisplay/src/FreeInkDisplay.cpp b/libs/display/FreeInkDisplay/src/FreeInkDisplay.cpp index d202bc14..0a9cf5d0 100644 --- a/libs/display/FreeInkDisplay/src/FreeInkDisplay.cpp +++ b/libs/display/FreeInkDisplay/src/FreeInkDisplay.cpp @@ -775,6 +775,18 @@ void FreeInkDisplay::displayWindow(uint16_t x, uint16_t y, uint16_t w, uint16_t #endif } +bool FreeInkDisplay::supportsGrayFrame() const { return !_inverted && _driver && _driver->supportsGrayFrame(); } + +void FreeInkDisplay::displayGrayscaleFrame(RefreshMode mode, bool turnOffScreen) { + if (_inverted || _inversionDirty || !_driver || !_driver->supportsGrayFrame()) { + displayBuffer(mode, turnOffScreen); + return; + } + syncPendingAsync(); + _shadowValid = false; + _driver->displayGrayFrame(_bus, frameBuffer, toInternal(mode), turnOffScreen); +} + void FreeInkDisplay::displayGrayBuffer(bool turnOffScreen, const unsigned char* lut, bool factoryMode) { #if defined(SSD1677_PROBE_DEBUG) && SSD1677_PROBE_DEBUG Serial.printf("[EPD] displayGrayBuffer\n"); diff --git a/libs/display/FreeInkDisplay/src/driver/LgfxEpdDriver.cpp b/libs/display/FreeInkDisplay/src/driver/LgfxEpdDriver.cpp index c9bcbe30..99aaa17a 100644 --- a/libs/display/FreeInkDisplay/src/driver/LgfxEpdDriver.cpp +++ b/libs/display/FreeInkDisplay/src/driver/LgfxEpdDriver.cpp @@ -122,7 +122,11 @@ uint8_t* g_lsb = nullptr; uint8_t* g_msb = nullptr; uint16_t g_w = 0, g_h = 0, g_wb = 0; -constexpr uint8_t kGrayBlack = 0x00, kGrayDark = 0x55, kGrayLight = 0xAA, kGrayWhite = 0xFF; +// 0x00 and 0xFF survive Panel_EPD's quantiser at both rails whatever the Bayer +// cell (it clamps), so the two rails need no board input. The greys do -- see +// LgfxEpdConfig::grayDark. +constexpr uint8_t kGrayBlack = 0x00, kGrayWhite = 0xFF; +uint8_t g_grayDark = 0, g_grayLight = 0; void allocCanvas(uint16_t w, uint16_t h) { g_w = w; @@ -184,7 +188,7 @@ void overlayCanvasGray() { const uint8_t mask = 0x80 >> bit; const bool lb = (l & mask) != 0, mb = (m & mask) != 0; if (!lb && !mb) continue; - drow[bx * 8 + bit] = mb && !lb ? kGrayLight : kGrayDark; + drow[bx * 8 + bit] = (mb && !lb) ? g_grayLight : g_grayDark; } } } @@ -197,12 +201,62 @@ void overlayCanvasGray() { // page refreshed with those modes flashed when its AA pass ran. lgfx::epd_mode::epd_mode_t g_lastBaseEpdMode = lgfx::epd_mode::epd_fast; +// Wait out a refresh this driver just queued. +// +// waitDisplay() alone can return before the refresh has begun: Panel_EPD's +// display() raises _display_busy, yields (vTaskDelay(1)), and only then posts +// the job. The yield lets the panel task reach the top of its loop, where it +// assigns _display_busy = remain unconditionally -- false on an idle panel -- +// clearing the flag the caller just raised, then blocking on a queue the job +// has not reached. Between xQueueSend() returning and the task waking, the flag +// reads false for a refresh that has not started, so a caller that trusts it +// walks straight into the panel task's diff copy and tears it. Torn step state +// is how a pixel ends up with a step index that never terminates, `remain` +// never clears, and the next waitDisplay() blocks forever -- the reader frozen +// with input still alive. +// +// Yielding first lets the panel task ingest the job and re-raise the flag; the +// wait after it then means what it says. (1.5.16 shipped this, 1.5.17 reverted +// it on a ghosting suspicion; the ghosting survived the revert, which clears +// this guard of that charge.) +void settleDisplay() { + vTaskDelay(pdMS_TO_TICKS(2)); + g_dev.waitDisplay(); +} + void pushCanvas(lgfx::epd_mode::epd_mode_t epdMode) { if (!g_canvas) return; g_dev.waitDisplay(); g_dev.setEpdMode(epdMode); g_canvas->pushSprite(0, 0); // commits to the panel; Panel_EPD runs the refresh + settleDisplay(); +} + +// Push the canvas keeping its grey levels, then refresh through the differential +// bank. +// +// Panel_EPD reads the epd_mode twice, at two different moments, and they do not +// have to agree. _draw_pixels() reads it while the sprite is being copied into +// the panel's 4bpp buffer, and in epd_fast/epd_fastest it Bayer-dithers every +// pixel to one of the two rails there and then -- that is what turned the AA +// greys into hard black speckle along glyph edges. task_update() reads it again +// when the refresh is queued, and only the fast modes skip lut_eraser, the +// preliminary pass that drives everything toward mid grey and shows as a flash. +// +// Splitting auto-display lets each read see the mode it should: quality while the +// pixels land (16 levels, no dither), fast when the refresh goes out (no eraser, +// and the same LUT bank the B/W base used, so Panel_EPD's per-pixel diff still +// skips everything that did not change). +void pushCanvasGraded(lgfx::epd_mode::epd_mode_t refreshMode) { + if (!g_canvas) return; g_dev.waitDisplay(); + g_dev.setEpdMode(lgfx::epd_mode::epd_quality); + g_dev.setAutoDisplay(false); + g_canvas->pushSprite(0, 0); // writes the panel buffer, queues no refresh + g_dev.setAutoDisplay(true); + g_dev.setEpdMode(refreshMode); + g_dev.display(); // covers the rect pushSprite accumulated + settleDisplay(); } } // namespace @@ -225,6 +279,8 @@ void LgfxEpdDriver::begin(EpdBus& bus) { g_dev.init(); g_dev.setRotation(_cfg.rotation); g_dev.setEpdMode(lgfx::epd_mode::epd_fast); + g_grayDark = _cfg.grayDark; + g_grayLight = _cfg.grayLight; allocCanvas(BoardConfig::ACTIVE.displayWidth, BoardConfig::ACTIVE.displayHeight); #endif } @@ -244,6 +300,42 @@ void LgfxEpdDriver::display(EpdBus& bus, const uint8_t* fb, const uint8_t* prev, #endif } +// One render, one push: the whole page -- text and its anti-aliasing greys -- +// reaches the panel as a single waveform. +// +// The two-push flow this replaces (B/W base, then a grey overlay push) existed +// to normalize fringe pixels to black before a from-black grey nudge, because a +// destination-indexed LUT cannot see where a pixel came from. The fast bank's +// grey columns are now self-normalizing (saturate at the white rail, then walk +// down to the level), so the base pass has nothing left to do and the page has +// no intermediate state to show: it arrives finished, or it has not arrived. +// +// The charge story rides on the same property. Under the old flow every fringe +// pixel swung black-to-grey through two pushes on every page turn, with a net +// drive imbalance each time; under one push, Panel_EPD's diff drives a pixel +// only when its target changes, and every grey drive begins with a saturating +// rail visit that erases accumulated bias. +void LgfxEpdDriver::displayGrayFrame(EpdBus& bus, const uint8_t* fb, RefreshMode mode, bool turnOff) { + (void)bus; +#if FREEINK_DRIVER_LGFX_EPD + if (!fb) return; + g_dev.waitDisplay(); // never write the canvas while a refresh may be in flight + fillCanvasBW(fb); + overlayCanvasGray(); + // HALF/FULL map to the GC16-style clean bank, whose columns land every level + // exactly, so the periodic scrub page carries its greys too. FAST takes the + // differential bank. Either way the write itself must be graded -- a fast-mode + // write Bayer-dithers the greys to the rails before any LUT is consulted. + g_lastBaseEpdMode = epdModeFor(mode); + pushCanvasGraded(g_lastBaseEpdMode); + if (turnOff) g_dev.sleep(); +#else + (void)fb; + (void)mode; + (void)turnOff; +#endif +} + void LgfxEpdDriver::copyGrayscaleLsb(EpdBus& bus, const uint8_t* lsb) { (void)bus; #if FREEINK_DRIVER_LGFX_EPD @@ -286,12 +378,20 @@ void LgfxEpdDriver::displayGray(EpdBus& bus, const uint8_t* fb, bool turnOff, co #if FREEINK_DRIVER_LGFX_EPD (void)fb; // the canvas from the base push IS the base; see overlayCanvasGray() overlayCanvasGray(); // darken only the pixels the planes select - // Same mode as the B/W base push, and now actually the same: Panel_EPD's - // per-pixel diff keys on the epd_mode LUT offset, so switching modes here - // re-drives every pixel (full-screen flash). This was hardcoded to epd_fast - // while display() maps HALF/FULL to epd_text, so a page refreshed with either - // of those flashed when its AA pass ran. - pushCanvas(g_lastBaseEpdMode); + // Refresh under the mode the base push used. Panel_EPD's per-pixel diff keys + // on the epd_mode LUT offset, so switching modes here re-drives every pixel -- + // a full-screen flash on any page the host refreshed with HALF or FULL. + // + // The pixel write is a separate question from the refresh, and on a board + // whose fast bank carries grey columns it must not go out under a fast mode: + // _draw_pixels() Bayer-dithers to the two rails there, which is what turned + // the greys into black speckle. pushCanvasGraded() writes under a graded mode + // and refreshes under this one. + if (_cfg.grayNudgeInFastBank) { + pushCanvasGraded(g_lastBaseEpdMode); + } else { + pushCanvas(g_lastBaseEpdMode); + } if (turnOff) g_dev.sleep(); #else (void)fb; diff --git a/libs/display/FreeInkDisplay/src/driver/LgfxEpdDriver.h b/libs/display/FreeInkDisplay/src/driver/LgfxEpdDriver.h index 9a54bf5e..b5397f55 100644 --- a/libs/display/FreeInkDisplay/src/driver/LgfxEpdDriver.h +++ b/libs/display/FreeInkDisplay/src/driver/LgfxEpdDriver.h @@ -41,6 +41,12 @@ class LgfxEpdDriver : public PanelDriver { // 16-gray path: the facade streams LSB/MSB 1-bpp planes (whole or in strips); // displayGray combines them with the B/W base into the panel's 8-bit gray canvas. bool supportsStripGrayscale() const override { return true; } + // Single-push grayscale: the page and its AA greys go out as one waveform. + // Valid because this board's fast bank carries self-normalizing grey columns + // (see ED047TC2Waveform.cpp); the generic two-push flow stays available for + // callers that do not use it. + bool supportsGrayFrame() const override { return true; } + void displayGrayFrame(EpdBus& bus, const uint8_t* fb, RefreshMode mode, bool turnOff) override; void copyGrayscaleLsb(EpdBus& bus, const uint8_t* lsb) override; void copyGrayscaleMsb(EpdBus& bus, const uint8_t* msb) override; void writeGrayscalePlaneStrip(EpdBus& bus, GrayPlane plane, const uint8_t* rows, uint16_t yStart, diff --git a/libs/display/FreeInkDisplay/src/driver/PanelDriver.h b/libs/display/FreeInkDisplay/src/driver/PanelDriver.h index 9cf890b4..3def8921 100644 --- a/libs/display/FreeInkDisplay/src/driver/PanelDriver.h +++ b/libs/display/FreeInkDisplay/src/driver/PanelDriver.h @@ -134,6 +134,20 @@ class PanelDriver { (void)bus; (void)plane; (void)rows; (void)yStart; (void)numRows; } virtual void prepareGrayscaleTarget(const uint8_t* bw) { (void)bw; } + // Single-push grayscale: `fb` is the intact 1-bpp B/W frame and the LSB/MSB + // planes were staged beforehand (copyGrayscale*/writeGrayscalePlaneStrip); + // the driver composes and displays base + greys as ONE waveform in `mode`. + // Only meaningful on a panel whose LUTs can land a grey from any source + // state -- a driver advertises that with supportsGrayFrame(). Everyone else + // keeps the two-push base + displayGray() flow. + virtual bool supportsGrayFrame() const { return false; } + virtual void displayGrayFrame(EpdBus& bus, const uint8_t* fb, RefreshMode mode, bool turnOff) { + (void)bus; + (void)fb; + (void)mode; + (void)turnOff; + } + virtual void displayGray(EpdBus& bus, const uint8_t* fb, bool turnOff, const unsigned char* lut, bool factoryMode) { (void)lut; (void)factoryMode; diff --git a/libs/hardware/BoardConfig/include/BoardConfig.h b/libs/hardware/BoardConfig/include/BoardConfig.h index e91ee260..f88d402e 100644 --- a/libs/hardware/BoardConfig/include/BoardConfig.h +++ b/libs/hardware/BoardConfig/include/BoardConfig.h @@ -531,6 +531,21 @@ struct TouchConfig { // controller). false = active-LOW (drive LOW to power it, e.g. X4 Pro's GPIO2). The // reset path drives the ON level; the sleep path drives the OFF level. bool powerEnableActiveHigh = true; + // Park the controller by holding `reset` asserted (LOW) through deep sleep. + // For boards with NO switched touch rail (powerEnable unassigned, e.g. the + // LilyGo T5 S3): there is nothing to cut, so without this the digitizer keeps + // scanning all through "off" — a GT911 costs several mA there, an order of + // magnitude more than everything else on a sleeping board put together. + // Held in reset it drops to a few µA. + // + // Opt-in per board rather than inferred from `powerEnable < 0`, because a pin + // called "reset" is not always one: MURPHY_M3's touch pin 45 is an active-LOW + // PMOS power gate, so driving it LOW would POWER the controller — the exact + // opposite of the intent — on the one board the inference would silently + // catch. PowerManager::powerDownRailsForSleep() latches the level with + // gpio_hold_en(); the touch bring-up paths in InputManager release the hold + // before their reset pulse, or touch is dead after the first wake. + bool holdResetInSleep = false; }; // PWM frontlight description (gpio == PIN_UNASSIGNED disables it). @@ -555,6 +570,17 @@ struct FrontlightConfig { // on. pwmFrequency still applies (PM1 PWM_FREQ register); resolution is the // PM1's fixed 12 bits. bool viaPm1Pwm = false; + // Boost-driver floors, in permille of full duty. Zero on boards whose LED + // hangs directly off the PWM pin. On a board that PWMs a boost converter's + // EN pin (LilyGo T5 S3: PT4103 behind GPIO11), an on-time shorter than the + // boost's start-up produces NO light rather than dim light, so a perceptual + // dimming curve must land its bottom on the boost's floor, not on one LSB. + // minStartPermille: lowest duty the boost reliably IGNITES from cold. + // minHoldPermille: lowest duty it stays lit at once running (<= start; + // FrontlightManager bridges the gap with a brief kick at + // start level when turning on into the hold band). + uint16_t minStartPermille = 0; + uint16_t minHoldPermille = 0; }; // I2C frontlight controller (LM3630A on the EEGO A4). The controller is driven @@ -749,7 +775,10 @@ constexpr TouchConfig NO_TOUCH = {TouchController::None, // with one physical nav key that is a real loss. constexpr TouchConfig LILYGO_T5_PRO_GT911 = { TouchController::Gt911, 39, 40, 3, 9, 0x5D, 0, 959, 0, 539, false, 0x14, false, true, - PIN_UNASSIGNED, true, false, true, true}; // powerEnable, swapXY, flipX, flipY, hasHomeKey + PIN_UNASSIGNED, true, false, true, true, // powerEnable, swapXY, flipX, flipY, hasHomeKey + true, // powerEnableActiveHigh: unused (no rail), spelled out to reach the field below + true}; // holdResetInSleep: nothing gates this GT911's power, so deep sleep parks it in + // reset on T_RST (GPIO9) instead — see TouchConfig::holdResetInSleep constexpr FrontlightConfig NO_FRONTLIGHT = {PIN_UNASSIGNED, 0, 0, true}; constexpr AudioConfig NO_AUDIO = {AudioOutput::None, PIN_UNASSIGNED, @@ -1177,13 +1206,40 @@ constexpr BoardProfile LILYGO_T5S3 = { 0, // displaySpiHz n/a (external bus) {14, 21, 13, 12, PIN_UNASSIGNED, false, 0}, // SD over SPI: SCLK14 MISO21 MOSI13 CS12 {PIN_UNASSIGNED, PIN_UNASSIGNED, PIN_UNASSIGNED, PIN_UNASSIGNED, PIN_UNASSIGNED, PIN_UNASSIGNED, 0, - false}, // power=BOOT (GPIO0), active-low + false}, // power=BOOT (GPIO0), active-low. Do NOT map IO48 as confirm: on the + // Pro Lite it reads spurious active-low pulses (phantom Confirm + // presses opened the reader menu by itself) — verify its wiring + // on real hardware before mapping it again. PIN_UNASSIGNED, // batteryAdc: none — uses the I2C fuel gauge below PIN_UNASSIGNED, 2.0f, PIN_UNASSIGNED, LILYGO_T5_PRO_GT911, // GT911 touch (SDA39 SCL40 INT3 RST9, 0x5D, portrait sensor -> landscape panel) - {11, 5000, 8, true}, // backlight: BL_EN GPIO11, PWM 5 kHz / 8-bit, active-high + // Frontlight: PT4103 boost EN behind GPIO11, PWM 1 kHz. 12-bit duty so the + // perceptual curve has real steps at the dim end (at 8 bits the whole + // 1..9% band collapsed into five LSBs) -- and NOT 13 at the old 5 kHz: the + // Arduino-3 LEDC path auto-picks its clock, and 5 kHz x 2^13 = 40.96 MHz + // overran the 40 MHz XTAL source, failing the attach and leaving the light + // COMPLETELY dead (no toggle, no slider). 1 kHz x 2^12 = 4.1 MHz clears + // every source the selector can choose by a wide margin. + // + // What the boost needs is a minimum ON-TIME, not a minimum duty: ~10 us lit + // and ~4.4 us did not, measured off 1.5.17/1.5.18. Those microseconds are + // what the floors below encode, so the PWM period is the lever on how dim + // the light can go. At 5 kHz (200 us period) the 5 us hold floor was 25 + // permille -- 2.5% duty, already ~18% of perceived full brightness, so 1% + // came up too bright and 1% -> 2% moved the duty by 4% (0.2 us), under the + // threshold where the boost's output changes at all. At 1 kHz (1000 us + // period) the SAME on-times are 10 and 5 permille: 1% lands at 0.5% duty, + // five times dimmer, and each LSB is 244 ns instead of 49 ns so 1% -> 2% + // is a 19% duty step. Nothing here asks the converter for a pulse shorter + // than one already shown to light it. + // + // 1 kHz is the floor for the frequency itself: IEEE 1789's low-risk flicker + // limit is depth < 8% x f_Hz, and these near-floor pulses are ~100% depth. + // Going below 1 kHz to chase more dimming range would trade a real + // photobiological margin for it. + {11, 1000, 12, true, PIN_UNASSIGNED, false, 10, 5}, NO_AUDIO, NO_LEDS, NO_FLIP, @@ -1198,15 +1254,21 @@ constexpr BoardProfile LILYGO_T5S3 = { // it whenever power was actually cut rather than merely deep-slept. {39, 40, 400000, 0x51, 0, 0, 0, RtcType::Pcf8563, ImuType::None}, 1.2f, // uiScale: 4.7" 960x540 touch (~234 PPI) — finger-sized chrome, like Sticky - // Power latch: main-power MOSFET on GPIO2, driven HIGH first thing in boot - // via holdPowerRails() or the board powers off when USB is unplugged. - {2}, - 0, // displayControllerVariant: not probed on this panel - // Bezel: this case sits closer over the glass at the sides than the X4's, so - // the default 3px leaves the first and last characters of a line hard to - // read. Measured by eye on hardware in two passes (3 -> 6 -> 8); top/bottom - // are correct at the defaults. Compare the X4 Pro's 7px sides. - {9, 8, 3, 8}}; + // NO power latch. This profile used to carry latch0 = GPIO2 with an M5Paper-style + // "main-power MOSFET, hold HIGH or the board dies on USB unplug" comment. GPIO2 is + // nothing of the sort here: the T5 E-paper S3 Pro schematic (LILYGO publishes none + // for the Lite; the vendor states the variants share the core design) maps ESP32 + // IO2 to RTC_INT — the PCF8563's open-drain alarm output, pulled up through 10K — + // and shows no soft power latch anywhere. Power sequencing is the BQ25896's job, + // with its /QON pin on the S4 button straight to ground, no GPIO involved. + // + // holdPowerRails() drives latch pins as OUTPUT HIGH first thing in setup(), so the + // stale entry had the firmware fighting the RTC's open-drain output every time an + // alarm asserted. Nothing had noticed because no alarm was armed yet — but the RTC + // is wired up now, so alarms and interrupts would simply never have been seen. + // (latchConflictsWithBus() cannot catch this: it guards display and SD bus pins, + // and SensorsConfig carries no RTC interrupt pin for it to compare against.) + {}}; // power: none // --- M5Paper v1.1 4.7" (ED047TC1 behind an IT8951E controller) — ESP32 -------- // 540x960 16-gray panel driven through an IT8951E timing controller over SPI @@ -1838,6 +1900,9 @@ inline void releaseSdRail() { digitalWrite(ACTIVE.sd.powerEnable, ACTIVE.sd.powerActiveHigh ? HIGH : LOW); } if (ACTIVE.sd.cs >= 0) { + // On boards with no SD rail the sleep path latches CS deasserted with + // gpio_hold_en; the hold survives reset and would swallow the write below. + gpio_hold_dis(static_cast(ACTIVE.sd.cs)); pinMode(ACTIVE.sd.cs, OUTPUT); digitalWrite(ACTIVE.sd.cs, HIGH); } diff --git a/libs/hardware/BoardT5S3/include/ED047TC2Waveform.h b/libs/hardware/BoardT5S3/include/ED047TC2Waveform.h new file mode 100644 index 00000000..fc31b67b --- /dev/null +++ b/libs/hardware/BoardT5S3/include/ED047TC2Waveform.h @@ -0,0 +1,64 @@ +#pragma once + +// ED047TC2 vendor waveform, in the LUT form LovyanGFX's Panel_EPD consumes. +// +// The panel on the LilyGo T5 S3 Pro has no on-glass controller: the waveform is +// the firmware's responsibility, and getting it wrong costs contrast and leaves +// ghosts. The tables in ED047TC2Waveform.cpp are generated from the panel +// vendor's own waveform blob by tools/gen_ed047tc2_waveform.py, which refuses to +// emit anything unless the blob still matches the structure it decodes. Do not +// hand-edit the generated file. +// +// The vendor waveform is specified per ambient temperature range, and the drive +// length grows as the panel gets colder. Pick the LUT with tempRangeIndex() from +// a real panel temperature rather than assuming room temperature; +// LilyGoT5S3LgfxConfig.cpp reads one from the TPS65185's thermistor. + +#include + +namespace freeink { +namespace ed047tc2 { + +// Temperature ranges the vendor waveform covers, coldest first. The blob carries +// 15..38 C; tempRangeIndex() clamps anything outside that to the nearest end. +constexpr size_t kTempRangeCount = 7; + +struct TempRange { + int8_t minC; + int8_t maxC; +}; + +extern const TempRange kTempRanges[kTempRangeCount]; + +// The bank Panel_EPD's differential modes (epd_fast / epd_fastest) run: the +// vendor DU waveform for the two rails, plus a nudge column for each of the two +// anti-aliasing greys. One bank carries both because Panel_EPD folds the bank +// offset into its per-pixel progress value -- pushing the B/W base and the greys +// under different epd_modes makes every pixel compare unequal and re-drives the +// whole screen. +extern const uint32_t* const kFastLut[kTempRangeCount]; +extern const size_t kFastLutStep[kTempRangeCount]; + +// The two canvas levels the grey columns above are cut for, per range. The grey +// nudge is from-black and destination-indexed, so a canvas that writes any other +// level lands on a column the LUT leaves undriven and the pixel stays black. +// LilyGoT5S3LgfxConfig turns these into the canvas bytes the driver writes. +extern const uint8_t kGrayLevelDark[kTempRangeCount]; +extern const uint8_t kGrayLevelLight[kTempRangeCount]; + +// The clean refresh, for epd_text / epd_quality. Those modes re-drive every +// non-white pixel whether or not it changed, so this one is charge neutral: it +// drives each level away from its target and back again, which nets zero and +// puts every re-driven pixel through a full rail-to-rail excursion. +extern const uint32_t* const kCleanLut[kTempRangeCount]; +extern const size_t kCleanLutStep[kTempRangeCount]; + +// Frames of drive a full black<->white transition takes in each range. Exposed +// for logging and for sizing refresh timeouts. +extern const uint8_t kDriveFrames[kTempRangeCount]; + +// Range covering tempC, clamped to the ends of the table. +size_t tempRangeIndex(int tempC); + +} // namespace ed047tc2 +} // namespace freeink diff --git a/libs/hardware/BoardT5S3/src/ED047TC2Waveform.cpp b/libs/hardware/BoardT5S3/src/ED047TC2Waveform.cpp new file mode 100644 index 00000000..dc523d87 --- /dev/null +++ b/libs/hardware/BoardT5S3/src/ED047TC2Waveform.cpp @@ -0,0 +1,846 @@ +// GENERATED FILE -- DO NOT EDIT BY HAND. +// +// Regenerate with: +// python tools/gen_ed047tc2_waveform.py +// +// Source: the ED047TC2 vendor waveform in epdiy form -- LilyGo ships it as +// Waveform_header/ED047TC2.h in Xinyuan-LilyGO/LilyGo-EPD47, epdiy as +// epdiy_ED047TC2.h; the two are the same data under different symbol names. +// See tools/gen_ed047tc2_waveform.py for the blob layout and for the structural +// checks the generator runs against it. +// +// The vendor waveform is a separable impulse waveform: a transition from source +// level `f` to destination level `t` needs `L[t] - L[f]` frames of drive, toward +// white when positive and toward black when negative, where L is the per +// temperature range impulse vector below (level 0 is black, 15 is white): +// +// 15..18 C L = [ 0, 2, 3, 4, 4, 5, 6, 7, 7, 8, 9, 10, 15, 16, 17, 24] +// 18..21 C L = [ 0, 3, 5, 6, 6, 7, 8, 9, 9, 9, 11, 12, 13, 17, 19, 21] +// 21..24 C L = [ 0, 3, 3, 5, 5, 6, 8, 8, 9, 9, 10, 12, 12, 17, 17, 21] +// 24..27 C L = [ 0, 3, 3, 5, 6, 6, 7, 8, 9, 9, 9, 9, 12, 16, 19, 21] +// 27..30 C L = [ 0, 3, 5, 5, 5, 6, 6, 7, 8, 9, 9, 9, 12, 13, 15, 17] +// 30..33 C L = [ 0, 4, 4, 4, 5, 6, 7, 7, 8, 8, 9, 10, 12, 12, 14, 16] +// 33..38 C L = [ 0, 1, 2, 2, 3, 6, 7, 7, 9, 9, 10, 10, 14, 14, 14, 14] +// +// Both emitted banks are SELF-NORMALIZING: a LovyanGFX LUT column is indexed by +// destination alone, so any column that must land on a mid level from an +// arbitrary source first saturates at a rail -- the clamp erases the pixel's +// history -- and then walks to its target. That is what lets a page carry its +// anti-aliasing greys in the same push as its text, with no separate B/W base +// pass to pre-position the fringe. +// +// kFastLut (epd_fast / epd_fastest): the vendor DU rails verbatim, plus a grey +// column per AA tone that spends L[15] frames at the white rail and then +// descends L[15]-L[g] frames to its level. Panel_EPD's per-pixel diff means a +// pixel is only driven when its target changes, so a fringe pixel that stays +// the same grey across a page turn is not driven at all -- which is also the +// charge story: drives happen on content changes only, each one begins with a +// saturating rail visit, and the rail visit resets whatever DC bias the pixel +// had accumulated. +// +// kCleanLut (epd_text / epd_quality): a GC16-style refresh. Every one of the 16 +// columns rail-normalizes (dark half to white, light half to black) and then +// walks to its exact level, so the periodic clean page both scrubs residue and +// re-lands every grey precisely. The previous bank drove each level away and +// symmetrically back, which is only a correct landing for the two rails; any +// grey pushed through it ended on a rail. +// +// Which levels carry the greys is decided per temperature range, because the +// vendor vector is not evenly spaced and its spacing moves with temperature: at +// 33..38 C levels 12 through 15 are all the same optical white. The board config +// reads kGrayLevelDark/kGrayLevelLight for the range it selected the LUT for and +// writes the matching canvas bytes, so the two cannot drift apart. +// +// 15..18 C dark = level 10 ( 38% to white) light = level 12 ( 62% to white) +// 18..21 C dark = level 6 ( 38% to white) light = level 12 ( 62% to white) +// 21..24 C dark = level 6 ( 38% to white) light = level 11 ( 57% to white) +// 24..27 C dark = level 7 ( 38% to white) light = level 12 ( 57% to white) +// 27..30 C dark = level 5 ( 35% to white) light = level 12 ( 71% to white) +// 30..33 C dark = level 5 ( 38% to white) light = level 11 ( 62% to white) +// 33..38 C dark = level 5 ( 43% to white) light = level 8 ( 64% to white) +// +// Keeping the drive length matched to the panel temperature IS the temperature +// compensation: e-ink particles move more slowly when cold, so a cold panel +// needs a longer push for the same optical result. +// +// Panel_EPD addresses its expanded LUT with uint8_t block indices, so all five +// banks together (eraser + quality + text + fast + fastest) must fit in 255 +// rows. Worst case here: 118 rows. + +#include + +namespace freeink { +namespace ed047tc2 { + +namespace { + +// One frame of a LovyanGFX Panel_EPD LUT: sixteen 2-bit drive codes, indexed by +// the destination level. 0 ends the sequence, 1 drives toward black, 2 drives +// toward white, 3 is a no-op that keeps the sequence running. +#define LUT_MAKE(d0, d1, d2, d3, d4, d5, d6, d7, d8, d9, da, db, dc, dd, de, df) \ + (uint32_t)((d0 << 0) | (d1 << 2) | (d2 << 4) | (d3 << 6) | (d4 << 8) | (d5 << 10) | \ + (d6 << 12) | (d7 << 14) | (d8 << 16) | (d9 << 18) | (da << 20) | \ + (db << 22) | (dc << 24) | (dd << 26) | (de << 28) | (df << 30)) + +// Differential bank: DU rails + self-normalizing AA grey columns. +constexpr uint32_t kFastR0[] = { + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 2, 3, 3, 2), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3), + 0u, +}; + +// Differential bank: DU rails + self-normalizing AA grey columns. +constexpr uint32_t kFastR1[] = { + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// Differential bank: DU rails + self-normalizing AA grey columns. +constexpr uint32_t kFastR2[] = { + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// Differential bank: DU rails + self-normalizing AA grey columns. +constexpr uint32_t kFastR3[] = { + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// Differential bank: DU rails + self-normalizing AA grey columns. +constexpr uint32_t kFastR4[] = { + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2, 3, 3, 2), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 1, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// Differential bank: DU rails + self-normalizing AA grey columns. +constexpr uint32_t kFastR5[] = { + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 1, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// Differential bank: DU rails + self-normalizing AA grey columns. +constexpr uint32_t kFastR6[] = { + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(1, 3, 3, 3, 3, 2, 3, 3, 2, 3, 3, 3, 3, 3, 3, 2), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(3, 3, 3, 3, 3, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// GC16-style clean: every level rail-normalizes, then lands exactly. +constexpr uint32_t kCleanR0[] = { + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// GC16-style clean: every level rail-normalizes, then lands exactly. +constexpr uint32_t kCleanR1[] = { + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// GC16-style clean: every level rail-normalizes, then lands exactly. +constexpr uint32_t kCleanR2[] = { + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// GC16-style clean: every level rail-normalizes, then lands exactly. +constexpr uint32_t kCleanR3[] = { + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// GC16-style clean: every level rail-normalizes, then lands exactly. +constexpr uint32_t kCleanR4[] = { + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// GC16-style clean: every level rail-normalizes, then lands exactly. +constexpr uint32_t kCleanR5[] = { + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +// GC16-style clean: every level rail-normalizes, then lands exactly. +constexpr uint32_t kCleanR6[] = { + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3), + 0u, +}; + +#undef LUT_MAKE + +} // namespace + +const TempRange kTempRanges[kTempRangeCount] = { + {15, 18}, + {18, 21}, + {21, 24}, + {24, 27}, + {27, 30}, + {30, 33}, + {33, 38}, +}; + +// The canvas levels the AA grey columns are cut for, per temperature range. A +// board config turns these into the grey bytes its canvas writes; they are not +// independently tunable -- retarget them in tools/gen_ed047tc2_waveform.py and +// regenerate, or the canvas will address a column the LUT does not drive. +const uint8_t kGrayLevelDark[kTempRangeCount] = {10, 6, 6, 7, 5, 5, 5}; + +const uint8_t kGrayLevelLight[kTempRangeCount] = {12, 12, 11, 12, 12, 11, 8}; + +const uint32_t* const kFastLut[kTempRangeCount] = { + kFastR0, + kFastR1, + kFastR2, + kFastR3, + kFastR4, + kFastR5, + kFastR6, +}; + +const size_t kFastLutStep[kTempRangeCount] = { + sizeof(kFastR0) / sizeof(kFastR0[0]), + sizeof(kFastR1) / sizeof(kFastR1[0]), + sizeof(kFastR2) / sizeof(kFastR2[0]), + sizeof(kFastR3) / sizeof(kFastR3[0]), + sizeof(kFastR4) / sizeof(kFastR4[0]), + sizeof(kFastR5) / sizeof(kFastR5[0]), + sizeof(kFastR6) / sizeof(kFastR6[0]), +}; + +const uint32_t* const kCleanLut[kTempRangeCount] = { + kCleanR0, + kCleanR1, + kCleanR2, + kCleanR3, + kCleanR4, + kCleanR5, + kCleanR6, +}; + +const size_t kCleanLutStep[kTempRangeCount] = { + sizeof(kCleanR0) / sizeof(kCleanR0[0]), + sizeof(kCleanR1) / sizeof(kCleanR1[0]), + sizeof(kCleanR2) / sizeof(kCleanR2[0]), + sizeof(kCleanR3) / sizeof(kCleanR3[0]), + sizeof(kCleanR4) / sizeof(kCleanR4[0]), + sizeof(kCleanR5) / sizeof(kCleanR5[0]), + sizeof(kCleanR6) / sizeof(kCleanR6[0]), +}; + +const uint8_t kDriveFrames[kTempRangeCount] = {24, 21, 21, 21, 17, 16, 14}; + +size_t tempRangeIndex(int tempC) { + for (size_t i = 0; i < kTempRangeCount; ++i) { + if (tempC < kTempRanges[i].maxC) return i; + } + return kTempRangeCount - 1; +} + +} // namespace ed047tc2 +} // namespace freeink diff --git a/libs/hardware/BoardT5S3/src/LilyGoT5S3LgfxConfig.cpp b/libs/hardware/BoardT5S3/src/LilyGoT5S3LgfxConfig.cpp index ebae3995..3ced32c9 100644 --- a/libs/hardware/BoardT5S3/src/LilyGoT5S3LgfxConfig.cpp +++ b/libs/hardware/BoardT5S3/src/LilyGoT5S3LgfxConfig.cpp @@ -1,42 +1,24 @@ #include +#include #include #include namespace { constexpr int kDefaultVcomMv = -1600; +constexpr uint8_t kTpsRegTmstValue = 0x00; constexpr uint8_t kTpsRegEnable = 0x01; constexpr uint8_t kTpsRegVcom = 0x03; +constexpr uint8_t kTpsRegTmst1 = 0x0D; constexpr uint8_t kTpsRegPowerGood = 0x0F; constexpr uint8_t kTpsEnableOutputs = 0x3F; +constexpr uint8_t kTpsStartConversion = 0x80; // TMST1 READ_THERM +constexpr uint8_t kTpsConversionDone = 0x20; // TMST1 CONV_END -#define LUT_MAKE(d0, d1, d2, d3, d4, d5, d6, d7, d8, d9, da, db, dc, dd, de, df) \ - (uint32_t)((d0 << 0) | (d1 << 2) | (d2 << 4) | (d3 << 6) | (d4 << 8) | (d5 << 10) | (d6 << 12) | \ - (d7 << 14) | (d8 << 16) | (d9 << 18) | (da << 20) | (db << 22) | (dc << 24) | \ - (dd << 26) | (de << 28) | (df << 30)) - -// Single waveform for BOTH the B/W base push and the AA gray overlay push. -// Panel_EPD's per-pixel diff embeds the epd_mode LUT offset in the stored -// value, so alternating modes between the two pushes of a page turn defeats -// the diff and re-drives the whole screen — the LovyanGFX default lut_fast -// then flashes every white pixel black for two frames (the full-screen black -// "swipe"). Using one LUT under one mode keeps unchanged pixels skipped. -// Columns 0/15 carry the default lut_fast drive (changed B/W text pixels); -// columns 1-6 / 9-14 carry the AA nudge for the gray levels AA produces. -constexpr uint32_t kFastLut[] = { - LUT_MAKE(2, 1, 1, 1, 1, 1, 1, 3, 3, 2, 2, 2, 2, 2, 2, 1), - LUT_MAKE(2, 3, 1, 1, 1, 1, 3, 3, 3, 3, 2, 2, 2, 2, 3, 1), - LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2), - LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2), - LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2), - LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2), - LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2), - LUT_MAKE(1, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2), - ~0u, - 0u, -}; - -#undef LUT_MAKE +// Used when the PMIC thermistor cannot be read. Room temperature sits in the +// middle of the waveform's range, so a wrong guess is off by at most a few +// frames either way. +constexpr int kAssumedTemperatureC = 25; bool writeTpsRegister(uint8_t reg, const uint8_t* data, size_t len) { BoardT5S3::ScopedI2CLock lock; @@ -63,6 +45,43 @@ bool readTpsRegister(uint8_t reg, uint8_t* data, size_t len) { return true; } +// Ambient temperature in degrees C from the PMIC's thermistor. +// +// Wait for READ_THERM to self-clear, not for CONV_END. CONV_END latches and is +// still set from whatever conversion ran before, so a loop that breaks on it +// alone returns on the very first poll and reads TMST_VALUE before this +// conversion has produced anything -- which is how this came back a flat 0 C and +// pinned the waveform to its coldest, longest range no matter how warm the panel +// actually was. +bool readTpsThermistor(int8_t* out) { + if (!writeTpsRegister8(kTpsRegTmst1, kTpsStartConversion)) return false; + for (int tries = 0; tries < 100; ++tries) { + delay(2); + uint8_t status = 0; + if (!readTpsRegister(kTpsRegTmst1, &status, 1)) return false; + if ((status & kTpsStartConversion) == 0 && (status & kTpsConversionDone) != 0) { + uint8_t value = 0; + if (!readTpsRegister(kTpsRegTmstValue, &value, 1)) return false; + *out = static_cast(value); + return true; + } + } + return false; +} + +// The PMIC answers I2C as soon as WAKEUP is high, well before the high-voltage +// rails come up, so the panel temperature can be sampled without driving the +// panel at all. That matters because this runs before the EPD bus exists: the +// waveform has to be chosen before LovyanGFX expands it at panel init. +bool readPanelTemperature(int8_t* out) { + if (!BoardT5S3::setPca9535PinMode(PCA9535_IO15_TPS_WAKEUP, OUTPUT)) return false; + if (!BoardT5S3::writePca9535Pin(PCA9535_IO15_TPS_WAKEUP, true)) return false; + delay(10); // PMIC wakeup, then its thermistor block settles + const bool ok = readTpsThermistor(out); + BoardT5S3::writePca9535Pin(PCA9535_IO15_TPS_WAKEUP, false); + return ok; +} + bool waitForPcaPinHigh(uint8_t pin, uint32_t timeoutMs) { const uint32_t start = millis(); bool high = false; @@ -150,12 +169,53 @@ bool epdPowerOn() { return true; } -} // namespace +freeink::LgfxEpdConfig buildConfig() { + int8_t measured = 0; + const bool measuredOk = readPanelTemperature(&measured); + const int tempC = measuredOk ? measured : kAssumedTemperatureC; + // Slots for modes this stack never refreshes with. CrossPoint maps FAST to + // epd_fast and HALF/FULL to epd_text; epd_quality is only ever a WRITE mode + // (graded pixel quantizer -- the write path reads no LUT) and epd_fastest is + // unused. Panel_EPD would fill an empty slot with LovyanGFX's stock tables, + // which both burns LUT blocks and is tuned for another panel; a one-row + // terminator keeps the slot valid and nearly free. + // + // The blocks matter more than they look: Panel_EPD stores per-pixel progress + // in uint16_t words and flags the fast modes with +0x8000, so the fast and + // fastest banks must START at block <= 127 -- half the space the uint8_t + // offset table suggests. The three-phase clean bank blew exactly this at + // cool temperature ranges (fast start at block 131), which killed every + // refresh the firmware makes and blanked the display below ~27 C while + // warmer boots worked. Stubbing the two dead slots puts the fast start near + // block 70 with room to grow. + static constexpr uint32_t kUnusedLut[] = {0u}; + const size_t range = freeink::ed047tc2::tempRangeIndex(tempC); + const uint32_t* fast = freeink::ed047tc2::kFastLut[range]; + const size_t fastStep = freeink::ed047tc2::kFastLutStep[range]; + const uint32_t* clean = freeink::ed047tc2::kCleanLut[range]; + const size_t cleanStep = freeink::ed047tc2::kCleanLutStep[range]; + // The grey columns of the fast bank are cut for these two levels in this range, + // so the canvas has to address exactly them. Reading both from the same table + // index is what keeps the two in step when the waveform is regenerated. + const uint8_t grayDark = freeink::grayLevelByte(freeink::ed047tc2::kGrayLevelDark[range]); + const uint8_t grayLight = freeink::grayLevelByte(freeink::ed047tc2::kGrayLevelLight[range]); -namespace freeink { + // Worth a line on the console: the drive length is the single number that + // decides how the panel looks, it is chosen once and never revisited, and a + // thermistor that reads high silently under-drives every transition. Without + // this there is no way to tell a waveform problem from a wrong temperature. + Serial.printf("[epd] panel %d C%s -> waveform range %u (%u..%u C), %u drive frames, AA greys at level %u/%u\n", + tempC, measuredOk ? "" : " (assumed, thermistor read failed)", static_cast(range), + freeink::ed047tc2::kTempRanges[range].minC, freeink::ed047tc2::kTempRanges[range].maxC, + freeink::ed047tc2::kDriveFrames[range], freeink::ed047tc2::kGrayLevelDark[range], + freeink::ed047tc2::kGrayLevelLight[range]); -const LgfxEpdConfig& lilygoT5S3LgfxConfig() { - static const LgfxEpdConfig cfg = { + // Each slot gets the waveform that matches how Panel_EPD drives it: the + // differential modes get the vendor DU table extended with the two AA grey + // columns, and the two modes that re-drive unchanged pixels get the charge + // neutral clean refresh. Filling all four keeps LovyanGFX's generic LUTs, + // which are tuned for a different panel, out of the picture entirely. + return { {EP_D0, EP_D1, EP_D2, EP_D3, EP_D4, EP_D5, EP_D6, EP_D7}, EP_STH, EP_STV, @@ -168,15 +228,31 @@ const LgfxEpdConfig& lilygoT5S3LgfxConfig() { 8, 0, {&prepareEpdPower, &epdPowerOn, &epdPowerOff}, - nullptr, - 0, - nullptr, - 0, - kFastLut, - sizeof(kFastLut) / sizeof(kFastLut[0]), - kFastLut, - sizeof(kFastLut) / sizeof(kFastLut[0]), + kUnusedLut, // lutQuality — write-only mode here; never refreshed with + 1, + clean, // lutText — Full/Half refreshes (the GC16-style blink) + cleanStep, + fast, // lutFast — page turns and the single-push AA + fastStep, + kUnusedLut, // lutFastest — unused by this stack + 1, + grayDark, + grayLight, + true, // grey columns live in the fast bank above }; +} + +} // namespace + +namespace freeink { + +const LgfxEpdConfig& lilygoT5S3LgfxConfig() { + // Built once, on the first call, which is the driver's begin(): LovyanGFX + // expands the LUTs into its own tables at panel init and never re-reads them, + // so the temperature the waveform is chosen for is the temperature at boot. + // The reader deep-sleeps between sessions and re-runs setup() on wake, so in + // practice the waveform tracks the ambient temperature session by session. + static const LgfxEpdConfig cfg = buildConfig(); return cfg; } diff --git a/libs/hardware/FrontlightManager/include/FrontlightManager.h b/libs/hardware/FrontlightManager/include/FrontlightManager.h index 101e3119..66e36c23 100644 --- a/libs/hardware/FrontlightManager/include/FrontlightManager.h +++ b/libs/hardware/FrontlightManager/include/FrontlightManager.h @@ -35,6 +35,18 @@ class FrontlightManager { void off(); void on(); + // Park the light for deep sleep: stop the PWM, then drive and LATCH the output + // pin(s) at the LED's inactive level (gpio_hold_en, so the level survives the + // sleep). Call it on the way into deep sleep, before the rails go down. + // + // Not the same as off(): off() writes a zero duty, but the pad is still owned + // by LEDC and sleep entry hands it to esp_sleep_config_gpio_isolate(), which + // floats it — what the light does then is down to an external pull, a + // board-layout detail. A frontlight left lit is the single largest load a + // sleeping reader can carry, so it is driven off rather than assumed off. + // begin() releases the hold on the next boot. + void prepareForDeepSleep(); + // Warm/cool mix, 0 = fully cool, 100 = fully warm, 50 = neutral. Only meaningful on a // two-channel board (hasColorTemperature()); a no-op on single-channel frontlights. void setColorTemperature(uint8_t warmPercent); @@ -106,6 +118,9 @@ class FrontlightManager { uint8_t _brightness = 0; uint8_t _brightnessLevel = 0; bool _useLevel = false; + // Duty written by the previous apply(); 0 = the light was off. The off->on + // edge is what arms the boost kick-start (see apply()). + uint32_t _lastTotalDuty = 0; uint8_t _lastBrightness = 50; uint8_t _warmPercent = 50; // neutral by default }; diff --git a/libs/hardware/FrontlightManager/src/FrontlightManager.cpp b/libs/hardware/FrontlightManager/src/FrontlightManager.cpp index 0cb64f9b..e933e190 100644 --- a/libs/hardware/FrontlightManager/src/FrontlightManager.cpp +++ b/libs/hardware/FrontlightManager/src/FrontlightManager.cpp @@ -3,8 +3,8 @@ #if FREEINK_CAP_FRONTLIGHT #include #include +#include // gpio_hold_en/dis in prepareForDeepSleep()/begin() #ifdef FREEINK_FRONTLIGHT_LS -#include #include // esp_sleep_sub_mode_config lives in a private IDF header (no public API exists // for balancing the refcounted RC_FAST keep-on the LEDC driver takes for @@ -111,9 +111,15 @@ void writeChannel(int8_t /*gpio*/, uint8_t ch, uint32_t duty) { ledc_set_duty(LEDC_LOW_SPEED_MODE, static_cast(ch), duty); ledc_update_duty(LEDC_LOW_SPEED_MODE, static_cast(ch)); } +void detachChannel(int8_t /*gpio*/, uint8_t ch) { + // Stop the timer output on this channel; the idle level it parks at is + // irrelevant because parkPinForSleep() takes the pad over immediately after. + ledc_stop(LEDC_LOW_SPEED_MODE, static_cast(ch), 0); +} #elif defined(ARDUINO) && ESP_ARDUINO_VERSION_MAJOR >= 3 bool attachChannel(int8_t gpio, uint8_t /*ch*/, uint32_t freq, uint8_t bits) { return ledcAttach(gpio, freq, bits); } void writeChannel(int8_t gpio, uint8_t /*ch*/, uint32_t duty) { ledcWrite(gpio, duty); } +void detachChannel(int8_t gpio, uint8_t /*ch*/) { ledcDetach(gpio); } #else bool attachChannel(int8_t gpio, uint8_t ch, uint32_t freq, uint8_t bits) { ledcSetup(ch, freq, bits); @@ -121,7 +127,34 @@ bool attachChannel(int8_t gpio, uint8_t ch, uint32_t freq, uint8_t bits) { return true; } void writeChannel(int8_t /*gpio*/, uint8_t ch, uint32_t duty) { ledcWrite(ch, duty); } +void detachChannel(int8_t gpio, uint8_t /*ch*/) { ledcDetachPin(gpio); } #endif + +// Release a pad hold left by a previous prepareForDeepSleep(). gpio_hold_en +// survives the deep-sleep wake reset, and a held pad silently ignores both the +// LEDC routing and any GPIO write — the light would never come back on. +void releasePinHold(int8_t gpio) { + if (gpio == BoardConfig::PIN_UNASSIGNED) return; + gpio_hold_dis(static_cast(gpio)); +} + +// Hand the pad from LEDC back to plain GPIO, drive the LED's INACTIVE level and +// latch it for deep sleep. Deliberately not left to +// esp_sleep_config_gpio_isolate(): an isolated pad floats, and what the light +// then does depends on an external pull that is a board-layout detail (the +// LilyGo T5 S3's PT4103 boost driver takes its EN through a 100R from this pin) +// — a lit frontlight is by far the largest load a sleeping reader can carry, so +// it gets driven off explicitly rather than assumed off. LEDC is stopped first +// or the peripheral output keeps overriding the GPIO write at the pad. +void parkPinForSleep(int8_t gpio, uint8_t ch, bool activeHigh) { + if (gpio == BoardConfig::PIN_UNASSIGNED) return; + detachChannel(gpio, ch); + const auto g = static_cast(gpio); + gpio_hold_dis(g); + pinMode(gpio, OUTPUT); + digitalWrite(gpio, activeHigh ? LOW : HIGH); + gpio_hold_en(g); +} } // namespace #endif @@ -162,6 +195,12 @@ void FrontlightManager::begin() { } if (fl.gpio == BoardConfig::PIN_UNASSIGNED) return; + // prepareForDeepSleep() latched these pads at the LED's off level, and the + // hold survives the wake reset. Release it before LEDC claims the pins, or the + // frontlight is dead after the first sleep/wake cycle. + releasePinHold(fl.gpio); + releasePinHold(fl.gpioWarm); + bool attachOk = attachChannel(fl.gpio, LEDC_CH_COOL, fl.pwmFrequency, fl.pwmResolutionBits); if (fl.gpioWarm != BoardConfig::PIN_UNASSIGNED) { attachOk = attachChannel(fl.gpioWarm, LEDC_CH_WARM, fl.pwmFrequency, fl.pwmResolutionBits) || attachOk; @@ -302,6 +341,19 @@ void FrontlightManager::apply() { } else if (!_useLevel) { totalDuty = perceptualDuty(_brightness, full); } + + // Boost-EN floor. On a light whose PWM gates a boost converter's EN pin, an + // on-time under the boost's start-up window produces NO light, so the dim + // end of any curve must land on the physical floor, not on one LSB -- that + // is how the plain gamma above blacked the light out at every ordinary + // reading brightness when it first shipped. Remap (0, full] onto + // [holdFloor, full]: the curve keeps its shape, 1% becomes the dimmest level + // the hardware can actually sustain, and boards with no floor configured are + // untouched (holdFloor == 0 leaves the identity mapping). + const uint32_t holdFloor = (full * fl.minHoldPermille + 500u) / 1000u; + if (totalDuty > 0 && holdFloor > 0) { + totalDuty = holdFloor + static_cast((static_cast(totalDuty) * (full - holdFloor)) / full); + } uint32_t warmDuty = 0; uint32_t coolDuty = totalDuty; if (dual) { @@ -311,6 +363,22 @@ void FrontlightManager::apply() { #ifdef FREEINK_FRONTLIGHT_LS updateLsKeepAlive(totalDuty != 0); #endif + + // Kick-start: a boost sustains below the duty it can ignite from. Turning on + // from dark into the hold band gets a brief burst at the ignition floor, then + // settles to the target -- which is what lets minHoldPermille sit below + // minStartPermille and 1% reach a level the boost could never start at. + const uint32_t startFloor = (full * fl.minStartPermille + 500u) / 1000u; + if (_lastTotalDuty == 0 && totalDuty > 0 && startFloor > 0 && totalDuty < startFloor) { + const uint32_t kickWarm = dual ? (startFloor * _warmPercent + 50u) / 100u : 0; + writeChannel(fl.gpio, LEDC_CH_COOL, physicalDuty(startFloor - kickWarm, full, fl.activeHigh)); + if (dual) { + writeChannel(fl.gpioWarm, LEDC_CH_WARM, physicalDuty(kickWarm, full, fl.activeHigh)); + } + delay(30); // one boost soft-start; the output cap carries the dip that follows + } + _lastTotalDuty = totalDuty; + writeChannel(fl.gpio, LEDC_CH_COOL, physicalDuty(coolDuty, full, fl.activeHigh)); if (dual) { @@ -354,6 +422,21 @@ void FrontlightManager::setBrightnessLevel(uint8_t level) { #endif } +void FrontlightManager::prepareForDeepSleep() { +#if FREEINK_CAP_FRONTLIGHT + const auto& fl = BoardConfig::ACTIVE.frontlight; + if (fl.viaPm1Pwm) { + // Paper Mono: the PWM lives in the M5PM1, so there is no ESP pad to park. + // Zero the duty (clearing the channel-enable bit) so the AW9967 is dark + // even if the EPD rail feeding it is still up when we sleep. + if (_begun) pm1FrontlightWrite(0); + return; + } + parkPinForSleep(fl.gpio, LEDC_CH_COOL, fl.activeHigh); + parkPinForSleep(fl.gpioWarm, LEDC_CH_WARM, fl.activeHigh); +#endif +} + void FrontlightManager::off() { setBrightness(0); } void FrontlightManager::on() { setBrightness(_lastBrightness); } diff --git a/libs/hardware/InputManager/src/InputManager.cpp b/libs/hardware/InputManager/src/InputManager.cpp index 219f0039..58f95eac 100644 --- a/libs/hardware/InputManager/src/InputManager.cpp +++ b/libs/hardware/InputManager/src/InputManager.cpp @@ -1693,6 +1693,13 @@ void InputManager::beginGt911() { Wire.setTimeOut(10); } + // Release any pad hold left on RESET by the sleep path (TouchConfig:: + // holdResetInSleep parks the controller in reset on boards with no touch + // rail). gpio_hold_en survives the deep-sleep wake reset, and a held pad + // silently swallows the reset dance below — touch would come back dead after + // the first wake, and only after a wake. + if (t.reset >= 0) gpio_hold_dis(static_cast(t.reset)); + auto resetWithIntLevel = [&](const uint8_t level) { if (t.reset < 0 || t.irq < 0) return; pinMode(t.irq, OUTPUT); @@ -1856,6 +1863,9 @@ void InputManager::beginFt6336u() { // Exact Murphy Reader reset timing: RESET low 50 ms, high 100 ms. // GPIO7 is shared with the display reset line on this board. if (t.reset >= 0) { + // Release a sleep-path hold first (TouchConfig::holdResetInSleep); it + // survives the wake reset and would make the pulse below a no-op. + gpio_hold_dis(static_cast(t.reset)); pinMode(t.reset, OUTPUT); digitalWrite(t.reset, LOW); delay(50); diff --git a/libs/hardware/PowerManager/include/PowerManager.h b/libs/hardware/PowerManager/include/PowerManager.h index 56224371..89b192c3 100644 --- a/libs/hardware/PowerManager/include/PowerManager.h +++ b/libs/hardware/PowerManager/include/PowerManager.h @@ -47,6 +47,19 @@ class PowerManager { // back-powering an unpowered controller) and HIGH when its rail remains on // (keeps deep-sleep state stable). NOTE: cutting the touch rail forfeits // touch-to-wake. + // + // Boards with NO gated rail are not left alone either, because there the + // peripheral simply stays powered and something has to quiet it: + // * Touch — the controller is parked in reset (RESET held asserted) on + // profiles that opt in via TouchConfig::holdResetInSleep. A GT911 left + // scanning costs several mA, dwarfing every other sleep load. + // * SD — chip-select is held DEASSERTED so a still-powered card idles + // deselected instead of floating into an undefined selection state. + // Every one of these holds is released again by the corresponding bring-up + // path (InputManager's touch reset, SDCardManager::begin(), + // BoardConfig::releaseSdRail()) — gpio_hold_en survives the wake reset, and a + // held pad silently swallows writes, so a missing release means dead hardware + // after the first sleep and only after a sleep. static void powerDownRailsForSleep(); // Isolate floating GPIOs to cut sleep current, then enter deep sleep. Does not diff --git a/libs/hardware/PowerManager/src/PowerManager.cpp b/libs/hardware/PowerManager/src/PowerManager.cpp index f3f8601d..62316936 100644 --- a/libs/hardware/PowerManager/src/PowerManager.cpp +++ b/libs/hardware/PowerManager/src/PowerManager.cpp @@ -85,7 +85,22 @@ void PowerManager::powerDownRailsForSleep() { // SD enable OFF = the inactive level: LOW for active-high enables, HIGH for the // active-low ones (e.g. X4 Pro's GPIO5, which powers the card while held LOW). holdRailOff(b.sd.powerEnable, b.sd.powerActiveHigh ? LOW : HIGH); + // With no rail to cut, the card stays powered through sleep, and + // esp_sleep_config_gpio_isolate() would leave its chip-select floating — an + // undefined selection state for a card that is still listening. Hold CS + // DEASSERTED (HIGH) instead, so it idles deselected. Skipped where the rail + // IS cut, for the same reason RESET is not held HIGH there: driving an input + // of an unpowered chip can back-power it through its protection diode. + // SDCardManager::begin() and BoardConfig::releaseSdRail() drop the hold. + if (b.sd.powerEnable < 0) holdRailOff(b.sd.cs, HIGH); holdRailOff(b.touch.powerEnable, b.touch.powerEnableActiveHigh ? LOW : HIGH); + // Boards with no touch rail have nothing to cut, so the digitizer would keep + // scanning all through deep sleep — a GT911 costs several mA there, which on + // its own is the difference between a milliamp-class and a microamp-class + // sleep. Park it in reset instead (asserted LOW). Opt-in per board: see + // TouchConfig::holdResetInSleep for why this is not inferred from a missing + // powerEnable. InputManager's touch bring-up releases the hold on wake. + if (b.touch.holdResetInSleep) holdRailOff(b.touch.reset, LOW); // The mic enable also carries a polarity flag; OFF is the inactive level. holdRailOff(b.mic.enable, b.mic.enableActiveHigh ? LOW : HIGH); } diff --git a/libs/hardware/SDCardManager/include/SDCardManager.h b/libs/hardware/SDCardManager/include/SDCardManager.h index 136442c5..3c7d5985 100644 --- a/libs/hardware/SDCardManager/include/SDCardManager.h +++ b/libs/hardware/SDCardManager/include/SDCardManager.h @@ -68,6 +68,18 @@ class SDCardManager { bool openFileForWrite(const char* moduleName, const String& path, FsFile& file); bool removeDir(const char* path); + // Unmount cleanly on the way into deep sleep. Flushes SdFat's FAT/directory + // cache and ends the card session, so a card that keeps its power through + // sleep is left idle in a state it defines, rather than mid-transaction with + // dirty cache. Call it AFTER the last write of the sleep sequence; the next + // boot re-mounts through begin() as usual (deep-sleep wake is a chip reset, + // so nothing here has to survive). + // + // Boards whose SD rail is switched off a moment later by + // PowerManager::powerDownRailsForSleep() want this too: an unmount before the + // power cut is strictly safer than yanking the rail mid-cache. + void prepareForSleep(); + // Optional board hook to bring up SD-card power before the card is mounted, for // boards whose SD rail isn't a plain GPIO (e.g. behind an I2C PMIC). Called once // at the start of begin(). The board registers it from its own board-support diff --git a/libs/hardware/SDCardManager/src/SDCardManager.cpp b/libs/hardware/SDCardManager/src/SDCardManager.cpp index 8a2af346..c7dc5925 100644 --- a/libs/hardware/SDCardManager/src/SDCardManager.cpp +++ b/libs/hardware/SDCardManager/src/SDCardManager.cpp @@ -65,6 +65,16 @@ bool SDCardManager::begin() { return initialized; } +void SDCardManager::prepareForSleep() { + if (!initialized) return; + _vol.end(); // flush the FAT/directory cache, then drop the mount + if (_dev) _dev->end(); + initialized = false; + cachedTotalBytes = 0; + cachedUsedBytes = 0; + cachedUsedBytesValid = false; +} + FsBlockDeviceInterface* SDCardManager::detachFilesystemForRawAccess() { if (!initialized || !_dev) return nullptr; _vol.end(); @@ -126,6 +136,12 @@ bool SDCardManager::begin() { digitalWrite(BoardConfig::ACTIVE.display.cs, HIGH); } + // The sleep path latches CS deasserted on boards with no SD rail, and the hold + // survives the deep-sleep wake reset. Release it before SdFat claims the pin, + // or CS is stuck HIGH and the card is never selected — mount fails on every + // boot that follows a sleep. + gpio_hold_dis(static_cast(SD_CS)); + // A board with a dedicated SD SPI bus (separateSpi) MUST NOT reconfigure the // global SPI object: that bus belongs to the display, and Arduino's second // SPI.begin() won't remap its pins, so the panel would end up transmitting on @@ -164,6 +180,15 @@ bool SDCardManager::begin() { return initialized; } + +void SDCardManager::prepareForSleep() { + if (!initialized) return; + sd.end(); // FsVolume::end() (flush FAT/dir cache) + card session end + initialized = false; + cachedTotalBytes = 0; + cachedUsedBytes = 0; + cachedUsedBytesValid = false; +} #endif bool SDCardManager::ready() const { diff --git a/tools/gen_ed047tc2_waveform.py b/tools/gen_ed047tc2_waveform.py new file mode 100644 index 00000000..06278b0e --- /dev/null +++ b/tools/gen_ed047tc2_waveform.py @@ -0,0 +1,520 @@ +#!/usr/bin/env python3 +"""Generate the ED047TC2 LovyanGFX waveform tables from the vendor waveform blob. + +Input is the epdiy-format vendor waveform header for the ED047TC2 panel used by +the LilyGo T5 S3 Pro (`epdiy_ED047TC2.h` / `ED047TC2.h` -- the two are the same +data under different symbol names). Output is +`libs/hardware/BoardT5S3/src/ED047TC2Waveform.cpp`. + + python tools/gen_ed047tc2_waveform.py path/to/epdiy_ED047TC2.h + +Vendor blob layout +------------------ +Each `epd_wp_ED047TC2___data[phases][16][4]` table holds, for one +draw mode and one temperature range, a 2-bit drive code per (destination level, +source level) pair per phase: + + code 0 = no drive, 1 = drive toward black, 2 = drive toward white + +The outer index of the 16 rows is the DESTINATION level; the 4 bytes of each row +hold the 16 SOURCE levels, four per byte, most significant pair first. Level 0 +is black, level 15 is white. + +Why the tables below are only two columns wide +---------------------------------------------- +Decoding the blob shows every mode/range is a separable impulse waveform: + + net_frames(to, from) == L[to] - L[from] + +with a zero diagonal, for one impulse vector L per temperature range shared by +all three modes (DU, GC16, GL16). The DU tables are exactly `L[15] + 1` phases +long and drive black->white as `L[15]` consecutive white frames (and white->black +as `L[15]` consecutive black frames), so DU reduces to a single number per +temperature range: the drive length. This script asserts all of that against the +blob before emitting anything, so a different or corrupted input fails loudly +rather than producing a plausible-looking wrong waveform. + +The emitted banks turn that vector into a single-push architecture: + +epd_fast/epd_fastest get the DU rails plus two SELF-NORMALIZING grey columns. +A LUT column is indexed by destination alone and cannot see where a pixel came +from, so a column that must land on a mid level from an arbitrary source first +saturates at a rail and then walks to its target: L[15] frames toward white +(any source becomes white -- the rail clamps), then L[15]-L[g] frames toward +black. One column, correct from every source state. This is what lets a page +carry its greys in the same push as its text: no separate B/W base pass exists +to pre-position the fringe, and none is needed. + +epd_text/epd_quality become a true GC16-style refresh: every one of the 16 +columns rail-normalizes (dark half to white, light half to black) and then +walks to its exact level. Destination-indexed yet source-independent, it both +scrubs residue and resets any accumulated DC bias, because the saturating rail +visit erases a pixel's drive history. + +Panel_EPD keeps one uint8_t block index per bank (lut_2pixel is addressed as +lindex >> 8), so the five banks together must stay within 255 rows. The +generator asserts that for every temperature range. + +""" + +import re +import sys +from pathlib import Path + +BLOB_RE = re.compile( + r"const uint8_t epd_wp_ED047TC2_(\d+)_(\d+)_data\[(\d+)\]\[16\]\[4\]\s*=\s*(.*?);", + re.S | re.I, +) +INTERVAL_RE = re.compile( + r"const EpdWaveformTempInterval ed047tc2_intervals\[(\d+)\]\s*=\s*(.*?);", re.S +) + +MODE_DU, MODE_GC16, MODE_GL16 = 1, 2, 5 + +# The vendor blob carries ranges 5..11 of the 14-entry interval table. +FIRST_RANGE = 5 +RANGE_COUNT = 7 + + +def parse(path): + text = Path(path).read_text(encoding="utf-8", errors="replace") + + tables = {} + for m in BLOB_RE.finditer(text): + mode, rng, phases = int(m.group(1)), int(m.group(2)), int(m.group(3)) + vals = [int(x, 16) for x in re.findall(r"0x([0-9a-fA-F]{2})", m.group(4))] + if len(vals) != phases * 16 * 4: + raise SystemExit(f"mode {mode} range {rng}: expected {phases*16*4} bytes, got {len(vals)}") + table = [] + for p in range(phases): + rows = [] + for r in range(16): + base = (p * 16 + r) * 4 + row = [] + for byte in vals[base : base + 4]: + row += [(byte >> (6 - 2 * k)) & 3 for k in range(4)] + rows.append(row) + table.append(rows) + tables[(mode, rng)] = table + + im = INTERVAL_RE.search(text) + if not im: + raise SystemExit("no ed047tc2_intervals table found") + nums = [int(x) for x in re.findall(r"\.(?:min|max)\s*=\s*(-?\d+)", im.group(2))] + intervals = list(zip(nums[0::2], nums[1::2])) + + return tables, intervals + + +def impulse_vector(table): + """Return L with L[0] == 0 such that net(to, from) == L[to] - L[from].""" + net = [[0] * 16 for _ in range(16)] + for phase in table: + for to in range(16): + for src in range(16): + code = phase[to][src] + if code == 1: + net[to][src] -= 1 + elif code == 2: + net[to][src] += 1 + elif code == 3: + raise SystemExit("unexpected drive code 3 in vendor blob") + return net, [net[to][0] - net[0][0] for to in range(16)] + + +def check(tables, intervals): + """Validate every structural assumption the generated tables rely on.""" + ranges = list(range(FIRST_RANGE, FIRST_RANGE + RANGE_COUNT)) + for mode in (MODE_DU, MODE_GC16, MODE_GL16): + for rng in ranges: + if (mode, rng) not in tables: + raise SystemExit(f"vendor blob is missing mode {mode} range {rng}") + if len(intervals) < FIRST_RANGE + RANGE_COUNT: + raise SystemExit("interval table too short for ranges 5..11") + + impulses = {} + for rng in ranges: + net_gc, l_gc = impulse_vector(tables[(MODE_GC16, rng)]) + # GC16 must be exactly separable with a zero diagonal. + for to in range(16): + for src in range(16): + if net_gc[to][src] != l_gc[to] - l_gc[src]: + raise SystemExit(f"range {rng}: GC16 is not separable at ({to},{src})") + if net_gc[to][to] != 0: + raise SystemExit(f"range {rng}: GC16 diagonal is not zero at {to}") + if l_gc != sorted(l_gc): + raise SystemExit(f"range {rng}: GC16 impulse vector is not monotonic: {l_gc}") + + # GL16 must agree with GC16 on the impulse vector. + _, l_gl = impulse_vector(tables[(MODE_GL16, rng)]) + if l_gl != l_gc: + raise SystemExit(f"range {rng}: GL16 impulse {l_gl} != GC16 impulse {l_gc}") + + # DU must be the two-level restriction of the same vector, run as one + # contiguous burst starting at phase 0, and nothing else. + du = tables[(MODE_DU, rng)] + drive = l_gc[15] + if len(du) != drive + 1: + raise SystemExit(f"range {rng}: DU has {len(du)} phases, expected {drive + 1}") + for to in range(16): + for src in range(16): + seq = [phase[to][src] for phase in du] + if to == 15 and src == 0: + want = [2] * drive + [0] + elif to == 0 and src == 15: + want = [1] * drive + [0] + elif to in (0, 15): + continue # intermediate sources: unused by a binarised canvas + else: + want = [0] * len(du) + if seq != want: + raise SystemExit(f"range {rng}: DU[{to}][{src}] = {seq}, expected {want}") + impulses[rng] = l_gc + return impulses + + +# Optical targets for the two AA greys, as a fraction of the impulse it takes to +# cross from black to white. The font buckets a glyph edge at 25-50% ink (its +# light grey) and 50-75% ink (its dark grey); these are those buckets' midpoints, +# so the panel reproduces the coverage the font quantiser measured. +# +# Do not reach for these to make text heavier or lighter -- that is what the +# reader's stroke weight setting is for, and it works by remapping which bucket +# gets which tone rather than by moving the tones themselves. Retune these only +# against a grey that reads as the wrong *shade* next to its neighbours. +GRAY_TARGET_DARK = 0.375 +GRAY_TARGET_LIGHT = 0.625 + +# A grey is only usable if it stays clear of white and clear of the other grey. +GRAY_MAX_FRACTION = 0.95 +GRAY_MIN_SEPARATION = 0.08 + +# Panel_EPD's per-bank offsets are uint8_t block indices, so all five banks +# (eraser + quality + text + fast + fastest) share a 255-row budget. +LUT_ROW_BUDGET = 255 +ERASER_ROWS = 3 # LovyanGFX lut_eraser: 2 drive rows + terminator +UNUSED_SLOT_ROWS = 1 # the board config stubs epd_quality/epd_fastest (see there) + +# The binding constraint is NOT the 255-row table: Panel_EPD stores per-pixel +# progress in uint16_t and flags fast modes with +0x8000, so a fast bank's +# STARTING block must be <= 127. Slot order is eraser, quality, text, fast, +# fastest; with quality stubbed the fast start is eraser + stub + clean. +FAST_START_BUDGET = 127 + + +def pick_gray_levels(impulse): + """Pick the (dark, light) canvas levels that best hit the targets in one range. + + The vendor impulse vector is not evenly spaced and its spacing changes with + temperature -- at 33..38 C levels 12..15 are all the same optical white, so a + level that reads as a good light grey when cold is no grey at all when warm. + Choosing per range is what keeps both greys real across the whole table. + + Runs of equal impulse make exact ties common (at 24..27 C levels 8 through 11 + are one optical step), so separation breaks them: of two pairs that score the + same, the one whose greys are further apart is the one you can actually tell + apart on the panel. + """ + white = impulse[15] + frac = [impulse[lv] / white for lv in range(16)] + + best = None + for dark in range(1, 15): + for light in range(dark + 1, 15): + if frac[light] > GRAY_MAX_FRACTION: + continue + sep = frac[light] - frac[dark] + if sep < GRAY_MIN_SEPARATION: + continue + err = (frac[dark] - GRAY_TARGET_DARK) ** 2 + (frac[light] - GRAY_TARGET_LIGHT) ** 2 + key = (round(err, 9), -sep) + if best is None or key < best[0]: + best = (key, dark, light) + if best is None: + raise SystemExit("no usable grey pair for impulse %s" % impulse) + return best[1], best[2] + + +def fast_rows(l15, dark, l_dark, light, l_light): + """The differential bank: DU rails plus two self-normalizing grey columns. + + Rails run the vendor DU verbatim: L[15] frames of full drive, then park. + Each grey column saturates at the white rail for the same L[15] frames -- + which erases whatever state the pixel arrived in, rail clamp doing the work + a source index would otherwise have to -- and then walks back down toward + black for L[15]-L[g] frames to land on its level. The walk-down phases park + the rails at no-op, so the bank is L[15] + max walk-down rows long. + """ + back_dark, back_light = l15 - l_dark, l15 - l_light + rows = [] + for f in range(l15 + max(back_dark, back_light)): + codes = [3] * 16 + if f < l15: + codes[0] = 1 + codes[15] = 2 + codes[dark] = 2 + codes[light] = 2 + else: + if f - l15 < back_dark: + codes[dark] = 1 + if f - l15 < back_light: + codes[light] = 1 + rows.append(" LUT_MAKE(%s)," % ", ".join(str(c) for c in codes)) + return rows + + +def clean_rows(impulse): + """The GC16-style clean bank for epd_text / epd_quality. + + Three phases, uniform across all 16 columns so the refresh reads as a + blink rather than as noise: every pixel drives to the black rail for L[15] + frames, then to the white rail for L[15], then walks down from white to its + exact level (L[15]-L[i] frames toward black). The double rail excursion is + the scrub -- it erases drive history and accumulated DC bias -- and the + final descent lands every level, greys included, precisely. + + An earlier cut of this bank sent each pixel to the rail OPPOSITE its + destination and back, which scrubbed and landed just as well but showed the + old page fading THROUGH the new page inverted, both at once: on a real + device the cadence refresh read as a screenful of garbage before the text + resolved. Same physics, reordered for the eye watching it. + """ + l15 = impulse[15] + rows = [] + for _ in range(l15): + rows.append(" LUT_MAKE(%s)," % ", ".join(["1"] * 16)) + for _ in range(l15): + rows.append(" LUT_MAKE(%s)," % ", ".join(["2"] * 16)) + for f in range(l15): + codes = ["1" if f < l15 - impulse[i] else "3" for i in range(16)] + rows.append(" LUT_MAKE(%s)," % ", ".join(codes)) + return rows + + +HEADER = '''// GENERATED FILE -- DO NOT EDIT BY HAND. +// +// Regenerate with: +// python tools/gen_ed047tc2_waveform.py +// +// Source: the ED047TC2 vendor waveform in epdiy form -- LilyGo ships it as +// Waveform_header/ED047TC2.h in Xinyuan-LilyGO/LilyGo-EPD47, epdiy as +// epdiy_ED047TC2.h; the two are the same data under different symbol names. +// See tools/gen_ed047tc2_waveform.py for the blob layout and for the structural +// checks the generator runs against it. +// +// The vendor waveform is a separable impulse waveform: a transition from source +// level `f` to destination level `t` needs `L[t] - L[f]` frames of drive, toward +// white when positive and toward black when negative, where L is the per +// temperature range impulse vector below (level 0 is black, 15 is white): +// +{impulse_comment} +// +// Both emitted banks are SELF-NORMALIZING: a LovyanGFX LUT column is indexed by +// destination alone, so any column that must land on a mid level from an +// arbitrary source first saturates at a rail -- the clamp erases the pixel\'s +// history -- and then walks to its target. That is what lets a page carry its +// anti-aliasing greys in the same push as its text, with no separate B/W base +// pass to pre-position the fringe. +// +// kFastLut (epd_fast / epd_fastest): the vendor DU rails verbatim, plus a grey +// column per AA tone that spends L[15] frames at the white rail and then +// descends L[15]-L[g] frames to its level. Panel_EPD\'s per-pixel diff means a +// pixel is only driven when its target changes, so a fringe pixel that stays +// the same grey across a page turn is not driven at all -- which is also the +// charge story: drives happen on content changes only, each one begins with a +// saturating rail visit, and the rail visit resets whatever DC bias the pixel +// had accumulated. +// +// kCleanLut (epd_text / epd_quality): a GC16-style refresh. Every one of the 16 +// columns rail-normalizes (dark half to white, light half to black) and then +// walks to its exact level, so the periodic clean page both scrubs residue and +// re-lands every grey precisely. The previous bank drove each level away and +// symmetrically back, which is only a correct landing for the two rails; any +// grey pushed through it ended on a rail. +// +// Which levels carry the greys is decided per temperature range, because the +// vendor vector is not evenly spaced and its spacing moves with temperature: at +// 33..38 C levels 12 through 15 are all the same optical white. The board config +// reads kGrayLevelDark/kGrayLevelLight for the range it selected the LUT for and +// writes the matching canvas bytes, so the two cannot drift apart. +// +{gray_comment} +// +// Keeping the drive length matched to the panel temperature IS the temperature +// compensation: e-ink particles move more slowly when cold, so a cold panel +// needs a longer push for the same optical result. +// +// Panel_EPD addresses its expanded LUT with uint8_t block indices, so all five +// banks together (eraser + quality + text + fast + fastest) must fit in 255 +// rows. Worst case here: {worst_rows} rows. + +#include + +namespace freeink {{ +namespace ed047tc2 {{ + +namespace {{ + +// One frame of a LovyanGFX Panel_EPD LUT: sixteen 2-bit drive codes, indexed by +// the destination level. 0 ends the sequence, 1 drives toward black, 2 drives +// toward white, 3 is a no-op that keeps the sequence running. +#define LUT_MAKE(d0, d1, d2, d3, d4, d5, d6, d7, d8, d9, da, db, dc, dd, de, df) \\ + (uint32_t)((d0 << 0) | (d1 << 2) | (d2 << 4) | (d3 << 6) | (d4 << 8) | (d5 << 10) | \\ + (d6 << 12) | (d7 << 14) | (d8 << 16) | (d9 << 18) | (da << 20) | \\ + (db << 22) | (dc << 24) | (dd << 26) | (de << 28) | (df << 30)) + +''' + +FOOTER = '''#undef LUT_MAKE + +}} // namespace + +const TempRange kTempRanges[kTempRangeCount] = {{ +{ranges} +}}; + +// The canvas levels the AA grey columns are cut for, per temperature range. A +// board config turns these into the grey bytes its canvas writes; they are not +// independently tunable -- retarget them in tools/gen_ed047tc2_waveform.py and +// regenerate, or the canvas will address a column the LUT does not drive. +const uint8_t kGrayLevelDark[kTempRangeCount] = {{{gray_dark_levels}}}; + +const uint8_t kGrayLevelLight[kTempRangeCount] = {{{gray_light_levels}}}; + +const uint32_t* const kFastLut[kTempRangeCount] = {{ +{fast_ptrs} +}}; + +const size_t kFastLutStep[kTempRangeCount] = {{ +{fast_steps} +}}; + +const uint32_t* const kCleanLut[kTempRangeCount] = {{ +{clean_ptrs} +}}; + +const size_t kCleanLutStep[kTempRangeCount] = {{ +{clean_steps} +}}; + +const uint8_t kDriveFrames[kTempRangeCount] = {{{drive_frames}}}; + +size_t tempRangeIndex(int tempC) {{ + for (size_t i = 0; i < kTempRangeCount; ++i) {{ + if (tempC < kTempRanges[i].maxC) return i; + }} + return kTempRangeCount - 1; +}} + +}} // namespace ed047tc2 +}} // namespace freeink +''' + + +def main(): + if len(sys.argv) != 2: + raise SystemExit(__doc__) + tables, intervals = parse(sys.argv[1]) + impulses = check(tables, intervals) + + ranges = list(range(FIRST_RANGE, FIRST_RANGE + RANGE_COUNT)) + drives = [impulses[r][15] for r in ranges] + grays = {r: pick_gray_levels(impulses[r]) for r in ranges} + + impulse_comment = "\n".join( + "// {:>2}..{:<2} C L = [{}]".format( + intervals[r][0], intervals[r][1], ", ".join("%2d" % v for v in impulses[r]) + ) + for r in ranges + ) + gray_comment = "\n".join( + "// {:>2}..{:<2} C dark = level {:>2} ({:>3.0f}% to white)" + " light = level {:>2} ({:>3.0f}% to white)".format( + intervals[r][0], + intervals[r][1], + grays[r][0], + 100.0 * impulses[r][grays[r][0]] / impulses[r][15], + grays[r][1], + 100.0 * impulses[r][grays[r][1]] / impulses[r][15], + ) + for r in ranges + ) + + # One bank pair per distinct (vector, grey pair); dedup by content. + fast_banks = {} # rows-tuple -> name + clean_banks = {} + fast_of = {} + clean_of = {} + worst_rows = 0 + for idx, r in enumerate(ranges): + L = impulses[r] + d, l = grays[r] + fr = tuple(fast_rows(L[15], d, L[d], l, L[l])) + cr = tuple(clean_rows(L)) + fast_of[r] = fast_banks.setdefault(fr, "kFastR%d" % idx) + clean_of[r] = clean_banks.setdefault(cr, "kCleanR%d" % idx) + # +1 per bank for the terminator row Panel_EPD copies too. Quality and + # fastest are stubbed in the board config, so they cost one row each. + total = ERASER_ROWS + 2 * UNUSED_SLOT_ROWS + (len(cr) + 1) + (len(fr) + 1) + worst_rows = max(worst_rows, total) + if total > LUT_ROW_BUDGET: + raise SystemExit( + "range %d: %d LUT rows exceeds Panel_EPD's %d-row budget" + % (r, total, LUT_ROW_BUDGET) + ) + fast_start = ERASER_ROWS + UNUSED_SLOT_ROWS + (len(cr) + 1) + if fast_start > FAST_START_BUDGET: + raise SystemExit( + "range %d: fast bank starts at block %d > %d -- the 0x8000 fast" + " flag would overflow Panel_EPD's uint16 step words and blank" + " the display" % (r, fast_start, FAST_START_BUDGET) + ) + + body = HEADER.format( + impulse_comment=impulse_comment, gray_comment=gray_comment, worst_rows=worst_rows + ) + for rows, name in fast_banks.items(): + body += "// Differential bank: DU rails + self-normalizing AA grey columns.\n" + body += "constexpr uint32_t %s[] = {\n%s\n 0u,\n};\n\n" % (name, "\n".join(rows)) + for rows, name in clean_banks.items(): + body += "// GC16-style clean: every level rail-normalizes, then lands exactly.\n" + body += "constexpr uint32_t %s[] = {\n%s\n 0u,\n};\n\n" % (name, "\n".join(rows)) + + body += FOOTER.format( + ranges="\n".join( + " {%d, %d}," % (intervals[r][0], intervals[r][1]) for r in ranges + ), + gray_dark_levels=", ".join(str(grays[r][0]) for r in ranges), + gray_light_levels=", ".join(str(grays[r][1]) for r in ranges), + fast_ptrs="\n".join(" %s," % fast_of[r] for r in ranges), + fast_steps="\n".join( + " sizeof(%s) / sizeof(%s[0])," % (fast_of[r], fast_of[r]) for r in ranges + ), + clean_ptrs="\n".join(" %s," % clean_of[r] for r in ranges), + clean_steps="\n".join( + " sizeof(%s) / sizeof(%s[0])," % (clean_of[r], clean_of[r]) for r in ranges + ), + drive_frames=", ".join(str(d) for d in drives), + ) + + out = ( + Path(__file__).resolve().parent.parent + / "libs/hardware/BoardT5S3/src/ED047TC2Waveform.cpp" + ) + out.write_text(body, encoding="utf-8") + print("wrote", out) + for idx, r in enumerate(ranges): + L = impulses[r] + d, l = grays[r] + print( + " range %2d %2d..%-2d C drive=%2d fast=%s(%d rows) clean=%s(%d rows) greys=lvl %d/%d" + % ( + r, intervals[r][0], intervals[r][1], L[15], + fast_of[r], len(fast_rows(L[15], d, L[d], l, L[l])), + clean_of[r], len(clean_rows(L)), d, l, + ) + ) + + +if __name__ == "__main__": + main()