mirror of
https://github.com/morluto/rea.git
synced 2026-10-30 19:35:56 +00:00
* feat(artifacts): read UnityFS AssetBundle containers inspect-artifact and extract-artifact now recognize the UnityFS signature and treat format versions 6 through 9 as containers. Header facts (format version, player/engine strings, declared size, dataflags, blocks-info bounds and the declared 16-byte data hash) are retained alongside a directory of CAB-* and .resS members. Member bytes stream on demand: only the blocks covering the requested member are read and decompressed with bounded expansion and cancellation. Bundles declaring asset-bundle encryption keep header facts and report unknown directory coverage. LZMA blocks-info reports unknown coverage; LZMA member reads fail as unavailable while the directory stays readable. Malformed counts, out-of-stream members and declared sizes beyond the file fail as format errors. UnityFS classifies by bytes in root detection, inventory graph construction and desktop inspection, which emits a unityfs-header fact. Validation: tsc --noEmit, oxlint (no new findings on touched files), 17 reader tests covering LZ4HC/uncompressed tables, blocks-info at end, format 6 without padding alignment, multi-block members, malformed input, unsupported compression, cancellation and abandoned streams; full artifacts/domain/conformance suites pass. Verified against one real YooAsset bundle (Unity 2021.3.32f1, format 8, LZ4HC): the streamed SerializedFile contained its managed assembly byte-identical to a UnityPy extraction. Other producer variants unverified. * fix(unityfs): own bounded member reads and preserve partial evidence * refactor(unityfs): clarify producer revision flag decisions * fix(artifacts): preserve coverage authority and bound UnityFS block work --------- Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
195 lines
6.2 KiB
TypeScript
195 lines
6.2 KiB
TypeScript
/** Synthetic UnityFS (AssetBundle) builders for reader tests. */
|
|
|
|
export interface UnityFsMember {
|
|
readonly path: string;
|
|
readonly data: string | Buffer;
|
|
readonly flags?: number;
|
|
readonly offset?: number;
|
|
readonly size?: number;
|
|
}
|
|
|
|
export interface UnityFsOptions {
|
|
readonly formatVersion?: number;
|
|
readonly playerVersion?: string;
|
|
readonly engineVersion?: string;
|
|
readonly flags?: number;
|
|
readonly blocksInfoAtEnd?: boolean;
|
|
readonly blocksInfoCompression?: number;
|
|
readonly blockCompression?: number;
|
|
readonly blockSize?: number;
|
|
readonly declaredNodeCount?: number;
|
|
readonly declaredBlockCount?: number;
|
|
readonly encodeBlock?: (data: Buffer) => Buffer;
|
|
}
|
|
|
|
const utf8 = (value: string): Buffer => Buffer.from(value, "utf8");
|
|
const cstring = (value: string): Buffer =>
|
|
Buffer.concat([utf8(value), Buffer.alloc(1)]);
|
|
|
|
const align16 = (value: number): number => (value + 15) & ~15;
|
|
|
|
/** One final LZ4 literal sequence; separate sequences require intervening matches. */
|
|
export const lz4LiteralBlock = (data: Buffer): Buffer => {
|
|
const head = [Math.min(data.length, 15) << 4];
|
|
if (data.length >= 15) {
|
|
let extra = data.length - 15;
|
|
while (extra >= 255) {
|
|
head.push(255);
|
|
extra -= 255;
|
|
}
|
|
head.push(extra);
|
|
}
|
|
return Buffer.concat([Buffer.from(head), data]);
|
|
};
|
|
|
|
const maybeCompress = (data: Buffer, compression: number): Buffer =>
|
|
compression === 2 || compression === 3 ? lz4LiteralBlock(data) : data;
|
|
|
|
interface BlockRecord {
|
|
readonly uncompressedSize: number;
|
|
readonly compressedSize: number;
|
|
readonly flags: number;
|
|
}
|
|
|
|
/** Chunk the virtual stream into blocks the way a producer would. */
|
|
const chunkIntoBlocks = (
|
|
stream: Buffer,
|
|
blockSize: number,
|
|
compression: number,
|
|
encodeBlock?: (data: Buffer) => Buffer,
|
|
): { records: BlockRecord[]; payload: Buffer[] } => {
|
|
const records: BlockRecord[] = [];
|
|
const payload: Buffer[] = [];
|
|
for (
|
|
let offset = 0;
|
|
offset < stream.length || (stream.length === 0 && offset === 0);
|
|
) {
|
|
const uncompressed = stream.subarray(offset, offset + blockSize);
|
|
if (uncompressed.length === 0 && stream.length !== 0) break;
|
|
const compressed =
|
|
encodeBlock?.(uncompressed) ?? maybeCompress(uncompressed, compression);
|
|
records.push({
|
|
uncompressedSize: uncompressed.length,
|
|
compressedSize: compressed.length,
|
|
flags: compression,
|
|
});
|
|
payload.push(compressed);
|
|
offset += blockSize;
|
|
if (stream.length === 0) break;
|
|
}
|
|
return { records, payload };
|
|
};
|
|
|
|
const buildTable = (
|
|
members: readonly UnityFsMember[],
|
|
contents: readonly Buffer[],
|
|
blockRecords: readonly BlockRecord[],
|
|
options: UnityFsOptions,
|
|
): Buffer => {
|
|
let offsets = 0;
|
|
const nodeRecords = members.map((member, index) => {
|
|
const record = {
|
|
offset: member.offset ?? offsets,
|
|
size: member.size ?? contents[index]?.length ?? 0,
|
|
flags: member.flags ?? 0,
|
|
path: member.path,
|
|
};
|
|
offsets += record.size;
|
|
return record;
|
|
});
|
|
const parts: Buffer[] = [Buffer.alloc(16), Buffer.alloc(4)];
|
|
parts[1]!.writeUInt32BE(options.declaredBlockCount ?? blockRecords.length, 0);
|
|
for (const record of blockRecords) {
|
|
const row = Buffer.alloc(10);
|
|
row.writeUInt32BE(record.uncompressedSize, 0);
|
|
row.writeUInt32BE(record.compressedSize, 4);
|
|
row.writeUInt16BE(record.flags, 8);
|
|
parts.push(row);
|
|
}
|
|
const countRow = Buffer.alloc(4);
|
|
countRow.writeUInt32BE(options.declaredNodeCount ?? nodeRecords.length, 0);
|
|
parts.push(countRow);
|
|
for (const record of nodeRecords) {
|
|
const row = Buffer.alloc(20);
|
|
row.writeBigUInt64BE(BigInt(record.offset), 0);
|
|
row.writeBigUInt64BE(BigInt(record.size), 8);
|
|
row.writeUInt32BE(record.flags, 16);
|
|
parts.push(row, cstring(record.path));
|
|
}
|
|
return Buffer.concat(parts);
|
|
};
|
|
|
|
export const buildUnityFs = (
|
|
members: readonly UnityFsMember[],
|
|
options: UnityFsOptions = {},
|
|
): Buffer => {
|
|
const formatVersion = options.formatVersion ?? 8;
|
|
const playerVersion = options.playerVersion ?? "5.x.x";
|
|
const engineVersion = options.engineVersion ?? "2021.3.32f1c1";
|
|
const blockCompression = options.blockCompression ?? 3;
|
|
const blocksInfoCompression = options.blocksInfoCompression ?? 3;
|
|
const blockSize = options.blockSize ?? 131072;
|
|
const blocksInfoAtEnd = options.blocksInfoAtEnd ?? false;
|
|
const flags =
|
|
options.flags ??
|
|
blocksInfoCompression |
|
|
(blocksInfoAtEnd ? 0x80 : 0) |
|
|
(formatVersion >= 7 ? 0x200 : 0) |
|
|
0x40;
|
|
|
|
const contents = members.map(({ data }) =>
|
|
typeof data === "string" ? utf8(data) : data,
|
|
);
|
|
const { records: blockRecords, payload: compressedBlocks } = chunkIntoBlocks(
|
|
Buffer.concat(contents),
|
|
blockSize,
|
|
blockCompression,
|
|
options.encodeBlock,
|
|
);
|
|
const table = buildTable(members, contents, blockRecords, options);
|
|
const compressedTable = maybeCompress(table, blocksInfoCompression);
|
|
|
|
const headerPrefix = Buffer.concat([
|
|
Buffer.from("UnityFS\0", "latin1"),
|
|
Buffer.alloc(4),
|
|
]);
|
|
headerPrefix.writeUInt32BE(formatVersion, 8);
|
|
const headerStrings = Buffer.concat([
|
|
cstring(playerVersion),
|
|
cstring(engineVersion),
|
|
]);
|
|
let position = headerPrefix.length + headerStrings.length + 20;
|
|
if (formatVersion >= 7) position = align16(position);
|
|
|
|
const dataArea = Buffer.concat(compressedBlocks);
|
|
let dataStart: number;
|
|
let blocksInfoOffset: number;
|
|
if (blocksInfoAtEnd) {
|
|
dataStart = position;
|
|
blocksInfoOffset = position + dataArea.length;
|
|
} else {
|
|
blocksInfoOffset = position;
|
|
dataStart =
|
|
(flags & 0x200) !== 0
|
|
? align16(position + compressedTable.length)
|
|
: position + compressedTable.length;
|
|
}
|
|
const totalSize = Math.max(
|
|
dataStart + dataArea.length,
|
|
blocksInfoAtEnd ? blocksInfoOffset + compressedTable.length : 0,
|
|
dataStart,
|
|
);
|
|
|
|
const file = Buffer.alloc(totalSize);
|
|
headerPrefix.copy(file, 0);
|
|
headerStrings.copy(file, headerPrefix.length);
|
|
const fixed = headerPrefix.length + headerStrings.length;
|
|
file.writeBigUInt64BE(BigInt(totalSize), fixed);
|
|
file.writeUInt32BE(compressedTable.length, fixed + 8);
|
|
file.writeUInt32BE(table.length, fixed + 12);
|
|
file.writeUInt32BE(flags, fixed + 16);
|
|
compressedTable.copy(file, blocksInfoOffset);
|
|
dataArea.copy(file, dataStart);
|
|
return file;
|
|
};
|