with one click
fix-up-redirectors
// Use when cleaning up Unreal ObjectRedirector assets in a P4-backed UE project. Do NOT use for non-P4 projects.
// Use when cleaning up Unreal ObjectRedirector assets in a P4-backed UE project. Do NOT use for non-P4 projects.
[HINT] Download the complete skill directory including SKILL.md and all related files
| name | fix-up-redirectors |
| author | christina |
| skill-type | technique-skill |
| description | Use when cleaning up Unreal ObjectRedirector assets in a P4-backed UE project. Do NOT use for non-P4 projects. |
| disable-model-invocation | false |
| argument-hint | [mode] [scope] -- mode: 'orphaned_safe' for orphans only; scope: e.g. /Game/Art (omit for whole project). With no args, lists common operations and asks. |
Unreal's editor command Fix Up Redirectors in Folder stalls on the first file someone else has checked out. This skill instead classifies every redirector by P4 safety, fixes only the safe ones in a fresh CL, and tells you who to ping for the rest.
Not every redirector needs the same treatment:
p4 delete the .uasset (and its .umap sibling, for level redirectors). Much faster than the fix-up path because there's no UE referencer load/save work.The classifier emits the fix-up safe set and (optionally) the orphaned safe set as separate JSON files. The apply script consumes either shape.
Redirectors are pointers, and pointers get referenced from places the on-disk asset graph cannot see. This skill's reference scan is heuristic by construction: it catches the cases that have been observed and encoded into the scanner, and quietly misses the rest. A clean scan is a hint that a fix is safe, not proof. Treat the residual risk as real even when every check passes -- the cost of an unfixed reference is a silent runtime failure (missing asset at load time, broken Blueprint pin, dangling soft ref) that often only shows up under specific gameplay conditions.
What the skill sees today:
.uasset files -- caught by phase-1 discovery via the UE asset registry. The redirector's own referencer list comes from here. Reliable for assets that import each other through standard UE serialization..uasset files -- also caught by phase-1 discovery (the asset registry tracks soft refs). Phase 4's rename_referencing_soft_object_paths handles the rewrite.bin/scan_code_references.py (engine in lib/code_refs.py). Default extensions: .cpp, .h, .hpp, .c, .cc, .cxx, .inl, .cs, .py, .ini, .uplugin, .uproject. Pattern: regex match for /Mount/...-shaped strings, narrowed by (a) mount must be a real .uproject/.uplugin//Engine mount discovered on disk, (b) the path must resolve to a real .uasset or .umap. The double filter is what gives the cache its signal-to-noise -- without it, false positives (test fixtures, /Script/... class paths, doc URLs, include paths) flood the cache..umap siblings -- the apply script's delete-only mode pairs a redirector .uasset with its .umap sibling so the depot never ends up with one half of the pair.What the skill does NOT see (each one is a real channel that has bitten projects):
.yaml, .json, .csv, .toml, .xml, and any project-specific data formats are ignored unless the user passes --extensions to include them. Any project that drives content from data tables or YAML configs has a coverage gap here.FString::Printf("/Game/Items/%s", ItemName) produces a path at runtime that no static scanner can match. The literal /Game/Items/ substring won't resolve to an asset on disk, so the scan ignores it.UCLASS() referenced as /Script/MyModule.UMyClass is a class path, not a content path -- and the scanner deliberately filters /Script/.... If a redirector is held alive only by a Blueprint that derives from a code class that itself names the asset by string, the chain is invisible to both phases..uasset files that the asset registry doesn't expose (custom serialization, third-party plugin formats). Phase 1 trusts the registry; anything outside the registry is a blind spot..csv config tables, perforce-only docs, automated test manifests, build scripts in shells other than the scanned set, anything generated at build time and not committed.--root (default cwd). A monorepo with sibling tools that reference game content from outside the project tree won't be covered.Because the residual risk is real, the apply path is structured as a series of progressively wider commits, not one big purge:
pick_one_per_dir.py to reduce the safe set to one redirector per package directory. This exercises every directory shape in the safe set (which is where most "this folder has unusual referencers" surprises live), with a CL small enough to revert in one command. In a reference run, 2839 fix-up redirectors collapsed to a 241-redirector subset; 68 orphaned redirectors collapsed to 18.The reason this works: a missed reference channel that breaks N assets in the test slice is cheap to revert (one CL, scoped to ~1% of the directories). The same channel breaking N assets in the full purge is expensive to revert (thousands of files, possibly across many directories whose referencers also got rewritten). Sampling concentrates the blast where reverts are still cheap.
The recommended workflow:
classify -> code-ref filter -> directory-sample -> apply test CL
-> soak (smoke playtest, tests, visual diff) -> apply full purge
The fix-up safe set and the orphaned safe set both pass through this pipeline; only the per-direction details differ (orphan path skips the code-ref filter because there are no referencers).
When a regression escapes the heuristic -- a missing-asset error, a broken Blueprint, a dangling soft ref after a clean fix-up run -- the playbook is:
DEFAULT_EXTENSIONS (e.g. a .yaml config, .csv data table)? A dynamic path construction? A redirect chain longer than one hop? An asset format outside the registry?lib/code_refs.py:
DEFAULT_EXTENSIONS (or document that the user must pass --extensions for that channel)._PATH_RE or add a parallel matcher; keep the mount + on-disk filter so signal-to-noise stays high.discover_mount_points../.local-data/code_references.yaml and the filter reuses it for 24 hours by default. After extending coverage, either delete the cache file or pass --max-age-hours 0 to filter_safe_by_code_refs.py so the next run regenerates it. Without this, the filter still reads the pre-fix scan and the regression repeats.bin/filter_safe_by_code_refs.py) and confirm the previously-missed reference now drops the affected redirector(s) from the safe set.The skill's value is the explicit map of what's covered and what isn't. Every regression that prompts a coverage extension should also prompt an edit to this section so future Claude knows whether the channel is in scope before promising a clean fix.
Fix Up Redirectors in Folder keeps failing on locked filesp4)ue-runner availabletmp/redirectors/ in cwd)The skill takes up to two positional args: an optional mode keyword and an optional scope (a UE content path like /Game/Art). Either, both, or neither may be present.
| Invocation | Mode | Scope | Behavior |
|---|---|---|---|
/fix-up-redirectors | (none -- show menu) | -- | Print the "Common operations" menu below and ask which the user wants. Do NOT start any phase. |
/fix-up-redirectors /Game/Art | full (default) | /Game/Art | Run all phases: discover, classify, report, code-ref filter, apply both fix-up safe set and (if user opts in at Phase 3) orphaned safe set. |
/fix-up-redirectors orphaned_safe | orphan-only | /Game | Skip the fix-up path entirely. Discover, classify, report orphan counts, then Phase 4 against orphaned.json (the apply script auto-detects delete-only mode from the input shape). Skip Phase 3.5 (orphans have no referencers, so source-code references can't apply). |
/fix-up-redirectors orphaned_safe /Game/Art | orphan-only | /Game/Art | Same as orphan-only, but scoped. |
Anything that isn't the literal string orphaned_safe is treated as a scope. The mode keyword, if present, must come first.
Future mode keywords (e.g. fix_up_safe to skip the orphan path, referenced_broken to dump a manual-cleanup report) plug into this same table -- add a row, branch the affected phases.
When the user types /fix-up-redirectors with no args, print exactly this menu and ask which they want -- do NOT start any phase yet:
Fix Up Redirectors -- common operations:
/fix-up-redirectors
Show this menu.
/fix-up-redirectors orphaned_safe
Delete the "orphaned safe" redirectors (target gone, zero referencers,
not checked out by anyone). Pure p4 deletes -- no referencer rewrites,
no code-ref filter. Cheap and routine; consider running every couple
of weeks.
/fix-up-redirectors /Game/SomePath
Full pipeline scoped to a sub-path: classify every redirector under
/Game/SomePath, run the code-ref filter, apply fix-ups for the safe
set, optionally delete orphans. Use this for content hygiene after a
rename/move pass in a specific area.
/fix-up-redirectors
(with no scope, after picking a mode)
Same as above but over all of /Game. Recommended only when the safe
set is small or after a successful directory-sampled test slice.
/fix-up-redirectors orphaned_safe /Game/SomePath
Orphan deletes scoped to a sub-path.
Which would you like to run?
Pick the matching mode + scope from the user's reply and re-enter the skill at Phase 1.
The full pipeline has six phases (Discover, Classify, Report, Code-ref filter, Apply, Final report). Track progress in a TodoWrite list. Per-mode skips:
--mode=delete-only), Final report.For any safe set with more than ~100 redirectors, run the per-directory subset reducer first and apply that smaller set as a test pass. The reducer picks exactly one redirector per unique package directory, deterministically.
Why this works: most "this might break something" scenarios are directory-shaped (a particular folder has unusual referencers, soft refs, or naming quirks). A one-per-directory slice exercises every directory shape without committing to a multi-thousand-file edit. In a reference run, 2839 safe redirectors collapsed to a 241-redirector subset that ran in ~19 minutes vs. multi-hour for the full purge — and surfaced any breakage early, when it could still be reverted cheaply.
Use the reducer on either the fix-up safe set or the orphaned safe set; the input/output JSON shape is the same.
~/.claude/plugins/data/plugins-kit/unreal-kit/.venv/Scripts/python.exe \
"${CLAUDE_PLUGIN_ROOT}/skills/fix-up-redirectors/bin/pick_one_per_dir.py" \
--in tmp/redirectors/safe_filtered.json \
--out tmp/redirectors/safe_per_dir.json
Then point Phase 4 at safe_per_dir.json instead. After the test CL submits cleanly, run Phase 4 again on the original safe set (with the per-dir entries removed if you want a strict residual, or just re-run the whole thing — already-fixed redirectors are no-ops in the second pass).
Run discovery via the plugin's ue-runner. Pass scope via SCOPE env var.
mkdir -p tmp/redirectors
MSYS_NO_PATHCONV=1 SCOPE="${1:-/Game}" \
"${CLAUDE_PLUGIN_ROOT}/skills/ue-python-api/bin/ue-runner.cmd" \
"${CLAUDE_PLUGIN_ROOT}/skills/fix-up-redirectors/bin/discover_redirectors.py" \
--copy-output tmp/redirectors/
MSYS_NO_PATHCONV=1is required on Windows Git Bash so it doesn't translate/Game/...into a Windows path before Python sees it.
The discovery YAML lands at tmp/redirectors/redirectors_discovery.yaml. It contains, per redirector: package name, on-disk file, target package, target-exists flag, all referencer files (hard + soft), and a flag for level referencers.
The classifier is a host-side script (no Unreal needed). Run it from the project root so p4 picks up the right P4USER/P4CLIENT from .p4config.txt:
~/.claude/plugins/data/plugins-kit/unreal-kit/.venv/Scripts/python.exe \
"${CLAUDE_PLUGIN_ROOT}/skills/fix-up-redirectors/bin/classify_safety.py" \
--discovery tmp/redirectors/redirectors_discovery.yaml \
--out-safe tmp/redirectors/safe.json \
--out-orphaned tmp/redirectors/orphaned.json \
--out-report tmp/redirectors/report.json
The host-side classifier needs pyyaml, which the bootstrap engine installs into the unreal-kit plugin's venv at ~/.claude/plugins/data/plugins-kit/unreal-kit/.venv/. Invoke that venv's Python directly -- the path is stable across plugin versions and resolves the right interpreter regardless of cwd. Do NOT use uv run python unless the cwd has a matching pyproject.toml listing pyyaml; from a project root that doesn't (the common case for this skill, since you run from your Unreal project's root for p4 to pick up .p4config.txt), uv falls back to a basic Python without pyyaml and the script crashes with ModuleNotFoundError. On macOS/Linux the venv path is ~/.claude/plugins/data/plugins-kit/unreal-kit/.venv/bin/python.
The classifier runs p4 opened -a once for the workspace, then buckets each redirector:
safe — fix-up safe set: target exists, neither the redirector nor any of its referencers is opened by anyone (levels are included)blocked — at least one file is opened by a teammate (or you, in another CL); records the user(s)broken — the redirector's target asset is missing. Sub-buckets:
orphaned_safe — target gone AND zero referencers AND the redirector .uasset itself is unlocked. Safe to p4 delete directly. Emitted to --out-orphaned if provided.orphaned_blocked — orphaned but the redirector itself is checked out by someone. Re-run later.referenced_broken — target gone but referencers exist. Manual cleanup needed; the skill never touches these.non_writable — at least one referencer file isn't in the local workspace mapping (plugin content we can't edit)The report also tracks how many safe redirectors touch a .umap referencer, just for visibility.
--out-orphaned is optional. Skip it if you only care about fix-up redirectors; pass it whenever you want to also clean up orphans (recommended for routine hygiene runs).
Phase 2 does NOT consult the code-references cache. That happens lazily in Phase 4 prep below, so we don't pay for a full source scan unless the user actually decides to apply.
Read tmp/redirectors/report.json and print this exact summary:
Scanning <scope>... <total> redirectors found.
<N> safe to fix (<M> touch levels)
<N> blocked by P4 checkouts:
@<user1> <count> (CL <#>, CL <#>)
@<user2> <count> (default CL)
...
<N> broken (target missing):
<N> orphaned (zero referencers, safe to delete)
<N> orphaned but checked out (re-run later)
<N> referenced-broken (manual cleanup needed)
<N> in non-writable mounts (plugin content; skipped)
Then ask, depending on mode:
orphaned_safe).Do NOT proceed without explicit yes.
Skip this phase entirely in orphan-only mode. Orphans have zero referencers by definition, including zero source-code referencers, so there's nothing for the filter to drop. Running it would just regenerate the cache for no benefit.
A redirector that's still referenced from C++/C#/Python source must NOT be fixed - the code would silently start pointing at a missing asset. We treat code references the same way we treat P4 checkouts: a hard block.
The cache lives at ./.local-data/code_references.yaml (per-project, not checked in). It's only required when applying. The filter script regenerates it transparently if it's missing or older than 24 hours; otherwise it reuses the cached scan:
~/.claude/plugins/data/plugins-kit/unreal-kit/.venv/Scripts/python.exe \
"${CLAUDE_PLUGIN_ROOT}/skills/fix-up-redirectors/bin/filter_safe_by_code_refs.py" \
--safe-in tmp/redirectors/safe.json \
--safe-out tmp/redirectors/safe_filtered.json \
--report-out tmp/redirectors/code_refs_report.json \
--max-age-hours 24
The scan walks the cwd by default. Override with --root <path> if your code lives elsewhere. Default extensions cover C/C++/C#/Python/INI plus .uproject/.uplugin; override with --extensions (comma-separated) to include configs (.yaml, .json) if your project encodes asset paths in data.
If any redirectors get dropped here, re-print an updated count to the user before proceeding:
Code-ref filter: <kept>/<total> remain after dropping <dropped> referenced from source.
To force a fresh scan ahead of time (e.g. you just renamed a bunch of assets in source):
~/.claude/plugins/data/plugins-kit/unreal-kit/.venv/Scripts/python.exe \
"${CLAUDE_PLUGIN_ROOT}/skills/fix-up-redirectors/bin/scan_code_references.py"
Important: SAFE_JSON must be an absolute path. UE commandlets run from a different cwd than the user's shell (typically <project>/Binaries/Win64), and a relative SAFE_JSON path silently misses the file. The apply script normalizes whatever it gets to absolute via os.path.abspath, so passing a relative path usually works — but pass an absolute path explicitly when scripting from CI or any setting where the cwd is unclear.
For the fix-up safe set, use safe_filtered.json from Phase 3.5, NOT the raw safe.json:
SAFE_JSON="$PWD/tmp/redirectors/safe_filtered.json" \
"${CLAUDE_PLUGIN_ROOT}/skills/ue-python-api/bin/ue-runner.cmd" \
"${CLAUDE_PLUGIN_ROOT}/skills/fix-up-redirectors/bin/apply_fixups.py"
For the orphaned safe set (delete-only), point SAFE_JSON at orphaned.json from Phase 2. The script auto-detects the input shape and switches to delete-only mode (no referencer load/save, no code-ref filter required because orphans have no referencers):
SAFE_JSON="$PWD/tmp/redirectors/orphaned.json" \
"${CLAUDE_PLUGIN_ROOT}/skills/ue-python-api/bin/ue-runner.cmd" \
"${CLAUDE_PLUGIN_ROOT}/skills/fix-up-redirectors/bin/apply_fixups.py"
To prepend a project-specific CL tag (e.g. for naming conventions like [Mix, Tool]), pass it via env:
CL_DESC_SUFFIX="[Mix, Tool]" SAFE_JSON="$PWD/tmp/redirectors/safe_filtered.json" \
"${CLAUDE_PLUGIN_ROOT}/skills/ue-python-api/bin/ue-runner.cmd" \
"${CLAUDE_PLUGIN_ROOT}/skills/fix-up-redirectors/bin/apply_fixups.py"
The apply script does (fix-up mode):
Fix up redirectors: <N> assets in <scope> (plus CL_DESC_SUFFIX if set).p4 edit -c <CL> every non-redirector referencer file.rename_referencing_soft_object_paths, force-save each package.EditorAssetLibrary.delete_asset on each redirector to release UE's file handle, then GC, then p4 reopen -c <CL> to herd UE-auto-opened deletes into our pending CL (with p4 delete -c <CL> as fallback for any not auto-opened).<project>/Saved/PythonOutput/redirectors_apply_<CL>.yaml.In delete-only mode: skips steps 2-4 (no referencer load/save needed), opens redirector .uasset files for delete in the new CL, and automatically includes the .umap sibling of every redirector that has one — level redirectors come in .uasset+.umap pairs and both files must land in the CL together.
If the apply script reports lock failures (UE held Windows handles even after delete_asset returned), it writes a retry list to <project>/Saved/PythonOutput/redirectors_lock_retry_<CL>.txt. After the commandlet exits (UE's process is gone, file handles released), run:
p4 -x - reconcile -c <CL> < <project>/Saved/PythonOutput/redirectors_lock_retry_<CL>.txt
reconcile notices the locally-deleted files and opens them for delete in the same CL. The script prints the exact command at the end of its run.
After phase 4, print:
Fixed N redirectors in CL <#>.
Skipped M (blocked by checkouts) - re-run later or ping the users above.
If there are blocked redirectors, suggest: "Tell the blocked users to run /fix-up-redirectors themselves to handle their slice."
orphaned_samples / broken_samples in report.json) and let the user investigate.p4 opened -c <CL> doesn't match the expected set, abort and tell the user. Do NOT call fixup_referencers against an unverified CL.Fix up redirectors:, refuse phase 4 and ask the user to either submit/revert that one first, or pass --force-new-cl.p4 delete: UE has been observed to keep Windows file handles open on referencer packages even after delete_asset and a GC pass. The apply script catches the OS-level lock errors -- the Windows P4 client emits the message as "being used by another process" (note: not the older "in use by another process"), and "access is denied" is the generic fallback -- per file, writes the affected paths to redirectors_lock_retry_<CL>.txt, and prints the exact p4 -x - delete -c <CL> command to run after the commandlet exits. Empirically the locked files are still on disk when the commandlet exits (UE held the handle long enough that the disk-delete never finished), so the retry uses p4 delete (which marks the depot delete and removes the local file in one step), NOT p4 reconcile (which would see an unchanged local file and do nothing). If a future UE version actually finishes the on-disk delete before exiting, p4 delete will error per-file with "file not found" -- in that case fall back to p4 -x - reconcile -c <CL> < <list> against the same list..umap): in both fix-up and delete-only modes the script automatically includes the .umap sibling of every .uasset redirector it deletes (the pairing logic runs before UE work and adds any .umap that exists on disk to redirector_files). Two shapes need this: normal level redirectors with both .uasset+.umap on disk (both must land in the apply CL or the depot keeps a dangling half), and .umap-native packages where discovery (UE asset registry) reports a .uasset path but only the .umap exists on disk (UE deletes the .umap and auto-opens it for delete in default CL — without pairing, the .umap is stranded outside the apply CL).fixup_referencers without first verifying the CL's opened set matches what discovery promised. A surprise file in the CL means the world moved between discovery and apply.safe.json directly into Phase 4. (Orphan runs skip the filter — orphans have no referencers, including no source-code referencers.)pick_one_per_dir.py) for the test pass — it cuts apply time by 10x+ and surfaces breakage early when revert is still cheap.SAFE_JSON path from CI. The apply script normalizes to absolute via os.path.abspath, but normalization happens in the commandlet's cwd (typically <project>/Binaries/Win64), not yours. Always pass an absolute path explicitly when scripting.The skill follows a facade-over-libs structure:
bin/ are thin facades that orchestrate one phase each
discover_redirectors.py — Phase 1classify_safety.py — Phase 2 (emits fix-up safe set + optional orphaned safe set + report)filter_safe_by_code_refs.py / scan_code_references.py — Phase 3.5pick_one_per_dir.py — per-directory subset reducer (works on either safe-set shape)apply_fixups.py — Phase 4 (fix-up mode and delete-only mode; auto-detected from the input shape)lib/p4cli.py — host-side P4 CLI (find, run, parse opened, where mapping)lib/package_paths.py — UE-side mount-point map and package -> on-disk pathlib/redirector_record.py — YAML/JSON I/O for the discovery and safe-set files (load_safe_set / save_safe_set are reused by pick_one_per_dir.py)lib/code_refs.py — host-side source scanner + cache I/O for ./.local-data/code_references.yaml (24h freshness)The libs are also useful for one-off redirector-related scripts. Import them directly:
import sys, os
sys.path.insert(0, os.path.join(os.environ['CLAUDE_PLUGIN_ROOT'], 'skills', 'fix-up-redirectors', 'lib'))
from p4cli import get_opened_map, run_p4