Skip to main content

The emulator

The emulator replaces only the firmware: it hands the real event loop the same bytes the reader would, keeps the panel as an image, and lets you drive the app from a web page or from a script that saves screenshots.

Testing on the reader is slow: build, plug in, copy, eject, open the app, tap, squint. And the reader cannot run a test suite. Yet most of Inkshelf (its screens, its layout, its servers) does not care whether the pixels end up on e-ink. The emulatorEmulator: A stand-in for the reader in a web page on your computer: the real event loop and screens, with a fake firmware. runs all of it on your computer, as it runs on the reader.

A flight simulator keeps the real cockpit, the real instruments and the real pilot, and fakes only the outside world. The emulator keeps the real app and the real event loop, and fakes only the firmware and the glass.

One seam: the firmware interface​

Everything the event loop and the screen need from the reader goes through one small Go interface, firmware, with eight methods:

internal/device/pocketbook/firmware.go
type firmware interface {
flush(canvas *image.Gray, rect image.Rectangle, waveform Waveform, full bool)
visible() bool
describe() string
nextHardwareEvent() ([]byte, bool)
nextTaskMessage() ([]byte, bool)
nextReply(id int32) ([]byte, bool)
send(message []byte) bool
battery() (percent int, charging, ok bool)
}

On the reader, readerFirmware implements it with system calls; it lives in files ending in _linux_arm.go, which Go builds only for the reader. Elsewhere, pocketbook.Run just fails, and pocketbook.RunEmulated runs the very same loop on an Emulator instead.

The key choice: messages cross the interface as raw bytes, in the firmware's own layouts. A click in the emulator is encoded by encodeHardwareEvent into the same 32 bytes the firmware would post, with the raw coordinates the touch panel would report (rawTouch, the inverse of touchPoint). So the event loop's decoding, rotation, dead zone and long-press timing all run for real, and are tested for real.

InteractiveWhat changes, what stays
Stays the same
internal/app
the screens, the state, the servers: the same code
event loop
event_loop.go, touch.go, keys.go: the same code
firmware interface
flush, visible, nextHardwareEvent, nextTaskMessage, nextReply, send, battery
Swapped
Behind the interface
readerFirmware
firmware_linux_arm.go: syscalls on the real firmware’s queues and shared memory
The panel
/dev/fb0 + epdc.ko
copyRotated, then the MXCFB ioctl; a GL16 page turn takes about 490 ms
A touch
a finger on the glass
the firmware reads /dev/input and posts 32 bytes on /hwevent
Buttons
Back, Forward, Menu
key press and release messages on /hwevent
Home, Wi-Fi, USB
the firmware’s task events
on the SysV task queue, with the whiteboard for who is in front
A tap at (700, 1048), as the event loop receives it
1e 00 00 00 | 00 00 00 00 | 37 03 00 00 | bc 02 00 00 | ff 00 00 00 | 01 00 00 00 | 00 00 00 00 | 00 00 00 00 type 30 (down) | finger 0 | raw x 823 | raw y 700 | pressure | tool | time (unused) ↑ written by the firmware: the same layout either way, and the loop reads only the first four numbers.
Try this: Switch between the reader and the emulator. Only the bottom boxes change; the bytes of the tap do not.

A panel that behaves like e-ink​

The emulator's panel is an image kept apart from the canvas. It changes only on a refresh, like the real one: an area the app drew but forgot to refresh stays stale on screen, exactly the bug you want to catch before deploying.

On each refresh, flush rounds every pixel to the grays the waveform can show: 16 for GC16 and GL16, 4 for GC4 and DU4, 2 (black and white) for DU and A2. A screen refreshed with DU looks as harsh in the emulator as on the reader.

With -eink, refreshes also take as long as on an InkPad 3: 600 ms for a full refresh, 490 ms for a partial GL16 or GC16, 265 ms for DU. The emulator waits for every refresh, while the reader only waits when one overlaps the last: it shows the worst case.

Interactive: a reader in your browser​

make emu-fake runs the app against built-in fake servers and serves a page at http://localhost:8740. The page talks to a small HTTP server in cmd/emulator:

