Compare commits

...

62 Commits

Author SHA1 Message Date
a6713859b2 better statusbar 2025-10-12 17:16:07 +02:00
aaac0514c0 more settings 2025-10-12 17:11:22 +02:00
1b6e9a0f78 settings app 2025-10-12 16:57:40 +02:00
c64f03a09f status bar 2 2025-10-12 15:18:42 +02:00
5ab8662332 statusbar 2025-10-12 15:03:34 +02:00
6d8834d9b2 nicer tetris 2025-10-12 14:04:06 +02:00
83ba775971 some stuff 2025-10-12 13:51:50 +02:00
df57e55171 better default scale mode 2025-10-12 00:52:58 +02:00
a3b837f329 a bit more speedup 2025-10-12 00:33:48 +02:00
fc9e85aea0 fast wide scale 2025-10-12 00:20:22 +02:00
b55feb68f8 some opt 2025-10-12 00:03:01 +02:00
f04b026d46 a little faster gameboy 2025-10-11 22:21:31 +02:00
e18278e130 disable analyzer 2025-10-11 20:59:29 +02:00
9a392d6aec check macro 2025-10-11 20:36:43 +02:00
961453e28a 8bit draw 2025-10-11 20:03:00 +02:00
a4c2719077 text 2025-10-11 16:54:41 +02:00
f721ebcb4c some refactoring 2025-10-11 16:44:48 +02:00
e9a05259c5 more cleanup 2025-10-11 15:15:25 +02:00
23400d817b more cleanup 2025-10-11 15:04:28 +02:00
fa2715a60a cleaner backend 2025-10-11 14:26:42 +02:00
899bfeef41 checkpoint 2025-10-11 12:54:46 +02:00
535b0078e5 some cleanup 2025-10-11 11:15:39 +02:00
5b75ff28e0 independnet gameboy 2025-10-10 17:11:49 +02:00
e9e371739b kinda sdk 2025-10-10 16:03:23 +02:00
28411535bb tetris high score 2025-10-10 11:18:04 +02:00
54d5f85538 nice dithering 2025-10-09 23:41:51 +02:00
c3295b9b01 fix rotated text 2025-10-09 23:19:05 +02:00
7fc48e5e93 fixes 2025-10-09 22:56:46 +02:00
afff3d0e02 display fix 2025-10-09 22:25:51 +02:00
0660c40ec4 sdkconfig 2025-10-09 18:57:35 +02:00
8520ef556b better gameboy timings 2025-10-09 18:14:17 +02:00
4e78618556 event loop 2025-10-09 17:12:17 +02:00
49455d1b36 move peanutgb to repo 2025-10-09 16:28:35 +02:00
ddf5a47c33 faster 2025-10-09 09:26:34 +02:00
13cdcb01dd gameboy stats 2025-10-09 00:16:25 +02:00
4c0fd5243f fps counter 2025-10-08 22:41:48 +02:00
429d704c8c vibecoded gb emulator 2025-10-08 22:19:21 +02:00
9a9e25e124 app menu 2025-10-08 21:21:33 +02:00
ecf6d09651 buzzer 2025-10-07 22:50:45 +02:00
413e021e49 async display 2025-10-07 14:57:15 +02:00
c9f0f59630 dump 2025-10-07 10:11:39 +02:00
9420887392 more cleanup 2025-10-07 01:26:39 +02:00
7df84f1e81 better gpt tetris 4 2025-10-07 01:14:38 +02:00
4861d26d8a better 2025-10-07 00:29:04 +02:00
e389a776be broken game over 2025-10-06 09:47:50 +02:00
8b8d9d3a55 pause 2025-10-06 09:34:33 +02:00
126d377836 re-enable auto sleep 2025-10-06 09:16:19 +02:00
3f8d90c18a bat stats 2025-10-06 09:14:25 +02:00
c439aecd03 craptrix 3 2025-10-06 08:50:02 +02:00
cd72c2d7df craptrix 2 2025-10-05 23:23:24 +02:00
589c598b01 craptrix 2025-10-05 22:25:19 +02:00
95a946e47f less crap spi 2025-07-31 17:00:47 +02:00
48d2089b69 get rid of window refresh 2025-07-31 16:13:54 +02:00
3e9b7b4326 less template pain 2025-07-31 16:12:35 +02:00
e1004ff196 x11 thread workaround 2025-07-31 14:36:21 +02:00
24df0fc825 add some missed includes 2025-07-31 14:35:58 +02:00
ab32731f4d dump 2025-07-28 09:39:13 +02:00
474a0b2a43 buttons interrupt 2025-07-26 15:34:20 +02:00
35219c353c set correct charge termination current 2025-07-26 12:43:20 +02:00
8180abed4c shutdown on 3v 2025-07-26 12:18:52 +02:00
12d634ecc9 set VEmpty to 3v 2025-07-26 12:07:53 +02:00
6a8f74384e some firmware updates (fuel gauge and port extender) 2025-07-26 11:45:47 +02:00
96 changed files with 11451 additions and 985 deletions

2
Firmware/.gitignore vendored
View File

@@ -1,3 +1,5 @@
build
cmake-build*
.idea
.cache
managed_components

23
Firmware/.vscode/c_cpp_properties.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"configurations": [
{
"name": "ESP-IDF",
"compilerPath": "${config:idf.toolsPath}/tools/riscv32-esp-elf/esp-14.2.0_20241119/riscv32-esp-elf/bin/riscv32-esp-elf-gcc",
"compileCommands": "${config:idf.buildPath}/compile_commands.json",
"includePath": [
"${config:idf.espIdfPath}/components/**",
"${config:idf.espIdfPathWin}/components/**",
"${workspaceFolder}/**"
],
"browse": {
"path": [
"${config:idf.espIdfPath}/components",
"${config:idf.espIdfPathWin}/components",
"${workspaceFolder}"
],
"limitSymbolsToIncludedHeaders": true
}
}
],
"version": 4
}

15
Firmware/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "gdbtarget",
"request": "attach",
"name": "Eclipse CDT GDB Adapter"
},
{
"type": "espidf",
"name": "Launch",
"request": "launch"
}
]
}

85
Firmware/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,85 @@
{
"idf.flashType": "JTAG",
"idf.port": "/dev/tty.usbmodem12401",
"C_Cpp.intelliSenseEngine": "default",
"files.associations": {
"bitset": "cpp",
"chrono": "cpp",
"algorithm": "cpp",
"random": "cpp",
"fstream": "cpp",
"streambuf": "cpp",
"regex": "cpp",
"*.inc": "cpp",
"vector": "cpp",
"esp_partition.h": "c",
"cstring": "cpp",
"array": "cpp",
"string_view": "cpp",
"any": "cpp",
"atomic": "cpp",
"barrier": "cpp",
"bit": "cpp",
"cctype": "cpp",
"charconv": "cpp",
"clocale": "cpp",
"cmath": "cpp",
"codecvt": "cpp",
"compare": "cpp",
"complex": "cpp",
"concepts": "cpp",
"condition_variable": "cpp",
"cstdarg": "cpp",
"cstddef": "cpp",
"cstdint": "cpp",
"cstdio": "cpp",
"cstdlib": "cpp",
"ctime": "cpp",
"cwchar": "cpp",
"cwctype": "cpp",
"deque": "cpp",
"forward_list": "cpp",
"list": "cpp",
"map": "cpp",
"set": "cpp",
"string": "cpp",
"unordered_map": "cpp",
"unordered_set": "cpp",
"exception": "cpp",
"functional": "cpp",
"iterator": "cpp",
"memory": "cpp",
"memory_resource": "cpp",
"netfwd": "cpp",
"numeric": "cpp",
"optional": "cpp",
"ratio": "cpp",
"system_error": "cpp",
"tuple": "cpp",
"type_traits": "cpp",
"utility": "cpp",
"format": "cpp",
"future": "cpp",
"initializer_list": "cpp",
"iomanip": "cpp",
"iosfwd": "cpp",
"iostream": "cpp",
"istream": "cpp",
"latch": "cpp",
"limits": "cpp",
"mutex": "cpp",
"new": "cpp",
"numbers": "cpp",
"ostream": "cpp",
"semaphore": "cpp",
"span": "cpp",
"sstream": "cpp",
"stdexcept": "cpp",
"stop_token": "cpp",
"text_encoding": "cpp",
"thread": "cpp",
"cinttypes": "cpp",
"typeinfo": "cpp",
"variant": "cpp"
}
}

4
Firmware/AGENTS.md Normal file
View File

@@ -0,0 +1,4 @@
To build:
(in zsh)
. "$HOME/esp/esp-idf/export.sh"
idf.py build

View File

@@ -2,5 +2,7 @@
# CMakeLists in this exact order for cmake to work correctly
cmake_minimum_required(VERSION 3.16)
add_compile_options(-Ofast)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(hello_world)

View File

@@ -0,0 +1,36 @@
idf_component_register(
SRCS
"src/bat_mon.cpp"
"src/buttons.cpp"
"src/buzzer.cpp"
"src/esp_backend.cpp"
"src/display.cpp"
"src/fs_helper.cpp"
"src/i2c_global.cpp"
"src/power_helper.cpp"
"src/shutdowner.cpp"
"src/spi_global.cpp"
INCLUDE_DIRS
"include"
PRIV_REQUIRES
driver
esp_timer
esp_driver_i2c
esp_driver_spi
littlefs
nvs_flash
)
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/utils" cardboy_utils_esp)
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/backend_interface" backend_interface_from_backend_esp)
add_library(cardboy_backend_esp INTERFACE)
target_link_libraries(cardboy_backend_esp
INTERFACE
${COMPONENT_LIB}
)
target_link_libraries(${COMPONENT_LIB}
PUBLIC
cardboy_backend_interface
)

View File

@@ -0,0 +1,8 @@
#pragma once
#include "cardboy/backend/esp_backend.hpp"
namespace cardboy::backend {
using ActiveBackend = EspBackend;
}

View File

@@ -5,10 +5,12 @@
#ifndef CB_BAT_MON_HPP
#define CB_BAT_MON_HPP
#include "config.hpp"
#include "cardboy/backend/esp/config.hpp"
#include "driver/i2c_master.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <cstdint>
class BatMon {
public:
@@ -20,9 +22,10 @@ public:
void pooler(); // FIXME:
private:
static inline i2c_device_config_t _dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = 0x70,
.scl_speed_hz = 100000,
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = 0x36,
.scl_speed_hz = 100000,
.flags = 0,
};
BatMon();

View File

@@ -7,16 +7,17 @@
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <cstdint>
typedef enum {
L1 = 1 << 1,
L2 = 1 << 6,
L3 = 1 << 0,
L4 = 1 << 7,
R1 = 1 << 5,
R2 = 1 << 2,
R3 = 1 << 4,
R4 = 1 << 3,
BTN_START = 1 << 1,
BTN_DOWN = 1 << 6,
BTN_SELECT = 1 << 0,
BTN_LEFT = 1 << 7,
BTN_UP = 1 << 5,
BTN_B = 1 << 2,
BTN_RIGHT = 1 << 4,
BTN_A = 1 << 3,
} btn_num;
class Buttons {
@@ -24,12 +25,16 @@ public:
static Buttons& get();
void pooler(); // FIXME:
uint8_t get_pressed();
void install_isr();
void register_listener(TaskHandle_t task);
TaskHandle_t _pooler_task;
private:
Buttons();
volatile uint8_t _current;
TaskHandle_t _pooler_task;
volatile TaskHandle_t _listener = nullptr;
};

View File

@@ -0,0 +1,54 @@
// Simple piezo buzzer helper using LEDC (PWM) for square wave tones.
// Provides a tiny queued pattern player for short game SFX without blocking.
#pragma once
#include <cstdint>
class Buzzer {
public:
static Buzzer& get();
void init(); // call once from app_main
// Queue a tone. freq=0 => silence. gap_ms is silence after tone before next.
void tone(uint32_t freq, uint32_t duration_ms, uint32_t gap_ms = 0);
// Convenience SFX
void beepRotate();
void beepMove();
void beepLock();
void beepLines(int lines); // 1..4 lines
void beepLevelUp(int level); // after increment
void beepGameOver();
// Mute controls
void setMuted(bool m);
void toggleMuted();
bool isMuted() const { return _muted; }
private:
struct Step {
uint32_t freq;
uint32_t dur_ms;
uint32_t gap_ms;
};
static constexpr int MAX_QUEUE = 16;
Step _queue[MAX_QUEUE]{};
int _q_head = 0; // inclusive
int _q_tail = 0; // exclusive
bool _running = false;
bool _in_gap = false;
void* _timer = nullptr; // esp_timer_handle_t (opaque here)
bool _muted = false;
Buzzer() = default;
void enqueue(const Step& s);
bool empty() const { return _q_head == _q_tail; }
Step& front() { return _queue[_q_head]; }
void popFront();
void startNext();
void schedule(uint32_t ms, bool gapPhase);
void applyFreq(uint32_t freq);
static void timerCb(void* arg);
void clearQueue() { _q_head = _q_tail = 0; }
};

View File

@@ -0,0 +1,30 @@
#ifndef CB_CONFIG_HPP
#define CB_CONFIG_HPP
#include "hal/spi_types.h"
#include "soc/gpio_num.h"
#define I2C_SCL GPIO_NUM_8
#define I2C_SDA GPIO_NUM_9
#define SPI_MOSI GPIO_NUM_5
#define SPI_MISO GPIO_NUM_0
#define SPI_SCK GPIO_NUM_4
#define SPI_DISP_CS GPIO_NUM_24
#define SPI_DISP_DISP GPIO_NUM_11
#define SPI_BUS SPI2_HOST
#include "cardboy/sdk/display_spec.hpp"
#define DISP_WIDTH cardboy::sdk::kDisplayWidth
#define DISP_HEIGHT cardboy::sdk::kDisplayHeight
#define BUZZER_PIN GPIO_NUM_25
#define PWR_INT GPIO_NUM_10
#define PWR_KILL GPIO_NUM_12
#define EXP_INT GPIO_NUM_1
#endif

View File

@@ -0,0 +1,72 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#ifndef CB_DISPLAY_HPP
#define CB_DISPLAY_HPP
#include "cardboy/backend/esp/config.hpp"
#include "cardboy/utils/utils.hpp"
#include "driver/spi_master.h"
// (Async memcpy removed for debugging simplification)
#include <array>
#include <bitset>
#include <cassert>
#include <cstdint>
namespace SMD {
static constexpr size_t kLineBytes = DISP_WIDTH / 8;
static constexpr size_t kLineMultiSingle = (kLineBytes + 2);
static constexpr size_t kLineDataBytes = kLineMultiSingle * DISP_HEIGHT + 2;
extern uint8_t* dma_buf;
void init();
// Double-buffered asynchronous frame pipeline:
// Usage pattern each frame:
// SMD::frame_ready(); // (start of frame) waits for previous transfer & ensures draw buffer is ready/synced
// ... write pixels into dma_buf via set_pixel / surface ...
// SMD::send_frame(); // (end of frame) queues SPI DMA of current framebuffer; once SPI finishes the sent buffer
// // is optionally cleared so the alternate buffer is ready for the next frame
void send_frame(bool clear_after_send = true);
void frame_ready();
bool frame_transfer_in_flight(); // optional diagnostic: is a frame transfer still in flight?
__attribute__((always_inline)) static void set_pixel(int x, int y, bool value) {
CARDBOY_CHECK(x >= 0 && x < DISP_WIDTH && y >= 0 && y < DISP_HEIGHT);
unsigned lineIdx = 2 + kLineMultiSingle * y + (x / 8);
unsigned bitIdx = 1 << (7 - (x % 8)) % 8;
if (value) {
dma_buf[lineIdx] &= ~bitIdx;
} else {
dma_buf[lineIdx] |= bitIdx;
}
}
__attribute__((always_inline)) static void set_pixel_8(int x, int y, std::uint8_t value) {
CARDBOY_CHECK(x >= 0 && x < DISP_WIDTH && y >= 0 && y < DISP_HEIGHT);
CARDBOY_CHECK((x % 8) == 0);
unsigned lineIdx = 2 + kLineMultiSingle * y + (x / 8);
dma_buf[lineIdx] = ~value;
}
extern "C" void s_spi_post_cb(spi_transaction_t* trans);
static inline spi_device_interface_config_t _devcfg = {
.mode = 0, // SPI mode 0
.clock_speed_hz = 10 * 1000 * 1000, // Clock out at 10 MHz
.spics_io_num = SPI_DISP_CS, // CS pin
.flags = SPI_DEVICE_POSITIVE_CS,
.queue_size = 1,
.pre_cb = nullptr,
.post_cb = s_spi_post_cb,
};
extern spi_device_handle_t _spi;
}; // namespace SMD
#endif // DISPLAY_HPP

View File

@@ -0,0 +1,26 @@
#pragma once
#include <esp_err.h>
#include <string_view>
class FsHelper {
public:
static FsHelper& get();
esp_err_t mount();
void unmount();
bool isMounted() const { return mounted; }
const char* basePath() const { return kBasePath; }
const char* partitionLabel() const { return kPartitionLabel; }
private:
FsHelper() = default;
static constexpr const char* kBasePath = "/lfs";
static constexpr const char* kPartitionLabel = "littlefs";
static constexpr const bool kFormatOnFailure = true;
bool mounted = false;
};

View File

@@ -5,7 +5,7 @@
#ifndef CB_I2C_GLOBAL_HPP
#define CB_I2C_GLOBAL_HPP
#include "config.hpp"
#include "cardboy/backend/esp/config.hpp"
#include "driver/i2c_master.h"
@@ -22,7 +22,7 @@ private:
.scl_io_num = I2C_SCL,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.flags = {.enable_internal_pullup = false, .allow_pd = true},
.flags = {.enable_internal_pullup = true, .allow_pd = true},
};
i2c_master_bus_handle_t _bus_handle;
}; // namespace i2c_global

View File

@@ -6,15 +6,16 @@
#define POWER_HELPER_HPP
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
class PowerHelper {
public:
static PowerHelper& get();
bool is_slow() const;
void set_slow(bool slow);
void reset_slow_isr(); // FIXME:
void delay(int slow_ms, int normal_ms);
bool is_slow() const;
void set_slow(bool slow);
BaseType_t reset_slow_isr(BaseType_t* xHigherPriorityTaskWoken);
void delay(int slow_ms, int normal_ms);
void install_isr();

View File

@@ -10,6 +10,7 @@ class Shutdowner {
public:
static Shutdowner& get();
void install_isr();
void shutdown();
private:
Shutdowner();
};

View File

@@ -5,7 +5,7 @@
#ifndef CB_SPI_GLOBAL_HPP
#define CB_SPI_GLOBAL_HPP
#include "config.hpp"
#include "cardboy/backend/esp/config.hpp"
#include "driver/spi_master.h"
@@ -20,7 +20,7 @@ private:
.sclk_io_num = SPI_SCK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 400 * 240 * 2};
.max_transfer_sz = 12482U};
}; // namespace spi_global

View File

@@ -0,0 +1,85 @@
#pragma once
#include <cardboy/sdk/display_spec.hpp>
#include "cardboy/backend/esp/display.hpp"
#include "cardboy/sdk/platform.hpp"
#include "cardboy/sdk/services.hpp"
#include <cstdint>
#include <memory>
namespace cardboy::backend::esp {
class EspRuntime;
class EspFramebuffer final : public cardboy::sdk::FramebufferFacade<EspFramebuffer> {
public:
EspFramebuffer() = default;
[[nodiscard]] int width_impl() const { return cardboy::sdk::kDisplayWidth; }
[[nodiscard]] int height_impl() const { return cardboy::sdk::kDisplayHeight; }
void __attribute__((always_inline)) drawPixel_impl(int x, int y, bool on) { SMD::set_pixel(x, y, on); }
void __attribute__((always_inline)) drawBits8_impl(int x, int y, std::uint8_t bits) {
SMD::set_pixel_8(x, y, bits);
}
void clear_impl(bool on);
void frameReady_impl();
void sendFrame_impl(bool clearAfterSend);
[[nodiscard]] bool frameInFlight_impl() const;
};
class EspInput final : public cardboy::sdk::InputFacade<EspInput> {
public:
cardboy::sdk::InputState readState_impl();
};
class EspClock final : public cardboy::sdk::ClockFacade<EspClock> {
public:
std::uint32_t millis_impl();
void sleep_ms_impl(std::uint32_t ms);
};
class EspRuntime {
public:
EspRuntime();
~EspRuntime();
cardboy::sdk::Services& serviceRegistry();
EspFramebuffer framebuffer;
EspInput input;
EspClock clock;
private:
void initializeHardware();
class BuzzerService;
class BatteryService;
class StorageService;
class RandomService;
class HighResClockService;
class PowerService;
class FilesystemService;
std::unique_ptr<BuzzerService> buzzerService;
std::unique_ptr<BatteryService> batteryService;
std::unique_ptr<StorageService> storageService;
std::unique_ptr<RandomService> randomService;
std::unique_ptr<HighResClockService> highResClockService;
std::unique_ptr<PowerService> powerService;
std::unique_ptr<FilesystemService> filesystemService;
cardboy::sdk::Services services{};
};
struct Backend {
using Framebuffer = EspFramebuffer;
using Input = EspInput;
using Clock = EspClock;
};
} // namespace cardboy::backend::esp
namespace cardboy::backend {
using EspBackend = esp::Backend;
} // namespace cardboy::backend

View File

@@ -0,0 +1,117 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "cardboy/backend/esp/bat_mon.hpp"
#include "cardboy/backend/esp/power_helper.hpp"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "cardboy/backend/esp/i2c_global.hpp"
#include "cardboy/backend/esp/shutdowner.hpp"
static i2c_master_dev_handle_t dev_handle;
BatMon& BatMon::get() {
static BatMon bat_mon;
return bat_mon;
}
static void start_pooler(void* arg) { static_cast<BatMon*>(arg)->pooler(); }
void WriteRegister(uint8_t reg, uint16_t value) {
uint8_t buf2[3];
buf2[0] = reg;
buf2[1] = value & 0xFF;
buf2[2] = value >> 8;
ESP_ERROR_CHECK(i2c_master_transmit(dev_handle, buf2, sizeof(buf2), -1));
}
uint16_t ReadRegister(uint8_t reg) {
uint16_t buffer;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 2, -1));
return buffer;
}
void WriteAndVerifyRegister(char RegisterAddress, int RegisterValueToWrite) {
int attempt = 0;
uint16_t RegisterValueRead;
do {
WriteRegister(RegisterAddress, RegisterValueToWrite);
vTaskDelay(1 / portTICK_PERIOD_MS);
RegisterValueRead = ReadRegister(RegisterAddress);
} while (RegisterValueToWrite != RegisterValueRead && attempt++ < 3);
}
static constexpr float RSense = 0.1; // 100mOhm
static constexpr uint16_t DesignCapMah = 180; // 100mOhm
constexpr float mahToCap(float mah) { return mah * (1000.0 / 5.0) * RSense; }
constexpr float capToMah(uint16_t cap) { return cap * (5.0 / 1000.0) / RSense; }
constexpr float regToCurrent(uint16_t reg) {
return static_cast<float>(static_cast<int16_t>(reg)) * 0.0015625f / RSense; // Convert to mA
}
constexpr uint16_t currentToReg(float current) { return static_cast<uint16_t>(current * RSense / 0.0015625f); }
constexpr float regToVoltage(uint16_t reg) {
return reg * 0.078125f * 0.001f; // Convert to volts
}
constexpr uint16_t voltageToReg(float voltage) {
return static_cast<uint16_t>(voltage / (0.078125f * 0.001f)); // Convert to register value
}
static constexpr uint16_t DesignCap = mahToCap(DesignCapMah);
static constexpr uint16_t IchgTerm = currentToReg(10);
static constexpr uint16_t VEmpty = 0b1001011001100001; // (3V/3.88V)
static constexpr uint16_t dQAcc = (DesignCap / 32);
BatMon::BatMon() {
ESP_ERROR_CHECK(i2c_master_bus_add_device(I2cGlobal::get().get_bus_handle(), &_dev_cfg, &dev_handle));
bool StatusPOR = ReadRegister(0x00) & 0x0002;
if (StatusPOR) // POR reset
{
printf("Gas gauge reset!\n");
while (ReadRegister(0x3D) & 1)
vTaskDelay(10 / portTICK_PERIOD_MS);
uint16_t HibCFG = ReadRegister(0xBA); // Store original HibCFG value
WriteRegister(0x60, 0x90); // Exit Hibernate Mode step 1
WriteRegister(0xBA, 0x0); // Exit Hibernate Mode step 2
WriteRegister(0x60, 0x0); // Exit Hibernate Mode step 3
WriteRegister(0x18, DesignCap); // Write DesignCap
WriteRegister(0x45, DesignCap / 32); // Write dQAcc
WriteRegister(0x1E, IchgTerm); // Write IchgTerm
WriteRegister(0x3A, VEmpty); // Write VEmpty
WriteRegister(0x46, dQAcc * 44138 / DesignCap); // Write dPAcc
WriteRegister(0xDB, 0x8000); // Write ModelCFG
// Poll ModelCFG.Refresh(highest bit), proceed to Step 4 when ModelCFG.Refresh = 0.
while (ReadRegister(0xDB) & 0x8000)
vTaskDelay(10 / portTICK_PERIOD_MS); // 10ms wait loop. Do not continue until ModelCFG.Refresh == 0.
WriteRegister(0xBA, HibCFG); // Restore Original HibCFG value
uint16_t Status = ReadRegister(0x00); // Read Status
WriteAndVerifyRegister(0x00, Status & 0xFFFD); // Write and Verify Status with POR bit cleared
}
xTaskCreate(&start_pooler, "BatMon", 2048, this, tskIDLE_PRIORITY, &_pooler_task);
}
void BatMon::pooler() {
while (true) {
uint8_t reg = 8;
uint16_t buffer;
_charge = capToMah(ReadRegister(0x05));
_current = regToCurrent(ReadRegister(0x0B));
_voltage = regToVoltage(ReadRegister(0x09));
PowerHelper::get().delay(10000, 1000);
if (_voltage < 3.0f) {
Shutdowner::get().shutdown();
}
}
}
float BatMon::get_voltage() const { return _voltage; }
float BatMon::get_charge() const { return _charge; }
float BatMon::get_current() const { return _current; }

