More actions
from lxml import etree import copy import re import argparse import uuid from pathlib import Path # This function takes an xpath, which is a query language for xml structures, and tries to respect the load order to give you the most up to date result def run_xpath_group( xpath, progressions ): ret = {} # This is a dict such that we can use the UUID to get rid of the correct duplicates for query in ( x['tree'].xpath(xpath) for x in progressions ): for item in query: ret[item.xpath('attribute[@id="UUID"]')[0].attrib['value']] = item return ret.values() # This is just an helper function used later for conditionally extending a list def or_add( target, add ): target.extend(add) return add # This sets up how we can interact with this script via the command line to actually get the work done arg_parser = argparse.ArgumentParser(description='Progressions manipulation for BG3') sub_parsers = arg_parser.add_subparsers( dest='command') run = sub_parsers.add_parser('run') run.add_argument('roots', nargs="+") run.add_argument('output_template') run.add_argument('output') parsed_args = arg_parser.parse_args() if parsed_args.command == 'run': # Given a path to where you've extracted the .pak files we'll collect all versions of Progressions.lsx progressions = [ { 'path' : s, 'tree' : etree.parse(str(s), etree.XMLParser(remove_blank_text=True))} for sublist in parsed_args.roots for s in Path(sublist).rglob( 'Progressions.lsx' ) ] # Ditto for ClassDescriptions.lsx class_descriptions = [ { 'path' : s, 'tree' : etree.parse(str(s), etree.XMLParser(remove_blank_text=True))} for sublist in parsed_args.roots for s in Path(sublist).rglob( 'ClassDescriptions.lsx' ) ] # Now we run a query on all those class descriptions, base_classes = run_xpath_group( '//node[@id="ClassDescription" and not(attribute[@id="ParentGuid"])]',class_descriptions) sub_classes = run_xpath_group( '//node[@id="ClassDescription" and attribute[@id="ParentGuid"]]',class_descriptions) progressions.sort( key=lambda x: x['tree'].xpath('//version')[0].attrib['build'] ) # Sort list such as the versions with highest build number is last xp = '//node[@id="Progression" and attribute[@id="ProgressionType" and @value="0"] and attribute[@id="Name" and ( @value="MulticlassSpellSlots" or '+' or '.join( '@value="'+x+'"' for x in (x.xpath('attribute[@id="Name"]')[0].attrib['value'] for x in base_classes) )+ ' ) ] ]' nodes_base_classes = [ copy.deepcopy(x) for x in run_xpath_group( xp, progressions ) ] # Copy the last levels & change their level to 13, this acts a sort of sentinel to make sure that it's possible to level up even if you're already level 12 in a class & have increased the max level level_13s = [ copy.deepcopy(x) for x in filter( lambda x: x.xpath('attribute[@id="Level" and @value="12"]'), nodes_base_classes) ] for node in level_13s: # TODO: This actually keeps all the bonuses of getting a level 12 twice, but this level isn't meant to be taken & merely be there as a guard node.xpath('attribute[@id="Level"]')[0].attrib['value'] = '13' node.xpath('attribute[@id="UUID"]')[0].attrib['value'] = str(uuid.uuid4()) # The copies needs a new UUID nodes_base_classes += level_13s for progress in nodes_base_classes: # Give perk to all levels for base classes allow_feat = progress.xpath('attribute[@id="AllowImprovement"]') or or_add( progress, [ etree.Element('attribute', {'id':'AllowImprovement', 'value' : 'true','type' : 'bool' }) ] ) allow_feat[0].attrib['value'] = 'true' xp = '//node[@id="Progression" and attribute[@id="ProgressionType" and @value="1"] and attribute[@id="Name" and ( '+' or '.join( '@value="'+x+'"' for x in (x.xpath('attribute[@id="Name"]')[0].attrib['value'] for x in sub_classes) )+ ' ) ] ]' nodes_sub_classes = [ copy.deepcopy(x) for x in run_xpath_group( xp, progressions ) ] # Get every class & sub class level which has a Boosts child, which contains the point improvements for that level for check_item in [item for sublist in [ *(x.xpath('//attribute[@id="Boosts"]') for x in nodes_base_classes), *(x.xpath('//attribute[@id="Boosts"]') for x in nodes_sub_classes ) ] for item in sublist]: cur_val = check_item.attrib['value'] # We don't increase everything, this is the set of points which gets times 3 for res in ['SpellSlot', 'ChannelDivinity', 'SorceryPoint', 'ArcaneRecoveryPoint', 'WarlockSpellSlot', 'KiPoint', 'Rage']: cur_val = re.sub( rf"(ActionResource\({res},)(\d)", lambda x: x.group(1)+str(int(x.group(2))*3), cur_val ) check_item.attrib['value'] = cur_val # Parse the xml template provided output = etree.parse(parsed_args.output_template, etree.XMLParser(remove_blank_text=True)) output_children = output.xpath('//children')[0] output_children.extend( copy.deepcopy(x) for x in nodes_base_classes ) # Fill in the base classes output_children.extend( copy.deepcopy(x) for x in nodes_sub_classes ) # And then the sub classes with open( parsed_args.output, mode='wb') as o: o.write( etree.tostring(output, doctype='<?xml version="1.0" encoding="UTF-8"?>', pretty_print=True) ) # This is an example of how I run this script, the UnpackedData folder on D: is where I've extracted all the relevant .pak files from the game using the multi mod tool, the second parameter is just a template for the output, and the last is where to put the generated output # py .\mod_xml.py run D:\ExportTool-v1.18.2\UnpackedData output_template_progressions.xml .\FeatsPointsCarry\Public\FeatsPointsCarry\Progressions\Progressions.lsx
This is an example of the output_template_progressions.xml I use for populating with the results:
<?xml version="1.0" encoding="UTF-8"?> <save> <version major="4" minor="0" revision="10" build="400"/> <region id="Progressions"> <node id="root"> <children /> </node> </region> </save>