Skip to content

[#130] Support SerializedFile v26 (Unity 6.7) - #145

Open
SkowronskiAndrew wants to merge 9 commits into
mainfrom
issue130-serializedfile-v26
Open

SkowronskiAndrew wants to merge 9 commits into
mainfrom
issue130-serializedfile-v26

Conversation

@SkowronskiAndrew

Copy link
Copy Markdown
Collaborator

Summary

Fixes #130.

Unity 6.7 moves the SerializedFile format from version 23 to version 26 through three cumulative
changes, two of which alter how a file has to be read. dump and analyze did not work on 6.7
output at all, and most serialized-file subcommands refused it. This makes all of them work,
while continuing to read every older version.

What changed in the format, from Runtime/Serialize/SerializedFileFormatVersion.h:

v Effect on a reader
24 TypeTree blobs carry their own TypeTreeFormatVersion (32 and up) instead of the file's. The metadata layout is unchanged.
25 The [SerializeReference] registry becomes a self-delimiting frame in the object's data, ahead of the first field that can hold a reference. No TypeTree node describes it - the first piece of object layout a TypeTree cannot express.
26 The file gains a table of shared sub-TypeTrees, stored once and keyed by content hash, that its types reference in place of repeating a compound.

Only <23, 23 and 26 occur in the wild - 24 and 25 shipped inside the 6.7 alpha - but the
changes are cumulative, so all three matter.

The shared-subtree change is the dangerous one. Such a node has a byte size and no children of its
own, which is exactly how every reader of this API recognises a basic type, so a naive reader
takes a fixed-size compound for a primitive of the same width, drops its fields - PPtrs among them -
and still lands in the right place afterwards. Unity's UnityFileSystemApi therefore now refuses
that node through UFS_GetTypeTreeNodeInfo and serves it through a subtree API instead. That
refusal, the registry frame parser, and the independent TypeTree version all arrived in the native
library in Unity commit e06bfae736e1, whose message says outright that the UnityDataTools side is
a separate PR pinning that library. This is that PR.

Where the layout is documented only in the engine source, this uses the library's entry points
rather than re-implementing it. The exception is SerializedFileDetector, which deliberately parses
metadata in pure C# without the native library, and which gains the v26 metadata changes directly.

Changes

Native library and its C# wrapper

  • Requires UnityFileSystemApi version 2 or newer, checked once in UnityFileSystem.Init with
    a message naming what is needed. Older files are read through the same library, so there is
    nothing to gain from a fallback. This branch does not contain that library yet - see Before
    merging.
  • DllWrapper gains GetTypeTreeSubtreeNodeInfo, GetTypeTreeRefSubtree and the three
    GetRegistryFrame* entry points, the HasSerializedRefs / IsSharedSubtreeRef node flags, and
    the HigherTypeTreeVersion / RequiresSubtreeApi result codes.
  • TypeTreeNode now reads every node through the subtree API, passing a null subtree for the
    tree's own nodes, so one walker covers both sides and there is no second path to keep in step. A
    shared subtree reference resolves its children through GetTypeTreeRefSubtree, and is never
    reported as a basic type. RequiresSubtreeApi throws, so a walk that bypasses TypeTreeNode
    fails loudly rather than silently misreading.
  • New ManagedReferenceRegistry models the registry: Version, FrameSize and the entries it
    holds. Versions 1 and 2 are read from their TypeTree nodes into the same entries, so consumers see
    one shape whatever the file version. It is public API of the UnityFileSystem library, which
    AGENTS.md describes as reusable for other analysis; inside this repo the frame path is what the
    three walkers use.

Reading objects

The registry frame sits ahead of the field flagged HasSerializedRefs, in root context only - the
same type tree read as a registry blob is frameless. TextDumperTool, PPtrAndCrcProcessor and
RandomAccessReader each step over it and read it; their walks stay separate because each maintains
its own cursor and does something different with the result, but the frame parsing itself is shared.

  • dump prints the registry in the shape the version 1 and 2 dumps already used, so the three stay
    comparable. It also prints a null reference, which only the version 3 format can express.
  • analyze walks each instance's data through its own type tree for PPtrs, as it already did for
    versions 1 and 2, and CRCs the frame's header and tables as the raw bytes they are. Note that a
    v23 and a v26 build of the same content will not have equal CRCs; the CRC is a walk-derived
    fingerprint, not a hash of the object's bytes (see CRC of objects with references is not comparable across separate databases #74).

serialized-file

  • Supported metadata versions now run to 26. Versions 24 and 25 are accepted too: their metadata
    layout is identical to 23, so refusing a layout we can read would be a failure with nothing behind
    it.
  • metadata reports the shared subtree table, and in JSON each subtree's content hash, size and
    whether it is inline. Each type entry also reports typeTreeFormatVersion, the stamp on the blob
    itself - 0 before version 23, 23 for version 23 files, 32 or higher from 6.7. That is the only way
    to see a TypeTree change that no longer bumps the file version. Cross-referenced with Expose TypeTree information for SerializedFile #54, which
    covers exposing TypeTree information properly.

Error reporting

analyze surfaced an unreadable version as Sequence contains no elements (reported in #130 while
testing #128). The version is known before the file is opened, so both analyze and dump now say
which version they found and which is the highest they understand, instead of leaving the native
loader to call it a possibly corrupt file.

Documentation

unity-content-format.md gains a "What changed in Unity 6.7" section under TypeTrees. Because the
docs cover every version UnityDataTool reads, the 6.7 behaviour is described as a difference from
6.6 rather than as the way things are. command-serialized-file.md and command-dump.md follow.

Test data

  • TestCommon/Data/PlayerWithTypeTreesV26/ - a 6.7 build of the same project and the same two
    scenes as the existing PlayerWithTypeTrees (which is version 22, not 23). Same content
    either side of the format change, so most of the new tests assert the two agree rather than
    pinning v26 on its own. 933 KB, against 814 KB for its older sibling.
  • TestCommon/Data/AssetBundleTypeTreeVariations/v26/managedreferences.bundle - Unity's own v26
    fixture, copied from its test corpus, which the two repos already share a source script with. It
    carries the [SerializeReference] shapes nothing else here reaches: a null reference, a PPtr
    inside a referenced object, a collection of references, a reference nested in another, a
    field-less compound, and a plain int before and after the references - that last pair being what
    catches a reader that does not step over the frame, since it reads them shifted rather than
    failing.
  • TestCommon/Data/LegacyFormats/v26format.assets - one file pegged at v26, since LeadingEdge moves
    forward with Unity.

Testing

dotnet test - 821 pass, 0 fail (802 before this branch). The pre-existing tests over v17 to v23
files are the backward-compatibility guard that matters most here, since TypeTreeNode now routes
every node through the subtree API, for old files as much as new.

New coverage: shared subtree resolution and the absence of those nodes in a v22 file; the registry
read through all three of its on-disk versions; the detector's shared subtree table and blob version
stamps across v22, v23 and v26; the registry shapes listed above; and the unsupported-version
message.

Verified by hand against the full 48 MB 6.7 player build, not just the checked-in subset: dump
produces clean output for every serialized file in it, and analyze completes with no failures. The
v22 and v26 builds of level0 produce identical analyze graphs (7 objects, 13 references), and
their dumps differ only by a genuinely new 6.7 field (m_CAHMap) and by PathIDs.

Before merging

  • The updated UnityFileSystemApi library is not in this branch. The checked-in library is
    still version 1, which the new Init check rejects, so CI will fail on every platform until
    official Windows, macOS and Linux builds from Unity 6.7 are committed here.
    Everything above was
    developed and verified against a local Windows debug build of 6000.7.0b2
    (UFS_GetDllVersion = 2), which was deliberately kept out of the commits. The suite should be
    re-run once the official libraries land, since only the Windows debug build has been exercised.
  • Issues Support Unity 6.7 content directory analysis (ContentLayout.json v3) #131 (6.7 ContentLayout.json) and Unity 6.7 - Support unity archive format v9 #144 (6.7 archive format) are separate 6.7
    problems, deliberately out of scope here. UnityProjects/LeadingEdge was not rebuilt with
    6.7, because doing so would break ContentLayout and Archive tests for those unrelated reasons; the
    coverage it was wanted for is provided by the fixtures above. The version bump and release also
    wait for the point where all the 6.7 fixes are assembled onto one branch.

🤖 Generated with Claude Code

SkowronskiAndrew and others added 6 commits September 15, 2026 22:30
Unity 6.7 moves the format from v23 to v26 through three cumulative changes:
TypeTrees carry their own version (24), the [SerializeReference] registry moves
into the object's data as a frame no TypeTree node describes (25), and the file
gains a table of shared subtrees its types reference (26).

The native library already has the external-tool half of this, so the C# side
adopts those entry points rather than reimplementing the layouts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The version 22 and version 26 builds of one project are the same content either
side of the format change, so most of these assert the two agree rather than
pinning v26 on its own. The shared subtree checks are the ones worth keeping in
mind: such a node has a size and no children, so a reader that takes it at face
value drops the compound's fields without failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The docs cover every Unity version UnityDataTool reads, so the 6.7 changes are
described as a difference from 6.6 rather than as the way things are.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds Unity's own v26 fixture, which carries a null reference, a PPtr inside a
referenced object, a collection of references, a reference nested in another,
a field-less compound, and plain fields either side of the registry - the last
being what catches a reader that does not step over the frame, since it reads
them shifted rather than failing.

Also roots a framed registry's property paths at the registry rather than at the
field whose data happens to follow it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The trees are finite - the deepest in the fixtures is 9 - so the depth limit
was guarding against nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cleanup from a review pass, no behaviour change except where noted.

The registry frame reader now throws on a malformed frame instead of returning
null, which removes the identical null-check and message that all three walkers
had copied. The dumper says "root object" rather than inferring it from
recursion depth, and its output-writing ritual collapses into one helper. The
analyzer's two registry paths now share the walk into a referenced object's
data, which is also what stops their property paths from drifting apart.

RandomAccessReader takes the object's size, so all three walkers bound the frame
read by the object rather than one of them by the whole file, and it reads only
the frame's header while walking fields - the tables are read if Registry is
asked for.

dump now reports an unsupported version the way analyze does, rather than
leaving the native loader to call it a possibly corrupt file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@SkowronskiAndrew

Copy link
Copy Markdown
Collaborator Author

AGENT: CI is red on all three platforms for a single expected reason, not a test regression:

System.NotSupportedException : UnityFileSystemApi version 1 is too old; version 2 or newer is
required. Replace the UnityFileSystemApi library shipped beside UnityDataTool with one from
Unity 6.7 or newer.

The checked-in native library is still version 1, and reading v26 needs the shared-subtree and
registry-frame entry points that arrived in version 2. Committing official Windows, macOS and Linux
builds from Unity 6.7 should turn all three green; locally, with a Windows debug build of 6000.7.0b2
in place, the suite is 821/821.

The suite is worth re-running once those land, since only the Windows debug build has been exercised
here.

Comment thread Analyzer.Tests/FileDetectionTests.cs Outdated
public void TryParseMetadata_VersionTooNew_ReturnsFalseWithMessage()
{
var headerInfo = new SerializedFileInfo { Version = 24 };
var headerInfo = new SerializedFileInfo { Version = 27 };

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we should set this to 99 or other much later version, so that there is less churn as the version changes.

Line 199 would still need to change, unless there is a way we can reach the actual maximum supported constant so that there is less maintenance burden for this test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — 99, and the expected-maximum assertion now reads SerializedFileDetector.MaxMetadataParseVersion instead of a literal, so neither line churns. The constant is public for that reason.

Shows information from the metadata section of a SerializedFile. This includes the Unity version, target platform, TypeTree storage mode (inline, external, or absent), and counts of the type entries recorded in the file. The JSON output includes additional per-type details; see the notes below.

Requires SerializedFile version 19 (Unity 2019.1) or newer. Files older than version 19 are not supported by this subcommand.
Requires SerializedFile version 19 (Unity 2019.1) or newer, up to version 26 (Unity 6.7). Files

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like having the current supported version hardcoded here, we may neglect to update that when new versions come up. We intend to support newer versions as they arrive with new Unity versions, although there can be a time lag. Users of UnityDataTools will often be on older versions of Unity than the current beta or brand new non-LTS release.
We have a runtime error if we don't support a version so perhaps that is better than trying to keep a documentation up to date,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the version ceiling is gone from the docs. It now says a file from a newer Unity is refused rather than misread, with a message naming the version found and the highest understood, and points at header as the way to check what you have.

Comment thread Documentation/command-dump.md Outdated
instance by its `rid`, its concrete C# type and its data. Where that section appears depends on the
Unity version: up to Unity 6.6 the registry is the last field of the object, and from Unity 6.7 it
precedes the first field that can hold a reference. The reported `version` is `2` before 6.7 and `3`
from 6.7. See [TypeTrees](unity-content-format.md#typetrees) for what changed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is quite technical and there is no example. Its not very clear whether the actual dump output would look different between these two versions, or we are just describing an inner detail about the binary representation of v2 and v3.

The comment in Analyzer/PPtrAndCrcProcessor.cs is a good place to get into technical details of the different supported versions because undertanding that actual parsing code requires some explaination. Meanwhile this is the doc for the user facing dump command.

Here it can be worth mentioning SerializeReference output for dump because that is something interesting that users would see when looking at some MonoBehaviors. But I think having a short example is important, possibly just showing an example of the new expected dump output, e.g. v3 instead of getting into the history.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — replaced the history paragraph with an actual example of the output (the references section for ScriptableObjectWIthSerializeReference, taken from the test data), plus one line each on the two things a user actually sees: two fields sharing a rid, and a null reference showing rid -2. The format history is left to the PPtrAndCrcProcessor comment and unity-content-format.md.

On your question of whether the dump output differs between v2 and v3: the entries are identical, and only two things change — the version number, and that the section is printed before the referencing fields in 6.7 instead of after. That is now one sentence at the end rather than the bulk of it.

"Data", "PlayerWithTypeTreesV26", "sharedassets1.assets"));

// The version is a big-endian uint32 at offset 8 of the header.
BinaryPrimitives.WriteUInt32BigEndian(bytes.AsSpan(8), 27);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again i think a number like 99 would be better than the next expected version, so we don't have to keep churning this test as new versions arrive.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Agreed on the intent, but 99 does not work here specifically. This test goes through real detection, and TryDetectSerializedFile only accepts a version in 1..50 as a plausible SerializedFile header — a version-99 file is not recognised as a SerializedFile at all, so analyze ignores it and exits 0 rather than failing. (The other test hands TryParseMetadata a header directly and bypasses that, which is why 99 is fine there.)

So this one derives it: SerializedFileDetector.MaxMetadataParseVersion + 1, with both assertions built from the same constant. No churn, and it stays inside the detector's plausible range. Happy to raise that 1..50 bound instead if you would rather both tests read the same way, but it is a heuristic that helps reject non-SerializedFiles, so I left it alone.

// sitting immediately before the data of the field flagged HasSerializedRefs. No TypeTree node
// describes it, so it is read through the native parser rather than reimplemented here - it is the
// first piece of object layout a TypeTree cannot express, and a second implementation of a layout
// documented only in the engine source would drift from it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this comment. Aren't we forced to implement that layout as a second implementation? Is it saying there is a risk of this drifting?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGENT: Fair — the comment was badly worded and said the opposite of what I meant. We are not writing a second implementation: UFS_GetRegistryFrameInfo/Types/Records wrap the engine's own ParseRegistryTableRaw, so the only parser of that layout is Unity's, and this class calls it. The one exception is the 8-byte header (version + byteLength), read directly in GetFrameSize, because a walker that only needs to step over a frame should not pay to parse its tables. Rewritten to say that.

…comments

The two tests that need an unsupported version now derive it from
SerializedFileDetector.MaxMetadataParseVersion, which is public for that reason,
so neither churns as Unity adds versions. The serialized-file docs no longer
name the ceiling either - the runtime error reports it.

The dump docs show what a SerializeReference object actually looks like instead
of describing how the format changed, and the registry class says plainly that
it calls the engine's frame parser rather than being a second implementation
of that layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SkowronskiAndrew and others added 2 commits September 16, 2026 08:41
…sits

RandomAccessReader.GetSize asked for the last child of a node with no children,
which threw for a [Serializable] class with no serialized fields. Such a node
occupies whatever its own size says, normally nothing. Exposed by the new
managedreferences fixture, whose ShapeNoData field sits between the references
and a plain int that has to read correctly after it.

The comments describing the registry frame said it precedes the first field that
can hold a reference. It does not: it leads the declaring class's own data, so
the flag rides that class's first field whether or not that field is a
reference. In the fixture it is a plain int.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t once

The dump page now leads with what a reader sees - a managedReference field holds
a rid, not an object - and shows a script whose three fields demonstrate the
points being made: two assigned the same instance, one left null. The previous
example had a single field called "reference" of type "managedReference", which
made it hard to tell which name came from the script and which from the format.

The format itself was explained in five places in near-identical words. The full
explanation now lives once, beside ProcessManagedReferenceRegistry, which is the
code that needs it. ManagedReferenceRegistry keeps a shorter one because it is
public API of the UnityFileSystem library and has to stand alone; the three
walkers just say what their own line does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support SerializedFile v26 (Unity 6.7)

1 participant