View File

@@ -0,0 +1,100 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "cardboy/backend/esp/buttons.hpp"
#include <driver/gpio.h>
#include <esp_err.h>
#include "cardboy/backend/esp/power_helper.hpp"
#include <rom/ets_sys.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "cardboy/backend/esp/config.hpp"
#include "cardboy/backend/esp/i2c_global.hpp"
static i2c_master_dev_handle_t dev_handle;
static inline i2c_device_config_t dev_cfg = {
.dev_addr_length = I2C_ADDR_BIT_LEN_7,
.device_address = 0x20,
.scl_speed_hz = 50000,
};
Buttons& Buttons::get() {
static Buttons buttons;
return buttons;
}
static void start_pooler(void* arg) { static_cast<Buttons*>(arg)->pooler(); }
static bool is_on_low;
static void wakeup(void* arg) {
if (is_on_low) {
ESP_ERROR_CHECK(gpio_set_intr_type(EXP_INT, GPIO_INTR_HIGH_LEVEL));
ESP_ERROR_CHECK(gpio_wakeup_enable(EXP_INT, GPIO_INTR_HIGH_LEVEL));
is_on_low = false;
BaseType_t xResult = pdFAIL;
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xTaskNotifyFromISR(Buttons::get()._pooler_task, 0, eNoAction, &xHigherPriorityTaskWoken);
PowerHelper::get().reset_slow_isr(&xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
} else {
ESP_ERROR_CHECK(gpio_set_intr_type(EXP_INT, GPIO_INTR_LOW_LEVEL));
ESP_ERROR_CHECK(gpio_wakeup_enable(EXP_INT, GPIO_INTR_LOW_LEVEL));
is_on_low = true;
}
}
Buttons::Buttons() {
ESP_ERROR_CHECK(i2c_master_bus_add_device(I2cGlobal::get().get_bus_handle(), &dev_cfg, &dev_handle));
uint8_t buf2[2];
// Config
buf2[0] = 6;
buf2[1] = 0xFF;
ESP_ERROR_CHECK(i2c_master_transmit(dev_handle, buf2, sizeof(buf2), -1));
buf2[0] = 7;
buf2[1] = 0x80;
ESP_ERROR_CHECK(i2c_master_transmit(dev_handle, buf2, sizeof(buf2), -1));
xTaskCreate(&start_pooler, "ButtonsPooler", 2048, this, 2, &_pooler_task);
ESP_ERROR_CHECK(gpio_reset_pin(EXP_INT));
ESP_ERROR_CHECK(gpio_set_direction(EXP_INT, GPIO_MODE_INPUT));
ESP_ERROR_CHECK(gpio_set_pull_mode(EXP_INT, GPIO_FLOATING));
ESP_ERROR_CHECK(gpio_set_intr_type(EXP_INT, GPIO_INTR_LOW_LEVEL));
ESP_ERROR_CHECK(gpio_wakeup_enable(EXP_INT, GPIO_INTR_LOW_LEVEL));
is_on_low = true;
}
static void delay(unsigned long long loop) {
for (unsigned long long i = 0; i < loop; i++) {
asm volatile("nop");
}
}
void Buttons::pooler() {
while (true) {
BaseType_t xResult = xTaskNotifyWait(pdFALSE, ULONG_MAX, nullptr, portMAX_DELAY);
uint8_t reg = 0;
uint8_t buffer;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 1, -1));
_current = buffer;
// read second port too to clear the interrupt
reg = 1;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 1, -1));
if (_listener)
xTaskNotifyGive(_listener);
}
}
uint8_t Buttons::get_pressed() { return _current; }
void Buttons::install_isr() { gpio_isr_handler_add(EXP_INT, wakeup, nullptr); }
void Buttons::register_listener(TaskHandle_t task) { _listener = task; }

View File

@@ -0,0 +1,160 @@
// Buzzer implementation
#include "cardboy/backend/esp/buzzer.hpp"
#include "cardboy/backend/esp/config.hpp"
#include <driver/ledc.h>
#include <esp_err.h>
#include <esp_timer.h>
static constexpr ledc_mode_t LEDC_MODE = LEDC_LOW_SPEED_MODE; // low speed is fine
static constexpr ledc_timer_t LEDC_TIMER = LEDC_TIMER_0;
static constexpr ledc_channel_t LEDC_CH = LEDC_CHANNEL_0;
static constexpr ledc_timer_bit_t LEDC_BITS = LEDC_TIMER_10_BIT;
Buzzer& Buzzer::get() {
static Buzzer b;
return b;
}
void Buzzer::init() {
ledc_timer_config_t tcfg{};
tcfg.speed_mode = LEDC_MODE;
tcfg.timer_num = LEDC_TIMER;
tcfg.duty_resolution = LEDC_BITS;
tcfg.freq_hz = 1000; // placeholder, changed per tone
tcfg.clk_cfg = LEDC_AUTO_CLK;
ESP_ERROR_CHECK(ledc_timer_config(&tcfg));
ledc_channel_config_t ccfg{};
ccfg.speed_mode = LEDC_MODE;
ccfg.channel = LEDC_CH;
ccfg.timer_sel = LEDC_TIMER;
ccfg.gpio_num = static_cast<int>(BUZZER_PIN);
ccfg.duty = 0; // start silent
ccfg.hpoint = 0;
ccfg.intr_type = LEDC_INTR_DISABLE;
ESP_ERROR_CHECK(ledc_channel_config(&ccfg));
esp_timer_create_args_t args{};
args.callback = &Buzzer::timerCb;
args.arg = this;
args.name = "buzz";
ESP_ERROR_CHECK(esp_timer_create(&args, reinterpret_cast<esp_timer_handle_t*>(&_timer)));
}
void Buzzer::applyFreq(uint32_t freq) {
if (freq == 0) {
ledc_stop(LEDC_MODE, LEDC_CH, 0);
return;
}
ledc_set_freq(LEDC_MODE, LEDC_TIMER, freq);
ledc_set_duty(LEDC_MODE, LEDC_CH, (1 << LEDC_BITS) / 2);
ledc_update_duty(LEDC_MODE, LEDC_CH);
}
void Buzzer::enqueue(const Step& s) {
int nextTail = (_q_tail + 1) % MAX_QUEUE;
if (nextTail == _q_head) { // full, drop oldest
_q_head = (_q_head + 1) % MAX_QUEUE;
}
_queue[_q_tail] = s;
_q_tail = nextTail;
}
void Buzzer::popFront() {
if (!empty())
_q_head = (_q_head + 1) % MAX_QUEUE;
}
void Buzzer::startNext() {
if (empty()) {
_running = false;
applyFreq(0);
return;
}
_running = true;
_in_gap = false;
Step& s = front();
applyFreq(s.freq);
schedule(s.dur_ms, false);
}
void Buzzer::schedule(uint32_t ms, bool gapPhase) {
if (!_timer)
return;
_in_gap = gapPhase;
esp_timer_stop(reinterpret_cast<esp_timer_handle_t>(_timer));
esp_timer_start_once(reinterpret_cast<esp_timer_handle_t>(_timer), (uint64_t) ms * 1000ULL);
}
void Buzzer::timerCb(void* arg) {
auto* self = static_cast<Buzzer*>(arg);
if (!self)
return;
if (self->_in_gap) {
self->popFront();
self->startNext();
return;
}
// Tone finished
if (!self->empty()) {
auto& s = self->front();
if (s.gap_ms) {
self->applyFreq(0);
self->schedule(s.gap_ms, true);
return;
}
self->popFront();
self->startNext();
}
}
void Buzzer::tone(uint32_t freq, uint32_t duration_ms, uint32_t gap_ms) {
if (_muted)
return; // ignore while muted
Step s{freq, duration_ms, gap_ms};
enqueue(s);
if (!_running)
startNext();
}
// ---- Game SFX ----
void Buzzer::beepRotate() { tone(1800, 25); }
void Buzzer::beepMove() { tone(1200, 12); }
void Buzzer::beepLock() { tone(900, 25); }
void Buzzer::beepLines(int lines) {
static const uint32_t base = 1100;
for (int i = 0; i < lines; ++i) {
tone(base + i * 190, 40, 12);
}
}
void Buzzer::beepLevelUp(int) {
tone(1600, 70, 25);
tone(2000, 90, 0);
}
void Buzzer::beepGameOver() {
tone(1000, 140, 40);
tone(700, 140, 40);
tone(400, 260, 0);
}
void Buzzer::setMuted(bool m) {
if (m == _muted)
return;
_muted = m;
if (_muted) {
clearQueue();
applyFreq(0);
if (_timer) {
esp_timer_stop(reinterpret_cast<esp_timer_handle_t>(_timer));
}
_running = false;
_in_gap = false;
} else {
// confirmation chirp
tone(1500, 40, 10);
tone(1900, 60, 0);
}
}
void Buzzer::toggleMuted() { setMuted(!_muted); }

View File

@@ -0,0 +1,155 @@
// Double-buffered display implementation with async memcpy ---------------------------------
#include "cardboy/backend/esp/display.hpp"
#include <cassert>
#include <cstring>
#include <driver/gpio.h>
#include "driver/spi_master.h"
#include "esp_async_memcpy.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "freertos/task.h"
DMA_ATTR static uint8_t s_dma_buffer0[SMD::kLineDataBytes]{};
DMA_ATTR static uint8_t s_dma_buffer1[SMD::kLineDataBytes]{};
static uint8_t* s_dma_buffers[2] = {s_dma_buffer0, s_dma_buffer1};
DMA_ATTR static uint8_t dma_buf_template[SMD::kLineDataBytes]{};
uint8_t* SMD::dma_buf = s_dma_buffers[0];
spi_device_handle_t SMD::_spi;
static spi_transaction_t _tx{};
static SemaphoreHandle_t _txSem = nullptr;
static bool _vcom = false;
static TaskHandle_t s_clearTaskHandle = nullptr;
static SemaphoreHandle_t s_clearReqSem = nullptr;
static SemaphoreHandle_t s_bufferSem[2] = {nullptr, nullptr};
static bool s_clearPending[2] = {true, true};
static async_memcpy_config_t config = ASYNC_MEMCPY_DEFAULT_CONFIG();
// update the maximum data stream supported by underlying DMA engine
static async_memcpy_handle_t driver = nullptr;
static volatile int s_drawBufIdx = 0;
static unsigned char reverse_bits3(unsigned char b) { return (b * 0x0202020202ULL & 0x010884422010ULL) % 0x3ff; }
static bool IRAM_ATTR my_async_memcpy_cb(async_memcpy_handle_t /*mcp_hdl*/, async_memcpy_event_t* /*event*/,
void* cb_args) {
BaseType_t high_task_wakeup = pdFALSE;
auto sem = static_cast<SemaphoreHandle_t>(cb_args);
xSemaphoreGiveFromISR(sem, &high_task_wakeup);
return high_task_wakeup == pdTRUE;
}
extern "C" void IRAM_ATTR s_spi_post_cb(spi_transaction_t* /*t*/) {
BaseType_t hpw = pdFALSE;
xSemaphoreGiveFromISR(s_clearReqSem, &hpw);
if (hpw)
portYIELD_FROM_ISR();
}
static void clear_task(void*) {
for (;;) {
if (xSemaphoreTake(s_clearReqSem, portMAX_DELAY) == pdTRUE) {
spi_transaction_t* r = nullptr;
ESP_ERROR_CHECK(spi_device_get_trans_result(SMD::_spi, &r, 0));
int bufIdx = (int) r->user;
xSemaphoreGive(_txSem);
const bool shouldClear = s_clearPending[bufIdx];
s_clearPending[bufIdx] = true;
if (shouldClear) {
constexpr unsigned alignedSize = SMD::kLineDataBytes - (SMD::kLineDataBytes % 4);
static_assert(SMD::kLineDataBytes - alignedSize < 8); // Last byte is zero anyway
ESP_ERROR_CHECK(esp_async_memcpy(driver, s_dma_buffers[bufIdx], dma_buf_template, alignedSize,
my_async_memcpy_cb, static_cast<void*>(s_bufferSem[bufIdx])));
} else {
if (!xSemaphoreGive(s_bufferSem[bufIdx]))
assert(false);
}
}
}
}
void SMD::init() {
spi_bus_add_device(SPI_BUS, &_devcfg, &_spi);
ESP_ERROR_CHECK(gpio_reset_pin(SPI_DISP_DISP));
ESP_ERROR_CHECK(gpio_set_direction(SPI_DISP_DISP, GPIO_MODE_OUTPUT));
ESP_ERROR_CHECK(gpio_set_level(SPI_DISP_DISP, 1));
ESP_ERROR_CHECK(gpio_hold_en(SPI_DISP_DISP));
for (int buf = 0; buf < 2; ++buf) {
auto* fb = s_dma_buffers[buf];
for (uint8_t i = 0; i < DISP_HEIGHT; ++i) {
fb[kLineMultiSingle * i + 1] = reverse_bits3(i + 1);
fb[2 + kLineMultiSingle * i + kLineBytes] = 0;
}
fb[kLineDataBytes - 1] = 0;
}
s_drawBufIdx = 0;
dma_buf = s_dma_buffers[s_drawBufIdx];
for (int y = 0; y < DISP_HEIGHT; ++y)
for (int x = 0; x < DISP_WIDTH; ++x)
set_pixel(x, y, false);
std::memcpy(dma_buf_template, dma_buf, sizeof(dma_buf_template));
std::memcpy(s_dma_buffers[1], dma_buf_template, sizeof(dma_buf_template));
ESP_ERROR_CHECK(esp_async_memcpy_install(&config, &driver)); // install driver with default DMA engine
s_clearReqSem = xSemaphoreCreateBinary();
for (int i = 0; i < 2; ++i) {
s_bufferSem[i] = xSemaphoreCreateBinary();
xSemaphoreGive(s_bufferSem[i]);
}
_txSem = xSemaphoreCreateBinary();
xSemaphoreGive(_txSem);
xTaskCreate(clear_task, "fbclr", 1536, nullptr, tskIDLE_PRIORITY + 1, &s_clearTaskHandle);
}
bool SMD::frame_transfer_in_flight() { return uxSemaphoreGetCount(s_bufferSem[s_drawBufIdx]) == 0; }
void SMD::send_frame(bool clear_after_send) {
assert(driver != nullptr);
if (!xSemaphoreTake(_txSem, portMAX_DELAY))
assert(false);
const int sendIdx = s_drawBufIdx;
assert(sendIdx >= 0 && sendIdx < 2);
SemaphoreHandle_t sem = s_bufferSem[sendIdx];
if (!xSemaphoreTake(sem, 0))
assert(false);
const int nextDrawIdx = sendIdx ^ 1;
s_clearPending[sendIdx] = clear_after_send;
_vcom = !_vcom;
_tx = {};
_tx.tx_buffer = s_dma_buffers[sendIdx];
_tx.length = SMD::kLineDataBytes * 8;
_tx.user = (void*) (sendIdx);
s_dma_buffers[sendIdx][0] = 0b10000000 | (_vcom << 6);
ESP_ERROR_CHECK(spi_device_queue_trans(_spi, &_tx, 0));
s_drawBufIdx = nextDrawIdx;
dma_buf = s_dma_buffers[nextDrawIdx];
}
void SMD::frame_ready() {
SemaphoreHandle_t sem = s_bufferSem[s_drawBufIdx];
// uint64_t waitedUs = 0;
if (!uxSemaphoreGetCount(sem)) {
// uint64_t start = esp_timer_get_time();
if (!xSemaphoreTake(sem, portMAX_DELAY))
assert(false);
if (!xSemaphoreGive(sem))
assert(false);
// waitedUs = esp_timer_get_time() - start;
}
// if (waitedUs)
// printf("Waited %" PRIu64 " us\n", waitedUs);
}

View File

@@ -0,0 +1,230 @@
#include "cardboy/backend/esp_backend.hpp"
#include "cardboy/backend/esp/bat_mon.hpp"
#include "cardboy/backend/esp/buttons.hpp"
#include "cardboy/backend/esp/buzzer.hpp"
#include "cardboy/backend/esp/config.hpp"
#include "cardboy/backend/esp/display.hpp"
#include "cardboy/backend/esp/fs_helper.hpp"
#include "cardboy/backend/esp/i2c_global.hpp"
#include "cardboy/backend/esp/power_helper.hpp"
#include "cardboy/backend/esp/shutdowner.hpp"
#include "cardboy/backend/esp/spi_global.hpp"
#include "cardboy/sdk/display_spec.hpp"
#include "driver/gpio.h"
#include "esp_err.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "nvs.h"
#include "nvs_flash.h"
#include <algorithm>
#include <cstdint>
#include <string>
#include <string_view>
namespace cardboy::backend::esp {
namespace {
void ensureNvsInit() {
static bool nvsReady = false;
if (nvsReady)
return;
esp_err_t err = nvs_flash_init();
if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
err = nvs_flash_init();
}
ESP_ERROR_CHECK(err);
nvsReady = true;
}
} // namespace
class EspRuntime::BuzzerService final : public cardboy::sdk::IBuzzer {
public:
void tone(std::uint32_t freq, std::uint32_t duration_ms, std::uint32_t gap_ms = 0) override {
Buzzer::get().tone(freq, duration_ms, gap_ms);
}
void beepRotate() override { Buzzer::get().beepRotate(); }
void beepMove() override { Buzzer::get().beepMove(); }
void beepLock() override { Buzzer::get().beepLock(); }
void beepLines(int lines) override { Buzzer::get().beepLines(lines); }
void beepLevelUp(int level) override { Buzzer::get().beepLevelUp(level); }
void beepGameOver() override { Buzzer::get().beepGameOver(); }
void setMuted(bool muted) override { Buzzer::get().setMuted(muted); }
void toggleMuted() override { Buzzer::get().toggleMuted(); }
[[nodiscard]] bool isMuted() const override { return Buzzer::get().isMuted(); }
};
class EspRuntime::BatteryService final : public cardboy::sdk::IBatteryMonitor {
public:
[[nodiscard]] bool hasData() const override { return true; }
[[nodiscard]] float voltage() const override { return BatMon::get().get_voltage(); }
[[nodiscard]] float charge() const override { return BatMon::get().get_charge(); }
[[nodiscard]] float current() const override { return BatMon::get().get_current(); }
};
class EspRuntime::StorageService final : public cardboy::sdk::IStorage {
public:
[[nodiscard]] bool readUint32(std::string_view ns, std::string_view key, std::uint32_t& out) override {
ensureNvsInit();
nvs_handle_t handle;
std::string nsStr(ns);
std::string keyStr(key);
if (nvs_open(nsStr.c_str(), NVS_READONLY, &handle) != ESP_OK)
return false;
std::uint32_t value = 0;
esp_err_t err = nvs_get_u32(handle, keyStr.c_str(), &value);
nvs_close(handle);
if (err != ESP_OK)
return false;
out = value;
return true;
}
void writeUint32(std::string_view ns, std::string_view key, std::uint32_t value) override {
ensureNvsInit();
nvs_handle_t handle;
std::string nsStr(ns);
std::string keyStr(key);
if (nvs_open(nsStr.c_str(), NVS_READWRITE, &handle) != ESP_OK)
return;
nvs_set_u32(handle, keyStr.c_str(), value);
nvs_commit(handle);
nvs_close(handle);
}
};
class EspRuntime::RandomService final : public cardboy::sdk::IRandom {
public:
[[nodiscard]] std::uint32_t nextUint32() override { return esp_random(); }
};
class EspRuntime::HighResClockService final : public cardboy::sdk::IHighResClock {
public:
[[nodiscard]] std::uint64_t micros() override { return static_cast<std::uint64_t>(esp_timer_get_time()); }
};
class EspRuntime::PowerService final : public cardboy::sdk::IPowerManager {
public:
void setSlowMode(bool enable) override { PowerHelper::get().set_slow(enable); }
[[nodiscard]] bool isSlowMode() const override { return PowerHelper::get().is_slow(); }
};
class EspRuntime::FilesystemService final : public cardboy::sdk::IFilesystem {
public:
bool mount() override { return FsHelper::get().mount() == ESP_OK; }
[[nodiscard]] bool isMounted() const override { return FsHelper::get().isMounted(); }
[[nodiscard]] std::string basePath() const override {
const char* path = FsHelper::get().basePath();
return path ? std::string(path) : std::string{};
}
};
EspRuntime::EspRuntime() : framebuffer(), input(), clock() {
initializeHardware();
buzzerService = std::make_unique<BuzzerService>();
batteryService = std::make_unique<BatteryService>();
storageService = std::make_unique<StorageService>();
randomService = std::make_unique<RandomService>();
highResClockService = std::make_unique<HighResClockService>();
powerService = std::make_unique<PowerService>();
filesystemService = std::make_unique<FilesystemService>();
services.buzzer = buzzerService.get();
services.battery = batteryService.get();
services.storage = storageService.get();
services.random = randomService.get();
services.highResClock = highResClockService.get();
services.powerManager = powerService.get();
services.filesystem = filesystemService.get();
}
EspRuntime::~EspRuntime() = default;
cardboy::sdk::Services& EspRuntime::serviceRegistry() { return services; }
void EspRuntime::initializeHardware() {
static bool initialized = false;
if (initialized)
return;
initialized = true;
ensureNvsInit();
PowerHelper::get();
Shutdowner::get();
Buttons::get();
esp_err_t isrErr = gpio_install_isr_service(0);
if (isrErr != ESP_OK && isrErr != ESP_ERR_INVALID_STATE) {
ESP_ERROR_CHECK(isrErr);
}
Shutdowner::get().install_isr();
PowerHelper::get().install_isr();
Buttons::get().install_isr();
I2cGlobal::get();
BatMon::get();
SpiGlobal::get();
SMD::init();
Buzzer::get().init();
FsHelper::get().mount();
}
void EspFramebuffer::clear_impl(bool on) {
for (int y = 0; y < height_impl(); ++y)
for (int x = 0; x < width_impl(); ++x)
SMD::set_pixel(x, y, on);
}
void EspFramebuffer::frameReady_impl() { SMD::frame_ready(); }
void EspFramebuffer::sendFrame_impl(bool clearAfterSend) { SMD::send_frame(clearAfterSend); }
bool EspFramebuffer::frameInFlight_impl() const { return SMD::frame_transfer_in_flight(); }
cardboy::sdk::InputState EspInput::readState_impl() {
cardboy::sdk::InputState state{};
const uint8_t pressed = Buttons::get().get_pressed();
if (pressed & BTN_UP)
state.up = true;
if (pressed & BTN_LEFT)
state.left = true;
if (pressed & BTN_RIGHT)
state.right = true;
if (pressed & BTN_DOWN)
state.down = true;
if (pressed & BTN_A)
state.a = true;
if (pressed & BTN_B)
state.b = true;
if (pressed & BTN_SELECT)
state.select = true;
if (pressed & BTN_START)
state.start = true;
return state;
}
std::uint32_t EspClock::millis_impl() {
TickType_t ticks = xTaskGetTickCount();
return static_cast<std::uint32_t>((static_cast<std::uint64_t>(ticks) * 1000ULL) / configTICK_RATE_HZ);
}
void EspClock::sleep_ms_impl(std::uint32_t ms) {
if (ms == 0)
return;
PowerHelper::get().delay(static_cast<int>(ms), static_cast<int>(ms));
}
} // namespace cardboy::backend::esp

