nitro-patcher 0.1.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.
Files changed (44) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +22 -0
  3. package/dist/browser.d.ts +34 -0
  4. package/dist/browser.js +4616 -0
  5. package/dist/browser.js.LEGAL.txt +147 -0
  6. package/dist/browser.js.map +7 -0
  7. package/dist/chunk-ANN47R34.js +1 -0
  8. package/dist/chunk-ANN47R34.js.map +1 -0
  9. package/dist/chunk-GFF67HX4.js +1181 -0
  10. package/dist/chunk-GFF67HX4.js.map +1 -0
  11. package/dist/chunk-L7ZM4LLP.js +396 -0
  12. package/dist/chunk-L7ZM4LLP.js.map +1 -0
  13. package/dist/cli.d.ts +1 -0
  14. package/dist/cli.js +76 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.js +56 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/nitro-helper.d.ts +315 -0
  20. package/dist/nitro-helper.js +44 -0
  21. package/dist/nitro-helper.js.map +1 -0
  22. package/dist/nitro-patch-helper.d.ts +46 -0
  23. package/dist/nitro-patch-helper.js +16 -0
  24. package/dist/nitro-patch-helper.js.map +1 -0
  25. package/package.json +86 -0
  26. package/src/browser/buffer.ts +1 -0
  27. package/src/browser.ts +60 -0
  28. package/src/cli.ts +76 -0
  29. package/src/index.ts +2 -0
  30. package/src/nitro/banner.ts +77 -0
  31. package/src/nitro/binary.ts +80 -0
  32. package/src/nitro/blz.ts +163 -0
  33. package/src/nitro/crc.ts +23 -0
  34. package/src/nitro/crypto.ts +178 -0
  35. package/src/nitro/filesystem.ts +183 -0
  36. package/src/nitro/header.ts +254 -0
  37. package/src/nitro/nds-file.ts +285 -0
  38. package/src/nitro/twl.ts +338 -0
  39. package/src/nitro-helper.ts +14 -0
  40. package/src/nitro-patch-helper.ts +95 -0
  41. package/src/patch/xdelta.ts +214 -0
  42. package/src/patch/zip.ts +206 -0
  43. package/src/runtime/browser.ts +40 -0
  44. package/src/runtime/node.ts +28 -0
