"""blend_remap_paths.py (2026-10-14) Run by Blender in the background, one .blend at a time: blender.exe -b --python blend_remap_paths.py -- Points every external path of the open file that lies under to the same place under : libraries, images, sounds, movie clips, fonts, external texts, volumes, cache files, and every file or folder path property of scenes (render output), modifiers, nodes and sequencer strips. A path that was relative stays relative (to the file's own, new location); an absolute one stays absolute. Data linked from another file is left alone (it is fixed in that file). Saves the file in place (Blender keeps its usual .blend1) and prints one line "REMAP {json}" for the caller. Written for the folder rename of 2026-10-14: D:\\SubnauticaCLONE became D:\\HADAL - The Descent, and some files still reached textures and linked parts through the old name (absolute paths, or relative ones that climb out and back in through it). """ import bpy, json, os, sys argv = sys.argv[sys.argv.index('--') + 1:] OLD = os.path.normpath(argv[0]) NEW = os.path.normpath(argv[1]) OLD_N = os.path.normcase(OLD) changes = [] def remap(p, lib=None): if not p or p.startswith('<'): return None try: a = os.path.normpath(bpy.path.abspath(p, library=lib)) except Exception: return None an = os.path.normcase(a) if not (an == OLD_N or an.startswith(OLD_N + os.sep)): return None a2 = NEW + a[len(OLD):] if p.startswith('//'): try: return bpy.path.relpath(a2) except ValueError: return a2 return a2 APPLY = True # first pass changes the paths, the second only counts what is still under the old root left = [] def fix(owner, attr, where, lib=None): p = getattr(owner, attr, None) if not isinstance(p, str): return q = remap(p, lib) if q is None or q == p: return if APPLY: setattr(owner, attr, q) changes.append({'where': where, 'from': p, 'to': q}) else: left.append(where) def fix_path_props(owner, where): # every string property that Blender marks as a file or folder path for prop in owner.bl_rna.properties: if prop.type == 'STRING' and prop.subtype in {'FILE_PATH', 'DIR_PATH'} and not prop.is_readonly: fix(owner, prop.identifier, f'{where}.{prop.identifier}') def walk(): for lib in bpy.data.libraries: fix(lib, 'filepath', f'library {lib.name}', lib.parent) for coll in ('images', 'sounds', 'movieclips', 'fonts', 'texts', 'volumes', 'cache_files'): for idb in getattr(bpy.data, coll, []): if idb.library is None: fix(idb, 'filepath', f'{coll} {idb.name}') for sc in bpy.data.scenes: if sc.library is not None: continue fix_path_props(sc.render, f'scene {sc.name} render') if sc.sequence_editor: for st in sc.sequence_editor.strips_all if hasattr(sc.sequence_editor, 'strips_all') else sc.sequence_editor.sequences_all: fix_path_props(st, f'scene {sc.name} strip {st.name}') for ob in bpy.data.objects: if ob.library is None: for md in ob.modifiers: fix_path_props(md, f'object {ob.name} modifier {md.name}') trees = [ng for ng in bpy.data.node_groups if ng.library is None] for coll in ('materials', 'worlds', 'scenes', 'lights', 'textures'): for idb in getattr(bpy.data, coll, []): nt = getattr(idb, 'node_tree', None) if idb.library is None and nt is not None: trees.append(nt) for nt in trees: for nd in nt.nodes: fix_path_props(nd, f'nodes {nt.name} {nd.name}') walk() APPLY = False walk() # what is still under the old root (should be nothing) saved = False if changes: bpy.ops.wm.save_mainfile() saved = True print('REMAP ' + json.dumps({'file': bpy.data.filepath, 'changed': len(changes), 'saved': saved, 'paths_left': len(left), 'left': left[:5], 'examples': changes[:5]}))