From 7c9558fd366c3b2b352654c166ed59ac39a914fd Mon Sep 17 00:00:00 2001 From: Oliver Hamlet Date: Sun, 2 Mar 2025 23:05:17 +0000 Subject: [PATCH] Initial translation of libloot into Rust This should be equivalent to libloot commit 55b341fc6cbdccee52e42923c13a91eddb5ca97d, except for: - Error types are incomplete - Setting a logging callback doesn't work properly - Serialising metadata to YAML isn't implemented - Tests are almost entirely missing - C/C++ FFI doesn't exist --- .gitignore | 1 + Cargo.lock | 1045 ++++++++++++++++++++++++ Cargo.toml | 21 + LICENSE | 674 +++++++++++++++ README.md | 49 ++ src/archive/ba2.rs | 143 ++++ src/archive/bsa.rs | 208 +++++ src/archive/find.rs | 185 +++++ src/archive/mod.rs | 7 + src/archive/parse.rs | 106 +++ src/database.rs | 455 +++++++++++ src/error.rs | 353 ++++++++ src/game.rs | 745 +++++++++++++++++ src/lib.rs | 24 + src/logging.rs | 74 ++ src/metadata/file.rs | 163 ++++ src/metadata/group.rs | 91 +++ src/metadata/location.rs | 77 ++ src/metadata/message.rs | 322 ++++++++ src/metadata/metadata_document.rs | 469 +++++++++++ src/metadata/mod.rs | 17 + src/metadata/plugin_cleaning_data.rs | 150 ++++ src/metadata/plugin_metadata.rs | 361 ++++++++ src/metadata/tag.rs | 106 +++ src/metadata/yaml.rs | 167 ++++ src/plugin.rs | 453 ++++++++++ src/sorting/dfs.rs | 210 +++++ src/sorting/groups.rs | 271 ++++++ src/sorting/mod.rs | 5 + src/sorting/plugins.rs | 1135 ++++++++++++++++++++++++++ src/sorting/validate.rs | 198 +++++ src/sorting/vertex.rs | 75 ++ src/version.rs | 47 ++ 33 files changed, 8407 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/archive/ba2.rs create mode 100644 src/archive/bsa.rs create mode 100644 src/archive/find.rs create mode 100644 src/archive/mod.rs create mode 100644 src/archive/parse.rs create mode 100644 src/database.rs create mode 100644 src/error.rs create mode 100644 src/game.rs create mode 100644 src/lib.rs create mode 100644 src/logging.rs create mode 100644 src/metadata/file.rs create mode 100644 src/metadata/group.rs create mode 100644 src/metadata/location.rs create mode 100644 src/metadata/message.rs create mode 100644 src/metadata/metadata_document.rs create mode 100644 src/metadata/mod.rs create mode 100644 src/metadata/plugin_cleaning_data.rs create mode 100644 src/metadata/plugin_metadata.rs create mode 100644 src/metadata/tag.rs create mode 100644 src/metadata/yaml.rs create mode 100644 src/plugin.rs create mode 100644 src/sorting/dfs.rs create mode 100644 src/sorting/groups.rs create mode 100644 src/sorting/mod.rs create mode 100644 src/sorting/plugins.rs create mode 100644 src/sorting/validate.rs create mode 100644 src/sorting/vertex.rs create mode 100644 src/version.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..a2812b6d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1045 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dataview" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50eb3a329e19d78c3a3dfa4ec5a51ecb84fa3a20c06edad04be25356018218f9" +dependencies = [ + "derive_pod", +] + +[[package]] +name = "derive_pod" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ea6706d74fca54e15f1d40b5cf7fe7f764aaec61352a9fcec58fe27e042fc8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "either" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7914353092ddf589ad78f25c5c1c21b7f80b0ff8621e7c814c3485b5306da9d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "esplugin" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5be744c54f80f535dc03a47c6a12c5116525edfa8a1365f13e91c42a0ad4d55" +dependencies = [ + "encoding_rs", + "memchr", + "nom 7.1.3", + "unicase", +] + +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.13.3+wasi-0.2.2", + "windows-targets", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "indexmap" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +dependencies = [ + "equivalent", + "hashbrown 0.15.2", +] + +[[package]] +name = "keyvalues-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e4c8354918309196302015ac9cae43362f1a13d0d5c5539a33b4c2fd2cd6d25" +dependencies = [ + "pest", + "pest_derive", + "thiserror 1.0.69", +] + +[[package]] +name = "libc" +version = "0.2.170" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" + +[[package]] +name = "libloadorder" +version = "18.2.2" +source = "git+https://github.com/Ortham/libloadorder.git?rev=6245dbc6824c4751daef5ab01dfe7b9497f5446f#6245dbc6824c4751daef5ab01dfe7b9497f5446f" +dependencies = [ + "dirs", + "encoding_rs", + "esplugin", + "keyvalues-parser", + "rayon", + "regex", + "rust-ini", + "unicase", + "windows", +] + +[[package]] +name = "libloot" +version = "0.25.3" +dependencies = [ + "crc32fast", + "esplugin", + "fancy-regex", + "libloadorder", + "log", + "loot-condition-interpreter", + "petgraph", + "rayon", + "saphyr", + "saphyr-parser", + "tempfile", + "unicase", +] + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "log" +version = "0.4.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" + +[[package]] +name = "loot-condition-interpreter" +version = "5.2.0" +source = "git+https://github.com/loot/loot-condition-interpreter.git?rev=f7d9947fa434037743ce81e2d409184ec0bfb693#f7d9947fa434037743ce81e2d409184ec0bfb693" +dependencies = [ + "crc32fast", + "esplugin", + "nom 8.0.0", + "pelite", + "regex", + "unicase", +] + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "pelite" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88dccf4bd32294364aeb7bd55d749604450e9db54605887551f21baea7617685" +dependencies = [ + "dataview", + "libc", + "no-std-compat", + "pelite-macros", + "winapi", +] + +[[package]] +name = "pelite-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7cf3f8ecebb0f4895f4892a8be0a0dc81b498f9d56735cb769dc31bf00815b" + +[[package]] +name = "pest" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" +dependencies = [ + "memchr", + "thiserror 2.0.11", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "proc-macro2" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rayon" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_users" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +dependencies = [ + "getrandom 0.2.15", + "libredox", + "thiserror 2.0.11", +] + +[[package]] +name = "regex" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" + +[[package]] +name = "rust-ini" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" +dependencies = [ + "cfg-if", + "ordered-multimap", + "trim-in-place", + "unicase", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "saphyr" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31513a748cdf8fb8d1b64dcc14fdd997fa02f2431671c7ce34efbea5e7eeea" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", + "saphyr-parser", +] + +[[package]] +name = "saphyr-parser" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "123f0a6da68f3072c7c761450276d1d444cb391c8be182a757cd26cf684cb77f" +dependencies = [ + "arraydeque", + "hashlink", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "2.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230" +dependencies = [ + "cfg-if", + "fastrand", + "getrandom 0.3.1", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +dependencies = [ + "thiserror-impl 2.0.11", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "trim-in-place" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343e926fc669bc8cde4fa3129ab681c63671bae288b1f1081ceee6d9d37904fc" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasi" +version = "0.13.3+wasi-0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-link", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-implement" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26fd936d991781ea39e87c3a27285081e3c0da5ca0fcbc02d368cc6f52ff01" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dccfd733ce2b1753b03b6d3c65edf020262ea35e20ccdf3e288043e6dd620e3" + +[[package]] +name = "windows-numerics" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06374efe858fab7e4f881500e6e86ec8bc28f9462c47e5a9941a0142ad86b189" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c" +dependencies = [ + "bitflags", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..4b43d1bf --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "libloot" +version = "0.25.3" +edition = "2024" +license = "GPL-3.0" + +[dependencies] +crc32fast = "1.4.2" +esplugin = "6.1.1" +libloadorder = { git = "https://github.com/Ortham/libloadorder.git", rev = "6245dbc6824c4751daef5ab01dfe7b9497f5446f" } +log = { version = "0.4.26", features = ["std"] } +loot-condition-interpreter = { git = "https://github.com/loot/loot-condition-interpreter.git", rev = "f7d9947fa434037743ce81e2d409184ec0bfb693" } +petgraph = "0.7.1" +fancy-regex = "0.14.0" +saphyr = "0.0.3" +saphyr-parser = "0.0.3" +unicase = "2.8.1" +rayon = "1.10.0" + +[dev-dependencies] +tempfile = "3.17.1" diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..94a9ed02 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 00000000..1e0c4f54 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# libloot-rs + +This is an **incomplete** and **experimental** reimplementation of [libloot](https://github.com/loot/libloot) using Rust instead of C++. + +## Current status + +Currently complete: + +- [x] Public API types and function declarations (excluding errors) +- [ ] Public API error types +- [x] Public API doc comments +- [x] Library versioning +- [ ] Setting a logging callback +- [x] Parsing metadata from YAML +- [ ] Serialising metadata to YAML +- [x] Game-related functionality +- [x] Plugin-related functionality +- [x] Archive-related functionality +- [x] Metadata-related functionality (excluding writing YAML) +- [x] Sorting functionality +- [ ] Unit tests +- [ ] Integration tests +- [ ] C++ FFI +- [ ] Python FFI + +The complete bits should match libloot commit [55b341fc6cbdccee52e42923c13a91eddb5ca97d](https://github.com/loot/libloot/commit/55b341fc6cbdccee52e42923c13a91eddb5ca97d), which is libloot v0.25.3 plus a few changes prompted by this translation. + +## Build + +Make sure you have [Rust](https://www.rust-lang.org/) installed. + +To build the library, set the `LIBLOOT_REVISION` env var and then run Cargo. Using PowerShell: + +```powershell +$env:LIBLOOT_REVISION = git rev-parse --short HEAD +cargo build --release +``` + +There aren't many tests, but those that exist can be run using: + +``` +cargo test +``` + +The public API has doc comments copied from libloot, and the API documentation can be built and viewed using: + +``` +cargo doc --open +``` diff --git a/src/archive/ba2.rs b/src/archive/ba2.rs new file mode 100644 index 00000000..e7d575f0 --- /dev/null +++ b/src/archive/ba2.rs @@ -0,0 +1,143 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + hash::{DefaultHasher, Hash, Hasher}, + io::{BufRead, Seek}, +}; + +use crate::error::{GeneralError, InvalidArgumentError}; + +use super::parse::{to_u32, to_u64}; + +pub(super) const TYPE_ID: [u8; 4] = *b"BTDX"; +const HEADER_SIZE: usize = 24; +const BA2_GENERAL_TYPE: [u8; 4] = *b"GNRL"; +const BA2_TEXTURE_TYPE: [u8; 4] = *b"DX10"; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +struct Header { + type_id: [u8; 4], + version: u32, + archive_type: [u8; 4], + file_count: u32, + file_paths_offset: u64, +} + +impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { + type Error = InvalidArgumentError; + + fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { + let header = Self { + type_id: TYPE_ID, + version: to_u32(&value), + archive_type: <[u8; 4]>::try_from(&value[4..8]) + .expect("Bytes slice is large enough to hold a 4-byte array"), + file_count: to_u32(&value[12..]), + file_paths_offset: to_u64(&value[16..]), + }; + + // The header version is 1, 7 or 8 for Fallout 4 and 2 or 3 for Starfield. + if !matches!(header.version, 1 | 2 | 3 | 7 | 8) { + return Err(InvalidArgumentError { + message: "BA2 file header version is invalid".into(), + }); + } + + if !matches!(header.archive_type, BA2_GENERAL_TYPE | BA2_TEXTURE_TYPE) { + return Err(InvalidArgumentError { + message: "BA2 file header archive type is invalid".into(), + }); + } + + Ok(header) + } +} + +pub(super) fn read_assets( + mut reader: T, +) -> Result>, GeneralError> { + let mut header_buffer = [0; HEADER_SIZE - TYPE_ID.len()]; + + reader.read_exact(&mut header_buffer)?; + + let header = Header::try_from(header_buffer)?; + + let mut assets = BTreeMap::new(); + + reader.seek(std::io::SeekFrom::Start(header.file_paths_offset))?; + + for _ in 0..header.file_count { + let mut length_buf = [0; 2]; + reader.read_exact(&mut length_buf)?; + + let path_length = u16::from_le_bytes(length_buf); + let mut file_path_bytes = vec![0; path_length.into()]; + reader.read_exact(file_path_bytes.as_mut_slice())?; + + normalise_path(&mut file_path_bytes); + + let file_path_bytes = trim_slashes(&file_path_bytes); + + let (folder_hash, file_hash) = rsplit_on(file_path_bytes, b'\\') + .map(|(folder_path, file_path)| (hash(&folder_path), hash(&file_path))) + .unwrap_or_else(|| (0, hash(&file_path_bytes))); + + let file_hashes: &mut BTreeSet = assets.entry(folder_hash).or_default(); + + if !file_hashes.insert(file_hash) { + return Err(InvalidArgumentError { + message: format!( + "Unexpected collision for file name hash {:x} in set for folder name hash {:x}", + file_hash, folder_hash + ), + } + .into()); + } + } + + Ok(assets) +} + +fn normalise_path(path_bytes: &mut [u8]) { + for byte in path_bytes { + // Ignore any non-ASCII characters. + if *byte > 127 { + continue; + } + + *byte = match byte { + b'/' => b'\\', + _ => byte.to_ascii_lowercase(), + } + } +} + +fn trim_slashes(mut path_bytes: &[u8]) -> &[u8] { + while let [first, rest @ ..] = path_bytes { + if *first == b'\\' { + path_bytes = rest; + } else { + break; + } + } + + while let [rest @ .., last] = path_bytes { + if *last == b'\\' { + path_bytes = rest; + } else { + break; + } + } + + path_bytes +} + +fn rsplit_on(slice: &[u8], needle: u8) -> Option<(&[u8], &[u8])> { + let index = slice.iter().rposition(|b| *b == needle)?; + Some((&slice[..index], &slice[index + 1..])) +} + +fn hash(value: &T) -> u64 { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish() +} diff --git a/src/archive/bsa.rs b/src/archive/bsa.rs new file mode 100644 index 00000000..70910735 --- /dev/null +++ b/src/archive/bsa.rs @@ -0,0 +1,208 @@ +use std::{ + collections::{BTreeMap, BTreeSet, btree_map::Entry}, + io::BufRead, +}; + +use crate::error::{GeneralError, InvalidArgumentError}; + +use super::parse::{to_u32, to_u64, to_usize}; + +pub(super) const TYPE_ID: [u8; 4] = *b"BSA\0"; +const HEADER_SIZE: usize = 36; +const FILE_RECORD_SIZE: usize = 16; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +struct Header { + type_id: [u8; 4], + version: u32, + records_offset: u32, + archive_flags: u32, + folder_count: u32, + total_file_count: u32, + total_folder_names_length: u32, + total_file_names_length: u32, + content_type_flags: u32, +} + +impl TryFrom<[u8; HEADER_SIZE - TYPE_ID.len()]> for Header { + type Error = InvalidArgumentError; + + fn try_from(value: [u8; HEADER_SIZE - TYPE_ID.len()]) -> Result { + let header = Self { + type_id: TYPE_ID, + version: to_u32(&value), + records_offset: to_u32(&value[4..]), + archive_flags: to_u32(&value[8..]), + folder_count: to_u32(&value[12..]), + total_file_count: to_u32(&value[16..]), + total_folder_names_length: to_u32(&value[20..]), + total_file_names_length: to_u32(&value[24..]), + content_type_flags: to_u32(&value[28..]), + }; + + if header.records_offset != 36 { + return Err(InvalidArgumentError { + message: format!( + "BSA file has an invalid records offset value: {}", + header.records_offset + ), + }); + } + + if (header.archive_flags & 0x40) != 0 { + return Err(InvalidArgumentError { + message: "BSA file uses big-endian numbers".into(), + }); + } + + Ok(header) + } +} + +struct FolderRecord { + name_hash: u64, + file_count: u32, + file_records_offset: u32, +} + +// Also used for v104 BSAs. +mod v103 { + use crate::archive::parse::{to_u32, to_u64}; + + use super::FolderRecord; + + pub(super) const FOLDER_RECORD_SIZE: usize = 16; + + pub(super) fn read_folder_record(value: &[u8]) -> FolderRecord { + assert!(value.len() >= FOLDER_RECORD_SIZE); + + FolderRecord { + name_hash: to_u64(value), + file_count: to_u32(&value[8..]), + file_records_offset: to_u32(&value[12..]), + } + } +} + +mod v105 { + use crate::archive::parse::{to_u32, to_u64}; + + use super::FolderRecord; + + pub(super) const FOLDER_RECORD_SIZE: usize = 24; + + pub(super) fn read_folder_record(value: &[u8]) -> FolderRecord { + assert!(value.len() >= FOLDER_RECORD_SIZE); + + FolderRecord { + name_hash: to_u64(value), + file_count: to_u32(&value[8..]), + file_records_offset: to_u32(&value[16..]), + } + } +} + +pub(super) fn read_assets( + mut reader: T, +) -> Result>, GeneralError> { + let mut header_buffer = [0; HEADER_SIZE - TYPE_ID.len()]; + + reader.read_exact(&mut header_buffer)?; + + let header = Header::try_from(header_buffer)?; + + match header.version { + 103 | 104 => read_assets_with_header::( + reader, + &header, + v103::read_folder_record, + ), + 105 => read_assets_with_header::( + reader, + &header, + v105::read_folder_record, + ), + _ => Err(InvalidArgumentError { + message: format!("BSA file has an unrecognised version: {}", header.version), + } + .into()), + } +} + +fn read_assets_with_header( + mut reader: T, + header: &Header, + read_folder_record: impl Fn(&[u8]) -> FolderRecord, +) -> Result>, GeneralError> { + let mut folders_buffer: Vec = vec![0; U * to_usize(header.folder_count)]; + + reader.read_exact(folders_buffer.as_mut_slice())?; + + let file_records_size = to_usize(header.folder_count) + + to_usize(header.total_folder_names_length) + + to_usize(header.total_file_count) * FILE_RECORD_SIZE; + + let mut file_records_buffer: Vec = vec![0; file_records_size]; + + reader.read_exact(file_records_buffer.as_mut_slice())?; + + let folder_record_offset_baseline = + HEADER_SIZE + folders_buffer.len() + to_usize(header.total_file_names_length); + + let mut assets = BTreeMap::new(); + for chunk in folders_buffer.chunks_exact(U) { + let folder_record = read_folder_record(chunk); + + let entry = assets.entry(folder_record.name_hash); + if let Entry::Occupied(_) = entry { + return Err(InvalidArgumentError { + message: format!( + "Unexpected collision for folder name hash {:x}", + folder_record.name_hash + ), + } + .into()); + } + + let file_records_offset = if (header.archive_flags & 0x1) == 0 { + to_usize(folder_record.file_records_offset) - folder_record_offset_baseline + } else { + let folder_name_length_offset = + to_usize(folder_record.file_records_offset) - folder_record_offset_baseline; + + if let Some(folder_name_length) = file_records_buffer.get(folder_name_length_offset) { + folder_name_length_offset + 1 + to_usize(u32::from(*folder_name_length)) + } else { + return Err(InvalidArgumentError { + message: "BSA file contains an invalid folder name length offset".into(), + } + .into()); + } + }; + + let file_records_buffer = match file_records_buffer.get(file_records_offset..) { + Some(s) => s, + None => { + return Err(InvalidArgumentError { + message: "BSA file contains an invalid file records offset".into(), + } + .into()); + } + }; + + let file_hashes: &mut BTreeSet = entry.or_default(); + + for file_chunk in file_records_buffer + .chunks_exact(FILE_RECORD_SIZE) + .take(to_usize(folder_record.file_count)) + { + let file_hash = to_u64(file_chunk); + + if !file_hashes.insert(file_hash) { + return Err(InvalidArgumentError { message: format!("Unexpected collision for file name hash {:x} in set for folder name hash {:x}", file_hash, folder_record.name_hash)}.into()); + } + } + } + + Ok(assets) +} diff --git a/src/archive/find.rs b/src/archive/find.rs new file mode 100644 index 00000000..5f855feb --- /dev/null +++ b/src/archive/find.rs @@ -0,0 +1,185 @@ +use std::{ + fs::OpenOptions, + path::{Path, PathBuf}, +}; + +use crate::{GameType, game::GameCache, plugin::has_ascii_extension}; + +const BSA_FILE_EXTENSION: &str = "bsa"; + +pub fn find_associated_archives( + game_type: GameType, + game_cache: &GameCache, + plugin_path: &Path, +) -> Vec { + match game_type { + GameType::TES3 | GameType::OpenMW => Vec::new(), + + // Skyrim (non-SE) plugins can only load BSAs that have exactly the same + // basename, ignoring file extensions. + GameType::TES5 => find_associated_archive(plugin_path), + + // Skyrim SE can load BSAs that have exactly the same basename, ignoring + // file extensions, and also BSAs with filenames of the form " + // - Textures.bsa" (case-insensitively). This assumes that Skyrim VR + // works the same way as Skyrim SE. + GameType::TES5SE | GameType::TES5VR => find_associated_archives_with_suffixes(plugin_path, BSA_FILE_EXTENSION, &["", " - Textures"]), + + // Oblivion .esp files can load archives which begin with the plugin + // basename. + GameType::TES4 => { + if has_ascii_extension(plugin_path, "esp") { + Vec::new() + } else { + find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache) + } + }, + + // FO3, FNV, FO4 plugins can load archives which begin with the plugin + // basename. This assumes that FO4 VR works the same way as FO4. + GameType::FO3 | GameType::FONV | GameType::FO4 | GameType::FO4VR => + find_associated_archives_with_arbitrary_suffixes(plugin_path, game_cache) + , + + // The game will load a BA2 that's suffixed with " - Voices_" + // where is whatever language Starfield is configured to use + // (sLanguage in the ini), so this isn't exactly correct but will work + // so long as a plugin with voices has voices for English, which seems + // likely. + GameType::Starfield => find_associated_archives_with_suffixes(plugin_path, "ba2", &[" - Main", " - Textures", " - Localization", " - Voices_en"]), + } +} + +fn find_associated_archive(plugin_path: &Path) -> Vec { + let archive_path = plugin_path.with_extension(BSA_FILE_EXTENSION); + + if archive_path.exists() { + vec![archive_path] + } else { + Vec::new() + } +} + +fn find_associated_archives_with_suffixes( + plugin_path: &Path, + archive_extension: &str, + supported_suffixes: &[&str], +) -> Vec { + let file_stem = match plugin_path.file_stem() { + Some(s) => s, + None => return Vec::new(), + }; + + supported_suffixes + .iter() + .map(|suffix| { + let mut filename = file_stem.to_os_string(); + filename.push(suffix); + filename.push("."); + filename.push(archive_extension); + + plugin_path.with_file_name(filename) + }) + .filter(|p| p.exists()) + .collect() +} + +fn find_associated_archives_with_arbitrary_suffixes( + plugin_path: &Path, + game_cache: &GameCache, +) -> Vec { + let plugin_stem_len = match plugin_path.file_stem().and_then(|s| s.to_str()) { + Some(s) => s.len(), + None => return Vec::new(), + }; + + game_cache + .archives() + .filter(|path| { + // Need to check if it starts with the given plugin's basename, + // but case insensitively. This is hard to do accurately, so + // instead check if the plugin with the same length basename and + // and the given plugin's file extension is equivalent. + let archive_filename = match path.file_name().and_then(|s| s.to_str()) { + Some(f) => f, + None => return false, + }; + + // Can't just slice the archive filename to the same length as the plugin file stem directly because that might not slice on a character boundary, so truncate the byte slice and then check it's still valid UTF-8. + let filename = + match std::str::from_utf8(&archive_filename.as_bytes()[..plugin_stem_len]) { + Ok(f) => f, + Err(_) => return false, + }; + + let archive_plugin_path = plugin_path.with_file_name(filename); + + are_file_paths_equivalent(&archive_plugin_path, plugin_path) + }) + .cloned() + .collect() +} + +#[cfg(windows)] +fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { + // See + // Or windows::Win32::Foundation::ERROR_SHARING_VIOLATION in the "windows" crate. + const ERROR_SHARING_VIOLATION: i32 = 32; + use std::os::windows::fs::OpenOptionsExt; + + let lhs_file = match OpenOptions::new().read(true).share_mode(0).open(lhs) { + Ok(f) => f, + Err(_) => return false, + }; + + let result = match OpenOptions::new().read(true).share_mode(0).open(rhs) { + Ok(f) => { + dbg!(f); + false + } + Err(e) => { + if let Some(error_code) = e.raw_os_error() { + error_code == ERROR_SHARING_VIOLATION + } else { + false + } + } + }; + + drop(lhs_file); + + result +} + +#[cfg(not(windows))] +fn are_file_paths_equivalent(lhs: &Path, rhs: &Path) -> bool { + use std::fs::unix::fs::MetadataExt; + + let lhs_metadata = match lhs.metadata() { + Ok(m) => m, + _ => return false, + }; + + let rhs_metadata = match rhs.metadata() { + Ok(m) => m, + _ => return false, + }; + + lhs_metadata.dev() == rhs_metadata.dev() && lhs_metadata.ino() == rhs_metadata.ino() +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + #[test] + fn are_file_paths_equivalent_should_be_true_if_given_the_same_path_twice() { + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test"); + std::fs::write(&file_path, "").unwrap(); + + assert!(are_file_paths_equivalent(&file_path, &file_path)) + } +} diff --git a/src/archive/mod.rs b/src/archive/mod.rs new file mode 100644 index 00000000..cfe6cf56 --- /dev/null +++ b/src/archive/mod.rs @@ -0,0 +1,7 @@ +mod ba2; +mod bsa; +mod find; +mod parse; + +pub use find::find_associated_archives; +pub use parse::assets_in_archives; diff --git a/src/archive/parse.rs b/src/archive/parse.rs new file mode 100644 index 00000000..c0825824 --- /dev/null +++ b/src/archive/parse.rs @@ -0,0 +1,106 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::File, + io::{BufReader, Read}, + path::{Path, PathBuf}, +}; + +use crate::{ + error::{GeneralError, InvalidArgumentError}, + plugin::has_ascii_extension, +}; + +use super::{ba2, bsa}; + +pub fn assets_in_archives(archive_paths: &[PathBuf]) -> BTreeMap> { + let mut archive_assets: BTreeMap> = BTreeMap::new(); + + for archive_path in archive_paths { + log::trace!( + "Getting assets loaded from the Bethesda archive at \"{}\"", + archive_path.display() + ); + + let assets = match get_assets_in_archive(archive_path) { + Ok(a) => a, + Err(e) => { + log::error!( + "Encountered an error while trying to read the Bethesda archive at \"{}\": {}", + archive_path.display(), + e + ); + continue; + } + }; + + let warn_on_hash_collisions = should_warn_on_hash_collisions(archive_path); + + for (folder_hash, file_hashes) in assets { + let entry_file_hashes = archive_assets.entry(folder_hash).or_default(); + + for file_hash in file_hashes { + if !entry_file_hashes.insert(file_hash) && warn_on_hash_collisions { + log::warn!( + "The folder and file with hashes {:x} and {:x} in \"{}\" are present in another Bethesda archive.", + folder_hash, + file_hash, + archive_path.display() + ); + } + } + } + } + + archive_assets +} + +fn should_warn_on_hash_collisions(archive_path: &Path) -> bool { + if !has_ascii_extension(archive_path, "ba2") { + return true; + } + + let filename = archive_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_ascii_lowercase(); + + filename.starts_with("fallout4 - ") || filename.starts_with("dlcultrahighresolution - ") +} + +fn get_assets_in_archive( + archive_path: &Path, +) -> Result>, GeneralError> { + let mut reader = BufReader::new(File::open(archive_path)?); + + let mut type_id: [u8; 4] = [0; 4]; + reader.read_exact(&mut type_id)?; + + match type_id { + bsa::TYPE_ID => bsa::read_assets(reader), + ba2::TYPE_ID => ba2::read_assets(reader), + _ => Err(InvalidArgumentError { + message: format!( + "Bethesda archive at \"{}\" has an unrecognised type ID", + archive_path.display() + ), + } + .into()), + } +} + +pub(super) fn to_u32(bytes: &[u8]) -> u32 { + let array = + <[u8; 4]>::try_from(&bytes[..4]).expect("Bytes slice is large enough to hold a u32"); + u32::from_le_bytes(array) +} + +pub(super) fn to_u64(bytes: &[u8]) -> u64 { + let array = + <[u8; 8]>::try_from(&bytes[..4]).expect("Bytes slice is large enough to hold a u64"); + u64::from_le_bytes(array) +} + +pub(super) fn to_usize(size: u32) -> usize { + usize::try_from(size).expect("usize can hold a u32") +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 00000000..f3c50d21 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,455 @@ +use std::{path::Path, str::FromStr}; + +use loot_condition_interpreter::Expression; + +use crate::{ + error::{FileAccessError, GeneralError, InvalidArgumentError}, + metadata::{ + File, Group, Message, PluginCleaningData, PluginMetadata, + metadata_document::MetadataDocument, + }, + sorting::{ + groups::{build_groups_graph, find_path}, + vertex::Vertex, + }, +}; + +/// The interface through which metadata can be accessed. +#[derive(Debug)] +pub struct Database { + masterlist: MetadataDocument, + userlist: MetadataDocument, + condition_evaluator_state: loot_condition_interpreter::State, +} + +impl Database { + #[must_use] + pub(crate) fn new(condition_evaluator_state: loot_condition_interpreter::State) -> Self { + Self { + masterlist: MetadataDocument::default(), + userlist: MetadataDocument::default(), + condition_evaluator_state, + } + } + + pub(crate) fn condition_evaluator_state_mut( + &mut self, + ) -> &mut loot_condition_interpreter::State { + &mut self.condition_evaluator_state + } + + /// Loads the masterlist from the given path. + /// + /// Replaces any existing data that was previously loaded from a masterlist. + pub fn load_masterlist(&mut self, path: &Path) -> Result<(), GeneralError> { + if path.exists() { + self.masterlist.load(path) + } else { + Err(FileAccessError { + message: format!( + "The given masterlist path does not exist: {}", + path.display() + ), + } + .into()) + } + } + + /// Loads the masterlist from the given path, using the prelude at the given + /// path. + /// + /// Replaces any existing data that was previously loaded from a masterlist. + pub fn load_masterlist_with_prelude( + &mut self, + masterlist_path: &Path, + prelude_path: &Path, + ) -> Result<(), GeneralError> { + if !masterlist_path.exists() { + Err(FileAccessError { + message: format!( + "The given masterlist path does not exist: {}", + masterlist_path.display() + ), + } + .into()) + } else if !prelude_path.exists() { + Err(FileAccessError { + message: format!( + "The given prelude path does not exist: {}", + prelude_path.display() + ), + } + .into()) + } else { + self.masterlist + .load_with_prelude(masterlist_path, prelude_path) + } + } + + /// Loads the userlist from the given path. + /// + /// Replaces any existing data that was previously loaded from a userlist. + pub fn load_userlist(&mut self, path: &Path) -> Result<(), GeneralError> { + if path.exists() { + self.userlist.load(path) + } else { + Err(FileAccessError { + message: format!("The given userlist path does not exist: {}", path.display()), + } + .into()) + } + } + + /// Writes a metadata file containing all loaded user-added metadata. + /// + /// If `output_path` already exists, it will be written if `overwrite` is + /// `true`, otherwise no data will be written. + pub fn write_user_metadata( + &self, + output_path: &Path, + overwrite: bool, + ) -> Result<(), GeneralError> { + validate_write_path(output_path, overwrite)?; + + self.userlist.save(output_path) + } + + /// Writes a metadata file that only contains plugin Bash Tag suggestions + /// and dirty info. + /// + /// If `output_path` already exists, it will be written if `overwrite` is + /// `true`, otherwise no data will be written. + pub fn write_minimal_list( + &self, + output_path: &Path, + overwrite: bool, + ) -> Result<(), GeneralError> { + validate_write_path(output_path, overwrite)?; + + let mut doc = MetadataDocument::default(); + + for plugin in self.masterlist.plugins() { + let mut minimal_plugin = PluginMetadata::new(plugin.name())?; + minimal_plugin.set_tags(plugin.tags().to_vec()); + minimal_plugin.set_dirty_info(plugin.dirty_info().to_vec()); + + doc.set_plugin_metadata(minimal_plugin); + } + + doc.save(output_path) + } + + /// Gets the Bash Tags that are listed in the loaded metadata lists. + /// + /// Bash Tag suggestions can include Bash Tags not in this list. + pub fn known_bash_tags(&self) -> Vec { + let mut tags = self.masterlist.bash_tags().to_vec(); + tags.extend_from_slice(self.userlist.bash_tags()); + + tags + } + + /// Get all general messages listed in the loaded metadata lists. + /// + /// If `evaluate_conditions` is `true`, any metadata conditions are + /// evaluated before the metadata is returned, otherwise unevaluated + /// metadata is returned. Evaluating general message conditions also clears + /// the condition cache before evaluating conditions. + pub fn general_messages( + &mut self, + evaluate_conditions: bool, + ) -> Result, GeneralError> { + let messages_iter = self + .masterlist + .messages() + .iter() + .chain(self.userlist.messages()); + + if evaluate_conditions { + self.condition_evaluator_state.clear_condition_cache()?; + + let messages = messages_iter + .filter_map(|m| { + filter_map_on_condition(m, m.condition(), &self.condition_evaluator_state) + }) + .collect::, _>>()?; + + Ok(messages) + } else { + Ok(messages_iter.cloned().collect()) + } + } + + /// Gets the groups that are defined in the loaded metadata lists. + /// + /// If `include_user_metadata` is `true`, any group metadata present in the + /// userlist is included in the returned metadata, otherwise the metadata + /// returned only includes metadata from the masterlist. + pub fn groups(&self, include_user_metadata: bool) -> Vec { + if include_user_metadata { + merge_groups(self.masterlist.groups(), self.userlist.groups()) + } else { + self.masterlist.groups().to_vec() + } + } + + /// Gets the groups that are defined or extended in the loaded userlist. + pub fn user_groups(&self) -> &[Group] { + self.userlist.groups() + } + + /// Sets the group definitions to store in the userlist, replacing any + /// definitions already loaded from the userlist. + pub fn set_user_groups(&mut self, groups: Vec) { + self.userlist.set_groups(groups); + } + + /// Get the "shortest" path between the two given groups according to their + /// "load after" metadata. + /// + /// The "shortest" path is defined as the path that maximises the amount of + /// user metadata involved while minimising the amount of masterlist + /// metadata involved. It's not the path involving the fewest groups. + /// + /// If there is no path between the two groups, the returned [Vec] will be + /// empty. + pub fn groups_path( + &self, + from_group_name: &str, + to_group_name: &str, + ) -> Result, GeneralError> { + let graph = build_groups_graph(self.masterlist.groups(), self.userlist.groups())?; + + find_path(&graph, from_group_name, to_group_name) + } + + /// Get all of a plugin's loaded metadata. + /// + /// If `include_user_metadata` is `true`, any user metadata the plugin has + /// is included in the returned metadata, otherwise the metadata returned + /// only includes metadata from the masterlist. + /// + /// If `evaluateConditions` is `true`, any metadata conditions are evaluated + /// before the metadata otherwise unevaluated metadata is returned. + /// Evaluating plugin metadata conditions does **not** clear the condition + /// cache. + pub fn plugin_metadata( + &self, + plugin_name: &str, + include_user_metadata: bool, + evaluate_conditions: bool, + ) -> Result, GeneralError> { + let mut metadata = self.masterlist.find_plugin(plugin_name)?; + + if include_user_metadata { + if let Some(mut user_metadata) = self.userlist.find_plugin(plugin_name)? { + if let Some(metadata) = metadata { + user_metadata.merge_metadata(&metadata); + } + metadata = Some(user_metadata); + } + } + + if evaluate_conditions { + if let Some(metadata) = metadata { + return evaluate_all_conditions(metadata, &self.condition_evaluator_state) + .map_err(Into::into); + } + } + + Ok(metadata) + } + + /// Get a plugin's metadata loaded from the given userlist. + /// + /// If `evaluateConditions` is `true`, any metadata conditions are evaluated + /// before the metadata otherwise unevaluated metadata is returned. + /// Evaluating plugin metadata conditions does **not** clear the condition + /// cache. + pub fn plugin_user_metadata( + &self, + plugin_name: &str, + evaluate_conditions: bool, + ) -> Result, GeneralError> { + let metadata = self.userlist.find_plugin(plugin_name); + + if evaluate_conditions { + if let Ok(Some(metadata)) = metadata { + return evaluate_all_conditions(metadata, &self.condition_evaluator_state) + .map_err(Into::into); + } + } + + metadata + } + + /// Sets a plugin's user metadata, replacing any loaded user metadata for + /// that plugin. + pub fn set_plugin_user_metadata(&mut self, plugin_metadata: PluginMetadata) { + self.userlist.set_plugin_metadata(plugin_metadata); + } + + /// Discards all loaded user metadata for the plugin with the given + /// filename. + pub fn discard_plugin_user_metadata(&mut self, plugin: &str) { + self.userlist.remove_plugin_metadata(plugin); + } + + /// Discards all loaded user metadata for all groups, plugins, and any + /// user-added general messages and known bash tags. + pub fn discard_all_user_metadata(&mut self) { + self.userlist.clear(); + } +} + +fn validate_write_path(output_path: &Path, overwrite: bool) -> Result<(), GeneralError> { + if !output_path.parent().map(|p| p.exists()).unwrap_or(false) { + Err(InvalidArgumentError { + message: "The output directory does not exist.".into(), + } + .into()) + } else if !overwrite && output_path.exists() { + Err(FileAccessError { + message: "Output file exists but overwrite is not set to true.".into(), + } + .into()) + } else { + Ok(()) + } +} + +fn merge_groups(lhs: &[Group], rhs: &[Group]) -> Vec { + let mut groups = lhs.to_vec(); + + let mut new_groups = Vec::new(); + + for rhs_group in rhs { + if let Some(group) = groups.iter_mut().find(|g| g.name() == rhs_group.name()) { + if rhs_group.description().is_some() || !rhs_group.after_groups().is_empty() { + let mut new_group = group.clone(); + + if let Some(description) = rhs_group.description() { + new_group = new_group.with_description(description.to_string()); + } + + if !rhs_group.after_groups().is_empty() { + let mut after_groups = new_group.after_groups().to_vec(); + after_groups.extend_from_slice(rhs_group.after_groups()); + + new_group = new_group.with_after_groups(after_groups); + } + + *group = new_group; + } + } else { + new_groups.push(rhs_group.clone()); + } + } + + groups.extend(new_groups); + + groups +} + +fn evaluate_all_conditions( + mut metadata: PluginMetadata, + state: &loot_condition_interpreter::State, +) -> Result, loot_condition_interpreter::Error> { + metadata.set_load_after_files(filter_files_on_conditions( + metadata.load_after_files(), + state, + )?); + + metadata.set_requirements(filter_files_on_conditions(metadata.requirements(), state)?); + + metadata.set_incompatibilities(filter_files_on_conditions( + metadata.incompatibilities(), + state, + )?); + + metadata.set_messages( + metadata + .messages() + .iter() + .filter_map(|m| filter_map_on_condition(m, m.condition(), state)) + .collect::, _>>()?, + ); + + metadata.set_tags( + metadata + .tags() + .iter() + .filter_map(|t| filter_map_on_condition(t, t.condition(), state)) + .collect::, _>>()?, + ); + + if !metadata.is_regex_plugin() { + metadata.set_dirty_info(filter_cleaning_data_on_conditions( + metadata.name(), + metadata.dirty_info(), + state, + )?); + + metadata.set_clean_info(filter_cleaning_data_on_conditions( + metadata.name(), + metadata.clean_info(), + state, + )?); + } + + if metadata.has_name_only() { + Ok(None) + } else { + Ok(Some(metadata)) + } +} + +fn evaluate_condition( + condition: Option<&str>, + state: &loot_condition_interpreter::State, +) -> Result { + if let Some(condition) = condition { + Expression::from_str(condition).and_then(|e| e.eval(state)) + } else { + Ok(true) + } +} + +fn filter_map_on_condition( + item: &T, + condition: Option<&str>, + state: &loot_condition_interpreter::State, +) -> Option> { + evaluate_condition(condition, state) + .map(|r| r.then(|| item.clone())) + .transpose() +} + +fn filter_files_on_conditions( + files: &[File], + state: &loot_condition_interpreter::State, +) -> Result, loot_condition_interpreter::Error> { + files + .iter() + .filter_map(|file| filter_map_on_condition(file, file.condition(), state)) + .collect::, _>>() +} + +fn filter_cleaning_data_on_conditions( + plugin_name: &str, + cleaning_info: &[PluginCleaningData], + state: &loot_condition_interpreter::State, +) -> Result, loot_condition_interpreter::Error> { + if plugin_name.is_empty() { + return Ok(Vec::new()); + } + + cleaning_info + .iter() + .filter_map(|i| { + let condition = format!("checksum(\"{}\", {:08X})", plugin_name, i.crc()); + + filter_map_on_condition(i, Some(condition.as_str()), state) + }) + .collect::, _>>() +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 00000000..264a6dbd --- /dev/null +++ b/src/error.rs @@ -0,0 +1,353 @@ +use std::fmt::Display; + +use petgraph::graph::NodeIndex; +use saphyr::Marker; + +use crate::{ + metadata::{ + MessageContent, + yaml::{YamlObjectType, to_yaml}, + }, + sorting::vertex::Vertex, +}; + +#[derive(Debug)] +pub struct CyclicInteractionError { + pub cycle: Vec, +} + +impl CyclicInteractionError { + pub fn new(cycle: Vec) -> Self { + Self { cycle } + } +} + +impl Display for CyclicInteractionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let cycle: String = self + .cycle + .iter() + .map(|v| { + if let Some(edge_type) = v.out_edge_type() { + format!("{} --[{}]-> ", v.name(), edge_type) + } else { + v.name().to_string() + } + }) + .chain(self.cycle.first().iter().map(|v| v.name().to_string())) + .collect(); + write!(f, "Cyclic interaction detected: {}", cycle) + } +} + +impl std::error::Error for CyclicInteractionError {} + +#[derive(Debug)] +pub struct FileAccessError { + pub message: String, +} + +impl FileAccessError { + pub(crate) fn new(message: String) -> Self { + FileAccessError { message } + } +} + +impl std::fmt::Display for FileAccessError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for FileAccessError {} + +#[derive(Debug)] +pub struct UndefinedGroupError { + pub group_name: String, +} + +impl UndefinedGroupError { + pub fn new(group_name: String) -> Self { + Self { group_name } + } +} + +impl Display for UndefinedGroupError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "The group \"{}\" does not exist", self.group_name) + } +} + +impl std::error::Error for UndefinedGroupError {} + +#[derive(Debug)] +pub struct InvalidArgumentError { + pub message: String, +} + +impl std::fmt::Display for InvalidArgumentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for InvalidArgumentError {} + +#[derive(Debug)] +pub(crate) struct YamlMergeKeyError { + value: saphyr::MarkedYaml, +} + +impl YamlMergeKeyError { + pub(crate) fn new(value: saphyr::MarkedYaml) -> Self { + YamlMergeKeyError { value } + } +} + +impl std::fmt::Display for YamlMergeKeyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut output = String::new(); + + let yaml = to_yaml(&self.value); + + if saphyr::YamlEmitter::new(&mut output).dump(&yaml).is_ok() { + write!( + f, + "Invalid YAML merge key value at line {} column {}: {}", + self.value.span.start.line(), + self.value.span.start.col(), + output + ) + } else { + write!( + f, + "Invalid YAML merge key value at line {} column {}: {:?}", + self.value.span.start.line(), + self.value.span.start.col(), + self.value + ) + } + } +} + +impl std::error::Error for YamlMergeKeyError {} + +#[derive(Debug)] +pub struct YamlParseError { + marker: saphyr::Marker, + message: String, +} + +impl YamlParseError { + pub fn new(marker: Marker, message: String) -> Self { + YamlParseError { marker, message } + } + + pub fn missing_key(marker: Marker, key: &str, yaml_type: YamlObjectType) -> Self { + YamlParseError::new( + marker, + format!("'{}' key missing from '{}' map object", key, yaml_type), + ) + } +} + +impl std::fmt::Display for YamlParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Encountered a YAML parsing error at line {} column {}: {}", + self.marker.line(), + self.marker.col(), + self.message + ) + } +} + +impl std::error::Error for YamlParseError {} + +#[derive(Debug)] +pub struct InvalidMultilingualMessageContents {} + +impl std::fmt::Display for InvalidMultilingualMessageContents { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Multilingual messages must contain a content string that uses the {} language code", + MessageContent::DEFAULT_LANGUAGE + ) + } +} + +impl std::error::Error for InvalidMultilingualMessageContents {} + +#[derive(Debug)] +pub struct PoisonedMutexError; + +impl std::fmt::Display for PoisonedMutexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "A mutex is poisoned",) + } +} + +impl std::error::Error for PoisonedMutexError {} + +#[derive(Debug)] +pub struct PathfindingError { + message: String, +} + +impl PathfindingError { + pub fn new(message: String) -> Self { + Self { message } + } +} + +impl std::fmt::Display for PathfindingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for PathfindingError {} + +#[derive(Debug)] +pub struct SortingLogicError { + message: String, +} + +impl SortingLogicError { + pub fn new(message: String) -> Self { + Self { message } + } +} + +impl std::fmt::Display for SortingLogicError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for SortingLogicError {} + +#[derive(Debug)] +pub struct GeneralError(Box); + +impl std::fmt::Display for GeneralError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl std::error::Error for GeneralError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.0.as_ref()) + } +} + +impl From for GeneralError { + fn from(value: std::io::Error) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: saphyr::ScanError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: YamlMergeKeyError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: FileAccessError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: YamlParseError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: loot_condition_interpreter::Error) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: std::num::TryFromIntError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: InvalidMultilingualMessageContents) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: InvalidArgumentError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: loadorder::Error) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From> for GeneralError { + fn from(_: std::sync::PoisonError) -> Self { + GeneralError(Box::new(PoisonedMutexError)) + } +} + +impl From for GeneralError { + fn from(value: esplugin::Error) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From> for GeneralError { + fn from(value: Box) -> Self { + GeneralError(value) + } +} + +impl From for GeneralError { + fn from(value: UndefinedGroupError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: CyclicInteractionError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From for GeneralError { + fn from(value: PathfindingError) -> Self { + GeneralError(Box::new(value)) + } +} + +impl From> for GeneralError { + fn from(_: petgraph::algo::Cycle) -> Self { + GeneralError(Box::new(CyclicInteractionError { cycle: Vec::new() })) + } +} + +impl From for GeneralError { + fn from(value: SortingLogicError) -> Self { + GeneralError(Box::new(value)) + } +} diff --git a/src/game.rs b/src/game.rs new file mode 100644 index 00000000..ba513080 --- /dev/null +++ b/src/game.rs @@ -0,0 +1,745 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt::Display, + path::{Path, PathBuf}, + sync::{Arc, RwLock}, +}; + +use loadorder::WritableLoadOrder; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; + +use crate::{ + database::Database, + error::{GeneralError, InvalidArgumentError}, + metadata::{ + Filename, + plugin_metadata::{GHOST_FILE_EXTENSION, iends_with_ascii}, + }, + plugin::{LoadScope, Plugin, is_valid_plugin, plugins_metadata}, + sorting::{ + groups::build_groups_graph, + plugins::{PluginSortingData, sort_plugins}, + }, +}; + +/// Codes used to create database handles for specific games. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum GameType { + /// The Elder Scrolls IV: Oblivion + TES4, + /// The Elder Scrolls V: Skyrim + TES5, + /// Fallout 3 + FO3, + /// Fallout: New Vegas + FONV, + /// Fallout 4 + FO4, + /// The Elder Scrolls V: Skyrim Special Edition + TES5SE, + /// Fallout 4 VR + FO4VR, + /// Skyrim VR + TES5VR, + /// The Elder Scrolls III: Morrowind + TES3, + /// Starfield + Starfield, + /// OpenMW + OpenMW, +} + +impl Display for GameType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GameType::TES4 => write!(f, "The Elder Scrolls IV: Oblivion"), + GameType::TES5 => write!(f, "The Elder Scrolls V: Skyrim"), + GameType::FO3 => write!(f, "Fallout 3"), + GameType::FONV => write!(f, "Fallout: New Vegas"), + GameType::FO4 => write!(f, "Fallout 4"), + GameType::TES5SE => write!(f, "The Elder Scrolls V: Skyrim Special Edition"), + GameType::FO4VR => write!(f, "Fallout 4 VR"), + GameType::TES5VR => write!(f, "The Elder Scrolls V: Skyrim VR"), + GameType::TES3 => write!(f, "The Elder Scrolls III: Morrowind"), + GameType::Starfield => write!(f, "Starfield"), + GameType::OpenMW => write!(f, "OpenMW"), + } + } +} + +impl From for loadorder::GameId { + fn from(value: GameType) -> Self { + match value { + GameType::TES4 => loadorder::GameId::Oblivion, + GameType::TES5 => loadorder::GameId::Skyrim, + GameType::FO3 => loadorder::GameId::Fallout3, + GameType::FONV => loadorder::GameId::FalloutNV, + GameType::FO4 => loadorder::GameId::Fallout4, + GameType::TES5SE => loadorder::GameId::SkyrimSE, + GameType::FO4VR => loadorder::GameId::Fallout4VR, + GameType::TES5VR => loadorder::GameId::SkyrimVR, + GameType::TES3 => loadorder::GameId::Morrowind, + GameType::Starfield => loadorder::GameId::Starfield, + GameType::OpenMW => loadorder::GameId::OpenMW, + } + } +} + +impl From for loot_condition_interpreter::GameType { + fn from(value: GameType) -> Self { + match value { + GameType::TES4 => loot_condition_interpreter::GameType::Oblivion, + GameType::TES5 => loot_condition_interpreter::GameType::Skyrim, + GameType::FO3 => loot_condition_interpreter::GameType::Fallout3, + GameType::FONV => loot_condition_interpreter::GameType::FalloutNV, + GameType::FO4 => loot_condition_interpreter::GameType::Fallout4, + GameType::TES5SE => loot_condition_interpreter::GameType::SkyrimSE, + GameType::FO4VR => loot_condition_interpreter::GameType::Fallout4VR, + GameType::TES5VR => loot_condition_interpreter::GameType::SkyrimVR, + GameType::TES3 => loot_condition_interpreter::GameType::Morrowind, + GameType::Starfield => loot_condition_interpreter::GameType::Starfield, + GameType::OpenMW => loot_condition_interpreter::GameType::OpenMW, + } + } +} + +impl From for esplugin::GameId { + fn from(value: GameType) -> Self { + match value { + GameType::TES4 => esplugin::GameId::Oblivion, + GameType::TES5 => esplugin::GameId::Skyrim, + GameType::FO3 => esplugin::GameId::Fallout3, + GameType::FONV => esplugin::GameId::FalloutNV, + GameType::FO4 | GameType::FO4VR => esplugin::GameId::Fallout4, + GameType::TES5SE | GameType::TES5VR => esplugin::GameId::SkyrimSE, + GameType::TES3 | GameType::OpenMW => esplugin::GameId::Morrowind, + GameType::Starfield => esplugin::GameId::Starfield, + } + } +} + +/// The interface through which game-specific functionality is provided. +#[derive(Debug)] +pub struct Game { + game_type: GameType, + game_path: PathBuf, + load_order: Box<(dyn WritableLoadOrder + Send + Sync + 'static)>, + // Stored in an Arc> to support loading metadata in parallel with + // loading plugins. + database: Arc>, + cache: GameCache, +} + +impl Game { + /// Initialise a new game handle, which is then used by all game-specific + /// functions. + /// + /// - `game_type` is a value representing which game to create the handle + /// for, + /// - `game_path` is the relative or absolute path to the directory + /// containing the game's executable. + /// + /// This function will attempt to look up the game's local data path, which + /// may fail in some situations (e.g. when running libloot natively on Linux + /// for a game other than Morrowind or OpenMW). [Game::with_local_path] + /// can be used to provide the local path instead. + pub fn new(game_type: GameType, game_path: &Path) -> Result { + log::info!( + "Attempting to create a game handle for game type {} with game path {:?}", + game_type, + game_path + ); + + let resolved_game_path = resolve_path(game_path); + if !resolved_game_path.is_dir() { + return Err(InvalidArgumentError { + message: format!( + "Given game path \"{:?}\" does not resolve to a valid directory.", + game_path + ), + } + .into()); + } + + let load_order = + loadorder::GameSettings::new(game_type.into(), &resolved_game_path)?.into_load_order(); + + let condition_evaluator_state = + new_condition_evaluator_state(game_type, &resolved_game_path, load_order.as_ref()); + + Ok(Game { + game_type, + game_path: resolved_game_path, + load_order, + database: Arc::new(RwLock::new(Database::new(condition_evaluator_state))), + cache: GameCache::default(), + }) + } + + /// Initialise a new game handle, which is then used by all game-specific + /// functions. + /// + /// - `game_type` is a value representing which game to create the handle + /// for, + /// - `game_path` is the relative or absolute path to the directory + /// containing the game's executable. + /// - `game_local_path` is the relative or absolute path to the game's local + /// data path. The local data folder is usually in `%%LOCALAPPDATA%`, but + /// Morrowind has no local data folder and OpenMW's is in the user's My + /// Games folder on Windows and in `$HOME/.config` on Linux. + pub fn with_local_path( + game_type: GameType, + game_path: &Path, + game_local_path: &Path, + ) -> Result { + log::info!( + "Attempting to create a game handle for game type {} with game path {:?} and game local path {:?}", + game_type, + game_path, + game_local_path + ); + + let resolved_game_path = resolve_path(game_path); + if !resolved_game_path.is_dir() { + return Err(InvalidArgumentError { + message: format!( + "Given game path \"{:?}\" does not resolve to a valid directory.", + game_path + ), + } + .into()); + } + + let resolved_game_local_path = resolve_path(game_local_path); + if resolved_game_local_path.exists() && !resolved_game_local_path.is_dir() { + return Err(InvalidArgumentError { + message: format!( + "Given game local path \"{:?}\" resolves to a path that exists but is not a valid directory.", + game_local_path + ), + }.into()); + } + + let load_order = loadorder::GameSettings::with_local_path( + game_type.into(), + &resolved_game_path, + &resolved_game_local_path, + )? + .into_load_order(); + + let condition_evaluator_state = + new_condition_evaluator_state(game_type, &resolved_game_path, load_order.as_ref()); + + Ok(Game { + game_type, + game_path: resolved_game_path, + load_order, + database: Arc::new(RwLock::new(Database::new(condition_evaluator_state))), + cache: GameCache::default(), + }) + } + + /// Get the game's type. + pub fn game_type(&self) -> GameType { + self.game_type + } + + /// Gets the currently-set additional data paths. + /// + /// The following games are configured with additional data paths by + /// default: + /// + /// - Fallout 4, when installed from the Microsoft Store + /// - Starfield + /// - OpenMW + pub fn additional_data_paths(&self) -> &[PathBuf] { + self.load_order + .game_settings() + .additional_plugins_directories() + } + + /// Set additional data paths. + /// + /// The additional data paths are used when interacting with the load order, + /// evaluating conditions and scanning for archives (BSA/BA2 depending on + /// the game). Additional data paths are used in the order they are given + /// (except with OpenMW, which checks them in reverse order), and take + /// precedence over the game's main data path. + /// + /// Setting additional data paths clears the condition cache in this game's + /// database object. + pub fn set_additional_data_paths( + &mut self, + additional_data_paths: &[&Path], + ) -> Result<(), GeneralError> { + let paths: Vec<_> = additional_data_paths + .iter() + .map(|p| p.to_path_buf()) + .collect(); + + let mut database = self.database.write()?; + let state = database.condition_evaluator_state_mut(); + state.clear_condition_cache()?; + + self.load_order + .game_settings_mut() + .set_additional_plugins_directories(paths.clone()); + + state.set_additional_data_paths(paths); + + Ok(()) + } + + /// Get the object used for accessing metadata-related functionality. + pub fn database(&self) -> Arc> { + Arc::clone(&self.database) + } + + /// Check if a file is a valid plugin. + /// + /// The validity check is not exhaustive: it generally checks that the + /// file is a valid plugin file extension for the game and that its header + /// (if applicable) can be parsed. + /// + /// `plugin_path` can be absolute or relative: relative paths are resolved + /// relative to the game's plugins directory, while absolute paths are used + /// as given. + pub fn is_valid_plugin(&self, plugin_path: &Path) -> bool { + is_valid_plugin(self.game_type, plugin_path) + } + + /// Fully parses plugins and loads their data. + /// + /// If a given plugin filename (or one that is case-insensitively equal) has + /// already been loaded, its previously-loaded data data is discarded. + /// + /// If the game is Morrowind, OpenMW or Starfield, it's only valid to fully + /// load a plugin if its masters are already loaded or included in the same + /// input slice. + /// + /// Relative paths in `plugin_paths` are resolved relative to the game's + /// plugins directory, while absolute paths are used as given. Each plugin + /// filename must be unique within the vector. + /// + /// Loading plugins clears the condition cache in this game's database + /// object. + pub fn load_plugins(&mut self, plugin_paths: &[&Path]) -> Result<(), GeneralError> { + let mut plugins = self.load_plugins_common(plugin_paths, LoadScope::WholePlugin)?; + + if matches!( + self.game_type, + GameType::TES3 | GameType::OpenMW | GameType::Starfield + ) { + let plugins_metadata = plugins_metadata(&plugins)?; + + for plugin in &mut plugins { + plugin.resolve_record_ids(&plugins_metadata)?; + } + } + + self.store_plugins(plugins) + } + + /// Parses plugin headers and loads their data. + /// + /// If a given plugin filename (or one that is case-insensitively equal) has + /// already been loaded, its previously-loaded data data is discarded. + /// + /// Relative paths in `plugin_paths` are resolved relative to the game's + /// plugins directory, while absolute paths are used as given. Each plugin + /// filename must be unique within the vector. + /// + /// Loading plugins clears the condition cache in this game's database + /// object. + pub fn load_plugin_headers(&mut self, plugin_paths: &[&Path]) -> Result<(), GeneralError> { + let plugins = self.load_plugins_common(plugin_paths, LoadScope::HeaderOnly)?; + + self.store_plugins(plugins) + } + + fn load_plugins_common( + &mut self, + plugin_paths: &[&Path], + load_scope: LoadScope, + ) -> Result, GeneralError> { + validate_plugin_paths(self.game_type, plugin_paths)?; + + let data_path = data_path(self.game_type, &self.game_path); + + let archive_paths = + find_archives(self.game_type, self.additional_data_paths(), &data_path)?; + + self.cache.set_archive_paths(archive_paths); + + log::trace!("Starting loading {}s.", load_scope); + + let plugins: Vec<_> = plugin_paths + .par_iter() + .filter_map(|path| { + try_load_plugin(&data_path, path, self.game_type, &self.cache, load_scope) + }) + .collect(); + + Ok(plugins) + } + + fn store_plugins(&mut self, plugins: Vec) -> Result<(), GeneralError> { + self.cache.insert_plugins(plugins); + + let mut database = self.database.write()?; + update_loaded_plugin_state( + database.condition_evaluator_state_mut(), + self.cache.plugins(), + ) + } + + /// Clears the plugins loaded by previous calls to [Game::load_plugins] or + /// [Game::load_plugin_headers]. + pub fn clear_loaded_plugins(&mut self) { + self.cache.clear_plugins(); + } + + /// Get data for a loaded plugin. + pub fn plugin(&self, plugin_name: &str) -> Option<&Plugin> { + self.cache.plugin(plugin_name) + } + + /// Get data for all loaded plugins. + pub fn loaded_plugins(&self) -> Vec<&Plugin> { + self.cache.plugins().collect() + } + + /// Calculates a new load order for the game's installed plugins (including + /// inactive plugins) and returns the sorted order. + /// + /// This pulls metadata from the masterlist and userlist if they are loaded, + /// and uses the loaded data of each plugin. No changes are applied to the + /// load order used by the game. This function does not load or evaluate the + /// masterlist or userlist. + /// + /// The order in which plugins are listed in `plugin_filenames` is used as + /// their current load order. All given plugins must have been already been + /// loaded using [Game::load_plugins] or [Game::load_plugin_headers]. + pub fn sort_plugins(&self, plugin_names: &[&str]) -> Result, GeneralError> { + let plugins = plugin_names + .iter() + .map(|n| { + self.plugin(n).ok_or_else(|| InvalidArgumentError { + message: format!("The plugin \"{}\" has not been loaded.", n), + }) + }) + .collect::, _>>()?; + + let database = self.database.read()?; + + let plugins_sorting_data = plugins + .into_iter() + .enumerate() + .map(|(i, p)| { + let masterlist_metadata = database.plugin_metadata(p.name(), false, true)?; + let user_metadata = database.plugin_user_metadata(p.name(), true)?; + PluginSortingData::new(p, masterlist_metadata.as_ref(), user_metadata.as_ref(), i) + }) + .collect::, _>>()?; + + if log::log_enabled!(log::Level::Debug) { + log::debug!("Current load order:"); + for plugin_name in plugin_names { + log::debug!("\t{}", plugin_name); + } + } + + let groups_graph = build_groups_graph(&database.groups(false), database.user_groups())?; + + let new_load_order = sort_plugins( + plugins_sorting_data, + &groups_graph, + self.load_order.game_settings().early_loading_plugins(), + )?; + + if log::log_enabled!(log::Level::Debug) { + log::debug!("Sorted load order:"); + for plugin_name in &new_load_order { + log::debug!("\t{}", plugin_name); + } + } + + Ok(new_load_order) + } + + /// Load the current load order state, discarding any previously held state. + /// + /// This function should be called whenever the load order or active state + /// of plugins "on disk" changes, so that the cached state is updated to + /// reflect the changes. + /// + /// Loading the current load order state clears the condition cache in this + /// game's database object. + pub fn load_current_load_order_state(&mut self) -> Result<(), GeneralError> { + self.load_order.load()?; + + let mut database = self.database.write()?; + let state = database.condition_evaluator_state_mut(); + state.clear_condition_cache()?; + state.set_active_plugins(&self.load_order.active_plugin_names()); + Ok(()) + } + + /// Check if the load order is ambiguous. + /// + /// This checks that all plugins in the current load order state have a + /// well-defined position in the "on disk" state, and that all data sources + /// are consistent. If the load order is ambiguous, different applications + /// may read different load orders from the same source data. + pub fn is_load_order_ambiguous(&self) -> Result { + self.load_order.is_ambiguous() + } + + /// Gets the path to the file that holds the list of active plugins. + /// The active plugins file path is often within the game's local path, but + /// its name and location varies by game and game configuration, so this + /// function exposes the path that libloot uses. + pub fn active_plugins_file_path(&self) -> &PathBuf { + self.load_order.game_settings().active_plugins_file() + } + + /// Check if the given plugin is active. + pub fn is_plugin_active(&self, plugin_name: &str) -> bool { + self.load_order.is_active(plugin_name) + } + + /// Get the current load order. + pub fn load_order(&self) -> Vec<&str> { + self.load_order.plugin_names() + } + + /// Set the game's load order. + /// + /// There is no way to persist the load order of inactive OpenMW plugins, so + /// setting an OpenMW load order will have no effect if the relative order + /// of active plugins is unchanged. + pub fn set_load_order(&mut self, load_order: &[&str]) -> Result<(), loadorder::Error> { + self.load_order.set_load_order(load_order) + } +} + +fn resolve_path(path: &Path) -> PathBuf { + if path.is_symlink() { + path.read_link().unwrap_or_else(|_| path.to_path_buf()) + } else { + path.to_path_buf() + } +} + +fn data_path(game_type: GameType, game_path: &Path) -> PathBuf { + match game_type { + GameType::TES3 => game_path.join("Data Files"), + GameType::OpenMW => game_path.join("resources/vfs"), + _ => game_path.join("Data"), + } +} + +fn new_condition_evaluator_state( + game_type: GameType, + game_path: &Path, + load_order: &(dyn WritableLoadOrder + Send + Sync + 'static), +) -> loot_condition_interpreter::State { + let data_path = data_path(game_type, game_path); + + let mut condition_evaluator_state = + loot_condition_interpreter::State::new(game_type.into(), data_path); + condition_evaluator_state.set_additional_data_paths( + load_order + .game_settings() + .additional_plugins_directories() + .to_vec(), + ); + + condition_evaluator_state +} + +fn validate_plugin_paths( + game_type: GameType, + plugin_paths: &[&Path], +) -> Result<(), InvalidArgumentError> { + // Check that all plugin filenames are unique. + let mut set = HashSet::new(); + for path in plugin_paths { + let filename = match path.file_name() { + Some(f) => f.to_string_lossy(), + None => { + return Err(InvalidArgumentError { + message: format!("The path \"{}\" has no filename.", path.display()), + }); + } + }; + if !set.insert(Filename::new(filename.to_string())) { + return Err(InvalidArgumentError { + message: format!("The filename \"{}\" is not unique.", filename), + }); + } + } + + let invalid_path = plugin_paths + .par_iter() + .find_any(|path| !is_valid_plugin(game_type, path)); + if let Some(invalid_path) = invalid_path { + return Err(InvalidArgumentError { + message: format!("\"{}\" is not a valid plugin", invalid_path.display()), + }); + } + + Ok(()) +} + +fn find_archives( + game_type: GameType, + additional_data_paths: &[PathBuf], + data_path: &Path, +) -> std::io::Result> { + let extension = archive_file_extension(game_type); + + let mut archive_paths = Vec::new(); + for path in additional_data_paths { + let paths = find_archives_in_path(path, extension)?; + archive_paths.extend(paths); + } + + let paths = find_archives_in_path(data_path, extension)?; + archive_paths.extend(paths); + + Ok(archive_paths) +} + +fn archive_file_extension(game_type: GameType) -> &'static str { + match game_type { + GameType::FO4 | GameType::FO4VR | GameType::Starfield => ".ba2", + _ => ".bsa", + } +} + +fn find_archives_in_path( + parent_path: &Path, + archive_file_extension: &str, +) -> std::io::Result> { + if !parent_path.exists() { + return Ok(Vec::new()); + } + + let paths = std::fs::read_dir(parent_path)? + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_type().map(|f| f.is_file()).unwrap_or(false) + && iends_with_ascii(&e.file_name().to_string_lossy(), archive_file_extension) + }) + .map(|e| e.path()) + .collect(); + + Ok(paths) +} + +fn try_load_plugin( + data_path: &Path, + plugin_path: &Path, + game_type: GameType, + game_cache: &GameCache, + load_scope: LoadScope, +) -> Option { + let resolved_path = resolve_plugin_path(game_type, data_path, plugin_path); + + match Plugin::new(game_type, game_cache, &resolved_path, load_scope) { + Ok(p) => Some(p), + Err(e) => { + log::error!( + "Caught error while trying to load \"{}\": {}", + plugin_path.display(), + e + ); + None + } + } +} + +fn resolve_plugin_path(game_type: GameType, data_path: &Path, plugin_path: &Path) -> PathBuf { + let plugin_path = data_path.join(plugin_path); + + if game_type != GameType::OpenMW && !plugin_path.exists() { + if let Some(filename) = plugin_path.file_name() { + log::debug!( + "Could not find plugin at {}, adding {} file extension", + plugin_path.display(), + GHOST_FILE_EXTENSION + ); + let mut filename = filename.to_os_string(); + filename.push(GHOST_FILE_EXTENSION); + plugin_path.with_file_name(filename) + } else { + plugin_path + } + } else { + plugin_path + } +} + +fn update_loaded_plugin_state<'a>( + state: &mut loot_condition_interpreter::State, + plugins: impl Iterator, +) -> Result<(), GeneralError> { + let mut plugin_versions = Vec::new(); + let mut plugin_crcs = Vec::new(); + + for plugin in plugins { + if let Some(version) = plugin.version() { + plugin_versions.push((plugin.name(), version)); + } + + if let Some(crc) = plugin.crc() { + plugin_crcs.push((plugin.name(), crc)); + } + } + + state.clear_condition_cache()?; + + state.set_plugin_versions(&plugin_versions); + + state.set_cached_crcs(&plugin_crcs)?; + + Ok(()) +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct GameCache { + plugins: HashMap, + archive_paths: HashSet, +} + +impl GameCache { + fn set_archive_paths(&mut self, archive_paths: Vec) { + self.archive_paths.clear(); + self.archive_paths.extend(archive_paths); + } + + fn insert_plugins(&mut self, plugins: Vec) { + for plugin in plugins { + self.plugins + .insert(Filename::new(plugin.name().to_string()), plugin); + } + } + + fn clear_plugins(&mut self) { + self.plugins.clear(); + } + + fn plugins(&self) -> impl Iterator { + self.plugins.values() + } + + fn plugin(&self, plugin_name: &str) -> Option<&Plugin> { + self.plugins.get(&Filename::new(plugin_name.to_string())) + } + + pub fn archives(&self) -> impl Iterator { + self.archive_paths.iter() + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..4abe3ac8 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,24 @@ +mod archive; +mod database; +pub mod error; +mod game; +mod logging; +pub mod metadata; +mod plugin; +mod sorting; +mod version; + +pub use database::Database; +use fancy_regex::{Regex, RegexBuilder}; +pub use game::{Game, GameType}; +pub use logging::{LogLevel, set_logging_callback}; +pub use plugin::Plugin; +pub use sorting::vertex::{EdgeType, Vertex}; +pub use version::{ + LIBLOOT_VERSION_MAJOR, LIBLOOT_VERSION_MINOR, LIBLOOT_VERSION_PATCH, is_compatible, + libloot_revision, libloot_version, +}; + +fn regex(name: &str) -> Result> { + Ok(RegexBuilder::new(name).case_insensitive(true).build()?) +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 00000000..eaa096d7 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,74 @@ +use log::{Metadata, Record}; + +// const LOGGER: OnceCell>> = OnceCell::new(); + +/// Set the callback function that is called when logging. +/// +/// The `callback` function's first parameter is the level of the message being +/// logged, and the second is the message itself. +pub fn set_logging_callback(callback: T) +where + T: Fn(LogLevel, &str) + Send + Sync + 'static, +{ + // FIXME: set_boxed_logger can only be called once, and it's not possible to retrieve and downcast the logger from log once set. + let logger = Box::new(CallbackLogger { callback }); + + log::set_boxed_logger(logger) + .map(|_| log::set_max_level(log::LevelFilter::Trace)) + .unwrap(); +} + +/// Codes used to specify different levels of API logging. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warning, + Error, + Fatal, +} + +impl std::fmt::Display for LogLevel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LogLevel::Trace => write!(f, "trace"), + LogLevel::Debug => write!(f, "debug"), + LogLevel::Info => write!(f, "info"), + LogLevel::Warning => write!(f, "warning"), + LogLevel::Error => write!(f, "error"), + LogLevel::Fatal => write!(f, "fatal"), + } + } +} + +impl From for LogLevel { + fn from(value: log::Level) -> Self { + match value { + log::Level::Trace => LogLevel::Trace, + log::Level::Debug => LogLevel::Debug, + log::Level::Info => LogLevel::Info, + log::Level::Warn => LogLevel::Warning, + log::Level::Error => LogLevel::Error, + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +struct CallbackLogger { + callback: T, +} + +impl log::Log for CallbackLogger { + fn enabled(&self, _metadata: &Metadata) -> bool { + true + } + + fn log(&self, record: &Record) { + if self.enabled(record.metadata()) { + (self.callback)(record.level().into(), &format!("{}", record.args())); + } + } + + fn flush(&self) {} +} diff --git a/src/metadata/file.rs b/src/metadata/file.rs new file mode 100644 index 00000000..b157a617 --- /dev/null +++ b/src/metadata/file.rs @@ -0,0 +1,163 @@ +use std::str::FromStr; + +use loot_condition_interpreter::Expression; +use saphyr::{MarkedYaml, YamlData}; +use unicase::UniCase; + +use crate::error::{GeneralError, InvalidMultilingualMessageContents, YamlParseError}; + +use super::{ + message::{MessageContent, parse_message_contents_yaml, validate_message_contents}, + yaml::{YamlObjectType, as_string_node, get_required_string_value, get_string_value}, +}; + +/// Represents a file in a game's Data folder, including files in +/// subdirectories. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct File { + name: Filename, + display_name: Option, + detail: Vec, + condition: Option, +} + +impl File { + /// Construct a [File] with the given name. This can also be a relative path. + #[must_use] + pub fn new(name: String) -> Self { + Self { + name: Filename::new(name), + ..Default::default() + } + } + + /// Set the name to be displayed for the file in messages, formatted using + /// CommonMark. + #[must_use] + pub fn with_display_name(mut self, display_name: String) -> Self { + self.display_name = Some(display_name); + self + } + + /// Set the condition string. + #[must_use] + pub fn with_condition(mut self, condition: String) -> Self { + self.condition = Some(condition); + self + } + + /// Set the detail message content, which may be appended to any messages + /// generated for this file. If multilingual, one language must be + /// [MessageContent::DEFAULT_LANGUAGE]. + pub fn with_detail( + mut self, + detail: Vec, + ) -> Result { + validate_message_contents(&detail)?; + self.detail = detail; + Ok(self) + } + + /// Gets the name of the file (which may actually be a path). + pub fn name(&self) -> &Filename { + &self.name + } + + /// Get the display name of the file. + pub fn display_name(&self) -> Option<&str> { + self.display_name.as_deref() + } + + /// Get the detail message content of the file. + /// + /// If this file causes an error message to be displayed, the detail message + /// content should be appended to that message, as it provides more detail + /// about the error (e.g. suggestions for how to resolve it). + pub fn detail(&self) -> &[MessageContent] { + &self.detail + } + + /// Get the condition string. + pub fn condition(&self) -> Option<&str> { + self.condition.as_deref() + } +} + +/// Represents a case-insensitive filename. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Filename(UniCase); + +impl Filename { + /// Construct a Filename using the given string. + #[must_use] + pub fn new(s: String) -> Self { + Filename(UniCase::new(s)) + } + + /// Get this Filename as a string. + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for &Filename { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Filename { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom<&MarkedYaml> for File { + type Error = GeneralError; + + fn try_from(value: &MarkedYaml) -> Result { + match &value.data { + YamlData::String(s) => Ok(File { + name: Filename(UniCase::new(s.to_string())), + display_name: None, + detail: Vec::new(), + condition: None, + }), + YamlData::Hash(h) => { + let name = + get_required_string_value(value.span.start, h, "name", YamlObjectType::File)?; + + let display_name = get_string_value(h, "display", YamlObjectType::File)?; + + let detail = match h.get(&as_string_node("detail")) { + Some(n) => parse_message_contents_yaml( + n, + "detail", + YamlObjectType::PluginCleaningData, + )?, + None => Vec::new(), + }; + + let condition = match get_string_value(h, "condition", YamlObjectType::File)? { + Some(n) => { + Expression::from_str(n)?; + Some(n.to_string()) + } + None => None, + }; + + Ok(File { + name: Filename(UniCase::new(name.to_string())), + display_name: display_name.map(|s| s.to_string()), + detail, + condition, + }) + } + _ => Err(YamlParseError::new( + value.span.start, + "'file' object must be a map or string".into(), + ) + .into()), + } + } +} diff --git a/src/metadata/group.rs b/src/metadata/group.rs new file mode 100644 index 00000000..2dd1750d --- /dev/null +++ b/src/metadata/group.rs @@ -0,0 +1,91 @@ +use saphyr::MarkedYaml; + +use super::yaml::{ + YamlObjectType, get_as_hash, get_required_string_value, get_string_value, get_strings_vec_value, +}; +use crate::error::YamlParseError; + +/// Represents a group to which plugin metadata objects can belong. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Group { + name: String, + description: Option, + after_groups: Vec, +} + +impl Group { + /// Construct a [Group] with the given name. + #[must_use] + pub fn new(name: String) -> Self { + Self { + name, + ..Default::default() + } + } + + /// Set a description for the group. + #[must_use] + pub fn with_description(mut self, description: String) -> Self { + self.description = Some(description); + self + } + + /// Set the names of the groups that this group loads after. + #[must_use] + pub fn with_after_groups(mut self, after_groups: Vec) -> Self { + self.after_groups = after_groups; + self + } + + /// The name of the group to which all plugins belong by default. + pub const DEFAULT_NAME: &'static str = "default"; + + /// Get the name of the group. + pub fn name(&self) -> &str { + &self.name + } + + /// Get the description of the group. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// Get the names of the groups that this group loads after. + pub fn after_groups(&self) -> &[String] { + &self.after_groups + } +} + +impl std::default::Default for Group { + /// Construct a Group with the default name and an empty set of groups to + /// load after. + #[must_use] + fn default() -> Self { + Self { + name: Group::DEFAULT_NAME.to_string(), + description: Default::default(), + after_groups: Default::default(), + } + } +} + +impl TryFrom<&MarkedYaml> for Group { + type Error = YamlParseError; + + fn try_from(value: &MarkedYaml) -> Result { + let hash = get_as_hash(value, YamlObjectType::Group)?; + + let name = + get_required_string_value(value.span.start, hash, "name", YamlObjectType::Group)?; + + let description = get_string_value(hash, "description", YamlObjectType::Group)?; + + let after = get_strings_vec_value(hash, "after", YamlObjectType::Group)?; + + Ok(Group { + name: name.to_string(), + description: description.map(|d| d.to_string()), + after_groups: after.iter().map(|a| a.to_string()).collect(), + }) + } +} diff --git a/src/metadata/location.rs b/src/metadata/location.rs new file mode 100644 index 00000000..99572e93 --- /dev/null +++ b/src/metadata/location.rs @@ -0,0 +1,77 @@ +use saphyr::{MarkedYaml, YamlData}; + +use crate::error::{GeneralError, YamlParseError}; + +use super::yaml::{YamlObjectType, get_required_string_value}; + +/// Represents a URL at which the parent plugin can be found. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Location { + url: String, + name: Option, +} + +impl Location { + /// Construct a [Location] with the given URL. + #[must_use] + pub fn new(url: String) -> Self { + Location { + url, + ..Default::default() + } + } + + /// Set a name for the URL, eg. the page or site name. + #[must_use] + pub fn with_name(mut self, name: String) -> Self { + self.name = Some(name); + self + } + + /// Get the URL. + pub fn url(&self) -> &str { + &self.url + } + + /// Get the descriptive name of this location. + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } +} + +impl TryFrom<&MarkedYaml> for Location { + type Error = GeneralError; + + fn try_from(value: &MarkedYaml) -> Result { + match &value.data { + YamlData::String(s) => Ok(Location { + url: s.clone(), + name: None, + }), + YamlData::Hash(h) => { + let link = get_required_string_value( + value.span.start, + h, + "link", + YamlObjectType::Location, + )?; + let name = get_required_string_value( + value.span.start, + h, + "name", + YamlObjectType::Location, + )?; + + Ok(Location { + url: link.to_string(), + name: Some(name.to_string()), + }) + } + _ => Err(YamlParseError::new( + value.span.start, + "'tag' object must be a map or string".into(), + ) + .into()), + } + } +} diff --git a/src/metadata/message.rs b/src/metadata/message.rs new file mode 100644 index 00000000..e260eaeb --- /dev/null +++ b/src/metadata/message.rs @@ -0,0 +1,322 @@ +use std::str::FromStr; + +use loot_condition_interpreter::Expression; +use saphyr::{MarkedYaml, YamlData}; + +use super::yaml::{ + YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_string_value, + get_strings_vec_value, +}; +use crate::error::{GeneralError, InvalidMultilingualMessageContents, YamlParseError}; + +/// Codes used to indicate the type of a message. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum MessageType { + /// A notification message that is of no significant severity. + #[default] + Say, + /// A warning message, used to indicate that an issue may be present that + /// the user may wish to act on. + Warn, + /// An error message, used to indicate that an issue that requires user + /// action is present. + Error, +} + +/// Represents a message's localised text content. +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct MessageContent { + text: String, + language: String, +} + +impl MessageContent { + /// The code for the default language assumed for message content. + pub const DEFAULT_LANGUAGE: &'static str = "en"; + + /// Construct a [MessageContent] object with the given text in the default + /// language. + #[must_use] + pub fn new(text: String) -> Self { + MessageContent { + text, + ..Default::default() + } + } + + /// Set the language code to the given value. + #[must_use] + pub fn with_language(mut self, language: String) -> Self { + self.language = language; + self + } + + /// Get the message text. + pub fn text(&self) -> &str { + &self.text + } + + /// Get the text's language code. + pub fn language(&self) -> &str { + &self.language + } +} + +impl std::default::Default for MessageContent { + /// Construct a [MessageContent] object with an empty message string and the + /// default language. + #[must_use] + fn default() -> Self { + Self { + text: Default::default(), + language: MessageContent::DEFAULT_LANGUAGE.into(), + } + } +} + +/// Choose a [MessageContent] object from those given in `content` based on the +/// given `language`. +/// +/// The locale or language code for the preferred language to select. Codes are +/// of the form `[language code]_[country code]`. +/// +/// * If the vector only contains a single element, that element is returned. +/// * If content with a language that exactly matches the given locale or +/// language code is present, that content is returned. +/// * If a locale code is given and there is no exact match but content for that +/// locale's language is present, that content is returned. +/// * If a language code is given and there is no exact match but content for a +/// locale in that language is present, that content is returned. +/// * If no locale or language code matches are found and content in the default +/// language is present, that content is returned. +/// * Otherwise, an empty [Option] is returned. +pub fn select_message_content<'a>( + content: &'a [MessageContent], + language: &str, +) -> Option<&'a MessageContent> { + if content.is_empty() { + None + } else if let [c] = content { + Some(c) + } else { + let language_code = language.split_once('_').map(|p| p.0); + + let mut matched = None; + let mut english = None; + + for mc in content { + if mc.language == language { + return Some(mc); + } else if matched.is_none() { + if language_code.is_some_and(|c| c == mc.language) { + matched = Some(mc); + } else if language_code.is_none() { + if let Some((content_language_code, _)) = mc.language.split_once('_') { + if content_language_code == language { + matched = Some(mc); + } + } + } + + if mc.language == MessageContent::DEFAULT_LANGUAGE { + english = Some(mc); + } + } + } + + if matched.is_some() { + matched + } else if english.is_some() { + english + } else { + None + } + } +} + +/// Represents a message with localisable text content. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Message { + message_type: MessageType, + content: Vec, + condition: Option, +} + +impl Message { + /// Construct a [Message] with the given type and a content string in the + /// language given by [MessageContent::DEFAULT_LANGUAGE]. + #[must_use] + pub fn new(message_type: MessageType, content: String) -> Self { + Self { + message_type, + content: vec![MessageContent { + text: content, + language: MessageContent::DEFAULT_LANGUAGE.to_string(), + }], + condition: None, + } + } + + /// Construct a [Message] with the given type and content. If more than one + /// [MessageContent] object is given, one must use + /// the language code given by [MessageContent::DEFAULT_LANGUAGE]. + pub fn multilingual( + message_type: MessageType, + content: Vec, + ) -> Result { + validate_message_contents(&content)?; + + Ok(Self { + message_type, + content, + condition: None, + }) + } + + /// Set the condition string. + #[must_use] + pub fn with_condition(mut self, condition: String) -> Self { + self.condition = Some(condition); + self + } + + /// Get the message type. + pub fn message_type(&self) -> MessageType { + self.message_type + } + + /// Get the message content. + pub fn content(&self) -> &[MessageContent] { + &self.content + } + + /// Get the condition string. + pub fn condition(&self) -> Option<&str> { + self.condition.as_deref() + } +} + +pub(crate) fn validate_message_contents( + contents: &[MessageContent], +) -> Result<(), InvalidMultilingualMessageContents> { + if contents.len() > 1 { + let english_string_exists = contents + .iter() + .any(|c| c.language == MessageContent::DEFAULT_LANGUAGE); + + if !english_string_exists { + return Err(InvalidMultilingualMessageContents {}); + } + } + + Ok(()) +} + +impl TryFrom<&MarkedYaml> for MessageContent { + type Error = YamlParseError; + + fn try_from(value: &MarkedYaml) -> Result { + let hash = get_as_hash(value, YamlObjectType::MessageContent)?; + + let text = + get_required_string_value(value.span.start, hash, "text", YamlObjectType::Message)?; + + let language = + get_required_string_value(value.span.start, hash, "lang", YamlObjectType::Message)?; + + Ok(MessageContent { + text: text.to_string(), + language: language.to_string(), + }) + } +} + +pub(crate) fn parse_message_contents_yaml( + value: &MarkedYaml, + key: &str, + parent_yaml_type: YamlObjectType, +) -> Result, GeneralError> { + let contents = match &value.data { + YamlData::String(s) => { + vec![MessageContent { + text: s.to_string(), + language: MessageContent::DEFAULT_LANGUAGE.to_string(), + }] + } + YamlData::Array(a) => a + .iter() + .map(MessageContent::try_from) + .collect::, _>>()?, + _ => { + return Err(YamlParseError::new( + value.span.start, + format!( + "'{}' key in '{}' map is not a list or string", + key, parent_yaml_type + ), + ) + .into()); + } + }; + + validate_message_contents(&contents)?; + + Ok(contents) +} + +impl TryFrom<&MarkedYaml> for Message { + type Error = GeneralError; + + fn try_from(value: &MarkedYaml) -> Result { + let hash = get_as_hash(value, YamlObjectType::Message)?; + + let message_type = + get_required_string_value(value.span.start, hash, "type", YamlObjectType::Message)?; + let message_type = match message_type { + "warn" => MessageType::Warn, + "error" => MessageType::Error, + _ => MessageType::Say, + }; + + let mut content = match hash.get(&as_string_node("content")) { + Some(n) => parse_message_contents_yaml(n, "content", YamlObjectType::Message)?, + None => { + return Err(YamlParseError::missing_key( + value.span.start, + "content", + YamlObjectType::Message, + ) + .into()); + } + }; + + let subs = get_strings_vec_value(hash, "subs", YamlObjectType::Message)?; + + if !subs.is_empty() { + for mc in &mut content { + for (index, sub) in subs.iter().enumerate() { + let placeholder = format!("{}", index); + if !mc.text.contains(&placeholder) { + return Err(YamlParseError::new(value.span.start, format!("Failed to substitute \"{}\" into message, no placeholder \"{}\" was found", sub, placeholder)).into()); + } + + mc.text = mc.text.replace(&placeholder, sub); + } + } + } + + let condition = match get_string_value(hash, "condition", YamlObjectType::Message)? { + Some(c) => { + Expression::from_str(c)?; + Some(c.to_string()) + } + None => None, + }; + + Ok(Message { + message_type, + content, + condition, + }) + } +} diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs new file mode 100644 index 00000000..e7bbc5aa --- /dev/null +++ b/src/metadata/metadata_document.rs @@ -0,0 +1,469 @@ +use std::{ + collections::{HashMap, HashSet}, + path::Path, + sync::LazyLock, +}; + +use saphyr::{MarkedYaml, YamlData}; + +use crate::error::{FileAccessError, GeneralError, YamlMergeKeyError, YamlParseError}; + +use super::{ + file::Filename, + group::Group, + message::Message, + plugin_metadata::PluginMetadata, + yaml::{YamlObjectType, as_string_node, get_as_slice}, +}; + +static MERGE_KEY: LazyLock = LazyLock::new(|| as_string_node("<<")); + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MetadataDocument { + bash_tags: Vec, + groups: Vec, + messages: Vec, + plugins: HashMap, + regex_plugins: Vec, +} + +impl MetadataDocument { + pub fn load(&mut self, file_path: &Path) -> Result<(), GeneralError> { + log::trace!("Loading file: {:?}", file_path); + + let content = std::fs::read_to_string(file_path)?; + + self.load_from_str(&content)?; + + log::trace!( + "Successfully loaded metadata from file at \"{:?}\".", + file_path + ); + + Ok(()) + } + + pub fn load_with_prelude( + &mut self, + masterlist_path: &Path, + prelude_path: &Path, + ) -> Result<(), GeneralError> { + let masterlist = std::fs::read_to_string(masterlist_path)?; + let prelude = std::fs::read_to_string(prelude_path)?; + + let masterlist = replace_prelude(masterlist, prelude); + + self.load_from_str(&masterlist)?; + + log::trace!( + "Successfully loaded metadata from file at \"{:?}\".", + masterlist_path + ); + + Ok(()) + } + + fn load_from_str(&mut self, string: &str) -> Result<(), GeneralError> { + let mut docs = MarkedYaml::load_from_str(string)?; + + let doc = docs + .pop() + .ok_or_else(|| FileAccessError::new("No documents in the loaded YAML".into()))?; + if !docs.is_empty() { + return Err(FileAccessError::new(format!( + "YAML file contained more than one document, found {}", + docs.len() + 1 + )) + .into()); + } + let doc = process_merge_keys(doc)?; + + let doc = match doc.data { + YamlData::Hash(h) => h, + _ => { + return Err(YamlParseError::new( + doc.span.start, + "The root of the YAML document is not a map.".into(), + ) + .into()); + } + }; + + let mut plugins: HashMap = HashMap::new(); + let mut regex_plugins: Vec = Vec::new(); + for plugin in get_as_slice(&doc, "plugins", YamlObjectType::MetadataDocument)? { + let plugin = PluginMetadata::try_from(plugin)?; + if plugin.is_regex_plugin() { + regex_plugins.push(plugin); + } else { + let filename = Filename::new(plugin.name().to_string()); + if plugins.contains_key(&filename) { + return Err(FileAccessError::new(format!( + "More than one entry exists for plugin \"{}\"", + plugin.name() + )) + .into()); + } + plugins.insert(filename, plugin); + } + } + + let messages = get_as_slice(&doc, "globals", YamlObjectType::MetadataDocument)? + .iter() + .map(Message::try_from) + .collect::, _>>()?; + + let mut bash_tags = Vec::new(); + let mut str_set = HashSet::new(); + for bash_tag_yaml in get_as_slice(&doc, "bash_tags", YamlObjectType::MetadataDocument)? { + let bash_tag = match bash_tag_yaml.data.as_str() { + Some(b) => b, + None => { + return Err(YamlParseError::new( + bash_tag_yaml.span.start, + "Found a non-string Bash Tag.".into(), + ) + .into()); + } + }; + + if str_set.contains(bash_tag) { + return Err(YamlParseError::new( + bash_tag_yaml.span.start, + format!("More than one entry exists for Bash Tag \"{}\"", bash_tag), + ) + .into()); + } + + bash_tags.push(bash_tag.to_string()); + str_set.insert(bash_tag); + } + + let mut group_names = HashSet::new(); + let mut groups = Vec::new(); + for group_yaml in get_as_slice(&doc, "groups", YamlObjectType::MetadataDocument)? { + let group = Group::try_from(group_yaml)?; + + let name = group.name().to_string(); + if group_names.contains(&name) { + return Err(YamlParseError::new( + group_yaml.span.start, + format!("More than one entry exists for group \"{}\"", group.name()), + ) + .into()); + } + + groups.push(group); + group_names.insert(name); + } + + if !group_names.contains(Group::DEFAULT_NAME) { + groups.insert(0, Group::default()); + } + + self.plugins = plugins; + self.regex_plugins = regex_plugins; + self.messages = messages; + self.bash_tags = bash_tags; + self.groups = groups; + + Ok(()) + } + + pub fn save(&self, file_path: &Path) -> Result<(), GeneralError> { + // let mut hash = saphyr::Hash::new(); + + // hash.insert( + // Yaml::String("bash_tags".into()), + // Yaml::Array( + // self.bash_tags + // .iter() + // .map(|b| Yaml::String(b.to_string())) + // .collect(), + // ), + // ); + + // let mut yaml = Yaml::Hash(hash); + + // let mut output = String::new(); + // FIXME: Can't handle the error because it's a type that's not exported by saphyr. + // let emitter = YamlEmitter::new(&mut output).dump(&yaml)?; + + todo!() + } + + pub fn bash_tags(&self) -> &[String] { + &self.bash_tags + } + + pub fn groups(&self) -> &[Group] { + &self.groups + } + + pub fn messages(&self) -> &[Message] { + &self.messages + } + + pub fn plugins(&self) -> impl Iterator { + self.plugins.values().chain(self.regex_plugins.iter()) + } + + pub fn find_plugin(&self, plugin_name: &str) -> Result, GeneralError> { + let mut metadata = match self.plugins.get(&Filename::new(plugin_name.to_string())) { + Some(m) => m.clone(), + None => PluginMetadata::new(plugin_name)?, + }; + + // Now we want to also match possibly multiple regex entries. + for regex_plugin in &self.regex_plugins { + if regex_plugin.name_matches(plugin_name) { + metadata.merge_metadata(regex_plugin); + } + } + + if metadata.has_name_only() { + Ok(None) + } else { + Ok(Some(metadata)) + } + } + + pub fn set_groups(&mut self, groups: Vec) { + self.groups = groups; + } + + pub fn set_plugin_metadata(&mut self, plugin_metadata: PluginMetadata) { + self.plugins.insert( + Filename::new(plugin_metadata.name().to_string()), + plugin_metadata, + ); + } + + pub fn remove_plugin_metadata(&mut self, plugin_name: &str) { + self.plugins.remove(&Filename::new(plugin_name.to_string())); + } + + pub fn clear(&mut self) { + self.bash_tags.clear(); + self.groups.clear(); + self.messages.clear(); + self.plugins.clear(); + self.regex_plugins.clear(); + } +} + +fn process_merge_keys(mut yaml: MarkedYaml) -> Result { + match yaml.data { + YamlData::Alias(_) => panic!("Alias encountered!"), + YamlData::Array(a) => { + yaml.data = merge_array_elements(a).map(YamlData::Array)?; + Ok(yaml) + } + YamlData::Hash(h) => { + yaml.data = merge_hash_keys(h).map(YamlData::Hash)?; + Ok(yaml) + } + _ => Ok(yaml), + } +} + +fn merge_array_elements( + array: saphyr::AnnotatedArray, +) -> Result, YamlMergeKeyError> { + array.into_iter().map(process_merge_keys).collect() +} + +fn merge_hash_keys( + hash: saphyr::AnnotatedHash, +) -> Result, YamlMergeKeyError> { + let mut hash: saphyr::AnnotatedHash = hash + .into_iter() + .map(|(key, value)| { + process_merge_keys(key) + .and_then(|key| process_merge_keys(value).map(|value| (key, value))) + }) + .collect::>()?; + + if let Some(value) = hash.remove(&MERGE_KEY) { + merge_into_hash(hash, value) + } else { + Ok(hash) + } +} + +fn merge_into_hash( + hash: saphyr::AnnotatedHash, + value: MarkedYaml, +) -> Result, YamlMergeKeyError> { + match value.data { + YamlData::::Array(a) => a.into_iter().try_fold(hash, |acc, e| { + if let YamlData::Hash(h) = e.data { + Ok(merge_hashes(acc, h)) + } else { + Err(YamlMergeKeyError::new(e)) + } + }), + YamlData::::Hash(h) => Ok(merge_hashes(hash, h)), + _ => Err(YamlMergeKeyError::new(value)), + } +} + +fn merge_hashes( + mut hash1: saphyr::AnnotatedHash, + hash2: saphyr::AnnotatedHash, +) -> saphyr::AnnotatedHash { + for (key, value) in hash2 { + hash1.entry(key).or_insert(value); + } + hash1 +} + +fn replace_prelude(masterlist: String, prelude: String) -> String { + if let Some((start, end)) = find_prelude_bounds(&masterlist) { + let prelude = indent_prelude(prelude); + + masterlist[..start].to_string() + &prelude + &masterlist[end..] + } else { + masterlist + } +} + +fn find_prelude_bounds(masterlist: &str) -> Option<(usize, usize)> { + let prelude_on_first_line = "prelude:"; + let prelude_on_new_line = "\nprelude:"; + + let start = if masterlist.starts_with(prelude_on_first_line) { + prelude_on_first_line.len() + } else if let Some(pos) = masterlist.find(prelude_on_new_line) { + pos + prelude_on_new_line.len() + } else { + return None; + }; + + let mut pos = start; + while let Some(next_line_break_pos) = masterlist[pos..].find('\n') { + if next_line_break_pos == masterlist.len() - 1 { + break; + } + + pos = next_line_break_pos + 1; + + if let Some(c) = masterlist.as_bytes().get(pos) { + if *c != b' ' && *c != b'#' && *c != b'\n' { + return Some((start, next_line_break_pos)); + } + } + } + + Some((start, masterlist.len())) +} + +fn indent_prelude(prelude: String) -> String { + let prelude = ("\n ".to_string() + &prelude.replace("\n", "\n ")).replace(" \n", "\n"); + + if prelude.ends_with("\n ") { + prelude[..prelude.len() - 2].to_string() + } else { + prelude + } +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + + const METADATA_LIST_YAML: &str = r#"bash_tags: + - 'C.Climate' + - 'Relev' + +groups: + - name: group1 + after: + - group2 + - name: group2 + after: + - default + +globals: + - type: say + content: 'A global message.' + +plugins: + - name: 'Blank.esm' + priority: -100 + msg: + - type: warn + content: 'This is a warning.' + - type: say + content: 'This message should be removed when evaluating conditions.' + condition: 'active("Blank - Different.esm")' + + - name: 'Blank.+\.esp' + after: + - 'Blank.esm' + + - name: 'Blank.+(Different)?.*\.esp' + inc: + - 'Blank.esp' + + - name: 'Blank.esp' + group: group2 + dirty: + - crc: 0xDEADBEEF + util: utility + "#; + + #[test] + fn load_should_resolve_aliases() { + let tmp_dir = tempdir().unwrap(); + let yaml = r#" + prelude: + - &anchor + type: say + content: test message + + globals: + - *anchor + "#; + + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, yaml).unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list.load(&path).unwrap(); + } + + #[test] + fn load_should_resolve_merge_keys() { + let tmp_dir = tempdir().unwrap(); + let yaml = r#" + prelude: + - &anchor + type: say + content: test message + + globals: + - <<: *anchor + condition: file("test.esp") + "#; + + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, yaml).unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list.load(&path).unwrap(); + } + + #[test] + fn load_should_deserialise_masterlist() { + let tmp_dir = tempdir().unwrap(); + + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, METADATA_LIST_YAML).unwrap(); + + let mut metadata_list = MetadataDocument::default(); + metadata_list.load(&path).unwrap(); + } +} diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs new file mode 100644 index 00000000..3143ecd5 --- /dev/null +++ b/src/metadata/mod.rs @@ -0,0 +1,17 @@ +mod file; +mod group; +mod location; +mod message; +pub(crate) mod metadata_document; +mod plugin_cleaning_data; +pub(crate) mod plugin_metadata; +mod tag; +pub(crate) mod yaml; + +pub use file::{File, Filename}; +pub use group::Group; +pub use location::Location; +pub use message::{Message, MessageContent, MessageType, select_message_content}; +pub use plugin_cleaning_data::PluginCleaningData; +pub use plugin_metadata::PluginMetadata; +pub use tag::{Tag, TagSuggestion}; diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs new file mode 100644 index 00000000..5443a23a --- /dev/null +++ b/src/metadata/plugin_cleaning_data.rs @@ -0,0 +1,150 @@ +use saphyr::MarkedYaml; + +use crate::error::{GeneralError, InvalidMultilingualMessageContents, YamlParseError}; + +use super::{ + message::{MessageContent, parse_message_contents_yaml, validate_message_contents}, + yaml::{YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_u32_value}, +}; + +/// Represents data identifying the plugin under which it is stored as dirty or +/// clean. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct PluginCleaningData { + crc: u32, + itm_count: u32, + deleted_reference_count: u32, + deleted_navmesh_count: u32, + cleaning_utility: String, + detail: Vec, +} + +impl PluginCleaningData { + /// Construct a [PluginCleaningData] object with the given CRC and cleaning + /// utility, no detail and the ITM, deleted reference and deleted navmesh + /// counts set to zero. + #[must_use] + pub fn new(crc: u32, cleaning_utility: String) -> Self { + Self { + crc, + cleaning_utility, + ..Default::default() + } + } + + /// Set the number of Identical To Master records found in the plugin. + #[must_use] + pub fn with_itm_count(mut self, itm_count: u32) -> Self { + self.itm_count = itm_count; + self + } + + /// Set the number of deleted references found in the plugin. + #[must_use] + pub fn with_deleted_reference_count(mut self, deleted_reference_count: u32) -> Self { + self.deleted_reference_count = deleted_reference_count; + self + } + + /// Set the number of deleted navmeshes found in the plugin. + #[must_use] + pub fn with_deleted_navmesh_count(mut self, deleted_navmesh_count: u32) -> Self { + self.deleted_navmesh_count = deleted_navmesh_count; + self + } + + /// Set the detail message content, which may be appended to any messages + /// generated for this cleaning data. If multilingual, one language must be + /// [MessageContent::DEFAULT_LANGUAGE]. + pub fn with_detail( + mut self, + detail: Vec, + ) -> Result { + validate_message_contents(&detail)?; + self.detail = detail; + Ok(self) + } + + /// Get the CRC that identifies the plugin that the cleaning data is for. + pub fn crc(&self) -> u32 { + self.crc + } + + /// Get the number of Identical To Master records found in the plugin. + pub fn itm_count(&self) -> u32 { + self.itm_count + } + + /// Get the number of deleted references found in the plugin. + pub fn deleted_reference_count(&self) -> u32 { + self.deleted_reference_count + } + + /// Get the number of deleted navmeshes found in the plugin. + pub fn deleted_navmesh_count(&self) -> u32 { + self.deleted_navmesh_count + } + + /// Get the cleaning utility that was used to check the plugin. + /// + /// The string may include a cleaning utility name, possibly related + /// information such as a version number and/or a CommonMark-formatted URL + /// to the utility's download location. + pub fn cleaning_utility(&self) -> &str { + &self.cleaning_utility + } + + /// Get any additional informative message content supplied with the + /// cleaning data, eg. a link to a cleaning guide or information on wild + /// edits or manual cleaning steps. + pub fn detail(&self) -> &[MessageContent] { + &self.detail + } +} + +impl TryFrom<&MarkedYaml> for PluginCleaningData { + type Error = GeneralError; + + fn try_from(value: &MarkedYaml) -> Result { + let hash = get_as_hash(value, YamlObjectType::PluginCleaningData)?; + + let crc = match get_u32_value(hash, "crc", YamlObjectType::PluginCleaningData)? { + Some(n) => n, + None => { + return Err(YamlParseError::missing_key( + value.span.start, + "crc", + YamlObjectType::PluginCleaningData, + ) + .into()); + } + }; + + let util = get_required_string_value( + value.span.start, + hash, + "util", + YamlObjectType::PluginCleaningData, + )?; + + let itm = get_u32_value(hash, "itm", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + let udr = get_u32_value(hash, "udr", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + let nav = get_u32_value(hash, "nav", YamlObjectType::PluginCleaningData)?.unwrap_or(0); + + let detail = match hash.get(&as_string_node("detail")) { + Some(n) => { + parse_message_contents_yaml(n, "detail", YamlObjectType::PluginCleaningData)? + } + None => Vec::new(), + }; + + Ok(PluginCleaningData { + crc, + itm_count: itm, + deleted_reference_count: udr, + deleted_navmesh_count: nav, + cleaning_utility: util.to_string(), + detail, + }) + } +} diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs new file mode 100644 index 00000000..f53a445e --- /dev/null +++ b/src/metadata/plugin_metadata.rs @@ -0,0 +1,361 @@ +use fancy_regex::Regex; +use saphyr::MarkedYaml; + +use crate::{ + error::{GeneralError, YamlParseError}, + regex, +}; + +use super::{ + file::File, + location::Location, + message::Message, + plugin_cleaning_data::PluginCleaningData, + tag::Tag, + yaml::{ + YamlObjectType, get_as_hash, get_as_slice, get_required_string_value, get_string_value, + }, +}; + +pub(crate) const GHOST_FILE_EXTENSION: &str = ".ghost"; + +/// Represents a plugin's metadata. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct PluginMetadata { + name: PluginName, + group: Option, + load_after: Vec, + requirements: Vec, + incompatibilities: Vec, + messages: Vec, + tags: Vec, + dirty_info: Vec, + clean_info: Vec, + locations: Vec, +} + +impl PluginMetadata { + /// Construct a [PluginMetadata] object with no metadata for a plugin with + /// the given filename. + pub fn new(name: &str) -> Result> { + Ok(Self { + name: PluginName::new(name)?, + ..Default::default() + }) + } + + /// Get the plugin name. + pub fn name(&self) -> &str { + &self.name.string + } + + /// Get the plugin's group. + /// + /// The [Option] is `None` if no group is explicitly set. + pub fn group(&self) -> Option<&str> { + self.group.as_deref() + } + + /// Get the plugins that the plugin must load after. + pub fn load_after_files(&self) -> &[File] { + &self.load_after + } + + /// Get the files that the plugin requires to be installed. + pub fn requirements(&self) -> &[File] { + &self.requirements + } + + /// Get the files that the plugin is incompatible with. + pub fn incompatibilities(&self) -> &[File] { + &self.incompatibilities + } + + /// Get the plugin's messages. + pub fn messages(&self) -> &[Message] { + &self.messages + } + + /// Get the plugin's Bash Tag suggestions. + pub fn tags(&self) -> &[Tag] { + &self.tags + } + + /// Get the plugin's dirty plugin information. + pub fn dirty_info(&self) -> &[PluginCleaningData] { + &self.dirty_info + } + + /// Get the plugin's clean plugin information. + pub fn clean_info(&self) -> &[PluginCleaningData] { + &self.clean_info + } + + /// Get the locations at which this plugin can be found. + pub fn locations(&self) -> &[Location] { + &self.locations + } + + /// Set the plugin's group. + pub fn set_group(&mut self, group: &str) { + self.group = Some(group.to_string()) + } + + /// Unsets the plugin's group, so that it is implicitly a member of the + /// default group. + pub fn unset_group(&mut self) { + self.group = None + } + + /// Get the plugins that the plugin must load after. + pub fn set_load_after_files(&mut self, files: Vec) { + self.load_after = files; + } + + /// Get the files that the plugin requires to be installed. + pub fn set_requirements(&mut self, files: Vec) { + self.requirements = files; + } + + /// Get the files that the plugin is incompatible with. + pub fn set_incompatibilities(&mut self, files: Vec) { + self.incompatibilities = files; + } + + /// Get the plugin's messages. + pub fn set_messages(&mut self, messages: Vec) { + self.messages = messages; + } + + /// Get the plugin's Bash Tag suggestions. + pub fn set_tags(&mut self, tags: Vec) { + self.tags = tags; + } + + /// Get the plugin's dirty plugin information. + pub fn set_dirty_info(&mut self, dirty_info: Vec) { + self.dirty_info = dirty_info; + } + + /// Get the plugin's clean plugin information. + pub fn set_clean_info(&mut self, clean_info: Vec) { + self.clean_info = clean_info; + } + + /// Get the locations at which this plugin can be found. + pub fn set_locations(&mut self, locations: Vec) { + self.locations = locations; + } + + /// Merge metadata from the given [PluginMetadata] object into this object. + /// + /// If an equal metadata object already exists in this PluginMetadata + /// object, it is not duplicated. This object's group is replaced by the + /// given object's group if the latter is explicit. + pub fn merge_metadata(&mut self, plugin: &PluginMetadata) { + if plugin.has_name_only() { + return; + } + + if self.group.is_none() && plugin.group.is_some() { + self.group = plugin.group.clone(); + } + + merge_vecs(&mut self.load_after, &plugin.load_after); + merge_vecs(&mut self.requirements, &plugin.requirements); + merge_vecs(&mut self.incompatibilities, &plugin.incompatibilities); + merge_vecs(&mut self.tags, &plugin.tags); + self.messages.extend(plugin.messages.iter().cloned()); + merge_vecs(&mut self.dirty_info, &plugin.dirty_info); + merge_vecs(&mut self.clean_info, &plugin.clean_info); + merge_vecs(&mut self.locations, &plugin.locations); + } + + /// Check if no plugin metadata is set. + pub fn has_name_only(&self) -> bool { + self.group.is_none() + && self.load_after.is_empty() + && self.requirements.is_empty() + && self.incompatibilities.is_empty() + && self.messages.is_empty() + && self.tags.is_empty() + && self.dirty_info.is_empty() + && self.clean_info.is_empty() + && self.locations.is_empty() + } + + /// Check if the plugin name is a regular expression. + /// + /// Returns `true` if the plugin name contains any of the characters `:\*?|` + /// and `false` otherwise. + pub fn is_regex_plugin(&self) -> bool { + self.name.regex.is_some() + } + + /// Check if the given plugin name matches this plugin metadata object's + /// name field. + /// + /// If the name field is a regular expression, the given plugin name will be + /// matched against it, otherwise the strings will be compared + /// case-insensitively. The given plugin name must be literal, i.e. not a + /// regular expression. + pub fn name_matches(&self, other_name: &str) -> bool { + if let Some(regex) = &self.name.regex { + regex.is_match(other_name).inspect_err(|e| { + log::error!("Encountered an error while trying to match the regex {} to the string {}: {}", regex.as_str(), other_name, e); + }).unwrap_or(false) + } else { + unicase::eq(self.name.string.as_str(), other_name) + } + } + + /// Serialises the plugin metadata as YAML. + pub fn as_yaml(&self) -> String { + todo!() + } +} + +#[derive(Clone, Debug, Default)] +struct PluginName { + string: String, + regex: Option, +} + +impl PluginName { + fn new(name: &str) -> Result> { + let name = trim_dot_ghost(name).to_string(); + + if is_regex_name(&name) { + let regex = regex(&format!("^{}$", &name))?; + Ok(Self { + string: name, + regex: Some(regex), + }) + } else { + Ok(Self { + string: name, + regex: None, + }) + } + } +} + +impl std::cmp::PartialEq for PluginName { + fn eq(&self, other: &Self) -> bool { + self.string == other.string + } +} + +impl std::cmp::Eq for PluginName {} + +impl std::cmp::PartialOrd for PluginName { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::cmp::Ord for PluginName { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.string.cmp(&other.string) + } +} + +impl std::hash::Hash for PluginName { + fn hash(&self, state: &mut H) { + self.string.hash(state); + } +} + +pub(crate) fn trim_dot_ghost(string: &str) -> &str { + if iends_with_ascii(string, GHOST_FILE_EXTENSION) { + &string[..(string.len() - 6)] + } else { + string + } +} + +pub(crate) fn iends_with_ascii(string: &str, suffix: &str) -> bool { + // as_bytes().into_iter() is faster than bytes(). + string.len() >= suffix.len() + && string + .as_bytes() + .iter() + .rev() + .zip(suffix.as_bytes().iter().rev()) + .all(|(string_byte, suffix_byte)| string_byte.eq_ignore_ascii_case(suffix_byte)) +} + +fn is_regex_name(name: &str) -> bool { + name.contains(|c| ":\\*?|".chars().any(|n| c == n)) +} + +fn merge_vecs(target: &mut Vec, source: &[T]) { + let initial_target_len = target.len(); + for element in source { + if !target[..initial_target_len].contains(element) { + target.push(element.clone()) + } + } +} + +impl TryFrom<&MarkedYaml> for PluginMetadata { + type Error = GeneralError; + + fn try_from(value: &MarkedYaml) -> Result { + let hash = get_as_hash(value, YamlObjectType::PluginMetadata)?; + + let name = get_required_string_value( + value.span.start, + hash, + "name", + YamlObjectType::PluginMetadata, + )?; + let name = match PluginName::new(name) { + Ok(n) => n, + Err(e) => { + return Err(YamlParseError::new( + value.span.start, + format!("Invalid regex in \"name\" key: {}", e), + ) + .into()); + } + }; + + let group = get_string_value(hash, "group", YamlObjectType::PluginMetadata)?; + + let load_after = get_vec::(hash, "after")?; + let requirements = get_vec::(hash, "req")?; + let incompatibilities = get_vec::(hash, "inc")?; + let messages = get_vec::(hash, "msg")?; + let tags = get_vec::(hash, "tag")?; + let dirty_info = get_vec::(hash, "dirty")?; + let clean_info = get_vec::(hash, "clean")?; + let locations = get_vec::(hash, "url")?; + + Ok(PluginMetadata { + name, + group: group.map(|g| g.to_string()), + load_after, + requirements, + incompatibilities, + messages, + dirty_info, + clean_info, + tags, + locations, + }) + } +} + +fn get_vec<'a, T: TryFrom<&'a MarkedYaml, Error = GeneralError>>( + hash: &'a saphyr::AnnotatedHash, + key: &str, +) -> Result, GeneralError> +where + GeneralError: From<>::Error>, +{ + get_as_slice(hash, key, YamlObjectType::PluginMetadata)? + .iter() + .map(|e| T::try_from(e)) + .collect::, _>>() +} diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs new file mode 100644 index 00000000..5bde1e38 --- /dev/null +++ b/src/metadata/tag.rs @@ -0,0 +1,106 @@ +use std::str::FromStr; + +use loot_condition_interpreter::Expression; +use saphyr::YamlData; + +use super::yaml::{YamlObjectType, get_required_string_value, get_string_value}; +use crate::error::{GeneralError, YamlParseError}; + +/// Represents whether a Bash Tag suggestion is for addition or removal. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum TagSuggestion { + #[default] + Addition, + Removal, +} + +/// Represents a Bash Tag suggestion for a plugin. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Tag { + name: String, + suggestion: TagSuggestion, + condition: Option, +} + +impl Tag { + /// Create a [Tag] suggestion for the given tag name. + #[must_use] + pub fn new(name: String, suggestion: TagSuggestion) -> Self { + Self { + name, + suggestion, + condition: None, + } + } + + /// Set the condition string. + #[must_use] + pub fn with_condition(mut self, condition: String) -> Self { + self.condition = Some(condition); + self + } + + /// Get the tag's name. + pub fn name(&self) -> &str { + &self.name + } + + /// Get if the tag should be added. + pub fn is_addition(&self) -> bool { + self.suggestion == TagSuggestion::Addition + } + + /// Get the condition string. + pub fn condition(&self) -> Option<&str> { + self.condition.as_deref() + } +} + +impl TryFrom<&saphyr::MarkedYaml> for Tag { + type Error = GeneralError; + + fn try_from(value: &saphyr::MarkedYaml) -> Result { + match &value.data { + YamlData::String(s) => { + let (name, suggestion) = name_and_suggestion(s); + Ok(Tag { + name, + suggestion, + condition: None, + }) + } + YamlData::Hash(h) => { + let name = + get_required_string_value(value.span.start, h, "name", YamlObjectType::Tag)?; + + let condition = match get_string_value(h, "condition", YamlObjectType::Tag)? { + Some(n) => { + Expression::from_str(n)?; + Some(n.to_string()) + } + None => None, + }; + + let (name, suggestion) = name_and_suggestion(name); + Ok(Tag { + name, + suggestion, + condition, + }) + } + _ => Err(YamlParseError::new( + value.span.start, + "'tag' object must be a map or string".into(), + ) + .into()), + } + } +} + +fn name_and_suggestion(value: &str) -> (String, TagSuggestion) { + if let Some(name) = value.strip_prefix("-") { + (name.to_string(), TagSuggestion::Removal) + } else { + (value.to_string(), TagSuggestion::Addition) + } +} diff --git a/src/metadata/yaml.rs b/src/metadata/yaml.rs new file mode 100644 index 00000000..e7feca4f --- /dev/null +++ b/src/metadata/yaml.rs @@ -0,0 +1,167 @@ +use saphyr::{AnnotatedArray, AnnotatedHash, MarkedYaml, Marker, Yaml, YamlData}; + +use crate::error::{GeneralError, YamlParseError}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub enum YamlObjectType { + File, + Group, + Location, + Message, + MessageContent, + PluginCleaningData, + PluginMetadata, + Tag, + MetadataDocument, +} + +impl std::fmt::Display for YamlObjectType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + YamlObjectType::File => write!(f, "file"), + YamlObjectType::Group => write!(f, "group"), + YamlObjectType::Location => write!(f, "location"), + YamlObjectType::Message => write!(f, "message"), + YamlObjectType::MessageContent => write!(f, "message content"), + YamlObjectType::PluginCleaningData => write!(f, "plugin cleaning data"), + YamlObjectType::PluginMetadata => write!(f, "plugin metadata"), + YamlObjectType::Tag => write!(f, "tag"), + YamlObjectType::MetadataDocument => write!(f, "metadata document"), + } + } +} + +pub fn to_yaml(yaml: &MarkedYaml) -> Yaml { + match &yaml.data { + saphyr::YamlData::Real(v) => Yaml::Real(v.clone()), + saphyr::YamlData::Integer(v) => Yaml::Integer(*v), + saphyr::YamlData::String(v) => Yaml::String(v.clone()), + saphyr::YamlData::Boolean(v) => Yaml::Boolean(*v), + saphyr::YamlData::Array(v) => Yaml::Array(to_array(v)), + saphyr::YamlData::Hash(v) => Yaml::Hash(to_hash(v)), + saphyr::YamlData::Alias(v) => Yaml::Alias(*v), + saphyr::YamlData::Null => Yaml::Null, + saphyr::YamlData::BadValue => Yaml::BadValue, + } +} + +fn to_array(array: &AnnotatedArray) -> saphyr::Array { + array.iter().map(to_yaml).collect() +} + +fn to_hash(hash: &AnnotatedHash) -> saphyr::Hash { + hash.iter() + .map(|(key, value)| (to_yaml(key), to_yaml(value))) + .collect() +} + +pub fn as_string_node(value: &str) -> MarkedYaml { + MarkedYaml { + span: saphyr_parser::Span::default(), + data: YamlData::String(value.into()), + } +} + +pub fn get_string_value<'a>( + hash: &'a AnnotatedHash, + key: &str, + yaml_type: YamlObjectType, +) -> Result, YamlParseError> { + match hash.get(&as_string_node(key)) { + Some(n) => match n.data.as_str() { + Some(n) => Ok(Some(n)), + None => Err(YamlParseError::new( + n.span.start, + format!("'{}' key in '{}' map is not a string", key, yaml_type), + )), + }, + None => Ok(None), + } +} + +pub fn get_required_string_value<'a>( + marker: Marker, + hash: &'a AnnotatedHash, + key: &str, + yaml_type: YamlObjectType, +) -> Result<&'a str, YamlParseError> { + match get_string_value(hash, key, yaml_type)? { + Some(n) => Ok(n), + None => Err(YamlParseError::missing_key(marker, key, yaml_type)), + } +} + +pub fn get_strings_vec_value<'a>( + hash: &'a AnnotatedHash, + key: &str, + yaml_type: YamlObjectType, +) -> Result, YamlParseError> { + match hash.get(&as_string_node(key)) { + Some(n) => match n.data.as_vec() { + Some(n) => n + .iter() + .map(|e| match e.data.as_str() { + Some(s) => Ok(s), + None => Err(YamlParseError::new( + e.span.start, + "Element in list is not a string".into(), + )), + }) + .collect::, _>>(), + None => Err(YamlParseError::new( + n.span.start, + format!("'{}' key in '{}' map is not a list", key, yaml_type), + )), + }, + None => Ok(Vec::new()), + } +} + +pub fn get_as_hash( + value: &MarkedYaml, + yaml_type: YamlObjectType, +) -> Result<&AnnotatedHash, YamlParseError> { + match value.data.as_hash() { + Some(h) => Ok(h), + None => Err(YamlParseError::new( + value.span.start, + format!("'{}' object must be a map", yaml_type), + )), + } +} + +pub fn get_u32_value( + hash: &AnnotatedHash, + key: &str, + yaml_type: YamlObjectType, +) -> Result, GeneralError> { + match hash.get(&as_string_node(key)) { + Some(n) => match n.data.as_i64() { + Some(n) => Ok(Some(n.try_into()?)), + None => Err(YamlParseError::new( + n.span.start, + format!("'{}' key in '{}' map is not a string", key, yaml_type), + ) + .into()), + }, + None => Ok(None), + } +} + +pub fn get_as_slice<'a>( + hash: &'a saphyr::AnnotatedHash, + key: &str, + yaml_type: YamlObjectType, +) -> Result<&'a [MarkedYaml], YamlParseError> { + if let Some(value) = hash.get(&as_string_node(key)) { + match value.data.as_vec() { + Some(n) => Ok(n.as_slice()), + None => Err(YamlParseError::new( + value.span.start, + format!("'{}' key in '{}' map is not an array", key, yaml_type), + )), + } + } else { + Ok(&[]) + } +} diff --git a/src/plugin.rs b/src/plugin.rs new file mode 100644 index 00000000..9a8be83c --- /dev/null +++ b/src/plugin.rs @@ -0,0 +1,453 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::File, + hash::Hasher, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, + sync::LazyLock, +}; + +use esplugin::ParseOptions; +use fancy_regex::Regex; + +use crate::{ + GameType, + archive::{assets_in_archives, find_associated_archives}, + error::{GeneralError, InvalidArgumentError}, + game::GameCache, + regex, +}; + +static VERSION_REGEXES: LazyLock> = LazyLock::new(|| { + /* The string below matches the range of version strings supported by + Pseudosem v1.0.1, excluding space separators, as they make version + extraction from inside sentences very tricky and have not been + seen "in the wild". */ + let pseudosem_regex_str = r"(\d+(?:\.\d+)+(?:[-._:]?[A-Za-z0-9]+)*)(?!,)"; + + Box::new([ + /* The string below matches timestamps that use forwardslashes for date + separators. However, Pseudosem v1.0.1 will only compare the first + two digits as it does not recognise forwardslashes as separators. */ + regex(r"(\d{1,2}/\d{1,2}/\d{1,4} \d{1,2}:\d{1,2}:\d{1,2})") + .expect("Hardcoded version timestamp regex should be valid"), + regex(&(String::from(r"version:?\s") + pseudosem_regex_str)) + .expect("Hardcoded version-prefixed pseudosem version regex should be valid"), + regex(&(String::from(r"(?:^|v|\s)") + pseudosem_regex_str)) + .expect("Hardcoded pseudosem version regex should be valid"), + /* The string below matches a number containing one or more + digits found at the start of the search string or preceded by + 'v' or 'version:. */ + regex(r"(?:^|v|version:\s*)(\d+)") + .expect("Hardcoded prefixed version number regex should be valid"), + ]) +}); + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub(crate) enum LoadScope { + HeaderOnly, + WholePlugin, +} + +impl std::fmt::Display for LoadScope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LoadScope::HeaderOnly => write!(f, "plugin header"), + LoadScope::WholePlugin => write!(f, "whole plugin"), + } + } +} + +/// Represents a plugin file that has been loaded. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Plugin { + name: String, + plugin: Option, + game_type: GameType, + crc: Option, + version: Option, + tags: Vec, + archive_paths: Vec, + archive_assets: BTreeMap>, +} + +impl Plugin { + pub(crate) fn new( + game_type: GameType, + game_cache: &GameCache, + plugin_path: &Path, + load_scope: LoadScope, + ) -> Result { + let name = name_string(plugin_path)?; + + let (parse_options, crc) = if load_scope == LoadScope::HeaderOnly { + (ParseOptions::header_only(), None) + } else { + let crc = calculate_crc(plugin_path)?; + (ParseOptions::whole_plugin(), Some(crc)) + }; + + let mut version = None; + let mut tags = Vec::new(); + let mut archive_paths = Vec::new(); + let mut archive_assets = BTreeMap::new(); + let plugin = + if game_type != GameType::OpenMW || !has_ascii_extension(plugin_path, "omwscripts") { + let mut plugin = esplugin::Plugin::new(game_type.into(), plugin_path); + plugin.parse_file(parse_options)?; + + if let Some(description) = plugin.description()? { + tags = extract_bash_tags(&description); + version = extract_version(&description)?; + } + + archive_paths = find_associated_archives(game_type, game_cache, plugin_path); + + archive_assets = assets_in_archives(&archive_paths); + + Some(plugin) + } else { + None + }; + + Ok(Self { + name, + plugin, + game_type, + crc, + version, + tags, + archive_paths, + archive_assets, + }) + } + + /// Get the plugin's filename. + /// + /// If the plugin was ghosted when it was loaded, this filename will be + /// without the .ghost suffix, unless the game is OpenMW, in which case + /// ghosted plugins are not supported. + pub fn name(&self) -> &str { + &self.name + } + + /// Get the value of the version field in the `HEDR` subrecord of the + /// plugin's `TES4` record. + /// + /// Returns `None` if the `TES4` record does not exist (e.g. for Morrowind + /// and OpenMW) or if the `HEDR` subrecord could not be found, of if the + /// version field's value was `NaN`. + pub fn header_version(&self) -> Option { + self.plugin.as_ref().and_then(|p| p.header_version()) + } + + /// Get the plugin's version number from its description field. + /// + /// The description field may not contain a version number, or libloot may + /// be unable to detect it. The description field parsing may fail to + /// extract the version number correctly, though it functions correctly in + /// all known cases. + pub fn version(&self) -> Option<&str> { + self.version.as_deref() + } + + /// Get the plugin's masters. + pub fn masters(&self) -> Result, GeneralError> { + self.plugin + .as_ref() + .map(|p| p.masters().map_err(Into::into)) + .unwrap_or_else(|| Ok(Vec::new())) + } + + /// Get any Bash Tags found in the plugin's description field. + pub fn bash_tags(&self) -> &[String] { + &self.tags + } + + /// Get the plugin's CRC-32 checksum. + /// + /// This will be `None` if the plugin is not fully loaded. + pub fn crc(&self) -> Option { + self.crc + } + + /// Check if the plugin is a master plugin. + /// + /// What causes a plugin to be a master plugin varies by game, but is + /// usually indicated by the plugin having its master flag set and/or by its + /// file extension. However, OpenMW uses neither for determining plugins' + /// load order so all OpenMW plugins are treated as non-masters. + /// + /// The term "master" is potentially confusing: a plugin A may not be a + /// *master plugin*, but may still be a *master of* another plugin by being + /// listed as such in that plugin's header record. Master plugins are + /// sometimes referred to as *master files* or simply *masters*, while the + /// other meaning is always referenced in relation to another plugin. + pub fn is_master(&self) -> bool { + if self.game_type == GameType::OpenMW { + false + } else { + self.plugin + .as_ref() + .map(|p| p.is_master_file()) + .unwrap_or(false) + } + } + + /// Check if the plugin is a light plugin. + pub fn is_light_plugin(&self) -> bool { + self.plugin + .as_ref() + .map(|p| p.is_light_plugin()) + .unwrap_or(false) + } + + /// Check if the plugin is a medium plugin. + pub fn is_medium_plugin(&self) -> bool { + self.plugin + .as_ref() + .map(|p| p.is_medium_plugin()) + .unwrap_or(false) + } + + /// Check if the plugin is an update plugin. + pub fn is_update_plugin(&self) -> bool { + self.plugin + .as_ref() + .map(|p| p.is_update_plugin()) + .unwrap_or(false) + } + + /// Check if the plugin is a blueprint plugin. + pub fn is_blueprint_plugin(&self) -> bool { + self.plugin + .as_ref() + .map(|p| p.is_blueprint_plugin()) + .unwrap_or(false) + } + + /// Check if the plugin is or would be valid as a light plugin. + pub fn is_valid_as_light_plugin(&self) -> Result { + self.plugin + .as_ref() + .map(|p| p.is_valid_as_light_plugin().map_err(Into::into)) + .unwrap_or(Ok(false)) + } + + /// Check if the plugin is or would be valid as a medium plugin. + pub fn is_valid_as_medium_plugin(&self) -> Result { + self.plugin + .as_ref() + .map(|p| p.is_valid_as_medium_plugin().map_err(Into::into)) + .unwrap_or(Ok(false)) + } + + /// Check if the plugin is or would be valid as an update plugin. + pub fn is_valid_as_update_plugin(&self) -> Result { + self.plugin + .as_ref() + .map(|p| p.is_valid_as_update_plugin().map_err(Into::into)) + .unwrap_or(Ok(false)) + } + + /// Check if the plugin contains any records other than its `TES3`/`TES4` + /// header. + pub fn is_empty(&self) -> bool { + self.plugin + .as_ref() + .and_then(|p| p.record_and_group_count()) + .unwrap_or(0) + == 0 + } + + /// Check if the plugin loads an archive (BSA/BA2 depending on the game). + pub fn loads_archive(&self) -> bool { + !self.archive_paths.is_empty() + } + + /// Check if two plugins contain a record with the same ID. + /// + /// FormIDs are compared for all games apart from Morrowind, which doesn't + /// have FormIDs and so has other identifying data compared. + pub fn do_records_overlap(&self, plugin: &Plugin) -> Result { + if let (Some(plugin), Some(other_plugin)) = (&self.plugin, &plugin.plugin) { + plugin.overlaps_with(other_plugin).map_err(Into::into) + } else { + Ok(false) + } + } + + pub(crate) fn override_record_count(&self) -> Result { + self.plugin + .as_ref() + .map(|p| p.count_override_records().map_err(Into::into)) + .unwrap_or(Ok(0)) + } + + pub(crate) fn asset_count(&self) -> usize { + self.archive_assets.values().fold(0, |acc, e| acc + e.len()) + } + + pub(crate) fn do_assets_overlap(&self, plugin: &Plugin) -> bool { + let mut assets_iter = self.archive_assets.iter(); + let mut other_assets_iter = plugin.archive_assets.iter(); + + let mut assets = assets_iter.next(); + let mut other_assets = other_assets_iter.next(); + while let (Some((folder, files)), Some((other_folder, other_files))) = + (assets, other_assets) + { + if folder < other_folder { + assets = assets_iter.next(); + } else if folder > other_folder { + other_assets = other_assets_iter.next(); + } else if files.intersection(other_files).next().is_some() { + return true; + } else { + // The folder hashes are equal but they don't contain any of the same + // file hashes, move on to the next folder. It doesn't matter which + // iterator gets incremented. + assets = assets_iter.next(); + } + } + + false + } + + pub(crate) fn resolve_record_ids( + &mut self, + plugins_metadata: &[esplugin::PluginMetadata], + ) -> Result<(), esplugin::Error> { + if let Some(plugin) = &mut self.plugin { + plugin.resolve_record_ids(plugins_metadata) + } else { + Ok(()) + } + } +} + +pub(crate) fn is_valid_plugin(game_type: GameType, plugin_path: &Path) -> bool { + if game_type == GameType::OpenMW && has_ascii_extension(plugin_path, "omwscripts") { + true + } else if has_plugin_file_extension(game_type, plugin_path) { + esplugin::Plugin::is_valid(game_type.into(), plugin_path, ParseOptions::header_only()) + } else { + log::debug!( + "The file \"{}\" is not a valid plugin", + plugin_path.display() + ); + false + } +} + +fn has_plugin_file_extension(game_type: GameType, plugin_path: &Path) -> bool { + let extension = if game_type != GameType::OpenMW && has_ascii_extension(plugin_path, "ghost") { + plugin_path + .file_stem() + .and_then(|s| Path::new(s).extension()) + } else { + plugin_path.extension() + }; + + if let Some(extension) = extension { + if extension.eq_ignore_ascii_case("esp") + || extension.eq_ignore_ascii_case("esm") + || (game_type == GameType::OpenMW + && (extension.eq_ignore_ascii_case("omwaddon") + || extension.eq_ignore_ascii_case("omwgame") + || extension.eq_ignore_ascii_case("omwscripts"))) + { + true + } else { + matches!( + game_type, + GameType::FO4 + | GameType::FO4VR + | GameType::TES5SE + | GameType::TES5VR + | GameType::Starfield + ) && extension.eq_ignore_ascii_case("esl") + } + } else { + false + } +} + +pub(crate) fn has_ascii_extension(path: &Path, extension: &str) -> bool { + path.extension() + .map(|e| e.eq_ignore_ascii_case(extension)) + .unwrap_or(false) +} + +pub(crate) fn plugins_metadata( + plugins: &[Plugin], +) -> Result, esplugin::Error> { + let esplugins: Vec<_> = plugins.iter().filter_map(|p| p.plugin.as_ref()).collect(); + esplugin::plugins_metadata(&esplugins) +} + +fn name_string(path: &Path) -> Result { + match path.file_name() { + Some(f) => match f.to_str() { + Some(f) => Ok(f.to_string()), + None => Err(InvalidArgumentError { + message: format!("The path \"{:?}\" has a non-Unicode filename", path), + }), + }, + None => Err(InvalidArgumentError { + message: format!("The path \"{}\" has no filename", path.display()), + }), + } +} + +fn calculate_crc(path: &Path) -> std::io::Result { + let file = File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = crc32fast::Hasher::new(); + + let mut buffer = reader.fill_buf()?; + while !buffer.is_empty() { + hasher.write(buffer); + let length = buffer.len(); + reader.consume(length); + + buffer = reader.fill_buf()?; + } + + Ok(hasher.finalize()) +} + +fn extract_bash_tags(description: &str) -> Vec { + let bash_tags_opener = "{{BASH:"; + + if let Some(mut start_pos) = description.find(bash_tags_opener) { + start_pos += bash_tags_opener.len(); + + if let Some(end_pos) = description[start_pos..].find("}}") { + return description[start_pos..start_pos + end_pos] + .split(",") + .map(|s| s.trim().to_string()) + .collect(); + } + } + Vec::new() +} + +fn extract_version(description: &str) -> Result, Box> { + for regex in &*VERSION_REGEXES { + let version = regex + .captures(description)? + .iter() + .flat_map(|captures| captures.iter()) + .flatten() + .map(|m| m.as_str().trim()) + .find(|v| !v.is_empty()) + .map(|v| v.to_string()); + + if version.is_some() { + return Ok(version); + } + } + + Ok(None) +} diff --git a/src/sorting/dfs.rs b/src/sorting/dfs.rs new file mode 100644 index 00000000..c5236b09 --- /dev/null +++ b/src/sorting/dfs.rs @@ -0,0 +1,210 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use petgraph::{ + Graph, + graph::{EdgeReference, NodeIndex}, + visit::EdgeRef, +}; + +use crate::{EdgeType, Vertex}; + +pub trait DfsVisitor<'a> { + fn visit_tree_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>); + + fn visit_forward_or_cross_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>); + + fn visit_back_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>); + + fn discover_node(&mut self, node_index: NodeIndex); + + fn finish_node(&mut self, node_index: NodeIndex); +} + +pub trait BidirBfsVisitor { + fn visit_forward_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex); + + fn visit_reverse_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex); + + fn visit_intersection_node(&mut self, node: NodeIndex); +} + +pub fn bidirectional_bfs( + graph: &Graph, + from_index: NodeIndex, + to_index: NodeIndex, + visitor: &mut impl BidirBfsVisitor, +) -> bool { + let mut forward_queue = VecDeque::from([from_index]); + let mut reverse_queue = VecDeque::from([to_index]); + let mut forward_visited = HashSet::from([from_index]); + let mut reverse_visited = HashSet::from([to_index]); + + while let (Some(forward_current), Some(reverse_current)) = + (forward_queue.pop_front(), reverse_queue.pop_front()) + { + if forward_current == to_index || reverse_visited.contains(&forward_current) { + visitor.visit_intersection_node(forward_current); + return true; + } + + for adjacent in graph.neighbors(forward_current) { + if !forward_visited.contains(&adjacent) { + visitor.visit_forward_bfs_edge(forward_current, adjacent); + + forward_visited.insert(adjacent); + forward_queue.push_back(adjacent); + } + } + + if reverse_current == from_index || forward_visited.contains(&reverse_current) { + visitor.visit_intersection_node(reverse_current); + return true; + } + + for adjacent in graph.neighbors_directed(reverse_current, petgraph::Direction::Incoming) { + if !reverse_visited.contains(&adjacent) { + visitor.visit_reverse_bfs_edge(adjacent, reverse_current); + + reverse_visited.insert(adjacent); + reverse_queue.push_back(adjacent); + } + } + } + + false +} + +// Petgraph has APIs for performing depth-first searches, but they don't give any information about the current edge, only its source and target nodes, which is a problem if the same pair of nodes can have multiple edges between them with different weights. As such, implement it myself. +pub fn find_cycle( + graph: &Graph, + node_mapper: impl FnMut(&N) -> String, +) -> Option> { + let mut cycle_detector = CycleDetector::new(graph, node_mapper); + + let mut colour_map = HashMap::new(); + + for node_index in graph.node_indices() { + depth_first_search(graph, &mut colour_map, node_index, &mut cycle_detector); + + if cycle_detector.found_cycle() { + return cycle_detector.into_cycle_path(); + } + } + + None +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum Colour { + #[default] + White, + Grey, + Black, +} + +pub fn depth_first_search<'a, N>( + graph: &'a Graph, + colour_map: &mut HashMap, + start_node_index: NodeIndex, + visitor: &mut impl DfsVisitor<'a>, +) { + let mut stack = vec![(start_node_index, graph.edges(start_node_index))]; + + colour_map.insert(start_node_index, Colour::Grey); + visitor.discover_node(start_node_index); + + while let Some((current, unprocessed_edges)) = stack.last_mut() { + if let Some(edge) = unprocessed_edges.next() { + let target = edge.target(); + + match colour_map.get(&target).unwrap_or(&Colour::White) { + Colour::White => { + visitor.visit_tree_edge(edge); + + colour_map.insert(target, Colour::Grey); + visitor.discover_node(target); + + stack.push((target, graph.edges(target))); + } + Colour::Grey => visitor.visit_back_edge(edge), + Colour::Black => visitor.visit_forward_or_cross_edge(edge), + } + } else { + colour_map.insert(*current, Colour::Black); + visitor.finish_node(*current); + + stack.pop(); + } + } +} + +#[derive(Clone, Debug)] +struct CycleDetector<'a, N, F: FnMut(&N) -> String> { + graph: &'a Graph, + get_node_name: F, + trail: Vec, + found_cycle: bool, +} + +impl<'a, N, F: FnMut(&N) -> String> CycleDetector<'a, N, F> { + fn new(graph: &'a Graph, get_node_name: F) -> Self { + CycleDetector { + graph, + get_node_name, + trail: Vec::new(), + found_cycle: false, + } + } + + fn found_cycle(&self) -> bool { + self.found_cycle + } + + fn into_cycle_path(self) -> Option> { + if self.found_cycle { + Some(self.trail) + } else { + None + } + } +} + +impl<'a, N, F: FnMut(&N) -> String> DfsVisitor<'a> for CycleDetector<'a, N, F> { + fn visit_tree_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>) { + if self.found_cycle { + return; + } + + let source = edge_ref.source(); + let name = (self.get_node_name)(&self.graph[source]); + let edge_type = *edge_ref.weight(); + + let vertex = Vertex::new(name).with_out_edge_type(edge_type); + + self.trail.push(vertex); + } + + fn visit_forward_or_cross_edge(&mut self, _: EdgeReference<'a, EdgeType>) {} + + fn visit_back_edge(&mut self, edge_ref: EdgeReference<'a, EdgeType>) { + self.visit_tree_edge(edge_ref); + + let target_name = (self.get_node_name)(&self.graph[edge_ref.target()]); + + if let Some(pos) = self.trail.iter().position(|v| v.name() == target_name) { + self.trail.drain(..pos); + self.found_cycle = true; + } else { + log::error!("The target of a back edge cannot be found in the current visitor trail"); + } + } + + fn discover_node(&mut self, _: NodeIndex) {} + + fn finish_node(&mut self, _: NodeIndex) { + if !self.found_cycle { + self.trail.pop(); + } + } +} diff --git a/src/sorting/groups.rs b/src/sorting/groups.rs new file mode 100644 index 00000000..9c7e0a66 --- /dev/null +++ b/src/sorting/groups.rs @@ -0,0 +1,271 @@ +use std::{cmp::Reverse, collections::HashMap}; + +use petgraph::{Graph, algo::bellman_ford, graph::NodeIndex}; + +use crate::{ + EdgeType, Vertex, + error::{ + CyclicInteractionError, GeneralError, InvalidArgumentError, PathfindingError, + UndefinedGroupError, + }, + metadata::Group, + sorting::dfs::find_cycle, +}; + +use super::dfs::{DfsVisitor, depth_first_search}; + +pub type GroupsGraph = Graph; + +pub fn build_groups_graph( + masterlist_groups: &[Group], + userlist_groups: &[Group], +) -> Result { + let masterlist_groups = sorted_by_name(masterlist_groups); + let userlist_groups = sorted_by_name(userlist_groups); + + let mut graph = GroupsGraph::new(); + let mut group_nodes: HashMap<&str, NodeIndex> = HashMap::new(); + + log::trace!("Adding masterlist groups to groups graph..."); + add_groups( + &mut graph, + &mut group_nodes, + &masterlist_groups, + EdgeType::MasterlistLoadAfter, + )?; + + log::trace!("Adding user groups to groups graph..."); + add_groups( + &mut graph, + &mut group_nodes, + &userlist_groups, + EdgeType::UserLoadAfter, + )?; + + if let Some(cycle) = find_cycle(&graph, |node| node.clone()) { + Err(CyclicInteractionError { cycle }.into()) + } else { + Ok(graph) + } +} + +fn sorted_by_name(groups: &[Group]) -> Vec<&Group> { + let mut groups: Vec<_> = groups.iter().collect(); + groups.sort_by_key(|a| a.name()); + + groups +} + +fn add_groups<'a>( + graph: &mut GroupsGraph, + group_nodes: &mut HashMap<&'a str, NodeIndex>, + groups: &[&'a Group], + edge_type: EdgeType, +) -> Result<(), UndefinedGroupError> { + for group in groups { + let key = group.name(); + if !group_nodes.contains_key(key) { + let node_index = graph.add_node(group.name().to_string()); + group_nodes.insert(key, node_index); + } + } + + for group in groups { + if log::log_enabled!(log::Level::Trace) { + log::trace!( + "Group \"{}\" directly loads after groups \"{}\"", + group.name(), + group.after_groups().join(", ") + ); + } + + let node_index = group_nodes + .get(group.name()) + .expect("Group node should have just been added"); + + for other_group_name in sorted_clone(group.after_groups()) { + if let Some(other_index) = group_nodes.get(other_group_name) { + graph.update_edge(*other_index, *node_index, edge_type); + } else { + return Err(UndefinedGroupError::new(other_group_name.to_string())); + } + } + } + + Ok(()) +} + +fn sorted_clone(strings: &[String]) -> Vec<&str> { + let mut strings: Vec<_> = strings.iter().map(|s| s.as_str()).collect(); + strings.sort(); + + strings +} + +pub fn find_path( + graph: &GroupsGraph, + from_group_name: &str, + to_group_name: &str, +) -> Result, GeneralError> { + let float_graph: Graph<&String, f32> = graph.map( + |_, n| n, + |_, e| { + if *e == EdgeType::UserLoadAfter { + // A very small number so that user edges are practically always preferred. + -1000000.0 + } else { + 1.0 + } + }, + ); + + let from_vertex = find_node_by_weight(graph, from_group_name)?; + let to_vertex = find_node_by_weight(graph, to_group_name)?; + + let paths = bellman_ford(&float_graph, from_vertex).map_err(|_| InvalidArgumentError { + message: "Groups graph contains a negative cycle".into(), + })?; + + let mut path = vec![Vertex::new(graph[to_vertex].clone())]; + let mut current = to_vertex; + while current != from_vertex { + let preceding_vertex = match paths.predecessors.get(current.index()) { + Some(Some(v)) => v, + Some(None) => { + log::info!( + "No path found from {} to {} while looking for path to {}", + graph[from_vertex], + graph[current], + graph[to_vertex] + ); + return Ok(Vec::new()); + } + _ => { + return Err(PathfindingError::new( + "Could not find a node in the graph while pathfinding".into(), + ) + .into()); + } + }; + + if *preceding_vertex == current { + log::error!( + "Unreachable vertex {} encountered while looking for vertex {}", + graph[current], + graph[from_vertex] + ); + return Ok(Vec::new()); + } + + let edge = match graph.find_edge(*preceding_vertex, current) { + Some(e) => e, + None => { + return Err(PathfindingError::new(format!( + "Could not find edge from \"{}\" to \"{}\"", + graph[*preceding_vertex], graph[current] + )) + .into()); + } + }; + + let vertex = Vertex::new(graph[*preceding_vertex].clone()).with_out_edge_type(graph[edge]); + path.push(vertex); + + current = *preceding_vertex; + } + + path.reverse(); + + Ok(path) +} + +fn find_node_by_weight( + graph: &Graph, + weight: &str, +) -> Result { + match graph + .node_indices() + .find(|i| graph.node_weight(*i).map(|w| *w == weight).unwrap_or(false)) + { + Some(n) => Ok(n), + None => { + log::error!("Can't find group with name {}", weight); + Err(InvalidArgumentError { + message: format!("Can't find group with name {}", weight), + }) + } + } +} + +/// Sort the group vertices so that root vertices come first, in order of +/// decreasing path length, but otherwise preserving the existing +/// (lexicographical) ordering. +pub fn sorted_group_nodes(graph: &GroupsGraph) -> Vec { + let mut nodes: Vec<(NodeIndex, bool, usize)> = graph + .node_indices() + .map(|n| { + if is_root_node(graph, n) { + let mut visitor = GroupsPathLengthVisitor::new(); + + depth_first_search(graph, &mut HashMap::new(), n, &mut visitor); + + (n, true, visitor.max_path_length()) + } else { + (n, false, 0) + } + }) + .collect(); + + nodes.sort_by_key(|a| Reverse((a.1, a.2))); + + nodes.into_iter().map(|n| n.0).collect() +} + +fn is_root_node(graph: &GroupsGraph, node_index: NodeIndex) -> bool { + graph + .neighbors_directed(node_index, petgraph::Direction::Incoming) + .next() + .is_none() +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +struct GroupsPathLengthVisitor { + max_path_length: usize, + current_path_length: usize, +} + +impl GroupsPathLengthVisitor { + fn new() -> Self { + Default::default() + } + + fn max_path_length(&self) -> usize { + self.max_path_length + } +} + +impl<'a> DfsVisitor<'a> for GroupsPathLengthVisitor { + fn visit_tree_edge(&mut self, _: petgraph::graph::EdgeReference<'a, EdgeType>) {} + + fn visit_forward_or_cross_edge(&mut self, _: petgraph::graph::EdgeReference<'a, EdgeType>) {} + + fn visit_back_edge(&mut self, _: petgraph::graph::EdgeReference<'a, EdgeType>) {} + + fn discover_node(&mut self, _: NodeIndex) { + self.current_path_length += 1; + if self.current_path_length > self.max_path_length { + self.max_path_length = self.current_path_length + } + } + + fn finish_node(&mut self, _: NodeIndex) { + self.current_path_length -= 1; + } +} + +pub fn get_default_group_node(graph: &GroupsGraph) -> Result { + graph + .node_indices() + .find(|n| graph[*n] == Group::DEFAULT_NAME) + .ok_or_else(|| UndefinedGroupError::new(Group::DEFAULT_NAME.to_string())) +} diff --git a/src/sorting/mod.rs b/src/sorting/mod.rs new file mode 100644 index 00000000..e4739c42 --- /dev/null +++ b/src/sorting/mod.rs @@ -0,0 +1,5 @@ +mod dfs; +pub mod groups; +pub mod plugins; +mod validate; +pub mod vertex; diff --git a/src/sorting/plugins.rs b/src/sorting/plugins.rs new file mode 100644 index 00000000..6e8f1b82 --- /dev/null +++ b/src/sorting/plugins.rs @@ -0,0 +1,1135 @@ +use std::{ + collections::{HashMap, HashSet}, + rc::Rc, +}; + +use log::log_enabled; +use petgraph::{ + Graph, + graph::{EdgeReference, NodeIndex}, + visit::EdgeRef, +}; + +use crate::{ + EdgeType, Plugin, + error::{CyclicInteractionError, GeneralError, SortingLogicError, UndefinedGroupError}, + metadata::{File, Group, PluginMetadata}, + sorting::groups::{get_default_group_node, sorted_group_nodes}, +}; + +use super::{ + dfs::{BidirBfsVisitor, DfsVisitor, bidirectional_bfs, depth_first_search, find_cycle}, + groups::GroupsGraph, + validate::{validate_plugin_groups, validate_specific_and_hardcoded_edges}, +}; + +#[derive(Debug)] +pub struct PluginSortingData<'a> { + plugin: &'a Plugin, + pub(super) is_master: bool, + override_record_count: usize, + + load_order_index: usize, + + pub(super) group: String, + group_is_user_metadata: bool, + pub(super) masterlist_load_after: Vec, + pub(super) user_load_after: Vec, + pub(super) masterlist_req: Vec, + pub(super) user_req: Vec, +} + +impl<'a> PluginSortingData<'a> { + pub fn new( + plugin: &'a Plugin, + masterlist_metadata: Option<&PluginMetadata>, + user_metadata: Option<&PluginMetadata>, + load_order_index: usize, + ) -> Result { + let override_record_count = plugin.override_record_count()?; + + Ok(Self { + plugin, + is_master: plugin.is_master(), + override_record_count, + load_order_index, + group: user_metadata + .and_then(|m| m.group()) + .or_else(|| masterlist_metadata.and_then(|m| m.group())) + .unwrap_or(Group::DEFAULT_NAME) + .to_string(), + group_is_user_metadata: user_metadata.and_then(|m| m.group()).is_some(), + masterlist_load_after: masterlist_metadata + .map(|m| to_filenames(m.load_after_files())) + .unwrap_or_default(), + user_load_after: user_metadata + .map(|m| to_filenames(m.load_after_files())) + .unwrap_or_default(), + masterlist_req: masterlist_metadata + .map(|m| to_filenames(m.requirements())) + .unwrap_or_default(), + user_req: user_metadata + .map(|m| to_filenames(m.requirements())) + .unwrap_or_default(), + }) + } + + pub(super) fn name(&self) -> &str { + self.plugin.name() + } + + fn is_blueprint_master(&self) -> bool { + self.is_master && self.plugin.is_blueprint_plugin() + } + + fn asset_count(&self) -> usize { + self.plugin.asset_count() + } + + pub(super) fn masters(&self) -> Result, GeneralError> { + self.plugin.masters() + } + + fn do_records_overlap(&self, other: &PluginSortingData) -> Result { + self.plugin.do_records_overlap(other.plugin) + } + + fn do_assets_overlap(&self, other: &PluginSortingData) -> bool { + self.plugin.do_assets_overlap(other.plugin) + } +} + +fn to_filenames(files: &[File]) -> Vec { + files + .iter() + .map(|f| f.name().as_str().to_string()) + .collect() +} + +type InnerPluginsGraph<'a> = Graph>, EdgeType>; + +#[derive(Debug, Default)] +struct PluginsGraph<'a> { + // Put the sorting data in Rc so that it can be held onto while mutating the graph. + inner: InnerPluginsGraph<'a>, + paths_cache: HashMap>, +} + +impl<'a> PluginsGraph<'a> { + fn new() -> Self { + PluginsGraph::default() + } + + fn add_node(&mut self, plugin: PluginSortingData<'a>) { + self.inner.add_node(Rc::new(plugin)); + } + + fn add_edge(&mut self, from: NodeIndex, to: NodeIndex, edge_type: EdgeType) { + if self.is_path_cached(from, to) { + return; + } + + log::debug!( + "Adding {} edge from \"{}\" to \"{}\".", + edge_type, + self.inner[from].name(), + self.inner[to].name() + ); + + self.inner.add_edge(from, to, edge_type); + + self.cache_path(from, to); + } + + fn node_indices(&self) -> petgraph::graph::NodeIndices { + self.inner.node_indices() + } + + fn add_specific_edges(&mut self) -> Result<(), GeneralError> { + log::trace!("Adding edges based on plugin data and non-group metadata..."); + + let mut node_index_iter = self.node_indices(); + while let Some(node_index) = node_index_iter.next() { + let plugin = Rc::clone(&self[node_index]); + + // This loop should have no effect now that master-flagged and + // non-master-flagged plugins are sorted separately, but is kept + // as a safety net. + for other_node_index in node_index_iter.clone() { + let other_plugin = &self[other_node_index]; + + if plugin.is_master == other_plugin.is_master { + continue; + } + + if other_plugin.is_master { + self.add_edge(other_node_index, node_index, EdgeType::MasterFlag); + } else { + self.add_edge(node_index, other_node_index, EdgeType::MasterFlag); + } + } + + for master in plugin.masters()? { + if let Some(other_node_index) = self.node_index_by_name(&master) { + self.add_edge(other_node_index, node_index, EdgeType::Master); + } + } + + for file in &plugin.masterlist_req { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge( + other_node_index, + node_index, + EdgeType::MasterlistRequirement, + ); + } + } + + for file in &plugin.user_req { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge(other_node_index, node_index, EdgeType::UserRequirement); + } + } + + for file in &plugin.masterlist_load_after { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge(other_node_index, node_index, EdgeType::MasterlistLoadAfter); + } + } + + for file in &plugin.user_load_after { + if let Some(other_node_index) = self.node_index_by_name(file) { + self.add_edge(other_node_index, node_index, EdgeType::UserLoadAfter); + } + } + } + + Ok(()) + } + + fn add_early_loading_plugin_edges(&mut self, early_loading_plugins: &[String]) { + log::trace!( + "Adding edges for implicitly active plugins and plugins with hardcoded positions..." + ); + + if early_loading_plugins.is_empty() { + return; + } + + let mut early_loading_plugin_indices: HashMap<&str, NodeIndex> = HashMap::new(); + let mut other_plugin_indices = Vec::new(); + for node_index in self.node_indices() { + let plugin = &self[node_index]; + if let Some(p) = early_loading_plugins + .iter() + .find(|e| unicase::eq(e.as_str(), plugin.name())) + { + early_loading_plugin_indices.insert(p.as_str(), node_index); + } else { + other_plugin_indices.push(node_index); + } + } + + if early_loading_plugin_indices.is_empty() { + return; + } + + let mut last_early_loading_plugin_index = None; + for window in early_loading_plugins.windows(2) { + if let [from, to] = window { + let from_index = early_loading_plugin_indices.get(from.as_str()); + let to_index = early_loading_plugin_indices.get(to.as_str()); + + if to_index.is_some() { + last_early_loading_plugin_index = to_index; + } else if from_index.is_some() { + last_early_loading_plugin_index = from_index; + } + + if let (Some(from_index), Some(to_index)) = (from_index, to_index) { + self.add_edge(*from_index, *to_index, EdgeType::Hardcoded); + } + } + } + + if let Some(from_index) = last_early_loading_plugin_index { + for to_index in other_plugin_indices { + self.add_edge(*from_index, to_index, EdgeType::Hardcoded); + } + } + } + + fn check_for_cycles(&mut self) -> Result<(), CyclicInteractionError> { + if let Some(cycle) = find_cycle(&self.inner, |node| node.name().to_string()) { + Err(CyclicInteractionError::new(cycle)) + } else { + Ok(()) + } + } + + fn add_group_edges(&mut self, groups_graph: &GroupsGraph) -> Result<(), UndefinedGroupError> { + log::trace!("Adding edges based on plugin group memberships..."); + + // First build a map from groups to the plugins in those groups. + let plugins_in_groups = get_plugins_in_groups(&self.inner); + + // Get the default group's vertex because it's needed for the DFSes. + let default_group_node = get_default_group_node(groups_graph)?; + + // Keep a record of which vertices have already been fully explored to avoid + // adding edges from their plugins more than once. + let mut finished_nodes = HashSet::new(); + // Now loop over the vertices in the groups graph. + // The vertex sort order prioritises resolving potential cycles in + // favour of earlier-loading groups. It does not guarantee that the + // longest paths will be walked first, because a root vertex may be in + // more than one path and the vertex sort order here does not influence + // which path the DFS takes. + for group_node in sorted_group_nodes(groups_graph) { + // Run a DFS from each vertex in the group graph, adding edges except from + // plugins in the default group. This could be run only on the root + // vertices, except that the DFS only visits each vertex once, so a branch + // and merge inside a given root's DAG would result in plugins from one of + // the branches not being carried forwards past the point at which the + // branches merge. + let mut visitor = GroupsPathVisitor::new( + self, + groups_graph, + &plugins_in_groups, + &mut finished_nodes, + Some(default_group_node), + ); + + depth_first_search(groups_graph, &mut HashMap::new(), group_node, &mut visitor); + } + + // Now do one last DFS starting from the default group and not ignoring its + // plugins. + let mut visitor = GroupsPathVisitor::new( + self, + groups_graph, + &plugins_in_groups, + &mut finished_nodes, + None, + ); + + depth_first_search( + groups_graph, + &mut HashMap::new(), + default_group_node, + &mut visitor, + ); + + Ok(()) + } + + fn add_overlap_edges(&mut self) -> Result<(), GeneralError> { + log::trace!("Adding edges for overlapping plugins..."); + + let mut node_index_iter = self.node_indices(); + while let Some(node_index) = node_index_iter.next() { + let plugin = Rc::clone(&self[node_index]); + let plugin_asset_count = plugin.asset_count(); + + if plugin.override_record_count == 0 && plugin_asset_count == 0 { + log::debug!( + "Skipping vertex for \"{}\": the plugin contains no override records and loads no assets", + plugin.name() + ); + continue; + } + + // This loop should have no effect now that master-flagged and + // non-master-flagged plugins are sorted separately, but is kept + // as a safety net. + for other_node_index in node_index_iter.clone() { + let other_plugin = &self[other_node_index]; + + // Don't add an edge between these two plugins if one already + // exists (only check direct edges and not paths for efficiency). + if self.inner.contains_edge(node_index, other_node_index) + || self.inner.contains_edge(other_node_index, node_index) + { + continue; + } + + // Two plugins can overlap due to overriding the same records, + // or by loading assets from BSAs/BA2s that have the same path. + // If records overlap, the plugin that overrides more records + // should load earlier. + // If assets overlap, the plugin that loads more assets should + // load earlier. + // If two plugins have overlapping records and assets and one + // overrides more records but loads fewer assets than the other, + // the fact it overrides more records should take precedence + // (records are more significant than assets). + // I.e. if two plugins don't have overlapping records, check their + // assets, otherwise only check their assets if their override + // record counts are equal. + + let outer_plugin_loads_first; + let edge_type; + + if plugin.override_record_count == other_plugin.override_record_count + || !plugin.do_records_overlap(other_plugin)? + { + // Records don't overlap, or override the same number of records, + // check assets. + // No records overlap, check assets. + let other_plugin_asset_count = other_plugin.asset_count(); + if plugin_asset_count == other_plugin_asset_count + || !plugin.do_assets_overlap(other_plugin) + { + // Assets don't overlap or both plugins load the same number of + // assets, don't add an edge. + continue; + } else { + outer_plugin_loads_first = plugin_asset_count > other_plugin_asset_count; + edge_type = EdgeType::AssetOverlap; + } + } else { + // Records overlap and override different numbers of records. + // Load this plugin first if it overrides more records. + outer_plugin_loads_first = + plugin.override_record_count > other_plugin.override_record_count; + edge_type = EdgeType::RecordOverlap + } + + let (from_index, to_index) = if outer_plugin_loads_first { + (node_index, other_node_index) + } else { + (other_node_index, node_index) + }; + + if !self.is_path_cached(from_index, to_index) { + if !self.path_exists(to_index, from_index) { + self.add_edge(from_index, to_index, edge_type); + } else { + log::debug!( + "Skipping {} edge from \"{}\" to \"{}\" as it would create a cycle.", + edge_type, + self[from_index].name(), + self[to_index].name() + ); + } + } + } + } + + Ok(()) + } + + fn add_tie_break_edges(&mut self) -> Result<(), SortingLogicError> { + log::trace!("Adding edges to break ties between plugins..."); + + // In order for the sort to be performed stably, there must be only one + // possible result. This can be enforced by adding edges between all vertices + // that aren't already linked. Use existing load order to decide the direction + // of these edges, and only add an edge if it won't cause a cycle. + // + // Brute-forcing this by adding an edge between every pair of vertices + // (unless it would cause a cycle) works but scales terribly, as before each + // edge is added a bidirectional search needs to be done for a path in the + // other direction (to detect a potential cycle). This search takes more time + // as the number of edges involves increases, so adding tie breaks gets slower + // as they get added. + // + // The point of adding these tie breaks is to ensure that there's a + // Hamiltonian path through the graph and therefore only one possible + // topological sort result. + // + // Instead of trying to brute-force this, iterate over the graph's vertices in + // their existing load order (each vertex represents a plugin, so the two + // terms are used interchangeably), and add an edge going from the earlier to + // the later for each consecutive pair of plugins (e.g. for [A, B, C], add + // edges A->B, B->C), unless adding the edge would cause a cycle. If sorting + // has made no changes to the load order, then it'll be possible to add all + // those edges and only N - 1 bidirectional searches will be needed when there + // are N vertices. + // + // If it's not possible to add such an edge for a pair of plugins [A, B], that + // means that LOOT thinks A needs to load after B, i.e. the sorted load order + // will be different. If the existing path between A and B is B -> C -> D -> A + // then walk back through the load order to find a plugin that B will load + // after without causing a cycle, and add an edge going from that plugin to B. + // Then do the same for each subsequent plugin in the path between A and B so + // that every plugin in the existing load order until A has a path to each of + // the plugins in the path from B to A, and that there is only one path that + // will visit all plugins until A. Keep a record of this path, because that's + // the load order that needs to be walked back through whenever the existing + // relative positions of plugins can't be used (if the existing load order was + // used, the process would miss out on plugins introduced in previous backward + // walks, and so you'd end up with multiple paths that don't necessarily touch + // all plugins). + + // Storage for the load order as it evolves. + let mut new_load_order: Vec = Vec::new(); + + // Holds nodes that have already been put into new_load_order. + let mut processed_nodes = HashSet::new(); + + // First get the graph vertices and sort them into the current load order. + let mut nodes: Vec<_> = self.node_indices().collect(); + nodes.sort_by_key(|a| self[*a].load_order_index); + + for window in nodes.windows(2) { + let (current, next) = match window { + [a, b] => (*a, *b), + _ => panic!("Window length should be 2, got {}", window.len()), + }; + + match self.find_path(next, current)? { + None => { + // There's no path from next to current, so it's OK to add + // an edge going in the other direction, meaning that next can + // load after current. + self.add_edge(current, next, EdgeType::TieBreak); + + // next now loads after current. If current hasn't + // already been added to the load order, append it. It might have already + // been added if it was part of a path going from next and + // current in a previous loop (i.e. for different values of + // next and current). + if !processed_nodes.contains(¤t) { + new_load_order.push(current); + processed_nodes.insert(current); + + log::debug!( + "The plugin \"{}\" loads at the end of the new load order so far.", + self[current].name() + ); + } else if new_load_order.last() != Some(¤t) { + log::trace!( + "The plugin \"{}\" has already been processed and is not the last in the new load order, determining where to place \"{}\".", + self[current].name(), + self[next].name() + ); + + // If current was already processed and not the last vertex + // in new_load_order then next also needs to be pinned in place or + // it may not have a defined position relative to all the + // vertices following current in new_load_order undefined, so + // there wouldn't be a unique path through them. + // + // We're using new_load_order.rend() as the last iterator position because + // we don't know current's position. + self.pin_node_position(&mut processed_nodes, &mut new_load_order, next, 0); + } + } + Some(mut path_from_next_node) => { + // Each vertex in pathFromNextVertex (besides the last, which is + // currentVertex) needs to be positioned relative to a vertex that has + // already been iterated over (i.e. in what begins as the old load + // order) so that there is a single path between all vertices. + // + // If currentVertex is the first in the iteration order, then + // nextVertex is simply the earliest known plugin in the new load order + // so far. + if nodes.first() == Some(¤t) { + // Record the path as the start of the new load order. + // Don't need to add any edges because there's nothing for nextVertex + // to load after at this point. + if log_enabled!(log::Level::Debug) { + log::debug!( + "The path ends with the first plugin checked, treating the following path as the start of the load order: {}", + path_to_string(&self.inner, &path_from_next_node) + ); + } + + for node in path_from_next_node { + new_load_order.push(node); + processed_nodes.insert(node); + } + continue; + } + + // Ignore the last vertex in the path because it's currentVertex and + // will just be appended to the load order so doesn't need special + // processing. + path_from_next_node.pop(); + + // This is used to keep track of when to stop searching for a + // vertex to load after, as a minor optimisation. + let mut range_start = 0; + + // Iterate over the path going from nextVertex towards currentVertex + // (which got chopped off the end of the path). + for node in path_from_next_node { + // Update reverseEndIt to reduce the scope of the search in the + // next loop (if there is one). + range_start = self.pin_node_position( + &mut processed_nodes, + &mut new_load_order, + node, + range_start, + ); + } + + // Add current to the end of the new_load_order - do this after processing the other vertices in the path so that involves less work. + if !processed_nodes.contains(¤t) { + new_load_order.push(current); + processed_nodes.insert(current); + } + } + } + } + + Ok(()) + } + + fn pin_node_position( + &mut self, + processed_nodes: &mut HashSet, + new_load_order: &mut Vec, + node_index: NodeIndex, + range_start: usize, + ) -> usize { + // It's possible that this vertex has already been pinned in place, + // e.g. because it was visited earlier in the old load order or + // as part of a path that was processed. In that case just skip it. + if processed_nodes.contains(&node_index) { + log::debug!( + "The plugin \"{}\" has already been processed, skipping it.", + self[node_index].name() + ); + return range_start; + } + + // Otherwise, this vertex needs to be inserted into the path that includes + // all other vertices that have been processed so far. This can be done by + // searching for the last vertex in the "new load order" path for which + // there is not a path going from this vertex to that vertex. I.e. find the + // last plugin that this one can load after. We could instead find the last + // plugin that this one *must* load after, but it turns out that's + // significantly slower because it generally involves going further back + // along the "new load order" path. + let previous_node_position = new_load_order + .get(range_start..) + .expect("last_pos is within the new_load_order vec") + .iter() + .rposition(|ni| !self.path_exists(node_index, *ni)); + + // Add an edge going from the found vertex to this one, in case it + // doesn't exist (we only know there's not a path going the other way). + if let Some(preceding_node_index) = + previous_node_position.and_then(|p| new_load_order.get(p)) + { + self.add_edge(*preceding_node_index, node_index, EdgeType::TieBreak); + } + + // Insert position is just after the found vertex, and a forward iterator + // points to the element one after the element pointed to by the + // corresponding reverse iterator. + let insert_position = previous_node_position.map(|i| i + 1).unwrap_or(0); + + // Add an edge going from this vertex to the next one in the "new load + // order" path, in case there isn't already one. + if let Some(following_node_index) = new_load_order.get(insert_position) { + self.add_edge(node_index, *following_node_index, EdgeType::TieBreak); + } + + // Now update newLoadOrder with the vertex's new position. + new_load_order.insert(insert_position, node_index); + + if log_enabled!(log::Level::Debug) { + if let Some(next_node_index) = new_load_order.get(insert_position + 1) { + log::debug!( + "The plugin \"{}\" loads before \"{}\" in the new load order.", + self[node_index].name(), + self[*next_node_index].name() + ); + } else { + log::debug!( + "The plugin \"{}\" loads at the end of the new load order so far.", + self[node_index].name() + ); + } + } + + // Return a new value for reverseEndIt, pointing to the newly + // inserted vertex, as if it was not the last vertex in a path + // being processed the next vertex in the path by definition + // cannot load before this one, so we can save an unnecessary + // check by using this new reverseEndIt value when pinning the + // next vertex. + insert_position + 1 + } + + fn topological_sort(&self) -> Result, petgraph::algo::Cycle> { + petgraph::algo::toposort(&self.inner, None) + } + + fn is_hamiltonian_path(&mut self, path: &[NodeIndex]) -> Option<(NodeIndex, NodeIndex)> { + log::trace!("Checking uniqueness of path through plugin graph..."); + + path.windows(2).find_map(|slice| match slice { + [a, b] => self.inner.contains_edge(*a, *b).then_some((*a, *b)), + _ => None, + }) + } + + fn cache_path(&mut self, from: NodeIndex, to: NodeIndex) { + self.paths_cache.entry(from).or_default().insert(to); + } + + fn is_path_cached(&self, from: NodeIndex, to: NodeIndex) -> bool { + self.paths_cache + .get(&from) + .map(|s| s.contains(&to)) + .unwrap_or(false) + } + + fn node_index_by_name(&self, name: &str) -> Option { + self.node_indices() + .find(|i| unicase::eq(self[*i].name(), name)) + } + + fn path_exists(&mut self, from: NodeIndex, to: NodeIndex) -> bool { + if self.is_path_cached(from, to) { + return true; + } + + let mut visitor = PathCacher::new(&mut self.paths_cache, from, to); + + bidirectional_bfs(&self.inner, from, to, &mut visitor) + } + + fn find_path( + &mut self, + from: NodeIndex, + to: NodeIndex, + ) -> Result>, SortingLogicError> { + let mut path_finder = PathFinder::new(&self.inner, &mut self.paths_cache, from, to); + + if bidirectional_bfs(&self.inner, from, to, &mut path_finder) { + path_finder.path() + } else { + Ok(None) + } + } +} + +impl<'a> std::ops::Index for PluginsGraph<'a> { + type Output = Rc>; + + fn index(&self, index: NodeIndex) -> &Self::Output { + &self.inner[index] + } +} + +pub fn sort_plugins( + mut plugins_sorting_data: Vec, + groups_graph: &GroupsGraph, + early_loading_plugins: &[String], +) -> Result, GeneralError> { + if plugins_sorting_data.is_empty() { + return Ok(Vec::new()); + } + + validate_plugin_groups(&plugins_sorting_data, groups_graph)?; + + // Sort the plugins according to the lexicographical order of their names. + // This ensures a consistent iteration order for vertices given the same + // input data. The vertex iteration order can affect what edges get added + // and so the final sorting result, so consistency is important. This order + // needs to be independent of any state (e.g. the current load order) so + // that sorting and applying the result doesn't then produce a different + // result if you then sort again. + plugins_sorting_data.sort_by(|a, b| a.name().cmp(b.name())); + + // Some parts of sorting are O(N^2) for N plugins, and master flags cause + // O(M*N) edges to be added for M masters and N non-masters, which can be + // two thirds of all edges added. The cost of each bidirectional search + // scales with the number of edges, so reducing edges makes searches + // faster. + // Similarly, blueprint plugins load after all others. + // As such, sort plugins using three separate graphs for masters, + // non-masters and blueprint plugins. This means that any edges that go from a + // non-master to a master are effectively ignored, so won't cause cyclic + // interaction errors. Edges going the other way will also effectively be + // ignored, but that shouldn't have a noticeable impact. + let (masters, non_masters): (Vec<_>, Vec<_>) = + plugins_sorting_data.into_iter().partition(|p| p.is_master); + + let (masters, blueprint_masters): (Vec<_>, Vec<_>) = + masters.into_iter().partition(|p| !p.is_blueprint_master()); + + validate_specific_and_hardcoded_edges( + &masters, + &blueprint_masters, + &non_masters, + early_loading_plugins, + )?; + + let mut masters_load_order = + sort_plugins_partition(masters, groups_graph, early_loading_plugins)?; + + let blueprint_masters_load_order = + sort_plugins_partition(blueprint_masters, groups_graph, early_loading_plugins)?; + + let non_masters_load_order = + sort_plugins_partition(non_masters, groups_graph, early_loading_plugins)?; + + masters_load_order.extend(non_masters_load_order); + masters_load_order.extend(blueprint_masters_load_order); + + Ok(masters_load_order) +} + +fn sort_plugins_partition( + plugins_sorting_data: Vec, + groups_graph: &GroupsGraph, + early_loading_plugins: &[String], +) -> Result, GeneralError> { + let mut graph = PluginsGraph::new(); + + for plugin in plugins_sorting_data { + graph.add_node(plugin); + } + + graph.add_specific_edges()?; + graph.add_early_loading_plugin_edges(early_loading_plugins); + + // Check for cycles now because from this point on edges are only added if + // they don't cause cycles, and adding overlap and tie-break edges is + // relatively slow, so checking now provides quicker feedback if there is an + // issue. + graph.check_for_cycles()?; + + graph.add_group_edges(groups_graph)?; + graph.add_overlap_edges()?; + graph.add_tie_break_edges()?; + + // Check for cycles again, just in case there's a bug that lets some occur. + // The check doesn't take a significant amount of time. + graph.check_for_cycles()?; + + let sorted_nodes = graph.topological_sort()?; + + if let Some((first, second)) = graph.is_hamiltonian_path(&sorted_nodes) { + log::error!( + "The path is not unique. No edge exists between {} and {}", + graph[first].name(), + graph[second].name() + ); + } + + let sorted_plugin_names = sorted_nodes + .into_iter() + .map(|i| graph[i].name().to_string()) + .collect(); + + Ok(sorted_plugin_names) +} + +fn path_to_string(graph: &InnerPluginsGraph, path: &[NodeIndex]) -> String { + path.iter() + .map(|i| graph[*i].name()) + .collect::>() + .join(", ") +} + +#[derive(Debug)] +struct PathFinder<'a, 'b> { + graph: &'a InnerPluginsGraph<'b>, + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, + forward_parents: HashMap, + reverse_children: HashMap, + intersection_node: Option, +} + +impl<'a, 'b> PathFinder<'a, 'b> { + fn new( + graph: &'a InnerPluginsGraph<'b>, + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, + ) -> Self { + Self { + graph, + cache, + from_node_index, + to_node_index, + forward_parents: HashMap::new(), + reverse_children: HashMap::new(), + intersection_node: None, + } + } + + fn cache_path(&mut self, from: NodeIndex, to: NodeIndex) { + self.cache.entry(from).or_default().insert(to); + } + + fn path(&self) -> Result>, SortingLogicError> { + match self.intersection_node { + None => Ok(None), + Some(intersection_node) => { + let mut current_node = intersection_node; + let mut path = vec![current_node]; + + while current_node != self.from_node_index { + if let Some(next) = self.forward_parents.get(¤t_node) { + path.push(*next); + current_node = *next; + } else { + log::error!( + "Could not find parent vertex of {}. Path so far is {}", + self.graph[current_node].name(), + path_to_string(self.graph, &path) + ); + return Err(SortingLogicError::new(format!( + "Could not find parent vertex of {}", + self.graph[current_node].name() + ))); + } + } + + // The path currently runs backwards, so reverse it. + path.reverse(); + + current_node = intersection_node; + + while current_node != self.to_node_index { + if let Some(next) = self.reverse_children.get(¤t_node) { + path.push(*next); + current_node = *next; + } else { + log::error!( + "Could not find child vertex of {}. Path so far is {}", + self.graph[current_node].name(), + path_to_string(self.graph, &path) + ); + return Err(SortingLogicError::new(format!( + "Could not find child vertex of {}", + self.graph[current_node].name() + ))); + } + } + + Ok(Some(path)) + } + } + } +} + +impl BidirBfsVisitor for PathFinder<'_, '_> { + fn visit_forward_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex) { + self.cache_path(self.from_node_index, target); + + self.forward_parents.insert(target, source); + } + + fn visit_reverse_bfs_edge(&mut self, source: NodeIndex, target: NodeIndex) { + self.cache_path(source, self.to_node_index); + + self.reverse_children.insert(source, target); + } + + fn visit_intersection_node(&mut self, node: NodeIndex) { + self.intersection_node = Some(node) + } +} + +#[derive(Debug)] +struct PathCacher<'a> { + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, +} + +fn get_plugins_in_groups(graph: &InnerPluginsGraph) -> HashMap> { + let mut plugins_in_groups: HashMap> = HashMap::new(); + + for node in graph.node_indices() { + let group_name = graph[node].group.clone(); + + plugins_in_groups.entry(group_name).or_default().push(node); + } + + plugins_in_groups +} + +impl<'a> PathCacher<'a> { + fn new( + cache: &'a mut HashMap>, + from_node_index: NodeIndex, + to_node_index: NodeIndex, + ) -> Self { + Self { + cache, + from_node_index, + to_node_index, + } + } + + fn cache_path(&mut self, from: NodeIndex, to: NodeIndex) { + self.cache.entry(from).or_default().insert(to); + } +} + +impl BidirBfsVisitor for PathCacher<'_> { + fn visit_forward_bfs_edge(&mut self, _: NodeIndex, target: NodeIndex) { + self.cache_path(self.from_node_index, target); + } + + fn visit_reverse_bfs_edge(&mut self, source: NodeIndex, _: NodeIndex) { + self.cache_path(source, self.to_node_index); + } + + fn visit_intersection_node(&mut self, _: NodeIndex) {} +} + +// Use type aliases to make intent clearer without the complications of introducing newtypes. +type PluginNodeIndex = NodeIndex; +type GroupNodeIndex = NodeIndex; + +struct GroupsPathVisitor<'a, 'b, 'c, 'd, 'e> { + plugins_graph: &'a mut PluginsGraph<'b>, + groups_graph: &'e GroupsGraph, + groups_plugins: &'c HashMap>, + finished_group_vertices: &'d mut HashSet, + group_node_to_ignore_as_source: Option, + edge_stack: Vec<(EdgeReference<'e, EdgeType>, &'c [PluginNodeIndex])>, + unfinishable_nodes: HashSet, +} + +impl<'a, 'b, 'c, 'd, 'e> GroupsPathVisitor<'a, 'b, 'c, 'd, 'e> { + fn new( + plugins_graph: &'a mut PluginsGraph<'b>, + groups_graph: &'e GroupsGraph, + groups_plugins: &'c HashMap>, + finished_group_vertices: &'d mut HashSet, + group_node_to_ignore_as_source: Option, + ) -> Self { + Self { + plugins_graph, + groups_graph, + groups_plugins, + finished_group_vertices, + group_node_to_ignore_as_source, + edge_stack: Vec::new(), + unfinishable_nodes: HashSet::new(), + } + } + + fn should_ignore_source_node(&self, node_index: GroupNodeIndex) -> bool { + self.group_node_to_ignore_as_source == Some(node_index) + || self.finished_group_vertices.contains(&node_index) + } + + fn find_plugins_in_group(&self, node_index: GroupNodeIndex) -> &'c [PluginNodeIndex] { + self.groups_plugins + .get(&self.groups_graph[node_index]) + .map(|v| v.as_slice()) + .unwrap_or_default() + } + + fn add_plugin_graph_edges( + &mut self, + edge_stack_index: usize, + target_plugins: &[PluginNodeIndex], + ) { + let from_plugins = self.edge_stack[edge_stack_index].1; + + let path_involves_user_metadata = self.edge_stack[edge_stack_index..] + .iter() + .any(|p| *p.0.weight() == EdgeType::UserLoadAfter); + + for from_plugin in from_plugins { + self.add_edges_from_plugin(*from_plugin, target_plugins, path_involves_user_metadata); + } + } + + fn add_edges_from_plugin( + &mut self, + from_plugin: PluginNodeIndex, + to_plugins: &[PluginNodeIndex], + path_involves_user_metadata: bool, + ) { + if to_plugins.is_empty() { + return; + } + + for to_plugin in to_plugins { + if !self.plugins_graph.is_path_cached(from_plugin, *to_plugin) { + if !self.plugins_graph.path_exists(*to_plugin, from_plugin) { + let involves_user_metadata = path_involves_user_metadata + || self.plugins_graph[from_plugin].group_is_user_metadata + || self.plugins_graph[*to_plugin].group_is_user_metadata; + + let edge_type = if involves_user_metadata { + EdgeType::UserGroup + } else { + EdgeType::MasterlistGroup + }; + + self.plugins_graph + .add_edge(from_plugin, *to_plugin, edge_type); + } else { + log::debug!( + "Skipping group edge from \"{}\" to \"{}\" as it would create a cycle.", + self.plugins_graph[from_plugin].name(), + self.plugins_graph[*to_plugin].name() + ); + } + } + } + } +} + +impl<'e> DfsVisitor<'e> for GroupsPathVisitor<'_, '_, '_, '_, 'e> { + fn visit_tree_edge(&mut self, edge_ref: EdgeReference<'e, EdgeType>) { + let source = edge_ref.source(); + let target = edge_ref.target(); + + // Add the edge to the stack so that its providence can be taken into + // account when adding edges from this source group and previous groups' + // plugins. + // Also record the plugins in the edge's source group, unless the source + // group should be ignored (e.g. because the visitor has been configured + // to ignore the default group's plugins as sources). + let edge_plugins = if self.should_ignore_source_node(source) { + &[] + } else { + self.find_plugins_in_group(source) + }; + self.edge_stack.push((edge_ref, edge_plugins)); + + // Find the plugins in the target group. + let target_plugins = self.find_plugins_in_group(target); + + // Add edges going from all the plugins in the groups in the path being + // currently walked, to the plugins in the current target group's plugins. + for i in 0..self.edge_stack.len() { + self.add_plugin_graph_edges(i, target_plugins); + } + } + + fn visit_forward_or_cross_edge(&mut self, edge_ref: EdgeReference<'e, EdgeType>) { + // Mark the source vertex as unfinishable, because none of the plugins in + // in the path so far can have edges added to plugins past the target + // vertex. + self.unfinishable_nodes.insert(edge_ref.source()); + } + + fn visit_back_edge(&mut self, _: EdgeReference<'e, EdgeType>) {} + + fn discover_node(&mut self, _: GroupNodeIndex) {} + + fn finish_node(&mut self, node_index: GroupNodeIndex) { + // Now that this vertex's DFS-tree has been fully explored, mark it as + // finished so that it won't have edges added from its plugins again in a + // different DFS that uses the same finished vertices set. + if self.group_node_to_ignore_as_source != Some(node_index) + && !self.unfinishable_nodes.contains(&node_index) + { + self.finished_group_vertices.insert(node_index); + } + + // Since this vertex has been fully explored, pop the edge stack to remove + // the edge that has this vertex as its target. + self.edge_stack.pop(); + } +} diff --git a/src/sorting/validate.rs b/src/sorting/validate.rs new file mode 100644 index 00000000..27a04384 --- /dev/null +++ b/src/sorting/validate.rs @@ -0,0 +1,198 @@ +use std::collections::HashSet; + +use unicase::UniCase; + +use crate::{ + EdgeType, Vertex, + error::{CyclicInteractionError, GeneralError, UndefinedGroupError}, +}; + +use super::{groups::GroupsGraph, plugins::PluginSortingData}; + +pub fn validate_plugin_groups( + plugins_sorting_data: &[PluginSortingData<'_>], + groups_graph: &GroupsGraph, +) -> Result<(), UndefinedGroupError> { + let group_names: HashSet<&String> = groups_graph + .node_indices() + .map(|i| &groups_graph[i]) + .collect(); + + for plugin in plugins_sorting_data { + if !group_names.contains(&plugin.group) { + return Err(UndefinedGroupError::new(plugin.group.clone())); + } + } + + Ok(()) +} + +pub fn validate_specific_and_hardcoded_edges( + masters: &[PluginSortingData<'_>], + blueprint_masters: &[PluginSortingData<'_>], + non_masters: &[PluginSortingData<'_>], + early_loading_plugins: &[String], +) -> Result<(), GeneralError> { + log::trace!("Validating specific and early-loading plugin edges..."); + + let non_masters_set: HashSet> = + masters.iter().map(|p| UniCase::new(p.name())).collect(); + let blueprint_masters_set: HashSet> = blueprint_masters + .iter() + .map(|p| UniCase::new(p.name())) + .collect(); + + validate_masters(masters, &non_masters_set, &blueprint_masters_set)?; + + validate_non_masters(non_masters, &blueprint_masters_set)?; + + // There's at least one master, check that there are no hardcoded + // non-masters. + validate_early_loading_plugins(early_loading_plugins, masters, &non_masters_set)?; + + Ok(()) +} + +fn validate_masters( + masters: &[PluginSortingData<'_>], + non_masters: &HashSet>, + blueprint_masters: &HashSet>, +) -> Result<(), GeneralError> { + log::trace!( + "Validating specific and early-loading plugin edges for non-blueprint master files..." + ); + masters + .iter() + .try_for_each(|m| validate_plugin(m, non_masters, blueprint_masters)) +} + +fn validate_non_masters( + non_masters: &[PluginSortingData<'_>], + blueprint_masters: &HashSet>, +) -> Result<(), GeneralError> { + log::trace!("Validating specific and early-loading plugin edges for non-master files..."); + + // Pass an empty set of non-masters so that the non-masters don't get validated against themselves. + let empty_set = HashSet::new(); + + non_masters + .iter() + .try_for_each(|p| validate_plugin(p, &empty_set, blueprint_masters)) +} + +fn validate_plugin( + plugin: &PluginSortingData<'_>, + non_masters: &HashSet>, + blueprint_masters: &HashSet>, +) -> Result<(), GeneralError> { + for master in plugin.masters()? { + let key = UniCase::new(master.as_str()); + if non_masters.contains(&key) { + return Err(CyclicInteractionError::new(vec![ + Vertex::new(master).with_out_edge_type(EdgeType::Master), + Vertex::new(plugin.name().to_string()).with_out_edge_type(EdgeType::MasterFlag), + ]) + .into()); + } + + if blueprint_masters.contains(&key) { + // Log a warning instead of throwing an exception because the game will + // just ignore this master, and the issue can't be fixed without + // editing the plugin and the blueprint master may not actually have + // any of its records overridden. + let plugin_type = if plugin.is_master { + "master" + } else { + "non-master" + }; + log::warn!( + "The {} plugin \"{}\" has the blueprint master \"{}\" as one of its masters", + plugin_type, + plugin.name(), + master + ); + } + } + + validate_files( + &plugin.masterlist_req, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::MasterlistRequirement, + )?; + + validate_files( + &plugin.user_req, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::UserRequirement, + )?; + + validate_files( + &plugin.masterlist_load_after, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::MasterlistLoadAfter, + )?; + + validate_files( + &plugin.user_load_after, + plugin.name(), + non_masters, + blueprint_masters, + EdgeType::UserLoadAfter, + )?; + + Ok(()) +} + +fn validate_files( + files: &[String], + plugin_name: &str, + non_masters: &HashSet>, + blueprint_masters: &HashSet>, + edge_type: EdgeType, +) -> Result<(), CyclicInteractionError> { + for file in files { + let key = UniCase::new(file.as_str()); + if non_masters.contains(&key) { + return Err(CyclicInteractionError::new(vec![ + Vertex::new(file.clone()).with_out_edge_type(edge_type), + Vertex::new(plugin_name.to_string()).with_out_edge_type(EdgeType::MasterFlag), + ])); + } + + if blueprint_masters.contains(&key) { + return Err(CyclicInteractionError::new(vec![ + Vertex::new(file.clone()).with_out_edge_type(edge_type), + Vertex::new(plugin_name.to_string()).with_out_edge_type(EdgeType::BlueprintMaster), + ])); + } + } + + Ok(()) +} + +fn validate_early_loading_plugins( + early_loading_plugins: &[String], + masters: &[PluginSortingData<'_>], + non_masters: &HashSet>, +) -> Result<(), CyclicInteractionError> { + if let Some(master) = masters.first() { + for plugin in early_loading_plugins { + let key = UniCase::new(plugin.as_str()); + if non_masters.contains(&key) { + // Just report the cycle to the first master. + return Err(CyclicInteractionError::new(vec![ + Vertex::new(plugin.to_string()).with_out_edge_type(EdgeType::Hardcoded), + Vertex::new(master.name().to_string()).with_out_edge_type(EdgeType::MasterFlag), + ])); + } + } + } + + Ok(()) +} diff --git a/src/sorting/vertex.rs b/src/sorting/vertex.rs new file mode 100644 index 00000000..0c4b8f38 --- /dev/null +++ b/src/sorting/vertex.rs @@ -0,0 +1,75 @@ +/// An enum representing the different possible types of interactions between +/// plugins or groups. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum EdgeType { + Hardcoded, + MasterFlag, + Master, + MasterlistRequirement, + UserRequirement, + MasterlistLoadAfter, + UserLoadAfter, + MasterlistGroup, + UserGroup, + RecordOverlap, + AssetOverlap, + TieBreak, + BlueprintMaster, +} + +impl std::fmt::Display for EdgeType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EdgeType::Hardcoded => write!(f, "Hardcoded"), + EdgeType::MasterFlag => write!(f, "Master Flag"), + EdgeType::Master => write!(f, "Master"), + EdgeType::MasterlistRequirement => write!(f, "Masterlist Requirement"), + EdgeType::UserRequirement => write!(f, "User Requirement"), + EdgeType::MasterlistLoadAfter => write!(f, "Masterlist Load After"), + EdgeType::UserLoadAfter => write!(f, "User Load After"), + EdgeType::MasterlistGroup => write!(f, "Masterlist Group"), + EdgeType::UserGroup => write!(f, "User Group"), + EdgeType::RecordOverlap => write!(f, "Record Overlap"), + EdgeType::AssetOverlap => write!(f, "Asset Overlap"), + EdgeType::TieBreak => write!(f, "Tie Break"), + EdgeType::BlueprintMaster => write!(f, "Blueprint Master"), + } + } +} + +/// Represents a plugin or group vertex in a path, and the type of the edge to +/// the next vertex in the path if one exists. +#[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct Vertex { + name: String, + out_edge_type: Option, +} + +impl Vertex { + /// Construct a Vertex with the given name and no out edge. + #[must_use] + pub fn new(name: String) -> Self { + Self { + name, + ..Default::default() + } + } + + /// Set the type of the edge going from this vertex to the next in the path. + #[must_use] + pub fn with_out_edge_type(mut self, out_edge_type: EdgeType) -> Self { + self.out_edge_type = Some(out_edge_type); + self + } + + /// Get the name of the plugin or group that the vertex represents. + pub fn name(&self) -> &str { + &self.name + } + + /// Get the type of the edge going from this vertex to the next in the path. + pub fn out_edge_type(&self) -> Option { + self.out_edge_type + } +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 00000000..429749de --- /dev/null +++ b/src/version.rs @@ -0,0 +1,47 @@ +/// libloot's major version number. +pub const LIBLOOT_VERSION_MAJOR: u32 = parse_u32(env!("CARGO_PKG_VERSION_MAJOR")); + +/// libloot's minor version number. +pub const LIBLOOT_VERSION_MINOR: u32 = parse_u32(env!("CARGO_PKG_VERSION_MINOR")); + +/// libloot's patch version number. +pub const LIBLOOT_VERSION_PATCH: u32 = parse_u32(env!("CARGO_PKG_VERSION_PATCH")); + +/// Get the library version in the form "major.minor.patch". +pub fn libloot_version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +/// Get the ID of the source control revision that libloot was built from. +pub fn libloot_revision() -> String { + libloot_revision_const().to_string() +} + +/// Checks whether the loaded API is compatible with the given version of the +/// API, abstracting API stability policy away from clients. The version +/// numbering used is major.minor.patch. +pub fn is_compatible(major: u32, minor: u32, _patch: u32) -> bool { + if major > 0 { + major == LIBLOOT_VERSION_MAJOR + } else { + minor == LIBLOOT_VERSION_MINOR + } +} + +const fn parse_u32(value: &str) -> u32 { + let mut acc = 0; + let mut i = 0; + while i < value.len() { + acc = acc * 10 + (value.as_bytes()[i] - b'0') as u32; + i += 1; + } + acc +} + +const fn libloot_revision_const() -> &'static str { + if let Some(s) = option_env!("LIBLOOT_REVISION") { + s + } else { + "unknown" + } +}