From 30e76f4450f2636ce32a272fd1ba86f0b93849fe Mon Sep 17 00:00:00 2001 From: Hawkynt Date: Fri, 23 Oct 2026 14:59:11 +0200 Subject: [PATCH 1/3] + FLEX (TSC 6800/6809) disk images, in-place R/W The catalogue listed the FLEX file system as MISSING. New Flex descriptor for raw .dsk sector images: SIR on track 0 sector 3, directory chain from sector 5 (it grows past track 0), linked 256-byte sectors with record numbers. Create formats as NEWDISK does (standard geometries or given); Add/Remove edit in place as FMS does - sectors from the head of the free chain, free or deleted directory slot, deleted entries marked $FF and their zeroed chain appended to the free chain's tail, SIR updated. Protected files, bad names, a full disk and a free chain disagreeing with the SIR are refused with the image unchanged. FLEX keeps no byte length, so contents are whole 252-byte records (KnownGap target in the conversion matrix, as LIF/RT-11). Detection: masked signature over the SIR's zero header and the first directory sector's link to track 0 sector 6; claims .flx and the shared .dsk. Oracle: flexfloppy (laurent-fr/flextools, black box) catalogues and extracts what we create and edit, adds into our edited disk, and we read what it formats and adds; TSC's FLEX 9.1 distribution disk (TSCFLX09.DSK, not committed - test reads it from test-corpus/ when present) reads byte-identical to flexfloppy's extraction, random file and dates included. Sourcing: rung 3 - TSC's 6809 FLEX Adaptation Guide and FLEX Advanced Programmer's Guide (normative); docs/FLEX-ON-DISK.md. --- Compression.Tests/Compression.Tests.csproj | 4 + .../ConversionMatrix/ConversionMatrixTests.cs | 1 + .../Flex/FlexExternalConformanceTests.cs | 123 ++++++++++ Compression.Tests/Flex/FlexTests.cs | 223 ++++++++++++++++++ .../Flex/ReferenceVectors/README.md | 18 ++ .../Flex/ReferenceVectors/TSCFLX09.manifest | 30 +++ .../Flex/ReferenceVectors/flexfloppy.dsk.gz | Bin 0 -> 4747 bytes .../Flex/ReferenceVectors/flexfloppy.manifest | 4 + .../Flex/ReferenceVectors/generate.sh | 18 ++ .../FileSystems/FileSystem.Flex/FlexDisk.cs | 182 ++++++++++++++ .../FileSystem.Flex/FlexFormatDescriptor.cs | 162 +++++++++++++ .../FileSystems/FileSystem.Flex/FlexWriter.cs | 205 ++++++++++++++++ Hawkynt.FileFormats.FileSystems/README.md | 1 + docs/FLEX-ON-DISK.md | 102 ++++++++ docs/catalogue/our-registry.csv | 1 + docs/catalogue/reference-formats.csv | 2 +- 16 files changed, 1075 insertions(+), 1 deletion(-) create mode 100644 Compression.Tests/Flex/FlexExternalConformanceTests.cs create mode 100644 Compression.Tests/Flex/FlexTests.cs create mode 100644 Compression.Tests/Flex/ReferenceVectors/README.md create mode 100644 Compression.Tests/Flex/ReferenceVectors/TSCFLX09.manifest create mode 100644 Compression.Tests/Flex/ReferenceVectors/flexfloppy.dsk.gz create mode 100644 Compression.Tests/Flex/ReferenceVectors/flexfloppy.manifest create mode 100644 Compression.Tests/Flex/ReferenceVectors/generate.sh create mode 100644 Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexDisk.cs create mode 100644 Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexFormatDescriptor.cs create mode 100644 Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexWriter.cs create mode 100644 docs/FLEX-ON-DISK.md diff --git a/Compression.Tests/Compression.Tests.csproj b/Compression.Tests/Compression.Tests.csproj index 7bba71ab0..e05c4087d 100644 --- a/Compression.Tests/Compression.Tests.csproj +++ b/Compression.Tests/Compression.Tests.csproj @@ -118,6 +118,10 @@ ZfsVectors.%(Filename)%(Extension) + + + FlexVectors.%(Filename)%(Extension) + DarVectors.%(Filename)%(Extension) diff --git a/Compression.Tests/ConversionMatrix/ConversionMatrixTests.cs b/Compression.Tests/ConversionMatrix/ConversionMatrixTests.cs index 5978a0c11..7f641bdd1 100644 --- a/Compression.Tests/ConversionMatrix/ConversionMatrixTests.cs +++ b/Compression.Tests/ConversionMatrix/ConversionMatrixTests.cs @@ -133,6 +133,7 @@ public static IEnumerable Grid() { { "Bbc", "[name/charset/size constraint] BBC Micro DFS disk; name-synthesizing FS, payload not found by content" }, { "Cpm", "[name/charset/size constraint] CP/M disk; 8.3-folding name-synth FS, payload not found by content" }, { "Lif", "[name/charset/size constraint] HP-71 LIF disk pads file content to a 256-byte record so bytes differ" }, + { "Flex", "[name/charset/size constraint] FLEX keeps no byte length: files are whole 252-byte sector records, zero-padded, so bytes differ" }, { "Creg", "[name/charset/size constraint] a Windows 9x hive's root key has no key-name record, so the matrix payload's root-level files have nowhere to live; CREG refuses them rather than inventing a key" }, { "Ps1MemoryCard", "[name/charset/size constraint] PS1 memory-card saves allocate whole 8 KiB blocks and arbitrary conversion payloads are zero-padded to the stored block size" }, { "Rt11", "[name/charset/size constraint] RT-11 disk pads content to a 512-byte block so bytes differ" }, diff --git a/Compression.Tests/Flex/FlexExternalConformanceTests.cs b/Compression.Tests/Flex/FlexExternalConformanceTests.cs new file mode 100644 index 000000000..14bab4a84 --- /dev/null +++ b/Compression.Tests/Flex/FlexExternalConformanceTests.cs @@ -0,0 +1,123 @@ +using Compression.Registry; +using FileSystem.Flex; + +namespace Compression.Tests.Flex; + +/// +/// Two-way oracle for FLEX disks with flexfloppy (laurent-fr/flextools, used as a +/// black box): it must catalogue and extract what we create and edit, and we must read +/// what it formats and adds — including into a disk we wrote. +/// +/// +/// Skips unless flexfloppy is found in WSL/Linux, at $CWB_FLEXFLOPPY, on the +/// PATH, or at ~/flex/flextools/flexfloppy/flexfloppy (where +/// Flex/ReferenceVectors/generate.sh expects it). +/// +[TestFixture] +[Category("ExternalFsInterop")] +[Category("Wsl")] +public sealed class FlexExternalConformanceTests { + + private string _tmp = null!; + + [SetUp] + public void SetUp() { + this._tmp = Path.Combine(Path.GetTempPath(), $"cwb_flex_{Guid.NewGuid():N}"); + Directory.CreateDirectory(this._tmp); + } + + [TearDown] + public void TearDown() { + try { Directory.Delete(this._tmp, true); } catch { /* best effort */ } + } + + private static string? _tool; + + private static string Tool() { + if (!FsInteropToolbox.WslAvailable) Assert.Ignore("WSL/Linux shell not available."); + _tool ??= FsInteropToolbox.RunWsl( + "for c in \"$CWB_FLEXFLOPPY\" \"$(command -v flexfloppy)\" \"$HOME/flex/flextools/flexfloppy/flexfloppy\"; do " + + "[ -n \"$c\" ] && [ -x \"$c\" ] && { echo \"$c\"; break; }; done").StdOut.Trim(); + if (_tool.Length == 0) Assert.Ignore("flexfloppy (https://github.com/laurent-fr/flextools) is not installed."); + return _tool; + } + + private static string W(string p) => FsInteropToolbox.WinToWsl(p); + + private (int Code, string Out) Ff(string args) { + var r = FsInteropToolbox.RunWsl($"'{Tool()}' {args} 2>&1"); + return (r.ExitCode, r.StdOut + r.StdErr); + } + + private static byte[] Padded(byte[] data) { + var p = new byte[Math.Max(1, (data.Length + 251) / 252) * 252]; + data.CopyTo(p, 0); + return p; + } + + [Test] + public void GivenOurDisk_WhenFlexfloppyExtractsIt_ThenEveryFileMatches() { + var files = new Dictionary { + ["HELLO.TXT"] = "HELLO\r"u8.ToArray(), + ["ONE.BIN"] = new byte[252], + ["BIG.DAT"] = Enumerable.Range(0, 5000).Select(i => (byte)(i * 13)).ToArray(), + }; + var img = Path.Combine(this._tmp, "ours.dsk"); + using (var fs = File.Create(img)) + new FlexFormatDescriptor().Create(fs, files.Select(kv => ArchiveInputInfo.InMemory(kv.Key, kv.Value)).ToList(), + new FormatCreateOptions { FormatSpecific = FormatCreateOptions.FormatSpecificFrom([new("VolumeLabel", "OURS")]) }); + + var cat = this.Ff($"--in {W(img)} --cat"); + Assert.That(cat.Code, Is.Zero, cat.Out); + Assert.That(cat.Out, Does.Contain("OURS")); + this.AssertFlexfloppyExtracts(img, files.ToDictionary(kv => kv.Key, kv => Padded(kv.Value))); + } + + [Test] + public void GivenFlexfloppyDisk_WhenWeAddAndRemoveAndFlexfloppyAddsMore_ThenBothSidesAgree() { + var img = Path.Combine(this._tmp, "theirs.dsk"); + var src = Path.Combine(this._tmp, "THEIRS.TXT"); + File.WriteAllBytes(src, Enumerable.Range(0, 700).Select(i => (byte)('A' + i % 26)).ToArray()); + Assert.That(this.Ff($"--new --tracks 40 --sectors 18 --label THEIRS --out {W(img)}").Code, Is.Zero); + Assert.That(this.Ff($"--in {W(img)} --add {W(src)}").Code, Is.Zero); + + // We read what flexfloppy wrote … + var disk = new FlexDisk(new MemoryStream(File.ReadAllBytes(img))); + var theirs = disk.Entries.Single(e => e.FullName == "THEIRS.TXT"); + Assert.That(disk.Extract(theirs).AsSpan(0, 700).ToArray(), Is.EqualTo(File.ReadAllBytes(src))); + + // … edit it in place … + var ours = Enumerable.Range(0, 3000).Select(i => (byte)(i ^ 0x5A)).ToArray(); + using (var fs = File.Open(img, FileMode.Open, FileAccess.ReadWrite)) { + var d = new FlexFormatDescriptor(); + d.Add(fs, [ArchiveInputInfo.InMemory("OURS.BIN", ours), ArchiveInputInfo.InMemory("GONE.TXT", [1, 2, 3])]); + d.Remove(fs, ["GONE.TXT"]); + } + + // … and flexfloppy adds into our edit and extracts everything. + var later = Path.Combine(this._tmp, "LATER.TXT"); + File.WriteAllBytes(later, "added by flexfloppy after us\r"u8.ToArray()); + var add = this.Ff($"--in {W(img)} --add {W(later)}"); + Assert.That(add.Code, Is.Zero, add.Out); + var extracted = this.AssertFlexfloppyExtracts(img, new() { ["OURS.BIN"] = Padded(ours) }); + Assert.That(extracted.Keys, Is.EquivalentTo(new[] { "THEIRS.TXT", "OURS.BIN", "LATER.TXT" }), "GONE.TXT stays deleted for flexfloppy"); + + var after = new FlexDisk(new MemoryStream(File.ReadAllBytes(img))); + Assert.That(after.Extract(after.Entries.Single(e => e.FullName == "LATER.TXT")).AsSpan(0, 29).ToArray(), Is.EqualTo(File.ReadAllBytes(later))); + Assert.That(after.Entries.Select(e => e.FullName), Is.EquivalentTo(extracted.Keys)); + } + + /// Runs flexfloppy --extract and checks the given files; returns everything it extracted. + private Dictionary AssertFlexfloppyExtracts(string img, Dictionary expected) { + var outDir = Path.Combine(this._tmp, $"x_{Guid.NewGuid():N}"); + Directory.CreateDirectory(outDir); + var r = this.Ff($"--in {W(img)} --extract {W(outDir)}"); + Assert.That(r.Code, Is.Zero, r.Out); + var got = Directory.GetFiles(outDir).ToDictionary(p => Path.GetFileName(p), File.ReadAllBytes); + foreach (var (n, d) in expected) { + Assert.That(got.ContainsKey(n), Is.True, $"flexfloppy did not extract {n}:\n{r.Out}"); + Assert.That(got[n], Is.EqualTo(d), $"{n} as flexfloppy extracts it"); + } + return got; + } +} diff --git a/Compression.Tests/Flex/FlexTests.cs b/Compression.Tests/Flex/FlexTests.cs new file mode 100644 index 000000000..f8cc0fc3b --- /dev/null +++ b/Compression.Tests/Flex/FlexTests.cs @@ -0,0 +1,223 @@ +using System.Buffers.Binary; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using Compression.Registry; +using FileSystem.Flex; + +namespace Compression.Tests.Flex; + +/// +/// FLEX disks: the reader against a disk flexfloppy formatted and filled (and, +/// when present in test-corpus/flex, the real TSC FLEX 9 distribution disk), the +/// writer's NEWDISK layout against the TSC Adaptation Guide, in-place edits and refusals. +/// The two-way flexfloppy oracle is . +/// +[TestFixture] +public class FlexTests { + + internal static byte[] Resource(string name) { + using var res = typeof(FlexTests).Assembly.GetManifestResourceStream($"FlexVectors.{name}") + ?? throw new InvalidOperationException($"{name} not embedded"); + using var ms = new MemoryStream(); + if (name.EndsWith(".gz", StringComparison.Ordinal)) { + using var gz = new GZipStream(res, CompressionMode.Decompress); + gz.CopyTo(ms); + } else res.CopyTo(ms); + return ms.ToArray(); + } + + internal static Dictionary Manifest(string name) + => Encoding.ASCII.GetString(Resource(name)).Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.TrimEnd('\r').Split(" ", 2)).ToDictionary(p => p[1], p => p[0]); + + internal static string Sha(byte[] d) => Convert.ToHexStringLower(SHA256.HashData(d)); + + private static Dictionary ReadAll(byte[] img) { + var d = new FlexDisk(new MemoryStream(img, writable: false)); + return d.Entries.ToDictionary(e => e.FullName, d.Extract); + } + + // ── third-party images ──────────────────────────────────────────────── + + [Test] + public void GivenFlexfloppyDisk_WhenRead_ThenEveryFileMatchesWhatFlexfloppyExtracts() { + var files = ReadAll(Resource("flexfloppy.dsk.gz")); + var manifest = Manifest("flexfloppy.manifest"); + Assert.That(files.Keys, Is.EquivalentTo(manifest.Keys)); + foreach (var (n, sha) in manifest) Assert.That(Sha(files[n]), Is.EqualTo(sha), n); + } + + [Test] + public void GivenRealTscFlex9Disk_WhenRead_ThenEveryFileMatchesWhatFlexfloppyExtracts() { + var path = Path.Combine(Documentation.FilesystemReadmeIsCurrentTests.RepositoryRoot(), "test-corpus", "flex", "TSCFLX09.dsk"); + if (!File.Exists(path)) + Assert.Ignore("test-corpus/flex/TSCFLX09.dsk (TSC FLEX 9.1 distribution disk, sha256 cd61d5f3…a5c) is not present; see Flex/ReferenceVectors/README.md."); + var img = File.ReadAllBytes(path); + var d = new FlexDisk(new MemoryStream(img)); + Assert.That((d.Label, d.VolumeNumber, d.Tracks, d.SectorsPerTrack, d.FreeSectors), Is.EqualTo(("FLEX-SWT", (ushort)6809, 35, 10, (ushort)161))); + var manifest = Manifest("TSCFLX09.manifest"); + var files = d.Entries.ToDictionary(e => e.FullName, d.Extract); + Assert.That(files.Keys, Is.EquivalentTo(manifest.Keys)); + foreach (var (n, sha) in manifest) Assert.That(Sha(files[n]), Is.EqualTo(sha), n); + Assert.That(d.Entries.Single(e => e.FullName == "ERRORS.SYS").IsRandom, Is.True, "ERRORS.SYS is a random file"); + Assert.That(d.Entries.Single(e => e.FullName == "FLEX.SYS").Created, Is.EqualTo(new DateTime(1980, 10, 3, 0, 0, 0, DateTimeKind.Utc))); + } + + // ── NEWDISK layout (Adaptation Guide 9.1) ───────────────────────────── + + [Test] + public void GivenEmptyDisk_WhenFormatted_ThenSirDirectoryAndFreeChainFollowNewdisk() { + var img = FlexWriter.Format(35, 10, "VOLUME", 7, new DateTime(1984, 5, 6)); + var sir = img.AsSpan(0x200, 256).ToArray(); + Assert.Multiple(() => { + Assert.That(sir.AsSpan(0, 16).IndexOfAnyExcept((byte)0), Is.EqualTo(-1), "bytes 0-15 zero"); + Assert.That(Encoding.ASCII.GetString(sir.AsSpan(16, 6)), Is.EqualTo("VOLUME")); + Assert.That(BinaryPrimitives.ReadUInt16BigEndian(sir.AsSpan(27)), Is.EqualTo(7), "volume number"); + Assert.That(sir.AsSpan(29, 4).ToArray(), Is.EqualTo(new byte[] { 1, 1, 34, 10 }), "first and last data sector"); + Assert.That(BinaryPrimitives.ReadUInt16BigEndian(sir.AsSpan(33)), Is.EqualTo(340), "34 tracks × 10 data sectors"); + Assert.That(sir.AsSpan(35, 3).ToArray(), Is.EqualTo(new byte[] { 5, 6, 84 }), "date month-day-year"); + Assert.That(sir[38], Is.EqualTo(34), "highest track"); + Assert.That(sir[39], Is.EqualTo(10), "highest sector"); + }); + // Directory sectors 5..10 on track 0 linked, last link zero. + Assert.That(img.AsSpan(4 * 256, 2).ToArray(), Is.EqualTo(new byte[] { 0, 6 })); + Assert.That(img.AsSpan(9 * 256, 2).ToArray(), Is.EqualTo(new byte[] { 0, 0 })); + // Free chain: last sector of track 1 links to track 2 sector 1; the disk's last sector ends it. + Assert.That(img.AsSpan((1 * 10 + 9) * 256, 2).ToArray(), Is.EqualTo(new byte[] { 2, 1 })); + Assert.That(img.AsSpan((34 * 10 + 9) * 256, 2).ToArray(), Is.EqualTo(new byte[] { 0, 0 })); + } + + // ── round trips and edits ───────────────────────────────────────────── + + private static byte[] Padded(byte[] data) { + var sectors = Math.Max(1, (data.Length + 251) / 252); + var p = new byte[sectors * 252]; + data.CopyTo(p, 0); + return p; + } + + [TestCase(0)] + [TestCase(1)] + [TestCase(251)] + [TestCase(252)] + [TestCase(253)] + [TestCase(20_000)] + public void GivenFileOfBoundarySize_WhenWrittenAndRead_ThenBytesComeBackInWholeRecords(int size) { + var data = Enumerable.Range(0, size).Select(i => (byte)(i * 7 + 1)).ToArray(); + var img = FlexWriter.Format(35, 10); + FlexWriter.AddFile(img, "f.bin", data); + Assert.That(ReadAll(img)["F.BIN"], Is.EqualTo(Padded(data))); + } + + [Test] + public void GivenMoreFilesThanTrackZeroHolds_WhenAdded_ThenTheDirectoryGrowsOntoTheFreeChain() { + var img = FlexWriter.Format(35, 10); // 6 directory sectors = 60 entries + for (var i = 0; i < 65; ++i) FlexWriter.AddFile(img, $"F{i}.DAT", [(byte)i]); + var d = new FlexDisk(new MemoryStream(img)); + Assert.That(d.Entries, Has.Count.EqualTo(65)); + Assert.That(d.DirectorySectors, Has.Count.EqualTo(7)); + Assert.That(d.DirectorySectors[^1].Track, Is.Not.Zero); + Assert.That(d.FreeSectors, Is.EqualTo(340 - 65 - 1)); + } + + [Test] + public void GivenFile_WhenRemoved_ThenEntryIsDeletedSectorsZeroedAndReturnedToTheFreeChain() { + var img = FlexWriter.Format(35, 10); + FlexWriter.AddFile(img, "KEEP.TXT", "keep"u8); + FlexWriter.AddFile(img, "DROP.TXT", Encoding.ASCII.GetBytes(new string('W', 600))); + Assert.That(FlexWriter.RemoveFile(img, "drop.txt"), Is.True); + Assert.That(FlexWriter.RemoveFile(img, "drop.txt"), Is.False); + var d = new FlexDisk(new MemoryStream(img)); + Assert.That(d.Entries.Select(e => e.FullName), Is.EqualTo(new[] { "KEEP.TXT" })); + Assert.That(d.FreeSectors, Is.EqualTo(339)); + Assert.That(Encoding.ASCII.GetString(img).Contains("WWWW"), Is.False); + Assert.That(img[0x400 + 16 + 24], Is.EqualTo(0xFF), "deleted entry's first byte"); + // The freed sectors are reused by the next file once the chain head is exhausted: + // the free chain still adds up. + FlexWriter.AddFile(img, "NEW.TXT", new byte[300]); + Assert.That(new FlexDisk(new MemoryStream(img)).FreeSectors, Is.EqualTo(337)); + } + + [Test] + public void GivenFlexfloppyDisk_WhenWeAddAndRemove_ThenItsOwnFilesAreUntouched() { + var img = Resource("flexfloppy.dsk.gz"); + using var ms = new MemoryStream(); + ms.Write(img); + var d = new FlexFormatDescriptor(); + d.Add(ms, [ArchiveInputInfo.InMemory("ADDED.DAT", new byte[1000])]); + d.Remove(ms, ["HELLO.TXT"]); + Assert.That(ms.Length, Is.EqualTo(img.Length)); + var files = ReadAll(ms.ToArray()); + var manifest = Manifest("flexfloppy.manifest"); + Assert.That(files.Keys, Is.EquivalentTo(new[] { "NUMBERS.TXT", "EXACT.BIN", "RANDOM.BIN", "ADDED.DAT" })); + foreach (var n in new[] { "NUMBERS.TXT", "EXACT.BIN", "RANDOM.BIN" }) Assert.That(Sha(files[n]), Is.EqualTo(manifest[n]), n); + } + + [Test] + public void GivenDescriptorCreate_WhenTreeIsGiven_ThenFilesLandByLeafNameAndGeometryFits() { + var d = new FlexFormatDescriptor(); + using var ms = new MemoryStream(); + d.Create(ms, [ArchiveInputInfo.InMemory("HELLO.TXT", "hi"u8.ToArray()), new ArchiveInputInfo("", "SUB/", true), ArchiveInputInfo.InMemory("SUB/INNER.TXT", new byte[100_000])], + new FormatCreateOptions()); + var disk = new FlexDisk(new MemoryStream(ms.ToArray())); + Assert.That(disk.Entries.Select(e => e.FullName), Is.EquivalentTo(new[] { "HELLO.TXT", "INNER.TXT" })); + Assert.That((disk.Tracks, disk.SectorsPerTrack), Is.EqualTo((40, 18)), "100 KB (398 sectors) outgrows 35×10 but fits 40×18"); + } + + // ── refusals ────────────────────────────────────────────────────────── + + [TestCase("NOEXT", TestName = "Flex_NoExtension_Refused")] + [TestCase("1ABC.TXT", TestName = "Flex_NameStartsWithDigit_Refused")] + [TestCase("TOOLONGNAME.TXT", TestName = "Flex_NineCharName_Refused")] + [TestCase("A.TEXT", TestName = "Flex_FourCharExtension_Refused")] + [TestCase("A B.TXT", TestName = "Flex_Space_Refused")] + public void GivenUnstorableName_WhenAdded_ThenRefused(string name) + => Assert.Throws(() => FlexWriter.AddFile(FlexWriter.Format(35, 10), name, [1])); + + [Test] + public void GivenDiskTooFull_WhenAddingABatch_ThenRefusedAndImageUnchanged() { + using var ms = new MemoryStream(); + ms.Write(FlexWriter.Format(35, 10)); + var before = ms.ToArray(); + var d = new FlexFormatDescriptor(); + Assert.Throws(() => d.Add(ms, + [ArchiveInputInfo.InMemory("OK.DAT", [1]), ArchiveInputInfo.InMemory("HUGE.DAT", new byte[341 * 252])])); + Assert.That(ms.ToArray(), Is.EqualTo(before)); + } + + [Test] + public void GivenProtectedFile_WhenRemoved_ThenRefused() { + var img = FlexWriter.Format(35, 10); + FlexWriter.AddFile(img, "LOCK.SYS", [1]); + img[0x400 + 16 + 11] = 0x40; // delete protect + Assert.Throws(() => FlexWriter.RemoveFile(img, "LOCK.SYS")); + } + + [Test] + public void GivenFreeChainDisagreeingWithSir_WhenEdited_ThenRefused() { + var img = FlexWriter.Format(35, 10); + BinaryPrimitives.WriteUInt16BigEndian(img.AsSpan(0x200 + 33), 5); + Assert.Throws(() => FlexWriter.AddFile(img, "A.TXT", [1])); + } + + [Test] + public void GivenChainShorterThanTheDirectorySays_WhenExtracted_ThenRefused() { + var img = FlexWriter.Format(35, 10); + FlexWriter.AddFile(img, "A.TXT", new byte[1000]); + BinaryPrimitives.WriteUInt16BigEndian(img.AsSpan(0x400 + 16 + 17), 9); + var d = new FlexDisk(new MemoryStream(img)); + Assert.Throws(() => d.Extract(d.Entries.Single())); + } + + [Test] + public void GivenNonFlexBytes_WhenOpened_ThenInvalidData() { + var img = FlexWriter.Format(35, 10); + img[0x200] = 1; + Assert.Throws(() => new FlexDisk(new MemoryStream(img))); + } + + [Test] + public void GivenFlexDisk_WhenDetectedByMagic_ThenFlex() + => Assert.That(Compression.Lib.FormatDetector.DetectByMagic(Resource("flexfloppy.dsk.gz")).ToString(), Is.EqualTo("Flex")); +} diff --git a/Compression.Tests/Flex/ReferenceVectors/README.md b/Compression.Tests/Flex/ReferenceVectors/README.md new file mode 100644 index 000000000..5ce2bffeb --- /dev/null +++ b/Compression.Tests/Flex/ReferenceVectors/README.md @@ -0,0 +1,18 @@ +# FLEX reference vectors + +| File | What it is | +| --- | --- | +| `flexfloppy.dsk.gz` | 35 × 10 disk that `flexfloppy` (laurent-fr/flextools, commit `7cb69053`, built with gcc on Ubuntu 24.04) formatted, label `CWBFIXTURE`, volume 42, and filled with four files; recipe `generate.sh`; gzipped only to keep the repository small | +| `flexfloppy.manifest` | `sha256sum` of what the same `flexfloppy --extract` returns for that disk | +| `TSCFLX09.manifest` | `sha256sum` of what `flexfloppy --extract` returns for TSC's FLEX 9.1 distribution disk `TSCFLX09.DSK` | + +The TSC disk is not committed, because the software on it belongs to its publisher. Get it +from the FUFU archive +(; +89,600 bytes, sha256 +`cd61d5f398703822a3c064fcd3db8e004f0dd94736c08a5c24eaaa174ac6da5c`) and put it at +`test-corpus/flex/TSCFLX09.dsk`. That directory is git-ignored; the test that reads the +disk is skipped while it is absent. + +Quirk observed in `flexfloppy`: it stores a 252-byte file in two sectors, so `EXACT.BIN` +extracts as 504 bytes. Our reader reproduces exactly the sectors it wrote. diff --git a/Compression.Tests/Flex/ReferenceVectors/TSCFLX09.manifest b/Compression.Tests/Flex/ReferenceVectors/TSCFLX09.manifest new file mode 100644 index 000000000..a9c80e2bd --- /dev/null +++ b/Compression.Tests/Flex/ReferenceVectors/TSCFLX09.manifest @@ -0,0 +1,30 @@ +3b0b830916d93d5cb775c0455144f8b32b0faeb87fc7c064e6a5350d2b7b01ba APPEND.CMD +ed42276a28073ec14d1df65ef348fb73fc43162c4984493f5603762365c006ed ASMB.CMD +0e0dea13fb57c91c75913515fa9bc12c292fbcd12dc27b37f1f86f1a161577c3 ASN.CMD +284d9bd9984f299d4ee8e3dfb972d6a1ce1cfe47c8eec4e02131dbede8617a3f BUILD.CMD +ae72f404a8cebbe82f3f11ce93f0395346840168e8d45850ea2fda932ced7b37 CAT.CMD +fa2e48db4178eece4541593d94297574e499499f249797bd06e5cb00abc14e6f DATE.CMD +e40224fe5c2394d233e547a8ce261bf22f6449c0fccb7056d101e3494441439e DELETE.CMD +c73abd131ac386d37d90ce94edee24543ab8c6478a0907028a7f5008cdd03c1f EDIT.CMD +a63f7d84f0b495f78d67f13efdbc2ffb7b4779119908b8c2c5cfb6121cfd21d3 ERRORS.SYS +1d5b11374222a81afcafd004471cf29a85c019610cfda4caacce165ff5439795 EXEC.CMD +73f2ddbb54c74c54e92e16d9ef27583ad64932a6cfcb1826a2c34955e8c168cd FLEX.COR +e33e706f058bbc94de18f008345833c05aca5ba80b1601c06d6c02aae4aa9f92 FLEX.SYS +56758c31b702c752e771ec518e981c5f92319b0028d261e0b9f32e37136bf43f I.CMD +9ccdc538fc4db8b02c7997d37f268b9ea0d810cefa261fe36a4ef5e8a1b442b5 JUMP.CMD +47ec6eb9222b0f9e58b131b67c3b984bd3ece5733ec28750776c293aba451f0b LINK.CMD +e5f92a0b9d59cb885d73a5cb812bb9d7ea16a50facb07bc6b390194ec4365989 LIST.CMD +e62eeeffe5b8112cbf95ee74ab88e793635d815811030cc387400711e9bb81bc NEWDISK.CMD +09167c8cdcaac7a0e2ddcd5e0fe827464ecc850707b0f34642afa802ec939c58 O.CMD +9d053a14f9fe0163fa2e6cb61046048eaf634a1afe9b1c7906b697092d92ab5e P.CMD +0c2f0003998cbca9d1b48a505fb1f95705bac8f79f3d1b9b95ccda31462f119b PRINT.CMD +b7bd8b1a41226a4cc30379afa66362547aac03dbcf596ac3662965687709a4b5 PRINT.SYS +bf825c5fea29a4cf91e4d94bd7f38ef6aa612dcccba1f35209d8ac84ab4ba46a PROT.CMD +55d0e473deca9747eba31ab73e7a5be60c9ee4fe8fd537f065bf13c807ac2ab6 QCHECK.CMD +e5fa5afdc842f9bbea19880d51050ad502bd731e5082bd3f04e9d0cc83f07c5e RENAME.CMD +57791ae6d575711c29ceabd4f024f02149d12b3b03469fa733d6f38b9c20b7cf SAVE.CMD +a2c19e01e7195dfe34d69dc56d1d36fea45566faa4d2e0ae34e2f384036d4404 SAVE.LOW +99868920b5a1a521f1ecfdac92b97fccf80fe42cc35d12fb874068b0e20db7e7 TTYSET.CMD +fbbe36ac893a478b7f630734c38c223de1c74a158cff3830c21684fd3015824f VERIFY.CMD +6220b9d08052b47bb68b6b0bc8e33921a16542c8ce9732e174230e46a63f0950 VERSION.CMD +b2da73c6a7dd9c85f0be859a5b04c688d319358f3c444d8ccc6726f581bbcd38 XOUT.CMD diff --git a/Compression.Tests/Flex/ReferenceVectors/flexfloppy.dsk.gz b/Compression.Tests/Flex/ReferenceVectors/flexfloppy.dsk.gz new file mode 100644 index 0000000000000000000000000000000000000000..3869346032305a3085d6d61c369ad6f7bfa729de GIT binary patch literal 4747 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 [path-to-flexfloppy] +set -e +out="${1:?usage: generate.sh [flexfloppy]}"; ff="${2:-$HOME/flex/flextools/flexfloppy/flexfloppy}" +mkdir -p "$out"; d=$(mktemp -d) +$ff --new --tracks 35 --sectors 10 --label CWBFIXTURE --number 42 --out $d/flexfloppy.dsk +mkdir $d/in; cd $d/in +printf 'HELLO FROM FLEXFLOPPY\r' > HELLO.TXT +seq 1 400 | tr '\n' '\r' > NUMBERS.TXT # 1492 bytes: six sectors, the last one partial +head -c 252 /dev/urandom > EXACT.BIN # exactly one sector +head -c 2000 /dev/urandom > RANDOM.BIN +for f in HELLO.TXT NUMBERS.TXT EXACT.BIN RANDOM.BIN; do $ff --in $d/flexfloppy.dsk --add $f; done +$ff --in $d/flexfloppy.dsk --cat +mkdir $d/x; $ff --in $d/flexfloppy.dsk --extract $d/x >/dev/null +(cd $d/x && sha256sum *) > "$out/flexfloppy.manifest" +gzip -9 -n -c $d/flexfloppy.dsk > "$out/flexfloppy.dsk.gz" diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexDisk.cs b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexDisk.cs new file mode 100644 index 000000000..4904f7238 --- /dev/null +++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexDisk.cs @@ -0,0 +1,182 @@ +using System.Buffers.Binary; +using System.Text; + +namespace FileSystem.Flex; + +/// One directory entry of a FLEX disk. +/// File name (up to 8 characters). +/// Extension (up to 3 characters). +/// Protection bits: 0x80 write, 0x40 delete, 0x20 read, 0x10 catalog protect. +/// Track of the first sector. +/// Sector of the first sector. +/// Track of the last sector. +/// Sector of the last sector. +/// Sectors in the file. +/// File Sector Map indicator: a random file whose first two sectors are its sector map. +/// Creation month. +/// Creation day. +/// Creation year, tens and units only. +/// Byte offset of the entry in the image. +public sealed record FlexEntry(string Name, string Extension, byte Attributes, byte StartTrack, byte StartSector, + byte EndTrack, byte EndSector, ushort Sectors, bool IsRandom, byte Month, byte Day, byte Year, long EntryOffset) { + /// NAME.EXT as FLEX shows it. + public string FullName => this.Extension.Length == 0 ? this.Name : this.Name + "." + this.Extension; + + /// Bytes the file holds: every sector's 252 data bytes. + public long Size => (long)this.Sectors * FlexDisk.DataBytes; + + /// Creation date, when it is a valid one (years 00-74 read as 20xx, 75-99 as 19xx). + public DateTime? Created { + get { + if (this.Month is < 1 or > 12 || this.Day is < 1 or > 31 || this.Year > 99) return null; + var year = this.Year < 75 ? 2000 + this.Year : 1900 + this.Year; + return this.Day <= DateTime.DaysInMonth(year, this.Month) ? new DateTime(year, this.Month, this.Day, 0, 0, 0, DateTimeKind.Utc) : null; + } + } +} + +/// +/// The FLEX (Technical Systems Consultants, 6800/6809) disk layout over a raw .dsk +/// image: 256-byte sectors, every track holding the same number of sector slots, sector +/// numbers from 1. Track 0 holds the boot sectors (1, 2), the System Information Record +/// (3), a reserved sector (4) and the directory chain from sector 5; all other sectors are +/// file chains or the free chain. See docs/FLEX-ON-DISK.md. +/// +public sealed class FlexDisk { + /// Bytes per sector. + public const int SectorSize = 256; + + /// Data bytes per sector after the link (2) and record number (2). + public const int DataBytes = 252; + + /// Directory entries per directory sector, after its 16-byte header. + public const int EntriesPerSector = 10; + + /// Bytes per directory entry. + public const int EntrySize = 24; + + /// Byte offset of the System Information Record (track 0, sector 3). + public const int SirOffset = 2 * SectorSize; + + private readonly Stream _stream; + + /// Sectors per track (SIR "highest sector number"). + public int SectorsPerTrack { get; } + + /// Tracks (SIR "highest track number" + 1). + public int Tracks { get; } + + /// Volume label (11 characters). + public string Label { get; } + + /// Volume number. + public ushort VolumeNumber { get; } + + /// Head of the free chain. + public (byte Track, byte Sector) FirstFree { get; } + + /// Tail of the free chain. + public (byte Track, byte Sector) LastFree { get; } + + /// Sectors in the free chain, as the SIR records it. + public ushort FreeSectors { get; } + + /// Every live directory entry, in directory order. + public IReadOnlyList Entries { get; } + + /// Directory sectors in chain order. + public IReadOnlyList<(byte Track, byte Sector)> DirectorySectors { get; } + + /// Opens a FLEX image; throws when it is not one. + public FlexDisk(Stream stream) { + ArgumentNullException.ThrowIfNull(stream); + this._stream = stream; + if (stream.Length < 5 * SectorSize) throw new InvalidDataException("Image too short for a FLEX disk (needs track 0 sectors 1-5)."); + var sir = this.ReadAt(SirOffset, SectorSize); + if (sir.AsSpan(0, 16).IndexOfAnyExcept((byte)0) >= 0) + throw new InvalidDataException("Not a FLEX disk: the System Information Record's first 16 bytes are not zero."); + this.Tracks = sir[0x26] + 1; + this.SectorsPerTrack = sir[0x27]; + if (this.SectorsPerTrack < 5 || this.Tracks < 2) + throw new InvalidDataException($"Not a FLEX disk: geometry {this.Tracks} tracks × {this.SectorsPerTrack} sectors from the SIR."); + this.Label = Encoding.ASCII.GetString(sir, 0x10, 11).TrimEnd('\0', ' '); + this.VolumeNumber = BinaryPrimitives.ReadUInt16BigEndian(sir.AsSpan(0x1B)); + this.FirstFree = (sir[0x1D], sir[0x1E]); + this.LastFree = (sir[0x1F], sir[0x20]); + this.FreeSectors = BinaryPrimitives.ReadUInt16BigEndian(sir.AsSpan(0x21)); + + var dirs = new List<(byte, byte)>(); + var entries = new List(); + foreach (var (t, s, sector) in this.Chain(0, 5, limit: this.Tracks * this.SectorsPerTrack)) { + dirs.Add((t, s)); + var at = this.Offset(t, s); + for (var i = 0; i < EntriesPerSector; ++i) { + var e = sector.AsSpan(16 + i * EntrySize, EntrySize); + if (e[0] == 0 || (e[0] & 0x80) != 0) continue; // never used / deleted + entries.Add(new FlexEntry( + Text(e[..8]), Text(e.Slice(8, 3)), e[11], e[13], e[14], e[15], e[16], + BinaryPrimitives.ReadUInt16BigEndian(e[17..]), e[19] != 0, e[21], e[22], e[23], + at + 16 + i * EntrySize)); + } + } + this.DirectorySectors = dirs; + this.Entries = entries; + } + + private static string Text(ReadOnlySpan field) { + var end = field.IndexOf((byte)0); + return Encoding.ASCII.GetString(end < 0 ? field : field[..end]).TrimEnd(' '); + } + + /// Byte offset of a sector; throws when it is outside the geometry or the image. + public long Offset(int track, int sector) { + if (track >= this.Tracks || sector < 1 || sector > this.SectorsPerTrack) + throw new InvalidDataException($"FLEX sector {track},{sector} lies outside the disk's {this.Tracks}×{this.SectorsPerTrack} geometry."); + var at = ((long)track * this.SectorsPerTrack + sector - 1) * SectorSize; + if (at + SectorSize > this._stream.Length) + throw new InvalidDataException($"FLEX sector {track},{sector} lies past the end of the image."); + return at; + } + + /// Reads one sector. + public byte[] ReadSector(int track, int sector) => this.ReadAt(this.Offset(track, sector), SectorSize); + + /// Follows a link chain; throws on a loop or on more than sectors. + public IEnumerable<(byte Track, byte Sector, byte[] Data)> Chain(byte track, byte sector, int limit) { + var seen = new HashSet(); + while (track != 0 || sector != 0) { + if (!seen.Add(track << 8 | sector) || seen.Count > limit) + throw new InvalidDataException($"FLEX sector chain loops or overruns at {track},{sector}."); + var data = this.ReadSector(track, sector); + yield return (track, sector, data); + (track, sector) = (data[0], data[1]); + } + } + + /// + /// The file's bytes: the 252 data bytes of every sector in its chain — a random file's + /// two File Sector Map sectors included, exactly as they are stored. Refuses a chain that + /// does not end at the directory's end address after the directory's sector count. + /// + public byte[] Extract(FlexEntry entry) { + ArgumentNullException.ThrowIfNull(entry); + var result = new byte[entry.Size]; + var n = 0; + (byte T, byte S) last = (0, 0); + foreach (var (t, s, data) in this.Chain(entry.StartTrack, entry.StartSector, entry.Sectors)) { + data.AsSpan(4, DataBytes).CopyTo(result.AsSpan(n * DataBytes)); + ++n; + last = (t, s); + } + if (n != entry.Sectors || last != (entry.EndTrack, entry.EndSector)) + throw new InvalidDataException($"FLEX file {entry.FullName}: its chain holds {n} sectors ending at {last.T},{last.S}, the directory says {entry.Sectors} ending at {entry.EndTrack},{entry.EndSector}."); + return result; + } + + private byte[] ReadAt(long offset, int count) { + var buf = new byte[count]; + this._stream.Position = offset; + this._stream.ReadExactly(buf); + return buf; + } +} diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexFormatDescriptor.cs b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexFormatDescriptor.cs new file mode 100644 index 000000000..9447466fe --- /dev/null +++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexFormatDescriptor.cs @@ -0,0 +1,162 @@ +#pragma warning disable CS1591 +using Compression.Registry; +using Compression.Registry.Streaming; +using static Compression.Registry.FormatHelpers; + +namespace FileSystem.Flex; + +/// +/// FLEX disk images (Technical Systems Consultants' FLEX for the 6800/6809, 1976-): raw +/// .dsk sector dumps with 256-byte linked sectors, a System Information Record on +/// track 0 sector 3 and a directory chain from track 0 sector 5. +/// +/// +/// +/// Written from TSC's own manuals — the 6809 FLEX Adaptation Guide (disk format, +/// NEWDISK, SIR layout) and the FLEX Advanced Programmer's Guide (directory entry, +/// data sector, deleted entries, random files); see docs/FLEX-ON-DISK.md. +/// +/// +/// R/W in place: Add takes sectors from the head of the free chain and a free or deleted +/// directory slot (growing the directory chain when full); Remove marks the entry deleted, +/// zeroes the file's sectors and appends them to the free chain. Edits run on a copy and +/// are written back only when every step succeeded. FLEX keeps no byte length, so file +/// contents are whole 252-byte records (padded with zeros on write). Proof: the real TSC +/// FLEX 9 distribution disk reads byte-identical to what flexfloppy (laurent-fr/flextools) +/// extracts, and flexfloppy lists and extracts what we write and edit, while we read +/// what it formats and adds. +/// +/// +public sealed class FlexFormatDescriptor : IFormatDescriptor, IArchiveFormatOperations, IArchiveCreatable, IArchiveModifiable, IFormatOptionsSchema { + + public IReadOnlyList OptionsSchema { get; } = [ + new FormatOptionDescriptor(Key: "VolumeLabel", DisplayName: "Volume Label", Kind: FormatOptionKind.String, Default: "", + Description: "SIR volume name (11 characters).", IsOptimizationAxis: false), + new FormatOptionDescriptor(Key: "Tracks", DisplayName: "Tracks", Kind: FormatOptionKind.Integer, Default: "0", + Description: "Tracks on the disk (0 = smallest standard geometry that fits: 35×10, 40×18, 77×15, 80×36, 255×255).", IsOptimizationAxis: false), + new FormatOptionDescriptor(Key: "SectorsPerTrack", DisplayName: "Sectors per track", Kind: FormatOptionKind.Integer, Default: "0", + Description: "256-byte sectors per track (0 = with Tracks, the standard geometry).", IsOptimizationAxis: false), + ]; + + public string Id => "Flex"; + public string DisplayName => "FLEX disk (6800/6809)"; + public FormatCategory Category => FormatCategory.Archive; + public FormatCapabilities Capabilities => + FormatCapabilities.CanList | FormatCapabilities.CanExtract | FormatCapabilities.CanTest | + FormatCapabilities.CanCreate | FormatCapabilities.CanModify | FormatCapabilities.SupportsMultipleEntries; + + public string DefaultExtension => ".flx"; + public IReadOnlyList Extensions => [".flx", ".dsk"]; + public IReadOnlyList CompoundExtensions => []; + + /// + /// FLEX has no magic number. What every FLEX disk shares: the SIR (track 0 sector 3) + /// starts with 16 zero bytes, and the first directory sector (track 0 sector 5) links to + /// track 0 sector 6 and has twelve zero bytes after its link and record number. + /// + public IReadOnlyList MagicSignatures { get; } = [BuildSignature()]; + + private static MagicSignature BuildSignature() { + var bytes = new byte[0x410 - 0x200]; + var mask = new byte[bytes.Length]; + for (var i = 0; i < 16; ++i) mask[i] = 0xFF; // SIR bytes 0-15 zero + bytes[0x201] = 0x06; // dir sector link: track 0, sector 6 + for (var i = 0x200; i < 0x210; ++i) mask[i] = 0xFF; // link 00 06, then 14 zero bytes + return new(bytes, Offset: 0x200, Confidence: 0.40, Mask: mask); + } + + public IReadOnlyList Methods => [new("stored", "Stored")]; + public string? TarCompressionFormatId => null; + public AlgorithmFamily Family => AlgorithmFamily.Archive; + public string Description => + "FLEX (TSC 6800/6809) disk image: linked 256-byte sectors, SIR and directory on track 0; " + + "in-place R/W checked against flexfloppy and the TSC FLEX 9 distribution disk."; + + public List List(Stream stream, string? password) { + var d = new FlexDisk(stream); + return d.Entries.Select((e, i) => new ArchiveEntryInfo(i, e.FullName, e.Size, e.Size, "Stored", false, false, e.Created)).ToList(); + } + + public void Extract(Stream stream, string outputDir, string? password, string[]? files) { + var d = new FlexDisk(stream); + foreach (var e in d.Entries) { + if (files != null && !MatchesFilter(e.FullName, files)) continue; + WriteFile(outputDir, e.FullName, d.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 d = new FlexDisk(archive); + var e = d.Entries.FirstOrDefault(x => string.Equals(x.FullName, entryName, StringComparison.OrdinalIgnoreCase)) + ?? throw new FileNotFoundException($"FLEX disk has no file '{entryName}'.", entryName); + return d.Extract(e); + } + + /// Formats a disk and writes the files by leaf name (FLEX has one flat directory). + public void Create(Stream output, IReadOnlyList inputs, FormatCreateOptions options) { + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(inputs); + ArgumentNullException.ThrowIfNull(options); + var files = inputs.Where(i => !i.IsDirectory) + .Select(i => (Name: Path.GetFileName(i.ArchiveName.Replace('\\', '/').TrimEnd('/')), Data: i.ReadContent(), When: i.Metadata?.LastWriteTimeUtc?.UtcDateTime)) + .ToList(); + foreach (var f in files) FlexWriter.EncodeName(f.Name); // refuse before formatting + var sectors = files.Sum(f => (long)Math.Max(1, (f.Data.Length + FlexDisk.DataBytes - 1) / FlexDisk.DataBytes)); + + var tracks = options.GetOptionInt("Tracks", 0); + var spt = options.GetOptionInt("SectorsPerTrack", 0); + if (tracks == 0 || spt == 0) (tracks, spt) = FlexWriter.ChooseGeometry(sectors, files.Count); + var img = FlexWriter.Format(tracks, spt, options.GetOption("VolumeLabel", "")); + foreach (var f in files) { + if (files.Count(x => string.Equals(x.Name, f.Name, StringComparison.OrdinalIgnoreCase)) > 1) + throw new NotSupportedException($"FLEX cannot hold two files named '{f.Name}'."); + FlexWriter.AddFile(img, f.Name, f.Data, f.When); + } + output.Write(img); + } + + /// Adds or replaces files in place; all inputs land or none do. + public void Add(Stream archive, IReadOnlyList inputs) { + ArgumentNullException.ThrowIfNull(inputs); + Transact(archive, img => { + foreach (var input in inputs) { + if (input.IsDirectory || input.ArchiveName.Contains('/') || input.ArchiveName.Contains('\\')) + throw new NotSupportedException($"FLEX has no directories; '{input.ArchiveName}' cannot be stored."); + FlexWriter.AddFile(img, input.ArchiveName, input.ReadContent(), input.Metadata?.LastWriteTimeUtc?.UtcDateTime); + } + }); + } + + /// Deletes the named files in place; unknown names are ignored. + public void Remove(Stream archive, string[] entryNames) { + ArgumentNullException.ThrowIfNull(entryNames); + Transact(archive, img => { + foreach (var n in entryNames) FlexWriter.RemoveFile(img, n); + }); + } + + private static void Transact(Stream archive, Action edit) { + ArgumentNullException.ThrowIfNull(archive); + if (!archive.CanSeek || !archive.CanWrite) throw new NotSupportedException("FLEX edits need a seekable, writable image."); + var original = new byte[archive.Length]; + archive.Position = 0; + archive.ReadExactly(original); + var img = (byte[])original.Clone(); + edit(img); + for (var off = 0; off < img.Length; off += FlexDisk.SectorSize) { + var n = Math.Min(FlexDisk.SectorSize, img.Length - off); + if (img.AsSpan(off, n).SequenceEqual(original.AsSpan(off, n))) continue; + archive.Position = off; + archive.Write(img, off, n); + } + archive.Flush(); + } +} diff --git a/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexWriter.cs b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexWriter.cs new file mode 100644 index 000000000..ec326f02a --- /dev/null +++ b/Hawkynt.FileFormats.FileSystems/FileSystems/FileSystem.Flex/FlexWriter.cs @@ -0,0 +1,205 @@ +using System.Buffers.Binary; +using System.Text; + +namespace FileSystem.Flex; + +/// +/// Formats FLEX disks the way NEWDISK does and edits them the way the File Management +/// System does: files are written by taking sectors from the head of the free chain, +/// deleted files have their name's first byte set to $FF (negative) and their chain appended +/// to the tail of the free chain. The image is edited in memory; callers write it back. +/// +/// +/// FLEX stores whole sectors of 252 data bytes and keeps no byte length, so a file whose +/// length is not a multiple of 252 is padded with zeros — what FLEX itself reads back. A +/// zero-length file takes one zero sector, the least a FLEX file can hold. Names are +/// folded to capitals and must fit FLEX's rule: a letter, then letters, digits, hyphens +/// or underscores, at most 8 characters, with an extension of 1-3 such characters. +/// +public static class FlexWriter { + /// Standard geometries tried, smallest first, when none is given. + public static readonly (int Tracks, int Sectors)[] Geometries = [(35, 10), (40, 18), (77, 15), (80, 36), (255, 255)]; + + /// Formats an empty disk. + public static byte[] Format(int tracks, int sectorsPerTrack, string label = "", ushort volumeNumber = 0, DateTime? date = null) { + if (tracks is < 2 or > 256 || sectorsPerTrack is < 5 or > 255) + throw new NotSupportedException($"FLEX geometry {tracks}×{sectorsPerTrack} is outside 2-256 tracks × 5-255 sectors."); + var img = new byte[(long)tracks * sectorsPerTrack * FlexDisk.SectorSize]; + + // Directory: track 0 sectors 5..last, linked, zeroed, last link 0. + for (var s = 5; s < sectorsPerTrack; ++s) { + var at = Offset(sectorsPerTrack, 0, s); + img[at] = 0; img[at + 1] = (byte)(s + 1); + } + // Free chain: every sector of tracks 1.., in order, last link 0. + for (var t = 1; t < tracks; ++t) + for (var s = 1; s <= sectorsPerTrack; ++s) { + var at = Offset(sectorsPerTrack, t, s); + var (nt, ns) = s < sectorsPerTrack ? (t, s + 1) : (t + 1, 1); + if (nt >= tracks) (nt, ns) = (0, 0); + img[at] = (byte)nt; img[at + 1] = (byte)ns; + } + + var sir = img.AsSpan(FlexDisk.SirOffset, FlexDisk.SectorSize); + var name = Encoding.ASCII.GetBytes(label.ToUpperInvariant()); + name.AsSpan(0, Math.Min(name.Length, 11)).CopyTo(sir[0x10..]); + BinaryPrimitives.WriteUInt16BigEndian(sir[0x1B..], volumeNumber); + sir[0x1D] = 1; sir[0x1E] = 1; + sir[0x1F] = (byte)(tracks - 1); sir[0x20] = (byte)sectorsPerTrack; + BinaryPrimitives.WriteUInt16BigEndian(sir[0x21..], checked((ushort)((tracks - 1) * sectorsPerTrack))); + WriteDate(sir[0x23..], date ?? DateTime.UtcNow); + sir[0x26] = (byte)(tracks - 1); + sir[0x27] = (byte)sectorsPerTrack; + return img; + } + + /// Picks the smallest standard geometry whose data area holds sectors. + public static (int Tracks, int Sectors) ChooseGeometry(long sectorsNeeded, int directoryEntries) { + foreach (var (t, s) in Geometries) { + var dirSlots = (s - 4) * FlexDisk.EntriesPerSector; + var extraDir = directoryEntries > dirSlots ? (directoryEntries - dirSlots + FlexDisk.EntriesPerSector - 1) / FlexDisk.EntriesPerSector : 0; + if ((long)(t - 1) * s >= sectorsNeeded + extraDir && (long)(t - 1) * s <= ushort.MaxValue) return (t, s); + } + throw new NotSupportedException($"FLEX disks hold at most {ushort.MaxValue} data sectors; {sectorsNeeded} are needed."); + } + + /// Encodes a host name as FLEX name and extension, or throws . + public static (byte[] Name, byte[] Ext) EncodeName(string fileName) { + ArgumentNullException.ThrowIfNull(fileName); + var up = fileName.ToUpperInvariant(); + var dot = up.LastIndexOf('.'); + var name = dot < 0 ? up : up[..dot]; + var ext = dot < 0 ? "" : up[(dot + 1)..]; + static bool Valid(string s, int max) => s.Length is > 0 && s.Length <= max && s[0] is >= 'A' and <= 'Z' + && s.All(c => c is >= 'A' and <= 'Z' or >= '0' and <= '9' or '-' or '_'); + if (!Valid(name, 8) || !Valid(ext, 3)) + throw new NotSupportedException($"FLEX names are a letter then letters, digits, '-' or '_' (8 at most) with a 1-3 character extension; '{fileName}' is not."); + var n = new byte[8]; + var e = new byte[3]; + Encoding.ASCII.GetBytes(name, n); + Encoding.ASCII.GetBytes(ext, e); + return (n, e); + } + + /// Adds (or replaces) a file in the image. + public static void AddFile(byte[] img, string fileName, ReadOnlySpan data, DateTime? date = null) { + ArgumentNullException.ThrowIfNull(img); + var (name, ext) = EncodeName(fileName); + var disk = new FlexDisk(new MemoryStream(img, writable: false)); + var existing = disk.Entries.FirstOrDefault(e => Matches(img, e.EntryOffset, name, ext)); + if (existing is not null) { + if ((existing.Attributes & 0xC0) != 0) + throw new NotSupportedException($"FLEX file {existing.FullName} is write- or delete-protected."); + RemoveFile(img, fileName); + disk = new FlexDisk(new MemoryStream(img, writable: false)); + } + + var sectors = Math.Max(1, (data.Length + FlexDisk.DataBytes - 1) / FlexDisk.DataBytes); + var spt = disk.SectorsPerTrack; + var free = FreeChain(img, disk); + var slot = FindSlot(img, disk); + var needDirSector = slot is null ? 1 : 0; + if (free.Count < sectors + needDirSector) + throw new NotSupportedException($"FLEX disk has {free.Count} free sectors; '{fileName}' needs {sectors + needDirSector}."); + + var taken = 0; + if (slot is null) { + // Grow the directory by one sector from the head of the free chain, as FMS does. + var (dt, ds) = free[taken++]; + var dat = Offset(spt, dt, ds); + img.AsSpan((int)dat, FlexDisk.SectorSize).Clear(); + var (lt, ls) = disk.DirectorySectors[^1]; + var lat = Offset(spt, lt, ls); + img[lat] = dt; img[lat + 1] = ds; + slot = dat + 16; + } + + for (var i = 0; i < sectors; ++i) { + var (t, s) = free[taken + i]; + var at = (int)Offset(spt, t, s); + var sector = img.AsSpan(at, FlexDisk.SectorSize); + sector.Clear(); + if (i + 1 < sectors) { sector[0] = free[taken + i + 1].Track; sector[1] = free[taken + i + 1].Sector; } + BinaryPrimitives.WriteUInt16BigEndian(sector[2..], (ushort)(i + 1)); + var start = i * FlexDisk.DataBytes; + if (start < data.Length) data.Slice(start, Math.Min(FlexDisk.DataBytes, data.Length - start)).CopyTo(sector[4..]); + } + var first = free[taken]; + var last = free[taken + sectors - 1]; + taken += sectors; + + var entry = img.AsSpan((int)slot.Value, FlexDisk.EntrySize); + entry.Clear(); + name.CopyTo(entry); + ext.CopyTo(entry[8..]); + entry[13] = first.Track; entry[14] = first.Sector; + entry[15] = last.Track; entry[16] = last.Sector; + BinaryPrimitives.WriteUInt16BigEndian(entry[17..], (ushort)sectors); + WriteDate(entry[21..], date ?? DateTime.UtcNow); + + var sir = img.AsSpan(FlexDisk.SirOffset, FlexDisk.SectorSize); + if (taken < free.Count) { sir[0x1D] = free[taken].Track; sir[0x1E] = free[taken].Sector; } + else { sir[0x1D] = 0; sir[0x1E] = 0; sir[0x1F] = 0; sir[0x20] = 0; } + BinaryPrimitives.WriteUInt16BigEndian(sir[0x21..], (ushort)(free.Count - taken)); + } + + /// Deletes a file: name marked deleted, sectors zeroed and appended to the free chain. False when absent. + public static bool RemoveFile(byte[] img, string fileName) { + ArgumentNullException.ThrowIfNull(img); + var (name, ext) = EncodeName(fileName); + var disk = new FlexDisk(new MemoryStream(img, writable: false)); + var e = disk.Entries.FirstOrDefault(x => Matches(img, x.EntryOffset, name, ext)); + if (e is null) return false; + if ((e.Attributes & 0x40) != 0) throw new NotSupportedException($"FLEX file {e.FullName} is delete-protected."); + _ = disk.Extract(e); // the chain must be sound before it joins the free chain + var spt = disk.SectorsPerTrack; + var chain = disk.Chain(e.StartTrack, e.StartSector, e.Sectors).Select(c => (c.Track, c.Sector)).ToList(); + var free = FreeChain(img, disk); + + for (var i = 0; i < chain.Count; ++i) { + var at = (int)Offset(spt, chain[i].Track, chain[i].Sector); + img.AsSpan(at + 2, FlexDisk.SectorSize - 2).Clear(); // wipe record number and data, keep the link + } + var sir = img.AsSpan(FlexDisk.SirOffset, FlexDisk.SectorSize); + if (free.Count == 0) { sir[0x1D] = e.StartTrack; sir[0x1E] = e.StartSector; } + else { + var tail = (int)Offset(spt, free[^1].Track, free[^1].Sector); + img[tail] = e.StartTrack; img[tail + 1] = e.StartSector; + } + sir[0x1F] = e.EndTrack; sir[0x20] = e.EndSector; + BinaryPrimitives.WriteUInt16BigEndian(sir[0x21..], (ushort)(free.Count + chain.Count)); + img[e.EntryOffset] = 0xFF; // "negative" first byte (FPM); $FF is what real disks show and what flexfloppy skips + return true; + } + + private static bool Matches(byte[] img, long entryOffset, byte[] name, byte[] ext) + => img.AsSpan((int)entryOffset, 8).SequenceEqual(name) && img.AsSpan((int)entryOffset + 8, 3).SequenceEqual(ext); + + /// The free chain, checked against the SIR's head, tail and count; an inconsistent one is refused. + private static List<(byte Track, byte Sector)> FreeChain(byte[] img, FlexDisk disk) { + List<(byte Track, byte Sector)> list = disk.FirstFree == (0, 0) + ? [] + : [.. disk.Chain(disk.FirstFree.Track, disk.FirstFree.Sector, disk.Tracks * disk.SectorsPerTrack).Select(c => (c.Track, c.Sector))]; + if (list.Count != disk.FreeSectors || (list.Count > 0 && list[^1] != disk.LastFree)) + throw new NotSupportedException($"FLEX free chain ({list.Count} sectors) does not match the SIR ({disk.FreeSectors} sectors ending {disk.LastFree.Track},{disk.LastFree.Sector}); the disk is not edited."); + return list; + } + + /// First never-used or deleted directory entry, or null when every directory sector is full. + private static long? FindSlot(byte[] img, FlexDisk disk) { + foreach (var (t, s) in disk.DirectorySectors) { + var at = Offset(disk.SectorsPerTrack, t, s); + for (var i = 0; i < FlexDisk.EntriesPerSector; ++i) { + var e = at + 16 + i * FlexDisk.EntrySize; + if (img[e] == 0 || (img[e] & 0x80) != 0) return e; + } + } + return null; + } + + private static long Offset(int spt, int track, int sector) => ((long)track * spt + sector - 1) * FlexDisk.SectorSize; + + private static void WriteDate(Span d, DateTime t) { + d[0] = (byte)t.Month; d[1] = (byte)t.Day; d[2] = (byte)(t.Year % 100); + } +} diff --git a/Hawkynt.FileFormats.FileSystems/README.md b/Hawkynt.FileFormats.FileSystems/README.md index 1964dd2ce..cffcb59a1 100644 --- a/Hawkynt.FileFormats.FileSystems/README.md +++ b/Hawkynt.FileFormats.FileSystems/README.md @@ -190,6 +190,7 @@ The `Id`, `State` and verb columns are read off the descriptors by `FilesystemRe | [D71](https://en.wikipedia.org/wiki/Commodore_DOS) | `D71` | R/W | ✅ | ✅ moving | — | ✅ | ✅ | — | ✅ | own reader + struct-parity tests | — | [VICE disk images](https://vice-emu.sourceforge.io/vice_17.html) | | [D81](https://en.wikipedia.org/wiki/Commodore_DOS) | `D81` | R/W | ✅ | ✅ moving | — | ✅ | ✅ | — | ✅ | own reader + struct-parity tests | — | [VICE disk images](https://vice-emu.sourceforge.io/vice_17.html) | | [DragonFS](https://en.wikipedia.org/wiki/Dragon_32/64) | `DragonFs` | R/W | ✅ | ✅ moving | — | ✅ | ✅ | — | ✅ | own reader + struct-parity tests | DragonDOS | [DragonDOS](https://en.wikipedia.org/wiki/Dragon_32/64) | +| [FLEX disk (6800/6809)](https://en.wikipedia.org/wiki/FLEX_(operating_system)) | `Flex` | R/W | — | — | — | — | — | — | ✅ | [`flexfloppy`](https://github.com/laurent-fr/flextools) catalogues and extracts our created and in-place-edited disks, and we read what it formats and adds; the TSC FLEX 9.1 distribution disk reads byte-identical to `flexfloppy`'s extraction | Raw `.dsk` sector images: linked 256-byte sectors, SIR on track 0 sector 3, directory chain from sector 5 (grows past track 0); files are whole 252-byte records (FLEX keeps no byte length), random files keep their sector map; in-place add/delete as FLEX's FMS does | [6809 FLEX Adaptation Guide](http://flexusergroup.com/flexusergroup/pdfs/6809fadg.pdf), [FLEX Advanced Programmer's Guide](http://www.flexusergroup.com/flexusergroup/pdfs/flexapg.pdf), [on-disk notes](https://github.com/Hawkynt/CompressionWorkbench/blob/main/docs/FLEX-ON-DISK.md) | | [G64 (Commodore GCR)](https://en.wikipedia.org/wiki/Commodore_DOS) | `G64` | R/W | ✅ | ✅ rebuild | — | ✅ | ✅ | — | ✅ | own reader + struct-parity tests | Raw GCR tracks; the writer GCR-encodes a 1541 image | [G64 (VICE)](https://vice-emu.sourceforge.io/vice_17.html) | | [GEMDOS (Atari ST)](https://en.wikipedia.org/wiki/Atari_TOS) | `Gemdos` | R/W | ✅ | ✅ rebuild | — | ✅ | ✅ | — | ✅ | own reader + struct-parity tests | Atari ST FAT variant | [GEMDOS](https://en.wikipedia.org/wiki/Atari_TOS) | | [MFS-1 (Acorn Master File System v1)](https://en.wikipedia.org/wiki/BBC_Master) | `Mfs1` | R/W | ✅ | ✅ moving | — | ✅ | ✅ | — | ✅ | own reader + struct-parity tests | Acorn MFS-1; geometry-preserving catalog rebuild for edits | [BBC Master](https://en.wikipedia.org/wiki/BBC_Master) | diff --git a/docs/FLEX-ON-DISK.md b/docs/FLEX-ON-DISK.md new file mode 100644 index 000000000..c9b7da367 --- /dev/null +++ b/docs/FLEX-ON-DISK.md @@ -0,0 +1,102 @@ +# FLEX disks on disk + +FLEX is the disk operating system Technical Systems Consultants wrote for the 6800 and 6809 +from 1976. Its disks are usually preserved as raw `.dsk` sector dumps. This page is the +specification `FileSystem.Flex` is written from. + +**Sources (rung 3, normative).** Both manuals are TSC's own, from the FLEX User Group archive: +* *6809 FLEX Adaptation Guide*, §2.3 (disk format) and §9.1 (NEWDISK, SIR table): + +* *FLEX Advanced Programmer's Guide*, the FCB directory-information bytes 4–27, + "Description of a directory sector", "Description of a data sector", "Random files" and + "Diskette Initialization": + +`flexfloppy` from laurent-fr/flextools (no licence, so used only as a black-box oracle) +and the TSC FLEX 9.1 distribution disk `TSCFLX09.DSK` (fufu archive, sha256 `cd61d5f3…ac5c`) +confirm the details. + +## Image geometry + +A `.dsk` is a raw dump of 256-byte sectors with no header. Every track has the same number +of sector slots, and sector numbers start at 1: +`offset = (track × sectors_per_track + sector − 1) × 256`. The geometry comes from the SIR. + +## Sectors + +Every sector except the two boot sectors is laid out as: + +| Bytes | Content | +| --- | --- | +| 0–1 | link: track and sector of the next sector in the chain; `00 00` ends it | +| 2–3 | logical record number, big-endian (1, 2, … in a file; 0 in the free chain) | +| 4–255 | 252 data bytes | + +FLEX records no byte length: a file is its sectors' data bytes, whole. + +## Track 0 + +| Sector | Use | +| --- | --- | +| 1–2 | boot loader | +| 3 | System Information Record (SIR) | +| 4 | reserved | +| 5–last | the directory chain, linked; its last sector's link is zero. The directory may grow onto other tracks | + +## SIR (track 0 sector 3; offsets decimal, from the Adaptation Guide §9.1.5) + +| Bytes | Content | +| --- | --- | +| 0–15 | zero (0–1 clear the link) | +| 16–26 | volume name, ASCII | +| 27–28 | volume number, binary (big-endian) | +| 29–30 | first sector of the free chain (track, sector) | +| 31–32 | last sector of the free chain | +| 33–34 | number of sectors in the free chain. NEWDISK writes the total data sectors, which excludes track 0 | +| 35–37 | date: month, day, year (binary) | +| 38 | highest track number | +| 39 | highest sector number (sectors per track) | + +Measured on `TSCFLX09.DSK`: `FLEX-SWT`, volume 6809, 35 tracks × 10 sectors, 161 free. +The free chain's tail is not at the end of the disk: deleted files are appended to the +tail. + +## Directory sector + +Bytes 0–3 hold the link and record number; bytes 4–15 are unused. Ten 24-byte entries +follow, from byte 16. An entry (FCB bytes 4–27): + +| Bytes | Content | +| --- | --- | +| 0–7 | name, NUL-padded; first byte `00` = never used, negative = deleted | +| 8–10 | extension, NUL-padded | +| 11 | attributes: bit 7 write-, 6 delete-, 5 read-, 4 catalog-protect | +| 12 | reserved | +| 13–14 | first sector (track, sector) | +| 15–16 | last sector | +| 17–18 | sector count, big-endian | +| 19 | File Sector Map indicator: non-zero (usually 2) = random file. Its first two sectors are the map, record 0 | +| 20 | reserved | +| 21–23 | creation date: month, day, year (tens and units) | + +Names start with a letter and contain letters, digits, `-` and `_`; FLEX stores them upper +case, and "files with null extensions should not be created". Deleted entries on real disks +carry `$FF` as the first byte, and that is the only marker `flexfloppy` skips. + +## What the implementation does with it + +* **Read**: the SIR, the directory chain (loops refused), and every live entry. File bytes + are the 252 data bytes of every sector in the chain. A random file's two map sectors are + included, as stored, which is what `flexfloppy` extracts. A chain whose length or end + sector disagrees with its entry is refused. +* **Create**: the NEWDISK layout — directory linked through track 0, every data sector in + one free chain, SIR filled in. The geometry is chosen from 35×10, 40×18, 77×15, 80×36 or + 255×255, or given. Files are written as FMS writes them. +* **Edit in place**, FMS style. Add takes sectors from the head of the free chain, uses the + first never-used or deleted directory slot (growing the directory chain by one sector when + none is free), numbers the records from 1, and updates the SIR. Remove refuses a + delete-protected file. Otherwise it sets the entry's first byte to `$FF`, zeroes the + file's record numbers and data, appends its chain to the tail of the free chain, and + updates the SIR. A free chain that disagrees with the SIR's head, tail or count is + refused, with the image unchanged. +* Contents are whole records. A file whose length is not a multiple of 252 comes back + zero-padded, and an empty file takes one sector. diff --git a/docs/catalogue/our-registry.csv b/docs/catalogue/our-registry.csv index a8bf87611..f57ce3bd2 100644 --- a/docs/catalogue/our-registry.csv +++ b/docs/catalogue/our-registry.csv @@ -183,6 +183,7 @@ format,Fits,FITS,Archive,,.fits;.fit;.fts,Y,Y,Y, format,Fla,Flash/Animate FLA,Archive,,.fla,Y,Y,Y, format,Flac,FLAC,Stream,,.flac;.fla,Y,Y,Y, format,FlacArchive,FLAC (archive view),Audio,,,Y,Y,, +format,Flex,FLEX disk (6800/6809),Archive,Y,.flx;.dsk,Y,Y,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,, diff --git a/docs/catalogue/reference-formats.csv b/docs/catalogue/reference-formats.csv index ab231c065..38b59c4cc 100644 --- a/docs/catalogue/reference-formats.csv +++ b/docs/catalogue/reference-formats.csv @@ -407,7 +407,7 @@ 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,, 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,, +FLEX file system,,filesystem,,,,,,,Y,1,http://www.flexusergroup.com/flexusergroup/pdfs/flexapg.pdf,Flex,Y,Y,Y,OK,flexfloppy, HAMMER,,filesystem,,,,,,,Y,1,,Hammer,Y,Y,Y,OK,, HAMMER2,,filesystem,,,,,,,Y,1,,Hammer2,Y,Y,Y,OK,, HFS (IBM MVS / z/OS),,filesystem,,,,,,,Y,1,,,,,,MISSING,, From 3397948c1ca61262923ff7d0fbd3a516c3aec681 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:36:14 +0000 Subject: [PATCH 2/3] * refresh the application screenshot --- screenshots/main-window.png | Bin 66235 -> 66487 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/screenshots/main-window.png b/screenshots/main-window.png index a9ceb7f7ce233693ac240e941d4af64380209648..0b9e538fc4925ca0fac7da3d502d22d2c1f76ddd 100644 GIT binary patch delta 28214 zcmc$`1z1&C8#e5Wqm&4e3M!%k($dl+NJvUINOz~yHfRI{JakA)cODK$5$SFY-QC^) zIyf_L%)Ily|Nnp2^?7kgoPGA*XRo!M=YH<{eiq#OmvY}<3Vc37nhNv7Up9GvB1jKd zQHv8Nmiy)F8&v<^`rQf|@$!_4+>|v04QeuroR~&Qy_C*UMDBKJJ4zX!=vk)(CKi4I z=^~-&&H6XWhaQ5}Pv&{{o4K98v=skL&e_WZmAbYCDCx)~@@0AqbAB(O4j)3H`m;64 zW!Do*5!Kk%@$8dtT}{pmEh>NS@& zR#w(|CQX;AnM}>ns)&qvKix@`_0#8@ddM*O%-*kKHQ*A7o;yj3bRMUBKB6gV)fR^d zr(N9K+(DNwQ@=kQNw=FNE#T_z>6vF#zwJyXz{RznJJ(hZ1>H?MWixJck;sA@YRqI= zE5}Ahd$R0pQwNcm;^5uk&eDqIWE}TgW>Znwn1dp01ye#c?=~ z^{?`l_-?H<5r&MWb+B4W&?CTUz>l>}cANFEMe&g&Pv^xcn~QjIT`DNzb5v zJ=;MrfLzBIqOnpij3c~qXvp5)7|%Oye%OBY`HluxjtX6|SO|H35QK2GJQwir^c&ls z-7+*LXm5|<0(%K^KjjJLIG_ZB(1|8me_T>KaN|E zbPlH;cTpLU1sM$_AmVwQ25*#R5B7TsxIWZd)YH&_Vm&X93EJ&^wlZ%c>wDn5#=wVX z-v3$K0!M`RiZ*faTpQej#&OwaSJ1s2Y1Bsc&i8sxLK-uz_KM2H4zplef`DUBza@Nc z&4Y2jp38jT`-#m$w%=?Q+$pQCvPw|c5Wyw-{#N#jM^09w)o#aSC2(hC6st5jmjU1R zerdQHY0+XmR=9*B8N_H9ZiY_#UlGstf6!J`401U4}5))qsiVd7%V(RA56(eCawN@q-J4` z+^@noIDuwL*-nciw392`M&0zyRd2m>OJTXCN+R_3nTlb#t+a^h%uLe|D=lyQ0SLJ{ zSP%IJW~&y~9y6V8LX~Pfu&X_tv@2T)^P$#mgm~9=Y~^ z)-^Zh)iXI*WW$0+t0A?1x9PRf9eYxyYiB!z8sy||yzsAyo^~?}U2tbyGTG;jgyCUX z%DGc(bErq3lJ4_rB)u>&Fz`HVTPjQuy~UcU2Wc1?)6X-g4rX~YP+KF|Tn0CW%hxd} zui}x{P}v;^f7TK>`>91Gk8Y%KLid};$>k-EYZjwPJu2A)Cgc$9k$J(OkxsJw^#(SX zSeh8Ej&j%Mj=NJrel%nRuT@wnHH*(QzTsrUBw#{v2Q>3vwM67w;|KB9ZZGHWhkLrN z5C$2zDfUjt3LH+tPtV=TXTNOdMn^}xB7^r=hR2g%Z6_1UCO+TDQ45dW$!hEv_}puT zNIY>DUccMg)HDQ@dH6oNpLv=-ppUcehw96BW+o&IPZ+IQ8!Ux?471cIm4Lgc>>JC{ z!N3eK9S)8c@;e>(nAgZp2!A(C=+RoqmVsPUGG9t%Zf{5≻I4k6+C(RWNQ%IBrOU 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 SgO+yN5$Y;h*YlL_J^l|r0)x^3 delta 27781 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 Date: Wed, 28 Oct 2026 06:49:48 +0100 Subject: [PATCH 3/3] # reject ambiguous firmware payloads before writing an image Adding a second named payload to Intel HEX could silently discard a file because the shared image builder kept only the first payload in filesystem enumeration order. Reject multiple non-metadata payloads, including an empty first payload, before any output is written. Tests verify creation refusal and byte-preserving add refusal. Verified all three regressions fail before the fix and pass after it, 93 focused tests, 34280 required tests with 1582 skips and no failures, and three package documentation checks. --- .../FirmwareHex/FirmwareHexPayloadTests.cs | 37 +++++++++++++++++++ .../FirmwareHexWriter.cs | 5 ++- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 Compression.Tests/FirmwareHex/FirmwareHexPayloadTests.cs diff --git a/Compression.Tests/FirmwareHex/FirmwareHexPayloadTests.cs b/Compression.Tests/FirmwareHex/FirmwareHexPayloadTests.cs new file mode 100644 index 000000000..bbbc898af --- /dev/null +++ b/Compression.Tests/FirmwareHex/FirmwareHexPayloadTests.cs @@ -0,0 +1,37 @@ +using Compression.Registry; +using FileFormat.FirmwareHex; + +namespace Compression.Tests.FirmwareHex; + +[TestFixture] +public sealed class FirmwareHexPayloadTests { + [TestCase(false)] + [TestCase(true)] + public void Create_RejectsMultiplePayloadsWithoutWritingOutput(bool emptyFirst) { + var descriptor = new IntelHexFormatDescriptor(); + using var output = new MemoryStream(); + var inputs = new[] { + ArchiveInputInfo.InMemory("first.bin", emptyFirst ? [] : [1, 2, 3]), + ArchiveInputInfo.InMemory("second.bin", [9, 8, 7]), + }; + + Assert.Throws(() => + ((IArchiveCreatable)descriptor).Create(output, inputs, new FormatCreateOptions())); + Assert.That(output.Length, Is.Zero); + } + + [Test] + public void Add_RejectsAnotherPayloadWithoutChangingTheArchive() { + var descriptor = new IntelHexFormatDescriptor(); + using var archive = new MemoryStream(); + ((IArchiveCreatable)descriptor).Create(archive, + [ArchiveInputInfo.InMemory("firmware.bin", [1, 2, 3])], new FormatCreateOptions()); + var original = archive.ToArray(); + archive.Position = 0; + + Assert.Throws(() => ((IArchiveModifiable)descriptor).Add(archive, + [ArchiveInputInfo.InMemory("ADD.BIN", [9, 8, 7])])); + Assert.That(archive.ToArray(), Is.EqualTo(original)); + } + +} diff --git a/Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.FirmwareHex/FirmwareHexWriter.cs b/Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.FirmwareHex/FirmwareHexWriter.cs index 6c7d4b452..1c9e17266 100644 --- a/Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.FirmwareHex/FirmwareHexWriter.cs +++ b/Hawkynt.FileFormats.FileSystems/FileFormats/FileFormat.FirmwareHex/FirmwareHexWriter.cs @@ -33,6 +33,7 @@ public static class FirmwareHexWriter { /// one the reader rendered. An input list with no payload describes an image /// with no data, which both formats can write. /// + /// More than one non-metadata payload is supplied. public static FirmwareImage ImageFrom(IReadOnlyList inputs, string sourceFormat) { ArgumentNullException.ThrowIfNull(inputs); @@ -53,7 +54,9 @@ public static FirmwareImage ImageFrom(IReadOnlyList inputs, st } // Any other single input is the payload, whatever it is called: a caller // converting a .bin into a .hex should not have to rename it first. - payload ??= input.ReadContent(); + if (payload is not null) + throw new InvalidDataException($"{sourceFormat} represents one logical firmware payload; multiple payload inputs are ambiguous."); + payload = input.ReadContent(); } List<(uint Address, byte[] Data)> segments;