# The wire format One byte-array tuple over AppMessage, defined here and implemented twice — in `src/pkjs/index.js` (writer) and `src/c/main.c` (reader). **If you change one, change the other and the version byte.** A silent mismatch between them decodes into garbage rooms rather than failing, which is why the version byte exists at all. ## Why a byte array and not a dictionary Pebble's guaranteed AppMessage buffers are small (124 in / 636 out documented minimum, ~2 KB in practice for a JS-backed app), and a dictionary costs ~7 bytes of overhead per tuple. A ten-room plan as one tuple per room would spend more on keys than on rooms. As one packed array it is a few hundred bytes with room to spare. ## Coordinates are quantised to a byte Room polygons are already stored normalised 0.0–1.0 by `identity`. Multiply by 255 and round: a vertex costs 2 bytes and the error is under half a percent of the plan's width, which on a 170 px inscribed box is sub-pixel. The phone does the projection into the watch's inscribed box; the watch does no floating point at all. ## Layout ``` header u8 version 1 u8 room_count u8 unplaced_count u8 flags bit0: positions are available on this level per room (room_count times) u8 name_len u8[] name UTF-8, truncated to 24 bytes by the writer u8 state 0 = empty, 1 = occupied, 2 = unknown (drawn, never reported) u8 vertex_count u16[] vertices x,y each u8, so 2 bytes per vertex u8 target_count anonymous radar targets — somebody is there, nobody knows who u16[] targets x,y each u8 u8 occupant_count per occupant u8 colour_index 0..7 into identity's PERSON_COLORS, 255 = unknown u8 initial ASCII u8 occ_flags bit0: has a fused position u16 position x,y each u8 — PRESENT ONLY when bit0 is set u8 name_len u8[] name UTF-8, truncated to 16 bytes per unplaced person (unplaced_count times) u8 colour_index u8 initial u8 name_len u8[] name ``` ## Truncation, and why the writer does it The writer builds the payload and, if it exceeds the inbox size, sheds in this order: 1. **Occupant names** — the detail screen loses full names and falls back to initials. Worst outcome of the three and still legible. 2. **Rooms with no occupants** — an empty room is the least informative thing on the plan. 3. **Whole levels** — refuse, and say so on the watch. It never silently sends a partial structure. A payload that decodes half-way is worse than one that does not arrive, because the watch cannot tell the difference between "three rooms" and "three rooms and then the buffer ran out". ## What is NOT in this format: the toggles line The toggles screen (README.md) sends its own message, as plain text: ``` name|1|Loggia\nname|0|Desk ``` One record per line, three fields: name, `1`/`0` for on, and the detail string the watch displays. The watch replies with the row INDEX it was looking at, never an id, so no identifier string has to be stored on the watch or copied back. It is deliberately not squeezed into the binary format above. That format exists because a floorplan does not otherwise fit in a ~2 KB AppMessage; a handful of short strings with no geometry gains nothing from it and would lose the readability that makes the format worth auditing. What the two share is the discipline: the packer (`src/pkjs/index.js`) and the parser (`src/c/main.c`) are round-tripped against each other in `test/run-tests.sh`, including the two cases that would otherwise decode as plausible garbage — a value containing the `|` separator, and a record truncated mid-way. Field lengths are capped by the writer to the C buffers' sizes (`TOGGLE_NAME_LEN`, `TOGGLE_DETAIL_LEN`), so truncation happens once, on the phone, rather than differently on each side.