From c9b42e71344492083c4aeb6ca2d07fdf9480e21e Mon Sep 17 00:00:00 2001 From: jetaudio Date: Sat, 22 Aug 2026 23:32:03 +0700 Subject: [PATCH 1/5] feat(t5s3): fix the power latch, add the boost-frontlight and sleep fields The T5 S3 Pro / Pro Lite profile carried `power.latch0 = GPIO2` with an M5Paper-style "main-power MOSFET, hold HIGH or the board dies on USB unplug" note. GPIO2 is nothing of the sort here: the T5 E-paper S3 Pro schematic 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, its /QON pin wired to the S4 button straight to ground. holdPowerRails() drives latch pins OUTPUT HIGH first thing in setup(), so the stale entry had the firmware fighting the RTC's output on every alarm. Nothing had noticed while no alarm was armed; now that the RTC is wired up, 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 to compare against.) The rest is new configuration for the two commits that follow: * TouchConfig::holdResetInSleep -- park the digitizer in reset through deep sleep on boards with no switched touch rail. Opt-in rather than inferred from `powerEnable < 0`, because a pin named "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. * FrontlightConfig::minStartPermille / minHoldPermille -- boost-driver floors, zero (and inert) on boards whose LEDs hang off the PWM pin. * The frontlight itself moves to 1 kHz / 12-bit. What the PT4103 needs is a minimum ON-TIME, not a minimum duty (~10 us lit, ~4.4 us did not), so the PWM period sets how dim the light can go: at 5 kHz the 5 us hold floor was 25 permille -- already ~18% of perceived full brightness, and 1% -> 2% moved the pulse by 0.2 us, under the threshold where the boost's output changes at all. At 1 kHz the same on-times are 10 and 5 permille, so 1% lands five times dimmer and each LSB is 244 ns instead of 49 ns. 1 kHz is also 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. 13-bit at the old 5 kHz is what failed the attach outright -- the Arduino-3 LEDC path auto-picks its clock and 5 kHz x 2^13 = 40.96 MHz overran the 40 MHz XTAL, leaving the light completely dead. Also documents, at input.power, that IO48 must not be mapped as Confirm: on the Pro Lite it reads spurious active-low pulses, which opened the reader menu by itself. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau --- .../BoardConfig/include/BoardConfig.h | 89 ++++++++++++++++--- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/libs/hardware/BoardConfig/include/BoardConfig.h b/libs/hardware/BoardConfig/include/BoardConfig.h index cfd78d22..1d139fab 100644 --- a/libs/hardware/BoardConfig/include/BoardConfig.h +++ b/libs/hardware/BoardConfig/include/BoardConfig.h @@ -517,6 +517,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). @@ -541,6 +556,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; }; // Audio output description (AudioOutput::None disables it). @@ -701,7 +727,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, @@ -1129,13 +1158,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, @@ -1150,15 +1206,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 @@ -1701,6 +1763,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); } From 2b13c2156a77d3619128ebb6e3861b858f815a16 Mon Sep 17 00:00:00 2001 From: jetaudio Date: Sat, 22 Aug 2026 23:32:16 +0700 Subject: [PATCH 2/5] feat(sleep): quiet a still-powered touch controller and SD card powerDownRailsForSleep() only ever cut rails. A board with no gated rail was left alone entirely -- so on the T5 S3 Pro the GT911 kept scanning all through deep sleep, several mA on its own, the difference between a milliamp-class and a microamp-class sleep. Two holds fill that gap, both only where there is no rail to cut: * Touch parks in reset (asserted LOW) on profiles that opt in via TouchConfig::holdResetInSleep. Opt-in rather than inferred from a missing powerEnable, because a board may have other reasons to leave RESET alone. * SD chip-select is held DEASSERTED (HIGH), so a card that keeps its power idles deselected instead of floating into an undefined selection state once esp_sleep_config_gpio_isolate() runs. Neither is applied where the rail IS cut, for the same reason RESET is not driven HIGH there: driving an input of an unpowered chip can back-power it through its protection diode. gpio_hold_en survives the deep-sleep wake reset, and a held pad silently swallows writes, so every hold needs a release on the way back up: InputManager's GT911 and FT6336U bring-up now call gpio_hold_dis() on the reset pin before the reset dance, which would otherwise be a no-op and leave touch dead after the first wake -- and only after a wake. SDCardManager::prepareForSleep() also unmounts cleanly before sleep, flushing SdFat's FAT/directory cache rather than leaving the card mid-transaction. Boards that cut the SD rail want this too: unmounting before the power cut beats yanking the rail with the cache dirty. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau --- .../InputManager/src/InputManager.cpp | 10 ++++++++ .../PowerManager/include/PowerManager.h | 13 ++++++++++ .../PowerManager/src/PowerManager.cpp | 15 +++++++++++ .../SDCardManager/include/SDCardManager.h | 12 +++++++++ .../SDCardManager/src/SDCardManager.cpp | 25 +++++++++++++++++++ 5 files changed, 75 insertions(+) diff --git a/libs/hardware/InputManager/src/InputManager.cpp b/libs/hardware/InputManager/src/InputManager.cpp index 24b091f1..cad2ebb7 100644 --- a/libs/hardware/InputManager/src/InputManager.cpp +++ b/libs/hardware/InputManager/src/InputManager.cpp @@ -1464,6 +1464,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); @@ -1627,6 +1634,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 1e7bb56f..01766b55 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)); + if (SD_SCLK >= 0 && SD_MOSI >= 0 && SD_MISO >= 0) { SPI.begin(SD_SCLK, SD_MISO, SD_MOSI, SD_CS); } @@ -147,6 +163,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 { From b0dc794b839ed3998a8ff20a48ad0da9edfb3c75 Mon Sep 17 00:00:00 2001 From: jetaudio Date: Sat, 22 Aug 2026 23:32:41 +0700 Subject: [PATCH 3/5] feat(t5s3): drive the ED047TC2 with its own waveform and one-push grayscale The T5 S3 Pro panel was running on LovyanGFX's stock LUTs, whose fast bank drives only the two rails. Anti-aliasing greys had nowhere to land, so Panel_EPD's _draw_pixels() Bayer-dithered them to black -- hard speckle along every glyph edge -- and a clean refresh left pixels behind because the two-level waveform never flashed both ways. This adds a real ED047TC2 waveform, generated by tools/gen_ed047tc2_waveform.py and matched to the panel temperature read from the on-board thermistor, plus the driver plumbing it needs: * LgfxEpdConfig::grayDark / grayLight, because the canvas byte a board should write for a grey is a property of its LUT, not a constant. Panel_EPD quantises a canvas byte as (v + bayer - 8) >> 4, so only v == (level << 4) | 8 lands on one level for every Bayer cell; anything else alternates between two and shows as speckle. grayLevelByte() builds a safe value. Boards that do not set these keep the even thirds the driver has always written. * LgfxEpdConfig::grayNudgeInFastBank, true when the board's fast bank carries grey columns, which lets the grayscale push go out through the differential bank: no lut_eraser flash, and the same bank the B/W base used, so the per-pixel diff still skips what did not change. Left false for boards on the stock LUTs -- they keep exactly the push they have today. * displayGrayscaleFrame() / supportsGrayFrame(): a page and its greys reach the panel as one waveform. The two-push flow it replaces existed to normalize fringe pixels to black before a from-black grey nudge; the fast bank's grey columns are self-normalizing, so the base pass has nothing left to do and the page has no intermediate state to show. It also halves the drive imbalance per page turn, since a fringe pixel no longer swings black-to-grey twice. Falls back to a plain B/W displayBuffer() where the driver cannot do it, or where output is inverted (the planes' meaning does not survive inversion). * settleDisplay(): waitDisplay() alone can return before the queued refresh has begun. Panel_EPD's display() raises _display_busy, yields, and only then posts the job; the yield lets the panel task reach the top of its loop and assign _display_busy = remain -- false on an idle panel -- clearing the flag the caller just raised. A caller that trusts it walks into the panel task's diff copy and tears it, and torn step state is how `remain` never clears and the next waitDisplay() blocks forever, the reader frozen with input still alive. Yielding before the wait lets the task ingest the job and re-raise the flag. Grey-level selection also breaks ties on separation rather than loop order, and the fast bank starts under block 128, without which the display came up blank. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau --- .../FreeInkDisplay/include/FreeInkDisplay.h | 6 + .../FreeInkDisplay/include/LgfxEpdConfig.h | 33 + .../FreeInkDisplay/src/FreeInkDisplay.cpp | 12 + .../src/driver/LgfxEpdDriver.cpp | 116 ++- .../FreeInkDisplay/src/driver/LgfxEpdDriver.h | 6 + .../FreeInkDisplay/src/driver/PanelDriver.h | 14 + .../BoardT5S3/include/ED047TC2Waveform.h | 64 ++ .../BoardT5S3/src/ED047TC2Waveform.cpp | 846 ++++++++++++++++++ .../BoardT5S3/src/LilyGoT5S3LgfxConfig.cpp | 154 +++- tools/gen_ed047tc2_waveform.py | 520 +++++++++++ 10 files changed, 1724 insertions(+), 47 deletions(-) create mode 100644 libs/hardware/BoardT5S3/include/ED047TC2Waveform.h create mode 100644 libs/hardware/BoardT5S3/src/ED047TC2Waveform.cpp create mode 100644 tools/gen_ed047tc2_waveform.py diff --git a/libs/display/FreeInkDisplay/include/FreeInkDisplay.h b/libs/display/FreeInkDisplay/include/FreeInkDisplay.h index 075a2a4e..de7ac1a5 100644 --- a/libs/display/FreeInkDisplay/include/FreeInkDisplay.h +++ b/libs/display/FreeInkDisplay/include/FreeInkDisplay.h @@ -244,6 +244,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 0f120008..eff07f5e 100644 --- a/libs/display/FreeInkDisplay/src/FreeInkDisplay.cpp +++ b/libs/display/FreeInkDisplay/src/FreeInkDisplay.cpp @@ -770,6 +770,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 6213623b..8d773648 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/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/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() From 62fb798874a5e2a857be54298247db7d13d8c764 Mon Sep 17 00:00:00 2001 From: jetaudio Date: Sat, 22 Aug 2026 23:33:01 +0700 Subject: [PATCH 4/5] feat(frontlight): dimming that works from 1% on a boost-EN light, and off in sleep On the T5 S3 Pro the PWM does not drive the LEDs directly -- it gates the EN pin of a PT4103 boost converter. That breaks two assumptions the manager was making. Dimming. A boost produces NO light for an on-time under its start-up window, so the dim end of a plain gamma curve lands below the physical floor and the light simply goes out -- which is what blacked it at ordinary reading brightnesses. Two board-supplied numbers fix it: minHoldPermille remaps (0, full] onto [floor, full], keeping the curve's shape while making 1% the dimmest level the hardware can sustain; and minStartPermille gives the off->on edge a brief burst at the ignition floor before settling to the target, since a boost sustains below the duty it can ignite from. Boards that configure neither are untouched -- zero leaves the identity mapping. The PWM also runs at 1 kHz with a 12-bit duty here; 13 bits made the attach fail outright, and the higher carrier put 1% below the boost's start-up window again. Deep sleep. off() writes a zero duty, but the pad still belongs to 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 (here, EN through a 100R). A frontlight left lit is by far the largest load a sleeping reader can carry, so prepareForDeepSleep() stops the PWM, drives the pin to the LED's inactive level and latches it with gpio_hold_en. begin() releases the hold on the next boot, without which the light would be dead after the first sleep/wake cycle -- a held pad silently ignores both the LEDC routing and any GPIO write. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau --- .../include/FrontlightManager.h | 15 ++++ .../src/FrontlightManager.cpp | 85 ++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/libs/hardware/FrontlightManager/include/FrontlightManager.h b/libs/hardware/FrontlightManager/include/FrontlightManager.h index f2e856d2..e3cd8a82 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); @@ -86,6 +98,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 090f0eec..820aad8b 100644 --- a/libs/hardware/FrontlightManager/src/FrontlightManager.cpp +++ b/libs/hardware/FrontlightManager/src/FrontlightManager.cpp @@ -2,8 +2,8 @@ #if FREEINK_CAP_FRONTLIGHT #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 @@ -110,9 +110,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); @@ -120,7 +126,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 @@ -135,6 +168,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; @@ -186,6 +225,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) { @@ -195,6 +247,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) { @@ -238,6 +306,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); } From f1291dbf7979e78cb9e1653cf2968f55e06e3514 Mon Sep 17 00:00:00 2001 From: jetaudio Date: Sat, 22 Aug 2026 23:34:59 +0700 Subject: [PATCH 5/5] docs(t5s3): fold the board's new behaviour into the support notes The RTC and the capacitive home key were listed as board peripherals the SDK does not cover; both are in the profile now. Also records where the ED047TC2 waveform comes from, why the grey canvas bytes are a board property, the boost-EN frontlight floor, and the touch-reset hold that deep sleep uses on a board with no touch rail. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMRDM2sLYU4smqRpWH6fau --- docs/lilygo-t5s3-support.md | 42 ++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) 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.