Initial commit with nexus updates and streamlined UI

This commit is contained in:
jschantli committed 2026-09-21 19:01:19 -07:00
commit f780daecad
41 files changed
+4668

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
import os
import unittest
from pathlib import Path
from mhwmm.core import paks
from mhwmm.core.deploy import DeployError, DeployMod, Deployer, build_desired, validate_dest
from mhwmm.core.layout import classify
from tests.helpers import TmpCase, make_game, make_kpka, snapshot, write_tree
class DeployBase(TmpCase):
def setUp(self):
super().setUp()
self.game = make_game(self.tmp)
self.baseline = paks.capture_baseline(self.game)
paks.save_baseline(self.paths.baseline_file, self.baseline)
def mod(self, mid: str, files: dict, **kw) -> DeployMod:
root = write_tree(self.paths.mods_dir / mid / "files", files)
return DeployMod(mid, tuple(classify(root, **kw).entries))
def deployer(self, mode="symlink") -> Deployer:
return Deployer(self.game, self.paths, mode)
def deploy(self, d: Deployer, mods):
return d.apply(d.plan(build_desired(mods, self.baseline)))
class RoundTrip(DeployBase):
def _roundtrip(self, mode):
# a pre-existing unmanaged file that a mod will shadow
write_tree(self.game, {"natives/STM/shadowed.txt": "original"})
before = snapshot(self.game)
mods = [
self.mod("a", {"natives/STM/a.bin": "A", "natives/STM/shadowed.txt": "mod",
"reframework/autorun/a.lua": "print(1)", "a.pak": "P1"}),
self.mod("b", {"natives/STM/deep/dir/b.bin": "B", "b.pak": "P2",
"sub.sub_000.pak.patch_099.pak": "S"}),
]
d = self.deployer(mode)
res = self.deploy(d, mods)
self.assertGreater(res.added, 0)
self.assertTrue((self.game / "natives/STM/deep/dir/b.bin").exists())
self.assertEqual((self.game / "natives/STM/shadowed.txt").read_text(), "mod")
self.assertTrue((self.game / "re_chunk_000.pak.patch_016.pak").exists())
self.assertTrue((self.game / "re_chunk_000.pak.patch_017.pak").exists())
self.assertTrue((self.game / "re_chunk_000.pak.sub_000.pak.patch_016.pak").exists())
self.assertNotEqual(before, snapshot(self.game))
d.undeploy_all()
self.assertEqual(before, snapshot(self.game))
self.assertEqual(d.manifest()["entries"], {})
def test_roundtrip_symlink(self):
self._roundtrip("symlink")
def test_roundtrip_hardlink(self):
self._roundtrip("hardlink")
def test_roundtrip_copy(self):
self._roundtrip("copy")
def test_incremental_disable_prunes_only_our_dirs(self):
(self.game / "natives").mkdir() # pre-existing, not ours
before = snapshot(self.game)
a = self.mod("a", {"natives/STM/x/a.bin": "A"})
d = self.deployer()
self.deploy(d, [a])
self.deploy(d, [])
self.assertEqual(before, snapshot(self.game))
self.assertTrue((self.game / "natives").is_dir())
class Conflicts(DeployBase):
def test_later_mod_wins_and_reorder_flips(self):
a = self.mod("a", {"natives/STM/shared.bin": "A"})
b = self.mod("b", {"natives/STM/Shared.bin": "B"}) # case differs
des = build_desired([a, b], self.baseline)
self.assertEqual(len(des.conflicts), 1)
self.assertEqual(des.conflicts[0].winner, "b")
self.assertEqual(des.conflicts[0].losers, ["a"])
des = build_desired([b, a], self.baseline)
self.assertEqual(des.conflicts[0].winner, "a")
d = self.deployer()
self.deploy(d, [a, b])
self.assertEqual((self.game / "natives/STM/Shared.bin").read_text(), "B")
self.deploy(d, [b, a])
self.assertEqual((self.game / "natives/STM/shared.bin").read_text(), "A")
self.assertFalse((self.game / "natives/STM/Shared.bin").exists())
def test_conflicts_span_pak_and_loose(self):
loose = "natives/STM/player/mod.bin"
pak_a = make_kpka([loose, "natives/stm/other.bin"])
pak_b = make_kpka(["natives/stm/other.bin"])
a = self.mod("a", {"a.pak": pak_a})
b = self.mod("b", {"b.pak": pak_b})
c = self.mod("c", {loose: "x"})
des = build_desired([a, b, c], self.baseline)
kinds = {(x.kind, tuple(x.mods)): x for x in des.conflicts}
self.assertIn(("pak-pak", ("a", "b")), kinds)
self.assertEqual(kinds[("pak-pak", ("a", "b"))].winner, "b")
pl = kinds[("pak-loose", ("a", "c"))]
self.assertEqual(pl.winner, "c") # loose loader beats PAK contents
class Safety(DeployBase):
def test_modified_file_is_never_deleted(self):
a = self.mod("a", {"natives/STM/a.bin": "A"})
d = self.deployer("copy")
self.deploy(d, [a])
target = self.game / "natives/STM/a.bin"
target.write_text("user edited")
plan = d.plan(build_desired([], self.baseline))
self.assertTrue(plan.warnings)
d.apply(plan)
self.assertEqual(target.read_text(), "user edited")
self.assertEqual(d.manifest()["entries"], {})
def test_foreign_pak_is_never_deleted(self):
d = self.deployer()
stranger = self.game / "re_chunk_000.pak.patch_016.pak"
stranger.write_bytes(b"not ours")
self.deploy(d, [])
d.undeploy_all()
self.assertEqual(stranger.read_bytes(), b"not ours")
def test_reframework_data_never_managed(self):
m = self.mod("a", {"reframework/data/settings.json": "x", "reframework/autorun/a.lua": "y"})
self.assertTrue(all("data" not in e.dest for e in m.entries))
self.assertIsNotNone(validate_dest("reframework/data/x.json"))
self.assertIsNotNone(validate_dest("../evil"))
self.assertIsNotNone(validate_dest("/etc/passwd"))
self.assertIsNotNone(validate_dest("natives/../../x"))
self.assertIsNotNone(validate_dest("MonsterHunterWilds.exe"))
self.assertIsNone(validate_dest("natives/STM/a.bin"))
def test_rollback_on_midway_failure(self):
good = self.mod("a", {"natives/STM/a.bin": "A", "natives/STM/z.bin": "Z"})
d = self.deployer()
plan = d.plan(build_desired([good], self.baseline))
os.unlink(good.entries[-1].src) # last add will fail: source vanished
before = snapshot(self.game)
with self.assertRaises(DeployError):
d.apply(plan)
self.assertEqual(before, snapshot(self.game))
self.assertEqual(d.manifest()["entries"], {})
def test_replacing_keeps_original_backup(self):
write_tree(self.game, {"natives/STM/s.txt": "orig"})
before = snapshot(self.game)
d = self.deployer()
self.deploy(d, [self.mod("a", {"natives/STM/s.txt": "A"})])
self.deploy(d, [self.mod("a", {"natives/STM/s.txt": "A"}), self.mod("b", {"natives/STM/s.txt": "B"})])
self.assertEqual((self.game / "natives/STM/s.txt").read_text(), "B")
d.undeploy_all()
self.assertEqual(before, snapshot(self.game))
def test_dll_choice(self):
root = write_tree(self.paths.mods_dir / "d" / "files", {"thing.dll": "x"})
self.assertEqual(len(classify(root).ambiguous), 1)
self.assertEqual(classify(root, "plugin").entries[0].dest, "reframework/plugins/thing.dll")
self.assertEqual(classify(root, "root").entries[0].dest, "thing.dll")
self.assertTrue(classify(root, "skip").empty)
class PakUpdate(DeployBase):
def test_slots_start_above_ceiling_in_load_order(self):
a = self.mod("a", {"a1.pak": "1", "a2.pak": "2"})
b = self.mod("b", {"b.pak": "3"})
slots = paks.allocate([(m.id, list(m.entries)) for m in (a, b)], self.baseline)
self.assertEqual([(s.mod_id, s.number) for s in slots], [("a", 16), ("a", 17), ("b", 18)])
def test_title_update_reslots(self):
d = self.deployer()
a = self.mod("a", {"a.pak": "AAA", "natives/STM/a.bin": "A"})
self.deploy(d, [a])
self.assertEqual(d.owned_paks(), {"re_chunk_000.pak.patch_016.pak"})
self.assertFalse(paks.detect_update(self.game, self.baseline, d.owned_paks()).updated)
# Steam ships patch_016 over our symlink and patch_017 on the sub chain
(self.game / "re_chunk_000.pak.patch_016.pak").unlink()
(self.game / "re_chunk_000.pak.patch_016.pak").write_bytes(b"vanilla-16")
(self.game / "re_chunk_000.pak.sub_000.pak.patch_016.pak").write_bytes(b"vanilla-sub-16")
rep = paks.detect_update(self.game, self.baseline, d.owned_paks())
self.assertTrue(rep.updated)
d.apply(d.plan(d.desired_from_manifest(exclude_kind="pak")))
self.assertEqual((self.game / "re_chunk_000.pak.patch_016.pak").read_bytes(), b"vanilla-16")
self.assertTrue((self.game / "natives/STM/a.bin").is_symlink()) # loose files untouched
self.baseline = paks.capture_baseline(self.game, d.owned_paks())
paks.save_baseline(self.paths.baseline_file, self.baseline)
self.assertEqual(self.baseline.ceiling("main"), 16)
self.assertEqual(self.baseline.ceiling("sub"), 16)
self.deploy(d, [a])
self.assertTrue((self.game / "re_chunk_000.pak.patch_017.pak").is_symlink())
self.assertEqual(d.owned_paks(), {"re_chunk_000.pak.patch_017.pak"})
d.undeploy_all()
self.assertEqual((self.game / "re_chunk_000.pak.patch_016.pak").read_bytes(), b"vanilla-16")
def test_synthetic_patch_016_from_outside_is_detected(self):
d = self.deployer()
(self.game / "re_chunk_000.pak.patch_016.pak").write_bytes(b"steam")
self.assertTrue(paks.detect_update(self.game, self.baseline, d.owned_paks()).updated)
def test_baseline_ceiling(self):
self.assertEqual(self.baseline.ceiling("main"), 15)
self.assertEqual(self.baseline.ceiling("sub"), 15)
if __name__ == "__main__":
unittest.main()