nitro-patcher 0.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/LICENSE +674 -0
- package/README.md +22 -0
- package/dist/browser.d.ts +34 -0
- package/dist/browser.js +4616 -0
- package/dist/browser.js.LEGAL.txt +147 -0
- package/dist/browser.js.map +7 -0
- package/dist/chunk-ANN47R34.js +1 -0
- package/dist/chunk-ANN47R34.js.map +1 -0
- package/dist/chunk-GFF67HX4.js +1181 -0
- package/dist/chunk-GFF67HX4.js.map +1 -0
- package/dist/chunk-L7ZM4LLP.js +396 -0
- package/dist/chunk-L7ZM4LLP.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +76 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +56 -0
- package/dist/index.js.map +1 -0
- package/dist/nitro-helper.d.ts +315 -0
- package/dist/nitro-helper.js +44 -0
- package/dist/nitro-helper.js.map +1 -0
- package/dist/nitro-patch-helper.d.ts +46 -0
- package/dist/nitro-patch-helper.js +16 -0
- package/dist/nitro-patch-helper.js.map +1 -0
- package/package.json +86 -0
- package/src/browser/buffer.ts +1 -0
- package/src/browser.ts +60 -0
- package/src/cli.ts +76 -0
- package/src/index.ts +2 -0
- package/src/nitro/banner.ts +77 -0
- package/src/nitro/binary.ts +80 -0
- package/src/nitro/blz.ts +163 -0
- package/src/nitro/crc.ts +23 -0
- package/src/nitro/crypto.ts +178 -0
- package/src/nitro/filesystem.ts +183 -0
- package/src/nitro/header.ts +254 -0
- package/src/nitro/nds-file.ts +285 -0
- package/src/nitro/twl.ts +338 -0
- package/src/nitro-helper.ts +14 -0
- package/src/nitro-patch-helper.ts +95 -0
- package/src/patch/xdelta.ts +214 -0
- package/src/patch/zip.ts +206 -0
- package/src/runtime/browser.ts +40 -0
- package/src/runtime/node.ts +28 -0
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nitro-patcher",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Native Node.js Nintendo DS ROM reader, repacker and ZIP/Xdelta patcher",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"license": "GPL-3.0-only",
|
|
10
|
+
"bin": {
|
|
11
|
+
"nitro-patcher": "dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./nitro-helper": {
|
|
21
|
+
"types": "./dist/nitro-helper.d.ts",
|
|
22
|
+
"import": "./dist/nitro-helper.js"
|
|
23
|
+
},
|
|
24
|
+
"./nitro-patch-helper": {
|
|
25
|
+
"types": "./dist/nitro-patch-helper.d.ts",
|
|
26
|
+
"import": "./dist/nitro-patch-helper.js"
|
|
27
|
+
},
|
|
28
|
+
"./browser": {
|
|
29
|
+
"types": "./dist/browser.d.ts",
|
|
30
|
+
"import": "./dist/browser.js"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"dist",
|
|
35
|
+
"src",
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"NOTICE.md",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/Xzonn/nitro-patcher.git"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@eslint/js": "10.0.1",
|
|
49
|
+
"@noble/ciphers": "2.4.0",
|
|
50
|
+
"@noble/hashes": "2.4.0",
|
|
51
|
+
"@types/node": "22.20.2",
|
|
52
|
+
"buffer": "6.0.3",
|
|
53
|
+
"esbuild": "0.27.3",
|
|
54
|
+
"eslint": "10.9.1",
|
|
55
|
+
"eslint-config-prettier": "10.1.8",
|
|
56
|
+
"fflate": "0.8.3",
|
|
57
|
+
"globals": "16.5.0",
|
|
58
|
+
"jiti": "2.7.0",
|
|
59
|
+
"prettier": "3.9.6",
|
|
60
|
+
"tsup": "8.5.1",
|
|
61
|
+
"tsx": "4.23.13",
|
|
62
|
+
"typescript": "5.9.3",
|
|
63
|
+
"typescript-eslint": "8.70.0"
|
|
64
|
+
},
|
|
65
|
+
"imports": {
|
|
66
|
+
"#nitro-runtime": "./src/runtime/node.ts",
|
|
67
|
+
"#browser-hashes": "@noble/hashes/legacy.js",
|
|
68
|
+
"#browser-hmac": "@noble/hashes/hmac.js",
|
|
69
|
+
"#browser-aes": "@noble/ciphers/aes.js"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"clean": "tsx scripts/clean.ts",
|
|
73
|
+
"build": "pnpm run build:node && pnpm run build:browser",
|
|
74
|
+
"typecheck": "tsc -p tsconfig.test.json",
|
|
75
|
+
"test": "tsx --test test/*.test.ts",
|
|
76
|
+
"test:reference": "tsx scripts/verify-reference.ts",
|
|
77
|
+
"check": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm test && pnpm run build && pnpm run test:browser",
|
|
78
|
+
"test:package": "tsx scripts/verify-package.ts",
|
|
79
|
+
"lint": "eslint . --max-warnings 0",
|
|
80
|
+
"format": "prettier --write .",
|
|
81
|
+
"format:check": "prettier --check .",
|
|
82
|
+
"build:node": "tsup src/index.ts src/nitro-helper.ts src/nitro-patch-helper.ts src/cli.ts --format esm --platform node --target node22 --splitting --sourcemap --dts --clean",
|
|
83
|
+
"build:browser": "tsx scripts/build-browser.ts",
|
|
84
|
+
"test:browser": "tsx scripts/verify-browser.ts"
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { Buffer } from 'buffer';
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { NDSFile } from './nitro/nds-file';
|
|
2
|
+
import { patchBuffer as patch } from './nitro-patch-helper';
|
|
3
|
+
|
|
4
|
+
export interface BrowserPatchOptions {
|
|
5
|
+
maxOutputSize?: number;
|
|
6
|
+
maxWindowSize?: number;
|
|
7
|
+
maxUncompressedSize?: number;
|
|
8
|
+
maxEntries?: number;
|
|
9
|
+
}
|
|
10
|
+
export interface BrowserPatchResult {
|
|
11
|
+
buffer: Uint8Array<ArrayBuffer>;
|
|
12
|
+
returnValue: 'SUCCESS' | 'MD5_MISMATCH';
|
|
13
|
+
inputMd5: string;
|
|
14
|
+
outputMd5: string;
|
|
15
|
+
}
|
|
16
|
+
export interface RomInfo {
|
|
17
|
+
gameTitle: string;
|
|
18
|
+
gameCode: string;
|
|
19
|
+
size: number;
|
|
20
|
+
fileCount: number;
|
|
21
|
+
checksums: { header: boolean; logo: boolean; secureArea: boolean; banner: boolean };
|
|
22
|
+
}
|
|
23
|
+
/** Parse ROM metadata entirely in memory. No filesystem or Node types required. */
|
|
24
|
+
export const inspectRom = (bytes: Uint8Array): RomInfo => {
|
|
25
|
+
const rom = new NDSFile(bytes);
|
|
26
|
+
return {
|
|
27
|
+
gameTitle: rom.header.gameTitle,
|
|
28
|
+
gameCode: rom.header.gameCode,
|
|
29
|
+
size: bytes.byteLength,
|
|
30
|
+
fileCount: rom.listFiles().length,
|
|
31
|
+
checksums: {
|
|
32
|
+
header: rom.header.headerCRC,
|
|
33
|
+
logo: rom.header.logoCRC,
|
|
34
|
+
secureArea: rom.header.secureCRC,
|
|
35
|
+
banner: rom.banner.bannerCRC,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
/** Synchronous CPU work; call in a Web Worker for responsive browser UIs. */
|
|
40
|
+
export const patchBuffer = (
|
|
41
|
+
original: Uint8Array,
|
|
42
|
+
archive: Uint8Array,
|
|
43
|
+
options: BrowserPatchOptions = {},
|
|
44
|
+
): BrowserPatchResult => {
|
|
45
|
+
const result = patch(original, archive, options);
|
|
46
|
+
// Return a plain transferable typed array, never a Node Buffer in public types.
|
|
47
|
+
const buffer = new Uint8Array(
|
|
48
|
+
result.buffer.buffer as ArrayBuffer,
|
|
49
|
+
result.buffer.byteOffset,
|
|
50
|
+
result.buffer.byteLength,
|
|
51
|
+
);
|
|
52
|
+
return {
|
|
53
|
+
buffer,
|
|
54
|
+
returnValue: result.returnValue,
|
|
55
|
+
inputMd5: result.inputMd5,
|
|
56
|
+
outputMd5: result.outputMd5,
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
export const PatchHelper = Object.freeze({ patchBuffer });
|
|
60
|
+
export const NitroHelper = Object.freeze({ inspectRom });
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { parseArgs } from 'node:util';
|
|
5
|
+
import { patchIt } from './nitro-patch-helper';
|
|
6
|
+
|
|
7
|
+
const help = `nitro-patcher — 原生 Node.js NDS ROM 补丁工具
|
|
8
|
+
|
|
9
|
+
用法:nitro-patcher [--json] <原始ROM> <补丁ZIP> <输出ROM>
|
|
10
|
+
|
|
11
|
+
--json 输出 JSON 状态和 MD5
|
|
12
|
+
-h, --help 显示帮助
|
|
13
|
+
-v, --version 显示版本`;
|
|
14
|
+
|
|
15
|
+
const sameFile = async (input: string, output: string): Promise<boolean> => {
|
|
16
|
+
if (resolve(input) === resolve(output)) return true;
|
|
17
|
+
const original = await stat(input);
|
|
18
|
+
try {
|
|
19
|
+
const destination = await stat(output);
|
|
20
|
+
return original.dev === destination.dev && original.ino === destination.ino;
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error;
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const main = async (): Promise<void> => {
|
|
28
|
+
const { values, positionals } = parseArgs({
|
|
29
|
+
allowPositionals: true,
|
|
30
|
+
options: {
|
|
31
|
+
help: { type: 'boolean', short: 'h' },
|
|
32
|
+
version: { type: 'boolean', short: 'v' },
|
|
33
|
+
json: { type: 'boolean' },
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
if (values.help) {
|
|
37
|
+
process.stdout.write(help);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (values.version) {
|
|
41
|
+
const metadata: unknown = JSON.parse(
|
|
42
|
+
await readFile(new URL('../package.json', import.meta.url), 'utf8'),
|
|
43
|
+
);
|
|
44
|
+
if (
|
|
45
|
+
!metadata ||
|
|
46
|
+
typeof metadata !== 'object' ||
|
|
47
|
+
!('version' in metadata) ||
|
|
48
|
+
typeof metadata.version !== 'string'
|
|
49
|
+
)
|
|
50
|
+
throw new Error('Invalid package version metadata');
|
|
51
|
+
console.log(metadata.version);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (positionals.length !== 3) throw new Error(help);
|
|
55
|
+
const [original, patch, output] = positionals;
|
|
56
|
+
if ((await sameFile(original, output)) || (await sameFile(patch, output)))
|
|
57
|
+
throw new Error('输出路径不能覆盖原始 ROM 或补丁包。');
|
|
58
|
+
const result = await patchIt(original, patch);
|
|
59
|
+
const destination = join(await realpath(dirname(resolve(output))), basename(output));
|
|
60
|
+
const temporary = await mkdtemp(join(dirname(destination), '.nitro-patcher-'));
|
|
61
|
+
try {
|
|
62
|
+
const file = join(temporary, 'output.nds');
|
|
63
|
+
await writeFile(file, result.buffer);
|
|
64
|
+
await rename(file, destination);
|
|
65
|
+
} finally {
|
|
66
|
+
await rm(temporary, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
const { returnValue, inputMd5, outputMd5 } = result;
|
|
69
|
+
if (values.json) console.log(JSON.stringify({ returnValue, inputMd5, outputMd5 }));
|
|
70
|
+
else console.log(`${returnValue}\n\n原始 ROM 的 MD5:${inputMd5}\n生成 ROM 的 MD5:${outputMd5}`);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
main().catch((error: unknown) => {
|
|
74
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Port of NitroHelper/Banner.cs. Copyright Xzonn and upstream contributors, GPL-3.0.
|
|
2
|
+
import { bytes, range } from './binary';
|
|
3
|
+
import { crc16 } from './crc';
|
|
4
|
+
|
|
5
|
+
const titles = [
|
|
6
|
+
'japaneseTitle',
|
|
7
|
+
'englishTitle',
|
|
8
|
+
'frenchTitle',
|
|
9
|
+
'germanTitle',
|
|
10
|
+
'italianTitle',
|
|
11
|
+
'spanishTitle',
|
|
12
|
+
'chineseTitle',
|
|
13
|
+
'koreanTitle',
|
|
14
|
+
] as const;
|
|
15
|
+
export class Banner {
|
|
16
|
+
version: number;
|
|
17
|
+
raw: Buffer;
|
|
18
|
+
bannerCRC: boolean;
|
|
19
|
+
tileData: Buffer;
|
|
20
|
+
palette: Buffer;
|
|
21
|
+
japaneseTitle = '';
|
|
22
|
+
englishTitle = '';
|
|
23
|
+
frenchTitle = '';
|
|
24
|
+
germanTitle = '';
|
|
25
|
+
italianTitle = '';
|
|
26
|
+
spanishTitle = '';
|
|
27
|
+
chineseTitle = '';
|
|
28
|
+
koreanTitle = '';
|
|
29
|
+
constructor(input: Uint8Array, offset = 0, hardSize = 0) {
|
|
30
|
+
const data = bytes(input, 'banner');
|
|
31
|
+
this.version = range(data, offset, 2, 'banner').readUInt16LE();
|
|
32
|
+
let size = this.getDefSize(hardSize);
|
|
33
|
+
if (this.version >>> 8 === 1)
|
|
34
|
+
size = hardSize > 0 && hardSize !== 0xffffffff ? Math.min(hardSize, 0x23c0) : 0x23c0;
|
|
35
|
+
this.raw = Buffer.from(range(data, offset, size, 'banner'));
|
|
36
|
+
this.bannerCRC = crc16(this.raw.subarray(0x20, 0x840)) === this.raw.readUInt16LE(2);
|
|
37
|
+
this.tileData = Buffer.from(this.raw.subarray(0x20, 0x220));
|
|
38
|
+
this.palette = Buffer.from(this.raw.subarray(0x220, 0x240));
|
|
39
|
+
for (const [i, title] of titles.entries())
|
|
40
|
+
this[title] =
|
|
41
|
+
i < this.titleCount
|
|
42
|
+
? this.raw
|
|
43
|
+
.subarray(0x240 + i * 0x100, 0x340 + i * 0x100)
|
|
44
|
+
.toString('utf16le')
|
|
45
|
+
.split('\0')[0]
|
|
46
|
+
: '';
|
|
47
|
+
}
|
|
48
|
+
get titleCount() {
|
|
49
|
+
return this.version >= 3 ? 8 : this.version >= 2 ? 7 : 6;
|
|
50
|
+
}
|
|
51
|
+
getDefSize(hardSize = 0) {
|
|
52
|
+
if (this.version === 2) return 0x940;
|
|
53
|
+
if (this.version === 3) return 0xa40;
|
|
54
|
+
if (this.version === 0x103) return hardSize > 0 && hardSize !== 0xffffffff ? hardSize : 0x23c0;
|
|
55
|
+
return 0x840;
|
|
56
|
+
}
|
|
57
|
+
toBuffer() {
|
|
58
|
+
const output = Buffer.from(this.raw);
|
|
59
|
+
output.writeUInt16LE(this.version);
|
|
60
|
+
if (this.tileData.length !== 512 || this.palette.length !== 32)
|
|
61
|
+
throw new RangeError('Invalid banner icon size');
|
|
62
|
+
this.tileData.copy(output, 0x20);
|
|
63
|
+
this.palette.copy(output, 0x220);
|
|
64
|
+
for (let i = 0; i < this.titleCount; i++) {
|
|
65
|
+
const text = Buffer.from(this[titles[i]], 'utf16le');
|
|
66
|
+
if (text.length > 256)
|
|
67
|
+
throw new RangeError(`Banner ${titles[i]} exceeds 128 UTF-16 code units`);
|
|
68
|
+
output.fill(0, 0x240 + i * 256, 0x340 + i * 256);
|
|
69
|
+
text.copy(output, 0x240 + i * 256);
|
|
70
|
+
}
|
|
71
|
+
output.writeUInt16LE(crc16(output.subarray(0x20, 0x840)), 2);
|
|
72
|
+
if (this.version >= 2) output.writeUInt16LE(crc16(output.subarray(0x20, 0x940)), 4);
|
|
73
|
+
if (this.version >= 3) output.writeUInt16LE(crc16(output.subarray(0x20, 0xa40)), 6);
|
|
74
|
+
if (this.version >>> 8 >= 1) output.writeUInt16LE(crc16(output.subarray(0x1240, 0x23c0)), 8);
|
|
75
|
+
return output;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export const DEFAULT_MAX_SIZE = 0x40000000;
|
|
2
|
+
|
|
3
|
+
export const bytes = (value: Uint8Array, name = 'input'): Buffer => {
|
|
4
|
+
if (!(value instanceof Uint8Array)) throw new TypeError(`${name} must be a Buffer or Uint8Array`);
|
|
5
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const range = (
|
|
9
|
+
buffer: Buffer,
|
|
10
|
+
offset: number,
|
|
11
|
+
size: number,
|
|
12
|
+
name = 'binary data',
|
|
13
|
+
): Buffer => {
|
|
14
|
+
if (
|
|
15
|
+
!Number.isSafeInteger(offset) ||
|
|
16
|
+
!Number.isSafeInteger(size) ||
|
|
17
|
+
offset < 0 ||
|
|
18
|
+
size < 0 ||
|
|
19
|
+
offset + size > buffer.length
|
|
20
|
+
) {
|
|
21
|
+
throw new RangeError(
|
|
22
|
+
`Invalid ${name} range: offset=${offset}, size=${size}, available=${buffer.length}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return buffer.subarray(offset, offset + size);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const align = (value: number, alignment = 0x200): number => {
|
|
29
|
+
return Math.ceil(value / alignment) * alignment;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Seekable, bounded in-memory writer; unwritten gaps have MemoryStream's zero fill. */
|
|
33
|
+
export class BinaryWriter {
|
|
34
|
+
readonly maxSize: number;
|
|
35
|
+
buffer: Buffer;
|
|
36
|
+
position: number;
|
|
37
|
+
length: number;
|
|
38
|
+
constructor(initialSize = 65536, maxSize = DEFAULT_MAX_SIZE) {
|
|
39
|
+
if (!Number.isSafeInteger(maxSize) || maxSize < 1 || maxSize > 0xffffffff)
|
|
40
|
+
throw new RangeError('Invalid output size limit');
|
|
41
|
+
this.maxSize = maxSize;
|
|
42
|
+
this.buffer = Buffer.alloc(Math.min(initialSize, maxSize));
|
|
43
|
+
this.position = 0;
|
|
44
|
+
this.length = 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
ensure(end: number) {
|
|
48
|
+
if (!Number.isSafeInteger(end) || end < 0 || end > this.maxSize)
|
|
49
|
+
throw new RangeError(`Output exceeds size limit (${this.maxSize} bytes)`);
|
|
50
|
+
if (end <= this.buffer.length) return;
|
|
51
|
+
const next = Buffer.alloc(Math.min(this.maxSize, Math.max(end, this.buffer.length * 2, 1024)));
|
|
52
|
+
this.buffer.copy(next, 0, 0, this.length);
|
|
53
|
+
this.buffer = next;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
write(data: Uint8Array) {
|
|
57
|
+
data = bytes(data);
|
|
58
|
+
if (!Number.isSafeInteger(this.position) || this.position < 0)
|
|
59
|
+
throw new RangeError('Invalid writer position');
|
|
60
|
+
this.ensure(this.position + data.length);
|
|
61
|
+
bytes(data).copy(this.buffer, this.position);
|
|
62
|
+
this.position += data.length;
|
|
63
|
+
this.length = Math.max(this.length, this.position);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fillTo(end: number, fill = 0xff) {
|
|
67
|
+
this.ensure(end);
|
|
68
|
+
if (end <= this.position) return;
|
|
69
|
+
this.buffer.fill(fill, this.position, end);
|
|
70
|
+
this.position = end;
|
|
71
|
+
this.length = Math.max(this.length, end);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pad(alignment: number, fill = 0xff) {
|
|
75
|
+
this.fillTo(align(this.position, alignment), fill);
|
|
76
|
+
}
|
|
77
|
+
toBuffer() {
|
|
78
|
+
return this.buffer.subarray(0, this.length);
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/nitro/blz.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
export interface Arm9Header {
|
|
2
|
+
reserved2: Buffer;
|
|
3
|
+
ARM9ramAddress: number;
|
|
4
|
+
ARM9size: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Native port of Xzonn/BlzHelper BLZ.cs (MIT), including overwrite-safe prefix.
|
|
8
|
+
// Compression returns an empty Buffer when no smaller representation exists.
|
|
9
|
+
export const decompressBLZ = (input: Buffer, maxSize = 0x10000000): Buffer => {
|
|
10
|
+
if (input.length < 8) throw new Error('BLZ footer is truncated');
|
|
11
|
+
const diff = input.readUInt32LE(input.length - 4);
|
|
12
|
+
const packed = input.readUInt32LE(input.length - 8),
|
|
13
|
+
header = packed >>> 24,
|
|
14
|
+
length = packed & 0xffffff;
|
|
15
|
+
const size = input.length + diff,
|
|
16
|
+
prefix = input.length - length;
|
|
17
|
+
if (header < 8 || header > length || length > input.length || size > maxSize)
|
|
18
|
+
throw new Error('Invalid BLZ size or footer');
|
|
19
|
+
const out = Buffer.alloc(size);
|
|
20
|
+
input.copy(out, 0, 0, prefix);
|
|
21
|
+
let src = input.length - header,
|
|
22
|
+
dst = size;
|
|
23
|
+
while (src > prefix) {
|
|
24
|
+
const flags = input[--src];
|
|
25
|
+
for (let bit = 128; bit && src > prefix; bit >>= 1) {
|
|
26
|
+
if (!(flags & bit)) {
|
|
27
|
+
if (dst <= prefix) throw new Error('BLZ output overflow');
|
|
28
|
+
out[--dst] = input[--src];
|
|
29
|
+
} else {
|
|
30
|
+
if (src - 2 < prefix) throw new Error('Truncated BLZ reference');
|
|
31
|
+
const high = input[--src],
|
|
32
|
+
low = input[--src];
|
|
33
|
+
const count = (high >>> 4) + 3,
|
|
34
|
+
offset = (((high & 15) << 8) | low) + 3;
|
|
35
|
+
if (dst - count < prefix || dst - 1 + offset >= size)
|
|
36
|
+
throw new Error('Invalid BLZ reference');
|
|
37
|
+
for (let j = 0; j < count; j++) {
|
|
38
|
+
--dst;
|
|
39
|
+
out[dst] = out[dst + offset];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (dst !== prefix) throw new Error('BLZ decompressed size mismatch');
|
|
45
|
+
return out;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const compressBLZ = (input: Buffer): Buffer => {
|
|
49
|
+
if (input.length > 0xffffff) throw new Error('BLZ input exceeds 24-bit format limit');
|
|
50
|
+
const temp = Buffer.alloc(input.length);
|
|
51
|
+
let src = input.length,
|
|
52
|
+
dst = input.length;
|
|
53
|
+
// Linked candidate lists retain upstream nearest-match tie breaking without
|
|
54
|
+
// scanning every byte of the 4 KiB search window for each literal.
|
|
55
|
+
const heads = new Int32Array(256).fill(-1);
|
|
56
|
+
const next = new Int32Array(input.length);
|
|
57
|
+
let indexedFrom = input.length;
|
|
58
|
+
while (src > 0) {
|
|
59
|
+
if (dst < 1) return Buffer.alloc(0);
|
|
60
|
+
const flagPos = --dst;
|
|
61
|
+
let flags = 0;
|
|
62
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
63
|
+
flags <<= 1;
|
|
64
|
+
if (!src) continue;
|
|
65
|
+
while (indexedFrom > src) {
|
|
66
|
+
const p = --indexedFrom;
|
|
67
|
+
next[p] = heads[input[p]];
|
|
68
|
+
heads[input[p]] = p;
|
|
69
|
+
}
|
|
70
|
+
const maxCount = Math.min(src, 18);
|
|
71
|
+
let best = 0,
|
|
72
|
+
offset = 0;
|
|
73
|
+
for (let p = heads[input[src - 1]]; p >= 0; p = next[p]) {
|
|
74
|
+
const d = p - src + 1;
|
|
75
|
+
if (d > 0x1002) break;
|
|
76
|
+
let n = 0;
|
|
77
|
+
const limit = Math.min(d, maxCount);
|
|
78
|
+
while (n < limit && input[src - 1 - n] === input[src + d - 1 - n]) n++;
|
|
79
|
+
if (n > best) {
|
|
80
|
+
best = n;
|
|
81
|
+
offset = d;
|
|
82
|
+
if (best === maxCount) break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (best < 3) {
|
|
86
|
+
if (dst < 1) return Buffer.alloc(0);
|
|
87
|
+
temp[--dst] = input[--src];
|
|
88
|
+
} else {
|
|
89
|
+
if (dst < 2) return Buffer.alloc(0);
|
|
90
|
+
src -= best;
|
|
91
|
+
dst -= 2;
|
|
92
|
+
temp[dst] = (offset - 3) & 255;
|
|
93
|
+
temp[dst + 1] = ((best - 3) << 4) | ((offset - 3) >>> 8);
|
|
94
|
+
flags |= 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
temp[flagPos] = flags;
|
|
98
|
+
}
|
|
99
|
+
let packed = temp.subarray(dst),
|
|
100
|
+
sourcePos = input.length,
|
|
101
|
+
compressedPos = packed.length,
|
|
102
|
+
prefix = 0,
|
|
103
|
+
skip = 0;
|
|
104
|
+
outer: while (sourcePos > 0) {
|
|
105
|
+
const flags = packed[--compressedPos];
|
|
106
|
+
for (let bit = 128; bit && sourcePos > 0; bit >>= 1) {
|
|
107
|
+
if (!(flags & bit)) {
|
|
108
|
+
compressedPos--;
|
|
109
|
+
sourcePos--;
|
|
110
|
+
} else {
|
|
111
|
+
sourcePos -= (packed[compressedPos - 1] >>> 4) + 3;
|
|
112
|
+
compressedPos -= 2;
|
|
113
|
+
if (sourcePos < compressedPos) {
|
|
114
|
+
prefix = sourcePos;
|
|
115
|
+
skip = compressedPos;
|
|
116
|
+
break outer;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
packed = Buffer.concat([input.subarray(0, prefix), packed.subarray(skip)]);
|
|
122
|
+
const aligned = Math.ceil(packed.length / 4) * 4,
|
|
123
|
+
finalSize = aligned + 8;
|
|
124
|
+
if (finalSize >= input.length) return Buffer.alloc(0);
|
|
125
|
+
const out = Buffer.alloc(finalSize, 255);
|
|
126
|
+
packed.copy(out);
|
|
127
|
+
const header = finalSize - packed.length,
|
|
128
|
+
length = packed.length - prefix + header;
|
|
129
|
+
out.writeUInt32LE(((header << 24) | length) >>> 0, finalSize - 8);
|
|
130
|
+
out.writeUInt32LE(input.length - finalSize, finalSize - 4);
|
|
131
|
+
return out;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export const decompressArm9 = (
|
|
135
|
+
data: Buffer,
|
|
136
|
+
header: Arm9Header,
|
|
137
|
+
): { compressed: boolean; data: Buffer } => {
|
|
138
|
+
if (data.length < 0x18) throw new Error('ARM9 data too short');
|
|
139
|
+
const nitro = data.readUInt32LE(0xc) === 0xdec00621 ? 12 : 0;
|
|
140
|
+
const init = header.reserved2.readUInt32LE(0) & 0x3fff;
|
|
141
|
+
const ptr = init ? data.readUInt32LE(init + 0x14) : header.ARM9ramAddress + header.ARM9size;
|
|
142
|
+
const compressed =
|
|
143
|
+
ptr > header.ARM9ramAddress && ptr + nitro >= header.ARM9ramAddress + data.length;
|
|
144
|
+
return {
|
|
145
|
+
compressed,
|
|
146
|
+
data: compressed
|
|
147
|
+
? Buffer.concat([
|
|
148
|
+
decompressBLZ(data.subarray(0, data.length - nitro)),
|
|
149
|
+
data.subarray(data.length - nitro),
|
|
150
|
+
])
|
|
151
|
+
: Buffer.from(data),
|
|
152
|
+
};
|
|
153
|
+
};
|
|
154
|
+
export const compressArm9 = (data: Buffer, header: Arm9Header, postSize = 0): Buffer => {
|
|
155
|
+
if (data.length < 0x4000) throw new Error('ARM9 secure area is truncated');
|
|
156
|
+
const nitro = data.readUInt32LE(0xc) === 0xdec00621 ? 12 : 0;
|
|
157
|
+
const packed = compressBLZ(data.subarray(0x4000, data.length - nitro));
|
|
158
|
+
if (!packed.length) throw new Error('ARM9 BLZ compression produced no smaller representation');
|
|
159
|
+
const out = Buffer.concat([data.subarray(0, 0x4000), packed, data.subarray(data.length - nitro)]);
|
|
160
|
+
const init = header.reserved2.readUInt32LE(0) & 0x3fff;
|
|
161
|
+
if (init) out.writeUInt32LE(out.length - postSize + header.ARM9ramAddress, init + 0x14);
|
|
162
|
+
return out;
|
|
163
|
+
};
|
package/src/nitro/crc.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// CRCs used by the Nintendo DS header and banner (NitroHelper, GPL-3.0).
|
|
2
|
+
const table16 = new Uint16Array(256);
|
|
3
|
+
const table32 = new Uint32Array(256);
|
|
4
|
+
for (let i = 0; i < 256; i++) {
|
|
5
|
+
let a = i,
|
|
6
|
+
b = i;
|
|
7
|
+
for (let bit = 0; bit < 8; bit++) {
|
|
8
|
+
a = (a >>> 1) ^ (a & 1 ? 0xa001 : 0);
|
|
9
|
+
b = (b >>> 1) ^ (b & 1 ? 0xedb88320 : 0);
|
|
10
|
+
}
|
|
11
|
+
table16[i] = a;
|
|
12
|
+
table32[i] = b;
|
|
13
|
+
}
|
|
14
|
+
export const crc16 = (data: Uint8Array, initial = 0xffff) => {
|
|
15
|
+
let crc = initial;
|
|
16
|
+
for (const byte of data) crc = (crc >>> 8) ^ table16[(crc ^ byte) & 0xff];
|
|
17
|
+
return crc;
|
|
18
|
+
};
|
|
19
|
+
export const crc32 = (data: Uint8Array) => {
|
|
20
|
+
let crc = 0xffffffff;
|
|
21
|
+
for (const byte of data) crc = (crc >>> 8) ^ table32[(crc ^ byte) & 0xff];
|
|
22
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
23
|
+
};
|