258 lines
7.6 KiB
TypeScript
258 lines
7.6 KiB
TypeScript
/**
|
|
* Parser for SAGE `.manifest` files, ported from OpenSAGE
|
|
* (src/OpenSage.Game/Data/StreamFS/ManifestFile.cs, commit d45d361).
|
|
*
|
|
* The manifest is a binary index produced by BinaryAssetBuilder: every asset
|
|
* compiled into a stream is listed with hashed type/instance ids, an offset
|
|
* into the asset-name string buffer, and an optional source file name.
|
|
* Parsing it lets the extension treat `reference` includes (static.xml,
|
|
* global.xml, audio.xml) as real, searchable asset pools.
|
|
*/
|
|
|
|
import { assetTypeNameFromHash } from "../model/schemaModel";
|
|
|
|
export interface ManifestAsset {
|
|
typeId: number;
|
|
typeName: string | null;
|
|
name: string;
|
|
sourceFileName: string;
|
|
}
|
|
|
|
export interface ManifestReferenceEntry {
|
|
referenceType: number;
|
|
path: string;
|
|
}
|
|
|
|
export interface ManifestInfo {
|
|
version: number;
|
|
isBigEndian: boolean;
|
|
isLinked: boolean;
|
|
assetCount: number;
|
|
assets: ManifestAsset[];
|
|
manifestReferences: ManifestReferenceEntry[];
|
|
/** Present when the file could not be parsed. */
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Derives an asset type from its manifest name. BAB stores manifest assets
|
|
* as "TypeName:Id" (e.g. "PlayerTemplate:Allies"), so the part before the
|
|
* first colon is the type even when the TypeId hash is unknown to the
|
|
* bundled AssetType table. Asset ids cannot contain ":" (InstanceId pattern),
|
|
* so the split is unambiguous.
|
|
*/
|
|
export function deriveAssetType(typeName: string | null, assetName: string): string | null {
|
|
if (typeName) return typeName;
|
|
const idx = assetName.indexOf(":");
|
|
if (idx <= 0) return null;
|
|
const prefix = assetName.slice(0, idx);
|
|
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(prefix) ? prefix : null;
|
|
}
|
|
|
|
/**
|
|
* Extracts the referenceable asset id from a manifest name. BAB stores
|
|
* manifest names as "TypeName:InstanceId" where the instance id may itself
|
|
* carry a subtype prefix for art assets, e.g.
|
|
* W3dContainer:W3DContainer:AUANTIVEHICLEVEHICLETECH1_SKN
|
|
* The referenceable id is the last colon-separated segment (asset ids cannot
|
|
* contain ":" per the InstanceId pattern).
|
|
*/
|
|
export function deriveAssetId(assetName: string): string {
|
|
const idx = assetName.lastIndexOf(":");
|
|
return idx >= 0 ? assetName.slice(idx + 1) : assetName;
|
|
}
|
|
|
|
class Cursor {
|
|
private pos = 0;
|
|
constructor(private buf: Uint8Array) {}
|
|
|
|
get position(): number {
|
|
return this.pos;
|
|
}
|
|
|
|
byte(): number {
|
|
if (this.pos >= this.buf.length) throw new Error("Unexpected end of file");
|
|
return this.buf[this.pos++];
|
|
}
|
|
|
|
uint16(): number {
|
|
const a = this.byte();
|
|
const b = this.byte();
|
|
return a | (b << 8);
|
|
}
|
|
|
|
uint32(): number {
|
|
const a = this.byte();
|
|
const b = this.byte();
|
|
const c = this.byte();
|
|
const d = this.byte();
|
|
return ((a | (b << 8) | (c << 16) | (d << 24)) >>> 0);
|
|
}
|
|
|
|
skip(n: number): void {
|
|
this.pos += n;
|
|
if (this.pos > this.buf.length) throw new Error("Unexpected end of file");
|
|
}
|
|
|
|
nullTerminatedString(): string {
|
|
const start = this.pos;
|
|
while (this.pos < this.buf.length && this.buf[this.pos] !== 0) {
|
|
this.pos++;
|
|
}
|
|
const end = this.pos;
|
|
if (this.pos < this.buf.length) this.pos++; // consume NUL
|
|
return decodeAscii(this.buf, start, end);
|
|
}
|
|
|
|
readNameBuffer(endPosition: number): Map<number, string> {
|
|
const names = new Map<number, string>();
|
|
let nameOffset = 0;
|
|
while (this.pos < endPosition) {
|
|
const start = this.pos;
|
|
names.set(nameOffset, this.nullTerminatedString());
|
|
nameOffset += this.pos - start;
|
|
}
|
|
return names;
|
|
}
|
|
}
|
|
|
|
function decodeAscii(buf: Uint8Array, start: number, end: number): string {
|
|
// Asset ids are ASCII; anything else decodes with replacement chars.
|
|
let out = "";
|
|
for (let i = start; i < end; i++) {
|
|
out += String.fromCharCode(buf[i]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function parseManifest(buffer: Uint8Array): ManifestInfo {
|
|
try {
|
|
return parseManifestInner(buffer);
|
|
} catch (err) {
|
|
return {
|
|
version: 0,
|
|
isBigEndian: false,
|
|
isLinked: false,
|
|
assetCount: 0,
|
|
assets: [],
|
|
manifestReferences: [],
|
|
error: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
}
|
|
|
|
function parseManifestInner(buffer: Uint8Array): ManifestInfo {
|
|
const cursor = new Cursor(buffer);
|
|
|
|
const testValue = cursor.uint32();
|
|
let version: number;
|
|
let isBigEndian: boolean;
|
|
let isLinked: boolean;
|
|
|
|
if (testValue === 0) {
|
|
version = cursor.uint16();
|
|
if (version !== 7) throw new Error(`Unsupported manifest version ${version}`);
|
|
isBigEndian = readBooleanChecked(cursor);
|
|
isLinked = readBooleanChecked(cursor);
|
|
} else {
|
|
cursor.skip(-4);
|
|
isBigEndian = readBooleanChecked(cursor);
|
|
isLinked = readBooleanChecked(cursor);
|
|
version = cursor.uint16();
|
|
if (version !== 5 && version !== 6) {
|
|
throw new Error(`Unsupported manifest version ${version}`);
|
|
}
|
|
}
|
|
|
|
const streamChecksum = cursor.uint32();
|
|
const allTypesHash = cursor.uint32();
|
|
const assetCount = cursor.uint32();
|
|
const totalInstanceDataSize = cursor.uint32();
|
|
const maxInstanceChunkSize = cursor.uint32();
|
|
const maxRelocationChunkSize = cursor.uint32();
|
|
const maxImportsChunkSize = cursor.uint32();
|
|
const assetReferenceBufferSize = cursor.uint32();
|
|
const referencedManifestNameBufferSize = cursor.uint32();
|
|
const assetNameBufferSize = cursor.uint32();
|
|
const sourceFileNameBufferSize = cursor.uint32();
|
|
|
|
void streamChecksum;
|
|
void allTypesHash;
|
|
void totalInstanceDataSize;
|
|
void maxInstanceChunkSize;
|
|
void maxRelocationChunkSize;
|
|
void maxImportsChunkSize;
|
|
|
|
interface RawEntry {
|
|
typeId: number;
|
|
nameOffset: number;
|
|
sourceFileNameOffset: number;
|
|
}
|
|
const rawEntries: RawEntry[] = [];
|
|
for (let i = 0; i < assetCount; i++) {
|
|
const typeId = cursor.uint32();
|
|
cursor.uint32(); // instanceId
|
|
cursor.uint32(); // typeHash
|
|
cursor.uint32(); // instanceHash
|
|
cursor.uint32(); // assetReferenceOffset
|
|
cursor.uint32(); // assetReferenceCount
|
|
const nameOffset = cursor.uint32();
|
|
const sourceFileNameOffset = cursor.uint32();
|
|
cursor.uint32(); // instanceDataSize
|
|
cursor.uint32(); // relocationDataSize
|
|
cursor.uint32(); // importsDataSize
|
|
if (version >= 6) {
|
|
cursor.byte(); // isTokenized
|
|
cursor.skip(3);
|
|
}
|
|
rawEntries.push({
|
|
typeId,
|
|
nameOffset,
|
|
sourceFileNameOffset,
|
|
});
|
|
}
|
|
|
|
// Asset references buffer (not needed for completions).
|
|
cursor.skip(assetReferenceBufferSize);
|
|
|
|
// Referenced manifest names.
|
|
const manifestRefsEnd = cursor.position + referencedManifestNameBufferSize;
|
|
const manifestReferences: ManifestReferenceEntry[] = [];
|
|
while (cursor.position < manifestRefsEnd) {
|
|
const referenceType = cursor.byte();
|
|
const path = cursor.nullTerminatedString();
|
|
manifestReferences.push({ referenceType, path });
|
|
}
|
|
|
|
// Asset names.
|
|
const assetNamesEnd = cursor.position + assetNameBufferSize;
|
|
const assetNames = cursor.readNameBuffer(assetNamesEnd);
|
|
|
|
// Source file names.
|
|
const sourceNamesEnd = cursor.position + sourceFileNameBufferSize;
|
|
const sourceNames = cursor.readNameBuffer(sourceNamesEnd);
|
|
|
|
const assets: ManifestAsset[] = rawEntries.map((entry) => ({
|
|
typeId: entry.typeId,
|
|
typeName: assetTypeNameFromHash(entry.typeId) ?? null,
|
|
name: assetNames.get(entry.nameOffset) ?? "",
|
|
sourceFileName: sourceNames.get(entry.sourceFileNameOffset) ?? "",
|
|
}));
|
|
|
|
return {
|
|
version,
|
|
isBigEndian,
|
|
isLinked,
|
|
assetCount,
|
|
assets,
|
|
manifestReferences,
|
|
};
|
|
}
|
|
|
|
function readBooleanChecked(cursor: Cursor): boolean {
|
|
const value = cursor.byte();
|
|
if (value === 0) return false;
|
|
if (value === 1) return true;
|
|
throw new Error(`Invalid boolean byte ${value}`);
|
|
}
|