Add the ability to require more than just existence for requirement metadata
As I've been exploring the feasibility of importing mlox's metadata into the Morrowind masterlist (see loot/morrowind#114), I've found that one of the things that mlox supports and LOOT doesn't is the ability to show an error for a missing requirement when a plugin exists but doesn't fulfill some condition (it's the wrong version, has the wrong size or has a description with unexpected content, or a combination of those).
For example, in mlox you can have a rule like:
[Requires]
dependent.esp
[VER >1.2 dependency.esp]
The closest you can get to this with LOOT's metadata is:
- name: dependent.esp
req:
- dependency.esp
msg:
- type: error
content: 'dependency.esp must have a version greater than 1.2.'
condition: 'not version("dependency.esp", "1.2", >)'
You can't put the condition on the req entry because that effectively means "check that this file exists when it does not have a version greater than 1.2", not "check that this file exists and has a version greater than 1.2".
This isn't an issue for importing mlox's metadata, because adding a plugin message to cover the additional requirement ends up having the same effect, but it would be nice to have the ability to fully represent the requirement in the req entry.
To achieve that, I've come up with the following:
- Add another field to the
Filemetadata object,check, which holds a condition string that is used to more precisely identify the file. - When metadata conditions are evaluated, the check would also be evaluated. The check's result would be inverted for
reqfile entries (because areqentry should be kept if the file can't be found).
With the check field, the example above would become:
- name: dependent.esp
req:
- name: dependency.esp
check: 'version("dependency.esp", "1.2", >)'
This would break the ABI, but would otherwise be backwards-compatible.
I'm undecided on what to do if the check isn't defined: the above assumes no change from the existing behaviour, but an alternative would be to use file("<file entry name>") as the check to evaluate if no check is defined for a file. That would be a change from the current behaviour, as it would mean that libloot's output when evaluating conditions would only include the reqs that don't exist and the incs that do, but without that change evaluating conditions would filter out files with checks that aren't installed but leave in files without checks that aren't installed, which seems more inconsistent.
It's woth noting that if an inc file is a plugin, LOOT will only show an error message if the file exists and it is active, and with this change LOOT would still need to check the latter.
Evaluating the checks as part of evaluating conditions wouldn't work because that would mean that requirements that have true conditions and that pass their checks would get filtered out, but they're needed as part of sorting, so that idea won't work. There could also be other reasons why you'd want to have the list of valid requirements whether or not they're present.
One solution could be to expose a method for just evaluating conditions, which could be:
-
GameInterface::Evaluate(string) -
GameInterface::Evaluate(ConditionalMetadata)(with a cast toFilethat also evaluates the check) -
ConditionalMetadata::Evaluate(GameInterface)(with an override inFilethat also evaluates the check) - something else?
Coming back to this:
- The addition of a
checkfield still seems good, though I'd rename it toconstraint. - The
constraintfield would act as an additional thing that needs to be true for the file to be treated as if it exists:- For load after files, if the file exists but the constraint is not true, it's treated as not existing
- For requirements, if the file exists but the constraint is not true, it's treated as not existing
- For incompatibilities, if the file exists but the constraint is not true, it's treated as not existing
- If no
constraintis defined then the file just needs to exist for it to be treated as if it exists. - The
constraintis not evaluated at the same time ascondition. - To evaluate the
constraint, aDatabaseInterface::Evaluate(std::string_view)function now seems like the best option. The API consumer can call that when they check if the file exists. The same function can be used to evaluate any given condition string.
As a missing load after file or incompatibility is no different from that file metadata not being defined, constraint doesn't add any benefit for them and can be combined with the condition to create a compound condition that is <condition> and <constraint>.
So, for example:
- name: dependent.esp
req:
- name: dependency.esp
constraint: 'version("dependency.esp", "1.2", >)'
condition: 'file("other.esp")'
means that dependency.esp is a requirement of dependent.esp when other.esp is installed, and dependency.esp must be installed and have a version greater than 1.2 for the requirement to be met.
- LOOT will sort
dependent.espafterdependency.espifother.espanddependency.espare installed and the latter has a version greater than1.2. If both plugins are installed butdependency.esp's version is less than1.2thendependent.espwill not be required to load after it by sorting. - LOOT will show an error message if
other.espis installed anddependency.espis not installed, or if both are installed but the latter does not have a version greater than1.2.
There's no way to represent that logic using just condition.
An example inc entry:
- name: plugin.esp
inc:
- name: incompatible.esp
constraint: 'version("incompatible.esp", "1.2", >)'
condition: 'file("other.esp")'
means that plugin.esp is incompatible with incompatible.esp when the latter has a version greater than 1.2 and other.esp is also installed. LOOT will show an error message if plugin.esp, other.esp and incompatible.esp are all installed and incompatible.esp has a version greater than 1.2.
That means you could rewrite that inc as:
- name: plugin.esp
inc:
- name: incompatible.esp
condition: 'file("other.esp") and version("incompatible.esp", "1.2", >)'
An example after entry:
- name: patch.esp
after:
- name: original.esp
constraint: 'version("original.esp", "1.2", >)'
condition: 'file("other.esp")'
means that patch.esp must load after original.esp when other.esp is installed and original.esp has a version greater than 1.2.
- LOOT will sort
patch.espafteroriginal.espifother.espandoriginal.espare installed and the latter has a version greater than1.2. If both plugins are installed butoriginal.esp's version is less than1.2thenpatch.espwill not be required to load after it by sorting.
That means you could rewrite that after as:
- name: patch.esp
after:
- name: original.esp
condition: 'file("other.esp") and version("original.esp", "1.2", >)'
Done in 575e6d5c93b8f31a07c5e6f1625788e8d1d5e2f0.