Pisces Moon OS · Device Architecture Reference

Three Devices,
One Treaty

Hardware reference for the three Tier 1 deployment targets — spanning the entire memory tier of the ESP32-S3 chip class, from 8 MB PSRAM down to no PSRAM at all.

Eric Becker · Fluid Fortune v1.2.0 · May 19, 2026 Hardware Reference
Abstract

Pisces Moon OS v1.2.0 ships on three physically different ESP32-S3 handhelds: the LilyGo T-Deck Plus, the LilyGo T-LoRa Pager, and the M5Stack Cardputer ADV. They share an SoC family but differ in almost every other way — display controller, input architecture, power management, peripheral wiring, SPI bus topology, and most consequentially the presence or absence of external PSRAM. Code that compiled fine on one device misbehaved in subtle, hard-to-debug ways on the next because the same Arduino API call talks to entirely different hardware underneath.

This document is the architectural reference that lets one source tree compile to all three targets. The SPI Bus Treaty is invariant. The Ghost Engine model is invariant. The application contract is invariant. The hardware integration — display drivers, input drivers, power rails, boot sequences, memory allocation strategies — varies per device and is gated behind #ifdef DEVICE_* defines. The treaty doesn't change. The hardware around the treaty does.

I.

The Three Devices

Three Tier 1 deployment targets, all validated in v1.2.0, all running the same operating system from the same source tree.

Target 1A · Origin Device
LilyGo T-Deck Plus
ESP32-S3, 8 MB PSRAM, 16 MB flash. ST7789 320×240 display. Physical mechanical trackball + TCA8418 QWERTY + GT911 capacitive touch. SX1262 LoRa radio. ATGM336H GPS module. AXP2101 PMU with direct GPIO rail gating. The original target — every architectural pattern in Pisces Moon OS was first proven here. 49 applications. Production firmware.
Target 1B · Pager Form Factor
LilyGo T-LoRa Pager
ESP32-S3, 8 MB PSRAM, 16 MB flash. ST7796U 480×222 display (renders correctly at 480×240 with no clipping). Synthetic trackball via TCA8418 — no physical ball. AW9523 keyboard expander. SX1262 LoRa radio. MIA-M10Q multi-constellation GPS. DRV2605 haptic driver. XL9555 I/O expander gates most peripheral rails. AXP2101 PMU underneath the expander. 52 applications. Production firmware.
Target 1C · The Generalization Proof
M5Stack Cardputer ADV
ESP32-S3FN8 (Stamp-S3A). No PSRAM. 320 KB internal SRAM, 8 MB flash. Credit-card form factor. ST7789V2 240×135 display on its own SPI bus (HSPI) — separate from the SD + LoRa bus (FSPI). 56-key full QWERTY via TCA8418 with FN modifier layer. Cap LoRa-1262 module adds SX1262 (+27dBm) and AT6668 multi-constellation GNSS over the 2×7-pin expansion header. 47 applications. The proof that the methodology generalizes to no-PSRAM hardware.
II.

At-a-Glance Comparison

PropertyT-Deck PlusT-LoRa PagerCardputer ADV
SoCESP32-S3 (N16R8)ESP32-S3 (N16R8)ESP32-S3FN8 (Stamp-S3A)
PSRAM8 MB8 MBNone
Flash16 MB16 MB8 MB
Internal SRAM320 KB320 KB320 KB
Display controllerST7789ST7796UST7789V2
Display resolution320×240480×240 (datasheet 480×222)240×135
Display SPI busFSPI (shared)FSPI (shared)HSPI (dedicated)
InputTrackball + TCA8418 QWERTY + GT911 touchSynthetic trackball + TCA8418 + AW952356-key QWERTY via TCA8418 + FN layer
LoRa radioSX1262 (onboard)SX1262 (onboard)SX1262 (Cap LoRa-1262 module)
GPSATGM336HMIA-M10Q (multi-constellation)AT6668 (multi-constellation, Cap module)
PMUAXP2101 + direct GPIOAXP2101 + XL9555 expanderM5 internal
BLE stackBluedroid (Arduino-ESP32)Bluedroid (Arduino-ESP32)NimBLE (ESP-IDF)
Apps registered495247
Status (v1.2.0)ValidatedValidatedValidated

