diff --git a/modules/drivers/gdey037t03/gdey037t03.c b/modules/drivers/gdey037t03/gdey037t03.c
new file mode 100644
index 000000000..7307ddf2a
--- /dev/null
+++ b/modules/drivers/gdey037t03/gdey037t03.c
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2026 Joshua Jun
+ *
+ * This file is part of the Moddable SDK Runtime.
+ *
+ * The Moddable SDK Runtime is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Moddable SDK Runtime is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with the Moddable SDK Runtime. If not, see .
+ *
+ */
+
+#include "xsmc.h"
+#include "xsHost.h"
+
+// Geometry must match gdey037t03.js: logical 416 x 240, native 240 x 416.
+#define STRIDE 52 // logical row bytes (416 >> 3)
+#define NATIVE_STRIDE 30 // native row bytes (240 >> 3)
+#define NATIVE_H 416
+
+// transpose(logicalBuffer, outBuffer): native(nx, ny) = logical(ny, nx).
+// Each native output byte packs 8 logical columns at one native row.
+void xs_gdey037t03_transpose(xsMachine *the)
+{
+ uint8_t *logical, *out;
+ xsUnsignedValue logicalLen, outLen;
+
+ xsmcGetBufferReadable(xsArg(0), (void **)&logical, &logicalLen);
+ xsmcGetBufferWritable(xsArg(1), (void **)&out, &outLen);
+ if ((logicalLen < STRIDE * 240) || (outLen < NATIVE_STRIDE * NATIVE_H))
+ xsUnknownError("buffer too small");
+
+ for (int ny = 0; ny < NATIVE_H; ny++) {
+ int lShift = 7 - (ny & 7);
+ const uint8_t *src = logical + (ny >> 3);
+ uint8_t *orow = out + ny * NATIVE_STRIDE;
+ for (int nb = 0; nb < NATIVE_STRIDE; nb++) {
+ uint8_t byte = 0;
+ if ((src[0] >> lShift) & 1) byte |= 0x80;
+ if ((src[STRIDE] >> lShift) & 1) byte |= 0x40;
+ if ((src[STRIDE * 2] >> lShift) & 1) byte |= 0x20;
+ if ((src[STRIDE * 3] >> lShift) & 1) byte |= 0x10;
+ if ((src[STRIDE * 4] >> lShift) & 1) byte |= 0x08;
+ if ((src[STRIDE * 5] >> lShift) & 1) byte |= 0x04;
+ if ((src[STRIDE * 6] >> lShift) & 1) byte |= 0x02;
+ if ((src[STRIDE * 7] >> lShift) & 1) byte |= 0x01;
+ orow[nb] = byte;
+ src += STRIDE << 3; // advance nx by 8
+ }
+ }
+}
diff --git a/modules/drivers/gdey037t03/gdey037t03.js b/modules/drivers/gdey037t03/gdey037t03.js
new file mode 100644
index 000000000..5133a162b
--- /dev/null
+++ b/modules/drivers/gdey037t03/gdey037t03.js
@@ -0,0 +1,307 @@
+/*
+ * Copyright (c) 2026 Joshua Jun
+ *
+ * This file is part of the Moddable SDK Runtime.
+ *
+ * The Moddable SDK Runtime is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Moddable SDK Runtime is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with the Moddable SDK Runtime. If not, see .
+ *
+ */
+
+/*
+ Datasheet: https://v4.cecdn.yun300.cn/100001_1909185148/GDEY037T03-new.pdf
+
+ The panel's pixel array is 240 x 416 with a reversed gate scan. This driver
+ presents the GDEY037T03's advertised orientation (416 x 240 landscape, upright)
+ so software uses it at rotation 0 with no flip: the framebuffer is transposed
+ into the native array on output, which also absorbs the gate-scan reversal.
+*/
+
+import Timer from "timer";
+import Bitmap from "commodetto/Bitmap";
+import Dither from "commodetto/Dither";
+
+const WIDTH = 416;
+const HEIGHT = 240;
+const STRIDE = WIDTH >> 3;
+const NATIVE_W = HEIGHT;
+const NATIVE_H = WIDTH;
+const NATIVE_STRIDE = NATIVE_W >> 3;
+const FRAME_BYTES = STRIDE * HEIGHT;
+
+// Native transpose (gdey037t03.c): native(nx, ny) = logical(ny, nx). Done in C
+// because the per-pixel bit loop over the whole frame is far too slow in script.
+function transpose(logical, out) @ "xs_gdey037t03_transpose";
+
+class EPD {
+ constructor() {
+ const Digital = device.io.Digital;
+ const SPI = device.io.SPI;
+
+ this.select = new Digital({
+ pin: device.pin.epdSelect,
+ mode: Digital.Output,
+ initialValue: 1,
+ });
+ this.dc = new Digital({
+ pin: device.pin.epdDC,
+ mode: Digital.Output,
+ initialValue: 1,
+ });
+ this.reset = new Digital({
+ pin: device.pin.epdReset,
+ mode: Digital.Output,
+ initialValue: 1,
+ });
+ this.busy = new Digital({ pin: device.pin.epdBusy, mode: Digital.Input });
+
+ this.spi = new SPI({ ...device.SPI.default, hz: 10_000_000, mode: 0 });
+ this.spi.byte = new Uint8Array(1);
+ }
+ close() {
+ this.select?.close();
+ this.dc?.close();
+ this.reset?.close();
+ this.busy?.close();
+ this.spi?.close();
+ this.select = this.dc = this.reset = this.busy = this.spi = undefined;
+ }
+
+ writeCMD(cmd) {
+ this.select.write(0);
+ this.dc.write(0);
+ this.spi.byte[0] = cmd;
+ this.spi.write(this.spi.byte);
+ this.select.write(1);
+ }
+ writeData(value) {
+ this.select.write(0);
+ this.dc.write(1);
+ this.spi.byte[0] = value;
+ this.spi.write(this.spi.byte);
+ this.select.write(1);
+ }
+ writeBuffer(buffer) {
+ this.select.write(0);
+ this.dc.write(1);
+ this.spi.write(buffer);
+ this.select.write(1);
+ }
+
+ isBusy() {
+ return !this.busy.read(); // LOW (0) = busy
+ }
+ waitBusy(ms = 5000) {
+ const deadline = Date.now() + ms;
+ do {
+ if (!this.isBusy()) return;
+ Timer.delay(1);
+ } while (Date.now() < deadline);
+ throw new Error("UC8253 BUSY timeout");
+ }
+
+ reset_() {
+ this.reset.write(0);
+ Timer.delay(10);
+ this.reset.write(1);
+ Timer.delay(10);
+ }
+
+ initFull() {
+ // EPD_Init — cleanest, flashes
+ this.reset_();
+ this.writeCMD(0x04);
+ this.waitBusy();
+ this.writeCMD(0x50);
+ this.writeData(0x97);
+ }
+ initFast() {
+ // EPD_Init_Fast — ~1.5 s
+ this.reset_();
+ this.writeCMD(0x04);
+ this.waitBusy();
+ this.writeCMD(0xe0);
+ this.writeData(0x02);
+ this.writeCMD(0xe5);
+ this.writeData(0x5f);
+ }
+ initPart() {
+ // EPD_Init_Part — no flash
+ this.reset_();
+ this.writeCMD(0x04);
+ this.waitBusy();
+ this.writeCMD(0xe0);
+ this.writeData(0x02);
+ this.writeCMD(0xe5);
+ this.writeData(0x6e);
+ this.writeCMD(0x50);
+ this.writeData(0xd7);
+ }
+
+ #refresh() {
+ this.writeCMD(0x12);
+ Timer.delay(1); // >= 200us before polling BUSY
+ this.waitBusy();
+ }
+ #powerOff() {
+ this.writeCMD(0x02);
+ this.waitBusy();
+ }
+
+ display(oldFrame, newFrame) {
+ this.writeCMD(0x10);
+ this.writeBuffer(oldFrame);
+ this.writeCMD(0x13);
+ this.writeBuffer(newFrame);
+ this.#refresh();
+ this.#powerOff();
+ }
+
+ displayPartial(x, y, w, h, oldWin, newWin) {
+ const xEnd = x + w - 1,
+ yEnd = y + h - 1;
+ this.writeCMD(0x91); // partial in
+ this.writeCMD(0x90); // window setting
+ this.writeData(x);
+ this.writeData(xEnd);
+ this.writeData(y >> 8);
+ this.writeData(y & 0xff);
+ this.writeData(yEnd >> 8);
+ this.writeData(yEnd & 0xff);
+ this.writeData(0x01);
+ this.writeCMD(0x10);
+ this.writeBuffer(oldWin);
+ this.writeCMD(0x13);
+ this.writeBuffer(newWin);
+ this.#refresh();
+ this.writeCMD(0x92); // partial out
+ this.#powerOff();
+ }
+
+ deepSleep() {
+ this.writeCMD(0x02);
+ this.waitBusy();
+ this.writeCMD(0x07);
+ this.writeData(0xa5);
+ }
+}
+
+class Display {
+ // Commodetto PixelsOut
+ #epd;
+ #current = new Uint8Array(FRAME_BYTES).fill(0xff); // logical frame being drawn (0xFF = white)
+ #previous = new Uint8Array(FRAME_BYTES).fill(0xff); // last displayed native frame (0x10 "old data")
+ #dither = new Dither({ width: WIDTH });
+ #mode = "full"; // full-refresh waveform: "full" (clean) or "fast"
+ #wantFull = true; // next update is a full (de-ghosting) refresh
+
+ constructor(options = {}) {
+ this.#epd = new EPD();
+ if (options.mode) this.#mode = options.mode;
+ }
+ close() {
+ if (this.#epd) {
+ this.#epd.deepSleep();
+ this.#epd.close();
+ this.#epd = undefined;
+ }
+ this.#dither?.close?.();
+ this.#dither = this.#current = this.#previous = undefined;
+ }
+ configure(options) {
+ if (undefined !== options?.refresh) this.#wantFull = options.refresh;
+ if (undefined !== options?.mode) this.#mode = options.mode;
+ if (undefined !== options?.dither) {
+ this.#dither.close();
+ let algorithm = options.dither;
+ if (false === algorithm) algorithm = "none";
+ else if (true === algorithm) algorithm = undefined;
+ this.#dither = new Dither({ width: WIDTH, algorithm });
+ }
+ }
+
+ begin(x, y, width, height) {
+ this.#current.position = 0;
+ this.#dither.reset();
+ }
+ send(pixels, offset = 0, byteLength = pixels.byteLength) {
+ const buffer = this.#current;
+ this.#dither.send(
+ Math.idiv(byteLength, WIDTH),
+ pixels,
+ offset,
+ buffer,
+ buffer.position,
+ );
+ buffer.position += byteLength >> 3;
+ }
+ end() {
+ const epd = this.#epd;
+ const next = this.#toNative(this.#current);
+ if (this.#wantFull) {
+ // full refresh: redraws every pixel, clears ghosting, flashes
+ "fast" === this.#mode ? epd.initFast() : epd.initFull();
+ epd.display(this.#previous, next);
+ this.#wantFull = false;
+ } else {
+ // partial waveform over the whole screen: no flash
+ epd.initPart();
+ epd.displayPartial(0, 0, NATIVE_W, NATIVE_H, this.#previous, next);
+ }
+ this.#previous = next;
+ }
+ continue() {
+ return this.end();
+ }
+ adaptInvalid(area) {
+ area.x = 0;
+ area.y = 0;
+ area.w = WIDTH;
+ area.h = HEIGHT;
+ }
+
+ // Force a full (de-ghosting) refresh of the current image right now.
+ refresh() {
+ const epd = this.#epd;
+ "fast" === this.#mode ? epd.initFast() : epd.initFull();
+ epd.display(this.#previous, this.#previous);
+ this.#wantFull = false;
+ }
+
+ get width() {
+ return WIDTH;
+ }
+ get height() {
+ return HEIGHT;
+ }
+ get pixelFormat() {
+ return Bitmap.Gray256;
+ }
+ get async() {
+ return false;
+ }
+ pixelsToBytes(count) {
+ return count;
+ }
+
+ // Transpose a full logical (W x H) frame into the native (H x W) panel array,
+ // absorbing the panel's reversed gate scan so the advertised landscape
+ // orientation comes out upright. The work is done natively (see gdey037t03.c).
+ #toNative(logical) {
+ const out = new Uint8Array(NATIVE_STRIDE * NATIVE_H);
+ transpose(logical.buffer, out.buffer);
+ return out;
+ }
+}
+
+export default Display;
diff --git a/modules/drivers/gdey037t03/manifest.json b/modules/drivers/gdey037t03/manifest.json
new file mode 100644
index 000000000..41ddf0e30
--- /dev/null
+++ b/modules/drivers/gdey037t03/manifest.json
@@ -0,0 +1,12 @@
+{
+ "modules": {
+ "gdey037t03": "$(MODULES)/drivers/gdey037t03/gdey037t03",
+ "commodetto/Dither": "$(COMMODETTO)/commodettoDither",
+ "commodetto/Bitmap": "$(COMMODETTO)/commodettoBitmap"
+ },
+ "preload": [
+ "gdey037t03",
+ "commodetto/Dither",
+ "commodetto/Bitmap"
+ ]
+}