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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nitro-patcher",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "description": "Native Node.js Nintendo DS ROM reader, repacker and ZIP/Xdelta patcher",
5
5
  "type": "module",
6
6
  "engines": {
@@ -49,6 +49,7 @@
49
49
  "@noble/ciphers": "2.4.0",
50
50
  "@noble/hashes": "2.4.0",
51
51
  "@types/node": "22.20.2",
52
+ "@types/node-forge": "1.3.14",
52
53
  "buffer": "6.0.3",
53
54
  "esbuild": "0.28.1",
54
55
  "eslint": "10.9.1",
@@ -59,7 +60,9 @@
59
60
  "fflate": "0.8.3",
60
61
  "globals": "16.5.0",
61
62
  "jiti": "2.7.0",
63
+ "playwright-core": "1.64.1",
62
64
  "prettier": "3.9.6",
65
+ "string_decoder": "1.3.0",
63
66
  "tsup": "8.5.1",
64
67
  "tsx": "4.23.13",
65
68
  "type-fest": "4.41.0",
@@ -73,7 +76,9 @@
73
76
  "#browser-aes": "@noble/ciphers/aes.js"
74
77
  },
75
78
  "dependencies": {
76
- "markdansi": "0.3.3"
79
+ "iconv-lite": "0.7.3",
80
+ "markdansi": "0.3.3",
81
+ "node-forge": "1.4.0"
77
82
  },
78
83
  "scripts": {
79
84
  "clean": "tsx scripts/clean.ts",
@@ -81,6 +86,7 @@
81
86
  "typecheck": "tsc -p tsconfig.test.json",
82
87
  "test": "tsx --test test/*.test.ts",
83
88
  "test:reference": "tsx scripts/verify-reference.ts",
89
+ "test:dsi": "tsx scripts/verify-dsi.ts",
84
90
  "check": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm test && pnpm run build && pnpm run test:browser",
85
91
  "test:package": "tsx scripts/verify-package.ts",
86
92
  "lint": "eslint . --max-warnings 0",
@@ -1 +1,2 @@
1
1
  export { Buffer } from "buffer";
2
+ export const window = globalThis;
package/src/browser.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  } from "./nitro-patch-helper";
9
9
 
10
10
  export interface PatchMetadata {
11
+ formatVersion?: number;
11
12
  id?: string;
12
13
  author?: string;
13
14
  name?: string;
@@ -30,11 +31,28 @@ export interface BrowserPatchOptions {
30
31
  maxUncompressedSize?: number;
31
32
  maxEntries?: number;
32
33
  }
34
+ export interface DsiCheck {
35
+ status: "valid" | "invalid" | "not-applicable";
36
+ reason?: string;
37
+ }
38
+ export interface DsiVerification {
39
+ arm9: DsiCheck;
40
+ arm7: DsiCheck;
41
+ arm9i: DsiCheck;
42
+ arm7i: DsiCheck;
43
+ arm9NoSecure: DsiCheck;
44
+ banner: DsiCheck;
45
+ sectors: DsiCheck;
46
+ blocks: DsiCheck;
47
+ master: DsiCheck;
48
+ signature: DsiCheck;
49
+ }
33
50
  export interface BrowserPatchResult {
34
51
  buffer: Uint8Array<ArrayBuffer>;
35
52
  returnValue: "SUCCESS" | "MD5_MISMATCH";
36
53
  inputMd5: string;
37
54
  outputMd5: string;
55
+ dsi?: DsiVerification;
38
56
  }
39
57
  export interface RomInfo {
40
58
  gameTitle: string;
@@ -42,10 +60,11 @@ export interface RomInfo {
42
60
  size: number;
43
61
  fileCount: number;
44
62
  checksums: { header: boolean; logo: boolean; secureArea: boolean; banner: boolean };
63
+ dsi?: DsiVerification;
45
64
  }
46
65
  /** Parse ROM metadata entirely in memory. No filesystem or Node types required. */
47
- export const inspectRom = (bytes: Uint8Array): RomInfo => {
48
- const rom = new NDSFile(bytes);
66
+ export const inspectRom = (bytes: Uint8Array, options: { dsiMode?: "legacy" | "complete" } = {}): RomInfo => {
67
+ const rom = new NDSFile(bytes, options);
49
68
  return {
50
69
  gameTitle: rom.header.gameTitle,
51
70
  gameCode: rom.header.gameCode,
@@ -57,6 +76,7 @@ export const inspectRom = (bytes: Uint8Array): RomInfo => {
57
76
  secureArea: rom.header.secureCRC,
58
77
  banner: rom.banner.bannerCRC,
59
78
  },
79
+ ...(options.dsiMode === "complete" && { dsi: rom.verifyDsi() }),
60
80
  };
61
81
  };
62
82
  /** Synchronous CPU work; call in a Web Worker for responsive browser UIs. */
@@ -77,6 +97,7 @@ export const patchBuffer = (
77
97
  returnValue: result.returnValue,
78
98
  inputMd5: result.inputMd5,
79
99
  outputMd5: result.outputMd5,
100
+ ...(result.dsi && { dsi: result.dsi }),
80
101
  };
81
102
  };
82
103
  /** Read and validate metadata.json from a patch package entirely in memory. */
package/src/cli.ts CHANGED
@@ -17,6 +17,7 @@ const help = `nitro-patcher — NDS ROM 补丁工具
17
17
 
18
18
  --json 结果输出为 JSON 格式
19
19
  --dry-run 完整执行补丁流程,但不写入输出 ROM
20
+ --dsi-mode extract/inspect 的 DSi 处理模式:legacy 或 complete
20
21
  -i, --input 输入 ROM 路径
21
22
  -p, --patch 补丁 ZIP 路径
22
23
  -o, --output 输出 ROM 或提取目录;试运行时可省略
@@ -29,6 +30,7 @@ interface CommandOptions {
29
30
  input: string | undefined;
30
31
  patch: string | undefined;
31
32
  output: string | undefined;
33
+ dsiMode: "legacy" | "complete" | undefined;
32
34
  }
33
35
 
34
36
  const sameFile = async (input: string, output: string): Promise<boolean> => {
@@ -79,7 +81,7 @@ const formatMetadata = (metadata: PatchMetadata): string => {
79
81
  const extractRom = async (options: CommandOptions): Promise<void> => {
80
82
  if (!options.input || !options.output || options.patch !== undefined || options.dryRun) throw new Error(help);
81
83
  const destination = join(await realpath(dirname(resolve(options.output))), basename(resolve(options.output)));
82
- const rom = new NDSFile(await readFile(options.input));
84
+ const rom = new NDSFile(await readFile(options.input), { dsiMode: options.dsiMode ?? "legacy" });
83
85
  const files = rom.listFiles();
84
86
  await mkdir(destination, { recursive: true });
85
87
  for (const { path } of files) {
@@ -106,7 +108,7 @@ const inspectRom = async (options: CommandOptions): Promise<void> => {
106
108
  if (!options.input || options.patch !== undefined || options.output !== undefined || options.dryRun)
107
109
  throw new Error(help);
108
110
  const input = await readFile(options.input);
109
- const rom = new NDSFile(input);
111
+ const rom = new NDSFile(input, { dsiMode: options.dsiMode ?? "legacy" });
110
112
  const info = {
111
113
  gameTitle: rom.header.gameTitle.replace(/\0+$/, ""),
112
114
  gameCode: rom.header.gameCode,
@@ -118,6 +120,7 @@ const inspectRom = async (options: CommandOptions): Promise<void> => {
118
120
  secureArea: rom.header.secureCRC,
119
121
  banner: rom.banner.bannerCRC,
120
122
  },
123
+ ...(options.dsiMode === "complete" && { dsi: rom.verifyDsi() }),
121
124
  };
122
125
  if (options.json) {
123
126
  console.log(JSON.stringify(info));
@@ -134,6 +137,9 @@ const inspectRom = async (options: CommandOptions): Promise<void> => {
134
137
  - Logo:${info.checksums.logo ? "通过" : "失败"}
135
138
  - Secure Area:${info.checksums.secureArea ? "通过" : "失败"}
136
139
  - Banner:${info.checksums.banner ? "通过" : "失败"}`);
140
+ if (info.dsi)
141
+ for (const [name, result] of Object.entries(info.dsi))
142
+ console.log(`- DSi ${name}:${result.status}${result.reason ? ` (${result.reason})` : ""}`);
137
143
  };
