Skip to content

Add mod installer adaptor contract#22495

Merged
halgari merged 6 commits intomasterfrom
halgari/adaptors-mod-installers
Apr 16, 2026
Merged

Add mod installer adaptor contract#22495
halgari merged 6 commits intomasterfrom
halgari/adaptors-mod-installers

Conversation

@halgari
Copy link
Copy Markdown
Contributor

@halgari halgari commented Apr 15, 2026

Summary

Introduces IGameInstallerService, the adaptor contract that decides where each file in a mod archive should be deployed. Adaptors can implement install() directly or declare glob-based StopPattern rules routed by resolveStopPatterns(). Cyberpunk 2077 ships with a concrete implementation covering the canonical mod layouts from the community redux extension.

To feed the contract, the main process now assembles a StorePathSnapshot (host OS, game OS, resolved bases per platform) at game discovery and hands it to the adaptor via adaptors:build-snapshot. The renderer bridge caches the snapshot plus the resolved GamePaths per game, and exposes a private installer registry through getAdaptorInstaller(gameId) for future InstallManager integration.

Adds scripts/test-adaptor.ts, a CLI that pulls a mod's file list from Nexus and runs it through a chosen adaptor's installer, rendering the source/anchor/destination mapping as a table.

Example run: Cyber Engine Tweaks

$ npx tsx scripts/test-adaptor.ts https://www.nexusmods.com/cyberpunk2077/mods/107

Mod:     CET 1.37.1 - Scripting fixes (cyberpunk2077 / 107)
Adaptor: cyberpunk2077 -> game:cyberpunk2077
File:    CET 1.37.1 - Scripting fixes-107-1-37-1-1759193708.zip (MAIN)

Archive: 19 files, 16 mapped, 3 unmapped
By anchor: game=16

Mapped (representative, full paths):
  bin/x64/global.ini                                                -> game/bin/x64/global.ini
  bin/x64/plugins/cyber_engine_tweaks/ThirdParty_LICENSES           -> game/bin/x64/plugins/cyber_engine_tweaks/ThirdParty_LICENSES
  bin/x64/plugins/cyber_engine_tweaks/fonts/NotoSans-Regular.ttf    -> game/bin/x64/plugins/cyber_engine_tweaks/fonts/NotoSans-Regular.ttf
  bin/x64/plugins/cyber_engine_tweaks/scripts/IconGlyphs/init.lua   -> game/bin/x64/plugins/cyber_engine_tweaks/scripts/IconGlyphs/init.lua
  bin/x64/plugins/cyber_engine_tweaks/tweakdb/tweakdb.str           -> game/bin/x64/plugins/cyber_engine_tweaks/tweakdb/tweakdb.str
  ...

Unmapped (3):
  bin/x64/LICENSE
  bin/x64/plugins/cyber_engine_tweaks.asi
  bin/x64/version.dll

The unmapped files are the CET ASI loader itself (cyber_engine_tweaks.asi, version.dll) plus a bare LICENSE. The current stop-pattern set does not yet route bootstrap DLLs/ASI files at bin/x64/, which is a known gap to address in a follow-up.

@halgari halgari marked this pull request as ready for review April 15, 2026 23:31
@halgari halgari requested a review from a team as a code owner April 15, 2026 23:31
@erri120
Copy link
Copy Markdown
Member

erri120 commented Apr 16, 2026

IGamePathService should take StorePathProvider, not StorePathSnapshot — and GamePaths needs a different shape

The snapshot is an IPC transport detail, not an API

StorePathSnapshot is how the framework gets pre-resolved paths into the adaptor Worker without a second round-trip. That's a sound implementation decision. But it shouldn't be the adaptor's API surface — StorePathSnapshot is now @public and every adaptor author has to know to call createStorePathProvider(snapshot) at the top of paths() before doing anything useful. That's boilerplate the framework should own.

The fix is a one-layer separation: the bridge wraps the snapshot before calling the adaptor, so adaptors always receive a StorePathProvider:

// bridge — adaptor never sees the seam
const snapshot = await window.api.adaptors.buildSnapshot(store, gamePath);
const provider  = createStorePathProvider(snapshot);  // framework's responsibility
cachedPaths     = await callAdaptor(name, pathsUri, "paths", [provider]);
// clean adaptor-facing contract
interface IGamePathService<T extends string = never> {
  paths(provider: StorePathProvider): Promise<GamePaths<"game" | T>>;
}

StorePathSnapshot, createStorePathProvider, and rehydrateGamePaths all become internal. If the IPC transport changes — snapshot batched into the load call, live-RPC proxy for some bases, test mock — no adaptor code changes.

The same applies to IGameInstallerService.install: the context: StorePathSnapshot parameter should be provider: StorePathProvider, reconstructed by the bridge from cachedSnapshot before dispatch.


GamePaths<T> is the wrong shape

