Back to Learn Hub
Binary format specification

The Zero Gen3 log format, record by record

A byte-level specification of the Gen3 (Cypher III / FST) diagnostic log: how 0xB2 records are framed and unescaped, what every decoded field means on the MBB and BMS boards, where the 538XX firmware variant breaks the rules, and which bytes are still unknown. Every offset here was re-measured against 352 real log files, and five claims this page previously made are retracted in place rather than quietly deleted. Every record count on this page names the framing convention and the file count it was taken under, because those two things are what make such a count reproducible.

Last updated: July 30, 2026
~43 min read

TL;DR

  • The writer escapes the delimiter. FE 4D on disk decodes to 0xB2 and FE 01 to 0xFE, so a well-formed record never contains a raw 0xB2 after byte 0 — zero occurrences in the 1,010,093 on-disk payload bytes (record[2:]) of the 19,342 records chained by declared length out of all nine native Gen3 MBB files in this corpus. Frame by the declared length first, then unescape the framed body. Doing it in the other order really does destroy framing, which is how the escape got mistaken for "0xB2 recurs inside payloads".
  • A native Gen3 MBB log does carry pack voltage and battery current, at record[34:38] and record[38:42], populated on 144 of 144 RUN records and zero of 417 HIB records. But no shipping decoder reads them and they are empty in the states that are about three quarters of a real log, so the BMS remains the practical electrical source. The MBB is not electrically blind; it is electrically sparse and unread.
  • There is no odometer in a native Gen3 MBB record. We published record[71:75] as one; it is a 12-bit ADC reading in microvolts, and on the weight of the evidence a reading of the 12 V accessory rail. The lattice is proven and the rail label is not: every value in the corpus satisfies v = 5128k + 4864, spanning 0.143 V to 13.440 V, and only 51.5% of consecutive samples are non-decreasing, but "which rail" rests on the firmware’s own text events rather than on a measurement. The field has been withdrawn from the decoder and is emitted raw.
  • All Gen3 records share the header 0xB2 | length | type | uint32 LE timestamp | uint32 LE microseconds | uint16 LE entry counter. MBB types are 0xFB (header), 0xFD (text, the bulk), 0x51 (telemetry, 75 B on current firmware), 0x54 (29 B, purpose unknown) and 0x52 (92 B, extended). Record lengths are firmware-dated: the same SR/F wrote 71-byte 0x51 records in 2023 and 75-byte ones in 2026.
  • BMS types are 0xFB (header), 0xFD (text), 0x4B (52 B, the primary telemetry source), 0x4D (78 B, extended) and 0x4C (70 B, cell detail). 0x4D and 0x4C are not 0x4B with a suffix: they insert 8 and 4 bytes after the shared 13-byte header, so every measurement field is shifted. Reading them at the 0x4B offsets yields plausible cell voltages on exactly zero records.
  • BMS payload[38:41] is a measured uint24 LE pack voltage in millivolts, not the "internal counter" it was filed as for years. It satisfies the 28-series acceptance envelope on 2,189 of the 2,319 telemetry rows the shipping decoder emits from seven BMS files — 94.4%, and 92.1% to 96.5% per file.
  • BMS payload[24] is NOT a state code. The enum 0x0B idle / 0x0C active / 0x09 unlock / 0x0A precharge is withdrawn: the byte takes 37 distinct values, those four cover 58.6% of rows, and it fails a counter test, a temperature test and a text-state test. It is stored verbatim and decoded nowhere.
  • Record byte 67 is NOT a charge-cycle count. It is the low byte of the uint24 LE full-charge-capacity register at record[67:70]. A decoder that read it as a cycle count shipped and was reverted the same day.
  • The 538XX-firmware MBB variant is a routing trap, not a new format. Its 0x2c records are upstream zero-log-parser’s ordinary Riding Status record, decoded since 2015. They carry pack voltage as an exact uint32 millivolt integer, battery current, RPM and five temperatures — but no road speed, and the fitted calibration we published for them is retracted.

The Gen3 MBB is electrically sparse, not electrically blind

Start here, because everything else in this specification follows from it — and because the sentence this section used to open with was too strong. An earlier version of this page said a native Gen3 MBB "contains no pack voltage, no battery current and no road speed that any decoder consumes". That was literally true and it misled, because the qualifier was carrying the whole claim. The bytes are there. Nothing reads them.

What is actually the case on a native Gen3 motorcycle — the Cypher III / FST platform, meaning the SR/F, SR/S and DSR/X files this corpus is built from — is that the Main Bike Board writes pack voltage as a uint32 little-endian in millivolts at record[34:38] and battery current as a signed int32 in milliamps at record[38:42], and that both are populated exactly when the high-voltage system is live: non-zero on 144 of 144 RUN records, 5 of 5 REV, 4 of 4 PARK, and zero of 417 HIB, zero of 408 PWSU and zero of 51 WAIT. Since hibernation and power-up together are 75% of the 0x51 records in a real log, the practical experience of the field is that it is usually empty. No shipping decoder — ours included — extracts it. So the Battery Management System remains the source you should build on, but the reason is sparseness and lack of tooling, not absence.

The record mix is what makes the shape of the file concrete. Across eight native Gen3 MBB files from five distinct motorcycles (131,200 bytes each, 19,089 records under length-chain framing), 16,553 records (86.7%) are ASCII text events, 1,311 (6.9%) are 0x51 telemetry snapshots, 1,061 (5.6%) are 0x54 secondaries, 67 (0.35%) are 0x52 extended records and 8 are file headers. A Gen3 MBB log is overwhelmingly a text log with a thin binary spine. The BMS side is similar in shape but its binary spine is densely populated: the seven BMS dumps hold 15,890 records between them, of which 13,178 (82.9%) are text, 1,803 (11.4%) carry the 0x4B telemetry type byte, 691 (4.4%) the 0x4D extended type and 84 the 0x4C. Every count on this page is now taken from length-chain framing with the escape honoured, which is why they sit slightly above the gap-split figures an earlier version quoted — chain framing recovers about 250 more records across the same eight MBB files.

The evidence for record[34:38] is a population gate plus a physical curve, not a correlation. The population gate is above and it is exception-free. The curve is better: on a 2021 file — the 16 kB truncated stub, the ninth native MBB file in this corpus and the one outside the eight-dump population the record-mix figures use — 32 consecutive CHRG records show the field rising monotonically from 102.081 V to 114.075 V over 5.51 hours while record[38:42] holds between −11.6 A and −15.3 A and SOC climbs 41 to 100 — and on the last six records SOC is pinned at 100 while the voltage still climbs from 111.8 V to 114.1 V. That is a constant-voltage absorption phase. No padding field and no SOC lookup table produces it. Integrating V·I over ΔSOC gives about 13.6 kWh on that file and about 12.4 kWh on a 2023 one — 13.55 and 12.41 by left-rectangle integration, 13.58 and 12.40 trapezoidal, so it is quoted to three significant figures and cannot drift on the integration rule — against a ZF14.4 pack that is 14.4 kWh nominal and 12.6 kWh usable. One caution about populations: those CHRG records live in the 71-byte 0x51 variant, which is why the state table further down this page, counted over the 75-byte records, shows only seven CHRG rows.

The scope caveat is severe and it is the reason this is not presented as settled. Every dynamic datapoint above comes from a 538ZF-prefix VIN — the 2020 to 2023 SR/F platform. The one true PAG-WMI motorcycle in this corpus, a DSR/X, contributes 278 0x51 records across two files with 16 and 2 of them populated, one RUN record and no CHRG records at all. Not a single conservation test can be run on PAG hardware here. The surviving alternative — that the layout is shared but DSR/X-era firmware populates it only in states this corpus barely contains — is not excluded.

A separate word at record[21:25] is the same quantity at worse resolution, and the argument for that is arithmetic rather than statistical. It is a multiple of 100 mV on 281 of 281 non-zero readings, where record[34:38] is a multiple of 100 on 2 of 302; on the 247 records where both are populated the two sit a median of 0.163 V apart; and it is zero on all 54 CHRG records while record[34:38] is populated on 47 of them. Prefer record[34:38]. An earlier version of this page argued for record[21:25] from a Pearson correlation against BMS pack voltage; that argument is retired. Re-run over a 2025-12 SR/F MBB and BMS pair, joining 129 RUN records to a BMS row within 60 seconds, seven uint32 windows in the record score |r| ≥ 0.90 — and the strongest is not record[21:25] at 0.907 but the window at offset 30, at 0.925. Correlation does not identify an offset, and treating it as though it does is precisely the mistake that put a 12 V rail on this page as an odometer.

record[25:29] and record[29:33] do not resolve. The former read as a signed int32 in milliamps spans −21.3 A to +176.0 A, which is physically plausible, but it shows no correlation with BMS pack current at any join tolerance tried (|r| ≤ 0.16 at ±10 s, ±20 s and ±30 s). record[29:33] spans 4 to 3,530, which at the historical 0.032 scale factor would be 0.1 to 113.0 km/h — plausible, and completely unverified, because a native Gen3 log contains no independent speed reference to check it against. Neither is read by any decoder and neither should be trusted from this page.

What each decoder writes — not what the firmware encodesTwo of these rows are bytes that exist and are read by nothing. One is a channel that does not exist at all.Gen2 MBBGen2 BMSnativeGen3 MBB538XX MBBGen3 BMSpack voltagepack currentspeedmotor RPMSOCodometer12 V accessorycell mVmotor tempambient tempthrottledecoded per samplepartial — text-derived or state-gatedbytes present, nothing reads themabsent from the formatThrottle is a full row of crosses on purpose. LogEntry.throttle_pct exists in the schema (migration 20241206_001) and is acolumn in the CSV and FIT exports, but no decoder ever assigns it: the value is NULL for every generation.
Read as: what backend/app/parser.py writes onto the entry object, not what the firmware encodes. That distinction was in this caption before and it needed to be in the figure, because it is load-bearing in two rows. Native Gen3 MBB pack voltage and current are marked bytes present, nothing reads them: they sit at record[34:38] and record[38:42], non-zero on 144/144 RUN records and 0/417 HIB records, and no shipping decoder consumes either. Its odometer row is absent rather than partial, because there is no odometer in that record at all — the field published here as one until 2026-07-29, record[71:75], is an ADC-quantised analogue voltage: every one of the 1,121 complete 75-byte 0x51 records satisfies v = 5,128k + 4,864 µV, and it falls on as many consecutive samples as it rises (51.5% non-decreasing), which no odometer does. That is proven. Reading it specifically as the 12 V accessory rail is not: it is a post-hoc identification, high-confidence because the firmware’s own text events quote DC-DC and battery rails in exactly those units and that band, but not verified against a measurement. The row is labelled on that basis and the value is stored raw, in microvolts, under a name that cannot be mistaken for a distance. Partial means the value exists but is not a per-sample channel: native Gen3 MBB motor and ambient temperature are post_2/post_3 (parser.py names post_2 pack_temp_low; tasks.py persists that same byte as motor_temp_c — one field, two names), and Gen2 MBB cell mV appears only as min_cell_mv on discharge-limit events. Those temperatures are not zero outside WAKE, STOP and CHRG, as this caption also claimed until 2026-07-29. Over the nine native Gen3 MBB files, length-chain framed: RUN populates all four post-state temperature fields on 144/144 of the complete 75-byte 0x51 records, plus 57/57 0x52 and 31/31 0x53 records that are also in RUN. Parking mostly, not entirely, zeroes them — HIB carries post_1/post_2 on 4.8% of records (20/417) and PWSU carries post_3/post_4 on 8.3% (34/408), neither ever the full set. The denominators 141/408/403 published here until 2026-07-30 belong to no framing this decoder has ever used. The 538XX column loses its speed mark: that channel was derived from RPM through a fitted constant and has been withdrawn. Reports decoded by that path are stored as log_generation = 3, so “Gen3” in the database is a larger set than “native Gen3” here.

One documented exception

The 538XX-firmware MBB variant does carry pack voltage, battery current, motor current and five temperatures in a record the shipping decoder reads. It is a Gen2-era motorcycle whose MBB was updated to a Gen3-style ring buffer, and reports decoded by that path are stored with log_generation = 3. "Gen3" in a database is therefore not the same set as "native Gen3" in this document. It does not carry road speed — see the retraction in that section.

Framing: one record shape, one delimiter, and a byte-stuffing escape

Every Gen3 record — MBB, BMS and the 538XX variant alike — begins with the same header. Byte 0 is the delimiter 0xB2. Byte 1 is the total on-disk record length in bytes, including the delimiter and including any escape bytes. Byte 2 is the record type. Bytes 3 to 6 are a Unix timestamp as a uint32 little-endian, bytes 7 to 10 are the sub-second part in microseconds, and bytes 11 to 12 are a uint16 little-endian entry counter that increments on every record, not only on telemetry. The payload follows and its interpretation depends entirely on the type byte.

The single most important thing on this page is the next sentence, and an earlier version of this specification did not contain it anywhere: 0xB2 is an ordinary data byte, so the writer escapes it. The two-byte sequence FE 4D on disk decodes to 0xB2, and FE 01 decodes to 0xFE. Consequently a well-formed record never contains a raw 0xB2 anywhere after byte 0 — measured, zero occurrences in the 1,010,093 ON-DISK bytes of record[2:], the whole record bar the delimiter and the length byte, across the 19,342 records length-chained out of all nine native Gen3 MBB files in this corpus — and zero across the native Gen3 BMS records. “On-disk” is load-bearing: unescape those same records and they hold 264 raw 0xB2 bytes, which is the whole point of the escape. The census on the same nine-file population: 504 records carry at least one escape, 518 escape bytes occur in total, decoding as 264 × FE 4D → 0xB2 and 254 × FE 01 → 0xFE, with zero anomalous FE xx pairs. Over the eight full-size app dumps alone — the population every record-mix figure on this page uses — the same census reads 19,089 records, 994,299 on-disk payload bytes, 468 escaped records and 477 escape bytes. The two must never share a sentence uncaptioned. That absence is not a coincidence; it is the guarantee byte stuffing exists to provide.

Order matters, and getting it backwards is what produced the wrong diagnosis this page used to publish. Frame by the declared length first, then unescape the framed body. Byte 1 is the only field that sits outside the escaped region, so it is the only field a reader can trust before unescaping — the type byte, the timestamp and every payload offset all shift when an escape falls in front of them. Unescaping the whole buffer and then splitting on 0xB2 genuinely does shred the file, because unescaping reintroduces 0xB2 bytes mid-payload. That is an argument about ordering, not evidence that the delimiter recurs.

Three decoders in this project frame differently, for historical reasons rather than good ones. Only the native Gen3 MBB path chains by the declared length: read byte 1, advance by it, and resynchronise to the next 0xB2 if the chain breaks. On the nine-file native MBB corpus that yields 19,342 records from 19,351 delimiters with 17 resyncs — about two structural breaks per file, both expected, being the 128-byte file header whose declared 117 bytes do not reach the body, and the torn record at the ring-buffer wrap. The Gen3 BMS path still splits on the delimiter gap and then unescapes each slice. The 538XX path does neither: it scans byte by byte, unescapes each candidate, accepts it on the timestamp gate, and then advances by one byte rather than by the record length — so it evaluates overlapping candidates and lets the per-type guards absorb them. Anything counted on that path is a per-candidate count, not a count of framed records, and this page says so wherever it quotes one. All three are correct enough on this corpus; the chain is the one to implement, because it degrades gracefully and neither of the others does.

