AssetHoard AssetHoard
RoadmapReleasesBlogDocsDownloadBuy
← All Posts
May 9, 2026

Parsing Godot .tres files and walking the resource graph

Asset Hoard library showing Godot .tres assets — material spheres, sprite frames animations and tilesets — with a SpriteFrames preview panel open on the right

A .tres file looks innocent. Open one in a text editor and you get something close to INI: a header, a few sections, some key-value pairs. Tiny. Readable. Surely a couple of regexes away from being parseable.

This impression survives roughly five minutes.

Godot resource files are a custom format that references other resources by path, by UID, and sometimes both. A StandardMaterial3D.tres is a small text file pointing at textures that live somewhere else. Move just the .tres to a different project and the material is broken on the other end. The textures cannot be found. The material falls back to magenta. Your asset is, for any practical purpose, useless.

This is a problem for an external asset manager. The whole point of Asset Hoard is that you find a thing in one place and use it somewhere else. If "use it somewhere else" only works for self-contained files, then half of what comes out of a Godot project is excluded.

So v0.1.13 ships proper .tres support. Materials, ShaderMaterials, SpriteFrames and TileSets all get real previews instead of generic icons. More importantly, dragging a .tres out of Asset Hoard now walks its reference graph and pulls every linked file along with it, reconstructing the res:// folder layout at the drop location. Drop the result into a Godot project and it just works.

This post is the long version of how that got built. Lexer, parser, resource resolution, rendering, and the drag-out behaviour. There are sharp edges in every layer.

The .tres Godot format

Before parsing anything you have to understand what you are parsing. The .tres format is text-based, deceptively simple at a glance, and full of corners.

A minimal example:

[gd_resource type="StandardMaterial3D" load_steps=3 format=3 uid="uid://abc123"]

[ext_resource type="Texture2D" uid="uid://def456" path="res://textures/stone_albedo.png" id="1_albedo"]
[ext_resource type="Texture2D" uid="uid://ghi789" path="res://textures/stone_normal.png" id="2_normal"]

[resource]
albedo_texture = ExtResource("1_albedo")
normal_enabled = true
normal_texture = ExtResource("2_normal")

The shape is sections in square brackets, each with a type and some attributes, followed by key-value pairs. [ext_resource] blocks declare external dependencies. [resource] is the main resource definition. Values can be primitives (numbers, strings, booleans) or constructor-like calls (ExtResource(...), Color(0.5, 0.5, 0.5, 1), Vector3(0, 1, 0)).

But this is the friendly version. Real files in real projects are denser, and the parser splits the work in two. A structural pass in handlers/tres.rs reads the [gd_resource] header, every [ext_resource] line, and every [sub_resource] block, because that grammar is uniform across resource types. The [resource] block is then handed off to a per-type body parser, dispatched on header.resource_type:

pub fn parse_tres(content: &str) -> Result<TresFile, TresParseError> {
    let structure = parse_tres_structure(content)?;
    let body = match structure.header.resource_type.as_str() {
        "SpriteFrames" => TresBody::SpriteFrames(
            crate::handlers::tres_spriteframes::parse_sprite_frames(content)?,
        ),
        "TileSet" => TresBody::TileSet(
            crate::handlers::tres_tileset::parse_tile_set(content)?,
        ),
        _ => TresBody::Flat(parse_resource_block_flat(content)),
    };
    Ok(TresFile { structure, body })
}

Anything without a dedicated parser falls into TresBody::Flat, a HashMap<String, String> that captures multi-line array and dict values verbatim by tracking bracket balance via a string-aware scanner. That alone covers StandardMaterial3D, ShaderMaterial, FontFile, Environment, and the long tail of Resource subclasses where the body is just key = value pairs.

