mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
chore: merge latest main before publishing audit fixes
This commit is contained in:
@@ -169,11 +169,14 @@ explicit path or endpoint and do not need it.
|
||||
absent metadata does not prove a binary is not Go, and unparsed text records
|
||||
remain explicit. See the
|
||||
[Go binary guide](https://github.com/morluto/rea/blob/main/docs/go-binaries.md).
|
||||
- Unity IL2CPP image, type or method names from `global-metadata.dat`:
|
||||
- Unity IL2CPP names, member owners and string literals from `global-metadata.dat`:
|
||||
`inspect_il2cpp_metadata` with the explicit local file; MCP defaults to compact
|
||||
counts. Query sections, images, types or methods through `inspect_analysis_view`
|
||||
counts. Query sections, images, types, methods, fields, properties, events,
|
||||
parameters or string_literals through `inspect_analysis_view`
|
||||
with the returned `parent_evidence_id`, using exact filters, batch indices and
|
||||
selected fields. Versions 29, 31 and 39; no Unity, IL2CPP
|
||||
selected fields. Parameters accept `method_indices`, avoiding a scan of all
|
||||
parameter pages. Missing collections in historical Evidence remain unknown.
|
||||
Versions 24, 27, 29, 31 and 39; no Unity, IL2CPP
|
||||
binary or disassembler is run. Unrecognized magic (possibly encrypted) and
|
||||
other versions are unsupported. Names do not establish native code addresses
|
||||
or runtime behavior. See the
|
||||
|
||||
@@ -41,13 +41,16 @@ source and type recovery remain a separate native-analysis question.
|
||||
## IL2CPP metadata
|
||||
|
||||
Use `inspect_il2cpp_metadata` for an explicit local Unity `global-metadata.dat`
|
||||
(versions 29, 31 and 39) when the question concerns assembly images, type or method names,
|
||||
namespaces or index ranges. No `open_binary`, Unity installation or target
|
||||
(versions 24, 27, 29, 31 and 39) when the question concerns assembly images,
|
||||
names, member owners, string literals, namespaces or index ranges. No `open_binary`, Unity installation or target
|
||||
execution is required. The CLI equivalent is `inspect-il2cpp-metadata PATH`.
|
||||
MCP defaults to counts with complete retained Evidence. Query `sections`,
|
||||
`images`, `types` or `methods` through `inspect_analysis_view`; use exact names,
|
||||
`images`, `types`, `methods`, `fields`, `properties`, `events`, `parameters` or
|
||||
`string_literals` through `inspect_analysis_view`; use exact names,
|
||||
batch indices and selected fields. Row indices are scoped by artifact SHA-256
|
||||
and collection; page offsets count filtered matches. Preserve
|
||||
and collection; page offsets count filtered matches. Parameters support batch
|
||||
`method_indices` to read one method's parameters directly. Historical reports
|
||||
without member collections retain unknown counts rather than empty inventories. Preserve
|
||||
artifact SHA-256 and index widths. Unrecognized magic may mean encryption and
|
||||
is unsupported, not decrypted. Linking names to `GameAssembly` or
|
||||
`UnityFramework` code addresses remains a separate native-analysis question.
|
||||
|
||||
@@ -180,7 +180,7 @@ Additional tools and host support depend on the target:
|
||||
| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](docs/native-investigation.md) |
|
||||
| Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) |
|
||||
| Go binaries | Embedded compiler version, modules, replacements and build settings | Built-in static ELF/PE/thin Mach-O inspection; no Go installation required |
|
||||
| IL2CPP metadata | Unity global-metadata.dat assembly images, type and method names and index ranges | Built-in static decoding of versions 29, 31 and 39; no Unity required |
|
||||
| IL2CPP metadata | Unity global-metadata.dat assembly images, type and method names and index ranges | Built-in static decoding of versions 24, 27, 29, 31 and 39; no Unity required |
|
||||
| Godot PCK | Archive inventory and extraction, original header/directory facts and selected content identities | Built-in static formats 2, 3 and 4; no Godot required |
|
||||
| Rust binaries | Embedded cargo-auditable crate names, versions, sources and dependency edges | Built-in static ELF/PE/thin Mach-O inspection; [Rust binaries](docs/rust-binaries.md) |
|
||||
| WASM artifact | Validation, sections, exact import/export WAT forms and decoded WAT | Caller-supplied WABT 1.0.42; `inspect_wasm_artifact` |
|
||||
|
||||
+6
-5
@@ -109,11 +109,12 @@ Inspect Unity IL2CPP metadata names without Unity or a disassembler:
|
||||
rea inspect-il2cpp-metadata /absolute/path/global-metadata.dat --json
|
||||
```
|
||||
|
||||
The command decodes a version 29, 31 or 39 `global-metadata.dat` and reports assembly
|
||||
images, type and method names, index ranges, header sections and SHA-256. It
|
||||
does not decrypt metadata or link names to native code addresses. MCP defaults
|
||||
to compact counts with retained Evidence; query indexed rows with filters,
|
||||
batch indices and selected fields. The CLI defaults to complete Evidence;
|
||||
The command decodes a version 24, 27, 29, 31 or 39 `global-metadata.dat` and reports assembly
|
||||
images, type, method and member names, string literals, index ranges, header
|
||||
sections and SHA-256. It does not decrypt metadata or link names to native code
|
||||
addresses. MCP defaults to compact counts
|
||||
with retained Evidence; query indexed rows with filters, batch indices and
|
||||
selected fields. The CLI defaults to complete Evidence;
|
||||
`--detail summary --evidence_output FILE` exports the full result to a new file
|
||||
and returns a compact overview. See [IL2CPP metadata](il2cpp-metadata.md).
|
||||
|
||||
|
||||
+61
-24
@@ -2,16 +2,20 @@
|
||||
|
||||
`inspect_il2cpp_metadata` / `inspect-il2cpp-metadata` decodes one selected local
|
||||
Unity IL2CPP `global-metadata.dat` file and returns its assembly images, type
|
||||
definitions and method definitions by name, namespace, index range and count,
|
||||
definitions, methods and named members with their owners, plus managed string
|
||||
literals, by stable index, namespace, range and count,
|
||||
with the header sections and serialized index widths. It reads the file without
|
||||
running it and requires no Unity installation, IL2CPP binary, active target,
|
||||
Hopper, Ghidra or IDA.
|
||||
|
||||
Metadata versions 29, 31 and 39 are supported. For example, Unity 2022.3.30f1
|
||||
Metadata versions 24, 27, 29, 31 and 39 are supported. For example, Unity 2019.4.41f2
|
||||
wrote version 24, 2020.3.49f1 version 27, 2022.3.30f1
|
||||
wrote version 29, 2022.3.62f3 wrote 31 and 6000.4.2f1 wrote 39; other Unity
|
||||
releases that write these numbers have not been checked. Other versions are
|
||||
rejected as unsupported. The path must name a regular file; directories and
|
||||
symbolic links are rejected.
|
||||
rejected as unsupported. Version 24 decoding covers the 264-byte header layout
|
||||
used by the cited Unity 2019.4 build; earlier 24.x layouts are not inferred from
|
||||
the version number and are refused when their header or records differ. The path
|
||||
must name a regular file; directories and symbolic links are rejected.
|
||||
|
||||
## MCP
|
||||
|
||||
@@ -29,8 +33,9 @@ the session. MCP requires an absolute filesystem path:
|
||||
|
||||
The summary retains the complete decoded Evidence in the session and returns a
|
||||
linked `inspect_analysis_view` overview: artifact identity, metadata version,
|
||||
index widths and `counts` (`sections`, `images`, `types`, `methods` and
|
||||
`nested_types`) without rows. Use `normalized_result.parent_evidence_id` to page
|
||||
index widths and `counts` (`sections`, `images`, `types`, `methods`,
|
||||
`nested_types`, `fields`, `properties`, `events`, `parameters` and
|
||||
`string_literals`) without rows. Use `normalized_result.parent_evidence_id` to page
|
||||
the original rows without reopening or decoding the file:
|
||||
|
||||
```json
|
||||
@@ -48,11 +53,15 @@ the original rows without reopening or decoding the file:
|
||||
}
|
||||
```
|
||||
|
||||
Collections are `sections`, `images`, `types` and `methods`. Every returned row
|
||||
includes its source `index`, scoped by artifact SHA-256 and collection. All
|
||||
collections accept batch `indices`, exact `names` and `fields`. Types also accept
|
||||
`image_indices`, `namespaces` and `declaring_types`; methods accept
|
||||
`declaring_types`. Filters intersect, duplicate indices are returned once, and
|
||||
Collections are `sections`, `images`, `types`, `methods`, `fields`,
|
||||
`properties`, `events`, `parameters` and `string_literals`. Every returned row includes
|
||||
its source `index`, scoped by artifact SHA-256 and collection. All collections
|
||||
accept batch `indices` and `fields`; collections whose rows have a `name` also
|
||||
accept exact `names`. Types also accept `image_indices`, `namespaces` and
|
||||
`declaring_types`; methods, fields, properties and events accept
|
||||
`declaring_types`. Parameters accept exact `method_indices`, so a batch of
|
||||
method indices retrieves their parameters without reading the entire parameter
|
||||
table. Filters intersect, duplicate indices are returned once, and
|
||||
rows retain metadata order. An empty filter selects no rows; `fields: []`
|
||||
returns indices only. Omitted fields return the complete selected rows.
|
||||
|
||||
@@ -74,6 +83,29 @@ For example, after resolving a type index, retrieve selected method facts:
|
||||
}
|
||||
```
|
||||
|
||||
After finding method indices, parameters can be requested directly:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": {
|
||||
"kind": "retained-evidence",
|
||||
"evidence_id": "<parent_evidence_id>"
|
||||
},
|
||||
"view": {
|
||||
"kind": "il2cpp-metadata",
|
||||
"collection": "parameters",
|
||||
"method_indices": [9785],
|
||||
"fields": ["name", "method"],
|
||||
"limit": 64
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Historical Evidence that predates these member collections remains queryable.
|
||||
A missing collection has an unknown (`null`) summary count, and requesting it
|
||||
reports that it was not collected; it is never presented as an empty inventory.
|
||||
Existing empty collections retain a zero count. Full Evidence is preserved.
|
||||
|
||||
`offset` counts matching rows, not source indices. `limit` defaults to 64;
|
||||
`coverage.next_offset` continues that filtered page. Coverage describes retained
|
||||
metadata facts only; runtime behavior and correspondence to native code remain
|
||||
@@ -110,19 +142,24 @@ The result contains:
|
||||
|
||||
- `artifact`: path, SHA-256 and byte length;
|
||||
- `magic`: unsigned little-endian header signature (`0xFAB11BAF`, delivered as a JSON number);
|
||||
- `metadata_version`: `29`, `31` or `39`;
|
||||
- `metadata_version`: `24`, `27`, `29`, `31` or `39`;
|
||||
- `index_widths`: the serialized widths (1, 2 or 4 bytes) of
|
||||
`type_definition`, `generic_container`, `parameter` and `type` indices, and
|
||||
`type_width_source`. Version 39 derives the type index width from the
|
||||
`interface-offsets-record` or the `type-definition-record`; versions 29 and
|
||||
31 always use 4-byte indices (`fixed-width-version`);
|
||||
- `sections`: all 31 header sections in header order, each with `offset`, `size`
|
||||
and `count`. Versions 29 and 31 record no counts, so `count` is `null` and
|
||||
`interface-offsets-record` or the `type-definition-record`; versions 24, 27, 29
|
||||
and 31 always use 4-byte indices (`fixed-width-version`);
|
||||
- `sections`: all version-specific header sections in header order, each with `offset`, `size`
|
||||
and `count`. Versions 24 to 31 record no counts, so `count` is `null` and
|
||||
record counts come from the section sizes;
|
||||
- `images`: `name`, `type_start` and `type_count`;
|
||||
- `types`: `name`, `namespace`, `method_start`, `method_count`,
|
||||
`nested_type_start`, `nested_type_count` and `declaring_type`;
|
||||
- `methods`: `name` and `declaring_type`;
|
||||
- `fields`, `properties` and `events`: `name` and `declaring_type`, the type
|
||||
whose member range holds the row (`-1` when none does);
|
||||
- `parameters`: `name` and `method`, the method whose parameter range holds it;
|
||||
- `string_literals`: managed string literals as `value`, or `value_base64` with
|
||||
the exact bytes when they are not valid UTF-8;
|
||||
- `limitations`.
|
||||
|
||||
A row's position in its collection is its metadata index, so `type_start`,
|
||||
@@ -141,7 +178,7 @@ unneeded rows out of agent context without discarding the source Evidence.
|
||||
|
||||
A file whose magic is not `0xFAB11BAF` fails as an unsupported target with
|
||||
`unrecognized-magic`. The file may be encrypted or may not be IL2CPP metadata.
|
||||
A version other than 29, 31 or 39 fails as an unsupported target with
|
||||
A version other than 24, 27, 29, 31 or 39 fails as an unsupported target with
|
||||
`unsupported-version`.
|
||||
|
||||
Damaged metadata returns an input error (`invalid_format`) whose
|
||||
@@ -150,7 +187,7 @@ message names one reason:
|
||||
| Reason | Meaning |
|
||||
| ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| `truncated-header` | The file is shorter than the header. |
|
||||
| `unexpected-header-size` | `stringLiterals.offset` is not the header size: 256 bytes for versions 29 and 31, 380 for 39. |
|
||||
| `unexpected-header-size` | `stringLiterals.offset` is not the header size: 264 bytes for version 24, 256 for 27, 29 and 31, 380 for 39. |
|
||||
| `section-out-of-bounds` | A section extends beyond the file, or a non-empty section starts inside the header. |
|
||||
| `section-overlap` | Two sections overlap. Zero-size sections are exempt and gaps are allowed. |
|
||||
| `record-size-mismatch` | A section size does not divide into its records, or the record size differs from the one the index widths imply. |
|
||||
@@ -177,19 +214,19 @@ relationships were decoded. A file that changes during reading and a cancelled
|
||||
request keep distinct errors. Decode checkpoints yield for cancellation, and
|
||||
close waits for active work. Each reader admits one inspection at a time.
|
||||
Resource guards bound the selected file to 256 MiB and the encoded complete
|
||||
report to 64 MiB, checking JSON/UTF-8 expansion before serialization. MCP also applies its
|
||||
report to 64 MiB, checking row counts before allocation and JSON/UTF-8/base64 expansion during
|
||||
decoding, before serialization. Names and identical literal spans are decoded
|
||||
once within one stable file snapshot; no filesystem results are cached across
|
||||
inspections. Ownership assignment also yields to cancellation. MCP also applies its
|
||||
[response transport budget](mcp-contracts.md); a complete report within the
|
||||
report limit can still require the summary and paging path.
|
||||
|
||||
## Outside this tool
|
||||
|
||||
- Decrypting or deobfuscating protected metadata.
|
||||
- Linking metadata to the native binary (`GameAssembly`, `UnityFramework`),
|
||||
including method RVAs and code addresses.
|
||||
- Fields, properties, signatures, parameters and generic instances.
|
||||
- Ghidra or other disassembler integration. Use the
|
||||
- Native code addresses, member types and method signatures; use the
|
||||
[native workflow](native-investigation.md) for native code.
|
||||
- Metadata versions other than 29, 31 and 39.
|
||||
- Metadata versions other than 24, 27, 29, 31 and 39.
|
||||
|
||||
Names come from the metadata file only. They do not establish which methods
|
||||
were compiled into the native binary or how they behave at runtime.
|
||||
|
||||
@@ -15,7 +15,7 @@ import { hashVerifierFile } from "./lib/verifier-file-resources.mjs";
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
console.log(
|
||||
"Usage: npm run verify:il2cpp:metadata -- [--metadata PATH]\nPATH is a caller-owned, unencrypted global-metadata.dat (version 29, 31 or 39). Without --metadata, only the advertised contract is verified.",
|
||||
"Usage: npm run verify:il2cpp:metadata -- [--metadata PATH]\nPATH is a caller-owned, unencrypted global-metadata.dat (version 24, 27, 29, 31 or 39). Without --metadata, only the advertised contract is verified.",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
@@ -120,6 +120,11 @@ try {
|
||||
methods: report.methods.length,
|
||||
nested_types: report.types.filter((type) => type.declaring_type !== -1)
|
||||
.length,
|
||||
fields: report.fields.length,
|
||||
properties: report.properties.length,
|
||||
events: report.events.length,
|
||||
parameters: report.parameters.length,
|
||||
string_literals: report.string_literals.length,
|
||||
});
|
||||
const page = analysisViewResultSchema.parse(
|
||||
(
|
||||
|
||||
@@ -41,6 +41,11 @@ const report: Il2cppMetadata = {
|
||||
images: [],
|
||||
types: [],
|
||||
methods: [],
|
||||
fields: [],
|
||||
properties: [],
|
||||
events: [],
|
||||
parameters: [],
|
||||
string_literals: [],
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -100,7 +100,13 @@ export class Il2cppMetadataService {
|
||||
name === "typeDefinitions" ||
|
||||
name === "methods" ||
|
||||
name === "nestedTypes" ||
|
||||
name === "strings"),
|
||||
name === "strings" ||
|
||||
name === "fields" ||
|
||||
name === "properties" ||
|
||||
name === "events" ||
|
||||
name === "parameters" ||
|
||||
name === "stringLiterals" ||
|
||||
name === "stringLiteralData"),
|
||||
)
|
||||
.map(({ offset, size }) => ({
|
||||
kind: "file-offset-range" as const,
|
||||
|
||||
@@ -18,11 +18,11 @@ export const registerIl2cppCommands = (
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.inspectIl2cppMetadata, {
|
||||
description:
|
||||
"Inventory IL2CPP global-metadata.dat images, types and methods",
|
||||
"Inventory IL2CPP global-metadata.dat names, owners and string literals",
|
||||
args: z.object({
|
||||
path: z
|
||||
.string()
|
||||
.describe("Local global-metadata.dat (version 29, 31 or 39)"),
|
||||
.describe("Local global-metadata.dat (version 24, 27, 29, 31 or 39)"),
|
||||
}),
|
||||
options: z.object(analysisEvidenceDeliveryOptions),
|
||||
run: ({ args, options }) =>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,7 @@ export const IL2CPP_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_il2cpp_metadata"),
|
||||
kind: "artifact-provider",
|
||||
description:
|
||||
"Inspect a local Unity IL2CPP global-metadata.dat (versions 29, 31 and 39). Default summary retains complete Evidence and returns artifact identity, version and counts and parent_evidence_id. Query inspect_analysis_view kind il2cpp-metadata, collection sections/images/types/methods, with exact indices or names, fields and pagination. Types also accept image_indices, namespaces and declaring_types; methods accept declaring_types. Filters intersect and empty arrays select none. Each row always keeps index, scoped to its artifact SHA-256 and collection; page offsets count matching rows in metadata order. Explicit detail complete returns all rows. Reads existing files, with 256 MiB input and 64 MiB encoded-report guards and one active inspection per reader. Unsupported versions and malformed metadata return reasons. Type declaring_type is derived from nestedTypes; native code addresses and runtime behavior remain unknown.",
|
||||
"Inspect a local Unity IL2CPP global-metadata.dat (versions 24, 27, 29, 31 and 39). Default summary retains complete Evidence and returns artifact identity, version and counts and parent_evidence_id. Query inspect_analysis_view kind il2cpp-metadata, collection sections/images/types/methods/fields/properties/events/parameters/string_literals, with exact indices or names, fields and pagination. Types also accept image_indices, namespaces and declaring_types; methods, fields, properties and events accept declaring_types. Parameters accept method_indices. Filters intersect and empty arrays select none. Each row always keeps index, scoped to its artifact SHA-256 and collection; page offsets count matching rows in metadata order. Explicit detail complete returns all rows. Reads existing files, with 256 MiB input and 64 MiB encoded-report guards and one active inspection per reader. Unsupported versions and malformed metadata return reasons. Type declaring_type is derived from nestedTypes, and member and parameter owners from index ranges; native code addresses and runtime behavior remain unknown.",
|
||||
inputSchema: inspectIl2cppMetadataInputSchema,
|
||||
outputSchema: evidenceResultOf(
|
||||
z.union([il2cppMetadataSchema, il2cppMetadataSummaryViewResultSchema]),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import {
|
||||
IL2CPP_METADATA_SECTION_NAMES,
|
||||
il2cppMetadataSchema,
|
||||
il2cppMetadataParentSchema,
|
||||
type Il2cppMetadata,
|
||||
} from "../il2cpp/il2cppMetadata.js";
|
||||
import { createEvidence } from "../evidence.js";
|
||||
import { freezeJsonSnapshot } from "../immutableJson.js";
|
||||
import { jsonObjectSchema } from "../jsonValue.js";
|
||||
import { projectIl2cppMetadataView } from "./il2cppMetadataView.js";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -55,6 +56,11 @@ const report: Il2cppMetadata = {
|
||||
},
|
||||
],
|
||||
methods: [{ name: "Run", declaring_type: 0 }],
|
||||
fields: [{ name: "value", declaring_type: 0 }],
|
||||
properties: [],
|
||||
events: [],
|
||||
parameters: [{ name: "times", method: 0 }],
|
||||
string_literals: [{ value: "Go", value_base64: null }],
|
||||
limitations: [],
|
||||
};
|
||||
|
||||
@@ -74,27 +80,139 @@ const page = {
|
||||
} as const;
|
||||
|
||||
it("validates a sealed parent once across pages and keeps counts exact", () => {
|
||||
const parse = vi.spyOn(il2cppMetadataSchema, "safeParse");
|
||||
const parse = vi.spyOn(il2cppMetadataParentSchema, "safeParse");
|
||||
const sealed = parent(freezeJsonSnapshot(structuredClone(report)), true);
|
||||
for (const offset of [0, 1]) {
|
||||
const view = projectIl2cppMetadataView(sealed, { ...page, offset });
|
||||
if (!view.ok) throw view.error;
|
||||
expect(view.value).toMatchObject({
|
||||
items: [{ index: offset, ...report.types[offset] }],
|
||||
counts: { images: 1, types: 2, methods: 1, nested_types: 1 },
|
||||
counts: {
|
||||
sections: report.sections.length,
|
||||
images: 1,
|
||||
types: 2,
|
||||
methods: 1,
|
||||
nested_types: 1,
|
||||
fields: 1,
|
||||
properties: 0,
|
||||
events: 0,
|
||||
parameters: 1,
|
||||
string_literals: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(parse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("revalidates unsealed inline input on every page", () => {
|
||||
const parse = vi.spyOn(il2cppMetadataSchema, "safeParse");
|
||||
const parse = vi.spyOn(il2cppMetadataParentSchema, "safeParse");
|
||||
const inline = parent(structuredClone(report), false);
|
||||
projectIl2cppMetadataView(inline, page);
|
||||
projectIl2cppMetadataView(inline, page);
|
||||
expect(parse).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("preserves historical omission as unknown while keeping existing metadata queries usable", () => {
|
||||
const historical = jsonObjectSchema.parse(report);
|
||||
for (const key of [
|
||||
"fields",
|
||||
"properties",
|
||||
"events",
|
||||
"parameters",
|
||||
"string_literals",
|
||||
])
|
||||
delete historical[key];
|
||||
const source = parent(freezeJsonSnapshot(historical), true);
|
||||
const summary = projectIl2cppMetadataView(source, { kind: "summary" });
|
||||
if (!summary.ok) throw summary.error;
|
||||
expect(summary.value).toMatchObject({
|
||||
summary: {
|
||||
counts: { types: 2, methods: 1, fields: null, parameters: null },
|
||||
},
|
||||
unknowns: expect.arrayContaining([
|
||||
expect.stringContaining("did not collect fields"),
|
||||
]),
|
||||
});
|
||||
const existing = projectIl2cppMetadataView(source, page);
|
||||
if (!existing.ok) throw existing.error;
|
||||
expect(existing.value).toMatchObject({
|
||||
items: [{ index: 0, name: "Outer" }],
|
||||
});
|
||||
const unavailable = projectIl2cppMetadataView(source, {
|
||||
kind: "il2cpp-metadata",
|
||||
collection: "parameters",
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
});
|
||||
expect(unavailable.ok).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ fields: [{ name: "bad", declaring_type: 2 }] },
|
||||
{ parameters: [{ name: "bad", method: 1 }] },
|
||||
{ string_literals: [{ value: null, value_base64: null }] },
|
||||
{ string_literals: [{ value: "Go", value_base64: "R28=" }] },
|
||||
])(
|
||||
"rejects inconsistent member ownership or literal representation in authenticated parent facts: %j",
|
||||
(changed) => {
|
||||
const result = projectIl2cppMetadataView(
|
||||
parent({ ...report, ...changed }, false),
|
||||
{ kind: "summary" },
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("selects a batch of parameter owners before paging and intersects exact identities and names", () => {
|
||||
const source = parent(
|
||||
freezeJsonSnapshot({
|
||||
...report,
|
||||
parameters: [
|
||||
{ name: "first", method: 0 },
|
||||
{ name: "unclaimed", method: -1 },
|
||||
{ name: "last", method: 0 },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const query = {
|
||||
kind: "il2cpp-metadata",
|
||||
collection: "parameters",
|
||||
method_indices: [0, 0, 99],
|
||||
fields: ["name", "method"],
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
} as const;
|
||||
const result = projectIl2cppMetadataView(source, {
|
||||
...query,
|
||||
method_indices: [...query.method_indices],
|
||||
fields: [...query.fields],
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value).toMatchObject({
|
||||
items: [{ index: 2, name: "last", method: 0 }],
|
||||
coverage: { total: 2, exhausted: true, next_offset: null },
|
||||
});
|
||||
for (const selection of [
|
||||
{ method_indices: [] },
|
||||
{ method_indices: [99] },
|
||||
{ method_indices: [0], indices: [1] },
|
||||
{ method_indices: [0], names: ["unclaimed"] },
|
||||
]) {
|
||||
const empty = projectIl2cppMetadataView(source, {
|
||||
...query,
|
||||
...selection,
|
||||
offset: 0,
|
||||
fields: [...query.fields],
|
||||
});
|
||||
if (!empty.ok) throw empty.error;
|
||||
expect(empty.value).toMatchObject({
|
||||
items: [],
|
||||
coverage: { total: 0, exhausted: true },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("intersects batch identities, fields, image ranges and exact names in source order", () => {
|
||||
const source = parent(freezeJsonSnapshot(structuredClone(report)), true);
|
||||
const result = projectIl2cppMetadataView(source, {
|
||||
|
||||
@@ -5,10 +5,14 @@ import type { JsonValue } from "../jsonValue.js";
|
||||
import { isImmutableJsonSnapshot } from "../immutableJson.js";
|
||||
import {
|
||||
il2cppMetadataImageSchema,
|
||||
il2cppMetadataMemberSchema,
|
||||
il2cppMetadataMethodSchema,
|
||||
il2cppMetadataSectionSchema,
|
||||
il2cppMetadataParameterSchema,
|
||||
il2cppMetadataStringLiteralSchema,
|
||||
il2cppMetadataObjectSchema,
|
||||
il2cppMetadataSchema,
|
||||
il2cppMetadataParentSchema,
|
||||
il2cppMetadataMemberCollections,
|
||||
il2cppMetadataTypeSchema,
|
||||
} from "../il2cpp/il2cppMetadata.js";
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
@@ -29,10 +33,25 @@ const countsSchema = z.strictObject({
|
||||
types: z.number().int().nonnegative(),
|
||||
methods: z.number().int().nonnegative(),
|
||||
nested_types: z.number().int().nonnegative(),
|
||||
fields: z.number().int().nonnegative().nullable(),
|
||||
properties: z.number().int().nonnegative().nullable(),
|
||||
events: z.number().int().nonnegative().nullable(),
|
||||
parameters: z.number().int().nonnegative().nullable(),
|
||||
string_literals: z.number().int().nonnegative().nullable(),
|
||||
});
|
||||
|
||||
export const il2cppMetadataSummarySchema = il2cppMetadataObjectSchema
|
||||
.omit({ images: true, types: true, methods: true, sections: true })
|
||||
.omit({
|
||||
sections: true,
|
||||
images: true,
|
||||
types: true,
|
||||
methods: true,
|
||||
fields: true,
|
||||
properties: true,
|
||||
events: true,
|
||||
parameters: true,
|
||||
string_literals: true,
|
||||
})
|
||||
.extend({ counts: countsSchema });
|
||||
|
||||
const indexSchema = z.number().int().nonnegative();
|
||||
@@ -44,13 +63,22 @@ const typeRowSchema = il2cppMetadataTypeSchema.extend({ index: indexSchema });
|
||||
const methodRowSchema = il2cppMetadataMethodSchema.extend({
|
||||
index: indexSchema,
|
||||
});
|
||||
const page = {
|
||||
const memberRowSchema = il2cppMetadataMemberSchema.extend({
|
||||
index: indexSchema,
|
||||
});
|
||||
const parameterRowSchema = il2cppMetadataParameterSchema.extend({
|
||||
index: indexSchema,
|
||||
});
|
||||
const stringLiteralRowSchema = il2cppMetadataStringLiteralSchema.extend({
|
||||
index: indexSchema,
|
||||
});
|
||||
const indexPage = {
|
||||
kind: z.literal("il2cpp-metadata"),
|
||||
indices: z.array(indexSchema).optional(),
|
||||
names: z.array(z.string()).optional(),
|
||||
offset: indexSchema.default(0),
|
||||
limit: z.number().int().positive().default(64),
|
||||
};
|
||||
const page = { ...indexPage, names: z.array(z.string()).optional() };
|
||||
const declaringTypes = z.array(z.number().int().min(-1)).optional();
|
||||
|
||||
/** Filters intersect; pages use metadata order and always retain the source row index. */
|
||||
@@ -81,6 +109,25 @@ export const il2cppMetadataViewRequestSchema = z.discriminatedUnion(
|
||||
declaring_types: declaringTypes,
|
||||
fields: z.array(methodRowSchema.keyof()).optional(),
|
||||
}),
|
||||
...(["fields", "properties", "events"] as const).map((collection) =>
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal(collection),
|
||||
declaring_types: declaringTypes,
|
||||
fields: z.array(memberRowSchema.keyof()).optional(),
|
||||
}),
|
||||
),
|
||||
z.strictObject({
|
||||
...page,
|
||||
collection: z.literal("parameters"),
|
||||
method_indices: z.array(indexSchema).optional(),
|
||||
fields: z.array(parameterRowSchema.keyof()).optional(),
|
||||
}),
|
||||
z.strictObject({
|
||||
...indexPage,
|
||||
collection: z.literal("string_literals"),
|
||||
fields: z.array(stringLiteralRowSchema.keyof()).optional(),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -89,6 +136,9 @@ export const il2cppMetadataViewItemsSchema = z.union([
|
||||
z.array(imageRowSchema.partial().required({ index: true })),
|
||||
z.array(typeRowSchema.partial().required({ index: true })),
|
||||
z.array(methodRowSchema.partial().required({ index: true })),
|
||||
z.array(memberRowSchema.partial().required({ index: true })),
|
||||
z.array(parameterRowSchema.partial().required({ index: true })),
|
||||
z.array(stringLiteralRowSchema.partial().required({ index: true })),
|
||||
]);
|
||||
|
||||
export const il2cppMetadataViewContextSchema = z.strictObject({
|
||||
@@ -96,9 +146,9 @@ export const il2cppMetadataViewContextSchema = z.strictObject({
|
||||
counts: countsSchema,
|
||||
});
|
||||
|
||||
type Report = z.infer<typeof il2cppMetadataSchema>;
|
||||
type Report = z.infer<typeof il2cppMetadataParentSchema>;
|
||||
type Request = z.infer<typeof il2cppMetadataViewRequestSchema>;
|
||||
type Row = Report[Request["collection"]][number];
|
||||
type Row = NonNullable<Report[Request["collection"]]>[number];
|
||||
interface CheckedReport {
|
||||
readonly report: Report;
|
||||
readonly counts: z.infer<typeof countsSchema>;
|
||||
@@ -119,7 +169,7 @@ const parseMetadataReport = (
|
||||
isImmutableJsonSnapshot(key);
|
||||
const cached = reusable ? checkedReports.get(key) : undefined;
|
||||
if (cached !== undefined) return ok(cached);
|
||||
const parsed = parseAnalysisViewResult(il2cppMetadataSchema, parent);
|
||||
const parsed = parseAnalysisViewResult(il2cppMetadataParentSchema, parent);
|
||||
if (!parsed.ok) return parsed;
|
||||
// This schema has no coercions/transforms; the authenticated owner value is
|
||||
// equivalent to the validated clone and remains immutable across pages.
|
||||
@@ -135,6 +185,11 @@ const parseMetadataReport = (
|
||||
(count, type) => count + Number(type.declaring_type !== -1),
|
||||
0,
|
||||
),
|
||||
fields: report.fields?.length ?? null,
|
||||
properties: report.properties?.length ?? null,
|
||||
events: report.events?.length ?? null,
|
||||
parameters: report.parameters?.length ?? null,
|
||||
string_literals: report.string_literals?.length ?? null,
|
||||
},
|
||||
};
|
||||
if (reusable) checkedReports.set(key, checked);
|
||||
@@ -168,12 +223,18 @@ const selectFields = (
|
||||
|
||||
/** Compile caller selections once; large batches do not multiply per-row work. */
|
||||
const selection = (view: Request, report: Report) => {
|
||||
const names = view.names === undefined ? undefined : new Set(view.names);
|
||||
const names =
|
||||
!("names" in view) || view.names === undefined
|
||||
? undefined
|
||||
: new Set(view.names);
|
||||
const owners =
|
||||
(view.collection !== "types" && view.collection !== "methods") ||
|
||||
view.declaring_types === undefined
|
||||
!("declaring_types" in view) || view.declaring_types === undefined
|
||||
? undefined
|
||||
: new Set(view.declaring_types);
|
||||
const methods =
|
||||
view.collection === "parameters" && view.method_indices !== undefined
|
||||
? new Set(view.method_indices)
|
||||
: undefined;
|
||||
const namespaces =
|
||||
view.collection !== "types" || view.namespaces === undefined
|
||||
? undefined
|
||||
@@ -203,7 +264,11 @@ const selection = (view: Request, report: Report) => {
|
||||
}
|
||||
}
|
||||
return (row: Row, index: number) => {
|
||||
if (names !== undefined && !names.has(row.name)) return false;
|
||||
if (
|
||||
names !== undefined &&
|
||||
(!("name" in row) || row.name === null || !names.has(row.name))
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
owners !== undefined &&
|
||||
(!("declaring_type" in row) || !owners.has(row.declaring_type))
|
||||
@@ -214,6 +279,11 @@ const selection = (view: Request, report: Report) => {
|
||||
(!("namespace" in row) || !namespaces.has(row.namespace))
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
methods !== undefined &&
|
||||
(!("method" in row) || !methods.has(row.method))
|
||||
)
|
||||
return false;
|
||||
if (imageIndices === undefined) return true;
|
||||
let lower = 0;
|
||||
let upper = ranges.length;
|
||||
@@ -239,7 +309,7 @@ export const projectIl2cppMetadataView = (
|
||||
path: ["view"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"IL2CPP metadata Evidence supports summary and selected sections/images/types/methods pages.",
|
||||
"IL2CPP metadata Evidence supports summary and selected sections, images, types, methods, member, parameter and string literal pages.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -262,7 +332,29 @@ export const projectIl2cppMetadataView = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
const { sections, images, types, methods, ...identity } = report;
|
||||
const {
|
||||
sections,
|
||||
images,
|
||||
types,
|
||||
methods,
|
||||
fields,
|
||||
properties,
|
||||
events,
|
||||
parameters,
|
||||
string_literals,
|
||||
...identity
|
||||
} = report;
|
||||
const allRows = {
|
||||
sections,
|
||||
images,
|
||||
types,
|
||||
methods,
|
||||
fields,
|
||||
properties,
|
||||
events,
|
||||
parameters,
|
||||
string_literals,
|
||||
};
|
||||
const shared = {
|
||||
parent_evidence_id: parent.evidenceId,
|
||||
parent_operation: "inspect_il2cpp_metadata" as const,
|
||||
@@ -270,7 +362,13 @@ export const projectIl2cppMetadataView = (
|
||||
artifact: { path: report.artifact.path, sha256: report.artifact.sha256 },
|
||||
limitations: [...parent.limitations],
|
||||
unknowns: [
|
||||
"Counts and page exhaustion cover retained metadata facts, not runtime loading or native code addresses.",
|
||||
...il2cppMetadataMemberCollections
|
||||
.filter((name) => report[name] === undefined)
|
||||
.map(
|
||||
(name) =>
|
||||
`Retained Evidence did not collect ${name}; its count is unknown. Reinspect the metadata to acquire these facts.`,
|
||||
),
|
||||
"Names come from unencrypted metadata only; native code addresses and runtime registration remain unknown.",
|
||||
...(parent.subject == null
|
||||
? [
|
||||
"The parent has no recorded subject; its normalized artifact identity cannot be cross-checked against a subject digest.",
|
||||
@@ -286,9 +384,19 @@ export const projectIl2cppMetadataView = (
|
||||
summary: { ...identity, counts },
|
||||
coverage: completeWithinViewCoverage(1, 1),
|
||||
});
|
||||
const rows = { sections, images, types, methods }[view.collection];
|
||||
const rows = allRows[view.collection];
|
||||
if (rows === undefined)
|
||||
return err(
|
||||
analysisViewInputError([
|
||||
{
|
||||
path: ["view", "collection"],
|
||||
reason: "invalid_value",
|
||||
message: `Retained Evidence did not collect ${view.collection}; inspect the metadata again to acquire these facts.`,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const matches = selection(view, report);
|
||||
const fields =
|
||||
const selectedFields =
|
||||
view.fields === undefined ? undefined : new Set<string>(view.fields);
|
||||
const items = [];
|
||||
let total = 0;
|
||||
@@ -296,7 +404,7 @@ export const projectIl2cppMetadataView = (
|
||||
const row = rows[index]!;
|
||||
if (!matches(row, index)) continue;
|
||||
if (total >= view.offset && items.length < view.limit)
|
||||
items.push(selectFields(row, index, fields));
|
||||
items.push(selectFields(row, index, selectedFields));
|
||||
total++;
|
||||
}
|
||||
return ok({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { digestSchema } from "../digests.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { canonicalBase64Schema } from "../stringPatterns.js";
|
||||
|
||||
/** Unsigned little-endian signature observed at file offset zero. */
|
||||
export const IL2CPP_METADATA_MAGIC = 0xfab11baf;
|
||||
@@ -40,8 +41,43 @@ export const IL2CPP_METADATA_SECTION_NAMES = [
|
||||
"exportedTypeDefinitions",
|
||||
] as const;
|
||||
|
||||
/** Decoded metadata versions; 29 and 31 use fixed 4-byte indices. */
|
||||
export const IL2CPP_METADATA_VERSIONS = [29, 31, 39] as const;
|
||||
/** Version 27 names for the four custom-attribute and indirect-call sections. */
|
||||
export const IL2CPP_METADATA_V27_SECTION_NAMES = [
|
||||
...IL2CPP_METADATA_SECTION_NAMES.slice(0, 24),
|
||||
"attributesInfo",
|
||||
"attributeTypes",
|
||||
"unresolvedVirtualCallParameterTypes",
|
||||
"unresolvedVirtualCallParameterRanges",
|
||||
...IL2CPP_METADATA_SECTION_NAMES.slice(28),
|
||||
] as const;
|
||||
|
||||
/** Version 24 header: 32 (offset, byte size) pairs in this order. */
|
||||
export const IL2CPP_METADATA_V24_SECTION_NAMES = [
|
||||
...IL2CPP_METADATA_SECTION_NAMES.slice(0, 22),
|
||||
"metadataUsageLists",
|
||||
"metadataUsagePairs",
|
||||
"fieldRefs",
|
||||
"referencedAssemblies",
|
||||
"attributesInfo",
|
||||
"attributeTypes",
|
||||
"unresolvedVirtualCallParameterTypes",
|
||||
"unresolvedVirtualCallParameterRanges",
|
||||
"windowsRuntimeTypeNames",
|
||||
"exportedTypeDefinitions",
|
||||
] as const;
|
||||
|
||||
/** Decoded metadata versions; all but 39 use fixed 4-byte indices. */
|
||||
export const IL2CPP_METADATA_VERSIONS = [24, 27, 29, 31, 39] as const;
|
||||
|
||||
/** Header section names in order for one metadata version. */
|
||||
export const il2cppMetadataSectionNames = (
|
||||
version: number,
|
||||
): readonly IL2cppMetadataSectionName[] =>
|
||||
version === 24
|
||||
? IL2CPP_METADATA_V24_SECTION_NAMES
|
||||
: version === 27
|
||||
? IL2CPP_METADATA_V27_SECTION_NAMES
|
||||
: IL2CPP_METADATA_SECTION_NAMES;
|
||||
|
||||
const count = z.number().int().nonnegative();
|
||||
/** Serialized metadata indices use -1 for an absent reference. */
|
||||
@@ -57,12 +93,24 @@ export const inspectIl2cppMetadataInputSchema = z.strictObject({
|
||||
.enum(["complete", "summary"])
|
||||
.default("summary")
|
||||
.describe(
|
||||
"summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type and method row inline",
|
||||
"summary (default) retains complete Evidence and returns identity and collection counts; complete returns every image, type, method, member, parameter and string-literal row inline",
|
||||
),
|
||||
});
|
||||
|
||||
export type IL2cppMetadataSectionName = z.infer<
|
||||
typeof il2cppMetadataSectionSchema
|
||||
>["name"];
|
||||
|
||||
export const il2cppMetadataSectionSchema = z.strictObject({
|
||||
name: z.enum(IL2CPP_METADATA_SECTION_NAMES),
|
||||
name: z.enum([
|
||||
...IL2CPP_METADATA_SECTION_NAMES,
|
||||
"attributesInfo",
|
||||
"attributeTypes",
|
||||
"unresolvedVirtualCallParameterTypes",
|
||||
"unresolvedVirtualCallParameterRanges",
|
||||
"metadataUsageLists",
|
||||
"metadataUsagePairs",
|
||||
]),
|
||||
offset: count,
|
||||
size: count,
|
||||
/** Header-recorded entry count; versions 29 and 31 record none. */
|
||||
@@ -98,6 +146,26 @@ export const il2cppMetadataMethodSchema = z.strictObject({
|
||||
),
|
||||
});
|
||||
|
||||
/** Field, property or event name and the type whose member range holds it. */
|
||||
export const il2cppMetadataMemberSchema = z.strictObject({
|
||||
name: z.string(),
|
||||
/** -1 when no type range claims the row. */
|
||||
declaring_type: optionalIndex,
|
||||
});
|
||||
|
||||
/** Parameter name and the method whose parameter range holds it. */
|
||||
export const il2cppMetadataParameterSchema = z.strictObject({
|
||||
name: z.string(),
|
||||
/** -1 when no method range claims the row. */
|
||||
method: optionalIndex,
|
||||
});
|
||||
|
||||
/** Managed string literal; undecodable UTF-8 keeps its exact bytes instead. */
|
||||
export const il2cppMetadataStringLiteralSchema = z.strictObject({
|
||||
value: z.string().nullable(),
|
||||
value_base64: canonicalBase64Schema.nullable(),
|
||||
});
|
||||
|
||||
/** Unrefined report fields, shared with projections that omit row collections. */
|
||||
export const il2cppMetadataObjectSchema = z.strictObject({
|
||||
artifact: z.strictObject({
|
||||
@@ -120,34 +188,94 @@ export const il2cppMetadataObjectSchema = z.strictObject({
|
||||
"fixed-width-version",
|
||||
]),
|
||||
}),
|
||||
sections: z
|
||||
.array(il2cppMetadataSectionSchema)
|
||||
.length(IL2CPP_METADATA_SECTION_NAMES.length),
|
||||
sections: z.array(il2cppMetadataSectionSchema),
|
||||
images: z.array(il2cppMetadataImageSchema),
|
||||
types: z.array(il2cppMetadataTypeSchema),
|
||||
methods: z.array(il2cppMetadataMethodSchema),
|
||||
fields: z.array(il2cppMetadataMemberSchema),
|
||||
properties: z.array(il2cppMetadataMemberSchema),
|
||||
events: z.array(il2cppMetadataMemberSchema),
|
||||
parameters: z.array(il2cppMetadataParameterSchema),
|
||||
string_literals: z.array(il2cppMetadataStringLiteralSchema),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
/** Complete name and count inventory of one decoded global-metadata.dat file. */
|
||||
export const il2cppMetadataSchema = il2cppMetadataObjectSchema.superRefine(
|
||||
(report, context) => {
|
||||
report.sections.forEach((section, index) => {
|
||||
if (section.name !== IL2CPP_METADATA_SECTION_NAMES[index])
|
||||
export const il2cppMetadataMemberCollections = [
|
||||
"fields",
|
||||
"properties",
|
||||
"events",
|
||||
"parameters",
|
||||
"string_literals",
|
||||
] as const;
|
||||
|
||||
/** Historical reports retain omission instead of inventing empty member inventories. */
|
||||
const il2cppMetadataParentObjectSchema = il2cppMetadataObjectSchema.partial({
|
||||
fields: true,
|
||||
properties: true,
|
||||
events: true,
|
||||
parameters: true,
|
||||
string_literals: true,
|
||||
});
|
||||
|
||||
const validateMetadataFacts = (
|
||||
report: z.infer<typeof il2cppMetadataParentObjectSchema>,
|
||||
context: z.RefinementCtx,
|
||||
): void => {
|
||||
const names = il2cppMetadataSectionNames(report.metadata_version);
|
||||
if (report.sections.length !== names.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["sections"],
|
||||
message: `Version ${report.metadata_version} headers have ${names.length} sections.`,
|
||||
});
|
||||
report.sections.forEach((section, index) => {
|
||||
if (section.name !== names[index])
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["sections", index, "name"],
|
||||
message: "Sections must follow header order.",
|
||||
});
|
||||
if (section.offset + section.size > report.artifact.bytes)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["sections", index],
|
||||
message: "Section range is outside the selected artifact.",
|
||||
});
|
||||
});
|
||||
for (const collection of ["fields", "properties", "events"] as const)
|
||||
report[collection]?.forEach((row, index) => {
|
||||
if (row.declaring_type >= report.types.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["sections", index, "name"],
|
||||
message: "Sections must follow header order.",
|
||||
});
|
||||
if (section.offset + section.size > report.artifact.bytes)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["sections", index],
|
||||
message: "Section range is outside the selected artifact.",
|
||||
path: [collection, index, "declaring_type"],
|
||||
message: "Member owner is outside the retained type definitions.",
|
||||
});
|
||||
});
|
||||
},
|
||||
report.parameters?.forEach((row, index) => {
|
||||
if (row.method >= report.methods.length)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["parameters", index, "method"],
|
||||
message: "Parameter owner is outside the retained method definitions.",
|
||||
});
|
||||
});
|
||||
report.string_literals?.forEach((row, index) => {
|
||||
if ((row.value === null) === (row.value_base64 === null))
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["string_literals", index],
|
||||
message:
|
||||
"A literal must retain exactly one UTF-8 or byte-exact base64 representation.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const il2cppMetadataSchema = il2cppMetadataObjectSchema.superRefine(
|
||||
validateMetadataFacts,
|
||||
);
|
||||
export const il2cppMetadataParentSchema =
|
||||
il2cppMetadataParentObjectSchema.superRefine(validateMetadataFacts);
|
||||
|
||||
export type InspectIl2cppMetadataInput = z.infer<
|
||||
typeof inspectIl2cppMetadataInputSchema
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { jsonStringByteLength } from "../domain/jsonStringByteLength.js";
|
||||
import type {
|
||||
Il2cppMetadataReadOptions,
|
||||
Il2cppMetadataRowCounts,
|
||||
} from "./Il2cppMetadataFile.js";
|
||||
|
||||
// Minimum serialized row shapes; actual integer widths and the report envelope
|
||||
// are checked exactly after decoding. This lower bound never refuses a fitting report.
|
||||
const emptyRows = {
|
||||
images: { name: "", type_start: 0, type_count: 0 },
|
||||
types: {
|
||||
name: "",
|
||||
namespace: "",
|
||||
method_start: 0,
|
||||
method_count: 0,
|
||||
nested_type_start: 0,
|
||||
nested_type_count: 0,
|
||||
declaring_type: 0,
|
||||
},
|
||||
methods: { name: "", declaring_type: 0 },
|
||||
fields: { name: "", declaring_type: 0 },
|
||||
properties: { name: "", declaring_type: 0 },
|
||||
events: { name: "", declaring_type: 0 },
|
||||
parameters: { name: "", method: 0 },
|
||||
string_literals: { value: "", value_base64: null },
|
||||
};
|
||||
|
||||
/** Per-snapshot admission, before text expansion and each retained fact. */
|
||||
export const createIl2cppMetadataAdmission = (
|
||||
limit: number,
|
||||
exceed: () => never,
|
||||
): Required<Il2cppMetadataReadOptions> => {
|
||||
let used = 0;
|
||||
// Keys/decoded names/literal spans are immutable within this one decoder run.
|
||||
const lengths = new Map<string, number>();
|
||||
const text = (value: string): number => {
|
||||
const cached = lengths.get(value);
|
||||
if (cached !== undefined) return cached;
|
||||
const length = jsonStringByteLength(value, limit - used);
|
||||
lengths.set(value, length);
|
||||
return length;
|
||||
};
|
||||
return {
|
||||
beforeRows(counts: Il2cppMetadataRowCounts) {
|
||||
let minimum = 0;
|
||||
for (const key of Object.keys(emptyRows) as (keyof typeof emptyRows)[])
|
||||
minimum += counts[key] * JSON.stringify(emptyRows[key]).length;
|
||||
if (minimum > limit) exceed();
|
||||
},
|
||||
beforeText(bytes) {
|
||||
if (bytes > limit - used) exceed();
|
||||
},
|
||||
onRow(row) {
|
||||
let bytes = 2,
|
||||
first = true;
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
bytes += (first ? 0 : 1) + text(key) + 1;
|
||||
first = false;
|
||||
bytes +=
|
||||
typeof value === "string" ? text(value) : value === null ? 4 : 1;
|
||||
if (used + bytes > limit) exceed();
|
||||
}
|
||||
used += bytes;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,54 +1,24 @@
|
||||
import {
|
||||
checkRange,
|
||||
Il2cppMetadataFormatFailure,
|
||||
} from "./Il2cppMetadataFormat.js";
|
||||
import {
|
||||
IL2CPP_METADATA_MAGIC,
|
||||
IL2CPP_METADATA_SECTION_NAMES,
|
||||
IL2CPP_METADATA_VERSIONS,
|
||||
il2cppMetadataSectionNames,
|
||||
type Il2cppMetadata,
|
||||
type Il2cppMetadataSection,
|
||||
type IL2cppMetadataSectionName,
|
||||
} from "../domain/il2cpp/il2cppMetadata.js";
|
||||
import {
|
||||
readIl2cppMembers,
|
||||
readIl2cppMemberTables,
|
||||
type MetadataOwnerRange,
|
||||
} from "./Il2cppMetadataMembers.js";
|
||||
|
||||
const NESTED_TYPE_ENTRY_BYTES = 4;
|
||||
|
||||
/** Stable machine-readable reason for each rejected input. */
|
||||
export const IL2CPP_METADATA_FAILURE_REASONS = {
|
||||
unsupported: ["unrecognized-magic", "unsupported-version"],
|
||||
corrupt: [
|
||||
"truncated-header",
|
||||
"unexpected-header-size",
|
||||
"section-out-of-bounds",
|
||||
"section-overlap",
|
||||
"record-size-mismatch",
|
||||
"type-index-width",
|
||||
"index-out-of-range",
|
||||
"nested-type-conflict",
|
||||
"declaring-type-mismatch",
|
||||
"image-range-overlap",
|
||||
"invalid-string",
|
||||
],
|
||||
} as const;
|
||||
export type Il2cppMetadataFailureKind =
|
||||
keyof typeof IL2CPP_METADATA_FAILURE_REASONS;
|
||||
export type Il2cppMetadataFailureReason =
|
||||
(typeof IL2CPP_METADATA_FAILURE_REASONS)[Il2cppMetadataFailureKind][number];
|
||||
|
||||
/** Distinguish inputs outside decoder coverage from damaged metadata. */
|
||||
export class Il2cppMetadataFormatFailure extends Error {
|
||||
readonly kind: Il2cppMetadataFailureKind;
|
||||
constructor(
|
||||
readonly reason: Il2cppMetadataFailureReason,
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`${reason}: ${message}`, options);
|
||||
this.name = "Il2cppMetadataFormatFailure";
|
||||
this.kind = (
|
||||
IL2CPP_METADATA_FAILURE_REASONS.unsupported as readonly string[]
|
||||
).includes(reason)
|
||||
? "unsupported"
|
||||
: "corrupt";
|
||||
}
|
||||
}
|
||||
|
||||
type SectionName = (typeof IL2CPP_METADATA_SECTION_NAMES)[number];
|
||||
type SectionName = IL2cppMetadataSectionName;
|
||||
type IndexWidth = 1 | 2 | 4;
|
||||
export type Il2cppMetadataVersion = (typeof IL2CPP_METADATA_VERSIONS)[number];
|
||||
type RecordSection = "typeDefinitions" | "images" | "methods";
|
||||
@@ -64,14 +34,24 @@ export type Il2cppMetadataDecoded = Omit<
|
||||
*/
|
||||
interface VersionLayout {
|
||||
readonly indexing: "fixed" | "counted";
|
||||
/** Version 29 and 31 type records also carry elementTypeIndex. */
|
||||
readonly typeDefinitionTypeIndices: 3 | 4;
|
||||
/** Type index fields: version 24 adds byref, 24 to 31 add elementTypeIndex. */
|
||||
readonly typeDefinitionTypeIndices: 3 | 4 | 5;
|
||||
/** Version 31 added returnParameterToken to method records. */
|
||||
readonly methodReturnParameterToken: boolean;
|
||||
}
|
||||
|
||||
const VERSION_LAYOUTS: Readonly<Record<Il2cppMetadataVersion, VersionLayout>> =
|
||||
{
|
||||
27: {
|
||||
indexing: "fixed",
|
||||
typeDefinitionTypeIndices: 4,
|
||||
methodReturnParameterToken: false,
|
||||
},
|
||||
24: {
|
||||
indexing: "fixed",
|
||||
typeDefinitionTypeIndices: 5,
|
||||
methodReturnParameterToken: false,
|
||||
},
|
||||
29: {
|
||||
indexing: "fixed",
|
||||
typeDefinitionTypeIndices: 4,
|
||||
@@ -95,7 +75,7 @@ const sectionEntryBytes = (layout: VersionLayout) =>
|
||||
/** Magic, version and one entry per section. */
|
||||
export const il2cppMetadataHeaderBytes = (version: Il2cppMetadataVersion) =>
|
||||
8 +
|
||||
IL2CPP_METADATA_SECTION_NAMES.length *
|
||||
il2cppMetadataSectionNames(version).length *
|
||||
sectionEntryBytes(VERSION_LAYOUTS[version]);
|
||||
|
||||
const isSupportedVersion = (value: number): value is Il2cppMetadataVersion =>
|
||||
@@ -142,7 +122,7 @@ const isIndexWidth = (value: number): value is IndexWidth =>
|
||||
value === 1 || value === 2 || value === 4;
|
||||
|
||||
/** Read a serialized index; the all-ones value of narrow widths means -1. */
|
||||
const readIndex = (bytes: Buffer, offset: number, width: IndexWidth) => {
|
||||
export const readIndex = (bytes: Buffer, offset: number, width: IndexWidth) => {
|
||||
if (width === 1) {
|
||||
const value = bytes.readUInt8(offset);
|
||||
return value === 0xff ? -1 : value;
|
||||
@@ -174,17 +154,17 @@ const readHeader = (bytes: Buffer) => {
|
||||
`the file has ${bytes.length} bytes, fewer than the ${headerBytes}-byte version ${version} header`,
|
||||
);
|
||||
const entryBytes = sectionEntryBytes(VERSION_LAYOUTS[version]);
|
||||
const sections: Il2cppMetadataSection[] = IL2CPP_METADATA_SECTION_NAMES.map(
|
||||
(name, index) => {
|
||||
const at = 8 + index * entryBytes;
|
||||
return {
|
||||
name,
|
||||
offset: bytes.readInt32LE(at),
|
||||
size: bytes.readInt32LE(at + 4),
|
||||
count: entryBytes === 12 ? bytes.readInt32LE(at + 8) : null,
|
||||
};
|
||||
},
|
||||
);
|
||||
const sections: Il2cppMetadataSection[] = il2cppMetadataSectionNames(
|
||||
version,
|
||||
).map((name, index) => {
|
||||
const at = 8 + index * entryBytes;
|
||||
return {
|
||||
name,
|
||||
offset: bytes.readInt32LE(at),
|
||||
size: bytes.readInt32LE(at + 4),
|
||||
count: entryBytes === 12 ? bytes.readInt32LE(at + 8) : null,
|
||||
};
|
||||
});
|
||||
return { magic, version, headerBytes, sections } as const;
|
||||
};
|
||||
|
||||
@@ -351,27 +331,17 @@ const recordCounts = (
|
||||
};
|
||||
};
|
||||
|
||||
const checkRange = (
|
||||
label: string,
|
||||
start: number,
|
||||
length: number,
|
||||
total: number,
|
||||
) => {
|
||||
if (length === 0 && start >= -1 && start <= total) return;
|
||||
if (start < 0 || start + length > total)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"index-out-of-range",
|
||||
`${label} range [${start}, ${start + length}) is outside the ${total} available entries`,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode NUL-terminated UTF-8 names once each from the strings section. An
|
||||
* index must start a string, so a shifted section or a pointer into another
|
||||
* name fails instead of returning a different name, and total decoding stays
|
||||
* bounded by the section size.
|
||||
*/
|
||||
const createStringReader = (bytes: Buffer, strings: Il2cppMetadataSection) => {
|
||||
const createStringReader = (
|
||||
bytes: Buffer,
|
||||
strings: Il2cppMetadataSection,
|
||||
beforeText?: (bytes: number) => void,
|
||||
) => {
|
||||
const stringEnd = strings.offset + strings.size;
|
||||
const pool = bytes.subarray(strings.offset, stringEnd);
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||
@@ -390,6 +360,7 @@ const createStringReader = (bytes: Buffer, strings: Il2cppMetadataSection) => {
|
||||
"invalid-string",
|
||||
`${label} string index ${index} starts inside another string`,
|
||||
);
|
||||
beforeText?.(end - index);
|
||||
let value: string;
|
||||
try {
|
||||
value = decoder.decode(pool.subarray(index, end));
|
||||
@@ -406,14 +377,19 @@ const createStringReader = (bytes: Buffer, strings: Il2cppMetadataSection) => {
|
||||
};
|
||||
|
||||
/** Checked header facts shared by the record readers. */
|
||||
interface MetadataTables {
|
||||
export interface MetadataTables {
|
||||
readonly bytes: Buffer;
|
||||
readonly section: (name: SectionName) => Il2cppMetadataSection;
|
||||
readonly widths: Il2cppMetadataDecoded["index_widths"];
|
||||
readonly recordBytes: ReturnType<typeof il2cppMetadataRecordBytes>;
|
||||
readonly counts: ReturnType<typeof recordCounts>;
|
||||
readonly typeDefinitionTypeIndices: number;
|
||||
readonly indexing: "fixed" | "counted";
|
||||
readonly methodReturnParameterToken: boolean;
|
||||
readonly readString: ReturnType<typeof createStringReader>;
|
||||
readonly members: ReturnType<typeof readIl2cppMemberTables>;
|
||||
readonly beforeText: Il2cppMetadataReadOptions["beforeText"];
|
||||
readonly onRow: Il2cppMetadataReadOptions["onRow"];
|
||||
}
|
||||
|
||||
function* readImages({
|
||||
@@ -423,6 +399,7 @@ function* readImages({
|
||||
recordBytes: sizes,
|
||||
counts,
|
||||
readString,
|
||||
onRow,
|
||||
}: MetadataTables): Generator<void, Il2cppMetadataDecoded["images"]> {
|
||||
const images = section("images");
|
||||
const typeTotal = counts.typeDefinitions;
|
||||
@@ -442,6 +419,7 @@ function* readImages({
|
||||
image.type_count,
|
||||
typeTotal,
|
||||
);
|
||||
onRow?.(image);
|
||||
result.push(image);
|
||||
}
|
||||
return result;
|
||||
@@ -517,14 +495,27 @@ function* rejectNestingCycles(types: Il2cppMetadataDecoded["types"]) {
|
||||
}
|
||||
}
|
||||
|
||||
function* readTypes(
|
||||
tables: MetadataTables,
|
||||
): Generator<void, Il2cppMetadataDecoded["types"]> {
|
||||
function* readTypes(tables: MetadataTables): Generator<
|
||||
void,
|
||||
{
|
||||
types: Il2cppMetadataDecoded["types"];
|
||||
memberRanges: {
|
||||
fields: MetadataOwnerRange[];
|
||||
properties: MetadataOwnerRange[];
|
||||
events: MetadataOwnerRange[];
|
||||
};
|
||||
}
|
||||
> {
|
||||
const { bytes, section, widths, recordBytes: sizes, counts } = tables;
|
||||
const { readString, typeDefinitionTypeIndices } = tables;
|
||||
const typeDefinitions = section("typeDefinitions");
|
||||
const methodTotal = counts.methods;
|
||||
const types: Il2cppMetadataDecoded["types"] = [];
|
||||
const memberRanges = {
|
||||
fields: [] as MetadataOwnerRange[],
|
||||
properties: [] as MetadataOwnerRange[],
|
||||
events: [] as MetadataOwnerRange[],
|
||||
};
|
||||
for (let index = 0; index < counts.typeDefinitions; index++) {
|
||||
if (index % 256 === 0) yield;
|
||||
const at = typeDefinitions.offset + index * sizes.typeDefinitions;
|
||||
@@ -554,10 +545,23 @@ function* readTypes(
|
||||
type.method_count,
|
||||
methodTotal,
|
||||
);
|
||||
memberRanges.fields.push({
|
||||
start: bytes.readInt32LE(starts),
|
||||
count: bytes.readUInt16LE(countsAt + 4),
|
||||
});
|
||||
memberRanges.events.push({
|
||||
start: bytes.readInt32LE(starts + 8),
|
||||
count: bytes.readUInt16LE(countsAt + 6),
|
||||
});
|
||||
memberRanges.properties.push({
|
||||
start: bytes.readInt32LE(starts + 12),
|
||||
count: bytes.readUInt16LE(countsAt + 2),
|
||||
});
|
||||
tables.onRow?.(type);
|
||||
types.push(type);
|
||||
}
|
||||
yield* assignNesting(tables, types);
|
||||
return types;
|
||||
return { types, memberRanges };
|
||||
}
|
||||
|
||||
function* readMethods({
|
||||
@@ -567,10 +571,19 @@ function* readMethods({
|
||||
recordBytes: sizes,
|
||||
counts,
|
||||
readString,
|
||||
}: MetadataTables): Generator<void, Il2cppMetadataDecoded["methods"]> {
|
||||
methodReturnParameterToken,
|
||||
onRow,
|
||||
}: MetadataTables): Generator<
|
||||
void,
|
||||
{
|
||||
methods: Il2cppMetadataDecoded["methods"];
|
||||
parameterRanges: MetadataOwnerRange[];
|
||||
}
|
||||
> {
|
||||
const methods = section("methods");
|
||||
const typeTotal = counts.typeDefinitions;
|
||||
const result: Il2cppMetadataDecoded["methods"] = [];
|
||||
const parameterRanges: MetadataOwnerRange[] = [];
|
||||
for (let index = 0; index < counts.methods; index++) {
|
||||
if (index % 256 === 0) yield;
|
||||
const at = methods.offset + index * sizes.methods;
|
||||
@@ -583,9 +596,22 @@ function* readMethods({
|
||||
"index-out-of-range",
|
||||
`method ${index} declares type ${method.declaring_type} outside the ${typeTotal} type definitions`,
|
||||
);
|
||||
parameterRanges.push({
|
||||
start: readIndex(
|
||||
bytes,
|
||||
at +
|
||||
4 +
|
||||
widths.type_definition +
|
||||
widths.type +
|
||||
(methodReturnParameterToken ? 4 : 0),
|
||||
widths.parameter,
|
||||
),
|
||||
count: bytes.readUInt16LE(at + sizes.methods - 2),
|
||||
});
|
||||
onRow?.(method);
|
||||
result.push(method);
|
||||
}
|
||||
return result;
|
||||
return { methods: result, parameterRanges };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -656,15 +682,28 @@ export interface Il2cppMetadataRowCounts {
|
||||
readonly images: number;
|
||||
readonly types: number;
|
||||
readonly methods: number;
|
||||
readonly fields: number;
|
||||
readonly properties: number;
|
||||
readonly events: number;
|
||||
readonly parameters: number;
|
||||
readonly string_literals: number;
|
||||
}
|
||||
|
||||
/** Decode image, type and method names without native binaries or decryption. */
|
||||
export interface Il2cppMetadataReadOptions {
|
||||
/** Called before any row is decoded; may throw to refuse the file. */
|
||||
readonly beforeRows?: (counts: Il2cppMetadataRowCounts) => void;
|
||||
/** Guard raw text before allocating its decoded or base64 representation. */
|
||||
readonly beforeText?: (bytes: number) => void;
|
||||
/** Admit each fact before retention, including repeated strings. */
|
||||
readonly onRow?: (
|
||||
row: Readonly<Record<string, string | number | null>>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/** Decode image, type, method and member names and string literals without native binaries or decryption. */
|
||||
export function* readIl2cppMetadataSteps(
|
||||
bytes: Buffer,
|
||||
options: {
|
||||
/** Called before any row is decoded; may throw to refuse the file. */
|
||||
readonly beforeRows?: (counts: Il2cppMetadataRowCounts) => void;
|
||||
} = {},
|
||||
options: Il2cppMetadataReadOptions = {},
|
||||
): Generator<void, Il2cppMetadataDecoded> {
|
||||
const { magic, version, headerBytes, sections } = readHeader(bytes);
|
||||
checkSections(bytes, headerBytes, sections);
|
||||
@@ -681,19 +720,37 @@ export function* readIl2cppMetadataSteps(
|
||||
recordBytes: expected,
|
||||
counts: recordCounts(version, section, expected),
|
||||
typeDefinitionTypeIndices: layout.typeDefinitionTypeIndices,
|
||||
readString: createStringReader(bytes, section("strings")),
|
||||
indexing: layout.indexing,
|
||||
methodReturnParameterToken: layout.methodReturnParameterToken,
|
||||
readString: createStringReader(
|
||||
bytes,
|
||||
section("strings"),
|
||||
options.beforeText,
|
||||
),
|
||||
members: readIl2cppMemberTables({
|
||||
widths,
|
||||
indexing: layout.indexing,
|
||||
section,
|
||||
}),
|
||||
beforeText: options.beforeText,
|
||||
onRow: options.onRow,
|
||||
};
|
||||
options.beforeRows?.({
|
||||
images: tables.counts.images,
|
||||
types: tables.counts.typeDefinitions,
|
||||
methods: tables.counts.methods,
|
||||
...tables.members.counts,
|
||||
});
|
||||
yield;
|
||||
const images = yield* readImages(tables);
|
||||
yield* checkImageRanges(images);
|
||||
const types = yield* readTypes(tables);
|
||||
const methods = yield* readMethods(tables);
|
||||
const { types, memberRanges } = yield* readTypes(tables);
|
||||
const { methods, parameterRanges } = yield* readMethods(tables);
|
||||
yield* checkMethodOwners(types, methods);
|
||||
const members = yield* readIl2cppMembers(tables, {
|
||||
...memberRanges,
|
||||
parameters: parameterRanges,
|
||||
});
|
||||
return {
|
||||
magic,
|
||||
metadata_version: version,
|
||||
@@ -702,13 +759,14 @@ export function* readIl2cppMetadataSteps(
|
||||
images,
|
||||
types,
|
||||
methods,
|
||||
...members,
|
||||
};
|
||||
}
|
||||
|
||||
/** Synchronous consumption of the same decoder for byte-level callers. */
|
||||
export const readIl2cppMetadata = (
|
||||
bytes: Buffer,
|
||||
options: Parameters<typeof readIl2cppMetadataSteps>[1] = {},
|
||||
options: Il2cppMetadataReadOptions = {},
|
||||
): Il2cppMetadataDecoded => {
|
||||
const steps = readIl2cppMetadataSteps(bytes, options);
|
||||
for (;;) {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Stable machine-readable reason for each rejected input. */
|
||||
export const IL2CPP_METADATA_FAILURE_REASONS = {
|
||||
unsupported: ["unrecognized-magic", "unsupported-version"],
|
||||
corrupt: [
|
||||
"truncated-header",
|
||||
"unexpected-header-size",
|
||||
"section-out-of-bounds",
|
||||
"section-overlap",
|
||||
"record-size-mismatch",
|
||||
"type-index-width",
|
||||
"index-out-of-range",
|
||||
"nested-type-conflict",
|
||||
"declaring-type-mismatch",
|
||||
"image-range-overlap",
|
||||
"invalid-string",
|
||||
],
|
||||
} as const;
|
||||
export type Il2cppMetadataFailureKind =
|
||||
keyof typeof IL2CPP_METADATA_FAILURE_REASONS;
|
||||
export type Il2cppMetadataFailureReason =
|
||||
(typeof IL2CPP_METADATA_FAILURE_REASONS)[Il2cppMetadataFailureKind][number];
|
||||
|
||||
/** Distinguish inputs outside decoder coverage from damaged metadata. */
|
||||
export class Il2cppMetadataFormatFailure extends Error {
|
||||
readonly kind: Il2cppMetadataFailureKind;
|
||||
constructor(
|
||||
readonly reason: Il2cppMetadataFailureReason,
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(`${reason}: ${message}`, options);
|
||||
this.name = "Il2cppMetadataFormatFailure";
|
||||
this.kind = (
|
||||
IL2CPP_METADATA_FAILURE_REASONS.unsupported as readonly string[]
|
||||
).includes(reason)
|
||||
? "unsupported"
|
||||
: "corrupt";
|
||||
}
|
||||
}
|
||||
|
||||
export const checkRange = (
|
||||
label: string,
|
||||
start: number,
|
||||
length: number,
|
||||
total: number,
|
||||
) => {
|
||||
if (length === 0 && start >= -1 && start <= total) return;
|
||||
if (start < 0 || start + length > total)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"index-out-of-range",
|
||||
`${label} range [${start}, ${start + length}) is outside the ${total} available entries`,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import type {
|
||||
Il2cppMetadata,
|
||||
Il2cppMetadataSection,
|
||||
} from "../domain/il2cpp/il2cppMetadata.js";
|
||||
import {
|
||||
checkRange,
|
||||
Il2cppMetadataFormatFailure,
|
||||
} from "./Il2cppMetadataFormat.js";
|
||||
import type { MetadataTables } from "./Il2cppMetadataFile.js";
|
||||
|
||||
type Members = Pick<
|
||||
Il2cppMetadata,
|
||||
"fields" | "properties" | "events" | "parameters" | "string_literals"
|
||||
>;
|
||||
export interface MetadataOwnerRange {
|
||||
readonly start: number;
|
||||
readonly count: number;
|
||||
}
|
||||
export interface MetadataMemberOwners {
|
||||
readonly fields: readonly MetadataOwnerRange[];
|
||||
readonly properties: readonly MetadataOwnerRange[];
|
||||
readonly events: readonly MetadataOwnerRange[];
|
||||
readonly parameters: readonly MetadataOwnerRange[];
|
||||
}
|
||||
|
||||
const entries = (
|
||||
indexing: MetadataTables["indexing"],
|
||||
section: Il2cppMetadataSection,
|
||||
recordBytes: number,
|
||||
) => {
|
||||
const count =
|
||||
indexing === "counted" ? (section.count ?? 0) : section.size / recordBytes;
|
||||
if (!Number.isInteger(count) || section.size !== count * recordBytes)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"record-size-mismatch",
|
||||
`section ${section.name} size ${section.size} does not hold whole ${recordBytes}-byte records`,
|
||||
);
|
||||
return count;
|
||||
};
|
||||
|
||||
/** Own member record widths and validate all counts before any row allocation. */
|
||||
export const readIl2cppMemberTables = ({
|
||||
widths,
|
||||
indexing,
|
||||
section,
|
||||
}: Pick<MetadataTables, "widths" | "indexing" | "section">) => {
|
||||
const recordBytes = {
|
||||
fields: 8 + widths.type,
|
||||
properties: 20,
|
||||
events: 20 + widths.type,
|
||||
parameters: 8 + widths.type,
|
||||
stringLiterals: indexing === "counted" ? 4 : 8,
|
||||
};
|
||||
const literalRecords = entries(
|
||||
indexing,
|
||||
section("stringLiterals"),
|
||||
recordBytes.stringLiterals,
|
||||
);
|
||||
return {
|
||||
recordBytes,
|
||||
literalRecords,
|
||||
counts: {
|
||||
fields: entries(indexing, section("fields"), recordBytes.fields),
|
||||
properties: entries(
|
||||
indexing,
|
||||
section("properties"),
|
||||
recordBytes.properties,
|
||||
),
|
||||
events: entries(indexing, section("events"), recordBytes.events),
|
||||
parameters: entries(
|
||||
indexing,
|
||||
section("parameters"),
|
||||
recordBytes.parameters,
|
||||
),
|
||||
string_literals:
|
||||
indexing === "counted"
|
||||
? Math.max(literalRecords - 1, 0)
|
||||
: literalRecords,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** Owners come from already parsed type/method facts, never a second raw-table interpretation. */
|
||||
function* claim(
|
||||
ranges: readonly MetadataOwnerRange[],
|
||||
total: number,
|
||||
label: string,
|
||||
): Generator<void, Int32Array> {
|
||||
const owners = new Int32Array(total).fill(-1);
|
||||
let work = 0;
|
||||
for (const [owner, range] of ranges.entries()) {
|
||||
if (++work % 256 === 0) yield;
|
||||
checkRange(
|
||||
`${label} range of owner ${owner}`,
|
||||
range.start,
|
||||
range.count,
|
||||
total,
|
||||
);
|
||||
for (let row = range.start; row < range.start + range.count; row++) {
|
||||
if (++work % 256 === 0) yield;
|
||||
if (owners[row] !== -1)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"declaring-type-mismatch",
|
||||
`${label} ${row} is claimed by owners ${owners[row]} and ${owner}`,
|
||||
);
|
||||
owners[row] = owner;
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
function* readNamed<
|
||||
T extends { name: string } & Record<string, string | number>,
|
||||
>(
|
||||
tables: MetadataTables,
|
||||
name: "fields" | "properties" | "events" | "parameters",
|
||||
ranges: readonly MetadataOwnerRange[],
|
||||
makeRow: (name: string, owner: number) => T,
|
||||
): Generator<void, T[]> {
|
||||
const section = tables.section(name),
|
||||
total = tables.members.counts[name];
|
||||
const owners = yield* claim(ranges, total, name),
|
||||
rows: T[] = [];
|
||||
for (let index = 0; index < total; index++) {
|
||||
if (index % 256 === 0) yield;
|
||||
const row = makeRow(
|
||||
tables.readString(
|
||||
tables.bytes.readInt32LE(
|
||||
section.offset + index * tables.members.recordBytes[name],
|
||||
),
|
||||
`${name} ${index} name`,
|
||||
),
|
||||
owners[index]!,
|
||||
);
|
||||
tables.onRow?.(row);
|
||||
rows.push(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function* readStringLiterals(
|
||||
tables: MetadataTables,
|
||||
): Generator<void, Members["string_literals"]> {
|
||||
const { bytes, section, indexing, members } = tables;
|
||||
const literals = section("stringLiterals"),
|
||||
data = section("stringLiteralData");
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
||||
// Repeated spans reuse their decoded text for this immutable file snapshot.
|
||||
const decoded = new Map<string, Members["string_literals"][number]>();
|
||||
const rows: Members["string_literals"] = [];
|
||||
if (indexing === "counted" && members.literalRecords === 1) {
|
||||
const sentinel = bytes.readInt32LE(literals.offset);
|
||||
if (sentinel < 0 || sentinel > data.size)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"index-out-of-range",
|
||||
`empty string literal sentinel ${sentinel} is outside the ${data.size}-byte literal data`,
|
||||
);
|
||||
}
|
||||
for (let index = 0; index < members.counts.string_literals; index++) {
|
||||
if (index % 256 === 0) yield;
|
||||
const at = literals.offset + index * members.recordBytes.stringLiterals;
|
||||
const start = bytes.readInt32LE(at + (indexing === "counted" ? 0 : 4));
|
||||
const length =
|
||||
indexing === "counted"
|
||||
? bytes.readInt32LE(at + 4) - start
|
||||
: bytes.readUInt32LE(at);
|
||||
if (start < 0 || length < 0 || start + length > data.size)
|
||||
throw new Il2cppMetadataFormatFailure(
|
||||
"index-out-of-range",
|
||||
`string literal ${index} [${start}, ${start + length}) is outside the ${data.size}-byte literal data`,
|
||||
);
|
||||
const key = `${start}:${length}`;
|
||||
let literal = decoded.get(key);
|
||||
if (literal === undefined) {
|
||||
// Strict UTF-8 round trips preserve at least these bytes; base64 is larger.
|
||||
// Refuse impossible output before allocating either representation.
|
||||
tables.beforeText?.(length);
|
||||
const value = bytes.subarray(
|
||||
data.offset + start,
|
||||
data.offset + start + length,
|
||||
);
|
||||
try {
|
||||
literal = { value: decoder.decode(value), value_base64: null };
|
||||
} catch {
|
||||
tables.beforeText?.(4 * Math.ceil(length / 3));
|
||||
literal = { value: null, value_base64: value.toString("base64") };
|
||||
}
|
||||
decoded.set(key, literal);
|
||||
}
|
||||
tables.onRow?.(literal);
|
||||
rows.push({ ...literal });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Decode each fact directly into its final row shape, yielding through ownership and content. */
|
||||
export function* readIl2cppMembers(
|
||||
tables: MetadataTables,
|
||||
owners: MetadataMemberOwners,
|
||||
): Generator<void, Members> {
|
||||
const member = (name: string, declaring_type: number) => ({
|
||||
name,
|
||||
declaring_type,
|
||||
});
|
||||
const fields = yield* readNamed(tables, "fields", owners.fields, member);
|
||||
const properties = yield* readNamed(
|
||||
tables,
|
||||
"properties",
|
||||
owners.properties,
|
||||
member,
|
||||
);
|
||||
const events = yield* readNamed(tables, "events", owners.events, member);
|
||||
const parameters = yield* readNamed(
|
||||
tables,
|
||||
"parameters",
|
||||
owners.parameters,
|
||||
(name, method) => ({ name, method }),
|
||||
);
|
||||
return {
|
||||
fields,
|
||||
properties,
|
||||
events,
|
||||
parameters,
|
||||
string_literals: yield* readStringLiterals(tables),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Il2cppMetadataFormatFailure } from "./Il2cppMetadataFormat.js";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import {
|
||||
readStableArtifact,
|
||||
@@ -17,6 +18,7 @@ import type { ExecutionOptions } from "../application/AnalysisProvider.js";
|
||||
import type { Il2cppMetadataPort } from "../application/il2cpp/Il2cppMetadataPort.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { createIl2cppMetadataAdmission } from "./Il2cppMetadataAdmission.js";
|
||||
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
@@ -36,7 +38,6 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
Il2cppMetadataFormatFailure,
|
||||
readIl2cppMetadataSteps,
|
||||
type Il2cppMetadataRowCounts,
|
||||
} from "./Il2cppMetadataFile.js";
|
||||
@@ -51,25 +52,6 @@ const OPERATION = "inspect_il2cpp_metadata";
|
||||
const INPUT_BYTES = 256 * 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;
|
||||
// The smallest JSON a row can take: empty names and one-digit numbers.
|
||||
const EMPTY_NAME = JSON.stringify("").length;
|
||||
|
||||
/** JSON characters of one row besides its strings, with one-digit numbers. */
|
||||
const rowOverhead = (row: Record<string, string | number>) =>
|
||||
JSON.stringify(row).length -
|
||||
2 * Object.values(row).filter((value) => typeof value === "string").length;
|
||||
const IMAGE_ROW = rowOverhead({ name: "", type_start: 0, type_count: 0 });
|
||||
const TYPE_ROW = rowOverhead({
|
||||
name: "",
|
||||
namespace: "",
|
||||
method_start: 0,
|
||||
method_count: 0,
|
||||
nested_type_start: 0,
|
||||
nested_type_count: 0,
|
||||
declaring_type: 0,
|
||||
});
|
||||
const METHOD_ROW = rowOverhead({ name: "", declaring_type: 0 });
|
||||
|
||||
const failureForPrimary = (cause: unknown, path: string): AnalysisError => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity")
|
||||
@@ -170,12 +152,6 @@ const reportLimit = (path: string, maximumBytes: number) =>
|
||||
{ maximum_report_bytes: maximumBytes },
|
||||
);
|
||||
|
||||
/** Lower bound from row counts alone, so oversized files fail before rows are allocated. */
|
||||
const countLowerBound = ({ images, types, methods }: Il2cppMetadataRowCounts) =>
|
||||
images * (IMAGE_ROW + EMPTY_NAME) +
|
||||
types * (TYPE_ROW + 2 * EMPTY_NAME) +
|
||||
methods * (METHOD_ROW + EMPTY_NAME);
|
||||
|
||||
const sourceArtifact = (path: string, snapshot: StableArtifactSnapshot) => ({
|
||||
path,
|
||||
sha256: snapshot.sha256,
|
||||
@@ -301,11 +277,14 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
|
||||
observed.artifact = artifact;
|
||||
assertArtifactReadActive(options?.signal);
|
||||
const limit = this.outputBytes;
|
||||
const admission = createIl2cppMetadataAdmission(limit, () => {
|
||||
throw reportLimit(input.path, limit);
|
||||
});
|
||||
const steps = readIl2cppMetadataSteps(snapshot.bytes, {
|
||||
...admission,
|
||||
beforeRows: (counts) => {
|
||||
observed.counts = counts;
|
||||
if (countLowerBound(counts) > limit)
|
||||
throw reportLimit(input.path, limit);
|
||||
admission.beforeRows(counts);
|
||||
},
|
||||
});
|
||||
let step = steps.next();
|
||||
@@ -319,9 +298,9 @@ export class Il2cppMetadataProvider implements Il2cppMetadataPort {
|
||||
...decoded,
|
||||
artifact,
|
||||
limitations: [
|
||||
"Only unencrypted global-metadata.dat versions 29, 31 and 39 are decoded; image, type and method names and counts are observed file content, not runtime behavior.",
|
||||
"Native code addresses, IL2CPP binary registration, fields, properties, signatures and generic instances are not recovered.",
|
||||
"declaring_type for types is derived from the nestedTypes section; row positions are the metadata TypeDefinitionIndex and MethodIndex values.",
|
||||
"Only unencrypted global-metadata.dat versions 24, 27, 29, 31 and 39 are decoded; names, counts and string literals are observed file content, not runtime behavior.",
|
||||
"Native code addresses, IL2CPP binary registration, member types, signatures and generic instances are not recovered.",
|
||||
"declaring_type for types is derived from the nestedTypes section, and member and parameter owners from the owners' index ranges; row positions are the metadata indices.",
|
||||
],
|
||||
};
|
||||
if (jsonValueByteLength(report, limit) > limit)
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
analysisViewResultSchema,
|
||||
il2cppMetadataSummaryViewResultSchema,
|
||||
} from "../../src/domain/analysisView/analysisView.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { createEvidence, parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../src/domain/evidenceBundle.js";
|
||||
import { jsonObjectSchema } from "../../src/domain/jsonValue.js";
|
||||
import { il2cppMetadataSchema } from "../../src/domain/il2cpp/il2cppMetadata.js";
|
||||
import {
|
||||
createIl2cppMetadataFixture,
|
||||
@@ -34,7 +36,10 @@ cliTest(
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-metadata-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const { bytes } = createIl2cppMetadataFixture({ typeIndexWidth: 2 });
|
||||
const { bytes } = createIl2cppMetadataFixture({
|
||||
typeIndexWidth: 2,
|
||||
propertiesAndEvents: true,
|
||||
});
|
||||
await writeFile(path, bytes);
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-il2cpp-metadata", path, "--json"],
|
||||
@@ -88,6 +93,11 @@ cliTest(
|
||||
types: 4,
|
||||
methods: 5,
|
||||
nested_types: 1,
|
||||
fields: 2,
|
||||
properties: 3,
|
||||
events: 2,
|
||||
parameters: 1,
|
||||
string_literals: 2,
|
||||
});
|
||||
expect(view.summary).not.toHaveProperty("sections");
|
||||
expect(view.summary.index_widths).toEqual(report.index_widths);
|
||||
@@ -99,6 +109,11 @@ cliTest(
|
||||
"images",
|
||||
"types",
|
||||
"methods",
|
||||
"fields",
|
||||
"properties",
|
||||
"events",
|
||||
"parameters",
|
||||
"string_literals",
|
||||
] as const) {
|
||||
const rows: unknown[] = [];
|
||||
let offset = 0;
|
||||
@@ -136,7 +151,7 @@ cliTest(
|
||||
const { client, call } = await connectLocalToolsMcp();
|
||||
const fixture = createIl2cppMetadataFixture();
|
||||
const unsupported = Buffer.from(fixture.bytes);
|
||||
unsupported.writeInt32LE(27, 4);
|
||||
unsupported.writeInt32LE(26, 4);
|
||||
const corrupt = Buffer.from(fixture.bytes);
|
||||
corrupt.writeInt32LE(
|
||||
fixture.section("typeDefinitions").offset + 4,
|
||||
@@ -144,7 +159,7 @@ cliTest(
|
||||
);
|
||||
for (const [name, bytes, expected] of [
|
||||
[
|
||||
"v27.dat",
|
||||
"v26.dat",
|
||||
unsupported,
|
||||
{
|
||||
code: "unsupported_target",
|
||||
@@ -210,7 +225,7 @@ it("queries compact retained metadata through the real SDK with exact row identi
|
||||
const overview = il2cppMetadataSummaryViewResultSchema.parse(
|
||||
parseEvidence(response.structuredContent).normalized_result,
|
||||
);
|
||||
expect(overview.summary.counts).toEqual({
|
||||
expect(overview.summary.counts).toMatchObject({
|
||||
sections: 31,
|
||||
images: 2,
|
||||
types: 4,
|
||||
@@ -286,6 +301,23 @@ it("queries compact retained metadata through the real SDK with exact row identi
|
||||
items: [{ index: 2, name: "Add", declaring_type: 1 }],
|
||||
coverage: { total: 2, exhausted: true, next_offset: null },
|
||||
});
|
||||
expect(
|
||||
await selected({
|
||||
collection: "parameters",
|
||||
method_indices: [2, 2, 99],
|
||||
fields: ["name", "method"],
|
||||
}),
|
||||
).toMatchObject({
|
||||
items: [{ index: 0, name: "amount", method: 2 }],
|
||||
coverage: { total: 1, exhausted: true },
|
||||
});
|
||||
expect(
|
||||
await selected({
|
||||
collection: "parameters",
|
||||
method_indices: [],
|
||||
fields: [],
|
||||
}),
|
||||
).toMatchObject({ items: [], coverage: { total: 0, exhausted: true } });
|
||||
expect(
|
||||
(await selected({ collection: "methods", indices: [2, 0], fields: [] }))
|
||||
.items,
|
||||
@@ -306,6 +338,83 @@ it("queries compact retained metadata through the real SDK with exact row identi
|
||||
await client.ping();
|
||||
}, 30000);
|
||||
|
||||
it("imports historical metadata Evidence with unavailable member collections without inventing empty results", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-history-"),
|
||||
path = join(root, "global-metadata.dat");
|
||||
await writeFile(path, createIl2cppMetadataFixture({ version: 29 }).bytes);
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const fresh = await call("inspect_il2cpp_metadata", {
|
||||
path,
|
||||
detail: "complete",
|
||||
});
|
||||
expect(fresh.isError, JSON.stringify(fresh)).not.toBe(true);
|
||||
const original = parseEvidence(fresh.structuredContent),
|
||||
legacy = jsonObjectSchema.parse(original.normalized_result);
|
||||
for (const key of [
|
||||
"fields",
|
||||
"properties",
|
||||
"events",
|
||||
"parameters",
|
||||
"string_literals",
|
||||
])
|
||||
delete legacy[key];
|
||||
const historical = createEvidence(
|
||||
{
|
||||
path,
|
||||
sha256: original.subject!.digest.sha256,
|
||||
format: "file",
|
||||
},
|
||||
original.provider,
|
||||
{
|
||||
operation: "inspect_il2cpp_metadata",
|
||||
parameters: { path },
|
||||
result: legacy,
|
||||
limitations: original.limitations,
|
||||
locations: original.locations,
|
||||
},
|
||||
);
|
||||
const bundle = join(root, "historical.json");
|
||||
await writeFile(bundle, JSON.stringify(createEvidenceBundle([historical])));
|
||||
await unlink(path);
|
||||
const imported = await call("import_evidence_bundle", { path: bundle });
|
||||
expect(imported.isError, JSON.stringify(imported)).not.toBe(true);
|
||||
const source = {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: historical.evidence_id,
|
||||
};
|
||||
const summary = await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: { kind: "summary" },
|
||||
});
|
||||
expect(summary.isError, JSON.stringify(summary)).not.toBe(true);
|
||||
expect(
|
||||
parseEvidence(summary.structuredContent).normalized_result,
|
||||
).toMatchObject({
|
||||
summary: { counts: { methods: 5, fields: null, parameters: null } },
|
||||
unknowns: expect.arrayContaining([
|
||||
expect.stringContaining("did not collect parameters"),
|
||||
]),
|
||||
});
|
||||
const types = await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: {
|
||||
kind: "il2cpp-metadata",
|
||||
collection: "types",
|
||||
indices: [0],
|
||||
fields: ["name"],
|
||||
},
|
||||
});
|
||||
expect(types.isError).not.toBe(true);
|
||||
expect(
|
||||
parseEvidence(types.structuredContent).normalized_result,
|
||||
).toMatchObject({ items: [{ index: 0, name: "Player" }] });
|
||||
const unavailable = await call("inspect_analysis_view", {
|
||||
source,
|
||||
view: { kind: "il2cpp-metadata", collection: "parameters" },
|
||||
});
|
||||
expect(parseMcpToolError(unavailable).error.code).toBe("invalid_request");
|
||||
}, 30000);
|
||||
|
||||
cliTest(
|
||||
"exports complete metadata Evidence for compact CLI delivery and portable selected reads",
|
||||
async ({ cli }) => {
|
||||
|
||||
@@ -1181,7 +1181,7 @@ it("preserves admitted Unicode and escaped AssemblyRef names in complete Evidenc
|
||||
);
|
||||
});
|
||||
|
||||
it.each([27, 31, 106])(
|
||||
it.each([27, 28, 31, 106])(
|
||||
"suggests metadata-only inspection for supported raw version %s",
|
||||
async (version) => {
|
||||
const root = await createTestTempDirectory("rea-desktop-metadata-probe-");
|
||||
@@ -1197,7 +1197,7 @@ it.each([27, 31, 106])(
|
||||
value: { magic: "0xfab11baf", raw_version: version },
|
||||
});
|
||||
expect(report.next_probes).toEqual(
|
||||
version === 31
|
||||
version === 27 || version === 31
|
||||
? [
|
||||
expect.objectContaining({
|
||||
operation: "inspect_il2cpp_metadata",
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import {
|
||||
Il2cppMetadataFormatFailure,
|
||||
type Il2cppMetadataFailureReason,
|
||||
} from "../../../src/il2cpp/Il2cppMetadataFormat.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
il2cppIndexWidth,
|
||||
il2cppMetadataRecordBytes,
|
||||
Il2cppMetadataFormatFailure,
|
||||
readIl2cppMetadata,
|
||||
type Il2cppMetadataFailureReason,
|
||||
readIl2cppMetadataSteps,
|
||||
} from "../../../src/il2cpp/Il2cppMetadataFile.js";
|
||||
import {
|
||||
createIl2cppMetadataFixture,
|
||||
FIXTURE_IMAGES,
|
||||
fixtureImages,
|
||||
FIXTURE_METHODS,
|
||||
FIXTURE_FIELDS,
|
||||
FIXTURE_PROPERTIES,
|
||||
FIXTURE_EVENTS,
|
||||
FIXTURE_PARAMETERS,
|
||||
FIXTURE_STRING_LITERALS,
|
||||
FIXTURE_TYPES,
|
||||
fixtureRows,
|
||||
type Il2cppMetadataFixture,
|
||||
@@ -47,11 +55,52 @@ const mutated = (
|
||||
};
|
||||
|
||||
describe("IL2CPP global-metadata version 39", () => {
|
||||
it("yields through a large owner range before emitting its first parameter fact", () => {
|
||||
const fixture = createIl2cppMetadataFixture({
|
||||
wide: { fillerTypes: 0, genericContainers: 0, parameters: 65_000 },
|
||||
});
|
||||
const bytes = Buffer.from(fixture.bytes),
|
||||
total = fixture.section("parameters").count!;
|
||||
bytes.writeUInt16LE(
|
||||
total,
|
||||
fixture.record("methods", 2) + fixture.recordBytes.methods - 2,
|
||||
);
|
||||
let firstParameter = false,
|
||||
yieldsBeforeParameter = 0;
|
||||
const steps = readIl2cppMetadataSteps(bytes, {
|
||||
onRow: (row) => {
|
||||
if ("method" in row) firstParameter = true;
|
||||
},
|
||||
});
|
||||
for (;;) {
|
||||
const step = steps.next();
|
||||
if (step.done) break;
|
||||
if (!firstParameter) yieldsBeforeParameter++;
|
||||
}
|
||||
expect(firstParameter).toBe(true);
|
||||
expect(yieldsBeforeParameter).toBeGreaterThan(200);
|
||||
});
|
||||
it.each([-1, 0x7fffffff])(
|
||||
"rejects an invalid empty-literal sentinel %i",
|
||||
(sentinel) => {
|
||||
rejects(
|
||||
mutated((fixture, bytes) => {
|
||||
bytes.writeInt32LE(4, fixture.header("stringLiterals", "size"));
|
||||
bytes.writeInt32LE(1, fixture.header("stringLiterals", "count"));
|
||||
bytes.writeInt32LE(
|
||||
sentinel,
|
||||
fixture.section("stringLiterals").offset,
|
||||
);
|
||||
}),
|
||||
"index-out-of-range",
|
||||
);
|
||||
},
|
||||
);
|
||||
it.each([1, 2, 4] as const)(
|
||||
"decodes names, ranges and nesting with a %i-byte TypeIndex under 1 KiB",
|
||||
"decodes names, ranges and nesting with a %i-byte TypeIndex in about 1 KiB",
|
||||
(typeIndexWidth) => {
|
||||
const fixture = createIl2cppMetadataFixture({ typeIndexWidth });
|
||||
expect(fixture.bytes.length).toBeLessThan(1024);
|
||||
expect(fixture.bytes.length).toBeLessThan(1100);
|
||||
const decoded = readIl2cppMetadata(fixture.bytes);
|
||||
expect(decoded.magic).toBe(fixture.bytes.readUInt32LE(0));
|
||||
expect(decoded.metadata_version).toBe(39);
|
||||
@@ -69,8 +118,8 @@ describe("IL2CPP global-metadata version 39", () => {
|
||||
expect(decoded.sections[0]).toEqual({
|
||||
name: "stringLiterals",
|
||||
offset: 380,
|
||||
size: 0,
|
||||
count: 0,
|
||||
size: 12,
|
||||
count: 3,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -270,9 +319,9 @@ describe("rejected IL2CPP metadata references", () => {
|
||||
});
|
||||
|
||||
it("rejects a shifted string section and names that start inside another string", () => {
|
||||
// Pulled back, the fixture's strings would start inside the header.
|
||||
// Pulled back, the fixture's strings would overlap the literal data.
|
||||
for (const [delta, reason] of [
|
||||
[-1, "section-out-of-bounds"],
|
||||
[-1, "section-overlap"],
|
||||
[1, "invalid-string"],
|
||||
] as const)
|
||||
rejects(
|
||||
@@ -376,7 +425,7 @@ describe("IL2CPP global-metadata versions 29 and 31", () => {
|
||||
expect(decoded.sections[0]).toEqual({
|
||||
name: "stringLiterals",
|
||||
offset: 256,
|
||||
size: 0,
|
||||
size: 16,
|
||||
count: null,
|
||||
});
|
||||
expect(decoded.sections.every((section) => section.count === null)).toBe(
|
||||
@@ -545,3 +594,113 @@ describe("IL2CPP narrow index sentinels", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("IL2CPP metadata members and string literals", () => {
|
||||
it.each([
|
||||
{ version: 24, typeIndexWidth: 4 },
|
||||
{ version: 27, typeIndexWidth: 4 },
|
||||
{ version: 29, typeIndexWidth: 4 },
|
||||
{ version: 31, typeIndexWidth: 4 },
|
||||
{ version: 39, typeIndexWidth: 1 },
|
||||
{ version: 39, typeIndexWidth: 2 },
|
||||
{ version: 39, typeIndexWidth: 4 },
|
||||
] as const)(
|
||||
"decodes nonempty properties and events with distinct owners: %j",
|
||||
(options) => {
|
||||
const fixture = createIl2cppMetadataFixture({
|
||||
...options,
|
||||
propertiesAndEvents: true,
|
||||
});
|
||||
const decoded = readIl2cppMetadata(fixture.bytes);
|
||||
expect(decoded.properties).toEqual(FIXTURE_PROPERTIES);
|
||||
expect(decoded.events).toEqual(FIXTURE_EVENTS);
|
||||
expect(fixture.section("properties").size).toBe(3 * 20);
|
||||
expect(fixture.section("events").size).toBe(
|
||||
2 * (20 + options.typeIndexWidth),
|
||||
);
|
||||
},
|
||||
);
|
||||
it.each([29, 31, 39] as const)(
|
||||
"pairs version %i members with their owners and keeps undecodable literals as bytes",
|
||||
(version) => {
|
||||
const decoded = readIl2cppMetadata(
|
||||
createIl2cppMetadataFixture({ version }).bytes,
|
||||
);
|
||||
expect(decoded.fields).toEqual(FIXTURE_FIELDS);
|
||||
expect(decoded.parameters).toEqual(FIXTURE_PARAMETERS);
|
||||
expect(decoded.properties).toEqual([]);
|
||||
expect(decoded.events).toEqual([]);
|
||||
expect(decoded.string_literals).toEqual(FIXTURE_STRING_LITERALS);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects member ranges claimed twice or outside their table", () => {
|
||||
// Type 1 claims field 0 as well, which type 0 already owns.
|
||||
rejects(
|
||||
mutated((fixture, bytes) => {
|
||||
const starts = fixture.record("typeDefinitions", 1) + 8 + 3 + 1 + 4;
|
||||
bytes.writeInt32LE(0, starts);
|
||||
}),
|
||||
"declaring-type-mismatch",
|
||||
);
|
||||
rejects(
|
||||
mutated((fixture, bytes) => {
|
||||
const starts = fixture.record("typeDefinitions", 1) + 8 + 3 + 1 + 4;
|
||||
bytes.writeInt32LE(2, starts);
|
||||
}),
|
||||
"index-out-of-range",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects string literals outside the literal data", () => {
|
||||
rejects(
|
||||
mutated((fixture, bytes) =>
|
||||
bytes.writeInt32LE(99, fixture.section("stringLiterals").offset + 4),
|
||||
),
|
||||
"index-out-of-range",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IL2CPP global-metadata version 27", () => {
|
||||
it("decodes the version 29 record layout with version 27 section names", () => {
|
||||
const decoded = readIl2cppMetadata(
|
||||
createIl2cppMetadataFixture({ version: 27 }).bytes,
|
||||
);
|
||||
expect(decoded.metadata_version).toBe(27);
|
||||
expect(decoded.index_widths.type_width_source).toBe("fixed-width-version");
|
||||
expect(decoded.sections.slice(24, 28).map(({ name }) => name)).toEqual([
|
||||
"attributesInfo",
|
||||
"attributeTypes",
|
||||
"unresolvedVirtualCallParameterTypes",
|
||||
"unresolvedVirtualCallParameterRanges",
|
||||
]);
|
||||
expect(decoded.images).toEqual(FIXTURE_IMAGES);
|
||||
expect(decoded.types).toEqual(FIXTURE_TYPES);
|
||||
expect(decoded.methods).toEqual(FIXTURE_METHODS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IL2CPP global-metadata version 24", () => {
|
||||
it("decodes the 32-section header and five-type-index records", () => {
|
||||
const fixture = createIl2cppMetadataFixture({ version: 24 });
|
||||
const decoded = readIl2cppMetadata(fixture.bytes);
|
||||
expect(decoded.metadata_version).toBe(24);
|
||||
expect(decoded.sections).toHaveLength(32);
|
||||
expect(decoded.sections[0]?.offset).toBe(264);
|
||||
expect(decoded.sections[22]?.name).toBe("metadataUsageLists");
|
||||
expect(decoded.images).toEqual(FIXTURE_IMAGES);
|
||||
expect(decoded.types).toEqual(FIXTURE_TYPES);
|
||||
expect(decoded.methods).toEqual(FIXTURE_METHODS);
|
||||
});
|
||||
|
||||
it("rejects version 24 records labeled as version 27", () => {
|
||||
rejects(
|
||||
mutated(
|
||||
(_, bytes) => bytes.writeInt32LE(27, 4),
|
||||
createIl2cppMetadataFixture({ version: 24 }),
|
||||
),
|
||||
"unexpected-header-size",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,96 @@ const sharedNameMetadata = (
|
||||
return Buffer.concat([header, name, records]);
|
||||
};
|
||||
|
||||
/** Fixed-width metadata whose literals share one span; no target code is involved. */
|
||||
const sharedLiteralMetadata = (literalBytes: Buffer, count: number) => {
|
||||
const header = Buffer.alloc(256),
|
||||
records = Buffer.alloc(count * 8);
|
||||
header.writeUInt32LE(0xfab11baf, 0);
|
||||
header.writeInt32LE(29, 4);
|
||||
for (let i = 0; i < count; i++)
|
||||
records.writeUInt32LE(literalBytes.length, i * 8);
|
||||
let cursor = 256;
|
||||
for (let section = 0; section < 31; section++) {
|
||||
const size =
|
||||
section === 0 ? records.length : section === 1 ? literalBytes.length : 0;
|
||||
header.writeInt32LE(cursor, 8 + section * 8);
|
||||
header.writeInt32LE(size, 12 + section * 8);
|
||||
cursor += size;
|
||||
}
|
||||
return Buffer.concat([header, records, literalBytes]);
|
||||
};
|
||||
|
||||
it("bounds repeated literal output during decoding and reuses one span instead of allocating every repetition", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-literals-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
await writeFile(path, sharedLiteralMetadata(Buffer.alloc(4096, 65), 20));
|
||||
const decode = vi.spyOn(TextDecoder.prototype, "decode");
|
||||
const provider = new Il2cppMetadataProvider(12_000);
|
||||
try {
|
||||
const result = await provider.inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expanded literals accepted");
|
||||
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
normalized_result: {
|
||||
validated_section_row_counts: { string_literals: 20 },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
decode.mock.calls.filter(([bytes]) => bytes?.byteLength === 4096),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses raw literal expansion before decoding and admits invalid UTF-8 only when base64 fits", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-literal-forms-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const decode = vi.spyOn(TextDecoder.prototype, "decode");
|
||||
for (const [bytes, limit, expectedDecodes] of [
|
||||
[Buffer.alloc(4096, 65), 1024, 0],
|
||||
[Buffer.alloc(2048, 255), 2400, 1],
|
||||
] as const) {
|
||||
decode.mockClear();
|
||||
await writeFile(path, sharedLiteralMetadata(bytes, 1));
|
||||
const provider = new Il2cppMetadataProvider(limit);
|
||||
try {
|
||||
const result = await provider.inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
expect(
|
||||
decode.mock.calls.filter(
|
||||
([value]) => value?.byteLength === bytes.length,
|
||||
),
|
||||
).toHaveLength(expectedDecodes);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("counts an unclaimed parameter table before allocating any member rows", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-member-counts-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
const fixture = createIl2cppMetadataFixture({
|
||||
wide: { fillerTypes: 0, genericContainers: 0, parameters: 2000 },
|
||||
});
|
||||
const bytes = Buffer.from(fixture.bytes);
|
||||
bytes.writeInt32LE(0x7fffffff, fixture.section("parameters").offset);
|
||||
await writeFile(path, bytes);
|
||||
const provider = new Il2cppMetadataProvider(4096);
|
||||
try {
|
||||
const result = await provider.inspect({ path });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBeInstanceOf(AnalysisResourceConstraintError);
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an oversized report from its row lower bound before serializing it", async () => {
|
||||
const root = await createTestTempDirectory("rea-il2cpp-provider-budget-");
|
||||
const path = join(root, "global-metadata.dat");
|
||||
|
||||
Vendored
+334
-96
@@ -1,6 +1,9 @@
|
||||
import { IL2CPP_METADATA_SECTION_NAMES } from "../../../src/domain/il2cpp/il2cppMetadata.js";
|
||||
import {
|
||||
il2cppMetadataSectionNames,
|
||||
type IL2cppMetadataSectionName,
|
||||
} from "../../../src/domain/il2cpp/il2cppMetadata.js";
|
||||
|
||||
type SectionName = (typeof IL2CPP_METADATA_SECTION_NAMES)[number];
|
||||
type SectionName = IL2cppMetadataSectionName;
|
||||
type Width = 1 | 2 | 4;
|
||||
|
||||
/** Synthetic version 39 metadata small enough to read in a hex dump. */
|
||||
@@ -15,12 +18,14 @@ export interface Il2cppMetadataFixtureOptions {
|
||||
readonly nestingCycle?: boolean;
|
||||
/** Append an image with no types, whose start is the -1 sentinel at the narrow width. */
|
||||
readonly emptyImage?: boolean;
|
||||
/** Nonempty property/event tables with deliberately different owner ranges. */
|
||||
readonly propertiesAndEvents?: boolean;
|
||||
/** Metadata version; 29 and 31 use fixed 4-byte indices and no header counts. */
|
||||
readonly version?: 29 | 31 | 39;
|
||||
readonly version?: 24 | 27 | 29 | 31 | 39;
|
||||
/**
|
||||
* Version 39 entry counts that widen the serialized indices: filler type
|
||||
* definitions (the last owns one extra method), and generic container and
|
||||
* parameter sections whose records are zero-filled and never decoded.
|
||||
* definitions (the last owns one extra method), zero-filled generic
|
||||
* containers, and zero-filled parameter rows that no method owns.
|
||||
*/
|
||||
readonly wide?: {
|
||||
readonly fillerTypes: number;
|
||||
@@ -29,32 +34,6 @@ export interface Il2cppMetadataFixtureOptions {
|
||||
};
|
||||
}
|
||||
|
||||
const widthFor = (entries: number): Width =>
|
||||
entries <= 0xff ? 1 : entries <= 0xffff ? 2 : 4;
|
||||
|
||||
/** Type and method rows a fixture holds, including any wide-index filler. */
|
||||
export const fixtureRows = (options: Il2cppMetadataFixtureOptions = {}) => {
|
||||
const fillers = options.wide?.fillerTypes ?? 0;
|
||||
const last = FIXTURE_TYPES.length + fillers - 1;
|
||||
const types = [
|
||||
...FIXTURE_TYPES,
|
||||
...Array.from({ length: fillers }, (_, index) => ({
|
||||
name: "Filler",
|
||||
namespace: "",
|
||||
method_start: index === fillers - 1 ? FIXTURE_METHODS.length : -1,
|
||||
method_count: index === fillers - 1 ? 1 : 0,
|
||||
nested_type_start: -1,
|
||||
nested_type_count: 0,
|
||||
declaring_type: -1,
|
||||
})),
|
||||
];
|
||||
const methods = [
|
||||
...FIXTURE_METHODS,
|
||||
...(fillers === 0 ? [] : [{ name: "Fill", declaring_type: last }]),
|
||||
];
|
||||
return { types, methods };
|
||||
};
|
||||
|
||||
export const FIXTURE_IMAGES = [
|
||||
{ name: "Assembly-CSharp.dll", type_start: 0, type_count: 3 },
|
||||
{ name: "mscorlib.dll", type_start: 3, type_count: 1 },
|
||||
@@ -107,6 +86,40 @@ export const FIXTURE_METHODS = [
|
||||
{ name: "ToString", declaring_type: 3 },
|
||||
] as const;
|
||||
|
||||
/** Members of types 0 and 1; row position is the FieldIndex. */
|
||||
export const FIXTURE_FIELDS = [
|
||||
{ name: "slot", declaring_type: 0 },
|
||||
{ name: "Count", declaring_type: 1 },
|
||||
] as const;
|
||||
|
||||
export const FIXTURE_PROPERTIES = [
|
||||
{ name: "IsActive", declaring_type: 0 },
|
||||
{ name: "DisplayName", declaring_type: 0 },
|
||||
{ name: "Capacity", declaring_type: 3 },
|
||||
] as const;
|
||||
|
||||
export const FIXTURE_EVENTS = [
|
||||
{ name: "Changed", declaring_type: 1 },
|
||||
{ name: "Completed", declaring_type: 3 },
|
||||
] as const;
|
||||
|
||||
/** The one parameter, owned by method 2 (`Add`). */
|
||||
export const FIXTURE_PARAMETERS = [{ name: "amount", method: 2 }] as const;
|
||||
|
||||
const INVALID_UTF8_LITERAL = Buffer.from([0xff, 0xfe]);
|
||||
export const FIXTURE_STRING_LITERALS = [
|
||||
{ value: "Hello", value_base64: null },
|
||||
{ value: null, value_base64: INVALID_UTF8_LITERAL.toString("base64") },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Per-image method tokens. Row ids restart in each image and do not follow
|
||||
* metadata order, so linking must use the token rather than the position.
|
||||
*/
|
||||
export const FIXTURE_METHOD_TOKENS = [
|
||||
0x06000002, 0x06000001, 0x06000003, 0x06000001, 0x06000002,
|
||||
] as const;
|
||||
|
||||
const writeIndex = (
|
||||
target: Buffer,
|
||||
offset: number,
|
||||
@@ -144,18 +157,19 @@ type SectionContent = { bytes: Buffer; count: number };
|
||||
|
||||
/** Write the header and lay out non-empty sections in header order. */
|
||||
const assembleFile = (
|
||||
version: 29 | 31 | 39,
|
||||
version: 24 | 27 | 29 | 31 | 39,
|
||||
contents: ReadonlyMap<SectionName, SectionContent>,
|
||||
) => {
|
||||
const fixed = version !== 39;
|
||||
const entryBytes = fixed ? 8 : 12;
|
||||
const headerBytes = 8 + IL2CPP_METADATA_SECTION_NAMES.length * entryBytes;
|
||||
const names = il2cppMetadataSectionNames(version);
|
||||
const headerBytes = 8 + names.length * entryBytes;
|
||||
const header = Buffer.alloc(headerBytes);
|
||||
header.writeUInt32LE(0xfab11baf, 0);
|
||||
header.writeInt32LE(version, 4);
|
||||
const body: Buffer[] = [];
|
||||
let cursor = headerBytes;
|
||||
IL2CPP_METADATA_SECTION_NAMES.forEach((name, index) => {
|
||||
names.forEach((name, index) => {
|
||||
const content = contents.get(name);
|
||||
const at = 8 + index * entryBytes;
|
||||
header.writeInt32LE(cursor, at);
|
||||
@@ -175,7 +189,7 @@ const assembleFile = (
|
||||
throw new Error(`Version ${version} headers record no counts`);
|
||||
return (
|
||||
8 +
|
||||
IL2CPP_METADATA_SECTION_NAMES.indexOf(name) * entryBytes +
|
||||
names.indexOf(name) * entryBytes +
|
||||
{ offset: 0, size: 4, count: 8 }[field]
|
||||
);
|
||||
};
|
||||
@@ -195,6 +209,245 @@ export const fixtureImages = (options: Il2cppMetadataFixtureOptions = {}) => [
|
||||
: []),
|
||||
];
|
||||
|
||||
/** Method records with parameter ranges for the fixture parameters. */
|
||||
const encodeMethods = (
|
||||
rows: readonly { readonly name: string; readonly declaring_type: number }[],
|
||||
methodBytes: number,
|
||||
{ D, T, P, G }: Readonly<Record<"D" | "T" | "P" | "G", Width>>,
|
||||
returnToken: number,
|
||||
intern: (value: string) => number,
|
||||
) => {
|
||||
const recordBytes = { methods: methodBytes };
|
||||
const methods = Buffer.alloc(rows.length * recordBytes.methods);
|
||||
rows.forEach((method, index) => {
|
||||
const at = index * recordBytes.methods;
|
||||
methods.writeInt32LE(intern(method.name), at);
|
||||
writeIndex(methods, at + 4, D, method.declaring_type);
|
||||
writeIndex(methods, at + 4 + D, T, 0);
|
||||
const parameterStart = at + 4 + D + T + returnToken;
|
||||
const parameters = FIXTURE_PARAMETERS.filter(
|
||||
(parameter) => parameter.method === index,
|
||||
).length;
|
||||
writeIndex(
|
||||
methods,
|
||||
parameterStart,
|
||||
P,
|
||||
parameters === 0
|
||||
? -1
|
||||
: FIXTURE_PARAMETERS.findIndex(
|
||||
(parameter) => parameter.method === index,
|
||||
),
|
||||
);
|
||||
writeIndex(methods, parameterStart + P, G, -1);
|
||||
methods.writeUInt32LE(
|
||||
FIXTURE_METHOD_TOKENS[index]!,
|
||||
parameterStart + P + G,
|
||||
);
|
||||
methods.writeUInt16LE(parameters, at + recordBytes.methods - 2);
|
||||
});
|
||||
|
||||
return methods;
|
||||
};
|
||||
|
||||
/** Named member, parameter and string literal sections for the fixture rows. */
|
||||
const encodeMembers = ({
|
||||
T,
|
||||
fixed,
|
||||
intern,
|
||||
named,
|
||||
}: {
|
||||
readonly T: Width;
|
||||
readonly fixed: boolean;
|
||||
readonly intern: (value: string) => number;
|
||||
readonly named: {
|
||||
readonly properties: readonly { name: string }[];
|
||||
readonly events: readonly { name: string }[];
|
||||
};
|
||||
}) => {
|
||||
// Field records: name, type index, token. Parameters: name, token, type.
|
||||
const fields = Buffer.alloc(FIXTURE_FIELDS.length * (8 + T));
|
||||
FIXTURE_FIELDS.forEach((field, index) => {
|
||||
fields.writeInt32LE(intern(field.name), index * (8 + T));
|
||||
writeIndex(fields, index * (8 + T) + 4, T, 0);
|
||||
});
|
||||
// Property: name, get, set, flags, token. Event: name, TypeIndex,
|
||||
// add/remove/raise MethodIndex values, token. Method indices stay int32.
|
||||
const properties = Buffer.alloc(named.properties.length * 20);
|
||||
named.properties.forEach((property, index) => {
|
||||
const at = index * 20;
|
||||
properties.writeInt32LE(intern(property.name), at);
|
||||
properties.writeInt32LE(0, at + 4);
|
||||
properties.writeInt32LE(1, at + 8);
|
||||
properties.writeUInt32LE(0x17000001 + index, at + 16);
|
||||
});
|
||||
const events = Buffer.alloc(named.events.length * (20 + T));
|
||||
named.events.forEach((event, index) => {
|
||||
const at = index * (20 + T);
|
||||
events.writeInt32LE(intern(event.name), at);
|
||||
writeIndex(events, at + 4, T, 0x2a);
|
||||
events.writeInt32LE(1, at + 4 + T);
|
||||
events.writeInt32LE(2, at + 8 + T);
|
||||
events.writeInt32LE(3, at + 12 + T);
|
||||
events.writeUInt32LE(0x14000001 + index, at + 16 + T);
|
||||
});
|
||||
const parameterRows = Buffer.alloc(FIXTURE_PARAMETERS.length * (8 + T));
|
||||
FIXTURE_PARAMETERS.forEach((parameter, index) => {
|
||||
parameterRows.writeInt32LE(intern(parameter.name), index * (8 + T));
|
||||
writeIndex(parameterRows, index * (8 + T) + 8, T, 0);
|
||||
});
|
||||
// Version 39 stores start offsets plus a sentinel; earlier versions store
|
||||
// (length, offset) pairs.
|
||||
const literalData = Buffer.concat([
|
||||
Buffer.from("Hello", "utf8"),
|
||||
INVALID_UTF8_LITERAL,
|
||||
]);
|
||||
const literalStarts = [0, 5, literalData.length];
|
||||
const literals = fixed
|
||||
? Buffer.alloc(16)
|
||||
: Buffer.alloc(literalStarts.length * 4);
|
||||
if (fixed)
|
||||
[
|
||||
[5, 0],
|
||||
[INVALID_UTF8_LITERAL.length, 5],
|
||||
].forEach(([length, start], index) => {
|
||||
literals.writeUInt32LE(length!, index * 8);
|
||||
literals.writeInt32LE(start!, index * 8 + 4);
|
||||
});
|
||||
else
|
||||
literalStarts.forEach((start, index) =>
|
||||
literals.writeInt32LE(start, index * 4),
|
||||
);
|
||||
|
||||
return {
|
||||
fields,
|
||||
properties,
|
||||
events,
|
||||
parameterRows,
|
||||
literals,
|
||||
literalData,
|
||||
literalRecords: fixed ? 2 : literalStarts.length,
|
||||
};
|
||||
};
|
||||
|
||||
const widthFor = (entries: number): Width =>
|
||||
entries <= 0xff ? 1 : entries <= 0xffff ? 2 : 4;
|
||||
|
||||
/** Type and method rows a fixture holds, including any wide-index filler. */
|
||||
export const fixtureRows = (options: Il2cppMetadataFixtureOptions = {}) => {
|
||||
const fillers = options.wide?.fillerTypes ?? 0;
|
||||
const last = FIXTURE_TYPES.length + fillers - 1;
|
||||
const types = [
|
||||
...FIXTURE_TYPES,
|
||||
...Array.from({ length: fillers }, (_, index) => ({
|
||||
name: "Filler",
|
||||
namespace: "",
|
||||
method_start: index === fillers - 1 ? FIXTURE_METHODS.length : -1,
|
||||
method_count: index === fillers - 1 ? 1 : 0,
|
||||
nested_type_start: -1,
|
||||
nested_type_count: 0,
|
||||
declaring_type: -1,
|
||||
})),
|
||||
];
|
||||
const methods = [
|
||||
...FIXTURE_METHODS,
|
||||
...(fillers === 0 ? [] : [{ name: "Fill", declaring_type: last }]),
|
||||
];
|
||||
return { types, methods };
|
||||
};
|
||||
|
||||
/** A zero-filled section of `count` records, or none when `count` is 0. */
|
||||
const zeroFilledSection = (
|
||||
name: SectionName,
|
||||
count: number,
|
||||
recordBytes: number,
|
||||
): [SectionName, SectionContent][] =>
|
||||
count === 0
|
||||
? []
|
||||
: [[name, { bytes: Buffer.alloc(count * recordBytes), count }]];
|
||||
|
||||
/** Real rows followed by zero-filled rows that no owner claims. */
|
||||
const paddedRows = (
|
||||
rows: Buffer,
|
||||
count: number,
|
||||
recordBytes: number,
|
||||
): SectionContent => ({
|
||||
bytes: Buffer.concat([rows, Buffer.alloc(count * recordBytes - rows.length)]),
|
||||
count,
|
||||
});
|
||||
|
||||
/** Encode the type record and its distinct member ranges in one place. */
|
||||
const encodeTypes = ({
|
||||
typeRows,
|
||||
recordSize,
|
||||
T,
|
||||
G,
|
||||
typeIndexFields,
|
||||
named,
|
||||
intern,
|
||||
}: {
|
||||
readonly typeRows: ReturnType<typeof fixtureRows>["types"];
|
||||
readonly recordSize: number;
|
||||
readonly T: Width;
|
||||
readonly G: Width;
|
||||
readonly typeIndexFields: number;
|
||||
readonly named: {
|
||||
readonly properties: readonly { declaring_type: number }[];
|
||||
readonly events: readonly { declaring_type: number }[];
|
||||
};
|
||||
readonly intern: (value: string) => number;
|
||||
}) => {
|
||||
const types = Buffer.alloc(typeRows.length * recordSize);
|
||||
typeRows.forEach((type, index) => {
|
||||
let at = index * recordSize;
|
||||
types.writeInt32LE(intern(type.name), at);
|
||||
types.writeInt32LE(intern(type.namespace), at + 4);
|
||||
at += 8;
|
||||
// byval, native declaring and parent TypeIndex values are deliberately
|
||||
// unrelated to nesting; the reader must use nestedTypes instead.
|
||||
for (let field = 0; field < typeIndexFields; field += 1, at += T)
|
||||
writeIndex(types, at, T, 0x2a);
|
||||
writeIndex(types, at, G, -1);
|
||||
at += G + 4;
|
||||
const fieldStart = FIXTURE_FIELDS.findIndex(
|
||||
(field) => field.declaring_type === index,
|
||||
);
|
||||
const fieldCount = FIXTURE_FIELDS.filter(
|
||||
(field) => field.declaring_type === index,
|
||||
).length;
|
||||
const starts = [
|
||||
fieldStart,
|
||||
type.method_start,
|
||||
named.events.findIndex((event) => event.declaring_type === index),
|
||||
named.properties.findIndex(
|
||||
(property) => property.declaring_type === index,
|
||||
),
|
||||
type.nested_type_start,
|
||||
];
|
||||
starts.forEach((value, field) => types.writeInt32LE(value, at + field * 4));
|
||||
types.writeInt32LE(-1, at + 20);
|
||||
types.writeInt32LE(-1, at + 24);
|
||||
types.writeInt32LE(-1, at + 28);
|
||||
at += 32;
|
||||
types.writeUInt16LE(type.method_count, at);
|
||||
types.writeUInt16LE(
|
||||
named.properties.filter((property) => property.declaring_type === index)
|
||||
.length,
|
||||
at + 2,
|
||||
);
|
||||
types.writeUInt16LE(fieldCount, at + 4);
|
||||
types.writeUInt16LE(
|
||||
named.events.filter((event) => event.declaring_type === index).length,
|
||||
at + 6,
|
||||
);
|
||||
types.writeUInt16LE(type.nested_type_count, at + 8);
|
||||
at += 16;
|
||||
types.writeUInt32LE(0, at);
|
||||
types.writeUInt32LE(0x02000001 + index, at + 4);
|
||||
});
|
||||
|
||||
return types;
|
||||
};
|
||||
|
||||
/** Lay sections out in header order; empty sections share the next offset. */
|
||||
export const createIl2cppMetadataFixture = (
|
||||
options: Il2cppMetadataFixtureOptions = {},
|
||||
@@ -203,15 +456,22 @@ export const createIl2cppMetadataFixture = (
|
||||
const fixed = version !== 39;
|
||||
const T: Width = fixed ? 4 : (options.typeIndexWidth ?? 1);
|
||||
const rows = fixtureRows(options);
|
||||
const named = {
|
||||
properties: options.propertiesAndEvents ? FIXTURE_PROPERTIES : [],
|
||||
events: options.propertiesAndEvents ? FIXTURE_EVENTS : [],
|
||||
};
|
||||
const genericContainers = options.wide?.genericContainers ?? 0;
|
||||
const parameterCount = options.wide?.parameters ?? 0;
|
||||
const parameterCount = Math.max(
|
||||
options.wide?.parameters ?? 0,
|
||||
FIXTURE_PARAMETERS.length,
|
||||
);
|
||||
const D: Width = fixed ? 4 : widthFor(rows.types.length);
|
||||
const G: Width = fixed ? 4 : widthFor(genericContainers);
|
||||
const P: Width = fixed ? 4 : widthFor(parameterCount);
|
||||
// Versions 29 and 31 also store elementTypeIndex in type records.
|
||||
const typeIndexFields = fixed ? 4 : 3;
|
||||
const typeIndexFields = version === 24 ? 5 : fixed ? 4 : 3;
|
||||
// Version 31 added returnParameterToken to method records.
|
||||
const returnToken = version === 29 ? 0 : 4;
|
||||
const returnToken = version === 31 || version === 39 ? 4 : 0;
|
||||
const nestedTypeEntries = options.nestingCycle === true ? [1, 0] : [1];
|
||||
const typeRows = rows.types.map((type, index) =>
|
||||
options.nestingCycle === true && index === 1
|
||||
@@ -243,31 +503,17 @@ export const createIl2cppMetadataFixture = (
|
||||
intern(type.namespace);
|
||||
}
|
||||
for (const row of [...rows.types, ...rows.methods]) intern(row.name);
|
||||
const strings = Buffer.concat(stringParts);
|
||||
for (const row of [...FIXTURE_FIELDS, ...FIXTURE_PARAMETERS])
|
||||
intern(row.name);
|
||||
|
||||
const types = Buffer.alloc(typeRows.length * recordBytes.typeDefinitions);
|
||||
typeRows.forEach((type, index) => {
|
||||
let at = index * recordBytes.typeDefinitions;
|
||||
types.writeInt32LE(intern(type.name), at);
|
||||
types.writeInt32LE(intern(type.namespace), at + 4);
|
||||
at += 8;
|
||||
// byval, native declaring and parent TypeIndex values are deliberately
|
||||
// unrelated to nesting; the reader must use nestedTypes instead.
|
||||
for (let field = 0; field < typeIndexFields; field += 1, at += T)
|
||||
writeIndex(types, at, T, 0x2a);
|
||||
writeIndex(types, at, G, -1);
|
||||
at += G + 4;
|
||||
const starts = [-1, type.method_start, -1, -1, type.nested_type_start];
|
||||
starts.forEach((value, field) => types.writeInt32LE(value, at + field * 4));
|
||||
types.writeInt32LE(-1, at + 20);
|
||||
types.writeInt32LE(-1, at + 24);
|
||||
types.writeInt32LE(-1, at + 28);
|
||||
at += 32;
|
||||
types.writeUInt16LE(type.method_count, at);
|
||||
types.writeUInt16LE(type.nested_type_count, at + 8);
|
||||
at += 16;
|
||||
types.writeUInt32LE(0, at);
|
||||
types.writeUInt32LE(0x02000001 + index, at + 4);
|
||||
const types = encodeTypes({
|
||||
typeRows,
|
||||
recordSize: recordBytes.typeDefinitions,
|
||||
T,
|
||||
G,
|
||||
typeIndexFields,
|
||||
named,
|
||||
intern,
|
||||
});
|
||||
|
||||
const images = Buffer.alloc(imageRows.length * recordBytes.images);
|
||||
@@ -282,17 +528,23 @@ export const createIl2cppMetadataFixture = (
|
||||
images.writeInt32LE(-1, at + 16 + 2 * D);
|
||||
});
|
||||
|
||||
const methods = Buffer.alloc(rows.methods.length * recordBytes.methods);
|
||||
rows.methods.forEach((method, index) => {
|
||||
const at = index * recordBytes.methods;
|
||||
methods.writeInt32LE(intern(method.name), at);
|
||||
writeIndex(methods, at + 4, D, method.declaring_type);
|
||||
writeIndex(methods, at + 4 + D, T, 0);
|
||||
const parameterStart = at + 4 + D + T + returnToken;
|
||||
writeIndex(methods, parameterStart, P, -1);
|
||||
writeIndex(methods, parameterStart + P, G, -1);
|
||||
methods.writeUInt32LE(0x06000001 + index, parameterStart + P + G);
|
||||
});
|
||||
const methods = encodeMethods(
|
||||
rows.methods,
|
||||
recordBytes.methods,
|
||||
{ D, T, P, G },
|
||||
returnToken,
|
||||
intern,
|
||||
);
|
||||
|
||||
const {
|
||||
fields,
|
||||
properties,
|
||||
events,
|
||||
parameterRows,
|
||||
literals,
|
||||
literalData,
|
||||
literalRecords,
|
||||
} = encodeMembers({ T, fixed, intern, named });
|
||||
|
||||
const nested = Buffer.alloc(nestedTypeEntries.length * 4);
|
||||
nestedTypeEntries.forEach((value, index) =>
|
||||
@@ -306,30 +558,16 @@ export const createIl2cppMetadataFixture = (
|
||||
: Buffer.alloc(interfaceOffsetBytes);
|
||||
|
||||
const contents = new Map<SectionName, SectionContent>([
|
||||
["strings", { bytes: strings, count: stringIndex.size }],
|
||||
["stringLiterals", { bytes: literals, count: literalRecords }],
|
||||
["stringLiteralData", { bytes: literalData, count: 2 }],
|
||||
["strings", { bytes: Buffer.concat(stringParts), count: stringIndex.size }],
|
||||
// Wide-index padding rows are zero-filled and owned by no method.
|
||||
["parameters", paddedRows(parameterRows, parameterCount, 8 + T)],
|
||||
["fields", { bytes: fields, count: FIXTURE_FIELDS.length }],
|
||||
["properties", { bytes: properties, count: named.properties.length }],
|
||||
["events", { bytes: events, count: named.events.length }],
|
||||
["methods", { bytes: methods, count: rows.methods.length }],
|
||||
...(genericContainers === 0
|
||||
? []
|
||||
: [
|
||||
[
|
||||
"genericContainers",
|
||||
{
|
||||
bytes: Buffer.alloc(genericContainers * 16),
|
||||
count: genericContainers,
|
||||
},
|
||||
] as [SectionName, SectionContent],
|
||||
]),
|
||||
...(parameterCount === 0
|
||||
? []
|
||||
: [
|
||||
[
|
||||
"parameters",
|
||||
{
|
||||
bytes: Buffer.alloc(parameterCount * (8 + T)),
|
||||
count: parameterCount,
|
||||
},
|
||||
] as [SectionName, SectionContent],
|
||||
]),
|
||||
...zeroFilledSection("genericContainers", genericContainers, 16),
|
||||
["nestedTypes", { bytes: nested, count: nestedTypeEntries.length }],
|
||||
["typeDefinitions", { bytes: types, count: typeRows.length }],
|
||||
["images", { bytes: images, count: imageRows.length }],
|
||||
|
||||
Reference in New Issue
Block a user