Merge develop into packet authentication policy

This commit is contained in:
Benjamin Faershtein
2026-07-19 15:33:16 -07:00
244 changed files with 10266 additions and 1139 deletions
+443 -31
View File
@@ -1,14 +1,17 @@
#include "AdminModule.h"
#include "Channels.h"
#include "DisplayFormatters.h"
#include "HardwareRNG.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "PositionPrecision.h"
#include "PowerFSM.h"
#include "RTC.h"
#include "SPILock.h"
#include "gps/RTC.h"
#include "input/InputBroker.h"
#include "meshUtils.h"
#include <FSCommon.h>
#include <Throttle.h>
#include <ctype.h> // for better whitespace handling
#if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI
#include "MeshtasticOTA.h"
@@ -26,6 +29,7 @@
#include "Default.h"
#include "MeshRadio.h"
#include "MessageStore.h"
#include "RadioInterface.h"
#include "TypeConversions.h"
#include "mesh/RadioLibInterface.h"
@@ -47,6 +51,9 @@
#include "GPS.h"
#endif
#include <RNG.h> // CryptRNG, the seeded CSPRNG used as fallback for the session passkey
#include <Throttle.h> // rollover-safe elapsed-time checks for the session passkey
#if MESHTASTIC_EXCLUDE_GPS
#include "modules/PositionModule.h"
#endif
@@ -124,9 +131,16 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
}
#endif
meshtastic_Channel *ch = &channels.getByIndex(mp.channel);
// Could tighten this up further by tracking the last public_key we went an AdminMessage request to
// and only allowing responses from that remote.
if (messageIsResponse(r)) {
// Only accept a response from a remote we sent the matching request to. from == 0 is a
// local client, which PhoneAPI has already gated.
const pb_size_t moduleConfigTag = r->which_payload_variant == meshtastic_AdminMessage_get_module_config_response_tag
? r->get_module_config_response.which_payload_variant
: 0;
if (mp.from != 0 && !responseIsSolicited(mp, r->which_payload_variant, moduleConfigTag)) {
LOG_INFO("Ignore admin response from 0x%08x, no outstanding request", mp.from);
return handled;
}
LOG_DEBUG("Allow admin response message");
} else if (mp.from == 0) {
// Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the
@@ -461,6 +475,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
LOG_INFO("Commit transaction for edited settings");
hasOpenEditTransaction = false;
saveChanges(SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS | SEGMENT_NODEDATABASE);
flushChannelWarnings(); // one coalesced message for everything edited in this transaction
break;
}
case meshtastic_AdminMessage_get_device_connection_status_request_tag: {
@@ -470,8 +485,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
}
case meshtastic_AdminMessage_get_module_config_response_tag: {
LOG_INFO("Client received a get_module_config response");
if (fromOthers && r->get_module_config_response.which_payload_variant ==
meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG) {
// which_payload_variant is the ModuleConfig oneof tag, so compare against that tag, not the
// AdminMessage ModuleConfigType enum (whose REMOTEHARDWARE value is a different number).
if (fromOthers && r->get_module_config_response.which_payload_variant == meshtastic_ModuleConfig_remote_hardware_tag) {
handleGetModuleConfigResponse(mp, r);
}
break;
@@ -522,7 +538,14 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
if (node != NULL) {
if (nodeDB->setProtectedFlag(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) {
nodeDB->eraseNodeSatellites(node->num);
#if HAS_SCREEN || defined(MESHTASTIC_INCLUDE_NICHE_GRAPHICS)
messageStore.deleteAllMessagesFromNode(node->num);
#endif
saveChanges(SEGMENT_NODEDATABASE, false);
#if HAS_SCREEN
if (screen)
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
#endif
} else if (mp.from == 0) { // local request from the phone - tell the user why it didn't take
sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", r->set_ignored_node, MAX_NUM_NODES - 2);
} else {
@@ -755,7 +778,7 @@ void AdminModule::handleSetOwner(const meshtastic_User &o)
changed = 1;
owner.is_licensed = o.is_licensed;
if (channels.ensureLicensedOperation()) {
sendWarning(licensedModeMessage);
warnLicensedMode();
}
}
if (owner.has_is_unmessagable != o.has_is_unmessagable ||
@@ -771,12 +794,35 @@ void AdminModule::handleSetOwner(const meshtastic_User &o)
}
}
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \
!MESHTASTIC_EXCLUDE_ACCELEROMETER
// Shared by double-tap-as-button (device) and wake-on-motion (display). Start on either flag's
// off->on edge; stop on the on->off edge once neither needs it (disable() clears `enabled` so a
// later re-enable restarts). Skip the stop if the sensor also drives the compass, else the heading
// freezes until reboot. wasOn/nowOn = old/new of the changed flag; otherFeatureOn = the other flag.
static void reconcileAccelerometerThread(bool wasOn, bool nowOn, bool otherFeatureOn)
{
if (!accelerometerThread) // null unless a sensor was detected at boot
return;
if (!wasOn && nowOn && accelerometerThread->enabled == false) {
accelerometerThread->enabled = true;
accelerometerThread->start();
} else if (wasOn && !nowOn && !otherFeatureOn && accelerometerThread->enabled == true &&
!accelerometerThread->providesHeading()) {
accelerometerThread->disable();
}
}
#endif
void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
{
auto changes = SEGMENT_CONFIG;
auto existingRole = config.device.role;
bool isRegionUnset = (config.lora.region == meshtastic_Config_LoRaConfig_RegionCode_UNSET);
bool requiresReboot = true;
bool loraPresetWarnPending = false;
meshtastic_Config_LoRaConfig pendingOldLora = {}, pendingNewLora = {};
switch (c.which_payload_variant) {
case meshtastic_Config_device_tag: {
@@ -784,12 +830,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
config.has_device = true;
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \
!MESHTASTIC_EXCLUDE_ACCELEROMETER
if (config.device.double_tap_as_button_press == false && c.payload_variant.device.double_tap_as_button_press == true &&
accelerometerThread->enabled == false) {
config.device.double_tap_as_button_press = c.payload_variant.device.double_tap_as_button_press;
accelerometerThread->enabled = true;
accelerometerThread->start();
}
reconcileAccelerometerThread(config.device.double_tap_as_button_press,
c.payload_variant.device.double_tap_as_button_press, config.display.wake_on_tap_or_motion);
#endif
if (config.device.button_gpio == c.payload_variant.device.button_gpio &&
config.device.buzzer_gpio == c.payload_variant.device.buzzer_gpio &&
@@ -888,12 +930,8 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
}
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && \
!MESHTASTIC_EXCLUDE_ACCELEROMETER
if (config.display.wake_on_tap_or_motion == false && c.payload_variant.display.wake_on_tap_or_motion == true &&
accelerometerThread->enabled == false) {
config.display.wake_on_tap_or_motion = c.payload_variant.display.wake_on_tap_or_motion;
accelerometerThread->enabled = true;
accelerometerThread->start();
}
reconcileAccelerometerThread(config.display.wake_on_tap_or_motion, c.payload_variant.display.wake_on_tap_or_motion,
config.device.double_tap_as_button_press);
#endif
config.display = c.payload_variant.display;
break;
@@ -1041,6 +1079,11 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
#endif
config.lora = validatedLora; // Finally, return the validated config back to the main config
if (validatedLora.modem_preset != oldLoraConfig.modem_preset) {
pendingOldLora = oldLoraConfig;
pendingNewLora = validatedLora;
loraPresetWarnPending = true;
}
break;
}
@@ -1103,6 +1146,11 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
} // end of switch case which_payload_variant
saveChanges(changes, requiresReboot);
if (loraPresetWarnPending)
warnOnLoraPresetChange(pendingOldLora, pendingNewLora);
// Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now.
if (!hasOpenEditTransaction)
flushChannelWarnings();
} // end of handleSetConfig
bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
@@ -1308,7 +1356,7 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc)
{
channels.setChannel(cc);
if (channels.ensureLicensedOperation()) {
sendWarning(licensedModeMessage);
warnLicensedMode();
}
// Refresh derived state (primaryIndex in particular) BEFORE the precision clamp below. usesPublicKey()
// resolves a secondary channel's key against the primary, so it must see the post-update primaryIndex;
@@ -1331,6 +1379,10 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc)
if (clamped)
sendWarning(publicChannelPrecisionMessage);
saveChanges(SEGMENT_CHANNELS, false);
warnOnChannelSet(channels.getByIndex(cc.index)); // passes the saved channel
// Inside an edit transaction the queued warnings are flushed once at commit; otherwise emit now.
if (!hasOpenEditTransaction)
flushChannelWarnings();
}
/**
@@ -1400,6 +1452,15 @@ void AdminModule::handleGetConfig(const meshtastic_MeshPacket &req, const uint32
LOG_INFO("Get config: Security");
res.get_config_response.which_payload_variant = meshtastic_Config_security_tag;
res.get_config_response.payload_variant.security = config.security;
// The device identity private key is backup material for the local owner only. A local
// admin client sets from == 0 (BLE/USB/TCP); never return the key to a remote requester,
// even an authorized one, since it would travel over the air. public_key/admin_key are
// public and stay put.
if (req.from != 0) {
auto &sec = res.get_config_response.payload_variant.security;
memset(sec.private_key.bytes, 0, sizeof(sec.private_key.bytes));
sec.private_key.size = 0;
}
break;
case meshtastic_AdminMessage_ConfigType_SESSIONKEY_CONFIG:
LOG_INFO("Get config: Sessionkey");
@@ -1746,7 +1807,7 @@ void AdminModule::handleSetHamMode(const meshtastic_HamParameters &p)
// Remove PSK of primary channel for plaintext amateur usage
if (channels.ensureLicensedOperation()) {
sendWarning(licensedModeMessage);
warnLicensedMode();
}
channels.onConfigChanged();
@@ -1766,11 +1827,15 @@ AdminModule::AdminModule() : ProtobufModule("Admin", meshtastic_PortNum_ADMIN_AP
void AdminModule::setPassKey(meshtastic_AdminMessage *res)
{
if (session_time == 0 || millis() / 1000 > session_time + 150) {
for (int i = 0; i < 8; i++) {
session_passkey[i] = random();
}
session_time = millis() / 1000;
// Regenerate once there is no session yet or the current key is older than 150s. session_time
// holds millis(); the Throttle check is rollover-safe, unlike the previous seconds comparison.
if (!sessionPasskeyValid || !Throttle::isWithinTimespanMs(session_time, 150 * 1000UL)) {
// Session passkey authenticates admin replies, so it must be unpredictable: prefer the
// hardware RNG, falling back to the seeded CSPRNG only when no hardware source exists.
if (!HardwareRNG::fill(session_passkey, sizeof(session_passkey)))
CryptRNG.rand(session_passkey, sizeof(session_passkey));
session_time = millis();
sessionPasskeyValid = true;
}
memcpy(res->session_passkey.bytes, session_passkey, 8);
res->session_passkey.size = 8;
@@ -1783,7 +1848,8 @@ bool AdminModule::checkPassKey(meshtastic_AdminMessage *res)
{ // check that the key in the packet is still valid
printBytes("Incoming session key: ", res->session_passkey.bytes, 8);
printBytes("Expected session key: ", session_passkey, 8);
return (session_time + 300 > millis() / 1000 && res->session_passkey.size == 8 &&
// Key is valid for 300s from issue; sessionPasskeyValid guards an unissued session.
return (sessionPasskeyValid && Throttle::isWithinTimespanMs(session_time, 300 * 1000UL) && res->session_passkey.size == 8 &&
memcmp(res->session_passkey.bytes, session_passkey, 8) == 0);
}
@@ -1804,6 +1870,107 @@ bool AdminModule::messageIsResponse(const meshtastic_AdminMessage *r)
return false;
}
// The response variant that answers a getter request, or 0 if the request has none.
static pb_size_t adminResponseForRequest(pb_size_t requestVariant)
{
switch (requestVariant) {
case meshtastic_AdminMessage_get_channel_request_tag:
return meshtastic_AdminMessage_get_channel_response_tag;
case meshtastic_AdminMessage_get_owner_request_tag:
return meshtastic_AdminMessage_get_owner_response_tag;
case meshtastic_AdminMessage_get_config_request_tag:
return meshtastic_AdminMessage_get_config_response_tag;
case meshtastic_AdminMessage_get_module_config_request_tag:
return meshtastic_AdminMessage_get_module_config_response_tag;
case meshtastic_AdminMessage_get_canned_message_module_messages_request_tag:
return meshtastic_AdminMessage_get_canned_message_module_messages_response_tag;
case meshtastic_AdminMessage_get_device_metadata_request_tag:
return meshtastic_AdminMessage_get_device_metadata_response_tag;
case meshtastic_AdminMessage_get_ringtone_request_tag:
return meshtastic_AdminMessage_get_ringtone_response_tag;
case meshtastic_AdminMessage_get_device_connection_status_request_tag:
return meshtastic_AdminMessage_get_device_connection_status_response_tag;
case meshtastic_AdminMessage_get_node_remote_hardware_pins_request_tag:
return meshtastic_AdminMessage_get_node_remote_hardware_pins_response_tag;
case meshtastic_AdminMessage_get_ui_config_request_tag:
return meshtastic_AdminMessage_get_ui_config_response_tag;
default:
return 0;
}
}
void AdminModule::noteOutgoingAdminRequest(const meshtastic_MeshPacket &p)
{
if (p.which_payload_variant != meshtastic_MeshPacket_decoded_tag || p.decoded.portnum != meshtastic_PortNum_ADMIN_APP)
return;
// Local admin is answered in-process, and a broadcast is never a request to one remote.
if (p.to == 0 || isBroadcast(p.to) || p.to == nodeDB->getNodeNum())
return;
meshtastic_AdminMessage admin = meshtastic_AdminMessage_init_zero;
if (!pb_decode_from_bytes(p.decoded.payload.bytes, p.decoded.payload.size, &meshtastic_AdminMessage_msg, &admin))
return;
const pb_size_t responseVariant = adminResponseForRequest(admin.which_payload_variant);
if (!responseVariant)
return; // not a getter whose response we can pair
const bool keyValid = p.pki_encrypted && p.public_key.size == 32;
// One entry per request (a client sends N indexed get_channel requests, each answered once, so
// entries must not merge). Free slot, else evict the oldest by rollover-safe elapsed time.
OutstandingAdminRequest *slot = nullptr;
for (auto &o : outstandingAdminRequests)
if (o.to == 0) {
slot = &o;
break;
}
if (!slot) {
const uint32_t now = millis();
slot = &outstandingAdminRequests[0];
for (auto &o : outstandingAdminRequests)
if ((uint32_t)(now - o.sentAtMs) > (uint32_t)(now - slot->sentAtMs))
slot = &o;
}
slot->to = p.to;
slot->sentAtMs = millis();
slot->expectedResponse = responseVariant;
slot->moduleConfigType = admin.which_payload_variant == meshtastic_AdminMessage_get_module_config_request_tag
? (uint8_t)admin.get_module_config_request
: 0;
slot->keyValid = keyValid;
if (keyValid)
memcpy(slot->key, p.public_key.bytes, 32);
else
memset(slot->key, 0, 32);
LOG_DEBUG("Admin request sent to 0x%08x, expecting its response", p.to);
}
bool AdminModule::responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag)
{
// Scan every entry: several requests for the same variant may differ only in pinning, and an
// unpinned one still authorizes the response even if a pinned one does not.
for (auto &o : outstandingAdminRequests) {
if (o.to != mp.from || o.expectedResponse != responseVariant)
continue;
if (!Throttle::isWithinTimespanMs(o.sentAtMs, kOutstandingAdminRequestMs)) {
o.to = 0; // lapsed; free the slot and keep looking for another live match
continue;
}
// A request pinned to a PKC key must be answered over PKC by that same key.
if (o.keyValid && (!mp.pki_encrypted || mp.public_key.size != 32 || memcmp(mp.public_key.bytes, o.key, 32) != 0))
continue;
// remote_hardware is the only module config that mutates state (the pin table), so require
// it to answer a request for that exact subtype, not just any get_module_config_request.
if (moduleConfigTag == meshtastic_ModuleConfig_remote_hardware_tag &&
o.moduleConfigType != meshtastic_AdminMessage_ModuleConfigType_REMOTEHARDWARE_CONFIG)
continue;
o.to = 0; // consume: one request authorizes one response, no replay within the window
return true;
}
return false;
}
bool AdminModule::messageIsRequest(const meshtastic_AdminMessage *r)
{
if (r->which_payload_variant == meshtastic_AdminMessage_get_channel_request_tag ||
@@ -1884,14 +2051,259 @@ void AdminModule::sendWarningAndLog(const char *format, ...)
vsnprintf(buf, sizeof(buf), format, args);
va_end(args);
LOG_WARN(buf);
// 2. Call sendWarning
// SECURITY NOTE: We pass "%s", buf instead of just 'buf'.
// If 'buf' contained a % symbol (e.g. "Battery 50%"), passing it directly
// would crash sendWarning. "%s" treats it purely as text.
// SECURITY NOTE: Both LOG_WARN and sendWarning are printf-style, so we pass
// "%s", buf rather than 'buf' directly. If 'buf' contained a % symbol (e.g. a
// user-supplied channel name like "50%"), passing it as the format string would
// read bogus varargs and could crash. "%s" treats it purely as text.
LOG_WARN("%s", buf);
sendWarning("%s", buf);
}
// Strip spaces and fold to lowercase for loose preset-name comparison.
static void normalizePresetName(const char *src, char *dst, size_t dstLen)
{
size_t j = 0;
for (size_t i = 0; src[i] && j + 1 < dstLen; i++) {
if (src[i] != ' ')
dst[j++] = (char)tolower((unsigned char)src[i]);
}
dst[j] = '\0';
}
// Record one channel-configuration warning. The first message is kept verbatim in case it
// turns out to be the only one; nameIssue/pskIssue and the channel bitmask feed the catch-all
// wording if more than one channel ends up flagged. flushChannelWarnings() emits the result.
void AdminModule::queueChannelWarning(uint8_t channelIndex, bool nameIssue, bool pskIssue, const char *format, ...)
{
if (pendingWarningCount == 0) {
va_list args;
va_start(args, format);
vsnprintf(pendingWarningText, sizeof(pendingWarningText), format, args);
va_end(args);
}
if (channelIndex < MAX_NUM_CHANNELS)
pendingWarningChannels |= (1u << channelIndex);
pendingWarningNameIssue |= nameIssue;
pendingWarningPskIssue |= pskIssue;
pendingWarningCount++;
}
// Queue the fixed "licensed mode activated" notice, deferring it to commit during an edit
// transaction so repeated triggers collapse to a single message.
void AdminModule::warnLicensedMode()
{
if (hasOpenEditTransaction)
pendingLicenseWarning = true;
else
sendWarning(licensedModeMessage);
}
// Emit the coalesced channel warning(s): nothing if none queued, the lone message verbatim if
// exactly one, otherwise a single catch-all naming every flagged channel. The licensed-mode
// notice, if queued, is emitted once alongside. Always resets state.
void AdminModule::flushChannelWarnings()
{
if (pendingLicenseWarning)
sendWarning(licensedModeMessage);
if (pendingWarningCount == 1) {
sendWarningAndLog("%s", pendingWarningText);
} else if (pendingWarningCount > 1) {
char list[48] = {};
for (uint8_t i = 0; i < MAX_NUM_CHANNELS; i++) {
if (!(pendingWarningChannels & (1u << i)))
continue;
char num[8];
snprintf(num, sizeof(num), "%s%u", *list ? ", " : "", i);
strncat(list, num, sizeof(list) - strlen(list) - 1);
}
if (pendingWarningNameIssue && pendingWarningPskIssue)
sendWarningAndLog("There may be name and PSK issues on channels %s", list); // max 60 bytes
else if (pendingWarningNameIssue)
sendWarningAndLog("There may be name issues on channels %s", list); // max 52 bytes
else
sendWarningAndLog("There may be PSK issues on channels %s", list); // max 51 bytes
}
pendingWarningText[0] = '\0';
pendingWarningChannels = 0;
pendingWarningCount = 0;
pendingWarningNameIssue = false;
pendingWarningPskIssue = false;
pendingLicenseWarning = false;
}
/**
* @brief Emit client warnings for common misconfigurations on a newly committed channel.
*
* Called from handleSetChannel() after the channel has been saved. The following checks
* are performed:
*
* - Blank PSK (size == 0) on a non-licensed device: the channel has no encryption.
* - Blank name with a non-default key (not the default key, 0x01): the name will auto-resolve to
* the current modem preset name, but the key mismatch means other preset nodes cannot
* decode traffic on this channel.
* - Named channel whose name is a case/space variant of the current modem preset: the
* explicit name prevents auto-resolution; client should clear it.
* - Same variant match but PSK is not the default key: looks like the preset channel but
* is incompatible with nodes using the preset's default key.
*
* @param cc The channel that was written.
*/
void AdminModule::warnOnChannelSet(const meshtastic_Channel &cc)
{
if (cc.role == meshtastic_Channel_Role_DISABLED || !cc.has_settings) // don't check unused channels
return;
bool blankPsk = (cc.settings.psk.size == 0 && !owner.is_licensed);
if (!config.lora.use_preset) { // custom or unset preset can mistype things too
const char *mistypePreset = nullptr;
if (*cc.settings.name) {
char normChan[32];
normalizePresetName(cc.settings.name, normChan, sizeof(normChan));
for (auto preset = _meshtastic_Config_LoRaConfig_ModemPreset_MIN;
preset <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX;
preset = (meshtastic_Config_LoRaConfig_ModemPreset)(preset + 1)) {
const char *name = DisplayFormatters::getModemPresetDisplayName(preset, false, true);
if (strcmp(name, "Invalid") == 0)
continue; // skip preset slots without a real display name
if (strcmp(cc.settings.name, name) == 0)
break; // exact match - not a mistype, no warning
char normPreset[32];
normalizePresetName(name, normPreset, sizeof(normPreset));
if (strcmp(normChan, normPreset) == 0) {
mistypePreset = name;
break;
}
}
}
// At most one warning: collapse blank-PSK + mistype into a single catch-all.
if (blankPsk && mistypePreset)
queueChannelWarning(cc.index, true, true, "There may be name and PSK issues on channel %d", cc.index);
else if (mistypePreset)
queueChannelWarning(cc.index, true, false,
"Channel %d name '%s' looks like a mistype of '%s' - "
"make sure to type it exactly!", // max 90 bytes
cc.index, cc.settings.name, mistypePreset);
else if (blankPsk)
queueChannelWarning(cc.index, false, true,
"Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes
cc.index, cc.settings.name);
return;
}
const char *presetName = DisplayFormatters::getModemPresetDisplayName(config.lora.modem_preset, false, true);
bool isDefaultKey = (cc.settings.psk.size == 1 && cc.settings.psk.bytes[0] == 0x01);
char normPreset[32], normChan[32]; // max size is 11 plus nul, but allow for future expansion
normalizePresetName(presetName, normPreset, sizeof(normPreset));
normalizePresetName(cc.settings.name, normChan, sizeof(normChan));
if (!*cc.settings.name) {
// Blank name resolves to the preset name - A and B are mutually exclusive (psk.size can't be both 0 and >0)
if (blankPsk)
queueChannelWarning(cc.index, false, true,
"Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes
cc.index, cc.settings.name);
else if (!isDefaultKey && cc.settings.psk.size > 0)
queueChannelWarning(cc.index, false, true,
"Channel %d will resolve to preset '%s' but uses a non-default key - "
"default-key nodes can't decode it.", // max 102 bytes
cc.index, presetName);
return;
}
if (strcmp(normChan, normPreset) != 0) { // name unrelated to preset
if (blankPsk)
queueChannelWarning(cc.index, false, true,
"Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes
cc.index, cc.settings.name);
return;
}
bool variantName = (strcmp(cc.settings.name, presetName) != 0);
bool keyMismatch = (!isDefaultKey && cc.settings.psk.size > 0);
int issues = (int)blankPsk + (int)variantName + (int)keyMismatch;
if (issues > 1) {
bool hasNameIssue = variantName;
bool hasPskIssue = blankPsk || keyMismatch;
if (hasNameIssue && hasPskIssue)
queueChannelWarning(cc.index, true, true, "There may be name and PSK issues on channel %d", cc.index);
else if (hasNameIssue)
queueChannelWarning(cc.index, true, false, "There may be name issues on channel %d", cc.index);
else
queueChannelWarning(cc.index, false, true, "There may be PSK issues on channel %d", cc.index);
return;
}
if (blankPsk)
queueChannelWarning(cc.index, false, true,
"Channel %d '%s' has a blank PSK (no encryption) instead of default key", // max 100 bytes
cc.index, cc.settings.name);
if (variantName)
queueChannelWarning(cc.index, true, false,
"Channel %d name '%s' looks like a mistype of '%s' - "
"clear the name to use the preset name automatically.", // max 113 bytes
cc.index, cc.settings.name, presetName);
if (keyMismatch)
queueChannelWarning(cc.index, false, true,
"Channel %d '%s' matches preset '%s' but uses a non-default key - "
"default-key nodes can't decode it.", // max 108 bytes
cc.index, cc.settings.name, presetName);
} // warnOnChannelSet
/**
* @brief Scan all channels for preset-name conflicts after a modem preset change is committed.
*
* Called from handleSetConfig() after the LoRa config has been saved, and only when
* modem_preset actually changed (rejected configs are never passed here). For every
* named, non-disabled channel two checks are performed:
*
* - Name matches the *old* preset (case-insensitive, spaces stripped): the channel
* was likely tracking the previous preset; the user should rename it if it should
* follow the new one.
* - Name matches the *new* preset: the channel name collides with the auto-generated
* preset name but won't resolve automatically because the name is set explicitly.
*
* No-ops if the new config does not use a preset.
*
* @param oldLora LoRa config before the update.
* @param newLora LoRa config after the update.
*/
void AdminModule::warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora)
{
if (!newLora.use_preset || newLora.modem_preset == oldLora.modem_preset)
return;
char normOld[32] = {}, normNew[32];
if (oldLora.use_preset) {
const char *oldName = DisplayFormatters::getModemPresetDisplayName(oldLora.modem_preset, false, true);
normalizePresetName(oldName, normOld, sizeof(normOld));
}
const char *newName = DisplayFormatters::getModemPresetDisplayName(newLora.modem_preset, false, true);
normalizePresetName(newName, normNew, sizeof(normNew));
// Queue one (name) warning per affected channel; flushChannelWarnings() collapses them
// into a single message - either the lone warning verbatim or a catch-all listing indices.
for (int i = 0; i < channels.getNumChannels(); i++) {
const meshtastic_Channel &ch = channels.getByIndex(i);
if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.has_settings || !*ch.settings.name)
continue;
char normChan[32];
normalizePresetName(ch.settings.name, normChan, sizeof(normChan));
if (*normOld && strcmp(normChan, normOld) == 0)
queueChannelWarning(i, true, false,
"Channel %d name '%s' matches the old preset. "
"Rename it manually if it should track the new preset.", // max 98 bytes
i, ch.settings.name);
else if (strcmp(normChan, normNew) == 0)
queueChannelWarning(i, true, false,
"Channel %d '%s' looks like preset '%s' but won't auto-resolve - "
"clear the name to fix it.", // max 98 bytes
i, ch.settings.name, newName);
}
} // warnOnLoraPresetChange
void disableBluetooth()
{
#if HAS_BLUETOOTH
+45 -1
View File
@@ -41,7 +41,8 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
bool hasOpenEditTransaction = false;
uint8_t session_passkey[8] = {0};
uint session_time = 0;
uint32_t session_time = 0; // millis() when the current session passkey was issued
bool sessionPasskeyValid = false; // separate flag: millis() 0 at boot is a valid issue time
void saveChanges(int saveWhat, bool shouldReboot = true);
@@ -77,7 +78,29 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
public:
void handleSetHamMode(const meshtastic_HamParameters &req);
/// Note an admin request leaving this node for a remote, so that remote's response is
/// accepted. Called from the client-to-mesh path (MeshService::handleToRadio).
void noteOutgoingAdminRequest(const meshtastic_MeshPacket &p);
private:
// An admin response has no session passkey and its sender need not hold an admin key, so a
// request we sent is the only thing vouching for it. Track each request independently (its own
// expiry and pinned key) so a later one can't extend or relax an earlier one.
static constexpr size_t kOutstandingAdminRequests = 8;
static constexpr uint32_t kOutstandingAdminRequestMs = 300 * 1000; // same window as the session passkey
struct OutstandingAdminRequest {
NodeNum to; // 0 = free slot
uint32_t sentAtMs; // millis() when this request went out
pb_size_t expectedResponse; // the one response variant this request authorizes
uint8_t moduleConfigType; // for get_module_config_request: which ModuleConfigType we asked
uint8_t key[32]; // pinned destination key when the request went out over PKC
bool keyValid;
};
OutstandingAdminRequest outstandingAdminRequests[kOutstandingAdminRequests] = {};
/// Whether a response (variant responseVariant, module-config subtype moduleConfigTag or 0)
/// from mp.from answers a request we sent; consumes the matched request so it can't be replayed.
bool responseIsSolicited(const meshtastic_MeshPacket &mp, pb_size_t responseVariant, pb_size_t moduleConfigTag);
void handleStoreDeviceUIConfig(const meshtastic_DeviceUIConfig &uicfg);
void handleSendInputEvent(const meshtastic_AdminMessage_InputEvent &inputEvent);
void reboot(int32_t seconds);
@@ -89,6 +112,27 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
bool messageIsRequest(const meshtastic_AdminMessage *r);
void sendWarning(const char *format, ...) __attribute__((format(printf, 2, 3)));
void sendWarningAndLog(const char *format, ...) __attribute__((format(printf, 2, 3)));
void warnOnLoraPresetChange(const meshtastic_Config_LoRaConfig &oldLora, const meshtastic_Config_LoRaConfig &newLora);
void warnOnChannelSet(const meshtastic_Channel &cc);
// Channel-configuration warnings are coalesced into a single client notification.
// queueChannelWarning() records one warning for a channel; while an edit transaction
// is open (begin/commit_edit_settings) the warnings accumulate and flushChannelWarnings()
// emits one combined message at commit. Outside a transaction the caller flushes
// immediately, so a single channel/preset edit still produces a single message.
void queueChannelWarning(uint8_t channelIndex, bool nameIssue, bool pskIssue, const char *format, ...)
__attribute__((format(printf, 5, 6)));
// Emit the "licensed mode activated" notice, deferring to commit during an edit transaction
// so repeated triggers (e.g. owner + several channels) produce a single message.
void warnLicensedMode();
void flushChannelWarnings();
char pendingWarningText[250] = {}; // the lone queued message, used verbatim when only one fired
uint32_t pendingWarningChannels = 0; // bitmask of channel indices with a queued warning
uint8_t pendingWarningCount = 0; // number of queued warnings this transaction
bool pendingWarningNameIssue = false; // any queued warning was about a channel name
bool pendingWarningPskIssue = false; // any queued warning was about a PSK
bool pendingLicenseWarning = false; // a licensed-mode notice is queued for this transaction
};
static constexpr const char *licensedModeMessage =
+1 -4
View File
@@ -289,10 +289,6 @@ void CannedMessageModule::updateDestinationSelectionList()
}
}
meshtastic_MeshPacket *p = allocDataPacket();
p->pki_encrypted = true;
p->channel = 0;
// Populate active channels
std::vector<String> seenChannels;
seenChannels.reserve(channels.getNumChannels());
@@ -2118,6 +2114,7 @@ static float getSnrLimit(meshtastic_Config_LoRaConfig_ModemPreset preset)
return -6.0f;
case PRESET(MEDIUM_SLOW):
case PRESET(MEDIUM_FAST):
case PRESET(MEDIUM_TURBO):
return -5.5f;
case PRESET(SHORT_SLOW):
case PRESET(SHORT_FAST):
+1 -1
View File
@@ -16,10 +16,10 @@
#include "ExternalNotificationModule.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "RTC.h"
#include "Router.h"
#include "buzz/buzz.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "main.h"
#include "mesh/generated/meshtastic/rtttl.pb.h"
#include <Arduino.h>
+109 -37
View File
@@ -1,11 +1,15 @@
#if !MESHTASTIC_EXCLUDE_PKI
#include "KeyVerificationModule.h"
#include "CryptoEngine.h"
#include "HardwareRNG.h"
#include "MeshService.h"
#include "RTC.h"
#include "gps/RTC.h"
#include "graphics/draw/MenuHandler.h"
#include "main.h"
#include "meshUtils.h"
#include "modules/AdminModule.h"
#include "modules/NodeInfoModule.h"
#include <RNG.h>
#include <SHA256.h>
KeyVerificationModule *keyVerificationModule;
@@ -34,7 +38,7 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons
{
updateState();
if (request->which_payload_variant == meshtastic_AdminMessage_key_verification_tag && mp.from == 0) {
LOG_WARN("Handling Key Verification Admin Message type %u", request->key_verification.message_type);
LOG_DEBUG("Handling Key Verification Admin Message type %u", request->key_verification.message_type);
if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_INITIATE_VERIFICATION &&
currentState == KEY_VERIFICATION_IDLE) {
@@ -48,9 +52,7 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons
} else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_VERIFY &&
request->key_verification.nonce == currentNonce) {
auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode);
if (remoteNodePtr)
remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
commitVerifiedRemoteNode();
resetToIdle();
} else if (request->key_verification.message_type == meshtastic_KeyVerificationAdmin_MessageType_DO_NOT_VERIFY) {
resetToIdle();
@@ -63,9 +65,8 @@ AdminMessageHandleResult KeyVerificationModule::handleAdminMessageForModule(cons
bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_KeyVerification *r)
{
updateState();
if (mp.pki_encrypted == false) {
return false;
}
// Note: pki_encrypted is not required here. The first response (M2) may arrive channel-encrypted in
// the bootstrap case; the follow-on hash1 packet (M3) is required to be PKI in its branch below.
if (mp.from != currentRemoteNode) { // because the inital connection request is handled in allocReply()
return false;
}
@@ -74,9 +75,14 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
}
if (currentState == KEY_VERIFICATION_SENDER_HAS_INITIATED && r->nonce == currentNonce && r->hash2.size == 32 &&
r->hash1.size == 0) {
r->hash1.size == 32) {
memcpy(hash2, r->hash2.bytes, 32);
IF_SCREEN(screen->showNumberPicker("Enter Security Number", 60000, 6, [](int number_picked) -> void {
// The response carries the responder's public key in hash1. If we don't already hold it, stash it
// as a pending key so the Router can PKI-encrypt our follow-on packet (committed to NodeDB on accept).
auto *responderNode = nodeDB->getMeshNode(currentRemoteNode);
if (responderNode == nullptr || responderNode->public_key.size != 32)
crypto->setPendingPublicKey(currentRemoteNode, r->hash1.bytes);
IF_SCREEN(screen->showNumberPicker("Enter Security Number", 60000, 6, false, [](uint32_t number_picked) -> void {
keyVerificationModule->processSecurityNumber(number_picked);
});)
@@ -95,7 +101,8 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
currentState = KEY_VERIFICATION_SENDER_AWAITING_NUMBER;
return true;
} else if (currentState == KEY_VERIFICATION_RECEIVER_AWAITING_HASH1 && r->hash1.size == 32 && r->nonce == currentNonce) {
} else if (currentState == KEY_VERIFICATION_RECEIVER_AWAITING_HASH1 && mp.pki_encrypted && r->hash1.size == 32 &&
r->nonce == currentNonce) {
if (memcmp(hash1, r->hash1.bytes, 32) == 0) {
memset(message, 0, sizeof(message));
sprintf(message, "Verification: \n");
@@ -108,10 +115,9 @@ bool KeyVerificationModule::handleReceivedProtobuf(const meshtastic_MeshPacket &
options.notificationType = graphics::notificationTypeEnum::selection_picker;
options.bannerCallback =
[=](int selected) {
LOG_DEBUG("User selected %d for key verification", selected);
if (selected == 1) {
auto remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode);
if (remoteNodePtr)
remoteNodePtr->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
keyVerificationModule->commitVerifiedRemoteNode();
}
};
screen->showOverlayBanner(options);)
@@ -139,22 +145,29 @@ bool KeyVerificationModule::sendInitialRequest(NodeNum remoteNode)
{
LOG_DEBUG("keyVerification start");
// generate nonce
updateState();
updateState(false);
if (currentState != KEY_VERIFICATION_IDLE) {
IF_SCREEN(graphics::menuHandler::menuQueue = graphics::menuHandler::ThrottleMessage;)
return false;
}
updateState(true);
currentNonce = random();
currentNonceTimestamp = getTime();
currentRemoteNode = remoteNode;
meshtastic_KeyVerification KeyVerification = meshtastic_KeyVerification_init_zero;
KeyVerification.nonce = currentNonce;
KeyVerification.hash2.size = 0;
KeyVerification.hash1.size = 0;
// Carry our public key in the otherwise-unused hash1 field so a peer that does not yet hold our
// key can learn it from this first message (bootstrap / onboarding).
KeyVerification.hash1.size = 32;
memcpy(KeyVerification.hash1.bytes, owner.public_key.bytes, 32);
meshtastic_MeshPacket *p = allocDataProtobuf(KeyVerification);
p->to = remoteNode;
p->channel = 0;
p->pki_encrypted = true;
// Only request PKI when we already hold the destination's key. Otherwise this first message goes out
// channel-encrypted (the Router falls back) so the peer can bootstrap from the key carried in hash1.
auto *remoteNodePtr = nodeDB->getMeshNode(remoteNode);
p->pki_encrypted = (remoteNodePtr != nullptr && remoteNodePtr->public_key.size == 32);
p->decoded.want_response = true;
p->priority = meshtastic_MeshPacket_Priority_HIGH;
service->sendToMesh(p, RX_SRC_LOCAL, true);
@@ -171,9 +184,6 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply()
if (currentState != KEY_VERIFICATION_IDLE) { // TODO: cooldown period
LOG_WARN("Key Verification requested, but already in a request");
return nullptr;
} else if (!currentRequest->pki_encrypted) {
LOG_WARN("Key Verification requested, but not in a PKI packet");
return nullptr;
}
currentState = KEY_VERIFICATION_RECEIVER_AWAITING_HASH1;
@@ -188,15 +198,43 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply()
response.nonce = scratch.nonce;
currentRemoteNode = req.from;
currentNonceTimestamp = getTime();
currentSecurityNumber = random(1, 999999);
// The security number is the handshake's MitM-resistance entropy, so draw it from the hardware
// RNG (falling back to the CSPRNG used for key material), never the predictable random().
// Hold cryptLock like xeddsa_sign does: on nRF52 the fill toggles the CC310 that packet crypto
// on the BLE task also uses, and the CryptRNG state is shared with the signing path.
uint32_t securityEntropy = 0;
{
concurrency::LockGuard g(cryptLock);
if (!HardwareRNG::fill((uint8_t *)&securityEntropy, sizeof(securityEntropy)))
CryptRNG.rand((uint8_t *)&securityEntropy, sizeof(securityEntropy));
}
currentSecurityNumber = (securityEntropy % 999999) + 1;
// generate hash1
// Resolve the requester's public key: from the PKI envelope, else carried in hash1 (bootstrap).
// Stash unknown keys as pending (committed to NodeDB only once verification is accepted).
const uint8_t *senderKey = nullptr;
if (currentRequest->pki_encrypted && currentRequest->public_key.size == 32) {
senderKey = currentRequest->public_key.bytes; // this is bizarre, fixme
} else if (scratch.hash1.size == 32) {
senderKey = scratch.hash1.bytes;
}
if (senderKey == nullptr) {
LOG_WARN("Key Verification request without a usable public key");
resetToIdle();
return nullptr;
}
auto *senderNode = nodeDB->getMeshNode(currentRemoteNode);
bool senderKeyInNodeDB = (senderNode != nullptr && senderNode->public_key.size == 32);
if (!senderKeyInNodeDB)
crypto->setPendingPublicKey(currentRemoteNode, senderKey);
// generate local hash1
hash.reset();
hash.update(&currentSecurityNumber, sizeof(currentSecurityNumber));
hash.update(&currentNonce, sizeof(currentNonce));
hash.update(&currentRemoteNode, sizeof(currentRemoteNode));
hash.update(&ourNodeNum, sizeof(ourNodeNum));
hash.update(currentRequest->public_key.bytes, currentRequest->public_key.size);
hash.update(senderKey, 32);
hash.update(owner.public_key.bytes, owner.public_key.size);
hash.finalize(hash1, 32);
@@ -205,15 +243,19 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply()
hash.update(&currentNonce, sizeof(currentNonce));
hash.update(hash1, 32);
hash.finalize(hash2, 32);
response.hash1.size = 0;
// Carry our public key in hash1 of the response so the requester can bootstrap our key as well.
response.hash1.size = 32;
memcpy(response.hash1.bytes, owner.public_key.bytes, 32);
response.hash2.size = 32;
memcpy(response.hash2.bytes, hash2, 32);
responsePacket = allocDataProtobuf(response);
responsePacket->pki_encrypted = true;
// PKI-encrypt the response only if we already held the requester's key. In the bootstrap case it goes
// out channel-encrypted so the requester (who lacks our key) can decode it and read hash1.
responsePacket->pki_encrypted = senderKeyInNodeDB;
IF_SCREEN(snprintf(message, 25, "Security Number \n%03u %03u", currentSecurityNumber / 1000, currentSecurityNumber % 1000);
screen->showSimpleBanner(message, 30000); LOG_WARN("%s", message);)
screen->showSimpleBanner(message, 30000); LOG_DEBUG("%s", message);)
meshtastic_ClientNotification *cn = clientNotificationPool.allocZeroed();
if (cn) {
cn->level = meshtastic_LogRecord_Level_WARNING;
@@ -227,7 +269,7 @@ meshtastic_MeshPacket *KeyVerificationModule::allocReply()
cn->payload_variant.key_verification_number_inform.security_number = currentSecurityNumber;
service->sendClientNotification(cn);
}
LOG_WARN("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce);
LOG_DEBUG("Security Number %04u, nonce %llu", currentSecurityNumber, currentNonce);
return responsePacket;
}
@@ -236,14 +278,18 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
SHA256 hash;
NodeNum ourNodeNum = nodeDB->getNodeNum();
uint8_t scratch_hash[32] = {0};
LOG_WARN("received security number: %u", incomingNumber);
meshtastic_NodeInfoLite *remoteNodePtr = nullptr;
remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode);
if (!remoteNodePtr || !nodeInfoLiteHasUser(remoteNodePtr) || remoteNodePtr->public_key.size != 32) {
currentState = KEY_VERIFICATION_IDLE;
return; // should we throw an error here?
LOG_DEBUG("received security number: %u", incomingNumber);
meshtastic_NodeInfoLite *remoteNodePtr = nodeDB->getMeshNode(currentRemoteNode);
// Resolve the remote public key: NodeDB if known, otherwise the pending key learned during this
// handshake (bootstrap case).
meshtastic_NodeInfoLite_public_key_t remotePublic = {0, {0}};
if (remoteNodePtr != nullptr && remoteNodePtr->public_key.size == 32) {
remotePublic = remoteNodePtr->public_key;
} else if (!crypto->getPendingPublicKey(currentRemoteNode, remotePublic)) {
LOG_WARN("No public key available for remote node, aborting key verification");
resetToIdle();
return;
}
LOG_WARN("hashing ");
// calculate hash1
hash.reset();
hash.update(&incomingNumber, sizeof(incomingNumber));
@@ -252,7 +298,7 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
hash.update(&currentRemoteNode, sizeof(currentRemoteNode));
hash.update(owner.public_key.bytes, owner.public_key.size);
hash.update(remoteNodePtr->public_key.bytes, remoteNodePtr->public_key.size);
hash.update(remotePublic.bytes, 32);
hash.finalize(hash1, 32);
hash.reset();
@@ -297,13 +343,13 @@ void KeyVerificationModule::processSecurityNumber(uint32_t incomingNumber)
return;
}
void KeyVerificationModule::updateState()
void KeyVerificationModule::updateState(bool resetTimer)
{
if (currentState != KEY_VERIFICATION_IDLE) {
// check for the 60 second timeout
if (currentNonceTimestamp < getTime() - 60) {
resetToIdle();
} else {
} else if (resetTimer) {
currentNonceTimestamp = getTime();
}
}
@@ -318,6 +364,32 @@ void KeyVerificationModule::resetToIdle()
currentSecurityNumber = 0;
currentRemoteNode = 0;
currentState = KEY_VERIFICATION_IDLE;
// Discard any not-yet-verified key learned during this handshake; on reject/timeout it is never trusted.
crypto->clearPendingPublicKey();
}
void KeyVerificationModule::commitVerifiedRemoteNode()
{
// The remote node already has a NodeDB entry by this point (packets were exchanged during the
// handshake), so getMeshNode is sufficient; bail defensively if it is somehow absent.
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(currentRemoteNode);
if (!node) {
LOG_WARN("Attempted to commit key, but unknown node");
return;
}
// If we only held the peer's key as a pending (unverified) key during the handshake, commit it to
// NodeDB now that the user has confirmed the verification, so future PKI traffic can use it.
meshtastic_NodeInfoLite_public_key_t pending = {0, {0}};
if (node->public_key.size != 32 && crypto->getPendingPublicKey(currentRemoteNode, pending))
node->public_key = pending;
node->bitfield |= NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK;
LOG_INFO("Node 0x%08x manually verified with security number %u", currentRemoteNode, currentSecurityNumber);
if (nodeInfoModule)
nodeInfoModule->sendOurNodeInfo(currentRemoteNode, false, node->channel, true);
crypto->clearPendingPublicKey();
currentState = KEY_VERIFICATION_IDLE;
// Persist the committed key and verified flag so manual verification survives a reboot.
nodeDB->saveToDisk(SEGMENT_NODEDATABASE);
}
void KeyVerificationModule::generateVerificationCode(char *readableCode)
+36 -2
View File
@@ -12,6 +12,39 @@ enum KeyVerificationState {
KEY_VERIFICATION_RECEIVER_AWAITING_HASH1,
};
// KeyVerification Module overview
// This module allows for two useful functions. First, it implements a 2sv process that can manually verify a trustworthy
// connection with another node. It specifically verifies that the other node holds the correct private key for its public key, so
// it is resistant to MitM attacks. Second, it can be used to bootstrap trust in a new node by carrying the public key in the
// initial unencrypted message (in the hash1 field of the KeyVerification protobuf). This allows a user to manually verify a new
// node even if they don't have that node in the local nodeDB at all.
// The handshake process is as follows (NodeA = initiator, NodeB = responder):
// 1. NodeA sends a KeyVerification message containing a random nonce and its own public key (in the
// hash1 field) to NodeB. Implemented in sendInitialRequest(). It is PKI-encrypted if NodeA already
// holds NodeB's key, otherwise channel-encrypted (the bootstrap case).
//
// 2. NodeB replies (allocReply()) with its own public key (hash1 field) and hash2. NodeB generates a
// random 6-digit security number and stashes NodeA's public key (as a pending key if not already in
// the nodeDB). It computes hash1 = SHA256(securityNumber, nonce, NodeA_num, NodeB_num, PK_A, PK_B),
// then hash2 = SHA256(nonce, hash1). The reply is PKI-encrypted only if NodeB already held NodeA's
// key; in the bootstrap case it is channel-encrypted so NodeA can read NodeB's key from hash1.
//
// 3. NodeA receives the reply (handleReceivedProtobuf()), checks the nonce, stashes NodeB's public key,
// and prompts the user to enter the security number. The security number is never sent over the mesh
// and must be communicated over a secondary channel. processSecurityNumber() recomputes hash1 from
// the entered number and verifies SHA256(nonce, hash1) matches the received hash2. NodeA then sends
// its hash1 back to NodeB in a PKI-encrypted KeyVerification message (the follow-on PKI packet) and
// shows the KeyVerificationFinalPrompt menu, displaying 8 characters derived from hash1.
//
// 4. NodeB receives NodeA's hash1 (handleReceivedProtobuf(); required to be PKI-encrypted), checks it
// matches the hash1 NodeB generated, and shows the same 8-character code for final confirmation.
//
// The final on-screen code comparison is the actual manual verification: the user confirms the codes
// match on both devices, proving the two nodes agree on the same public keys (no MitM substitution).
// PKI-encrypting the follow-on packet additionally proves each node holds the private key for the
// agreed public key.
class KeyVerificationModule : public ProtobufModule<meshtastic_KeyVerification> //, private concurrency::OSThread //
{
// CallbackObserver<KeyVerificationModule, const meshtastic::Status *> nodeStatusObserver =
@@ -29,6 +62,7 @@ class KeyVerificationModule : public ProtobufModule<meshtastic_KeyVerification>
bool sendInitialRequest(NodeNum remoteNode);
void generateVerificationCode(char *); // fills char with the user readable verification code
uint32_t getCurrentRemoteNode() { return currentRemoteNode; }
void commitVerifiedRemoteNode(); // Commit a pending key to NodeDB and mark the node manually verified
protected:
/* Called to handle a particular incoming message
@@ -58,8 +92,8 @@ class KeyVerificationModule : public ProtobufModule<meshtastic_KeyVerification>
char message[40] = {0};
void processSecurityNumber(uint32_t);
void updateState(); // check the timeouts and maybe reset the state to idle
void resetToIdle(); // Zero out module state
void updateState(bool resetTimer = true); // check the timeouts and maybe reset the state to idle
void resetToIdle(); // Zero out module state
};
extern KeyVerificationModule *keyVerificationModule;
+1 -1
View File
@@ -2,11 +2,11 @@
#include "Default.h"
#include "DisplayFormatters.h"
#include "NodeDB.h"
#include "RTC.h"
#include "RadioInterface.h"
#include "Router.h"
#include "TransmitHistory.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "main.h"
#include <Throttle.h>
#include <string.h>
+8
View File
@@ -19,6 +19,9 @@
#if !MESHTASTIC_EXCLUDE_CANNEDMESSAGES
#include "modules/CannedMessageModule.h"
#endif
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "modules/games/GamesModule.h"
#endif
#if !MESHTASTIC_EXCLUDE_DETECTIONSENSOR
#include "modules/DetectionSensorModule.h"
#endif
@@ -206,6 +209,11 @@ void setupModules()
cannedMessageModule = new CannedMessageModule();
}
#endif
#if HAS_SCREEN && BASEUI_HAS_GAMES
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
gamesModule = new GamesModule();
}
#endif
#if ARCH_PORTDUINO
new HostMetricsModule();
#endif
+1 -1
View File
@@ -2,7 +2,7 @@
#include "Default.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "RTC.h"
#include "gps/RTC.h"
#include <Throttle.h>
NeighborInfoModule *neighborInfoModule;
+1 -1
View File
@@ -3,10 +3,10 @@
#include "MeshService.h"
#include "NodeDB.h"
#include "NodeStatus.h"
#include "RTC.h"
#include "Router.h"
#include "TransmitHistory.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "main.h"
#include <Throttle.h>
#include <algorithm>
+1 -1
View File
@@ -5,13 +5,13 @@
#include "MeshService.h"
#include "NodeDB.h"
#include "PositionPrecision.h"
#include "RTC.h"
#include "Router.h"
#include "TransmitHistory.h"
#include "TypeConversions.h"
#include "airtime.h"
#include "configuration.h"
#include "gps/GeoCoord.h"
#include "gps/RTC.h"
#include "main.h"
#include "meshUtils.h"
#include "meshtastic/atak.pb.h"
+1 -1
View File
@@ -2,9 +2,9 @@
#include "MeshService.h"
#include "NodeDB.h"
#include "PowerMon.h"
#include "RTC.h"
#include "Router.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "main.h"
#include "sleep.h"
#include "target_specific.h"
+4 -4
View File
@@ -13,12 +13,12 @@
#include "MeshService.h"
#include "NodeDB.h"
#include "PowerFSM.h"
#include "RTC.h"
#include "Router.h"
#include "SPILock.h"
#include "airtime.h"
#include "configuration.h"
#include "gps/GeoCoord.h"
#include "gps/RTC.h"
#include <Arduino.h>
#include <Throttle.h>
@@ -154,7 +154,7 @@ ProcessMessage RangeTestModuleRadio::handleReceived(const meshtastic_MeshPacket
NodeInfoLite *n = nodeDB->getMeshNode(getFrom(&mp));
LOG_DEBUG("-----------------------------------------");
LOG_DEBUG("p.payload.bytes \"%s\"", p.payload.bytes);
LOG_DEBUG("p.payload.bytes \"%.*s\"", (int)p.payload.size, p.payload.bytes);
LOG_DEBUG("p.payload.size %d", p.payload.size);
LOG_DEBUG("---- Received Packet:");
LOG_DEBUG("mp.from %d", mp.from);
@@ -192,7 +192,7 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp)
meshtastic_NodeInfoLite *n = nodeDB->getMeshNode(getFrom(&mp));
/*
LOG_DEBUG("-----------------------------------------");
LOG_DEBUG("p.payload.bytes \"%s\"", p.payload.bytes);
LOG_DEBUG("p.payload.bytes \"%.*s\"", (int)p.payload.size, p.payload.bytes);
LOG_DEBUG("p.payload.size %d", p.payload.size);
LOG_DEBUG("---- Received Packet:");
LOG_DEBUG("mp.from %d", mp.from);
@@ -307,7 +307,7 @@ bool RangeTestModuleRadio::appendFile(const meshtastic_MeshPacket &mp)
fileToAppend.printf("%d,", mp.hop_limit); // Packet Hop Limit
// TODO: If quotes are found in the payload, it has to be escaped.
fileToAppend.printf("\"%s\"\n", p.payload.bytes);
fileToAppend.printf("\"%.*s\"\n", (int)p.payload.size, p.payload.bytes);
fileToAppend.printf("%i,", mp.rx_rssi); // RX RSSI
fileToAppend.flush();
+1 -1
View File
@@ -1,9 +1,9 @@
#include "RemoteHardwareModule.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "RTC.h"
#include "Router.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "main.h"
#include <Throttle.h>
+3 -3
View File
@@ -3,9 +3,9 @@
#include "MeshService.h"
#include "NMEAWPL.h"
#include "NodeDB.h"
#include "RTC.h"
#include "Router.h"
#include "configuration.h"
#include "gps/RTC.h"
#include <Arduino.h>
#include <Throttle.h>
@@ -396,7 +396,7 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp
lastRxID = mp.id;
// LOG_DEBUG("* * Message came this device");
// serialPrint->println("* * Message came this device");
serialPrint->printf("%s", p.payload.bytes);
serialPrint->printf("%.*s", (int)p.payload.size, p.payload.bytes);
}
}
} else {
@@ -408,7 +408,7 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(getFrom(&mp));
const char *sender = nodeInfoLiteHasUser(node) ? node->short_name : "???";
serialPrint->println();
serialPrint->printf("%s: %s", sender, p.payload.bytes);
serialPrint->printf("%s: %.*s", sender, (int)p.payload.size, p.payload.bytes);
serialPrint->println();
} else if ((moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_NMEA ||
moduleConfig.serial.mode == meshtastic_ModuleConfig_SerialConfig_Serial_Mode_CALTOPO) &&
+1 -1
View File
@@ -15,11 +15,11 @@
#include "StoreForwardModule.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "RTC.h"
#include "Router.h"
#include "Throttle.h"
#include "airtime.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "memGet.h"
#include "mesh-pb-constants.h"
#include "mesh/generated/meshtastic/storeforward.pb.h"
@@ -9,11 +9,11 @@
#include "MeshService.h"
#include "NodeDB.h"
#include "PowerFSM.h"
#include "RTC.h"
#include "Router.h"
#include "TransmitHistory.h"
#include "UnitConversions.h"
#include "detect/ScanI2CTwoWire.h"
#include "gps/RTC.h"
#include "graphics/ScreenFonts.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/images.h"
+1 -1
View File
@@ -4,11 +4,11 @@
#include "MeshService.h"
#include "NodeDB.h"
#include "PowerFSM.h"
#include "RTC.h"
#include "RadioLibInterface.h"
#include "Router.h"
#include "TransmitHistory.h"
#include "configuration.h"
#include "gps/RTC.h"
#include "main.h"
#include "memGet.h"
#include <OLEDDisplay.h>
@@ -9,11 +9,11 @@
#include "NodeDB.h"
#include "Power.h"
#include "PowerFSM.h"
#include "RTC.h"
#include "Router.h"
#include "TransmitHistory.h"
#include "UnitConversions.h"
#include "buzz.h"
#include "gps/RTC.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/images.h"
#include "main.h"
+1 -1
View File
@@ -9,10 +9,10 @@
#include "NodeDB.h"
#include "Power.h"
#include "PowerFSM.h"
#include "RTC.h"
#include "Router.h"
#include "TransmitHistory.h"
#include "UnitConversions.h"
#include "gps/RTC.h"
#include "main.h"
#include "sleep.h"
#include "target_specific.h"
+1 -1
View File
@@ -9,9 +9,9 @@
#include "Power.h"
#include "PowerFSM.h"
#include "PowerTelemetry.h"
#include "RTC.h"
#include "Router.h"
#include "TransmitHistory.h"
#include "gps/RTC.h"
#include "graphics/SharedUIDisplay.h"
#include "main.h"
#include "sleep.h"
@@ -4,8 +4,8 @@
#include "../detect/ReClockI2C.h"
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "RTC.h"
#include "TelemetrySensor.h"
#include "gps/RTC.h"
#define PMSA003I_I2C_CLOCK_SPEED 100000
#define PMSA003I_FRAME_LENGTH 32
+12 -2
View File
@@ -104,8 +104,18 @@ bool SCD4XSensor::getMetrics(meshtastic_Telemetry *measurement)
reClockI2C.setClock(SCD4X_I2C_CLOCK_SPEED);
#endif /* SCD4X_I2C_CLOCK_SPEED */
bool dataReady;
error = scd4x.getDataReadyStatus(dataReady);
bool dataReady = false;
uint8_t dataReadyTries = 0;
while (!dataReady && (dataReadyTries < SCD4X_MAX_RETRIES)) {
error = scd4x.getDataReadyStatus(dataReady);
if (error != SCD4X_NO_ERROR || !dataReady) {
LOG_WARN("%s: Error collecting data. Retrying", sensorName);
delay(100);
dataReadyTries++;
}
}
if (error != SCD4X_NO_ERROR || !dataReady) {
#ifdef SCD4X_I2C_CLOCK_SPEED
LOG_DEBUG("%s: restoring clock speed", sensorName);
+2 -1
View File
@@ -4,13 +4,14 @@
#include "../detect/ReClockI2C.h"
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "RTC.h"
#include "TelemetrySensor.h"
#include "gps/RTC.h"
#include <SensirionI2cScd4x.h>
// Max speed 400kHz
#define SCD4X_I2C_CLOCK_SPEED 400000
#define SCD4X_WARMUP_MS 5000
#define SCD4X_MAX_RETRIES 3
class SCD4XSensor : public TelemetrySensor
{
+1 -1
View File
@@ -4,9 +4,9 @@
#include "../detect/ReClockI2C.h"
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "RTC.h"
#include "TelemetrySensor.h"
#include "Wire.h"
#include "gps/RTC.h"
// Warm up times for SEN5X from the datasheet
#ifndef SEN5X_WARMUP_MS_1
+1 -1
View File
@@ -4,8 +4,8 @@
#include "../detect/ReClockI2C.h"
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "RTC.h"
#include "TelemetrySensor.h"
#include "gps/RTC.h"
#include <SensirionI2cSfa3x.h>
#define SFA30_I2C_CLOCK_SPEED 100000
+4 -2
View File
@@ -25,12 +25,14 @@ ProcessMessage TextMessageModule::handleReceived(const meshtastic_MeshPacket &mp
// Guard against running in MeshtasticUI or with no screen
if (config.display.displaymode != meshtastic_Config_DisplayConfig_DisplayMode_COLOR) {
// Store in the central message history
const StoredMessage &sm = messageStore.addFromPacket(mp);
const StoredMessage *sm = messageStore.tryAddFromPacket(mp);
if (!sm)
return ProcessMessage::CONTINUE;
// Pass message to renderer (banner + thread switching + scroll reset)
// Use the global Screen singleton to retrieve the current OLED display
auto *display = screen ? screen->getDisplayDevice() : nullptr;
graphics::MessageRenderer::handleNewMessage(display, sm, mp);
graphics::MessageRenderer::handleNewMessage(display, *sm, mp);
})
// Only trigger screen wake if configuration allows it
if (shouldWakeOnReceivedMessage()) {
+13 -2
View File
@@ -726,9 +726,16 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack
return ProcessMessage::CONTINUE;
}
// A known signer's NodeInfo arriving unsigned is unauthenticated (the sender is forgeable), so
// it must not drive any cache or identity write below. Computed once here (getMeshNode is O(N))
// and reused by both the cache-refresh and the direct-response identity path.
const bool isNodeInfo = mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP;
const meshtastic_NodeInfoLite *senderNode = isNodeInfo ? nodeDB->getMeshNode(getFrom(&mp)) : nullptr;
const bool unauthenticatedSigner = senderNode && nodeInfoLiteHasXeddsaSigned(senderNode) && !mp.xeddsa_signed;
// Learn NodeInfo payloads into the dedicated PSRAM cache, and refresh the tier-3
// role cache for any node we already track (keeps the dedup role exception current).
if (mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP) {
if (isNodeInfo && !unauthenticatedSigner) {
cacheNodeInfoPacket(mp);
updateCachedRoleFromNodeInfo(mp);
}
@@ -744,8 +751,12 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack
if (cfg.nodeinfo_direct_response_max_hops > 0 && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP &&
mp.decoded.want_response && !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) {
if (shouldRespondToNodeInfo(&mp, true)) {
// Unicast NodeInfo is never signed, so a known signer's identity claim here is
// unauthenticated: don't overwrite its stored name. The cached response is unaffected.
// unauthenticatedSigner was computed above (this branch is NodeInfo-only).
meshtastic_User requester = meshtastic_User_init_zero;
if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) {
if (!unauthenticatedSigner &&
pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) {
nodeDB->updateUser(getFrom(&mp), requester, mp.channel);
}
logAction("respond", &mp, "nodeinfo-cache");
+26 -21
View File
@@ -4,8 +4,8 @@
#include "FSCommon.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "RTC.h"
#include "Router.h"
#include "gps/RTC.h"
/*
AudioModule
@@ -68,23 +68,23 @@ void run_codec2(void *parameter)
}
if (audioModule->radio_state == RadioState::rx) {
size_t bytesOut = 0;
if (memcmp(audioModule->rx_encode_frame, &audioModule->tx_header, sizeof(audioModule->tx_header)) == 0) {
for (int i = 4; i < audioModule->rx_encode_frame_index; i += audioModule->encode_codec_size) {
// Reject frames not carrying our own header (magic + mode); an invalid mode byte
// would else NULL-deref via codec2_create. The bound keeps reads inside the buffer.
const int headerLen = sizeof(audioModule->tx_header);
const int frameSize = audioModule->encode_codec_size;
int limit = audioModule->rx_encode_frame_index;
if (limit > (int)sizeof(audioModule->rx_encode_frame))
limit = sizeof(audioModule->rx_encode_frame);
if (frameSize > 0 && limit >= headerLen &&
memcmp(audioModule->rx_encode_frame, &audioModule->tx_header, headerLen) == 0) {
for (int i = headerLen; i + frameSize <= limit; i += frameSize) {
codec2_decode(audioModule->codec2, audioModule->output_buffer, audioModule->rx_encode_frame + i);
i2s_write(I2S_PORT, &audioModule->output_buffer, audioModule->adc_buffer_size, &bytesOut,
pdMS_TO_TICKS(500));
// adc_buffer_size is a sample count; i2s_write wants bytes (int16_t samples).
i2s_write(I2S_PORT, &audioModule->output_buffer, audioModule->adc_buffer_size * sizeof(int16_t),
&bytesOut, pdMS_TO_TICKS(500));
}
} else {
// if the buffer header does not match our own codec, make a temp decoding setup.
CODEC2 *tmp_codec2 = codec2_create(audioModule->rx_encode_frame[3]);
codec2_set_lpc_post_filter(tmp_codec2, 1, 0, 0.8, 0.2);
int tmp_encode_codec_size = (codec2_bits_per_frame(tmp_codec2) + 7) / 8;
int tmp_adc_buffer_size = codec2_samples_per_frame(tmp_codec2);
for (int i = 4; i < audioModule->rx_encode_frame_index; i += tmp_encode_codec_size) {
codec2_decode(tmp_codec2, audioModule->output_buffer, audioModule->rx_encode_frame + i);
i2s_write(I2S_PORT, &audioModule->output_buffer, tmp_adc_buffer_size, &bytesOut, pdMS_TO_TICKS(500));
}
codec2_destroy(tmp_codec2);
LOG_WARN("Audio: dropping frame with mismatched or short codec2 header");
}
}
}
@@ -213,16 +213,18 @@ int32_t AudioModule::runOnce()
}
}
if (radio_state == RadioState::tx) {
// Get I2S data from the microphone and place in data buffer
// Get I2S data from the microphone and place in data buffer. adc_buffer_size is a
// sample count; i2s_read and adc_buffer_index are in bytes, so scale by int16_t.
size_t bytesIn = 0;
res = i2s_read(I2S_PORT, adc_buffer + adc_buffer_index, adc_buffer_size - adc_buffer_index, &bytesIn,
const size_t frameBytes = adc_buffer_size * sizeof(uint16_t);
res = i2s_read(I2S_PORT, (uint8_t *)adc_buffer + adc_buffer_index, frameBytes - adc_buffer_index, &bytesIn,
pdMS_TO_TICKS(40)); // wait 40ms for audio to arrive.
if (res == ESP_OK) {
adc_buffer_index += bytesIn;
if (adc_buffer_index == adc_buffer_size) {
if (adc_buffer_index >= frameBytes) {
adc_buffer_index = 0;
memcpy((void *)speech, (void *)adc_buffer, 2 * adc_buffer_size);
memcpy((void *)speech, (void *)adc_buffer, frameBytes);
// Notify run_codec2 task that the buffer is ready.
radio_state = RadioState::tx;
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
@@ -273,9 +275,12 @@ ProcessMessage AudioModule::handleReceived(const meshtastic_MeshPacket &mp)
if ((moduleConfig.audio.codec2_enabled) && (myRegion->profile->audioPermitted)) {
auto &p = mp.decoded;
if (!isFromUs(&mp)) {
memcpy(rx_encode_frame, p.payload.bytes, p.payload.size);
size_t n = p.payload.size;
if (n > sizeof(rx_encode_frame)) // bound a crafted payload to the RX buffer
n = sizeof(rx_encode_frame);
memcpy(rx_encode_frame, p.payload.bytes, n);
radio_state = RadioState::rx;
rx_encode_frame_index = p.payload.size;
rx_encode_frame_index = n;
// Notify run_codec2 task that the buffer is ready.
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
vTaskNotifyGiveFromISR(codec2HandlerTask, &xHigherPriorityTaskWoken);
+315
View File
@@ -0,0 +1,315 @@
#include "Breakout.h"
// ===========================================================================
// Pure BreakoutGame logic (no display/FS dependencies; always compiled)
// ===========================================================================
uint32_t BreakoutGame::nextRandom()
{
uint32_t x = rng;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
rng = x;
return x;
}
void BreakoutGame::buildBricks()
{
for (uint8_t r = 0; r < BRICK_ROWS; r++)
for (uint8_t c = 0; c < BRICK_COLS; c++)
bricks[r][c] = 1;
bricksLeft = static_cast<uint16_t>(BRICK_ROWS) * BRICK_COLS;
}
void BreakoutGame::serveBall()
{
// Centre the paddle and launch the ball upward from just above it, at a slight angle whose
// side is chosen randomly so successive serves are not identical.
paddleLeft = (BOARD_W - PADDLE_W) / 2;
ballPxX = static_cast<int32_t>(BOARD_W / 2) * SUBPX;
ballPxY = static_cast<int32_t>(PADDLE_Y - 2) * SUBPX;
ballVx = (nextRandom() & 1u) ? 28 : -28;
ballVy = -BALL_VY;
}
void BreakoutGame::nextLevel()
{
levelNum++;
buildBricks();
serveBall();
}
void BreakoutGame::reset(uint32_t seed)
{
rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0
points = 0;
livesLeft = START_LIVES;
levelNum = 1;
alive = true;
ballTick = false;
buildBricks();
serveBall();
}
void BreakoutGame::movePaddle(int16_t dxPx)
{
if (!alive)
return;
paddleLeft += dxPx;
if (paddleLeft < 0)
paddleLeft = 0;
else if (paddleLeft > BOARD_W - PADDLE_W)
paddleLeft = BOARD_W - PADDLE_W;
}
void BreakoutGame::moveLeft()
{
movePaddle(-PADDLE_STEP);
}
void BreakoutGame::moveRight()
{
movePaddle(PADDLE_STEP);
}
bool BreakoutGame::step()
{
if (!alive)
return false;
// The ball advances on every other step() so the caller can tick (and poll the paddle) at twice
// the ball's rate -- this keeps the ball speed constant while paddle control refreshes faster.
ballTick = !ballTick;
if (!ballTick)
return true;
ballPxX += ballVx;
ballPxY += ballVy;
int16_t px = static_cast<int16_t>(ballPxX / SUBPX);
int16_t py = static_cast<int16_t>(ballPxY / SUBPX);
// Side walls.
if (px <= 0) {
ballPxX = 0;
px = 0;
ballVx = -ballVx;
} else if (px >= BOARD_W - 1) {
ballPxX = static_cast<int32_t>(BOARD_W - 1) * SUBPX;
px = BOARD_W - 1;
ballVx = -ballVx;
}
// Top wall.
if (py <= 0) {
ballPxY = 0;
py = 0;
ballVy = -ballVy;
}
// Bricks: at most one brick per step (single, blocky reflection off the bottom/top face).
if (py >= BRICK_TOP && py < BRICK_TOP + BRICK_ROWS * BRICK_H) {
const int r = (py - BRICK_TOP) / BRICK_H;
const int c = px / BRICK_W;
if (r >= 0 && r < BRICK_ROWS && c >= 0 && c < BRICK_COLS && bricks[r][c]) {
bricks[r][c] = 0;
bricksLeft--;
points += POINTS_PER_BRICK;
ballVy = -ballVy;
if (bricksLeft == 0) {
nextLevel();
return true;
}
}
}
// Paddle: bounce up and steer horizontally based on where the ball struck.
if (ballVy > 0 && py >= PADDLE_Y - 1 && py <= PADDLE_Y + PADDLE_H) {
if (px >= paddleLeft && px < paddleLeft + PADDLE_W) {
ballPxY = static_cast<int32_t>(PADDLE_Y - 1) * SUBPX;
ballVy = -BALL_VY;
// Six zones across the paddle map to increasing outward angles; no zone is vertical.
static const int16_t vxByZone[6] = {-48, -28, -8, 8, 28, 48};
int zone = ((px - paddleLeft) * 6) / PADDLE_W;
if (zone < 0)
zone = 0;
else if (zone > 5)
zone = 5;
ballVx = vxByZone[zone];
}
}
// Ball lost past the bottom edge.
if (py >= BOARD_H) {
if (livesLeft > 0)
livesLeft--;
if (livesLeft == 0) {
alive = false;
return false;
}
serveBall();
}
return alive;
}
// ===========================================================================
// Breakout adapter (display + persistence; BaseUI games build only)
// ===========================================================================
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "main.h"
#if ARCH_PORTDUINO && defined(__linux__)
#include "input/LinuxJoystick.h"
#endif
// Paddle pixels moved per tick while a joystick direction is held (continuous polling path).
static constexpr int16_t PADDLE_POLL_STEP = 3;
#if GRAPHICS_TFT_COLORING_ENABLED
// Classic Breakout brick-wall colours, top row to bottom.
static uint16_t brickRowColor(uint8_t row)
{
using namespace graphics;
switch (row) {
case 0:
return TFTPalette::Red;
case 1:
return TFTPalette::Orange;
case 2:
return TFTPalette::Yellow;
case 3:
return TFTPalette::Green;
default:
return TFTPalette::Cyan;
}
}
#endif
Breakout::Breakout()
{
scores_.load();
}
int32_t Breakout::tickIntervalMs() const
{
// Tick at twice the ball's cadence: the ball advances every other step() (BreakoutGame::step),
// so halving the interval keeps the ball speed the same while the paddle is polled/redrawn twice
// as often. Speed ramps with level: ~22 ms base, floor 10 ms.
int32_t iv = 45 - static_cast<int32_t>(game.level() - 1) * 5;
if (iv < 20)
iv = 20;
return iv / 2;
}
bool Breakout::tick()
{
#if ARCH_PORTDUINO && defined(__linux__)
// Poll the joystick's held direction directly so the paddle glides continuously instead of
// creeping along at the D-pad's slow auto-repeat rate.
if (aLinuxJoystick) {
const int held = aLinuxJoystick->heldXZone();
if (held < 0)
game.movePaddle(-PADDLE_POLL_STEP);
else if (held > 0)
game.movePaddle(PADDLE_POLL_STEP);
}
#endif
return game.step();
}
void Breakout::handleInput(input_broker_event ev)
{
#if ARCH_PORTDUINO && defined(__linux__)
// When a joystick is present the paddle is polled continuously in tick(); ignore the discrete
// (and slow) repeat events so we don't double-move.
if (aLinuxJoystick)
return;
#endif
switch (ev) {
case INPUT_BROKER_LEFT:
game.moveLeft();
break;
case INPUT_BROKER_RIGHT:
game.moveRight();
break;
default:
break;
}
}
void Breakout::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
const int16_t w = display->getWidth();
const int16_t cx = x + w / 2;
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(cx, y, "B R E A K O U T");
const int16_t logoX = x + (w - breakout_width) / 2;
const int16_t logoY = y + 15;
display->drawXbm(logoX, logoY, breakout_width, breakout_height, breakout);
#if GRAPHICS_TFT_COLORING_ENABLED
// The glyph is three brick courses, a ball, and a paddle -- colour each to match the game.
const uint16_t abg = graphics::getThemeBodyBg();
graphics::registerTFTColorRegionDirect(logoX, logoY + 1, breakout_width, 2, graphics::TFTPalette::Red, abg);
graphics::registerTFTColorRegionDirect(logoX, logoY + 4, breakout_width, 2, graphics::TFTPalette::Yellow, abg);
graphics::registerTFTColorRegionDirect(logoX, logoY + 7, breakout_width, 2, graphics::TFTPalette::Green, abg);
graphics::registerTFTColorRegionDirect(logoX + 4, logoY + 14, 8, 2, graphics::TFTPalette::Blue, abg); // paddle
graphics::registerTFTColorRegionDirect(logoX + 7, logoY + 10, 2, 2, graphics::TFTPalette::White, abg); // ball
#endif
char hi[32];
if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast<unsigned long>(scores_.scoreAt(0)));
else
snprintf(hi, sizeof(hi), "High: %lu", static_cast<unsigned long>(scores_.scoreAt(0)));
display->drawString(cx, y + 34, hi);
}
void Breakout::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
display->setFont(FONT_SMALL);
// Score row (top-left), remaining lives as small squares (top-right).
char buf[16];
display->setTextAlignment(TEXT_ALIGN_LEFT);
snprintf(buf, sizeof(buf), "Sc %lu", static_cast<unsigned long>(game.score()));
display->drawString(x + 2, y, buf);
for (uint8_t i = 0; i < game.lives(); i++)
display->fillRect(x + display->getWidth() - 3 - i * 4, y + 2, 2, 2);
// Bricks.
for (uint8_t r = 0; r < BreakoutGame::BRICK_ROWS; r++)
for (uint8_t c = 0; c < BreakoutGame::BRICK_COLS; c++)
if (game.brickAt(r, c))
display->fillRect(x + c * BreakoutGame::BRICK_W, y + BreakoutGame::BRICK_TOP + r * BreakoutGame::BRICK_H,
BreakoutGame::BRICK_W - 1, BreakoutGame::BRICK_H - 1);
// Paddle.
display->fillRect(x + game.paddleX(), y + BreakoutGame::PADDLE_Y, BreakoutGame::PADDLE_W, BreakoutGame::PADDLE_H);
// Ball.
display->fillRect(x + game.ballX(), y + game.ballY(), 2, 2);
#if GRAPHICS_TFT_COLORING_ENABLED
// Colour the wall by row, plus a blue paddle and white ball. One region per brick row (the row's
// lit bricks take the colour; cleared cells and gaps stay background). Paddle then ball register
// last so the ball always wins where it overlaps.
const uint16_t bg = graphics::getThemeBodyBg();
for (uint8_t r = 0; r < BreakoutGame::BRICK_ROWS; r++)
graphics::registerTFTColorRegionDirect(x, y + BreakoutGame::BRICK_TOP + r * BreakoutGame::BRICK_H, BreakoutGame::BOARD_W,
BreakoutGame::BRICK_H - 1, brickRowColor(r), bg);
graphics::registerTFTColorRegionDirect(x + game.paddleX(), y + BreakoutGame::PADDLE_Y, BreakoutGame::PADDLE_W,
BreakoutGame::PADDLE_H, graphics::TFTPalette::Blue, bg);
graphics::registerTFTColorRegionDirect(x + game.ballX(), y + game.ballY(), 2, 2, graphics::TFTPalette::White, bg);
#endif
}
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+138
View File
@@ -0,0 +1,138 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
/**
* Pure, self-contained Breakout game logic.
*
* No Arduino/display dependencies - designed to be unit-tested natively (see test/test_breakout)
* and reused by the Breakout adapter below without pulling in the display stack.
*
* The board is a fixed BOARD_W x BOARD_H pixel field. A grid of bricks sits near the top, a paddle
* slides along the bottom, and a ball bounces between them. Ball position/velocity are tracked in
* fixed-point sub-pixels (SUBPX per pixel) so movement is smooth and deterministic; everything is
* integer math and statically sized (no heap).
*/
class BreakoutGame
{
public:
// Playfield, in pixels (a standard 128x64 OLED in landscape).
static constexpr int16_t BOARD_W = 128;
static constexpr int16_t BOARD_H = 64;
// Brick grid.
static constexpr uint8_t BRICK_COLS = 8;
static constexpr uint8_t BRICK_ROWS = 5;
static constexpr int16_t BRICK_W = BOARD_W / BRICK_COLS; // 16
static constexpr int16_t BRICK_H = 4;
static constexpr int16_t BRICK_TOP = 12; // top of the first course; leaves room for the score row
// Paddle.
static constexpr int16_t PADDLE_W = 24;
static constexpr int16_t PADDLE_H = 2;
static constexpr int16_t PADDLE_Y = BOARD_H - 3; // top edge of the paddle
static constexpr int16_t PADDLE_STEP = 6; // pixels moved per input event
static constexpr uint8_t START_LIVES = 3;
static constexpr uint8_t POINTS_PER_BRICK = 10;
/** (Re)start a full game: rebuild bricks, reset lives/score, serve the ball. `seed` drives the
* xorshift32 RNG used for the initial serve direction. */
void reset(uint32_t seed);
/** Advance the simulation by one tick (move the ball, resolve collisions). Returns true if the
* game is still in progress, false once the last life is lost. No-op returning false once dead. */
bool step();
/** Slide the paddle; the ball is unaffected. moveLeft/moveRight use the default per-press step;
* movePaddle takes an explicit signed pixel delta (used when polling a held joystick). */
void movePaddle(int16_t dxPx);
void moveLeft();
void moveRight();
bool isPlaying() const { return alive; }
uint32_t score() const { return points; }
uint8_t lives() const { return livesLeft; }
uint8_t level() const { return levelNum; }
uint16_t bricksRemaining() const { return bricksLeft; }
// --- Rendering accessors (all in board pixels) ---
int16_t paddleX() const { return paddleLeft; }
int16_t ballX() const { return static_cast<int16_t>(ballPxX / SUBPX); }
int16_t ballY() const { return static_cast<int16_t>(ballPxY / SUBPX); }
bool brickAt(uint8_t row, uint8_t col) const { return row < BRICK_ROWS && col < BRICK_COLS && bricks[row][col]; }
private:
static constexpr int32_t SUBPX = 16; // fixed-point sub-pixels per pixel
static constexpr int32_t BALL_VY = 40; // vertical ball speed (sub-pixels/step)
static constexpr int16_t BALL_SIZE = 2; // ball is drawn BALL_SIZE x BALL_SIZE
void buildBricks();
void serveBall();
void nextLevel();
uint32_t nextRandom();
uint8_t bricks[BRICK_ROWS][BRICK_COLS] = {0}; // 0 == cleared, 1 == present
uint16_t bricksLeft = 0;
int16_t paddleLeft = 0; // left edge of the paddle, in pixels
int32_t ballPxX = 0, ballPxY = 0; // ball centre, in sub-pixels
int32_t ballVx = 0, ballVy = 0; // ball velocity, in sub-pixels/step
uint32_t points = 0;
uint8_t livesLeft = 0;
uint8_t levelNum = 1;
uint32_t rng = 1; // xorshift32 state (never 0)
bool alive = false;
bool ballTick = false; // ball advances on every other step() (see step())
};
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Game.h"
#include "HighScoreTable.h"
/**
* Breakout as a hosted Game. Wraps the pure BreakoutGame logic above and supplies the attract art,
* the playfield renderer, the paddle input, the level-based speed curve, and its own local
* high-score table. No mesh protocol (scores are local-only).
*/
class Breakout : public Game
{
public:
Breakout();
const char *name() const override { return "Breakout"; }
void start(uint32_t seed) override { game.reset(seed); }
bool tick() override; // polls a held joystick for the paddle, then advances the ball
bool isPlaying() const override { return game.isPlaying(); }
uint32_t score() const override { return game.score(); }
int32_t tickIntervalMs() const override;
void handleInput(input_broker_event ev) override;
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
HighScoreTableBase &scores() override { return scores_; }
private:
// On-disk high-score record. New file (magic 'BRKT', version 1); layout mirrors Snake's.
struct BreakoutEntry {
uint32_t score;
uint32_t nodeNum;
char shortName[5]; // three characters, NUL-terminated
uint32_t epoch; // getValidTime(), 0 if no RTC
} __attribute__((packed));
BreakoutGame game;
HighScoreTable<BreakoutEntry> scores_{"/prefs/breakout.dat", 0x424B5254u, 1, "Breakout"};
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+303
View File
@@ -0,0 +1,303 @@
#include "ChirpyRunner.h"
// ===========================================================================
// Pure ChirpyRunnerGame logic (no display/FS dependencies; always compiled)
// ===========================================================================
uint32_t ChirpyRunnerGame::nextRandom()
{
uint32_t x = rng;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
rng = x;
return x;
}
int16_t ChirpyRunnerGame::pickGapSteps()
{
return static_cast<int16_t>(GAP_STEPS_MIN + static_cast<int16_t>(nextRandom() % (GAP_STEPS_MAX - GAP_STEPS_MIN + 1)));
}
void ChirpyRunnerGame::resetClouds()
{
// Spread the clouds across the sky at staggered x, at varied heights near the top.
for (uint8_t i = 0; i < CLOUD_COUNT; i++) {
cloud[i].xSub = static_cast<int32_t>(i * (BOARD_W / CLOUD_COUNT) + 6) * SUBPX;
cloud[i].y = static_cast<int16_t>(10 + nextRandom() % 10u); // 10..19 (below the score row)
}
}
void ChirpyRunnerGame::scrollClouds()
{
// Slow parallax drift; wrap back to the right (at a fresh height) once off the left edge.
for (uint8_t i = 0; i < CLOUD_COUNT; i++) {
cloud[i].xSub -= CLOUD_SPEED_SUB;
if (cloud[i].xSub / SUBPX + CLOUD_W < 0) {
cloud[i].xSub = static_cast<int32_t>(BOARD_W + nextRandom() % 24u) * SUBPX;
cloud[i].y = static_cast<int16_t>(10 + nextRandom() % 10u);
}
}
}
void ChirpyRunnerGame::spawnObstacle()
{
for (uint8_t i = 0; i < MAX_OBSTACLES; i++) {
if (obst[i].active)
continue;
obst[i].active = true;
obst[i].scored = false;
obst[i].xSub = static_cast<int32_t>(BOARD_W) * SUBPX;
obst[i].w = OBST_W;
// Three height tiers so timing varies (kept clearable with margin for a forgiving jump).
const uint32_t tier = nextRandom() % 3u;
obst[i].h = BUILDING_HEIGHTS[tier];
obst[i].colorIdx = static_cast<uint8_t>((spawnCount / 10u) % OBST_COLOR_COUNT);
spawnCount++;
return;
}
}
void ChirpyRunnerGame::reset(uint32_t seed)
{
rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0
points = 0;
alive = true;
chirpyTop = groundedTopSub();
vy = 0;
jumpGravity = GRAVITY;
grounded = true;
for (uint8_t i = 0; i < MAX_OBSTACLES; i++)
obst[i] = {};
speedSub = SPEED_BASE;
spawnTimer = 0; // first obstacle spawns on the first step
spawnCount = 0;
resetClouds();
}
int32_t ChirpyRunnerGame::jumpScaleQ8() const
{
// ratio = current scroll speed / base scroll speed, in Q8 (256 == 1.0).
const int32_t ratioQ8 = (speedSub * 256) / SPEED_BASE;
// Feed only JUMP_SPEEDUP_PCT of the speed increase into the jump scale: k = 1 + (ratio-1)*pct.
const int32_t kQ8 = 256 + ((ratioQ8 - 256) * JUMP_SPEEDUP_PCT) / 100;
return kQ8 < 256 ? 256 : kQ8; // never slower than the base hop
}
void ChirpyRunnerGame::jump()
{
if (!alive || !grounded)
return;
// Scale velocity by k and gravity by k*k: the apex height is unchanged (Chirpy still clears the
// same buildings) but the air-time shrinks by k, so the clearing window tightens with the speed.
const int32_t kQ8 = jumpScaleQ8();
vy = -(JUMP_V * kQ8) / 256;
jumpGravity = (GRAVITY * kQ8 * kQ8) / (256 * 256);
if (jumpGravity < GRAVITY)
jumpGravity = GRAVITY;
grounded = false;
}
bool ChirpyRunnerGame::step()
{
if (!alive)
return false;
scrollClouds(); // decorative background parallax
// --- Chirpy vertical motion (gravity latched at launch, so the arc stays consistent mid-air) ---
vy += jumpGravity;
chirpyTop += vy;
const int32_t gt = groundedTopSub();
if (chirpyTop >= gt) {
chirpyTop = gt;
vy = 0;
grounded = true;
} else {
grounded = false;
}
// --- Scroll obstacles, score, retire off-screen ones ---
for (uint8_t i = 0; i < MAX_OBSTACLES; i++) {
if (!obst[i].active)
continue;
obst[i].xSub -= speedSub;
const int16_t ox = obstacleX(i);
if (!obst[i].scored && ox + obst[i].w < CHIRPY_X) {
obst[i].scored = true;
points++;
}
if (ox + obst[i].w < 0)
obst[i].active = false;
}
// --- Spawn on a tick timer (time-based spacing that scales with speed) ---
if (spawnTimer > 0)
spawnTimer--;
if (spawnTimer <= 0) {
spawnObstacle();
spawnTimer = pickGapSteps();
}
// --- Difficulty ramp (scroll speed grows with score, then caps) ---
const uint32_t capped = points < SPEED_CAP_PTS ? points : SPEED_CAP_PTS;
speedSub = SPEED_BASE + static_cast<int32_t>(capped) * SPEED_INC;
// --- Collision (forgiving hitbox: skip the antenna, inset the sides) ---
const int16_t hx = CHIRPY_X + 2;
const int16_t hxr = hx + (CHIRPY_W - 4);
const int16_t hBottom = chirpyY() + CHIRPY_H;
const int16_t hTop = chirpyY() + 4;
for (uint8_t i = 0; i < MAX_OBSTACLES; i++) {
if (!obst[i].active)
continue;
const int16_t ox = obstacleX(i);
const int16_t oxr = ox + obst[i].w;
const int16_t oTop = GROUND_Y - obst[i].h;
if (hx < oxr && hxr > ox && hTop < GROUND_Y && hBottom > oTop) {
alive = false;
return false;
}
}
return alive;
}
// ===========================================================================
// ChirpyRunner adapter (display + persistence; BaseUI games build only)
// ===========================================================================
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "main.h"
ChirpyRunner::ChirpyRunner()
{
scores_.load();
}
void ChirpyRunner::handleInput(input_broker_event ev)
{
// SELECT is the jump (as requested); UP is accepted as a convenient alternate.
if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_SELECT_LONG || ev == INPUT_BROKER_UP)
game.jump();
}
void ChirpyRunner::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
const int16_t w = display->getWidth();
const int16_t cx = x + w / 2;
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(cx, y, "CHIRPY DASH");
const int16_t logoX = x + (w - chirpy_run_width) / 2;
const int16_t logoY = y + 15;
display->drawXbm(logoX, logoY, chirpy_run_width, chirpy_run_height, chirpy_run);
#if GRAPHICS_TFT_COLORING_ENABLED
// Chirpy is green, with white eyes. The eyes are the lit pixels at rows 5-7, cols 4-7 of the
// glyph; a white region registered after the green one wins there.
graphics::registerTFTColorRegionDirect(logoX, logoY, chirpy_run_width, chirpy_run_height,
graphics::TFTPalette::MeshtasticGreen, graphics::getThemeBodyBg());
graphics::registerTFTColorRegionDirect(logoX + 4, logoY + 5, 4, 3, graphics::TFTPalette::White, graphics::getThemeBodyBg());
#endif
char hi[32];
if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast<unsigned long>(scores_.scoreAt(0)));
else
snprintf(hi, sizeof(hi), "High: %lu", static_cast<unsigned long>(scores_.scoreAt(0)));
display->drawString(cx, y + 34, hi);
}
#if GRAPHICS_TFT_COLORING_ENABLED
// Obstacle colour palette; the game logic advances the index every 10 spawns.
static uint16_t obstacleColor(uint8_t idx)
{
using namespace graphics;
switch (idx) {
case 0:
return TFTPalette::Red;
case 1:
return TFTPalette::Orange;
case 2:
return TFTPalette::Yellow;
case 3:
return TFTPalette::Magenta;
case 4:
return TFTPalette::Cyan;
default:
return TFTPalette::Blue;
}
}
#endif
void ChirpyRunner::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
display->setFont(FONT_SMALL);
// Clouds drifting in the background (drawn first so everything else sits in front).
for (uint8_t i = 0; i < ChirpyRunnerGame::cloudSlots(); i++) {
const int16_t cxp = x + game.cloudX(i);
const int16_t cyp = y + game.cloudY(i);
display->fillRect(cxp + 2, cyp, 4, 1);
display->fillRect(cxp + 1, cyp + 1, 6, 1);
display->fillRect(cxp, cyp + 2, 8, 1);
#if GRAPHICS_TFT_COLORING_ENABLED
graphics::registerTFTColorRegionDirect(cxp, cyp, 8, 3, graphics::TFTPalette::LightGray, graphics::getThemeBodyBg());
#endif
}
// Score (top-left).
char buf[16];
display->setTextAlignment(TEXT_ALIGN_LEFT);
snprintf(buf, sizeof(buf), "Sc %lu", static_cast<unsigned long>(game.score()));
display->drawString(x + 2, y, buf);
// Ground line.
display->drawLine(x, y + ChirpyRunnerGame::GROUND_Y, x + display->getWidth() - 1, y + ChirpyRunnerGame::GROUND_Y);
// Obstacles drawn as little buildings: a solid tower with two columns of punched-out windows
// (dark holes). On colour displays the walls cycle colour every 10 spawns and the windows glow
// (they are the region's off-pixels, so they take the off-colour).
for (uint8_t i = 0; i < ChirpyRunnerGame::obstacleSlots(); i++) {
if (!game.obstacleActive(i))
continue;
const int16_t oh = game.obstacleH(i);
const int16_t ow = game.obstacleW(i);
const int16_t oxp = x + game.obstacleX(i);
const int16_t oyp = y + ChirpyRunnerGame::GROUND_Y - oh;
display->setColor(WHITE);
display->fillRect(oxp, oyp, ow, oh);
// Windows: 1px holes in the left and right columns, every other row, skipping the roof row
// and the ground-floor rows so the tower reads as a building.
display->setColor(BLACK);
for (int16_t wy = oyp + 2; wy <= oyp + oh - 3; wy += 2) {
display->fillRect(oxp + 1, wy, 1, 1);
display->fillRect(oxp + ow - 2, wy, 1, 1);
}
display->setColor(WHITE);
#if GRAPHICS_TFT_COLORING_ENABLED
graphics::registerTFTColorRegionDirect(oxp, oyp, ow, oh, obstacleColor(game.obstacleColorIndex(i)),
graphics::TFTPalette::White); // lit windows
#endif
}
// Chirpy.
const int16_t cxp = x + ChirpyRunnerGame::CHIRPY_X;
const int16_t cyp = y + game.chirpyY();
display->drawXbm(cxp, cyp, chirpy_run_width, chirpy_run_height, chirpy_run);
#if GRAPHICS_TFT_COLORING_ENABLED
graphics::registerTFTColorRegionDirect(cxp, cyp, chirpy_run_width, chirpy_run_height, graphics::TFTPalette::MeshtasticGreen,
graphics::getThemeBodyBg());
graphics::registerTFTColorRegionDirect(cxp + 4, cyp + 5, 4, 3, graphics::TFTPalette::White, graphics::getThemeBodyBg());
#endif
}
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+177
View File
@@ -0,0 +1,177 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
/**
* Pure, self-contained Chirpy Runner game logic (a dino-runner / flappy-style side-scroller).
*
* No Arduino/display dependencies - designed to be unit-tested natively (see test/test_chirpy)
* and reused by the ChirpyRunner adapter below without pulling in the display stack.
*
* Chirpy runs in place at a fixed x on the ground; obstacles scroll in from the right and the
* player presses jump (SELECT) to hop over them. Gravity pulls Chirpy back down. Clearing an
* obstacle scores a point and nudges the scroll speed up. A collision ends the run. Chirpy's
* vertical motion is tracked in fixed-point sub-pixels (SUBPX per pixel); everything is integer
* math and statically sized (no heap).
*/
class ChirpyRunnerGame
{
public:
// Playfield, in pixels (a standard 128x64 OLED in landscape).
static constexpr int16_t BOARD_W = 128;
static constexpr int16_t BOARD_H = 64;
// Chirpy sprite box and fixed horizontal position; GROUND_Y is where his feet rest.
static constexpr int16_t CHIRPY_W = 12;
static constexpr int16_t CHIRPY_H = 16;
static constexpr int16_t CHIRPY_X = 14;
static constexpr int16_t GROUND_Y = BOARD_H - 2; // 62
static constexpr uint8_t MAX_OBSTACLES = 4;
static constexpr int16_t OBST_W = 6;
static constexpr uint8_t OBST_COLOR_COUNT = 6; // obstacle colour advances every 10 spawns
static constexpr uint8_t CLOUD_COUNT = 3; // decorative background clouds
/** (Re)start a run: Chirpy on the ground, no obstacles yet, score 0. `seed` drives the
* xorshift32 RNG used for obstacle heights and spacing. */
void reset(uint32_t seed);
/** Advance one tick (gravity + scroll + spawn + collision). Returns true while the run is in
* progress, false once Chirpy hits an obstacle. No-op returning false once dead. */
bool step();
/** Hop, if Chirpy is on the ground (single jump; ignored while airborne). */
void jump();
bool isPlaying() const { return alive; }
uint32_t score() const { return points; }
bool onGround() const { return grounded; }
// --- Rendering accessors (board pixels) ---
int16_t chirpyY() const { return static_cast<int16_t>(chirpyTop / SUBPX); } // sprite top
static constexpr uint8_t obstacleSlots() { return MAX_OBSTACLES; }
bool obstacleActive(uint8_t i) const { return i < MAX_OBSTACLES && obst[i].active; }
int16_t obstacleX(uint8_t i) const { return static_cast<int16_t>(obst[i].xSub / SUBPX); }
uint8_t obstacleW(uint8_t i) const { return obst[i].w; }
uint8_t obstacleH(uint8_t i) const { return obst[i].h; }
uint8_t obstacleColorIndex(uint8_t i) const { return obst[i].colorIdx; }
static constexpr uint8_t cloudSlots() { return CLOUD_COUNT; }
int16_t cloudX(uint8_t i) const { return static_cast<int16_t>(cloud[i].xSub / SUBPX); }
int16_t cloudY(uint8_t i) const { return cloud[i].y; }
private:
static constexpr int32_t SUBPX = 16; // fixed-point sub-pixels per pixel
static constexpr int32_t GRAVITY = 7; // downward accel (sub-px/step^2)
static constexpr int32_t JUMP_V = 75; // initial upward velocity on a hop (sub-px/step)
static constexpr int32_t SPEED_BASE = 32; // base scroll speed (sub-px/step == 2 px)
static constexpr int32_t SPEED_INC = 2; // speed added per point scored
static constexpr uint32_t SPEED_CAP_PTS = 40; // score at which the speed ramp caps (== 5 px/step)
// Obstacle spacing is measured in TICKS, not pixels, so the time between obstacles stays
// constant as the scroll speed ramps up -- otherwise a fixed pixel gap collapses into fewer
// ticks at high speed until it drops below the jump duration and clearing becomes impossible.
// The min is kept just above the ~22-tick jump so obstacles come in a tight but landable rhythm.
static constexpr int16_t GAP_STEPS_MIN = 23; // min ticks between spawns (> jump duration)
static constexpr int16_t GAP_STEPS_MAX = 40; // max ticks between spawns
// Chirpy's hop scales with the scroll speed. A fixed-length jump actually makes the game EASIER
// as the buildings speed up: his "high enough to clear" window stays ~constant while the time a
// building spends crossing his x-range shrinks (width / speed). Scaling the launch velocity by k
// and gravity by k*k keeps the apex height identical (so he still clears the same buildings)
// while cutting the air-time by k, which shrinks the clearing window in step with the world.
// JUMP_SPEEDUP_PCT is how much of the scroll-speed increase feeds into k:
// 100 == fully proportional (difficulty stays flat), lower == Chirpy speeds up sub-linearly
// (so it still eases off slightly at high speed), higher == it gets harder.
static constexpr int32_t JUMP_SPEEDUP_PCT = 50;
static constexpr int8_t BUILDING_HEIGHTS[] = {8, 12, 16}; // three tiers of obstacle heights (pixels)
static constexpr int32_t CLOUD_SPEED_SUB = 6; // background scroll speed (sub-px/step, slow parallax)
static constexpr int16_t CLOUD_W = 8; // cloud puff width (for off-screen wrap)
struct Obstacle {
int32_t xSub; // left edge, sub-pixels
uint8_t w;
uint8_t h;
uint8_t colorIdx; // which colour batch (advances every 10 spawns)
bool active;
bool scored;
};
struct Cloud {
int32_t xSub; // left edge, sub-pixels
int16_t y; // top, pixels
};
int32_t groundedTopSub() const { return static_cast<int32_t>(GROUND_Y - CHIRPY_H) * SUBPX; }
// Jump scale factor k for the current scroll speed, in Q8 fixed point (256 == 1.0 == base speed).
int32_t jumpScaleQ8() const;
uint32_t nextRandom();
void spawnObstacle();
int16_t pickGapSteps();
void resetClouds();
void scrollClouds();
int32_t chirpyTop = 0; // sprite-top y, sub-pixels
int32_t vy = 0; // vertical velocity, sub-pixels/step
int32_t jumpGravity = GRAVITY; // gravity for the current hop (latched at launch, scaled by k*k)
bool grounded = true;
Obstacle obst[MAX_OBSTACLES] = {};
Cloud cloud[CLOUD_COUNT] = {};
int32_t speedSub = SPEED_BASE;
int16_t spawnTimer = 0; // ticks until the next obstacle spawns
uint32_t spawnCount = 0; // total obstacles spawned (drives the colour cycle)
uint32_t points = 0;
uint32_t rng = 1; // xorshift32 state (never 0)
bool alive = false;
};
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Game.h"
#include "HighScoreTable.h"
/**
* Chirpy Runner as a hosted Game. Wraps the pure logic above and supplies the attract art, the
* side-scroller renderer (Chirpy sprite + obstacles + ground), the jump input, and its own
* local high-score table. No mesh protocol (scores are local-only).
*/
class ChirpyRunner : public Game
{
public:
ChirpyRunner();
const char *name() const override { return "Chirpy Dash"; }
void start(uint32_t seed) override { game.reset(seed); }
bool tick() override { return game.step(); }
bool isPlaying() const override { return game.isPlaying(); }
uint32_t score() const override { return game.score(); }
int32_t tickIntervalMs() const override { return 33; } // ~30 fps; difficulty ramps via scroll speed
void handleInput(input_broker_event ev) override;
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
HighScoreTableBase &scores() override { return scores_; }
private:
// On-disk high-score record. New file (magic 'CHRP', version 1); layout mirrors Snake's.
struct ChirpyEntry {
uint32_t score;
uint32_t nodeNum;
char shortName[5]; // three characters, NUL-terminated
uint32_t epoch; // getValidTime(), 0 if no RTC
} __attribute__((packed));
ChirpyRunnerGame game;
HighScoreTable<ChirpyEntry> scores_{"/prefs/chirpy.dat", 0x43485250u, 1, "Chirpy"};
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "HighScoreTable.h"
#include "input/InputBroker.h" // input_broker_event
#include "mesh/MeshModule.h" // ProcessMessage, meshtastic_MeshPacket
#include <cstdint>
class OLEDDisplay;
class OLEDDisplayUiState;
class GamesModule;
/**
* A single game hosted by GamesModule. The host owns the shared UI state machine (attract /
* playing / paused / game-over / high-scores), the initials picker, high-score persistence calls,
* the tick thread, and the mesh port; each Game supplies only the game-specific pieces: its
* attract art, its playfield, its per-key input while playing, its speed curve, and its own
* high-score table (and, optionally, a mesh announce/receive protocol).
*/
class Game
{
public:
virtual ~Game() = default;
virtual const char *name() const = 0;
// --- Lifecycle ---
virtual void start(uint32_t seed) = 0; // (re)start the underlying game logic
virtual bool tick() = 0; // advance one step; returns isPlaying() afterwards
virtual bool isPlaying() const = 0;
virtual uint32_t score() const = 0;
virtual int32_t tickIntervalMs() const = 0; // per-game speed curve
// --- Input while PLAYING (the host handles the BACK-to-pause and menu keys) ---
virtual void handleInput(input_broker_event ev) = 0;
// --- Rendering (the host draws the shared PAUSED / GAME OVER / HIGH SCORES chrome) ---
virtual void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) = 0; // title/art + hi + hint
virtual void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) = 0; // playfield only
virtual const char *gameOverHint() const { return "SELECT: scores"; }
// --- High scores (the host runs the initials picker + save) ---
virtual HighScoreTableBase &scores() = 0;
// --- Mesh (defaults are no-ops; only games with a wire protocol override these) ---
// Note: the new-high-score announcement is NOT here -- it is shared by every game and lives in
// GamesModule (which splices name() into one common message).
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) { return ProcessMessage::CONTINUE; }
virtual bool wantsPeriodicMesh() const { return false; }
// Perform any due periodic broadcast and return ms until the next one (-1 == nothing pending).
virtual int32_t meshTick(GamesModule &host) { return -1; }
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+373
View File
@@ -0,0 +1,373 @@
#include "GamesModule.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Breakout.h"
#include "ChirpyRunner.h"
#include "PowerFSM.h"
#include "Snake.h"
#include "Tetris.h"
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "main.h"
#include "mesh/NodeDB.h"
#if GAMES_ANNOUNCE_HIGH_SCORE
#include "MeshService.h"
#endif
GamesModule *gamesModule;
GamesModule::GamesModule() : SinglePortModule("games", meshtastic_PortNum_PRIVATE_APP), concurrency::OSThread("Games")
{
// Register the hosted games. Order sets the attract-screen cycle order (Snake is shown first).
games.push_back(new Snake());
games.push_back(new Tetris());
games.push_back(new ChirpyRunner());
games.push_back(new Breakout());
inputObserver.observe(inputBroker);
// Keep the tick thread alive at boot only if a game broadcasts periodically; otherwise idle
// until the player launches a game. The first idle tick reschedules to the real cadence.
bool periodic = false;
for (Game *g : games)
periodic = periodic || g->wantsPeriodicMesh();
if (periodic)
setIntervalFromNow(1000);
else
disable();
}
// ---------------------------------------------------------------------------
// Lifecycle / state transitions
// ---------------------------------------------------------------------------
void GamesModule::launchGame()
{
if (games.empty())
return;
// The games frame is already current (the player is on the attract screen), so just begin play
// with the selected game -- no focus change or frameset regeneration needed.
active = games[selected];
startPlaying();
}
void GamesModule::startPlaying()
{
active->start(static_cast<uint32_t>(random()) ^ millis());
uiState = GAMES_PLAYING;
lastAwakeKickMs = millis();
kickTick();
requestRedraw();
}
void GamesModule::enterGameOver()
{
lastScore = active ? active->score() : 0;
lastRank = -1;
lastWasNewTop = false;
uiState = GAMES_GAMEOVER;
// Arcade-style: if the score placed, prompt for initials, then record it in the picker's
// callback. Otherwise just show the game-over screen.
if (active && active->scores().qualifies(lastScore))
promptForInitials();
requestRedraw();
}
void GamesModule::promptForInitials()
{
screen->showAlphanumericPicker("New High Score!\nEnter initials", "AAA", 60000, HighScoreTableBase::INITIALS_LEN,
[this](const std::string &initials) { this->recordHighScore(initials.c_str()); });
}
void GamesModule::recordHighScore(const char *initials)
{
if (!active)
return;
bool isNewTop = false;
lastRank = active->scores().insert(lastScore, initials, nodeDB ? nodeDB->getNodeNum() : 0, isNewTop);
lastWasNewTop = isNewTop;
if (lastRank >= 0)
active->scores().save(); // table changed -- the only time we write flash
#if GAMES_ANNOUNCE_HIGH_SCORE
if (isNewTop && lastScore > 0)
announceHighScore(initials, lastScore);
#endif
requestRedraw();
}
#if GAMES_ANNOUNCE_HIGH_SCORE
void GamesModule::announceHighScore(const char *initials, uint32_t score)
{
if (!active || !service)
return;
if (!initials || initials[0] == '\0')
return;
meshtastic_MeshPacket *p = allocDataPacket();
p->to = NODENUM_BROADCAST;
p->channel = 0; // primary channel
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
// One shared message for every game, with the game's name spliced in. ASCII only -- avoids tofu
// if a receiving node's font lacks a glyph.
p->decoded.payload.size = snprintf(reinterpret_cast<char *>(p->decoded.payload.bytes), sizeof(p->decoded.payload.bytes),
GAMES_HIGH_SCORE_STRING, active->name(), initials, static_cast<unsigned long>(score));
service->sendToMesh(p);
LOG_INFO("Games: announced new %s high score %lu", active->name(), static_cast<unsigned long>(score));
}
#endif
void GamesModule::exitToIdle()
{
uiState = GAMES_IDLE;
active = nullptr;
// The games frame is always present, so we just return it to the attract screen and redraw --
// no frameset change. interceptingKeyboardInput() now returns false, so the D-pad navigates
// between frames again. Keep ticking only if a game still needs its periodic broadcast.
bool periodic = false;
for (Game *g : games)
periodic = periodic || g->wantsPeriodicMesh();
if (periodic)
setIntervalFromNow(1000);
else
disable();
requestRedraw();
}
void GamesModule::requestRedraw()
{
UIFrameEvent e;
e.action = UIFrameEvent::Action::REDRAW_ONLY;
notifyObservers(&e);
}
void GamesModule::kickTick()
{
enabled = true;
setIntervalFromNow(250); // brief beat so the player sees the board before it moves
}
int32_t GamesModule::runOnce()
{
if (uiState == GAMES_PLAYING && active) {
if (!active->tick()) {
enterGameOver();
return disable();
}
// Keep the display awake through long runs that generate no key presses.
const uint32_t now = millis();
if (now - lastAwakeKickMs > 1500) {
powerFSM.trigger(EVENT_PRESS);
lastAwakeKickMs = now;
}
requestRedraw();
return active->tickIntervalMs();
}
// Idle: service any game that broadcasts periodically; sleep until the soonest one is due.
int32_t next = -1;
for (Game *g : games) {
const int32_t due = g->meshTick(*this);
if (due >= 0 && (next < 0 || due < next))
next = due;
}
return next < 0 ? disable() : next;
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
int GamesModule::handleInputEvent(const InputEvent *event)
{
// Ignore all input unless the games frame is the one actually on screen -- otherwise the attract
// screen's UP/DOWN would hijack normal frame navigation from wherever the player happens to be.
if (!screen || !screen->isGamesFrameShown())
return 0;
if (screen->isOverlayBannerShowing())
return 0; // a menu banner is up; don't steal its input
const input_broker_event ev = event->inputEvent;
const bool isBack = (ev == INPUT_BROKER_CANCEL || ev == INPUT_BROKER_BACK);
switch (uiState) {
case GAMES_IDLE:
// Attract screen: UP/DOWN cycle which game is shown; SELECT (handled by Screen) launches it;
// long-press SELECT opens that game's high-score table. Everything else passes through so
// the D-pad still navigates between frames.
if (!games.empty() && (ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_UP)) {
const uint8_t n = static_cast<uint8_t>(games.size());
selected = (ev == INPUT_BROKER_DOWN) ? (selected + 1) % n : (selected + n - 1) % n;
requestRedraw();
return 1;
}
if (ev == INPUT_BROKER_SELECT_LONG && !games.empty()) {
active = games[selected];
uiState = GAMES_HISCORES;
requestRedraw();
return 1;
}
return 0;
case GAMES_PLAYING:
if (isBack) {
uiState = GAMES_PAUSED; // BACK to pause; from there choose resume or quit
disable();
requestRedraw();
} else if (active) {
active->handleInput(ev);
if (!active->isPlaying()) {
enterGameOver();
return 1;
}
requestRedraw();
}
return 1;
case GAMES_PAUSED:
if (isBack) {
exitToIdle(); // quit from pause
} else if (ev == INPUT_BROKER_SELECT || ev == INPUT_BROKER_UP || ev == INPUT_BROKER_DOWN || ev == INPUT_BROKER_LEFT ||
ev == INPUT_BROKER_RIGHT) {
uiState = GAMES_PLAYING;
kickTick();
requestRedraw();
}
return 1;
case GAMES_GAMEOVER:
if (ev == INPUT_BROKER_SELECT) {
uiState = GAMES_HISCORES;
requestRedraw();
} else if (isBack) {
exitToIdle();
}
return 1;
case GAMES_HISCORES:
if (ev == INPUT_BROKER_SELECT_LONG && active) {
static const char *opts[] = {"No", "Yes"};
graphics::BannerOverlayOptions confirm;
confirm.message = "Clear Scores?";
confirm.optionsArrayPtr = opts;
confirm.optionsCount = 2;
confirm.bannerCallback = [this](int sel) {
if (sel == 1 && active) {
active->scores().clear();
active->scores().save();
requestRedraw();
LOG_INFO("Games: high scores cleared");
}
};
if (screen)
screen->showOverlayBanner(confirm);
} else if (ev == INPUT_BROKER_SELECT || isBack) {
exitToIdle();
}
return 1;
default:
return 0;
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
void GamesModule::drawCenteredLines(OLEDDisplay *display, int16_t x, int16_t y, const char *const *lines, uint8_t count)
{
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
const int16_t lineH = FONT_HEIGHT_SMALL;
const int16_t total = static_cast<int16_t>(count) * lineH;
int16_t startY = y + (display->getHeight() - total) / 2;
if (startY < y)
startY = y;
const int16_t cx = x + display->getWidth() / 2;
for (uint8_t i = 0; i < count; i++)
display->drawString(cx, startY + i * lineH, lines[i]);
}
void GamesModule::drawHighScores(OLEDDisplay *display, int16_t x, int16_t y, HighScoreTableBase &scores)
{
display->setFont(FONT_SMALL);
display->setColor(WHITE);
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->drawString(x, y, "HIGH SCORES");
display->setTextAlignment(TEXT_ALIGN_LEFT);
const int16_t rowH = (display->getHeight() - FONT_HEIGHT_SMALL) / HighScoreTableBase::HS_COUNT;
for (uint8_t i = 0; i < HighScoreTableBase::HS_COUNT; i++) {
char row[32];
if (scores.scoreAt(i) > 0) {
snprintf(row, sizeof(row), "%u. %-4s %lu", static_cast<unsigned>(i + 1), scores.nameAt(i),
static_cast<unsigned long>(scores.scoreAt(i)));
} else {
snprintf(row, sizeof(row), "%u. ---", static_cast<unsigned>(i + 1));
}
display->drawString(x + 6, y + FONT_HEIGHT_SMALL + i * rowH, row);
}
}
void GamesModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState * /*state*/, int16_t x, int16_t y)
{
display->setColor(WHITE);
switch (uiState) {
case GAMES_IDLE:
if (!games.empty())
games[selected]->drawAttract(display, x, y);
break;
case GAMES_PLAYING:
if (active)
active->drawPlaying(display, x, y);
break;
case GAMES_PAUSED:
if (active) {
active->drawPlaying(display, x, y);
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(x + display->getWidth() / 2, y + display->getHeight() / 2 - FONT_HEIGHT_SMALL / 2, "- PAUSED -");
}
break;
case GAMES_GAMEOVER: {
char scoreLine[24];
snprintf(scoreLine, sizeof(scoreLine), "Score: %lu", static_cast<unsigned long>(lastScore));
const char *status = lastWasNewTop ? "NEW HIGH SCORE!" : (lastRank >= 0 ? "You made the top 5!" : "");
const char *hint = active ? active->gameOverHint() : "SELECT: scores";
const char *lines[] = {"GAME OVER", scoreLine, status, hint};
drawCenteredLines(display, x, y, lines, 4);
break;
}
case GAMES_HISCORES:
if (active)
drawHighScores(display, x, y, active->scores());
break;
default:
break;
}
}
// ---------------------------------------------------------------------------
// Mesh receive - dispatch to each hosted game
// ---------------------------------------------------------------------------
ProcessMessage GamesModule::handleReceived(const meshtastic_MeshPacket &mp)
{
for (Game *g : games) {
const ProcessMessage r = g->handleReceived(mp);
if (r != ProcessMessage::CONTINUE)
return r;
}
requestRedraw(); // a merged remote score should show up if the high-score screen is open
return ProcessMessage::CONTINUE;
}
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+101
View File
@@ -0,0 +1,101 @@
#pragma once
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Game.h"
#include "Observer.h"
#include "concurrency/OSThread.h"
#include "input/InputBroker.h"
#include "mesh/SinglePortModule.h"
#include <vector>
// Broadcasting a new all-time #1 to the mesh is a COMPILE-TIME option, default OFF. Spending shared
// airtime must be opted into at build time with -DGAMES_ANNOUNCE_HIGH_SCORE=1; when disabled (the
// default) the announcement code is compiled out entirely -- there is no runtime toggle. The
// announcement is shared by every hosted game, with the game's name() spliced into the message.
#ifndef GAMES_ANNOUNCE_HIGH_SCORE
#define GAMES_ANNOUNCE_HIGH_SCORE 0
#endif
#ifndef GAMES_HIGH_SCORE_STRING
#define GAMES_HIGH_SCORE_STRING "New %s high score %lu by %s!"
#endif
enum GamesUiState : uint8_t {
GAMES_IDLE, // attract screen of the selected game; OSThread idle (unless a game broadcasts)
GAMES_PLAYING, // active game running; tick thread ticking
GAMES_PAUSED, // paused mid-game
GAMES_GAMEOVER, // final score / new-high notice
GAMES_HISCORES, // top-5 table of the active/selected game
};
/**
* The single host for all BaseUI games. It owns the always-present "games" frame (drawn through
* Screen's trampoline right after home), the shared UI state machine, the game-tick OSThread, the
* initials picker + high-score persistence flow, and the PRIVATE_APP mesh port. Individual games
* are self-contained Game subclasses registered in the constructor (see src/modules/games/); the
* attract screen cycles between them with UP/DOWN and SELECT plays the shown game.
*/
class GamesModule : public SinglePortModule, public Observable<const UIFrameEvent *>, private concurrency::OSThread
{
public:
GamesModule();
/// Start the currently-selected game (invoked when SELECT is pressed on the games frame). The
/// games frame is already current, so this just begins play.
void launchGame();
// Drawn through the games-frame trampoline, and queried by Screen's input gating / nav-bar, so
// these are public. While a game is active we own the D-pad; on the attract screen the D-pad
// cycles games (UP/DOWN) and otherwise navigates between frames as usual.
void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y);
virtual bool interceptingKeyboardInput() override { return uiState != GAMES_IDLE; }
/// Mesh passthrough for hosted games (a Game is not itself a MeshModule).
meshtastic_MeshPacket *gameAllocDataPacket() { return allocDataPacket(); }
protected:
virtual int32_t runOnce() override; // game tick + idle mesh scheduling
virtual Observable<const UIFrameEvent *> *getUIFrameObservable() override { return this; }
virtual bool wantUIFrame() override { return false; } // shares the games frame; no own slot
virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
private:
int handleInputEvent(const InputEvent *event);
CallbackObserver<GamesModule, const InputEvent *> inputObserver =
CallbackObserver<GamesModule, const InputEvent *>(this, &GamesModule::handleInputEvent);
// === State transitions ===
void startPlaying();
void enterGameOver();
void exitToIdle();
void requestRedraw();
void kickTick();
// === Shared game-over / high-score flow ===
void promptForInitials();
void recordHighScore(const char *initials);
#if GAMES_ANNOUNCE_HIGH_SCORE
// Broadcast a new all-time #1 as a plain text message, shared by every game (name() spliced in).
void announceHighScore(const char *initials, uint32_t score);
#endif
// === Shared rendering ===
void drawCenteredLines(OLEDDisplay *display, int16_t x, int16_t y, const char *const *lines, uint8_t count);
void drawHighScores(OLEDDisplay *display, int16_t x, int16_t y, HighScoreTableBase &scores);
std::vector<Game *> games;
uint8_t selected = 0; // attract-screen cursor (index into games)
Game *active = nullptr; // game currently playing / whose scores are shown; null when idle
GamesUiState uiState = GAMES_IDLE;
uint32_t lastScore = 0; // score of the just-finished game (for the GAME OVER screen)
int lastRank = -1; // rank achieved last game (-1 == didn't place)
bool lastWasNewTop = false; // last game set a new all-time #1
uint32_t lastAwakeKickMs = 0; // throttles the power-FSM wake nudge during long runs
};
extern GamesModule *gamesModule;
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+176
View File
@@ -0,0 +1,176 @@
#pragma once
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "DebugConfiguration.h"
#include "FSCommon.h"
#include "SPILock.h"
#include "SafeFile.h"
#include "concurrency/LockGuard.h"
#include "gps/RTC.h"
#include "mesh/NodeDB.h" // owner (short-name fallback)
#include <ErriezCRC32.h>
#include <cstddef>
#include <cstdint>
#include <cstring>
/**
* Non-templated view of a game's top-N high-score table, so the shared games UI (attract line,
* high-score screen) and the Game interface can talk to any game's table without knowing its
* on-disk record layout.
*/
class HighScoreTableBase
{
public:
static constexpr uint8_t HS_COUNT = 5; // entries kept per game
static constexpr uint8_t INITIALS_LEN = 3; // arcade-style initials captured per high score
virtual ~HighScoreTableBase() = default;
virtual uint32_t scoreAt(uint8_t i) const = 0;
virtual const char *nameAt(uint8_t i) const = 0;
// True if `score` would place on the sorted-descending table (peek; no mutation).
virtual bool qualifies(uint32_t score) const = 0;
// Insert under `initials` with the given `nodeNum` (0 == local, or a foreign node for merged
// remote scores). Returns the 0-based rank if it placed, else -1; isNewTop set on the #1 slot.
virtual int insert(uint32_t score, const char *initials, uint32_t nodeNum, bool &isNewTop) = 0;
virtual void clear() = 0;
virtual void load() = 0;
virtual void save() = 0;
virtual bool loaded() const = 0;
};
/**
* Templated top-N table shared by every game. The algorithm (qualify / insert / load / save / CRC)
* is identical across games; only the on-disk record layout differs, so `Entry` is a template
* parameter. Each game supplies its own packed `Entry` (with `score`, `shortName[5]`, `nodeNum`,
* `epoch` fields, in whatever byte order its existing save file used) plus a file path, magic, and
* version -- so pre-existing save files keep loading byte-for-byte after the refactor.
*/
template <typename Entry> class HighScoreTable : public HighScoreTableBase
{
public:
HighScoreTable(const char *path, uint32_t magic, uint8_t version, const char *logTag)
: path_(path), magic_(magic), version_(version), logTag_(logTag)
{
}
uint32_t scoreAt(uint8_t i) const override { return entries_[i].score; }
const char *nameAt(uint8_t i) const override { return entries_[i].shortName; }
const Entry &entryAt(uint8_t i) const { return entries_[i]; }
bool qualifies(uint32_t score) const override
{
if (score == 0)
return false;
for (int i = 0; i < HS_COUNT; i++) {
if (score > entries_[i].score)
return true;
}
return false;
}
int insert(uint32_t score, const char *initials, uint32_t nodeNum, bool &isNewTop) override
{
isNewTop = false;
if (score == 0)
return -1;
int pos = -1;
for (int i = 0; i < HS_COUNT; i++) {
if (score > entries_[i].score) {
pos = i;
break;
}
}
if (pos < 0)
return -1; // not good enough to place
for (int i = HS_COUNT - 1; i > pos; i--)
entries_[i] = entries_[i - 1];
Entry &e = entries_[pos];
memset(&e, 0, sizeof(e));
e.score = score;
e.nodeNum = nodeNum;
strncpy(e.shortName, (initials && initials[0]) ? initials : owner.short_name, sizeof(e.shortName) - 1);
e.shortName[sizeof(e.shortName) - 1] = '\0';
e.epoch = getValidTime(RTCQualityDevice, false); // 0 when no valid RTC
isNewTop = (pos == 0);
return pos;
}
void clear() override { memset(entries_, 0, sizeof(entries_)); }
bool loaded() const override { return loaded_; }
void load() override
{
loaded_ = true;
memset(entries_, 0, sizeof(entries_));
#ifdef FSCom
concurrency::LockGuard g(spiLock);
auto f = FSCom.open(path_, FILE_O_READ);
if (!f)
return;
File file;
const bool readOk = (f.read(reinterpret_cast<uint8_t *>(&file), sizeof(file)) == sizeof(file));
f.close();
if (!readOk || file.magic != magic_ || file.version != version_) {
LOG_DEBUG("%s: no valid high-score file, starting fresh", logTag_);
return;
}
if (crc32Buffer(&file, offsetof(File, crc)) != file.crc) {
LOG_WARN("%s: high-score CRC mismatch, resetting table", logTag_);
return;
}
memcpy(entries_, file.entries, sizeof(entries_));
LOG_INFO("%s: loaded high scores (top=%lu)", logTag_, static_cast<unsigned long>(entries_[0].score));
#endif
}
void save() override
{
#ifdef FSCom
{
concurrency::LockGuard g(spiLock);
FSCom.mkdir("/prefs");
}
File file;
memset(&file, 0, sizeof(file));
file.magic = magic_;
file.version = version_;
memcpy(file.entries, entries_, sizeof(entries_));
file.crc = crc32Buffer(&file, offsetof(File, crc));
auto sf = SafeFile(path_, true);
const size_t written = sf.write(reinterpret_cast<uint8_t *>(&file), sizeof(file));
if (!sf.close() || written != sizeof(file))
LOG_WARN("%s: failed to save high scores", logTag_);
#endif
}
private:
// On-disk layout. The 8-byte header (magic + version + 3 reserved) matches both the original
// Snake and Tetris files byte-for-byte, so their save files still load unchanged.
struct File {
uint32_t magic;
uint8_t version;
uint8_t reserved[3];
Entry entries[HighScoreTableBase::HS_COUNT];
uint32_t crc; // crc32 over every preceding byte
} __attribute__((packed));
Entry entries_[HS_COUNT] = {};
const char *path_;
uint32_t magic_;
uint8_t version_;
const char *logTag_;
bool loaded_ = false;
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+264
View File
@@ -0,0 +1,264 @@
#include "Snake.h"
// ===========================================================================
// Pure SnakeGame logic (no display/FS dependencies; always compiled)
// ===========================================================================
void SnakeGame::reset(uint32_t seed)
{
memset(occ, 0, sizeof(occ));
len = 0;
points = 0;
alive = true;
won = false;
rng = seed ? seed : 0xA5A5A5A5u; // xorshift32 must never be seeded with 0
// Spawn a START_LEN snake horizontally in the middle of the board, heading right, with the
// head at the centre and the body trailing to its left.
const uint8_t cx = GRID_W / 2;
const uint8_t cy = GRID_H / 2;
dir = DIR_RIGHT;
pendingDir = DIR_RIGHT;
tailIdx = 0;
for (uint8_t i = 0; i < START_LEN; i++) {
const uint8_t x = static_cast<uint8_t>(cx - (START_LEN - 1) + i);
body[i] = {x, cy};
setOcc(cellIndex(x, cy));
len++;
}
headIdx = static_cast<uint16_t>(START_LEN - 1);
placeFood();
}
bool SnakeGame::isReverse(Direction a, Direction b)
{
return (a == DIR_UP && b == DIR_DOWN) || (a == DIR_DOWN && b == DIR_UP) || (a == DIR_LEFT && b == DIR_RIGHT) ||
(a == DIR_RIGHT && b == DIR_LEFT);
}
bool SnakeGame::setDirection(Direction d)
{
if (isReverse(dir, d))
return false;
pendingDir = d;
return true;
}
uint32_t SnakeGame::nextRandom()
{
uint32_t x = rng;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
rng = x;
return x;
}
bool SnakeGame::placeFood()
{
const uint16_t free = static_cast<uint16_t>(CELL_COUNT - len);
if (free == 0)
return false; // board full -> caller treats as a win
// Pick the k-th free cell (k uniform in [0, free)) via a single linear scan. Deterministic,
// always valid, and cheap for a 384-cell board -- no rejection sampling / near-full special case.
uint16_t k = static_cast<uint16_t>(nextRandom() % free);
for (uint16_t idx = 0; idx < CELL_COUNT; idx++) {
if (!getOcc(idx)) {
if (k == 0) {
foodCell = {static_cast<uint8_t>(idx % GRID_W), static_cast<uint8_t>(idx / GRID_W)};
return true;
}
k--;
}
}
return false; // unreachable while free > 0
}
bool SnakeGame::step()
{
if (!alive)
return false;
dir = pendingDir; // commit the latched heading
const Cell h = body[headIdx];
int16_t nx = h.x;
int16_t ny = h.y;
switch (dir) {
case DIR_UP:
ny--;
break;
case DIR_DOWN:
ny++;
break;
case DIR_LEFT:
nx--;
break;
case DIR_RIGHT:
nx++;
break;
}
// Wall collision (signed math catches the 0-- underflow too).
if (nx < 0 || nx >= GRID_W || ny < 0 || ny >= GRID_H) {
alive = false;
return false;
}
const uint16_t nidx = cellIndex(static_cast<uint8_t>(nx), static_cast<uint8_t>(ny));
const bool eating = (nx == foodCell.x && ny == foodCell.y);
// When not eating the tail vacates this tick, so free it first: moving into the cell the
// tail is leaving is legal (classic snake), moving into any other body cell is fatal.
if (!eating) {
clearOcc(cellIndex(body[tailIdx].x, body[tailIdx].y));
tailIdx = static_cast<uint16_t>((tailIdx + 1) % CAP);
len--;
}
if (getOcc(nidx)) {
alive = false;
return false;
}
// Advance the head into the new cell.
headIdx = static_cast<uint16_t>((headIdx + 1) % CAP);
body[headIdx] = {static_cast<uint8_t>(nx), static_cast<uint8_t>(ny)};
setOcc(nidx);
len++;
if (eating) {
points++;
if (!placeFood()) {
won = true;
alive = false; // board completely filled -- the player won
}
}
return alive;
}
// ===========================================================================
// Snake adapter (display + persistence; BaseUI games build only)
// ===========================================================================
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "main.h"
// --- Pixel layout on a 128x64 OLED -----------------------------------------------------------
// Each cell is CELL_PX square. The GRID_W x GRID_H playfield (128x48) is bottom-aligned, leaving
// a SCORE_H-pixel score bar across the top. On taller displays the board bottom-aligns and
// centres horizontally; screen edges are the walls.
static constexpr int16_t CELL_PX = 4;
static constexpr int16_t SCORE_H = 16;
Snake::Snake()
{
scores_.load();
}
int32_t Snake::tickIntervalMs() const
{
// Speed ramps up as the snake grows: 160 ms base, down to a 70 ms floor.
int32_t iv = 160 - static_cast<int32_t>(game.length()) * 3;
return iv < 70 ? 70 : iv;
}
void Snake::handleInput(input_broker_event ev)
{
switch (ev) {
case INPUT_BROKER_UP:
game.setDirection(SnakeGame::DIR_UP);
break;
case INPUT_BROKER_DOWN:
game.setDirection(SnakeGame::DIR_DOWN);
break;
case INPUT_BROKER_LEFT:
game.setDirection(SnakeGame::DIR_LEFT);
break;
case INPUT_BROKER_RIGHT:
game.setDirection(SnakeGame::DIR_RIGHT);
break;
default:
break;
}
}
void Snake::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
const int16_t w = display->getWidth();
const int16_t cx = x + w / 2;
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(cx, y, "S N A K E");
// Snake glyph, centred below the title.
const int16_t logoX = x + (w - snake_width) / 2;
const int16_t logoY = y + 15;
display->drawXbm(logoX, logoY, snake_width, snake_height, snake);
#if GRAPHICS_TFT_COLORING_ENABLED
// On a colour display, paint the snake green with a red tongue. The forked tongue is the pair
// of pixels poking out past the body on the right edge (~row 6 of the 16x16 glyph); a red
// region registered after the green one wins where they overlap, so the body stays green.
const uint16_t bg = graphics::getThemeBodyBg();
graphics::registerTFTColorRegionDirect(logoX, logoY, snake_width, snake_height, graphics::TFTPalette::Green, bg);
graphics::registerTFTColorRegionDirect(logoX + 13, logoY + 5, 3, 4, graphics::TFTPalette::Red, bg);
#endif
char hi[32];
if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast<unsigned long>(scores_.scoreAt(0)));
else
snprintf(hi, sizeof(hi), "High: %lu", static_cast<unsigned long>(scores_.scoreAt(0)));
display->drawString(cx, y + 34, hi);
}
void Snake::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
{
char buf[24];
display->setColor(WHITE);
display->setFont(FONT_SMALL);
// Score bar: current score on the left, best on the right.
display->setTextAlignment(TEXT_ALIGN_LEFT);
snprintf(buf, sizeof(buf), "Score %lu", static_cast<unsigned long>(game.score()));
display->drawString(x + 2, y + 2, buf);
if (scores_.scoreAt(0) > 0) {
display->setTextAlignment(TEXT_ALIGN_RIGHT);
snprintf(buf, sizeof(buf), "Hi %lu", static_cast<unsigned long>(scores_.scoreAt(0)));
display->drawString(x + display->getWidth() - 2, y + 2, buf);
}
display->drawLine(x, y + SCORE_H - 1, x + display->getWidth() - 1, y + SCORE_H - 1);
// Board is bottom-aligned; centre it horizontally.
const int16_t boardW = SnakeGame::GRID_W * CELL_PX;
const int16_t boardH = SnakeGame::GRID_H * CELL_PX;
const int16_t ox = x + (display->getWidth() - boardW) / 2;
const int16_t oy = y + display->getHeight() - boardH;
for (uint16_t i = 0; i < game.length(); i++) {
SnakeGame::Cell c = game.bodyAt(i);
display->fillRect(ox + c.x * CELL_PX, oy + c.y * CELL_PX, CELL_PX, CELL_PX);
}
// Food: a 2x2 dot centred in its cell.
SnakeGame::Cell f = game.food();
display->fillRect(ox + f.x * CELL_PX + 1, oy + f.y * CELL_PX + 1, 2, 2);
#if GRAPHICS_TFT_COLORING_ENABLED
// On a colour display, paint the whole snake green, then the apple red on top. One region over
// the board tints every lit body cell green (the snake can be too long for per-cell regions);
// a red region over the food pixel wins where it overlaps.
const uint16_t bg = graphics::getThemeBodyBg();
graphics::registerTFTColorRegionDirect(ox, oy, boardW, boardH, graphics::TFTPalette::MeshtasticGreen, bg);
graphics::registerTFTColorRegionDirect(ox + f.x * CELL_PX + 1, oy + f.y * CELL_PX + 1, 2, 2, graphics::TFTPalette::Red, bg);
#endif
}
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+152
View File
@@ -0,0 +1,152 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <string.h>
/**
* Pure, self-contained Snake game logic.
*
* Deliberately free of Arduino / Screen / heap dependencies so it can be unit-tested natively
* (see test/test_snake) and reused by the Snake adapter below without pulling in the display stack.
*
* The board is a fixed GRID_W x GRID_H grid of cells. The snake body lives in a ring buffer
* sized to the whole board, plus an occupancy bitmap for O(1) collision and food-placement
* checks. No dynamic allocation: total state is ~1 KB and statically sized.
*/
class SnakeGame
{
public:
// Playfield dimensions in cells. Chosen so that at CELL_PX = 4 the board is 128x48, leaving
// a 16 px score bar at the top of a 128x64 OLED (see Snake.cpp for the pixel layout).
static constexpr uint8_t GRID_W = 32;
static constexpr uint8_t GRID_H = 12;
static constexpr uint16_t CELL_COUNT = static_cast<uint16_t>(GRID_W) * GRID_H; // 384
// Initial snake length at the start of a game.
static constexpr uint8_t START_LEN = 3;
enum Direction : uint8_t { DIR_UP, DIR_DOWN, DIR_LEFT, DIR_RIGHT };
struct Cell {
uint8_t x;
uint8_t y;
};
/**
* (Re)start a game. The snake spawns horizontally in the middle of the board heading right,
* and the first food is placed. `seed` drives deterministic food placement (xorshift32).
*/
void reset(uint32_t seed);
/**
* Latch a new heading to be applied on the next step(). A 180-degree reversal of the
* currently-committed direction is rejected (returns false) because it would immediately
* run the head into the neck. Comparing against the committed direction (not the pending
* one) means multiple key presses within a single tick can't chain into a reversal.
*/
bool setDirection(Direction d);
/**
* Advance the simulation by one tick. Returns true if the snake is still alive afterwards,
* false if this move ended the game (wall hit, self-collision, or board filled == win).
* Once dead, further step() calls are no-ops returning false.
*/
bool step();
bool isPlaying() const { return alive; }
bool isWon() const { return won; }
uint16_t length() const { return len; }
uint32_t score() const { return points; }
Cell head() const { return body[headIdx]; }
Cell food() const { return foodCell; }
Direction direction() const { return dir; }
/// True if cell (x,y) is currently part of the snake body.
bool occupied(uint8_t x, uint8_t y) const { return getOcc(cellIndex(x, y)); }
/// Iterate the body from tail (i == 0) to head (i == length()-1); used by the renderer.
Cell bodyAt(uint16_t i) const { return body[(tailIdx + i) % CAP]; }
/**
* Test/aid seam: force the next food to a specific cell so unit tests can drive
* deterministic growth. Unused in production. Caller must pass an unoccupied cell.
*/
void placeFoodAt(uint8_t x, uint8_t y) { foodCell = {x, y}; }
private:
static constexpr uint16_t CAP = CELL_COUNT; // ring capacity == board size (len is tracked explicitly)
Cell body[CAP] = {0};
uint16_t headIdx = 0; // ring index of the head (front) cell
uint16_t tailIdx = 0; // ring index of the tail (oldest) cell
uint16_t len = 0; // number of live body cells
uint8_t occ[(CELL_COUNT + 7) / 8] = {0}; // occupancy bitmap, indexed by cellIndex()
Cell foodCell = {0, 0};
Direction dir = DIR_RIGHT;
Direction pendingDir = DIR_RIGHT;
uint32_t points = 0;
uint32_t rng = 1; // xorshift32 state (never 0)
bool alive = false;
bool won = false;
static uint16_t cellIndex(uint8_t x, uint8_t y) { return static_cast<uint16_t>(y) * GRID_W + x; }
bool getOcc(uint16_t idx) const { return (occ[idx >> 3] >> (idx & 7)) & 1u; }
void setOcc(uint16_t idx) { occ[idx >> 3] |= static_cast<uint8_t>(1u << (idx & 7)); }
void clearOcc(uint16_t idx) { occ[idx >> 3] &= static_cast<uint8_t>(~(1u << (idx & 7))); }
uint32_t nextRandom();
bool placeFood(); // returns false if the board is full (no free cell -> win)
static bool isReverse(Direction a, Direction b);
};
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Game.h"
#include "HighScoreTable.h"
/**
* Snake as a hosted Game. Wraps the pure SnakeGame logic above and supplies the attract art, the
* playfield renderer, the direction input, the length-based speed curve, and its own high-score
* table. The new-high-score mesh announcement is shared by all games and lives in GamesModule.
*/
class Snake : public Game
{
public:
Snake();
const char *name() const override { return "Snake"; }
void start(uint32_t seed) override { game.reset(seed); }
bool tick() override { return game.step(); }
bool isPlaying() const override { return game.isPlaying(); }
uint32_t score() const override { return game.score(); }
int32_t tickIntervalMs() const override;
void handleInput(input_broker_event ev) override;
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
HighScoreTableBase &scores() override { return scores_; }
private:
// On-disk high-score record; layout preserved from the original SnakeModule so snake.dat keeps
// loading. Magic 'SNEK', file version 1.
struct SnakeEntry {
uint32_t score;
uint32_t nodeNum;
char shortName[5]; // three characters, NUL-terminated
uint32_t epoch; // getValidTime(), 0 if no RTC
} __attribute__((packed));
SnakeGame game;
HighScoreTable<SnakeEntry> scores_{"/prefs/snake.dat", 0x534E454Bu, 1, "Snake"};
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+494
View File
@@ -0,0 +1,494 @@
#include "Tetris.h"
// ===========================================================================
// Pure TetrisGame logic (no display/FS dependencies; always compiled)
// ===========================================================================
// ---------------------------------------------------------------------------
// Shape table
//
// SHAPES[type][rot][row] encodes each row of the 4×4 bounding box as a 4-bit
// column bitmask (bit 0 = leftmost column). All seven SRS tetrominoes in their
// four CW rotations.
//
// Type 0 I Type 1 O Type 2 T Type 3 S
// Type 4 Z Type 5 J Type 6 L
// ---------------------------------------------------------------------------
const uint8_t TetrisGame::SHAPES[PIECE_TYPES][4][4] = {
// I ---- rot0: .XXXX rot1: ..X.. rot2: ..... rot3: .X...
{{0, 15, 0, 0}, {4, 4, 4, 4}, {0, 0, 15, 0}, {2, 2, 2, 2}},
// O ---- same all rotations
{{6, 6, 0, 0}, {6, 6, 0, 0}, {6, 6, 0, 0}, {6, 6, 0, 0}},
// T ----
{{2, 7, 0, 0}, {2, 6, 2, 0}, {0, 7, 2, 0}, {2, 3, 2, 0}},
// S ----
{{6, 3, 0, 0}, {1, 3, 2, 0}, {0, 6, 3, 0}, {2, 6, 4, 0}},
// Z ----
{{3, 6, 0, 0}, {2, 3, 1, 0}, {0, 3, 6, 0}, {4, 6, 2, 0}},
// J ----
{{1, 7, 0, 0}, {6, 2, 2, 0}, {0, 7, 4, 0}, {2, 2, 3, 0}},
// L ----
{{4, 7, 0, 0}, {2, 2, 6, 0}, {0, 7, 1, 0}, {3, 2, 2, 0}},
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
bool TetrisGame::pieceCell(uint8_t type, uint8_t rot, uint8_t pr, uint8_t pc)
{
if (type >= PIECE_TYPES || rot >= 4 || pr >= 4 || pc >= 4)
return false;
return (SHAPES[type][rot][pr] >> pc) & 1u;
}
uint32_t TetrisGame::nextRandom()
{
uint32_t x = rng;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
rng = x;
return x;
}
TetrisGame::Piece TetrisGame::spawnPiece(uint8_t type) const
{
// Centre horizontally in the 10-wide board; bounding box starts at col 3.
Piece p;
p.type = type;
p.rot = 0;
p.col = static_cast<int8_t>((BOARD_COLS - 4) / 2); // 3 for a 10-wide board
p.row = 0;
return p;
}
bool TetrisGame::canPlace(const Piece &p) const
{
for (uint8_t pr = 0; pr < 4; pr++) {
for (uint8_t pc = 0; pc < 4; pc++) {
if (!pieceCell(p.type, p.rot, pr, pc))
continue;
const int16_t br = static_cast<int16_t>(p.row) + pr;
const int16_t bc = static_cast<int16_t>(p.col) + pc;
if (br < 0)
continue; // above the board - allowed during spawn
if (br >= BOARD_ROWS || bc < 0 || bc >= BOARD_COLS)
return false;
if (board[br][bc] != 0)
return false;
}
}
return true;
}
void TetrisGame::lockPiece()
{
for (uint8_t pr = 0; pr < 4; pr++) {
for (uint8_t pc = 0; pc < 4; pc++) {
if (!pieceCell(cur.type, cur.rot, pr, pc))
continue;
const int16_t br = static_cast<int16_t>(cur.row) + pr;
const int16_t bc = static_cast<int16_t>(cur.col) + pc;
if (br >= 0 && br < BOARD_ROWS && bc >= 0 && bc < BOARD_COLS)
board[br][bc] = static_cast<uint8_t>(cur.type + 1); // colour 1..7
}
}
}
int TetrisGame::clearLines()
{
int cleared = 0;
for (int r = BOARD_ROWS - 1; r >= 0;) {
bool full = true;
for (int c = 0; c < BOARD_COLS && full; c++) {
if (board[r][c] == 0)
full = false;
}
if (full) {
// Shift every row above down by one.
for (int rr = r; rr > 0; rr--)
memcpy(board[rr], board[rr - 1], BOARD_COLS);
memset(board[0], 0, BOARD_COLS);
cleared++;
// Recheck same index - it now contains the row that was above.
} else {
r--;
}
}
return cleared;
}
void TetrisGame::advanceNext()
{
lockPiece();
const int cleared = clearLines();
if (cleared > 0) {
lines += static_cast<uint16_t>(cleared);
// Nintendo-style line-clear scoring (×level).
static const uint16_t LINE_PTS[5] = {0, 100, 300, 500, 800};
pts += LINE_PTS[cleared < 5 ? cleared : 4] * lvl;
// Level up every 10 lines, cap at 20.
const uint8_t newLvl = static_cast<uint8_t>(lines / 10 + 1);
lvl = newLvl > 20 ? 20 : newLvl;
}
// nxt becomes the active piece; generate a fresh nxt.
cur = nxt;
if (!canPlace(cur)) {
alive = false;
return;
}
nxt = spawnPiece(static_cast<uint8_t>(nextRandom() % PIECE_TYPES));
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
void TetrisGame::reset(uint32_t seed)
{
memset(board, 0, sizeof(board));
pts = 0;
lvl = 1;
lines = 0;
alive = true;
rng = seed ? seed : 0xA5A5A5A5u;
cur = spawnPiece(static_cast<uint8_t>(nextRandom() % PIECE_TYPES));
nxt = spawnPiece(static_cast<uint8_t>(nextRandom() % PIECE_TYPES));
}
bool TetrisGame::moveLeft()
{
Piece p = cur;
p.col--;
if (!canPlace(p))
return false;
cur = p;
return true;
}
bool TetrisGame::moveRight()
{
Piece p = cur;
p.col++;
if (!canPlace(p))
return false;
cur = p;
return true;
}
bool TetrisGame::rotate()
{
Piece p = cur;
p.rot = static_cast<uint8_t>((p.rot + 1) % 4);
if (canPlace(p)) {
cur = p;
return true;
}
// Wall-kick: try ±1, ±2 column offsets.
const int8_t kicks[] = {-1, 1, -2, 2};
for (int8_t kick : kicks) {
Piece q = p;
q.col = static_cast<int8_t>(p.col + kick);
if (canPlace(q)) {
cur = q;
return true;
}
}
return false;
}
bool TetrisGame::softDrop()
{
Piece p = cur;
p.row++;
if (!canPlace(p)) {
advanceNext(); // locks cur, clears lines, spawns next; may set alive=false
return false;
}
cur = p;
return true;
}
void TetrisGame::hardDrop()
{
const int8_t land = ghostRow();
const uint32_t dropped = static_cast<uint32_t>(land - cur.row);
pts += dropped * 2;
cur.row = land;
advanceNext();
}
int8_t TetrisGame::ghostRow() const
{
Piece p = cur;
while (true) {
Piece q = p;
q.row++;
if (!canPlace(q))
break;
p = q;
}
return p.row;
}
bool TetrisGame::step()
{
if (!alive)
return false;
softDrop(); // may lock and advance, potentially setting alive=false
return alive;
}
// ===========================================================================
// Tetris adapter (display + persistence + mesh; BaseUI games build only)
// ===========================================================================
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "GamesModule.h"
#include "NodeDB.h"
#include "graphics/Screen.h"
#include "graphics/ScreenFonts.h"
#include "graphics/TFTColorRegions.h"
#include "graphics/TFTPalette.h"
#include "graphics/images.h"
#include "main.h"
#include <cstddef>
#include <cstring>
// ---------------------------------------------------------------------------
// Vertical pixel layout on a 128×64 OLED
//
// Board occupies the left side of the screen:
// x = col × CELL_PX (col 0 at left edge)
// y = row × CELL_PX (row 0 at top edge)
// 10 cols × 4 px = 40 px wide
// 16 rows × 4 px = 64 px tall (fills the full display height)
//
// Score panel: x = SCORE_OX .. 127 (86 px wide)
// Labels (SCR / LVL / NXT) + values + next-piece preview.
// ---------------------------------------------------------------------------
static constexpr int16_t CELL_PX = 4;
Tetris::Tetris()
{
scores_.load();
}
int32_t Tetris::tickIntervalMs() const
{
// Speed ramps with level: 600 ms base, 45 ms per level, floor 50 ms.
int32_t iv = 600 - static_cast<int32_t>(game.level()) * 45;
return iv < 50 ? 50 : iv;
}
void Tetris::handleInput(input_broker_event ev)
{
switch (ev) {
case INPUT_BROKER_UP:
game.rotate();
break;
case INPUT_BROKER_LEFT:
game.moveLeft();
break;
case INPUT_BROKER_RIGHT:
game.moveRight();
break;
case INPUT_BROKER_DOWN:
game.softDrop();
break;
case INPUT_BROKER_SELECT:
case INPUT_BROKER_SELECT_LONG:
game.hardDrop();
break;
default:
break;
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
#if GRAPHICS_TFT_COLORING_ENABLED
// Classic tetromino colours, indexed by piece type (0..6 == I O T S Z J L). Native RGB565.
static uint16_t tetrominoColor(uint8_t type)
{
using namespace graphics;
switch (type) {
case 0:
return TFTPalette::Cyan; // I
case 1:
return TFTPalette::Yellow; // O
case 2:
return TFTPalette::Magenta; // T
case 3:
return TFTPalette::Green; // S
case 4:
return TFTPalette::Red; // Z
case 5:
return TFTPalette::Blue; // J
case 6:
return TFTPalette::Orange; // L
default:
return TFTPalette::White;
}
}
#endif
void Tetris::drawAttract(OLEDDisplay *display, int16_t x, int16_t y)
{
display->setColor(WHITE);
const int16_t w = display->getWidth();
const int16_t cx = x + w / 2;
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(cx, y, "T E T R I S");
const int16_t logoX = x + (w - tetris_width) / 2;
const int16_t logoY = y + 15;
display->drawXbm(logoX, logoY, tetris_width, tetris_height, tetris);
#if GRAPHICS_TFT_COLORING_ENABLED
// The logo glyph is a T tetromino -- tint it the T-piece colour on colour displays.
graphics::registerTFTColorRegionDirect(logoX, logoY, tetris_width, tetris_height, tetrominoColor(2),
graphics::getThemeBodyBg());
#endif
char hi[32];
if (scores_.scoreAt(0) > 0 && scores_.nameAt(0)[0] != '\0')
snprintf(hi, sizeof(hi), "High: %s %lu", scores_.nameAt(0), static_cast<unsigned long>(scores_.scoreAt(0)));
else
snprintf(hi, sizeof(hi), "High: %lu", static_cast<unsigned long>(scores_.scoreAt(0)));
display->drawString(cx, y + 34, hi);
}
void Tetris::drawPlaying(OLEDDisplay *display, int16_t x, int16_t y)
{
// Centered vertical layout:
// board: 10 cols × CELL_PX wide, fills display height (BOARD_ROWS × CELL_PX)
// left panel (NXT preview) : x = 0 .. ox-2
// right panel (SCR / LVL) : x = ox+boardW+1 .. display.width-1
const int16_t boardW = TetrisGame::BOARD_COLS * CELL_PX; // 40
const int16_t ox = x + (display->getWidth() - boardW) / 2; // horizontal centre
const int16_t oy = y;
display->setColor(WHITE);
// Separator lines either side of the board, plus bottom wall.
display->drawLine(ox - 1, oy, ox - 1, oy + display->getHeight() - 1);
display->drawLine(ox + boardW, oy, ox + boardW, oy + display->getHeight() - 1);
display->drawLine(ox - 1, oy + display->getHeight() - 1, ox + boardW, oy + display->getHeight() - 1);
// Cell helper.
auto drawCell = [&](int8_t col, int8_t row) {
if (col < 0 || row < 0 || col >= TetrisGame::BOARD_COLS || row >= TetrisGame::BOARD_ROWS)
return;
display->fillRect(ox + static_cast<int16_t>(col) * CELL_PX, oy + static_cast<int16_t>(row) * CELL_PX, CELL_PX - 1,
CELL_PX - 1);
};
// Locked cells.
for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++)
for (uint8_t c = 0; c < TetrisGame::BOARD_COLS; c++)
if (game.board[r][c])
drawCell(static_cast<int8_t>(c), static_cast<int8_t>(r));
// Ghost piece - hollow outline.
const TetrisGame::Piece &cur = game.current();
const int8_t ghostR = game.ghostRow();
if (ghostR != cur.row) {
for (uint8_t pr = 0; pr < 4; pr++) {
for (uint8_t pc = 0; pc < 4; pc++) {
if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc))
continue;
const int8_t gc = static_cast<int8_t>(cur.col + pc);
const int8_t gr = static_cast<int8_t>(ghostR + pr);
if (gc < 0 || gr < 0 || gc >= TetrisGame::BOARD_COLS || gr >= TetrisGame::BOARD_ROWS)
continue;
display->setPixel(ox + static_cast<int16_t>(gc) * CELL_PX + 1, oy + static_cast<int16_t>(gr) * CELL_PX + 1);
}
}
}
// Active piece - filled.
for (uint8_t pr = 0; pr < 4; pr++) {
for (uint8_t pc = 0; pc < 4; pc++) {
if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc))
continue;
drawCell(static_cast<int8_t>(cur.col + pc), static_cast<int8_t>(cur.row + pr));
}
}
// --- Right panel: SCR and LVL ---
const int16_t rpx = ox + boardW + 2;
char buf[12];
display->setFont(FONT_SMALL);
display->setTextAlignment(TEXT_ALIGN_LEFT);
display->drawString(rpx, y + 2, "SCR");
snprintf(buf, sizeof(buf), "%lu", static_cast<unsigned long>(game.score()));
display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL, buf);
display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL * 2 + 2, "LVL");
snprintf(buf, sizeof(buf), "%u", static_cast<unsigned>(game.level()));
display->drawString(rpx, y + 2 + FONT_HEIGHT_SMALL * 3 + 2, buf);
// --- Left panel: NXT (next piece preview) centred in the panel ---
const int16_t lpanelW = ox - 2; // pixels available left of board separator
static constexpr int16_t PREV_PX = 3; // px per preview cell
const int16_t previewW = 4 * PREV_PX; // 12 px
const int16_t lpx = x + (lpanelW - previewW) / 2;
const int16_t nxtLabelY = y + 2;
const int16_t nxtPreviewY = nxtLabelY + FONT_HEIGHT_SMALL + 2;
display->setTextAlignment(TEXT_ALIGN_CENTER);
display->drawString(x + lpanelW / 2, nxtLabelY, "NXT");
const TetrisGame::Piece &nxt = game.next();
for (uint8_t pr = 0; pr < 4; pr++)
for (uint8_t pc = 0; pc < 4; pc++)
if (TetrisGame::pieceCell(nxt.type, nxt.rot, pr, pc))
display->fillRect(lpx + static_cast<int16_t>(pc) * PREV_PX, nxtPreviewY + static_cast<int16_t>(pr) * PREV_PX,
PREV_PX - 1, PREV_PX - 1);
#if GRAPHICS_TFT_COLORING_ENABLED
// On a colour display (e.g. HUB75), tint every block with its tetromino colour. The mono buffer
// still carries the block pixels drawn above; registering a colour region over each run of
// same-colour cells makes those "on" pixels render in colour instead of the theme foreground.
// Runs are merged horizontally per row to stay within the region budget, and empty cells cost
// nothing, so the region count only grows with how full the board is.
const uint16_t bg = graphics::getThemeBodyBg();
// Combined colour grid: locked cells plus the falling piece (same colour source == type + 1).
uint8_t cg[TetrisGame::BOARD_ROWS][TetrisGame::BOARD_COLS];
for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++)
for (uint8_t c = 0; c < TetrisGame::BOARD_COLS; c++)
cg[r][c] = game.board[r][c];
for (uint8_t pr = 0; pr < 4; pr++)
for (uint8_t pc = 0; pc < 4; pc++) {
if (!TetrisGame::pieceCell(cur.type, cur.rot, pr, pc))
continue;
const int br = cur.row + pr, bc = cur.col + pc;
if (br >= 0 && br < TetrisGame::BOARD_ROWS && bc >= 0 && bc < TetrisGame::BOARD_COLS)
cg[br][bc] = static_cast<uint8_t>(cur.type + 1);
}
for (uint8_t r = 0; r < TetrisGame::BOARD_ROWS; r++) {
uint8_t c = 0;
while (c < TetrisGame::BOARD_COLS) {
const uint8_t v = cg[r][c];
if (v == 0) {
c++;
continue;
}
const uint8_t c0 = c;
while (c < TetrisGame::BOARD_COLS && cg[r][c] == v)
c++;
const int16_t rx = ox + static_cast<int16_t>(c0) * CELL_PX;
const int16_t ry = oy + static_cast<int16_t>(r) * CELL_PX;
const int16_t rw = static_cast<int16_t>(c - c0) * CELL_PX - 1; // span the run, drop the trailing gap
graphics::registerTFTColorRegionDirect(rx, ry, rw, CELL_PX - 1, tetrominoColor(static_cast<uint8_t>(v - 1)), bg);
}
}
// Next-piece preview: one region over the 4x4 grid tinted with its colour.
graphics::registerTFTColorRegionDirect(lpx, nxtPreviewY, 4 * PREV_PX, 4 * PREV_PX, tetrominoColor(nxt.type), bg);
#endif
}
#endif // HAS_SCREEN && BASEUI_HAS_GAMES
+156
View File
@@ -0,0 +1,156 @@
#pragma once
#include <stdint.h>
#include <string.h>
/**
* Pure, self-contained Tetris game logic.
*
* No Arduino/display dependencies - designed to be unit-tested natively and reused by the Tetris
* adapter below without pulling in the display stack.
*
* Board coordinate system: col=0 is leftmost, row=0 is top (gravity goes toward higher rows).
* board[row][col] holds 0 (empty) or 1..7 (locked piece colour index).
* No dynamic allocation: total struct size is ~260 bytes.
*/
class TetrisGame
{
public:
static constexpr uint8_t BOARD_COLS = 10;
static constexpr uint8_t BOARD_ROWS = 16; // 16×4 px = 64 px - fills a standard 64px OLED
static constexpr uint8_t PIECE_TYPES = 7; // I O T S Z J L
struct Piece {
int8_t col; // left column of the 4×4 bounding box (may be negative during spawn)
int8_t row; // top row of the 4×4 bounding box (may be negative during spawn)
uint8_t type; // 0..6
uint8_t rot; // 0..3
};
// board[row][col]: 0 = empty, 1..7 = locked piece colour
uint8_t board[BOARD_ROWS][BOARD_COLS];
/** Start (or restart) the game. seed drives the xorshift32 RNG. */
void reset(uint32_t seed);
/** Shift the current piece left one column. Returns true if it moved. */
bool moveLeft();
/** Shift the current piece right one column. Returns true if it moved. */
bool moveRight();
/**
* Rotate the current piece CW. Tries a basic wall-kick (±1, ±2 column) if the
* natural rotation overlaps a wall or locked cell. Returns true if it rotated.
*/
bool rotate();
/**
* Move the current piece down one row. If it cannot fall it is locked,
* lines are cleared, the next piece becomes current, and a new next is generated.
* Returns false after locking (game may or may not be over).
*/
bool softDrop();
/**
* Instantly drop the current piece to where it would land and lock it.
* Awards 2 pts per row dropped.
*/
void hardDrop();
/**
* Gravity tick: same as softDrop() - move down one row, lock if needed.
* Returns true while the game is alive, false after game-over.
*/
bool step();
bool isPlaying() const { return alive; }
uint32_t score() const { return pts; }
uint8_t level() const { return lvl; }
uint16_t linesCleared() const { return lines; }
const Piece &current() const { return cur; }
const Piece &next() const { return nxt; }
/**
* Returns the top row the current piece would occupy if instantly dropped.
* Used by the renderer to show a ghost/shadow piece.
*/
int8_t ghostRow() const;
/**
* Returns true if cell (pr, pc) within the 4×4 bounding box is filled for
* the given piece type and rotation. Safe for any (type, rot, pr, pc).
*/
static bool pieceCell(uint8_t type, uint8_t rot, uint8_t pr, uint8_t pc);
private:
Piece cur = {};
Piece nxt = {};
uint32_t pts = 0;
uint8_t lvl = 1;
uint16_t lines = 0;
uint32_t rng = 1; // xorshift32 state - must never be 0
bool alive = false;
uint32_t nextRandom();
Piece spawnPiece(uint8_t type) const;
void advanceNext(); // lock cur, clear lines, shift nxt→cur, spawn new nxt
bool canPlace(const Piece &p) const;
void lockPiece();
int clearLines(); // returns number of lines cleared (0..4)
// Shape table: SHAPES[type][rot][row] = 4-bit column bitmask.
// Bit 0 = leftmost column (col 0), bit 3 = rightmost (col 3) of the 4×4 box.
static const uint8_t SHAPES[PIECE_TYPES][4][4];
};
#include "configuration.h"
#if HAS_SCREEN && BASEUI_HAS_GAMES
#include "Game.h"
#include "HighScoreTable.h"
/**
* Tetris as a hosted Game. Wraps the pure TetrisGame logic above and supplies the attract art, the
* portrait playfield renderer, the rotate/move/drop input, the level-based speed curve, and its
* own high-score table. The new-high-score mesh announcement is shared by all games and lives in
* GamesModule.
*/
class Tetris : public Game
{
public:
Tetris();
const char *name() const override { return "Tetris"; }
void start(uint32_t seed) override { game.reset(seed); }
bool tick() override { return game.step(); }
bool isPlaying() const override { return game.isPlaying(); }
uint32_t score() const override { return game.score(); }
int32_t tickIntervalMs() const override;
void handleInput(input_broker_event ev) override;
void drawAttract(OLEDDisplay *display, int16_t x, int16_t y) override;
void drawPlaying(OLEDDisplay *display, int16_t x, int16_t y) override;
const char *gameOverHint() const override { return "SEL: scores BCK: exit"; }
HighScoreTableBase &scores() override { return scores_; }
private:
// On-disk high-score record; layout preserved from the original TetrisModule so tetris.dat
// keeps loading. Magic 'TETR', file version 1.
struct TetrisEntry {
uint32_t score;
char shortName[5]; // NUL-terminated 3-char display name
uint32_t nodeNum;
uint32_t epoch;
} __attribute__((packed));
TetrisGame game;
HighScoreTable<TetrisEntry> scores_{"/prefs/tetris.dat", 0x54455452u, 1, "Tetris"};
};
#endif // HAS_SCREEN && BASEUI_HAS_GAMES