Why span-splicing, not parse-and-re-emit

A Unity asset is a multi-document YAML stream where each document carries an anchor (--- !u!114 &11400000), and every cross-asset reference ({fileID: 11400000, guid: …}) points at one of those anchors. Parsing with yaml-rust2 discards formatting, key order, and the non-standard headers — re-emitting from the parsed model would rewrite the whole file (reordered keys, dropped/renumbered anchors, a churned diff), and any of those silently breaks references pointing into the file.

So EditableUnityFile keeps the file as raw text and edits by span-splicing: an indentation-aware scanner locates the exact byte range of the value, that range — and only that range — is replaced, and the result is re-parsed; a splice that produced malformed YAML fails loudly instead of writing a broken asset. Every &fileID, every reference, indentation, key order, the %YAML/%TAG preamble, even CRLF line endings survive untouched because they are never rewritten. Round-trip tests against the fixture corpus pin exactly this: a no-op load→emit is the identity, and a single-value edit changes exactly one line.

Paths

A path is /-separated and addresses a value within one document's body: a key descends into a mapping (m_Name), a numeric segment indexes a sequence (parameters/0 — Unity mapping keys are never bare integers, so the heuristic is unambiguous), and a final segment may name a subfield of an inline reference (m_Script/guid). Examples: m_Name · parameters/2/saved · m_ChildStates/0/m_State. The scanner handles Unity's quirks: sequences at the key's own indent, a sequence element's first field inline on the - line, and flow-map (- {fileID: N}) elements.

Value edits — library and CLI

set_scalar sets an int/float/bool/string (rendered Unity-style: 0 not 0.0, 1/0 bools) and reaches subfields inside an inline reference; set_reference replaces a whole {…} reference — the canonical "swap an animation" edit — and can add a guid a local reference lacked. Errors are structured anyhow messages, never panics.

let mut file = EditableUnityFile::parse(&text)?;
let doc = file.doc_by_file_id(110200002).unwrap();  // select by &fileID anchor
file.set_scalar(doc, &parse_path("m_WriteDefaultValues"), Scalar::Int(1))?;
file.set_reference(doc, &parse_path("m_Motion"), 7400000, Some("…32 hex…"), 2)?;
let edited = file.into_string();

The same edits from the CLI:

# Rename (single-doc file, --doc optional). Default output is stdout: a pure preview. avatar asset set Parameters.asset --path m_Name --value Hands # Multi-document file: pick the document by its fileID anchor; write in place. avatar asset set Hands.controller --doc 110600000 --path m_BlendParameter \ --value GestureLeftWeight -o Hands.controller --force # Re-target a reference (cross-asset); omit --ref-guid/--ref-type for a local {fileID: N}. avatar asset set Hands.controller --doc 110200002 --path m_Motion \ --ref 7400000 --ref-guid 1234567890abcdef1234567890abcdef --ref-type 2

--value type is inferred (int → float → bool → string); force it with --type. Writes go through the shared WriteGuard: stdout by default, -o <file> to write (no clobber without --force), --dry-run to report without touching the filesystem, --json for a machine-readable report. Mutation stays on the CLI behind the guard — it is deliberately not exposed over the read-only MCP server.

Structural edits

Beyond values, the document-level edits a prefab rewrite needs — still span-based, still leaving every untouched byte alone:

MethodWhat it does
remove_documentDrop a whole --- !u!… &id document (header + body). References to it elsewhere are left as-is (Unity reads a dangling local ref as null), so pair it with remove_sequence_item on the owner's list.
replace_document_bodySwap the body while keeping the header — the object's fileID and every reference to it stay valid. This is how a component is retyped at the same slot (DynamicBoneVRCPhysBone, SDK2 descriptor → SDK3).
retag_documentRewrite the header for a class change (e.g. CapsuleCollider 136 → MonoBehaviour 114).
append_documentAdd a new document at the end (fileID must be unused).
append_sequence_item / remove_sequence_itemAdd to / remove from a block sequence such as m_Component or m_Children (converting [] ↔ block form; multi-line items re-indented).

Guarantees & limits

Guaranteed: everything the edit doesn't address is byte-identical, and a splice that would produce malformed YAML is refused. Out of scope: adding or removing mapping keys inside a body — a body that needs a different key set is regenerated whole (the generators in avatar-anim-gen, the component emitters in avatar-migrate) and swapped in with replace_document_body. A value edit can only change a value that already exists.