RequestWhat it does
GET /the page itself
GET /panel.pngwhat the panel shows now, as a PNG
GET /refreshesa stream (server-sent events) of every refresh: rectangle, waveform, full or not
POST /pointera touch: down, move or up at x, y
POST /keya button press or release: prev, next, menu, ok, back…
POST /controlhome, resume, battery, net-up, net-down, exit

A click is a tap, holding is a long press, dragging is a swipe. [ and ] are the page buttons, m is Menu, Enter is OK, Backspace is Back. The page can outline each refresh with its waveform, which makes it easy to see what a tap actually redraws.

Headless: scripts and screenshots​

With -script FILE, there is no page. A script drives the app, one command per line, and shot NAME saves the panel as a PNG:

CommandWhat it does
tap X Y, long X Y, swipe X1 Y1 X2 Y2touches, in screen pixels
key NAME, hold NAME DURATIONbuttons
home, resumeto the background and back
net up, net down, battery PERCENT [charging]firmware news
wait DURATION, idle [TIMEOUT]sleep; wait until the app is idle
shot NAMEwait until idle, then save OUT/NAME.png
exitask the app to exit, as the firmware does

A script never guesses how long something takes. Before each touch or key, it waits until the app is settled: the input before it handled, and the background work it started (a download, a list loading, a cover) over. A shot waits until the app is idle: settled, and the panel unchanged for 500 ms.

InteractiveA script, line by line
shot 01-series
tap 700 1048 # Beta Chronicles
shot 02-books
tap 1200 1790 # Next list page
shot 03-books-page-4
swipe 300 900 1100 900 # previous list page
shot 04-books-page-3
tap 150 84 # ‹ Back
home
wait 300ms
resume # a full redraw
shot 05-resumed
‹FakeContinue: Alpha Saga 2Alpha SagaBeta ChroniclesGamma…‹ Prev1/1Next ›
line 1/13
make shots runs: go run ./cmd/emulator -fake -fresh -script emulator/scripts/smoke.txt -out build/shots. The script starts once the app is idle.
build/shots/
no shot yet
Try this: Step through the script and watch the shots pile up in build/shots/. Notice that no line says how long to wait for the download of covers.

Shots as tests​

make shots-all runs every script in emulator/scripts/, each with the fake servers it was written for, and -frozen-status: a fixed clock (14 March 2025, 9:41), fixed free space, and fixed addresses for the fake servers. Two runs then give byte-identical PNGs.

That turns screenshots into a test. make baseline saves a set of shots; after a change, make shots-compare (diff -r) lists every PNG that differs. Each one must be a difference you meant: you open it, look, and then accept it with make baseline. make verify runs all of this after the build, go vet and go test.

Try it yourself​

With Go installed, from the repository's root:

make emu-fake # then open http://localhost:8740
make emu-fake EMUFLAGS="-eink" # with real refresh times
make emu-fake EMUFLAGS="-fake-type calibreweb" # a fake Calibre-Web, with EPUBs
make shots SCRIPT=emulator/scripts/mealie.txt EMUFLAGS="-fake-type mealie" # PNGs in build/shots
Going deeper
  • What it cannot show. Real ghosting, the reader's speed, Wi-Fi, sleep, USB, and what the firmware does when the app goes to the background. For those: make deploy, use the app, and read applications/inkshelf.log over USB.
  • Settled, precisely. The emulator's Pending reports input or task messages not handled yet, or a repaint not started. The app adds its background work (BackgroundWork: what it started with state.Go). The script polls both every 10 ms.
  • Background in the emulator. home makes the emulator drop input and send event 152; the panel keeps its picture, since no other app draws. resume sends event 151, and the loop calls Draw, as on the reader.
  • Its own folder. The emulated reader keeps its files in emulator/ (-dir), with config.json written as on the reader; with -fake, under emulator/fake/. -fresh starts without downloaded books or saved filters.

Where it lives in the code​

The full story: The emulator.

That ends the part about the screen. Next, the books themselves, starting with the file format of most of them: What is an EPUB?

Check yourself​

Why does the emulator encode touches as 32-byte firmware messages instead of calling app.Pointer directly?
The app draws a button but forgets to refresh it. What does the emulator show?
Why does make shots-all pass -frozen-status?