Editing Unity YAML
The modify counterpart to the read-only lint/stats/describe surface:
avatar_unity_yaml::EditableUnityFile and avatar asset set edit an
existing Unity asset (.asset, .controller, .prefab,
.anim, .unity) while preserving every byte they don't touch. Full text:
docs/reference/unity-yaml-edit.md.
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:
| Method | What it does |
|---|---|
remove_document | Drop 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_body | Swap 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 (DynamicBone → VRCPhysBone, SDK2 descriptor → SDK3). |
retag_document | Rewrite the header for a class change (e.g. CapsuleCollider 136 → MonoBehaviour 114). |
append_document | Add a new document at the end (fileID must be unused). |
append_sequence_item / remove_sequence_item | Add 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.