feat: support non-Bethesda games, starting with Stellar Blade - #5
Merged
Conversation
Deciding where a mod starts inside a downloaded archive was hardcoded to Bethesda. `ArchiveTree::data_looks_valid` read two consts - the Gamebryo folder names and the Gamebryo file extensions - so a level of an archive counted as a mod root only if it held `textures/`, `meshes/`, an `.esp` and so on. Everything above it inherited that. `simple_archive_base` never resolved for a game outside the family, so `open_archive` returned `NotSimple` and the user got the manual picker with a file tree and no guidance. Every non-Bethesda mod, every time. The game was declarable and mountable; only its mods would not install. The vocabulary is now a value the game supplies: - `LayoutRules`, whose `Default` is the same two Gamebryo lists, unchanged. - `valid_folders` / `valid_suffixes` on `GameDef` and in the TOML schema. - `LayoutRules::for_game`, threaded from the game id at every entry point. `open_archive` gained a parameter. Nothing else did: every other public entry already took the game, it was simply never used to classify. That parameter is renamed `game_name` -> `game_id` throughout, because it has always carried the Eidos id and now selects the rules, so a caller passing MO2's short name would silently get the default instead of an error. A game declares its vocabulary as a whole, not list by list. Naming any rule drops the Gamebryo lists entirely. Falling back per list would be wrong for the first game that needs this: Stellar Blade's mods are `.pak` files and nothing else, and inheriting `textures/` would let an archive shipping a texture folder read as a valid mod root and install to the wrong place. Stellar Blade ships as the first non-Bethesda built-in, read off a real install rather than guessed: Unreal, no load order, `SB/Content/Paks` as the deploy root so that `~mods` and `LogicMods` both land correctly from one union. UE4SS is its script extender but `script_extender` stays empty - that field models an exe swap, and UE4SS is a proxy DLL. Two existing invariants were written when every game was Bethesda's and had to be corrected rather than relaxed. `plugin_games_have_primaries` said "anything but FileTime needs masters", which stopped being true the moment a game had no plugin system; it now names the two mechanisms it is about. The new guard on the fallback originally looped over the catalog, which silently shrank the moment a game declared its own rules - it now names the eleven Bethesda games outright. Behaviour is unchanged for every game that existed before. That is not asserted, it is recorded: `tests/corpus/` freezes the checker's verdicts on 50 real mod root shapes and 7 real archives from a live Skyrim SE instance, anonymised down to the structure the checker actually reads. The record was blessed on untouched code and never moved through the refactor. On the 13 real Stellar Blade archives that joined it, the Gamebryo vocabulary resolves none and the game's own resolves nine, both counts asserted.
First cut of a 13k-line main.rs. A pure move: the palette, the three container styles, the row striping and the selection/conflict colours, unchanged, with pub(crate) added so the crate root still reaches them. Leaves first on purpose. These call nothing back into the GUI, so the extraction cannot reorder anything, and Rust refuses to compile a move that lost a symbol.
Floating cards, drop gaps, list rows, the icon buttons and the conflict legend. A pure move again: everything here builds an Element and hands it back, so nothing about the order of anything can shift. Two of them read the conflict and selection colours, which is why this lands after theme.rs rather than beside it.
Welcome through summary, plus the frame they share. Pure move; the two state readers they call (selected_game, planned_instance) are now pub(crate) because a binary crate root does not re-export what it does not mark.
The single largest block in main.rs at just under 3000 lines: the file tree, conflicts, categories, notes and Nexus tabs for one mod, plus the diagnostics that feed the same dialog. Pure move. Diagnostic's fields become pub(crate) because the tests that read them stayed behind in main.rs.
Three thousand lines: every Message the GUI can receive and what it does to App. This is the half of the program that decides; what was left in main.rs after it only draws. Having both in one file was the main reason nothing could be found. Pure move. It needs no widget or theme imports at all, which is the clearest evidence the seam was in the right place.
Menu bar, toolbar, the two lists, the tabs and the status bar. Pure move; the fields of TreeRow and OwRow become pub(crate) because the mod info dialog reads them and a struct's fields do not inherit the item's visibility.
The scripted-installer step UI and the parchment tones it defines for itself. Pure move; it turns out to need no shared widget builders at all.
…n order Preferences, the executables editor, the about box and the LOOT report go to dialogs.rs. main.rs keeps what the modules all need - Message, App, the state helpers - plus the iced wiring, and imports neither theme nor widgets any more: nothing at the crate root draws. Two things the split surfaced rather than caused. The FOMOD cut had swallowed the app entry point, which kept compiling only because the glob re-imported `main` into the crate root; it is back where it belongs. And three helpers had been sitting below the test module since long before any of this, which is what clippy's "items after a test module" was about.
The caches, the selection and fold arithmetic, keyboard navigation and the save/reload paths: everything that touches App without drawing or dispatching. main.rs is now the types the modules share plus the iced wiring. Pure move; the top-level item count across the crate is unchanged.
The tab bar was unconditional, so Stellar Blade - the first game with no plugin system at all - showed a Plugins tab that opened an empty list it would never fill. Oblivion and Morrowind had the same problem for a different reason: their order is file timestamps, which Eidos does not manage either. The condition is the one every plugin path already bails on, GameSpec::for_id, named once as game_manages_plugins. And because app.tab outlives a game switch, effective_tab keeps a remembered-but-invisible tab from deciding which panel draws. Also: meta.ini now records MO2's short name (SkyrimSE) rather than the Eidos id. Nothing reads it back for behaviour - it is what MO2 sees when it opens a mod Eidos installed, and it did not recognise the game. A sidecar still wins, and an id outside the catalog falls back to itself.
UE4SS mods are Stellar Blade's SKSE plugins, and they ship a tree addressed from the game INSTALL root - SB/Binaries/Win64/ue4ss/Mods/... - not from the mod-merge root. Nothing recognised that shape, so every one of them reached the manual picker, whose contract is to drop everything beside the chosen root: the whole archive. Which directory marks an install-root archive comes from data_dir. A mod root nested below the install root (SB/Content/Paks) names one; a mod root that IS the install root's child (Data, Data Files) does not. Every Bethesda game is in the second group, so the branch is unreachable for them - asserted, not assumed. The real archives forced the harder half. CustomNanosuitSystem ships a UE4SS mod AND a pak, both under SB/. Sending the directory whole to Root/ would put the pak back at <game>/SB/Content/Paks, which is exactly where the Data union is mounted: shadowed, never served, a mod that installs cleanly and works by half. So the split is cut along the data_dir path instead - SB/Content/Paks becomes the Data half, SB/Binaries travels through Root/ at its own path. That needed RootSplit's root entries to carry their archive-relative path rather than a bare name, since Root/Binaries is not where the loader looks. Every existing entry is top-level, where the path and the name are the same string, so no Bethesda case moved. The corpus anonymiser was hiding the very thing the rule reads: it replaced SB with dN. It now keeps the engine's own directory names, which are no more identifying than 'meshes'. Skyrim's verdicts are unchanged across the change, and Stellar Blade goes from 9 to 10 of 13 real archives installing unattended.
…ator The Stellar Blade source was an absolute path from the machine the corpus was first generated on. Harmless at runtime - the script never runs in the app - but it is somebody's disk layout in a public repo, and on any other machine it fell through to "directory does not exist" and produced a quietly Skyrim-only corpus that still looked like a successful run. Each source now takes an environment variable, and the run reports every source it skipped. Regenerating with EIDOS_CORPUS_SB_MODS set reproduces the committed corpus byte for byte.
Switching from Skyrim to a game with no mods at all still drew Skyrim's whole merged Data tree, provenance labels and all, beside an empty mod list. The Data tab memoises each directory against `view_generation`, and the switch never bumped it - so every directory already listed was answered out of the old instance. `Message::Finish` already carried a careful list of what belongs to the previous instance (plugins, saves, downloads, selection, counts); the view caches were simply not on it, along with the expanded-path set, which names directories that need not exist in the new game. The test fails without the fix: it seeds the three caches and the expanded set, switches instance, and requires all four to be empty.
Stellar Blade's Diagnostics tab carried three findings about a plugin system it does not have. The worst of them was also wrong twice over: "LOOT cannot sort Stellar Blade - this game orders plugins by file timestamp; sort it by hand in the Plugins tab" describes Oblivion, and points at a tab this game does not show. Two predicates, because one is not enough and collapsing them breaks the other end. `game_manages_plugins` is Eidos writing the order; `game_has_plugins` is the game having plugins at all. Morrowind is the game that separates them: it has .esp files and a load order, Eidos just does not write it - so it should keep being told LOOT cannot sort it, which gating on the narrower predicate would have silently taken away. LOOT advice, load-order analysis -> game_has_plugins profile plugin state -> game_manages_plugins `LoadOrder` is re-exported from eidos-games beside `GameDef`, which is where a caller already looks for the descriptor's types.
…hive Two problems in the manual picker, both hit by the same archive: a variant pack of screenshots plus two zips, each holding one version of the mod. The advisory line said the check "only recognises the game's own folder names". That was already half the story - extensions have always counted too - and became actively misleading once the vocabulary went per-game, because a Stellar Blade mod is recognised ONLY by its .pak and never by a folder. And nothing said what to do. No level of such an archive can look valid, so the dialog repeated "does NOT look valid" wherever the user clicked, with no hint that the answer is to open one of the inner files instead. It now counts the nested archives and says so, singular and plural. The hint appears only on the dead end, not as a remark on every archive that fails the check, and a directory merely named like an archive is not one.
A mod installed through Eidos loaded its pak and added nothing to Custom Nanosuit System. The reason is one path component. CNS roots its recursive config scan at Dirs.Game.Content.Paks["~mods"] (helpers.lua). A .dekcns.json deployed to Content/Paks is therefore never scanned - Unreal still loads the mod's _P pak from there, so the mod looks installed and half-works, which is the worst way to be wrong. Confirmed against a real run: the log showed 9 config files found, all of them the framework's own defaults from inside ~mods, and none from the two outfit mods installed beside it. data_dir moves down one level. valid_folders empties out at the same time and for the same reason: with ~mods as the deploy root, naming it as a data folder would make an archive shipping ~mods/foo.pak look valid AT that level, and the wrapper would survive into ~mods/~mods/foo.pak. A Stellar Blade mod is recognised by carrying a pak, never by a folder. This makes the CNS archive split three ways rather than two, all correct: ~mods/ is the Data half, LogicMods/ and SB/Binaries/ both travel through Root/ at their own paths. The end-to-end test now asserts all three destinations. The corpus was truncating archives at three levels, which is above Stellar Blade's four-component deploy root - it had been recording a verdict for a shape it had cut past the part that decides it. Deepened to five. Skyrim's verdicts are unchanged across both changes.
Launching Stellar Blade died instantly with `launch failed: No such file or directory (os error 2)` - a message naming neither the path nor the reason. The mount point was SB/Content/Paks/~mods, which the game never creates: it is a modding convention, so a clean install has no such directory and the mount had nothing to mount over. That would have failed for every user of such a game, not just one who had moved the directory aside. It is now created before the mount, which is the same promise Eidos already makes for its own directories, and an empty directory inside the game is inert. And a NotFound launch failure now prints the mod directory and the game install it was working with, so the next person gets something to act on rather than errno 2.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Eidos only knew how to install Bethesda mods. The archive checker read a hardcoded Gamebryo vocabulary - folder names and file extensions - so every other game's archives fell through to the manual picker, every time. The game was declarable and mountable; its mods would not install.
The vocabulary is now per-game data on
GameDef(and in the user TOML schema), with the Gamebryo lists unchanged as the default for every game that declares nothing. Archives addressed from the game's install root are recognised fromdata_dirand split along it, so one archive can land in the data union, at the game root viaRoot/, or both.Stellar Blade ships as the first non-Bethesda built-in, read off a real install rather than guessed, and validated end to end in game: UE4SS Lua mods served through
Root/, paks and Custom Nanosuit System configs through the data union, game writes captured in overwrite.The engine was never touched:
eidos-coreandeidos-fuseare at zero changed files.eidos-gui/src/main.rssplit from 12,968 lines into nine modules (~3,000 remain: shared types + iced wiring). Pure moves - 278 top-level items before and after.meta.inirecords MO2's short name.621 tests green; clippy clean under CI's exact command.