diff --git a/SabreTools.IO.Test/ReadOnlyBitStreamTests.cs b/SabreTools.IO.Test/ReadOnlyBitStreamTests.cs
new file mode 100644
index 0000000..5301c7e
--- /dev/null
+++ b/SabreTools.IO.Test/ReadOnlyBitStreamTests.cs
@@ -0,0 +1,54 @@
+using System.IO;
+using SabreTools.IO.Streams;
+using Xunit;
+
+namespace SabreTools.IO.Test
+{
+ public class ReadOnlyBitStreamTests
+ {
+ [Fact]
+ public void DefaultConstructorTest()
+ {
+ var stream = new ReadOnlyBitStream(new MemoryStream());
+ Assert.Equal(0, stream.Length);
+ Assert.Equal(0, stream.Position);
+
+ stream = new ReadOnlyBitStream(new MemoryStream(new byte[16]));
+ Assert.Equal(16, stream.Length);
+ Assert.Equal(0, stream.Position);
+ }
+
+ [Fact]
+ public void ReadSingleBitTest()
+ {
+ byte[] data = [0b01010101];
+ var stream = new ReadOnlyBitStream(new MemoryStream(data));
+ byte? bit = stream.ReadBit();
+ Assert.NotNull(bit);
+ Assert.Equal((byte)0b00000001, bit);
+ Assert.Equal(1, stream.Position);
+ }
+
+ [Fact]
+ public void ReadBitsLSBTest()
+ {
+ byte[] data = [0b01010101, 0b01010101, 0b01010101, 0b01010101];
+ var stream = new ReadOnlyBitStream(new MemoryStream(data));
+ uint? bits = stream.ReadBitsLSB(4);
+ Assert.NotNull(bits);
+ Assert.Equal((byte)0b00000101, bits);
+ Assert.Equal(1, stream.Position);
+ }
+
+ [Fact]
+ public void ReadBitsMSBTest()
+ {
+ byte[] data = [0b01010101, 0b01010101, 0b01010101, 0b01010101];
+ var stream = new ReadOnlyBitStream(new MemoryStream(data));
+ uint? bits = stream.ReadBitsMSB(4);
+ Assert.NotNull(bits);
+ Assert.Equal((byte)0b00001010, bits);
+ Assert.Equal(1, stream.Position);
+ }
+ }
+}
\ No newline at end of file
diff --git a/SabreTools.IO/Streams/ReadOnlyBitStream.cs b/SabreTools.IO/Streams/ReadOnlyBitStream.cs
new file mode 100644
index 0000000..54e084b
--- /dev/null
+++ b/SabreTools.IO/Streams/ReadOnlyBitStream.cs
@@ -0,0 +1,238 @@
+using System;
+using System.IO;
+
+namespace SabreTools.IO.Streams
+{
+ ///
+ /// Wrapper to allow reading bits from a source stream
+ ///
+ public class ReadOnlyBitStream
+ {
+ ///
+ public long Position => _source.Position;
+
+ ///
+ public long Length => _source.Length;
+
+ ///
+ /// Original stream source
+ ///
+ private readonly Stream _source;
+
+ ///
+ /// Last read byte value from the stream
+ ///
+ private byte? _bitBuffer;
+
+ ///
+ /// Index in the byte of the current bit
+ ///
+ private int _bitIndex;
+
+ ///
+ /// Create a new BitStream from a source Stream
+ ///
+ public ReadOnlyBitStream(Stream source)
+ {
+ _source = source;
+ _bitBuffer = null;
+ _bitIndex = 0;
+
+ // Verify the stream
+ if (!source.CanRead || !source.CanSeek)
+ throw new ArgumentException($"{nameof(source)} needs to be readable and seekable");
+ }
+
+ ///
+ /// Discard the current cached byte
+ ///
+ public void Discard()
+ {
+ _bitBuffer = null;
+ _bitIndex = 0;
+ }
+
+ ///
+ /// Read a single bit, if possible
+ ///
+ /// The next bit encoded in a byte, null on error or end of stream
+ public byte? ReadBit()
+ {
+ // If we reached the end of the stream
+ if (_source.Position >= _source.Length)
+ return null;
+
+ // If we don't have a value cached
+ if (_bitBuffer == null)
+ {
+ // Read the next byte, if possible
+ _bitBuffer = ReadSourceByte();
+ if (_bitBuffer == null)
+ return null;
+
+ // Reset the bit index
+ _bitIndex = 0;
+ }
+
+ // Get the value by bit-shifting
+ int value = _bitBuffer.Value & 0x01;
+ _bitBuffer = (byte?)(_bitBuffer >> 1);
+ _bitIndex++;
+
+ // Reset the byte if we're at the end
+ if (_bitIndex >= 8)
+ Discard();
+
+ return (byte)value;
+ }
+
+ ///
+ /// Read a multiple bits in LSB, if possible
+ ///
+ /// The next bits encoded in a UInt32, null on error or end of stream
+ public uint? ReadBitsLSB(int bits)
+ {
+ uint value = 0;
+ for (int i = 0; i < bits; i++)
+ {
+ // Read the next bit
+ byte? bitValue = ReadBit();
+ if (bitValue == null)
+ return null;
+
+ // Add the bit shifted by the current index
+ value += (uint)(bitValue.Value << i);
+ }
+
+ return value;
+ }
+
+ ///
+ /// Read a multiple bits in MSB, if possible
+ ///
+ /// The next bits encoded in a UInt32, null on error or end of stream
+ public uint? ReadBitsMSB(int bits)
+ {
+ uint value = 0;
+ for (int i = 0; i < bits; i++)
+ {
+ // Read the next bit
+ byte? bitValue = ReadBit();
+ if (bitValue == null)
+ return null;
+
+ // Add the bit shifted by the current index
+ value = (value << 1) + bitValue.Value;
+ }
+
+ return value;
+ }
+
+ ///
+ /// Read a byte, if possible
+ ///
+ /// The next byte, null on error or end of stream
+ /// Assumes the stream is byte-aligned
+ public byte? ReadByte()
+ {
+ try
+ {
+ Discard();
+ return _source.ReadByteValue();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Read a UInt16, if possible
+ ///
+ /// The next UInt16, null on error or end of stream
+ /// Assumes the stream is byte-aligned
+ public ushort? ReadUInt16()
+ {
+ try
+ {
+ Discard();
+ return _source.ReadUInt16();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Read a UInt32, if possible
+ ///
+ /// The next UInt32, null on error or end of stream
+ /// Assumes the stream is byte-aligned
+ public uint? ReadUInt32()
+ {
+ try
+ {
+ Discard();
+ return _source.ReadUInt32();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Read a UInt64, if possible
+ ///
+ /// The next UInt64, null on error or end of stream
+ /// Assumes the stream is byte-aligned
+ public ulong? ReadUInt64()
+ {
+ try
+ {
+ Discard();
+ return _source.ReadUInt64();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Read bytes, if possible
+ ///
+ /// Number of bytes to read
+ /// The next bytes, null on error or end of stream
+ /// Assumes the stream is byte-aligned
+ public byte[]? ReadBytes(int bytes)
+ {
+ try
+ {
+ Discard();
+ return _source.ReadBytes(bytes);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Read a single byte from the underlying stream, if possible
+ ///
+ /// The next full byte from the stream, null on error or end of stream
+ private byte? ReadSourceByte()
+ {
+ try
+ {
+ return _source.ReadByteValue();
+ }
+ catch
+ {
+ return null;
+ }
+ }
+ }
+}
\ No newline at end of file