From 1342d84700c7864b638f8e0bcbc34f84833d543b Mon Sep 17 00:00:00 2001 From: mfoltz Date: Sat, 16 May 2026 23:33:48 -0500 Subject: [PATCH 1/5] Improve Eclipse runtime-load friendliness --- Core.cs | 8 +++++++- Patches/InitializationPatches.cs | 11 +++++++++-- Plugin.cs | 17 ++++++++++++++--- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Core.cs b/Core.cs index 53aa1f6..6195a5e 100644 --- a/Core.cs +++ b/Core.cs @@ -78,6 +78,12 @@ public static void Reset() _localCharacter = Entity.Null; _localUser = Entity.Null; + + if (_monoBehaviour != null) + { + UnityEngine.Object.Destroy(_monoBehaviour.gameObject); + _monoBehaviour = null; + } } public static void SetCanvas(UICanvasBase canvas) { @@ -157,4 +163,4 @@ public static EntityQuery BuildEntityQuery( } public static Il2CppSystem.Type Il2CppTypeOf() => Il2CppType.Of(); -} \ No newline at end of file +} diff --git a/Patches/InitializationPatches.cs b/Patches/InitializationPatches.cs index a1bfe2a..07874b9 100644 --- a/Patches/InitializationPatches.cs +++ b/Patches/InitializationPatches.cs @@ -69,8 +69,15 @@ public static void TryInitializeAttributeValues() [HarmonyPrefix] static void OnUpdatePrefix(ClientBootstrapSystem __instance) { - CanvasService._shiftRoutine.Stop(); - CanvasService._canvasRoutine.Stop(); + if (CanvasService._shiftRoutine != null) + { + CanvasService._shiftRoutine.Stop(); + } + + if (CanvasService._canvasRoutine != null) + { + CanvasService._canvasRoutine.Stop(); + } CanvasService.DataHUD._killSwitch = true; CanvasService._shiftRoutine = null; diff --git a/Plugin.cs b/Plugin.cs index ceaed04..c0bb417 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -58,10 +58,19 @@ public override void Load() return; } - _harmony = Harmony.CreateAndPatchAll(Assembly.GetExecutingAssembly()); InitConfig(); + _harmony = Harmony.CreateAndPatchAll(Assembly.GetExecutingAssembly()); EmberglassEclipseBridge.Initialize(); - Core.Log.LogInfo($"{MyPluginInfo.PLUGIN_NAME}[{MyPluginInfo.PLUGIN_VERSION}] loaded on client!"); + Core.Log.LogInfo($"{MyPluginInfo.PLUGIN_NAME}[{MyPluginInfo.PLUGIN_VERSION}] loaded on client; waiting for game data and UI hooks."); + } + static void Initialize() + { + if (Instance == null || Application.productName == "VRisingServer") + { + return; + } + + Core.Log.LogInfo($"{MyPluginInfo.PLUGIN_NAME}[{MyPluginInfo.PLUGIN_VERSION}] runtime initialize callback reached; waiting for game data and UI hooks."); } static void InitConfig() { @@ -98,7 +107,9 @@ static ConfigEntry InitConfigEntry(string section, string key, T defaultVa } public override bool Unload() { - _harmony.UnpatchSelf(); + _harmony?.UnpatchSelf(); + _harmony = null; + Core.Reset(); return true; } } From ddb3fe4d7af9c81c8d9976f96f71a0d75a652906 Mon Sep 17 00:00:00 2001 From: mfoltz Date: Sun, 17 May 2026 12:29:36 -0500 Subject: [PATCH 2/5] Improve client mod coexistence safeguards --- .../mod-pair-compatibility/README.md | 156 ++++++++ .codex/scripts/New-BepInExPluginSetProof.ps1 | 126 +++++++ .../scripts/New-ModPairCompatibilityProof.ps1 | 184 ++++++++++ .codex/scripts/Save-GitHubReleaseAsset.ps1 | 185 ++++++++++ .../scripts/Use-ModPairClientProofProfile.ps1 | 338 ++++++++++++++++++ .gitignore | 5 +- Plugin.cs | 4 + Services/CanvasService.cs | 38 +- Services/EmberglassEclipseBridge.cs | 29 +- 9 files changed, 1058 insertions(+), 7 deletions(-) create mode 100644 .codex/runtime-proofs/mod-pair-compatibility/README.md create mode 100644 .codex/scripts/New-BepInExPluginSetProof.ps1 create mode 100644 .codex/scripts/New-ModPairCompatibilityProof.ps1 create mode 100644 .codex/scripts/Save-GitHubReleaseAsset.ps1 create mode 100644 .codex/scripts/Use-ModPairClientProofProfile.ps1 diff --git a/.codex/runtime-proofs/mod-pair-compatibility/README.md b/.codex/runtime-proofs/mod-pair-compatibility/README.md new file mode 100644 index 0000000..e1d4edf --- /dev/null +++ b/.codex/runtime-proofs/mod-pair-compatibility/README.md @@ -0,0 +1,156 @@ +# Mod Pair Compatibility Proof + +This proof shape is for checking whether two client-side mods can load together without a targeted startup or UI bring-up failure. It is intentionally generic: the current Eclipse/BloodCraftHub check is one profile of this workflow, not the name or owner of the workflow. + +## Labels + +- `pairLabel`: stable lowercase label for the two-mod pairing, such as `eclipse-bloodcrafthub`. +- `subjectMod`: the mod being changed or evaluated in this repository. +- `peerMod`: the other mod in the compatibility pair. +- `supportMod`: an additional staged mod needed to make the scenario realistic without redefining the pair under test. +- `proofMode`: one of `subject-only`, `peer-only`, `combined-control`, or `combined-candidate`. +- `runId`: timestamped identifier for one staging and evidence collection attempt. + +Use these names in receipts, folders, and summaries so future community troubleshooting can reuse the same packet shape. + +## Stop Rules + +Stop and mark the run inconclusive when: + +- the staged plugin inventory contains unrelated mods; +- the proof requires mutating the live game profile without a restorable backup; +- the client/server build, BepInEx pack, or mod versions cannot be recorded; +- the observed result is only a manual impression with no retained log or receipt; +- the failure moves outside the named compatibility lane. + +## Recommended Matrix + +| proofMode | Staged mods | Purpose | +| --- | --- | --- | +| `subject-only` | subject mod only | Prove the changed mod still loads by itself. | +| `peer-only` | peer mod only | Prove the peer mod is not already failing alone. | +| `combined-control` | subject + peer with suspected risky setting enabled | Optional negative control when safe and quick. | +| `combined-candidate` | subject + peer with candidate mitigation enabled | Main compatibility proof. | + +For the Eclipse/BloodCraftHub case, the useful candidate profile is `combined-candidate` with Eclipse `UIOptions.AttributeBuffs=false`. A useful optional control is `combined-control` with `UIOptions.AttributeBuffs=true`. + +## Receipt Requirements + +Each run should retain: + +- `receipt.json` with `pairLabel`, `proofMode`, mod names, artifact hashes, git commit, config overrides, and timestamp; +- `plugin-inventory.txt` listing the staged plugins; +- copied config overrides or a plain text description of them; +- `LogOutput.log` or the closest available BepInEx log from the run; +- `summary.md` naming the pass/fail/inconclusive result and the observed markers. + +Expected success markers should be named before the run. For the current compatibility lane, useful markers are: + +- subject mod loaded; +- peer mod loaded; +- no fatal or repeated exception around `CanvasService`; +- no fatal or repeated exception around the targeted DOTS attribute-buffer path; +- client reaches the agreed checkpoint, ideally world entry or a timed post-UI-bring-up survival window. + +## Dry-Run Staging + +Use the generic helper to create a proof packet before launching the game: + +```powershell +.\.codex\scripts\New-ModPairCompatibilityProof.ps1 ` + -PairLabel eclipse-bloodcrafthub ` + -ProofMode combined-candidate ` + -SubjectModName Eclipse ` + -SubjectModArtifact .\bin\Release\net6.0\Eclipse.dll ` + -PeerModName BloodCraftHub ` + -PeerModArtifact C:\Path\To\BloodCraftHub.dll ` + -ConfigOverride "Eclipse:io.zfolmt.Eclipse.cfg:UIOptions.AttributeBuffs=false" +``` + +The helper creates a run folder under `.codex/runs/mod-pair-compatibility/`, stages only the named mod artifacts, and writes the initial receipt. Runtime launch and log collection remain explicit follow-up steps. + +When the pair needs a realistic companion mod, keep the pair labels stable and add support artifacts explicitly: + +```powershell +.\.codex\scripts\New-ModPairCompatibilityProof.ps1 ` + -PairLabel eclipse-bloodcrafthub ` + -ProofMode combined-candidate ` + -SubjectModName Eclipse ` + -SubjectModArtifact .\bin\Release\net6.0\Eclipse.dll ` + -PeerModName BloodCraftHub ` + -PeerModArtifact C:\Path\To\BloodCraftHub.dll ` + -SupportModArtifact C:\Path\To\Bloodcraft.dll ` + -ConfigOverride "Eclipse:io.zfolmt.Eclipse.cfg:UIOptions.AttributeBuffs=false" +``` + +## GitHub Release Assets + +When the peer mod is distributed through GitHub Releases, fetch the exact release asset into the local artifact cache first: + +```powershell +.\.codex\scripts\Save-GitHubReleaseAsset.ps1 ` + -ReleaseUrl "https://github.com/KDavidP1987/BloodCraftHub/releases/latest" ` + -AssetPattern "*.dll" +``` + +For ZIP assets: + +```powershell +.\.codex\scripts\Save-GitHubReleaseAsset.ps1 ` + -Repository KDavidP1987/BloodCraftHub ` + -Tag latest ` + -AssetPattern "*.zip" ` + -ExtractZip +``` + +The downloader requires the asset pattern to match exactly one GitHub release asset. It writes a receipt beside the downloaded file under `.codex\artifacts\mod-releases\`; use the downloaded DLL, or the extracted DLL if the release asset was a ZIP, as `-PeerModArtifact` for the proof packet. + +## Client Sandbox Setup + +Use `VRisingCodex` as the client proof sandbox when available: + +```powershell +$run = ".\.codex\runs\mod-pair-compatibility\eclipse-bloodcrafthub\combined-candidate\" + +.\.codex\scripts\Use-ModPairClientProofProfile.ps1 ` + -Action Install ` + -ClientRoot "C:\Program Files (x86)\Steam\steamapps\common\VRisingCodex" ` + -ProofRunDirectory $run +``` + +The install action backs up the sandbox's current `BepInEx\plugins` and `BepInEx\config`, clears the plugin directory, copies the staged proof plugins, and applies config overrides recorded in the proof packet. + +After the client run: + +```powershell +.\.codex\scripts\Use-ModPairClientProofProfile.ps1 ` + -Action Collect ` + -ClientRoot "C:\Program Files (x86)\Steam\steamapps\common\VRisingCodex" + +.\.codex\scripts\Use-ModPairClientProofProfile.ps1 ` + -Action Restore ` + -ClientRoot "C:\Program Files (x86)\Steam\steamapps\common\VRisingCodex" +``` + +`Collect` copies the current client logs and config into the proof run. `Restore` puts the previous sandbox plugin/config state back from the generated backup. + +## Server Support Profiles + +When the client pair needs a server mod loaded to produce meaningful replies, create a separate BepInEx plugin-set proof for the server sandbox instead of adding server mods to the client pair: + +```powershell +.\.codex\scripts\New-BepInExPluginSetProof.ps1 ` + -ProfileLabel bloodcraft-server-support ` + -TargetRole server ` + -PluginArtifact C:\Path\To\Bloodcraft.dll, C:\Path\To\VampireCommandFramework.dll +``` + +Install it into the dedicated-server Codex sandbox with the same profile installer, using the server executable name: + +```powershell +.\.codex\scripts\Use-ModPairClientProofProfile.ps1 ` + -Action Install ` + -ClientRoot "C:\Program Files (x86)\Steam\steamapps\common\VRisingDedicatedServerCodex" ` + -ExpectedExecutableName VRisingServer.exe ` + -ProofRunDirectory ".\.codex\runs\bepinex-plugin-set\bloodcraft-server-support\server\" +``` diff --git a/.codex/scripts/New-BepInExPluginSetProof.ps1 b/.codex/scripts/New-BepInExPluginSetProof.ps1 new file mode 100644 index 0000000..82ebc6f --- /dev/null +++ b/.codex/scripts/New-BepInExPluginSetProof.ps1 @@ -0,0 +1,126 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[a-z0-9][a-z0-9-]*$')] + [string]$ProfileLabel, + + [Parameter(Mandatory = $true)] + [ValidateSet('client', 'server')] + [string]$TargetRole, + + [Parameter(Mandatory = $true)] + [string[]]$PluginArtifact, + + [string[]]$ConfigOverride = @(), + + [string]$RunRoot = ".codex\runs\bepinex-plugin-set", + + [string]$RunId = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-ExistingFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $resolved = @(Resolve-Path -LiteralPath $Path -ErrorAction Stop) + if ($resolved.Count -ne 1) { + throw "Expected one file for '$Path', found $($resolved.Count)." + } + + $item = Get-Item -LiteralPath $resolved[0].Path + if (-not $item.PSIsContainer) { + return $item + } + + throw "Expected a file path, got directory '$Path'." +} + +if ([string]::IsNullOrWhiteSpace($RunId)) { + $RunId = Get-Date -Format "yyyyMMdd-HHmmss" +} + +$artifacts = @($PluginArtifact | ForEach-Object { Resolve-ExistingFile -Path $_ }) +$runDirectory = Join-Path $RunRoot (Join-Path $ProfileLabel (Join-Path $TargetRole $RunId)) +$stagePluginDirectory = Join-Path $runDirectory "stage\BepInEx\plugins" +New-Item -ItemType Directory -Path $stagePluginDirectory -Force | Out-Null + +$stagedArtifacts = @() +foreach ($artifact in $artifacts) { + $hash = Get-FileHash -LiteralPath $artifact.FullName -Algorithm SHA256 + $target = Join-Path $stagePluginDirectory $artifact.Name + Copy-Item -LiteralPath $artifact.FullName -Destination $target -Force + + $stagedArtifacts += [ordered]@{ + name = [System.IO.Path]::GetFileNameWithoutExtension($artifact.Name) + sourcePath = $artifact.FullName + stagedPath = (Resolve-Path -LiteralPath $target).Path + sha256 = $hash.Hash + length = $artifact.Length + lastWriteTimeUtc = $artifact.LastWriteTimeUtc.ToString("o") + } +} + +$inventoryPath = Join-Path $runDirectory "plugin-inventory.txt" +$stagedArtifacts | ForEach-Object { + "{0}`t{1}`t{2}" -f $_.name, $_.sha256, $_.stagedPath +} | Set-Content -LiteralPath $inventoryPath -Encoding utf8 + +$configPath = Join-Path $runDirectory "config-overrides.txt" +if ($ConfigOverride.Count -gt 0) { + $ConfigOverride | Set-Content -LiteralPath $configPath -Encoding utf8 +} +else { + "No config overrides recorded." | Set-Content -LiteralPath $configPath -Encoding utf8 +} + +$gitCommit = "" +try { + $gitCommit = (& git rev-parse HEAD).Trim() +} +catch { + $gitCommit = "unavailable" +} + +$receipt = [ordered]@{ + schema = "bepinex-plugin-set-proof.v1" + runId = $RunId + profileLabel = $ProfileLabel + targetRole = $TargetRole + createdAtUtc = (Get-Date).ToUniversalTime().ToString("o") + repository = (Resolve-Path -LiteralPath ".").Path + gitCommit = $gitCommit + stagedPluginDirectory = (Resolve-Path -LiteralPath $stagePluginDirectory).Path + configOverrides = $ConfigOverride + artifacts = $stagedArtifacts + result = "not-run" + resultNotes = "Staging receipt only. Install this plugin set into an isolated BepInEx profile, then collect logs into this run directory." +} + +$receiptPath = Join-Path $runDirectory "receipt.json" +$receipt | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $receiptPath -Encoding utf8 + +$summaryPath = Join-Path $runDirectory "summary.md" +@( + "# BepInEx Plugin Set Proof" + "" + "- Profile: ``$ProfileLabel``" + "- Target role: ``$TargetRole``" + "- Result: ``not-run``" + "" + "## Next Steps" + "" + "1. Install this staged plugin set into the isolated BepInEx target." + "2. Run the target to the agreed checkpoint." + "3. Collect logs into this run directory." + "4. Update ``result`` and ``resultNotes`` in ``receipt.json``." +) | Set-Content -LiteralPath $summaryPath -Encoding utf8 + +Write-Host "Created BepInEx plugin set proof packet:" +Write-Host " $runDirectory" +Write-Host "Receipt:" +Write-Host " $receiptPath" diff --git a/.codex/scripts/New-ModPairCompatibilityProof.ps1 b/.codex/scripts/New-ModPairCompatibilityProof.ps1 new file mode 100644 index 0000000..5baefaf --- /dev/null +++ b/.codex/scripts/New-ModPairCompatibilityProof.ps1 @@ -0,0 +1,184 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[a-z0-9][a-z0-9-]*$')] + [string]$PairLabel, + + [Parameter(Mandatory = $true)] + [ValidateSet('subject-only', 'peer-only', 'combined-control', 'combined-candidate')] + [string]$ProofMode, + + [Parameter(Mandatory = $true)] + [string]$SubjectModName, + + [Parameter(Mandatory = $true)] + [string]$SubjectModArtifact, + + [Parameter(Mandatory = $true)] + [string]$PeerModName, + + [Parameter(Mandatory = $true)] + [string]$PeerModArtifact, + + [string[]]$SupportModArtifact = @(), + + [string[]]$ConfigOverride = @(), + + [string]$RunRoot = ".codex\runs\mod-pair-compatibility", + + [string]$RunId = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-ExistingFile { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $resolved = @(Resolve-Path -LiteralPath $Path -ErrorAction Stop) + if ($resolved.Count -ne 1) { + throw "Expected one file for '$Path', found $($resolved.Count)." + } + + $item = Get-Item -LiteralPath $resolved.Path + if (-not $item.PSIsContainer) { + return $item + } + + throw "Expected a file path, got directory '$Path'." +} + +function Add-Artifact { + param( + [Parameter(Mandatory = $true)] + [System.IO.FileInfo]$Artifact, + + [Parameter(Mandatory = $true)] + [string]$Role, + + [Parameter(Mandatory = $true)] + [string]$ModName, + + [Parameter(Mandatory = $true)] + [string]$PluginDirectory + ) + + $hash = Get-FileHash -LiteralPath $Artifact.FullName -Algorithm SHA256 + $target = Join-Path $PluginDirectory $Artifact.Name + Copy-Item -LiteralPath $Artifact.FullName -Destination $target -Force + + [ordered]@{ + role = $Role + name = $ModName + sourcePath = $Artifact.FullName + stagedPath = (Resolve-Path -LiteralPath $target).Path + sha256 = $hash.Hash + length = $Artifact.Length + lastWriteTimeUtc = $Artifact.LastWriteTimeUtc.ToString("o") + } +} + +if ([string]::IsNullOrWhiteSpace($RunId)) { + $RunId = Get-Date -Format "yyyyMMdd-HHmmss" +} + +$subjectArtifact = Resolve-ExistingFile -Path $SubjectModArtifact +$peerArtifact = Resolve-ExistingFile -Path $PeerModArtifact +$supportArtifacts = @($SupportModArtifact | ForEach-Object { Resolve-ExistingFile -Path $_ }) + +$runDirectory = Join-Path $RunRoot (Join-Path $PairLabel (Join-Path $ProofMode $RunId)) +$stagePluginDirectory = Join-Path $runDirectory "stage\BepInEx\plugins" +New-Item -ItemType Directory -Path $stagePluginDirectory -Force | Out-Null + +$artifacts = @() +if (($ProofMode -eq "subject-only") -or ($ProofMode -eq "combined-control") -or ($ProofMode -eq "combined-candidate")) { + $artifacts += Add-Artifact -Artifact $subjectArtifact -Role "subjectMod" -ModName $SubjectModName -PluginDirectory $stagePluginDirectory +} + +if (($ProofMode -eq "peer-only") -or ($ProofMode -eq "combined-control") -or ($ProofMode -eq "combined-candidate")) { + $artifacts += Add-Artifact -Artifact $peerArtifact -Role "peerMod" -ModName $PeerModName -PluginDirectory $stagePluginDirectory +} + +foreach ($supportArtifact in $supportArtifacts) { + $supportName = [System.IO.Path]::GetFileNameWithoutExtension($supportArtifact.Name) + $artifacts += Add-Artifact -Artifact $supportArtifact -Role "supportMod" -ModName $supportName -PluginDirectory $stagePluginDirectory +} + +$inventoryPath = Join-Path $runDirectory "plugin-inventory.txt" +$artifacts | ForEach-Object { + "{0}`t{1}`t{2}`t{3}" -f $_.role, $_.name, $_.sha256, $_.stagedPath +} | Set-Content -LiteralPath $inventoryPath -Encoding utf8 + +$configPath = Join-Path $runDirectory "config-overrides.txt" +if ($ConfigOverride.Count -gt 0) { + $ConfigOverride | Set-Content -LiteralPath $configPath -Encoding utf8 +} +else { + "No config overrides recorded." | Set-Content -LiteralPath $configPath -Encoding utf8 +} + +$gitCommit = "" +try { + $gitCommit = (& git rev-parse HEAD).Trim() +} +catch { + $gitCommit = "unavailable" +} + +$receipt = [ordered]@{ + schema = "mod-pair-compatibility-proof.v1" + runId = $RunId + pairLabel = $PairLabel + proofMode = $ProofMode + createdAtUtc = (Get-Date).ToUniversalTime().ToString("o") + repository = (Resolve-Path -LiteralPath ".").Path + gitCommit = $gitCommit + subjectMod = [ordered]@{ + name = $SubjectModName + artifactPath = $subjectArtifact.FullName + } + peerMod = [ordered]@{ + name = $PeerModName + artifactPath = $peerArtifact.FullName + } + supportMods = @($supportArtifacts | ForEach-Object { + [ordered]@{ + name = [System.IO.Path]::GetFileNameWithoutExtension($_.Name) + artifactPath = $_.FullName + } + }) + stagedPluginDirectory = (Resolve-Path -LiteralPath $stagePluginDirectory).Path + configOverrides = $ConfigOverride + artifacts = $artifacts + result = "not-run" + resultNotes = "Staging receipt only. Launch the client from an isolated profile, then copy logs into this run directory." +} + +$receiptPath = Join-Path $runDirectory "receipt.json" +$receipt | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $receiptPath -Encoding utf8 + +$summaryPath = Join-Path $runDirectory "summary.md" +@( + "# Mod Pair Compatibility Proof" + "" + "- Pair: ``$PairLabel``" + "- Proof mode: ``$ProofMode``" + "- Subject mod: ``$SubjectModName``" + "- Peer mod: ``$PeerModName``" + "- Result: ``not-run``" + "" + "## Next Steps" + "" + "1. Copy or point this staged profile at an isolated client test environment." + "2. Launch the client and reach the agreed checkpoint." + "3. Copy the BepInEx log into this run directory." + "4. Update ``result`` and ``resultNotes`` in ``receipt.json``." +) | Set-Content -LiteralPath $summaryPath -Encoding utf8 + +Write-Host "Created mod pair compatibility proof packet:" +Write-Host " $runDirectory" +Write-Host "Receipt:" +Write-Host " $receiptPath" diff --git a/.codex/scripts/Save-GitHubReleaseAsset.ps1 b/.codex/scripts/Save-GitHubReleaseAsset.ps1 new file mode 100644 index 0000000..026b640 --- /dev/null +++ b/.codex/scripts/Save-GitHubReleaseAsset.ps1 @@ -0,0 +1,185 @@ +[CmdletBinding()] +param( + [string]$Repository = "", + + [string]$ReleaseUrl = "", + + [string]$Tag = "latest", + + [Parameter(Mandatory = $true)] + [string]$AssetPattern, + + [string]$OutputDirectory = ".codex\artifacts\mod-releases", + + [switch]$ExtractZip +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Get-GitHubHeaders { + $headers = @{ + "Accept" = "application/vnd.github+json" + "User-Agent" = "Eclipse-ModPairCompatibilityProof" + "X-GitHub-Api-Version" = "2022-11-28" + } + + if (-not [string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) { + $headers["Authorization"] = "Bearer $env:GITHUB_TOKEN" + } + + $headers +} + +function Read-ReleaseUrl { + param( + [Parameter(Mandatory = $true)] + [string]$Url + ) + + $match = [regex]::Match($Url, '^https://github\.com/([^/]+)/([^/]+)/releases(?:/tag/([^/?#]+)|/latest)?') + if (-not $match.Success) { + throw "ReleaseUrl must look like https://github.com/owner/repo/releases/tag/v1.2.3 or /releases/latest." + } + + $parsedTag = "latest" + if ($match.Groups[3].Success -and -not [string]::IsNullOrWhiteSpace($match.Groups[3].Value)) { + $parsedTag = [System.Uri]::UnescapeDataString($match.Groups[3].Value) + } + + [ordered]@{ + repository = "$($match.Groups[1].Value)/$($match.Groups[2].Value)" + tag = $parsedTag + } +} + +function Get-ReleaseApiUri { + param( + [Parameter(Mandatory = $true)] + [string]$RepositoryName, + + [Parameter(Mandatory = $true)] + [string]$ReleaseTag + ) + + if ($ReleaseTag -eq "latest") { + return "https://api.github.com/repos/$RepositoryName/releases/latest" + } + + $escapedTag = [System.Uri]::EscapeDataString($ReleaseTag) + "https://api.github.com/repos/$RepositoryName/releases/tags/$escapedTag" +} + +function Resolve-ArtifactDirectory { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + + [Parameter(Mandatory = $true)] + [string]$RepositoryName, + + [Parameter(Mandatory = $true)] + [string]$ReleaseTag + ) + + $safeRepository = $RepositoryName -replace '[\\/]', '__' + $safeTag = $ReleaseTag -replace '[^\w\.-]', '_' + Join-Path $Root (Join-Path $safeRepository $safeTag) +} + +if ([string]::IsNullOrWhiteSpace($Repository) -and [string]::IsNullOrWhiteSpace($ReleaseUrl)) { + throw "Provide either -Repository owner/name or -ReleaseUrl." +} + +if (-not [string]::IsNullOrWhiteSpace($ReleaseUrl)) { + $parsed = Read-ReleaseUrl -Url $ReleaseUrl + if ([string]::IsNullOrWhiteSpace($Repository)) { + $Repository = $parsed.repository + } + + if ($Tag -eq "latest") { + $Tag = $parsed.tag + } +} + +if ($Repository -notmatch '^[^/\s]+/[^/\s]+$') { + throw "Repository must be in owner/name form." +} + +$releaseApiUri = Get-ReleaseApiUri -RepositoryName $Repository -ReleaseTag $Tag +$headers = Get-GitHubHeaders +$release = Invoke-RestMethod -Method Get -Uri $releaseApiUri -Headers $headers +$assets = @($release.assets | Where-Object { $_.name -like $AssetPattern }) + +if ($assets.Count -eq 0) { + $available = @($release.assets | ForEach-Object { $_.name }) -join ", " + throw "No assets matched '$AssetPattern'. Available assets: $available" +} + +if ($assets.Count -gt 1) { + $matches = @($assets | ForEach-Object { $_.name }) -join ", " + throw "AssetPattern '$AssetPattern' matched multiple assets: $matches" +} + +$asset = $assets[0] +$artifactDirectory = Resolve-ArtifactDirectory -Root $OutputDirectory -RepositoryName $Repository -ReleaseTag $release.tag_name +New-Item -ItemType Directory -Path $artifactDirectory -Force | Out-Null + +$artifactPath = Join-Path $artifactDirectory $asset.name +$downloadHeaders = Get-GitHubHeaders +$downloadHeaders["Accept"] = "application/octet-stream" +Invoke-WebRequest -Method Get -Uri $asset.browser_download_url -Headers $downloadHeaders -OutFile $artifactPath + +$artifactItem = Get-Item -LiteralPath $artifactPath +$artifactHash = Get-FileHash -LiteralPath $artifactPath -Algorithm SHA256 +$extractedFiles = @() + +if ($ExtractZip -and ($artifactItem.Extension -ieq ".zip")) { + $extractDirectory = Join-Path $artifactDirectory ([System.IO.Path]::GetFileNameWithoutExtension($artifactItem.Name)) + if (Test-Path -LiteralPath $extractDirectory) { + Remove-Item -LiteralPath $extractDirectory -Recurse -Force + } + + Expand-Archive -LiteralPath $artifactPath -DestinationPath $extractDirectory -Force + $extractedFiles = @(Get-ChildItem -LiteralPath $extractDirectory -File -Recurse | ForEach-Object { + $hash = Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256 + [ordered]@{ + path = $_.FullName + sha256 = $hash.Hash + length = $_.Length + } + }) +} + +$receipt = [ordered]@{ + schema = "github-release-asset.v1" + downloadedAtUtc = (Get-Date).ToUniversalTime().ToString("o") + repository = $Repository + releaseTag = $release.tag_name + releaseName = $release.name + releaseUrl = $release.html_url + assetPattern = $AssetPattern + assetName = $asset.name + assetUrl = $asset.browser_download_url + artifactPath = (Resolve-Path -LiteralPath $artifactPath).Path + sha256 = $artifactHash.Hash + length = $artifactItem.Length + extractedFiles = $extractedFiles +} + +$receiptPath = Join-Path $artifactDirectory "github-release-asset.receipt.json" +$receipt | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $receiptPath -Encoding utf8 + +Write-Host "Downloaded GitHub release asset:" +Write-Host " $artifactPath" +Write-Host "SHA256:" +Write-Host " $($artifactHash.Hash)" +Write-Host "Receipt:" +Write-Host " $receiptPath" + +if ($extractedFiles.Count -gt 0) { + Write-Host "Extracted files:" + $extractedFiles | ForEach-Object { + Write-Host " $($_.path)" + } +} diff --git a/.codex/scripts/Use-ModPairClientProofProfile.ps1 b/.codex/scripts/Use-ModPairClientProofProfile.ps1 new file mode 100644 index 0000000..961c482 --- /dev/null +++ b/.codex/scripts/Use-ModPairClientProofProfile.ps1 @@ -0,0 +1,338 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [ValidateSet('Install', 'Collect', 'Restore')] + [string]$Action, + + [string]$ClientRoot = "C:\Program Files (x86)\Steam\steamapps\common\VRisingCodex", + + [string]$ExpectedExecutableName = "VRising.exe", + + [string]$ProofRunDirectory = "", + + [string]$BackupDirectory = "", + + [string]$EvidenceLabel = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Resolve-Directory { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + $resolved = @(Resolve-Path -LiteralPath $Path -ErrorAction Stop) + if ($resolved.Count -ne 1) { + throw "Expected one directory for '$Path', found $($resolved.Count)." + } + + $item = Get-Item -LiteralPath $resolved[0].Path + if ($item.PSIsContainer) { + return $item + } + + throw "Expected directory '$Path'." +} + +function Assert-ChildPath { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + + [Parameter(Mandatory = $true)] + [string]$Child + ) + + $rootPath = [System.IO.Path]::GetFullPath($Root).TrimEnd('\') + $childPath = [System.IO.Path]::GetFullPath($Child) + if (-not $childPath.StartsWith($rootPath + "\", [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing path outside client root: $childPath" + } +} + +function Copy-DirectoryContents { + param( + [Parameter(Mandatory = $true)] + [string]$Source, + + [Parameter(Mandatory = $true)] + [string]$Destination + ) + + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + if (Test-Path -LiteralPath $Source) { + Get-ChildItem -LiteralPath $Source -Force | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $Destination -Recurse -Force + } + } +} + +function Clear-DirectoryContents { + param( + [Parameter(Mandatory = $true)] + [string]$Path + ) + + New-Item -ItemType Directory -Path $Path -Force | Out-Null + Get-ChildItem -LiteralPath $Path -Force | ForEach-Object { + Remove-Item -LiteralPath $_.FullName -Recurse -Force + } +} + +function Set-IniValue { + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$Section, + + [Parameter(Mandatory = $true)] + [string]$Key, + + [Parameter(Mandatory = $true)] + [string]$Value + ) + + $lines = @() + if (Test-Path -LiteralPath $Path) { + $lines = @(Get-Content -LiteralPath $Path) + } + + $sectionHeader = "[$Section]" + $sectionIndex = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i].Trim() -eq $sectionHeader) { + $sectionIndex = $i + break + } + } + + if ($sectionIndex -lt 0) { + if ($lines.Count -gt 0 -and $lines[$lines.Count - 1] -ne "") { + $lines += "" + } + + $lines += $sectionHeader + $lines += "$Key = $Value" + $lines | Set-Content -LiteralPath $Path -Encoding utf8 + return + } + + $insertIndex = $lines.Count + for ($i = $sectionIndex + 1; $i -lt $lines.Count; $i++) { + $trimmed = $lines[$i].Trim() + if ($trimmed.StartsWith("[") -and $trimmed.EndsWith("]")) { + $insertIndex = $i + break + } + + $escapedKey = [regex]::Escape($Key) + if ($trimmed -match "^$escapedKey\s*=") { + $lines[$i] = "$Key = $Value" + $lines | Set-Content -LiteralPath $Path -Encoding utf8 + return + } + } + + $before = @() + $after = @() + if ($insertIndex -gt 0) { + $before = $lines[0..($insertIndex - 1)] + } + + if ($insertIndex -lt $lines.Count) { + $after = $lines[$insertIndex..($lines.Count - 1)] + } + + @($before + "$Key = $Value" + $after) | Set-Content -LiteralPath $Path -Encoding utf8 +} + +function Apply-ConfigOverride { + param( + [Parameter(Mandatory = $true)] + [string]$Override, + + [Parameter(Mandatory = $true)] + [string]$ConfigDirectory + ) + + $parts = $Override.Split(':', 3) + if ($parts.Count -ne 3) { + throw "Invalid config override '$Override'. Expected ModName:ConfigFile:Section.Key=value." + } + + $configFile = $parts[1] + $setting = $parts[2] + $settingParts = $setting.Split('=', 2) + if ($settingParts.Count -ne 2) { + throw "Invalid config setting '$setting'. Expected Section.Key=value." + } + + $pathParts = $settingParts[0].Split('.', 2) + if ($pathParts.Count -ne 2) { + throw "Invalid config key '$($settingParts[0])'. Expected Section.Key." + } + + $configPath = Join-Path $ConfigDirectory $configFile + Set-IniValue -Path $configPath -Section $pathParts[0] -Key $pathParts[1] -Value $settingParts[1] +} + +function Get-ActiveStatePath { + param( + [Parameter(Mandatory = $true)] + [string]$ClientRootPath + ) + + Join-Path $ClientRootPath ".codex-client-proof-active.json" +} + +$clientRootItem = Resolve-Directory -Path $ClientRoot +$clientRootPath = $clientRootItem.FullName +$pluginsDirectory = Join-Path $clientRootPath "BepInEx\plugins" +$configDirectory = Join-Path $clientRootPath "BepInEx\config" +$logOutputPath = Join-Path $clientRootPath "BepInEx\LogOutput.log" +$errorLogPath = Join-Path $clientRootPath "BepInEx\ErrorLog.log" +$activeStatePath = Get-ActiveStatePath -ClientRootPath $clientRootPath + +if (-not (Test-Path -LiteralPath (Join-Path $clientRootPath $ExpectedExecutableName))) { + throw "BepInEx target root does not contain ${ExpectedExecutableName}: $clientRootPath" +} + +New-Item -ItemType Directory -Path $pluginsDirectory -Force | Out-Null +New-Item -ItemType Directory -Path $configDirectory -Force | Out-Null + +if ($Action -eq "Install") { + if ([string]::IsNullOrWhiteSpace($ProofRunDirectory)) { + throw "-ProofRunDirectory is required for Install." + } + + if (Test-Path -LiteralPath $activeStatePath) { + throw "An active proof profile already exists. Run Restore first: $activeStatePath" + } + + $proofRunItem = Resolve-Directory -Path $ProofRunDirectory + $proofRunPath = $proofRunItem.FullName + $stagePluginsDirectory = Join-Path $proofRunPath "stage\BepInEx\plugins" + $configOverridesPath = Join-Path $proofRunPath "config-overrides.txt" + $receiptPath = Join-Path $proofRunPath "receipt.json" + + if (-not (Test-Path -LiteralPath $stagePluginsDirectory)) { + throw "Proof run does not contain staged plugins: $stagePluginsDirectory" + } + + if (-not (Test-Path -LiteralPath $receiptPath)) { + throw "Proof run does not contain receipt.json: $receiptPath" + } + + $runId = Split-Path -Leaf $proofRunPath + $backupRoot = Join-Path $clientRootPath ".codex-client-proof-backups" + $backupPath = Join-Path $backupRoot $runId + Assert-ChildPath -Root $clientRootPath -Child $backupPath + + New-Item -ItemType Directory -Path $backupPath -Force | Out-Null + Copy-DirectoryContents -Source $pluginsDirectory -Destination (Join-Path $backupPath "plugins") + Copy-DirectoryContents -Source $configDirectory -Destination (Join-Path $backupPath "config") + + Clear-DirectoryContents -Path $pluginsDirectory + Copy-DirectoryContents -Source $stagePluginsDirectory -Destination $pluginsDirectory + + $configOverrides = @() + if (Test-Path -LiteralPath $configOverridesPath) { + $configOverrides = @(Get-Content -LiteralPath $configOverridesPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_ -ne "No config overrides recorded." }) + foreach ($override in $configOverrides) { + Apply-ConfigOverride -Override $override -ConfigDirectory $configDirectory + } + } + + $state = [ordered]@{ + schema = "mod-pair-client-proof-profile.v1" + action = "Install" + installedAtUtc = (Get-Date).ToUniversalTime().ToString("o") + clientRoot = $clientRootPath + proofRunDirectory = $proofRunPath + backupDirectory = $backupPath + stagedPluginDirectory = $stagePluginsDirectory + configOverrides = $configOverrides + } + + $state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $activeStatePath -Encoding utf8 + Write-Host "Installed proof profile into:" + Write-Host " $clientRootPath" + Write-Host "Backup:" + Write-Host " $backupPath" + Write-Host "Active state:" + Write-Host " $activeStatePath" + return +} + +if ($Action -eq "Collect") { + if ([string]::IsNullOrWhiteSpace($ProofRunDirectory)) { + if (-not (Test-Path -LiteralPath $activeStatePath)) { + throw "-ProofRunDirectory is required when no active profile state exists." + } + + $state = Get-Content -LiteralPath $activeStatePath -Raw | ConvertFrom-Json + $ProofRunDirectory = $state.proofRunDirectory + } + + $proofRunItem = Resolve-Directory -Path $ProofRunDirectory + $proofRunPath = $proofRunItem.FullName + $evidenceDirectoryName = "client-logs" + if (-not [string]::IsNullOrWhiteSpace($EvidenceLabel)) { + if ($EvidenceLabel -notmatch '^[a-z0-9][a-z0-9-]*$') { + throw "EvidenceLabel must be lowercase letters, numbers, or hyphens." + } + + $evidenceDirectoryName = "client-logs-$EvidenceLabel" + } + + $collectedDirectory = Join-Path $proofRunPath $evidenceDirectoryName + New-Item -ItemType Directory -Path $collectedDirectory -Force | Out-Null + + if (Test-Path -LiteralPath $logOutputPath) { + Copy-Item -LiteralPath $logOutputPath -Destination (Join-Path $collectedDirectory "LogOutput.log") -Force + } + + if (Test-Path -LiteralPath $errorLogPath) { + Copy-Item -LiteralPath $errorLogPath -Destination (Join-Path $collectedDirectory "ErrorLog.log") -Force + } + + Copy-DirectoryContents -Source $configDirectory -Destination (Join-Path $collectedDirectory "config") + Write-Host "Collected client proof evidence into:" + Write-Host " $collectedDirectory" + return +} + +if ($Action -eq "Restore") { + if ([string]::IsNullOrWhiteSpace($BackupDirectory)) { + if (-not (Test-Path -LiteralPath $activeStatePath)) { + throw "-BackupDirectory is required when no active profile state exists." + } + + $state = Get-Content -LiteralPath $activeStatePath -Raw | ConvertFrom-Json + $BackupDirectory = $state.backupDirectory + } + + $backupItem = Resolve-Directory -Path $BackupDirectory + $backupPath = $backupItem.FullName + Assert-ChildPath -Root $clientRootPath -Child $backupPath + + $backupPlugins = Join-Path $backupPath "plugins" + $backupConfig = Join-Path $backupPath "config" + + Clear-DirectoryContents -Path $pluginsDirectory + Clear-DirectoryContents -Path $configDirectory + Copy-DirectoryContents -Source $backupPlugins -Destination $pluginsDirectory + Copy-DirectoryContents -Source $backupConfig -Destination $configDirectory + + if (Test-Path -LiteralPath $activeStatePath) { + Remove-Item -LiteralPath $activeStatePath -Force + } + + Write-Host "Restored client profile from:" + Write-Host " $backupPath" +} diff --git a/.gitignore b/.gitignore index 4cd7a07..1e41ecd 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,9 @@ artifacts/ # VS Code .vscode/ +.codex-tmp/ +.codex/artifacts/ +.codex/runs/ *_i.c *_p.c @@ -305,4 +308,4 @@ TestPatches.cs Debugging.cs Notepad.cs VSystem.cs -Bridges.cs \ No newline at end of file +Bridges.cs diff --git a/Plugin.cs b/Plugin.cs index c0bb417..9d0a576 100644 --- a/Plugin.cs +++ b/Plugin.cs @@ -26,6 +26,7 @@ public static ManualLogSource LogInstance static ConfigEntry _professions; static ConfigEntry _quests; static ConfigEntry _shiftSlot; + static ConfigEntry _attributeBuffs; static ConfigEntry _eclipsed; static ConfigEntry _useEmberglassBridge; public static bool Leveling @@ -44,6 +45,8 @@ public static bool Quests => _quests.Value; public static bool ShiftSlot => _shiftSlot.Value; + public static bool AttributeBuffsEnabled + => _attributeBuffs.Value; public static bool Eclipsed => _eclipsed.Value; public static bool UseEmberglassBridge @@ -82,6 +85,7 @@ static void InitConfig() _professions = InitConfigEntry("UIOptions", "Professions", true, "Enable/Disable the professions tab, requires both ClientCompanion/ProfessionSystem to be enabled in Bloodcraft."); _quests = InitConfigEntry("UIOptions", "QuestTrackers", true, "Enable/Disable the quest tracker, requires both ClientCompanion/QuestSystem to be enabled in Bloodcraft."); _shiftSlot = InitConfigEntry("UIOptions", "ShiftSlot", true, "Enable/Disable the shift slot, requires both ClientCompanion and shift slot spell to be enabled in Bloodcraft."); + _attributeBuffs = InitConfigEntry("UIOptions", "AttributeBuffs", false, "Enable/Disable applying Bloodcraft bonus stats to the character attribute buffer. Leave disabled if another client UI mod is also installed or if startup crashes occur."); _eclipsed = InitConfigEntry("UIOptions", "Eclipsed", true, "Set to false for slower update intervals (0.1s -> 1s) if performance is negatively impacted."); _useEmberglassBridge = InitConfigEntry("UIOptions", "UseEmberglassBridge", false, "Use Emberglass for the Bloodcraft/Eclipse bridge when Emberglass is installed. Falls back to the legacy chat bridge when disabled or unavailable."); } diff --git a/Services/CanvasService.cs b/Services/CanvasService.cs index c2d19c4..ea0f663 100644 --- a/Services/CanvasService.cs +++ b/Services/CanvasService.cs @@ -44,6 +44,7 @@ static Entity LocalCharacter static BufferLookup ModifyUnitStatBuffLookup => ClientChatSystemPatch.ModifyUnitStatBuffLookup; static bool Eclipsed { get; } = Plugin.Eclipsed; + static bool AttributeBuffs => Plugin.AttributeBuffsEnabled; public static WaitForSeconds WaitForSeconds { get; } = Eclipsed ? new WaitForSeconds(0.1f) : new WaitForSeconds(1f); @@ -102,7 +103,7 @@ public static IEnumerator CanvasUpdateLoop() } } - var buffer = TryGetSourceBuffer(); + var buffer = AttributeBuffs ? TryGetSourceBuffer() : default; if (_legacyBar) { @@ -130,7 +131,7 @@ public static IEnumerator CanvasUpdateLoop() } } - if (StatBuffActive) + if (StatBuffActive && AttributeBuffs) { try { @@ -417,7 +418,14 @@ public static void InitializeAbilitySlotButtons() if (keyValuePair.Value && AbilitySlotNamePaths.ContainsKey(keyValuePair.Key)) { GameObject abilitySlotObject = GameObject.Find(AbilitySlotNamePaths[keyValuePair.Key]); - SimpleStunButton stunButton = abilitySlotObject.AddComponent(); + if (abilitySlotObject == null) + { + Core.Log.LogWarning($"Ability slot '{keyValuePair.Key}' not found; skipping click toggle binding."); + continue; + } + + SimpleStunButton stunButton = abilitySlotObject.GetComponent() + ?? abilitySlotObject.AddComponent(); if (keyValuePair.Key.Equals(UIElement.Professions)) { @@ -1726,10 +1734,24 @@ public static void ConfigureShiftSlot(ref GameObject shiftSlotObject, ref Abilit if (abilityDummyObject != null) { + GameObject abilitiesObject = GameObject.Find("HUDCanvas(Clone)/BottomBarCanvas/BottomBar(Clone)/Content/Background/AbilityBar/Abilities/"); + if (abilitiesObject == null) + { + Core.Log.LogWarning("AbilityBar abilities container is null; shift slot disabled."); + return; + } + shiftSlotObject = UnityEngine.Object.Instantiate(abilityDummyObject); RectTransform rectTransform = shiftSlotObject.GetComponent(); + RectTransform abilitiesTransform = abilitiesObject.GetComponent(); - RectTransform abilitiesTransform = GameObject.Find("HUDCanvas(Clone)/BottomBarCanvas/BottomBar(Clone)/Content/Background/AbilityBar/Abilities/").GetComponent(); + if (rectTransform == null || abilitiesTransform == null) + { + Core.Log.LogWarning("AbilityBar transform data is unavailable; shift slot disabled."); + UnityEngine.Object.Destroy(shiftSlotObject); + shiftSlotObject = null; + return; + } UnityEngine.Object.DontDestroyOnLoad(shiftSlotObject); SceneManager.MoveGameObjectToScene(shiftSlotObject, SceneManager.GetSceneByName("VRisingWorld")); @@ -1778,6 +1800,14 @@ public static void ConfigureShiftSlot(ref GameObject shiftSlotObject, ref Abilit abilityIcon.SetActive(true); keybindObject = GameObject.Find("HUDCanvas(Clone)/BottomBarCanvas/BottomBar(Clone)/Content/Background/AbilityBar/Abilities/AbilityBarEntry_Dummy(Clone)/KeybindBackground/Keybind/"); + if (keybindObject == null) + { + Core.Log.LogWarning("Shift slot keybind object is null; shift slot disabled."); + UnityEngine.Object.Destroy(shiftSlotObject); + shiftSlotObject = null; + return; + } + TextMeshProUGUI keybindText = keybindObject.GetComponent(); keybindText.SetText("Shift"); keybindText.enabled = true; diff --git a/Services/EmberglassEclipseBridge.cs b/Services/EmberglassEclipseBridge.cs index 5a8fb4f..bcb7fb3 100644 --- a/Services/EmberglassEclipseBridge.cs +++ b/Services/EmberglassEclipseBridge.cs @@ -21,6 +21,7 @@ internal static class EmberglassEclipseBridge static PropertyInfo _isReady; static EventInfo _onReady; static EventInfo _onClientReady; + static string _pendingRegistrationMessage; public static void Initialize() { @@ -77,11 +78,18 @@ public static bool TrySendRegistration(string message) if (!IsClientReady()) { + _pendingRegistrationMessage = message; LogNotReady(); return false; } - return TrySendRegistrationNow(message); + bool sent = TrySendRegistrationNow(message); + if (sent) + { + _pendingRegistrationMessage = null; + } + + return sent; } static bool TrySendRegistrationNow(string message) @@ -125,11 +133,13 @@ static void OnClientReady() if (_clientReadyLogged) { + TrySendPendingRegistration(); return; } _clientReadyLogged = true; Core.Log.LogInfo("[EclipseBridge:Emberglass] client ready"); + TrySendPendingRegistration(); } static void OnReady(User user) @@ -220,6 +230,21 @@ static bool IsReady() return _isReady?.GetValue(null) is true; } + static void TrySendPendingRegistration() + { + if (string.IsNullOrWhiteSpace(_pendingRegistrationMessage) || !_available || _disabledForSession) + { + return; + } + + string message = _pendingRegistrationMessage; + if (TrySendRegistrationNow(message)) + { + _pendingRegistrationMessage = null; + Core.Log.LogInfo("[EclipseBridge:Emberglass] pending registration sent after client ready"); + } + } + static void LogUnavailable(string reason) { if (_unavailableLogged) @@ -238,7 +263,7 @@ static void LogNotReady() } _notReadyLogged = true; - Core.Log.LogInfo("[EclipseBridge:Emberglass] client not ready; using ChatMessage bridge"); + Core.Log.LogInfo("[EclipseBridge:Emberglass] client not ready; queued Emberglass retry and using ChatMessage bridge"); } static void DisableForSession(string reason) From 572d8fccd95145e47fbee0e7072cda56169c4cc1 Mon Sep 17 00:00:00 2001 From: mfoltz Date: Sun, 17 May 2026 13:30:33 -0500 Subject: [PATCH 3/5] Guard proof staging against duplicate plugin names --- .../mod-pair-compatibility/README.md | 1 + .codex/scripts/New-BepInExPluginSetProof.ps1 | 21 ++++++++ .../scripts/New-ModPairCompatibilityProof.ps1 | 49 +++++++++++++++++-- 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/.codex/runtime-proofs/mod-pair-compatibility/README.md b/.codex/runtime-proofs/mod-pair-compatibility/README.md index e1d4edf..01048e8 100644 --- a/.codex/runtime-proofs/mod-pair-compatibility/README.md +++ b/.codex/runtime-proofs/mod-pair-compatibility/README.md @@ -18,6 +18,7 @@ Use these names in receipts, folders, and summaries so future community troubles Stop and mark the run inconclusive when: - the staged plugin inventory contains unrelated mods; +- two staged artifacts would land with the same plugin filename; - the proof requires mutating the live game profile without a restorable backup; - the client/server build, BepInEx pack, or mod versions cannot be recorded; - the observed result is only a manual impression with no retained log or receipt; diff --git a/.codex/scripts/New-BepInExPluginSetProof.ps1 b/.codex/scripts/New-BepInExPluginSetProof.ps1 index 82ebc6f..25beb6f 100644 --- a/.codex/scripts/New-BepInExPluginSetProof.ps1 +++ b/.codex/scripts/New-BepInExPluginSetProof.ps1 @@ -40,11 +40,32 @@ function Resolve-ExistingFile { throw "Expected a file path, got directory '$Path'." } +function Assert-UniqueArtifactFileNames { + param( + [Parameter(Mandatory = $true)] + [System.IO.FileInfo[]]$Artifacts + ) + + $duplicates = @($Artifacts | Group-Object -Property { $_.Name.ToLowerInvariant() } | Where-Object { $_.Count -gt 1 }) + if ($duplicates.Count -eq 0) { + return + } + + $messages = @() + foreach ($duplicate in $duplicates) { + $messages += "{0}: {1}" -f $duplicate.Group[0].Name, (($duplicate.Group | ForEach-Object { $_.FullName }) -join "; ") + } + + throw "Duplicate staged plugin filenames are not supported because BepInEx plugin staging would overwrite files. Rename or wrap one artifact, or stage this scenario manually. Duplicates: $($messages -join " | ")" +} + if ([string]::IsNullOrWhiteSpace($RunId)) { $RunId = Get-Date -Format "yyyyMMdd-HHmmss" } $artifacts = @($PluginArtifact | ForEach-Object { Resolve-ExistingFile -Path $_ }) +Assert-UniqueArtifactFileNames -Artifacts $artifacts + $runDirectory = Join-Path $RunRoot (Join-Path $ProfileLabel (Join-Path $TargetRole $RunId)) $stagePluginDirectory = Join-Path $runDirectory "stage\BepInEx\plugins" New-Item -ItemType Directory -Path $stagePluginDirectory -Force | Out-Null diff --git a/.codex/scripts/New-ModPairCompatibilityProof.ps1 b/.codex/scripts/New-ModPairCompatibilityProof.ps1 index 5baefaf..2440a80 100644 --- a/.codex/scripts/New-ModPairCompatibilityProof.ps1 +++ b/.codex/scripts/New-ModPairCompatibilityProof.ps1 @@ -81,6 +81,28 @@ function Add-Artifact { } } +function Assert-UniqueStageFileNames { + param( + [Parameter(Mandatory = $true)] + [object[]]$StagePlan + ) + + $duplicates = @($StagePlan | Group-Object -Property { $_.Artifact.Name.ToLowerInvariant() } | Where-Object { $_.Count -gt 1 }) + if ($duplicates.Count -eq 0) { + return + } + + $messages = @() + foreach ($duplicate in $duplicates) { + $entries = @($duplicate.Group | ForEach-Object { + "{0}:{1}={2}" -f $_.Role, $_.ModName, $_.Artifact.FullName + }) + $messages += "{0}: {1}" -f $duplicate.Group[0].Artifact.Name, ($entries -join "; ") + } + + throw "Duplicate staged plugin filenames are not supported because BepInEx plugin staging would overwrite files. Rename or wrap one artifact, or stage this scenario manually. Duplicates: $($messages -join " | ")" +} + if ([string]::IsNullOrWhiteSpace($RunId)) { $RunId = Get-Date -Format "yyyyMMdd-HHmmss" } @@ -93,18 +115,37 @@ $runDirectory = Join-Path $RunRoot (Join-Path $PairLabel (Join-Path $ProofMode $ $stagePluginDirectory = Join-Path $runDirectory "stage\BepInEx\plugins" New-Item -ItemType Directory -Path $stagePluginDirectory -Force | Out-Null -$artifacts = @() +$stagePlan = @() if (($ProofMode -eq "subject-only") -or ($ProofMode -eq "combined-control") -or ($ProofMode -eq "combined-candidate")) { - $artifacts += Add-Artifact -Artifact $subjectArtifact -Role "subjectMod" -ModName $SubjectModName -PluginDirectory $stagePluginDirectory + $stagePlan += [pscustomobject]@{ + Artifact = $subjectArtifact + Role = "subjectMod" + ModName = $SubjectModName + } } if (($ProofMode -eq "peer-only") -or ($ProofMode -eq "combined-control") -or ($ProofMode -eq "combined-candidate")) { - $artifacts += Add-Artifact -Artifact $peerArtifact -Role "peerMod" -ModName $PeerModName -PluginDirectory $stagePluginDirectory + $stagePlan += [pscustomobject]@{ + Artifact = $peerArtifact + Role = "peerMod" + ModName = $PeerModName + } } foreach ($supportArtifact in $supportArtifacts) { $supportName = [System.IO.Path]::GetFileNameWithoutExtension($supportArtifact.Name) - $artifacts += Add-Artifact -Artifact $supportArtifact -Role "supportMod" -ModName $supportName -PluginDirectory $stagePluginDirectory + $stagePlan += [pscustomobject]@{ + Artifact = $supportArtifact + Role = "supportMod" + ModName = $supportName + } +} + +Assert-UniqueStageFileNames -StagePlan $stagePlan + +$artifacts = @() +foreach ($stageItem in $stagePlan) { + $artifacts += Add-Artifact -Artifact $stageItem.Artifact -Role $stageItem.Role -ModName $stageItem.ModName -PluginDirectory $stagePluginDirectory } $inventoryPath = Join-Path $runDirectory "plugin-inventory.txt" From b66e074c5984fa0795f0514959556476ce1e227d Mon Sep 17 00:00:00 2001 From: mfoltz Date: Sun, 17 May 2026 13:38:42 -0500 Subject: [PATCH 4/5] Honor proof mode artifact requirements --- .../scripts/New-ModPairCompatibilityProof.ps1 | 78 +++++++++++++++---- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/.codex/scripts/New-ModPairCompatibilityProof.ps1 b/.codex/scripts/New-ModPairCompatibilityProof.ps1 index 2440a80..4b99208 100644 --- a/.codex/scripts/New-ModPairCompatibilityProof.ps1 +++ b/.codex/scripts/New-ModPairCompatibilityProof.ps1 @@ -8,16 +8,12 @@ param( [ValidateSet('subject-only', 'peer-only', 'combined-control', 'combined-candidate')] [string]$ProofMode, - [Parameter(Mandatory = $true)] [string]$SubjectModName, - [Parameter(Mandatory = $true)] [string]$SubjectModArtifact, - [Parameter(Mandatory = $true)] [string]$PeerModName, - [Parameter(Mandatory = $true)] [string]$PeerModArtifact, [string[]]$SupportModArtifact = @(), @@ -81,6 +77,37 @@ function Add-Artifact { } } +function Test-RequiresSubjectArtifact { + param( + [Parameter(Mandatory = $true)] + [string]$Mode + ) + + ($Mode -eq "subject-only") -or ($Mode -eq "combined-control") -or ($Mode -eq "combined-candidate") +} + +function Test-RequiresPeerArtifact { + param( + [Parameter(Mandatory = $true)] + [string]$Mode + ) + + ($Mode -eq "peer-only") -or ($Mode -eq "combined-control") -or ($Mode -eq "combined-candidate") +} + +function Assert-RequiredValue { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [string]$Value + ) + + if ([string]::IsNullOrWhiteSpace($Value)) { + throw "$Name is required for ProofMode '$ProofMode'." + } +} + function Assert-UniqueStageFileNames { param( [Parameter(Mandatory = $true)] @@ -107,8 +134,23 @@ if ([string]::IsNullOrWhiteSpace($RunId)) { $RunId = Get-Date -Format "yyyyMMdd-HHmmss" } -$subjectArtifact = Resolve-ExistingFile -Path $SubjectModArtifact -$peerArtifact = Resolve-ExistingFile -Path $PeerModArtifact +$requiresSubjectArtifact = Test-RequiresSubjectArtifact -Mode $ProofMode +$requiresPeerArtifact = Test-RequiresPeerArtifact -Mode $ProofMode + +$subjectArtifact = $null +if ($requiresSubjectArtifact) { + Assert-RequiredValue -Name "-SubjectModName" -Value $SubjectModName + Assert-RequiredValue -Name "-SubjectModArtifact" -Value $SubjectModArtifact + $subjectArtifact = Resolve-ExistingFile -Path $SubjectModArtifact +} + +$peerArtifact = $null +if ($requiresPeerArtifact) { + Assert-RequiredValue -Name "-PeerModName" -Value $PeerModName + Assert-RequiredValue -Name "-PeerModArtifact" -Value $PeerModArtifact + $peerArtifact = Resolve-ExistingFile -Path $PeerModArtifact +} + $supportArtifacts = @($SupportModArtifact | ForEach-Object { Resolve-ExistingFile -Path $_ }) $runDirectory = Join-Path $RunRoot (Join-Path $PairLabel (Join-Path $ProofMode $RunId)) @@ -116,7 +158,7 @@ $stagePluginDirectory = Join-Path $runDirectory "stage\BepInEx\plugins" New-Item -ItemType Directory -Path $stagePluginDirectory -Force | Out-Null $stagePlan = @() -if (($ProofMode -eq "subject-only") -or ($ProofMode -eq "combined-control") -or ($ProofMode -eq "combined-candidate")) { +if ($requiresSubjectArtifact) { $stagePlan += [pscustomobject]@{ Artifact = $subjectArtifact Role = "subjectMod" @@ -124,7 +166,7 @@ if (($ProofMode -eq "subject-only") -or ($ProofMode -eq "combined-control") -or } } -if (($ProofMode -eq "peer-only") -or ($ProofMode -eq "combined-control") -or ($ProofMode -eq "combined-candidate")) { +if ($requiresPeerArtifact) { $stagePlan += [pscustomobject]@{ Artifact = $peerArtifact Role = "peerMod" @@ -177,14 +219,18 @@ $receipt = [ordered]@{ createdAtUtc = (Get-Date).ToUniversalTime().ToString("o") repository = (Resolve-Path -LiteralPath ".").Path gitCommit = $gitCommit - subjectMod = [ordered]@{ - name = $SubjectModName - artifactPath = $subjectArtifact.FullName - } - peerMod = [ordered]@{ - name = $PeerModName - artifactPath = $peerArtifact.FullName - } + subjectMod = if ($subjectArtifact -ne $null) { + [ordered]@{ + name = $SubjectModName + artifactPath = $subjectArtifact.FullName + } + } else { $null } + peerMod = if ($peerArtifact -ne $null) { + [ordered]@{ + name = $PeerModName + artifactPath = $peerArtifact.FullName + } + } else { $null } supportMods = @($supportArtifacts | ForEach-Object { [ordered]@{ name = [System.IO.Path]::GetFileNameWithoutExtension($_.Name) From 459cccf70eba29d626bced33a73211f98a09e71e Mon Sep 17 00:00:00 2001 From: mfoltz Date: Sun, 17 May 2026 13:57:01 -0500 Subject: [PATCH 5/5] Harden client proof profile install safety --- .../scripts/Use-ModPairClientProofProfile.ps1 | 55 +++++++++++++------ 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/.codex/scripts/Use-ModPairClientProofProfile.ps1 b/.codex/scripts/Use-ModPairClientProofProfile.ps1 index 961c482..7b1ca1a 100644 --- a/.codex/scripts/Use-ModPairClientProofProfile.ps1 +++ b/.codex/scripts/Use-ModPairClientProofProfile.ps1 @@ -49,7 +49,7 @@ function Assert-ChildPath { $rootPath = [System.IO.Path]::GetFullPath($Root).TrimEnd('\') $childPath = [System.IO.Path]::GetFullPath($Child) if (-not $childPath.StartsWith($rootPath + "\", [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Refusing path outside client root: $childPath" + throw "Refusing path outside root '$rootPath': $childPath" } } @@ -151,7 +151,7 @@ function Set-IniValue { @($before + "$Key = $Value" + $after) | Set-Content -LiteralPath $Path -Encoding utf8 } -function Apply-ConfigOverride { +function ConvertTo-ConfigOverride { param( [Parameter(Mandatory = $true)] [string]$Override, @@ -178,7 +178,24 @@ function Apply-ConfigOverride { } $configPath = Join-Path $ConfigDirectory $configFile - Set-IniValue -Path $configPath -Section $pathParts[0] -Key $pathParts[1] -Value $settingParts[1] + Assert-ChildPath -Root $ConfigDirectory -Child $configPath + + [pscustomobject]@{ + Raw = $Override + ConfigPath = $configPath + Section = $pathParts[0] + Key = $pathParts[1] + Value = $settingParts[1] + } +} + +function Apply-ConfigOverride { + param( + [Parameter(Mandatory = $true)] + [pscustomobject]$Override + ) + + Set-IniValue -Path $Override.ConfigPath -Section $Override.Section -Key $Override.Key -Value $Override.Value } function Get-ActiveStatePath { @@ -228,26 +245,20 @@ if ($Action -eq "Install") { throw "Proof run does not contain receipt.json: $receiptPath" } - $runId = Split-Path -Leaf $proofRunPath - $backupRoot = Join-Path $clientRootPath ".codex-client-proof-backups" - $backupPath = Join-Path $backupRoot $runId - Assert-ChildPath -Root $clientRootPath -Child $backupPath - - New-Item -ItemType Directory -Path $backupPath -Force | Out-Null - Copy-DirectoryContents -Source $pluginsDirectory -Destination (Join-Path $backupPath "plugins") - Copy-DirectoryContents -Source $configDirectory -Destination (Join-Path $backupPath "config") - - Clear-DirectoryContents -Path $pluginsDirectory - Copy-DirectoryContents -Source $stagePluginsDirectory -Destination $pluginsDirectory - $configOverrides = @() + $configOverrideRecords = @() if (Test-Path -LiteralPath $configOverridesPath) { $configOverrides = @(Get-Content -LiteralPath $configOverridesPath | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $_ -ne "No config overrides recorded." }) foreach ($override in $configOverrides) { - Apply-ConfigOverride -Override $override -ConfigDirectory $configDirectory + $configOverrideRecords += ConvertTo-ConfigOverride -Override $override -ConfigDirectory $configDirectory } } + $runId = Split-Path -Leaf $proofRunPath + $backupRoot = Join-Path $clientRootPath ".codex-client-proof-backups" + $backupPath = Join-Path $backupRoot $runId + Assert-ChildPath -Root $clientRootPath -Child $backupPath + $state = [ordered]@{ schema = "mod-pair-client-proof-profile.v1" action = "Install" @@ -259,7 +270,19 @@ if ($Action -eq "Install") { configOverrides = $configOverrides } + New-Item -ItemType Directory -Path $backupPath -Force | Out-Null + Copy-DirectoryContents -Source $pluginsDirectory -Destination (Join-Path $backupPath "plugins") + Copy-DirectoryContents -Source $configDirectory -Destination (Join-Path $backupPath "config") + $state | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $activeStatePath -Encoding utf8 + + Clear-DirectoryContents -Path $pluginsDirectory + Copy-DirectoryContents -Source $stagePluginsDirectory -Destination $pluginsDirectory + + foreach ($overrideRecord in $configOverrideRecords) { + Apply-ConfigOverride -Override $overrideRecord + } + Write-Host "Installed proof profile into:" Write-Host " $clientRootPath" Write-Host "Backup:"