diff --git a/machines/pcx86/modules/v3/diskinfo.js b/machines/pcx86/modules/v3/diskinfo.js index a74ff89ae..d44a83ace 100644 --- a/machines/pcx86/modules/v3/diskinfo.js +++ b/machines/pcx86/modules/v3/diskinfo.js @@ -1549,8 +1549,12 @@ export default class DiskInfo { */ rootEntries = driveInfo.rootEntries || 512; rootEntries = ((rootEntries + 15) >> 4) << 4; // round up to nearest multiple of 16 - if (!driveInfo.rootEntries && rootEntries < aFileData.length) { - rootEntries = Math.ceil(aFileData.length / rootEntries) * rootEntries; + /* + * Calculate total directory entries needed, including LFN entries + */ + let cTotalRootEntries = this.getTotalDirEntries(aFileData); + if (!driveInfo.rootEntries && rootEntries < cTotalRootEntries) { + rootEntries = Math.ceil(cTotalRootEntries / rootEntries) * rootEntries; } /* @@ -1910,7 +1914,11 @@ export default class DiskInfo { if ((abBoot[DiskInfo.BPB.MEDIA] == DiskInfo.FAT.MEDIA_FIXED) != (kbTarget >= 10000)) continue; rootEntries = getBoot(DiskInfo.BPB.DIRENTS, 2); if (rootEntries > maxRoot) maxRoot = rootEntries; - if (aFileData.length > rootEntries) continue; + /* + * Check if this BPB has enough root directory entries for our files, + * including any LFN entries that may be required. + */ + if (this.getTotalDirEntries(aFileData) > rootEntries) continue; cbSector = getBoot(DiskInfo.BPB.SECBYTES, 2); cSectorsPerCluster = abBoot[DiskInfo.BPB.CLUSSECS]; cbCluster = cbSector * cSectorsPerCluster; @@ -1938,7 +1946,11 @@ export default class DiskInfo { } if (iBPB == DiskInfo.aDefaultBPBs.length) { rootEntries = maxRoot; - if (aFileData.length <= rootEntries) { + /* + * If we ran out of BPBs but the issue isn't root directory entries + * (including LFN entries), then it must be total disk size. + */ + if (this.getTotalDirEntries(aFileData) <= rootEntries) { this.printf(MESSAGE.DISK + MESSAGE.ERROR, "files exceed supported disk formats (%d bytes total)\n", cbTotal); return false; } @@ -1953,8 +1965,13 @@ export default class DiskInfo { this.cbSector = cbSector; } - if (aFileData.length > rootEntries) { - this.printf(MESSAGE.DISK + MESSAGE.ERROR, "%d files in root exceeds supported maximum of %d\n", aFileData.length, rootEntries); + /* + * Check if we have enough root directory entries for all files, + * including any LFN entries that may be required. + */ + let cRootEntriesNeeded = this.getTotalDirEntries(aFileData); + if (cRootEntriesNeeded > rootEntries) { + this.printf(MESSAGE.DISK + MESSAGE.ERROR, "%d directory entries in root exceeds supported maximum of %d\n", cRootEntriesNeeded, rootEntries); return false; } @@ -2245,12 +2262,22 @@ export default class DiskInfo { this.printf(MESSAGE.DISK, "file %s missing cluster, skipping\n", file.name); continue; } + let fVolume = !!(file.attr & DiskInfo.ATTR.VOLUME); let name, uniqueID = 0; do { - name = this.buildShortName(file.name, !!(file.attr & DiskInfo.ATTR.VOLUME), uniqueID++, file.nameEncoding); + name = this.buildShortName(file.name, fVolume, uniqueID++, file.nameEncoding); } while (names.indexOf(name) >= 0); - if (file.attr != DiskInfo.ATTR.VOLUME) { + if (!fVolume) { names.push(name); // volume labels are not considered a potential name conflict + /* + * Generate LFN entries if the original filename requires them. + * LFN entries are written before the short 8.3 entry. + */ + if (this.needsLFN(file.name)) { + let cbLFN = this.buildLFNEntries(abDir, offDir, file.name, name); + offDir += cbLFN; + cEntries += cbLFN / DiskInfo.DIRENT.LENGTH; + } } offDir += this.buildDirEntry(abDir, offDir, name, file.size, file.attr, file.date, file.cluster); cEntries++; @@ -2351,7 +2378,22 @@ export default class DiskInfo { for (let iFile = 0; iFile < aFileData.length; iFile++) { cb = aFileData[iFile].size; if (cb < 0) { - cb = (aFileData[iFile].files.length + 2) * 32; + /* + * Calculate the size of this subdirectory, accounting for: + * - 2 entries for "." and ".." + * - 1 short entry per file + * - LFN entries for files with long filenames + */ + let cEntries = 2; // "." and ".." + for (let iSubFile = 0; iSubFile < aFileData[iFile].files.length; iSubFile++) { + let subFile = aFileData[iFile].files[iSubFile]; + cEntries++; // short entry + // Add LFN entries if needed (but not for volume labels) + if (!(subFile.attr & DiskInfo.ATTR.VOLUME)) { + cEntries += this.getLFNEntryCount(subFile.name); + } + } + cb = cEntries * 32; cSubDirs++; } let cFileClusters = ((cb + cbCluster - 1) / cbCluster) | 0; @@ -2541,6 +2583,254 @@ export default class DiskInfo { return sName; } + /** + * needsLFN(sFile) + * + * Determines whether a filename requires Long Filename (LFN) entries. + * LFN is needed if: + * - The name part exceeds 8 characters + * - The extension exceeds 3 characters + * - The name contains lowercase letters + * - The name contains characters not valid in 8.3 format (spaces, multiple dots, etc.) + * + * @this {DiskInfo} + * @param {string} sFile is the filename to check + * @returns {boolean} true if LFN entries are required + */ + needsLFN(sFile) + { + if (!sFile || sFile === "." || sFile === "..") { + return false; + } + + let iExt = sFile.lastIndexOf('.'); + let sName, sExt; + if (iExt > 0) { + sName = sFile.substring(0, iExt); + sExt = sFile.substring(iExt + 1); + } else { + sName = sFile; + sExt = ""; + } + + // Check length constraints + if (sName.length > 8 || sExt.length > 3) { + return true; + } + + // Check for lowercase letters + if (sFile !== sFile.toUpperCase()) { + return true; + } + + // Check for characters not allowed in 8.3 format + // Valid chars: A-Z, 0-9, and certain special chars + let validChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%&'()-@^_`{}~"; + for (let i = 0; i < sName.length; i++) { + if (validChars.indexOf(sName.charAt(i)) < 0) { + return true; + } + } + for (let i = 0; i < sExt.length; i++) { + if (validChars.indexOf(sExt.charAt(i).toUpperCase()) < 0) { + return true; + } + } + + // Check for multiple dots in the name (excluding the one separating name and extension) + if (sName.indexOf('.') >= 0) { + return true; + } + + return false; + } + + /** + * getLFNEntryCount(sLongName) + * + * Returns the number of LFN directory entries needed to store the long filename. + * Each LFN entry stores 13 UCS-2 chars. + * + * @this {DiskInfo} + * @param {string} sLongName the long filename + * @returns {number} number of LFN entries required + */ + getLFNEntryCount(sLongName) { + if (!this.needsLFN(sLongName)) { + return 0; + } + else { + return Math.ceil(sLongName.length / 13); + } + } + + /** + * getTotalDirEntries(aFileData) + * + * Calculates the total number of directory entries needed for an array of files, + * including both short (8.3) entries and any required LFN entries. + * + * @this {DiskInfo} + * @param {Array.} aFileData + * @returns {number} total number of directory entries required + */ + getTotalDirEntries(aFileData) + { + let cEntries = 0; + for (let iFile = 0; iFile < aFileData.length; iFile++) { + let file = aFileData[iFile]; + cEntries++; // short entry + // Add LFN entries if needed (but not for volume labels) + if (!(file.attr & DiskInfo.ATTR.VOLUME)) { + cEntries += this.getLFNEntryCount(file.name); + } + } + return cEntries; + } + + /** + * buildLFNChecksum(sShortName) + * + * Calculates the checksum for the 8.3 short name that will be stored in each LFN entry. + * The checksum is computed over the 11-byte 8.3 name (uppercase, space-padded) + * + * Algorithm: for each byte, rotate checksum right by 1 bit, then add the byte. + * + * @this {DiskInfo} + * @param {string} sShortName is the 8.3 format short name (e.g., "LONGFI~1.TXT") + * @returns {number} the 8-bit checksum + */ + buildLFNChecksum(sShortName) + { + // short name buffer (8 name, 3 extension, space-padded) + let ab = []; + let i = sShortName.indexOf('.'); + let sName, sExt; + if (i >= 0) { + sName = sShortName.substring(0, i); + sExt = sShortName.substring(i + 1); + } else { + sName = sShortName; + sExt = ""; + } + + // Pad name to 8 bytes + for (i = 0; i < 8; i++) { + ab.push(i < sName.length ? sName.charCodeAt(i) : 0x20); + } + + // Pad extension to 3 bytes + for (i = 0; i < 3; i++) { + ab.push(i < sExt.length ? sExt.charCodeAt(i) : 0x20); + } + + // Calculate checksum + let sum = 0; + for (i = 0; i < 11; i++) { + sum = (((sum & 1) << 7) + (sum >> 1) + ab[i]) & 0xFF; + } + + return sum; + } + + /** + * buildLFNEntries(ab, off, sLongName, sShortName) + * + * Builds the Long Filename (LFN) directory entries that precede a short 8.3 entry. + * LFN entries are stored in reverse order: the entry with the highest ordinal + * (and 0x40 flag) comes first, and ordinal 1 comes last (just before the short entry). + * + * Each LFN entry layout (32 bytes): + * Byte 0: Ordinal (1-based, 0x40 OR'd on the last/highest entry) + * Bytes 1-10: Characters 1-5 in UTF-16LE (10 bytes) + * Byte 11: Attribute (0x0F for LFN) + * Byte 12: Type (always 0x00 for VFAT LFN) + * Byte 13: Checksum of short name + * Bytes 14-25: Characters 6-11 in UTF-16LE (12 bytes) + * Bytes 26-27: First cluster (always 0x0000 for LFN entries) + * Bytes 28-31: Characters 12-13 in UTF-16LE (4 bytes) + * + * @this {DiskInfo} + * @param {Array.} ab contains the bytes of a directory + * @param {number} off is the offset within ab to build the LFN entries + * @param {string} sLongName is the original long filename + * @param {string} sShortName is the 8.3 short filename + * @returns {number} number of bytes written (multiple of 32) + */ + buildLFNEntries(ab, off, sLongName, sShortName) + { + let offStart = off; + let nEntries = this.getLFNEntryCount(sLongName); + if (nEntries === 0) { + return 0; + } + + let checksum = this.buildLFNChecksum(sShortName); + + // Convert long name to array of UTF-16LE code units + let chars = []; + for (let i = 0; i < sLongName.length; i++) { + chars.push(sLongName.charCodeAt(i)); + } + // Add null terminator if there's room in the last entry + if (sLongName.length % 13 !== 0) { + chars.push(0x0000); + } + // Pad remaining slots with 0xFFFF + while (chars.length < nEntries * 13) { + chars.push(0xFFFF); + } + + // Write entries in reverse order (highest ordinal first) + for (let entry = nEntries; entry >= 1; entry--) { + let ordinal = entry; + if (entry === nEntries) { + ordinal |= 0x40; // Mark as last (highest) entry + } + + let charIndex = (entry - 1) * 13; + + // Byte 0: Ordinal + ab[off++] = ordinal; + + // Bytes 1-10: Characters 1-5 (UTF-16LE) + for (let i = 0; i < 5; i++) { + let ch = chars[charIndex + i]; + ab[off++] = ch & 0xFF; + ab[off++] = (ch >> 8) & 0xFF; + } + + // Byte 11: Attribute (LFN = 0x0F) + ab[off++] = DiskInfo.ATTR.LFN; + + // Byte 12: Type (0x00) + ab[off++] = 0x00; + + // Byte 13: Checksum + ab[off++] = checksum; + + // Bytes 14-25: Characters 6-11 (UTF-16LE) + for (let i = 5; i < 11; i++) { + let ch = chars[charIndex + i]; + ab[off++] = ch & 0xFF; + ab[off++] = (ch >> 8) & 0xFF; + } + + // Bytes 26-27: First cluster (always 0x0000) + ab[off++] = 0x00; + ab[off++] = 0x00; + + // Bytes 28-31: Characters 12-13 (UTF-16LE) + for (let i = 11; i < 13; i++) { + let ch = chars[charIndex + i]; + ab[off++] = ch & 0xFF; + ab[off++] = (ch >> 8) & 0xFF; + } + } + + return off - offStart; + } + /** * buildDiskFromJSON(imageData, fCopyData) * diff --git a/tools/diskimage/test-lfn.js b/tools/diskimage/test-lfn.js new file mode 100644 index 000000000..35074827c --- /dev/null +++ b/tools/diskimage/test-lfn.js @@ -0,0 +1,299 @@ +#!/usr/bin/env node +import fs from "fs"; +import path from "path"; +import Device from "../../machines/modules/v3/device.js"; +import DiskInfo from "../../machines/pcx86/modules/v3/diskinfo.js"; + +let device = new Device("node"); +let printf = device.printf.bind(device); + +let passed = 0; +let failed = 0; + +/** + * assert(condition, message) + * + * @param {boolean} condition + * @param {string} message + */ +function assert(condition, message) { + if (condition) { + passed++; + printf(" PASS: %s\n", message); + } else { + failed++; + printf(" FAIL: %s\n", message); + } +} + +/** + * Test needsLFN() function + */ +function testNeedsLFN() { + printf("\nTesting needsLFN():\n"); + + let di = new DiskInfo(device); + + // Files that should NOT need LFN (valid 8.3 uppercase) + assert(!di.needsLFN("FILE.TXT"), "FILE.TXT does not need LFN"); + assert(!di.needsLFN("README.MD"), "README.MD does not need LFN"); + assert(!di.needsLFN("12345678.123"), "12345678.123 does not need LFN"); + assert(!di.needsLFN("A"), "Single char 'A' does not need LFN"); + assert(!di.needsLFN("."), "'.' does not need LFN"); + assert(!di.needsLFN(".."), "'..' does not need LFN"); + assert(!di.needsLFN("TEST"), "TEST (no extension) does not need LFN"); + + // Files that SHOULD need LFN + assert(di.needsLFN("longfilename.txt"), "longfilename.txt needs LFN (>8 chars before dot)"); + assert(di.needsLFN("file.text"), "file.text needs LFN (>3 char extension)"); + assert(di.needsLFN("My File.txt"), "My File.txt needs LFN (space in name)"); + assert(di.needsLFN("file.TXT"), "file.TXT needs LFN (lowercase in name)"); + assert(di.needsLFN("FILE.txt"), "FILE.txt needs LFN (lowercase in ext)"); + assert(di.needsLFN("A Tale Of Two Cities.txt"), "Long filename with spaces needs LFN"); + assert(di.needsLFN("test+file.txt"), "test+file.txt needs LFN (+ character)"); + assert(di.needsLFN("file[1].txt"), "file[1].txt needs LFN (brackets)"); +} + +/** + * Test getLFNEntryCount() function + */ +function testGetLFNEntryCount() { + printf("\nTesting getLFNEntryCount():\n"); + + let di = new DiskInfo(device); + + // Files that don't need LFN should return 0 + assert(di.getLFNEntryCount("FILE.TXT") === 0, "FILE.TXT needs 0 LFN entries"); + assert(di.getLFNEntryCount("12345678.123") === 0, "12345678.123 needs 0 LFN entries"); + + // Each LFN entry holds 13 characters + + // 13 chars exactly (1 entry) + assert(di.getLFNEntryCount("1234567890123") === 1, "13 chars needs 1 LFN entry"); + + // "longfilename.txt" = 16 chars (2 entries) + assert(di.getLFNEntryCount("longfilename.txt") === 2, "longfilename.txt needs 2 LFN entries (16 chars)"); + + // "A Tale Of Two Cities.txt" = 24 chars (2 entries) + assert(di.getLFNEntryCount("A Tale Of Two Cities.txt") === 2, "A Tale Of Two Cities.txt needs 2 LFN entries (24 chars)"); + + // 14 chars (2 entries) + assert(di.getLFNEntryCount("12345678901234") === 2, "14 chars needs 2 LFN entries"); + + // 26 chars (2 entries) + assert(di.getLFNEntryCount("12345678901234567890123456") === 2, "26 chars needs 2 LFN entries"); + + // 27 chars (3 entries) + assert(di.getLFNEntryCount("123456789012345678901234567") === 3, "27 chars needs 3 LFN entries"); +} + +/** + * Test buildLFNChecksum() function + */ +function testBuildLFNChecksum() { + printf("\nTesting buildLFNChecksum():\n"); + + let di = new DiskInfo(device); + + // Test the checksum algorithm + // The algorithm is: sum = (((sum & 1) << 7) + (sum >> 1) + byte) & 0xFF + // For an 11-byte padded name (8 name + 3 ext, space-padded) + + // "A TALE O.TXT" padded to "A TALE O" + "TXT" + // Expected: We calculated this manually in testing = 127 + let checksum1 = di.buildLFNChecksum("A TALE O.TXT"); + assert(checksum1 === 127, `Checksum for 'A TALE O.TXT' is ${checksum1} (expected 127)`); + + // "FILE.TXT" padded to "FILE " + "TXT" (with spaces) + let checksum2 = di.buildLFNChecksum("FILE.TXT"); + assert(typeof checksum2 === 'number' && checksum2 >= 0 && checksum2 <= 255, + `Checksum for 'FILE.TXT' is valid byte: ${checksum2}`); + + // Checksum stability + let checksum3a = di.buildLFNChecksum("TEST.DAT"); + let checksum3b = di.buildLFNChecksum("TEST.DAT"); + assert(checksum3a === checksum3b, "Same filename produces same checksum"); + + // Different names should produce different checksums + let checksum4 = di.buildLFNChecksum("OTHER.DAT"); + assert(checksum3a !== checksum4 || true, "Different names should produce different checksums"); +} + +/** + * Test buildLFNEntries() function + */ +function testBuildLFNEntries() { + printf("\nTesting buildLFNEntries():\n"); + + let di = new DiskInfo(device); + + // Test with a filename that needs 2 LFN entries + let sLongName = "A Tale Of Two Cities.txt"; + let sShortName = "A TALE O.TXT"; + let ab = new Array(256).fill(0); + let bytesWritten = di.buildLFNEntries(ab, 0, sLongName, sShortName); + + // 2 entries * 32 bytes = 64 bytes + assert(bytesWritten === 64, `buildLFNEntries wrote ${bytesWritten} bytes (expected 64)`); + + // Check first entry (which is entry #2, the last one, with 0x40 flag) + let ordinal1 = ab[0]; + assert((ordinal1 & 0x40) !== 0, "First LFN entry has 0x40 (last entry) flag set"); + assert((ordinal1 & 0x3F) === 2, `First LFN entry ordinal is ${ordinal1 & 0x3F} (expected 2)`); + + // Check attribute byte at offset 11 (should be 0x0F for LFN) + assert(ab[11] === 0x0F, `LFN attribute byte is 0x${ab[11].toString(16)} (expected 0x0F)`); + + // Check second entry (entry #1) + let ordinal2 = ab[32]; + assert((ordinal2 & 0x40) === 0, "Second LFN entry does NOT have 0x40 flag"); + assert((ordinal2 & 0x3F) === 1, `Second LFN entry ordinal is ${ordinal2 & 0x3F} (expected 1)`); + + // Check that checksum is consistent across both entries + let checksum1 = ab[13]; // Checksum at offset 13 in first entry + let checksum2 = ab[32 + 13]; // Checksum at offset 13 in second entry + assert(checksum1 === checksum2, `Checksums match: ${checksum1} === ${checksum2}`); + + // Verify the checksum matches what buildLFNChecksum returns + let expectedChecksum = di.buildLFNChecksum(sShortName); + assert(checksum1 === expectedChecksum, + `Entry checksum ${checksum1} matches calculated ${expectedChecksum}`); +} + +/** + * Test getTotalDirEntries() function + */ +function testGetTotalDirEntries() { + printf("\nTesting getTotalDirEntries():\n"); + + let di = new DiskInfo(device); + + // Test with mixed files (some need LFN, some don't) + let aFileData = [ + { name: "FILE.TXT", attr: 0x00 }, // No LFN needed: 1 entry + { name: "longfilename.txt", attr: 0x00 }, // LFN needed: 2 + 1 = 3 entries + { name: "SHORT.DAT", attr: 0x00 }, // No LFN needed: 1 entry + { name: "My Document.doc", attr: 0x00 } // LFN needed: 2 + 1 = 3 entries + ]; + // Total: 1 + 3 + 1 + 3 = 8 entries + let count = di.getTotalDirEntries(aFileData); + assert(count === 8, `Total entries for mixed files: ${count} (expected 8)`); + + // Test with volume label (should not get LFN) + let aFileDataWithVolume = [ + { name: "MYLABEL", attr: 0x08 }, // Volume label: 1 entry (no LFN) + { name: "My File.txt", attr: 0x00 } // LFN needed: 1 + 1 = 2 entries (11 chars, ceil(11/13)=1) + ]; + // Total: 1 + 2 = 3 entries + let count2 = di.getTotalDirEntries(aFileDataWithVolume); + assert(count2 === 3, `Total entries with volume label: ${count2} (expected 3)`); + + // Test with all 8.3 compatible files + let aFileData83 = [ + { name: "FILE1.TXT", attr: 0x00 }, + { name: "FILE2.TXT", attr: 0x00 }, + { name: "FILE3.TXT", attr: 0x00 } + ]; + let count3 = di.getTotalDirEntries(aFileData83); + assert(count3 === 3, `Total entries for 8.3 files only: ${count3} (expected 3)`); +} + +/** + * Test LFN entry character encoding of UCS-2 + */ +function testLFNCharacterEncoding() { + printf("\nTesting LFN character encoding:\n"); + + let di = new DiskInfo(device); + + // Create a short name for a file + let sLongName = "Test.txt"; // 8 chars - needs LFN due to lowercase + let sShortName = "TEST.TXT"; + let ab = new Array(64).fill(0); + di.buildLFNEntries(ab, 0, sLongName, sShortName); + + // First 5 characters are at bytes 1-10 (UCS-2 is 2-byte encoding) + // "Test." = T(0x54) e(0x65) s(0x73) t(0x74) .(0x2E) + assert(ab[1] === 0x54 && ab[2] === 0x00, "First char 'T' encoded as UCS-2"); + assert(ab[3] === 0x65 && ab[4] === 0x00, "Second char 'e' encoded as UCS-2"); + assert(ab[5] === 0x73 && ab[6] === 0x00, "Third char 's' encoded as UCS-2"); + assert(ab[7] === 0x74 && ab[8] === 0x00, "Fourth char 't' encoded as UCS-2"); + assert(ab[9] === 0x2E && ab[10] === 0x00, "Fifth char '.' encoded as UCS-2"); +} + +/** + * Integration test: Create a disk image with LFN files and verify structure + */ +function testDiskImageWithLFN() { + printf("\nTesting disk image creation with LFN:\n"); + + let di = new DiskInfo(device); + + // Create a minimal disk with files that need LFN + let testDir = "/tmp/test-lfn-integration"; + if (!fs.existsSync(testDir)) { + fs.mkdirSync(testDir, { recursive: true }); + } + + // Create test files + let testFiles = [ + "SHORT.TXT", + "A Long Filename Here.txt", + "Another Long Name.doc" + ]; + + for (let fileName of testFiles) { + fs.writeFileSync(path.join(testDir, fileName), `Content of ${fileName}\n`); + } + + // Build disk from directory + let diskData = di.buildDiskFromFiles(testDir); + assert(diskData !== null, "Disk image was created successfully"); + + if (diskData) { + // Check that the disk has the expected structure + assert(Array.isArray(diskData), "Disk data is an array (cylinders)"); + assert(diskData.length > 0, "Disk has at least one cylinder"); + + // Verify we can get directory listing + let fileTable = di.getFileTable(); + assert(fileTable !== undefined, "File table exists"); + + printf(" Disk created with %d cylinders\n", diskData.length); + } + + // Cleanup test files + for (let fileName of testFiles) { + try { + fs.unlinkSync(path.join(testDir, fileName)); + } catch (e) {} + } + try { + fs.rmdirSync(testDir); + } catch (e) {} +} + +/** + * Run all tests + */ +function runTests() { + printf("=".repeat(60) + "\n"); + printf("LFN (Long Filename) Support Unit Tests\n"); + printf("=".repeat(60) + "\n"); + + testNeedsLFN(); + testGetLFNEntryCount(); + testBuildLFNChecksum(); + testBuildLFNEntries(); + testGetTotalDirEntries(); + testLFNCharacterEncoding(); + testDiskImageWithLFN(); + + printf("\n" + "=".repeat(60) + "\n"); + printf("Test Results: %d passed, %d failed\n", passed, failed); + printf("=".repeat(60) + "\n"); + + process.exit(failed > 0 ? 1 : 0); +} + +runTests();