Map<T | Base, QualifiedPath> has two problems:

  1. Map.get() always returns QualifiedPath | undefined. The framework can never read the game path without a null check and a throw, even though the contract says it's always present.

  2. T | Base is too wide. Base values like AppData and XdgData are inputs the adaptor consumes from the provider — they don't belong in the output map. Including them in the key type implies something that isn't true.

The right shape is a plain mapped type:

type GamePaths<T extends string> = {
  [K in T]: QualifiedPath;
};

Adaptors declare all their keys, including "game", in T:

type MyGamePaths = "game" | "saves";

class GamePathService implements IGamePathService<"saves"> {
  async paths(provider: StorePathProvider): Promise<GamePaths<MyGamePaths>> {
    const game = await provider.fromBase("game");
    let saves: QualifiedPath;
    if (provider.isWindows) {
      const documents = await provider.fromBase("documents");
      saves = documents.join("My Game", "saves");
    } else {
      saves = game.join("saves");
    }
    return { game, saves };
  }
}

paths.game is always QualifiedPath — no .get(), no null check. The framework reads it directly. Base.Game === "game" so adaptors can use either the constant or the literal.

The "game" key is enforced at the interface level, not inside GamePaths:

interface IGamePathService<T extends string = never> {
  paths(provider: StorePathProvider): Promise<GamePaths<"game" | T>>;
}

An adaptor implementing IGamePathService<"saves"> must return both game and saves — the compiler rejects a return missing either. GamePaths itself stays a simple mapped type with no intersection, no mandatory fields baked in, no Map overhead.


StorePathProvider needs isWindows narrowing restored

The example above uses provider.isWindows — this matters beyond ergonomics. The old StorePathProviderBase<TBase, TStore, IsWindows extends boolean> encoded available bases in the type: SteamLinuxPathProvider had IsWindows = false, making fromBase("documents") a compile error. After an if (provider.isWindows) check, TypeScript narrowed to a type where "documents" was in TBase and the call became valid.

The new StorePathProvider.fromBase(base: Base) accepts any Base regardless of OS. fromBase(Base.AppData) on a Linux-native snapshot is a runtime PathProviderError where a compile error was possible. The gameOS === OS.Windows check gives the same runtime behaviour but loses the narrowing entirely.

Restoring this — a StorePathProvider union with an isWindows discriminant that narrows the fromBase overload — is the right long-term shape. The current flat Base enum is already the right vocabulary; it just needs the narrowing layer on top.

halgari added 6 commits April 16, 2026 05:53
Adaptors now receive a pre-resolved StorePathSnapshot (nested typed Map
keyed by OS and Base enums) scoped to the discovery, wrap it via
createStorePathProvider, and return a GamePaths<T> map of concrete OS
paths. The host forwards that map into getGameTools as plain data, so
there is no symbolic mapping to cache and no per-resolve RPC chain.

- adaptor-api: collapse SteamPathProvider|GOGPathProvider union into a
  single StorePathProvider; add Store/OS/Base enums and
  StorePathSnapshot; replace GameFolder/GameFolderMap with GamePaths<T>;
  rename resolveGameFolders -> paths; add createStorePathProvider and
  rehydrateGamePaths helpers for crossing the structured-clone boundary.
- Generic IGamePathService<T> / IGameToolsService<T> so adaptors keep
  their extra key types through the pipeline.
- main: build StorePathSnapshot per discovery with all Windows and
  Linux bases pre-resolved; expose via new adaptors:build-snapshot IPC.
  Proton support reserved via gameOS field (not yet detected).
- renderer bridge: drop buildBasePaths/storeGameId/SerializedQualifiedPath;
  fetch the snapshot from the host, pass it to paths(), forward the
  opaque result to getGameTools. Caches reset on re-discovery.
- shared: extend Serializable to include Map/Set (Electron's structured-
  clone IPC already preserves them).
- cyberpunk2077: implement paths() against the snapshot; branch on
  gameOS for Windows vs Linux save locations.
Adaptors can now describe where each file in a mod archive should be
deployed by returning symbolic {source, anchor, destination} mappings,
where anchor is a key from the same adaptor's IGamePathService<T>. The
host resolves anchors to concrete QualifiedPaths at deployment time, so
installer logic stays OS- and path-agnostic.

- adaptor-api: new IGameInstallerService<T> and InstallMapping<T>
  contracts. install() takes the same StorePathSnapshot that paths()
  already receives, threaded through as a shared "game context" rather
  than flat primitive args.
- @vortex/fs: new branded RelativePath type and relativePath()
  constructor. Validates and normalizes (rejects absolute paths,
  drive-letter prefixes, and .. segments; normalizes backslashes and
  trims a trailing slash).
- renderer bridge: discovers /installer URIs by suffix, caches the
  StorePathSnapshot alongside cached paths so install() can reuse it,
  adds invokeInstaller, and exposes a renderer-side
  adaptorInstallerRegistry keyed by gameId for a future InstallManager
  integration. Adds a manifest warning when /installer or /tools is
  declared without /paths.
