nitro-patcher 0.1.0 → 0.2.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/README.md +2 -0
- package/dist/browser.d.ts +25 -0
- package/dist/browser.js +82 -3
- package/dist/browser.js.map +3 -3
- package/dist/{chunk-L7ZM4LLP.js → chunk-ZY3OSZCA.js} +76 -2
- package/dist/{chunk-L7ZM4LLP.js.map → chunk-ZY3OSZCA.js.map} +1 -1
- package/dist/cli.js +104 -22
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +14 -2
- package/dist/nitro-patch-helper.d.ts +35 -1
- package/dist/nitro-patch-helper.js +15 -3
- package/package.json +4 -1
- package/src/browser.ts +43 -2
- package/src/cli.ts +116 -20
- package/src/nitro-patch-helper.ts +120 -1
package/src/browser.ts
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import { NDSFile } from './nitro/nds-file';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
patchBuffer as patch,
|
|
4
|
+
readPatchInfo as readInfo,
|
|
5
|
+
readPatchMetadata as readMetadata,
|
|
6
|
+
readPatchReadme as readReadme,
|
|
7
|
+
} from './nitro-patch-helper';
|
|
8
|
+
|
|
9
|
+
export interface PatchMetadata {
|
|
10
|
+
id?: string;
|
|
11
|
+
author?: string;
|
|
12
|
+
name?: string;
|
|
13
|
+
homepage?: string;
|
|
14
|
+
version?: string;
|
|
15
|
+
isBeta?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface PatchReadme {
|
|
18
|
+
format: 'markdown' | 'plaintext';
|
|
19
|
+
content: string;
|
|
20
|
+
}
|
|
21
|
+
export interface PatchInfo {
|
|
22
|
+
metadata: PatchMetadata | null;
|
|
23
|
+
readme: PatchReadme | null;
|
|
24
|
+
}
|
|
3
25
|
|
|
4
26
|
export interface BrowserPatchOptions {
|
|
5
27
|
maxOutputSize?: number;
|
|
@@ -56,5 +78,24 @@ export const patchBuffer = (
|
|
|
56
78
|
outputMd5: result.outputMd5,
|
|
57
79
|
};
|
|
58
80
|
};
|
|
59
|
-
|
|
81
|
+
/** Read and validate metadata.json from a patch package entirely in memory. */
|
|
82
|
+
export const readPatchMetadata = (
|
|
83
|
+
archive: Uint8Array,
|
|
84
|
+
options: BrowserPatchOptions = {},
|
|
85
|
+
): PatchMetadata | null => readMetadata(archive, options);
|
|
86
|
+
/** Read a root README.md or README.txt from a patch package entirely in memory. */
|
|
87
|
+
export const readPatchReadme = (
|
|
88
|
+
archive: Uint8Array,
|
|
89
|
+
options: BrowserPatchOptions = {},
|
|
90
|
+
): PatchReadme | null => readReadme(archive, options);
|
|
91
|
+
/** Read metadata and README content while parsing the patch package only once. */
|
|
92
|
+
export const readPatchInfo = (archive: Uint8Array, options: BrowserPatchOptions = {}): PatchInfo =>
|
|
93
|
+
readInfo(archive, options);
|
|
94
|
+
|
|
95
|
+
export const PatchHelper = Object.freeze({
|
|
96
|
+
patchBuffer,
|
|
97
|
+
readPatchInfo,
|
|
98
|
+
readPatchMetadata,
|
|
99
|
+
readPatchReadme,
|
|
100
|
+
});
|
|
60
101
|
export const NitroHelper = Object.freeze({ inspectRom });
|
package/src/cli.ts
CHANGED
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
import { mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import {
|
|
5
|
+
import { render } from 'markdansi';
|
|
6
|
+
import { patchBuffer, readPatchInfo, type PatchMetadata } from './nitro-patch-helper';
|
|
6
7
|
|
|
7
8
|
const help = `nitro-patcher — 原生 Node.js NDS ROM 补丁工具
|
|
8
9
|
|
|
9
|
-
用法:
|
|
10
|
+
用法:
|
|
11
|
+
nitro-patcher [--json] [--dry-run] <原始ROM> <补丁ZIP> [输出ROM]
|
|
12
|
+
nitro-patcher [--json] [--dry-run] --input <原始ROM> --patch <补丁ZIP> [--output <输出ROM>]
|
|
10
13
|
|
|
11
|
-
--json
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
+
--json 输出 JSON 状态和 MD5
|
|
15
|
+
--dry-run 完整执行补丁流程,但不写入输出 ROM
|
|
16
|
+
-i, --input 原始 ROM 路径
|
|
17
|
+
-p, --patch 补丁 ZIP 路径
|
|
18
|
+
-o, --output 输出 ROM 路径;试运行时可省略
|
|
19
|
+
-h, --help 显示帮助
|
|
20
|
+
-v, --version 显示版本`;
|
|
14
21
|
|
|
15
22
|
const sameFile = async (input: string, output: string): Promise<boolean> => {
|
|
16
23
|
if (resolve(input) === resolve(output)) return true;
|
|
@@ -24,6 +31,39 @@ const sameFile = async (input: string, output: string): Promise<boolean> => {
|
|
|
24
31
|
}
|
|
25
32
|
};
|
|
26
33
|
|
|
34
|
+
const safeTerminalText = (value: string): string => {
|
|
35
|
+
const characters: string[] = [];
|
|
36
|
+
for (const character of value.replaceAll('\r\n', '\n').replaceAll('\r', '\n')) {
|
|
37
|
+
const code = character.codePointAt(0)!;
|
|
38
|
+
if (
|
|
39
|
+
(code < 0x20 && code !== 0x09 && code !== 0x0a) ||
|
|
40
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
41
|
+
(code >= 0x202a && code <= 0x202e) ||
|
|
42
|
+
(code >= 0x2066 && code <= 0x2069)
|
|
43
|
+
)
|
|
44
|
+
continue;
|
|
45
|
+
characters.push(character);
|
|
46
|
+
}
|
|
47
|
+
return characters.join('');
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const formatMetadata = (metadata: PatchMetadata): string => {
|
|
51
|
+
const labels = {
|
|
52
|
+
author: '作者',
|
|
53
|
+
name: '名称',
|
|
54
|
+
homepage: '主页',
|
|
55
|
+
version: '版本',
|
|
56
|
+
} as const;
|
|
57
|
+
const lines: string[] = [];
|
|
58
|
+
for (const field of ['author', 'name', 'homepage', 'version'] as const) {
|
|
59
|
+
const value = metadata[field];
|
|
60
|
+
if (value === undefined) continue;
|
|
61
|
+
const suffix = field === 'version' && metadata.isBeta ? '(测试版)' : '';
|
|
62
|
+
lines.push(`- ${labels[field]}:${safeTerminalText(value).replaceAll('\n', ' ')}${suffix}`);
|
|
63
|
+
}
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
};
|
|
66
|
+
|
|
27
67
|
const main = async (): Promise<void> => {
|
|
28
68
|
const { values, positionals } = parseArgs({
|
|
29
69
|
allowPositionals: true,
|
|
@@ -31,6 +71,10 @@ const main = async (): Promise<void> => {
|
|
|
31
71
|
help: { type: 'boolean', short: 'h' },
|
|
32
72
|
version: { type: 'boolean', short: 'v' },
|
|
33
73
|
json: { type: 'boolean' },
|
|
74
|
+
'dry-run': { type: 'boolean' },
|
|
75
|
+
input: { type: 'string', short: 'i' },
|
|
76
|
+
patch: { type: 'string', short: 'p' },
|
|
77
|
+
output: { type: 'string', short: 'o' },
|
|
34
78
|
},
|
|
35
79
|
});
|
|
36
80
|
if (values.help) {
|
|
@@ -51,23 +95,75 @@ const main = async (): Promise<void> => {
|
|
|
51
95
|
console.log(metadata.version);
|
|
52
96
|
return;
|
|
53
97
|
}
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
98
|
+
const dryRun = values['dry-run'] ?? false;
|
|
99
|
+
const usesNamedPaths =
|
|
100
|
+
values.input !== undefined || values.patch !== undefined || values.output !== undefined;
|
|
101
|
+
if (usesNamedPaths && positionals.length > 0) throw new Error(help);
|
|
102
|
+
if (
|
|
103
|
+
(usesNamedPaths && (!values.input || !values.patch || (!dryRun && !values.output))) ||
|
|
104
|
+
(!usesNamedPaths &&
|
|
105
|
+
((!dryRun && positionals.length !== 3) ||
|
|
106
|
+
(dryRun && (positionals.length < 2 || positionals.length > 3))))
|
|
107
|
+
)
|
|
108
|
+
throw new Error(help);
|
|
109
|
+
const [original, patch, output] = usesNamedPaths
|
|
110
|
+
? [values.input, values.patch, values.output]
|
|
111
|
+
: positionals;
|
|
112
|
+
if (!original || !patch) throw new Error(help);
|
|
113
|
+
if (!dryRun) {
|
|
114
|
+
if (!output) throw new Error(help);
|
|
115
|
+
if ((await sameFile(original, output)) || (await sameFile(patch, output)))
|
|
116
|
+
throw new Error('输出路径不能覆盖原始 ROM 或补丁包。');
|
|
117
|
+
}
|
|
118
|
+
const [originalBytes, patchBytes] = await Promise.all([readFile(original), readFile(patch)]);
|
|
119
|
+
const info = readPatchInfo(patchBytes);
|
|
120
|
+
const result = patchBuffer(originalBytes, patchBytes);
|
|
121
|
+
if (!dryRun) {
|
|
122
|
+
if (!output) throw new Error(help);
|
|
123
|
+
const destination = join(await realpath(dirname(resolve(output))), basename(output));
|
|
124
|
+
const temporary = await mkdtemp(join(dirname(destination), '.nitro-patcher-'));
|
|
125
|
+
try {
|
|
126
|
+
const file = join(temporary, 'output.nds');
|
|
127
|
+
await writeFile(file, result.buffer);
|
|
128
|
+
await rename(file, destination);
|
|
129
|
+
} finally {
|
|
130
|
+
await rm(temporary, { recursive: true, force: true });
|
|
131
|
+
}
|
|
67
132
|
}
|
|
68
133
|
const { returnValue, inputMd5, outputMd5 } = result;
|
|
69
|
-
if (values.json)
|
|
70
|
-
|
|
134
|
+
if (values.json) {
|
|
135
|
+
console.log(
|
|
136
|
+
JSON.stringify({
|
|
137
|
+
returnValue,
|
|
138
|
+
inputMd5,
|
|
139
|
+
outputMd5,
|
|
140
|
+
...info,
|
|
141
|
+
...(dryRun && { dryRun: true }),
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const sections: string[] = [];
|
|
148
|
+
if (info.metadata) {
|
|
149
|
+
const metadata = formatMetadata(info.metadata);
|
|
150
|
+
if (metadata) sections.push(`补丁元数据:\n\n${metadata}`);
|
|
151
|
+
}
|
|
152
|
+
if (info.readme) {
|
|
153
|
+
const content = safeTerminalText(info.readme.content);
|
|
154
|
+
const rendered =
|
|
155
|
+
info.readme.format === 'markdown'
|
|
156
|
+
? render(content, {
|
|
157
|
+
width: process.stdout.columns ?? 80,
|
|
158
|
+
color: Boolean(process.stdout.isTTY),
|
|
159
|
+
hyperlinks: false,
|
|
160
|
+
})
|
|
161
|
+
: content;
|
|
162
|
+
sections.push(`补丁说明:\n\n${rendered.trimEnd()}`);
|
|
163
|
+
}
|
|
164
|
+
if (dryRun) sections.push('试运行完成,未写入输出 ROM。');
|
|
165
|
+
sections.push(`${returnValue}\n\n原始 ROM 的 MD5:${inputMd5}\n生成 ROM 的 MD5:${outputMd5}`);
|
|
166
|
+
console.log(sections.join('\n\n'));
|
|
71
167
|
};
|
|
72
168
|
|
|
73
169
|
main().catch((error: unknown) => {
|
|
@@ -31,7 +31,117 @@ export interface PatchOptions {
|
|
|
31
31
|
maxEntries?: number;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export interface PatchMetadata {
|
|
35
|
+
id?: string;
|
|
36
|
+
author?: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
homepage?: string;
|
|
39
|
+
version?: string;
|
|
40
|
+
isBeta?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface PatchReadme {
|
|
44
|
+
format: 'markdown' | 'plaintext';
|
|
45
|
+
content: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PatchInfo {
|
|
49
|
+
metadata: PatchMetadata | null;
|
|
50
|
+
readme: PatchReadme | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
const md5 = (data: Uint8Array): string => hashDigest('md5', data).toString('hex');
|
|
54
|
+
const invalidMetadata = (message: string): null => {
|
|
55
|
+
console.warn(`忽略 metadata.json:${message}`);
|
|
56
|
+
return null;
|
|
57
|
+
};
|
|
58
|
+
const invalidMetadataField = (field: keyof PatchMetadata, message: string): void => {
|
|
59
|
+
console.warn(`忽略 metadata.json 的 ${field}:${message}`);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const readMetadata = (files: Map<string, Buffer>): PatchMetadata | null => {
|
|
63
|
+
const file = files.get('metadata.json');
|
|
64
|
+
if (!file) return null;
|
|
65
|
+
|
|
66
|
+
let metadata: unknown;
|
|
67
|
+
try {
|
|
68
|
+
metadata = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(file));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return invalidMetadata(
|
|
71
|
+
`格式错误,可能是因为补丁包已损坏:${error instanceof Error ? error.message : String(error)}`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata))
|
|
75
|
+
return invalidMetadata('根节点必须是对象。');
|
|
76
|
+
|
|
77
|
+
const value = metadata as Record<string, unknown>;
|
|
78
|
+
const result: PatchMetadata = {};
|
|
79
|
+
for (const field of ['id', 'author', 'name', 'homepage', 'version'] as const) {
|
|
80
|
+
if (typeof value[field] === 'string') result[field] = value[field];
|
|
81
|
+
else invalidMetadataField(field, '必须是字符串。');
|
|
82
|
+
}
|
|
83
|
+
if (typeof value.isBeta === 'boolean') result.isBeta = value.isBeta;
|
|
84
|
+
else invalidMetadataField('isBeta', '必须是布尔值。');
|
|
85
|
+
|
|
86
|
+
return result;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const readReadme = (files: Map<string, Buffer>): PatchReadme | null => {
|
|
90
|
+
for (const [filename, format] of [
|
|
91
|
+
['README.md', 'markdown'],
|
|
92
|
+
['README.txt', 'plaintext'],
|
|
93
|
+
] as const) {
|
|
94
|
+
const file = files.get(filename.toLowerCase());
|
|
95
|
+
if (!file) continue;
|
|
96
|
+
try {
|
|
97
|
+
return {
|
|
98
|
+
format,
|
|
99
|
+
content: new TextDecoder('utf-8', { fatal: true }).decode(file),
|
|
100
|
+
};
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.warn(
|
|
103
|
+
`忽略 ${filename}:不是有效的 UTF-8 文本:${error instanceof Error ? error.message : String(error)}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** Read and validate metadata.json from a NitroPatcher ZIP package. */
|
|
111
|
+
export const readPatchMetadata = (
|
|
112
|
+
patch: Uint8Array,
|
|
113
|
+
options: PatchOptions = {},
|
|
114
|
+
): PatchMetadata | null => readMetadata(readZip(patch, options));
|
|
115
|
+
|
|
116
|
+
/** Read a root README.md or README.txt from a NitroPatcher ZIP package. */
|
|
117
|
+
export const readPatchReadme = (
|
|
118
|
+
patch: Uint8Array,
|
|
119
|
+
options: PatchOptions = {},
|
|
120
|
+
): PatchReadme | null => readReadme(readZip(patch, options));
|
|
121
|
+
|
|
122
|
+
/** Read metadata and README content while parsing the patch package only once. */
|
|
123
|
+
export const readPatchInfo = (patch: Uint8Array, options: PatchOptions = {}): PatchInfo => {
|
|
124
|
+
const files = readZip(patch, options);
|
|
125
|
+
return { metadata: readMetadata(files), readme: readReadme(files) };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Read metadata.json from a NitroPatcher ZIP package on disk. */
|
|
129
|
+
export const readPatchMetadataFile = async (
|
|
130
|
+
patchPath: string,
|
|
131
|
+
options: PatchOptions = {},
|
|
132
|
+
): Promise<PatchMetadata | null> => readPatchMetadata(await readFile(patchPath), options);
|
|
133
|
+
|
|
134
|
+
/** Read a patch README from disk. */
|
|
135
|
+
export const readPatchReadmeFile = async (
|
|
136
|
+
patchPath: string,
|
|
137
|
+
options: PatchOptions = {},
|
|
138
|
+
): Promise<PatchReadme | null> => readPatchReadme(await readFile(patchPath), options);
|
|
139
|
+
|
|
140
|
+
/** Read patch metadata and README content from disk. */
|
|
141
|
+
export const readPatchInfoFile = async (
|
|
142
|
+
patchPath: string,
|
|
143
|
+
options: PatchOptions = {},
|
|
144
|
+
): Promise<PatchInfo> => readPatchInfo(await readFile(patchPath), options);
|
|
35
145
|
|
|
36
146
|
/** Apply an existing NitroPatcher ZIP package entirely in memory. */
|
|
37
147
|
export const patchBuffer = (
|
|
@@ -90,6 +200,15 @@ export const patchIt = async (
|
|
|
90
200
|
};
|
|
91
201
|
|
|
92
202
|
/** Namespaced counterpart of the original C# helper, sharing the same typed functions. */
|
|
93
|
-
export const PatchHelper = Object.freeze({
|
|
203
|
+
export const PatchHelper = Object.freeze({
|
|
204
|
+
patchBuffer,
|
|
205
|
+
patchIt,
|
|
206
|
+
readPatchInfo,
|
|
207
|
+
readPatchInfoFile,
|
|
208
|
+
readPatchMetadata,
|
|
209
|
+
readPatchMetadataFile,
|
|
210
|
+
readPatchReadme,
|
|
211
|
+
readPatchReadmeFile,
|
|
212
|
+
});
|
|
94
213
|
export { decodeXdelta } from './patch/xdelta';
|
|
95
214
|
export type { XdeltaOptions } from './patch/xdelta';
|