Initial commit

main
noah metz 2026-06-01 14:35:42 -06:00
parent 2f9ec49e72
commit 11e5953ccd
26 changed files with 5010 additions and 0 deletions

@ -0,0 +1,3 @@
CompileFlags:
CompilationDatabase: build
Remove: [-mlongcalls, -fstrict-volatile-bitfields, -fno-tree-switch-conversion]

2
.gitignore vendored

@ -0,0 +1,2 @@
case/venv/
case/output/*.stl

@ -0,0 +1,7 @@
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(commeownder)
if(DEBUG)
add_compile_definitions(DEBUG)
endif()

@ -0,0 +1,62 @@
SHELL := /bin/bash
.ONESHELL:
ESP_ENV := . ~/scripts/esp_env.sh
SIM_BUILD := simulator/build
PORT ?= /dev/ttyUSB0
# All firmware targets build with DEBUG=1 so serial commands and DBG lines
# are always available for testing and monitoring.
IDF := $(ESP_ENV) && idf.py -DDEBUG=1
.PHONY: all firmware sim test flash monitor flash-monitor clean firmware-clean sim-clean case case_live case_clean
all: firmware sim
firmware:
$(IDF) build
flash:
$(IDF) flash
monitor:
$(IDF) monitor
flash-monitor:
$(IDF) flash monitor
sim:
cmake --build $(SIM_BUILD) --parallel
test: sim flash
sleep 5
$(SIM_BUILD)/test_ble --port $(PORT)
clean: firmware-clean sim-clean case_clean
firmware-clean:
$(IDF) fullclean
sim-clean:
cmake --build $(SIM_BUILD) --target clean
# --- case (build123d) ---
CASE_VENV := case/venv
CASE_PYTHON := $(CASE_VENV)/bin/python
CASE_PIP := $(CASE_VENV)/bin/pip
CASE_FW := $(CASE_VENV)/bin/fw123d
$(CASE_VENV)/.installed: case/requirements.txt
python3.13 -m venv $(CASE_VENV)
$(CASE_PIP) install --upgrade pip
$(CASE_PIP) install -r case/requirements.txt
touch $@
case: $(CASE_VENV)/.installed
$(CASE_PYTHON) case/case.py
case_live: $(CASE_VENV)/.installed
$(CASE_FW) case/case.py
case_clean:
rm -rf $(CASE_VENV) case/output/*.stl

@ -0,0 +1,61 @@
from build123d import *
# --- Facade panel ---
PANEL = 1.6 # plate thickness (Cherry MX standard ~1.5 mm)
# --- Component footprints ---
SW_SIZE = 14.0 # Cherry MX plate cutout (square)
SW_PITCH = 19.0 # switch centre-to-centre
N_SW = 3
OLED_W = 25.0 # 0.96" SSD1306 visible window width
OLED_H = 13.0 # visible window height
# --- OLED alignment pegs (solid 2 mm, 24 mm square pattern) ---
POST_D = 2.0 # peg diameter
POST_SPACING = 24.0 # hole centre-to-centre
POST_H = 12.0 # peg length behind panel
# --- Layout ---
# MARGIN_Y=7 and OLED_SW_GAP=9 push outer_h to 50 mm, giving the top pegs
# 1.5 mm clearance from the panel edge and the bottom pegs 2.5 mm clearance
# above the switch cutout tops.
MARGIN_X = 3.0
MARGIN_Y = 7.0
OLED_SW_GAP = 9.0
outer_w = (N_SW - 1) * SW_PITCH + SW_SIZE + 2 * MARGIN_X # 58 mm
outer_h = 2 * MARGIN_Y + OLED_H + OLED_SW_GAP + SW_SIZE # 50 mm
oled_cy = outer_h / 2 - MARGIN_Y - OLED_H / 2 # 11.5 mm
sw_cy = -outer_h / 2 + MARGIN_Y + SW_SIZE / 2 # -11.0 mm
with BuildPart() as case:
Box(outer_w, outer_h, PANEL)
with BuildSketch(Plane.XY.offset(PANEL / 2)):
with Locations((0, oled_cy)):
Rectangle(OLED_W, OLED_H)
with Locations((-SW_PITCH, sw_cy), (0, sw_cy), (SW_PITCH, sw_cy)):
RectangleRounded(SW_SIZE, SW_SIZE, 0.3)
extrude(amount=-PANEL, mode=Mode.SUBTRACT)
# Solid 2 mm alignment pegs extending from the back face
half_s = POST_SPACING / 2
post_z = -PANEL / 2 - POST_H / 2
post_xy = [
(-half_s, oled_cy - half_s),
( half_s, oled_cy - half_s),
(-half_s, oled_cy + half_s),
( half_s, oled_cy + half_s),
]
with Locations([(x, y, post_z) for x, y in post_xy]):
Cylinder(POST_D / 2, POST_H)
export_stl(case.part, "case/output/case.stl")
try:
from ocp_vscode import show
show(case)
except Exception:
pass

@ -0,0 +1,4 @@
build123d>=0.10.0
cadquery-ocp>=7.8,<7.9
ocp-vscode
git+https://github.com/jdegenstein/filewatcher123d

@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Display SSD1306 vertical-byte glyph/icon arrays as console text.
Handles two array shapes found in C headers:
Single-page glyphs uint8_t name[][W]
Each row is one glyph: W columns × 8 rows.
Displayed side-by-side in a grid.
Two-page icons uint8_t name[...][2][W] or name[2][W]
Each pair of rows is one icon: W columns × 16 rows.
Outer 1-pixel border trimmed 14×14 display.
Usage:
python3 icons.py [FILE] show all glyphs and icons (default: main/font.h)
"""
import re
import sys
# ── Glyph display (single page, arbitrary W×8) ───────────────────────────────
def decode_glyph(data):
"""data: list of W ints → 8×W bitmap."""
w = len(data)
return [[(data[col] >> row) & 1 for col in range(w)] for row in range(8)]
def show_glyphs(entries, per_row=10):
"""entries: list of (label, data) — print glyphs side by side."""
if not entries:
return
glyph_w = len(entries[0][1])
col_w = max(glyph_w, max(len(label) for label, _ in entries))
sep = " "
for i in range(0, len(entries), per_row):
chunk = entries[i : i + per_row]
grids = [decode_glyph(data) for _, data in chunk]
labels = [label for label, _ in chunk]
print(sep.join(f"{lbl:^{col_w}}" for lbl in labels))
for row in range(8):
parts = ["".join("x" if grids[g][row][c] else " " for c in range(glyph_w))
for g in range(len(chunk))]
print(sep.join(f"{p:^{col_w}}" for p in parts))
print()
# ── Icon display (two pages, W×16 → trimmed 14×14) ───────────────────────────
def decode_icon(data):
"""data: [[W ints page0], [W ints page1]] → 14×14 list-of-lists."""
w = len(data[0])
rows = [[(data[page][col] >> bit) & 1
for col in range(w)]
for page in range(2)
for bit in range(8)]
return [r[1:-1] for r in rows[1:-1]]
def show_icon(grid, name=""):
if name:
print(f"── {name} ──")
for row in grid:
print("".join("x" if p else " " for p in row))
print()
# ── C file parser ─────────────────────────────────────────────────────────────
def _hex_row(raw):
return [int(x, 16) for x in re.findall(r"0x[0-9A-Fa-f]+", raw)]
def parse_file(path):
"""
Parse all uint8_t byte arrays from a C header.
Returns:
glyphs: dict of array_name list of (label, [W bytes])
icons: dict of name [[W page0 bytes], [W page1 bytes]]
"""
text = open(path).read()
glyphs = {}
icons = {}
# ── Two-page icon arrays ──────────────────────────────────────────────────
# Matches: uint8_t name[...][2][W] = { ... };
# Also: uint8_t name[2][W] = { ... };
for m in re.finditer(
r"uint8_t\s+(\w+)\s*(?:\[[^\]]*\])*\[2\]\[([^\]]+)\]\s*=\s*\{(.*?)\}\s*;",
text, re.DOTALL,
):
array_name = m.group(1)
body = m.group(3)
# Pull each { {top}, {bot} } pair, with an optional // comment before it
pairs = re.findall(
r"(?://\s*(.+?)\n)?\s*\{\s*\{([^}]+)\}\s*,\s*\{([^}]+)\}\s*\}",
body,
)
if pairs:
for i, (comment, raw0, raw1) in enumerate(pairs):
label = comment.strip() if comment.strip() else f"{array_name}[{i}]"
icons[label] = [_hex_row(raw0), _hex_row(raw1)]
else:
# Standalone name[2][W] — whole array is one icon
rows = re.findall(r"\{([^}]+)\}", body)
if len(rows) == 2:
icons[array_name] = [_hex_row(rows[0]), _hex_row(rows[1])]
# ── Single-page glyph arrays ──────────────────────────────────────────────
# Matches: uint8_t name[][W] = { {bytes}, // label \n ... };
for m in re.finditer(
r"uint8_t\s+(\w+)\s*\[\]\s*\[([^\]]+)\]\s*=\s*\{(.*?)\}\s*;",
text, re.DOTALL,
):
array_name = m.group(1)
body = m.group(3)
entries = []
for em in re.finditer(r"\{([^}]+)\}\s*(?:,\s*)?(?://\s*(.+))?", body):
data = _hex_row(em.group(1))
comment = (em.group(2) or "").strip().strip("'\"")
label = comment if comment else str(len(entries))
entries.append((label, data))
if entries:
glyphs[array_name] = entries
return glyphs, icons
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
path = next((a for a in sys.argv[1:] if not a.startswith("--")), "main/font.h")
glyphs, icons = parse_file(path)
if not glyphs and not icons:
sys.exit(f"no glyph or icon arrays found in {path}")
for array_name, entries in glyphs.items():
print(f"{'' * 60}")
print(f" {array_name} ({len(entries)} glyphs)")
print(f"{'' * 60}\n")
show_glyphs(entries)
for name, data in icons.items():
show_icon(decode_icon(data), name)

@ -0,0 +1,7 @@
idf_component_register(SRCS "main.c" "draw.c" "ble.c" "game.c"
INCLUDE_DIRS "."
PRIV_REQUIRES driver nvs_flash bt vfs)
if(DEBUG)
target_compile_definitions(${COMPONENT_LIB} PUBLIC DEBUG)
endif()

@ -0,0 +1,216 @@
#include "ble.h"
#include "state.h"
#include "nimble/nimble_port.h"
#include "nimble/nimble_port_freertos.h"
#include "services/gap/ble_svc_gap.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <string.h>
#include <stdio.h>
volatile int g_ble_scanning;
volatile int g_ble_initialized;
uint8_t g_own_addr_type;
uint8_t g_own_addr[6];
uint8_t g_player_id = 0xFF;
volatile int g_reset_requested;
int g_reset_cmd_ticks;
static int ble_gap_event_handler(struct ble_gap_event *event, void *arg);
static void ble_adv_start_internal(void)
{
ble_payload_t payload;
payload.magic[0] = BLE_MFR_MAGIC_0;
payload.magic[1] = BLE_MFR_MAGIC_1;
payload.game_id[0] = g_game_id[0];
payload.game_id[1] = g_game_id[1];
memcpy(payload.name, g_player_name, PLAYER_NAME_LEN);
payload.life = (int16_t)g_life;
payload.poison = (uint8_t)g_counters[0];
payload.eliminated = (uint8_t)g_eliminated;
payload.player_id = g_player_id;
payload.reset_cmd = (g_reset_cmd_ticks > 0) ? 1 : 0;
for (int i = 0; i < MAX_OPPONENTS; i++)
payload.cmdr_dmg[i] = (uint8_t)g_cmdr_damage[i];
static uint8_t mfr[2 + sizeof(ble_payload_t)];
mfr[0] = 0xFF; mfr[1] = 0xFF;
memcpy(mfr + 2, &payload, sizeof(payload));
struct ble_hs_adv_fields fields = {0};
fields.mfg_data = mfr;
fields.mfg_data_len = sizeof(mfr);
ble_gap_adv_set_fields(&fields);
struct ble_gap_adv_params params = {0};
params.conn_mode = BLE_GAP_CONN_MODE_NON;
params.disc_mode = BLE_GAP_DISC_MODE_NON;
params.itvl_min = 0x0320; // 500ms
params.itvl_max = 0x0320;
ble_gap_adv_start(g_own_addr_type, NULL, BLE_HS_FOREVER, &params, ble_gap_event_handler, NULL);
#ifdef DEBUG
printf("DBG ADV_TX name=%-8.8s life=%d poison=%u game_id=%02X%02X eliminated=%u pid=%u reset=%u\n",
payload.name, (int)payload.life, (unsigned)payload.poison,
payload.game_id[0], payload.game_id[1], (unsigned)payload.eliminated,
(unsigned)payload.player_id, (unsigned)payload.reset_cmd);
fflush(stdout);
#endif
}
void ble_adv_update(void)
{
if (!g_ble_initialized || g_ble_scanning) return;
ble_gap_adv_stop();
if (g_ble_enabled) ble_adv_start_internal();
}
static void ble_update_peer(const ble_addr_t *addr, const ble_payload_t *p)
{
if (p->game_id[0] != g_game_id[0] || p->game_id[1] != g_game_id[1]) return;
int slot = -1;
for (int i = 0; i < MAX_BLE_PEERS; i++) {
if (g_peers[i].active && memcmp(&g_peers[i].addr, addr, sizeof(ble_addr_t)) == 0) {
slot = i; break;
}
}
if (slot < 0) {
for (int i = 0; i < MAX_BLE_PEERS; i++) {
if (!g_peers[i].active) { slot = i; break; }
}
if (slot < 0) {
slot = 0;
for (int i = 1; i < MAX_BLE_PEERS; i++)
if (g_peers[i].last_seen < g_peers[slot].last_seen) slot = i;
}
}
uint8_t prev_reset_cmd = g_peers[slot].reset_cmd;
g_peers[slot].addr = *addr;
g_peers[slot].life = p->life;
g_peers[slot].poison = p->poison;
g_peers[slot].eliminated = p->eliminated;
g_peers[slot].player_id = p->player_id;
g_peers[slot].reset_cmd = p->reset_cmd;
memcpy(g_peers[slot].cmdr_dmg, p->cmdr_dmg, MAX_OPPONENTS);
g_peers[slot].last_seen = g_tick;
g_peers[slot].active = 1;
memcpy(g_peers[slot].name, p->name, PLAYER_NAME_LEN);
g_peers[slot].name[PLAYER_NAME_LEN] = '\0';
if (p->reset_cmd && !prev_reset_cmd) g_reset_requested = 1;
#ifdef DEBUG
printf("DBG PEER_RX slot=%d addr=%02X:%02X:%02X:%02X:%02X:%02X name=%-8.8s life=%d poison=%u game_id=%02X%02X eliminated=%u pid=%u reset=%u cmdr=[%u,%u,%u,%u]\n",
slot,
addr->val[5], addr->val[4], addr->val[3],
addr->val[2], addr->val[1], addr->val[0],
p->name, (int)p->life, (unsigned)p->poison,
p->game_id[0], p->game_id[1], (unsigned)p->eliminated,
(unsigned)p->player_id, (unsigned)p->reset_cmd,
(unsigned)p->cmdr_dmg[0], (unsigned)p->cmdr_dmg[1],
(unsigned)p->cmdr_dmg[2], (unsigned)p->cmdr_dmg[3]);
fflush(stdout);
#endif
}
static int ble_gap_event_handler(struct ble_gap_event *event, void *arg)
{
if (event->type == BLE_GAP_EVENT_DISC) {
const uint8_t *adv_data = event->disc.data;
int len = event->disc.length_data;
int i = 0;
while (i < len) {
uint8_t adlen = adv_data[i];
if (adlen == 0 || i + adlen >= len) break;
uint8_t adtype = adv_data[i+1];
if (adtype == 0xFF && adlen >= 1 + 2 + (int)sizeof(ble_payload_t)) {
const uint8_t *payload_ptr = adv_data + i + 4;
if (payload_ptr[0] == BLE_MFR_MAGIC_0 && payload_ptr[1] == BLE_MFR_MAGIC_1) {
ble_update_peer(&event->disc.addr, (const ble_payload_t *)payload_ptr);
}
}
i += 1 + adlen;
}
} else if (event->type == BLE_GAP_EVENT_DISC_COMPLETE) {
g_ble_scanning = 0;
if (g_ble_enabled) ble_adv_start_internal();
}
return 0;
}
static void ble_on_sync(void)
{
ble_hs_id_infer_auto(0, &g_own_addr_type);
ble_hs_id_copy_addr(g_own_addr_type, g_own_addr, NULL);
if (g_player_id == 0xFF) g_player_id = 0;
g_ble_initialized = 1;
if (g_ble_enabled) ble_adv_start_internal();
}
static void ble_on_reset(int reason) { (void)reason; }
static void ble_host_task(void *arg)
{
nimble_port_run();
nimble_port_freertos_deinit();
}
static void ble_manager_task(void *arg)
{
while (!g_ble_initialized) vTaskDelay(pdMS_TO_TICKS(100));
while (1) {
vTaskDelay(pdMS_TO_TICKS(10000));
if (!g_ble_enabled || g_ble_scanning) continue;
g_ble_scanning = 1;
ble_gap_adv_stop();
struct ble_gap_disc_params disc_params = {0};
disc_params.itvl = 0x0100; disc_params.window = 0x0080; disc_params.passive = 1;
int rc = ble_gap_disc(g_own_addr_type, 5000, &disc_params, ble_gap_event_handler, NULL);
if (rc != 0) {
g_ble_scanning = 0;
if (g_ble_enabled) ble_adv_start_internal();
}
vTaskDelay(pdMS_TO_TICKS(6000));
if (g_ble_scanning) {
ble_gap_disc_cancel();
g_ble_scanning = 0;
if (g_ble_enabled) ble_adv_start_internal();
}
}
}
void player_id_resolve(void)
{
if (!g_ble_initialized || !g_ble_enabled) return;
for (int i = 0; i < MAX_BLE_PEERS; i++) {
if (!g_peers[i].active || g_peers[i].player_id != g_player_id) continue;
int we_lose = 0;
for (int b = 5; b >= 0; b--) {
if (g_peers[i].addr.val[b] < g_own_addr[b]) { we_lose = 1; break; }
if (g_peers[i].addr.val[b] > g_own_addr[b]) break;
}
if (!we_lose) continue;
uint8_t used[BLE_MAX_PLAYERS] = {0};
for (int j = 0; j < MAX_BLE_PEERS; j++)
if (g_peers[j].active && g_peers[j].player_id < BLE_MAX_PLAYERS)
used[g_peers[j].player_id] = 1;
for (int id = 0; id < BLE_MAX_PLAYERS; id++) {
if (!used[id]) { g_player_id = (uint8_t)id; break; }
}
ble_adv_update();
break;
}
}
void ble_init(void)
{
nimble_port_init();
ble_hs_cfg.sync_cb = ble_on_sync;
ble_hs_cfg.reset_cb = ble_on_reset;
ble_svc_gap_init();
nimble_port_freertos_init(ble_host_task);
xTaskCreate(ble_manager_task, "ble_mgr", 4096, NULL, 5, NULL);
}

@ -0,0 +1,41 @@
#pragma once
#include <stdint.h>
#include "config.h"
#include "host/ble_hs.h"
typedef struct __attribute__((packed)) {
uint8_t magic[2];
uint8_t game_id[2];
char name[PLAYER_NAME_LEN];
int16_t life;
uint8_t poison;
uint8_t eliminated;
uint8_t player_id;
uint8_t reset_cmd;
uint8_t cmdr_dmg[MAX_OPPONENTS];
} ble_payload_t;
typedef struct {
ble_addr_t addr;
char name[PLAYER_NAME_LEN + 1];
int16_t life;
uint8_t poison;
uint8_t eliminated;
uint8_t player_id;
uint8_t reset_cmd;
uint8_t cmdr_dmg[MAX_OPPONENTS];
uint32_t last_seen;
int active;
} ble_peer_t;
extern volatile int g_ble_scanning;
extern volatile int g_ble_initialized;
extern uint8_t g_own_addr_type;
extern uint8_t g_own_addr[6];
extern uint8_t g_player_id;
extern volatile int g_reset_requested;
extern int g_reset_cmd_ticks;
void ble_init(void);
void ble_adv_update(void);
void player_id_resolve(void);

@ -0,0 +1,89 @@
#pragma once
// ── Buttons ───────────────────────────────────────────────────────────────────
#define BTN_RIGHT_GPIO 23
#define BTN_LEFT_GPIO 4
#define BTN_MID_GPIO 5
// ── RGB LED ───────────────────────────────────────────────────────────────────
#define LED_R_GPIO 13
#define LED_G_GPIO 14
#define LED_B_GPIO 27
// ── I2C / OLED ────────────────────────────────────────────────────────────────
#define I2C_PORT I2C_NUM_0
#define I2C_SDA_GPIO 21
#define I2C_SCL_GPIO 22
#define I2C_FREQ_HZ 400000
#define OLED_ADDR 0x3C
#define OLED_WIDTH 128
#define OLED_PAGES 8
#define HEADER_PAGES 2
// ── Button timing ─────────────────────────────────────────────────────────────
#define HOLD_DELAY 50
#define HOLD_REPEAT 10
#define MID_HOLD_MENU 15
#define MID_HOLD_SETTINGS 100
// ── BLE ───────────────────────────────────────────────────────────────────────
#define BLE_MFR_MAGIC_0 0xC0
#define BLE_MFR_MAGIC_1 0xDE
#define BLE_GAME_ID_0 0x42
#define BLE_GAME_ID_1 0x42
#define BLE_MAX_PLAYERS 5
#define MAX_BLE_PEERS 4
#define BLE_PEER_TIMEOUT 3000
// ── Menus ─────────────────────────────────────────────────────────────────────
#define NUM_MENUS 4
#define MENU_LIFE 0
#define MENU_CMDR 1
#define MENU_COUNTERS 2
#define MENU_SETTINGS 3
extern const int menu_slot[NUM_MENUS];
// ── Settings ──────────────────────────────────────────────────────────────────
#define NUM_SETTINGS 8
#define SET_BRIGHTNESS 0
#define SET_START_LIFE 1
#define SET_NUM_OPP 2
#define SET_BLE 3
#define SET_RESET 4
#define SET_RESET_ALL 5
#define SET_PLAYER_NAME 7
#define SET_GAME_ID 6
#define BRIGHTNESS_MIN 1
#define BRIGHTNESS_MAX 100
#define PLAYER_NAME_LEN 8
#define GAME_ID_DIGITS 4
extern const char *setting_names[NUM_SETTINGS];
extern const int start_life_opts[];
#define NUM_LIFE_OPTS 3
extern const char NAME_CHARS[];
#define NUM_NAME_CHARS 37
#define NUM_HEX_CHARS 16
// ── Counters ──────────────────────────────────────────────────────────────────
#define NUM_COUNTERS 4
extern const char *counter_names[NUM_COUNTERS];
// ── Opponents ─────────────────────────────────────────────────────────────────
#define MAX_OPPONENTS 4
// ── Font / display ────────────────────────────────────────────────────────────
#define SCALE 5
#define CHAR_WIDTH (5 * SCALE + SCALE)
#define CHAR_PAGES 5
#define START_PAGE (HEADER_PAGES + (OLED_PAGES - HEADER_PAGES - CHAR_PAGES + 1) / 2)
#define SEG_W 16
#define ICON_W 16
#define FONT_BASE 0x20
#define FONT_MAX 0x5A
// ── NVS ───────────────────────────────────────────────────────────────────────
#define NVS_NS "settings"
#define SAVE_DELAY 300

@ -0,0 +1,294 @@
#include "draw.h"
#include "config.h"
#include "game.h"
#include "font.h"
#include "state.h"
#include "driver/ledc.h"
#include "driver/i2c.h"
#include "freertos/FreeRTOS.h"
#include <string.h>
#include <stdio.h>
const int menu_slot[NUM_MENUS] = {0, 1, 2, 7};
const char *setting_names[NUM_SETTINGS] = {
"LED BRIGHTNESS", "STARTING LIFE", "NUM OPPONENTS", "BLE",
"RESET", "RESET ALL", "GAME ID", "PLAYER NAME",
};
#define LEDC_TIMER_SEL LEDC_TIMER_0
#define LEDC_MODE_ LEDC_LOW_SPEED_MODE
#define LEDC_DUTY_RES_ LEDC_TIMER_8_BIT
#define LEDC_FREQ_HZ_ 5000
// ── LED ───────────────────────────────────────────────────────────────────────
void led_init(void)
{
ledc_timer_config_t timer = {
.speed_mode = LEDC_MODE_,
.timer_num = LEDC_TIMER_SEL,
.duty_resolution = LEDC_DUTY_RES_,
.freq_hz = LEDC_FREQ_HZ_,
.clk_cfg = LEDC_AUTO_CLK,
};
ledc_timer_config(&timer);
ledc_channel_config_t ch[] = {
{.gpio_num=LED_R_GPIO,.speed_mode=LEDC_MODE_,.channel=LEDC_CHANNEL_0,.timer_sel=LEDC_TIMER_SEL,.duty=0,.hpoint=0},
{.gpio_num=LED_G_GPIO,.speed_mode=LEDC_MODE_,.channel=LEDC_CHANNEL_1,.timer_sel=LEDC_TIMER_SEL,.duty=0,.hpoint=0},
{.gpio_num=LED_B_GPIO,.speed_mode=LEDC_MODE_,.channel=LEDC_CHANNEL_2,.timer_sel=LEDC_TIMER_SEL,.duty=0,.hpoint=0},
};
for (int i = 0; i < 3; i++) ledc_channel_config(&ch[i]);
}
void led_update_for_count(int count, uint8_t scale)
{
int c = count < 0 ? 0 : count > 80 ? 80 : count;
uint8_t r, g, b;
if (c <= 40) {
int cc = c < 10 ? 10 : c;
r = (uint8_t)((uint32_t)(40-cc)*scale*g_led_max/30/255);
g = (uint8_t)((uint32_t)(cc-10)*scale*g_led_max/30/255);
b = 0;
} else {
r = 0;
g = (uint8_t)((uint32_t)(80-c)*g_led_max/40);
b = (uint8_t)((uint32_t)(c-40)*g_led_max/40);
}
ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_0, r); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_0);
ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_1, g); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_1);
ledc_set_duty(LEDC_MODE_, LEDC_CHANNEL_2, b); ledc_update_duty(LEDC_MODE_, LEDC_CHANNEL_2);
}
// ── OLED ──────────────────────────────────────────────────────────────────────
static void oled_write(const uint8_t *buf, size_t len)
{
i2c_master_write_to_device(I2C_PORT, OLED_ADDR, buf, len, pdMS_TO_TICKS(100));
}
static void oled_cmd(uint8_t c) { uint8_t b[2]={0x00,c}; oled_write(b,2); }
static void oled_write_page(int p, const uint8_t *row);
void oled_init(void)
{
static const uint8_t seq[] = {
0xAE,0xD5,0x80,0xA8,0x3F,0xD3,0x00,0x40,0x8D,0x14,0x20,0x00,
0xA1,0xC8,0xDA,0x12,0x81,0xCF,0xD9,0xF1,0xDB,0x40,0xA4,0xA6,0xAF,
};
for (int i = 0; i < (int)sizeof(seq); i++) oled_cmd(seq[i]);
}
void oled_clear(void)
{
uint8_t row[OLED_WIDTH]; memset(row, 0, sizeof(row));
for (int p = 0; p < OLED_PAGES; p++) oled_write_page(p, row);
}
static void oled_write_page(int p, const uint8_t *row)
{
oled_cmd(0x21); oled_cmd(0); oled_cmd(127);
oled_cmd(0x22); oled_cmd(p); oled_cmd(p);
uint8_t buf[OLED_WIDTH+1]; buf[0]=0x40;
memcpy(buf+1, row, OLED_WIDTH); oled_write(buf, sizeof(buf));
}
void oled_draw_header(void)
{
uint8_t pages[HEADER_PAGES][OLED_WIDTH];
memset(pages, 0, sizeof(pages));
for (int seg = 0; seg < NUM_MENUS; seg++) {
uint8_t bg = (seg == g_active_menu) ? 0x00 : 0xFF;
int sx = menu_slot[seg] * SEG_W;
const uint8_t (*icon)[ICON_W] = (seg == MENU_CMDR && g_ble_enabled) ? icon_net : icons[seg];
for (int col = 0; col < ICON_W; col++) {
pages[0][sx+col] = bg ^ icon[0][col];
pages[1][sx+col] = bg ^ icon[1][col];
}
}
for (int p = 0; p < HEADER_PAGES; p++) oled_write_page(p, pages[p]);
}
void oled_draw_centered(const char *str)
{
int len = strlen(str);
int start = (OLED_WIDTH - len * CHAR_WIDTH) / 2;
if (start < 0) start = 0;
for (int p = HEADER_PAGES; p < OLED_PAGES; p++) {
uint8_t row[OLED_WIDTH]; memset(row, 0, sizeof(row));
if (p >= START_PAGE && p < START_PAGE + CHAR_PAGES) {
int char_page = p - START_PAGE;
for (int i = 0; i < len; i++) {
char c = str[i];
if (c < FONT_BASE || c > FONT_MAX) c = ' ';
const uint8_t *glyph = font5x8[(uint8_t)c - FONT_BASE];
int col = start + i * CHAR_WIDTH;
for (int sc = 0; sc < 5*SCALE; sc++) {
int x = col + sc;
if (x < 0 || x >= OLED_WIDTH) continue;
uint8_t font_col = glyph[sc/SCALE];
uint8_t pixel_byte = 0;
for (int b = 0; b < 8; b++) {
int src_row = (char_page*8+b)/SCALE;
if (src_row < 8 && ((font_col >> src_row) & 1)) pixel_byte |= (1<<b);
}
row[x] = pixel_byte;
}
}
}
oled_write_page(p, row);
}
}
// ── Pixel framebuffer helpers ─────────────────────────────────────────────────
static uint8_t row_mask(int p, int base, int h)
{
uint8_t mask = 0;
for (int r = 0; r < 8; r++)
if (8*p+r >= base && 8*p+r < base+h) mask |= (1<<r);
return mask;
}
static void pxbuf_fill(uint8_t pages[][OLED_WIDTH], int content_h, int base, int entry_h)
{
for (int p = base/8; p <= (base+entry_h-1)/8 && p < content_h/8; p++) {
uint8_t mask = row_mask(p, base, entry_h);
for (int x = 0; x < OLED_WIDTH; x++) pages[p][x] |= mask;
}
}
static int pxbuf_str(uint8_t pages[][OLED_WIDTH], int content_h,
int base, int x, const char *str, int inv)
{
for (; *str && x+5 <= OLED_WIDTH; str++) {
uint8_t c = (uint8_t)*str;
if (c < FONT_BASE || c > FONT_MAX) c = ' ';
const uint8_t *glyph = font5x8[c - FONT_BASE];
for (int sc = 0; sc < 5; sc++, x++) {
for (int g = 0; g < 8; g++) {
if ((glyph[sc] >> g) & 1) {
int row = base + 1 + g;
if (row < content_h) {
if (inv) pages[row/8][x] &= ~(1 << (row%8));
else pages[row/8][x] |= (1 << (row%8));
}
}
}
}
x++;
}
return x;
}
// ── Core list renderer ────────────────────────────────────────────────────────
void oled_draw_rows(const oled_row_t *rows, int count, int active)
{
const int content_h = (OLED_PAGES - HEADER_PAGES) * 8;
const int entry_h = 9;
const int max_full = content_h / entry_h;
int start = active - max_full / 2;
if (start < 0) start = 0;
if (start + max_full > count) start = count - max_full;
if (start < 0) start = 0;
uint8_t pages[OLED_PAGES - HEADER_PAGES][OLED_WIDTH];
memset(pages, 0, sizeof(pages));
for (int ei = 0; ei < max_full+1 && (start+ei) < count; ei++) {
int entry = start + ei;
int inv = (entry == active);
int base = ei * entry_h;
if (inv) pxbuf_fill(pages, content_h, base, entry_h);
if (rows[entry].label && rows[entry].label[0])
pxbuf_str(pages, content_h, base, 2, rows[entry].label, inv);
if (rows[entry].value[0]) {
int vlen = (int)strlen(rows[entry].value);
int rx = OLED_WIDTH - 2 - vlen*6;
if (rx < 0) rx = 0;
pxbuf_str(pages, content_h, base, rx, rows[entry].value, inv);
if (inv && rows[entry].cursor >= 0 && rows[entry].cursor < vlen) {
int cursor_x = rx + rows[entry].cursor * 6;
for (int p = base/8; p <= (base+entry_h-1)/8 && p < content_h/8; p++) {
uint8_t mask = row_mask(p, base, entry_h);
for (int j = 0; j < 6 && cursor_x+j < OLED_WIDTH; j++)
pages[p][cursor_x+j] &= ~mask;
}
char one[2] = { rows[entry].value[rows[entry].cursor], 0 };
pxbuf_str(pages, content_h, base, cursor_x, one, 0);
}
}
}
for (int p = 0; p < OLED_PAGES - HEADER_PAGES; p++)
oled_write_page(HEADER_PAGES + p, pages[p]);
}
// ── Menu draw functions (thin wrappers that build rows and call oled_draw_rows) ──
void oled_draw_list(const char **labels, const int *values, int count, int active)
{
oled_row_t rows[8];
int n = count < 8 ? count : 8;
for (int i = 0; i < n; i++) {
rows[i].label = labels[i];
rows[i].cursor = -1;
snprintf(rows[i].value, sizeof(rows[i].value), "%d", values[i]);
}
oled_draw_rows(rows, n, active);
}
void oled_draw_players(void)
{
int total = MAX_BLE_PEERS + 1 + (!g_ble_enabled ? 1 : 0);
oled_row_t rows[MAX_BLE_PEERS + 2];
rows[0].label = g_player_name;
rows[0].cursor = -1;
snprintf(rows[0].value, sizeof(rows[0].value),
g_eliminated ? "ELIM" : "%d/%d", g_life, g_counters[0]);
for (int pi = 0; pi < MAX_BLE_PEERS; pi++) {
rows[pi+1].cursor = -1;
if (g_peers[pi].active) {
rows[pi+1].label = g_peers[pi].name;
snprintf(rows[pi+1].value, sizeof(rows[pi+1].value),
g_peers[pi].eliminated ? "ELIM" : "%d/%d/%d",
g_peers[pi].life, g_peers[pi].poison, g_cmdr_damage[pi]);
} else {
rows[pi+1].label = "";
rows[pi+1].value[0] = 0;
}
}
if (!g_ble_enabled) {
rows[MAX_BLE_PEERS+1].label = "BLE OFF";
rows[MAX_BLE_PEERS+1].value[0] = 0;
rows[MAX_BLE_PEERS+1].cursor = -1;
}
oled_draw_rows(rows, total, g_active_player);
}
void oled_draw_settings(void)
{
oled_row_t rows[NUM_SETTINGS];
for (int i = 0; i < NUM_SETTINGS; i++) {
rows[i].label = setting_names[i];
rows[i].cursor = -1;
}
snprintf(rows[SET_BRIGHTNESS].value, sizeof(rows[0].value), "%d%%", g_brightness_pct);
snprintf(rows[SET_START_LIFE].value, sizeof(rows[0].value), "%d", start_life_opts[g_start_life_index]);
snprintf(rows[SET_NUM_OPP].value, sizeof(rows[0].value), "%d", g_num_opponents);
memcpy(rows[SET_PLAYER_NAME].value, g_player_name, PLAYER_NAME_LEN);
rows[SET_PLAYER_NAME].value[PLAYER_NAME_LEN] = 0;
rows[SET_PLAYER_NAME].cursor = g_name_cursor;
snprintf(rows[SET_BLE].value, sizeof(rows[0].value), "%s", g_ble_enabled ? "ON" : "OFF");
snprintf(rows[SET_GAME_ID].value, sizeof(rows[0].value), "%02X%02X", g_game_id[0], g_game_id[1]);
rows[SET_GAME_ID].cursor = g_game_id_cursor;
snprintf(rows[SET_RESET].value, sizeof(rows[0].value), "---");
snprintf(rows[SET_RESET_ALL].value, sizeof(rows[0].value), "---");
oled_draw_rows(rows, NUM_SETTINGS, g_active_setting);
}

@ -0,0 +1,19 @@
#pragma once
#include <stdint.h>
typedef struct {
const char *label; // left-aligned; NULL or empty = blank row
char value[16]; // right-aligned formatted string; empty = omit
int cursor; // index into value for edit cursor indicator; -1 = none
} oled_row_t;
void led_init(void);
void led_update_for_count(int count, uint8_t scale);
void oled_init(void);
void oled_clear(void);
void oled_draw_header(void);
void oled_draw_centered(const char *str);
void oled_draw_rows(const oled_row_t *rows, int count, int active);
void oled_draw_list(const char **labels, const int *values, int count, int active);
void oled_draw_players(void);
void oled_draw_settings(void);

@ -0,0 +1,86 @@
#pragma once
#include <stdint.h>
#include "config.h"
static const uint8_t font5x8[][5] = {
{0x00,0x00,0x00,0x00,0x00}, // ' '
{0x00,0x00,0x5F,0x00,0x00}, // '!'
{0x00,0x07,0x00,0x07,0x00}, // '"'
{0x14,0x7F,0x14,0x7F,0x14}, // '#'
{0x24,0x2A,0x7F,0x2A,0x12}, // '$'
{0x23,0x13,0x08,0x64,0x62}, // '%'
{0x36,0x49,0x55,0x22,0x50}, // '&'
{0x00,0x05,0x03,0x00,0x00}, // '\''
{0x00,0x1C,0x22,0x41,0x00}, // '('
{0x00,0x41,0x22,0x1C,0x00}, // ')'
{0x14,0x08,0x3E,0x08,0x14}, // '*'
{0x08,0x08,0x3E,0x08,0x08}, // '+'
{0x00,0x50,0x30,0x00,0x00}, // ','
{0x08,0x08,0x08,0x08,0x08}, // '-'
{0x00,0x60,0x60,0x00,0x00}, // '.'
{0x20,0x10,0x08,0x04,0x02}, // '/'
{0x3E,0x51,0x49,0x45,0x3E}, // '0'
{0x00,0x42,0x7F,0x40,0x00}, // '1'
{0x72,0x49,0x49,0x49,0x46}, // '2'
{0x21,0x41,0x49,0x4D,0x33}, // '3'
{0x18,0x14,0x12,0x7F,0x10}, // '4'
{0x27,0x45,0x45,0x45,0x39}, // '5'
{0x3C,0x4A,0x49,0x49,0x31}, // '6'
{0x41,0x21,0x11,0x09,0x07}, // '7'
{0x36,0x49,0x49,0x49,0x36}, // '8'
{0x46,0x49,0x49,0x29,0x1E}, // '9'
{0x00,0x36,0x36,0x00,0x00}, // ':'
{0x00,0x56,0x36,0x00,0x00}, // ';'
{0x08,0x14,0x22,0x41,0x00}, // '<'
{0x14,0x14,0x14,0x14,0x14}, // '='
{0x00,0x41,0x22,0x14,0x08}, // '>'
{0x02,0x01,0x51,0x09,0x06}, // '?'
{0x32,0x49,0x79,0x41,0x3E}, // '@'
{0x7E,0x11,0x11,0x11,0x7E}, // 'A'
{0x7F,0x49,0x49,0x49,0x36}, // 'B'
{0x3E,0x41,0x41,0x41,0x22}, // 'C'
{0x7F,0x41,0x41,0x22,0x1C}, // 'D'
{0x7F,0x49,0x49,0x49,0x41}, // 'E'
{0x7F,0x09,0x09,0x09,0x01}, // 'F'
{0x3E,0x41,0x49,0x49,0x7A}, // 'G'
{0x7F,0x08,0x08,0x08,0x7F}, // 'H'
{0x00,0x41,0x7F,0x41,0x00}, // 'I'
{0x20,0x40,0x41,0x3F,0x01}, // 'J'
{0x7F,0x08,0x14,0x22,0x41}, // 'K'
{0x7F,0x40,0x40,0x40,0x40}, // 'L'
{0x7F,0x02,0x04,0x02,0x7F}, // 'M'
{0x7F,0x04,0x08,0x10,0x7F}, // 'N'
{0x3E,0x41,0x41,0x41,0x3E}, // 'O'
{0x7F,0x09,0x09,0x09,0x06}, // 'P'
{0x3E,0x41,0x51,0x21,0x5E}, // 'Q'
{0x7F,0x09,0x19,0x29,0x46}, // 'R'
{0x46,0x49,0x49,0x49,0x31}, // 'S'
{0x01,0x01,0x7F,0x01,0x01}, // 'T'
{0x3F,0x40,0x40,0x40,0x3F}, // 'U'
{0x1F,0x20,0x40,0x20,0x1F}, // 'V'
{0x3F,0x40,0x38,0x40,0x3F}, // 'W'
{0x63,0x14,0x08,0x14,0x63}, // 'X'
{0x07,0x08,0x70,0x08,0x07}, // 'Y'
{0x61,0x51,0x49,0x45,0x43}, // 'Z'
};
static const uint8_t icons[NUM_MENUS][2][ICON_W] = {
// Heart (life)
{ {0x00,0x70,0xF8,0xFC,0xFC,0xFC,0xF8,0xF0,0xF0,0xF8,0xFC,0xFC,0xFC,0xF8,0x70,0x00},
{0x00,0x00,0x00,0x01,0x03,0x07,0x0F,0x3F,0x3F,0x0F,0x07,0x03,0x01,0x00,0x00,0x00} },
// Shield (commander damage)
{ {0x00,0xFC,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFE,0xFC,0x00,0x00},
{0x00,0x07,0x0F,0x1F,0x3F,0x3F,0x3F,0x7F,0x7F,0x3F,0x3F,0x1F,0x0F,0x07,0x00,0x00} },
// Plus (counters)
{ {0x00,0x00,0x00,0xC0,0xC0,0xC0,0xF8,0xF8,0xF8,0xF8,0xC0,0xC0,0xC0,0x00,0x00,0x00},
{0x00,0x00,0x00,0x03,0x03,0x03,0x1F,0x1F,0x1F,0x1F,0x03,0x03,0x03,0x00,0x00,0x00} },
// Cog (settings)
{ {0x00,0x80,0xDC,0xFC,0xFC,0xB8,0x9C,0xFE,0xFE,0x9C,0xB8,0xFC,0xFC,0xDC,0x80,0x00},
{0x00,0x01,0x3B,0x3F,0x3F,0x1D,0x39,0x7F,0x7F,0x39,0x1D,0x3F,0x3F,0x3B,0x01,0x00} },
};
// Signal bars shown on MENU_CMDR slot when BLE is enabled
static const uint8_t icon_net[2][ICON_W] = {
{0x00,0x00,0x00,0x00,0x00,0x00,0x80,0x80,0x00,0xF8,0xF8,0x00,0xFE,0xFE,0x00,0x00},
{0x00,0x00,0x00,0x38,0x38,0x00,0x3F,0x3F,0x00,0x3F,0x3F,0x00,0x3F,0x3F,0x00,0x00},
};

@ -0,0 +1,97 @@
#include "game.h"
#include "config.h"
#include "state.h"
#include "ble.h"
#include "nvs.h"
#include "esp_err.h"
#include <string.h>
#include <stdio.h>
const int start_life_opts[] = {20, 30, 40};
const char NAME_CHARS[] = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
const char *counter_names[NUM_COUNTERS] = {"POISON", "EXPERIENCE", "ENERGY", "LORE"};
void settings_reset_defaults(void)
{
g_brightness_pct = 10;
g_start_life_index = 2;
g_num_opponents = 3;
strncpy(g_player_name, "PLAYER 1", PLAYER_NAME_LEN);
g_player_name[PLAYER_NAME_LEN] = '\0';
g_ble_enabled = 0;
g_led_max = 26;
g_game_id[0] = BLE_GAME_ID_0;
g_game_id[1] = BLE_GAME_ID_1;
}
void game_reset(void)
{
g_life = start_life_opts[g_start_life_index];
g_eliminated = 0;
memset(g_cmdr_damage, 0, sizeof(g_cmdr_damage));
memset(g_counters, 0, sizeof(g_counters));
}
void check_elimination(void)
{
if (g_eliminated) return;
if (g_life <= 0 || g_counters[0] >= 10) { g_eliminated = 1; return; }
for (int i = 0; i < g_num_opponents; i++)
if (g_cmdr_damage[i] >= 21) { g_eliminated = 1; return; }
}
void settings_load(void)
{
nvs_handle_t nvs;
if (nvs_open(NVS_NS, NVS_READONLY, &nvs) != ESP_OK) return;
int32_t val;
if (nvs_get_i32(nvs, "brightness", &val) == ESP_OK) g_brightness_pct = (int)val;
if (nvs_get_i32(nvs, "start_life", &val) == ESP_OK) g_start_life_index = (int)val;
if (nvs_get_i32(nvs, "num_opp", &val) == ESP_OK) g_num_opponents = (int)val;
if (nvs_get_i32(nvs, "ble_en", &val) == ESP_OK) g_ble_enabled = (int)val;
if (nvs_get_i32(nvs, "game_id", &val) == ESP_OK) {
g_game_id[0] = (uint8_t)((val >> 8) & 0xFF);
g_game_id[1] = (uint8_t)(val & 0xFF);
}
size_t len = sizeof(g_player_name);
nvs_get_str(nvs, "pname", g_player_name, &len);
if (nvs_get_i32(nvs, "player_id", &val) == ESP_OK) g_player_id = (uint8_t)val;
if (nvs_get_i32(nvs, "life", &val) == ESP_OK) g_life = (int)val;
if (nvs_get_i32(nvs, "elim", &val) == ESP_OK) g_eliminated = (int)val;
char key[8];
for (int i = 0; i < MAX_OPPONENTS; i++) {
snprintf(key, sizeof(key), "cmdr%d", i);
if (nvs_get_i32(nvs, key, &val) == ESP_OK) g_cmdr_damage[i] = (int)val;
}
for (int i = 0; i < NUM_COUNTERS; i++) {
snprintf(key, sizeof(key), "cnt%d", i);
if (nvs_get_i32(nvs, key, &val) == ESP_OK) g_counters[i] = (int)val;
}
nvs_close(nvs);
}
void settings_save(void)
{
nvs_handle_t nvs;
if (nvs_open(NVS_NS, NVS_READWRITE, &nvs) != ESP_OK) return;
nvs_set_i32(nvs, "brightness", (int32_t)g_brightness_pct);
nvs_set_i32(nvs, "start_life", (int32_t)g_start_life_index);
nvs_set_i32(nvs, "num_opp", (int32_t)g_num_opponents);
nvs_set_i32(nvs, "ble_en", (int32_t)g_ble_enabled);
nvs_set_i32(nvs, "game_id", (int32_t)((g_game_id[0] << 8) | g_game_id[1]));
nvs_set_str(nvs, "pname", g_player_name);
nvs_set_i32(nvs, "player_id", (int32_t)g_player_id);
nvs_set_i32(nvs, "life", (int32_t)g_life);
nvs_set_i32(nvs, "elim", (int32_t)g_eliminated);
char key[8];
for (int i = 0; i < MAX_OPPONENTS; i++) {
snprintf(key, sizeof(key), "cmdr%d", i);
nvs_set_i32(nvs, key, (int32_t)g_cmdr_damage[i]);
}
for (int i = 0; i < NUM_COUNTERS; i++) {
snprintf(key, sizeof(key), "cnt%d", i);
nvs_set_i32(nvs, key, (int32_t)g_counters[i]);
}
nvs_commit(nvs);
nvs_close(nvs);
}

@ -0,0 +1,12 @@
#pragma once
#include "config.h"
extern const int start_life_opts[];
extern const char NAME_CHARS[];
extern const char *counter_names[NUM_COUNTERS];
void settings_reset_defaults(void);
void game_reset(void);
void check_elimination(void);
void settings_load(void);
void settings_save(void);

@ -0,0 +1,464 @@
#include "config.h"
#include "state.h"
#include "game.h"
#include "draw.h"
#include "ble.h"
#include "driver/gpio.h"
#include "driver/i2c.h"
#include "driver/uart.h"
#include "esp_vfs_dev.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "esp_err.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <stdio.h>
#include <string.h>
#include <math.h>
// ── Global state ──────────────────────────────────────────────────────────────
// Game
int g_life;
int g_cmdr_damage[MAX_OPPONENTS];
int g_counters[NUM_COUNTERS];
int g_eliminated;
// UI
int g_active_menu;
int g_active_opponent;
int g_active_counter;
int g_active_player;
int g_active_setting;
int g_name_cursor;
// Settings
int g_brightness_pct = 10;
int g_start_life_index = 2; // 40
int g_num_opponents = 3;
char g_player_name[PLAYER_NAME_LEN + 1] = "PLAYER 1";
int g_ble_enabled = 0;
uint8_t g_led_max = 26;
int g_game_id_cursor = 0;
uint8_t g_game_id[2] = {BLE_GAME_ID_0, BLE_GAME_ID_1};
// Peers
ble_peer_t g_peers[MAX_BLE_PEERS];
// Timing
uint32_t g_tick;
// ── Serial command task ───────────────────────────────────────────────────────
static void serial_print_state(void)
{
printf("DBG STATE life=%d poison=%u cmdr=[%d,%d,%d,%d] counters=[%d,%d,%d] menu=%d ble=%d pid=%u eliminated=%d\n",
g_life, (unsigned)g_counters[0],
g_cmdr_damage[0], g_cmdr_damage[1], g_cmdr_damage[2], g_cmdr_damage[3],
g_counters[0], g_counters[1], g_counters[2],
g_active_menu, g_ble_enabled, (unsigned)g_player_id, g_eliminated);
fflush(stdout);
}
static void serial_cmd_task(void *arg)
{
(void)arg;
char buf[64];
int pos = 0;
for (;;) {
int c = fgetc(stdin);
if (c < 0) { vTaskDelay(pdMS_TO_TICKS(10)); continue; }
if (c == '\n' || c == '\r') {
if (pos > 0) {
buf[pos] = '\0';
pos = 0;
int val = 0;
if (strncmp(buf, "SET ", 4) == 0) {
const char *kv = buf + 4;
if (sscanf(kv, "life=%d", &val) == 1) {
g_life = val; check_elimination();
}
if (sscanf(kv, "ble=%d", &val) == 1) {
g_ble_enabled = val ? 1 : 0;
}
{
char fmt[14];
for (int i = 0; i < MAX_OPPONENTS; i++) {
snprintf(fmt, sizeof(fmt), "cmdr%d=%%d", i);
if (sscanf(kv, fmt, &val) == 1) {
g_cmdr_damage[i] = val < 0 ? 0 : val; check_elimination(); break;
}
}
for (int i = 0; i < NUM_COUNTERS; i++) {
snprintf(fmt, sizeof(fmt), "counter%d=%%d", i);
if (sscanf(kv, fmt, &val) == 1) {
g_counters[i] = val < 0 ? 0 : val; check_elimination(); break;
}
}
}
ble_adv_update();
serial_print_state();
} else if (strcmp(buf, "RESET") == 0) {
game_reset();
ble_adv_update();
serial_print_state();
} else if (strcmp(buf, "CLEARNVS") == 0) {
nvs_handle_t nvs;
if (nvs_open(NVS_NS, NVS_READWRITE, &nvs) == ESP_OK) {
nvs_erase_all(nvs);
nvs_commit(nvs);
nvs_close(nvs);
}
settings_reset_defaults();
game_reset();
ble_adv_update();
serial_print_state();
} else if (strcmp(buf, "STATE") == 0) {
serial_print_state();
}
}
} else if (pos < (int)sizeof(buf) - 1) {
buf[pos++] = (char)c;
}
}
}
// ── app_main ──────────────────────────────────────────────────────────────────
void app_main(void)
{
led_init();
i2c_config_t i2c_cfg = {
.mode=I2C_MODE_MASTER, .sda_io_num=I2C_SDA_GPIO, .scl_io_num=I2C_SCL_GPIO,
.sda_pullup_en=GPIO_PULLUP_ENABLE, .scl_pullup_en=GPIO_PULLUP_ENABLE,
.master.clk_speed=I2C_FREQ_HZ,
};
ESP_ERROR_CHECK(i2c_param_config(I2C_PORT, &i2c_cfg));
ESP_ERROR_CHECK(i2c_driver_install(I2C_PORT, I2C_MODE_MASTER, 0, 0, 0));
esp_err_t nvs_err = nvs_flash_init();
if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
nvs_flash_erase(); nvs_flash_init();
}
g_life = start_life_opts[g_start_life_index];
settings_load();
g_led_max = (uint8_t)(255 * g_brightness_pct / 100);
oled_init();
oled_clear();
gpio_config_t gpio_cfg = {
.pin_bit_mask = (1ULL<<BTN_RIGHT_GPIO)|(1ULL<<BTN_LEFT_GPIO)|(1ULL<<BTN_MID_GPIO),
.mode=GPIO_MODE_INPUT, .pull_up_en=GPIO_PULLUP_ENABLE,
.pull_down_en=GPIO_PULLDOWN_DISABLE, .intr_type=GPIO_INTR_DISABLE,
};
gpio_config(&gpio_cfg);
ble_init();
uart_driver_install(UART_NUM_0, 256, 0, 0, NULL, 0);
esp_vfs_dev_uart_use_driver(0);
setvbuf(stdin, NULL, _IONBF, 0);
xTaskCreate(serial_cmd_task, "serial_cmd", 4096, NULL, 3, NULL);
oled_draw_header();
{ char ibuf[12]; snprintf(ibuf, sizeof(ibuf), "%d", g_life); oled_draw_centered(ibuf); }
led_update_for_count(g_life, 255);
#ifdef DEBUG
serial_print_state();
#endif
int prev_right=1, prev_left=1, prev_mid=1;
int hold_right=0, hold_left=0, hold_mid=0, mid_fired_hold=0;
// Require 2 consecutive released ticks before arming hold; prevents accidental
// hold fires when rapid taps have sub-10ms releases that the sampler misses.
int release_r=2, release_l=2;
int hold_armed_r=1, hold_armed_l=1;
int players_tick=0;
int settings_dirty=0, settings_save_tick=0;
#define MARK_DIRTY() do { settings_dirty = 1; settings_save_tick = 0; } while (0)
float breath_phase=0.0f;
char nbuf[12];
char opponent_labels[MAX_OPPONENTS][PLAYER_NAME_LEN+1];
const char *opponent_label_ptrs[MAX_OPPONENTS];
while (1) {
g_tick++;
int right = gpio_get_level(BTN_RIGHT_GPIO);
int left = gpio_get_level(BTN_LEFT_GPIO);
int mid = gpio_get_level(BTN_MID_GPIO);
int changed = 0;
// ── Middle button ────────────────────────────────────────────────────
if (mid == 0) {
hold_mid++;
if (!mid_fired_hold && hold_mid == MID_HOLD_SETTINGS) {
if (g_active_menu == MENU_SETTINGS) {
g_active_menu = 0;
} else {
g_active_menu = MENU_SETTINGS;
g_active_setting = 0;
g_name_cursor = 0;
g_game_id_cursor = 0;
}
mid_fired_hold = 1;
hold_right = hold_left = players_tick = 0;
oled_draw_header();
changed = 1;
}
} else {
if (prev_mid == 0 && !mid_fired_hold) {
if (hold_mid >= MID_HOLD_MENU) {
if (g_active_menu == MENU_SETTINGS) {
g_active_menu = 0;
} else {
g_active_menu = (g_active_menu + 1) % (NUM_MENUS - 1);
}
hold_right = hold_left = players_tick = 0;
oled_draw_header();
changed = 1;
} else if (g_active_menu == MENU_SETTINGS) {
if (g_active_setting == SET_PLAYER_NAME) {
g_name_cursor++;
if (g_name_cursor >= PLAYER_NAME_LEN) {
g_name_cursor = 0;
g_active_setting = (g_active_setting + 1) % NUM_SETTINGS;
g_game_id_cursor = 0;
}
} else if (g_active_setting == SET_GAME_ID) {
g_game_id_cursor++;
if (g_game_id_cursor >= GAME_ID_DIGITS) {
g_game_id_cursor = 0;
g_active_setting = (g_active_setting + 1) % NUM_SETTINGS;
}
} else {
g_active_setting = (g_active_setting + 1) % NUM_SETTINGS;
g_name_cursor = 0;
g_game_id_cursor = 0;
}
changed = 1;
} else if (g_active_menu == MENU_CMDR) {
if (g_ble_enabled) {
int next = (g_active_player + 1) % (MAX_BLE_PEERS + 1);
while (next != g_active_player && next != 0 && !g_peers[next - 1].active)
next = (next + 1) % (MAX_BLE_PEERS + 1);
g_active_player = next;
} else {
g_active_opponent = (g_active_opponent + 1) % g_num_opponents;
}
changed = 1;
} else if (g_active_menu == MENU_COUNTERS) {
g_active_counter = (g_active_counter + 1) % NUM_COUNTERS;
changed = 1;
}
}
hold_mid = 0; mid_fired_hold = 0;
}
// ── Left / Right ─────────────────────────────────────────────────────
int delta_r=0, delta_l=0;
if (right==0) {
if (prev_right==1) {
delta_r=1; hold_right=0;
hold_armed_r = (release_r >= 2);
release_r = 0;
} else if (hold_armed_r && ++hold_right>=HOLD_DELAY && (hold_right-HOLD_DELAY)%HOLD_REPEAT==0) {
delta_r=1;
}
} else { hold_right=0; if (release_r < 2) release_r++; }
if (left==0) {
if (prev_left==1) {
delta_l=1; hold_left=0;
hold_armed_l = (release_l >= 2);
release_l = 0;
} else if (hold_armed_l && ++hold_left>=HOLD_DELAY && (hold_left-HOLD_DELAY)%HOLD_REPEAT==0) {
delta_l=1;
}
} else { hold_left=0; if (release_l < 2) release_l++; }
if (delta_r || delta_l) {
int d = delta_r - delta_l;
if (g_active_menu == MENU_LIFE) {
g_life += d; changed = 1; MARK_DIRTY(); check_elimination();
} else if (g_active_menu == MENU_CMDR) {
if (g_ble_enabled) {
if (g_active_player == 0) {
g_life += d; check_elimination();
} else {
int slot = g_active_player - 1;
g_cmdr_damage[slot] += d;
if (g_cmdr_damage[slot] < 0) g_cmdr_damage[slot] = 0;
check_elimination();
}
} else {
g_cmdr_damage[g_active_opponent] += d;
if (g_cmdr_damage[g_active_opponent] < 0) g_cmdr_damage[g_active_opponent] = 0;
check_elimination();
}
changed = 1; MARK_DIRTY();
} else if (g_active_menu == MENU_COUNTERS) {
g_counters[g_active_counter] += d;
if (g_counters[g_active_counter] < 0) g_counters[g_active_counter] = 0;
changed = 1; MARK_DIRTY(); check_elimination();
} else if (g_active_menu == MENU_SETTINGS) {
changed = 1; MARK_DIRTY();
switch (g_active_setting) {
case SET_BRIGHTNESS:
g_brightness_pct += d;
if (g_brightness_pct < BRIGHTNESS_MIN) g_brightness_pct = BRIGHTNESS_MIN;
if (g_brightness_pct > BRIGHTNESS_MAX) g_brightness_pct = BRIGHTNESS_MAX;
g_led_max = (uint8_t)(255 * g_brightness_pct / 100);
break;
case SET_START_LIFE:
g_start_life_index += d;
if (g_start_life_index < 0) g_start_life_index = 0;
if (g_start_life_index >= NUM_LIFE_OPTS) g_start_life_index = NUM_LIFE_OPTS-1;
break;
case SET_NUM_OPP:
g_num_opponents += d;
if (g_num_opponents < 1) g_num_opponents = 1;
if (g_num_opponents > MAX_OPPONENTS) g_num_opponents = MAX_OPPONENTS;
if (g_active_opponent >= g_num_opponents) g_active_opponent = 0;
break;
case SET_PLAYER_NAME: {
char curr = g_player_name[g_name_cursor];
const char *pos = strchr(NAME_CHARS, curr);
int idx = pos ? (int)(pos - NAME_CHARS) : 0;
idx = (idx + d + NUM_NAME_CHARS) % NUM_NAME_CHARS;
g_player_name[g_name_cursor] = NAME_CHARS[idx];
break;
}
case SET_BLE:
g_ble_enabled += d;
if (g_ble_enabled < 0) g_ble_enabled = 0;
if (g_ble_enabled > 1) g_ble_enabled = 1;
ble_adv_update();
oled_draw_header();
break;
case SET_GAME_ID: {
int byte = g_game_id_cursor / 2;
int nibble = g_game_id_cursor % 2;
int val = nibble == 0 ? (g_game_id[byte] >> 4) & 0xF : g_game_id[byte] & 0xF;
val = (val + d + NUM_HEX_CHARS) % NUM_HEX_CHARS;
if (nibble == 0)
g_game_id[byte] = (g_game_id[byte] & 0x0F) | ((uint8_t)val << 4);
else
g_game_id[byte] = (g_game_id[byte] & 0xF0) | (uint8_t)val;
ble_adv_update();
break;
}
case SET_RESET:
game_reset();
ble_adv_update();
break;
case SET_RESET_ALL:
game_reset();
g_reset_cmd_ticks = 1500;
ble_adv_update();
break;
}
}
}
// ── Remote reset command ──────────────────────────────────────────────
if (g_reset_requested) {
g_reset_requested = 0;
game_reset();
MARK_DIRTY();
changed = 1;
ble_adv_update();
}
// ── reset_cmd broadcast countdown ────────────────────────────────────
if (g_reset_cmd_ticks > 0 && --g_reset_cmd_ticks == 0) ble_adv_update();
// ── Peer expiry + player ID resolution ───────────────────────────────
if (g_tick % 100 == 0) {
for (int i = 0; i < MAX_BLE_PEERS; i++) {
if (g_peers[i].active && g_tick - g_peers[i].last_seen > BLE_PEER_TIMEOUT) {
#ifdef DEBUG
printf("DBG PEER_EXPIRE slot=%d name=%-8.8s\n", i, g_peers[i].name);
fflush(stdout);
#endif
g_peers[i].active = 0;
}
}
player_id_resolve();
}
// ── Autosave settings ─────────────────────────────────────────────────
if (settings_dirty && ++settings_save_tick >= SAVE_DELAY) {
settings_save(); settings_dirty = 0; settings_save_tick = 0;
}
// ── BLE adv update on game state change ───────────────────────────────
if (changed && (g_active_menu==MENU_LIFE || g_active_menu==MENU_COUNTERS ||
g_active_menu==MENU_CMDR)) {
ble_adv_update();
}
// ── Players periodic refresh ──────────────────────────────────────────
if (g_active_menu == MENU_CMDR && g_ble_enabled) {
if (++players_tick >= 100) {
players_tick = 0; oled_draw_players();
}
} else { players_tick = 0; }
// ── LED ───────────────────────────────────────────────────────────────
if (g_life == 0) {
led_update_for_count(0, 255);
} else if (g_life < 10) {
float speed = 1.0f + (10.0f - g_life) / 9.0f * 2.0f;
breath_phase += 2.0f * 3.14159265f * speed / 200.0f;
uint8_t sc = (uint8_t)(127.5f + 127.5f * sinf(breath_phase));
led_update_for_count(g_life, sc);
} else if (changed) {
led_update_for_count(g_life, 255);
}
// ── Display dispatch ──────────────────────────────────────────────────
if (changed) {
switch (g_active_menu) {
case MENU_LIFE:
snprintf(nbuf, sizeof(nbuf), "%d", g_life);
oled_draw_centered(nbuf);
break;
case MENU_CMDR:
for (int i = 0; i < g_num_opponents; i++) {
int found = 0;
if (g_ble_enabled) {
for (int j = 0; j < MAX_BLE_PEERS; j++) {
if (g_peers[j].active && g_peers[j].player_id == (uint8_t)i) {
snprintf(opponent_labels[i], sizeof(opponent_labels[i]), "%.4s", g_peers[j].name);
found = 1;
break;
}
}
}
if (!found)
snprintf(opponent_labels[i], sizeof(opponent_labels[i]), "CMD %d", i+1);
opponent_label_ptrs[i] = opponent_labels[i];
}
if (!g_ble_enabled)
oled_draw_list(opponent_label_ptrs, g_cmdr_damage, g_num_opponents, g_active_opponent);
else
oled_draw_players();
break;
case MENU_COUNTERS:
oled_draw_list(counter_names, g_counters, NUM_COUNTERS, g_active_counter);
break;
case MENU_SETTINGS:
oled_draw_settings();
break;
}
}
#ifdef DEBUG
if (changed || g_tick % 1000 == 0)
serial_print_state();
#endif
prev_right = right; prev_left = left; prev_mid = mid;
vTaskDelay(pdMS_TO_TICKS(10));
}
}

@ -0,0 +1,33 @@
#pragma once
#include "config.h"
#include "ble.h"
// Game
extern int g_life;
extern int g_cmdr_damage[MAX_OPPONENTS];
extern int g_counters[NUM_COUNTERS];
extern int g_eliminated;
// UI
extern int g_active_menu;
extern int g_active_opponent;
extern int g_active_counter;
extern int g_active_player;
extern int g_active_setting;
extern int g_name_cursor;
// Settings
extern int g_brightness_pct;
extern int g_start_life_index;
extern int g_num_opponents;
extern char g_player_name[PLAYER_NAME_LEN + 1];
extern int g_ble_enabled;
extern uint8_t g_led_max;
extern int g_game_id_cursor;
extern uint8_t g_game_id[2];
// Peers
extern ble_peer_t g_peers[MAX_BLE_PEERS];
// Timing
extern uint32_t g_tick;

@ -0,0 +1,2 @@
(kicad_pcb (version 20260206) (generator "pcbnew") (generator_version "10.0")
)

@ -0,0 +1,14 @@
(kicad_sch
(version 20260306)
(generator "eeschema")
(generator_version "10.0")
(uuid 4f1ddaab-5ed8-4280-ae9f-5da3efc5b3b5)
(paper "A4")
(lib_symbols)
(sheet_instances
(path "/"
(page "1")
)
)
(embedded_fonts no)
)

File diff suppressed because it is too large Load Diff

@ -0,0 +1,3 @@
CONFIG_BT_ENABLED=y
CONFIG_BT_NIMBLE_ENABLED=y
CONFIG_BT_CONTROLLER_ENABLED=y

@ -0,0 +1,33 @@
cmake_minimum_required(VERSION 3.16)
project(simulator C)
# Needed to find GLib
find_package(PkgConfig REQUIRED)
pkg_check_modules(GLIB2 REQUIRED glib-2.0 gio-2.0)
include_directories(${GLIB2_INCLUDE_DIRS})
link_directories(${GLIB2_LIBRARY_DIRS})
add_definitions(${GLIB2_CFLAGS_OTHER})
include(FetchContent)
FetchContent_Declare(
bluez_inc
GIT_REPOSITORY https://github.com/weliem/bluez_inc.git
GIT_TAG main
)
FetchContent_MakeAvailable(bluez_inc)
add_executable(simulator main.c)
target_link_libraries(simulator PRIVATE Binc ${GLIB2_LIBRARIES})
# Integration test binary forks the simulator binary, needs only pthreads
add_executable(test_ble test_ble.c)
target_link_libraries(test_ble PRIVATE pthread)
add_dependencies(test_ble simulator)
target_compile_definitions(test_ble PRIVATE
SIM_BIN="$<TARGET_FILE:simulator>"
)

@ -0,0 +1,283 @@
#include <glib.h>
#include <glib-unix.h>
#include <gio/gio.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <getopt.h>
#include "adapter.h"
#include "advertisement.h"
#include "device.h"
#include "logger.h"
#define TAG "commeownder-sim"
#define MANUFACTURER_ID 0xFFFF
#define MAGIC_0 0xC0
#define MAGIC_1 0xDE
#define PLAYER_NAME_LEN 8
#define MAX_OPPONENTS 4
#pragma pack(push, 1)
typedef struct {
uint8_t magic[2];
uint8_t game_id[2];
char name[PLAYER_NAME_LEN];
int16_t life;
uint8_t poison;
uint8_t eliminated;
uint8_t player_id;
uint8_t reset_cmd;
uint8_t cmdr_dmg[MAX_OPPONENTS];
} ble_payload_t;
#pragma pack(pop)
static GMainLoop *loop = NULL;
static Adapter *default_adapter = NULL;
static Advertisement *adv = NULL;
static char g_name[PLAYER_NAME_LEN + 1] = {0};
static int16_t g_life = 40;
static int16_t g_start_life = 40;
static uint8_t g_poison = 0;
static uint8_t g_game_id[2] = {0x42, 0x42};
static uint8_t g_eliminated = 0;
static uint8_t g_player_id = 0;
static uint8_t g_reset_cmd = 0;
static uint8_t g_cmdr_dmg[MAX_OPPONENTS] = {0};
// Monotonic time of last processed reset (microseconds); 0 = never
static gint64 g_last_reset_us = 0;
#define RESET_COOLDOWN_US (10LL * 1000000LL) // 10 seconds
static void start_advertising(void) {
ble_payload_t payload;
memset(&payload, 0, sizeof(payload));
payload.magic[0] = MAGIC_0;
payload.magic[1] = MAGIC_1;
payload.game_id[0] = g_game_id[0];
payload.game_id[1] = g_game_id[1];
strncpy(payload.name, g_name, PLAYER_NAME_LEN);
payload.life = g_life;
payload.poison = g_poison;
payload.eliminated = g_eliminated;
payload.player_id = g_player_id;
payload.reset_cmd = g_reset_cmd;
memcpy(payload.cmdr_dmg, g_cmdr_dmg, MAX_OPPONENTS);
GByteArray *data = g_byte_array_new();
g_byte_array_append(data, (const guint8 *)&payload, sizeof(payload));
adv = binc_advertisement_create();
binc_advertisement_set_manufacturer_data(adv, MANUFACTURER_ID, data);
binc_advertisement_set_interval(adv, 500, 500);
binc_advertisement_set_general_discoverable(adv, FALSE);
g_byte_array_free(data, TRUE);
binc_adapter_start_advertising(default_adapter, adv);
fprintf(stderr, "advertising: name='%s' life=%d poison=%u game_id=%02X%02X pid=%u reset=%u cmdr=[%u,%u,%u,%u]\n",
g_name, (int)g_life, (unsigned)g_poison,
g_game_id[0], g_game_id[1], (unsigned)g_player_id, (unsigned)g_reset_cmd,
g_cmdr_dmg[0], g_cmdr_dmg[1], g_cmdr_dmg[2], g_cmdr_dmg[3]);
}
static void restart_advertising(void) {
if (adv) {
binc_adapter_stop_advertising(default_adapter, adv);
binc_advertisement_free(adv);
adv = NULL;
}
start_advertising();
}
static void on_scan_result(Adapter *adapter, Device *device) {
(void)adapter;
GHashTable *mfr_data = binc_device_get_manufacturer_data(device);
if (!mfr_data) return;
int key = MANUFACTURER_ID;
GByteArray *data = g_hash_table_lookup(mfr_data, &key);
if (!data || data->len < (guint)sizeof(ble_payload_t)) return;
const ble_payload_t *p = (const ble_payload_t *)data->data;
if (p->magic[0] != MAGIC_0 || p->magic[1] != MAGIC_1) return;
if (p->game_id[0] != g_game_id[0] || p->game_id[1] != g_game_id[1]) return;
char name[PLAYER_NAME_LEN + 1];
memcpy(name, p->name, PLAYER_NAME_LEN);
name[PLAYER_NAME_LEN] = '\0';
printf("PEER addr=%s name=%-8s life=%-5d poison=%u pid=%u cmdr=[%u,%u,%u,%u]%s%s\n",
binc_device_get_address(device), name,
(int)p->life, (unsigned)p->poison, (unsigned)p->player_id,
(unsigned)p->cmdr_dmg[0], (unsigned)p->cmdr_dmg[1],
(unsigned)p->cmdr_dmg[2], (unsigned)p->cmdr_dmg[3],
p->eliminated ? " ELIMINATED" : "",
p->reset_cmd ? " RESET_CMD" : "");
fflush(stdout);
// Handle reset_cmd with cooldown to avoid acting on repeated broadcasts
if (p->reset_cmd) {
gint64 now = g_get_monotonic_time();
if (now - g_last_reset_us >= RESET_COOLDOWN_US) {
g_last_reset_us = now;
g_life = g_start_life;
g_poison = 0;
g_eliminated = 0;
printf("RESET_ALL life=%d\n", (int)g_life);
fflush(stdout);
restart_advertising();
}
}
}
static void on_powered_state_changed(Adapter *adapter, gboolean state) {
if (!state) return;
start_advertising();
binc_adapter_set_discovery_filter(adapter, -100, NULL, NULL);
binc_adapter_set_discovery_cb(adapter, on_scan_result);
binc_adapter_start_discovery(adapter);
fprintf(stderr, "scanning for peers...\n");
}
static void cleanup(void) {
if (adv) {
binc_adapter_stop_advertising(default_adapter, adv);
binc_advertisement_free(adv);
adv = NULL;
}
if (default_adapter) {
binc_adapter_stop_discovery(default_adapter);
binc_adapter_free(default_adapter);
default_adapter = NULL;
}
if (loop) g_main_loop_quit(loop);
}
static gboolean cleanup_handler(gpointer user_data) {
(void)user_data;
cleanup();
return G_SOURCE_REMOVE;
}
static void print_usage(const char *prog) {
fprintf(stderr,
"Usage: %s --name <name> [--life <n>] [--poison <n>] [--game-id <hex4>] [--player-id <n>] [--eliminated] [--reset-cmd] [--cmdr-dmg <idx>:<val>]...\n"
" --name Player name (max 8 chars, required)\n"
" --life Life total (default: 40)\n"
" --poison Poison counters 0-9 (default: 0)\n"
" --game-id 4-hex-digit game ID (default: 4242)\n"
" --player-id Player slot 0-4 (default: 0)\n"
" --eliminated Mark player as eliminated\n"
" --reset-cmd Advertise reset_cmd=1 (tells peers to reset counters)\n"
" --cmdr-dmg Commander damage from opponent slot idx (0-3), e.g. --cmdr-dmg 0:7 (repeatable)\n",
prog);
}
int main(int argc, char *argv[]) {
log_set_level(LOG_WARN);
static const struct option long_opts[] = {
{"name", required_argument, NULL, 'n'},
{"life", required_argument, NULL, 'l'},
{"poison", required_argument, NULL, 'p'},
{"game-id", required_argument, NULL, 'g'},
{"player-id", required_argument, NULL, 'i'},
{"eliminated", no_argument, NULL, 'e'},
{"reset-cmd", no_argument, NULL, 'r'},
{"cmdr-dmg", required_argument, NULL, 'd'},
{NULL, 0, NULL, 0}
};
int name_set = 0;
int opt;
while ((opt = getopt_long(argc, argv, "", long_opts, NULL)) != -1) {
switch (opt) {
case 'n':
strncpy(g_name, optarg, PLAYER_NAME_LEN);
g_name[PLAYER_NAME_LEN] = '\0';
name_set = 1;
break;
case 'l':
g_life = (int16_t)atoi(optarg);
break;
case 'p':
g_poison = (uint8_t)atoi(optarg);
break;
case 'g': {
unsigned int id;
if (sscanf(optarg, "%x", &id) != 1) {
fprintf(stderr, "invalid game-id '%s' (expected 4 hex digits)\n", optarg);
return 1;
}
g_game_id[0] = (uint8_t)((id >> 8) & 0xFF);
g_game_id[1] = (uint8_t)(id & 0xFF);
break;
}
case 'i':
g_player_id = (uint8_t)atoi(optarg);
break;
case 'e':
g_eliminated = 1;
break;
case 'r':
g_reset_cmd = 1;
break;
case 'd': {
int idx, val;
if (sscanf(optarg, "%d:%d", &idx, &val) != 2 || idx < 0 || idx >= MAX_OPPONENTS || val < 0 || val > 255) {
fprintf(stderr, "invalid cmdr-dmg '%s' (expected <idx>:<val>, idx 0-%d, val 0-255)\n", optarg, MAX_OPPONENTS - 1);
return 1;
}
g_cmdr_dmg[idx] = (uint8_t)val;
break;
}
default:
print_usage(argv[0]);
return 1;
}
}
if (!name_set) {
print_usage(argv[0]);
return 1;
}
g_start_life = g_life;
g_unix_signal_add(SIGINT, cleanup_handler, NULL);
g_unix_signal_add(SIGTERM, cleanup_handler, NULL);
GDBusConnection *dbusConnection = g_bus_get_sync(G_BUS_TYPE_SYSTEM, NULL, NULL);
if (!dbusConnection) {
fprintf(stderr, "failed to connect to D-Bus\n");
return 1;
}
loop = g_main_loop_new(NULL, FALSE);
default_adapter = binc_adapter_get_default(dbusConnection);
if (!default_adapter) {
fprintf(stderr, "no Bluetooth adapter found\n");
return 1;
}
fprintf(stderr, "adapter: %s (%s)\n",
binc_adapter_get_name(default_adapter),
binc_adapter_get_address(default_adapter));
binc_adapter_set_powered_state_cb(default_adapter, on_powered_state_changed);
if (binc_adapter_get_powered_state(default_adapter)) {
on_powered_state_changed(default_adapter, TRUE);
} else {
binc_adapter_power_on(default_adapter);
}
g_main_loop_run(loop);
g_dbus_connection_close_sync(dbusConnection, NULL, NULL);
g_object_unref(dbusConnection);
g_main_loop_unref(loop);
return 0;
}

File diff suppressed because it is too large Load Diff