From 3107bbb97fbb3cdd6db7d4986488576878591b34 Mon Sep 17 00:00:00 2001 From: Andrew Opalach Date: Thu, 20 Nov 2025 18:38:35 -0500 Subject: Flesh out ModSlots Signed-off-by: Andrew Opalach --- ModSlots/ModSlots.py | 246 ++++++++++++++++++++++++++++++++------------------- 1 file changed, 155 insertions(+), 91 deletions(-) (limited to 'ModSlots/ModSlots.py') diff --git a/ModSlots/ModSlots.py b/ModSlots/ModSlots.py index 36e0778..78e7224 100755 --- a/ModSlots/ModSlots.py +++ b/ModSlots/ModSlots.py @@ -2,19 +2,29 @@ import sys sys.dont_write_bytecode = True +import os import os.path -import hashlib +import shutil +import re from enum import Enum +from hashlib import sha256 from zipfile import ZipFile -#from unrardll import extract, names -#from py7zr import SevenZipFile -from ArmorIDS import ArmorIDS -# @TODO: -# - Detach files (copy instead of symlink, MHWNewCamera.json) -# - Test example to normal and back -# - More automatic override for the simplest case -# - Support invisible weapons (default no-include) +try: + from unrardll import extract, names + RAR_SUPPORT = True +except ImportError: + RAR_SUPPORT = False + +try: + from py7zr import SevenZipFile + SEVEN_ZIP_SUPPORT = True +except ImportError: + SEVEN_ZIP_SUPPORT = False + +from ArmorIDs import ArmorIDs + +NATIVEPC_SUBDIRS = ('ec', 'hm', 'npc', 'pg', 'posteffect', 'sound', 'ui', 'village', 'accessory', 'em', 'light', 'otomo', 'photo', 'quest', 'system', 'unit_resource', 'common', 'event', 'ngword_list', 'pc', 'pl', 'shortcut', 'vfx', 'wp', 'plugins') USAGE = f'Usage: {sys.argv[0]} ' @@ -49,17 +59,19 @@ if not os.path.isdir(Game_Directory): print(f'Directory {Game_Directory} doesn\'t exist.') sys.exit(1) +if Mode == 'verify' and not os.path.isfile(Hash_Path): + print(f'Verification requested but previous hash not found at {Hash_Path}.') + sys.exit(1) + Error_Messages = [] Error_Index = 1 def Error(msg): global Error_Messages global Error_Index - msg = f'{Error_Index}: ' + msg + msg = f'{Error_Index}: {msg}' Error_Messages.append(msg) Error_Index += 1 - print(msg + '\n^^^^^^^^^^^^^^^^^^^^^^^') - -ArmorIDS_Inverted = { v: k for k, v in ArmorIDS.items() } + print(msg + '\n' + '^'*len(msg)) class ArchiveType(Enum): ZIP = 0 @@ -68,53 +80,47 @@ class ArchiveType(Enum): class Mod(): def __init__(self, path): - m = hashlib.sha256() + self.name = path.split('/')[-1] + m = sha256() m.update(open(path, 'rb').read()) self.hash = m.hexdigest() ext = path.split('.')[-1].lower() + self.files = [] if ext == 'zip': self.type = ArchiveType.ZIP self.obj = ZipFile(path, 'r') - self.files = [] for file in self.obj.infolist(): if file.filename[-1] == '/': continue self.files.append(file.filename) elif ext == 'rar': self.type = ArchiveType.RAR - pass elif ext == '7z': self.type = ArchiveType.SEVEN_ZIP - pass - self.toplevels = [] + self.obj = SevenZipFile(path, 'r') + for file in self.obj.list(): + if not file.is_directory: + self.files.append(file.filename) self.roots = [] self.ignored = [] self.overrides = {} self.map = {} - def substitute_override_name(self, name): + def sub_override_name(self, name, slinger): if name[0] == '[': name = name[1:-1] - if name in ArmorIDS_Inverted.keys(): - name = ArmorIDS_Inverted[name][2:] + if name in ArmorIDs.keys(): + name = ArmorIDs[name][1 if slinger else 0][2:] else: return None return name def get_id_from_subpath(self, sub): - for armor_id in ArmorIDS.keys(): - armor_id = armor_id[2:] - search = sub.find(armor_id) - if search >= 0: - return sub[search:search + 8] - return None - - def find_overrides_for_file(self, file): - for key in self.overrides.keys(): - search = file.find(key) - if search >= 0: - return self.overrides[key] - return None + if sub.startswith('nativePC/pl/f_equip/pl'): + return sub[22:30], False + elif sub.startswith('nativePC/wp/slg/slg'): + return sub[18:27], True + return None, None def file_sort_key(self, x): for i in range(0, len(self.roots)): @@ -122,57 +128,86 @@ class Mod(): return i return 0 - def do_map(self, All_Files): + # If file is within a defined root, truncate it's path up to nativePC/. + def maybe_strip_root(self, file): + for root in self.roots: + if file.startswith(root): + nativePC = file.find('nativePC') + if nativePC >= 0: + return file[nativePC:], root + return file, None + + def do_map(self, All_Files, Global_Overrides): + print(self.name) for file in sorted(self.files, key=self.file_sort_key): - ignore_this = False - for ig in self.ignored: - if file.startswith(ig): - ignore_this = True + base, root = self.maybe_strip_root(file) + has_overrides = False + for key in self.overrides.keys(): + if base.startswith(key): + has_overrides = True break - if ignore_this: + if (base.startswith(tuple(self.ignored)) or not base.startswith('nativePC')) and not has_overrides: continue - path_in_archive = file - # If file is within a defined root, truncate it's path up to nativePC/. - for root in self.roots: - if file.startswith(root): - nativePC = file.find('nativePC') - if nativePC >= 0: - file = file[nativePC:] - break + has_global_override = False + for key in Global_Overrides.values(): + if base.startswith(key[1]): + has_global_override = True + break targets = [] - if file in self.overrides.keys(): - targets.extend(self.overrides[file]) - elif not (file.startswith('nativePC') or file in self.toplevels): - # If file isn't within nativePC/ and is not explicitly included, ignore it. - continue - else: - overrides = self.find_overrides_for_file(file) - if not overrides: - targets.append(file) # Include file as-is. - else: - # Substitute armor names, like '[Anjanath]', with their IDs. + if base in self.overrides.keys() and len(self.overrides[base]) > 0: # Direct remaps. + for target in self.overrides[base]: + if len(target.split('/')[-1].split('.')) == 1: + print(f'Warning: Possible non-direct path as a direct override: {target}') + targets.append(target) + elif not has_global_override: + targets.append(base) + # Substitute armor names, like '[Anja]', with their IDs. + for i, target in enumerate(targets): + for key, overrides in self.overrides.items(): + if not target.startswith(key) or key == target: + continue for override in overrides: - target_id = self.get_id_from_subpath(file) + target_id, is_slinger = self.get_id_from_subpath(target) if not target_id: - Error(f'Invalid target: {file}.') + Error(f'Invalid target: {target}') continue - mapped_name = self.substitute_override_name(override) + mapped_name = self.sub_override_name(override, is_slinger) if not mapped_name: - Error(f'Invalid substitute: {override}.') + Error(f'Invalid substitute: {override}') continue - targets.append(file.replace(target_id, mapped_name)) - print(path_in_archive) + targets[i] = target.replace(target_id, mapped_name) + break + print(f' {file}') + # Apply global overrides. + for match, override in Global_Overrides.items(): + for target in targets: + if re.match(match, target): + override[0].map[override[1]].append(target) + targets.remove(target) + print(f' (Globally overwritten by) {override[0].name}:{override[1]}') + print(f' -> {target}') + All_Files.append((self, target, file)) + break + # Map targets. unique_targets = [] for target in targets: - # @TODO: Allow "important" mapping instead of relying on order? - if target in All_Files: - print(f' (Overwritten)') + overwritten = None + for other in All_Files: + if other[1] == target: + overwritten = other + break + if overwritten: + if overwritten[0] == self: + print(f' (Overwritten by) {overwritten[2]}') + else: + print(f' (Overwritten by) {overwritten[0].name}:{overwritten[2]}') else: unique_targets.append(target) - print(' -> ' + target) + print(f' -> {target}') if len(unique_targets) > 0: - All_Files.extend(unique_targets) - self.map[path_in_archive] = unique_targets + for target in unique_targets: + All_Files.append((self, target, file)) + self.map[file] = unique_targets def add_to_hash(self, m): output = f'{os.path.join(Extract_Directory, self.hash)}' @@ -180,6 +215,8 @@ class Mod(): os.mkdir(output) if self.type == ArchiveType.ZIP: self.obj.extractall(path=output) + elif self.type == ArchiveType.SEVEN_ZIP: + self.obj.extractall(path=output) for key in self.map: m.update(open(os.path.join(output, key), 'rb').read()) @@ -189,6 +226,9 @@ class Mod(): for key in self.map: targets = self.map[key] for target in targets: + copy = target[0] == '|'; + if copy: + target = target[1:] if target in old_list: old_list.remove(target) l.write(target + '\n') @@ -201,9 +241,15 @@ class Mod(): if os.path.islink(target): os.remove(target) elif os.path.isfile(target) or os.path.isdir(target): - Error(f'Not deleting non-symlink file: {target}.') + if not copy: + Error(f'Not replacing/removing non-symlink file: {target}') continue - os.symlink(f'{os.path.join(output, key)}', target) + file = os.path.join(output, key) + if copy: + print(f'{file}\n (detached) -> {target}') + shutil.copyfile(file, target) + else: + os.symlink(file, target) def delete(self): for key in self.map: @@ -213,11 +259,13 @@ class Mod(): if os.path.islink(target): os.remove(target) elif os.path.isfile(target) or os.path.isdir(target): - Error(f'Not deleting non-symlink file: {target}.') + Error(f'Not deleting non-symlink file: {target}') def is_armor_override(line): sp = line.split('/') - if len(sp) >= 3 and sp[2] == 'slg': + if len(sp[-1]) == 10 and sp[-1].startswith('pl'): + return True + if len(sp[-1]) == 11 and sp[-1].startswith('slg'): return True if sp[-1] in ['helm', 'body', 'arm', 'wst', 'leg']: return True @@ -226,10 +274,13 @@ def is_armor_override(line): Mods = [] Current_Mod = None Current_Override = None +Global_Overrides = {} with open(Slots, 'r') as f: for line in f.read().splitlines(): if len(line) == 0 or line[0] == '#': continue + if line == 'XXX': + break if line[0] == ';': if Current_Mod: Mods.append(Current_Mod) @@ -237,54 +288,67 @@ with open(Slots, 'r') as f: path = os.path.join(Mods_Directory, line[1:]) if os.path.isfile(path): Current_Mod = Mod(path) + Current_Override = None else: Error(f'File not found: {path}') continue cmd = '' - if line[0] == '*': + if line[0] == '&': cmd = 'root' elif line[0] == '<': cmd = 'ignore' + elif line[0] == '|': + cmd = 'detach' elif line[0] == ':': cmd = 'override' elif line[0] == '>': cmd = 'value' + elif line[0] == '*': + cmd = 'global' if cmd: line = line[1:] - if line.startswith('pl/'): - line = 'nativePC/' + line - if line.startswith('wp/'): + if line.startswith(NATIVEPC_SUBDIRS): line = 'nativePC/' + line if cmd == 'root' and Current_Mod: Current_Mod.roots.append(line) elif cmd == 'ignore' and Current_Mod: Current_Mod.ignored.append(line) + elif cmd == 'detach' and Current_Mod: + Current_Mod.ignored.append(line) + Current_Mod.map[line] = [f'|{line}'] elif cmd == 'override' and Current_Mod: if not (is_armor_override(line) or line in Current_Mod.files): Error(f'File not found: {line}') - Current_Mod = None - continue - Current_Override = line - Current_Mod.overrides[Current_Override] = [] + else: + Current_Override = line + Current_Mod.overrides[Current_Override] = [] + elif cmd == 'global' and Current_Mod and Current_Override: + Current_Mod.map[Current_Override] = [] + Global_Overrides[line] = (Current_Mod, Current_Override) elif cmd == 'value' and Current_Mod: if Current_Override: Current_Mod.overrides[Current_Override].append(line) - else: - Current_Mod.toplevels.append(line) + elif line[0] == '[': # Special case guess for armor mods. + for file in Current_Mod.files: + base, root = Current_Mod.maybe_strip_root(file) + if base.startswith('nativePC/pl/f_equip/pl') or base.startswith('nativePC/wp/slg/slg'): + override = '/'.join(base.split('/')[0:4]) + Current_Mod.overrides[override] = [line] if Current_Mod: Mods.append(Current_Mod) Current_Mod = None All_Files = [] -M = hashlib.sha256() +M = sha256() for mod in Mods: - mod.do_map(All_Files) + mod.do_map(All_Files, Global_Overrides) mod.add_to_hash(M) with open(List_Path, 'a+') as l: l.seek(0) old_list = l.read().splitlines() - l.truncate(0) + if Mode == 'write': + l.truncate(0) if Mode == 'write': for mod in Mods: @@ -300,14 +364,14 @@ if Mode == 'verify': if os.path.isfile(Hash_Path): with open(Hash_Path, 'r') as f: if f.read() == M.hexdigest(): - print('Verification passed') + print('Verification passed!') else: - print('Verification failed') + print('Verification failed.') elif Mode == 'delete': for mod in Mods: mod.delete() if len(Error_Messages) > 0: - print('-----------------------') + print('---------------------------') for err in Error_Messages: print(err) -- cgit v1.2.3-101-g0448