Compare commits

...
18 Commits
Author SHA1 Message Date
d4rks1d33 2a5c442503 IR as main app again but maybe remove later and make it external
Build Dev Firmware / build (push) Waiting to run
2026-07-18 18:51:41 -03:00
David 90e1c2b850 Fix custom emulate placeholder metadata
Build Dev Firmware / build (push) Waiting to run
2026-07-18 15:46:00 +02:00
David b6d8d67c6e Fix custom emulate decoded metadata display
Build Dev Firmware / build (push) Waiting to run
2026-07-18 15:22:59 +02:00
David ac8909904e Add custom emulate button metadata fallback
Build Dev Firmware / build (push) Canceled after 0s
2026-07-17 15:55:02 +02:00
David d4d8b0e646 Initialize custom emulate button metadata 2026-07-17 15:49:57 +02:00
David 36483ec07d Align custom emulate button labels
Build Dev Firmware / build (push) Canceled after 0s
2026-07-17 14:12:38 +02:00
David 807e74b7d6 Use custom emulate UI when enabled
Build Dev Firmware / build (push) Canceled after 0s
2026-07-17 13:47:28 +02:00
David f4fe2d48c1 Improve SubGhz proto filter handling
Build Dev Firmware / build (push) Has been cancelled
2026-07-15 12:03:08 +02:00
d4rks1d33 bc59edff17 FlipperGB is more fluid and stable, tutorial ask on discord
Build Dev Firmware / build (push) Has been cancelled
2026-07-15 00:56:04 -03:00
d4rks1d33 63d951dc38 Update ProtoPirate
Build Dev Firmware / build (push) Has been cancelled
2026-07-13 22:15:44 -03:00
d4rks1d33 f1422cb701 Full dpad emulation on receiver scene not need to save the signal to use all the buttons 2026-07-13 22:14:08 -03:00
David f7b04f1382 Fix saved signal editor command and counter updates
Build Dev Firmware / build (push) Has been cancelled
2026-07-12 23:55:11 +02:00
David 7286b5c529 Avoid in-place FastFAP objcopy 2026-07-12 21:29:36 +02:00
David 6a49fd89a1 Fix saved signal editor availability 2026-07-12 21:21:28 +02:00
David c4378c2e20 Approve SubGhz custom button API symbol 2026-07-12 19:36:51 +02:00
David 6e5ba9ec22 Rework saved signal editor and duplicate warning 2026-07-12 16:31:22 +02:00
David 502570a18b Show signal editor from saved file menu 2026-07-12 14:22:44 +02:00
David e606c5b24f Add saved signal editor and duplicate detection 2026-07-12 13:58:10 +02:00
155 changed files with 18004 additions and 1433 deletions
+1
View File
@@ -9,6 +9,7 @@ App(
"subghz",
"subghz_remote",
"subghz_bruteforcer",
"infrared",
"archive",
"main_apps_on_start",
],
@@ -0,0 +1,113 @@
#include "subghz_button_labels.h"
#include <furi.h>
#include <lib/subghz/blocks/custom_btn.h>
#include <string.h>
static const char* const button_default_labels[SUBGHZ_BUTTON_LABEL_COUNT] = {
"Original",
"Up",
"Down",
"Left",
"Right",
"Button 5",
"Button 6",
"Button 7",
};
typedef struct {
const char* protocol;
uint8_t max_custom_btn;
const char* labels[SUBGHZ_BUTTON_LABEL_COUNT];
} SubGhzProtocolButtonLabels;
static const SubGhzProtocolButtonLabels protocol_button_labels[] = {
{"VAG GROUP", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"Porsche AG", 4, {"Original", "Lock", "Unlock", "Trunk", "Open"}},
{"FORD V0", 3, {"Original", "Lock", "Unlock", "Trunk"}},
{"Ford V2", 4, {"Unlock", "Lock", "Trunk", "Panic", "Remote Start"}},
{"PSA GROUP", 4, {"Original", "Lock", "Unlock", "Trunk", "Trunk"}},
{"PSA OLD", 4, {"Original", "Lock", "Unlock", "Trunk", "Trunk"}},
{"KIA/HYU V0", 4, {"Original", "Lock", "Unlock", "Trunk", "Horn"}},
{"KIA/HYU V1", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"KIA/HYU V2", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"KIA/HYU V3", 5, {"Original", "Lock", "Unlock", "Trunk", "Panic", "Horn"}},
{"KIA/HYU V4", 5, {"Original", "Lock", "Unlock", "Trunk", "Panic", "Horn"}},
{"KIA/HYU V3/V4", 5, {"Original", "Lock", "Unlock", "Trunk", "Panic", "Horn"}},
{"KIA/HYU V5", 4, {"Original", "Unlock", "Lock", "Trunk", "Horn"}},
{"KIA/HYU V6", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"SUBARU", 5, {"Original", "Lock", "Unlock", "Trunk", "Panic", "Extra"}},
{"SUZUKI", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"Star Line", 4, {"Original", "Lock", "Unlock", "Trunk", "Start"}},
{"Scher-Khan", 4, {"Original", "Lock", "Unlock", "Trunk", "Start"}},
{"Sheriff CFM", 4, {"Original", "Lock", "Unlock", "Trunk", "Panic"}},
{"Nice FloR-S", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"CAME Atomo", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Alutech AT-4N", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"KeeLoq", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"Phoenix_V2", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"Beninca ARC", 2, {"Original", "Btn 1", "Btn 2"}},
{"GangQi", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Hay21", 2, {"Original", "Btn 1", "Btn 2"}},
{"Hollarm", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Jarolift", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"KingGates Stylo4k", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Princeton", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"Roger", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Security+ 2.0", 4, {"Original", "Btn 1", "Btn 2", "Btn 3", "Btn 4"}},
{"Somfy Telis", 3, {"Original", "Btn 1", "Btn 2", "Btn 3"}},
{"Faac SLH", 1, {"Original", "Btn 1"}},
};
void subghz_button_labels_reset(const char* labels[SUBGHZ_BUTTON_LABEL_COUNT]) {
for(uint8_t i = 0; i < SUBGHZ_BUTTON_LABEL_COUNT; i++) {
labels[i] = button_default_labels[i];
}
}
void subghz_button_labels_apply_protocol(
const char* protocol,
const char* labels[SUBGHZ_BUTTON_LABEL_COUNT]) {
if(!protocol) return;
for(uint8_t i = 0; i < COUNT_OF(protocol_button_labels); i++) {
if(strcmp(protocol, protocol_button_labels[i].protocol) == 0) {
for(uint8_t btn = 0; btn < SUBGHZ_BUTTON_LABEL_COUNT; btn++) {
if(protocol_button_labels[i].labels[btn]) {
labels[btn] = protocol_button_labels[i].labels[btn];
}
}
break;
}
}
}
const char* subghz_button_labels_get(
const char* const labels[SUBGHZ_BUTTON_LABEL_COUNT],
uint8_t custom_btn_id,
uint8_t original_custom_btn) {
if(custom_btn_id == SUBGHZ_CUSTOM_BTN_OK) {
if((original_custom_btn != SUBGHZ_CUSTOM_BTN_OK) &&
(original_custom_btn < SUBGHZ_BUTTON_LABEL_COUNT)) {
return labels[original_custom_btn];
}
}
if(custom_btn_id < SUBGHZ_BUTTON_LABEL_COUNT) {
return labels[custom_btn_id];
}
return "Button";
}
uint8_t subghz_button_labels_get_max_custom_btn(const char* protocol) {
if(!protocol) return 0;
for(uint8_t i = 0; i < COUNT_OF(protocol_button_labels); i++) {
if(strcmp(protocol, protocol_button_labels[i].protocol) == 0) {
return protocol_button_labels[i].max_custom_btn;
}
}
return 0;
}
@@ -0,0 +1,26 @@
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define SUBGHZ_BUTTON_LABEL_COUNT 8
void subghz_button_labels_reset(const char* labels[SUBGHZ_BUTTON_LABEL_COUNT]);
void subghz_button_labels_apply_protocol(
const char* protocol,
const char* labels[SUBGHZ_BUTTON_LABEL_COUNT]);
const char* subghz_button_labels_get(
const char* const labels[SUBGHZ_BUTTON_LABEL_COUNT],
uint8_t custom_btn_id,
uint8_t original_custom_btn);
uint8_t subghz_button_labels_get_max_custom_btn(const char* protocol);
#ifdef __cplusplus
}
#endif
@@ -15,6 +15,7 @@ typedef enum {
SubGhzCustomEventSceneReceiverInfoTxStart,
SubGhzCustomEventSceneReceiverInfoTxStop,
SubGhzCustomEventSceneReceiverInfoSave,
SubGhzCustomEventSceneReceiverInfoTxFullDpad,
SubGhzCustomEventSceneSaveName,
SubGhzCustomEventSceneSignalSettings,
SubGhzCustomEventSceneSaveSuccess,
@@ -361,6 +361,49 @@ SubGhzTxRxStartTxState subghz_txrx_tx_start(SubGhzTxRx* instance, FlipperFormat*
return ret;
}
bool subghz_txrx_rebuild_from_fff(SubGhzTxRx* instance, FlipperFormat* flipper_format) {
furi_assert(instance);
furi_assert(flipper_format);
subghz_txrx_stop(instance);
bool rebuilt = false;
FuriString* temp_str = furi_string_alloc();
SubGhzTransmitter* transmitter = NULL;
do {
if(!flipper_format_rewind(flipper_format)) {
FURI_LOG_E(TAG, "Rewind error");
break;
}
if(!flipper_format_read_string(flipper_format, "Protocol", temp_str)) {
FURI_LOG_E(TAG, "Missing Protocol");
break;
}
transmitter =
subghz_transmitter_alloc_init(instance->environment, furi_string_get_cstr(temp_str));
if(!transmitter) {
FURI_LOG_E(TAG, "Protocol not found");
break;
}
rebuilt =
subghz_transmitter_deserialize(transmitter, flipper_format) == SubGhzProtocolStatusOk;
if(!rebuilt) {
FURI_LOG_E(TAG, "Protocol rebuild failed");
break;
}
} while(false);
if(transmitter) {
subghz_transmitter_free(transmitter);
}
furi_string_free(temp_str);
return rebuilt;
}
void subghz_txrx_rx_start(SubGhzTxRx* instance) {
furi_assert(instance);
subghz_txrx_stop(instance);
@@ -105,6 +105,15 @@ void subghz_txrx_get_frequency_and_modulation(
*/
SubGhzTxRxStartTxState subghz_txrx_tx_start(SubGhzTxRx* instance, FlipperFormat* flipper_format);
/**
* Rebuild protocol data without starting TX.
*
* @param instance Pointer to a SubGhzTxRx
* @param flipper_format Pointer to a FlipperFormat
* @return bool True when encoder deserialization updated protocol data successfully
*/
bool subghz_txrx_rebuild_from_fff(SubGhzTxRx* instance, FlipperFormat* flipper_format);
/**
* Start RX CC1101
*
@@ -9,11 +9,13 @@
*/
#include "../subghz_i.h"
#include "../views/subghz_car_emulate.h"
#include "../helpers/subghz_button_labels.h"
#include "../helpers/subghz_custom_event.h"
#include <lib/subghz/blocks/generic.h>
#include <notification/notification_messages.h>
#include "../helpers/subghz_txrx_i.h"
#include <lib/subghz/blocks/custom_btn_i.h>
#include <string.h>
#define TAG "SubGhzSceneCarEmulate"
#define MIN_TX_TICKS 66U /* ~666 ms at 100 ms tick */
@@ -29,6 +31,9 @@ typedef struct {
uint32_t freq;
char preset_short[12]; /* "AM650", "FM476", … */
bool has_serial;
bool has_counter;
/* TX state */
bool is_transmitting;
bool stop_pending; /* stop requested before MIN_TX_TICKS elapsed */
@@ -36,6 +41,7 @@ typedef struct {
/* Resolved custom button repeated while the physical key is held */
uint8_t pending_button;
uint8_t max_custom_button;
} CarEmulateState;
static CarEmulateState* s_state = NULL;
@@ -201,8 +207,101 @@ static void car_emulate_read_freq_preset(SubGhz* subghz, CarEmulateState* st) {
furi_string_free(preset_str);
}
static bool car_emulate_hex_digit(char c, uint8_t* value) {
if(c >= '0' && c <= '9') {
*value = c - '0';
return true;
} else if(c >= 'a' && c <= 'f') {
*value = c - 'a' + 10;
return true;
} else if(c >= 'A' && c <= 'F') {
*value = c - 'A' + 10;
return true;
}
return false;
}
static bool car_emulate_parse_hex_after(
const char* text,
const char* marker,
uint32_t* value) {
const char* field = strstr(text, marker);
if(!field) return false;
field += strlen(marker);
while(*field == ' ' || *field == '\t' || *field == '[') {
field++;
}
if(field[0] == '0' && (field[1] == 'x' || field[1] == 'X')) {
field += 2;
}
uint32_t parsed = 0;
uint8_t digits = 0;
uint8_t digit_value = 0;
while((digits < 8) && car_emulate_hex_digit(*field, &digit_value)) {
parsed = (parsed << 4) | digit_value;
digits++;
field++;
}
if(digits == 0) return false;
*value = parsed;
return true;
}
static bool car_emulate_serial_is_placeholder(void) {
return !s_state->has_serial || (s_state->serial <= 1);
}
static bool car_emulate_counter_is_placeholder(void) {
return !s_state->has_counter || (s_state->current_counter == 0);
}
static void car_emulate_set_counter(uint32_t value) {
s_state->original_counter = value;
s_state->current_counter = value;
s_state->has_counter = true;
}
static void car_emulate_apply_global_metadata(void) {
furi_assert(s_state);
if(subghz_block_generic_global.cnt_is_available) {
car_emulate_set_counter(subghz_block_generic_global.current_cnt);
}
}
static void car_emulate_apply_decoded_metadata(FuriString* decoded_text) {
furi_assert(s_state);
if(!decoded_text) return;
const char* text = furi_string_get_cstr(decoded_text);
uint32_t value = 0;
if((car_emulate_parse_hex_after(text, "Sn:", &value) ||
car_emulate_parse_hex_after(text, "SN:", &value) ||
car_emulate_parse_hex_after(text, "Ser:", &value) ||
car_emulate_parse_hex_after(text, "Serial:", &value)) &&
(value != 0)) {
s_state->serial = value;
s_state->has_serial = true;
} else if(
car_emulate_serial_is_placeholder() &&
car_emulate_parse_hex_after(text, "Fix:", &value) &&
(value != 0)) {
s_state->serial = value;
s_state->has_serial = true;
}
if(car_emulate_parse_hex_after(text, "Cnt:", &value) &&
((value != 0) || car_emulate_counter_is_placeholder())) {
car_emulate_set_counter(value);
}
}
/** Update Btn and Cnt fields in fff_data so the transmitter re-serialises them. */
static void car_emulate_apply_button(SubGhz* subghz, InputKey key) {
static bool car_emulate_apply_button(SubGhz* subghz, InputKey key) {
UNUSED(subghz);
uint8_t custom_btn_id;
@@ -215,7 +314,11 @@ static void car_emulate_apply_button(SubGhz* subghz, InputKey key) {
default: custom_btn_id = SUBGHZ_CUSTOM_BTN_OK; break;
}
subghz_custom_btn_set(custom_btn_id);
if(custom_btn_id > s_state->max_custom_button) {
return false;
}
return subghz_custom_btn_set(custom_btn_id);
}
@@ -331,7 +434,8 @@ void subghz_scene_car_emulate_on_enter(void* context) {
}
flipper_format_rewind(fff);
flipper_format_read_uint32(fff, "Serial", &s_state->serial, 1);
s_state->has_serial =
flipper_format_read_uint32(fff, "Serial", &s_state->serial, 1);
flipper_format_rewind(fff);
uint32_t btn_tmp = 0;
@@ -340,7 +444,8 @@ void subghz_scene_car_emulate_on_enter(void* context) {
}
flipper_format_rewind(fff);
flipper_format_read_uint32(fff, "Cnt", &s_state->original_counter, 1);
s_state->has_counter =
flipper_format_read_uint32(fff, "Cnt", &s_state->original_counter, 1);
s_state->current_counter = s_state->original_counter;
furi_string_free(tmp);
@@ -356,24 +461,58 @@ void subghz_scene_car_emulate_on_enter(void* context) {
* - subghz_custom_btn_get_original() → the button that was in the file
* - subghz_custom_btn_is_allowed() → true if protocol supports it
* - subghz_custom_btn_get_max() → number of buttons available */
subghz_block_generic_global_reset(NULL);
subghz_custom_btns_reset();
SubGhzProtocolDecoderBase* decoder = subghz_txrx_get_decoder(subghz->txrx);
if(decoder && fff) {
flipper_format_rewind(fff);
subghz_protocol_decoder_base_deserialize(decoder, fff);
if(subghz_protocol_decoder_base_deserialize(decoder, fff) == SubGhzProtocolStatusOk) {
FuriString* decoded_text = furi_string_alloc();
subghz_protocol_decoder_base_get_string(decoder, decoded_text);
car_emulate_apply_global_metadata();
car_emulate_apply_decoded_metadata(decoded_text);
furi_string_free(decoded_text);
}
/* Rewind again so subsequent reads in car_emulate_read_freq_preset()
* start from the beginning of the file. */
flipper_format_rewind(fff);
}
subghz_car_emulate_view_set_labels(
s_state->max_custom_button =
subghz_custom_btn_is_allowed() ? subghz_custom_btn_get_max() : SUBGHZ_CUSTOM_BTN_OK;
if(s_state->max_custom_button == SUBGHZ_CUSTOM_BTN_OK) {
s_state->max_custom_button =
subghz_button_labels_get_max_custom_btn(s_state->protocol_name);
if(s_state->max_custom_button != SUBGHZ_CUSTOM_BTN_OK) {
subghz_custom_btn_set_max(s_state->max_custom_button);
}
}
const char* button_labels[SUBGHZ_BUTTON_LABEL_COUNT];
subghz_button_labels_reset(button_labels);
subghz_button_labels_apply_protocol(s_state->protocol_name, button_labels);
subghz_car_emulate_view_set_labels(
subghz->car_emulate_view,
"UNLOCK", /* OK */
"LOCK", /* Up */
"TRUNK", /* Down */
"PANIC", /* Left */
"START" /* Right */
subghz_button_labels_get(
button_labels, SUBGHZ_CUSTOM_BTN_OK, subghz_custom_btn_get_original()),
s_state->max_custom_button >= SUBGHZ_CUSTOM_BTN_UP ?
subghz_button_labels_get(
button_labels, SUBGHZ_CUSTOM_BTN_UP, subghz_custom_btn_get_original()) :
"",
s_state->max_custom_button >= SUBGHZ_CUSTOM_BTN_DOWN ?
subghz_button_labels_get(
button_labels, SUBGHZ_CUSTOM_BTN_DOWN, subghz_custom_btn_get_original()) :
"",
s_state->max_custom_button >= SUBGHZ_CUSTOM_BTN_LEFT ?
subghz_button_labels_get(
button_labels, SUBGHZ_CUSTOM_BTN_LEFT, subghz_custom_btn_get_original()) :
"",
s_state->max_custom_button >= SUBGHZ_CUSTOM_BTN_RIGHT ?
subghz_button_labels_get(
button_labels, SUBGHZ_CUSTOM_BTN_RIGHT, subghz_custom_btn_get_original()) :
""
);
car_emulate_read_freq_preset(subghz, s_state);
@@ -407,14 +546,18 @@ bool subghz_scene_car_emulate_on_event(void* context, SceneManagerEvent event) {
car_emulate_stop_tx(subghz);
}
/* Bump counter */
s_state->current_counter++;
/* Set the custom button BEFORE deserialize() is called inside
* subghz_tx_start() → subghz_txrx_tx_start().
* The protocol's deserialize() will call subghz_custom_btn_get()
* to pick the right button code. */
car_emulate_apply_button(subghz, key);
if(!car_emulate_apply_button(subghz, key)) {
notification_message(subghz->notifications, &sequence_error);
car_emulate_refresh_view(subghz);
return true;
}
/* Bump counter */
s_state->current_counter++;
/* Only update the counter in fff_data; the protocol handles Btn. */
car_emulate_update_fff(subghz, s_state->current_counter);
@@ -1,105 +1,68 @@
#include "../subghz_i.h"
#include <lib/subghz/subghz_protocol_registry.h>
#include <stdlib.h>
#define TAG "SubGhzSceneProtocolList"
/* ── helpers ──────────────────────────────────────────────────────────────── */
typedef struct {
SubGhz* subghz;
const char* protocol_name;
} SubGhzProtocolListItemContext;
static bool proto_filter_contains(const char* filter, const char* name) {
const char* p = filter;
while(*p) {
const char* comma = strchr(p, ',');
size_t len = comma ? (size_t)(comma - p) : strlen(p);
if(len == strlen(name) && strncmp(p, name, len) == 0) return true;
if(!comma) break;
p = comma + 1;
}
return false;
static SubGhzProtocolListItemContext* protocol_list_item_contexts = NULL;
static void subghz_scene_protocol_list_free_contexts(void) {
free(protocol_list_item_contexts);
protocol_list_item_contexts = NULL;
}
static void proto_filter_toggle(char* filter, size_t filter_size, const char* name) {
if(proto_filter_contains(filter, name)) {
/* remove it */
char tmp[256] = {0};
const char* p = filter;
bool first = true;
while(*p) {
const char* comma = strchr(p, ',');
size_t len = comma ? (size_t)(comma - p) : strlen(p);
if(!(len == strlen(name) && strncmp(p, name, len) == 0)) {
if(!first) strncat(tmp, ",", sizeof(tmp) - strlen(tmp) - 1);
strncat(tmp, p, len < sizeof(tmp) - strlen(tmp) - 1 ? len : 0);
first = false;
}
if(!comma) break;
p = comma + 1;
}
strncpy(filter, tmp, filter_size - 1);
filter[filter_size - 1] = '\0';
} else {
/* add it */
if(filter[0] != '\0') strncat(filter, ",", filter_size - strlen(filter) - 1);
strncat(filter, name, filter_size - strlen(filter) - 1);
}
}
/* ── callbacks ────────────────────────────────────────────────────────────── */
static void subghz_scene_protocol_list_item_changed(VariableItem* item) {
SubGhz* subghz = variable_item_get_context(item);
uint8_t value_index = variable_item_get_current_value_index(item);
SubGhzProtocolListItemContext* item_context = variable_item_get_context(item);
if(!item_context || !item_context->subghz || !item_context->protocol_name) return;
uint32_t proto_idx =
scene_manager_get_scene_state(subghz->scene_manager, SubGhzSceneProtocolList);
size_t selected =
variable_item_list_get_selected_item_index(subghz->variable_item_list);
UNUSED(proto_idx);
SubGhz* subghz = item_context->subghz;
bool should_disable = variable_item_get_current_value_index(item) == 1;
const SubGhzProtocol* protocol =
subghz_protocol_registry_get_by_index(&subghz_protocol_registry, selected);
if(!protocol) return;
bool changed = subghz_last_settings_protocol_filter_set(
subghz->last_settings, item_context->protocol_name, should_disable);
bool is_disabled = subghz_last_settings_protocol_filter_contains(
subghz->last_settings, item_context->protocol_name);
const char* name = protocol->name;
bool currently_in =
proto_filter_contains(subghz->last_settings->protocol_filter, name);
if((value_index == 1) != currently_in) {
proto_filter_toggle(
subghz->last_settings->protocol_filter,
sizeof(subghz->last_settings->protocol_filter),
name);
}
variable_item_set_current_value_text(item, value_index ? "ONLY" : "---");
subghz_last_settings_save(subghz->last_settings);
variable_item_set_current_value_index(item, is_disabled ? 1 : 0);
variable_item_set_current_value_text(item, is_disabled ? "OFF" : "ON");
if(changed) subghz_last_settings_save(subghz->last_settings);
}
/* ── scene callbacks ──────────────────────────────────────────────────────── */
void subghz_scene_protocol_list_on_enter(void* context) {
SubGhz* subghz = context;
VariableItemList* list = subghz->variable_item_list;
variable_item_list_reset(list);
subghz_scene_protocol_list_free_contexts();
size_t protocol_count = subghz_protocol_registry_count(&subghz_protocol_registry);
protocol_list_item_contexts =
malloc(sizeof(SubGhzProtocolListItemContext) * protocol_count);
furi_check(protocol_list_item_contexts);
for(size_t i = 0; i < protocol_count; i++) {
const SubGhzProtocol* protocol =
subghz_protocol_registry_get_by_index(&subghz_protocol_registry, i);
if(!protocol) continue;
protocol_list_item_contexts[i].subghz = subghz;
protocol_list_item_contexts[i].protocol_name = protocol->name;
VariableItem* item = variable_item_list_add(
list,
protocol->name,
2,
subghz_scene_protocol_list_item_changed,
subghz);
&protocol_list_item_contexts[i]);
bool enabled = proto_filter_contains(
subghz->last_settings->protocol_filter, protocol->name);
variable_item_set_current_value_index(item, enabled ? 1 : 0);
variable_item_set_current_value_text(item, enabled ? "ONLY" : "---");
bool is_disabled =
subghz_last_settings_protocol_filter_contains(subghz->last_settings, protocol->name);
variable_item_set_current_value_index(item, is_disabled ? 1 : 0);
variable_item_set_current_value_text(item, is_disabled ? "OFF" : "ON");
}
variable_item_list_set_selected_item(
@@ -132,4 +95,5 @@ void subghz_scene_protocol_list_on_exit(void* context) {
SubGhzSceneProtocolList,
variable_item_list_get_selected_item_index(subghz->variable_item_list));
variable_item_list_reset(subghz->variable_item_list);
subghz_scene_protocol_list_free_contexts();
}
@@ -105,27 +105,10 @@ static void subghz_scene_add_to_history_callback(
SubGhz* subghz = context;
// The check can be moved to /lib/subghz/receiver.c, but may result in false positives
/* Protocol name allowlist filter — if non-empty, drop anything not in the list */
if(subghz->last_settings->protocol_filter[0] != '\0') {
const char* proto_name = decoder_base->protocol->name;
const char* filter = subghz->last_settings->protocol_filter;
bool allowed = false;
/* Walk the comma-separated list */
const char* p = filter;
while(*p) {
const char* comma = strchr(p, ',');
size_t len = comma ? (size_t)(comma - p) : strlen(p);
if(len == strlen(proto_name) && strncmp(p, proto_name, len) == 0) {
allowed = true;
break;
}
if(!comma) break;
p = comma + 1;
}
if(!allowed) {
FURI_LOG_D(TAG, "%s filtered by allowlist", proto_name);
return;
}
if(subghz_last_settings_protocol_filter_contains(
subghz->last_settings, decoder_base->protocol->name)) {
FURI_LOG_D(TAG, "%s filtered by protocol OFF list", decoder_base->protocol->name);
return;
}
if((decoder_base->protocol->flag & subghz->ignore_filter) == 0) {
@@ -679,15 +679,13 @@ void subghz_scene_receiver_config_on_enter(void* context) {
1,
NULL,
subghz);
if(subghz->last_settings->protocol_filter[0] == '\0') {
variable_item_set_current_value_text(item, "All");
uint8_t protocol_filter_count =
subghz_last_settings_protocol_filter_count(subghz->last_settings);
if(protocol_filter_count == 0) {
variable_item_set_current_value_text(item, "All ON");
} else {
uint8_t count = 1;
for(const char* p = subghz->last_settings->protocol_filter; *p; p++) {
if(*p == ',') count++;
}
static char filter_count_str[8];
snprintf(filter_count_str, sizeof(filter_count_str), "%u set", count);
snprintf(filter_count_str, sizeof(filter_count_str), "%u OFF", protocol_filter_count);
variable_item_set_current_value_text(item, filter_count_str);
}
@@ -1,6 +1,7 @@
#include "../subghz_i.h"
#include <lib/subghz/blocks/custom_btn.h>
#include <flipper_format/flipper_format_i.h>
#include "applications/main/subghz/helpers/subghz_txrx_i.h"
#include <lib/subghz/blocks/generic.h>
@@ -20,6 +21,9 @@ void subghz_scene_receiver_info_callback(GuiButtonType result, InputType type, v
} else if((result == GuiButtonTypeRight) && (type == InputTypeShort)) {
view_dispatcher_send_custom_event(
subghz->view_dispatcher, SubGhzCustomEventSceneReceiverInfoSave);
} else if((result == GuiButtonTypeLeft) && (type == InputTypeShort)) {
view_dispatcher_send_custom_event(
subghz->view_dispatcher, SubGhzCustomEventSceneReceiverInfoTxFullDpad);
}
}
@@ -29,7 +33,6 @@ static bool subghz_scene_receiver_info_update_parser(void* context) {
if(subghz_txrx_load_decoder_by_name_protocol(
subghz->txrx,
subghz_history_get_protocol_name(subghz->history, subghz->idx_menu_chosen))) {
// we are trying to deserialize without checking for errors, since it is assumed that we just received this chignal
subghz_protocol_decoder_base_deserialize(
subghz_txrx_get_decoder(subghz->txrx),
subghz_history_get_raw_data(subghz->history, subghz->idx_menu_chosen));
@@ -37,7 +40,6 @@ static bool subghz_scene_receiver_info_update_parser(void* context) {
SubGhzRadioPreset* preset =
subghz_history_get_radio_preset(subghz->history, subghz->idx_menu_chosen);
//Edit TX power, if necessary.
subghz_txrx_set_tx_power(preset->data, preset->data_size, subghz->tx_power);
subghz_txrx_set_preset(
@@ -93,7 +95,7 @@ void subghz_scene_receiver_info_draw_widget(SubGhz* subghz) {
subghz_scene_receiver_info_callback,
subghz);
}
// Removed static check
if(subghz_txrx_protocol_is_transmittable(subghz->txrx, false)) {
widget_add_button_element(
subghz->widget,
@@ -101,9 +103,14 @@ void subghz_scene_receiver_info_draw_widget(SubGhz* subghz) {
"Send",
subghz_scene_receiver_info_callback,
subghz);
widget_add_button_element(
subghz->widget,
GuiButtonTypeLeft,
"Full",
subghz_scene_receiver_info_callback,
subghz);
}
} else {
// [NO_DOLPHIN] widget_add_icon_element(subghz->widget, 83, 22, &I_WarningDolphinFlip_45x42);
widget_add_string_element(
subghz->widget, 13, 8, AlignLeft, AlignBottom, FontSecondary, "Error history parse.");
}
@@ -131,12 +138,7 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
if(!subghz_scene_receiver_info_update_parser(subghz)) {
return false;
}
//CC1101 Stop RX -> Start TX
subghz_txrx_hopper_pause(subghz->txrx);
// key concept: we start endless TX until user release OK button, and after this we send last
// protocols repeats - this guarantee that one press OK will
// be guarantee send the required minimum protocol data packets
// for all of this we use subghz_block_generic_global.endless_tx in protocols _yield function.
subghz->state_notifications = SubGhzNotificationStateTx;
subghz_block_generic_global.endless_tx = true;
if(!subghz_tx_start(
@@ -146,37 +148,51 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
subghz_txrx_hopper_unpause(subghz->txrx);
subghz->state_notifications = SubGhzNotificationStateRx;
subghz_block_generic_global.endless_tx = false;
return true;
}
return true;
} else if(event.event == SubGhzCustomEventSceneReceiverInfoTxStop) {
//CC1101 Stop Tx -> next tick event Start RX
// user release OK
// we switch off endless_tx - that mean protocols yield finish endless transmission,
// send upload "repeat=xx" times, and after will be stoped by the tick event down in this code
subghz->state_notifications = SubGhzNotificationStateTxWait;
subghz_block_generic_global.endless_tx = false;
return true;
} else if(event.event == SubGhzCustomEventSceneReceiverInfoSave) {
//CC1101 Stop RX -> Save
subghz->state_notifications = SubGhzNotificationStateIDLE;
subghz_txrx_hopper_set_state(subghz->txrx, SubGhzHopperStateOFF);
subghz_txrx_stop(subghz->txrx);
if(!subghz_scene_receiver_info_update_parser(subghz)) {
return false;
}
if(subghz_txrx_protocol_is_serializable(subghz->txrx)) {
subghz_file_name_clear(subghz);
subghz->save_datetime =
subghz_history_get_datetime(subghz->history, subghz->idx_menu_chosen);
subghz->save_datetime_set = true;
scene_manager_next_scene(subghz->scene_manager, SubGhzSceneSaveName);
}
return true;
} else if(event.event == SubGhzCustomEventSceneReceiverInfoTxFullDpad) {
if(!subghz_scene_receiver_info_update_parser(subghz)) {
return false;
}
FlipperFormat* fff_history =
subghz_history_get_raw_data(subghz->history, subghz->idx_menu_chosen);
FlipperFormat* fff_data = subghz_txrx_get_fff_data(subghz->txrx);
Stream* src = flipper_format_get_raw_stream(fff_history);
Stream* dst = flipper_format_get_raw_stream(fff_data);
stream_seek(src, 0, StreamOffsetFromStart);
stream_clean(dst);
stream_copy_full(src, dst);
stream_seek(dst, 0, StreamOffsetFromStart);
scene_manager_next_scene(subghz->scene_manager, SubGhzSceneTransmitter);
return true;
}
} else if(event.type == SceneManagerEventTypeTick) {
if(subghz_txrx_hopper_get_state(subghz->txrx) != SubGhzHopperStateOFF) {
subghz_txrx_hopper_update(subghz->txrx, subghz->last_settings->hopping_threshold);
@@ -193,18 +209,15 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
subghz->state_notifications = SubGhzNotificationStateRx;
break;
case SubGhzNotificationStateTxWait:
// we wait until hardware TX finished and after stop TX and start RX, else just blink led
if(!subghz_devices_is_async_complete_tx(subghz->txrx->radio_device)) {
notification_message(subghz->notifications, &sequence_blink_magenta_10);
} else {
subghz_txrx_stop(subghz->txrx);
// update screen
widget_reset(subghz->widget);
subghz_scene_receiver_info_draw_widget(subghz);
subghz->state_notifications = SubGhzNotificationStateIDLE;
if(!scene_manager_has_previous_scene(subghz->scene_manager, SubGhzSceneDecodeRAW)) {
if(!scene_manager_has_previous_scene(
subghz->scene_manager, SubGhzSceneDecodeRAW)) {
subghz_txrx_rx_start(subghz->txrx);
subghz_txrx_hopper_unpause(subghz->txrx);
if(!subghz_history_get_text_space_left(subghz->history, NULL)) {
@@ -222,7 +235,6 @@ bool subghz_scene_receiver_info_on_event(void* context, SceneManagerEvent event)
void subghz_scene_receiver_info_on_exit(void* context) {
SubGhz* subghz = context;
widget_reset(subghz->widget);
subghz_txrx_reset_dynamic_and_custom_btns(subghz->txrx);
}
@@ -110,6 +110,7 @@ bool subghz_scene_save_name_on_event(void* context, SceneManagerEvent event) {
} else if(event.type == SceneManagerEventTypeCustom) {
if(event.event == SubGhzCustomEventSceneSaveName) {
if(strcmp(subghz->file_name_tmp, "") != 0) {
furi_string_reset(subghz->error_str);
furi_string_cat_printf(
subghz->file_path,
"/%s%s",
@@ -12,8 +12,14 @@ void subghz_scene_save_success_on_enter(void* context) {
// Setup view
Popup* popup = subghz->popup;
// [NO_DOLPHIN] popup_set_icon(popup, 36, 5, &I_DolphinSaved_92x58);
popup_set_header(popup, "Saved", 15, 19, AlignLeft, AlignBottom);
popup_set_timeout(popup, 1500);
if(furi_string_size(subghz->error_str)) {
popup_set_header(popup, "Saved", 15, 4, AlignLeft, AlignTop);
popup_set_text(popup, furi_string_get_cstr(subghz->error_str), 4, 18, AlignLeft, AlignTop);
popup_set_timeout(popup, 2500);
} else {
popup_set_header(popup, "Saved", 15, 19, AlignLeft, AlignBottom);
popup_set_timeout(popup, 1500);
}
popup_set_context(popup, subghz);
popup_set_callback(popup, subghz_scene_save_success_popup_callback);
popup_enable_timeout(popup);
@@ -72,4 +78,5 @@ void subghz_scene_save_success_on_exit(void* context) {
Popup* popup = subghz->popup;
popup_reset(popup);
furi_string_reset(subghz->error_str);
}
@@ -2,10 +2,10 @@
enum SubmenuIndex {
SubmenuIndexEmulate,
SubmenuIndexSignalSettings,
SubmenuIndexPsaDecrypt,
SubmenuIndexEdit,
SubmenuIndexDelete,
SubmenuIndexSignalSettings,
SubmenuIndexCounterBf, /* <-- comma was missing here */
SubmenuIndexCarEmulateSettings,
};
@@ -20,17 +20,20 @@ void subghz_scene_saved_menu_on_enter(void* context) {
FlipperFormat* fff = subghz_txrx_get_fff_data(subghz->txrx);
bool is_psa_encrypted = false;
bool has_signal_editor = false;
bool has_counter = false;
if(fff) {
FuriString* proto = furi_string_alloc();
flipper_format_rewind(fff);
if(flipper_format_read_string(fff, "Protocol", proto)) {
has_signal_editor = !furi_string_equal_str(proto, "RAW");
if(furi_string_equal_str(proto, "PSA GROUP")) {
FuriString* type_str = furi_string_alloc();
flipper_format_rewind(fff);
if(!flipper_format_read_string(fff, "Type", type_str) ||
furi_string_equal_str(type_str, "00")) {
is_psa_encrypted = true;
has_signal_editor = false;
}
furi_string_free(type_str);
}
@@ -53,6 +56,15 @@ void subghz_scene_saved_menu_on_enter(void* context) {
SubmenuIndexEmulate,
subghz_scene_saved_menu_submenu_callback,
subghz);
if(has_signal_editor) {
submenu_add_item(
subghz->submenu,
"Signal Editor",
SubmenuIndexSignalSettings,
subghz_scene_saved_menu_submenu_callback,
subghz);
}
}
if(is_psa_encrypted) {
@@ -85,15 +97,6 @@ void subghz_scene_saved_menu_on_enter(void* context) {
subghz_scene_saved_menu_submenu_callback,
subghz);
if(furi_hal_rtc_is_flag_set(FuriHalRtcFlagDebug)) {
submenu_add_item(
subghz->submenu,
"Signal Settings",
SubmenuIndexSignalSettings,
subghz_scene_saved_menu_submenu_callback,
subghz);
}
if(has_counter) {
submenu_add_item(
subghz->submenu,
@@ -118,17 +121,7 @@ bool subghz_scene_saved_menu_on_event(void* context, SceneManagerEvent event) {
scene_manager_set_scene_state(
subghz->scene_manager, SubGhzSceneSavedMenu, SubmenuIndexEmulate);
bool use_custom = subghz->last_settings->custom_car_emulate;
if(use_custom) {
FlipperFormat* fff = subghz_txrx_get_fff_data(subghz->txrx);
uint32_t cnt_tmp = 0;
flipper_format_rewind(fff);
if(!flipper_format_read_uint32(fff, "Cnt", &cnt_tmp, 1)) {
use_custom = false;
}
}
if(use_custom) {
if(subghz->last_settings->custom_car_emulate) {
scene_manager_next_scene(subghz->scene_manager, SubGhzSceneCarEmulate);
} else {
scene_manager_next_scene(subghz->scene_manager, SubGhzSceneTransmitter);
@@ -1,10 +1,14 @@
#include "../subghz_i.h"
#include "subghz/types.h"
#include "../helpers/subghz_button_labels.h"
#include "../helpers/subghz_custom_event.h"
#include <lib/toolbox/value_index.h>
#include <machine/endian.h>
#include <toolbox/strint.h>
#include <lib/subghz/blocks/generic.h>
#include <lib/subghz/blocks/custom_btn_i.h>
#include <string.h>
#include <stdio.h>
#define TAG "SubGhzSceneSignalSettings"
@@ -13,6 +17,8 @@ static uint32_t counter32 = 0x0;
static uint16_t counter16 = 0x0;
static uint8_t cnt_byte_count = 0;
static uint8_t* cnt_byte_ptr = NULL;
static uint32_t counter_value = 0;
static uint32_t counter_mask = 0xffffffffUL;
static FuriString* byte_input_text;
@@ -21,6 +27,26 @@ static uint8_t btn_byte_count = 1;
static uint8_t* btn_byte_ptr = NULL;
static uint8_t submenu_called = 0;
static bool button_uses_custom_btn = false;
static uint8_t button_custom_id = SUBGHZ_CUSTOM_BTN_OK;
static bool subghz_scene_signal_settings_rebuild_save_reload(
SubGhz* subghz,
bool use_custom_btn,
uint8_t custom_btn_id);
enum {
SignalSettingsIndexCounterMode,
SignalSettingsIndexCounter,
SignalSettingsIndexButton,
};
enum {
SignalSettingsCounterStepDown,
SignalSettingsCounterStepValue,
SignalSettingsCounterStepUp,
SignalSettingsCounterStepCount,
};
#define COUNTER_MODE_COUNT 8
static const char* const counter_mode_text[COUNTER_MODE_COUNT] = {
@@ -45,6 +71,30 @@ static const int32_t counter_mode_value[COUNTER_MODE_COUNT] = {
7,
};
static const uint8_t button_value[] = {
SUBGHZ_CUSTOM_BTN_OK,
SUBGHZ_CUSTOM_BTN_UP,
SUBGHZ_CUSTOM_BTN_DOWN,
SUBGHZ_CUSTOM_BTN_LEFT,
SUBGHZ_CUSTOM_BTN_RIGHT,
5,
6,
7,
};
#define BUTTON_VALUE_COUNT SUBGHZ_BUTTON_LABEL_COUNT
static const char* button_labels[BUTTON_VALUE_COUNT] = {
"Original",
"Up",
"Down",
"Left",
"Right",
"Button 5",
"Button 6",
"Button 7",
};
typedef struct {
char* name;
uint8_t mode_count;
@@ -59,7 +109,206 @@ static Protocols protocols[] = {
{"Phoenix_V2", 3},
};
#define PROTOCOLS_COUNT (sizeof(protocols) / sizeof(Protocols));
#define PROTOCOLS_COUNT (sizeof(protocols) / sizeof(Protocols))
static void subghz_scene_signal_settings_reset_button_labels(void) {
subghz_button_labels_reset(button_labels);
}
static void subghz_scene_signal_settings_apply_button_labels(const char* protocol) {
subghz_button_labels_apply_protocol(protocol, button_labels);
}
static const char* subghz_scene_signal_settings_get_button_label(uint8_t custom_btn_id) {
return subghz_button_labels_get(
button_labels, custom_btn_id, subghz_custom_btn_get_original());
}
static bool subghz_scene_signal_settings_update_uint32_field(
FlipperFormat* fff,
const char* key,
uint32_t value) {
flipper_format_rewind(fff);
return flipper_format_insert_or_update_uint32(fff, key, &value, 1);
}
static uint32_t subghz_scene_signal_settings_counter_get_mask(void) {
uint8_t bits = subghz_block_generic_global.cnt_length_bit;
if((bits == 0) || (bits >= 32)) {
return 0xffffffffUL;
}
return (1UL << bits) - 1UL;
}
static void subghz_scene_signal_settings_counter_sync_byte_input(void) {
if(cnt_byte_count == 4) {
counter32 = __bswap32(counter_value);
cnt_byte_ptr = (uint8_t*)&counter32;
} else if(cnt_byte_count == 2) {
counter16 = __bswap16((uint16_t)counter_value);
cnt_byte_ptr = (uint8_t*)&counter16;
}
}
static void subghz_scene_signal_settings_counter_set_value(uint32_t value) {
counter_value = value & counter_mask;
subghz_scene_signal_settings_counter_sync_byte_input();
}
static void subghz_scene_signal_settings_counter_format(FuriString* text) {
if(cnt_byte_count == 4) {
furi_string_printf(text, "%lX", (unsigned long)counter_value);
} else {
furi_string_printf(text, "%X", (unsigned int)(counter_value & 0xffffU));
}
}
static void subghz_scene_signal_settings_counter_update_item(VariableItem* item) {
char text[9] = {0};
variable_item_set_current_value_index(item, SignalSettingsCounterStepValue);
if(cnt_byte_count == 4) {
snprintf(text, sizeof(text), "%lX", (unsigned long)counter_value);
} else {
snprintf(text, sizeof(text), "%X", (unsigned int)(counter_value & 0xffffU));
}
variable_item_set_current_value_text(item, text);
}
static bool subghz_scene_signal_settings_counter_save(SubGhz* subghz) {
FlipperFormat* fff = subghz_txrx_get_fff_data(subghz->txrx);
if(!subghz_scene_signal_settings_update_uint32_field(fff, "Cnt", counter_value)) {
FURI_LOG_E(TAG, "Error update/insert Cnt value");
dialog_message_show_storage_error(subghz->dialogs, "Cannot save\ncounter");
return false;
}
subghz_block_generic_global_counter_override_set(counter_value);
return subghz_scene_signal_settings_rebuild_save_reload(subghz, false, SUBGHZ_CUSTOM_BTN_OK);
}
static bool subghz_scene_signal_settings_hex_digit(char c, uint8_t* value) {
if(c >= '0' && c <= '9') {
*value = c - '0';
return true;
} else if(c >= 'a' && c <= 'f') {
*value = c - 'a' + 10;
return true;
} else if(c >= 'A' && c <= 'F') {
*value = c - 'A' + 10;
return true;
}
return false;
}
static bool subghz_scene_signal_settings_parse_hex_field(
const char* text,
const char* marker,
uint32_t* value,
uint8_t* length_bit) {
const char* field = strstr(text, marker);
if(!field) return false;
field += strlen(marker);
while(*field == ' ' || *field == '\t') {
field++;
}
uint32_t parsed = 0;
uint8_t digits = 0;
uint8_t digit_value = 0;
while(digits < 8 && subghz_scene_signal_settings_hex_digit(*field, &digit_value)) {
parsed = (parsed << 4) | digit_value;
digits++;
field++;
}
if(digits == 0) return false;
*value = parsed;
*length_bit = digits * 4;
return true;
}
static void subghz_scene_signal_settings_apply_text_fallback(FuriString* decoded_text) {
uint32_t value = 0;
uint8_t length_bit = 0;
const char* text = furi_string_get_cstr(decoded_text);
if(!subghz_block_generic_global.cnt_is_available &&
subghz_scene_signal_settings_parse_hex_field(text, "Cnt:", &value, &length_bit)) {
subghz_block_generic_global.cnt_is_available = true;
subghz_block_generic_global.current_cnt = value;
subghz_block_generic_global.cnt_length_bit = length_bit;
}
if(!subghz_block_generic_global.btn_is_available &&
subghz_scene_signal_settings_parse_hex_field(text, "Btn:", &value, &length_bit)) {
subghz_block_generic_global.btn_is_available = true;
subghz_block_generic_global.current_btn = value & 0xFF;
subghz_block_generic_global.btn_length_bit = length_bit > 8 ? 8 : length_bit;
}
}
static void subghz_scene_signal_settings_apply_file_fallback(FlipperFormat* fff) {
uint32_t value = 0;
if(!subghz_block_generic_global.cnt_is_available) {
flipper_format_rewind(fff);
if(flipper_format_read_uint32(fff, "Cnt", &value, 1)) {
subghz_block_generic_global.cnt_is_available = true;
subghz_block_generic_global.current_cnt = value;
subghz_block_generic_global.cnt_length_bit = 32;
}
}
if(!subghz_block_generic_global.btn_is_available) {
flipper_format_rewind(fff);
if(flipper_format_read_uint32(fff, "Btn", &value, 1)) {
subghz_block_generic_global.btn_is_available = true;
subghz_block_generic_global.current_btn = value & 0xFF;
subghz_block_generic_global.btn_length_bit = 8;
}
}
}
static bool subghz_scene_signal_settings_rebuild_save_reload(
SubGhz* subghz,
bool use_custom_btn,
uint8_t custom_btn_id) {
const char* file_path = furi_string_get_cstr(subghz->file_path);
FlipperFormat* fff = subghz_txrx_get_fff_data(subghz->txrx);
bool updated = false;
int32_t counter_mult = furi_hal_subghz_get_rolling_counter_mult();
furi_hal_subghz_set_rolling_counter_mult(0);
if(use_custom_btn) {
subghz_custom_btn_set(custom_btn_id);
}
do {
if(!subghz_txrx_rebuild_from_fff(subghz->txrx, fff)) {
FURI_LOG_E(TAG, "Error rebuilding protocol data");
break;
}
if(!subghz_save_protocol_to_file(subghz, fff, file_path)) {
FURI_LOG_E(TAG, "Error saving edited signal");
break;
}
if(!subghz_key_load(subghz, file_path, false)) {
FURI_LOG_E(TAG, "Error reloading edited signal");
break;
}
updated = true;
} while(false);
furi_hal_subghz_set_rolling_counter_mult(counter_mult);
if(!updated) {
dialog_message_show_storage_error(subghz->dialogs, "Cannot save\nsignal");
}
return updated;
}
void subghz_scene_signal_settings_counter_mode_changed(VariableItem* item) {
uint8_t index = variable_item_get_current_value_index(item);
@@ -99,6 +348,44 @@ void subghz_scene_signal_settings_counter_mode_changed(VariableItem* item) {
}
}
void subghz_scene_signal_settings_counter_changed(VariableItem* item) {
if(!cnt_byte_ptr || cnt_byte_count == 0) return;
uint8_t index = variable_item_get_current_value_index(item);
if(index == SignalSettingsCounterStepValue) {
subghz_scene_signal_settings_counter_update_item(item);
return;
}
if(index == SignalSettingsCounterStepUp) {
subghz_scene_signal_settings_counter_set_value(counter_value + 1);
} else {
subghz_scene_signal_settings_counter_set_value(counter_value - 1);
}
subghz_scene_signal_settings_counter_update_item(item);
SubGhz* subghz = variable_item_get_context(item);
furi_assert(subghz);
subghz_scene_signal_settings_counter_save(subghz);
}
void subghz_scene_signal_settings_button_changed(VariableItem* item) {
uint8_t index = variable_item_get_current_value_index(item);
if(index >= BUTTON_VALUE_COUNT) index = 0;
button_custom_id = button_value[index];
variable_item_set_current_value_text(
item, subghz_scene_signal_settings_get_button_label(button_custom_id));
if(!button_uses_custom_btn) return;
SubGhz* subghz = variable_item_get_context(item);
furi_assert(subghz);
subghz_scene_signal_settings_rebuild_save_reload(subghz, true, button_custom_id);
}
void subghz_scene_signal_settings_byte_input_callback(void* context) {
SubGhz* subghz = context;
view_dispatcher_send_custom_event(subghz->view_dispatcher, SubGhzCustomEventByteInputDone);
@@ -108,8 +395,10 @@ void subghz_scene_signal_settings_variable_item_list_enter_callback(void* contex
SubGhz* subghz = context;
// when we click OK on "Edit counter" item
if(index == 1) {
if(index == SignalSettingsIndexCounter) {
if(!cnt_byte_ptr || cnt_byte_count == 0) return;
submenu_called = 1;
furi_string_set_str(byte_input_text, "Enter ");
furi_string_cat_printf(byte_input_text, "%i", subghz_block_generic_global.cnt_length_bit);
furi_string_cat_str(byte_input_text, "-bits counter in HEX");
@@ -127,8 +416,10 @@ void subghz_scene_signal_settings_variable_item_list_enter_callback(void* contex
view_dispatcher_switch_to_view(subghz->view_dispatcher, SubGhzViewIdByteInput);
}
// when we click OK on "Edit button" item
if(index == 2) {
if(index == SignalSettingsIndexButton && !button_uses_custom_btn) {
if(!btn_byte_ptr || btn_byte_count == 0) return;
submenu_called = 2;
furi_string_set_str(byte_input_text, "Enter ");
furi_string_cat_printf(byte_input_text, "%i", subghz_block_generic_global.btn_length_bit);
furi_string_cat_str(byte_input_text, "-bits button in HEX");
@@ -150,6 +441,22 @@ void subghz_scene_signal_settings_variable_item_list_enter_callback(void* contex
void subghz_scene_signal_settings_on_enter(void* context) {
SubGhz* subghz = context;
counter32 = 0;
counter16 = 0;
cnt_byte_count = 0;
cnt_byte_ptr = NULL;
counter_value = 0;
counter_mask = 0xffffffffUL;
button = 0;
btn_byte_count = 1;
btn_byte_ptr = NULL;
submenu_called = 0;
button_uses_custom_btn = false;
button_custom_id = SUBGHZ_CUSTOM_BTN_OK;
subghz_block_generic_global_reset(NULL);
subghz_custom_btns_reset();
subghz_scene_signal_settings_reset_button_labels();
// ### Counter mode section ###
// When we open saved file we do some check and fill up subghz->file_path.
@@ -162,6 +469,7 @@ void subghz_scene_signal_settings_on_enter(void* context) {
Storage* storage = furi_record_open(RECORD_STORAGE);
FlipperFormat* fff_data_file = flipper_format_file_alloc(storage);
FuriString* tmp_text = furi_string_alloc_set_str("");
FuriString* protocol_name = furi_string_alloc();
uint32_t tmp_counter_mode = 0;
counter_mode = 0xff;
@@ -174,8 +482,9 @@ void subghz_scene_signal_settings_on_enter(void* context) {
FURI_LOG_E(TAG, "Error open file %s", file_path);
} else {
flipper_format_read_string(fff_data_file, "Protocol", tmp_text);
furi_string_set(protocol_name, tmp_text);
// compare available protocols names, load CounterMode value from file and setup variable_item_list values_count
for(uint8_t i = 0; i < PROTOCOLS_COUNT i++) {
for(uint8_t i = 0; i < PROTOCOLS_COUNT; i++) {
if(!strcmp(furi_string_get_cstr(tmp_text), protocols[i].name)) {
mode_count = protocols[i].mode_count;
if(flipper_format_read_uint32(fff_data_file, "CounterMode", &tmp_counter_mode, 1)) {
@@ -223,7 +532,10 @@ void subghz_scene_signal_settings_on_enter(void* context) {
// deserialaze and decode loaded sugbhz file and push data to subghz_block_generic_global variable
if(subghz_protocol_decoder_base_deserialize(decoder, subghz_txrx_get_fff_data(subghz->txrx)) ==
SubGhzProtocolStatusOk) {
subghz_scene_signal_settings_apply_button_labels(furi_string_get_cstr(protocol_name));
subghz_protocol_decoder_base_get_string(decoder, tmp_text);
subghz_scene_signal_settings_apply_text_fallback(tmp_text);
subghz_scene_signal_settings_apply_file_fallback(subghz_txrx_get_fff_data(subghz->txrx));
} else {
FURI_LOG_E(TAG, "Cant deserialize this subghz file");
}
@@ -232,55 +544,75 @@ void subghz_scene_signal_settings_on_enter(void* context) {
if(!subghz_block_generic_global.cnt_is_available) {
counter_mode = 0xff;
furi_string_set_str(tmp_text, "-");
FURI_LOG_D(TAG, "Counter mode and edit not available for this protocol");
} else {
counter_not_available = false;
counter_mask = subghz_scene_signal_settings_counter_get_mask();
// Check is there byte_count more than 2 hex bytes long or not
// To show hex value we must correct revert bytes for ByteInput view with __bswapХХ
// ByteInput stores the visible hex value as big-endian bytes.
if(subghz_block_generic_global.cnt_length_bit > 16) {
counter32 = subghz_block_generic_global.current_cnt;
furi_string_printf(tmp_text, "%lX", counter32);
counter32 = __bswap32(counter32);
cnt_byte_ptr = (uint8_t*)&counter32;
cnt_byte_count = 4;
} else {
counter16 = subghz_block_generic_global.current_cnt;
furi_string_printf(tmp_text, "%X", counter16);
counter16 = __bswap16(counter16);
cnt_byte_ptr = (uint8_t*)&counter16;
cnt_byte_count = 2;
}
subghz_scene_signal_settings_counter_set_value(subghz_block_generic_global.current_cnt);
subghz_scene_signal_settings_counter_format(tmp_text);
}
item = variable_item_list_add(variable_item_list, "Edit Counter", 1, NULL, subghz);
variable_item_set_current_value_index(item, 0);
item = variable_item_list_add(
variable_item_list,
"Edit Counter",
counter_not_available ? 1 : SignalSettingsCounterStepCount,
counter_not_available ? NULL : subghz_scene_signal_settings_counter_changed,
subghz);
variable_item_set_current_value_index(
item, counter_not_available ? 0 : SignalSettingsCounterStepValue);
variable_item_set_current_value_text(item, furi_string_get_cstr(tmp_text));
variable_item_set_locked(item, (counter_not_available), "Not available\nfor this\nprotocol !");
//
// ### Button edit section ###
if(!subghz_block_generic_global.btn_is_available) {
if(subghz_custom_btn_is_allowed()) {
uint8_t max_custom_btn = subghz_custom_btn_get_max();
uint8_t custom_button_count = max_custom_btn + 1;
if(custom_button_count > BUTTON_VALUE_COUNT) custom_button_count = BUTTON_VALUE_COUNT;
button_uses_custom_btn = custom_button_count > 1;
}
if(button_uses_custom_btn) {
button_not_available = false;
furi_string_set_str(
tmp_text, subghz_scene_signal_settings_get_button_label(SUBGHZ_CUSTOM_BTN_OK));
} else if(!subghz_block_generic_global.btn_is_available) {
furi_string_set_str(tmp_text, "-");
FURI_LOG_D(TAG, "Button edit not available for this protocol");
} else {
button_not_available = false;
button = subghz_block_generic_global.current_btn;
furi_string_printf(tmp_text, "%X", button);
btn_byte_ptr = (uint8_t*)&button;
furi_string_printf(tmp_text, "%X", button);
}
item = variable_item_list_add(variable_item_list, "Edit Button", 1, NULL, subghz);
uint8_t button_count = 1;
if(button_uses_custom_btn) {
button_count = subghz_custom_btn_get_max() + 1;
if(button_count > BUTTON_VALUE_COUNT) button_count = BUTTON_VALUE_COUNT;
}
item = variable_item_list_add(
variable_item_list,
button_uses_custom_btn ? "Button" : "Edit Button",
button_count,
button_uses_custom_btn ? subghz_scene_signal_settings_button_changed : NULL,
subghz);
variable_item_set_current_value_index(item, 0);
variable_item_set_current_value_text(item, furi_string_get_cstr(tmp_text));
variable_item_set_locked(item, (button_not_available), "Not available\nfor this\nprotocol !");
//
furi_assert(cnt_byte_ptr);
furi_assert(cnt_byte_count > 0);
furi_assert(btn_byte_ptr);
furi_string_free(tmp_text);
furi_string_free(protocol_name);
view_dispatcher_switch_to_view(subghz->view_dispatcher, SubGhzViewIdVariableItemList);
}
@@ -290,23 +622,21 @@ bool subghz_scene_signal_settings_on_event(void* context, SceneManagerEvent even
if(event.type == SceneManagerEventTypeCustom) {
if(event.event == SubGhzCustomEventByteInputDone) {
FlipperFormat* fff = subghz_txrx_get_fff_data(subghz->txrx);
switch(submenu_called) {
// edit counter
case 1:
switch(cnt_byte_count) {
case 2:
// set new cnt value and override_flag to global variable and call transmit to generate and save subghz signal
counter16 = __bswap16(counter16);
subghz_block_generic_global_counter_override_set(counter16);
subghz_tx_start(subghz, subghz_txrx_get_fff_data(subghz->txrx));
subghz_txrx_stop(subghz->txrx);
subghz_scene_signal_settings_counter_set_value(counter16);
subghz_scene_signal_settings_counter_save(subghz);
break;
case 4:
// the same for 32 bit Counter
counter32 = __bswap32(counter32);
subghz_block_generic_global_counter_override_set(counter32);
subghz_tx_start(subghz, subghz_txrx_get_fff_data(subghz->txrx));
subghz_txrx_stop(subghz->txrx);
subghz_scene_signal_settings_counter_set_value(counter32);
subghz_scene_signal_settings_counter_save(subghz);
break;
default:
break;
@@ -314,14 +644,10 @@ bool subghz_scene_signal_settings_on_event(void* context, SceneManagerEvent even
break;
// edit button
case 2:
subghz_scene_signal_settings_update_uint32_field(fff, "Btn", button);
subghz_block_generic_global_button_override_set(button);
// save counter mult to rewrite subghz singnal without changing counter
int32_t tmp_counter = furi_hal_subghz_get_rolling_counter_mult();
furi_hal_subghz_set_rolling_counter_mult(0);
subghz_tx_start(subghz, subghz_txrx_get_fff_data(subghz->txrx));
subghz_txrx_stop(subghz->txrx);
// restore counter mult
furi_hal_subghz_set_rolling_counter_mult(tmp_counter);
subghz_scene_signal_settings_rebuild_save_reload(
subghz, false, SUBGHZ_CUSTOM_BTN_OK);
break;
default:
@@ -330,13 +656,10 @@ bool subghz_scene_signal_settings_on_event(void* context, SceneManagerEvent even
scene_manager_previous_scene(subghz->scene_manager);
return true;
} else {
if(event.type == SceneManagerEventTypeBack) {
scene_manager_previous_scene(subghz->scene_manager);
return true;
}
}
} else if(event.type == SceneManagerEventTypeBack) {
scene_manager_previous_scene(subghz->scene_manager);
return true;
}
return false;
}
@@ -352,4 +675,5 @@ void subghz_scene_signal_settings_on_exit(void* context) {
byte_input_set_result_callback(subghz->byte_input, NULL, NULL, NULL, NULL, 0);
byte_input_set_header_text(subghz->byte_input, "");
furi_string_free(byte_input_text);
subghz_custom_btns_reset();
}
@@ -23,7 +23,7 @@
#define SUBGHZ_LAST_SETTING_FIELD_LED_AND_POWER_AMP "LedAndPowerAmp"
#define SUBGHZ_LAST_SETTING_FIELD_TX_POWER "TXPower"
#define SUBGHZ_LAST_SETTING_FIELD_CUSTOM_CAR_EMULATE "CustomCarEmulate"
#define SUBGHZ_LAST_SETTING_FIELD_PROTOCOL_FILTER "ProtocolFilter"
#define SUBGHZ_LAST_SETTING_FIELD_PROTOCOL_FILTER "ProtocolFilterOff"
SubGhzLastSettings* subghz_last_settings_alloc(void) {
SubGhzLastSettings* instance = malloc(sizeof(SubGhzLastSettings));
@@ -187,6 +187,7 @@ void subghz_last_settings_load(SubGhzLastSettings* instance, size_t preset_count
flipper_format_rewind(fff_data_file);
}
furi_string_free(filter_str);
subghz_last_settings_protocol_filter_normalize(instance);
} while(0);
} else {
+190 -1
View File
@@ -3,6 +3,7 @@
#include <furi_hal.h>
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#include <storage/storage.h>
#include <lib/subghz/types.h>
@@ -13,6 +14,7 @@
#define SUBGHZ_LAST_SETTING_DEFAULT_FREQUENCY 433920000
#define SUBGHZ_LAST_SETTING_FREQUENCY_ANALYZER_FEEDBACK_LEVEL 2
#define SUBGHZ_LAST_SETTING_DEFAULT_PRESET_HOPPING_THRESHOLD (-80.0f)
#define SUBGHZ_LAST_SETTINGS_PROTOCOL_FILTER_SIZE 1024
typedef struct {
uint32_t frequency;
@@ -31,9 +33,196 @@ typedef struct {
bool leds_and_amp;
uint8_t tx_power;
bool custom_car_emulate;
char protocol_filter[256]; /* comma-separated allowlist, empty = disabled */
char protocol_filter[SUBGHZ_LAST_SETTINGS_PROTOCOL_FILTER_SIZE]; /* comma-separated disabled protocols, empty = all enabled */
} SubGhzLastSettings;
static inline void subghz_last_settings_protocol_filter_next_token(
const char** cursor,
const char** token,
size_t* token_len) {
const char* start = *cursor;
while((*start == ',') || (*start == ' ') || (*start == '\t')) {
start++;
}
const char* end = start;
while((*end != '\0') && (*end != ',')) {
end++;
}
const char* trim_end = end;
while((trim_end > start) && ((trim_end[-1] == ' ') || (trim_end[-1] == '\t'))) {
trim_end--;
}
*token = start;
*token_len = (size_t)(trim_end - start);
*cursor = (*end == ',') ? end + 1 : end;
}
static inline bool subghz_last_settings_protocol_filter_token_matches(
const char* token,
size_t token_len,
const char* name,
size_t name_len) {
return (token_len == name_len) && (strncmp(token, name, token_len) == 0);
}
static inline bool subghz_last_settings_protocol_filter_contains_token(
const char* filter,
const char* token,
size_t token_len) {
const char* cursor = filter;
const char* current = NULL;
size_t current_len = 0;
while(*cursor != '\0') {
subghz_last_settings_protocol_filter_next_token(&cursor, &current, &current_len);
if((current_len != 0) &&
subghz_last_settings_protocol_filter_token_matches(
current, current_len, token, token_len)) {
return true;
}
}
return false;
}
static inline bool subghz_last_settings_protocol_filter_append_token(
char* filter,
size_t filter_size,
const char* token,
size_t token_len) {
if(token_len == 0) return true;
size_t filter_len = strlen(filter);
size_t separator_len = filter_len == 0 ? 0 : 1;
if((filter_len + separator_len + token_len) >= filter_size) {
return false;
}
if(separator_len != 0) {
filter[filter_len++] = ',';
}
memcpy(&filter[filter_len], token, token_len);
filter[filter_len + token_len] = '\0';
return true;
}
static inline bool subghz_last_settings_protocol_filter_contains(
const SubGhzLastSettings* instance,
const char* protocol) {
if((instance == NULL) || (protocol == NULL) || (protocol[0] == '\0')) return false;
return subghz_last_settings_protocol_filter_contains_token(
instance->protocol_filter, protocol, strlen(protocol));
}
static inline bool subghz_last_settings_protocol_filter_normalize(
SubGhzLastSettings* instance) {
if(instance == NULL) return false;
char normalized[SUBGHZ_LAST_SETTINGS_PROTOCOL_FILTER_SIZE] = {0};
const char* cursor = instance->protocol_filter;
const char* token = NULL;
size_t token_len = 0;
while(*cursor != '\0') {
subghz_last_settings_protocol_filter_next_token(&cursor, &token, &token_len);
if((token_len == 0) ||
subghz_last_settings_protocol_filter_contains_token(normalized, token, token_len)) {
continue;
}
if(!subghz_last_settings_protocol_filter_append_token(
normalized, sizeof(normalized), token, token_len)) {
return false;
}
}
bool changed = strcmp(instance->protocol_filter, normalized) != 0;
if(changed) {
memcpy(instance->protocol_filter, normalized, sizeof(instance->protocol_filter));
}
return changed;
}
static inline bool subghz_last_settings_protocol_filter_set(
SubGhzLastSettings* instance,
const char* protocol,
bool disabled) {
if((instance == NULL) || (protocol == NULL) || (protocol[0] == '\0')) return false;
char updated[SUBGHZ_LAST_SETTINGS_PROTOCOL_FILTER_SIZE] = {0};
const char* cursor = instance->protocol_filter;
const char* token = NULL;
size_t token_len = 0;
const size_t protocol_len = strlen(protocol);
bool protocol_written = false;
while(*cursor != '\0') {
subghz_last_settings_protocol_filter_next_token(&cursor, &token, &token_len);
if(token_len == 0) continue;
bool is_target = subghz_last_settings_protocol_filter_token_matches(
token, token_len, protocol, protocol_len);
if(is_target) {
if(disabled && !protocol_written) {
if(!subghz_last_settings_protocol_filter_append_token(
updated, sizeof(updated), protocol, protocol_len)) {
return false;
}
protocol_written = true;
}
continue;
}
if(!subghz_last_settings_protocol_filter_contains_token(updated, token, token_len)) {
if(!subghz_last_settings_protocol_filter_append_token(
updated, sizeof(updated), token, token_len)) {
return false;
}
}
}
if(disabled && !protocol_written) {
if(!subghz_last_settings_protocol_filter_append_token(
updated, sizeof(updated), protocol, protocol_len)) {
return false;
}
}
bool changed = strcmp(instance->protocol_filter, updated) != 0;
if(changed) {
memcpy(instance->protocol_filter, updated, sizeof(instance->protocol_filter));
}
return changed;
}
static inline uint8_t subghz_last_settings_protocol_filter_count(
const SubGhzLastSettings* instance) {
if(instance == NULL) return 0;
char seen[SUBGHZ_LAST_SETTINGS_PROTOCOL_FILTER_SIZE] = {0};
const char* cursor = instance->protocol_filter;
const char* token = NULL;
size_t token_len = 0;
uint8_t count = 0;
while(*cursor != '\0') {
subghz_last_settings_protocol_filter_next_token(&cursor, &token, &token_len);
if((token_len == 0) ||
subghz_last_settings_protocol_filter_contains_token(seen, token, token_len)) {
continue;
}
if(!subghz_last_settings_protocol_filter_append_token(seen, sizeof(seen), token, token_len)) {
break;
}
count++;
}
return count;
}
SubGhzLastSettings* subghz_last_settings_alloc(void);
void subghz_last_settings_free(SubGhzLastSettings* instance);
@@ -17,11 +17,11 @@ typedef struct {
char preset[12];
bool is_transmitting;
uint8_t anim_frame;
char label_ok[12];
char label_up[12];
char label_down[12];
char label_left[12];
char label_right[12];
char label_ok[16];
char label_up[16];
char label_down[16];
char label_left[16];
char label_right[16];
} SubGhzCarEmulateViewModel;
/* ── Handle ─────────────────────────────────────────────────────────────── */
@@ -79,7 +79,7 @@ static void subghz_car_emulate_view_draw(Canvas* canvas, void* model_ptr) {
const uint8_t font_h = canvas_current_font_height(canvas);
/* Centre → UNLOCK (OK button) */
{
if(m->label_ok[0]) {
const char* lbl = m->label_ok;
uint8_t w = (uint8_t)(canvas_string_width(canvas, lbl) + 8U);
canvas_draw_rbox(canvas, 64 - w / 2, 45 - font_h / 2, w, font_h, 3);
@@ -89,7 +89,7 @@ static void subghz_car_emulate_view_draw(Canvas* canvas, void* model_ptr) {
}
/* Up → LOCK */
{
if(m->label_up[0]) {
const char* lbl = m->label_up;
uint8_t w = (uint8_t)(canvas_string_width(canvas, lbl) + 8U);
canvas_draw_rbox(canvas, 64 - w / 2, 33 - font_h / 2, w, font_h, 3);
@@ -99,7 +99,7 @@ static void subghz_car_emulate_view_draw(Canvas* canvas, void* model_ptr) {
}
/* Left → PANIC */
{
if(m->label_left[0]) {
const char* lbl = m->label_left;
uint8_t w = (uint8_t)(canvas_string_width(canvas, lbl) + 8U);
canvas_draw_rbox(canvas, 0, 46 - font_h / 2, w, font_h, 3);
@@ -109,7 +109,7 @@ static void subghz_car_emulate_view_draw(Canvas* canvas, void* model_ptr) {
}
/* Right → generic extra */
{
if(m->label_right[0]) {
const char* lbl = m->label_right;
uint8_t w = (uint8_t)(canvas_string_width(canvas, lbl) + 8U);
canvas_draw_rbox(canvas, 127 - w, 46 - font_h / 2, w, font_h, 3);
@@ -119,7 +119,7 @@ static void subghz_car_emulate_view_draw(Canvas* canvas, void* model_ptr) {
}
/* Down → BOOT */
{
if(m->label_down[0]) {
const char* lbl = m->label_down;
uint8_t w = (uint8_t)(canvas_string_width(canvas, lbl) + 8U);
canvas_draw_rbox(canvas, 64 - w / 2, 57 - font_h / 2, w, font_h, 3);
@@ -259,6 +259,11 @@ void subghz_car_emulate_view_set_labels(
strncpy(m->label_down, down ? down : "", sizeof(m->label_down) - 1);
strncpy(m->label_left, left ? left : "", sizeof(m->label_left) - 1);
strncpy(m->label_right, right ? right : "", sizeof(m->label_right) - 1);
m->label_ok[sizeof(m->label_ok) - 1] = '\0';
m->label_up[sizeof(m->label_up) - 1] = '\0';
m->label_down[sizeof(m->label_down) - 1] = '\0';
m->label_left[sizeof(m->label_left) - 1] = '\0';
m->label_right[sizeof(m->label_right) - 1] = '\0';
},
true);
}
+67 -15
View File
@@ -50,7 +50,11 @@ Navigate with **Up/Down**, activate with **OK**, close with **Back**.
| **Press START** | Sends a Start press to the game (pause menus, "PRESS START" screens) and resumes |
| **Press SELECT** | Sends a Select press to the game and resumes |
| **Frameskip** | Change with **Left/Right**: `auto` (recommended), or fixed `04`. `auto` shows the skip level currently in use |
| **Sound** | Toggle piezo sound on/off (`n/a` if the speaker is in use by another app) |
| **Sound** | Volume: cycle off/25/50/75/100% with **Left/Right**; **OK** toggles mute/full (`n/a` if the speaker is in use by another app) |
| **Scale** | `fit` (whole screen, squashed) or `1:1 crop` (pixel-perfect center crop) |
| **Dither** | `std` (2x2 ordered), `temporal` (alternating pattern: the LCD's slow pixels average it into perceived grayscale), `hi-con` (grays snap to black/white: crisper text) |
| **OK hold** | `A+Start`: holding OK ~0.7 s also sends Start (off by default: interferes with games that hold A) |
| **Save/Load state** | Instant save states (`.ss1` file next to the ROM) |
| **Save SRAM** | Writes the cartridge battery save (`.sav`) to the SD card immediately |
| **Exit** | Saves SRAM (if the cartridge has a battery) and quits the app |
@@ -95,8 +99,9 @@ piezo:
3. otherwise the noise channel, mapped to a short low buzz (percussion).
The result is a monophonic ringtone-style rendition of the game's music
and sound effects (sweeps like Mario's jump work). It can be toggled in
the emulator menu. Since no waveforms are synthesized, the CPU cost is
and sound effects (sweeps like Mario's jump work). Volume is adjustable in
the emulator menu (off/25/50/75/100%, perceptually spaced for the piezo's
nonlinear loudness). Since no waveforms are synthesized, the CPU cost is
negligible (one counter per emulated instruction) and RAM cost is ~120
bytes.
@@ -108,8 +113,31 @@ bytes.
(70224 M-cycles instead of 17556). Unnoticeable on a desktop, a slideshow
on a 64 MHz Cortex-M4. Fixed — this alone made everything ~4.5x faster.
- While halted (games spend most of each frame in HALT waiting for vblank),
the CPU steps 4 M-cycles at a time instead of 1, making the idle part of
the CPU steps 8 M-cycles at a time instead of 1, making the idle part of
the frame cheap.
- PPU/timer/APU use catch-up batching: instead of stepping their state
machines after every CPU instruction, cycles accumulate and are flushed
every 32 M-cycles - or immediately before any IO register access, so
LY/STAT/DIV/TIMA/IF always read exact. This removes the single biggest
per-instruction cost of the interpreter loop (~2.8x faster emulation).
- Undefined opcodes cost 1 cycle instead of 0: with 0-cycle entries,
executing garbage could advance the PC without advancing the PPU clock
and spin run_to_vblank() forever, freezing the whole device.
- EI enables interrupts only after the following instruction (hardware
behaviour). Without the delay, the classic `EI / HALT` pause idiom
consumed its wake interrupt before HALT and games froze waiting for a
button press that had already been eaten (input dead, music playing).
- STOP freezes the CPU until joypad activity, independent of IE/IF/IME
(hardware behaviour), and skips its padding byte.
- ROM streaming: bank switches to the already-mapped bank skip the cache;
the upper 8 KB half of a bank streams in lazily on first read; the LRU
never evicts the most-recently-used page and protects the single hottest
page (the music-driver bank) from map-streaming evictions; the Gameboy
core block is pre-allocated during ROM load so the page cache can size
itself against real free heap (Pokemon: ~2x more cache slots).
- The auto-frameskip EMA uses signed arithmetic (an unsigned underflow
could pin it at maximum), and the menu shows the measured cost of one
emulated frame in ms next to the free heap (16.7ms = full speed).
- **Frameskip `auto`** (default) measures the real cost of each emulated frame
and skips *rendering* (never emulation) to keep the game running at correct
speed. Games stay full-speed logically; visible FPS drops instead.
@@ -117,8 +145,32 @@ bytes.
- The PPU only renders the 64 scanlines (out of 144) that survive the
downscale to the Flipper LCD — ~55% of the per-frame rendering work is
skipped with zero visual difference.
- Bank-switch heavy games may micro-stutter when a 16 KB bank has to be
streamed from the SD card (only happens when the ROM doesn't fit in RAM).
- ROM streaming works on lazily-mapped 4 KB units (quarter banks): bank
switching itself is free, and data is only fetched when actually read.
Measured on Pokemon Red: the game rewrites its bank register ~200x per
frame while reading from ~17 units — eager fetching caused ~86 phantom
cache misses per frame; the lazy design reduces that to ~1.
- Input is read by polling the button GPIOs directly each frame: the OS
input-event service can be starved by a CPU-heavy game (a firmware
timer-daemon priority quirk) and used to wedge all input until reboot.
- Frame pacing runs on an absolute 59.73 Hz tick grid with auto-frameskip
hysteresis: constant-velocity motion stays judder-free.
- The emulator core is compiled `-O2` (the rest of the app stays `-Os`) and
the per-instruction hot path (opcode fetch, PPU/timer/APU ticks) is
manually inlined across the core, cutting per-instruction call overhead.
- The CPU interpreter is a unity build: the 500 opcode bodies compile in
the same translation unit as the dispatch switch, so the compiler inlines
them directly into the jump table (two cross-TU calls per instruction
eliminated) - and the deduplication actually made the binary smaller.
- The PPU renders tiles byte-wise, not pixel-wise: tile bitplanes are
decoded 8 pixels at once through a 256-entry spread LUT and written as
whole palette-mapped bytes (4 px per store) - ~7x fewer operations per
scanline. Sprites skip fully-transparent rows and use the same LUTs.
- The 160x144 -> 128x64 downscale/dither also works one packed byte
(4 pixels) per lookup instead of per-pixel shade extraction.
- In `auto` mode the frameskip may go up to 8 (fixed settings stay 0-4):
for heavy streamed games, correct game speed at a lower visible fps beats
slow motion.
- The emulator menu shows the free heap (`NNk free`) so you can see the
memory headroom of the current game at a glance.
@@ -149,8 +201,8 @@ ufbt launch # builds, installs and runs on a connected Flipper
The exact core that ships in the FAP can be compiled and tested on a desktop:
```sh
g++ -std=c++17 -O2 -fno-exceptions -fno-rtti -I gb \
-o hosttest/hosttest hosttest/main.cpp gb/*.cc
g++ -std=c++17 -O2 -fno-exceptions -fno-rtti -I lib/gbcore \
-o hosttest/hosttest hosttest/main.cpp lib/gbcore/*.cc
# Blargg CPU tests (print Passed/Failed via the serial port):
./hosttest/hosttest path/to/01-special.gb 4000
@@ -170,8 +222,8 @@ Current status: **Blargg `cpu_instrs` 11/11 PASS**.
| Upstream (PC) | This port (Flipper) |
|---|---|
| Whole ROM in RAM (with several transient copies) | 16 KB bank streaming from SD with an adaptive LRU cache; bank 0 resident; when every bank fits, the whole ROM is preloaded into individual 16 KB slots (O(1) switching, SD file closed) |
| — | All ROM-dependent allocations are 16 KB or smaller and are checked against the largest free heap block first: heap fragmentation can never crash the firmware, the app degrades to streaming or shows "Not enough RAM" instead |
| Whole ROM in RAM (with several transient copies) | 8 KB page streaming from SD with an adaptive LRU cache; bank 0 resident; when every page fits, the whole ROM is preloaded into individual 8 KB slots (O(1) switching, SD file closed) |
| — | All ROM-dependent allocations are 8 KB or smaller and are checked against the largest free heap block first: heap fragmentation can never crash the firmware, the app degrades to streaming or shows "Not enough RAM" instead |
| Renders all 144 scanlines | Renders only the 64 scanlines that are actually displayed after the 144→64 downscale (row mask, ~2x faster rendering) |
| PPU counts M-cycles against T-cycle constants (4x too much CPU emulation per frame) | Hardware-correct M-cycle constants (114 per scanline): ~4.5x faster overall |
| DIV register incremented every M-cycle (64x too fast) | Correct 16384 Hz rate (games use DIV for delays and randomness) |
@@ -187,14 +239,14 @@ Current status: **Blargg `cpu_instrs` 11/11 PASS**.
| No APU at all | Register-level APU (sweep/envelope/length/NR52, proper read-back masks) driving the piezo with the dominant voice |
RAM budget on device (256 KB total, ~140 KB heap; the app binary itself
loads into ~32 KB of that heap): ~19.5 KB emulation state, 16 KB bank 0,
adaptive bank cache (10 KB heap kept in reserve for the system), 032 KB
cartridge RAM per game, 4 KB stack. The bank cache is allocated greedily in
independent 16 KB blocks until the reserve would be touched, so any `.gb`
loads into ~37 KB of that heap): ~19.5 KB emulation state, 16 KB bank 0,
adaptive page cache (10 KB heap kept in reserve for the system), 032 KB
cartridge RAM per game, 4 KB stack. The page cache is allocated greedily in
independent 8 KB blocks until the reserve would be touched, so any `.gb`
ROM size works: small ROMs end up fully resident, large ones stream through
however many slots fit. Worst case (1 MB ROM + 32 KB battery RAM, e.g.
Pokémon Red/Blue) needs ~78 KB before the first cache slot, which fits the
post-launch heap with room for 12 streaming slots.
post-launch heap with room for a few streaming pages.
## License
+13 -2
View File
@@ -3,13 +3,24 @@ App(
name="FlipGB",
apptype=FlipperAppType.EXTERNAL,
entry_point="flipgb_app",
sources=["*.c*", "!hosttest"],
sources=["*.c*", "!hosttest", "!lib", "!tests"],
requires=["gui", "dialogs", "storage"],
stack_size=8 * 1024,
cdefines=[("GB_FB_ROWS", "64")],
fap_private_libs=[
Lib(
# The emulator core is built as a private lib so it can use -O2:
# ufbt builds FAP sources with -Os, which costs ~30% emulation
# speed in the per-instruction hot path. Code size difference
# is ~6 KB - well worth it.
name="gbcore",
cflags=["-O2"],
cdefines=[("GB_FB_ROWS", "64")],
),
],
fap_category="Games",
fap_icon="flipgb_icon.png",
fap_author="user",
fap_version="1.0",
fap_version="5.1",
fap_description="Game Boy (DMG) emulator. Loads .gb ROMs from the SD card, streams ROM banks, battery saves, adaptive frameskip.",
)
Binary file not shown.
Binary file not shown.
@@ -1,80 +0,0 @@
#pragma once
#include "definitions.h"
/* Cartridge with pluggable ROM bank provider.
*
* Instead of holding the whole ROM in RAM (impossible on Flipper Zero for
* anything above 32 KB), the cartridge asks the platform for a pointer to a
* 16 KB bank whenever the game switches banks. On the desktop test build the
* provider just returns `rom + bank * 0x4000`; on the Flipper it is backed
* by an LRU cache streaming from the SD card.
*
* Supported mappers: ROM only, MBC1 (incl. upper bits / mode select),
* MBC2 (built-in 512x4 RAM), MBC3 (no RTC), MBC5.
*/
using RomBankProvider = const u8* (*)(void* ctx, uint bank);
enum class MBCType : u8 {
None,
MBC1,
MBC2,
MBC3,
MBC5,
Unsupported,
};
class Cartridge {
public:
/* bank0 must stay valid for the lifetime of the cartridge */
void init(
const u8* bank0_data,
uint rom_bank_count,
MBCType mbc_type,
u8* cart_ram,
u32 cart_ram_size,
RomBankProvider provider,
void* provider_ctx);
auto read(u16 addr) const -> u8 {
if(addr < 0x4000) return bank0[addr];
if(addr < 0x8000) return bankN[addr - 0x4000];
/* 0xA000 - 0xBFFF: cartridge RAM */
return read_ram(addr);
}
void write(u16 addr, u8 value);
auto get_ram() -> u8* { return ram; }
auto get_ram_size() const -> u32 { return ram_size; }
/* Header helpers (operate on the first bank) */
static auto parse_mbc(u8 cartridge_type_byte) -> MBCType;
static auto has_battery(u8 cartridge_type_byte) -> bool;
static auto rom_bank_count_from_header(u8 rom_size_byte) -> uint;
static auto ram_size_from_header(u8 ram_size_byte, MBCType mbc) -> u32;
private:
auto read_ram(u16 addr) const -> u8;
void write_ram(u16 addr, u8 value);
void update_rom_bank();
const u8* bank0 = nullptr;
const u8* bankN = nullptr;
u8* ram = nullptr;
u32 ram_size = 0;
RomBankProvider provider = nullptr;
void* provider_ctx = nullptr;
MBCType mbc = MBCType::None;
uint bank_count = 2;
bool ram_enabled = false;
bool advanced_banking_mode = false; /* MBC1 mode 1 */
uint bank_low = 1; /* MBC1: 5 bits, MBC3: 7 bits, MBC5: 8 bits */
uint bank_high = 0; /* MBC1: 2 bits, MBC5: 9th bit */
uint ram_bank = 0;
};
@@ -1,73 +0,0 @@
#pragma once
#include "input.h"
#include "cpu.h"
#include "video.h"
#include "timer.h"
#include "mmu.h"
#include "cartridge.h"
#include "apu.h"
class Gameboy {
public:
/* bank0: pointer to the first 16 KB of ROM (stays resident).
* provider: returns pointers to 16 KB switchable banks. */
Gameboy(
const u8* bank0,
uint rom_bank_count,
MBCType mbc,
u8* cart_ram,
u32 cart_ram_size,
RomBankProvider provider,
void* provider_ctx);
/* Runs the emulator until the next vblank (one full frame). */
void run_to_vblank();
/* Called on every vblank BEFORE the framebuffer is cleared for the next
* frame: this is where the frontend must convert/copy the image. */
void set_frame_callback(void (*cb)(void*), void* ctx) {
user_frame_cb = cb;
user_frame_ctx = ctx;
}
void button_pressed(GbButton button);
void button_released(GbButton button);
void set_skip_render(bool skip) { video.skip_render = skip; }
/* Optional display-line mask (see Video::row_mask). The array must stay
* valid for the lifetime of the emulator. */
void set_row_mask(const u8* mask) { video.set_row_mask(mask); }
auto get_framebuffer() const -> const FrameBuffer& { return video.get_framebuffer(); }
auto get_cartridge_ram() -> u8* { return cartridge.get_ram(); }
auto get_cartridge_ram_size() const -> u32 { return cartridge.get_ram_size(); }
Cartridge cartridge;
CPU cpu;
friend class CPU;
Video video;
friend class Video;
MMU mmu;
friend class MMU;
Timer timer;
friend class Timer;
Apu apu;
Input input;
private:
void tick();
static void vblank_trampoline(void* ctx);
volatile bool frame_done = false;
void (*user_frame_cb)(void*) = nullptr;
void* user_frame_ctx = nullptr;
};
-54
View File
@@ -1,54 +0,0 @@
#include "input.h"
#include "bitwise.h"
void Input::button_pressed(GbButton button) {
set_button(button, true);
}
void Input::button_released(GbButton button) {
set_button(button, false);
}
void Input::set_button(GbButton button, bool set) {
if (button == GbButton::Up) { up = set; }
if (button == GbButton::Down) { down = set; }
if (button == GbButton::Left) { left = set; }
if (button == GbButton::Right) { right = set; }
if (button == GbButton::A) { a = set; }
if (button == GbButton::B) { b = set; }
if (button == GbButton::Select) { select = set; }
if (button == GbButton::Start) { start = set; }
}
void Input::write(u8 set) {
using bitwise::check_bit;
direction_switch = !check_bit(set, 4);
button_switch = !check_bit(set, 5);
}
auto Input::get_input() const -> u8 {
using bitwise::set_bit_to;
u8 buttons = 0b1111;
if (direction_switch) {
buttons = set_bit_to(buttons, 0, !right);
buttons = set_bit_to(buttons, 1, !left);
buttons = set_bit_to(buttons, 2, !up);
buttons = set_bit_to(buttons, 3, !down);
}
if (button_switch) {
buttons = set_bit_to(buttons, 0, !a);
buttons = set_bit_to(buttons, 1, !b);
buttons = set_bit_to(buttons, 2, !select);
buttons = set_bit_to(buttons, 3, !start);
}
buttons = set_bit_to(buttons, 4, !direction_switch);
buttons = set_bit_to(buttons, 5, !button_switch);
return buttons;
}
-35
View File
@@ -1,35 +0,0 @@
#pragma once
#include "address.h"
#include "definitions.h"
class Gameboy;
/* Serial output hook, used by the host test harness to capture Blargg test
* ROM output. Null on the Flipper build. */
extern "C" {
extern void (*gb_serial_hook)(u8 byte);
}
class MMU {
public:
MMU(Gameboy& inGb);
auto read(const Address& address) const -> u8;
void write(const Address& address, u8 byte);
private:
auto read_io(const Address& address) const -> u8;
void write_io(const Address& address, u8 byte);
void dma_transfer(u8 byte);
Gameboy& gb;
u8 work_ram[0x2000] = {}; /* DMG: 8 KB (was 32 KB upstream) */
u8 oam_ram[0xA0] = {};
u8 high_ram[0x80] = {};
u8 serial_data = 0;
friend class Video;
};
-77
View File
@@ -1,77 +0,0 @@
#include "timer.h"
#include "definitions.h"
#include "gameboy.h"
#include "cpu.h"
#include "bitwise.h"
const uint CLOCKS_PER_CYCLE = 4;
Timer::Timer(Gameboy& _gb) : gb(_gb) {}
void Timer::tick(uint cycles) {
/* DIV increments at 16384 Hz = every 64 M-cycles (upstream incremented
* it once per M-cycle: 64x too fast, breaking games that use DIV for
* delays or randomness) */
div_clocks += cycles;
if(div_clocks >= 64) {
divider.set(static_cast<u8>(divider.value() + (div_clocks >> 6)));
div_clocks &= 63;
}
clocks += cycles * CLOCKS_PER_CYCLE;
auto timer_is_on = timer_control.check_bit(2);
if (timer_is_on == 0) { return; }
auto clock_limit = clocks_needed_to_increment();
if (clocks >= clock_limit) {
clocks = clocks % clock_limit;
u8 old_timer_counter = timer_counter.value();
timer_counter.increment();
if (timer_counter.value() < old_timer_counter) {
gb.cpu.interrupt_flag.set_bit_to(2, true);
timer_counter.set(timer_modulo.value());
}
}
}
auto Timer::get_divider() const -> u8 { return divider.value(); }
auto Timer::get_timer() const -> u8 { return timer_counter.value(); }
auto Timer::get_timer_modulo() const -> u8 { return timer_modulo.value(); }
// Only the bottom three bits of this register are usable
auto Timer::get_timer_control() const -> u8 { return timer_control.value() & 0x3; }
void Timer::reset_divider() {
divider.set(0x0);
}
void Timer::set_timer(u8 value) {
timer_counter.set(value);
}
void Timer::set_timer_modulo(u8 value) {
timer_modulo.set(value);
}
void Timer::set_timer_control(u8 value) {
timer_control.set(value);
}
uint Timer::clocks_needed_to_increment() {
using bitwise::check_bit;
switch (get_timer_control()) {
case 0: return CLOCK_RATE / 4096;
case 1: return CLOCK_RATE / 262144;
case 2: return CLOCK_RATE / 65536;
case 3: return CLOCK_RATE / 16384;
default: return CLOCK_RATE / 4096; /* unreachable */
}
}
-37
View File
@@ -1,37 +0,0 @@
#pragma once
#include "definitions.h"
#include "register.h"
class Gameboy;
class Timer {
public:
Timer(Gameboy& inGb);
void tick(uint cycles);
auto get_divider() const -> u8;
auto get_timer() const -> u8;
auto get_timer_modulo() const -> u8;
auto get_timer_control() const -> u8;
void reset_divider();
void set_timer(u8 value);
void set_timer_modulo(u8 value);
void set_timer_control(u8 value);
private:
uint clocks_needed_to_increment();
uint clocks = 0;
uint div_clocks = 0;
Gameboy& gb;
ByteRegister divider;
ByteRegister timer_counter;
ByteRegister timer_modulo;
ByteRegister timer_control;
};
-353
View File
@@ -1,353 +0,0 @@
#include "video.h"
#include "gameboy.h"
#include "cpu.h"
#include "bitwise.h"
using bitwise::check_bit;
Video::Video(Gameboy& inGb)
: gb(inGb) {
}
void Video::tick(Cycles cycles) {
cycle_counter += cycles.cycles;
switch(current_mode) {
case VideoMode::ACCESS_OAM:
if(cycle_counter >= CLOCKS_PER_SCANLINE_OAM) {
cycle_counter = cycle_counter % CLOCKS_PER_SCANLINE_OAM;
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, true);
current_mode = VideoMode::ACCESS_VRAM;
}
break;
case VideoMode::ACCESS_VRAM:
if(cycle_counter >= CLOCKS_PER_SCANLINE_VRAM) {
cycle_counter = cycle_counter % CLOCKS_PER_SCANLINE_VRAM;
current_mode = VideoMode::HBLANK;
bool hblank_interrupt = check_bit(lcd_status.value(), 3);
if(hblank_interrupt) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
}
bool ly_coincidence_interrupt = check_bit(lcd_status.value(), 6);
bool ly_coincidence = ly_compare.value() == line.value();
if(ly_coincidence_interrupt && ly_coincidence) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
}
lcd_status.set_bit_to(2, ly_coincidence);
lcd_status.set_bit_to(1, false);
lcd_status.set_bit_to(0, false);
}
break;
case VideoMode::HBLANK:
if(cycle_counter >= CLOCKS_PER_HBLANK) {
if(!skip_render) write_scanline(line.value());
line.increment();
cycle_counter = cycle_counter % CLOCKS_PER_HBLANK;
/* Line 145 (index 144) is the first line of VBLANK */
if(line == 144) {
current_mode = VideoMode::VBLANK;
lcd_status.set_bit_to(1, false);
lcd_status.set_bit_to(0, true);
gb.cpu.interrupt_flag.set_bit_to(0, true);
} else {
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, false);
current_mode = VideoMode::ACCESS_OAM;
}
}
break;
case VideoMode::VBLANK:
if(cycle_counter >= CLOCKS_PER_SCANLINE) {
line.increment();
cycle_counter = cycle_counter % CLOCKS_PER_SCANLINE;
/* Line 155 (index 154) is the last line */
if(line == 154) {
if(!skip_render) {
write_sprites();
draw();
buffer.reset();
} else {
draw(); /* still notify the frontend for pacing */
}
line.reset();
current_mode = VideoMode::ACCESS_OAM;
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, false);
};
}
break;
}
}
auto Video::display_enabled() const -> bool {
return check_bit(control_byte, 7);
}
auto Video::window_tile_map() const -> bool {
return check_bit(control_byte, 6);
}
auto Video::window_enabled() const -> bool {
return check_bit(control_byte, 5);
}
auto Video::bg_window_tile_data() const -> bool {
return check_bit(control_byte, 4);
}
auto Video::bg_tile_map_display() const -> bool {
return check_bit(control_byte, 3);
}
auto Video::sprite_size() const -> bool {
return check_bit(control_byte, 2);
}
auto Video::sprites_enabled() const -> bool {
return check_bit(control_byte, 1);
}
auto Video::bg_enabled() const -> bool {
return check_bit(control_byte, 0);
}
void Video::write_scanline(u8 current_line) {
if(!display_enabled()) {
return;
}
/* Lines the frontend never displays are not worth rendering */
if(row_mask && current_line < GAMEBOY_HEIGHT && !row_mask[current_line]) {
return;
}
if(bg_enabled()) {
draw_bg_line(current_line);
}
if(window_enabled()) {
draw_window_line(current_line);
}
}
void Video::write_sprites() {
if(!sprites_enabled()) {
return;
}
for(uint sprite_n = 0; sprite_n < 40; sprite_n++) {
draw_sprite(sprite_n);
}
}
void Video::draw_bg_line(uint current_line) {
/* Note: tileset two uses signed numbering to share half the tiles with
* tileset one */
bool use_tile_set_zero = bg_window_tile_data();
bool use_tile_map_zero = !bg_tile_map_display();
Palette palette = load_palette(bg_palette);
u16 tile_set_address = use_tile_set_zero ? TILE_SET_ZERO_ADDRESS : TILE_SET_ONE_ADDRESS;
u16 tile_map_address = use_tile_map_zero ? TILE_MAP_ZERO_ADDRESS : TILE_MAP_ONE_ADDRESS;
uint screen_y = current_line;
uint scrolled_y = (screen_y + scroll_y.value()) % BG_MAP_SIZE;
uint tile_y = scrolled_y / TILE_HEIGHT_PX;
uint tile_pixel_y = scrolled_y % TILE_HEIGHT_PX;
uint tile_data_line_offset = tile_pixel_y * 2;
/* Render tile-by-tile instead of refetching the tile data for every
* pixel like upstream did */
uint scroll_x_val = scroll_x.value();
uint screen_x = 0;
while(screen_x < GAMEBOY_WIDTH) {
uint scrolled_x = (screen_x + scroll_x_val) % BG_MAP_SIZE;
uint tile_x = scrolled_x / TILE_WIDTH_PX;
uint tile_pixel_x = scrolled_x % TILE_WIDTH_PX;
uint tile_index = tile_y * TILES_PER_LINE + tile_x;
u8 tile_id = video_ram[tile_map_address - 0x8000 + tile_index];
uint tile_data_mem_offset = use_tile_set_zero ?
tile_id * TILE_BYTES :
static_cast<uint>(
(static_cast<s8>(tile_id) + 128)) *
TILE_BYTES;
uint line_addr = (tile_set_address - 0x8000) + tile_data_mem_offset +
tile_data_line_offset;
u8 pixels_1 = video_ram[line_addr];
u8 pixels_2 = video_ram[line_addr + 1];
/* Draw the remainder of this tile's row */
for(uint px = tile_pixel_x; px < TILE_WIDTH_PX && screen_x < GAMEBOY_WIDTH;
px++, screen_x++) {
u8 pixel_color = get_pixel_from_line(pixels_1, pixels_2, static_cast<u8>(px));
buffer.set_pixel(screen_x, screen_y, get_shade_from_palette(pixel_color, palette));
}
}
}
void Video::draw_window_line(uint current_line) {
bool use_tile_set_zero = bg_window_tile_data();
bool use_tile_map_zero = !window_tile_map();
Palette palette = load_palette(bg_palette);
u16 tile_set_address = use_tile_set_zero ? TILE_SET_ZERO_ADDRESS : TILE_SET_ONE_ADDRESS;
u16 tile_map_address = use_tile_map_zero ? TILE_MAP_ZERO_ADDRESS : TILE_MAP_ONE_ADDRESS;
uint screen_y = current_line;
uint scrolled_y = screen_y - window_y.value();
if(scrolled_y >= GAMEBOY_HEIGHT) {
return;
}
uint tile_y = scrolled_y / TILE_HEIGHT_PX;
uint tile_pixel_y = scrolled_y % TILE_HEIGHT_PX;
uint tile_data_line_offset = tile_pixel_y * 2;
for(uint screen_x = 0; screen_x < GAMEBOY_WIDTH; screen_x++) {
uint scrolled_x = screen_x + window_x.value() - 7;
uint tile_x = scrolled_x / TILE_WIDTH_PX;
uint tile_pixel_x = scrolled_x % TILE_WIDTH_PX;
uint tile_index = tile_y * TILES_PER_LINE + tile_x;
if(tile_index >= 32 * 32) continue;
u8 tile_id = video_ram[tile_map_address - 0x8000 + tile_index];
uint tile_data_mem_offset = use_tile_set_zero ?
tile_id * TILE_BYTES :
static_cast<uint>(
(static_cast<s8>(tile_id) + 128)) *
TILE_BYTES;
uint line_addr = (tile_set_address - 0x8000) + tile_data_mem_offset +
tile_data_line_offset;
u8 pixels_1 = video_ram[line_addr];
u8 pixels_2 = video_ram[line_addr + 1];
u8 pixel_color = get_pixel_from_line(pixels_1, pixels_2, static_cast<u8>(tile_pixel_x));
buffer.set_pixel(screen_x, screen_y, get_shade_from_palette(pixel_color, palette));
}
}
void Video::draw_sprite(const uint sprite_n) {
/* Each sprite is represented by 4 bytes */
u16 oam_start = static_cast<u16>(sprite_n * SPRITE_BYTES);
u8 sprite_y = gb.mmu.oam_ram[oam_start];
u8 sprite_x = gb.mmu.oam_ram[oam_start + 1];
/* Offscreen sprites are not drawn */
if(sprite_y == 0 || sprite_y >= 160) {
return;
}
if(sprite_x == 0 || sprite_x >= 168) {
return;
}
uint sprite_height = sprite_size() ? 16 : 8;
u8 pattern_n = gb.mmu.oam_ram[oam_start + 2];
u8 sprite_attrs = gb.mmu.oam_ram[oam_start + 3];
/* Bits 0-3 are used only for CGB */
bool use_palette_1 = check_bit(sprite_attrs, 4);
bool flip_x = check_bit(sprite_attrs, 5);
bool flip_y = check_bit(sprite_attrs, 6);
bool obj_behind_bg = check_bit(sprite_attrs, 7);
Palette palette = use_palette_1 ? load_palette(sprite_palette_1) :
load_palette(sprite_palette_0);
uint tile_offset = pattern_n * TILE_BYTES;
int start_y = sprite_y - 16;
int start_x = sprite_x - 8;
for(uint y = 0; y < sprite_height; y++) {
int screen_y = start_y + static_cast<int>(y);
if(screen_y < 0 || screen_y >= static_cast<int>(GAMEBOY_HEIGHT)) continue;
if(row_mask && !row_mask[screen_y]) continue;
uint src_y = !flip_y ? y : sprite_height - y - 1;
uint line_addr = tile_offset + src_y * 2; /* relative to tile set zero */
u8 pixels_1 = video_ram[line_addr];
u8 pixels_2 = video_ram[line_addr + 1];
for(uint x = 0; x < TILE_WIDTH_PX; x++) {
int screen_x = start_x + static_cast<int>(x);
if(screen_x < 0 || screen_x >= static_cast<int>(GAMEBOY_WIDTH)) continue;
uint src_x = !flip_x ? x : TILE_WIDTH_PX - x - 1;
u8 gb_color = get_pixel_from_line(pixels_1, pixels_2, static_cast<u8>(src_x));
/* Color 0 is transparent */
if(gb_color == 0) {
continue;
}
Shade existing_pixel = buffer.get_pixel(
static_cast<uint>(screen_x), static_cast<uint>(screen_y));
/* Note: same behaviour as upstream - compares the final shade
* rather than the logical color 0 */
if(obj_behind_bg && existing_pixel != SHADE_WHITE) {
continue;
}
buffer.set_pixel(
static_cast<uint>(screen_x),
static_cast<uint>(screen_y),
get_shade_from_palette(gb_color, palette));
}
}
}
auto Video::get_pixel_from_line(u8 byte1, u8 byte2, u8 pixel_index) -> u8 {
using bitwise::bit_value;
return static_cast<u8>(
(bit_value(byte2, 7 - pixel_index) << 1) | bit_value(byte1, 7 - pixel_index));
}
auto Video::load_palette(const ByteRegister& palette_register) -> Palette {
u8 v = palette_register.value();
Palette palette;
palette.color0 = static_cast<Shade>(v & 0x3);
palette.color1 = static_cast<Shade>((v >> 2) & 0x3);
palette.color2 = static_cast<Shade>((v >> 4) & 0x3);
palette.color3 = static_cast<Shade>((v >> 6) & 0x3);
return palette;
}
auto Video::get_shade_from_palette(u8 color, const Palette& palette) -> Shade {
switch(color) {
case 0:
return palette.color0;
case 1:
return palette.color1;
case 2:
return palette.color2;
default:
return palette.color3;
}
}
void Video::draw() {
if(vblank_callback) vblank_callback(vblank_ctx);
}
Binary file not shown.
@@ -3,7 +3,7 @@
* Usage: hosttest <rom.gb> [max_frames] [--dump-frame N]
*/
#include "../gb/gameboy.h"
#include "../lib/gbcore/gameboy.h"
#include <cstdio>
#include <cstdlib>
@@ -29,9 +29,9 @@ static void serial_hook(u8 byte) {
}
}
static const u8* bank_provider(void* /*ctx*/, uint bank) {
long offset = static_cast<long>(bank) * 0x4000;
if(offset + 0x4000 > g_rom_size) offset = 0;
static const u8* bank_provider(void* /*ctx*/, uint unit) {
long offset = static_cast<long>(unit) * 0x1000; /* 4 KB ROM units */
if(offset + 0x1000 > g_rom_size) offset = 0;
return g_rom + offset;
}
@@ -98,10 +98,32 @@ int main(int argc, char** argv) {
bool dump = false;
bool rowmask = false;
bool dump_audio = false;
bool skiprender = false;
/* input script: --press <frame>:<BTN>:<hold_frames>, repeatable */
struct ScriptPress { long frame; GbButton btn; long hold; };
ScriptPress script[32];
int nscript = 0;
for(int i = 1; i < argc; i++) {
if(!strcmp(argv[i], "--dump-frame")) dump = true;
if(!strcmp(argv[i], "--rowmask")) rowmask = true;
if(!strcmp(argv[i], "--dump-audio")) dump_audio = true;
if(!strcmp(argv[i], "--skiprender")) skiprender = true;
if(!strcmp(argv[i], "--press") && i + 1 < argc && nscript < 32) {
char name[16];
long fr, hold;
if(sscanf(argv[++i], "%ld:%15[^:]:%ld", &fr, name, &hold) == 3) {
GbButton b = GbButton::A;
if(!strcmp(name, "A")) b = GbButton::A;
else if(!strcmp(name, "B")) b = GbButton::B;
else if(!strcmp(name, "START")) b = GbButton::Start;
else if(!strcmp(name, "SELECT")) b = GbButton::Select;
else if(!strcmp(name, "UP")) b = GbButton::Up;
else if(!strcmp(name, "DOWN")) b = GbButton::Down;
else if(!strcmp(name, "LEFT")) b = GbButton::Left;
else if(!strcmp(name, "RIGHT")) b = GbButton::Right;
script[nscript++] = {fr, b, hold};
}
}
}
/* Same 144 -> 64 line subsampling the Flipper frontend uses */
@@ -146,12 +168,17 @@ int main(int argc, char** argv) {
auto* gb = new Gameboy(g_rom, banks, mbc, cart_ram, ram_size, bank_provider, nullptr);
gb->set_frame_callback(frame_hook, gb);
if(rowmask) gb->set_row_mask(mask);
if(skiprender) gb->set_skip_render(true);
u32 last_freq = 0;
int last_ch = -1;
u8 last_vol = 0;
for(long frame = 0; frame < max_frames; frame++) {
for(int i = 0; i < nscript; i++) {
if(script[i].frame == frame) gb->button_pressed(script[i].btn);
if(script[i].frame + script[i].hold == frame) gb->button_released(script[i].btn);
}
gb->run_to_vblank();
if(dump_audio) {
@@ -1,15 +1,16 @@
/* Cold code: size-optimized on the embedded target. Every KB of binary
* is a KB of heap the ROM page cache loses (the FAP loads into RAM), and
* nothing in this file runs per-instruction. */
#if defined(__arm__) && defined(__GNUC__)
#pragma GCC optimize("Os")
#endif
#include "apu.h"
/* One frame-sequencer step every 2048 M-cycles (8192 T-cycles = 512 Hz),
* in the same M-cycle domain the CPU/PPU/timer now share. */
static const uint FRAME_SEQ_PERIOD = 2048;
void Apu::tick(uint cycles) {
if(!power) return;
seq_counter += cycles;
while(seq_counter >= FRAME_SEQ_PERIOD) {
seq_counter -= FRAME_SEQ_PERIOD;
/* Slow path of Apu::tick (see apu.h): runs the due frame-sequencer steps */
void Apu::seq_run() {
while(seq_counter >= 2048) {
seq_counter -= 2048;
seq_step = (u8)((seq_step + 1) & 7);
if((seq_step & 1) == 0) clock_lengths(); /* 256 Hz */
@@ -28,7 +28,14 @@ struct ApuVoice {
class Apu {
public:
void tick(uint cycles);
/* Called once per emulated instruction; the fast path (powered off, or
* no sequencer step due) must stay inline and branch-cheap. One
* frame-sequencer step every 2048 M-cycles = 8192 T-cycles = 512 Hz. */
void tick(uint cycles) {
if(!power) return;
seq_counter += cycles;
if(seq_counter >= 2048) seq_run();
}
/* 0xFF10 - 0xFF3F (sound registers + wave RAM) */
auto read(u16 addr) const -> u8;
@@ -37,6 +44,73 @@ public:
/* n: 0 = pulse 1, 1 = pulse 2, 2 = wave, 3 = noise */
void get_voice(uint n, ApuVoice* out) const;
/* save-state support: exact raw state (masked IO reads lose
* write-only bits like the frequency-low registers) */
struct State {
u8 regs[20]; /* ch[4] x nr0..nr4 */
u8 nr50, nr51, power;
u8 wave[16];
u8 enabled_mask;
u8 env_vol[4], env_tim[4];
u16 length[4];
u32 order[4];
u32 sweep_shadow;
u8 sweep_timer, sweep_en;
u32 seq_counter;
u8 seq_step;
u32 trig_counter;
};
void export_state(State* s) const {
for(uint n = 0; n < 4; n++) {
s->regs[n * 5 + 0] = ch[n].nr0;
s->regs[n * 5 + 1] = ch[n].nr1;
s->regs[n * 5 + 2] = ch[n].nr2;
s->regs[n * 5 + 3] = ch[n].nr3;
s->regs[n * 5 + 4] = ch[n].nr4;
s->env_vol[n] = ch[n].env_volume;
s->env_tim[n] = ch[n].env_timer;
s->length[n] = (u16)ch[n].length;
s->order[n] = ch[n].order;
if(ch[n].enabled) s->enabled_mask |= (u8)(1 << n);
}
s->nr50 = nr50;
s->nr51 = nr51;
s->power = power;
for(uint i = 0; i < 16; i++)
s->wave[i] = wave_ram[i];
s->sweep_shadow = sweep_shadow;
s->sweep_timer = sweep_timer;
s->sweep_en = sweep_enabled;
s->seq_counter = seq_counter;
s->seq_step = seq_step;
s->trig_counter = trigger_counter;
}
void import_state(const State* s) {
for(uint n = 0; n < 4; n++) {
ch[n].nr0 = s->regs[n * 5 + 0];
ch[n].nr1 = s->regs[n * 5 + 1];
ch[n].nr2 = s->regs[n * 5 + 2];
ch[n].nr3 = s->regs[n * 5 + 3];
ch[n].nr4 = s->regs[n * 5 + 4];
ch[n].env_volume = s->env_vol[n];
ch[n].env_timer = s->env_tim[n];
ch[n].length = s->length[n];
ch[n].order = s->order[n];
ch[n].enabled = (s->enabled_mask >> n) & 1;
}
nr50 = s->nr50;
nr51 = s->nr51;
power = s->power != 0;
for(uint i = 0; i < 16; i++)
wave_ram[i] = s->wave[i];
sweep_shadow = s->sweep_shadow;
sweep_timer = s->sweep_timer;
sweep_enabled = s->sweep_en != 0;
seq_counter = s->seq_counter;
seq_step = s->seq_step;
trigger_counter = s->trig_counter;
}
/* Master volume 0..7 (louder of the two NR50 output terminals) */
auto master_volume() const -> u8 {
u8 l = (nr50 >> 4) & 7;
@@ -81,6 +155,7 @@ private:
}
void trigger(uint n);
void seq_run();
void clock_lengths();
void clock_envelopes();
void clock_sweep();
@@ -1,3 +1,10 @@
/* Cold code: size-optimized on the embedded target. Every KB of binary
* is a KB of heap the ROM page cache loses (the FAP loads into RAM), and
* nothing in this file runs per-instruction. */
#if defined(__arm__) && defined(__GNUC__)
#pragma GCC optimize("Os")
#endif
#include "cartridge.h"
void Cartridge::init(
@@ -21,6 +28,7 @@ void Cartridge::init(
bank_low = 1;
bank_high = 0;
ram_bank = 0;
cur_bank = 0xFFFFFFFFu;
update_rom_bank();
}
@@ -36,6 +44,11 @@ void Cartridge::update_rom_bank() {
bank = (bank_high << 5) | bank_low;
/* bank_low == 0 is translated to 1 at write time */
break;
case MBCType::MBC1M:
/* multicart wiring: only 4 low bits reach the ROM, the high
* register selects the sub-game */
bank = (bank_high << 4) | (bank_low & 0x0F);
break;
case MBCType::MBC2:
case MBCType::MBC3:
bank = bank_low;
@@ -51,7 +64,22 @@ void Cartridge::update_rom_bank() {
if(bank_count) bank %= bank_count;
bankN = provider(provider_ctx, bank);
/* Bank switching is FREE: quarters are unmapped and fetched lazily
* on first read. Games rewrite the bank register constantly without
* reading -- eager fetching here was ~86 phantom misses per frame. */
if(bank == cur_bank) return;
cur_bank = bank;
bankN_q[0] = bankN_q[1] = bankN_q[2] = bankN_q[3] = nullptr;
}
void Cartridge::fetch_quarter(uint q) const {
/* Refresh the LRU stamps of the quarters already mapped so this
* fetch can never evict them (the provider protects the four most
* recently returned units; at most 3 are mapped when we get here). */
for(uint i = 0; i < 4; i++) {
if(bankN_q[i]) bankN_q[i] = provider(provider_ctx, cur_bank * 4 + i);
}
bankN_q[q] = provider(provider_ctx, cur_bank * 4 + q);
}
void Cartridge::write(u16 addr, u8 value) {
@@ -64,6 +92,7 @@ void Cartridge::write(u16 addr, u8 value) {
case MBCType::None:
return;
case MBCType::MBC1M:
case MBCType::MBC1:
if(addr < 0x2000) {
ram_enabled = (value & 0x0F) == 0x0A;
@@ -109,7 +138,12 @@ void Cartridge::write(u16 addr, u8 value) {
* reads return 0xFF via ram_bank marker) */
ram_bank = value;
} else {
/* RTC latch: unsupported */
/* RTC latch: writing 0x00 then 0x01 latches the clock */
if(rtc_present) {
if(value == 0x00) rtc.latch_armed = true;
else if(value == 0x01 && rtc.latch_armed) rtc_latch();
if(value != 0x00) rtc.latch_armed = false;
}
}
return;
@@ -140,26 +174,80 @@ auto Cartridge::read_ram(u16 addr) const -> u8 {
return static_cast<u8>(ram[(addr - 0xA000) & 0x1FF] | 0xF0);
}
if(mbc == MBCType::MBC3 && ram_bank > 0x03) return 0xFF; /* RTC regs */
if(mbc == MBCType::MBC3 && ram_bank > 0x03) {
if(rtc_present && ram_bank >= 0x08 && ram_bank <= 0x0C)
return rtc_read((u8)(ram_bank - 0x08));
return 0xFF;
}
u32 idx = static_cast<u32>(addr - 0xA000) + static_cast<u32>(ram_bank & 0x0F) * 0x2000;
if(idx >= ram_size) idx %= ram_size;
return ram[idx];
}
auto Cartridge::rtc_seconds() const -> u32 {
if(rtc.halted || !rtc_now) return rtc.halt_value;
return rtc_now() - rtc.base;
}
void Cartridge::rtc_latch() {
u32 t = rtc_seconds();
rtc.latched[0] = (u8)(t % 60);
rtc.latched[1] = (u8)((t / 60) % 60);
rtc.latched[2] = (u8)((t / 3600) % 24);
u32 days = t / 86400;
rtc.latched[3] = (u8)(days & 0xFF);
rtc.latched[4] = (u8)(((days >> 8) & 1) | (rtc.halted ? 0x40 : 0) |
(days > 511 ? 0x80 : 0));
}
auto Cartridge::rtc_read(u8 reg) const -> u8 {
static const u8 mask[5] = {0x3F, 0x3F, 0x1F, 0xFF, 0xC1};
return (u8)(rtc.latched[reg] | (u8)~mask[reg]); /* unused bits read 1 */
}
void Cartridge::rtc_write(u8 reg, u8 value) {
/* rebuild the counter from the current live values with one field
* replaced, then re-derive base */
u32 t = rtc_seconds();
u32 s = t % 60, m = (t / 60) % 60, h = (t / 3600) % 24, d = t / 86400;
switch(reg) {
case 0: s = value % 60; break;
case 1: m = value % 60; break;
case 2: h = value % 24; break;
case 3: d = (d & 0x100) | value; break;
case 4:
d = (d & 0xFF) | ((value & 1) << 8);
rtc.halted = (value & 0x40) != 0;
break;
}
u32 nt = ((d * 24 + h) * 60 + m) * 60 + s;
if(rtc.halted) {
rtc.halt_value = nt;
} else if(rtc_now) {
rtc.base = rtc_now() - nt;
}
}
void Cartridge::write_ram(u16 addr, u8 value) {
if(!ram || !ram_enabled) return;
if(mbc == MBCType::MBC2) {
ram[(addr - 0xA000) & 0x1FF] = value & 0x0F;
ram_written = true;
return;
}
if(mbc == MBCType::MBC3 && ram_bank > 0x03) return; /* RTC regs */
if(mbc == MBCType::MBC3 && ram_bank > 0x03) {
if(rtc_present && ram_bank >= 0x08 && ram_bank <= 0x0C)
rtc_write((u8)(ram_bank - 0x08), value);
return;
}
u32 idx = static_cast<u32>(addr - 0xA000) + static_cast<u32>(ram_bank & 0x0F) * 0x2000;
if(idx >= ram_size) idx %= ram_size;
ram[idx] = value;
ram_written = true;
}
auto Cartridge::parse_mbc(u8 t) -> MBCType {
@@ -0,0 +1,152 @@
#pragma once
#include "definitions.h"
/* Cartridge with pluggable ROM page provider.
*
* Instead of holding the whole ROM in RAM (impossible on Flipper Zero for
* anything above 32 KB), the cartridge asks the platform for pointers to
* 8 KB ROM pages (page n = ROM offset n * 0x2000) whenever the game
* switches banks. 8 KB granularity -- half a MBC bank -- doubles how many
* cache slots fit in the same RAM and halves the SD stall of a cache miss,
* which matters a lot for bank-switch heavy games (Pokemon switches banks
* for music/code/data every frame). On the desktop test build the provider
* just returns `rom + page * 0x2000`; on the Flipper it is backed by an
* LRU cache streaming from the SD card.
*
* Supported mappers: ROM only, MBC1 (incl. upper bits / mode select),
* MBC2 (built-in 512x4 RAM), MBC3 (no RTC), MBC5.
*/
/* The provider hands out 4 KB ROM units (unit n = ROM offset n * 0x1000)
* and guarantees the FOUR most recently returned units stay valid. */
using RomBankProvider = const u8* (*)(void* ctx, uint unit);
enum class MBCType : u8 {
None,
MBC1,
MBC1M, /* MBC1 multicart wiring (bank_high shifts by 4, not 5) */
MBC2,
MBC3,
MBC5,
Unsupported,
};
/* Wall-clock hook for the MBC3 RTC: returns seconds since an arbitrary
* epoch. Host build: time(). Flipper: furi_hal_rtc timestamp. */
using RtcNowProvider = u32 (*)(void);
struct Mbc3Rtc {
/* live counters derived from now() - base, plus the latched copy */
u32 base = 0; /* now() when the RTC was at 0s (adjusted on writes) */
bool halted = false;
u32 halt_value = 0; /* frozen seconds while halted */
u8 latched[5] = {}; /* S, M, H, DL, DH */
bool latch_armed = false;
};
class Cartridge {
public:
/* bank0 must stay valid for the lifetime of the cartridge */
void init(
const u8* bank0_data,
uint rom_bank_count,
MBCType mbc_type,
u8* cart_ram,
u32 cart_ram_size,
RomBankProvider provider,
void* provider_ctx);
auto read(u16 addr) const -> u8 {
if(addr < 0x4000) return bank0[addr];
if(addr < 0x8000) {
/* The switchable bank is four LAZILY mapped 4 KB quarters:
* nothing is fetched at bank-switch time. Measured on Pokemon
* Red: the game rewrites the bank register ~200x per frame
* (trampolines/ISR save-restore) while actually READING from
* only ~17 units -- eager mapping caused ~86 phantom cache
* misses per frame (the whole 100 ms lag). */
uint q = (uint)(addr - 0x4000) >> 12;
if(!bankN_q[q]) fetch_quarter(q);
return bankN_q[q][addr & 0x0FFF];
}
/* 0xA000 - 0xBFFF: cartridge RAM */
return read_ram(addr);
}
void write(u16 addr, u8 value);
auto get_ram() -> u8* { return ram; }
auto get_ram_size() const -> u32 { return ram_size; }
/* battery-RAM dirty tracking (frontend autosave) */
auto ram_dirty() const -> bool { return ram_written; }
void clear_ram_dirty() { ram_written = false; }
/* MBC3 RTC */
void set_rtc_provider(RtcNowProvider p) { rtc_now = p; }
auto has_rtc() const -> bool { return rtc_present; }
void set_rtc_present(bool p) { rtc_present = p; }
auto rtc_state() -> Mbc3Rtc& { return rtc; }
/* save-state accessors */
struct BankState {
u8 bank_low, bank_high, ram_bank;
bool ram_enabled, advanced_mode;
};
void export_banks(BankState* st) const {
st->bank_low = (u8)bank_low;
st->bank_high = (u8)bank_high;
st->ram_bank = (u8)ram_bank;
st->ram_enabled = ram_enabled;
st->advanced_mode = advanced_banking_mode;
}
void import_banks(const BankState* st) {
bank_low = st->bank_low;
bank_high = st->bank_high;
ram_bank = st->ram_bank;
ram_enabled = st->ram_enabled;
advanced_banking_mode = st->advanced_mode;
cur_bank = 0xFFFFFFFFu; /* force remap */
update_rom_bank();
}
/* Header helpers (operate on the first bank) */
static auto parse_mbc(u8 cartridge_type_byte) -> MBCType;
static auto has_battery(u8 cartridge_type_byte) -> bool;
static auto rom_bank_count_from_header(u8 rom_size_byte) -> uint;
static auto ram_size_from_header(u8 ram_size_byte, MBCType mbc) -> u32;
private:
auto read_ram(u16 addr) const -> u8;
void write_ram(u16 addr, u8 value);
void fetch_quarter(uint q) const;
auto rtc_read(u8 reg) const -> u8;
void rtc_write(u8 reg, u8 value);
void rtc_latch();
auto rtc_seconds() const -> u32;
void update_rom_bank();
const u8* bank0 = nullptr;
mutable const u8* bankN_q[4] = {}; /* lazy 4 KB quarters of the bank */
uint cur_bank = 0xFFFFFFFFu; /* currently selected bank (memo) */
u8* ram = nullptr;
u32 ram_size = 0;
RomBankProvider provider = nullptr;
void* provider_ctx = nullptr;
MBCType mbc = MBCType::None;
uint bank_count = 2;
bool ram_enabled = false;
bool ram_written = false;
bool rtc_present = false;
RtcNowProvider rtc_now = nullptr;
Mbc3Rtc rtc;
bool advanced_banking_mode = false; /* MBC1 mode 1 */
uint bank_low = 1; /* MBC1: 5 bits, MBC3: 7 bits, MBC5: 8 bits */
uint bank_high = 0; /* MBC1: 2 bits, MBC5: 9th bit */
uint ram_bank = 0;
};
@@ -29,18 +29,52 @@ void CPU::init_post_boot() {
}
auto CPU::tick() -> Cycles {
handle_interrupts();
/* STOP: the CPU core is frozen until joypad activity (notify_joypad).
* Interrupt flags raised meanwhile stay pending. */
if (stopped) { return 8; }
/* Halted: batch 4 M-cycles per iteration. Games spend most of every
/* Interrupt fast path: nothing pending for the enabled sources (the
* overwhelmingly common case) costs two loads and a branch. Bits 5-7
* of IF/IE are unwired on hardware and must be masked: without the
* mask, IF's always-set upper bits (post-boot 0xE1) against a game
* writing IE=0xFF would push PC without dispatching any vector. */
u8 fired = (u8)(interrupt_flag.value() & interrupt_enabled.value() & 0x1F);
if(fired) {
/* an actual dispatch costs 5 M-cycles on hardware; the vector's
* first instruction executes on the next tick */
if(handle_interrupts(fired)) return 5;
}
/* Halted: batch 8 M-cycles per iteration. Games spend most of every
* frame in HALT waiting for vblank; stepping 1 cycle at a time made
* the idle part of the frame as expensive to emulate as the busy part.
* Interrupt recognition is delayed by at most 3 M-cycles (12 T-cycles),
* Interrupt recognition is delayed by at most 7 M-cycles (28 T-cycles),
* well within what real hardware tolerates. */
if (halted) { return 4; }
if (halted) { return 8; }
/* EI enables IME only AFTER the instruction that follows it. Without
* this delay, the classic pause idiom `EI / HALT` with an interrupt
* already pending dispatched BETWEEN the two: the ISR consumed the
* wake event, RETI returned onto the HALT, and the game slept with
* its wake condition already spent -- input appeared permanently dead
* while the vblank ISR (music) kept running. */
bool ei_was_pending = ime_pending;
u16 opcode_pc = pc.value();
auto opcode = get_byte_from_pc();
if(halt_bug) {
/* DMG halt bug: the byte after HALT is fetched without the PC
* advancing, so it is executed twice (or becomes its own operand) */
pc.set(opcode_pc);
halt_bug = false;
}
auto cycles = execute_opcode(opcode, opcode_pc);
if(ei_was_pending && ime_pending) {
/* not cancelled by a DI in the delay slot */
interrupts_enabled = true;
ime_pending = false;
}
return cycles;
}
@@ -55,37 +89,23 @@ auto CPU::execute_opcode(const u8 opcode, u16 opcode_pc) -> Cycles {
return execute_normal_opcode(opcode, opcode_pc);
}
void CPU::handle_interrupts() {
u8 fired_interrupts = interrupt_flag.value() & interrupt_enabled.value();
if (!fired_interrupts) { return; }
if (halted && fired_interrupts != 0x0) {
// TODO: Handle halt bug
auto CPU::handle_interrupts(u8 fired_interrupts) -> bool {
if (halted) {
halted = false;
}
if (!interrupts_enabled) {
return;
return false;
}
stack_push(pc);
bool handled_interrupt = false;
handled_interrupt = handle_interrupt(0, interrupts::vblank, fired_interrupts);
if (handled_interrupt) { return; }
handled_interrupt = handle_interrupt(1, interrupts::lcdc_status, fired_interrupts);
if (handled_interrupt) { return; }
handled_interrupt = handle_interrupt(2, interrupts::timer, fired_interrupts);
if (handled_interrupt) { return; }
handled_interrupt = handle_interrupt(3, interrupts::serial, fired_interrupts);
if (handled_interrupt) { return; }
handled_interrupt = handle_interrupt(4, interrupts::joypad, fired_interrupts);
if (handled_interrupt) { return; }
if (handle_interrupt(0, interrupts::vblank, fired_interrupts)) return true;
if (handle_interrupt(1, interrupts::lcdc_status, fired_interrupts)) return true;
if (handle_interrupt(2, interrupts::timer, fired_interrupts)) return true;
if (handle_interrupt(3, interrupts::serial, fired_interrupts)) return true;
if (handle_interrupt(4, interrupts::joypad, fired_interrupts)) return true;
return false; /* unreachable: fired is masked non-zero */
}
auto CPU::handle_interrupt(u8 interrupt_bit, u16 interrupt_vector, u8 fired_interrupts) -> bool {
@@ -96,27 +116,20 @@ auto CPU::handle_interrupt(u8 interrupt_bit, u16 interrupt_vector, u8 fired_inte
interrupt_flag.set_bit_to(interrupt_bit, false);
pc.set(interrupt_vector);
interrupts_enabled = false;
/* belt and braces: a dispatch must never carry the halt-bug PC-repeat
* into the handler's first instruction */
halt_bug = false;
/* A pending EI (redundant EI executed right before the dispatch) must
* die here: without this, IME was re-enabled one instruction INTO the
* interrupt handler, allowing unintended nested interrupts to corrupt
* non-reentrant handlers -- games wedged after a few button presses. */
ime_pending = false;
return true;
}
auto CPU::get_byte_from_pc() -> u8 {
u8 byte = gb.mmu.read(Address(pc));
pc.increment();
return byte;
}
auto CPU::get_signed_byte_from_pc() -> s8 {
u8 byte = get_byte_from_pc();
return static_cast<s8>(byte);
}
auto CPU::get_word_from_pc() -> u16 {
u8 low_byte = get_byte_from_pc();
u8 high_byte = get_byte_from_pc();
return compose_bytes(high_byte, low_byte);
}
/* get_byte_from_pc / get_signed_byte_from_pc / get_word_from_pc are the
* hottest functions in the emulator (every instruction fetches through
* them): they are defined inline at the bottom of gameboy.h. */
void CPU::set_flag_zero(bool set) { f.set_flag_zero(set); }
void CPU::set_flag_subtract(bool set) { f.set_flag_subtract(set); }
@@ -223,3 +236,11 @@ auto CPU::execute_cb_opcode(const u8 opcode, u16 opcode_pc) -> Cycles {
return opcode_cycles_cb[opcode];
}
/* Unity build: the opcode implementations are compiled inside this same
* translation unit so the dispatch switches above can call (and inline)
* them directly. As separate TUs, every emulated instruction paid two
* cross-TU calls (dispatch -> opcode_XX -> opcode helper), which the
* compiler could not eliminate without LTO. */
#include "opcode_mapping.inc"
#include "opcodes.inc"
@@ -51,14 +51,60 @@ public:
ByteRegister interrupt_flag;
ByteRegister interrupt_enabled;
/* save-state support */
struct State {
u16 af, bc, de, hl, sp, pc;
u8 ime, ime_pend, halt, stop;
};
void export_state(State* s) const {
s->af = af.value();
s->bc = bc.value();
s->de = de.value();
s->hl = hl.value();
s->sp = sp.value();
s->pc = pc.value();
s->ime = interrupts_enabled;
s->ime_pend = ime_pending;
s->halt = halted;
s->stop = stopped;
}
void import_state(const State* s) {
af.set(s->af);
bc.set(s->bc);
de.set(s->de);
hl.set(s->hl);
sp.set(s->sp);
pc.set(s->pc);
interrupts_enabled = s->ime != 0;
ime_pending = s->ime_pend != 0;
halted = s->halt != 0;
stopped = s->stop != 0;
halt_bug = false;
}
private:
void handle_interrupts();
auto handle_interrupts(u8 fired_interrupts) -> bool; /* true = dispatched */
auto handle_interrupt(u8 interrupt_bit, u16 interrupt_vector, u8 fired_interrupts) -> bool;
Gameboy& gb;
bool interrupts_enabled = false;
bool ime_pending = false; /* EI takes effect AFTER the next instruction */
bool halted = false;
bool stopped = false; /* STOP: woken only by joypad activity */
bool halt_bug = false; /* HALT w/ IME=0 and pending IF&IE: PC repeats */
public:
/* Joypad line activity: wakes STOP unconditionally (real DMG behaviour:
* STOP exits on a joypad line change regardless of IE/IF/IME). */
void notify_joypad() {
if(stopped) {
stopped = false;
halted = false;
}
}
private:
bool branch_taken = false;
@@ -7,6 +7,7 @@ using uint = unsigned int;
using u8 = uint8_t;
using u16 = uint16_t;
using u32 = uint32_t;
using u64 = uint64_t;
using s8 = int8_t;
using s16 = uint16_t; /* kept as upstream (unused alias) */
@@ -61,6 +61,15 @@ public:
* storage slot s starts at bit offset s * GAMEBOY_WIDTH * 2. */
auto raw() const -> const u8* { return buf; }
/* Direct pointer to the packed bytes of screen row y (40 bytes,
* 4 pixels each, LSB-first) or nullptr if the row has no storage.
* Used by the byte-oriented renderer in video.cc. */
auto row_ptr(uint y) -> u8* {
uint slot = row_slot[y];
if(slot == NO_ROW) return nullptr;
return buf + slot * (GAMEBOY_WIDTH / 4);
}
private:
static const u8 NO_ROW = 0xFF;
@@ -43,25 +43,24 @@ void Gameboy::vblank_trampoline(void* ctx) {
void Gameboy::button_pressed(GbButton button) {
input.button_pressed(button);
/* Request the joypad interrupt (missing upstream); mainly wakes
* games waiting in HALT/STOP for input. */
cpu.interrupt_flag.set_bit_to(4, true);
/* Joypad interrupt: hardware only triggers for currently selected
* P1 matrix lines. Mainly wakes games waiting in HALT for input. */
if(input.line_selected(button)) cpu.interrupt_flag.set_bit_to(4, true);
/* STOP wakes on any joypad line activity regardless of IE/IF/IME */
cpu.notify_joypad();
}
void Gameboy::button_released(GbButton button) {
input.button_released(button);
cpu.notify_joypad(); /* STOP wakes on any line CHANGE, release included */
}
void Gameboy::run_to_vblank() {
/* Gameboy::tick and the peripheral ticks it calls are defined inline
* in gameboy.h, so this loop compiles into one flattened body with a
* single out-of-line call per instruction (cpu.tick). */
frame_done = false;
while(!frame_done) {
tick();
}
}
void Gameboy::tick() {
auto cycles = cpu.tick();
video.tick(cycles);
timer.tick(cycles.cycles);
apu.tick(cycles.cycles);
}
@@ -0,0 +1,218 @@
#pragma once
#include "input.h"
#include "cpu.h"
#include "video.h"
#include "timer.h"
#include "mmu.h"
#include "cartridge.h"
#include "apu.h"
class Gameboy {
public:
/* bank0: pointer to the first 16 KB of ROM (stays resident).
* provider: returns pointers to 16 KB switchable banks. */
Gameboy(
const u8* bank0,
uint rom_bank_count,
MBCType mbc,
u8* cart_ram,
u32 cart_ram_size,
RomBankProvider provider,
void* provider_ctx);
/* Runs the emulator until the next vblank (one full frame). */
void run_to_vblank();
/* Called on every vblank BEFORE the framebuffer is cleared for the next
* frame: this is where the frontend must convert/copy the image. */
void set_frame_callback(void (*cb)(void*), void* ctx) {
user_frame_cb = cb;
user_frame_ctx = ctx;
}
void button_pressed(GbButton button);
void button_released(GbButton button);
void set_skip_render(bool skip) { video.skip_render = skip; }
/* Flush pending peripheral cycles. Called automatically every
* PERIPH_BATCH M-cycles and before any IO register read/write so
* LY/STAT/DIV/TIMA/IF are always fresh where the game can see them. */
void sync_peripherals() {
uint n = pending_cycles;
if(n) {
pending_cycles = 0;
video.tick(Cycles(n));
timer.tick(n);
apu.tick(n);
mmu.serial_tick(n);
}
}
/* Optional display-line mask (see Video::row_mask). The array must stay
* valid for the lifetime of the emulator. */
void set_row_mask(const u8* mask) { video.set_row_mask(mask); }
auto get_framebuffer() const -> const FrameBuffer& { return video.get_framebuffer(); }
auto get_cartridge_ram() -> u8* { return cartridge.get_ram(); }
auto get_cartridge_ram_size() const -> u32 { return cartridge.get_ram_size(); }
Cartridge cartridge;
CPU cpu;
friend class CPU;
Video video;
friend class Video;
MMU mmu;
friend class MMU;
Timer timer;
friend class Timer;
Apu apu;
Input input;
private:
void tick();
/* Peripheral catch-up batching (the single biggest hot-loop win on the
* Cortex-M4): instead of stepping the PPU/timer/APU state machines
* after every CPU instruction, cycles accumulate and are flushed every
* PERIPH_BATCH M-cycles -- or immediately whenever the CPU touches an
* IO register. Interrupt delivery is delayed by at most PERIPH_BATCH
* M-cycles (128 T-cycles), far below what games can observe, while IO
* reads always see exact values thanks to the sync points. */
static const uint PERIPH_BATCH = 32;
uint pending_cycles = 0;
static void vblank_trampoline(void* ctx);
volatile bool frame_done = false;
void (*user_frame_cb)(void*) = nullptr;
void* user_frame_ctx = nullptr;
};
/* ==================== hot-path inline implementations ====================
*
* Everything below runs once (or more) per emulated instruction. These
* bodies need the complete Gameboy definition, and they are defined here
* -- instead of in their .cc files -- so the core can inline them without
* LTO. Before this, each emulated instruction paid 6-8 cross-TU function
* calls (opcode fetch through the MMU, PPU/timer/APU ticks), which
* dominated the frame time on the Cortex-M4.
*
* Deliberately NOT inlined: the full MMU read/write dispatch. Inlining it
* into the ~300 opcode call sites costs ~15 KB of code (= RAM on the
* Flipper, where the binary loads into the same heap as the ROM cache).
* Only the instruction-fetch fast path (PC in ROM, true for virtually
* every instruction) is inlined below. */
inline void Video::tick(Cycles cycles) {
cycle_counter += cycles.cycles;
/* Fast path: no mode boundary crossed. Boundary work (interrupts,
* scanline rendering) is out-of-line in video.cc. The counter wrap
* uses subtraction instead of the old modulo: per-instruction cycle
* increments (<= 6) can never overshoot a whole extra period. */
switch(current_mode) {
case VideoMode::ACCESS_OAM:
if(cycle_counter >= CLOCKS_PER_SCANLINE_OAM) {
cycle_counter -= CLOCKS_PER_SCANLINE_OAM;
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, true);
current_mode = VideoMode::ACCESS_VRAM;
}
break;
case VideoMode::ACCESS_VRAM:
if(cycle_counter >= CLOCKS_PER_SCANLINE_VRAM) {
cycle_counter -= CLOCKS_PER_SCANLINE_VRAM;
mode_transition_vram_end();
}
break;
case VideoMode::HBLANK:
if(cycle_counter >= CLOCKS_PER_HBLANK) {
cycle_counter -= CLOCKS_PER_HBLANK;
mode_transition_hblank_end();
}
break;
case VideoMode::VBLANK:
if(cycle_counter >= CLOCKS_PER_SCANLINE) {
cycle_counter -= CLOCKS_PER_SCANLINE;
mode_transition_vblank_line();
}
break;
}
}
inline void Timer::tick(uint cycles) {
/* DIV increments at 16384 Hz = every 64 M-cycles */
div_clocks += cycles;
if(div_clocks >= 64) {
divider.set(static_cast<u8>(divider.value() + (div_clocks >> 6)));
div_clocks &= 63;
}
/* Accumulate T-cycles only while the timer is enabled (accumulating
* with TAC off built an unbounded backlog that drained as a burst of
* spurious overflows when the timer was enabled). The sub-period
* remainder is kept across TAC toggles, matching hardware phase
* behaviour more closely. */
if(!timer_control.check_bit(2)) return;
clocks += cycles * 4; /* M-cycles -> T-cycles */
uint clock_limit = clocks_needed_to_increment();
/* a long instruction can cross more than one period of the fastest
* (16 T-cycle) timer rate; the old modulo silently dropped those */
while(clocks >= clock_limit) {
clocks -= clock_limit;
u8 old_timer_counter = timer_counter.value();
timer_counter.increment();
if(timer_counter.value() < old_timer_counter) {
gb.cpu.interrupt_flag.set_bit_to(2, true);
timer_counter.set(timer_modulo.value());
}
}
}
inline void MMU::serial_tick(uint cycles) {
if(!serial_clocks) return;
if(serial_clocks > cycles) {
serial_clocks -= cycles;
return;
}
/* transfer complete: no link partner, the line reads all 1s */
serial_clocks = 0;
serial_data = 0xFF;
serial_control &= 0x7F; /* clear the busy bit */
gb.cpu.interrupt_flag.set_bit_to(3, true);
}
inline auto CPU::get_byte_from_pc() -> u8 {
u16 a = pc.value();
pc.increment();
/* PC sits in cartridge ROM for virtually every instruction: read the
* mapped page directly and skip the full MMU dispatch */
if(a < 0x8000) return gb.cartridge.read(a);
return gb.mmu.read(Address(a));
}
inline auto CPU::get_signed_byte_from_pc() -> s8 {
return static_cast<s8>(get_byte_from_pc());
}
inline auto CPU::get_word_from_pc() -> u16 {
u8 low_byte = get_byte_from_pc();
u8 high_byte = get_byte_from_pc();
return static_cast<u16>((high_byte << 8) | low_byte);
}
inline void Gameboy::tick() {
pending_cycles += cpu.tick().cycles;
if(pending_cycles >= PERIPH_BATCH) sync_peripherals();
}
@@ -0,0 +1,73 @@
/* Cold code: size-optimized on the embedded target. Every KB of binary
* is a KB of heap the ROM page cache loses (the FAP loads into RAM), and
* nothing in this file runs per-instruction. */
#if defined(__arm__) && defined(__GNUC__)
#pragma GCC optimize("Os")
#endif
#include "input.h"
#include "bitwise.h"
void Input::button_pressed(GbButton button) {
set_button(button, true);
}
void Input::button_released(GbButton button) {
set_button(button, false);
}
void Input::set_button(GbButton button, bool set) {
if (button == GbButton::Up) { up = set; }
if (button == GbButton::Down) { down = set; }
if (button == GbButton::Left) { left = set; }
if (button == GbButton::Right) { right = set; }
if (button == GbButton::A) { a = set; }
if (button == GbButton::B) { b = set; }
if (button == GbButton::Select) { select = set; }
if (button == GbButton::Start) { start = set; }
}
void Input::write(u8 set) {
using bitwise::check_bit;
direction_switch = !check_bit(set, 4);
button_switch = !check_bit(set, 5);
}
auto Input::get_input() const -> u8 {
using bitwise::set_bit_to;
/* with both matrix lines selected the nibbles combine (AND: pressed
* pulls low); the old code let the button nibble overwrite the
* direction nibble */
u8 dir = 0b1111, btn = 0b1111;
if (direction_switch) {
/* opposite directions are mechanically impossible on the real
* d-pad rocker: if a frontend ever feeds both, show neither
* (games misbehave badly on impossible pad states) */
bool r = right, l = left, u = up, d = down;
if(r && l) r = l = false;
if(u && d) u = d = false;
dir = set_bit_to(dir, 0, !r);
dir = set_bit_to(dir, 1, !l);
dir = set_bit_to(dir, 2, !u);
dir = set_bit_to(dir, 3, !d);
}
if (button_switch) {
btn = set_bit_to(btn, 0, !a);
btn = set_bit_to(btn, 1, !b);
btn = set_bit_to(btn, 2, !select);
btn = set_bit_to(btn, 3, !start);
}
u8 buttons = static_cast<u8>(dir & btn);
buttons = set_bit_to(buttons, 4, !direction_switch);
buttons = set_bit_to(buttons, 5, !button_switch);
/* bits 6-7 are unwired and read as 1 on hardware */
return static_cast<u8>(buttons | 0xC0);
}
@@ -21,6 +21,14 @@ public:
auto get_input() const -> u8;
/* is this button's matrix line currently selected via P1? (the joypad
* interrupt only triggers for selected lines on hardware) */
auto line_selected(GbButton b) const -> bool {
bool is_dir = (b == GbButton::Up || b == GbButton::Down ||
b == GbButton::Left || b == GbButton::Right);
return is_dir ? direction_switch : button_switch;
}
private:
void set_button(GbButton button, bool set);
@@ -94,6 +94,9 @@ void MMU::write(const Address& address, u8 byte) {
}
auto MMU::read_io(const Address& address) const -> u8 {
/* catch-up: make LY/STAT/DIV/TIMA/IF/NRxx exact before the game reads */
gb.sync_peripherals();
u16 a = address.value();
/* Sound registers + wave RAM */
@@ -107,7 +110,10 @@ auto MMU::read_io(const Address& address) const -> u8 {
return serial_data;
case 0xFF02:
return 0xFF;
/* bit 7 reads 1 while a transfer is in flight; only bits 7 and 0
* are wired on DMG. The old hardwired 0xFF made SC look "busy
* forever" to games polling for completion. */
return static_cast<u8>(serial_control | 0x7E);
case 0xFF04:
return gb.timer.get_divider();
@@ -122,13 +128,14 @@ auto MMU::read_io(const Address& address) const -> u8 {
return gb.timer.get_timer_control();
case 0xFF0F:
return gb.cpu.interrupt_flag.value();
/* bits 5-7 are unwired and read as 1 */
return static_cast<u8>(gb.cpu.interrupt_flag.value() | 0xE0);
case 0xFF40:
return gb.video.control_byte;
case 0xFF41:
return gb.video.lcd_status.value();
return static_cast<u8>(gb.video.lcd_status.value() | 0x80); /* bit 7 unwired */
case 0xFF42:
return gb.video.scroll_y.value();
@@ -137,7 +144,7 @@ auto MMU::read_io(const Address& address) const -> u8 {
return gb.video.scroll_x.value();
case 0xFF44:
return gb.video.line.value();
return gb.video.ly_read(); /* line-153 quirk: reads 0 */
case 0xFF45:
return gb.video.ly_compare.value();
@@ -167,6 +174,9 @@ auto MMU::read_io(const Address& address) const -> u8 {
}
void MMU::write_io(const Address& address, u8 byte) {
/* catch-up: apply pending time before the write changes IO state */
gb.sync_peripherals();
u16 a = address.value();
/* Sound registers + wave RAM */
@@ -185,10 +195,18 @@ void MMU::write_io(const Address& address, u8 byte) {
return;
case 0xFF02:
/* Serial control: transfer start with internal clock -> deliver
* the byte immediately (enough for link-less games and for the
* Blargg test ROMs which print through the serial port) */
if((byte & 0x81) == 0x81 && gb_serial_hook) gb_serial_hook(serial_data);
/* Serial control. A master transfer (internal clock) with no
* cable attached still completes on real hardware: 0xFF shifts
* in after 8 bit-clocks (4096 T-cycles = 1024 M-cycles at
* 8192 Hz) and the serial interrupt fires. Tetris's 2P handshake
* (Start at the title with 2 PLAYER selected) busy-waits on that
* interrupt: without it the game wedged forever. A slave start
* (0x80) never completes without a partner clock - correct. */
serial_control = byte & 0x81;
if((byte & 0x81) == 0x81) {
if(gb_serial_hook) gb_serial_hook(serial_data);
serial_clocks = 1024;
}
return;
case 0xFF04:
@@ -216,7 +234,9 @@ void MMU::write_io(const Address& address, u8 byte) {
return;
case 0xFF41:
gb.video.lcd_status.set(byte);
/* bits 0-2 (mode + coincidence) are read-only */
gb.video.lcd_status.set(
static_cast<u8>((byte & 0x78) | (gb.video.lcd_status.value() & 0x07)));
return;
case 0xFF42:
@@ -228,7 +248,7 @@ void MMU::write_io(const Address& address, u8 byte) {
return;
case 0xFF44:
gb.video.line.set(0x0);
/* LY is read-only on DMG (upstream reset it to 0) */
return;
case 0xFF45:
@@ -0,0 +1,59 @@
#pragma once
#include "address.h"
#include "definitions.h"
class Gameboy;
/* Serial output hook, used by the host test harness to capture Blargg test
* ROM output. Null on the Flipper build. */
extern "C" {
extern void (*gb_serial_hook)(u8 byte);
}
class MMU {
public:
MMU(Gameboy& inGb);
auto read(const Address& address) const -> u8;
void write(const Address& address, u8 byte);
/* Serial link: a master transfer (SC=0x81) with no cable completes
* after 8 bit-clocks reading 0xFF and raises the serial interrupt.
* Without this, games that probe the link on demand (Tetris 2P
* handshake at the title screen!) busy-wait forever. Ticked from
* Gameboy::sync_peripherals; defined inline in gameboy.h. */
void serial_tick(uint cycles);
/* save-state accessors */
auto wram_ptr() -> u8* { return work_ram; }
auto oam_ptr() -> u8* { return oam_ram; }
auto hram_ptr() -> u8* { return high_ram; }
void export_serial(u8* sb, u8* sc, u32* clk) const {
*sb = serial_data;
*sc = serial_control;
*clk = serial_clocks;
}
void import_serial(u8 sb, u8 sc, u32 clk) {
serial_data = sb;
serial_control = sc;
serial_clocks = clk;
}
private:
auto read_io(const Address& address) const -> u8;
void write_io(const Address& address, u8 byte);
void dma_transfer(u8 byte);
Gameboy& gb;
u8 work_ram[0x2000] = {}; /* DMG: 8 KB (was 32 KB upstream) */
u8 oam_ram[0xA0] = {};
u8 high_ram[0x80] = {};
u8 serial_data = 0;
u8 serial_control = 0;
uint serial_clocks = 0; /* M-cycles until the running transfer completes */
friend class Video;
};
@@ -3,6 +3,13 @@
#include <array>
/* NOTE: undefined opcodes (0xD3, 0xDB, 0xDD, 0xE3, 0xE4, 0xEB, 0xEC, 0xED,
* 0xF4, 0xFC, 0xFD) and the 0xCB prefix placeholder cost 1 M-cycle here,
* NOT 0. With 0-cycle entries, executing garbage (bad jump, corrupted
* read) advanced the PC without advancing the PPU/timer clocks, so
* run_to_vblank() could spin without ever finishing a frame: on the
* Flipper that froze the whole device until a hard reboot. */
const std::array<u8, 256> opcode_cycles = {
1, 3, 2, 2, 1, 1, 2, 1, 5, 2, 2, 2, 1, 1, 2, 1,
1, 3, 2, 2, 1, 1, 2, 1, 3, 2, 2, 2, 1, 1, 2, 1,
@@ -16,10 +23,10 @@ const std::array<u8, 256> opcode_cycles = {
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
2, 3, 3, 4, 3, 4, 2, 4, 2, 4, 3, 0, 3, 6, 2, 4,
2, 3, 3, 0, 3, 4, 2, 4, 2, 4, 3, 0, 3, 0, 2, 4,
3, 3, 2, 0, 0, 4, 2, 4, 4, 1, 4, 0, 0, 0, 2, 4,
3, 3, 2, 1, 0, 4, 2, 4, 3, 2, 4, 1, 0, 0, 2, 4
2, 3, 3, 4, 3, 4, 2, 4, 2, 4, 3, 1, 3, 6, 2, 4,
2, 3, 3, 1, 3, 4, 2, 4, 2, 4, 3, 1, 3, 1, 2, 4,
3, 3, 2, 1, 1, 4, 2, 4, 4, 1, 4, 1, 1, 1, 2, 4,
3, 3, 2, 1, 1, 4, 2, 4, 3, 2, 4, 1, 1, 1, 2, 4
};
const std::array<u8, 256> opcode_cycles_branched = {
@@ -35,10 +42,10 @@ const std::array<u8, 256> opcode_cycles_branched = {
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1,
5, 3, 4, 4, 6, 4, 2, 4, 5, 4, 4, 0, 6, 6, 2, 4,
5, 3, 4, 0, 6, 4, 2, 4, 5, 4, 4, 0, 6, 0, 2, 4,
3, 3, 2, 0, 0, 4, 2, 4, 4, 1, 4, 0, 0, 0, 2, 4,
3, 3, 2, 1, 0, 4, 2, 4, 3, 2, 4, 1, 0, 0, 2, 4
5, 3, 4, 4, 6, 4, 2, 4, 5, 4, 4, 1, 6, 6, 2, 4,
5, 3, 4, 1, 6, 4, 2, 4, 5, 4, 4, 1, 6, 1, 2, 4,
3, 3, 2, 1, 1, 4, 2, 4, 4, 1, 4, 1, 1, 1, 2, 4,
3, 3, 2, 1, 1, 4, 2, 4, 3, 2, 4, 1, 1, 1, 2, 4
};
const std::array<u8, 256> opcode_cycles_cb = {
@@ -1,4 +1,5 @@
#include "cpu.h"
#include "gameboy.h" /* inline hot-path definitions (operand fetch) */
/* clang-format off */
/**
@@ -262,12 +262,13 @@ void CPU::opcode_dec(Address&& addr) {
/* DI */
void CPU::opcode_di() {
interrupts_enabled = false;
ime_pending = false; /* DI in the EI delay slot cancels the enable */
}
/* EI */
/* EI: IME is enabled after the NEXT instruction (see CPU::tick) */
void CPU::opcode_ei() {
interrupts_enabled = true;
ime_pending = true;
}
@@ -341,9 +342,25 @@ void CPU::opcode_jr(Condition condition) {
}
/* HALT */
/* HALT. DMG halt bug: entering HALT with IME=0 while an enabled
* interrupt is already pending does NOT halt -- instead the byte after
* HALT is executed twice (PC fails to advance once).
*
* CRITICAL: a pending EI (the `EI; HALT` idiom -- HALT executes inside
* EI's delay slot with IME still momentarily 0) is NOT the halt-bug
* case: IME rises right after HALT and the interrupt dispatches
* normally. Without the ime_pending check, the stale halt_bug flag
* survived into the dispatch and corrupted the ISR's first instruction
* fetch (PC reset made the operand re-read the opcode byte = wild
* jump): Mario wedged "walking right forever, music playing" after a
* few A presses. */
void CPU::opcode_halt() {
halted = true;
if(!interrupts_enabled && !ime_pending &&
(interrupt_flag.value() & interrupt_enabled.value() & 0x1F)) {
halt_bug = true;
} else {
halted = true;
}
}
@@ -543,10 +560,10 @@ void CPU::opcode_ret(Condition condition) {
}
/* RETI */
/* RETI: unlike EI, re-enables IME immediately (no delay slot) */
void CPU::opcode_reti() {
opcode_ret();
opcode_ei();
interrupts_enabled = true;
}
@@ -810,9 +827,17 @@ void CPU::opcode_srl(Address&& addr) {
}
/* STOP */
/* STOP: on real DMG the CPU freezes until a joypad LINE CHANGE -- this is
* independent of IE/IF/IME (it does not go through interrupt dispatch).
* Modelling it as plain HALT was wrong twice over: with IE bit4 clear a
* press never satisfied IF&IE so the game froze forever, and with IE
* bit0 set our free-running PPU vblank woke it instantly. The `stopped`
* flag is cleared only by CPU::notify_joypad() (wired to button presses).
* STOP is also a 2-byte instruction: consume the padding byte. */
void CPU::opcode_stop() {
/* halted = true; */
get_byte_from_pc(); /* skip the 0x00 pad */
halted = true;
stopped = true;
}
@@ -0,0 +1,58 @@
#pragma once
#include "definitions.h"
#include "register.h"
class Gameboy;
class Timer {
public:
Timer(Gameboy& inGb)
: gb(inGb) {}
/* Called once per emulated instruction: defined inline at the bottom
* of gameboy.h (needs the complete Gameboy type). */
void tick(uint cycles);
auto get_divider() const -> u8 { return divider.value(); }
auto get_timer() const -> u8 { return timer_counter.value(); }
auto get_timer_modulo() const -> u8 { return timer_modulo.value(); }
/* Only the bottom three bits of this register are usable */
auto get_timer_control() const -> u8 { return timer_control.value() & 0x7; }
void reset_divider() {
/* DIV writes reset the shared prescaler: TIMA phase resets too */
divider.set(0x0);
div_clocks = 0;
clocks = 0;
}
/* save-state accessors */
void set_counters(uint c, uint d) { clocks = c; div_clocks = d; }
auto get_clocks() const -> uint { return clocks; }
auto get_div_clocks() const -> uint { return div_clocks; }
void set_divider_raw(u8 v) { divider.set(v); }
void set_timer(u8 value) { timer_counter.set(value); }
void set_timer_modulo(u8 value) { timer_modulo.set(value); }
void set_timer_control(u8 value) { timer_control.set(value); }
private:
auto clocks_needed_to_increment() const -> uint {
switch(get_timer_control() & 0x3) {
case 0: return CLOCK_RATE / 4096;
case 1: return CLOCK_RATE / 262144;
case 2: return CLOCK_RATE / 65536;
default: return CLOCK_RATE / 16384;
}
}
uint clocks = 0;
uint div_clocks = 0;
Gameboy& gb;
ByteRegister divider;
ByteRegister timer_counter;
ByteRegister timer_modulo;
ByteRegister timer_control;
};
@@ -0,0 +1,412 @@
#include "video.h"
#include "gameboy.h"
#include "cpu.h"
#include "bitwise.h"
using bitwise::check_bit;
/* ---------------------------------------------------------- tile decode */
/* s_tile_lut[b] spreads the 8 bits of a tile-data byte into 8 2-bit pixel
* fields, leftmost pixel (bit 7) in the LOWEST field -- the same LSB-first
* packing the framebuffer uses, so whole bytes can be emitted directly.
* X-flipped sprites bit-reverse the tile bytes first (cheaper than a
* second 512-byte LUT: this binary is RAM on the Flipper). */
static u16 s_tile_lut[256];
static bool s_luts_ready = false;
static inline u8 bitrev8(u8 b) {
b = (u8)((b >> 4) | (b << 4));
b = (u8)(((b & 0xCC) >> 2) | ((b & 0x33) << 2));
b = (u8)(((b & 0xAA) >> 1) | ((b & 0x55) << 1));
return b;
}
Video::Video(Gameboy& inGb)
: gb(inGb) {
if(!s_luts_ready) {
for(uint b = 0; b < 256; b++) {
u16 fwd = 0;
for(uint k = 0; k < 8; k++) {
fwd |= (u16)(((b >> (7 - k)) & 1) << (2 * k));
}
s_tile_lut[b] = fwd;
}
s_luts_ready = true;
}
}
/* Palette LUT: maps a packed 2bpp byte (4 pixels) through BGP in one
* lookup. Rebuilt only when the game writes a new BGP value. */
__attribute__((optimize("Os"))) auto Video::bg_pal_lut() -> const u8* {
u8 bgp = bg_palette.value();
if(!bgp_lut_valid || bgp != bgp_lut_cached_for) {
u8 shade[4] = {
(u8)(bgp & 0x3),
(u8)((bgp >> 2) & 0x3),
(u8)((bgp >> 4) & 0x3),
(u8)((bgp >> 6) & 0x3),
};
for(uint v = 0; v < 256; v++) {
bgp_lut[v] = (u8)(
shade[v & 3] | (shade[(v >> 2) & 3] << 2) | (shade[(v >> 4) & 3] << 4) |
(shade[(v >> 6) & 3] << 6));
}
bgp_lut_cached_for = bgp;
bgp_lut_valid = true;
}
return bgp_lut;
}
/* Video::tick is called once per emulated instruction; it lives as an
* inline definition at the bottom of gameboy.h. Mode-transition work
* (rendering, interrupts) stays out-of-line in this file. */
__attribute__((optimize("Os"))) void Video::mode_transition_vram_end() {
current_mode = VideoMode::HBLANK;
if(check_bit(lcd_status.value(), 3)) {
gb.cpu.interrupt_flag.set_bit_to(1, true); /* hblank STAT */
}
lcd_status.set_bit_to(1, false);
lcd_status.set_bit_to(0, false);
}
/* LY=LYC is evaluated when a line BEGINS (hardware: during mode 2 / OAM
* scan). Firing it at the end of the line -- as upstream did -- made
* games' raster effects (dmg-acid2 uses LYC IRQs to change WX/LCDC on
* exact rows) apply one line late. */
__attribute__((optimize("Os"))) void Video::check_lyc() {
bool eq = ly_compare.value() == line.value();
if(eq && check_bit(lcd_status.value(), 6)) {
gb.cpu.interrupt_flag.set_bit_to(1, true);
}
lcd_status.set_bit_to(2, eq);
}
__attribute__((optimize("Os"))) void Video::mode_transition_hblank_end() {
if(!skip_render) write_scanline(line.value());
line.increment();
check_lyc(); /* new line starts here */
/* Line 145 (index 144) is the first line of VBLANK */
if(line == 144) {
current_mode = VideoMode::VBLANK;
lcd_status.set_bit_to(1, false);
lcd_status.set_bit_to(0, true);
gb.cpu.interrupt_flag.set_bit_to(0, true);
} else {
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, false);
current_mode = VideoMode::ACCESS_OAM;
}
}
__attribute__((optimize("Os"))) void Video::mode_transition_vblank_line() {
line.increment();
/* LY=LYC also fires for vblank lines 145-153 (games wait on it).
* Line 154 is transient (LY wraps to 0) and never matches. */
if(line.value() < 154) check_lyc();
/* Line 155 (index 154) is the last line */
if(line == 154) {
if(!skip_render) {
draw();
buffer.reset();
} else {
draw(); /* still notify the frontend for pacing */
}
line.reset();
window_line = 0; /* the window's internal line counter is per-frame */
check_lyc(); /* line 0 begins */
current_mode = VideoMode::ACCESS_OAM;
lcd_status.set_bit_to(1, true);
lcd_status.set_bit_to(0, false);
}
}
auto Video::display_enabled() const -> bool {
return check_bit(control_byte, 7);
}
auto Video::window_tile_map() const -> bool {
return check_bit(control_byte, 6);
}
auto Video::window_enabled() const -> bool {
return check_bit(control_byte, 5);
}
auto Video::bg_window_tile_data() const -> bool {
return check_bit(control_byte, 4);
}
auto Video::bg_tile_map_display() const -> bool {
return check_bit(control_byte, 3);
}
auto Video::sprite_size() const -> bool {
return check_bit(control_byte, 2);
}
auto Video::sprites_enabled() const -> bool {
return check_bit(control_byte, 1);
}
auto Video::bg_enabled() const -> bool {
return check_bit(control_byte, 0);
}
void Video::write_scanline(u8 current_line) {
if(!display_enabled()) {
return;
}
/* Lines the frontend never displays are not worth rendering */
if(row_mask && current_line < GAMEBOY_HEIGHT && !row_mask[current_line]) {
return;
}
if(bg_enabled()) {
draw_bg_line(current_line);
}
if(window_enabled()) {
draw_window_line(current_line);
}
/* Sprites are composited per scanline like hardware: mid-frame raster
* changes to OBP/LCDC (object size/enable) apply to the correct rows
* (dmg-acid2 exercises this), and only displayed lines pay the cost. */
if(sprites_enabled()) {
draw_sprites_line(current_line);
}
}
/* Byte-oriented tile-strip renderer: emits `count` pixels of one tile-map
* row into the packed framebuffer row `dst_row`, starting at screen pixel
* `dst_x`, sourcing map pixels from `src_px` (wraps at 256). Pixels flow
* through a small bit-queue so the bulk of the line is written as whole
* palette-mapped bytes (4 px per store) instead of per-pixel RMW packing:
* ~7x fewer operations per line than the old per-pixel path. */
void Video::render_strip(
u8* dst_row,
uint dst_x,
uint count,
uint map_row_base, /* VRAM offset of the tile map row (32 entries) */
uint src_px,
uint tile_line_off, /* 2 * (row within the tile) */
bool use_tile_set_zero,
const u8* pal_lut) {
uint tile_x = (src_px >> 3) & 31;
uint fine = src_px & 7;
uint tset_base = use_tile_set_zero ? 0x0000u : 0x0800u;
/* bit-queue of pending 2bpp pixels, LSB = next pixel to emit */
u32 acc = 0;
uint nbits = 0;
#define FETCH_TILE_ROW() \
do { \
u8 tid = video_ram[map_row_base + tile_x]; \
tile_x = (tile_x + 1) & 31; \
uint toff = use_tile_set_zero ? \
(uint)tid * TILE_BYTES : \
(uint)((s8)tid + 128) * TILE_BYTES; \
uint la = tset_base + toff + tile_line_off; \
u16 v = (u16)(s_tile_lut[video_ram[la]] | (s_tile_lut[video_ram[la + 1]] << 1)); \
acc |= (u32)v << nbits; \
nbits += 16; \
} while(0)
/* prime the queue, discarding the fine-scroll pixels */
FETCH_TILE_ROW();
acc >>= fine * 2;
nbits -= fine * 2;
/* head: single pixels until the destination is byte-aligned */
while(count && (dst_x & 3)) {
if(nbits < 2) FETCH_TILE_ROW();
uint sh = (dst_x & 3) * 2;
u8* p = dst_row + (dst_x >> 2);
*p = (u8)((*p & ~(3u << sh)) | ((pal_lut[acc & 3] & 3u) << sh));
acc >>= 2;
nbits -= 2;
dst_x++;
count--;
}
/* body: whole bytes, palette applied 4 pixels at a time */
u8* out = dst_row + (dst_x >> 2);
while(count >= 4) {
if(nbits < 8) FETCH_TILE_ROW();
*out++ = pal_lut[acc & 0xFF];
acc >>= 8;
nbits -= 8;
count -= 4;
dst_x += 4;
}
/* tail */
while(count) {
if(nbits < 2) FETCH_TILE_ROW();
uint sh = (dst_x & 3) * 2;
u8* p = dst_row + (dst_x >> 2);
*p = (u8)((*p & ~(3u << sh)) | ((pal_lut[acc & 3] & 3u) << sh));
acc >>= 2;
nbits -= 2;
dst_x++;
count--;
}
#undef FETCH_TILE_ROW
}
void Video::draw_bg_line(uint current_line) {
/* Note: tileset two uses signed numbering to share half the tiles with
* tileset one */
bool use_tile_set_zero = bg_window_tile_data();
bool use_tile_map_zero = !bg_tile_map_display();
u8* dst = buffer.row_ptr(current_line);
if(!dst) return;
uint scrolled_y = (current_line + scroll_y.value()) % BG_MAP_SIZE;
uint map_row_base = (use_tile_map_zero ? (TILE_MAP_ZERO_ADDRESS - 0x8000) :
(TILE_MAP_ONE_ADDRESS - 0x8000)) +
(scrolled_y / TILE_HEIGHT_PX) * TILES_PER_LINE;
render_strip(
dst,
0,
GAMEBOY_WIDTH,
map_row_base,
scroll_x.value(),
(scrolled_y % TILE_HEIGHT_PX) * 2,
use_tile_set_zero,
bg_pal_lut());
}
void Video::draw_window_line(uint current_line) {
bool use_tile_set_zero = bg_window_tile_data();
bool use_tile_map_zero = !window_tile_map();
/* the window only starts once LY reaches WY */
if(current_line < window_y.value()) return;
/* WX off-screen hides the window for this line WITHOUT advancing its
* internal line counter: when it reappears, drawing resumes from the
* next window row, not from LY-WY (dmg-acid2 "chin" behaviour) */
uint wx = window_x.value();
if(wx > 166) return;
uint start_x = wx >= 7 ? wx - 7 : 0;
if(start_x >= GAMEBOY_WIDTH) return;
uint src_row = window_line; /* internal per-frame counter */
if(src_row >= 256) return;
window_line++; /* the counter advances on every line the window shows */
u8* dst = buffer.row_ptr(current_line);
if(!dst) return; /* row not displayed: counter still advanced above */
uint map_row_base = (use_tile_map_zero ? (TILE_MAP_ZERO_ADDRESS - 0x8000) :
(TILE_MAP_ONE_ADDRESS - 0x8000)) +
((src_row / TILE_HEIGHT_PX) & 31) * TILES_PER_LINE;
render_strip(
dst,
start_x,
GAMEBOY_WIDTH - start_x,
map_row_base,
wx >= 7 ? 0 : 7 - wx,
(src_row % TILE_HEIGHT_PX) * 2,
use_tile_set_zero,
bg_pal_lut());
}
void Video::draw_sprites_line(uint current_line) {
uint sprite_height = sprite_size() ? 16 : 8;
/* hardware OAM scan: the first 10 sprites (in OAM order) covering
* this line are selected */
u8 sel[10];
uint nsel = 0;
for(uint n = 0; n < 40 && nsel < 10; n++) {
int sy = (int)gb.mmu.oam_ram[n * 4] - 16;
if((int)current_line >= sy && (int)current_line < sy + (int)sprite_height) {
sel[nsel++] = (u8)n;
}
}
if(nsel == 0) return;
u8* row = buffer.row_ptr(current_line);
if(!row) return;
/* draw in reverse priority order so the winner is painted last:
* lower X wins overlaps, ties broken by lower OAM index */
for(uint i = 1; i < nsel; i++) { /* insertion sort by X descending */
u8 v = sel[i];
u8 vx = gb.mmu.oam_ram[v * 4 + 1];
uint j = i;
while(j > 0 && gb.mmu.oam_ram[sel[j - 1] * 4 + 1] < vx) {
sel[j] = sel[j - 1];
j--;
}
sel[j] = v;
}
for(uint i = 0; i < nsel; i++) {
uint n = sel[i];
u16 oam = (u16)(n * 4);
u8 sprite_y = gb.mmu.oam_ram[oam];
u8 sprite_x = gb.mmu.oam_ram[oam + 1];
if(sprite_x == 0 || sprite_x >= 168) continue; /* off-screen */
u8 pattern_n = gb.mmu.oam_ram[oam + 2];
u8 attrs = gb.mmu.oam_ram[oam + 3];
bool use_palette_1 = check_bit(attrs, 4);
bool flip_x = check_bit(attrs, 5);
bool flip_y = check_bit(attrs, 6);
bool obj_behind_bg = check_bit(attrs, 7);
/* in 8x16 mode the hardware ignores bit 0 of the tile id */
if(sprite_height == 16) pattern_n &= 0xFE;
u8 obp = use_palette_1 ? sprite_palette_1.value() : sprite_palette_0.value();
u8 shade[4] = {
(u8)(obp & 0x3),
(u8)((obp >> 2) & 0x3),
(u8)((obp >> 4) & 0x3),
(u8)((obp >> 6) & 0x3),
};
uint y = current_line - (uint)(sprite_y - 16);
uint src_y = flip_y ? sprite_height - 1 - y : y;
uint line_addr = pattern_n * TILE_BYTES + src_y * 2;
u8 b1 = video_ram[line_addr];
u8 b2 = video_ram[line_addr + 1];
if(flip_x) {
b1 = bitrev8(b1);
b2 = bitrev8(b2);
}
u16 v = (u16)(s_tile_lut[b1] | (s_tile_lut[b2] << 1));
if(v == 0) continue; /* fully transparent row */
int start_x = (int)sprite_x - 8;
for(uint x = 0; x < TILE_WIDTH_PX; x++, v >>= 2) {
uint color = v & 3;
if(color == 0) continue; /* transparent */
int screen_x = start_x + (int)x;
if(screen_x < 0 || screen_x >= (int)GAMEBOY_WIDTH) continue;
uint sh = ((uint)screen_x & 3) * 2;
u8* p = row + ((uint)screen_x >> 2);
/* same as upstream: priority compares the final shade */
if(obj_behind_bg && ((*p >> sh) & 3) != SHADE_WHITE) continue;
*p = (u8)((*p & ~(3u << sh)) | (shade[color] << sh));
}
}
}
void Video::draw() {
if(vblank_callback) vblank_callback(vblank_ctx);
}
@@ -28,6 +28,7 @@ public:
}
auto vram_read(u16 offset) const -> u8 { return video_ram[offset]; }
auto vram_ptr() -> u8* { return video_ram; } /* save states */
void vram_write(u16 offset, u8 value) { video_ram[offset] = value; }
/* When true, scanline/sprite rendering work is skipped (frame skip);
@@ -70,12 +71,24 @@ public:
private:
void write_scanline(u8 current_line);
void write_sprites();
void draw_sprites_line(uint current_line);
void draw();
void mode_transition_vram_end();
void check_lyc();
void mode_transition_hblank_end();
void mode_transition_vblank_line();
void draw_bg_line(uint current_line);
void draw_window_line(uint current_line);
void draw_sprite(uint sprite_n);
static auto get_pixel_from_line(u8 byte1, u8 byte2, u8 pixel_index) -> u8;
void render_strip(
u8* dst_row,
uint dst_x,
uint count,
uint map_row_base,
uint src_px,
uint tile_line_off,
bool use_tile_set_zero,
const u8* pal_lut);
auto bg_pal_lut() -> const u8*;
static auto is_on_screen(int x, int y) -> bool {
return x >= 0 && y >= 0 && x < static_cast<int>(GAMEBOY_WIDTH) &&
@@ -91,13 +104,35 @@ private:
auto sprites_enabled() const -> bool;
auto bg_enabled() const -> bool;
static auto load_palette(const ByteRegister& palette_register) -> Palette;
static auto get_shade_from_palette(u8 color, const Palette& palette) -> Shade;
Gameboy& gb;
FrameBuffer buffer;
/* packed-byte -> palette-mapped-byte LUT for the current BGP value */
u8 bgp_lut[256];
u8 bgp_lut_cached_for = 0;
bool bgp_lut_valid = false;
/* window internal line counter (per frame, advances only on lines
* where the window is actually shown) */
uint window_line = 0;
public:
/* LY as the CPU sees it (line 153 reads 0 for most of its duration) */
auto ly_read() const -> u8 {
u8 v = line.value();
return v == 153 ? 0 : v;
}
/* save-state accessors */
auto get_mode() const -> u8 { return (u8)current_mode; }
auto get_cycle_counter() const -> uint { return cycle_counter; }
void set_state(u8 ly, u8 mode, uint counter) {
line.set(ly);
current_mode = (VideoMode)(mode & 3);
cycle_counter = counter;
}
private:
u8 video_ram[0x2000] = {}; /* DMG: 8 KB (was 16 KB upstream) */
VideoMode current_mode = VideoMode::ACCESS_OAM;
File diff suppressed because it is too large Load Diff
+25 -12
View File
@@ -13,12 +13,15 @@ def ProtoPirateDefineEnabled(name, app_manifest_path=app_manifest_path):
_ENABLE_TIMING_TUNER = ProtoPirateDefineEnabled("ENABLE_TIMING_TUNER_SCENE")
_ENABLE_EMULATE = ProtoPirateDefineEnabled("ENABLE_EMULATE_FEATURE")
_MAIN_APP_SOURCES = [
"*.c*",
"!protocols/plugins",
"!scenes/plugins",
"!protocols",
"!protopirate_emulate_plugin.c",
"!protopirate_psa_bf_plugin.c",
"protocols/protocol_items.c",
"protocols/protocols_common.c",
"!raw_file_reader.c",
@@ -30,6 +33,11 @@ if not _ENABLE_TIMING_TUNER:
"!protocol_timings.c",
]
if not _ENABLE_EMULATE:
_MAIN_APP_SOURCES += [
"!protopirate_scene_emulate.c",
]
App(
appid="proto_pirate",
name="ProtoPirate",
@@ -39,7 +47,7 @@ App(
requires=["gui"],
stack_size=8 * 1024,
fap_description="Decode car key fob signals from Sub-GHz",
fap_version="3.0",
fap_version="3.2",
fap_icon="images/protopirate_10px.png",
fap_category="Sub-GHz",
fap_icon_assets="images",
@@ -165,7 +173,11 @@ def ProtoPirateTxProtocolPlugin(
App=App,
FlipperAppType=FlipperAppType,
tx_plugin_sources=_TX_PLUGIN_SOURCES,
enable_emulate=_ENABLE_EMULATE,
):
if not enable_emulate:
return
App(
appid=f"protopirate_tx_{key}_plugin",
apptype=FlipperAppType.PLUGIN,
@@ -370,17 +382,18 @@ if _ENABLE_TIMING_TUNER:
fal_embedded=True,
)
App(
appid="protopirate_emulate_plugin",
apptype=FlipperAppType.PLUGIN,
entry_point="protopirate_emulate_plugin_ep",
requires=["proto_pirate"],
sources=[
"scenes/plugins/protopirate_emulate_plugin.c",
"protocols/protocol_items.c",
],
fal_embedded=True,
)
if _ENABLE_EMULATE:
App(
appid="protopirate_emulate_plugin",
apptype=FlipperAppType.PLUGIN,
entry_point="protopirate_emulate_plugin_ep",
requires=["proto_pirate"],
sources=[
"scenes/plugins/protopirate_emulate_plugin.c",
"protocols/protocol_items.c",
],
fal_embedded=True,
)
App(
appid="protopirate_psa_bf_plugin",
@@ -7,7 +7,9 @@
#include <string.h>
#define TAG "ProtoPirateProtocolPlugin"
#ifdef ENABLE_EMULATE_FEATURE
#define PROTOPIRATE_TX_PLUGIN_PATH_MAX 160U
#endif
static const char* protopirate_get_registry_plugin_path(ProtoPirateProtocolRegistryRoute route) {
switch(route) {
@@ -25,6 +27,7 @@ static const char* protopirate_get_registry_plugin_path(ProtoPirateProtocolRegis
}
}
#ifdef ENABLE_EMULATE_FEATURE
static bool protopirate_build_tx_protocol_plugin_path(
const char* tx_key,
char* plugin_path,
@@ -40,6 +43,7 @@ static bool protopirate_build_tx_protocol_plugin_path(
tx_key);
return (written > 0) && ((size_t)written < plugin_path_size);
}
#endif
static const SubGhzProtocolRegistry protopirate_empty_protocol_registry = {
.items = NULL,
@@ -165,6 +169,7 @@ static bool protopirate_ensure_protocol_registry_plugin(
return true;
}
#ifdef ENABLE_EMULATE_FEATURE
static bool protopirate_ensure_tx_protocol_plugin(
ProtoPirateApp* app,
const char* protocol_name,
@@ -264,6 +269,7 @@ static bool protopirate_ensure_tx_protocol_plugin(
*registry = plugin->registry;
return true;
}
#endif
bool protopirate_refresh_protocol_registry(ProtoPirateApp* app, bool ensure_receiver_ready) {
furi_check(app);
@@ -378,6 +384,7 @@ bool protopirate_apply_protocol_registry_for_context(
}
if(protocol_name && protocol_name[0] != '\0') {
#ifdef ENABLE_EMULATE_FEATURE
const char* registry_name = protopirate_protocol_catalog_canonical_name(protocol_name);
if(!registry_name || !protopirate_protocol_catalog_can_tx(protocol_name)) {
FURI_LOG_E(TAG, "No TX protocol plugin for %s", protocol_name);
@@ -410,6 +417,9 @@ bool protopirate_apply_protocol_registry_for_context(
subghz_environment_set_protocol_registry(app->txrx->environment, tx_registry);
app->txrx->protocol_registry = tx_registry;
return true;
#else
return false;
#endif
}
ProtoPirateProtocolRegistryRoute route = protopirate_get_protocol_registry_route(
@@ -103,12 +103,14 @@ void protopirate_settings_load(ProtoPirateSettings* settings) {
}
settings->hopping_enabled = (hopping_temp == 1);
#ifdef ENABLE_EMULATE_FEATURE
uint32_t emulate_temp = 0;
if(!flipper_format_read_uint32(ff, "EmulateFeature", &emulate_temp, 1)) {
FURI_LOG_I(TAG, "EmulateFeature key missing, defaulting to disabled");
emulate_temp = 0;
}
settings->emulate_feature_enabled = (emulate_temp == 1);
#endif
uint32_t check_saved_temp = 0;
if(!flipper_format_read_uint32(ff, "CheckSaved", &check_saved_temp, 1)) {
@@ -185,11 +187,13 @@ void protopirate_settings_save(ProtoPirateSettings* settings) {
break;
}
#ifdef ENABLE_EMULATE_FEATURE
uint32_t emulate_temp = settings->emulate_feature_enabled ? 1 : 0;
if(!flipper_format_write_uint32(ff, "EmulateFeature", &emulate_temp, 1)) {
FURI_LOG_E(TAG, "Failed to write emulate feature flag");
break;
}
#endif
uint32_t check_saved_temp = settings->check_saved ? 1 : 0;
if(!flipper_format_write_uint32(ff, "CheckSaved", &check_saved_temp, 1)) {
@@ -513,6 +513,20 @@ static bool protopirate_storage_copy_key_2(
protopirate_storage_copy_u32_optional(save_file, flipper_format, "Key_2");
}
static bool protopirate_storage_copy_key2(
FlipperFormat* save_file,
FlipperFormat* flipper_format) {
bool copied = false;
if(!protopirate_storage_copy_hex_fixed(save_file, flipper_format, "Key2", 8, &copied)) {
return false;
}
if(copied) {
return true;
}
return protopirate_storage_copy_hex_or_u32(save_file, flipper_format, "Key2", 4);
}
static bool protopirate_storage_write_capture_data(
FlipperFormat* save_file,
FlipperFormat* flipper_format) {
@@ -548,7 +562,7 @@ static bool protopirate_storage_write_capture_data(
protopirate_storage_base_u32_fields,
COUNT_OF(protopirate_storage_base_u32_fields)))
break;
if(!protopirate_storage_copy_hex_or_u32(save_file, flipper_format, "Key2", 4)) break;
if(!protopirate_storage_copy_key2(save_file, flipper_format)) break;
if(!protopirate_storage_copy_u32_optional(save_file, flipper_format, "KeyIdx")) break;
if(!protopirate_storage_copy_u32_optional(save_file, flipper_format, "Seed")) break;
if(!protopirate_storage_copy_hex_or_u32(save_file, flipper_format, "ValidationField", 2))
@@ -3,6 +3,8 @@
#include <furi.h>
#include <string.h>
#include "../defines.h"
#define TAG "ProtoPirateCatalog"
#define PROTOPIRATE_CC1101_REG_MDMCFG2 0x12U
@@ -17,6 +19,12 @@
#define PROTOPIRATE_COUNT_OF(array) (sizeof(array) / sizeof((array)[0]))
#ifdef ENABLE_EMULATE_FEATURE
#define PROTOPIRATE_TX_KEY(key) key
#else
#define PROTOPIRATE_TX_KEY(key) NULL
#endif
typedef enum {
ProtoPirateProtocolCatalogModulationAM = 0,
ProtoPirateProtocolCatalogModulationFM,
@@ -28,33 +36,33 @@ typedef struct {
} ProtoPirateProtocolCatalogAlias;
static const ProtoPirateProtocolCatalogEntry protopirate_protocol_catalog[] = {
{"Chrysler V0", ProtoPirateProtocolCatalogRouteAMDefault, "chrysler_v0"},
{"Fiat V0", ProtoPirateProtocolCatalogRouteAMDefault, "fiat_v0"},
{"Fiat V1", ProtoPirateProtocolCatalogRouteAMDefault, "fiat_v1"},
{"Chrysler V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("chrysler_v0")},
{"Fiat V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("fiat_v0")},
{"Fiat V1", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("fiat_v1")},
{"Fiat V2", ProtoPirateProtocolCatalogRouteAMDefault, NULL},
{"Ford V0", ProtoPirateProtocolCatalogRouteAMDefault, "ford_v0"},
{"Ford V1", ProtoPirateProtocolCatalogRouteFMF4, "ford_v1"},
{"Ford V2", ProtoPirateProtocolCatalogRouteFMF4, "ford_v2"},
{"Ford V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("ford_v0")},
{"Ford V1", ProtoPirateProtocolCatalogRouteFMF4, PROTOPIRATE_TX_KEY("ford_v1")},
{"Ford V2", ProtoPirateProtocolCatalogRouteFMF4, PROTOPIRATE_TX_KEY("ford_v2")},
{"Ford V3", ProtoPirateProtocolCatalogRouteFMF4, NULL},
{"Honda Static", ProtoPirateProtocolCatalogRouteFMHonda1, "honda_static"},
{"Honda V1", ProtoPirateProtocolCatalogRouteAMDefault, "honda_v1"},
{"Kia V0", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v0"},
{"Kia V1", ProtoPirateProtocolCatalogRouteAMDefault, "kia_v1"},
{"Kia V2", ProtoPirateProtocolCatalogRouteAMDefault, "kia_v2"},
{"Kia V3/V4", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v3_v4"},
{"Kia V5", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v5"},
{"Kia V6", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v6"},
{"Kia V7", ProtoPirateProtocolCatalogRouteFMDefault, "kia_v7"},
{"Honda V2", ProtoPirateProtocolCatalogRouteFMF4, "honda_v2"},
{"Mazda V0", ProtoPirateProtocolCatalogRouteByModulation, "mazda_v0"},
{"Honda Static", ProtoPirateProtocolCatalogRouteFMHonda1, PROTOPIRATE_TX_KEY("honda_static")},
{"Honda V1", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("honda_v1")},
{"Kia V0", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v0")},
{"Kia V1", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("kia_v1")},
{"Kia V2", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("kia_v2")},
{"Kia V3/V4", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v3_v4")},
{"Kia V5", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v5")},
{"Kia V6", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v6")},
{"Kia V7", ProtoPirateProtocolCatalogRouteFMDefault, PROTOPIRATE_TX_KEY("kia_v7")},
{"Honda V2", ProtoPirateProtocolCatalogRouteFMF4, PROTOPIRATE_TX_KEY("honda_v2")},
{"Mazda V0", ProtoPirateProtocolCatalogRouteByModulation, PROTOPIRATE_TX_KEY("mazda_v0")},
{"Mitsubishi V0", ProtoPirateProtocolCatalogRouteFMDefault, NULL},
{"Porsche Touareg", ProtoPirateProtocolCatalogRouteAMDefault, NULL},
{"PSA", ProtoPirateProtocolCatalogRouteByModulation, "psa"},
{"Renault V0", ProtoPirateProtocolCatalogRouteAMDefault, "renault_v0"},
{"PSA", ProtoPirateProtocolCatalogRouteByModulation, PROTOPIRATE_TX_KEY("psa")},
{"Renault V0", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("renault_v0")},
{"Scher-Khan", ProtoPirateProtocolCatalogRouteFMDefault, NULL},
{"Star Line", ProtoPirateProtocolCatalogRouteAMDefault, "star_line"},
{"Subaru", ProtoPirateProtocolCatalogRouteAMDefault, "subaru"},
{"VAG", ProtoPirateProtocolCatalogRouteAMVag, "vag"},
{"Star Line", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("star_line")},
{"Subaru", ProtoPirateProtocolCatalogRouteAMDefault, PROTOPIRATE_TX_KEY("subaru")},
{"VAG", ProtoPirateProtocolCatalogRouteAMVag, PROTOPIRATE_TX_KEY("vag")},
};
static const ProtoPirateProtocolCatalogAlias protopirate_protocol_catalog_aliases[] = {
@@ -90,7 +90,11 @@ ProtoPirateApp* protopirate_app_alloc() {
app->auto_save = settings.auto_save;
app->check_saved = settings.check_saved;
app->tx_power = settings.tx_power;
#ifdef ENABLE_EMULATE_FEATURE
app->emulate_feature_enabled = settings.emulate_feature_enabled;
#else
app->emulate_feature_enabled = false;
#endif
// Init setting - KEEP THIS, it's small
app->setting = subghz_setting_alloc();
@@ -175,7 +179,11 @@ void protopirate_app_free(ProtoPirateApp* app) {
settings.check_saved = app->check_saved;
settings.tx_power = app->tx_power;
settings.hopping_enabled = (app->txrx->hopper_state != ProtoPirateHopperStateOFF);
#ifdef ENABLE_EMULATE_FEATURE
settings.emulate_feature_enabled = app->emulate_feature_enabled;
#else
settings.emulate_feature_enabled = false;
#endif
// Find current preset index
settings.preset_index = 0;
@@ -273,14 +281,18 @@ int32_t protopirate_app(char* p) {
//We now jump straight to emulate scene from Browser. If the user wanted the key to look at, just click back.
if(load_saved) {
#ifdef ENABLE_EMULATE_FEATURE
if(protopirate_app->emulate_feature_enabled) {
view_dispatcher_send_custom_event(
protopirate_app->view_dispatcher, ProtoPirateCustomEventSavedInfoEmulate);
notification_message(protopirate_app->notifications, &sequence_success);
} else {
#endif
view_dispatcher_send_custom_event(
protopirate_app->view_dispatcher, ProtoPirateCustomEventReceiverInfoSave);
#ifdef ENABLE_EMULATE_FEATURE
}
#endif
}
view_dispatcher_run(protopirate_app->view_dispatcher);
@@ -12,6 +12,7 @@
#define CREDIT_LINE_HEIGHT 10
#define SCROLL_SPEED 1
#ifdef ENABLE_EMULATE_FEATURE
static const InputKey EMULATE_TOGGLE_COMBO[] = {
InputKeyUp,
InputKeyUp,
@@ -23,6 +24,7 @@ static const InputKey EMULATE_TOGGLE_COMBO[] = {
InputKeyRight,
};
#define EMULATE_TOGGLE_COMBO_LEN (sizeof(EMULATE_TOGGLE_COMBO) / sizeof(EMULATE_TOGGLE_COMBO[0]))
#endif
static const char* credits[] = {
"",
@@ -62,7 +64,9 @@ typedef struct {
uint8_t frame;
uint8_t seed;
int16_t scroll_offset;
#ifdef ENABLE_EMULATE_FEATURE
uint8_t combo_progress;
#endif
} GlitchState;
static GlitchState g_state = {0};
@@ -159,6 +163,7 @@ static void about_draw_callback(Canvas* canvas, void* context) {
static bool about_input_callback(InputEvent* event, void* context) {
furi_check(context);
#ifdef ENABLE_EMULATE_FEATURE
ProtoPirateApp* app = context;
if(event->type != InputTypePress) {
@@ -184,8 +189,13 @@ static bool about_input_callback(InputEvent* event, void* context) {
}
return true;
#else
UNUSED(event);
return false;
#endif
}
#ifdef ENABLE_EMULATE_FEATURE
static void about_show_emulate_toggle_popup(ProtoPirateApp* app) {
const bool now_enabled = app->emulate_feature_enabled;
@@ -204,6 +214,7 @@ static void about_show_emulate_toggle_popup(ProtoPirateApp* app) {
dialog_message_show(app->dialogs, message);
dialog_message_free(message);
}
#endif
void protopirate_scene_about_on_enter(void* context) {
furi_check(context);
@@ -218,7 +229,9 @@ void protopirate_scene_about_on_enter(void* context) {
g_state.frame = 0;
g_state.seed = furi_get_tick() & 0xFF;
g_state.scroll_offset = 0;
#ifdef ENABLE_EMULATE_FEATURE
g_state.combo_progress = 0;
#endif
view_set_draw_callback(app->view_about, about_draw_callback);
view_set_input_callback(app->view_about, about_input_callback);
@@ -245,7 +258,9 @@ bool protopirate_scene_about_on_event(void* context, SceneManagerEvent event) {
view_commit_model(app->view_about, true);
consumed = true;
} else if(event.type == SceneManagerEventTypeCustom) {
}
#ifdef ENABLE_EMULATE_FEATURE
else if(event.type == SceneManagerEventTypeCustom) {
if(event.event == ProtoPirateCustomEventAboutToggleEmulate) {
app->emulate_feature_enabled = !app->emulate_feature_enabled;
@@ -262,6 +277,7 @@ bool protopirate_scene_about_on_event(void* context, SceneManagerEvent event) {
consumed = true;
}
}
#endif
return consumed;
}
@@ -1205,7 +1205,9 @@ bool protopirate_scene_sub_decode_on_event(void* context, SceneManagerEvent even
app);
ctx->signal_info_left_is_emulate = false;
#ifdef ENABLE_EMULATE_FEATURE
bool left_button_used = false;
#endif
app->emulate_disabled_for_loaded = true;
// Store reference to history item's flipper format for saving
@@ -1239,7 +1241,9 @@ bool protopirate_scene_sub_decode_on_event(void* context, SceneManagerEvent even
"Brute force",
protopirate_scene_sub_decode_widget_callback,
app);
#ifdef ENABLE_EMULATE_FEATURE
left_button_used = true;
#endif
}
}
}
@@ -0,0 +1,44 @@
# Contributing to TagTinker
Thanks for wanting to help out! Here's how to get involved.
## Getting Started
1. Fork the repo
2. Create a feature branch: `git checkout -b my-feature`
3. Make your changes
4. Test on hardware if possible (or at minimum, ensure `ufbt build` passes)
5. Commit with a clear message
6. Open a pull request
## Code Style
- C99, no C++ features
- 4-space indentation (no tabs)
- `snake_case` for functions and variables
- Keep functions short and focused
## What We're Looking For
- Bug fixes (especially memory-related — the Flipper has very limited heap)
- Support for new ESL tag sizes and models
- NFC tag support for additional ESL formats
- UI/UX improvements
- Android companion app improvements
## Pull Request Guidelines
- Use **"Create a merge commit"** when merging to preserve contributor attribution.
- Keep commits focused — one feature or fix per PR.
## Reporting Issues
Please open a GitHub issue with:
- What you expected to happen
- What actually happened
- Your Flipper firmware version
- The ESL model you're testing with (if applicable)
## License
By contributing, you agree that your contributions will be licensed under GPL-3.0.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+87
View File
@@ -0,0 +1,87 @@
# TagTinker V2.1
<p align="center">
<strong>Infrared ESL Research Toolkit for Flipper Zero</strong><br>
<sub>Protocol study • Signal analysis • Digital Art</sub>
</p>
<p align="center">
<img alt="License: GPL-3.0" src="https://img.shields.io/badge/License-GPL--3.0-blue.svg">
<img alt="Platform: Flipper Zero" src="https://img.shields.io/badge/Platform-Flipper%20Zero-black.svg">
<a href="https://i12bp8.github.io/TagTinker/"><img alt="Image Prep" src="https://img.shields.io/badge/Image%20Prep-Open%20in%20browser-a78bfa?logo=github"></a>
</p>
<p align="center">
<strong><a href="https://i12bp8.github.io/TagTinker/">→ Launch the TagTinker Image Prep web app ←</a></strong>
</p>
<img alt="Demo Image" src="https://raw.githubusercontent.com/i12bp8/TagTinker/refs/heads/main/tagtinkerdemo.jpg">
## Overview
TagTinker is a Flipper Zero app for exploring infrared electronic shelf-label (ESL) protocols. It allows you to transmit custom images and text to supported graphics tags. A companion **web image preparer** runs entirely in the browser and lets you drop, dither and download Flipper-ready BMPs without any install.
As the Flipper Zero team notes:
> "FYI: this is pure infrared signal, same that you use in TV remotes. The whole security was relying on obscurity of protocol."
This tool is built for IoT security curiosity, learning about obscure protocols, and displaying digital art on e-ink hardware.
> [!WARNING]
> **Hardware Warning:** Many infrared ESL tags store their firmware, address, and display data in volatile RAM to save cost and energy. If you remove the battery or let it fully discharge, the tag will lose all programming and become unresponsive ("dead"). It usually cannot be recovered without the original base station.
## Features
- **TagTinker Flipper App:** High-performance, zero-allocation RLE streaming IR engine.
- **TagTinker Image Prep (web):** Single-file, dependency-free HTML page that lists every supported tag profile, runs a full image pipeline (tone, contrast, detail, sharpen, dither, photo-grade Oklab 3-colour quantisation) and exports a Flipper-ready BMP. Hosted at **[i12bp8.github.io/TagTinker](https://i12bp8.github.io/TagTinker/)** (source: `web-image-prep/`).
- **Drop-folder image flow:** Drop a prepared BMP into `apps_data/tagtinker/dropped/` on the Flipper SD card, then open `Targeted Payloads → <tag> → Set Image` and pick it. The Flipper rescales any BMP on the fly so a single file can target any tag and any page.
- **NFC Tag Scan:** Instantly identify ESL targets by scanning their NFC tag — no manual barcode entry needed.
- **WiFi Plugins (optional):** Plug a Flipper WiFi Dev Board (ESP32-S2) into the GPIO header to unlock live, network-rendered tag designs — crypto price cards, weather tiles, identicons, and more — auto-discovered by the FAP. New plugins live entirely on the cloud worker; the Flipper firmware never has to be re-flashed to add one.
<img alt="image" src="https://raw.githubusercontent.com/i12bp8/TagTinker/refs/heads/main/PXL_20260427_092219442.jpg" />
- Display text, custom images, and test-patterns.
- Support for monochrome and accent-color (red/yellow) graphics tags.
## Getting Started
1. Build the Flipper app from this repository and install it via `ufbt`. The first launch creates `apps_data/tagtinker/dropped/` on your SD card.
2. Open **[i12bp8.github.io/TagTinker](https://i12bp8.github.io/TagTinker/)** in any browser, pick your tag profile, drop an image, tweak, and download the BMP.
3. Copy the BMP into `apps_data/tagtinker/dropped/` on the SD card (over `qFlipper`, USB MTP, or whatever you use).
4. On the Flipper open `Targeted Payloads → <your tag> → Set Image`, pick the BMP, choose a page, send.
## FAQ
**Does this require a Flipper Zero?**
No, not at all! You can do this with less than $5 worth of microcontroller hardware (like an ESP32 and an IR LED). The Flipper Zero just happens to be my favorite security research tool, which is why I built the app for this platform.
**Where is the `.fap` release?**
The Flipper app is source-first. Build the `.fap` yourself from this repository with `ufbt` so it matches your firmware and local toolchain.
**What if it crashes or behaves oddly?**
If you are using a custom firmware branch, custom asset packs, or a heavily modified device setup, start by testing from a clean baseline firmware.
## Credits & Background
This project is deeply indebted to the incredible public reverse-engineering work by **furrtek**.
To understand the underlying protocol, signal structure, and history, please read his research:
- **Furrteks ESL research:** [https://www.furrtek.org/?a=esl](https://www.furrtek.org/?a=esl)
- **PrecIR reference implementation:** [https://github.com/furrtek/PrecIR](https://github.com/furrtek/PrecIR)
NFC tag decoding contributed by **7h30th3r0n3**.
## Disclaimer
> [!CAUTION]
> **STRICTLY PROHIBITED FOR ILLEGAL USE**
>
> TagTinker is an independent project intended **strictly** for educational research, security curiosity, and displaying digital art on hardware that **you legally own**.
>
> Under no circumstances is this software allowed to be used for illegal activities. You are strictly prohibited from using TagTinker to alter retail displays, modify electronic shelf labels in stores, interfere with third-party infrastructure, or cause any form of vandalism or financial harm.
>
> The creator of TagTinker assumes absolutely no liability for any misuse of this software. By using this software, you agree to take full responsibility for your actions and use it responsibly and legally.
## License
Licensed under the **GNU General Public License v3.0** (GPL-3.0). See the [`LICENSE`](LICENSE) file for details.
@@ -0,0 +1,43 @@
App(
appid="tagtinker",
name="TagTinker",
apptype=FlipperAppType.EXTERNAL,
entry_point="tagtinker_app_main",
requires=["gui", "notification", "dialogs", "storage", "bt", "nfc", "expansion"],
stack_size=12 * 1024,
fap_icon="tagtinker_10px.png",
fap_category="Infrared",
fap_author="i12bp8",
fap_description="Educational ESL study tool for owned hardware",
fap_version="2.1",
sources=[
"tagtinker_app.c",
"ir/tagtinker_ir.c",
"protocol/tagtinker_proto.c",
"scenes/tagtinker_scene.c",
"scenes/tagtinker_scene_about.c",
"scenes/tagtinker_scene_barcode_input.c",
"scenes/tagtinker_scene_broadcast.c",
"scenes/tagtinker_scene_broadcast_menu.c",
"scenes/tagtinker_scene_image_options.c",
"scenes/tagtinker_scene_main_menu.c",
"scenes/tagtinker_scene_preset_list.c",
"scenes/tagtinker_scene_synced_image_list.c",
"scenes/tagtinker_scene_settings.c",
"scenes/tagtinker_scene_size_picker.c",
"scenes/tagtinker_scene_target_actions.c",
"scenes/tagtinker_scene_target_menu.c",
"scenes/tagtinker_scene_text_input.c",
"scenes/tagtinker_scene_transmit.c",
"scenes/tagtinker_scene_text_box.c",
"scenes/tagtinker_scene_warning.c",
"views/numlock_input.c",
"nfc/tagtinker_nfc.c",
"scenes/tagtinker_scene_nfc_scan.c",
"wifi/tagtinker_wifi.c",
"wifi/tagtinker_wifi_bmp.c",
"scenes/tagtinker_scene_wifi_plugins.c",
"scenes/tagtinker_scene_wifi_setup.c",
"scenes/tagtinker_scene_wifi_run.c",
],
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"name": "tagtinker-cloud-plugins",
"version": "1.0.0",
"private": true,
"description": "Cloudflare Worker that renders TagTinker WiFi plugins server-side.",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"tail": "wrangler tail"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240909.0",
"typescript": "^5.5.4",
"wrangler": "^3.78.0"
},
"dependencies": {
"jpeg-js": "^0.4.4",
"upng-js": "^2.1.0"
}
}
@@ -0,0 +1,293 @@
/*
* Server-side 1bpp / 2bpp canvas. Each plane is a packed bitmap with the
* exact byte layout the ESP forwards to the Flipper (and the Flipper
* TXes verbatim to the tag): rows top-down, bytes MSB-first within a
* row, row_stride padded to 4 bytes (BMP convention).
*
* bit == 0 -> ink off (white)
* bit == 1 -> ink on (black on plane 0, accent on plane 1)
*
* Plane 1 only allocated when accent is requested AND supported.
*/
import { FONT_5x7, FONT_5x7_W, FONT_5x7_H, FONT_EXTRA } from "./font";
export type Ink = 0 | 1; // 0 = primary (black), 1 = accent (red/yellow)
export class Canvas {
readonly width: number;
readonly height: number;
readonly planes: number;
readonly rowStride: number;
readonly plane0: Uint8Array;
readonly plane1: Uint8Array | null;
constructor(width: number, height: number, planes: 1 | 2) {
this.width = width;
this.height = height;
this.planes = planes;
this.rowStride = ((width + 31) >> 5) << 2; // round up to 4 bytes
this.plane0 = new Uint8Array(this.rowStride * height);
this.plane1 = planes === 2 ? new Uint8Array(this.rowStride * height) : null;
}
private plane(ink: Ink): Uint8Array {
if (ink === 1 && this.plane1) return this.plane1;
return this.plane0;
}
setPixel(x: number, y: number, ink: Ink = 0): void {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
const i = y * this.rowStride + (x >> 3);
const mask = 0x80 >> (x & 7);
/* Last-write-wins on tri-state pixels: setting a pixel on one plane
* clears the same pixel on the OTHER plane. Without this, drawing
* black text on top of an accent-filled badge produces undefined
* results on the e-paper driver (typically the accent plane wins
* and the text vanishes). With this rule a pixel is always exactly
* one of {white, black, accent}, which is what plugin authors
* intuitively expect when stacking primitives. */
if (ink === 1 && this.plane1) {
this.plane1[i] |= mask;
this.plane0[i] &= ~mask;
} else {
this.plane0[i] |= mask;
if (this.plane1) this.plane1[i] &= ~mask;
}
}
clearPixel(x: number, y: number, ink: Ink = 0): void {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
const p = this.plane(ink);
const i = y * this.rowStride + (x >> 3);
p[i] &= ~(0x80 >> (x & 7));
}
/** Force a pixel to "white" (paper) by clearing BOTH planes. Useful
* for knockout text inside a coloured badge: white text on red
* background = no ink at all on those pixels, just the paper
* showing through. */
whitePixel(x: number, y: number): void {
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
const i = y * this.rowStride + (x >> 3);
const mask = ~(0x80 >> (x & 7));
this.plane0[i] &= mask;
if (this.plane1) this.plane1[i] &= mask;
}
hline(x: number, y: number, w: number, ink: Ink = 0): void {
for (let i = 0; i < w; i++) this.setPixel(x + i, y, ink);
}
vline(x: number, y: number, h: number, ink: Ink = 0): void {
for (let i = 0; i < h; i++) this.setPixel(x, y + i, ink);
}
line(x0: number, y0: number, x1: number, y1: number, ink: Ink = 0): void {
// Bresenham
const dx = Math.abs(x1 - x0);
const sx = x0 < x1 ? 1 : -1;
const dy = -Math.abs(y1 - y0);
const sy = y0 < y1 ? 1 : -1;
let err = dx + dy;
while (true) {
this.setPixel(x0, y0, ink);
if (x0 === x1 && y0 === y1) break;
const e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
}
}
rect(x: number, y: number, w: number, h: number, ink: Ink = 0): void {
this.hline(x, y, w, ink);
this.hline(x, y + h - 1, w, ink);
this.vline(x, y, h, ink);
this.vline(x + w - 1, y, h, ink);
}
fillRect(x: number, y: number, w: number, h: number, ink: Ink = 0): void {
for (let yy = 0; yy < h; yy++) this.hline(x, y + yy, w, ink);
}
/* ---- Text -------------------------------------------------------- */
textSize(s: string, scale = 1): { w: number; h: number } {
return { w: s.length * (FONT_5x7_W + 1) * scale, h: FONT_5x7_H * scale };
}
drawText(x: number, y: number, s: string, ink: Ink = 0, scale = 1): void {
for (let ci = 0; ci < s.length; ci++) {
const ch = s.charCodeAt(ci);
let glyph: number[];
if (ch >= 32 && ch <= 127) {
glyph = FONT_5x7[ch - 32] ?? FONT_5x7[0];
} else {
/* Non-ASCII codepoint: try the extra glyph table (, £, ¥, , ...).
* Plugins can ship Unicode strings directly and this lookup
* keeps the per-glyph cell width identical, so textSize() math
* still works unchanged. */
glyph = FONT_EXTRA[ch] ?? FONT_5x7[0];
}
for (let row = 0; row < FONT_5x7_H; row++) {
const bits = glyph[row];
for (let col = 0; col < FONT_5x7_W; col++) {
if (bits & (1 << (FONT_5x7_W - 1 - col))) {
for (let dy = 0; dy < scale; dy++) {
for (let dx = 0; dx < scale; dx++) {
this.setPixel(
x + (ci * (FONT_5x7_W + 1) + col) * scale + dx,
y + row * scale + dy,
ink,
);
}
}
}
}
}
}
}
/** Knockout-text version of drawText: each glyph pixel is forced to
* white (clears BOTH planes). Use this when drawing a label inside
* a solid coloured badge so the paper shows through the letters. */
drawTextWhite(x: number, y: number, s: string, scale = 1): void {
for (let ci = 0; ci < s.length; ci++) {
const ch = s.charCodeAt(ci);
let glyph: number[];
if (ch >= 32 && ch <= 127) glyph = FONT_5x7[ch - 32] ?? FONT_5x7[0];
else glyph = FONT_EXTRA[ch] ?? FONT_5x7[0];
for (let row = 0; row < FONT_5x7_H; row++) {
const bits = glyph[row];
for (let col = 0; col < FONT_5x7_W; col++) {
if (bits & (1 << (FONT_5x7_W - 1 - col))) {
for (let dy = 0; dy < scale; dy++) {
for (let dx = 0; dx < scale; dx++) {
this.whitePixel(
x + (ci * (FONT_5x7_W + 1) + col) * scale + dx,
y + row * scale + dy,
);
}
}
}
}
}
}
}
drawTextCentered(cx: number, y: number, s: string, ink: Ink = 0, scale = 1): void {
const { w } = this.textSize(s, scale);
this.drawText(cx - (w >> 1), y, s, ink, scale);
}
drawTextRight(rx: number, y: number, s: string, ink: Ink = 0, scale = 1): void {
const { w } = this.textSize(s, scale);
this.drawText(rx - w, y, s, ink, scale);
}
/* ---- Sparkline --------------------------------------------------- */
sparkline(
x: number, y: number, w: number, h: number,
samples: number[], ink: Ink = 0, dot = true,
): void {
if (samples.length < 2) return;
let lo = Infinity, hi = -Infinity;
for (const v of samples) {
if (v < lo) lo = v;
if (v > hi) hi = v;
}
if (hi === lo) hi = lo + 1;
const xs: number[] = [];
const ys: number[] = [];
for (let i = 0; i < samples.length; i++) {
const px = x + Math.round((i * (w - 1)) / (samples.length - 1));
const py = y + h - 1 - Math.round(((samples[i] - lo) * (h - 1)) / (hi - lo));
xs.push(px); ys.push(py);
}
for (let i = 1; i < samples.length; i++) {
this.line(xs[i - 1], ys[i - 1], xs[i], ys[i], ink);
}
if (dot) {
const lx = xs[xs.length - 1], ly = ys[ys.length - 1];
this.fillRect(lx - 1, ly - 1, 3, 3, ink);
}
}
/* ---- Floyd-Steinberg dither (image to 1bpp/2bpp) ----------------- */
/**
* Blit a pre-scaled grayscale (or RGB) image into the canvas with
* Floyd-Steinberg dithering. `gray` is row-major, 1 byte per pixel.
* `rgb` (optional) is row-major RGB triplets; when present and an accent
* mode is set, saturated red/yellow pixels are routed to plane 1.
*/
blitDithered(
dx: number, dy: number, dw: number, dh: number,
gray: Uint8Array, rgb: Uint8Array | null, sw: number, sh: number,
accentMode: "none" | "red" | "yellow",
): void {
// Nearest-neighbour scale source -> dest, then dither in place.
const buf = new Float32Array(dw * dh);
const accFlag = new Uint8Array(dw * dh);
for (let y = 0; y < dh; y++) {
const sy = Math.min(sh - 1, Math.floor((y * sh) / dh));
for (let x = 0; x < dw; x++) {
const sx = Math.min(sw - 1, Math.floor((x * sw) / dw));
const gi = sy * sw + sx;
buf[y * dw + x] = gray[gi];
if (rgb && accentMode !== "none") {
const ri = gi * 3;
const r = rgb[ri], g = rgb[ri + 1], b = rgb[ri + 2];
if (accentMode === "red" && r > 150 && g < 90 && b < 90) {
accFlag[y * dw + x] = 1;
} else if (accentMode === "yellow" && r > 180 && g > 150 && b < 100) {
accFlag[y * dw + x] = 1;
}
}
}
}
for (let y = 0; y < dh; y++) {
for (let x = 0; x < dw; x++) {
const i = y * dw + x;
const old = buf[i];
const isAccent = accFlag[i] === 1;
const newPx = old < 128 ? 0 : 255;
if (newPx === 0) {
this.setPixel(dx + x, dy + y, isAccent ? 1 : 0);
}
const err = old - newPx;
if (x + 1 < dw) buf[i + 1] += err * 7 / 16;
if (y + 1 < dh) {
if (x > 0) buf[i + dw - 1] += err * 3 / 16;
buf[i + dw] += err * 5 / 16;
if (x + 1 < dw) buf[i + dw + 1] += err * 1 / 16;
}
}
}
}
/* ---- Serialization ---------------------------------------------- */
/**
* Encode as the binary blob the ESP forwards to the Flipper:
* uint16 width LE, uint16 height LE, uint8 planes, uint8 reserved,
* uint16 row_stride LE,
* plane0 bytes,
* plane1 bytes (if planes == 2).
*/
toBytes(): Uint8Array {
const planeBytes = this.rowStride * this.height;
const total = 8 + planeBytes * this.planes;
const out = new Uint8Array(total);
const view = new DataView(out.buffer);
view.setUint16(0, this.width, true);
view.setUint16(2, this.height, true);
out[4] = this.planes;
out[5] = 0;
view.setUint16(6, this.rowStride, true);
out.set(this.plane0, 8);
if (this.plane1) out.set(this.plane1, 8 + planeBytes);
return out;
}
}
@@ -0,0 +1,142 @@
/*
* Compact 5x7 ASCII bitmap font (95 glyphs, 0x20..0x7E).
*
* Each glyph is 7 bytes. Each byte is a row, low 5 bits = pixels left to
* right (we shift starting at bit 4 down to bit 0). Stored MSB-aligned in
* a uint8 by left-shifting 3 bits at runtime (we just mask `(bits >> n)`
* with FONT_5x7_W).
*
* Mirrors esp32-wifi-fw/main/font_5x7.c so designs look identical whether
* a future plugin is rendered server-side here or fell back to the device.
*/
export const FONT_5x7_W = 5;
export const FONT_5x7_H = 7;
// Each entry: 7 row bytes, low 5 bits = pixel row left -> right.
// Columns shifted to bits [4..0]. We test `bits & (1 << (4 - col))` in canvas.
export const FONT_5x7: number[][] = [
[0,0,0,0,0,0,0], // ' '
[0x04,0x04,0x04,0x04,0x00,0x00,0x04], // '!'
[0x0a,0x0a,0x00,0x00,0x00,0x00,0x00], // '"'
[0x0a,0x1f,0x0a,0x1f,0x0a,0x00,0x00], // '#'
[0x04,0x0f,0x14,0x0e,0x05,0x1e,0x04], // '$'
[0x18,0x19,0x02,0x04,0x08,0x13,0x03], // '%'
[0x08,0x14,0x14,0x08,0x15,0x12,0x0d], // '&'
[0x04,0x04,0x00,0x00,0x00,0x00,0x00], // '''
[0x02,0x04,0x08,0x08,0x08,0x04,0x02], // '('
[0x08,0x04,0x02,0x02,0x02,0x04,0x08], // ')'
[0x00,0x04,0x15,0x0e,0x15,0x04,0x00], // '*'
[0x00,0x04,0x04,0x1f,0x04,0x04,0x00], // '+'
[0x00,0x00,0x00,0x00,0x06,0x06,0x04], // ','
[0x00,0x00,0x00,0x1f,0x00,0x00,0x00], // '-'
[0x00,0x00,0x00,0x00,0x00,0x06,0x06], // '.'
[0x00,0x01,0x02,0x04,0x08,0x10,0x00], // '/'
[0x0e,0x11,0x13,0x15,0x19,0x11,0x0e], // '0'
[0x04,0x0c,0x04,0x04,0x04,0x04,0x0e], // '1'
[0x0e,0x11,0x01,0x02,0x04,0x08,0x1f], // '2'
[0x1f,0x02,0x04,0x02,0x01,0x11,0x0e], // '3'
[0x02,0x06,0x0a,0x12,0x1f,0x02,0x02], // '4'
[0x1f,0x10,0x1e,0x01,0x01,0x11,0x0e], // '5'
[0x06,0x08,0x10,0x1e,0x11,0x11,0x0e], // '6'
[0x1f,0x01,0x02,0x04,0x08,0x08,0x08], // '7'
[0x0e,0x11,0x11,0x0e,0x11,0x11,0x0e], // '8'
[0x0e,0x11,0x11,0x0f,0x01,0x02,0x0c], // '9'
[0x00,0x06,0x06,0x00,0x06,0x06,0x00], // ':'
[0x00,0x06,0x06,0x00,0x06,0x06,0x04], // ';'
[0x02,0x04,0x08,0x10,0x08,0x04,0x02], // '<'
[0x00,0x00,0x1f,0x00,0x1f,0x00,0x00], // '='
[0x08,0x04,0x02,0x01,0x02,0x04,0x08], // '>'
[0x0e,0x11,0x01,0x02,0x04,0x00,0x04], // '?'
[0x0e,0x11,0x17,0x15,0x17,0x10,0x0e], // '@'
[0x0e,0x11,0x11,0x1f,0x11,0x11,0x11], // 'A'
[0x1e,0x11,0x11,0x1e,0x11,0x11,0x1e], // 'B'
[0x0e,0x11,0x10,0x10,0x10,0x11,0x0e], // 'C'
[0x1c,0x12,0x11,0x11,0x11,0x12,0x1c], // 'D'
[0x1f,0x10,0x10,0x1e,0x10,0x10,0x1f], // 'E'
[0x1f,0x10,0x10,0x1e,0x10,0x10,0x10], // 'F'
[0x0e,0x11,0x10,0x17,0x11,0x11,0x0e], // 'G'
[0x11,0x11,0x11,0x1f,0x11,0x11,0x11], // 'H'
[0x0e,0x04,0x04,0x04,0x04,0x04,0x0e], // 'I'
[0x07,0x02,0x02,0x02,0x02,0x12,0x0c], // 'J'
[0x11,0x12,0x14,0x18,0x14,0x12,0x11], // 'K'
[0x10,0x10,0x10,0x10,0x10,0x10,0x1f], // 'L'
[0x11,0x1b,0x15,0x15,0x11,0x11,0x11], // 'M'
[0x11,0x11,0x19,0x15,0x13,0x11,0x11], // 'N'
[0x0e,0x11,0x11,0x11,0x11,0x11,0x0e], // 'O'
[0x1e,0x11,0x11,0x1e,0x10,0x10,0x10], // 'P'
[0x0e,0x11,0x11,0x11,0x15,0x12,0x0d], // 'Q'
[0x1e,0x11,0x11,0x1e,0x14,0x12,0x11], // 'R'
[0x0e,0x11,0x10,0x0e,0x01,0x11,0x0e], // 'S'
[0x1f,0x04,0x04,0x04,0x04,0x04,0x04], // 'T'
[0x11,0x11,0x11,0x11,0x11,0x11,0x0e], // 'U'
[0x11,0x11,0x11,0x11,0x11,0x0a,0x04], // 'V'
[0x11,0x11,0x11,0x15,0x15,0x15,0x0a], // 'W'
[0x11,0x11,0x0a,0x04,0x0a,0x11,0x11], // 'X'
[0x11,0x11,0x11,0x0a,0x04,0x04,0x04], // 'Y'
[0x1f,0x01,0x02,0x04,0x08,0x10,0x1f], // 'Z'
[0x0e,0x08,0x08,0x08,0x08,0x08,0x0e], // '['
[0x00,0x10,0x08,0x04,0x02,0x01,0x00], // '\'
[0x0e,0x02,0x02,0x02,0x02,0x02,0x0e], // ']'
[0x04,0x0a,0x11,0x00,0x00,0x00,0x00], // '^'
[0x00,0x00,0x00,0x00,0x00,0x00,0x1f], // '_'
[0x08,0x04,0x02,0x00,0x00,0x00,0x00], // '`'
[0x00,0x00,0x0e,0x01,0x0f,0x11,0x0f], // 'a'
[0x10,0x10,0x16,0x19,0x11,0x11,0x1e], // 'b'
[0x00,0x00,0x0e,0x10,0x10,0x11,0x0e], // 'c'
[0x01,0x01,0x0d,0x13,0x11,0x11,0x0f], // 'd'
[0x00,0x00,0x0e,0x11,0x1f,0x10,0x0e], // 'e'
[0x06,0x09,0x08,0x1c,0x08,0x08,0x08], // 'f'
[0x00,0x0f,0x11,0x11,0x0f,0x01,0x0e], // 'g'
[0x10,0x10,0x16,0x19,0x11,0x11,0x11], // 'h'
[0x04,0x00,0x0c,0x04,0x04,0x04,0x0e], // 'i'
[0x02,0x00,0x06,0x02,0x02,0x12,0x0c], // 'j'
[0x10,0x10,0x12,0x14,0x18,0x14,0x12], // 'k'
[0x0c,0x04,0x04,0x04,0x04,0x04,0x0e], // 'l'
[0x00,0x00,0x1a,0x15,0x15,0x11,0x11], // 'm'
[0x00,0x00,0x16,0x19,0x11,0x11,0x11], // 'n'
[0x00,0x00,0x0e,0x11,0x11,0x11,0x0e], // 'o'
[0x00,0x1e,0x11,0x11,0x1e,0x10,0x10], // 'p'
[0x00,0x0d,0x13,0x13,0x0d,0x01,0x01], // 'q'
[0x00,0x00,0x16,0x19,0x10,0x10,0x10], // 'r'
[0x00,0x00,0x0f,0x10,0x0e,0x01,0x1e], // 's'
[0x08,0x08,0x1c,0x08,0x08,0x09,0x06], // 't'
[0x00,0x00,0x11,0x11,0x11,0x13,0x0d], // 'u'
[0x00,0x00,0x11,0x11,0x11,0x0a,0x04], // 'v'
[0x00,0x00,0x11,0x11,0x15,0x15,0x0a], // 'w'
[0x00,0x00,0x11,0x0a,0x04,0x0a,0x11], // 'x'
[0x00,0x00,0x11,0x11,0x0f,0x01,0x0e], // 'y'
[0x00,0x00,0x1f,0x02,0x04,0x08,0x1f], // 'z'
[0x02,0x04,0x04,0x08,0x04,0x04,0x02], // '{'
[0x04,0x04,0x04,0x00,0x04,0x04,0x04], // '|'
[0x08,0x04,0x04,0x02,0x04,0x04,0x08], // '}'
[0x09,0x15,0x12,0x00,0x00,0x00,0x00], // '~'
[0,0,0,0,0,0,0], // 0x7f (fallback)
];
/* Extra non-ASCII glyphs the canvas will render via direct codepoint
* lookup. Each entry is a 7-row 5-wide bitmap in the same packing as
* FONT_5x7 (low 5 bits = pixels left-to-right). Keyed by Unicode code
* point so plugins can write `"€69,420"` literally and have it Just Work. */
export const FONT_EXTRA: Record<number, number[]> = {
0x00A3: [0x07,0x08,0x08,0x1E,0x08,0x08,0x1F], // £ pound
0x00A5: [0x11,0x0A,0x04,0x1F,0x04,0x1F,0x04], // ¥ yen
0x00A2: [0x04,0x0E,0x14,0x14,0x14,0x0E,0x04], // ¢ cent
0x20AC: [0x06,0x09,0x1E,0x08,0x1E,0x09,0x06], // € euro
0x00B0: [0x06,0x09,0x06,0x00,0x00,0x00,0x00], // ° degree
0x2191: [0x04,0x0E,0x15,0x04,0x04,0x04,0x04], // ↑ up arrow
0x2193: [0x04,0x04,0x04,0x04,0x15,0x0E,0x04], // ↓ down arrow
0x25B2: [0x04,0x04,0x0E,0x0E,0x1F,0x1F,0x00], // ▲ filled up
0x25BC: [0x00,0x1F,0x1F,0x0E,0x0E,0x04,0x04], // ▼ filled down
0x2022: [0x00,0x00,0x0E,0x1F,0x0E,0x00,0x00], // • bullet
0x25C9: [0x0E,0x11,0x15,0x1B,0x15,0x11,0x0E], // ◉ fisheye
0x25EF: [0x0E,0x11,0x11,0x11,0x11,0x11,0x0E], // ◯ large circle
0x25C6: [0x04,0x0E,0x1F,0x1F,0x1F,0x0E,0x04], // ◆ diamond
0x25A0: [0x00,0x1F,0x1F,0x1F,0x1F,0x1F,0x00], // ■ filled square
0x25A1: [0x00,0x1F,0x11,0x11,0x11,0x1F,0x00], // □ empty square
0x2588: [0x1F,0x1F,0x1F,0x1F,0x1F,0x1F,0x1F], // █ full block
0x2592: [0x15,0x0A,0x15,0x0A,0x15,0x0A,0x15], // ▒ medium shade
0x253C: [0x04,0x04,0x04,0x1F,0x04,0x04,0x04], // ┼ crosshair
0x2605: [0x04,0x04,0x1F,0x0E,0x0E,0x11,0x00], // ★ black star
0x2606: [0x04,0x0A,0x11,0x0A,0x0A,0x11,0x00], // ☆ white star (rough)
};
@@ -0,0 +1,113 @@
/*
* Shared image-fetch / decode / dither helpers used by every plugin
* that wants to render a downloaded raster onto the e-paper canvas.
*
* - fetchImageGray(url): downloads, sniffs PNG vs JPEG, decodes, then
* composites alpha over white and converts to perceptual luma. The
* result is always a tightly packed Uint8Array of grayscale 0..255.
* - blitGrayDither(canvas, ...): nearest-neighbour scales a grayscale
* buffer into a destination rect on a Canvas and serpentine
* Floyd-Steinberg dithers it to monochrome ink in the process.
*
* Keeping these in one file means the worker bundle ships UPNG / jpeg-js
* once, not once per plugin, and any pixel-level improvements (e.g.
* a different dither) propagate everywhere automatically.
*/
import * as UPNG from "upng-js";
// jpeg-js is CommonJS without proper types, so import as any.
// eslint-disable-next-line @typescript-eslint/no-var-requires
import jpegJs from "jpeg-js";
import { Canvas, Ink } from "./canvas";
export interface GrayImage { gray: Uint8Array; w: number; h: number; }
/** Fetch a remote image and reduce it to a grayscale luma buffer. PNG
* and JPEG are both supported - we sniff the magic bytes rather than
* trusting the URL extension or the Content-Type header (which CDNs
* frequently get wrong). */
export async function fetchImageGray(url: string): Promise<GrayImage | null> {
let raw: ArrayBuffer;
try {
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) return null;
raw = await r.arrayBuffer();
} catch {
return null;
}
return decodeImageGray(raw);
}
/** Decode a buffer into grayscale, sniffing PNG vs JPEG on first bytes. */
export function decodeImageGray(buf: ArrayBuffer): GrayImage | null {
const u8 = new Uint8Array(buf);
if (u8.length < 4) return null;
let rgba: Uint8Array;
let w: number, h: number;
/* PNG magic: 89 50 4E 47 */
if (u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4E && u8[3] === 0x47) {
let dec;
try { dec = UPNG.decode(buf); } catch { return null; }
const arr = UPNG.toRGBA8(dec);
if (!arr.length) return null;
rgba = new Uint8Array(arr[0]);
w = dec.width; h = dec.height;
/* JPEG magic: FF D8 FF */
} else if (u8[0] === 0xFF && u8[1] === 0xD8 && u8[2] === 0xFF) {
let dec;
try { dec = jpegJs.decode(u8, { useTArray: true }); } catch { return null; }
rgba = dec.data as Uint8Array;
w = dec.width; h = dec.height;
} else {
return null;
}
const gray = new Uint8Array(w * h);
for (let i = 0, j = 0; i < gray.length; i++, j += 4) {
const a = rgba[j + 3] / 255;
const r = rgba[j] * a + 255 * (1 - a);
const g = rgba[j + 1] * a + 255 * (1 - a);
const b = rgba[j + 2] * a + 255 * (1 - a);
gray[i] = (0.2126 * r + 0.7152 * g + 0.0722 * b) | 0;
}
return { gray, w, h };
}
/** Serpentine Floyd-Steinberg dither into a destination rect. The
* source is nearest-neighbour scaled into a working float buffer so
* the dither runs at output resolution (where artefacts actually
* matter to the user). */
export function blitGrayDither(
c: Canvas, dx: number, dy: number, dstW: number, dstH: number,
img: GrayImage, ink: Ink = 0,
): void {
const { gray, w: srcW, h: srcH } = img;
const buf = new Float32Array(dstW * dstH);
for (let y = 0; y < dstH; y++) {
const sy = Math.min(srcH - 1, Math.floor((y * srcH) / dstH));
for (let x = 0; x < dstW; x++) {
const sx = Math.min(srcW - 1, Math.floor((x * srcW) / dstW));
buf[y * dstW + x] = gray[sy * srcW + sx];
}
}
for (let y = 0; y < dstH; y++) {
const rev = (y & 1) === 1;
const xStart = rev ? dstW - 1 : 0;
const xEnd = rev ? -1 : dstW;
const xStep = rev ? -1 : 1;
for (let x = xStart; x !== xEnd; x += xStep) {
const i = y * dstW + x;
const old = buf[i];
const np = old < 128 ? 0 : 255;
if (np === 0) c.setPixel(dx + x, dy + y, ink);
const err = old - np;
const sx = rev ? -1 : 1;
if (x + sx >= 0 && x + sx < dstW) buf[i + sx] += err * 7 / 16;
if (y + 1 < dstH) {
if (x - sx >= 0 && x - sx < dstW) buf[i + dstW - sx] += err * 3 / 16;
buf[i + dstW] += err * 5 / 16;
if (x + sx >= 0 && x + sx < dstW) buf[i + dstW + sx] += err * 1 / 16;
}
}
}
}
@@ -0,0 +1,108 @@
/*
* TagTinker WiFi Plugins - Cloudflare Worker entry.
*
* Endpoints:
*
* GET /plugins
* -> JSON: { plugins: [ {id,name,description,accent_modes,params}, ... ] }
*
* GET /render/:id?w=<int>&h=<int>&accent=<none|red|yellow>&<param>=<val>...
* -> application/octet-stream
* Format (little-endian):
* uint16 width, uint16 height, uint8 planes, uint8 reserved,
* uint16 row_stride,
* plane0 bytes (rowStride * height),
* plane1 bytes (if planes == 2).
*
* The ESP32 simply forwards the byte stream to the Flipper as a
* RESULT_BEGIN + N x RESULT_CHUNK + RESULT_END frame sequence.
*/
import { Plugin, AccentMode } from "./plugin";
import { cryptoPlugin } from "./plugins/crypto";
import { weatherPlugin } from "./plugins/weather";
import { identiconPlugin } from "./plugins/identicon";
import { githubPlugin } from "./plugins/github";
const PLUGINS: Plugin[] = [
cryptoPlugin, weatherPlugin, identiconPlugin, githubPlugin,
];
const CORS_HEADERS: Record<string, string> = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
};
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...CORS_HEADERS },
});
}
function errorResponse(message: string, status = 400): Response {
return jsonResponse({ error: message }, status);
}
function parseAccent(s: string | null): AccentMode {
return s === "red" || s === "yellow" ? s : "none";
}
async function handleRender(id: string, url: URL): Promise<Response> {
const plugin = PLUGINS.find((p) => p.manifest.id === id);
if (!plugin) return errorResponse(`unknown plugin '${id}'`, 404);
const w = parseInt(url.searchParams.get("w") ?? "296", 10);
const h = parseInt(url.searchParams.get("h") ?? "128", 10);
if (!(w > 0 && w <= 1024 && h > 0 && h <= 1024)) {
return errorResponse("bad w/h", 400);
}
const accent = parseAccent(url.searchParams.get("accent"));
const params: Record<string, string> = {};
for (const [k, v] of url.searchParams.entries()) {
if (k !== "w" && k !== "h" && k !== "accent") params[k] = v;
}
try {
const c = await plugin.render(params, w, h, accent);
const bytes = c.toBytes();
return new Response(bytes, {
status: 200,
headers: {
"Content-Type": "application/octet-stream",
"Cache-Control": "no-store",
...CORS_HEADERS,
},
});
} catch (e: any) {
return errorResponse(`render failed: ${e?.message ?? e}`, 500);
}
}
export default {
async fetch(req: Request): Promise<Response> {
const url = new URL(req.url);
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
if (url.pathname === "/" || url.pathname === "/health") {
return jsonResponse({
ok: true,
service: "tagtinker-cloud-plugins",
plugins: PLUGINS.length,
});
}
if (url.pathname === "/plugins") {
return jsonResponse({ plugins: PLUGINS.map((p) => p.manifest) });
}
const m = url.pathname.match(/^\/render\/([a-zA-Z0-9_-]+)$/);
if (m) return handleRender(m[1], url);
return errorResponse("not found", 404);
},
};
@@ -0,0 +1,43 @@
/*
* Plugin shape. A plugin exports:
* - manifest: served verbatim from /plugins
* - render(params, w, h, accent): builds a Canvas and returns it
*
* Adding a new plugin = drop a new file in src/plugins/, import + push it
* to PLUGINS in src/index.ts. Cloudflare deploys, Flipper picks it up on
* the next "Refresh Plugins".
*/
import { Canvas } from "./canvas";
export type ParamType = "string" | "int" | "enum" | "bool";
export interface ParamSpec {
key: string;
label: string;
type: ParamType;
default: string;
options?: string[];
min?: number;
max?: number;
}
export const ACCENT_NONE = 0;
export const ACCENT_RED = 1;
export const ACCENT_YELLOW = 2;
export interface PluginManifest {
id: string;
name: string;
description: string;
/** Bitmask: 1 = mono OK, 2 = red, 4 = yellow */
accent_modes: number;
params: ParamSpec[];
}
export type AccentMode = "none" | "red" | "yellow";
export interface Plugin {
manifest: PluginManifest;
render(params: Record<string, string>, w: number, h: number, accent: AccentMode): Promise<Canvas>;
}
@@ -0,0 +1,256 @@
/*
* Crypto Price plugin - editorial / share-worthy layout.
*
* Mono tags get a clean black & white card; tri-colour tags get a
* dramatic accent treatment on the sparkline fill, the delta badge,
* the corner brackets and a thin underline accent on the price - the
* kind of thing that looks intentional in a photo of an e-paper tag.
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
const COIN_MAP: Record<string, string> = {
BTC: "bitcoin", ETH: "ethereum", SOL: "solana", XRP: "ripple",
DOGE: "dogecoin", ADA: "cardano", BNB: "binancecoin", LINK: "chainlink",
};
const RANGE_DAYS: Record<string, number> = { "24H": 1, "7D": 7, "30D": 30 };
/* The Canvas now ships €/£/¥ glyphs in FONT_EXTRA so we can render them
* literally as the price prefix - same width budget as "$". */
const CCY_PREFIX: Record<string, string> = { USD: "$", EUR: "\u20AC", GBP: "\u00A3" };
function formatPrice(v: number, prefix: string): string {
let body: string;
if (v >= 1) body = v.toFixed(2);
else if (v >= 0.01) body = v.toFixed(4);
else body = v.toFixed(6);
const [intPart, frac] = body.split(".");
const withCommas = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return prefix + withCommas + (frac ? "." + frac : "");
}
async function fetchPrice(id: string, vs: string): Promise<{ price: number; change: number }> {
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=${vs}&include_24hr_change=true`;
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) throw new Error(`price ${r.status}`);
const j: any = await r.json();
const c = j[id];
if (!c) throw new Error("coin not in response");
const k = vs.toLowerCase();
return { price: c[k] ?? 0, change: c[`${k}_24h_change`] ?? 0 };
}
async function fetchHistory(id: string, vs: string, days: number, samples: number): Promise<number[]> {
const url = `https://api.coingecko.com/api/v3/coins/${id}/market_chart?vs_currency=${vs.toLowerCase()}&days=${days}`;
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) throw new Error(`history ${r.status}`);
const j: any = await r.json();
const arr: [number, number][] = j.prices ?? [];
if (arr.length === 0) return [];
const out: number[] = [];
for (let i = 0; i < samples; i++) {
const idx = Math.floor((i * (arr.length - 1)) / Math.max(1, samples - 1));
out.push(arr[idx][1]);
}
return out;
}
/* ─── Drawing helpers ─────────────────────────────────────────────── */
const BAYER4 = [
[ 0, 8, 2, 10],
[12, 4, 14, 6],
[ 3, 11, 1, 9],
[15, 7, 13, 5],
];
function fillStippled(c: Canvas, x: number, y: number, w: number, h: number,
density: number, ink: Ink) {
const t = Math.max(0, Math.min(15, Math.round(density * 16) - 1));
for (let yy = 0; yy < h; yy++) {
for (let xx = 0; xx < w; xx++) {
if (BAYER4[yy & 3][xx & 3] <= t) c.setPixel(x + xx, y + yy, ink);
}
}
}
function fillUnderCurve(c: Canvas, xs: number[], ys: number[],
baselineY: number, ink: Ink) {
for (let i = 0; i < xs.length - 1; i++) {
const x0 = xs[i], y0 = ys[i];
const x1 = xs[i + 1], y1 = ys[i + 1];
const dx = x1 - x0;
if (dx <= 0) continue;
for (let x = x0; x <= x1; x++) {
const t = (x - x0) / dx;
const y = Math.round(y0 + (y1 - y0) * t);
const top = Math.min(y, baselineY);
const bot = Math.max(y, baselineY);
for (let yy = top; yy <= bot; yy++) {
if (BAYER4[yy & 3][x & 3] < 8) c.setPixel(x, yy, ink);
}
}
}
}
function cornerBrackets(c: Canvas, x: number, y: number, w: number, h: number,
len: number, ink: Ink) {
c.hline(x, y, len, ink); c.vline(x, y, len, ink);
c.hline(x + w - len, y, len, ink); c.vline(x + w - 1, y, len, ink);
c.hline(x, y + h - 1, len, ink); c.vline(x, y + h - len, len, ink);
c.hline(x + w - len, y + h - 1, len, ink); c.vline(x + w - 1, y + h - len, len, ink);
}
/* ─── Plugin ─────────────────────────────────────────────────────── */
export const cryptoPlugin: Plugin = {
manifest: {
id: "crypto",
name: "Crypto Price",
description: "Editorial price card with sparkline",
accent_modes: 1 | 2 | 4,
params: [
{ key: "symbol", label: "Coin", type: "enum", default: "BTC",
options: Object.keys(COIN_MAP) },
{ key: "currency", label: "Currency", type: "enum", default: "USD",
options: Object.keys(CCY_PREFIX) },
{ key: "range", label: "Range", type: "enum", default: "24H",
options: Object.keys(RANGE_DAYS) },
],
},
async render(params, W, H, accent: AccentMode) {
const sym = (params.symbol ?? "BTC").toUpperCase();
const vs = (params.currency ?? "USD").toUpperCase();
const range = (params.range ?? "24H").toUpperCase();
const id = COIN_MAP[sym] ?? "bitcoin";
const days = RANGE_DAYS[range] ?? 1;
const prefix = CCY_PREFIX[vs] ?? "$";
const { price, change } = await fetchPrice(id, vs);
const samples = Math.min(W, 256);
const hist = await fetchHistory(id, vs, days, samples);
const planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
/* On mono tags accent ink == primary - visually a bit boring but the
* structural design (corner brackets, dithered fills, big type) still
* carries the look. On tri-colour tags the accent plane gives the
* splash of red/yellow that makes the card pop. */
const acc: Ink = planes === 2 ? 1 : 0;
const blk: Ink = 0;
const margin = W < 200 ? 4 : 8;
/* ── Corner viewfinder brackets in accent ─────────────────────── */
const bracketLen = Math.max(8, Math.min(16, Math.floor(W / 24)));
cornerBrackets(c, 1, 1, W - 2, H - 2, bracketLen, acc);
/* ── Header row: symbol/ccy on the left, delta badge right ────── */
const headerY = margin + 2;
const symLabel = `${sym}/${vs}`;
/* Header: "BTC/USD" in scale 2 - the focal element of the top bar. */
c.drawText(margin + 4, headerY, symLabel, blk, 2);
const symH = c.textSize(symLabel, 2).h;
/* Range chip on header row right of the symbol. */
{
const chip = range;
const cs = c.textSize(chip, 1);
const cx = margin + 4 + c.textSize(symLabel, 2).w + 6;
const cy = headerY + Math.max(0, (symH - cs.h) >> 1);
/* Box outline in primary, the chip text reads black on white. */
c.rect(cx - 2, cy - 1, cs.w + 4, cs.h + 2, blk);
c.drawText(cx, cy, chip, blk, 1);
}
/* Delta badge top-right: solid accent block with a chunky black
* triangle and the % delta in black, all rendered on plane 0 over
* the accent plane fill. Reads cleanly as "black ink on red/yellow
* paint" on tri-colour tags - and as plain black on mono tags. */
{
const arrow = change >= 0 ? "\u25B2" : "\u25BC"; // ▲ / ▼ glyph
const pct = `${change >= 0 ? "+" : "-"}${Math.abs(change).toFixed(2)}%`;
const arrowSize = c.textSize(arrow, 1);
const pctSize = c.textSize(pct, 1);
const padX = 5;
const gap = 3;
const bw = padX + arrowSize.w + gap + pctSize.w + padX;
const bh = Math.max(arrowSize.h, pctSize.h) + 6;
const bx = W - margin - bw - 2;
const by = headerY + Math.max(0, (symH - bh) >> 1);
/* Solid accent fill behind everything, then knock out the arrow
* and the % text in WHITE (paper showing through both planes).
* White-on-red has way higher contrast than black-on-red on the
* actual e-paper - the black ink and the red ink absorb similar
* amounts of light, so black text on red reads as a muddy
* monochrome blob. White (paper) on red is the print-magazine
* standard for a reason. */
c.fillRect(bx, by, bw, bh, acc);
const ax = bx + padX;
const ay = by + ((bh - arrowSize.h) >> 1);
c.drawTextWhite(ax, ay, arrow, 1);
const px = ax + arrowSize.w + gap;
const py = by + ((bh - pctSize.h) >> 1);
c.drawTextWhite(px, py, pct, 1);
}
/* ── Big price ─────────────────────────────────────────────── */
const priceTxt = formatPrice(price, prefix);
/* Pick the largest scale that fits. */
let pscale = W >= 280 ? 4 : W >= 200 ? 3 : 2;
while (pscale > 1 && c.textSize(priceTxt, pscale).w > W - 2 * margin - 6) pscale--;
const pSize = c.textSize(priceTxt, pscale);
const priceY = headerY + symH + Math.max(6, Math.floor(H / 14));
const priceX = (W - pSize.w) >> 1;
c.drawText(priceX, priceY, priceTxt, blk, pscale);
/* ── Sparkline + accent fill ─────────────────────────────── */
if (hist.length >= 2) {
/* No underline now - the price headline breathes and the sparkline
* gets the full lower half of the card. */
const sparkTop = priceY + pSize.h + Math.max(6, Math.floor(H / 14));
const sparkBot = H - margin - 4;
const sparkH = sparkBot - sparkTop;
const sparkX = margin + 4;
const sparkW = W - 2 * (margin + 4);
if (sparkH > 8) {
let lo = Infinity, hi = -Infinity;
for (const v of hist) { if (v < lo) lo = v; if (v > hi) hi = v; }
if (hi === lo) hi = lo + 1;
const xs: number[] = [];
const ys: number[] = [];
for (let i = 0; i < hist.length; i++) {
const px = sparkX + Math.round((i * (sparkW - 1)) / (hist.length - 1));
const py = sparkTop + sparkH - 1 -
Math.round(((hist[i] - lo) * (sparkH - 1)) / (hi - lo));
xs.push(px); ys.push(py);
}
/* Accent stippled fill below the curve - the visual hero. */
fillUnderCurve(c, xs, ys, sparkBot - 1, acc);
/* Curve itself in BLACK, drawn thick so it pops over the
* stippled fill - this is the "real" line the eye reads. */
for (let i = 1; i < xs.length; i++) {
c.line(xs[i - 1], ys[i - 1], xs[i], ys[i], blk);
/* Add a 1-pixel "halo" above for extra weight. */
if (ys[i] > sparkTop)
c.line(xs[i - 1], ys[i - 1] - 1, xs[i], ys[i] - 1, blk);
}
/* Baseline rule. */
c.hline(sparkX, sparkBot, sparkW, blk);
/* Latest value pin: small accent dot + black ring. */
const lx = xs[xs.length - 1], ly = ys[ys.length - 1];
c.fillRect(lx - 2, ly - 2, 5, 5, acc);
c.rect(lx - 2, ly - 2, 5, 5, blk);
}
}
return c;
},
};
@@ -0,0 +1,337 @@
/*
* GitHub Profile plugin - "trading card" badge.
*
* Tuned for the 208x112 tag (the common case); scales to any size.
*
*
* @torvalds accent stripe + knockout
*
* AVATAR LINUS TORVALDS scale-2 name
* round Creator of Linux optional 1-line bio
*
*
* 178K 234 712
* STARS FOLLOWERS REPOS
*
*
* Palette:
* - Accent: top stripe + the STARS number.
* - Black: typography, frame, dividers, dithered avatar.
* - White: paper, knockout @handle in the stripe.
*
* Data: api.github.com only - the heatmap API was the slowest leg of
* the previous render and the trading-card layout has no heatmap, so
* we drop that fetch entirely. Render p99 went from ~1.4s to ~600ms.
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
import { fetchImageGray, blitGrayDither } from "../image_util";
interface GhUser {
login: string;
name: string | null;
bio: string | null;
avatar_url: string;
followers: number;
public_repos: number;
}
interface GhRepoLite { stargazers_count: number; }
async function fetchUser(login: string): Promise<GhUser | null> {
try {
const r = await fetch(`https://api.github.com/users/${encodeURIComponent(login)}`, {
headers: { "User-Agent": "TagTinker/1.0", "Accept": "application/vnd.github+json" },
});
if (!r.ok) return null;
return await r.json() as GhUser;
} catch { return null; }
}
async function fetchTotalStars(login: string): Promise<number | null> {
try {
const r = await fetch(
`https://api.github.com/users/${encodeURIComponent(login)}/repos` +
`?per_page=100&sort=pushed`,
{ headers: { "User-Agent": "TagTinker/1.0", "Accept": "application/vnd.github+json" } });
if (!r.ok) return null;
const repos: GhRepoLite[] = await r.json();
let total = 0;
for (const r of repos) total += r.stargazers_count | 0;
return total;
} catch { return null; }
}
function compact(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(n < 10_000 ? 1 : 0) + "K";
return String(n);
}
function ascii(s: string): string {
return s
.replace(/[\u2018\u2019\u02BC]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/[\u2013\u2014]/g, "-")
.replace(/\u2026/g, "...")
.replace(/[^\x20-\x7E]/g, "");
}
function truncate(s: string, max: number): string {
if (max <= 0) return "";
if (s.length <= max) return s;
if (max <= 3) return s.slice(0, max);
return s.slice(0, max - 3) + "...";
}
/* Deterministic filler lines for users with no bio. The choice is
* keyed on the login so the same person always gets the same quip
* across renders (otherwise it'd flicker between each refresh and
* read as a glitch rather than a feature). Kept short so it fits
* on one line at the 208x112 column width (~22 chars). */
const BIO_FILLERS: string[] = [
"no bio. just vibes.",
"git push origin self",
"probably refactoring.",
"404: bio not found.",
"lurking in repos.",
"shipping, not updating.",
"lives in the terminal.",
"touch grass? maybe later.",
"compiling thoughts...",
"reads code for fun.",
"bio loading...",
"rm -rf /writers-block",
];
function pickFiller(login: string): string {
let h = 0;
for (let i = 0; i < login.length; i++) h = (h * 31 + login.charCodeAt(i)) | 0;
return BIO_FILLERS[Math.abs(h) % BIO_FILLERS.length];
}
/** Greedy word-wrap into at most `maxLines` lines of `maxChars`.
* Words that don't fit the current line are bumped to the next
* line (never split mid-word). Anything that doesn't fit in the
* vertical budget is dropped silently - no ellipsis, no mid-word
* cut, the bio just stops on a clean word break. */
function wrapText(s: string, maxChars: number, maxLines: number): string[] {
if (maxChars <= 0 || maxLines <= 0) return [];
const words = s.split(/\s+/).filter(Boolean);
const lines: string[] = [];
let cur = "";
for (let i = 0; i < words.length && lines.length < maxLines; i++) {
const w = words[i];
const cand = cur ? cur + " " + w : w;
if (cand.length <= maxChars) {
cur = cand;
} else {
if (cur) {
lines.push(cur);
if (lines.length >= maxLines) { cur = ""; break; }
}
/* If a single word is wider than the column we still need to
* place it somewhere - otherwise the bio could go entirely
* blank for a user whose first word is e.g. an absurdly long
* URL. Hard-break it on column boundaries (rare path). */
if (w.length <= maxChars) {
cur = w;
} else {
let rest = w;
while (rest.length > maxChars && lines.length < maxLines) {
lines.push(rest.slice(0, maxChars));
rest = rest.slice(maxChars);
}
cur = lines.length < maxLines ? rest : "";
}
}
}
if (cur && lines.length < maxLines) lines.push(cur);
return lines;
}
export const githubPlugin: Plugin = {
manifest: {
id: "github",
name: "GitHub Profile",
description: "Trading-card style profile badge with stars/followers/repos",
accent_modes: 1 | 2 | 4,
params: [
{ key: "username", label: "Username", type: "string", default: "torvalds" },
],
},
async render(params, W, H, accent: AccentMode) {
const login = ((params.username ?? "torvalds").trim() || "torvalds");
const planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
const acc: Ink = planes === 2 ? 1 : 0;
const blk: Ink = 0;
const [user, stars] = await Promise.all([
fetchUser(login),
fetchTotalStars(login),
]);
if (!user) {
const t1 = "USER NOT FOUND";
const t2 = "@" + login;
const s1 = c.textSize(t1, 2), s2 = c.textSize(t2, 1);
c.drawText((W - s1.w) >> 1, (H >> 1) - s1.h, t1, blk, 2);
c.drawText((W - s2.w) >> 1, (H >> 1) + 2, t2, blk, 1);
c.rect(0, 0, W, H, blk);
return c;
}
/* Geometry
* Three horizontal bands:
* 1. stripe - thin accent bar with knockout @handle
* 2. portrait - circular avatar + display name + (optional bio)
* 3. stats - 3-column number panel (stars / followers / repos)
* Coordinates are derived from W/H so a 296x128 tag gets a roomier
* version of the same card for free, but everything is sized so
* the 208x112 layout stays tight and balanced. */
const stripeH = 11;
const padX = 4;
/* Stats band height = scale-2 number (14) + 2-px gap + scale-1
* label (7) + 4-px breathing room top/bot = 31 px. We push the
* divider as far down as that constraint allows so the avatar
* gets the rest of the page. On 112-tall it lands at ~78. */
const statsBandH = 7 + 2 + 14 + 4 + 4;
const dividerY = H - statsBandH;
/* 1. Top accent stripe with knockout handle
* fillRect first (accent ink), then drawTextWhite punches the
* @handle out of it, leaving paper showing through the letters.
* Looks like a printed magazine title bar. */
c.fillRect(0, 0, W, stripeH, acc);
/* Show the full short URL ("github.com/<login>") - reads as a
* proper profile link instead of just an @handle. If the login
* is too long for the column we drop back to "@<login>". */
const fullLink = "github.com/" + ascii(user.login);
const linkMax = Math.max(1, Math.floor((W - 12) / 6));
const linkStr = fullLink.length <= linkMax
? fullLink
: truncate("@" + ascii(user.login), linkMax);
const linkSize = c.textSize(linkStr, 1);
c.drawTextWhite((W - linkSize.w) >> 1, (stripeH - 7) >> 1, linkStr, 1);
/* 2. Portrait band: square avatar + name + bio
* Avatar is a clean square so it gets the full pixel budget
* for the dithered face (no corners wasted to a circle mask).
* Size scales with the band height so a wider tag gets a
* bigger picture automatically. */
const bandTop = stripeH + 2;
const bandBot = dividerY - 2;
const bandH = bandBot - bandTop;
const avSize = Math.min(64, bandH); // 60 on 112-tall
const avX = padX + 4;
const avY = bandTop + ((bandH - avSize) >> 1);
const avatarUrl = user.avatar_url.includes("?")
? `${user.avatar_url}&s=${avSize * 2}`
: `${user.avatar_url}?s=${avSize * 2}`;
const avatar = await fetchImageGray(avatarUrl);
if (avatar) {
blitGrayDither(c, avX + 1, avY + 1, avSize - 2, avSize - 2, avatar, blk);
} else {
const ini = (user.login.charAt(0) || "?").toUpperCase();
const sz = c.textSize(ini, 4);
c.drawText(avX + ((avSize - sz.w) >> 1),
avY + ((avSize - sz.h) >> 1), ini, blk, 4);
}
/* 1-px square frame so the dithered face has a clean edge. */
c.rect(avX, avY, avSize, avSize, blk);
/* Name + (optional) bio, right of the avatar. */
const tX = avX + avSize + 8;
const tW = W - tX - padX;
const nameRaw = (user.name && user.name.trim() ? user.name : user.login).trim();
const nameAscii = ascii(nameRaw).toUpperCase();
/* Prefer scale 2; drop to scale 1 if it doesn't fit. Bold is
* faux'd by drawing the glyphs twice with a 1-px x-offset -
* a classic bitmap-font trick that keeps the 5x7 readable
* while giving the name visible weight. */
const max2 = Math.max(1, Math.floor(tW / 12));
const max1 = Math.max(1, Math.floor(tW / 6));
const nameScale = c.textSize(nameAscii, 2).w <= tW && nameAscii.length <= max2 ? 2 : 1;
const nameStr = truncate(nameAscii, nameScale === 2 ? max2 : max1);
const nameH = 7 * nameScale;
/* Anchor the name to the top of the avatar (visually aligned
* with the picture) and let the bio flow downward. */
const blockTop = avY + 1;
c.drawText(tX, blockTop, nameStr, blk, nameScale);
c.drawText(tX + 1, blockTop, nameStr, blk, nameScale); // faux-bold
/* Flexible bio
* Wrap into however many lines fit between the name and the
* divider. Lines that don't fit get an ellipsis on the last
* surviving line, so the user can tell text was truncated.
* If the user has no bio at all we fall back to a deterministic
* filler line so the slot doesn't read as a render bug. */
{
const bioText = user.bio && user.bio.trim()
? ascii(user.bio).trim()
: pickFiller(user.login);
const bioTop = blockTop + nameH + 3;
const bioBudget = bandBot - bioTop;
/* Glyphs are 7 px tall - flush them with no inter-line gap so
* we squeeze the maximum number of full lines into the band.
* Descenders aren't a concern in a 5x7 bitmap font. */
const lineH = 7;
const maxLines = Math.max(0, Math.floor((bioBudget + 1) / lineH));
if (maxLines > 0) {
const lines = wrapText(bioText, max1, maxLines);
let by = bioTop;
for (const ln of lines) {
c.drawText(tX, by, ln, blk, 1);
by += lineH;
}
}
}
/* 3. Hairline divider
* Short of the side margins so it reads as a rule, not a
* frame extension. */
c.hline(padX + 4, dividerY, W - 2 * (padX + 4), blk);
/* 4. Stats panel: 3 equal columns
* Numbers in scale 2, labels in scale 1, vertical hairlines
* between columns. The STARS number gets accent ink so the
* eye lands on it first. */
const statsTop = dividerY + 2;
const statsBot = H - 2;
const colW = Math.floor((W - 2 * padX) / 3);
const stats: Array<{ num: string; label: string; accent: boolean }> = [
{ num: compact(stars ?? 0), label: "STARS", accent: true },
{ num: compact(user.followers), label: "FOLLOWERS", accent: false },
{ num: compact(user.public_repos), label: "REPOS", accent: false },
];
/* Number sits directly above the label with a 2-px gap, both
* pinned to the bottom of the band. This keeps the divider /
* portrait band tall while the stats hug the bottom edge. */
const lblY = statsBot - 8;
const numY = lblY - 14 - 2;
for (let i = 0; i < 3; i++) {
const s = stats[i];
const cx = padX + colW * i + (colW >> 1);
const ns = c.textSize(s.num, 2);
c.drawText(cx - (ns.w >> 1), numY, s.num, s.accent ? acc : blk, 2);
const ls = c.textSize(s.label, 1);
c.drawText(cx - (ls.w >> 1), lblY, s.label, blk, 1);
}
/* Column dividers: a couple of pixels in from the band edges
* so they don't kiss the outer frame. */
for (let i = 1; i < 3; i++) {
const dx = padX + colW * i;
c.vline(dx, statsTop + 2, statsBot - statsTop - 4, blk);
}
/* Outer 1-px frame
* Drawn last so it overlays the stripe corners cleanly. */
c.rect(0, 0, W, H, blk);
return c;
},
};
@@ -0,0 +1,392 @@
/*
* Identicon plugin - DEF CON / hardware-hacker badge aesthetic.
*
* The card is laid out like an actual electronics badge: a "chip" on the
* left holding the symmetric pixel-art identicon (with silkscreen border
* and orientation notch), a typographic stack on the right with the
* callsign in big type / a hex UID / a role tag, PCB-style pin headers
* along the top edge, and a binary "barcode" footer that's deterministic
* from the seed so two people with the same name get the same pattern -
* exactly like a real con badge. Accent ink (red/yellow on tri-colour
* tags) carries the role tag, chip notch, pin row and a few signature
* highlights so the card pops in a photo.
*
* Avatar sources:
* - "local" : the built-in symmetric 5x5/7x7/9x9 pixel grid.
* - DiceBear : pixel-art / bottts / lorelei / micah / adventurer /
* fun-emoji / shapes / pixel - fetched from the public
* DiceBear API as a small PNG, decoded with upng-js,
* and Floyd-Steinberg dithered into the chip area.
* Same callsign always picks the same face because
* the seed is the literal name.
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
import { fetchImageGray, blitGrayDither } from "../image_util";
/* FNV-1a 32-bit. Used everywhere we need a deterministic seed. */
function hash32(s: string): number {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
}
return h >>> 0;
}
/* xorshift32 PRNG factory. */
function rng(seed: number) {
let r = seed || 0xCAFEBABE;
return () => {
r ^= r << 13; r >>>= 0;
r ^= r >>> 17;
r ^= r << 5; r >>>= 0;
return r;
};
}
/* Hacker-flavoured class tags. Pick deterministically from the seed
* so the same name always gets the same role - just like a real con
* where your callsign is the immutable part of your identity. */
const ROLES = [
"OPERATOR", "ANALYST", "PHREAK", "CRACKER",
"HACKER", "AGENT", "GHOST", "ROGUE",
"ARTIST", "WIZARD", "SCOUT", "CIPHER",
];
/* DiceBear style ids supported by this plugin. The empty string maps to
* the local generator. Order matters - it's the order shown in the FAP
* dropdown. */
const STYLES = [
"local", "pixel-art", "bottts", "lorelei",
"adventurer", "micah", "fun-emoji", "shapes",
];
/* DiceBear avatar URL builder. The actual fetch / decode / dither is
* done by the shared `image_util` helpers so every other image plugin
* (GitHub avatars, NASA APOD, etc.) shares the same pipeline. */
function dicebearUrl(style: string, seed: string, size: number): string {
return `https://api.dicebear.com/9.x/${encodeURIComponent(style)}` +
`/png?seed=${encodeURIComponent(seed)}` +
`&size=${size}` +
`&backgroundColor=ffffff`;
}
/* ─── Drawing helpers ─────────────────────────────────────────────── */
/** A dashed horizontal line - reads as a PCB trace. */
function dashedHLine(c: Canvas, x: number, y: number, w: number, dash: number, gap: number, ink: Ink) {
let xx = 0;
while (xx < w) {
const len = Math.min(dash, w - xx);
for (let i = 0; i < len; i++) c.setPixel(x + xx + i, y, ink);
xx += dash + gap;
}
}
/** Pin-header row: alternating filled/empty squares like an IC pin
* strip. Used at the top of the badge for hardware authenticity. */
function pinHeader(c: Canvas, x: number, y: number, count: number,
pin: number, gap: number, ink: Ink) {
for (let i = 0; i < count; i++) {
const px = x + i * (pin + gap);
if ((i & 1) === 0) {
// filled square
for (let dy = 0; dy < pin; dy++)
for (let dx = 0; dx < pin; dx++)
c.setPixel(px + dx, y + dy, ink);
} else {
// empty square (outline only)
c.rect(px, y, pin, pin, ink);
}
}
}
/** Chip silkscreen: rounded-corner rectangle with a notch on top
* (the universal "this is an IC" affordance). */
function chipFrame(c: Canvas, x: number, y: number, w: number, h: number,
notchInk: Ink, frameInk: Ink) {
// Outer frame
c.rect(x, y, w, h, frameInk);
// Inner shadow line for "engraved" feel
c.rect(x + 2, y + 2, w - 4, h - 4, frameInk);
// Top-centre orientation notch (semicircle approximation in 5 px)
const nx = x + (w >> 1) - 2;
const ny = y;
// Fill paper above the notch then re-draw with notch ink.
for (let i = 0; i < 5; i++) c.whitePixel(nx + i, ny);
for (let i = 0; i < 5; i++) c.whitePixel(nx + i, ny + 1);
// Notch arc
c.setPixel(nx + 1, ny, notchInk);
c.setPixel(nx + 2, ny, notchInk);
c.setPixel(nx + 3, ny, notchInk);
c.setPixel(nx, ny + 1, notchInk);
c.setPixel(nx + 4, ny + 1, notchInk);
c.setPixel(nx + 1, ny + 2, notchInk);
c.setPixel(nx + 2, ny + 2, notchInk);
c.setPixel(nx + 3, ny + 2, notchInk);
}
/** Crosshair / reticle - decorative element. */
function crosshair(c: Canvas, cx: number, cy: number, r: number, ink: Ink) {
c.line(cx - r, cy, cx + r, cy, ink);
c.line(cx, cy - r, cx, cy + r, ink);
// Outer tick marks
c.setPixel(cx - r - 2, cy, ink);
c.setPixel(cx + r + 2, cy, ink);
c.setPixel(cx, cy - r - 2, ink);
c.setPixel(cx, cy + r + 2, ink);
// Centre dot
c.setPixel(cx, cy, ink);
}
/** Binary "barcode" strip derived from a seed. Each bit becomes a
* filled or empty cell of the given width. Looks like a hardware
* serial barcode, reads as cyberpunk decoration. */
function binaryBar(c: Canvas, x: number, y: number, totalW: number, h: number,
seed: number, ink: Ink) {
const cellW = 2;
const cells = Math.floor(totalW / cellW);
let r = seed;
for (let i = 0; i < cells; i++) {
r ^= r << 13; r >>>= 0;
r ^= r >>> 17;
r ^= r << 5; r >>>= 0;
if ((r & 0xFF) > 110) {
for (let dy = 0; dy < h; dy++)
for (let dx = 0; dx < cellW; dx++)
c.setPixel(x + i * cellW + dx, y + dy, ink);
}
}
}
/* ─── Plugin ─────────────────────────────────────────────────────── */
export const identiconPlugin: Plugin = {
manifest: {
id: "identicon",
name: "Identicon",
description: "DEF CON-style hacker badge from a callsign",
accent_modes: 1 | 2 | 4,
params: [
{ key: "name", label: "Callsign", type: "string", default: "Dolphin" },
{ key: "style", label: "Style", type: "enum", default: "pixel-art",
options: STYLES },
/* These two only matter when style == "local" - the DiceBear
* styles render a pre-composed image and ignore them. */
{ key: "grid", label: "Grid", type: "enum", default: "7",
options: ["5", "7", "9"] },
{ key: "subStyle", label: "Local Pat.", type: "enum", default: "blocky",
options: ["blocky", "rings", "diagonal"] },
],
},
async render(params, W, H, accent: AccentMode) {
const name = ((params.name ?? "Dolphin").trim() || "Dolphin").toUpperCase();
const style = params.style ?? "pixel-art";
const grid = parseInt(params.grid ?? "7", 10);
const subStyle = params.subStyle ?? "blocky";
const seed = hash32(name);
const planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
const acc: Ink = planes === 2 ? 1 : 0;
const blk: Ink = 0;
/* ── Geometry ──────────────────────────────────────────────── */
const margin = 4;
const headerH = 8; // top pin row + trace
const footerH = 12; // bottom barcode + tagline
/* ── Top pin header strip ──────────────────────────────────── */
{
const pinSize = 3;
const pinGap = 2;
const stride = pinSize + pinGap;
const count = Math.floor((W - margin * 2) / stride);
const px = margin + 1;
const py = margin;
pinHeader(c, px, py, count, pinSize, pinGap, acc);
}
/* PCB trace just under the pin row, with a gap in the middle for
* a small label tag. */
{
const traceY = margin + 8;
const tagText = `\u25C6 DEFCON 2026 \u25C6`;
const tagSize = c.textSize(tagText, 1);
const tagX = (W - tagSize.w) >> 1;
// Left dashed segment.
dashedHLine(c, margin + 4, traceY, tagX - margin - 8, 4, 2, blk);
// Right dashed segment.
const rStart = tagX + tagSize.w + 4;
dashedHLine(c, rStart, traceY, W - margin - 4 - rStart, 4, 2, blk);
// Tag label centred.
c.drawText(tagX, traceY - 3, tagText, blk, 1);
}
/* ── Identicon "chip" on the left ─────────────────────────── */
const bodyTop = margin + headerH + 8;
const bodyBot = H - footerH - margin;
const bodyH = bodyBot - bodyTop;
const chipSize = Math.min(bodyH, Math.floor(W * 0.42));
const chipX = margin + 2;
const chipY = bodyTop + ((bodyH - chipSize) >> 1);
chipFrame(c, chipX, chipY, chipSize, chipSize, acc, blk);
/* Inner area where the avatar lives - the chip frame eats 6 px on
* each side so the avatar isn't crowding the silkscreen. */
const inset = 6;
const innerW = chipSize - inset * 2;
const innerH = chipSize - inset * 2;
const innerX = chipX + inset;
const innerY = chipY + inset;
if (style === "local") {
/* Built-in symmetric pixel grid (zero-network). */
const cell = Math.floor(innerW / grid);
const used = cell * grid;
const ax = chipX + ((chipSize - used) >> 1);
const ay = chipY + ((chipSize - used) >> 1);
const half = Math.ceil(grid / 2);
const next = rng(seed);
const cells: boolean[][] = [];
for (let y = 0; y < grid; y++) {
cells.push([]);
for (let x = 0; x < half; x++) {
let on = (next() & 0xFF) > 110;
if (subStyle === "rings") {
const dx = x - (half - 1) / 2;
const dy = y - (grid - 1) / 2;
on = on !== ((Math.round(Math.sqrt(dx * dx + dy * dy)) & 1) === 0);
} else if (subStyle === "diagonal") {
on = on !== (((x + y) & 1) === 0);
}
cells[y].push(on);
}
}
for (let y = 0; y < grid; y++) {
for (let x = 0; x < grid; x++) {
const sx = x < half ? x : grid - 1 - x;
if (cells[y][sx]) {
c.fillRect(ax + x * cell, ay + y * cell, cell, cell, blk);
}
}
}
} else {
/* DiceBear-rendered avatar: fetch a small PNG (max(innerW, 96))
* for crisp dithering, then Floyd-Steinberg into the chip. We
* fall through to the local generator on any network failure
* so a flaky connection still produces a valid badge. */
const fetchSize = Math.max(96, innerW);
const dib = await fetchImageGray(dicebearUrl(style, name, fetchSize));
if (dib) {
blitGrayDither(c, innerX, innerY, innerW, innerH, dib, blk);
} else {
/* Soft fallback: a single accented "?" in the chip area so the
* user knows the avatar fetch failed but the rest of the
* badge is still useful. */
const q = "?";
const qs = c.textSize(q, 4);
c.drawText(innerX + ((innerW - qs.w) >> 1),
innerY + ((innerH - qs.h) >> 1),
q, acc, 4);
}
}
/* Tiny crosshair in the chip's bottom-right corner - reads as a
* registration/orientation mark, very PCB. */
crosshair(c, chipX + chipSize - 6, chipY + chipSize - 6, 2, acc);
/* ── Right column: callsign / UID / role tag ──────────────── */
const rightX = chipX + chipSize + 8;
const rightW = W - rightX - margin - 2;
/* CALLSIGN: pick the largest scale the name fits in. */
let callScale = 3;
while (callScale > 1 && c.textSize(name, callScale).w > rightW) callScale--;
const callY = bodyTop + 2;
c.drawText(rightX, callY, name.slice(0, 12), blk, callScale);
const callH = c.textSize(name, callScale).h;
/* Black underline below callsign - thick statement bar. */
{
const ulY = callY + callH + 2;
const ulW = Math.min(rightW - 4, c.textSize(name.slice(0, 12), callScale).w);
for (let i = 0; i < 2; i++) c.hline(rightX, ulY + i, ulW, blk);
}
/* UID line: "UID 0xA4F1B2C3" in mono. */
const uidY = callY + callH + 8;
const uidStr = "UID 0x" + (seed >>> 0).toString(16).toUpperCase().padStart(8, "0");
c.drawText(rightX, uidY, uidStr, blk, 1);
/* Role pill: "OPERATOR" etc. in solid accent block with white
* knockout text - matches the crypto badge styling. */
{
const role = ROLES[seed % ROLES.length];
const roleSize = c.textSize(role, 1);
const padX = 4;
const padY = 3;
const bw = roleSize.w + padX * 2;
const bh = roleSize.h + padY * 2;
const bx = rightX;
const by = uidY + roleSize.h + 6;
c.fillRect(bx, by, bw, bh, acc);
c.drawTextWhite(bx + padX, by + padY, role, 1);
}
/* "MEMBER SINCE 2026" small caps line, lower right. */
{
const labelY = bodyBot - 8;
const small = "MEMBER \u00B7 2026";
c.drawText(rightX, labelY, small, blk, 1);
}
/* ── Footer: binary barcode + tagline ─────────────────────── */
{
const fy = H - footerH - 1;
// Left & right ranger marks.
c.drawText(margin, fy + 4, "\u25A0\u25A0\u25A1\u25A0", blk, 1);
const tail = "\u25A0\u25A1\u25A0\u25A0";
const tailW = c.textSize(tail, 1).w;
c.drawText(W - margin - tailW, fy + 4, tail, blk, 1);
// Binary barcode strip in the middle.
const barX = margin + 28;
const barW = W - margin * 2 - 56;
binaryBar(c, barX, fy + 2, barW, 6, seed ^ 0xA5A5A5A5, blk);
}
/* ── Outer card border + corner brackets in accent ────────── */
c.rect(0, 0, W, H, blk);
/* Heavy corner brackets in accent give the card the "viewfinder"
* frame look that translates so well to a phone photo. */
const cornerLen = 8;
// top-left
for (let i = 0; i < 2; i++) {
c.hline(0, i, cornerLen, acc);
c.vline(i, 0, cornerLen, acc);
}
// top-right
for (let i = 0; i < 2; i++) {
c.hline(W - cornerLen, i, cornerLen, acc);
c.vline(W - 1 - i, 0, cornerLen, acc);
}
// bottom-left
for (let i = 0; i < 2; i++) {
c.hline(0, H - 1 - i, cornerLen, acc);
c.vline(i, H - cornerLen, cornerLen, acc);
}
// bottom-right
for (let i = 0; i < 2; i++) {
c.hline(W - cornerLen, H - 1 - i, cornerLen, acc);
c.vline(W - 1 - i, H - cornerLen, cornerLen, acc);
}
return c;
},
};
@@ -0,0 +1,180 @@
/*
* Weather plugin.
*
* Renders a clean, minimalist weather card:
*
*
* AMSTERDAM NL
*
* 12°C
* partly cloudy
*
* Mon 14° / 8° Tue 11° / 6°
*
*
* Procedural icons (sun, cloud, rain, snow, storm) drawn with the canvas
* primitives - they scale cleanly to any tag size and look distinctive
* even at the smallest 152x152 panels.
*
* Data source: wttr.in (free, no key, returns JSON when ?format=j1).
*/
import { Canvas, Ink } from "../canvas";
import { Plugin, AccentMode } from "../plugin";
type Sky = "sun" | "cloud" | "rain" | "snow" | "storm" | "fog";
function classify(code: number): Sky {
// wttr WWO weatherCode mapping to a small icon vocabulary.
if ([113].includes(code)) return "sun";
if ([116, 119, 122].includes(code)) return "cloud";
if ([143, 248, 260].includes(code)) return "fog";
if ([200, 386, 389, 392, 395].includes(code)) return "storm";
if ([179, 227, 230, 320, 323, 326, 329, 332, 335, 338, 350, 368, 371, 374, 377].includes(code))
return "snow";
return "rain";
}
function drawIcon(c: Canvas, sky: Sky, cx: number, cy: number, r: number, accent: Ink): void {
switch (sky) {
case "sun": {
// Filled disc + 8 rays in accent ink.
for (let y = -r; y <= r; y++)
for (let x = -r; x <= r; x++)
if (x * x + y * y <= (r - 1) * (r - 1)) c.setPixel(cx + x, cy + y, accent);
const rr = r + 2, R = r + r;
c.line(cx - R, cy, cx - rr, cy, accent);
c.line(cx + rr, cy, cx + R, cy, accent);
c.line(cx, cy - R, cx, cy - rr, accent);
c.line(cx, cy + rr, cx, cy + R, accent);
c.line(cx - R, cy - R, cx - rr, cy - rr, accent);
c.line(cx + rr, cy - rr, cx + R, cy - R, accent);
c.line(cx - R, cy + R, cx - rr, cy + rr, accent);
c.line(cx + rr, cy + rr, cx + R, cy + R, accent);
break;
}
case "cloud": {
// Three overlapping discs forming a cloud silhouette.
const blob = (ox: number, oy: number, br: number) => {
for (let y = -br; y <= br; y++)
for (let x = -br; x <= br; x++)
if (x * x + y * y <= br * br) c.setPixel(cx + ox + x, cy + oy + y, 0);
};
blob(-r, 2, Math.floor(r * 0.6));
blob(0, -2, Math.floor(r * 0.7));
blob(r - 2, 2, Math.floor(r * 0.6));
c.hline(cx - r, cy + Math.floor(r * 0.6), 2 * r, 0);
break;
}
case "rain":
drawIcon(c, "cloud", cx, cy, r, 0);
for (let i = -r; i <= r; i += 4) {
c.line(cx + i, cy + r + 2, cx + i - 2, cy + r + 6, accent);
}
break;
case "snow":
drawIcon(c, "cloud", cx, cy, r, 0);
for (let i = -r; i <= r; i += 5) {
const yy = cy + r + 4;
c.setPixel(cx + i, yy, accent);
c.setPixel(cx + i - 1, yy + 1, accent);
c.setPixel(cx + i + 1, yy + 1, accent);
c.setPixel(cx + i, yy + 2, accent);
}
break;
case "storm":
drawIcon(c, "cloud", cx, cy, r, 0);
// Lightning bolt
const bx = cx, by = cy + r;
c.line(bx, by, bx + 3, by + 4, accent);
c.line(bx + 3, by + 4, bx - 1, by + 5, accent);
c.line(bx - 1, by + 5, bx + 3, by + 9, accent);
break;
case "fog":
for (let i = 0; i < 4; i++) {
c.hline(cx - r + (i % 2) * 2, cy - r + i * 4, 2 * r - (i % 2) * 4, 0);
}
break;
}
}
async function fetchWeather(loc: string): Promise<any> {
const url = `https://wttr.in/${encodeURIComponent(loc)}?format=j1`;
const r = await fetch(url, { headers: { "User-Agent": "TagTinker/1.0" } });
if (!r.ok) throw new Error(`wttr ${r.status}`);
return await r.json();
}
export const weatherPlugin: Plugin = {
manifest: {
id: "weather",
name: "Weather",
description: "Live weather card with forecast",
accent_modes: 1 | 2 | 4,
params: [
{ key: "location", label: "Location", type: "string", default: "Paris" },
{ key: "units", label: "Units", type: "enum", default: "C", options: ["C", "F"] },
],
},
async render(params, W, H, accent: AccentMode) {
const loc = (params.location ?? "Paris").trim() || "Paris";
/* Defensive: only accept C or F. The Flipper used to (briefly) leak
* stale param values across plugins, which once produced "20°USD". */
const rawUnits = (params.units ?? "C").toUpperCase();
const units = rawUnits === "F" ? "F" : "C";
const data = await fetchWeather(loc);
const cur = data.current_condition?.[0] ?? {};
const code = parseInt(cur.weatherCode ?? "113", 10);
const sky = classify(code);
const tempC = parseFloat(cur.temp_C ?? "0");
const tempF = parseFloat(cur.temp_F ?? "32");
const desc = (cur.weatherDesc?.[0]?.value ?? "").toLowerCase();
const area = data.nearest_area?.[0];
const city = (area?.areaName?.[0]?.value ?? loc).toUpperCase();
const country = area?.country?.[0]?.value ?? "";
const planes: 1 | 2 = accent === "none" ? 1 : 2;
const c = new Canvas(W, H, planes);
const accentInk: Ink = planes === 2 ? 1 : 0;
const margin = W < 200 ? 4 : 8;
// Header: city + country on right.
c.drawText(margin, margin, city, 0, 1);
c.drawTextRight(W - margin, margin, country.toUpperCase(), 0, 1);
c.hline(margin, margin + 9, W - 2 * margin, 0);
// Big icon + temperature
const iconR = Math.min(20, Math.floor(H / 6));
const ix = margin + iconR + 4;
const iy = margin + 18 + iconR;
drawIcon(c, sky, ix, iy, iconR, accentInk);
const t = `${units === "F" ? Math.round(tempF) : Math.round(tempC)}\xB0${units}`;
let scale = W >= 260 ? 3 : 2;
while (scale > 1 && c.textSize(t, scale).w > W - ix - iconR - margin * 2) scale--;
c.drawText(ix + iconR + 8, iy - Math.floor(c.textSize(t, scale).h / 2), t, 0, scale);
// Description in italics-ish (just plain, but a row under).
c.drawText(margin, iy + iconR + 6, desc, 0, 1);
// 2-day forecast.
const fcs = data.weather?.slice(0, 2) ?? [];
if (fcs.length > 0) {
const baseY = H - margin - 9;
const cellW = Math.floor((W - 2 * margin) / fcs.length);
for (let i = 0; i < fcs.length; i++) {
const f = fcs[i];
const lo = units === "F" ? f.mintempF : f.mintempC;
const hi = units === "F" ? f.maxtempF : f.maxtempC;
const dn = f.date ? new Date(f.date).toLocaleDateString("en-US", { weekday: "short" }) : "";
const txt = `${dn} ${hi}\xB0/${lo}\xB0`;
c.drawText(margin + i * cellW, baseY, txt, 0, 1);
}
}
return c;
},
};
@@ -0,0 +1,35 @@
/* Minimal type shims for the CommonJS image libraries we use in the
* worker bundle. These libs have no official @types packages, and we
* only touch a tiny slice of each surface, so a hand-written shim is
* cheaper than wrestling with `allowJs`. */
declare module "jpeg-js" {
interface DecodedJpeg {
width: number;
height: number;
data: Uint8Array | Buffer;
}
interface JpegJs {
decode(buf: ArrayBuffer | Uint8Array,
opts?: { useTArray?: boolean; maxMemoryUsageInMB?: number; formatAsRGBA?: boolean })
: DecodedJpeg;
encode(rawImageData: { data: Uint8Array; width: number; height: number },
quality?: number): { data: Uint8Array; width: number; height: number };
}
const jpegJs: JpegJs;
export default jpegJs;
}
declare module "upng-js" {
interface DecodedPng {
width: number;
height: number;
depth: number;
ctype: number;
data: Uint8Array;
tabs: Record<string, unknown>;
frames: unknown[];
}
export function decode(buf: ArrayBuffer | Uint8Array): DecodedPng;
export function toRGBA8(out: DecodedPng): ArrayBuffer[];
}
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,7 @@
name = "tagtinker"
main = "src/index.ts"
compatibility_date = "2024-09-23"
# `npm run deploy` will publish to <name>.<account-subdomain>.workers.dev.
# Once deployed, point the ESP firmware at the resulting URL via
# the FAP "WiFi Setup -> Server URL" field.
@@ -0,0 +1,7 @@
# TagTinker WiFi firmware - ESP-IDF top-level CMake.
cmake_minimum_required(VERSION 3.16)
set(EXTRA_COMPONENT_DIRS shared)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(tagtinker_wifi)
@@ -0,0 +1,21 @@
# TagTinker WiFi cloud-renderer - thin firmware. All drawing and API
# integration lives in cloud-plugins/ (Cloudflare Worker); this binary
# is just a UART <-> HTTPS bridge.
idf_component_register(
SRCS
"main.c"
"wifi_link.c"
"wifi_net.c"
"cloud_client.c"
INCLUDE_DIRS "."
REQUIRES
nvs_flash
esp_wifi
esp_netif
esp_event
esp_http_client
esp-tls
driver
json
shared
)
@@ -0,0 +1,230 @@
/*
* Cloud client - implementation.
*
* Uses esp_http_client. URL escapes parameters before they're appended to
* the query string. The render path streams the response directly via
* the HTTP_EVENT_ON_DATA callback to avoid buffering the (potentially
* 50+ KB) framebuffer in memory.
*/
#include "cloud_client.h"
#include "esp_http_client.h"
#include "esp_crt_bundle.h"
#include "esp_log.h"
#include "nvs.h"
#include "nvs_flash.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char* TAG = "cloud";
static const char* NVS_NS = "tt_cloud";
static char s_base_url[128] = TT_CLOUD_DEFAULT_URL;
const char* cloud_client_url(void) { return s_base_url; }
void cloud_client_set_url(const char* url) {
if(!url || !*url) return;
strncpy(s_base_url, url, sizeof(s_base_url) - 1);
s_base_url[sizeof(s_base_url) - 1] = 0;
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READWRITE, &h) == ESP_OK) {
nvs_set_str(h, "url", s_base_url);
nvs_commit(h);
nvs_close(h);
}
}
void cloud_client_load(void) {
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READONLY, &h) != ESP_OK) return;
size_t l = sizeof(s_base_url);
if(nvs_get_str(h, "url", s_base_url, &l) != ESP_OK) {
strncpy(s_base_url, TT_CLOUD_DEFAULT_URL, sizeof(s_base_url) - 1);
}
nvs_close(h);
}
/* ---- Tiny URL builder --------------------------------------------------- */
static int hexv(uint8_t b) { return b < 10 ? '0' + b : 'a' + (b - 10); }
static void url_append_escaped(char* dst, size_t cap, size_t* pos, const char* s) {
while(*s && *pos + 4 < cap) {
unsigned char c = (unsigned char)*s++;
bool safe = (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') ||
(c >= 'a' && c <= 'z') || c == '-' || c == '_' || c == '.';
if(safe) {
dst[(*pos)++] = (char)c;
} else {
dst[(*pos)++] = '%';
dst[(*pos)++] = (char)hexv(c >> 4);
dst[(*pos)++] = (char)hexv(c & 0xF);
}
}
dst[*pos] = 0;
}
/* ---- /plugins ----------------------------------------------------------- */
typedef struct {
char* buf;
size_t cap;
size_t len;
} BodyBuf;
static esp_err_t body_evt(esp_http_client_event_t* e) {
BodyBuf* b = e->user_data;
if(e->event_id != HTTP_EVENT_ON_DATA) return ESP_OK;
size_t take = e->data_len;
if(b->len + take + 1 > b->cap) take = (b->cap > b->len + 1) ? (b->cap - b->len - 1) : 0;
if(take == 0) return ESP_OK;
memcpy(b->buf + b->len, e->data, take);
b->len += take;
b->buf[b->len] = 0;
return ESP_OK;
}
char* cloud_client_fetch_plugins_json(size_t* out_len) {
char url[256];
snprintf(url, sizeof(url), "%s/plugins", s_base_url);
BodyBuf b = { .buf = malloc(8192), .cap = 8192, .len = 0 };
if(!b.buf) return NULL;
b.buf[0] = 0;
esp_http_client_config_t cfg = {
.url = url,
.event_handler = body_evt,
.user_data = &b,
.timeout_ms = 20000,
.crt_bundle_attach = esp_crt_bundle_attach,
};
esp_http_client_handle_t c = esp_http_client_init(&cfg);
if(!c) { free(b.buf); return NULL; }
esp_http_client_set_header(c, "User-Agent", "TagTinker-WiFi/2.0");
esp_err_t r = esp_http_client_perform(c);
int code = esp_http_client_get_status_code(c);
esp_http_client_cleanup(c);
if(r != ESP_OK || code != 200) {
ESP_LOGW(TAG, "plugins: err=%d code=%d", r, code);
free(b.buf);
return NULL;
}
if(out_len) *out_len = b.len;
return b.buf;
}
/* ---- /render -- streaming -------------------------------------------- */
bool cloud_client_render(
const char* plugin_id,
uint16_t target_w, uint16_t target_h,
uint8_t accent,
const char* const* keys,
const char* const* values,
uint8_t n_params,
uint16_t* out_w, uint16_t* out_h,
uint8_t* out_planes, uint16_t* out_row_stride,
tt_cloud_chunk_cb chunk_cb, void* user,
char err_msg[64]) {
/* Build URL: <base>/render/<id>?w=<>&h=<>&accent=<>&<k>=<v>... */
char url[768];
size_t pos = 0;
int wrote = snprintf(url, sizeof(url), "%s/render/", s_base_url);
if(wrote < 0 || (size_t)wrote >= sizeof(url)) {
if(err_msg) snprintf(err_msg, 64, "url too long");
return false;
}
pos = (size_t)wrote;
url_append_escaped(url, sizeof(url), &pos, plugin_id);
int wrote2 = snprintf(url + pos, sizeof(url) - pos,
"?w=%u&h=%u&accent=%s",
(unsigned)target_w, (unsigned)target_h,
accent == 1 ? "red" : accent == 2 ? "yellow" : "none");
if(wrote2 < 0) {
if(err_msg) snprintf(err_msg, 64, "url build failed");
return false;
}
pos += (size_t)wrote2;
for(uint8_t i = 0; i < n_params; i++) {
if(pos + 4 >= sizeof(url)) break;
url[pos++] = '&';
url_append_escaped(url, sizeof(url), &pos, keys[i]);
url[pos++] = '=';
url_append_escaped(url, sizeof(url), &pos, values[i]);
}
url[pos] = 0;
/* We use the streaming HTTP API so we can read the 8-byte header
* first (and surface w/h/planes to the caller before forwarding any
* payload), then loop reading body bytes as they arrive. This lets
* the caller emit RESULT_BEGIN at exactly the right moment. */
esp_http_client_config_t cfg = {
.url = url,
.timeout_ms = 25000,
.crt_bundle_attach = esp_crt_bundle_attach,
.buffer_size = 1024,
};
esp_http_client_handle_t c = esp_http_client_init(&cfg);
if(!c) { if(err_msg) snprintf(err_msg, 64, "client init"); return false; }
esp_http_client_set_header(c, "User-Agent", "TagTinker-WiFi/2.0");
esp_err_t r = esp_http_client_open(c, 0);
if(r != ESP_OK) {
esp_http_client_cleanup(c);
if(err_msg) snprintf(err_msg, 64, "open %d", r);
return false;
}
int64_t total = esp_http_client_fetch_headers(c);
int code = esp_http_client_get_status_code(c);
if(code != 200) {
esp_http_client_close(c);
esp_http_client_cleanup(c);
if(err_msg) snprintf(err_msg, 64, "http %d", code);
return false;
}
(void)total;
/* Read the 8-byte header. */
uint8_t hdr[8]; size_t got = 0;
while(got < 8) {
int n = esp_http_client_read(c, (char*)hdr + got, 8 - got);
if(n <= 0) break;
got += (size_t)n;
}
if(got < 8) {
esp_http_client_close(c); esp_http_client_cleanup(c);
if(err_msg) snprintf(err_msg, 64, "short header");
return false;
}
uint16_t W = (uint16_t)hdr[0] | ((uint16_t)hdr[1] << 8);
uint16_t H = (uint16_t)hdr[2] | ((uint16_t)hdr[3] << 8);
uint8_t P = hdr[4];
uint16_t RS = (uint16_t)hdr[6] | ((uint16_t)hdr[7] << 8);
if(out_w) *out_w = W;
if(out_h) *out_h = H;
if(out_planes) *out_planes = P;
if(out_row_stride) *out_row_stride = RS;
/* Stream the rest in <= 512-byte chunks. */
uint8_t chunk[512];
while(true) {
int n = esp_http_client_read(c, (char*)chunk, sizeof(chunk));
if(n < 0) break;
if(n == 0) {
if(esp_http_client_is_complete_data_received(c)) break;
continue;
}
if(chunk_cb) chunk_cb(chunk, (uint16_t)n, user);
}
esp_http_client_close(c);
esp_http_client_cleanup(c);
return true;
}
@@ -0,0 +1,54 @@
/*
* Cloud plugin client.
*
* Talks to the TagTinker Cloudflare Worker that hosts plugin manifests
* and renders. Default URL is hard-coded below; override at runtime by
* calling cloud_client_set_url() (the value is persisted in NVS).
*
* GET <base>/plugins -> JSON manifest list
* GET <base>/render/<id>?...-> binary framebuffer (see worker docs)
*
* The framebuffer header (8 bytes, little-endian) is:
* uint16 width, uint16 height, uint8 planes, uint8 reserved,
* uint16 row_stride
* followed by `row_stride * height * planes` bytes of pixel data.
*/
#ifndef TT_CLOUD_CLIENT_H
#define TT_CLOUD_CLIENT_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
#define TT_CLOUD_DEFAULT_URL "https://tagtinker.jhackerr.workers.dev"
/* Persisted base URL (no trailing slash). */
const char* cloud_client_url(void);
void cloud_client_set_url(const char* url);
void cloud_client_load(void); /* call once after nvs_flash_init */
/* Forwarded JSON for /plugins. Caller frees with free(). NULL on failure. */
char* cloud_client_fetch_plugins_json(size_t* out_len);
/* Streaming /render call.
*
* Returns true on 200 OK. The header (width, height, planes, row_stride)
* is filled in before any chunk_cb is invoked. chunk_cb is then called
* one or more times with consecutive plane bytes (chunk_len <= 1024).
* Plane 0 is delivered first, then plane 1 (if planes==2). */
typedef void (*tt_cloud_chunk_cb)(const uint8_t* data, uint16_t len, void* user);
bool cloud_client_render(
const char* plugin_id,
uint16_t target_w, uint16_t target_h,
uint8_t accent, /* 0=none, 1=red, 2=yellow */
const char* const* keys,
const char* const* values,
uint8_t n_params,
/* Output header: */
uint16_t* out_w, uint16_t* out_h,
uint8_t* out_planes, uint16_t* out_row_stride,
tt_cloud_chunk_cb chunk_cb, void* user,
char err_msg[64]);
#endif /* TT_CLOUD_CLIENT_H */
@@ -0,0 +1,354 @@
/*
* TagTinker WiFi firmware - thin cloud-renderer.
*
* The ESP no longer carries plugins, fonts, drawing primitives or HTTP
* APIs for individual data sources. Instead it acts as a small bridge:
*
* Flipper LIST_PLUGINS -> GET <cloud>/plugins -> forward N x PLUGIN
* Flipper RUN_PLUGIN -> GET <cloud>/render/<id>?...-> forward as
* RESULT_BEGIN + N x CHUNK + RESULT_END.
*
* Plugins live in the Cloudflare Worker at the URL printed during
* `wrangler deploy`; updating plugins doesn't require re-flashing.
*
* A minimal cJSON-based parser converts the worker's /plugins JSON into
* the framed-protocol PLUGIN frames the FAP already understands, so the
* Flipper-side code is unchanged.
*/
#include "tt_wifi_proto.h"
#include "wifi_link.h"
#include "wifi_net.h"
#include "cloud_client.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "cJSON.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
static const char* TAG = "main";
/* ---- Encoding helpers --------------------------------------------------- */
typedef struct { uint8_t* p; uint16_t cap; uint16_t len; } Wb;
static void wb_init(Wb* b, uint8_t* buf, uint16_t cap) { b->p = buf; b->cap = cap; b->len = 0; }
static void wb_u8 (Wb* b, uint8_t v) { if(b->len < b->cap) b->p[b->len++] = v; }
static void wb_u16(Wb* b, uint16_t v) { wb_u8(b, v & 0xFF); wb_u8(b, v >> 8); }
static void wb_u32(Wb* b, uint32_t v) { wb_u16(b, v); wb_u16(b, v >> 16); }
static void wb_i32(Wb* b, int32_t v) { wb_u32(b, (uint32_t)v); }
static void wb_zstr(Wb* b, const char* s) {
if(!s) s = "";
size_t l = strlen(s);
if(l > 255) l = 255;
wb_u8(b, (uint8_t)l);
for(size_t i = 0; i < l && b->len < b->cap; i++) b->p[b->len++] = (uint8_t)s[i];
}
/* ---- Decoding helpers --------------------------------------------------- */
typedef struct { const uint8_t* p; uint16_t len; uint16_t pos; } Rb;
static void rb_init(Rb* r, const uint8_t* p, uint16_t len) { r->p = p; r->len = len; r->pos = 0; }
static bool rb_u8 (Rb* r, uint8_t* v) { if(r->pos + 1 > r->len) return false; *v = r->p[r->pos++]; return true; }
static bool rb_u16(Rb* r, uint16_t* v) { uint8_t a,b; if(!rb_u8(r,&a)||!rb_u8(r,&b)) return false; *v = (uint16_t)a | ((uint16_t)b << 8); return true; }
static bool rb_zstr(Rb* r, char* out, size_t cap) {
uint8_t l; if(!rb_u8(r, &l)) return false;
if(r->pos + l > r->len) return false;
size_t n = (l < cap - 1) ? l : cap - 1;
memcpy(out, r->p + r->pos, n);
out[n] = 0;
r->pos += l;
return true;
}
/* ---- HELLO / STATUS ---------------------------------------------------- */
static void send_hello(void) {
uint8_t buf[80]; Wb w; wb_init(&w, buf, sizeof(buf));
wb_u16(&w, 0x0200); /* fw version 2.0 (cloud) */
wb_u32(&w, esp_get_free_heap_size());
wb_zstr(&w, "TagTinker WiFi");
wifi_link_send(TT_FRAME_HELLO, buf, w.len);
}
static void send_wifi_status(void) {
uint8_t buf[80]; Wb w; wb_init(&w, buf, sizeof(buf));
wb_u8(&w, wifi_net_state());
wb_u8(&w, (uint8_t)(int8_t)wifi_net_rssi());
wb_zstr(&w, wifi_net_ssid());
wb_zstr(&w, wifi_net_ip());
wifi_link_send(TT_FRAME_WIFI_STATUS, buf, w.len);
}
/* ---- Cached plugins JSON --------------------------------------------- */
/* Filled by handle_list_plugins() and reused by handle_run_plugin() so we
* don't have to do a fresh TLS handshake just to translate plugin index
* back into id - critical on the S2 where we can barely fit one TLS
* session at a time. */
static cJSON* s_cached_root = NULL;
static const cJSON* s_cached_arr = NULL;
static void cached_set(cJSON* root) {
if(s_cached_root) { cJSON_Delete(s_cached_root); }
s_cached_root = root;
s_cached_arr = root ? cJSON_GetObjectItemCaseSensitive(root, "plugins") : NULL;
}
/* ---- /plugins JSON -> PLUGIN frames ----------------------------------- */
/* type tags from the worker -> wire type IDs the FAP expects. */
static uint8_t param_type_from(const char* t) {
if(!t) return 0;
if(strcmp(t, "string") == 0) return 0;
if(strcmp(t, "int") == 0) return 1;
if(strcmp(t, "enum") == 0) return 2;
if(strcmp(t, "bool") == 0) return 3;
return 0;
}
static void emit_plugin_from_json(int idx, const cJSON* p) {
uint8_t buf[TT_FRAME_MAX_PAYLOAD]; Wb w; wb_init(&w, buf, sizeof(buf));
wb_u8(&w, (uint8_t)idx);
const cJSON* id = cJSON_GetObjectItemCaseSensitive(p, "id");
const cJSON* name = cJSON_GetObjectItemCaseSensitive(p, "name");
const cJSON* desc = cJSON_GetObjectItemCaseSensitive(p, "description");
const cJSON* acc = cJSON_GetObjectItemCaseSensitive(p, "accent_modes");
const cJSON* params = cJSON_GetObjectItemCaseSensitive(p, "params");
wb_zstr(&w, cJSON_IsString(id) ? id->valuestring : "");
wb_zstr(&w, cJSON_IsString(name) ? name->valuestring : "");
wb_zstr(&w, cJSON_IsString(desc) ? desc->valuestring : "");
wb_u8 (&w, (uint8_t)(cJSON_IsNumber(acc) ? acc->valueint : 1));
int pc = cJSON_IsArray(params) ? cJSON_GetArraySize(params) : 0;
if(pc > 6) pc = 6;
wb_u8(&w, (uint8_t)pc);
for(int i = 0; i < pc; i++) {
const cJSON* sp = cJSON_GetArrayItem(params, i);
const cJSON* k = cJSON_GetObjectItemCaseSensitive(sp, "key");
const cJSON* l = cJSON_GetObjectItemCaseSensitive(sp, "label");
const cJSON* tt = cJSON_GetObjectItemCaseSensitive(sp, "type");
const cJSON* dv = cJSON_GetObjectItemCaseSensitive(sp, "default");
const cJSON* opts = cJSON_GetObjectItemCaseSensitive(sp, "options");
const cJSON* mn = cJSON_GetObjectItemCaseSensitive(sp, "min");
const cJSON* mx = cJSON_GetObjectItemCaseSensitive(sp, "max");
wb_zstr(&w, cJSON_IsString(k) ? k->valuestring : "");
wb_zstr(&w, cJSON_IsString(l) ? l->valuestring : "");
uint8_t pt = param_type_from(cJSON_IsString(tt) ? tt->valuestring : "string");
wb_u8(&w, pt);
wb_zstr(&w, cJSON_IsString(dv) ? dv->valuestring : "");
if(pt == 2) {
int oc = cJSON_IsArray(opts) ? cJSON_GetArraySize(opts) : 0;
if(oc > 8) oc = 8;
wb_u8(&w, (uint8_t)oc);
for(int j = 0; j < oc; j++) {
const cJSON* o = cJSON_GetArrayItem(opts, j);
wb_zstr(&w, cJSON_IsString(o) ? o->valuestring : "");
}
} else if(pt == 1) {
wb_i32(&w, cJSON_IsNumber(mn) ? mn->valueint : 0);
wb_i32(&w, cJSON_IsNumber(mx) ? mx->valueint : 100);
}
}
wifi_link_send(TT_FRAME_PLUGIN, buf, w.len);
}
static void handle_list_plugins(void) {
if(!wifi_net_wait_connected(8000)) {
wifi_link_send_error("WiFi not connected");
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
return;
}
size_t n = 0;
char* body = cloud_client_fetch_plugins_json(&n);
if(!body) {
wifi_link_send_error("plugin fetch failed");
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
return;
}
cJSON* root = cJSON_Parse(body);
free(body);
if(!root) {
wifi_link_send_error("plugin JSON parse failed");
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
return;
}
cached_set(root);
int total = cJSON_IsArray(s_cached_arr) ? cJSON_GetArraySize(s_cached_arr) : 0;
for(int i = 0; i < total && i < 16; i++) {
emit_plugin_from_json(i, cJSON_GetArrayItem(s_cached_arr, i));
}
wifi_link_send(TT_FRAME_PLUGINS_END, NULL, 0);
}
/* ---- /render -- plugin run -------------------------------------------- */
/* Shared state for the render flow: cloud_client fills the dims first,
* then drives our chunk_cb which emits RESULT_BEGIN on its first call,
* then RESULT_CHUNKs, then RESULT_END after the call returns. */
typedef struct {
bool begin_sent;
uint16_t* out_w;
uint16_t* out_h;
uint8_t* out_planes;
uint16_t* out_rs;
uint32_t total_bytes;
uint32_t recv_bytes;
uint8_t last_pct_emitted;
} RenderFwd;
static void render_chunk_cb(const uint8_t* data, uint16_t len, void* user) {
RenderFwd* f = user;
if(!f->begin_sent) {
uint8_t hdr[16]; uint16_t off = 0;
uint16_t w = *f->out_w, h = *f->out_h;
uint8_t p = *f->out_planes;
uint16_t rs = *f->out_rs;
hdr[off++] = (uint8_t)(w & 0xFF); hdr[off++] = (uint8_t)(w >> 8);
hdr[off++] = (uint8_t)(h & 0xFF); hdr[off++] = (uint8_t)(h >> 8);
hdr[off++] = p;
uint32_t total = (uint32_t)rs * h * p;
hdr[off++] = (uint8_t)(total & 0xFF);
hdr[off++] = (uint8_t)((total >> 8) & 0xFF);
hdr[off++] = (uint8_t)((total >> 16) & 0xFF);
hdr[off++] = (uint8_t)((total >> 24) & 0xFF);
wifi_link_send(TT_FRAME_RESULT_BEGIN, hdr, off);
f->begin_sent = true;
f->total_bytes = total;
f->recv_bytes = 0;
f->last_pct_emitted = 50;
wifi_link_send_progress(50, "Receiving image");
}
while(len > 0) {
uint16_t take = len > 512 ? 512 : len;
wifi_link_send(TT_FRAME_RESULT_CHUNK, data, take);
data += take; len -= take;
f->recv_bytes += take;
}
/* 50..95% during streaming. Emit at most one progress frame per 10%
* boundary - sending one after every chunk doubled the frame count
* and starved the Flipper's UART RX during the burst, which was
* dropping tail bytes (see tagtinker_wifi.c history). */
if(f->total_bytes) {
uint32_t pct = 50 + (f->recv_bytes * 45) / f->total_bytes;
if(pct > 95) pct = 95;
if(pct >= f->last_pct_emitted + 10 || (pct >= 95 && f->last_pct_emitted < 95)) {
wifi_link_send_progress((uint8_t)pct, "Receiving image");
f->last_pct_emitted = (uint8_t)pct;
}
}
}
static void handle_run_plugin(const uint8_t* payload, uint16_t len) {
Rb r; rb_init(&r, payload, len);
char id[32];
uint8_t accent = 0, n_params = 0;
uint16_t target_w = 0, target_h = 0;
uint8_t plugin_idx = 0;
if(!rb_u8(&r, &plugin_idx)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u16(&r, &target_w)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u16(&r, &target_h)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u8(&r, &accent)) { wifi_link_send_error("bad RUN frame"); return; }
if(!rb_u8(&r, &n_params)) { wifi_link_send_error("bad RUN frame"); return; }
/* Use the manifest cached during the most recent /plugins call - this
* avoids a second TLS handshake and the associated memory pressure. */
if(!s_cached_arr) {
wifi_link_send_error("no plugin cache (refresh first)"); return;
}
const cJSON* p = cJSON_GetArrayItem(s_cached_arr, plugin_idx);
const cJSON* idj = p ? cJSON_GetObjectItemCaseSensitive(p, "id") : NULL;
if(!cJSON_IsString(idj)) {
wifi_link_send_error("bad plugin index"); return;
}
strncpy(id, idj->valuestring, sizeof(id) - 1);
id[sizeof(id) - 1] = 0;
/* Read params into parallel arrays for cloud_client_render. */
static char keys[6][32];
static char vals[6][96];
const char* k_ptrs[6];
const char* v_ptrs[6];
if(n_params > 6) n_params = 6;
for(uint8_t i = 0; i < n_params; i++) {
if(!rb_zstr(&r, keys[i], sizeof(keys[i])) ||
!rb_zstr(&r, vals[i], sizeof(vals[i]))) {
wifi_link_send_error("bad param"); return;
}
k_ptrs[i] = keys[i];
v_ptrs[i] = vals[i];
}
if(!wifi_net_wait_connected(8000)) {
wifi_link_send_error("WiFi not connected"); return;
}
wifi_link_send_progress(15, "Connecting to cloud");
uint16_t rw = 0, rh = 0, rs = 0; uint8_t rp = 0;
RenderFwd fwd = {
.begin_sent = false,
.out_w = &rw, .out_h = &rh, .out_planes = &rp, .out_rs = &rs,
};
char err[64] = {0};
bool ok = cloud_client_render(
id, target_w, target_h, accent,
k_ptrs, v_ptrs, n_params,
&rw, &rh, &rp, &rs,
render_chunk_cb, &fwd, err);
if(!ok) {
wifi_link_send_error(err[0] ? err : "render failed");
return;
}
if(!fwd.begin_sent) {
wifi_link_send_error("empty body");
return;
}
wifi_link_send(TT_FRAME_RESULT_END, NULL, 0);
wifi_link_send_progress(100, "Done");
}
/* ---- WIFI_SET / WIFI_FORGET ------------------------------------------- */
static void handle_wifi_set(const uint8_t* payload, uint16_t len) {
Rb r; rb_init(&r, payload, len);
char ssid[33], pwd[65];
if(!rb_zstr(&r, ssid, sizeof(ssid)) || !rb_zstr(&r, pwd, sizeof(pwd))) {
wifi_link_send_error("bad WIFI_SET"); return;
}
wifi_net_set_creds(ssid, pwd);
send_wifi_status();
}
/* ---- Main RX dispatch --------------------------------------------------- */
static void on_frame(uint8_t type, const uint8_t* payload, uint16_t len, void* user) {
(void)user;
switch(type) {
case TT_FRAME_PING: wifi_link_send(TT_FRAME_PING, NULL, 0); break;
case TT_FRAME_WIFI_SET: handle_wifi_set(payload, len); break;
case TT_FRAME_WIFI_FORGET: wifi_net_forget(); send_wifi_status(); break;
case TT_FRAME_WIFI_STATUS: send_wifi_status(); break;
case TT_FRAME_LIST_PLUGINS: handle_list_plugins(); break;
case TT_FRAME_RUN_PLUGIN: handle_run_plugin(payload, len); break;
default: ESP_LOGW(TAG, "unhandled type 0x%02X", type); break;
}
}
void app_main(void) {
ESP_LOGI(TAG, "TagTinker cloud-renderer booting");
wifi_net_init();
cloud_client_load();
wifi_link_init(on_frame, NULL);
vTaskDelay(pdMS_TO_TICKS(200));
send_hello();
while(1) {
vTaskDelay(pdMS_TO_TICKS(2000));
send_wifi_status();
}
}
@@ -0,0 +1,187 @@
/*
* Framed UART link - implementation.
*
* UART config: UART1 by default on the Flipper WiFi Dev Board. The numbers
* are picked to match the FAP-side defaults; if you change them on one side
* you must change them on the other.
*
* pin TX = GPIO17, pin RX = GPIO18, baud 230400, no flow control.
*
* RX runs in a dedicated task that re-syncs on the 0xAA 0x55 SOF whenever
* a CRC mismatch or oversize frame is seen.
*/
#include "wifi_link.h"
#include "tt_wifi_proto.h"
#include "driver/uart.h"
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"
#include <string.h>
#include <stdio.h>
/* The Flipper Wi-Fi Devboard wires the ESP32-S2's UART0 (default
* IO_MUX pins U0TXD=GPIO43, U0RXD=GPIO44) to the Flipper. We claim
* UART0 for our framed binary protocol; the IDF console is silenced
* via CONFIG_ESP_CONSOLE_NONE=y so the two never collide. */
#define LINK_UART_NUM UART_NUM_0
#define LINK_PIN_TX UART_PIN_NO_CHANGE
#define LINK_PIN_RX UART_PIN_NO_CHANGE
#define LINK_BAUD 230400
#define LINK_RXBUF 4096
#define LINK_TXBUF 1024
static const char* TAG = "link";
static WifiLinkRxFn s_rx_cb;
static void* s_rx_user;
static SemaphoreHandle_t s_tx_lock;
bool wifi_link_send(uint8_t type, const uint8_t* payload, uint16_t len) {
if(len > TT_FRAME_MAX_PAYLOAD) {
ESP_LOGE(TAG, "tx: payload %u over limit", (unsigned)len);
return false;
}
/* Frame buffer: 2(SOF) + 1(type) + 2(len) + payload + 2(crc). */
uint8_t hdr[5];
hdr[0] = TT_FRAME_SOF0;
hdr[1] = TT_FRAME_SOF1;
hdr[2] = type;
hdr[3] = (uint8_t)(len & 0xFFU);
hdr[4] = (uint8_t)(len >> 8);
/* CRC over [type, len_lo, len_hi, payload...]. */
uint16_t crc = 0xFFFFU;
{
for(int i = 2; i < 5; i++) {
crc ^= (uint16_t)hdr[i] << 8;
for(int b = 0; b < 8; b++)
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U) : (uint16_t)(crc << 1);
}
for(uint16_t i = 0; i < len; i++) {
crc ^= (uint16_t)payload[i] << 8;
for(int b = 0; b < 8; b++)
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U) : (uint16_t)(crc << 1);
}
}
uint8_t tail[2] = { (uint8_t)(crc >> 8), (uint8_t)(crc & 0xFFU) };
xSemaphoreTake(s_tx_lock, portMAX_DELAY);
int ok = uart_write_bytes(LINK_UART_NUM, (const char*)hdr, sizeof(hdr));
if(ok > 0 && len) ok = uart_write_bytes(LINK_UART_NUM, (const char*)payload, len);
if(ok > 0) ok = uart_write_bytes(LINK_UART_NUM, (const char*)tail, sizeof(tail));
xSemaphoreGive(s_tx_lock);
return ok > 0;
}
bool wifi_link_send_progress(uint8_t percent, const char* msg) {
uint8_t buf[1 + 1 + 64];
if(!msg) msg = "";
size_t mlen = strnlen(msg, sizeof(buf) - 2);
buf[0] = percent;
buf[1] = (uint8_t)mlen;
memcpy(&buf[2], msg, mlen);
return wifi_link_send(TT_FRAME_PROGRESS, buf, (uint16_t)(2 + mlen));
}
bool wifi_link_send_error(const char* msg) {
uint8_t buf[1 + 96];
if(!msg) msg = "";
size_t mlen = strnlen(msg, sizeof(buf) - 1);
buf[0] = (uint8_t)mlen;
memcpy(&buf[1], msg, mlen);
return wifi_link_send(TT_FRAME_ERROR, buf, (uint16_t)(1 + mlen));
}
/* ---- RX task ----------------------------------------------------------- */
static inline uint16_t crc16_step(uint16_t crc, uint8_t b) {
crc ^= (uint16_t)b << 8;
for(int i = 0; i < 8; i++)
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U)
: (uint16_t)(crc << 1);
return crc;
}
static void rx_task(void* arg) {
(void)arg;
enum { S_SOF0, S_SOF1, S_TYPE, S_LEN_LO, S_LEN_HI, S_PAYLOAD, S_CRC_HI, S_CRC_LO } st = S_SOF0;
uint8_t type = 0;
uint16_t len = 0, idx = 0;
uint16_t crc_calc = 0xFFFFU;
uint16_t crc_recv = 0;
static uint8_t payload[TT_FRAME_MAX_PAYLOAD];
#define crc_step(B) (crc_calc = crc16_step(crc_calc, (B)))
while(1) {
uint8_t b;
int n = uart_read_bytes(LINK_UART_NUM, &b, 1, pdMS_TO_TICKS(1000));
if(n != 1) continue;
switch(st) {
case S_SOF0:
if(b == TT_FRAME_SOF0) st = S_SOF1;
break;
case S_SOF1:
st = (b == TT_FRAME_SOF1) ? S_TYPE : S_SOF0;
break;
case S_TYPE:
type = b; crc_calc = 0xFFFFU; crc_step(b); st = S_LEN_LO;
break;
case S_LEN_LO:
len = b; crc_step(b); st = S_LEN_HI;
break;
case S_LEN_HI:
len |= (uint16_t)b << 8;
crc_step(b);
if(len > TT_FRAME_MAX_PAYLOAD) { st = S_SOF0; break; }
idx = 0;
st = (len == 0) ? S_CRC_HI : S_PAYLOAD;
break;
case S_PAYLOAD:
payload[idx++] = b; crc_step(b);
if(idx >= len) st = S_CRC_HI;
break;
case S_CRC_HI:
crc_recv = (uint16_t)b << 8; st = S_CRC_LO;
break;
case S_CRC_LO:
crc_recv |= b;
if(crc_recv == crc_calc) {
if(s_rx_cb) s_rx_cb(type, payload, len, s_rx_user);
} else {
ESP_LOGW(TAG, "CRC mismatch type=0x%02X len=%u", type, len);
}
st = S_SOF0;
break;
}
}
}
void wifi_link_init(WifiLinkRxFn cb, void* user) {
s_rx_cb = cb;
s_rx_user = user;
s_tx_lock = xSemaphoreCreateMutex();
uart_config_t cfg = {
.baud_rate = LINK_BAUD,
.data_bits = UART_DATA_8_BITS,
.parity = UART_PARITY_DISABLE,
.stop_bits = UART_STOP_BITS_1,
.flow_ctrl = UART_HW_FLOWCTRL_DISABLE,
.source_clk = UART_SCLK_DEFAULT,
};
uart_driver_install(LINK_UART_NUM, LINK_RXBUF, LINK_TXBUF, 0, NULL, 0);
uart_param_config(LINK_UART_NUM, &cfg);
uart_set_pin(LINK_UART_NUM, LINK_PIN_TX, LINK_PIN_RX, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE);
/* RX task also runs the frame dispatch + cloud_client_render(). The
* mbedTLS handshake allocates ~6-8 KB on the stack during ECDHE +
* cert chain validation, so 16 KB gives a comfortable margin. The
* old 4 KB stack silently overflowed and locked the task. */
xTaskCreate(rx_task, "link_rx", 16384, NULL, 6, NULL);
}
@@ -0,0 +1,27 @@
/*
* Framed UART link to the Flipper.
*
* The link is a simple two-way pipe of TT_FRAME_* records (see
* shared/tt_wifi_proto.h). All TX is funnelled through wifi_link_send_*; all
* RX is delivered to a callback registered with wifi_link_set_handler().
*/
#ifndef TT_WIFI_LINK_H
#define TT_WIFI_LINK_H
#include <stdbool.h>
#include <stdint.h>
#include <stddef.h>
typedef void (*WifiLinkRxFn)(uint8_t type, const uint8_t* payload, uint16_t len, void* user);
void wifi_link_init(WifiLinkRxFn cb, void* user);
/* Send a fully-formed frame. Returns false if the payload is oversized or
* the UART driver couldn't accept it. */
bool wifi_link_send(uint8_t type, const uint8_t* payload, uint16_t len);
/* Convenience helpers for the most common frames. */
bool wifi_link_send_progress(uint8_t percent, const char* msg);
bool wifi_link_send_error (const char* msg);
#endif /* TT_WIFI_LINK_H */
@@ -0,0 +1,141 @@
/*
* WiFi station - implementation.
*/
#include "wifi_net.h"
#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"
#include "freertos/task.h"
#include <string.h>
static const char* TAG = "net";
static const char* NVS_NS = "tt_wifi";
static EventGroupHandle_t s_eg;
#define EV_CONNECTED (1U << 0)
#define EV_FAIL (1U << 1)
static char s_ssid[33];
static char s_pwd[65];
static char s_ip[16];
static int8_t s_rssi = 0;
static uint8_t s_state = TT_WIFI_DISCONNECTED;
uint8_t wifi_net_state(void) { return s_state; }
int8_t wifi_net_rssi(void) { return s_rssi; }
const char* wifi_net_ssid(void) { return s_ssid; }
const char* wifi_net_ip(void) { return s_ip; }
static void load_creds_from_nvs(void) {
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READONLY, &h) != ESP_OK) return;
size_t l = sizeof(s_ssid);
if(nvs_get_str(h, "ssid", s_ssid, &l) != ESP_OK) s_ssid[0] = 0;
l = sizeof(s_pwd);
if(nvs_get_str(h, "pwd", s_pwd, &l) != ESP_OK) s_pwd[0] = 0;
nvs_close(h);
}
static void save_creds_to_nvs(void) {
nvs_handle_t h;
if(nvs_open(NVS_NS, NVS_READWRITE, &h) != ESP_OK) return;
nvs_set_str(h, "ssid", s_ssid);
nvs_set_str(h, "pwd", s_pwd);
nvs_commit(h);
nvs_close(h);
}
static void wifi_event_handler(void* arg, esp_event_base_t base, int32_t id, void* data) {
(void)arg;
if(base == WIFI_EVENT) {
switch(id) {
case WIFI_EVENT_STA_START:
s_state = TT_WIFI_CONNECTING;
esp_wifi_connect();
break;
case WIFI_EVENT_STA_DISCONNECTED: {
wifi_event_sta_disconnected_t* d = data;
ESP_LOGW(TAG, "disconnected reason=%d", d->reason);
if(d->reason == WIFI_REASON_NO_AP_FOUND) s_state = TT_WIFI_NO_AP;
else if(d->reason == WIFI_REASON_AUTH_FAIL ||
d->reason == WIFI_REASON_HANDSHAKE_TIMEOUT) s_state = TT_WIFI_AUTH_FAILED;
else s_state = TT_WIFI_DISCONNECTED;
xEventGroupSetBits(s_eg, EV_FAIL);
esp_wifi_connect();
break;
}
}
} else if(base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t* g = data;
snprintf(s_ip, sizeof(s_ip), IPSTR, IP2STR(&g->ip_info.ip));
s_state = TT_WIFI_CONNECTED;
xEventGroupSetBits(s_eg, EV_CONNECTED);
}
}
static void try_connect(void) {
if(!s_ssid[0]) return;
wifi_config_t cfg = {0};
strncpy((char*)cfg.sta.ssid, s_ssid, sizeof(cfg.sta.ssid) - 1);
strncpy((char*)cfg.sta.password, s_pwd, sizeof(cfg.sta.password) - 1);
cfg.sta.threshold.authmode = WIFI_AUTH_OPEN;
cfg.sta.pmf_cfg.capable = true;
esp_wifi_set_config(WIFI_IF_STA, &cfg);
esp_wifi_disconnect();
esp_wifi_connect();
}
void wifi_net_init(void) {
s_eg = xEventGroupCreate();
esp_err_t r = nvs_flash_init();
if(r == ESP_ERR_NVS_NO_FREE_PAGES || r == ESP_ERR_NVS_NEW_VERSION_FOUND) {
nvs_flash_erase(); nvs_flash_init();
}
esp_netif_init();
esp_event_loop_create_default();
esp_netif_create_default_wifi_sta();
wifi_init_config_t init = WIFI_INIT_CONFIG_DEFAULT();
esp_wifi_init(&init);
esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL);
esp_event_handler_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &wifi_event_handler, NULL);
esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_set_storage(WIFI_STORAGE_RAM);
load_creds_from_nvs();
esp_wifi_start();
if(s_ssid[0]) try_connect();
}
void wifi_net_set_creds(const char* ssid, const char* pwd) {
strncpy(s_ssid, ssid ? ssid : "", sizeof(s_ssid) - 1);
strncpy(s_pwd, pwd ? pwd : "", sizeof(s_pwd) - 1);
save_creds_to_nvs();
try_connect();
}
void wifi_net_forget(void) {
s_ssid[0] = 0; s_pwd[0] = 0;
save_creds_to_nvs();
esp_wifi_disconnect();
s_state = TT_WIFI_DISCONNECTED;
}
bool wifi_net_wait_connected(uint32_t timeout_ms) {
if(s_state == TT_WIFI_CONNECTED) return true;
EventBits_t bits = xEventGroupWaitBits(
s_eg, EV_CONNECTED, pdFALSE, pdFALSE, pdMS_TO_TICKS(timeout_ms));
return (bits & EV_CONNECTED) != 0;
}
@@ -0,0 +1,31 @@
/*
* WiFi station + NVS-backed credential storage.
*
* Credentials live under NVS namespace "tt_wifi", keys "ssid" / "pwd".
* wifi_net_init() reads them and tries to connect; if missing, the radio
* stays parked until wifi_net_set_creds() is called by the Flipper.
*/
#ifndef TT_WIFI_NET_H
#define TT_WIFI_NET_H
#include <stdbool.h>
#include <stdint.h>
#include "tt_wifi_proto.h"
void wifi_net_init(void);
/* Returns the current state. The lower 4 bits map to TT_WIFI_*. */
uint8_t wifi_net_state(void);
int8_t wifi_net_rssi (void);
const char* wifi_net_ssid(void);
const char* wifi_net_ip (void);
/* Persists creds, drops the current AP, reconnects. */
void wifi_net_set_creds(const char* ssid, const char* pwd);
void wifi_net_forget(void);
/* Block (with timeout) until connected. Returns true on success. */
bool wifi_net_wait_connected(uint32_t timeout_ms);
#endif /* TT_WIFI_NET_H */
@@ -0,0 +1,21 @@
# TagTinker WiFi firmware - partition table aligned to the hardcoded
# offsets the Flipper "ESP Flasher" app writes to. From its source
# (esp_flasher_worker.h):
#
# ESP_ADDR_BOOT = 0x01000 "Bootloader"
# ESP_ADDR_PART = 0x08000 "Partition Table"
# ESP_ADDR_NVS = 0x09000 "NVS"
# ESP_ADDR_BOOT_APP0 = 0x0E000 "boot_app0" (== otadata)
# ESP_ADDR_APP_A = 0x10000 "Firmware A" (== ota_0)
# ESP_ADDR_APP_B = 0x150000 "Firmware B" (== ota_1)
#
# Because ota_1 must live at 0x150000, we need at least 4 MB of flash;
# the Flipper Wi-Fi Devboard's ESP32-S2-WROVER carries 4 MB so this fits.
# Each app slot is 1.25 MB which leaves plenty of headroom over our
# ~744 KB image.
#
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
ota_0, app, ota_0, 0x10000, 0x140000,
ota_1, app, ota_1, 0x150000, 0x140000,
Can't render this file because it contains an unexpected character in line 2 and column 23.
@@ -0,0 +1,46 @@
# TagTinker WiFi firmware - ESP-IDF defaults.
# Targets the Flipper Wi-Fi Devboard (ESP32-S2-MINI; no PSRAM).
# Bigger stack for the main task: TLS handshake + JSON parse needs room.
CONFIG_ESP_MAIN_TASK_STACK_SIZE=12288
# UART0 is owned by our framed protocol to the Flipper. The IDF console
# is moved to USB-Serial-JTAG (the dev board's USB-C) so logs are visible
# during development without colliding with our binary frames.
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
CONFIG_BOOTLOADER_LOG_LEVEL_NONE=y
CONFIG_BOOTLOADER_LOG_LEVEL=0
# Stack overflow detection: panic instead of silently wedging when a task
# blows its stack (we hit this on the link_rx task during mbedTLS).
CONFIG_FREERTOS_CHECK_STACKOVERFLOW_CANARY=y
CONFIG_FREERTOS_WATCHPOINT_END_OF_STACK=y
# WiFi tuning - we don't need the full feature matrix.
CONFIG_ESP32_WIFI_STATIC_RX_BUFFER_NUM=8
CONFIG_ESP32_WIFI_DYNAMIC_RX_BUFFER_NUM=16
CONFIG_ESP32_WIFI_DYNAMIC_TX_BUFFER_NUM=16
# mbedTLS: the small "common subset" Mozilla bundle (~20 CAs incl. the
# Cloudflare DigiCert + ISRG roots), with stock buffer sizes so we don't
# choke on large TLS records. Hardware AES/SHA accel keeps the handshake
# fast. We deliberately leave SSL_IN/OUT_CONTENT_LEN at defaults (16 KB).
CONFIG_MBEDTLS_HARDWARE_AES=y
CONFIG_MBEDTLS_HARDWARE_SHA=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_CMN=y
# Use our custom OTA-capable partition table at the offsets the Flipper
# "ESP Flasher" app expects (boot=0x1000, part=0x8000, app0=0xE000,
# firmware A=0x10000, firmware B=0x150000).
CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv"
CONFIG_PARTITION_TABLE_FILENAME="partitions.csv"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
# We don't include a phy_init partition (no room next to the ESP Flasher
# fixed offsets); use the embedded default calibration data.
CONFIG_ESP_PHY_INIT_DATA_IN_PARTITION=n
@@ -0,0 +1,4 @@
idf_component_register(
SRCS ""
INCLUDE_DIRS "."
)
@@ -0,0 +1,130 @@
/*
* TagTinker WiFi - Flipper <-> ESP32 framed UART protocol.
*
* This header is shared verbatim between the FAP and the ESP32 firmware so
* frame layouts only need to be edited in one place.
*
* Frame on the wire:
*
* +------+------+------+--------+----------+--------+
* | 0xAA | 0x55 | TYPE | LEN_LE | PAYLOAD | CRC16 |
* +------+------+------+--------+----------+--------+
* 1B 1B 1B 2B LEN B 2B
*
* - TYPE: one of TT_FRAME_* below.
* - LEN_LE: little-endian payload length (0..16383).
* - CRC16: CRC-16/CCITT-FALSE over TYPE..end-of-PAYLOAD.
*
* Direction in comments: F->E = Flipper to ESP, E->F = ESP to Flipper.
*
* String encoding: zstrings are length-prefixed (u8 len, then bytes). NUL
* terminator is *not* included on the wire. Empty strings are "\0\0".
*/
#ifndef TT_WIFI_PROTO_H
#define TT_WIFI_PROTO_H
#include <stdint.h>
#define TT_FRAME_SOF0 0xAAU
#define TT_FRAME_SOF1 0x55U
/* Maximum payload size we'll allocate buffers for on either side. Keep this
* comfortably under the ESP's RX buffer; image data is chunked into
* RESULT_CHUNK frames so this only bounds control traffic. */
#define TT_FRAME_MAX_PAYLOAD 1024U
/* Frame types -------------------------------------------------------------- */
enum {
/* Handshake / status. */
TT_FRAME_HELLO = 0x01, /* E->F: u16 fw_ver, u32 free_heap, zstring fw_name */
TT_FRAME_PING = 0x02, /* F->E: empty | E->F: empty (echo) */
/* WiFi config. */
TT_FRAME_WIFI_SET = 0x10, /* F->E: zstring ssid, zstring password */
TT_FRAME_WIFI_FORGET = 0x11, /* F->E: empty (clears NVS creds) */
TT_FRAME_WIFI_STATUS = 0x12, /* either way:
* u8 state (TT_WIFI_*),
* i8 rssi,
* zstring ssid,
* zstring ip */
/* Plugin discovery / execution. */
TT_FRAME_LIST_PLUGINS = 0x20, /* F->E: empty */
TT_FRAME_PLUGIN = 0x21, /* E->F: one frame per plugin (see below) */
TT_FRAME_PLUGINS_END = 0x22, /* E->F: end-of-list sentinel */
TT_FRAME_RUN_PLUGIN = 0x30, /* F->E: see TT_RUN_PLUGIN layout below */
TT_FRAME_PROGRESS = 0x31, /* E->F: u8 percent, zstring message */
TT_FRAME_RESULT_BEGIN = 0x32, /* E->F: u16 width, u16 height, u8 planes (1|2),
* u32 total_bytes */
TT_FRAME_RESULT_CHUNK = 0x33, /* E->F: raw plane bytes */
TT_FRAME_RESULT_END = 0x34, /* E->F: empty - all chunks delivered */
TT_FRAME_ERROR = 0x3F, /* E->F: zstring message */
};
/* WiFi state codes (TT_FRAME_WIFI_STATUS payload byte 0). */
enum {
TT_WIFI_DISCONNECTED = 0,
TT_WIFI_CONNECTING = 1,
TT_WIFI_CONNECTED = 2,
TT_WIFI_AUTH_FAILED = 3,
TT_WIFI_NO_AP = 4,
};
/*
* TT_FRAME_PLUGIN payload layout
* ------------------------------
* u8 plugin_index
* zstr id (short stable id, e.g. "crypto")
* zstr name (display name, e.g. "Crypto Price")
* zstr description (one-liner shown on the run screen)
* u8 accent_modes (bitmask: 1=mono, 2=red, 4=yellow)
* u8 param_count
* repeated param_count times:
* zstr key (machine name, e.g. "symbol")
* zstr label (display name, e.g. "Symbol")
* u8 type (TT_PARAM_*)
* zstr default_value (always a string; client parses per type)
* if type == TT_PARAM_ENUM:
* u8 option_count
* repeated option_count times: zstr option
* if type == TT_PARAM_INT:
* i32_le min
* i32_le max
*/
enum {
TT_PARAM_STRING = 0,
TT_PARAM_INT = 1,
TT_PARAM_ENUM = 2,
TT_PARAM_BOOL = 3,
};
/* TT_FRAME_RUN_PLUGIN payload layout
* ---------------------------------
* u8 plugin_index
* u16 target_w
* u16 target_h
* u8 accent (TT_ACCENT_*)
* u8 param_count
* repeated: zstr key, zstr value (string-encoded, the plugin parses)
*/
enum {
TT_ACCENT_NONE = 0, /* mono tag */
TT_ACCENT_RED = 1,
TT_ACCENT_YELLOW = 2,
};
/* CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no reflect, no xor-out).
* Tiny, branchless, no table - fine for the small frames we send. */
static inline uint16_t tt_crc16(const uint8_t* data, uint32_t len) {
uint16_t crc = 0xFFFFU;
for(uint32_t i = 0; i < len; i++) {
crc ^= (uint16_t)data[i] << 8;
for(int b = 0; b < 8; b++) {
crc = (crc & 0x8000U) ? (uint16_t)((crc << 1) ^ 0x1021U) : (uint16_t)(crc << 1);
}
}
return crc;
}
#endif /* TT_WIFI_PROTO_H */
@@ -0,0 +1,174 @@
/*
* IR transmitter.
*
* TIM1 CH3N drives the built-in IR LED carrier.
* DWT->CYCCCNT handles the symbol timing so we do not need another timer.
*/
#include "tagtinker_ir.h"
#include <furi.h>
#include <furi_hal.h>
#include <furi_hal_bus.h>
#include <furi_hal_resources.h>
#include <furi_hal_gpio.h>
#include <furi_hal_cortex.h>
#include <stm32wbxx_ll_tim.h>
/* Carrier setup for the built-in IR LED on TIM1 CH3N. */
#define CARRIER_TIM TIM1
#define CARRIER_ARR (51 - 1)
#define CARRIER_CCR 25
/*
* PP4 sends two bits per symbol. The gap selects the symbol value.
* These are pre-computed CPU cycle counts at 64 MHz to avoid any
* per-call overhead in the tight timing loop.
*/
#define PP4_BURST_CYCLES 2581
static const uint32_t pp4_gap_cycles[4] = {
3871, /* symbol 0 ~60 us */
15483, /* symbol 3 ~242 us */
7741, /* symbol 2 ~121 us */
11612, /* symbol 1 ~181 us */
};
static bool ir_initialized = false;
static volatile bool ir_stop_requested = false;
static inline void carrier_on(void) {
uint32_t ccmr2 = CARRIER_TIM->CCMR2;
ccmr2 &= ~(TIM_CCMR2_OC3M);
ccmr2 |= (TIM_CCMR2_OC3M_2 | TIM_CCMR2_OC3M_1 | TIM_CCMR2_OC3M_0); /* PWM2 */
CARRIER_TIM->CCMR2 = ccmr2;
}
static inline void carrier_off(void) {
uint32_t ccmr2 = CARRIER_TIM->CCMR2;
ccmr2 &= ~(TIM_CCMR2_OC3M);
ccmr2 |= TIM_CCMR2_OC3M_2; /* Force inactive */
CARRIER_TIM->CCMR2 = ccmr2;
}
static inline void delay_cycles(uint32_t cycles) {
uint32_t start = DWT->CYCCNT;
while((DWT->CYCCNT - start) < cycles) {
}
}
static void send_frame_pp4(const uint8_t* data, size_t len) {
for(size_t byte_idx = 0; byte_idx < len; byte_idx++) {
uint8_t current_byte = data[byte_idx];
for(int sym = 0; sym < 4; sym++) {
uint8_t symbol = current_byte & 0x03;
current_byte >>= 2;
carrier_on();
delay_cycles(PP4_BURST_CYCLES);
carrier_off();
delay_cycles(pp4_gap_cycles[symbol]);
}
}
/* Final closing burst */
carrier_on();
delay_cycles(PP4_BURST_CYCLES);
carrier_off();
}
void tagtinker_ir_init(void) {
if(ir_initialized) return;
if(furi_hal_bus_is_enabled(FuriHalBusTIM1)) {
furi_hal_bus_disable(FuriHalBusTIM1);
}
furi_hal_bus_enable(FuriHalBusTIM1);
furi_hal_gpio_init_ex(
&gpio_infrared_tx,
GpioModeAltFunctionPushPull,
GpioPullNo,
GpioSpeedVeryHigh,
GpioAltFn1TIM1);
LL_TIM_SetPrescaler(CARRIER_TIM, 0);
LL_TIM_SetAutoReload(CARRIER_TIM, CARRIER_ARR);
LL_TIM_SetCounter(CARRIER_TIM, 0);
LL_TIM_OC_SetMode(CARRIER_TIM, LL_TIM_CHANNEL_CH3, LL_TIM_OCMODE_PWM2);
LL_TIM_OC_SetCompareCH3(CARRIER_TIM, CARRIER_CCR);
LL_TIM_OC_EnablePreload(CARRIER_TIM, LL_TIM_CHANNEL_CH3);
LL_TIM_CC_EnableChannel(CARRIER_TIM, LL_TIM_CHANNEL_CH3N);
LL_TIM_EnableAllOutputs(CARRIER_TIM);
carrier_off();
LL_TIM_EnableCounter(CARRIER_TIM);
LL_TIM_GenerateEvent_UPDATE(CARRIER_TIM);
ir_stop_requested = false;
ir_initialized = true;
}
void tagtinker_ir_deinit(void) {
if(!ir_initialized) return;
tagtinker_ir_stop();
carrier_off();
LL_TIM_DisableAllOutputs(CARRIER_TIM);
LL_TIM_CC_DisableChannel(CARRIER_TIM, LL_TIM_CHANNEL_CH3N);
LL_TIM_DisableCounter(CARRIER_TIM);
if(furi_hal_bus_is_enabled(FuriHalBusTIM1)) {
furi_hal_bus_disable(FuriHalBusTIM1);
}
furi_hal_gpio_init(&gpio_infrared_tx, GpioModeAnalog, GpioPullNo, GpioSpeedLow);
ir_initialized = false;
}
bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats_raw, uint8_t delay) {
if(!ir_initialized) return false;
if(!data || len == 0 || len > 255) return false;
ir_stop_requested = false;
uint32_t repeats = repeats_raw & 0x7FFF;
for(uint32_t rep = 0; rep <= repeats; rep++) {
if(ir_stop_requested) {
carrier_off();
return false;
}
/*
* FURI_CRITICAL_ENTER/EXIT wraps each individual frame to prevent
* OS interrupts from breaking the microsecond-level IR symbol timing.
* The critical section is short (~1-2ms per frame) so it won't stall
* the OS noticeably, and we yield via furi_delay_ms between repeats.
*/
FURI_CRITICAL_ENTER();
send_frame_pp4(data, len);
FURI_CRITICAL_EXIT();
if(rep < repeats) {
/* Short gap between repeats using cycle-accurate delay.
* delay parameter = gap in units of 500µs.
* Always yield to OS every 5 repeats to prevent watchdog starvation. */
if(delay > 0) {
uint32_t gap_cycles = (uint32_t)delay * 32000U; /* 500µs per unit at 64MHz */
delay_cycles(gap_cycles);
}
/* Yield to OS periodically */
if((rep % 5U) == 4U) furi_delay_ms(1);
}
}
return true;
}
void tagtinker_ir_stop(void) {
ir_stop_requested = true;
carrier_off();
}
@@ -0,0 +1,22 @@
/*
* IR transmitter API.
*
* Sends already-built ESL frames over the built-in IR LED.
*/
#pragma once
#include <stdint.h>
#include <stddef.h>
#include <stdbool.h>
void tagtinker_ir_init(void);
void tagtinker_ir_deinit(void);
/* Repeats are sent with PP4 timing; the high bit is ignored for legacy saved data. */
bool tagtinker_ir_transmit(const uint8_t* data, size_t len, uint16_t repeats, uint8_t delay);
bool tagtinker_ir_is_busy(void);
void tagtinker_ir_stop(void);
@@ -0,0 +1,108 @@
/*
* TagTinker ESL NFC tag decoder (implementation)
*
* ESL tags contain an NDEF URI whose last 10 characters
* encode the ESL ID using a custom base64 alphabet.
* This module decodes them into the 17-char barcode format
* expected by tagtinker_barcode_to_plid().
*/
#include "tagtinker_nfc.h"
#include <string.h>
/* Direct ASCII-to-index lookup table, -1 = not in alphabet */
static const int8_t CHAR_LUT[128] = {
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,
-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,35,-1,-1,
28,24,30,38,58, 5,23, 6, 3,40,-1,-1,-1,-1,-1,-1,
-1,20,15,54,16,44,46,63, 4,48,34,19,37, 0,26, 1,
8,41,31, 2,45,55,60,12,11,57,33,-1,-1,-1,-1,50,
-1,13,39, 9,43,18,29,52,59, 7,61,62,14,25,32,56,
42,47,53,22,36,49,10,21,17,27,51,-1,-1,-1,-1,-1,
};
static int alphabet_index(char c) {
uint8_t idx = (uint8_t)c;
if(idx >= 128) return -1;
return CHAR_LUT[idx];
}
static uint32_t decode_b64(const char* s, int len) {
uint32_t r = 0;
for(int i = 0; i < len; i++) {
int idx = alphabet_index(s[(len - 1) - i]);
if(idx < 0) return 0;
r = (r * 64) + (uint32_t)idx;
}
return r;
}
static bool decode_tag(const char* tag, char barcode[18]) {
if(strlen(tag) != 10) return false;
for(int i = 0; i < 10; i++) {
if(alphabet_index(tag[i]) < 0) return false;
}
uint32_t val1 = decode_b64(tag + 5, 5);
uint32_t val2 = decode_b64(tag, 5);
char raw[20];
snprintf(raw, sizeof(raw), "%09lu%09lu", (unsigned long)val1, (unsigned long)val2);
int lc = (raw[0] - '0') * 10 + (raw[1] - '0');
if(lc > 25) return false;
char letter = (char)(lc + 65);
barcode[0] = letter;
memcpy(barcode + 1, raw + 2, 16);
barcode[17] = '\0';
if(barcode[1] != '4') return false;
int cs = 0;
for(int i = 0; i < 16; i++) {
char c = barcode[i];
cs += (c >= 'a' && c <= 'z') ? (c - 32) : c;
}
return (cs % 10) == (barcode[16] - '0');
}
static bool extract_from_pages(const MfUltralightData* mfu, char barcode[18]) {
if(mfu->pages_read < 11) return false;
const uint8_t* p3 = mfu->page[3].data;
if(p3[0] != 0xE1) return false;
const uint8_t* p4 = mfu->page[4].data;
if(p4[0] != 0x03) return false;
uint8_t ndef_len = p4[1];
if(ndef_len < 5) return false;
uint8_t flat[28];
for(int i = 0; i < 7; i++) {
memcpy(flat + i * 4, mfu->page[4 + i].data, 4);
}
int payload_end = 6 + (ndef_len - 4);
if(payload_end > 28) payload_end = 28;
char url_body[40] = {0};
int j = 0;
for(int i = 6; i < payload_end && j < 39; i++) {
if(flat[i] == 0xFE) break;
url_body[j++] = (char)flat[i];
}
const char* last_slash = strrchr(url_body, '/');
if(!last_slash) return false;
return decode_tag(last_slash + 1, barcode);
}
bool tagtinker_nfc_decode_barcode(const MfUltralightData* mfu_data, char barcode[18]) {
if(!mfu_data) return false;
barcode[0] = '\0';
return extract_from_pages(mfu_data, barcode);
}
@@ -0,0 +1,14 @@
/*
* TagTinker ESL NFC tag decoder
*
* Decodes NDEF URI from ESL Mifare Ultralight tags
* into the 17-character barcode format used by TagTinker.
*/
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include <nfc/protocols/mf_ultralight/mf_ultralight.h>
bool tagtinker_nfc_decode_barcode(const MfUltralightData* mfu_data, char barcode[18]);
@@ -0,0 +1,312 @@
/*
* ESL protocol helpers.
*/
#include "tagtinker_proto.h"
#include "../tagtinker_app.h"
#include <string.h>
#include <stdlib.h>
typedef struct {
uint16_t type_code;
uint16_t width;
uint16_t height;
TagTinkerTagKind kind;
TagTinkerTagColor color;
const char* model_name;
uint8_t pl_bit_def;
} TagTinkerProfileEntry;
static const TagTinkerProfileEntry profile_table[] = {
{1206, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E2 HCS", 0},
{1207, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E2 HCN", 4},
{1217, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E5 HCS", 2},
{1219, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E5 HCN", 1},
{1240, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCS", 3},
{1241, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCN", 0},
{1242, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCN FZ", 0},
{1243, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E4 HCW", 0},
{1265, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "Continuum E5 HCS", 2},
{1275, 320, 192, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "DM110", 0},
{1276, 320, 140, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "DM90", 0},
{1291, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "FVL Promoline 3-16", 0},
{1300, 172, 72, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "DM3370", 0},
{1314, 400, 300, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD110", 0},
{1315, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD L", 0},
{1317, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD S", 0},
{1318, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD M", 0},
{1319, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD200", 0},
{1322, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD S", 0},
{1324, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD M FZ", 0},
{1327, 208, 112, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD M Red", 0},
{1328, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red", 0},
{1336, 400, 300, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD110 Red", 0},
{1339, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD S Red", 0},
{1340, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD200 Red", 0},
{1344, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow, "SmartTag HD L Yellow", 0},
{1346, 800, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow, "SmartTag HD200 Yellow", 0},
{1348, 264, 176, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD T Red", 0},
{1349, 264, 176, TagTinkerTagKindDotMatrix, TagTinkerTagColorYellow, "SmartTag HD T Yellow", 0},
{1351, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorMono, "SmartTag HD150", 0},
{1353, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD150 Red", 0},
{1354, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD150 Red", 0},
{1370, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red (2021)", 0},
{1371, 648, 480, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD150 Red (2021)", 0},
{1510, 0, 0, TagTinkerTagKindSegment, TagTinkerTagColorMono, "SmartTag E5 M", 1},
{1627, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red", 0},
{1628, 296, 128, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD L Red", 0},
{1639, 152, 152, TagTinkerTagKindDotMatrix, TagTinkerTagColorRed, "SmartTag HD S Red", 0},
};
static const TagTinkerProfileEntry* find_profile_entry(uint16_t type_code) {
for(size_t i = 0; i < COUNT_OF(profile_table); i++) {
if(profile_table[i].type_code == type_code) return &profile_table[i];
}
return NULL;
}
uint16_t tagtinker_crc16(const uint8_t* data, size_t len) {
uint16_t crc = 0x8408;
for(size_t i = 0; i < len; i++) {
crc ^= data[i];
for(int b = 0; b < 8; b++)
crc = (crc & 1) ? (crc >> 1) ^ 0x8408 : crc >> 1;
}
return crc;
}
static size_t terminate(uint8_t* buf, size_t len) {
uint16_t crc = tagtinker_crc16(buf, len);
buf[len] = crc & 0xFF;
buf[len + 1] = (crc >> 8) & 0xFF;
return len + 2;
}
static size_t raw_frame(uint8_t* buf, uint8_t proto, const uint8_t plid[4], uint8_t cmd) {
buf[0] = proto;
memcpy(&buf[1], plid, 4);
buf[5] = cmd;
return 6;
}
static size_t mcu_frame(uint8_t* buf, const uint8_t plid[4], uint8_t cmd) {
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x34);
buf[p++] = 0x00;
buf[p++] = 0x00;
buf[p++] = 0x00;
buf[p++] = cmd;
return p;
}
static void append_word(uint8_t* buf, size_t* p, uint16_t value) {
buf[(*p)++] = (value >> 8) & 0xFF;
buf[(*p)++] = value & 0xFF;
}
bool tagtinker_barcode_to_plid(const char* barcode, uint8_t plid[4]) {
if(!barcode || strlen(barcode) != 17) return false;
uint64_t a = 0, b = 0;
for(int i = 2; i < 7; i++) a = a * 10 + (barcode[i] - '0');
for(int i = 7; i < 12; i++) b = b * 10 + (barcode[i] - '0');
uint64_t id = (a << 16) | b;
plid[0] = id & 0xFF; // LSB first
plid[1] = (id >> 8) & 0xFF;
plid[2] = (id >> 16) & 0xFF;
plid[3] = (id >> 24) & 0xFF;
return true;
}
bool tagtinker_barcode_to_type(const char* barcode, uint16_t* type_code) {
if(!barcode || strlen(barcode) != 17 || !type_code) return false;
uint16_t type = 0;
for(int i = 12; i < 16; i++) type = type * 10 + (barcode[i] - '0');
*type_code = type;
return true;
}
bool tagtinker_barcode_to_profile(const char* barcode, TagTinkerTagProfile* profile) {
if(!profile) return false;
memset(profile, 0, sizeof(*profile));
uint16_t type_code = 0;
if(!tagtinker_barcode_to_type(barcode, &type_code)) return false;
profile->type_code = type_code;
const TagTinkerProfileEntry* entry = find_profile_entry(type_code);
if(!entry) return false;
profile->width = entry->width; profile->height = entry->height;
profile->kind = entry->kind; profile->color = entry->color;
profile->model_name = entry->model_name; profile->pl_bit_def = entry->pl_bit_def;
profile->known = true;
return true;
}
size_t tagtinker_make_ping_frame(uint8_t* buf, const uint8_t plid[4]) {
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x97);
buf[p++] = 0x01;
buf[p++] = 0x00;
buf[p++] = 0x00;
buf[p++] = 0x00;
for(int i = 0; i < 20; i++) buf[p++] = 0x01;
return terminate(buf, p);
}
size_t tagtinker_make_refresh_frame(uint8_t* buf, const uint8_t plid[4]) {
size_t p = mcu_frame(buf, plid, 0x01);
for(int i = 0; i < 18; i++) buf[p++] = 0x00;
return terminate(buf, p);
}
size_t tagtinker_make_image_param_frame(
uint8_t* buf, const uint8_t plid[4], uint16_t byte_count, uint8_t comp_type,
uint8_t page, uint16_t width, uint16_t height, uint16_t pos_x, uint16_t pos_y) {
size_t p = mcu_frame(buf, plid, 0x05);
append_word(buf, &p, byte_count);
buf[p++] = 0x00;
buf[p++] = comp_type;
buf[p++] = page;
append_word(buf, &p, width);
append_word(buf, &p, height);
append_word(buf, &p, pos_x);
append_word(buf, &p, pos_y);
append_word(buf, &p, 0x0000);
buf[p++] = 0x88;
append_word(buf, &p, 0x0000);
for(int i = 0; i < 4; i++) buf[p++] = 0x00;
return terminate(buf, p);
}
size_t tagtinker_make_image_data_frame(
uint8_t* buf,
const uint8_t plid[4],
uint16_t index,
const uint8_t data[TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME]) {
size_t p = mcu_frame(buf, plid, 0x20);
append_word(buf, &p, index);
memcpy(&buf[p], data, TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME);
p += TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME;
return terminate(buf, p);
}
typedef struct { uint8_t* data; size_t bit_pos; } TagTinkerBitWriter;
static inline void bit_writer_append(TagTinkerBitWriter* writer, uint8_t bit) {
size_t byte_idx = writer->bit_pos / 8U;
size_t bit_idx = 7U - (writer->bit_pos % 8U);
if(bit) writer->data[byte_idx] |= (uint8_t)(1U << bit_idx);
writer->bit_pos++;
}
static size_t record_run_bit_length(uint32_t count) {
size_t bits = 0; do { bits++; count >>= 1; } while(count);
return (bits * 2U) - 1U;
}
static void bit_writer_append_run(TagTinkerBitWriter* writer, uint32_t count) {
uint8_t bits[32]; int n = 0; uint32_t v = count;
while(v) { bits[n++] = v & 1U; v >>= 1; }
for(int i = 0; i < n / 2; i++) { uint8_t t = bits[i]; bits[i] = bits[n-1-i]; bits[n-1-i] = t; }
for(int i = 1; i < n; i++) bit_writer_append(writer, 0U);
for(int i = 0; i < n; i++) bit_writer_append(writer, bits[i]);
}
static inline uint8_t plane_pixel_at(const uint8_t* p1, const uint8_t* p2, size_t count, size_t idx) {
return (idx < count) ? p1[idx] : p2[idx - count];
}
static size_t tagtinker_rle_planes_bit_length(const uint8_t* p1, const uint8_t* p2, size_t count) {
if(!p1) return 0;
size_t total = p2 ? (count * 2U) : count;
if(total == 0) return 0;
size_t bit_len = 1U;
uint8_t run_pixel = plane_pixel_at(p1, p2, count, 0);
uint32_t run_count = 1;
for(size_t i = 1; i < total; i++) {
uint8_t pix = plane_pixel_at(p1, p2, count, i);
if(pix == run_pixel) run_count++;
else { bit_len += record_run_bit_length(run_count); run_pixel = pix; run_count = 1; }
}
if(run_count > 0U) bit_len += record_run_bit_length(run_count);
return bit_len;
}
static void tagtinker_pack_planes_raw(const uint8_t* p1, const uint8_t* p2, size_t count, uint8_t* out) {
size_t total = p2 ? (count * 2U) : count;
TagTinkerBitWriter writer = {.data = out, .bit_pos = 0};
for(size_t i = 0; i < total; i++) bit_writer_append(&writer, plane_pixel_at(p1, p2, count, i));
}
static void tagtinker_pack_planes_rle(const uint8_t* p1, const uint8_t* p2, size_t count, uint8_t* out) {
size_t total = p2 ? (count * 2U) : count;
if(total == 0) return;
TagTinkerBitWriter writer = {.data = out, .bit_pos = 0};
uint8_t run_pixel = plane_pixel_at(p1, p2, count, 0);
uint32_t run_count = 1;
bit_writer_append(&writer, run_pixel);
for(size_t i = 1; i < total; i++) {
uint8_t pix = plane_pixel_at(p1, p2, count, i);
if(pix == run_pixel) run_count++;
else { bit_writer_append_run(&writer, run_count); run_pixel = pix; run_count = 1; }
}
if(run_count > 0U) bit_writer_append_run(&writer, run_count);
}
#define DATA_BITS_PER_FRAME (TAGTINKER_IMAGE_DATA_BYTES_PER_FRAME * 8U)
bool tagtinker_encode_planes_payload(
const uint8_t* p1, const uint8_t* p2, size_t count, TagTinkerCompressionMode mode, TagTinkerImagePayload* payload) {
if(!p1 || !payload) return false;
memset(payload, 0, sizeof(*payload));
size_t total = p2 ? (count * 2U) : count;
size_t comp_len = tagtinker_rle_planes_bit_length(p1, p2, count);
bool use_compressed = (mode == TagTinkerCompressionRle) ||
(mode == TagTinkerCompressionAuto && comp_len > 0U && comp_len < total);
size_t src_len = use_compressed ? comp_len : total;
size_t padded_bits = src_len + ((DATA_BITS_PER_FRAME - (src_len % DATA_BITS_PER_FRAME)) % DATA_BITS_PER_FRAME);
uint8_t* data = calloc(padded_bits / 8U, 1);
if(!data) return false;
if(use_compressed) tagtinker_pack_planes_rle(p1, p2, count, data);
else tagtinker_pack_planes_raw(p1, p2, count, data);
payload->data = data; payload->byte_count = padded_bits / 8U;
payload->comp_type = use_compressed ? 2U : 0U;
return true;
}
bool tagtinker_encode_image_payload(
const uint8_t* pixels, uint16_t width, uint16_t height, bool color_clear,
TagTinkerCompressionMode mode, TagTinkerImagePayload* payload) {
size_t count = (size_t)width * height;
uint8_t* second = NULL;
if(color_clear) { second = malloc(count); if(!second) return false; memset(second, 1, count); }
bool ok = tagtinker_encode_planes_payload(pixels, second, count, mode, payload);
free(second); return ok;
}
void tagtinker_free_image_payload(TagTinkerImagePayload* payload) {
if(payload && payload->data) { free(payload->data); payload->data = NULL; }
}
bool tagtinker_is_barcode_valid(const char* barcode) {
if(!barcode || strlen(barcode) != 17) return false;
return true;
}
size_t tagtinker_make_addressed_frame(uint8_t* buf, const uint8_t plid[4], const uint8_t* payload, size_t len) {
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, payload[0]);
memcpy(&buf[p], payload + 1, len - 1); p += len - 1;
return terminate(buf, p);
}
size_t tagtinker_build_broadcast_page_frame(uint8_t* buf, uint8_t page, bool forever, uint16_t duration) {
const uint8_t plid[4] = {0};
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x06);
buf[p++] = ((page & 7) << 3) | 0x01 | (forever ? 0x80 : 0x00);
buf[p++] = 0x00; buf[p++] = 0x00;
buf[p++] = (duration >> 8) & 0xFF; buf[p++] = duration & 0xFF;
return terminate(buf, p);
}
size_t tagtinker_build_broadcast_debug_frame(uint8_t* buf) {
const uint8_t plid[4] = {0};
size_t p = raw_frame(buf, TAGTINKER_PROTO_DM, plid, 0x06);
buf[p++] = 0xF1; buf[p++] = 0x00; buf[p++] = 0x00; buf[p++] = 0x00; buf[p++] = 0x0A;
return terminate(buf, p);
}

Some files were not shown because too many files have changed in this diff Show More