- cyberpunk2077: reference installer that routes .ini to preferences,
  .sav to saves, and everything else to the game install dir. Also
  tightens paths() to reject native Linux discoveries, since no native
  Linux build exists; gameOS must be Windows (Proton).
- adaptors.ts: Proton TODO on nativeToQualifiedPath documenting the
  drive-letter regex edge case when a Wine-prefix path has not been
  translated before being handed in.

InstallManager integration (registering as an IModInstaller and
translating anchor mappings into IInstruction[]) is deferred to a
follow-up PR.
Adaptors can now implement install() by declaring a list of glob
patterns instead of writing per-file routing logic. A shared
resolveStopPatterns() helper walks the archive's file list, picks the
first matching pattern per file, and emits the same InstallMapping<T>
the contract already consumes. Opt-in: adaptors that want bespoke
logic keep their imperative install().

- adaptor-api: new StopPattern<T> type with { match, anchor, destination? }.
  destination can be omitted (destination = matched stable suffix of
  the file path), a template with {source}/{match}/{basename}/{stem}/
  {ext} placeholders, or a function for full control. Unmatched files
  are silently dropped.
- adaptor-api: new glob matcher supporting **, *, ?, and {a,b,c}
  alternation. Case-insensitive to align with Windows filesystem
  semantics and classic Vortex stop patterns. A leading **/ marks a
  pattern as wrapper-tolerant; the captured suffix becomes the
  implicit destination, which gives classic "strip wrapping dir"
  behaviour for free.
- cyberpunk2077: rewrite install() to use a 14-pattern CYBERPUNK_STOP_PATTERNS
  list translated from the canonical paths in E1337Kat/cyberpunk2077_ext_redux.
  Covers archive (canon/legacy/loose), CET, ReShade, Red4Ext, REDscript,
  TweakXL, Audioware, r6 config XML/JSON, REDmod, and engine config/tools.
  All mods install under Base.Game.

Note: this shares a name with Vortex's existing stop patterns (in
installer_fomod_shared/utils/gameSupport.ts) but has broader semantics.
Classic patterns are regex and only identify the mod root; these are
globs that also specify anchor + destination.
Takes a Nexus Mods mod URL, discovers the adaptor whose info.nexusMods
domain matches, runs a real mod's archive file list through install(),
and renders the resulting InstallMappings as a cli-table3 table. Useful
for verifying that adaptor stop-pattern sets behave correctly on
real-world mods without booting the full Vortex shell.

Flow: parse URL -> resolve numeric gameId via
data.nexusmods.com/.../games.json -> scan packages/adaptors/*/dist for
the first adaptor whose getGameInfo reports a matching nexusMods domain
-> load it in a Worker via createAdaptorHost -> call paths() against a
synthetic Windows snapshot -> fetch mod files via the public
api.nexusmods.com/v2/graphql modFiles query (unauthenticated) -> pull
the archive manifest from file-metadata.nexusmods.com -> call install()
-> render the mappings table. Runs via `npx tsx scripts/test-adaptor.ts
<url>`.

- cli-table3: added to the pnpm catalog and root devDependencies.
- cyberpunk2077 build.mjs: narrow externals to exact root package names
  (`@vortex/adaptor-api`, `@vortex/fs`). Previously used `startsWith`,
  which externalized subpath imports like
  `@vortex/adaptor-api/contracts/game-installer`, but the adaptor
  sandbox at src/main/src/node-adaptor-host/bootstrap.ts only exposes
  the two root specifiers. Subpath modules now inline into the bundle.
  Safe because they only export pure helpers, not stateful singletons
  like the @provides registry.
- Reject unknown store and empty gamePath in adaptors:build-snapshot
- Validate the adaptor install() response shape before returning
- Make the installer registry module-private behind accessor functions
- Document Serializable as Electron structured-clone (not JSON) semantics
- Fix encodeURI -> encodeURIComponent in test-adaptor and document NEXUS_API_KEY
…narrowing

- Change GamePaths<T> from Map to plain mapped type { [K in T]: QualifiedPath }
  so framework reads paths.game directly without null checks
- Move StorePathSnapshot wrapping into the worker dispatch layer so
  adaptor methods receive StorePathProvider directly
- Add WindowsStorePathProvider/LinuxStorePathProvider discriminated union
  with isWindows narrowing for compile-time OS-specific base safety
@halgari halgari force-pushed the halgari/adaptors-mod-installers branch from 3b628fd to 2be15bf Compare April 16, 2026 13:15
@halgari
Copy link
Copy Markdown
Contributor Author

halgari commented Apr 16, 2026

I much prefer raw data over classes, but I get that approach doesn't line up with the rest of the new code design. Made the changes described.

@halgari halgari merged commit e8c3292 into master Apr 16, 2026
5 checks passed
@halgari halgari deleted the halgari/adaptors-mod-installers branch April 16, 2026 13:28
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.

2 participants