Architecture
The decisions that hold the rest up, and the reasoning behind each — including the ones that cost a rewrite to learn.
Most of wisq is unremarkable. These are the parts that are not: places where the obvious approach is wrong, and where knowing why saves the next person a day.
Rust, and one hybrid app
Everything is Rust except the phone app, which is hybrid. The host daemon and the RISC-V interpreter are Rust — a program with no interface, and a loop over a byte array; neither has a reason to carry a language runtime. The app is the exception because it has to be: the interface, the touch model and the remote desktop client are built on UIKit and Network.framework, which belong to the platform they target. So the app is a Swift shell around a Rust core, and the seam between them is a C ABI of seven functions.
The question for anything new is which of those two it looks like, not which language is nicer to write.
No JIT, and never was
iOS grants executable memory only to development-signed applications. Every emulator on the App Store is therefore an interpreter, and the ones that pretend otherwise are not on the App Store. That is not a limitation wisq works around — it is the premise. The work went into making the interpreter fast instead.
| Change | Effect |
|---|---|
| Register file off a Swift array | 2.7× — the optimiser reloaded the buffer after every opaque call |
| Branch-free immediate sign extension | +8% — loads and stores are 47% of a Linux boot |
| Mapped guest RAM instead of cleared | construction 33–194 ms → under 0.1 ms |
| Explicit thread quality of service | keeps the interpreter off efficiency cores |
Three other attempts were measured and reverted: moving cold opcodes out of line cost 9%, unconditional register write-back cost 3%, and a denser dispatch table gained nothing. A negative measurement is worth as much as a positive one and both are in the history.
The pixel format is negotiated for rendering, not for the network
An RFB client may ask the server for whatever pixel layout it wants. The tempting choice is the one that sends the fewest bytes; the right choice is the one the phone can hand to the graphics stack without touching it. wisq asks for 32 bits little-endian with red at 16, green at 8 and blue at 0, which lands in memory as B, G, R, X — exactly what Core Graphics reads as byteOrder32Little with the first component skipped.
CPIXEL is not TPIXEL
ZRLE packs colours as CPIXEL, three bytes in the negotiated byte order — B, G, R for the format above. Tight packs them as TPIXEL, which is always R, G, B regardless of what was negotiated. Swapping the two produces a picture that is entirely readable and entirely the wrong colour, with no error anywhere. It is the single easiest way to lose an afternoon in this codebase.
zlib streams live as long as the session
The compressed encodings share dictionaries across rectangles: a stream is opened once and fed for the life of the connection. One mis-parsed byte does not corrupt one rectangle, it corrupts every frame after it. This is why reconnection builds fresh streams rather than reusing the old ones, and why the decoder is tested against fixtures produced by a reference zlib rather than by itself.
The console is incremental
The obvious way to render a serial console is to keep every byte and re-derive the visible text on each arrival. That is work proportional to the whole history per chunk — quadratic in the output. Measured: 2 000 lines cost 36.7 seconds of processing, against 0.22 now. The emulator stayed fast while the interface melted, which reads to the user as the VM being slow.
Being incremental also fixed a real defect. The escape parser now keeps its state between chunks, so a sequence split across two writes is recognised instead of being printed as text.
The touch model is the product
A desktop drawn on a phone is unusable if the pointer is your fingertip. wisq draws a virtual cursor on its own layer, offset from the finger, with inertia — so small buttons are reachable and you can see what you are about to hit. Press and release are spaced 50 ms apart and ordered, because guests drop clicks that arrive in the same millisecond.
Determinism where it can be had
The local machine's virtual clock advances with executed instructions rather than wall time. The same kernel image therefore boots identically on every device and in CI, which is what makes a boot usable as a test — and what lets a benchmark compare two interpreters honestly, because the instruction counts have to match before the throughputs mean anything.