Skip to content

Add Vortex coexistence & Collections handling (Unit K) - #25

Merged
TheValiantOne merged 1 commit into
mainfrom
feature/vortex-coexistence-collections
Aug 11, 2026
Merged

Add Vortex coexistence & Collections handling (Unit K)#25
TheValiantOne merged 1 commit into
mainfrom
feature/vortex-coexistence-collections

Conversation

@TheValiantOne

Copy link
Copy Markdown
Owner

Summary

Unit K of the Vortex companion extension: detect, warn, reconcile when Vortex's built-in game-witcher3 Script Merger integration modifies WSM merge state out from under this extension. Per docs/vortex-extension-design.md §0/Open Question 1, there is no vortex-api mechanism to intercept, disable, or block another extension's registrations or event handlers (re-confirmed against the published lib/api.d.ts), so this unit cannot prevent either hazard - only notice, warn distinctly, and point the user at existing remediation (the merge-history dashlet, "Resolve Script Conflicts"). Per the task's instructions, docs/vortex-extension-design.md itself is intentionally untouched (a parallel unit is reconciling it separately) - the design rationale below, and in code comments, is the substitute channel.

Two mechanistic corrections to the design doc's own framing

Found by fetching and reading the real Nexus-Mods/Vortex monorepo source directly (gh api repos/Nexus-Mods/Vortex/contents/extensions/games/game-witcher3/src/{eventHandlers,mergeBackup,common,index}.ts), not carried over from the design doc's summary. Both changed how detection had to work here, so they're recorded here and in src/coexistenceGuard.ts's own header doc comment with the same citations:

  1. Each companion binary has its own separate MergeInventory.xml by default. mergeBackup.ts's handleMergedScripts resolves MERGE_INV_MANIFEST ("MergeInventory.xml", common.ts) against path.dirname(scriptMergerTool.path), where scriptMergerTool is the discovery entry for SCRIPT_MERGER_ID = "W3ScriptMerger" - game-witcher3's own discovered tool (typically the IDCs/WitcherScriptMerger fork it auto-downloads). This extension registers a distinct tool id, WitcherScriptMergerEnhanced (discoveredTool.ts), at its own acquired path. Per WSM's own Paths.Inventory, resolved against Environment.CurrentDirectory (pinned to AppContext.BaseDirectory before dispatching to merge/mcp), each binary's MergeInventory.xml lives next to that binary - not a shared file. What genuinely is shared: handleMergedScripts's mergedScriptsPath = path.join(gamePath, "Mods", mergedModName), the real, physical merged-mod-content folder every WSM instance reads/writes via identical GameDirectory/ModsDirectory resolution. This is why coexistenceGuard.ts snapshots a plain recursive listing of that real folder alongside a list_merges-derived signature, rather than relying on list_merges/MergeInventory.xml alone - the folder listing is the one signal that also catches drift in the default (separate-binaries) configuration; list_merges alone would only catch it if a user pointed game-witcher3's own W3ScriptMerger discovery at the exact same binary this extension acquired.
  2. The per-profile backup/restore hazard is opt-in per profile, not automatic. mergeBackup.ts's genBaseProps returns undefined (a no-op) unless state.persistent.profiles[profileId].features.local_merges is true, and game-witcher3's own index.ts registers that feature (context.registerProfileFeature("local_merges", "boolean", "settings", "Profile Data", "This profile will store and restore profile specific data (merged scripts, loadorder, etc) when switching profiles", ...)) as an ordinary Vortex profile toggle defaulting to unset/false. So hazard 2 does not fire "on every profile switch" as the design doc's own phrasing suggests - only for a profile the user has explicitly opted into that feature.

