nitro-patcher 0.2.1 → 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,665 @@
1
+ import { hmacSha1, readFileSync, verifyRsaSha1 } from "#nitro-runtime";
2
+
3
+ import { BinaryWriter, align, range } from "./binary";
4
+ import { compressArm9, decompressArm9 } from "./blz";
5
+ import type { Arm9Header } from "./blz";
6
+ import { crc16 } from "./crc";
7
+ import { aes128CtrCrypt, encryptSecureArea, modcryptKey } from "./crypto";
8
+ import { Header } from "./header";
9
+ import { HMAC_SHA1_KEY } from "./twl";
10
+
11
+ export interface DsiCheck {
12
+ status: "valid" | "invalid" | "not-applicable";
13
+ reason?: string;
14
+ }
15
+ export interface DsiVerification {
16
+ arm9: DsiCheck;
17
+ arm7: DsiCheck;
18
+ arm9i: DsiCheck;
19
+ arm7i: DsiCheck;
20
+ arm9NoSecure: DsiCheck;
21
+ banner: DsiCheck;
22
+ sectors: DsiCheck;
23
+ blocks: DsiCheck;
24
+ master: DsiCheck;
25
+ signature: DsiCheck;
26
+ }
27
+
28
+ type ProgramName = "arm9" | "arm7" | "arm9i" | "arm7i";
29
+ interface Program {
30
+ name: ProgramName;
31
+ start: number;
32
+ length: number;
33
+ data: Buffer;
34
+ original: Buffer;
35
+ replaced: boolean;
36
+ }
37
+ interface CryptRegion {
38
+ id: 1 | 2;
39
+ program: ProgramName;
40
+ offset: number;
41
+ length: number;
42
+ kind: "whole" | "secure" | "fixed";
43
+ }
44
+ export interface DsiSection {
45
+ label: string;
46
+ start: number;
47
+ originalSize: number;
48
+ data: Buffer;
49
+ place: (start: number) => void;
50
+ }
51
+
52
+ const hash = (data: Buffer): Buffer => hmacSha1(HMAC_SHA1_KEY, data);
53
+ const check = (valid: boolean): DsiCheck => ({ status: valid ? "valid" : "invalid" });
54
+ const absent = (reason: string): DsiCheck => ({ status: "not-applicable", reason });
55
+ const fields = {
56
+ arm9: ["ARM9romOffset", "ARM9size", "hmac_arm9"],
57
+ arm7: ["ARM7romOffset", "ARM7size", "hmac_arm7"],
58
+ arm9i: ["dsi9_rom_offset", "dsi9_size", "hmac_arm9i"],
59
+ arm7i: ["dsi7_rom_offset", "dsi7_size", "hmac_arm7i"],
60
+ } as const;
61
+ const digestFields = [
62
+ "digest_ntr_start",
63
+ "digest_ntr_size",
64
+ "digest_twl_start",
65
+ "digest_twl_size",
66
+ "sector_hashtable_start",
67
+ "sector_hashtable_size",
68
+ "block_hashtable_start",
69
+ "block_hashtable_size",
70
+ "digest_sector_size",
71
+ "digest_block_sectorcount",
72
+ ] as const;
73
+
74
+ export const noDsiVerification = (): DsiVerification =>
75
+ Object.fromEntries(
76
+ ["arm9", "arm7", "arm9i", "arm7i", "arm9NoSecure", "banner", "sectors", "blocks", "master", "signature"].map(
77
+ (name) => [name, absent("Not a DSi-enhanced cartridge")],
78
+ ),
79
+ ) as unknown as DsiVerification;
80
+
81
+ /** Complete cartridge processing; legacy TWL serialization stays independent. */
82
+ export class CompleteTWL {
83
+ readonly header: Header;
84
+ readonly programs: Map<ProgramName, Program>;
85
+ readonly regions: CryptRegion[];
86
+ readonly hasDigests: boolean;
87
+ readonly Hashtable1Data: Buffer;
88
+ readonly Hashtable2Data: Buffer;
89
+ readonly Header2Data: Buffer[];
90
+ readonly Overlays9Sha1Hmac: Buffer[];
91
+ readonly twlEncrypted: boolean;
92
+ readonly firstDSiHashOffset: number;
93
+ readonly dsiHashSize: number;
94
+ private readonly original: Buffer;
95
+ private readonly limit: number;
96
+
97
+ constructor(header: Header, overlays: readonly Buffer[], original: Buffer, limit: number) {
98
+ if (header.unitCode !== 2) throw new Error("Complete DSi processing requires a DSi-enhanced cartridge");
99
+ if (header.tid_high && header.tid_high !== 0x30000)
100
+ throw new Error("DSiWare and non-cartridge titles are unsupported");
101
+ this.header = new Header(header.raw);
102
+ this.header.decrypted = header.decrypted;
103
+ this.header.nitrocode = header.nitrocode;
104
+ this.original = original;
105
+ this.limit = limit;
106
+ this.hasDigests = digestFields.some((name) => header[name] !== 0);
107
+ this.validateDigests(header, original);
108
+ if (header.sector_hashtable_size > limit || header.block_hashtable_size > limit)
109
+ throw new RangeError("DSi digest table exceeds output size limit");
110
+ for (const name of ["dsi9_ram_address", "dsi7_ram_address"] as const)
111
+ if (!header[name] || header[name] === 0xffffffff) throw new Error(`Missing DSi program load address: ${name}`);
112
+ this.Hashtable1Data = Buffer.from(
113
+ range(original, header.sector_hashtable_start, header.sector_hashtable_size, "DSi sector table"),
114
+ );
115
+ this.Hashtable2Data = Buffer.from(
116
+ range(original, header.block_hashtable_start, header.block_hashtable_size, "DSi block table"),
117
+ );
118
+ this.firstDSiHashOffset = this.hasDigests ? (header.digest_ntr_size / header.digest_sector_size) * 20 : 0;
119
+ this.dsiHashSize = this.hasDigests ? (header.digest_twl_size / header.digest_sector_size) * 20 : 0;
120
+ this.programs = new Map(
121
+ Object.entries(fields).map(([name, [offset, size]]) => {
122
+ const start = header[offset];
123
+ const length = header[size];
124
+ if (length && !start) throw new Error(`Missing ${name} ROM offset`);
125
+ if (align(length, 16) > limit) throw new RangeError(`${name} exceeds output size limit`);
126
+ const data = Buffer.from(range(original, start, length ? align(length, 16) : 0, name));
127
+ return [
128
+ name as ProgramName,
129
+ { name: name as ProgramName, start, length, data, original: data, replaced: false },
130
+ ];
131
+ }),
132
+ );
133
+ const ordered = [...this.programs.values()].filter((program) => program.length).sort((a, b) => a.start - b.start);
134
+ for (let index = 1; index < ordered.length; index++)
135
+ if (ordered[index - 1].start + ordered[index - 1].data.length > ordered[index].start)
136
+ throw new Error("DSi program regions overlap");
137
+ this.regions = [];
138
+ for (const id of [1, 2] as const) {
139
+ const start = header[`modcrypt${id}_start`];
140
+ const length = header[`modcrypt${id}_size`];
141
+ if (!start && !length) continue;
142
+ if (!start || !length || length % 16) throw new Error(`Invalid Modcrypt region ${id}`);
143
+ const candidates = ordered.filter(
144
+ (program) => start >= program.start && start + length <= program.start + program.data.length,
145
+ );
146
+ if (candidates.length !== 1)
147
+ throw new Error(`Modcrypt region ${id} conflicts with program ranges: ${start}..${start + length}`);
148
+ const program = candidates[0];
149
+ if (program.name === "arm9" || program.name === "arm7")
150
+ throw new Error(`Modcrypt region ${id} targets ${program.name}; dual-mode cartridge startup is unsupported`);
151
+ const offset = start - program.start;
152
+ this.regions.push({
153
+ id,
154
+ program: program.name,
155
+ offset,
156
+ length,
157
+ kind:
158
+ offset === 0 && length === program.data.length
159
+ ? "whole"
160
+ : offset === 0 && program.name === "arm9i" && length === Math.min(0x4000, program.data.length)
161
+ ? "secure"
162
+ : "fixed",
163
+ });
164
+ }
165
+ this.twlEncrypted = !!(header.twlInternalFlags & 2);
166
+ this.decryptPrograms();
167
+ for (const program of this.programs.values()) program.original = Buffer.from(program.data);
168
+ const boundary = header.twl_region_start * 0x80000;
169
+ const first = Math.min(...ordered.filter((program) => program.name.endsWith("i")).map((program) => program.start));
170
+ const prefixEnd = boundary + 0x3000;
171
+ this.Header2Data =
172
+ boundary &&
173
+ prefixEnd <= original.length &&
174
+ ((Number.isFinite(first) && first >= prefixEnd) ||
175
+ (header.digest_twl_size && header.digest_twl_start >= prefixEnd) ||
176
+ header.total_rom_size >= prefixEnd)
177
+ ? Array.from({ length: 3 }, (_, index) =>
178
+ Buffer.from(range(original, boundary + index * 0x1000, 0x1000, "TWL unreadable region")),
179
+ )
180
+ : [];
181
+ this.Overlays9Sha1Hmac = overlays.map(hash);
182
+ }
183
+
184
+ private validateDigests(h: Header, original: Buffer): void {
185
+ if (!this.hasDigests) return;
186
+ const s = h.digest_sector_size;
187
+ const b = h.digest_block_sectorcount;
188
+ if (!Number.isSafeInteger(s) || s <= 0 || s > 0x4000 || 0x4000 % s)
189
+ throw new Error("Invalid DSi digest sector size");
190
+ if (!b || b > 0x100000) throw new Error("Invalid DSi digest block sector count");
191
+ if (
192
+ !h.digest_ntr_start ||
193
+ h.digest_ntr_size < 0x4000 ||
194
+ h.digest_ntr_size % s ||
195
+ h.digest_twl_size % s ||
196
+ (h.digest_twl_size && !h.digest_twl_start)
197
+ )
198
+ throw new Error("Incomplete DSi digest regions");
199
+ const count = (h.digest_ntr_size + h.digest_twl_size) / s;
200
+ if (
201
+ !h.sector_hashtable_start ||
202
+ !h.block_hashtable_start ||
203
+ !h.sector_hashtable_size ||
204
+ h.sector_hashtable_size % (b * 20) ||
205
+ h.sector_hashtable_size < count * 20 ||
206
+ h.block_hashtable_size !== h.sector_hashtable_size / b
207
+ )
208
+ throw new Error("Incomplete or invalid DSi digest tables");
209
+ for (const [start, size, label] of [
210
+ [h.digest_ntr_start, h.digest_ntr_size, "NTR digest region"],
211
+ [h.digest_twl_start, h.digest_twl_size, "TWL digest region"],
212
+ [h.sector_hashtable_start, h.sector_hashtable_size, "DSi sector table"],
213
+ [h.block_hashtable_start, h.block_hashtable_size, "DSi block table"],
214
+ ] as const)
215
+ range(original, start, size, label);
216
+ if (
217
+ h.digest_ntr_start + h.digest_ntr_size > h.sector_hashtable_start ||
218
+ h.sector_hashtable_start + h.sector_hashtable_size > h.block_hashtable_start ||
219
+ (h.digest_twl_size && h.block_hashtable_start + h.block_hashtable_size > h.digest_twl_start)
220
+ )
221
+ throw new Error("DSi digest regions overlap");
222
+ }
223
+
224
+ private decryptPrograms(): void {
225
+ if (!this.regions.length) return;
226
+ const h = this.header;
227
+ const key = modcryptKey(h);
228
+ const solutions: Buffer[][] = [];
229
+ const programs = [...this.programs.values()];
230
+ for (let mask = 0; mask < 2 ** this.regions.length; mask++) {
231
+ const views = programs.map((program) => Buffer.from(program.data));
232
+ for (let index = this.regions.length - 1; index >= 0; index--) {
233
+ if (!(mask & (1 << index))) continue;
234
+ const region = this.regions[index];
235
+ const data = views[programs.findIndex((program) => program.name === region.program)];
236
+ aes128CtrCrypt(
237
+ key,
238
+ (region.id === 1 ? h.hmac_arm9 : h.hmac_arm7).subarray(0, 16),
239
+ data.subarray(region.offset, region.offset + region.length),
240
+ ).copy(data, region.offset);
241
+ }
242
+ const accepted = [...new Set(this.regions.map((region) => region.program))].every((name) => {
243
+ const index = programs.findIndex((program) => program.name === name);
244
+ const program = programs[index];
245
+ if (hash(views[index].subarray(0, program.length)).equals(h[fields[name][2]])) return true;
246
+ if (!this.hasDigests) return false;
247
+ const relevant = this.regions.filter((region) => region.program === name);
248
+ return relevant.every((region) => {
249
+ const address = program.start + region.offset;
250
+ const base =
251
+ address >= h.digest_twl_start && address < h.digest_twl_start + h.digest_twl_size
252
+ ? h.digest_twl_start
253
+ : h.digest_ntr_start;
254
+ const length = base === h.digest_twl_start ? h.digest_twl_size : h.digest_ntr_size;
255
+ if (address < base || address >= base + length) return false;
256
+ const first = base + Math.floor((address - base) / h.digest_sector_size) * h.digest_sector_size;
257
+ const last = Math.min(base + length, align(address + region.length, h.digest_sector_size));
258
+ for (let sector = first; sector < last; sector += h.digest_sector_size) {
259
+ const bytes = Buffer.from(range(this.original, sector, h.digest_sector_size, "Modcrypt evidence sector"));
260
+ for (const [p, view] of programs.map((program, i) => [program, views[i]] as const)) {
261
+ const start = Math.max(sector, p.start),
262
+ end = Math.min(sector + bytes.length, p.start + view.length);
263
+ if (end > start) view.copy(bytes, start - sector, start - p.start, end - p.start);
264
+ }
265
+ const digest =
266
+ (((base === h.digest_twl_start ? h.digest_ntr_size : 0) + sector - base) / h.digest_sector_size) * 20;
267
+ if (hash(bytes).equals(range(this.Hashtable1Data, digest, 20, "Modcrypt evidence hash"))) return true;
268
+ }
269
+ return false;
270
+ });
271
+ });
272
+ if (accepted && !solutions.some((solution) => solution.every((data, index) => data.equals(views[index]))))
273
+ solutions.push(views);
274
+ }
275
+ if (solutions.length !== 1)
276
+ throw new Error(
277
+ `Cannot determine Modcrypt state for regions ${this.regions.map((region) => region.id).join(", ")}`,
278
+ );
279
+ programs.forEach((program, index) => {
280
+ program.data = solutions[0][index];
281
+ });
282
+ }
283
+
284
+ get DSi9Data(): Buffer {
285
+ return this.getProgram("arm9i");
286
+ }
287
+ set DSi9Data(data: Buffer) {
288
+ this.setProgram("arm9i", data);
289
+ }
290
+ get DSi7Data(): Buffer {
291
+ return this.getProgram("arm7i");
292
+ }
293
+ set DSi7Data(data: Buffer) {
294
+ this.setProgram("arm7i", data);
295
+ }
296
+ getProgram(name: ProgramName): Buffer {
297
+ const program = this.programs.get(name)!;
298
+ return program.data.subarray(0, program.length);
299
+ }
300
+ setProgram(name: ProgramName, data: Buffer): void {
301
+ if (align(data.length, 16) > this.limit) throw new RangeError(`${name} exceeds output size limit`);
302
+ const program = this.programs.get(name)!;
303
+ const stored = Buffer.alloc(align(data.length, 16), 255);
304
+ if (program.length === data.length) program.data.copy(stored);
305
+ data.copy(stored);
306
+ program.data = stored;
307
+ program.length = data.length;
308
+ program.replaced = true;
309
+ }
310
+ importArm9iData(data: Buffer | string, offset = 0, size?: number): void {
311
+ const input = typeof data === "string" ? readFileSync(data) : data;
312
+ this.setProgram("arm9i", range(input, offset, size ?? input.length - offset, "ARM9i import"));
313
+ }
314
+ importArm7iData(data: Buffer | string, offset = 0, size?: number): void {
315
+ const input = typeof data === "string" ? readFileSync(data) : data;
316
+ this.setProgram("arm7i", range(input, offset, size ?? input.length - offset, "ARM7i import"));
317
+ }
318
+ unchanged(): boolean {
319
+ return [...this.programs.values()].every(
320
+ (program) =>
321
+ !(program.replaced && !program.length) &&
322
+ program.length === this.header[fields[program.name][1]] &&
323
+ program.data.equals(program.original),
324
+ );
325
+ }
326
+
327
+ updateOverlays9Sha1Hmac(arm9: Buffer, h: Arm9Header, overlays: readonly Buffer[]): Buffer {
328
+ if (overlays.length !== this.Overlays9Sha1Hmac.length)
329
+ throw new Error("Changing signed ARM9 overlay count is unsupported");
330
+ const hashes = overlays.map(hash);
331
+ if (hashes.every((value, index) => value.equals(this.Overlays9Sha1Hmac[index]))) return arm9;
332
+ const unpacked = decompressArm9(arm9, h, this.limit);
333
+ const oldSequence = Buffer.concat(this.Overlays9Sha1Hmac);
334
+ const newSequence = Buffer.concat(hashes);
335
+ const matches: { position: number; current: boolean }[] = [];
336
+ for (const [sequence, current] of [
337
+ [oldSequence, false],
338
+ [newSequence, true],
339
+ ] as const)
340
+ for (
341
+ let position = unpacked.data.indexOf(sequence, 0x4000);
342
+ position >= 0;
343
+ position = unpacked.data.indexOf(sequence, position + 1)
344
+ )
345
+ matches.push({ position, current });
346
+ if (matches.length !== 1) throw new Error("ARM9 overlay HMAC table cannot be uniquely located");
347
+ if (matches[0].current) return arm9;
348
+ newSequence.copy(unpacked.data, matches[0].position);
349
+ if (!unpacked.compressed) return unpacked.data;
350
+ const init = h.reserved2.readUInt32LE(0) & 0x3fff;
351
+ const end = init ? arm9.readUInt32LE(init + 0x14) - h.ARM9ramAddress : arm9.length;
352
+ return compressArm9(unpacked.data, h, arm9.length - end);
353
+ }
354
+
355
+ private view(data: Buffer, h: Header, start: number, length: number, programs = this.programs): Buffer {
356
+ const output = Buffer.from(range(data, start, length, "DSi digest input"));
357
+ for (const program of programs.values()) {
358
+ const [offset, size] = fields[program.name];
359
+ const address = h[offset];
360
+ const limit = program.name === "arm9" ? h[size] : align(h[size], 16);
361
+ const from = Math.max(start, address),
362
+ to = Math.min(start + length, address + limit);
363
+ if (to <= from) continue;
364
+ let source = program.data;
365
+ if (program.name === "arm9" && h.decrypted) source = encryptSecureArea(h.gameCode, source, true);
366
+ source.copy(output, from - start, from - address, to - address);
367
+ }
368
+ return output;
369
+ }
370
+
371
+ verify(data = this.original, h = this.header): DsiVerification {
372
+ const programCheck = (name: ProgramName): DsiCheck => {
373
+ const program = this.programs.get(name)!;
374
+ if (!program.length) return absent("Program has zero logical length");
375
+ const input = name === "arm9" && h.decrypted ? encryptSecureArea(h.gameCode, program.data, true) : program.data;
376
+ return check(hash(input.subarray(0, program.length)).equals(h[fields[name][2]]));
377
+ };
378
+ const result: DsiVerification = {
379
+ arm9: programCheck("arm9"),
380
+ arm7: programCheck("arm7"),
381
+ arm9i: programCheck("arm9i"),
382
+ arm7i: programCheck("arm7i"),
383
+ arm9NoSecure:
384
+ h.ARM9size >= 0x4000
385
+ ? check(hash(this.getProgram("arm9").subarray(0x4000)).equals(h.hmac_arm9_no_secure))
386
+ : absent("ARM9 has no complete secure area"),
387
+ banner: check(hash(range(data, h.bannerOffset, h.banner_size, "DSi banner")).equals(h.hmac_icon_title)),
388
+ sectors: absent("Digest tables omitted"),
389
+ blocks: absent("Digest tables omitted"),
390
+ master: absent("Digest tables omitted"),
391
+ signature: h.rsa_signature.every((value) => value === 0)
392
+ ? absent("Signature omitted")
393
+ : check(verifyRsaSha1(range(data, 0, 0xe00, "Signed DSi header"), h.rsa_signature)),
394
+ };
395
+ if (this.hasDigests) {
396
+ let valid = true,
397
+ index = 0;
398
+ for (const [start, size] of [
399
+ [h.digest_ntr_start, h.digest_ntr_size],
400
+ [h.digest_twl_start, h.digest_twl_size],
401
+ ])
402
+ for (let position = start; position < start + size; position += h.digest_sector_size)
403
+ valid =
404
+ hash(this.view(data, h, position, h.digest_sector_size)).equals(
405
+ range(data, h.sector_hashtable_start + index++ * 20, 20, "DSi sector digest"),
406
+ ) && valid;
407
+ result.sectors = check(valid);
408
+ valid = true;
409
+ for (let offset = 0; offset < h.sector_hashtable_size; offset += h.digest_block_sectorcount * 20)
410
+ valid =
411
+ hash(
412
+ range(data, h.sector_hashtable_start + offset, h.digest_block_sectorcount * 20, "DSi sector block"),
413
+ ).equals(
414
+ range(data, h.block_hashtable_start + offset / h.digest_block_sectorcount, 20, "DSi block digest"),
415
+ ) && valid;
416
+ result.blocks = check(valid);
417
+ result.master = check(
418
+ hash(range(data, h.block_hashtable_start, h.block_hashtable_size, "DSi master input")).equals(
419
+ h.hmac_digest_master,
420
+ ),
421
+ );
422
+ }
423
+ return result;
424
+ }
425
+
426
+ serialize(h: Header, sections: DsiSection[], forceLayout: boolean): Buffer {
427
+ const originalHeader = this.header;
428
+ const dsiPrograms = [this.programs.get("arm9i")!, this.programs.get("arm7i")!];
429
+ const originalEnd = Math.max(
430
+ originalHeader.ROMsize,
431
+ originalHeader.total_rom_size,
432
+ originalHeader.dsi9_rom_offset + align(originalHeader.dsi9_size, 16),
433
+ originalHeader.dsi7_rom_offset + align(originalHeader.dsi7_size, 16),
434
+ originalHeader.digest_twl_start + originalHeader.digest_twl_size,
435
+ );
436
+ const ntrLimit = this.hasDigests
437
+ ? originalHeader.digest_ntr_start + originalHeader.digest_ntr_size
438
+ : originalHeader.twl_region_start * 0x80000 ||
439
+ Math.min(...dsiPrograms.filter((program) => program.length).map((program) => program.start));
440
+ const ordered = sections
441
+ .filter((section) => section.originalSize || section.data.length)
442
+ .sort((a, b) => a.start - b.start);
443
+ let preserve =
444
+ !forceLayout &&
445
+ ordered.every((section, index) => {
446
+ const next = ordered[index + 1]?.start ?? ntrLimit;
447
+ if (
448
+ section.start < h.headerSize ||
449
+ section.start + section.originalSize > next ||
450
+ section.start + section.data.length > next
451
+ )
452
+ return false;
453
+ const extra = this.original.subarray(section.start + section.originalSize, section.start + section.data.length);
454
+ return extra.every((value) => value === 0) || extra.every((value) => value === 255);
455
+ });
456
+ const originalDsi = [...dsiPrograms]
457
+ .filter((program) => originalHeader[fields[program.name][1]])
458
+ .sort((a, b) => a.start - b.start);
459
+ for (const [index, program] of originalDsi.entries()) {
460
+ const next = originalDsi[index + 1]?.start ?? originalEnd;
461
+ if (program.start + program.data.length > next) preserve = false;
462
+ const extra = this.original.subarray(
463
+ program.start + align(originalHeader[fields[program.name][1]], 16),
464
+ program.start + program.data.length,
465
+ );
466
+ if (!(extra.every((value) => value === 0) || extra.every((value) => value === 255))) preserve = false;
467
+ }
468
+ if (dsiPrograms.some((program) => program.length && !originalHeader[fields[program.name][1]])) preserve = false;
469
+ let ntrEnd: number;
470
+ let end: number;
471
+ let boundary = originalHeader.twl_region_start * 0x80000;
472
+ if (preserve) {
473
+ for (const section of sections) section.place(section.start);
474
+ ntrEnd = originalHeader.ROMsize;
475
+ end = originalEnd;
476
+ } else {
477
+ this.checkMovableTwl(boundary, originalDsi, originalEnd);
478
+ let position = Math.max(h.headerSize, 0x4000);
479
+ for (const section of sections) {
480
+ section.start = position;
481
+ section.place(position);
482
+ position = align(position + section.data.length, 0x200);
483
+ }
484
+ const ntrDataEnd = position;
485
+ if (this.hasDigests) {
486
+ const s = h.digest_sector_size;
487
+ h.digest_ntr_start = h.ARM9romOffset;
488
+ h.digest_ntr_size = align(ntrDataEnd - h.digest_ntr_start, s);
489
+ h.sector_hashtable_start = align(h.digest_ntr_start + h.digest_ntr_size, 0x200);
490
+ }
491
+ let twlLength = 0;
492
+ for (const program of dsiPrograms) if (program.length) twlLength = align(twlLength, 0x400) + program.data.length;
493
+ if (this.hasDigests) {
494
+ h.digest_twl_size = twlLength ? align(twlLength, h.digest_sector_size) : 0;
495
+ const count = (h.digest_ntr_size + h.digest_twl_size) / h.digest_sector_size;
496
+ h.sector_hashtable_size = align(count * 20, h.digest_block_sectorcount * 20);
497
+ h.block_hashtable_start = align(h.sector_hashtable_start + h.sector_hashtable_size, 0x200);
498
+ h.block_hashtable_size = h.sector_hashtable_size / h.digest_block_sectorcount;
499
+ ntrEnd = h.block_hashtable_start + h.block_hashtable_size;
500
+ } else ntrEnd = ntrDataEnd;
501
+ const originalBoundary = originalHeader.twl_region_start * 0x80000;
502
+ const boundaryAlignment = originalBoundary ? Math.max(0x80000, originalBoundary & -originalBoundary) : 0x80000;
503
+ boundary = align(ntrEnd, boundaryAlignment);
504
+ position = boundary + 0x3000;
505
+ let first = 0;
506
+ for (const program of dsiPrograms) {
507
+ const [offset] = fields[program.name];
508
+ if (!program.length) {
509
+ h[offset] = 0;
510
+ continue;
511
+ }
512
+ position = align(position, 0x400);
513
+ h[offset] = position;
514
+ first ||= position;
515
+ position += program.data.length;
516
+ }
517
+ if (this.hasDigests) h.digest_twl_start = first;
518
+ end = this.hasDigests && first ? first + h.digest_twl_size : position;
519
+ }
520
+ h.size = Math.max(originalHeader.size, 2 ** Math.ceil(Math.log2(Math.max(end, 0x20000))));
521
+ if (h.size > this.limit) throw new RangeError("Output exceeds size limit");
522
+ const writer = new BinaryWriter(h.size, this.limit);
523
+ writer.fillTo(h.size);
524
+ if (preserve) this.original.copy(writer.buffer);
525
+ else {
526
+ range(this.original, 0, Math.max(h.headerSize, 0x4000), "DSi original header area").copy(writer.buffer);
527
+ if (this.hasDigests) {
528
+ const originalGap = this.original.subarray(
529
+ originalHeader.sector_hashtable_start + originalHeader.sector_hashtable_size,
530
+ originalHeader.block_hashtable_start,
531
+ );
532
+ if (originalGap.length && originalGap.every((value) => value === 0))
533
+ writer.buffer.fill(0, h.sector_hashtable_start + h.sector_hashtable_size, h.block_hashtable_start);
534
+ }
535
+ }
536
+ // FAT contents depend on the final placement of every file.
537
+ for (const section of sections) {
538
+ writer.position = section.start;
539
+ writer.write(section.data);
540
+ if (preserve && section.data.length < section.originalSize) {
541
+ writer.fillTo(section.start + section.originalSize);
542
+ }
543
+ }
544
+ for (const program of dsiPrograms) {
545
+ const [offset, size, mac] = fields[program.name];
546
+ h[size] = program.length;
547
+ if (!program.length && program.replaced) {
548
+ h[offset] = 0;
549
+ h[mac] = Buffer.alloc(20);
550
+ if (preserve && originalHeader[size]) {
551
+ writer.position = originalHeader[offset];
552
+ writer.fillTo(originalHeader[offset] + align(originalHeader[size], 16));
553
+ }
554
+ }
555
+ if (program.length) {
556
+ writer.position = h[offset];
557
+ writer.write(program.data);
558
+ const oldSize = originalHeader[size];
559
+ if (preserve && align(oldSize, 16) > program.data.length) writer.fillTo(h[offset] + align(oldSize, 16));
560
+ }
561
+ }
562
+ const outputPrograms = new Map([...this.programs].map(([name, program]) => [name, { ...program }]));
563
+ for (const name of ["arm9", "arm7"] as const) {
564
+ const program = outputPrograms.get(name)!;
565
+ const [offset, size] = fields[name];
566
+ program.data = Buffer.from(range(writer.buffer, h[offset], h[size], name));
567
+ program.length = h[size];
568
+ }
569
+ for (const program of outputPrograms.values()) {
570
+ if (!program.length) continue;
571
+ const data =
572
+ program.name === "arm9" && h.decrypted ? encryptSecureArea(h.gameCode, program.data, true) : program.data;
573
+ h[fields[program.name][2]] = hash(data.subarray(0, program.length));
574
+ }
575
+ h.hmac_arm9_no_secure = hash(outputPrograms.get("arm9")!.data.subarray(0x4000));
576
+ h.hmac_icon_title = hash(range(writer.buffer, h.bannerOffset, h.banner_size, "DSi output banner"));
577
+ if (this.hasDigests) {
578
+ range(writer.buffer, h.sector_hashtable_start, h.sector_hashtable_size, "DSi output sector table").fill(0);
579
+ range(writer.buffer, h.block_hashtable_start, h.block_hashtable_size, "DSi output block table").fill(0);
580
+ let index = 0;
581
+ for (const [start, size] of [
582
+ [h.digest_ntr_start, h.digest_ntr_size],
583
+ [h.digest_twl_start, h.digest_twl_size],
584
+ ])
585
+ for (let position = start; position < start + size; position += h.digest_sector_size)
586
+ hash(this.view(writer.buffer, h, position, h.digest_sector_size, outputPrograms)).copy(
587
+ writer.buffer,
588
+ h.sector_hashtable_start + index++ * 20,
589
+ );
590
+ for (let offset = 0; offset < h.sector_hashtable_size; offset += h.digest_block_sectorcount * 20)
591
+ hash(
592
+ range(
593
+ writer.buffer,
594
+ h.sector_hashtable_start + offset,
595
+ h.digest_block_sectorcount * 20,
596
+ "DSi output sector block",
597
+ ),
598
+ ).copy(writer.buffer, h.block_hashtable_start + offset / h.digest_block_sectorcount);
599
+ h.hmac_digest_master = hash(
600
+ range(writer.buffer, h.block_hashtable_start, h.block_hashtable_size, "DSi output master input"),
601
+ );
602
+ }
603
+ const key = this.regions.length ? modcryptKey(h) : Buffer.alloc(0);
604
+ for (const region of this.regions) {
605
+ const program = this.programs.get(region.program)!;
606
+ const size = !program.length
607
+ ? 0
608
+ : region.kind === "whole"
609
+ ? program.data.length
610
+ : region.kind === "secure"
611
+ ? Math.min(0x4000, program.data.length)
612
+ : region.length;
613
+ if (size && region.offset + size > program.data.length)
614
+ throw new Error(`Modcrypt region ${region.id} exceeds ${program.name} after replacement`);
615
+ const start = size ? h[fields[program.name][0]] + region.offset : 0;
616
+ h[`modcrypt${region.id}_start`] = start;
617
+ h[`modcrypt${region.id}_size`] = size;
618
+ if (size && h.twlInternalFlags & 2) {
619
+ const input = range(writer.buffer, start, size, `Output Modcrypt region ${region.id}`);
620
+ aes128CtrCrypt(key, (region.id === 1 ? h.hmac_arm9 : h.hmac_arm7).subarray(0, 16), input).copy(
621
+ writer.buffer,
622
+ start,
623
+ );
624
+ }
625
+ }
626
+ h.ROMsize = ntrEnd;
627
+ h.total_rom_size = end;
628
+ h.ntr_region_end = boundary / 0x80000;
629
+ h.twl_region_start = boundary / 0x80000;
630
+ h.size = Math.max(originalHeader.size, 2 ** Math.ceil(Math.log2(Math.max(end, 0x20000))));
631
+ const secure = range(writer.buffer, h.ARM9romOffset, 0x4000, "DSi secure area");
632
+ h.secureCRC16 = crc16(h.decrypted ? encryptSecureArea(h.gameCode, secure, true) : secure);
633
+ h.rsa_signature = originalHeader.rsa_signature;
634
+ writer.position = 0;
635
+ writer.write(h.toBuffer());
636
+ const originalMirror = range(this.original, 0x8000, 0x1000, "Original cartridge mirror");
637
+ for (let start = 0x1000; start < 0x4000; start += 0x1000)
638
+ if (range(this.original, start, 0x1000, "NTR unreadable region").equals(originalMirror))
639
+ range(writer.buffer, 0x8000, 0x1000, "Updated cartridge mirror").copy(writer.buffer, start);
640
+ if (this.Header2Data.length) {
641
+ for (const [index, data] of this.Header2Data.entries()) {
642
+ writer.position = boundary + index * 0x1000;
643
+ writer.write(
644
+ data.equals(originalMirror) ? range(writer.buffer, 0x8000, 0x1000, "Updated cartridge mirror") : data,
645
+ );
646
+ }
647
+ }
648
+ return writer.toBuffer();
649
+ }
650
+
651
+ private checkMovableTwl(boundary: number, programs: Program[], end: number): void {
652
+ let position = boundary + this.Header2Data.length * 0x1000;
653
+ for (const program of programs) {
654
+ if (program.start < position) throw new Error("TWL input regions overlap");
655
+ const gap = range(this.original, position, program.start - position, "TWL preserved bytes");
656
+ const padding = gap;
657
+ if (padding.length && !(padding.every((value) => value === 0) || padding.every((value) => value === 255)))
658
+ throw new Error("TWL contains unrecognized bytes whose relocation is unsupported");
659
+ position = program.start + align(this.header[fields[program.name][1]], 16);
660
+ }
661
+ const tail = range(this.original, position, end - position, "TWL preserved tail");
662
+ if (tail.length && !(tail.every((value) => value === 0) || tail.every((value) => value === 255)))
663
+ throw new Error("TWL contains unrecognized trailing data whose relocation is unsupported");
664
+ }
665
+ }