fix(il2cpp): preserve loader ownership and ABI field boundaries

Resolve pointer and scalar facts through their shared loader-aware owners before registration search or type rendering. Preserve unresolved names and attributes, read count fields at their declared width, and admit declared zero-fill registration tails without accepting unmapped bytes.

Cover relocation overlap and adjacency, loader-bound fields, ABI padding, nullable names, and CLI/MCP behavior; document format and verification limits.

Fixes #6881
Fixes #6873
Fixes #6871
This commit is contained in:
morluto
2026-10-24 04:28:05 +08:00
parent f2db494bfc
commit 52ca222bd0
14 changed files with 420 additions and 59 deletions
+22 -4
View File
@@ -204,9 +204,13 @@ assembly image. Each module record starts with its module name, its method
pointer count and its method pointer array. The reader finds, for every metadata
image, the single record whose name pointer reaches that image's name string and
whose count equals the image's method count. It then requires exactly one
`codeGenModulesCount`/`codeGenModules` pair that lists exactly those records. No
symbols are needed, so stripped builds work. A method's token row id selects its
slot in the module's method pointer array. Within one image, each method must
`codeGenModulesCount`/`codeGenModules` pair that lists exactly those records.
`codeGenModulesCount` and module method counts own four bytes, followed by
alignment padding before their eight-byte pointers. Unresolved loader writes
overlapping those four bytes prevent the registration from being established;
padding outside the field is neither required nor interpreted as part of its
count. No symbols are needed, so stripped builds work. A method's token row id
selects its slot in the module's method pointer array. Within one image, each method must
claim a distinct MethodDef token row; duplicate rows are refused before any
linked method table or seed output is published. Row ids may repeat across
different images, and distinct rows may still resolve to the same address when
@@ -265,12 +269,26 @@ The result adds:
metadata name (`TKey`). Nested generic arguments stay with the component
that declares them, for example `Game.Outer<int>+Inner<string>` or
`Game.Outer<int>+Leaf` when the nested type adds no parameters. A trailing
`&` marks a by-reference type. `name` is `null` only for type index `-1`.
`&` marks a by-reference type. `name` is `null` for type index `-1`, metadata
version 27 as described below, or an unresolved type-table slot. A dyld bind
or unresolved ELF relocation retains the type index without interpreting its
stored word as a resolved pointer. If an unresolved loader value occurs
inside a type graph, linking is refused with `unsupported-pointer-encoding`;
a concrete name is not synthesized from that placeholder.
`binary` also records `types_field` and `types_count`: the address of
`Il2CppMetadataRegistration.typesCount` and its value. That record is found as
the single place whose `fieldOffsetsCount` and `typeDefinitionsSizesCount` both
equal the metadata type-definition count and whose type table is file mapped.
The complete 128-byte registration must have unambiguous loader coverage. Its
used fields through byte 112 must be file-backed; a trailing declared zero-fill
range can cover the final unused fields. An unmapped or conflicting tail is
refused. These layouts follow the versioned [Unity 2022.3.8f1 producer header](https://github.com/MlgmXyysd/libil2cpp/blob/master/libil2cpp/Unity_2022.3/2022.3.8f1/il2cpp-class-internals.h#L596-L637)
(redistributed source) and the [AArch64 aggregate alignment rules](https://github.com/ARM-software/abi-aa/blob/2025Q1/aapcs64/aapcs64.rst#aggregates).
Dyld pointer ownership comes from the parsed bind stream, as in Apple's
[dyld analyzer](https://github.com/apple-oss-distributions/dyld/blob/dyld-1165.3/common/MachOAnalyzer.cpp#L1508-L1511), rather than the slot's placeholder word.
The padding, zero-fill-tail and unresolved-type cases have authored regression
fixtures; they do not add real Unity-build verification to the checks above.
Without `binary_path`, `binary` is `null` and the four binary collections are
empty in the complete producer report to indicate unrequested observations. Their summary counts, including state counts, are `null`, and native page requests report unavailable observations. This does not establish an empty native inventory. Missing native fields in historical Evidence remain unknown too.
+1 -1
View File
@@ -207,7 +207,7 @@ export const il2cppMethodAddressSchema = z.strictObject({
/** Index into the binary Il2CppType table and its C#-like display name. */
export const il2cppTypeReferenceSchema = z.strictObject({
type: optionalIndex,
/** Null for type index -1, and for every row of a version 27 report. */
/** Null for type index -1, unresolved native type-table slots, and version 27. */
name: z.string().nullable(),
});
+8 -8
View File
@@ -1,11 +1,6 @@
import type { Il2cppMethodAddress } from "../domain/il2cpp/il2cppMetadata.js";
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
import {
openImageSteps,
readPointer,
readSlot,
isExecutable,
} from "./Il2cppBinaryImage.js";
import { openImageSteps, readSlot, isExecutable } from "./Il2cppBinaryImage.js";
import {
findModules,
findCodeRegistration,
@@ -180,8 +175,13 @@ export function* linkIl2cppBinarySteps(
);
// 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;
const address = readSlot(image, registration.types + BigInt(type * 8));
if (address === "unknown") return { type, name: null };
if (address === null)
throw new Il2cppBinaryFormatFailure(
"invalid-type",
`type ${type} has no mapped type-table slot`,
);
if (address === 0n)
throw new Il2cppBinaryFormatFailure(
"invalid-type",
+21 -13
View File
@@ -76,7 +76,7 @@ export interface NativeImage {
readonly imageBase: bigint;
readonly unresolvedPointerSlots: ReadonlyMap<bigint, number>;
readonly zeroFillPointers: ReadonlyMap<bigint, bigint>;
/** Mach-O loader fixups, used when interpreting method pointer slots. */
/** Mach-O loader fixups shared by every native pointer consumer. */
readonly machoPointerFixups?: PointerFixups;
}
@@ -191,13 +191,8 @@ export function* openImageSteps(
};
}
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;
/** Stored bytes only; loader interpretation belongs to readSlot. */
const readStoredWord = (image: NativeImage, address: bigint): bigint | null => {
try {
const offset = mappedFileOffset(
image.container.mappings,
@@ -216,8 +211,12 @@ export const readPointer = (
export const readUInt32 = (
image: NativeImage,
address: bigint,
): number | null => {
if (hasUnresolvedPointer(image, address, 4)) return null;
): number | "unknown" | null => {
if (
hasUnresolvedElfBytes(image, address, 4) ||
image.machoPointerFixups?.hasUnresolvedLegacyBytes(address, 4) === true
)
return "unknown";
const virtual = image.zeroFillPointers.get(address);
if (virtual !== undefined) return Number(virtual & 0xffffffffn);
try {
@@ -329,7 +328,7 @@ export const isExecutable = (image: NativeImage, address: bigint): boolean => {
}
};
const hasUnresolvedPointer = (
const hasUnresolvedElfBytes = (
image: NativeImage,
address: bigint,
size = 8,
@@ -346,13 +345,13 @@ export const readSlot = (
image: NativeImage,
address: bigint,
): bigint | "unknown" | null => {
if (hasUnresolvedPointer(image, address)) return "unknown";
if (hasUnresolvedElfBytes(image, address)) return "unknown";
try {
const virtual = image.zeroFillPointers.get(address);
let raw: bigint | null;
if (virtual !== undefined) raw = virtual;
else if (isZeroFilled(image, address, 8)) raw = 0n;
else raw = readPointer(image, address);
else raw = readStoredWord(image, address);
if (raw === null) return null;
if (image.machoPointerFixups !== undefined) {
const resolution = image.machoPointerFixups.decode(address, raw);
@@ -372,3 +371,12 @@ export const readSlot = (
throw cause;
}
};
/** Require an observed resolved pointer; unknown and unmapped slots stay null. */
export const readPointer = (
image: NativeImage,
address: bigint,
): bigint | null => {
const value = readSlot(image, address);
return typeof value === "bigint" ? value : null;
};
+35 -15
View File
@@ -7,6 +7,7 @@ import {
isMapped,
hasPointerArrayCoverage,
readPointer,
readUInt32,
type NativeImage,
} from "./Il2cppBinaryImage.js";
import type { Il2cppMetadataDecoded } from "./Il2cppMetadataFile.js";
@@ -136,6 +137,8 @@ export function* findModules(
const indices = names.get(target);
if (indices === undefined) continue;
const address = mapping.address + BigInt(at);
// Matching stored bytes nominate a candidate, not a resolved pointer.
if (readPointer(image, address) !== target) continue;
let recordOffset: number;
try {
recordOffset = mappedFileOffset(
@@ -160,9 +163,10 @@ export function* findModules(
let work = 0;
for (const index of indices) {
if (++work % 256 === 0) yield;
const count = image.bytes.readUInt32LE(offset + 8);
const count = readUInt32(image, address + 8n);
if (count !== counts[index]) continue;
const pointers = image.bytes.readBigUInt64LE(offset + 16);
const pointers = readPointer(image, address + 16n);
if (pointers === null) continue;
if (!hasPointerArrayCoverage(image, pointers, count)) continue;
const list = candidates.get(index) ?? [];
if (list.some((candidate) => candidate.address === address)) continue;
@@ -207,7 +211,6 @@ export function* findCodeRegistration(
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 }[] = [];
const matchedFields = new Set<bigint>();
for (const mapping of image.container.mappings) {
@@ -215,9 +218,13 @@ export function* findCodeRegistration(
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;
// The count owns four bytes; the following four only align its pointer.
if (image.bytes.readUInt32LE(offset) !== modules.length) continue;
const field = mapping.address + BigInt(at);
if (readUInt32(image, field) !== modules.length) continue;
const array = readPointer(image, field + 8n);
if (array === null || !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;
@@ -226,7 +233,6 @@ export function* findCodeRegistration(
seen.add(pointer);
}
if (seen.size === modules.length) {
const field = mapping.address + BigInt(at);
// Exact load aliases can visit one virtual registration field twice.
if (matchedFields.has(field)) continue;
matchedFields.add(field);
@@ -272,8 +278,11 @@ function* findModuleArrayByCode(
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)) {
const raw = image.bytes.readBigUInt64LE(mapping.offset + at);
const value = expected.has(raw)
? readPointer(image, mapping.address + BigInt(at))
: null;
if (value === null || !expected.has(value)) {
runStart = at + 8;
lastPosition.clear();
continue;
@@ -333,21 +342,32 @@ export function* findMetadataRegistration(
const matches: { field: bigint; count: number; types: bigint }[] = [];
const matchedFields = new Set<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) {
// The fields used below end at byte 112. The full 128-byte registration
// must still have loader coverage, including any declared zero-fill tail.
for (let at = start; at + 112 <= 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);
const address = mapping.address + BigInt(at);
if (!hasPointerArrayCoverage(image, address, 16)) continue;
if (readUInt32(image, address + 80n) !== typeDefinitions) continue;
if (readUInt32(image, address + 96n) !== typeDefinitions) continue;
const count = readUInt32(image, address + 48n);
const types = readPointer(image, address + 56n);
const fieldOffsets = readPointer(image, address + 88n);
const typeSizes = readPointer(image, address + 104n);
if (
typeof count !== "number" ||
count === 0 ||
count > 0x7fffffff ||
!isMapped(image, word(base + 88), 8) ||
!isMapped(image, word(base + 104), 8) ||
types === null ||
fieldOffsets === null ||
typeSizes === null ||
!isMapped(image, fieldOffsets, 8) ||
!isMapped(image, typeSizes, 8) ||
!isMapped(image, types, count * 8)
)
continue;
+21 -7
View File
@@ -1,6 +1,6 @@
import { Il2cppBinaryFormatFailure } from "./Il2cppBinaryFormat.js";
import {
readPointer,
readSlot,
readUInt32,
isMapped,
type NativeImage,
@@ -46,12 +46,26 @@ export const createTypeNamer = (
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`);
const unresolved = (address: bigint, label: string): never => {
throw new Il2cppBinaryFormatFailure(
"unsupported-pointer-encoding",
`${label} at 0x${address.toString(16)} is loader-unresolved; its stored bytes are not a resolved type value`,
);
};
const pointer = (address: bigint, label: string): bigint => {
const value = readSlot(image, address);
if (value === "unknown") return unresolved(address, label);
return (
value ?? fail(`${label} at 0x${address.toString(16)} is not file mapped`)
);
};
const u32 = (address: bigint, label: string): number => {
const value = readUInt32(image, address);
if (value === "unknown") return unresolved(address, label);
return (
value ?? fail(`${label} at 0x${address.toString(16)} is not file mapped`)
);
};
let cachedBytes = 0;
let work = 0;
const guard = (bytes: number) => {
+1 -1
View File
@@ -531,7 +531,7 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
? "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); nested generic arguments stay with the type component that declares them. Custom modifiers, array bounds and attributes are not 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); nested generic arguments stay with the type component that declares them. An unresolved type-table slot retains its type index with a null name; a loader-unresolved value inside the type graph is refused rather than rendered from stored placeholder bytes. Custom modifiers, array bounds and attributes are not reported.",
...(linked.binary !== null &&
linked.binary.code_gen_modules_field === null
? [
+11
View File
@@ -187,6 +187,7 @@ it("marks only opcode-selected pointer slots rebased, including target zero", ()
kind: "rebase",
target: BASE + 0x200n,
});
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x100n, 4)).toBe(false);
expect(fixups.decode(BASE + 0x118n, 0n)).toEqual({
kind: "rebase",
target: 0n,
@@ -252,6 +253,7 @@ it("retains known prefix rebases and leaves unmatched locations unknown on an in
expect(fixups.issues).toMatchObject([
{ code: "rebase_stream_decode_failed" },
]);
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x108n, 4)).toBe(true);
});
it("does not treat 32-bit text rebases as 64-bit metadata pointers", () => {
@@ -263,6 +265,10 @@ it("does not treat 32-bit text rebases as 64-bit metadata pointers", () => {
kind: "unsupported",
reason: "Legacy non-pointer rebase is not a 64-bit metadata pointer",
});
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x100n, 4)).toBe(true);
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x103n, 4)).toBe(true);
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x104n, 4)).toBe(false);
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x108n, 4)).toBe(false);
expect(fixups.decode(BASE + 0x108n, BASE + 0x300n)).toMatchObject({
kind: "raw",
target: BASE + 0x300n,
@@ -438,6 +444,7 @@ describe("incomplete legacy bind applicability", () => {
expect(fixups.issues).toMatchObject([
{ code: "bind_stream_decode_failed" },
]);
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x1008n, 4)).toBe(true);
});
it("retains a positive bind prefix while leaving unmatched slots unknown", () => {
@@ -1285,6 +1292,10 @@ describe("legacy pointer fixup streams", () => {
kind: "raw",
target: 0x99n,
});
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x4014n, 4)).toBe(true);
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x4018n, 4)).toBe(false);
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x401cn, 4)).toBe(false);
expect(fixups.hasUnresolvedLegacyBytes(BASE + 0x401cn, 8)).toBe(true);
});
it("wraps a negative DO_BIND_ADD_ADDR_ULEB step and keeps decoding", () => {
+60 -6
View File
@@ -65,6 +65,10 @@ export interface PointerFixups {
readonly legacyBindStreams: number;
/** Bytes in rebase streams that could not be completely decoded. */
readonly unreadRebaseBytes: number;
/** Unresolved legacy writes intersecting a scalar span, including incomplete
* legacy applicability. Chained pointer values still require decode().
*/
hasUnresolvedLegacyBytes(address: bigint, size: number): boolean;
/** Decode the pointer stored at `address` whose raw 64-bit value is `raw`. */
decode(address: bigint, raw: bigint): PointerResolution;
}
@@ -508,6 +512,8 @@ const chainedFixups = (
legacyRebaseStreams: rebases.examined,
legacyBindStreams: binds.examined,
unreadRebaseBytes: rebases.unreadBytes,
hasUnresolvedLegacyBytes: (address, size) =>
hasUnresolvedLegacyBytes(segments, rebases, binds, address, size),
decode(address, raw) {
const rawPointer = (): PointerResolution =>
applicabilityComplete
@@ -805,13 +811,13 @@ const readRebaseStream = (
}
};
const resolveLegacyRebase = (
/** Read loader-owned membership without interpreting a stored pointer word. */
const legacyRebaseState = (
segments: readonly Segment[],
membership: Uint8Array | null,
address: bigint,
raw: bigint,
): PointerResolution | undefined => {
if (membership === null) return undefined;
): number | Extract<PointerResolution, { readonly kind: "unsupported" }> => {
if (membership === null) return 0;
let fileOffset: number | undefined;
for (const segment of segments) {
const relative = address - segment.address;
@@ -830,10 +836,20 @@ const resolveLegacyRebase = (
};
fileOffset = Number(mapped);
}
if (fileOffset === undefined) return undefined;
if (fileOffset === undefined) return 0;
const at = Math.floor(fileOffset / 4);
const shift = (fileOffset % 4) * 2;
const state = ((membership[at] ?? 0) >> shift) & 3;
return ((membership[at] ?? 0) >> shift) & 3;
};
const resolveLegacyRebase = (
segments: readonly Segment[],
membership: Uint8Array | null,
address: bigint,
raw: bigint,
): PointerResolution | undefined => {
const state = legacyRebaseState(segments, membership, address);
if (typeof state !== "number") return state;
if (state === 0) return undefined;
if (state === REBASE_POINTER) return { kind: "rebase", target: raw };
return {
@@ -1153,6 +1169,41 @@ const parseLegacyBinds = (
};
};
/** Query retained legacy facts; never expand bind runs or read scalar padding. */
const hasUnresolvedLegacyBytes = (
segments: readonly Segment[],
rebases: RebaseFacts,
binds: BindFacts,
address: bigint,
size: number,
): boolean => {
if (size <= 0) return false;
if (!rebases.complete || !binds.complete) return true;
const first = address - 7n;
const end = address + BigInt(size);
for (const run of binds.runs) {
// An eight-byte bind overlaps [address,end) exactly when its start is
// in [address-7,end). Find the first such member without expanding it.
const index =
first <= run.start
? 0n
: (first - run.start + run.stride - 1n) / run.stride;
if (index < run.count && run.start + index * run.stride < end) return true;
}
if (rebases.membership !== null)
for (let slot = first; slot < end; slot++) {
const state = legacyRebaseState(segments, rebases.membership, slot);
// Text rebases own four bytes; conflicting kinds may include a pointer.
if (
typeof state !== "number" ||
state === REBASE_CONFLICT ||
(state === REBASE_NON_POINTER && slot + 4n > address)
)
return true;
}
return false;
};
/** Build pointer decoding for one slice from its fixup load commands. */
export const parsePointerFixups = (
bytes: Buffer,
@@ -1187,6 +1238,7 @@ export const parsePointerFixups = (
legacyRebaseStreams: 0,
legacyBindStreams: 0,
unreadRebaseBytes: 0,
hasUnresolvedLegacyBytes: () => false,
decode: (_address, raw) => rawPointer(raw),
};
const failures = [...rebases.failures, ...binds.failures];
@@ -1201,6 +1253,8 @@ export const parsePointerFixups = (
legacyRebaseStreams: rebases.examined,
legacyBindStreams: binds.examined,
unreadRebaseBytes: rebases.unreadBytes,
hasUnresolvedLegacyBytes: (address, size) =>
hasUnresolvedLegacyBytes(segments, rebases, binds, address, size),
decode(address, raw) {
const match = resolveLegacyBind(binds.runs, address);
const rebase = resolveLegacyRebase(
@@ -19,6 +19,7 @@ const createInput = (
legacyRebaseStreams: 0,
legacyBindStreams: 0,
unreadRebaseBytes: 0,
hasUnresolvedLegacyBytes: () => false,
decode: (address) =>
address === 0x1000n
? {
+2
View File
@@ -176,6 +176,7 @@ it("counts examined slots and reports an unresolved async slot", () => {
legacyRebaseStreams: 0,
legacyBindStreams: 0,
unreadRebaseBytes: 0,
hasUnresolvedLegacyBytes: () => false,
},
declarations: {
facet: "swift_declarations",
@@ -314,6 +315,7 @@ describe("Swift vtable coverage facts: unsupported pointer fixup coverage", () =
legacyRebaseStreams: 0,
legacyBindStreams: 0,
unreadRebaseBytes: 0,
hasUnresolvedLegacyBytes: () => false,
},
declarations: {
facet: "swift_declarations",
+131
View File
@@ -34,6 +34,7 @@ import {
createElfFixture,
createIl2cppBinaryFixture,
createIl2cppDyldBindFixture,
createIl2cppRegistrationTailFixture,
createUniversalFixture,
FIXTURE_BINARY_BASE,
FIXTURE_METHOD_ADDRESSES,
@@ -899,6 +900,136 @@ cliTest(
30000,
);
cliTest(
"retains unknown dyld-bound type names and source identity through CLI and MCP",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-il2cpp-type-bind-");
const path = join(root, "global-metadata.dat");
const binaryPath = join(root, "GameAssembly.dylib");
await writeFile(path, createLinkedIl2cppMetadataFixture().bytes);
const inspect = async (bytes: Buffer) => {
await writeFile(binaryPath, bytes);
const output = await runFileCli(cli, {
arguments: [
"inspect-il2cpp-metadata",
path,
"--binary",
binaryPath,
"--json",
],
});
expect(output.exitCode, output.stderr).toBe(0);
const report = parseCliMetadata(output.json);
expect(report.binary?.artifact).toEqual({
path: binaryPath,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
});
expect(await readFile(binaryPath)).toEqual(bytes);
return report;
};
const control = await inspect(createIl2cppBinaryFixture());
expect(control.return_types[0]?.name).toBe(FIXTURE_RETURN_TYPE_NAME);
const { client, call } = await connectLocalToolsMcp();
for (const raw of [
0n,
FIXTURE_BINARY_BASE + 0x980n,
FIXTURE_BINARY_BASE + 0x9b0n,
]) {
const report = await inspect(createIl2cppDyldBindFixture(raw, 0x900));
for (const collection of [
"return_types",
"field_types",
"parameter_types",
] as const)
expect(report[collection]).toEqual(
control[collection].map((row) =>
row.type === 0 ? { ...row, name: null } : row,
),
);
expect(report.methods).toEqual(control.methods);
expect(report.method_addresses).toEqual(control.method_addresses);
expect(report.limitations).toContainEqual(
expect.stringContaining("unresolved type-table slot"),
);
// One transport round trip covers the nullable name contract; all stored
// placeholder variants above cross the real CLI workflow.
if (raw === 0n) {
const response = await call("inspect_il2cpp_metadata", {
path,
binary_path: binaryPath,
detail: "complete",
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(parseCliMetadata(response.structuredContent)).toEqual(report);
}
}
await client.ping();
},
30000,
);
cliTest(
"links ABI count padding and declared zero-fill registration tails through CLI and MCP",
async ({ cli }) => {
const root = await createTestTempDirectory(
"rea-il2cpp-registration-layout-",
);
const path = join(root, "global-metadata.dat");
await writeFile(path, createLinkedIl2cppMetadataFixture().bytes);
const { client, call } = await connectLocalToolsMcp();
for (const tailBytes of [0, 8, 16] as const) {
const bytes = createIl2cppRegistrationTailFixture(tailBytes);
bytes.fill(0xff, 0x604, 0x608);
const binaryPath = join(root, `registration-${tailBytes}.dylib`);
await writeFile(binaryPath, bytes);
const output = await runFileCli(cli, {
arguments: [
"inspect-il2cpp-metadata",
path,
"--binary",
binaryPath,
"--json",
],
});
expect(output.exitCode, output.stderr).toBe(0);
const report = parseCliMetadata(output.json);
expect(report.binary).toMatchObject({
code_gen_modules_field: "0x100000600",
types_field: "0x100000fb0",
types_count: 4,
artifact: {
path: binaryPath,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
},
});
expect(report.return_types.map((row) => row.name)).toEqual(
Array.from({ length: 5 }, () => FIXTURE_RETURN_TYPE_NAME),
);
expect(report.method_addresses.map((row) => row.state)).toEqual([
"code",
"code",
"code",
"code",
"no-body",
]);
if (tailBytes === 16) {
const response = await call("inspect_il2cpp_metadata", {
path,
binary_path: binaryPath,
detail: "complete",
});
expect(response.isError, JSON.stringify(response)).not.toBe(true);
expect(parseCliMetadata(response.structuredContent)).toEqual(report);
}
expect(await readFile(binaryPath)).toEqual(bytes);
}
await client.ping();
},
30000,
);
cliTest(
"keeps nested IL2CPP generic arguments on their declaring type components",
async ({ cli }) => {
@@ -1,4 +1,8 @@
import { Il2cppBinaryFormatFailure } from "../../../src/il2cpp/Il2cppBinaryFormat.js";
import {
openImageSteps,
readUInt32,
} from "../../../src/il2cpp/Il2cppBinaryImage.js";
import { expect, it } from "vitest";
import {
linkIl2cppBinary,
@@ -9,6 +13,7 @@ import {
createElfFixture,
createIl2cppBinaryFixture,
createIl2cppDyldBindFixture,
createIl2cppRegistrationTailFixture,
createUniversalFixture,
createSplitPointerPeFixture,
FIXTURE_BINARY_BASE,
@@ -593,6 +598,71 @@ it("keeps unbound Mach-O method words classified from their values", () => {
expect(linked.method_addresses[1]).toEqual(expectedAddresses[1]);
expect(linked.method_addresses[4]).toEqual(expectedAddress("no-body"));
});
it("refuses an unresolved inner type value instead of rendering its stored placeholder", () => {
expect(
failure(createIl2cppDyldBindFixture(FIXTURE_BINARY_BASE + 0x990n, 0x980)),
).toEqual({
reason: "unsupported-pointer-encoding",
kind: "unsupported",
});
});
it.each([
[0x600, 2n, "code-registration-not-found"],
[0x408, 3n, "code-gen-modules-not-found"],
[0x850, 4n, "metadata-registration-not-found"],
[0x860, 4n, "metadata-registration-not-found"],
[0x988, 0x001d0000n, "unsupported-pointer-encoding"],
] as const)("does not trust a dyld-owned scalar at %s", (slot, raw, reason) => {
expect(failure(createIl2cppDyldBindFixture(raw, slot)).reason).toBe(reason);
});
it.each([
[0x5f8, 0n, 2],
[0x5fc, 2n << 32n, "unknown"],
[0x600, 2n, "unknown"],
[0x604, 0n, 2],
] as const)(
"uses the four-byte count span for a dyld bind at %s",
(slot, raw, expected) => {
const steps = openImageSteps(
createIl2cppDyldBindFixture(raw, slot),
undefined,
);
let step = steps.next();
while (!step.done) step = steps.next();
expect(readUInt32(step.value, FIXTURE_BINARY_BASE + 0x600n)).toBe(expected);
},
);
it.each([
[0x400, "code-gen-modules-not-found"],
[0x410, "code-gen-modules-not-found"],
[0x500, "code-registration-not-found"],
[0x608, "code-registration-not-found"],
[0x838, "metadata-registration-not-found"],
] as const)(
"does not nominate a registration from dyld-owned word at %s",
(slot, reason) => {
const raw = createIl2cppBinaryFixture().readBigUInt64LE(slot);
expect(failure(createIl2cppDyldBindFixture(raw, slot)).reason).toBe(reason);
},
);
it.each([1, 0xff])(
"ignores ABI count padding byte %d without accepting a changed count",
(padding) => {
const bytes = createIl2cppBinaryFixture();
bytes.fill(padding, 0x604, 0x608);
expect(link(bytes)).toEqual(link(createIl2cppBinaryFixture()));
bytes.writeUInt32LE(3, 0x600);
expect(failure(bytes).reason).toBe("code-registration-not-found");
},
);
it.each([8, 16] as const)(
"rejects %d absent registration bytes without declared zero-fill",
(tailBytes) => {
expect(
failure(createIl2cppRegistrationTailFixture(tailBytes, false)).reason,
).toBe("metadata-registration-not-found");
},
);
it.each([
["PC32", 2],
+36 -4
View File
@@ -187,15 +187,25 @@ export const createIl2cppBinaryFixture = (
return bytes;
};
/** Add one classic dyld bind for a method slot, retaining its chosen raw word. */
export const createIl2cppDyldBindFixture = (raw: bigint): Buffer => {
/** Add one classic dyld bind at the selected slot, retaining its stored word. */
export const createIl2cppDyldBindFixture = (
raw: bigint,
slotOffset = POINTERS,
): Buffer => {
const bytes = Buffer.from(createIl2cppBinaryFixture());
const command = 104;
const streamOffset = 0xf00;
const offsetBytes: number[] = [];
let remaining = slotOffset;
do {
const byte = remaining & 0x7f;
remaining >>>= 7;
offsetBytes.push(byte | (remaining === 0 ? 0 : 0x80));
} while (remaining !== 0);
const stream = Buffer.concat([
Buffer.from([0x3e, 0x40]),
Buffer.from("_FixtureMethod\0", "utf8"),
Buffer.from([0x51, 0x70, 0x80, 0x06, 0x90, 0x00]),
Buffer.from([0x51, 0x70, ...offsetBytes, 0x90, 0x00]),
]);
bytes.writeUInt32LE(2, 16);
bytes.writeUInt32LE(120, 20);
@@ -204,7 +214,29 @@ export const createIl2cppDyldBindFixture = (raw: bigint): Buffer => {
bytes.writeUInt32LE(streamOffset, command + 16);
bytes.writeUInt32LE(stream.length, command + 20);
stream.copy(bytes, streamOffset);
bytes.writeBigUInt64LE(raw, 0x300);
// A classic pointer bind names writable loader storage.
bytes.writeUInt32LE(7, 88);
bytes.writeUInt32LE(7, 92);
bytes.writeBigUInt64LE(raw, slotOffset);
return bytes;
};
/** Complete registration whose final zero words may occupy declared zero-fill. */
export const createIl2cppRegistrationTailFixture = (
tailBytes: 0 | 8 | 16,
zeroFilled = true,
): Buffer => {
const source = createIl2cppBinaryFixture();
source.copy(
source,
SIZE - 128,
METADATA_REGISTRATION,
METADATA_REGISTRATION + 128,
);
source.fill(0, METADATA_REGISTRATION, METADATA_REGISTRATION + 128);
const bytes = source.subarray(0, SIZE - tailBytes);
bytes.writeBigUInt64LE(BigInt(bytes.length), 80);
if (!zeroFilled) bytes.writeBigUInt64LE(BigInt(bytes.length), 64);
return bytes;
};