The length arithmetic is the cleanest available proof that the escape is universal. Subtract the number of escape bytes from the declared length and every record type collapses onto a single canonical value, without exception: native MBB 0x51 lands on 71 or 75, 0x54 on 29, 0x48 on 62, 0x49 on 38; BMS 0x4B on 52, 0x4C on 70, 0x4D on 78; and on the 538XX variant, 0x2c lands on exactly 40 in 19,516 of 19,516 records whose on-disk lengths are spread across 40 (17,720 of them), 41 (1,688) and 42 (108) — a byte-scan population over the 14 adopted files, so those are candidates rather than chained records. Two further oracles that never touch the length byte agree: the uint16 entry counter at record[11:13] is recovered by unescaping on 38 of 38 records where two clean neighbours pin it to a single integer (raw bytes: 0 of 38), and the timestamp lands inside its neighbours’ bracket on 19 of 19 (raw: 0 of 19).

Only those two sequences ever occur, so the general transform is underdetermined. Upstream zero-log-parser implements FE xx as 0xFE XOR (xx − 1); the simpler xx XOR 0xFF is identical for every odd xx, and both observed successors are odd. Implement the two proven mappings and treat any other FE xx pair as an anomaly to count and surface rather than to transform. How many there are depends on which decoder walked the bytes, so name it: through the three shipped framers this 352-file corpus yields 17 in total — 12 over the 135 538XX-signature files under the byte scan, 5 (all FE 00) in the seven native Gen3 BMS files, and none at all in the nine native Gen3 MBB files. The 12 have successors 0x27 three times, 0x64, 0xC1 and 0x00 twice each, and 0x0F, 0xC2 and 0x84 once. A rival figure of 13 is also right and is a different population: it is what the browser decoder counts on its production route, and it decomposes as 5 in BMS files, 8 in Gen2 MBB files the classifier sends to the length-chain framer, and zero in the 14 real 538XX files. Upstream’s BinaryTools.unescape_block has implemented this for Gen2 all along, so a decoder built on that library needs no Gen3 special case.

Ignoring the escape is not a rounding error. On the 538XX firmware the old gate tested the on-disk length against 40, which discards every escaped telemetry record by construction — 1,796 records across the corpus, and the loss is value-correlated, because a record becomes unrepresentable exactly when one of its decoded bytes happens to equal 0xB2 or 0xFE. Whole parked stretches vanish at once rather than thinning out. On the native Gen3 MBB side, 504 of the 19,342 records in the nine-file census carry an escape, and 291 of them decode to a different date once it is honoured, because the escape sits inside or ahead of the timestamp field.

One 0xB2 recordCommon to native Gen3 MBB, Gen3 BMS and the 538XX MBB variant[0]0xB2delimiter[1]sizeON-DISK[2]typeuint8[3:7]timestampuint32 LE, Unix s[7:11]microsecondsuint32 LE[11:13]counteruint16 LE[13:]payloadtype-specificA · frame by the declared length, then unescapecorrect — byte 1 is the only field outside the escaped regionon diskB20481512FE34D45354C6617D082A90F1000116012F91300140015001600170A1800190020002100220023declared size byte[1] = 0x48 = 72 on-disk bytes (record continues past this strip)FE 4D → B2 · one escape pair, inside the timestamp fielddecodedB20481512B235344C5616D072A80F900106011F91200130014001500160A1700180019002000210022canonical length 71 = 72 − 1 escapeTimestamp bytes [3:7] decoded = B2 53 4C 61 = 0x614C53B2 → 2021-09-23 12:15:14, inside the framer window.Read from the wire instead: FE 4D 53 4C = 0x4C534DFE → 2010-07-31, outside it. The record is thrown away.B · unescape the whole buffer, then split on 0xB2breaks — the escape exists precisely to keep this byte out of the bodyB20481512B235344C5616D072A80F900106011F91200130014001500160A1700180019002000210022fragment 1 — 3 Bfragment 2 — 68 B, no valid headerOne record becomes two fragments. Neither carries a plausible timestamp at +3, so both are discarded — andthis is the failure that was long misread as “0xB2 recurs inside payloads”.
Every Gen3-family record starts B2 · size · type · uint32 LE timestamp · uint32 LE microseconds · uint16 LE counter. The strips below are one real 0x51 record, verbatim from a 2021 SR/S dump — the 16 kB truncated stub, which is the ninth native Gen3 MBB file and outside the eight-app-dump census the pooled counts on this page use — carrying one FE 4D escape pair inside its timestamp field. Order is the whole lesson: frame by the declared on-disk length, then unescape what you framed. Do it the other way round and the recovered 0xB2 becomes a delimiter in the middle of your own record. Across the 14 adopted 538XX dumps in logs/ there are 96,727 0xB2 bytes; walking the length chain and resyncing at each ring-buffer tear, 96,632 of them (99.90%) are genuine record starts, 61 fall interior to a framed record and 34 are never framed at all. Tighten “interior” to mean inside a record whose declared length lands exactly on the next record start and the count drops to 2 — the writer escapes the rest. zero-log-parser LOG_STRUCTURE.md documents the same escape for Gen2, and its BinaryTools.unescape_block implements it.
Which decoder gets the fileMBB file bytes_is_538xx_mbb_variant(raw)raw[0:4] == b'MBB\0' AND raw[38:42] == A1 A1 A1 A1run _parse_538xx_mbb_entries() over the whole bufferoutcome gaten_state ≥ 10 AND telemetry_wire_exact ≥ 50 ← the PRE-unescape countis_definitely_pre_gen3(vin, model) — hard vetomodel ∈ {S, SR, DS, DSR, FX, FXS, XU}OR VIN[9] ∈ 'ABCDEFGHJ' (2010–2018)any positive signal?model ∈ {SR/F, SR/S, DSR/X, FXE} · VIN ~ (ZF[A-Z]Z|XXFZ|DZ[A-Z]Z)OR: ASCII 'MBB' in raw[:32] AND ≥ 10 0xB2 bytes ← the overrideyesnonono signaturepassyesyes538XX decoder adoptedlog_generation = 3MBB charging left to the BMSGen1 / Gen2 pathupstream zero-log-parser JSON→ _json_to_mbb_entriesnative Gen3 decoder_parse_gen3_mbb_entries+ _extract_gen3_telemetryIndependently of all of this, both Gen3 binary framers discard any record whose uint32 timestamp at +3 falls outside2019-01-01 (Unix 1546300800) … today + 365 days.
There is no year threshold anywhere in the classifier. The branch to watch is the last one. “A populated 0xB2 ring buffer in the binary” means, in the code, only this: the ASCII bytes MBB somewhere in the first 32 bytes, plus at least ten 0xB2 bytes anywhere in the file. A 538XX file satisfies both trivially — its header is MBB\0 — so the pipeline logs “Gen3 detected from binary structure” and never takes the upstream Gen2 JSON path. That override, not any gap in the format, is what sent nine of the fourteen 538XX files in logs/ to zero rides. Upstream zero-log-parser has decoded their 0x2c Riding Status records since 2015 and reads one of these files today as 7,629 entries including 2,048 structured Riding rows. An earlier version of this caption said both mis-routings “produce empty output” and that on the Gen2 path upstream decodes every record to a blank “Board Status” row; the second half of that is false. The 538XX probe therefore runs before the routing decision, to get in front of the override. Genuine Gen2 MBB files share the MBB\0 + A1 A1 A1 A1 signature and write the same 40-byte 0x2c record, so neither the signature nor the record type discriminates — only the outcome gate does, and it does so by counting a pre-unescape population that is itself an artifact of a fixed bug. On the 135 signature files in logs/ that gate adopts 14, holds 30 on the Gen2 path and rejects 91. The 30 held files are the reason the counter has not been swapped for the honest one: they now decode 425 to 2,408 telemetry entries each while scoring 0 to 8 wire-exact, and it is that number alone that keeps them on a Gen2 path where their upstream decode is complete and correct. Note also that _is_538xx_mbb_variant’s own docstring says it “must only be consulted INSIDE the firmware-format fallback”; the routing code calls it unconditionally for MBB files, and the code is what runs.
The common record header (offsets are record-absolute, after unescaping)
byte[0]     0xB2        delimiter / fencepost marker
byte[1]     length      total ON-DISK record size in bytes, including byte 0
                        and including any 0xFE escape bytes
byte[2]     type        record type (0xFB, 0xFD, 0x51, 0x54, 0x52, 0x4B, 0x4C, 0x4D, 0x2c)
byte[3:7]   timestamp   Unix seconds, uint32 little-endian
byte[7:11]  microsec    sub-second part of the timestamp, uint32 LE
byte[11:13] counter     entry counter, uint16 LE — every record, not just telemetry
byte[13:]   payload     type-specific

escape      FE 4D  ->  B2
            FE 01  ->  FE
            any other FE xx: an anomaly. Count it, surface it, do not transform it.

Two frames are in play and confusing them costs an afternoon. These offsets are record-absolute, measured from the 0xB2. Upstream zero-log-parser slices log_data[address+2 : address+length], unescapes that, and then takes message = block[5:] — so an upstream payload offset is 7 lower than the record-absolute offset used here, and a state field at record[42] is at upstream payload[35]. This project’s own decoder passes raw_entry[6:] internally, a third frame, which is why record[17] appears in the source as binary_data[11]. An earlier version of this page said upstream’s payload began at record byte 6; that was this project’s convention, not upstream’s.

Frame by declared length, then unescape

The delimiter never appears raw inside a well-formed record, because the writer escapes it: FE 4D decodes to 0xB2 and FE 01 to 0xFE. Splitting on every 0xB2 therefore works better than it has any right to — but the escape still has to be honoured, because the declared length counts on-disk bytes. Frame by byte 1, then unescape the framed record. Unescaping the whole buffer first reintroduces 0xB2 mid-payload and is measurably worse. This page previously told you the opposite, that 0xB2 recurs inside payloads and that this is why a naive split fails; on the 538XX firmware it names, 96,632 of the 96,727 0xB2 bytes in the 14 adopted files (99.90%) are genuine record starts under length-chain framing, 34 are never framed at all and 61 fall inside a record — and of those 61, only 2 sit inside a record whose declared length lands exactly on the next record start. The other 59 are at ring-wrap tears, where “inside a record” means inside a frame the chain had already lost.

Which files count as Gen3

There is no model-year threshold in the classifier. A log is treated as Gen3 when a hard veto does not fire and at least one of three positive signals does — or, failing that, when a separate routing override outside the classifier catches it. Getting this order right matters, because the 0xB2 delimiter is shared with Gen2 dealer-format MbbD files, and a Gen2 file misclassified as Gen3 silently loses its Sevcon and throttle events.

The hard veto fires when the model is one of S, SR, DS, DSR, FX, FXS or XU, or when the VIN year code — the tenth character — is one of A through J, which maps to model years 2010 to 2018. Year code K (2019) is the first that the veto stops rejecting, which is also when the SR/F launched. The veto is a veto, not a threshold: a 2019-or-later VIN still needs a positive signal.

The classifier itself has three positive signals, checked in order. First, the model is one of SR/F, SR/S, DSR/X or FXE, compared with the forward slash stripped. Second, the VIN matches the Gen3 family regular expression, which accepts the substrings ZF?Z, XXFZ or DZ?Z where ? is any single letter. Third — and this one is a conjunction, not a lone pattern — the decoded text contains a Gen3-only marker, either a "State change … from … to …" line or a "Disch limits curr N cap N act N pow N" line, AND contains no Gen2 MBB conditions line, the one that reads "PackTemp: h NC, l NC, PackSOC: N%". Both halves are required: a Gen2 dealer log can carry state-change text of its own, and the absent-Gen2-telemetry half is what stops it counting.

A fourth route into the Gen3 decoder exists, and it is worth separating from the three above because it is easy to read the code and conclude the classifier has four signals. It does not. After the classifier has returned false, the caller applies a routing override: an MBB file whose 0xB2 ring buffer framed successfully, on a motorcycle the hard veto did not reject, is sent to the Gen3 decoder anyway. That is what rescues a file whose header text yields neither a usable model nor a VIN. It happens outside the classifier, so an implementation that reproduces only the table below will not reproduce it. A BMS file has the equivalent shortcut: it is treated as Gen3 when its Gen3 binary loader succeeds, independently of model and VIN.

The FXE deserves its own note, because it is the one model where this specification and the rest of this site can look like they disagree. FXE is in the classifier’s Gen3 model set alongside SR/F, SR/S and DSR/X, so an FXE log matches signal 1 and is stored with log_generation = 3. That is a statement about which decoder the file is routed to, not about the motorcycle’s product generation — a catalogue that describes the FXE as a Cypher II machine is answering a different question, and both can be right at once.

It is also worth being precise about what has actually been verified. The only FXE files in this corpus are three 262,144-byte dealer dumps from one motorcycle, and their MBB body is not a native Gen3 ring buffer at all. It opens with the ASCII "MBB" plus a null rather than a 0xB2, carries A1 A1 A1 A1 at offset 38, contains none of the null-padded RUN, PWSU, HIB or CHRG tokens the native-Gen3 decoder searches for, and holds 1,771 and 2,200 records of type 0x2c across the two MBB files. That is the 538XX firmware variant described later on this page, and it is the path those files decode through. So nothing in the native-Gen3 record maps here has been checked against an FXE.

Gen3 classification inputs
RuleValueEffect
Hard veto — modelS, SR, DS, DSR, FX, FXS, XUGen3 detection skipped entirely
Hard veto — VIN year codeA–J (2010–2018), VIN character 10Gen3 detection skipped entirely
Signal 1 — modelSR/F, SR/S, DSR/X, FXEClassified Gen3
Signal 2 — VIN familyZF?Z, XXFZ or DZ?Z as a substringClassified Gen3
Signal 3 — text patterns("State change … from … to" OR "Disch limits curr …") AND NOT "PackTemp: h …, PackSOC: …"Classified Gen3
Router override — binary structureMBB file whose 0xB2 ring buffer frames, hard veto not firedRouted to the Gen3 decoder outside the classifier
Router override — BMSGen3 BMS binary loader succeedsRouted to the Gen3 BMS decoder
Independent gateUnescaped record timestamp in 2019-01-01 .. now + 365 daysRecord rejected by both Gen3 framers

The first five rows are the classifier: two vetoes plus a three-way disjunction, not a year comparison. A 2021 SR/F with an unreadable model string is still classified Gen3 through its VIN family; a 2019 FX is vetoed on model regardless of anything in the file. The two override rows are NOT part of that disjunction — they are applied by the caller after the classifier has already returned false, which is why an implementation of the classifier alone routes fewer files to the Gen3 decoder than ours does. The overrides still respect the vetoes.

Native Gen3 MBB record types

A native Gen3 MBB file is 131,200 bytes as exported by the Zero app, with the marker "MBB" at offset 0x0D and the first 0xB2 at offset 0. Dealer exports are 262,144 bytes. The counts below are per file and were measured on eight real logs; the ranges are wide because they depend on how the motorcycle was used before the export.