View File

@@ -0,0 +1,68 @@
#include "cardboy/backend/esp/fs_helper.hpp"
#include <esp_idf_version.h>
#include <esp_littlefs.h>
#include <esp_log.h>
#include <cstring>
namespace {
constexpr const char* kTag = "FsHelper";
} // namespace
FsHelper& FsHelper::get() {
static FsHelper instance;
return instance;
}
esp_err_t FsHelper::mount() {
if (mounted)
return ESP_OK;
esp_vfs_littlefs_conf_t conf{};
conf.base_path = kBasePath;
conf.partition_label = kPartitionLabel;
conf.format_if_mount_failed = kFormatOnFailure;
conf.dont_mount = false;
#if ESP_IDF_VERSION_MAJOR >= 5
conf.read_only = false;
#endif
const esp_err_t err = esp_vfs_littlefs_register(&conf);
if (err != ESP_OK) {
if (err == ESP_ERR_NOT_FOUND) {
ESP_LOGE(kTag, "Failed to find LittleFS partition '%s'", kPartitionLabel);
} else if (err == ESP_FAIL) {
ESP_LOGE(kTag, "Failed to mount LittleFS at %s (consider enabling format)",
kBasePath);
} else {
ESP_LOGE(kTag, "esp_vfs_littlefs_register failed: %s", esp_err_to_name(err));
}
return err;
}
mounted = true;
size_t total = 0;
size_t used = 0;
const esp_err_t infoErr = esp_littlefs_info(kPartitionLabel, &total, &used);
if (infoErr == ESP_OK) {
ESP_LOGI(kTag, "LittleFS mounted at %s (%zu / %zu bytes used)", kBasePath, used, total);
} else {
ESP_LOGW(kTag, "LittleFS mounted but failed to query usage: %s", esp_err_to_name(infoErr));
}
return ESP_OK;
}
void FsHelper::unmount() {
if (!mounted)
return;
const esp_err_t err = esp_vfs_littlefs_unregister(kPartitionLabel);
if (err != ESP_OK) {
ESP_LOGW(kTag, "Failed to unmount LittleFS (%s)", esp_err_to_name(err));
return;
}
mounted = false;
ESP_LOGI(kTag, "LittleFS unmounted from %s", kBasePath);
}

View File

@@ -2,7 +2,7 @@
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "i2c_global.hpp"
#include "cardboy/backend/esp/i2c_global.hpp"
I2cGlobal& I2cGlobal::get() {

View File

@@ -2,11 +2,7 @@
// Created by Stepan Usatiuk on 03.03.2025.
//
#include "power_helper.hpp"
#include <config.hpp>
#include <driver/gpio.h>
#include <esp_sleep.h>
#include "cardboy/backend/esp/power_helper.hpp"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
@@ -25,32 +21,22 @@ void PowerHelper::set_slow(bool slow) {
}
}
void PowerHelper::reset_slow_isr() {
BaseType_t PowerHelper::reset_slow_isr(BaseType_t* xHigherPriorityTaskWoken) {
_slow = false;
return xEventGroupSetBitsFromISR(_event_group, 1, xHigherPriorityTaskWoken);
}
static void wakeup(void* arg) {
BaseType_t xHigherPriorityTaskWoken, xResult;
xHigherPriorityTaskWoken = pdFALSE;
_slow = false;
xResult = xEventGroupSetBitsFromISR(_event_group, 1, &xHigherPriorityTaskWoken);
xResult = static_cast<PowerHelper*>(arg)->reset_slow_isr(&xHigherPriorityTaskWoken);
if (xResult != pdFAIL) {
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
};
}
static void wakeup(void* arg) { static_cast<PowerHelper*>(arg)->reset_slow_isr(); }
PowerHelper::PowerHelper() : _event_group(xEventGroupCreate()) {
ESP_ERROR_CHECK(gpio_reset_pin(DIRECT_BTN));
ESP_ERROR_CHECK(gpio_set_direction(DIRECT_BTN, GPIO_MODE_INPUT));
ESP_ERROR_CHECK(gpio_set_pull_mode(DIRECT_BTN, GPIO_FLOATING));
ESP_ERROR_CHECK(gpio_set_intr_type(DIRECT_BTN, GPIO_INTR_HIGH_LEVEL));
ESP_ERROR_CHECK(gpio_wakeup_enable(DIRECT_BTN, GPIO_INTR_HIGH_LEVEL));
// ESP_ERROR_CHECK(gpio_install_isr_service(0));
// gpio_isr_handler_add(DIRECT_BTN, wakeup, this);
set_slow(false);
}
PowerHelper::PowerHelper() : _event_group(xEventGroupCreate()) { set_slow(false); }
void PowerHelper::delay(int slow_ms, int normal_ms) {
if (is_slow()) {
@@ -67,4 +53,6 @@ void PowerHelper::delay(int slow_ms, int normal_ms) {
vTaskDelay(normal_ms / portTICK_PERIOD_MS);
}
}
void PowerHelper::install_isr() { gpio_isr_handler_add(DIRECT_BTN, wakeup, this); }
void PowerHelper::install_isr() {
// gpio_isr_handler_add(EXP_INT, wakeup, this);
}

View File

@@ -2,38 +2,43 @@
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "shutdowner.hpp"
#include "cardboy/backend/esp/shutdowner.hpp"
#include <driver/gpio.h>
#include <esp_sleep.h>
#include "config.hpp"
#include "cardboy/backend/esp/config.hpp"
Shutdowner& Shutdowner::get() {
static Shutdowner instance;
return instance;
}
static void IRAM_ATTR shutdown(void* arg) {
static void IRAM_ATTR int_shutdown(void* arg) {
// printf("Shutting down...\n");
ESP_ERROR_CHECK(gpio_hold_dis(PWR_KILL));
ESP_ERROR_CHECK(gpio_set_level(PWR_KILL, 0));
}
Shutdowner::Shutdowner() {
ESP_ERROR_CHECK(gpio_reset_pin(PWR_INT));
ESP_ERROR_CHECK(gpio_reset_pin(PWR_KILL));
void Shutdowner::shutdown() {
ESP_ERROR_CHECK(gpio_hold_dis(PWR_KILL));
ESP_ERROR_CHECK(gpio_set_level(PWR_KILL, 0));
}
ESP_ERROR_CHECK(gpio_set_direction(PWR_INT, GPIO_MODE_INPUT));
Shutdowner::Shutdowner() {
ESP_ERROR_CHECK(gpio_reset_pin(PWR_KILL));
ESP_ERROR_CHECK(gpio_set_direction(PWR_KILL, GPIO_MODE_OUTPUT));
ESP_ERROR_CHECK(gpio_set_level(PWR_KILL, 1));
ESP_ERROR_CHECK(gpio_hold_en(PWR_KILL));
ESP_ERROR_CHECK(gpio_reset_pin(PWR_INT));
ESP_ERROR_CHECK(gpio_set_direction(PWR_INT, GPIO_MODE_INPUT));
ESP_ERROR_CHECK(gpio_set_pull_mode(PWR_INT, GPIO_FLOATING));
ESP_ERROR_CHECK(gpio_set_intr_type(PWR_INT, GPIO_INTR_LOW_LEVEL));
// ESP_ERROR_CHECK(esp_sleep_enable_gpio_wakeup());
ESP_ERROR_CHECK(gpio_wakeup_enable(PWR_INT, GPIO_INTR_LOW_LEVEL));
// ESP_ERROR_CHECK(gpio_install_isr_service(0));
ESP_ERROR_CHECK(gpio_hold_en(PWR_KILL));
// gpio_isr_handler_add(PWR_INT, shutdown, nullptr);
}
void Shutdowner::install_isr() { gpio_isr_handler_add(PWR_INT, shutdown, nullptr); }
void Shutdowner::install_isr() { gpio_isr_handler_add(PWR_INT, int_shutdown, nullptr); }

View File

@@ -2,7 +2,7 @@
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "spi_global.hpp"
#include "cardboy/backend/esp/spi_global.hpp"
SpiGlobal& SpiGlobal::get() {
static SpiGlobal SpiGlobal;

View File

@@ -0,0 +1,31 @@
idf_component_register(
INCLUDE_DIRS ""
REQUIRES backend-esp
)
set(CARDBOY_BUILD_SFML OFF CACHE BOOL "Disable desktop backend build" FORCE)
set(CARDBOY_SDK_BACKEND_LIBRARY cardboy_backend_esp CACHE STRING "Cardboy backend implementation" FORCE)
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/core" cb-sdk-build-core)
add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/../../sdk/apps" cb-sdk-build-apps)
target_compile_options(cardboy_backend_esp
INTERFACE
-fjump-tables
-ftree-switch-conversion
)
target_link_libraries(cardboy_backend_esp
INTERFACE
idf::driver
idf::esp_timer
idf::esp_driver_spi
idf::freertos
)
target_link_libraries(${COMPONENT_LIB}
INTERFACE
cardboy_backend_esp
cardboy_sdk
cardboy_apps
)

View File

@@ -0,0 +1,21 @@
dependencies:
idf:
source:
type: idf
version: 5.5.1
joltwallet/littlefs:
component_hash: 8e12955f47e27e6070b76715a96d6c75fc2b44f069e8c33679332d9bdd3120c4
dependencies:
- name: idf
require: private
version: '>=5.0'
source:
registry_url: https://components.espressif.com/
type: service
version: 1.20.1
direct_dependencies:
- idf
- joltwallet/littlefs
manifest_hash: 261ed140a57f28f061ce29bcf3ae4833c35c16fa5e15670490bf2aacedefa622
target: esp32h2
version: 2.0.0

248
Firmware/ghettoprof.sh Executable file
View File

@@ -0,0 +1,248 @@
#!/usr/bin/env bash
# parallel-pc-profile.sh — parallel symbol resolver + optional annotated disassembly
# Supports C++ demangling, LLVM disassembler, and optional no-inlines aggregation (symbol-table based).
#
# Usage:
# ./parallel-pc-profile.sh [-j jobs] [--annotate] [--no-inlines] firmware.elf pcs.txt
set -euo pipefail
usage() {
echo "Usage: $0 [-j jobs] [--annotate] [--no-inlines] firmware.elf pcs.txt"
exit 1
}
ANNOTATE=0
JOBS=""
NO_INLINES=0
# ---- args ----
while [[ $# -gt 0 ]]; do
case "$1" in
-j) JOBS="$2"; shift 2 ;;
--annotate) ANNOTATE=1; shift ;;
--no-inlines) NO_INLINES=1; shift ;;
-h|--help) usage ;;
*) break ;;
esac
done
[[ $# -lt 2 ]] && usage
ELF="$1"
PCS_IN="$2"
[[ ! -f "$ELF" ]] && { echo "ELF not found: $ELF" >&2; exit 2; }
[[ ! -f "$PCS_IN" ]] && { echo "PC log not found: $PCS_IN" >&2; exit 3; }
# ---- tools ----
ADDR2LINE=""
for t in llvm-addr2line eu-addr2line riscv32-esp-elf-addr2line xtensa-esp32-elf-addr2line addr2line; do
if command -v "$t" >/dev/null 2>&1; then ADDR2LINE="$t"; break; fi
done
[[ -z "$ADDR2LINE" ]] && { echo "No addr2line found"; exit 4; }
if command -v llvm-objdump >/dev/null 2>&1; then
OBJDUMP="llvm-objdump"
else
for t in riscv32-esp-elf-objdump xtensa-esp32-elf-objdump objdump; do
if command -v "$t" >/dev/null 2>&1; then OBJDUMP="$t"; break; fi
done
fi
[[ -z "${OBJDUMP:-}" ]] && { echo "No objdump found"; exit 5; }
if command -v llvm-nm >/dev/null 2>&1; then
NM="llvm-nm"
elif command -v nm >/dev/null 2>&1; then
NM="nm"
else
NM=""
fi
if command -v c++filt >/dev/null 2>&1; then
CPPFILT="c++filt"
elif command -v llvm-cxxfilt >/dev/null 2>&1; then
CPPFILT="llvm-cxxfilt"
else
CPPFILT=""
fi
# ---- cores ----
if [[ -z "$JOBS" ]]; then
if command -v nproc >/dev/null 2>&1; then JOBS=$(nproc)
elif [[ "$OSTYPE" == "darwin"* ]]; then JOBS=$(sysctl -n hw.ncpu 2>/dev/null || echo 4)
else JOBS=$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)
fi
fi
(( JOBS = JOBS > 1 ? JOBS - 1 : 1 ))
echo ">> Using $JOBS parallel jobs"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
# ---- extract PCs ----
grep -aoE '0x[0-9a-fA-F]+' "$PCS_IN" | tr 'A-F' 'a-f' | sort | uniq -c >"$TMP/pc_counts.txt" || true
awk '{print $2}' "$TMP/pc_counts.txt" >"$TMP/addrs.txt"
[[ ! -s "$TMP/addrs.txt" ]] && { echo "No addresses found"; exit 5; }
# ---- parallel addr2line (live PC -> function to stderr) ----
CHUNK=400
split -l "$CHUNK" "$TMP/addrs.txt" "$TMP/chunk."
find "$TMP" -name 'chunk.*' -type f -print0 \
| xargs -0 -I{} -n1 -P "$JOBS" bash -c '
set -euo pipefail
ADDR2LINE="$1"; ELF="$2"; CHUNK="$3"; CPP="$4"
OUT="${CHUNK}.sym"
"$ADDR2LINE" -a -f -e "$ELF" $(cat "$CHUNK") \
| tee "$OUT" \
| awk '"'"'NR%3==1{a=$0;next} NR%3==2{f=$0; printf "%s\t%s\n",a,f; next} NR%3==0{next}'"'"' \
| { if [[ -n "$CPP" ]]; then "$CPP"; else cat; fi; } 1>&2
' _ "$ADDR2LINE" "$ELF" {} "$CPPFILT"
# Collate triplets
cat "$TMP"/chunk.*.sym > "$TMP/symbols.raw"
# ---- parse 3-line addr/func/file:line ----
# Normalize leading zeros in addresses so joins match grep-extracted PCs
awk 'NR%3==1{a=$0; sub(/^0x0+/, "0x", a); next} NR%3==2{f=$0; next} NR%3==0{print a "\t" f "\t" $0}' \
"$TMP/symbols.raw" >"$TMP/map.tsv"
# ---- counts: addr -> samplecount ----
awk '{printf "%s\t%s\n",$2,$1}' "$TMP/pc_counts.txt" | sort -k1,1 >"$TMP/counts.tsv"
# ---- choose mapping: default (addr2line; may show inlined names) vs --no-inlines (symbol-table) ----
DEFAULT_ADDR_FUNC="$TMP/addr_func.tsv"
cut -f1,2 "$TMP/map.tsv" | sort -k1,1 >"$DEFAULT_ADDR_FUNC"
if [[ "$NO_INLINES" == "1" ]]; then
if [[ -z "$NM" ]]; then
echo "WARNING: nm/llvm-nm not found; falling back to inline-aware mapping." >&2
ADDR_FUNC_FILE="$DEFAULT_ADDR_FUNC"
else
echo ">> Building symbol table for no-inlines mapping..."
# Create sorted function symbols: hexaddr\tname (demangled if possible afterwards)
# Try llvm-nm format first; fall back to generic nm.
if [[ "$NM" == "llvm-nm" ]]; then
# llvm-nm -n --defined-only emits: ADDRESS TYPE NAME
"$NM" -n --defined-only "$ELF" \
| awk '/ [Tt] /{print $1 "\t" $3}' > "$TMP/syms.raw"
else
# generic nm -n emits: ADDRESS TYPE NAME (varies a bit across platforms)
"$NM" -n --defined-only "$ELF" 2>/dev/null \
| awk '/ [Tt] /{print $1 "\t" $3}' > "$TMP/syms.raw" || true
# macOS nm might output different columns; handle common alt layout:
if [[ ! -s "$TMP/syms.raw" ]]; then
"$NM" -n "$ELF" 2>/dev/null | awk '/ [Tt] /{print $1 "\t" $3}' > "$TMP/syms.raw" || true
fi
fi
if [[ -n "$CPPFILT" && -s "$TMP/syms.raw" ]]; then
"$CPPFILT" < "$TMP/syms.raw" > "$TMP/syms.dem.raw" || cp "$TMP/syms.raw" "$TMP/syms.dem.raw"
else
cp "$TMP/syms.raw" "$TMP/syms.dem.raw"
fi
# Normalize addresses and sort ascending
awk '{addr=$1; sub(/^0x0+/, "0x", addr); print addr "\t" $2}' "$TMP/syms.dem.raw" \
| awk 'NF' \
| sort -k1,1 > "$TMP/syms.tsv"
if [[ ! -s "$TMP/syms.tsv" ]]; then
echo "WARNING: no text symbols found; falling back to inline-aware mapping." >&2
ADDR_FUNC_FILE="$DEFAULT_ADDR_FUNC"
else
# Map each PC to the *containing* function: last symbol with addr <= PC.
# Both syms.tsv and addrs.txt are sorted asc → single pass merge.
awk '
function hex2num(h, x, n,i,c) {
gsub(/^0x/,"",h); n=0
for(i=1;i<=length(h);i++){ c=substr(h,i,1)
x = index("0123456789abcdef", tolower(c)) - 1
if (x<0) x = index("0123456789ABCDEF", c) - 1
n = n*16 + x
}
return n
}
BEGIN {
# preload symbols
while ((getline < ARGV[1]) > 0) {
saddr[NSYM]=$1; sname[NSYM]=$2; NSYM++
}
# load PCs
while ((getline < ARGV[2]) > 0) {
pc[NPC]=$0; NPC++
}
# pointers
si=0
for (i=0; i<NPC; i++) {
p=pc[i]; pn=hex2num(p)
# advance symbol index while next symbol start <= pc
while (si+1<NSYM && hex2num(saddr[si+1]) <= pn) si++
# output mapping: p -> sname[si] (if any)
if (si<NSYM && hex2num(saddr[si]) <= pn)
printf "%s\t%s\n", p, sname[si]
else
printf "%s\t<unknown>\n", p
}
exit 0
}
' "$TMP/syms.tsv" "$TMP/addrs.txt" \
| sort -k1,1 > "$TMP/addr_func.noinline.tsv"
ADDR_FUNC_FILE="$TMP/addr_func.noinline.tsv"
fi
fi
else
ADDR_FUNC_FILE="$DEFAULT_ADDR_FUNC"
fi
# ---- aggregate to hot functions ----
join -t $'\t' -a1 -e "<unknown>" -o 1.2,2.2 "$TMP/counts.tsv" "$ADDR_FUNC_FILE" \
| awk -F'\t' '{s[$2]+=$1} END{for(k in s) printf "%8d %s\n",s[k],k}' \
| sort -nr > "$TMP/hot.txt"
# ---- demangle final hot list (if available) ----
if [[ -n "$CPPFILT" ]]; then
"$CPPFILT" < "$TMP/hot.txt" > hot_functions.txt
else
cp "$TMP/hot.txt" hot_functions.txt
fi
echo "=== Top 50 hot functions ==="
head -50 hot_functions.txt
echo "Full list in: hot_functions.txt"
# ---- annotated source+assembly (optional) ----
if (( ANNOTATE )); then
echo ">> Generating annotated source+assembly..."
awk '{printf "%s %s\n",$2,$1}' "$TMP/pc_counts.txt" >"$TMP/count.map"
if [[ "$OBJDUMP" == "llvm-objdump" ]]; then
# Portable across llvm-objdump versions
"$OBJDUMP" --source -l --demangle -d "$ELF" >"$TMP/disasm.txt"
else
"$OBJDUMP" -S -C -l -d "$ELF" >"$TMP/disasm.txt"
fi
# Overlay hit counts onto the disassembly
awk -v counts="$TMP/count.map" '
BEGIN {
while ((getline < counts) > 0) {
addr=$1; cnt=$2
gsub(/^0x/,"",addr)
map[addr]=cnt
}
}
/^[[:space:]]*[0-9a-f]+:/ {
split($1,a,":"); key=a[1]
if (key in map)
printf("%-12s %6d | %s\n", $1, map[key], substr($0, index($0,$2)))
else
print $0
next
}
{ print }
' "$TMP/disasm.txt" > annotated.S
echo "Annotated source + assembly written to: annotated.S"
echo "Tip: less -R annotated.S"
fi

View File

@@ -1,13 +1,16 @@
idf_component_register(SRCS
src/hello_world_main.cpp
src/display.cpp
src/bat_mon.cpp
src/spi_global.cpp
src/i2c_global.cpp
src/disp_tools.cpp
src/disp_tty.cpp
src/shutdowner.cpp
src/buttons.cpp
src/power_helper.cpp
PRIV_REQUIRES spi_flash esp_driver_i2c driver
INCLUDE_DIRS "include")
idf_component_register(
SRCS
"src/app_main.cpp"
PRIV_REQUIRES
sdk-esp
littlefs
REQUIRES
backend-esp
EMBED_FILES
"roms/builtin_demo1.gb"
"roms/builtin_demo2.gb"
INCLUDE_DIRS
""
)
littlefs_create_partition_image(littlefs ../flash_data FLASH_IN_PROJECT)

View File

@@ -0,0 +1,17 @@
## IDF Component Manager Manifest File
dependencies:
## Required IDF version
idf:
version: '>=4.1.0'
# # Put list of dependencies here
# # For components maintained by Espressif:
# component: "~1.0.0"
# # For 3rd party components:
# username/component: ">=1.0.0,<2.0.0"
# username2/component2:
# version: "~1.0.0"
# # For transient dependencies `public` flag can be set.
# # `public` flag doesn't have an effect dependencies of the `main` component.
# # All dependencies of `main` are public by default.
# public: true
joltwallet/littlefs: ^1.20

View File

@@ -1,29 +0,0 @@
#ifndef CB_CONFIG_HPP
#define CB_CONFIG_HPP
#include "hal/spi_types.h"
#include "soc/gpio_num.h"
#define I2C_SCL GPIO_NUM_8
#define I2C_SDA GPIO_NUM_9
#define SPI_MOSI GPIO_NUM_5
#define SPI_MISO GPIO_NUM_0
#define SPI_SCK GPIO_NUM_4
#define SPI_DISP_CS GPIO_NUM_11
#define SPI_BUS SPI2_HOST
#define DISP_WIDTH 400
#define DISP_HEIGHT 240
#define PWR_INT GPIO_NUM_10
#define PWR_KILL GPIO_NUM_12
#define SHR_OUT GPIO_NUM_23
#define SHR_CLK GPIO_NUM_3
#define SHR_SH GPIO_NUM_2
#define DIRECT_BTN GPIO_NUM_1
#endif

View File

@@ -1,28 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#ifndef CB_DISP_TOOLS_HPP
#define CB_DISP_TOOLS_HPP
#include <display.hpp>
class DispTools {
public:
static DispTools& get();
void clear();
bool get_pixel(int x, int y);
void set_pixel(int x, int y);
void reset_pixel(int x, int y);
void draw_rectangle(int x1, int y1, int x2, int y2);
void draw_line(int x1, int y1, int x2, int y2);
void draw_circle(int x, int y, int r);
void draw_to_display();
private:
SMD::disp_frame_t disp_frame;
};
#endif // DISP_TOOLS_HPP

View File

@@ -1,42 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#ifndef DISP_TTY_HPP
#define DISP_TTY_HPP
#include <array>
#include <cstddef>
#include "config.hpp"
#include <format>
class FbTty {
public:
void putchar(char c);
void putstr(const char* str);
void reset();
template<typename... Args>
auto fmt(std::format_string<Args...> fmt, Args&&... args) {
auto str = std::format(fmt, std::forward<Args>(args)...);
putstr(str.c_str());
}
private:
void draw_char(int col, int row);
int _cur_col = 0;
int _cur_row = 0;
static constexpr size_t _max_col = DISP_WIDTH / 8;
static constexpr size_t _max_row = DISP_HEIGHT / 16;
std::array<std::array<char, _max_row>, _max_col> _buf = {};
void next_col();
void next_row();
};
#endif // DISP_TTY_HPP

View File

@@ -1,44 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#ifndef CB_DISPLAY_HPP
#define CB_DISPLAY_HPP
#include "config.hpp"
#include "driver/spi_master.h"
#include <array>
#include <bitset>
class SMD {
public:
using disp_line_t = std::bitset<400>;
using disp_frame_t = std::array<disp_line_t, 240>;
static SMD& get();
void clear();
void draw(const disp_frame_t& frame);
private:
SMD();
static inline spi_device_interface_config_t _devcfg = {
.mode = 0, // SPI mode 0
.clock_speed_hz = 2 * 1000 * 1000, // Clock out at 10 MHz
.spics_io_num = SPI_DISP_CS, // CS pin
.flags = SPI_DEVICE_POSITIVE_CS,
.queue_size = 3,
// .pre_cb = lcd_spi_pre_transfer_callback, //Specify pre-transfer callback to handle D/C line
};
static constexpr size_t kLineBytes = DISP_WIDTH / 8;
spi_device_handle_t _spi;
bool _vcom = false;
static constexpr size_t kLineData = (kLineBytes + 4);
std::array<uint8_t, kLineData> buf{};
std::array<uint8_t, kLineBytes> prep_line(const SMD::disp_line_t& line);
};
#endif // DISPLAY_HPP

View File

@@ -0,0 +1,7 @@
# Built-in ROM placeholders
This directory holds Game Boy ROM images that get embedded into the firmware via `EMBED_FILES`.
The repository includes two small placeholder files (`builtin_demo1.gb` and `builtin_demo2.gb`) so
that the build system always has something to embed, but they are not valid games. Replace them
with legally distributable ROMs to ship useful built-in titles. Filenames are used to derive the
save-game slot name.

View File

@@ -0,0 +1,243 @@
// Cardboy firmware entry point: boot platform services and run the modular app system.
#include "cardboy/apps/clock_app.hpp"
#include "cardboy/apps/gameboy_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/apps/settings_app.hpp"
#include "cardboy/apps/tetris_app.hpp"
#include "cardboy/backend/esp_backend.hpp"
#include "cardboy/sdk/app_system.hpp"
#include "cardboy/sdk/persistent_settings.hpp"
#include "esp_err.h"
#include "esp_pm.h"
#include "esp_sleep.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "sdkconfig.h"
#include <algorithm>
#include <cinttypes>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <span>
#include <string>
#include <string_view>
#include <vector>
namespace {
extern "C" {
extern const uint8_t _binary_builtin_demo1_gb_start[];
extern const uint8_t _binary_builtin_demo1_gb_end[];
extern const uint8_t _binary_builtin_demo2_gb_start[];
extern const uint8_t _binary_builtin_demo2_gb_end[];
}
constexpr apps::EmbeddedRomDescriptor kEmbeddedRoms[] = {
{
.name = "Builtin Demo 1",
.saveSlug = "builtin_demo1",
.start = _binary_builtin_demo1_gb_start,
.end = _binary_builtin_demo1_gb_end,
},
{
.name = "Builtin Demo 2",
.saveSlug = "builtin_demo2",
.start = _binary_builtin_demo2_gb_start,
.end = _binary_builtin_demo2_gb_end,
},
};
} // namespace
#if CONFIG_FREERTOS_GENERATE_RUN_TIME_STATS && CONFIG_FREERTOS_USE_TRACE_FACILITY
namespace {
constexpr TickType_t kStatsTaskDelayTicks = pdMS_TO_TICKS(5000);
constexpr TickType_t kStatsWarmupDelay = pdMS_TO_TICKS(2000);
constexpr UBaseType_t kStatsTaskPriority = tskIDLE_PRIORITY + 1;
constexpr uint32_t kStatsTaskStack = 4096;
constexpr char kStatsTaskName[] = "TaskStats";
struct TaskRuntimeSample {
TaskHandle_t handle;
uint32_t runtime;
};
struct TaskUsageRow {
std::string name;
uint64_t delta;
UBaseType_t priority;
uint32_t stackHighWaterBytes;
bool isIdle;
};
[[nodiscard]] uint64_t deltaWithWrap(uint32_t current, uint32_t previous) {
if (current >= previous)
return static_cast<uint64_t>(current - previous);
return static_cast<uint64_t>(current) + (static_cast<uint64_t>(UINT32_MAX) - previous) + 1ULL;
}
void task_usage_monitor(void*) {
static constexpr char tag[] = "TaskUsage";
std::vector<TaskRuntimeSample> lastSamples;
uint32_t lastTotal = 0;
vTaskDelay(kStatsWarmupDelay);
while (true) {
vTaskDelay(kStatsTaskDelayTicks);
const UBaseType_t taskCount = uxTaskGetNumberOfTasks();
if (taskCount == 0)
continue;
std::vector<TaskStatus_t> statusBuffer(taskCount);
uint32_t totalRuntime = 0;
const UBaseType_t captured = uxTaskGetSystemState(statusBuffer.data(), statusBuffer.size(), &totalRuntime);
if (captured == 0)
continue;
statusBuffer.resize(captured);
std::vector<TaskRuntimeSample> currentSamples;
currentSamples.reserve(statusBuffer.size());
if (lastTotal == 0) {
for (const auto& status: statusBuffer) {
currentSamples.push_back({status.xHandle, status.ulRunTimeCounter});
}
lastSamples = std::move(currentSamples);
lastTotal = totalRuntime;
continue;
}
const uint64_t totalDelta = deltaWithWrap(totalRuntime, lastTotal);
if (totalDelta == 0)
continue;
std::vector<TaskUsageRow> rows;
rows.reserve(statusBuffer.size());
uint64_t idleDelta = 0;
uint64_t activeDelta = 0;
uint64_t accountedDelta = 0;
for (const auto& status: statusBuffer) {
const auto it = std::find_if(lastSamples.begin(), lastSamples.end(), [&](const TaskRuntimeSample& entry) {
return entry.handle == status.xHandle;
});
const uint32_t previousRuntime = (it != lastSamples.end()) ? it->runtime : status.ulRunTimeCounter;
const uint64_t taskDelta =
(it != lastSamples.end()) ? deltaWithWrap(status.ulRunTimeCounter, previousRuntime) : 0ULL;
currentSamples.push_back({status.xHandle, status.ulRunTimeCounter});
TaskUsageRow row{.name = std::string(status.pcTaskName ? status.pcTaskName : ""),
.delta = taskDelta,
.priority = status.uxCurrentPriority,
.stackHighWaterBytes =
static_cast<uint32_t>(status.usStackHighWaterMark) * sizeof(StackType_t),
.isIdle = status.uxCurrentPriority == tskIDLE_PRIORITY ||
(status.pcTaskName && std::strncmp(status.pcTaskName, "IDLE", 4) == 0)};
rows.push_back(std::move(row));
accountedDelta += taskDelta;
if (rows.back().isIdle)
idleDelta += taskDelta;
else
activeDelta += taskDelta;
}
lastSamples = std::move(currentSamples);
lastTotal = totalRuntime;
if (rows.empty())
continue;
std::sort(rows.begin(), rows.end(),
[](const TaskUsageRow& a, const TaskUsageRow& b) { return a.delta > b.delta; });
const double windowMs = static_cast<double>(totalDelta) / 1000.0;
std::printf("\n[%s] CPU usage over %.1f ms window\n", tag, windowMs);
for (const auto& row: rows) {
if (row.delta == 0 || row.isIdle)
continue;
const double pct = (static_cast<double>(row.delta) * 100.0) / static_cast<double>(totalDelta);
std::printf(" %-16s %6.2f%% (prio=%u stack_free=%lu B)\n", row.name.c_str(), pct, row.priority,
static_cast<unsigned long>(row.stackHighWaterBytes));
}
const double idlePct = (idleDelta * 100.0) / static_cast<double>(totalDelta);
std::printf(" %-16s %6.2f%% (aggregated idle)\n", "<idle>", idlePct);
const uint64_t residual = (accountedDelta >= totalDelta) ? 0ULL : (totalDelta - accountedDelta);
if (residual > 0) {
const double residualPct = (static_cast<double>(residual) * 100.0) / static_cast<double>(totalDelta);
std::printf(" %-16s %6.2f%% (ISRs / scheduler)\n", "<isr>", residualPct);
}
std::printf("[%s] Active %.2f%% | Idle %.2f%%\n", tag, (activeDelta * 100.0) / static_cast<double>(totalDelta),
idlePct);
const uint32_t heapFree = esp_get_free_heap_size();
const uint32_t heapMinimum = esp_get_minimum_free_heap_size();
std::printf("[%s] Heap free %lu B | Min free %lu B\n", tag, static_cast<unsigned long>(heapFree),
static_cast<unsigned long>(heapMinimum));
std::fflush(stdout);
}
}
void start_task_usage_monitor() {
xTaskCreatePinnedToCore(task_usage_monitor, kStatsTaskName, kStatsTaskStack, nullptr, kStatsTaskPriority, nullptr,
0);
}
} // namespace
#else
inline void start_task_usage_monitor() {}
#endif
extern "C" void app_main() {
apps::setGameboyEmbeddedRoms(std::span<const apps::EmbeddedRomDescriptor>(kEmbeddedRoms));
static cardboy::backend::esp::EspRuntime runtime;
cardboy::sdk::AppContext context(runtime.framebuffer, runtime.input, runtime.clock);
context.services = &runtime.serviceRegistry();
cardboy::sdk::AppSystem system(context);
context.system = &system;
const cardboy::sdk::PersistentSettings persistentSettings =
cardboy::sdk::loadPersistentSettings(context.getServices());
if (auto* buzzer = context.buzzer())
buzzer->setMuted(persistentSettings.mute);
#ifdef CONFIG_PM_ENABLE
if (persistentSettings.autoLightSleep) {
const esp_pm_config_t pm_config = {
.max_freq_mhz = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ,
.min_freq_mhz = 16,
.light_sleep_enable = true,
};
ESP_ERROR_CHECK(esp_pm_configure(&pm_config));
}
ESP_ERROR_CHECK(esp_sleep_enable_gpio_wakeup());
#endif
system.registerApp(apps::createMenuAppFactory());
system.registerApp(apps::createSettingsAppFactory());
system.registerApp(apps::createClockAppFactory());
system.registerApp(apps::createTetrisAppFactory());
system.registerApp(apps::createGameboyAppFactory());
// start_task_usage_monitor();
system.run();
}

View File

@@ -1,78 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "bat_mon.hpp"
#include <power_helper.hpp>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "i2c_global.hpp"
static i2c_master_dev_handle_t dev_handle;
BatMon& BatMon::get() {
static BatMon bat_mon;
return bat_mon;
}
static void start_pooler(void* arg) { static_cast<BatMon*>(arg)->pooler(); }
BatMon::BatMon() {
ESP_ERROR_CHECK(i2c_master_bus_add_device(I2cGlobal::get().get_bus_handle(), &_dev_cfg, &dev_handle));
uint8_t reg = 1;
uint8_t buffer;
uint8_t buf2[2];
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 1, -1));
if (buffer & (1 << 4)) // POR reset
{
printf("Gas gauge reset!\n");
buf2[0] = 1;
buf2[1] = 0 << 4;
ESP_ERROR_CHECK(i2c_master_transmit(dev_handle, buf2, sizeof(buf2), -1));
buf2[0] = 0;
buf2[1] = 1 << 4 | 1 << 2; // 10 bit adc
ESP_ERROR_CHECK(i2c_master_transmit(dev_handle, buf2, sizeof(buf2), -1));
}
xTaskCreate(&start_pooler, "BatMon", 2048, this, tskIDLE_PRIORITY, &_pooler_task);
}
void BatMon::pooler() {
while (true) {
uint8_t reg = 8;
uint16_t buffer;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 2, -1));
float voltage = buffer;
voltage *= 2.44f;
voltage /= 1000;
_voltage = voltage;
reg = 2;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 2, -1));
float charge = *reinterpret_cast<int16_t*>(&buffer);
charge *= 6.70f;
charge /= 50;
_charge = charge;
reg = 6;
ESP_ERROR_CHECK(
i2c_master_transmit_receive(dev_handle, &reg, sizeof(reg), reinterpret_cast<uint8_t*>(&buffer), 2, -1));
float current = static_cast<int16_t>(buffer << 2);
current *= 11.77f;
current /= 50;
current /= 4;
_current = current;
PowerHelper::get().delay(10000, 1000);
}
}
float BatMon::get_voltage() const { return _voltage; }
float BatMon::get_charge() const { return _charge; }
float BatMon::get_current() const { return _current; }