The same source tree compiles to all three targets. Per-device behavior is gated behind #ifdef DEVICE_TDECK_PLUS, #ifdef DEVICE_TLORAPAGER, and #ifdef DEVICE_CARDPUTER_ADV. The OS knows which target it is running on; the application contract does not need to. 47 applications run identically across all three devices despite the underlying hardware differences documented below.

III.

Display

T-Deck Plus — ST7789 320×240

Standard ST7789 panel, 320×240 native resolution, RGB565 color. Driven by Arduino_GFX_Library directly over SPI. Backlight on GPIO 42. Reset is a direct GPIO — can be pulsed synchronously. gfx is typed as Arduino_GFX *.

T-LoRa Pager — ST7796U 480×240

ST7796U panel wider than the datasheet specifies — datasheet says 480×222, the panel renders correctly at 480×240 with no clipping. Driven by a custom PMDispTLoRaPager class wrapping the same API surface as Arduino_GFX. Backlight is the same GPIO 42 pin number but a different power rail. Display reset routed through the XL9555 I/O expander on P06 — must be pulsed via I²C, not direct GPIO. gfx is typed as PMDispTLoRaPager *.

Cardputer ADV — ST7789V2 240×135

Smaller ST7789V2 panel at 240×135 on its own dedicated SPI bus (HSPI), separate from the SD + LoRa shared bus (FSPI). The split bus is the single biggest architectural benefit of the Cardputer hardware design — display traffic does not contend with SD or LoRa for SPI access, simplifying Treaty compliance for any app that draws while logging. The trade-off is significantly less screen real estate, requiring per-app rendering adjustments documented in Section VIII.

Conditional gfx declaration

#ifdef DEVICE_TLORAPAGER
extern PMDispTLoRaPager *gfx;
#else
extern Arduino_GFX *gfx;
#endif

Every app file that draws to the display starts with this declaration. The API surface is identical past this point. The display driver implementation absorbs the device difference.

IV.

Input

The input architecture is the single biggest divergence between the three devices and the source of the most subtle bugs encountered during cross-device porting.

T-Deck Plus — physical trackball + TCA8418 + touch

5-pin mechanical trackball wired to direct ESP32 GPIOs with internal pullups. update_trackball() performs edge-detection with a lockout window to prevent runaway scrolling. update_trackball_game() uses a shorter lockout suitable for gameplay. TCA8418 keyboard controller on I²C — the same chip used on the T-LoRa Pager and Cardputer, same FIFO-polling pattern. GT911 capacitive touch on I²C (SDA=18, SCL=8), reset on GPIO 21.

T-LoRa Pager — synthetic trackball, no physical ball

No trackball at all. The OS synthesizes trackball events from arrow-key presses on the AW9523 keyboard expander to maintain API compatibility with apps that expect trackball semantics. Apps written for the T-Deck Plus run unmodified — the synthesis layer absorbs the hardware difference. GPIO 21, which is TOUCH_RST on T-Deck Plus, is SD_CS on T-LoRa Pager — see Problem 7 in the white paper for the cross-device collision this produced.

Cardputer ADV — 56-key QWERTY with FN modifier layer

Full QWERTY keyboard with 56 physical keys via TCA8418, accessed through an FN modifier layer for arrow keys, special characters, and function keys. The FN layer is documented in the firmware in cardputer_keymap.h and exposed to apps via the same key-event API as the other two devices. No trackball synthesis required — apps that expect arrow-key navigation simply receive arrow-key events. Apps that expect trackball semantics receive a software adapter that synthesizes from arrow keys, the same as on the T-LoRa Pager.

