messageIndexOffsets = messageIndexOffsetsRef == null ? null : messageIndexOffsetsRef.get();
+
+ if (messageIndexOffsets == null)
+ {
+ messageIndexOffsets = MCAP.parseList(dataInput, MessageIndexOffset::new, messageIndexOffsetsOffset, messageIndexOffsetsLength);
+ messageIndexOffsetsRef = new WeakReference<>(messageIndexOffsets);
+ }
+
+ return messageIndexOffsets;
+ }
+
+ @Override
+ public long messageIndexLength()
+ {
+ return messageIndexLength;
+ }
+
+ @Override
+ public Compression compression()
+ {
+ return compression;
+ }
+
+ @Override
+ public long recordsCompressedLength()
+ {
+ return recordsCompressedLength;
+ }
+
+ @Override
+ public long recordsUncompressedLength()
+ {
+ return recordsUncompressedLength;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof ChunkIndex other && ChunkIndex.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Compression.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Compression.java
new file mode 100644
index 000000000..0aedba235
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Compression.java
@@ -0,0 +1,36 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+public enum Compression
+{
+ NONE(""), LZ4("lz4"), ZSTD("zstd");
+
+ private final String name;
+ private final int length;
+
+ Compression(String name)
+ {
+ this.name = name;
+ length = name.getBytes().length + Integer.BYTES;
+ }
+
+ public int getLength()
+ {
+ return length;
+ }
+
+ public String getName()
+ {
+ return name;
+ }
+
+ public static Compression fromString(String name)
+ {
+ return switch (name.trim().toLowerCase())
+ {
+ case "" -> NONE;
+ case "lz4" -> LZ4;
+ case "zstd" -> ZSTD;
+ default -> throw new IllegalArgumentException("Unsupported compression: " + name);
+ };
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/DataEnd.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/DataEnd.java
new file mode 100644
index 000000000..f526ec16d
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/DataEnd.java
@@ -0,0 +1,81 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+import us.ihmc.scs2.session.mcap.specs.MCAP;
+
+/**
+ * A Data End record indicates the end of the data section.
+ *
+ * @see MCAP Data End
+ */
+public class DataEnd implements MCAPElement
+{
+ private final long dataSectionCRC32;
+
+ public DataEnd(MCAPDataInput dataInput, long elementPosition, long elementLength)
+ {
+ dataInput.position(elementPosition);
+ dataSectionCRC32 = dataInput.getUnsignedInt();
+ MCAP.checkLength(elementLength, getElementLength());
+ }
+
+ public DataEnd(long dataSectionCrc32)
+ {
+ this.dataSectionCRC32 = dataSectionCrc32;
+ }
+
+ @Override
+ public long getElementLength()
+ {
+ return Integer.BYTES;
+ }
+
+ /**
+ * CRC-32 of all bytes in the data section. A value of 0 indicates the CRC-32 is not available.
+ */
+ public long dataSectionCRC32()
+ {
+ return dataSectionCRC32;
+ }
+
+ @Override
+ public void write(MCAPDataOutput dataOutput)
+ {
+ dataOutput.putUnsignedInt(dataSectionCRC32);
+ }
+
+ @Override
+ public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32)
+ {
+ if (crc32 == null)
+ crc32 = new MCAPCRC32Helper();
+ crc32.addUnsignedInt(dataSectionCRC32);
+ return crc32;
+ }
+
+ @Override
+ public String toString()
+ {
+ return getClass().getSimpleName() + ":\n\t-dataSectionCrc32 = " + dataSectionCRC32;
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof DataEnd other && equals(other);
+ }
+
+ @Override
+ public boolean equals(MCAPElement mcapElement)
+ {
+ if (mcapElement == this)
+ return true;
+
+ if (mcapElement instanceof DataEnd other)
+ return dataSectionCRC32() == other.dataSectionCRC32();
+
+ return false;
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Footer.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Footer.java
new file mode 100644
index 000000000..80d03ad91
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Footer.java
@@ -0,0 +1,209 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+import us.ihmc.scs2.session.mcap.specs.MCAP;
+
+import java.util.Collection;
+
+/**
+ * Footer records contain end-of-file information. MCAP files must end with a Footer record.
+ *
+ * @see MCAP Footer
+ */
+public class Footer implements MCAPElement
+{
+ public static final int ELEMENT_LENGTH = 2 * Long.BYTES + Integer.BYTES;
+ private final MCAPDataInput dataInput;
+ /**
+ * Position in the file of the first record of the summary section.
+ *
+ * The summary section contains schema, channel, chunk index, attachment index, metadata index, and statistics records.
+ * It is not delimited by an encapsulating record as the rest, instead, the summary section simply starts right after the {@link DataEnd} record.
+ * Note that the records in the summary section must be grouped by {@link Opcode}.
+ *
+ */
+ private final long summarySectionOffset;
+ /**
+ * The summary section is followed directly by summary offset records.
+ *
+ * There is one summary offset record per {@link Opcode} in the summary section.
+ * The summary offset records are used to quickly locate the start of each group.
+ */
+ private final long summaryOffsetSectionOffset;
+ private final long summaryCRC32;
+ private Records summarySection;
+ private Records summaryOffsetSection;
+
+ public Footer(MCAPDataInput dataInput, long elementPosition, long elementLength)
+ {
+ this.dataInput = dataInput;
+
+ dataInput.position(elementPosition);
+ summarySectionOffset = MCAP.checkPositiveLong(dataInput.getLong(), "summarySectionOffset");
+ summaryOffsetSectionOffset = MCAP.checkPositiveLong(dataInput.getLong(), "summaryOffsetSectionOffset");
+ summaryCRC32 = dataInput.getUnsignedInt();
+ MCAP.checkLength(elementLength, getElementLength());
+ }
+
+ public Footer(long summarySectionOffset, Collection extends Record> summaryRecords, Collection extends Record> summaryOffsetRecords)
+ {
+ this.summarySection = new Records(summaryRecords);
+ this.summaryOffsetSection = new Records(summaryOffsetRecords);
+
+ this.summarySectionOffset = summarySectionOffset;
+ this.summaryOffsetSectionOffset = summarySectionOffset + summarySection.getElementLength();
+
+ MCAPCRC32Helper crc32 = new MCAPCRC32Helper();
+ summarySection.forEach(record -> record.updateCRC(crc32));
+ summaryOffsetSection.forEach(record -> record.updateCRC(crc32));
+ crc32.addUnsignedByte(Opcode.FOOTER.id());
+ crc32.addLong(ELEMENT_LENGTH);
+ crc32.addLong(summarySectionOffset);
+ crc32.addLong(summaryOffsetSectionOffset);
+ this.summaryCRC32 = crc32.getValue();
+
+ this.dataInput = null;
+ }
+
+ public static long computeOffsetFooter(MCAPDataInput dataInput)
+ {
+ // Offset to the beginning of the footer.
+ return dataInput.size() - Record.RECORD_HEADER_LENGTH - ELEMENT_LENGTH - Magic.MAGIC_SIZE;
+ }
+
+ @Override
+ public long getElementLength()
+ {
+ return ELEMENT_LENGTH;
+ }
+
+ public Records summarySection()
+ {
+ if (summarySection == null && summarySectionOffset != 0)
+ summarySection = Records.load(dataInput, summarySectionOffset, summarySectionLength());
+ return summarySection;
+ }
+
+ public long summarySectionLength()
+ {
+ long summarySectionEnd = summaryOffsetSectionOffset != 0 ? summaryOffsetSectionOffset : computeOffsetFooter(dataInput);
+ return summarySectionEnd - summarySectionOffset;
+ }
+
+ public Records summaryOffsetSection()
+ {
+ if (summaryOffsetSection == null && summaryOffsetSectionOffset != 0)
+ summaryOffsetSection = Records.load(dataInput, summaryOffsetSectionOffset, summaryOffsetSectionLength());
+ return summaryOffsetSection;
+ }
+
+ public long summaryOffsetSectionLength()
+ {
+ return computeOffsetFooter(dataInput) - summaryOffsetSectionOffset;
+ }
+
+ private byte[] summaryCRC32Input;
+
+ public byte[] summaryCRC32Input()
+ {
+ if (summaryCRC32Input == null)
+ {
+ long offset = summaryCRC32StartOffset();
+ long length = dataInput.size() - offset - Magic.MAGIC_SIZE - Integer.BYTES;
+ summaryCRC32Input = dataInput.getBytes(offset, (int) length);
+ }
+ return summaryCRC32Input;
+ }
+
+ /**
+ * Offset to the first record of the summary section or to the footer if there is no summary section.
+ *
+ * It is used to compute the CRC.
+ *
+ */
+ public long summaryCRC32StartOffset()
+ {
+ return summarySectionOffset != 0 ? summarySectionOffset : computeOffsetFooter(dataInput);
+ }
+
+ public long summarySectionOffset()
+ {
+ return summarySectionOffset;
+ }
+
+ public long summaryOffsetSectionOffset()
+ {
+ return summaryOffsetSectionOffset;
+ }
+
+ /**
+ * A CRC-32 of all bytes from the start of the Summary section up through and including the end of
+ * the previous field (summary_offset_start) in the footer record. A value of 0 indicates the CRC-32
+ * is not available.
+ */
+ public long summaryCRC32()
+ {
+ return summaryCRC32;
+ }
+
+ @Override
+ public void write(MCAPDataOutput dataOutput)
+ {
+ dataOutput.putLong(summarySectionOffset());
+ dataOutput.putLong(summaryOffsetSectionOffset());
+ dataOutput.putUnsignedInt(summaryCRC32());
+ }
+
+ @Override
+ public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32)
+ {
+ if (crc32 == null)
+ crc32 = new MCAPCRC32Helper();
+ crc32.addLong(summarySectionOffset());
+ crc32.addLong(summaryOffsetSectionOffset());
+ crc32.addUnsignedInt(summaryCRC32());
+ return crc32;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public String toString(int indent)
+ {
+ String out = getClass().getSimpleName() + ":";
+ out += "\n\t-ofsSummarySection = " + summarySectionOffset();
+ out += "\n\t-ofsSummaryOffsetSection = " + summaryOffsetSectionOffset();
+ out += "\n\t-summaryCrc32 = " + summaryCRC32();
+ return MCAPElement.indent(out, indent);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof Footer other && equals(other);
+ }
+
+ @Override
+ public boolean equals(MCAPElement mcapElement)
+ {
+ if (mcapElement == this)
+ return true;
+
+ if (mcapElement instanceof Footer other)
+ {
+ if (summarySectionOffset() != other.summarySectionOffset())
+ return false;
+ if (summaryOffsetSectionOffset() != other.summaryOffsetSectionOffset())
+ return false;
+ return summaryCRC32() == other.summaryCRC32();
+ }
+
+ return false;
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Header.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Header.java
new file mode 100644
index 000000000..c16ffad9d
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Header.java
@@ -0,0 +1,97 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+import us.ihmc.scs2.session.mcap.specs.MCAP;
+
+import java.util.Objects;
+
+/**
+ * Header is the first record in an MCAP file.
+ *
+ * @see MCAP Header
+ */
+public class Header implements MCAPElement
+{
+ private final String profile;
+ private final String library;
+
+ public Header(MCAPDataInput dataInput, long elementPosition, long elementLength)
+ {
+ dataInput.position(elementPosition);
+ profile = dataInput.getString();
+ library = dataInput.getString();
+ MCAP.checkLength(elementLength, getElementLength());
+ }
+
+ public Header(String profile, String library)
+ {
+ this.profile = profile;
+ this.library = library;
+ }
+
+ @Override
+ public long getElementLength()
+ {
+ return 2 * Integer.BYTES + profile.length() + library.length();
+ }
+
+ public String profile()
+ {
+ return profile;
+ }
+
+ public String library()
+ {
+ return library;
+ }
+
+ @Override
+ public void write(MCAPDataOutput dataOutput)
+ {
+ dataOutput.putString(profile);
+ dataOutput.putString(library);
+ }
+
+ @Override
+ public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32)
+ {
+ if (crc32 == null)
+ crc32 = new MCAPCRC32Helper();
+ crc32.addString(profile);
+ crc32.addString(library);
+ return crc32;
+ }
+
+ @Override
+ public String toString()
+ {
+ String out = getClass().getSimpleName() + ": ";
+ out += "\n\t-profile = " + profile;
+ out += "\n\t-library = " + library;
+ return out;
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof Header other && equals(other);
+ }
+
+ @Override
+ public boolean equals(MCAPElement mcapElement)
+ {
+ if (mcapElement == this)
+ return true;
+
+ if (mcapElement instanceof Header other)
+ {
+ if (!Objects.equals(profile(), other.profile()))
+ return false;
+ return Objects.equals(library(), other.library());
+ }
+
+ return false;
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MCAPElement.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MCAPElement.java
new file mode 100644
index 000000000..d7944d089
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MCAPElement.java
@@ -0,0 +1,28 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+
+public interface MCAPElement
+{
+ static String indent(String stringToIndent, int indent)
+ {
+ if (indent <= 0)
+ return stringToIndent;
+ String indentStr = "\t".repeat(indent);
+ return indentStr + stringToIndent.replace("\n", "\n" + indentStr);
+ }
+
+ void write(MCAPDataOutput dataOutput);
+
+ MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32);
+
+ long getElementLength();
+
+ default String toString(int indent)
+ {
+ return indent(toString(), indent);
+ }
+
+ boolean equals(MCAPElement mcapElement);
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Magic.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Magic.java
new file mode 100644
index 000000000..c9c755a1b
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Magic.java
@@ -0,0 +1,38 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+
+import java.util.Arrays;
+
+/**
+ * @see MCAP Magic
+ */
+public class Magic
+{
+ public static final int MAGIC_SIZE = 8;
+ public static final byte[] MAGIC_BYTES = {-119, 77, 67, 65, 80, 48, 13, 10};
+
+ private Magic()
+ {
+
+ }
+
+ public static void readMagic(MCAPDataInput dataInput, long elementPosition)
+ {
+ dataInput.position(elementPosition);
+ byte[] magic = dataInput.getBytes(MAGIC_SIZE);
+ if (!(Arrays.equals(magic, MAGIC_BYTES)))
+ throw new IllegalArgumentException("Invalid magic bytes: " + Arrays.toString(magic) + ". Expected: " + Arrays.toString(MAGIC_BYTES));
+ }
+
+ public static void writeMagic(MCAPDataOutput dataOutput)
+ {
+ dataOutput.putBytes(MAGIC_BYTES);
+ }
+
+ public static long getElementLength()
+ {
+ return MAGIC_SIZE;
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Message.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Message.java
new file mode 100644
index 000000000..393f46e90
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Message.java
@@ -0,0 +1,95 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+
+import java.nio.ByteBuffer;
+import java.util.Objects;
+
+/**
+ * Message records encode a single timestamped message on a channel.
+ * The message encoding and schema must match that of the Channel record corresponding to the message's channel ID.
+ *
+ * @see MCAP Message
+ */
+public interface Message extends MCAPElement
+{
+ static Message load(MCAPDataInput dataInput, long elementPosition, long elementLength)
+ {
+ return new MessageDataInputBacked(dataInput, elementPosition, elementLength);
+ }
+
+ int channelId();
+
+ long sequence();
+
+ long logTime();
+
+ long publishTime();
+
+ long dataOffset();
+
+ int dataLength();
+
+ ByteBuffer messageBuffer();
+
+ byte[] messageData();
+
+ @Override
+ default void write(MCAPDataOutput dataOutput)
+ {
+ dataOutput.putUnsignedShort(channelId());
+ dataOutput.putUnsignedInt(sequence());
+ dataOutput.putLong(logTime());
+ dataOutput.putLong(publishTime());
+ dataOutput.putUnsignedInt(dataLength());
+ dataOutput.putByteBuffer(messageBuffer());
+ }
+
+ @Override
+ default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32)
+ {
+ if (crc32 == null)
+ crc32 = new MCAPCRC32Helper();
+ crc32.addUnsignedShort(channelId());
+ crc32.addUnsignedInt(sequence());
+ crc32.addLong(logTime());
+ crc32.addLong(publishTime());
+ crc32.addUnsignedInt(dataLength());
+ crc32.addByteBuffer(messageBuffer());
+ return crc32;
+ }
+
+ @Override
+ default long getElementLength()
+ {
+ return dataLength() + Short.BYTES + Integer.BYTES + 2 * Long.BYTES;
+ }
+
+ @Override
+ default boolean equals(MCAPElement mcapElement)
+ {
+ if (mcapElement == this)
+ return true;
+
+ if (mcapElement instanceof Message other)
+ {
+ if (channelId() != other.channelId())
+ return false;
+ if (sequence() != other.sequence())
+ return false;
+ if (logTime() != other.logTime())
+ return false;
+ if (publishTime() != other.publishTime())
+ return false;
+ if (dataOffset() != other.dataOffset())
+ return false;
+ if (dataLength() != other.dataLength())
+ return false;
+ return Objects.equals(messageBuffer(), other.messageBuffer());
+ }
+
+ return false;
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageDataInputBacked.java
new file mode 100644
index 000000000..2278359a7
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageDataInputBacked.java
@@ -0,0 +1,130 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.specs.MCAP;
+
+import java.lang.ref.WeakReference;
+import java.nio.ByteBuffer;
+
+class MessageDataInputBacked implements Message
+{
+ private final MCAPDataInput dataInput;
+ private final int channelId;
+ private final long sequence;
+ private final long logTime;
+ private final long publishTime;
+ private final long dataOffset;
+ private final int dataLength;
+ private WeakReference messageBufferRef;
+ private WeakReference messageDataRef;
+
+ MessageDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength)
+ {
+ this.dataInput = dataInput;
+
+ dataInput.position(elementPosition);
+ channelId = dataInput.getUnsignedShort();
+ sequence = dataInput.getUnsignedInt();
+ logTime = MCAP.checkPositiveLong(dataInput.getLong(), "logTime");
+ publishTime = MCAP.checkPositiveLong(dataInput.getLong(), "publishTime");
+ dataOffset = dataInput.position();
+ dataLength = (int) (elementLength - (Short.BYTES + Integer.BYTES + 2 * Long.BYTES));
+ MCAP.checkLength(elementLength, getElementLength());
+ }
+
+ @Override
+ public int channelId()
+ {
+ return channelId;
+ }
+
+ @Override
+ public long sequence()
+ {
+ return sequence;
+ }
+
+ @Override
+ public long logTime()
+ {
+ return logTime;
+ }
+
+ @Override
+ public long publishTime()
+ {
+ return publishTime;
+ }
+
+ /**
+ * Returns the offset of the data portion of this message in the buffer returned by
+ * {@link #messageBuffer()}.
+ *
+ * @return the offset of the data portion of this message.
+ */
+ @Override
+ public long dataOffset()
+ {
+ return dataOffset;
+ }
+
+ /**
+ * Returns the length of the data portion of this message.
+ *
+ * @return the length of the data portion of this message.
+ */
+ @Override
+ public int dataLength()
+ {
+ return dataLength;
+ }
+
+ /**
+ * Returns the buffer containing this message, the data AND the header. Use {@link #dataOffset()}
+ * and {@link #dataLength()} to get the data portion.
+ *
+ * @return the buffer containing this message.
+ */
+ @Override
+ public ByteBuffer messageBuffer()
+ {
+ ByteBuffer messageBuffer = messageBufferRef == null ? null : messageBufferRef.get();
+ if (messageBuffer == null)
+ {
+ messageBuffer = dataInput.getByteBuffer(dataOffset, dataLength, false);
+ messageBufferRef = new WeakReference<>(messageBuffer);
+ }
+ return messageBuffer;
+ }
+
+ @Override
+ public byte[] messageData()
+ {
+ byte[] messageData = messageDataRef == null ? null : messageDataRef.get();
+
+ if (messageData == null)
+ {
+ messageData = dataInput.getBytes(dataOffset, dataLength);
+ messageDataRef = new WeakReference<>(messageData);
+ }
+ return messageData;
+ }
+
+ @Override
+ public String toString()
+ {
+ String out = getClass().getSimpleName() + ": ";
+ out += "\n\t-channelId = " + channelId;
+ out += "\n\t-sequence = " + sequence;
+ out += "\n\t-logTime = " + logTime;
+ out += "\n\t-publishTime = " + publishTime;
+ // out += "\n\t-data = " + data;
+ return out;
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof Message other && Message.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndex.java
new file mode 100644
index 000000000..1f4112bac
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndex.java
@@ -0,0 +1,67 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.euclid.tools.EuclidCoreIOTools;
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * MessageIndex records allow readers to locate individual records within a chunk by timestamp.
+ * A sequence of Message Index records occurs immediately after each chunk.
+ * Exactly one Message Index record must exist in the sequence for every channel on which a message occurs inside the chunk.
+ *
+ * @see MCAP Message Index
+ */
+public interface MessageIndex extends MCAPElement
+{
+ int channelId();
+
+ List messageIndexEntries();
+
+ @Override
+ default void write(MCAPDataOutput dataOutput)
+ {
+ dataOutput.putUnsignedShort(channelId());
+ dataOutput.putCollection(messageIndexEntries());
+ }
+
+ @Override
+ default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32)
+ {
+ if (crc32 == null)
+ crc32 = new MCAPCRC32Helper();
+ crc32.addUnsignedShort(channelId());
+ crc32.addCollection(messageIndexEntries());
+ return crc32;
+ }
+
+ @Override
+ default String toString(int indent)
+ {
+ String out = getClass().getSimpleName() + ":";
+ out += "\n\t-channelId = " + channelId();
+ List messageIndexEntries = messageIndexEntries();
+ out += "\n\t-messageIndexEntries = " + (messageIndexEntries == null ?
+ "null" :
+ "\n" + EuclidCoreIOTools.getCollectionString("\n", messageIndexEntries, e -> e.toString(indent + 1)));
+ return MCAPElement.indent(out, indent);
+ }
+
+ @Override
+ default boolean equals(MCAPElement mcapElement)
+ {
+ if (mcapElement == this)
+ return true;
+
+ if (mcapElement instanceof MessageIndex other)
+ {
+ if (channelId() != other.channelId())
+ return false;
+ return Objects.equals(messageIndexEntries(), other.messageIndexEntries());
+ }
+
+ return false;
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexDataInputBacked.java
new file mode 100644
index 000000000..5a0808c24
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexDataInputBacked.java
@@ -0,0 +1,66 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.specs.MCAP;
+
+import java.lang.ref.WeakReference;
+import java.util.List;
+
+public class MessageIndexDataInputBacked implements MessageIndex
+{
+ private final MCAPDataInput dataInput;
+ private final long elementLength;
+ private final int channelId;
+ private WeakReference> messageIndexEntriesRef;
+ private final long messageIndexEntriesOffset;
+ private final long messageIndexEntriesLength;
+
+ public MessageIndexDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength)
+ {
+ this.dataInput = dataInput;
+ this.elementLength = elementLength;
+
+ dataInput.position(elementPosition);
+ channelId = dataInput.getUnsignedShort();
+ messageIndexEntriesLength = dataInput.getUnsignedInt();
+ messageIndexEntriesOffset = dataInput.position();
+ }
+
+ @Override
+ public long getElementLength()
+ {
+ return elementLength;
+ }
+
+ @Override
+ public int channelId()
+ {
+ return channelId;
+ }
+
+ @Override
+ public List messageIndexEntries()
+ {
+ List messageIndexEntries = messageIndexEntriesRef == null ? null : messageIndexEntriesRef.get();
+
+ if (messageIndexEntries == null)
+ {
+ messageIndexEntries = MCAP.parseList(dataInput, MessageIndexEntry::new, messageIndexEntriesOffset, messageIndexEntriesLength);
+ messageIndexEntriesRef = new WeakReference<>(messageIndexEntries);
+ }
+
+ return messageIndexEntries;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof MessageIndex other && MessageIndex.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexEntry.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexEntry.java
new file mode 100644
index 000000000..27b3d2c1a
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexEntry.java
@@ -0,0 +1,106 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+import us.ihmc.scs2.session.mcap.specs.MCAP;
+
+public class MessageIndexEntry implements MCAPElement
+{
+ /**
+ * Time at which the message was recorded.
+ */
+ private final long logTime;
+
+ /**
+ * Offset is relative to the start of the uncompressed chunk data.
+ */
+ private final long offset;
+
+ /**
+ * @param logTime time at which the message was recorded.
+ * @param offset offset is relative to the start of the uncompressed chunk data.
+ */
+ public MessageIndexEntry(long logTime, long offset)
+ {
+ this.logTime = MCAP.checkPositiveLong(logTime, "logTime");
+ this.offset = MCAP.checkPositiveLong(offset, "offset");
+ }
+
+ public MessageIndexEntry(MCAPDataInput dataInput, long elementPosition)
+ {
+ dataInput.position(elementPosition);
+ logTime = MCAP.checkPositiveLong(dataInput.getLong(), "logTime");
+ offset = MCAP.checkPositiveLong(dataInput.getLong(), "offset");
+ }
+
+ @Override
+ public long getElementLength()
+ {
+ return 2 * Long.BYTES;
+ }
+
+ public long logTime()
+ {
+ return logTime;
+ }
+
+ public long offset()
+ {
+ return offset;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public void write(MCAPDataOutput dataOutput)
+ {
+ dataOutput.putLong(logTime());
+ dataOutput.putLong(offset());
+ }
+
+ @Override
+ public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32)
+ {
+ if (crc32 == null)
+ crc32 = new MCAPCRC32Helper();
+ crc32.addLong(logTime());
+ crc32.addLong(offset());
+ return crc32;
+ }
+
+ @Override
+ public String toString(int indent)
+ {
+ String out = getClass().getSimpleName() + ":";
+ out += "\n\t-logTime = " + logTime();
+ out += "\n\t-offset = " + offset();
+ return MCAPElement.indent(out, indent);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof MessageIndexEntry other && equals(other);
+ }
+
+ @Override
+ public boolean equals(MCAPElement mcapElement)
+ {
+ if (mcapElement == this)
+ return true;
+
+ if (mcapElement instanceof MessageIndexEntry other)
+ {
+ if (logTime() != other.logTime())
+ return false;
+ return offset() == other.offset();
+ }
+
+ return false;
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexOffset.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexOffset.java
new file mode 100644
index 000000000..65649d104
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MessageIndexOffset.java
@@ -0,0 +1,100 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+import us.ihmc.scs2.session.mcap.specs.MCAP;
+
+public class MessageIndexOffset implements MCAPElement
+{
+ /**
+ * Channel ID.
+ */
+ private final int channelId;
+ /**
+ * Offset of the message index record for that channel after the chunk, from the start of the file.
+ */
+ private final long offset;
+
+ public MessageIndexOffset(MCAPDataInput dataInput, long elementPosition)
+ {
+ dataInput.position(elementPosition);
+ channelId = dataInput.getUnsignedShort();
+ offset = MCAP.checkPositiveLong(dataInput.getLong(), "offset");
+ }
+
+ public MessageIndexOffset(int channelId, long offset)
+ {
+ this.channelId = channelId;
+ this.offset = offset;
+ }
+
+ @Override
+ public long getElementLength()
+ {
+ return Short.BYTES + Long.BYTES;
+ }
+
+ public int channelId()
+ {
+ return channelId;
+ }
+
+ public long offset()
+ {
+ return offset;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public void write(MCAPDataOutput dataOutput)
+ {
+ dataOutput.putUnsignedShort(channelId);
+ dataOutput.putLong(offset);
+ }
+
+ @Override
+ public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32)
+ {
+ if (crc32 == null)
+ crc32 = new MCAPCRC32Helper();
+ crc32.addUnsignedShort(channelId);
+ crc32.addLong(offset);
+ return crc32;
+ }
+
+ @Override
+ public String toString(int indent)
+ {
+ String out = getClass().getSimpleName() + ":";
+ out += "\n\t-channelId = " + channelId();
+ out += "\n\t-offset = " + offset();
+ return MCAPElement.indent(out, indent);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof MessageIndexOffset other && equals(other);
+ }
+
+ @Override
+ public boolean equals(MCAPElement mcapElement)
+ {
+ if (mcapElement == this)
+ return true;
+
+ if (mcapElement instanceof MessageIndexOffset other)
+ {
+ if (channelId() != other.channelId())
+ return false;
+ return offset() == other.offset();
+ }
+ return false;
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Metadata.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Metadata.java
new file mode 100644
index 000000000..4a494564b
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Metadata.java
@@ -0,0 +1,96 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.euclid.tools.EuclidCoreIOTools;
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+import us.ihmc.scs2.session.mcap.specs.MCAP;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * A metadata record contains arbitrary user data in key-value pairs.
+ *
+ * @see MCAP Metadata
+ */
+public class Metadata implements MCAPElement
+{
+ private final String name;
+ private final List metadata;
+ private final int metadataLength;
+
+ Metadata(MCAPDataInput dataInput, long elementPosition, long elementLength)
+ {
+ dataInput.position(elementPosition);
+ name = dataInput.getString();
+ long start = dataInput.position();
+ metadata = MCAP.parseList(dataInput, StringPair::new); // TODO Looks into postponing the loading of the metadata.
+ metadataLength = (int) (dataInput.position() - start);
+ MCAP.checkLength(elementLength, getElementLength());
+ }
+
+ @Override
+ public long getElementLength()
+ {
+ return Integer.BYTES + name.length() + metadataLength;
+ }
+
+ public String name()
+ {
+ return name;
+ }
+
+ public List metadata()
+ {
+ return metadata;
+ }
+
+ @Override
+ public void write(MCAPDataOutput dataOutput)
+ {
+ dataOutput.putString(name);
+ dataOutput.putCollection(metadata);
+ }
+
+ @Override
+ public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32)
+ {
+ if (crc32 == null)
+ crc32 = new MCAPCRC32Helper();
+ crc32.addString(name);
+ crc32.addCollection(metadata);
+ return crc32;
+ }
+
+ @Override
+ public String toString()
+ {
+ String out = getClass().getSimpleName() + ": ";
+ out += "\n\t-name = " + name();
+ out += "\n\t-metadata = " + EuclidCoreIOTools.getCollectionString(", ", metadata(), e -> e.key());
+ return out;
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof Metadata other && equals(other);
+ }
+
+ @Override
+ public boolean equals(MCAPElement mcapElement)
+ {
+ if (mcapElement == this)
+ return true;
+
+ if (mcapElement instanceof Metadata other)
+ {
+ if (!name().equals(other.name()))
+ return false;
+ return Objects.equals(metadata(), other.metadata());
+ }
+
+ return false;
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndex.java
new file mode 100644
index 000000000..c91bad990
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndex.java
@@ -0,0 +1,77 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+
+import java.util.Objects;
+
+/**
+ * A metadata index record contains the location of a metadata record within the file.
+ *
+ * @see MCAP Metadata Index
+ */
+public interface MetadataIndex extends MCAPElement
+{
+ @Override
+ default long getElementLength()
+ {
+ return 2 * Long.BYTES + Integer.BYTES + name().length();
+ }
+
+ Record metadata();
+
+ long metadataOffset();
+
+ long metadataLength();
+
+ String name();
+
+ @Override
+ default void write(MCAPDataOutput dataOutput)
+ {
+ dataOutput.putLong(metadataOffset());
+ dataOutput.putLong(metadataLength());
+ dataOutput.putString(name());
+ }
+
+ @Override
+ default MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32)
+ {
+ if (crc32 == null)
+ crc32 = new MCAPCRC32Helper();
+ crc32.addLong(metadataOffset());
+ crc32.addLong(metadataLength());
+ crc32.addString(name());
+ return crc32;
+ }
+
+ @Override
+ default String toString(int indent)
+ {
+ String out = getClass().getSimpleName() + ": ";
+ out += "\n\t-metadataOffset = " + metadataOffset();
+ out += "\n\t-metadataLength = " + metadataLength();
+ out += "\n\t-name = " + name();
+ return MCAPElement.indent(out, indent);
+ }
+
+ @Override
+ default boolean equals(MCAPElement mcapElement)
+ {
+ if (mcapElement == this)
+ return true;
+
+ if (mcapElement instanceof MetadataIndex other)
+ {
+ if (metadataOffset() != other.metadataOffset())
+ return false;
+ if (metadataLength() != other.metadataLength())
+ return false;
+ if (!Objects.equals(name(), other.name()))
+ return false;
+ return Objects.equals(metadata(), other.metadata());
+ }
+
+ return false;
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndexDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndexDataInputBacked.java
new file mode 100644
index 000000000..d7bb80631
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MetadataIndexDataInputBacked.java
@@ -0,0 +1,69 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.specs.MCAP;
+
+import java.lang.ref.WeakReference;
+
+public class MetadataIndexDataInputBacked implements MetadataIndex
+{
+ private final MCAPDataInput dataInput;
+ private final long metadataOffset;
+ private final long metadataLength;
+ private final String name;
+ private WeakReference metadataRef;
+
+ MetadataIndexDataInputBacked(MCAPDataInput dataInput, long elementPosition, long elementLength)
+ {
+ this.dataInput = dataInput;
+
+ dataInput.position(elementPosition);
+ metadataOffset = MCAP.checkPositiveLong(dataInput.getLong(), "metadataOffset");
+ metadataLength = MCAP.checkPositiveLong(dataInput.getLong(), "metadataLength");
+ name = dataInput.getString();
+ MCAP.checkLength(elementLength, getElementLength());
+ }
+
+ @Override
+ public Record metadata()
+ {
+ Record metadata = metadataRef == null ? null : metadataRef.get();
+
+ if (metadata == null)
+ {
+ metadata = new RecordDataInputBacked(dataInput, metadataOffset);
+ metadataRef = new WeakReference<>(metadata);
+ }
+ return metadata;
+ }
+
+ @Override
+ public long metadataOffset()
+ {
+ return metadataOffset;
+ }
+
+ @Override
+ public long metadataLength()
+ {
+ return metadataLength;
+ }
+
+ @Override
+ public String name()
+ {
+ return name;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof MetadataIndex other && MetadataIndex.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableAttachmentIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableAttachmentIndex.java
new file mode 100644
index 000000000..db7804b31
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableAttachmentIndex.java
@@ -0,0 +1,120 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+public class MutableAttachmentIndex implements AttachmentIndex
+{
+ private long attachmentOffset;
+ private long attachmentLength;
+ private long logTime;
+ private long createTime;
+ private long dataLength;
+ private String name;
+ private String mediaType;
+ private Record attachment;
+
+ @Override
+ public Record attachment()
+ {
+ return attachment;
+ }
+
+ public void setAttachment(Record attachment)
+ {
+ this.attachment = attachment;
+ Attachment attachmentBody = attachment.body();
+ attachmentLength = attachment.getElementLength();
+ logTime = attachmentBody.logTime();
+ createTime = attachmentBody.createTime();
+ dataLength = attachmentBody.dataLength();
+ name = attachmentBody.name();
+ mediaType = attachmentBody.mediaType();
+ }
+
+ @Override
+ public long attachmentOffset()
+ {
+ return attachmentOffset;
+ }
+
+ public void setAttachmentOffset(long attachmentOffset)
+ {
+ this.attachmentOffset = attachmentOffset;
+ }
+
+ @Override
+ public long attachmentLength()
+ {
+ return attachmentLength;
+ }
+
+ public void setAttachmentLength(long attachmentLength)
+ {
+ this.attachmentLength = attachmentLength;
+ }
+
+ @Override
+ public long logTime()
+ {
+ return logTime;
+ }
+
+ public void setLogTime(long logTime)
+ {
+ this.logTime = logTime;
+ }
+
+ @Override
+ public long createTime()
+ {
+ return createTime;
+ }
+
+ public void setCreateTime(long createTime)
+ {
+ this.createTime = createTime;
+ }
+
+ @Override
+ public long dataLength()
+ {
+ return dataLength;
+ }
+
+ public void setDataLength(long dataLength)
+ {
+ this.dataLength = dataLength;
+ }
+
+ @Override
+ public String name()
+ {
+ return name;
+ }
+
+ public void setName(String name)
+ {
+ this.name = name;
+ }
+
+ @Override
+ public String mediaType()
+ {
+ return mediaType;
+ }
+
+ public void setMediaType(String mediaType)
+ {
+ this.mediaType = mediaType;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof AttachmentIndex other && AttachmentIndex.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunk.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunk.java
new file mode 100644
index 000000000..bf94c2140
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunk.java
@@ -0,0 +1,179 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import com.github.luben.zstd.ZstdCompressCtx;
+import us.ihmc.scs2.session.mcap.encoding.LZ4FrameEncoder;
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.output.MCAPByteBufferDataOutput;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Objects;
+
+public class MutableChunk implements Chunk
+{
+ private long messageStartTime;
+ private long messageEndTime;
+ private Compression compression = Compression.LZ4;
+
+ private Records records;
+ private long recordsCRC32 = -1L;
+ private ByteBuffer recordsCompressedData;
+
+ public void setMessageStartTime(long messageStartTime)
+ {
+ this.messageStartTime = messageStartTime;
+ }
+
+ public void setMessageEndTime(long messageEndTime)
+ {
+ this.messageEndTime = messageEndTime;
+ }
+
+ public void setCompression(Compression compression)
+ {
+ this.compression = compression;
+ }
+
+ public void setRecords(Records records)
+ {
+ this.records = records;
+ }
+
+ @Override
+ public long messageStartTime()
+ {
+ return messageStartTime;
+ }
+
+ @Override
+ public long messageEndTime()
+ {
+ return messageEndTime;
+ }
+
+ @Override
+ public long recordsUncompressedLength()
+ {
+ return records.getElementLength();
+ }
+
+ @Override
+ public long uncompressedCRC32()
+ {
+ return records == null ? 0 : records.updateCRC(null).getValue();
+ }
+
+ @Override
+ public Compression compression()
+ {
+ return compression;
+ }
+
+ @Override
+ public long recordsCompressedLength()
+ {
+ return getRecordsCompressedBuffer().remaining();
+ }
+
+ @Override
+ public Records records()
+ {
+ return records;
+ }
+
+ @Override
+ public long getElementLength()
+ {
+ getRecordsCompressedBuffer(); // Make sure the compressed data is available.
+ return 4 * Long.BYTES + Integer.BYTES + compression.getLength() + recordsCompressedLength();
+ }
+
+ @Override
+ public ByteBuffer getRecordsCompressedBuffer(boolean directBuffer)
+ {
+ long newRecordsCRC32 = uncompressedCRC32();
+
+ if (recordsCompressedData == null || recordsCRC32 != newRecordsCRC32)
+ {
+ recordsCRC32 = newRecordsCRC32;
+ Objects.requireNonNull(compression, "The compression has not been set yet.");
+ Objects.requireNonNull(records, "The records have not been set yet.");
+
+ ByteBuffer uncompressedBuffer = getRecordsUncompressedBuffer(compression == Compression.ZSTD);
+
+ recordsCompressedData = switch (compression)
+ {
+ case NONE:
+ {
+ yield uncompressedBuffer;
+ }
+ case LZ4:
+ {
+ LZ4FrameEncoder lz4FrameEncoder = new LZ4FrameEncoder();
+ yield lz4FrameEncoder.encode(uncompressedBuffer, null);
+ }
+ case ZSTD:
+ {
+ try (ZstdCompressCtx zstdCompressCtx = new ZstdCompressCtx())
+ {
+ yield zstdCompressCtx.compress(uncompressedBuffer);
+ }
+ }
+ };
+ recordsCompressedData.order(ByteOrder.LITTLE_ENDIAN);
+ }
+
+ return recordsCompressedData.duplicate();
+ }
+
+ @Override
+ public ByteBuffer getRecordsUncompressedBuffer(boolean directBuffer)
+ {
+ MCAPByteBufferDataOutput recordsOutput = new MCAPByteBufferDataOutput((int) records.getElementLength(), 2, directBuffer);
+ records.forEach(element -> element.write(recordsOutput));
+ recordsOutput.close();
+ return recordsOutput.getBuffer();
+ }
+
+ @Override
+ public void write(MCAPDataOutput dataOutput)
+ {
+ dataOutput.putLong(messageStartTime);
+ dataOutput.putLong(messageEndTime);
+ dataOutput.putLong(records.getElementLength());
+ dataOutput.putUnsignedInt(records.updateCRC(null).getValue());
+ dataOutput.putString(compression.getName());
+ ByteBuffer recordsCompressedBuffer = getRecordsCompressedBuffer();
+ dataOutput.putLong(recordsCompressedBuffer.remaining());
+ dataOutput.putByteBuffer(recordsCompressedBuffer);
+ }
+
+ @Override
+ public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32)
+ {
+ if (crc32 == null)
+ crc32 = new MCAPCRC32Helper();
+ crc32.addLong(messageStartTime);
+ crc32.addLong(messageEndTime);
+ crc32.addLong(records.getElementLength());
+ crc32.addUnsignedInt(records.updateCRC(null).getValue());
+ crc32.addString(compression.getName());
+ ByteBuffer recordsCompressedBuffer = getRecordsCompressedBuffer();
+ crc32.addLong(recordsCompressedBuffer.remaining());
+ crc32.addByteBuffer(recordsCompressedBuffer);
+ return crc32;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof Chunk other && Chunk.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunkIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunkIndex.java
new file mode 100644
index 000000000..01f1fe433
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableChunkIndex.java
@@ -0,0 +1,172 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+public class MutableChunkIndex implements ChunkIndex
+{
+ private Record chunk;
+ private long messageStartTime;
+ private long messageEndTime;
+ private long chunkOffset;
+ private long chunkLength;
+ private long messageIndexOffsetsLength;
+ private List messageIndexOffsets;
+ private long messageIndexLength;
+ private Compression compression;
+ private long recordsCompressedLength;
+ private long recordsUncompressedLength;
+
+ @Override
+ public Record chunk()
+ {
+ return chunk;
+ }
+
+ public void setChunk(Record chunk)
+ {
+ this.chunk = chunk;
+ Chunk body = chunk.body();
+ messageStartTime = body.messageStartTime();
+ messageEndTime = body.messageEndTime();
+ chunkLength = chunk.getElementLength();
+ // Resets the message index offsets, they have to be set manually.
+ messageIndexOffsets = new ArrayList<>();
+ messageIndexOffsetsLength = 0L;
+ compression = body.compression();
+ recordsCompressedLength = body.recordsCompressedLength();
+ recordsUncompressedLength = body.recordsUncompressedLength();
+ }
+
+ @Override
+ public long messageStartTime()
+ {
+ return messageStartTime;
+ }
+
+ public void setMessageStartTime(long messageStartTime)
+ {
+ this.messageStartTime = messageStartTime;
+ }
+
+ @Override
+ public long messageEndTime()
+ {
+ return messageEndTime;
+ }
+
+ public void setMessageEndTime(long messageEndTime)
+ {
+ this.messageEndTime = messageEndTime;
+ }
+
+ @Override
+ public long chunkOffset()
+ {
+ return chunkOffset;
+ }
+
+ public void setChunkOffset(long chunkOffset)
+ {
+ this.chunkOffset = chunkOffset;
+ }
+
+ @Override
+ public long chunkLength()
+ {
+ return chunkLength;
+ }
+
+ public void setChunkLength(long chunkLength)
+ {
+ this.chunkLength = chunkLength;
+ }
+
+ @Override
+ public long messageIndexOffsetsLength()
+ {
+ return messageIndexOffsetsLength;
+ }
+
+ public void setMessageIndexOffsetsLength(long messageIndexOffsetsLength)
+ {
+ this.messageIndexOffsetsLength = messageIndexOffsetsLength;
+ }
+
+ @Override
+ public List messageIndexOffsets()
+ {
+ return messageIndexOffsets;
+ }
+
+ public void setMessageIndexOffsets(List messageIndexOffsets)
+ {
+ this.messageIndexOffsets = messageIndexOffsets;
+ messageIndexOffsetsLength = messageIndexOffsets.stream().mapToLong(MessageIndexOffset::getElementLength).sum();
+ }
+
+ @Override
+ public long messageIndexLength()
+ {
+ return messageIndexLength;
+ }
+
+ public void setMessageIndexLength(long messageIndexLength)
+ {
+ this.messageIndexLength = messageIndexLength;
+ }
+
+ @Override
+ public Compression compression()
+ {
+ return compression;
+ }
+
+ public void setCompression(Compression compression)
+ {
+ this.compression = compression;
+ }
+
+ @Override
+ public long recordsCompressedLength()
+ {
+ return recordsCompressedLength;
+ }
+
+ public void setRecordsCompressedLength(long recordsCompressedLength)
+ {
+ this.recordsCompressedLength = recordsCompressedLength;
+ }
+
+ @Override
+ public long recordsUncompressedLength()
+ {
+ return recordsUncompressedLength;
+ }
+
+ public void setRecordsUncompressedLength(long recordsUncompressedLength)
+ {
+ this.recordsUncompressedLength = recordsUncompressedLength;
+ }
+
+ @Override
+ public long getElementLength()
+ {
+ Objects.requireNonNull(messageIndexOffsets, "The message index offsets must be set before calling this method.");
+ Objects.requireNonNull(compression, "The compression must be set before calling this method.");
+ return 7 * Long.BYTES + Integer.BYTES + messageIndexOffsetsLength + compression.getLength();
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof ChunkIndex other && ChunkIndex.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessage.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessage.java
new file mode 100644
index 000000000..0439ab866
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessage.java
@@ -0,0 +1,116 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import java.nio.ByteBuffer;
+
+public class MutableMessage implements Message
+{
+ private int channelId;
+ private long sequence;
+ private long logTime;
+ private long publishTime;
+ private long dataOffset;
+ private int dataLength;
+ private byte[] messageData;
+
+ public MutableMessage(int channelId, byte[] data)
+ {
+ this.channelId = channelId;
+ this.messageData = data;
+ this.dataLength = data.length;
+ }
+
+ @Override
+ public int channelId()
+ {
+ return channelId;
+ }
+
+ public void setChannelId(int channelId)
+ {
+ this.channelId = channelId;
+ }
+
+ @Override
+ public long sequence()
+ {
+ return sequence;
+ }
+
+ public void setSequence(long sequence)
+ {
+ this.sequence = sequence;
+ }
+
+ @Override
+ public long logTime()
+ {
+ return logTime;
+ }
+
+ public void setLogTime(long logTime)
+ {
+ this.logTime = logTime;
+ }
+
+ @Override
+ public long publishTime()
+ {
+ return publishTime;
+ }
+
+ public void setPublishTime(long publishTime)
+ {
+ this.publishTime = publishTime;
+ }
+
+ @Override
+ public long dataOffset()
+ {
+ return dataOffset;
+ }
+
+ public void setDataOffset(long dataOffset)
+ {
+ this.dataOffset = dataOffset;
+ }
+
+ @Override
+ public int dataLength()
+ {
+ return dataLength;
+ }
+
+ public void setDataLength(int dataLength)
+ {
+ this.dataLength = dataLength;
+ }
+
+ @Override
+ public byte[] messageData()
+ {
+ return messageData;
+ }
+
+ @Override
+ public ByteBuffer messageBuffer()
+ {
+ return ByteBuffer.wrap(messageData);
+ }
+
+ public void setMessageData(byte[] messageData)
+ {
+ this.messageData = messageData;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof Message other && Message.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessageIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessageIndex.java
new file mode 100644
index 000000000..454a603a4
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMessageIndex.java
@@ -0,0 +1,65 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class MutableMessageIndex implements MessageIndex
+{
+ private int channelId;
+ private List messageIndexEntries;
+ private long messageIndexEntriesLength = 0;
+
+ public void setChannelId(int channelId)
+ {
+ this.channelId = channelId;
+ }
+
+ public void setMessageIndexEntries(List messageIndexEntries)
+ {
+ this.messageIndexEntries = messageIndexEntries;
+ messageIndexEntriesLength = messageIndexEntries.stream().mapToLong(MessageIndexEntry::getElementLength).sum();
+ }
+
+ public void addMessageIndexEntry(MessageIndexEntry messageIndexEntry)
+ {
+ if (messageIndexEntries == null)
+ messageIndexEntries = new ArrayList<>();
+ messageIndexEntries.add(messageIndexEntry);
+ messageIndexEntriesLength += messageIndexEntry.getElementLength();
+ }
+
+ public long messageIndexEntriesLength()
+ {
+ return messageIndexEntriesLength;
+ }
+
+ @Override
+ public long getElementLength()
+ {
+ return Short.BYTES + Integer.BYTES + messageIndexEntriesLength;
+ }
+
+ @Override
+ public int channelId()
+ {
+ return channelId;
+ }
+
+ @Override
+ public List messageIndexEntries()
+ {
+ return messageIndexEntries;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof MessageIndex other && MessageIndex.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMetadataIndex.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMetadataIndex.java
new file mode 100644
index 000000000..ee3385c05
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableMetadataIndex.java
@@ -0,0 +1,68 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+public class MutableMetadataIndex implements MetadataIndex
+{
+ private long metadataOffset;
+ private long metadataLength;
+ private String name;
+ private Record metadata;
+
+ @Override
+ public Record metadata()
+ {
+ return metadata;
+ }
+
+ public void setMetadata(Record metadata)
+ {
+ this.metadata = metadata;
+ Metadata metadataBody = metadata.body();
+ metadataLength = metadata.getElementLength();
+ name = metadataBody.name();
+ }
+
+ @Override
+ public long metadataOffset()
+ {
+ return metadataOffset;
+ }
+
+ public void setMetadataOffset(long metadataOffset)
+ {
+ this.metadataOffset = metadataOffset;
+ }
+
+ @Override
+ public long metadataLength()
+ {
+ return metadataLength;
+ }
+
+ public void setMetadataLength(long metadataLength)
+ {
+ this.metadataLength = metadataLength;
+ }
+
+ @Override
+ public String name()
+ {
+ return name;
+ }
+
+ public void setName(String name)
+ {
+ this.name = name;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof MetadataIndex other && MetadataIndex.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableRecord.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableRecord.java
new file mode 100644
index 000000000..3902935c0
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableRecord.java
@@ -0,0 +1,110 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+
+import java.util.Objects;
+
+public class MutableRecord implements Record
+{
+ private Opcode op;
+ private Object body;
+
+ public MutableRecord()
+ {
+ }
+
+ public MutableRecord(Object body)
+ {
+ this.op = body == null ? null : Opcode.byBodyType(body.getClass());
+ this.body = body;
+ }
+
+ public void setOp(Opcode op)
+ {
+ this.op = op;
+ }
+
+ public void setBody(Object body)
+ {
+ this.body = body;
+ }
+
+ @Override
+ public Opcode op()
+ {
+ return op;
+ }
+
+ @Override
+ public T body()
+ {
+ return (T) body;
+ }
+
+ @Override
+ public void write(MCAPDataOutput dataOutput, boolean writeBody)
+ {
+ dataOutput.putUnsignedByte(op == null ? 0 : op.id());
+ dataOutput.putLong(bodyLength());
+
+ if (writeBody)
+ {
+ if (body instanceof MCAPElement)
+ ((MCAPElement) body).write(dataOutput);
+ else if (body instanceof byte[])
+ dataOutput.putBytes((byte[]) body);
+ else
+ throw new UnsupportedOperationException("Unsupported body type: " + body.getClass());
+ }
+ }
+
+ @Override
+ public MCAPCRC32Helper updateCRC(MCAPCRC32Helper crc32)
+ {
+ if (crc32 == null)
+ crc32 = new MCAPCRC32Helper();
+ crc32.addUnsignedByte(op == null ? 0 : op.id());
+ crc32.addLong(bodyLength());
+
+ if (body instanceof MCAPElement)
+ ((MCAPElement) body).updateCRC(crc32);
+ else if (body instanceof byte[])
+ crc32.addBytes((byte[]) body);
+ else
+ throw new UnsupportedOperationException("Unsupported body type: " + body.getClass());
+ return crc32;
+ }
+
+ @Override
+ public long getElementLength()
+ {
+ return Record.RECORD_HEADER_LENGTH + bodyLength();
+ }
+
+ @Override
+ public long bodyLength()
+ {
+ Objects.requireNonNull(body);
+ long bodyLength;
+ if (body instanceof MCAPElement)
+ bodyLength = ((MCAPElement) body).getElementLength();
+ else if (body instanceof byte[])
+ bodyLength = ((byte[]) body).length;
+ else
+ throw new UnsupportedOperationException("Unsupported body type: " + body.getClass());
+ return bodyLength;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof Record other && Record.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableStatistics.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableStatistics.java
new file mode 100644
index 000000000..7c9168b82
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/MutableStatistics.java
@@ -0,0 +1,233 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public class MutableStatistics implements Statistics
+{
+ private long messageCount;
+ private int schemaCount;
+ private long channelCount;
+ private long attachmentCount;
+ private long metadataCount;
+ private long chunkCount;
+ private long messageStartTime;
+ private long messageEndTime;
+ private List channelMessageCounts;
+ private long channelMessageCountsLength;
+
+ @Override
+ public long getElementLength()
+ {
+ return 3 * Long.BYTES + 5 * Integer.BYTES + Short.BYTES + channelMessageCountsLength;
+ }
+
+ public void incrementCount(Opcode op)
+ {
+ switch (op)
+ {
+ case MESSAGE:
+ incrementMessageCount();
+ break;
+ case SCHEMA:
+ incrementSchemaCount();
+ break;
+ case CHANNEL:
+ incrementChannelCount();
+ break;
+ case ATTACHMENT:
+ incrementAttachmentCount();
+ break;
+ case METADATA:
+ incrementMetadataCount();
+ break;
+ case CHUNK:
+ incrementChunkCount();
+ break;
+ default:
+ // Do nothing
+ }
+ }
+
+ public void incrementMessageCount()
+ {
+ messageCount++;
+ }
+
+ public void setMessageCount(long messageCount)
+ {
+ this.messageCount = messageCount;
+ }
+
+ public void incrementSchemaCount()
+ {
+ schemaCount++;
+ }
+
+ public void setSchemaCount(int schemaCount)
+ {
+ this.schemaCount = schemaCount;
+ }
+
+ public void incrementChannelCount()
+ {
+ channelCount++;
+ }
+
+ public void setChannelCount(long channelCount)
+ {
+ this.channelCount = channelCount;
+ }
+
+ public void incrementAttachmentCount()
+ {
+ attachmentCount++;
+ }
+
+ public void setAttachmentCount(long attachmentCount)
+ {
+ this.attachmentCount = attachmentCount;
+ }
+
+ public void incrementMetadataCount()
+ {
+ metadataCount++;
+ }
+
+ public void setMetadataCount(long metadataCount)
+ {
+ this.metadataCount = metadataCount;
+ }
+
+ public void incrementChunkCount()
+ {
+ chunkCount++;
+ }
+
+ public void setChunkCount(long chunkCount)
+ {
+ this.chunkCount = chunkCount;
+ }
+
+ public void updateMessageTimes(long messageStartTime, long messageEndTime)
+ {
+ updateMessageStartTime(messageStartTime);
+ updateMessageEndTime(messageEndTime);
+ }
+
+ public void updateMessageStartTime(long messageStartTime)
+ {
+ if (this.messageStartTime == 0)
+ this.messageStartTime = messageStartTime;
+ else
+ this.messageStartTime = Math.min(this.messageStartTime, messageStartTime);
+ }
+
+ public void setMessageStartTime(long messageStartTime)
+ {
+ this.messageStartTime = messageStartTime;
+ }
+
+ public void updateMessageEndTime(long messageEndTime)
+ {
+ if (this.messageEndTime == 0)
+ this.messageEndTime = messageEndTime;
+ else
+ this.messageEndTime = Math.max(this.messageEndTime, messageEndTime);
+ }
+
+ public void setMessageEndTime(long messageEndTime)
+ {
+ this.messageEndTime = messageEndTime;
+ }
+
+ public void incrementChannelMessageCount(int channelIndex)
+ {
+ if (channelMessageCounts == null)
+ channelMessageCounts = new ArrayList<>();
+ Optional countOptional = channelMessageCounts.stream().filter(count -> count.channelId() == channelIndex).findFirst();
+
+ if (countOptional.isPresent())
+ {
+ countOptional.get().incrementMessageCount();
+ }
+ else
+ {
+ channelMessageCounts.add(new ChannelMessageCount(channelIndex, 1));
+ channelMessageCountsLength += ChannelMessageCount.ELEMENT_LENGTH;
+ }
+ }
+
+ public void setChannelMessageCounts(List channelMessageCounts)
+ {
+ this.channelMessageCounts = channelMessageCounts;
+ channelMessageCountsLength = channelMessageCounts.size() * ChannelMessageCount.ELEMENT_LENGTH;
+ }
+
+ @Override
+ public long messageCount()
+ {
+ return messageCount;
+ }
+
+ @Override
+ public int schemaCount()
+ {
+ return schemaCount;
+ }
+
+ @Override
+ public long channelCount()
+ {
+ return channelCount;
+ }
+
+ @Override
+ public long attachmentCount()
+ {
+ return attachmentCount;
+ }
+
+ @Override
+ public long metadataCount()
+ {
+ return metadataCount;
+ }
+
+ @Override
+ public long chunkCount()
+ {
+ return chunkCount;
+ }
+
+ @Override
+ public long messageStartTime()
+ {
+ return messageStartTime;
+ }
+
+ @Override
+ public long messageEndTime()
+ {
+ return messageEndTime;
+ }
+
+ @Override
+ public List channelMessageCounts()
+ {
+ return channelMessageCounts;
+ }
+
+ @Override
+ public String toString()
+ {
+ return toString(0);
+ }
+
+ @Override
+ public boolean equals(Object object)
+ {
+ return object instanceof Statistics other && Statistics.super.equals(other);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Opcode.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Opcode.java
new file mode 100644
index 000000000..7d5a9a052
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Opcode.java
@@ -0,0 +1,176 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import gnu.trove.map.hash.TIntObjectHashMap;
+
+public enum Opcode
+{
+ /**
+ * Header is the first record in an MCAP file.
+ *
+ * @see Header
+ */
+ HEADER(1),
+ /**
+ * Footer records contain end-of-file information. MCAP files must end with a Footer record.
+ *
+ * @see Footer
+ */
+ FOOTER(2),
+
+ /**
+ * A Schema record defines an individual schema.
+ * Schema records are uniquely identified within a file by their schema ID.
+ * A Schema record must occur at least once in the file prior to any Channel referring to its ID.
+ * Any two schema records sharing a common ID must be identical.
+ *
+ * @see Schema
+ */
+ SCHEMA(3),
+ /**
+ * Channel records define encoded streams of messages on topics.
+ * Channel records are uniquely identified within a file by their channel ID.
+ * A Channel record must occur at least once in the file prior to any message referring to its channel ID.
+ * Any two channel records sharing a common ID must be identical.
+ *
+ * @see Channel
+ */
+ CHANNEL(4),
+ /**
+ * Message records encode a single timestamped message on a channel.
+ * The message encoding and schema must match that of the Channel record corresponding to the message's channel ID.
+ *
+ * @see Message
+ */
+ MESSAGE(5),
+ /**
+ * Chunk records each contain a batch of Schema, Channel, and Message records.
+ * The batch of records contained in a chunk may be compressed or uncompressed.
+ * All messages in the chunk must reference channels recorded earlier in the file (in a previous chunk or earlier in the current chunk).
+ *
+ * @see Chunk
+ */
+ CHUNK(6),
+ /**
+ * MessageIndex records allow readers to locate individual records within a chunk by timestamp.
+ * A sequence of Message Index records occurs immediately after each chunk.
+ * Exactly one Message Index record must exist in the sequence for every channel on which a message occurs inside the chunk.
+ *
+ * @see MessageIndex
+ */
+ MESSAGE_INDEX(7),
+ /**
+ * ChunkIndex records contain the location of a Chunk record and its associated MessageIndex records.
+ * A ChunkIndex record exists for every Chunk in the file.
+ *
+ * @see ChunkIndex
+ */
+ CHUNK_INDEX(8),
+ /**
+ * Attachment records contain auxiliary artifacts such as text, core dumps, calibration data, or other arbitrary data.
+ * Attachment records must not appear within a chunk.
+ *
+ * @see Attachment
+ */
+ ATTACHMENT(9),
+ /**
+ * An Attachment Index record contains the location of an attachment in the file.
+ * An Attachment Index record exists for every Attachment record in the file.
+ *
+ * @see AttachmentIndex
+ */
+ ATTACHMENT_INDEX(10),
+ /**
+ * A Statistics record contains summary information about the recorded data.
+ * The statistics record is optional, but the file should contain at most one.
+ *
+ * @see Statistics
+ */
+ STATISTICS(11),
+ /**
+ * A metadata record contains arbitrary user data in key-value pairs.
+ *
+ * @see Metadata
+ */
+ METADATA(12),
+ /**
+ * A metadata index record contains the location of a metadata record within the file.
+ *
+ * @see MetadataIndex
+ */
+ METADATA_INDEX(13),
+ /**
+ * A Summary Offset record contains the location of records within the summary section.
+ * Each Summary Offset record corresponds to a group of summary records with the same opcode.
+ *
+ * @see SummaryOffset
+ */
+ SUMMARY_OFFSET(14),
+ /**
+ * A Data End record indicates the end of the data section.
+ *
+ * @see DataEnd
+ */
+ DATA_END(15);
+
+ private final int id;
+
+ Opcode(int id)
+ {
+ this.id = id;
+ }
+
+ public int id()
+ {
+ return id;
+ }
+
+ private static final TIntObjectHashMap byId = new TIntObjectHashMap<>(15);
+
+ static
+ {
+ for (Opcode e : Opcode.values())
+ byId.put(e.id(), e);
+ }
+
+ public static Opcode byId(int id)
+ {
+ return byId.get(id);
+ }
+
+ public static Opcode byBodyType(Class> bodyType)
+ {
+ if (bodyType == null)
+ return null;
+ if (Header.class.isAssignableFrom(bodyType))
+ return HEADER;
+ if (Footer.class.isAssignableFrom(bodyType))
+ return FOOTER;
+ if (Schema.class.isAssignableFrom(bodyType))
+ return SCHEMA;
+ if (Channel.class.isAssignableFrom(bodyType))
+ return CHANNEL;
+ if (Message.class.isAssignableFrom(bodyType))
+ return MESSAGE;
+ if (Chunk.class.isAssignableFrom(bodyType))
+ return CHUNK;
+ if (MessageIndex.class.isAssignableFrom(bodyType))
+ return MESSAGE_INDEX;
+ if (ChunkIndex.class.isAssignableFrom(bodyType))
+ return CHUNK_INDEX;
+ if (Attachment.class.isAssignableFrom(bodyType))
+ return ATTACHMENT;
+ if (AttachmentIndex.class.isAssignableFrom(bodyType))
+ return ATTACHMENT_INDEX;
+ if (Statistics.class.isAssignableFrom(bodyType))
+ return STATISTICS;
+ if (Metadata.class.isAssignableFrom(bodyType))
+ return METADATA;
+ if (MetadataIndex.class.isAssignableFrom(bodyType))
+ return METADATA_INDEX;
+ if (SummaryOffset.class.isAssignableFrom(bodyType))
+ return SUMMARY_OFFSET;
+ if (DataEnd.class.isAssignableFrom(bodyType))
+ return DATA_END;
+ throw new IllegalArgumentException("Unsupported body type: " + bodyType);
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Record.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Record.java
new file mode 100644
index 000000000..9eacb4ede
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/Record.java
@@ -0,0 +1,106 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * MCAP files may contain a variety of records.
+ * Records are identified by a single-byte opcode.
+ * Record opcodes in the range 0x01-0x7F are reserved for future MCAP format usage.
+ * 0x80-0xFF are reserved for application extensions and user proposals.
+ * 0x00 is not a valid opcode.
+ *
+ * @see MCAP Records
+ */
+public interface Record extends MCAPElement
+{
+ int RECORD_HEADER_LENGTH = 9;
+
+ static Record load(MCAPDataInput dataInput)
+ {
+ return load(dataInput, dataInput.position());
+ }
+
+ static Record load(MCAPDataInput dataInput, long elementPosition)
+ {
+ return new RecordDataInputBacked(dataInput, elementPosition);
+ }
+
+ Opcode op();
+
+ long bodyLength();
+
+ T body();
+
+ @Override
+ default String toString(int indent)
+ {
+ String out = getClass().getSimpleName() + ":";
+ out += "\n\t-op = " + op();
+ Object body = body();
+ out += "\n\t-body = " + (body == null ? "null" : "\n" + ((MCAPElement) body).toString(indent + 2));
+ return MCAPElement.indent(out, indent);
+ }
+
+ @Override
+ default void write(MCAPDataOutput dataOutput)
+ {
+ write(dataOutput, true);
+ }
+
+ void write(MCAPDataOutput dataOutput, boolean writeBody);
+
+ default Record generateMetadataIndexRecord(long metadataOffset)
+ {
+ if (op() != Opcode.METADATA)
+ throw new UnsupportedOperationException("Cannot generate a metadata index record from a non-metadata record");
+
+ MutableMetadataIndex metadataIndex = new MutableMetadataIndex();
+ metadataIndex.setMetadataOffset(metadataOffset);
+ metadataIndex.setMetadata(this);
+ return new MutableRecord(metadataIndex);
+ }
+
+ default Record generateAttachmentIndexRecord(long attachmentOffset)
+ {
+ if (op() != Opcode.ATTACHMENT)
+ throw new UnsupportedOperationException("Cannot generate an attachment index record from a non-attachment record");
+
+ MutableAttachmentIndex attachmentIndex = new MutableAttachmentIndex();
+ attachmentIndex.setAttachmentOffset(attachmentOffset);
+ attachmentIndex.setAttachment(this);
+ return new MutableRecord(attachmentIndex);
+ }
+
+ default Record generateChunkIndexRecord(long chunkOffset, List extends Record> messageIndexRecordList)
+ {
+ if (op() != Opcode.CHUNK)
+ throw new UnsupportedOperationException("Cannot generate a chunk index record from a non-chunk record");
+
+ MutableChunkIndex chunkIndex = new MutableChunkIndex();
+ chunkIndex.setChunkOffset(chunkOffset);
+ chunkIndex.setChunk(this);
+ chunkIndex.setMessageIndexOffsets(Records.generateMessageIndexOffsets(chunkOffset + getElementLength(), messageIndexRecordList));
+ chunkIndex.setMessageIndexLength(messageIndexRecordList.stream().mapToLong(Record::getElementLength).sum());
+ return new MutableRecord(chunkIndex);
+ }
+
+ @Override
+ default boolean equals(MCAPElement mcapElement)
+ {
+ if (mcapElement == this)
+ return true;
+
+ if (mcapElement instanceof Record other)
+ {
+ if (op() != other.op())
+ return false;
+ return Objects.equals(body(), other.body());
+ }
+
+ return false;
+ }
+}
diff --git a/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/RecordDataInputBacked.java b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/RecordDataInputBacked.java
new file mode 100644
index 000000000..3b2ca020f
--- /dev/null
+++ b/scs2-session-logger/src/main/java/us/ihmc/scs2/session/mcap/specs/records/RecordDataInputBacked.java
@@ -0,0 +1,131 @@
+package us.ihmc.scs2.session.mcap.specs.records;
+
+import us.ihmc.scs2.session.mcap.encoding.MCAPCRC32Helper;
+import us.ihmc.scs2.session.mcap.input.MCAPDataInput;
+import us.ihmc.scs2.session.mcap.output.MCAPDataOutput;
+import us.ihmc.scs2.session.mcap.specs.MCAP;
+
+import java.lang.ref.WeakReference;
+
+public class RecordDataInputBacked implements Record
+{
+
+ private final MCAPDataInput dataInput;
+
+ private final Opcode op;
+ private final long bodyLength;
+ private final long bodyOffset;
+ private WeakReference