View File

@@ -1,59 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "buttons.hpp"
#include <driver/gpio.h>
#include <esp_err.h>
#include <power_helper.hpp>
#include <rom/ets_sys.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "config.hpp"
Buttons& Buttons::get() {
static Buttons buttons;
return buttons;
}
static void start_pooler(void* arg) { static_cast<Buttons*>(arg)->pooler(); }
Buttons::Buttons() {
ESP_ERROR_CHECK(gpio_reset_pin(SHR_OUT));
ESP_ERROR_CHECK(gpio_reset_pin(SHR_CLK));
ESP_ERROR_CHECK(gpio_reset_pin(SHR_SH));
ESP_ERROR_CHECK(gpio_set_direction(SHR_OUT, GPIO_MODE_INPUT));
ESP_ERROR_CHECK(gpio_set_pull_mode(SHR_OUT, GPIO_FLOATING));
ESP_ERROR_CHECK(gpio_set_direction(SHR_SH, GPIO_MODE_OUTPUT));
ESP_ERROR_CHECK(gpio_set_direction(SHR_CLK, GPIO_MODE_OUTPUT));
xTaskCreate(&start_pooler, "ButtonsPooler", 2048, this, 1, &_pooler_task);
}
static void delay(unsigned long long loop) {
for (unsigned long long i = 0; i < loop; i++) {
asm volatile("nop");
}
}
void Buttons::pooler() {
while (true) {
ESP_ERROR_CHECK(gpio_set_level(SHR_SH, 0));
ESP_ERROR_CHECK(gpio_set_level(SHR_SH, 1));
uint8_t new_val = 0;
for (int i = 0; i < 8; i++) {
ESP_ERROR_CHECK(gpio_set_level(SHR_CLK, 0));
new_val |= gpio_get_level(SHR_OUT) << i;
ESP_ERROR_CHECK(gpio_set_level(SHR_CLK, 1));
}
_current = new_val;
PowerHelper::get().delay(10000, 100);
}
}
uint8_t Buttons::get_pressed() { return _current; }

View File

@@ -1,75 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "disp_tools.hpp"
#include <cmath>
#include <display.hpp>
DispTools& DispTools::get() {
static DispTools disp_tools;
return disp_tools;
}
void DispTools::clear() {
for (int y = 0; y < DISP_HEIGHT; y++) {
for (int x = 0; x < DISP_WIDTH; x++) {
disp_frame[y][x] = 1;
}
}
}
bool DispTools::get_pixel(int x, int y) { return disp_frame[y][x]; }
void DispTools::reset_pixel(int x, int y) { disp_frame[y][x] = true; }
void DispTools::set_pixel(int x, int y) { disp_frame[y][x] = false; }
void DispTools::draw_rectangle(int x1, int y1, int x2, int y2) {
int dy = y2 - y1;
while (std::abs(dy) > 0) {
draw_line(x1, y1 + dy, x2, y1 + dy);
dy += (dy > 0) ? -1 : 1;
}
}
void DispTools::draw_line(int x1, int y1, int x2, int y2) {
int dx = x2 - x1;
int dy = y2 - y1;
int a = 0, b = 0, diff = 0;
if (dx == 0) {
while (dy != 0) {
set_pixel(x1, y1 + dy);
dy += (dy > 0) ? -1 : 1;
}
return;
}
if (dy == 0) {
while (dx != 0) {
set_pixel(x1 + dx, y1);
dx += (dx > 0) ? -1 : 1;
}
return;
}
while (std::abs(a) <= std::abs(dx) && std::abs(b) <= std::abs(dy)) {
set_pixel(x1 + a, y1 + b);
if (diff < 0) {
a += (dx > 0) ? 1 : -1;
diff += std::abs(dy);
} else {
b += (dy > 0) ? 1 : -1;
diff -= std::abs(dx);
}
}
}
void DispTools::draw_circle(int x, int y, int r) {
if (r > 181)
return;
int dy = -r;
while (dy <= r) {
int dx = static_cast<int>(std::sqrt(r * r - dy * dy));
draw_line(x - dx, y + dy, x + dx, y + dy);
dy++;
}
}
void DispTools::draw_to_display() { SMD::get().draw(disp_frame); }

View File

@@ -1,62 +0,0 @@
//
// Created by Stepan Usatiuk on 26.04.2024.
//
#include "disp_tty.hpp"
#include <disp_tools.hpp>
#include "Fonts.hpp"
void FbTty::draw_char(int col, int row) {
for (int x = 0; x < 8; x++) {
for (int y = 0; y < 16; y++) {
bool color = fonts_Terminess_Powerline[_buf[col][row]][y] & (1 << (8 - x));
if (color)
DispTools::get().set_pixel(col * 8 + x, row * 16 + y);
else
DispTools::get().reset_pixel(col * 8 + x, row * 16 + y);
}
}
}
void FbTty::reset() {
_cur_col = 0;
_cur_row = 0;
}
void FbTty::putchar(char c) {
if (c == '\n') {
next_row();
return;
}
_buf[_cur_col][_cur_row] = c;
draw_char(_cur_col, _cur_row);
next_col();
}
void FbTty::putstr(const char* str) {
while (*str != 0) {
putchar(*str);
str++;
}
}
void FbTty::next_col() {
_cur_col++;
_cur_col = _cur_col % _max_col;
if (_cur_col == 0) {
next_row();
} else {
_buf[_cur_col][_cur_row] = ' ';
draw_char(_cur_col, _cur_row);
}
}
void FbTty::next_row() {
_cur_col = 0;
_cur_row++;
_cur_row = _cur_row % _max_row;
for (int i = 0; i < _max_col; i++) {
_buf[i][_cur_row] = ' ';
draw_char(i, _cur_row);
}
}

View File

@@ -1,62 +0,0 @@
//
// Created by Stepan Usatiuk on 02.03.2025.
//
#include "display.hpp"
#include <cstring>
#include "driver/spi_master.h"
// This solution is attributed to Rich Schroeppel in the Programming Hacks section
// TODO: Why does the device flag not work?
unsigned char reverse_bits3(unsigned char b) { return (b * 0x0202020202ULL & 0x010884422010ULL) % 0x3ff; }
std::array<uint8_t, SMD::kLineBytes> SMD::prep_line(const SMD::disp_line_t& line) {
std::array<uint8_t, kLineBytes> data{};
for (int i = 0; i < DISP_WIDTH; i++) {
data[i / 8] = data[i / 8] | (line[i] << (i % 8));
}
for (int i = 0; i < kLineBytes; i++) {
data[i] = reverse_bits3(data[i]);
}
return data;
}
SMD& SMD::get() {
static SMD smd;
return smd;
}
SMD::SMD() { spi_bus_add_device(SPI_BUS, &_devcfg, &_spi); }
void SMD::clear() {
std::array<uint8_t, 2> buf{};
buf[0] = 0b00100000;
spi_transaction_t t{};
t.tx_buffer = buf.data();
t.length = buf.size() * 8;
ESP_ERROR_CHECK(spi_device_transmit(_spi, &t));
}
void SMD::draw(const disp_frame_t& frame) {
_vcom = !_vcom;
for (uint8_t i = 0; i < DISP_HEIGHT; i++) {
spi_transaction_t t{};
t.tx_buffer = buf.data();
t.length = buf.size() * 8;
buf[0] = 0b10000000 | (_vcom << 6);
buf[1] = reverse_bits3(i + 1);
auto prepared = prep_line(frame.at(i));
memcpy(buf.data() + 2, prepared.data(), kLineBytes);
buf[2 + kLineBytes] = 0;
buf[2 + kLineBytes + 1] = 0;
ESP_ERROR_CHECK(spi_device_transmit(_spi, &t));
}
}

View File

@@ -1,119 +0,0 @@
/*
* SPDX-FileCopyrightText: 2010-2022 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: CC0-1.0
*/
#include <buttons.hpp>
#include <cstdint>
#include <disp_tools.hpp>
#include <disp_tty.hpp>
#include <esp_pm.h>
#include <inttypes.h>
#include <stdio.h>
#include "esp_chip_info.h"
#include "esp_flash.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "sdkconfig.h"
#include "display.hpp"
#include "bat_mon.hpp"
#include "driver/i2c_master.h"
#include "driver/spi_master.h"
#include "i2c_global.hpp"
#include <driver/gpio.h>
#include <esp_sleep.h>
#include <memory>
#include <power_helper.hpp>
#include <shutdowner.hpp>
#include <spi_global.hpp>
#include <string>
FbTty tty;
extern "C" void app_main() {
esp_pm_config_t pm_config = {
.max_freq_mhz = CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ, .min_freq_mhz = 16, .light_sleep_enable = true};
ESP_ERROR_CHECK(esp_pm_configure(&pm_config));
printf("Hello world!\n");
// TODO: Where to put that?
ESP_ERROR_CHECK(esp_sleep_enable_gpio_wakeup());
// For some reason, calling it here hangs on startup, sometimes
// ESP_ERROR_CHECK(gpio_install_isr_service(0));
PowerHelper::get();
Shutdowner::get();
ESP_ERROR_CHECK(gpio_install_isr_service(0));
Shutdowner::get().install_isr();
PowerHelper::get().install_isr();
Buttons::get();
I2cGlobal::get();
BatMon::get();
SpiGlobal::get();
SMD::get();
SMD::get().clear();
DispTools::get().clear();
DispTools::get().draw_line(0, 0, 400, 240);
DispTools::get().draw_circle(100, 100, 20);
DispTools::get().draw_to_display();
tty.putstr("Hello\nworld!");
DispTools::get().draw_to_display();
int rx = 30, ry = 30;
int lastmove = 0;
while (true) {
// SMD::clear();
// printf("Voltage: %f\n", BatMon::get_voltage());
DispTools::get().clear();
tty.reset();
uint8_t pressed = Buttons::get().get_pressed();
if (pressed & L3)
rx -= 5;
if (pressed & L4)
ry += 5;
if (pressed & R3)
ry -= 5;
if (pressed & R4)
rx += 5;
if (pressed == 0 && !PowerHelper::get().is_slow())
lastmove++;
else if (pressed != 0) {
lastmove = 0;
PowerHelper::get().set_slow(false);
}
if (lastmove > 20) {
lastmove = 0;
PowerHelper::get().set_slow(true);
}
bool slow = PowerHelper::get().is_slow();
tty.fmt("{:.1f}mA {:.1f}V {:.1f}mAh {}", BatMon::get().get_current(), BatMon::get().get_voltage(),
BatMon::get().get_charge(), slow ? "S" : "");
if (rx < 30)
rx = 30;
if (rx > 370)
rx = 370;
if (ry < 30)
ry = 30;
if (ry > 210)
ry = 210;
// tty.fmt("Button: {}", pressed);
DispTools::get().draw_circle(rx, ry, 20);
// printf("Restarting in %d seconds...\n", i);
DispTools::get().draw_to_display();
PowerHelper::get().delay(10000, 30);
}
// printf("Restarting now.\n");
// fflush(stdout);
// esp_restart();
}

6
Firmware/partitions.csv Normal file
View File

