nitro-patcher 0.2.0 → 0.2.1
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 +5 -7
- 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 +38 -48
- 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/nitro/twl.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { hashDigest, hmacSha1,
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
import type { Arm9Header } from
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
1
|
+
import { hashDigest, hmacSha1, readFileSync, verifyRsaSha1 } from "#nitro-runtime";
|
|
2
|
+
|
|
3
|
+
import type { BinaryWriter } from "./binary";
|
|
4
|
+
import type { Arm9Header } from "./blz";
|
|
5
|
+
import { compressArm9, decompressArm9 } from "./blz";
|
|
6
|
+
import type { ModcryptHeader } from "./crypto";
|
|
7
|
+
import { aes128CtrCrypt, encryptSecureArea, modcryptKey } from "./crypto";
|
|
7
8
|
|
|
8
9
|
export interface TwlHeader extends ModcryptHeader {
|
|
9
10
|
digest_sector_size: number;
|
|
@@ -43,8 +44,8 @@ export interface OverlayRange {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
export const HMAC_SHA1_KEY = Buffer.from(
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
"2106c0deba98ce3fa692e39d46f2ed0176e3cc08562363facad4ecdf9a6278348f6d633cfe22ca9220889723d2cfaec232678dfeca836498acfd3e3787465824",
|
|
48
|
+
"hex",
|
|
48
49
|
);
|
|
49
50
|
const hash = (data: Buffer): Buffer => hmacSha1(HMAC_SHA1_KEY, data);
|
|
50
51
|
function slice(data: Buffer, start: number, size: number, label: string): Buffer {
|
|
@@ -60,46 +61,44 @@ function slice(data: Buffer, start: number, size: number, label: string): Buffer
|
|
|
60
61
|
}
|
|
61
62
|
function validate(h: TwlHeader): void {
|
|
62
63
|
for (const field of [
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
64
|
+
"digest_ntr_start",
|
|
65
|
+
"digest_ntr_size",
|
|
66
|
+
"digest_twl_start",
|
|
67
|
+
"digest_twl_size",
|
|
68
|
+
"sector_hashtable_start",
|
|
69
|
+
"sector_hashtable_size",
|
|
70
|
+
"block_hashtable_start",
|
|
71
|
+
"block_hashtable_size",
|
|
72
|
+
"dsi9_rom_offset",
|
|
73
|
+
"dsi9_size",
|
|
74
|
+
"dsi7_rom_offset",
|
|
75
|
+
"dsi7_size",
|
|
76
|
+
"modcrypt1_start",
|
|
77
|
+
"modcrypt1_size",
|
|
78
|
+
"modcrypt2_start",
|
|
79
|
+
"modcrypt2_size",
|
|
80
|
+
"total_rom_size",
|
|
80
81
|
] as const) {
|
|
81
82
|
if (!Number.isSafeInteger(h[field]) || h[field] < 0 || h[field] > 0xffffffff) {
|
|
82
83
|
throw new Error(`Invalid TWL ${field}`);
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
const s = h.digest_sector_size;
|
|
86
|
-
if (!Number.isSafeInteger(s) || s <= 0 || s > 0x4000 || 0x4000 % s)
|
|
87
|
-
throw new Error('Invalid TWL digest sector size');
|
|
87
|
+
if (!Number.isSafeInteger(s) || s <= 0 || s > 0x4000 || 0x4000 % s) throw new Error("Invalid TWL digest sector size");
|
|
88
88
|
if (
|
|
89
89
|
!Number.isSafeInteger(h.digest_block_sectorcount) ||
|
|
90
90
|
h.digest_block_sectorcount <= 0 ||
|
|
91
91
|
h.digest_block_sectorcount > 0x100000
|
|
92
92
|
)
|
|
93
|
-
throw new Error(
|
|
93
|
+
throw new Error("Invalid TWL digest block sector count");
|
|
94
94
|
if (h.digest_ntr_size < 0x4000 || h.digest_ntr_size % s || h.digest_twl_size % s)
|
|
95
|
-
throw new Error(
|
|
95
|
+
throw new Error("Invalid TWL digest region size");
|
|
96
96
|
const hashes = (h.digest_ntr_size + h.digest_twl_size) / s;
|
|
97
97
|
if (
|
|
98
98
|
hashes * 20 > h.sector_hashtable_size ||
|
|
99
|
-
Math.ceil(h.sector_hashtable_size / (h.digest_block_sectorcount * 20)) * 20 >
|
|
100
|
-
h.block_hashtable_size
|
|
99
|
+
Math.ceil(h.sector_hashtable_size / (h.digest_block_sectorcount * 20)) * 20 > h.block_hashtable_size
|
|
101
100
|
)
|
|
102
|
-
throw new Error(
|
|
101
|
+
throw new Error("TWL hash tables are too small");
|
|
103
102
|
}
|
|
104
103
|
export class TWL {
|
|
105
104
|
readonly firstDSiHashOffset: number;
|
|
@@ -117,51 +116,31 @@ export class TWL {
|
|
|
117
116
|
this.firstDSiHashOffset = (h.digest_ntr_size / h.digest_sector_size) * 20;
|
|
118
117
|
this.dsiHashSize = (h.digest_twl_size / h.digest_sector_size) * 20;
|
|
119
118
|
this.Hashtable1Data = Buffer.from(
|
|
120
|
-
slice(original, h.sector_hashtable_start, h.sector_hashtable_size,
|
|
119
|
+
slice(original, h.sector_hashtable_start, h.sector_hashtable_size, "Sector hash table"),
|
|
121
120
|
);
|
|
122
121
|
this.Hashtable2Data = Buffer.from(
|
|
123
|
-
slice(original, h.block_hashtable_start, h.block_hashtable_size,
|
|
124
|
-
);
|
|
125
|
-
this.DSi9Data = Buffer.from(
|
|
126
|
-
slice(original, h.dsi9_rom_offset, Math.max(h.modcrypt1_size, h.dsi9_size), 'ARM9i'),
|
|
127
|
-
);
|
|
128
|
-
this.DSi7Data = Buffer.from(
|
|
129
|
-
slice(original, h.dsi7_rom_offset, Math.max(h.modcrypt2_size, h.dsi7_size), 'ARM7i'),
|
|
122
|
+
slice(original, h.block_hashtable_start, h.block_hashtable_size, "Block hash table"),
|
|
130
123
|
);
|
|
124
|
+
this.DSi9Data = Buffer.from(slice(original, h.dsi9_rom_offset, Math.max(h.modcrypt1_size, h.dsi9_size), "ARM9i"));
|
|
125
|
+
this.DSi7Data = Buffer.from(slice(original, h.dsi7_rom_offset, Math.max(h.modcrypt2_size, h.dsi7_size), "ARM7i"));
|
|
131
126
|
this.Header2Data = h.trimmedRom
|
|
132
127
|
? null
|
|
133
128
|
: Array.from({ length: 3 }, (_, i) =>
|
|
134
|
-
Buffer.from(
|
|
135
|
-
slice(original, h.digest_twl_start - 0x3000 + 0x1000 * i, 0x1000, 'Secondary header'),
|
|
136
|
-
),
|
|
129
|
+
Buffer.from(slice(original, h.digest_twl_start - 0x3000 + 0x1000 * i, 0x1000, "Secondary header")),
|
|
137
130
|
);
|
|
138
|
-
this.Overlays9Sha1Hmac = overlays.map((o) =>
|
|
139
|
-
hash(slice(original, o.offset, o.size, 'ARM9 overlay')),
|
|
140
|
-
);
|
|
131
|
+
this.Overlays9Sha1Hmac = overlays.map((o) => hash(slice(original, o.offset, o.size, "ARM9 overlay")));
|
|
141
132
|
let encrypted = false;
|
|
142
133
|
const modcryptStart = [
|
|
143
134
|
{ start: h.modcrypt1_start, size: h.modcrypt1_size },
|
|
144
135
|
{ start: h.modcrypt2_start, size: h.modcrypt2_size },
|
|
145
|
-
].find(
|
|
146
|
-
({ start, size }) => size > 0 && start >= h.digest_twl_start && start < 0xffffffff,
|
|
147
|
-
)?.start;
|
|
136
|
+
].find(({ start, size }) => size > 0 && start >= h.digest_twl_start && start < 0xffffffff)?.start;
|
|
148
137
|
if (modcryptStart !== undefined) {
|
|
149
138
|
const index = Math.floor((modcryptStart - h.digest_twl_start) / h.digest_sector_size);
|
|
150
139
|
const actual = hash(
|
|
151
|
-
slice(
|
|
152
|
-
original,
|
|
153
|
-
h.digest_twl_start + index * h.digest_sector_size,
|
|
154
|
-
h.digest_sector_size,
|
|
155
|
-
'Modcrypt sector',
|
|
156
|
-
),
|
|
140
|
+
slice(original, h.digest_twl_start + index * h.digest_sector_size, h.digest_sector_size, "Modcrypt sector"),
|
|
157
141
|
);
|
|
158
142
|
encrypted = !actual.equals(
|
|
159
|
-
slice(
|
|
160
|
-
this.Hashtable1Data,
|
|
161
|
-
this.firstDSiHashOffset + index * 20,
|
|
162
|
-
20,
|
|
163
|
-
'Modcrypt sector hash',
|
|
164
|
-
),
|
|
143
|
+
slice(this.Hashtable1Data, this.firstDSiHashOffset + index * 20, 20, "Modcrypt sector hash"),
|
|
165
144
|
);
|
|
166
145
|
}
|
|
167
146
|
if (encrypted) this.cryptSections(h);
|
|
@@ -178,8 +157,8 @@ export class TWL {
|
|
|
178
157
|
const off = h[`modcrypt${id}_start`] - start;
|
|
179
158
|
const result = aes128CtrCrypt(
|
|
180
159
|
key,
|
|
181
|
-
slice(counter, 0, 16,
|
|
182
|
-
slice(data, off, size,
|
|
160
|
+
slice(counter, 0, 16, "Modcrypt counter"),
|
|
161
|
+
slice(data, off, size, "Modcrypt payload"),
|
|
183
162
|
);
|
|
184
163
|
if (writer) {
|
|
185
164
|
writer.position = start + off;
|
|
@@ -194,20 +173,16 @@ export class TWL {
|
|
|
194
173
|
this.DSi7Data = this.importData(data, offset, size);
|
|
195
174
|
}
|
|
196
175
|
private importData(data: Buffer | string, offset: number, size?: number): Buffer {
|
|
197
|
-
if (typeof data ===
|
|
176
|
+
if (typeof data === "string") data = readFileSync(data);
|
|
198
177
|
size ??= data.length - offset;
|
|
199
|
-
const src = slice(data, offset, size,
|
|
178
|
+
const src = slice(data, offset, size, "DSi import");
|
|
200
179
|
const out = Buffer.alloc(Math.ceil(size / 16) * 16, 255);
|
|
201
180
|
src.copy(out);
|
|
202
181
|
return out;
|
|
203
182
|
}
|
|
204
|
-
updateOverlays9Sha1Hmac(
|
|
205
|
-
arm9Data: Buffer,
|
|
206
|
-
header: Arm9Header,
|
|
207
|
-
overlays: readonly Buffer[],
|
|
208
|
-
): Buffer {
|
|
183
|
+
updateOverlays9Sha1Hmac(arm9Data: Buffer, header: Arm9Header, overlays: readonly Buffer[]): Buffer {
|
|
209
184
|
if (overlays.length !== this.Overlays9Sha1Hmac.length)
|
|
210
|
-
throw new Error(
|
|
185
|
+
throw new Error("Changing ARM9 overlay count is unsupported");
|
|
211
186
|
const hashes = overlays.map(hash);
|
|
212
187
|
if (hashes.every((h, i) => h.equals(this.Overlays9Sha1Hmac[i]))) return Buffer.from(arm9Data);
|
|
213
188
|
const init = header.reserved2.readUInt32LE(0) & 0x3fff;
|
|
@@ -215,30 +190,23 @@ export class TWL {
|
|
|
215
190
|
const unpacked = decompressArm9(arm9Data, header);
|
|
216
191
|
const end = unpacked.data.readUInt32LE(init + 8) - header.ARM9ramAddress;
|
|
217
192
|
let found = -1;
|
|
218
|
-
for (
|
|
219
|
-
let p = Math.min(end - hashes.length * 20, unpacked.data.length - hashes.length * 20);
|
|
220
|
-
p >= 0;
|
|
221
|
-
p--
|
|
222
|
-
) {
|
|
193
|
+
for (let p = Math.min(end - hashes.length * 20, unpacked.data.length - hashes.length * 20); p >= 0; p--) {
|
|
223
194
|
if (unpacked.data.subarray(p, p + 20).equals(this.Overlays9Sha1Hmac[0])) {
|
|
224
195
|
found = p;
|
|
225
196
|
break;
|
|
226
197
|
}
|
|
227
198
|
}
|
|
228
|
-
if (found < 0)
|
|
229
|
-
throw new Error('ARM9 overlay hashes changed but original hash table was not found');
|
|
199
|
+
if (found < 0) throw new Error("ARM9 overlay hashes changed but original hash table was not found");
|
|
230
200
|
hashes.forEach((h, i) => h.copy(unpacked.data, found + i * 20));
|
|
231
201
|
// Original C# inverted this check and tried to compress uncompressed data.
|
|
232
202
|
// Preserve the original storage format: recompress only a compressed ARM9.
|
|
233
|
-
return unpacked.compressed
|
|
234
|
-
? compressArm9(unpacked.data, header, arm9Data.length - hdrptr)
|
|
235
|
-
: unpacked.data;
|
|
203
|
+
return unpacked.compressed ? compressArm9(unpacked.data, header, arm9Data.length - hdrptr) : unpacked.data;
|
|
236
204
|
}
|
|
237
205
|
writeTo(writer: BinaryWriter, h: TwlHeader): Buffer {
|
|
238
206
|
validate(h);
|
|
239
207
|
// Fixed DSi regions cannot be shifted safely without rewriting the header.
|
|
240
208
|
if (writer.position > h.sector_hashtable_start) {
|
|
241
|
-
throw new Error(
|
|
209
|
+
throw new Error("NTR output overlaps the fixed DSi sector hash table");
|
|
242
210
|
}
|
|
243
211
|
if (
|
|
244
212
|
h.sector_hashtable_start + h.sector_hashtable_size > h.block_hashtable_start ||
|
|
@@ -246,7 +214,7 @@ export class TWL {
|
|
|
246
214
|
h.dsi9_rom_offset + this.DSi9Data.length > h.dsi7_rom_offset ||
|
|
247
215
|
h.dsi7_rom_offset + this.DSi7Data.length > h.total_rom_size
|
|
248
216
|
) {
|
|
249
|
-
throw new Error(
|
|
217
|
+
throw new Error("DSi sections overlap or exceed the ROM size");
|
|
250
218
|
}
|
|
251
219
|
const fill = (end: number): void => {
|
|
252
220
|
if (writer.position < end) writer.fillTo(end, 255);
|
|
@@ -268,7 +236,7 @@ export class TWL {
|
|
|
268
236
|
fill(h.total_rom_size);
|
|
269
237
|
const finalPosition = writer.position;
|
|
270
238
|
const read = (p: number, n: number): Buffer =>
|
|
271
|
-
slice(writer.buffer.subarray(0, writer.length), p, n,
|
|
239
|
+
slice(writer.buffer.subarray(0, writer.length), p, n, "TWL output digest");
|
|
272
240
|
let secure: Buffer = Buffer.from(read(h.digest_ntr_start, 0x4000));
|
|
273
241
|
if (h.decrypted) secure = encryptSecureArea(h.gameCode, secure);
|
|
274
242
|
let index = 0;
|
|
@@ -276,19 +244,10 @@ export class TWL {
|
|
|
276
244
|
writer.position = h.sector_hashtable_start + index++ * 20;
|
|
277
245
|
writer.write(hash(b));
|
|
278
246
|
};
|
|
279
|
-
for (let p = 0; p < 0x4000; p += h.digest_sector_size)
|
|
280
|
-
|
|
281
|
-
for (
|
|
282
|
-
let p = h.digest_ntr_start + 0x4000;
|
|
283
|
-
p < h.digest_ntr_start + h.digest_ntr_size;
|
|
284
|
-
p += h.digest_sector_size
|
|
285
|
-
)
|
|
247
|
+
for (let p = 0; p < 0x4000; p += h.digest_sector_size) put(secure.subarray(p, p + h.digest_sector_size));
|
|
248
|
+
for (let p = h.digest_ntr_start + 0x4000; p < h.digest_ntr_start + h.digest_ntr_size; p += h.digest_sector_size)
|
|
286
249
|
put(read(p, h.digest_sector_size));
|
|
287
|
-
for (
|
|
288
|
-
let p = h.digest_twl_start;
|
|
289
|
-
p < h.digest_twl_start + h.digest_twl_size;
|
|
290
|
-
p += h.digest_sector_size
|
|
291
|
-
)
|
|
250
|
+
for (let p = h.digest_twl_start; p < h.digest_twl_start + h.digest_twl_size; p += h.digest_sector_size)
|
|
292
251
|
put(read(p, h.digest_sector_size));
|
|
293
252
|
index = 0;
|
|
294
253
|
for (
|
|
@@ -314,9 +273,9 @@ export const updateHeaderSignatures = (
|
|
|
314
273
|
originalHeader: Buffer,
|
|
315
274
|
keepOriginal: boolean,
|
|
316
275
|
): void => {
|
|
317
|
-
const signedData = Buffer.from(slice(originalHeader, 0, 0xe00,
|
|
318
|
-
const digest = slice(header.hmac_digest_master, 0, 20,
|
|
319
|
-
const signature = slice(header.rsa_signature, 0, 128,
|
|
276
|
+
const signedData = Buffer.from(slice(originalHeader, 0, 0xe00, "Signed header"));
|
|
277
|
+
const digest = slice(header.hmac_digest_master, 0, 20, "Master digest");
|
|
278
|
+
const signature = slice(header.rsa_signature, 0, 128, "RSA signature");
|
|
320
279
|
digest.copy(signedData, 0x328);
|
|
321
280
|
const valid = verifyRsaSha1(signedData, signature);
|
|
322
281
|
const position = writer.position;
|
|
@@ -328,7 +287,7 @@ export const updateHeaderSignatures = (
|
|
|
328
287
|
mask[0] = 0;
|
|
329
288
|
mask[1] = 1;
|
|
330
289
|
mask[107] = 0;
|
|
331
|
-
hashDigest(
|
|
290
|
+
hashDigest("sha1", signedData).copy(mask, 108);
|
|
332
291
|
header.rsa_signature = mask;
|
|
333
292
|
}
|
|
334
293
|
writer.position = 0xf80;
|
package/src/nitro-helper.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
export { NDSFile } from
|
|
2
|
-
export type {
|
|
3
|
-
export { Header, UnitCode } from
|
|
4
|
-
export { Banner } from
|
|
5
|
-
export { FileAllocationTable, FileNameTable, OverlayTable } from
|
|
6
|
-
export type { NitroFile, NitroFolder,
|
|
7
|
-
export { BinaryWriter } from
|
|
8
|
-
export { crc16, crc32 } from
|
|
9
|
-
export {
|
|
10
|
-
export type { ModcryptHeader } from
|
|
11
|
-
export {
|
|
12
|
-
export type { Arm9Header } from
|
|
13
|
-
export { TWL, updateHeaderSignatures } from
|
|
14
|
-
export type {
|
|
1
|
+
export { NDSFile } from "./nitro/nds-file";
|
|
2
|
+
export type { RomFileInfo, RomOptions } from "./nitro/nds-file";
|
|
3
|
+
export { Header, UnitCode } from "./nitro/header";
|
|
4
|
+
export { Banner } from "./nitro/banner";
|
|
5
|
+
export { FileAllocationTable, FileNameTable, OverlayTable } from "./nitro/filesystem";
|
|
6
|
+
export type { FatEntry, NitroFile, NitroFolder, OverlayItem } from "./nitro/filesystem";
|
|
7
|
+
export { BinaryWriter } from "./nitro/binary";
|
|
8
|
+
export { crc16, crc32 } from "./nitro/crc";
|
|
9
|
+
export { aes128CtrCrypt, encryptSecureArea, modcryptKey } from "./nitro/crypto";
|
|
10
|
+
export type { ModcryptHeader } from "./nitro/crypto";
|
|
11
|
+
export { compressArm9, compressBLZ, decompressArm9, decompressBLZ } from "./nitro/blz";
|
|
12
|
+
export type { Arm9Header } from "./nitro/blz";
|
|
13
|
+
export { TWL, updateHeaderSignatures } from "./nitro/twl";
|
|
14
|
+
export type { OverlayRange, SignedTwlHeader, TwlHeader } from "./nitro/twl";
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// Native TypeScript port of NitroPatchHelper/PatchHelper.cs, GPL-3.0.
|
|
2
|
-
import { hashDigest, readFile } from
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { decodeXdelta } from
|
|
2
|
+
import { hashDigest, readFile } from "#nitro-runtime";
|
|
3
|
+
|
|
4
|
+
import { bytes } from "./nitro/binary";
|
|
5
|
+
import { NDSFile } from "./nitro/nds-file";
|
|
6
|
+
import { decodeXdelta } from "./patch/xdelta";
|
|
7
|
+
import { extractZipEntry as extractEntry, readZip } from "./patch/zip";
|
|
7
8
|
|
|
8
9
|
export const PatchReturnValue = Object.freeze({
|
|
9
|
-
SUCCESS:
|
|
10
|
-
MD5_MISMATCH:
|
|
10
|
+
SUCCESS: "SUCCESS",
|
|
11
|
+
MD5_MISMATCH: "MD5_MISMATCH",
|
|
11
12
|
} as const);
|
|
12
13
|
export type PatchReturnValue = (typeof PatchReturnValue)[keyof typeof PatchReturnValue];
|
|
13
14
|
|
|
@@ -41,7 +42,7 @@ export interface PatchMetadata {
|
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export interface PatchReadme {
|
|
44
|
-
format:
|
|
45
|
+
format: "markdown" | "plaintext";
|
|
45
46
|
content: string;
|
|
46
47
|
}
|
|
47
48
|
|
|
@@ -50,7 +51,7 @@ export interface PatchInfo {
|
|
|
50
51
|
readme: PatchReadme | null;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
const md5 = (data: Uint8Array): string => hashDigest(
|
|
54
|
+
const md5 = (data: Uint8Array): string => hashDigest("md5", data).toString("hex");
|
|
54
55
|
const invalidMetadata = (message: string): null => {
|
|
55
56
|
console.warn(`忽略 metadata.json:${message}`);
|
|
56
57
|
return null;
|
|
@@ -60,43 +61,46 @@ const invalidMetadataField = (field: keyof PatchMetadata, message: string): void
|
|
|
60
61
|
};
|
|
61
62
|
|
|
62
63
|
const readMetadata = (files: Map<string, Buffer>): PatchMetadata | null => {
|
|
63
|
-
const file = files.get(
|
|
64
|
+
const file = files.get("metadata.json");
|
|
64
65
|
if (!file) return null;
|
|
65
66
|
|
|
66
67
|
let metadata: unknown;
|
|
67
68
|
try {
|
|
68
|
-
metadata = JSON.parse(new TextDecoder(
|
|
69
|
+
metadata = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(file));
|
|
69
70
|
} catch (error) {
|
|
70
71
|
return invalidMetadata(
|
|
71
72
|
`格式错误,可能是因为补丁包已损坏:${error instanceof Error ? error.message : String(error)}`,
|
|
72
73
|
);
|
|
73
74
|
}
|
|
74
|
-
if (!metadata || typeof metadata !==
|
|
75
|
-
return invalidMetadata(
|
|
75
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
|
|
76
|
+
return invalidMetadata("根节点必须是对象。");
|
|
76
77
|
|
|
77
78
|
const value = metadata as Record<string, unknown>;
|
|
78
79
|
const result: PatchMetadata = {};
|
|
79
|
-
for (const field of [
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
for (const field of ["id", "author", "name", "homepage", "version"] as const) {
|
|
81
|
+
const fieldValue = value[field];
|
|
82
|
+
if (fieldValue === undefined) continue;
|
|
83
|
+
if (typeof fieldValue === "string") result[field] = fieldValue;
|
|
84
|
+
else invalidMetadataField(field, "必须是字符串。");
|
|
82
85
|
}
|
|
83
|
-
if (
|
|
84
|
-
|
|
86
|
+
if (value.isBeta === undefined) return result;
|
|
87
|
+
if (typeof value.isBeta === "boolean") result.isBeta = value.isBeta;
|
|
88
|
+
else invalidMetadataField("isBeta", "必须是布尔值。");
|
|
85
89
|
|
|
86
90
|
return result;
|
|
87
91
|
};
|
|
88
92
|
|
|
89
93
|
const readReadme = (files: Map<string, Buffer>): PatchReadme | null => {
|
|
90
94
|
for (const [filename, format] of [
|
|
91
|
-
[
|
|
92
|
-
[
|
|
95
|
+
["README.md", "markdown"],
|
|
96
|
+
["README.txt", "plaintext"],
|
|
93
97
|
] as const) {
|
|
94
98
|
const file = files.get(filename.toLowerCase());
|
|
95
99
|
if (!file) continue;
|
|
96
100
|
try {
|
|
97
101
|
return {
|
|
98
102
|
format,
|
|
99
|
-
content: new TextDecoder(
|
|
103
|
+
content: new TextDecoder("utf-8", { fatal: true }).decode(file),
|
|
100
104
|
};
|
|
101
105
|
} catch (error) {
|
|
102
106
|
console.warn(
|
|
@@ -108,16 +112,12 @@ const readReadme = (files: Map<string, Buffer>): PatchReadme | null => {
|
|
|
108
112
|
};
|
|
109
113
|
|
|
110
114
|
/** Read and validate metadata.json from a NitroPatcher ZIP package. */
|
|
111
|
-
export const readPatchMetadata = (
|
|
112
|
-
patch
|
|
113
|
-
options: PatchOptions = {},
|
|
114
|
-
): PatchMetadata | null => readMetadata(readZip(patch, options));
|
|
115
|
+
export const readPatchMetadata = (patch: Uint8Array, options: PatchOptions = {}): PatchMetadata | null =>
|
|
116
|
+
readMetadata(readZip(patch, options));
|
|
115
117
|
|
|
116
118
|
/** Read a root README.md or README.txt from a NitroPatcher ZIP package. */
|
|
117
|
-
export const readPatchReadme = (
|
|
118
|
-
patch
|
|
119
|
-
options: PatchOptions = {},
|
|
120
|
-
): PatchReadme | null => readReadme(readZip(patch, options));
|
|
119
|
+
export const readPatchReadme = (patch: Uint8Array, options: PatchOptions = {}): PatchReadme | null =>
|
|
120
|
+
readReadme(readZip(patch, options));
|
|
121
121
|
|
|
122
122
|
/** Read metadata and README content while parsing the patch package only once. */
|
|
123
123
|
export const readPatchInfo = (patch: Uint8Array, options: PatchOptions = {}): PatchInfo => {
|
|
@@ -125,6 +125,10 @@ export const readPatchInfo = (patch: Uint8Array, options: PatchOptions = {}): Pa
|
|
|
125
125
|
return { metadata: readMetadata(files), readme: readReadme(files) };
|
|
126
126
|
};
|
|
127
127
|
|
|
128
|
+
/** Extract one file from a ZIP archive entirely in memory. */
|
|
129
|
+
export const extractZipEntry = (archive: Uint8Array, entryName: string, options: PatchOptions = {}): Buffer =>
|
|
130
|
+
extractEntry(archive, entryName, options);
|
|
131
|
+
|
|
128
132
|
/** Read metadata.json from a NitroPatcher ZIP package on disk. */
|
|
129
133
|
export const readPatchMetadataFile = async (
|
|
130
134
|
patchPath: string,
|
|
@@ -132,40 +136,29 @@ export const readPatchMetadataFile = async (
|
|
|
132
136
|
): Promise<PatchMetadata | null> => readPatchMetadata(await readFile(patchPath), options);
|
|
133
137
|
|
|
134
138
|
/** Read a patch README from disk. */
|
|
135
|
-
export const readPatchReadmeFile = async (
|
|
136
|
-
patchPath
|
|
137
|
-
options: PatchOptions = {},
|
|
138
|
-
): Promise<PatchReadme | null> => readPatchReadme(await readFile(patchPath), options);
|
|
139
|
+
export const readPatchReadmeFile = async (patchPath: string, options: PatchOptions = {}): Promise<PatchReadme | null> =>
|
|
140
|
+
readPatchReadme(await readFile(patchPath), options);
|
|
139
141
|
|
|
140
142
|
/** Read patch metadata and README content from disk. */
|
|
141
|
-
export const readPatchInfoFile = async (
|
|
142
|
-
patchPath
|
|
143
|
-
options: PatchOptions = {},
|
|
144
|
-
): Promise<PatchInfo> => readPatchInfo(await readFile(patchPath), options);
|
|
143
|
+
export const readPatchInfoFile = async (patchPath: string, options: PatchOptions = {}): Promise<PatchInfo> =>
|
|
144
|
+
readPatchInfo(await readFile(patchPath), options);
|
|
145
145
|
|
|
146
146
|
/** Apply an existing NitroPatcher ZIP package entirely in memory. */
|
|
147
|
-
export const patchBuffer = (
|
|
148
|
-
original
|
|
149
|
-
patch: Uint8Array,
|
|
150
|
-
options: PatchOptions = {},
|
|
151
|
-
): PatchResult => {
|
|
152
|
-
const input = bytes(original, 'original ROM');
|
|
147
|
+
export const patchBuffer = (original: Uint8Array, patch: Uint8Array, options: PatchOptions = {}): PatchResult => {
|
|
148
|
+
const input = bytes(original, "original ROM");
|
|
153
149
|
const patches = readZip(patch, options);
|
|
154
150
|
const inputMd5 = md5(input);
|
|
155
151
|
const preprocessing = patches.get(`preprocessing/${inputMd5}.xdelta`);
|
|
156
|
-
const rom = new NDSFile(
|
|
157
|
-
preprocessing ? decodeXdelta(input, preprocessing, options) : input,
|
|
158
|
-
options,
|
|
159
|
-
);
|
|
152
|
+
const rom = new NDSFile(preprocessing ? decodeXdelta(input, preprocessing, options) : input, options);
|
|
160
153
|
let matched = true;
|
|
161
|
-
const md5File = patches.get(
|
|
154
|
+
const md5File = patches.get("md5.txt");
|
|
162
155
|
if (md5File) {
|
|
163
156
|
matched = false;
|
|
164
|
-
for (const line of md5File.toString(
|
|
157
|
+
for (const line of md5File.toString("utf8").split("\n")) {
|
|
165
158
|
const expected = line.trim();
|
|
166
|
-
if (!expected || expected.startsWith(
|
|
159
|
+
if (!expected || expected.startsWith("#")) continue;
|
|
167
160
|
// Keep the original format contract: exactly 32 characters, case-insensitive comparison.
|
|
168
|
-
if (expected.length !== 32) throw new Error(
|
|
161
|
+
if (expected.length !== 32) throw new Error("md5.txt 格式错误,可能是因为补丁包已损坏。");
|
|
169
162
|
if (expected.toLowerCase() === inputMd5) {
|
|
170
163
|
matched = true;
|
|
171
164
|
break;
|
|
@@ -173,11 +166,9 @@ export const patchBuffer = (
|
|
|
173
166
|
}
|
|
174
167
|
}
|
|
175
168
|
for (const { path } of rom.listFiles()) {
|
|
176
|
-
const normalized = path.replaceAll(
|
|
169
|
+
const normalized = path.replaceAll("\\", "/").toLowerCase();
|
|
177
170
|
const delta = patches.get(`xdelta/${normalized}`);
|
|
178
|
-
const replacement = delta
|
|
179
|
-
? decodeXdelta(rom.getFile(path), delta, options)
|
|
180
|
-
: patches.get(normalized);
|
|
171
|
+
const replacement = delta ? decodeXdelta(rom.getFile(path), delta, options) : patches.get(normalized);
|
|
181
172
|
if (replacement) rom.replaceFile(path, replacement);
|
|
182
173
|
}
|
|
183
174
|
const buffer = rom.toBuffer();
|
|
@@ -201,6 +192,7 @@ export const patchIt = async (
|
|
|
201
192
|
|
|
202
193
|
/** Namespaced counterpart of the original C# helper, sharing the same typed functions. */
|
|
203
194
|
export const PatchHelper = Object.freeze({
|
|
195
|
+
extractZipEntry,
|
|
204
196
|
patchBuffer,
|
|
205
197
|
patchIt,
|
|
206
198
|
readPatchInfo,
|
|
@@ -210,5 +202,5 @@ export const PatchHelper = Object.freeze({
|
|
|
210
202
|
readPatchReadme,
|
|
211
203
|
readPatchReadmeFile,
|
|
212
204
|
});
|
|
213
|
-
export { decodeXdelta } from
|
|
214
|
-
export type { XdeltaOptions } from
|
|
205
|
+
export { decodeXdelta } from "./patch/xdelta";
|
|
206
|
+
export type { XdeltaOptions } from "./patch/xdelta";
|