mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
+22
-4
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
Vendored
+36
-4
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user