diff --git a/.agents/skills/reverse-engineer-anything/SKILL.md b/.agents/skills/reverse-engineer-anything/SKILL.md index b659cff7ef..c09f476179 100644 --- a/.agents/skills/reverse-engineer-anything/SKILL.md +++ b/.agents/skills/reverse-engineer-anything/SKILL.md @@ -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 diff --git a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md index a9c49e748c..3c459481ff 100644 --- a/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md +++ b/.agents/skills/reverse-engineer-anything/references/native-and-artifacts.md @@ -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. diff --git a/README.md b/README.md index d75cac321b..c20e53b81d 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/docs/cli.md b/docs/cli.md index 85cb8029b1..346b859b22 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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). diff --git a/docs/il2cpp-metadata.md b/docs/il2cpp-metadata.md index 16a5de4863..8bd950895d 100644 --- a/docs/il2cpp-metadata.md +++ b/docs/il2cpp-metadata.md @@ -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": "" + }, + "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. diff --git a/scripts/verify-il2cpp-metadata.mjs b/scripts/verify-il2cpp-metadata.mjs index 7dd4ba66a2..049642c6cb 100644 --- a/scripts/verify-il2cpp-metadata.mjs +++ b/scripts/verify-il2cpp-metadata.mjs @@ -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( ( diff --git a/src/application/il2cpp/Il2cppMetadataService.test.ts b/src/application/il2cpp/Il2cppMetadataService.test.ts index fe0bc7ae7b..7757f2a050 100644 --- a/src/application/il2cpp/Il2cppMetadataService.test.ts +++ b/src/application/il2cpp/Il2cppMetadataService.test.ts @@ -41,6 +41,11 @@ const report: Il2cppMetadata = { images: [], types: [], methods: [], + fields: [], + properties: [], + events: [], + parameters: [], + string_literals: [], limitations: [], }; diff --git a/src/application/il2cpp/Il2cppMetadataService.ts b/src/application/il2cpp/Il2cppMetadataService.ts index e8ec2d0c91..819c363d6b 100644 --- a/src/application/il2cpp/Il2cppMetadataService.ts +++ b/src/application/il2cpp/Il2cppMetadataService.ts @@ -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, diff --git a/src/cli/il2cppCommands.ts b/src/cli/il2cppCommands.ts index 6dd59be862..3458cf99d5 100644 --- a/src/cli/il2cppCommands.ts +++ b/src/cli/il2cppCommands.ts @@ -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 }) => diff --git a/src/contracts/analysisViewToolContracts.ts b/src/contracts/analysisViewToolContracts.ts index ade711978b..c32d107a14 100644 --- a/src/contracts/analysisViewToolContracts.ts +++ b/src/contracts/analysisViewToolContracts.ts @@ -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: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", 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 references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods, with exact indices/names, fields and paging; types also select image_indices/namespaces/declaring_types and methods select declaring_types. Rows always keep their original index scoped to the parent artifact digest. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', + '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: ""}. Use normalized_result.parent_evidence_id from a summary receipt. Source kind is required. Portable source uses {kind: "inline", evidence: }. A completed Evidence JSON file on this host uses {kind: "evidence-file", 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 references use view {kind: "javascript", collection: "modules", projection: "references", where: {path: "main.js", kind: "javascript-module"}, offset: 0, limit: 64}; where matches exact retained strings (null selects unavailable paths). Rows include canonical identity, retained source-range count and observation support states. Source coverage and selection remain explicit; exhausting matching rows does not prove runtime loading or complete analysis. For exact file/native facts and processing gaps, use view {kind: "javascript-facts", where: {paths: ["package.json"]}, property_keys: ["name", "operation", "analysis_status"]}. Rows page original observations with canonical identities and Evidence; missing properties remain omitted. Paths match recorded artifact-path/source-range locations; optional kinds filter nodes. To discover field names without values, opt into include_available_property_keys: true with property_keys: []. Canonical identity and Evidence are separate from properties; keep each node_id/observation_id pair together when composing results. Inventory uses view: {kind: "inventory", collection: "occurrences", logical_paths: ["package.json"], offset: 0, limit: 64}. Inventory views page nodes, occurrences, edges or integrity contradictions. Occurrences accept exact logical_paths and occurrence_ids; nodes accept artifact_ids. Batch filters intersect (omitted means all available, [] means none); fields selects columns while retaining identity links. source_complete describes retained graph membership; source_coverage describes producer coverage, including unknown encrypted PCK directories. Page exhaustion does not prove absence in a partial source. WASM views use kind wasm and facet sections/imports/exports/candidates/glue/wat/headers/details/tool_profile; text facets use UTF-16 code units and source forms remain exact. Mach-O views use kind macho and facet entrypoints/architectures/build_metadata/load_commands/dependencies/imports/exports/segments/provenance; source_exhaustive false means unreturned rows may exist. Interface Builder views use kind interface-builder and collection documents/nodes/edges/coverage; nodes and edges accept an exact document relative_path. EVM summary returns all inferred interface candidates, carrier/decoded identities and explicit unknowns without hex or duplicate stdout. EVM views use kind evm and facet functions/bytecode_hex/stdout/stderr; function pages preserve the original candidate rows, and text offsets/limits count UTF-16 code units (hex has two characters per decoded byte). Exhausting candidate pages does not establish global interface completeness. Managed artifact summaries preserve complete identity/classification, metadata status, module/MVID, frameworks, coverage and limitations without unrelated inventories. Managed pages use kind managed and collection references/resources/attributes, retaining source coverage and build-local token scope; page exhaustion does not establish complete metadata. Managed member summaries keep identity, selection with unmatched names, coverage and per-collection counts; managed-members pages use collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings and carry the source selection and coverage. Android package summaries retain all declarations, engine identity and counts without decoded manifest text. Android manifest pages use kind android, facet manifest, offset and limit in UTF-16 code units; source_status preserves partial decoded coverage even when the retained text page is exhausted. Dart AOT summary preserves target/ABI/library identity, snapshot headers and pool lower bounds. String pages use kind dart-aot, collection package_uris/dart_uris/class_like_tokens; source coverage remains partial when the producer says partial. Protobuf views use kind protobuf, collection bodies/frames/fields, with record_ordinals, body_pointers and fields; field pages also select frame_indices, field_ids, field_numbers or schema_names. Byte identity, offset basis and body status remain present. Text and schema_value are opt-in page columns. IL2CPP metadata views use kind il2cpp-metadata and collection sections/images/types/methods/fields/properties/events/parameters/string_literals, with exact indices, fields and paging; named collections also select names, types also select image_indices/namespaces/declaring_types, and methods/fields/properties/events select declaring_types. Parameters accept method_indices. Rows always keep their original index scoped to the parent artifact digest. Desktop directory views use kind desktop-application and collection files/omissions/candidates/components/next_probes, with fields and pagination; files and omissions accept exact paths or their stable IDs, and candidates accept families. Source coverage and stable row IDs remain available. Native inventory Evidence from list_names, list/search_procedures or list/search_strings uses view: {kind: "native-inventory", offset: 0, limit: 64, fields: ["value"]}. Rows keep source_index and address even with selected fields. Summary counts describe retained rows; provider discovery coverage remains unknown. Native function Evidence requires kind native: view: {kind: "native", facet: "procedure"}. Facets select procedure, pseudocode, assembly, basic blocks, comments, callers/callees, references, unresolved calls, referenced strings/names, native API and high-pcode. Use nonnegative offset and a positive caller-selected limit (defaults 0 and 64); pseudocode uses UTF-16 code units, other native pages use rows. Returns artifact identity, parent Evidence ID, a distinct view digest, coverage, limitations and unknowns. Actual serialized size determines MCP transport admission; choose a smaller page or export the complete retained Evidence if needed.', inputSchema: inspectAnalysisViewInputSchema, outputSchema: evidenceResultOf(analysisViewResultSchema), examples: [ diff --git a/src/contracts/il2cpp/il2cppToolContracts.ts b/src/contracts/il2cpp/il2cppToolContracts.ts index 26cb8fd796..643cfb5996 100644 --- a/src/contracts/il2cpp/il2cppToolContracts.ts +++ b/src/contracts/il2cpp/il2cppToolContracts.ts @@ -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]), diff --git a/src/domain/analysisView/il2cppMetadataView.test.ts b/src/domain/analysisView/il2cppMetadataView.test.ts index c035cc9ea7..4aa4cf50d8 100644 --- a/src/domain/analysisView/il2cppMetadataView.test.ts +++ b/src/domain/analysisView/il2cppMetadataView.test.ts @@ -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, { diff --git a/src/domain/analysisView/il2cppMetadataView.ts b/src/domain/analysisView/il2cppMetadataView.ts index 8db56d816c..3949e63e9d 100644 --- a/src/domain/analysisView/il2cppMetadataView.ts +++ b/src/domain/analysisView/il2cppMetadataView.ts @@ -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; +type Report = z.infer; type Request = z.infer; -type Row = Report[Request["collection"]][number]; +type Row = NonNullable[number]; interface CheckedReport { readonly report: Report; readonly counts: z.infer; @@ -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(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({ diff --git a/src/domain/il2cpp/il2cppMetadata.ts b/src/domain/il2cpp/il2cppMetadata.ts index 8d4b7dc288..25ded1f3af 100644 --- a/src/domain/il2cpp/il2cppMetadata.ts +++ b/src/domain/il2cpp/il2cppMetadata.ts @@ -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, + 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 diff --git a/src/il2cpp/Il2cppMetadataAdmission.ts b/src/il2cpp/Il2cppMetadataAdmission.ts new file mode 100644 index 0000000000..926044abc9 --- /dev/null +++ b/src/il2cpp/Il2cppMetadataAdmission.ts @@ -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 => { + let used = 0; + // Keys/decoded names/literal spans are immutable within this one decoder run. + const lengths = new Map(); + 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; + }, + }; +}; diff --git a/src/il2cpp/Il2cppMetadataFile.ts b/src/il2cpp/Il2cppMetadataFile.ts index e401676cf4..686395f73a 100644 --- a/src/il2cpp/Il2cppMetadataFile.ts +++ b/src/il2cpp/Il2cppMetadataFile.ts @@ -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> = { + 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; readonly counts: ReturnType; readonly typeDefinitionTypeIndices: number; + readonly indexing: "fixed" | "counted"; + readonly methodReturnParameterToken: boolean; readonly readString: ReturnType; + readonly members: ReturnType; + readonly beforeText: Il2cppMetadataReadOptions["beforeText"]; + readonly onRow: Il2cppMetadataReadOptions["onRow"]; } function* readImages({ @@ -423,6 +399,7 @@ function* readImages({ recordBytes: sizes, counts, readString, + onRow, }: MetadataTables): Generator { 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 { +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 { + 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>, + ) => 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 { 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[1] = {}, + options: Il2cppMetadataReadOptions = {}, ): Il2cppMetadataDecoded => { const steps = readIl2cppMetadataSteps(bytes, options); for (;;) { diff --git a/src/il2cpp/Il2cppMetadataFormat.ts b/src/il2cpp/Il2cppMetadataFormat.ts new file mode 100644 index 0000000000..ba4b179f1c --- /dev/null +++ b/src/il2cpp/Il2cppMetadataFormat.ts @@ -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`, + ); +}; diff --git a/src/il2cpp/Il2cppMetadataMembers.ts b/src/il2cpp/Il2cppMetadataMembers.ts new file mode 100644 index 0000000000..8f157f70d1 --- /dev/null +++ b/src/il2cpp/Il2cppMetadataMembers.ts @@ -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) => { + 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 { + 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, +>( + tables: MetadataTables, + name: "fields" | "properties" | "events" | "parameters", + ranges: readonly MetadataOwnerRange[], + makeRow: (name: string, owner: number) => T, +): Generator { + 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 { + 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(); + 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 { + 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), + }; +} diff --git a/src/il2cpp/Il2cppMetadataProvider.ts b/src/il2cpp/Il2cppMetadataProvider.ts index 4c30e62f8b..21e450ea94 100644 --- a/src/il2cpp/Il2cppMetadataProvider.ts +++ b/src/il2cpp/Il2cppMetadataProvider.ts @@ -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) => - 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) diff --git a/tests/acceptance/il2cppMetadataCli.test.ts b/tests/acceptance/il2cppMetadataCli.test.ts index b04bfc4555..7a63147833 100644 --- a/tests/acceptance/il2cppMetadataCli.test.ts +++ b/tests/acceptance/il2cppMetadataCli.test.ts @@ -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 }) => { diff --git a/tests/boundary/filesystem/desktopApplication.test.ts b/tests/boundary/filesystem/desktopApplication.test.ts index 4942a767b7..eaadfc27af 100644 --- a/tests/boundary/filesystem/desktopApplication.test.ts +++ b/tests/boundary/filesystem/desktopApplication.test.ts @@ -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", diff --git a/tests/boundary/il2cpp/il2cppMetadataFile.test.ts b/tests/boundary/il2cpp/il2cppMetadataFile.test.ts index 5c2a249d6f..1bee0800fa 100644 --- a/tests/boundary/il2cpp/il2cppMetadataFile.test.ts +++ b/tests/boundary/il2cpp/il2cppMetadataFile.test.ts @@ -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", + ); + }); +}); diff --git a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts index 8adb90630f..e18e759613 100644 --- a/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts +++ b/tests/boundary/il2cpp/il2cppMetadataProvider.test.ts @@ -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"); diff --git a/tests/fixtures/il2cpp/metadata.ts b/tests/fixtures/il2cpp/metadata.ts index b73eaad151..56b46236b6 100644 --- a/tests/fixtures/il2cpp/metadata.ts +++ b/tests/fixtures/il2cpp/metadata.ts @@ -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, ) => { 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>, + 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["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([ - ["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 }],