nitro-patcher 0.2.0 → 0.3.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 +5 -2
- package/dist/browser.js +1367 -1418
- package/dist/browser.js.map +4 -4
- package/dist/{chunk-GFF67HX4.js → chunk-GLK3K2HY.js} +221 -282
- package/dist/chunk-GLK3K2HY.js.map +1 -0
- package/dist/{chunk-ZY3OSZCA.js → chunk-IGURDH5L.js} +180 -186
- package/dist/chunk-IGURDH5L.js.map +1 -0
- package/dist/cli.js +99 -21
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -2
- package/dist/nitro-helper.d.ts +78 -78
- package/dist/nitro-helper.js +1 -1
- package/dist/nitro-patch-helper.d.ts +5 -2
- package/dist/nitro-patch-helper.js +4 -2
- package/package.json +6 -2
- package/src/browser/buffer.ts +1 -1
- package/src/browser.ts +16 -12
- package/src/cli.ts +137 -60
- package/src/index.ts +2 -2
- package/src/nitro/banner.ts +28 -31
- package/src/nitro/binary.ts +6 -16
- package/src/nitro/blz.ts +13 -21
- package/src/nitro/crypto.ts +78 -84
- package/src/nitro/filesystem.ts +36 -39
- package/src/nitro/header.ts +58 -71
- package/src/nitro/nds-file.ts +44 -63
- package/src/nitro/twl.ts +59 -100
- package/src/nitro-helper.ts +14 -14
- package/src/nitro-patch-helper.ts +49 -57
- package/src/patch/xdelta.ts +43 -49
- package/src/patch/zip.ts +47 -56
- package/src/runtime/browser.ts +9 -11
- package/src/runtime/node.ts +14 -15
- package/dist/chunk-GFF67HX4.js.map +0 -1
- package/dist/chunk-ZY3OSZCA.js.map +0 -1
package/src/cli.ts
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { patchBuffer, readPatchInfo, type PatchMetadata } from './nitro-patch-helper';
|
|
2
|
+
import { render } from "markdansi";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
7
6
|
|
|
8
|
-
|
|
7
|
+
import { NDSFile } from "./nitro/nds-file";
|
|
8
|
+
import { type PatchMetadata, patchBuffer, readPatchInfo } from "./nitro-patch-helper";
|
|
9
|
+
|
|
10
|
+
const help = `nitro-patcher — NDS ROM 补丁工具
|
|
9
11
|
|
|
10
12
|
用法:
|
|
11
13
|
nitro-patcher [--json] [--dry-run] <原始ROM> <补丁ZIP> [输出ROM]
|
|
12
|
-
nitro-patcher [--json] [--dry-run] --input <原始ROM> --patch <补丁ZIP> [--output <输出ROM>]
|
|
14
|
+
nitro-patcher patch [--json] [--dry-run] --input <原始ROM> --patch <补丁ZIP> [--output <输出ROM>]
|
|
15
|
+
nitro-patcher extract [--json] --input <ROM> --output <目录>
|
|
16
|
+
nitro-patcher inspect [--json] --input <ROM>
|
|
13
17
|
|
|
14
|
-
--json
|
|
18
|
+
--json 结果输出为 JSON 格式
|
|
15
19
|
--dry-run 完整执行补丁流程,但不写入输出 ROM
|
|
16
|
-
-i, --input
|
|
20
|
+
-i, --input 输入 ROM 路径
|
|
17
21
|
-p, --patch 补丁 ZIP 路径
|
|
18
|
-
-o, --output 输出 ROM
|
|
22
|
+
-o, --output 输出 ROM 或提取目录;试运行时可省略
|
|
19
23
|
-h, --help 显示帮助
|
|
20
24
|
-v, --version 显示版本`;
|
|
21
25
|
|
|
26
|
+
interface CommandOptions {
|
|
27
|
+
json: boolean;
|
|
28
|
+
dryRun: boolean;
|
|
29
|
+
input: string | undefined;
|
|
30
|
+
patch: string | undefined;
|
|
31
|
+
output: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
22
34
|
const sameFile = async (input: string, output: string): Promise<boolean> => {
|
|
23
35
|
if (resolve(input) === resolve(output)) return true;
|
|
24
36
|
const original = await stat(input);
|
|
@@ -26,14 +38,14 @@ const sameFile = async (input: string, output: string): Promise<boolean> => {
|
|
|
26
38
|
const destination = await stat(output);
|
|
27
39
|
return original.dev === destination.dev && original.ino === destination.ino;
|
|
28
40
|
} catch (error) {
|
|
29
|
-
if (!(error instanceof Error &&
|
|
41
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error;
|
|
30
42
|
return false;
|
|
31
43
|
}
|
|
32
44
|
};
|
|
33
45
|
|
|
34
46
|
const safeTerminalText = (value: string): string => {
|
|
35
47
|
const characters: string[] = [];
|
|
36
|
-
for (const character of value.replaceAll(
|
|
48
|
+
for (const character of value.replaceAll("\r\n", "\n").replaceAll("\r", "\n")) {
|
|
37
49
|
const code = character.codePointAt(0)!;
|
|
38
50
|
if (
|
|
39
51
|
(code < 0x20 && code !== 0x09 && code !== 0x0a) ||
|
|
@@ -44,37 +56,97 @@ const safeTerminalText = (value: string): string => {
|
|
|
44
56
|
continue;
|
|
45
57
|
characters.push(character);
|
|
46
58
|
}
|
|
47
|
-
return characters.join(
|
|
59
|
+
return characters.join("");
|
|
48
60
|
};
|
|
49
61
|
|
|
50
62
|
const formatMetadata = (metadata: PatchMetadata): string => {
|
|
51
63
|
const labels = {
|
|
52
|
-
author:
|
|
53
|
-
name:
|
|
54
|
-
homepage:
|
|
55
|
-
version:
|
|
64
|
+
author: "作者",
|
|
65
|
+
name: "名称",
|
|
66
|
+
homepage: "主页",
|
|
67
|
+
version: "版本",
|
|
56
68
|
} as const;
|
|
57
69
|
const lines: string[] = [];
|
|
58
|
-
for (const field of [
|
|
70
|
+
for (const field of ["author", "name", "homepage", "version"] as const) {
|
|
59
71
|
const value = metadata[field];
|
|
60
72
|
if (value === undefined) continue;
|
|
61
|
-
const suffix = field ===
|
|
62
|
-
lines.push(`- ${labels[field]}:${safeTerminalText(value).replaceAll(
|
|
73
|
+
const suffix = field === "version" && metadata.isBeta ? "(测试版)" : "";
|
|
74
|
+
lines.push(`- ${labels[field]}:${safeTerminalText(value).replaceAll("\n", " ")}${suffix}`);
|
|
75
|
+
}
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const extractRom = async (options: CommandOptions): Promise<void> => {
|
|
80
|
+
if (!options.input || !options.output || options.patch !== undefined || options.dryRun) throw new Error(help);
|
|
81
|
+
const destination = join(await realpath(dirname(resolve(options.output))), basename(resolve(options.output)));
|
|
82
|
+
const rom = new NDSFile(await readFile(options.input));
|
|
83
|
+
const files = rom.listFiles();
|
|
84
|
+
await mkdir(destination, { recursive: true });
|
|
85
|
+
for (const { path } of files) {
|
|
86
|
+
const output = resolve(destination, path);
|
|
87
|
+
if (!output.startsWith(`${destination}${sep}`)) throw new Error(`ROM 文件路径超出输出目录:${path}`);
|
|
88
|
+
await mkdir(dirname(output), { recursive: true });
|
|
89
|
+
await writeFile(output, rom.getFile(path));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (options.json) {
|
|
93
|
+
console.log(
|
|
94
|
+
JSON.stringify({
|
|
95
|
+
output: destination,
|
|
96
|
+
fileCount: files.length,
|
|
97
|
+
files: files.map(({ path }) => path),
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
console.log(`已提取 ${files.length} 个文件到:${destination}`);
|
|
63
102
|
}
|
|
64
|
-
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const inspectRom = async (options: CommandOptions): Promise<void> => {
|
|
106
|
+
if (!options.input || options.patch !== undefined || options.output !== undefined || options.dryRun)
|
|
107
|
+
throw new Error(help);
|
|
108
|
+
const input = await readFile(options.input);
|
|
109
|
+
const rom = new NDSFile(input);
|
|
110
|
+
const info = {
|
|
111
|
+
gameTitle: rom.header.gameTitle.replace(/\0+$/, ""),
|
|
112
|
+
gameCode: rom.header.gameCode,
|
|
113
|
+
size: input.length,
|
|
114
|
+
fileCount: rom.listFiles().length,
|
|
115
|
+
checksums: {
|
|
116
|
+
header: rom.header.headerCRC,
|
|
117
|
+
logo: rom.header.logoCRC,
|
|
118
|
+
secureArea: rom.header.secureCRC,
|
|
119
|
+
banner: rom.banner.bannerCRC,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
if (options.json) {
|
|
123
|
+
console.log(JSON.stringify(info));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
console.log(`ROM 信息:
|
|
127
|
+
|
|
128
|
+
标题:${safeTerminalText(info.gameTitle).replaceAll("\n", " ")}
|
|
129
|
+
游戏代码:${safeTerminalText(info.gameCode).replaceAll("\n", " ")}
|
|
130
|
+
大小:${info.size} 字节
|
|
131
|
+
文件数量:${info.fileCount}
|
|
132
|
+
校验:
|
|
133
|
+
- Header:${info.checksums.header ? "通过" : "失败"}
|
|
134
|
+
- Logo:${info.checksums.logo ? "通过" : "失败"}
|
|
135
|
+
- Secure Area:${info.checksums.secureArea ? "通过" : "失败"}
|
|
136
|
+
- Banner:${info.checksums.banner ? "通过" : "失败"}`);
|
|
65
137
|
};
|
|
66
138
|
|
|
67
139
|
const main = async (): Promise<void> => {
|
|
68
140
|
const { values, positionals } = parseArgs({
|
|
69
141
|
allowPositionals: true,
|
|
70
142
|
options: {
|
|
71
|
-
help: { type:
|
|
72
|
-
version: { type:
|
|
73
|
-
json: { type:
|
|
74
|
-
|
|
75
|
-
input: { type:
|
|
76
|
-
patch: { type:
|
|
77
|
-
output: { type:
|
|
143
|
+
help: { type: "boolean", short: "h" },
|
|
144
|
+
version: { type: "boolean", short: "v" },
|
|
145
|
+
json: { type: "boolean" },
|
|
146
|
+
"dry-run": { type: "boolean" },
|
|
147
|
+
input: { type: "string", short: "i" },
|
|
148
|
+
patch: { type: "string", short: "p" },
|
|
149
|
+
output: { type: "string", short: "o" },
|
|
78
150
|
},
|
|
79
151
|
});
|
|
80
152
|
if (values.help) {
|
|
@@ -82,38 +154,43 @@ const main = async (): Promise<void> => {
|
|
|
82
154
|
return;
|
|
83
155
|
}
|
|
84
156
|
if (values.version) {
|
|
85
|
-
const metadata: unknown = JSON.parse(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
!metadata ||
|
|
90
|
-
typeof metadata !== 'object' ||
|
|
91
|
-
!('version' in metadata) ||
|
|
92
|
-
typeof metadata.version !== 'string'
|
|
93
|
-
)
|
|
94
|
-
throw new Error('Invalid package version metadata');
|
|
157
|
+
const metadata: unknown = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
158
|
+
if (!metadata || typeof metadata !== "object" || !("version" in metadata) || typeof metadata.version !== "string")
|
|
159
|
+
throw new Error("Invalid package version metadata");
|
|
95
160
|
console.log(metadata.version);
|
|
96
161
|
return;
|
|
97
162
|
}
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
)
|
|
163
|
+
const options: CommandOptions = {
|
|
164
|
+
json: values.json ?? false,
|
|
165
|
+
dryRun: values["dry-run"] ?? false,
|
|
166
|
+
input: values.input,
|
|
167
|
+
patch: values.patch,
|
|
168
|
+
output: values.output,
|
|
169
|
+
};
|
|
170
|
+
const command = positionals.length === 1 ? positionals[0] : undefined;
|
|
171
|
+
if (positionals.length !== 0 && positionals.length !== 1 && positionals.length !== 3) throw new Error(help);
|
|
172
|
+
if (command !== undefined && command !== "patch" && command !== "extract" && command !== "inspect")
|
|
173
|
+
throw new Error(help);
|
|
174
|
+
if (command === "extract") {
|
|
175
|
+
await extractRom(options);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (command === "inspect") {
|
|
179
|
+
await inspectRom(options);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const dryRun = options.dryRun;
|
|
184
|
+
const usesLegacyPaths = positionals.length === 3;
|
|
185
|
+
if (usesLegacyPaths && (values.input !== undefined || values.patch !== undefined || values.output !== undefined))
|
|
108
186
|
throw new Error(help);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
: positionals;
|
|
187
|
+
if (!usesLegacyPaths && (!values.input || !values.patch || (!dryRun && !values.output))) throw new Error(help);
|
|
188
|
+
const [original, patch, output] = usesLegacyPaths ? positionals : [values.input, values.patch, values.output];
|
|
112
189
|
if (!original || !patch) throw new Error(help);
|
|
113
190
|
if (!dryRun) {
|
|
114
191
|
if (!output) throw new Error(help);
|
|
115
192
|
if ((await sameFile(original, output)) || (await sameFile(patch, output)))
|
|
116
|
-
throw new Error(
|
|
193
|
+
throw new Error("输出路径不能覆盖原始 ROM 或补丁包。");
|
|
117
194
|
}
|
|
118
195
|
const [originalBytes, patchBytes] = await Promise.all([readFile(original), readFile(patch)]);
|
|
119
196
|
const info = readPatchInfo(patchBytes);
|
|
@@ -121,9 +198,9 @@ const main = async (): Promise<void> => {
|
|
|
121
198
|
if (!dryRun) {
|
|
122
199
|
if (!output) throw new Error(help);
|
|
123
200
|
const destination = join(await realpath(dirname(resolve(output))), basename(output));
|
|
124
|
-
const temporary = await mkdtemp(join(dirname(destination),
|
|
201
|
+
const temporary = await mkdtemp(join(dirname(destination), ".nitro-patcher-"));
|
|
125
202
|
try {
|
|
126
|
-
const file = join(temporary,
|
|
203
|
+
const file = join(temporary, "output.nds");
|
|
127
204
|
await writeFile(file, result.buffer);
|
|
128
205
|
await rename(file, destination);
|
|
129
206
|
} finally {
|
|
@@ -131,7 +208,7 @@ const main = async (): Promise<void> => {
|
|
|
131
208
|
}
|
|
132
209
|
}
|
|
133
210
|
const { returnValue, inputMd5, outputMd5 } = result;
|
|
134
|
-
if (
|
|
211
|
+
if (options.json) {
|
|
135
212
|
console.log(
|
|
136
213
|
JSON.stringify({
|
|
137
214
|
returnValue,
|
|
@@ -147,23 +224,23 @@ const main = async (): Promise<void> => {
|
|
|
147
224
|
const sections: string[] = [];
|
|
148
225
|
if (info.metadata) {
|
|
149
226
|
const metadata = formatMetadata(info.metadata);
|
|
150
|
-
if (metadata) sections.push(
|
|
227
|
+
if (metadata) sections.push(`补丁信息:\n\n${metadata}`);
|
|
151
228
|
}
|
|
152
229
|
if (info.readme) {
|
|
153
230
|
const content = safeTerminalText(info.readme.content);
|
|
154
231
|
const rendered =
|
|
155
|
-
info.readme.format ===
|
|
232
|
+
info.readme.format === "markdown"
|
|
156
233
|
? render(content, {
|
|
157
234
|
width: process.stdout.columns ?? 80,
|
|
158
235
|
color: Boolean(process.stdout.isTTY),
|
|
159
236
|
hyperlinks: false,
|
|
160
237
|
})
|
|
161
238
|
: content;
|
|
162
|
-
sections.push(`补丁说明:\n\n${rendered.
|
|
239
|
+
sections.push(`补丁说明:\n\n${rendered.trim()}`);
|
|
163
240
|
}
|
|
164
|
-
if (dryRun) sections.push(
|
|
241
|
+
if (dryRun) sections.push("试运行完成,未写入输出 ROM。");
|
|
165
242
|
sections.push(`${returnValue}\n\n原始 ROM 的 MD5:${inputMd5}\n生成 ROM 的 MD5:${outputMd5}`);
|
|
166
|
-
console.log(sections.join(
|
|
243
|
+
console.log(sections.join("\n\n"));
|
|
167
244
|
};
|
|
168
245
|
|
|
169
246
|
main().catch((error: unknown) => {
|
package/src/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export * from
|
|
2
|
-
export * from
|
|
1
|
+
export * from "./nitro-helper";
|
|
2
|
+
export * from "./nitro-patch-helper";
|
package/src/nitro/banner.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
// Port of NitroHelper/Banner.cs. Copyright Xzonn and upstream contributors, GPL-3.0.
|
|
2
|
-
import { bytes, range } from
|
|
3
|
-
import { crc16 } from
|
|
2
|
+
import { bytes, range } from "./binary";
|
|
3
|
+
import { crc16 } from "./crc";
|
|
4
4
|
|
|
5
5
|
const titles = [
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
"japaneseTitle",
|
|
7
|
+
"englishTitle",
|
|
8
|
+
"frenchTitle",
|
|
9
|
+
"germanTitle",
|
|
10
|
+
"italianTitle",
|
|
11
|
+
"spanishTitle",
|
|
12
|
+
"chineseTitle",
|
|
13
|
+
"koreanTitle",
|
|
14
14
|
] as const;
|
|
15
15
|
export class Banner {
|
|
16
16
|
version: number;
|
|
@@ -18,21 +18,20 @@ export class Banner {
|
|
|
18
18
|
bannerCRC: boolean;
|
|
19
19
|
tileData: Buffer;
|
|
20
20
|
palette: Buffer;
|
|
21
|
-
japaneseTitle =
|
|
22
|
-
englishTitle =
|
|
23
|
-
frenchTitle =
|
|
24
|
-
germanTitle =
|
|
25
|
-
italianTitle =
|
|
26
|
-
spanishTitle =
|
|
27
|
-
chineseTitle =
|
|
28
|
-
koreanTitle =
|
|
21
|
+
japaneseTitle = "";
|
|
22
|
+
englishTitle = "";
|
|
23
|
+
frenchTitle = "";
|
|
24
|
+
germanTitle = "";
|
|
25
|
+
italianTitle = "";
|
|
26
|
+
spanishTitle = "";
|
|
27
|
+
chineseTitle = "";
|
|
28
|
+
koreanTitle = "";
|
|
29
29
|
constructor(input: Uint8Array, offset = 0, hardSize = 0) {
|
|
30
|
-
const data = bytes(input,
|
|
31
|
-
this.version = range(data, offset, 2,
|
|
30
|
+
const data = bytes(input, "banner");
|
|
31
|
+
this.version = range(data, offset, 2, "banner").readUInt16LE();
|
|
32
32
|
let size = this.getDefSize(hardSize);
|
|
33
|
-
if (this.version >>> 8 === 1)
|
|
34
|
-
|
|
35
|
-
this.raw = Buffer.from(range(data, offset, size, 'banner'));
|
|
33
|
+
if (this.version >>> 8 === 1) size = hardSize > 0 && hardSize !== 0xffffffff ? Math.min(hardSize, 0x23c0) : 0x23c0;
|
|
34
|
+
this.raw = Buffer.from(range(data, offset, size, "banner"));
|
|
36
35
|
this.bannerCRC = crc16(this.raw.subarray(0x20, 0x840)) === this.raw.readUInt16LE(2);
|
|
37
36
|
this.tileData = Buffer.from(this.raw.subarray(0x20, 0x220));
|
|
38
37
|
this.palette = Buffer.from(this.raw.subarray(0x220, 0x240));
|
|
@@ -41,9 +40,9 @@ export class Banner {
|
|
|
41
40
|
i < this.titleCount
|
|
42
41
|
? this.raw
|
|
43
42
|
.subarray(0x240 + i * 0x100, 0x340 + i * 0x100)
|
|
44
|
-
.toString(
|
|
45
|
-
.split(
|
|
46
|
-
:
|
|
43
|
+
.toString("utf16le")
|
|
44
|
+
.split("\0")[0]
|
|
45
|
+
: "";
|
|
47
46
|
}
|
|
48
47
|
get titleCount() {
|
|
49
48
|
return this.version >= 3 ? 8 : this.version >= 2 ? 7 : 6;
|
|
@@ -57,14 +56,12 @@ export class Banner {
|
|
|
57
56
|
toBuffer() {
|
|
58
57
|
const output = Buffer.from(this.raw);
|
|
59
58
|
output.writeUInt16LE(this.version);
|
|
60
|
-
if (this.tileData.length !== 512 || this.palette.length !== 32)
|
|
61
|
-
throw new RangeError('Invalid banner icon size');
|
|
59
|
+
if (this.tileData.length !== 512 || this.palette.length !== 32) throw new RangeError("Invalid banner icon size");
|
|
62
60
|
this.tileData.copy(output, 0x20);
|
|
63
61
|
this.palette.copy(output, 0x220);
|
|
64
62
|
for (let i = 0; i < this.titleCount; i++) {
|
|
65
|
-
const text = Buffer.from(this[titles[i]],
|
|
66
|
-
if (text.length > 256)
|
|
67
|
-
throw new RangeError(`Banner ${titles[i]} exceeds 128 UTF-16 code units`);
|
|
63
|
+
const text = Buffer.from(this[titles[i]], "utf16le");
|
|
64
|
+
if (text.length > 256) throw new RangeError(`Banner ${titles[i]} exceeds 128 UTF-16 code units`);
|
|
68
65
|
output.fill(0, 0x240 + i * 256, 0x340 + i * 256);
|
|
69
66
|
text.copy(output, 0x240 + i * 256);
|
|
70
67
|
}
|
package/src/nitro/binary.ts
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
export const DEFAULT_MAX_SIZE = 0x40000000;
|
|
2
2
|
|
|
3
|
-
export const bytes = (value: Uint8Array, name =
|
|
3
|
+
export const bytes = (value: Uint8Array, name = "input"): Buffer => {
|
|
4
4
|
if (!(value instanceof Uint8Array)) throw new TypeError(`${name} must be a Buffer or Uint8Array`);
|
|
5
5
|
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
-
export const range = (
|
|
9
|
-
buffer: Buffer,
|
|
10
|
-
offset: number,
|
|
11
|
-
size: number,
|
|
12
|
-
name = 'binary data',
|
|
13
|
-
): Buffer => {
|
|
8
|
+
export const range = (buffer: Buffer, offset: number, size: number, name = "binary data"): Buffer => {
|
|
14
9
|
if (
|
|
15
10
|
!Number.isSafeInteger(offset) ||
|
|
16
11
|
!Number.isSafeInteger(size) ||
|
|
@@ -18,16 +13,12 @@ export const range = (
|
|
|
18
13
|
size < 0 ||
|
|
19
14
|
offset + size > buffer.length
|
|
20
15
|
) {
|
|
21
|
-
throw new RangeError(
|
|
22
|
-
`Invalid ${name} range: offset=${offset}, size=${size}, available=${buffer.length}`,
|
|
23
|
-
);
|
|
16
|
+
throw new RangeError(`Invalid ${name} range: offset=${offset}, size=${size}, available=${buffer.length}`);
|
|
24
17
|
}
|
|
25
18
|
return buffer.subarray(offset, offset + size);
|
|
26
19
|
};
|
|
27
20
|
|
|
28
|
-
export const align = (value: number, alignment = 0x200): number =>
|
|
29
|
-
return Math.ceil(value / alignment) * alignment;
|
|
30
|
-
};
|
|
21
|
+
export const align = (value: number, alignment = 0x200): number => Math.ceil(value / alignment) * alignment;
|
|
31
22
|
|
|
32
23
|
/** Seekable, bounded in-memory writer; unwritten gaps have MemoryStream's zero fill. */
|
|
33
24
|
export class BinaryWriter {
|
|
@@ -37,7 +28,7 @@ export class BinaryWriter {
|
|
|
37
28
|
length: number;
|
|
38
29
|
constructor(initialSize = 65536, maxSize = DEFAULT_MAX_SIZE) {
|
|
39
30
|
if (!Number.isSafeInteger(maxSize) || maxSize < 1 || maxSize > 0xffffffff)
|
|
40
|
-
throw new RangeError(
|
|
31
|
+
throw new RangeError("Invalid output size limit");
|
|
41
32
|
this.maxSize = maxSize;
|
|
42
33
|
this.buffer = Buffer.alloc(Math.min(initialSize, maxSize));
|
|
43
34
|
this.position = 0;
|
|
@@ -55,8 +46,7 @@ export class BinaryWriter {
|
|
|
55
46
|
|
|
56
47
|
write(data: Uint8Array) {
|
|
57
48
|
data = bytes(data);
|
|
58
|
-
if (!Number.isSafeInteger(this.position) || this.position < 0)
|
|
59
|
-
throw new RangeError('Invalid writer position');
|
|
49
|
+
if (!Number.isSafeInteger(this.position) || this.position < 0) throw new RangeError("Invalid writer position");
|
|
60
50
|
this.ensure(this.position + data.length);
|
|
61
51
|
bytes(data).copy(this.buffer, this.position);
|
|
62
52
|
this.position += data.length;
|
package/src/nitro/blz.ts
CHANGED
|
@@ -7,7 +7,7 @@ export interface Arm9Header {
|
|
|
7
7
|
// Native port of Xzonn/BlzHelper BLZ.cs (MIT), including overwrite-safe prefix.
|
|
8
8
|
// Compression returns an empty Buffer when no smaller representation exists.
|
|
9
9
|
export const decompressBLZ = (input: Buffer, maxSize = 0x10000000): Buffer => {
|
|
10
|
-
if (input.length < 8) throw new Error(
|
|
10
|
+
if (input.length < 8) throw new Error("BLZ footer is truncated");
|
|
11
11
|
const diff = input.readUInt32LE(input.length - 4);
|
|
12
12
|
const packed = input.readUInt32LE(input.length - 8),
|
|
13
13
|
header = packed >>> 24,
|
|
@@ -15,7 +15,7 @@ export const decompressBLZ = (input: Buffer, maxSize = 0x10000000): Buffer => {
|
|
|
15
15
|
const size = input.length + diff,
|
|
16
16
|
prefix = input.length - length;
|
|
17
17
|
if (header < 8 || header > length || length > input.length || size > maxSize)
|
|
18
|
-
throw new Error(
|
|
18
|
+
throw new Error("Invalid BLZ size or footer");
|
|
19
19
|
const out = Buffer.alloc(size);
|
|
20
20
|
input.copy(out, 0, 0, prefix);
|
|
21
21
|
let src = input.length - header,
|
|
@@ -24,16 +24,15 @@ export const decompressBLZ = (input: Buffer, maxSize = 0x10000000): Buffer => {
|
|
|
24
24
|
const flags = input[--src];
|
|
25
25
|
for (let bit = 128; bit && src > prefix; bit >>= 1) {
|
|
26
26
|
if (!(flags & bit)) {
|
|
27
|
-
if (dst <= prefix) throw new Error(
|
|
27
|
+
if (dst <= prefix) throw new Error("BLZ output overflow");
|
|
28
28
|
out[--dst] = input[--src];
|
|
29
29
|
} else {
|
|
30
|
-
if (src - 2 < prefix) throw new Error(
|
|
30
|
+
if (src - 2 < prefix) throw new Error("Truncated BLZ reference");
|
|
31
31
|
const high = input[--src],
|
|
32
32
|
low = input[--src];
|
|
33
33
|
const count = (high >>> 4) + 3,
|
|
34
34
|
offset = (((high & 15) << 8) | low) + 3;
|
|
35
|
-
if (dst - count < prefix || dst - 1 + offset >= size)
|
|
36
|
-
throw new Error('Invalid BLZ reference');
|
|
35
|
+
if (dst - count < prefix || dst - 1 + offset >= size) throw new Error("Invalid BLZ reference");
|
|
37
36
|
for (let j = 0; j < count; j++) {
|
|
38
37
|
--dst;
|
|
39
38
|
out[dst] = out[dst + offset];
|
|
@@ -41,12 +40,12 @@ export const decompressBLZ = (input: Buffer, maxSize = 0x10000000): Buffer => {
|
|
|
41
40
|
}
|
|
42
41
|
}
|
|
43
42
|
}
|
|
44
|
-
if (dst !== prefix) throw new Error(
|
|
43
|
+
if (dst !== prefix) throw new Error("BLZ decompressed size mismatch");
|
|
45
44
|
return out;
|
|
46
45
|
};
|
|
47
46
|
|
|
48
47
|
export const compressBLZ = (input: Buffer): Buffer => {
|
|
49
|
-
if (input.length > 0xffffff) throw new Error(
|
|
48
|
+
if (input.length > 0xffffff) throw new Error("BLZ input exceeds 24-bit format limit");
|
|
50
49
|
const temp = Buffer.alloc(input.length);
|
|
51
50
|
let src = input.length,
|
|
52
51
|
dst = input.length;
|
|
@@ -131,31 +130,24 @@ export const compressBLZ = (input: Buffer): Buffer => {
|
|
|
131
130
|
return out;
|
|
132
131
|
};
|
|
133
132
|
|
|
134
|
-
export const decompressArm9 = (
|
|
135
|
-
data
|
|
136
|
-
header: Arm9Header,
|
|
137
|
-
): { compressed: boolean; data: Buffer } => {
|
|
138
|
-
if (data.length < 0x18) throw new Error('ARM9 data too short');
|
|
133
|
+
export const decompressArm9 = (data: Buffer, header: Arm9Header): { compressed: boolean; data: Buffer } => {
|
|
134
|
+
if (data.length < 0x18) throw new Error("ARM9 data too short");
|
|
139
135
|
const nitro = data.readUInt32LE(0xc) === 0xdec00621 ? 12 : 0;
|
|
140
136
|
const init = header.reserved2.readUInt32LE(0) & 0x3fff;
|
|
141
137
|
const ptr = init ? data.readUInt32LE(init + 0x14) : header.ARM9ramAddress + header.ARM9size;
|
|
142
|
-
const compressed =
|
|
143
|
-
ptr > header.ARM9ramAddress && ptr + nitro >= header.ARM9ramAddress + data.length;
|
|
138
|
+
const compressed = ptr > header.ARM9ramAddress && ptr + nitro >= header.ARM9ramAddress + data.length;
|
|
144
139
|
return {
|
|
145
140
|
compressed,
|
|
146
141
|
data: compressed
|
|
147
|
-
? Buffer.concat([
|
|
148
|
-
decompressBLZ(data.subarray(0, data.length - nitro)),
|
|
149
|
-
data.subarray(data.length - nitro),
|
|
150
|
-
])
|
|
142
|
+
? Buffer.concat([decompressBLZ(data.subarray(0, data.length - nitro)), data.subarray(data.length - nitro)])
|
|
151
143
|
: Buffer.from(data),
|
|
152
144
|
};
|
|
153
145
|
};
|
|
154
146
|
export const compressArm9 = (data: Buffer, header: Arm9Header, postSize = 0): Buffer => {
|
|
155
|
-
if (data.length < 0x4000) throw new Error(
|
|
147
|
+
if (data.length < 0x4000) throw new Error("ARM9 secure area is truncated");
|
|
156
148
|
const nitro = data.readUInt32LE(0xc) === 0xdec00621 ? 12 : 0;
|
|
157
149
|
const packed = compressBLZ(data.subarray(0x4000, data.length - nitro));
|
|
158
|
-
if (!packed.length) throw new Error(
|
|
150
|
+
if (!packed.length) throw new Error("ARM9 BLZ compression produced no smaller representation");
|
|
159
151
|
const out = Buffer.concat([data.subarray(0, 0x4000), packed, data.subarray(data.length - nitro)]);
|
|
160
152
|
const init = header.reserved2.readUInt32LE(0) & 0x3fff;
|
|
161
153
|
if (init) out.writeUInt32LE(out.length - postSize + header.ARM9ramAddress, init + 0x14);
|