Universal input convention

All three devices expose the same key-event API: pm_input_get_event() returns a struct with key, modifier, and event-type fields. Apps written against this API run identically on all three. Hardware differences are absorbed in the per-device input driver implementations.

V.

Power Management

T-Deck Plus — AXP2101 + direct GPIO rails

AXP2101 PMU handles battery charging and voltage regulation. BOARD_POWERON = GPIO 10 gates the main peripheral rail. Sleep path: backlight LOW, panel sleep, BOARD_POWERON LOW disables peripherals. Simple, direct path.

T-LoRa Pager — AXP2101 + XL9555 I/O expander

AXP2101 is still present, but most peripheral power gating happens through an XL9555 I/O expander on the I²C bus. The XL9555 must be initialized before any of its gated peripherals can be used. xl9555_init() runs during boot; xl9555_set_bit(pin, true/false) toggles individual rails.

XL9555 pin map (T-LoRa Pager)

XL9555 PinFunction
P00LORA_EN — SX1262 power enable
P01GPS_EN — MIA-M10Q power enable
P02SENSOR_EN — onboard sensors
P03DRV2605_EN — haptic driver enable
P06DISP_RESET — display controller reset
P07KB_RESET — keyboard controller reset

Sleep path on T-LoRa Pager

Significantly more complex than the T-Deck. Disable haptic via XL9555 P03 LOW. Stop GPS via XL9555 P01 LOW. Stop LoRa via XL9555 P00 LOW. Sleep display via XL9555 P06 pulse. AXP2101 enters low-power state last, after all peripherals have been gated. The walkdown sequence matters — gating peripherals in the wrong order leaves residual current draw that defeats the sleep budget.

Cardputer ADV — M5 internal management

M5Stack uses its own internal power management on the Cardputer ADV. Battery charging and voltage regulation are handled by M5 hardware. The OS does not need to interact with the PMU directly for normal operation. Sleep and shutdown paths use M5's documented APIs. The convention from the T-Deck and T-LoRa Pager — explicit peripheral gating before low-power entry — still applies, but most of the work is done by the hardware itself rather than orchestrated by software.

VI.

The SPI Bus Treaty — Per-Device Application

The Treaty is invariant. The hardware it governs is not. The full Treaty specification — its four rules, the audit results, and the prior art — is documented in the SPI Bus Treaty whitepaper. This section documents how each rule applies on each device.

T-Deck Plus — full Treaty discipline

Display, SD card, and SX1262 LoRa radio all share FSPI. The Treaty applies in its full form: every transaction wrapped in spi_mutex_take() / spi_mutex_give(). The wifi_in_use flag arbitrates WiFi radio access between the Ghost Engine and the foreground AI client. This is the device where the Treaty was first developed.

T-LoRa Pager — Treaty plus deferred SD init

Same FSPI bus, same three consumers, same Treaty discipline. The difference is that SD initialization runs in a deferred background task (late_sd_init_task) after all other peripherals have completed init, with a full SPI.end() + 20ms wait + SPI.begin() cold restart in the middle. The Treaty applies to all SD operations once mount completes; the sd_in_use flag prevents Ghost Engine from attempting to use SD before mount succeeds. See Problem 8 in the white paper for the bug this works around.

Cardputer ADV — Treaty plus WiFi mode-locking

Display is on its own SPI bus (HSPI), simplifying Treaty compliance — display traffic cannot contend with SD or LoRa. SD and LoRa share FSPI; the Treaty applies to their interactions. The major addition on this device is WiFi mode-locking: the no-PSRAM constraint forces full esp_wifi_stop() + esp_wifi_deinit() + reinit on every transition between CLIENT and SCANNER modes, because the WiFi stack's per-mode buffers cannot coexist in 320 KB of SRAM. The Treaty's Rule 3 (radio mutual exclusion via shared boolean) is extended into Rule 3-CARDPUTER: hardware-honest mode arbitration with full teardown, not flag arbitration.

