cffs-image-tool 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { basename } from 'node:path';
3
+ import { SECTOR_SIZE, FLAG_USED, DATA_START } from './constants.js';
4
+ import { DirEntry } from './types.js';
5
+ import { parseName, formatName } from './filename.js';
6
+ import { writeSector } from './image.js';
7
+ import {
8
+ readDirectory, writeDirectory, findFile, findFreeSlot,
9
+ calcNextSector, calcSectorCount,
10
+ } from './directory.js';
11
+
12
+ /**
13
+ * Add a host file to the image. Optionally rename with targetName (8.3 format).
14
+ */
15
+ export function addFile(buf: Buffer, hostPath: string, targetName?: string): void {
16
+ const data = readFileSync(hostPath);
17
+
18
+ if (data.length > 0xFFFF) {
19
+ throw new Error(`File too large: ${data.length} bytes (max 65535)`);
20
+ }
21
+
22
+ const nameInput = targetName ?? basename(hostPath);
23
+ const { name, ext } = parseName(nameInput);
24
+
25
+ const entries = readDirectory(buf);
26
+
27
+ // If file with same name exists, clear old entry (overwrite behavior matches BIOS FsSaveFile)
28
+ const existing = findFile(entries, name, ext);
29
+ if (existing) {
30
+ existing.flags = 0;
31
+ }
32
+
33
+ const slotIndex = findFreeSlot(entries);
34
+ if (slotIndex === -1) {
35
+ throw new Error('Directory is full (16 entries maximum)');
36
+ }
37
+
38
+ // Recalculate next sector after potentially clearing old entry
39
+ const startSector = calcNextSector(entries);
40
+
41
+ // Check that the file data fits in the image
42
+ const sectorCount = calcSectorCount(data.length);
43
+ const endByte = (startSector + sectorCount) * SECTOR_SIZE;
44
+ if (endByte > buf.length) {
45
+ throw new Error(`Not enough space in image: need sector ${startSector + sectorCount - 1}, image has ${buf.length / SECTOR_SIZE} sectors`);
46
+ }
47
+
48
+ // Write file data to sectors
49
+ for (let i = 0; i < sectorCount; i++) {
50
+ const chunk = Buffer.alloc(SECTOR_SIZE);
51
+ const srcOffset = i * SECTOR_SIZE;
52
+ const remaining = Math.min(SECTOR_SIZE, data.length - srcOffset);
53
+ if (remaining > 0) {
54
+ data.copy(chunk, 0, srcOffset, srcOffset + remaining);
55
+ }
56
+ writeSector(buf, startSector + i, chunk);
57
+ }
58
+
59
+ // Update directory entry
60
+ entries[slotIndex] = {
61
+ name,
62
+ ext,
63
+ flags: FLAG_USED,
64
+ startSector,
65
+ fileSize: data.length,
66
+ index: slotIndex,
67
+ };
68
+
69
+ writeDirectory(buf, entries);
70
+ }
71
+
72
+ /**
73
+ * Remove a file by name (clears flags only, no compaction — matches BIOS FsDeleteFile).
74
+ */
75
+ export function removeFile(buf: Buffer, nameInput: string): void {
76
+ const { name, ext } = parseName(nameInput);
77
+ const entries = readDirectory(buf);
78
+ const entry = findFile(entries, name, ext);
79
+
80
+ if (!entry) {
81
+ throw new Error(`File not found: ${nameInput}`);
82
+ }
83
+
84
+ entry.flags = 0;
85
+ writeDirectory(buf, entries);
86
+ }
87
+
88
+ /**
89
+ * List all in-use directory entries.
90
+ */
91
+ export function listFiles(buf: Buffer): DirEntry[] {
92
+ return readDirectory(buf).filter((e) => (e.flags & FLAG_USED) !== 0);
93
+ }
94
+
95
+ /**
96
+ * Extract a file from the image to the host filesystem.
97
+ */
98
+ export function extractFile(buf: Buffer, nameInput: string, outputPath?: string): void {
99
+ const { name, ext } = parseName(nameInput);
100
+ const entries = readDirectory(buf);
101
+ const entry = findFile(entries, name, ext);
102
+
103
+ if (!entry) {
104
+ throw new Error(`File not found: ${nameInput}`);
105
+ }
106
+
107
+ const sectorCount = calcSectorCount(entry.fileSize);
108
+ const output = Buffer.alloc(entry.fileSize);
109
+
110
+ for (let i = 0; i < sectorCount; i++) {
111
+ const offset = (entry.startSector + i) * SECTOR_SIZE;
112
+ const srcSlice = buf.subarray(offset, offset + SECTOR_SIZE);
113
+ const dstOffset = i * SECTOR_SIZE;
114
+ const remaining = Math.min(SECTOR_SIZE, entry.fileSize - dstOffset);
115
+ srcSlice.copy(output, dstOffset, 0, remaining);
116
+ }
117
+
118
+ const outPath = outputPath ?? formatName(entry);
119
+ writeFileSync(outPath, output);
120
+ }
121
+
122
+ /**
123
+ * Defragment the image: sort used entries by startSector, rewrite contiguously from LBA 1.
124
+ */
125
+ export function defragment(buf: Buffer): void {
126
+ const entries = readDirectory(buf);
127
+ const usedEntries = entries
128
+ .filter((e) => (e.flags & FLAG_USED) !== 0)
129
+ .sort((a, b) => a.startSector - b.startSector);
130
+
131
+ let currentSector = DATA_START;
132
+
133
+ for (const entry of usedEntries) {
134
+ const sectorCount = calcSectorCount(entry.fileSize);
135
+
136
+ // Only move if not already in the right spot
137
+ if (entry.startSector !== currentSector) {
138
+ // Read file data from current location
139
+ const fileData = Buffer.alloc(sectorCount * SECTOR_SIZE);
140
+ for (let i = 0; i < sectorCount; i++) {
141
+ const srcOffset = (entry.startSector + i) * SECTOR_SIZE;
142
+ buf.copy(fileData, i * SECTOR_SIZE, srcOffset, srcOffset + SECTOR_SIZE);
143
+ }
144
+
145
+ // Write to new location
146
+ for (let i = 0; i < sectorCount; i++) {
147
+ const chunk = fileData.subarray(i * SECTOR_SIZE, (i + 1) * SECTOR_SIZE);
148
+ writeSector(buf, currentSector + i, chunk);
149
+ }
150
+
151
+ entry.startSector = currentSector;
152
+ }
153
+
154
+ currentSector += sectorCount;
155
+ }
156
+
157
+ // Zero out any sectors after the last used file (reclaim space)
158
+ const totalSectors = buf.length / SECTOR_SIZE;
159
+ for (let s = currentSector; s < totalSectors; s++) {
160
+ buf.fill(0, s * SECTOR_SIZE, (s + 1) * SECTOR_SIZE);
161
+ }
162
+
163
+ // Write updated directory
164
+ writeDirectory(buf, entries);
165
+ }
166
+
167
+ /**
168
+ * Clear all directory entries (zero the directory sector).
169
+ */
170
+ export function clearImage(buf: Buffer): void {
171
+ const sector = Buffer.alloc(SECTOR_SIZE);
172
+ writeSector(buf, 0, sector);
173
+ }
174
+
175
+ /**
176
+ * Return image stats.
177
+ */
178
+ export function imageInfo(buf: Buffer): {
179
+ totalSectors: number;
180
+ usedEntries: number;
181
+ freeEntries: number;
182
+ nextFreeSector: number;
183
+ usedDataSectors: number;
184
+ freeDataSectors: number;
185
+ } {
186
+ const entries = readDirectory(buf);
187
+ const usedEntries = entries.filter((e) => (e.flags & FLAG_USED) !== 0);
188
+ const nextFreeSector = calcNextSector(entries);
189
+ const totalSectors = buf.length / SECTOR_SIZE;
190
+
191
+ let usedDataSectors = 0;
192
+ for (const e of usedEntries) {
193
+ usedDataSectors += calcSectorCount(e.fileSize);
194
+ }
195
+
196
+ return {
197
+ totalSectors,
198
+ usedEntries: usedEntries.length,
199
+ freeEntries: entries.length - usedEntries.length,
200
+ nextFreeSector,
201
+ usedDataSectors,
202
+ freeDataSectors: totalSectors - 1 - usedDataSectors, // -1 for directory sector
203
+ };
204
+ }
@@ -0,0 +1,8 @@
1
+ export interface DirEntry {
2
+ name: string;
3
+ ext: string;
4
+ flags: number;
5
+ startSector: number;
6
+ fileSize: number;
7
+ index: number;
8
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "Node16",
5
+ "moduleResolution": "Node16",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "declaration": true,
13
+ "sourceMap": true,
14
+ "types": ["node"]
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist"]
18
+ }