chore: merge latest main before publishing audit fixes

This commit is contained in:
morluto committed 2026-10-20 19:29:26 +08:00
commit 00a055be0f
24 files changed
+1707 -315

No files matched your search

@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+6 -1
View File
@@ -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,
+2 -2
View File
@@ -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 }) =>
+1 -1
View File
@@ -17,7 +17,7 @@ export const ANALYSIS_VIEW_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_analysis_view"),
kind: "application",
description:
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized non-entity paths written by POST/PUT/PATCH (item-shaped and delete-only paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: "<absolute path>"}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module refeLine truncated
'Inspect selected views of completed binary-layout, JavaScript application, native analyze_function, inspect_artifact, inventory_artifact, inspect_wasm_artifact, inspect_macho, decode_interface_builder, inspect_evm_interface, inspect_managed_artifact, inspect_managed_members, inspect_android_package, inspect_dart_aot, inspect_il2cpp_metadata or inspect_web_network_capture Evidence without repeating analysis. Historical capture summaries return identity and counts without bodies or archive inventories. Capture views page retained records: view {kind: "capture", detail: "preview" | "complete", offset: 0, limit: 20}; preview selects recorded headline fields and binary descriptors, complete returns exact records with all sidecars. Offsets follow the selected parent record order, preserving original producer ordinals. Paging never reopens the source or reruns the decoder. For inspect_pcap_capture parents, kind pcap pages endpoints, dns-queries, tls-sni, protocols, http-uris or unattributed-addresses. Select an exact query for dns-addresses, or a tshark/capinfos stream for UTF-16 diagnostics text. Page totals count retained facts; source coverage remains explicit. Qlog summaries keep profile/scope and counts; kind qlog pages traces, streams, loss-declarations, incomplete-records, contributions, ranges or final-sizes. Except traces, select trace_index; stream-specific facets also require exact stream_id and direction. Facts retain all-recorded-events-before-selection scope, and interval overlap or endpoint loss declarations do not prove retransmission or network loss. The api-surface view groups retained HAR requests by method, origin and format-rule path template; templated segments are inferences. It reads no headers or bodies and reports no query values. The environment-manifest view projects the same HAR requests on the primary origin (most JSON-like records) into a replayable tool surface: view {kind: "environment-manifest", template_rules: ["after-uid-literal", "opaque-token", "sibling-variance"], include_query_parameter_names: true}. Each tool has a deterministic name, a path template and parameters from the four format rules plus the selected opt-in rules, observed query parameter names, a value-free body shape for POST/PUT/PATCH, status and content-type sets and record ordinals; records are pointers into the parent, excluded groups carry a reason and three fixed unknowns are always stated. Templates, parameters and names are inferences; the view copies no header, cookie or body values and refuses parents inspected with sensitive values. A capture_browser_scenario parent that selected response bodies or header values projects the same way, one record per network transaction in request order; it relies on capture-time redaction of declared secrets instead of that refusal, and a transaction whose URL was redacted is left unrepresented. The environment-state view extracts, from the same parents, the initial state the recorded reads observed: view {kind: "environment-state", template_rules: [...]} with the rules of the manifest it accompanies. It reads response bodies recorded before the first related write and reports entity types with an identity field (proved by a path parameter or inferred from a field name), entities in collection order with per-view values where views differ, documents for non-parameterized non-entity paths written by POST/PUT/PATCH (item-shaped and delete-only paths stay recorded), and a render plan per read tool: item and list views reproduce their latest recordings from the state, and every other read tool is a recorded view with a reason. Types and identity are inferences; values are recorded data, so it refuses parents inspected with sensitive values, and a scenario parent must have selected response bodies; its values keep the capture redaction markers, and a write whose URL it redacted ends the state of every type. For a retained record use source: {kind: "retained-evidence", evidence_id: "<parent_evidence_id>"}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: <complete Evidence>}. A completed Evidence JSON file on this host uses {kind: "evidence-file", path: "<absolute path>"}: REA reads and authenticates it, then projects it like retained Evidence, so a large capture is bounded by the file reader rather than by inline-input accounting. For layout, JavaScript, inventory, WASM, Mach-O, Interface Builder, EVM, managed artifact, Android package and Dart AOT sources, request a summary with view: {kind: "summary"}; summary is a view kind and has no facet. The WASM summary gives validation, counts, WAT digest/length, tool profile and execution/network states without reading WAT text. Select a layout mitigations/linkage facet, one section/symbol/module, or a stable page. Module pages include assets, bundled and source modules; use node_id for ambiguous or unavailable paths. Typed module refeLine truncated
inputSchema: inspectAnalysisViewInputSchema,
outputSchema: evidenceResultOf(analysisViewResultSchema),
examples: [
+1 -1
View File
@@ -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, {
+125 -17
View File
@@ -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({
+148 -20
View File
@@ -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
+66
View File
@@ -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;
},
};
};
+146 -88
View File
@@ -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 (;;) {
+53
View File
@@ -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`,
);
};
+226
View File
@@ -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),
};
}
+10 -31
View File
@@ -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)
+114 -5
View File
@@ -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");
+334 -96
View File
@@ -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 }],