feat(il2cpp): link native methods and export Ghidra seeds

Link IL2CPP metadata names to native addresses and types through shared CLI/MCP workflows, with bounded queries and atomic Ghidra seed export.

Preserve the original implementation by 8rulerstar and follow-up correctness, resource, provenance, and CI dependency repairs. Verified against real Windows/Android artifacts, independent references, actual Ghidra, and focused CLI/MCP tests.

Fixes #5112.
This commit is contained in:
JANG BYUNGKUN authored and GitHub committed 2026-10-20 21:56:54 +08:00
1 parent e50a271c72
commit 52d1a72af0
43 files changed
+5060 -368

No files matched your search

+221
View File
@@ -0,0 +1,221 @@
import {
GoBinaryFormatFailure,
mappedFileOffset,
isZeroFilledSpan,
type GoFileMapping,
type GoZeroFillMapping,
} from "../go/GoBinaryContainer.js";
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
const DT_RELA = 7n,
DT_RELASZ = 8n,
DT_RELAENT = 9n;
const DT_PLTRELSZ = 2n,
DT_PLTREL = 20n,
DT_JMPREL = 23n;
const MAX_OVERLAY_ENTRIES = 65536;
const malformed = (message: string) =>
new Il2cppBinaryFormatFailure("malformed-container", message);
const unsupported = (message: string) =>
new Il2cppBinaryFormatFailure("unsupported-pointer-encoding", message);
interface RelocatedImage {
readonly bytes: Buffer;
readonly unresolved: Set<bigint>;
readonly zeroFillPointers: Map<bigint, bigint>;
}
interface RelocationSource {
readonly image: Buffer;
readonly mappings: readonly GoFileMapping[];
readonly zeroFills: readonly GoZeroFillMapping[];
readonly relative: number;
readonly unknownPointerKinds: ReadonlySet<number>;
}
/** Standard dynamic relocations whose destination spans at most eight bytes.
* Other encodings (including COPY and TLS descriptors) need their own width
* semantics; do not silently apply them or turn their stored zero into absence.
*/
const RELOCATION_KINDS = new Map([
[
183,
{
relative: 1027,
unknownPointerKinds: new Set([
257, 258, 259, 260, 261, 262, 1025, 1026, 1028, 1029, 1030, 1032,
]),
},
],
[
62,
{
relative: 8,
unknownPointerKinds: new Set([1, 2, 6, 7, 10, 11, 16, 17, 18, 24, 37]),
},
],
]);
function* dynamicTags(image: Buffer): Generator<void, Map<bigint, bigint>> {
if (image.length < 64) throw malformed("ELF header is truncated");
const offset = Number(image.readBigUInt64LE(32)),
width = image.readUInt16LE(54),
count = image.readUInt16LE(56);
if (width < 56 || offset + count * width > image.length)
throw malformed("ELF program headers extend beyond the file");
let dynamic: { offset: number; size: number } | undefined;
for (let index = 0; index < count; index++) {
if (index % 256 === 0) yield;
const at = offset + index * width;
if (image.readUInt32LE(at) !== 2) continue;
if (dynamic !== undefined) throw malformed("multiple PT_DYNAMIC segments");
dynamic = {
offset: Number(image.readBigUInt64LE(at + 8)),
size: Number(image.readBigUInt64LE(at + 32)),
};
}
if (dynamic === undefined)
throw new Il2cppBinaryFormatFailure(
"unsupported-container",
"ELF image has no PT_DYNAMIC segment",
);
if (dynamic.size % 16 !== 0 || dynamic.offset + dynamic.size > image.length)
throw malformed("PT_DYNAMIC is truncated or outside the file");
const tags = new Map<bigint, bigint>();
const unique = new Set([
DT_RELA,
DT_RELASZ,
DT_RELAENT,
DT_JMPREL,
DT_PLTRELSZ,
DT_PLTREL,
]);
for (let at = dynamic.offset; at < dynamic.offset + dynamic.size; at += 16) {
if ((at - dynamic.offset) % 65536 === 0) yield;
const tag = image.readBigInt64LE(at);
if (tag === 0n) return tags;
if (tags.has(tag) && unique.has(tag))
throw malformed(`duplicate dynamic tag ${tag}`);
tags.set(tag, image.readBigUInt64LE(at + 8));
}
throw malformed("PT_DYNAMIC has no DT_NULL terminator");
}
const fileOffset = (
mappings: readonly GoFileMapping[],
address: bigint,
size: number,
): number | null => {
try {
return mappedFileOffset(mappings, address, size);
} catch (cause) {
if (cause instanceof GoBinaryFormatFailure) return null;
throw cause;
}
};
const admitOverlay = (output: RelocatedImage) => {
if (
output.unresolved.size + output.zeroFillPointers.size >=
MAX_OVERLAY_ENTRIES
)
throw new Il2cppBinaryFormatFailure(
"native-index-capacity",
`ELF relocation overlay exceeds ${MAX_OVERLAY_ENTRIES} entries`,
);
};
/** Each admitted record is interpreted once from the original immutable bytes. */
function* applyRelaTable(
source: RelocationSource,
output: RelocatedImage,
address: bigint,
size: bigint | undefined,
entry: bigint,
): Generator<void> {
if (
size === undefined ||
size < 0n ||
entry !== 24n ||
size % entry !== 0n ||
size > BigInt(source.image.length)
)
throw malformed("invalid DT_RELASZ or DT_RELAENT");
const start = fileOffset(source.mappings, address, Number(size));
if (start === null) throw malformed("RELA table is not file-backed");
for (let at = start; at < start + Number(size); at += 24) {
if ((at - start) % (24 * 4096) === 0) yield;
const info = source.image.readBigUInt64LE(at + 8),
kind = Number(info & 0xffffffffn);
if (kind === 0) continue;
const destination = source.image.readBigUInt64LE(at);
const target = fileOffset(source.mappings, destination, 8);
if (kind !== source.relative || info >> 32n !== 0n) {
if (!source.unknownPointerKinds.has(kind))
throw unsupported(`ELF relocation kind ${kind} is not decoded`);
if (!output.unresolved.has(destination)) {
admitOverlay(output);
output.unresolved.add(destination);
}
// Registration scans read this private image directly. Clear unknown
// fields so their stored bytes cannot masquerade as resolved pointers.
if (target !== null) output.bytes.fill(0, target, target + 8);
continue;
}
const value = source.image.readBigInt64LE(at + 16) & 0xffffffffffffffffn;
if (target !== null) output.bytes.writeBigUInt64LE(value, target);
else if (
isZeroFilledSpan(source.mappings, source.zeroFills, destination, 8)
) {
if (!output.zeroFillPointers.has(destination)) admitOverlay(output);
output.zeroFillPointers.set(destination, value);
} else
throw malformed(
"RELATIVE relocation target is outside unambiguous loaded memory",
);
}
}
/** Materialize relative pointers in a private copy, preserving unknown and .bss facts. */
export function* applyElfRelativeRelocationsSteps(
image: Buffer,
mappings: readonly GoFileMapping[],
zeroFills: readonly GoZeroFillMapping[],
): Generator<void, RelocatedImage> {
if (image.length < 64) throw malformed("ELF header is truncated");
const kinds = RELOCATION_KINDS.get(image.readUInt16LE(18));
if (kinds === undefined)
throw new Il2cppBinaryFormatFailure(
"unsupported-container",
"ELF relocations require AArch64 or x86-64",
);
const tags = yield* dynamicTags(image);
if (tags.has(0x6000000fn) || tags.has(0x60000011n) || tags.has(17n))
throw unsupported(
"Android packed (APS2) or REL relocations are not decoded",
);
yield;
const output: RelocatedImage = {
bytes: Buffer.from(image),
unresolved: new Set(),
zeroFillPointers: new Map(),
};
const source = { image, mappings, zeroFills, ...kinds };
const rela = tags.get(DT_RELA);
if (rela !== undefined)
yield* applyRelaTable(
source,
output,
rela,
tags.get(DT_RELASZ),
tags.get(DT_RELAENT) ?? 24n,
);
else if ((tags.get(DT_RELASZ) ?? 0n) !== 0n)
throw malformed("DT_RELASZ has no DT_RELA");
const plt = tags.get(DT_JMPREL);
if (plt !== undefined) {
if (tags.get(DT_PLTREL) !== DT_RELA)
throw unsupported("PLT relocations require RELA encoding");
yield* applyRelaTable(source, output, plt, tags.get(DT_PLTRELSZ), 24n);
}
return output;
}
+217
View File
@@ -0,0 +1,217 @@
import type { Il2cppMethodAddress } from "../domain/il2cpp/il2cppMetadata.js";
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
import {
openImageSteps,
readPointer,
readSlot,
isExecutable,
} from "./Il2cppBinaryImage.js";
import {
findModules,
findCodeRegistration,
findMetadataRegistration,
} from "./Il2cppBinaryRegistration.js";
import { createTypeNamer } from "./Il2cppBinaryTypes.js";
import type {
Il2cppMetadataDecoded,
Il2cppMetadataLinkFacts,
Il2cppMetadataReadOptions,
} from "./Il2cppMetadataFile.js";
/** One linked code-generation module; its name matches a metadata image. */
export interface Il2cppCodeGenModule {
readonly image: number;
readonly address: bigint;
readonly method_pointer_count: number;
readonly method_pointers: bigint;
}
type Il2cppMethodCode = bigint | "no-body" | "unknown";
export interface Il2cppBinaryLink {
readonly format: "elf" | "macho" | "pe";
readonly architecture: string;
readonly image_base: bigint;
/**
* Address of the CodeRegistration codeGenModulesCount field, or null when the
* static registration pair was unresolved and an executable byte-pattern
* candidate identified the array; instruction boundaries remain unverified.
*/
readonly code_gen_modules_field: bigint | null;
/** Address of the code-generation module pointer array. */
readonly code_gen_modules_array: bigint;
readonly code_gen_modules: readonly Il2cppCodeGenModule[];
/**
* Native code address per metadata method index; "no-body" when its module
* slot is zero, "unknown" when its owner or relocated pointer is unresolved.
*/
readonly method_addresses: Il2cppMethodAddress[];
/** Address of MetadataRegistration's typesCount field. */
readonly types_field: bigint;
readonly types_count: number;
/** Il2CppType table index and display name per method return, field and parameter. */
readonly return_types: Il2cppTypeReference[];
readonly field_types: Il2cppTypeReference[];
readonly parameter_types: Il2cppTypeReference[];
}
export type Il2cppTypeReference = {
readonly type: number;
readonly name: string | null;
};
export type Il2cppBinaryReadOptions = Pick<
Il2cppMetadataReadOptions,
"beforeText" | "onRow"
>;
/**
* Link metadata methods to native code addresses without executing the image.
* A method token's row id selects its module's methodPointers slot.
*/
export function* linkIl2cppBinarySteps(
bytes: Buffer,
metadata: Il2cppMetadataDecoded,
facts: Il2cppMetadataLinkFacts,
architecture?: string,
options: Il2cppBinaryReadOptions = {},
): Generator<void, Il2cppBinaryLink> {
const version = metadata.metadata_version;
yield;
const image = yield* openImageSteps(bytes, architecture);
const modules = yield* findModules(image, metadata);
const registration_ = yield* findCodeRegistration(image, modules);
const methodImage = new Int32Array(metadata.methods.length).fill(-1);
for (const [index, entry] of metadata.images.entries())
for (let offset = 0; offset < entry.type_count; offset++) {
const type = metadata.types[entry.type_start + offset]!;
if (offset % LINK_BATCH === 0) yield;
for (let method = 0; method < type.method_count; method++) {
if (method % LINK_BATCH === 0) yield;
methodImage[type.method_start + method] = index;
}
}
yield;
const methodAddress = (index: number): Il2cppMethodCode => {
const owner = methodImage[index]!;
// No image range holds this method, so no module slot describes it.
if (owner === -1) return "unknown";
const module = modules[owner]!;
const token = facts.methodTokens[index]!;
const row = token & 0xffffff;
if (token >>> 24 !== 0x06 || row === 0 || row > module.method_pointer_count)
throw new Il2cppBinaryFormatFailure(
"invalid-method-token",
`method ${index} token 0x${token.toString(16)} is outside module ${metadata.images[owner]!.name}'s ${module.method_pointer_count} method pointers`,
);
const address = readSlot(
image,
module.method_pointers + BigInt((row - 1) * 8),
);
if (address === null || address === undefined)
throw new Il2cppBinaryFormatFailure(
"invalid-method-pointer",
`module ${metadata.images[owner]!.name} method pointer ${row - 1} is not file mapped`,
);
if (address === "unknown") return "unknown";
if (address === 0n) return "no-body";
if (!isExecutable(image, address))
throw new Il2cppBinaryFormatFailure(
"invalid-method-pointer",
`method ${index} points outside unambiguous executable file data at 0x${address.toString(16)}`,
);
return address;
};
const method_addresses: Il2cppMethodAddress[] = [];
for (let index = 0; index < metadata.methods.length; index++) {
if (index % LINK_BATCH === 0) yield;
const code = methodAddress(index);
if (typeof code === "bigint" && code < image.imageBase)
throw new Il2cppBinaryFormatFailure(
"invalid-method-pointer",
"method address precedes the link image base",
);
const row: Il2cppMethodAddress =
typeof code === "bigint"
? {
state: "code",
address: `0x${code.toString(16)}`,
rva: `0x${(code - image.imageBase).toString(16)}`,
}
: { state: code, address: null, rva: null };
options.onRow?.(row);
method_addresses.push(row);
}
const registration = yield* findMetadataRegistration(
image,
metadata.types.length,
);
const typeName = createTypeNamer(
image,
metadata,
facts,
version,
options.beforeText,
);
function* reference(type: number): Generator<void, Il2cppTypeReference> {
if (type === -1) return { type, name: null };
if (type < 0 || type >= registration.count)
throw new Il2cppBinaryFormatFailure(
"invalid-type",
`type index ${type} is outside the ${registration.count} binary types`,
);
// 27.0/27.1 and 27.2 share header version 27 but place byref differently.
if (version === 27) return { type, name: null };
const address =
readPointer(image, registration.types + BigInt(type * 8)) ?? 0n;
if (address === 0n)
throw new Il2cppBinaryFormatFailure(
"invalid-type",
`type ${type} has no Il2CppType record`,
);
return { type, name: yield* typeName(address, 0) };
}
function* column(types: Int32Array) {
const rows: Il2cppTypeReference[] = [];
for (let index = 0; index < types.length; index++) {
if (index % LINK_BATCH === 0) yield;
const row = yield* reference(types[index]!);
options.onRow?.(row);
rows.push(row);
}
return rows;
}
const return_types = yield* column(facts.returnTypes);
const field_types = yield* column(facts.fieldTypes);
const parameter_types = yield* column(facts.parameterTypes);
return {
format: image.container.format,
architecture: image.container.architecture,
image_base: image.imageBase,
code_gen_modules_field: registration_.field,
code_gen_modules_array: registration_.array,
code_gen_modules: modules,
method_addresses,
types_field: registration.field,
types_count: registration.count,
return_types,
field_types,
parameter_types,
};
}
/** Rows between cooperative yields while linking. */
const LINK_BATCH = 256;
/** Synchronous linking for byte-level callers. */
export const linkIl2cppBinary = (
bytes: Buffer,
metadata: Il2cppMetadataDecoded,
facts: Il2cppMetadataLinkFacts,
architecture?: string,
): Il2cppBinaryLink => {
const steps = linkIl2cppBinarySteps(bytes, metadata, facts, architecture);
for (;;) {
const step = steps.next();
if (step.done) return step.value;
}
};
+49
View File
@@ -0,0 +1,49 @@
export const IL2CPP_BINARY_FAILURE_REASONS = {
unsupported: [
"unsupported-container",
"slice-selection-required",
"slice-not-found",
"ambiguous-slice-selection",
"unsupported-pointer-encoding",
"unsupported-type-kind",
],
capacity: [
"native-index-capacity",
"type-nesting-capacity",
"native-type-text-capacity",
],
malformed: [
"malformed-container",
"code-gen-modules-not-found",
"ambiguous-code-gen-module",
"code-registration-not-found",
"invalid-method-pointer",
"invalid-method-token",
"metadata-registration-not-found",
"invalid-type",
],
} as const;
export type Il2cppBinaryFailureReason =
(typeof IL2CPP_BINARY_FAILURE_REASONS)[keyof typeof IL2CPP_BINARY_FAILURE_REASONS][number];
/** A native image REA cannot link, either outside coverage or inconsistent with the metadata. */
export class Il2cppBinaryFormatFailure extends Error {
readonly kind: "unsupported" | "malformed" | "capacity";
constructor(
readonly reason: Il2cppBinaryFailureReason,
message: string,
) {
super(`${reason}: ${message}`);
this.name = "Il2cppBinaryFormatFailure";
this.kind = (
IL2CPP_BINARY_FAILURE_REASONS.unsupported as readonly string[]
).includes(reason)
? "unsupported"
: (IL2CPP_BINARY_FAILURE_REASONS.capacity as readonly string[]).includes(
reason,
)
? "capacity"
: "malformed";
}
}
+319
View File
@@ -0,0 +1,319 @@
import {
readFatContainerHeader,
validateFatSlice,
} from "../domain/apple/machoContainer.js";
import {
GoBinaryFormatFailure,
GoBinaryResourceFailure,
mappedFileOffset,
isZeroFilledSpan,
type GoBinaryContainer,
} from "../go/GoBinaryContainer.js";
import { readGoBinaryContainer } from "../go/GoBinaryImage.js";
import { applyElfRelativeRelocationsSteps } from "./ElfRelocations.js";
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
const MACHO_CPU = new Map([
[0x01000007, "amd64"],
[0x0100000c, "arm64"],
]);
/** Select one thin image; a universal Mach-O needs the caller's architecture. */
const selectImage = (
bytes: Buffer,
architecture: string | undefined,
): Buffer => {
const fat = readFatContainerHeader(bytes, bytes.length);
if (fat.state === "not-fat") {
if (architecture !== undefined)
throw new Il2cppBinaryFormatFailure(
"slice-not-found",
`architecture ${architecture} was requested but the binary is not a universal Mach-O`,
);
return bytes;
}
if (fat.state !== "parsed")
throw new Il2cppBinaryFormatFailure(
"malformed-container",
fat.state === "malformed" ? fat.reason : "universal header is truncated",
);
const available = fat.slices.map(
(slice) => MACHO_CPU.get(slice.cpuType) ?? `macho-cpu-${slice.cpuType}`,
);
if (architecture === undefined)
throw new Il2cppBinaryFormatFailure(
"slice-selection-required",
`universal Mach-O holds ${available.join(", ")}; select one architecture`,
);
if (available.filter((value) => value === architecture).length > 1)
throw new Il2cppBinaryFormatFailure(
"ambiguous-slice-selection",
`multiple universal slices declare ${architecture}; no slice was selected`,
);
const index = available.indexOf(architecture);
const slice = fat.slices[index];
if (slice === undefined)
throw new Il2cppBinaryFormatFailure(
"slice-not-found",
`universal Mach-O holds ${available.join(", ")}, not ${architecture}`,
);
const issue = validateFatSlice(slice, bytes.length, fat.tableEnd);
if (issue !== null)
throw new Il2cppBinaryFormatFailure("malformed-container", issue);
return bytes.subarray(slice.offset, slice.offset + slice.size);
};
export interface NativeImage {
readonly bytes: Buffer;
readonly container: GoBinaryContainer;
readonly imageBase: bigint;
readonly unresolvedPointerSlots: ReadonlySet<bigint>;
readonly zeroFillPointers: ReadonlyMap<bigint, bigint>;
}
export function* openImageSteps(
bytes: Buffer,
architecture: string | undefined,
): Generator<void, NativeImage> {
let image = selectImage(bytes, architecture);
let container: GoBinaryContainer;
try {
container = readGoBinaryContainer(image);
} catch (cause) {
if (cause instanceof GoBinaryResourceFailure)
throw new Il2cppBinaryFormatFailure(
"native-index-capacity",
cause.message,
);
if (cause instanceof GoBinaryFormatFailure)
throw new Il2cppBinaryFormatFailure(
cause.kind === "unsupported"
? "unsupported-container"
: "malformed-container",
cause.message,
);
throw cause;
}
if (
architecture !== undefined &&
(container.format !== "macho" || container.architecture !== architecture)
)
throw new Il2cppBinaryFormatFailure(
"malformed-container",
"Selected FAT declaration disagrees with the slice header architecture.",
);
if (container.bits !== 64 || container.byte_order !== "little")
throw new Il2cppBinaryFormatFailure(
"unsupported-container",
`only 64-bit little-endian images are supported, not ${container.bits}-bit ${container.byte_order}-endian`,
);
let relocated:
| {
bytes: Buffer;
unresolved: ReadonlySet<bigint>;
zeroFillPointers: ReadonlyMap<bigint, bigint>;
}
| undefined;
try {
if (container.format === "elf")
relocated = yield* applyElfRelativeRelocationsSteps(
image,
container.mappings,
container.zero_fills,
);
} catch (cause) {
if (cause instanceof GoBinaryFormatFailure)
throw new Il2cppBinaryFormatFailure("malformed-container", cause.message);
throw cause;
}
if (relocated !== undefined) image = relocated.bytes;
if (container.format === "macho" && container.macho_chained_fixups === true)
throw new Il2cppBinaryFormatFailure(
"unsupported-pointer-encoding",
"Mach-O chained fixups are not decoded",
);
if (container.format === "pe" && container.image_base === undefined)
throw new Il2cppBinaryFormatFailure(
"malformed-container",
"PE image base was not observed",
);
const imageBase =
container.image_base ??
container.mappings.find((mapping) => mapping.offset === 0)?.address ??
container.mappings.reduce<bigint | undefined>(
(lowest, mapping) =>
lowest === undefined || mapping.address < lowest
? mapping.address
: lowest,
undefined,
) ??
0n;
return {
bytes: image,
container,
imageBase,
unresolvedPointerSlots: relocated?.unresolved ?? new Set<bigint>(),
zeroFillPointers: relocated?.zeroFillPointers ?? new Map<bigint, bigint>(),
};
}
export const readPointer = (
image: NativeImage,
address: bigint,
): bigint | null => {
if (hasUnresolvedPointer(image, address)) return null;
const virtual = image.zeroFillPointers.get(address);
if (virtual !== undefined) return virtual;
try {
const offset = mappedFileOffset(
image.container.mappings,
address,
8,
image.container.zero_fills,
);
return image.bytes.readBigUInt64LE(offset);
} catch (cause) {
if (cause instanceof GoBinaryFormatFailure) return null;
throw cause;
}
};
/** A UInt32 field owns four bytes; reading a pointer would also require unrelated padding. */
export const readUInt32 = (
image: NativeImage,
address: bigint,
): number | null => {
if (hasUnresolvedPointer(image, address, 4)) return null;
const virtual = image.zeroFillPointers.get(address);
if (virtual !== undefined) return Number(virtual & 0xffffffffn);
try {
return image.bytes.readUInt32LE(
mappedFileOffset(
image.container.mappings,
address,
4,
image.container.zero_fills,
),
);
} catch (cause) {
if (cause instanceof GoBinaryFormatFailure) return null;
throw cause;
}
};
export const isMapped = (image: NativeImage, address: bigint, size: number) => {
try {
mappedFileOffset(image.container.mappings, address, size);
return true;
} catch (cause) {
if (cause instanceof GoBinaryFormatFailure) return false;
throw cause;
}
};
// An array of slots that all lack a body can sit wholly in zero-fill (.bss):
// an iOS-only assembly in an Android build has its pointers there.
export const isZeroFilled = (
image: NativeImage,
address: bigint,
size: number,
) =>
isZeroFilledSpan(
image.container.mappings,
image.container.zero_fills,
address,
size,
);
/** Admit readable slots across loader ranges without walking every pointer.
* Each chunk uses the format owner's alias checks; a gap or split slot is refused.
*/
export const hasPointerArrayCoverage = (
image: NativeImage,
address: bigint,
count: number,
): boolean => {
const end = address + BigInt(count) * 8n;
let cursor = address;
while (cursor < end) {
let fileEnd = cursor;
for (const range of image.container.mappings)
if (
range.address <= cursor &&
range.address + BigInt(range.size) > fileEnd
)
fileEnd = range.address + BigInt(range.size);
let coveredEnd = fileEnd;
if (fileEnd === cursor)
for (const range of image.container.zero_fills)
if (range.address <= cursor && range.address + range.size > coveredEnd)
coveredEnd = range.address + range.size;
const stop = coveredEnd < end ? coveredEnd : end;
const size = Number(((stop - cursor) / 8n) * 8n);
if (size <= 0) return false;
try {
if (fileEnd === cursor) {
if (!isZeroFilled(image, cursor, size)) return false;
} else {
mappedFileOffset(
image.container.mappings,
cursor,
size,
image.container.zero_fills,
);
}
} catch (cause) {
if (cause instanceof GoBinaryFormatFailure) return false;
throw cause;
}
cursor += BigInt(size);
}
return true;
};
/** Both executable coverage and the unique full loader mapping must agree. */
export const isExecutable = (image: NativeImage, address: bigint): boolean => {
try {
const offset = mappedFileOffset(
image.container.mappings,
address,
1,
image.container.zero_fills,
);
return (
offset ===
mappedFileOffset(image.container.executable_mappings ?? [], address, 1)
);
} catch (cause) {
if (cause instanceof GoBinaryFormatFailure) return false;
throw cause;
}
};
const hasUnresolvedPointer = (
image: NativeImage,
address: bigint,
size = 8,
): boolean => {
if (image.unresolvedPointerSlots.size === 0) return false;
// Unsupported relocation widths are unknown; conservatively cover any
// overlap with the pointer rather than infer a zero or trust stored bytes.
for (let delta = -7; delta < size; delta++)
if (image.unresolvedPointerSlots.has(address + BigInt(delta))) return true;
return false;
};
export const readSlot = (
image: NativeImage,
address: bigint,
): bigint | "unknown" | null => {
if (hasUnresolvedPointer(image, address)) return "unknown";
const virtual = image.zeroFillPointers.get(address);
if (virtual !== undefined) return virtual;
try {
return isZeroFilled(image, address, 8) ? 0n : readPointer(image, address);
} catch (cause) {
if (cause instanceof GoBinaryFormatFailure)
throw new Il2cppBinaryFormatFailure("malformed-container", cause.message);
throw cause;
}
};
+339
View File
@@ -0,0 +1,339 @@
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
import {
isMapped,
hasPointerArrayCoverage,
readPointer,
type NativeImage,
} from "./Il2cppBinaryImage.js";
import type { Il2cppMetadataDecoded } from "./Il2cppMetadataFile.js";
import type { Il2cppCodeGenModule } from "./Il2cppBinaryFile.js";
// Bound the retained name-address index independently of binary size. No
// repeated name is evidence of ambiguous module ownership by itself.
const TOTAL_NAME_OCCURRENCES = 65536;
const SCAN_BYTES = 65536;
interface NameTrie {
readonly next: Map<number, NameTrie>;
readonly owners: number[];
}
/** Reverse byte matching permits C-string suffix sharing. Only a module's
* declared pointer later establishes which exact candidate it actually names.
* No decoding/alphabet guess or per-name binary rescan is needed.
*/
function* nameAddresses(
image: NativeImage,
names: readonly string[],
): Generator<void, Map<bigint, readonly number[]>> {
const root: NameTrie = { next: new Map(), owners: [] };
let nodes = 1,
work = 0;
for (const [index, name] of names.entries()) {
if (++work % 256 === 0) yield;
if (name === "") continue;
if (Buffer.byteLength(name) > TOTAL_NAME_OCCURRENCES)
throw new Il2cppBinaryFormatFailure(
"native-index-capacity",
"module-name trie exceeds its65536-node workspace",
);
let node = root;
const bytes = Buffer.from(name, "utf8");
for (let at = bytes.length - 1; at >= 0; at--) {
if (++work % 4096 === 0) yield;
let next = node.next.get(bytes[at]!);
if (next === undefined) {
if (nodes >= TOTAL_NAME_OCCURRENCES)
throw new Il2cppBinaryFormatFailure(
"native-index-capacity",
"module-name trie exceeds its65536-node workspace",
);
next = { next: new Map(), owners: [] };
node.next.set(bytes[at]!, next);
nodes++;
}
node = next;
}
node.owners.push(index);
}
const found = new Map<bigint, readonly number[]>();
for (const mapping of image.container.mappings) {
const region = image.bytes.subarray(
mapping.offset,
mapping.offset + mapping.size,
);
for (let chunk = 0; chunk < region.length; chunk += SCAN_BYTES) {
yield;
const part = region.subarray(
chunk,
Math.min(chunk + SCAN_BYTES, region.length),
);
for (
let end = part.indexOf(0);
end >= 0;
end = part.indexOf(0, end + 1)
) {
let node: NameTrie | undefined = root;
for (let at = chunk + end - 1; at >= 0; at--) {
if (++work % 4096 === 0) yield;
node = node.next.get(region[at]!);
if (node === undefined) break;
if (node.owners.length === 0) continue;
const address = mapping.address + BigInt(at);
if (!found.has(address) && found.size >= TOTAL_NAME_OCCURRENCES)
throw new Il2cppBinaryFormatFailure(
"native-index-capacity",
`native name-address index exceeds ${TOTAL_NAME_OCCURRENCES} entries`,
);
found.set(address, node.owners);
}
}
}
}
return found;
}
/** Method count per image from its contiguous type-definition range. */
function* imageMethodCounts(
metadata: Il2cppMetadataDecoded,
): Generator<void, number[]> {
const counts: number[] = [];
for (const image of metadata.images) {
yield;
let total = 0;
for (let offset = 0; offset < image.type_count; offset++) {
if (offset % 256 === 0) yield;
total += metadata.types[image.type_start + offset]!.method_count;
}
counts.push(total);
}
return counts;
}
/**
* Find Il2CppCodeGenModule records: a moduleName pointer to an image name
* followed by methodPointerCount equal to that image's method count.
*/
export function* findModules(
image: NativeImage,
metadata: Il2cppMetadataDecoded,
): Generator<void, Il2cppCodeGenModule[]> {
const names = yield* nameAddresses(
image,
metadata.images.map((entry) => entry.name),
);
const counts = yield* imageMethodCounts(metadata);
const candidates = new Map<number, Il2cppCodeGenModule[]>();
for (const mapping of image.container.mappings) {
const start = Number((8n - (mapping.address % 8n)) % 8n);
for (let at = start; at + 24 <= mapping.size; at += 8) {
if ((at & 0xffff) < 8) yield;
const offset = mapping.offset + at;
const target = image.bytes.readBigUInt64LE(offset);
const indices = names.get(target);
if (indices === undefined) continue;
let work = 0;
for (const index of indices) {
if (++work % 256 === 0) yield;
const count = image.bytes.readUInt32LE(offset + 8);
if (count !== counts[index]) continue;
const pointers = image.bytes.readBigUInt64LE(offset + 16);
if (!hasPointerArrayCoverage(image, pointers, count)) continue;
const list = candidates.get(index) ?? [];
if (list.length !== 0)
throw new Il2cppBinaryFormatFailure(
"ambiguous-code-gen-module",
`multiple records match code generation module ${metadata.images[index]!.name}`,
);
list.push({
image: index,
address: mapping.address + BigInt(at),
method_pointer_count: count,
method_pointers: pointers,
});
candidates.set(index, list);
}
}
}
return metadata.images.map((entry, index) => {
const list = candidates.get(index) ?? [];
if (list.length === 0)
throw new Il2cppBinaryFormatFailure(
"code-gen-modules-not-found",
`no code generation module names ${entry.name} with ${counts[index]} method pointers`,
);
if (list.length > 1)
throw new Il2cppBinaryFormatFailure(
"ambiguous-code-gen-module",
`${list.length} records match code generation module ${entry.name}`,
);
return list[0]!;
});
}
/**
* Find the CodeRegistration codeGenModulesCount/codeGenModules pair: a count
* equal to the image count followed by a pointer to an array holding exactly
* the located module records.
*/
export function* findCodeRegistration(
image: NativeImage,
modules: readonly Il2cppCodeGenModule[],
): Generator<void, { readonly field: bigint | null; readonly array: bigint }> {
const expected = new Set(modules.map((module) => module.address));
const total = BigInt(modules.length);
const matches: { field: bigint; array: bigint }[] = [];
for (const mapping of image.container.mappings) {
const start = Number((8n - (mapping.address % 8n)) % 8n);
for (let at = start; at + 16 <= mapping.size; at += 8) {
if ((at & 0xffff) < 8) yield;
const offset = mapping.offset + at;
if (image.bytes.readBigUInt64LE(offset) !== total) continue;
const array = image.bytes.readBigUInt64LE(offset + 8);
if (!isMapped(image, array, modules.length * 8)) continue;
const seen = new Set<bigint>();
for (let slot = 0; slot < modules.length; slot++) {
if (slot % 256 === 0) yield;
const pointer = readPointer(image, array + BigInt(slot * 8));
if (pointer === null || !expected.has(pointer)) break;
seen.add(pointer);
}
if (seen.size === modules.length) {
if (matches.length !== 0)
throw new Il2cppBinaryFormatFailure(
"code-registration-not-found",
"multiple CodeRegistration module tables match",
);
matches.push({ field: mapping.address + BigInt(at), array });
}
}
}
if (matches.length === 0 && image.container.architecture === "amd64") {
const array = yield* findModuleArrayByCode(image, expected);
if (array !== null) return { field: null, array };
}
if (matches.length !== 1)
throw new Il2cppBinaryFormatFailure(
"code-registration-not-found",
`${matches.length} CodeRegistration module tables list the ${modules.length} located modules`,
);
return matches[0]!;
}
/**
* Some x86-64 builds (a Unity 2023.2 Linux GameAssembly.so) keep no static
* CodeRegistration: the compiler folded the module array and its count into
* the one loop that walks it, so no data word points at the array. Find every
* pointer array that lists exactly the located modules, then require exactly
* one of them to be the target of a RIP-relative `lea`/`mov` (REX.W 8D/8B with
* a disp32 operand). Anything else stays code-registration-not-found.
*/
const MODULE_ARRAY_CANDIDATES = 64;
function* findModuleArrayByCode(
image: NativeImage,
expected: ReadonlySet<bigint>,
): Generator<void, bigint | null> {
const arrays = new Set<bigint>();
for (const mapping of image.container.mappings) {
const start = Number((8n - (mapping.address % 8n)) % 8n);
let runStart = start;
const lastPosition = new Map<bigint, number>();
for (let at = start; at + 8 <= mapping.size; at += 8) {
if ((at & 0xffff) < 8) yield;
const value = image.bytes.readBigUInt64LE(mapping.offset + at);
if (!expected.has(value)) {
runStart = at + 8;
lastPosition.clear();
continue;
}
const previous = lastPosition.get(value);
if (previous !== undefined && previous >= runStart)
runStart = previous + 8;
lastPosition.set(value, at);
if (at - runStart === (expected.size - 1) * 8) {
arrays.add(mapping.address + BigInt(runStart));
// Retain overlapping windows; at most one position per expected module.
if (arrays.size > MODULE_ARRAY_CANDIDATES)
throw new Il2cppBinaryFormatFailure(
"native-index-capacity",
`module-array index exceeds ${MODULE_ARRAY_CANDIDATES} entries`,
);
}
}
}
if (arrays.size === 0) return null;
const referenced = new Set<bigint>();
for (const mapping of image.container.executable_mappings ?? []) {
const bytes = image.bytes;
const end = mapping.offset + mapping.size - 7;
for (let at = mapping.offset; at <= end; at++) {
if ((at & 0xffff) < 8) yield;
const rex = bytes[at]!;
if ((rex & 0xfb) !== 0x48) continue; // REX.W, optionally REX.R
const opcode = bytes[at + 1]!;
if (opcode !== 0x8d && opcode !== 0x8b) continue;
if ((bytes[at + 2]! & 0xc7) !== 0x05) continue; // mod 00, rm 101: RIP + disp32
const next = mapping.address + BigInt(at - mapping.offset + 7);
const target = next + BigInt(bytes.readInt32LE(at + 3));
if (arrays.has(target)) referenced.add(target);
}
}
return referenced.size === 1 ? [...referenced][0]! : null;
}
/**
* Find MetadataRegistration by its two TypeDefinitionIndex-sized counts:
* fieldOffsetsCount and typeDefinitionsSizesCount (words 10 and 12) both equal
* the metadata type-definition count and are followed by mapped pointers.
* typesCount and types are words 6 and 7. Counts are int32 in 8-byte slots.
*/
export function* findMetadataRegistration(
image: NativeImage,
typeDefinitions: number,
): Generator<
void,
{
readonly field: bigint;
readonly count: number;
readonly types: bigint;
}
> {
const matches: { field: bigint; count: number; types: bigint }[] = [];
const low = (offset: number) => image.bytes.readUInt32LE(offset);
const word = (offset: number) => image.bytes.readBigUInt64LE(offset);
for (const mapping of image.container.mappings) {
const start = Number((8n - (mapping.address % 8n)) % 8n);
for (let at = start; at + 128 <= mapping.size; at += 8) {
if ((at & 0xffff) < 8) yield;
const base = mapping.offset + at;
if (low(base + 80) !== typeDefinitions) continue;
if (low(base + 96) !== typeDefinitions) continue;
const count = low(base + 48);
const types = word(base + 56);
if (
count === 0 ||
count > 0x7fffffff ||
!isMapped(image, word(base + 88), 8) ||
!isMapped(image, word(base + 104), 8) ||
!isMapped(image, types, count * 8)
)
continue;
if (matches.length !== 0)
throw new Il2cppBinaryFormatFailure(
"metadata-registration-not-found",
"multiple MetadataRegistration candidates match",
);
matches.push({
field: mapping.address + BigInt(at + 48),
count,
types,
});
}
}
if (matches.length !== 1)
throw new Il2cppBinaryFormatFailure(
"metadata-registration-not-found",
`${matches.length} MetadataRegistration candidates count ${typeDefinitions} type definitions`,
);
return matches[0]!;
}
+175
View File
@@ -0,0 +1,175 @@
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
import {
readPointer,
readUInt32,
isMapped,
type NativeImage,
} from "./Il2cppBinaryImage.js";
import type {
Il2cppMetadataDecoded,
Il2cppMetadataLinkFacts,
} from "./Il2cppMetadataFile.js";
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"],
]);
// 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;
/** 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 fail = (message: string): never => {
throw new Il2cppBinaryFormatFailure("invalid-type", message);
};
const pointer = (address: bigint, label: string) =>
readPointer(image, address) ??
fail(`${label} at 0x${address.toString(16)} is not file mapped`);
const u32 = (address: bigint, label: string) =>
readUInt32(image, address) ??
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);
};
function* definitionName(index: number): Generator<void, 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 (++work % 256 === 0) 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;
}
const name = join(parts.reverse(), "+");
return namespace === "" ? name : join([namespace, name], ".");
}
const byAddress = new Map<bigint, string>();
const active = new Set<bigint>();
const byrefBit = version >= 29 ? 29 : 30;
function* name(address: bigint, depth: number): Generator<void, string> {
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 (++work % 256 === 0) yield;
const data = pointer(address, "Il2CppType data");
const bits = u32(address + 8n, "Il2CppType attributes");
const kind = (bits >>> 16) & 0xff;
const index = Number(BigInt.asIntN(32, data & 0xffffffffn));
let result: string;
const primitive = PRIMITIVES.get(kind);
if (primitive !== undefined) result = primitive;
else if (kind === 0x11 || kind === 0x12)
result = yield* definitionName(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),
"[",
",".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* generic(data, depth);
else
throw new Il2cppBinaryFormatFailure(
"unsupported-type-kind",
`type kind 0x${kind.toString(16)} is not rendered`,
);
if (((bits >>> 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);
}
}
function* generic(data: bigint, depth: number): Generator<void, string> {
const definition =
version >= 27
? yield* name(pointer(data, "generic class type"), depth + 1)
: yield* definitionName(u32(data, "generic class definition"));
const instance = pointer(data + 8n, "generic class instance");
const count = u32(instance, "generic argument count");
const argv = pointer(instance + 8n, "generic arguments");
guard(Math.max(count - 1, 0) * 2);
if (!isMapped(image, argv, count * 8))
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 (++work % 256 === 0) yield;
const argument = yield* name(
pointer(argv + BigInt(slot * 8), "generic argument"),
depth + 1,
);
bytes += Buffer.byteLength(argument);
guard(bytes);
args.push(argument);
}
return join([definition.replace(/`\d+$/u, ""), "<", join(args, ", "), ">"]);
}
return name;
};
+162
View File
@@ -0,0 +1,162 @@
import { lstat } from "node:fs/promises";
import { isAbsolute } from "node:path";
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
import type {
Il2cppSeedWriterPort,
Il2cppSeedOutput,
Il2cppSeedReceipt,
} from "../application/il2cpp/Il2cppSeedWriterPort.js";
import { writeTextParts } from "../application/JsonFiles.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { EvidenceFileError } from "../domain/evidenceErrors.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { Il2cppMetadata } from "../domain/il2cpp/il2cppMetadata.js";
import { err, ok, type Result } from "../domain/result.js";
import { ghidraDefaultBase, il2cppGhidraSeeds } from "./Il2cppGhidraSeeds.js";
const OPERATION = "inspect_il2cpp_metadata";
const invalid = (field: string, message: string) =>
new AnalysisInputError(OPERATION, undefined, [
{ path: [field], reason: "invalid_value", message },
]);
/** Atomic create-only publication, with failed cleanup retained for close retry. */
export class Il2cppGhidraSeedWriter implements Il2cppSeedWriterPort {
readonly #resources = new ArtifactResourceScope();
readonly #cleanup = AnalysisCleanupLease.forCloseOwner(
this.#resources,
"IL2CPP seed output resources",
);
#writing = false;
async check(
output: Il2cppSeedOutput,
options?: ExecutionOptions,
): Promise<Result<void, AnalysisError>> {
if (options?.signal?.aborted)
return err(
new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }),
);
if (!isAbsolute(output.path))
return err(
invalid(
"ghidra_seeds",
"Seed output needs an absolute filesystem path.",
),
);
if (
output.base !== undefined &&
!/^0x[0-9a-fA-F]{1,16}$/u.test(output.base)
)
return err(
invalid("seed_base", "Seed base must be a 64-bit hexadecimal address."),
);
try {
await lstat(output.path);
return err(
invalid(
"ghidra_seeds",
`--ghidra-seeds ${output.path} already exists; seed files are created, never replaced.`,
),
);
} catch (cause) {
if (cause instanceof Error && "code" in cause && cause.code === "ENOENT")
return ok(undefined);
return err(
new EvidenceFileError("write", "io", { path: output.path, cause }),
);
}
}
async write(
report: Il2cppMetadata,
output: Il2cppSeedOutput,
options?: ExecutionOptions,
): Promise<Result<Il2cppSeedReceipt, AnalysisError>> {
if (this.#writing || this.#resources.hasPending || this.#cleanup.released)
return err(
new AnalysisResourceConstraintError(
OPERATION,
"capacity",
"Seed output is busy, closed, or awaiting owned cleanup.",
{ maximum_concurrent_seed_exports: 1 },
),
);
this.#writing = true;
try {
return await this.#resources.run(() =>
this.#write(report, output, options),
);
} finally {
this.#writing = false;
}
}
async #write(
report: Il2cppMetadata,
output: Il2cppSeedOutput,
options: ExecutionOptions | undefined,
): Promise<Result<Il2cppSeedReceipt, AnalysisError>> {
const ready = await this.check(output, options);
if (!ready.ok) return ready;
if (report.binary === null)
return err(invalid("binary_path", "Ghidra seeds need binary_path."));
let seeds;
try {
seeds = il2cppGhidraSeeds(
report,
output.base === undefined
? ghidraDefaultBase(report.binary)
: BigInt(output.base),
);
} catch (cause) {
if (cause instanceof AnalysisError) return err(cause);
throw cause;
}
const written = await writeTextParts(
[seeds.text],
output.path,
false,
this.#resources,
options?.signal === undefined
? undefined
: { signal: options.signal, operation: OPERATION },
);
if (!written.ok) {
if (written.error.cleanupIncomplete)
this.#cleanup.bindCleanupError(written.error);
return written;
}
return ok({
...written.value,
functions: seeds.functions,
folded: seeds.folded,
no_body: seeds.no_body,
unknown: seeds.unknown,
});
}
async close(): Promise<void> {
try {
await this.#cleanup.release();
} catch (cause) {
if (
!(cause instanceof ArtifactReaderFailure) ||
cause.cleanup === undefined
)
throw cause;
const error = new ProviderCleanupError(
"il2cpp-metadata",
cause.cleanup.resources,
{ reason: cause.cleanup.reason },
{ cause, operation: OPERATION },
);
this.#cleanup.bindCleanupError(error);
throw error;
}
}
}
+199
View File
@@ -0,0 +1,199 @@
import {
MAX_GHIDRA_SEED_BYTES,
MAX_GHIDRA_SEED_ENTRIES,
MAX_GHIDRA_SEED_NAME_LENGTH,
} from "../ghidra/GhidraAnalysisSeeds.js";
import {
AnalysisInputError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
const OPERATION = "inspect_il2cpp_metadata";
const capacity = (message: string) =>
new AnalysisResourceConstraintError(OPERATION, "capacity", message, {
maximum_seed_bytes: MAX_GHIDRA_SEED_BYTES,
maximum_seed_name_characters: MAX_GHIDRA_SEED_NAME_LENGTH,
});
import type { Il2cppMetadata } from "../domain/il2cpp/il2cppMetadata.js";
/** A linked metadata report reduced to Ghidra function seeds. */
export interface Il2cppGhidraSeeds {
/** `address<TAB>function<TAB>name` lines in the REA_GHIDRA_SEED_FILE format. */
readonly text: string;
readonly functions: number;
/** Methods whose code address another method already seeded (identical code folding). */
readonly folded: number;
/** Each folded address with the name its function seed kept and the names it shares. */
readonly folded_groups: readonly {
readonly address: string;
readonly kept: string;
readonly also: readonly string[];
}[];
/** Methods without native code: abstract, interface, or generic definitions. */
readonly without_address: number;
readonly no_body: number;
readonly unknown: number;
}
/**
* Where Ghidra's default loaders place the binary: an ELF linked at 0 goes to
* 0x100000, while Mach-O and PE images keep their link base.
*/
export const ghidraDefaultBase = (
binary: NonNullable<Il2cppMetadata["binary"]>,
): bigint => {
const linked = BigInt(binary.image_base);
return binary.format === "elf" && linked === 0n ? 0x100000n : linked;
};
/** `Namespace.Outer.Inner` for a type, following declaring_type to the outermost type. */
const typeName = (report: Il2cppMetadata, index: number): string => {
const parts: string[] = [];
let namespace = "";
const seen = new Set<number>();
let length = 0;
for (let at = index; at !== -1;) {
if (seen.has(at))
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["types", at],
reason: "invalid_format",
message: "Cyclic type ancestry cannot name a seed.",
},
]);
seen.add(at);
const type = report.types[at];
if (type === undefined)
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["types", at],
reason: "invalid_format",
message: "Seed type owner is unavailable.",
},
]);
length += type.name.length + 1;
if (length > MAX_GHIDRA_SEED_NAME_LENGTH)
throw capacity("Qualified seed type name exceeds the Ghidra name limit.");
parts.unshift(type.name);
namespace = type.namespace;
if (length + namespace.length > MAX_GHIDRA_SEED_NAME_LENGTH)
throw capacity("Qualified seed type name exceeds the Ghidra name limit.");
at = type.declaring_type;
}
return namespace === "" ? parts.join(".") : `${namespace}.${parts.join(".")}`;
};
/**
* Name every linked method `Type$$Method`, the Il2CppDumper convention, at
* `base + rva`. Whitespace becomes `_` because seed names cannot contain it.
* The first method at an address names its function, since a seed file holds
* one function seed per address. The other methods at that address are listed
* in `#` comment lines after the seeds, so a reader of the file or the
* decompilation can see that the kept name is one of several.
*/
export const il2cppGhidraSeeds = (
report: Il2cppMetadata,
base: bigint,
): Il2cppGhidraSeeds => {
if (report.binary === null)
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["binary_path"],
reason: "invalid_value",
message: "Ghidra seeds need a report linked with binary_path.",
},
]);
const seeded = new Map<bigint, { kept: string; also: string[] }>();
const lines: string[] = [];
let folded = 0;
let withoutAddress = 0,
noBody = 0,
unknown = 0,
bytes = 0;
const typeNames = new Map<number, string>();
const reserve = (length: number) => {
if (length > MAX_GHIDRA_SEED_BYTES - bytes)
throw capacity("Seed file exceeds Ghidra byte admission.");
bytes += length;
};
const emit = (text: string) => {
reserve(Buffer.byteLength(text));
lines.push(text);
};
report.methods.forEach((method, index) => {
const rva = report.method_addresses[index]?.rva ?? null;
if (rva === null) {
withoutAddress++;
if (report.method_addresses[index]?.state === "no-body") noBody++;
else unknown++;
return;
}
const address = base + BigInt(rva);
if (address < 0n || address > 0xffffffffffffffffn)
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["seed_base"],
reason: "invalid_value",
message: "Rebased seed address exceeds 64 bits.",
},
]);
let ownerName = typeNames.get(method.declaring_type);
if (ownerName === undefined) {
ownerName = typeName(report, method.declaring_type);
typeNames.set(method.declaring_type, ownerName);
}
if (ownerName.length + 2 + method.name.length > MAX_GHIDRA_SEED_NAME_LENGTH)
throw capacity(
"Qualified method seed name exceeds Ghidra character admission.",
);
const name = `${ownerName}$$${method.name}`.replace(/\s/gu, "_");
if (name.includes("\0"))
throw new AnalysisInputError(OPERATION, undefined, [
{
path: ["methods", index, "name"],
reason: "invalid_format",
message: "Seed names cannot contain NUL.",
},
]);
const group = seeded.get(address);
if (group !== undefined) {
folded++;
if (group.also.length === 0)
reserve(
Buffer.byteLength(
`# folded 0x${address.toString(16)} ${group.kept} also\n`,
),
);
reserve(Buffer.byteLength(` ${name}`));
group.also.push(name);
return;
}
seeded.set(address, { kept: name, also: [] });
if (seeded.size > MAX_GHIDRA_SEED_ENTRIES)
throw capacity("Seed function count exceeds Ghidra entry admission.");
emit(`0x${address.toString(16)}\tfunction\t${name}\n`);
});
const foldedGroups = [...seeded]
.filter(([, group]) => group.also.length > 0)
.map(([address, group]) => ({
address: `0x${address.toString(16)}`,
kept: group.kept,
also: group.also,
}));
for (const group of foldedGroups) {
lines.push(`# folded ${group.address} ${group.kept} also`);
for (const name of group.also) lines.push(` ${name}`);
lines.push("\n");
}
emit(
`# metadata_sha256 ${report.artifact.sha256}\n# binary_sha256 ${report.binary.artifact.sha256}\n# link_base ${report.binary.image_base}\n# seed_base 0x${base.toString(16)}\n`,
);
return {
text: lines.join(""),
functions: seeded.size,
folded,
folded_groups: foldedGroups,
without_address: withoutAddress,
no_body: noBody,
unknown,
};
};
+9
View File
@@ -29,6 +29,7 @@ const emptyRows = {
export const createIl2cppMetadataAdmission = (
limit: number,
exceed: () => never,
linked = false,
): Required<Il2cppMetadataReadOptions> => {
let used = 0;
// Keys/decoded names/literal spans are immutable within this one decoder run.
@@ -45,6 +46,14 @@ export const createIl2cppMetadataAdmission = (
let minimum = 0;
for (const key of Object.keys(emptyRows) as (keyof typeof emptyRows)[])
minimum += counts[key] * JSON.stringify(emptyRows[key]).length;
if (linked)
minimum +=
counts.methods *
(JSON.stringify({ state: "code", address: null, rva: null })
.length +
JSON.stringify({ type: 0, name: "" }).length) +
(counts.fields + counts.parameters) *
JSON.stringify({ type: 0, name: "" }).length;
if (minimum > limit) exceed();
},
beforeText(bytes) {
+122 -29
View File
@@ -1,5 +1,6 @@
import {
checkRange,
readIndex,
Il2cppMetadataFormatFailure,
} from "./Il2cppMetadataFormat.js";
import {
@@ -24,7 +25,13 @@ export type Il2cppMetadataVersion = (typeof IL2CPP_METADATA_VERSIONS)[number];
type RecordSection = "typeDefinitions" | "images" | "methods";
export type Il2cppMetadataDecoded = Omit<
Il2cppMetadata,
"artifact" | "limitations"
| "artifact"
| "limitations"
| "binary"
| "method_addresses"
| "return_types"
| "field_types"
| "parameter_types"
>;
/**
@@ -121,19 +128,6 @@ export const il2cppMetadataRecordBytes = (
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. */
export 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)
@@ -390,6 +384,7 @@ export interface MetadataTables {
readonly members: ReturnType<typeof readIl2cppMemberTables>;
readonly beforeText: Il2cppMetadataReadOptions["beforeText"];
readonly onRow: Il2cppMetadataReadOptions["onRow"];
readonly linkFacts?: Il2cppMetadataLinkFacts;
}
function* readImages({
@@ -572,6 +567,7 @@ function* readMethods({
counts,
readString,
methodReturnParameterToken,
linkFacts,
onRow,
}: MetadataTables): Generator<
void,
@@ -596,6 +592,17 @@ function* readMethods({
"index-out-of-range",
`method ${index} declares type ${method.declaring_type} outside the ${typeTotal} type definitions`,
);
if (linkFacts !== undefined) {
const returnAt = at + 4 + widths.type_definition;
const tokenAt =
returnAt +
widths.type +
(methodReturnParameterToken ? 4 : 0) +
widths.parameter +
widths.generic_container;
linkFacts.returnTypes[index] = readIndex(bytes, returnAt, widths.type);
linkFacts.methodTokens[index] = bytes.readUInt32LE(tokenAt);
}
parameterRanges.push({
start: readIndex(
bytes,
@@ -700,11 +707,75 @@ export interface Il2cppMetadataReadOptions {
) => void;
}
/** Per-row typed fields the public inventory omits but native linking needs. */
export interface Il2cppMetadataLinkFacts {
/** Method definition token per method index. */
readonly methodTokens: Uint32Array;
/** Il2CppType table index of each method's return type. */
readonly returnTypes: Int32Array;
readonly fieldTypes: Int32Array;
readonly parameterTypes: Int32Array;
/** Demand-read names from this stable metadata snapshot, by GenericParameterIndex. */
readonly genericParameterName: (index: number) => string | undefined;
}
/** Own the row layout once; unused generic names allocate no arrays or strings. */
const createGenericParameterReader = (tables: MetadataTables) => {
const parameters = tables.section("genericParameters"),
width = tables.widths.generic_container + 12;
const count = entriesIn(parameters, width);
if (parameters.count !== null && count !== parameters.count)
throw new Il2cppMetadataFormatFailure(
"record-size-mismatch",
"generic parameter count disagrees with its extent",
);
return (index: number): string | undefined => {
if (!Number.isInteger(index) || index < 0 || index >= count)
return undefined;
return tables.readString(
tables.bytes.readInt32LE(
parameters.offset + index * width + tables.widths.generic_container,
),
`generic parameter ${index} name`,
);
};
};
/** Decoded metadata plus the typed row facts that link it to a native binary. */
export interface Il2cppMetadataForLinking {
readonly metadata: Il2cppMetadataDecoded;
readonly link: Il2cppMetadataLinkFacts;
}
/** Decode image, type, method and member names and string literals without native binaries or decryption. */
export function* readIl2cppMetadataSteps(
bytes: Buffer,
options: Il2cppMetadataReadOptions = {},
): Generator<void, Il2cppMetadataDecoded> {
return (yield* readMetadataCoreSteps(bytes, options, false)).metadata;
}
/** Decode metadata and retain the typed row facts used for native linking. */
export function* readIl2cppMetadataForLinkingSteps(
bytes: Buffer,
options: Il2cppMetadataReadOptions = {},
): Generator<void, Il2cppMetadataForLinking> {
const parsed = yield* readMetadataCoreSteps(bytes, options, true);
if (parsed.link === undefined)
throw new Error(
"Native metadata reader did not retain requested linking facts",
);
return { metadata: parsed.metadata, link: parsed.link };
}
function* readMetadataCoreSteps(
bytes: Buffer,
options: Il2cppMetadataReadOptions,
linking: boolean,
): Generator<
void,
{ metadata: Il2cppMetadataDecoded; link: Il2cppMetadataLinkFacts | undefined }
> {
const { magic, version, headerBytes, sections } = readHeader(bytes);
checkSections(bytes, headerBytes, sections);
const byName = new Map(sections.map((entry) => [entry.name, entry]));
@@ -713,7 +784,7 @@ export function* readIl2cppMetadataSteps(
const widths =
layout.indexing === "counted" ? resolveWidths(section) : FIXED_WIDTHS;
const expected = il2cppMetadataRecordBytes(widths, version);
const tables: MetadataTables = {
let tables: MetadataTables = {
bytes,
section,
widths,
@@ -741,6 +812,17 @@ export function* readIl2cppMetadataSteps(
methods: tables.counts.methods,
...tables.members.counts,
});
if (linking)
tables = {
...tables,
linkFacts: {
methodTokens: new Uint32Array(tables.counts.methods),
returnTypes: new Int32Array(tables.counts.methods),
fieldTypes: new Int32Array(tables.members.counts.fields),
parameterTypes: new Int32Array(tables.members.counts.parameters),
genericParameterName: createGenericParameterReader(tables),
},
};
yield;
const images = yield* readImages(tables);
yield* checkImageRanges(images);
@@ -752,25 +834,36 @@ export function* readIl2cppMetadataSteps(
parameters: parameterRanges,
});
return {
magic,
metadata_version: version,
index_widths: widths,
sections,
images,
types,
methods,
...members,
metadata: {
magic,
metadata_version: version,
index_widths: widths,
sections,
images,
types,
methods,
...members,
},
link: tables.linkFacts,
};
}
/** Synchronous consumption of the same decoder for byte-level callers. */
export const readIl2cppMetadata = (
bytes: Buffer,
options: Il2cppMetadataReadOptions = {},
): Il2cppMetadataDecoded => {
const steps = readIl2cppMetadataSteps(bytes, options);
const runSteps = <T>(steps: Generator<void, T>): T => {
for (;;) {
const step = steps.next();
if (step.done) return step.value;
}
};
/** Synchronous consumption of the same decoder for byte-level callers. */
export const readIl2cppMetadata = (
bytes: Buffer,
options: Il2cppMetadataReadOptions = {},
): Il2cppMetadataDecoded => runSteps(readIl2cppMetadataSteps(bytes, options));
/** Synchronous decoding that also retains the typed row facts used for native linking. */
export const readIl2cppMetadataForLinking = (
bytes: Buffer,
options: Il2cppMetadataReadOptions = {},
): Il2cppMetadataForLinking =>
runSteps(readIl2cppMetadataForLinkingSteps(bytes, options));
+13
View File
@@ -51,3 +51,16 @@ export const checkRange = (
`${label} range [${start}, ${start + length}) is outside the ${total} available entries`,
);
};
/** Read a serialized index; the all-ones value of narrow widths means -1. */
export const readIndex = (bytes: Buffer, offset: number, width: 1 | 2 | 4) => {
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);
};
+17
View File
@@ -4,6 +4,7 @@ import type {
} from "../domain/il2cpp/il2cppMetadata.js";
import {
checkRange,
readIndex,
Il2cppMetadataFormatFailure,
} from "./Il2cppMetadataFormat.js";
import type { MetadataTables } from "./Il2cppMetadataFile.js";
@@ -132,6 +133,22 @@ function* readNamed<
),
owners[index]!,
);
if (
tables.linkFacts !== undefined &&
(name === "fields" || name === "parameters")
) {
const column =
name === "fields"
? tables.linkFacts.fieldTypes
: tables.linkFacts.parameterTypes;
column[index] = readIndex(
tables.bytes,
section.offset +
index * tables.members.recordBytes[name] +
(name === "fields" ? 4 : 8),
tables.widths.type,
);
}
tables.onRow?.(row);
rows.push(row);
}
+290 -49
View File
@@ -1,4 +1,7 @@
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
import { Il2cppMetadataFormatFailure } from "./Il2cppMetadataFormat.js";
import type { AnalysisPartialObservation } from "../domain/analysisErrorBase.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { setImmediate } from "node:timers/promises";
import {
readStableArtifact,
@@ -38,9 +41,14 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { err, ok, type Result } from "../domain/result.js";
import {
readIl2cppMetadataForLinkingSteps,
readIl2cppMetadataSteps,
type Il2cppMetadataRowCounts,
} from "./Il2cppMetadataFile.js";
import {
linkIl2cppBinarySteps,
type Il2cppBinaryLink,
} from "./Il2cppBinaryFile.js";
/** Bundled format reader identity; the metadata version is a separate observed fact. */
export const IL2CPP_METADATA_PROVIDER_IDENTITY = {
@@ -50,10 +58,45 @@ export const IL2CPP_METADATA_PROVIDER_IDENTITY = {
} as const;
const OPERATION = "inspect_il2cpp_metadata";
const INPUT_BYTES = 256 * 1024 * 1024;
// GameAssembly binaries of large games reach several hundred megabytes.
const BINARY_BYTES = 1024 * 1024 * 1024;
// A 12.9 MB Unity 6000.4 file with 112,232 methods serializes to 7.8 MB.
const OUTPUT_BYTES = 64 * 1024 * 1024;
const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
const failureForPrimary = (
cause: unknown,
path: string,
signal?: AbortSignal,
field: "path" | "binary_path" = "path",
maximum = INPUT_BYTES,
): AnalysisError => {
if (cause instanceof AnalysisError) return cause;
if (cause instanceof Il2cppBinaryFormatFailure && cause.kind === "capacity")
return new AnalysisResourceConstraintError(
OPERATION,
cause.reason === "native-type-text-capacity" ? "memory" : "capacity",
cause.message,
{},
{ cause },
);
if (
cause instanceof Il2cppBinaryFormatFailure ||
cause instanceof Il2cppMetadataFormatFailure
) {
const binary = cause instanceof Il2cppBinaryFormatFailure;
return cause.kind === "unsupported"
? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, {
cause,
})
: new AnalysisInputError(OPERATION, { cause }, [
{
path: [binary ? field : "path"],
reason: "invalid_format",
message: binary
? `Selected binary ${path} does not link to the metadata: ${cause.message}`
: `Selected artifact ${path} is corrupt: ${cause.message}`,
},
]);
}
if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity")
return new AnalysisArtifactChangedError(OPERATION, path, cause.message, {
cause,
@@ -62,30 +105,18 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
return new AnalysisCancelledError(OPERATION, {
cause: artifactFileCancellationCause(cause),
});
if (cause instanceof Il2cppMetadataFormatFailure)
return cause.kind === "unsupported"
? new AnalysisUnsupportedTargetError(OPERATION, path, cause.message, {
cause,
})
: new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
reason: "invalid_format",
message: `Selected artifact ${path} is corrupt: ${cause.message}`,
},
]);
if (cause instanceof ArtifactReaderFailure) {
if (cause.reason === "limit")
return new AnalysisResourceConstraintError(
OPERATION,
"file-size",
cause.message,
{ maximum_input_bytes: INPUT_BYTES },
{ maximum_input_bytes: maximum },
{ cause },
);
if (cause.reason === "path")
return new AnalysisInputError(OPERATION, { cause }, [
{ path: ["path"], reason: "invalid_format", message: cause.message },
{ path: [field], reason: "invalid_format", message: cause.message },
]);
}
if (cause instanceof Error && "code" in cause) {
@@ -100,7 +131,7 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
)
return new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
path: [field],
reason: "invalid_value",
message: `Selected artifact could not be read (${String(cause.code)}): ${path}.`,
},
@@ -119,12 +150,26 @@ const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
);
};
const failureFor = (cause: unknown, path: string): AnalysisError => {
const failureFor = (
cause: unknown,
path: string,
signal?: AbortSignal,
field: "path" | "binary_path" = "path",
maximum = INPUT_BYTES,
): AnalysisError => {
const primary =
cause instanceof ArtifactFileCleanupFailure &&
cause.fileCleanupFailure.outcome.kind === "failed"
? failureForPrimary(cause.fileCleanupFailure.outcome.cause, path)
: failureForPrimary(cause, path);
? failureForPrimary(
cause.fileCleanupFailure.outcome.cause,
path,
signal,
field,
maximum,
)
: cause instanceof ArtifactFileCleanupFailure && signal?.aborted
? new AnalysisCancelledError(OPERATION, { cause: signal.reason })
: failureForPrimary(cause, path, signal, field, maximum);
if (
cause instanceof ArtifactReaderFailure &&
cause.partialObservation !== undefined &&
@@ -144,6 +189,37 @@ const failureFor = (cause: unknown, path: string): AnalysisError => {
return error;
};
const hex = (value: bigint) => `0x${value.toString(16)}`;
/** Report fields for a linked binary, with addresses as exact hexadecimal strings. */
const binaryReport = (
link: Il2cppBinaryLink,
artifact: { path: string; sha256: string; bytes: number },
) => ({
binary: {
artifact,
format: link.format,
architecture: link.architecture,
image_base: hex(link.image_base),
code_gen_modules_field:
link.code_gen_modules_field === null
? null
: hex(link.code_gen_modules_field),
code_gen_modules_array: hex(link.code_gen_modules_array),
code_gen_modules: link.code_gen_modules.map((module) => ({
image: module.image,
address: hex(module.address),
method_pointer_count: module.method_pointer_count,
})),
types_field: hex(link.types_field),
types_count: link.types_count,
},
return_types: link.return_types,
field_types: link.field_types,
parameter_types: link.parameter_types,
method_addresses: link.method_addresses,
});
const reportLimit = (path: string, maximumBytes: number) =>
new AnalysisResourceConstraintError(
OPERATION,
@@ -162,6 +238,14 @@ const sourceArtifact = (path: string, snapshot: StableArtifactSnapshot) => ({
const metadataSourceEvidence = (
artifact: Il2cppMetadata["artifact"],
counts: Il2cppMetadataRowCounts | undefined,
binary:
| {
path: string;
architecture?: string;
artifact?: Il2cppMetadata["artifact"];
}
| undefined = undefined,
previous?: AnalysisPartialObservation,
) =>
createEvidence(
{
@@ -173,10 +257,41 @@ const metadataSourceEvidence = (
{
operation: "observe_il2cpp_metadata_source",
predicateType: "rea.il2cpp-metadata-source",
parameters: { path: artifact.path },
parameters: {
path: artifact.path,
...(binary === undefined
? {}
: {
binary_path: binary.path,
...(binary.architecture === undefined
? {}
: { binary_architecture: binary.architecture }),
}),
},
result: {
artifact,
inventory_status: "not-returned-as-complete",
...(binary === undefined
? {}
: {
selected_binary: {
path: binary.path,
identity_status:
binary.artifact === undefined ? "unavailable" : "observed",
...(binary.artifact === undefined
? {}
: {
sha256: binary.artifact.sha256,
bytes: binary.artifact.bytes,
}),
...(binary.architecture === undefined
? {}
: { architecture: binary.architecture }),
},
}),
...(previous === undefined
? {}
: { upstream_partial_observation: jsonValueSchema.parse(previous) }),
...(counts === undefined
? {}
: { validated_section_row_counts: { ...counts } }),
@@ -250,37 +365,46 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
const observed: {
artifact?: Il2cppMetadata["artifact"];
counts?: Il2cppMetadataRowCounts;
binary?: Il2cppMetadata["artifact"];
} = {};
const selectedBinarySource = () =>
input.binary_path === undefined
? undefined
: {
path: input.binary_path,
...(input.binary_architecture === undefined
? {}
: { architecture: input.binary_architecture }),
...(observed.binary === undefined
? {}
: { artifact: observed.binary }),
};
try {
return await this.#resources.run(async () => {
const snapshot = await readStableArtifact(
const snapshot = await this.#readArtifact(
input.path,
INPUT_BYTES,
options?.signal,
undefined,
this.#resources,
).catch((cause: unknown) => {
// Only this awaited stable read owns these typed physical outcomes.
const outcome =
cause instanceof ArtifactFileCleanupFailure
? (cause as ArtifactFileCleanupFailure<StableArtifactSnapshot>)
.fileCleanupFailure.outcome
: cause instanceof ArtifactFileCancelled
? (cause as ArtifactFileCancelled<StableArtifactSnapshot>)
.fileCancellation.outcome
: undefined;
if (outcome?.kind === "completed")
observed.artifact = sourceArtifact(input.path, outcome.value);
throw cause;
});
(artifact) => {
observed.artifact = artifact;
},
);
const artifact = sourceArtifact(input.path, snapshot);
observed.artifact = artifact;
assertArtifactReadActive(options?.signal);
const limit = this.outputBytes;
const admission = createIl2cppMetadataAdmission(limit, () => {
throw reportLimit(input.path, limit);
});
const steps = readIl2cppMetadataSteps(snapshot.bytes, {
const admission = createIl2cppMetadataAdmission(
limit,
() => {
throw reportLimit(input.path, limit);
},
input.binary_path !== undefined,
);
const readMetadata =
input.binary_path === undefined
? readIl2cppMetadataSteps
: readIl2cppMetadataForLinkingSteps;
const steps = readMetadata(snapshot.bytes, {
...admission,
beforeRows: (counts) => {
observed.counts = counts;
@@ -293,13 +417,100 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
assertArtifactReadActive(options?.signal);
step = steps.next();
}
const decoded = step.value;
const value = step.value;
const decoded = "metadata" in value ? value.metadata : value;
const link = "metadata" in value ? value.link : undefined;
const binaryPath = input.binary_path;
let linked: Pick<
Il2cppMetadata,
| "binary"
| "method_addresses"
| "return_types"
| "field_types"
| "parameter_types"
> = {
binary: null,
method_addresses: [],
return_types: [],
field_types: [],
parameter_types: [],
};
if (binaryPath !== undefined) {
if (link === undefined)
throw new AnalysisOutputError(
OPERATION,
"Native linking facts were not retained for the selected binary.",
);
try {
const binary = await this.#readArtifact(
binaryPath,
BINARY_BYTES,
options?.signal,
(artifact) => {
observed.binary = artifact;
},
);
assertArtifactReadActive(options?.signal);
const linking = linkIl2cppBinarySteps(
binary.bytes,
decoded,
link,
input.binary_architecture,
admission,
);
let linkStep = linking.next();
while (!linkStep.done) {
await this.yieldControl();
assertArtifactReadActive(options?.signal);
linkStep = linking.next();
}
linked = binaryReport(linkStep.value, {
path: binaryPath,
sha256: binary.sha256,
bytes: binary.bytes.length,
});
} catch (cause: unknown) {
const failure = failureFor(
cause,
binaryPath,
options?.signal,
"binary_path",
BINARY_BYTES,
);
failure.partialObservation = metadataSourceEvidence(
artifact,
observed.counts,
selectedBinarySource(),
failure.partialObservation,
);
return err(failure);
}
}
const report = {
...decoded,
...linked,
artifact,
limitations: [
"Only unencrypted global-metadata.dat versions 24, 27, 29, 31 and 39 are decoded; names, counts and string literals are observed file content, not runtime behavior.",
"Native code addresses, IL2CPP binary registration, member types, signatures and generic instances are not recovered.",
binaryPath === undefined
? "Native code addresses and IL2CPP binary registration are not recovered without binary_path."
: "Method addresses come from the binary's code-generation module methodPointers, matched by module name, method count and metadata token; generic method instances, adjustor thunks and invokers are not linked, no-body means an observed zero slot, and unknown means no module claimed the method or its slot has an unresolved relocation.",
binaryPath === undefined
? "Field, parameter and return types are not recovered without binary_path; type indices refer to the native binary."
: decoded.metadata_version === 27
? "Field, parameter and return type names are null for metadata version 27: versions 27.0 to 27.2 share that header number but place the Il2CppType byref flag differently, so a name could gain or lose & without warning; type indices are still reported."
: "Field, parameter and return type names are rendered from the binary's Il2CppType table in a C#-like form (keywords for primitives, Namespace.Outer+Inner, generic parameters by metadata name); custom modifiers, array bounds and attributes are not reported.",
...(linked.binary !== null &&
linked.binary.code_gen_modules_field === null
? [
"The static CodeRegistration pair was not resolved. The module array is inferred from executable bytes consistent with RIP-relative LEA/MOV referencing one array candidate; instruction boundaries and runtime use were not verified.",
]
: []),
...(binaryPath === undefined
? []
: [
"The binary/metadata pair is caller-selected. Module name/count/token agreement is structural evidence, not a cryptographic proof that both files come from the same build.",
]),
"declaring_type for types is derived from the nestedTypes section, and member and parameter owners from the owners' index ranges; row positions are the metadata indices.",
],
};
@@ -317,16 +528,46 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
});
} catch (cause: unknown) {
const failure = failureFor(cause, input.path);
if (
observed.artifact !== undefined &&
failure.partialObservation === undefined
)
failure.retainPartialObservation(
metadataSourceEvidence(observed.artifact, observed.counts),
if (observed.artifact !== undefined)
failure.partialObservation = metadataSourceEvidence(
observed.artifact,
observed.counts,
selectedBinarySource(),
failure.partialObservation,
);
return err(failure);
} finally {
this.#inspecting = false;
}
}
async #readArtifact(
path: string,
maximum: number,
signal: AbortSignal | undefined,
observe: (artifact: Il2cppMetadata["artifact"]) => void,
): Promise<StableArtifactSnapshot> {
try {
const snapshot = await readStableArtifact(
path,
maximum,
signal,
undefined,
this.#resources,
);
observe(sourceArtifact(path, snapshot));
return snapshot;
} catch (cause) {
const outcome =
cause instanceof ArtifactFileCleanupFailure
? (cause as ArtifactFileCleanupFailure<StableArtifactSnapshot>)
.fileCleanupFailure.outcome
: cause instanceof ArtifactFileCancelled
? (cause as ArtifactFileCancelled<StableArtifactSnapshot>)
.fileCancellation.outcome
: undefined;
if (outcome?.kind === "completed")
observe(sourceArtifact(path, outcome.value));
throw cause;
}
}
}