diff --git a/src/LibHac/FsSystem/UnionStorage.cs b/src/LibHac/FsSystem/UnionStorage.cs
new file mode 100644
index 00000000..3e424633
--- /dev/null
+++ b/src/LibHac/FsSystem/UnionStorage.cs
@@ -0,0 +1,352 @@
+using System;
+using LibHac.Common;
+using LibHac.Common.FixedArrays;
+using LibHac.Diag;
+using LibHac.Fs;
+using LibHac.Os;
+using LibHac.Util;
+
+namespace LibHac.FsSystem;
+
+///
+/// An that allows modifying a base storage by writing any modifications to a separate
+/// "log" storage.
+///
+/// The storage is split into equally-sized blocks. When a block is modified for the first time,
+/// a new entry containing the block's data will be added to the log. Any subsequent read/write operations
+/// on that block will use the log storage entry instead of the base storage.
+/// Log format:
+///
+/// s64 BlockSize;
+/// Entry Entries[];
+///
+/// Entry {
+/// s64 BaseStorageOffset;
+/// u8 Data[BlockSize];
+/// };
+///
+/// Based on FS 13.1.0 (nnSdk 13.4.0)
+public class UnionStorage : IStorage
+{
+ private const long Sentinel = -1;
+ private const int LogHeaderSize = 8;
+ private const int LogEntryHeaderSize = 8;
+
+ private ValueSubStorage _baseStorage;
+ private ValueSubStorage _logStorage;
+ private long _blockSize;
+ private byte[] _buffer;
+ private int _blockCount;
+ private SdkMutexType _mutex;
+
+ public UnionStorage()
+ {
+ _mutex = new SdkMutexType();
+ }
+
+ public override void Dispose()
+ {
+ _baseStorage.Dispose();
+ _logStorage.Dispose();
+ _buffer = null;
+
+ base.Dispose();
+ }
+
+ private static long GetLogSize(long blockSize)
+ {
+ return blockSize + LogEntryHeaderSize;
+ }
+
+ private static long GetDataOffset(long logOffset)
+ {
+ return logOffset + LogEntryHeaderSize;
+ }
+
+ private static long GetLogTailOffset(long blockSize, int blockCount)
+ {
+ return GetLogSize(blockSize) * blockCount + LogHeaderSize;
+ }
+
+ public static Result Format(in ValueSubStorage storage, long blockSize)
+ {
+ Assert.SdkRequiresGreater(blockSize, 1);
+ Assert.SdkRequires(BitUtil.IsPowerOfTwo(blockSize));
+
+ var header = new Array2();
+ header[0] = blockSize;
+ header[1] = Sentinel;
+
+ return storage.Write(0, SpanHelpers.AsReadOnlyByteSpan(in header));
+ }
+
+ public Result Initialize(in ValueSubStorage baseStorage, in ValueSubStorage logStorage, long blockSize)
+ {
+ Assert.SdkRequiresNull(_buffer);
+
+ Result rc = logStorage.Read(0, SpanHelpers.AsByteSpan(ref _blockSize));
+ if (rc.IsFailure()) return rc.Miss();
+
+ if (blockSize <= 1 || !BitUtil.IsPowerOfTwo(blockSize) || blockSize != _blockSize)
+ return ResultFs.InvalidLogBlockSize.Log();
+
+ // Read through the log to see if we already have any existing entries
+ for (long offset = LogHeaderSize; ; offset += GetLogSize(_blockSize))
+ {
+ long offsetOriginal = 0;
+ rc = logStorage.Read(offset, SpanHelpers.AsByteSpan(ref offsetOriginal));
+ if (rc.IsFailure()) return rc.Miss();
+
+ if (offsetOriginal == Sentinel)
+ break;
+
+ if (offsetOriginal % _blockCount != 0)
+ return ResultFs.InvalidLogOffset.Log();
+
+ _blockCount++;
+ }
+
+ _baseStorage.Set(in baseStorage);
+ _logStorage.Set(in logStorage);
+ _buffer = new byte[_blockSize];
+
+ return Result.Success;
+ }
+
+ public Result Freeze()
+ {
+ Assert.SdkRequiresNotNull(_buffer);
+
+ using ScopedLock scopedLock = ScopedLock.Lock(ref _mutex);
+
+ long tailOffset = GetLogTailOffset(_blockSize, _blockCount);
+ long value = Sentinel;
+ Result rc = _logStorage.Write(tailOffset, SpanHelpers.AsReadOnlyByteSpan(in value));
+ if (rc.IsFailure()) return rc.Miss();
+
+ rc = _logStorage.Flush();
+ if (rc.IsFailure()) return rc.Miss();
+
+ return Result.Success;
+ }
+
+ public Result Commit()
+ {
+ Assert.SdkRequiresNotNull(_buffer);
+
+ using ScopedLock scopedLock = ScopedLock.Lock(ref _mutex);
+
+ long tailOffset = GetLogTailOffset(_blockSize, _blockCount);
+ long logSize = GetLogSize(_blockSize);
+
+ // Read each block of data from the log storage and write it to the base storage
+ for (long offset = LogHeaderSize; offset < tailOffset; offset += logSize)
+ {
+ long offsetOriginal = 0;
+ Result rc = _logStorage.Read(offset, SpanHelpers.AsByteSpan(ref offsetOriginal));
+ if (rc.IsFailure()) return rc.Miss();
+
+ if (offsetOriginal == Sentinel)
+ return ResultFs.UnexpectedEndOfLog.Log();
+
+ rc = _logStorage.Read(GetDataOffset(offset), _buffer);
+ if (rc.IsFailure()) return rc.Miss();
+
+ rc = _baseStorage.Write(offsetOriginal, _buffer);
+ if (rc.IsFailure()) return rc.Miss();
+ }
+
+ return _baseStorage.Flush();
+ }
+
+ public override Result Read(long offset, Span destination)
+ {
+ Assert.SdkRequiresNotNull(_buffer);
+
+ if (destination.IsEmpty)
+ return Result.Success;
+
+ // Get the start offset of the block containing the requested offset
+ long offsetBuffer = 0;
+ long offsetOriginal = Alignment.AlignDownPow2(offset, _blockSize);
+ long sizeSkipBlock = offset - offsetOriginal;
+
+ while (offsetBuffer < destination.Length)
+ {
+ // Determine how much of the block we should read
+ long sizeReadBlock = _blockSize - sizeSkipBlock;
+ long sizeRemaining = destination.Length - offsetBuffer;
+ long sizeToRead = Math.Min(sizeReadBlock, sizeRemaining);
+ Span currentDestination = destination.Slice((int)offsetBuffer, (int)sizeToRead);
+
+ // Check if the log contains the block we need
+ Result rc = FindLog(out bool found, out long offsetLog, offsetOriginal);
+ if (rc.IsFailure()) return rc.Miss();
+
+ // If it does, read from the log; otherwise read from the base storage
+ if (found)
+ {
+ rc = _logStorage.Read(GetDataOffset(offsetLog) + sizeSkipBlock, currentDestination);
+ if (rc.IsFailure()) return rc.Miss();
+ }
+ else
+ {
+ rc = _baseStorage.Read(offsetOriginal + sizeSkipBlock, currentDestination);
+ if (rc.IsFailure()) return rc.Miss();
+ }
+
+ offsetBuffer += sizeToRead;
+ offsetOriginal += _blockSize;
+ sizeSkipBlock = 0;
+ }
+
+ return Result.Success;
+ }
+
+ public override Result Write(long offset, ReadOnlySpan source)
+ {
+ Assert.SdkRequiresNotNull(_buffer);
+
+ if (source.IsEmpty)
+ return Result.Success;
+
+ using ScopedLock scopedLock = ScopedLock.Lock(ref _mutex);
+
+ // Get the start offset of the block containing the requested offset
+ long offsetBuffer = 0;
+ long offsetOriginal = Alignment.AlignDownPow2(offset, _blockSize);
+ long sizeSkipBlock = offset - offsetOriginal;
+
+ while (offsetBuffer < source.Length)
+ {
+ Assert.SdkNotEqual(Sentinel, offsetOriginal);
+
+ long sizeWriteBlock = _blockSize - sizeSkipBlock;
+ long sizeRemaining = source.Length - offsetBuffer;
+ long sizeToWrite = Math.Min(sizeWriteBlock, sizeRemaining);
+ ReadOnlySpan currentSource = source.Slice((int)offsetBuffer, (int)sizeToWrite);
+
+ // Check if the log contains the block we need.
+ Result rc = FindLog(out bool found, out long offsetLog, offsetOriginal);
+ if (rc.IsFailure()) return rc.Miss();
+
+ if (found)
+ {
+ // If it does, write directly to the log.
+ _logStorage.Write(GetDataOffset(offsetLog) + sizeSkipBlock, currentSource);
+ if (rc.IsFailure()) return rc.Miss();
+ }
+ else
+ {
+ // Otherwise we need to add a new entry to the log.
+ _logStorage.Write(offsetLog, SpanHelpers.AsReadOnlyByteSpan(in offsetOriginal));
+ if (rc.IsFailure()) return rc.Miss();
+
+ if (sizeToWrite == _blockSize)
+ {
+ // If we're writing a complete block we can write the entire block directly to the log.
+ _logStorage.Write(GetDataOffset(offsetLog) + sizeSkipBlock, currentSource);
+ if (rc.IsFailure()) return rc.Miss();
+ }
+ else
+ {
+ // If we're writing a partial block we need to read the existing data block from the base storage
+ // into a buffer first.
+ _baseStorage.Read(offsetOriginal, _buffer);
+ if (rc.IsFailure()) return rc.Miss();
+
+ // Fill in the appropriate parts of the buffer with our new data.
+ currentSource.CopyTo(_buffer.AsSpan((int)sizeSkipBlock, (int)sizeToWrite));
+
+ // Write the entire modified block to the new log entry.
+ _logStorage.Write(GetDataOffset(offsetLog), _buffer);
+ if (rc.IsFailure()) return rc.Miss();
+ }
+
+ _blockCount++;
+ }
+
+ offsetBuffer += sizeToWrite;
+ offsetOriginal += _blockSize;
+ sizeSkipBlock = 0;
+ }
+
+ return Result.Success;
+ }
+
+ public override Result Flush()
+ {
+ Assert.SdkRequiresNotNull(_buffer);
+
+ Result rc = _baseStorage.Flush();
+ if (rc.IsFailure()) return rc.Miss();
+
+ rc = _logStorage.Flush();
+ if (rc.IsFailure()) return rc.Miss();
+
+ return Result.Success;
+ }
+
+ public override Result GetSize(out long size)
+ {
+ Assert.SdkRequiresNotNull(_buffer);
+
+ return _baseStorage.GetSize(out size);
+ }
+
+ public override Result SetSize(long size)
+ {
+ return ResultFs.UnsupportedSetSizeForUnionStorage.Log();
+ }
+
+ public override Result OperateRange(Span outBuffer, OperationId operationId, long offset, long size,
+ ReadOnlySpan inBuffer)
+ {
+ for (long currentOffset = Alignment.AlignDownPow2(offset, _blockSize);
+ currentOffset < offset + size;
+ currentOffset += _blockSize)
+ {
+ Result rc = FindLog(out bool found, out long offsetLog, currentOffset);
+ if (rc.IsFailure()) return rc.Miss();
+
+ if (found)
+ {
+ _logStorage.OperateRange(outBuffer, operationId, offsetLog, _blockSize, inBuffer);
+ if (rc.IsFailure()) return rc.Miss();
+ }
+ }
+
+ return _baseStorage.OperateRange(outBuffer, operationId, offset, size, inBuffer);
+ }
+
+ private Result FindLog(out bool logFound, out long outLogOffset, long offsetOriginal)
+ {
+ Assert.SdkRequiresNotNull(_buffer);
+
+ outLogOffset = GetLogTailOffset(_blockSize, _blockCount);
+ logFound = false;
+
+ long logTailOffset = GetLogTailOffset(_blockSize, _blockCount);
+ long logSize = GetLogSize(_blockSize);
+
+ // Go through each log entry to see if any are at the requested offset
+ for (long logOffset = LogHeaderSize; logOffset < logTailOffset; logOffset += logSize)
+ {
+ long offset = 0;
+ Result rc = _logStorage.Read(logOffset, SpanHelpers.AsByteSpan(ref offset));
+ if (rc.IsFailure()) return rc.Miss();
+
+ if (offset == Sentinel)
+ return ResultFs.LogNotFound.Log();
+
+ if (offset == offsetOriginal)
+ {
+ outLogOffset = logOffset;
+ logFound = true;
+ break;
+ }
+ }
+
+ return Result.Success;
+ }
+}
\ No newline at end of file