Patrick Lidstone
Self-hosted

What would a modern Psion Series 5 look like?

The Series 5 got the interface right and was let down by the hardware around it: 640x240 in sixteen greys, and a network that meant a serial cable or an infrared link to a mobile phone. Both of those are cheap to fix now. What has not gone away is that a machine this small still runs into hard limits, and they are not the limits of 1997.

The answer this one arrived at is an 800x480 colour panel, WiFi with mail, calendar and SSH over it, a link to a telephone, and nineteen applications including OPL. It is a hobby build on a development board, not a product.

The hardware is an Elecrow CrowPanel 5.0" - an ESP32-S3-WROOM-1-N4R8 driving an 800x480 RGB panel with a GT911 touch digitiser, with 4 MB of flash, 8 MB of octal PSRAM and 320 KB of internal SRAM. PlatformIO on ESP-IDF and FreeRTOS. Roughly 70,000 lines across 216 files, excluding the vendored SQLite and libssh and the generated font, icon and sound tables.

The System screen, with the nineteen applications

Every picture here is a screen dump taken off the running board over the serial line, not a mock-up or a host-side render.

Colour, the network and the telephone

None of the three could have come from an emulator. The parent repository is a Series 3mx emulator and it works the way emulators work: you get the original machine including its limits, the screen stays the size it was, and nothing new runs on it without a toolchain from 1997. This is the diy/ directory beside it, and it is not an emulator. Nothing interprets ARM code, and there is no ROM image anywhere in it. The EPOC/EIKON API is reimplemented natively in C++, closely enough that application source written for an ER5 machine ports across by adjusting it rather than rewriting it. Everything else follows from that constraint, which is why the project is shaped as it is rather than taking the shortest route to a screen that resembles a Psion.

Two things that sound alike and are not. The API is a reimplementation; the applications are new. What is reproduced is EIKON - an open-source reimplementation of the toolkit, sitting on ESP-IDF and FreeRTOS rather than on EPOC, and compiled to native machine code for the chip. Every application above it was written from scratch against the Series 5 user guide. Nothing was ported, decompiled or lifted; the guide is the specification, and where it is explicit the implementation follows it literally. The porting contract exists so that other people's ER5 source can come across, and it was validated against a real published application. It is not how anything here was built.

Colour is the obvious one. The machine boots into a sixteen-colour mode whose palette is drawn for this interface rather than being a generic set: five of the sixteen are greys, because the borders, the dimmed text, the dither and the button faces lean on neutral shading far more than on hues, and a grey step is worth more here than another colour. The Series 5's own look is still a mode - a ramp interpolated between its warm-green paper and its blue-black ink - but it is a costume rather than the default, because a low-contrast reflective-STN ramp on a backlit TFT reads as washed out. A 256-colour mode is implemented and works; it needs 384,000 bytes of screen bitmap against 192,000, which is a great deal to pay for hues this interface never asks for. 800x480 was chosen because 640x240 drops into it at 1:1 with room to spare, and EPOC's fonts are bitmap fonts that do not survive resampling.

The network is real this time. The Series 5 had an application called Web with a serial cable or an infrared link to a handset underneath it, which is a browser in name; here there is WiFi and a certificate bundle, and mail, calendar sync, the notification poll and an SSH client all run over it. The radio only starts at all because 192 KB of internal SRAM was given back to it, which is the memory work below. The browser itself is a companion project rather than an application here: esp32-browser vendors this toolkit and builds a genuine HTML, CSS and JavaScript engine on top of it, described further down. It is a separate firmware image on the same board, because this one is already at 96.9% of its flash partition.

The telephone is where intent runs furthest ahead of what is finished. A Series 5 could just about talk to a phone over infrared to dial a modem, which made the phone an accessory; on a pocket machine now the link to a handset is the point rather than a peripheral. Phone is one of the nineteen applications: a dialler with a keypad, caller id, signal, missed calls and message reading, over an ESP32/WROVER that runs a Bluetooth handsfree and message stack and sits on the I2C bus at address 0x42. It shares the touch controller's bus because the board has no second pair of I2C pins, and it is polled four times a second because there is no spare GPIO for the bridge's alert line either. The part that cannot be checked by looking at the screen - whether a call is up, whose number that is, whether a message has a body yet - is a plain C state machine with 54 desktop test cases against the bridge's own event lines.

