User:Sairon/Python example for working with .lsx

From Baldur's Gate 3 Wiki
Jump to navigation Jump to search
   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>