Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

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.

AT A GLANCERename-RomFile.ps1
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.

Rename-RomFile.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-Pathstring[].Folders or single files. Takes pipeline input.
-Extensionstring[]every fileOnly rename these extensions, so saves and artwork are left alone.
-Recurseswitch—Include subfolders.
-StripTagstring[]Unl, Unlicensed, Alt, language listsRegular expressions for round-bracket tags to remove. Pass your own list to keep (Unl) or drop more.
-KeepFlagsswitch—Keep square-bracket flags like [!] and [T+Eng].
-RegionStylestringFullFull writes (USA), (Europe), (Japan). Short writes (US), (EU), (JP).
-MoveArticleswitch—Turn "Title, The" into "The Title".
-TitleCaseswitch—Title-case the game title. All-caps words like NBA are left alone.
-YearCsvstring—CSV with Title and Year columns. Matching ignores case and punctuation, and tries the title with and without a leading article.
-YearStylestringLeadingLeading writes "1991 - Title (USA)". Tag writes "Title (USA) (1991)".

Run it

Preview the cleanup for one system.

.\Rename-RomFile.ps1 -Path D:\Games\snes -WhatIf

Clean 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 -NoTypeInformation

Short 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 Unchanged

What you'll see

Example outputvalues are illustrative
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

  1. Split the name. The title is everything before the first bracket. The rest is read as round-bracket tags and square-bracket flags.
  2. Filter the tags. Square-bracket flags are dropped unless you pass -KeepFlags. Round-bracket tags that match a -StripTag pattern are dropped too; by default that's (Unl), (Alt 1) and language lists like (En,Fr,De).
  3. 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.
  4. 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.
  5. 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).
  6. 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-Item in 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.