The compromises that remain

Some have gone since 1997. There is more memory here than the machine these applications were written for, storage is SQLite rather than a private format, the panel is colour, and the network is on the device rather than on the other end of a cable.

The ones that are left are the interesting part, and most of what follows is about them.

Flash is the binding one. The firmware sits at 96.9% of its partition on a board with 4 MB, and there is nothing spare.

Internal SRAM is the other. There is 320 KB of it, the screen bitmap wants 192,000 bytes, and the radio wants a chunk of what is left. Moving the bitmap out to PSRAM takes free memory at boot from 49 KB to 241 KB and is the only reason esp_wifi_init succeeds at all, at a cost in drawing speed that is measured below rather than guessed at.

There is no MMU. EPOC's guarantee that one application cannot reach another's memory cannot be reproduced on this silicon, and what the hardware does offer instead is not the same thing.

The panel has a floor under how fast anything can move. A full-client repaint is about 90 ms and a palette expansion of the whole screen is 37.4 ms, which is why a game on the card runs at 13 frames per second with a ceiling of 16. That is fine for a spreadsheet and it is not fine for anything that wants to animate. Starving PSRAM bandwidth - the radio, or a flash erase - makes the RGB DMA underrun and the picture scroll sideways.

Power has not been measured. There is no figure here for how long any of this would last on a battery, and the panel and the radio are the two things that would decide it. Where that cost is already visible is the telephone: hearing an incoming call needs the notification poll at 3 seconds rather than 30, which is 20 bytes a second on the wire and nothing at all, but which keeps the WiFi radio from sleeping. That is why it is a setting the user turns on rather than a default.

Why the Series 5 interface

Applications for it were written small, for a machine with less memory than an ESP32-S3, and the result was quiet, fast and predictable. Nothing was ever really closed - the launcher kept applications alive and switched between them - which is why the machine felt instant, and that behaviour costs nothing to reproduce.

The practical reason matters more. EPOC is a documented, finished design with a manual, so there is an authority to check behaviour against rather than a series of decisions to invent. The Series 5mx user guide is the specification for most of what is here: Sheet's rule that the first character you type decides what the cell contains, Sketch's five levels of undo, Data's hidden labels still being searched. Where the guide is explicit, the implementation follows it literally.

The porting constraint, and what it buys

Header names survive, so #include <e32base.h> compiles. Two-phase construction, User::Leave, TRAP/TRAPD, the cleanup stack, _LIT, TBuf/TPtrC, CCoeControl::Draw, OfferKeyEventL, HandlePointerEventL and DynInitMenuPaneL all mean what they meant in 1997.

The constraint was tested rather than asserted. VocabBox, a real Series 5mx application published under GPL-3.0, was read to find what a working app actually touches. It found eleven missing pieces of API and two bugs, and the useful thing was that almost all of it was framework rather than drawing. One of the two bugs is the sort that only hardware finds: a real app's CreateAppUiL is literally return new(ELeave) CMyAppUi;, because the framework calls ConstructL() on the result afterwards, and that second call was missing. The application constructed without error, logged itself started, and drew nothing. Nothing about the symptom pointed at the cause.

What a port pays is the resource compiler, which does not exist here - an .rss menu becomes a static array of CEikMenuPaneItem::SData with EPOC's own flag names and bit values. Beyond that, there are no active objects, no RFs, and one window rather than a window server. A port that leans on active objects for asynchronous work needs restructuring around FreeRTOS tasks, and there is no shortcut for it.

The layers

Each layer is an ESP-IDF component and depends only on the ones beneath it, because the decomposition is EPOC's and following it is what keeps the API honest.

components/e32 descriptors, leaves, cleanup stack, arrays
components/gdi bitmaps, fonts, all fourteen EPOC draw modes, palettes, seven-segment
components/coe CCoeControl, the control stack, pointer routing
components/eikon widgets, menus with cascades, toolbar, on-screen keyboard, the app framework
components/etext the rich text model and the layout engine under Word
components/opl the Organiser Programming Language: lexer, translator, VM, keyword library
components/store tables, record buffers, ordered cursors, the sqlar archive
components/sqlite SQLite plus a VFS talking to wear levelling directly
components/board panel, touch, backlight, clock, WiFi. Plain C, and the only layer not EPOC-shaped