sd_in_use flag

An additional convention on the T-LoRa Pager and Cardputer ADV: every SD operation checks g_sd_ready (set by late_sd_init_task on successful mount) and increments g_sd_in_use for the duration of the operation. The Ghost Engine's ensure_session_file() respects this flag, deferring its operations if SD is busy with foreground work. The Treaty enforces ordering at the bus level; the sd_in_use flag enforces ordering at the filesystem level.

VII.

Boot Sequence

T-Deck Plus — ~3 seconds to launcher

  1. ESP32 powers up.
  2. Arduino setup() runs.
  3. pinMode(BOARD_POWERON, OUTPUT), then HIGH — peripheral rail on.
  4. Initialize PMU (AXP2101) over I²C.
  5. Initialize display over SPI.
  6. Initialize SD card over SPI — synchronous, blocks until complete.
  7. Mount or create vault directory.
  8. Auto-connect WiFi.
  9. Spawn Ghost Engine on Core 0.
  10. Launcher takes over on Core 1.

T-LoRa Pager — ~2 seconds to launcher, ~6 seconds to SD ready

  1. ESP32 powers up.
  2. Arduino setup() runs.
  3. Initialize I²C bus.
  4. Initialize PMU (AXP2101).
  5. Initialize XL9555 I/O expander — unique to T-LoRa Pager.
  6. Walk XL9555 bits HIGH: LORA_EN, GPS_EN, SENSOR_EN, DRV2605_EN.
  7. Initialize display (XL9555 P06 pulse for reset, then SPI init).
  8. Initialize keyboard controllers (TCA8418 + AW9523).
  9. Spawn late_sd_init_task on Core 0 — deferred SD mount.
  10. Launcher takes over on Core 1 immediately. Apps that need SD poll g_sd_ready.
  11. Background: late_sd_init_task performs SPI.end() + 20ms wait + SPI.begin() cold restart + mount retry loop. Sets g_sd_ready = true on success.
  12. Ghost Engine spawns after SD ready or 15-second timeout.

Cardputer ADV — ~2 seconds to launcher

  1. ESP32 powers up.
  2. Arduino setup() runs.
  3. Initialize HSPI for display, FSPI for SD + LoRa.
  4. Initialize display on HSPI (no contention with SD or LoRa).
  5. Initialize SD on FSPI — synchronous.
  6. Initialize TCA8418 keyboard.
  7. If Cap LoRa-1262 module present: initialize SX1262 + AT6668 GPS on FSPI.
  8. Spawn Ghost Engine on Core 0 with PSRAM flag isolation — BOARD_HAS_PSRAM not defined for this build.
  9. Launcher takes over on Core 1.
VIII.

Width-Aware Rendering

Every app file that draws to the display starts with this convention at the top:

#ifdef DEVICE_TLORAPAGER
  extern PMDispTLoRaPager *gfx;
  static constexpr int DISP_W = 480;
  static constexpr int DISP_H = 240;
#elif defined(DEVICE_CARDPUTER_ADV)
  extern Arduino_GFX *gfx;
  static constexpr int DISP_W = 240;
  static constexpr int DISP_H = 135;
#else
  extern Arduino_GFX *gfx;
  static constexpr int DISP_W = 320;
  static constexpr int DISP_H = 240;
#endif

All hardcoded references to specific pixel widths become DISP_W. Headers span full width on each device. Content scales appropriately.

Rules

  • Apps render at full DISP_W × DISP_H on each device.
  • Games keep their 320×240 canvas as a reference frame, centered horizontally on the T-LoRa Pager via X_OFF = (480 - 320) / 2 = 80. On the Cardputer's 240×135 screen, games are not portable — they require per-game adaptation or omission from the Cardputer build.
  • Centering pattern: setCursor(DISP_W / 2 - textWidth / 2, y).
  • Right-anchoring pattern: setCursor(DISP_W - margin, y).
  • Vertical layout: DISP_H is 240 on T-Deck and T-LoRa Pager, 135 on Cardputer. Apps that assume 240 rows of UI must compress or paginate on Cardputer.

