Files
reverse-engineering-anything/src/il2cpp/Il2cppBinaryTypes.ts
T
JANG BYUNGKUNandmorluto 149b43ee61 fix(il2cpp): link arm64 builds whose registrations were inlined (#6989)
* fix(il2cpp): accept arm64 module arrays loaded through adrp/add pairs

A Unity 2022.3 macOS Master (LTO) arm64 build keeps no static CodeRegistration, so the module-array fallback that only decoded x86-64 RIP-relative references reported code-registration-not-found.
The registration owner now also decodes aligned adrp + add pairs in executable ranges and keeps the same exact-module-set and single-reference rules.
A minimal Mach-O fixture failed on the previous reader (47 passed / 1 failed) and passes here with four fail-closed variants (48/48 binary tests).

* fix(il2cpp): name the matched instruction form in inferred module-array limits

The inferred-array limitation always described RIP-relative LEA/MOV, which is wrong for arm64 results.
The provider now names the adrp/add pair on arm64, and the tool contract and docs cover both forms and state that a located array alone does not finish linking a Master build.
The new arm64 provider case failed before the wording change (1 passed / 1 failed) and both cases pass now.

* fix(il2cpp): accept type tables loaded without a static MetadataRegistration

A Unity 2022.3.62f3 macOS Master build inlines MetadataRegistration as well as CodeRegistration, so linking stopped at metadata-registration-not-found after the module array was found. Registration now infers the only field linking reads, the Il2CppType table, from the one run of type-shaped pointers that covers every referenced type index and whose start is loaded by the existing RIP-relative or adrp/add forms; types_field is null and the Evidence is inferred. The new arm64 and x86-64 fixtures failed on the previous reader (52 passed / 2 failed). Four run-selection variants (interior reference, run extended before the loaded address, two loaded runs, run shorter than the referenced indices) keep metadata-registration-not-found; instruction decoding and record-shape checks are not covered by these fixtures.

* test(il2cpp): pin adrp page deltas, register pairing and type-record shape

Every adrp fixture targeted its own 4 KiB page, so dropping the page delta or its sign extension, the add register check, the type-kind bound or the type-definition bound left all binary tests passing. Fixtures now reach a later and an earlier page, pair an add with a different destination or source register, break the type run with an out-of-range kind or type-definition index, and bound type-table candidates when the metadata references few type indices. Each of those five source mutations now fails 1 to 3 of 85 binary and provider tests.

* refactor(il2cpp): share Il2CppType kind decoding and document type-table bounds

The type-table fallback decoded the Il2CppType kind and class/value-type kinds separately from the type namer; both now use the Types owner. Zero and unaligned words are rejected before mapping lookup (Master sample metadata-registration search 92 to 68 ms). Docs and the limitation state that types_count is an upper bound, that a run spanning mappings or a small referenced type range can fail, and credit Cpp2IL StrippedCodeRegSupport and the data-side MetadataRegistration searches of Il2CppDumper and Cpp2IL. Master sample output is unchanged (11,031 code addresses, 37,025 type rows).

* fix(il2cpp): refuse generic instances when the type table was inferred

An inferred type table has no static MetadataRegistration, so the
genericMethodTable it would anchor cannot be located. Report
generic-methods-unsupported instead of reading from a null field.

* fix(il2cpp): fail closed when an inferred type table is not unique

Adversarial review found that the type-table fallback could pick another
type-shaped pointer table when the real one was unloaded or excluded, could
replace a static MetadataRegistration whose counts matched, and accepted a
run cut short by a mapping end. Require exactly one long-enough run in the
image, ending inside its mapping and loaded by code; skip the fallback when a
record's two counts matched; accept only assigned Il2CppTypeEnum kinds; and
look records up through the indexed file mapping so many mappings cannot
multiply the scan. Docs and limitations now describe the run length as at
least every referenced type index rather than an upper bound.

* fix(il2cpp): keep the type-table fallback cheap and its refusals exact

Review follow-ups: check that an address can lie in a mapping before the
throwing indexed lookup, so words pointing into gaps or zero-fill no longer
build an exception each (64 MiB: 74 s to under 2 s); treat a run starting at
its mapping start like one reaching its end; stop at the second long-enough
run instead of a 64-candidate capacity error; ignore ADRP/ADD pairs on
register 31; and say whether a counted MetadataRegistration was unusable or
no code-loaded run was found. Docs no longer generalize from one build or
state tool history they do not source.

* fix(il2cpp): judge type-table slots by their loaded value

The run scan read stored words, so a dyld-bound slot's placeholder could
decide whether a run qualified. Confirm each slot through readSlot and give
a run bounded by a bound or unresolved slot an unknown extent, so the
placeholder no longer changes the choice.

* docs(il2cpp): keep the failure-reason table layout

Shorten the two changed meanings so the table keeps its column widths and the
diff shows only those rows.

* docs(il2cpp): keep the reason table at its formatted width

* fix(il2cpp): read type-table record fields through the loader too

The previous change confirmed table slots through readSlot but still read
each record's kind and definition index from stored bytes. Read them with
readUInt32 and readSlot, and treat a record field the loader binds or leaves
unresolved like an unresolved slot: the run it bounds has an unknown extent.

* perf(il2cpp): share image mappings across loader-aware reads

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-25 04:21:18 +08:00

471 lines
16 KiB
TypeScript

import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
import {
readSlot,
readUInt32,
hasPointerArrayCoverage,
type NativeImage,
} from "./Il2cppBinaryImage.js";
import type {
Il2cppMetadataDecoded,
Il2cppMetadataLinkFacts,
} from "./Il2cppMetadataFile.js";
/** Il2CppType's type enum: bits 16-23 of the word after its 8-byte data. */
export const il2cppTypeKind = (attributes: number): number =>
(attributes >>> 16) & 0xff;
/** Largest Il2CppTypeEnum value read as a type kind (IL2CPP_TYPE_MVAR). */
export const IL2CPP_TYPE_KIND_MAX = 0x1e;
/** Class and value-type kinds, whose data is a TypeDefinitionIndex. */
export const isDefinitionTypeKind = (kind: number): boolean =>
kind === 0x11 || kind === 0x12;
const PRIMITIVES = new Map([
[0x01, "void"],
[0x02, "bool"],
[0x03, "char"],
[0x04, "sbyte"],
[0x05, "byte"],
[0x06, "short"],
[0x07, "ushort"],
[0x08, "int"],
[0x09, "uint"],
[0x0a, "long"],
[0x0b, "ulong"],
[0x0c, "float"],
[0x0d, "double"],
[0x0e, "string"],
[0x16, "System.TypedReference"],
[0x18, "nint"],
[0x19, "nuint"],
[0x1c, "object"],
]);
// Il2CppDumper's GetTypeName primitive names differ from the C#-like ones.
const DUMPER_PRIMITIVES = new Map([
...PRIMITIVES,
[0x16, "TypedReference"],
[0x18, "IntPtr"],
[0x19, "UIntPtr"],
]);
// Cached rendered text has its own workspace bound; the report owner separately
// charges each returned occurrence and JSON expansion. No live-state cache.
const TYPE_TEXT_BYTES = 64 * 1024 * 1024;
const TYPE_DEPTH = 64;
type Text = Generator<void, string>;
/** How one naming convention renders definitions; the graph walk is shared. */
interface TypeStyle {
readonly primitives: ReadonlyMap<number, string>;
/** Byref mark bit, or null when the convention never marks byref. */
readonly byrefBit: number | null;
/** A class or value type definition by TypeDefinitionIndex. */
readonly definition: (index: number) => Text;
/** An Il2CppGenericClass with its instance arguments. */
readonly genericClass: (data: bigint, depth: number) => Text;
}
/**
* One snapshot's type-graph walk: cooperative yields, cycle and depth guards,
* and a UTF-8 workspace reserved before every expansion or join.
*/
const createTypeRenderer = (
image: NativeImage,
facts: Il2cppMetadataLinkFacts,
beforeText: ((bytes: number) => void) | undefined,
style: (tools: RenderTools) => TypeStyle,
) => {
const fail = (message: string): never => {
throw new Il2cppBinaryFormatFailure("invalid-type", message);
};
const unresolved = (address: bigint, label: string): never => {
throw new Il2cppBinaryFormatFailure(
"unsupported-pointer-encoding",
`${label} at 0x${address.toString(16)} is loader-unresolved; its stored bytes are not a resolved type value`,
);
};
const pointer = (address: bigint, label: string): bigint => {
const value = readSlot(image, address);
if (value === "unknown") return unresolved(address, label);
return (
value ?? fail(`${label} at 0x${address.toString(16)} is not file mapped`)
);
};
const u32 = (address: bigint, label: string): number => {
const value = readUInt32(image, address);
if (value === "unknown") return unresolved(address, label);
return (
value ?? fail(`${label} at 0x${address.toString(16)} is not file mapped`)
);
};
let cachedBytes = 0;
let work = 0;
const guard = (bytes: number) => {
if (bytes > TYPE_TEXT_BYTES - cachedBytes)
throw new Il2cppBinaryFormatFailure(
"native-type-text-capacity",
`rendered type text workspace exceeds ${TYPE_TEXT_BYTES} UTF-8 bytes`,
);
beforeText?.(bytes);
};
const join = (parts: readonly string[], separator = "") => {
let bytes = Math.max(parts.length - 1, 0) * Buffer.byteLength(separator);
for (const part of parts) {
bytes += Buffer.byteLength(part);
guard(bytes);
}
return parts.join(separator);
};
const tick = () => ++work % 256 === 0;
const tools = { fail, pointer, u32, guard, join, tick };
const rules = style(tools);
const byAddress = new Map<bigint, string>();
const active = new Set<bigint>();
function* name(address: bigint, depth: number): Text {
const cached = byAddress.get(address);
if (cached !== undefined) return cached;
if (active.has(address))
fail(`cyclic Il2CppType at 0x${address.toString(16)}`);
if (depth > TYPE_DEPTH)
throw new Il2cppBinaryFormatFailure(
"type-nesting-capacity",
`type rendering exceeds ${TYPE_DEPTH} nested frames`,
);
active.add(address);
try {
if (tick()) yield;
const data = pointer(address, "Il2CppType data");
const bits = u32(address + 8n, "Il2CppType attributes");
const kind = il2cppTypeKind(bits);
const index = Number(BigInt.asIntN(32, data & 0xffffffffn));
let result: string;
const primitive = rules.primitives.get(kind);
if (primitive !== undefined) result = primitive;
else if (isDefinitionTypeKind(kind))
result = yield* rules.definition(index);
else if (kind === 0x0f || kind === 0x1d)
result = join([
yield* name(data, depth + 1),
kind === 0x0f ? "*" : "[]",
]);
else if (kind === 0x14) {
const rank = u32(data + 8n, "Il2CppArrayType rank") & 0xff;
if (rank === 0) fail("array rank is zero");
result = join([
yield* name(pointer(data, "array element"), depth + 1),
rank === 1 ? "[*]" : `[${",".repeat(rank - 1)}]`,
]);
} else if (kind === 0x13 || kind === 0x1e)
result =
facts.genericParameterName(index) ??
fail(`generic parameter ${index} is out of range`);
else if (kind === 0x15) result = yield* rules.genericClass(data, depth);
else
throw new Il2cppBinaryFormatFailure(
"unsupported-type-kind",
`type kind 0x${kind.toString(16)} is not rendered`,
);
if (rules.byrefBit !== null && ((bits >>> rules.byrefBit) & 1) === 1)
result = join([result, "&"]);
const bytes = Buffer.byteLength(result);
guard(bytes);
cachedBytes += bytes;
byAddress.set(address, result);
return result;
} finally {
active.delete(address);
}
}
/** Il2CppGenericInst argument names, reserved before each one is kept. */
function* instanceArgumentList(
instance: bigint,
depth: number,
): Generator<void, string[]> {
const count = u32(instance, "generic argument count");
const argv = pointer(instance + 8n, "generic arguments");
guard(Math.max(count - 1, 0) * 2);
// The array may span adjacent loader ranges; each slot is read on its own.
if (!hasPointerArrayCoverage(image, argv, count))
fail("generic argument array is not file mapped");
const args: string[] = [];
let bytes = Math.max(count - 1, 0) * 2;
for (let slot = 0; slot < count; slot++) {
if (tick()) yield;
const argument = yield* name(
pointer(argv + BigInt(slot * 8), "generic argument"),
depth + 1,
);
bytes += Buffer.byteLength(argument);
guard(bytes);
args.push(argument);
}
return args;
}
/** `<A, B>` for one Il2CppGenericInst, reserved before each argument joins. */
function* instanceArguments(instance: bigint, depth: number): Text {
const args = yield* instanceArgumentList(instance, depth);
return join(["<", join(args, ", "), ">"]);
}
return { name, instanceArgumentList, instanceArguments, tools };
};
type RenderTools = {
readonly fail: (message: string) => never;
readonly pointer: (address: bigint, label: string) => bigint;
readonly u32: (address: bigint, label: string) => number;
readonly guard: (bytes: number) => void;
readonly join: (parts: readonly string[], separator?: string) => string;
/** True on every 256th unit of work, when the caller yields. */
readonly tick: () => boolean;
};
/** TypeDefinitionIndex of an Il2CppGenericClass's class or value type definition. */
const genericClassDefinition = (
{ fail, pointer, u32 }: RenderTools,
data: bigint,
version: number,
): number => {
if (version < 27) return u32(data, "generic class definition");
const definitionType = pointer(data, "generic class type");
const definitionData = pointer(definitionType, "generic class definition");
const definitionKind =
(u32(definitionType + 8n, "generic class type attributes") >>> 16) & 0xff;
if (definitionKind !== 0x11 && definitionKind !== 0x12)
fail("generic class type is not a class or value type definition");
return Number(BigInt.asIntN(32, definitionData & 0xffff_ffffn));
};
/** Enclosing types outermost first, with the namespace of the outermost. */
function* declaringChain(
metadata: Il2cppMetadataDecoded,
{ fail, tick }: RenderTools,
index: number,
): Generator<void, { parts: string[]; namespace: string }> {
const seen = new Set<number>();
const parts: string[] = [];
let namespace = "";
for (let at = index; at !== -1;) {
if (seen.has(at)) fail(`cyclic declaring type at ${at}`);
if (tick()) yield;
seen.add(at);
const type =
metadata.types[at] ?? fail(`type definition ${at} is out of range`);
parts.push(type.name);
namespace = type.namespace;
at = type.declaring_type;
}
return { parts: parts.reverse(), namespace };
}
/** Nested generic definitions take their own slice of the arguments. */
function* genericDefinitionName(
metadata: Il2cppMetadataDecoded,
facts: Il2cppMetadataLinkFacts,
{ fail, join, tick }: RenderTools,
index: number,
args: readonly string[],
): Text {
if (!Number.isInteger(index) || index < 0 || index >= metadata.types.length)
fail(`type definition ${index} is out of range`);
const chain: number[] = [];
const seen = new Set<number>();
for (let at = index; at !== -1;) {
if (seen.has(at)) fail(`cyclic declaring type at ${at}`);
if (tick()) yield;
const type = metadata.types[at];
if (type === undefined)
return fail(`type definition ${at} is out of range`);
seen.add(at);
chain.push(at);
at = type.declaring_type;
}
let previousArgumentCount = 0;
const parts: string[] = [];
for (const typeIndex of chain.reverse()) {
if (tick()) yield;
const type = metadata.types[typeIndex]!;
const argumentCount = facts.genericTypeArgumentCounts[typeIndex];
if (
argumentCount === undefined ||
argumentCount < previousArgumentCount ||
argumentCount > args.length
)
return fail(
`generic argument count does not match nested type definition ${typeIndex}`,
);
const componentArguments = args.slice(previousArgumentCount, argumentCount);
previousArgumentCount = argumentCount;
const name = type.name.replace(/`\d+$/u, "");
parts.push(
componentArguments.length === 0
? name
: join([name, "<", join(componentArguments, ", "), ">"]),
);
}
if (previousArgumentCount !== args.length)
fail(
`generic argument count ${args.length} does not match nested type definition ${index}`,
);
const name = join(parts, "+");
const namespace = metadata.types[chain[0]!]!.namespace;
return namespace === "" ? name : join([namespace, name], ".");
}
/** Render one snapshot's type graph cooperatively without allocating unchecked expansions. */
export const createTypeNamer = (
image: NativeImage,
metadata: Il2cppMetadataDecoded,
facts: Il2cppMetadataLinkFacts,
version: number,
beforeText?: (bytes: number) => void,
) => {
const renderer = createTypeRenderer(image, facts, beforeText, (tools) => {
function* definitionName(index: number): Text {
const { parts, namespace } = yield* declaringChain(
metadata,
tools,
index,
);
const name = tools.join(parts, "+");
return namespace === "" ? name : tools.join([namespace, name], ".");
}
return {
primitives: PRIMITIVES,
byrefBit: version >= 29 ? 29 : 30,
definition: definitionName,
*genericClass(data, depth) {
const definition = genericClassDefinition(tools, data, version);
const args = yield* renderer.instanceArgumentList(
tools.pointer(data + 8n, "generic class instance"),
depth,
);
return yield* genericDefinitionName(
metadata,
facts,
tools,
definition,
args,
);
},
};
});
return renderer.name;
};
/**
* Render generic method instance names the way Il2CppDumper's
* GetMethodSpecName does: namespaces only on the declaring type, nested types
* joined with '.', the backtick arity cut at the first '`', no byref mark and
* IntPtr/UIntPtr/TypedReference for those primitives. The byref bit is never
* read, so the 27.0 to 27.2 byref placement does not matter here.
*/
export const createMethodSpecNamer = (
image: NativeImage,
metadata: Il2cppMetadataDecoded,
facts: Il2cppMetadataLinkFacts,
version: number,
beforeText?: (bytes: number) => void,
) => {
const cut = (text: string) => {
const at = text.indexOf("`");
return at === -1 ? text : text.slice(0, at);
};
/** Enclosing types joined with '.', with the namespace only when asked. */
function* nestedName(
tools: RenderTools,
index: number,
namespaces: boolean,
keepOwnArity = false,
): Text {
const { parts, namespace } = yield* declaringChain(metadata, tools, index);
const name = tools.join(
parts.map((part, at) =>
keepOwnArity && at === parts.length - 1 ? part : cut(part),
),
".",
);
return namespaces && namespace !== ""
? tools.join([namespace, name], ".")
: name;
}
const containerIndex = (tools: RenderTools, index: number) =>
facts.typeGenericContainer(index) ??
tools.fail(
`type definition ${index} has no decoded generic container index`,
);
function* containerParameters(tools: RenderTools, index: number): Text {
const container = containerIndex(tools, index);
if (container === -1) return "";
const entry =
facts.genericContainer(container) ??
tools.fail(`generic container ${container} is out of range`);
if (entry.count <= 0)
tools.fail(
`generic container ${container} has ${entry.count} parameters`,
);
const names: string[] = [];
let bytes = (entry.count - 1) * 2;
for (let slot = 0; slot < entry.count; slot++) {
if (tools.tick()) yield;
const parameter =
facts.genericParameterName(entry.parameterStart + slot) ??
tools.fail(
`generic parameter ${entry.parameterStart + slot} is out of range`,
);
bytes += Buffer.byteLength(parameter);
tools.guard(bytes);
names.push(parameter);
}
return tools.join(["<", tools.join(names, ", "), ">"]);
}
const renderer = createTypeRenderer(image, facts, beforeText, (tools) => ({
primitives: DUMPER_PRIMITIVES,
byrefBit: null,
*definition(index) {
return tools.join([
yield* nestedName(tools, index, false),
yield* containerParameters(tools, index),
]);
},
*genericClass(data, depth) {
const owner = genericClassDefinition(tools, data, version);
return tools.join([
yield* nestedName(tools, owner, false),
yield* renderer.instanceArguments(
tools.pointer(data + 8n, "generic class instance"),
depth,
),
]);
},
}));
const { tools } = renderer;
/**
* `Namespace.Type<ClassArgs>$$Method<MethodArgs>`: the declaring type keeps
* its arity suffix unless it is a generic definition.
*/
return function* methodSpecName(
method: number,
classInstance: bigint | null,
methodInstance: bigint | null,
): Text {
const definition =
metadata.methods[method] ??
tools.fail(`method ${method} is out of range`);
const owner = definition.declaring_type;
const generic = containerIndex(tools, owner) !== -1;
return tools.join([
yield* nestedName(tools, owner, true, !generic),
classInstance === null
? ""
: yield* renderer.instanceArguments(classInstance, 0),
"$$",
definition.name,
methodInstance === null
? ""
: yield* renderer.instanceArguments(methodInstance, 0),
]);
};
};