User:Sairon/Python example for working with .lsx: Difference between revisions

From bg3.wiki
Jump to navigation Jump to search
(Python script example for making sweeping changes to the progressions)
 
 
(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>