fix(il2cpp): link arm64 builds whose registrations were inlined (#6989)

* fix(il2cpp): accept arm64 module arrays loaded through adrp/add pairs

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
JANG BYUNGKUN
2026-10-25 05:21:18 +09:00
committed by GitHub
parent 22b88300d0
commit 149b43ee61
17 changed files with 1006 additions and 68 deletions
+29 -4
View File
@@ -241,7 +241,7 @@ record addresses remain distinct candidates, and conflicting file mappings
remain malformed.
Module records establish names through their declared NUL-terminated string pointers. NUL/0xFF padding, spaces, Unicode and suffix-shared storage are supported. A pointer to a longer string does not match just its suffix; a pointer directly into a shared suffix may name it. Exact reversed UTF-8 matching avoids rescanning the file for every name and retains duplicate-name owners until module records resolve them. A contributor investigated a Linux
`GameAssembly.so` from Unity 2023.2.13f1 whose static registration was not located. When no static pair is resolved on x86-64, the reader searches executable ranges for bytes consistent with RIP-relative LEA/MOV references to module-array candidates. Exactly one referenced array candidate must remain. `code_gen_modules_field` is then `null`, and Evidence is **inferred**: instruction boundaries, the absence of a static registration elsewhere, and runtime use were not verified. The module records and slots themselves remain file observations. Static-table associations are **derived**. Otherwise the result is `code-registration-not-found`.
`GameAssembly.so` from Unity 2023.2.13f1 whose static registration was not located. When no static pair is resolved on x86-64 or arm64, the reader searches executable ranges for bytes consistent with a reference to a module-array candidate: RIP-relative LEA/MOV on x86-64, an adjacent `adrp`/`add` pair on arm64. Exactly one referenced array candidate must remain. `code_gen_modules_field` is then `null`, and Evidence is **inferred**: instruction boundaries, the absence of a static registration elsewhere, and runtime use were not verified. The module records and slots themselves remain file observations. Static-table associations are **derived**. Otherwise the result is `code-registration-not-found`. Cpp2IL's StrippedCodeRegSupport plugin also recovers the module list of an inlined CodeRegistration from the module names; this fallback additionally requires the exact located module set and a single code reference. A Unity 2022.3.62f3 macOS Master build checked by a contributor keeps neither registration symbol; its type table is then inferred as described below.
The binary/metadata pair is caller-selected. Name/count/token agreement is structural evidence, not a cryptographic same-build proof. File-offset locations refer to the primary metadata artifact; linked virtual addresses refer to `binary.artifact`. Both SHA-256 identities remain available in compact native pages as `artifact` and `binary_source`.
@@ -299,6 +299,30 @@ The result adds:
`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.
Linking reads only that table. When no record on x86-64 or arm64 has both of
those counts, the reader collects maximal runs of pointers to Il2CppType-shaped
records (an assigned Il2CppTypeEnum kind from 0x01 to 0x1e; class and
value-type data below the type-definition count) that are long enough for every
type index the metadata references. Exactly one such run may exist in the
image, it must start and end inside its mapping (a run touching either end may
continue in a neighbouring mapping), and its start must be loaded by the same
instruction forms as the module-array fallback. `types_field` is then `null`,
`types_count` is that run's length rather than an observed `typesCount`, and
Evidence is **inferred**. The run length is at least every referenced type
index but can differ from `typesCount`: a type-shaped pointer after the table
would lengthen it, and an unreadable slot (such as an unresolved relocation)
would end it early. Only the type indices the metadata references are read. A
second long-enough run anywhere, a run that a type-shaped neighbour extends
before the loaded address, a run touching a mapping end or a slot the loader
binds or leaves unresolved (its stored word is only a placeholder), and a covered
128-byte record whose two counts match but whose other fields are unusable all
leave `metadata-registration-not-found` rather than a guess; the search stops
at the second run. The length requirement only separates the type table from
other pointer runs when the metadata references most of it: on the one Master
build checked, the highest referenced index is 6,031 of 6,576 entries. A
metadata referencing few type indices usually
leaves several short runs and fails closed.
Counts use their declared 32-bit width independently of alignment padding.
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
@@ -354,7 +378,8 @@ the result has no rows, and `generic_method_pointers_field`,
`generic_method_pointers_count` and `generic_method_table_stride` are `null`.
A table of a few rows can still leave two layouts agreeing, which fails as
ambiguous rather than picking one. Metadata versions 24 and 39 and builds
without a static CodeRegistration (`code_gen_modules_field` `null`) fail as
without a static CodeRegistration (`code_gen_modules_field` `null`) or
MetadataRegistration (`types_field` `null`) fail as
`generic-methods-unsupported`: for version 24 the type-definition generic
container offset and the generic class layout have not been checked against a
real build, so it is refused rather than guessed.
@@ -419,10 +444,10 @@ The builds checked are listed after the module search rules above.
| `code-registration-not-found` | input error | No unique module table or executable-byte array candidate was resolved. |
| `invalid-method-token` | input error | A token is outside its module's method-pointer rows, or a MethodDef row is reused within one image. |
| `invalid-method-pointer` | input error | A slot is unavailable or its nonzero address lacks unambiguous executable file coverage. |
| `metadata-registration-not-found` | input error | No single MetadataRegistration matches the type-definition count. |
| `metadata-registration-not-found` | input error | No single MetadataRegistration or code-loaded type-table candidate was resolved. |
| `invalid-type` | input error | A type index or `Il2CppType` record is out of range, unmapped or malformed. |
| `unsupported-type-kind` | unsupported | A valid type kind has no implemented rendering. |
| `generic-methods-unsupported` | unsupported | Generic instances were requested for metadata version 24 or 39 or a build without a static CodeRegistration. |
| `generic-methods-unsupported` | unsupported | Generic instances were requested for metadata version 24 or 39, or a build lacks either static registration. |
| `generic-method-registration-not-found` | input error | No CodeRegistration layout bounds every genericMethodTable row, or the generic tables are unreadable. |
| `ambiguous-generic-method-registration` | input error | More than one CodeRegistration layout bounds every genericMethodTable row. |
@@ -261,13 +261,17 @@ it("closes both ports and keeps combined cleanup incomplete until both owners re
metadata.resolveCleanup();
expect(failure.cleanupIncomplete).toBe(false);
});
it.each([false, true])(
"classifies native association confidence (inferred: %s)",
async (inferred) => {
it.each([
["static", null, "derived"],
["module array", "code_gen_modules_field", "inferred"],
["type table", "types_field", "inferred"],
] as const)(
"classifies native association confidence (%s)",
async (_, inferred, confidence) => {
const value = linkedReport();
if (inferred) value.binary!.code_gen_modules_field = null;
if (inferred !== null) value.binary![inferred] = null;
const result = await serviceReturning(value).inspect(selectedPair);
if (!result.ok) throw result.error;
expect(result.value.confidence).toBe(inferred ? "inferred" : "derived");
expect(result.value.confidence).toBe(confidence);
},
);
@@ -240,7 +240,8 @@ export class Il2cppMetadataService {
confidence:
value.binary === null
? "observed"
: value.binary.code_gen_modules_field === null
: value.binary.code_gen_modules_field === null ||
value.binary.types_field === null
? "inferred"
: "derived",
limitations: value.limitations,
+1 -1
View File
@@ -16,7 +16,7 @@ export const IL2CPP_TOOL_CONTRACTS = [
kind: "artifact-provider",
requiresActiveTarget: true,
description:
"Inspect local Unity IL2CPP metadata (24/27/29/31/39), optionally linking a caller-selected 64-bit little-endian Mach-O, PE or ELF binary. Static reading only. Default summary retains complete Evidence; use parent_evidence_id with inspect_analysis_view kind il2cpp-metadata for named collections, indices, fields and paging. Parameters accept method_indices; method_addresses accept addresses and states. Stable row indices are scoped to the metadata digest; native pages also identify the binary. Native states distinguish executable code, observed zero slots (no-body), and unknown owners/relocations. Shared addresses preserve all methods. code_gen_modules pages accept image_indices; summaries omit that table. Type names are C#-like; version27 retains indices with unknown names. generic_instances adds genericMethodTable rows with Il2CppDumper-style names (versions 27/29/31, static CodeRegistration); generic_method_instances pages accept method_indices, addresses and states; summaries count issues, paged as generic_method_issues. Unrequested native counts are null. Static-table links are derived; the x86-64 executable-byte fallback is inferred and does not verify instruction boundaries. Input guards:256MiB metadata/1GiB binary;64MiB encoded report; one active inspection. Unsupported layouts, pointer encodings and resource limits have typed reasons. Full Evidence remains available with detail complete. Neither static linking nor structural agreement proves runtime behavior or a same-build pair.",
"Inspect local Unity IL2CPP metadata (24/27/29/31/39), optionally linking a caller-selected 64-bit little-endian Mach-O, PE or ELF binary. Static reading only. Default summary retains complete Evidence; use parent_evidence_id with inspect_analysis_view kind il2cpp-metadata for named collections, indices, fields and paging. Parameters accept method_indices; method_addresses accept addresses and states. Stable row indices are scoped to the metadata digest; native pages also identify the binary. Native states distinguish executable code, observed zero slots (no-body), and unknown owners/relocations. Shared addresses preserve all methods. code_gen_modules pages accept image_indices; summaries omit that table. Type names are C#-like; version27 retains indices with unknown names. generic_instances adds genericMethodTable rows with Il2CppDumper-style names (versions 27/29/31, static CodeRegistration and MetadataRegistration); generic_method_instances pages accept method_indices, addresses and states; summaries count issues, paged as generic_method_issues. Unrequested native counts are null. Static-table links are derived; the x86-64/arm64 executable-byte fallback is inferred and does not verify instruction boundaries. Input guards:256MiB metadata/1GiB binary;64MiB encoded report; one active inspection. Unsupported layouts, pointer encodings and resource limits have typed reasons. Full Evidence remains available with detail complete. Neither static linking nor structural agreement proves runtime behavior or a same-build pair.",
inputSchema: inspectIl2cppMetadataInputSchema,
outputSchema: evidenceResultOf(
z.union([il2cppMetadataSchema, il2cppMetadataSummaryViewResultSchema]),
@@ -554,6 +554,24 @@ it("keeps native summaries bounded while paging selected module facts", () => {
});
});
it.each([
[
"code_gen_modules_field",
"Module-array association",
"Type-table association",
],
["types_field", "Type-table association", "Module-array association"],
] as const)(
"keeps an inferred %s as its own unknown",
(field, present, absent) => {
const value = linkedReport();
value.binary![field] = null;
const unknowns = summarize(parent(value, false)).unknowns.map(String);
expect(unknowns.filter((line) => line.startsWith(present))).toHaveLength(1);
expect(unknowns.some((line) => line.startsWith(absent))).toBe(false);
},
);
const genericLinked = (): Il2cppMetadata => {
const linked = linkedReport();
return {
@@ -606,6 +606,11 @@ export const projectIl2cppMetadataView = (
"Module-array association is inferred from executable byte patterns; instruction boundaries and runtime use remain unverified.",
]
: []),
...(report.binary != null && report.binary.types_field === null
? [
"Type-table association and types_count are inferred from executable byte patterns and Il2CppType-shaped records; instruction boundaries and runtime use remain unverified.",
]
: []),
...[
...il2cppMetadataMemberCollections,
...il2cppMetadataBinaryCollections,
+6 -2
View File
@@ -288,8 +288,12 @@ export const il2cppBinaryLinkSchema = z.strictObject({
/** The code-generation module pointer array. */
code_gen_modules_array: il2cppAddressSchema,
code_gen_modules: z.array(il2cppCodeGenModuleSchema),
/** MetadataRegistration's typesCount field, followed by the types pointer. */
types_field: il2cppAddressSchema,
/**
* MetadataRegistration's typesCount field, followed by the types pointer;
* null when no static registration had the counts and executable byte-pattern
* candidates identified the type table, whose run length is then types_count.
*/
types_field: il2cppAddressSchema.nullable(),
types_count: count,
/** Null unless generic_instances was requested. */
generic_methods: il2cppGenericMethodLinkSchema.nullable(),
+26 -3
View File
@@ -45,8 +45,13 @@ export interface Il2cppBinaryLink {
* 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;
/**
* Address of MetadataRegistration's typesCount field, or null when no static
* registration matched and an executable byte-pattern candidate identified
* the type table; types_count is then the inferred run length, at least
* every referenced type index but not an observed typesCount.
*/
readonly types_field: bigint | null;
readonly types_count: number;
/** Il2CppType table index and display name per method return, field and parameter. */
readonly return_types: Il2cppTypeReference[];
@@ -170,6 +175,7 @@ export function* linkIl2cppBinarySteps(
const registration = yield* findMetadataRegistration(
image,
metadata.types.length,
yield* referencedTypeCount(facts),
);
const typeName = createTypeNamer(
image,
@@ -222,7 +228,7 @@ export function* linkIl2cppBinarySteps(
facts,
registration_.field,
// findMetadataRegistration reports typesCount, word 6.
registration.field - 48n,
registration.field === null ? null : registration.field - 48n,
options,
)
: null;
@@ -243,6 +249,23 @@ export function* linkIl2cppBinarySteps(
};
}
/** One past the highest type index any return, field or parameter references. */
function* referencedTypeCount(
facts: Il2cppMetadataLinkFacts,
): Generator<void, number> {
let count = 0;
for (const types of [
facts.returnTypes,
facts.fieldTypes,
facts.parameterTypes,
])
for (let index = 0; index < types.length; index++) {
if (index % LINK_BATCH === 0) yield;
count = Math.max(count, types[index]! + 1);
}
return count;
}
/** Rows between cooperative yields while linking. */
const LINK_BATCH = 256;
+7 -1
View File
@@ -103,7 +103,8 @@ export function* linkGenericMethodsSteps(
metadata: Il2cppMetadataDecoded,
facts: Il2cppMetadataLinkFacts,
modulesField: bigint | null,
metadataRegistration: bigint,
/** Null when the type table was inferred without a static MetadataRegistration. */
metadataRegistration: bigint | null,
options: Il2cppBinaryReadOptions,
): Generator<void, Il2cppGenericMethodLink> {
const version = metadata.metadata_version;
@@ -113,6 +114,11 @@ export function* linkGenericMethodsSteps(
"generic-methods-unsupported",
"this binary keeps no static CodeRegistration, so genericMethodPointers cannot be located",
);
if (metadataRegistration === null)
throw new Il2cppBinaryFormatFailure(
"generic-methods-unsupported",
"this binary keeps no static MetadataRegistration, so genericMethodTable cannot be located",
);
const word = (address: bigint) => readPointer(image, address);
const count = (address: bigint) => {
const value = word(address);
+19 -34
View File
@@ -6,9 +6,11 @@ import { binaryMetadataArchitecture } from "../domain/binaryMetadataArchitecture
import {
BinaryFormatFailure,
BinaryResourceFailure,
createFileMappingIndex,
mappedFileOffset,
isZeroFilledSpan,
type BinaryContainer,
type FileMappingIndex,
} from "../native/metadata/BinaryContainer.js";
import { readBinaryContainer } from "../native/metadata/readBinaryContainer.js";
import {
@@ -74,6 +76,8 @@ const selectImage = (
export interface NativeImage {
readonly bytes: Buffer;
readonly container: BinaryContainer;
/** One image's immutable load mappings, shared by stored and loader-aware reads. */
readonly fileMappings: FileMappingIndex;
readonly imageBase: bigint;
readonly unresolvedPointerSlots: ReadonlyMap<bigint, number>;
readonly zeroFillPointers: ReadonlyMap<bigint, bigint>;
@@ -190,6 +194,10 @@ export function* openImageSteps(
return {
bytes: image,
container,
fileMappings: createFileMappingIndex(
container.mappings,
container.zero_fills,
),
imageBase,
unresolvedPointerSlots: relocated?.unresolved ?? new Map<bigint, number>(),
zeroFillPointers: relocated?.zeroFillPointers ?? new Map<bigint, bigint>(),
@@ -200,12 +208,7 @@ export function* openImageSteps(
/** Stored bytes only; loader interpretation belongs to readSlot. */
const readStoredWord = (image: NativeImage, address: bigint): bigint | null => {
try {
const offset = mappedFileOffset(
image.container.mappings,
address,
8,
image.container.zero_fills,
);
const offset = image.fileMappings.offset(address, 8);
return image.bytes.readBigUInt64LE(offset);
} catch (cause) {
if (cause instanceof BinaryFormatFailure) return null;
@@ -226,14 +229,7 @@ export const readUInt32 = (
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,
),
);
return image.bytes.readUInt32LE(image.fileMappings.offset(address, 4));
} catch (cause) {
if (cause instanceof BinaryFormatFailure) return null;
throw cause;
@@ -245,12 +241,7 @@ export const hexAddress = (value: bigint) => `0x${value.toString(16)}`;
export const isMapped = (image: NativeImage, address: bigint, size: number) => {
try {
mappedFileOffset(
image.container.mappings,
address,
size,
image.container.zero_fills,
);
image.fileMappings.offset(address, size);
return true;
} catch (cause) {
if (cause instanceof BinaryFormatFailure) return false;
@@ -302,12 +293,7 @@ export const hasPointerArrayCoverage = (
if (fileEnd === cursor) {
if (!isZeroFilled(image, cursor, size)) return false;
} else {
mappedFileOffset(
image.container.mappings,
cursor,
size,
image.container.zero_fills,
);
image.fileMappings.offset(cursor, size);
}
} catch (cause) {
if (cause instanceof BinaryFormatFailure) return false;
@@ -321,12 +307,7 @@ export const hasPointerArrayCoverage = (
/** 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,
);
const offset = image.fileMappings.offset(address, 1);
return (
offset ===
mappedFileOffset(image.container.executable_mappings ?? [], address, 1)
@@ -359,8 +340,12 @@ export const readSlot = (
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 = readStoredWord(image, address);
else {
// A successful indexed read already excludes zero-fill aliases. Only a
// non-file-backed slot needs the zero-fill ownership walk.
raw = readStoredWord(image, address);
if (raw === null && isZeroFilled(image, address, 8)) raw = 0n;
}
if (raw === null) return null;
if (image.machoPointerFixups !== undefined) {
const resolution = image.machoPointerFixups.decode(address, raw);
+249 -9
View File
@@ -7,10 +7,16 @@ import {
isMapped,
hasPointerArrayCoverage,
readPointer,
readSlot,
readUInt32,
type NativeImage,
} from "./Il2cppBinaryImage.js";
import type { Il2cppMetadataDecoded } from "./Il2cppMetadataFile.js";
import {
IL2CPP_TYPE_KIND_MAX,
il2cppTypeKind,
isDefinitionTypeKind,
} from "./Il2cppBinaryTypes.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.
@@ -245,7 +251,11 @@ export function* findCodeRegistration(
}
}
}
if (matches.length === 0 && image.container.architecture === "amd64") {
const architecture = image.container.architecture;
if (
matches.length === 0 &&
(architecture === "amd64" || architecture === "arm64")
) {
const array = yield* findModuleArrayByCode(image, expected);
if (array !== null) return { field: null, array };
}
@@ -258,12 +268,16 @@ export function* findCodeRegistration(
}
/**
* 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
* Some builds 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. Seen on x86-64 (a Unity 2023.2 Linux GameAssembly.so) and on
* arm64 (a Unity 2022.3 macOS Master/LTO GameAssembly.dylib). 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.
* one of them to be the target of an address-forming instruction in an
* executable range: on x86-64 a RIP-relative `lea`/`mov` (REX.W 8D/8B with a
* disp32 operand), on arm64 an `adrp` immediately followed by an `add` of its
* page offset to the same register. Anything else stays
* code-registration-not-found.
*/
const MODULE_ARRAY_CANDIDATES = 64;
@@ -303,6 +317,17 @@ function* findModuleArrayByCode(
}
}
if (arrays.size === 0) return null;
const referenced =
image.container.architecture === "arm64"
? yield* arm64PageReferences(image, arrays)
: yield* ripRelativeReferences(image, arrays);
return referenced.size === 1 ? [...referenced][0]! : null;
}
function* ripRelativeReferences(
image: NativeImage,
arrays: ReadonlySet<bigint>,
): Generator<void, Set<bigint>> {
const referenced = new Set<bigint>();
for (const mapping of image.container.executable_mappings ?? []) {
const bytes = image.bytes;
@@ -319,7 +344,44 @@ function* findModuleArrayByCode(
if (arrays.has(target)) referenced.add(target);
}
}
return referenced.size === 1 ? [...referenced][0]! : null;
return referenced;
}
/**
* arm64 instructions are 4-byte aligned words, so only aligned addresses are
* decoded. `adrp xd, page` forms the 4 KiB page of pc plus a signed 21-bit
* page delta; the next word must be `add xd, xd, #imm12` (64-bit, unshifted),
* which adds the offset within that page. Pairs split by other instructions or
* loads through `ldr` are not followed.
*/
function* arm64PageReferences(
image: NativeImage,
arrays: ReadonlySet<bigint>,
): Generator<void, Set<bigint>> {
const referenced = new Set<bigint>();
const bytes = image.bytes;
for (const mapping of image.container.executable_mappings ?? []) {
const start = Number((4n - (mapping.address % 4n)) % 4n);
for (let at = start; at + 8 <= mapping.size; at += 4) {
if ((at & 0xffff) < 4) yield;
const adrp = bytes.readUInt32LE(mapping.offset + at);
if ((adrp & 0x9f000000) >>> 0 !== 0x90000000) continue;
const add = bytes.readUInt32LE(mapping.offset + at + 4);
if ((add & 0xffc00000) >>> 0 !== 0x91000000) continue;
const register = adrp & 0x1f;
// Register 31 is xzr for ADRP and sp for ADD, so no address is formed.
if (register === 31) continue;
if ((add & 0x1f) !== register || ((add >>> 5) & 0x1f) !== register)
continue;
const pages =
(((((adrp >>> 5) & 0x7ffff) << 2) | ((adrp >>> 29) & 3)) << 11) >> 11;
const pc = mapping.address + BigInt(at);
const target =
(pc & ~0xfffn) + (BigInt(pages) << 12n) + BigInt((add >>> 10) & 0xfff);
if (arrays.has(target)) referenced.add(target);
}
}
return referenced;
}
/**
@@ -327,20 +389,26 @@ function* findModuleArrayByCode(
* 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.
* Linking reads only the type table, so when no static registration matches on
* x86-64 or arm64 the table is inferred from code instead (field null).
*/
export function* findMetadataRegistration(
image: NativeImage,
typeDefinitions: number,
requiredTypes: number,
): Generator<
void,
{
readonly field: bigint;
readonly field: bigint | null;
readonly count: number;
readonly types: bigint;
}
> {
const matches: { field: bigint; count: number; types: bigint }[] = [];
const matchedFields = new Set<bigint>();
// A record whose two counts match is static evidence even when its other
// fields are unusable; code is then not asked to stand in for it.
let countsMatched = false;
const low = (offset: number) => image.bytes.readUInt32LE(offset);
for (const mapping of image.container.mappings) {
const start = Number((8n - (mapping.address % 8n)) % 8n);
@@ -355,6 +423,7 @@ export function* findMetadataRegistration(
if (!hasPointerArrayCoverage(image, address, 16)) continue;
if (readUInt32(image, address + 80n) !== typeDefinitions) continue;
if (readUInt32(image, address + 96n) !== typeDefinitions) continue;
countsMatched = true;
const count = readUInt32(image, address + 48n);
const types = readPointer(image, address + 56n);
const fieldOffsets = readPointer(image, address + 88n);
@@ -387,10 +456,181 @@ export function* findMetadataRegistration(
});
}
}
const architecture = image.container.architecture;
let inferred = false;
if (
!countsMatched &&
requiredTypes > 0 &&
(architecture === "amd64" || architecture === "arm64")
) {
inferred = true;
const table = yield* findTypeTableByCode(
image,
typeDefinitions,
requiredTypes,
);
if (table !== null) return { field: null, ...table };
}
if (matches.length !== 1)
throw new Il2cppBinaryFormatFailure(
"metadata-registration-not-found",
`${matches.length} MetadataRegistration candidates count ${typeDefinitions} type definitions`,
countsMatched && matches.length === 0
? `a MetadataRegistration counts ${typeDefinitions} type definitions, but its type table, field offsets or type sizes are not mapped`
: inferred
? `no MetadataRegistration counts ${typeDefinitions} type definitions, and no single code-loaded run of Il2CppType pointers covers every referenced type index`
: `${matches.length} MetadataRegistration candidates count ${typeDefinitions} type definitions`,
);
return matches[0]!;
}
/**
* An inlined MetadataRegistration leaves the Il2CppType pointer table loaded
* directly by code. Candidates are maximal runs of pointers to Il2CppType-shaped
* records (a defined Il2CppTypeEnum kind; class and value-type data below the
* metadata type-definition count) long enough for every type index the
* metadata references. Exactly one such run may exist in the image, it must
* end inside its mapping, and code must load its start with the same
* instruction forms as the module-array fallback; anything else fails closed.
* The run length becomes the count: never below what the metadata needs, but
* not an observed typesCount.
*/
/** Il2CppTypeEnum values 0x01 to 0x1e; 0x17 and 0x1a are unassigned. */
const isDefinedTypeKind = (kind: number) =>
kind >= 1 && kind <= IL2CPP_TYPE_KIND_MAX && kind !== 0x17 && kind !== 0x1a;
function* findTypeTableByCode(
image: NativeImage,
typeDefinitions: number,
requiredTypes: number,
): Generator<void, { readonly count: number; readonly types: bigint } | null> {
const { bytes, container } = image;
// Nomination and loader-aware confirmation share the image's mapping index.
// Most words are not record addresses; reject those without the throwing
// lookup. Starts are sorted, and reach[i] is the furthest end among the
// first i + 1 mappings, so one binary search finds a possible container.
const spans = container.mappings
.filter((mapping) => mapping.size > 0)
.map((mapping) => ({
start: mapping.address,
end: mapping.address + BigInt(mapping.size),
}))
.sort((left, right) =>
left.start < right.start ? -1 : left.start > right.start ? 1 : 0,
);
const reach: bigint[] = [];
for (const [index, span] of spans.entries())
reach.push(
index === 0 || span.end > reach[index - 1]!
? span.end
: reach[index - 1]!,
);
const maybeMapped = (address: bigint) => {
let lowIndex = 0;
let highIndex = spans.length - 1;
let found = -1;
while (lowIndex <= highIndex) {
const middle = (lowIndex + highIndex) >> 1;
if (spans[middle]!.start <= address) {
found = middle;
lowIndex = middle + 1;
} else highIndex = middle - 1;
}
return found >= 0 && reach[found]! >= address + 12n;
};
const recordOffset = (address: bigint) => {
if (!maybeMapped(address)) return -1;
try {
return image.fileMappings.offset(address, 12);
} catch (cause) {
// Unmapped, zero-filled or ambiguously mapped records are not types.
if (cause instanceof BinaryFormatFailure) return -1;
throw cause;
}
};
const isType = (address: bigint) => {
// Records hold an 8-byte pointer first; zero and unaligned words are not types.
if (address === 0n || (address & 7n) !== 0n) return false;
const offset = recordOffset(address);
if (offset < 0) return false;
const kind = il2cppTypeKind(bytes.readUInt32LE(offset + 8));
if (!isDefinedTypeKind(kind)) return false;
if (!isDefinitionTypeKind(kind)) return true;
return bytes.readBigUInt64LE(offset) < BigInt(typeDefinitions);
};
// A run that touches either end of its mapping may continue in a
// neighbouring one, so its length and start are unknown; it still counts as
// a candidate but cannot be chosen.
const runs = new Map<bigint, number | null>();
// Stored words only nominate slots: a slot the loader binds or leaves
// unresolved has no known value, so a run it bounds has an unknown extent
// whatever its placeholder holds.
// The same holds for a record's kind word and its definition index.
const recordState = (target: bigint): "type" | "other" | "unknown" => {
if (recordOffset(target) < 0) return "other";
const attributes = readUInt32(image, target + 8n);
if (attributes === "unknown") return "unknown";
if (attributes === null) return "other";
const kind = il2cppTypeKind(attributes);
if (!isDefinedTypeKind(kind)) return "other";
if (!isDefinitionTypeKind(kind)) return "type";
const data = readSlot(image, target);
if (data === "unknown") return "unknown";
return typeof data === "bigint" && data < BigInt(typeDefinitions)
? "type"
: "other";
};
// A word whose slot or record the loader leaves unknown bounds a run with
// an unknown extent.
const unresolved = (address: bigint) => {
const slot = readSlot(image, address);
return (
slot === "unknown" ||
(typeof slot === "bigint" && recordState(slot) === "unknown")
);
};
for (const mapping of container.mappings) {
const start = Number((8n - (mapping.address % 8n)) % 8n);
let runStart = -1;
let startUnknown = false;
for (let at = start; at <= mapping.size; at += 8) {
if ((at & 0xffff) < 8) yield;
const address = mapping.address + BigInt(at);
let endUnknown = at + 8 > mapping.size;
if (!endUnknown) {
const raw = bytes.readBigUInt64LE(mapping.offset + at);
if (isType(raw)) {
const slot = readSlot(image, address);
const state = slot === raw ? recordState(slot) : "other";
if (state === "type") {
if (runStart < 0) {
runStart = at;
startUnknown = at === start || unresolved(address - 8n);
}
continue;
}
endUnknown = slot === "unknown" || state === "unknown";
} else if (runStart >= 0) endUnknown = unresolved(address);
}
if (runStart >= 0 && (at - runStart) / 8 >= requiredTypes) {
runs.set(
mapping.address + BigInt(runStart),
endUnknown || startUnknown ? null : (at - runStart) / 8,
);
// Another long-enough run could be the real table while code loads
// only this one, so a second candidate fails closed at once.
if (runs.size > 1) return null;
}
runStart = -1;
}
}
if (runs.size !== 1) return null;
const [start, count] = [...runs][0]!;
if (count === null) return null;
const starts = new Set([start]);
const referenced =
container.architecture === "arm64"
? yield* arm64PageReferences(image, starts)
: yield* ripRelativeReferences(image, starts);
if (!referenced.has(start)) return null;
return { count, types: start };
}
+12 -2
View File
@@ -9,6 +9,16 @@ import type {
Il2cppMetadataDecoded,
Il2cppMetadataLinkFacts,
} from "./Il2cppMetadataFile.js";
/** Il2CppType's type enum: bits 16-23 of the word after its 8-byte data. */
export const il2cppTypeKind = (attributes: number): number =>
(attributes >>> 16) & 0xff;
/** Largest Il2CppTypeEnum value read as a type kind (IL2CPP_TYPE_MVAR). */
export const IL2CPP_TYPE_KIND_MAX = 0x1e;
/** Class and value-type kinds, whose data is a TypeDefinitionIndex. */
export const isDefinitionTypeKind = (kind: number): boolean =>
kind === 0x11 || kind === 0x12;
const PRIMITIVES = new Map([
[0x01, "void"],
[0x02, "bool"],
@@ -127,12 +137,12 @@ const createTypeRenderer = (
if (tick()) yield;
const data = pointer(address, "Il2CppType data");
const bits = u32(address + 8n, "Il2CppType attributes");
const kind = (bits >>> 16) & 0xff;
const kind = il2cppTypeKind(bits);
const index = Number(BigInt.asIntN(32, data & 0xffffffffn));
let result: string;
const primitive = rules.primitives.get(kind);
if (primitive !== undefined) result = primitive;
else if (kind === 0x11 || kind === 0x12)
else if (isDefinitionTypeKind(kind))
result = yield* rules.definition(index);
else if (kind === 0x0f || kind === 0x1d)
result = join([
+12 -2
View File
@@ -67,6 +67,11 @@ const INPUT_BYTES = 256 * 1024 * 1024;
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;
/** Instruction form the module-array fallback matched, per architecture. */
const inferredReferenceForm = (architecture: string) =>
architecture === "arm64"
? "an adjacent ADRP+ADD pair"
: "RIP-relative LEA/MOV";
const failureForPrimary = (
facts: ArtifactFilePrimaryFacts,
path: string,
@@ -229,7 +234,7 @@ const binaryReport = (
address: hex(module.address),
method_pointer_count: module.method_pointer_count,
})),
types_field: hex(link.types_field),
types_field: link.types_field === null ? null : hex(link.types_field),
types_count: link.types_count,
generic_methods:
link.generic_methods === null
@@ -587,7 +592,12 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
...(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.",
`The static CodeRegistration pair was not resolved. The module array is inferred from executable bytes consistent with ${inferredReferenceForm(linked.binary.architecture)} referencing one array candidate; instruction boundaries and runtime use were not verified.`,
]
: []),
...(linked.binary !== null && linked.binary.types_field === null
? [
`No static MetadataRegistration had the type-definition counts. The type table is inferred from executable bytes consistent with ${inferredReferenceForm(linked.binary.architecture)} referencing the one run of Il2CppType-shaped pointers long enough for every referenced type index; types_count is that run's length, at least every referenced type index but not an observed typesCount, and instruction boundaries and runtime use were not verified.`,
]
: []),
...(binaryPath === undefined
@@ -50,6 +50,14 @@ const expectedAddress = (value: bigint | "no-body" | "unknown") =>
const expectedAddresses = FIXTURE_METHOD_ADDRESSES.map(expectedAddress);
const link = (bytes: Buffer, architecture?: string) =>
linkIl2cppBinary(bytes, metadata, facts, architecture);
// A type-table fallback needs the metadata to reference most of the table, as
// real builds do; referencing type 3 requires all four fixture entries.
const wholeTable = {
...facts,
returnTypes: Int32Array.from(facts.returnTypes, () => 3),
};
const linkWholeTable = (bytes: Buffer) =>
linkIl2cppBinary(bytes, metadata, wholeTable);
const failure = (bytes: Buffer, architecture?: string) => {
try {
link(bytes, architecture);
@@ -430,6 +438,389 @@ it.each([
).toBe("code-registration-not-found");
});
it("finds an arm64 module array through the ADRP+ADD pair that loads it", () => {
const linked = link(
createIl2cppBinaryFixture({
codeRegistration: false,
adrpAdd: [[0xc00, 0x500]],
}),
);
expect(linked.code_gen_modules_field).toBeNull();
expect(linked.code_gen_modules_array).toBe(FIXTURE_BINARY_BASE + 0x500n);
expect(linked.method_addresses).toEqual(expectedAddresses);
});
it.each([
["no instruction pair loads the array", {}],
["the pair starts off a 4-byte instruction boundary", { at: 0xc02 }],
["two module-array candidates are each loaded", { second: true }],
["the pair lies outside executable ranges", { protection: 3 }],
] as const)(
"keeps arm64 code-registration-not-found when %s",
(_, variant: { at?: number; second?: boolean; protection?: number }) => {
const bytes = createIl2cppBinaryFixture({
codeRegistration: false,
adrpAdd:
Object.keys(variant).length === 0
? []
: [
[variant.at ?? 0xc00, 0x500],
...(variant.second === true ? [[0xc10, 0x520] as const] : []),
],
});
if (variant.second === true) bytes.copy(bytes, 0x520, 0x500, 0x510);
if (variant.protection !== undefined)
bytes.writeUInt32LE(variant.protection, 92);
expect(failure(bytes).reason).toBe("code-registration-not-found");
},
);
it("finds an arm64 type table through the ADRP+ADD pair that loads it", () => {
const linked = linkWholeTable(
createIl2cppBinaryFixture({
metadataRegistration: false,
adrpAdd: [[0xc20, 0x900]],
}),
);
const reference = linkWholeTable(createIl2cppBinaryFixture());
expect(linked.types_field).toBeNull();
expect(linked.types_count).toBe(4);
expect(linked.return_types).toEqual(reference.return_types);
expect(linked.field_types).toEqual(reference.field_types);
expect(linked.parameter_types).toEqual(reference.parameter_types);
});
it("finds an x86-64 type table through the instruction that loads it", () => {
const image = createIl2cppBinaryFixture({ metadataRegistration: false });
// lea rcx, [rip + disp32] at 0xc40, loading the type table at 0x900.
image.set([0x48, 0x8d, 0x0d], 0xc40);
image.writeInt32LE(0x900 - (0xc40 + 7), 0xc43);
const linked = linkWholeTable(createElfFixture(image, { machine: 62 }));
expect(linked.types_field).toBeNull();
expect(linked.types_count).toBe(4);
expect(linked.return_types).toEqual(
linkWholeTable(
createElfFixture(createIl2cppBinaryFixture(), { machine: 62 }),
).return_types,
);
});
it.each([
["the pair loads an address inside the run", { target: 0x908 }],
[
"a type-shaped word extends the run before the loaded address",
{ before: true },
],
["two runs are each loaded", { second: true }],
[
"the loaded run is shorter than the referenced type indices",
{ short: true },
],
["a record's kind is past the Il2CppTypeEnum range", { kind: 0x1f }],
[
"a value-type record names a type definition past the metadata",
{ definition: 4n },
],
] as const)(
"keeps metadata-registration-not-found when %s",
(
_,
variant: {
target?: number;
before?: boolean;
second?: boolean;
short?: boolean;
kind?: number;
definition?: bigint;
},
) => {
const bytes = createIl2cppBinaryFixture({
metadataRegistration: false,
adrpAdd: [
[0xc20, variant.target ?? 0x900],
...(variant.second === true ? [[0xc30, 0xb00] as const] : []),
],
});
if (variant.second === true) bytes.copy(bytes, 0xb00, 0x900, 0x920);
// The int record (type 3) is a valid Il2CppType, so the run starts at 0x8f8.
if (variant.before === true)
bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x9b0n, 0x8f8);
// Kind 0 ends the run after two entries; the facts below reference type 3.
if (variant.short === true) bytes.writeUInt32LE(0, 0x980 + 2 * 16 + 8);
// Either record breaks the four-entry run into pieces shorter than four.
if (variant.kind !== undefined)
bytes.writeUInt32LE(variant.kind << 16, 0x980 + 16 + 8);
if (variant.definition !== undefined)
bytes.writeBigUInt64LE(variant.definition, 0x980 + 2 * 16);
// Facts referencing type 3 require a four-entry run.
const required =
variant.short === true ||
variant.kind !== undefined ||
variant.definition !== undefined
? { ...facts, returnTypes: Int32Array.from(facts.returnTypes, () => 3) }
: facts;
let reason: string | undefined;
try {
linkIl2cppBinary(bytes, metadata, required);
} catch (cause) {
if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause;
reason = cause.reason;
}
expect(reason).toBe("metadata-registration-not-found");
},
);
it.each([
[
// The real table is unloaded; a copy elsewhere is loaded instead.
"only a second long-enough run is loaded",
{ metadataRegistration: false, adrpAdd: [[0xc20, 0xb00]] },
(bytes: Buffer) => bytes.copy(bytes, 0xb00, 0x900, 0x920),
],
[
"a static MetadataRegistration matches its counts but not its type table",
{ adrpAdd: [[0xc20, 0x900]] },
(bytes: Buffer) =>
bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0xdead0000n, 0x800 + 56),
],
[
"the loaded run reaches the end of its mapping",
{
metadataRegistration: false,
adrpAdd: [[0xc20, 0x900]],
splitSegmentAt: 0x920,
},
() => undefined,
],
[
"a record's kind is the unassigned Il2CppTypeEnum value 0x17",
{ metadataRegistration: false, adrpAdd: [[0xc20, 0x900]] },
(bytes: Buffer) => bytes.writeUInt32LE(0x17 << 16, 0x980 + 16 + 8),
],
[
"a record's kind is the unassigned Il2CppTypeEnum value 0x1a",
{ metadataRegistration: false, adrpAdd: [[0xc20, 0x900]] },
(bytes: Buffer) => bytes.writeUInt32LE(0x1a << 16, 0x980 + 16 + 8),
],
[
// Three entries, one short of the four that type 3 needs.
"the loaded run is one entry short",
{ metadataRegistration: false, adrpAdd: [[0xc20, 0x900]] },
(bytes: Buffer) => bytes.writeBigUInt64LE(0n, 0x918),
],
[
"the ADRP+ADD pair uses register 31",
{ metadataRegistration: false, adrpAdd: [[0xc20, 0x900]] },
(bytes: Buffer) => {
bytes.writeUInt32LE((bytes.readUInt32LE(0xc20) | 0x1f) >>> 0, 0xc20);
bytes.writeUInt32LE((bytes.readUInt32LE(0xc24) | 0x3ff) >>> 0, 0xc24);
},
],
] as const)(
"keeps metadata-registration-not-found when %s, with the whole table referenced",
(_, options, patch) => {
const bytes = createIl2cppBinaryFixture(options);
patch(bytes);
let reason: string | undefined;
try {
linkWholeTable(bytes);
} catch (cause) {
if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause;
reason = cause.reason;
}
expect(reason).toBe("metadata-registration-not-found");
},
);
it("does not choose a run that starts at its mapping start, where the table may continue backwards", () => {
// The table spans 0x900..0x920; the split puts its first entry in the
// previous mapping, and code loads 0x908, the first entry of the next.
const bytes = createIl2cppBinaryFixture({
metadataRegistration: false,
splitSegmentAt: 0x908,
adrpAdd: [[0xc20, 0x908]],
});
let reason: string | undefined;
try {
linkIl2cppBinary(bytes, metadata, {
...facts,
returnTypes: Int32Array.from(facts.returnTypes, () => 1),
});
} catch (cause) {
if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause;
reason = cause.reason;
}
expect(reason).toBe("metadata-registration-not-found");
});
it("says why no MetadataRegistration or inferred type table was accepted", () => {
const message = (bytes: Buffer) => {
try {
linkWholeTable(bytes);
} catch (cause) {
if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause;
return cause.message;
}
throw new Error("expected a link failure");
};
const unusable = createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x900]] });
unusable.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0xdead0000n, 0x800 + 56);
expect(message(unusable)).toContain(
"a MetadataRegistration counts 4 type definitions, but its type table",
);
expect(
message(createIl2cppBinaryFixture({ metadataRegistration: false })),
).toContain("no single code-loaded run of Il2CppType pointers");
});
it("does not let a dyld-bound slot's placeholder decide the type-table run", () => {
const outcome = (placeholder: bigint) => {
// The last table slot (0x918) is bound by dyld; its stored word is only a
// placeholder. Remove the static registration and load the table by code.
const bytes = createIl2cppDyldBindFixture(placeholder, 0x918);
bytes.fill(0, 0x800, 0x880);
createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x900]] }).copy(
bytes,
0xc20,
0xc20,
0xc28,
);
try {
return linkWholeTable(bytes).types_count;
} catch (cause) {
if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause;
return cause.reason;
}
};
// A type-shaped placeholder and a zero one give the same refusal.
expect(outcome(FIXTURE_BINARY_BASE + 0x9b0n)).toBe(
"metadata-registration-not-found",
);
expect(outcome(0n)).toBe("metadata-registration-not-found");
});
it("does not let a dyld-bound record field decide the type-table run", () => {
const outcome = (bound: boolean) => {
// The int record (0x9b0) is the table's last entry. Binding the word
// that holds its kind leaves that kind to the loader, although the
// stored placeholder still reads as a valid kind.
const kindWord = createIl2cppBinaryFixture().readBigUInt64LE(0x9b8);
const bytes = bound
? createIl2cppDyldBindFixture(kindWord, 0x9b8)
: createIl2cppBinaryFixture();
bytes.fill(0, 0x800, 0x880);
createIl2cppBinaryFixture({ adrpAdd: [[0xc20, 0x900]] }).copy(
bytes,
0xc20,
0xc20,
0xc28,
);
try {
return linkWholeTable(bytes).types_count;
} catch (cause) {
if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause;
return cause.reason;
}
};
expect(outcome(false)).toBe(4);
expect(outcome(true)).toBe("metadata-registration-not-found");
});
it("keeps metadata-registration-not-found when x86-64 code loads an address inside the run", () => {
const image = createIl2cppBinaryFixture({ metadataRegistration: false });
image.set([0x48, 0x8d, 0x0d], 0xc40);
image.writeInt32LE(0x908 - (0xc40 + 7), 0xc43);
let reason: string | undefined;
try {
linkWholeTable(createElfFixture(image, { machine: 62 }));
} catch (cause) {
if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause;
reason = cause.reason;
}
expect(reason).toBe("metadata-registration-not-found");
});
it.each([
["a later page", { pages: 2, code: 0xc00, array: 0x1500 }],
["an earlier page", { pages: 3, code: 0x2c00, array: 0x500 }],
] as const)(
"follows an arm64 ADRP page delta to a module array on %s",
(_, layout) => {
const bytes = createIl2cppBinaryFixture({
codeRegistration: false,
pages: layout.pages,
adrpAdd: [[layout.code, layout.array]],
});
if (layout.array !== 0x500) {
bytes.copy(bytes, layout.array, 0x500, 0x510);
bytes.fill(0, 0x500, 0x510);
}
const linked = link(bytes);
expect(linked.code_gen_modules_array).toBe(
FIXTURE_BINARY_BASE + BigInt(layout.array),
);
expect(linked.method_addresses).toEqual(expectedAddresses);
},
);
it("follows a negative arm64 ADRP page delta to the type table", () => {
const linked = linkWholeTable(
createIl2cppBinaryFixture({
metadataRegistration: false,
pages: 3,
adrpAdd: [[0x2c20, 0x900]],
}),
);
expect(linked.types_field).toBeNull();
expect(linked.types_count).toBe(4);
});
it.each([
["destination", 0],
["source", 5],
] as const)(
"keeps code-registration-not-found when the ADD %s register differs from the ADRP register",
(_, shift) => {
const bytes = createIl2cppBinaryFixture({
codeRegistration: false,
adrpAdd: [[0xc00, 0x500]],
});
const add = bytes.readUInt32LE(0xc04);
// The fixture pair uses x8; write x9 into one register field of the ADD.
bytes.writeUInt32LE(((add & ~(0x1f << shift)) | (9 << shift)) >>> 0, 0xc04);
expect(failure(bytes).reason).toBe("code-registration-not-found");
},
);
it("fails closed at the second type-table candidate when the metadata references few type indices", () => {
const bytes = createIl2cppBinaryFixture({
metadataRegistration: false,
pages: 2,
adrpAdd: [[0xc20, 0x900]],
});
// 65 isolated pointers to the int record, each a one-entry run.
for (let index = 0; index < 65; index++)
bytes.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x9b0n, 0x1000 + index * 16);
const outcome = (required: typeof facts) => {
try {
return linkIl2cppBinary(bytes, metadata, required).types_count;
} catch (cause) {
if (!(cause instanceof Il2cppBinaryFormatFailure)) throw cause;
return cause.reason;
}
};
// Fixture methods reference only type 0, so every isolated pointer is a
// candidate run, and the second one ends the search.
expect(outcome(facts)).toBe("metadata-registration-not-found");
// Referencing type 3 requires four entries; the isolated pointers drop out.
expect(
outcome({
...facts,
returnTypes: Int32Array.from(facts.returnTypes, () => 3),
}),
).toBe(4);
});
it("refuses to choose among too many candidate module arrays", () => {
const base = createIl2cppBinaryFixture({
codeRegistration: false,
@@ -1066,6 +1457,31 @@ it.each<
expect(genericFailure(options, version)).toEqual({ reason, kind });
});
it("refuses generic linking when the type table was inferred", () => {
// Without a static MetadataRegistration, genericMethodTable has no anchor.
const decoded = genericFacts(31);
expect(() =>
linkIl2cppBinary(
createIl2cppBinaryFixture({
metadataRegistration: false,
adrpAdd: [[0xc20, 0x900]],
}),
decoded.metadata,
{
...decoded.link,
returnTypes: Int32Array.from(decoded.link.returnTypes, () => 3),
},
undefined,
{ genericInstances: true },
),
).toThrow(
expect.objectContaining({
reason: "generic-methods-unsupported",
message: expect.stringContaining("no static MetadataRegistration"),
}),
);
});
it("keeps contradicting rows unknown and reports the record address", () => {
const issues = (options: GenericMethodFixtureOptions) => {
const generic = linkGeneric(options).generic_methods!;
@@ -1,15 +1,17 @@
import { expect, it } from "vitest";
import { expect, it, vi } from "vitest";
import { linkIl2cppBinary } from "../../../src/il2cpp/Il2cppBinaryFile.js";
import {
isMapped,
openImageSteps,
readPointer,
readSlot,
readUInt32,
} from "../../../src/il2cpp/Il2cppBinaryImage.js";
import { readIl2cppMetadataForLinking } from "../../../src/il2cpp/Il2cppMetadataFile.js";
import {
createElfFixture,
createIl2cppBinaryFixture,
createIl2cppTypeScanFixture,
FIXTURE_BINARY_BASE,
} from "../../fixtures/il2cpp/binary.js";
import { createLinkedIl2cppMetadataFixture } from "../../fixtures/il2cpp/metadata.js";
@@ -61,6 +63,42 @@ const withLoadRanges = (
return result;
};
it("reuses admitted mappings for repeated file-backed loader reads", () => {
const original = createIl2cppBinaryFixture();
const image = open(createElfFixture(original, { zeroFilledTable: true }));
const mappings = vi.spyOn(image.container.mappings, Symbol.iterator);
const zeroFills = vi.spyOn(image.container.zero_fills, "some");
try {
for (let index = 0; index < 32; index++) {
expect(readSlot(image, FIXTURE_BINARY_BASE + 0x900n)).toBe(
original.readBigUInt64LE(0x900),
);
expect(readUInt32(image, FIXTURE_BINARY_BASE + 0x988n)).toBe(
original.readUInt32LE(0x988),
);
expect(isMapped(image, FIXTURE_BINARY_BASE + 0x980n, 12)).toBe(true);
}
expect(mappings).not.toHaveBeenCalled();
expect(zeroFills).not.toHaveBeenCalled();
} finally {
mappings.mockRestore();
zeroFills.mockRestore();
}
});
it("links one code-loaded type table among short runs across many mappings", () => {
const bytes = createIl2cppTypeScanFixture(64, 128);
const snapshot = Buffer.from(bytes);
const result = linkIl2cppBinary(bytes, metadata, {
...facts,
returnTypes: Int32Array.from(facts.returnTypes, () => 3),
});
expect(result.types_field).toBeNull();
expect(result.types_count).toBe(4);
expect(result.return_types.every((row) => row.name === "int")).toBe(true);
expect(bytes).toEqual(snapshot);
});
it.each([183, 62])(
"links agreeing split ELF RELA spans without changing input bytes (machine %s)",
(machine) => {
@@ -643,6 +643,99 @@ it("retains both observed file identities and the selected architecture when nat
}
});
it.each([
{
architecture: "x86-64",
name: "libil2cpp.so",
binary: () =>
createElfFixture(
createIl2cppBinaryFixture({
codeRegistration: false,
codeReference: true,
}),
{ machine: 62 },
),
named: "RIP-relative LEA/MOV",
absent: "ADRP+ADD",
},
{
architecture: "arm64",
name: "GameAssembly.dylib",
binary: () =>
createIl2cppBinaryFixture({
codeRegistration: false,
adrpAdd: [[0xc00, 0x500]],
}),
named: "ADRP+ADD",
absent: "RIP-relative LEA/MOV",
},
])(
"names the $architecture instruction form behind an inferred module array",
async ({ name, binary, named, absent }) => {
const root = await createTestTempDirectory("rea-il2cpp-inferred-form-");
const path = join(root, "global-metadata.dat"),
binary_path = join(root, name);
await writeFile(path, createLinkedIl2cppMetadataFixture().bytes);
await writeFile(binary_path, binary());
const provider = new Il2cppMetadataProvider();
try {
const result = await provider.inspect({ path, binary_path });
if (!result.ok) throw result.error;
const inferred = result.value.limitations.filter((line) =>
line.includes("static CodeRegistration pair was not resolved"),
);
expect(inferred).toHaveLength(1);
expect(inferred[0]).toContain(named);
expect(inferred[0]).not.toContain(absent);
} finally {
await provider.close();
}
},
);
it("states that an inferred type table's count is a run length", async () => {
const root = await createTestTempDirectory("rea-il2cpp-inferred-types-");
const path = join(root, "global-metadata.dat"),
binary_path = join(root, "GameAssembly.dylib");
// A field typed as type 3 makes the metadata reference the whole
// four-entry table, as real builds reference most of theirs.
const fixture = createLinkedIl2cppMetadataFixture();
const metadata = Buffer.from(fixture.bytes);
metadata.writeUInt8(3, fixture.section("fields").offset + 4);
await writeFile(path, metadata);
await writeFile(
binary_path,
createIl2cppBinaryFixture({
metadataRegistration: false,
adrpAdd: [[0xc20, 0x900]],
}),
);
const provider = new Il2cppMetadataProvider();
try {
const result = await provider.inspect({ path, binary_path });
if (!result.ok) throw result.error;
expect(result.value.binary).toMatchObject({
types_field: null,
types_count: 4,
code_gen_modules_field: expect.any(String),
});
const inferred = result.value.limitations.filter((line) =>
line.includes(
"No static MetadataRegistration had the type-definition counts",
),
);
expect(inferred).toHaveLength(1);
expect(inferred[0]).toContain("ADRP+ADD");
expect(
result.value.limitations.some((line) =>
line.includes("static CodeRegistration pair was not resolved"),
),
).toBe(false);
} finally {
await provider.close();
}
});
it("reports generic method instances only when generic_instances is set", async () => {
const root = await createTestTempDirectory("rea-il2cpp-provider-generic-");
const path = join(root, "global-metadata.dat");
+63 -3
View File
@@ -29,6 +29,10 @@ export interface Il2cppBinaryFixtureOptions {
readonly duplicateCodeRegistration?: boolean;
/** Write a RIP-relative `lea rax, [module array]`, as an inlined build loads it. */
readonly codeReference?: boolean;
/** Write `adrp x8, page; add x8, x8, #pageoff` at each file offset, loading the target offset. */
readonly adrpAdd?: readonly (readonly [at: number, target: number])[];
/** 4 KiB pages in the Mach-O image; extra pages are zero, past every table. */
readonly pages?: number;
/** Omit the MetadataRegistration type table. */
readonly metadataRegistration?: boolean;
/** Write a second distinct MetadataRegistration candidate. */
@@ -131,20 +135,24 @@ const METHOD_SPECS = 0xb80;
const GENERIC_METHOD_TABLE = 0xbc0;
const GENERIC_METHOD_POINTERS = 0xd00;
const imageSize = (options: Il2cppBinaryFixtureOptions) =>
SIZE * (options.pages ?? 1);
export const createIl2cppBinaryFixture = (
options: Il2cppBinaryFixtureOptions = {},
): Buffer => {
const bytes = Buffer.alloc(SIZE);
const size = imageSize(options);
const bytes = Buffer.alloc(size);
const address = (offset: number) => FIXTURE_BINARY_BASE + BigInt(offset);
const chained = options.chainedFixups === true;
const split = options.splitSegmentAt;
// LC_SEGMENT_64 records, read-execute; a split gives two adjacent ones.
const segments: readonly [name: string, start: number, end: number][] =
split === undefined
? [["__TEXT", 0, SIZE]]
? [["__TEXT", 0, size]]
: [
["__TEXT", 0, split],
["__DATA", split, SIZE],
["__DATA", split, size],
];
const commands = segments.length + (chained ? 1 : 0);
// Mach-O 64-bit header: arm64 dylib with its load commands.
@@ -214,6 +222,21 @@ export const createIl2cppBinaryFixture = (
bytes.set([0x48, 0x8d, 0x05], at);
bytes.writeInt32LE(MODULE_ARRAY - (at + 7), at + 3);
}
for (const [at, target] of options.adrpAdd ?? []) {
const pc = address(at),
to = address(target);
const pages = Number(((to >> 12n) - (pc >> 12n)) & 0x1fffffn);
// ADRP x8: immlo in bits 29-30, immhi in bits 5-23.
bytes.writeUInt32LE(
(0x90000008 | ((pages & 3) << 29) | ((pages >> 2) << 5)) >>> 0,
at,
);
// ADD x8, x8, #pageoff (64-bit, unshifted imm12).
bytes.writeUInt32LE(
(0x91000108 | (Number(to & 0xfffn) << 10)) >>> 0,
at + 4,
);
}
if (options.codeRegistration !== false) {
bytes.writeBigUInt64LE(2n, REGISTRATION);
bytes.writeBigUInt64LE(address(MODULE_ARRAY), REGISTRATION + 8);
@@ -591,6 +614,43 @@ export const createElfFixture = (
return file;
};
/** A code-loaded type table plus isolated one-entry candidate runs in disjoint ELF mappings. */
export const createIl2cppTypeScanFixture = (
mappingCount: number,
bytesPerMapping: number,
): Buffer => {
const image = createIl2cppBinaryFixture({
metadataRegistration: false,
adrpAdd: [[0xc20, 0x900]],
});
const original = createElfFixture(image);
const candidates = Buffer.alloc(mappingCount * bytesPerMapping);
for (let at = 0; at < candidates.length; at += 16)
candidates.writeBigUInt64LE(FIXTURE_BINARY_BASE + 0x9b0n, at);
// The original dynamic segment and complete payload retain their addresses.
// Move only the program header table to the unmapped end of the file.
const headers: Buffer[] = [Buffer.from(original.subarray(120, 176))];
for (let index = 0; index <= mappingCount; index++) {
const offset =
index === 0 ? 0 : original.length + (index - 1) * bytesPerMapping;
const size = index === 0 ? original.length : bytesPerMapping;
const header = Buffer.alloc(56);
header.writeUInt32LE(1);
header.writeUInt32LE(index === 0 ? 7 : 6, 4);
header.writeBigUInt64LE(BigInt(offset), 8);
header.writeBigUInt64LE(FIXTURE_BINARY_BASE + BigInt(offset), 16);
header.writeBigUInt64LE(FIXTURE_BINARY_BASE + BigInt(offset), 24);
header.writeBigUInt64LE(BigInt(size), 32);
header.writeBigUInt64LE(BigInt(size), 40);
header.writeBigUInt64LE(1n, 48);
headers.push(header);
}
const result = Buffer.concat([original, candidates, ...headers]);
result.writeBigUInt64LE(BigInt(original.length + candidates.length), 32);
result.writeUInt16LE(headers.length, 56);
return result;
};
export interface GenericRegistrationImageOptions {
/** Let the 104-byte layout bound the rows as well as the 120-byte one. */
readonly ambiguous?: boolean;