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 instead of being deleted. Every record count on this page names the framing convention and the file count it was taken under, because a count cannot be reproduced without them. The Gen3 decode is also checked record by record against the community parser, and where the two differ the page says why.
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. There are 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 destroys 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], plus the controller’s own DC-bus voltage and current and the motor rpm. All are populated on 144 of 144 RUN records and zero of 417 HIB records, because they go to zero with the high-voltage bus. The shipping decoder has read them since 2026-09-07, gated on a live bus. They are empty in the states that make up about three quarters of a real log, so the BMS remains the better electrical source.
- There is no odometer in a 0x51 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: 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. The odometer is real but it lives elsewhere: in the tail of the longer 0x52 and 0x53 records, on the newer firmware layout only, where it is frozen across a charge and rises only under RUN. Say which record a negative result is about.
- All Gen3 records share a 13-byte header: 0xB2 | length | type | uint32 LE timestamp | uint32 LE microseconds | uint8 sequence | int8 UTC offset. Bytes 11 and 12 were published here as one uint16 "entry counter". They are two fields: byte 11 steps by one on every record and restarts at zero when the board wakes, and byte 12 is the bike’s time-zone setting in hours (−7 on a US bike, +1 and +2 in Europe), which stayed put at every one of the 11 wraps of byte 11 in this corpus. The timestamp is already UTC; byte 12 only says what the dash showed.
- MBB types are 0xFB (header: VIN, model, model year, board and firmware revisions, firmware build), 0xFD (text, the bulk), 0x51 / 0x52 / 0x53 (state telemetry in three sizes), 0x54 (motor: shaft torque, motor current, DC bus voltage), 0x48 (one 49-byte record per charger that answered: name, mains volts, amps and hertz, DC output, CAN id, serial), 0x49 (charge ceiling and target) and 0x4F (a firmware queue that overflowed, almost always a CAN queue). All of them are decoded. 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.
- Every 128-byte page of a Gen3 BMS file after the first opens with 00 F0 FF 00, written OVER four bytes of record data. Whatever was there is gone: before the decoder learned this, 699 of the 2,894 telemetry rows it emitted from eight BMS files read at least one field through a marker, and in 268 of them that field was a timestamp that still looked plausible, a median 22 days from its neighbours. Values under a marker are now withheld, and the 612 records whose successor’s 0xB2 the marker destroyed are re-framed at their declared length instead of being lost. A marker on a record’s type byte leaves a timestamp of 0xHH00FFF0, the same second for every bike in a 194-day span. That is why 2025-10-28 17:40:00 UTC turned up on unrelated motorcycles. On the 1,220-file production corpus the fix removed 290 forged file-days and 228 battery-only rides.
- BMS text starts at record byte 13, right after the header. The decoder used to take "the first printable byte after byte 7" instead, which on a UTC+0 bike began at the sequence byte and stopped at the offset byte’s zero: 53.5% of BMS text events were dropped and 14% emitted as garbage. Fixed in 2026-10, it more than doubles the BMS state-machine events a native Gen3 file yields.
- 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.
- Checked record by record against the community parser’s main branch (90c8cb2), aligned by file offset rather than by time over all 18 native Gen3 files here: all 2,866 cell readings, 1,727 vehicle states, 119 charger records and 18 queue reports agree, every difference in current and pack voltage is a word one side refuses as erased flash or out of envelope, and upstream decodes none of the 33,460 text records.
- The 538XX-firmware MBB variant is a routing trap. 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. They carry no road speed, and the fitted calibration we published for them is retracted.
On this page
The Gen3 MBB carries sparse electrical telemetry
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 carried the whole claim: the bytes were there and nothing read them.
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), 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 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, in practice the field is usually empty. The shipping decoder has extracted it since 2026-09-07, and only from a record whose bus is live: a zero here means "the high-voltage system was down", not "zero volts measured", and publishing it as a reading would put a measurement on a chart for a motorcycle asleep in a garage. So the Battery Management System remains the source you should build on, because the MBB fields, although present, are sparse.
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. 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, against a ZF14.4 pack that is 14.4 kWh nominal and 12.6 kWh usable. Left-rectangle integration gives 13.55 and 12.41 and trapezoidal integration 13.58 and 12.40, so the result is quoted to three significant figures and cannot drift on the integration rule. 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] measures the same bus at worse resolution. It is a second instrument, the motor controller’s own DC-bus voltage in 100 mV steps, beside the BMS-sourced pack voltage at record[34:38] in 1 mV. The argument for "same bus" 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 the window at offset 30, at 0.925, ahead of record[21:25] at 0.907. Correlation does not identify an offset, and treating it as though it does is the mistake that put a 12 V rail on this page as an odometer.
record[25:29] and record[29:33] are the controller’s DC-bus current, a signed int32 in milliamps, and the motor rpm, a signed int32. Both were resolved on 2026-09-07 and have been decoded since, gated on a live bus like the voltages. This page previously said neither resolved, and the reason it could not was that a native 0x51 record carries no independent speed reference. The longer 0x52 and 0x53 records do: they carry a road speed in their tail, and dividing it by the rpm in the same record gives a constant across a whole ride: 0.0252 km/h per rpm on every older-layout row and 0.0266 on the newer-layout bike. That constant is the gearing, and a field that is not an rpm does not hold it fixed. The DC-bus current still shows no useful correlation with BMS pack current at any join tolerance tried (|r| ≤ 0.16), because the two boards do not sample together; it is published as the controller’s reading and nothing more.
backend/app/parser.py writes onto the entry object, not what the firmware encodes. The distinction is load-bearing in several rows. Native Gen3 MBB pack voltage and current were marked bytes present, nothing reads them until 2026-10: they sit at record[34:38] and record[38:42], non-zero on 144/144 RUN records and 0/417 HIB records, and they have been decoded since 2026-09-07. They are partial because a zero there means the bus is down, not zero volts. The odometer is partial for a different reason: there is none in the 75-byte 0x51, and the one in the tail of the longer 0x52/0x53 exists only on the newer firmware layout. 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 also covers values that are not a per-sample channel: native Gen3 MBB motor temperature is post_1 and is zero whenever the motor is not turning, while ambient is post_0 (the five post-state words were labelled one slot out until 2026-09-07, when post_2 was being stored as the motor temperature; it is the inverter), 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, byte 11 is a sequence counter that steps by one on every record of every type, modulo 256, and byte 12 is the motorcycle’s UTC-offset setting in whole hours as a signed byte. The payload follows from byte 13 and its interpretation depends entirely on the type byte.
Bytes 11 and 12 are two fields, a sequence number and the bike’s UTC-offset setting, and this page used to publish them as one 16-bit counter. The next section maps all 13 header bytes, with what each value means and what the sequence byte reveals about a file.
0xB2 is an ordinary data byte, so the writer escapes it. An earlier version of this specification did not mention the escape anywhere. 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, there are 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. Quote either census with its population named. The absence of a raw 0xB2 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 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. That is about two structural breaks per file, both expected: 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, except where a flash page marker has overwritten the next record’s 0xB2; there it cuts the gap back to the declared length (see the page-marker section). 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). That is 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 sequence byte at record[11] is recovered by unescaping on 360 of 360 records where an escape precedes it and its two neighbours pin it to a single value (raw bytes: 9 of 360, over the ten native Gen3 MBB files in this corpus as of 2026-10), 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 costs whole records. 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. 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.
B2 · size · type · uint32 LE timestamp · uint32 LE microseconds · uint8 sequence · int8 UTC offset. The last two were drawn here as one uint16 “counter” until 2026-10, and byte 12 is the bike’s time-zone setting, not a counter’s high byte. 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. The order matters: 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, because the writer escapes the rest. zero-log-parser LOG_STRUCTURE.md documents the same escape for Gen2, and its BinaryTools.unescape_block implements it.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 as this site ran it in July 2026 it read one of these files 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 post-unescape 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 (by the version this site pins) 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. Since 2026-10-03 the last box has a gate in front of it: none of its signals routes an MBB to the native decoder unless the records carry the FST record header (gen3_mbb_fst_layout). Thirty-seven distinct Gen2-container files in logs/ had been sent there by the override or by the XXFZ VIN arm, with every message read six characters late and no odometer or ride decoded; 36 of them now take the upstream Gen2 decode.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, 0x52, 0x53, 0x54, 0x48, 0x49,
0x4F on the MBB; 0x4B, 0x4C, 0x4D on the BMS; 0x2c on 538XX)
byte[3:7] timestamp Unix seconds, uint32 little-endian, TRUE UTC
byte[7:11] microsec sub-second part of the timestamp, uint32 LE, at most 1,000,000
byte[11] sequence uint8, +1 per record of ANY type, mod 256
byte[12] utc offset int8, the bike's time-zone setting in hours (0xF9 = -7).
Informational: never add it to the timestamp
byte[13:] payload type-specific; text records start their ASCII here
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. 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.
The 13-byte record header, byte by byte
Every native Gen3 record opens with the same 13 bytes, on the main board and on the battery-management board alike, and five of its fields are worth reading on their own: the length that frames the record, the type that decides how to read it, a UTC timestamp to the microsecond, a per-record sequence number and the motorcycle’s time-zone setting. The table is the map. The measurements beside it are over the 18 native Gen3 files in this corpus (ten main-board and eight battery-management dumps) and count only records whose 13 header bytes no flash page marker touched.
Bytes 7 to 10 are the sub-second part of the timestamp, in microseconds. They never exceed one second: the largest value over every intact header in those 18 files is exactly 1,000,000, and not one record goes above it. That bound is more useful than it looks, because a record in the older layout carries payload in the same four bytes and breaks it on almost every record, which is how the decoder tells the two layouts apart (see "Two record layouts, decided per file" below).
This site published bytes 11 and 12 as a single uint16 little-endian "entry counter" at [11:13] until October 2026, on this page and in its own decoder documentation. It was wrong, and the measurement that shows it is short. A 16-bit counter carries into its high byte when the low byte wraps from 255 to 0; byte 11 wraps 11 times in these files (nine times on main boards, twice on packs), and at every one of those wraps byte 12 stayed exactly where it was. They are two fields: a one-byte sequence number and the bike’s UTC-offset setting.
| Bytes | Field | How to read it | Measured over the 18 native files |
|---|---|---|---|
| 0 | Delimiter | 0xB2. Never raw inside a record: the writer escapes it as FE 4D, and a literal 0xFE as FE 01 | Frame by byte 1 first, then unescape what you framed |
| 1 | Length | Total ON-DISK size, counting byte 0 and every escape byte. The only field outside the escaped region, so the only one to trust before unescaping | Minus the escape count, every record type lands on one canonical length |
| 2 | Type | 0xFB header, 0xFD text, 0x51 to 0x54, 0x48, 0x49 and 0x4F on the main board; 0x4B, 0x4C and 0x4D on the pack | Read after unescaping, like everything from here on |
| 3–6 | Timestamp | uint32 little-endian, Unix seconds, true UTC | Byte 6 changes once every 2^24 seconds, about 194 days |
| 7–10 | Microseconds | uint32 little-endian, the sub-second part of the timestamp | Maximum exactly 1,000,000; never above it |
| 11 | Sequence | uint8, +1 per record of any type, wraps 255 → 0, and restarts at 0 when the board wakes | +1 between 96.4% of adjacent main-board records and 96.1% of adjacent pack records |
| 12 | UTC offset | int8, the bike’s time-correction setting in whole hours. Informational: never add it to the timestamp | −7, 0, +1, +2 and +8 seen; constant over long runs |
| 13… | Payload | Type-specific. A 0xFD text record’s NUL-terminated ASCII starts here | Byte 13 is printable on 8,786 of 8,788 pack text records no marker touched, and on 18,570 of 18,570 main-board text records |
Upstream zero-log-parser reads bytes 7 to 12 as a six-byte prefix in front of its payload: a sub-second word in microseconds, a per-entry sequence counter (which it measured stepping by one between 97.3% of 826,535 adjacent entries), and a byte it leaves unlabelled as a "marker", noting that a UTC-offset reading of it is still open. The microseconds and the sequence were upstream’s first.
What the sequence byte exposes
Byte 11 is the cheapest integrity check in the format. It steps by one on every record the board writes, whatever the record’s type, so two records that sit next to each other in the file should differ by exactly one. Over the pairs of intact records that are adjacent on disk in the 18 native files, they do on 20,913 of 21,693 main-board pairs (96.4%) and on 13,628 of 14,177 pack pairs (96.1%). The exceptions are not noise. Each kind says something about the file:
- A restart at zero: 506 on the main boards and 536 on the packs. The counter starts again when the board wakes: 499 and 530 of those restarts follow at least a minute of silence, and the record that takes number 0 is the board’s own wake line: "Board was reset" on 453 of the main-board restarts and "Exiting Hibernate" on 51, and on the packs the same two lines almost every time. A restart marks a boot boundary that can be found without reading any text.
- A step forward of more than one: 268 on the main boards, 3 on the packs. The two records are neighbours on disk, but the numbers between them were taken by records that are not at this place in the file.
- A step backwards: 6 on the main boards. Inside a single second the board sometimes stores records out of their numbered order (a run reads 12, 14, 13, 15), so even within one second the position in the file is not always the order the records were numbered in.
- A jump at the ring-buffer wrap (10 on the packs), where the newest record in the buffer is followed by the oldest.
- The 11 wraps from 255 to 0 are ordinary +1 steps, and byte 12 did not move at any of them.
Byte 12: the motorcycle’s UTC-offset setting
Byte 12 is a signed byte holding the bike’s time-correction setting in whole hours: the offset the dash applies to show local time. The timestamp in bytes 3 to 6 is already UTC; the battery console proves it by printing the same epoch beside its own rendering. So the offset is informational: it says what the rider saw on the dash, never how to move a stored time, and a decoder that adds it to the timestamp shifts every event in the file by hours. This site’s decoder stores it once per file, as the most frequent value, with every other value it saw kept beside it.
Five of the 18 files carry a second value. A change inside one file is a change of the setting, not of the clock: a rider moving the dash an hour for daylight saving, or a board that wrote a few records before its stored setting was loaded. The counts look like both: 2,205 records at +1 after 33 at +2, 2,300 at 0 beside 33 at +1, 2,362 at +2 beside 18 at +1, 2,112 at +1 beside 2 at 0 on a pack, and one main board with 69 records at −7 before 2,347 at 0. Through all of it the timestamps stay UTC, so nothing on a timeline moves; only the label a rider would have read changes. Upstream’s own, larger file set shows mostly 0xF9, 0x02, 0xFC, 0xFB and 0x01 in this byte (−7, +2, −4, −5 and +1 as offsets), every one of them a civil time zone.
| Value | Byte | Files where it is the setting | Records | What it means |
|---|---|---|---|---|
| −7 | 0xF9 | 1 main board | 322 | Seven hours west of UTC, a North American setting |
| 0 | 0x00 | 3 main boards, 3 packs | 13,315 | UTC, for example the UK outside summer time |
| +1 | 0x01 | 2 main boards, 2 packs | 8,877 | Central Europe in winter, or the UK in summer |
| +2 | 0x02 | 2 main boards, 1 pack | 6,883 | Central Europe in summer |
| +8 | 0x08 | 2 main boards, 2 packs | 8,908 | Eight hours east of UTC |
A value outside −12 to +14 is not a civil time zone, and the decoder does not read one there. On a battery file that is usually a page marker sitting on the byte, which is why the per-file summary counts only records whose header no marker touched.
Retracted here: "a uint16 entry counter at [11:13]"
Until October 2026 this page, and this site’s decoder documentation, described bytes 11 and 12 as one little-endian 16-bit entry counter. A 16-bit counter carries into its high byte when the low byte wraps; at all 11 wraps of byte 11 in this corpus byte 12 stayed put, and byte 12 takes only the handful of values in the table above. Read as a counter, it put every record of a UTC+2 bike 512 numbers above those of a UTC0 bike for no reason at all.
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, a conjunction rather than 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 that the classifier has four signals, which 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.
For an MBB file the bytes now decide before any of that (2026-10-03). The native decoder reads the FST record header (text from byte 13, the state at a fixed offset), and a Gen2-container file, the "MBB" + null header with A1 A1 A1 A1 at offset 38, frames its 0xB2 records with the payload from byte 7 instead. Sent to the native decoder by the override or by the XXFZ arm of the VIN family, such a file had every message read six characters late and gave up no odometer and no ride. So none of the signals above routes an MBB to the native decoder unless its header is the native 0xFB board record or its records carry the FST header: a sub-second word of at most 1,000,000 on at least 80% of them, and a civil UTC offset in byte 12 on at least 80%, one value holding half. Across the 151 MBB files in this corpus with "MBB" in their first 32 bytes, the two byte signals agree on every file and the populations do not come near each other: the 10 native files score 1.0, 1.0 and at least 0.971, and the 141 Gen2-container files at most 0.149, 0.442 and 0.316. Of the 37 distinct Gen2-container files that used to reach the native decoder, 36 now take the upstream Gen2 decode; the last has a 1970 clock in every record, which no decoder can place.
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.
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,809 and 2,228 records of type 0x2c across the two MBB files. That count is a byte scan over every 0xB2 whose type byte is 0x2c, the same convention that reproduces this page's BMS record-type table exactly; require the on-disk length to be exactly 40 and the same two files give 1,682 and 2,093. (An earlier revision published 1,771 and 2,200 without naming a convention, and those two numbers reproduce under neither of these. Re-measured 2026-07-30.) 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.
| Rule | Value | Effect |
|---|---|---|
| Hard veto: model | S, SR, DS, DSR, FX, FXS, XU | Gen3 detection skipped entirely |
| Hard veto: VIN year code | A–J (2010–2018), VIN character 10 | Gen3 detection skipped entirely |
| Signal 1: model | SR/F, SR/S, DSR/X, FXE | Classified Gen3 |
| Signal 2: VIN family | ZF?Z, XXFZ or DZ?Z as a substring | Classified Gen3 |
| Signal 3: text patterns | ("State change … from … to" OR "Disch limits curr …") AND NOT "PackTemp: h …, PackSOC: …" | Classified Gen3 |
| Router override: binary structure | MBB file whose 0xB2 ring buffer frames, hard veto not fired | Routed to the Gen3 decoder outside the classifier |
| Record-layout gate: MBB | Native 0xFB header, or FST record header on the framed records | Required before ANY signal above routes an MBB to the native decoder |
| Router override: BMS | Gen3 BMS binary loader succeeds | Routed to the Gen3 BMS decoder |
| Independent gate | Unescaped record timestamp in 2019-01-01 .. now + 365 days | Record 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. The record-layout gate sits in front of all of it for an MBB: no model, VIN family or override sends a file whose records lack the FST header to the native decoder.
Two record layouts, decided per file
Not every file that reaches a Gen3 decoder uses the 13-byte header above. Gen2-platform main-board dumps, the 538XX variant and some battery-management exports are streams of 0xB2 records too, but in their older layout the payload (text included) starts at record byte 7, straight after the timestamp: there are no microseconds, no sequence and no offset byte. A Gen2-shaped battery dump can reach the Gen3 battery decoder, because it carries the 0xB2 telemetry the router looks for.
Applying the FST rules to such a file is costly: reading its text from byte 13 cut the first six characters off every message ("DEBUG: CAN Link Is Down" became "CAN Link Is Down") and dropped every record the cut left too short. On one production file that takes this path, byte 7 starts the text on all but one of 3,556 text records. So the layout is decided per file, from the file’s own bytes, by a single test (has_fst_record_layout in the decoder). A file uses the FST layout when either of these holds:
- 1the flash page marker 00 F0 FF 00 opens both its second and its third 128-byte page (only an FST battery image writes it, so this alone settles the question); or
- 2over its records with a full 13-byte header, bytes 7 to 10 read at most 1,000,000 on at least 80% of them, byte 12 reads as a civil UTC offset (−12 to +14) on at least 80%, and one offset value accounts for at least half.
| Population | Files | Bytes 7–10 within one second | Byte 12 a civil offset | Dominant offset |
|---|---|---|---|---|
| FST layout (8 paged battery files, 10 main-board files) | 18 | ≥ 0.970 | ≥ 0.990 | ≥ 0.959 |
| Every other file | 345 | ≤ 0.160 | ≤ 0.442 | ≤ 0.316 |
| Threshold for FST | — | 0.80 | 0.80 | 0.50 |
The thresholds were set on a wider sample of 374 files: this corpus plus eleven production files a production comparison had flagged. There the lowest FST dominant-offset share was 0.685, on a 13.6 MB raw battery dump that crossed a daylight-saving change and wrote −5 and −6; every non-FST file stayed at or below 0.16, 0.44 and 0.32. Paged files are scored too, but the marker alone decides them.
Never decide the layout from a filename or a VIN prefix
A file name is whatever the person who copied the file typed. The one FST image in this corpus that does not carry the app’s export naming is a truncated 16 kB main-board stub, and the eleven production files the comparison flagged included native FST images whose names said otherwise. A VIN prefix is no better: the 538XX variant writes a Gen3-style body behind a Gen2 VIN. Anything the test cannot decide (a file with no full-length record) keeps the older behaviour, which is the one every non-FST file needs. In a legacy record bytes 7 to 10 are payload and byte 12 is the sixth character of a message (the ":" of "DEBUG:"), so the two populations never come near each other.
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.
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. So 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]. The decoder has read them since 2026-09-07, but only while the high-voltage bus is live, 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 consumes.| Type | Name | Typical size | Count per file | Share | Contents |
|---|---|---|---|---|---|
| 0xFB | Header | 117 B | 1 | — | VIN, board type, serial, part number, firmware build |
| 0xFD | Text event | variable, 28–105 B | 1,811–2,331 | 86.7% | Null-terminated ASCII log messages |
| 0x51 | Telemetry | 75 B (71 B on 2023 firmware) | 131–239 | 6.9% | State, SOC, DC-bus and pack voltage and current, motor rpm, ambient, motor, inverter and cell temperatures, 12 V rail |
| 0x54 | Motor | 29 B | 0–239 | 5.6% | Shaft torque, motor current, DC-bus voltage; written beside every 0x51 |
| 0x52 | Extended | 92 B (88 B on 2023 firmware) | 0–31 | 0.35% | Everything 0x51 carries, plus road speed, estimated range and (on the newer layout) the odometer |
| 0x48 / 0x49 / 0x4F / 0x53 | Charge, queue, largest telemetry | variable | 0–69 | 0.5% | Charger table, charge targets, FreeRTOS queue overflow, and the largest state record (all decoded, see below) |
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): the file names its motorcycle
Exactly one header record opens every native Gen3 main-board file, and its timestamp is the download time rather than a log time. It names the motorcycle, and since release 3.37.0 this site’s decoder reads it out of the bytes (the VIN, the model, the model year, the board and firmware revisions and the firmware build) instead of taking any of them from the file name. On the ten native main-board files in this corpus every one of those fields is present and readable on all ten.
The VIN is accepted only when it passes the 49 CFR 565 check digit, the 538 or PAG prefix and the VIN character set. The slot is writable text, and the check digit is what makes it safe to prefer the header over the file name, which is the thing a rename changes. It starts at offset 0x29 on all ten files here. Upstream zero-log-parser found these offsets first (its header decode of 15 September 2026, credited there to AtomicDog’s 2022 pull request), and reports the VIN one byte later, at 0x2A, on about 2.6% of production files, with every other field moving with it; an escaped byte in the header’s own timestamp is the likeliest cause. The decoder therefore tries the unescaped header first, then raw offsets 0x29 and 0x2A, and reads nothing at all when no VIN validates, because the shift is then unknown.
The model year is this decoder’s addition. The two bytes before the model name are a uint16 holding the year times ten (20210 for a 2021), and on ten of ten files it equals the year encoded in the VIN’s tenth character. Its high byte, 0x4E or 0x4F, is what a scan for printable text used to read as part of the model, which is where "NSRF" and "ODSRX" came from. Here: model SRF on four files, SRS on three and DSRX on three; board revision 2 on three, 4 on five and 5 on two; firmware revisions 19, 29, 42, 43 and 44.
What a rider gets from it: a file that still says which motorcycle it came from after it has been renamed or forwarded, a model year that cross-checks the VIN, and the main board’s hardware and firmware revision on the day of the download, which is the first thing to compare when two files from the same bike decode differently. The values themselves identify a motorcycle or narrow it down, and none is printed on this page.
[0] 0xB2
[1] 0x75 (117)
[2] 0xFB
[3:7] timestamp — download time, uint32 LE
[7:11] microseconds
[11] sequence
[12] UTC-offset setting, int8 hours (was "log format version")
[13:16] "MBB"
[23:25] model year x 10, uint16 LE — 20210 for an MY2021
[25:41] model, NUL-padded — "SRF", "SRS", "DSRX" (was read as "NSRF",
"ODSRX": the N / O is the model-year word's high byte)
[41:58] VIN, 17 ASCII characters, check digit at the ninth;
one byte later, [42:59], on ~2.6% of production files
[60:74] board serial number — not decoded, never published
[92:101] MBB part number — observed 40-08155, 40-08198, 40-08285
[101] board revision, uint8 — 2, 4, 5 observed
[103] firmware revision, uint8 — 19, 29, 42, 43, 44 observed
[107:...] firmware build identifier, NUL-padded hex digitsObserved 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 the four-byte microsecond field, the sequence byte and the UTC-offset byte. That last byte was described here as a board marker, 0x02 on the MBB and 0x01 on the BMS; it read 0x02 and 0x01 only because the files it was read from came from motorcycles set to UTC+2 and UTC+1.
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, the median burst is 31 records, most often 27 text events, 2 telemetry records and 2 secondaries, written over a 91-second window. A cycle here is 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 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.
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 reads the state field at a fixed offset per record type (record byte 42 on a 0x51, 46 on a 0x52, 50 on a 0x53) and accepts one of ten null-padded ASCII tokens there: RUN, STOP, HIB, STRT, PWSU, CHRG, WAIT, WAKE, REV, PARK. Every other offset is relative to it. It used to search the whole record for a token instead, and a text record that merely contained one of the words had a character of its message read as a state of charge: almost always a space, 0x20, which is 32. Production carried 512 rides ending at exactly SoC 32 before the anchor went in on the server, and the browser decoder behind /decode kept searching until 2026-10. 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 eight tokens the decoder originally knew; the remaining 9 carry REV or PARK (five and four, all on the one DSR/X here) and were discarded until those two were added, server-side in 2026-08 and in this browser decoder shortly after. They were never tokenless and never empty: all 9 carry a populated pack voltage at record[34:38], between 102.561 V and 102.630 V, so the two tokens recovered real telemetry rather than noise. The anchor table is what lets the same decoder read the 92-byte 0x52 record, where the state sits four bytes later, and the 106-byte 0x53, eight 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 record’s sequence byte: it takes 105 distinct values across those same 144 RUN records and lands in {0x00, 0x6A, 0x60} on none of them.
| State | Records | SOC = 0 | post_1 / post_2 set | post_3 / post_4 set |
|---|---|---|---|---|
| HIB | 417 | 9 (2.2%) | 20 (4.8%) | 0 |
| PWSU | 408 | 373 (91.4%) | 0 | 34 (8.3%) |
| RUN | 144 | 0 | 144 (100%) | 144 (100%) |
| WAIT | 51 | 47 (92.2%) | 3 (5.9%) | 3 (5.9%) |
| WAKE | 44 | 0 | 3 (6.8%) | 44 (100%) |
| STOP | 41 | 0 | 18 (43.9%) | 41 (100%) |
| CHRG | 7 | 0 | 0 | 7 (100%) |
| REV | 5 | 0 | 5 (100%) | 5 (100%) |
| PARK | 4 | 0 | 4 (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 was the second argument for adding them to the token list. That has since been done on both decoders.
[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] sequence, uint8 — +1 per record of any type, mod 256. NOT a mode
flag and NOT half of a uint16 counter; see the note.
[12] UTC-offset setting, int8 hours
[13:17] zeros
[17] controller mode flag — 0x00 active, 0x6A standby, 0x60 transition
[18:21] zeros
[21:25] DC-bus voltage, int32 LE, mV, in 100 mV steps — the CONTROLLER's
reading of the bus [34:38] measures. Zero on every CHRG record.
[25:29] DC-bus current, int32 LE, mA — only while the bus is live
[29:33] motor rpm, int32 LE — only while the bus is live
[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 — AMBIENT temperature, hundredths of a degree on most firmware
and whole degrees on some (each file is internally pure)
[55:59] post_1 — MOTOR temperature, zero when the bike is parked
[59:63] post_2 — INVERTER temperature, tracks post_1, zero when parked
[63:67] post_3 — cell HIGH temperature, written in every state
[67:71] post_4 — cell LOW temperature, <= post_3 on every record measured
[71:75] 12 V accessory-rail voltage, uint32 LE, microvolts — NOT an odometer,
see the retraction belowThree 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. The decoder has read both since 2026-09-07, from records whose bus is live. Second, the five post-state temperatures were published here, and stored, under the wrong names until 2026-09-07: post_0 as an "internal metric", post_1 as pack-high, post_2 as pack-low (and persisted as the motor temperature), post_3 as ambient and post_4 as controller. The order was right and the labels were not, and the state table below already proved it: post_1 and post_2 are set on every RUN record and on none in CHRG (a motor and an inverter are dead when the bike is parked), while post_3 and post_4 are set in RUN, STOP, WAKE and CHRG alike, which is what cells do. 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 sequence byte 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 odometer anywhere in a 0x51 record. That sentence was later quoted as "there is no odometer in a native Gen3 MBB record", which is false: the scan behind it covered the 75-byte 0x51 only, and the odometer lives in the tail of the longer 0x52 and 0x53 records (see the extended-record section below).
How this one was caught, and what it cost
The tell was arithmetic. 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 a neutral name, because "which rail" is high-confidence and not proven.
Motor record (0x54, 29 bytes)
The 0x54 record is written beside every 0x51 and it is the motor channel: four signed int32 little-endian words after the header (shaft torque in micro-newton-metres, motor current in whole amps, a free-running counter, and the DC-bus voltage in millivolts), with FF FF FF FF meaning "not measured". This page listed it as purpose unknown until 2026-09-07, because on a parked bike it is almost empty: 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 (no torque, no current, a stopped counter), and 782 (80.8%) end with the sentinel FF FF FF FF where the bus voltage goes; a further 78 (8.1%) end with four zero bytes.
Two internal checks have to hold, and both do on every record measured. The torque is exactly 0.2 N·m per amp of motor current (27.6 N·m at 138 A, 49.2 at 246, 59.2 at 296), so the first two words are one measurement through the motor constant, and that constant is the evidence the framing is right. And the bus voltage falls as torque rises, 111.9 V at 0.6 N·m to 106.9 V at 59.2 N·m, staying inside 3.82 to 4.00 V per cell on a 28-series pack. A published field map calls that word "discharge power W"; a power channel does not sag under load.
[0] 0xB2 [1] 0x1D (29) [2] 0x54 [3:7] timestamp — identical to the paired 0x51 record [7:11] microseconds [11] sequence [12] UTC-offset setting (was "0x02, MBB marker") [13:17] shaft torque, int32 LE, micro-N·m — 0.2 N·m per amp, exactly [17:21] motor current, int32 LE, A [21:25] counter, int32 LE [25:29] DC-bus voltage, int32 LE, mV — FF FF FF FF when not measured
Extended records (0x52, 92 bytes, and 0x53, 106 bytes): speed, range and the odometer
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; the 0x53 inserts eight and puts it at byte 50. Both are rare (67 0x52 across the eight app dumps under length-chain framing, 87 with the ninth file included, and 0 to 31 per file, appearing mainly around WAKE events), and both carry a TAIL that a 0x51 ends before. An earlier version of this page said 66 0x52; that is the same quantity counted as raw 0xB2 delimiters whose un-unescaped timestamp passes the window, a different convention.
The tail holds the road speed in millimetres per hour (km/h × 1,000,000), the dash’s estimated remaining range in hundredths of a kilometre, and, on the newer firmware layout only (the one whose 0x51 is 75 bytes), the odometer in tenths of a kilometre. Relative to the state token: on the newer layout the odometer sits at +35, the speed at +39 and the range at +43 as a uint16; on the older layout the speed is at +35, the range at +39 as a uint32, and there is no odometer at all. The older layout’s word at +39 is the trap: it rises smoothly from 5,000 to 11,773 on one file and 11,005 to 18,642 on another, squarely in a Zero’s mileage band, and every sample of that rise is a CHRG record. An odometer does not move while the bike is plugged in; that is the range estimate climbing as the pack fills. The newer layout’s odometer passes the test the impostors fail: it is frozen across a whole charge, rises only under RUN, and is non-decreasing on 48 of 48 and 36 of 36 steps on the two files with enough samples. The decoder still checks the series per file and withdraws it if more than a tenth of its steps go backwards.
Charge targets and main-board state snapshots (0x49, 0x4B–0x4D)
Four further main-board record types are decoded. The charger table (0x48), the queue-full report (0x4F) and the firmware build banner (0x32) each have a subsection of their own below; the two short ones are here.
Type 0x49 carries the charge ceiling in millivolts, the charge target as a percentage and a storage voltage: 117.60 V, 90% and 106.00 V on one bike, the 90 being the rider’s own limit.
Types 0x4B, 0x4C and 0x4D also occur on the main-board side of some production files, as 53-, 84- and 96-byte state snapshots with a state token at record byte 42, 46 or 50, the same three positions as 0x51, 0x52 and 0x53. None occurs in this corpus, so the decoder reads the token and nothing else, and does not use it for ride detection until something has measured what it would do there. The battery board writes the same three type codes at different lengths with an unrelated layout; the length is what tells them apart.
Charger table (0x48): which chargers answered, and from which grid
While the motorcycle charges, the main board writes a 0x48 about every 640 seconds: the 13-byte header followed by one 49-byte block for each charger that answered on the CAN bus (62 bytes with one block, 111 with two). This corpus holds 119 of them, in three of the ten native main-board files, from three motorcycles, and every one is 62 bytes long. Where a bike has two chargers they do not share a record here: each answers in a one-block record of its own, with CAN id 16 and 17, and on 48 occasions the two land in the same second. That is how a comparison that paired records by timestamp once matched the wrong two and reported a disagreement about the CAN id where there was none.
The mains fields prove the layout, and they prove it because this corpus spans two grids. One motorcycle reads 113 to 115 V at 60 Hz on the same bytes where the other two read 218 to 236 V at 49 or 50 Hz, and nothing but a real mains reading separates those populations on the same offsets. Frequency is therefore read as a class per bike rather than a number: one bike here charges from a 60 Hz grid and two from 50 Hz.
The name is the charger’s own ASCII: "3kW" on 97 records and "Flt 6" on 22 here; upstream zero-log-parser also reports "Charger", "6kW" and "3kWFCC" from its own files. "Flt 6" is what the only charger on one bike writes in its name field on every one of its records while it delivers 15 to 26 A, so it is not a charger that stopped, and what it abbreviates is not known. Charger firmware versions 201 (66 records), 206 (31) and 104 (22) occur, each on one bike. The serial number is decoded and never published.
What a rider can read from it: which chargers answered, and when one stops answering; the voltage and frequency at the socket; how much current each unit drew from the wall (5 to 12 A here) and what it delivered to the pack. It is the AC side of a charge, and no other record carries it.
| Record bytes | Field | Type | Seen in this corpus |
|---|---|---|---|
| 13–22 | Name | 10 bytes, NUL-padded ASCII | "3kW" (97), "Flt 6" (22) |
| 23–24 | Flags | uint16 LE | Bits not yet identified |
| 25–28 | DC output voltage | uint32 LE, mV | Pack-side voltage |
| 29 | DC output current | uint8, A | 5 to 26 A |
| 37 | Command in | uint8 | Stored; meaning unconfirmed |
| 39 | AC input voltage | uint8, V | 113–115 V or 218–236 V |
| 40 | AC input current | uint8, A | 5, 7, 9 or 12 A |
| 42 | AC frequency | uint8, Hz | 49, 50 or 60 |
| 44 | CAN id | uint8 | 16, 17 |
| 45–46 | Firmware version | uint16 LE | 104, 201, 206 |
| 47–50 | Serial number | uint32 LE | Decoded; never published |
| 53–56 | Charge ceiling | uint32 LE, mV | The voltage the charger may reach |
| 57 | Command out | uint8 | Stored; meaning unconfirmed |
Upstream zero-log-parser decoded this record first, on 20 September 2026, and reads the name, flags, frequency, CAN id, version and serial at these same bytes; the DC output, the AC voltage and current and the charge ceiling are this decoder’s. Where both read a byte they agree on all 119 records.
Queue full (0x4F): the firmware ran out of room for a message
A 0x4F is 31 bytes: the header, a 10-byte NUL-padded queue name and two uint32 values. It is written when a firmware task tries to post a message to a FreeRTOS queue that is already full. The message is dropped, and this record is the firmware saying so. Eighteen occur here, in four of the ten native main-board files, and every one names a CAN queue: CAN2txQ eleven times, CAN1txQ six and CAN0RxQ once. On these motorcycles a full queue is CAN-bus congestion.
The first value is constant for each queue (64 for CAN1txQ, 16 for CAN2txQ and 48 for CAN0RxQ) and reads like the queue’s capacity. The second varies, from 46 to 76 on CAN2txQ and from 735 to 79,252 on CAN1txQ. Neither meaning is confirmed, so both are published under neutral names, as upstream does.
What a rider can read from it: one record is one dropped message, not a fault, and nothing on the dash corresponds to it. Recurrence is what counts: the same queue overflowing on every ride, or overflows clustering around a charge or a fault, says which bus was overloaded, and when.
| Queue name | Seen here | Probable role (inferred, not stated by the firmware) |
|---|---|---|
| CAN0RxQ, CAN1RxQ, CAN2RxQ | CAN0RxQ once | Messages received on CAN bus 0, 1 or 2 |
| CAN1txQ, CAN2txQ | CAN1txQ 6, CAN2txQ 11 | Messages waiting to be sent on CAN bus 1 or 2 |
| logQ | — | Entries waiting to be written to this log |
| dashQ | — | Messages for the dashboard |
| uarttxQ, uartrxQ | — | A serial port, out and in, plausibly the diagnostic console |
| pduQ | — | A power-distribution unit, if PDU means what it usually does |
| lssQ | — | CANopen Layer Setting Services, which assigns node ids on the bus (the same LSS that names the log line freeing a charger’s node id) |
The eleven names are upstream zero-log-parser’s, which decoded this record first, on 20 September 2026. Only the names in the middle column were measured here; every role in the last column is an inference from a name.
[0:13] the common header [13:23] queue name, 10 bytes, NUL-padded ASCII [23:27] value a, uint32 LE — constant per queue: 64, 16, 48 here [27:31] value b, uint32 LE — varies; meaning unconfirmed
Firmware build banner (0x32): which software ran, and whether it changed
Type 0x32 is a firmware build banner. It uses the older record layout (no microseconds, sequence or offset byte, payload from record byte 7), and its four time bytes hold a small boot-relative number, not a date. In this corpus all 59 of them sit in 32 files from Gen2-platform main boards: 31 of those are 538XX-variant dumps, the ones routed to the Gen3 main-board path, and none is a native Gen3 ring buffer.
After one byte of unknown meaning, the payload is a C compiler’s build stamp (month, day, year and time, as __DATE__ and __TIME__ print them), followed, optionally, by a build number and a flash bank, "banka" or "bankb", each NUL-terminated. Here 54 records carry all three (a 32-byte payload) and 5 carry the date alone (22 bytes); upstream also lists 31- and 38-byte forms. The banks split 45 "banka", 9 "bankb" and 5 unnamed, and the builds were compiled between 2019 and 2024.
What a rider can read from it: which firmware build the main board ran while the buffer was being written, and whether that changed. A file that names more than one build spans an update (11 of the 32 files here do), and the decoder lists every build in file order, with how many records announced it. Two bank names suggest an A/B flash scheme, in which an update is written to the bank not in use; that is an inference from the names. Build numbers and dates narrow a motorcycle to a small population, so this page does not print them.
[0] 0xB2
[1] length, on disk
[2] 0x32
[3:7] uint32 LE — a small boot-relative value, NOT a Unix time
[7] one byte, meaning unknown
[8:] "Mmm DD YYYY HH:MM:SS" NUL build date and time (__DATE__ __TIME__)
build number NUL optional
"banka" or "bankb" NUL optional: the flash bankUpstream zero-log-parser decoded this record first, on 15 September 2026. The decoder here applies the same structural rule: the date must parse and every trailing field must be printable ASCII, or the record is declined rather than half-named.
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. 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.
| File exported | Model | MBB part | 0x51 on-disk lengths (of which escaped) | 0x54 present |
|---|---|---|---|---|
| 2021-09 (16 kB stub) | SR/S | 40-08198 | 71 (32, 0 escaped), 72 (11, 11), 73 (2, 2) | no |
| 2023-04 | SR/F | 40-08155 | 71 (177, 0), 72 (12, 12), 74 (1, 1) | no |
| 2025-10 | DSR/X | 40-08285 | 75 (142, 0), 76 (5, 5) | yes |
| 2025-12-29 | SR/F | 40-08155 | 75 (220, 0), 76 (19, 19) | yes |
| 2025-12-31 | SR/S | 40-08198 | 75 (152, 0), 76 (2, 2) | yes |
| 2026-01-01 | SR/S | 40-08198 | 75 (147, 0), 76 (3, 3) | yes |
| 2026-01-21 | DSR/X | 40-08285 | 75 (130, 0), 76 (1, 1) | yes |
| 2026-02-07 08:53 | SR/F | 40-08198 | 75 (143, 0), 76 (6, 6) | yes |
| 2026-02-07 14:37 | SR/F | 40-08155 | 75 (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 known record format and a routing trap
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. 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 on one of these files, as this site did in July 2026, 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 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, and both are 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, although the bug was in the router. Before concluding that the upstream library cannot handle a format, check whether it already does.
The variant is still a documented exception to the rule that opens this specification, and its records are still worth a field map, because a shipping decoder reads their electrical channels. 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 the newer-firmware Riding Status record, present throughout the mainstream Gen2 corpus that upstream already parses.
What 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.
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 == 40Read 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.
| Offset | Width | Field | Decode | Accepted range |
|---|---|---|---|---|
| +7 | uint8 | Pack temperature high | °C | 0 < t < 80 |
| +8 | uint8 | Pack temperature low | °C | 0 < t < 80 |
| +9 | uint16 LE | State of charge | % | 0–100 |
| +11 | uint32 LE | Pack voltage | millivolts, exact | 50–130 V |
| +15 | int16 LE | Motor temperature | °C | 0 < t < 150 |
| +17 | int16 LE | Controller temperature | °C | 0 < t < 150 |
| +19 | uint16 LE | Motor RPM | RPM; no speed is derived | ≤ 8000 |
| +23 | int16 LE | Battery current | whole amps, discharge positive | ±400 A |
| +25 | uint8 | Module presence bitmask | 0/1/2/3 → 00/10/01/11 | ≤ 3 |
| +26 | int16 LE | Motor current | amps | ±1000 A |
| +28 | int16 LE | Ambient temperature | °C | −50 … 70 inclusive |
| +30 | uint16 LE | Odometer | km, lifetime, non-decreasing | see 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, which is 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 shows 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 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 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.
The fit was replaced by 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 (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.
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 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.
| Event text, matched case-insensitively | Derived state |
|---|---|
| Module scheme changed from … to Running mode | RUN |
| Module scheme changed from … to Stopped mode | STOP |
| Module scheme changed from … to Charging mode | CHRG |
| Entering charge standby | CHRG |
| Leaving charge standby | STOP |
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.
Those numbers 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 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: 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, for a reason 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.
| Type | Name | Size | Count per file | Total in corpus | Contents |
|---|---|---|---|---|---|
| 0xFB | Header | 125 B | 1 | 7 | Board type, part number, serial, cellbox, firmware |
| 0xFD | Text event | variable | 1,596–1,997 | 13,178 | State transitions, faults, diagnostics |
| 0x4B | Telemetry | 52 B | 186–527 | 1,803 | Cell voltages, current, SOC, pack voltage, state |
| 0x4D | Extended telemetry | 78 B | 79–126 | 691 | The 0x4B fields shifted +8, plus the full-charge-capacity register |
| 0x4C | Cell detail | 70 B | 0–65 | 84 | The 0x4B fields shifted +4; written during operational periods |
| 0x00 / 0xF0 | Partial or debug | variable | 12–28 | 127 | Ring-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.
[0] 0xB2
[1] 0x7D (125)
[2] 0xFB
[3:7] timestamp
[11] sequence
[12] UTC-offset setting, int8 hours
[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 hashText event record (0xFD, variable)
Same structure as the MBB text record: the common 13-byte header, then NUL-terminated ASCII from record byte 13. This page used to say the BMS differs by a "board marker" of 0x01 at byte 12 where the MBB has 0x02; byte 12 is the UTC-offset setting, and the two values were two motorcycles’ time zones. These events are the highest-confidence signal for when the motorcycle was turned on, which makes them load-bearing for session detection.
The decoder did not know where the text starts until 2026-10. It took "the first printable byte at or after byte 7", so whenever a sub-second, sequence or offset byte happened to be printable the text began there, and stopped at the next zero, which on a UTC+0 motorcycle is byte 12 itself. Over the 13,522 BMS text records in eight files whose header is intact and whose timestamp is plausible, 4,268 (31.6%) came out right, 7,234 (53.5%) were dropped as "too short" (7,025 of them carrying real text such as "Entering ZERO_BMS_STATE_IDLE"), and 1,890 were published as garbage like "Po\x0e", with 130 more carrying a junk prefix. Byte 13 is printable on 8,786 of the 8,788 BMS text records no page marker touched, and on 18,570 of 18,570 MBB text records. Only type 0xFD is text: every other record that reached the text parser (a telemetry record of the wrong length, a record whose type byte a page marker had overwritten) was being mined for printable bytes and published as an "event", and no longer is. The fix more than doubles the state-machine events a file yields, and it changes BMS ride detection for the better: split rides merge back into the one ride the MBB recorded.
All of that is the FST record layout, and the decoder now checks for it per file, from the bytes, before applying any of it. The same decoder is also handed files that are not FST at all (Gen2-shaped BMS dumps and odd exports that the 0xB2 telemetry probe routes here), and in their records the text does start at byte 7. On those the byte-13 rule cut six characters off every message ("DEBUG: CAN Link Is Down" became "CAN Link Is Down") and dropped the records left too short. A file counts as FST when the page marker opens its second and third pages, or, failing that, when the sub-second word is at most 1,000,000 on at least 80% of its records and byte 12 reads as one dominant civil UTC offset; measured over 374 files the two populations sit far apart (at least 0.97 against at most 0.16 on the first test). Anything else keeps the original rule exactly.
A text record a page marker ran through is a prefix of what the firmware wrote, because the marker opens with a zero byte and ends the string there. It is kept and flagged as truncated, and a pattern that captures up to the end of it is not trusted: "Entering ZERO_BMS_STATE_HIB" cut short is not the state HIB.
- "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.
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 counts against treating any of the four 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.
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. Applied to an extended record, this map yields values a sanity check silently throws away, not slightly wrong ones. That 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] | Rows | Report mode 3 (sleep) | SOC = 0 | Load flag 0x78 | Mean |current| |
|---|---|---|---|---|---|
| 0x00 | 1,057 | 57 (5.4%) | 19 (1.8%) | 590 (55.8%) | 3,869 mA |
| 0x20 | 517 | 427 (82.6%) | 517 (100%) | 504 (97.5%) | 40 mA |
| 0xF0 / 0xFF | 24 | 6 (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.
| Value | Meaning | Rows | Share |
|---|---|---|---|
| 1 | Standard SOC reporting | 522 | 32.7% |
| 3 | Sleep / minimal | 490 | 30.7% |
| 7 | Active high-rate, charge or discharge | 437 | 27.3% |
| 4 | Idle | 31 | 1.9% |
| 5 | Precharge | 29 | 1.8% |
| 6 | Unknown | 28 | 1.8% |
| 0 / 240 / 255 | Uninitialised or flash sentinel | 61 | 3.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.
payload[0:3] = record[8:11], the upper three bytes of the microsecond word.
payload[2] is always 0x00 (the word never exceeds 1,000,000);
the 0xF0 / 0xFF once read here as a "flag" is the page marker
payload[3] = record[11], the sequence counter (same byte on 0x4C / 0x4D)
payload[4] = record[12], the UTC-offset setting, int8 hours
payload[5] = record[13], 0x00 on 1,347 of 1,347 marker-free 0x4B records
(was "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:24] pack current, int32 LE, mA — positive discharge, negative charge
payload[22:24] the HIGH HALF of that word, not a flag — see below
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] NOT a temperature - retracted, see belowpayload[24] is not a state code: a retraction
The pack current is the largest single measurement error this page has had to correct. It was read as a signed 16-bit value in milliamps, which caps at ±32.767 A. A Zero pack draws several hundred amps under acceleration, and because nothing clamps, the stored value wrapped instead of clipping: it held the true current modulo 65,536 mA, which inverts the sign rather than flattening the peak. That is why the recorded direction was near a coin flip while riding: 55.5% discharge, against 78.4% on the same bike’s own MBB log over the same rides.
It is a 32-bit value, and the two bytes this page described as a firmware charging indicator are its high half. Over all 72 native Gen3 BMS files on production, 45,060 telemetry records: those bytes read FF FF on 80.5% of records whose low half is negative and 00 00 on 84.9% of those whose low half is positive. That is a sign extension. The flag was real in that it fired during charging, but not for the reason given: FF FF is what a negative number looks like, and charge currents are negative.
Three checks, each against its own null. Coulomb counting against the pack’s own state of charge: over 84 contiguous segments the sign of the integrated current agrees with the direction of the SOC change on 83 of 84 under the 32-bit read, against 49 of 84 under the 16-bit one, where 50% is the coin-flip null. The implied capacity is 138.7 Ah per 100 points of SOC (a ZF14.4, ZF15.6 or ZF17.3 pack is 129, 139 or 154), against 16.8 Ah before, which is not a motorcycle. And Ohm’s law against the separately verified pack voltage gives a median slope of −29.9 mΩ, negative on 35 of 38 bikes, a plausible 28-series internal resistance; the old read gave −5.6 mΩ and negative on 20 of 38.
An unreadable current window now yields nothing rather than zero, because zero reads as an idle pack. A genuine zero is kept: it is a real reading on about a fifth of all records.
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.
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 payload[43] in the same record it gives r = −0.05, and the two bytes are equal on 1.3% of rows, though that particular comparison is now worthless, because payload[43] turned out not to be a temperature either. 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 sits 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.
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.
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.
| Value | Rows | Share | Under load | Enum claimed |
|---|---|---|---|---|
| 0x0B | 603 | 26.0% | 14.4% | idle |
| 0x0C | 566 | 24.4% | 45.2% | active |
| 0x0A | 142 | 6.1% | 34.5% | precharge |
| 0x0D | 131 | 5.6% | 16.0% | unnamed |
| 0x10 | 111 | 4.8% | 62.2% | unnamed |
| 0x0F | 54 | 2.3% | 40.7% | unnamed |
| 0x12 | 52 | 2.2% | 15.4% | unnamed |
| 0x14 | 51 | 2.2% | 3.9% | unnamed |
| 0x09 | 47 | 2.0% | 74.5% | unlock |
| 0x11 | 41 | 1.8% | 12.2% | unnamed |
| 0x00 | 40 | 1.7% | 12.5% | unnamed |
| 0x08 | 39 | 1.7% | 35.9% | unnamed |
| 0x13 / 0x16 / 0x17 | 106 | 4.6% | 2.7% / 5.9% / 2.9% | unnamed |
| 0x06 | 36 | 1.6% | 13.9% | unnamed |
| 0x15 / 0x18 | 60 | 2.6% | 3.3% / 0.0% | unnamed |
| 0x07 | 29 | 1.3% | 20.7% | unnamed |
| 0x0E | 26 | 1.1% | 7.7% | unnamed |
| 0x05 | 25 | 1.1% | 12.0% | unnamed |
| 0xF0 / 0xFF | 43 | 1.9% | 10.5% / 29.2% | flash sentinels |
| 14 further values, each under 20 rows | 117 | 5.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: a decoder using the 0x4B map on a 0x4D fails completely instead of degrading, 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 byte at record 51 of a 0x4B (the byte this page once published as a temperature, and which is not one; see the callout below): 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.
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] the 0x4B's record[51] shifted +8 - NOT a temperature
extra[8:10] per-bike calibration, uint16 LE — observed 3,083–5,910; the two
bytes differ by 0–2. 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 byteWithdrawn: 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, value] byte pair: the high byte is the same 0-to-27 byte the 0x4B carries at record[51] (256 → 1, 1024 → 4, 5632 → 22). This callout used to call that byte "degrees Celsius", and it is not a temperature. It was retracted on 2026-07-31 against the MBB twin’s pack temperature (r = −0.074 over 1,275 pairs), and that comparison turned out to have used the wrong MBB word, because the MBB’s temperature labels were themselves one slot out until 2026-09-07. Re-measured against the corrected MBB cell temperatures, paired within 90 seconds over the eight same-bike pairs here (n = 339), it correlates at r = −0.41 with the cell-low temperature and −0.43 with ambient, sits a median 7 °C away, and is within 1 °C on 1.5% of pairs. A temperature does not run backwards against two thermometers. The low byte is not always zero: observed pairs include (0,2), (6,18), (0,4), (6,24), (1,13) and (3,10). The three-value enum was an artifact of only ever looking at rows whose flag byte happened to be zero. Upstream zero-log-parser reached the same verdict independently and dropped its own reading of the byte in 2026-09.
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 65: three of the seven files carry none at all, and one carries 65 of the 84. (This sentence used to say "0 and 58", contradicting the table above it. Re-measured on 2026-07-30 under the shipped byte scan: the per-file counts are 0, 0, 0, 4, 7, 8, 65, and the same scan reproduces the table's 0x4B 186–527 / 1,803 and 0x4D 79–126 / 691 exactly. The table was right.) 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 not-a-temperature byte at record byte 55 has a median of 14 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 payload[43] at all. The decoder accepts a 50-byte gap into the telemetry path on that basis.
Resolved since, and kept for the record. Measured over all 72 native Gen3 BMS files on production, eight files across four bikes are written entirely in the short family (40-08201 "FST Mono" on seven, 40-08213 "FST Mod" on one), with no standard-family telemetry record between them, and the +4 / +8 shifts hold unchanged. What follows was true of the seven-file corpus. 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. That is exactly how 744 telemetry rows were being read at the wrong offsets and then silently discarded by a sanity check that was doing its job. (744 is the figure this page measures elsewhere: 666 extended 0x4D plus 78 cell-detail 0x4C reaching the branch, of which 694 were recovered. An earlier draft said 675 here, which belongs to the escape-blind 0x4D-only convention this page spends a section retracting.)
The BMS flash page marker: four bytes overwritten on every page
A native Gen3 battery-management file is written to flash in 128-byte pages, and the start of every page after the first carries the same four bytes: 00 F0 FF 00. They are not a separator between records and they are not inserted anywhere. They are written OVER whatever the firmware had put at that offset (a record’s delimiter, its length, its type, part of its timestamp, or a measurement), and the four bytes that were there are gone. Nothing in the file can bring them back.
Recognising the marker is easy, because its position never varies: file offset 128·k for k = 1 to 1,025 (1,025 of 1,025 pages after the first) in all eight native Gen3 battery files in this corpus, 8,200 markers in all, and in none of the ten main-board files, which are not paged. A decoder can therefore find every marker before it frames a single record. What it cannot do is look at a decoded value afterwards and tell whether a marker produced it, because the marker’s bytes decode to ordinary-looking numbers. The figure shows two real records it hit.
logs/, anonymised: no file, VIN or rider date is shown, and in record A the three low bytes of the real timestamp are masked. The red bytes are the marker. The pack’s flash writes 00 F0 FF 00 at file offset 128·k on every page after the first (1,025 times in each of the eight files, 8,200 in all), and it replaces the four bytes that were there. Before release 3.37.0 the decoder read straight through it: record A published a charge of more than a thousand amperes, and record B published a timestamp built from the marker’s own bytes. Since 3.37.0 both decoders locate the marker by file offset, carry its position through the unescape and withhold exactly the fields it covers; everything else in the record is read as written. Bytes 36 to 51 are omitted because the marker does not reach them.What the marker lands on
Where the marker falls inside a record decides what reading through it costs. Counted over all 8,200 markers in the eight battery files, against the delimiter gap each one falls in:
| The marker starts on | Markers | What a decoder reading through it gets |
|---|---|---|
| A byte inside a record’s payload or text | 5,807 | A wrong measurement (a current, a voltage, a state of charge) or a message cut short |
| The microseconds (record bytes 7–10) | 635 | Wrong sub-second timing, and sometimes the sequence or offset byte too |
| The next record’s 0xB2, or 1–3 bytes before it | 612 | Two records merged into one gap: split on 0xB2 and the first, intact record goes to the wrong parser |
| The timestamp itself (bytes 3–6) | 574 | A year of 1970, 2097 or 2105, refused by the timestamp window, so the record is lost |
| The sequence or offset byte (bytes 11–12) | 302 | A wrong sequence or offset, and the first bytes of the payload |
| The type byte (byte 2) | 139 | Type 0x00 and a timestamp of 0xHH00FFF0, a forged second; 138 of the 139 still look plausible |
| The length byte (byte 1) | 131 | Length 0x00, type 0xF0 and a timestamp of 0xHHHH00FF, up to 18 hours off; 130 of the 131 still look plausible |
Every marker located by file offset, and counted against the delimiter gap it falls in. The last two rows are the only ones that leave a believable timestamp behind: 268 rows in all, on 38 distinct forged seconds.
Overwrite, not insertion: how the file proves it
This page once described a three-byte "page footer", F0 FF 00, that a decoder could splice out. Both halves were wrong: the marker is four bytes, and there is nothing to splice, because it does not sit between records.
The record lengths prove it is an overwrite. Over the 8,005 delimiter gaps that straddle a page boundary in the eight battery files, the declared length equals the gap on 7,246 and is shorter on 759, and it is never the gap minus four: the marker’s bytes are counted inside the record they land in. Over the 10,365 gaps with no marker in them the declared length equals the gap every time. The file size agrees: a battery export is 131,328 bytes and a main-board export 131,200, exactly one 128-byte page apart.
A cheap file-integrity check
A native Gen3 BMS export carries exactly 1,025 markers, one at each of offsets 128, 256, … 131,200. A different count means the file is truncated, padded or not a Gen3 BMS export at all. A decoder that finds markers at 128 and 256 knows to expect the rest.
Why the same forged date turns up on unrelated motorcycles
A timestamp is four little-endian bytes, so its low byte comes first. When the marker starts on a record’s type byte it writes F0 FF 00 over the timestamp’s three low bytes and leaves only the top one, which changes once every 2^24 seconds, about 194 days. Every record hit that way within the same 194 days therefore decodes to the same second, whichever motorcycle wrote it: 0x6800FFF0 is 2025-04-17 13:19:44 UTC, 0x6900FFF0 is 2025-10-28 17:40:00 UTC and 0x6A00FFF0 is 2026-05-10 22:00:16 UTC. When the marker starts one byte earlier, on the length, the timestamp’s two low bytes become FF 00, and the forged time lands up to 18 hours from the real one, on a value every hit in the same 18-hour block shares.
In this corpus that produced 268 rows with a forged but plausible timestamp, on 38 distinct seconds, and 2025-10-28 17:40:00 alone appears on 95 records from five of the six motorcycles whose battery files are here. When unrelated bikes share a date to the second, the cause is this marker, not a coincidence or a firmware event.
Days and rides were built on those rows. A handful of forged timestamps on a day the bike was parked becomes a day of activity; real current values carried onto a forged date become a ride that never happened. Re-decoded with release 3.37.0, the eight local battery files lose 13 file-days and 18 of their 65 battery-detected rides, and every day removed is one that only forged timestamps supported.
What the decoder does now
Since release 3.37.0 both decoders (the server’s and the browser port behind /decode) do four things with the marker, and never a fifth:
- 1Locate every marker by file offset before framing, with the same two-page test upstream uses: a file is paged when the marker opens both its second and its third page, so a single coincidental match cannot switch the handling on.
- 2Carry each marker’s position through the unescape, so a record knows exactly which of its own bytes were overwritten even when an escape sits in front of them.
- 3Withhold exactly the fields those bytes belong to: a current becomes "not measured", a timestamp becomes unreadable and the row stays undated, a text record that runs into a marker is kept up to the marker and flagged as truncated, and a telemetry record whose cell-voltage gate bytes were hit is dropped, because a gate that read garbage proves nothing. Everything else in the record is read as written.
- 4Where the marker sits on the next record’s 0xB2 (612 times here), cut the gap back to the first record’s declared length, so that intact record is decoded instead of being handed to the text parser as an oversized blob. The second record, whose header is gone, is not decoded.
| Field | Telemetry rows withheld | Out of |
|---|---|---|
| Measured pack voltage (the row carries the cell-based estimate instead) | 156 | 2,985 |
| Pack current | 130 | 2,985 |
| Timestamp (the row stays undated) | 130 | 2,985 |
| Sequence byte | 124 | 2,985 |
| Load flag | 122 | 2,985 |
| Report mode | 104 | 2,985 |
| Activity byte | 103 | 2,985 |
| State of charge | 24 | 2,985 |
| Any of the above | 692 | 2,985 |
Of the same decode’s 13,127 text events, 4,316 are flagged as cut at a marker. Before the fix the same eight files yielded 2,894 telemetry rows, 699 of which carried at least one value read through a marker; the populations differ because the fix also recovered records the old framing lost.
Never splice, and never prefer a number to a gap
Splicing (removing the four marker bytes and joining what is on either side) is the fifth thing, and it is never done. Nothing was inserted: the four original bytes were overwritten, so joining the remainder produces a record one field short, with every later field in the wrong place. And a fabricated value is worse than a gap. A gap is visible: a reader, a chart or a session detector can see that nothing was measured and work around it. A value read through the marker looks exactly like a measurement (a 1,048 A charge, a date five bikes share), and it flows into every total, every session and every comparison downstream with nothing to mark it as different. Withholding costs a few readings per page and keeps every remaining number believable.
Measured on the production corpus
The change was graded against the full production corpus before it shipped: 1,220 log files, decoded by master and by release 3.37.0. Gen1, Gen2 and Gen3 main-board files decode identically on every metric; only the 224 native Gen3 battery files change, and they change the way the arithmetic above says they must.
- +243,755 usable entries across the 224 battery files.
- 290 file-days removed, every one of them a marker-forged date. They recur across unrelated motorcycles: 2026-05-10, the day of the 0x6A00FFF0 second, had appeared on 57 different bikes, and 2025-10-28, the day of 0x6900FFF0, on 38.
- 228 battery-only "rides" no longer produced. For 196 of them the same motorcycle’s main-board log covers the time and records no ride at all.
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], holding 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.
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 does not decrease when you accelerate. 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 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. That 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.
This page tells the story instead of correcting the offset without comment, because 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] 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 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.
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 countThe "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%.
Timestamp validity windows
One file-level detail will confuse a decoder that does not expect it: which timestamps to believe at all. The flash page marker, which forges some of them on battery files, has a section of its own above.
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 one: 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.
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.| Window | Range | Applied by | Purpose |
|---|---|---|---|
| Gen3 framing | 2019-01-01 .. now + 365 days | Both Gen3 binary framers, per record, POST-unescape | Reject unwritten / erased ring-buffer slots |
| Generic validity | calendar years 2015 .. 2030 | Post-parse, every generation | Exclude 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.
| Board | Field | Location | Status |
|---|---|---|---|
| BMS | Cell voltage min / max | payload[13:15], payload[17:19] | Decoded: uint16 LE mV, validated 2,500–4,500; add 8 on 0x4D, 4 on 0x4C |
| BMS | Cell open-circuit voltage | payload[15:17] | Decoded: stored, not yet used for SOC |
| BMS | State of charge | payload[19] | Decoded: 0 means sleeping, not empty |
| BMS | Pack current | payload[20:24] | Decoded: int32 LE mA. Read as int16 until 2026-07-31 |
| BMS | Pack voltage | payload[38:41] | Decoded: uint24 LE mV, measured, 94.4% in envelope |
| BMS | Temperature | — | Does not exist in the telemetry record. payload[43] spans 0–27 and runs against the MBB cell temperatures (r = −0.41); retracted |
| BMS | Charging indicator | payload[22:24] | Derived: the high half of the int32 current; its sign, not a flag |
| Both | Sequence counter | record[11] | Decoded: uint8, +1 per record of any type; stored as bms_sequence on BMS rows |
| Both | UTC-offset setting | record[12] | Decoded: int8 hours; summarised per file, never applied to a timestamp |
| BMS | Values under a page marker | every 128·k | Withheld: the field is null and named in withheld_fields |
| BMS | Load flag / bus engaged | payload[27], payload[28] | Decoded: complementary signals |
| BMS | Report mode | payload[37] | Decoded: 7 known values across the record types; value 6 unexplained |
| BMS | Activity byte (formerly "state code") | payload[24] | NOT decoded: stored verbatim; 37 values, no enum survives testing |
| BMS | Sleep indicator | payload[9] | Decoded: 0x20 sleeping, 0x00 awake (measured) |
| BMS | Full-charge capacity | record[67:70] + twin at [74:77] | Decoded: uint24 LE mAh, stored, not yet consumed |
| BMS | Pack voltage (0x4D copy) | record[54:57] | Decoded: uint24 LE mV; retracted as a "coulomb counter" |
| BMS | Per-bike cumulative counter | extra[10:12] | Unknown: varies 38,967–56,414, no correlation found |
| BMS | Per-brick cell detail | 0x4C record, fields shifted +4 | Decoded: same map as 0x4B at +4; per-brick mapping not established |
| BMS | Charge cycle count | — | Does not exist. Byte 67 is a capacity byte |
| MBB | State | record[42:51], [46:55], [50:59] by type | Decoded: anchored per record type, ten tokens including REV and PARK |
| MBB | State of charge | record[33] | Decoded: uint8, 0 in PWSU means uninitialised |
| MBB | Controller mode flag | record[17] | Decoded: 0x00 active, 0x6A standby, 0x60 transition |
| MBB | Temperatures | record[51:71], five uint32 | Decoded: ambient, motor, inverter, cell high, cell low (relabelled 2026-09-07) |
| MBB | Battery current | record[38:42] | Decoded: int32 LE mA, only while the bus is live |
| MBB | DC-bus voltage / current, motor rpm | record[21:33] | Decoded: the controller’s readings, only while the bus is live |
| MBB | 12 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 |
| MBB | Road speed, estimated range | 0x52 / 0x53 tail | Decoded: mm/h and hundredths of a km; not in a 0x51 |
| MBB | Odometer | 0x52 / 0x53 tail, newer layout | Decoded: tenths of a km, series-checked per file; the older layout and the 0x51 carry none. record[71:75] is a 12 V rail (retracted) |
| MBB | Motor record | 0x54, 29 bytes | Decoded: shaft torque, motor current, DC-bus voltage |
| MBB | Pack voltage | record[34:38] | Decoded: only while the bus is live |
| MBB | Charger table / charge targets | 0x48 / 0x49 | Decoded: every charger record, mains V/A/Hz, DC out, CAN id, serial; ceiling, target %, storage V |
| MBB | Queue full | 0x4F, 31 bytes | Decoded: queue name and two counters, meaning unconfirmed |
| MBB | Header | 0xFB | Decoded: VIN (check-digit validated), model, model year, board and firmware revision, firmware build |
| MBB | Firmware build banner | 0x32, older layout, Gen2-platform dumps | Decoded: build date, build number, flash bank; every build a file announces, in file order |
| Both | Text events | 0xFD, from record byte 13 | Decoded: NUL-terminated ASCII; text a page marker cuts is kept up to the marker and flagged |
| MBB | State snapshot | 0x4B / 0x4C / 0x4D, MBB side | Tag only: none in this corpus; not used for rides |
| 538XX | Voltage, current, RPM, 5 temps, odometer | 0x2c record, 40 bytes | Decoded: 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.
How this decode was checked against the community parser
The Gen3 decode on this page was compared, field by field, with the community parser zero-log-parser at its main-branch commit 90c8cb2 of 29 September 2026. Upstream is a second opinion, not ground truth, and the point of the exercise was to learn where the two agree, where they do not and why. The short version is the first subsection; the method, the numbers and the credit follow.
The short version
This site decodes Gen1 and Gen2 logs with the community parser, zero-log-parser, pinned at commit 90c8cb2 (29 September 2026; at 87c6e757, February 2026, before that); the native Gen3 records on this page are decoded by this site’s own code. On 3 October 2026 the two Gen3 decoders were compared record by record, aligned by file offset, against the parser’s main branch at 90c8cb2 (29 September 2026, the version the hosted parser at home.hasslers.net/zerologparser runs). Wherever both decode a record they agree: all 2,866 cell-voltage readings, the state of charge, the state fields, the temperatures, the charger table and the queue-full reports. Several of those records were decoded upstream first (the Gen3 main-board header and the 0x32 firmware build on 15 September, the 0x48 charger table and 0x4F queue-full on 20 September, a page-aware walker for the pack’s flash pages on 22 September), and this site’s versions, released on 3 October, were checked against them. Four differences remain, one of them in the parser’s favour: it reads both serial numbers out of a Gen3 pack’s header, which this decode does not. Since the pin moved, the site records the two serials the parser reads, as information only; nothing identifies a pack by them yet. In the other direction, the parser does not yet decode the Gen3 text records (type 0xFD), the largest record class on Gen3: its message decoder reads the record’s six-byte prefix as the string. It reads the first four payload bytes of the 0x54 record as an odometer; they are the sub-second part of the record’s timestamp. And its LOG_STRUCTURE.md still places an odometer in bytes 0–3 of the 0x51 record, where this corpus finds none; on this site’s reading the odometer is in the tail of the newer-layout 0x52 and 0x53 records.
Method: align by file offset, never by timestamp
Each record is framed once, by this site’s framers (the declared-length chain on the main board, the delimiter gap with the page-marker cut on the battery board), and the same unescaped bytes are handed to both decoders: this one its frame, upstream its payload, which starts at record byte 7. A record is identified by where it sits in the file, and nothing else.
Timestamps cannot do that job. Two records written in the same second are the normal case, not the exception: in the ten native main-board files 19,705 of 21,724 framed records (90.7%) share their second with at least one other record, and 16,919 (77.9%) with another record of the same type; in the eight battery files it is 15,735 of 18,370 (85.7%) and 13,184 (71.8%). A join on time pairs the wrong records and reports disagreements that do not exist. An earlier comparison reported the two decoders disagreeing about a charger’s CAN id, 16 against 17, when it had paired two different chargers that answered in the same second.
The corpus is every native Gen3 file in this repository: 18 files, ten main-board and eight battery-management dumps. Upstream’s current code is loaded only for the comparison and never in the same process as a full decode, because loading it beside the version this site pins changes what the pinned library returns. The script that runs it, gen3_upstream_crosscheck.py among this repository’s backend scripts, writes nothing and exits 0: it is a report, not a gate, because a disagreement can be upstream’s error as easily as ours.
Results, field by field
Measured on 3 October 2026 against upstream main at 90c8cb2.
| Field | Records | Agree | Differ | Withheld here |
|---|---|---|---|---|
| BMS lowest and highest cell voltage | 2,866 | 2,866 | 0 | 0 |
| BMS state of charge | 2,866 | 2,842 | 0 | 24, under a page marker |
| BMS pack current | 2,866 | 2,707 | 29 | 130, under a page marker |
| BMS measured pack voltage | 2,866 | 2,695 | 15 | 156, under a page marker |
| MBB vehicle state (0x51, 0x52, 0x53) | 1,727 | 1,727 | 0 | — |
| MBB 0x51 temperatures: motor, inverter, cell high, cell low | 1,536 each | 1,536 each | 0 | — |
| MBB 0x48 charger: name, CAN id, version, serial, frequency | 119 each | 119 each | 0 | — |
| MBB 0x4F queue full: name and both values | 18 each | 18 each | 0 | — |
| Text records (0xFD), both boards | 33,460 | Upstream returns none of the messages | — | — |
The 2,866 battery rows are the telemetry records both decoders emit. Beyond them, 119 telemetry records are decoded here and not upstream, 625 by neither, 235 upstream decodes and this decoder drops because a page marker overwrote part of the record, and 1 this decoder drops for another reason.
Where the two disagree, and why
Every difference in the table has a cause that can be named:
- All 29 current differences are words this decoder refuses as erased flash. Twenty-seven times the four bytes read FF FF FF FF, which upstream publishes as −1 mA; twice they are other combinations of flash-sentinel bytes, which upstream reads as 240 mA and −16 mA.
- All 15 pack-voltage differences are rows whose measured word falls outside the 28-cell envelope or under a marker. This decoder then publishes 28 times the record’s own average cell voltage, and upstream the word as read: 13 differ by 39 to 647 mV, and in the other two the word reads 0 and 164 mV.
- Text is where the two part completely. The 18 files hold 33,460 text records, 18,570 on the main boards and 14,890 on the packs, and this decoder reads 31,686 of them as text: 18,023 main-board messages and 13,663 battery events. Upstream main returns none of those messages. Its message decoder reads the record’s six-byte FST prefix as the string, so a record that says "State change from CHRG to …" comes back as two control characters. Text is the largest record class in a Gen3 file, and it is where the state machine, the faults and the contactor events are written in words.
The regression guard: 43 real records, two decoders, one file
A comparison against a moving upstream is a measurement, not a test. What keeps this decode from drifting is a committed fixture of 43 anonymised real records (22 main-board, 18 battery-management and 3 older-layout battery records), together with the values this decoder produces from each. It holds no header record and no identifying text, and the charger serials in it are zeroed. The Python decoder is tested against it in the backend suite and the browser decoder behind /decode against the same file in the frontend suite, so the two implementations cannot drift apart on a record without a test failing. When the browser port was brought back in step for release 3.37.0, it differed from the server decoder on 0 values across the 18 native files.
What upstream found first
Much of what this page added in October 2026 was found first by the community parser, and the decoder here was checked against it rather than written in isolation. In upstream’s main branch:
- the Gen3 main-board header offsets for the VIN, model, board and firmware revision and firmware build, on 15 September 2026 (commit 8591af3), building on AtomicDog’s 2022 pull request;
- the 0x32 firmware build banner, also on 15 September (825c9d2);
- the 0x48 charger table and the 0x4F queue-full report with its eleven queue names, on 20 September (f1e34cb, 1da1827);
- the main-board 0x4B, 0x4C and 0x4D state snapshots, on 20 September (82475b7);
- the sub-second microseconds and the per-entry sequence counter in the record prefix;
- and the flash page marker as an overwrite rather than an insertion, with the two cases in which it destroys a record’s header or its type and a page-aware walk of the battery file, on 22 September (2c98751), measured by upstream on 291 files.
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.
That agreement is a portability guarantee: it proves the two implementations agree, and it cannot prove that either agrees with the firmware. Where the Python decoder is wrong, this one is wrong in exactly the same way, by design, and that is what makes the cross-check meaningful. 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. Decoding makes no network request of any kind: nothing is uploaded, no VIN is transmitted, and no analytics event carries anything from your log. Close the tab and it is gone.
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. The shipping decoder has read both since 2026-09-07, from records whose bus is live; they are empty in the states making up 75% of a real log, so the BMS remains the better source. Speed: yes, but not in the common record. The longer 0x52 and 0x53 records carry the road speed, the estimated range and (on newer firmware) the odometer in their tail; the 75-byte 0x51 carries none of them, and record[71:75], published here as an odometer, is a 12 V accessory rail in microvolts. The voltage and current measurements come from five SR/F-platform motorcycles; the one DSR/X in this corpus barely populates them, so treat them 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 in UTC, bytes 7 to 10 are the sub-second part in microseconds, byte 11 is a sequence counter that steps by one on every record, and byte 12 is the motorcycle’s UTC-offset setting in hours as a signed byte (two fields this page used to describe as one uint16 "entry counter"). The payload starts at byte 13. MBB types are 0xFB header, 0xFD text, 0x51, 0x52 and 0x53 state telemetry, 0x54 motor, 0x48 charger table, 0x49 charge targets and 0x4F queue overflow. 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. On a BMS file, remember that four bytes at every 128-byte page boundary are a marker, not data.
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. There are 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 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?
Not in the common record, and not on every firmware. On the newer firmware layout (the one whose 0x51 is 75 bytes), the longer 0x52 and 0x53 records carry the odometer in their tail, in tenths of a kilometre; it has been decoded since 2026-09-07, frozen across a charge and rising only under RUN, and it is checked as a series per file before it is kept. The older layout, whose 0x51 is 71 bytes, carries none: the word there that rises smoothly into a plausible mileage does so only while the motorcycle is charging, and it is the estimated range. What this page used to publish as the odometer was something else again. 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: a motorcycle whose log has no newer-layout 0x52 or 0x53 record now reports no odometer and no per-ride distance rather than a fabricated one. A real odometer also exists 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, with 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 the upstream version this site pins reads these files. 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 00 F0 FF 00 byte sequence in a Gen3 BMS file mean?
It is the flash page marker, which is neither random corruption nor a separator. In every native Gen3 battery file measured it sits at file offset 128·k for k = 1 to 1,025, 1,025 times per file, and in no main-board file. It is written over four bytes of whatever record occupied that position, so those four bytes are gone: a decoder must locate the marker by file offset and withhold the fields it covers, and must never splice it out, because nothing was inserted. An earlier version of this answer said the marker was three bytes, F0 FF 00, that it started at offset 129, and that it could be spliced out before framing; all three statements were wrong. Counting the markers is still a cheap integrity check: a native Gen3 BMS export carries exactly 1,025.
Why do some Gen3 battery logs show activity on a date that other bikes share to the second?
Because the flash page marker forged it. When the marker lands on a record’s type byte it overwrites the timestamp’s three low bytes with F0 FF 00 and leaves only the top byte, so every record hit that way within the same 194 days decodes to one second, whichever motorcycle wrote it: 0x6900FFF0 is 2025-10-28 17:40:00 UTC and 0x6A00FFF0 is 2026-05-10 22:00:16 UTC. In this corpus 2025-10-28 17:40:00 appears on 95 records from five different bikes; on the production corpus one forged day had appeared on 57 motorcycles and another on 38. Since release 3.37.0 those timestamps are withheld, the rows stay undated, and the days and battery-only rides built on them are gone.
Does zero-log-parser decode Gen3 logs?
It depends on which version, and this site uses it for none of its Gen3 decoding either way. The version this site pinned for Gen1 and Gen2 before, commit 87c6e757 of February 2026, prints a Gen3 file’s header as empty or "Unknown". The version it pins now, 90c8cb2 of 29 September 2026, has moved a long way: it reads the Gen3 main-board header, the battery telemetry, the charger, queue-full and firmware-build records and walks the battery file’s flash pages, and where it and this site’s decoder both decode a record they agree: 2,866 of 2,866 cell readings over the 18 native Gen3 files here. What it does not yet decode is the text records, the largest class in a Gen3 file: its message decoder reads the record’s six-byte prefix as the string.
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 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.
Sources:
- zero-motorcycle-community/zero-log-parser, the upstream open-source parser this project builds on (opens in a new tab)
- zero-log-parser: LOG_STRUCTURE.md, the community reference for the Gen1/Gen2 record layout (opens in a new tab)
- zero-log-parser issue #16, "Support SR/F (Gen3) logs", open 2020-02-21 to 2026-02-08 (opens in a new tab)
- zero-log-parser commit 2c98751 (2026-09-22): the page-aware walker for the FST/Gen3 BMS page format (opens in a new tab)
- zero-log-parser commit 8591af3 (2026-09-15): Gen3 MBB header fields (opens in a new tab)
- zero-log-parser commits f1e34cb and 1da1827 (2026-09-20): the 0x48 charger table and 0x4F queue-full records (opens in a new tab)
- NHTSA vPIC: VIN decoding, including the model-year character used by the Gen3 veto (opens in a new tab)
- 49 CFR 565: Vehicle Identification Number requirements, the model-year code table (opens in a new tab)
- ZeroManual: community documentation of Zero Motorcycles diagnostics (Internet Archive) (opens in a new tab)
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.