nitro-patcher 0.3.0 → 1.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.
- package/dist/browser.d.ts +37 -2
- package/dist/browser.js +11501 -101
- package/dist/browser.js.LEGAL.txt +664 -0
- package/dist/browser.js.map +4 -4
- package/dist/chunk-LK3JXA24.js +2067 -0
- package/dist/chunk-LK3JXA24.js.map +1 -0
- package/dist/{chunk-IGURDH5L.js → chunk-ZQM3LT5L.js} +362 -8
- package/dist/chunk-ZQM3LT5L.js.map +1 -0
- package/dist/cli.js +19 -7
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +6 -2
- package/dist/nitro-helper.d.ts +43 -134
- package/dist/nitro-helper.js +3 -1
- package/dist/nitro-patch-helper.d.ts +17 -1
- package/dist/nitro-patch-helper.js +4 -2
- package/dist/twl-complete-B2is30H0.d.ts +201 -0
- package/package.json +10 -3
- package/src/browser/buffer.ts +1 -0
- package/src/browser.ts +40 -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 +66 -3
- package/src/patch/create.ts +341 -0
- package/src/patch/xdelta3-LICENSE +201 -0
- 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/src/nitro/twl.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { hmacSha1, readFileSync, verifyRsaSha1 } from "#nitro-runtime";
|
|
2
2
|
|
|
3
3
|
import type { BinaryWriter } from "./binary";
|
|
4
|
+
import { DEFAULT_MAX_SIZE } from "./binary";
|
|
4
5
|
import type { Arm9Header } from "./blz";
|
|
5
6
|
import { compressArm9, decompressArm9 } from "./blz";
|
|
6
7
|
import type { ModcryptHeader } from "./crypto";
|
|
7
8
|
import { aes128CtrCrypt, encryptSecureArea, modcryptKey } from "./crypto";
|
|
9
|
+
import { Header } from "./header";
|
|
10
|
+
import { CompleteTWL } from "./twl-complete";
|
|
8
11
|
|
|
9
12
|
export interface TwlHeader extends ModcryptHeader {
|
|
10
13
|
digest_sector_size: number;
|
|
@@ -108,9 +111,46 @@ export class TWL {
|
|
|
108
111
|
readonly Header2Data: Buffer[] | null;
|
|
109
112
|
readonly Overlays9Sha1Hmac: Buffer[];
|
|
110
113
|
readonly twlEncrypted: boolean;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
readonly complete: CompleteTWL | undefined;
|
|
115
|
+
private dsi9Data: Buffer = Buffer.alloc(0);
|
|
116
|
+
private dsi7Data: Buffer = Buffer.alloc(0);
|
|
117
|
+
get DSi9Data(): Buffer {
|
|
118
|
+
return this.complete?.DSi9Data ?? this.dsi9Data;
|
|
119
|
+
}
|
|
120
|
+
set DSi9Data(data: Buffer) {
|
|
121
|
+
if (this.complete) this.complete.DSi9Data = data;
|
|
122
|
+
else this.dsi9Data = data;
|
|
123
|
+
}
|
|
124
|
+
get DSi7Data(): Buffer {
|
|
125
|
+
return this.complete?.DSi7Data ?? this.dsi7Data;
|
|
126
|
+
}
|
|
127
|
+
set DSi7Data(data: Buffer) {
|
|
128
|
+
if (this.complete) this.complete.DSi7Data = data;
|
|
129
|
+
else this.dsi7Data = data;
|
|
130
|
+
}
|
|
131
|
+
constructor(
|
|
132
|
+
header: TwlHeader,
|
|
133
|
+
overlays: readonly OverlayRange[],
|
|
134
|
+
original: Buffer,
|
|
135
|
+
options: { dsiMode?: "legacy" | "complete"; maxOutputSize?: number } = {},
|
|
136
|
+
) {
|
|
137
|
+
if (options.dsiMode === "complete") {
|
|
138
|
+
if (!(header instanceof Header)) throw new TypeError("Complete DSi handling requires a parsed Header");
|
|
139
|
+
this.complete = new CompleteTWL(
|
|
140
|
+
header,
|
|
141
|
+
overlays.map((overlay) => slice(original, overlay.offset, overlay.size, "Signed ARM9 overlay")),
|
|
142
|
+
original,
|
|
143
|
+
options.maxOutputSize ?? DEFAULT_MAX_SIZE,
|
|
144
|
+
);
|
|
145
|
+
this.firstDSiHashOffset = this.complete.firstDSiHashOffset;
|
|
146
|
+
this.dsiHashSize = this.complete.dsiHashSize;
|
|
147
|
+
this.Hashtable1Data = this.complete.Hashtable1Data;
|
|
148
|
+
this.Hashtable2Data = this.complete.Hashtable2Data;
|
|
149
|
+
this.Header2Data = this.complete.Header2Data;
|
|
150
|
+
this.Overlays9Sha1Hmac = this.complete.Overlays9Sha1Hmac;
|
|
151
|
+
this.twlEncrypted = this.complete.twlEncrypted;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
114
154
|
validate(header);
|
|
115
155
|
const h = header;
|
|
116
156
|
this.firstDSiHashOffset = (h.digest_ntr_size / h.digest_sector_size) * 20;
|
|
@@ -167,9 +207,11 @@ export class TWL {
|
|
|
167
207
|
}
|
|
168
208
|
}
|
|
169
209
|
importArm9iData(data: Buffer | string, offset = 0, size?: number): void {
|
|
210
|
+
if (this.complete) return this.complete.importArm9iData(data, offset, size);
|
|
170
211
|
this.DSi9Data = this.importData(data, offset, size);
|
|
171
212
|
}
|
|
172
213
|
importArm7iData(data: Buffer | string, offset = 0, size?: number): void {
|
|
214
|
+
if (this.complete) return this.complete.importArm7iData(data, offset, size);
|
|
173
215
|
this.DSi7Data = this.importData(data, offset, size);
|
|
174
216
|
}
|
|
175
217
|
private importData(data: Buffer | string, offset: number, size?: number): Buffer {
|
|
@@ -181,6 +223,7 @@ export class TWL {
|
|
|
181
223
|
return out;
|
|
182
224
|
}
|
|
183
225
|
updateOverlays9Sha1Hmac(arm9Data: Buffer, header: Arm9Header, overlays: readonly Buffer[]): Buffer {
|
|
226
|
+
if (this.complete) return this.complete.updateOverlays9Sha1Hmac(arm9Data, header, overlays);
|
|
184
227
|
if (overlays.length !== this.Overlays9Sha1Hmac.length)
|
|
185
228
|
throw new Error("Changing ARM9 overlay count is unsupported");
|
|
186
229
|
const hashes = overlays.map(hash);
|
|
@@ -203,6 +246,7 @@ export class TWL {
|
|
|
203
246
|
return unpacked.compressed ? compressArm9(unpacked.data, header, arm9Data.length - hdrptr) : unpacked.data;
|
|
204
247
|
}
|
|
205
248
|
writeTo(writer: BinaryWriter, h: TwlHeader): Buffer {
|
|
249
|
+
if (this.complete) throw new Error("Complete DSi serialization requires NDSFile.toBuffer()");
|
|
206
250
|
validate(h);
|
|
207
251
|
// Fixed DSi regions cannot be shifted safely without rewriting the header.
|
|
208
252
|
if (writer.position > h.sector_hashtable_start) {
|
|
@@ -266,32 +310,22 @@ export class TWL {
|
|
|
266
310
|
}
|
|
267
311
|
}
|
|
268
312
|
|
|
269
|
-
/** Refresh the digest
|
|
313
|
+
/** Refresh the master digest while preserving the cartridge signature. */
|
|
270
314
|
export const updateHeaderSignatures = (
|
|
271
315
|
writer: BinaryWriter,
|
|
272
316
|
header: SignedTwlHeader,
|
|
273
317
|
originalHeader: Buffer,
|
|
274
|
-
|
|
318
|
+
_keepOriginal = true,
|
|
275
319
|
): void => {
|
|
276
320
|
const signedData = Buffer.from(slice(originalHeader, 0, 0xe00, "Signed header"));
|
|
277
321
|
const digest = slice(header.hmac_digest_master, 0, 20, "Master digest");
|
|
278
322
|
const signature = slice(header.rsa_signature, 0, 128, "RSA signature");
|
|
279
323
|
digest.copy(signedData, 0x328);
|
|
280
|
-
|
|
324
|
+
verifyRsaSha1(signedData, signature);
|
|
281
325
|
const position = writer.position;
|
|
282
326
|
writer.position = 0x328;
|
|
283
327
|
writer.write(digest);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const mask = Buffer.alloc(128, 0xff);
|
|
287
|
-
mask[0] = 0;
|
|
288
|
-
mask[1] = 1;
|
|
289
|
-
mask[107] = 0;
|
|
290
|
-
hashDigest("sha1", signedData).copy(mask, 108);
|
|
291
|
-
header.rsa_signature = mask;
|
|
292
|
-
}
|
|
293
|
-
writer.position = 0xf80;
|
|
294
|
-
writer.write(header.rsa_signature);
|
|
295
|
-
}
|
|
328
|
+
writer.position = 0xf80;
|
|
329
|
+
writer.write(signature);
|
|
296
330
|
writer.position = position;
|
|
297
331
|
};
|
package/src/nitro-helper.ts
CHANGED
|
@@ -6,9 +6,10 @@ export { FileAllocationTable, FileNameTable, OverlayTable } from "./nitro/filesy
|
|
|
6
6
|
export type { FatEntry, NitroFile, NitroFolder, OverlayItem } from "./nitro/filesystem";
|
|
7
7
|
export { BinaryWriter } from "./nitro/binary";
|
|
8
8
|
export { crc16, crc32 } from "./nitro/crc";
|
|
9
|
-
export { aes128CtrCrypt, encryptSecureArea, modcryptKey } from "./nitro/crypto";
|
|
9
|
+
export { aes128CtrCrypt, decryptSecureArea, encryptSecureArea, modcryptKey } from "./nitro/crypto";
|
|
10
10
|
export type { ModcryptHeader } from "./nitro/crypto";
|
|
11
11
|
export { compressArm9, compressBLZ, decompressArm9, decompressBLZ } from "./nitro/blz";
|
|
12
12
|
export type { Arm9Header } from "./nitro/blz";
|
|
13
13
|
export { TWL, updateHeaderSignatures } from "./nitro/twl";
|
|
14
14
|
export type { OverlayRange, SignedTwlHeader, TwlHeader } from "./nitro/twl";
|
|
15
|
+
export type { DsiCheck, DsiVerification } from "./nitro/twl-complete";
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
import { hashDigest, readFile } from "#nitro-runtime";
|
|
3
3
|
|
|
4
4
|
import { bytes } from "./nitro/binary";
|
|
5
|
+
import { OverlayTable } from "./nitro/filesystem";
|
|
5
6
|
import { NDSFile } from "./nitro/nds-file";
|
|
7
|
+
import type { DsiVerification } from "./nitro/twl-complete";
|
|
6
8
|
import { decodeXdelta } from "./patch/xdelta";
|
|
7
|
-
import { extractZipEntry as extractEntry, readZip } from "./patch/zip";
|
|
9
|
+
import { extractZipEntry as extractEntry, readZip, readZipEntries } from "./patch/zip";
|
|
8
10
|
|
|
9
11
|
export const PatchReturnValue = Object.freeze({
|
|
10
12
|
SUCCESS: "SUCCESS",
|
|
@@ -19,6 +21,7 @@ export interface PatchResult {
|
|
|
19
21
|
/** MD5 of the original input, before preprocessing. */
|
|
20
22
|
inputMd5: string;
|
|
21
23
|
outputMd5: string;
|
|
24
|
+
dsi?: DsiVerification;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export interface PatchOptions {
|
|
@@ -33,6 +36,7 @@ export interface PatchOptions {
|
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
export interface PatchMetadata {
|
|
39
|
+
formatVersion?: number;
|
|
36
40
|
id?: string;
|
|
37
41
|
author?: string;
|
|
38
42
|
name?: string;
|
|
@@ -77,6 +81,13 @@ const readMetadata = (files: Map<string, Buffer>): PatchMetadata | null => {
|
|
|
77
81
|
|
|
78
82
|
const value = metadata as Record<string, unknown>;
|
|
79
83
|
const result: PatchMetadata = {};
|
|
84
|
+
if (value.formatVersion !== undefined) {
|
|
85
|
+
const version = value.formatVersion;
|
|
86
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1)
|
|
87
|
+
throw new Error("metadata.json formatVersion must be a positive safe integer");
|
|
88
|
+
if (version !== 1 && version !== 2) throw new Error(`Unsupported XZP format version: ${version}`);
|
|
89
|
+
result.formatVersion = version;
|
|
90
|
+
}
|
|
80
91
|
for (const field of ["id", "author", "name", "homepage", "version"] as const) {
|
|
81
92
|
const fieldValue = value[field];
|
|
82
93
|
if (fieldValue === undefined) continue;
|
|
@@ -146,10 +157,18 @@ export const readPatchInfoFile = async (patchPath: string, options: PatchOptions
|
|
|
146
157
|
/** Apply an existing NitroPatcher ZIP package entirely in memory. */
|
|
147
158
|
export const patchBuffer = (original: Uint8Array, patch: Uint8Array, options: PatchOptions = {}): PatchResult => {
|
|
148
159
|
const input = bytes(original, "original ROM");
|
|
149
|
-
const
|
|
160
|
+
const entries = readZipEntries(patch, options);
|
|
161
|
+
const patches = new Map([...entries].map(([name, entry]) => [name, entry.data]));
|
|
162
|
+
const formatVersion = readMetadata(patches)?.formatVersion ?? 1;
|
|
150
163
|
const inputMd5 = md5(input);
|
|
151
164
|
const preprocessing = patches.get(`preprocessing/${inputMd5}.xdelta`);
|
|
152
|
-
const
|
|
165
|
+
const romOptions = { ...options, dsiMode: formatVersion === 2 ? ("complete" as const) : ("legacy" as const) };
|
|
166
|
+
const rom = new NDSFile(preprocessing ? decodeXdelta(input, preprocessing, options) : input, romOptions);
|
|
167
|
+
if (formatVersion === 2)
|
|
168
|
+
for (const name of ["arm9i.bin", "arm7i.bin"]) {
|
|
169
|
+
if ((patches.has(name) || patches.has(`xdelta/${name}`)) && !rom.hasFile(name))
|
|
170
|
+
throw new Error(`${name} requires a complete DSi-enhanced cartridge`);
|
|
171
|
+
}
|
|
153
172
|
let matched = true;
|
|
154
173
|
const md5File = patches.get("md5.txt");
|
|
155
174
|
if (md5File) {
|
|
@@ -171,12 +190,54 @@ export const patchBuffer = (original: Uint8Array, patch: Uint8Array, options: Pa
|
|
|
171
190
|
const replacement = delta ? decodeXdelta(rom.getFile(path), delta, options) : patches.get(normalized);
|
|
172
191
|
if (replacement) rom.replaceFile(path, replacement);
|
|
173
192
|
}
|
|
193
|
+
if (formatVersion === 2) {
|
|
194
|
+
for (const name of ["overarm9.bin", "overarm7.bin"])
|
|
195
|
+
if (!rom.hasFile(name)) {
|
|
196
|
+
const delta = patches.get(`xdelta/${name}`);
|
|
197
|
+
const replacement = delta ? decodeXdelta(Buffer.alloc(0), delta, options) : patches.get(name);
|
|
198
|
+
if (replacement) rom.replaceFile(name, replacement);
|
|
199
|
+
}
|
|
200
|
+
const candidates = new Map<string, { path: string; data: Buffer; delta: boolean }>();
|
|
201
|
+
for (const [name, entry] of entries) {
|
|
202
|
+
const delta = name.startsWith("xdelta/");
|
|
203
|
+
const target = delta ? name.slice(7) : name;
|
|
204
|
+
if ((!target.startsWith("data/") && !target.startsWith("overlay/")) || rom.hasFile(target)) continue;
|
|
205
|
+
if (!delta && entries.has(`xdelta/${target}`)) continue;
|
|
206
|
+
candidates.set(target, { path: delta ? entry.path.slice(7) : entry.path, data: entry.data, delta });
|
|
207
|
+
}
|
|
208
|
+
if (candidates.size && (patches.has("fnt.bin") || patches.has("xdelta/fnt.bin")))
|
|
209
|
+
throw new Error("Adding files conflicts with an fnt.bin replacement");
|
|
210
|
+
const overlayTables = new Map<string, OverlayTable>();
|
|
211
|
+
for (const name of ["overarm9.bin", "overarm7.bin"])
|
|
212
|
+
if (rom.hasFile(name)) {
|
|
213
|
+
const raw = rom.getFile(name);
|
|
214
|
+
overlayTables.set(name, new OverlayTable(raw, 0, raw.length, name === "overarm9.bin"));
|
|
215
|
+
}
|
|
216
|
+
const additions = [...candidates.values()].sort((a, b) => Buffer.compare(Buffer.from(a.path), Buffer.from(b.path)));
|
|
217
|
+
for (const entry of additions) {
|
|
218
|
+
const content = entry.delta ? decodeXdelta(Buffer.alloc(0), entry.data, options) : entry.data;
|
|
219
|
+
if (entry.path.toLowerCase().startsWith("overlay/")) {
|
|
220
|
+
const match = /^overlay\/overlay(7)?_(\d{4,})\.bin$/i.exec(entry.path);
|
|
221
|
+
const overlayId = match?.[2];
|
|
222
|
+
const arm9 = !match?.[1];
|
|
223
|
+
if (!overlayId || String(Number(overlayId)).padStart(4, "0") !== overlayId)
|
|
224
|
+
throw new Error(`Invalid overlay path: ${entry.path}`);
|
|
225
|
+
const name = arm9 ? "overarm9.bin" : "overarm7.bin";
|
|
226
|
+
const table = overlayTables.get(name);
|
|
227
|
+
if (!table) throw new Error(`New overlay requires ${name}: ${entry.path}`);
|
|
228
|
+
const metadata = table.entries.find((item) => item.overlayId === Number(overlayId));
|
|
229
|
+
if (!metadata) throw new Error(`New overlay is missing its table entry: ${entry.path}`);
|
|
230
|
+
rom.addOverlay(arm9, metadata, content);
|
|
231
|
+
} else rom.addFile(entry.path, content);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
174
234
|
const buffer = rom.toBuffer();
|
|
175
235
|
return {
|
|
176
236
|
buffer,
|
|
177
237
|
returnValue: matched ? PatchReturnValue.SUCCESS : PatchReturnValue.MD5_MISMATCH,
|
|
178
238
|
inputMd5,
|
|
179
239
|
outputMd5: md5(buffer),
|
|
240
|
+
...(formatVersion === 2 && rom.header.unitCode === 2 && { dsi: new NDSFile(buffer, romOptions).verifyDsi() }),
|
|
180
241
|
};
|
|
181
242
|
};
|
|
182
243
|
|
|
@@ -202,5 +263,7 @@ export const PatchHelper = Object.freeze({
|
|
|
202
263
|
readPatchReadme,
|
|
203
264
|
readPatchReadmeFile,
|
|
204
265
|
});
|
|
266
|
+
export { createPatch } from "./patch/create";
|
|
267
|
+
export type { CreatePatchOptions, CreatePatchResult } from "./patch/create";
|
|
205
268
|
export { decodeXdelta } from "./patch/xdelta";
|
|
206
269
|
export type { XdeltaOptions } from "./patch/xdelta";
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { encode } from "@limrun/xdelta3-wasm";
|
|
2
|
+
import { zipSync } from "fflate";
|
|
3
|
+
|
|
4
|
+
import { hashDigest } from "#nitro-runtime";
|
|
5
|
+
|
|
6
|
+
import { decodeXdelta } from "./xdelta";
|
|
7
|
+
import { bytes } from "../nitro/binary";
|
|
8
|
+
import { crc16 } from "../nitro/crc";
|
|
9
|
+
import { decryptSecureArea, encryptSecureArea } from "../nitro/crypto";
|
|
10
|
+
import type { NitroFolder } from "../nitro/filesystem";
|
|
11
|
+
import { Header } from "../nitro/header";
|
|
12
|
+
import { NDSFile } from "../nitro/nds-file";
|
|
13
|
+
import { patchBuffer } from "../nitro-patch-helper";
|
|
14
|
+
import type { PatchMetadata, PatchOptions } from "../nitro-patch-helper";
|
|
15
|
+
|
|
16
|
+
export interface CreatePatchOptions extends PatchOptions {
|
|
17
|
+
metadata?: Omit<PatchMetadata, "formatVersion">;
|
|
18
|
+
readme?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CreatePatchResult {
|
|
22
|
+
buffer: Uint8Array<ArrayBuffer>;
|
|
23
|
+
changedFiles: number;
|
|
24
|
+
warnings: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const normalizePath = (path: string): string => path.replaceAll("\\", "/").toLowerCase();
|
|
28
|
+
const listFiles = (rom: NDSFile) => new Map(rom.listFiles().map((file) => [normalizePath(file.path), file]));
|
|
29
|
+
const listDirectories = (folder: NitroFolder, prefix = "data"): string[] =>
|
|
30
|
+
[prefix, ...folder.folders.flatMap((child) => listDirectories(child, `${prefix}/${child.name}`))].sort();
|
|
31
|
+
const getUnnamedFiles = (rom: NDSFile): Buffer[] => {
|
|
32
|
+
const namedIds = new Set(
|
|
33
|
+
rom
|
|
34
|
+
.listFiles()
|
|
35
|
+
.filter((file) => /^(data|overlay)\//i.test(file.path))
|
|
36
|
+
.map((file) => file.id),
|
|
37
|
+
);
|
|
38
|
+
return rom.fatTable.entries
|
|
39
|
+
.filter((entry) => !namedIds.has(entry.id))
|
|
40
|
+
.map((entry) => rom.original.subarray(entry.offset, entry.offset + entry.size));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Repacking assigns physical locations, capacities and derived checksums.
|
|
44
|
+
const normalizeHeader = (rom: NDSFile): Buffer => {
|
|
45
|
+
const header = new Header(rom.getFile("header.bin"));
|
|
46
|
+
header.size = 0x20000;
|
|
47
|
+
for (const field of [
|
|
48
|
+
"ARM9romOffset",
|
|
49
|
+
"ARM9size",
|
|
50
|
+
"ARM7romOffset",
|
|
51
|
+
"ARM7size",
|
|
52
|
+
"FNToffset",
|
|
53
|
+
"FNTsize",
|
|
54
|
+
"FAToffset",
|
|
55
|
+
"FATsize",
|
|
56
|
+
"ARM9overlayOffset",
|
|
57
|
+
"ARM9overlaySize",
|
|
58
|
+
"ARM7overlayOffset",
|
|
59
|
+
"ARM7overlaySize",
|
|
60
|
+
"bannerOffset",
|
|
61
|
+
"secureCRC16",
|
|
62
|
+
"ROMsize",
|
|
63
|
+
"logoCRC16",
|
|
64
|
+
"headerCRC16",
|
|
65
|
+
] as const)
|
|
66
|
+
header[field] = 0;
|
|
67
|
+
if (header.unitCode === 2) {
|
|
68
|
+
for (const field of [
|
|
69
|
+
"ntr_region_end",
|
|
70
|
+
"twl_region_start",
|
|
71
|
+
"dsi9_rom_offset",
|
|
72
|
+
"dsi7_rom_offset",
|
|
73
|
+
"digest_ntr_start",
|
|
74
|
+
"digest_ntr_size",
|
|
75
|
+
"digest_twl_start",
|
|
76
|
+
"digest_twl_size",
|
|
77
|
+
"sector_hashtable_start",
|
|
78
|
+
"sector_hashtable_size",
|
|
79
|
+
"block_hashtable_start",
|
|
80
|
+
"block_hashtable_size",
|
|
81
|
+
"total_rom_size",
|
|
82
|
+
"modcrypt1_start",
|
|
83
|
+
"modcrypt1_size",
|
|
84
|
+
"modcrypt2_start",
|
|
85
|
+
"modcrypt2_size",
|
|
86
|
+
] as const)
|
|
87
|
+
header[field] = 0;
|
|
88
|
+
for (const field of [
|
|
89
|
+
"hmac_arm9",
|
|
90
|
+
"hmac_arm7",
|
|
91
|
+
"hmac_digest_master",
|
|
92
|
+
"hmac_icon_title",
|
|
93
|
+
"hmac_arm9i",
|
|
94
|
+
"hmac_arm7i",
|
|
95
|
+
"hmac_arm9_no_secure",
|
|
96
|
+
"rsa_signature",
|
|
97
|
+
] as const)
|
|
98
|
+
header[field] = Buffer.alloc(header[field].length);
|
|
99
|
+
}
|
|
100
|
+
return header.raw;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const compareContent = (actual: Buffer, expected: Buffer, path: string, warnings: string[]): void => {
|
|
104
|
+
if (!actual.equals(expected)) warnings.push(`重建内容与修改后的 ROM 不同:${path}`);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const errorMessage = (reason: unknown): string => (reason instanceof Error ? reason.message : String(reason));
|
|
108
|
+
|
|
109
|
+
const parseRom = (input: Buffer, label: string, warnings: string[]): NDSFile => {
|
|
110
|
+
try {
|
|
111
|
+
const rom = new NDSFile(input, { dsiMode: "complete" });
|
|
112
|
+
rom.listFiles();
|
|
113
|
+
return rom;
|
|
114
|
+
} catch (reason) {
|
|
115
|
+
try {
|
|
116
|
+
const rom = new NDSFile(input);
|
|
117
|
+
rom.listFiles();
|
|
118
|
+
warnings.push(`${label} 使用常规模式解析:${errorMessage(reason)}`);
|
|
119
|
+
return rom;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
throw new Error(`无法解析${label}:${errorMessage(error)}`, { cause: error });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const createDelta = async (before: Buffer, after: Buffer): Promise<Buffer> => {
|
|
127
|
+
const chunks: Buffer[] = [];
|
|
128
|
+
async function* targetChunks() {
|
|
129
|
+
yield after;
|
|
130
|
+
}
|
|
131
|
+
for await (const chunk of encode(targetChunks(), {
|
|
132
|
+
size: before.length,
|
|
133
|
+
read(offset, into) {
|
|
134
|
+
const part = before.subarray(offset, offset + into.length);
|
|
135
|
+
into.set(part);
|
|
136
|
+
return part.length;
|
|
137
|
+
},
|
|
138
|
+
}))
|
|
139
|
+
chunks.push(Buffer.from(chunk));
|
|
140
|
+
return Buffer.concat(chunks);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const verifyContent = (actual: NDSFile, expected: NDSFile, arm9: Buffer, warnings: string[]): void => {
|
|
144
|
+
const actualFiles = listFiles(actual);
|
|
145
|
+
const expectedFiles = listFiles(expected);
|
|
146
|
+
if ([...actualFiles.keys()].sort().join("\n") !== [...expectedFiles.keys()].sort().join("\n"))
|
|
147
|
+
warnings.push("重建 ROM 的文件路径集合与修改后的 ROM 不同。");
|
|
148
|
+
const actualDirectories = new Set(listDirectories(actual.data));
|
|
149
|
+
const expectedDirectories = new Set(listDirectories(expected.data));
|
|
150
|
+
for (const path of expectedDirectories)
|
|
151
|
+
if (!actualDirectories.has(path)) {
|
|
152
|
+
if ([...expectedFiles.keys()].some((name) => name.startsWith(normalizePath(`${path}/`))))
|
|
153
|
+
warnings.push(`重建 ROM 缺少目录:${path}`);
|
|
154
|
+
else warnings.push(`新增空目录已忽略:${path}`);
|
|
155
|
+
}
|
|
156
|
+
for (const path of actualDirectories)
|
|
157
|
+
if (!expectedDirectories.has(path)) {
|
|
158
|
+
if ([...actualFiles.keys()].some((name) => name.startsWith(normalizePath(`${path}/`))))
|
|
159
|
+
warnings.push(`重建 ROM 保留了原始目录:${path}`);
|
|
160
|
+
else warnings.push(`已保留原始 ROM 的空目录:${path}`);
|
|
161
|
+
}
|
|
162
|
+
for (const [path, file] of expectedFiles) {
|
|
163
|
+
if (["header.bin", "fnt.bin", "fat.bin", "banner.bin", "overarm9.bin", "overarm7.bin"].includes(path)) continue;
|
|
164
|
+
if (!actualFiles.has(path)) continue;
|
|
165
|
+
let content = path === "arm9.bin" ? Buffer.from(arm9) : expected.getFile(file.path);
|
|
166
|
+
if (path === "arm9.bin" && expected.header.unitCode === 2) {
|
|
167
|
+
const plain = expected.header.decrypted ? content : decryptSecureArea(expected.header.gameCode, content);
|
|
168
|
+
plain.writeUInt16LE(crc16(plain.subarray(0x10, 0x800)), 0xe);
|
|
169
|
+
content = expected.header.decrypted ? plain : encryptSecureArea(expected.header.gameCode, plain);
|
|
170
|
+
}
|
|
171
|
+
compareContent(actual.getFile(file.path), content, file.path, warnings);
|
|
172
|
+
}
|
|
173
|
+
compareContent(actual.banner.toBuffer(), expected.banner.toBuffer(), "banner.bin", warnings);
|
|
174
|
+
compareContent(normalizeHeader(actual), normalizeHeader(expected), "header.bin", warnings);
|
|
175
|
+
compareContent(actual.header.dlp_signature, expected.header.dlp_signature, "Download Play 签名", warnings);
|
|
176
|
+
for (const [left, right] of [
|
|
177
|
+
[actual.overlay9Table, expected.overlay9Table],
|
|
178
|
+
[actual.overlay7Table, expected.overlay7Table],
|
|
179
|
+
]) {
|
|
180
|
+
if (left.entries.length !== right.entries.length) warnings.push("重建 ROM 的覆盖模块数量与修改后的 ROM 不同。");
|
|
181
|
+
for (const entry of right.entries) {
|
|
182
|
+
const other = left.entries.find((item) => item.overlayId === entry.overlayId);
|
|
183
|
+
if (!other) {
|
|
184
|
+
warnings.push(`重建 ROM 缺少覆盖模块:${entry.overlayId}`);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
for (const field of [
|
|
188
|
+
"ramAddress",
|
|
189
|
+
"ramSize",
|
|
190
|
+
"bssSize",
|
|
191
|
+
"staticInitialiserStartAddress",
|
|
192
|
+
"staticInitialiserEndAddress",
|
|
193
|
+
"reserved",
|
|
194
|
+
] as const)
|
|
195
|
+
if (other[field] !== entry[field])
|
|
196
|
+
warnings.push(`重建覆盖模块 ${entry.overlayId} 的 ${field} 与修改后的 ROM 不同。`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const actualUnnamed = getUnnamedFiles(actual);
|
|
200
|
+
const expectedUnnamed = getUnnamedFiles(expected);
|
|
201
|
+
if (actualUnnamed.length !== expectedUnnamed.length)
|
|
202
|
+
warnings.push("重建 ROM 的未命名 FAT 项数量与修改后的 ROM 不同。");
|
|
203
|
+
actualUnnamed.forEach((content, index) => {
|
|
204
|
+
if (expectedUnnamed[index]) compareContent(content, expectedUnnamed[index], `未命名 FAT 项 ${index}`, warnings);
|
|
205
|
+
});
|
|
206
|
+
if (actual.header.unitCode === 2)
|
|
207
|
+
for (const [name, check] of Object.entries(actual.verifyDsi()))
|
|
208
|
+
if (check.status === "invalid") warnings.push(`重建 ROM 的 DSi 校验结果为 invalid:${name}`);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const verifyPatch = (
|
|
212
|
+
input: Buffer,
|
|
213
|
+
archive: Uint8Array,
|
|
214
|
+
target: NDSFile,
|
|
215
|
+
arm9: Buffer,
|
|
216
|
+
options: PatchOptions,
|
|
217
|
+
warnings: string[],
|
|
218
|
+
): boolean => {
|
|
219
|
+
let output: Buffer;
|
|
220
|
+
try {
|
|
221
|
+
output = patchBuffer(input, archive, options).buffer;
|
|
222
|
+
} catch (reason) {
|
|
223
|
+
warnings.push(`补丁应用检查未完成:${errorMessage(reason)}`);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
verifyContent(parseRom(output, "重建 ROM", warnings), target, arm9, warnings);
|
|
228
|
+
} catch (reason) {
|
|
229
|
+
warnings.push(`补丁数据核对未完成:${errorMessage(reason)}`);
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** Create an XZP; differences and application checks are returned as warnings. */
|
|
235
|
+
export const createPatch = async (
|
|
236
|
+
original: Uint8Array,
|
|
237
|
+
modified: Uint8Array,
|
|
238
|
+
options: CreatePatchOptions = {},
|
|
239
|
+
): Promise<CreatePatchResult> => {
|
|
240
|
+
const input = bytes(original, "original ROM");
|
|
241
|
+
const targetBytes = bytes(modified, "modified ROM");
|
|
242
|
+
const warnings: string[] = [];
|
|
243
|
+
const source = parseRom(input, "原始 ROM", warnings);
|
|
244
|
+
const target = parseRom(targetBytes, "修改后的 ROM", warnings);
|
|
245
|
+
let preprocessing = false;
|
|
246
|
+
if (source.header.gameCode !== target.header.gameCode)
|
|
247
|
+
warnings.push(`两个 ROM 的游戏代码不同:${source.header.gameCode} / ${target.header.gameCode}`);
|
|
248
|
+
if (source.header.unitCode !== target.header.unitCode)
|
|
249
|
+
warnings.push(`两个 ROM 的 unitCode 不同:${source.header.unitCode} / ${target.header.unitCode}`);
|
|
250
|
+
if (source.header.ROMversion !== target.header.ROMversion)
|
|
251
|
+
warnings.push(`两个 ROM 的修订版本不同:${source.header.ROMversion} / ${target.header.ROMversion}`);
|
|
252
|
+
if (source.header.gameCode !== target.header.gameCode || source.header.unitCode !== target.header.unitCode)
|
|
253
|
+
preprocessing = true;
|
|
254
|
+
const sourceFiles = listFiles(source);
|
|
255
|
+
const targetFiles = listFiles(target);
|
|
256
|
+
const headerChanged = !normalizeHeader(source).equals(normalizeHeader(target));
|
|
257
|
+
const sourceArm9 = source.getFile("arm9.bin");
|
|
258
|
+
const targetArm9 = target.getFile("arm9.bin");
|
|
259
|
+
const sourcePostData = sourceArm9.subarray(source.header.ARM9size);
|
|
260
|
+
const targetPostData = targetArm9.subarray(target.header.ARM9size);
|
|
261
|
+
if (!sourcePostData.equals(targetPostData)) warnings.push("两个 ROM 的 ARM9 尾部记录不同。");
|
|
262
|
+
for (const [label, rom, entries] of [
|
|
263
|
+
["原始 ROM", source, sourceFiles],
|
|
264
|
+
["修改后的 ROM", target, targetFiles],
|
|
265
|
+
] as const) {
|
|
266
|
+
const file = entries.get("arm9.bin")!;
|
|
267
|
+
const end = file.offset + file.size;
|
|
268
|
+
if (rom.header.nitrocode && rom.original.subarray(end, end + 4).equals(Buffer.from("2106c0de", "hex")))
|
|
269
|
+
warnings.push(`${label} 的 ARM9 含有额外尾部记录。`);
|
|
270
|
+
}
|
|
271
|
+
const targetProgram = targetArm9.subarray(0, target.header.ARM9size);
|
|
272
|
+
const arm9 =
|
|
273
|
+
sourcePostData.length && targetProgram.subarray(-sourcePostData.length).equals(sourcePostData)
|
|
274
|
+
? targetProgram
|
|
275
|
+
: Buffer.concat([targetProgram, sourcePostData]);
|
|
276
|
+
const removed = [...sourceFiles.keys()].filter((path) => !targetFiles.has(path));
|
|
277
|
+
if (removed.length) {
|
|
278
|
+
warnings.push(`修改后的 ROM 缺少原始 ROM 中的 ${removed.length} 个文件。`);
|
|
279
|
+
preprocessing = true;
|
|
280
|
+
}
|
|
281
|
+
const sourceUnnamed = getUnnamedFiles(source);
|
|
282
|
+
const targetUnnamed = getUnnamedFiles(target);
|
|
283
|
+
if (
|
|
284
|
+
sourceUnnamed.length !== targetUnnamed.length ||
|
|
285
|
+
sourceUnnamed.some((content, index) => !content.equals(targetUnnamed[index] ?? Buffer.alloc(0)))
|
|
286
|
+
) {
|
|
287
|
+
warnings.push("两个 ROM 的未命名 FAT 数据不同。");
|
|
288
|
+
preprocessing = true;
|
|
289
|
+
}
|
|
290
|
+
const entries: Record<string, Uint8Array> = {};
|
|
291
|
+
let changedFiles = 0;
|
|
292
|
+
for (const [path, file] of targetFiles) {
|
|
293
|
+
if (path === "fnt.bin" || path === "fat.bin") continue;
|
|
294
|
+
if (path === "header.bin" && !headerChanged) continue;
|
|
295
|
+
const before = sourceFiles.has(path) ? source.getFile(file.path) : Buffer.alloc(0);
|
|
296
|
+
const after = path === "arm9.bin" ? arm9 : target.getFile(file.path);
|
|
297
|
+
if (sourceFiles.has(path) && before.equals(after)) continue;
|
|
298
|
+
if (!sourceFiles.has(path) && !/^(data|overlay)\//.test(path) && !/^overarm[97]\.bin$/.test(path))
|
|
299
|
+
warnings.push(`新增系统文件:${file.path}`);
|
|
300
|
+
try {
|
|
301
|
+
const delta = await createDelta(before, after);
|
|
302
|
+
if (decodeXdelta(before, delta, options).equals(after)) entries[`xdelta/${file.path}`] = delta;
|
|
303
|
+
else {
|
|
304
|
+
warnings.push(`文件差分解码内容不同,已保存完整替换内容:${file.path}`);
|
|
305
|
+
entries[file.path] = after;
|
|
306
|
+
}
|
|
307
|
+
} catch (reason) {
|
|
308
|
+
warnings.push(`文件差分检查未完成,已保存完整替换内容:${file.path}:${errorMessage(reason)}`);
|
|
309
|
+
entries[file.path] = after;
|
|
310
|
+
}
|
|
311
|
+
changedFiles++;
|
|
312
|
+
}
|
|
313
|
+
const metadata: PatchMetadata = { ...options.metadata, formatVersion: 2 };
|
|
314
|
+
const inputMd5 = hashDigest("md5", input).toString("hex");
|
|
315
|
+
entries["metadata.json"] = Buffer.from(JSON.stringify(metadata, null, 2));
|
|
316
|
+
entries["md5.txt"] = Buffer.from(inputMd5);
|
|
317
|
+
entries["readme.md"] = Buffer.from(options.readme ?? "");
|
|
318
|
+
let archive = zipSync(entries, { level: 9 });
|
|
319
|
+
if (!preprocessing) preprocessing = !verifyPatch(input, archive, target, arm9, options, warnings);
|
|
320
|
+
if (preprocessing) {
|
|
321
|
+
try {
|
|
322
|
+
const delta = await createDelta(input, targetBytes);
|
|
323
|
+
const formatVersion = target.header.unitCode & 2 && !target.twl?.complete ? 1 : 2;
|
|
324
|
+
archive = zipSync(
|
|
325
|
+
{
|
|
326
|
+
[`preprocessing/${inputMd5}.xdelta`]: delta,
|
|
327
|
+
"metadata.json": Buffer.from(JSON.stringify({ ...metadata, formatVersion }, null, 2)),
|
|
328
|
+
"md5.txt": entries["md5.txt"],
|
|
329
|
+
"readme.md": entries["readme.md"],
|
|
330
|
+
},
|
|
331
|
+
{ level: 9 },
|
|
332
|
+
);
|
|
333
|
+
changedFiles = 1;
|
|
334
|
+
warnings.push("补丁使用整份 ROM 的预处理差分保存修改。");
|
|
335
|
+
verifyPatch(input, archive, target, targetArm9, options, warnings);
|
|
336
|
+
} catch (reason) {
|
|
337
|
+
warnings.push(`预处理差分生成未完成,已保留按文件补丁包:${errorMessage(reason)}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { buffer: new Uint8Array(archive), changedFiles, warnings };
|
|
341
|
+
};
|