nitro-patcher 0.3.0 → 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.
- package/dist/browser.d.ts +25 -2
- package/dist/browser.js +10507 -78
- package/dist/browser.js.LEGAL.txt +460 -0
- package/dist/browser.js.map +4 -4
- package/dist/{chunk-IGURDH5L.js → chunk-B7NTPBDX.js} +70 -8
- package/dist/chunk-B7NTPBDX.js.map +1 -0
- package/dist/chunk-LK3JXA24.js +2067 -0
- package/dist/chunk-LK3JXA24.js.map +1 -0
- package/dist/cli.js +19 -7
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -2
- package/dist/nitro-helper.d.ts +43 -134
- package/dist/nitro-helper.js +3 -1
- package/dist/nitro-patch-helper.d.ts +4 -0
- package/dist/nitro-patch-helper.js +2 -2
- package/dist/twl-complete-B2is30H0.d.ts +201 -0
- package/package.json +8 -2
- package/src/browser/buffer.ts +1 -0
- package/src/browser.ts +23 -2
- package/src/cli.ts +15 -2
- package/src/nitro/blz.ts +13 -4
- package/src/nitro/crypto.ts +29 -11
- package/src/nitro/filesystem.ts +88 -5
- package/src/nitro/header.ts +4 -0
- package/src/nitro/nds-file.ts +378 -30
- package/src/nitro/twl-complete.ts +665 -0
- package/src/nitro/twl.ts +53 -19
- package/src/nitro-helper.ts +2 -1
- package/src/nitro-patch-helper.ts +64 -3
- package/src/patch/zip.ts +14 -4
- package/src/runtime/browser.ts +2 -2
- package/src/runtime/node.ts +2 -15
- package/src/runtime/rsa.ts +14 -0
- package/dist/chunk-GLK3K2HY.js +0 -1120
- package/dist/chunk-GLK3K2HY.js.map +0 -1
- package/dist/chunk-IGURDH5L.js.map +0 -1
package/src/nitro/nds-file.ts
CHANGED
|
@@ -4,20 +4,25 @@ import { readFile, writeFile } from "#nitro-runtime";
|
|
|
4
4
|
import { Banner } from "./banner";
|
|
5
5
|
import { BinaryWriter, DEFAULT_MAX_SIZE, bytes, range } from "./binary";
|
|
6
6
|
import { crc16 } from "./crc";
|
|
7
|
-
import { encryptSecureArea } from "./crypto";
|
|
8
|
-
import { FileAllocationTable, FileNameTable, OverlayTable } from "./filesystem";
|
|
9
|
-
import type { NitroFile, NitroFolder } from "./filesystem";
|
|
7
|
+
import { decryptSecureArea, encryptSecureArea } from "./crypto";
|
|
8
|
+
import { FileAllocationTable, FileNameTable, OverlayTable, encodeName } from "./filesystem";
|
|
9
|
+
import type { NitroFile, NitroFolder, OverlayItem } from "./filesystem";
|
|
10
10
|
import { Header } from "./header";
|
|
11
11
|
import { TWL } from "./twl";
|
|
12
|
+
import { noDsiVerification } from "./twl-complete";
|
|
13
|
+
import type { DsiSection, DsiVerification } from "./twl-complete";
|
|
12
14
|
|
|
13
15
|
export interface RomOptions {
|
|
14
16
|
/** Maximum generated ROM size in bytes. Defaults to 1 GiB. */
|
|
15
17
|
maxOutputSize?: number;
|
|
18
|
+
/** Complete DSi cartridge handling is opt-in for direct API calls. */
|
|
19
|
+
dsiMode?: "legacy" | "complete";
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
export interface RomFileInfo {
|
|
19
23
|
/** ROM-relative path, including data/, overlay/, or a system filename. */
|
|
20
24
|
path: string;
|
|
25
|
+
/** Input ROM identity and position; both are -1 for an added file until the output is reopened. */
|
|
21
26
|
id: number;
|
|
22
27
|
offset: number;
|
|
23
28
|
size: number;
|
|
@@ -39,12 +44,18 @@ export class NDSFile {
|
|
|
39
44
|
readonly warnings: string[] = [];
|
|
40
45
|
private readonly paths = new Map<string, { path: string; file: NitroFile }>();
|
|
41
46
|
private readonly maxOutputSize: number;
|
|
47
|
+
private readonly addedOverlays = new Map<string, { arm9: boolean; entry: OverlayItem }>();
|
|
48
|
+
private structureChanged = false;
|
|
42
49
|
|
|
43
50
|
constructor(input: Uint8Array, options: RomOptions = {}) {
|
|
44
51
|
this.original = bytes(input, "ROM");
|
|
45
52
|
this.maxOutputSize = options.maxOutputSize ?? DEFAULT_MAX_SIZE;
|
|
53
|
+
if (!Number.isSafeInteger(this.maxOutputSize) || this.maxOutputSize < 1 || this.maxOutputSize > 0xffffffff)
|
|
54
|
+
throw new RangeError("Invalid output size limit");
|
|
46
55
|
this.header = new Header(this.original);
|
|
47
56
|
const h = this.header;
|
|
57
|
+
if (options.dsiMode === "complete" && h.unitCode === 3)
|
|
58
|
+
throw new Error("DSi-exclusive cartridges and DSiWare are unsupported");
|
|
48
59
|
range(this.original, h.ARM9romOffset, h.ARM9size + (h.nitrocode ? 12 : 0), "ARM9");
|
|
49
60
|
range(this.original, h.ARM7romOffset, h.ARM7size, "ARM7");
|
|
50
61
|
this.banner = new Banner(this.original, h.bannerOffset, h.banner_size);
|
|
@@ -81,7 +92,53 @@ export class NDSFile {
|
|
|
81
92
|
for (const child of folder.folders) visit(child, `${prefix + child.name}/`);
|
|
82
93
|
};
|
|
83
94
|
visit(this.root, "");
|
|
84
|
-
if (
|
|
95
|
+
if (options.dsiMode === "complete" && h.unitCode & 2) {
|
|
96
|
+
const regions: { start: number; size: number; name: string; payload?: boolean }[] = [
|
|
97
|
+
...this.root.files.map((file) => ({ start: file.offset, size: file.size, name: file.name })),
|
|
98
|
+
...this.fatTable.entries.map((file) => ({
|
|
99
|
+
start: file.offset,
|
|
100
|
+
size: file.size,
|
|
101
|
+
name: `FAT ${file.id}`,
|
|
102
|
+
payload: true,
|
|
103
|
+
})),
|
|
104
|
+
{ start: h.dsi9_rom_offset, size: h.dsi9_size, name: "ARM9i" },
|
|
105
|
+
{ start: h.dsi7_rom_offset, size: h.dsi7_size, name: "ARM7i" },
|
|
106
|
+
{ start: h.sector_hashtable_start, size: h.sector_hashtable_size, name: "DSi sector table" },
|
|
107
|
+
{ start: h.block_hashtable_start, size: h.block_hashtable_size, name: "DSi block table" },
|
|
108
|
+
]
|
|
109
|
+
.filter((region) => region.size)
|
|
110
|
+
.sort((a, b) => a.start - b.start);
|
|
111
|
+
let systemEnd: (typeof regions)[number] | undefined;
|
|
112
|
+
let payloadEnd: (typeof regions)[number] | undefined;
|
|
113
|
+
for (const region of regions) {
|
|
114
|
+
range(this.original, region.start, region.size, region.name);
|
|
115
|
+
for (const previous of [systemEnd, region.payload ? undefined : payloadEnd])
|
|
116
|
+
if (previous && previous.start + previous.size > region.start)
|
|
117
|
+
throw new Error(`DSi ROM regions overlap: ${previous.name} and ${region.name}`);
|
|
118
|
+
if (region.payload) {
|
|
119
|
+
if (!payloadEnd || region.start + region.size > payloadEnd.start + payloadEnd.size) payloadEnd = region;
|
|
120
|
+
} else systemEnd = region;
|
|
121
|
+
}
|
|
122
|
+
this.twl = new TWL(
|
|
123
|
+
h,
|
|
124
|
+
this.overlay9Table.entries
|
|
125
|
+
.filter((entry) => entry.reserved & 0x2000000)
|
|
126
|
+
.map((entry) => {
|
|
127
|
+
const file = this.fatTable.entries[entry.fileId];
|
|
128
|
+
return { offset: file.offset, size: file.size };
|
|
129
|
+
}),
|
|
130
|
+
this.original,
|
|
131
|
+
{ dsiMode: "complete", maxOutputSize: this.maxOutputSize },
|
|
132
|
+
);
|
|
133
|
+
for (const [name, offset, size] of [
|
|
134
|
+
["arm9i.bin", h.dsi9_rom_offset, h.dsi9_size],
|
|
135
|
+
["arm7i.bin", h.dsi7_rom_offset, h.dsi7_size],
|
|
136
|
+
] as const) {
|
|
137
|
+
const file: NitroFile = { name, id: 0xffff, offset, size };
|
|
138
|
+
this.root.files.push(file);
|
|
139
|
+
this.paths.set(name, { path: name, file });
|
|
140
|
+
}
|
|
141
|
+
} else if (h.unitCode & 2 && h.twlInternalFlags & 1 && h.tid_high && h.tid_high !== 0xffffffff) {
|
|
85
142
|
// Upstream silently discarded malformed TWL regions. Preserve valid DSi data,
|
|
86
143
|
// and reject malformed data instead of emitting a ROM with missing sections.
|
|
87
144
|
this.twl = new TWL(h, arm9Overlays, this.original);
|
|
@@ -103,7 +160,12 @@ export class NDSFile {
|
|
|
103
160
|
path,
|
|
104
161
|
id: file.id,
|
|
105
162
|
offset: file.offset,
|
|
106
|
-
size:
|
|
163
|
+
size:
|
|
164
|
+
this.twl?.complete && (path === "arm9i.bin" || path === "arm7i.bin")
|
|
165
|
+
? this.twl.complete.getProgram(path === "arm9i.bin" ? "arm9i" : "arm7i").length
|
|
166
|
+
: this.addedOverlays.size && (path === "overarm9.bin" || path === "overarm7.bin")
|
|
167
|
+
? this.readOverlayTable(path === "overarm9.bin").entries.length * 32
|
|
168
|
+
: (file.replacement?.length ?? file.size),
|
|
107
169
|
}));
|
|
108
170
|
}
|
|
109
171
|
hasFile(path: string): boolean {
|
|
@@ -114,19 +176,149 @@ export class NDSFile {
|
|
|
114
176
|
if (!entry) throw new Error(`ROM file not found: ${path}`);
|
|
115
177
|
return Buffer.from(this.fileBytes(entry.file));
|
|
116
178
|
}
|
|
117
|
-
/** Replace an existing file.
|
|
179
|
+
/** Replace an existing file. */
|
|
118
180
|
replaceFile(path: string, content: Uint8Array): void {
|
|
119
|
-
|
|
181
|
+
let entry = this.paths.get(key(path));
|
|
182
|
+
if (!entry && (key(path) === "overarm9.bin" || key(path) === "overarm7.bin")) {
|
|
183
|
+
if (content.byteLength > this.maxOutputSize) throw new RangeError("Replacement exceeds output size limit");
|
|
184
|
+
new OverlayTable(content, 0, content.byteLength, key(path) === "overarm9.bin");
|
|
185
|
+
const file: NitroFile = { name: key(path), id: 0xffff, offset: 0, size: 0 };
|
|
186
|
+
entry = { path: file.name, file };
|
|
187
|
+
this.root.files.push(file);
|
|
188
|
+
this.paths.set(file.name, entry);
|
|
189
|
+
this.structureChanged = true;
|
|
190
|
+
}
|
|
120
191
|
if (!entry) throw new Error(`ROM file not found: ${path}`);
|
|
192
|
+
if (this.twl?.complete && content.byteLength > this.maxOutputSize)
|
|
193
|
+
throw new RangeError("Replacement exceeds output size limit");
|
|
194
|
+
if (this.twl?.complete && (key(path) === "arm9i.bin" || key(path) === "arm7i.bin")) {
|
|
195
|
+
this.twl.complete.setProgram(key(path) === "arm9i.bin" ? "arm9i" : "arm7i", bytes(content));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
121
198
|
entry.file.replacement = Buffer.from(bytes(content));
|
|
122
199
|
}
|
|
200
|
+
/** Add a NitroFS file and any missing parent directories. */
|
|
201
|
+
addFile(path: string, content: Uint8Array): void {
|
|
202
|
+
const components = path.replaceAll("\\", "/").split("/");
|
|
203
|
+
if (
|
|
204
|
+
components[0]?.toLowerCase() !== "data" ||
|
|
205
|
+
components.length < 2 ||
|
|
206
|
+
components.slice(1).some((name) => !name || name === "." || name === ".." || name.includes("\0"))
|
|
207
|
+
)
|
|
208
|
+
throw new Error(`Invalid NitroFS path: ${path}`);
|
|
209
|
+
if (components.length > 258) throw new Error("FNT directory depth exceeds 256");
|
|
210
|
+
if (this.hasFile(path)) throw new Error(`ROM file already exists: ${path}`);
|
|
211
|
+
const replacement = Buffer.from(bytes(content));
|
|
212
|
+
const names: { name: string; nameBytes?: Buffer }[] = components.slice(1).map((name) => ({ name }));
|
|
213
|
+
let existing: NitroFolder | undefined = this.data;
|
|
214
|
+
for (const [index, component] of names.entries()) {
|
|
215
|
+
const normalized = component.name.toLowerCase();
|
|
216
|
+
if (existing?.files.some((file) => file.name.toLowerCase() === normalized))
|
|
217
|
+
throw new Error(`ROM path conflicts with a file: ${path}`);
|
|
218
|
+
const child: NitroFolder | undefined = existing?.folders.find((child) => child.name.toLowerCase() === normalized);
|
|
219
|
+
if (index === names.length - 1 && child) throw new Error(`ROM path conflicts with a directory: ${path}`);
|
|
220
|
+
if (!child) component.nameBytes = encodeName(component.name);
|
|
221
|
+
existing = child;
|
|
222
|
+
}
|
|
223
|
+
let folder = this.data;
|
|
224
|
+
let prefix = "data";
|
|
225
|
+
for (const [index, component] of names.entries()) {
|
|
226
|
+
const normalized = component.name.toLowerCase();
|
|
227
|
+
const child = folder.folders.find((child) => child.name.toLowerCase() === normalized);
|
|
228
|
+
if (index === names.length - 1) {
|
|
229
|
+
const file: NitroFile = { ...component, id: -1, offset: -1, size: replacement.length, replacement };
|
|
230
|
+
folder.files.push(file);
|
|
231
|
+
const outputPath = `${prefix}/${file.name}`;
|
|
232
|
+
this.paths.set(key(outputPath), { path: outputPath, file });
|
|
233
|
+
} else {
|
|
234
|
+
if (child) folder = child;
|
|
235
|
+
else {
|
|
236
|
+
const created: NitroFolder = { ...component, id: -1, files: [], folders: [] };
|
|
237
|
+
folder.folders.push(created);
|
|
238
|
+
folder = created;
|
|
239
|
+
}
|
|
240
|
+
prefix += `/${folder.name}`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
this.structureChanged = true;
|
|
244
|
+
}
|
|
245
|
+
/** Add an overlay; file IDs are assigned when the ROM is serialized. */
|
|
246
|
+
addOverlay(arm9: boolean, metadata: Omit<OverlayItem, "fileId">, content: Uint8Array): void {
|
|
247
|
+
const path = `overlay/overlay${arm9 ? "" : "7"}_${String(metadata.overlayId).padStart(4, "0")}.bin`;
|
|
248
|
+
if (!Number.isSafeInteger(metadata.overlayId) || metadata.overlayId < 0 || metadata.overlayId >= 0xffffffff)
|
|
249
|
+
throw new RangeError("Invalid overlay ID");
|
|
250
|
+
if (this.hasFile(path)) throw new Error(`ROM file already exists: ${path}`);
|
|
251
|
+
if (content.byteLength > this.maxOutputSize) throw new RangeError("Overlay exceeds output size limit");
|
|
252
|
+
const entry: OverlayItem = { ...metadata, fileId: 0 };
|
|
253
|
+
const table = new OverlayTable(Buffer.alloc(0), 0, 0, arm9);
|
|
254
|
+
table.entries.push(entry);
|
|
255
|
+
table.toBuffer();
|
|
256
|
+
const replacement = Buffer.from(bytes(content));
|
|
257
|
+
const file: NitroFile = { name: path.slice(8), id: -1, offset: -1, size: replacement.length, replacement };
|
|
258
|
+
const tableName = arm9 ? "overarm9.bin" : "overarm7.bin";
|
|
259
|
+
if (!this.hasFile(tableName)) this.replaceFile(tableName, Buffer.alloc(0));
|
|
260
|
+
this.overlay.files.push(file);
|
|
261
|
+
this.paths.set(path, { path, file });
|
|
262
|
+
this.addedOverlays.set(path, { arm9, entry });
|
|
263
|
+
this.structureChanged = true;
|
|
264
|
+
}
|
|
265
|
+
private readOverlayTable(arm9: boolean): OverlayTable {
|
|
266
|
+
const original = arm9 ? this.overlay9Table : this.overlay7Table;
|
|
267
|
+
const replacement = this.paths.get(arm9 ? "overarm9.bin" : "overarm7.bin")?.file.replacement;
|
|
268
|
+
const raw = replacement ?? original.toBuffer();
|
|
269
|
+
const table = new OverlayTable(raw, 0, raw.length, arm9);
|
|
270
|
+
for (const { arm9: target, entry } of this.addedOverlays.values()) {
|
|
271
|
+
if (target !== arm9) continue;
|
|
272
|
+
const existing = table.entries.find((item) => item.overlayId === entry.overlayId);
|
|
273
|
+
if (existing) Object.assign(existing, entry);
|
|
274
|
+
else table.entries.push({ ...entry });
|
|
275
|
+
}
|
|
276
|
+
const ids = new Set<number>();
|
|
277
|
+
for (const entry of table.entries) {
|
|
278
|
+
if (ids.has(entry.overlayId)) throw new Error(`Duplicate overlay ID: ${entry.overlayId}`);
|
|
279
|
+
ids.add(entry.overlayId);
|
|
280
|
+
const path = `overlay/overlay${arm9 ? "" : "7"}_${String(entry.overlayId).padStart(4, "0")}.bin`;
|
|
281
|
+
if (!this.hasFile(path)) throw new Error(`Overlay table entry has no payload: ${path}`);
|
|
282
|
+
}
|
|
283
|
+
if (original.entries.some((entry) => !ids.has(entry.overlayId)))
|
|
284
|
+
throw new Error("Removing overlays is unsupported");
|
|
285
|
+
return table;
|
|
286
|
+
}
|
|
123
287
|
private fileBytes(file: NitroFile): Buffer {
|
|
288
|
+
if (this.twl?.complete && (file.name === "arm9i.bin" || file.name === "arm7i.bin"))
|
|
289
|
+
return this.twl.complete.getProgram(file.name === "arm9i.bin" ? "arm9i" : "arm7i");
|
|
290
|
+
if (this.addedOverlays.size && (file.name === "overarm9.bin" || file.name === "overarm7.bin"))
|
|
291
|
+
return this.readOverlayTable(file.name === "overarm9.bin").toBuffer();
|
|
124
292
|
return file.replacement ?? range(this.original, file.offset, file.size, file.name);
|
|
125
293
|
}
|
|
126
294
|
|
|
295
|
+
verifyDsi(): DsiVerification {
|
|
296
|
+
if (this.twl?.complete) return this.twl.complete.verify();
|
|
297
|
+
if (this.header.unitCode & 2)
|
|
298
|
+
return new NDSFile(this.original, { dsiMode: "complete", maxOutputSize: this.maxOutputSize }).verifyDsi();
|
|
299
|
+
return noDsiVerification();
|
|
300
|
+
}
|
|
301
|
+
|
|
127
302
|
/** Rebuild offsets, allocation table, CRCs and ROM padding. Safe to call repeatedly. */
|
|
128
303
|
toBuffer(): Buffer {
|
|
304
|
+
if (
|
|
305
|
+
this.twl?.complete &&
|
|
306
|
+
!this.structureChanged &&
|
|
307
|
+
this.twl.complete.unchanged() &&
|
|
308
|
+
this.header.raw.equals(this.original.subarray(0, this.header.raw.length)) &&
|
|
309
|
+
this.banner
|
|
310
|
+
.toBuffer()
|
|
311
|
+
.equals(new Banner(this.original, this.header.bannerOffset, this.header.banner_size).toBuffer()) &&
|
|
312
|
+
[...this.paths.values()].every(
|
|
313
|
+
({ file }) =>
|
|
314
|
+
!file.replacement || file.replacement.equals(range(this.original, file.offset, file.size, file.name)),
|
|
315
|
+
)
|
|
316
|
+
) {
|
|
317
|
+
if (this.original.length > this.maxOutputSize) throw new RangeError("Output exceeds size limit");
|
|
318
|
+
return Buffer.from(this.original);
|
|
319
|
+
}
|
|
129
320
|
const fntReplacement = this.root.files.find((file) => file.name === "fnt.bin")?.replacement;
|
|
321
|
+
if (this.structureChanged && fntReplacement) throw new Error("Adding files conflicts with an fnt.bin replacement");
|
|
130
322
|
if (fntReplacement) {
|
|
131
323
|
const replacementTree = new FileNameTable(this.fatTable, fntReplacement, 0, fntReplacement.length);
|
|
132
324
|
const collect = (folder: NitroFolder): number[] =>
|
|
@@ -134,6 +326,9 @@ export class NDSFile {
|
|
|
134
326
|
if (collect(replacementTree.root).join(",") !== collect(this.data).join(","))
|
|
135
327
|
throw new Error("Replacement FNT must preserve existing file IDs");
|
|
136
328
|
}
|
|
329
|
+
const layout = this.structureChanged ? this.fntTable.serialize(this.fatTable, this.overlay.files) : undefined;
|
|
330
|
+
if (this.twl?.complete) return this.serializeDsi(layout);
|
|
331
|
+
const overlayFiles = new Map(this.overlay.files.map((file) => [file.id, file]));
|
|
137
332
|
const h = new Header(this.header.toBuffer());
|
|
138
333
|
h.nitrocode = this.header.nitrocode;
|
|
139
334
|
h.decrypted = this.header.decrypted;
|
|
@@ -153,31 +348,33 @@ export class NDSFile {
|
|
|
153
348
|
}
|
|
154
349
|
const placements = new Map<number, { start: number; end: number }>();
|
|
155
350
|
const write = (file: NitroFile, padding = true): void => {
|
|
351
|
+
const id = layout ? (file.id >= 0 ? layout.sourceIds.get(file.id) : layout.ids.get(file)) : file.id;
|
|
352
|
+
if (layout && id !== undefined && placements.has(id)) return;
|
|
353
|
+
if (layout && file.id >= 0) file = overlayFiles.get(file.id) ?? file;
|
|
156
354
|
const start = writer.position;
|
|
157
355
|
writer.write(this.fileBytes(file));
|
|
158
|
-
if (
|
|
356
|
+
if (id !== undefined && id !== 0xffff) placements.set(id, { start, end: writer.position });
|
|
159
357
|
if (padding) writer.pad(0x200);
|
|
160
358
|
};
|
|
161
359
|
const overlayIds = new Set<number>();
|
|
162
360
|
const writeOverlays = (arm9: boolean): void => {
|
|
163
|
-
const originalTable = arm9 ? this.overlay9Table : this.overlay7Table;
|
|
164
361
|
const name = arm9 ? "overarm9.bin" : "overarm7.bin";
|
|
165
362
|
const entry = this.root.files.find((file) => file.name === name);
|
|
166
363
|
if (!entry) return;
|
|
167
|
-
const
|
|
168
|
-
const table = new OverlayTable(raw, 0, raw.length, arm9);
|
|
364
|
+
const table = this.readOverlayTable(arm9);
|
|
169
365
|
const files = this.overlay.files
|
|
170
366
|
.filter((file) => file.name.startsWith(arm9 ? "overlay_" : "overlay7_"))
|
|
171
|
-
.sort((a, b) => a.id - b.id);
|
|
367
|
+
.sort((a, b) => (a.id < 0 ? (b.id < 0 ? 0 : 1) : b.id < 0 ? -1 : a.id - b.id));
|
|
172
368
|
for (const file of files) {
|
|
173
|
-
const item = table.entries.find(
|
|
174
|
-
|
|
175
|
-
|
|
369
|
+
const item = table.entries.find(
|
|
370
|
+
(item) => file.name === `overlay${arm9 ? "" : "7"}_${String(item.overlayId).padStart(4, "0")}.bin`,
|
|
371
|
+
)!;
|
|
372
|
+
const size = this.fileBytes(layout && file.id >= 0 ? overlayFiles.get(file.id)! : file).length;
|
|
176
373
|
if ((item.reserved >>> 24) & 1 || (!arm9 && item.reserved > 0))
|
|
177
374
|
item.reserved = ((item.reserved & 0xff000000) | (size & 0xffffff)) >>> 0;
|
|
375
|
+
item.fileId = layout ? (file.id >= 0 ? layout.sourceIds.get(file.id)! : layout.ids.get(file)!) : file.id;
|
|
178
376
|
overlayIds.add(file.id);
|
|
179
377
|
}
|
|
180
|
-
if (table.entries.length !== files.length) throw new Error("Changing overlay file count is unsupported");
|
|
181
378
|
const encoded = table.toBuffer();
|
|
182
379
|
if (arm9) {
|
|
183
380
|
h.ARM9overlayOffset = writer.position;
|
|
@@ -207,10 +404,12 @@ export class NDSFile {
|
|
|
207
404
|
h.ARM7overlayOffset = 0;
|
|
208
405
|
writeOverlays(false);
|
|
209
406
|
h.FNToffset = writer.position;
|
|
210
|
-
|
|
211
|
-
|
|
407
|
+
const fnt = layout?.fnt ?? this.fileBytes(system("fnt.bin"));
|
|
408
|
+
h.FNTsize = fnt.length;
|
|
409
|
+
writer.write(fnt);
|
|
410
|
+
writer.pad(0x200);
|
|
212
411
|
h.FAToffset = writer.position;
|
|
213
|
-
h.FATsize = this.fatTable.entries.length * 8;
|
|
412
|
+
h.FATsize = (layout?.files.length ?? this.fatTable.entries.length) * 8;
|
|
214
413
|
writer.write(Buffer.alloc(h.FATsize));
|
|
215
414
|
writer.pad(0x200);
|
|
216
415
|
h.bannerOffset = writer.position;
|
|
@@ -223,19 +422,22 @@ export class NDSFile {
|
|
|
223
422
|
if (h.unitCode & 2 && h.headerSize > 0x200) h.banner_size = bannerBytes.length;
|
|
224
423
|
writer.write(bannerBytes);
|
|
225
424
|
writer.pad(0x200);
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
425
|
+
if (layout) {
|
|
426
|
+
for (const [index, file] of layout.files.entries()) write(file, index < layout.files.length - 1);
|
|
427
|
+
} else
|
|
428
|
+
for (const [index, id] of this.fatTable.sortedIDs.entries()) {
|
|
429
|
+
if (overlayIds.has(id)) continue;
|
|
430
|
+
const file = FileNameTable.findFile(id, this.root);
|
|
431
|
+
if (file) write(file, index < this.fatTable.sortedIDs.length - 1);
|
|
432
|
+
else {
|
|
433
|
+
// Games can address FAT entries directly without a NitroFS filename.
|
|
434
|
+
// Keep their payload and allocation instead of silently zeroing the entry.
|
|
435
|
+
const entry = this.fatTable.entries[id];
|
|
436
|
+
if (entry.size > 0) {
|
|
437
|
+
write({ ...entry, name: `FAT entry ${id}` }, index < this.fatTable.sortedIDs.length - 1);
|
|
438
|
+
}
|
|
236
439
|
}
|
|
237
440
|
}
|
|
238
|
-
}
|
|
239
441
|
const logicalEnd = writer.position;
|
|
240
442
|
const fat = Buffer.alloc(h.FATsize);
|
|
241
443
|
for (const [id, location] of placements) {
|
|
@@ -263,4 +465,150 @@ export class NDSFile {
|
|
|
263
465
|
async saveAs(path: string): Promise<void> {
|
|
264
466
|
await writeFile(path, this.toBuffer());
|
|
265
467
|
}
|
|
468
|
+
|
|
469
|
+
private serializeDsi(layout: ReturnType<FileNameTable["serialize"]> | undefined): Buffer {
|
|
470
|
+
const twl = this.twl!.complete!;
|
|
471
|
+
const h = new Header(this.header.raw);
|
|
472
|
+
h.nitrocode = this.header.nitrocode;
|
|
473
|
+
const system = (name: string): NitroFile => this.paths.get(name)!.file;
|
|
474
|
+
const replacement = system("header.bin").replacement;
|
|
475
|
+
if (replacement) {
|
|
476
|
+
const source = new Header(replacement);
|
|
477
|
+
if (
|
|
478
|
+
source.unitCode !== 2 ||
|
|
479
|
+
source.tid_high !== h.tid_high ||
|
|
480
|
+
source.tid_low !== h.tid_low ||
|
|
481
|
+
(h.dsi9_size && !source.dsi9_size) ||
|
|
482
|
+
(h.dsi7_size && !source.dsi7_size)
|
|
483
|
+
)
|
|
484
|
+
throw new Error("Replacement header changes or clears the DSi cartridge identity or programs");
|
|
485
|
+
// Preserve semantic and unknown header bytes; physical fields are assigned below.
|
|
486
|
+
source.raw.copy(h.raw);
|
|
487
|
+
for (const [start, end] of [
|
|
488
|
+
[0x20, 0x40],
|
|
489
|
+
[0x40, 0x60],
|
|
490
|
+
[0x68, 0x6e],
|
|
491
|
+
[0x80, 0x88],
|
|
492
|
+
[0x90, 0x94],
|
|
493
|
+
[0x1c0, 0x1c4],
|
|
494
|
+
[0x1cc, 0x1d4],
|
|
495
|
+
[0x1dc, 0x214],
|
|
496
|
+
[0x220, 0x230],
|
|
497
|
+
[0x300, 0x378],
|
|
498
|
+
[0x3a0, 0x3b4],
|
|
499
|
+
[0xf80, 0x1000],
|
|
500
|
+
])
|
|
501
|
+
this.header.raw.copy(h.raw, start, start, end);
|
|
502
|
+
h.ARM9entryAddress = source.ARM9entryAddress;
|
|
503
|
+
h.ARM9ramAddress = source.ARM9ramAddress;
|
|
504
|
+
h.ARM7entryAddress = source.ARM7entryAddress;
|
|
505
|
+
h.ARM7ramAddress = source.ARM7ramAddress;
|
|
506
|
+
h.dsi7_ram_address = source.dsi7_ram_address;
|
|
507
|
+
h.headerSize = this.header.headerSize;
|
|
508
|
+
}
|
|
509
|
+
const sections: DsiSection[] = [];
|
|
510
|
+
const add = (label: string, file: NitroFile, data: Buffer, place: (start: number) => void): void => {
|
|
511
|
+
sections.push({ label, start: file.offset, originalSize: file.size, data, place });
|
|
512
|
+
};
|
|
513
|
+
const arm9File = system("arm9.bin");
|
|
514
|
+
let arm9 = this.fileBytes(arm9File);
|
|
515
|
+
if (arm9File.replacement) h.nitrocode = arm9.length >= 12 && arm9.readUInt32LE(arm9.length - 12) === 0xdec00621;
|
|
516
|
+
if (h.gameCode !== twl.header.gameCode && arm9.readBigUInt64LE(0) !== 0xe7ffdeffe7ffdeffn)
|
|
517
|
+
arm9 = encryptSecureArea(h.gameCode, decryptSecureArea(twl.header.gameCode, arm9));
|
|
518
|
+
const signedOverlays = this.readOverlayTable(true).entries.filter((entry) => entry.reserved & 0x2000000);
|
|
519
|
+
if (signedOverlays.length !== twl.Overlays9Sha1Hmac.length) {
|
|
520
|
+
if (!arm9File.replacement) throw new Error("Changing signed overlay count requires an ARM9 replacement");
|
|
521
|
+
} else
|
|
522
|
+
arm9 = twl.updateOverlays9Sha1Hmac(
|
|
523
|
+
arm9,
|
|
524
|
+
h,
|
|
525
|
+
signedOverlays.map((entry) => this.getFile(`overlay/overlay_${String(entry.overlayId).padStart(4, "0")}.bin`)),
|
|
526
|
+
);
|
|
527
|
+
h.ARM9size = arm9.length - (h.nitrocode ? 12 : 0);
|
|
528
|
+
h.decrypted = arm9.length >= 8 && arm9.readBigUInt64LE(0) === 0xe7ffdeffe7ffdeffn;
|
|
529
|
+
const plainArm9 = h.decrypted ? Buffer.from(arm9) : decryptSecureArea(h.gameCode, arm9);
|
|
530
|
+
plainArm9.writeUInt16LE(crc16(range(plainArm9, 0x10, 0x7f0, "ARM9 secure-area CRC")), 0xe);
|
|
531
|
+
arm9 = h.decrypted ? plainArm9 : encryptSecureArea(h.gameCode, plainArm9);
|
|
532
|
+
add("ARM9", arm9File, arm9, (start) => {
|
|
533
|
+
h.ARM9romOffset = start;
|
|
534
|
+
});
|
|
535
|
+
const written = new Set<number>();
|
|
536
|
+
const fileList =
|
|
537
|
+
layout?.files ??
|
|
538
|
+
this.fatTable.entries.map(
|
|
539
|
+
(entry) => FileNameTable.findFile(entry.id, this.root) ?? { ...entry, name: `FAT entry ${entry.id}` },
|
|
540
|
+
);
|
|
541
|
+
const fat = Buffer.alloc(fileList.length * 8);
|
|
542
|
+
const addPayload = (file: NitroFile): void => {
|
|
543
|
+
const id = layout ? (file.id >= 0 ? layout.sourceIds.get(file.id)! : layout.ids.get(file)!) : file.id;
|
|
544
|
+
if (written.has(id)) return;
|
|
545
|
+
written.add(id);
|
|
546
|
+
const overlay = file.id >= 0 ? this.overlay.files.find((overlay) => overlay.id === file.id) : undefined;
|
|
547
|
+
if (overlay) file = overlay;
|
|
548
|
+
const data = this.fileBytes(file);
|
|
549
|
+
add(file.name, file, data, (start) => {
|
|
550
|
+
fat.writeUInt32LE(start, id * 8);
|
|
551
|
+
fat.writeUInt32LE(start + data.length, id * 8 + 4);
|
|
552
|
+
});
|
|
553
|
+
};
|
|
554
|
+
const overlays = (arm9: boolean): void => {
|
|
555
|
+
const name = arm9 ? "overarm9.bin" : "overarm7.bin";
|
|
556
|
+
if (!this.hasFile(name)) return;
|
|
557
|
+
const file = system(name);
|
|
558
|
+
const table = this.readOverlayTable(arm9);
|
|
559
|
+
for (const entry of table.entries) {
|
|
560
|
+
const payload = this.paths.get(
|
|
561
|
+
`overlay/overlay${arm9 ? "" : "7"}_${String(entry.overlayId).padStart(4, "0")}.bin`,
|
|
562
|
+
)!.file;
|
|
563
|
+
const size = this.fileBytes(payload).length;
|
|
564
|
+
if ((entry.reserved >>> 24) & 1 || (!arm9 && entry.reserved > 0))
|
|
565
|
+
entry.reserved = ((entry.reserved & 0xff000000) | size) >>> 0;
|
|
566
|
+
entry.fileId = layout
|
|
567
|
+
? payload.id >= 0
|
|
568
|
+
? layout.sourceIds.get(payload.id)!
|
|
569
|
+
: layout.ids.get(payload)!
|
|
570
|
+
: payload.id;
|
|
571
|
+
}
|
|
572
|
+
add(name, file, table.toBuffer(), (start) => {
|
|
573
|
+
if (arm9) {
|
|
574
|
+
h.ARM9overlayOffset = start;
|
|
575
|
+
h.ARM9overlaySize = table.entries.length * 32;
|
|
576
|
+
} else {
|
|
577
|
+
h.ARM7overlayOffset = start;
|
|
578
|
+
h.ARM7overlaySize = table.entries.length * 32;
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
for (const entry of table.entries)
|
|
582
|
+
addPayload(
|
|
583
|
+
this.paths.get(`overlay/overlay${arm9 ? "" : "7"}_${String(entry.overlayId).padStart(4, "0")}.bin`)!.file,
|
|
584
|
+
);
|
|
585
|
+
};
|
|
586
|
+
overlays(true);
|
|
587
|
+
const arm7 = system("arm7.bin");
|
|
588
|
+
h.ARM7size = this.fileBytes(arm7).length;
|
|
589
|
+
add("ARM7", arm7, this.fileBytes(arm7), (start) => {
|
|
590
|
+
h.ARM7romOffset = start;
|
|
591
|
+
});
|
|
592
|
+
overlays(false);
|
|
593
|
+
const fnt = layout?.fnt ?? this.fileBytes(system("fnt.bin"));
|
|
594
|
+
h.FNTsize = fnt.length;
|
|
595
|
+
add("FNT", system("fnt.bin"), fnt, (start) => {
|
|
596
|
+
h.FNToffset = start;
|
|
597
|
+
});
|
|
598
|
+
h.FATsize = fat.length;
|
|
599
|
+
add("FAT", system("fat.bin"), fat, (start) => {
|
|
600
|
+
h.FAToffset = start;
|
|
601
|
+
});
|
|
602
|
+
const bannerFile = system("banner.bin");
|
|
603
|
+
const banner = bannerFile.replacement
|
|
604
|
+
? new Banner(bannerFile.replacement, 0, bannerFile.replacement.length)
|
|
605
|
+
: this.banner;
|
|
606
|
+
const bannerBytes = banner.toBuffer();
|
|
607
|
+
h.banner_size = bannerBytes.length;
|
|
608
|
+
add("Banner", bannerFile, bannerBytes, (start) => {
|
|
609
|
+
h.bannerOffset = start;
|
|
610
|
+
});
|
|
611
|
+
for (const file of fileList) addPayload(file);
|
|
612
|
+
return twl.serialize(h, sections, !!layout);
|
|
613
|
+
}
|
|
266
614
|
}
|