CVR Manager 1.0.0

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Exterrata
2026-10-25 02:46:35 -04:00
commit 9bb4f85802
59 changed files with 7913 additions and 0 deletions
@@ -0,0 +1,87 @@
<#
One-off migration: give every install folder the 4-character id suffix again.
before: <library>\installs\Public Scripting\
after: <library>\installs\Public Scripting-9ce8\ (suffix = first 4 characters of the install's Id in config.json)
Run it after 2026-10-05-flatten-install-layout.ps1 (which produced names without a suffix).
With the suffix, two installs may share a name without their folders colliding.
Dry run by default. Run with pwsh.
pwsh -File 2026-10-05-add-folder-id-suffix.ps1 # show the plan
pwsh -File 2026-10-05-add-folder-id-suffix.ps1 -Apply
Close CVRManager and every game from the library first. config.json is backed up next to itself,
and progress is saved after every install, so a folder held open by Explorer only blocks that install.
#>
[CmdletBinding()]
param(
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
[switch]$Apply,
[switch]$SkipAppCheck # only for testing against a throwaway copy
)
$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'
$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 ', '))." }
$all = @($cfg.Installs)
$plans = @()
foreach ($i in $all) {
$stem = "$(Get-SafeFolderName $i.Name)-$($i.Id.Substring(0, 4))"
$want = $stem; $n = 2
while ($true) {
$clash = $all | Where-Object { $_ -ne $i -and $_.Folder -ieq $want }
$existsOther = ($want -ine $i.Folder) -and (Test-Path (Join-Path $installs $want))
if (-not $clash -and -not $existsOther) { break }
$want = "$stem-$n"; $n++
}
if ($want -cne $i.Folder) { $plans += [pscustomobject]@{ Install = $i; From = $i.Folder; To = $want } }
}
"Library: $lib"
if ($plans.Count -eq 0) { 'Nothing to do: every install folder already has its suffix.'; return }
"Planned renames:"
foreach ($p in $plans) { " - $($p.Install.Name): '$($p.From)' -> '$($p.To)'" + $(if (-not (Test-Path (Join-Path $installs $p.From))) { ' (folder missing on disk: config entry only)' } else { '' }) }
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 { [IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false))) }
$failed = @()
foreach ($p in $plans) {
$oldDir = Join-Path $installs $p.From
$newDir = Join-Path $installs $p.To
try {
if (Test-Path $oldDir) {
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
Save-Config
" 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.'
}
}
if ($failed.Count -gt 0) { "Finished with problems for: $($failed -join ', '). Everything else was renamed and saved." } else { 'Config updated. Start CVRManager again.' }
@@ -0,0 +1,140 @@
<#
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.' }
@@ -0,0 +1,99 @@
<#
One-off migration: import the beta access codes listed in "<library>\branch codes.txt" (one code per line) into CVRManager.
Each code is resolved with Steam (anonymous CheckAppBetaPassword, the same call the app's "Enter beta code" box makes), so a
code that unlocks several branches is stored once per branch. For every branch it unlocks:
- config.json -> Unlocked gets { Name, Code } (an existing entry for that branch, e.g. a name-only one, gets the code filled in)
- installs on that branch get BranchPassword set, so Update works without opening the dropdown
Codes are never printed; they are shown as a length only.
The codes are checked as your signed-in Steam account, so sign in first: CVRManager > Settings > "Sign in (beta codes)".
The saved login token is read with the same Windows user that signed in, so run this as that user.
Needs a built app for the resolver: -BinDir (default bin\v26 next to the project) must contain CVRManager.dll and SteamKit2.dll.
Run it OUTSIDE Claude's sandbox (a normal terminal), otherwise it edits a private copy of %APPDATA%.
Dry run by default (resolves codes, writes nothing).
pwsh -File 2026-10-05-import-branch-codes.ps1
pwsh -File 2026-10-05-import-branch-codes.ps1 -Apply
Close CVRManager first with -Apply. config.json is backed up next to itself.
#>
[CmdletBinding()]
param(
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
[string]$CodesFile,
[string]$BinDir = (Join-Path $PSScriptRoot '..\..\bin\v26'),
[switch]$Apply
)
$ErrorActionPreference = 'Stop'
$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' }
if (-not $CodesFile) { $CodesFile = Join-Path $lib 'branch codes.txt' }
if (-not (Test-Path $CodesFile)) { throw "No codes file at $CodesFile" }
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
$BinDir = (Resolve-Path $BinDir).Path
[void][Reflection.Assembly]::LoadFrom((Join-Path $BinDir 'SteamKit2.dll'))
[void][Reflection.Assembly]::LoadFrom((Join-Path $BinDir 'CVRManager.dll'))
$resolver = [CVRManager.Services.BranchCodeService]
$account = $cfg.SteamUser
$token = [CVRManager.Services.TokenVault]::Unprotect($cfg.SteamToken)
if (-not $token) { throw 'Not signed in to Steam in the app. Open CVRManager > Settings > "Sign in (beta codes)", then run this again.' }
"Checking codes as Steam account '$account'."
$codes = @(Get-Content $CodesFile | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Select-Object -Unique)
"Codes in file: $($codes.Count)"
$known = @($cfg.Unlocked | Where-Object { $_.Code } | ForEach-Object { $_.Code } | Select-Object -Unique |
Where-Object { $c = $_; -not @($cfg.Unlocked | Where-Object { $_.Code -ceq $c -and -not $_.Key }).Count })
$resolved = @()
$n = 0
foreach ($code in $codes) {
$n++
if ($known -ccontains $code) { " code $n (len $($code.Length)): already stored with its key, skipped"; continue }
for ($try = 1; $try -le 6; $try++) {
try {
$keys = $resolver::ResolveAsync($code, $account, $token, [Threading.CancellationToken]::None).GetAwaiter().GetResult()
$names = @($keys.Keys)
" code $n (len $($code.Length)): unlocks $($names.Count) branch(es): $($names -join ', ')"
foreach ($name in $names) { $resolved += [pscustomobject]@{ Name = $name; Code = $code; Key = $resolver::ProtectKey($keys[$name]) } }
break
}
catch {
$msg = $_.Exception.InnerException.Message ?? $_.Exception.Message
if ($msg -match 'RateLimitExceeded' -and $try -lt 6) { " code ${n}: Steam rate limit, waiting 60 s (try $try of 5)"; Start-Sleep 60; continue }
" code $n (len $($code.Length)): NOT resolved - $msg"
break
}
}
}
if ($resolved.Count -eq 0) { 'Nothing resolved; nothing to do.'; return }
$unlocked = [System.Collections.Generic.List[object]]::new()
foreach ($u in @($cfg.Unlocked)) { $unlocked.Add($u) }
$added = 0; $filled = 0; $same = 0; $installsSet = 0
foreach ($r in $resolved) {
$existing = $unlocked | Where-Object { $_.Name -eq $r.Name } | Select-Object -First 1
if (-not $existing) { $unlocked.Add([pscustomobject]@{ Name = $r.Name; Code = $r.Code; Key = $r.Key }); $added++ }
elseif ($existing.Code -ceq $r.Code) { $same++; $existing | Add-Member -NotePropertyName Key -NotePropertyValue $r.Key -Force }
else { if ([string]::IsNullOrEmpty($existing.Code)) { $filled++ } else { " note: replacing the stored code for '$($r.Name)'" }; $existing.Code = $r.Code; $existing | Add-Member -NotePropertyName Key -NotePropertyValue $r.Key -Force }
foreach ($i in @($cfg.Installs | Where-Object { $_.Branch -eq $r.Name -and $_.BranchPassword -cne $r.Code })) { $i.BranchPassword = $r.Code; $installsSet++ }
}
"Unlocked entries: $added added, $filled filled in, $same already correct. Installs given a branch password: $installsSet."
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to save.'; return }
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item $configPath $backup
"Config backed up to $backup"
$cfg.Unlocked = @($unlocked)
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
'Config updated. Start CVRManager again.'
@@ -0,0 +1,64 @@
<#
One-off migration: copy the list of ChilloutVR beta branches you have unlocked in Steam
into CVRManager's branch dropdown. Only branch NAMES are copied; no codes (Steam does not keep them in a readable form).
Reads <Steam>\config\config.vdf -> InstallConfigStore/Software/Valve/Steam/apps/661130, keys "betahash_<branch>".
Writes the names to config.json -> Unlocked (skipping names already present).
Dry run by default. Run with pwsh.
pwsh -File 2026-10-05-import-steam-unlocked-branches.ps1 # show what would be added
pwsh -File 2026-10-05-import-steam-unlocked-branches.ps1 -Apply
Close CVRManager first (it rewrites config.json). config.json is backed up next to itself.
#>
[CmdletBinding()]
param(
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
[string]$SteamDir,
[switch]$Apply,
[switch]$SkipAppCheck # only for testing against a throwaway copy
)
$ErrorActionPreference = 'Stop'
if (-not $SteamDir) { $SteamDir = (Get-ItemProperty 'HKCU:\Software\Valve\Steam' -ErrorAction SilentlyContinue).SteamPath }
if (-not $SteamDir) { throw 'Could not find Steam; pass -SteamDir.' }
$vdf = Join-Path $SteamDir 'config\config.vdf'
if (-not (Test-Path $vdf)) { throw "No $vdf" }
function Get-Block([string]$text, [int]$from) {
$open = $text.IndexOf('{', $from)
if ($open -lt 0) { return $null }
$depth = 0
for ($i = $open; $i -lt $text.Length; $i++) {
if ($text[$i] -eq '{') { $depth++ } elseif ($text[$i] -eq '}') { $depth--; if ($depth -eq 0) { return $text.Substring($open, $i - $open + 1) } }
}
$null
}
$text = Get-Content $vdf -Raw
$apps = [regex]::Match($text, '"apps"\s*\{')
if (-not $apps.Success) { throw 'No "apps" section in config.vdf' }
$appsBlock = Get-Block $text $apps.Index
$app = [regex]::Match($appsBlock, '"661130"\s*\{')
if (-not $app.Success) { 'ChilloutVR has no entry in Steam''s config; nothing to import.'; return }
$appBlock = Get-Block $appsBlock $app.Index
$names = @([regex]::Matches($appBlock, '"betahash_([^"]+)"') | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique)
"Branches Steam has unlocked for ChilloutVR ($($names.Count)): $($names -join ', ')"
$configPath = Join-Path $ConfigDir 'config.json'
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
if ($Apply -and -not $SkipAppCheck -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
$existing = @($cfg.Unlocked | ForEach-Object { $_.Name })
$toAdd = @($names | Where-Object { $existing -inotcontains $_ })
"Already in CVRManager: $($existing.Count). To add: $($toAdd.Count)$(if ($toAdd.Count) { ' (' + ($toAdd -join ', ') + ')' })"
if ($toAdd.Count -eq 0) { 'Nothing to do.'; return }
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to add them.'; return }
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item $configPath $backup
"Config backed up to $backup"
$merged = @($cfg.Unlocked) + @($toAdd | ForEach-Object { [pscustomobject]@{ Name = $_; Code = '' } })
$cfg.Unlocked = @($merged)
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
"Added $($toAdd.Count) branch name(s). Start CVRManager again."
@@ -0,0 +1,66 @@
<#
One-off repair: point each install in the REAL config.json at its folder on disk, using the <Name>-<id4> format.
Background: the earlier layout migrations were run from inside Claude's packaged app, whose %APPDATA% writes land in a
private copy of the profile. The folders under %LOCALAPPDATA%\CVRManager\installs were really moved, but the real
%APPDATA%\CVRManager\config.json was never updated, so the app showed "Not installed". This script fixes the real file.
It must be started OUTSIDE Claude's sandbox (a normal terminal, or Win32_Process.Create), otherwise it edits the private copy.
Each -Map entry is "<folder currently on disk>=<install id, at least 4 chars>".
The folder is renamed to <SafeName>-<first 4 of id> and the install's Folder in config.json is set to the new name.
Dry run by default.
pwsh -File 2026-10-05-repair-real-config-folders.ps1 -Map 'Public Scripting-6e3b=9ce8','Public Scripting copy-2f65=1864'
pwsh -File ... -Apply
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)][string[]]$Map,
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
[switch]$Apply
)
$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'
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
$installs = Join-Path $lib 'installs'
"Config: $configPath"
"Library: $lib"
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first.' }
if ($Apply -and (Get-Process ChilloutVR -ErrorAction SilentlyContinue | Where-Object { $_.Path -and $_.Path.StartsWith($installs, [StringComparison]::OrdinalIgnoreCase) })) { throw 'Close the game first.' }
$plans = foreach ($entry in $Map) {
$dirName, $idPrefix = $entry.Split('=', 2)
$inst = @($cfg.Installs | Where-Object { $_.Id.StartsWith($idPrefix, [StringComparison]::OrdinalIgnoreCase) })
if ($inst.Count -ne 1) { throw "Id '$idPrefix' matches $($inst.Count) installs." }
$inst = $inst[0]
if (-not (Test-Path (Join-Path $installs $dirName))) { throw "No folder $dirName in $installs" }
[pscustomobject]@{ Install = $inst; From = $dirName; To = "$(Get-SafeFolderName $inst.Name)-$($inst.Id.Substring(0, 4))" }
}
foreach ($p in $plans) { " $($p.Install.Name) (id $($p.Install.Id)): disk '$($p.From)' -> '$($p.To)'; config Folder '$($p.Install.Folder)' -> '$($p.To)'" }
$targets = $plans | ForEach-Object { $_.To }
if (($targets | Select-Object -Unique).Count -ne $targets.Count) { throw 'Two installs would get the same folder.' }
foreach ($p in $plans) { if ($p.From -ine $p.To -and (Test-Path (Join-Path $installs $p.To))) { throw "Target folder '$($p.To)' already exists." } }
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply.'; return }
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item $configPath $backup
"Config backed up to $backup"
foreach ($p in $plans) {
if ($p.From -cne $p.To) { [IO.Directory]::Move((Join-Path $installs $p.From), (Join-Path $installs $p.To)) }
$p.Install.Folder = $p.To
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
" done: $($p.Install.Name) -> $($p.To)"
}
'Config updated. Start CVRManager again.'
@@ -0,0 +1,59 @@
<#
One-off migration: the app no longer has a built-in "Menu patch" hook. Your menu patch becomes an ordinary command hook.
- writes "<library>\hooks\menupatch\apply.ps1": copies the MenuPatch folder (and PatchMenus.bat) from this folder into the game
folder, then runs MenuPatch\patch.ps1 there, exiting with its exit code
- config.json: the hook with id "menupatch" gets Command = that apply.ps1 and loses its "Kind"
Your existing master folder (<library>\hooks\menupatch\MenuPatch and PatchMenus.bat) is not touched.
Run it OUTSIDE Claude's sandbox (a normal terminal), otherwise it edits a private copy of %APPDATA%.
Dry run by default; -Apply writes. Close CVRManager first with -Apply. config.json is backed up next to itself.
#>
[CmdletBinding()]
param(
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
[switch]$Apply
)
$ErrorActionPreference = 'Stop'
$configPath = Join-Path $ConfigDir 'config.json'
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
$lib = if ($cfg.LibraryRoot) { $cfg.LibraryRoot } else { Join-Path $env:LOCALAPPDATA 'CVRManager' }
$master = Join-Path $lib 'hooks\menupatch'
$apply1 = Join-Path $master 'apply.ps1'
$hook = @($cfg.Hooks) | Where-Object { $_.Id -eq 'menupatch' } | Select-Object -First 1
if (-not $hook) { 'No hook with id "menupatch" in the config; nothing to migrate.'; return }
"Hook '$($hook.Name)': Kind=$($hook.Kind), Command='$($hook.Command)'"
"Master folder: $master (MenuPatch present: $(Test-Path (Join-Path $master 'MenuPatch\patch.ps1')))"
"apply.ps1 will be written to: $apply1"
$script = @'
$ErrorActionPreference = 'Stop'
$game = $env:CVR_GAME_DIR
if (-not $game) { throw 'CVR_GAME_DIR is not set; run this from CVRManager.' }
$src = Join-Path $PSScriptRoot 'MenuPatch'
if (-not (Test-Path (Join-Path $src 'patch.ps1'))) { throw "No MenuPatch\patch.ps1 in $PSScriptRoot" }
robocopy $src (Join-Path $game 'MenuPatch') /E /XD backup 'reference-*' /NFL /NDL /NJH /NJS /NP | Out-Null
if ($LASTEXITCODE -ge 8) { throw "robocopy failed ($LASTEXITCODE)" }
$bat = Join-Path $PSScriptRoot 'PatchMenus.bat'
if (Test-Path $bat) { Copy-Item $bat (Join-Path $game 'PatchMenus.bat') -Force }
& (Join-Path $game 'MenuPatch\patch.ps1')
if ($LASTEXITCODE -eq 2) { exit 0 }
exit $LASTEXITCODE
'@
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to save.'; return }
New-Item -ItemType Directory $master -Force | Out-Null
[IO.File]::WriteAllText($apply1, $script, (New-Object Text.UTF8Encoding($false)))
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item $configPath $backup
$hook.Command = $apply1
$hook.Arguments = ''
$hook.PSObject.Properties.Remove('Kind')
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
"Config backed up to $backup"
'Done. Start CVRManager again.'
@@ -0,0 +1,33 @@
<#
One-off cleanup for configs written by builds before 2026-10-13, which added a built-in "Menu patch" hook to every config.
Removes the hook with id "menupatch" when it has no command (the leftover), and drops it from every install.
A menupatch hook that has a command (converted by 2026-10-13-menu-patch-to-command-hook.ps1) is left alone.
Dry run by default; -Apply writes. Close CVRManager first. config.json is backed up next to itself.
#>
[CmdletBinding()]
param(
[string]$ConfigDir = (Join-Path $env:APPDATA 'CVRManager'),
[switch]$Apply
)
$ErrorActionPreference = 'Stop'
$configPath = Join-Path $ConfigDir 'config.json'
if (-not (Test-Path $configPath)) { throw "No config at $configPath" }
if ($Apply -and (Get-Process CVRManager -ErrorAction SilentlyContinue)) { throw 'Close CVRManager first; it would overwrite the change.' }
$cfg = Get-Content $configPath -Raw | ConvertFrom-Json
$hook = @($cfg.Hooks) | Where-Object { $_.Id -eq 'menupatch' } | Select-Object -First 1
if (-not $hook) { 'No menu patch hook in the config; nothing to do.'; return }
if ($hook.Command) { "The menu patch hook runs '$($hook.Command)'; it was set up on purpose, leaving it."; return }
$users = @($cfg.Installs | Where-Object { $_.HookIds -contains 'menupatch' } | ForEach-Object Name)
"Leftover hook '$($hook.Name)' (no command), enabled on: $(if ($users) { $users -join ', ' } else { 'no installs' })"
if (-not $Apply) { ''; 'Dry run only. Re-run with -Apply to remove it.'; return }
$backup = "$configPath.bak-$(Get-Date -Format 'yyyyMMdd-HHmmss')"
Copy-Item $configPath $backup
$cfg.Hooks = @($cfg.Hooks | Where-Object { $_.Id -ne 'menupatch' })
foreach ($i in $cfg.Installs) { $i.HookIds = @($i.HookIds | Where-Object { $_ -ne 'menupatch' }) }
[IO.File]::WriteAllText($configPath, ($cfg | ConvertTo-Json -Depth 30), (New-Object Text.UTF8Encoding($false)))
"Config backed up to $backup"
'Removed. Start CVRManager again.'