9bb4f85802
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
141 lines
7.7 KiB
PowerShell
141 lines
7.7 KiB
PowerShell
<#
|
|
One-off migration: old install layout -> current layout.
|
|
|
|
old: <library>\installs\<Name>-<4 hex>\game\ChilloutVR.exe
|
|
new: <library>\installs\<Name>\ChilloutVR.exe
|
|
|
|
Also registers install folders that exist on disk but are missing from config.json
|
|
(name taken from the folder, branch from the game's appmanifest BetaKey).
|
|
|
|
Dry run by default. Nothing is changed unless -Apply is given. Run with pwsh.
|
|
pwsh -File 2026-10-05-flatten-install-layout.ps1 # show the plan
|
|
pwsh -File 2026-10-05-flatten-install-layout.ps1 -Apply # do it
|
|
|
|
Close CVRManager and every game from the library first. config.json is backed up next to itself.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
|
|
[switch]$Apply,
|
|
[switch]$SkipAppCheck # only for testing against a throwaway copy; skips the 'CVRManager is running' check
|
|
)
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
function Get-SafeFolderName([string]$name) {
|
|
$bad = [IO.Path]::GetInvalidFileNameChars()
|
|
$clean = -join ($name.ToCharArray() | ForEach-Object { if ($bad -contains $_) { ' ' } else { $_ } })
|
|
while ($clean.Contains(' ')) { $clean = $clean.Replace(' ', ' ') }
|
|
$clean = $clean.Trim().TrimEnd('.')
|
|
if ($clean.Length -gt 60) { $clean = $clean.Substring(0, 60).TrimEnd() }
|
|
if ($clean.Length -eq 0) { 'Install' } else { $clean }
|
|
}
|
|
|
|
$configPath = Join-Path $ConfigDir 'config.json'
|
|
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
|
|
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
|
|
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
|
|
$installs = Join-Path $lib 'installs'
|
|
if (-not (Test-Path $installs)) { 'No installs folder; nothing to do.'; return }
|
|
|
|
$running = @(if (-not $SkipAppCheck) { Get-Process CVRManager -ErrorAction SilentlyContinue }) + @(Get-Process ChilloutVR -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($installs, [StringComparison]::OrdinalIgnoreCase) })
|
|
if ($Apply -and $running.Count -gt 0) { throw "Close CVRManager and any game started from the library first (running: $(($running | ForEach-Object { $_.ProcessName + ' ' + $_.Id }) -join ', '))." }
|
|
|
|
$list = [System.Collections.Generic.List[object]]::new()
|
|
foreach ($i in @($cfg.Installs)) { $list.Add($i) }
|
|
$newEntries = @()
|
|
|
|
# orphan folders: on disk, not in config
|
|
$known = @($list | ForEach-Object { $_.Folder.ToLowerInvariant() })
|
|
foreach ($dir in Get-ChildItem $installs -Directory) {
|
|
if ($known -contains $dir.Name.ToLowerInvariant()) { continue }
|
|
$hasExe = (Test-Path (Join-Path $dir.FullName 'ChilloutVR.exe')) -or (Test-Path (Join-Path $dir.FullName 'game\ChilloutVR.exe'))
|
|
if (-not $hasExe) { continue }
|
|
$m = [regex]::Match($dir.Name, '^(.*)-([0-9a-f]{4})$')
|
|
$name = if ($m.Success) { $m.Groups[1].Value.Replace('-', ' ').Trim() } else { $dir.Name }
|
|
$branch = 'public'
|
|
foreach ($acf in @((Join-Path $dir.FullName 'game\steamapps\appmanifest_661130.acf'), (Join-Path $dir.FullName 'steamapps\appmanifest_661130.acf'))) {
|
|
if (Test-Path $acf) {
|
|
$b = [regex]::Match((Get-Content $acf -Raw), '"UserConfig"\s*\{[^}]*?"BetaKey"\s*"([^"]*)"')
|
|
if ($b.Success -and $b.Groups[1].Value) { $branch = $b.Groups[1].Value; break }
|
|
}
|
|
}
|
|
$defaultHooks = @($cfg.Hooks | Where-Object { $_.DefaultOn } | ForEach-Object { $_.Id })
|
|
$entry = [pscustomobject]@{
|
|
Id = ([guid]::NewGuid().ToString('N')).Substring(0, 8); Name = $name; Folder = $dir.Name; Branch = $branch
|
|
BranchPassword = ''; LaunchArgs = ''; LaunchMode = 'desktop'; Mods = @(); HookIds = $defaultHooks; Deployed = [pscustomobject]@{}
|
|
}
|
|
$list.Add($entry); $newEntries += $entry
|
|
}
|
|
|
|
function Test-Taken([string]$candidate, $self) {
|
|
foreach ($o in $list) { if ($o -ne $self -and $o.Folder -ieq $candidate) { return $true } }
|
|
if ($candidate -ieq $self.Folder) { return $false }
|
|
return (Test-Path (Join-Path $installs $candidate))
|
|
}
|
|
|
|
$plans = @()
|
|
foreach ($i in $list) {
|
|
$legacy = Join-Path (Join-Path $installs $i.Folder) 'game'
|
|
$needsFlatten = (Test-Path $legacy) -and ((Test-Path (Join-Path $legacy 'ChilloutVR.exe')) -or -not (Test-Path (Join-Path (Join-Path $installs $i.Folder) 'ChilloutVR.exe')))
|
|
$base = Get-SafeFolderName $i.Name
|
|
$candidate = $base; $n = 2
|
|
while (Test-Taken $candidate $i) { $candidate = "$base ($n)"; $n++ }
|
|
$needsRename = $candidate -cne $i.Folder
|
|
if ($needsFlatten -or $needsRename) { $plans += [pscustomobject]@{ Install = $i; From = $i.Folder; To = $candidate; Flatten = $needsFlatten; Rename = $needsRename; Registers = ($newEntries -contains $i) } }
|
|
}
|
|
|
|
"Library: $lib"
|
|
if ($newEntries.Count -gt 0) { "Install folders on disk that are missing from the config ($($newEntries.Count)):"; $newEntries | ForEach-Object { " + register '$($_.Name)' (folder '$($_.Folder)', branch '$($_.Branch)')" } }
|
|
if ($plans.Count -eq 0 -and $newEntries.Count -eq 0) { 'Nothing to do: every install already uses the current layout.'; return }
|
|
"Planned moves:"
|
|
foreach ($p in $plans) {
|
|
$what = @(); if ($p.Rename) { $what += "rename folder '$($p.From)' -> '$($p.To)'" }; if ($p.Flatten) { $what += "move the contents of 'game' up one level" }
|
|
" - $($p.Install.Name): $($what -join '; ')"
|
|
}
|
|
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to make these changes.'; return }
|
|
|
|
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
|
|
Copy-Item $configPath $backup
|
|
"Config backed up to $backup"
|
|
|
|
function Save-Config([object[]]$installsToWrite) {
|
|
$cfg.Installs = @($installsToWrite)
|
|
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
|
|
}
|
|
|
|
# progress is written after every install, so a failure part-way never leaves the config out of step with the disk
|
|
$registered = @($list | Where-Object { $newEntries -notcontains $_ }) # installs that were already in the config stay there
|
|
$failed = @()
|
|
foreach ($p in $plans) {
|
|
$oldDir = Join-Path $installs $p.From
|
|
$newDir = Join-Path $installs $p.To
|
|
try {
|
|
if ($p.Rename) {
|
|
if ($oldDir -ieq $newDir) { $tmp = "$oldDir.renaming"; [IO.Directory]::Move($oldDir, $tmp); [IO.Directory]::Move($tmp, $newDir) }
|
|
else { [IO.Directory]::Move($oldDir, $newDir) }
|
|
$p.Install.Folder = $p.To
|
|
}
|
|
$legacy = Join-Path $newDir 'game'
|
|
if ($p.Flatten -and (Test-Path $legacy)) {
|
|
foreach ($entry in Get-ChildItem $legacy -Force) {
|
|
$dest = Join-Path $newDir $entry.Name
|
|
if (Test-Path $dest) { Write-Warning "Skipped '$($entry.Name)' for $($p.Install.Name): already exists at the destination"; continue }
|
|
if ($entry.PSIsContainer) { [IO.Directory]::Move($entry.FullName, $dest) } else { [IO.File]::Move($entry.FullName, $dest) }
|
|
}
|
|
if (-not (Get-ChildItem $legacy -Force)) { Remove-Item $legacy }
|
|
}
|
|
if ($newEntries -contains $p.Install) { $registered += $p.Install }
|
|
Save-Config (@($list | Where-Object { $newEntries -notcontains $_ -or $registered -contains $_ }))
|
|
" done: $($p.Install.Name) -> $newDir"
|
|
}
|
|
catch [UnauthorizedAccessException], [IO.IOException] {
|
|
$failed += $p.Install.Name
|
|
Write-Warning "$($p.Install.Name): $($_.Exception.Message)"
|
|
Write-Warning "A folder of this install is probably open in Explorer or another program. Close it and run the script again; it continues where it stopped."
|
|
Save-Config (@($list | Where-Object { $newEntries -notcontains $_ -or $registered -contains $_ }))
|
|
}
|
|
}
|
|
|
|
if ($failed.Count -gt 0) { "Finished with problems for: $($failed -join ', '). Everything else was migrated and saved." }
|
|
else { 'Config updated. Start CVRManager again.' }
|