Skip to content

Commit 904344c

Browse files
committed
feat: support arknights lz4
1 parent a9c1c11 commit 904344c

File tree

3 files changed

+121
-31
lines changed

3 files changed

+121
-31
lines changed

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ Currently only supports:
1515

1616
```js
1717
import fs from 'fs';
18-
import { loadAssetBundle, AssetType } from '@arkntools/unity-js';
18+
import { loadAssetBundle, AssetType, BundleEnv } from '@arkntools/unity-js';
1919

2020
(async () => {
21-
const bundle = await loadAssetBundle(fs.readFileSync('character_table003334.ab'));
21+
const bundle = await loadAssetBundle(fs.readFileSync('character_table003334.ab', { env: BundleEnv.ARKNIGHTS }));
2222
for (const obj of bundle.objects) {
2323
if (obj.type === AssetType.TextAsset) {
2424
fs.writeFileSync(`${obj.name}.bytes`, obj.data);
@@ -28,7 +28,7 @@ import { loadAssetBundle, AssetType } from '@arkntools/unity-js';
2828
})();
2929

3030
(async () => {
31-
const bundle = await loadAssetBundle(fs.readFileSync('spritepack_ui_char_avatar_h1_0.ab'));
31+
const bundle = await loadAssetBundle(fs.readFileSync('spritepack_ui_char_avatar_h1_0.ab', { env: BundleEnv.ARKNIGHTS }));
3232
for (const obj of bundle.objects) {
3333
if (obj.type === AssetType.Sprite && obj.name === 'char_002_amiya') {
3434
fs.writeFileSync(`${obj.name}.png`, await obj.getImage()!);
@@ -38,7 +38,7 @@ import { loadAssetBundle, AssetType } from '@arkntools/unity-js';
3838
})();
3939

4040
(async () => {
41-
const bundle = await loadAssetBundle(fs.readFileSync('char_1028_texas2.ab'), {
41+
const bundle = await loadAssetBundle(fs.readFileSync('char_1028_texas2.ab', { env: BundleEnv.ARKNIGHTS }), {
4242
// Some sprites may not give the PathID of the alpha texture, you can provide a custom function to find it.
4343
findAlphaTexture: (texture, assets) =>
4444
assets.find(({ name }) => name === `${texture.name}[alpha]`),
@@ -58,3 +58,4 @@ import { loadAssetBundle, AssetType } from '@arkntools/unity-js';
5858
- [RazTools/Studio](https://github.com/RazTools/Studio)
5959
- [K0lb3/UnityPy](https://github.com/K0lb3/UnityPy)
6060
- [yuanyan3060/unity-rs](https://github.com/yuanyan3060/unity-rs)
61+
- [MooncellWiki/UnityPy](https://github.com/MooncellWiki/UnityPy)

src/bundle.ts

Lines changed: 96 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { AssetObject } from './classes';
55
import { concatArrayBuffer, ensureArrayBuffer } from './utils/buffer';
66
import { ArrayBufferReader } from './utils/reader';
77
import { UnityCN } from './utils/unitycn';
8+
import { isVersionLargerThanOrEqual, parseVersion } from './utils/version';
89
import { unzipIfNeed } from './utils/zip';
910
import { AssetType } from '.';
1011
import type { AssetBundle, Texture2D } from '.';
@@ -60,7 +61,8 @@ enum CompressionType {
6061
LZMA,
6162
LZ4,
6263
LZ4_HC,
63-
LZHAM,
64+
CUSTOM_4,
65+
CUSTOM_5,
6466
}
6567

6668
enum FileType {
@@ -73,10 +75,15 @@ enum FileType {
7375
ZIP_FILE,
7476
}
7577

78+
export enum BundleEnv {
79+
ARKNIGHTS,
80+
}
81+
7682
export interface BundleLoadOptions {
7783
/** 有些 Sprite 可能不会给出 AlphaTexture 的 PathID,可以传入自定义函数去寻找 */
7884
findAlphaTexture?: (texture: Texture2D, assets: Texture2D[]) => Texture2D | undefined;
7985
unityCNKey?: string;
86+
env?: BundleEnv;
8087
}
8188

8289
export class Bundle {
@@ -170,7 +177,7 @@ export class Bundle {
170177
private readUnityCN(r: ArrayBufferReader, key: string) {
171178
let mask: ArchiveFlags;
172179

173-
const version = this.parseVersion(this.header.unityReversion);
180+
const version = parseVersion(this.header.unityReversion);
174181
if (
175182
version[0] < 2020 || // 2020 and earlier
176183
(version[0] === 2020 && version[1] === 3 && version[2] <= 34) || // 2020.3.34 and earlier
@@ -195,11 +202,19 @@ export class Bundle {
195202
throw new Error(`Unsupported bundle flags: ${ArchiveFlags[flags] || flags}`);
196203
}
197204

205+
const reversion = parseVersion(this.header.unityReversion);
206+
198207
if (version >= 7) r.align(16);
208+
else if (isVersionLargerThanOrEqual(reversion, [2019, 4])) {
209+
const preAlign = r.position;
210+
const align = (16 - (preAlign % 16)) % 16;
211+
if (align) r.move(align);
212+
}
199213

200214
const blockInfoBuffer = r.readBuffer(compressedBlocksInfoSize);
201215
const compressionType = flags & ArchiveFlags.COMPRESSION_TYPE_MASK;
202-
const blockInfoUncompressedBuffer = decompressBuffer(
216+
217+
const blockInfoUncompressedBuffer = this.decompressBuffer(
203218
blockInfoBuffer,
204219
compressionType,
205220
uncompressedBlocksInfoSize,
@@ -237,13 +252,15 @@ export class Bundle {
237252
private readBlocks(r: ArrayBufferReader) {
238253
const results: ArrayBuffer[] = [];
239254

255+
if (this.header.flags & ArchiveFlags.BLOCK_INFO_NEED_PADDING_AT_START) r.align(16);
256+
240257
for (const [i, { flags, compressedSize, uncompressedSize }] of this.blockInfos.entries()) {
241258
const compressionType = flags & StorageBlockFlags.COMPRESSION_TYPE_MASK;
242259
const compressedBuffer = r.readBuffer(compressedSize);
243260
if (this.unityCN && flags & 0x100) {
244261
this.unityCN.decryptBlock(compressedBuffer, i);
245262
}
246-
const uncompressedBuffer = decompressBuffer(
263+
const uncompressedBuffer = this.decompressBuffer(
247264
compressedBuffer,
248265
compressionType,
249266
uncompressedSize,
@@ -266,38 +283,90 @@ export class Bundle {
266283
return files;
267284
}
268285

269-
private parseVersion(str: string) {
270-
return str
271-
.replace(/\D/g, '.')
272-
.split('.')
273-
.filter(Boolean)
274-
.map(v => parseInt(v));
286+
private decompressBuffer(
287+
data: ArrayBuffer,
288+
type: number,
289+
uncompressedSize?: number,
290+
): ArrayBuffer {
291+
if (type === CompressionType.NONE) return data;
292+
293+
if (!uncompressedSize) throw new Error('Uncompressed size not provided');
294+
295+
switch (type) {
296+
case CompressionType.LZMA:
297+
return decompressLzmaWithSize(new Uint8Array(data), uncompressedSize);
298+
299+
case CompressionType.LZ4:
300+
case CompressionType.LZ4_HC:
301+
return decompressLz4(new Uint8Array(data), uncompressedSize).buffer;
302+
}
303+
304+
const isArknights = this.options?.env === BundleEnv.ARKNIGHTS;
305+
306+
if (isArknights && (type === CompressionType.CUSTOM_4 || type === CompressionType.CUSTOM_5)) {
307+
return decompressArkLz4(data, uncompressedSize).buffer;
308+
}
309+
310+
throw new Error(`Unsupported compression type: ${CompressionType[type] || type}`);
275311
}
276312
}
277313

278-
const decompressBuffer = (
279-
data: ArrayBuffer,
280-
type: number,
281-
uncompressedSize?: number,
282-
): ArrayBuffer => {
283-
if (type === CompressionType.NONE) return data;
314+
const readLongLengthNoCheck = (ip: Uint8Array, pos: number): [number, number] => {
315+
let b = 0;
316+
let l = 0;
317+
while (true) {
318+
b = ip[pos];
319+
pos++;
320+
l += b;
321+
if (b !== 255) break;
322+
}
323+
return [l, pos];
324+
};
325+
326+
// From https://github.com/MooncellWiki/UnityPy by Kengxxiao
327+
const decompressArkLz4 = (data: ArrayBuffer, uncompressedSize: number) => {
328+
const AK_LITERAL_LENGTH_MASK = ((1 << 4) - 1) & 0xff;
329+
const AK_MATCH_LENGTH_MASK = ~AK_LITERAL_LENGTH_MASK & 0xff;
330+
331+
const fixedCompressedData = new Uint8Array(data);
332+
333+
let ip = 0;
334+
let op = 0;
284335

285-
if (!uncompressedSize) throw new Error('Uncompressed size not provided');
336+
while (true) {
337+
let literalLength = fixedCompressedData[ip] & AK_LITERAL_LENGTH_MASK;
338+
let matchLength = ((fixedCompressedData[ip] & AK_MATCH_LENGTH_MASK) >> 4) & 0xff;
286339

287-
switch (type) {
288-
case CompressionType.LZMA:
289-
return decompressLzmaWithSize(new Uint8Array(data), uncompressedSize);
340+
fixedCompressedData[ip] = ((literalLength << 4) | matchLength) & 0xff;
341+
ip++;
290342

291-
case CompressionType.LZ4:
292-
case CompressionType.LZ4_HC:
293-
return decompressLz4(new Uint8Array(data), uncompressedSize).buffer;
343+
if (literalLength === 15) {
344+
const [l, newIp] = readLongLengthNoCheck(fixedCompressedData, ip);
345+
literalLength += l;
346+
ip = newIp;
347+
}
348+
349+
op += literalLength;
350+
ip += literalLength;
351+
352+
if (uncompressedSize <= op) break;
294353

295-
case CompressionType.LZHAM:
296-
throw new Error('Not implemented');
354+
const offset = fixedCompressedData[ip + 1] | (fixedCompressedData[ip] << 8);
355+
fixedCompressedData[ip] = offset & 0xff;
356+
fixedCompressedData[ip + 1] = (offset >> 8) & 0xff;
357+
ip += 2;
297358

298-
default:
299-
throw new Error(`Unsupported compression type: ${CompressionType[type] || type}`);
359+
if (matchLength === 15) {
360+
const [m, newIp] = readLongLengthNoCheck(fixedCompressedData, ip);
361+
matchLength += m;
362+
ip = newIp;
363+
}
364+
365+
matchLength += 4;
366+
op += matchLength;
300367
}
368+
369+
return decompressLz4(fixedCompressedData, uncompressedSize);
301370
};
302371

303372
const getFileType = (data: ArrayBuffer) => {

src/utils/version.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export const parseVersion = (version: string) =>
2+
version
3+
.replace(/\D/g, '.')
4+
.split('.')
5+
.filter(Boolean)
6+
.map(str => parseInt(str));
7+
8+
export const isVersionLargerThanOrEqual = (version: number[], target: number[]) => {
9+
const maxLength = Math.max(version.length, target.length);
10+
11+
for (let i = 0; i < maxLength; i++) {
12+
const v1 = version[i] ?? 0;
13+
const v2 = target[i] ?? 0;
14+
15+
if (v1 > v2) return true;
16+
if (v1 < v2) return false;
17+
}
18+
19+
return true;
20+
};

0 commit comments

Comments
 (0)