@@ -0,0 +1,285 @@
1
+ // Native TypeScript port of NitroHelper/NDSFile.cs, GPL-3.0.
2
+ import { readFile, writeFile } from '#nitro-runtime';
3
+ import { BinaryWriter, bytes, range, DEFAULT_MAX_SIZE } from './binary';
4
+ import { Header } from './header';
5
+ import { Banner } from './banner';
6
+ import { FileAllocationTable, FileNameTable, OverlayTable } from './filesystem';
7
+ import type { NitroFile, NitroFolder } from './filesystem';
8
+ import { crc16 } from './crc';
9
+ import { encryptSecureArea } from './crypto';
10
+ import { TWL } from './twl';
11
+
12
+ export interface RomOptions {
13
+ /** Maximum generated ROM size in bytes. Defaults to 1 GiB. */
14
+ maxOutputSize?: number;
15
+ }
16
+
17
+ export interface RomFileInfo {
18
+ /** ROM-relative path, including data/, overlay/, or a system filename. */
19
+ path: string;
20
+ id: number;
21
+ offset: number;
22
+ size: number;
23
+ }
24
+
25
+ const key = (path: string): string => path.replaceAll('\\', '/').toLowerCase();
26
+
27
+ /** Parsed Nintendo DS ROM. Replacements do not change the input buffer. */
28
+ export class NDSFile {
29
+ readonly original: Buffer;
30
+ readonly header: Header;
31
+ readonly banner: Banner;
32
+ readonly fatTable: FileAllocationTable;
33
+ readonly fntTable: FileNameTable;
34
+ readonly overlay9Table: OverlayTable;
35
+ readonly overlay7Table: OverlayTable;
36
+ readonly root: NitroFolder;
37
+ readonly twl: TWL | undefined;
38
+ readonly warnings: string[] = [];
39
+ private readonly paths = new Map<string, { path: string; file: NitroFile }>();
40
+ private readonly maxOutputSize: number;
41
+
42
+ constructor(input: Uint8Array, options: RomOptions = {}) {
43
+ this.original = bytes(input, 'ROM');
44
+ this.maxOutputSize = options.maxOutputSize ?? DEFAULT_MAX_SIZE;
45
+ this.header = new Header(this.original);
46
+ const h = this.header;
47
+ range(this.original, h.ARM9romOffset, h.ARM9size + (h.nitrocode ? 12 : 0), 'ARM9');
48
+ range(this.original, h.ARM7romOffset, h.ARM7size, 'ARM7');
49
+ this.banner = new Banner(this.original, h.bannerOffset, h.banner_size);
50
+ this.fatTable = new FileAllocationTable(this.original, h.FAToffset, h.FATsize);
51
+ this.fntTable = new FileNameTable(this.fatTable, this.original, h.FNToffset, h.FNTsize);
52
+ this.root = this.fntTable.root;
53
+ this.overlay9Table = new OverlayTable(
54
+ this.original,
55
+ h.ARM9overlayOffset,
56
+ h.ARM9overlaySize,
57
+ true,
58
+ );
59
+ this.overlay7Table = new OverlayTable(
60
+ this.original,
61
+ h.ARM7overlayOffset,
62
+ h.ARM7overlaySize,
63
+ false,
64
+ );
65
+ const arm9Overlays = this.overlay9Table.readBasicOverlays(this.fatTable);
66
+ this.root.folders.push({
67
+ name: 'overlay',
68
+ id: 0xffff,
69
+ files: [...arm9Overlays, ...this.overlay7Table.readBasicOverlays(this.fatTable)],
70
+ folders: [],
71
+ });
72
+ const system = (name: string, offset: number, size: number): void => {
73
+ range(this.original, offset, size, name);
74
+ this.root.files.push({ name, id: 0xffff, offset, size });
75
+ };
76
+ system('header.bin', 0, h.headerSize);
77
+ system('banner.bin', h.bannerOffset, this.banner.raw.length);
78
+ system('fnt.bin', h.FNToffset, h.FNTsize);
79
+ system('fat.bin', h.FAToffset, h.FATsize);
80
+ system('arm9.bin', h.ARM9romOffset, h.ARM9size + (h.nitrocode ? 12 : 0));
81
+ system('arm7.bin', h.ARM7romOffset, h.ARM7size);
82
+ if (h.ARM9overlaySize) system('overarm9.bin', h.ARM9overlayOffset, h.ARM9overlaySize);
83
+ if (h.ARM7overlaySize) system('overarm7.bin', h.ARM7overlayOffset, h.ARM7overlaySize);
84
+ const visit = (folder: NitroFolder, prefix: string): void => {
85
+ for (const file of folder.files) {
86
+ const path = prefix + file.name;
87
+ if (this.paths.has(key(path))) throw new Error(`Duplicate ROM path: ${path}`);
88
+ this.paths.set(key(path), { path, file });
89
+ }
90
+ for (const child of folder.folders) visit(child, prefix + child.name + '/');
91
+ };
92
+ visit(this.root, '');
93
+ if (h.unitCode & 2 && h.twlInternalFlags & 1 && h.tid_high && h.tid_high !== 0xffffffff) {
94
+ // Upstream silently discarded malformed TWL regions. Preserve valid DSi data,
95
+ // and reject malformed data instead of emitting a ROM with missing sections.
96
+ this.twl = new TWL(h, arm9Overlays, this.original);
97
+ }
98
+ }
99
+
100
+ static async fromFile(path: string, options: RomOptions = {}): Promise<NDSFile> {
101
+ return new NDSFile(await readFile(path), options);
102
+ }
103
+ get data(): NitroFolder {
104
+ return this.root.folders[0];
105
+ }
106
+ get overlay(): NitroFolder {
107
+ return this.root.folders[1];
108
+ }
109
+
110
+ listFiles(): RomFileInfo[] {
111
+ return [...this.paths.values()].map(({ path, file }) => ({
112
+ path,
113
+ id: file.id,
114
+ offset: file.offset,
115
+ size: file.replacement?.length ?? file.size,
116
+ }));
117
+ }
118
+ hasFile(path: string): boolean {
119
+ return this.paths.has(key(path));
120
+ }
121
+ getFile(path: string): Buffer {
122
+ const entry = this.paths.get(key(path));
123
+ if (!entry) throw new Error(`ROM file not found: ${path}`);
124
+ return Buffer.from(this.fileBytes(entry.file));
125
+ }
126
+ /** Replace an existing file. Adding or deleting filesystem entries is not supported. */
127
+ replaceFile(path: string, content: Uint8Array): void {
128
+ const entry = this.paths.get(key(path));
129
+ if (!entry) throw new Error(`ROM file not found: ${path}`);
130
+ entry.file.replacement = Buffer.from(bytes(content));
131
+ }
132
+ private fileBytes(file: NitroFile): Buffer {
133
+ return file.replacement ?? range(this.original, file.offset, file.size, file.name);
134
+ }
135
+
136
+ /** Rebuild offsets, allocation table, CRCs and ROM padding. Safe to call repeatedly. */
137
+ toBuffer(): Buffer {
138
+ const fntReplacement = this.root.files.find((file) => file.name === 'fnt.bin')?.replacement;
139
+ if (fntReplacement) {
140
+ const replacementTree = new FileNameTable(
141
+ this.fatTable,
142
+ fntReplacement,
143
+ 0,
144
+ fntReplacement.length,
145
+ );
146
+ const collect = (folder: NitroFolder): number[] =>
147
+ [...folder.files.map((file) => file.id), ...folder.folders.flatMap(collect)].sort(
148
+ (a, b) => a - b,
149
+ );
150
+ if (collect(replacementTree.root).join(',') !== collect(this.data).join(','))
151
+ throw new Error('Replacement FNT must preserve existing file IDs');
152
+ }
153
+ const h = new Header(this.header.toBuffer());
154
+ h.nitrocode = this.header.nitrocode;
155
+ h.decrypted = this.header.decrypted;
156
+ h.trimmedRom = this.header.trimmedRom;
157
+ h.dlp_signature = this.header.dlp_signature;
158
+ const writer = new BinaryWriter(65536, this.maxOutputSize);
159
+ const system = (name: string): NitroFile => {
160
+ const file = this.root.files.find((file) => file.name === name);
161
+ if (!file) throw new Error(`Missing ROM system file: ${name}`);
162
+ return file;
163
+ };
164
+ const replacedHeader = system('header.bin').replacement;
165
+ if (replacedHeader) {
166
+ const replacement = new Header(replacedHeader);
167
+ h.gameTitle = replacement.gameTitle;
168
+ h.gameCode = replacement.gameCode;
169
+ }
170
+ const placements = new Map<number, { start: number; end: number }>();
171
+ const write = (file: NitroFile, padding = true): void => {
172
+ const start = writer.position;
173
+ writer.write(this.fileBytes(file));
174
+ if (file.id !== 0xffff) placements.set(file.id, { start, end: writer.position });
175
+ if (padding) writer.pad(0x200);
176
+ };
177
+ const overlayIds = new Set<number>();
178
+ const writeOverlays = (arm9: boolean): void => {
179
+ const originalTable = arm9 ? this.overlay9Table : this.overlay7Table;
180
+ const name = arm9 ? 'overarm9.bin' : 'overarm7.bin';
181
+ const entry = this.root.files.find((file) => file.name === name);
182
+ if (!entry) return;
183
+ const raw = entry.replacement ?? originalTable.toBuffer();
184
+ const table = new OverlayTable(raw, 0, raw.length, arm9);
185
+ const files = this.overlay.files
186
+ .filter((file) => file.name.startsWith(arm9 ? 'overlay_' : 'overlay7_'))
187
+ .sort((a, b) => a.id - b.id);
188
+ for (const file of files) {
189
+ const item = table.entries.find((item) => item.fileId === file.id);
190
+ if (!item) throw new Error(`Replacement overlay table is missing file id ${file.id}`);
191
+ const size = this.fileBytes(file).length;
192
+ if ((item.reserved >>> 24) & 1 || (!arm9 && item.reserved > 0))
193
+ item.reserved = ((item.reserved & 0xff000000) | (size & 0xffffff)) >>> 0;
194
+ overlayIds.add(file.id);
195
+ }
196
+ if (table.entries.length !== files.length)
197
+ throw new Error('Changing overlay file count is unsupported');
198
+ const encoded = table.toBuffer();
199
+ if (arm9) {
200
+ h.ARM9overlayOffset = writer.position;
201
+ h.ARM9overlaySize = encoded.length;
202
+ } else {
203
+ h.ARM7overlayOffset = writer.position;
204
+ h.ARM7overlaySize = encoded.length;
205
+ }
206
+ writer.write(encoded);
207
+ writer.pad(0x200);
208
+ for (const file of files) write(file);
209
+ };
210
+ writer.position = h.headerSize;
211
+ h.ARM9romOffset = writer.position;
212
+ const arm9 = system('arm9.bin');
213
+ const arm9Bytes = this.fileBytes(arm9);
214
+ if (arm9.replacement) {
215
+ h.nitrocode =
216
+ arm9Bytes.length >= 12 && arm9Bytes.readUInt32LE(arm9Bytes.length - 12) === 0xdec00621;
217
+ }
218
+ h.ARM9size = arm9Bytes.length - (h.nitrocode ? 12 : 0);
219
+ write(arm9);
220
+ h.ARM9overlayOffset = 0;
221
+ writeOverlays(true);
222
+ h.ARM7romOffset = writer.position;
223
+ h.ARM7size = this.fileBytes(system('arm7.bin')).length;
224
+ write(system('arm7.bin'));
225
+ h.ARM7overlayOffset = 0;
226
+ writeOverlays(false);
227
+ h.FNToffset = writer.position;
228
+ h.FNTsize = this.fileBytes(system('fnt.bin')).length;
229
+ write(system('fnt.bin'));
230
+ h.FAToffset = writer.position;
231
+ h.FATsize = this.fatTable.entries.length * 8;
232
+ writer.write(Buffer.alloc(h.FATsize));
233
+ writer.pad(0x200);
234
+ h.bannerOffset = writer.position;
235
+ const bannerFile = system('banner.bin');
236
+ const banner = bannerFile.replacement
237
+ ? new Banner(bannerFile.replacement, 0, bannerFile.replacement.length)
238
+ : this.banner;
239
+ const bannerBytes = banner.toBuffer();
240
+ // NTR has no banner_size field; DSi stores its actual banner length.
241
+ if (h.unitCode & 2 && h.headerSize > 0x200) h.banner_size = bannerBytes.length;
242
+ writer.write(bannerBytes);
243
+ writer.pad(0x200);
244
+ for (const [index, id] of this.fatTable.sortedIDs.entries()) {
245
+ if (overlayIds.has(id)) continue;
246
+ const file = FileNameTable.findFile(id, this.root);
247
+ if (file) write(file, index < this.fatTable.sortedIDs.length - 1);
248
+ else {
249
+ // Games can address FAT entries directly without a NitroFS filename.
250
+ // Keep their payload and allocation instead of silently zeroing the entry.
251
+ const entry = this.fatTable.entries[id];
252
+ if (entry.size > 0) {
253
+ write({ ...entry, name: `FAT entry ${id}` }, index < this.fatTable.sortedIDs.length - 1);
254
+ }
255
+ }
256
+ }
257
+ const logicalEnd = writer.position;
258
+ const fat = Buffer.alloc(h.FATsize);
259
+ for (const [id, location] of placements) {
260
+ fat.writeUInt32LE(location.start, id * 8);
261
+ fat.writeUInt32LE(location.end, id * 8 + 4);
262
+ }
263
+ writer.position = h.FAToffset;
264
+ writer.write(fat);
265
+ writer.position = logicalEnd;
266
+ // An ARM9 replacement can add or remove the decrypted secure-area marker.
267
+ // Both TWL hashing and the header CRC must use the bytes being serialized.
268
+ h.decrypted =
269
+ writer.length >= 0x4008 && writer.buffer.readBigUInt64LE(0x4000) === 0xe7ffdeffe7ffdeffn;
270
+ if (this.twl) this.twl.writeTo(writer, h);
271
+ h.ROMsize = writer.position;
272
+ h.size = 2 ** Math.ceil(Math.log2(Math.max(h.ROMsize + h.dlp_signature.length, 0x20000)));
273
+ const secure = writer.toBuffer().subarray(0x4000, 0x8000);
274
+ h.secureCRC16 = crc16(h.decrypted ? encryptSecureArea(h.gameCode, secure) : secure);
275
+ writer.position = 0;
276
+ writer.write(h.toBuffer());
277
+ writer.position = h.ROMsize;
278
+ writer.write(h.dlp_signature);
279
+ writer.fillTo(h.size);
280
+ return writer.toBuffer();
281
+ }
282
+ async saveAs(path: string): Promise<void> {
283
+ await writeFile(path, this.toBuffer());
284
+ }
285
+ }
@@ -0,0 +1,338 @@
1
+ import { hashDigest, hmacSha1, verifyRsaSha1, readFileSync } from '#nitro-runtime';
2
+ import type { BinaryWriter } from './binary';
3
+ import type { ModcryptHeader } from './crypto';
4
+ import type { Arm9Header } from './blz';
5
+ import { aes128CtrCrypt, encryptSecureArea, modcryptKey } from './crypto';
6
+ import { compressArm9, decompressArm9 } from './blz';
7
+
8
+ export interface TwlHeader extends ModcryptHeader {
9
+ digest_sector_size: number;
10
+ digest_block_sectorcount: number;
11
+ digest_ntr_start: number;
12
+ digest_ntr_size: number;
13
+ digest_twl_start: number;
14
+ digest_twl_size: number;
15
+ sector_hashtable_start: number;
16
+ sector_hashtable_size: number;
17
+ block_hashtable_start: number;
18
+ block_hashtable_size: number;
19
+ dsi9_rom_offset: number;
20
+ dsi9_size: number;
21
+ dsi7_rom_offset: number;
22
+ dsi7_size: number;
23
+ modcrypt1_start: number;
24
+ modcrypt1_size: number;
25
+ modcrypt2_start: number;
26
+ modcrypt2_size: number;
27
+ total_rom_size: number;
28
+ trimmedRom: boolean;
29
+ decrypted: boolean;
30
+ hmac_arm9: Buffer;
31
+ hmac_arm7: Buffer;
32
+ hmac_digest_master: Buffer;
33
+ }
34
+
35
+ export interface SignedTwlHeader {
36
+ hmac_digest_master: Buffer;
37
+ rsa_signature: Buffer;
38
+ }
39
+
40
+ export interface OverlayRange {
41
+ offset: number;
42
+ size: number;
43
+ }
44
+
45
+ export const HMAC_SHA1_KEY = Buffer.from(
46
+ '2106c0deba98ce3fa692e39d46f2ed0176e3cc08562363facad4ecdf9a6278348f6d633cfe22ca9220889723d2cfaec232678dfeca836498acfd3e3787465824',
47
+ 'hex',
48
+ );
49
+ const hash = (data: Buffer): Buffer => hmacSha1(HMAC_SHA1_KEY, data);
50
+ function slice(data: Buffer, start: number, size: number, label: string): Buffer {
51
+ if (
52
+ !Number.isSafeInteger(start) ||
53
+ !Number.isSafeInteger(size) ||
54
+ start < 0 ||
55
+ size < 0 ||
56
+ start + size > data.length
57
+ )
58
+ throw new RangeError(`${label} outside data range`);
59
+ return data.subarray(start, start + size);
60
+ }
61
+ function validate(h: TwlHeader): void {
62
+ for (const field of [
63
+ 'digest_ntr_start',
64
+ 'digest_ntr_size',
65
+ 'digest_twl_start',
66
+ 'digest_twl_size',
67
+ 'sector_hashtable_start',
68
+ 'sector_hashtable_size',
69
+ 'block_hashtable_start',
70
+ 'block_hashtable_size',
71
+ 'dsi9_rom_offset',
72
+ 'dsi9_size',
73
+ 'dsi7_rom_offset',
74
+ 'dsi7_size',
75
+ 'modcrypt1_start',
76
+ 'modcrypt1_size',
77
+ 'modcrypt2_start',
78
+ 'modcrypt2_size',
79
+ 'total_rom_size',
80
+ ] as const) {
81
+ if (!Number.isSafeInteger(h[field]) || h[field] < 0 || h[field] > 0xffffffff) {
82
+ throw new Error(`Invalid TWL ${field}`);
83
+ }
84
+ }
85
+ const s = h.digest_sector_size;
86
+ if (!Number.isSafeInteger(s) || s <= 0 || s > 0x4000 || 0x4000 % s)
87
+ throw new Error('Invalid TWL digest sector size');
88
+ if (
89
+ !Number.isSafeInteger(h.digest_block_sectorcount) ||
90
+ h.digest_block_sectorcount <= 0 ||
91
+ h.digest_block_sectorcount > 0x100000
92
+ )
93
+ throw new Error('Invalid TWL digest block sector count');
94
+ if (h.digest_ntr_size < 0x4000 || h.digest_ntr_size % s || h.digest_twl_size % s)
95
+ throw new Error('Invalid TWL digest region size');
96
+ const hashes = (h.digest_ntr_size + h.digest_twl_size) / s;
97
+ if (
98
+ hashes * 20 > h.sector_hashtable_size ||
99
+ Math.ceil(h.sector_hashtable_size / (h.digest_block_sectorcount * 20)) * 20 >
100
+ h.block_hashtable_size
101
+ )
102
+ throw new Error('TWL hash tables are too small');
103
+ }
104
+ export class TWL {
105
+ readonly firstDSiHashOffset: number;
106
+ readonly dsiHashSize: number;
107
+ readonly Hashtable1Data: Buffer;
108
+ readonly Hashtable2Data: Buffer;
109
+ readonly Header2Data: Buffer[] | null;
110
+ readonly Overlays9Sha1Hmac: Buffer[];
111
+ readonly twlEncrypted: boolean;
112
+ DSi9Data: Buffer;
113
+ DSi7Data: Buffer;
114
+ constructor(header: TwlHeader, overlays: readonly OverlayRange[], original: Buffer) {
115
+ validate(header);
116
+ const h = header;
117
+ this.firstDSiHashOffset = (h.digest_ntr_size / h.digest_sector_size) * 20;
118
+ this.dsiHashSize = (h.digest_twl_size / h.digest_sector_size) * 20;
119
+ this.Hashtable1Data = Buffer.from(
120
+ slice(original, h.sector_hashtable_start, h.sector_hashtable_size, 'Sector hash table'),
121
+ );
122
+ this.Hashtable2Data = Buffer.from(
123
+ slice(original, h.block_hashtable_start, h.block_hashtable_size, 'Block hash table'),
124
+ );
125
+ this.DSi9Data = Buffer.from(
126
+ slice(original, h.dsi9_rom_offset, Math.max(h.modcrypt1_size, h.dsi9_size), 'ARM9i'),
127
+ );
128
+ this.DSi7Data = Buffer.from(
129
+ slice(original, h.dsi7_rom_offset, Math.max(h.modcrypt2_size, h.dsi7_size), 'ARM7i'),
130
+ );
131
+ this.Header2Data = h.trimmedRom
132
+ ? null
133
+ : Array.from({ length: 3 }, (_, i) =>
134
+ Buffer.from(
135
+ slice(original, h.digest_twl_start - 0x3000 + 0x1000 * i, 0x1000, 'Secondary header'),
136
+ ),
137
+ );
138
+ this.Overlays9Sha1Hmac = overlays.map((o) =>
139
+ hash(slice(original, o.offset, o.size, 'ARM9 overlay')),
140
+ );
141
+ let encrypted = false;
142
+ const modcryptStart = [
143
+ { start: h.modcrypt1_start, size: h.modcrypt1_size },
144
+ { start: h.modcrypt2_start, size: h.modcrypt2_size },
145
+ ].find(
146
+ ({ start, size }) => size > 0 && start >= h.digest_twl_start && start < 0xffffffff,
147
+ )?.start;
148
+ if (modcryptStart !== undefined) {
149
+ const index = Math.floor((modcryptStart - h.digest_twl_start) / h.digest_sector_size);
150
+ const actual = hash(
151
+ slice(
152
+ original,
153
+ h.digest_twl_start + index * h.digest_sector_size,
154
+ h.digest_sector_size,
155
+ 'Modcrypt sector',
156
+ ),
157
+ );
158
+ encrypted = !actual.equals(
159
+ slice(
160
+ this.Hashtable1Data,
161
+ this.firstDSiHashOffset + index * 20,
162
+ 20,
163
+ 'Modcrypt sector hash',
164
+ ),
165
+ );
166
+ }
167
+ if (encrypted) this.cryptSections(h);
168
+ this.twlEncrypted = !!(h.twlInternalFlags & 2);
169
+ }
170
+ private cryptSections(h: TwlHeader, writer?: BinaryWriter): void {
171
+ const key = modcryptKey(h);
172
+ for (const [id, data, start, counter] of [
173
+ [1, this.DSi9Data, h.dsi9_rom_offset, h.hmac_arm9],
174
+ [2, this.DSi7Data, h.dsi7_rom_offset, h.hmac_arm7],
175
+ ] as const) {
176
+ const size = h[`modcrypt${id}_size`];
177
+ if (!size) continue;
178
+ const off = h[`modcrypt${id}_start`] - start;
179
+ const result = aes128CtrCrypt(
180
+ key,
181
+ slice(counter, 0, 16, 'Modcrypt counter'),
182
+ slice(data, off, size, 'Modcrypt payload'),
183
+ );
184
+ if (writer) {
185
+ writer.position = start + off;
186
+ writer.write(result);
187
+ } else result.copy(data, off);
188
+ }
189
+ }
190
+ importArm9iData(data: Buffer | string, offset = 0, size?: number): void {
191
+ this.DSi9Data = this.importData(data, offset, size);
192
+ }
193
+ importArm7iData(data: Buffer | string, offset = 0, size?: number): void {
194
+ this.DSi7Data = this.importData(data, offset, size);
195
+ }
196
+ private importData(data: Buffer | string, offset: number, size?: number): Buffer {
197
+ if (typeof data === 'string') data = readFileSync(data);
198
+ size ??= data.length - offset;
199
+ const src = slice(data, offset, size, 'DSi import');
200
+ const out = Buffer.alloc(Math.ceil(size / 16) * 16, 255);
201
+ src.copy(out);
202
+ return out;
203
+ }
204
+ updateOverlays9Sha1Hmac(
205
+ arm9Data: Buffer,
206
+ header: Arm9Header,
207
+ overlays: readonly Buffer[],
208
+ ): Buffer {
209
+ if (overlays.length !== this.Overlays9Sha1Hmac.length)
210
+ throw new Error('Changing ARM9 overlay count is unsupported');
211
+ const hashes = overlays.map(hash);
212
+ if (hashes.every((h, i) => h.equals(this.Overlays9Sha1Hmac[i]))) return Buffer.from(arm9Data);
213
+ const init = header.reserved2.readUInt32LE(0) & 0x3fff;
214
+ const hdrptr = arm9Data.readUInt32LE(init + 0x14) - header.ARM9ramAddress;
215
+ const unpacked = decompressArm9(arm9Data, header);
216
+ const end = unpacked.data.readUInt32LE(init + 8) - header.ARM9ramAddress;
217
+ let found = -1;
218
+ for (
219
+ let p = Math.min(end - hashes.length * 20, unpacked.data.length - hashes.length * 20);
220
+ p >= 0;
221
+ p--
222
+ ) {
223
+ if (unpacked.data.subarray(p, p + 20).equals(this.Overlays9Sha1Hmac[0])) {
224
+ found = p;
225
+ break;
226
+ }
227
+ }
228
+ if (found < 0)
229
+ throw new Error('ARM9 overlay hashes changed but original hash table was not found');
230
+ hashes.forEach((h, i) => h.copy(unpacked.data, found + i * 20));
231
+ // Original C# inverted this check and tried to compress uncompressed data.
232
+ // Preserve the original storage format: recompress only a compressed ARM9.
233
+ return unpacked.compressed
234
+ ? compressArm9(unpacked.data, header, arm9Data.length - hdrptr)
235
+ : unpacked.data;
236
+ }
237
+ writeTo(writer: BinaryWriter, h: TwlHeader): Buffer {
238
+ validate(h);
239
+ // Fixed DSi regions cannot be shifted safely without rewriting the header.
240
+ if (writer.position > h.sector_hashtable_start) {
241
+ throw new Error('NTR output overlaps the fixed DSi sector hash table');
242
+ }
243
+ if (
244
+ h.sector_hashtable_start + h.sector_hashtable_size > h.block_hashtable_start ||
245
+ h.block_hashtable_start + h.block_hashtable_size > h.dsi9_rom_offset ||
246
+ h.dsi9_rom_offset + this.DSi9Data.length > h.dsi7_rom_offset ||
247
+ h.dsi7_rom_offset + this.DSi7Data.length > h.total_rom_size
248
+ ) {
249
+ throw new Error('DSi sections overlap or exceed the ROM size');
250
+ }
251
+ const fill = (end: number): void => {
252
+ if (writer.position < end) writer.fillTo(end, 255);
253
+ };
254
+ fill(h.sector_hashtable_start);
255
+ writer.position = h.sector_hashtable_start + h.sector_hashtable_size;
256
+ fill(h.block_hashtable_start);
257
+ writer.position = h.block_hashtable_start + h.block_hashtable_size;
258
+ fill(h.dsi9_rom_offset);
259
+ if (this.Header2Data && !h.trimmedRom) {
260
+ writer.position = h.digest_twl_start - 0x3000;
261
+ this.Header2Data.forEach((b) => writer.write(b));
262
+ }
263
+ writer.position = h.dsi9_rom_offset;
264
+ writer.write(this.DSi9Data);
265
+ fill(h.dsi7_rom_offset);
266
+ writer.position = h.dsi7_rom_offset;
267
+ writer.write(this.DSi7Data);
268
+ fill(h.total_rom_size);
269
+ const finalPosition = writer.position;
270
+ const read = (p: number, n: number): Buffer =>
271
+ slice(writer.buffer.subarray(0, writer.length), p, n, 'TWL output digest');
272
+ let secure: Buffer = Buffer.from(read(h.digest_ntr_start, 0x4000));
273
+ if (h.decrypted) secure = encryptSecureArea(h.gameCode, secure);
274
+ let index = 0;
275
+ const put = (b: Buffer): void => {
276
+ writer.position = h.sector_hashtable_start + index++ * 20;
277
+ writer.write(hash(b));
278
+ };
279
+ for (let p = 0; p < 0x4000; p += h.digest_sector_size)
280
+ put(secure.subarray(p, p + h.digest_sector_size));
281
+ for (
282
+ let p = h.digest_ntr_start + 0x4000;
283
+ p < h.digest_ntr_start + h.digest_ntr_size;
284
+ p += h.digest_sector_size
285
+ )
286
+ put(read(p, h.digest_sector_size));
287
+ for (
288
+ let p = h.digest_twl_start;
289
+ p < h.digest_twl_start + h.digest_twl_size;
290
+ p += h.digest_sector_size
291
+ )
292
+ put(read(p, h.digest_sector_size));
293
+ index = 0;
294
+ for (
295
+ let p = h.sector_hashtable_start;
296
+ p < h.sector_hashtable_start + h.sector_hashtable_size;
297
+ p += h.digest_block_sectorcount * 20
298
+ ) {
299
+ const digest = hash(read(p, h.digest_block_sectorcount * 20));
300
+ writer.position = h.block_hashtable_start + index++ * 20;
301
+ writer.write(digest);
302
+ }
303
+ h.hmac_digest_master = hash(read(h.block_hashtable_start, h.block_hashtable_size));
304
+ if (this.twlEncrypted) this.cryptSections(h, writer);
305
+ writer.position = finalPosition;
306
+ return h.hmac_digest_master;
307
+ }
308
+ }
309
+
310
+ /** Refresh the digest and keep valid RSA signatures; optional no$gba hash mask. */
311
+ export const updateHeaderSignatures = (
312
+ writer: BinaryWriter,
313
+ header: SignedTwlHeader,
314
+ originalHeader: Buffer,
315
+ keepOriginal: boolean,
316
+ ): void => {
317
+ const signedData = Buffer.from(slice(originalHeader, 0, 0xe00, 'Signed header'));
318
+ const digest = slice(header.hmac_digest_master, 0, 20, 'Master digest');
319
+ const signature = slice(header.rsa_signature, 0, 128, 'RSA signature');
320
+ digest.copy(signedData, 0x328);
321
+ const valid = verifyRsaSha1(signedData, signature);
322
+ const position = writer.position;
323
+ writer.position = 0x328;
324
+ writer.write(digest);
325
+ if (!valid) {
326
+ if (!keepOriginal) {
327
+ const mask = Buffer.alloc(128, 0xff);
328
+ mask[0] = 0;
329
+ mask[1] = 1;
330
+ mask[107] = 0;
331
+ hashDigest('sha1', signedData).copy(mask, 108);
332
+ header.rsa_signature = mask;
333
+ }
334
+ writer.position = 0xf80;
335
+ writer.write(header.rsa_signature);
336
+ }
337
+ writer.position = position;
338
+ };
@@ -0,0 +1,14 @@
1
+ export { NDSFile } from './nitro/nds-file';
2
+ export type { RomOptions, RomFileInfo } from './nitro/nds-file';
3
+ export { Header, UnitCode } from './nitro/header';
4
+ export { Banner } from './nitro/banner';
5
+ export { FileAllocationTable, FileNameTable, OverlayTable } from './nitro/filesystem';
6
+ export type { NitroFile, NitroFolder, FatEntry, OverlayItem } from './nitro/filesystem';
7
+ export { BinaryWriter } from './nitro/binary';
8
+ export { crc16, crc32 } from './nitro/crc';
9
+ export { encryptSecureArea, aes128CtrCrypt, modcryptKey } from './nitro/crypto';
10
+ export type { ModcryptHeader } from './nitro/crypto';
11
+ export { compressBLZ, decompressBLZ, compressArm9, decompressArm9 } from './nitro/blz';
12
+ export type { Arm9Header } from './nitro/blz';
13
+ export { TWL, updateHeaderSignatures } from './nitro/twl';
14
+ export type { TwlHeader, SignedTwlHeader, OverlayRange } from './nitro/twl';