-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsortilege.py
More file actions
5264 lines (4662 loc) · 229 KB
/
Copy pathsortilege.py
File metadata and controls
5264 lines (4662 loc) · 229 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Sortilege
A single-file UEFN Python editor tool that scans a project's Content
Drawer, classifies every asset by type, and sorts assets into a standard
folder structure using reference-safe move APIs. Dry-run preview is the
default; a deliberate confirm is required before anything is changed.
Redirectors left behind by moves are auto-cleaned on a best-effort basis,
every run is logged, and an undo log lets you reverse a completed run.
Usage (from the UEFN Output Log, in Cmd mode -- not the Python console):
py "C:/path/to/sortilege.py" [preview|apply|undo|probe]
preview (default) - scan, classify, print a dry-run plan, write it to
a JSON file. Never changes anything.
apply - same as preview, then executes the plan if
I_UNDERSTAND_THIS_MODIFIES_MY_PROJECT is True
below (and, if available, a Yes/No dialog).
undo - reverse the moves recorded in the most recent
undo log (or a path given as the next argument).
probe - print a read-only capability + environment
report. Never changes anything.
Credit: mangoUEFN.
License: Apache 2.0 + Commons Clause.
"""
# =====================================================================
# === CONFIG - EDIT ME ===
# =====================================================================
# Everything in this section is safe to change by hand -- no coding
# knowledge required. Save the file and re-run the script after any
# change here.
CONFIG = {
# Which folder each category of asset gets sorted into, as a folder
# name under the sort root (see SORT_ROOT below). Change the name on
# the right to rename a destination folder; it is created if missing.
"FOLDER_MAP": {
"Meshes": "Meshes", "Materials": "Materials", "Textures": "Textures",
"Audio": "Audio", "Animations": "Animations", "Props": "Props",
"UI": "UI", "VFX": "VFX", "Other": "Other",
},
# Which category each engine asset type belongs to. Add a line here
# if you use an asset type that is not listed; unlisted types fall
# back to "Other" (or get skipped entirely if STRICT_MODE is True).
"CLASSIFICATION": {
"StaticMesh": "Meshes", "SkeletalMesh": "Meshes",
"Material": "Materials", "MaterialInstanceConstant": "Materials",
"MaterialFunction": "Materials", "MaterialParameterCollection": "Materials",
"Texture2D": "Textures", "TextureCube": "Textures", "TextureRenderTarget2D": "Textures",
"SoundWave": "Audio", "SoundCue": "Audio", "SoundClass": "Audio",
"SoundAttenuation": "Audio", "MetaSoundSource": "Audio",
"AnimSequence": "Animations", "AnimMontage": "Animations",
"AnimBlueprint": "Animations", "BlendSpace": "Animations",
"Skeleton": "Animations", "PhysicsAsset": "Animations",
"Blueprint": "Props", "LevelSequence": "Props",
"WidgetBlueprint": "UI", "Font": "UI", "FontFace": "UI",
"NiagaraSystem": "VFX", "NiagaraEmitter": "VFX", "ParticleSystem": "VFX",
},
# Bonus feature: rename files to match a prefix convention (SM_, M_,
# T_, ...) whenever ENABLE_PREFIX_RENAME below is True. Ignored
# otherwise. Add a line here to teach it a new class -> prefix.
"PREFIX_MAP": {
"StaticMesh": "SM_", "SkeletalMesh": "SK_", "Material": "M_",
"MaterialInstanceConstant": "MI_", "MaterialFunction": "MF_",
"Texture2D": "T_", "TextureCube": "TC_", "SoundWave": "S_",
"SoundCue": "SC_", "AnimSequence": "A_", "AnimMontage": "AM_",
"AnimBlueprint": "ABP_", "Blueprint": "BP_", "WidgetBlueprint": "WBP_",
"NiagaraSystem": "NS_", "Skeleton": "SKEL_", "PhysicsAsset": "PHYS_",
"LevelSequence": "LS_", "BlendSpace": "BS_",
},
# "" = sort straight into folders at the content root. Set this to a
# name like "_Organized" to nest all sorted folders under one parent
# instead, leaving whatever else you have at the root untouched.
"SORT_ROOT": "",
# False = flat per-type sorting (the default): every mesh to Meshes,
# every texture to Textures, and so on. True = keep each asset's KIT
# together instead: a prop's mesh, its materials, and their textures
# all live under one folder named after the prop, nested along the
# dependency chain (see README, "Keeping kits together"). Needs a
# dependency-lookup API; builds without it fall back to flat sorting
# automatically (a note is printed when that happens).
"GROUP_BY_ASSET": False,
# Which asset types can OWN a kit, in priority order: the first type
# in this list that matches wins, so a mesh referenced by a Blueprint
# belongs to the Blueprint's kit rather than starting its own.
"GROUP_ANCHOR_CLASSES": [
"Blueprint", "WidgetBlueprint", "NiagaraSystem", "SkeletalMesh",
"StaticMesh", "LevelSequence",
],
# Where things used by TWO OR MORE kits go (they belong to no single
# kit): a folder with this name at the sort root, with the usual
# per-type subfolders inside it.
"GROUP_SHARED_FOLDER": "Shared",
# [] = scan the whole project. Or list specific folders to limit the
# scan to, e.g. ["/YourProject/OldStuff"].
"SCOPE_FOLDERS": [],
# True = scope to whatever folder(s) are selected in the Content
# Browser when you run the script. Only works if your UEFN build
# supports reading the selection; falls back to SCOPE_FOLDERS above
# when it does not. Experimental -- see README.
"USE_SELECTION": False,
# Extra folders that should never be touched, on top of the built-in
# protections (Verse, levels/maps, __ExternalActors__, etc).
"EXCLUDE_FOLDERS": [],
# True = also rename files to match the PREFIX_MAP convention above.
"ENABLE_PREFIX_RENAME": False,
# True = try to clean up leftover redirectors after moving assets.
"CLEAN_REDIRECTORS": True,
# Safety net for the line above: True (recommended, default) = before
# actually deleting a redirector, ALSO double-check the asset
# registry's own get_referencers() (hard+soft references) when this
# build exposes it, on top of the existing find_package_referencers_
# for_asset check. Research/field report: find_package_referencers_
# for_asset does not reliably report every SOFT reference on every
# UEFN build, which let a still-soft-referenced redirector get
# deleted in a live sort (breaking that reference the instant the
# redirector was gone: "soft references a missing package"). When
# either check reports a referencer, either one raises, or get_
# referencers is simply unavailable on this build (no way to double-
# check at all), the redirector is KEPT instead of deleted -- left
# behind and reported in the run summary, but never a broken
# reference. Set to False to go back to the old single-check
# criterion (find_package_referencers_for_asset only).
"CONSERVATIVE_REDIRECTORS": True,
# True = double-check the result after applying (recommended).
"VERIFY_AFTER": True,
# True = after applying (or undoing), remove folders the moves left
# completely empty -- including parent folders that empty out once
# their children go. Folders still holding ANYTHING (assets,
# subfolders, leftover redirectors) are kept and listed in the run
# report. Set this to False to keep every folder exactly as it is.
"CLEAN_EMPTY_FOLDERS": True,
# True = skip asset types that are not listed in CLASSIFICATION
# instead of sending them to the "Other" folder.
"STRICT_MODE": False,
# "" = pick a log folder automatically (your project's Saved folder
# if it can be found, otherwise the folder this script lives in).
"LOG_DIR": "",
# True = show an interactive preview window (recommended) when you run
# this script with no argument (or "preview"). It shows the same scan
# results as the console, lets you tweak folder mappings live and
# re-scan, and gates apply behind an in-window checkbox instead of
# editing this file. Automatically falls back to the console-only
# flow below if this UEFN build's Python can't open the window for
# any reason. Set this to False (non-coders: change True to False
# above) to always use the console-only flow.
"USE_GUI": True,
# True (default) = draw the preview window in dark colors, matching
# UEFN's own dark editor theme. Set this to False for the plain
# system-default (light) look. Purely cosmetic: every control works
# identically either way, and on tk builds that reject any part of
# the styling the window simply keeps its default colors for that
# part instead of failing.
"DARK_MODE": True,
# Crash-diagnostics switch. True = an apply (or undo) only MOVES the
# assets -- every optional pass after that (soft-reference fixup,
# redirector cleanup, empty-folder sweep) and every collect_garbage()
# call are skipped. Use this to narrow down which stage of a
# crash-prone run is actually responsible: run once with this True,
# then flip the individual switches below back on one at a time.
# Verify still runs afterward (it only reads, never writes) unless
# VERIFY_AFTER above is also False. Leave this False for normal use.
"SAFE_MODE": False,
# Crash-diagnostics switch. True = every collect_garbage() call is
# skipped, independent of SAFE_MODE above -- use this to test whether
# garbage collection itself is the crash cause without also turning
# off the redirector/soft-reference/empty-folder passes. SAFE_MODE
# already skips these regardless of this setting. Leave this False
# for normal use.
"DISABLE_GC": False,
# True = best-effort fix-up of FSoftObjectPath references to moved
# assets after an apply (only actually does anything on builds that
# support it). Set to False to skip just this one pass without
# turning on full SAFE_MODE above (which forces it off regardless).
"FIX_SOFT_REFERENCES": True,
# True = after moving an asset your Verse code references by its
# folder-qualified name (Asset Reflection -- see the README's
# "Fixing Verse references" section), rewrite that reference in your
# real .verse source files so the qualified name matches the asset's
# new location. Off = leave every .verse file exactly as it is (the
# caution about updating Verse code by hand still applies). Python
# cannot compile Verse -- always run Build Verse Code in UEFN
# afterward to confirm your project still compiles.
"FIX_VERSE_REFERENCES": True,
# True = also rewrite a BARE root-level Verse reference (a plain name
# with no folder qualification at all, like "T_Hex" -- see the
# README's "Fixing Verse references" section). A bare name is a
# plain word with nothing to distinguish it from an unrelated
# identifier that merely happens to match, so it is always flagged
# "(bare name - review)" in the preview either way. Set this to
# False to SKIP bare-name rewrites entirely (they are listed in the
# preview/report as "skipped (bare name - fix manually)" instead, so
# you know to handle those by hand) while still rewriting every
# qualified (dotted) reference automatically -- the common,
# provably-safe case this feature exists for. Ignored when
# FIX_VERSE_REFERENCES above is False.
"FIX_VERSE_BARE_NAMES": True,
# "" = auto-detect this project's real directory from a scanned
# asset's on-disk path (unreal.SystemLibrary.get_system_path()) and
# look for .verse source files there. Deliberately NOT unreal.Paths.
# project_dir() by default -- in UEFN that call resolves to the
# Fortnite ENGINE directory, not your project, which used to make this
# feature silently scan the wrong folder and find zero real edits. Set
# this to a specific folder path (your project's folder, the one
# containing your .uefnproject) to skip auto-detection and search
# there instead.
"VERSE_SEARCH_DIR": "",
# The deliberate safety switch. Leave this False to only ever preview
# what would happen -- nothing is changed. Set it to True (and
# re-run) when you are ready to actually move things. See README.md
# for the full walkthrough, including the confirm dialog.
"I_UNDERSTAND_THIS_MODIFIES_MY_PROJECT": False,
}
# =====================================================================
# === IMPORTS ===
# =====================================================================
# `unreal` only exists inside UEFN's embedded Python. Wrapped so this
# file still imports cleanly (and CONFIG above is still usable) even
# when run outside the editor, e.g. by a plain syntax check.
try:
import unreal
except ImportError:
unreal = None
print("Sortilege: could not import 'unreal'. This script only runs "
"inside UEFN's embedded Python (enable Project Settings > "
"Plugins > Python Editor Script Plugin, then run it from the "
"Output Log in Cmd mode: py \"path/to/sortilege.py\").")
import datetime
import hashlib
import json
import os
import re
import shutil
import sys
# =====================================================================
# === PROTECTED ASSETS ===
# =====================================================================
# These are never touched, no matter what the config above says, and
# every skip is logged with the reason below.
PROTECTED_CATEGORIES = {
# "VerseClass" is a best-guess protected class name pending live-probe
# confirmation against a real UEFN build -- kept alongside the
# defensive "any class name containing 'Verse'" net in classify()
# below, so whatever the real engine's actual Verse-linked class
# name(s) turn out to be, they still get caught and protected.
"VerseClass": (
"Verse-linked asset (moving it can break Verse code references; "
"Verse source is not rewritten by asset-move APIs)"
),
"World": "Level/map (moving can break the island)",
"Level": "Level/map (moving can break the island)",
"ObjectRedirector": "redirector (handled by cleanup pass)",
}
# NEVER_MOVE: structural project assets that must NEVER be moved, no
# matter what -- checked FIRST inside classify(), before PROTECTED_
# CATEGORIES, the CLASSIFICATION table, grouping, or STRICT_MODE's
# "Other" fallback, in BOTH flat and group-by-asset sorting modes. A
# field-reported live UEFN apply moved the project's GameFeatureData
# asset and broke the project ("Project 'X' is broken because it's
# missing its GameFeatureData"), leaving broken references behind --
# this is the fix. Exact class names research-confirmed (Epic's own
# Python/API docs, 2026-07-23): UGameFeatureData -> "GameFeatureData"
# (a UPrimaryDataAsset subclass); UMapBuildDataRegistry ->
# "MapBuildDataRegistry" (a UObject subclass, one per level, holds its
# precomputed lighting/reflection data); AWorldDataLayers ->
# "WorldDataLayers" (unreal.WorldDataLayers is a real Python-exposed
# class) and UDataLayerAsset -> "DataLayerAsset" (World Partition's data
# layers). "World"/"Level" (UWorld/ULevel) were already protected above.
NEVER_MOVE_CLASSES = {
"World", "Level", "MapBuildDataRegistry", "GameFeatureData",
"LevelStreaming", "WorldDataLayers", "DataLayerAsset",
}
NEVER_MOVE_REASON = (
"structural project asset (GameFeatureData/Level/Verse) - moving it "
"breaks the project, kept in place"
)
# Case-insensitive substring net -- same defensive idea as the pre-
# existing "any class name containing 'Verse'" net below, extended to
# GameFeatureData/World-Partition: catches subclasses or build-specific
# variants whose exact class name could not be enumerated in advance (a
# future "UEFNGameFeatureDataOverride" or "VerseDevice"-style type).
# Deliberately does NOT include "level" as a bare substring -- that would
# wrongly catch LevelSequence, a normal, safe Props asset that must keep
# moving; only the exact "Level"/"LevelStreaming" names above are
# protected by name.
NEVER_MOVE_SUBSTRINGS = ("verse", "gamefeature", "worldpartition")
def _never_move_reason(class_name):
"""Return the NEVER_MOVE skip reason if `class_name` is a structural
project asset that must never be moved, else None. This is checked
FIRST inside classify() -- before PROTECTED_CATEGORIES, the
CLASSIFICATION table, and STRICT_MODE -- so both build_plan()'s
per-asset loop and _compute_group_plan()'s eligibility filter honor
it identically in flat and group-by-asset modes: an asset this
returns a reason for can never anchor a kit nor be pulled into
another kit's dependency closure, because _compute_group_plan()
excludes it from `eligible` up front, before any anchor/closure
computation runs."""
if class_name in NEVER_MOVE_CLASSES:
return NEVER_MOVE_REASON
lowered = class_name.lower()
for substr in NEVER_MOVE_SUBSTRINGS:
if substr in lowered:
return NEVER_MOVE_REASON
return None
# Path segments that mark a system/protected location (One-File-Per-Actor
# data and similar engine-managed folders); never reorganized.
_PROTECTED_PATH_MARKERS = ("__ExternalActors__", "__ExternalObjects__")
def is_protected_path(path):
"""True if `path` sits inside a system-managed folder that must never
be touched: __ExternalActors__/__ExternalObjects__ (OFPA data) or any
folder segment starting with a double underscore."""
for marker in _PROTECTED_PATH_MARKERS:
if marker in path:
return True
for segment in path.split("/"):
if segment.startswith("__"):
return True
return False
_ILLEGAL_NAME_CHARS = "\\/:*?\"<>|"
def validate_asset_name(name):
"""Return None if `name` is a safe asset name to rename to, else a
skip reason. Unreal's rename APIs throw validation errors on periods
and the usual filesystem-illegal characters; leading/trailing
whitespace is never an intentional name."""
if not name:
return "invalid target name"
if name != name.strip():
return "invalid target name"
if "." in name:
return "invalid target name"
for ch in name:
if ch in _ILLEGAL_NAME_CHARS:
return "invalid target name"
return None
# =====================================================================
# === CAPABILITY PROBE ===
# =====================================================================
# Different UEFN builds whitelist different slices of the Python API.
# The core set this tool assumes is always present: EditorAssetLibrary,
# AssetRegistryHelpers, AssetToolsHelpers. Everything else is probed once
# here with hasattr/getattr, wrapped in try/except, and gated at the call
# site -- never assumed.
class Capabilities:
"""Bag of booleans describing what this UEFN build's Python API
actually supports. Build one with probe_capabilities(); do not
construct by hand."""
def __init__(self):
self.editor_dialog = False
self.selected_folders = False
self.path_view_folders = False
self.scoped_slow_task = False
self.fix_up_redirectors = False
self.class_paths_filter = False
self.project_root_api = False
self.soft_path_rename = False
self.collect_garbage = False
self.dependency_query = False
self.referencer_query = False
def report(self):
"""Printable lines for probe mode and the summary log."""
names = (
"editor_dialog", "selected_folders", "path_view_folders",
"scoped_slow_task", "fix_up_redirectors", "class_paths_filter",
"project_root_api", "soft_path_rename", "collect_garbage",
"dependency_query", "referencer_query",
)
lines = ["Sortilege capability probe:"]
for name in names:
value = getattr(self, name, False)
lines.append(" %-20s %s" % (name, "yes" if value else "no"))
return lines
def probe_capabilities():
"""Probe the live (or mock) `unreal` module for every optional API
Sortilege can use, and return a Capabilities instance. Every single
check is wrapped in its own try/except so one odd build can't crash
the probe -- a failed check just reads as "not available"."""
caps = Capabilities()
if unreal is None:
return caps
try:
caps.editor_dialog = hasattr(unreal, "EditorDialog")
except Exception:
caps.editor_dialog = False
try:
caps.selected_folders = (
hasattr(unreal, "EditorUtilityLibrary")
and hasattr(unreal.EditorUtilityLibrary, "get_selected_folder_paths")
)
except Exception:
caps.selected_folders = False
try:
caps.path_view_folders = (
hasattr(unreal, "EditorUtilityLibrary")
and hasattr(unreal.EditorUtilityLibrary, "get_selected_path_view_folder_paths")
)
except Exception:
caps.path_view_folders = False
try:
caps.scoped_slow_task = hasattr(unreal, "ScopedSlowTask")
except Exception:
caps.scoped_slow_task = False
try:
tools = unreal.AssetToolsHelpers.get_asset_tools()
caps.fix_up_redirectors = hasattr(tools, "fix_up_redirectors")
except Exception:
caps.fix_up_redirectors = False
try:
caps.class_paths_filter = hasattr(unreal, "TopLevelAssetPath")
except Exception:
caps.class_paths_filter = False
try:
caps.project_root_api = hasattr(unreal.EditorAssetLibrary, "get_project_root_asset_directory")
except Exception:
caps.project_root_api = False
try:
tools = unreal.AssetToolsHelpers.get_asset_tools()
caps.soft_path_rename = hasattr(tools, "rename_referencing_soft_object_paths")
except Exception:
caps.soft_path_rename = False
try:
caps.collect_garbage = (
hasattr(unreal, "SystemLibrary")
and hasattr(unreal.SystemLibrary, "collect_garbage")
)
except Exception:
caps.collect_garbage = False
try:
registry = unreal.AssetRegistryHelpers.get_asset_registry()
caps.dependency_query = (
hasattr(registry, "get_dependencies")
and hasattr(unreal, "AssetRegistryDependencyOptions")
)
except Exception:
caps.dependency_query = False
try:
registry = unreal.AssetRegistryHelpers.get_asset_registry()
caps.referencer_query = (
hasattr(registry, "get_referencers")
and hasattr(unreal, "AssetRegistryDependencyOptions")
)
except Exception:
caps.referencer_query = False
return caps
# =====================================================================
# === SCAN ===
# =====================================================================
def discover_content_roots():
"""Return the top-level content mount(s) for this project, e.g.
["/MyProject"] in UEFN, or ["/Game"] for a .uproject-style project.
Never hardcode "/Game/" anywhere else in this file -- always call
this and use its result.
Primary (research-confirmed): unreal.EditorAssetLibrary.
get_project_root_asset_directory(). Falls back to scanning the
registry's top-level mounts when that API is unavailable."""
if unreal is None:
return []
lib = unreal.EditorAssetLibrary
if hasattr(lib, "get_project_root_asset_directory"):
try:
raw = lib.get_project_root_asset_directory()
root = "/" + str(raw).strip("/")
if root and root != "/":
return [root]
except Exception:
pass
try:
entries = lib.list_assets("/", recursive=False, include_folder=True)
except Exception:
entries = []
mounts = []
seen = set()
for entry in entries:
top = str(entry).rstrip("/")
if not top.startswith("/"):
continue
segments = top.split("/")
if len(segments) < 2 or not segments[1]:
continue
if segments[1] in ("Engine", "Script"):
continue
if segments[1].startswith("__"):
continue
mount = "/" + segments[1]
if mount not in seen:
seen.add(mount)
mounts.append(mount)
if "/Game" in mounts:
return ["/Game"]
return mounts
def scan_assets(scope_folders):
"""Scan the asset registry and return a flat list of dicts:
{"path", "name", "folder", "class_name"}. `scope_folders` limits the
scan to those folders; pass an empty list to scan every discovered
content root.
Deprecated-field trap (research-confirmed via UEFN-TOOLBELT):
AssetData.object_path and .asset_class are deprecated and can throw
inside current builds. object_path is never touched. class_name
comes from asset_class_path.asset_name first (its own try/except),
falling back to the legacy asset_class (its own try/except), else
the literal string "Unknown". Everything is str()'d defensively."""
if unreal is None:
return []
registry = unreal.AssetRegistryHelpers.get_asset_registry()
folders = list(scope_folders) if scope_folders else discover_content_roots()
results = []
seen_paths = set()
for folder in folders:
try:
asset_datas = registry.get_assets_by_path(folder, recursive=True)
except Exception:
asset_datas = []
for asset_data in asset_datas:
try:
path = str(asset_data.package_name)
except Exception:
continue
if not path or path in seen_paths:
continue
seen_paths.add(path)
try:
name = str(asset_data.asset_name)
except Exception:
name = path.rsplit("/", 1)[-1]
folder_of_asset = path.rsplit("/", 1)[0] if "/" in path else ""
try:
class_name = str(asset_data.asset_class_path.asset_name)
except Exception:
try:
class_name = str(asset_data.asset_class)
except Exception:
class_name = "Unknown"
results.append({
"path": path,
"name": name,
"folder": folder_of_asset,
"class_name": class_name,
})
return results
# =====================================================================
# === CLASSIFY ===
# =====================================================================
def classify(class_name, config):
"""Return (category, None) if `class_name` should be sorted, else
(None, skip_reason). Checks NEVER_MOVE first (structural project
assets -- GameFeatureData/Level/World/MapBuildDataRegistry/Verse --
see _never_move_reason()), then PROTECTED_CATEGORIES, then a
defensive "contains Verse" net, then the editable CLASSIFICATION
table, then STRICT_MODE vs. "Other". NEVER_MOVE is checked before
everything else, including STRICT_MODE, so it can never be bypassed
by any config combination."""
never_move = _never_move_reason(class_name)
if never_move:
return None, never_move
if class_name in PROTECTED_CATEGORIES:
return None, PROTECTED_CATEGORIES[class_name]
# Defensive net: PROTECTED_CATEGORIES's "VerseClass" entry is a
# best-guess pending live-probe confirmation of the real engine's
# actual Verse-linked class name(s). Any class name that merely
# CONTAINS "Verse" (case-insensitive) is treated the same protected
# way, so whatever the real name turns out to be, it is still caught.
if "verse" in class_name.lower():
return None, PROTECTED_CATEGORIES["VerseClass"]
classification = config.get("CLASSIFICATION", {})
if class_name in classification:
return classification[class_name], None
if config.get("STRICT_MODE", False):
return None, "unknown class (STRICT_MODE)"
return "Other", None
# =====================================================================
# === PLAN BUILDER ===
# =====================================================================
def _dest_folder(content_root, sort_root, folder_name):
parts = [content_root.rstrip("/")]
if sort_root:
parts.append(sort_root.strip("/"))
parts.append(folder_name)
return "/".join(parts)
def _is_excluded(path, exclude_folders):
for raw in exclude_folders:
excl = str(raw).rstrip("/")
if not excl:
continue
if path == excl or path.startswith(excl + "/"):
return True
return False
def _compute_new_name(name, class_name, config):
"""Apply the ENABLE_PREFIX_RENAME convention. Returns
(new_name, needs_rename). Only adds the correct prefix when it is
genuinely absent; if a *different* known prefix is present (e.g.
"T_Rock" on a StaticMesh), that wrong prefix is stripped first so the
result is "SM_Rock", not "SM_T_Rock"."""
if not config.get("ENABLE_PREFIX_RENAME", False):
return name, False
prefix_map = config.get("PREFIX_MAP", {})
correct_prefix = prefix_map.get(class_name)
if not correct_prefix:
return name, False
if name.startswith(correct_prefix):
return name, False
stripped = name
known_prefixes = sorted(set(prefix_map.values()), key=len, reverse=True)
for other_prefix in known_prefixes:
if other_prefix != correct_prefix and name.startswith(other_prefix):
stripped = name[len(other_prefix):]
break
new_name = correct_prefix + stripped
return new_name, (new_name != name)
# --- Group-by-asset (dependency clustering) ------------------------------
# Real-world grounding: imported props are KITS -- a mesh plus its
# material instance(s) plus their textures (e.g. SM_Alessio +
# MI_Bone_Alessio + T_Bone_Position/Rotation/Weights), times ~90 kits in a
# real project. Flat per-type sorting scatters every kit across /Meshes,
# /Materials, /Textures. When CONFIG["GROUP_BY_ASSET"] is on (and this
# build's registry exposes a dependency-query API -- caps.dependency_
# query), build_plan() keeps each kit together instead, chain-nested:
# a member's destination nests along its dependency path from the kit's
# anchor, each hop appending that node's type folder, consecutive
# same-type hops collapsed. Assets shared by 2+ kits go to a flat
# GROUP_SHARED_FOLDER (they have no single owning chain); assets in no
# kit sort flat exactly as before.
def _kit_name(name, class_name, prefix_map):
"""Kit folder name for an anchor: the anchor's asset name with its
own class's PREFIX_MAP prefix stripped when present (SM_Alessio ->
Alessio, BP_LuckyBlock -> LuckyBlock). Falls back to the full name
when there is no prefix to strip or stripping would leave nothing."""
prefix = prefix_map.get(class_name)
if prefix and name.startswith(prefix) and len(name) > len(prefix):
return name[len(prefix):]
return name
def _dependency_scan(anchor_path, eligible_paths, boundary_paths=None):
"""BFS the dependency graph out from `anchor_path`, restricted to
`eligible_paths` (scanned project assets that passed every skip rule
-- engine/script paths and protected/excluded assets are never
traversed or collected). Returns {member_path: [path nodes]} where
the node list is the member's dependency path from the anchor (first
hop first, ending with the member itself, anchor excluded).
`boundary_paths` are KIT BOUNDARIES: assets that belong to their own
kit (anchors of the same or higher priority class than this scan's
anchor -- see _compute_group_plan). Reaching one neither absorbs it
nor traverses through it; it and its subtree stay with its own kit.
Shortest path wins by construction (BFS), with ties broken by
discovery order over SORTED dependency lists -- deterministic run to
run. A visited set makes dependency cycles terminate; the anchor
itself can never become its own member. Every registry call is
fail-soft: an exception just means that node contributes no further
dependencies."""
if unreal is None:
return {}
try:
registry = unreal.AssetRegistryHelpers.get_asset_registry()
except Exception:
return {}
try:
options = unreal.AssetRegistryDependencyOptions(
include_hard_package_references=True,
include_soft_package_references=True)
except Exception:
try:
options = unreal.AssetRegistryDependencyOptions()
except Exception:
return {}
parents = {}
visited = set([anchor_path])
queue = [anchor_path]
while queue:
current = queue.pop(0)
try:
deps = registry.get_dependencies(current, options)
except Exception:
deps = []
for dep_path in sorted(str(d) for d in (deps or [])):
if dep_path in visited:
continue
visited.add(dep_path)
if dep_path not in eligible_paths:
# Not a movable scanned asset (engine path, protected,
# excluded, unscanned): never in a kit, never traversed
# THROUGH either -- its own dependencies belong to it.
continue
if boundary_paths is not None and dep_path in boundary_paths:
# Kit boundary: another anchor's territory. Not absorbed,
# not traversed through -- its subtree is its own kit's.
continue
parents[dep_path] = current
queue.append(dep_path)
members = {}
for member in parents:
nodes = []
cursor = member
while cursor != anchor_path:
nodes.append(cursor)
cursor = parents[cursor]
nodes.reverse()
members[member] = nodes
return members
def _compute_group_plan(assets, config, content_root_norm, all_roots_norm):
"""The grouping pass: decide a destination-folder override for every
asset that belongs to a kit. Returns (overrides, stats) where
`overrides` maps asset path -> dest folder (assets absent from it
sort flat, exactly as without grouping) and `stats` is {"kits": N,
"shared": M, "loose": K} for the preview header.
Skip rules are applied FIRST: an asset that is protected, excluded,
outside every content root, or of a protected class is not eligible
-- it never anchors a kit, never joins one, and keeps its normal skip
reason in the main build_plan() loop.
Anchors are chosen in GROUP_ANCHOR_CLASSES priority order, iterating
candidates in sorted(asset path) order (never scan order) so results
are deterministic run to run. An asset already claimed by a
higher-priority anchor's dependency closure can not anchor its own
kit (a StaticMesh referenced by a Blueprint is part of the
Blueprint's kit). Anchor-to-anchor edges at the SAME or HIGHER
priority are kit boundaries instead: BFS neither absorbs the other
anchor nor traverses through it -- it (and its subtree) belongs to
its own kit, so a Blueprint depending on another Blueprint yields
two real kits, identically regardless of scan order. An accepted
anchor's own destination is final; member routing can never
overwrite it. Members reachable from 2+ accepted anchors are shared:
they go to GROUP_SHARED_FOLDER/<type folder>, flat, since no single
chain owns them. Everything else in a closure nests chain-style: kit
root (anchor's type folder + kit name), then one type-folder segment
per node along the member's dependency path, consecutive duplicates
collapsed (a Material's MaterialFunction sits beside it, not in
Materials/Materials)."""
folder_map = config.get("FOLDER_MAP", {})
prefix_map = config.get("PREFIX_MAP", {})
sort_root = config.get("SORT_ROOT", "") or ""
exclude_folders = config.get("EXCLUDE_FOLDERS", [])
anchor_classes = config.get("GROUP_ANCHOR_CLASSES", []) or []
shared_folder = config.get("GROUP_SHARED_FOLDER", "Shared") or "Shared"
eligible = {}
for a in assets:
path = a["path"]
if is_protected_path(path):
continue
if _is_excluded(path, exclude_folders):
continue
in_any_root = any(
path == root or path.startswith(root + "/") for root in all_roots_norm
)
if not in_any_root:
continue
category, _reason = classify(a["class_name"], config)
if category is None:
continue
eligible[path] = {"asset": a, "category": category}
eligible_paths = set(eligible.keys())
# Priority index per anchor class: lower index = higher priority.
# First occurrence wins if a class is listed twice.
anchor_priority = {}
for index, cls in enumerate(anchor_classes):
if cls not in anchor_priority:
anchor_priority[cls] = index
claimed = set()
kits = []
for anchor_class in anchor_classes:
# Kit boundaries for this priority tier: every eligible asset of
# an anchor class at the SAME or HIGHER priority belongs to its
# own kit -- BFS must neither absorb it nor traverse through it.
# Lower-priority anchor classes are NOT boundaries: a Blueprint
# still absorbs its StaticMesh.
tier = anchor_priority[anchor_class]
boundaries = set(
p for p, info in eligible.items()
if info["asset"]["class_name"] in anchor_priority
and anchor_priority[info["asset"]["class_name"]] <= tier
)
# Candidates iterate in sorted(path) order, never scan order, so
# any remaining tie-break is deterministic run to run.
candidates = sorted(
p for p, info in eligible.items()
if info["asset"]["class_name"] == anchor_class
)
for path in candidates:
if path in claimed:
continue
members = _dependency_scan(
path, eligible_paths, boundary_paths=boundaries - set([path]))
members.pop(path, None)
claimed.add(path)
claimed.update(members.keys())
kits.append((path, members))
anchor_root_paths = set(anchor_path for anchor_path, _members in kits)
# Shared counting runs AFTER boundary handling, over the final
# closures only -- no phantom double-counts from an anchor absorbed
# into another kit; the stats below match the folders that actually
# materialize. Anchor roots can never count as members.
membership_counts = {}
for _anchor_path, members in kits:
for member in members:
if member in anchor_root_paths:
continue
membership_counts[member] = membership_counts.get(member, 0) + 1
shared_members = set(
m for m, count in membership_counts.items() if count > 1)
overrides = {}
# Pass 1: every accepted anchor's own destination -- FINAL.
kit_roots = {}
for anchor_path, _members in kits:
anchor_info = eligible[anchor_path]
kit_type_folder = folder_map.get(
anchor_info["category"], anchor_info["category"])
kit_name = _kit_name(
anchor_info["asset"]["name"], anchor_info["asset"]["class_name"],
prefix_map)
kit_root = (_dest_folder(content_root_norm, sort_root, kit_type_folder)
+ "/" + kit_name)
kit_roots[anchor_path] = kit_root
overrides[anchor_path] = kit_root
# Pass 2: member routing. An anchor root must never be overwritten --
# boundary handling above makes anchors unreachable as members, but
# the guard stays regardless (assert-style skip, not a crash).
for anchor_path, members in kits:
kit_root = kit_roots[anchor_path]
for member, nodes in members.items():
if member in anchor_root_paths:
continue
if member in shared_members:
continue
segments = []
for node in nodes:
node_folder = folder_map.get(
eligible[node]["category"], eligible[node]["category"])
if not segments or segments[-1] != node_folder:
segments.append(node_folder)
overrides[member] = kit_root + "/" + "/".join(segments)
for member in shared_members:
member_folder = folder_map.get(
eligible[member]["category"], eligible[member]["category"])
overrides[member] = (
_dest_folder(content_root_norm, sort_root, shared_folder)
+ "/" + member_folder)
stats = {
"kits": len(kits),
"shared": len(shared_members),
"loose": len(eligible) - len(claimed),
}
return overrides, stats
def build_plan(assets, config, caps):
"""Build the immutable plan dict from a list of scanned assets (the
shape scan_assets() returns). Reads the current content root via
discover_content_roots() and otherwise operates purely on `assets`
and `config`. Returns:
{"moves": [...], "skips": [...], "stats": {...},
"content_root": str, "sort_root": str, "timestamp": str}
When CONFIG["GROUP_BY_ASSET"] is on and the build supports dependency
queries (caps.dependency_query), a "grouping" key is added with the
kit statistics and destinations come from the grouping pass above --
every OTHER rule (skips, collisions, renames) applies unchanged."""
roots = discover_content_roots()
# roots[0] is the PRIMARY root -- used below for computing every
# destination folder, exactly as before. But discover_content_roots()
# can come back with MORE than one mount on its fallback path (no
# "/Game" among them), and an asset legitimately living under any of
# those other mounts is still project content, not something to skip
# as "outside project content" -- membership is checked against the
# union of every discovered root, not just the primary one.
content_root = roots[0] if roots else ""
all_roots_norm = [r.rstrip("/") for r in roots if r]
sort_root = config.get("SORT_ROOT", "") or ""
exclude_folders = config.get("EXCLUDE_FOLDERS", [])
folder_map = config.get("FOLDER_MAP", {})
moves = []
skips = []
planned_dest_paths = {}
existing_paths = set(a["path"] for a in assets)
content_root_norm = content_root.rstrip("/")
group_overrides = {}
grouping_stats = None
if config.get("GROUP_BY_ASSET", False):
if caps is not None and getattr(caps, "dependency_query", False):
group_overrides, grouping_stats = _compute_group_plan(
assets, config, content_root_norm, all_roots_norm)
else:
_console_warning(
"Sortilege: grouping unavailable in this build (no "
"dependency query API), using flat mapping.")
for a in assets:
path = a["path"]
name = a["name"]
folder = a["folder"]
class_name = a["class_name"]
if is_protected_path(path):
skips.append({"path": path, "class_name": class_name,
"reason": "protected system folder"})
continue
if _is_excluded(path, exclude_folders):
skips.append({"path": path, "class_name": class_name,
"reason": "excluded folder"})
continue