The first time you care about memory layout is usually not because you want to, but because you have to.
In high-frequency trading, bytes are the interface. Market data feeds arrive as tightly packed binary messages designed to minimize bandwidth and parsing overhead. Exchange specs don’t describe objects or classes; they describe offsets. Byte 0–1 is the message length. Byte 2 is the message type. Byte 3–10 is the timestamp. Everything is deliberate, everything is fixed, and everything assumes you interpret the bytes exactly as intended.
At some point you inevitably end up staring at a specification that looks something like
Offset Size Field
0 2 MessageLength (uint16)
2 1 MessageType (char)
3 8 SendingTime (uint64)
11 4 InstrumentID (uint32)
15 8 Price (int64)
23 4 Quantity (uint32)
27 1 Flags (bitfield)
The natural instinct is to translate this directly into a struct. After all, structs are supposed to represent structured data. But the moment you do that, you’ve implicitly asked the compiler a very specific question: "Please lay out this data in memory exactly the way this external binary format expects", and the compiler might reply with "I’ll do something reasonable for the CPU". Those two goals are often aligned, but not always!
CPUs prefer aligned memory accesses. Languages prefer type safety. Compilers insert padding you didn’t ask for. Some reorder things for performance. Others preserve declaration order but still insert invisible bytes between fields. Different languages make different guarantees. Even the same language can produce different layouts depending on architecture, ABI, or compiler flags. This doesn't matter most of the time, until you need the exact byte representation.
Binary protocols, file formats, shared memory regions, kernel interfaces, FPGA feeds, and network packets all rely on a contract that exists below the level most languages try to abstract away. The abstraction leaks the moment your program must agree with something that wasn't compiled together with it.
That’s where struct packing and unpacking comes in.
This post is about building a mental model of how structured C++ data becomes bytes, why that representation may not match an external binary format, and how to write parsers that make layout, byte order, and lifetime explicit. We’ll look at how alignment rules influence layout, why padding appears, how endianness affects interpretation, and how different ecosystems give you varying levels of control over representation.
Let’s start with the most dangerous assumption
If a struct has the same fields as the binary message, then it has the same layout as the binary message.
That assumption is false often enough that it should make you nervous. Suppose we translate the exchange specification directly into a C++ struct
struct MarketDataMessage { std::uint16_t MessageLength; char MessageType; std::uint64_t SendingTime; std::uint32_t InstrumentID; std::int64_t Price; std::uint32_t Quantity; std::uint8_t Flags; };
At first glance, this looks perfect... The fields are in the right order. The types look correct. The names match the specification. Surely this corresponds to the binary format. But the compiler sees something different. The compiler is not looking at an exchange specification. It is looking at CPU-friendly memory layout. It knows that a uint64_t usually wants to live at an address divisible by 8. It knows that an int64_t usually wants the same. It knows that arrays of this struct need each element to be properly aligned too. So the compiler may lay the struct out more like this
Offset Size Field
0 2 MessageLength
2 1 MessageType
3 5 padding
8 8 SendingTime
16 4 InstrumentID
20 4 padding
24 8 Price
32 4 Quantity
36 1 Flags
37 3 tail padding
Total size: 40 bytes
The protocol message is 28 bytes. The C++ struct may be 40 bytes. That is not a small difference. That is not a cosmetic difference. That is your parser reading the wrong bytes.
You can prove this with sizeof and offsetof
#include <cstddef> #include <cstdint> #include <iomanip> #include <iostream> #include <type_traits> struct MarketDataMessage { std::uint16_t MessageLength; char MessageType; std::uint64_t SendingTime; std::uint32_t InstrumentID; std::int64_t Price; std::uint32_t Quantity; std::uint8_t Flags; }; static_assert(std::is_standard_layout_v<MarketDataMessage>); #define PRINT_OFFSET(type, field) \ std::cout << std::left << std::setw(16) << #field \ << " offset = " << offsetof(type, field) << '\n' int main() { std::cout << "sizeof(MarketDataMessage) = " << sizeof(MarketDataMessage) << '\n'; PRINT_OFFSET(MarketDataMessage, MessageLength); PRINT_OFFSET(MarketDataMessage, MessageType); PRINT_OFFSET(MarketDataMessage, SendingTime); PRINT_OFFSET(MarketDataMessage, InstrumentID); PRINT_OFFSET(MarketDataMessage, Price); PRINT_OFFSET(MarketDataMessage, Quantity); PRINT_OFFSET(MarketDataMessage, Flags); }
The static_assert(std::is_standard_layout_v<MarketDataMessage>) is important because offsetof is only meant for types with predictable standard-layout behavior. This still does not mean the struct matches the wire format. It only means C++ gives us enough layout regularity to inspect the offsets meaningfully.
If you compile this on a 64-bit machine, you will likely see something close to
sizeof(MarketDataMessage) = 40
MessageLength offset = 0
MessageType offset = 2
SendingTime offset = 8
InstrumentID offset = 16
Price offset = 24
Quantity offset = 32
Flags offset = 36
But the feed expects
MessageLength offset = 0
MessageType offset = 2
SendingTime offset = 3
InstrumentID offset = 11
Price offset = 15
Quantity offset = 23
Flags offset = 27
For example, a 28-byte packet might look like this 1C 00 41 88 77 66 55 44 33 22 11 2A 00 00 00 15 CD 5B 07 00 00 00 00 64 00 00 00 03
Offset 0: 1C 00 => MessageLength = 28
Offset 2: 41 => MessageType = 'A'
Offset 3: 88 77 66 55 44 33 22 11 => SendingTime = 0x1122334455667788
Offset 11: 2A 00 00 00 => InstrumentID = 42
Offset 15: 15 CD 5B 07 00 00 00 00 => Price = 123456789
Offset 23: 64 00 00 00 => Quantity = 100
Offset 27: 03 => Flags = 3
This is the level at which the protocol operates. It does not know that your program has a MarketDataMessage struct. It only knows that byte 11 begins a 4-byte little-endian integer, byte 15 begins an 8-byte little-endian signed integer, and so on.
Our struct is not wrong from the compiler’s perspective. It is wrong from the protocol’s perspective. That distinction matters. The compiler’s job is to produce correct and efficient code for the language’s object model. The exchange’s job is to define a stable byte contract. Those are different contracts.
Alignment: the hidden force behind padding
Most CPUs do not treat all memory addresses equally. A 1-byte value can usually live anywhere. A 2-byte value is often happiest at an address divisible by 2. A 4-byte value wants an address divisible by 4. An 8-byte value wants an address divisible by 8.
So something like uint64_t x is usually expected to be stored at an address like 0x1000 or 0x1008 or 0x1010 or ..., and not 0x1003 or 0x1005 or ... because an address like 0x1003 is not divisible by 8, so an 8-byte load from there will be unaligned.
On some architectures, unaligned access is allowed but slower. On others, it may trap. Even on architectures where unaligned access works, the compiler may still generate different code depending on what it can assume about alignment. This is why padding exists.
Consider this smaller struct
struct Foo { char a; std::uint32_t b; };
You might expect it to be 5 bytes as a: 1 byte and b: 4 bytes, but it's 8 bytes
Offset Size Field
0 1 a
1 3 padding
4 4 b
The compiler inserts 3 bytes after a so that b starts at offset 4.
Now consider an array Foo arr[2]. If Foo were 5 bytes, the second element would start 5 bytes after the first. That means the uint32_t b inside the second element would start at an awkward offset. So the compiler also adds tail padding to structs when needed. The size of a struct is usually rounded up to satisfy the alignment requirements of its fields. This means padding can appear in two places
- Internal padding between fields.
- Tail padding at the end of the struct.
Tail padding is especially easy to forget because it does not correspond to any field.
The three layouts you should keep separate
When working with binary data, it helps to think in terms of three different layouts.
Wire layout
This is the external binary contract. It is the layout defined by an exchange, file format, network protocol, kernel API, FPGA, or another process. The wire layout does not care what your compiler prefers.
Memory layout
This is how your language stores an object in memory. The memory layout may contain padding. It may depend on compiler rules, ABI rules, target architecture, and annotations.
Semantic layout
This is the representation your application actually wants to use. For example, the wire format may store price as
Price = 123456789
Scale = 4
But your application may need it as price = 12345.6789.
These three things are related, but they are not the same. A common mistake is to use one struct for all three jobs. I would keep three representations separate
raw bytes => wire parser => domain object
The parser understands offsets and byte order. The domain object understands business meaning.
Packing the struct
Most systems languages give you a way to request a packed layout. In C and C++, one common approach is #pragma pack.
NOTE - #pragma pack is not a portable C++ language feature in the same way that struct is. It is a compiler-supported extension understood by major compilers, but the exact behavior still belongs to the compiler and platform. If you depend on it, verify it.
#include <cstdint> #pragma pack(push, 1) struct PackedMarketDataMessage { std::uint16_t MessageLength; char MessageType; std::uint64_t SendingTime; std::uint32_t InstrumentID; std::int64_t Price; std::uint32_t Quantity; std::uint8_t Flags; }; #pragma pack(pop)
With packing set to 1, the compiler is told not to insert alignment padding between fields. The layout becomes
Offset Size Field
0 2 MessageLength
2 1 MessageType
3 8 SendingTime
11 4 InstrumentID
15 8 Price
23 4 Quantity
27 1 Flags
Total size: 28 bytes
Now this looks like the protocol. But there are tradeoffs.
Packed structs are not magic. They do not make the CPU like unaligned access. They merely force the compiler to represent the struct with smaller alignment. Accessing packed fields may generate less efficient code. On some platforms, it may require multiple byte loads and shifts instead of a single aligned load. Also, packing solves only one problem: padding. It does not solve
- endianness
- integer representation assumptions
- floating-point representation assumptions
- bitfield ordering
- versioning
- variable-length fields
- optional fields
- lifetime issues
- strict aliasing issues
- validation
- bounds checking
Packing can be useful, but it is not a parser. A rough rule of thumb:
| Situation | Packed struct | Explicit parser |
|---|---|---|
| Fixed internal shared-memory layout you fully control | Maybe | Useful |
| External exchange or network protocol | Risky | Yes |
| Cross-platform parser | Risky | Yes |
| Variable-length message | No | Yes |
| One-off debugging tool | Fine | Fine |
| Production low-latency parser | Only with strict validation | Yes |
Packed structs are most defensible when the binary layout is fixed, the platform is controlled, the compiler is known, and the layout is verified at build time. Explicit parsers are better when the data crosses a boundary you do not fully control.
You should still verify the layout explicitly
#include <cstddef> #include <cstdint> #pragma pack(push, 1) struct PackedMarketDataMessage { std::uint16_t MessageLength; char MessageType; std::uint64_t SendingTime; std::uint32_t InstrumentID; std::int64_t Price; std::uint32_t Quantity; std::uint8_t Flags; }; #pragma pack(pop) static_assert(sizeof(PackedMarketDataMessage) == 28, "wrong message size"); static_assert(offsetof(PackedMarketDataMessage, MessageLength) == 0, "wrong offset"); static_assert(offsetof(PackedMarketDataMessage, MessageType) == 2, "wrong offset"); static_assert(offsetof(PackedMarketDataMessage, SendingTime) == 3, "wrong offset"); static_assert(offsetof(PackedMarketDataMessage, InstrumentID) == 11, "wrong offset"); static_assert(offsetof(PackedMarketDataMessage, Price) == 15, "wrong offset"); static_assert(offsetof(PackedMarketDataMessage, Quantity) == 23, "wrong offset"); static_assert(offsetof(PackedMarketDataMessage, Flags) == 27, "wrong offset");
The static assertions are not optional decoration. They are executable documentation. If the compiler, architecture, or build configuration does not produce the layout we expect, fail the build.
Packed structs are tempting, but parsing is often better
A packed struct makes the wire layout look convenient:
auto *msg = reinterpret_cast<const PackedMarketDataMessage *>(buffer.data()); std::cout << "instrument=" << msg->InstrumentID << '\n';
This looks attractive, feels zero-copy, feels fast. But it can hide several problems.
- The buffer may not be sufficiently aligned for the struct pointer. Packing reduces the alignment of the struct itself, but careless casts can still run into portability issues.
- The bytes may be in little-endian or big-endian order. Reading
msg->InstrumentIDdirectly interprets those bytes using the host machine’s endianness. - The buffer may not actually contain 28 bytes. A packet may be truncated, malformed, or simply not the message type you think it is.
- The struct ties your parser to the compiler’s representation of the fields.
A more explicit parser is boring, but robust. It is verbose, but when a packet is malformed at 9:30:00.001, I would rather debug offsets than debug undefined assumptions.
#include <cstddef> #include <cstdint> #include <optional> #include <span> struct MarketDataMessage { std::uint16_t message_length; char message_type; std::uint64_t sending_time; std::uint32_t instrument_id; std::int64_t price; std::uint32_t quantity; std::uint8_t flags; }; std::uint16_t read_u16_le(std::span<const std::uint8_t> bytes, std::size_t offset) { return static_cast<std::uint16_t>(bytes[offset]) | static_cast<std::uint16_t>(bytes[offset + 1]) << 8; } std::uint32_t read_u32_le(std::span<const std::uint8_t> bytes, std::size_t offset) { return static_cast<std::uint32_t>(bytes[offset]) | static_cast<std::uint32_t>(bytes[offset + 1]) << 8 | static_cast<std::uint32_t>(bytes[offset + 2]) << 16 | static_cast<std::uint32_t>(bytes[offset + 3]) << 24; } std::uint64_t read_u64_le(std::span<const std::uint8_t> bytes, std::size_t offset) { return static_cast<std::uint64_t>(bytes[offset]) | static_cast<std::uint64_t>(bytes[offset + 1]) << 8 | static_cast<std::uint64_t>(bytes[offset + 2]) << 16 | static_cast<std::uint64_t>(bytes[offset + 3]) << 24 | static_cast<std::uint64_t>(bytes[offset + 4]) << 32 | static_cast<std::uint64_t>(bytes[offset + 5]) << 40 | static_cast<std::uint64_t>(bytes[offset + 6]) << 48 | static_cast<std::uint64_t>(bytes[offset + 7]) << 56; } std::int64_t read_i64_le(std::span<const std::uint8_t> bytes, std::size_t offset) { return static_cast<std::int64_t>(read_u64_le(bytes, offset)); } std::optional<MarketDataMessage> parse_market_data_message(std::span<const std::uint8_t> bytes) { if (bytes.size() < 28) { return std::nullopt; } return MarketDataMessage{ .message_length = read_u16_le(bytes, 0), .message_type = static_cast<char>(bytes[2]), .sending_time = read_u64_le(bytes, 3), .instrument_id = read_u32_le(bytes, 11), .price = read_i64_le(bytes, 15), .quantity = read_u32_le(bytes, 23), .flags = bytes[27], }; }
This code is more verbose than a cast, but every important assumption is visible. The function takes a std::span, so it can parse data from a std::vector, an array, a receive buffer, or any other contiguous byte sequence without owning it. Returning std::optional makes malformed input explicit without throwing exceptions in the hot path.
I prefer this version in production code because I can audit every offset without mentally expanding a cast.
In high-performance code, you can still optimize this. You can use compiler intrinsics, memcpy, byte-swap instructions, vectorized decoding, generated parsers, or carefully validated packed structs. But correctness starts with a parser that says what it means.
A memcpy-based middle ground
Manual shifts make byte order obvious, but there is another pattern: copy the bytes into an integer object with std::memcpy, then byte-swap if needed.
#include <bit> #include <cstdint> #include <cstring> std::uint32_t read_u32_le_memcpy(const std::uint8_t *p) { std::uint32_t value; std::memcpy(&value, p, sizeof(value)); if constexpr (std::endian::native == std::endian::big) { value = __builtin_bswap32(value); } /* __builtin_bswap32 is a GCC/Clang builtin. In C++23, you can use `std::byteswap(value)` instead. */ return value; }
The important detail is that this does not reinterpret the packet buffer as a std::uint32_t*. It copies bytes into a real std::uint32_t object. That avoids unaligned pointer dereferences and strict-aliasing problems. Optimizing compilers usually turn small fixed-size memcpy calls like this into efficient loads. This still does not remove the need for bounds checks. You must verify that the buffer contains enough bytes before calling the helper.
Endianness: the byte order inside the field
Alignment and padding answer this question
Where does the field start?
Endianness answers a different question
Once I find the field, how do I interpret its bytes?
Take this 32-bit integer 0x12345678. It contains four bytes 12 34 56 78
A big-endian machine stores the most significant byte first:
- Address + 0: 12
- Address + 1: 34
- Address + 2: 56
- Address + 3: 78
A little-endian machine stores the least significant byte first:
- Address + 0: 78
- Address + 1: 56
- Address + 2: 34
- Address + 3: 12
So if the protocol says:
Offset Size Field
11 4 InstrumentID (uint32, little-endian)
and the bytes are 78 56 34 12, then the value is 0x12345678. But if you interpret those same bytes as big-endian, you get 0x78563412. The bytes did not change. Your interpretation changed. That is the core idea.
Do not say the data is an integer too casually. The data is bytes. You construct an integer from those bytes using a byte order. This is why protocol specifications usually say things like All integer fields are little-endian. or All numeric fields are encoded in network byte order.
Network byte order usually means big-endian, but market data feeds do not universally use big-endian. Some use little-endian because the consumers are overwhelmingly running on little-endian commodity hardware. Some use big-endian because of older network conventions. Some have their own historical reasons.
Python: struct makes the layout explicit
Python’s struct module is useful because it forces you to write down the binary format.
For our message:
import struct WIRE_FORMAT = struct.Struct("<HcQIqIB") def parse_market_data(packet: bytes) -> dict: if len(packet) < WIRE_FORMAT.size: raise ValueError("packet too short") ( message_length, message_type, sending_time, instrument_id, price, quantity, flags ) = WIRE_FORMAT.unpack_from(packet, 0) return { "message_length": message_length, "message_type": message_type.decode("ascii"), "sending_time": sending_time, "instrument_id": instrument_id, "price": price, "quantity": quantity, "flags": flags }
The leading < means
- little-endian
- standard sizes
- no native alignment padding
The rest of the format means
- H: unsigned short (2 bytes)
- c: char (1 byte)
- Q: unsigned long long (8 bytes)
- I: unsigned int (4 bytes)
- q: signed long long (8 bytes)
- I: unsigned int (4 bytes)
- B: unsigned char (1 byte)
Variable-length messages change the shape of the problem
Fixed-layout messages are the easy case. Many real protocols have a fixed header followed by variable-length data:
Offset Size Field
0 2 MessageLength
2 1 MessageType
3 8 SendingTime
11 2 SymbolLength
13 N Symbol
13+N 4 Quantity
Now the offset of Quantity depends on SymbolLength. You cannot represent this cleanly with a single fixed struct. You need staged parsing.
#include <cstddef> #include <cstdint> #include <optional> #include <span> #include <string_view> struct SymbolMessageView { std::uint16_t message_length; char message_type; std::uint64_t sending_time; std::string_view symbol; std::uint32_t quantity; }; std::optional<SymbolMessageView> parse_symbol_message(std::span<const std::uint8_t> bytes) { if (bytes.size() < 13) { return std::nullopt; } const auto message_length = read_u16_le(bytes, 0); const auto message_type = static_cast<char>(bytes[2]); const auto sending_time = read_u64_le(bytes, 3); const auto symbol_length = read_u16_le(bytes, 11); const std::size_t symbol_offset = 13; const std::size_t quantity_offset = symbol_offset + symbol_length; if (bytes.size() < quantity_offset + 4) { return std::nullopt; } const auto *symbol_begin = reinterpret_cast<const char *>(bytes.data() + symbol_offset); return SymbolMessageView{ .message_length = message_length, .message_type = message_type, .sending_time = sending_time, .symbol = std::string_view(symbol_begin, symbol_length), .quantity = read_u32_le(bytes, quantity_offset), }; }
The returned std::string_view does not own the symbol bytes. It is only valid as long as the original packet buffer remains alive. That is fine for immediate parsing, logging, or dispatch, but dangerous if the message is stored after the receive buffer is reused.
Zero-copy is not the same as zero-work
In low-latency systems, zero-copy sounds like the obvious goal, and sometimes it is. If packets are arriving at millions of messages per second, avoiding unnecessary copies can matter. But zero-copy parsing does not mean zero interpretation. You still have to
- check that the buffer is long enough
- check the message type
- interpret byte order
- handle unaligned fields
- decode flags
- validate lengths
- branch on schema versions
- convert raw prices into application representation
A useful compromise is the view pattern. Instead of copying every field into a new object immediately, keep a reference to the packet and expose accessors
#include <array> #include <cstddef> #include <cstdint> #include <iostream> #include <span> #include <stdexcept> class MarketDataView { public: explicit MarketDataView(std::span<const std::uint8_t> bytes) : bytes_(bytes) { if (bytes_.size() < 28) { throw std::runtime_error("short packet"); } } std::uint16_t message_length() const { return read_u16_le(0); } char message_type() const { return static_cast<char>(bytes_[2]); } std::uint64_t sending_time() const { return read_u64_le(3); } std::uint32_t instrument_id() const { return read_u32_le(11); } std::int64_t price() const { return static_cast<std::int64_t>(read_u64_le(15)); } std::uint32_t quantity() const { return read_u32_le(23); } std::uint8_t flags() const { return bytes_[27]; } private: std::span<const std::uint8_t> bytes_; std::uint16_t read_u16_le(std::size_t offset) const { return static_cast<std::uint16_t>(bytes_[offset]) | static_cast<std::uint16_t>(bytes_[offset + 1]) << 8; } std::uint32_t read_u32_le(std::size_t offset) const { return static_cast<std::uint32_t>(bytes_[offset]) | static_cast<std::uint32_t>(bytes_[offset + 1]) << 8 | static_cast<std::uint32_t>(bytes_[offset + 2]) << 16 | static_cast<std::uint32_t>(bytes_[offset + 3]) << 24; } std::uint64_t read_u64_le(std::size_t offset) const { return static_cast<std::uint64_t>(bytes_[offset]) | static_cast<std::uint64_t>(bytes_[offset + 1]) << 8 | static_cast<std::uint64_t>(bytes_[offset + 2]) << 16 | static_cast<std::uint64_t>(bytes_[offset + 3]) << 24 | static_cast<std::uint64_t>(bytes_[offset + 4]) << 32 | static_cast<std::uint64_t>(bytes_[offset + 5]) << 40 | static_cast<std::uint64_t>(bytes_[offset + 6]) << 48 | static_cast<std::uint64_t>(bytes_[offset + 7]) << 56; } }; int main() { const std::array<std::uint8_t, 28> packet = { 0x1C, 0x00, // MessageLength = 28 0x41, // MessageType = 'A' 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // SendingTime = 0x1122334455667788 0x2A, 0x00, 0x00, 0x00, // InstrumentID = 42 0x15, 0xCD, 0x5B, 0x07, 0x00, 0x00, 0x00, 0x00, // Price = 123456789 0x64, 0x00, 0x00, 0x00, // Quantity = 100 0x03 // Flags = 3 }; MarketDataView message(packet); std::cout << "message_length = " << message.message_length() << '\n'; std::cout << "message_type = " << message.message_type() << '\n'; std::cout << "sending_time = " << message.sending_time() << '\n'; std::cout << "instrument_id = " << message.instrument_id() << '\n'; std::cout << "price = " << message.price() << '\n'; std::cout << "quantity = " << message.quantity() << '\n'; std::cout << "flags = " << static_cast<int>(message.flags()) << '\n'; }
This avoids copying all fields up front, while keeping the byte interpretation explicit. But views have a lifetime constraint. The view is only valid while the underlying packet buffer is valid. That matters if your network stack reuses buffers. It matters if you store messages in a queue. It matters if one thread receives packets and another processes them later.
A copied domain object is more expensive, but safer to store
struct MarketData { std::uint64_t sending_time; std::uint32_t instrument_id; std::int64_t price; std::uint32_t quantity; std::uint8_t flags; };
My rule is, parse into a view only if the packet dies in the same stage. If the message crosses a queue, thread, or retry path, copy the fields I need.
A practical parser checklist
Before trusting a binary parser, ask:
- Did I validate the minimum message length before reading fields?
- Did I check the message type before decoding the body?
- Is every field read from an explicit offset?
- Is byte order handled explicitly?
- Are variable-length fields bounds-checked before use?
- Are strings treated as length-delimited data rather than null-terminated C strings?
- Are packed layouts verified with
static_assert? - Are reserved bytes and reserved bits handled intentionally?
- Is the lifetime of the underlying buffer clear?
- Do I have tests using known-good byte sequences from the protocol specification?
The deeper lesson
Struct packing is not really about structs. It is about contracts.
Let the spec define the bytes. Let the parser translate those bytes. Let the domain object represent what your application actually uses. Yes, this is tedious. It feels annoying until the first time a feed changes, a compiler flag changes, or a packet gets truncated. Then the separation stops feeling like ceremony and starts looking like the thing that saved you.