Cardputer 240×135 layout strategies

The Cardputer's display is significantly smaller than the other two targets. Three strategies are used in the codebase: (1) compression — smaller fonts, tighter line spacing, reduced padding; (2) pagination — multi-page UIs where the T-Deck shows a single-screen list, the Cardputer shows the same list across two or three pages with page-indicator dots; (3) omission — games and visualization apps that cannot meaningfully scale to 240×135 are excluded from the Cardputer build via #ifndef DEVICE_CARDPUTER_ADV guards around their registration.

IX.

Cardputer ADV — No PSRAM, Full Multi-Radio

The Cardputer ADV is the most architecturally significant of the three devices because it demonstrates that the Pisces Moon methodology generalizes to the no-PSRAM extreme of the ESP32-S3 chip class. This section documents the device-specific work required to make that generalization real.

The key architectural difference

The Cardputer ADV uses the ESP32-S3FN8 (Stamp-S3A) variant. The FN8 has the same Xtensa LX7 CPU as the N16R8 variants in the T-Deck Plus and T-LoRa Pager, but it has no PSRAM. Total memory is 320 KB of internal SRAM plus 8 MB of flash. The 8 MB PSRAM that Pisces Moon's original memory architecture assumed is simply not present.

Memory budget — verified May 2026

At the moment the wardrive task spawned in the v1.2.0 port, free heap measured 97 KB. WiFi STA initialization requires approximately 54 KB. NimBLE observer initialization requires approximately 48 KB. The honest reading of these numbers in early development was that concurrent multi-radio operation on this hardware was impossible. The three architectural changes documented below reclaimed enough memory to make it work.

Change 1 — Lazy app-state allocation

Every always-on app buffer was moved out of static memory and into app-lifetime allocations. Mesh Messenger channel buffers, audio playlists, RetroPack ROM tables, ELF browser app lists, recorder file metadata — all allocated on app entry, released on app exit. The OS no longer reserves RAM for dormant apps. Result: 81 KB of static memory returned to the heap before the user did anything. Static footprint dropped from 190 KB to 109 KB. Free heap at task-spawn rose from 97 KB to 219 KB.

Change 2 — WiFi mode-locking with hard teardown

CLIENT mode (Gemini AI client) and SCANNER mode (Ghost Engine wardrive) made mutually exclusive at the OS level. Mode transitions perform full esp_wifi_stop() + esp_wifi_deinit() teardown before re-init in the new mode. The per-mode buffers cannot coexist in 320 KB of SRAM. Hardware-honest arbitration, not software flag arbitration.

Change 3 — PlatformIO PSRAM flag isolation

-DBOARD_HAS_PSRAM stripped from the Cardputer build target. The ESP-IDF heap allocator no longer attempts PSRAM allocations that would silently fall through to internal SRAM, masking the real memory pressure.

Result — verified field measurements

  • 219 KB free heap after WiFi STA init.
  • 174 KB free heap after NimBLE observer init.
  • 125 KB free heap during active wardrive: 24 BLE devices, 14 WiFi networks, GPS lock at 27 satellites.
  • Four concurrent radios on hardware with 320 KB of total SRAM.
  • 47 applications registered. Real-time SD logging. Persistent multi-app state.

Cap LoRa-1262 module

