|
<# |
|
restructure-switch-for-romm.ps1 |
|
|
|
Builds RomM's multi-file Switch layout on the TrueNAS share (Z:) so updates |
|
and DLC associate with their base game. RomM treats a FOLDER-per-game as one |
|
game and tags special subfolders (update/ dlc/). It matches base .nsp in |
|
C:\Emulation\roms\switch to the extras in |
|
C:\Emulation\storage\ryujinx\patchesAndDlc by GAME NAME, and writes: |
|
|
|
Z:\emulation\roms\switch\<Game>\<base>.nsp |
|
Z:\emulation\roms\switch\<Game>\update\<update .nsp> |
|
Z:\emulation\roms\switch\<Game>\dlc\<dlc .nsp> |
|
|
|
Your device's C:\Emulation structure is NOT modified. Only Z: is written. |
|
It also removes the old FLAT *.nsp that the first copy left directly under |
|
Z:\...\switch (now superseded by the per-game folders). |
|
|
|
Classification: filename containing "DLC" -> dlc; otherwise -> update. |
|
Extras with no matching base game (e.g. Cuphead) are reported and skipped. |
|
|
|
Idempotent + resumable (robocopy /Z). ALWAYS preview first: |
|
powershell -ExecutionPolicy Bypass -File .\restructure-switch-for-romm.ps1 -DryRun |
|
Then apply: |
|
powershell -ExecutionPolicy Bypass -File .\restructure-switch-for-romm.ps1 |
|
#> |
|
|
|
param( |
|
[string]$Src = "C:\Emulation", |
|
[string]$Dst = "Z:\emulation", |
|
[switch]$DryRun |
|
) |
|
|
|
$ErrorActionPreference = "Stop" |
|
$PSNativeCommandUseErrorActionPreference = $false |
|
|
|
$swSrc = Join-Path $Src 'roms\switch' |
|
$swDst = Join-Path $Dst 'roms\switch' |
|
if (-not (Test-Path $swSrc)) { throw "Switch source not found: $swSrc" } |
|
|
|
# extras: known path, else recursive search for a patchesAndDlc folder under ryujinx |
|
$dlcSrc = Join-Path $Src 'storage\ryujinx\patchesAndDlc' |
|
if (-not (Test-Path $dlcSrc)) { |
|
$found = Get-ChildItem (Join-Path $Src 'storage\ryujinx') -Recurse -Directory -Filter 'patchesAndDlc' -ErrorAction SilentlyContinue | Select-Object -First 1 |
|
if ($found) { $dlcSrc = $found.FullName } |
|
} |
|
|
|
function Normalize-Name([string]$fileName) { |
|
$n = [IO.Path]::GetFileNameWithoutExtension($fileName) |
|
$n = $n -replace '\[[^\]]*\]', '' # drop [titleid] / [v..] / [DLC ..] |
|
$n = $n -replace '\([^\)]*\)', '' # drop (1.2.0) etc. |
|
$n = $n -replace '(?i)\bv\d+(\.\d+)*\b', '' # drop trailing v1.0.1 / v0 |
|
($n -replace '\s+', ' ').Trim() |
|
} |
|
|
|
$skip = '(?i)^(metadata\.txt|systeminfo\.txt|gamelist\.xml|desktop\.ini)$' |
|
|
|
# --- gather base games --- |
|
$bases = @{} |
|
Get-ChildItem -LiteralPath $swSrc -File -ErrorAction SilentlyContinue | |
|
Where-Object { $_.Extension -ieq '.nsp' -and $_.Name -notmatch $skip } | |
|
ForEach-Object { |
|
$key = Normalize-Name $_.Name |
|
$bases[$key] = [pscustomobject]@{ Name = $key; File = $_; Updates = @(); Dlc = @() } |
|
} |
|
|
|
# --- gather + match extras --- |
|
$unmatched = @() |
|
if ($dlcSrc -and (Test-Path $dlcSrc)) { |
|
Get-ChildItem -LiteralPath $dlcSrc -File -ErrorAction SilentlyContinue | |
|
Where-Object { $_.Extension -ieq '.nsp' } | |
|
ForEach-Object { |
|
$key = Normalize-Name $_.Name |
|
$isDlc = $_.Name -match '(?i)\bDLC\b' |
|
if ($bases.ContainsKey($key)) { |
|
if ($isDlc) { $bases[$key].Dlc += $_ } |
|
else { $bases[$key].Updates += $_ } |
|
} else { |
|
$unmatched += $_ |
|
} |
|
} |
|
} else { |
|
Write-Host "WARNING: patchesAndDlc folder not found under $Src\storage\ryujinx" -ForegroundColor Yellow |
|
} |
|
|
|
Write-Host "=== Switch restructure plan ($(if($DryRun){'DRY RUN'}else{'APPLY'})) ===" -ForegroundColor Cyan |
|
Write-Host " base src : $swSrc" |
|
Write-Host " extras : $dlcSrc" |
|
Write-Host " dest : $swDst" |
|
Write-Host "" |
|
|
|
function Copy-One($file, $dstDir) { |
|
# use FileInfo members (NOT Get-Item/Split-Path) so names with [ ] aren't treated as wildcards |
|
$a = @($file.DirectoryName, $dstDir, $file.Name, '/Z', '/R:3', '/W:5', '/NP', '/NJH', '/NJS') |
|
if ($DryRun) { $a += '/L' } |
|
& robocopy @a | Out-Null |
|
return $LASTEXITCODE |
|
} |
|
|
|
foreach ($b in ($bases.Values | Sort-Object Name)) { |
|
$gameDir = Join-Path $swDst $b.Name |
|
Write-Host ("* {0}" -f $b.Name) -ForegroundColor Green |
|
if (-not $DryRun) { $null = New-Item -ItemType Directory -Force -Path $gameDir } |
|
$dstBase = Join-Path $gameDir $b.File.Name # target inside the game folder |
|
$flatBase = Join-Path $swDst $b.File.Name # where the first bulk copy left it on Z: |
|
$srcLen = $b.File.Length |
|
# "complete" = exists AND byte size matches the source (guards against half-copied files) |
|
$dstOk = (Test-Path -LiteralPath $dstBase) -and ((Get-Item -LiteralPath $dstBase -ErrorAction SilentlyContinue).Length -eq $srcLen) |
|
$flatOk = (Test-Path -LiteralPath $flatBase) -and ((Get-Item -LiteralPath $flatBase -ErrorAction SilentlyContinue).Length -eq $srcLen) |
|
if ($dstOk) { |
|
Write-Host (" base : {0} (already complete in folder - skip)" -f $b.File.Name) |
|
} elseif ($flatOk) { |
|
# server-side rename within the same TrueNAS share -- instant, no network re-copy |
|
Write-Host (" base : {0} (MOVE existing copy on Z: into folder - no re-copy)" -f $b.File.Name) -ForegroundColor DarkGreen |
|
if (-not $DryRun) { Move-Item -LiteralPath $flatBase -Destination $dstBase -Force } |
|
} else { |
|
Write-Host (" base : {0} (copy from source - not on Z: or incomplete)" -f $b.File.Name) |
|
[void](Copy-One $b.File $gameDir) |
|
} |
|
|
|
foreach ($u in $b.Updates) { |
|
$sz = "{0:N0} MB" -f ($u.Length/1MB) |
|
Write-Host (" upd : {0} ({1})" -f $u.Name, $sz) |
|
[void](Copy-One $u (Join-Path $gameDir 'update')) |
|
} |
|
foreach ($d in $b.Dlc) { |
|
$sz = if ($d.Length -ge 1MB) { "{0:N0} MB" -f ($d.Length/1MB) } else { "{0:N0} KB" -f ($d.Length/1KB) } |
|
# tiny DLC nsp is usually a licence/unlock ticket (normal for e.g. BotW - content ships in the update), NOT broken. |
|
$warn = if ($d.Length -lt 1MB) { " <-- tiny (licence/unlock nsp; normal for BotW - needs the update applied)" } else { "" } |
|
Write-Host (" dlc : {0} ({1}){2}" -f $d.Name, $sz, $warn) -ForegroundColor $(if($warn){'Yellow'}else{'Gray'}) |
|
[void](Copy-One $d (Join-Path $gameDir 'dlc')) |
|
} |
|
Write-Host "" |
|
} |
|
|
|
if ($unmatched.Count) { |
|
Write-Host "--- extras with NO base game (skipped) ---" -ForegroundColor Yellow |
|
$unmatched | ForEach-Object { Write-Host (" {0}" -f $_.Name) } |
|
Write-Host " (add the matching base .nsp to roms\switch and re-run to include these)" |
|
Write-Host "" |
|
} |
|
|
|
# --- remove old flat base .nsp left directly under Z:\...\switch --- |
|
$flat = @(Get-ChildItem -LiteralPath $swDst -File -Filter *.nsp -ErrorAction SilentlyContinue) |
|
$subdirs = @(Get-ChildItem -LiteralPath $swDst -Directory -ErrorAction SilentlyContinue) |
|
if ($flat) { |
|
Write-Host "--- leftover flat *.nsp directly under $swDst ---" -ForegroundColor Yellow |
|
foreach ($f in $flat) { |
|
# only delete a flat file if the same file now exists inside a game folder (a true duplicate) |
|
$inFolder = $subdirs | ForEach-Object { Join-Path $_.FullName $f.Name } | Where-Object { Test-Path -LiteralPath $_ } |
|
if ($inFolder) { |
|
if ($DryRun) { Write-Host (" would remove (now in folder): {0}" -f $f.Name) } |
|
else { Remove-Item -LiteralPath $f.FullName -Force; Write-Host (" removed (now in folder): {0}" -f $f.Name) } |
|
} else { |
|
Write-Host (" kept (not placed in any folder): {0}" -f $f.Name) -ForegroundColor DarkYellow |
|
} |
|
} |
|
Write-Host "" |
|
} |
|
|
|
Write-Host ("Done ({0}). {1} base games, {2} extras matched, {3} unmatched." -f ` |
|
$(if($DryRun){'preview'}else{'applied'}), $bases.Count, |
|
(($bases.Values | ForEach-Object { $_.Updates.Count + $_.Dlc.Count }) | Measure-Object -Sum).Sum, |
|
$unmatched.Count) -ForegroundColor Cyan |
|
if ($DryRun) { Write-Host "Re-run without -DryRun to apply." -ForegroundColor Cyan } |