Where the ring buffer goesRecord-type mix of one dump per decoder path, each normalised to 100%87.1%native Gen3 MBB131,200 B app dump · 2,368 records0xFD text2,0630x51 telemetry1510x54 secondary1510x52 / 0xFB313.7 text : 1 telemetry26.6%32.7%40.6%538XX MBB262,144 B dealer dump · 7,758 records0xFD text2,0670x2c telemetry2,54012 other types3,1510.81 text : 1 telemetry84.5%10.1%Gen3 BMS131,328 B app dump · 2,312 records0xFD text1,9530x4B telemetry2340x4D ext (+8)950x4C/00/FB308.3 text : 1 telemetrySegments thinner than 4 px are drawn at 4 px; read the counts, not the sliver.
Each column is one named real file, normalised to its own record count, so the comparison is about proportion. Left and right are the MBB and BMS halves of a single app export from one SR/F — 20260207_14.37_538ZFAZ75MCK17XX3_MBB.bin and …_BMS1.bin; the middle column is 538xxcz4xkcj11XX5_MbbD_2025-12-12.bin, a 262 kB dealer dump. All three columns are counted the same way — length-chain framing, frame by the declared length then unescape — which is what changed the middle column most: it was quoted here as 6,135 records and 2,487 telemetry, from a pass that could not see an escaped record. Two of the three shipped decoders do something else at runtime (the BMS gap-frames; the 538XX path byte-scans with overlapping candidates), so this is a measurement of the format, held constant across the columns to make them comparable — and it agrees with the shipped 538XX scan, which reads 2,283 canonical-40 0x2c records straight off the wire in this file plus 257 recovered by unescaping, for the same 2,540. These per-file numbers sit a little off the pooled corpus figures the article quotes (length-chain: 19,089 MBB records across the eight app dumps, 15,890 BMS across seven); same measurement, different denominator. The consequence is the shape of the argument: a native Gen3 MBB spends 87.1% of its 2,368 records on ASCII text and gives you 151 telemetry records — which do carry pack voltage and current at record[34:38] and record[38:42], but no decoder reads them and they are zero in hibernation (0 of the 417 HIB records across the nine native Gen3 MBB files, against 144 of 144 in RUN). The 538XX variant writes 2,540 telemetry records that a decoder actually consumes, and that is the whole difference.
MBB record types, measured across eight native Gen3 files
TypeNameTypical sizeCount per fileShareContents
0xFBHeader117 B1VIN, board type, serial, part number, firmware build
0xFDText eventvariable, 28–105 B1,811–2,33186.7%Null-terminated ASCII log messages
0x51Telemetry75 B (71 B on 2023 firmware)131–2396.9%State, SOC, pack voltage, battery current, four post-state integers
0x54Secondary29 B0–2395.6%Paired 1:1 with 0x51; purpose unknown
0x52Extended92 B (88 B on 2023 firmware)0–310.35%Same shape as 0x51 with four extra pre-state bytes
0x48 / 0x49 / 0x4F / 0x53Firmware-specificvariable0–690.5%Present on some firmware builds only; undecoded

Shares are of 19,089 records across the eight files, framed by the declared length and unescaped. Per-file totals ranged from 2,238 to 2,535 records. Sizes in the third column are the canonical, post-unescape lengths; on disk a record carrying one escape is one byte longer.

Header record (0xFB, 117 bytes)

Exactly one header record appears per file, and its timestamp is the download time rather than a log time. The fields below were read from real files; VIN serials are masked here in the same way the internal documentation masks them.

MBB 0xFB header field map
[0]        0xB2
[1]        0x75 (117)
[2]        0xFB
[3:7]      timestamp — download time, uint32 LE
[7:12]     internal counters
[12]       log format version
[13:16]    "MBB"
[23:31]    board type — observed "NSRF" (SR/F), "ODSRX" (DSR/X)
[41:59]    VIN, null-terminated — e.g. "538ZFAZ75MCK17XX3"
[60:74]    serial number
[92:101]   MBB part number — observed 40-08155, 40-08198, 40-08285
[107:116]  firmware build identifier

Observed part numbers map to platforms: 40-08155 on 2020–2021 SR/F, 40-08198 on SR/S and a 2023 SR/F, 40-08285 on DSR/X. The part number alone does not determine the record layout — see the firmware-drift note below.

Text event record (0xFD, variable)

Text events are the bulk of the file and the only place a native Gen3 MBB ever states a high-voltage pack reading. The payload is null-terminated ASCII starting at record byte 13, after a four-byte internal counter, a sequence number and a board marker byte (0x02 on the MBB, 0x01 on the BMS).

A single hibernation wake cycle produces a highly repeatable burst of these. Measured over 416 complete wake cycles in the eight full-size app dumps — a cycle being a pair of consecutive "Hibernating for N sec" markers 3,000 to 4,200 seconds apart, over length-chain-framed records; count only the markers that declare 3,600 seconds and the same eight files give 411 — the median burst is 31 records, most often 27 text events, 2 telemetry records and 2 secondaries, written over a 91-second window. The markers recur on a 3,691-second median interval, which is the declared 3,600-second sleep plus the burst that follows it. That period is not inferred: the last record of each burst is literally the text "Saving Stats, Hibernating for 3600 sec", and 444 of the 462 hibernate directives in those eight files specify 3600 seconds — 446 of 464 if the ninth, truncated file is counted. The remaining 18 specify 31,536,000 seconds, which is 365 days: a storage or transport mode.

The internal documentation stated the hibernation rate two different ways, "about 15 to 20 entries per hour" and "about 15 to 20 entries per wake-up cycle, occurring every 1 hour". Per wake-up cycle is the correct framing, and because the cycle is hourly the two rates coincide. The number is the part that was wrong: the measured median is 31 raw records per cycle, not 15 to 20.

  • "State change from STRT to PWSU" and "State change from PWSU to HIB" — the wake and re-hibernate transitions.
  • "Saving Stats, Hibernating for N sec" — the sleep interval the board just requested.
  • "Registering Mod N (NNNNNNmV, N brick )" — the only high-voltage pack reading on the MBB side.
  • "Disch limits: curr N cap N act N pow N" and "Ch limits: ..." — discharge and charge power envelopes in watts.
  • "Fault pending: CODE" and "Fault cleared: CODE" — fault transitions, e.g. HVIL_OPEN.
  • "Requesting 12v charge. DC-DC NuV, Battery NuV, Combined NuV, Ambient NC" — the 12 V accessory battery, in microvolts.
A complete hibernation wake cycle, 31 records in 91 seconds
01:43:28  0xFD  Board was reset. Source: Hib Wake RTC, Power-On, Supply WD, ...
01:43:29  0xFD  MBB RTC treated valid.
01:43:29  0xFD  Kill Sw = STOP
01:43:29  0xFD  Immobilizer = LOCKED
01:43:29  0xFD  Control flags changed. Old: 0x0000 New: 0x400020
01:43:29  0xFD  Disable flags changed. Old: 0x0000 New: 0x008d
01:43:29  0xFD  State change from STRT to PWSU
01:43:29  0x51  telemetry  state='PWSU'  soc=0
01:43:29  0xFD  Disch limits: curr 0 cap 0 act 0 pow 0
01:43:29  0xFD  Ch limits: curr 0 cap 0 act 0 pow 0
01:43:29  0x54  secondary
01:43:29  0xFD  Requesting 12v charge. DC-DC 179216uV, Battery 13132544uV, ...
01:43:29  0xFD  Fault pending: HVIL_OPEN sets in 2 seconds
01:43:29  0xFD  Kickstand Sw = DOWN
01:43:32  0xFD  Registering Mod 2 (104832mV, 4 brick )
01:43:32  0xFD  LTSM state: INIT to DIS
01:44:29  0xFD  Timed out in PW Startup
01:44:29  0xFD  State change from PWSU to HIB
01:44:29  0x51  telemetry  state='HIB'  soc=56
01:44:29  0x54  secondary
01:44:29  0xFD  Stopping 12v charge. DC-DC 302288uV, Battery 13142800uV, ...
01:44:59  0xFD  Saving Stats, Hibernating for 3600 sec

Abridged: repeated "Control flags changed" and limit lines are omitted, and the full burst is 31 records. The pack voltage 104832 mV in the "Registering Mod" line is the only high-voltage reading a native Gen3 MBB produces, and it is text, not a binary field.

Telemetry record (0x51, 75 bytes)

This is the binary spine of the MBB log. The decoder does not read the state field at a fixed offset; it searches the record for one of eight null-padded ASCII tokens — RUN, STOP, HIB, STRT, PWSU, CHRG, WAIT, WAKE — and derives every other offset from where it lands. Two populations are in play here and mixing them is how this page got the next number wrong twice. Once the escape is honoured there are 1,121 complete 75-byte 0x51 records across the nine native MBB files, and 1,078 of those were already 75 bytes on disk. The token lands at record byte 42 on every one of them. On 1,069 of the 1,078 it is one of the decoder’s eight tokens; the remaining 9 carry REV or PARK — five and four, all on the one DSR/X here — and are discarded only because GEN3_STATES does not list those two. They are not tokenless, and they are not empty: all 9 carry a populated pack voltage at record[34:38], between 102.561 V and 102.630 V, so adding the two tokens would recover real telemetry rather than noise. The search-based approach is what lets the same decoder read the 92-byte 0x52 record, where the state sits four bytes later.

Two fields are addressed relative to the token. SOC is the single byte nine before it, at record[33] on a 75-byte record. The six post-state uint32s begin nine bytes after the token starts, because the state field is padded to nine bytes.

The mode flag at record[17] deserves a note, because two different revisions of the internal documentation described it two different ways round. It is a mode flag, and it is at byte 17, not byte 11. Measured by state over the 1,121 complete 75-byte 0x51 records: it is 0x00 on 144 of 144 RUN records, 0x6A on 408 of 408 PWSU records, 0x6A on 397 of 417 HIB records with 0x60 on the other 20, and 0x6A on 7 of 7 CHRG. It varies within a single motorcycle by state, so it cannot be the per-bike hardware identifier it was once called. Byte 11, which an earlier revision named as the mode flag instead, is the low byte of the entry counter: it takes 105 distinct values across those same 144 RUN records and lands in {0x00, 0x6A, 0x60} on none of them.

Which 0x51 fields are populated, by state (1,121 complete 75-byte records, nine files)
StateRecordsSOC = 0post_1 / post_2 setpost_3 / post_4 set
HIB4179 (2.2%)20 (4.8%)0
PWSU408373 (91.4%)034 (8.3%)
RUN1440144 (100%)144 (100%)
WAIT5147 (92.2%)3 (5.9%)3 (5.9%)
WAKE4403 (6.8%)44 (100%)
STOP41018 (43.9%)41 (100%)
CHRG7007 (100%)
REV505 (100%)5 (100%)
PARK404 (100%)4 (100%)

The population is the 1,121 records whose length is the canonical 75 AFTER unescaping, which is the same population every other 0x51 figure on this page uses. An earlier version of this table was counted over the 1,078 records that were already 75 bytes ON DISK — the 43 escaped ones dropped, which is the very thing this page spends a section retracting — and read HIB 405, PWSU 397, RUN 130, WAIT 50, STOP 36. Those numbers are right about that narrower population and wrong as a description of the format. The four post-state integers behave as two pairs, not four independent channels: post_1 and post_2 are always populated together, and so are post_3 and post_4. The earlier claim that temperatures appear only in WAKE, STOP and CHRG is not supported — RUN populates all four, and PWSU and HIB each populate one pair. REV and PARK behave exactly like RUN, which is a second argument for adding them to the token list.

MBB 0x51 telemetry, 75 bytes, record-absolute offsets
[0]       0xB2 delimiter
[1]       0x4B (75) declared length
[2]       0x51 type
[3:7]     timestamp, uint32 LE
[7:11]    microseconds — sub-second part of the timestamp, uint32 LE.
          A multiple of 1000 on 1,121 of 1,121 records, max exactly 1,000,000.
[11:13]   entry counter, uint16 LE — increments by exactly +1 on 96.1% of
          consecutive records. Byte 11 alone is NOT a mode flag; see the note.
[13:17]   zeros
[17]      controller mode flag — 0x00 active, 0x6A standby, 0x60 transition
[18:21]   zeros
[21:25]   pack voltage, uint32 LE, mV, quantised to 100 mV — the same quantity
          as [34:38] at worse resolution. Zero on every CHRG record.
[25:29]   pre-state word B — zero on 86.9% of records, undecoded
[29:33]   pre-state word C — zero on 88.8% of records, undecoded
[33]      SOC %, uint8 — 0 means uninitialised, not empty
[34:38]   pack voltage, uint32 LE, mV — non-zero on 144/144 RUN, 5/5 REV,
          4/4 PARK; zero on 417/417 HIB, 408/408 PWSU, 51/51 WAIT
[38:42]   battery current, int32 LE, mA — discharge positive, charge negative
[42:51]   state name, ASCII, null-padded to 9 bytes
[51:55]   post_0 — internal metric, observed 411–4,250, correlates with uptime
[55:59]   post_1 — pack-high temperature, uint32, pairs with post_2
[59:63]   post_2 — pack-low temperature = motor temp, pairs with post_1
[63:67]   post_3 — ambient temperature, uint32, pairs with post_4
[67:71]   post_4 — controller temperature, uint32, pairs with post_3
[71:75]   12 V accessory-rail voltage, uint32 LE, microvolts — NOT an odometer,
          see the retraction below

Three notes. First, [34:38] and [38:42] were published on this page as "[34:42] zeros (padding)"; they are not padding, and the evidence is in the section above. They are measured on five 538ZF (SR/F-platform) motorcycles — on the one PAG-WMI bike in this corpus only 18 of 278 0x51 records populate them, so this is not confirmed for DSR/X-era firmware. No shipping decoder reads either field. Second, the decoder accepts post_1 to post_4 as temperatures only when the value falls in 1–120 °C, and maps them in order to pack-high, pack-low, ambient and controller; the parser calls post_2 the pack-low temperature and the ingest layer stores that same uint32 as the motorcycle’s motor temperature, so "pack-low" here and "motor temperature" elsewhere on this site are two names for record[59:63]. Third, byte 11: an earlier revision of the internal documentation called it the controller mode flag and called byte 17 a per-bike hardware ID. Both were backwards. Byte 11 is the low byte of the entry counter and is high-entropy — 105 distinct values across the 144 RUN records — while byte 17 is a clean function of the state.

Retracted: record[71:75] is not an odometer

This page published record[71:75] as a lifetime odometer in metres, and explained its behaviour as jitter — "13,183.8 km to 13,276.1 km within one 20-minute ride, a spread of 92.3 km". It is not an odometer and it does not jitter. Every one of the 1,121 complete 75-byte records in this corpus satisfies v = 5128k + 4864 with k between 27 and 2,620, which is a 12-bit ADC with a 5.128 mV least-significant bit and about 21 V of full scale. Read as microvolts the field spans 0.143 V to 13.440 V, and the firmware’s own text events quote the accessory battery in exactly those units and that band ("Stopping 12v charge. DC-DC 302288uV, Battery 13142800uV"). The 92.3 "km" spread inside one ride is a 12 V rail moving 13.184 V to 13.276 V. Only 51.5% of consecutive samples are non-decreasing — a coin flip — and an exhaustive scan of every uint32 window from offset 3 to 71 finds no monotone window except the timestamp itself. There is no verified odometer anywhere in a native Gen3 MBB telemetry record.

How this one was caught, and what it cost

The tell was arithmetic, not physics. Consecutive differences of the field share a greatest common divisor of 5,128 with a constant residue of 4,864 on 1,121 of 1,121 samples — a lattice that no distance measurement produces and every ADC does. The corroboration was that five unrelated motorcycles across three model families and five model years (SR/F 2020, SR/F 2021, SR/S 2021, SR/F 2023, DSR/X 2024) all reported "odometers" inside a 272 km window. In this project the field had been passing a 100,000 to 200,000,000 range gate, which 1,121 of 1,121 samples cleared, and was reaching the dashboard as the headline odometer and as per-ride distance. It has been removed from the decoder; the field is now emitted raw and unscaled under an honest name, because "which rail" is high-confidence and not proven.

Secondary record (0x54, 29 bytes) — purpose unknown

The 0x54 record is paired one-to-one with 0x51 in every burst observed, and its content is almost entirely absent. Of the 968 records whose ON-DISK length is the nominal 29 bytes, across the eight app dumps, 848 (87.6%) have bytes 13 through 24 set to zero, and 782 (80.8%) end with the four-byte sentinel FF FF FF FF at record[25:29]; a further 78 (8.1%) end with four zero bytes.

What the record is for is genuinely unknown. The pairing and the sentinel are consistent with a null or error secondary channel — a second measurement source that reports "no data" — but nothing in the corpus distinguishes that hypothesis from a reserved slot the firmware never fills. It is listed here as unknown rather than guessed at, and no decoder reads it.

MBB 0x54 secondary, 29 bytes
[0]       0xB2
[1]       0x1D (29)
[2]       0x54
[3:7]     timestamp — identical to the paired 0x51 record
[7:11]    internal counter
[11]      sequence
[12]      0x02 (MBB marker)
[13:25]   zeros on 87.6% of records
[25:29]   FF FF FF FF on 80.8% of records; 00 00 00 00 on 8.1%

Extended record (0x52, 92 bytes)

The 0x52 record has the same shape as 0x51 with four extra bytes inserted in the pre-state area, which moves the state token from record byte 42 to byte 46 and pushes the post-state block to byte 55. Because the decoder finds the state by search rather than by offset, it reads 0x52 records without special-casing them. Extra bytes at the end hold additional counters that nothing decodes. The record is rare — 67 across the eight app dumps under length-chain framing, 87 with the ninth file included, and 0 to 31 per file — and it appears mainly around WAKE events. An earlier version of this page said 66. That is the same quantity counted as raw 0xB2 delimiters whose un-unescaped timestamp passes the window, which is a different convention and a smaller number.

Record layout is firmware-dated, not part-number-keyed

This is the trap that will cost a third-party implementer the most time. The same motorcycle can write different record layouts at different times, and the MBB part number does not change with it.

A 2021 SR/F with MBB part number 40-08155 produced a 2023 export containing 190 records of type 0x51 at declared lengths of 71, 72 and 74 bytes, plus 31 records of type 0x48, 30 of type 0x53 and 8 of type 0x49, and no 0x54 records at all. The same VIN, same part number, produced a 2026 export containing 151 records of type 0x51 at 75 and 76 bytes, 151 of type 0x54, and none of 0x48, 0x49 or 0x53. A firmware update changed the record set and the record size while the header still reported 40-08155.

The practical consequence: a decoder must key its field map on the declared record length, not on the part number, the model or the year. But this page previously drew a second, wrong conclusion from the same table — that a length one or two bytes over the nominal, 76 rather than 75 or 30 rather than 29, is firmware drift and is benign. It is neither. Those are escaped records, and L minus the escape count lands on the canonical length without a single exception across the corpus. The distinction is testable rather than cosmetic. Across the nine native MBB files there are 69 over-length 0x51 records, and 56 of them carry their state token one byte late, at raw byte 43, which is exactly what one unhonoured escape does; the other 13 carry more than one and are displaced further. (An earlier version of this page said 81 and 71. The 81 folds the 12 over-length 0x52 records into the 0x51 count, and 71 reproduces under no framing convention tried.) Unescape first and the token sits at byte 42 on 1,347 of 1,356 records using the decoder’s eight tokens, and on all 1,356 once REV and PARK are counted.

Observed 0x51 on-disk lengths by file — every over-nominal length is an escape
File exportedModelMBB part0x51 on-disk lengths (of which escaped)0x54 present
2021-09 (16 kB stub)SR/S40-0819871 (32, 0 escaped), 72 (11, 11), 73 (2, 2)no
2023-04SR/F40-0815571 (177, 0), 72 (12, 12), 74 (1, 1)no
2025-10DSR/X40-0828575 (142, 0), 76 (5, 5)yes
2025-12-29SR/F40-0815575 (220, 0), 76 (19, 19)yes
2025-12-31SR/S40-0819875 (152, 0), 76 (2, 2)yes
2026-01-01SR/S40-0819875 (147, 0), 76 (3, 3)yes
2026-01-21DSR/X40-0828575 (130, 0), 76 (1, 1)yes
2026-02-07 08:53SR/F40-0819875 (143, 0), 76 (6, 6)yes
2026-02-07 14:37SR/F40-0815575 (144, 0), 76 (7, 7)yes

All nine native Gen3 MBB files, length-chain framed. Read the bracketed pair as (records at that on-disk length, of which carry at least one 0xFE escape). Every over-nominal length is 100% escape-bearing and every nominal one is 100% escape-free, which is the point: 76 is not a firmware variant of 75, it is 75 plus one escape byte. An earlier version of this table listed six files and understated five of the escaped counts.

The 538XX firmware variant: a routing trap, not a new format

Some motorcycles with 538XX-prefixed VINs — historically Gen2, and rejected outright by the pre-Gen3 veto on model, on VIN year code, or both — were switched by a firmware update to an MBB body that is a flash ring buffer of length-prefixed 0xB2 records. The file looks Gen2 from the outside and Gen3 from the inside. That much is true and it is the reason this section exists.

What this page previously said next is false, and it is the most instructive error on the page. It said: "Before a purpose-built decoder existed, neither path could read it. The Gen2 text parser decoded every record to an empty Board Status row and produced zero usable entries." These files were never unreadable. The 0x2c record is upstream zero-log-parser’s ordinary Riding Status record, dispatched since 2015, with its uint32 millivolt pack voltage read since 2020. Run the upstream parser today on one of these files and it exits cleanly with 7,629 entries including 2,048 fully structured Riding rows carrying odometer, pack voltage, RPM, battery current and ambient temperature.

What actually failed was routing, inside this project. The native-Gen3 loader requires only the ASCII "MBB" somewhere in the first 32 bytes and at least ten 0xB2 bytes — both trivially true of a 538XX file, whose header is literally "MBB\0". It returns thousands of entries, so the pipeline sets "Gen3 detected from binary structure" and never takes the upstream Gen2 JSON path. Nine of the fourteen files in this corpus went to zero rides that way; the other five already worked. The fix that shipped was a decoder. The bug was a router. If you take one thing from this section, take the habit of checking whether the upstream library already handles your format before concluding it does not.

The variant is still a documented exception to the rule that opens this specification, and its records are still worth a field map, because they are the one MBB records a shipping decoder reads electrically. They carry pack voltage, battery current, motor current, five temperatures, SOC, motor RPM and an odometer. They do not carry road speed. When the decode is adopted the report is reclassified as generation 3 and its entries are fed to the existing Gen3 ride detector unchanged.

Detection: a header signature plus an outcome gate

The signature is read from the file bytes, not from the VIN: bytes 0 to 3 are the ASCII "MBB" plus a null, and bytes 38 to 41 are A1 A1 A1 A1, followed in real files by an ASCII build date.

Genuine Gen2 MBB files share that signature exactly, so it is not a discriminator on its own. The probe therefore runs the full decode first and adopts the result only if the outcome clears a threshold: at least 10 state-change entries and at least 50 telemetry entries.

This page used to explain that threshold by saying "real Gen2 passes the signature but writes no 0x2c telemetry records at all". That is flatly wrong. Genuine Gen2 files write plenty: the 121 signature-sharing files this gate rejects contain 137,944 length-chained 0x2c records between them, 46,721 of which are the same canonical 40-byte form, and a 40-file sample decodes 22,831 of 22,867 of them to a plausible pack voltage with a median of 104,392 mV and a pack-to-cell ratio of 28.043. The 40-byte 0x2c is simply the newer-firmware Riding Status record, present throughout the mainstream Gen2 corpus that upstream already parses.

What actually separated the two populations was a second bug. The old gate tested the on-disk length against 40 and so could not see an escaped record; on most Gen2 bikes a field pinned at a value whose low byte is 0xB2 means every telemetry record is escaped, so they scored 0 to 8 while genuine 538XX files scored 146 to 2,353. Fixing the escape erases that accidental separation — 30 of the 135 signature-sharing files jump to between 425 and 2,408 telemetry records. Adopting this decoder for them would be a routing change rather than a decode fix, so the yield gate deliberately keeps counting the pre-unescape population until that question is settled on its own terms, and the same 14 files are adopted before and after. Native Gen3 starts with 0xB2 and fails the signature outright.

The probe runs before Gen3 classification rather than after, because 538XX VINs land on both sides of the veto: some are vetoed to the Gen2 path and others are not vetoed and would be routed to the native-Gen3 decoder.

538XX detection
signature   raw[0:4]  == "MBB\0"
            raw[38:42] == A1 A1 A1 A1
outcome     >= 10 state-change entries  AND  >= 50 telemetry entries
            (counted PRE-unescape, deliberately — see above)
framing     scan for 0xB2, take byte[+1] as the ON-DISK length,
            unescape the framed record, then accept it only when the
            uint32 LE at +3 OF THE UNESCAPED RECORD is a plausible
            timestamp (2019-01-01 .. now + 365 days)
gate        telemetry records are gated on the UNESCAPED length == 40

Read the timestamp after unescaping, not before. The five-byte sequence 06 FE 01 5D 65 occurs exactly once in this 352-file corpus, in a 538XX dealer dump: read raw, the uint32 at +3 is 0x5D01FE06, which is 13 June 2019; unescaped it is 0x655DFE06, which is 22 November 2023. A record dated four and a half years early sorts to the front of the file and poisons any running-maximum guard downstream of it. The same failure at scale is on the native Gen3 MBB side, where 291 of the 19,342 chain-framed records across the nine files decode to a different date once the escape is honoured. Gating on the on-disk length instead of the unescaped one is the same class of mistake, and on the 538XX path it costs 1,796 telemetry records.

Telemetry record (0x2c, exactly 40 bytes)

Offsets are record-absolute, on the unescaped record. This map is upstream zero-log-parser’s own run_status table, not something derived here — upstream’s payload offsets are these minus 7. Every channel is range-guarded so that corrupt ring-buffer frames are dropped rather than charted. Two derived quantities are computed in the decoder: power as V × A / 1000, and shaft torque as kW × 9549 / rpm for rpm above 100, which is gearing-independent in a way that speed is not.

538XX 0x2c telemetry field map (upstream run_status, record-absolute)
OffsetWidthFieldDecodeAccepted range
+7uint8Pack temperature high°C0 < t < 80
+8uint8Pack temperature low°C0 < t < 80
+9uint16 LEState of charge%0–100
+11uint32 LEPack voltagemillivolts, exact50–130 V
+15int16 LEMotor temperature°C0 < t < 150
+17int16 LEController temperature°C0 < t < 150
+19uint16 LEMotor RPMRPM — no speed is derived≤ 8000
+23int16 LEBattery currentwhole amps, discharge positive±400 A
+25uint8Module presence bitmask0/1/2/3 → 00/10/01/11≤ 3
+26int16 LEMotor currentamps±1000 A
+28int16 LEAmbient temperature°C−50 … 70 inclusive
+30uint16 LEOdometerkm, lifetime, non-decreasingsee guard below

The odometer is the one place this decoder deliberately departs from upstream, which reads a uint32 at +30. The two agree on 19,480 of 19,516 records. The 36 disagreements are spread across four files, 31 of them in one, and every one is a record where byte 32 reads 2 and turns a 6,972 km odometer into 138,044 km — implausible for the bike and not monotone with its neighbours. The decoder’s own odometer_high_word_rejected counter over the same files reads 36, which corroborates the count independently. The uint16 read is kept, at the cost of wrapping past 65,535 km; no motorcycle in this corpus is close.

Retracted: the fitted calibration

This subsection used to be titled "Calibration provenance" and it opened: "None of the scale factors above was assumed. Each channel was fitted against either BMS ground truth or a physical conservation law, and the fit statistics are the reason the map is trusted." It then cited voltage fitted against BMS pack voltage at r = 0.96, current validated by distribution matching, and a speed scale of 0.023323 km/h per RPM falling out of odometer distance conservation at r = 0.999. All three claims are withdrawn. This was the worst mistake on the page, and it is worth setting out exactly how a fit with a good correlation coefficient can be wrong.

Voltage. The retired read was a uint16 at +12 scaled by 0.24608 with an offset of 4.25 V. That uint16 is the middle two bytes of an exact 32-bit millivolt integer, with the low byte discarded: the identity u32 at +11 equals 256 × u16 at +12 plus record byte 11 holds on 19,513 of 19,516 records. Fitting a line to a lossy slice of a number you already have exactly cannot be right, and it is not: against the true value the fitted line is off by a median of 0.1391 V, a 99th percentile of 0.5710 V and a maximum of 0.9728 V, and it exceeds 0.1 V on 62.8% of records. A self-contained check that needs no external reference: u32 at +11 divided by the cell voltage at +36 has a median of 28.070, which is a 28-series pack read in millivolts. The correlation of 0.96 was real and it was uninformative, because a monotone lossy transform of a quantity correlates with that quantity by construction.

Current. This one was worse than an approximation. The retired read was an int16 at +22 divided by 256, presented as Q8.8 amps. Record byte 22 is a pad byte — zero on 19,511 of 19,516 records — so that expression reduces algebraically to int8 of record byte 23, an identity verified on all 19,516. The real field is an int16 at +23 in whole amps. 487 records (2.50%) exceed int8 range and 418 of those came out sign-inverted, so a +206 A launch was charted as −50 A. Every motorcycle’s peak discharge was clipped at about 127 A against true per-file peaks of 199 to 385 A, understating the peak by 37.7% to 70.5%. That range compares each decoder’s peak under its own guard, which is what a user would actually have seen; restrict the retired read to only the records the current ±400 A guard accepts and it becomes 37.7% to 73.8%.

Road speed. There is no speed field in this record, and there never was. The 0.023323 constant was fitted from odometer distance conservation, which is a sound method for a constant that exists; refitting it per file gives 0.02273 to 0.02504 across the 13 files with more than 50 km of motion, a spread of ±5.2%, because final drive ratio and tyre circumference genuinely differ by model. The shipped constant sat between 6.9% low and 2.6% high depending on the bike. A per-model table could have been fitted, but the 14 adopted files carry seven distinct VINs and four of those contribute a single file each, so that table would have been fitting noise — the same mistake the voltage calibration made, one level up. The decoder now publishes motor RPM, which is measured, and no derived speed at all.

What replaced the fit is not another fit. It is upstream zero-log-parser’s own run_status field map, which has dispatched 0x2c since 2015. The independent check that it is right is physical rather than statistical: over 483 ride sessions with at least 5% SOC used and at least 2 km covered, integrated electrical energy per SOC percent moved from 43.1 Wh/% to 50.0 Wh/% — implying a usable pack of 5.00 kWh rather than 4.31 kWh, which is correct for the ZF3.6 and ZF7.2 packs these motorcycles carry — and energy per kilometre moved from 46.3 to 54.1 Wh/km against Zero’s own published 55 to 70. The coefficient of variation across sessions fell from 44.8% to 39.4%. A correction that both moves an estimate to the physically right value and reduces its scatter is doing something a better-fitting line cannot.

The odometer carries its own guard, because it is lifetime non-decreasing and the ring buffer is not. Any reading below the running maximum, or zero, is rejected and the last good value is carried forward flat. A forward step is accepted only when the increase is no more than the larger of 5 km and 200 km/h times the elapsed hours since the previous accepted reading. The cadence term stops sparse telemetry from under-counting a long gap while still dropping bit-flipped frames.

