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.
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.
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.
At-a-Glance Comparison
| Property | T-Deck Plus | T-LoRa Pager | Cardputer ADV |
|---|---|---|---|
| SoC | ESP32-S3 (N16R8) | ESP32-S3 (N16R8) | ESP32-S3FN8 (Stamp-S3A) |
| PSRAM | 8 MB | 8 MB | None |
| Flash | 16 MB | 16 MB | 8 MB |
| Internal SRAM | 320 KB | 320 KB | 320 KB |
| Display controller | ST7789 | ST7796U | ST7789V2 |
| Display resolution | 320×240 | 480×240 (datasheet 480×222) | 240×135 |
| Display SPI bus | FSPI (shared) | FSPI (shared) | HSPI (dedicated) |
| Input | Trackball + TCA8418 QWERTY + GT911 touch | Synthetic trackball + TCA8418 + AW9523 | 56-key QWERTY via TCA8418 + FN layer |
| LoRa radio | SX1262 (onboard) | SX1262 (onboard) | SX1262 (Cap LoRa-1262 module) |
| GPS | ATGM336H | MIA-M10Q (multi-constellation) | AT6668 (multi-constellation, Cap module) |
| PMU | AXP2101 + direct GPIO | AXP2101 + XL9555 expander | M5 internal |
| BLE stack | Bluedroid (Arduino-ESP32) | Bluedroid (Arduino-ESP32) | NimBLE (ESP-IDF) |
| Apps registered | 49 | 52 | 47 |
| Status (v1.2.0) | Validated | Validated | Validated |
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.
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.
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.
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 Pin | Function |
|---|---|
| P00 | LORA_EN — SX1262 power enable |
| P01 | GPS_EN — MIA-M10Q power enable |
| P02 | SENSOR_EN — onboard sensors |
| P03 | DRV2605_EN — haptic driver enable |
| P06 | DISP_RESET — display controller reset |
| P07 | KB_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.
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.
Boot Sequence
T-Deck Plus — ~3 seconds to launcher
- ESP32 powers up.
- Arduino
setup()runs. pinMode(BOARD_POWERON, OUTPUT), then HIGH — peripheral rail on.- Initialize PMU (AXP2101) over I²C.
- Initialize display over SPI.
- Initialize SD card over SPI — synchronous, blocks until complete.
- Mount or create vault directory.
- Auto-connect WiFi.
- Spawn Ghost Engine on Core 0.
- Launcher takes over on Core 1.
T-LoRa Pager — ~2 seconds to launcher, ~6 seconds to SD ready
- ESP32 powers up.
- Arduino
setup()runs. - Initialize I²C bus.
- Initialize PMU (AXP2101).
- Initialize XL9555 I/O expander — unique to T-LoRa Pager.
- Walk XL9555 bits HIGH: LORA_EN, GPS_EN, SENSOR_EN, DRV2605_EN.
- Initialize display (XL9555 P06 pulse for reset, then SPI init).
- Initialize keyboard controllers (TCA8418 + AW9523).
- Spawn
late_sd_init_taskon Core 0 — deferred SD mount. - Launcher takes over on Core 1 immediately. Apps that need SD poll
g_sd_ready. - Background:
late_sd_init_taskperformsSPI.end()+ 20ms wait +SPI.begin()cold restart + mount retry loop. Setsg_sd_ready = trueon success. - Ghost Engine spawns after SD ready or 15-second timeout.
Cardputer ADV — ~2 seconds to launcher
- ESP32 powers up.
- Arduino
setup()runs. - Initialize HSPI for display, FSPI for SD + LoRa.
- Initialize display on HSPI (no contention with SD or LoRa).
- Initialize SD on FSPI — synchronous.
- Initialize TCA8418 keyboard.
- If Cap LoRa-1262 module present: initialize SX1262 + AT6668 GPS on FSPI.
- Spawn Ghost Engine on Core 0 with PSRAM flag isolation —
BOARD_HAS_PSRAMnot defined for this build. - Launcher takes over on Core 1.
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_Hon 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_His 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.
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.
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.
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.
| # | Pitfall | Fix |
|---|---|---|
| 01 | Hardcoded 320 widths — renders correctly on T-Deck, draws a narrowing column on T-LoRa Pager's 480px screen | Use DISP_W throughout |
| 02 | Hardcoded setCursor(160, y) for centering — 160 is center of 320 but ⅓ of 480 | setCursor(DISP_W / 2 - textWidth / 2, y) |
| 03 | Right-anchored elements via setCursor(280, y) — T-Deck-centric, leaves gap on wider screen | setCursor(DISP_W - margin, y) |
| 04 | GPIO 21 used as TOUCH_RST on T-Deck — same pin is SD_CS on T-LoRa Pager, causes SD mount corruption | Gate touch init behind #ifdef DEVICE_TDECK_PLUS |
| 05 | Synchronous SD mount in setup() on T-LoRa Pager — blocks boot, sometimes never completes due to SPI state corruption | Use late_sd_init_task with cold SPI restart |
| 06 | Direct GPIO writes to peripheral power rails on T-LoRa Pager — pin is gated through XL9555 expander, not direct | Use xl9555_set_bit(pin, true/false) |
| 07 | Static buffer declarations in app files on Cardputer — consumes scarce SRAM whether app is running or not | Allocate on app entry, release on app exit (lazy app-state) |
| 08 | ps_malloc() calls in apps assumed to fall back gracefully on no-PSRAM — actually returns NULL on Cardputer | Wrap in #ifdef BOARD_HAS_PSRAM with internal-RAM fallback path |
| 09 | WiFi mode transitions via esp_wifi_set_mode() on Cardputer — leaks buffers, eventually OOMs | Full esp_wifi_stop() + esp_wifi_deinit() + reinit in target mode |
| 10 | Bluedroid BLE stack on Cardputer — too large for 320 KB SRAM | Use NimBLE (smaller footprint, ESP-IDF native) |
| 11 | Games designed for 320×240 forced onto Cardputer 240×135 — UI elements clipped, controls unreachable | Exclude 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