@@ -0,0 +1,6 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
phy_init, data, phy, 0x10000, 0x1000,
factory, app, factory, 0x20000, 0x250000,
littlefs, data, littlefs,, 0x190000,
1 # Name Type SubType Offset Size Flags
2 nvs data nvs 0x9000 0x5000
3 otadata data ota 0xe000 0x2000
4 phy_init data phy 0x10000 0x1000
5 factory app factory 0x20000 0x250000
6 littlefs data littlefs 0x190000

View File

@@ -0,0 +1,76 @@
# Generated from CLion C/C++ Code Style settings
---
Language: Cpp
BasedOnStyle: LLVM
AccessModifierOffset: -4
AlignAfterOpenBracket: Align
AlignConsecutiveAssignments:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignConsecutiveBitFields:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignConsecutiveDeclarations:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignConsecutiveMacros:
Enabled: true
AcrossEmptyLines: false
AcrossComments: false
AlignTrailingComments:
Kind: Always
OverEmptyLines: 2
SpacesBeforeTrailingComments: 1
AlignOperands: Align
AlignEscapedNewlines: Right
AlwaysBreakTemplateDeclarations: Yes
BraceWrapping:
AfterCaseLabel: false
AfterClass: false
AfterControlStatement: false
AfterEnum: false
AfterFunction: false
AfterNamespace: false
AfterStruct: false
AfterUnion: false
AfterExternBlock: false
BeforeCatch: false
BeforeElse: false
BeforeLambdaBody: false
BeforeWhile: false
SplitEmptyFunction: true
SplitEmptyRecord: true
SplitEmptyNamespace: true
BreakBeforeBraces: Custom
BreakConstructorInitializers: AfterColon
BreakConstructorInitializersBeforeComma: false
ColumnLimit: 120
ConstructorInitializerAllOnOneLineOrOnePerLine: false
ContinuationIndentWidth: 8
IncludeCategories:
- Regex: '^<.*'
Priority: 1
- Regex: '^".*'
Priority: 2
- Regex: '.*'
Priority: 3
IncludeIsMainRegex: '([-_](test|unittest))?$'
IndentCaseLabels: true
IndentWidth: 4
InsertNewlineAtEOF: true
MacroBlockBegin: ''
MacroBlockEnd: ''
MaxEmptyLinesToKeep: 2
PointerAlignment: Left
SpaceAfterCStyleCast: true
SpaceAfterTemplateKeyword: false
SpaceBeforeRangeBasedForLoopColon: false
SpaceInEmptyParentheses: false
SpacesInAngles: false
SpacesInConditionalStatement: false
SpacesInCStyleCastParentheses: false
SpacesInParentheses: false
...

View File

@@ -0,0 +1,37 @@
cmake_minimum_required(VERSION 3.16)
project(cardboy_sdk LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED YES)
set(CMAKE_CXX_EXTENSIONS NO)
add_subdirectory(utils)
add_subdirectory(backend_interface)
set(CARDBOY_SDK_BACKEND_LIBRARY "" CACHE STRING "Backend implementation library for Cardboy SDK")
set(_cardboy_backend_default "${CARDBOY_SDK_BACKEND_LIBRARY}")
option(CARDBOY_BUILD_SFML "Build desktop SFML backend and launcher" ON)
if (CARDBOY_BUILD_SFML)
add_subdirectory(backends/desktop)
if (DEFINED CARDBOY_DESKTOP_BACKEND_TARGET AND NOT CARDBOY_DESKTOP_BACKEND_TARGET STREQUAL "")
set(_cardboy_backend_default "${CARDBOY_DESKTOP_BACKEND_TARGET}")
endif ()
endif ()
if (_cardboy_backend_default STREQUAL "")
message(FATAL_ERROR "CARDBOY_SDK_BACKEND_LIBRARY is not set. Provide a backend implementation library or enable one of the available backends.")
endif ()
set(CARDBOY_SDK_BACKEND_LIBRARY "${_cardboy_backend_default}" CACHE STRING "Backend implementation library for Cardboy SDK" FORCE)
add_subdirectory(core)
add_subdirectory(apps)
if (CARDBOY_BUILD_SFML)
add_subdirectory(launchers/desktop)
endif ()

View File

@@ -0,0 +1,19 @@
add_library(cardboy_apps STATIC)
set_target_properties(cardboy_apps PROPERTIES
EXPORT_NAME apps
)
target_link_libraries(cardboy_apps
PUBLIC
cardboy_sdk
${CARDBOY_SDK_BACKEND_LIBRARY}
)
target_compile_features(cardboy_apps PUBLIC cxx_std_20)
add_subdirectory(menu)
add_subdirectory(clock)
add_subdirectory(settings)
add_subdirectory(gameboy)
add_subdirectory(tetris)

View File

@@ -0,0 +1,9 @@
target_sources(cardboy_apps
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/clock_app.cpp
)
target_include_directories(cardboy_apps
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)

View File

@@ -0,0 +1,12 @@
#pragma once
#include "cardboy/sdk/app_framework.hpp"
#include <memory>
namespace apps {
std::unique_ptr<cardboy::sdk::IAppFactory> createClockAppFactory();
} // namespace apps

View File

@@ -0,0 +1,238 @@
#include "cardboy/apps/clock_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include <algorithm>
#include <cstdint>
#include <cstdio>
#include <ctime>
#include <string>
#include <string_view>
namespace apps {
namespace {
using cardboy::sdk::AppContext;
constexpr const char* kClockAppName = "Clock";
using Framebuffer = typename AppContext::Framebuffer;
using Clock = typename AppContext::Clock;
struct TimeSnapshot {
bool hasWallTime = false;
int hour24 = 0;
int minute = 0;
int second = 0;
int year = 0;
int month = 0;
int day = 0;
int weekday = 0;
std::uint64_t uptimeSeconds = 0;
};
class ClockApp final : public cardboy::sdk::IApp {
public:
explicit ClockApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer), clock(ctx.clock) {}
void onStart() override {
cancelRefreshTimer();
lastSnapshot = {};
dirty = true;
const auto snap = captureTime();
renderIfNeeded(snap);
lastSnapshot = snap;
refreshTimer = context.scheduleRepeatingTimer(200);
}
void onStop() override { cancelRefreshTimer(); }
void handleEvent(const cardboy::sdk::AppEvent& event) override {
switch (event.type) {
case cardboy::sdk::AppEventType::Button:
handleButtonEvent(event.button);
break;
case cardboy::sdk::AppEventType::Timer:
if (event.timer.handle == refreshTimer)
updateDisplay();
break;
}
}
private:
AppContext& context;
Framebuffer& framebuffer;
Clock& clock;
bool use24Hour = true;
bool dirty = false;
cardboy::sdk::AppTimerHandle refreshTimer = cardboy::sdk::kInvalidAppTimer;
TimeSnapshot lastSnapshot{};
void cancelRefreshTimer() {
if (refreshTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(refreshTimer);
refreshTimer = cardboy::sdk::kInvalidAppTimer;
}
}
void handleButtonEvent(const cardboy::sdk::AppButtonEvent& button) {
const auto& current = button.current;
const auto& previous = button.previous;
if (current.b && !previous.b) {
context.requestAppSwitchByName(kMenuAppName);
return;
}
if (current.select && !previous.select) {
use24Hour = !use24Hour;
dirty = true;
}
updateDisplay();
}
void updateDisplay() {
const auto snap = captureTime();
if (!sameSnapshot(snap, lastSnapshot))
dirty = true;
renderIfNeeded(snap);
lastSnapshot = snap;
}
static bool sameSnapshot(const TimeSnapshot& a, const TimeSnapshot& b) {
return a.hasWallTime == b.hasWallTime && a.hour24 == b.hour24 && a.minute == b.minute && a.second == b.second;
}
TimeSnapshot captureTime() const {
TimeSnapshot snap{};
snap.uptimeSeconds = clock.millis() / 1000ULL;
std::time_t raw = 0;
if (std::time(&raw) != static_cast<std::time_t>(-1) && raw > 0) {
std::tm tm{};
if (localtime_r(&raw, &tm) != nullptr) {
snap.hasWallTime = true;
snap.hour24 = tm.tm_hour;
snap.minute = tm.tm_min;
snap.second = tm.tm_sec;
snap.year = tm.tm_year + 1900;
snap.month = tm.tm_mon + 1;
snap.day = tm.tm_mday;
snap.weekday = tm.tm_wday;
return snap;
}
}
// Fallback to uptime-derived clock
snap.hasWallTime = false;
snap.hour24 = static_cast<int>((snap.uptimeSeconds / 3600ULL) % 24ULL);
snap.minute = static_cast<int>((snap.uptimeSeconds / 60ULL) % 60ULL);
snap.second = static_cast<int>(snap.uptimeSeconds % 60ULL);
return snap;
}
static void drawCenteredText(Framebuffer& fb, int y, std::string_view text, int scale, int letterSpacing = 0) {
const int width = font16x8::measureText(text, scale, letterSpacing);
const int x = (fb.width() - width) / 2;
font16x8::drawText(fb, x, y, text, scale, true, letterSpacing);
}
static std::string formatDate(const TimeSnapshot& snap) {
static const char* kWeekdays[] = {"SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"};
if (!snap.hasWallTime)
return "UPTIME MODE";
const char* weekday = (snap.weekday >= 0 && snap.weekday < 7) ? kWeekdays[snap.weekday] : "";
char buffer[32];
std::snprintf(buffer, sizeof(buffer), "%s %04d-%02d-%02d", weekday, snap.year, snap.month, snap.day);
return buffer;
}
void renderIfNeeded(const TimeSnapshot& snap) {
if (!dirty)
return;
dirty = false;
framebuffer.frameReady();
const int scaleLarge = 3;
const int scaleSeconds = 2;
const int scaleSmall = 1;
int hourDisplay = snap.hour24;
bool isPm = false;
if (!use24Hour) {
isPm = hourDisplay >= 12;
int h12 = hourDisplay % 12;
if (h12 == 0)
h12 = 12;
hourDisplay = h12;
}
char mainLine[6];
std::snprintf(mainLine, sizeof(mainLine), "%02d:%02d", hourDisplay, snap.minute);
const int mainW = font16x8::measureText(mainLine, scaleLarge, 0);
const int timeY = (framebuffer.height() - font16x8::kGlyphHeight * scaleLarge) / 2 - 12;
const int timeX = (framebuffer.width() - mainW) / 2;
font16x8::drawText(framebuffer, timeX, timeY, mainLine, scaleLarge, true, 0);
char secondsLine[3];
std::snprintf(secondsLine, sizeof(secondsLine), "%02d", snap.second);
const int secondsX = timeX + mainW + 12;
const int secondsY = timeY + font16x8::kGlyphHeight * scaleLarge - font16x8::kGlyphHeight * scaleSeconds;
font16x8::drawText(framebuffer, secondsX, secondsY, secondsLine, scaleSeconds, true, 0);
if (!use24Hour) {
font16x8::drawText(framebuffer, timeX + mainW + 12, timeY, isPm ? "PM" : "AM", scaleSmall, true, 0);
} else {
font16x8::drawText(framebuffer, timeX + mainW + 12, timeY, "24H", scaleSmall, true, 0);
}
const std::string dateLine = formatDate(snap);
drawCenteredText(framebuffer, timeY + font16x8::kGlyphHeight * scaleLarge + 28, dateLine, scaleSmall, 1);
if (!snap.hasWallTime) {
char uptimeLine[32];
const std::uint64_t days = snap.uptimeSeconds / 86400ULL;
const std::uint64_t hrs = (snap.uptimeSeconds / 3600ULL) % 24ULL;
const std::uint64_t mins = (snap.uptimeSeconds / 60ULL) % 60ULL;
const std::uint64_t secs = snap.uptimeSeconds % 60ULL;
if (days > 0) {
std::snprintf(uptimeLine, sizeof(uptimeLine), "%llud %02llu:%02llu:%02llu UP",
static_cast<unsigned long long>(days), static_cast<unsigned long long>(hrs),
static_cast<unsigned long long>(mins), static_cast<unsigned long long>(secs));
} else {
std::snprintf(uptimeLine, sizeof(uptimeLine), "%02llu:%02llu:%02llu UP",
static_cast<unsigned long long>(hrs), static_cast<unsigned long long>(mins),
static_cast<unsigned long long>(secs));
}
drawCenteredText(framebuffer, framebuffer.height() - 68, uptimeLine, scaleSmall, 1);
}
drawCenteredText(framebuffer, framebuffer.height() - 36, "SELECT TOGGLE 12/24H", 1, 1);
drawCenteredText(framebuffer, framebuffer.height() - 18, "B BACK", 1, 1);
framebuffer.sendFrame();
}
};
class ClockAppFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kClockAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
return std::make_unique<ClockApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createClockAppFactory() { return std::make_unique<ClockAppFactory>(); }
} // namespace apps

View File

@@ -0,0 +1,10 @@
target_sources(cardboy_apps
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/gameboy_app.cpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/apps/peanut_gb.h
)
target_include_directories(cardboy_apps
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)

View File

@@ -0,0 +1,26 @@
#pragma once
#include "cardboy/sdk/app_framework.hpp"
#include <memory>
#include <string_view>
#include <span>
#include <cstdint>
namespace apps {
inline constexpr char kGameboyAppName[] = "Game Boy";
inline constexpr std::string_view kGameboyAppNameView = kGameboyAppName;
struct EmbeddedRomDescriptor {
std::string_view name;
std::string_view saveSlug;
const std::uint8_t* start = nullptr;
const std::uint8_t* end = nullptr;
};
void setGameboyEmbeddedRoms(std::span<const EmbeddedRomDescriptor> descriptors);
std::unique_ptr<cardboy::sdk::IAppFactory> createGameboyAppFactory();
} // namespace apps

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
target_sources(cardboy_apps
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/menu_app.cpp
)
target_include_directories(cardboy_apps
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)

View File

@@ -0,0 +1,16 @@
#pragma once
#include "cardboy/sdk/app_framework.hpp"
#include <memory>
#include <string_view>
namespace apps {
inline constexpr char kMenuAppName[] = "Menu";
inline constexpr std::string_view kMenuAppNameView = kMenuAppName;
std::unique_ptr<cardboy::sdk::IAppFactory> createMenuAppFactory();
} // namespace apps

View File

@@ -0,0 +1,176 @@
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include <algorithm>
#include <cstdlib>
#include <string>
#include <string_view>
#include <vector>
namespace apps {
namespace {
using cardboy::sdk::AppContext;
using Framebuffer = typename AppContext::Framebuffer;
struct MenuEntry {
std::string name;
std::size_t index = 0;
};
class MenuApp final : public cardboy::sdk::IApp {
public:
explicit MenuApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) { refreshEntries(); }
void onStart() override {
refreshEntries();
dirty = true;
renderIfNeeded();
}
void handleEvent(const cardboy::sdk::AppEvent& event) override {
if (event.type != cardboy::sdk::AppEventType::Button)
return;
const auto& current = event.button.current;
const auto& previous = event.button.previous;
if (current.left && !previous.left) {
moveSelection(-1);
} else if (current.right && !previous.right) {
moveSelection(+1);
}
const bool launch = (current.a && !previous.a) || (current.select && !previous.select);
if (launch)
launchSelected();
renderIfNeeded();
}
private:
AppContext& context;
Framebuffer& framebuffer;
std::vector<MenuEntry> entries;
std::size_t selected = 0;
bool dirty = false;
void moveSelection(int step) {
if (entries.empty())
return;
const int count = static_cast<int>(entries.size());
int next = static_cast<int>(selected) + step;
next = (next % count + count) % count;
selected = static_cast<std::size_t>(next);
dirty = true;
}
void launchSelected() {
if (entries.empty())
return;
const auto target = entries[selected].index;
if (context.system && context.system->currentFactoryIndex() == target)
return;
context.requestAppSwitchByIndex(target);
}
void refreshEntries() {
entries.clear();
if (!context.system)
return;
const std::size_t total = context.system->appCount();
for (std::size_t i = 0; i < total; ++i) {
const cardboy::sdk::IAppFactory* factory = context.system->factoryAt(i);
if (!factory)
continue;
const char* name = factory->name();
if (!name)
continue;
if (std::string_view(name) == kMenuAppNameView)
continue;
entries.push_back(MenuEntry{std::string(name), i});
}
if (selected >= entries.size())
selected = entries.empty() ? 0 : entries.size() - 1;
dirty = true;
}
static void drawCenteredText(Framebuffer& fb, int y, std::string_view text, int scale, int letterSpacing = 0) {
const int width = font16x8::measureText(text, scale, letterSpacing);
const int x = (fb.width() - width) / 2;
font16x8::drawText(fb, x, y, text, scale, true, letterSpacing);
}
void drawPagerDots() {
if (entries.size() <= 1)
return;
const int count = static_cast<int>(entries.size());
const int spacing = 20;
const int dotSize = 7;
const int totalW = spacing * (count - 1);
const int startX = (framebuffer.width() - totalW) / 2;
const int baseline = framebuffer.height() - (font16x8::kGlyphHeight + 48);
for (int i = 0; i < count; ++i) {
const int cx = startX + i * spacing;
for (int dx = -dotSize / 2; dx <= dotSize / 2; ++dx) {
for (int dy = -dotSize / 2; dy <= dotSize / 2; ++dy) {
const bool isSelected = (static_cast<std::size_t>(i) == selected);
const bool on = isSelected || std::abs(dx) == dotSize / 2 || std::abs(dy) == dotSize / 2;
if (on)
framebuffer.drawPixel(cx + dx, baseline + dy, true);
}
}
}
}
void renderIfNeeded() {
if (!dirty)
return;
dirty = false;
framebuffer.frameReady();
drawCenteredText(framebuffer, 24, "APPS", 1, 1);
if (entries.empty()) {
drawCenteredText(framebuffer, framebuffer.height() / 2 - 18, "NO OTHER APPS", 2, 1);
drawCenteredText(framebuffer, framebuffer.height() - 72, "ADD MORE IN FIRMWARE", 1, 1);
} else {
const std::string& name = entries[selected].name;
const int titleScale = 2;
const int centerY = framebuffer.height() / 2 - (font16x8::kGlyphHeight * titleScale) / 2;
drawCenteredText(framebuffer, centerY, name, titleScale, 0);
const std::string indexLabel = std::to_string(selected + 1) + "/" + std::to_string(entries.size());
const int topRightX = framebuffer.width() - font16x8::measureText(indexLabel, 1, 0) - 16;
font16x8::drawText(framebuffer, topRightX, 20, indexLabel, 1, true, 0);
drawPagerDots();
drawCenteredText(framebuffer, framebuffer.height() - 48, "A/SELECT START", 1, 1);
drawCenteredText(framebuffer, framebuffer.height() - 28, "L/R CHOOSE", 1, 1);
}
framebuffer.sendFrame();
}
};
class MenuAppFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kMenuAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
return std::make_unique<MenuApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createMenuAppFactory() { return std::make_unique<MenuAppFactory>(); }
} // namespace apps

View File

@@ -0,0 +1,9 @@
target_sources(cardboy_apps
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/settings_app.cpp
)
target_include_directories(cardboy_apps
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)

View File

@@ -0,0 +1,13 @@
#pragma once
#include "cardboy/sdk/app_framework.hpp"
#include <memory>
namespace apps {
inline constexpr char kSettingsAppName[] = "Settings";
std::unique_ptr<cardboy::sdk::IAppFactory> createSettingsAppFactory();
} // namespace apps

View File