The corners that earned their own code paths:

  • The format attribute on the header. format=2 is Godot 3, format=3 is Godot 4. The TileSet parser branches on this directly, because Godot 3 keys tiles by integer on the [resource] block while Godot 4 uses TileSetAtlasSource sub_resources with cell coords like 0:0/0 = 0. Same .tres extension, two near-unrelated grammars.
  • Both ExtResource styles. Godot 4 quotes the id (ExtResource("1_albedo")); Godot 3 uses an unquoted integer (ExtResource(1)). The lexer accepts both.
  • AtlasTexture vs direct ExtResource frames. A SpriteFrames frame can be a slice of an atlas via SubResource("AtlasTexture_idle_0") or the whole texture via ExtResource("1_xyz"). Both shapes appear in godot-demo-projects, so both are handled via a FrameTextureRef enum.
  • Aseprite Wizard metadata. The dominant 2D pipeline writes metadata/_aseprite_wizard_* keys after the animations array, including a multi-line nested dict. We locate animations = by key name rather than position so the trailing junk is ignored.
  • Trailing commas in dicts and arrays. Godot 4 emits them, Godot 3 does not. The Variant parser tolerates both.
  • StringName literals. The &"idle" form for Godot 4 dict keys, distinct from regular strings.
  • Color(...), Vector2(...), Rect2(...), PackedColorArray(...) and friends. Lexed as opaque TypedCall { name, raw_args } tokens. Most are preserved verbatim; specific call sites parse Rect2 and Vector2i arguments by hand when they need to.
  • One-PNG-per-tile TileSets. A Godot 3 .tres like hexagonal_map references 26 different PNGs, one per tile. We normalise this into "N atlas sources × 1 tile each" so the rest of the pipeline does not fork.
  • Godot 4 multi-cell tiles. size_in_atlas = Vector2i(W, H) declares a tile that spans W×H cells. The preview compositor honours it so a 2×1 tree draws double-width.

What we explicitly do not support: GDScript-defined custom resources (no metadata to render against, no safe way to execute), .tscn scenes (most of the format would work, but it is not in v0.1.13), TileSetScenesCollectionSource (silently skipped), and Godot 3 SpriteFrames (the format is fundamentally different and currently returns an empty result rather than a partial parse).

Lexing and parsing

The lexer and parser live in their own module, godot_variant/, with a hard rule that it imports only from std and serde. Nothing else in the crate. The isolation is deliberate: it is a candidate to extract as a workspace crate later, then maybe to crates.io if it turns out to be useful to anyone else.

The token enum:

pub enum Token {
    LBracket, RBracket, LBrace, RBrace, Comma, Colon,
    String(String),
    StringName(String),                              // &"idle"
    Int(i64),
    Float(f64),                                      // includes inf, -inf, nan
    Bool(bool),
    Null,
    SubResourceRef(String),                          // SubResource("AtlasTexture_xyz")
    ExtResourceRef(String),                          // ExtResource("1_abc") or ExtResource(1)
    TypedCall { name: String, raw_args: String },    // Color(1,1,1,1), Vector2(0,0), ...
}

tokenize(input: &str) -> Result<Vec<Token>, LexError> returns a Vec rather than an iterator. Inputs are tiny (a few KB per animations = [...] blob), the parser benefits from look-ahead, and any error short-circuits the batch.

The parser is recursive descent and panic-free by construction. No unwrap(), no out-of-bounds indexing, every fallible operation returns Result. The AST is intentionally minimal:

pub enum VariantValue {
    Dict(HashMap<String, VariantValue>),
    Array(Vec<VariantValue>),
    String(String),
    StringName(String),
    Int(i64),
    Float(f64),
    Bool(bool),
    Null,
    SubResourceRef(String),
    ExtResourceRef(String),
    TypedCall { name: String, raw_args: String },
}

I did not seriously evaluate nom, chumsky or pest. The format is just irregular enough that a generic combinator approach would have been more code than the focused state machine, and the no-third-party-dep rule on the module makes it easier to lift into its own crate later.

Errors do not tear the whole parse down. The structural pass skips malformed [ext_resource] and [sub_resource] blocks and continues. A SpriteFrames body that fails to lex or parse on a format=2 file gets downgraded to an empty result with a debug log; on format=3 the error propagates. The TileSet parser is best-effort throughout, with partial results always valid:

let variant = match lexer::tokenize(animations_text) {
    Ok(toks) => match parser::parse_variant(toks) {
        Ok(v) => v,
        Err(e) => {
            if format == 2 {
                log::debug!(
                    "parse_sprite_frames: format=2 graceful degradation (parse): {}",
                    e
                );
                return Ok(SpriteFramesData::empty());
            }
            return Err(TresParseError::BodyParseError(format!(
                "failed to parse animations Variant: {}", e
            )));
        }
    },
    ...
};

