nitro-patcher 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.d.ts +25 -2
- package/dist/browser.js +10507 -78
- package/dist/browser.js.LEGAL.txt +460 -0
- package/dist/browser.js.map +4 -4
- package/dist/{chunk-IGURDH5L.js → chunk-B7NTPBDX.js} +70 -8
- package/dist/chunk-B7NTPBDX.js.map +1 -0
- package/dist/chunk-LK3JXA24.js +2067 -0
- package/dist/chunk-LK3JXA24.js.map +1 -0
- package/dist/cli.js +19 -7
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -2
- package/dist/nitro-helper.d.ts +43 -134
- package/dist/nitro-helper.js +3 -1
- package/dist/nitro-patch-helper.d.ts +4 -0
- package/dist/nitro-patch-helper.js +2 -2
- package/dist/twl-complete-B2is30H0.d.ts +201 -0
- package/package.json +8 -2
- package/src/browser/buffer.ts +1 -0
- package/src/browser.ts +23 -2
- package/src/cli.ts +15 -2
- package/src/nitro/blz.ts +13 -4
- package/src/nitro/crypto.ts +29 -11
- package/src/nitro/filesystem.ts +88 -5
- package/src/nitro/header.ts +4 -0
- package/src/nitro/nds-file.ts +378 -30
- package/src/nitro/twl-complete.ts +665 -0
- package/src/nitro/twl.ts +53 -19
- package/src/nitro-helper.ts +2 -1
- package/src/nitro-patch-helper.ts +64 -3
- package/src/patch/zip.ts +14 -4
- package/src/runtime/browser.ts +2 -2
- package/src/runtime/node.ts +2 -15
- package/src/runtime/rsa.ts +14 -0
- package/dist/chunk-GLK3K2HY.js +0 -1120
- package/dist/chunk-GLK3K2HY.js.map +0 -1
- package/dist/chunk-IGURDH5L.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nitro-patcher",
|
|
3
|
-
"version": "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
|
-
"
|
|
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",
|
package/src/browser/buffer.ts
CHANGED
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 = (
|
|
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
|
-
|
|
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([
|
|
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)]);
|
package/src/nitro/crypto.ts
CHANGED
|
@@ -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
|
|
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]] =
|
|
138
|
-
[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] =
|
|
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] =
|
|
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
|
-
|
|
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] =
|
|
186
|
+
[b, a] = crypt(b, a);
|
|
169
187
|
output.writeUInt32LE(a, 0);
|
|
170
188
|
output.writeUInt32LE(b, 4);
|
|
171
189
|
return output;
|
package/src/nitro/filesystem.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// Based on NitroHelper FNT/FAT/OverlayTable, GPL-3.0.
|
|
2
|
-
import
|
|
2
|
+
import iconv from "iconv-lite";
|
|
3
3
|
|
|
4
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
package/src/nitro/header.ts
CHANGED
|
@@ -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");
|