diff --git a/Compression.Tests/Compression.Tests.csproj b/Compression.Tests/Compression.Tests.csproj
index 023dbbe39..70f1d79a1 100644
--- a/Compression.Tests/Compression.Tests.csproj
+++ b/Compression.Tests/Compression.Tests.csproj
@@ -150,6 +150,10 @@
G722Vectors.%(Filename)%(Extension)
+
+
+ FossilVectors.%(Filename)%(Extension)
+
ZooVectors.%(Filename)%(Extension)
diff --git a/Compression.Tests/Fossil/FossilTests.cs b/Compression.Tests/Fossil/FossilTests.cs
new file mode 100644
index 000000000..556ec4247
--- /dev/null
+++ b/Compression.Tests/Fossil/FossilTests.cs
@@ -0,0 +1,110 @@
+using System.Buffers.Binary;
+using System.IO.Compression;
+using System.Security.Cryptography;
+using System.Text;
+using FileSystem.Fossil;
+
+namespace Compression.Tests.Fossil;
+
+///
+/// Fossil: the reader against a volume plan9port's flfmt formatted and its
+/// fossil server filled over 9P (see Fossil/ReferenceVectors/README.md),
+/// compared with what that server read back; plus refusals.
+///
+[TestFixture]
+public class FossilTests {
+
+ private static byte[] Image() {
+ using var res = typeof(FossilTests).Assembly.GetManifestResourceStream("FossilVectors.fossil.img.gz")
+ ?? throw new InvalidOperationException("fossil.img.gz not embedded");
+ using var gz = new GZipStream(res, CompressionMode.Decompress);
+ using var ms = new MemoryStream();
+ gz.CopyTo(ms);
+ return ms.ToArray();
+ }
+
+ private static Dictionary Manifest() {
+ using var res = typeof(FossilTests).Assembly.GetManifestResourceStream("FossilVectors.fossil.manifest")
+ ?? throw new InvalidOperationException("fossil.manifest not embedded");
+ using var sr = new StreamReader(res);
+ return sr.ReadToEnd().Split('\n', StringSplitOptions.RemoveEmptyEntries)
+ .Select(l => l.TrimEnd('\r').Split(" ", 2)).ToDictionary(p => p[1], p => p[0]);
+ }
+
+ private static string Sha(byte[] d) => Convert.ToHexStringLower(SHA256.HashData(d));
+
+ [Test]
+ public void GivenServerWrittenVolume_WhenRead_ThenActiveTreeMatchesWhatTheServerReadBack() {
+ var r = new FossilReader(new MemoryStream(Image(), writable: false));
+ var manifest = Manifest();
+ var files = r.Entries.Where(e => !e.IsDirectory && e.Path.StartsWith("active/", StringComparison.Ordinal)).ToDictionary(e => e.Path);
+ Assert.That(files.Keys, Is.EquivalentTo(manifest.Keys));
+ foreach (var (path, sha) in manifest) Assert.That(Sha(r.Extract(files[path])), Is.EqualTo(sha), path);
+ }
+
+ [Test]
+ public void GivenServerWrittenVolume_WhenRead_ThenDirectoriesModesAndOwnersAreThoseTheServerCreated() {
+ var r = new FossilReader(new MemoryStream(Image(), writable: false));
+ var byPath = r.Entries.ToDictionary(e => e.Path);
+ Assert.Multiple(() => {
+ Assert.That(byPath["active/docs"].IsDirectory, Is.True);
+ Assert.That(byPath["active/docs"].Mode & 0x1FF, Is.EqualTo(0x1FD), "d775");
+ Assert.That(byPath["active/empty-dir"].IsDirectory, Is.True);
+ Assert.That(byPath["active/docs/notes.txt"].Mode & 0x1FF, Is.EqualTo(0x180), "600");
+ Assert.That(byPath["active/hello.txt"].Uid, Is.EqualTo("sys"));
+ Assert.That(byPath["active/big.bin"].Source.Depth, Is.EqualTo(2), "900 KB at 4 KiB blocks needs a two-level pointer tree");
+ });
+ }
+
+ [Test]
+ public void GivenSnapshotTakenBeforeARewrite_WhenRead_ThenTheSnapshotKeepsTheOldBytes() {
+ var r = new FossilReader(new MemoryStream(Image(), writable: false));
+ var snap = r.Entries.Where(e => !e.IsDirectory && e.Path.StartsWith("snapshot/", StringComparison.Ordinal) && e.Path.EndsWith("/hello.txt", StringComparison.Ordinal)).ToList();
+ Assert.That(snap, Has.Count.EqualTo(1), string.Join(", ", r.Entries.Select(e => e.Path)));
+ Assert.That(Encoding.ASCII.GetString(r.Extract(snap[0])), Is.EqualTo("hello from fossil\n"));
+ var active = r.Entries.Single(e => e.Path == "active/hello.txt");
+ Assert.That(Encoding.ASCII.GetString(r.Extract(active)), Is.EqualTo("hello again, after the snapshot was taken\n"));
+ }
+
+ [Test]
+ public void GivenVolume_WhenDetectedByMagic_ThenFossil()
+ => Assert.That(Compression.Lib.FormatDetector.DetectByMagic(Image().AsSpan(0, 192 * 1024)).ToString(), Is.EqualTo("Fossil"));
+
+ [Test]
+ public void GivenWrongHeaderMagic_WhenOpened_ThenInvalidData() {
+ var img = Image();
+ img[FossilReader.HeaderOffset] ^= 0xFF;
+ Assert.Throws(() => new FossilReader(new MemoryStream(img)));
+ }
+
+ [Test]
+ public void GivenWrongSuperblockMagic_WhenOpened_ThenInvalidData() {
+ var img = Image();
+ var bs = BinaryPrimitives.ReadUInt16BigEndian(img.AsSpan(FossilReader.HeaderOffset + 6));
+ var super = BinaryPrimitives.ReadUInt32BigEndian(img.AsSpan(FossilReader.HeaderOffset + 8));
+ img[(int)(super * bs)] ^= 0xFF;
+ Assert.Throws(() => new FossilReader(new MemoryStream(img)));
+ }
+
+ [Test]
+ public void GivenTruncatedImage_WhenRead_ThenRefusedNotGuessed() {
+ var img = Image().AsSpan(0, 200 * 1024).ToArray();
+ Assert.Catch(() => {
+ var r = new FossilReader(new MemoryStream(img));
+ foreach (var e in r.Entries) r.Extract(e);
+ });
+ }
+
+ [Test]
+ public void GivenDamagedMetaBlock_WhenRead_ThenRefused() {
+ var img = Image();
+ var magic = new byte[] { 0x56, 0x56, 0xFC, 0x7A };
+ // Corrupt every meta block's magic: no directory can be listed any more.
+ for (var i = img.AsSpan().IndexOf(magic); i >= 0; ) {
+ img[i] = 0;
+ var next = img.AsSpan(i + 1).IndexOf(magic);
+ i = next < 0 ? -1 : i + 1 + next;
+ }
+ Assert.Throws(() => new FossilReader(new MemoryStream(img)));
+ }
+}
diff --git a/Compression.Tests/Fossil/ReferenceVectors/README.md b/Compression.Tests/Fossil/ReferenceVectors/README.md
new file mode 100644
index 000000000..35930ae31
--- /dev/null
+++ b/Compression.Tests/Fossil/ReferenceVectors/README.md
@@ -0,0 +1,17 @@
+# Fossil reference volume
+
+`fossil.img.gz` is a 16 MiB Fossil volume (4 KiB blocks) that nothing in this repository
+wrote. It is gzipped only to keep the repository small.
+
+| | |
+| --- | --- |
+| Formatted by | `flfmt -b 4096` from plan9port, commit `1f1396913fb5372408f442ed26933f5ae4b399cd`, built on Ubuntu 24.04 (WSL) |
+| Written by | that plan9port's `fossil` server. Its console created the directories and files; plan9port's `9p` client wrote the data; `fsys main snap` took a local snapshot; `hello.txt` was rewritten after it; `sync`/`halt` closed the volume |
+| Recipe | `generate.sh ` |
+| Created | 2026-10-23 |
+| Manifest | sha256 of every active file as the same server read it back over 9P |
+
+The volume covers nested and empty directories, an empty file, a 900,000-byte file
+(a two-level pointer tree at 4 KiB blocks), modes 775/755/664/644/600, and a snapshot
+whose `hello.txt` keeps the bytes from before the rewrite. `leaf.dat` comes from
+`/dev/urandom`, so regenerating changes the image and the manifest together.
diff --git a/Compression.Tests/Fossil/ReferenceVectors/fossil.img.gz b/Compression.Tests/Fossil/ReferenceVectors/fossil.img.gz
new file mode 100644
index 000000000..d0d89827d
Binary files /dev/null and b/Compression.Tests/Fossil/ReferenceVectors/fossil.img.gz differ
diff --git a/Compression.Tests/Fossil/ReferenceVectors/fossil.manifest b/Compression.Tests/Fossil/ReferenceVectors/fossil.manifest
new file mode 100644
index 000000000..4f98e7d39
--- /dev/null
+++ b/Compression.Tests/Fossil/ReferenceVectors/fossil.manifest
@@ -0,0 +1,5 @@
+46d7ffce3c46edc763a81f0392aad235724efb76cf34c5007291a25534129c12 active/big.bin
+f54cbcdce6015b8729e230193ea636f06eecca9088e86ce7e525a803cd584e7d active/docs/deep/leaf.dat
+3ec6319da06f237aca849b1dad73d310556423b4179661dcc25852a118975468 active/docs/notes.txt
+e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 active/empty
+886eafda3a87ef99b654084301c104b9c098a3495cd21c03a495ba067287b1fe active/hello.txt
diff --git a/Compression.Tests/Fossil/ReferenceVectors/generate.sh b/Compression.Tests/Fossil/ReferenceVectors/generate.sh
new file mode 100644
index 000000000..456aae998
--- /dev/null
+++ b/Compression.Tests/Fossil/ReferenceVectors/generate.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+# Builds fossil.img.gz + fossil.manifest with plan9port (commit 1f13969): flfmt formats a
+# 16 MiB volume with 4 KiB blocks, the fossil server (console on a pty via script(1))
+# creates the tree, files are written over 9P with plan9port's 9p client, a local snapshot
+# is taken, and hello.txt is rewritten after it. The manifest is what that server reads
+# back over 9P: sha256 and path from the Fossil root.
+# Usage: generate.sh (expects plan9port built in ~/plan9port)
+set -u
+out="${1:?usage: generate.sh }"; mkdir -p "$out"
+export PLAN9=$HOME/plan9port PATH=$PATH:$HOME/plan9port/bin:$HOME/plan9port/bin/fossil
+d=$(mktemp -d); img=$d/fossil.img; port=$((20000 + RANDOM % 20000)); A="tcp!127.0.0.1!$port"
+truncate -s 16M $img
+flfmt -y -b 4096 $img > /dev/null
+cat > $d/cons.txt < $d/fossil.log 2>&1 &)
+exec 3> $d/con
+for i in $(seq 1 30); do 9p -a "$A" ls / >/dev/null 2>&1 && break; sleep 0.5; done
+printf 'hello from fossil\n' | 9p -a "$A" write /hello.txt
+yes "fossil pointer tree " | head -c 900000 | 9p -a "$A" write /big.bin # 900,000 bytes: 220 blocks > 204 pointers per 4 KiB block, so depth 2
+printf 'private notes\n' | 9p -a "$A" write /docs/notes.txt
+head -c 5000 /dev/urandom | 9p -a "$A" write /docs/deep/leaf.dat
+trap '' PIPE
+echo "fsys main snap" >&3; sleep 3
+printf 'hello again, after the snapshot was taken\n' | 9p -a "$A" write /hello.txt
+mkdir -p $d/x/active/docs/deep
+for f in hello.txt empty big.bin docs/notes.txt docs/deep/leaf.dat; do 9p -a "$A" read /$f > $d/x/active/$f; done
+{ echo "fsys main sync"; sleep 2; echo "fsys main halt"; } >&3 2>/dev/null; sleep 3
+exec 3>&- 2>/dev/null
+pkill -f "$d/cons.txt" 2>/dev/null; sleep 2
+(cd $d/x && find . -type f | sort | sed 's#^\./##' | while read -r f; do echo "$(sha256sum < "$f" | cut -d' ' -f1) $f"; done) > "$out/fossil.manifest"
+gzip -9 -n -c $img > "$out/fossil.img.gz"
+ls -la "$out"; cat "$out/fossil.manifest"
diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Fossil/FossilFormatDescriptor.cs b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Fossil/FossilFormatDescriptor.cs
new file mode 100644
index 000000000..d930386fc
--- /dev/null
+++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Fossil/FossilFormatDescriptor.cs
@@ -0,0 +1,77 @@
+#pragma warning disable CS1591
+using Compression.Registry;
+using Compression.Registry.Streaming;
+using static Compression.Registry.FormatHelpers;
+
+namespace FileSystem.Fossil;
+
+///
+/// Fossil — the Plan 9 file server's on-disk format (Quinlan, McKie, Cox, 2002), as plan9port's
+/// flfmt and fossil write it. Header magic 0x3776ae89 at 128 KiB.
+///
+///
+///
+/// Read-only. The whole local tree is listed from the Fossil root, so the live file system
+/// is active/… and local snapshots, if any, are snapshot/…. Blocks already
+/// archived to Venti are refused, because there is no Venti here to fetch them from.
+///
+///
+/// Proof: volumes made by plan9port's flfmt and filled by its fossil server
+/// over 9P read byte-identical to what that server returns. Sourcing: rung 1 — converted
+/// from plan9port's MIT-licensed fossil sources (THIRD-PARTY-NOTICE.Fossil.txt). No writer:
+/// a writer must keep the label partition, epochs and tags consistent, and is not yet
+/// proven against fossil.
+///
+///
+public sealed class FossilFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations {
+ public string Id => "Fossil";
+ public string DisplayName => "Fossil (Plan 9)";
+ public FormatCategory Category => FormatCategory.Archive;
+ public FormatCapabilities Capabilities =>
+ FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanTest |
+ FormatCapabilities.SupportsMultipleEntries | FormatCapabilities.SupportsDirectories;
+ public string DefaultExtension => ".fossil";
+ public IReadOnlyList Extensions => [".fossil"];
+ public IReadOnlyList CompoundExtensions => [];
+ public IReadOnlyList MagicSignatures => [
+ new([0x37, 0x76, 0xAE, 0x89, 0x00, 0x01], Offset: FossilReader.HeaderOffset, Confidence: 0.90),
+ ];
+ public IReadOnlyList Methods => [new("stored", "Stored")];
+ public string? TarCompressionFormatId => null;
+ public AlgorithmFamily Family => AlgorithmFamily.Archive;
+ public string Description => "Plan 9 Fossil file server volume (local blocks); read-only, Venti-archived blocks refused.";
+
+ public List List(Stream stream, string? password) {
+ var r = new FossilReader(stream);
+ return r.Entries.Select((e, i) => new ArchiveEntryInfo(i, e.Path, e.Size, e.Size, "Stored", e.IsDirectory, false,
+ DateTime.UnixEpoch.AddSeconds(e.MTime))).ToList();
+ }
+
+ public void Extract(Stream stream, string outputDir, string? password, string[]? files) {
+ var r = new FossilReader(stream);
+ foreach (var e in r.Entries) {
+ if (files != null && !MatchesFilter(e.Path, files)) continue;
+ if (e.IsDirectory) {
+ Directory.CreateDirectory(Path.Combine(outputDir, e.Path.Replace('/', Path.DirectorySeparatorChar)));
+ continue;
+ }
+ WriteFile(outputDir, e.Path, r.Extract(e));
+ }
+ }
+
+ public Stream OpenEntry(Stream archive, string entryName, string? password) {
+ var bytes = this.ExtractEntryToMemory(archive, entryName, password);
+ return new BoundedEntryStream(new MemoryStream(bytes, writable: false), bytes.Length, leaveOpen: false);
+ }
+
+ public byte[] ExtractEntryToMemory(Stream archive, string entryName, string? password) {
+ ArgumentNullException.ThrowIfNull(archive);
+ ArgumentNullException.ThrowIfNull(entryName);
+ if (archive.CanSeek) archive.Position = 0;
+ var r = new FossilReader(archive);
+ var name = entryName.Replace('\\', '/').TrimStart('/');
+ var e = r.Entries.FirstOrDefault(x => !x.IsDirectory && x.Path == name)
+ ?? throw new FileNotFoundException($"Fossil volume has no file '{entryName}'.", entryName);
+ return r.Extract(e);
+ }
+}
diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Fossil/FossilReader.cs b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Fossil/FossilReader.cs
new file mode 100644
index 000000000..678f18cf3
--- /dev/null
+++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Fossil/FossilReader.cs
@@ -0,0 +1,262 @@
+using System.Buffers.Binary;
+using System.Text;
+
+namespace FileSystem.Fossil;
+
+/// One file or directory of a Fossil volume.
+/// Slash-separated path from the Fossil root (active/…, snapshot/…).
+/// True for a directory.
+/// File size from the data source's entry.
+/// Plan 9 mode word: permission bits plus ModeDir (1<<15) and friends.
+/// Owner.
+/// Group.
+/// Modification time (UNIX seconds).
+/// The entry of the file's data source.
+public sealed record FossilEntry(string Path, bool IsDirectory, long Size, uint Mode, string Uid, string Gid, uint MTime, FossilSourceEntry Source);
+
+/// A source ("stream") entry as stored in a directory block (40 bytes).
+public readonly record struct FossilSourceEntry(uint Gen, ushort PSize, ushort DSize, byte Flags, byte Depth, long Size, uint Address, bool IsLocal, bool IsZero, uint Tag, uint Snap) {
+ /// Entry in use.
+ public bool IsActive => (this.Flags & 0x01) != 0;
+ /// Source holds entries (a directory stream).
+ public bool IsDir => (this.Flags & 0x02) != 0;
+}
+
+///
+/// Reads a Fossil volume (Plan 9's file server, as flfmt lays it out and the
+/// fossil server writes it): header at 128 KiB, superblock, label and data
+/// partitions; files are trees of local blocks addressed by their block number. Blocks
+/// already archived to Venti are refused — this reader has no Venti. See
+/// docs/FOSSIL-ON-DISK.md.
+///
+///
+/// Converted from plan9port's MIT-licensed fossil sources (pack.c, vac.c,
+/// source.c, file.c); see THIRD-PARTY-NOTICE.Fossil.txt.
+///
+public sealed class FossilReader {
+ public const uint HeaderMagic = 0x3776AE89, SuperMagic = 0x2340A3B1, MetaMagic = 0x5656FC7A, DirMagic = 0x1C4D9072;
+ public const int HeaderOffset = 128 * 1024, ScoreSize = 20, EntrySize = 40, MaxDepth = 7;
+ public const uint ModeDir = 1u << 15, ModeLink = 1u << 14, ModeSnapshot = 1u << 20;
+
+ /// SHA-1 of the empty string: Venti's score of a zero-length block.
+ private static readonly byte[] ZeroScore = Convert.FromHexString("DA39A3EE5E6B4B0D3255BFEF95601890AFD80709");
+
+ private readonly Stream _stream;
+
+ /// Bytes per block.
+ public int BlockSize { get; }
+
+ /// First block of the data partition.
+ public uint DataStart { get; }
+
+ /// End of the data partition.
+ public uint DataEnd { get; }
+
+ /// Superblock epochs.
+ public (uint Low, uint High) Epochs { get; }
+
+ /// Block address of the active root.
+ public uint Active { get; }
+
+ /// Superblock name (the volume label).
+ public string Name { get; }
+
+ /// Every file and directory, parents first.
+ public IReadOnlyList Entries { get; }
+
+ /// Opens a Fossil volume; throws for anything it cannot read faithfully.
+ public FossilReader(Stream stream) {
+ ArgumentNullException.ThrowIfNull(stream);
+ this._stream = stream;
+ if (stream.Length < HeaderOffset + 512) throw new InvalidDataException("Image too short for a Fossil header at 128 KiB.");
+ var h = this.Read(HeaderOffset, 512);
+ if (U32(h, 0) != HeaderMagic) throw new InvalidDataException("Not a Fossil volume: header magic 0x3776ae89 missing at 128 KiB.");
+ if (U16(h, 4) != 1) throw new InvalidDataException($"Fossil header version {U16(h, 4)} is not 1.");
+ this.BlockSize = U16(h, 6);
+ var super = U32(h, 8);
+ this.DataStart = U32(h, 16);
+ this.DataEnd = U32(h, 20);
+ if (this.BlockSize < 512 || this.BlockSize % 512 != 0 || this.DataEnd <= this.DataStart)
+ throw new InvalidDataException("Fossil header geometry is invalid.");
+
+ var s = this.Read((long)super * this.BlockSize, 512);
+ if (U32(s, 0) != SuperMagic || U16(s, 4) != 1) throw new InvalidDataException("Fossil superblock magic or version is wrong.");
+ this.Epochs = (U32(s, 6), U32(s, 10));
+ this.Active = U32(s, 22);
+ this.Name = CString(s.AsSpan(54, 128));
+
+ // The active root block holds one entry: the root source. Its first three entries are
+ // the root directory's entry stream, its meta stream, and the meta block naming the root.
+ var rootBlock = this.ReadBlock(this.Active);
+ var root = ParseEntry(rootBlock, 0);
+ if (!root.IsActive || !root.IsDir) throw new InvalidDataException("Fossil active root source is not an active directory.");
+ var dirSource = this.SourceEntry(root, 0);
+ var metaSource = this.SourceEntry(root, 1);
+
+ var entries = new List();
+ this.Walk(dirSource, metaSource, "", entries, 0);
+ this.Entries = entries;
+ }
+
+ /// Reads a file's bytes; refuses blocks that are not local or lie outside the data partition.
+ public byte[] Extract(FossilEntry entry) {
+ ArgumentNullException.ThrowIfNull(entry);
+ if (entry.IsDirectory) return [];
+ return this.ReadSource(entry.Source);
+ }
+
+ private void Walk(FossilSourceEntry dir, FossilSourceEntry meta, string prefix, List entries, int depth) {
+ // A directory loop shows up as unbounded nesting.
+ if (depth > 64) throw new InvalidDataException("Fossil directory nesting beyond 64 levels (a loop?).");
+ var metaData = this.ReadSource(meta);
+ for (var off = 0; off < metaData.Length; off += meta.DSize) {
+ var mb = metaData.AsSpan(off, Math.Min(meta.DSize, metaData.Length - off));
+ foreach (var de in ParseMetaBlock(mb)) {
+ var path = prefix.Length == 0 ? de.Elem : prefix + "/" + de.Elem;
+ var isDir = (de.Mode & ModeDir) != 0;
+ var src = this.SourceEntry(dir, de.Entry);
+ if (!src.IsActive || src.Gen != de.Gen)
+ throw new InvalidDataException($"Fossil '{path}': its source entry {de.Entry} is inactive or of another generation.");
+ entries.Add(new FossilEntry(path, isDir, isDir ? 0 : src.Size, de.Mode, de.Uid, de.Gid, de.MTime, src));
+ if (!isDir) continue;
+ var msrc = this.SourceEntry(dir, de.MEntry);
+ if (!msrc.IsActive || msrc.Gen != de.MGen)
+ throw new InvalidDataException($"Fossil '{path}': its meta source entry {de.MEntry} is inactive or of another generation.");
+ this.Walk(src, msrc, path, entries, depth + 1);
+ }
+ }
+ }
+
+ private sealed record DirEntry(string Elem, uint Entry, uint Gen, uint MEntry, uint MGen, string Uid, string Gid, uint MTime, uint Mode);
+
+ /// DirEntries of one MetaBlock, in index order (vac.c mbUnpack/meUnpack/deUnpack).
+ private static List ParseMetaBlock(ReadOnlySpan p) {
+ var list = new List();
+ if (p.Length < 12 || p.IndexOfAnyExcept((byte)0) < 0) return list; // an all-zero block holds no entries
+ var magic = U32(p, 0);
+ if (magic != MetaMagic && magic != MetaMagic - 1) throw new InvalidDataException("Fossil meta block magic is wrong.");
+ int size = U16(p, 4), maxIndex = U16(p, 8), nIndex = U16(p, 10);
+ var omin = 12 + maxIndex * 4;
+ if (size > p.Length || p.Length < omin || nIndex > maxIndex) throw new InvalidDataException("Fossil meta block header is inconsistent.");
+ for (var i = 0; i < nIndex; ++i) {
+ int eo = U16(p, 12 + i * 4), en = U16(p, 14 + i * 4);
+ if (eo < omin || eo + en > size || en < 8) throw new InvalidDataException("Fossil meta block index points outside the block.");
+ list.Add(ParseDirEntry(p.Slice(eo, en)));
+ }
+ return list;
+ }
+
+ private static DirEntry ParseDirEntry(ReadOnlySpan p) {
+ if (U32(p, 0) != DirMagic) throw new InvalidDataException("Fossil directory entry magic is wrong.");
+ var version = U16(p, 4);
+ if (version is < 7 or > 9) throw new InvalidDataException($"Fossil directory entry version {version} is unknown.");
+ var o = 6;
+ var elem = Str(p, ref o);
+ var entry = U32(p, o); o += 4;
+ uint gen = 0, mentry = entry + 1, mgen = 0;
+ if (version >= 9) { gen = U32(p, o); mentry = U32(p, o + 4); mgen = U32(p, o + 8); o += 12; }
+ o += 8; // qid
+ if (version == 7) o += ScoreSize; // replacement score
+ var uid = Str(p, ref o);
+ var gid = Str(p, ref o);
+ _ = Str(p, ref o); // mid
+ if (o + 20 > p.Length) throw new InvalidDataException("Fossil directory entry is truncated.");
+ var mtime = U32(p, o);
+ var mode = U32(p, o + 16);
+ if (elem.Length == 0 || elem is "." or ".." || elem.Contains('/')) throw new InvalidDataException($"Fossil directory entry has an unusable name '{elem}'.");
+ return new(elem, entry, gen, mentry, mgen, uid, gid, mtime, mode);
+ }
+
+ private static string Str(ReadOnlySpan p, ref int o) {
+ if (o + 2 > p.Length) throw new InvalidDataException("Fossil directory entry string is truncated.");
+ int n = U16(p, o);
+ if (o + 2 + n > p.Length) throw new InvalidDataException("Fossil directory entry string is truncated.");
+ var s = Encoding.UTF8.GetString(p.Slice(o + 2, n));
+ o += 2 + n;
+ return s;
+ }
+
+ /// Entry of a directory source.
+ private FossilSourceEntry SourceEntry(FossilSourceEntry dir, uint index) {
+ if (!dir.IsDir) throw new InvalidDataException("Fossil: entry lookup in a source that is not a directory.");
+ var epb = (uint)(dir.DSize / EntrySize);
+ if (epb == 0) throw new InvalidDataException("Fossil directory source has a data block smaller than one entry.");
+ var block = this.SourceBlock(dir, index / epb);
+ return ParseEntry(block, (int)(index % epb));
+ }
+
+ private static FossilSourceEntry ParseEntry(ReadOnlySpan b, int index) {
+ var p = b.Slice(index * EntrySize, EntrySize);
+ var flags = p[8];
+ var depth = (byte)((flags >> 2) & 7);
+ var size = (long)U16(p, 14) << 32 | U32(p, 16);
+ var local = (flags & 0x20) != 0;
+ if (local) return new(U32(p, 0), U16(p, 4), U16(p, 6), (byte)(flags & ~0x1C), depth, size, U32(p, 36), true, false, U32(p, 32), U32(p, 28));
+ var score = p.Slice(20, ScoreSize);
+ var zero = score.SequenceEqual(ZeroScore) || score.IndexOfAnyExcept((byte)0) < 0;
+ // A non-local score with 16 leading zeros is still a local block address (globalToLocal).
+ var addr = score[..16].IndexOfAnyExcept((byte)0) < 0 ? U32(score, 16) : uint.MaxValue;
+ return new(U32(p, 0), U16(p, 4), U16(p, 6), (byte)(flags & ~0x1C), depth, size, addr, addr != uint.MaxValue && !zero, zero, 0, 0);
+ }
+
+ /// All bytes of a source (its entry's size).
+ private byte[] ReadSource(FossilSourceEntry e) {
+ if (e.Size < 0 || e.Size > int.MaxValue) throw new InvalidDataException("Fossil source is too large to buffer.");
+ var data = new byte[e.Size];
+ if (e.Size == 0) return data;
+ if (e.DSize == 0) throw new InvalidDataException("Fossil source with data has a zero block size.");
+ var blocks = (e.Size + e.DSize - 1) / e.DSize;
+ for (long bn = 0; bn < blocks; ++bn) {
+ var block = this.SourceBlock(e, (uint)bn);
+ var at = bn * e.DSize;
+ var n = (int)Math.Min(e.DSize, e.Size - at);
+ block.AsSpan(0, Math.Min(n, block.Length)).CopyTo(data.AsSpan((int)at));
+ }
+ return data;
+ }
+
+ /// Block of a source, walking its pointer tree (source.c _sourceBlock).
+ private byte[] SourceBlock(FossilSourceEntry e, uint bn) {
+ var np = e.PSize / ScoreSize;
+ if (e.Depth > MaxDepth || (e.Depth > 0 && np == 0)) throw new InvalidDataException("Fossil source pointer tree is malformed.");
+ var index = new int[MaxDepth + 1];
+ var levels = 0;
+ for (var b = bn; b > 0; b /= (uint)np) {
+ if (levels >= e.Depth) throw new InvalidDataException($"Fossil block {bn} lies beyond the source's depth {e.Depth}.");
+ index[levels++] = (int)(b % (uint)np);
+ }
+ if (e.IsZero) return new byte[e.DSize];
+ if (!e.IsLocal) throw new InvalidDataException("Fossil source lives on Venti; this reader has no Venti connection.");
+ var block = this.ReadBlock(e.Address);
+ for (var level = e.Depth - 1; level >= 0; --level) {
+ var score = block.AsSpan(index[level] * ScoreSize, ScoreSize);
+ if (score.SequenceEqual(ZeroScore) || score.IndexOfAnyExcept((byte)0) < 0)
+ return new byte[level == 0 ? e.DSize : e.PSize];
+ if (score[..16].IndexOfAnyExcept((byte)0) >= 0)
+ throw new InvalidDataException("Fossil pointer refers to a Venti block; this reader has no Venti connection.");
+ block = this.ReadBlock(U32(score, 16));
+ }
+ return block;
+ }
+
+ private byte[] ReadBlock(uint addr) {
+ if (addr >= this.DataEnd - this.DataStart) throw new InvalidDataException($"Fossil block {addr} lies outside the data partition.");
+ return this.Read(((long)this.DataStart + addr) * this.BlockSize, this.BlockSize);
+ }
+
+ private byte[] Read(long offset, int count) {
+ if (offset + count > this._stream.Length) throw new InvalidDataException($"Fossil read at {offset} lies past the end of the image.");
+ var buf = new byte[count];
+ this._stream.Position = offset;
+ this._stream.ReadExactly(buf);
+ return buf;
+ }
+
+ private static string CString(ReadOnlySpan b) {
+ var end = b.IndexOf((byte)0);
+ return Encoding.UTF8.GetString(end < 0 ? b : b[..end]);
+ }
+
+ private static uint U32(ReadOnlySpan b, int at) => BinaryPrimitives.ReadUInt32BigEndian(b[at..]);
+ private static ushort U16(ReadOnlySpan b, int at) => BinaryPrimitives.ReadUInt16BigEndian(b[at..]);
+}
diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Fossil/THIRD-PARTY-NOTICE.Fossil.txt b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Fossil/THIRD-PARTY-NOTICE.Fossil.txt
new file mode 100644
index 000000000..ce3ef4e6a
--- /dev/null
+++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Fossil/THIRD-PARTY-NOTICE.Fossil.txt
@@ -0,0 +1,27 @@
+FossilReader.cs is converted from the Fossil file server in plan9port:
+
+ https://github.com/9fans/plan9port src/cmd/fossil (pack.c, vac.c, vac.h, source.c, file.c, dat.h)
+ revision 1f1396913fb5372408f442ed26933f5ae4b399cd
+
+Copyright © 2021 Plan 9 Foundation
+Portions Copyright © 2001-2008 Russ Cox
+Portions Copyright © 2008-2009 Google Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/Hawkynt.FileFormats.FileSystems/README.md b/Hawkynt.FileFormats.FileSystems/README.md
index 6bee1a597..4ee792651 100644
--- a/Hawkynt.FileFormats.FileSystems/README.md
+++ b/Hawkynt.FileFormats.FileSystems/README.md
@@ -115,6 +115,7 @@ The `Id`, `State` and verb columns are read off the descriptors by `FilesystemRe
| [ext1](https://en.wikipedia.org/wiki/Extended_file_system) | `Ext1` | R/W | ✅ | ✅ moving | — | ✅ | ✅ | — | ✅ | [`dumpe2fs`](https://github.com/tytso/e2fsprogs) accepts as ext2 | 1992 ext, magic `0xEF51`; no `mkfs.ext1` exists | [Extended file system](https://en.wikipedia.org/wiki/Extended_file_system) |
| [ext2/3/4](https://en.wikipedia.org/wiki/Ext4) | `Ext` | R/W | ✅ | ✅ moving | — | ✅ | ✅ | — | ✅ | [`e2fsck -fn`](https://github.com/tytso/e2fsprogs) / `dumpe2fs` / `debugfs` + reverse `mke2fs` | ext2 / ext3 / ext4; add/remove anywhere in the tree and every defrag mode except interleave run in place, never rebuilt | [ext4 documentation](https://www.kernel.org/doc/html/latest/filesystems/ext4/) |
| [F2FS](https://en.wikipedia.org/wiki/F2FS) | `F2fs` | R/W | ✅ | ✅ moving | — | ✅ | ✅ | — | ✅ | host-kernel `f2fs` mount r/w + [`fsck.f2fs`](https://git.kernel.org/pub/scm/linux/kernel/git/jaegeuk/f2fs-tools.git) | — | [F2FS documentation](https://www.kernel.org/doc/html/latest/filesystems/f2fs.html) |
+| [Fossil (Plan 9)](https://en.wikipedia.org/wiki/Fossil_(file_system)) | `Fossil` | R | — | — | — | — | — | — | — | volume formatted by plan9port's `flfmt` and filled by its `fossil` server over 9P (incl. a snapshot) reads byte-identical to what that server returns | Whole local tree from the Fossil root (`active/…`, `snapshot/…`); blocks archived to Venti are refused. No writer yet | [plan9port fossil](https://github.com/9fans/plan9port/tree/master/src/cmd/fossil), [on-disk notes](https://github.com/Hawkynt/CompressionWorkbench/blob/main/docs/FOSSIL-ON-DISK.md) |
| [GFS (Sistina/Red Hat, original)](https://en.wikipedia.org/wiki/GFS2) | `Gfs1` | R/W | ✅ | ✅ moving | — | ✅ | ✅ | — | ✅ | own reader + struct-parity tests; the Linux `gfs2` driver rejects GFS1 | Sistina GFS | [GFS2](https://www.kernel.org/doc/html/latest/filesystems/gfs2.html) |
| [GFS2 (Global File System 2)](https://en.wikipedia.org/wiki/GFS2) | `Gfs2` | R/W | ✅ | ✅ moving | — | ✅ | ✅ | — | ✅ | [`fsck.gfs2 -n`](https://pagure.io/gfs2-utils) + `debugfs.gfs2`; `mkfs.gfs2` image read by the package | Standalone `lock_nolock`; stuffed + indirect-tree files; edits rebuild while preserving image-size floor, lock table, UUID and file dinode attributes (mode, uid/gid, timestamps, policy flags) | [GFS2](https://www.kernel.org/doc/html/latest/filesystems/gfs2.html) |
| [HAMMER (DragonFly BSD)](https://en.wikipedia.org/wiki/HAMMER_(file_system)) | `Hammer` | R/W | ✅ | ✅ moving | — | ✅ | ✅ | — | ✅ | DragonFly kernel mount r/w under QEMU + [`hammer show` / `hammer checkmap`](https://www.dragonflybsd.org/hammer/) | Single volume, about 1 GB minimum; edits rebuild the volume | [HAMMER](https://www.dragonflybsd.org/hammer/) |
@@ -284,7 +285,7 @@ ops.Remove(image, ["var/log/old.log"]);
-Every public and protected member of all 886 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Hawkynt.FileFormats.FileSystems/REFERENCE.md).
+Every public and protected member of all 890 types, generated from the built assembly and its XML documentation, is in [REFERENCE.md](https://github.com/Hawkynt/CompressionWorkbench/blob/main/Hawkynt.FileFormats.FileSystems/REFERENCE.md).
diff --git a/Hawkynt.FileFormats.FileSystems/REFERENCE.md b/Hawkynt.FileFormats.FileSystems/REFERENCE.md
index 8c41326ac..21439cab7 100644
--- a/Hawkynt.FileFormats.FileSystems/REFERENCE.md
+++ b/Hawkynt.FileFormats.FileSystems/REFERENCE.md
@@ -7080,6 +7080,104 @@ Builds Microsoft Xbox / Xbox 360 FATX filesystem images from scratch per the rev
| `Build` | `byte[] Build(int sectorsPerCluster = 0, uint volumeId = 0)` | Builds a complete FATX image containing the previously-added files. |
| `WriteTo` | `void WriteTo(Stream output, int sectorsPerCluster = 0, uint volumeId = 0)` | Writes the image into `output`: the superblock and FAT, then every cluster payload at its offset. Only a non-seekable target has to materialise the image, so a seekable one is bounded by the disk rather than by what a byte[] can address. |
+### Namespace `FileSystem.Fossil`
+
+[`FossilEntry`](#fossilentry) · [`FossilFormatDescriptor`](#fossilformatdescriptor) · [`FossilReader`](#fossilreader) · [`FossilSourceEntry`](#fossilsourceentry)
+
+#### `FossilEntry`
+
+One file or directory of a Fossil volume.
+
+Implements `IEquatable`.
+
+| Member | Signature | Summary |
+| --- | --- | --- |
+| `FossilEntry` | `FossilEntry(string Path, bool IsDirectory, long Size, uint Mode, string Uid, string Gid, uint MTime, FossilSourceEntry Source)` | One file or directory of a Fossil volume. |
+| `Gid` | `string Gid { get; init; }` | Group. |
+| `IsDirectory` | `bool IsDirectory { get; init; }` | True for a directory. |
+| `MTime` | `uint MTime { get; init; }` | Modification time (UNIX seconds). |
+| `Mode` | `uint Mode { get; init; }` | Plan 9 mode word: permission bits plus `ModeDir` (1<<15) and friends. |
+| `Path` | `string Path { get; init; }` | Slash-separated path from the Fossil root (`active/…`, `snapshot/…`). |
+| `Size` | `long Size { get; init; }` | File size from the data source's entry. |
+| `Source` | `FossilSourceEntry Source { get; init; }` | The entry of the file's data source. |
+| `Uid` | `string Uid { get; init; }` | Owner. |
+
+#### `FossilFormatDescriptor`
+
+Fossil — the Plan 9 file server's on-disk format (Quinlan, McKie, Cox, 2002), as plan9port's `flfmt` and `fossil` write it. Header magic `0x3776ae89` at 128 KiB.
+
+Implements `IArchiveFormatOperations`, `IFormatDescriptor`.
+
+| Member | Signature | Summary |
+| --- | --- | --- |
+| `FossilFormatDescriptor` | `FossilFormatDescriptor()` | |
+| `Capabilities` | `FormatCapabilities Capabilities { get; }` | |
+| `Category` | `FormatCategory Category { get; }` | |
+| `CompoundExtensions` | `IReadOnlyList CompoundExtensions { get; }` | |
+| `DefaultExtension` | `string DefaultExtension { get; }` | |
+| `Description` | `string Description { get; }` | |
+| `DisplayName` | `string DisplayName { get; }` | |
+| `Extensions` | `IReadOnlyList Extensions { get; }` | |
+| `Family` | `AlgorithmFamily Family { get; }` | |
+| `Id` | `string Id { get; }` | |
+| `MagicSignatures` | `IReadOnlyList MagicSignatures { get; }` | |
+| `Methods` | `IReadOnlyList Methods { get; }` | |
+| `TarCompressionFormatId` | `string TarCompressionFormatId { get; }` | |
+| `ExtractEntryToMemory` | `byte[] ExtractEntryToMemory(Stream archive, string entryName, string password)` | |
+| `Extract` | `void Extract(Stream stream, string outputDir, string password, string[] files)` | |
+| `List` | `List List(Stream stream, string password)` | |
+| `OpenEntry` | `Stream OpenEntry(Stream archive, string entryName, string password)` | |
+
+#### `FossilReader`
+
+Reads a Fossil volume (Plan 9's file server, as `flfmt` lays it out and the `fossil` server writes it): header at 128 KiB, superblock, label and data partitions; files are trees of local blocks addressed by their block number. Blocks already archived to Venti are refused — this reader has no Venti. See `docs/FOSSIL-ON-DISK.md`.
+
+| Member | Signature | Summary |
+| --- | --- | --- |
+| `FossilReader` | `FossilReader(Stream stream)` | Opens a Fossil volume; throws `InvalidDataException` for anything it cannot read faithfully. |
+| `DirMagic` | `const uint DirMagic` | |
+| `EntrySize` | `const int EntrySize` | |
+| `HeaderMagic` | `const uint HeaderMagic` | |
+| `HeaderOffset` | `const int HeaderOffset` | |
+| `MaxDepth` | `const int MaxDepth` | |
+| `MetaMagic` | `const uint MetaMagic` | |
+| `ModeDir` | `const uint ModeDir` | |
+| `ModeLink` | `const uint ModeLink` | |
+| `ModeSnapshot` | `const uint ModeSnapshot` | |
+| `ScoreSize` | `const int ScoreSize` | |
+| `SuperMagic` | `const uint SuperMagic` | |
+| `Active` | `uint Active { get; }` | Block address of the active root. |
+| `BlockSize` | `int BlockSize { get; }` | Bytes per block. |
+| `DataEnd` | `uint DataEnd { get; }` | End of the data partition. |
+| `DataStart` | `uint DataStart { get; }` | First block of the data partition. |
+| `Entries` | `IReadOnlyList Entries { get; }` | Every file and directory, parents first. |
+| `Epochs` | `ValueTuple Epochs { get; }` | Superblock epochs. |
+| `Name` | `string Name { get; }` | Superblock name (the volume label). |
+| `Extract` | `byte[] Extract(FossilEntry entry)` | Reads a file's bytes; refuses blocks that are not local or lie outside the data partition. |
+
+#### `FossilSourceEntry`
+
+A source ("stream") entry as stored in a directory block (40 bytes).
+
+Implements `IEquatable`.
+
+| Member | Signature | Summary |
+| --- | --- | --- |
+| `FossilSourceEntry` | `FossilSourceEntry(uint Gen, ushort PSize, ushort DSize, byte Flags, byte Depth, long Size, uint Address, bool IsLocal, bool IsZero, uint Tag, uint Snap)` | A source ("stream") entry as stored in a directory block (40 bytes). |
+| `Address` | `uint Address { get; init; }` | |
+| `DSize` | `ushort DSize { get; init; }` | |
+| `Depth` | `byte Depth { get; init; }` | |
+| `Flags` | `byte Flags { get; init; }` | |
+| `Gen` | `uint Gen { get; init; }` | |
+| `IsActive` | `bool IsActive { get; }` | Entry in use. |
+| `IsDir` | `bool IsDir { get; }` | Source holds entries (a directory stream). |
+| `IsLocal` | `bool IsLocal { get; init; }` | |
+| `IsZero` | `bool IsZero { get; init; }` | |
+| `PSize` | `ushort PSize { get; init; }` | |
+| `Size` | `long Size { get; init; }` | |
+| `Snap` | `uint Snap { get; init; }` | |
+| `Tag` | `uint Tag { get; init; }` | |
+
### Namespace `FileSystem.Gemdos`
[`GemdosBpb`](#gemdosbpb) · [`GemdosExtentMap`](#gemdosextentmap) · [`GemdosFormatDescriptor`](#gemdosformatdescriptor) · [`GemdosInPlaceModifier`](#gemdosinplacemodifier) · [`GemdosOptimizer`](#gemdosoptimizer) · [`GemdosOptimizer.GemdosGeometry`](#gemdosoptimizergemdosgeometry) · [`GemdosReader`](#gemdosreader) · [`GemdosWriter`](#gemdoswriter)
diff --git a/docs/FOSSIL-ON-DISK.md b/docs/FOSSIL-ON-DISK.md
new file mode 100644
index 000000000..b5f1410c8
--- /dev/null
+++ b/docs/FOSSIL-ON-DISK.md
@@ -0,0 +1,87 @@
+# Fossil on disk
+
+Fossil is the Plan 9 file server (Quinlan, McKie and Cox, 2002). It keeps the active file
+system and its local snapshots in one partition of copy-on-write blocks, and can archive
+snapshots to Venti. This page describes what `FileSystem.Fossil` reads.
+
+**Sources (rung 1).** The fossil sources in plan9port
+(, `src/cmd/fossil`, revision `1f13969`) are under the
+MIT licence since the Plan 9 Foundation relicensed Plan 9 in 2021. The reader converts
+their unpack routines (`pack.c`, `vac.c`) and their block walk (`source.c`, `file.c`).
+Notice: `THIRD-PARTY-NOTICE.Fossil.txt` beside the code.
+
+All integers are big-endian.
+
+## Header (byte 131072, 512 bytes)
+
+| Offset | Size | Field |
+| --- | --- | --- |
+| 0 | 4 | magic `0x3776ae89` |
+| 4 | 2 | version, 1 |
+| 6 | 2 | block size |
+| 8 | 4 | block number of the superblock |
+| 12 | 4 | first block of the label partition |
+| 16 | 4 | first block of the data partition (the end of the labels) |
+| 20 | 4 | end of the data partition |
+
+Data block *a* lives at byte `(data + a) × blockSize`.
+
+## Superblock (512 bytes)
+
+`magic 0x2340a3b1` (4), version 1 (2), epochLow (4), epochHigh (4), next qid (8),
+**active** (4, the block holding the active root), next (4), current (4), the last archived
+score (20), and the name (128).
+
+## Entries ("sources") — 40 bytes
+
+| Offset | Size | Field |
+| --- | --- | --- |
+| 0 | 4 | gen |
+| 4 | 2 | psize: pointer block size (holds psize/20 scores) |
+| 6 | 2 | dsize: data block size |
+| 8 | 1 | flags: 0x01 active, 0x02 directory (the source holds entries), bits 2–4 depth, 0x20 local, 0x80 no-archive |
+| 14 | 6 | size in bytes (48-bit) |
+| 20 | 20 | score when not local |
+| 27 / 28 / 32 / 36 | 1 / 4 / 4 / 4 | local entries only: archive flag, snapshot epoch, tag, block address |
+
+A score with 16 leading zero bytes is a local block number in its last 4 bytes.
+`DA39A3EE…0709`, the SHA-1 of nothing, is an empty block. Any other score is a Venti block.
+
+**Block *bn* of a source:** the entry names its top block. With `np = psize/20`, write *bn*
+in base *np*. Starting from the top block, the depth−1 … 0 digits pick the score in each
+pointer block. The leaf is a data block, `dsize` bytes, cut to the source size.
+
+## Directory tree
+
+The block at `active` holds one entry: the root source. Its entries 0, 1 and 2 are the
+root directory's entry stream, its meta stream, and a meta stream naming the root itself.
+
+A meta stream is a sequence of `dsize` MetaBlocks. A MetaBlock starts with magic
+`0x5656fc7a` (or one less, for the old sort order), then size, free, maxindex and nindex
+(2 bytes each). Next comes an index of (offset, length) pairs, then the DirEntries.
+
+A DirEntry has magic `0x1c4d9072` (4), version 7–9 (2), the element name (2-byte length +
+bytes), entry (4), and gen, mentry and mgen (4 each, from version 9). Then come the qid
+(8), a 20-byte replacement score in version 7 only, and uid, gid and mid (strings). The
+mtime, mcount, ctime, atime and mode (4 each) follow, then optional sections.
+
+A child is entry `entry` of its parent's entry stream, and its `gen` must match. A directory
+also has a meta stream: entry `mentry`, generation `mgen`. Mode bit `1<<15` marks a
+directory, and `1<<20` a snapshot.
+
+The Fossil root contains `active` (the live tree) and `archive`. After `snap` it also
+contains `snapshot/YYYY/MMDD/HHMM`, whose sources carry the snapshot epoch.
+
+## What the implementation does with it
+
+It reads the whole local tree, files and directories with modes, owners and mtimes,
+snapshots included. Entries must be active and of the right generation, every block must
+lie in the data partition, and nesting is bounded. A Venti score is refused, because there
+is no Venti here. Read-only: a writer must keep labels, epochs and tags consistent and is
+not proven against `fossil` yet.
+
+Checked against a volume formatted by `flfmt` from plan9port and filled by plan9port's
+`fossil` server over 9P. It holds a two-level pointer tree, nested and empty directories,
+and a snapshot taken before a rewrite. It reads back byte-identical to what that server
+returned. `flchk` was not usable as an oracle: without Venti it reports errors even on a
+freshly formatted volume.
diff --git a/docs/LICENCE-PROVENANCE.md b/docs/LICENCE-PROVENANCE.md
index ca50e4c15..ba5a7df42 100644
--- a/docs/LICENCE-PROVENANCE.md
+++ b/docs/LICENCE-PROVENANCE.md
@@ -31,7 +31,7 @@ package carries; everything else keeps a `THIRD-PARTY-NOTICE..txt` beside
| Class | Upstream rows | Notes |
|---|---|---|
-| A | 18 | |
+| A | 19 | |
| B | 17 | FFmpeg alone covers 38 codec folders |
| C | 3 | specifications: `docs/CVF-CODECS-ON-DISK.md` (rewrite already under way), `docs/IT-SAMPLE-COMPRESSION-SPEC.md`, `docs/FDS-EXPANSION-SOUND-SPEC.md` |
| R | 8 | |
@@ -70,6 +70,7 @@ package carries; everything else keeps a `THIRD-PARTY-NOTICE..txt` beside
| Claim site | Upstream | Licence | Where the notice is |
|---|---|---|---|
+| `Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Fossil/FossilReader.cs` | plan9port Fossil `pack.c`, `vac.c`, `vac.h`, `source.c`, `file.c`, `dat.h`, revision `1f1396913fb5372408f442ed26933f5ae4b399cd` | MIT | `Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Fossil/THIRD-PARTY-NOTICE.Fossil.txt` |
| `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidBootImage/AndroidBootImage.cs`, `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidBootImage/AndroidBootImageMetadata.cs` | AOSP mkbootimg `bootimg.h`, `mkbootimg.py` and `unpack_bootimg.py`, revision `d2bb0af5ba6d3198a3e99529c97eda1be0b5a093` | BSD-3-Clause and Apache-2.0 | `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidBootImage/THIRD-PARTY-NOTICE.mkbootimg.txt` |
| `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidVbmeta/AvbVbmeta.cs`, `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidVbmeta/AvbDescriptorText.cs` | AOSP Android Verified Boot headers and `avbtool.py`, revision `c5066a96caa7bf4150c0a8cc8cc14ab81733fdc7` | MIT | `Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.AndroidVbmeta/THIRD-PARTY-NOTICE.avb.txt` |
| `Compression.Core/Dictionary/Lzbitmap/` | libzbitmap (Ernesto A. Fernández, Corellium) | MIT | `THIRD-PARTY-NOTICE.libzbitmap.txt` |
diff --git a/docs/catalogue/our-registry.csv b/docs/catalogue/our-registry.csv
index 9512a342d..c9b2dcc87 100644
--- a/docs/catalogue/our-registry.csv
+++ b/docs/catalogue/our-registry.csv
@@ -187,6 +187,7 @@ format,FlacArchive,FLAC (archive view),Audio,,,Y,Y,,
format,Fli,FLI/FLC (Autodesk animation),Archive,,.fli;.flc,Y,Y,,
format,Flv,Flash Video (FLV),Video,,.flv,Y,Y,Y,
format,Fmod,FMOD Sample Bank,Archive,,.fsb,Y,Y,,
+format,Fossil,Fossil (Plan 9),Archive,Y,.fossil,Y,Y,,
format,FreeArc,FreeArc,Archive,,.arc,Y,Y,Y,Y
format,Freeze,Freeze,Stream,,.f;.freeze,,Y,Y,
format,Fsg,FSG (Win32 PE),Archive,,,Y,Y,,
diff --git a/docs/catalogue/reference-formats.csv b/docs/catalogue/reference-formats.csv
index c842b846e..02baac7c8 100644
--- a/docs/catalogue/reference-formats.csv
+++ b/docs/catalogue/reference-formats.csv
@@ -405,7 +405,7 @@ ext3cow,,filesystem,,,,,,,Y,1,,,,,,MISSING,,
FAT8 (Disk BASIC),,filesystem,,,,,,,Y,1,,,,,,MISSING,,
FATX (Xbox),fatx;xbox,filesystem,,,,,,,Y,1,,Fatx,Y,Y,Y,OK,,
"UFS / FFS (BSD), UFS2",ufs;ufs1,filesystem,,,,,,,Y,1,,Ufs,Y,Y,Y,PARTIAL,,UFS1 only; UFS2 (magic 0x19540119) is not recognised
-Fossil (Plan 9),,filesystem,,,,,,,Y,1,,,,,,MISSING,,
+Fossil (Plan 9),,filesystem,,,,,,,Y,1,https://github.com/9fans/plan9port/tree/master/src/cmd/fossil,Fossil,Y,,,READ-ONLY-BUT-SOURCES-WRITE,flfmt,writer not yet proven against the fossil server
Files-11 (ODS-1/2/5),ods2;ods5;ods-1;ods-2;ods-5;openvms,filesystem,,,,,,,Y,1,,OpenVms;Ods1,Y,Y,Y,OK,,
FLEX file system,,filesystem,,,,,,,Y,1,,,,,,MISSING,,
HAMMER,,filesystem,,,,,,,Y,1,,Hammer,Y,Y,Y,OK,,
diff --git a/screenshots/main-window.png b/screenshots/main-window.png
index bd3934451..e3795c0d0 100644
Binary files a/screenshots/main-window.png and b/screenshots/main-window.png differ