diff --git a/CMakeLists.txt b/CMakeLists.txt index b09fb7d..ff9d169 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,7 +10,8 @@ endif() # --- core library (portable, no GUI deps) --- add_library(msxcore STATIC - core/disk_image.cpp core/fat12.cpp core/msxfile.cpp core/rom.cpp core/cas.cpp core/util.cpp core/tape.cpp) + core/disk_image.cpp core/fat12.cpp core/msxfile.cpp core/rom.cpp core/cas.cpp core/util.cpp core/tape.cpp + core/partition.cpp) target_include_directories(msxcore PUBLIC core) # --- CLI --- diff --git a/README.md b/README.md index f96a5b5..bf2d5e7 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ It handles the whole MSX file stack: |--------|--------------| | **Disk images** | `.dsk` / raw sector dumps, and `.dmk` track images (protected disks). Auto-detects geometry (SS/DS, 360/720 KB). | | **Protected / RAW** | A `.dmk` preserves the physical IDAM/sector layout, so copy-protected disks survive: non-standard sector sizes, deleted-data address marks, duplicate IDs and CRC anomalies are surfaced in a **sector inspector**. Many games (Fray, Xak, ...) show only 1-2 catalogued files while the rest of the disk is **RAW uncatalogued sector data** read directly by a custom loader (not encryption); a **cluster map + RAW extractor/importer + block carver** makes that data visible, exportable, editable-and-reimportable, and splittable into BLOAD/BASIC segments. | +| **Hard disks / SD cards** | MSX-DOS 2 / Nextor images: **FAT16** (and FAT12) volumes, **MBR partition tables with extended/logical drives**, superfloppy images; pick a partition with `--part N` (CLI) or the Partition selector (GUI). Create them with `mkhdd` / `format 32M`. FAT32 is reported as unsupported. | | **FAT12 filesystem** | MSX-DOS disks: boot/BPB, FAT chains, root + subdirectories, volume label, free space, extraction, **deleted-entry recovery**, and full **read/write** — overwrite / grow / shrink / **add new files** / **rename** / **create folders** / delete (automatic cluster (re)allocation across both FAT copies), plus **format a blank disk** from scratch. | | **Analysis / integrity** | CRC32 of files and images, a **filesystem check** (cross-links, broken chains, lost vs RAW clusters), and a **disk diff** (files only-in-A / only-in-B / differing by CRC). Optional `.bak` backup on save. | | **MSX files** | Type detection (tokenized BASIC `0xFF`, BLOAD binary `0xFE`, ASCII, data), a full **BASIC detokenizer** and an inverse **tokenizer** (edit a listing as text and save it back tokenized). Handles standard + `0xFF`-extended tokens, hex/oct/int/BCD-float constants, line references, string & REM literals. | @@ -104,7 +105,9 @@ Linux, macOS arm64 and Windows): ## CLI usage Every `` may be a path into subdirectories (`GAMES/SAVE/SLOT1.DAT`), -case-insensitive. Run `msxforge` without arguments for this list. +case-insensitive. On a partitioned hard disk image, add `--part N` to any disk +command (default: the first FAT partition); `info` lists the partitions. Run +`msxforge` without arguments for this list. ``` msxforge info disk/format/geometry/FAT summary @@ -119,7 +122,8 @@ msxforge protect physical sector / protection inspector msxforge raw [out.bin] cluster map (named/RAW/free) + extract RAW data msxforge carve split the RAW region into BLOAD/BASIC/gap blocks msxforge rawput [outimage] write RAW data back into the image -msxforge format [360|720] create a blank formatted disk +msxforge format [360|720|M] [LABEL] blank floppy, or a FAT16 superfloppy of N MB +msxforge mkhdd M[,M...] partitioned hard disk / SD image (FAT12/16) msxforge pack [360|720] [--label L] build a disk from a folder tree msxforge unpack extract the whole tree to a folder msxforge undelete <[DIR/]?NAME> [LETTER] [outimage] recover a deleted file @@ -186,6 +190,10 @@ detokenize, and delete frees clusters and drops the entry. double-precision constants that would otherwise read back as integers carry a `#`, so detokenize → tokenize reproduces the bytes; the real MSX `LIST` may print some of these without the suffix. +- Hard disk support is verified on images built by `mkhdd`/`format` (tests and + the GUI), not yet on SD cards partitioned by Nextor's own FDISK. Images are + read fully into memory; on Windows, image byte offsets are 32-bit, so keep + images below 2 GB there (`format`/`mkhdd` create at most 2047 MB). - Directories are created, grown and removed; the root directory keeps its fixed size (112 entries on standard disks), as on MSX-DOS. diff --git a/cli/main.cpp b/cli/main.cpp index e249b87..d27e6df 100644 --- a/cli/main.cpp +++ b/cli/main.cpp @@ -13,6 +13,7 @@ #include "../core/cas.h" #include "../core/util.h" #include "../core/tape.h" +#include "../core/partition.h" #include "hostfs.h" #include #include @@ -42,10 +43,38 @@ static std::vector slurp(const char* path) { return v; } +// --part N (global option): which partition of a hard disk image to use. +static int g_part = 0; + +// Mount the filesystem of an image: a floppy / superfloppy directly, a +// partitioned disk through partition g_part (default: the first FAT one). +static bool mountVolume(DiskImage& dk, Fat12& fs, std::string& err) { + auto parts = findPartitions(dk); + if (parts.empty()) { + if (g_part > 1) { err = "image has no partition table"; return false; } + return fs.mount(dk, &err); + } + const Partition* p = nullptr; + if (g_part > 0) { + for (auto& x : parts) if (x.number == g_part) p = &x; + if (!p) { err = "no partition " + std::to_string(g_part) + " (the image has " + + std::to_string(parts.size()) + ")"; return false; } + } else { + for (auto& x : parts) if (x.fat()) { p = &x; break; } + if (!p) { err = "no FAT partition on this disk"; return false; } + } + if (fs.mount(dk, p->firstSector, p->sectorCount, &err)) return true; + // A table that is really junk in a floppy's boot sector: try the image as a + // plain volume before giving up (only when no partition was asked for). + std::string plain; + if (g_part == 0 && fs.mount(dk, &plain)) return true; + return false; +} + static int mountDisk(const char* path, DiskImage& dk, Fat12& fs) { std::string err; if (!dk.load(path, &err)) { fprintf(stderr, "load error: %s\n", err.c_str()); return 2; } - if (!fs.mount(dk, &err)) { fprintf(stderr, "mount error: %s\n", err.c_str()); return 3; } + if (!mountVolume(dk, fs, err)) { fprintf(stderr, "mount error: %s\n", err.c_str()); return 3; } return 0; } @@ -64,9 +93,18 @@ static int cmd_info(const char* img) { printf("Geometry : %d cyl x %d head x %d spt (%ld logical sectors)\n", dk.cylinders(), dk.heads(), dk.sectorsPerTrack(), dk.logicalSectors()); printf("Protected : %s\n", dk.isProtected() ? "YES" : "no"); + auto parts = findPartitions(dk); + if (!parts.empty()) { + printf("Partitions : %zu (use --part N; default = first FAT partition)\n", parts.size()); + for (auto& p : parts) + printf(" %d %-8s %-15s start=%-9ld %8ld sectors (%ld MB)\n", p.number, + p.logical ? "logical" : "primary", partitionTypeName(p.type), + p.firstSector, p.sectorCount, p.sectorCount / 2048); + } Fat12 fs; - if (fs.mount(dk, &err)) { - printf("Filesystem : FAT12 OEM='%s' label='%s'\n", + if (mountVolume(dk, fs, err)) { + if (!parts.empty()) printf("Volume : starts at sector %ld\n", fs.volumeStart()); + printf("Filesystem : FAT%d OEM='%s' label='%s'\n", fs.fatBits(), fs.oemName().c_str(), fs.volumeLabel().c_str()); printf("Clusters : %d x %d bytes free=%ld bytes\n", fs.totalClusters(), fs.sectorsPerCluster()*512, fs.freeBytes()); @@ -272,13 +310,65 @@ static std::string baseName(const std::string& p) { return s == std::string::npos ? p : p.substr(s + 1); } +// "720" / "360" (KB floppies) or "M" (MB) -> sectors; 0 if invalid. +static long parseSize(const std::string& t) { + if (t == "720") return 1440; + if (t == "360") return 720; + if (t.size() > 1 && (t.back() == 'M' || t.back() == 'm')) { + long mb = atol(t.substr(0, t.size() - 1).c_str()); + return (mb >= 1 && mb <= 2047) ? mb * 2048 : 0; // < 2 GB: offsets are 32-bit on Windows + } + return 0; +} + +static bool writeImage(const char* path, const std::vector& img) { + FILE* f = fopen(path, "wb"); + if (!f) return false; + bool ok = fwrite(img.data(), 1, img.size(), f) == img.size(); + return fclose(f) == 0 && ok; +} + static int cmd_format(int argc, char** argv) { - if (argc < 3) { fprintf(stderr, "need [360|720]\n"); return 1; } - int kb = (argc >= 4) ? atoi(argv[3]) : 720; - auto img = formatBlankImage(kb == 360 ? 720 : 1440); - FILE* f = fopen(argv[2], "wb"); if (!f) { fprintf(stderr, "cannot write\n"); return 2; } - fwrite(img.data(), 1, img.size(), f); fclose(f); - printf("formatted blank %dKB image -> %s (%zu bytes)\n", kb, argv[2], img.size()); + // format [360|720|M] [LABEL] + if (argc < 3) { fprintf(stderr, "need [360|720|M] [LABEL]\n"); return 1; } + std::string sz = argc >= 4 ? argv[3] : "720"; + long secs = parseSize(sz); + if (!secs) { fprintf(stderr, "size must be 360, 720 or M (1-2047)\n"); return 1; } + std::vector img = (secs == 1440 || secs == 720) ? formatBlankImage((int)secs) + : formatFatVolume(secs, 0, argc >= 5 ? argv[4] : ""); + if (img.empty()) { fprintf(stderr, "cannot format that size\n"); return 1; } + if (argc >= 5 && (secs == 1440 || secs == 720)) { // floppy label via the filesystem + DiskImage dk; Fat12 fs; std::string err; + dk.loadBytes(img, argv[2], &err); + if (!fs.mount(dk, &err) || !fs.setVolumeLabel(argv[4], &err)) { fprintf(stderr, "label: %s\n", err.c_str()); return 6; } + img = dk.raw(); + } + if (!writeImage(argv[2], img)) { fprintf(stderr, "cannot write %s\n", argv[2]); return 2; } + printf("formatted %s image -> %s (%zu bytes)\n", sz.c_str(), argv[2], img.size()); + return 0; +} + +static int cmd_mkhdd(int argc, char** argv) { + // mkhdd M[,M...] + if (argc < 4) { fprintf(stderr, "need M[,M...]\n"); return 1; } + std::vector sizes; + std::string list = argv[3]; + size_t p = 0; + while (p <= list.size()) { + size_t c = list.find(',', p); + std::string item = list.substr(p, c == std::string::npos ? std::string::npos : c - p); + long secs = item.empty() ? 0 : parseSize(item.back() == 'M' || item.back() == 'm' ? item : item + "M"); + if (!secs || secs == 1440 || secs == 720) { fprintf(stderr, "bad partition size '%s' (use e.g. 32M)\n", item.c_str()); return 1; } + sizes.push_back(secs); + if (sizes.size() > 255) { fprintf(stderr, "at most 255 partitions\n"); return 1; } + if (c == std::string::npos) break; + p = c + 1; + } + std::string err; + auto img = buildPartitionedImage(sizes, &err); + if (img.empty()) { fprintf(stderr, "mkhdd: %s\n", err.c_str()); return 6; } + if (!writeImage(argv[2], img)) { fprintf(stderr, "cannot write %s\n", argv[2]); return 2; } + printf("created %s: %zu partition(s), %zu bytes\n", argv[2], sizes.size(), img.size()); return 0; } @@ -496,7 +586,7 @@ static int cmd_crc(int argc, char** argv) { static int cmd_fsck(const char* img) { DiskImage dk; Fat12 fs; std::string err; if (!dk.load(img, &err)) { fprintf(stderr, "%s\n", err.c_str()); return 2; } - if (!fs.mount(dk, &err)) { fprintf(stderr, "mount: %s\n", err.c_str()); return 3; } + if (!mountVolume(dk, fs, err)) { fprintf(stderr, "mount: %s\n", err.c_str()); return 3; } Fat12::FsCheck c = fs.verify(); printf("used-by-files: %ld clusters unreferenced(RAW/lost): %ld free: %ld\n", c.usedByFiles, c.unreferenced, c.freeClusters); @@ -594,10 +684,20 @@ static int cmd_cas(const char* path) { } int main(int argc, char** argv) { + // Strip the global --part N option wherever it appears. + std::vector args; + for (int i = 0; i < argc; ++i) { + if (!strcmp(argv[i], "--part") && i + 1 < argc) { g_part = atoi(argv[++i]); continue; } + args.push_back(argv[i]); + } + argc = (int)args.size(); + args.push_back(nullptr); + argv = args.data(); if (argc < 3) { fprintf(stderr, "MSXFileForge CLI\n" - "usage: msxforge [args] (FILE may be a path: DIR/NAME.EXT)\n" + "usage: msxforge [args] [--part N] (FILE may be a path: DIR/NAME.EXT)\n" + " --part N selects partition N of a hard disk image (default: first FAT one)\n" "\n" "disk images (.dsk / .dmk):\n" " info geometry, filesystem, free space\n" @@ -610,7 +710,8 @@ int main(int argc, char** argv) { " del [outimg] delete a file or an empty directory\n" " rename rename in place\n" " mkdir [/PARENT] create a directory\n" - " format [360|720] blank MSX-DOS disk\n" + " format [360|720|M] [LABEL] blank floppy, or a FAT16 superfloppy\n" + " mkhdd M[,M...] partitioned hard disk / SD image\n" " pack [360|720] [--label L] build a disk from a folder\n" " unpack extract the whole tree to a folder\n" " undelete <[DIR/]?NAME> [LETTER] [outimg] recover a deleted file\n" @@ -646,6 +747,7 @@ int main(int argc, char** argv) { if (cmd == "carve") return cmd_carve(argc, argv); if (cmd == "rawput") return cmd_rawput(argc, argv); if (cmd == "format") return cmd_format(argc, argv); + if (cmd == "mkhdd") return cmd_mkhdd(argc, argv); if (cmd == "add") return cmd_add(argc, argv); if (cmd == "rename") return cmd_rename(argc, argv); if (cmd == "mkdir") return cmd_mkdir(argc, argv); diff --git a/core/disk_image.cpp b/core/disk_image.cpp index 6337e3b..f3df32e 100644 --- a/core/disk_image.cpp +++ b/core/disk_image.cpp @@ -57,6 +57,7 @@ bool DiskImage::loadBytes(std::vector bytes, const std::string& nameHin std::string* err) { path_ = nameHint; format_ = DiskFormat::Unknown; + ++generation_; raw_ = std::move(bytes); sectors_.clear(); lba_.clear(); lbaSector_.clear(); cylinders_ = heads_ = spt_ = 0; protectedFlags_ = false; dirty_ = false; @@ -138,6 +139,9 @@ bool DiskImage::parsePlain(std::string* err) { const uint8_t* b = raw_.data(); bool bootish = (b[0] == 0xEB || b[0] == 0xE9) && rd16(b + 0x0B) == 512; format_ = bootish ? DiskFormat::DSK_Plain : DiskFormat::RAW; + // Anything well beyond floppy sizes is a hard disk / SD card image + // (partitioned, or a single "superfloppy" FAT volume). + if (raw_.size() > 2L * 1024 * 1024) format_ = DiskFormat::HDD; return true; } @@ -397,6 +401,7 @@ bool DiskImage::writeLogicalSector(long lba, const uint8_t in[512]) { if (sectors_[(size_t)lbaSector_[(size_t)lba]].markOffset < 0) return false; } memcpy(raw_.data() + off, in, 512); + ++generation_; // DMK: keep the data CRC valid, unless the sector carried a deliberate CRC // error (copy protection), which is preserved as found. if (format_ == DiskFormat::DMK && (size_t)lba < lbaSector_.size() && lbaSector_[(size_t)lba] >= 0) { diff --git a/core/disk_image.h b/core/disk_image.h index d95fb90..1eb2046 100644 --- a/core/disk_image.h +++ b/core/disk_image.h @@ -87,7 +87,11 @@ class DiskImage { // Mutable container bytes (for the raw hex editor). Does NOT mark dirty by // itself - callers that actually change bytes should call markDirty(). - std::vector& rawMut() { return raw_; } + std::vector& rawMut() { ++generation_; return raw_; } + + // Bumped by every load and write (and rawMut()), so readers that cache + // sectors can tell when the bytes may have changed underneath them. + uint64_t generation() const { return generation_; } // Physical sector table (for the protected/RAW inspector). const std::vector& sectors() const { return sectors_; } @@ -115,6 +119,7 @@ class DiskImage { int cylinders_ = 0, heads_ = 0, spt_ = 0; bool protectedFlags_ = false; bool dirty_ = false; + uint64_t generation_ = 0; }; } // namespace msx diff --git a/core/fat12.cpp b/core/fat12.cpp index 0004de6..3ebb40b 100644 --- a/core/fat12.cpp +++ b/core/fat12.cpp @@ -3,6 +3,7 @@ // Free software under the GNU GPL v3 (or later) - see LICENSE. Modified // versions must be distributed under the same license. #include "fat12.h" +#include #include #include #include @@ -53,8 +54,23 @@ std::string DirEntry::attrString() const { return a; } +// One-sector read cache: FAT scans read the same sector hundreds of times in +// a row. Writes go through putSector(), which keeps it coherent. bool Fat12::sectorBytes(long lba, uint8_t out[512]) const { - return disk_ && disk_->readSector(lba, out); + if (!disk_ || lba < 0 || (partLimit_ > 0 && lba >= partLimit_)) return false; + if (lba == cacheLba_ && cacheDisk_ == disk_ && cacheGen_ == disk_->generation()) { + memcpy(out, cache_, 512); return true; + } + if (!disk_->readSector(partBase_ + lba, out)) return false; + memcpy(cache_, out, 512); cacheLba_ = lba; cacheDisk_ = disk_; cacheGen_ = disk_->generation(); + return true; +} + +bool Fat12::putSector(long lba, const uint8_t in[512]) { + if (!disk_ || lba < 0 || (partLimit_ > 0 && lba >= partLimit_)) return false; + if (!disk_->writeLogicalSector(partBase_ + lba, in)) return false; + if (lba == cacheLba_ && cacheDisk_ == disk_) { memcpy(cache_, in, 512); cacheGen_ = disk_->generation(); } + return true; } // Standard MSX/PC floppy geometries, keyed by the media descriptor byte that @@ -92,8 +108,17 @@ bool Fat12::mountFromMediaByte(std::string* err) { } bool Fat12::mount(DiskImage& disk, std::string* err) { + return mount(disk, 0, 0, err); +} + +bool Fat12::mount(DiskImage& disk, long firstSector, long sectorCount, std::string* err) { disk_ = &disk; mounted_ = false; + cacheLba_ = -1; + partBase_ = firstSector < 0 ? 0 : firstSector; + long avail = disk.logicalSectors() - partBase_; + partLimit_ = (sectorCount > 0 && sectorCount < avail) ? sectorCount : 0; + long volSectors = partLimit_ > 0 ? partLimit_ : avail; uint8_t b[512]; if (!sectorBytes(0, b)) { if (err) *err = "cannot read boot sector"; return false; } @@ -102,62 +127,105 @@ bool Fat12::mount(DiskImage& disk, std::string* err) { rsvd_ = rd16(b + 0x0E); nfat_ = b[0x10]; ndir_ = rd16(b + 0x11); - totalSectors_ = rd16(b + 0x13); + long total = rd16(b + 0x13); + // The 32-bit count at 0x20 exists only in DOS 3.31+ BPBs; on MSX-DOS 1 + // boot sectors those bytes are boot code. Trust it only with an extended + // boot signature, or for a large volume the image can actually hold. + uint32_t total32 = (uint32_t)(b[0x20] | (b[0x21] << 8) | (b[0x22] << 16) | ((uint32_t)b[0x23] << 24)); + if (total == 0 && total32 <= 0x7FFFFFFF && + (b[0x26] == 0x28 || b[0x26] == 0x29 || (total32 > 65535 && (long)total32 <= volSectors))) + total = (long)total32; media_ = b[0x15]; spf_ = rd16(b + 0x16); oem_ = trim(std::string((const char*)b + 3, 8)); - bool bpbOk = bps_ == 512 && isPow2(spc_) && spc_ <= 64 && nfat_ >= 1 && nfat_ <= 4 && + bool bpbOk = bps_ == 512 && isPow2(spc_) && spc_ <= 128 && nfat_ >= 1 && nfat_ <= 4 && ndir_ > 0 && ndir_ % 16 == 0 && spf_ > 0 && rsvd_ > 0; - if (bpbOk && totalSectors_ == 0) totalSectors_ = (int)disk.logicalSectors(); + if (bpbOk && total == 0) total = volSectors; if (bpbOk) { - long data = (long)totalSectors_ - rsvd_ - (long)nfat_ * spf_ - (ndir_ * 32 + 511) / 512; + long data = total - rsvd_ - (long)nfat_ * spf_ - (ndir_ * 32 + 511) / 512; bpbOk = data >= spc_; } - if (!bpbOk) { + if (bpbOk) totalSectors_ = (int)std::min(total, 0x7FFFFFFF); + else { oem_.clear(); - if (!mountFromMediaByte(err)) return false; + // Only floppies come without a BPB; a partition always has one. + std::string mErr; + // (The media-byte table only describes floppies up to 1.44MB.) + if (partBase_ != 0 || volSectors > 2 * 2880 || !mountFromMediaByte(&mErr)) { + bool fat32 = bps_ == 512 && spf_ == 0 && ndir_ == 0 && isPow2(spc_) && + nfat_ >= 1 && nfat_ <= 2 && rsvd_ > 0 && rd32(b + 0x24) > 0; + if (err) *err = fat32 ? "FAT32 volumes are not supported (MSX-DOS / Nextor use FAT12 and FAT16)" + : (mErr.empty() ? "not a FAT boot sector" : mErr); + return false; + } } - // Never address sectors the container does not have (a truncated image, or - // a media-byte guess larger than the dump). DMK images may legitimately - // end in unreadable holes, so their logical size is not a cap. - if (disk.format() != DiskFormat::DMK && totalSectors_ > disk.logicalSectors()) - totalSectors_ = (int)disk.logicalSectors(); - fatStartSec_ = rsvd_; rootStartSec_ = rsvd_ + (long)nfat_ * spf_; rootSectors_ = (ndir_ * 32 + bps_ - 1) / bps_; dataStartSec_ = rootStartSec_ + rootSectors_; - long dataSectors = totalSectors_ - dataStartSec_; - totalClusters_ = spc_ > 0 ? (int)(dataSectors / spc_) : 0; - // A FAT12 entry is 1.5 bytes: never address clusters the FAT cannot hold. - int fatCapacity = (int)((long)spf_ * 512 * 2 / 3) - 2; - if (totalClusters_ > fatCapacity) totalClusters_ = fatCapacity; - if (totalClusters_ > 4084) totalClusters_ = 4084; - if (totalClusters_ < 1) { if (err) *err = "FAT12: no data area"; return false; } + + // The FAT type follows from the cluster count the BPB describes + // (Microsoft's rule), BEFORE any clamping to what the container holds: a + // truncated FAT16 image is still FAT16. A DMK is a floppy: its geometry + // bounds the count. + long typeSectors = totalSectors_; + if (disk.format() == DiskFormat::DMK) { + long geo = (long)disk.cylinders() * disk.heads() * disk.sectorsPerTrack(); + if (geo > 0 && typeSectors > geo) typeSectors = geo; + } + long bpbClusters = spc_ > 0 ? (typeSectors - dataStartSec_) / spc_ : 0; + fat16_ = bpbClusters >= 4085; + if (fat16_) { eoc_ = 0xFFFF; eocMin_ = 0xFFF8; bad_ = 0xFFF7; rsvMin_ = 0xFFF0; } + else { eoc_ = 0xFFF; eocMin_ = 0xFF8; bad_ = 0xFF7; rsvMin_ = 0xFF0; } + + // Never address sectors the container does not have (a truncated image, or + // a media-byte guess larger than the dump). DMK images may legitimately + // end in unreadable holes, so their logical size is not a cap. + if (disk.format() != DiskFormat::DMK && totalSectors_ > volSectors) + totalSectors_ = (int)volSectors; + long clusters = spc_ > 0 ? (totalSectors_ - dataStartSec_) / spc_ : 0; + if (clusters > bpbClusters) clusters = bpbClusters; + if (clusters > 65524) clusters = 65524; + // Never address clusters the FAT cannot hold (1.5 or 2 bytes per entry). + long fatCapacity = fat16_ ? (long)spf_ * 512 / 2 - 2 : (long)spf_ * 512 * 2 / 3 - 2; + if (clusters > fatCapacity) clusters = fatCapacity; + totalClusters_ = (int)clusters; + if (totalClusters_ < 1) { if (err) *err = "FAT: no data area"; return false; } + nextFree_ = 2; + cacheLba_ = -1; // Volume label: prefer a root VOL entry, else BPB ext label if present. volLabel_.clear(); for (auto& e : readRootDir(false)) { if (e.isVolume()) { volLabel_ = e.name; break; } } - if (volLabel_.empty() && bpbOk && b[0x26] == 0x29) // extended boot sig - volLabel_ = trim(std::string((const char*)b + 0x2B, 11)); + if (volLabel_.empty() && bpbOk && b[0x26] == 0x29) { // extended boot sig + std::string l = trim(std::string((const char*)b + 0x2B, 11)); + if (l != "NO NAME") volLabel_ = l; + } mounted_ = true; return true; } int Fat12::fatEntry(int cluster) const { + if (fat16_) { + long fatByte = fatStartSec_ * 512 + (long)cluster * 2; + uint8_t s0[512]; + if (!sectorBytes(fatByte / 512, s0)) return eoc_; + int off = (int)(fatByte % 512); + return s0[off] | (s0[off + 1] << 8); + } long fatByte = fatStartSec_ * 512 + (cluster + cluster / 2); long sec = fatByte / 512; int off = (int)(fatByte % 512); uint8_t s0[512], s1[512]; - if (!sectorBytes(sec, s0)) return 0xFFF; + if (!sectorBytes(sec, s0)) return eoc_; uint8_t lo = s0[off]; uint8_t hi; - if (off == 511) { if (!sectorBytes(sec + 1, s1)) return 0xFFF; hi = s1[0]; } + if (off == 511) { if (!sectorBytes(sec + 1, s1)) return eoc_; hi = s1[0]; } else hi = s0[off + 1]; uint16_t v = (uint16_t)(lo | (hi << 8)); return (cluster & 1) ? (v >> 4) & 0xFFF : v & 0xFFF; @@ -291,7 +359,7 @@ std::vector Fat12::readFile(const DirEntry& e) const { long remaining = e.size; long clusterBytes = (long)spc_ * 512; int guard = 0; - while (remaining > 0 && cluster >= 2 && cluster < 0xFF8 && + while (remaining > 0 && cluster >= 2 && cluster < eocMin_ && guard++ < totalClusters_ + 8) { long firstSec = dataStartSec_ + (long)(cluster - 2) * spc_; for (int s = 0; s < spc_ && remaining > 0; ++s) { @@ -320,7 +388,7 @@ long Fat12::freeBytes() const { bool Fat12::readByteAt(long off, uint8_t& out) const { long lba = off / 512; int within = (int)(off % 512); uint8_t sec[512]; - if (!disk_ || !disk_->readSector(lba, sec)) return false; + if (!sectorBytes(lba, sec)) return false; out = sec[within]; return true; } @@ -328,30 +396,41 @@ bool Fat12::readByteAt(long off, uint8_t& out) const { bool Fat12::writeByteAt(long off, uint8_t value) { long lba = off / 512; int within = (int)(off % 512); uint8_t sec[512]; - if (!disk_ || !disk_->readSector(lba, sec)) return false; + if (!sectorBytes(lba, sec)) return false; sec[within] = value; - return disk_->writeLogicalSector(lba, sec); + return putSector(lba, sec); } void Fat12::setFatEntry(int cluster, int value) { - value &= 0xFFF; + if (value == 0 && cluster < nextFree_) nextFree_ = cluster; for (int f = 0; f < nfat_; ++f) { long base = (fatStartSec_ + (long)f * spf_) * 512; + if (fat16_) { + long o = base + (long)cluster * 2; + writeByteAt(o, (uint8_t)(value & 0xFF)); + writeByteAt(o + 1, (uint8_t)((value >> 8) & 0xFF)); + continue; + } + int v12 = value & 0xFFF; long o = base + cluster + cluster / 2; uint8_t lo, hi; if (!readByteAt(o, lo)) continue; if (!readByteAt(o + 1, hi)) continue; uint16_t v = (uint16_t)(lo | (hi << 8)); - if (cluster & 1) v = (uint16_t)((v & 0x000F) | (value << 4)); - else v = (uint16_t)((v & 0xF000) | value); + if (cluster & 1) v = (uint16_t)((v & 0x000F) | (v12 << 4)); + else v = (uint16_t)((v & 0xF000) | v12); writeByteAt(o, (uint8_t)(v & 0xFF)); writeByteAt(o + 1, (uint8_t)(v >> 8)); } } int Fat12::allocCluster() { - for (int c = 2; c < totalClusters_ + 2; ++c) - if (fatEntry(c) == 0) return c; + // Search from the lowest cluster that may be free (setFatEntry lowers the + // hint when it frees one), so filling a large FAT16 volume stays linear. + if (nextFree_ < 2) nextFree_ = 2; + for (int c = nextFree_; c < totalClusters_ + 2; ++c) + if (fatEntry(c) == 0) { nextFree_ = c; return c; } + nextFree_ = totalClusters_ + 2; return -1; } @@ -382,7 +461,7 @@ bool Fat12::writeClusterData(int cluster, const uint8_t* src, long len) { } else { memset(sec, 0, 512); } - if (!disk_->writeLogicalSector(firstSec + s, sec)) return false; + if (!putSector(firstSec + s, sec)) return false; } return true; } @@ -433,7 +512,7 @@ bool Fat12::writeFile(DirEntry& e, const std::vector& data, std::string while ((int)chain.size() < needClusters) { int nc = allocCluster(); if (nc < 0) { releaseNew(); if (err) *err = "disk full (no free clusters)"; return false; } - setFatEntry(nc, 0xFFF); // reserve immediately so the next alloc skips it + setFatEntry(nc, eoc_); // reserve immediately so the next alloc skips it chain.push_back(nc); } @@ -450,7 +529,7 @@ bool Fat12::writeFile(DirEntry& e, const std::vector& data, std::string // Relink the FAT: chain[0..need-1] linked, last -> EOF; free the rest. for (int i = 0; i < needClusters; ++i) { - int next = (i + 1 < needClusters) ? chain[i + 1] : 0xFFF; + int next = (i + 1 < needClusters) ? chain[i + 1] : eoc_; setFatEntry(chain[i], next); } for (size_t i = needClusters; i < chain.size(); ++i) @@ -557,7 +636,7 @@ bool Fat12::undelete(DirEntry& e, char firstChar, std::string* err) { } for (long i = 0; i < need; ++i) { int c = e.startCluster + (int)i; - setFatEntry(c, i + 1 < need ? c + 1 : 0xFFF); + setFatEntry(c, i + 1 < need ? c + 1 : eoc_); } } writeByteAt(e.entryOffset, nm[0]); @@ -650,7 +729,7 @@ std::vector Fat12::extractRawUnnamed() const { long firstSec = dataStartSec_ + (long)(cluster - 2) * spc_; for (int s = 0; s < spc_; ++s) { uint8_t sec[512]; - if (disk_->readSector(firstSec + s, sec)) out.insert(out.end(), sec, sec + 512); + if (sectorBytes(firstSec + s, sec)) out.insert(out.end(), sec, sec + 512); } } return out; @@ -675,10 +754,10 @@ bool Fat12::writeRawUnnamed(const std::vector& data, long* written, std long firstSec = dataStartSec_ + (long)(cluster - 2) * spc_; for (int s = 0; s < spc_ && pos < total; ++s) { uint8_t sec[512]; - if (!disk_->readSector(firstSec + s, sec)) { if (err) *err = "read error"; return false; } + if (!sectorBytes(firstSec + s, sec)) { if (err) *err = "read error"; return false; } long chunk = total - pos; if (chunk > 512) chunk = 512; memcpy(sec, data.data() + pos, (size_t)chunk); // partial tail keeps old bytes - if (!disk_->writeLogicalSector(firstSec + s, sec)) { if (err) *err = "write error"; return false; } + if (!putSector(firstSec + s, sec)) { if (err) *err = "write error"; return false; } pos += chunk; } } @@ -812,7 +891,7 @@ long Fat12::freeDirSlot(int dirCluster, bool allowGrow) { if (nc < 0) return -1; std::vector zero((size_t)spc_ * 512, 0); if (!writeClusterData(nc, zero.data(), (long)zero.size())) return -1; - setFatEntry(nc, 0xFFF); + setFatEntry(nc, eoc_); setFatEntry(chain.back(), nc); return clusterByteOffset(nc); } @@ -906,11 +985,11 @@ bool Fat12::makeDir(const std::string& parentPath, const std::string& name, std: if (slot < 0) { if (err) *err = "parent directory is full"; return false; } int nc = allocCluster(); if (nc < 0) { if (err) *err = "disk full"; return false; } - setFatEntry(nc, 0xFFF); + setFatEntry(nc, eoc_); // Zero the new directory cluster, then write . and .. entries. long firstSec = dataStartSec_ + (long)(nc - 2) * spc_; uint8_t zero[512]; memset(zero, 0, sizeof zero); - for (int s = 0; s < spc_; ++s) disk_->writeLogicalSector(firstSec + s, zero); + for (int s = 0; s < spc_; ++s) putSector(firstSec + s, zero); uint8_t dot[11], dotdot[11]; pack83(".", dot); pack83("..", dotdot); writeDirEntry(firstSec * 512 + 0, dot, 0x10, (uint16_t)nc, 0); writeDirEntry(firstSec * 512 + 32, dotdot, 0x10, (uint16_t)parent, 0); @@ -940,17 +1019,17 @@ Fat12::FsCheck Fat12::verify() const { int c = e.startCluster, guard = 0; long chainLen = 0; bool broken = false; - if (c == 1 || (c >= 2 && c - 2 >= totalClusters_ && c < 0xFF8)) { + if (c == 1 || (c >= 2 && c - 2 >= totalClusters_ && c < eocMin_)) { r.issues.push_back(path + ": start cluster out of range"); broken = true; } - while (!broken && c >= 2 && c < 0xFF8 && guard++ < totalClusters_ + 4) { + while (!broken && c >= 2 && c < eocMin_ && guard++ < totalClusters_ + 4) { int idx = c - 2; if (idx >= (int)owner.size()) { r.issues.push_back(path + ": cluster out of range"); broken = true; break; } if (owner[idx] >= 0) { r.issues.push_back(path + ": cross-linked at cluster " + std::to_string(c)); broken = true; break; } owner[idx] = 1; r.usedByFiles++; chainLen++; int nxt = fatEntry(c); if (nxt == 0) { r.issues.push_back(path + ": chain hits a free cluster"); broken = true; break; } - if (nxt == 1 || (nxt >= 0xFF0 && nxt <= 0xFF7)) { + if (nxt == 1 || nxt == bad_ || (nxt >= totalClusters_ + 2 && nxt < eocMin_)) { r.issues.push_back(path + ": chain hits a reserved/bad cluster marker"); broken = true; break; } c = nxt; @@ -970,7 +1049,7 @@ Fat12::FsCheck Fat12::verify() const { int idx = c - 2; int v = fatEntry(c); if (v == 0) r.freeClusters++; - else if (v == 0xFF7) continue; // marked bad: neither free nor lost + else if (v == bad_) continue; // marked bad: neither free nor lost else if (idx >= 0 && idx < (int)owner.size() && owner[idx] < 0) r.unreferenced++; } return r; diff --git a/core/fat12.h b/core/fat12.h index 0bc4ec7..5b2d6e7 100644 --- a/core/fat12.h +++ b/core/fat12.h @@ -26,7 +26,7 @@ struct DirEntry { uint32_t size = 0; uint16_t date = 0, time = 0; bool deleted = false; // first byte 0xE5 (recoverable) - long entryOffset = -1; // byte offset of the 32-byte record in the image + long entryOffset = -1; // byte offset of the 32-byte record, relative to the volume bool isDir() const { return (attr & 0x10) != 0; } bool isVolume() const { return (attr & 0x08) != 0; } @@ -40,8 +40,17 @@ class Fat12 { // Mount the filesystem. Returns false if the boot sector / BPB is not FAT12. // Takes a non-const image so files can be written back in place. bool mount(DiskImage& disk, std::string* err = nullptr); + // Mount a volume that starts at `firstSector` of the image (a partition of + // a hard disk / SD card image). `sectorCount` caps the volume (0 = to the + // end of the image). + bool mount(DiskImage& disk, long firstSector, long sectorCount, std::string* err = nullptr); bool mounted() const { return mounted_; } + // 12 or 16. FAT12 below 4085 clusters, FAT16 from there (as in MS-DOS). + int fatBits() const { return fat16_ ? 16 : 12; } + // First sector of the mounted volume inside the image (0 for floppies). + long volumeStart() const { return partBase_; } + // BPB-derived facts. int bytesPerSector() const { return bps_; } int sectorsPerCluster() const { return spc_; } @@ -147,7 +156,8 @@ class Fat12 { // Total bytes available in the RAW region (allocated-but-unnamed clusters). long rawUnnamedCapacity() const; - // Byte offset in the image where a data cluster begins (for the raw viewer). + // Byte offset where a data cluster begins, relative to the volume (add + // volumeStart() * 512 for the position in the image). long clusterByteOffset(int cluster) const; int firstDataCluster() const { return 2; } @@ -174,7 +184,8 @@ class Fat12 { std::vector readDirAtCluster(int cluster, bool includeDeleted) const; std::vector readRootDir(bool includeDeleted) const; int fatEntry(int cluster) const; - bool sectorBytes(long lba, uint8_t out[512]) const; + bool sectorBytes(long lba, uint8_t out[512]) const; // volume-relative read + bool putSector(long lba, const uint8_t in[512]); // volume-relative write static std::vector parseEntries(const std::vector& buf, long baseOffset, bool includeDeleted, bool* ended = nullptr); @@ -183,6 +194,14 @@ class Fat12 { bool mounted_ = false; int bps_ = 512, spc_ = 2, rsvd_ = 1, nfat_ = 2, ndir_ = 112; int totalSectors_ = 0, spf_ = 3, totalClusters_ = 0; + bool fat16_ = false; + int eoc_ = 0xFFF, eocMin_ = 0xFF8, bad_ = 0xFF7, rsvMin_ = 0xFF0; // per FAT width + long partBase_ = 0, partLimit_ = 0; + int nextFree_ = 2; // no free cluster below this one + mutable long cacheLba_ = -1; // one-sector read cache + mutable const DiskImage* cacheDisk_ = nullptr; + mutable uint64_t cacheGen_ = 0; + mutable uint8_t cache_[512] = {0}; uint8_t media_ = 0; long fatStartSec_ = 0, rootStartSec_ = 0, dataStartSec_ = 0; int rootSectors_ = 0; diff --git a/core/partition.cpp b/core/partition.cpp new file mode 100644 index 0000000..de5456d --- /dev/null +++ b/core/partition.cpp @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Alonso (GS·RUN). MSXFileForge. +// Free software under the GNU GPL v3 (or later) - see LICENSE. Modified +// versions must be distributed under the same license. +#include "partition.h" +#include +#include + +namespace msx { + +namespace { +uint16_t rd16(const uint8_t* p) { return (uint16_t)(p[0] | (p[1] << 8)); } +uint32_t rd32(const uint8_t* p) { return p[0] | (p[1] << 8) | (p[2] << 16) | ((uint32_t)p[3] << 24); } +void wr16(uint8_t* p, uint32_t v) { p[0] = v & 0xFF; p[1] = (v >> 8) & 0xFF; } +void wr32(uint8_t* p, uint32_t v) { wr16(p, v); wr16(p + 2, v >> 16); } +bool isExtended(uint8_t t) { return t == 0x05 || t == 0x0F || t == 0x85; } +bool isPow2(int v) { return v > 0 && (v & (v - 1)) == 0; } +} // namespace + +const char* partitionTypeName(uint8_t t) { + switch (t) { + case 0x00: return "empty"; + case 0x01: return "FAT12"; + case 0x04: return "FAT16 (<32MB)"; + case 0x05: return "extended"; + case 0x06: return "FAT16"; + case 0x0B: case 0x0C: return "FAT32"; + case 0x0E: return "FAT16 (LBA)"; + case 0x0F: return "extended (LBA)"; + default: return "other"; + } +} + +bool looksLikeFatBoot(const uint8_t* b) { + int spc = b[0x0D], nfat = b[0x10], ndir = rd16(b + 0x11), spf = rd16(b + 0x16); + long total = rd16(b + 0x13); + if (total == 0) total = (long)rd32(b + 0x20); + return (b[0] == 0xEB || b[0] == 0xE9) && rd16(b + 0x0B) == 512 && isPow2(spc) && + nfat >= 1 && nfat <= 4 && ndir > 0 && ndir % 16 == 0 && spf > 0 && + rd16(b + 0x0E) > 0 && total > 0; +} + +std::vector findPartitions(const DiskImage& disk) { + std::vector out; + uint8_t mbr[512]; + if (!disk.readSector(0, mbr) || mbr[510] != 0x55 || mbr[511] != 0xAA) return out; + if (looksLikeFatBoot(mbr)) return out; // superfloppy + const int64_t total = disk.logicalSectors(); + // Floppy-sized images are never partitioned: boot code there can contain + // 0x55AA and bytes at 0x1BE that happen to look like a table. + if (total <= 2 * 2880) return out; + uint8_t s1[512]; // FAT right after a boot sector? + if (disk.readSector(1, s1) && s1[0] >= 0xF8 && s1[1] == 0xFF && s1[2] == 0xFF) return out; + + // Fields are unsigned 32-bit; all arithmetic in 64 bits (long is 32-bit on + // Windows) and every result must fit a positive long. + auto field = [](const uint8_t* e, int off) { return (int64_t)rd32(e + off); }; + auto valid = [&](const uint8_t* e, int64_t base, int64_t lo, int64_t hi) { + int64_t start = base + field(e, 8), count = field(e, 12); + return (e[0] == 0x00 || e[0] == 0x80) && e[4] != 0 && field(e, 8) > 0 && count > 0 && + start >= lo && start + count <= hi && start + count <= 0x7FFFFFFF; + }; + std::vector prim; + for (int i = 0; i < 4; ++i) { + const uint8_t* e = mbr + 0x1BE + i * 16; + if (e[4] == 0) continue; + if (!valid(e, 0, 1, total)) return {}; // malformed: not an MBR + prim.push_back(e); + } + if (prim.empty()) return out; + + auto overlaps = [&](int64_t a, int64_t n) { + for (auto& p : out) + if (a < (int64_t)p.firstSector + p.sectorCount && (int64_t)p.firstSector < a + n) return true; + return false; + }; + int number = 0; + int64_t extStart = -1, extEnd = -1; + for (const uint8_t* e : prim) { + if (isExtended(e[4])) { + if (extStart < 0) { extStart = field(e, 8); extEnd = extStart + field(e, 12); } + continue; + } + if (overlaps(field(e, 8), field(e, 12))) continue; // never expose two views of one area + Partition p; + p.number = ++number; p.type = e[4]; + p.firstSector = (long)field(e, 8); p.sectorCount = (long)field(e, 12); + out.push_back(p); + } + // Logical drives: a chain of EBRs. Entry 0 = the drive (relative to this + // EBR), entry 1 = the next EBR (relative to the extended partition). Both + // must stay inside the extended partition. + std::set seen; + int64_t ebr = extStart; + while (ebr > 0 && ebr < extEnd && seen.insert(ebr).second && seen.size() <= 4096) { + uint8_t s[512]; + if (!disk.readSector((long)ebr, s) || s[510] != 0x55 || s[511] != 0xAA) break; + const uint8_t* e0 = s + 0x1BE; + const uint8_t* e1 = s + 0x1CE; + if (e0[4] != 0 && !isExtended(e0[4]) && valid(e0, ebr, ebr + 1, extEnd) && + !overlaps(ebr + field(e0, 8), field(e0, 12))) { + Partition p; + p.number = ++number; p.type = e0[4]; p.logical = true; + p.firstSector = (long)(ebr + field(e0, 8)); p.sectorCount = (long)field(e0, 12); + out.push_back(p); + } + ebr = (e1[4] != 0 && isExtended(e1[4])) ? extStart + field(e1, 8) : -1; + } + return out; +} + +std::vector formatFatVolume(long totalSectors, long hiddenSectors, const std::string& label) { + std::vector img; + if (totalSectors < 64) return img; + const int rsvd = 1, nfat = 2, ndir = 512; + const long rootSecs = ndir * 32 / 512; + // FAT16 if the volume can hold at least 4085 clusters, using the smallest + // cluster size that keeps the count <= 65524; otherwise FAT12 (<= 4084). + auto layout = [&](bool f16, int c, long& fatSecs) { + fatSecs = 1; + long n = 0; + for (int it = 0; it < 16; ++it) { // FAT size and cluster count depend on each other + n = (totalSectors - rsvd - nfat * fatSecs - rootSecs) / c; + if (n < 0) n = 0; + long bytes = f16 ? (n + 2) * 2 : ((n + 2) * 3 + 1) / 2; + fatSecs = (bytes + 511) / 512; + } + n = (totalSectors - rsvd - nfat * fatSecs - rootSecs) / c; + // The iteration can settle one sector short; grow the FAT until it + // holds every cluster the BPB implies. + auto need = [&](long k) { return f16 ? (k + 2) * 2 : ((k + 2) * 3 + 1) / 2; }; + while (n > 0 && need(n) > fatSecs * 512) { + ++fatSecs; + n = (totalSectors - rsvd - nfat * fatSecs - rootSecs) / c; + } + return n; + }; + bool fat16 = false; + int spc = 0; + long spf = 0, clusters = 0; + for (int c = 1; c <= 128; c *= 2) { + long f; long n = layout(true, c, f); + if (n <= 65524) { if (n >= 4085) { fat16 = true; spc = c; spf = f; clusters = n; } break; } + } + if (!fat16) { + for (int c = 1; c <= 128; c *= 2) { + long f; long n = layout(false, c, f); + if (n <= 4084) { spc = c; spf = f; clusters = n; break; } + } + } + if (spc == 0 || clusters < 1) return img; + + img.assign((size_t)totalSectors * 512, 0); + uint8_t* b = img.data(); + b[0] = 0xEB; b[1] = 0x3C; b[2] = 0x90; + memcpy(b + 3, "MSXFForg", 8); + wr16(b + 0x0B, 512); + b[0x0D] = (uint8_t)spc; + wr16(b + 0x0E, rsvd); + b[0x10] = nfat; + wr16(b + 0x11, ndir); + if (totalSectors < 65536) wr16(b + 0x13, (uint32_t)totalSectors); + else wr32(b + 0x20, (uint32_t)totalSectors); + b[0x15] = 0xF8; + wr16(b + 0x16, (uint32_t)spf); + wr16(b + 0x18, 63); wr16(b + 0x1A, 255); // nominal CHS geometry + wr32(b + 0x1C, (uint32_t)hiddenSectors); + b[0x24] = 0x80; // drive number + b[0x26] = 0x29; // extended boot signature + wr32(b + 0x27, 0x4D535846u ^ (uint32_t)totalSectors); // volume serial + char lab[12]; memset(lab, ' ', 11); lab[11] = 0; + if (label.empty()) memcpy(lab, "NO NAME", 7); + for (size_t i = 0; i < label.size() && i < 11; ++i) lab[i] = (char)toupper((unsigned char)label[i]); + memcpy(b + 0x2B, lab, 11); + memcpy(b + 0x36, fat16 ? "FAT16 " : "FAT12 ", 8); + b[0x3E] = 0x18; b[0x3F] = 0xFE; // boot code: JR $ (not bootable) + b[0x1FE] = 0x55; b[0x1FF] = 0xAA; + for (int f = 0; f < nfat; ++f) { + uint8_t* fat = b + (size_t)(rsvd + f * spf) * 512; + fat[0] = 0xF8; fat[1] = 0xFF; fat[2] = 0xFF; + if (fat16) fat[3] = 0xFF; + } + if (!label.empty()) { // root VOL entry too + uint8_t* root = b + (size_t)(rsvd + nfat * spf) * 512; + memcpy(root, lab, 11); + root[0x0B] = 0x08; + } + return img; +} + +std::vector buildPartitionedImage(const std::vector& sizes, std::string* err) { + std::vector img; + if (sizes.empty()) { if (err) *err = "no partitions requested"; return img; } + for (long s : sizes) + if (s < 64) { if (err) *err = "a partition needs at least 64 sectors"; return img; } + const long kAlign = 63; // first volume at sector 63, classic layout + bool useExt = sizes.size() > 4; + long total = kAlign; + for (size_t i = 0; i < sizes.size(); ++i) total += sizes[i] + ((useExt && i > 0) ? 1 : 0); + if (total > 0x7FFFFFFFL / 512) { if (err) *err = "image too large"; return img; } + img.assign((size_t)total * 512, 0); + + auto typeFor = [](const std::vector& vol, long secs) -> uint8_t { + bool f16 = memcmp(&vol[0x36], "FAT16", 5) == 0; // as formatFatVolume chose + return !f16 ? 0x01 : (secs < 65536 ? 0x04 : 0x06); + }; + auto putEntry = [](uint8_t* e, uint8_t type, uint32_t start, uint32_t count) { + e[0] = 0x00; + e[1] = 0xFE; e[2] = 0xFF; e[3] = 0xFF; // CHS "use LBA" + e[4] = type; + e[5] = 0xFE; e[6] = 0xFF; e[7] = 0xFF; + wr32(e + 8, start); wr32(e + 12, count); + }; + uint8_t* mbr = img.data(); + long pos = kAlign; + size_t primaries = useExt ? 1 : sizes.size(); + for (size_t i = 0; i < primaries; ++i) { + auto vol = formatFatVolume(sizes[i], pos); + if (vol.empty()) { if (err) *err = "cannot format partition " + std::to_string(i + 1); return {}; } + memcpy(&img[(size_t)pos * 512], vol.data(), vol.size()); + putEntry(mbr + 0x1BE + i * 16, typeFor(vol, sizes[i]), (uint32_t)pos, (uint32_t)sizes[i]); + pos += sizes[i]; + } + if (useExt) { + long extStart = pos, extSize = total - pos; + putEntry(mbr + 0x1BE + 16, 0x05, (uint32_t)extStart, (uint32_t)extSize); + for (size_t i = 1; i < sizes.size(); ++i) { + long ebr = pos, volStart = pos + 1; + auto vol = formatFatVolume(sizes[i], volStart); + if (vol.empty()) { if (err) *err = "cannot format partition " + std::to_string(i + 1); return {}; } + memcpy(&img[(size_t)volStart * 512], vol.data(), vol.size()); + uint8_t* s = &img[(size_t)ebr * 512]; + putEntry(s + 0x1BE, typeFor(vol, sizes[i]), 1, (uint32_t)sizes[i]); + long next = volStart + sizes[i]; + if (i + 1 < sizes.size()) + putEntry(s + 0x1CE, 0x05, (uint32_t)(next - extStart), (uint32_t)(sizes[i + 1] + 1)); + s[510] = 0x55; s[511] = 0xAA; + pos = next; + } + } + mbr[510] = 0x55; mbr[511] = 0xAA; + return img; +} + +} // namespace msx diff --git a/core/partition.h b/core/partition.h new file mode 100644 index 0000000..b440d1f --- /dev/null +++ b/core/partition.h @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Alonso (GS·RUN). MSXFileForge. +// Free software under the GNU GPL v3 (or later) - see LICENSE. Modified +// versions must be distributed under the same license. +// MSXFileForge - hard disk / SD card images: MBR partitions and FAT volumes. +// +// MSX-DOS 2 and Nextor devices use a PC-style MBR. Nextor's FDISK typically +// creates one primary partition plus an extended partition holding further +// logical drives, all FAT12 or FAT16. A "superfloppy" (no partition table, +// FAT boot sector at sector 0) is reported as having no partitions. +#pragma once +#include +#include +#include +#include "disk_image.h" + +namespace msx { + +struct Partition { + int number = 0; // 1-based, in table order (primaries, then logicals) + uint8_t type = 0; // MBR type byte (0x01 FAT12, 0x04/0x06/0x0E FAT16, ...) + long firstSector = 0; // absolute LBA in the image + long sectorCount = 0; + bool logical = false; // inside an extended partition + bool fat() const { return type == 0x01 || type == 0x04 || type == 0x06 || type == 0x0E; } +}; + +const char* partitionTypeName(uint8_t type); + +// True if the 512 bytes look like a FAT boot sector with a usable BPB. +bool looksLikeFatBoot(const uint8_t sector[512]); + +// Partitions listed by the MBR (and the chain of extended boot records). +// Empty when sector 0 is not an MBR (floppy / superfloppy images). +std::vector findPartitions(const DiskImage& disk); + +// A freshly formatted FAT volume of `totalSectors` 512-byte sectors: FAT16 +// when it can hold 4085+ clusters, else FAT12. `hiddenSectors` is the +// volume's LBA on its disk (stored in the BPB). `label` up to 11 chars. +std::vector formatFatVolume(long totalSectors, long hiddenSectors = 0, + const std::string& label = ""); + +// A whole disk image: an MBR followed by one FAT volume per entry of +// `sizesSectors`. Up to 4 become primary partitions; with more, the first is +// primary and the rest are logical drives in an extended partition. +std::vector buildPartitionedImage(const std::vector& sizesSectors, + std::string* err = nullptr); + +} // namespace msx diff --git a/gui/main.cpp b/gui/main.cpp index 6deac48..9348b29 100644 --- a/gui/main.cpp +++ b/gui/main.cpp @@ -36,6 +36,7 @@ #include "../core/disk_image.h" #include "../core/fat12.h" +#include "../core/partition.h" #include "../core/msxfile.h" #include "../core/rom.h" #include "../core/cas.h" @@ -59,6 +60,8 @@ struct App { DiskImage disk; Fat12 fs; bool fsMounted = false; + std::vector parts; // hard disk images: MBR partitions + int partIdx = 0; std::vector listing; std::string cwd = "/"; bool showDeleted = false; @@ -205,6 +208,17 @@ static void selectFile(App& a, int idx) { if (a.selType == MsxFileType::BloadBinary) a.bload = parseBloadHeader(a.selData); } +// Mount the filesystem: the image itself, or the selected partition. +static bool mountCurrent(App& a, std::string& err) { + if (a.parts.empty()) return a.fs.mount(a.disk, &err); + if (a.partIdx < 0 || a.partIdx >= (int)a.parts.size()) a.partIdx = 0; + const Partition& p = a.parts[(size_t)a.partIdx]; + if (a.fs.mount(a.disk, p.firstSector, p.sectorCount, &err)) return true; + std::string plain; // junk "table" in a floppy boot sector: read the image as one volume + if (a.fs.mount(a.disk, &plain)) { a.parts.clear(); err.clear(); return true; } + return false; +} + static void openPath(App& a, const std::string& path) { a.imagePath = path; a.mode = Mode::None; a.fsMounted = false; a.listing.clear(); a.cas.clear(); a.blob.clear(); a.cwd = "/"; @@ -241,11 +255,15 @@ static void openPath(App& a, const std::string& path) { std::string err; if (!a.disk.load(path, &err)) { a.status = "Load error: " + err; return; } a.mode = Mode::Disk; - a.fsMounted = a.fs.mount(a.disk, &err); + a.parts = findPartitions(a.disk); + a.partIdx = 0; + for (size_t i = 0; i < a.parts.size(); ++i) if (a.parts[i].fat()) { a.partIdx = (int)i; break; } + a.fsMounted = mountCurrent(a, err); if (a.fsMounted) refreshListing(a); else a.listing.clear(); a.status = std::string("Loaded ") + to_string(a.disk.format()) + (a.disk.isProtected() ? " [PROTECTED]" : "") + - (a.fsMounted ? " | FAT12 OK" : (" | no FS: " + err)); + (a.parts.empty() ? "" : " | " + std::to_string(a.parts.size()) + " partition(s)") + + (a.fsMounted ? " | FAT" + std::to_string(a.fs.fatBits()) + " OK" : (" | no FS: " + err)); } // Write the current file's edited bytes back into the image (in memory). @@ -629,7 +647,7 @@ static void drawRawMap(App& a) { : map[idx] == Fat12::CL_RAW ? "RAW (unnamed)" : "free"; ImGui::BeginTooltip(); ImGui::Text("cluster %d (%s)", idx + 2, st); - ImGui::Text("image offset 0x%06lX", a.fs.clusterByteOffset(idx + 2)); + ImGui::Text("image offset 0x%06lX", a.fs.volumeStart() * 512 + a.fs.clusterByteOffset(idx + 2)); ImGui::EndTooltip(); } } @@ -642,8 +660,31 @@ static void drawFileTree(App& a) { "Their data may still be recoverable if the clusters were not reused."); ImGui::SameLine(); if (ImGui::Button("Refresh")) refreshListing(a); + if (!a.parts.empty()) { + auto label = [](const Partition& p) { + char buf[96]; + snprintf(buf, sizeof buf, "%d: %s, %ld MB%s", p.number, partitionTypeName(p.type), + p.sectorCount / 2048, p.logical ? " (logical)" : ""); + return std::string(buf); + }; + ImGui::SetNextItemWidth(220); + if (ImGui::BeginCombo("Partition", label(a.parts[(size_t)a.partIdx]).c_str())) { + for (int i = 0; i < (int)a.parts.size(); ++i) { + if (ImGui::Selectable(label(a.parts[(size_t)i]).c_str(), i == a.partIdx) && i != a.partIdx) { + a.partIdx = i; a.cwd = "/"; clearSelection(a); + std::string err; + a.fsMounted = mountCurrent(a, err); + if (a.fsMounted) { refreshListing(a); a.status = "Partition " + std::to_string(a.parts[(size_t)i].number) + ": FAT" + std::to_string(a.fs.fatBits()); } + else { a.listing.clear(); a.status = "Partition " + std::to_string(a.parts[(size_t)i].number) + ": no FS: " + err; } + } + } + ImGui::EndCombo(); + } + help("This is a hard disk / SD card image with a partition table (MSX-DOS 2 / Nextor). " + "Each partition is a separate drive."); + } ImGui::Separator(); - if (!a.fsMounted) { ImGui::TextDisabled("No FAT12 filesystem on this image."); return; } + if (!a.fsMounted) { ImGui::TextDisabled("No FAT filesystem on this image / partition."); return; } // Directory edit toolbar. if (ImGui::Button("Add file...")) { @@ -1226,11 +1267,16 @@ int main(int argc, char** argv) { // Info strip ImGui::TextDisabled("Format:"); ImGui::SameLine(); ImGui::TextColored(kAccent, "%s", to_string(app.disk.format())); - ImGui::SameLine(); ImGui::TextDisabled("| Geometry:"); ImGui::SameLine(); - ImGui::Text("%dc x %dh x %ds", app.disk.cylinders(), app.disk.heads(), app.disk.sectorsPerTrack()); + if (app.disk.format() == DiskFormat::HDD) { // CHS means nothing here: show the size + ImGui::SameLine(); ImGui::TextDisabled("| Size:"); ImGui::SameLine(); + ImGui::Text("%ld MB (%ld sectors)", app.disk.logicalSectors() / 2048, app.disk.logicalSectors()); + } else { + ImGui::SameLine(); ImGui::TextDisabled("| Geometry:"); ImGui::SameLine(); + ImGui::Text("%dc x %dh x %ds", app.disk.cylinders(), app.disk.heads(), app.disk.sectorsPerTrack()); + } if (app.disk.isProtected()) { ImGui::SameLine(); ImGui::TextColored(kWarn, "| PROTECTED"); } if (app.fsMounted) { - ImGui::SameLine(); ImGui::TextDisabled("| FAT12:"); ImGui::SameLine(); + ImGui::SameLine(); ImGui::TextDisabled(app.fs.fatBits() == 16 ? "| FAT16:" : "| FAT12:"); ImGui::SameLine(); ImGui::Text("OEM '%s' free %ld KB", app.fs.oemName().c_str(), app.fs.freeBytes()/1024); } ImGui::Separator(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6ff2c48..5456263 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,5 +1,5 @@ add_executable(msxff_tests - test_main.cpp test_fat12.cpp test_basic.cpp test_tape.cpp test_rom.cpp test_disk.cpp) + test_main.cpp test_fat12.cpp test_basic.cpp test_tape.cpp test_rom.cpp test_disk.cpp test_hdd.cpp) target_link_libraries(msxff_tests PRIVATE msxcore) add_test(NAME core COMMAND msxff_tests) set_tests_properties(core PROPERTIES TIMEOUT 120) diff --git a/tests/cli_smoke.cmake b/tests/cli_smoke.cmake index a807283..63bb5b8 100644 --- a/tests/cli_smoke.cmake +++ b/tests/cli_smoke.cmake @@ -107,4 +107,23 @@ run(0 "${MSXFORGE}" fsck "${WORK}/p.dsk") run(0 "${MSXFORGE}" label "${WORK}/p.dsk" --clear) expect_in("removed") +# ---- hard disk images: FAT16, partitions ------------------------------------ +run(0 "${MSXFORGE}" mkhdd "${WORK}/h.img" 8M,4M,2M,3M,2M) +run(0 "${MSXFORGE}" info "${WORK}/h.img") +expect_in("Partitions : 5") +expect_in("FAT16") +run(0 "${MSXFORGE}" add "${WORK}/h.img" "${WORK}/notes.txt" / ONE.TXT) # partition 1 +run(0 "${MSXFORGE}" add "${WORK}/h.img" "${WORK}/notes.txt" / FOUR.TXT --part 4) +run(0 "${MSXFORGE}" ls "${WORK}/h.img" --part 4) +expect_in("FOUR.TXT") +run(0 "${MSXFORGE}" ls "${WORK}/h.img" --part 1) +expect_in("ONE.TXT") +run(4 "${MSXFORGE}" cat "${WORK}/h.img" FOUR.TXT --part 1) +run(3 "${MSXFORGE}" ls "${WORK}/h.img" --part 9) +run(0 "${MSXFORGE}" fsck "${WORK}/h.img" --part 4) +run(0 "${MSXFORGE}" format "${WORK}/sd.img" 40M NEXTOR) +run(0 "${MSXFORGE}" info "${WORK}/sd.img") +expect_in("FAT16") +expect_in("NEXTOR") + message(STATUS "cli smoke test passed") diff --git a/tests/test_hdd.cpp b/tests/test_hdd.cpp new file mode 100644 index 0000000..f801325 --- /dev/null +++ b/tests/test_hdd.cpp @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Alonso (GS·RUN). MSXFileForge. +// Free software under the GNU GPL v3 (or later) - see LICENSE. Modified +// versions must be distributed under the same license. +// Hard disk / SD images: FAT16 volumes, MBR + extended partitions. +#include "check.h" +#include "disk_image.h" +#include "fat12.h" +#include "partition.h" +#include "util.h" +#include + +using namespace msx; + +namespace { + +std::vector pattern(size_t n, uint8_t seed) { + std::vector v(n); + uint32_t x = 0xC0FFEEu ^ seed; + for (auto& b : v) { x ^= x << 13; x ^= x >> 17; x ^= x << 5; b = (uint8_t)x; } + return v; +} +const long MB = 2048; // sectors + +bool clean(const Fat12& fs) { + auto r = fs.verify(); + for (auto& i : r.issues) fprintf(stderr, " fsck: %s\n", i.c_str()); + return r.issues.empty(); +} + +} // namespace + +TEST(hdd_fat16_superfloppy) { + std::string err; + DiskImage dk; + CHECK(dk.loadBytes(formatFatVolume(32 * MB, 0, "nextor"), "sd.dsk", &err)); + CHECK_EQ(dk.format(), DiskFormat::HDD); + CHECK(findPartitions(dk).empty()); + Fat12 fs; + CHECK(fs.mount(dk, &err)); + CHECK_EQ(fs.fatBits(), 16); + CHECK_EQ(fs.sectorsPerCluster(), 1); // 65536 sectors still fit 1-sector clusters + CHECK(fs.totalClusters() >= 4085 && fs.totalClusters() <= 65524); + CHECK_EQ(fs.volumeLabel(), std::string("NEXTOR")); + CHECK(clean(fs)); +} + +TEST(hdd_fat16_chains_past_cluster_4095) { + // Cluster numbers above 0xFFF exist only on FAT16: a chain through 4095 + // must not be mistaken for an end-of-chain marker. + std::string err; + DiskImage dk; + CHECK(dk.loadBytes(formatFatVolume(32 * MB), "sd.dsk", &err)); + Fat12 fs; CHECK(fs.mount(dk, &err)); + fs.setTimestamp(0x5A21, 0); + auto big = pattern(6 * 1024 * 1024, 1); // ~12300 clusters of 512 bytes + CHECK(fs.createFile("/", "BIG.BIN", big, &err)); + CHECK(fs.makeDir("/", "SUB", &err)); + CHECK(fs.createFile("/SUB", "TAIL.BIN", pattern(5000, 2), &err)); + long freeBefore = fs.freeBytes(); + DiskImage again; CHECK(again.loadBytes(dk.raw(), "sd.dsk", &err)); + Fat12 fs2; CHECK(fs2.mount(again, &err)); + DirEntry e; + CHECK(fs2.lookup("/BIG.BIN", e)); CHECK(fs2.readFile(e) == big); + CHECK(fs2.lookup("/SUB/TAIL.BIN", e)); CHECK(fs2.readFile(e) == pattern(5000, 2)); + CHECK(e.startCluster > 4095); + CHECK_EQ(fs2.freeBytes(), freeBefore); + CHECK(clean(fs2)); + // Shrink and delete free everything again. + CHECK(fs2.lookup("/BIG.BIN", e)); + CHECK(fs2.writeFile(e, pattern(10, 3), &err)); + CHECK(fs2.deleteFile(e, &err)); + CHECK(clean(fs2)); +} + +TEST(hdd_small_volume_is_fat12) { + std::string err; + DiskImage dk; + CHECK(dk.loadBytes(formatFatVolume(2 * MB), "small.dsk", &err)); + Fat12 fs; CHECK(fs.mount(dk, &err)); + CHECK_EQ(fs.fatBits(), 12); + CHECK(fs.createFile("/", "A.BIN", pattern(300000, 4), &err)); + DirEntry e; CHECK(fs.lookup("/A.BIN", e)); + CHECK(fs.readFile(e) == pattern(300000, 4)); + CHECK(clean(fs)); +} + +TEST(hdd_cluster_size_grows_with_volume) { + std::string err; + struct { long sectors; int spc; } cases[] = {{64 * MB, 2}, {128 * MB, 4}, {512 * MB, 16}}; + for (auto& c : cases) { + DiskImage dk; CHECK(dk.loadBytes(formatFatVolume(c.sectors), "v.img", &err)); + Fat12 fs; CHECK(fs.mount(dk, &err)); + CHECK_EQ(fs.fatBits(), 16); + CHECK_EQ(fs.sectorsPerCluster(), c.spc); + CHECK(fs.totalClusters() >= 4085 && fs.totalClusters() <= 65524); + } +} + +TEST(hdd_large_volume_uses_32bit_sector_count) { + std::string err; + auto vol = formatFatVolume(100 * MB); + CHECK_EQ(vol[0x13] | (vol[0x14] << 8), 0); // > 65535 sectors: 0x20 field + DiskImage dk; CHECK(dk.loadBytes(vol, "big.dsk", &err)); + Fat12 fs; CHECK(fs.mount(dk, &err)); + CHECK_EQ(fs.fatBits(), 16); + CHECK_EQ(fs.totalBytes(), 100L * 1024 * 1024); + CHECK(fs.totalClusters() <= 65524); +} + +TEST(hdd_primary_partitions) { + std::string err; + auto img = buildPartitionedImage({16 * MB, 8 * MB, 2 * MB}, &err); + CHECK(!img.empty()); + DiskImage dk; CHECK(dk.loadBytes(img, "hdd.img", &err)); + auto parts = findPartitions(dk); + CHECK_EQ(parts.size(), (size_t)3); + if (parts.size() != 3) return; + CHECK_EQ(parts[0].firstSector, 63L); + CHECK_EQ(parts[0].type, (uint8_t)0x04); + CHECK_EQ(parts[2].type, (uint8_t)0x01); // 2MB -> FAT12 + CHECK(!parts[1].logical); + // Each partition is its own filesystem; writes stay inside it. + for (auto& p : parts) { + Fat12 fs; + CHECK(fs.mount(dk, p.firstSector, p.sectorCount, &err)); + CHECK_EQ(fs.volumeStart(), p.firstSector); + char nm[16]; snprintf(nm, sizeof nm, "P%d.DAT", p.number); + CHECK(fs.createFile("/", nm, pattern(50000, (uint8_t)p.number), &err)); + } + for (auto& p : parts) { + Fat12 fs; CHECK(fs.mount(dk, p.firstSector, p.sectorCount, &err)); + auto ls = fs.list("/"); + CHECK_EQ(ls.size(), (size_t)1); + DirEntry e; + char nm[16]; snprintf(nm, sizeof nm, "/P%d.DAT", p.number); + CHECK(fs.lookup(nm, e)); + CHECK(fs.readFile(e) == pattern(50000, (uint8_t)p.number)); + CHECK(clean(fs)); + } + // The whole image does not mount as a single FAT volume. + Fat12 whole; CHECK(!whole.mount(dk, &err)); +} + +TEST(hdd_extended_partition_chain) { + std::string err; + auto img = buildPartitionedImage({4 * MB, 4 * MB, 3 * MB, 5 * MB, 2 * MB, 6 * MB}, &err); + DiskImage dk; CHECK(dk.loadBytes(img, "hdd.img", &err)); + auto parts = findPartitions(dk); + CHECK_EQ(parts.size(), (size_t)6); + if (parts.size() != 6) return; + CHECK(!parts[0].logical); + for (size_t i = 1; i < parts.size(); ++i) CHECK(parts[i].logical); + CHECK_EQ(parts[3].sectorCount, 5 * MB); + for (auto& p : parts) { + Fat12 fs; + CHECK(fs.mount(dk, p.firstSector, p.sectorCount, &err)); + CHECK_EQ(fs.totalBytes(), p.sectorCount * 512); + CHECK(clean(fs)); + } +} + +TEST(hdd_mbr_rejections) { + std::string err; + // A FAT boot sector is never read as an MBR, even with 0x55AA. + DiskImage dk; CHECK(dk.loadBytes(formatFatVolume(8 * MB), "a.img", &err)); + CHECK(findPartitions(dk).empty()); + // An entry that runs past the end of the image invalidates the table. + auto img = buildPartitionedImage({4 * MB}, &err); + img[0x1BE + 12] = 0xFF; img[0x1BE + 13] = 0xFF; img[0x1BE + 14] = 0xFF; + CHECK(dk.loadBytes(img, "b.img", &err)); + CHECK(findPartitions(dk).empty()); + // A self-referencing EBR chain terminates. + auto ext = buildPartitionedImage({4 * MB, 4 * MB, 4 * MB, 4 * MB, 4 * MB}, &err); + DiskImage d2; CHECK(d2.loadBytes(ext, "c.img", &err)); + auto parts = findPartitions(d2); + CHECK_EQ(parts.size(), (size_t)5); + long extStart = ext[0x1CE + 8] | (ext[0x1CE + 9] << 8) | (ext[0x1CE + 10] << 16); + uint8_t* ebr2 = &ext[(size_t)(extStart + 1 + 4 * MB) * 512]; // second EBR + ebr2[0x1CE + 8] = 0; ebr2[0x1CE + 9] = 0; ebr2[0x1CE + 10] = 0; ebr2[0x1CE + 11] = 0; // -> back to the first + CHECK(d2.loadBytes(ext, "c.img", &err)); + CHECK(findPartitions(d2).size() <= 5); +} + +TEST(hdd_fat32_is_refused) { + std::string err; + // A realistic FAT32 boot sector: no root entry count, no 16-bit sizes, + // sectors-per-FAT and root cluster in the FAT32 extension. + std::vector vol((size_t)MB * 512, 0); // 1MB is enough to hold the BPB + uint8_t* b = vol.data(); + b[0] = 0xEB; b[1] = 0x58; b[2] = 0x90; + b[0x0B] = 0x00; b[0x0C] = 0x02; // 512 bytes/sector + b[0x0D] = 8; b[0x0E] = 32; // spc, reserved + b[0x10] = 2; // FATs + b[0x15] = 0xF8; + b[0x20] = 0x00; b[0x21] = 0x00; b[0x22] = 0x02; // total32 + b[0x24] = 0x00; b[0x25] = 0x04; // spf32 + b[0x2C] = 2; // root cluster + b[0x1FE] = 0x55; b[0x1FF] = 0xAA; + DiskImage dk; CHECK(dk.loadBytes(vol, "f32.img", &err)); + Fat12 fs; + CHECK(!fs.mount(dk, &err)); + CHECK(err.find("FAT32") != std::string::npos); +} + +// ---- Regressions found in review ------------------------------------------ + +namespace { +// Clusters the BPB of a formatted volume implies, and whether its FAT holds them. +bool fatHoldsClusters(const std::vector& v, long* clustersOut = nullptr) { + auto r16 = [&](int o) { return (long)(v[(size_t)o] | (v[(size_t)o + 1] << 8)); }; + long total = r16(0x13); + if (!total) total = r16(0x20) | (r16(0x22) << 16); + long spc = v[0x0D], rsvd = r16(0x0E), nfat = v[0x10], ndir = r16(0x11), spf = r16(0x16); + long n = (total - rsvd - nfat * spf - (ndir * 32 + 511) / 512) / spc; + bool f16 = memcmp(&v[0x36], "FAT16", 5) == 0; + if (clustersOut) *clustersOut = n; + if (f16 != (n >= 4085)) return false; // label must match Microsoft's rule + long bytes = f16 ? (n + 2) * 2 : ((n + 2) * 3 + 1) / 2; + return bytes <= spf * 512; +} +} // namespace + +TEST(hdd_truncated_fat16_stays_fat16) { + std::string err; + auto vol = formatFatVolume(8192); // FAT16, 8095 clusters + { + DiskImage dk; CHECK(dk.loadBytes(vol, "v.img", &err)); + Fat12 fs; CHECK(fs.mount(dk, &err)); + CHECK(fs.createFile("/", "A.BIN", pattern(100 * 1024, 1), &err)); + vol = dk.raw(); + } + vol.resize(4000 * 512); // truncated dump + DiskImage dk; CHECK(dk.loadBytes(vol, "v.img", &err)); + Fat12 fs; CHECK(fs.mount(dk, &err)); + CHECK_EQ(fs.fatBits(), 16); + DirEntry e; CHECK(fs.lookup("/A.BIN", e)); + CHECK(fs.readFile(e) == pattern(100 * 1024, 1)); + CHECK(clean(fs)); + // A partition entry smaller than its volume's BPB: same rule. + DiskImage d2; CHECK(d2.loadBytes(formatFatVolume(8192), "w.img", &err)); + Fat12 f2; CHECK(f2.mount(d2, 0, 4000, &err)); + CHECK_EQ(f2.fatBits(), 16); +} + +TEST(hdd_floppy_with_table_like_boot_code_is_a_floppy) { + std::string err; + for (int variant = 0; variant < 3; ++variant) { + DiskImage base; CHECK(base.loadBytes(formatBlankImage(1440), "f.dsk", &err)); + Fat12 f0; CHECK(f0.mount(base, &err)); + CHECK(f0.createFile("/", "GAME.BAS", pattern(1000, 7), &err)); + auto img = base.raw(); + uint8_t* e = &img[0x1BE]; + e[0] = 0; e[4] = variant == 2 ? 0x01 : 0x21; e[8] = 10; e[12] = 100; + img[510] = 0x55; img[511] = 0xAA; + if (variant == 0) img[0] = 0x00; // valid BPB, odd jump byte + if (variant >= 1) memset(&img[3], 0, 0x1E - 3); // no BPB: media-byte floppy + DiskImage dk; CHECK(dk.loadBytes(img, "f.dsk", &err)); + CHECK(findPartitions(dk).empty()); + Fat12 fs; CHECK(fs.mount(dk, &err)); + DirEntry x; CHECK(fs.lookup("/GAME.BAS", x)); + CHECK(fs.readFile(x) == pattern(1000, 7)); + } +} + +TEST(hdd_fsck_accepts_clusters_near_fff0) { + std::string err; + DiskImage dk; CHECK(dk.loadBytes(formatFatVolume(66067), "v.img", &err)); + Fat12 fs; CHECK(fs.mount(dk, &err)); + CHECK_EQ(fs.fatBits(), 16); + CHECK_EQ(fs.sectorsPerCluster(), 1); + CHECK_EQ(fs.totalClusters(), 65522); // highest cluster 0xFFF3 + CHECK(fs.createFile("/", "FILL.BIN", pattern((size_t)65518 * 512, 2), &err)); + CHECK(fs.createFile("/", "TAIL.BIN", pattern(4 * 512, 3), &err)); + DirEntry t; CHECK(fs.lookup("/TAIL.BIN", t)); + CHECK_EQ(t.startCluster, 0xFFF0); + CHECK(fs.readFile(t) == pattern(4 * 512, 3)); + CHECK(clean(fs)); + CHECK_EQ(fs.freeBytes(), 0L); +} + +TEST(hdd_format_fat_always_big_enough) { + // Every size up to 9000 sectors plus sizes the fixed-point used to miss. + int bad = 0; + std::vector sizes; + for (long n = 64; n <= 9000; ++n) sizes.push_back(n); + for (long n : {375L, 4136L, 4141L, 4161L, 65536L, 131071L, 1048577L, 4178399L}) sizes.push_back(n); + for (long n : sizes) { + auto v = formatFatVolume(n); + if (v.empty() || !fatHoldsClusters(v)) { if (bad++ < 5) fprintf(stderr, " size %ld\n", n); } + } + CHECK_EQ(bad, 0); +} + +TEST(hdd_old_boot_sectors_keep_floppy_geometry) { + std::string err; + // total16 == 0 and boot code where a DOS 3.31 BPB keeps total32: the + // code bytes must not be taken as a sector count. + auto img = formatBlankImage(1440); + img[0x13] = 0; img[0x14] = 0; + img[0x20] = 0x00; img[0x21] = 0x00; img[0x22] = 0x01; img[0x23] = 0x00; // "65536" + DiskImage dk; CHECK(dk.loadBytes(img, "o.dsk", &err)); + Fat12 fs; CHECK(fs.mount(dk, &err)); + CHECK_EQ(fs.fatBits(), 12); + CHECK_EQ(fs.totalClusters(), 713); + // Same through a DMK container. + DiskImage dm; CHECK(dm.loadBytes(dskToDmk(img, &err), "o.dmk", &err)); + Fat12 fd; CHECK(fd.mount(dm, &err)); + CHECK_EQ(fd.fatBits(), 12); + CHECK(clean(fd)); +} + +TEST(hdd_floppy_without_bpb_and_code_at_0x24_mounts) { + std::string err; + auto img = formatBlankImage(1440); + memset(&img[3], 0, 0x3E - 3); + img[0x0B] = 0x00; img[0x0C] = 0x02; // only bytes/sector survives + img[0x24] = 0xCD; img[0x25] = 0x12; // boot code, not a FAT32 field + DiskImage dk; CHECK(dk.loadBytes(img, "c.dsk", &err)); + Fat12 fs; + CHECK(fs.mount(dk, &err)); + CHECK_EQ(fs.totalClusters(), 713); +} + +TEST(hdd_overlapping_partitions_are_not_both_exposed) { + std::string err; + auto img = buildPartitionedImage({8 * MB, 8 * MB}, &err); + // Make entry 2 start inside entry 1. + uint8_t* e2 = &img[0x1CE]; + long s1 = img[0x1BE + 8] | (img[0x1BE + 9] << 8) | (img[0x1BE + 10] << 16); + long s2 = s1 + 100; + e2[8] = s2 & 0xFF; e2[9] = (s2 >> 8) & 0xFF; e2[10] = (s2 >> 16) & 0xFF; e2[11] = 0; + DiskImage dk; CHECK(dk.loadBytes(img, "o.img", &err)); + auto parts = findPartitions(dk); + CHECK_EQ(parts.size(), (size_t)1); +}