A Rust monorepo of tools for VRChat avatars (Unity, SDK3 / Avatars 3.0). This document is the architecture + roadmap of record. Decisions marked [decided] are locked; [open] items are revisited as we learn.
There are two layers, and only one is fully ours to own in Rust.
| Layer | What lives here | Rust ownership |
|---|---|---|
3D asset — .fbx |
Armature/skeleton, bone names, bind poses, skinning weights, blendshapes | Full read (fbxcel); write is the open risk (§8). This is where "armature not set up right" lives. |
Unity/VRChat project — UnityYAML (.anim, .controller, .asset, .prefab, scene, .meta) |
Avatar Descriptor, humanoid bone mapping, animator layers, expression menus/params, blend trees, gesture keyframes | Read/lint low-risk; generate requires correct fileIDs/GUID refs. |
| Upload/build | VRCSDK control panel → VRChat servers | None. Interactive VRChat-account login; Unity license painful in containers; effectively Windows. Not ours. |
So this repo is file transformation / analysis / validation / generation plus an OSC runtime daemon — not a Unity replacement. We generate assets you drop into Unity; you build & upload there.
References: Unity YAML, VRChat Rig Requirements, fbxcel, GameCI / Unity license, VPM CLI, OSC Avatar Parameters, Animator Parameters.
Crate dirs are unprefixed; package names avatar-<slug>; lib names avatar_<slug>. Binary: avatar.
Foundation
avatar-fbx— load binary FBX (fbxceltree), extractModel/Geometryobjects +Connectionsinto a typed scene graph; plusFbxDocumentfor editing + write-back. [built: read M0/M1, write M3]avatar-unity-yaml— read UnityYAML (multi-doc--- !u!<classID> &<fileID>, GUID refs in.meta). Reader only for now; write/round-trip is deferred (see §8). [built: M2]
Models
avatar-armature— renderer-agnostic skeleton + Unity Humanoid bone enum; infer mapping from bone names; validate against VRChat rig requirements; propose repairs. [building: M1]avatar-unity-asset— typed Unity asset graphs overavatar-unity-yaml.AnimatorController(parameters, conditions, blend trees, state machines, write-defaults) is built for M2 lint rules;AnimationClip/ material / scene typing still to come. [built: AnimatorController (M2); rest M4]avatar-fbxalso writes:FbxDocumentretainsfbxcel's mutable tree, edits objects by id (rename / reparent / global-settings / scale), and serializes back to binary FBX. [built: M3]avatar-vrc-descriptor— expression menu/params (256-bit budget, 8-control menu) and the VRC Avatar Descriptor (view position, viseme lip-sync, expression refs, playable layers) extracted structurally; SDK3 rule constants. [built: M2]avatar-vpm— parsevpm-manifest.json; discover project root; report editor version + installed SDK / Modular Avatar / VRCFury versions. [built: M2]
Logic
avatar-lint— diagnostics engine; rules over project/params/menus/descriptor/controllers/ PhysBones → structured report (serde_json+ pretty print, error/warn/info). Rule codes (VRC001–VRC052) indocs/reference/sdk3-lint-rules.md. [built: M2 + extensions]avatar-stats— offline VRChat performance ranking (Excellent→Very Poor): geometry metrics from an FBX (analyze_fbx) and component metrics from a project's avatars (analyze_project), ranked against the PC + Android limit tables (encoded as data). [built]avatar-anim-gen— generate.animclips + FX-layer blend trees (wrapped in a full FXAnimatorController) for analog gestures and general expression authoring. Unity-YAML emitter, deterministic fileIDs, reader-validated round-trip + a real-editor import gate in CI. [built: M4 — library +avatar anim-gen clip|blendtree|controllerCLI; full FX animator-layer assembly done; CLI-generated assets gated against a real Unity editor]avatar-migrate— SDK2 → SDK3 migration of an avatar project: the SDK2 prefab rewritten in place overEditableUnityFile(descriptor/PipelineManager retyped at their fileIDs, root motion off, DynamicBone → PhysBone with the SDK's own disassembled conversion rules, Cloth → PhysBone skirt, subtree stripping, gesture overrides → FX layer, rig-derived eye look) + a VCC-openable project tree. [built — library +avatar migrate sdk3CLI; golden-tested on a synthetic SDK2 fixture, validated on a real 2021 export, imported + uploaded through Unity/VCC on the user's side]. Post-migration PhysBone tuning on any SDK3 prefab (avatar physbone list|set|split|stretch:PhysBoneSpecread-back + re-render with per-chain curves, chains split onto their own components, chain stretch for a longer skirt/tail, chain re-angling;avatar render --pose <prefab>draws the FBX in a prefab's pose to preview it). [built — golden-tested; applied to the real avatar, second round after wearing it]
Runtime
avatar-osc—rosc-backed VRChat OSC parameter protocol:/avatar/parameters/*,/input/*axes & buttons (reset-to-zero),/avatar/change, split pure-codec + UDPParamClient, plus offline OSCQuery avatar-config (AvatarConfig) parsing. [built: M5 — library +avatar oscCLI; live OSCQuery discovery next]avatar-osc-gestures— daemon mapping controller trigger analog → fractional gesture weights → OSC. The "Vive advanced controls" feature. Glam-freeAnalogSource+ pureHandMapping(deadzone)- change-detecting
GestureDaemon; demo trigger sweep headless, OpenXR input backend on-device. [built: M5 — library +avatar osc gesturesCLI; OpenXR input adapter next]
- change-detecting
avatar-mcp— a domain-agnostic Model Context Protocol server (stdio JSON-RPC 2.0): pureServer::handledispatch core + thinserve_stdioloop, no async, mirroring theavatar-osccodec/transport split. The cli wires the read/diagnose surface in as MCP tools so an agent host can discover + call capabilities structurally. [built — library +avatar mcp serveCLI; read-only tools, generation tools next]
Entry point
avatar-cli—avatarbinary, clap v4 subcommands. [building: M1]
Test support
avatar-testkit— test-only (publish = false): the golden-snapshot harness (golden::assert_json/redact_roots), the shared fixture-corpus resolver (corpus/workspace_root), and in-code synthetic-FBX builders (fbx::humanoid_skeleton, featurefbx). Pulled in as a dev-dependency by the crates that golden-test their report surfaces. Seedocs/reference/testing.md. [built]
avatar fbx inspect model.fbx— node hierarchy, skeleton roots, bone count, scale/orientation, units; flags unapplied transforms, wrong axis/units, extra root, unskinned mesh, missing T-pose.avatar armature check model.fbx— map detected bones → Unity Humanoid; report missing/mis-mapped bones against the required spine chain (Hips→Spine→Chest→Neck→Head + shoulders + hands + feet).avatar armature fix model.fbx -o fixed.fbx— opt-in safe repairs (rename to humanoid-friendly convention, reparent stray bones, normalize scale/orientation), write FBX or emit a headless Blender script (see §8). [M3]- For a Unity project:
avatar lint <projectDir>adds Avatar-definition + Descriptor + SDK3 checks.
VRChat already exposes the analog signal: GestureLeftWeight/GestureRightWeight are floats 0→1
from trigger pull; a blend tree in the Fist slot blends on them. The feature splits:
- Authoring (
avatar-anim-gen): generate FX-layer blend trees + keyframed.animso any gesture reaches any fraction (conceptually what ComboGestureExpressions does, as a generator). - Runtime (
avatar-osc-gestures): daemon reads controller analog inputs, sends/avatar/parameters/<Gesture>Weight— mimicking Vive advanced-controls mapping on any hardware.
- Rust → Docker yes:
docker/rust.Dockerfile, UID/GID-matched like the Legaia Ghidra image. CI = GitHub Actions:fmt --check+clippy --all-targets -D warnings+test --workspace. - Unity → mostly no: GameCI can run Unity headless for validation, but upload needs interactive auth and Unity licensing is painful in containers. Documented as optional; not a dependency. Upload stays a manual Unity step.
Locked to the legend-of-legaia-re style — see CLAUDE.md. Addition vs. Legaia: a
rust-toolchain.toml pinning the toolchain (fbxcel + edition 2024 want a recent compiler).
- [decided] Crate prefix
avatar-(libsavatar_*, binaryavatar). - [decided] FBX writes: prototype native (
fbxcelwriter) first in M1/M3; fall back to emitting headless Blender Python scripts if Unity/Blender reject the output. - [decided] First build target: M0 scaffold + M1 armature diagnosis on a real FBX.
- [open] Whether
avatar-lintshould emit Modular Avatar components as an output format. - [open] ASCII-FBX support (currently out — binary only).
- FBX write-back (was the biggest). Resolved in M3.
fbxcel0.9'swriterfeature gives a mutable tree + binary serializer (Writer::write_tree/finalize);avatar-fbx'sFbxDocumentloads, edits by object id, and writes back. Proven by round-trip tests, including anAVATAR_SAMPLE_FBX-gated apply-on-a-real-Mixamo-FBX test. Residual: final acceptance is a manual Unity import. Notewrite_treere-emits arrays uncompressed (larger files, semantically fine). The Blender-headless fallback is reserved for mutations that require re-transforming geometry (scale/orientation normalization), which M3 deliberately flags rather than applies. - UnityYAML round-trip +
.metaGUIDs. Read/lint low-risk; generating whole assets needs correct fileIDs/GUIDs (done in M4 for.anim/.controller). Surgical edits are now safe too:avatar_unity_yaml::EditableUnityFile(andavatar asset set) edit an existing asset by span-splicing the raw text — it locates the target value's byte range and replaces only that, leaving every&fileID,{fileID, guid}reference, key order, and byte of formatting untouched by construction, then re-parses to validate. This sidesteps the original risk (a parse→re-emit that reorders keys / drops anchors breaks references): unchanged bytes are never rewritten. Scope is value edits (scalars, reference re-targets, flow-map subfields); structural edits (adding/removing keys or sequence elements) still prefer the generators. Seedocs/reference/unity-yaml-edit.md. - VRChat SDK churn. Encode rules as data/config, not hardcoded constants.
- Ecosystem overlap. Modular Avatar / VRCFury / NDMF already cover non-destructive Unity-side building. Complement them (own the FBX/armature + headless-validation layer; consider emitting MA components) rather than duplicate.
- Malformed / hostile inputs. Hardened:
.unitypackageextraction caps decompressed bytes per entry (512 MiB) and in total (2 GiB) — a decompression-bomb guard;avatar-gltfrejects primitives with >u32::MAXvertices;avatar-fbxreadsUpAxis/FrontAxisvia checkedi32::try_from; andavatar-unity-yaml'sparse_lossyis now infallible by construction (no latent panic path).
- M0 — Scaffold. Workspace, CI, hooks, docs,
avatar-fbxreading a real FBX node tree. (this pass) - M1 — Armature diagnosis.
avatar fbx inspect/armature check; resolve FBX-write question. (this pass) - M2 — Project linting. ✅
avatar-unity-yaml,avatar-unity-asset,avatar-vpm,avatar-vrc-descriptor,avatar-lint→avatar lint <project>SDK3-compliance report. Covers params budget, menus, dangling refs, project/SDK info, the VRC Avatar Descriptor parsed from prefabs/scenes (expression-parameters/menu reference resolution via a guid→path.metaindex, playable-layer animator-controller existence, viseme lip-sync, eye-look config, and an expression-parameter↔animator wiring cross-check by name+type:VRC002/030–037), and animator-controller contents (parameter references, default states, Write Defaults consistency, duplicate params:VRC040–044). Since extended with seven more rules:VRC012(param used by no menu/animator),VRC022(empty menu control),VRC038(duplicate/empty viseme blendshape),VRC045(Write Defaults inconsistent across the avatar's playable-layer controllers), and a PhysBones/Avatar-Dynamics groupVRC050–052(unresolvable root, moves zero transforms, collider slots but none wired). M2 gaps closed. Since extended again: animation-clip rules (VRC046–VRC049: missing/absent state motions, FX-layer transform/muscle curves, empty clips), the viseme↔source-FBX morph-channel cross-check (VRC039, the first cross-layer rule), and a hygiene/Android group (VRC060missing scripts,VRC061non-mobile shaders). Next: contacts. - M3 — Armature repair. ✅
avatar armature fix <model.fbx>plans repairs and (with-o) writes a corrected binary FBX viaavatar-fbx'sFbxDocument. The one native repair is canonical humanoid bone renames (id-safe; what makes Unity auto-map). Mis-wired parent topology and scale/orientation normalization are flagged, not applied: each needs a geometry transform (→ Blender territory), not a metadata relabel — re-pointing a bone'sOOconnection without recomposing its local transform would move its rest/bind pose. Dry-run by default. The native-FBX-write risk (§8) is resolved. Rules/behaviour:docs/reference/armature-repair.md. The headless-Blender fallback is now emitted:--blender-scriptrenders the whole plan (renames + rest-pose-preserving reparents + transform baking) as a Blender Python script (avatar_armature::blender_script); running it under CI is the remaining step. - M4 — Asset generation. 🟡 Library + CLI landed; expression assets + the toggle composite since.
avatar-anim-gengenerates Unity-YAML.animclips (AnimationClip: blendshape-weight + GameObject-active curves) and FX-layer analog-gesture blend trees (BlendTree), with a faithful YAML emitter, deterministic FNV-seeded fileIDs, and a reader-validated round-trip — driven byavatar anim-gen blendtree/… clip/… controller. Acontroller.rsmodule also emits a full FXAnimatorController(class 91) wrapping a blend-tree layer (m_AnimatorParameters+m_AnimatorLayers+ state machine), now exposed by thecontrollersubcommand. The reader round-trip is in-repo, and the Unity-acceptance workflow now imports CLI-generated.anim/.controllerassets into a real editor (GeneratedAssetAcceptance.cs) and asserts they parse into the expected object types with no import errors — closing the "live Unity import" gap for M4 (gated on aUNITY_LICENSEsecret). Since extended with expression-asset generation (VRCExpressionParameters/VRCExpressionsMenu,avatar anim-gen params|menu) and the compositeavatar togglebundle (On/Off clips + two-state FX controller + params + menu + guid-pinning.metasidecars — the end-to-end authoring loop), all also exposed as non-writing MCP tools (avatar_gen_*). The typedAnimationClipreader landed inavatar-unity-asset(feeding lint's clip rulesVRC046–VRC049). Remaining: typed material/scene inavatar-unity-asset. Behaviour:docs/reference/anim-gen.md. - M5 — OSC runtime. 🟡 Library + CLI landed.
avatar-oscimplements VRChat's OSC parameter protocol (/avatar/parameters,/input,/avatar/change) as a pure codec + non-blocking UDPParamClient, plus offline OSCQuery avatar-config parsing — driven byavatar osc send|input|monitor|change|query. The analog-gesture daemonavatar-osc-gestures(the "Vive advanced controls on any hardware" feature) maps a controller trigger → gesture + weight with a glam-freeAnalogSource, a pure deadzonedHandMapping, and change-detected sends — driven byavatar osc gestures(a synthetic demo sweep headless). Remaining: live OSCQuery (HTTP/mDNS) discovery and the daemon's on-device OpenXR input backend. Behaviour:docs/reference/osc-runtime.md.
A cost-side complement to lint's correctness-side checks: avatar-stats reproduces VRChat's
performance ranking (Excellent→Very Poor) offline. avatar stats <model.fbx> ranks the geometry
metrics (triangles, skinned/basic meshes, material slots, bones); avatar stats <project> ranks the
component metrics per avatar (PhysBone components/colliders, contacts, particle systems, lights,
renderers, …), recognizing VRChat dynamics MonoBehaviours structurally and everything else by Unity
class id. Overall rank = the worst measured metric; metrics a given source can't measure (total
particles, constraints) are surfaced in not_evaluated rather than silently assumed clean. The
project path resolves renderers' m_Mesh guids to their source FBX for triangles (distinct files
counted once; unresolved meshes flagged), reads bones from m_Bones, estimates texture memory
for both PC and Android through the renderer → material → texture chain (per-texture VRAM from image
dimensions + per-platform import format — DXT/BC vs ASTC/ETC2; an estimate, since the imported GPU
format isn't knowable offline), and computes PhysBone affected-transform & collision-check counts
by walking the transform hierarchy under each PhysBone's rootTransform (descendants minus
ignoreTransforms, plus an endpoint per chain tip when endpointPosition is set, × assigned
colliders — an estimate of VRChat's Avatar-Dynamics cost; PhysBones with an unresolvable root are
flagged), so a project gets a unified geometry+component rank on both platforms. Limit tables (PC +
Android) are encoded as data (decision §7, risk 3). Total particle count (per ParticleSystem:
min(maxParticles, ceil(rate × lifetime)), summed; unparseable systems flagged) and constraint
count + depth (Unity constraint class ids 320–325 + structural VRChat constraints; depth = longest
constraint→source chain via a cycle-safe walk) are now measured rather than deferred. Mesh-particle
polygon cost (mesh-mode renderer's mesh triangles × particle count, resolved through the
renderer→mesh→FBX chain — approximate) and particle trail/collision flags (systems with
TrailModule/CollisionModule enabled) have now landed too, emptying the not_evaluated list by
default. Behaviour: docs/reference/performance-stats.md.
A parallel band requested by the Legaia VR spectator PRD (§9): load and render/pose a rig, renderer-agnostic (the consuming wgpu viewport owns the draw). Independent of the VRChat path; it generalizes the layer beneath VRChat (rig + pose + input). Crates:
avatar-mesh— PODRawMesh/SkinDatainterchange (noglam, no format dep).avatar-fbx::meshes()+avatar-gltf— FBX and glTF →RawMesh+ skin/bind matrices.avatar-pose—PosedSkeleton→ world matrices, GPU bone-matrix palette, CPU skinning, andpose::ikanalytic two-bone IK. The onlyglam(f32) in the runtime tier.avatar-input— backend-agnosticTrackerState/TrackerSource(HMD + controllers + trackers);MockSource+ anosc-feature backend; OpenXR is the planned on-device backend.
Bind transforms come straight from the file (TransformLink / inverse-bind), never recomposed from
Lcl + PreRotation; correctness is pinned by a renderer-free rest-pose reproduction test on
both importers. Behaviour: docs/reference/rig-runtime.md. Next: GPU skinning demo in the viewport
(consumer side), OpenXR backend, optional IK refinements (twist, foot-lock).