| name | validate-assets |
| description | Pre-flight a LunCoSim `.mo`, `.usda`, `.wgsl`, or `.rhai` asset with `ValidateAsset` or the production CLI before loading it. Use for parse, reference, schema, shader-parameter, Modelica, or authored-lint checks. It is a read-only query; use `RunLint` for a loaded scene and test-via-api for runtime behavior.
|
Validate an asset (pre-flight)
ValidateAsset answers one question — does this file parse, and would the
engine accept it? — without a scene, a cosim, a GPU, or a window. It is the
cheapest possible check and it is safe to run against a live luncosim
mid-simulation: it only reads files.
Implementation: crates/lunco-scene-commands/src/validate.rs.
Related: author-usd-component (author the
file), use-asset-library (get it discovered),
build-vehicle (wheels), test-via-api
(drive the running app once it validates).
Two invocation forms
CLI — no app, no window, no GPU
target/debug/luncosim --validate \
assets/models/LunCo/Electrical/Battery.mo \
assets/vessels/rovers/skid_rover.usda \
assets/shaders/rover_hull.wgsl
The flag is intercepted in crates/lunco-luncosim/src/bin/luncosim.rs
before the Bevy App is built, and the process exits — nothing is
rendered, no window opens, no port is bound. Run it anywhere, any time.
| Exit code | Meaning |
|---|
| 0 | every report ok |
| 1 | at least one report failed |
| 2 | --validate given with no paths |
- Multiple paths: everything after
--validate up to the first argument
starting with --.
- Exact flag match only —
--validate=path and -v are not parsed.
- Output per file:
OK <path> (<kind>) / FAIL <path> (<kind>), then
indented error: and warning: lines on stdout.
API — against a running luncosim
curl -s -X POST http://127.0.0.1:4101/api/commands \
-H "Content-Type: application/json" \
-d '{"type":"ExecuteCommand","command":"ValidateAsset","params":{"path":"lunco://models/LunCo/Electrical/Battery.mo"}}'
Only one param: path (string). It is a query provider, so the data
comes back in the response body — no secondary result request is needed.
Answered by luncosim binaries only. ValidateAsset is registered in
SpawnCommandPlugin (crates/lunco-scene-commands/src/commands.rs), which
lunica does not link — asking lunica gives CommandNotFound. Use the CLI form
when only lunica is up.
The report
{"path":"…", "kind":"modelica|usd|wgsl|rhai|unknown",
"ok":true, "errors":[], "warnings":[], "info":{}}
ok == errors.is_empty(). Warnings never fail a file. path echoes what you
passed, not the resolved disk path — if you need to know which file was read,
pass an unambiguous one.
What each extension actually checks
| Ext | Checks | Can it FAIL? |
|---|
.mo | rumoca parse_to_syntax + branch-free lint | yes |
.usda | layer parse → compose the reference closure → strict WheelParams::read on every PhysxVehicleWheelAPI prim | yes |
.wgsl | ParamSchema::parse — reflect the struct Material uniform + //!@ annotations | no — warnings only |
.rhai | rhai::Engine::new().compile(), nothing executed | yes |
| anything else | unsupported extension error | yes |
Extension gate is literal: .usda only — .usd and .usdc are rejected as
unsupported, not parsed.
.mo — the branch-free lint is the point
rumoca's solver path is branch-free, so validate.rs scans the source (after
stripping comments) and emits errors, not warnings:
when / elsewhen — an error anywhere in the file.
if — an error only inside an equation / initial equation /
algorithm / initial algorithm section. An if in a binding or a modifier
is fine.
Fix by rewriting as der(x) = expr with max()/min() clamps. Battery,
network, and brownout equations belong in Modelica, not a tick script.
info carries {model, params, inputs, outputs:null}. outputs is always
null — outputs are not knowable before a compile.
Lint caveats (real false positives): the scanner does not strip string
literals, so a when/if inside a description string or annotation(...)
is flagged. And end if; / end when; resets the "in an equation section"
flag, so ifs after a nested block close stop being flagged.
.usda — this is the one that catches broken references
Three stages, first failure short-circuits:
usda_to_data — this file's own syntax.
compose_file_to_stage — fetches the whole layer closure
(subLayers + references + payload, including arcs inside variant
blocks). A dangling @lunco://…@ is a hard error here. This is the single
best reason to run it: bare paths silently no-load at runtime,
but a missing target fails loudly right here.
WheelParams::read on every prim with PhysxVehicleWheelAPI — the same
strict reader the spawner uses. The error names every missing attribute:
wheel /Rover/Wheel_FL would refuse to spawn — missing required attributes: …
info.wheel_prims lists each wheel with ok and, when failing, missing.
Three things it does not catch: binary leaf references (.glb/.obj/.stl
are not layers, so a broken mesh path passes); suspension-inherited wheel
attrs — the reader is called with no attachment suspension, so a wheel that
only validates once its suspension arc composes at spawn time is judged
without it; and collider geometry, which is where mechanism bugs live.
That last one is a limit worth knowing. Validation is per-prim and
schema-shaped, so it cannot see that two colliders on the same vehicle overlap,
or that a strut hangs lower than the foot that is supposed to carry it — facts
about composed transforms and extents, not about attributes. Clearance is a
runtime check: run the scene under luncosim test and assert the mechanism
moved (see author-usd-physics).
A vehicle can validate perfectly and still land on its shins.
.wgsl — cannot fail, read the warnings
There is no naga validation — deliberately. A syntactically broken shader
that still contains a parsable struct Material reports ok: true. What you get
is the reflected param schema (info.shader_params with name/type/offset/
ui/default, plus uniform_size) and two possible warnings:
no reflectable Material struct — the shader exposes no tunable params and
cannot be driven by SetObjectProperty.
not prop-pickable: engine fields beyond sun_vis — it uses //!@engine
params only the terrain pipeline fills, so the prop-material picker skips it.
It still works as a scene shader. See
use-asset-library § Shaders.
Path resolution — the trap
resolve() (validate.rs) tries, in order:
Path::new(ref).is_file() — absolute, or relative to the current
working directory.
lunco_assets::engine_asset_local_path(ref) — the runtime lunco:// root,
selected from the executable/package ancestry and then the current-directory
ancestry.
Consequences:
- ❌
models/X.mo is ambiguous: it resolves to <cwd>/models/X.mo if that
exists, shadowing <runtime-assets>/models/X.mo.
- ✅
lunco:// keeps the same runtime root when launched from a subdirectory.
- ✅ Run from the repo root and pass either
assets/models/X.mo (unambiguous
filesystem) or lunco://models/X.mo (unambiguous scheme).
- ❌
twin:// cannot be resolved at all, even with an instance running —
the resolver only knows the engine root. Pass the twin file's real filesystem
path instead.
The rules are authored — the lint layer
Everything above is what the loader would refuse: parse, compose,
WheelParams. Compiled, because it is the loader's own code. A second tier runs
on the same call and answers a different question — is this right? Those
rules live in assets/scripting/policy/lint_<domain>.rhai and are reached
through the lint.<domain> hook, so adding, tightening or silencing one is an
edit to a script, not a rebuild.
Findings arrive in the same report: error severity joins errors (and flips
ok), everything else joins warnings. Each line is prefixed with its domain
and rule id, which is what you grep for:
[usd/nested-body-no-joint] /Rover/Motor_FL — applies PhysicsRigidBodyAPI inside
the body </Rover> but no joint names it — it is a SEPARATE body held by nothing
and will fall out of the vehicle. …
The USD rules include nested-body-no-joint (error), joint-target-not-a-body (error),
collision-enabled-without-api (error),
dynamic-body-no-collider (warn), mass-outside-any-body (warn),
conditionally-stable-joint-drive (error), joint-drive-negative-stiffness
(error), joint-drive-negative-damping (error), invalid-gear-drive (error),
and invalid-network-synthesizer (error). Collection ownership is derived
from the composed member role schemas by the same runtime classifier; an
absent selector does not make a physical actuator collection a Modelica
network. See
author-usd-physics
for the authoring rule they enforce and
docs/architecture/lint-substrate.md
for the design.
The file is not the scene — RunLint
ValidateAsset lints a file. After a scene is loaded, spawned into and
edited, no file describes what is running; lint that with the verb:
cmd("RunLint", #{}); // lints every loaded stage, same rules, same facts
query("LintReport"); // { errors, warnings, findings[] }
or {"type":"ExecuteCommand","command":"RunLint"} over HTTP/MCP. Nothing lints automatically at load,
on a physics tick, or on a background cadence — deliberately. An editor,
launcher, or caller explicitly repeats the command after an authored change, and
register_hook("lint.usd", "lint_usd", my_rules); // next RunLint obeys
re-shapes the rules for the next explicit lint run without a rebuild.
Where it fits
edit .usda / .mo / .wgsl / .rhai
↓
--validate ← seconds, no GPU. Catches: syntax, broken refs, missing
↓ wheel attrs, if/when in Modelica, unparsable rhai.
load the scene ← test-via-api
↓
drive it / assert ← author-scenario, drivetrain_parity
Validate every file you touched before you launch anything. A --validate
run costs seconds; a luncosim launch that dies on a typo costs a compile.
Anti-patterns
- ❌ Launching the full luncosim to find out whether a file parses — that is what
--validate is for.
- ❌ Sending
ValidateAsset to lunica and concluding the command doesn't
exist. It is luncosim-only; use the CLI.
- ❌ Treating a
.wgsl ok: true as "the shader compiles" — no naga runs.
Only a real load proves the pipeline builds.
- ❌ Passing a bare
models/X.mo from an arbitrary CWD and trusting which file
was read — path in the report echoes your input, not the resolved path.
- ❌ Passing a
twin:// address — unresolvable; use the filesystem path.
- ❌ Reading
ok and ignoring warnings on a .wgsl — that file can never
report ok: false, so the warnings ARE the result.
- ❌ Adding an
if to a .mo equation section to "handle a case" — rewrite it
branch-free; the lint is enforcing a real solver constraint, not a style rule.
- ❌ Reading a
[usd/…] lint error as "the file is broken syntax". It parsed and
composed fine — it says the file would load and then behave wrongly. Fix the
authoring, don't chase the parser.
- ❌ Validating a file and concluding the running scene is clean. Runtime spawns
and edits are in no file;
cmd("RunLint", #{}) is the check for those.
- ❌ Adding a rule in Rust. Rules go in
assets/scripting/policy/lint_*.rhai;
only new FACTS are Rust, and only when no existing fact can answer the
question (facts.prims[].schemas answers most of them).