Files
Ra3ModXmlExt/src/indexer/manifestParser.ts
T
2026-08-01 14:00:17 +02:00

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}`);
}