138
144
 
139
145
  const main = async (): Promise<void> => {
@@ -144,6 +150,7 @@ const main = async (): Promise<void> => {
144
150
  version: { type: "boolean", short: "v" },
145
151
  json: { type: "boolean" },
146
152
  "dry-run": { type: "boolean" },
153
+ "dsi-mode": { type: "string" },
147
154
  input: { type: "string", short: "i" },
148
155
  patch: { type: "string", short: "p" },
149
156
  output: { type: "string", short: "o" },
@@ -160,12 +167,16 @@ const main = async (): Promise<void> => {
160
167
  console.log(metadata.version);
161
168
  return;
162
169
  }
170
+ const dsiMode = values["dsi-mode"];
171
+ if (dsiMode !== undefined && dsiMode !== "legacy" && dsiMode !== "complete")
172
+ throw new Error("Invalid --dsi-mode; expected legacy or complete");
163
173
  const options: CommandOptions = {
164
174
  json: values.json ?? false,
165
175
  dryRun: values["dry-run"] ?? false,
166
176
  input: values.input,
167
177
  patch: values.patch,
168
178
  output: values.output,
179
+ dsiMode,
169
180
  };
170
181
  const command = positionals.length === 1 ? positionals[0] : undefined;
171
182
  if (positionals.length !== 0 && positionals.length !== 1 && positionals.length !== 3) throw new Error(help);
@@ -181,6 +192,7 @@ const main = async (): Promise<void> => {
181
192
  }
182
193
 
183
194
  const dryRun = options.dryRun;
195
+ if (options.dsiMode !== undefined) throw new Error("patch selects DSi processing from metadata.json formatVersion");
184
196
  const usesLegacyPaths = positionals.length === 3;
185
197
  if (usesLegacyPaths && (values.input !== undefined || values.patch !== undefined || values.output !== undefined))
186
198
  throw new Error(help);
@@ -216,6 +228,7 @@ const main = async (): Promise<void> => {
216
228
  outputMd5,
217
229
  ...info,
218
230
  ...(dryRun && { dryRun: true }),
231
+ ...(result.dsi && { dsi: result.dsi }),
219
232
  }),
220
233
  );
221
234
  return;
