nitro-patcher 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.d.ts +25 -2
- package/dist/browser.js +10507 -78
- package/dist/browser.js.LEGAL.txt +460 -0
- package/dist/browser.js.map +4 -4
- package/dist/{chunk-IGURDH5L.js → chunk-B7NTPBDX.js} +70 -8
- package/dist/chunk-B7NTPBDX.js.map +1 -0
- package/dist/chunk-LK3JXA24.js +2067 -0
- package/dist/chunk-LK3JXA24.js.map +1 -0
- package/dist/cli.js +109 -17
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -2
- package/dist/nitro-helper.d.ts +43 -134
- package/dist/nitro-helper.js +3 -1
- package/dist/nitro-patch-helper.d.ts +4 -0
- package/dist/nitro-patch-helper.js +2 -2
- package/dist/twl-complete-B2is30H0.d.ts +201 -0
- package/package.json +8 -2
- package/src/browser/buffer.ts +1 -0
- package/src/browser.ts +23 -2
- package/src/cli.ts +118 -18
- package/src/nitro/blz.ts +13 -4
- package/src/nitro/crypto.ts +29 -11
- package/src/nitro/filesystem.ts +88 -5
- package/src/nitro/header.ts +4 -0
- package/src/nitro/nds-file.ts +378 -30
- package/src/nitro/twl-complete.ts +665 -0
- package/src/nitro/twl.ts +53 -19
- package/src/nitro-helper.ts +2 -1
- package/src/nitro-patch-helper.ts +64 -3
- package/src/patch/zip.ts +14 -4
- package/src/runtime/browser.ts +2 -2
- package/src/runtime/node.ts +2 -15
- package/src/runtime/rsa.ts +14 -0
- package/dist/chunk-GLK3K2HY.js +0 -1120
- package/dist/chunk-GLK3K2HY.js.map +0 -1
- package/dist/chunk-IGURDH5L.js.map +0 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
declare const UnitCode: Readonly<{
|
|
2
|
+
NDS: 0;
|
|
3
|
+
NDS_DSi: 2;
|
|
4
|
+
DSi: 3;
|
|
5
|
+
}>;
|
|
6
|
+
declare class Header {
|
|
7
|
+
raw: Buffer;
|
|
8
|
+
trimmedRom: boolean;
|
|
9
|
+
doublePadding: boolean;
|
|
10
|
+
nitrocode: boolean;
|
|
11
|
+
decrypted: boolean;
|
|
12
|
+
headerCRC: boolean;
|
|
13
|
+
logoCRC: boolean;
|
|
14
|
+
secureCRC: boolean;
|
|
15
|
+
dlp_signature: Buffer;
|
|
16
|
+
ARM9romOffset: number;
|
|
17
|
+
ARM9entryAddress: number;
|
|
18
|
+
ARM9ramAddress: number;
|
|
19
|
+
ARM9size: number;
|
|
20
|
+
ARM7romOffset: number;
|
|
21
|
+
ARM7entryAddress: number;
|
|
22
|
+
ARM7ramAddress: number;
|
|
23
|
+
ARM7size: number;
|
|
24
|
+
FNToffset: number;
|
|
25
|
+
FNTsize: number;
|
|
26
|
+
FAToffset: number;
|
|
27
|
+
FATsize: number;
|
|
28
|
+
ARM9overlayOffset: number;
|
|
29
|
+
ARM9overlaySize: number;
|
|
30
|
+
ARM7overlayOffset: number;
|
|
31
|
+
ARM7overlaySize: number;
|
|
32
|
+
flagsRead: number;
|
|
33
|
+
flagsInit: number;
|
|
34
|
+
bannerOffset: number;
|
|
35
|
+
dsi9_rom_offset: number;
|
|
36
|
+
offset_0x1C4: number;
|
|
37
|
+
dsi9_ram_address: number;
|
|
38
|
+
dsi9_size: number;
|
|
39
|
+
dsi7_rom_offset: number;
|
|
40
|
+
offset_0x1D4: number;
|
|
41
|
+
dsi7_ram_address: number;
|
|
42
|
+
dsi7_size: number;
|
|
43
|
+
digest_ntr_start: number;
|
|
44
|
+
digest_ntr_size: number;
|
|
45
|
+
digest_twl_start: number;
|
|
46
|
+
digest_twl_size: number;
|
|
47
|
+
sector_hashtable_start: number;
|
|
48
|
+
sector_hashtable_size: number;
|
|
49
|
+
block_hashtable_start: number;
|
|
50
|
+
block_hashtable_size: number;
|
|
51
|
+
digest_sector_size: number;
|
|
52
|
+
digest_block_sectorcount: number;
|
|
53
|
+
banner_size: number;
|
|
54
|
+
offset_0x20C: number;
|
|
55
|
+
total_rom_size: number;
|
|
56
|
+
offset_0x214: number;
|
|
57
|
+
offset_0x218: number;
|
|
58
|
+
offset_0x21C: number;
|
|
59
|
+
modcrypt1_start: number;
|
|
60
|
+
modcrypt1_size: number;
|
|
61
|
+
modcrypt2_start: number;
|
|
62
|
+
modcrypt2_size: number;
|
|
63
|
+
tid_low: number;
|
|
64
|
+
tid_high: number;
|
|
65
|
+
public_sav_size: number;
|
|
66
|
+
private_sav_size: number;
|
|
67
|
+
unitCode: number;
|
|
68
|
+
encryptionSeed: number;
|
|
69
|
+
twlInternalFlags: number;
|
|
70
|
+
permitsFlags: number;
|
|
71
|
+
ROMversion: number;
|
|
72
|
+
internalFlags: number;
|
|
73
|
+
secureCRC16: number;
|
|
74
|
+
ROMtimeout: number;
|
|
75
|
+
ARM9autoload: number;
|
|
76
|
+
ARM7autoload: number;
|
|
77
|
+
ROMsize: number;
|
|
78
|
+
ntr_region_end: number;
|
|
79
|
+
twl_region_start: number;
|
|
80
|
+
headerSize: number;
|
|
81
|
+
logoCRC16: number;
|
|
82
|
+
headerCRC16: number;
|
|
83
|
+
debug_romOffset: number;
|
|
84
|
+
debug_size: number;
|
|
85
|
+
debug_ramAddress: number;
|
|
86
|
+
reserved3: number;
|
|
87
|
+
mbk9_wramcnt_setting: number;
|
|
88
|
+
region_flags: number;
|
|
89
|
+
access_control: number;
|
|
90
|
+
scfg_ext_mask: number;
|
|
91
|
+
gameTitle: string;
|
|
92
|
+
gameCode: string;
|
|
93
|
+
makerCode: string;
|
|
94
|
+
reserved: Buffer;
|
|
95
|
+
reserved2: Buffer;
|
|
96
|
+
logo: Buffer;
|
|
97
|
+
appflags: Buffer;
|
|
98
|
+
hmac_arm9: Buffer;
|
|
99
|
+
hmac_arm7: Buffer;
|
|
100
|
+
hmac_digest_master: Buffer;
|
|
101
|
+
hmac_icon_title: Buffer;
|
|
102
|
+
hmac_arm9i: Buffer;
|
|
103
|
+
hmac_arm7i: Buffer;
|
|
104
|
+
hmac_arm9_no_secure: Buffer;
|
|
105
|
+
rsa_signature: Buffer;
|
|
106
|
+
secureDisable: bigint;
|
|
107
|
+
constructor(input: Uint8Array);
|
|
108
|
+
get size(): number;
|
|
109
|
+
set size(value: number);
|
|
110
|
+
toBuffer(): Buffer<ArrayBuffer>;
|
|
111
|
+
toString(): string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface Arm9Header {
|
|
115
|
+
nitrocode?: boolean;
|
|
116
|
+
reserved2: Buffer;
|
|
117
|
+
ARM9ramAddress: number;
|
|
118
|
+
ARM9size: number;
|
|
119
|
+
}
|
|
120
|
+
declare const decompressBLZ: (input: Buffer, maxSize?: number) => Buffer;
|
|
121
|
+
declare const compressBLZ: (input: Buffer) => Buffer;
|
|
122
|
+
declare const decompressArm9: (data: Buffer, header: Arm9Header, maxSize?: number) => {
|
|
123
|
+
compressed: boolean;
|
|
124
|
+
data: Buffer;
|
|
125
|
+
};
|
|
126
|
+
declare const compressArm9: (data: Buffer, header: Arm9Header, postSize?: number) => Buffer;
|
|
127
|
+
|
|
128
|
+
interface DsiCheck {
|
|
129
|
+
status: "valid" | "invalid" | "not-applicable";
|
|
130
|
+
reason?: string;
|
|
131
|
+
}
|
|
132
|
+
interface DsiVerification {
|
|
133
|
+
arm9: DsiCheck;
|
|
134
|
+
arm7: DsiCheck;
|
|
135
|
+
arm9i: DsiCheck;
|
|
136
|
+
arm7i: DsiCheck;
|
|
137
|
+
arm9NoSecure: DsiCheck;
|
|
138
|
+
banner: DsiCheck;
|
|
139
|
+
sectors: DsiCheck;
|
|
140
|
+
blocks: DsiCheck;
|
|
141
|
+
master: DsiCheck;
|
|
142
|
+
signature: DsiCheck;
|
|
143
|
+
}
|
|
144
|
+
type ProgramName = "arm9" | "arm7" | "arm9i" | "arm7i";
|
|
145
|
+
interface Program {
|
|
146
|
+
name: ProgramName;
|
|
147
|
+
start: number;
|
|
148
|
+
length: number;
|
|
149
|
+
data: Buffer;
|
|
150
|
+
original: Buffer;
|
|
151
|
+
replaced: boolean;
|
|
152
|
+
}
|
|
153
|
+
interface CryptRegion {
|
|
154
|
+
id: 1 | 2;
|
|
155
|
+
program: ProgramName;
|
|
156
|
+
offset: number;
|
|
157
|
+
length: number;
|
|
158
|
+
kind: "whole" | "secure" | "fixed";
|
|
159
|
+
}
|
|
160
|
+
interface DsiSection {
|
|
161
|
+
label: string;
|
|
162
|
+
start: number;
|
|
163
|
+
originalSize: number;
|
|
164
|
+
data: Buffer;
|
|
165
|
+
place: (start: number) => void;
|
|
166
|
+
}
|
|
167
|
+
/** Complete cartridge processing; legacy TWL serialization stays independent. */
|
|
168
|
+
declare class CompleteTWL {
|
|
169
|
+
readonly header: Header;
|
|
170
|
+
readonly programs: Map<ProgramName, Program>;
|
|
171
|
+
readonly regions: CryptRegion[];
|
|
172
|
+
readonly hasDigests: boolean;
|
|
173
|
+
readonly Hashtable1Data: Buffer;
|
|
174
|
+
readonly Hashtable2Data: Buffer;
|
|
175
|
+
readonly Header2Data: Buffer[];
|
|
176
|
+
readonly Overlays9Sha1Hmac: Buffer[];
|
|
177
|
+
readonly twlEncrypted: boolean;
|
|
178
|
+
readonly firstDSiHashOffset: number;
|
|
179
|
+
readonly dsiHashSize: number;
|
|
180
|
+
private readonly original;
|
|
181
|
+
private readonly limit;
|
|
182
|
+
constructor(header: Header, overlays: readonly Buffer[], original: Buffer, limit: number);
|
|
183
|
+
private validateDigests;
|
|
184
|
+
private decryptPrograms;
|
|
185
|
+
get DSi9Data(): Buffer;
|
|
186
|
+
set DSi9Data(data: Buffer);
|
|
187
|
+
get DSi7Data(): Buffer;
|
|
188
|
+
set DSi7Data(data: Buffer);
|
|
189
|
+
getProgram(name: ProgramName): Buffer;
|
|
190
|
+
setProgram(name: ProgramName, data: Buffer): void;
|
|
191
|
+
importArm9iData(data: Buffer | string, offset?: number, size?: number): void;
|
|
192
|
+
importArm7iData(data: Buffer | string, offset?: number, size?: number): void;
|
|
193
|
+
unchanged(): boolean;
|
|
194
|
+
updateOverlays9Sha1Hmac(arm9: Buffer, h: Arm9Header, overlays: readonly Buffer[]): Buffer;
|
|
195
|
+
private view;
|
|
196
|
+
verify(data?: Buffer<ArrayBufferLike>, h?: Header): DsiVerification;
|
|
197
|
+
serialize(h: Header, sections: DsiSection[], forceLayout: boolean): Buffer;
|
|
198
|
+
private checkMovableTwl;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export { type Arm9Header as A, CompleteTWL as C, type DsiCheck as D, Header as H, UnitCode as U, type DsiVerification as a, compressBLZ as b, compressArm9 as c, decompressArm9 as d, decompressBLZ as e };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nitro-patcher",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Native Node.js Nintendo DS ROM reader, repacker and ZIP/Xdelta patcher",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"@noble/ciphers": "2.4.0",
|
|
50
50
|
"@noble/hashes": "2.4.0",
|
|
51
51
|
"@types/node": "22.20.2",
|
|
52
|
+
"@types/node-forge": "1.3.14",
|
|
52
53
|
"buffer": "6.0.3",
|
|
53
54
|
"esbuild": "0.28.1",
|
|
54
55
|
"eslint": "10.9.1",
|
|
@@ -59,7 +60,9 @@
|
|
|
59
60
|
"fflate": "0.8.3",
|
|
60
61
|
"globals": "16.5.0",
|
|
61
62
|
"jiti": "2.7.0",
|
|
63
|
+
"playwright-core": "1.64.1",
|
|
62
64
|
"prettier": "3.9.6",
|
|
65
|
+
"string_decoder": "1.3.0",
|
|
63
66
|
"tsup": "8.5.1",
|
|
64
67
|
"tsx": "4.23.13",
|
|
65
68
|
"type-fest": "4.41.0",
|
|
@@ -73,7 +76,9 @@
|
|
|
73
76
|
"#browser-aes": "@noble/ciphers/aes.js"
|
|
74
77
|
},
|
|
75
78
|
"dependencies": {
|
|
76
|
-
"
|
|
79
|
+
"iconv-lite": "0.7.3",
|
|
80
|
+
"markdansi": "0.3.3",
|
|
81
|
+
"node-forge": "1.4.0"
|
|
77
82
|
},
|
|
78
83
|
"scripts": {
|
|
79
84
|
"clean": "tsx scripts/clean.ts",
|
|
@@ -81,6 +86,7 @@
|
|
|
81
86
|
"typecheck": "tsc -p tsconfig.test.json",
|
|
82
87
|
"test": "tsx --test test/*.test.ts",
|
|
83
88
|
"test:reference": "tsx scripts/verify-reference.ts",
|
|
89
|
+
"test:dsi": "tsx scripts/verify-dsi.ts",
|
|
84
90
|
"check": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm test && pnpm run build && pnpm run test:browser",
|
|
85
91
|
"test:package": "tsx scripts/verify-package.ts",
|
|
86
92
|
"lint": "eslint . --max-warnings 0",
|
package/src/browser/buffer.ts
CHANGED
package/src/browser.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "./nitro-patch-helper";
|
|
9
9
|
|
|
10
10
|
export interface PatchMetadata {
|
|
11
|
+
formatVersion?: number;
|
|
11
12
|
id?: string;
|
|
12
13
|
author?: string;
|
|
13
14
|
name?: string;
|
|
@@ -30,11 +31,28 @@ export interface BrowserPatchOptions {
|
|
|
30
31
|
maxUncompressedSize?: number;
|
|
31
32
|
maxEntries?: number;
|
|
32
33
|
}
|
|
34
|
+
export interface DsiCheck {
|
|
35
|
+
status: "valid" | "invalid" | "not-applicable";
|
|
36
|
+
reason?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface DsiVerification {
|
|
39
|
+
arm9: DsiCheck;
|
|
40
|
+
arm7: DsiCheck;
|
|
41
|
+
arm9i: DsiCheck;
|
|
42
|
+
arm7i: DsiCheck;
|
|
43
|
+
arm9NoSecure: DsiCheck;
|
|
44
|
+
banner: DsiCheck;
|
|
45
|
+
sectors: DsiCheck;
|
|
46
|
+
blocks: DsiCheck;
|
|
47
|
+
master: DsiCheck;
|
|
48
|
+
signature: DsiCheck;
|
|
49
|
+
}
|
|
33
50
|
export interface BrowserPatchResult {
|
|
34
51
|
buffer: Uint8Array<ArrayBuffer>;
|
|
35
52
|
returnValue: "SUCCESS" | "MD5_MISMATCH";
|
|
36
53
|
inputMd5: string;
|
|
37
54
|
outputMd5: string;
|
|
55
|
+
dsi?: DsiVerification;
|
|
38
56
|
}
|
|
39
57
|
export interface RomInfo {
|
|
40
58
|
gameTitle: string;
|
|
@@ -42,10 +60,11 @@ export interface RomInfo {
|
|
|
42
60
|
size: number;
|
|
43
61
|
fileCount: number;
|
|
44
62
|
checksums: { header: boolean; logo: boolean; secureArea: boolean; banner: boolean };
|
|
63
|
+
dsi?: DsiVerification;
|
|
45
64
|
}
|
|
46
65
|
/** Parse ROM metadata entirely in memory. No filesystem or Node types required. */
|
|
47
|
-
export const inspectRom = (bytes: Uint8Array): RomInfo => {
|
|
48
|
-
const rom = new NDSFile(bytes);
|
|
66
|
+
export const inspectRom = (bytes: Uint8Array, options: { dsiMode?: "legacy" | "complete" } = {}): RomInfo => {
|
|
67
|
+
const rom = new NDSFile(bytes, options);
|
|
49
68
|
return {
|
|
50
69
|
gameTitle: rom.header.gameTitle,
|
|
51
70
|
gameCode: rom.header.gameCode,
|
|
@@ -57,6 +76,7 @@ export const inspectRom = (bytes: Uint8Array): RomInfo => {
|
|
|
57
76
|
secureArea: rom.header.secureCRC,
|
|
58
77
|
banner: rom.banner.bannerCRC,
|
|
59
78
|
},
|
|
79
|
+
...(options.dsiMode === "complete" && { dsi: rom.verifyDsi() }),
|
|
60
80
|
};
|
|
61
81
|
};
|
|
62
82
|
/** Synchronous CPU work; call in a Web Worker for responsive browser UIs. */
|
|
@@ -77,6 +97,7 @@ export const patchBuffer = (
|
|
|
77
97
|
returnValue: result.returnValue,
|
|
78
98
|
inputMd5: result.inputMd5,
|
|
79
99
|
outputMd5: result.outputMd5,
|
|
100
|
+
...(result.dsi && { dsi: result.dsi }),
|
|
80
101
|
};
|
|
81
102
|
};
|
|
82
103
|
/** Read and validate metadata.json from a patch package entirely in memory. */
|
package/src/cli.ts
CHANGED
|
@@ -1,25 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { render } from "markdansi";
|
|
3
|
-
import { mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
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
5
|
import { parseArgs } from "node:util";
|
|
6
6
|
|
|
7
|
+
import { NDSFile } from "./nitro/nds-file";
|
|
7
8
|
import { type PatchMetadata, patchBuffer, readPatchInfo } from "./nitro-patch-helper";
|
|
8
9
|
|
|
9
|
-
const help = `nitro-patcher —
|
|
10
|
+
const help = `nitro-patcher — NDS ROM 补丁工具
|
|
10
11
|
|
|
11
12
|
用法:
|
|
12
13
|
nitro-patcher [--json] [--dry-run] <原始ROM> <补丁ZIP> [输出ROM]
|
|
13
|
-
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>
|
|
14
17
|
|
|
15
|
-
--json
|
|
18
|
+
--json 结果输出为 JSON 格式
|
|
16
19
|
--dry-run 完整执行补丁流程,但不写入输出 ROM
|
|
17
|
-
-
|
|
20
|
+
--dsi-mode extract/inspect 的 DSi 处理模式:legacy 或 complete
|
|
21
|
+
-i, --input 输入 ROM 路径
|
|
18
22
|
-p, --patch 补丁 ZIP 路径
|
|
19
|
-
-o, --output 输出 ROM
|
|
23
|
+
-o, --output 输出 ROM 或提取目录;试运行时可省略
|
|
20
24
|
-h, --help 显示帮助
|
|
21
25
|
-v, --version 显示版本`;
|
|
22
26
|
|
|
27
|
+
interface CommandOptions {
|
|
28
|
+
json: boolean;
|
|
29
|
+
dryRun: boolean;
|
|
30
|
+
input: string | undefined;
|
|
31
|
+
patch: string | undefined;
|
|
32
|
+
output: string | undefined;
|
|
33
|
+
dsiMode: "legacy" | "complete" | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
const sameFile = async (input: string, output: string): Promise<boolean> => {
|
|
24
37
|
if (resolve(input) === resolve(output)) return true;
|
|
25
38
|
const original = await stat(input);
|
|
@@ -65,6 +78,70 @@ const formatMetadata = (metadata: PatchMetadata): string => {
|
|
|
65
78
|
return lines.join("\n");
|
|
66
79
|
};
|
|
67
80
|
|
|
81
|
+
const extractRom = async (options: CommandOptions): Promise<void> => {
|
|
82
|
+
if (!options.input || !options.output || options.patch !== undefined || options.dryRun) throw new Error(help);
|
|
83
|
+
const destination = join(await realpath(dirname(resolve(options.output))), basename(resolve(options.output)));
|
|
84
|
+
const rom = new NDSFile(await readFile(options.input), { dsiMode: options.dsiMode ?? "legacy" });
|
|
85
|
+
const files = rom.listFiles();
|
|
86
|
+
await mkdir(destination, { recursive: true });
|
|
87
|
+
for (const { path } of files) {
|
|
88
|
+
const output = resolve(destination, path);
|
|
89
|
+
if (!output.startsWith(`${destination}${sep}`)) throw new Error(`ROM 文件路径超出输出目录:${path}`);
|
|
90
|
+
await mkdir(dirname(output), { recursive: true });
|
|
91
|
+
await writeFile(output, rom.getFile(path));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (options.json) {
|
|
95
|
+
console.log(
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
output: destination,
|
|
98
|
+
fileCount: files.length,
|
|
99
|
+
files: files.map(({ path }) => path),
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
} else {
|
|
103
|
+
console.log(`已提取 ${files.length} 个文件到:${destination}`);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const inspectRom = async (options: CommandOptions): Promise<void> => {
|
|
108
|
+
if (!options.input || options.patch !== undefined || options.output !== undefined || options.dryRun)
|
|
109
|
+
throw new Error(help);
|
|
110
|
+
const input = await readFile(options.input);
|
|
111
|
+
const rom = new NDSFile(input, { dsiMode: options.dsiMode ?? "legacy" });
|
|
112
|
+
const info = {
|
|
113
|
+
gameTitle: rom.header.gameTitle.replace(/\0+$/, ""),
|
|
114
|
+
gameCode: rom.header.gameCode,
|
|
115
|
+
size: input.length,
|
|
116
|
+
fileCount: rom.listFiles().length,
|
|
117
|
+
checksums: {
|
|
118
|
+
header: rom.header.headerCRC,
|
|
119
|
+
logo: rom.header.logoCRC,
|
|
120
|
+
secureArea: rom.header.secureCRC,
|
|
121
|
+
banner: rom.banner.bannerCRC,
|
|
122
|
+
},
|
|
123
|
+
...(options.dsiMode === "complete" && { dsi: rom.verifyDsi() }),
|
|
124
|
+
};
|
|
125
|
+
if (options.json) {
|
|
126
|
+
console.log(JSON.stringify(info));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
console.log(`ROM 信息:
|
|
130
|
+
|
|
131
|
+
标题:${safeTerminalText(info.gameTitle).replaceAll("\n", " ")}
|
|
132
|
+
游戏代码:${safeTerminalText(info.gameCode).replaceAll("\n", " ")}
|
|
133
|
+
大小:${info.size} 字节
|
|
134
|
+
文件数量:${info.fileCount}
|
|
135
|
+
校验:
|
|
136
|
+
- Header:${info.checksums.header ? "通过" : "失败"}
|
|
137
|
+
- Logo:${info.checksums.logo ? "通过" : "失败"}
|
|
138
|
+
- Secure Area:${info.checksums.secureArea ? "通过" : "失败"}
|
|
139
|
+
- Banner:${info.checksums.banner ? "通过" : "失败"}`);
|
|
140
|
+
if (info.dsi)
|
|
141
|
+
for (const [name, result] of Object.entries(info.dsi))
|
|
142
|
+
console.log(`- DSi ${name}:${result.status}${result.reason ? ` (${result.reason})` : ""}`);
|
|
143
|
+
};
|
|
144
|
+
|
|
68
145
|
const main = async (): Promise<void> => {
|
|
69
146
|
const { values, positionals } = parseArgs({
|
|
70
147
|
allowPositionals: true,
|
|
@@ -73,6 +150,7 @@ const main = async (): Promise<void> => {
|
|
|
73
150
|
version: { type: "boolean", short: "v" },
|
|
74
151
|
json: { type: "boolean" },
|
|
75
152
|
"dry-run": { type: "boolean" },
|
|
153
|
+
"dsi-mode": { type: "string" },
|
|
76
154
|
input: { type: "string", short: "i" },
|
|
77
155
|
patch: { type: "string", short: "p" },
|
|
78
156
|
output: { type: "string", short: "o" },
|
|
@@ -89,16 +167,37 @@ const main = async (): Promise<void> => {
|
|
|
89
167
|
console.log(metadata.version);
|
|
90
168
|
return;
|
|
91
169
|
}
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
170
|
+
const dsiMode = values["dsi-mode"];
|
|
171
|
+
if (dsiMode !== undefined && dsiMode !== "legacy" && dsiMode !== "complete")
|
|
172
|
+
throw new Error("Invalid --dsi-mode; expected legacy or complete");
|
|
173
|
+
const options: CommandOptions = {
|
|
174
|
+
json: values.json ?? false,
|
|
175
|
+
dryRun: values["dry-run"] ?? false,
|
|
176
|
+
input: values.input,
|
|
177
|
+
patch: values.patch,
|
|
178
|
+
output: values.output,
|
|
179
|
+
dsiMode,
|
|
180
|
+
};
|
|
181
|
+
const command = positionals.length === 1 ? positionals[0] : undefined;
|
|
182
|
+
if (positionals.length !== 0 && positionals.length !== 1 && positionals.length !== 3) throw new Error(help);
|
|
183
|
+
if (command !== undefined && command !== "patch" && command !== "extract" && command !== "inspect")
|
|
184
|
+
throw new Error(help);
|
|
185
|
+
if (command === "extract") {
|
|
186
|
+
await extractRom(options);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (command === "inspect") {
|
|
190
|
+
await inspectRom(options);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const dryRun = options.dryRun;
|
|
195
|
+
if (options.dsiMode !== undefined) throw new Error("patch selects DSi processing from metadata.json formatVersion");
|
|
196
|
+
const usesLegacyPaths = positionals.length === 3;
|
|
197
|
+
if (usesLegacyPaths && (values.input !== undefined || values.patch !== undefined || values.output !== undefined))
|
|
100
198
|
throw new Error(help);
|
|
101
|
-
|
|
199
|
+
if (!usesLegacyPaths && (!values.input || !values.patch || (!dryRun && !values.output))) throw new Error(help);
|
|
200
|
+
const [original, patch, output] = usesLegacyPaths ? positionals : [values.input, values.patch, values.output];
|
|
102
201
|
if (!original || !patch) throw new Error(help);
|
|
103
202
|
if (!dryRun) {
|
|
104
203
|
if (!output) throw new Error(help);
|
|
@@ -121,7 +220,7 @@ const main = async (): Promise<void> => {
|
|
|
121
220
|
}
|
|
122
221
|
}
|
|
123
222
|
const { returnValue, inputMd5, outputMd5 } = result;
|
|
124
|
-
if (
|
|
223
|
+
if (options.json) {
|
|
125
224
|
console.log(
|
|
126
225
|
JSON.stringify({
|
|
127
226
|
returnValue,
|
|
@@ -129,6 +228,7 @@ const main = async (): Promise<void> => {
|
|
|
129
228
|
outputMd5,
|
|
130
229
|
...info,
|
|
131
230
|
...(dryRun && { dryRun: true }),
|
|
231
|
+
...(result.dsi && { dsi: result.dsi }),
|
|
132
232
|
}),
|
|
133
233
|
);
|
|
134
234
|
return;
|
|
@@ -137,7 +237,7 @@ const main = async (): Promise<void> => {
|
|
|
137
237
|
const sections: string[] = [];
|
|
138
238
|
if (info.metadata) {
|
|
139
239
|
const metadata = formatMetadata(info.metadata);
|
|
140
|
-
if (metadata) sections.push(
|
|
240
|
+
if (metadata) sections.push(`补丁信息:\n\n${metadata}`);
|
|
141
241
|
}
|
|
142
242
|
if (info.readme) {
|
|
143
243
|
const content = safeTerminalText(info.readme.content);
|
package/src/nitro/blz.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export interface Arm9Header {
|
|
2
|
+
nitrocode?: boolean;
|
|
2
3
|
reserved2: Buffer;
|
|
3
4
|
ARM9ramAddress: number;
|
|
4
5
|
ARM9size: number;
|
|
@@ -130,22 +131,30 @@ export const compressBLZ = (input: Buffer): Buffer => {
|
|
|
130
131
|
return out;
|
|
131
132
|
};
|
|
132
133
|
|
|
133
|
-
export const decompressArm9 = (
|
|
134
|
+
export const decompressArm9 = (
|
|
135
|
+
data: Buffer,
|
|
136
|
+
header: Arm9Header,
|
|
137
|
+
maxSize = 0x10000000,
|
|
138
|
+
): { compressed: boolean; data: Buffer } => {
|
|
134
139
|
if (data.length < 0x18) throw new Error("ARM9 data too short");
|
|
135
|
-
|
|
140
|
+
if (data.length > maxSize) throw new RangeError("ARM9 exceeds output size limit");
|
|
141
|
+
const nitro = (header.nitrocode ?? data.readUInt32LE(0xc) === 0xdec00621) ? 12 : 0;
|
|
136
142
|
const init = header.reserved2.readUInt32LE(0) & 0x3fff;
|
|
137
143
|
const ptr = init ? data.readUInt32LE(init + 0x14) : header.ARM9ramAddress + header.ARM9size;
|
|
138
144
|
const compressed = ptr > header.ARM9ramAddress && ptr + nitro >= header.ARM9ramAddress + data.length;
|
|
139
145
|
return {
|
|
140
146
|
compressed,
|
|
141
147
|
data: compressed
|
|
142
|
-
? Buffer.concat([
|
|
148
|
+
? Buffer.concat([
|
|
149
|
+
decompressBLZ(data.subarray(0, data.length - nitro), maxSize - nitro),
|
|
150
|
+
data.subarray(data.length - nitro),
|
|
151
|
+
])
|
|
143
152
|
: Buffer.from(data),
|
|
144
153
|
};
|
|
145
154
|
};
|
|
146
155
|
export const compressArm9 = (data: Buffer, header: Arm9Header, postSize = 0): Buffer => {
|
|
147
156
|
if (data.length < 0x4000) throw new Error("ARM9 secure area is truncated");
|
|
148
|
-
const nitro = data.readUInt32LE(0xc) === 0xdec00621 ? 12 : 0;
|
|
157
|
+
const nitro = (header.nitrocode ?? data.readUInt32LE(0xc) === 0xdec00621) ? 12 : 0;
|
|
149
158
|
const packed = compressBLZ(data.subarray(0x4000, data.length - nitro));
|
|
150
159
|
if (!packed.length) throw new Error("ARM9 BLZ compression produced no smaller representation");
|
|
151
160
|
const out = Buffer.concat([data.subarray(0, 0x4000), packed, data.subarray(data.length - nitro)]);
|
package/src/nitro/crypto.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createAesBlockCipher } from "#nitro-runtime";
|
|
2
2
|
|
|
3
|
+
import { crc16 } from "./crc";
|
|
4
|
+
|
|
3
5
|
export interface ModcryptHeader {
|
|
4
6
|
gameCode: string;
|
|
5
7
|
gameTitle: string;
|
|
@@ -117,32 +119,37 @@ export const modcryptKey = (header: ModcryptHeader): Buffer => {
|
|
|
117
119
|
return out;
|
|
118
120
|
};
|
|
119
121
|
|
|
120
|
-
export const encryptSecureArea = (gameCode: string, input: Buffer): Buffer =>
|
|
122
|
+
export const encryptSecureArea = (gameCode: string, input: Buffer, refreshCrc = false): Buffer =>
|
|
123
|
+
secureAreaCrypt(gameCode, input, false, refreshCrc);
|
|
124
|
+
export const decryptSecureArea = (gameCode: string, input: Buffer): Buffer => secureAreaCrypt(gameCode, input, true);
|
|
125
|
+
|
|
126
|
+
const secureAreaCrypt = (gameCode: string, input: Buffer, decrypting: boolean, refreshCrc = false): Buffer => {
|
|
121
127
|
if (typeof gameCode !== "string" || gameCode.length !== 4) throw new Error("Expected four-character game code");
|
|
122
128
|
if (input.length < 0x800) throw new RangeError("Secure area must contain at least 0x800 bytes");
|
|
123
129
|
const output = Buffer.from(input);
|
|
130
|
+
if (refreshCrc) output.writeUInt16LE(crc16(output.subarray(0x10, 0x800)), 0xe);
|
|
124
131
|
let magic = new Uint32Array(0x412);
|
|
125
132
|
let args: [number, number, number] = [0, 0, 0];
|
|
126
|
-
function
|
|
133
|
+
function crypt(a: number, b: number, reverse = false): [number, number] {
|
|
127
134
|
for (let i = 0; i < 16; i++) {
|
|
128
|
-
const c = (magic[i] ^ a) >>> 0;
|
|
135
|
+
const c = (magic[reverse ? 17 - i : i] ^ a) >>> 0;
|
|
129
136
|
const sum = (magic[18 + (c >>> 24)] + magic[274 + ((c >>> 16) & 255)]) >>> 0;
|
|
130
137
|
const f = (magic[786 + (c & 255)] + (magic[530 + ((c >>> 8) & 255)] ^ sum)) >>> 0;
|
|
131
138
|
a = (b ^ f) >>> 0;
|
|
132
139
|
b = c;
|
|
133
140
|
}
|
|
134
|
-
return [(b ^ magic[17]) >>> 0, (a ^ magic[16]) >>> 0];
|
|
141
|
+
return [(b ^ magic[reverse ? 0 : 17]) >>> 0, (a ^ magic[reverse ? 1 : 16]) >>> 0];
|
|
135
142
|
}
|
|
136
143
|
function init2() {
|
|
137
|
-
[args[2], args[1]] =
|
|
138
|
-
[args[1], args[0]] =
|
|
144
|
+
[args[2], args[1]] = crypt(args[2], args[1]);
|
|
145
|
+
[args[1], args[0]] = crypt(args[1], args[0]);
|
|
139
146
|
const key = Buffer.alloc(12);
|
|
140
147
|
args.forEach((v, i) => key.writeUInt32LE(v, i * 4));
|
|
141
148
|
for (let j = 0; j < 18; j++) magic[j] ^= key.readUInt32BE((j * 4) & 7);
|
|
142
149
|
let a = 0,
|
|
143
150
|
b = 0;
|
|
144
151
|
for (let i = 0; i < 0x412; i += 2) {
|
|
145
|
-
[a, b] =
|
|
152
|
+
[a, b] = crypt(a, b);
|
|
146
153
|
magic[i] = a;
|
|
147
154
|
magic[i + 1] = b;
|
|
148
155
|
}
|
|
@@ -155,17 +162,28 @@ export const encryptSecureArea = (gameCode: string, input: Buffer): Buffer => {
|
|
|
155
162
|
init2();
|
|
156
163
|
}
|
|
157
164
|
init1();
|
|
165
|
+
if (decrypting) {
|
|
166
|
+
const [b, a] = crypt(output.readUInt32LE(4), output.readUInt32LE(0), true);
|
|
167
|
+
output.writeUInt32LE(a, 0);
|
|
168
|
+
output.writeUInt32LE(b, 4);
|
|
169
|
+
}
|
|
158
170
|
args[1] = (args[1] << 1) >>> 0;
|
|
159
171
|
args[2] >>>= 1;
|
|
160
172
|
init2();
|
|
161
|
-
for (let p = 8; p < 0x800; p += 8) {
|
|
162
|
-
const [b, a] =
|
|
173
|
+
for (let p = decrypting ? 0 : 8; p < 0x800; p += 8) {
|
|
174
|
+
const [b, a] = crypt(output.readUInt32LE(p + 4), output.readUInt32LE(p), decrypting);
|
|
163
175
|
output.writeUInt32LE(a, p);
|
|
164
176
|
output.writeUInt32LE(b, p + 4);
|
|
165
177
|
}
|
|
166
|
-
|
|
178
|
+
if (decrypting) {
|
|
179
|
+
if (output.readUInt32LE(0) !== 0x72636e65 || output.readUInt32LE(4) !== 0x6a624f79)
|
|
180
|
+
throw new Error("Invalid KEY1 secure-area marker");
|
|
181
|
+
output.writeBigUInt64LE(0xe7ffdeffe7ffdeffn);
|
|
182
|
+
return output;
|
|
183
|
+
}
|
|
184
|
+
let [b, a] = crypt(0x6a624f79, 0x72636e65);
|
|
167
185
|
init1();
|
|
168
|
-
[b, a] =
|
|
186
|
+
[b, a] = crypt(b, a);
|
|
169
187
|
output.writeUInt32LE(a, 0);
|
|
170
188
|
output.writeUInt32LE(b, 4);
|
|
171
189
|
return output;
|