Property Decoding

The tagged property stream

Each property in an export is a tag followed by a value:

FName  property name       "None" terminates the stream
FName  type name           must be one of the fourteen known *Property names
i32    size                byte length of the value
i32    array index         static-array element index
...    type-specific tag extras
...    the value

Type-specific extras before the value:

Type Extra
StructProperty struct name FName
BoolProperty the value itself — u8 if p_ver ≥ 673, otherwise u32 (size is 0)
ByteProperty enum name FName, if p_ver ≥ 633

If the enum name is present and isn't None, the byte value is stored as an FName label rather than a raw byte, and is rendered as EnumName::ValueName.

For fixed-size scalar types the reader realigns to value_start + size after reading, so a tag whose declared size disagrees with the type's natural width can't desynchronise the stream.

Where the stream ends

Parsing stops when it reads a property named None, when a tag doesn't look like a property, or on any error — the last two rewind the cursor first. Whatever remains in the export is the native tail. There is no length field marking that boundary.

Arrays

An ArrayProperty value is a i32 count followed by count elements, packed with no per-element tags. Decoding therefore needs the array's Inner property from the schema. The reader tries, in order:

  1. Byte fast path — if Inner is a ByteProperty and the count exactly equals the remaining payload bytes, read it as a flat byte array. This is what makes Scaleform RawData recoverable.
  2. Binary struct path — if Inner is a StructProperty, try reading count structs positionally. Accepted only if it lands exactly on the tag's declared end.
  3. Element-by-element — read each element by its inner kind. Accepted only if it lands exactly on the declared end.
  4. Raw — anything else is emitted as @bytes(...) with a warning naming the property.

Exact-fit rule

Every path above is validated against the tag's declared size. A decode that overruns or underruns is thrown away rather than trusted — that's the mechanism that keeps a wrong schema guess from corrupting everything downstream.

Structs

Three cases, checked in order:

Built-in atomics. Vector, Vector2D, Vector4, Quat, Rotator, Color, LinearColor, Box, Box2D, BoxSphereBounds, Matrix, Plane, Sphere, Guid, IntPoint, TwoVectors, and the InterpCurvePoint* family are read from a fixed table with no schema lookup.

Immutable structs. If the resolved ScriptStruct has STRUCT_Immutable (0x20) or STRUCT_ImmutableWhenCooked (0x80), its value is written positionally with no field tags. The reader walks the struct's whole class chain, listing every child property in order and reading each one by its declared kind, honouring array_dim > 1 as a fixed-size array.

Tagged structs. Everything else is a nested tagged property stream terminated by None.

If none of that resolves, the reader falls back to parsing a nested tagged stream bounded by the declared size, and to raw hex if even that fails.

Native properties

When an object's class has no registered asset serializer but still has a non-empty native tail, the tail is decoded as a positional walk of the class's properties flagged CPF_Native (0x1000):

  • Walk the class chain, and within each class its children in declaration order.
  • A property with CPF_Native is read positionally at the current cursor; array_dim > 1 reads that many consecutive values.
  • A StructProperty without the flag is descended into, since its members may carry it — its fields are emitted with dotted names like Bounds.SphereRadius.
  • An ArrayProperty whose inner is a struct uses the tagged value's element count to know how many native structs follow, and emits Name[0].Field, Name[1].Field, ….

The result is rendered under a // native-serialized (CPF_Native) comment in the .uo, which the parser recognises on the way back in.

Native decoding is best-effort

Native serialization is arbitrary C++ — CPF_Native reconstruction covers the common case where the C++ Serialize simply writes the flagged members in order, and nothing more. If a class writes anything else, the emitted fields will be wrong. Prefer @bytes(...) round-trip over editing native fields you haven't verified.

Value model

Variant Rendered as
None None
Byte 0x1f
Int 42
Bool true / false
Float 1.0, 3.14159
Object &Name, &Outer.Name when ambiguous, &extern:Pkg::Name for imports
Name 'SomeName'
EnumLabel EPixelFormat::PF_DXT5
String "quoted, escaped"
Array [a, b, c] inline when ≤8 scalars, otherwise one per line
Struct { Field = value, … } — tagged, one field per line
AtomicStruct { X = 1.0, Y = 2.0, Z = 0.0 } — positional, inline
Raw @bytes(deadbeef…)

Writing back

Property::write re-emits a tag with the size field back-patched after the value is written, so edited values of different lengths are handled correctly. BoolProperty writes its value into the tag and a size of 0, matching the engine.

Every name the tag references — property name, type name, struct name, enum name, and any enum label or FName inside the value — must exist in the package's name table. The packer grows the table as needed and ships the result as a .namemap.