Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <out> 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. |
Expand Down Expand Up @@ -104,7 +105,9 @@ Linux, macOS arm64 and Windows):
## CLI usage

Every `<FILE>` 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 <image> disk/format/geometry/FAT summary
Expand All @@ -119,7 +122,8 @@ msxforge protect <image> physical sector / protection inspector
msxforge raw <image> [out.bin] cluster map (named/RAW/free) + extract RAW data
msxforge carve <image> split the RAW region into BLOAD/BASIC/gap blocks
msxforge rawput <image> <hostfile> [outimage] write RAW data back into the image
msxforge format <out.dsk> [360|720] create a blank formatted disk
msxforge format <out> [360|720|<N>M] [LABEL] blank floppy, or a FAT16 superfloppy of N MB
msxforge mkhdd <out.img> <N>M[,<N>M...] partitioned hard disk / SD image (FAT12/16)
msxforge pack <hostdir> <out.dsk> [360|720] [--label L] build a disk from a folder tree
msxforge unpack <image> <hostdir> extract the whole tree to a folder
msxforge undelete <image> <[DIR/]?NAME> [LETTER] [outimage] recover a deleted file
Expand Down Expand Up @@ -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.

Expand Down
126 changes: 114 additions & 12 deletions cli/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#include "../core/cas.h"
#include "../core/util.h"
#include "../core/tape.h"
#include "../core/partition.h"
#include "hostfs.h"
#include <algorithm>
#include <cstdio>
Expand Down Expand Up @@ -42,10 +43,38 @@ static std::vector<uint8_t> 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;
}

Expand All @@ -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());
Expand Down Expand Up @@ -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 "<N>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<uint8_t>& 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 <out.dsk> [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 <out> [360|720|<N>M] [LABEL]
if (argc < 3) { fprintf(stderr, "need <out.dsk> [360|720|<N>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 <N>M (1-2047)\n"); return 1; }
std::vector<uint8_t> 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 <out.img> <N>M[,<N>M...]
if (argc < 4) { fprintf(stderr, "need <out.img> <N>M[,<N>M...]\n"); return 1; }
std::vector<long> 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;
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<char*> 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 <cmd> <file> [args] (FILE may be a path: DIR/NAME.EXT)\n"
"usage: msxforge <cmd> <file> [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 <img> geometry, filesystem, free space\n"
Expand All @@ -610,7 +710,8 @@ int main(int argc, char** argv) {
" del <img> <FILE|DIR> [outimg] delete a file or an empty directory\n"
" rename <img> <FILE> <NEWNAME> rename in place\n"
" mkdir <img> <NAME> [/PARENT] create a directory\n"
" format <out.dsk> [360|720] blank MSX-DOS disk\n"
" format <out> [360|720|<N>M] [LABEL] blank floppy, or a FAT16 superfloppy\n"
" mkhdd <out.img> <N>M[,<N>M...] partitioned hard disk / SD image\n"
" pack <hostdir> <out.dsk> [360|720] [--label L] build a disk from a folder\n"
" unpack <img> <hostdir> extract the whole tree to a folder\n"
" undelete <img> <[DIR/]?NAME> [LETTER] [outimg] recover a deleted file\n"
Expand Down Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions core/disk_image.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ bool DiskImage::loadBytes(std::vector<uint8_t> 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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion core/disk_image.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint8_t>& rawMut() { return raw_; }
std::vector<uint8_t>& 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<SectorInfo>& sectors() const { return sectors_; }
Expand Down Expand Up @@ -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
Loading
Loading