diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index 72d55efba6..6d2e22bed5 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -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+Inner` or `Game.Outer+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. diff --git a/src/domain/il2cpp/il2cppMetadata.ts b/src/domain/il2cpp/il2cppMetadata.ts index 88ca5a70ce..4e1a02e6d2 100644 --- a/src/domain/il2cpp/il2cppMetadata.ts +++ b/src/domain/il2cpp/il2cppMetadata.ts @@ -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(), }); diff --git a/src/il2cpp/Il2cppBinaryFile.ts b/src/il2cpp/Il2cppBinaryFile.ts index f096e861db..e4b843c69b 100644 --- a/src/il2cpp/Il2cppBinaryFile.ts +++ b/src/il2cpp/Il2cppBinaryFile.ts @@ -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", diff --git a/src/il2cpp/Il2cppBinaryImage.ts b/src/il2cpp/Il2cppBinaryImage.ts index af254ba299..dc12b2ba1c 100644 --- a/src/il2cpp/Il2cppBinaryImage.ts +++ b/src/il2cpp/Il2cppBinaryImage.ts @@ -76,7 +76,7 @@ export interface NativeImage { readonly imageBase: bigint; readonly unresolvedPointerSlots: ReadonlyMap; readonly zeroFillPointers: ReadonlyMap; - /** 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; +}; diff --git a/src/il2cpp/Il2cppBinaryRegistration.ts b/src/il2cpp/Il2cppBinaryRegistration.ts index 0452477ec5..02a98caa68 100644 --- a/src/il2cpp/Il2cppBinaryRegistration.ts +++ b/src/il2cpp/Il2cppBinaryRegistration.ts @@ -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 { const expected = new Set(modules.map((module) => module.address)); - const total = BigInt(modules.length); const matches: { field: bigint; array: bigint }[] = []; const matchedFields = new Set(); 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(); 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(); 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(); 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; diff --git a/src/il2cpp/Il2cppBinaryTypes.ts b/src/il2cpp/Il2cppBinaryTypes.ts index 4fa69ee93d..2fe7fcb3b2 100644 --- a/src/il2cpp/Il2cppBinaryTypes.ts +++ b/src/il2cpp/Il2cppBinaryTypes.ts @@ -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) => { diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index 2de3d83c06..dce515bcd0 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -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 ? [ diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index 6e0ca4ac91..43cda5621b 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -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", () => { diff --git a/src/native/AppleMachoFixups.ts b/src/native/AppleMachoFixups.ts index 78de93e90c..de2c6939cc 100644 --- a/src/native/AppleMachoFixups.ts +++ b/src/native/AppleMachoFixups.ts @@ -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 => { + 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( diff --git a/src/native/AppleObjcClassImportBindings.test.ts b/src/native/AppleObjcClassImportBindings.test.ts index bf7244f71a..bddc53eafc 100644 --- a/src/native/AppleObjcClassImportBindings.test.ts +++ b/src/native/AppleObjcClassImportBindings.test.ts @@ -19,6 +19,7 @@ const createInput = ( legacyRebaseStreams: 0, legacyBindStreams: 0, unreadRebaseBytes: 0, + hasUnresolvedLegacyBytes: () => false, decode: (address) => address === 0x1000n ? { diff --git a/src/native/AppleSwiftVtables.test.ts b/src/native/AppleSwiftVtables.test.ts index 24f490d6ab..bd24420095 100644 --- a/src/native/AppleSwiftVtables.test.ts +++ b/src/native/AppleSwiftVtables.test.ts @@ -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", diff --git a/tests/acceptance/il2cppMetadataCli.test.ts b/tests/acceptance/il2cppMetadataCli.test.ts index 7a0f816201..9431232fe4 100644 --- a/tests/acceptance/il2cppMetadataCli.test.ts +++ b/tests/acceptance/il2cppMetadataCli.test.ts @@ -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 }) => { diff --git a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts index c8c656191b..910451636b 100644 --- a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts +++ b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts @@ -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], diff --git a/tests/fixtures/il2cpp/binary.ts b/tests/fixtures/il2cpp/binary.ts index 27795c0795..47e772044c 100644 --- a/tests/fixtures/il2cpp/binary.ts +++ b/tests/fixtures/il2cpp/binary.ts @@ -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; };