Nothing above board knows that the panel is 800x480, that touch is a GT911, or that PSRAM exists.

Two buffers, and what the second one costs

All drawing goes into an 800x480 bitmap at 4 bits per pixel - 192,000 bytes, sixteen colours, a pixel is a nibble. Once per pass of the event loop the accumulated dirty rectangle is expanded through a palette into the panel's 768,000-byte RGB565 framebuffer, which the RGB peripheral's DMA scans continuously. Drawing straight into RGB565 would be simpler and much slower: every overlapping draw would pay the full cost again, whereas composing at 4bpp expands each dirty pixel exactly once however many times it was drawn over.

Where the 4bpp bitmap lives was decided by measurement, twice. It started in internal SRAM. Moving it to PSRAM takes a full-screen fill from 1.4 ms to 9.4 ms, a palette expansion from 33.8 ms to 37.4 ms and a screen of text from 17.4 ms to 18.5 ms. It gives back 192 KB, so internal free memory at boot goes from 49 KB to 241 KB - and without that, esp_wifi_init fails with ESP_ERR_NO_MEM and the radio does not start at all. PSRAM is now the default and a build flag restores the old arrangement for comparison. The honest statement of the cost is "a full-screen fill goes from 1.4 ms to 9.4 ms", not "a few per cent of everything", and the first measurement said the latter only because the fill path was slow enough at the time to hide it.

Leaves are setjmp and longjmp

C++ exceptions are disabled, and TRAP is built on setjmp/longjmp exactly as EPOC's was. This is not a shortcut. A leave must not run destructors, because not running them is the entire reason the cleanup stack exists; implementing TRAP with try/catch would turn every ported two-phase construction into a double free on its error path. The trap frame chain and the cleanup stack are per-task, held in a small fixed table rather than in FreeRTOS thread-local storage, because the reserved TLS indices vary between ESP-IDF versions and colliding with pthread's slot produces corruption that looks like anything but its cause.

What runs on it

The System screen shows nineteen applications: Calc, Word, Sheet, Agenda, Data, Contacts, Email, Sketch, Jotter, Time, Settings, OPL, SSH, Comms, Record, Spell, Files, Card and Phone. Most are from the 5mx guide and behave as it describes.

Every one of them was written from scratch. The guide describes what an application does and the toolkit says how it must be built, and between the two there is enough to write the thing without ever seeing Psion's version of it. That is slower than porting would have been and it is the only route available, since no source was ever released.

Data, the card index, in card view

Data is a card index over the shared store, and is almost entirely a thin layer on top of it: the browser list is the ordered view, the card is the record buffer, Find is a filter and Sort is an order. That was the point of building the store first. Word sits on a proper three-layer text engine - a rich text model, a layout engine that wraps, measures and hit-tests, and a control that owns almost no logic - because the hard parts of a word processor are the model and the layout, and neither is application code.

Five of the nineteen are not from 1997. SSH is a real client over libssh, and it is the one channel deliberately not delegated to a server, because an SSH gateway means the server holding the private key and seeing the session in clear. Comms is the same terminal with a UART at the far end instead of a socket. Files browses both drives and, while it is open, serves the tree over HTTP with the address on screen. Phone is the dialler above, a terminal for a telephone rather than a telephone. Card runs whatever module is on the SD card.

OPL, on the device

components/opl is a real language runtime rather than a toy: a tokeniser, a translator that emits its own bytecode, a VM and a keyword library of 89 named keywords. It translates OPL source on the device. It deliberately does not read ER5 .opo files - that format is undocumented, and source-level compatibility is the project's governing constraint anyway.

Implemented and verified on hardware: procedures with parameters and recursion, LOCAL/GLOBAL, 1-based bounds-checked arrays, the four scalar types with OPL's coercion rules, the control structures, graphics that composite under the console's text so PRINT and gPRINT mix as they do on a Psion, dialogs, menus, ONERR and TRAP, and the database keywords. OPL's -1 truth, its bitwise AND/OR/NOT, and integer division between integers are all faithful - 22/7 is 3 and 22.0/7.0 is 3.14285714286.

The VM runs in slices and never to completion. RunSlice(n) executes at most n instructions and returns; GET and PAUSE park in a state rather than blocking, and the event loop drives it at 800 steps a tick. There is one UI task, so an OPL infinite loop cannot lock the machine and the runtime needs no thread, stack or lock of its own.

