<# One-off migration: old install layout -> current layout. old: \installs\-<4 hex>\game\ChilloutVR.exe new: \installs\\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.' }