Design

  • Trigger points (index.ts): gamemode-activated (session start / switching into Witcher 3 mid-session), profile-did-change, and did-deploy - the last positioned above the existing isModOrDependencyInstallActive early-return, since installing a Collection is exactly the window importScriptMerges() (hazard 1) fires in; a check gated behind that same early-return would never see the deployment where the overwrite happened.
  • Not wired off profile-will-change. That event is a plain, synchronous events.on emit (EVENTS.md marks will-deploy/did-deploy "Async.", not this one), and game-witcher3's own onProfileWillChange handler is registered the same synchronous way (confirmed in its index.ts) - Vortex never awaits it, so there's no reliable way to bracket its file moves from a second listener on the same event. profile-did-change (after the switch) is used instead; every trigger point re-observes settled state rather than trying to catch a change mid-flight.
  • A deliberate second WSM process spawn on did-deploy, not amortized onto conflictScan.ts's scanWsmConflicts client. Amortizing would either widen that module's own established, separately-tested return shape and inFlightScan coalescing, or bypass it with a second connect anyway - more coupling than a rare-event detector (not a per-deployment hot path) justifies. computeMergeStateSnapshot's two independent MCP calls (get_status, list_merges) run concurrently via Promise.all, and the connect uses a bounded 15s per-request timeout (matching conflictScan.ts's own POST_DEPLOY_SCAN_TIMEOUT_MS) since this sits inside Vortex's awaited did-deploy window on that trigger path.
  • Reconciliation after this extension's own writes. resolveAction.ts's runMergeConflictsWorkflow calls coexistenceGuard.ts after every mergeConflicts call, but not the same way for both: a real merge (dryRun !== true) silently calls recordOwnMergeStateSnapshot (this extension's own write, known-good by definition); a dry-run preview instead goes through checkCoexistenceDrift (compare-and-warn), since a preview performs no write - silently re-baselining there (an earlier version of this branch did exactly that) would let a preview-then-cancel workflow permanently erase evidence of real, undetected drift with no notification ever shown.
  • Notification distinctness. A separate id/wording from the ordinary conflicts notification ("something changed your WSM merge state outside this extension", not "you have new conflicts"), not suppressible by default, and dismisses the ordinary conflicts notification (which was computed against now-stale state) alongside resetting conflictNotifications.ts's own suppression signature - otherwise a change that fully resolved every conflict would never get its stale "N unresolved" notification cleared, since the reset alone produces the same empty-signature value a fresh session starts with.

Testing

  • src/coexistenceGuard.test.ts (new, 26 tests): signature helpers, folder-listing walk (including a symlink-handling regression test, self-skipping on EPERM), computeMergeStateSnapshot, checkCoexistenceDrift (including the fixed baseline-commit-after-success ordering and the stale-notification dismissal), refreshCoexistenceState.
  • test/coexistenceGuard.integration.test.ts (new): spawns a real WitcherScriptMerger.Headless.exe, drives a genuine auto-solving merge, and proves computeMergeStateSnapshot/checkCoexistenceDrift react correctly to real before/after state.
  • src/index.test.ts / src/resolveAction.test.ts: updated/extended for the new wiring and the preview-vs-real-merge branch.

Full pipeline (typecheck && build && lint && test && test:integration) passes: 214 tests total.

Review

Ran /code-review; addressed all findings before this PR:

  • A severe bug where the dry-run preview path unconditionally silently re-baselined (same as the real merge), which could hide genuine external drift behind a mere preview-and-cancel - fixed by branching on dryRun.
  • gamemode-activated was documented as a wired trigger point but never actually connected to refreshCoexistenceState - fixed, with a regression test that would have caught it.
  • A misleading test title/assertion that claimed the conflict scan continues when refreshCoexistenceState rejects, while its own neighboring comment (and the actual code) said the opposite - corrected.
  • computeMergeStateSnapshot's two independent MCP calls parallelized via Promise.all.
  • walkFilesRecursive silently skipped symlinked entries - fixed to record them via lstat, with a self-skipping-on-EPERM regression test.

A second, independent pass via the advisor tool caught two more before commit: checkCoexistenceDrift committing its baseline before the notification succeeded (should retry on failure, mirroring notifyConflictsIfChanged's own documented reasoning) - fixed; and conflictNotifications.ts's resetConflictNotificationState doc comment claiming "no production caller should ever need it," now false since coexistenceGuard.ts is a real production caller - fixed.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah

Detect, warn, and reconcile when Vortex's built-in game-witcher3 Script
Merger integration modifies WSM merge state out from under this
companion extension - Collections import (importScriptMerges) and
per-profile merge backup/restore (mergeBackup.ts's
storeToProfile/restoreFromProfile) can both overwrite merge output with
no way for this extension to intercept them (vortex-api has no
extension-blocking API). New src/coexistenceGuard.ts snapshots two
independent signals (a list_merges-derived signature, and a plain
recursive listing of the real merged-mod folder - the latter needed
because each companion binary keeps its own separate MergeInventory.xml,
so the folder is the one resource genuinely shared with game-witcher3's
own discovered tool) and compares them at three trigger points wired
from index.ts: gamemode-activated, profile-did-change (not
profile-will-change, which Vortex never awaits), and did-deploy -
positioned above the isModOrDependencyInstallActive gate so a Collection
install is still observed. resolveAction.ts's own merge workflow
reconciles the baseline afterward: silently for a real merge (this
extension's own write), but through the same compare-and-warn path for
a dry-run preview, so a preview-then-cancel can't silently erase
evidence of undetected drift.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GXAuGMLB44T5Zv5o5ZzKah
@TheValiantOne
TheValiantOne merged commit c6c2bb5 into main Aug 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant