Files
reverse-engineering-anything/docs/unityfs.md
T
Entrpi c13dd36d95 fix(artifacts): decide how compare_artifacts compares every format_metadata kind (#8231)
* feat(artifacts): inventory and extract ESP32 firmware images

ESP32-family app and bootloader images (magic 0xE9) had no reader, so
open_binary refused OTA downloads, app partitions and bootloaders.

Add the esp-image container format behind inspect_artifact and
extract_artifact. A pure decoder reads each byte once and keeps only
fixed-size fields. Derived facts cover the chip, from a table cited to
ESP-IDF v6.1; segment classification as ESP-IDF's should_map and
should_load; checksum and appended SHA-256 results; app and bootloader
descriptors; Secure Boot v2 signature blocks, observed and unverified;
and undescribed trailing byte ranges. Truncation and undescribed bytes
make coverage unknown. ESP8266 images are refused with a specific
reason, and ciphertext stays unrecognized.

Add the format to every target, Evidence, snapshot, live-summary and
artifact vocabulary, document it, and add an optional verify:esp-images
lane that compares esptool-built images with esptool image-info and
espsecure signature-info-v2.

Refs #3615

* test(artifacts): run the ESP image lane on esptool v5.6.0

The lane compared descriptor fields only when esptool image-info printed
one, so a descriptor that REA reports and esptool does not print passed
without comparison. Compare the descriptor kind in both directions.

Record that the lane passed for all 13 images with esptool and espsecure
v5.6.0, the version the layout facts cite, as well as with 4.12.0.

* refactor(artifacts): define ESP image layout sizes once

The decoder and the derived facts each defined the segment-header,
appended-digest and flash-sector sizes, the erased-byte scan and the
offset formatter. Define them once with the other fixed-layout structures
and import them in both places. The signature slot count is the one
constant that drives the slot scan.

Correct the observedEnd comment: it is the end of the last chunk read.

* fix(artifacts): report ESP-IDF's segment length limit by chip

ESP-IDF's verify_segment_header rejects a segment of 16 MiB or more
unless the chip maps flash through 32-bit cache addresses
(SOC_SPI_MEM_SUPPORT_CACHE_32BIT_ADDR_MAP: ESP32-S3, P4, C5, C61, H4 and
S31 in v6.1). Record that capability in the chip table and add a
limitation for such segments; unknown chips get none.

`loaded` repeats should_load. It does not mean the bootloader accepts the
segment: verify_load_addresses still rejects one outside the chip's RAM,
which REA does not model. Say so in the docs, the skill reference and the
inventory limitation.

* test(artifacts): check ESP lane tools first and verify extraction

The lane started work before checking its tools, so a missing RISC-V
target, ld.lld, esptool or espsecure surfaced as a raw spawn or command
error. Check each one before building and name the variable to set. Warn
when esptool is not v5.6.0, the release the layout facts cite.

Assert each segment's class for the ESP32-C3 app, its variants and the
bootloader, as the Xtensa image already did. Extract the app and the
signed image through the CLI and compare every file with the bytes at
esptool's segment offsets or in the appended signature sector; the
source's SHA-256 must be unchanged and equal the reported root_sha256.

* docs(artifacts): state what the ESP image lane does not establish

The Verification section listed the lane's checks but not its gaps: only
ESP32-C3 and ESP32 images come from a real producer, no real project
build, OTA download or flash dump is inspected, no signature is verified,
and the lane does not run in CI.

Record the timing of the final lane run on esptool v5.6.0.

* fix(artifacts): compare ESP segment declarations

Segment entries hash only their data, so compare_artifacts reported a
segment that moved to another load address with the same bytes as
unchanged; only the image itself changed. Compare each segment's
load_addr and data_len as its declaration, as Node SEA and PyInstaller
members are compared, and count a changed declaration as a metadata
change, also when content is unavailable.

Header offsets are layout and the classification is derived, so neither
is compared.

* test(artifacts): run ESP lane commands as owned process trees

The lane ran its tools through execFile. A timeout killed only the direct
child, so a linker or assembler that Clang or GCC had started could keep
running while the lane removed its workspace. Run every command through
createVerifierCommands, check the tools inside withVerifierCleanup, and
remove the workspace only after commands.close() has stopped every tree;
a failed close keeps the workspace.

Only a nonzero exit now counts as a refusal or an expected esptool
failure; timeouts and spawn failures propagate. Supervising each command
roughly doubles the lane's run time.

* test(artifacts): cancel ESP decoding after its first decoded chunk

The cancellation test requested cancel or close on setImmediate, which
can land while the file is still being opened and checked, so it never
showed that decoding had started. It also passed against a reader that
ignored both requests while decoding. Acknowledge the decoder's first
chunk, then cancel or close, join the operation, and assert that no
later chunk reaches the decoder and that the reader ends closed.

* docs(artifacts): record which ESP metadata comparison ignores

Segments are compared by their declaration. The image's and the
signature blocks' format_metadata are not: every field comes from their
own bytes, which content comparison already covers, and a block's
image_digest_matches follows the image's content.

* fix(artifacts): decide declaration comparison for every metadata kind

compare_artifacts listed the format_metadata kinds it compared by hand,
so a new kind went uncompared without notice; ESP segment load addresses
were first missed this way. One table beside the schemas now gives every
kind a declaration projection or the reason comparison uses none of its
facts, and a kind without an entry does not compile. Consumers read one
result per pair instead of naming formats.

- Godot PCK entries stay uncompared. Godot looks members up by the
  simplified path without res://, which logical_path holds apart from
  Unicode normalization. Godot 4.3 exports store res:// and 4.4.1
  exports do not, so comparing source_path would mark every member
  changed. The reader admits only the encryption flag, which the
  occurrence's encrypted field records.
- A changed PyInstaller declaration is now a metadata change also when
  the member's content is unavailable, as for ASAR, Node SEA and ESP
  segments. It was unknown before, for no recorded reason.
- Root kinds and ESP signature blocks stay uncompared: their fields come
  from their own bytes, which content comparison covers.
- A declaration that only one side records now adds one generic
  limitation for every compared kind. Only Node SEA added one before,
  with its own text.

* fix(artifacts): keep content comparison for metadata records with content

The record rule compared two metadata occurrences by their declaration
alone. A hand-built record that names content, with an equal declaration,
then hid a content change; PyInstaller records already did so before the
table. The rule now needs both records without an artifact_id, which is
every metadata record a reader emits.

Review changes in the same area:

- The unknown-declaration limitation also covers declarations of different
  kinds and non-container observations, and tests compare it with its
  constant.
- docs/godot-pck.md gives each Godot version's lookup path, the backslash
  case, and that a member that becomes encrypted compares unknown. The
  table points to it instead of restating it.
- The PyInstaller regression moves to an acceptance test whose MCP client
  validates the advertised output schema, and the domain tests keep one
  representative case per behavior.

* fix(artifacts): compare encryption changes when content is unavailable

An encrypted member has no content. Comparison then took the
content-unavailable fallback, which counted only ASAR storage and format
declarations, so a Godot PCK, ZIP or PYZ member that became encrypted
compared unknown although both sides record `encrypted`. The record rule
compared node-less metadata records by format declaration alone, so a PYZ
namespace record whose encryption flipped compared unchanged.

`OccurrenceMetadata` now owns the encryption comparison beside ASAR
storage and the format declaration. Every occurrence records `encrypted`,
so a pair's comparison is never unknown. The fallback, the record rule and
the metadata dimension read it there; `metadataChanged` no longer compares
`encrypted` itself. The record rule compares all of `OccurrenceMetadata`:
a changed value is changed in metadata, with availability when another is
unknown.

Decision: occurrence limitations still do not count for node-less
records. Readers derive them from the declared facts (the PYZ caveat
follows `encrypted`), and their wording can change between REA versions
while the record stays the same. The fallback does not compare them
either.

Behavior changes:
- A pair with unavailable content whose `encrypted` differs is changed
  [metadata, availability], not unknown [availability].
- A node-less record whose `encrypted` differs is changed [metadata], not
  unchanged.
- A hand-built node-less record that records ASAR storage now compares
  it; no reader emits such records.

Remaining:
- The fallback still ignores `entry_kind` and `executable`, which readers
  also declare, so a file that becomes a symlink, or a mode change on an
  encrypted ZIP member, still compares unknown. `executable` has its own
  dimension, so its fix needs content-free checks split from node checks.
  Each is its own behavior decision.
- Node-bearing pairs still compare limitation wording; records do not.
  Whether limitation wording is ever a comparison fact needs one decision.

* fix(artifacts): compare entry kind and executable when content is unavailable

When either side lacked verified content, comparison took a fallback that
counted only ASAR storage, encryption and format declarations. It compared
`entry_kind` only in the metadata check of pairs with content, and
`executable` only beside the node checks. A ZIP member that became a
symlink, whose target REA does not read, and an encrypted ZIP member whose
Unix mode gained an execute bit therefore compared unknown, although
readers declare both fields on every occurrence.

`OccurrenceMetadata` now owns the entry kind comparison beside encryption;
it is never unknown. Comparison checks fall into two groups: declared facts
that need no content (`executable` and the metadata declarations), and
facts that need both content nodes (content, kind, format, size, relations
and node metadata). The fallback, the record rule and pairs with content
share the declared checks and one rule for unknown declarations. Kind and
format stay content checks: without content they are guesses from the path.

Behavior changes:
- Unless both sides have content, a pair whose entry kind differs is
  changed [metadata, availability], not unknown [availability].
- Such a pair whose `executable` differs is changed [executable,
  availability], with metadata when another declared fact also differs.
- A hand-built node-less record whose `executable` differs is changed
  [executable]. No reader emits such records.

Remaining: declared sizes and integrity digests still compare only through
observed bytes, so they do not count when content is unavailable.

* fix(artifacts): never mark a symlink executable

On POSIX hosts the directory reader recorded a symlink's own permission
bits as `executable`. Linux reports 0777 for every symlink and macOS
usually 0755, so directory, app bundle and DMG symlinks read true. zip.js,
the PyInstaller reader and the Windows branch record false. The previous
commit compares `executable` without content, so a symlink that two
readers or two hosts record differently compared changed in `executable`,
and a non-executable file that became a symlink also reported `executable`.

`hostExecutableMarking` now gives a symlink no marking on any host:
running a symlink runs its target, and the link's own bits do not control
that.

Behavior change: directory, app bundle and DMG inventories record
`executable: false` for symlinks. Occurrence IDs do not change, but the
graph digests and manifest IDs of inventories with symlinks do. Comparing
an inventory stored before this change with a new one therefore reports
changed [executable, availability] once for each such symlink.

* fix(artifacts): stop comparing limitation wording

Pairs with content compared node and occurrence limitations as metadata,
while node-less records, the content-unavailable fallback and inventory
limitations did not. Limitations explain what an observation could
establish. Readers mostly derive them from fields that comparison already
reads, from other occurrences or from what REA could read, and their
wording changes between REA versions. So adding `A` beside an unchanged
ZIP member `a` marked `a` changed in metadata through its case-collision
note, and an older and a newer inventory of equal bytes could differ
wherever a reader reworded a note.

No comparison path reads limitations now. Inventory limitations still
appear in the result, and occurrence limitations stay in the inventory
Evidence. Readers write no node limitations, so only hand-built inventories
compared them.

Behavior change: for a pair with content, limitation text no longer adds
metadata. A pair that differs only there compares unchanged, or unknown
[availability] when a declaration is unknown.

Remaining: a few facts appear only in limitation text, or also in fields
that comparison does not read, so their changes are no longer reported
while the compared bytes stay equal: an ASAR block integrity table or an
unsupported integrity declaration, a Godot member's all-zero MD5, a ZIP
name's leading `./` (also in `source_path`), and a PyInstaller PYZ image
digest (also in `pyz_sha256`; the archive root still reports the change).
Each needs a field or a comparison decision of its own if it matters.

* fix(artifacts): compare declared digests when content is unavailable

Without observed bytes on both sides, comparison ignored the containers'
declared integrity claims. Two encrypted Godot PCK members therefore
compared unknown even when their declared MD5s showed that the script had
changed. Godot's exporter stores the MD5 of a member's original bytes, so
the MD5 does not depend on the encryption IV. This comes from the 4.3,
4.4.1 and 4.7.1 exporter sources; no real encrypted export was compared.

When either side has no content, claims of one algorithm that differ now
make the path changed in metadata and availability. Equal claims add no
change, because they are unverified, and observed bytes still decide when
both sides have them. Godot PCK and ASAR readers record such claims.

For a readable member, a directory that declares or omits an MD5 only
decides whether REA can check its bytes, so such a member compares
unchanged. An encrypted member with an MD5 on only one side stays unknown.

Remaining: declared sizes still compare only through observed bytes.

* docs(artifacts): check the contract's compared kinds against the table

docs/mcp-contracts.md named the format_metadata kinds that
compare_artifacts compares in prose, by hand, so a kind added to the
declaration table could go unnamed. The integration branch already omits
esp-flash-partition, which the ESP flash layout branch adds to the table.

The paragraph now names each compared kind by its identifier. A
conformance test requires it to name exactly the kinds whose table entry
has a declaration, which artifactFormatMetadata.ts exports as
COMPARED_FORMAT_METADATA_KINDS, and no kind that comparison ignores.

* test(artifacts): share the comparison acceptance MCP fixture

Six artifact comparison acceptance tests each copied the same MCP
steps: open_binary, inspect_artifact, the inventory_artifact substep
Evidence, then compare_artifacts. The coverage test repeated the
inspect steps three times and kept its own inventory lookup.

tests/fixtures/artifactComparisonMcp.ts now owns these steps on the
schema-validating connectLocalToolsMcp client.
connectArtifactComparisonMcp() returns call, inspect(path) and
compare(left, right). inventoryEvidence() reads the inventory Evidence
that an inspection retains, from MCP or CLI output. Each test still
writes its own authored input and keeps its expectations.

The coverage lookup matched evidence.operation, not the substep
operation. The inspection schema fixes both to inventory_artifact, so
both select the same substep.

The plist acceptance test does not change. It compares the inspection
Evidence itself across CLI and MCP and passes the comparison Evidence
to verify_reconstruction and find_changed_behavior.

No assertion or scenario changes.

* fix(artifacts): leave .NET bundle member declarations uncompared

Upstream main compares no .NET bundle member facts. Comparing `file_type`
changed `compare_artifacts` output beyond this branch's fixes, so the kind
stays uncompared until that is decided. A changed manifest still shows as a
content change of the host.

* fix(artifacts): leave HEX region declarations uncompared

The comparison table compared a region's start_address and length. A
region's path names its start and end addresses, and its length follows
from them, so two regions that pair at one path always declare the same
facts: the entry could never find a change. It only made a region with
equal bytes unknown when the other side recorded no metadata, such as a
directory of extracted regions. The kind is not compared, as on main.

The contract paragraph also stops naming .NET bundle members, which
3e51a7760 left uncompared.

* test(artifacts): compare node-less records of any compared kind

The record rule follows the comparison table, not PyInstaller, but only
PyInstaller records exercised it. An ESP segment record shows both
outcomes: an equal declaration compares unchanged and a moved one changed
in metadata, without the availability that the fallback for unread
content would add.

* test(artifacts): compare a node-less record's recorded ASAR storage

The record rule compares ASAR storage beside the declaration and
`encrypted`, but no test recorded it on a metadata record. A changed
value is changed in metadata, and a value that only one side records
is unknown in availability, as for any occurrence.

* test(artifacts): compare UnityFS member declarations through MCP

The table compares a UnityFS member's declared source_path and raw
flags, but no comparison test covered the kind, so dropping either field
from the projection left every test passing. Bundles with equal member
bytes now show both: other flags, or a stored path in decomposed form,
compare changed in metadata, and reordered members, whose index and
stream offset are layout, compare unchanged.

* test(artifacts): use the shared comparison fixture for UnityFS

The UnityFS comparison test arrived from the bottom branch with its own
copy of the open_binary, inspect_artifact and compare_artifacts steps,
which tests/fixtures/artifactComparisonMcp.ts owns here. It now uses
connectArtifactComparisonMcp() like the other comparison acceptance
tests. No assertion changes.

* test(artifacts): find the contract's compared kinds by marker

The docs conformance test found its paragraph by an exact English phrase,
so rewording "also compares" failed it while the named kinds stayed
right. The paragraph now sits between BEGIN and END markers, as the
allocation report's sections do, and the test reads the kinds between
them. It still requires exactly the kinds whose table entry has a
declaration.
2026-11-04 23:53:01 +08:00

5.2 KiB
Raw Blame History

UnityFS AssetBundle archives

inspect-artifact and extract-artifact read standalone UnityFS AssetBundle containers without running Unity or the producer application. Header format versions 6 through 8 are supported. Version 9 adds a different block-table layout and is unsupported until independently verified.

Inspection retains the header facts: format version, player and engine version strings, declared size, dataflags, blocks-and-directory table bounds, the producer's 16-byte data hash as declared, and the resolved data-area offset. Members (CAB-* SerializedFiles, .resS resource streams, shared assets) retain declared source paths, virtual stream offsets, sizes and raw node flags. The directory flag identifies directories. compare_artifacts compares each member's declared source_path and raw flags; its index and offset are layout. Virtual offsets are not physical carrier locations: member source_location is null. Member reads only examine their covering blocks. Uncompressed ranges stream directly; compressed ranges reuse at most one completed decoded block. Independent 64 KiB output chunks preserve cache ownership. Carrier freshness is checked when a member is opened, including when a cached block suffices, and after its stream completes. Reader close cancels and joins outstanding streams before releasing its descriptor.

Data blocks compressed with none, LZ4 or LZ4HC are supported. LZMA-compressed blocks and blocks-info tables are reported as unsupported coverage or an unavailable member read; the directory and any readable members are still retained. Bundles declaring asset-bundle encryption keep their header facts but report unknown directory coverage; no decryption is attempted. The reused 0x200 encryption/padding flag is interpreted by producer revision. Ambiguous revisions retain unknown layout coverage. Unread block counts and data offsets are null, never invented zeroes. A declared size that exceeds the input file, node paths outside the block stream, and counts that cannot fit their table are malformed-input errors.

Resource bounds

These are resource policies, not format maxima. Encoded and decoded directory buffers are each limited to 16 MiB. Buffers, parsed rows, retained entries and path projections share a conservative 64 MiB admission budget, reserved before allocation. Each LZ4 decode allows at most 64 MiB of output and its bounded encoded representation; plain blocks stream without this allocation. A reader admits four active member streams. The shared archive plaintext-work ceiling is the larger of 256 MiB and 500 times carrier size: declared stream bytes, readable logical occurrences (including aliases), and cumulative block-decode attempts are each charged against that ceiling. Empty-output blocks retain their source count and offsets but are removed from member read plans. A separate member-I/O budget allows four times the larger of 1,024 and the sum of nonempty blocks, directory nodes, and readable 64 KiB plaintext chunks. It admits ordinary passes while bounding repeated tiny-block reads independently of output bytes. Unavailable content is retained as named occurrences with unknown coverage and no invented content hash.

Use existing summary delivery and retained inventory views for selected fields, filters and paging; full Evidence remains available without repeating the graph.

CLI

rea inspect-artifact assets/hotfix.bundle --detail summary \
  --evidence_output evidence/hotfix.json --json
rea extract-artifact assets/hotfix.bundle --json

Detection is byte-based on the UnityFS\0 signature, so .bundle, .ab, assets_* YooAsset bundles and extensionless files all classify as unityfs containers. Compact delivery keeps complete Evidence in the requested bundle.

Verification scope

Synthetic fixtures cover format 6–8 layouts, padding independent of header alignment, directories, malformed tables, allocation admission, overlapping members, unsupported LZMA, cancellation inside decoding, paused iteration, close during I/O, cleanup retry, empty blocks with unsupported codecs, cache freshness and independent output buffers. Compiled CLI and full/compact MCP tests cover inventory and extraction.

A public UnityPy atlas_test bundle (Unity 2018.4.11f1, format 6) is verified against independent UnityPy 1.24.2 raw member bytes. Source SHA-256: 61ee5afb5f5c64f0a4f65f039d09b2509198363e713423289c895067ac215177. The source is pinned to UnityPy commit 417d59522a998fdee9a9c2b29fe35f3063222457. The same public corpus supplies banner_1 (2018.4.4f1) and char_118_yuki.ab (5.6.7f1), whose raw members also match the independent oracle. xinzexi_2_n_tex (2017.4.37f1) uses LZMA data: its member names and sizes are retained with unavailable hashes and unknown coverage. UnityPy-produced repacks additionally verify format 7/8, padded and unpadded front tables, end tables, and unsupported LZMA directories. These repacks are library-produced tests, not Unity engine executions. The PR records producer parameters, source digests and measured task costs; synthetic fixtures alone do not establish real Unity version coverage. SerializedFile objects, textures and runtime behavior are outside this container reader's verification scope.