the symptom, in your words

"Model.Load of a missing vmdl returns the error model, not null"

✓ verified on 26.07.22
lane Tooling & environmentposted updated

▸ SYMPTOM

You call Model.Load("models/my_prop.vmdl") on a path that doesn't exist (typo, asset not compiled yet, wrong project). Instead of getting null, you get a model that renders as the engine's giant orange ERROR text mesh in the scene. Your null check passed, so the error mesh spawns silently.

▸ CAUSE

Model.Load does not always return null for missing assets. It can return the engine's built-in error model, and that error model's Name property may contain the requested path, not "error". So a naive model.Name.Contains("error") check may also pass depending on the path you requested.

▸ FIX

Use the full defensive gate:

snippet
var model = Model.Load( path );
if ( model == null || model.IsError )
{
    Log.Warning( $"Model not found or failed to load: {path}" );
    return;
}

Model.IsError is the reliable check: it returns true for the error model regardless of what Name contains. Don't rely on null checks or string matching alone.

Critical addendum: a failed load is cached for the life of the editor process

Confirmed on engine 26.07.22: a failed Model.Load result is cached for the life of the editor process, not the play session: play_stop/play_start does NOT clear it, only a fresh editor process does. If a system calls Model.Load(path) while the target is still unindexed or uncompiled, gets the error model back, and skips or hides the placement, then later gets the asset properly compiled, that system keeps rendering nothing (or the error model) across any number of stop/start cycles in the same session, every later Model.Load(path) for that string hits the same poisoned entry.

This is what turns "we compiled it and it's still invisible" into a false art-defect or logic-bug hunt: the process that reported the failure is the same process still holding the failed answer. Diagnose by count, not by eye: log every load result (path, success/error) at build time and treat "compiled the asset but the scene is unchanged" as proof of this cache rather than a new bug. The only fix is restarting the editor; the very next boot re-resolves every path fresh. Verify with a durable log line (not the live console, which scrolls a build summary out of view in seconds) that reports a placed/loaded count matching the source data with zero load failures.

The error model also passes a bounds check

One more trap for anything doing a placement census: the error model reports positive bounds, not just a non-null handle and a plausible Name. So a validity gate written as "did it load, and are its bounds non-degenerate?" still counts every missing asset as a successfully-placed prop: the orange ERROR mesh has real extents. IsError remains the only reliable discriminator; do not substitute a bounds test for it. If a prop-placement pass reports the right count but the world is full of ERROR text (or empty), audit the gate for exactly this: it trusted bounds where it should have trusted IsError.

▸ WHY IT WORKS

The engine's asset pipeline substitutes a visible error placeholder instead of returning null so that missing assets are obvious in the editor viewport. IsError is the engine's own flag for this substitution, making it the only reliable way to distinguish a real loaded model from an error fallback.

Verified on engine 26.07.22: seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?
changelog
  • Added the bounds-check trap: the error model reports positive bounds too, so a placement census gated on 'loaded and bounds > 0' still miscounts missing assets as placed. Gate on IsError.
  • Added the process-lifetime cache-poison addendum (a failed Model.Load result survives play_stop/play_start; only a fresh editor process clears it). Re-verified on engine 26.07.22.
  • Published

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord