ThistleOS simulator architecture: SDL2, real Rust kernel, fake HAL drivers, virtual I2C bus

Overview

The ThistleOS simulator compiles the same kernel, display server, window manager, and app code that runs on real ESP32 hardware into a native x86/ARM desktop binary. SDL2 provides the display window and keyboard/mouse input; libcurl provides HTTP networking. Everything else — HAL registration, driver vtables, IPC, permissions, the ELF syscall table — is the real production code.

What the simulator replaces with stubs:

What the simulator does not test: real-time interrupt latency, actual power consumption, hardware-specific driver quirks, flash wear leveling, and radio RF characteristics. For those, use real hardware.

Quick Start

Prerequisites: SDL2, CMake, a C compiler, Rust (for the kernel crate), and libcurl.

# Install dependencies (Ubuntu/Debian)
sudo apt-get install -y libsdl2-dev pkg-config cmake build-essential libcurl4-openssl-dev

# Install dependencies (macOS)
brew install sdl2 cmake pkg-config curl

# Build
cd simulator
mkdir -p build && cd build
cmake .. && make -j$(nproc)

# Run with default device (T-Deck, 320x240)
./thistle_sim

# Run as T-Deck Pro (e-paper)
./thistle_sim --device tdeck-pro

# Run headless boot test
./thistle_sim --headless --timeout 5000 \
  --assert ../tests/boot_assertions.txt \
  --device tdeck

CLI Reference

FlagArgumentDescription
--deviceNAMESimulate a specific board. See Supported Devices for the full list. Default: tdeck.
--headlessRun without an SDL window. The framebuffer is allocated but never presented. Required for CI.
--timeoutMSExit after MS milliseconds. Used with --headless to bound test runtime. Typical value: 5000.
--assertFILEOn exit, evaluate assertions from FILE against captured log output. Exit code 0 = all pass, 1 = failure.
--scenarioFILELoad a JSON scenario file that injects sensor data, GPS coordinates, battery state, and IMU readings into the virtual hardware.
-h, --helpPrint usage and the list of supported device names.

Headless Testing

Headless mode (--headless) is the foundation of automated testing. The simulator boots the full kernel, registers all HAL drivers, launches the window manager and launcher app, then exits after the timeout. Combined with --assert, it verifies that the boot sequence produces the expected log output.

Assertion file format

An assertion file is a plain text file. Each non-empty, non-comment line is a pattern:

Example (tests/boot_assertions.txt):

# ThistleOS Simulator Boot Assertions
# +pattern = must appear in output
# -pattern = must NOT appear in output

# Kernel boot sequence
+kernel_init: 0
+display_server_init: 0
+display_server_register_wm: 0
+Launcher launched
+ThistleOS Simulator ready

# Crash indicators
-PANIC
-stack overflow
-abort()
-Guru Meditation
-assert failed

If any + pattern is missing or any - pattern is found, the simulator exits with code 1 and prints which assertions failed.

Scenario Engine

The --scenario flag loads a JSON file that injects state into the virtual hardware layer before and during the boot sequence. This lets you test how the kernel and apps respond to specific sensor readings, GPS fixes, and battery conditions.

JSON format

{
  "power": {
    "voltage_mv": 4100,
    "percent": 95,
    "state": "charging"
  },
  "gps": {
    "latitude": 51.5074,
    "longitude": -0.1278,
    "altitude_m": 11.0,
    "satellites": 8,
    "fix_valid": true
  },
  "imu": {
    "accel": [0.1, -0.2, 9.78],
    "gyro": [0.5, -0.3, 0.1]
  }
}
SectionFieldsEffect
powervoltage_mv, percent, stateSets the virtual battery voltage, percentage, and charging state (charging, discharging, full).
gpslatitude, longitude, altitude_m, satellites, fix_validInjects a GPS fix into the virtual GPS driver. Apps calling hal_gps_get_position() receive these values.
imuaccel [x,y,z], gyro [x,y,z]Sets accelerometer and gyroscope readings on the virtual QMI8658C device model.

Virtual I2C Bus

The simulator provides a virtual I2C bus (sim_i2c_bus.c) that replaces the ESP-IDF i2c_master driver. When a kernel driver or HAL call performs an I2C read or write, the virtual bus routes the transaction to the appropriate device model based on the 7-bit I2C address.

Device models are registered during board_init() based on the selected device's capabilities. Each model implements a register map that mimics the real hardware closely enough for the kernel drivers to function correctly. Register reads return realistic default values; register writes update internal state that subsequent reads reflect.

The virtual SPI bus (sim_spi_bus.c) follows the same pattern for SPI-attached peripherals like displays and LoRa radios.

Device Models

Five virtual I2C device models are implemented, covering the peripherals found across the supported boards:

ModelSourceI2C AddressReal HardwareKey Registers
dev_pcf8563devices/dev_pcf8563.c0x51PCF8563 RTCTime, date, alarm, timer, CLKOUT control
dev_qmi8658cdevices/dev_qmi8658c.c0x6AQMI8658C 6-axis IMUWHO_AM_I, accelerometer XYZ, gyroscope XYZ, config
dev_tca8418devices/dev_tca8418.c0x34TCA8418 keyboard controllerKey event FIFO, GPIO config, interrupt status
dev_cst328devices/dev_cst328.c0x1ACST328 touch controllerTouch point X/Y, finger count, gesture ID
dev_ltr553devices/dev_ltr553.c0x23LTR-553ALS light/proximityALS data (lux), PS data (proximity), config

Input injection functions allow tests and the SDL event handler to feed keystrokes and touch events into the virtual devices:

// Inject a key press into the virtual TCA8418
dev_tca8418_inject_key(0x1E, true);   // key down
dev_tca8418_inject_key(0x1E, false);  // key up

// Inject a touch event into the virtual CST328
dev_cst328_inject_touch(160, 120, true);   // finger down at (160,120)
dev_cst328_inject_touch(160, 120, false);  // finger up

// Set sensor values on the virtual QMI8658C
dev_qmi8658c_set_accel(0.0f, 0.0f, 9.81f);
dev_qmi8658c_set_gyro(0.0f, 0.0f, 0.0f);

// Set light sensor readings
dev_ltr553_set_lux(500);
dev_ltr553_set_proximity(100);

Writing Tests

Adding an assertion file

Create a new .txt file in simulator/tests/ with + and - patterns. Run it manually first:

./build/thistle_sim --headless --timeout 5000 \
  --assert tests/my_assertions.txt \
  --device tdeck

Adding an integration test

Edit simulator/tests/run_integration_tests.sh and add a run_test call. The function signature is:

run_test "test_name" "device" "assert_file" "[extra_args]"

Example — testing that the Heltec V3 (128x64 OLED, LoRa, no keyboard) boots and registers its radio driver:

run_test "radio/heltec-v3" "heltec-v3" "$TESTS_DIR/radio_assertions.txt"

Using scenarios in tests

Create a JSON scenario file and pass it with --scenario:

run_test "battery/low" "tdeck" "$TESTS_DIR/low_battery_assertions.txt" \
  "--scenario $TESTS_DIR/low_battery_scenario.json"

Rust kernel unit tests

The Rust kernel crate (components/kernel_rs/) contains 1,231 unit tests covering all 57 kernel modules. These run on the host (not on ESP32) with:

# macOS
cargo test --target aarch64-apple-darwin -- --test-threads=1

# Linux (CI)
cargo test --target x86_64-unknown-linux-gnu -- --test-threads=1

The --test-threads=1 flag is required because many kernel modules use global state (HAL registry, app manager, event bus) that is not safe to access from parallel test threads.

CI Integration

GitHub Actions runs four test jobs on every push and pull request to main:

JobWhat it doesDepends on
unit-testsVerifies C test code compiles and counts test cases.
simulator-bootBuilds the simulator and runs a single headless boot test on the T-Deck device model.
simulator-integrationRuns the full integration test suite: boot tests across all 10 devices, HAL completeness checks, driver init verification, radio/GPS tests, scenario engine tests, and minimal device tests.simulator-boot
rust-kernel-testsRuns all 1,231 Rust kernel unit tests on x86_64 Linux.

The simulator-integration job depends on simulator-boot — if the basic boot test fails, the full suite is skipped to save CI minutes.

Interpreting failures

Adding Device Models

To add a new virtual I2C device model:

  1. Create the source file — Add simulator/devices/dev_yourchip.c. Implement a register read/write handler that the virtual I2C bus calls.
  2. Declare the registration function — Add void dev_yourchip_register(int bus_index, uint16_t addr); to simulator/devices/sim_devices.h.
  3. Register in board_init — In simulator/board_simulator.c, call dev_yourchip_register() for devices that include the chip.
  4. Add sensor injection (optional) — If the device produces sensor data, add functions like dev_yourchip_set_value() and declare them in sim_devices.h.
  5. Write assertions — Create an assertion file that verifies the device registers at the expected address and the corresponding kernel driver initializes.

The device model only needs to implement the registers that the kernel driver actually reads and writes. Consult the real driver source in components/drv_*/ to determine which registers matter.

Tip
Start by returning the correct WHO_AM_I or device ID register value. Most drivers check this first and abort if it does not match.

Supported Devices

The simulator can emulate all 10 supported ThistleOS boards. Each device model configures the display resolution, available peripherals, and which virtual I2C devices are registered.

DeviceBoard NameResolutionKeyboardTouchRadioGPSE-Paper
tdeck-proT-Deck Pro320 x 240YesYesYesYesYes
tdeckT-Deck320 x 240YesYesYesYesNo
tdeck-plusT-Deck Plus320 x 240YesYesYesYesNo
tdisplayT-Display-S3320 x 170NoYesNoNoNo
heltec-v3Heltec V3128 x 64NoNoYesNoNo
cardputerCardputer240 x 135YesNoNoNoNo
cyd-s022CYD S022240 x 320NoYesNoNoNo
cyd-s028CYD S028320 x 240NoYesNoNoNo
t3-s3T3-S3128 x 64NoNoYesNoNo
c3-miniC3-Mini128 x 64NoNoNoNoNo

Devices with radio and GPS capabilities (T-Deck Pro, T-Deck, T-Deck Plus) register virtual LoRa radio and GPS drivers. The T-Deck Pro additionally uses the e-paper display path with simulated partial/full refresh modes. Minimal devices like the C3-Mini register only a display and storage driver, making them useful for testing the kernel's graceful handling of missing peripherals.