This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
A desktop simulator for CrossPoint firmware. It is not a standalone app, it ships as a PlatformIO library that downstream firmware adds as a lib_dep (named simulator) and builds with platform = native and -DSIMULATOR. The result is the firmware compiled as a host binary, with the e-ink display rendered into an SDL2 window.
There is no build target inside this repo. Build and run happen in the consuming firmware project. See README.md for end-user setup, and .claude/CONTEXT-sim-notes.md for the deep architecture notes and bug-fix history (read this before non-trivial changes).
pio run -e simulator -t run_simulator # build + launch
pio run -e simulator # build only, then .pio/build/simulator/program
rm -rf ./fs_/.crosspoint/ # clear stale on-disk caches after storage/cache changesFor local dev against this repo, the firmware's platformio.ini should reference it as simulator=symlink://../crosspoint-simulator instead of the git URL.
There are no tests, no linter, and no per-file build commands. A change is "tested" by running the simulator and exercising the affected feature.
The simulator is a collection of host-side reimplementations of the firmware's hardware abstraction layer (HAL) and its Arduino/ESP-IDF dependencies. Each Hal*.cpp/.h here corresponds to a Hal* class in the firmware's lib/hal/, and must keep the same public surface or the firmware will not link.
The HAL stub rule. When the firmware adds a new method to a HAL class and calls it, the simulator fails to link until a matching stub is added to the corresponding Hal*.cpp here. Most additions are one-line no-ops. This is the single most common reason a simulator build breaks after pulling firmware updates.
Why the simulator's design has the shape it does (the non-obvious parts):
- SDL on main thread. macOS requires all SDL calls to come from the main thread, but firmware drives rendering from a FreeRTOS render task. The split lives in src/HalDisplay.cpp:
refreshDisplay(background thread) converts the 1bpp framebuffer to ARGB and sets an atomicpendingPresentflag.presentIfNeeded(called fromsimulator_mainon the main thread) does the actual SDL upload and present. Do not call SDL render functions from anywhere else. - Orientation rotation lives in two places. The firmware's renderer rotates content into the landscape framebuffer (90 CCW for
Portrait). The simulator undoes that withSDL_RenderCopyEx. If you change one, change the other. The dst rect is landscape-shaped and centre-offset becauseSDL_RenderCopyExrotates around the dst centre. - HiDPI / dithering. Set
SDL_HINT_RENDER_SCALE_QUALITY=1beforeSDL_CreateTexture, plusSDL_WINDOW_ALLOW_HIGHDPIandSDL_RenderSetLogicalSize. Without all three, Bayer-dithered grays render as harsh black/white stripes on Retina. - POSIX fds, not std::fstream, in src/HalStorage.cpp. This was a deliberate rewrite. fstream's separate get/put pointers, eofbit-blocks-seek behaviour, and write-only seek restrictions caused several silent-corruption bugs. Do not reintroduce fstream here. All paths are prefixed with
./fs_so the simulated filesystem stays sandboxed under the binary's working directory;/books/on the SD card maps to./fs_/books/. Directory iteration skips only the special.and..entries; firmware applies its own hidden-file policy. - FreeRTOS shim. src/freertos/ maps
xTaskCreatetostd::thread, task notifies to a condvar + counter, andSemaphoreHandle_ttostd::recursive_mutex. Athread_local SimTaskHandle*lets each task thread find its own handle. _exit(0)notreturn 0. src/simulator_main.cpp ends with_exit(0)afterSDL_Quit()to skip C++ global destructors. The render task is[[noreturn]], so running destructors while it is mid-render races and produces a "quit unexpectedly" dialog. Keep this.- Time uses
steady_clock.millis()/micros()in src/Arduino.h deliberately usesteady_clock, notsystem_clock, so wall-clock changes do not perturb timing.
Host-specific code paths:
- MD5: src/MD5Builder.h is a thin dispatcher that auto-selects the implementation via
#ifdef __APPLE__/#elif __linux__. src/MD5Builder_mac.h uses CommonCrypto; src/MD5Builder_linux.h uses OpenSSL. No downstream swapping is needed - just includeMD5Builder.h. - Web server shims: src/WebServer.cpp, src/WebSocketsServer.cpp, and src/NetworkClient.cpp expose firmware port 80 as
http://127.0.0.1:8080/and port 81 WebSockets asws://127.0.0.1:8081/.CROSSPOINT_SIM_HTTP_PORTmoves the pair together when either port is occupied. Current CrossPoint builds compile their firmware-ownedCrossPointWebServer.cppandWebDAVHandler.cppagainst these shims;CROSSPOINT_SIMULATOR_PROJECT_WEBSERVERdisables only the legacy reduced substitute in this library. - Build flags: macOS gets architecture-correct SDL compiler and linker flags
from
sdl2-config, so the same sample works on Intel and Apple Silicon. Linux/WSL additionally links OpenSSL with-lssl -lcrypto -Wno-deprecated-declarations(OpenSSL 3.x deprecatesMD5_*). See sample-platformio-macos.ini and sample-platformio-linux-wsl.ini. Keep both in sync when build flags change. Native Windows is not supported, WSL is. - Linker stubs: src/firmware_link_stubs.cpp provides symbols the firmware expects from other translation units (uzlib checksums, HWCDC Serial shim, LUT stubs). When the firmware adds a new global-extern symbol with no simulator counterpart, add its stub here.
src/BoardConfig.h selects X4 by default,
SIMULATOR_DEVICE_X3 for X3, SIMULATOR_DEVICE_X4_PRO for X4 Pro, and
SIMULATOR_DEVICE_STICKY for Seeed Sticky.
SIMULATOR_DISPLAY_UC8179 and SIMULATOR_DISPLAY_UC8279 select per-batch
controller revisions without changing a device's geometry or capabilities.
Keep the reported board and controller aligned with the firmware SDK. X4 Pro
uses the same 800x480 display geometry as X4 but adds touch, a capacitive Home
key, frontlight state, inversion, and an RTC. Sticky also uses 800x480 and adds
touch, RTC, and tilt without a Home key or frontlight.
HalGPIO::update owns the SDL event pump for the whole simulator, do not poll SDL events elsewhere. Scancodes map to button indices BTN_BACK=0 through BTN_POWER=6. SDL_QUIT sets the quitRequested atomic that HalDisplay::shouldQuit() reads.
For repeatable QA, CROSSPOINT_SIM_INPUT_SCRIPT schedules synthetic key and
touch-device edges through the same HalGPIO state as real SDL input, and
CROSSPOINT_SIM_SCREENSHOTS captures renderer output on the SDL main thread.
Keep synthetic held-time timestamps on the SDL_GetTicks() clock used by real
keyboard events; the firmware's millis() clock has a different origin. The
deep-sleep loop must also process synthetic input. Process relaunch promotes
the optional *_AFTER_WAKE schedules and clears the pre-sleep schedules so
automation cannot enter an infinite sleep/relaunch cycle.
- Adding a new HAL method? Mirror the firmware signature exactly and stub it (usually no-op) in the matching
Hal*.cpp/.h. Do not invent new public methods that don't exist in the firmware HAL. - Adding a new Arduino/ESP-IDF symbol? Add the minimum stub to the corresponding header in src/ (e.g. src/WiFi.h, src/Arduino.h). Match the upstream signature, return a sensible default.
- Touching storage or caching code? After the change,
rm -rf ./fs_/.crosspoint/in the firmware project before re-running, otherwise stale caches built by the old code will mask the fix. - Touching display, threading, or shutdown? Re-read the "Why the simulator's design has the shape it does" section above first. Several of those decisions undo subtle bugs that will resurface if reverted.