A parser that accepts its own packets can still be wrong
What a small MAVLink v2 recorder taught me about state machines, checksums, and the difference between a working demo and wire compatibility.
I built a small C++17 telemetry recorder to understand what happens below a drone SDK. The program accepts a stream one byte at a time, reconstructs MAVLink v2 frames, decodes a few useful messages, and writes the result to CSV. The visible output is simple. The interesting engineering is everything required before a row is safe to write.
01Framing a stream without boundaries
A byte stream has no message boundaries. The parser cannot ask for the next packet; it only sees the next byte. I modelled that uncertainty as four explicit states: HUNTING waits for the MAVLink magic byte, HEADER collects the fixed frame metadata, PAYLOAD reads exactly the declared number of bytes, and CRC collects the checksum before deciding whether the frame may be decoded.
That structure made failure behaviour easier to reason about. State and index movement are visible, memory is bounded by a fixed MAVLink-sized buffer, and a rejected frame returns the parser to HUNTING. This is more verbose than handing the stream to a library, but the verbosity is useful when the goal is to understand framing rather than merely consume messages.
02Separate framing from meaning
I kept protocol framing separate from message decoding. The framing layer reconstructs the 24-bit message identifier and delivers a complete payload. The decoder owns message-specific interpretation: HEARTBEAT exposes vehicle state, ATTITUDE converts radians to degrees, and GLOBAL_POSITION_INT converts scaled coordinates and altitude into human-readable units. Adding another message should change the decoder and its checksum metadata, not the state machine.
03The self-consistency trap
The most useful lesson came from the checksum. My test harness builds packets, and my parser checks them with the same CRC-extra calculation. The demo therefore agrees with itself. During review, I compared that calculation with the MAVLink wire algorithm and found a crucial distinction: the message-specific extra byte must be accumulated into the existing X.25 checksum state. XORing the result of a fresh one-byte checksum is not equivalent.
This is the self-consistency trap. When the producer and consumer share the same mistake, end-to-end tests can stay green while interoperability is broken. A parser that accepts packets created by its own helper has proved internal agreement, not protocol compliance.
04Test the protocol, not the implementation
The right next test is not another locally generated packet. It is a table of authoritative wire-format vectors produced by an independent MAVLink implementation, including valid frames, corrupted payloads, unknown message IDs, truncated input, back-to-back traffic, and signed MAVLink v2 frames. Each vector should specify whether a frame is accepted, what fields are decoded, and where the parser must recover after rejection.
Signed frames reveal another boundary. MAVLink v2 can append a 13-byte signature when the incompatibility flag requests it. A parser that stops after the two checksum bytes will treat the signature as unrelated stream data. That may eventually resynchronise, but it is not correct framing. Protocol flags are behaviour, not decorative metadata.
05A better definition of done
I still value the first implementation. It taught me how to turn an informal packet diagram into state, invariants, bounded storage, and decoding boundaries. More importantly, reviewing it changed the definition of done: compiling cleanly and decoding a generated example are milestones; interoperability begins only when an independent implementation agrees.
That distinction applies beyond MAVLink. For databases, network protocols, file formats, and event schemas, compatibility is an external property. The strongest tests cross the boundary between implementations instead of asking one implementation to verify itself.