Programs are not compiled in. They arrive over the serial line into the archive, and the application's Open button lists what is there; its Edit button opens an editor over the console with the on-screen keyboard docked below it, so a program can be changed on the device rather than resent from a PC. Missing: OPX extensions, sprites, and the date and time keywords.

Applications on the card

This exists because the firmware ran out of room. The S3 has 4 MB of flash and the application partition is 3 MB of it, which the built-in suite had very nearly filled - there was no version of "add another application" that fitted. Sideloading was the way out: build a module against the ESP-IDF SDK, put it on the card, and have the firmware load it at run time through a stable ABI. What started as a way to reclaim flash turned out to be the more interesting half of the machine, because it is also how anybody else adds an application without building the firmware.

Native code, held on the SD card, loaded and run without reflashing. Not bytecode - the module is Xtensa machine code, relocated into PSRAM and jumped to, using Espressif's elf_loader component. A 2,260-byte one-shot module relocates in 455 microseconds and a 3,196-byte hosted one in 525 to 591 microseconds, which is the number that mattered: launching a module is not perceptibly slower than launching a built-in application.

The Card application listing the modules on the SD card

The ABI is flat C - 88 entries - and that is deliberate. The toolkit underneath is C++, and a module could in principle link against CWindowGc and CEikonEnv directly. It should not: C++ couples a caller to name mangling, vtable layout and object sizes, so recompiling the firmware with a different toolchain would break every module in ways that present as corruption rather than as a link error. Colours cross the boundary as palette indices, text as UTF-8, rectangles as four ints. Every entry is permanent; new capability gets a new name beside the old one.

The games are the honest test of it, and they are what found the gaps. Invaders drew every alien as around 48 rectangles from a bitmask and ran at 10 to 11 frames per second no matter what it asked for, because a full-client repaint is about 90 ms. A sprite call and a dirty-rectangle repaint took it to 13 f/s at a requested 12.5, with a new ceiling of 16.

The store

One SQLite database holds both the documents and the databases. Measured on the board: 517 KB of flash, 200 KB of PSRAM, and 468 bytes of internal SRAM - the last of those only because SQLITE_CONFIG_HEAP points SQLite at a PSRAM arena, SQLITE_ENABLE_MEMSYS5 gives it an allocator for it, and SQLITE_OMIT_AUTOINIT forces the ordering, since SQLite that initialises before it has been told where to allocate from lands in internal SRAM. Writes cost about 25 ms a row, which is the flash sector erase and not SQLite, so bulk work goes in one transaction. Reads are about 5 ms.

Documents live in the same database using SQLite's own sqlar archive schema, uncompressed. Files go on over the serial line with tools/putfile.py, and because sqlar is a published format the database can be pulled off the device and read with the stock CLI - sqlite3 diy.db -A -l lists it and -x extracts. That is the whole answer to getting documents on and off a machine with no removable media.

Two things were got wrong in the VFS and are worth repeating. The header sector was being rewritten on every file growth, which is an extra erase and program per page. And the VFS claimed ATOMIC4K and POWERSAFE_OVERWRITE, both of which are false on raw flash - a sector write is an erase then a program, so losing power between them leaves the sector blank, and claiming those capabilities lets SQLite skip the journalling that is the only thing protecting the database.

The serial console is the debugger

src/debugcon.cpp is a single-character console on UART0. It takes a screenshot, injects taps, drags, presses and keys, dumps a 24-entry pointer history with the control-stack index that consumed each event, restarts the panel, sets the clock and runs the storage self-tests. tools/screenshot.py sends s, reads back the run-length-encoded 4bpp bitmap with its own palette, and writes a PNG - and it can drive the UI first, so one command sets up a state and photographs it. That is worth more than it sounds on a device with no keyboard and a screen the build machine cannot see. It is how the font bugs were found, and how pressed and unpressed silkscreen buttons were compared pixel by pixel to prove their geometry was identical.

Its blind spot is its most useful property. It reads the 4bpp bitmap, never the panel framebuffer, so a clean screenshot beside a visibly wrong panel localises the fault to everything downstream of the bitmap.

Two faults worth recording

The picture scrolls sideways, and the screenshot is perfect

