Merge develop into packet authentication policy
This commit is contained in:
+443
-31
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(¤tSecurityNumber, sizeof(currentSecurityNumber));
|
||||
hash.update(¤tNonce, sizeof(currentNonce));
|
||||
hash.update(¤tRemoteNode, 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(¤tNonce, 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(¤tRemoteNode, 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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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,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) &&
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ¤t() 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
|
||||
Reference in New Issue
Block a user