feat(il2cpp): inventory Unity IL2CPP global-metadata images, types and methods (#3807)

* feat(il2cpp): inventory IL2CPP global-metadata images, types and methods

Add inspect_il2cpp_metadata (CLI inspect-il2cpp-metadata), a read-only
in-process decoder for unencrypted Unity global-metadata.dat version 39.
It reports header sections, serialized index widths, images, type
definitions (nesting from the nestedTypes section) and method names.

Unrecognized magic and other versions are unsupported; damaged headers,
overlapping sections, inconsistent record sizes, out-of-range indices and
invalid strings fail with a reason code and no partial success. MCP
detail summary retains the complete Evidence and pages rows through
inspect_analysis_view kind il2cpp-metadata.

Adds an opt-in verify:il2cpp:metadata -- --metadata PATH check.

* feat(il2cpp): deliver metadata summaries by default over MCP

A 112,232-method file produced a 10.5 MB complete response, above the
10 MiB stdio budget. Default MCP delivery is now the retained summary;
detail complete stays available, and the one-shot CLI requests it.

* fix(il2cpp): reject shifted strings, nesting cycles and method owner drift

Review fuzzing showed that moving the strings section offset by one byte
silently renamed every image, type and method. Name indices must now start
a string, which also bounds total decoding by the section size.

Nesting cycles and methods whose declaring type disagrees with the owning
type range are rejected instead of returned. Adds service, shutdown and
index-width coverage, and moves environment-specific measurements out of
the public guide and tool description.

* feat(il2cpp): decode global-metadata versions 29 and 31

Unity 2022.3 writes version 29 or 31 metadata: a 256-byte header of
(offset, size) pairs, fixed 4-byte indices, type records with
elementTypeIndex, and method records that gained returnParameterToken in
version 31. Record counts come from section sizes and must be whole.

Sections report a null count for these versions and index widths report
fixed-width-version. Image, type and method rows keep the same shape, and
the string, nesting and method-ownership checks apply to every version.

* fix(il2cpp): bound report size before serializing and tighten coverage

Rows share decoded names, so a 130 KB file could expand into gigabytes of
JSON before the 64 MiB budget check, ending in an internal RangeError. The
provider now refuses from a lower bound on the serialized rows first.

The decoder also rejects non-empty sections inside the header, methods
outside their declaring type's range and overlapping image type ranges, and
keeps a leading byte order mark in names. Layouts now carry one indexing
mode instead of two coupled flags, and the tool description explains
nested_type_start.

* fix(il2cpp): refuse oversized metadata before decoding rows

Address review findings on the IL2CPP metadata inventory:

- check a lower bound from header row counts before any row object is
  allocated, so a large rejected file no longer builds every row first;
- validate sealed parent Evidence and count its rows once per report
  instead of on every inspect_analysis_view page;
- decode version 39 fixtures with 2- and 4-byte type-definition,
  generic-container and parameter indices.

* docs(il2cpp): attach the decoder comment to readIl2cppMetadata

* fix(il2cpp): deliver complete rows by default like the other inspection tools

inspect_il2cpp_metadata defaulted to detail summary, the only inspection
tool to do so. Omitted detail now returns every row inline, as
inspect_artifact, decode_interface_builder and the capture tools do;
summary stays one argument away and the guide, CLI reference and skill
reference say to select it first for large games, whose complete MCP
delivery is refused at the stdio budget with retained Evidence.

* test(il2cpp): cover narrow-width -1 sentinels and fix the skill's default

An empty image stores its type start as the all-ones value of a one- or
two-byte index; the decoder reads it as -1, but no fixture exercised it,
so removing either conversion passed the suite. The fixture can now append
an empty image, and both widths decode it. The skill still described a
summary default; it now says to select summary first, as the guide does.

* docs(il2cpp): request summary explicitly in the summary example

* chore(il2cpp): hash the verifier metadata through owned file resources

* fix(il2cpp): count escaped name length without building the escaped string

* fix(il2cpp): keep the verifier's check failure and small-table last page

* feat(domain): count JSON string length without building the escaped string

* fix(il2cpp): keep descriptor cleanup owners and share the JSON length count

* test(domain): give the backslash case real backslashes

* fix(il2cpp): select the CLI path and finish the command like the other tools

Follow main's absoluteCallerPath (#4067) and pass the command signal to finishAnalysisCommand.

* fix(il2cpp): select the verifier input path like the CLI

Use absoluteCallerPath (#4067) so a path with a symlink followed by .. names the same file the CLI and MCP inspect.

* feat(il2cpp): add compact composable metadata queries

Retain source-indexed Evidence for filtered batch and field queries. Bound representation growth before serialization, yield decoding for cancellation, and share CLI Evidence publication with desktop inspection. Verify public Unity metadata against independent byte and managed-DLL oracles and exercise actual stdio SDK workflows.

* fix(il2cpp): retain the validated metadata header magic

* test(il2cpp): remove obsolete execution wording exception

* fix(artifacts): preserve export selection and completed source facts

---------

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
JANG BYUNGKUNandgensokyo-dev authored and GitHub committed 2026-10-20 07:20:47 +08:00
1 parent 82c76e2cba
commit 833136507d
51 files changed
+4666 -116

No files matched your search

+718
View File
@@ -0,0 +1,718 @@
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<Record<Il2cppMetadataVersion, VersionLayout>> =
{
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<Record<RecordSection, number>> => {
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<Record<RecordSection, number>>,
): Readonly<Record<RecordSection | "nestedTypes", number>> => {
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<RecordSection, number>;
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<number, string>();
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<typeof il2cppMetadataRecordBytes>;
readonly counts: ReturnType<typeof recordCounts>;
readonly typeDefinitionTypeIndices: number;
readonly readString: ReturnType<typeof createStringReader>;
}
function* readImages({
bytes,
section,
widths,
recordBytes: sizes,
counts,
readString,
}: MetadataTables): Generator<void, Il2cppMetadataDecoded["images"]> {
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<void, Il2cppMetadataDecoded["types"]> {
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<void, Il2cppMetadataDecoded["methods"]> {
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<void, Il2cppMetadataDecoded> {
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<typeof readIl2cppMetadataSteps>[1] = {},
): Il2cppMetadataDecoded => {
const steps = readIl2cppMetadataSteps(bytes, options);
for (;;) {
const step = steps.next();
if (step.done) return step.value;
}
};