Fix stale absolute paths baked inside Godot binary resources (.mesh / .material / .scn / .res / .tres) after a folder rename, migration, or Phase-3-style restructure. Use this skill whenever the user reports editor errors like "Cannot open file 'res://<old path>'", "Failed loading resource", "referenced non-existent resource at", or when they've just moved a folder and the editor console lights up red even though the files exist at the new location. Also use when they ask "how do I rename/mov...
Scanned 9/8/2026
Install to Claude Code
npx -y skills add infometa/workbuddyskills --skill godot-asset-path-surgery --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Godot Asset Path Surgery?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/infometa-godot-asset-path-surgery)More formats (shields.io, HTML) on the badges page.
---
name: godot-asset-path-surgery
description: Fix stale absolute paths baked inside Godot binary resources (.mesh / .material / .scn / .res / .tres) after a folder rename, migration, or Phase-3-style restructure. Use this skill whenever the user reports editor errors like "Cannot open file 'res://<old path>'", "Failed loading resource", "referenced non-existent resource at", or when they've just moved a folder and the editor console lights up red even though the files exist at the new location. Also use when they ask "how do I rename/move an asset folder in Godot without breaking references".
version: 1.0.0
---
# Godot Asset Path Surgery
> **When to invoke**: user is staring at red errors of the form
> `Cannot open file 'res://old/path/thing.material'` *while the file
> obviously exists at a new path*. This is almost always because a binary
> resource has the old path hard-coded in its payload (Godot bakes
> `ExtResource` paths into `.mesh` / `.material` / `.scn` / compressed
> `.tres` / `.res` at save time).
---
## Core insight
Godot resources carry **two kinds of path information**:
| Kind | Where | Update mechanism |
|---|---|---|
| **Discovery path** (how the editor finds the file) | `.godot/uid_cache.bin`, `<file>.uid` sidecar | Auto-updated by Godot when it sees the file move |
| **Internal ExtResource paths** (who this file references) | **Inside the binary payload** of the file | **Not auto-updated** — the file must be `load()` + `save()` again to rewrite them |
Phase-3-style folder migrations fix kind #1 for free but **silently break kind #2**. Symptom: editor reports broken refs; game often still runs because `load()` can still find the target via UID, but the error panel is polluted and hides real issues.
---
## Decision tree
```
Editor shows: "Cannot open file 'res://<old-path>'"
│
├─ Does the target file exist at the new path?
│ ├─ No → you actually deleted it. Restore it or fix the reference.
│ └─ Yes → go to next check
│
├─ Is the error coming from a *binary* resource (.mesh/.material/.scn/.res)?
│ ├─ No → it's a plain .tscn/.tres referencing the old path by text;
│ │ use sed/IDE search-replace on the text file and you're done.
│ └─ Yes → use the surgery pattern below (file's internal payload
│ carries the stale path as UTF-8 bytes).
│
├─ Does the affected binary ALSO depend on another resource that also moved?
│ ├─ No → Pattern A (simple resave).
│ └─ Yes → Pattern B (shim + reassign + resave), because ResourceLoader
│ refuses to open a file whose ExtResource target is missing.
```
---
## Pattern A — Simple resave (self-contained binary)
**When**: a `.mesh` / `.material` / `.scn` carries a stale *self-path* header
or a stale external path, but the target of that external path still exists
(just at a new location accessible by UID).
```gdscript
# tools/resave_<thing>.gd -- SceneTree-mode one-shot
extends SceneTree
const PATHS := [
"res://art/models/<new>/thing.mesh",
"res://art/models/<new>/thing.material",
]
func _initialize() -> void:
var failed := 0
for p in PATHS:
var res := ResourceLoader.load(p, "", ResourceLoader.CACHE_MODE_IGNORE)
if res == null:
push_error("[resave] load failed: %s" % p)
failed += 1
continue
# Force the in-memory resource_path to match the on-disk path so
# ResourceSaver doesn't keep the stale path baked into the header.
res.resource_path = p
# FLAG_COMPRESS preserves the original RSCC compression; without it
# binary resources (esp. .material) may bloat 3-5x.
var err := ResourceSaver.save(res, p, ResourceSaver.FLAG_COMPRESS)
if err != OK:
push_error("[resave] save failed (%d): %s" % [err, p])
failed += 1
continue
print("[resave] ok: ", p)
quit(0 if failed == 0 else 1)
```
Run:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless --quit \
--path . --script tools/resave_<thing>.gd
```
---
## Pattern B — Shim + reassign (binary with broken ExtResource)
**When**: the binary resource internally references an `ExtResource` whose
*path* is stale, and Godot refuses to open the binary ("Can't load
dependency"). Pattern A won't work because `ResourceLoader.load()` fails
before you can rewrite the resource.
**Strategy**: temporarily place the dependency's current content at the
*old* path so the loader resolves, retarget the reference to the *new*
path in memory, save, clean up the shim.
```gdscript
# tools/resave_<thing>.gd -- SceneTree-mode one-shot
extends SceneTree
const DEP_NEW := "res://art/models/<new>/material.material"
const DEP_OLD := "res://old/path/material.material"
const DEP_OLD_DIR := "res://old/path"
const BINARY := "res://art/models/<new>/mesh_with_dep.mesh"
func _initialize() -> void:
# 1. Pre-load the dep from its real path (the one we want baked in).
var dep := ResourceLoader.load(DEP_NEW, "", ResourceLoader.CACHE_MODE_REUSE)
if dep == null:
push_error("cannot load dep at %s" % DEP_NEW)
quit(1)
return
# 2. Shim: copy dep to the old path so the binary's stale ExtResource resolves.
var da := DirAccess.open("res://")
var old_dir_rel := DEP_OLD_DIR.replace("res://", "")
if not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(DEP_OLD_DIR)):
da.make_dir_recursive(old_dir_rel)
if da.copy(DEP_NEW, DEP_OLD) != OK:
push_error("temp copy failed")
quit(1)
return
# 3. Now the binary loads. Retarget its material(s) to the canonical
# instance whose resource_path is DEP_NEW, not the shim.
var mesh := ResourceLoader.load(BINARY, "", ResourceLoader.CACHE_MODE_IGNORE) as ArrayMesh
if mesh != null:
for i in mesh.get_surface_count():
mesh.surface_set_material(i, dep)
mesh.resource_path = BINARY
ResourceSaver.save(mesh, BINARY, ResourceSaver.FLAG_COMPRESS)
print("[resave] ok: ", BINARY)
# 4. Teardown: remove the shim + any dir tree we created.
da.remove(DEP_OLD)
_remove_empty_parents_up_to_res(DEP_OLD_DIR)
quit(0)
func _remove_empty_parents_up_to_res(path: String) -> void:
var da := DirAccess.open("res://")
var cur := path
while cur != "res://" and cur.begins_with("res://"):
var d := DirAccess.open(cur)
if d == null: break
d.list_dir_begin()
var has_child := false
while true:
var n := d.get_next()
if n == "": break
if n == "." or n == "..": continue
has_child = true; break
d.list_dir_end()
if has_child: break
if da.remove(cur) != OK: break
cur = cur.get_base_dir()
```
Adapt the `surface_set_material()` line to whatever API the binary uses
(e.g. for a PackedScene you'd iterate nodes; for a ShaderMaterial you'd
reassign `shader` or texture params).
---
## Post-surgery verification checklist
Run all three:
1. **No more stale paths in binaries**:
```bash
grep -rln '<old-path-fragment>' . --exclude-dir='.godot'
```
Expected: only your maintenance script (which records the constant).
2. **Headless load smoke test** — drop this into `tools/_smoke.gd` and
delete after:
```gdscript
extends SceneTree
func _initialize() -> void:
var ps := load("res://scenes/.../affected_scene.tscn") as PackedScene
if ps == null: push_error("FAIL"); quit(1); return
var inst := ps.instantiate()
print("OK children=", inst.get_child_count())
inst.queue_free()
quit(0)
```
Run:
```bash
/Applications/Godot.app/Contents/MacOS/Godot --headless \
--path . --script tools/_smoke.gd 2>&1 | grep -E 'ERROR|OK:'
```
Expected: no `ERROR` lines referencing old paths, one `OK children=N`.
3. **Editor visual check** — reopen the editor and watch the error panel.
For best results: first clear stale caches (see the
`godot-headless-verify` skill for the safe clean-cache recipe).
---
## Critical gotchas
### Length mismatch kills naive binary sed
```python
# DO NOT DO THIS — it corrupts the file
content = open(f, 'rb').read()
content = content.replace(b'level/textures/Material.material',
b'art/models/material.material') # NEW is longer
open(f, 'wb').write(content)
```
Godot binary resources encode strings as length-prefixed (Pascal-style).
If new path length ≠ old path length, the length prefix still says "51
chars" but the string is now 55 chars — subsequent fields shift, parser
explodes. **Always go through `ResourceLoader`/`ResourceSaver`**, never
touch raw bytes.
### Forgetting `FLAG_COMPRESS`
Default `ResourceSaver.save(res, path)` writes **uncompressed** (`RSRC`
magic). If the original was `RSCC` (compressed), you'll see 3-5x file
bloat. Always pass `ResourceSaver.FLAG_COMPRESS` unless you have a
reason not to.
### `CACHE_MODE_REUSE` vs `CACHE_MODE_IGNORE`
- **IGNORE**: Force a fresh load from disk. Use for the resource you're
about to mutate and save, so you don't accidentally mutate the editor's
cached instance.
- **REUSE**: Share with the editor cache. Use for *dependencies* that
you want other references to keep pointing at.
### `.uid` sidecar
Any new `.gd` file you drop in (like your one-shot `resave_*.gd`) needs a
`.uid` sidecar or the project-specific pre-commit hook will reject the
commit. Generate by running Godot once: `Godot --headless --import --path .`.
---
## Anti-patterns — don't do these
- ❌ **Binary sed**: length mismatch corrupts files (see above).
- ❌ **Duplicate assets at old + new paths**: tempting, but creates two
copies that diverge. Violates `docs/ASSET_GUIDELINES.md §7`.
- ❌ **Leaving the surgery script in place without telling anyone**:
mark it clearly as one-shot maintenance, add a header comment
explaining what bug it fixed (with commit SHA if possible), keep it
under `tools/` so `check_asset_structure.gd` accepts it.
- ❌ **Running the script without `--headless` first**: user may have the
editor open, which will race-save stale state back over your fix.
---
## When to NOT use this skill
- **.tscn / .tres text resources**: they're text, do a plain sed or IDE
global-replace.
- **User can just open-save in editor by hand**: if you're only dealing
with 1-2 files and the user is comfortable in the editor, point them
at "FileSystem dock → right-click → Reimport / Save". This skill is
for 5+ files or when the user wants a reproducible, scripted fix.
- **UID-based references**: Godot 4 uses `uid://...` identifiers that are
location-independent. If a `.tscn` references `uid://xyz` and the file
has moved, Godot resolves it via UID cache — no surgery needed, just
ensure `.godot/uid_cache.bin` is fresh (see `godot-headless-verify`).
---
## Reference: real fix done on TPS demo
Commit `7b94c7b` ("fix(art): rewrite core/*.mesh + Material_001.material
to drop stale Phase 3.6 paths"): after moving `level/textures/structure/Core/`
to `art/models/environment/arena/core/`, 5 binaries still baked the old
path. Script: [`tools/resave_core_meshes.gd`](../../../tools/resave_core_meshes.gd).
Result: 0 editor ERRORs + meshes shrunk 58% thanks to Godot 4.6's newer
compressor.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!