Unit tests cover every Variant shape we have seen in the wild: Godot 4 quoted refs, Godot 3 unquoted-int refs, dicts with StringName keys, arrays of dicts, nested PackedColorArray strings containing parens. Plus panic-free torture tests for unterminated strings, unbalanced parens, garbage bytes and lone -.

Resolving references

Parsing produces a resource with a list of declared external references. That is the easy half. The hard half is resolution: figuring out where each res:// path actually lives on disk, whether the referenced file is in your library, and what to do when it is not.

Resources nest. A StandardMaterial3D references textures. A SpriteFrames might reference an AtlasTexture defined as a sub_resource that itself points at a PNG. A TileSet references atlas textures and can chain into terrain definitions. So the full picture for any one .tres is a directed graph of references, sometimes several layers deep. Resolution is per-reference, not per-graph: the import pipeline runs the resolver on every .tres it sees, and the graph emerges from each asset's resolved entries linking up to other library assets. Walking the graph as a graph only matters at drag-out time, covered later.

Asset Hoard works on user-imported folders, and there is no guarantee a .tres lives inside a real Godot project tree. The resolver does not look for a project.godot file. Instead, for each res:// reference on a single .tres, it walks up the directory tree from the .tres's on-disk location (capped at depth 10) and at each level tries two candidates: <ancestor>/<full stripped path> to preserve the directory structure inside res://, and <ancestor>/<basename> as a flattened-pack fallback for authors who stripped the textures/ prefix when distributing. First match wins.

pub fn resolve_res_path(res_path: &str, tres_file: &Path) -> Option<PathBuf> {
    let rel = res_path.strip_prefix("res://")?;
    let rel_path = Path::new(rel);
    let basename = rel_path.file_name();

    const MAX_ANCESTOR_DEPTH: usize = 10;

    let mut current = tres_file.parent()?;
    for _ in 0..MAX_ANCESTOR_DEPTH {
        let full = current.join(rel_path);
        if full.exists() { return Some(full); }
        if let Some(name) = basename {
            let flat = current.join(name);
            if flat.exists() { return Some(flat); }
        }
        match current.parent() {
            Some(p) => current = p,
            None => break,
        }
    }
    None
}

The resolver runs as a post-import pass via the resolve_tres_references Tauri command. For each .tres asset it reads the header to assign a file_type of material / spriteframes / tileset / package, walks every [ext_resource] block, attempts to resolve each res:// path to a real file on disk, matches resolved paths against existing library assets, and writes the lot into metadata.references[] on the asset row. The pass is idempotent. Rerunning it is cheap and self-healing, so a previously-missing dependency that gets imported later picks up resolved_asset_id on the next pass.

References are not a separate table. They live as a JSON array on assets.metadata:

{
  "references": [
    {
      "path": "res://textures/stone_albedo.png",
      "resolved_asset_id": 1247,
      "disk_path": "/abs/path/to/stone_albedo.png",
      "slot": "albedo_texture"
    }
  ]
}

categorize_tres_references splits that array into three buckets at read time by stat'ing each disk_path: imported (resolved_asset_id populated), importable (unresolved but the disk path exists), missing (unresolved and the file cannot be located). Stat happens at categorise-time rather than resolve-time, so a file moved between sessions does not leave stale UI state.

Resolution is eager at import. The whole graph for a .tres is walked once, the result is cached in metadata.references[], and subsequent reads (preview, drag-out, references panel) never re-walk. The trade-off is staleness: if a referenced file moves on disk after import without rerunning the resolver, the cached disk_path is wrong. The categorise step's stat call covers the missing-now case, and there is a "Re-resolve Godot References" context action to force a re-walk.

Cycle handling lives in the drag-out path rather than the resolver itself. gather_tres_drag_payload recurses through .tres → .tres chains using a BFS queue with both a depth cap of 5 and a visited-asset-id HashSet, so a .tres referencing a .tres referencing back stops cleanly.

Rendering: the part that took the longest

Parsing and resolution are mechanical. Rendering is where the work actually became interesting, because each resource type needs its own thinking about what a useful preview looks like.

A useful preview is the one that lets you tell two similar resources apart at a glance. A generic "Material" icon fails this test immediately. A solid grey sphere is barely better. The goal is a thumbnail where the difference between stone_rough.tres and stone_polished.tres is visible without opening either file.

One overall architectural choice worth flagging up front: PBR rendering happens on the frontend via Three.js, not headless in Rust. Tauri is already shipping a WebView, the WebGL renderer is right there, and the result is a live, rotatable preview rather than a baked image. Thumbnails for the grid view are captured from the same renderer via canvas.toDataURL() and round-tripped to disk. One pipeline, two outputs.

StandardMaterial3D, ORMMaterial3D, SpatialMaterial. Rendered to a real sphere using THREE.MeshStandardMaterial and THREE.SphereGeometry, with an IBL studio environment for reflections and a single directional key light. The thumbnail generator keeps a singleton offscreen WebGLRenderer (preserveDrawingBuffer: true), swaps the maps onto a reused sphere, and captures the result. The preview is a real material, not a representation.

ShaderMaterial. Render the linked .gdshader source as code with GLSL syntax highlighting via highlight.js. Trying to render an arbitrary user shader without running it is impossible, and running untrusted user shaders against a stub uniform set produces garbage at best. Code preview turns out to be more useful in practice. You can scan the shader and recognise it immediately, which is more than you can say for yet another generic sphere.

FastNoiseLite + Gradient. These are procedural materials with no texture references at all. The naive approach is "no textures, no preview, fall back to icon", which produces a folder full of identical blank spheres. Instead we feed the Godot 4 FastNoiseLite parameters extracted from the .tres body into the fastnoise-lite npm package, which is a JavaScript port of the same library Godot itself uses. The noise is sampled into a canvas, the gradient is applied pixel by pixel, and the result plugs into the same texture slot a real albedo map would. It will not be pixel-identical to Godot. RNG seed handling and Godot's seamless mode (4D noise sampled on a torus) differ. But visually it is equivalent, which is the point. An ice noise material looks like ice and a lava one looks like lava.

SpriteFrames. Animation playback. Frame data is pre-flattened in Rust during the import pass, so each frame carries its source asset id, disk path, atlas region (sx, sy, sw, sh) and per-frame duration multiplier. The frontend does not need to walk sub_resources at playback time. The preview component blits each frame's region onto a canvas with a setTimeout-driven loop, with animation tabs, transport controls, frame counter, and 0.5×/1×/2× speed control. Trying to communicate "this is animated" via a static image is a losing battle, so we just animate it.

TileSet. Interactive zoom and pan preview. The Rust parser produces a normalised TileSetData covering atlas sources, tile counts, terrain names (set-major, so multi-set TileSets keep their grouping), custom data layer names, and physics and navigation layer counts. The Svelte preview renders the atlas with margin and separation honoured, multi-cell tile spans drawing across the cells they cover, and tooltip name lookup. The Godot 3 sub-rect path bakes the offset into per-tile explicit_region so the same renderer handles both formats.

Custom resource scripts. There is no safe way to run user GDScript from an external tool, so these get an "indexed but no preview" fallback with the flat-properties list as their detail view. This is the weakest part of the current implementation and probably the area I would most welcome community input on.

Drag-out: reconstructing res://

The final piece is the drag-out behaviour, and this is the bit I am most pleased with.

When a user drags a .tres out of Asset Hoard, the OS drag-and-drop pipeline expects a list of file paths. The naive implementation hands over the path to the .tres itself. The user drops it into their Godot project, and immediately discovers the textures are not there.

The fix is conceptually simple. Before the drag begins, build a temporary directory containing the .tres and every file in its dependency graph, arranged in the res:// layout each file expects. Hand the OS that whole tree as the drag payload. The user drops it, the operating system copies the lot, and the .tres finds its textures because the relative paths still resolve.

The drag-out logic lives in commands/tres_drag.rs. Two Tauri commands cover the cases: stage_tres_for_drag for one or more .tres assets dragged directly, and stage_tres_bundle_for_drag for dragging a bundle whose contents include .tres files. The bundle command merges the trees so the drop lands as a single self-contained project root rather than per-asset subdirectories.

Layout reconstruction skips longest-common-prefix entirely. The staging root is the project root: every reference lands at <staging_root>/<res_path stripped of "res://">, and the .tres itself goes at the staging root using just its filename. After the user drops the staging tree into a Godot project, the res:// references resolve from the new location.

let staging_root = std::env::temp_dir()
    .join("assethoard_drag")
    .join(format!("tres-{}", uuid::Uuid::new_v4()));

Source priority for each reference is resolved_asset_id first (use the library copy, which is canonical and never stale), then disk_path if it still exists, then skip if missing. A join_relative helper walks the path segment by segment, dropping .., . and empty segments, and replacing Windows-illegal characters (\ : * ? " < > |) so a malformed res:// path cannot escape the staging root. Files are copied via std::fs::copy rather than symlinked. Symlinks on Windows need elevation, the asset manager has no idea what filesystem the drop target sits on, and a copy is the only thing that survives a drop into a network share or a USB drive.

Lifetime is per-drag, not per-process. The frontend gets back a staging_root path plus a list of dragged_paths (the top-level entries inside the staging root, since dragging the staging root itself would prefix every res:// with the folder name and break resolution). When the OS drag-completed callback fires, the frontend invokes cleanup_tres_drag_staging, which remove_dir_alls the staging directory while refusing to touch anything outside the assethoard_drag namespace as a safety check. As belt-and-braces against a crash mid-drag leaving directories behind, cleanup_old_drag_staging runs on app startup and sweeps any tres-* directories with stale mtimes.

The drag is best-effort, not all-or-nothing. Missing dependencies are skipped with a debug log rather than aborting, so the user gets a valid tree of whatever did resolve and can fill the gaps by hand. The references panel has already surfaced anything in the missing bucket before they get to drag, so this is not a surprise.

Within a single drag, identical sources are deduped and target-path collisions follow first-write-wins, with a warning logged. A real collision means the resolver is wrong upstream.

The behaviour is always-on for .tres assets and not user-configurable.

What is next

A few things I am still working on or thinking about.

UID-based path recovery. UIDs are parsed and stored on the [gd_resource] header and on each [ext_resource] block, but resolution is path-only today. When a referenced file moves and the path goes stale, falling back to a library-wide UID index is the planned recovery path. Tracked as a separate card.

Custom resource scripts. Anything driven by user GDScript currently gets the "indexed but no preview" fallback. The cleanest path forward is probably a render hint that resource authors can embed in their own resources, which is something I would want to coordinate with the Godot community before defining. Would love to hear what shape that should take.

Scene file (.tscn) parsing. Scene files share most of the resource format and would unlock a lot. Currently in scope but not in v0.1.13.

GDExtension resources. Outside the scope of what an external tool can reasonably parse without engine-specific knowledge. Probably an "indexed but no preview" forever, unless GDExtension authors want to ship metadata alongside their resources.

Extracting godot_variant as a crate. The lexer and parser module is std + serde only on purpose. If there is appetite for a published crate that handles Godot 3 and Godot 4 Variant grammar with graceful error recovery, get in touch.

If you have run into adjacent problems, whether that is building tooling that consumes Godot resources from outside the editor, or wrestling with the format yourself, I would genuinely like to hear about it. Find me on Discord or send a message through the contact form.

For anyone curious about the tool itself, Asset Hoard is a local-first asset manager for indie game devs and digital artists. The Godot work shipped in v0.1.13. Open beta, free during beta, runs offline.

Detail page on the Godot work: assethoard.com/godot-asset-manager


Mark

Previous Best Offline Asset Manager for Indie Developers
AssetHoard AssetHoard

The asset manager for indie game devs and artists

Product

Buy AssetHoard Documentation Quick Start Guide Roadmap Release Notes Forgot License?

Platforms

Unity Godot

Community

Discord Mastodon Bluesky Subscribe to updates

Company

Contact Careers Privacy Policy

© 2026 AssetHoard. All rights reserved. Made in Melbourne 🇦🇺