What the fitted 538XX calibration costMeasured over 19,516 canonical-40 0x2c records · 14 files · 7 VIN prefixes · shipped byte scanA · voltage — absolute error of the retired fitted line, voltsagainst u32 LE @ +11, which is the same quantity exactly · 19,512 records inside the 50–130 V guard0.00.20.40.60.81.0median 0.139 Vp99 0.571 Vmax 0.973 V37.2%62.8% of records are wrong by more than 0.1 VThe fit was never approximating an unknown. It was approximating a number already present in the record, having thrown away its low byte.B · current — what an int8 could and could not express, ampsint8: −128 … +127 Atrue per-file peak discharge, 199385 A-400-2000200400487 records (2.5%) fall outside the int8 window; 418 of them came out sign-inverted, charting a +206 A launch as −50 A.Each read judged under its own guard, the retired one understated the peak by 37.7% to 70.5%.
This figure replaces a retraction, and it contains no invented data. The slot previously held two schematic scatter panels dramatising r = 0.96 for a fitted voltage line and r = 0.999 for a fitted speed constant. Both fits are withdrawn, so the panels went with them. What is plotted instead is the measured cost of the fit, over the 19,516 canonical-40 0x2c records the shipped byte scan accepts in the 14 files that clear the 538XX adoption gate (17,720 of them already 40 bytes on disk, 1,796 recovered by unescaping). The voltage panel is the decisive one, and its error statistics are over the 19,512 of those records inside the decoder’s 50–130 V guard — the four outside it are corrupt frames that fit to megavolts, so the 0.9728 V maximum is a guarded number and is quoted against the guarded population. The retired u16 @+12 × 0.24608 + 4.25 was fitting a 16-bit slice of a field that is already an exact 32-bit millivolt integer — u32 @+11 == 256 × u16 @+12 + rec[11] holds on 19,513 of 19,516 records — so a correlation of 0.96 was guaranteed by construction and told us nothing. The current panel is the one that mattered to riders: i16 @+22 / 256 straddles a pad byte and reduces algebraically to int8(rec[23]), clipping every motorcycle’s peak discharge at about 127 A. Comparing each read’s peak under its own guard — the retired one had no ±400 A guard, because an int8 can never trip it — the understatement runs 37.7% to 70.5%, worst on 538xx8z19gcj0XX68_MBB_2025-10-26.bin, 102.43 → 347 A. (Restrict the old read instead to the records the new guard accepts and the range is 37.7–73.8%; the first pair is what a rider would actually have seen, so it is the pair published.) Field map now taken verbatim from upstream’s zero-log-parser run_status table, which has dispatched 0x2c since 2015.

Withdrawn: raw × 0.24608 + 4.25, Q8.8 current, and rpm × 0.023323

If you built a 538XX decoder from the previous version of this table, three of its channels are wrong. Replace the uint16 voltage at +12 with the uint32 millivolt integer at +11. Replace the Q8.8 current at +22 with the int16 whole-amp field at +23, which is the one that stops sign-inverting your hard accelerations. And delete the derived speed: the record has no speed field, and the constant we published is off by up to 7% in a model-dependent direction. Motor RPM at +19 is the measured quantity.

State events (0xFD) and why charging stays with the BMS

This firmware writes none of the native-Gen3 binary state tokens. State is derived from ASCII text events whose payload starts at record byte 7 rather than byte 13, and it is carried forward by timestamp onto the following telemetry records.

Charging is deliberately not detected on this path. The 538XX MBB carries no charge telemetry, and its charger-contactor events fire repeatedly within a single charge, which fragments one real charge into dozens of dataless markers. The decoder therefore emits no MBB charging sessions at all and leaves charge detection entirely to the BMS.

538XX state derivation from text
Event text, matched case-insensitivelyDerived state
Module scheme changed from … to Running modeRUN
Module scheme changed from … to Stopped modeSTOP
Module scheme changed from … to Charging modeCHRG
Entering charge standbyCHRG
Leaving charge standbySTOP

What fixing the routing recovered

The before-and-after numbers are real pipeline outcomes and they are worth keeping, provided they are labelled for what they measure. Two 538XX motorcycles went from nothing to a complete history: one from 0 rides and 0 km to 112 rides and 678 km, the other from 0 to 88 rides and 423 km. Over the same run, native-Gen3 and genuine Gen2 files produced byte-identical output, so the new path introduced no regression on the formats that already worked.

What those numbers do not measure is a format being decoded for the first time. They measure a file finally reaching a decoder. Upstream could already read these records; the entries were lost to the binary-structure override described at the top of this section. Read the figures as the cost of a routing bug, not as evidence that a new record layout was cracked.

A second, honest defence of the purpose-built path does survive, and it is narrower than the original claim. On the five files that did reach the upstream Gen2 path, its session detector over-fragments badly — 546 rides across 602 km, about 1.1 km per "ride", against 131 rides and 491 km here — and this project’s physical guards reject about 0.24% of upstream rows as impossible. So the session logic and the guards earn their place. The record decoder did not need to be written; only the router needed fixing.

The odometer unit was confirmed to be kilometres, not metres, by continuity with the same motorcycles’ historical Gen2 odometer readings. The format contract and the discriminator are both pinned by automated tests, so a future change that breaks either fails the build rather than silently reverting these bikes to zero rides.

Why the threshold is an outcome, not a signature

Signature-based detection would misfire on every genuine Gen2 file, because the header bytes are identical. Running the decode and judging the result is more expensive but it was believed to be false-positive-proof. It is not, and the reason is instructive: the yield gate’s discriminating power came from a bug in the decoder it was gating, so fixing the escape erased the separation. The gate now deliberately counts a pre-unescape population it knows to be an artifact, because changing it would silently move 30 files onto a different decoder. When a threshold works, check whether it is measuring the thing you think it is.

Gen3 BMS record types

A Gen3 BMS file exported from the Zero app is 131,328 bytes — exactly 128 bytes larger than the MBB file, which is not a coincidence and is explained under flash page markers below. Dealer exports are 262,144 bytes. Filenames appear as VIN_BMS.bin, VIN_BMS0.bin, VIN_BMS1.bin, VIN_BmsD0_*.bin or VIN_10.bin depending on the export path.

This is the board that matters for electrical data. Everything the application shows for a native Gen3 motorcycle — pack voltage, current, power, cell spread, state of charge, charge energy — originates in the records below.

BMS record types, measured across seven Gen3 BMS files
TypeNameSizeCount per fileTotal in corpusContents
0xFBHeader125 B17Board type, part number, serial, cellbox, firmware
0xFDText eventvariable1,596–1,99713,178State transitions, faults, diagnostics
0x4BTelemetry52 B186–5271,803Cell voltages, current, SOC, pack voltage, state
0x4DExtended telemetry78 B79–126691The 0x4B fields shifted +8, plus the full-charge-capacity register
0x4CCell detail70 B0–6584The 0x4B fields shifted +4; written during operational periods
0x00 / 0xF0Partial or debugvariable12–28127Ring-buffer overwrite artifacts and firmware debug

The seven files hold 15,890 length-chained records between them, from five distinct VINs, and the six rows above account for all of them — the header row is the seven one-per-file 0xFB records. Counted a different way the same seven files give 16,005 raw 0xB2 delimiters and 15,998 gap-framed records, which is what the ring-buffer page publishes; those are three conventions over one set of bytes, not three disagreeing measurements. Five one-off type bytes (0x78 twice, 0x6B, 0x6C, 0x76 once each) are folded into the partial/debug row and are too rare to characterise. Note that a 0x4C is a 70-byte record and 70 is not on the gap-dispatch list any length-first decoder uses, which is exactly why every one of them went to the text parser until the type dispatch landed.

Header record (0xFB, 125 bytes)

One per file. The BMS header identifies the battery control board rather than the motorcycle, which is why it is the right place to look when a pack has been swapped.

BMS 0xFB header field map
[0]        0xB2
[1]        0x7D (125)
[2]        0xFB
[3:7]      timestamp
[13:16]    "BMS"
[23:31]    board type — "FST BCB" on every Gen3 file measured
[40:52]    part number — 40-08247-01 (3 files), 40-08295-01 (2),
           40-08367-01 (2). That is every value in this corpus
[56:69]    serial number
[72:80]    cellbox part number
[96]       firmware version major
[97:99]    cell count
[99:108]   firmware hash

Text event record (0xFD, variable)

Same structure as the MBB text record, except that the board marker at record byte 12 is 0x01 rather than 0x02. These events are the highest-confidence signal for when the motorcycle was actually turned on, which makes them load-bearing for session detection.

  • "Entering ZERO_BMS_STATE_OPERATIONAL" — the motorcycle was turned on.
  • "Entering ZERO_BMS_STATE_IDLE" — the motorcycle was turned off.
  • "Entering ZERO_BMS_STATE_HIBERNATE", "…_STANDBY", "…_PRECHARGE", "…_UNLOCK" — the remaining states.
  • "Precharge complete" and "Precharge: 95%" — contactor pre-charge progress.
  • "Contactor open command received", preceded by a cell millivolt count.
  • "Fault set: INVALID/NO CELL TYPE" — a CRC error reading the cellbox.
  • "Saving stats, hibernating for 86400 sec" — the BMS sleep interval, typically a day rather than the MBB’s hour.
BMS byte 24 — payload[24] / record[32]Under-load fraction by observed value — a monotone activity proxy, not a state machinebyte 24under load — payload[27] = 0xFFnsharebus onmedian |I| mA25%50%75%100%0x0974.5%472.0%72.3%5,7960x1062.2%1114.8%57.7%2130x0C45.2%56624.4%44.3%00x0F40.7%542.3%40.7%720x0835.9%391.7%35.9%00x0A34.5%1426.1%34.5%00xFF29.2%241.0%29.2%00x0720.7%291.3%20.7%00x0D16.0%1315.6%15.3%00x1215.4%522.2%15.4%00x0B14.4%60326.0%13.6%00x0613.9%361.6%11.1%00x0012.5%401.7%25.0%00x1112.2%411.8%12.2%10x0512.0%251.1%12.0%00x0E7.7%261.1%7.7%00x165.9%341.5%5.9%00x143.9%512.2%2.0%00x153.3%301.3%3.3%00x172.9%351.5%2.9%00x132.7%371.6%0.0%00x180.0%301.3%0.0%0one of the four values the retracted enum named (0x09 · 0x0A · 0x0B · 0x0C)a value the enum never named0xFF — a flash sentinel, not a state value. Listed only because it clears the n ≥ 20 bar.Why the four-state reading does not survive re-measurement37 distinct values occur, spanning 0x00–0x23 plus the 0xF0 / 0xFF flash sentinels. 0x09, 0x0A, 0x0B and 0x0Ctogether cover 1,358 of the 2,319 rows — 58.6%. The other 41% are values the enum has no name for at all.Not a counter: 82.0% of consecutive deltas inside a 120 s window are exactly zero (973 of 1,186).Not a temperature: Pearson r = −0.05 against the byte-43 pack temperature; the two agree on 1.3% of rows.One of the four does line up with the BMS’s own text states, and the honest thing is to say so. Carryingeach “Entering ZERO_BMS_STATE_x” event forward in time, 0x09 sits under UNLOCK on 62% of rows (n = 45) — thename it was given. 0x0B does not: the value called IDLE sits under HIBERNATE 41%, IDLE 26%, UNLOCK 14%.Carried by the 52 B 0x4B, 50 B FST Mono, 70 B 0x4C (+4) and 78 B 0x4D (+8) records. No decoder branches on it:stored verbatim as LogEntry.bms_state_code. The reliable load discriminators are payload[27] and payload[28].
payload[24]record[32] — is stored as LogEntry.bms_state_code and no code branches on it; tasks.py only groups it into a per-report distribution. docs/GEN3_BINARY_FORMAT.md once mapped four of its values to a state machine — 0x0B idle, 0x09 unlock, 0x0A precharge, 0x0C active — and that reading has been retracted; the doc now carries the retraction and parser.py’s docstring says in as many words not to republish it. This figure used to draw it, arrows and all. What survives re-measurement is weaker and one-dimensional: the under-load fraction rises roughly monotonically across at least eight codes, and two values the enum never named — 0x10 (n = 111, 62.2% under load) and 0x0F (n = 54, 40.7%) — sit inside that chain, 0x10 above 0x0C. So the ordering is not evidence for the four; it is evidence against treating any of them as a named state. Re-measured on the 2,319 telemetry rows the shipped decoder emits from the 7 Gen3 BMS dumps in logs/: split at every 0xB2, unescape each slice, dispatch on the type byte with the unescaped length as cross-check (0x4C → +4, 0x4D → +8, otherwise the 50/52/78 length rule at +0), then the 2,500–4,500 mV cell gate. The figure carried a 1,557-row table until 2026-07-30; 1,557 is what this decoder emitted before the escape fix and the type dispatch, so every count in it has been re-derived rather than rescaled. Treat the byte as an opaque per-firmware activity hint, not a decode.

Telemetry record (0x4B, 52 bytes) — the primary data source

Payload offsets below are relative to the payload start at record byte 8, matching the convention the decoder uses. To convert to record-absolute, add 8: payload[38] is record[46].

The decoder validates before it trusts. Cell voltages outside 2,500 to 4,500 mV cause the whole record to be discarded as corrupt, which is also what rejects the text events that occasionally land on a telemetry-sized gap. SOC is accepted only in 1 to 100; a zero means sleeping or not calculated, not empty.

BMS telemetry record 0x4B — 52 bytesRecord-absolute offsets. payload[k] = record[8+k]. On a 0x4D add 8 to every offset below; on a 0x4C add 4.+0+1+2+3+4+5+6+7+8+9+10+11+12[0][13][26][39]0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051★ record[46:49] = payload[38:41] — measured pack voltage, uint24 LE, millivoltsDocumented as an internal counter from the first Gen3 decoder until 2026-07-01. Re-identified because the“counter” sagged under load: 2,189/2,319 rows = 94.4% (92.1–96.5% per file) sit inside 28 × cell_min … 28 × cell_max.record header — delimiter · size · typeuint32 LE timestamp, [3:7]cell voltages (low / OCV / high)SOC %pack current, int16 LE mAstate code, load, bus, report mode, temp★ measured pack voltage, uint24 LE mVundecoded / padding
The 52-byte 0x4B record is the only per-sample electrical channel a native Gen3 bike produces. Offsets are record-absolute; the decoder reads a 44-byte payload starting at record byte 8, so payload[k] == record[8+k]. The highlighted record[46:49] payload[38:41] — carried the comment “internal counter ~43000-49000” that “mysteriously sagged under load”. It sags because it is the pack voltage. Re-measured here on the 7 Gen3 BMS dumps, over the 2,319 telemetry rows the shipped decoder emits: 2,189 of 2,319 (94.4%; per-file 92.1–96.5%) satisfy 28 × cell_min ≤ value ≤ 28 × cell_max, which is exactly the acceptance envelope _parse_gen3_bms_entries now uses before falling back to 28 × mean(cell_low, cell_high). This grid is the 0x4B layout and nothing else. A 0x4D shares only the 13-byte header and then inserts 8 bytes; a 0x4C inserts 4. Sweeping candidate shifts and testing whether the cell words land in a plausible 2,500–4,500 mV band, a 0x4D scores 0 at these offsets and 619 of 666 at +8, and a 0x4C scores 0 here and 75 of 78 at +4. Zero is the number to remember: a decoder that applies this map to an extended record does not produce slightly wrong values, it produces values a sanity check silently throws away — which cost 694 telemetry rows here, 42.7% of the population, before the shift was found. (The older figure of 1,557 rows still in circulation is what this decoder emitted before that fix and before the escape fix; 1,557 → 1,625 → 2,319.)
payload[9] settled empirically on the 1,598 0x4B rows the decoder emits
payload[9]RowsReport mode 3 (sleep)SOC = 0Load flag 0x78Mean |current|
0x001,05757 (5.4%)19 (1.8%)590 (55.8%)3,869 mA
0x20517427 (82.6%)517 (100%)504 (97.5%)40 mA
0xF0 / 0xFF246 (25.0%)6 (25.0%)14 (58.3%)3,356 mA

The population is the 1,598 0x4B records inside the 2,319 telemetry rows the shipping decoder emits, because payload[9] only sits at this offset on a 0x4B — on a 0x4D it is eight bytes later and on a 0x4C four. Every one of the 517 rows with payload[9] = 0x20 has SOC = 0, 97.5% have no load, and their mean current is 40 mA against 3,869 mA for the 0x00 rows. 0x20 is the sleeping value and 0x00 is awake. The internal documentation stated the opposite polarity; the code and the measurement agree with each other and the documentation is wrong. 0xF0 and 0xFF are flash sentinels, not states. An earlier version of this table read 1,022 / 511 / 24 and was counted before the 2026-07-29 escape fix.

Report mode distribution, payload[37], over the 1,598 0x4B rows
ValueMeaningRowsShare
1Standard SOC reporting52232.7%
3Sleep / minimal49030.7%
7Active high-rate, charge or discharge43727.3%
4Idle311.9%
5Precharge291.8%
6Unknown281.8%
0 / 240 / 255Uninitialised or flash sentinel613.8%

This byte is payload[37], which is record byte 45 on a 0x4B. It is sometimes quoted as "byte 37 of each BMS entry", which points at the wrong byte. The population is the 1,598 0x4B rows, because that is the only record type where payload[37] and record byte 45 are the same byte; a 0x4D writes its mode at record byte 53. Six real values plus the sentinels account for every 0x4B row, and unlike payload[24] below this one does behave like an enum. Across all 2,319 emitted rows, read at each type’s own shifted offset, the mix is 7 on 592, 1 on 530, 2 on 520, 3 on 500, 4 on 31, 5 on 30, 6 on 28 and a sentinel on 88 — value 2 comes entirely from the 0x4D.

BMS 0x4B payload, 44 bytes, offsets relative to record byte 8
payload[0:2]    entry counter, uint16 LE
payload[2]      internal flag — 0x00, 0xF0 or 0xFF
payload[3]      sequence counter, per BMS scan cycle
payload[4]      sub-counter
payload[5]      constant 0x01 (BMS marker)
payload[9]      sleep indicator — 0x20 sleeping, 0x00 awake (measured, see below)
payload[13:15]  cell voltage minimum, uint16 LE, mV
payload[15:17]  cell open-circuit voltage low, uint16 LE, mV
payload[17:19]  cell voltage maximum, uint16 LE, mV
payload[19]     SOC %, uint8 — 0xFF or 0xF0 means invalid
payload[20:22]  pack current, int16 LE, mA — positive discharge, negative charge
payload[22:24]  charging indicator — FF FF charging, 00 00 not charging
payload[24]     opaque byte — stored verbatim, NOT decoded; 37 values, no enum
payload[25]     constant 0x33
payload[26]     flag — 0x00 normal, 0x0C transitional
payload[27]     load flag — 0x78 no load, 0xFF under load
payload[28]     bus engaged — 0x00 off, 0x01 on
payload[37]     report mode — 1 standard, 3 sleep, 4 idle, 5 precharge,
                6 unknown, 7 active. A seventh value, 2, is written only
                by the 0x4D record.
payload[38:41]  measured pack voltage, uint24 LE, mV — see the next section
payload[41:43]  unknown / padding
payload[43]     temperature, °C, small integer

payload[24] is not a state code: a retraction

This site published, and our own internal notes carried, a four-value enum for payload[24]: 0x0B idle, 0x0C active, 0x09 unlock, 0x0A precharge. It is withdrawn. The byte is read out of every telemetry record and stored verbatim, no decoder acts on its value, and nothing in the corpus supports mapping it to the BMS state names. If you have taken that enum from here, from a copy of it, or from a decoder built on it, discard it.

What the byte actually looks like: across the same 2,319 telemetry rows the shipping decoder emits, used everywhere else on this page, payload[24] takes 37 distinct values — every value from 0x00 to 0x23 except 0x02, plus the two flash sentinels 0xF0 and 0xFF. The four codes the enum named cover 1,358 rows, which is 58.6%, so two rows in five are a value the enum has no name for; and 0x09, the "unlock" state, is 47 rows, 2.0% of the corpus.

Three separate tests were run against the byte and it failed all three. It is not a counter: 82.0% of consecutive deltas inside a 120-second window are exactly zero, 973 of 1,186, and the sequence neither increases monotonically nor wraps. It is not a temperature: correlated against the temperature byte at payload[43] in the same record it gives r = −0.05, and the two bytes are equal on 1.3% of rows. And it does not track the BMS text state machine as a partition — carry each "Entering ZERO_BMS_STATE_x" event forward in time and one of the four does line up with the name it was given, 0x09 sitting under UNLOCK on 62% of rows (n = 45), but 0x0B does not: the value called "idle" sits under HIBERNATE on 41%, IDLE on 26% and UNLOCK on 14%. One match out of four is what a four-state enum looks like when it is wrong, and the honest thing is to report the one that held.

The one property that does survive is weak and it is not an enum. Ranked by the fraction of rows that are under load, the byte orders as a rough activity proxy over at least eight values — but two values the enum never named, 0x10 at 62.2% under load and 0x0F at 40.7%, sit inside the very chain the enum used as its evidence, one of them above the supposed "active" code. A four-state machine cannot have unnamed states sitting between its states.

The honest summary is short. payload[24] is a firmware-internal byte that varies with activity, has at least 37 values, and has no decoded meaning. Treat it as opaque, and do not write "0x0B = idle, 0x0C = active" anywhere without re-deriving it from your own files first.

payload[24] measured distribution, 2,319 telemetry rows, seven BMS files
ValueRowsShareUnder loadEnum claimed
0x0B60326.0%14.4%idle
0x0C56624.4%45.2%active
0x0A1426.1%34.5%precharge
0x0D1315.6%16.0%— unnamed
0x101114.8%62.2%— unnamed
0x0F542.3%40.7%— unnamed
0x12522.2%15.4%— unnamed
0x14512.2%3.9%— unnamed
0x09472.0%74.5%unlock
0x11411.8%12.2%— unnamed
0x00401.7%12.5%— unnamed
0x08391.7%35.9%— unnamed
0x13 / 0x16 / 0x171064.6%2.7% / 5.9% / 2.9%— unnamed
0x06361.6%13.9%— unnamed
0x15 / 0x18602.6%3.3% / 0.0%— unnamed
0x07291.3%20.7%— unnamed
0x0E261.1%7.7%— unnamed
0x05251.1%12.0%— unnamed
0xF0 / 0xFF431.9%10.5% / 29.2%flash sentinels
14 further values, each under 20 rows1175.0%0–33%— unnamed

Every value observed in the corpus is listed or rolled into the last row; nothing is omitted for tidiness, and the rows sum to 2,319. "Under load" is the fraction of rows in that group with payload[27] = 0xFF, read at each record type’s own shifted offset — the same definition the figure above uses, so the two agree row for row. The ordering by that column is the only structure in the byte, and it is a continuum over at least eight values, not four states. An earlier version of this table was counted over 1,557 rows, the population this decoder emitted before the 2026-07-29 escape fix and type dispatch; every count in it moved, so all of them were re-derived rather than rescaled.

Withdrawn: the 0x09–0x0C state enum

The mapping 0x0B idle / 0x0C active / 0x09 unlock / 0x0A precharge was published on this site and is wrong. It covers 58.6% of the 2,319 telemetry rows the shipping decoder emits, fails a counter test and a temperature test, matches the text-state machine on one of its four values rather than four, and is interleaved with unnamed values that behave the same way. The BMS state machine is real, but it lives in the ASCII text events, not in this byte.

Extended telemetry (0x4D, 78 bytes) — and the +8 shift

This page said "the first 52 bytes of a 0x4D record are identical in layout to a 0x4B record". They are not, and this is the single field-map error most likely to cost a third-party implementer a day. A 0x4D shares only the 13-byte header. It then inserts eight bytes, so every measurement field sits eight bytes later than its 0x4B position, and the "26 extra bytes" this section used to describe are the last 26 bytes of a shifted payload rather than a suffix appended to an unshifted one.

The measurement is unambiguous. Sweeping candidate shifts from 0 to 12 and testing whether the two cell-voltage words land between 2,500 and 4,500 mV, over the length-exact records in seven Gen3 BMS files: a 0x4B scores 1,598 of 1,732 at shift 0 and at most 14 anywhere else; a 0x4D scores zero at shift 0 and 619 of 666 at +8, with the next-best shift scoring 2; a 0x4C scores zero at shift 0 and 75 of 78 at +4, with the next-best scoring 3. Denominators move by a few records depending on how the file is walked — a length-chain walk over the same dumps gives 624 of 671 — but the fraction is 92.9% every way we have counted it, and the score at the 0x4B offsets is zero every way. Zero at the nominal offsets is the important number — it means a decoder using the 0x4B map on a 0x4D does not degrade, it fails completely, and if it has a cell-voltage sanity check it will discard every record silently.

Four corroborations of +8 that never touch the cell gate that selected those records. The uint24 at record[54:57] is the pack voltage the 0x4B writes at record[46:49], with a median of 104,793 mV, inside the 28-cell envelope on 578 records, a pack-to-low-cell ratio with median 28.020, and low cell no greater than high cell on 619 of 619. Record byte 59 behaves exactly like the 0x4B temperature byte at record 51 — median 10 against the 0x4B’s 13, and a range of 0 to 27 once the 14 rows carrying a 0xF0 or 0xFF flash sentinel are set aside; taken raw the range is the full 0 to 255, which is what a sentinel does to a small-integer field. Record bytes 51 and 52 are each zero on 603 of 619 and both zero on 594, mirroring the two zero pads flanking the 0x4B report-mode byte. And the load flag, bus-engaged byte and report mode agree with each other rather than with anything external, though not perfectly: of the 72 records reading load 0xFF, 68 also read bus engaged and 67 read report mode 7; of the 527 reading load 0x78, 502 read report mode 2. Report mode 2 occurs on 520 rows in all, so the two sets nearly but do not exactly coincide.

That last number is a small finding in its own right. Report mode 2 is a value the 0x4B record never writes, so the enum has seven values, not six: 1, 3, 4, 5, 6 and 7 from the 0x4B plus 2 from the 0x4D.

The cost of the shift, before it was found, was that every 0x4D and every 0x4C telemetry row was discarded — 744 records at the canonical lengths reached the telemetry branch and every one was gated out by the cell-voltage check. Fixing the offsets recovered 694 of them on this corpus, 619 from the 0x4D and 75 from the 0x4C, which is 42.7% more Gen3 BMS telemetry with none lost. Eighty-four percent of the recovered 0x4D rows — 521 of 619, and 47% to 96% depending on the file — land inside a gap of more than an hour in the same file’s 0x4B series, so this is real sampling density rather than duplicates, and it feeds the battery page and the state-of-health estimate. The one field that always survived is the capacity register, because it is harvested by record length before the gate runs.

BMS 0x4D extra bytes, offsets 52 to 77 of the full record
extra[0:2]    = record[52:54]  two zero pad bytes flanking the report-mode byte
                               (the 0x4B writes the same pair at record[43:45])
extra[1]      = record[53]     report mode — 2 on 520/619, 7 on 72
extra[2:5]    = record[54:57]  measured pack voltage, uint24 LE, mV — the 0x4B's
                               record[46:49] shifted +8. NOT a coulomb counter.
extra[5]      = record[57]     zero pad
extra[6]      = record[58]     flag byte — 0, 1, 3, 5, 6, 9, 25 observed
extra[7]      = record[59]     temperature, °C — the 0x4B's record[51] shifted +8
extra[8:10]   per-bike calibration, uint16 LE — observed 3,083–5,910; the two
              bytes differ by 0–2, so probably an adjacent temperature pair.
              Unverified — treat as undecoded.
extra[10:12]  per-bike cumulative counter, uint16 LE — observed 38,967–56,414
extra[12:14]  calibration — byte 12 constant 0x03, byte 13 varies 150–162
extra[14]     constant 0x00
extra[15:18]  full-charge capacity, uint24 LE, mAh = record[67:70]
extra[18:20]  constant 0x0000
extra[20]     sub-mode — alternates between 4 and 5
extra[21]     constant 0x00
extra[22:25]  full-charge capacity, twin copy = record[74:77]
extra[25]     trailing byte

Withdrawn: the coulomb counter and the sub-state enum

Two lines of the previous version of this map are retracted. extra[2:4] was published as "coulomb counter, uint16 LE — decreases ~3 units/hour in hibernation". It is the low 16 bits of the uint24 pack voltage at record[54:57], an identity that holds on 619 of 619 accepted records, and the "3 units per hour" decay is about 3 millivolts per hour of pack sag while parked. That is the same misreading this page had already retracted for the 0x4B copy of the field, eighteen lines earlier — the 0x4D copy was missed, which is a good argument for grepping a retraction rather than fixing it in one place. And extra[6:8] was published as "sub-state flags, uint16 LE — observed 256, 1024, 5632". It is a [flag, temperature] byte pair: the high byte is degrees Celsius (256 → 1 °C, 1024 → 4 °C, 5632 → 22 °C), spans 0 to 27 and matches the 0x4B temperature byte. The low byte is not always zero — observed pairs include (0,2), (6,18), (0,4), (6,24), (1,13) and (3,10) — so this is not a temperature shifted left by eight, and the three-value enum was an artifact of only ever looking at rows whose flag byte happened to be zero.

Cell detail (0x4C, 70 bytes) — shifted +4 — and the 50-byte question

The 0x4C record appears during operational periods only and is rare: 84 records across the whole corpus, with a per-file count between 0 and 58. It carries the same payload as a 0x4B shifted four bytes later, by the same mechanism as the 0x4D’s eight — four bytes inserted after the shared 13-byte header. Over the 78 length-exact 0x4C records here, cell voltages are plausible at +4 on 75 (96.2%) and at the 0x4B offsets on zero.

At +4 the rest of the record is equally coherent, which is what distinguishes a real shift from a lucky one. Record byte 48 is a zero pad on 75 of 75 and record byte 47 on 74 of 75, record byte 49 is the report mode and reads 7 — active high-rate — on 75 of 75, the temperature at record byte 55 has a median of 14 °C once the three flash-sentinel rows are excluded (16 if they are not), and the uint24 pack voltage at record[50:53] has a median of 108,144 mV. Median current is −9.5 A, so these records are written while charging.

Until this decoder learned the shift it never read one: 70 was not on the gap-dispatch list, so every 0x4C went to the text and event parser. Eighteen of them across the corpus produced an "event" whose text was three consecutive printable bytes of binary payload — strings like "@~\x05" and "7\x0b3". Those eighteen rows are the only entries the type dispatch takes away.

A 50-byte telemetry variant, sometimes called FST Mono, is documented on the SR/S board with part number 40-08201. It uses the same field map as the 52-byte record, truncated by the two trailing bytes, so it has no temperature byte at payload[43]. The decoder accepts a 50-byte gap into the telemetry path on that basis.

It is worth being clear about the limits of that: none of the seven BMS files measured here comes from a 40-08201 board — they are 40-08247-01 on three files, 40-08295-01 on two and 40-08367-01 on two, and that list is exhaustive for this corpus — so nothing here can confirm the variant. What it does show is a cost. Of the 514 fifty-byte gaps in the corpus, 509 are still fifty bytes after unescaping, and 98.0% of them carry the type byte 0xFD, meaning they are text events; none carries 0x4B. Every one of those is passed to the telemetry decoder and then rejected by the cell-voltage sanity check, which is why the misrouting is harmless in practice rather than merely undetected.

Check the type byte, not only the gap

A decoder written from this specification should switch on record[2] and use the unescaped length as a cross-check, which is the inverse of what a gap-first decoder does. Three offset maps are needed, not one: 0x4B at +0, 0x4C at +4, 0x4D at +8. Dispatching on length alone cannot express that, because it cannot tell a 78-byte 0x4D from any other 78-byte record — which is exactly how 675 telemetry rows were being read at the wrong offsets and then silently discarded by a sanity check that was doing its job.

payload[38:41]: the pack voltage that was filed as a counter

For years this field was documented as reserved counter space. An older field map called it "internal counter, uint16 LE, roughly 43,000 to 49,000 when idle, drops under load", and noted that it sagged during riding without explaining why.

It sags under load because it is the pack voltage. The field is three bytes, not two — a uint24 little-endian at payload[38:41], equal to record[46:49] — and it holds the measured pack voltage in millivolts. The truncated uint16 reading of 43,000 to 49,000 was the bottom two bytes of a number like 109,500.

