# LoBBS Internals [https://discord.gg/DMrGcGQfMN](https://discord.gg/DMrGcGQfMN) Player commands are in the [daily user guide](lobbs-daily.md). The machine wire format is in the [Machine API](lobbs-machine.md). Why plugins work the way they do, how to define your own hooks, and the new-app checklist are in the [plugin author's guide](lobbs-plugins.md). ## Request flow 1. A DM line starting with `/` may include an optional request id (`/42 mail list`). 2. Page-only verbs (`/p2`, `/p 2`, `/43 p2`) load the cached `LoBBSResponse` for that sender and render a page. 3. Otherwise `slash_cmd` runs. Each plugin compares the verb and returns quietly when it is not the owner. 4. Handlers build a `LoBBSResponse` and call `lobbsCommandReplyResponse`. Successful replies are cached, then page 1 is rendered. 5. Without a request id, plain text uses `{p n/t}` footers. With an id, machine replies use `ok [n:max]`. There is no central unknown-command reply. If no module owns the verb, the node stays silent. ## One verb, rest untouched After peeling `/[id]`, the registry takes one verb token and leaves the rest on `ctx.rest`. Example: `/mail send ben this is my long message` keeps `this is my long message` intact. Mail peels `send`, then `ben`, then treats the rest as the body. Nothing re-tokenizes the whole line up front. Handlers use `lobbsArgShift`, `lobbsArgPeek`, `lobbsArgRest`, `lobbsArgShiftUint`, `lobbsArgPeekIsUint`, and `lobbsArgShiftMany` on `ctx.rest`. Do not use partial `atoi` on numeric tokens. Subcommands use one `LoBBSVerb` table for dispatch (`lobbsDispatchSub`) and help (`lobbsHelpForTable`). Rows with `fn == nullptr` are help-only. `LOBBS_V_LOGIN` and `LOBBS_V_SYSOP` gate dispatch and hide sysop rows from help when the caller is not a sysop. ## Paging and reply cache Every successful command answers with page 1 and stores the full result set in RAM for that sender's node id. | Constant | Value | Role | | ------------------------------- | ----- | ------------------------------------------- | | `LOBBS_REPLY_CACHE_TTL_SEC` | 300 | About five minutes, reset on each page read | | `LOBBS_REPLY_CACHE_MAX_BYTES` | 8192 | Larger payloads are not cached | | `LOBBS_REPLY_CACHE_MAX_ENTRIES` | 32 | Oldest entry dropped when full | A new successful command replaces the cache. Errors do not change it. Over-limit payloads send page 1 and clear the cache, so `/p2` replies `No cached reply.`. Past the end: `No such page.` Follow with `/p2` or `/43 p2` (request id optional). Plain-text serialization runs `display_human` on each record before paging. ## Help and `/hi` `/help` with no remainder builds a topic list: built-in rows (`help`, `hi`, `pN`) plus rows from `help_topics`. Logged-in users see feature topics. Sysop-only topics follow each plugin's rules. `/help mail` or `/help mail send` runs `help_for_topic`. The query is in `args` as `LODB_F_TITLE`. Plugins set `LODB_F_DESCRIPTION` on the value record when they recognize the query. If nothing matches, the reply is `No help found for …`. `/hi` is a welcome screen, not the command catalog. `/status` uses `status_lines`. ## Hook bus Source: `LoBBSHooks.h`. Modeled on WordPress actions and filters. The bus is shaped so a handler could later come from a script instead of compiled C++. Why it works this way, and how to define hooks of your own, is in the [plugin author's guide](lobbs-plugins.md). Rules: - Hooks are named by string, matched case-insensitively. The bus stores the name pointer, so pass a string literal. Any number of handlers, added at runtime, no cap. `lobbsHooksReset()` clears all. - There is no list of allowed names. Firing a name nobody registered does nothing. Registering a name nobody fires is never called. - Every hook receives `args` as a `LoScalar`. Data crosses the bus as LoScalar fields, not C++ types. - No hook claims or short-circuits. Every handler runs. - Priority is fixed at registration (lower first, ties keep registration order). Bands: `HELP` 0, `AUTH` 10, `FEATURE` 20, `STATUS` 30, `TIME` 31. Install registers at `AUTH - 1`. - A handler registered with the wrong kind for a name is logged and skipped. - Handlers talk only through `ctx`, `args`, and the value. No handler calls another plugin. Three kinds: - Action: `(ctx, args)`, fire and forget. - Record filter: `(ctx, LoScalar &value, args)`. Caller seeds `value`, each handler edits it or leaves it. - List filter: `(ctx, std::vector &value, args)`. Same, over a list. | Hook | Kind | Initial value | args | Role | | ----------------- | ------ | ---------------------- | ------------------------------------------------ | ---------------------------------------------------------------------------------- | | `slash_cmd` | action | n/a | verb (`LOBBS_ARG_VERB`), rest (`LOBBS_ARG_REST`) | Compare with `lobbsSlashVerbIs`, parse `ctx->rest` | | `seed` | action | n/a | empty | Demo and test builds (`LOBBS_SEED`). Each app seeds its tables | | `help_topics` | list | built-in topics | empty | Append `{title, description}` rows | | `help_for_topic` | record | title = topic | title = query | Set description when the topic matches | | `status_lines` | list | empty | empty | Append `{title, description}` (`Users` / `12 total` renders as `Users (12 total)`) | | `display_human` | record | generic title line | source record | Rewrite title for records you recognize | | `config_keys` | list | empty | empty | Append key definition rows (`default`, `min`, `max` in fields 0–2) | | `config_validate` | record | title = key, 0 = value | key definition record | Set `LODB_F_ERROR` to reject; empty means ok | | `config_changed` | action | n/a | title = key, field 0 = effective value | Apply new settings after set, reset, or database open | | `install_mounts` | list | empty | empty | Append `{title = mount name}` rows a database may be installed on | These are the hooks core and the bundled apps fire. Apps add their own names the same way. Something happened: action. Contribute to one shared value: record filter. Contribute to a shared list: list filter. A new need gets a new hook name, not a new kind. ## Result sets and rendering Handlers build `LoBBSResponse` (`ok`, `error`, `records`) with `lobbsRecordPush`, `lobbsResponseAppendRecord`, or `lobbsResponseSetError`, then call `lobbsCommandReplyResponse`. `lobbsCommandReply` and `lobbsCommandReplyError` wrap it. Handlers never page. Human: generic `title (description)` per record, then `display_human`, packed into 200 bytes with `{p n/t}`. Machine: records encoded as LoScalar lines, sliced into pages with `ok [n:max]`. See the Machine API guide. `lobbsMsgRegisterDisplay()` (wireup) registers one `display_human` handler for mail and news list and read rows. Mail and news command modules do not register their own `display_human` filters. ## LoFS and install LoFS exposes `/` as a virtual root listing mounts. Writable paths start with `//…` (optional `sd`, optional `extra` when built with `LOBBS_EXTRA_QSPI=1`, `flash2` on nRF52840, `flash`). Mount roots cannot be removed, renamed, or used as copy sources. Cross-mount `mv` copies files then deletes the source. Directories require same-mount rename. `/flash2` is a second nRF52840 internal-flash partition, 100 KiB at `0xD4000`–`0xED000` between the app and InternalFS. The linker scripts `src/platform/nrf52/nrf52840_s140_v6.ld` (set on `nrf52840_base`) and `nrf52840_s140_v7.ld` end `FLASH` at `0xD4000` and provide `__flash2_start` / `__flash2_end`. LoFS reads them as weak symbols, so nRF builds on other linker scripts have no `/flash2`. The partition is a second `Adafruit_LittleFS` with 128 B blocks over `flash_nrf5x`. It is formatted when mount fails or by SysOp `/format flash2` (`LoFS::format`, which erases the pages first). Its block IO takes InternalFS's lock because the `flash_nrf5x` page cache is shared and the BLE task writes bonds to InternalFS. `/extra` is optional onboard QSPI flash (2 MiB, 4096 B sectors, 256 B read/prog, 512-block lookahead). LoFS uses `LoFSQspi` (`nrfx_qspi`, FASTREAD/PP at SCK/8, no quad-enable). Boot sends release-from-deep-power-down (`0xAB`) and software reset (`0x66`/`0x99`), then reads the JEDEC ID and expects Puya `0x85`. A wrong or missing ID skips the mount and never formats. A mount failure on a good ID formats, as does `/format extra`. `/format flash` runs `FSCom.format()` then `nodeDB->saveToDisk()`, the same recovery Meshtastic uses after a failed save. Formatting the install mount reruns `lobbsInstallInit`, so the node goes Blank unless another mount holds an install. Every custom instruction holds IO2/IO3 high: they are WP#/HOLD# while the quad-enable bit is clear, so LoFSQspi polls the status register (`0x05`) itself instead of `nrfx_qspi_mem_busy_check`, which sends it with HOLD# low. `LoFS::list` callbacks get `(ctx, basename, isDirectory, size)`. `size` is the file length, 0 for directories and mounts. Each mount has a `dbSafe` flag (`LoFS::mountDbSafe`). `sd`, `extra`, and `flash2` are db-safe. `flash` is db-safe only when there is no `flash2` mount, because a failed Meshtastic save on nRF52 formats InternalFS. Install points come from the `install_mounts` list filter. Core registers a provider that lists present db-safe mounts in mount-table order, which is preference order (`sd`, `extra`, `flash2`, `flash`). `/install` accepts only listed mounts, the install hint and usage show them, and demo seeding uses the first one. Shared mounts (`/flash`) keep `LOFS_SHARED_RESERVE_BYTES` free (16 KiB nRF52, 128 KiB other hardware, 0 Portduino; override with `-D`). `LoFS::hasRoom(path, bytes)` rounds `bytes` up to the block size, adds slack for metadata, adds the mount reserve, and compares with free space. It returns true when the mount reports no size. LoDB checks it before every record write and returns `LODB_ERR_FULL`. FsCommands checks it for `/mkdir`, `/cp`, `/upload`, and cross-mount `/mv`. Apps map `LODB_ERR_FULL` to `Disk full.` via `lobbsDbErrorText`. Deletes are never blocked. LoBBS home: everything lives under `//lobbs` (`LOBBS_HOME_DIR`). `install.ls` (field `LOBBS_INSTALL_FIELD_VERSION` = `LOBBS_VERSION` at install) marks the install and is written last. `db/` is the LoDB (`LoDb("db")` opened at the home). `apps//` and `home//` are reserved conventions. At boot `lobbsInstallInit` walks `install_mounts` in order and takes the first mount holding `lobbs/install.ls`. Later hits are logged and ignored. No marker lives on `/flash`. | State | Behavior | | ------- | ----------------------------------------------------------------------- | | Blank | No `lobbs/install.ls` on any install mount. Only `/help` and `/install` | | Ready | Marker found and `LoDb::open(home)` succeeded | | Offline | Marker found but the database failed to open. Non-help commands error | `/install` authorization matches admin PKI in `AdminModule` (local client or encrypted DM with a configured admin key). Only `/install` creates SysOp accounts. ## Sessions Logins live in RAM, not LoDB: `AuthDal` holds a vector of `{nodeId, userUuid, lastActiveMs, cwd}` slots, keyed by sender node id. Slot count and idle expiry come from `session.max` and `session.idle` in the config registry (defaults 16 and 86400 s). Each lookup refreshes `lastActiveMs`. A slot idle past the expiry, or whose user row is gone, is freed on lookup. Logging in reuses the node's slot, takes a free or expired one, or evicts the longest idle. Shrinking the limit keeps the most recently active. Sessions end on reboot, and `clearSessions()` runs whenever the database opens. Logins never write flash; overrides live in the `config` table. ## Config registry SysOp `/config` lists, reads, sets, and resets uint32 settings. Plugins declare keys on `config_keys` via `lobbsConfigPushKey`. `/help config key` replies with the key's help text, default, and range. Validation runs on `config_validate` (core registers a min/max range check at help priority). Successful sets upsert one row in table `config` (title = key); reset deletes the row so defaults cost no flash. `config_changed` fires after each set or reset and for every key when the database opens (`lobbsInstallDatabaseOpened`). SysOp file commands, chunked upload, and path rules are documented in the [SysOp guide](lobbs-sysop.md). ## LoScalar One record is one line of numbered fields, `number:value`, joined by `|` in ascending field order, numbers `0..99`. Values escape `\`, `|`, and newline with a backslash. Typed accessors cover strings, `uint32`, `uint64`, bools, and 32-byte keys as hex. `encode` takes a byte limit and fails rather than write past it. ``` 95:1790000000|96:1790000000|97:see you at the trailhead|99:9168245037165234184 ``` Why this shape: - One format everywhere. The same line is a row on disk, a record in a `LoBBSResponse`, a hook argument, and a line in a machine reply. Nothing translates between a storage format and a wire format, and a client decodes exactly what the node stored. - You can read it. `/cat` on a row file, or the SD card in a laptop, shows the fields as text. Debugging a node in the field needs no decoder. - No schema compiler. A record type is a `*Records.h` header of field numbers. A new field is a new constant, with no generated code and no build step. - Numbers instead of names. A field costs two digits and a colon, not a key name. That matters when the whole reply is 200 bytes. - Decoding keeps every field, including numbers the reader does not know. Two apps, or a firmware and a client a release apart, can share a record without agreeing on all of it. - Flat on purpose. There is no nesting and no quoting. A news body full of quotes needs no JSON parser. A structured value, like the wall grid, is one string field the owning app parses. - Small. The codec is one class with no dependencies, which fits nRF52 flash and the 512-byte stack cap. On nRF52, do not format `uint64_t` with `%llu`. Use `loU64ToDec` from `loutil/LoUtil.h`. `loHumanBytes` prints short sizes (`9B`, `1.2K`, `1.8M`). ## LoDB `LoDb::open(root)` stores tables at `///` (LoBBS: `//lobbs/db/
/`), one `<16 hex uuid>.ls` file per row. Writes go to `.w` then rename. API: `registerTable`, `insert`, `get`, `update`, `upsert`, `deleteRecord`, `select`, `count`. Row cap `LODB_MAX_RECORD_BYTES` (1024). Uuid from `lodb_new_uuid`. Prefer `upsert` for keyed or singleton rows. Runtime quotas and session limits use the config registry, not per-app singleton tables. ### Field numbers App fields use `0..93` (`LODB_F_USER_LIMIT` is 94). Each app's `*Records.h` is the schema. System fields count down from 99. | Number | Name | Who writes it | | ------ | -------------------- | -------------------------------- | | 99 | `LODB_F_ID` | LoDB, the row uuid | | 98 | `LODB_F_TITLE` | The app. Human renderer line | | 97 | `LODB_F_DESCRIPTION` | The app. Mail, news, yarn bodies | | 96 | `LODB_F_CREATED` | LoDB unless set. Unix seconds | | 94 | `LODB_F_ERROR` | Any filter. User-facing error | | 95 | `LODB_F_UPDATED` | LoDB unless set. Unix seconds | Strip 95 and 96 before `update` to get default stamps. Times are Unix seconds, not milliseconds. ## Stack budget `LoBBSStackGuard.h`: on nRF52 every LoBBS function is capped at 512 bytes of stack (`-Wstack-usage=512` as an error). Commands run on the 4 KB loop task shared with the router. Include the guard last in every LoBBS `.cpp`. Keep large buffers off the stack. ## Verifying Build `pio run -e rak4631` and run `trunk fmt` before commit. Native tests are not green on this branch. See `.cursor/rules/lobbs-verification.mdc`. ## Plugin checklist See [lobbs-plugins.md](lobbs-plugins.md).