diff --git a/remote_packages.py b/remote_packages.py index 4aef108..020d8e3 100644 --- a/remote_packages.py +++ b/remote_packages.py @@ -672,7 +672,7 @@ VISUAL_MOD_REVISIONS = { "visual_pretty_night_sky": "1", "visual_epoch_water": "1", "visual_fog_pushback": "1", - "visual_pink_herbs": "1", + "visual_pink_herbs": "2", } @@ -943,6 +943,74 @@ def _verify_mpq(path): raise RemotePackageError("Downloaded file is not a valid MPQ archive.") + +def _files_match_exactly(path_a, path_b): + """Compare two files byte-for-byte without loading them fully into memory.""" + try: + if os.path.getsize(path_a) != os.path.getsize(path_b): + return False + with open(path_a, "rb") as first, open(path_b, "rb") as second: + while True: + first_chunk = first.read(1024 * 1024) + second_chunk = second.read(1024 * 1024) + if first_chunk != second_chunk: + return False + if not first_chunk: + return True + except OSError: + return False + + +def _migrate_legacy_pink_herbs_patch(target_dir, downloaded_path, progress=None): + """Release legacy patch-H ownership only when doing so is demonstrably safe. + + Pink Herbs used to be installed as Data\\patch-H.mpq. Other visual packs + may also use that filename, so a user may have replaced the old managed + file manually. During the v2 migration we only remove/restore patch-H when + the live file is missing or exactly matches the freshly downloaded Pink + Herbs MPQ. Otherwise the current patch-H is preserved and ownership is + forgotten before Pink Herbs moves to patch-V.mpq. + """ + mod_id = "visual_pink_herbs" + legacy_rel = _safe_relative_path(os.path.join("Data", "patch-H.mpq")) + legacy_key = os.path.normcase(legacy_rel) + files = _load_managed_manifest(target_dir, mod_id) + + if not any(os.path.normcase(rel) == legacy_key for rel in files): + return + + legacy_target = os.path.join(target_dir, legacy_rel) + safe_to_release = not os.path.lexists(legacy_target) + if os.path.isfile(legacy_target): + safe_to_release = _files_match_exactly(legacy_target, downloaded_path) + + if safe_to_release: + _restore_or_remove_managed_file(target_dir, mod_id, legacy_rel) + _emit_progress( + progress, + "Released legacy Pink Herbs patch-H.mpq for migration to patch-V.mpq.", + None, + None, + ) + else: + _emit_progress( + progress, + "Keeping existing patch-H.mpq because it no longer matches Pink Herbs.", + None, + None, + ) + + data = _load_managed_manifest_data(target_dir, mod_id) + remaining = [ + rel for rel in files if os.path.normcase(rel) != legacy_key + ] + _write_managed_manifest( + target_dir, + mod_id, + remaining, + revision=data.get("revision"), + ) + def _install_remote_mpq( target_dir, mod_id, @@ -1038,15 +1106,40 @@ def install_fog_pushback(target_dir, progress=None): def install_pink_herbs(target_dir, progress=None): - _install_remote_mpq( - target_dir, - "visual_pink_herbs", + mod_id = "visual_pink_herbs" + destination = os.path.join("Data", "patch-V.mpq") + temp_path = _download( "https://raw.githubusercontent.com/seacrabsam/patch-herb/main/patch-H.mpq", - os.path.join("Data", "patch-H.mpq"), + suffix=".mpq", progress=progress, label="Downloading Pink Herbs", - revision=VISUAL_MOD_REVISIONS["visual_pink_herbs"], + timeout=300, ) + try: + _verify_mpq(temp_path) + _migrate_legacy_pink_herbs_patch( + target_dir, + temp_path, + progress=progress, + ) + _emit_progress( + progress, + f"Installing {os.path.basename(destination)}...", + None, + None, + ) + _install_managed_files( + target_dir, + mod_id, + [(temp_path, destination)], + revision=VISUAL_MOD_REVISIONS[mod_id], + ) + finally: + try: + os.remove(temp_path) + except OSError: + pass + return "seacrabsam/patch-herb main" diff --git a/setup_tool.py b/setup_tool.py index 5174f68..4b14b31 100644 --- a/setup_tool.py +++ b/setup_tool.py @@ -129,7 +129,7 @@ class WowSetupTool: "pretty_night_sky": "Replaces the Vanilla night sky with a more detailed starry sky. Installed file: Data\\patch-Z.mpq. The original hosted patch-9 name is deliberately changed so the official numeric patch-9.mpq is never overwritten.", "epoch_water": "Replaces Vanilla water textures with the Epoch Water visual pack. Installed file: Data\\patch-W.mpq.", "fog_pushback": "Pushes environmental fog farther back for a clearer long-distance view. Installed file: Data\\patch-Y.mpq. Works best together with an increased Farclip value.", - "pink_herbs": "Turns most herb-node textures bright pink/purple to make gathering nodes easier to spot. Installed file: Data\\patch-H.mpq.", + "pink_herbs": "Turns most herb-node textures bright pink/purple to make gathering nodes easier to spot. Installed file: Data\\patch-V.mpq. The patch-V name avoids conflicts with other visual packs that use patch-H.mpq.", "no_error_sounds": "Installs the complete NoErrorSounds pack: muted spell fizzle sounds plus its included muted interface sounds. Installed as loose WAV files under Sound\\Spells\\Fizzle and Sound\\interface (no MPQ).", "fish_ping": "Replaces the fishing bite sound with a much more noticeable ping. Installed file: Sound\\Spells\\Tradeskills\\FishBite.wav (no MPQ). Designed specifically for WoW Vanilla 1.12.1.", "warlock_muted_demons": "Mutes the repeated voice lines from Warlock demons using Vanilla-compatible loose sound replacements. Installed as loose WAV files under Data\\Sound\\Creature (no MPQ)." diff --git a/tests/test_safety.py b/tests/test_safety.py index 9cc284d..f690c3c 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -279,6 +279,94 @@ class ManagedPackageTests(unittest.TestCase): ) ) + def test_pink_herbs_v2_migration_restores_original_patch_h(self): + with tempfile.TemporaryDirectory() as root, tempfile.TemporaryDirectory() as src: + mod_id = "visual_pink_herbs" + legacy_rel = os.path.join("Data", "patch-H.mpq") + new_rel = os.path.join("Data", "patch-V.mpq") + legacy_target = os.path.join(root, legacy_rel) + source = os.path.join(src, "pink-herbs.mpq") + + os.makedirs(os.path.dirname(legacy_target)) + with open(source, "wb") as handle: + handle.write(b"MPQ pink-herbs") + with open(legacy_target, "wb") as handle: + handle.write(b"MPQ pink-herbs") + + _, _, backup_root = remote_packages._managed_locations(root, mod_id) + backup_h = os.path.join(backup_root, legacy_rel) + os.makedirs(os.path.dirname(backup_h)) + with open(backup_h, "wb") as handle: + handle.write(b"MPQ faithful-upscale") + + remote_packages._write_managed_manifest( + root, + mod_id, + [legacy_rel], + revision="1", + ) + + remote_packages._migrate_legacy_pink_herbs_patch(root, source) + remote_packages._install_managed_files( + root, + mod_id, + [(source, new_rel)], + revision="2", + ) + + with open(legacy_target, "rb") as handle: + self.assertEqual(handle.read(), b"MPQ faithful-upscale") + with open(os.path.join(root, new_rel), "rb") as handle: + self.assertEqual(handle.read(), b"MPQ pink-herbs") + + manifest = remote_packages._load_managed_manifest_data(root, mod_id) + self.assertEqual(manifest.get("files"), ["Data/patch-V.mpq"]) + self.assertEqual(manifest.get("revision"), "2") + + def test_pink_herbs_v2_migration_preserves_replaced_patch_h(self): + with tempfile.TemporaryDirectory() as root, tempfile.TemporaryDirectory() as src: + mod_id = "visual_pink_herbs" + legacy_rel = os.path.join("Data", "patch-H.mpq") + new_rel = os.path.join("Data", "patch-V.mpq") + legacy_target = os.path.join(root, legacy_rel) + source = os.path.join(src, "pink-herbs.mpq") + + os.makedirs(os.path.dirname(legacy_target)) + with open(source, "wb") as handle: + handle.write(b"MPQ pink-herbs") + with open(legacy_target, "wb") as handle: + handle.write(b"MPQ replacement-patch-h") + + _, _, backup_root = remote_packages._managed_locations(root, mod_id) + backup_h = os.path.join(backup_root, legacy_rel) + os.makedirs(os.path.dirname(backup_h)) + with open(backup_h, "wb") as handle: + handle.write(b"MPQ older-original-patch-h") + + remote_packages._write_managed_manifest( + root, + mod_id, + [legacy_rel], + revision="1", + ) + + remote_packages._migrate_legacy_pink_herbs_patch(root, source) + remote_packages._install_managed_files( + root, + mod_id, + [(source, new_rel)], + revision="2", + ) + + with open(legacy_target, "rb") as handle: + self.assertEqual(handle.read(), b"MPQ replacement-patch-h") + with open(os.path.join(root, new_rel), "rb") as handle: + self.assertEqual(handle.read(), b"MPQ pink-herbs") + + manifest = remote_packages._load_managed_manifest_data(root, mod_id) + self.assertEqual(manifest.get("files"), ["Data/patch-V.mpq"]) + self.assertEqual(manifest.get("revision"), "2") + def test_transactional_sound_pack_rolls_back_all_files_and_manifest(self): with tempfile.TemporaryDirectory() as root, tempfile.TemporaryDirectory() as src: mod_id = "audio_test"