Skip to content

feat: support non-Bethesda games, starting with Stellar Blade - #5

Merged
MotherSphere merged 19 commits into
mainfrom
feat/per-game-mod-layouts
Aug 3, 2026
Merged

feat: support non-Bethesda games, starting with Stellar Blade#5
MotherSphere merged 19 commits into
mainfrom
feat/per-game-mod-layouts

Conversation

@MotherSphere

Copy link
Copy Markdown
Member

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 from data_dir and split along it, so one archive can land in the data union, at the game root via Root/, 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-core and eidos-fuse are at zero changed files.

  • A characterisation corpus built from real installs (58 Skyrim shapes, 41 Stellar Blade) freezes the checker's verdicts; Skyrim's never moved through any of this.
  • 10 of 13 real Stellar Blade archives install with no question asked (0 before), asserted in a test.
  • eidos-gui/src/main.rs split from 12,968 lines into nine modules (~3,000 remain: shared types + iced wiring). Pure moves - 278 top-level items before and after.
  • Fixes found by running a real instance: stale merged view on instance switch, plugin advice given to games with no plugins, the manual picker now names nested-archive bundles, the mount point is created when the game does not ship one, meta.ini records MO2's short name.

621 tests green; clippy clean under CI's exact command.

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.
@MotherSphere
MotherSphere merged commit 4e50858 into main Aug 3, 2026
4 of 5 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 3, 2026
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