The Cardputer ADV's LoRa and GPS capabilities come from the M5Stack Cap LoRa-1262 module, which plugs into the 2×7-pin expansion header on the top edge of the device. The module adds: SX1262 LoRa transceiver with +27dBm output stage (higher power than the onboard SX1262 in the T-Deck or T-LoRa Pager), AT6668 multi-constellation GNSS, and a small antenna integrated into the module housing. The OS detects the Cap module at boot via I²C presence check and conditionally enables LoRa and GPS apps. Bench validation May 20, 2026: Mesh Messenger send/receive confirmed between Cardputer ADV with Cap LoRa-1262 and T-Deck Plus on the LongFast channel, concurrent with active wardrive logging on both devices.

Known issues (v1.2.0)

  • Cap LoRa-1262 module validated — bench validation May 20, 2026: two-device mesh send/receive confirmed between T-Deck Plus (!f1bc15a9) and Cardputer ADV with Cap LoRa-1262 (!04043a1d) on LongFast channel, with concurrent active wardrive logging on both devices and SD transcripts written to /mesh_logs/messages.csv.
  • Audio playback apps that buffer entire WAV files in memory are unavailable — streaming-only playback supported on Cardputer.
  • Games that exceed 240×135 native resolution are excluded from the Cardputer build.
  • The 56-key keyboard's FN modifier layer has no physical key labels for FN-shifted characters; a printed reference card ships with the device documentation.
X.

Common Pitfalls

These are the failure modes encountered during T-LoRa Pager and Cardputer ADV bring-up, documented so they can be avoided on the next hardware port.

#PitfallFix
01Hardcoded 320 widths — renders correctly on T-Deck, draws a narrowing column on T-LoRa Pager's 480px screenUse DISP_W throughout
02Hardcoded setCursor(160, y) for centering — 160 is center of 320 but ⅓ of 480setCursor(DISP_W / 2 - textWidth / 2, y)
03Right-anchored elements via setCursor(280, y) — T-Deck-centric, leaves gap on wider screensetCursor(DISP_W - margin, y)
04GPIO 21 used as TOUCH_RST on T-Deck — same pin is SD_CS on T-LoRa Pager, causes SD mount corruptionGate touch init behind #ifdef DEVICE_TDECK_PLUS
05Synchronous SD mount in setup() on T-LoRa Pager — blocks boot, sometimes never completes due to SPI state corruptionUse late_sd_init_task with cold SPI restart
06Direct GPIO writes to peripheral power rails on T-LoRa Pager — pin is gated through XL9555 expander, not directUse xl9555_set_bit(pin, true/false)
07Static buffer declarations in app files on Cardputer — consumes scarce SRAM whether app is running or notAllocate on app entry, release on app exit (lazy app-state)
08ps_malloc() calls in apps assumed to fall back gracefully on no-PSRAM — actually returns NULL on CardputerWrap in #ifdef BOARD_HAS_PSRAM with internal-RAM fallback path
09WiFi mode transitions via esp_wifi_set_mode() on Cardputer — leaks buffers, eventually OOMsFull esp_wifi_stop() + esp_wifi_deinit() + reinit in target mode
10Bluedroid BLE stack on Cardputer — too large for 320 KB SRAMUse NimBLE (smaller footprint, ESP-IDF native)
11Games designed for 320×240 forced onto Cardputer 240×135 — UI elements clipped, controls unreachableExclude from Cardputer build via #ifndef DEVICE_CARDPUTER_ADV

The pitfalls share a common shape: an assumption that worked on the origin device fails silently or catastrophically on a different device with the same SoC family. The pattern is recurrent enough that it has earned its own discipline: before any new hardware port, audit every assumption about pin assignment, power rail topology, memory availability, and peripheral initialization order against the new device's actual schematic. The schematic is the source of truth, not the marketing material and not the previous port.

“The treaty doesn't change. The hardware around the treaty does.”

Device Architecture Reference — Pisces Moon OS v1.2.0 — May 19, 2026

Eric Becker · Fluid Fortune · forge@fluidfortune.com

Companion documents: white paper · engineering record · SPI Bus Treaty

Source: github.com/FluidFortune/pisces-moon-os