Commit Graph
162 Commits
Author SHA1 Message Date
Oliver Hamlet 86abf044a7 Don't resolve symlink game and local paths
Path::is_dir() already resolves symlinks before checking if the path is a directory.

Passing down the resolved game path symlink to libloadorder means that when using MO2's VFS, libloadorder won't see the plugins that MO2 has "installed", because its VFS doesn't work with symlinks, so the files don't appear to be present in the symlink's target directory, they only appear to be present within the symlink directory path.
2026-05-29 08:58:39 +01:00
Oliver Hamlet 7942083cd0 Allow loaded plugins to be retrieved using a .ghost extension
libloot trims any .ghost extension from plugins it loads, so be consistent with that behaviour when getting or sorting plugins.
2026-05-14 18:58:26 +01:00
Oliver Hamlet a038e9a3af Store asset paths instead of hashes
This avoids the issue of hash collisions, within any one archive, a plugin's archives, or between different plugins' archives. That means that sorting has accurate data on what the asset counts loaded by plugins are, and what the asset overlap between plugins, so it can make better decisions when adding overlap edges.

It does require that the archives include the folder and file names, which is apparently not strictly required, but it seems that having the names is practically required[1][2], and I verified that the parsing code can handle archives from all of the supported games (apart from Morrowind, which doesn't have its BSAs read by LOOT). That testing covered 321 archives containing 771246 folder records and 3404007 file records, including some mod BSAs/BA2s.

If an archive doesn't have the flags set for containing folder and file names, then libloot will log an error and effectively ignore its contents, so the new behaviour means that some archives that were (potentially inaccurately) taken into account before may now be ignored. Falling back to using the hashes for archives that don't have names would be an option, but that complicates comparison against archives that do have names, and I have no evidence that the fallback would be useful in practice.

This doubles the size of each map key and set entry (from 8 to 16 bytes), introduces another level of indirection when comparing keys or values, and also means that the name strings need to be stored, further increasing memory usage.

I tested the performance impact when sorting Skyrim SE and Starfield load orders, using LOOT v0.29.1 and comparing against using it with libloot v0.24.4:

- The Skyrim SE load order had 1630 plugins and 18 BSAs totalling 3.14 GB (not including Skyrim.esm's or its BSAs, which LOOT doesn't fully load).
  - Loading plugins (which includes parsing BSAs) was ~ 2% (5 ms) faster
  - Sorting was ~ 1% (21 ms) faster
  - Memory usage after sorting was 1 MB (< 1%) higher
  - These differences are probably within margin of error

- The Starfield load order had 31 plugins and 72 BA2s totalling 20.2 GB (not including Starfield.esm or its BA2s, which LOOT doesn't fully load).
  - Loading plugins (which includes parsing BA2s) was ~ 96% (423 ms) faster
  - Sorting speed was unchanged
  - Memory usage after sorting was 11 MB (12%) higher

It looks like the accuracy improvement is also faster, and the memory usage cost is relatively modest.

[1]: https://en.uesp.net/wiki/Oblivion_Mod:BSA_File_Format
[2]: https://en.uesp.net/wiki/Skyrim_Mod:Archive_File_Format
2026-04-28 19:43:15 +01:00
Oliver Hamlet b49085f9b9 Stop treating asset hash collisions as errors
Feedback from Robert on Discord[1] is that hash collisions can be very common for certain mods.

Since erroring causes that archive's assets to be ignored, it results in less accurate sorting behaviour than not erroring, and if there are many hash collisions then logging them just produces noise.

Instead, count the number of collisions and debug log a single statement if it's non-zero, so there's still some indication to aid debugging any unexpected sorting behaviour it causes.

[1]: https://discord.com/channels/473542112974077963/473542230095822848/1498060566344896673

The linked Discord message (and a few following messages) is:

> pretty sure for Skyblivion we have > Many duplicates all over our files
and i mean A LOT
> but 100s of thousands of files that i'd expect
> [...]
> quirks of the hashing formula the game uses
>
> -- Robert in #support at 2026-04-27, 21:36 PM
2026-04-28 19:00:27 +01:00
Oliver Hamlet 1970a0b30c Fix new clippy errors
The failing lint was added in Rust v1.95.0.
2026-04-28 19:00:22 +01:00
Oliver Hamlet 665b18e394 Add more logging during plugin loading 2026-04-03 17:12:59 +01:00
Oliver Hamlet fff0bdbc8a Avoid using ..Default::default()
Exhaustively naming fields means I can't forget to set sensible values in cases where Default::default() might not produce them.
2026-04-03 16:53:37 +01:00
Oliver Hamlet 8cb826bf65 Remove log warning on archive hash collision
TL;DR: The warning added a lot more noise than expected, doesn't really
add value, and improving it isn't worth the effort.

The warning was logged when the same pair of folder hash and file hash
appeared more than once within the set of archive files being loaded
(as the function was used, the set would be the archives loaded by a
single plugin).

Hashes are used directly instead of asset file paths because the paths
are not necessarily present in BSA files, and while they are present in
BA2 files (at least, I'm not aware of any option to omit them or any
files that do omit them), hashes are calculated from the file paths they
contain for consistency with the approach needed for BSA files.

Without the file paths it's not possible to determine if the repeated
hashes represent the same asset file (possibly containing different
data) or different files that have colliding hashes.

I had assumed that it would be unusual for a single plugin to load more
than one archive file containing the same asset file path, so any
repeated hash pairs would likely indicate hash collisions, but feedback
from Pickysaurus on behalf of Vortex users indicates that's not true,
and that logging all the warnings significantly slows down fully loading
plugins.

There are a few ways that the logged warning could be improved: tracking
which archive file existing hashes were inserted from would make it
easier to identify the pair of archive files that might need
investigating; reading BA2 files could defer transforming their asset file
paths into hashes until after all the files for a plugin have been read
(or even past that, to account for hash collisions between different
plugins' assets); and reading BSA files could opportunistically store the
asset file paths if they are present, and fall back to comparing using
hashes if not.

However, even if the warning was logged for only true positive hash
collisions, this is all in service of a sorting heuristic that is only
used when adding overlap edges and a pair of plugins do not have
overlapping records but do both load assets, and a collision would mean
that a plugin might seem to load fewer assets than it does, and could
also appear to overlap with a plugin that doesn't actually load assets
with the same file paths. That in turn might result in the two plugins
loading in one order instead of the other, causing one's assets to
override the other's. If that's a problem, then it can be fixed using
load after metadata, and you can only really tell if it's a problem by
spotting something wrong in game, so the warning doesn't really add much
value, and although it indicates that there might be a problem, there's
enough conditions between the warning and there actually being an issue
that logging it as a warning is excessive anyway.
2026-03-13 18:40:49 +00:00
Oliver Hamlet 81388873da Add bits for cross-compiling to Windows using MinGW
Some tests have been updated because UTF-8 is used as the native
path encoding with MinGW/Wine, unlike MSVC/Windows.

Some of the tests fail:

- 4 Rust tests fail because long paths are not enabled and so the
  paths used when creating symlinks and junction paths are too
  long. I've tested them with x86_64-pc-windows-gnu and
  x86_64-pc-windows-gnullvm, and both see the same behaviour. The
  tests pass when the MinGW-built executable is run on Windows, so
  this is a Wine limitation.
- 12 C++ tests fail because directory symlink creation is not
  implemented. They fail whether the MinGW-built executable is run
  in Wine or on Windows, so this is a MinGW limitation.
- 1 C++ filesystem test fails because long paths are not enabled.

The failing tests are skipped at runtime when built with MinGW,
aside from the one test for long paths being enabled, which expects
them to be disabled when built with MinGW.

If long paths are enabled, e.g. by running

wine reg add HKLM\\System\\CurrentControlSet\\Control\\Filesystem /v LongPathsEnabled /t REG_DWORD /d 1 /f

then many more tests fail because the C++ tests create long paths
when that Registry value is set, but it doesn't seem to actually
enable long path support in Wine, so various filesystem operations
fail.
2026-02-03 08:52:23 +00:00
Oliver Hamlet 7910670675 Refactor max path component length constant 2026-01-31 15:29:33 +00:00
Oliver Hamlet 832690957a Require loot-condition-interpreter v6
It includes new functionality that is required for the metadata syntax v0.29.

Add a couple of tests that rely on that new functionality so that the
dependency is backed by tests that fail if it's not met.
2026-01-27 20:09:28 +00:00
Oliver Hamlet f606f169a5 Add condition field to plugin cleaning data metadata 2026-01-21 17:55:14 +00:00
Oliver Hamlet 55510729ca Add some tests for regex syntax support
Some functionality is only supported because the regex is built with the
Unicode flag instead of the UnicodeSets flag, but that's done for
backwards compatibility as the latter disallows some syntax that may be
in use.
2026-01-18 21:33:23 +00:00
Oliver Hamlet d14486433f Add line breaks to first two levels of metadata list output
Aside from the known bash tags list, since they're all short strings.
2026-01-14 19:02:40 +00:00
Oliver Hamlet b7363b0a29 Replace WriteMode with MetadataWriteOptions
This is a breaking API change, but the addition of any future options won't be.
2026-01-03 13:26:52 +00:00
Oliver Hamlet dae06e1f10 Add option for anchoring scalar files
As they're relatively simple strings, the indirection introduced by using an alias may harm readability more than the alias helps to prevent typos, shorten the metadata doc and make repetition more obvious.
2026-01-02 19:58:50 +00:00
Oliver Hamlet e8deace7a4 Emit anchors in a "common" YAML key
To match how they're organised in the masterlists. The order of the anchor types matters: contents and conditions go before messages and files because the latter can contain the former, so writing the former first ensures that all anchors are written at the same level of indentation.
2026-01-02 19:58:47 +00:00
Oliver Hamlet da00052245 Add support for emitting YAML with anchors and aliases
Support is currently limited to aliasing:

- Condition and constraint strings
- File values
- Message values
- MessageContent arrays (including single-value arrays that are
  serialised as strings)

An anchor is written if the same value appears more than once in the
metadata document being written, and if an anchor for that value has not
already been written. If a value has already been written with an
anchor, later appearances of that value will be written as aliases of
that anchor.

Anchors are named according to the type of data they're for, followed by
an incrementing number, e.g. file1, message1, contents1, condition1.

This behaviour is configurable within libloot, the configuration options
will be exposed externally once the functionality is more settled.
2026-01-02 19:49:05 +00:00
Oliver Hamlet 72dde58e43 Preserve plugin metadata insertion order 2026-01-02 18:40:27 +00:00
Oliver Hamlet 74628bda7c Compare regex strings case-insensitively in remove_plugin_metadata() 2026-01-02 18:02:18 +00:00
Oliver Hamlet 12a0407b4c Add the ability to remove regex plugin metadata 2026-01-02 16:45:31 +00:00
Oliver Hamlet 96b2ed2373 Add include_user_metadata parameter to Database::general_messages()
The C++ wrapper defaults it to true, so the behaviour hasn't changed for
existing callers that don't pass a value for the evaluateConditions
parameter, i.e. that call GetGeneralMessages().

Since the new parameter comes before evaluateConditions,
existing GetGeneralMessages(true) and GetGeneralMessages(false) calls
need to be replaced with GetGeneralMessages(true, true) and
GetGeneralMessages(true, false) to retain their existing behaviour. The
parameter order was chosen to match that of GetPluginMetadata(), at the
cost of this backwards incompatibility.
2026-01-02 16:41:43 +00:00
Oliver Hamlet f582a513bc Add Database::set_user_general_messages() 2026-01-02 16:41:42 +00:00
Oliver Hamlet 1d123c8b21 Add Database::user_general_messages() 2026-01-02 16:41:42 +00:00
Oliver Hamlet 744586fe96 Add include_user_metadata parameter to Database::known_bash_tags()
The C++ wrapper defaults it to true, so the behaviour hasn't changed for
existing callers.
2026-01-02 16:41:07 +00:00