User:Sairon/Python example for working with .lsx: Difference between revisions
Jump to navigation
Jump to search
(Python script example for making sweeping changes to the progressions) |
m (Taylan moved page Python example for working with .lsx to User:Sairon/Python example for working with .lsx without leaving a redirect) |
||
(3 intermediate revisions by one other user not shown) | |||
Line 5: | Line 5: | ||
import uuid | import uuid | ||
from pathlib import Path | 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 | # 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 ): | def run_xpath_group( xpath, progressions ): | ||
Line 13: | Line 13: | ||
ret[item.xpath('attribute[@id="UUID"]')[0].attrib['value']] = item | ret[item.xpath('attribute[@id="UUID"]')[0].attrib['value']] = item | ||
return ret.values() | return ret.values() | ||
# This is just an helper function used later for conditionally extending a list | # This is just an helper function used later for conditionally extending a list | ||
def or_add( target, add ): | def or_add( target, add ): | ||
target.extend(add) | target.extend(add) | ||
return add | return add | ||
# This sets up how we can interact with this script via the command line to actually get the work done | # 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') | arg_parser = argparse.ArgumentParser(description='Progressions manipulation for BG3') | ||
sub_parsers = arg_parser.add_subparsers( dest='command') | sub_parsers = arg_parser.add_subparsers( dest='command') | ||
run = sub_parsers.add_parser('run') | run = sub_parsers.add_parser('run') | ||
run.add_argument('roots', nargs="+") | run.add_argument('roots', nargs="+") | ||
run.add_argument('output_template') | run.add_argument('output_template') | ||
run.add_argument('output') | run.add_argument('output') | ||
parsed_args = arg_parser.parse_args() | parsed_args = arg_parser.parse_args() | ||
if parsed_args.command == 'run': | if parsed_args.command == 'run': | ||
# Given a path to where you've extracted the .pak files we'll collect all versions of Progressions.lsx | # Given a path to where you've extracted the .pak files we'll collect all versions of Progressions.lsx | ||
Line 38: | Line 38: | ||
base_classes = run_xpath_group( '//node[@id="ClassDescription" and not(attribute[@id="ParentGuid"])]',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) | 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 | 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) )+ ' ) ] ]' | 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 ) ] | nodes_base_classes = [ copy.deepcopy(x) for x in run_xpath_group( xp, progressions ) ] | ||
# Copy the last levels & change their level to 13, this | # 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) ] | 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: | for node in level_13s: | ||
Line 49: | Line 49: | ||
node.xpath('attribute[@id="UUID"]')[0].attrib['value'] = str(uuid.uuid4()) # The copies needs a new UUID | node.xpath('attribute[@id="UUID"]')[0].attrib['value'] = str(uuid.uuid4()) # The copies needs a new UUID | ||
nodes_base_classes += level_13s | nodes_base_classes += level_13s | ||
for progress in nodes_base_classes: # Give perk to all levels for base classes | 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 = progress.xpath('attribute[@id="AllowImprovement"]') or or_add( progress, [ etree.Element('attribute', {'id':'AllowImprovement', 'value' : 'true','type' : 'bool' }) ] ) | ||
allow_feat[0].attrib['value'] = 'true' | 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) )+ ' ) ] ]' | 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 ) ] | 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 | # 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]: | 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]: | ||
Line 64: | Line 64: | ||
cur_val = re.sub( rf"(ActionResource\({res},)(\d)", lambda x: x.group(1)+str(int(x.group(2))*3), cur_val ) | 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 | check_item.attrib['value'] = cur_val | ||
# Parse the xml template provided | # Parse the xml template provided | ||
output = etree.parse(parsed_args.output_template, etree.XMLParser(remove_blank_text=True)) | output = etree.parse(parsed_args.output_template, etree.XMLParser(remove_blank_text=True)) |
Latest revision as of 16:10, 9 March 2024
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>