SCRIPT LIBRARY · POWERSHELL
Clean Up Game File Names Without Losing the Tags That Matter
Strip dump flags like [!] and [b1], turn (U) into (USA), tidy revision tags and optionally add release years from a CSV, with a before-and-after list of every rename.
- What it does
- Renames game backups in the "Title (Region) (Rev x) [flags]" style so they read cleanly, removing dump flags and noise tags, normalizing regions and revisions, and optionally adding a year from a CSV. Returns the old and new name for every file.
- Requires
- Windows PowerShell 5.1 or PowerShell 7+
- No modules
- For years, a CSV with Title and Year columns that you put together
- Permissions
- Read and write access to the folders you rename in. No admin rights.
- Runs on
- Windows 10/11, macOS or Linux with PowerShell 7
- Tested
- Parse-checked and run in PowerShell 7.4 against folders of dummy files, with -WhatIf, for real, a year CSV, both region styles, title case, trailing articles and two files that would end up with the same name
Part 3 of the thread Keeping a media library tidy
A folder of game backups collects names from every era of dumping. Some files say (U) [!], some say (USA), one says (UE) (V1.1) [h1C], and a few have (En,Fr,De,Es,It) glued on the end. It all works, but it's hard to read in a frontend, and it makes duplicates hard to spot.
I had a rename script per system for my own library, each with its own list of titles and years pasted into the top of it. This is the one general-purpose version. It keeps the parts of a name that tell you something (title, region, revision, disc number) and drops the rest, and it can add a release year if you hand it a CSV.
Everything comes back as an old name and a new name, so you can run it with -WhatIf, read the list, and only then let it loose.
<#
.SYNOPSIS
Cleans up game file names: strips dump flags, normalizes region and revision tags, and optionally adds a year.
.DESCRIPTION
Works on names in the "Title (Region) (Rev x) [flags]" style used by No-Intro, Redump and GoodTools sets.
For each file it:
- removes square-bracket dump flags such as [!], [b1], [a2], [o1], [h1C], [f1] (keep them with -KeepFlags)
- removes round-bracket tags that match -StripTag, such as (Unl), (Alt 1) and language lists like (En,Fr,De)
- turns short region codes like (U), (E), (J), (UE), (US), (JP) into full names like (USA) and (Japan),
or the other way round with -RegionStyle Short
- tidies revision tags: (REV01) and (Rev 01) become (Rev 1), (V1.1) becomes (v1.1)
- optionally moves a trailing article ("Legend of Zelda, The") to the front, and title-cases the title
- optionally adds a release year looked up by title in a CSV you provide (columns: Title, Year)
Every file gets an object back with the old and new names, so you can pipe the lot to Export-Csv.
Supports -WhatIf. Case-only renames work on Windows too.
.PARAMETER Path
One or more folders (or single files). Defaults to the current folder.
.PARAMETER Extension
File extensions to consider. Default: every file.
.PARAMETER Recurse
Include subfolders.
.PARAMETER StripTag
Regular expressions for round-bracket tags to remove. Default: Unl, Unlicensed, Alt and language lists.
.PARAMETER KeepFlags
Keep square-bracket flags like [!] and [T+Eng] instead of removing them.
.PARAMETER RegionStyle
Full (default) writes (USA), (Europe), (Japan), (World). Short writes (US), (EU), (JP), (W).
.PARAMETER MoveArticle
Turn "Title, The" into "The Title" (also A and An).
.PARAMETER TitleCase
Title-case the game title. All-caps words like NBA or WWF are left alone.
.PARAMETER YearCsv
CSV with Title and Year columns. Matching ignores case and punctuation.
.PARAMETER YearStyle
Leading (default) writes "1991 - Title (USA).ext". Tag writes "Title (USA) (1991).ext".
.EXAMPLE
.\Rename-RomFile.ps1 -Path D:\Games\snes -WhatIf
.EXAMPLE
.\Rename-RomFile.ps1 -Path D:\Games\genesis -MoveArticle -YearCsv .\genesis-years.csv | Export-Csv renames.csv -NoTypeInformation
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('FullName')]
[string[]]$Path = '.',
[string[]]$Extension,
[switch]$Recurse,
[string[]]$StripTag = @('^Unl$', '^Unlicensed$', '^Alt( \d+)?$', '^(En|Ja|Fr|De|Es|It|Nl|Pt|Sv|No|Da|Fi|Zh|Ko|Pl|Ru)(,(En|Ja|Fr|De|Es|It|Nl|Pt|Sv|No|Da|Fi|Zh|Ko|Pl|Ru))*$'),
[switch]$KeepFlags,
[ValidateSet('Full', 'Short')][string]$RegionStyle = 'Full',
[switch]$MoveArticle,
[switch]$TitleCase,
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })][string]$YearCsv,
[ValidateSet('Leading', 'Tag')][string]$YearStyle = 'Leading'
)
begin {
$toFull = @{
'U' = 'USA'; 'US' = 'USA'; 'E' = 'Europe'; 'EU' = 'Europe'; 'J' = 'Japan'; 'JP' = 'Japan'; 'W' = 'World'; 'WW' = 'World'
'UE' = 'USA, Europe'; 'JU' = 'Japan, USA'; 'JUE' = 'World'; 'A' = 'Australia'; 'B' = 'Brazil'; 'K' = 'Korea'; 'F' = 'France'
'G' = 'Germany'; 'S' = 'Spain'; 'I' = 'Italy'; 'C' = 'China'; 'UK' = 'UK'
}
$toShort = @{ 'USA' = 'US'; 'Europe' = 'EU'; 'Japan' = 'JP'; 'World' = 'W'; 'Australia' = 'AU'; 'Brazil' = 'BR'; 'Korea' = 'KR'; 'France' = 'FR'; 'Germany' = 'DE'; 'Spain' = 'ES'; 'Italy' = 'IT'; 'China' = 'CN'; 'UK' = 'UK' }
$fullNames = @($toShort.Keys) + 'Asia', 'Canada', 'Hong Kong', 'Netherlands', 'Sweden', 'Taiwan', 'Russia', 'Scandinavia'
$extFilter = @($Extension | Where-Object { $_ } | ForEach-Object { if ($_ -like '.*') { $_.ToLower() } else { ".$_".ToLower() } })
$smallWords = 'a', 'an', 'and', 'at', 'but', 'by', 'for', 'in', 'of', 'on', 'or', 'the', 'to', 'vs', 'with'
function Get-MatchKey([string]$Text) { ($Text.ToLowerInvariant() -replace '&', 'and' -replace '[^a-z0-9]+', ' ').Trim() }
$years = @{}
if ($YearCsv) {
foreach ($row in Import-Csv -LiteralPath $YearCsv) {
if ($row.Title -and $row.Year -match '^\d{4}$') { $years[(Get-MatchKey $row.Title)] = $row.Year }
}
Write-Verbose "Loaded $($years.Count) titles from $YearCsv"
}
function Format-Region([string]$Tag) {
$parts = if ($toFull.ContainsKey($Tag)) { $toFull[$Tag] -split ',\s*' } else { $Tag -split '\s*,\s*' }
$parts = foreach ($p in $parts) { if ($toFull.ContainsKey($p)) { $toFull[$p] } else { $p } }
if (@($parts | Where-Object { $fullNames -contains $_ }).Count -ne @($parts).Count) { return $null }
if ($RegionStyle -eq 'Short') { $parts = foreach ($p in $parts) { if ($toShort.ContainsKey($p)) { $toShort[$p] } else { $p } } }
($parts | Select-Object -Unique) -join ', '
}
function Get-CleanName([string]$BaseName) {
$title = ($BaseName -replace '\s*[\(\[].*$', '').Trim()
$rest = $BaseName.Substring([Math]::Min($BaseName.Length, $BaseName.IndexOf($title) + $title.Length))
if (-not $title) { return $BaseName }
$tags = foreach ($m in [regex]::Matches($rest, '\(([^\)]+)\)|\[([^\]]+)\]')) {
if ($m.Groups[1].Success) {
$tag = $m.Groups[1].Value.Trim()
if (@($StripTag | Where-Object { $tag -match $_ }).Count) { continue }
if ($tag -match '^\d{4}$' -and $years.Count) { continue } # re-added from the CSV below
$region = Format-Region $tag
if ($region) { "($region)"; continue }
if ($tag -match '^rev\s*0*([0-9]+|[a-z])$') { "(Rev $($Matches[1].ToUpper()))"; continue }
if ($tag -match '^v\s*(\d+(\.\d+)*)$') { "(v$($Matches[1]))"; continue }
"($tag)"
}
elseif ($KeepFlags) { "[$($m.Groups[2].Value.Trim())]" }
}
if ($MoveArticle -and $title -match '^(?<main>.+?), (?<art>The|A|An)(?<tail>\s*-\s*.*)?$') {
$title = "$($Matches.art) $($Matches.main)$($Matches.tail)"
}
if ($TitleCase) {
$words = $title -split ' '
$title = (& {
for ($i = 0; $i -lt $words.Count; $i++) {
$w = $words[$i]
if ($w -cmatch '^[A-Z0-9\W]+$' -and $w -match '[A-Z]{2}') { $w; continue } # NBA, WWF, II
$lower = $w.ToLowerInvariant()
if ($i -gt 0 -and $smallWords -contains $lower -and $words[$i - 1] -ne '-') { $lower; continue }
if ($lower.Length) { $lower.Substring(0, 1).ToUpperInvariant() + $lower.Substring(1) } else { $w }
}
}) -join ' '
}
$year = $null
if ($years.Count) {
$year = $years[(Get-MatchKey $title)]
if (-not $year -and $title -match '^(The|A|An) (.+)$') { $year = $years[(Get-MatchKey "$($Matches[2]), $($Matches[1])")] }
if (-not $year -and $title -match '^(.+), (The|A|An)$') { $year = $years[(Get-MatchKey "$($Matches[2]) $($Matches[1])")] }
if ($title -match '^\d{4} - ') { $year = $null }
}
$name = (@($title) + @($tags | Select-Object -Unique)) -join ' '
if ($year -and $YearStyle -eq 'Leading') { $name = "$year - $name" }
elseif ($year) { $name = "$name ($year)" }
($name -replace '\s{2,}', ' ').Trim().TrimEnd('.')
}
}
process {
foreach ($item in $Path) {
$files = if (Test-Path -LiteralPath $item -PathType Container) {
Get-ChildItem -LiteralPath $item -File -Recurse:$Recurse | Where-Object { -not $extFilter -or $extFilter -contains $_.Extension.ToLower() }
}
elseif (Test-Path -LiteralPath $item -PathType Leaf) { Get-Item -LiteralPath $item }
else { Write-Warning "Not found: $item"; continue }
$planned = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($file in $files) {
$newName = (Get-CleanName $file.BaseName) + $file.Extension
$result = [ordered]@{ Folder = $file.DirectoryName; OldName = $file.Name; NewName = $newName; Status = 'Unchanged' }
if ($newName -ceq $file.Name) { [pscustomobject]$result; continue }
$target = Join-Path $file.DirectoryName $newName
$caseOnly = $newName -eq $file.Name
if (-not $planned.Add($target) -or (-not $caseOnly -and (Test-Path -LiteralPath $target))) {
$result.Status = 'SkippedExists'
Write-Warning "$($file.Name): '$newName' already exists (or another file would get that name). Left alone."
[pscustomobject]$result; continue
}
$result.Status = 'WhatIf'
if ($PSCmdlet.ShouldProcess($file.FullName, "Rename to $newName")) {
try {
if ($caseOnly) {
# Windows won't rename to a name that differs only by case, so go through a temporary name.
$temp = $file.Name + '.' + [guid]::NewGuid().ToString('N').Substring(0, 8) + '.tmp'
Rename-Item -LiteralPath $file.FullName -NewName $temp -ErrorAction Stop
Rename-Item -LiteralPath (Join-Path $file.DirectoryName $temp) -NewName $newName -ErrorAction Stop
}
else { Rename-Item -LiteralPath $file.FullName -NewName $newName -ErrorAction Stop }
$result.Status = 'Renamed'
}
catch {
$result.Status = "Failed: $($_.Exception.Message)"
Write-Warning "$($file.Name): $($_.Exception.Message)"
}
}
[pscustomobject]$result
}
}
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-Path | string[] | . | Folders or single files. Takes pipeline input. |
-Extension | string[] | every file | Only rename these extensions, so saves and artwork are left alone. |
-Recurse | switch | — | Include subfolders. |
-StripTag | string[] | Unl, Unlicensed, Alt, language lists | Regular expressions for round-bracket tags to remove. Pass your own list to keep (Unl) or drop more. |
-KeepFlags | switch | — | Keep square-bracket flags like [!] and [T+Eng]. |
-RegionStyle | string | Full | Full writes (USA), (Europe), (Japan). Short writes (US), (EU), (JP). |
-MoveArticle | switch | — | Turn "Title, The" into "The Title". |
-TitleCase | switch | — | Title-case the game title. All-caps words like NBA are left alone. |
-YearCsv | string | — | CSV with Title and Year columns. Matching ignores case and punctuation, and tries the title with and without a leading article. |
-YearStyle | string | Leading | Leading writes "1991 - Title (USA)". Tag writes "Title (USA) (1991)". |
Run it
Preview the cleanup for one system.
.\Rename-RomFile.ps1 -Path D:\Games\snes -WhatIfClean up, fix trailing articles and add years, then keep the list of what changed.
.\Rename-RomFile.ps1 -Path D:\Games\genesis -MoveArticle -YearCsv .\genesis-years.csv | Export-Csv renames.csv -NoTypeInformationShort region codes, and only strip Alt tags, so (Unl) and language lists stay.
.\Rename-RomFile.ps1 -Path D:\Games\nes -RegionStyle Short -StripTag '^Alt( \d+)?$'Only show the files that would change.
.\Rename-RomFile.ps1 -Path D:\Games -Recurse -WhatIf | Where-Object Status -ne UnchangedWhat you'll see
WARNING: Space Blaster (UE) (V1.1) [h1C].zip: 'Space Blaster (USA, Europe) (v1.1).zip' already exists (or another file would get that name). Left alone.
OldName NewName Status
------- ------- ------
Legend of Zelda, The - A Link to the Past (U) (REV01) [!].smc 1991 - The Legend of Zelda - A Link to the Past (USA) (Rev 1).smc Renamed
Puzzle Tower (Japan) (En,Ja) (Unl).zip Puzzle Tower (Japan).zip Renamed
Sonic the Hedgehog (U) [!].bin 1991 - Sonic the Hedgehog (USA).bin Renamed
Space Blaster (UE) (V1.1) [h1C].zip Space Blaster (USA, Europe) (v1.1).zip SkippedExists
Space Blaster (USA, Europe) (v1.1).zip Space Blaster (USA, Europe) (v1.1).zip Unchanged
How it works
- Split the name. The title is everything before the first bracket. The rest is read as round-bracket tags and square-bracket flags.
- Filter the tags. Square-bracket flags are dropped unless you pass
-KeepFlags. Round-bracket tags that match a-StripTagpattern are dropped too; by default that's (Unl), (Alt 1) and language lists like (En,Fr,De). - Normalize what's left. Region tags are mapped both ways: (U), (US) and (USA) all become (USA), or (US) with
-RegionStyle Short. (REV01) becomes (Rev 1), (V1.1) becomes (v1.1). Anything it doesn't recognize, like (Disc 2) or (Beta), is kept as-is. - Fix the title. With
-MoveArticle, "Legend of Zelda, The - A Link to the Past" becomes "The Legend of Zelda - A Link to the Past". With-TitleCase, small words stay lowercase and acronyms stay in capitals. - Add a year. If you gave it a CSV, the title is looked up with case and punctuation ignored, trying it with the article at the front and at the back. A match is added as a leading "1991 - " or a trailing (1991).
- Rename safely. A name that already exists, or that another file in the same run is about to take, is skipped with a warning. Case-only renames go through a temporary name, because Windows refuses to rename a file to itself in different capitals.
Take it further
- Deduplicate next. Once the names agree, Remove-DuplicateRom groups them far more reliably.
- Keep an undo file. Export the output to CSV. Swapping the columns and feeding it to
Rename-Itemin a loop puts every name back. - Borrow the approach for other archives. The same "keep the useful tags, drop the noise" idea is how I name and file a classic Mac archive.
Things that'll trip you up
- Some frontends match on the exact name. Scrapers and playlists that look games up by file name (or by No-Intro name) may stop finding artwork after a rename. Scrapers that hash the file don't care. Rename first, then scrape, or rebuild the playlists afterwards.
- The year CSV is yours to build. The script doesn't guess years. A two-column CSV exported from a spreadsheet or a game database dump works fine. Titles it can't find just don't get a year, and the rest of the cleanup still happens.
- Collisions are skipped, not overwritten. When two files clean up to the same name, like a hacked dump and the good one, the first keeps its spot and the second is left alone with a warning. That's usually a sign you have a duplicate to deal with.
- Stripping [!] loses information. The verified flag is handy when you're choosing between dumps. If you plan to deduplicate, do that before you rename, or use -KeepFlags.