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.
@@ -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 (h.unitCode & 2 && h.twlInternalFlags & 1 && h.tid_high && h.tid_high !== 0xffffffff) {
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: file.replacement?.length ?? file.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. Adding or deleting filesystem entries is not supported. */
179
+ /** Replace an existing file. */
118
180
  replaceFile(path: string, content: Uint8Array): void {
119
- const entry = this.paths.get(key(path));
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 (file.id !== 0xffff) placements.set(file.id, { start, end: writer.position });
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 raw = entry.replacement ?? originalTable.toBuffer();
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((item) => item.fileId === file.id);
174
- if (!item) throw new Error(`Replacement overlay table is missing file id ${file.id}`);
175
- const size = this.fileBytes(file).length;
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
- h.FNTsize = this.fileBytes(system("fnt.bin")).length;
211
- write(system("fnt.bin"));
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
- for (const [index, id] of this.fatTable.sortedIDs.entries()) {
227
- if (overlayIds.has(id)) continue;
228
- const file = FileNameTable.findFile(id, this.root);
229
- if (file) write(file, index < this.fatTable.sortedIDs.length - 1);
230
- else {
231
- // Games can address FAT entries directly without a NitroFS filename.
232
- // Keep their payload and allocation instead of silently zeroing the entry.
233
- const entry = this.fatTable.entries[id];
234
- if (entry.size > 0) {
235
- write({ ...entry, name: `FAT entry ${id}` }, index < this.fatTable.sortedIDs.length - 1);
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
  }