import { IL2CPP_METADATA_MAGIC, IL2CPP_METADATA_SECTION_NAMES, IL2CPP_METADATA_VERSIONS, type Il2cppMetadata, type Il2cppMetadataSection, } from "../domain/il2cpp/il2cppMetadata.js"; const NESTED_TYPE_ENTRY_BYTES = 4; /** Stable machine-readable reason for each rejected input. */ export const IL2CPP_METADATA_FAILURE_REASONS = { unsupported: ["unrecognized-magic", "unsupported-version"], corrupt: [ "truncated-header", "unexpected-header-size", "section-out-of-bounds", "section-overlap", "record-size-mismatch", "type-index-width", "index-out-of-range", "nested-type-conflict", "declaring-type-mismatch", "image-range-overlap", "invalid-string", ], } as const; export type Il2cppMetadataFailureKind = keyof typeof IL2CPP_METADATA_FAILURE_REASONS; export type Il2cppMetadataFailureReason = (typeof IL2CPP_METADATA_FAILURE_REASONS)[Il2cppMetadataFailureKind][number]; /** Distinguish inputs outside decoder coverage from damaged metadata. */ export class Il2cppMetadataFormatFailure extends Error { readonly kind: Il2cppMetadataFailureKind; constructor( readonly reason: Il2cppMetadataFailureReason, message: string, options?: ErrorOptions, ) { super(`${reason}: ${message}`, options); this.name = "Il2cppMetadataFormatFailure"; this.kind = ( IL2CPP_METADATA_FAILURE_REASONS.unsupported as readonly string[] ).includes(reason) ? "unsupported" : "corrupt"; } } type SectionName = (typeof IL2CPP_METADATA_SECTION_NAMES)[number]; type IndexWidth = 1 | 2 | 4; export type Il2cppMetadataVersion = (typeof IL2CPP_METADATA_VERSIONS)[number]; type RecordSection = "typeDefinitions" | "images" | "methods"; export type Il2cppMetadataDecoded = Omit< Il2cppMetadata, "artifact" | "limitations" >; /** * Version-specific header and record shape. "fixed" headers hold * (offset, size) pairs and every index is a 4-byte int32; "counted" headers * hold (offset, size, count) triples whose counts select variable index widths. */ interface VersionLayout { readonly indexing: "fixed" | "counted"; /** Version 29 and 31 type records also carry elementTypeIndex. */ readonly typeDefinitionTypeIndices: 3 | 4; /** Version 31 added returnParameterToken to method records. */ readonly methodReturnParameterToken: boolean; } const VERSION_LAYOUTS: Readonly> = { 29: { indexing: "fixed", typeDefinitionTypeIndices: 4, methodReturnParameterToken: false, }, 31: { indexing: "fixed", typeDefinitionTypeIndices: 4, methodReturnParameterToken: true, }, 39: { indexing: "counted", typeDefinitionTypeIndices: 3, methodReturnParameterToken: true, }, }; const sectionEntryBytes = (layout: VersionLayout) => layout.indexing === "counted" ? 12 : 8; /** Magic, version and one entry per section. */ export const il2cppMetadataHeaderBytes = (version: Il2cppMetadataVersion) => 8 + IL2CPP_METADATA_SECTION_NAMES.length * sectionEntryBytes(VERSION_LAYOUTS[version]); const isSupportedVersion = (value: number): value is Il2cppMetadataVersion => (IL2CPP_METADATA_VERSIONS as readonly number[]).includes(value); /** Width the runtime selects for an index into a collection of this many entries. */ export const il2cppIndexWidth = (entries: number): IndexWidth => entries <= 0xff ? 1 : entries <= 0xffff ? 2 : 4; /** Record sizes implied by the index widths of one metadata version. */ export const il2cppMetadataRecordBytes = ( widths: { readonly type_definition: IndexWidth; readonly generic_container: IndexWidth; readonly parameter: IndexWidth; readonly type: IndexWidth; }, version: Il2cppMetadataVersion, ): Readonly> => { const layout = VERSION_LAYOUTS[version]; return { // Two string indices, the type indices, a generic container index, // flags, eight int32 starts, eight uint16 counts, bitfield and token. typeDefinitions: 68 + layout.typeDefinitionTypeIndices * widths.type + widths.generic_container, // Name, assembly, two type-definition starts with uint32 counts, entry // point, token, custom-attribute start and count. images: 32 + 2 * widths.type_definition, // Name, declaring type, return type, optional return token, parameter // start, generic container, token and four uint16 fields. methods: 16 + (layout.methodReturnParameterToken ? 4 : 0) + widths.type_definition + widths.type + widths.parameter + widths.generic_container, }; }; const isIndexWidth = (value: number): value is IndexWidth => value === 1 || value === 2 || value === 4; /** Read a serialized index; the all-ones value of narrow widths means -1. */ const readIndex = (bytes: Buffer, offset: number, width: IndexWidth) => { if (width === 1) { const value = bytes.readUInt8(offset); return value === 0xff ? -1 : value; } if (width === 2) { const value = bytes.readUInt16LE(offset); return value === 0xffff ? -1 : value; } return bytes.readInt32LE(offset); }; const readHeader = (bytes: Buffer) => { const magic = bytes.length < 4 ? null : bytes.readUInt32LE(0); if (bytes.length < 8 || magic !== IL2CPP_METADATA_MAGIC) throw new Il2cppMetadataFormatFailure( "unrecognized-magic", "the file does not start with the IL2CPP global-metadata magic 0xFAB11BAF; encrypted or non-metadata inputs are not decoded", ); const version = bytes.readInt32LE(4); if (!isSupportedVersion(version)) throw new Il2cppMetadataFormatFailure( "unsupported-version", `metadata version ${version} is not supported; versions ${IL2CPP_METADATA_VERSIONS.join(", ")} are decoded`, ); const headerBytes = il2cppMetadataHeaderBytes(version); if (bytes.length < headerBytes) throw new Il2cppMetadataFormatFailure( "truncated-header", `the file has ${bytes.length} bytes, fewer than the ${headerBytes}-byte version ${version} header`, ); const entryBytes = sectionEntryBytes(VERSION_LAYOUTS[version]); const sections: Il2cppMetadataSection[] = IL2CPP_METADATA_SECTION_NAMES.map( (name, index) => { const at = 8 + index * entryBytes; return { name, offset: bytes.readInt32LE(at), size: bytes.readInt32LE(at + 4), count: entryBytes === 12 ? bytes.readInt32LE(at + 8) : null, }; }, ); return { magic, version, headerBytes, sections } as const; }; const checkSections = ( bytes: Buffer, headerBytes: number, sections: readonly Il2cppMetadataSection[], ) => { const first = sections[0]; if (first?.offset !== headerBytes) throw new Il2cppMetadataFormatFailure( "unexpected-header-size", `stringLiterals starts at ${String(first?.offset)}, not immediately after the ${headerBytes}-byte header`, ); for (const section of sections) if ( section.offset < 0 || section.size < 0 || (section.count ?? 0) < 0 || section.offset + section.size > bytes.length ) throw new Il2cppMetadataFormatFailure( "section-out-of-bounds", `section ${section.name} (offset ${section.offset}, size ${section.size}, count ${String(section.count)}) is negative or extends past the ${bytes.length}-byte file`, ); else if (section.size > 0 && section.offset < headerBytes) throw new Il2cppMetadataFormatFailure( "section-out-of-bounds", `section ${section.name} starts at ${section.offset}, inside the ${headerBytes}-byte header`, ); // Empty sections may share an offset with any neighbor; gaps are padding. const occupied = sections .filter((section) => section.size > 0) .sort((left, right) => left.offset - right.offset); for (let index = 1; index < occupied.length; index += 1) { const previous = occupied[index - 1]!; const current = occupied[index]!; if (previous.offset + previous.size > current.offset) throw new Il2cppMetadataFormatFailure( "section-overlap", `section ${previous.name} [${previous.offset}, ${previous.offset + previous.size}) overlaps ${current.name} starting at ${current.offset}`, ); } }; /** A counted header's entry count; fixed headers record none. */ const headerCount = (section: Il2cppMetadataSection) => { if (section.count === null) throw new TypeError(`section ${section.name} has no header count`); return section.count; }; /** Record size from a counted header; null for an empty section. */ const recordBytes = (section: Il2cppMetadataSection): number | null => { const count = headerCount(section); if (count === 0) { if (section.size !== 0) throw new Il2cppMetadataFormatFailure( "record-size-mismatch", `section ${section.name} declares ${section.size} bytes for zero records`, ); return null; } if (section.size % count !== 0) throw new Il2cppMetadataFormatFailure( "record-size-mismatch", `section ${section.name} size ${section.size} is not a whole multiple of its ${count} records`, ); return section.size / count; }; /** Entry count of a section whose header records only its size. */ const entriesIn = (section: Il2cppMetadataSection, entryBytes: number) => { if (section.size % entryBytes !== 0) throw new Il2cppMetadataFormatFailure( "record-size-mismatch", `section ${section.name} size ${section.size} is not a whole multiple of its ${entryBytes}-byte records`, ); return section.size / entryBytes; }; const FIXED_WIDTHS: Il2cppMetadataDecoded["index_widths"] = { type_definition: 4, generic_container: 4, parameter: 4, type: 4, type_width_source: "fixed-width-version", }; const resolveWidths = ( section: (name: SectionName) => Il2cppMetadataSection, ): Il2cppMetadataDecoded["index_widths"] => { const type_definition = il2cppIndexWidth( headerCount(section("typeDefinitions")), ); const generic_container = il2cppIndexWidth( headerCount(section("genericContainers")), ); const parameter = il2cppIndexWidth(headerCount(section("parameters"))); // TypeIndex counts native types, so its width is recovered from records. const interfaceOffset = recordBytes(section("interfaceOffsets")); let type: number; let type_width_source: Il2cppMetadataDecoded["index_widths"]["type_width_source"]; if (interfaceOffset !== null) { type = interfaceOffset - 4; type_width_source = "interface-offsets-record"; } else { const typeDefinition = recordBytes(section("typeDefinitions")); type = typeDefinition === null ? Number.NaN : (typeDefinition - 68 - generic_container) / 3; type_width_source = "type-definition-record"; } if (!isIndexWidth(type)) throw new Il2cppMetadataFormatFailure( "type-index-width", `the ${type_width_source} does not imply a 1-, 2- or 4-byte TypeIndex`, ); return { type_definition, generic_container, parameter, type, type_width_source, }; }; /** Record counts checked against the record sizes the widths imply. */ const recordCounts = ( version: Il2cppMetadataVersion, section: (name: SectionName) => Il2cppMetadataSection, expected: Readonly>, ): Readonly> => { const names = ["typeDefinitions", "images", "methods"] as const; if (VERSION_LAYOUTS[version].indexing === "fixed") { const sized = Object.fromEntries( names.map((name) => [name, entriesIn(section(name), expected[name])]), ) as Record; return { ...sized, nestedTypes: entriesIn(section("nestedTypes"), NESTED_TYPE_ENTRY_BYTES), }; } for (const name of names) { const actual = recordBytes(section(name)); if (actual !== null && actual !== expected[name]) throw new Il2cppMetadataFormatFailure( "record-size-mismatch", `section ${name} records are ${actual} bytes, but the index widths imply ${expected[name]}`, ); } const nestedTypes = section("nestedTypes"); if (nestedTypes.size !== headerCount(nestedTypes) * NESTED_TYPE_ENTRY_BYTES) throw new Il2cppMetadataFormatFailure( "record-size-mismatch", `section nestedTypes size ${nestedTypes.size} does not hold ${String(nestedTypes.count)} 4-byte entries`, ); return { typeDefinitions: headerCount(section("typeDefinitions")), images: headerCount(section("images")), methods: headerCount(section("methods")), nestedTypes: headerCount(nestedTypes), }; }; const checkRange = ( label: string, start: number, length: number, total: number, ) => { if (length === 0 && start >= -1 && start <= total) return; if (start < 0 || start + length > total) throw new Il2cppMetadataFormatFailure( "index-out-of-range", `${label} range [${start}, ${start + length}) is outside the ${total} available entries`, ); }; /** * Decode NUL-terminated UTF-8 names once each from the strings section. An * index must start a string, so a shifted section or a pointer into another * name fails instead of returning a different name, and total decoding stays * bounded by the section size. */ const createStringReader = (bytes: Buffer, strings: Il2cppMetadataSection) => { const stringEnd = strings.offset + strings.size; const pool = bytes.subarray(strings.offset, stringEnd); const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); const cache = new Map(); return (index: number, label: string) => { const cached = cache.get(index); if (cached !== undefined) return cached; const end = index < 0 || index >= pool.length ? -1 : pool.indexOf(0, index); if (end === -1) throw new Il2cppMetadataFormatFailure( "invalid-string", `${label} string index ${index} has no terminated entry inside the ${strings.size}-byte string section`, ); if (index > 0 && pool[index - 1] !== 0) throw new Il2cppMetadataFormatFailure( "invalid-string", `${label} string index ${index} starts inside another string`, ); let value: string; try { value = decoder.decode(pool.subarray(index, end)); } catch (cause: unknown) { throw new Il2cppMetadataFormatFailure( "invalid-string", `${label} string index ${index} is not valid UTF-8`, { cause }, ); } cache.set(index, value); return value; }; }; /** Checked header facts shared by the record readers. */ interface MetadataTables { readonly bytes: Buffer; readonly section: (name: SectionName) => Il2cppMetadataSection; readonly widths: Il2cppMetadataDecoded["index_widths"]; readonly recordBytes: ReturnType; readonly counts: ReturnType; readonly typeDefinitionTypeIndices: number; readonly readString: ReturnType; } function* readImages({ bytes, section, widths, recordBytes: sizes, counts, readString, }: MetadataTables): Generator { const images = section("images"); const typeTotal = counts.typeDefinitions; const width = widths.type_definition; const result: Il2cppMetadataDecoded["images"] = []; for (let index = 0; index < counts.images; index++) { if (index % 256 === 0) yield; const at = images.offset + index * sizes.images; const image = { name: readString(bytes.readInt32LE(at), `image ${index} name`), type_start: readIndex(bytes, at + 8, width), type_count: bytes.readUInt32LE(at + 8 + width), }; checkRange( `image ${index} types`, image.type_start, image.type_count, typeTotal, ); result.push(image); } return result; } /** Record each type's enclosing type from its declaring type's nestedTypes range. */ function* assignNesting( { bytes, section, counts }: MetadataTables, types: Il2cppMetadataDecoded["types"], ) { let work = 0; const nestedTypes = section("nestedTypes"); for (const [index, type] of types.entries()) { if (work++ % 256 === 0) yield; checkRange( `type ${index} nested types`, type.nested_type_start, type.nested_type_count, counts.nestedTypes, ); for (let offset = 0; offset < type.nested_type_count; offset += 1) { if (work++ % 256 === 0) yield; const nested = bytes.readInt32LE( nestedTypes.offset + (type.nested_type_start + offset) * NESTED_TYPE_ENTRY_BYTES, ); const target = types[nested]; if (target === undefined) throw new Il2cppMetadataFormatFailure( "index-out-of-range", `type ${index} lists nested type ${nested} outside the ${types.length} type definitions`, ); if (nested === index) throw new Il2cppMetadataFormatFailure( "nested-type-conflict", `type ${index} lists itself as a nested type`, ); if (target.declaring_type !== -1) throw new Il2cppMetadataFormatFailure( "nested-type-conflict", `type ${nested} is listed as nested in type ${index} and type ${target.declaring_type}`, ); target.declaring_type = index; } } yield* rejectNestingCycles(types); } /** Each enclosing chain must reach a top-level type. */ function* rejectNestingCycles(types: Il2cppMetadataDecoded["types"]) { // 0 unvisited, 1 on the current chain, 2 reaches a top-level type. let work = 0; const state = new Uint8Array(types.length); for (let first = 0; first < types.length; first += 1) { if (work++ % 256 === 0) yield; const chain: number[] = []; let current = first; while (current !== -1 && state[current] === 0) { if (work++ % 256 === 0) yield; state[current] = 1; chain.push(current); current = types[current]!.declaring_type; } if (current !== -1 && state[current] === 1) throw new Il2cppMetadataFormatFailure( "nested-type-conflict", `type ${current} is nested inside itself through its declaring types`, ); for (const index of chain) { if (work++ % 256 === 0) yield; state[index] = 2; } } } function* readTypes( tables: MetadataTables, ): Generator { const { bytes, section, widths, recordBytes: sizes, counts } = tables; const { readString, typeDefinitionTypeIndices } = tables; const typeDefinitions = section("typeDefinitions"); const methodTotal = counts.methods; const types: Il2cppMetadataDecoded["types"] = []; for (let index = 0; index < counts.typeDefinitions; index++) { if (index % 256 === 0) yield; const at = typeDefinitions.offset + index * sizes.typeDefinitions; // int32 starts follow both string indices, the type indices, the // generic container index and flags. const starts = at + 12 + typeDefinitionTypeIndices * widths.type + widths.generic_container; const countsAt = starts + 32; const type = { name: readString(bytes.readInt32LE(at), `type ${index} name`), namespace: readString( bytes.readInt32LE(at + 4), `type ${index} namespace`, ), method_start: bytes.readInt32LE(starts + 4), method_count: bytes.readUInt16LE(countsAt), nested_type_start: bytes.readInt32LE(starts + 16), nested_type_count: bytes.readUInt16LE(countsAt + 8), declaring_type: -1, }; checkRange( `type ${index} methods`, type.method_start, type.method_count, methodTotal, ); types.push(type); } yield* assignNesting(tables, types); return types; } function* readMethods({ bytes, section, widths, recordBytes: sizes, counts, readString, }: MetadataTables): Generator { const methods = section("methods"); const typeTotal = counts.typeDefinitions; const result: Il2cppMetadataDecoded["methods"] = []; for (let index = 0; index < counts.methods; index++) { if (index % 256 === 0) yield; const at = methods.offset + index * sizes.methods; const method = { name: readString(bytes.readInt32LE(at), `method ${index} name`), declaring_type: readIndex(bytes, at + 4, widths.type_definition), }; if (method.declaring_type < -1 || method.declaring_type >= typeTotal) throw new Il2cppMetadataFormatFailure( "index-out-of-range", `method ${index} declares type ${method.declaring_type} outside the ${typeTotal} type definitions`, ); result.push(method); } return result; } /** * Type method ranges and method declaring types must agree in both * directions, so a damaged range cannot strand or misattribute a method. */ function* checkMethodOwners( types: Il2cppMetadataDecoded["types"], methods: Il2cppMetadataDecoded["methods"], ) { let work = 0; for (const [index, type] of types.entries()) { if (work++ % 256 === 0) yield; for (let offset = 0; offset < type.method_count; offset += 1) { if (work++ % 256 === 0) yield; const method = type.method_start + offset; const declaring = methods[method]!.declaring_type; if (declaring !== index) throw new Il2cppMetadataFormatFailure( "declaring-type-mismatch", `method ${method} is in type ${index}'s range but declares type ${declaring}`, ); } } for (const [index, method] of methods.entries()) { if (work++ % 256 === 0) yield; const owner = types[method.declaring_type]; if ( owner !== undefined && (index < owner.method_start || index >= owner.method_start + owner.method_count) ) throw new Il2cppMetadataFormatFailure( "declaring-type-mismatch", `method ${index} declares type ${method.declaring_type} but is outside its method range`, ); } } /** Each type belongs to at most one image. */ function* checkImageRanges(images: Il2cppMetadataDecoded["images"]) { const ranges: { start: number; end: number; index: number }[] = []; for (let index = 0; index < images.length; index++) { if (index % 256 === 0) yield; const image = images[index]!; if (image.type_count > 0) ranges.push({ start: image.type_start, end: image.type_start + image.type_count, index, }); } ranges.sort((left, right) => left.start - right.start); for (let at = 1; at < ranges.length; at++) { if (at % 256 === 0) yield; const previous = ranges[at - 1]!; const current = ranges[at]!; if (previous.end > current.start) throw new Il2cppMetadataFormatFailure( "image-range-overlap", `image ${previous.index} types [${previous.start}, ${previous.end}) overlap image ${current.index} starting at type ${current.start}`, ); } } /** Row counts known from the header and record sizes, before rows are built. */ export interface Il2cppMetadataRowCounts { readonly images: number; readonly types: number; readonly methods: number; } /** Decode image, type and method names without native binaries or decryption. */ export function* readIl2cppMetadataSteps( bytes: Buffer, options: { /** Called before any row is decoded; may throw to refuse the file. */ readonly beforeRows?: (counts: Il2cppMetadataRowCounts) => void; } = {}, ): Generator { const { magic, version, headerBytes, sections } = readHeader(bytes); checkSections(bytes, headerBytes, sections); const byName = new Map(sections.map((entry) => [entry.name, entry])); const section = (name: SectionName) => byName.get(name)!; const layout = VERSION_LAYOUTS[version]; const widths = layout.indexing === "counted" ? resolveWidths(section) : FIXED_WIDTHS; const expected = il2cppMetadataRecordBytes(widths, version); const tables: MetadataTables = { bytes, section, widths, recordBytes: expected, counts: recordCounts(version, section, expected), typeDefinitionTypeIndices: layout.typeDefinitionTypeIndices, readString: createStringReader(bytes, section("strings")), }; options.beforeRows?.({ images: tables.counts.images, types: tables.counts.typeDefinitions, methods: tables.counts.methods, }); yield; const images = yield* readImages(tables); yield* checkImageRanges(images); const types = yield* readTypes(tables); const methods = yield* readMethods(tables); yield* checkMethodOwners(types, methods); return { magic, metadata_version: version, index_widths: widths, sections, images, types, methods, }; } /** Synchronous consumption of the same decoder for byte-level callers. */ export const readIl2cppMetadata = ( bytes: Buffer, options: Parameters[1] = {}, ): Il2cppMetadataDecoded => { const steps = readIl2cppMetadataSteps(bytes, options); for (;;) { const step = steps.next(); if (step.done) return step.value; } };