Using Rust to parse Godot .tres files and walk the resource graph

Lobsters Hottest Tools

Summary

This blog post details the implementation of .tres file parsing and resource graph walking in Rust for the Asset Hoard asset manager, enabling external dependency resolution and drag-and-drop export for Godot projects.

<p><a href="https://lobste.rs/s/7qxxww/using_rust_parse_godot_tres_files_walk">Comments</a></p>
Original Article
View Cached Full Text

Cached at: 05/16/26, 09:10 AM

# Parsing Godot .tres files and walking the resource graph Source: [https://assethoard.com/blog/parsing-godot-tres-files](https://assethoard.com/blog/parsing-godot-tres-files) [![Asset Hoard library showing Godot .tres assets — material spheres, sprite frames animations and tilesets — with a SpriteFrames preview panel open on the right](https://assethoard.com/releases/assethoard-v0.1.13.png)](https://assethoard.com/releases/assethoard-v0.1.13.png) 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](https://assethoard.com/releases/0.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\. ## Format quirks and how we handle them Corner caseWhat it looks likeHow we handle itFormat header`format=2`\(Godot 3\) vs`format=3`\(Godot 4\)\. Same`\.tres`extension, near\-unrelated grammars for TileSets\.TileSet parser branches on the format attribute\. Godot 3 keys tiles by integer on`\[resource\]`; Godot 4 uses`TileSetAtlasSource`sub\-resources\.`ExtResource`styles`ExtResource\("1\_albedo"\)`\(Godot 4, quoted id\) vs`ExtResource\(1\)`\(Godot 3, unquoted integer\)\.Lexer accepts both\.AtlasTexture vs ExtResource framesA SpriteFrames frame can be`SubResource\("AtlasTexture\_idle\_0"\)`\(atlas slice\) or`ExtResource\("1\_xyz"\)`\(whole texture\)\.Both shapes handled via a`FrameTextureRef`enum\.Aseprite Wizard metadata`metadata/\_aseprite\_wizard\_\*`keys written after the animations array, including a multi\-line nested dict\.Locate`animations =`by key name rather than position, so trailing junk is ignored\.Trailing commasGodot 4 emits them in dicts and arrays; Godot 3 does not\.Variant parser tolerates both\.`StringName`literals`&"idle"`form for Godot 4 dict keys, distinct from regular strings\.Lexed as a separate token type\.Type constructors`Color\(\.\.\.\)`,`Vector2\(\.\.\.\)`,`Rect2\(\.\.\.\)`,`PackedColorArray\(\.\.\.\)`and friends\.Lexed as opaque`TypedCall \{ name, raw\_args \}`tokens\. Preserved verbatim; specific call sites parse`Rect2`and`Vector2i`by hand when needed\.One\-PNG\-per\-tile TileSetsGodot 3`hexagonal\_map\.tres`references 26 PNGs, one per tile\.Normalised into "N atlas sources × 1 tile each" so the rest of the pipeline does not fork\.Multi\-cell tilesGodot 4's`size\_in\_atlas = Vector2i\(W, H\)`declares a tile spanning W×H cells\.Preview compositor honours it so a 2×1 tree draws at 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](https://assethoard.com/releases/0.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 sub\_resource that itself points at a PNG\. A TileSet references atlas textures and can chain into terrain definitions\. The full picture for any one`\.tres`is a directed graph, sometimes several layers deep, but the resolver does not treat it as one\. It runs per\-reference on every`\.tres`it sees during import, and the graph emerges implicitly as each asset's resolved entries link up to others in the library\. Walking the graph as a graph only matters at drag\-out time\. 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 each resource type 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 is direct: 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 symlinking\. 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\_all`s 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](https://assethoard.com/releases/0.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\.[Discord](https://discord.gg/e6MW7hDSAp)or the[contact form](https://assethoard.com/contact)\. Asset Hoard is a local\-first asset manager for indie game devs and digital artists\. The Godot work shipped in[v0\.1\.13](https://assethoard.com/releases/0.1.13)\. Open beta, free during beta, runs offline\. Feature overview at[assethoard\.com/godot\-asset\-manager](https://assethoard.com/godot-asset-manager)\. --- *Mark*

Similar Articles

Rust x GBA: Setup and Pixels

Lobsters Hottest

A tutorial guide that walks through setting up a Rust project to build a ROM that runs on the Game Boy Advance, covering project setup and pixel rendering.

Migrating from Go to Rust

Hacker News Top

A comprehensive guide for Go developers migrating to Rust, focusing on backend services, comparing tradeoffs in correctness, runtime, and ergonomics, with practical advice on incremental migration.

Rust implementations of vision transformer models

Reddit r/ArtificialInteligence

A Rust crate for building and experimenting with Vision Transformer (ViT) models, providing typed configs, reusable structs, and runnable examples for research and production.

godotengine/godot

GitHub Trending (daily)

Godot Engine is a free, open-source, cross-platform game engine for creating 2D and 3D games, with a community-driven development model under the MIT license.