The decoder accepts the measured value only when it satisfies the 28-series acceptance envelope, that is when 28 × cell_min ≤ value ≤ 28 × cell_max, using the minimum and maximum cell voltages from the same record. Measured across the corpus, 2,189 of the 2,319 telemetry rows the shipping decoder emits (94.4%) satisfy it, with a per-file range of 92.1% to 96.5%. Those figures were 1,473 of 1,557, 94.6% and 92.2% to 96.6% before the 2026-07-29 escape fix and type dispatch enlarged the population; both are right about their own population, and 2,319 is the one that ships. When the value falls outside the envelope — sleep frames, and rows corrupted by the ring buffer — the decoder falls back to the old estimate of 28 times the mean of the low and high cell voltages.

This is not a cosmetic correction. Pack power is computed as the absolute value of V × I, so every Gen3 power figure, every charge-energy integral and everything derived from them changed when the field was identified. It is also a good illustration of why an acceptance envelope beats a scale factor: the envelope is derived from two other fields in the same record, so it validates the reading against the motorcycle rather than against an assumption.

The tell was in the data all along

A counter that decreases when you accelerate is not a counter. Any field whose behaviour contradicts its label is worth re-reading at a different width and a different signedness before it is filed as reserved.

The capacity register, byte 67, and what state of health you can actually compute

The 0x4D extended record holds the pack’s full-charge capacity in milliamp-hours as a uint24 little-endian at record[67:70], mirrored at record[74:77]. It is a real stored register rather than noise: the twin copy tracks the primary to within about 1%.

Extraction is deliberately conservative. A sample is collected from a 78-byte record only when three conditions hold: no flash sentinel byte (0xF0 or 0xFF) appears anywhere in record[67:70] or record[74:77]; the twin copies agree to better than 2%; and the value falls between 50,000 and 250,000 mAh, which spans every Zero pack from the ZF7.2 to the ZF17.3. The median of the surviving samples is stamped onto a single telemetry entry per log, because the register is a stable per-pack value and one carrier row is enough.

Measured on the corpus, this yields 50 to 100 clean samples per file and per-pack medians of 124,521, 127,419, 134,793, 143,313 and 144,656 mAh across five motorcycles — which is a pack-capacity reading, one division away from a state-of-health percentage, obtained without needing to catch a large charge session. The register is the one field on a 0x4D that survived the offset bug described earlier, because the harvest selects records by length before the telemetry decoder’s cell-voltage check ever runs.

A wrong reading you may encounter elsewhere: byte 67 is not a cycle count

Record byte 67 has been described — including in our own internal notes, and in a decoder that briefly shipped — as a uint8 charge-cycle count, presented as a key health metric with a per-motorcycle table of values: 16, 105, 137, 187 and 209 across five bikes. That reading is wrong, and the table has been deleted rather than corrected, because the numbers in it meant nothing.

Byte 67 is the low byte of the uint24 little-endian full-charge-capacity register at record[67:70]. It wanders across 0 to 255 with no monotonicity and no relationship to charge count, which is exactly what the low byte of a capacity reading in milliamp-hours does.

The old table refutes itself on its own numbers. It recorded the two bytes above the supposed cycle count as a "per-bike constant, e.g. 486". Read 486 as the high two bytes of the same uint24 and the register sits at byte 67 plus 124,416 mAh. The five "cycle counts" were the bottom 0.2% of a single pack-capacity reading of about 124 Ah — a number that is, unsurprisingly, in the same range as the medians measured above.

The decoder that wrote this value into a charge-cycle column shipped and was reverted the same day once the register was identified. The dataclass field still exists for compatibility and is never populated, and the non-assignment is documented at the point where it would otherwise happen. Anything reading a Gen3 charge-cycle count from this pipeline is reading a null.

The reason to tell this story rather than quietly fix the offset is that the wrong reading is loose in the world. If you have a Gen3 decoder, or a spreadsheet, or an article that reports charge cycles from byte 67, the number in it is the bottom byte of a capacity register and it should be discarded rather than recalibrated.

record[67:70] — one register, not two fieldsFull-charge capacity, uint24 LE, mAh · SR/F 538ZFAZ75MCK17XX3, median of 88 twin-agreeing samplesrecord[67] = 105 is herehigh two bytes = 486 → 486 × 256 = 124,416 mAh0 mAh124,521 mAh99.916% of the registerrecord[67] magnified ×1,190064128192255five published values on one byte’s rangerecord[67] = 105105 ÷ 124,521 = 0.084% of the register255 ÷ 124,521 = 0.20% — the ceilingDrawn at 1.2 px; true width at this scale is 0.61 px.record[67]record[67:70]twin record[74:77]bike (serial masked)16144,656 mAhagrees within 2%DSR/X · PAGDZAZ87RP00XX54105124,521 mAhagrees within 2%SR/F 2021 · 538ZFAZ75MCK17XX3137134,793 mAhagrees within 2%SR/F 2023 · 538ZFDZ79PCK24XX2187127,419 mAhagrees within 2%SR/F 2020 · 538ZFAZ73LCK12XX7209143,313 mAhagrees within 2%SR/S 2021 · 538ZFBZ79MCL15XX0The published table read 16 / 105 / 137 / 171 / 209. On the files in logs/ the same byte reads16 / 105 / 137 / 187 / 209 — each is register mod 256, and none is stable per bike. That is the point.
record[67] was published as “CHARGE CYCLE COUNT ← KEY HEALTH METRIC” with a five-bike table of values. It is the low byte of the uint24 LE full-charge-capacity register at record[67:70], mirrored at record[74:77]. The decoder that wrote it into LogEntry.charge_cycles shipped in 5fd0793 at 18:57 on 2026-07-01 and was reverted in 56f78c5 at 20:18 the same evening — 81 minutes later. Registers here are the median of the sentinel-clean, twin-agreeing samples in each file (n = 50 to 100 per file, upper median as the decoder takes it, sorted(samples)[n // 2]); VIN serials are masked, matching docs/. A uint8 can never exceed 255, so the largest share this byte can ever hold of a ~124 Ah register is 0.20%.

No cycle count exists in the Gen3 binary

Not at byte 67, and not anywhere else that has been identified. If you need a cycle count for a Gen3 motorcycle, it has to be reconstructed from detected charging sessions, with all the caveats that a ring buffer implies.

What state of health can be computed, and what it costs

No explicit state-of-health percentage field exists in any generation of the Zero binary format. Three health-related signals do exist on Gen3, and only the first is close to actionable.

The full-charge-capacity register is the direct reading, and dividing it by the VIN-nominal design capacity gives a state-of-health ratio that does not depend on catching a large charge. It is extracted and stored today but not yet consumed by any endpoint, so no percentage currently shown anywhere is derived from it. Open-circuit voltage at payload[15:17] is an infrared-compensated idle cell voltage that could in principle be mapped to SOC through a cell reference curve, and is available even in a hibernation-only dump; no such curve is implemented. A third signal listed here previously — a coulomb counter at extra[2:4] said to track self-discharge at about 3 units per hour — does not exist: those bytes are the low half of the 0x4D pack voltage, and the decay is pack sag in millivolts.

What actually ships is a generation-independent session method: capacity is estimated per session as energy divided by the fraction of SOC moved, divided by the number of active battery modules, and state of health is the mean of the last five estimates against the nominal per-module capacity.

  • No direct state-of-health byte exists in any generation. Gen3 gets a capacity register, which is one division away, and nothing reads it back yet.
  • The session method needs a large SOC swing. With the 15% gate, a motorcycle that is only ever topped up produces zero estimates and therefore no figure at all.
  • A Gen3 ring buffer can starve the session method outright on a motorcycle that mostly sits, because the sessions are overwritten before they are exported.
  • The open-circuit-voltage route needs an accurate reference curve for the cell chemistry in question, which is not published.
The shipping session-based state-of-health method
SoH % = mean(last 5 capacity estimates) / nominal_per_module x 100
capacity estimate (per session)
        = energy_kwh / (delta_SOC / 100) / active_module_count
sources : charging sessions (energy, SOC gained)
          ride sessions     (energy consumed, SOC used)
filters : delta_SOC >= 15
          charging: energy must be measured, not SOC-inferred
          estimate within 0.4x .. 1.15x of nominal
          interquartile-range outlier removal once >= 10 estimates exist
nominal : nameplate pack capacity, divided by active module count

The "measured, not inferred" filter is load-bearing. A charge recovered from an SOC gap carries an energy value that was itself computed as delta-SOC times capacity, so dividing it back by delta-SOC is circular and would drag the result toward 100%.

Flash page markers and timestamp windows

Two file-level details will confuse a decoder that does not expect them.

The F0 FF 00 marker is a 128-byte page footer, not random corruption

The three-byte sequence F0 FF 00 has been described as a flash artifact appearing "at random positions within records" throughout both MBB and BMS logs. Measured, it is neither random nor present in both.

In every one of the seven Gen3 BMS files, the sequence occurs exactly 1,025 times, first at byte offset 129 (0x81) and then at a stride of exactly 128 bytes, running to offset 131,201. The spacing histogram across 1,024 intervals contains a single value: 128. In all nine native Gen3 MBB files it occurs zero times, and no repeating three-byte pattern exists at any 128-byte stride.

That makes it a structural feature of the BMS log driver: a fixed marker written once per 128-byte flash page. It also explains the file size. A BMS export is 131,328 bytes and an MBB export is 131,200 — a difference of exactly 128, one extra page.

The practical consequence is much better than "treat records containing it as corrupt". The marker positions are entirely predictable: any byte at an offset congruent to 129, 130 or 131 modulo 128 in a BMS file is a page marker rather than record data. A decoder can splice them out before framing, instead of discarding whole records that happen to straddle a page boundary.

A cheap file-integrity check

Counting occurrences of F0 FF 00 in a Gen3 BMS file should return exactly 1,025. A different count means the file is truncated, padded or not a Gen3 BMS export at all — a one-line check that catches a whole class of bad uploads before parsing starts.

Two different timestamp validity windows

The timestamp is a uint32 little-endian at record bytes 3 to 6, and two different windows are applied to it at two different stages. Conflating them produces either silently dropped records or garbage sessions.

The Gen3 binary framers use the strict window of 2019-01-01 (Unix 1,546,300,800) to the present plus 365 days. This page previously described that window as a framing signal — the thing that distinguishes a genuine record boundary from a 0xB2 byte sitting inside a payload. Measured, it does almost none of that. Over the 14 adopted 538XX files the shipped decoder’s own counters turn down 21,106 candidates: 18,002 whose timestamp field reads exactly zero — an erased flash slot the firmware never wrote — and 3,104 that hold something outside the window. Neither class is delimiter disambiguation; the escape does that. (Over all 135 signature files the same counters read 154,668 and 164,256 against 614,539 accepted candidates. That is a different denominator and a byte-scan population, so quote it only with both stated.) An earlier version of this page gave 23,026 rejects, 22,972 genuine starts, 17,992 zeros and "only 54 true non-boundaries"; none of that quartet reproduces, and the genuine-start decomposition of the 3,104 is not something this corpus settles, so it is not claimed here.

The generic post-parse filter, applied to every generation after decoding, accepts calendar years 2015 to 2030. That is what separates entries with usable timestamps from real-time-clock-reset garbage before session detection runs. Records that fail it are kept but sorted to the end, because a record with a broken clock can still carry a valid measurement.

Two practical notes on the values themselves. The most significant byte of the timestamp, record byte 6, is around 0x69 for 2025 and 2026 dates; that byte has occasionally been mistaken for a record type marker, and it is not — the type byte is record[2]. And read the timestamp from the unescaped record, never from the wire. An escape landing inside bytes 3 to 6 shifts everything after it, and a record whose on-disk bytes read as June 2019 can decode to November 2023 once the FE 01 pair is honoured.

Two timestamp windows, two jobsNeither is a stylistic choice. The stricter one is a filter on unwritten slots — NOT the framer’s only boundary test201020122014201620182020202220242026202820302032«1970 RTC reset»21052015-01-01 … 2030-12-31genericis_valid_timestamp()2019-01-01 (Unix 1546300800) …today + 365 drolls forwardGen3 framerunwritten-slot filterWhat the strict window actually filters: unwritten ring-buffer slots, not payload noise. Across the 14 adopted538XX dumps the shipped decoder turns down 21,106 candidates here — 18,002 of them (85.3%) reading a timestampof exactly zero, an erased flash slot; the other 3,104 read some other out-of-window value. The 0xFE escape,not this window, is what keeps the delimiter out of payloads, and byte 1 is what frames a record.Read the uint32 from the UNESCAPED record: 291 records across the 9 native Gen3 MBB files change date onceunescaped, by as much as eleven years — the record in figure 1 reads 2010 on the wire and 2021 decoded.Year code K = 2019 is also the first VIN year the pre-Gen3 veto stops rejecting.
Two windows exist in the code and they do different jobs. is_valid_timestamp() (MIN_VALID_YEAR = 2015, MAX_VALID_YEAR = 2030) is the generic post-parse filter every generation runs, separating usable entries from RTC-reset garbage before session detection. The Gen3 binary framers use a stricter window, 1546300800 (2019-01-01) to now + 365 days. This caption used to justify that by saying a plausible timestamp is the only thing distinguishing a real record boundary from a 0xB2 byte inside a payload. It is not — the 0xFE escape keeps the delimiter out of payloads and the declared length at byte 1 is what frames a record; the window is an unwritten-slot filter, and a good one. What it turns down, from the shipped decoder’s own diagnostics over the 14 adopted 538XX dumps in logs/: 21,106 candidates, of which 18,002 read a timestamp of exactly zero — an erased flash slot — and 3,104 read something else outside the window. (The triple 23,026 / 22,972 / 54 published here until 2026-07-30 reproduces on no population; do not carry it forward.) One thing has not changed: read the uint32 from the unescaped record, because an escape landing inside bytes 3–6 shifts it. Over the nine native Gen3 MBB files, length-chain framed, 291 records decode to a different date once unescaped — 37 of them in the 2023 SR/F dump, whose wire bytes read 1992 and whose decoded bytes read 2023.
Timestamp windows
WindowRangeApplied byPurpose
Gen3 framing2019-01-01 .. now + 365 daysBoth Gen3 binary framers, per record, POST-unescapeReject unwritten / erased ring-buffer slots
Generic validitycalendar years 2015 .. 2030Post-parse, every generationExclude RTC-reset garbage from session detection

Decode status: known, partial and unknown

The table below supersedes the Gen3 rows of the older status table published on the generation-comparison page. It also supersedes an earlier version of itself: four rows here contradict what this page said before, and they are marked so you can tell which claim you are holding. Three statuses are used deliberately — "Decoded" means the shipping decoder writes it onto an entry, "Present but NOT read" means the bytes are identified and no code path consumes them, and "Does not exist" means the channel is absent from the format, not merely undecoded.

Gen3 field decode status
BoardFieldLocationStatus
BMSCell voltage min / maxpayload[13:15], payload[17:19]Decoded — uint16 LE mV, validated 2,500–4,500; add 8 on 0x4D, 4 on 0x4C
BMSCell open-circuit voltagepayload[15:17]Decoded — stored, not yet used for SOC
BMSState of chargepayload[19]Decoded — 0 means sleeping, not empty
BMSPack currentpayload[20:22]Decoded — int16 LE mA, positive discharge
BMSPack voltagepayload[38:41]Decoded — uint24 LE mV, measured, 94.4% in envelope
BMSTemperaturepayload[43]Decoded — °C; absent on the 50-byte variant
BMSCharging indicatorpayload[22:24]Decoded — FF FF while charging
BMSLoad flag / bus engagedpayload[27], payload[28]Decoded — complementary signals
BMSReport modepayload[37]Decoded — 7 known values across the record types; value 6 unexplained
BMSActivity byte (formerly "state code")payload[24]NOT decoded — stored verbatim; 37 values, no enum survives testing
BMSSleep indicatorpayload[9]Decoded — 0x20 sleeping, 0x00 awake (measured)
BMSFull-charge capacityrecord[67:70] + twin at [74:77]Decoded — uint24 LE mAh, stored, not yet consumed
BMSPack voltage (0x4D copy)record[54:57]Decoded — uint24 LE mV; retracted as a "coulomb counter"
BMSPer-bike cumulative counterextra[10:12]Unknown — varies 38,967–56,414, no correlation found
BMSPer-brick cell detail0x4C record, fields shifted +4Decoded — same map as 0x4B at +4; per-brick mapping not established
BMSCharge cycle countDoes not exist. Byte 67 is a capacity byte
MBBStaterecord[42:51] (search-located)Decoded — 8 tokens at byte 42 on 1,069 of 1,078; the other 9 carry REV or PARK
MBBState of chargerecord[33]Decoded — uint8, 0 in PWSU means uninitialised
MBBController mode flagrecord[17]Decoded — 0x00 active, 0x6A standby, 0x60 transition
MBBTemperaturesrecord[55:71], four uint32Decoded — pack-high, pack-low (persisted as motor temperature), ambient, controller
MBBBattery currentrecord[38:42]Present but NOT read — int32 LE mA, populated with the voltage
MBB12 V accessory rail (identification not proven)record[71:75]Emitted raw — uint32 LE microvolts on a 5,128 µV lattice; the ADC lattice is proven, "which rail" is not
MBBRoad speedNot present in native Gen3 binary telemetry
MBBOdometerDoes not exist. record[71:75] is a 12 V rail — retracted
MBBpost_0 internal metricrecord[51:55]Unknown — 411–4,250, correlates with uptime
MBBPre-state words B / Crecord[25:33]Unknown — zero in 87–89% of records, not read
MBBSecondary record0x54, 29 bytesUnknown — paired 1:1 with telemetry, content empty
MBBPack voltagerecord[34:38], and [21:25] at 100 mVPresent but NOT read — non-zero on 144/144 RUN, 0/417 HIB
MBBTypes 0x48 / 0x49 / 0x4F / 0x53firmware-specific recordsUnknown — present on some builds only
538XXVoltage, current, RPM, 5 temps, odometer0x2c record, 40 bytesDecoded — upstream run_status map; the fitted calibration is retracted

Payload offsets are relative to record byte 8 on the BMS and record byte 0 on the MBB, matching the convention each decoder uses. Add 8 to a BMS payload offset for the record-absolute position.

In-browser decoder

This decoder applies the specification above to a file you choose. It frames the 0xB2 records, classifies each type byte, decodes the field maps documented on this page and reports what it could not read, so you can check any offset here against your own log.

It is the same decode as the server pipeline: validated field-for-field against the Python parser on a forced route, which runs every applicable decoder over every one of the 352 real log files — 1,190,446 entries, zero divergences, byte-identical canonical hashes on all 352. On the production route, where the classifier picks one decoder per file, the same corpus yields 67 decoded files and 317,124 entries, also with zero divergences. Gen3 MBB, Gen3 BMS and the 538XX firmware variant are supported; Gen1/Gen2 files are recognised and identified but not decoded here, which is why the production total is the smaller of the two.

Read that as a portability guarantee, not a correctness one. It proves the two implementations agree; it cannot prove either agrees with the firmware. Where the Python decoder is wrong, this one is wrong in exactly the same way — that property is deliberate, and it is what makes the cross-check meaningful at all. The known departures from the format are the ones set out above: several identified fields that no decoder reads, and the retractions marked in place.

Decoded entirely in your browser — this file is never uploaded.

The decoder is JavaScript running in this tab. Your .bin is read with the standard file API, decoded in memory and shown below. This page makes no network request of any kind while you use it: nothing is uploaded, no VIN is transmitted, and no analytics event carries anything from your log. Close the tab and it is gone.

One file is a three-day window. Gen3 writes into a ring buffer that overwrites itself oldest-first: a parked bike wakes about once an hour and writes a median of 31 entries, so the buffer fills in roughly 3.1–3.3 parked days — faster still while riding or charging, when the boards write several times as often. Anything older than that is already gone from the file you just decoded — not lost by this tool, overwritten by the bike. Keeping history means keeping the files: an account stores each export and stitches overlapping ones into one continuous timeline per VIN. This decoder stays free and complete either way.
In the meantime, the full analysis pipeline — session detection, battery health and fault findings — is available by uploading a log, or you can try it on sample data in the read-only demo.

Frequently asked questions

Does the Gen3 MBB log contain pack voltage, current or speed?

Voltage and current: yes, and this page previously said otherwise. Pack voltage is a uint32 little-endian in millivolts at record[34:38] and battery current is a signed int32 in milliamps at record[38:42], and both are populated exactly when the high-voltage system is live — non-zero on 144 of 144 RUN records and zero on 417 of 417 hibernation records. A charge session read from these bytes shows a textbook constant-voltage absorption phase, and integrating them over SOC lands on the pack’s usable capacity. What is true is that no shipping decoder reads either field and that they are empty in the states making up 75% of a real log, so the BMS remains the practical source. Speed: no. There is no road-speed field in a native Gen3 MBB record, and there is no odometer either — record[71:75], published here as one, is a 12 V accessory rail in microvolts. The measurements above come from five SR/F-platform motorcycles; the one DSR/X in this corpus barely populates the field, so treat it as unconfirmed on that hardware.

What is the structure of a Gen3 0xB2 record?

Byte 0 is the delimiter 0xB2, byte 1 is the total on-disk record length including the delimiter and any escape bytes, byte 2 is the record type, bytes 3 to 6 are a Unix timestamp as a uint32 little-endian, bytes 7 to 10 are the sub-second part in microseconds, and bytes 11 to 12 are a uint16 little-endian entry counter. MBB types are 0xFB header, 0xFD text, 0x51 telemetry, 0x54 secondary and 0x52 extended. BMS types are 0xFB header, 0xFD text, 0x4B telemetry, 0x4D extended telemetry and 0x4C cell detail. Frame by byte 1, then unescape the framed body.

Do I need to handle the 0xFE escape?

Yes, and an earlier version of this page did not mention it at all. The writer escapes the delimiter: FE 4D on disk decodes to 0xB2 and FE 01 decodes to 0xFE, so a well-formed record contains no raw 0xB2 after byte 0 — zero occurrences in the 1,010,093 on-disk bytes of record[2:] across the 19,342 records length-chained out of all nine native Gen3 MBB files, of which 504 carry an escape and 518 escape bytes occur in total (264 FE 4D, 254 FE 01, no other pair). Over the eight full-size app dumps alone the same census reads 19,089 records, 994,299 bytes, 468 escaped and 477 escape bytes. Frame by the declared length in byte 1 first, then unescape the framed body; byte 1 is the only field outside the escaped region, so it is the only one you can trust before unescaping. Doing it the other way round reintroduces 0xB2 mid-payload and genuinely does shred the file, which is how the escape got mistaken for the delimiter recurring in payloads. Ignoring it is expensive: on the 538XX firmware, gating telemetry on the on-disk length rather than the unescaped length discards 1,796 records, and the loss is value-correlated rather than random.

Is there an odometer in a native Gen3 MBB log?

No, and this page said there was. record[71:75] was published here as a lifetime odometer in metres whose readings "jitter by tens of kilometres". It is a 12-bit ADC reading in microvolts, and on the weight of the evidence a reading of the 12 V accessory rail — the lattice is proven and the rail label is not. Every one of the 1,121 complete telemetry records in this corpus satisfies v = 5128k + 4864 — a 5.128 mV least-significant bit over about 21 V of full scale — and read as volts the field spans 0.143 V to 13.440 V, which is what the firmware’s own text events report for the accessory battery in exactly those units. Only 51.5% of consecutive samples are non-decreasing, and no uint32 window anywhere in the record is monotone except the timestamp. Five unrelated motorcycles across three model families and five model years all reported "odometers" inside a 272 km window. The field has been withdrawn from the decoder, so these motorcycles now report no odometer and no per-ride distance rather than a fabricated one. A real odometer does exist on Gen2 and on the 538XX firmware variant, at a different offset in a different record.

Can I read a BMS 0x4D record using the 0x4B field map?

No — and it will not fail loudly. A 0x4D shares only the 13-byte header and then inserts eight bytes, so every measurement field sits eight bytes later; a 0x4C inserts four. Sweeping shifts from 0 to 12 and testing whether the cell-voltage words land in a plausible 2,500 to 4,500 mV band, a 0x4D scores zero at the 0x4B offsets and 619 of 666 at +8, and a 0x4C scores zero and 75 of 78 at +4. Zero is the number that matters: a decoder with a cell-voltage sanity check reads the zero-pad block, fails the check and discards every extended record silently. In this project that was 744 records at the canonical lengths — fixing the offsets recovered 694 of them, 42.7% more Gen3 BMS telemetry, 84% of the 0x4D rows landing inside gaps of more than an hour in the standard series. Dispatch on the type byte at record[2] and use the unescaped length only as a cross-check; length alone cannot distinguish a 78-byte 0x4D from any other 78-byte record.

What is at BMS payload[38:41]?

The measured pack voltage, as a uint24 little-endian in millivolts. It was documented for years as an internal counter of roughly 43,000 to 49,000 that mysteriously sagged under load — that was a uint16 read of the low two bytes of the real three-byte value. The decoder accepts it when it falls between 28 times the minimum cell voltage and 28 times the maximum cell voltage from the same record, which is satisfied on 2,189 of the 2,319 telemetry rows the shipping decoder emits (94.4%, and 92.1% to 96.5% per file), and otherwise falls back to a 28-series cell-average estimate. The widely-copied 1,473 of 1,557 is the same test on the population this decoder emitted before the 2026-07-29 escape fix.

What does BMS payload[24] mean — is it the BMS state code?

No. An enum reading 0x0B idle, 0x0C active, 0x09 unlock and 0x0A precharge was published, including on this site, and it is withdrawn. Across the 2,319 telemetry rows the shipping decoder emits, the byte takes 37 distinct values — every value from 0x00 to 0x23 except 0x02, plus the flash sentinels 0xF0 and 0xFF — and those four codes cover only 58.6% of rows. It is not a counter (82.0% of consecutive deltas inside a 120-second window are exactly zero), it is not a temperature (r = -0.05 against the temperature byte in the same record), and it does not partition the ZERO_BMS_STATE_ transitions in the text events: carry those forward and 0x09 does sit under UNLOCK on 62% of rows, but 0x0B, the value called "idle", sits under HIBERNATE, IDLE and UNLOCK alike. It does correlate loosely with whether the pack is under load, but two values the enum never named, 0x10 at 62.2% under load and 0x0F at 40.7%, sit inside that ordering. The decoder stores the byte verbatim and acts on it nowhere. Treat it as opaque.

Is byte 67 of a Gen3 BMS record a charge cycle count?

No. It is the low byte of the uint24 little-endian full-charge-capacity register at record[67:70], mirrored at record[74:77], holding the pack capacity in milliamp-hours — typically around 124,000 to 145,000 mAh. Read as a standalone uint8 it wanders across 0 to 255 with no monotonicity. A decoder that wrote it into a charge-cycle field shipped and was reverted the same day. There is no known charge-cycle field anywhere in the Gen3 binary format.

What is the 538XX firmware variant?

A Gen2-era motorcycle whose MBB was updated by firmware to a Gen3-style flash ring buffer of length-prefixed 0xB2 records. The file has a Gen2 header — "MBB" followed by a null at offset 0, and A1 A1 A1 A1 at offset 38 — but a Gen3-style body. This page used to add that "neither path could read it"; that is false. Its 0x2c record is upstream zero-log-parser’s ordinary Riding Status record, dispatched since 2015, and upstream reads these files today. What failed was routing inside this project: the native-Gen3 loader needs only the ASCII "MBB" and ten 0xB2 bytes, both true here, so a working Gen2 decode was discarded. The records are exactly 40 bytes and carry pack voltage as an exact uint32 millivolt integer, battery and motor current, five temperatures, SOC, motor RPM and an odometer — but no road speed. Detection runs the decode and adopts it only if at least 10 state-change and 50 telemetry entries come out, because genuine Gen2 files share the header signature and write the same record.

How many records does a Gen3 log hold, and what fills them?

Between 2,238 and 2,535 framed records per 131,200-byte MBB file, and 2,038 to 2,327 per 131,328-byte BMS file, measured across eight and seven files respectively and framed by chaining the declared length. Text events dominate both: 86.7% of MBB records (16,553 of 19,089) and 82.9% of BMS records (13,178 of 15,890). Count the same BMS files by splitting on the delimiter gap instead and you get 15,998 records at 82.4% text, which is the pair the ring-buffer page publishes; naming the convention is what stops the two reading as a contradiction. On the MBB the filler is the hibernation wake cycle, which writes a median of 31 records — 27 text, 2 telemetry, 2 secondary — in a 91-second burst, repeating on a 60-minute period announced in the log as "Saving Stats, Hibernating for 3600 sec".

Can a Gen3 log tell me the battery state of health?

Indirectly. No explicit state-of-health field exists in any generation of the format. Gen3 does store a full-charge-capacity register in its 0x4D records, which is one division by the nominal design capacity away from a percentage, but it is currently extracted and stored rather than consumed. What ships today is a session-based estimate: energy divided by the SOC fraction moved, per module, averaged over the last five qualifying sessions, which requires an SOC swing of at least 15% to produce any estimate at all.

What does the F0 FF 00 byte sequence mean?

It is a flash page marker, not random corruption. In every Gen3 BMS file measured it occurs exactly 1,025 times, starting at byte offset 129 and repeating at a stride of exactly 128 bytes with no exceptions. It does not occur at all in native Gen3 MBB files. Because its positions are entirely predictable, a decoder can splice the markers out before framing rather than discarding records that straddle a page boundary, and counting them is a cheap integrity check on an uploaded BMS file.

Credit: the upstream zero-log-parser project

Everything on this page stands on zero-motorcycle-community/zero-log-parser, the open-source project that first documented the Zero log format. We use it directly for Gen1 and Gen2 parsing, and its LOG_STRUCTURE.md remains the community reference for those generations. The Gen3 record maps above are ours only because that project made the problem legible in the first place.

Gen3 support was tracked in issue #16, open from 21 February 2020 to 8 February 2026. The maintainer’s assessment is worth quoting in full, because it is exactly right and it is why this page exists:

“Zero has a new format for SR/F logs which only roughly matches the existing formats, so the parser will almost need to be written from scratch… we have no copies of officially decoded logs to emulate.”

There is still no official specification and no decoded reference output. Every offset here was recovered by measurement, and corrections against real files are welcome.

Every byte offset published here was measured against real files rather than read off a decoder. The record-mix and field-population figures come from eight full-size native Gen3 MBB app dumps and seven Gen3 BMS dumps covering five distinct motorcycles (SR/F, SR/S, DSR/X). A ninth native MBB file exists — a 16,384-byte truncated stub from a sixth motorcycle, which the pipeline really does decode as Gen3 — and it is included in the escape census, the 0x51 state populations and the framing figures but excluded from the record-mix shares; every count on this page says which of the two it belongs to. The framing, escape and 538XX figures come from the full 352-file corpus, of which 135 share the 538XX header signature and 14 pass its yield gate. Measurements were taken on 2026-07-30. Where the decoder and the bytes disagreed, the bytes won: five claims previously published on this page were retracted as a result, and each retraction is left in place with the measurement that killed it rather than being silently deleted.

This is an independent, community-maintained specification produced by reverse engineering. It is not published, endorsed or reviewed by Zero Motorcycles, and no part of it should be treated as an official format description. Record layouts change with firmware — one motorcycle in this corpus changed its telemetry record size between two exports — so verify against your own files before relying on any offset here.