Touch lands in the wrong place at the same time. That is an RGB DMA underrun: the peripheral loses its place in the scanline and never recovers. The framebuffer is untouched and the touch coordinates are correct, which is why everything except the panel looks fine. Bounce buffers fix it. It is triggered by anything that starves PSRAM bandwidth - the radio is the usual culprit, and a flash erase is the other, since an erase disables the cache and PSRAM goes with it. No bounce buffer can absorb 30 ms of erase, and CONFIG_SPI_FLASH_AUTO_SUSPEND=y is the real cure. Resyncing the panel afterwards was tried first and is worse: with the cause gone, the restart is the only visible flicker.

Cache coherency is not optional

The panel framebuffer is in PSRAM, the LCD's DMA reads PSRAM directly, and the CPU writes to it through a write-back cache, so every write has to be flushed with esp_cache_msync. Getting it wrong does not look like a cache bug. It looks like persistent corruption in whatever region was drawn last, because those are the cache lines still resident, everything earlier having been evicted.

Provenance, and why the fonts are hand-drawn

There is no official Psion source release. Symbian's EPL-1.0 publication has been read as reference only. No code is copied and nothing is vendored from it; the one vendored dependency of that kind is the SQLite amalgamation, which is public domain. Reference manuals are held locally and are not redistributed.

The fonts are the same story in a more visible form. The Series 5's faces are Psion's copyright, they were never released, and a project whose fonts cannot be published is a project nobody else can build. The seven faces in tools/fonts/ are therefore drawn from scratch to approximate ER5's proportions, compiled by tools/mkfont.py into checked-in C++ tables, with a previewer for inspecting a source. The 24-pixel face is the 12 doubled through EPX/Scale2x rather than pixel-doubled, because doubling naively doubles the staircase on every diagonal along with the letter. The icons work the same way: 100 PNG sources, converted to 4bpp bitmap data with a one-bit cutout mask by tools/mkbitmap.py.

How it connects to the other projects

The toolkit is shared with esp32-browser, which vendors it as vendor/diy and builds an HTML, CSS and JavaScript engine on top of the same descriptors, the same 4bpp graphics layer, the same control framework and the same EIKON widgets. It presents itself as an application called Web. The debugging tools go across unmodified - the browser's firmware keeps diy's serial wire protocol, so vendor/diy/tools/screenshot.py drives it with no changes at all.

The cloud backend is FlowMachine, on an explicitly BlackBerry-like model: a per-user service holds the mailbox connection and does all the heavy MIME and attachment work, feeding a thin, bandwidth-constrained device behind a lightweight poll that says only whether anything has changed. The device speaks neither SMTP nor IMAP. It receives text it can put straight on the screen, and pictures arrive as raw 4bpp pixels in exactly the layout TBitmapData uses, dithered and scaled server-side, so the response is read into a CFbsBitmap and blitted with no conversion anywhere. The endpoint contracts are frozen in basic_alike/kernel/docs/psion-contracts/ and mirrored in this repository, so a later contract change is a visible diff rather than a silent drift.

The C++ toolkit is also the base being ported to Rust for Path OS, the desktop operating system, where it gets a real window server and real protected multitasking. The Series 5's control framework is much closer to that job than modern immediate-mode UI is, which is why it is the right base to start from.

What is not built

There is no window server. CWindowGc exists and application code draws through it, but there are no windows - one implicit full-screen surface, one accumulated dirty rectangle, and a menu pane is a control that happens to be drawn last rather than a window with a z-order.

There is no memory protection, and there will not be an exact equivalent. The ESP32-S3 has no MMU, so EPOC's guarantee that one application cannot touch another's memory cannot be reproduced. What the hardware offers - coarse permission control, two security worlds, stack-overflow checking - is worth having and is not isolation. The honest word is containment, and the documentation says so. A faulty loadable module can still panic the machine: the ABI is a closed door, not a wall.

There is no physical keyboard. The key event plumbing is complete and is the same path a real keyboard will use, which is why text can be typed on the on-screen keyboard or injected over the serial console, but nothing generates a hardware key event yet.

There is no file system in the EPOC sense - no RFs, no RFile, no document association, no UID registry - and the four-class application skeleton runs unchanged only because AppDllUid() is called and ignored.

The firmware currently occupies 3,048,160 bytes of a 3,145,728-byte partition - 96.9% of it, on a board with 4 MB of flash and every byte of it now allocated. Whatever gets added next has to be paid for by something coming out, or by a bigger flash part.