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/src/nitro/twl.ts CHANGED
@@ -1,10 +1,13 @@
1
- import { hashDigest, hmacSha1, readFileSync, verifyRsaSha1 } from "#nitro-runtime";
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
- DSi9Data: Buffer;
112
- DSi7Data: Buffer;
113
- constructor(header: TwlHeader, overlays: readonly OverlayRange[], original: Buffer) {
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 and keep valid RSA signatures; optional no$gba hash mask. */
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
- keepOriginal: boolean,
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
- const valid = verifyRsaSha1(signedData, signature);
324
+ verifyRsaSha1(signedData, signature);
281
325
  const position = writer.position;
282
326
  writer.position = 0x328;
283
327
  writer.write(digest);
284
- if (!valid) {
285
- if (!keepOriginal) {
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
  };
@@ -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 patches = readZip(patch, options);
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 rom = new NDSFile(preprocessing ? decodeXdelta(input, preprocessing, options) : input, options);
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
+ };