package/src/nitro/blz.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export interface Arm9Header {
2
+ nitrocode?: boolean;
2
3
  reserved2: Buffer;
3
4
  ARM9ramAddress: number;
4
5
  ARM9size: number;
@@ -130,22 +131,30 @@ export const compressBLZ = (input: Buffer): Buffer => {
130
131
  return out;
131
132
  };
132
133
 
133
- export const decompressArm9 = (data: Buffer, header: Arm9Header): { compressed: boolean; data: Buffer } => {
134
+ export const decompressArm9 = (
135
+ data: Buffer,
136
+ header: Arm9Header,
137
+ maxSize = 0x10000000,
138
+ ): { compressed: boolean; data: Buffer } => {
134
139
  if (data.length < 0x18) throw new Error("ARM9 data too short");
135
- const nitro = data.readUInt32LE(0xc) === 0xdec00621 ? 12 : 0;
140
+ if (data.length > maxSize) throw new RangeError("ARM9 exceeds output size limit");
141
+ const nitro = (header.nitrocode ?? data.readUInt32LE(0xc) === 0xdec00621) ? 12 : 0;
136
142
  const init = header.reserved2.readUInt32LE(0) & 0x3fff;
137
143
  const ptr = init ? data.readUInt32LE(init + 0x14) : header.ARM9ramAddress + header.ARM9size;
138
144
  const compressed = ptr > header.ARM9ramAddress && ptr + nitro >= header.ARM9ramAddress + data.length;
139
145
  return {
140
146
  compressed,
141
147
  data: compressed
142
- ? Buffer.concat([decompressBLZ(data.subarray(0, data.length - nitro)), data.subarray(data.length - nitro)])
148
+ ? Buffer.concat([
149
+ decompressBLZ(data.subarray(0, data.length - nitro), maxSize - nitro),
150
+ data.subarray(data.length - nitro),
151
+ ])
143
152
  : Buffer.from(data),
144
153
  };
145
154
  };
146
155
  export const compressArm9 = (data: Buffer, header: Arm9Header, postSize = 0): Buffer => {
147
156
  if (data.length < 0x4000) throw new Error("ARM9 secure area is truncated");
148
- const nitro = data.readUInt32LE(0xc) === 0xdec00621 ? 12 : 0;
157
+ const nitro = (header.nitrocode ?? data.readUInt32LE(0xc) === 0xdec00621) ? 12 : 0;
149
158
  const packed = compressBLZ(data.subarray(0x4000, data.length - nitro));
150
159
  if (!packed.length) throw new Error("ARM9 BLZ compression produced no smaller representation");
151
160
  const out = Buffer.concat([data.subarray(0, 0x4000), packed, data.subarray(data.length - nitro)]);
@@ -1,5 +1,7 @@
1
1
  import { createAesBlockCipher } from "#nitro-runtime";
2
2
 
3
+ import { crc16 } from "./crc";
4
+
3
5
  export interface ModcryptHeader {
4
6
  gameCode: string;
5
7
  gameTitle: string;
@@ -117,32 +119,37 @@ export const modcryptKey = (header: ModcryptHeader): Buffer => {
117
119
  return out;
118
120
  };
119
121
 
120
- export const encryptSecureArea = (gameCode: string, input: Buffer): Buffer => {
122
+ export const encryptSecureArea = (gameCode: string, input: Buffer, refreshCrc = false): Buffer =>
123
+ secureAreaCrypt(gameCode, input, false, refreshCrc);
124
+ export const decryptSecureArea = (gameCode: string, input: Buffer): Buffer => secureAreaCrypt(gameCode, input, true);
125
+
126
+ const secureAreaCrypt = (gameCode: string, input: Buffer, decrypting: boolean, refreshCrc = false): Buffer => {
121
127
  if (typeof gameCode !== "string" || gameCode.length !== 4) throw new Error("Expected four-character game code");
122
128
  if (input.length < 0x800) throw new RangeError("Secure area must contain at least 0x800 bytes");
123
129
  const output = Buffer.from(input);
130
+ if (refreshCrc) output.writeUInt16LE(crc16(output.subarray(0x10, 0x800)), 0xe);
124
131
  let magic = new Uint32Array(0x412);
125
132
  let args: [number, number, number] = [0, 0, 0];
126
- function encrypt(a: number, b: number): [number, number] {
133
+ function crypt(a: number, b: number, reverse = false): [number, number] {
127
134
  for (let i = 0; i < 16; i++) {
128
- const c = (magic[i] ^ a) >>> 0;
135
+ const c = (magic[reverse ? 17 - i : i] ^ a) >>> 0;
129
136
  const sum = (magic[18 + (c >>> 24)] + magic[274 + ((c >>> 16) & 255)]) >>> 0;
130
137
  const f = (magic[786 + (c & 255)] + (magic[530 + ((c >>> 8) & 255)] ^ sum)) >>> 0;
131
138
  a = (b ^ f) >>> 0;
132
139
  b = c;
133
140
  }
134
- return [(b ^ magic[17]) >>> 0, (a ^ magic[16]) >>> 0];
141
+ return [(b ^ magic[reverse ? 0 : 17]) >>> 0, (a ^ magic[reverse ? 1 : 16]) >>> 0];
135
142
  }
136
143
  function init2() {
137
- [args[2], args[1]] = encrypt(args[2], args[1]);
138
- [args[1], args[0]] = encrypt(args[1], args[0]);
144
+ [args[2], args[1]] = crypt(args[2], args[1]);
145
+ [args[1], args[0]] = crypt(args[1], args[0]);
139
146
  const key = Buffer.alloc(12);
140
147
  args.forEach((v, i) => key.writeUInt32LE(v, i * 4));
141
148
  for (let j = 0; j < 18; j++) magic[j] ^= key.readUInt32BE((j * 4) & 7);
142
149
  let a = 0,
143
150
  b = 0;
144
151
  for (let i = 0; i < 0x412; i += 2) {
145
- [a, b] = encrypt(a, b);
152
+ [a, b] = crypt(a, b);
146
153
  magic[i] = a;
147
154
  magic[i + 1] = b;
148
155
  }
@@ -155,17 +162,28 @@ export const encryptSecureArea = (gameCode: string, input: Buffer): Buffer => {
155
162
  init2();
156
163
  }
157
164
  init1();
165
+ if (decrypting) {
166
+ const [b, a] = crypt(output.readUInt32LE(4), output.readUInt32LE(0), true);
167
+ output.writeUInt32LE(a, 0);
168
+ output.writeUInt32LE(b, 4);
169
+ }
158
170
  args[1] = (args[1] << 1) >>> 0;
159
171
  args[2] >>>= 1;
160
172
  init2();
161
- for (let p = 8; p < 0x800; p += 8) {
162
- const [b, a] = encrypt(output.readUInt32LE(p + 4), output.readUInt32LE(p));
173
+ for (let p = decrypting ? 0 : 8; p < 0x800; p += 8) {
174
+ const [b, a] = crypt(output.readUInt32LE(p + 4), output.readUInt32LE(p), decrypting);
163
175
  output.writeUInt32LE(a, p);
164
176
  output.writeUInt32LE(b, p + 4);
165
177
  }
166
- let [b, a] = encrypt(0x6a624f79, 0x72636e65);
178
+ if (decrypting) {
179
+ if (output.readUInt32LE(0) !== 0x72636e65 || output.readUInt32LE(4) !== 0x6a624f79)
180
+ throw new Error("Invalid KEY1 secure-area marker");
181
+ output.writeBigUInt64LE(0xe7ffdeffe7ffdeffn);
182
+ return output;
183
+ }
184
+ let [b, a] = crypt(0x6a624f79, 0x72636e65);
167
185
  init1();
168
- [b, a] = encrypt(b, a);
186
+ [b, a] = crypt(b, a);
169
187
  output.writeUInt32LE(a, 0);
170
188
  output.writeUInt32LE(b, 4);
171
189
  return output;
@@ -1,9 +1,12 @@
1
1
  // Based on NitroHelper FNT/FAT/OverlayTable, GPL-3.0.
2
- import { bytes, range } from "./binary";
2
+ import iconv from "iconv-lite";
3
3
 
4
- /** A file in the original ROM, optionally replaced with new bytes. */
4
+ import { BinaryWriter, bytes, range } from "./binary";
5
+
6
+ /** Original IDs/offsets describe the input ROM; newly added files use -1 until serialized and reopened. */
5
7
  export interface NitroFile {
6
8
  name: string;
9
+ nameBytes?: Buffer;
7
10
  id: number;
8
11
  offset: number;
9
12
  size: number;
@@ -12,11 +15,27 @@ export interface NitroFile {
12
15
 
13
16
  export interface NitroFolder {
14
17
  name: string;
18
+ nameBytes?: Buffer;
15
19
  id: number;
16
20
  files: NitroFile[];
17
21
  folders: NitroFolder[];
18
22
  }
19
23
 
24
+ export const encodeName = (name: string): Buffer => {
25
+ const encoded = iconv.encode(name, "shift_jis");
26
+ if (new TextDecoder("shift_jis").decode(encoded) !== name)
27
+ throw new Error(`FNT name cannot be represented in Shift_JIS: ${name}`);
28
+ if (!encoded.length || encoded.length > 127) throw new Error("FNT name must contain 1 to 127 encoded bytes");
29
+ return encoded;
30
+ };
31
+
32
+ export interface FileSystemLayout {
33
+ fnt: Buffer;
34
+ files: NitroFile[];
35
+ ids: Map<NitroFile, number>;
36
+ sourceIds: Map<number, number>;
37
+ }
38
+
20
39
  export interface FatEntry {
21
40
  id: number;
22
41
  offset: number;
@@ -72,7 +91,8 @@ export class FileNameTable {
72
91
  if (!tag) break;
73
92
  const length = tag & 0x7f;
74
93
  if (!length) throw new Error("Empty FNT directory name");
75
- const filename = decoder.decode(range(table, cursor, length, "FNT filename"));
94
+ const nameBytes = Buffer.from(range(table, cursor, length, "FNT filename"));
95
+ const filename = decoder.decode(nameBytes);
76
96
  cursor += length;
77
97
  if (
78
98
  filename.includes("/") ||
@@ -89,13 +109,15 @@ export class FileNameTable {
89
109
  const id = range(table, cursor, 2, "FNT directory id").readUInt16LE();
90
110
  cursor += 2;
91
111
  if (id < 0xf000) throw new Error("Invalid FNT directory id");
92
- folder.folders.push(parse(id & 0xfff, filename, depth + 1));
112
+ const child = parse(id & 0xfff, filename, depth + 1);
113
+ child.nameBytes = nameBytes;
114
+ folder.folders.push(child);
93
115
  } else {
94
116
  const entry = fat.entries[fileId++];
95
117
  if (!entry) throw new Error("FNT file id is outside FAT");
96
118
  if (fileIds.has(entry.id)) throw new Error(`Duplicate FNT file id: ${entry.id}`);
97
119
  fileIds.add(entry.id);
98
- folder.files.push({ name: filename, ...entry });
120
+ folder.files.push({ name: filename, nameBytes, ...entry });
99
121
  }
100
122
  }
101
123
  return folder;
@@ -103,6 +125,67 @@ export class FileNameTable {
103
125
  this.root = { name: "root", id: 0xffff, files: [], folders: [parse(0, "data", 0)] };
104
126
  }
105
127
 
128
+ serialize(fat: FileAllocationTable, overlays: NitroFile[]): FileSystemLayout {
129
+ const directories: { folder: NitroFolder; parent: number; firstId: number }[] = [];
130
+ const named: NitroFile[] = [];
131
+ const sort = <T extends NitroFile | NitroFolder>(items: T[]): T[] =>
132
+ [...items].sort((a, b) => Buffer.compare(a.nameBytes ?? encodeName(a.name), b.nameBytes ?? encodeName(b.name)));
133
+ const visit = (folder: NitroFolder, parent: number): void => {
134
+ const index = directories.length;
135
+ directories.push({ folder, parent, firstId: named.length });
136
+ if (directories.length > 4096) throw new Error("FNT directory count exceeds 4096");
137
+ named.push(...sort(folder.files));
138
+ for (const child of sort(folder.folders)) visit(child, 0xf000 + index);
139
+ };
140
+ visit(this.root.folders[0], 0xf000);
141
+ const namedIds = new Set(named.filter((file) => file.id >= 0).map((file) => file.id));
142
+ const overlayIds = new Set(overlays.map((file) => file.id));
143
+ const unnamed = fat.entries
144
+ .filter((entry) => !namedIds.has(entry.id))
145
+ .map((entry) => ({ ...entry, name: `FAT entry ${entry.id}` }));
146
+ const files: NitroFile[] = [
147
+ ...unnamed.filter((entry) => overlayIds.has(entry.id)),
148
+ ...overlays
149
+ .filter((file) => file.id < 0)
150
+ .sort(
151
+ (a, b) =>
152
+ Number(a.name.startsWith("overlay7_")) - Number(b.name.startsWith("overlay7_")) ||
153
+ Number(a.name.slice(a.name.indexOf("_") + 1, -4)) - Number(b.name.slice(b.name.indexOf("_") + 1, -4)),
154
+ ),
155
+ ...unnamed.filter((entry) => !overlayIds.has(entry.id)),
156
+ ];
157
+ const firstNamed = files.length;
158
+ files.push(...named);
159
+ if (files.length > 0xf000) throw new Error("Filesystem file IDs exceed 0xEFFF");
160
+ const ids = new Map(files.map((file, id) => [file, id]));
161
+ const sourceIds = new Map(files.filter((file) => file.id >= 0).map((file) => [file.id, ids.get(file)!]));
162
+ const directoryIds = new Map(directories.map(({ folder }, index) => [folder, 0xf000 + index]));
163
+ const writer = new BinaryWriter();
164
+ writer.write(Buffer.alloc(directories.length * 8));
165
+ for (const [index, { folder, parent, firstId }] of directories.entries()) {
166
+ const record = Buffer.alloc(8);
167
+ record.writeUInt32LE(writer.position);
168
+ record.writeUInt16LE(firstNamed + firstId, 4);
169
+ record.writeUInt16LE(index === 0 ? directories.length : parent, 6);
170
+ record.copy(writer.buffer, index * 8);
171
+ for (const file of sort(folder.files)) {
172
+ const name = file.nameBytes ?? encodeName(file.name);
173
+ writer.write(Buffer.from([name.length]));
174
+ writer.write(name);
175
+ }
176
+ for (const child of sort(folder.folders)) {
177
+ const name = child.nameBytes ?? encodeName(child.name);
178
+ writer.write(Buffer.from([0x80 | name.length]));
179
+ writer.write(name);
180
+ const id = Buffer.alloc(2);
181
+ id.writeUInt16LE(directoryIds.get(child)!);
182
+ writer.write(id);
183
+ }
184
+ writer.write(Buffer.from([0]));
185
+ }
186
+ return { fnt: writer.toBuffer(), files, ids, sourceIds };
187
+ }
188
+
106
189
  static findFile(id: number, folder: NitroFolder): NitroFile | undefined {
107
190
  for (const file of folder.files) if (file.id === id) return file;
108
191
  for (const subfolder of folder.folders) {
@@ -77,6 +77,8 @@ export class Header {
77
77
  declare ARM9autoload: number;
78
78
  declare ARM7autoload: number;
79
79
  declare ROMsize: number;
80
+ declare ntr_region_end: number;
81
+ declare twl_region_start: number;
80
82
  declare headerSize: number;
81
83
  declare logoCRC16: number;
82
84
  declare headerCRC16: number;
@@ -219,6 +221,8 @@ field("secureDisable", 0x78, 8, "number");
219
221
  field("ROMsize", 0x80, 4, "number");
220
222
  field("headerSize", 0x84, 4, "number");
221
223
  field("reserved2", 0x88, 56);
224
+ field("ntr_region_end", 0x90, 2, "number");
225
+ field("twl_region_start", 0x92, 2, "number");
222
226
  field("logo", 0xc0, 156);
223
227
  field("logoCRC16", 0x15c, 2, "number");
224
228
  field("headerCRC16", 0x15e, 2, "number");