{
    "archive_path": "archive/1789423628.614962",
    "base_url": "www.serpentine.com/posts/2026/x3-stripes",
    "basename": "",
    "bookmarked_date": "2026-09-14 22:07",
    "canonical": {
        "archive_org_path": "https://web.archive.org/web/www.serpentine.com/posts/2026/x3-stripes",
        "dom_path": "output.html",
        "favicon_path": "favicon.ico",
        "git_path": "git/",
        "google_favicon_path": "https://www.google.com/s2/favicons?domain=www.serpentine.com",
        "headers_path": "headers.json",
        "htmltotext_path": "htmltotext.txt",
        "index_path": "index.html",
        "media_path": "media/",
        "mercury_path": "mercury/content.html",
        "pdf_path": "output.pdf",
        "readability_path": "readability/content.html",
        "screenshot_path": "screenshot.png",
        "singlefile_path": "singlefile.html",
        "warc_path": "warc/",
        "wget_path": "www.serpentine.com/posts/2026/x3-stripes/index.html"
    },
    "domain": "www.serpentine.com",
    "extension": "",
    "hash": "H9JXJA98EF9C3AS3TRE7",
    "history": {
        "archive_org": [],
        "dom": [],
        "favicon": [
            {
                "cmd": [
                    "curl",
                    "--silent",
                    "--location",
                    "--compressed",
                    "--max-time",
                    "120",
                    "--output",
                    "favicon.ico",
                    "--user-agent",
                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ArchiveBox/0.7.4 (+https://github.com/ArchiveBox/ArchiveBox/) curl/curl 8.14.1 (x86_64-pc-linux-gnu)",
                    "https://www.google.com/s2/favicons?domain=www.serpentine.com"
                ],
                "cmd_version": "curl 8.14.1 (x86_64-pc-linux-gnu)",
                "end_ts": "2026-09-14T22:07:09.893881+00:00",
                "index_texts": null,
                "output": "favicon.ico",
                "pwd": "/data/archive/1789423628.614962",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-14T22:07:09.378538+00:00",
                "status": "succeeded"
            }
        ],
        "git": [],
        "headers": [
            {
                "cmd": [
                    "curl",
                    "--silent",
                    "--location",
                    "--compressed",
                    "--head",
                    "--max-time",
                    "120",
                    "--user-agent",
                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ArchiveBox/0.7.4 (+https://github.com/ArchiveBox/ArchiveBox/) curl/curl 8.14.1 (x86_64-pc-linux-gnu)",
                    "https://www.serpentine.com/posts/2026/x3-stripes/"
                ],
                "cmd_version": "curl 8.14.1 (x86_64-pc-linux-gnu)",
                "end_ts": "2026-09-14T22:07:10.204178+00:00",
                "index_texts": null,
                "output": "headers.json",
                "pwd": "/data/archive/1789423628.614962",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-14T22:07:09.903373+00:00",
                "status": "succeeded"
            }
        ],
        "htmltotext": [],
        "media": [],
        "mercury": [
            {
                "cmd": [
                    "/app/node_modules/@postlight/parser/cli.js",
                    "https://www.serpentine.com/posts/2026/x3-stripes/"
                ],
                "cmd_version": "1.0.0",
                "end_ts": "2026-09-14T22:07:16.297933+00:00",
                "index_texts": null,
                "output": "mercury",
                "pwd": "/data/archive/1789423628.614962",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-14T22:07:13.364712+00:00",
                "status": "succeeded"
            }
        ],
        "pdf": [],
        "readability": [
            {
                "cmd": [
                    "/app/node_modules/readability-extractor/readability-extractor",
                    "/tmp/tmp0cz6e1v3",
                    "https://www.serpentine.com/posts/2026/x3-stripes/"
                ],
                "cmd_version": "0.0.11",
                "end_ts": "2026-09-14T22:07:13.349209+00:00",
                "index_texts": [
                    "I\u2019ve been a lifelong reader, which has proved a fragile habit in our era of endless trivial\ndistraction. While books and e-ink readers solve the problem of competition for attention, they\ncan\u2019t compete with a phone for convenience. That tiny device in my pocket has made reading both\neasier and more vulnerable to displacement whenever a notification pops up.A few days ago, I learned for the first time about a recent generation of tiny e-ink readers.\nSince they\u2019re cheap, it was easy to give into curiosity: I bought an Xteink\nX3. Not only is it astonishingly tiny, I could immediately\ninstall the delightful open-source\nCrossPoint firmware. I was very\nquickly able to install a few books. I also appreciated being able to install custom fonts,\nthough I was a little surprised by their indifferent rendering (foreshadowing\u2026).CrossPoint is very configurable out of the box, so I converted a photo to a dithered black and\nwhite bitmap as my \u201cdevice sleep\u201d screen, and was pleased with how pretty this looked. When I\nread that the device supported 4 whole shades of grey, I was intrigued: would a greyscale image\nlook better?Down the rabbit hole: first image bugsThis revealed what looked like a bug: the sleep screen was displaying greyscale images with very\nmurky dark areas. Either my middle-aged eyes were finally failing me, or was dark grey rendering\nas black? I created a quick test image and verified that dark grey really was black, while\nlight grey was extremely pale (almost white). Mildly annoying, but hardly unexpected on a\ncheap device, and easily worked around: let\u2019s create a three-tone image!With less of the regenerated image containing large regions of black, I now saw a new bug: in\nthe CrossPoint viewer app, the prior screen contents were still present in ghostly form (only on\npaler parts of the display, hence me failing to notice the first time around). However, on the\nsame image, the sleep screen didn\u2019t suffer from this problem. This suggested that there were\ntwo image renderers making different decisions, and the viewer app\u2019s code was buggy.In both cases, though, the photograph had distinctive vertical stripes across it that were not\npresent in the bitmap file.A phone photo of my tri-tone image. The fine vertical stripes are easiest to see in the background.Since I knew next to nothing about e-ink, ESP32 development, or CrossPoint, I started\ninvestigating in the usual late-2026 way, using GPT-6 Astra in Codex. I would capture the X3\u2019s\nscreen on my phone and drop images into my Codex session.An e-ink screen uses voltage pulses to move black and white pigment particles, which stay in\nplace after the power is removed. An incomplete update or insufficient voltage can leave ghosted\ntraces of the prior image behind.To display a greyscale image, CrossPoint first draws a black-and-white base in which even the\ngrey pixels start out black. It then runs a short voltage-pulse waveform to move selected pixels\npartway towards white. This second stage, a \u201cnudge,\u201d produces dark and light shades of grey by\ndriving those pixels for different amounts of time.Astra found that the viewer did a fast black-and-white update and simply stopped, without ever\nperforming the grey nudge. It quickly fixed the problem.The stripes proved much more stubborn. Astra initially flailed, blaming the Floyd\u2013Steinberg\ndithering it had used to prepare my sleep picture. A different algorithm made no difference. It\nthen followed a lead down the stack, and flagged the nudge waveform as worth investigating.But we were struggling to agree on what artifact we were even looking at or measuring, which\nconcerned me; I didn\u2019t want to burn state-of-the-art tokens chasing phantoms. When I pushed,\nAstra dug in and reported a stripe pattern two pixels wide. That didn\u2019t make any sense to me, so\nI asked it to annotate the photograph, and found that it had picked out fine dither texture\ninstead of the bands I could see across the image.What Astra's FFT foundUsing a fast Fourier transform (FFT) here was quite clever: it\u2019s an almost ideal tool to pick\nout and quantify repeating patterns that are hard to measure by eye. Astra applied a\ntwo-dimensional FFT to small patches of the photograph and the source image, and found a strong\nrepeat at roughly two screen pixels in both.Unfortunately, error-diffusion dithering produces structure of its own, by its nature often\nhigh-frequency noise that creates a strong signal in an FFT. Astra had picked out the fine dot\npattern of Floyd\u2013Steinberg, the very algorithm it had chosen to prepare the image and blamed\nearly in the investigation. The broader bands I was complaining about appeared only on the\nreader. When I challenged its estimate, it made this annotation, which confirmed that we were\nlooking at different patterns.Astra\u2019s annotation of the fine dither texture behind its two-pixel estimate.Making the stripes measurableSlightly frazzled by Astra\u2019s hypotheses that were going nowhere, I switched to Fable 5.1 in\nClaude Code for another perspective.I had a hunch that the width of the stripes meant something, but even identifying the stripes\nhad eluded Astra. And this isn\u2019t easy, as a lot of sources introduce patterns and noise:The image\u2019s own dither pattern that had tripped up AstraWhatever was introducing the stripesThe X3 screen\u2019s own physical characteristicsA handheld phone photo of this mess:Sensor noiseVariable focus within an imageLens distortion (these have to be macro shots to capture the 259ppi screen)Lighting and exposure variations, processing artifactsMotion blur from my shaky handsWarned away from Astra\u2019s naive image processing dead end, Fable wrote code to average the\nbrightness down each column. For the view below, it used a sliding window 200 rows tall: each\npoint became the average of a short vertical strip around it. This averaged away the dither\ntexture, while a brightness difference that persisted down a column would survive. Broad shapes\nin the picture remained, but the stripes became much easier to see:A crop after applying the sliding vertical average. The broad shapes belong to the photograph; the fine vertical bands are the defect.Fable now used an FFT on a one-dimensional brightness profile to measure the spacing and\nstrength of the vertical pattern. Its first guesstimate put the stripes roughly seven screen\npixels apart, but this was based on a guess of my photograph\u2019s scale.It returned its attention to dithering, this time inside the firmware, proposing that repeated\nrounding errors could line up to produce the vertical bands. When I mentioned that my source\nimage was already dithered, it became more excited, but this ended up being a 20-minute false\nlead. Another investigation involved Fable getting worked up over the bit depth of an image, but\nthis too led nowhere. At least it was being more novel in its investigations than Astra?What was different about grey?I had also been investigating much simpler images on the device. Removing either the grey or the\npattern made the stripes disappear:ImageGrey pixels presentNeighbouring pixels in different statesStripesFlat grey fieldyesnononeBlack-and-white dithernoyesnoneGrey dither, using either of two methodsyesyesyesThe specific combination of grey pixels with neighbours of a different shade was what caused\ntrouble. Fable matched small blocks of the source image to the phone photograph, which finally\nallowed it to see that light-grey pixels carried the stripe, an important detail that hadn\u2019t\neven been clear to me due to the very pale tone of light-grey pixels.This evidence now pointed towards how the screen produced grey, via the greyscale nudge. The\ncode for this lives in freeink-sdk, the hardware\nlibrary CrossPoint uses.Fable was initially reluctant to go further: \u201cI cannot design or validate a LUT change from\nhere.\u201d A LUT is the lookup table holding the nudge waveform. When I pointed out that the X3 was\non my desk, and I could photograph whatever a new build displayed, it came back with experiments\nwe could run.The X3\u2019s odd choice of a magnetic contact charger worked in our favour here. Fable could flash\nand reboot the device over USB while it was plugged in. I could then pick the device up to\nphotograph the screen and put it back down without ever needing to fiddle with a USB-C\nconnector.A test pattern, and a second bugThe existing nudge lasted seven scan cycles: each time, the controller worked through the\npanel\u2019s rows, applying the next step of the voltage sequence to each pixel. We still thought the\nstripe spacing was about seven pixels. Could the timing be showing up as a spatial pattern?\nChanging the waveform\u2019s duration would give us something to compare.First we needed a better image to measure, so I suggested to Fable that it should generate a\ntest image. It created a pattern with flat patches at all four shades, mixtures of grey with\nblack or white, a checkerboard, and lines running in both directions.The test pattern. Flat patches establish the four shades; the patterned areas test how grey behaves beside other shades. The checkerboard is at the right of the third row.The test image made progress dramatically easier. Fable knew how the image should look, so\nvariations in my photos and the screen\u2019s appearance became possible to see and account for. For\nexample, Fable\u2019s original scale estimate had mistaken a feature in the photograph\u2019s spectrum for\nthe screen\u2019s pixel grid. With the test pattern as a ruler, the stripe period turned out to be\neight pixels, rather than seven.I supplied raw DNG files from my phone as well as processed photographs. Comparing the two\nshowed that the phone\u2019s processing exaggerated the stripe amplitude by about 70 percent and\nshifted the apparent grey levels. We used raw files after that. Fable worked out how to locate\nthe test patches despite changes in framing, perspective, and lens distortion, so it could\nmeasure each build and photo in the same way.We tried stretching the nudge from seven frames to ten, then a version that interleaved drive\npulses with rests. Neither affected the stripes. This ruled out the proposed connection between\nthe frame count and the stripe period.Three, then four, shades of greyThe test pattern also brought back a problem we\u2019d worked around earlier.The test pattern under the original waveform. The first two patches in the top row should be black and dark grey. Both are black. The dark-grey-on-black patch at the left of the third row has disappeared too.Our supposedly four-shade reader was definitely displaying three shades; this wasn\u2019t just my\naging eyes. Dark grey was black. I was lucky enough to be watching Fable doing its thinking when\nit found this, because it only reported this as an offhand drive-by comment. Understanding the\nsignificance of its finding, I had to jump in, stop it, and get it to dig in deeper.Fable traced this to a disagreement between CrossPoint and the driver about how to request dark\ngrey. CrossPoint sends two bits per pixel for the nudge, selecting one of four waveform tables.\nIts code for dark grey selected a table that did nothing. The intended dark-grey drive was\nsitting in a different table. Fable fixed the mistake and issued a PR against freeink-sdk.With that fixed, dark grey appeared, and the patch that should have been dark grey dithered on\nblack became a visible speckle instead of a solid black square. But the light-grey drive hadn\u2019t\nchanged, and neither had the stripes. We had opportunistically fixed another bug while building\nthe test for the first one.The missing shade had also been making text look rough (remember that mention of indifferent\ntext rendering?). CrossPoint anti-aliases its text, so pixels intended to soften the edges with\ndark grey had been coming out black, yielding chonky text. Restoring that shade improved text\nthroughout the reader.A slower way to draw a pictureThe driver contained another waveform we hadn\u2019t tried: the manufacturer\u2019s four-grey image\nmode, called XTH4. It used a much longer sequence of pulses to produce the four shades, taking\nroughly a second to refresh. That would be an annoying delay on every page turn, but seemed\nreasonable for opening a picture or drawing the sleep screen.The table was already in freeink-sdk, and another reader\u2019s driver used a version of it for\nimages. We could simply try it on the X3 without inventing a waveform from scratch. Fable was\nnot at all sure this would achieve anything, but I urged it to forge ahead.There was a memory problem to solve first. The nudge only needs to distinguish dark grey,\nlight grey, and \u201cleave this pixel alone\u201d. Black and white can share that last instruction\nbecause the first pass has already drawn them. The longer waveform needs separate\ninstructions for all four shades.The other reader\u2019s driver solved this by keeping a copy of the black-and-white image in RAM and\ncombining it with the grey data. My X3 uses an ESP32-C3 with about 380 kilobytes of RAM, and its\nlargest free block was only 53 kilobytes. At 528 by 792 pixels, even a one-bit copy of the\nscreen would consume 52 kilobytes. There wouldn\u2019t be room to casually add another buffer.Fable proposed having CrossPoint draw the data in the required format to begin with. The image\nviewer already decoded the file once for each rendering pass; those passes could produce two\nbits per pixel that identified all four shades. The driver would receive what it needed without\nanother screenful of data.With this all implemented and flashed, I opened the image that had kicked off the investigation,\nand told Fable: \u201cThe stripes ARE NO LONGER visible in my original why are there stripes?\nimage.\u201dThe photograph on my X3, before and after. Below the line it uses the original grey waveform; above it uses the new one.The measurements agreed. The column variation dropped from roughly four percent of the\nblack-to-white range to one percent. The peak at eight pixels disappeared entirely from the\nfrequency spectrum.Column brightness relative to the black-to-white range, with gridlines every eight screen pixels. The regular oscillation in the upper plot disappears in the lower oneThe longer waveform also had the happy side effect of improving the shades themselves.\nRestoring dark grey had given us four levels; this also pulled light grey further from white\n(remember my comments about it being very pale?). Dark grey is a bit darker than I\u2019d like, so\nthere\u2019s still some tuning to do.Measured shades under the original firmware, after restoring dark grey, and under the longer waveform. The dotted lines mark evenly spaced brightness levels for comparison.Do we get closure?So this is a half-satisfying investigation: replacing the code fixed the problem, but do we\nreally know why it occurred in the first place? Not quite.Here is my best guess, based on my newly acquired and still very limited knowledge of\nelectrophoretic displays. The rows of an e-ink panel are switched by a gate driver etched into\nthe glass. A handful of interleaved clocks drive the gates, each opening every nth row for a\nslightly different interval of time. A particle being driven hard to black or white is\ninsensitive to timing variation. A pixel nudged partway to grey over three frames gets only the\ncharge its row\u2019s time window allows, so every eighth row comes out a little lighter or darker.When a column is charged, this happens one row at a time, and its voltage only has to move when\nconsecutive rows are different shades. If a grey pixel follows a black one, that voltage swing\nhas to complete inside our brief and variable time window. A flat grey field never swings, while\na black-and-white swings, but the optical response saturates, so timing doesn\u2019t matter so much.\nOnly when we have a grey dither do we see a swing and sensitivity to timing, and that\u2019s when\nwe get our stripes.Do I know that this is true? No, but it fits the electrical architecture and the evidence I\nhave. I could have pursued this further, but I was relieved to have non-stripy imagery.A happy pixelated endingThe driver change became freeink-sdk#95. Both\nof my freeink-sdk pull requests were merged within hours. I submitted a couple of CrossPoint PRs\ntoo. Amusingly, someone else gazumped me there, by merging an almost identical set of changes\nright before mine got reviewed.I find the timescale of all this a little ridiculous. A few hours after unboxing my first e-ink\ndevice, I was testing fixes to its display waveforms. Astra and Fable handled unfamiliar code,\nbuild tools, and measurement scripts, while I could concentrate on what the experiments ought\nto do, provide major hints like use of FFTs and test patterns, quibble and steer around\ninvestigative dead ends, and share what the screen actually showed.This experience of being able to jump into the unknown and immediately make progress was quite\nexciting. Even though I didn\u2019t know much about e-ink, CrossPoint, or ESP32 in the beginning, I\nlearned a lot within hours. It\u2019s definitely whetted my appetite for more of this sort of\nlow-level device hacking."
                ],
                "output": "readability",
                "pwd": "/data/archive/1789423628.614962",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-14T22:07:11.639222+00:00",
                "status": "succeeded"
            }
        ],
        "screenshot": [],
        "singlefile": [],
        "title": [
            {
                "cmd": [
                    "curl",
                    "--silent",
                    "--location",
                    "--compressed",
                    "--max-time",
                    "120",
                    "--user-agent",
                    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ArchiveBox/0.7.4 (+https://github.com/ArchiveBox/ArchiveBox/) curl/curl 8.14.1 (x86_64-pc-linux-gnu)",
                    "https://www.serpentine.com/posts/2026/x3-stripes/"
                ],
                "cmd_version": "curl 8.14.1 (x86_64-pc-linux-gnu)",
                "end_ts": "2026-09-14T22:07:11.631727+00:00",
                "index_texts": null,
                "output": "How my e-reader lost its stripes | teideal glic deisbh\u00e9alach",
                "pwd": "/data/archive/1789423628.614962",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-14T22:07:11.617178+00:00",
                "status": "succeeded"
            }
        ],
        "wget": [
            {
                "cmd": [
                    "wget",
                    "--no-verbose",
                    "--adjust-extension",
                    "--convert-links",
                    "--force-directories",
                    "--backup-converted",
                    "--span-hosts",
                    "--no-parent",
                    "-e",
                    "robots=off",
                    "--timeout=120",
                    "--restrict-file-names=windows",
                    "--warc-file=/data/archive/1789423628.614962/warc/1789423630",
                    "--page-requisites",
                    "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 ArchiveBox/0.7.4 (+https://github.com/ArchiveBox/ArchiveBox/) wget/GNU Wget 1.21.3",
                    "--compression=auto",
                    "https://www.serpentine.com/posts/2026/x3-stripes/"
                ],
                "cmd_version": "GNU Wget 1.21.3",
                "end_ts": "2026-09-14T22:07:11.608336+00:00",
                "index_texts": null,
                "output": "www.serpentine.com/posts/2026/x3-stripes/index.html",
                "pwd": "/data/archive/1789423628.614962",
                "schema": "ArchiveResult",
                "start_ts": "2026-09-14T22:07:10.213915+00:00",
                "status": "succeeded"
            }
        ]
    },
    "icons": null,
    "is_archived": true,
    "is_static": false,
    "latest": {
        "archive_org": null,
        "dom": null,
        "favicon": "favicon.ico",
        "git": null,
        "media": null,
        "pdf": null,
        "screenshot": null,
        "singlefile": null,
        "title": "How my e-reader lost its stripes | teideal glic deisbh\u00e9alach",
        "warc": null,
        "wget": "www.serpentine.com/posts/2026/x3-stripes/index.html"
    },
    "link_dir": "/data/archive/1789423628.614962",
    "newest_archive_date": "2026-09-14T22:07:13.364712+00:00",
    "num_failures": 0,
    "num_outputs": 6,
    "oldest_archive_date": "2026-09-14T22:07:09.378538+00:00",
    "path": "/posts/2026/x3-stripes/",
    "schema": "Link",
    "scheme": "https",
    "snapshot_id": "c3e74ea9-21a8-4304-b191-c6b68ad97b55",
    "sources": [
        "/data/sources/1789423628-import.txt"
    ],
    "tags": null,
    "tags_str": "",
    "timestamp": "1789423628.614962",
    "title": "How my e-reader lost its stripes | teideal glic deisbh\u00e9alach",
    "updated": "2026-09-14T22:07:09.378128+00:00",
    "updated_date": "2026-09-14 22:07",
    "url": "https://www.serpentine.com/posts/2026/x3-stripes/"
}