feat(il2cpp): link generic method instances behind --generic-instances (#5785)

* feat(il2cpp): read generic containers on demand and render method spec names

Generic container indices and records are demand readers on the same metadata snapshot, so links that do not ask for generic instances allocate nothing. Type rendering keeps one graph walk with its workspace, depth and cycle guards; the C#-like names and the Il2CppDumper GetMethodSpecName names are two styles over it.

* feat(il2cpp): link generic method instances with admitted rows and kept issues

generic_instances links every genericMethodTable row through methodSpecs, genericInsts and genericMethodPointers, choosing the one CodeRegistration layout whose counts bound every row. The table is reserved against the report budget before any row is resolved; rows and issues are admitted before retention, and a contradicting row stays unknown with an address-backed issue instead of failing the link. Views page the rows by definition, state, address and name, unrequested and historical collections stay unknown, and Ghidra seeds fold instances after the definitions.

* feat(il2cpp): accept --generic-instances on the CLI and describe it in tool contracts

* docs(il2cpp): describe generic method instances and their limits

* fix(il2cpp): keep issued generic rows unknown and name their row

A method spec that contradicted the metadata still reported its pointer slot as code, while the schema and provider limitation promised unknown. Such rows now keep no address, every issue carries its genericMethodTable row, the domain validator requires unknown exactly on issued rows, and shared specs name one other row instead of all of them. The row-size lower bound moves to the metadata admission owner, the hex helper is shared, and budget wording says checked rather than reserved.

* test(il2cpp): reject ambiguous generic layouts and pin budget, yield and limit paths

* fix(il2cpp): keep unmapped generic slots per row and reject undeclared arguments

Layout selection no longer requires every genericMethodPointers slot to be
mapped; each row reads its own slot, so one unmapped slot leaves only that
row unknown with an issue. Method specs whose class or method argument count
differs from the owner's declared generic container are issued and unnamed.
Fixtures now declare the containers their generic rows fill.

* fix(il2cpp): page generic method issues instead of retaining them in summaries

Summaries keep generic_methods without its issues and count them in
counts.generic_method_issues; the generic_method_issues collection pages
them by rows, indices and fields, as code_gen_modules already does. Docs
also name the method_indices filter the request schema accepts.

* fix(il2cpp): read split generic arguments per slot and method containers on demand

Generic argument arrays may span adjacent loader ranges: admit them with
hasPointerArrayCoverage and read each slot through readPointer, instead of
requiring the whole array in one mapping.

Method generic container indices are read on demand from the method record,
like type containers, so no link path allocates a per-method column. An owner
whose container index is out of range now leaves the method spec unnamed with
an issue instead of naming it unchecked.

* fix(il2cpp): report generic entries past the address space at their row

A damaged methodSpecs or genericMethodPointers pointer could make a spec or
slot address exceed 64 bits. The issue kept that address, which the address
schema rejects, so inspection failed instead of leaving the row unknown.
Check the entry range first and attach the issue to the genericMethodTable
row (or its slot field).

* chore(il2cpp): remove unrelated formatting changes

* fix: restore adb float pattern and mobile fixture from main

The merge commit's lint-staged run rewrote these two main files: oxlint --fix
dropped the escape in [+\-], which is invalid under the RegExp v flag.

* fix(il2cpp): preserve unreadable generic method identities

* docs(il2cpp): record generic-instance workflow verification

* docs(il2cpp): qualify final integration checks

---------

Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com>
This commit is contained in:
JANG BYUNGKUNandgensokyo-dev authored and GitHub committed 2026-10-24 16:19:05 +08:00
1 parent c350343c33
commit 3ce311757d
30 files changed
+3034 -222

No files matched your search

+313 -105
View File
@@ -2,7 +2,7 @@ import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
import {
readSlot,
readUInt32,
isMapped,
hasPointerArrayCoverage,
type NativeImage,
} from "./Il2cppBinaryImage.js";
import type {
@@ -30,18 +30,41 @@ const PRIMITIVES = new Map([
[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;
/** Render one snapshot's type graph cooperatively without allocating unchecked expansions. */
export const createTypeNamer = (
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,
metadata: Il2cppMetadataDecoded,
facts: Il2cppMetadataLinkFacts,
version: number,
beforeText?: (bytes: number) => void,
beforeText: ((bytes: number) => void) | undefined,
style: (tools: RenderTools) => TypeStyle,
) => {
const fail = (message: string): never => {
throw new Il2cppBinaryFormatFailure("invalid-type", message);
@@ -84,80 +107,12 @@ export const createTypeNamer = (
}
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], ".");
}
function* genericDefinitionName(
index: number,
args: readonly string[],
): Generator<void, string> {
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 (++work % 256 === 0) 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 (++work % 256 === 0) 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], ".");
}
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>();
const byrefBit = version >= 29 ? 29 : 30;
function* name(address: bigint, depth: number): Generator<void, string> {
function* name(address: bigint, depth: number): Text {
const cached = byAddress.get(address);
if (cached !== undefined) return cached;
if (active.has(address))
@@ -169,16 +124,16 @@ export const createTypeNamer = (
);
active.add(address);
try {
if (++work % 256 === 0) yield;
if (tick()) 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);
const primitive = rules.primitives.get(kind);
if (primitive !== undefined) result = primitive;
else if (kind === 0x11 || kind === 0x12)
result = yield* definitionName(index);
result = yield* rules.definition(index);
else if (kind === 0x0f || kind === 0x1d)
result = join([
yield* name(data, depth + 1),
@@ -195,13 +150,14 @@ export const createTypeNamer = (
result =
facts.genericParameterName(index) ??
fail(`generic parameter ${index} is out of range`);
else if (kind === 0x15) result = yield* generic(data, depth);
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 (((bits >>> byrefBit) & 1) === 1) result = join([result, "&"]);
if (rules.byrefBit !== null && ((bits >>> rules.byrefBit) & 1) === 1)
result = join([result, "&"]);
const bytes = Buffer.byteLength(result);
guard(bytes);
cachedBytes += bytes;
@@ -211,33 +167,21 @@ export const createTypeNamer = (
active.delete(address);
}
}
function* generic(data: bigint, depth: number): Generator<void, string> {
let definitionIndex: number;
if (version >= 27) {
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");
definitionIndex = Number(
BigInt.asIntN(32, definitionData & 0xffff_ffffn),
);
} else definitionIndex = u32(data, "generic class definition");
const instance = pointer(data + 8n, "generic class instance");
/** 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);
if (!isMapped(image, argv, count * 8))
// 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 (++work % 256 === 0) yield;
if (tick()) yield;
const argument = yield* name(
pointer(argv + BigInt(slot * 8), "generic argument"),
depth + 1,
@@ -246,7 +190,271 @@ export const createTypeNamer = (
guard(bytes);
args.push(argument);
}
return yield* genericDefinitionName(definitionIndex, args);
return args;
}
return name;
/** `<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),
]);
};
};