Persistence
Saving and loading settings, and the .proto build path
There is no settings API - persistence is the Storage API calls plus a convention. This page is that convention: when to save, where to put the file, and how to lay out its bytes so that shipping a new version of a tapp does not orphan settings saved by earlier versions.
A complete working app is at examples/persistence/.
When to save
The memory_if hooks on the descriptor are optional: load() runs after the tapp's init() at launch, save() when the device flushes its settings and when the tape is swapped while the tapp owns the engine - never on close. The dependable contract is therefore two calls made by the tapp itself:
- load in
init(), and fall back to defaults when the file is missing or rejected - save in
deinit()
deinit() runs on a normal exit. It does not run if the battery dies, so anything the user would not want to lose should also be written at the moment it changes - or behind an explicit "save" button, which is what the example does.
Warning — Never touch storage from process(). Every call here blocks on the SD card for milliseconds; the audio callback has microseconds. Use init(), deinit(), tick() or an input handler.
Where to put the file
Any path may be used - each tapp has a private folder and every write lands in it. The firmware rewrites the path to .data/<appname>/<path> on the card, where <appname> comes from the manifest name. Writes cannot reach outside it:
#define MY_FILE "/settings.pb" // -> .data/myapp/settings.pb on the card storage_write_file(MY_FILE, buf, n);
.data/ is hidden but sits at the card root, so a user can still copy a save off over USB.
Reading is not restricted the same way: a read falls back to the card when the file is not in the tapp's folder, which is what keeps browser_open results and foreign assets (ROMs, samples, WAVs) loadable. See Reads.
The quick way: a fixed struct
For a handful of values that will never change shape, write the struct. Guard it with a magic word and check the length, because the file lives on a removable card that a host can overwrite with anything:
#define HISCORE_MAGIC 0x54544831u
static void load_hiscore(my_model_t* m) {
uint32_t rec[2];
if(storage_read_file("/hiscore.bin", rec, sizeof(rec)) != sizeof(rec)) return;
if(rec[0] != HISCORE_MAGIC) return;
m->hiscore = rec[1];
}
static void save_hiscore(const my_model_t* m) {
const uint32_t rec[2] = {HISCORE_MAGIC, m->hiscore};
storage_write_file("/hiscore.bin", rec, sizeof(rec));
}The limitation is that adding a field invalidates every file already written. Bumping the magic discards old saves; omitting the bump makes them misread. That is acceptable for a high score and costly for a settings page.
Note — Test the write result as > 0, never == size. On device storage_write_file() returns the byte count.
The durable way: a .proto schema
Protobuf stores field numbers rather than offsets, so a reader skips fields it does not know and defaults the ones that are absent. A field can be added in an update and files written by earlier versions still load. The cost is roughly 4KB of nanopb runtime in the tapp.
Place a .proto next to the tapp's .c; tapp-build handles the rest:
// myapp/settings.proto
syntax = "proto3";
import "nanopb.proto";
message PB_Settings {
uint32 ver = 1;
uint32 bpm = 2;
string label = 3 [(nanopb).max_size = 16];
}Build the folder, not the file - ./tapp-build myapp. The generated settings.pb.h and settings.pb.c land in a temporary directory that is already on the include path, and the nanopb runtime is compiled and linked in automatically:
#include "tapp_api.h"
#include <pb_encode.h>
#include <pb_decode.h>
#include "settings.pb.h"
static bool settings_save(const my_model_t* m) {
PB_Settings pb = PB_Settings_init_zero;
pb.ver = 1;
pb.bpm = m->bpm;
strncpy(pb.label, m->label, sizeof(pb.label) - 1);
uint8_t buf[PB_Settings_size]; // generated: the largest possible encoding
pb_ostream_t out = pb_ostream_from_buffer(buf, sizeof(buf));
if(!pb_encode(&out, PB_Settings_fields, &pb)) return false;
return storage_write_file("/settings.pb", buf, out.bytes_written) > 0;
}Notes before adopting it:
- nanopb is not bundled. Add it with
git submodule add https://github.com/nanopb/nanopb.git nanopb, or pointNANOPB_DIRat a checkout. Its generator needs theprotobufandgrpcio-toolsPython packages. Inside the firmware tree it is found automatically, and the browser builder needs nothing installed at all - it runs protoc as wasm and produces the same bytes. - Bound every variable-length field.
[(nanopb).max_size = N]on a string or bytes field,[(nanopb).max_count = N]on a repeated one. Without a bound nanopb emits a callback field instead of a plain C member, which is far more code than settings need. A sibling<name>.optionsfile works too, but inline options travel with the schema. - Only the tapp's own directory is on the proto include path, plus nanopb's.
import "nanopb.proto"resolves; importing another project's.protodoes not. - The generated files are temporary. They are deleted when the build finishes, so there is nothing to inspect or commit - the
.protois the source of truth.
Version it, then distrust it
Make the first field a version number and refuse anything that disagrees. proto3 cannot distinguish an absent field from a zero one, so this is the only cheap way to reject a file written when field 4 meant something else.
A successful decode says the bytes were well-formed, not that the values are usable. Clamp numbers to the range the code assumes and treat every index or enum as hostile - this file is user-writable over USB, and an out-of-range index read straight into an array subscript is a hard fault:
if(pb.ver != SETTINGS_VER) return false; // reject, use defaults m->bpm = pb.bpm < 40u ? 40u : (pb.bpm > 300u ? 300u : pb.bpm); // clamp, never trust