@@ -0,0 +1,198 @@
#include "cardboy/apps/settings_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/persistent_settings.hpp"
#include <array>
#include <cstddef>
#include <memory>
#include <string>
#include <string_view>
namespace apps {
namespace {
using cardboy::sdk::AppContext;
using Framebuffer = typename AppContext::Framebuffer;
enum class SettingOption {
Sound,
AutoLightSleep,
};
constexpr std::array<SettingOption, 2> kOptions = {
SettingOption::Sound,
SettingOption::AutoLightSleep,
};
class SettingsApp final : public cardboy::sdk::IApp {
public:
explicit SettingsApp(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) {}
void onStart() override {
loadSettings();
dirty = true;
renderIfNeeded();
}
void handleEvent(const cardboy::sdk::AppEvent& event) override {
if (event.type != cardboy::sdk::AppEventType::Button)
return;
const auto& current = event.button.current;
const auto& previous = event.button.previous;
const bool previousAvailable = buzzerAvailable;
syncBuzzerState();
if (previousAvailable != buzzerAvailable)
dirty = true;
if (current.b && !previous.b) {
context.requestAppSwitchByName(kMenuAppName);
return;
}
bool moved = false;
if (current.down && !previous.down) {
moveSelection(+1);
moved = true;
} else if (current.up && !previous.up) {
moveSelection(-1);
moved = true;
}
const bool togglePressed = (current.a && !previous.a) || (current.select && !previous.select) ||
(current.start && !previous.start);
if (togglePressed)
handleToggle();
if (moved)
dirty = true;
renderIfNeeded();
}
private:
AppContext& context;
Framebuffer& framebuffer;
bool buzzerAvailable = false;
cardboy::sdk::PersistentSettings settings{};
std::size_t selectedIndex = 0;
bool dirty = false;
void loadSettings() {
settings = cardboy::sdk::loadPersistentSettings(context.getServices());
syncBuzzerState();
}
void syncBuzzerState() {
auto* buzzer = context.buzzer();
buzzerAvailable = (buzzer != nullptr);
if (!buzzer)
return;
if (buzzer->isMuted() != settings.mute)
buzzer->setMuted(settings.mute);
}
void moveSelection(int delta) {
const int count = static_cast<int>(kOptions.size());
if (count == 0)
return;
const int current = static_cast<int>(selectedIndex);
int next = (current + delta) % count;
if (next < 0)
next += count;
selectedIndex = static_cast<std::size_t>(next);
}
void handleToggle() {
switch (kOptions[selectedIndex]) {
case SettingOption::Sound:
toggleSound();
break;
case SettingOption::AutoLightSleep:
toggleAutoLightSleep();
break;
}
}
void toggleSound() {
if (!buzzerAvailable)
return;
settings.mute = !settings.mute;
cardboy::sdk::savePersistentSettings(context.getServices(), settings);
syncBuzzerState();
if (!settings.mute) {
if (auto* buzzer = context.buzzer())
buzzer->beepMove();
}
dirty = true;
}
void toggleAutoLightSleep() {
settings.autoLightSleep = !settings.autoLightSleep;
cardboy::sdk::savePersistentSettings(context.getServices(), settings);
dirty = true;
}
static void drawCenteredText(Framebuffer& fb, int y, std::string_view text, int scale, int letterSpacing = 1) {
const int width = font16x8::measureText(text, scale, letterSpacing);
const int x = (fb.width() - width) / 2;
font16x8::drawText(fb, x, y, text, scale, true, letterSpacing);
}
void drawOptionRow(int row, std::string_view label, std::string_view value, bool selected) {
std::string prefix = selected ? "> " : " ";
std::string line = prefix;
line.append(label);
line.append(": ");
line.append(value);
const int x = 24;
const int y = 56 + row * 24;
font16x8::drawText(framebuffer, x, y, line, 1, true, 1);
}
void renderIfNeeded() {
if (!dirty)
return;
dirty = false;
framebuffer.frameReady();
framebuffer.clear(false);
drawCenteredText(framebuffer, 24, "SETTINGS", 1, 1);
const std::string soundValue = buzzerAvailable ? (settings.mute ? "OFF" : "ON") : "N/A";
drawOptionRow(0, "SOUND", soundValue, selectedIndex == 0);
const std::string lightSleepValue = settings.autoLightSleep ? "ON" : "OFF";
drawOptionRow(1, "AUTO LIGHT SLEEP", lightSleepValue, selectedIndex == 1);
if (!buzzerAvailable)
drawCenteredText(framebuffer, 120, "SOUND CONTROL UNAVAILABLE", 1, 1);
drawCenteredText(framebuffer, framebuffer.height() - 54, "UP/DOWN MOVE", 1, 1);
drawCenteredText(framebuffer, framebuffer.height() - 36, "A/START/SELECT TOGGLE", 1, 1);
drawCenteredText(framebuffer, framebuffer.height() - 18, "B BACK | LIGHT SLEEP AFTER RESET", 1, 1);
framebuffer.sendFrame();
}
};
class SettingsAppFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kSettingsAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(cardboy::sdk::AppContext& context) override {
return std::make_unique<SettingsApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createSettingsAppFactory() { return std::make_unique<SettingsAppFactory>(); }
} // namespace apps

View File

@@ -0,0 +1,9 @@
target_sources(cardboy_apps
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/tetris_app.cpp
)
target_include_directories(cardboy_apps
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)

View File

@@ -0,0 +1,12 @@
#pragma once
#include "cardboy/sdk/app_framework.hpp"
#include <memory>
namespace apps {
std::unique_ptr<cardboy::sdk::IAppFactory> createTetrisAppFactory();
} // namespace apps

View File

@@ -0,0 +1,663 @@
#include "cardboy/apps/tetris_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include "cardboy/sdk/app_framework.hpp"
#include "cardboy/sdk/app_system.hpp"
#include "cardboy/sdk/display_spec.hpp"
#include "cardboy/sdk/input_state.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include <cstdio>
#include <random>
#include <string>
#include <string_view>
#include <vector>
namespace apps {
namespace {
using cardboy::sdk::AppButtonEvent;
using cardboy::sdk::AppContext;
using cardboy::sdk::AppEvent;
using cardboy::sdk::AppEventType;
using cardboy::sdk::AppTimerHandle;
using cardboy::sdk::InputState;
constexpr char kTetrisAppName[] = "Tetris";
constexpr int kBoardWidth = 10;
constexpr int kBoardHeight = 20;
constexpr int kCellSize = 10;
constexpr std::array<int, 5> kLineScores = {0, 40, 100, 300, 1200};
struct BlockOffset {
int x = 0;
int y = 0;
};
struct Tetromino {
std::array<std::array<BlockOffset, 4>, 4> rotations{};
};
constexpr std::array<BlockOffset, 4> makeOffsets(std::initializer_list<BlockOffset> blocks) {
std::array<BlockOffset, 4> out{};
std::size_t idx = 0;
for (const auto& b: blocks) {
out[idx++] = b;
}
return out;
}
constexpr std::array<BlockOffset, 4> rotate(const std::array<BlockOffset, 4>& src) {
std::array<BlockOffset, 4> out{};
for (std::size_t i = 0; i < src.size(); ++i) {
out[i].x = -src[i].y;
out[i].y = src[i].x;
}
return out;
}
constexpr Tetromino makeTetromino(std::initializer_list<BlockOffset> baseBlocks) {
Tetromino tet{};
tet.rotations[0] = makeOffsets(baseBlocks);
for (int r = 1; r < 4; ++r)
tet.rotations[r] = rotate(tet.rotations[r - 1]);
return tet;
}
constexpr std::array<Tetromino, 7> kPieces = {{
makeTetromino({{-1, 0}, {0, 0}, {1, 0}, {2, 0}}), // I
makeTetromino({{-1, 0}, {0, 0}, {1, 0}, {1, 1}}), // J
makeTetromino({{-1, 1}, {-1, 0}, {0, 0}, {1, 0}}), // L
makeTetromino({{0, 0}, {1, 0}, {0, 1}, {1, 1}}), // O
makeTetromino({{-1, 0}, {0, 0}, {0, 1}, {1, 1}}), // S
makeTetromino({{-1, 0}, {0, 0}, {1, 0}, {0, 1}}), // T
makeTetromino({{-1, 1}, {0, 1}, {0, 0}, {1, 0}}), // Z
}};
class RandomBag {
public:
RandomBag() { refill(); }
void seed(std::uint32_t value) { rng.seed(value); }
int next() {
if (bag.empty())
refill();
int val = bag.back();
bag.pop_back();
return val;
}
private:
std::vector<int> bag;
std::mt19937 rng{std::random_device{}()};
void refill() {
bag.clear();
bag.reserve(7);
for (int i = 0; i < 7; ++i)
bag.push_back(i);
std::shuffle(bag.begin(), bag.end(), rng);
}
};
struct ActivePiece {
int type = 0;
int rotation = 0;
int x = 0;
int y = 0;
};
struct GameState {
std::array<int, kBoardWidth * kBoardHeight> board{};
ActivePiece current{};
int nextPiece = 0;
int level = 1;
int linesCleared = 0;
int score = 0;
int highScore = 0;
bool paused = false;
bool gameOver = false;
};
[[nodiscard]] std::uint32_t randomSeed(AppContext& ctx) {
if (auto* rnd = ctx.random())
return rnd->nextUint32();
static std::random_device rd;
return rd();
}
class TetrisGame {
public:
explicit TetrisGame(AppContext& ctx) : context(ctx), framebuffer(ctx.framebuffer) {
bag.seed(randomSeed(context));
loadHighScore();
reset();
}
void onStart() {
scheduleDropTimer();
dirty = true;
renderIfNeeded();
}
void onStop() { cancelTimers(); }
void handleEvent(const AppEvent& event) {
switch (event.type) {
case AppEventType::Button:
handleButtons(event.button);
break;
case AppEventType::Timer:
handleTimer(event.timer.handle);
break;
}
renderIfNeeded();
}
private:
AppContext& context;
typename AppContext::Framebuffer& framebuffer;
GameState state;
RandomBag bag;
InputState lastInput{};
bool dirty = false;
AppTimerHandle dropTimer = cardboy::sdk::kInvalidAppTimer;
AppTimerHandle softTimer = cardboy::sdk::kInvalidAppTimer;
void reset() {
cancelTimers();
int oldHigh = state.highScore;
state = {};
state.highScore = oldHigh;
state.current.type = bag.next();
state.nextPiece = bag.next();
state.current.x = kBoardWidth / 2;
state.current.y = 0;
state.level = 1;
state.gameOver = false;
state.paused = false;
dirty = true;
scheduleDropTimer();
if (auto* power = context.powerManager())
power->setSlowMode(false);
}
void handleButtons(const AppButtonEvent& evt) {
const auto& cur = evt.current;
const auto& prev = evt.previous;
lastInput = cur;
if (cur.b && !prev.b) {
context.requestAppSwitchByName(kMenuAppName);
return;
}
if (cur.start && !prev.start) {
if (state.gameOver) {
reset();
} else {
state.paused = !state.paused;
if (auto* power = context.powerManager())
power->setSlowMode(state.paused);
}
dirty = true;
}
if (state.paused || state.gameOver)
return;
if (cur.left && !prev.left)
tryMove(-1, 0);
if (cur.right && !prev.right)
tryMove(1, 0);
if (cur.a && !prev.a)
rotate(1);
if (cur.select && !prev.select)
hardDrop();
if (cur.down && !prev.down) {
softDropStep();
scheduleSoftDropTimer();
} else if (!cur.down && prev.down) {
cancelSoftDropTimer();
}
}
void handleTimer(AppTimerHandle handle) {
if (handle == dropTimer) {
if (!state.paused && !state.gameOver)
gravityStep();
} else if (handle == softTimer) {
if (lastInput.down && !state.paused && !state.gameOver)
softDropStep();
else
cancelSoftDropTimer();
}
}
void cancelTimers() {
if (dropTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(dropTimer);
dropTimer = cardboy::sdk::kInvalidAppTimer;
}
cancelSoftDropTimer();
}
void cancelSoftDropTimer() {
if (softTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(softTimer);
softTimer = cardboy::sdk::kInvalidAppTimer;
}
}
void scheduleDropTimer() {
cancelDropTimer();
const std::uint32_t interval = dropIntervalMs();
dropTimer = context.scheduleRepeatingTimer(interval);
}
void cancelDropTimer() {
if (dropTimer != cardboy::sdk::kInvalidAppTimer) {
context.cancelTimer(dropTimer);
dropTimer = cardboy::sdk::kInvalidAppTimer;
}
}
void scheduleSoftDropTimer() {
cancelSoftDropTimer();
softTimer = context.scheduleRepeatingTimer(60);
}
[[nodiscard]] std::uint32_t dropIntervalMs() const {
const int base = 700;
const int step = 50;
int interval = base - (state.level - 1) * step;
if (interval < 120)
interval = 120;
return static_cast<std::uint32_t>(interval);
}
[[nodiscard]] const Tetromino& currentPiece() const { return kPieces[state.current.type]; }
bool canPlace(int nx, int ny, int rot) const {
const auto& piece = kPieces[state.current.type];
rot = ((rot % 4) + 4) % 4;
for (const auto& block: piece.rotations[rot]) {
int gx = nx + block.x;
int gy = ny + block.y;
if (gx < 0 || gx >= kBoardWidth)
return false;
if (gy >= kBoardHeight)
return false;
if (gy >= 0 && cellAt(gx, gy) != 0)
return false;
}
return true;
}
[[nodiscard]] int cellAt(int x, int y) const { return state.board[y * kBoardWidth + x]; }
void setCell(int x, int y, int value) { state.board[y * kBoardWidth + x] = value; }
void tryMove(int dx, int dy) {
int nx = state.current.x + dx;
int ny = state.current.y + dy;
if (canPlace(nx, ny, state.current.rotation)) {
state.current.x = nx;
state.current.y = ny;
dirty = true;
if (dx != 0) {
if (auto* buzzer = context.buzzer())
buzzer->beepMove();
}
}
}
void rotate(int direction) {
int nextRot = state.current.rotation + (direction >= 0 ? 1 : -1);
nextRot = ((nextRot % 4) + 4) % 4;
if (canPlace(state.current.x, state.current.y, nextRot)) {
state.current.rotation = nextRot;
dirty = true;
if (auto* buzzer = context.buzzer())
buzzer->beepRotate();
}
}
void gravityStep() {
if (!canPlace(state.current.x, state.current.y + 1, state.current.rotation)) {
lockPiece();
} else {
state.current.y++;
dirty = true;
}
}
void softDropStep() {
if (canPlace(state.current.x, state.current.y + 1, state.current.rotation)) {
state.current.y++;
state.score += 1;
updateHighScore();
dirty = true;
if (auto* buzzer = context.buzzer())
buzzer->beepMove();
} else {
lockPiece();
}
}
void hardDrop() {
int distance = 0;
while (canPlace(state.current.x, state.current.y + distance + 1, state.current.rotation))
++distance;
if (distance > 0) {
state.current.y += distance;
state.score += distance * 2;
updateHighScore();
dirty = true;
if (auto* buzzer = context.buzzer())
buzzer->beepMove();
}
lockPiece();
}
void lockPiece() {
for (const auto& block: currentPiece().rotations[state.current.rotation]) {
int gx = state.current.x + block.x;
int gy = state.current.y + block.y;
if (gy >= 0 && gy < kBoardHeight && gx >= 0 && gx < kBoardWidth)
setCell(gx, gy, state.current.type + 1);
if (gy < 0)
state.gameOver = true;
}
handleLineClear();
spawnNext();
dirty = true;
if (state.gameOver) {
cancelSoftDropTimer();
cancelDropTimer();
if (auto* buzzer = context.buzzer())
buzzer->beepGameOver();
if (auto* power = context.powerManager())
power->setSlowMode(true);
} else {
if (auto* buzzer = context.buzzer())
buzzer->beepLock();
}
}
void handleLineClear() {
int cleared = 0;
for (int y = kBoardHeight - 1; y >= 0; --y) {
bool full = true;
for (int x = 0; x < kBoardWidth; ++x) {
if (cellAt(x, y) == 0) {
full = false;
break;
}
}
if (full) {
++cleared;
for (int pull = y; pull > 0; --pull)
for (int x = 0; x < kBoardWidth; ++x)
setCell(x, pull, cellAt(x, pull - 1));
for (int x = 0; x < kBoardWidth; ++x)
setCell(x, 0, 0);
++y; // re-check same row after collapse
}
}
if (cleared > 0) {
state.linesCleared += cleared;
if (cleared < static_cast<int>(kLineScores.size()))
state.score += kLineScores[cleared] * state.level;
else
state.score += kLineScores.back() * state.level;
int newLevel = 1 + state.linesCleared / 10;
if (newLevel != state.level) {
state.level = newLevel;
scheduleDropTimer();
if (auto* buzzer = context.buzzer())
buzzer->beepLevelUp(state.level);
}
updateHighScore();
if (auto* buzzer = context.buzzer())
buzzer->beepLines(cleared);
}
}
void spawnNext() {
state.current.type = state.nextPiece;
state.current.rotation = 0;
state.current.x = kBoardWidth / 2;
state.current.y = 0;
state.nextPiece = bag.next();
if (!canPlace(state.current.x, state.current.y, state.current.rotation))
state.gameOver = true;
}
void updateHighScore() {
if (state.score > state.highScore) {
state.highScore = state.score;
if (auto* storage = context.storage())
storage->writeUint32("tetris", "best", static_cast<std::uint32_t>(state.highScore));
}
}
void loadHighScore() {
if (auto* storage = context.storage()) {
std::uint32_t stored = 0;
if (storage->readUint32("tetris", "best", stored))
state.highScore = static_cast<int>(stored);
}
}
void renderIfNeeded() {
if (!dirty)
return;
dirty = false;
framebuffer.frameReady();
drawBoard();
drawActivePiece();
drawNextPreview();
drawHUD();
framebuffer.sendFrame();
}
void drawBoard() {
const int originX = (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2;
const int originY = (cardboy::sdk::kDisplayHeight - kBoardHeight * kCellSize) / 2;
for (int y = 0; y < kBoardHeight; ++y) {
for (int x = 0; x < kBoardWidth; ++x) {
if (int value = cellAt(x, y); value != 0)
drawCell(originX, originY, x, y, value - 1, true);
}
}
drawBoardFrame(originX, originY);
}
void drawActivePiece() {
if (state.gameOver)
return;
const int originX = (cardboy::sdk::kDisplayWidth - kBoardWidth * kCellSize) / 2;
const int originY = (cardboy::sdk::kDisplayHeight - kBoardHeight * kCellSize) / 2;
for (const auto& block: currentPiece().rotations[state.current.rotation]) {
int gx = state.current.x + block.x;
int gy = state.current.y + block.y;
if (gy < 0)
continue;
drawCell(originX, originY, gx, gy, state.current.type, false);
}
}
static bool patternPixel(int pieceIndex, int dx, int dy) {
const int idx = std::clamp(pieceIndex, 0, static_cast<int>(kPieces.size()) - 1);
const int mx = dx & 0x3;
const int my = dy & 0x3;
switch (idx) {
case 0: // I - vertical stripes
return mx < 2;
case 1: // J - horizontal stripes
return my < 2;
case 2: { // L - forward diagonal
const int sum = (mx + my) & 0x3;
return sum < 2;
}
case 3: // O - diamond centerpiece
return (mx == 1 && my == 1) || (mx == 2 && my == 1) || (mx == 1 && my == 2) || (mx == 2 && my == 2);
case 4: // S - checkerboard
return ((mx ^ my) & 0x1) == 0;
case 5: { // T - cross
return (mx == 0) || (my == 0);
}
case 6: { // Z - backward diagonal
int diff = mx - my;
if (diff < 0)
diff += 4;
diff &= 0x3;
return diff < 2;
}
}
return true;
}
void drawPatternBlock(int x0, int y0, int size, int pieceIndex, bool locked) {
const int idx = std::clamp(pieceIndex, 0, static_cast<int>(kPieces.size()) - 1);
for (int dy = 0; dy < size; ++dy) {
for (int dx = 0; dx < size; ++dx) {
const bool border = dx == 0 || dx == size - 1 || dy == 0 || dy == size - 1;
bool fill = patternPixel(idx, dx, dy);
if (!locked && !border)
fill = fill && (((dx + dy) & 0x1) == 0);
const bool on = border || fill;
framebuffer.drawPixel(x0 + dx, y0 + dy, on);
}
}
}
void drawCell(int originX, int originY, int cx, int cy, int pieceIndex, bool locked) {
const int x0 = originX + cx * kCellSize;
const int y0 = originY + cy * kCellSize;
drawPatternBlock(x0, y0, kCellSize, pieceIndex, locked);
}
void drawBoardFrame(int originX, int originY) {
const int widthPixels = kBoardWidth * kCellSize;
const int heightPixels = kBoardHeight * kCellSize;
const int x0 = originX;
const int y0 = originY;
const int x1 = originX + widthPixels - 1;
const int y1 = originY + heightPixels - 1;
for (int x = x0; x <= x1; ++x) {
framebuffer.drawPixel(x, y0, true);
framebuffer.drawPixel(x, y1, true);
}
for (int y = y0; y <= y1; ++y) {
framebuffer.drawPixel(x0, y, true);
framebuffer.drawPixel(x1, y, true);
}
}
void drawNextPreview() {
const int blockSize = kCellSize;
const int boxSize = blockSize * 4;
const int originX = (cardboy::sdk::kDisplayWidth + kBoardWidth * kCellSize) / 2 + 24;
const int originY = (cardboy::sdk::kDisplayHeight - boxSize) / 2;
for (int dy = 0; dy < boxSize; ++dy)
for (int dx = 0; dx < boxSize; ++dx)
framebuffer.drawPixel(originX + dx, originY + dy,
(dy == 0 || dy == boxSize - 1 || dx == 0 || dx == boxSize - 1));
const auto& piece = kPieces[state.nextPiece];
for (const auto& block: piece.rotations[0]) {
const int px = originX + (block.x + 1) * blockSize + 1;
const int py = originY + (block.y + 1) * blockSize + 1;
drawPatternBlock(px, py, blockSize - 2, state.nextPiece, true);
}
}
void drawLabel(int x, int y, std::string_view text, int scale = 1) {
font16x8::drawText(framebuffer, x, y, text, scale, true, 1);
}
void drawHUD() {
const int margin = 16;
drawLabel(margin, margin, "SCORE", 1);
drawLabel(margin, margin + 16, std::to_string(state.score), 1);
drawLabel(margin, margin + 40, "BEST", 1);
drawLabel(margin, margin + 56, std::to_string(state.highScore), 1);
drawLabel(margin, margin + 80, "LEVEL", 1);
drawLabel(margin, margin + 96, std::to_string(state.level), 1);
if (auto* battery = context.battery(); battery && battery->hasData()) {
char line[32];
std::snprintf(line, sizeof(line), "BAT %.2fV", battery->voltage());
drawLabel(margin, margin + 120, line, 1);
}
drawLabel(margin, cardboy::sdk::kDisplayHeight - 48, "A ROTATE", 1);
drawLabel(margin, cardboy::sdk::kDisplayHeight - 32, "DOWN DROP", 1);
drawLabel(margin, cardboy::sdk::kDisplayHeight - 16, "B MENU", 1);
if (state.paused)
drawCenteredBanner("PAUSED");
else if (state.gameOver)
drawCenteredBanner("GAME OVER");
}
void drawCenteredBanner(std::string_view text) {
const int w = font16x8::measureText(text, 2, 1);
const int h = font16x8::kGlyphHeight * 2;
const int x = (cardboy::sdk::kDisplayWidth - w) / 2;
const int y = (cardboy::sdk::kDisplayHeight - h) / 2;
for (int yy = -4; yy < h + 4; ++yy)
for (int xx = -6; xx < w + 6; ++xx)
framebuffer.drawPixel(x + xx, y + yy, yy == -4 || yy == h + 3 || xx == -6 || xx == w + 5);
font16x8::drawText(framebuffer, x, y, text, 2, true, 1);
}
};
class TetrisApp final : public cardboy::sdk::IApp {
public:
explicit TetrisApp(AppContext& ctx) : game(ctx) {}
void onStart() override { game.onStart(); }
void onStop() override { game.onStop(); }
void handleEvent(const AppEvent& event) override { game.handleEvent(event); }
private:
TetrisGame game;
};
class TetrisFactory final : public cardboy::sdk::IAppFactory {
public:
const char* name() const override { return kTetrisAppName; }
std::unique_ptr<cardboy::sdk::IApp> create(AppContext& context) override {
return std::make_unique<TetrisApp>(context);
}
};
} // namespace
std::unique_ptr<cardboy::sdk::IAppFactory> createTetrisAppFactory() { return std::make_unique<TetrisFactory>(); }
} // namespace apps

View File

@@ -0,0 +1,22 @@
add_library(cardboy_backend_interface INTERFACE)
target_link_libraries(cardboy_backend_interface INTERFACE cardboy_utils)
set_target_properties(cardboy_backend_interface PROPERTIES
EXPORT_NAME backend_interface
)
target_include_directories(cardboy_backend_interface
INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_sources(cardboy_backend_interface
INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/backend/backend_interface.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/backend.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/display_spec.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/input_state.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/platform.hpp
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/sdk/services.hpp
)

View File

@@ -0,0 +1,14 @@
#pragma once
#include <concepts>
namespace cardboy::backend {
template<typename Backend>
concept BackendInterface = requires {
typename Backend::Framebuffer;
typename Backend::Input;
typename Backend::Clock;
};
} // namespace cardboy::backend

View File

@@ -0,0 +1,10 @@
#pragma once
#include <cardboy/sdk/platform.hpp>
#include <cardboy/backend/backend_impl.hpp>
#include <cardboy/backend/backend_interface.hpp>
namespace cardboy::backend {
static_assert(BackendInterface<ActiveBackend>, "ActiveBackend must provide Framebuffer, Input, Clock types");
} // namespace cardboy::backend

View File

@@ -0,0 +1,8 @@
#pragma once
namespace cardboy::sdk {
inline constexpr int kDisplayWidth = 400;
inline constexpr int kDisplayHeight = 240;
} // namespace cardboy::sdk

View File

@@ -0,0 +1,18 @@
#pragma once
namespace cardboy::sdk {
class FramebufferHooks {
public:
using PreSendHook = void (*)(void* framebuffer, void* userData);
static void setPreSendHook(PreSendHook hook, void* userData);
static void clearPreSendHook();
static void invokePreSend(void* framebuffer);
private:
static PreSendHook hook_;
static void* userData_;
};
} // namespace cardboy::sdk

View File

@@ -0,0 +1,16 @@
#pragma once
namespace cardboy::sdk {
struct InputState {
bool up = false;
bool left = false;
bool right = false;
bool down = false;
bool a = false;
bool b = false;
bool select = false;
bool start = false;
};
} // namespace cardboy::sdk

View File

@@ -0,0 +1,137 @@
#pragma once
#include "cardboy/sdk/framebuffer_hooks.hpp"
#include "input_state.hpp"
#include <concepts>
#include <cstdint>
namespace cardboy::sdk {
namespace detail {
template<typename Impl>
concept HasClearImpl = requires(Impl& impl, bool value) {
{ impl.clear_impl(value) };
};
template<typename Impl>
concept HasFrameReadyImpl = requires(Impl& impl) {
{ impl.frameReady_impl() };
};
template<typename Impl>
concept HasSendFrameImpl = requires(Impl& impl, bool flag) {
{ impl.sendFrame_impl(flag) };
};
template<typename Impl>
concept HasDrawBits8Impl = requires(Impl& impl, int x, int y, std::uint8_t bits) { impl.drawBits8_impl(x, y, bits); };
template<typename Impl>
concept HasFrameInFlightImpl = requires(const Impl& impl) {
{ impl.frameInFlight_impl() } -> std::convertible_to<bool>;
};
template<typename Impl>
concept HasSleepMsImpl = requires(Impl& impl, std::uint32_t value) {
{ impl.sleep_ms_impl(value) };
};
} // namespace detail
template<typename Impl>
class FramebufferFacade {
public:
[[nodiscard]] __attribute__((always_inline)) int width() const { return impl().width_impl(); }
[[nodiscard]] __attribute__((always_inline)) int height() const { return impl().height_impl(); }
__attribute__((always_inline)) void drawPixel(int x, int y, bool on) { impl().drawPixel_impl(x, y, on); }
__attribute__((always_inline)) void drawBits8(int x, int y, std::uint8_t bits) {
if constexpr (detail::HasDrawBits8Impl<Impl>) {
impl().drawBits8_impl(x, y, bits);
} else {
defaultDrawBits8(x, y, bits);
}
}
void clear(bool on) {
if constexpr (detail::HasClearImpl<Impl>) {
impl().clear_impl(on);
} else {
defaultClear(on);
}
}
__attribute__((always_inline)) void frameReady() {
if constexpr (detail::HasFrameReadyImpl<Impl>)
impl().frameReady_impl();
}
__attribute__((always_inline)) void sendFrame(bool clearDrawBuffer = true) {
if constexpr (detail::HasSendFrameImpl<Impl>) {
FramebufferHooks::invokePreSend(&impl());
impl().sendFrame_impl(clearDrawBuffer);
}
}
[[nodiscard]] __attribute__((always_inline)) bool isFrameInFlight() const {
if constexpr (detail::HasFrameInFlightImpl<Impl>)
return impl().frameInFlight_impl();
return false;
}
protected:
FramebufferFacade() = default;
~FramebufferFacade() = default;
private:
[[nodiscard]] __attribute__((always_inline)) Impl& impl() { return static_cast<Impl&>(*this); }
[[nodiscard]] __attribute__((always_inline)) const Impl& impl() const { return static_cast<const Impl&>(*this); }
void defaultClear(bool on) {
for (int y = 0; y < height(); ++y)
for (int x = 0; x < width(); ++x)
drawPixel(x, y, on);
}
void defaultDrawBits8(int x, int y, std::uint8_t bits) {
for (int col = 0; col < 8; ++col) {
const std::uint8_t mask = static_cast<std::uint8_t>(1u << (7 - col));
const bool pixelOn = (bits & mask) != 0;
drawPixel(x + col, y, pixelOn);
}
}
};
template<typename Impl>
class InputFacade {
public:
InputState readState() { return impl().readState_impl(); }
protected:
InputFacade() = default;
~InputFacade() = default;
private:
[[nodiscard]] Impl& impl() { return static_cast<Impl&>(*this); }
};
template<typename Impl>
class ClockFacade {
public:
std::uint32_t millis() { return impl().millis_impl(); }
void sleep_ms(std::uint32_t ms) {
if constexpr (detail::HasSleepMsImpl<Impl>)
impl().sleep_ms_impl(ms);
}
protected:
ClockFacade() = default;
~ClockFacade() = default;
private:
[[nodiscard]] Impl& impl() { return static_cast<Impl&>(*this); }
};
} // namespace cardboy::sdk

View File

@@ -0,0 +1,87 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
namespace cardboy::sdk {
class IBuzzer {
public:
virtual ~IBuzzer() = default;
virtual void init() {}
virtual void tone(std::uint32_t freq, std::uint32_t duration_ms, std::uint32_t gap_ms = 0) = 0;
virtual void beepRotate() {}
virtual void beepMove() {}
virtual void beepLock() {}
virtual void beepLines(int /*lines*/) {}
virtual void beepLevelUp(int /*level*/) {}
virtual void beepGameOver() {}
virtual void setMuted(bool /*muted*/) {}
virtual void toggleMuted() {}
[[nodiscard]] virtual bool isMuted() const { return false; }
};
class IBatteryMonitor {
public:
virtual ~IBatteryMonitor() = default;
[[nodiscard]] virtual bool hasData() const { return false; }
[[nodiscard]] virtual float voltage() const { return 0.0f; }
[[nodiscard]] virtual float charge() const { return 0.0f; }
[[nodiscard]] virtual float current() const { return 0.0f; }
};
class IStorage {
public:
virtual ~IStorage() = default;
[[nodiscard]] virtual bool readUint32(std::string_view ns, std::string_view key, std::uint32_t& out) = 0;
virtual void writeUint32(std::string_view ns, std::string_view key, std::uint32_t value) = 0;
};
class IRandom {
public:
virtual ~IRandom() = default;
[[nodiscard]] virtual std::uint32_t nextUint32() = 0;
};
class IHighResClock {
public:
virtual ~IHighResClock() = default;
[[nodiscard]] virtual std::uint64_t micros() = 0;
};
class IPowerManager {
public:
virtual ~IPowerManager() = default;
virtual void setSlowMode(bool enable) = 0;
[[nodiscard]] virtual bool isSlowMode() const = 0;
};
class IFilesystem {
public:
virtual ~IFilesystem() = default;
virtual bool mount() = 0;
[[nodiscard]] virtual bool isMounted() const = 0;
[[nodiscard]] virtual std::string basePath() const = 0;
};
struct Services {
IBuzzer* buzzer = nullptr;
IBatteryMonitor* battery = nullptr;
IStorage* storage = nullptr;
IRandom* random = nullptr;
IHighResClock* highResClock = nullptr;
IPowerManager* powerManager = nullptr;
IFilesystem* filesystem = nullptr;
};
} // namespace cardboy::sdk

View File

@@ -0,0 +1,38 @@
include(FetchContent)
set(SFML_BUILD_AUDIO OFF CACHE BOOL "Disable SFML audio module" FORCE)
set(SFML_BUILD_NETWORK OFF CACHE BOOL "Disable SFML network module" FORCE)
set(SFML_BUILD_EXAMPLES OFF CACHE BOOL "Disable SFML examples" FORCE)
set(SFML_BUILD_TESTS OFF CACHE BOOL "Disable SFML tests" FORCE)
set(SFML_USE_SYSTEM_DEPS OFF CACHE BOOL "Use bundled SFML dependencies" FORCE)
FetchContent_Declare(
SFML
GIT_REPOSITORY https://github.com/SFML/SFML.git
GIT_TAG 3.0.2
GIT_SHALLOW ON
)
FetchContent_MakeAvailable(SFML)
add_library(cardboy_backend_desktop STATIC
src/desktop_backend.cpp
)
set_target_properties(cardboy_backend_desktop PROPERTIES
EXPORT_NAME backend_desktop
)
target_include_directories(cardboy_backend_desktop
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_link_libraries(cardboy_backend_desktop
PUBLIC
cardboy_backend_interface
SFML::Window
SFML::Graphics
SFML::System
)
set(CARDBOY_DESKTOP_BACKEND_TARGET cardboy_backend_desktop PARENT_SCOPE)

View File

@@ -0,0 +1,7 @@
#pragma once
#include "cardboy/backend/desktop_backend.hpp"
namespace cardboy::backend {
using ActiveBackend = DesktopBackend;
}

View File

@@ -0,0 +1,186 @@
#pragma once
#include "cardboy/sdk/platform.hpp"
#include "cardboy/sdk/services.hpp"
#include <SFML/Graphics.hpp>
#include <SFML/Window/Keyboard.hpp>
#include <chrono>
#include <cstdint>
#include <filesystem>
#include <limits>
#include <random>
#include <string>
#include <string_view>
#include <thread>
#include <unordered_map>
#include <vector>
namespace cardboy::backend::desktop {
constexpr int kPixelScale = 2;
class DesktopRuntime;
class DesktopBuzzer final : public cardboy::sdk::IBuzzer {
public:
void tone(std::uint32_t, std::uint32_t, std::uint32_t) override {}
void beepRotate() override {}
void beepMove() override {}
void beepLock() override {}
void beepLines(int) override {}
void beepLevelUp(int) override {}
void beepGameOver() override {}
};
class DesktopBattery final : public cardboy::sdk::IBatteryMonitor {
public:
[[nodiscard]] bool hasData() const override { return false; }
};
class DesktopStorage final : public cardboy::sdk::IStorage {
public:
[[nodiscard]] bool readUint32(std::string_view ns, std::string_view key, std::uint32_t& out) override;
void writeUint32(std::string_view ns, std::string_view key, std::uint32_t value) override;
private:
std::unordered_map<std::string, std::uint32_t> data;
static std::string composeKey(std::string_view ns, std::string_view key);
};
class DesktopRandom final : public cardboy::sdk::IRandom {
public:
DesktopRandom();
[[nodiscard]] std::uint32_t nextUint32() override;
private:
std::mt19937 rng;
std::uniform_int_distribution<std::uint32_t> dist;
};
class DesktopHighResClock final : public cardboy::sdk::IHighResClock {
public:
DesktopHighResClock();
[[nodiscard]] std::uint64_t micros() override;
private:
const std::chrono::steady_clock::time_point start;
};
class DesktopPowerManager final : public cardboy::sdk::IPowerManager {
public:
void setSlowMode(bool enable) override { slowMode = enable; }
[[nodiscard]] bool isSlowMode() const override { return slowMode; }
private:
bool slowMode = false;
};
class DesktopFilesystem final : public cardboy::sdk::IFilesystem {
public:
DesktopFilesystem();
bool mount() override;
[[nodiscard]] bool isMounted() const override { return mounted; }
[[nodiscard]] std::string basePath() const override { return basePathPath.string(); }
private:
std::filesystem::path basePathPath;
bool mounted = false;
};
class DesktopFramebuffer final : public cardboy::sdk::FramebufferFacade<DesktopFramebuffer> {
public:
explicit DesktopFramebuffer(DesktopRuntime& runtime);
[[nodiscard]] int width_impl() const;
[[nodiscard]] int height_impl() const;
void drawPixel_impl(int x, int y, bool on);
void clear_impl(bool on);
void frameReady_impl();
void sendFrame_impl(bool clearAfterSend);
[[nodiscard]] bool frameInFlight_impl() const { return false; }
private:
DesktopRuntime& runtime;
};
class DesktopInput final : public cardboy::sdk::InputFacade<DesktopInput> {
public:
explicit DesktopInput(DesktopRuntime& runtime);
cardboy::sdk::InputState readState_impl();
void handleKey(sf::Keyboard::Key key, bool pressed);
private:
DesktopRuntime& runtime;
cardboy::sdk::InputState state{};
};
class DesktopClock final : public cardboy::sdk::ClockFacade<DesktopClock> {
public:
explicit DesktopClock(DesktopRuntime& runtime);
std::uint32_t millis_impl();
void sleep_ms_impl(std::uint32_t ms);
private:
DesktopRuntime& runtime;
const std::chrono::steady_clock::time_point start;
};
class DesktopRuntime {
public:
DesktopRuntime();
cardboy::sdk::Services& serviceRegistry();
void processEvents();
void presentIfNeeded();
void sleepFor(std::uint32_t ms);
[[nodiscard]] bool isRunning() const { return running; }
DesktopFramebuffer framebuffer;
DesktopInput input;
DesktopClock clock;
private:
friend class DesktopFramebuffer;
friend class DesktopInput;
friend class DesktopClock;
void setPixel(int x, int y, bool on);
void clearPixels(bool on);
sf::RenderWindow window;
sf::Texture texture;
sf::Sprite sprite;
std::vector<std::uint8_t> pixels;
bool dirty = true;
bool running = true;
bool clearNextFrame = true;
DesktopBuzzer buzzerService;
DesktopBattery batteryService;
DesktopStorage storageService;
DesktopRandom randomService;
DesktopHighResClock highResService;
DesktopPowerManager powerService;
DesktopFilesystem filesystemService;
cardboy::sdk::Services services{};
};
struct Backend {
using Framebuffer = DesktopFramebuffer;
using Input = DesktopInput;
using Clock = DesktopClock;
};
} // namespace cardboy::backend::desktop
namespace cardboy::backend {
using DesktopBackend = desktop::Backend;
} // namespace cardboy::backend

View File

@@ -0,0 +1,235 @@
#include "cardboy/backend/desktop_backend.hpp"
#include "cardboy/sdk/display_spec.hpp"
#include <SFML/Graphics.hpp>
#include <SFML/Window.hpp>
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <stdexcept>
#include <system_error>
namespace cardboy::backend::desktop {
bool DesktopStorage::readUint32(std::string_view ns, std::string_view key, std::uint32_t& out) {
auto it = data.find(composeKey(ns, key));
if (it == data.end())
return false;
out = it->second;
return true;
}
void DesktopStorage::writeUint32(std::string_view ns, std::string_view key, std::uint32_t value) {
data[composeKey(ns, key)] = value;
}
std::string DesktopStorage::composeKey(std::string_view ns, std::string_view key) {
std::string result;
result.reserve(ns.size() + key.size() + 1);
result.append(ns.begin(), ns.end());
result.push_back(':');
result.append(key.begin(), key.end());
return result;
}
DesktopRandom::DesktopRandom() : rng(std::random_device{}()), dist(0u, std::numeric_limits<std::uint32_t>::max()) {}
std::uint32_t DesktopRandom::nextUint32() { return dist(rng); }
DesktopHighResClock::DesktopHighResClock() : start(std::chrono::steady_clock::now()) {}
std::uint64_t DesktopHighResClock::micros() {
const auto now = std::chrono::steady_clock::now();
return static_cast<std::uint64_t>(std::chrono::duration_cast<std::chrono::microseconds>(now - start).count());
}
DesktopFilesystem::DesktopFilesystem() {
if (const char* env = std::getenv("CARDBOY_ROM_DIR"); env && *env) {
basePathPath = std::filesystem::path(env);
} else {
basePathPath = std::filesystem::current_path() / "roms";
}
}
bool DesktopFilesystem::mount() {
std::error_code ec;
if (std::filesystem::exists(basePathPath, ec)) {
mounted = std::filesystem::is_directory(basePathPath, ec);
} else {
mounted = std::filesystem::create_directories(basePathPath, ec);
}
return mounted;
}
DesktopFramebuffer::DesktopFramebuffer(DesktopRuntime& runtime) : runtime(runtime) {}
int DesktopFramebuffer::width_impl() const { return cardboy::sdk::kDisplayWidth; }
int DesktopFramebuffer::height_impl() const { return cardboy::sdk::kDisplayHeight; }
void DesktopFramebuffer::drawPixel_impl(int x, int y, bool on) { runtime.setPixel(x, y, on); }
void DesktopFramebuffer::clear_impl(bool on) { runtime.clearPixels(on); }
void DesktopFramebuffer::frameReady_impl() {
if (runtime.clearNextFrame) {
runtime.clearPixels(false);
runtime.clearNextFrame = false;
}
}
void DesktopFramebuffer::sendFrame_impl(bool clearAfterSend) {
runtime.clearNextFrame = clearAfterSend;
runtime.dirty = true;
}
DesktopInput::DesktopInput(DesktopRuntime& runtime) : runtime(runtime) {}
cardboy::sdk::InputState DesktopInput::readState_impl() { return state; }
void DesktopInput::handleKey(sf::Keyboard::Key key, bool pressed) {
switch (key) {
case sf::Keyboard::Key::Up:
state.up = pressed;
break;
case sf::Keyboard::Key::Down:
state.down = pressed;
break;
case sf::Keyboard::Key::Left:
state.left = pressed;
break;
case sf::Keyboard::Key::Right:
state.right = pressed;
break;
case sf::Keyboard::Key::Z:
case sf::Keyboard::Key::A:
state.a = pressed;
break;
case sf::Keyboard::Key::X:
case sf::Keyboard::Key::S:
state.b = pressed;
break;
case sf::Keyboard::Key::Space:
state.select = pressed;
break;
case sf::Keyboard::Key::Enter:
state.start = pressed;
break;
default:
break;
}
}
DesktopClock::DesktopClock(DesktopRuntime& runtime) : runtime(runtime), start(std::chrono::steady_clock::now()) {}
std::uint32_t DesktopClock::millis_impl() {
const auto now = std::chrono::steady_clock::now();
return static_cast<std::uint32_t>(std::chrono::duration_cast<std::chrono::milliseconds>(now - start).count());
}
void DesktopClock::sleep_ms_impl(std::uint32_t ms) { runtime.sleepFor(ms); }
DesktopRuntime::DesktopRuntime() :
window(sf::VideoMode(
sf::Vector2u{cardboy::sdk::kDisplayWidth * kPixelScale, cardboy::sdk::kDisplayHeight * kPixelScale}),
"Cardboy Desktop"),
texture(), sprite(texture),
pixels(static_cast<std::size_t>(cardboy::sdk::kDisplayWidth * cardboy::sdk::kDisplayHeight) * 4, 0),
framebuffer(*this), input(*this), clock(*this) {
window.setFramerateLimit(60);
if (!texture.resize(sf::Vector2u{cardboy::sdk::kDisplayWidth, cardboy::sdk::kDisplayHeight}))
throw std::runtime_error("Failed to allocate texture for desktop framebuffer");
sprite.setTexture(texture, true);
sprite.setScale(sf::Vector2f{static_cast<float>(kPixelScale), static_cast<float>(kPixelScale)});
clearPixels(false);
presentIfNeeded();
services.buzzer = &buzzerService;
services.battery = &batteryService;
services.storage = &storageService;
services.random = &randomService;
services.highResClock = &highResService;
services.powerManager = &powerService;
services.filesystem = &filesystemService;
}
cardboy::sdk::Services& DesktopRuntime::serviceRegistry() { return services; }
void DesktopRuntime::setPixel(int x, int y, bool on) {
if (x < 0 || y < 0 || x >= cardboy::sdk::kDisplayWidth || y >= cardboy::sdk::kDisplayHeight)
return;
const std::size_t idx = static_cast<std::size_t>(y * cardboy::sdk::kDisplayWidth + x) * 4;
const std::uint8_t value = on ? static_cast<std::uint8_t>(255) : static_cast<std::uint8_t>(0);
pixels[idx + 0] = value;
pixels[idx + 1] = value;
pixels[idx + 2] = value;
pixels[idx + 3] = 255;
dirty = true;
}
void DesktopRuntime::clearPixels(bool on) {
const std::uint8_t value = on ? static_cast<std::uint8_t>(255) : static_cast<std::uint8_t>(0);
for (std::size_t i = 0; i < pixels.size(); i += 4) {
pixels[i + 0] = value;
pixels[i + 1] = value;
pixels[i + 2] = value;
pixels[i + 3] = 255;
}
dirty = true;
}
void DesktopRuntime::processEvents() {
while (auto eventOpt = window.pollEvent()) {
const sf::Event& event = *eventOpt;
if (event.is<sf::Event::Closed>()) {
running = false;
window.close();
continue;
}
if (const auto* keyPressed = event.getIf<sf::Event::KeyPressed>()) {
input.handleKey(keyPressed->code, true);
continue;
}
if (const auto* keyReleased = event.getIf<sf::Event::KeyReleased>()) {
input.handleKey(keyReleased->code, false);
continue;
}
}
}
void DesktopRuntime::presentIfNeeded() {
if (!dirty)
return;
texture.update(pixels.data());
window.clear(sf::Color::Black);
window.draw(sprite);
window.display();
dirty = false;
}
void DesktopRuntime::sleepFor(std::uint32_t ms) {
const auto target = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
do {
processEvents();
presentIfNeeded();
if (!running)
std::exit(0);
if (ms == 0)
return;
const auto now = std::chrono::steady_clock::now();
if (now >= target)
return;
const auto remaining = std::chrono::duration_cast<std::chrono::milliseconds>(target - now);
if (remaining.count() > 2)
std::this_thread::sleep_for(std::min<std::chrono::milliseconds>(remaining, std::chrono::milliseconds(8)));
else
std::this_thread::yield();
} while (true);
}
} // namespace cardboy::backend::desktop

View File

@@ -0,0 +1,25 @@
cmake_minimum_required(VERSION 3.16)
add_library(cardboy_sdk STATIC
${CMAKE_CURRENT_SOURCE_DIR}/src/app_system.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/status_bar.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/framebuffer_hooks.cpp
${CMAKE_CURRENT_SOURCE_DIR}/src/persistent_settings.cpp
)
set_target_properties(cardboy_sdk PROPERTIES
EXPORT_NAME sdk
)
target_include_directories(cardboy_sdk
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_compile_features(cardboy_sdk PUBLIC cxx_std_20)
target_link_libraries(cardboy_sdk
PUBLIC
cardboy_backend_interface
${CARDBOY_SDK_BACKEND_LIBRARY}
)

View File

@@ -0,0 +1,127 @@
#pragma once
#include "cardboy/gfx/Fonts.hpp"
#include <array>
#include <cctype>
#include <string_view>
namespace font16x8 {
constexpr int kGlyphWidth = 8;
constexpr int kGlyphHeight = 16;
constexpr unsigned char kFallbackChar = '?';
inline unsigned char normalizeChar(char ch) {
unsigned char uc = static_cast<unsigned char>(ch);
if (uc >= 'a' && uc <= 'z')
uc = static_cast<unsigned char>(std::toupper(static_cast<unsigned char>(uc)));
if (!std::isprint(static_cast<unsigned char>(uc)))
return kFallbackChar;
return uc;
}
inline const std::array<uint8_t, kGlyphHeight>& glyphBitmap(char ch) {
unsigned char uc = normalizeChar(ch);
return fonts_Terminess_Powerline[uc];
}
enum class Rotation {
None,
Clockwise90,
CounterClockwise90,
};
struct TextBounds {
int width = 0;
int height = 0;
};
template<typename Framebuffer>
inline void drawGlyph(Framebuffer& fb, int x, int y, char ch, int scale = 1, bool on = true,
Rotation rotation = Rotation::None) {
const auto& rows = glyphBitmap(ch);
if (rotation == Rotation::None && scale == 1 && on && ((x % 8) == 0)) {
for (int row = 0; row < kGlyphHeight; ++row) {
const uint8_t rowBits = rows[row];
fb.drawBits8(x, y + row, rowBits);
}
return;
}
for (int row = 0; row < kGlyphHeight; ++row) {
const uint8_t rowBits = rows[row];
for (int col = 0; col < kGlyphWidth; ++col) {
const uint8_t mask = static_cast<uint8_t>(1u << (kGlyphWidth - 1 - col));
if ((rowBits & mask) == 0)
continue;
for (int sx = 0; sx < scale; ++sx) {
for (int sy = 0; sy < scale; ++sy) {
int dstX = x;
int dstY = y;
switch (rotation) {
case Rotation::None:
dstX += col * scale + sx;
dstY += row * scale + sy;
break;
case Rotation::Clockwise90:
dstX += row * scale + sx;
dstY += (kGlyphWidth - 1 - col) * scale + sy;
break;
case Rotation::CounterClockwise90:
dstX += (kGlyphHeight - 1 - row) * scale + sx;
dstY += col * scale + sy;
break;
}
fb.drawPixel(dstX, dstY, on);
}
}
}
}
}
inline TextBounds measureTextBounds(std::string_view text, int scale = 1, int letterSpacing = 1,
Rotation rotation = Rotation::None) {
if (text.empty())
return {};
const int advance = (kGlyphWidth + letterSpacing) * scale;
const int extent = static_cast<int>(text.size()) * advance - letterSpacing * scale;
const int height = kGlyphHeight * scale;
switch (rotation) {
case Rotation::None:
return {extent, height};
case Rotation::Clockwise90:
case Rotation::CounterClockwise90:
return {height, extent};
}
return {extent, height};
}
inline int measureText(std::string_view text, int scale = 1, int letterSpacing = 1) {
if (text.empty())
return 0;
const int advance = (kGlyphWidth + letterSpacing) * scale;
return static_cast<int>(text.size()) * advance - letterSpacing * scale;
}
template<typename Framebuffer>
inline void drawText(Framebuffer& fb, int x, int y, std::string_view text, int scale = 1, bool on = true,
int letterSpacing = 1, Rotation rotation = Rotation::None) {
if (text.empty())
return;
const int advance = (kGlyphWidth + letterSpacing) * scale;
if (rotation == Rotation::None) {
int cursor = x;
for (char ch: text) {
drawGlyph(fb, cursor, y, ch, scale, on, rotation);
cursor += advance;
}
} else {
int cursor = y;
for (char ch: text) {
drawGlyph(fb, x, cursor, ch, scale, on, rotation);
cursor += advance;
}
}
}
} // namespace font16x8

View File

@@ -0,0 +1,133 @@
#pragma once
#include <cardboy/sdk/backend.hpp>
#include <cardboy/sdk/platform.hpp>
#include <cardboy/sdk/services.hpp>
#include <cstdint>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
namespace cardboy::sdk {
class AppSystem;
using AppTimerHandle = std::uint32_t;
constexpr AppTimerHandle kInvalidAppTimer = 0;
enum class AppEventType {
Button,
Timer,
};
struct AppButtonEvent {
InputState current{};
InputState previous{};
};
struct AppTimerEvent {
AppTimerHandle handle = kInvalidAppTimer;
};
struct AppEvent {
AppEventType type;
std::uint32_t timestamp_ms = 0;
AppButtonEvent button{};
AppTimerEvent timer{};
};
using ActiveBackend = cardboy::backend::ActiveBackend;
struct AppContext {
using Framebuffer = typename ActiveBackend::Framebuffer;
using Input = typename ActiveBackend::Input;
using Clock = typename ActiveBackend::Clock;
AppContext() = delete;
AppContext(Framebuffer& fb, Input& in, Clock& clk) : framebuffer(fb), input(in), clock(clk) {}
Framebuffer& framebuffer;
Input& input;
Clock& clock;
AppSystem* system = nullptr;
Services* services = nullptr;
[[nodiscard]] Services* getServices() const { return services; }
[[nodiscard]] IBuzzer* buzzer() const { return services ? services->buzzer : nullptr; }
[[nodiscard]] IBatteryMonitor* battery() const { return services ? services->battery : nullptr; }
[[nodiscard]] IStorage* storage() const { return services ? services->storage : nullptr; }
[[nodiscard]] IRandom* random() const { return services ? services->random : nullptr; }
[[nodiscard]] IHighResClock* highResClock() const { return services ? services->highResClock : nullptr; }
[[nodiscard]] IPowerManager* powerManager() const { return services ? services->powerManager : nullptr; }
[[nodiscard]] IFilesystem* filesystem() const { return services ? services->filesystem : nullptr; }
void requestAppSwitchByIndex(std::size_t index) {
pendingAppIndex = index;
pendingAppName.clear();
pendingSwitchByName = false;
pendingSwitch = true;
}
void requestAppSwitchByName(std::string_view name) {
pendingAppName.assign(name.begin(), name.end());
pendingSwitchByName = true;
pendingSwitch = true;
}
[[nodiscard]] bool hasPendingAppSwitch() const { return pendingSwitch; }
AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat = false) {
if (!system)
return kInvalidAppTimer;
return scheduleTimerInternal(delay_ms, repeat);
}
AppTimerHandle scheduleRepeatingTimer(std::uint32_t interval_ms) {
if (!system)
return kInvalidAppTimer;
return scheduleTimerInternal(interval_ms, true);
}
void cancelTimer(AppTimerHandle handle) {
if (!system)
return;
cancelTimerInternal(handle);
}
void cancelAllTimers() {
if (!system)
return;
cancelAllTimersInternal();
}
private:
friend class AppSystem;
bool pendingSwitch = false;
bool pendingSwitchByName = false;
std::size_t pendingAppIndex = 0;
std::string pendingAppName;
AppTimerHandle scheduleTimerInternal(std::uint32_t delay_ms, bool repeat);
void cancelTimerInternal(AppTimerHandle handle);
void cancelAllTimersInternal();
};
class IApp {
public:
virtual ~IApp() = default;
virtual void onStart() {}
virtual void onStop() {}
virtual void handleEvent(const AppEvent& event) = 0;
};
class IAppFactory {
public:
virtual ~IAppFactory() = default;
virtual const char* name() const = 0;
virtual std::unique_ptr<IApp> create(AppContext& context) = 0;
};
} // namespace cardboy::sdk

View File

@@ -0,0 +1,80 @@
#pragma once
#include "app_framework.hpp"
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
namespace cardboy::sdk {
class AppSystem {
public:
explicit AppSystem(AppContext context);
~AppSystem();
void registerApp(std::unique_ptr<IAppFactory> factory);
bool startApp(const std::string& name);
bool startAppByIndex(std::size_t index);
void run();
[[nodiscard]] std::size_t appCount() const { return factories.size(); }
[[nodiscard]] const IAppFactory* factoryAt(std::size_t index) const;
[[nodiscard]] std::size_t indexOfFactory(const IAppFactory* factory) const;
[[nodiscard]] std::size_t currentFactoryIndex() const { return activeIndex; }
[[nodiscard]] const IApp* currentApp() const { return current.get(); }
[[nodiscard]] const IAppFactory* currentFactory() const { return activeFactory; }
private:
friend struct AppContext;
struct TimerRecord {
AppTimerHandle id = kInvalidAppTimer;
std::uint32_t generation = 0;
std::uint32_t due_ms = 0;
std::uint32_t interval_ms = 0;
bool repeat = false;
bool active = false;
};
AppTimerHandle scheduleTimer(std::uint32_t delay_ms, bool repeat);
void cancelTimer(AppTimerHandle handle);
void cancelAllTimers();
void dispatchEvent(const AppEvent& event);
void processDueTimers(std::uint32_t now, std::vector<AppEvent>& outEvents);
std::uint32_t nextTimerDueMs(std::uint32_t now) const;
void clearTimersForCurrentApp();
TimerRecord* findTimer(AppTimerHandle handle);
bool handlePendingSwitchRequest();
AppContext context;
std::vector<std::unique_ptr<IAppFactory>> factories;
std::unique_ptr<IApp> current;
IAppFactory* activeFactory = nullptr;
std::size_t activeIndex = static_cast<std::size_t>(-1);
std::vector<TimerRecord> timers;
AppTimerHandle nextTimerId = 1;
std::uint32_t currentGeneration = 0;
InputState lastInputState{};
};
inline AppTimerHandle AppContext::scheduleTimerInternal(std::uint32_t delay_ms, bool repeat) {
return system ? system->scheduleTimer(delay_ms, repeat) : kInvalidAppTimer;
}
inline void AppContext::cancelTimerInternal(AppTimerHandle handle) {
if (system)
system->cancelTimer(handle);
}
inline void AppContext::cancelAllTimersInternal() {
if (system)
system->cancelAllTimers();
}
} // namespace cardboy::sdk

View File

@@ -0,0 +1,20 @@
#pragma once
#include <cstddef>
namespace cardboy::sdk {
class FramebufferHooks {
public:
using PreSendHook = void (*)(void* framebuffer, void* userData);
static void setPreSendHook(PreSendHook hook, void* userData);
static void clearPreSendHook();
static void invokePreSend(void* framebuffer);
private:
static PreSendHook hook_;
static void* userData_;
};
} // namespace cardboy::sdk

View File

@@ -0,0 +1,15 @@
#pragma once
#include "cardboy/sdk/services.hpp"
namespace cardboy::sdk {
struct PersistentSettings {
bool mute = false;
bool autoLightSleep = false;
};
PersistentSettings loadPersistentSettings(Services* services);
void savePersistentSettings(Services* services, const PersistentSettings& settings);
} // namespace cardboy::sdk

View File

@@ -0,0 +1,92 @@
#pragma once
#include "cardboy/sdk/input_state.hpp"
#include "cardboy/sdk/services.hpp"
#include "cardboy/gfx/font16x8.hpp"
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <string>
#include <string_view>
namespace cardboy::sdk {
class StatusBar {
public:
static StatusBar& instance();
void setServices(Services* services) { services_ = services; }
void setEnabled(bool value);
void toggle();
[[nodiscard]] bool isEnabled() const { return enabled_; }
void setCurrentAppName(std::string_view name);
[[nodiscard]] bool handleToggleInput(const InputState& current, const InputState& previous);
template<typename Framebuffer>
void renderIfEnabled(Framebuffer& fb) {
if (!enabled_)
return;
renderBar(fb);
}
private:
StatusBar() = default;
template<typename Framebuffer>
void renderBar(Framebuffer& fb) {
const int width = fb.width();
if (width <= 0)
return;
const int barHeight = font16x8::kGlyphHeight + 2;
const int fillHeight = std::min(barHeight, fb.height());
if (fillHeight <= 0)
return;
const std::string leftText = prepareLeftText(width);
const std::string rightText = prepareRightText();
for (int y = 0; y < fillHeight; ++y) {
for (int x = 0; x < width; ++x)
fb.drawPixel(x, y, true);
}
for (int x = 0; x < width; ++x)
fb.drawPixel(x, 0, false);
const int textY = 1;
const int bottomSeparatorY = textY + font16x8::kGlyphHeight + 1;
if (bottomSeparatorY < fillHeight) {
for (int x = 0; x < width; ++x)
fb.drawPixel(x, bottomSeparatorY, (x % 2) != 0);
}
const int leftX = 2;
if (!leftText.empty())
font16x8::drawText(fb, leftX, textY, leftText, 1, false, 1);
if (!rightText.empty()) {
int rightWidth = font16x8::measureText(rightText, 1, 1);
int rightX = width - rightWidth - 2;
const int minRightX = leftX + font16x8::measureText(leftText, 1, 1) + 6;
if (rightX < minRightX)
rightX = std::max(minRightX, width / 2);
if (rightX < width)
font16x8::drawText(fb, rightX, textY, rightText, 1, false, 1);
}
}
[[nodiscard]] std::string prepareLeftText(int displayWidth) const;
[[nodiscard]] std::string prepareRightText() const;
bool enabled_ = false;
Services* services_ = nullptr;
std::string appName_{};
};
} // namespace cardboy::sdk

View File

@@ -0,0 +1,254 @@
#include "cardboy/sdk/app_system.hpp"
#include "cardboy/sdk/framebuffer_hooks.hpp"
#include "cardboy/sdk/status_bar.hpp"
#include <algorithm>
#include <limits>
#include <utility>
namespace cardboy::sdk {
namespace {
[[nodiscard]] bool inputsDiffer(const InputState& a, const InputState& b) {
return a.up != b.up || a.down != b.down || a.left != b.left || a.right != b.right || a.a != b.a || a.b != b.b ||
a.select != b.select || a.start != b.start;
}
constexpr std::uint32_t kIdlePollMs = 16;
template<typename Framebuffer>
void statusBarPreSendHook(void* framebuffer, void* userData) {
auto* fb = static_cast<Framebuffer*>(framebuffer);
auto* status = static_cast<StatusBar*>(userData);
if (fb && status)
status->renderIfEnabled(*fb);
}
} // namespace
AppSystem::AppSystem(AppContext ctx) : context(std::move(ctx)) {
context.system = this;
auto& statusBar = StatusBar::instance();
statusBar.setServices(context.services);
using FBType = typename AppContext::Framebuffer;
FramebufferHooks::setPreSendHook(&statusBarPreSendHook<FBType>, &statusBar);
}
AppSystem::~AppSystem() { FramebufferHooks::clearPreSendHook(); }
void AppSystem::registerApp(std::unique_ptr<IAppFactory> factory) {
if (!factory)
return;
factories.emplace_back(std::move(factory));
}
bool AppSystem::startApp(const std::string& name) {
for (std::size_t i = 0; i < factories.size(); ++i) {
if (factories[i]->name() == name)
return startAppByIndex(i);
}
return false;
}
bool AppSystem::startAppByIndex(std::size_t index) {
if (index >= factories.size())
return false;
context.system = this;
auto& factory = factories[index];
auto app = factory->create(context);
if (!app)
return false;
if (current) {
current->onStop();
current.reset();
}
activeFactory = factory.get();
activeIndex = index;
context.pendingSwitch = false;
context.pendingSwitchByName = false;
context.pendingAppName.clear();
clearTimersForCurrentApp();
current = std::move(app);
lastInputState = context.input.readState();
StatusBar::instance().setServices(context.services);
StatusBar::instance().setCurrentAppName(activeFactory ? activeFactory->name() : "");
current->onStart();
return true;
}
void AppSystem::run() {
if (!current) {
if (factories.empty() || !startAppByIndex(0))
return;
}
std::vector<AppEvent> events;
events.reserve(4);
while (true) {
events.clear();
const std::uint32_t now = context.clock.millis();
processDueTimers(now, events);
const InputState inputNow = context.input.readState();
const bool consumedByStatusToggle = StatusBar::instance().handleToggleInput(inputNow, lastInputState);
if (!consumedByStatusToggle && inputsDiffer(inputNow, lastInputState)) {
AppEvent evt{};
evt.type = AppEventType::Button;
evt.timestamp_ms = now;
evt.button.current = inputNow;
evt.button.previous = lastInputState;
events.push_back(evt);
lastInputState = inputNow;
} else if (consumedByStatusToggle) {
lastInputState = inputNow;
}
for (const auto& evt: events) {
dispatchEvent(evt);
if (handlePendingSwitchRequest())
break;
}
const std::uint32_t waitBase = context.clock.millis();
std::uint32_t waitMs = nextTimerDueMs(waitBase);
if (waitMs == 0)
continue;
if (waitMs == std::numeric_limits<std::uint32_t>::max())
waitMs = kIdlePollMs;
else
waitMs = std::min(waitMs, kIdlePollMs);
if (waitMs > 0)
context.clock.sleep_ms(waitMs);
}
}
const IAppFactory* AppSystem::factoryAt(std::size_t index) const {
if (index >= factories.size())
return nullptr;
return factories[index].get();
}
std::size_t AppSystem::indexOfFactory(const IAppFactory* factory) const {
if (!factory)
return static_cast<std::size_t>(-1);
for (std::size_t i = 0; i < factories.size(); ++i) {
if (factories[i].get() == factory)
return i;
}
return static_cast<std::size_t>(-1);
}
AppTimerHandle AppSystem::scheduleTimer(std::uint32_t delay_ms, bool repeat) {
if (!current)
return kInvalidAppTimer;
TimerRecord record;
record.id = nextTimerId++;
if (record.id == kInvalidAppTimer)
record.id = nextTimerId++;
record.generation = currentGeneration;
const auto now = context.clock.millis();
record.due_ms = now + delay_ms;
record.interval_ms = repeat ? std::max<std::uint32_t>(1, delay_ms) : 0;
record.repeat = repeat;
record.active = true;
timers.push_back(record);
return record.id;
}
void AppSystem::cancelTimer(AppTimerHandle handle) {
auto* timer = findTimer(handle);
if (timer)
timer->active = false;
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& rec) { return !rec.active; }),
timers.end());
}
void AppSystem::cancelAllTimers() {
for (auto& timer: timers) {
if (timer.generation == currentGeneration)
timer.active = false;
}
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& rec) { return !rec.active; }),
timers.end());
}
void AppSystem::dispatchEvent(const AppEvent& event) {
if (current)
current->handleEvent(event);
}
void AppSystem::processDueTimers(std::uint32_t now, std::vector<AppEvent>& outEvents) {
for (auto& timer: timers) {
if (!timer.active || timer.generation != currentGeneration)
continue;
if (static_cast<std::int32_t>(now - timer.due_ms) >= 0) {
AppEvent ev{};
ev.type = AppEventType::Timer;
ev.timestamp_ms = now;
ev.timer.handle = timer.id;
outEvents.push_back(ev);
if (timer.repeat) {
const std::uint32_t interval = timer.interval_ms ? timer.interval_ms : 1;
timer.due_ms = now + interval;
} else {
timer.active = false;
}
}
}
timers.erase(std::remove_if(timers.begin(), timers.end(), [](const TimerRecord& rec) { return !rec.active; }),
timers.end());
}
std::uint32_t AppSystem::nextTimerDueMs(std::uint32_t now) const {
std::uint32_t minWait = std::numeric_limits<std::uint32_t>::max();
for (const auto& timer: timers) {
if (!timer.active || timer.generation != currentGeneration)
continue;
if (static_cast<std::int32_t>(now - timer.due_ms) >= 0)
return 0;
const std::uint32_t delta = timer.due_ms - now;
if (delta < minWait)
minWait = delta;
}
return minWait;
}
void AppSystem::clearTimersForCurrentApp() {
++currentGeneration;
timers.clear();
}
AppSystem::TimerRecord* AppSystem::findTimer(AppTimerHandle handle) {
for (auto& timer: timers) {
if (!timer.active || timer.generation != currentGeneration)
continue;
if (timer.id == handle)
return &timer;
}
return nullptr;
}
bool AppSystem::handlePendingSwitchRequest() {
if (!context.pendingSwitch)
return false;
const bool byName = context.pendingSwitchByName;
const std::size_t reqIndex = context.pendingAppIndex;
const std::string reqName = context.pendingAppName;
context.pendingSwitch = false;
context.pendingSwitchByName = false;
context.pendingAppName.clear();
bool switched = false;
if (byName)
switched = startApp(reqName);
else
switched = startAppByIndex(reqIndex);
return switched;
}
} // namespace cardboy::sdk

