Skip to main content

Journal Binary Format (.lpj)

The .lpj format is a block-based binary recording of reassembled NMEA 2000 CAN frames. Each file is self-contained: a consumer can read it like a live stream and build up device state from the frames.

File header (16 bytes)​

OffsetSizeField
03Magic: "LPJ" (0x4C 0x50 0x4A)
31Version: 0x01 or 0x02
44BlockSize: uint32 LE (bytes, power of 2, default 262144, min 4096)
84Flags: uint32 LE, bits 0-7 = CompressionType
124Reserved: uint32 LE (0)

Versions​

  • v1 (0x01): Time-based seeking only. No sequence numbers in blocks.
  • v2 (0x02): Adds BaseSeq (uint64 LE) to block headers for sequence-based seeking.

Compression types​

ValueModeBlock layout
0NoneFixed-size blocks, O(1) byte-offset seeking
1zstdVariable-size blocks with block index
2zstd+dictPer-block dictionary, variable-size blocks with block index

Uncompressed block layout​

Each block is exactly BlockSize bytes.

┌──────────────────────────────────────────────────────────────┐
│ +0 BaseTime (8 bytes, int64 LE, unix microseconds) │
│ +8 BaseSeq (8 bytes, uint64 LE) [v2 only] │
├──────────────────────────────────────────────────────────────┤
│ +8/+16 Frame data │
│ [delta] [CANID] [8B data] (standard) │
│ [delta] [CANID] [len] [data] (extended) │
│ ... │
├──────────────────────────────────────────────────────────────┤
│ Zero padding │
├──────────────────────────────────────────────────────────────┤
│ Device table (variable-length entries) │
│ EntryCount: uint16 LE │
│ Entry[0..N-1] │
├──────────────────────────────────────────────────────────────┤
│ +(BlockSize-10) Trailer (10 bytes) │
│ DeviceTableSize: uint16 LE │
│ FrameCount: uint32 LE │
│ Checksum: uint32 LE (CRC32C of [0..BlockSize-4)) │
└──────────────────────────────────────────────────────────────┘

Compressed block layout​

Each compressed block has a header followed by the compressed payload.

zstd (type 1)​

┌──────────────────────────────────────────────────────────────┐
│ Header (12 bytes v1 / 20 bytes v2) │
│ BaseTime: int64 LE (unix microseconds) │
│ BaseSeq: uint64 LE [v2 only] │
│ CompressedLen: uint32 LE │
├──────────────────────────────────────────────────────────────┤
│ CompressedData (CompressedLen bytes) │
│ zstd frame, decompresses to BlockSize bytes │
└──────────────────────────────────────────────────────────────┘

zstd+dict (type 2)​

┌──────────────────────────────────────────────────────────────┐
│ Header (16 bytes v1 / 24 bytes v2) │
│ BaseTime: int64 LE │
│ BaseSeq: uint64 LE [v2 only] │
│ DictLen: uint32 LE │
│ CompressedLen: uint32 LE │
├──────────────────────────────────────────────────────────────┤
│ DictData (DictLen bytes) │
│ zstd dictionary trained from this block's data │
├──────────────────────────────────────────────────────────────┤
│ CompressedData (CompressedLen bytes) │
│ zstd frame compressed with DictData │
└──────────────────────────────────────────────────────────────┘

The decompressed block has the same layout as an uncompressed block. CRC32C is computed on the uncompressed data and verified after decompression.

Frame encoding​

Two variants, selected by bit 31 of the CANID uint32:

Standard-length (bit 31 = 1)​

Data is exactly 8 bytes. No DataLen field.

FieldEncodingSize
DeltaUsunsigned varint1-3 bytes
CANIDuint32 LE (bit 31 set)4 bytes
Datafixed 8 bytes8 bytes

Total: 13-15 bytes. Covers ~90-95% of frames (all standard single-frame PGNs).

Extended-length (bit 31 = 0)​

Variable-length data with explicit length.

FieldEncodingSize
DeltaUsunsigned varint1-3 bytes
CANIDuint32 LE (bit 31 clear)4 bytes
DataLenunsigned varint1-2 bytes
DataDataLen bytesvariable

Used for reassembled fast-packets (up to 1785 bytes) and rare short frames.

Reader logic​

canid = read uint32 LE
if canid & 0x80000000:
canid &= 0x7FFFFFFF // mask off bit 31
data = read 8 bytes
else:
dataLen = read varint
data = read dataLen bytes

Device table​

Located at BlockSize - 10 - DeviceTableSize within the block.

FieldSizeDescription
EntryCountuint16 LENumber of entries

Per entry (variable length, minimum 19 bytes):

FieldSizeDescription
Sourceuint8Source address (0-253)
NAMEuint64 LE64-bit ISO NAME
ActiveFromuint32 LEFrame index where this binding became active (0 = before block start)
ProductCodeuint16 LEPGN 126996 product code (0 = unknown)
ModelIDlength-prefixed stringModel identifier
SoftwareVersionlength-prefixed stringSoftware version
ModelVersionlength-prefixed stringModel/hardware version
ModelSeriallength-prefixed stringSerial number

Length-prefixed strings: 1-byte length followed by that many bytes. Empty strings have length 0.

ActiveFrom semantics​

  • ActiveFrom = 0: device was known before this block (carried over from previous state)
  • ActiveFrom > 0: device was discovered (or changed source address) at that frame index within the block
  • Multiple entries for the same source address: the one with the largest ActiveFrom <= targetFrame is active at that frame
  • This handles mid-block address claim conflicts correctly

Block index​

Appended at file close for compressed files only.

┌──────────────────────────────────────┐
│ Offset[0]: uint64 LE │
│ Offset[1]: uint64 LE │
│ ... │
│ Offset[N-1]: uint64 LE │
├──────────────────────────────────────┤
│ Count: uint32 LE │
│ Magic: "LPJI" (4 bytes) │
└──────────────────────────────────────┘

Reading: seek to EOF-8, read Count + Magic. If Magic == "LPJI", seek to EOF - 8 - Count*8 and read the offset table. On crash/truncation (no valid magic), fall back to forward-scanning block headers.

Seeking​

Time-based (both v1 and v2)​

Uncompressed: binary search reading BaseTime at 16 + mid * BlockSize. O(log N) disk reads.

Compressed: read block index from EOF, binary search in-memory. O(log N) comparisons + 1 read + 1 decompress.

Sequence-based (v2 only)​

Same algorithms, searching on BaseSeq instead of BaseTime. Frame at index i in a block has seq = BaseSeq + i.

Size estimates​

At ~200 frames/sec typical bus rate:

MetricUncompressedzstd (~4x)
Throughput~2.7 KB/s~0.7 KB/s
Per hour~10 MB~2.5 MB
Per day~233 MB~58 MB
Per month~7 GB~1.7 GB

Device table overhead: ~1000-1600 bytes per block (20 devices with product info). Block index overhead: ~600 bytes/hour (negligible).