View File

@@ -0,0 +1,23 @@
#include "cardboy/sdk/framebuffer_hooks.hpp"
namespace cardboy::sdk {
FramebufferHooks::PreSendHook FramebufferHooks::hook_ = nullptr;
void* FramebufferHooks::userData_ = nullptr;
void FramebufferHooks::setPreSendHook(PreSendHook hook, void* userData) {
hook_ = hook;
userData_ = userData;
}
void FramebufferHooks::clearPreSendHook() {
hook_ = nullptr;
userData_ = nullptr;
}
void FramebufferHooks::invokePreSend(void* framebuffer) {
if (hook_)
hook_(framebuffer, userData_);
}
} // namespace cardboy::sdk

View File

@@ -0,0 +1,40 @@
#include "cardboy/sdk/persistent_settings.hpp"
#include <cstdint>
#include <string_view>
namespace cardboy::sdk {
namespace {
constexpr std::string_view kNamespace = "settings";
constexpr std::string_view kMuteKey = "mute";
constexpr std::string_view kAutoLightSleepKey = "autosleep";
[[nodiscard]] std::uint32_t boolToStorage(bool value) { return value ? 1U : 0U; }
[[nodiscard]] bool storageToBool(std::uint32_t value) { return value != 0U; }
} // namespace
PersistentSettings loadPersistentSettings(Services* services) {
PersistentSettings settings{};
if (!services || !services->storage)
return settings;
std::uint32_t raw = 0;
if (services->storage->readUint32(kNamespace, kMuteKey, raw))
settings.mute = storageToBool(raw);
if (services->storage->readUint32(kNamespace, kAutoLightSleepKey, raw))
settings.autoLightSleep = storageToBool(raw);
return settings;
}
void savePersistentSettings(Services* services, const PersistentSettings& settings) {
if (!services || !services->storage)
return;
services->storage->writeUint32(kNamespace, kMuteKey, boolToStorage(settings.mute));
services->storage->writeUint32(kNamespace, kAutoLightSleepKey, boolToStorage(settings.autoLightSleep));
}
} // namespace cardboy::sdk

View File

@@ -0,0 +1,81 @@
#include "cardboy/sdk/status_bar.hpp"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
namespace cardboy::sdk {
StatusBar& StatusBar::instance() {
static StatusBar bar;
return bar;
}
void StatusBar::setEnabled(bool value) { enabled_ = value; }
void StatusBar::toggle() {
enabled_ = !enabled_;
if (services_ && services_->buzzer)
services_->buzzer->beepMove();
}
void StatusBar::setCurrentAppName(std::string_view name) {
appName_.assign(name.begin(), name.end());
std::transform(appName_.begin(), appName_.end(), appName_.begin(),
[](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
}
bool StatusBar::handleToggleInput(const InputState& current, const InputState& previous) {
const bool comboNow = current.start && current.select && current.up;
const bool comboPrev = previous.start && previous.select && previous.up;
if (comboNow && !comboPrev) {
toggle();
return true;
}
return false;
}
std::string StatusBar::prepareLeftText(int displayWidth) const {
std::string text = appName_.empty() ? std::string("CARDBOY") : appName_;
int maxWidth = std::max(0, displayWidth - 32);
while (!text.empty() && font16x8::measureText(text, 1, 1) > maxWidth)
text.pop_back();
return text;
}
std::string StatusBar::prepareRightText() const {
if (!services_)
return {};
std::string right;
if (services_->battery && services_->battery->hasData()) {
const float current = services_->battery->current();
const float chargeMah = services_->battery->charge();
const float fallbackV = services_->battery->voltage();
char buf[64];
if (std::isfinite(current) && std::isfinite(chargeMah)) {
std::snprintf(buf, sizeof(buf), "cur %.2fmA chr %.2fmAh", static_cast<double>(current),
static_cast<double>(chargeMah));
} else {
std::snprintf(buf, sizeof(buf), "vol %.2fV", static_cast<double>(fallbackV));
}
right.assign(buf);
}
if (services_->powerManager && services_->powerManager->isSlowMode()) {
if (!right.empty())
right.append(" ");
right.append("SLOW");
}
if (services_->buzzer && services_->buzzer->isMuted()) {
if (!right.empty())
right.append(" ");
right.append("MUTE");
}
return right;
}
} // namespace cardboy::sdk

View File

@@ -0,0 +1,16 @@
add_executable(cardboy_desktop
src/main.cpp
)
set_target_properties(cardboy_desktop PROPERTIES
EXPORT_NAME desktop_launcher
)
target_link_libraries(cardboy_desktop
PRIVATE
cardboy_apps
cardboy_sdk
${CARDBOY_SDK_BACKEND_LIBRARY}
)
target_compile_features(cardboy_desktop PRIVATE cxx_std_20)

View File

@@ -0,0 +1,41 @@
#include "cardboy/apps/clock_app.hpp"
#include "cardboy/apps/gameboy_app.hpp"
#include "cardboy/apps/menu_app.hpp"
#include "cardboy/apps/settings_app.hpp"
#include "cardboy/apps/tetris_app.hpp"
#include "cardboy/backend/desktop_backend.hpp"
#include "cardboy/sdk/app_system.hpp"
#include "cardboy/sdk/persistent_settings.hpp"
#include <cstdio>
#include <exception>
using cardboy::backend::desktop::DesktopRuntime;
int main() {
try {
DesktopRuntime runtime;
cardboy::sdk::AppContext context(runtime.framebuffer, runtime.input, runtime.clock);
context.services = &runtime.serviceRegistry();
cardboy::sdk::AppSystem system(context);
const cardboy::sdk::PersistentSettings persistentSettings =
cardboy::sdk::loadPersistentSettings(context.getServices());
if (auto* buzzer = context.buzzer())
buzzer->setMuted(persistentSettings.mute);
system.registerApp(apps::createMenuAppFactory());
system.registerApp(apps::createSettingsAppFactory());
system.registerApp(apps::createClockAppFactory());
system.registerApp(apps::createGameboyAppFactory());
system.registerApp(apps::createTetrisAppFactory());
system.run();
} catch (const std::exception& ex) {
std::fprintf(stderr, "Cardboy desktop runtime failed: %s\n", ex.what());
return 1;
}
return 0;
}

View File

@@ -0,0 +1,21 @@
add_library(cardboy_utils INTERFACE)
option(CARDBOY_MORE_CHECKS "More checks" OFF)
set_target_properties(cardboy_utils PROPERTIES
EXPORT_NAME utils
)
target_include_directories(cardboy_utils
INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}/include
)
if(CARDBOY_MORE_CHECKS)
target_compile_definitions(cardboy_utils INTERFACE CARDBOY_MORE_CHECKS=1)
endif()
target_sources(cardboy_utils
INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}/include/cardboy/utils/utils.hpp
)

View File

@@ -0,0 +1,59 @@
//
// Created by Stepan Usatiuk on 11.10.2025.
//
#ifndef CARDBOY_SDK_UTILS_HPP
#define CARDBOY_SDK_UTILS_HPP
#ifndef CARDBOY_MORE_CHECKS
#define CARDBOY_MORE_CHECKS 0
#endif
#if CARDBOY_MORE_CHECKS
#include <cstdio>
#include <cstdlib>
// Fails the program with a message. Internal use.
#define CARDBOY__CHECK_FAIL_IMPL(expr_str, file, line, func, msg_opt) \
do { \
std::fprintf(stderr, \
"CARDBOY_CHECK failed: %s\n at %s:%d in %s%s%s\n", \
(expr_str), (file), (line), (func), \
((msg_opt) ? "\n message: " : ""), \
((msg_opt) ? (msg_opt) : "")); \
std::fflush(stderr); \
std::abort(); \
} while (0)
// Runtime check that is active only when CARDBOY_MORE_CHECKS != 0.
// Evaluates the expression exactly once.
#define CARDBOY_CHECK(expr) \
do { \
if (!(expr)) { \
CARDBOY__CHECK_FAIL_IMPL(#expr, __FILE__, __LINE__, __func__, NULL); \
} \
} while (0)
// Same as CARDBOY_CHECK but allows providing a custom C-string message.
#define CARDBOY_CHECK_MSG(expr, msg) \
do { \
if (!(expr)) { \
CARDBOY__CHECK_FAIL_IMPL(#expr, __FILE__, __LINE__, __func__, (msg));\
} \
} while (0)
// Execute arbitrary code only when checks are enabled.
#define CARDBOY_CHECK_CODE(code) \
do { \
code; \
} while (0)
#else
// Checks compiled out when CARDBOY_MORE_CHECKS == 0.
#define CARDBOY_CHECK(expr) do { (void)sizeof(expr); } while (0)
#define CARDBOY_CHECK_MSG(expr, _) do { (void)sizeof(expr); } while (0)
#define CARDBOY_CHECK_CODE(code) do { } while (0)
#endif
#endif // CARDBOY_SDK_UTILS_HPP

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff