def Tweak_Escorts(empty_diffs = 0): ''' Adjust escort scripts to fire less often. Test result: negligible difference (5% at most, probably noise). ''' game_file = Load_File('aiscripts/order.fight.escort.xml') xml_root = game_file.Get_Root() ''' These scripts run far more often than anything else, specifically hitting a 500 ms wait then doing a move_to_position, even when out of visibility. Can try increasing the wait to eg. 5s. Results: - New game, 0.4x sector size, 3x job ships, flying 300 km above trinity sanctum in warrior start ship, pointing toward the sector center. - Driver set to adaptive power, adaptive vsync (60 fps cap). - Game restart between tests, other major programs shut down (no firefox). - With change: 37.3 fps, escort wait+move at 20% of aiscript hits. - Without change: 41.3 fps, escort wait_move at 40% of aiscript hits. - Retest change: 42.8 fps - Retest no change: 40.8 fps - Retest change: 43.1 Around a 2 fps boost, or 5%. ''' if not empty_diffs: wait_node = xml_root.find('./attention[@min="unknown"]/actions/wait[@exact="500ms"]') wait_node.set('exact', '5s') game_file.Update_Root(xml_root) return
def Adjust_Ware_Price_Spread( # Allow multipliers to be given as a loose list of args. *match_rule_multipliers ): ''' Adjusts ware min to max price spreads. This primarily impacts trading profit. Spread will be limited to ensure 10 credits from min to max, to avoid impairing AI decision making. * match_rule_multipliers: - Series of matching rules paired with the spread multipliers to use. ''' wares_file = Load_File('libraries/wares.xml') xml_root = wares_file.Get_Root() # Get wars paired with multipliers. for ware, multiplier in Gen_Wares_Matched_To_Args(xml_root, match_rule_multipliers): # Look up the existing spread. price_node = ware.find('./price') price_min = int(price_node.get('min')) price_avg = int(price_node.get('average')) price_max = int(price_node.get('max')) # If price is 0 or 1, just skip. if price_avg in [0,1]: continue # Can individually adjust the min and max separations from average. new_min = round(price_avg - (price_avg - price_min) * multiplier) new_max = round(price_avg + (price_max - price_avg) * multiplier) # Limit to a spread of 10 credits or more from min to max, # or 5 from average. if new_min > price_avg - 5: new_min = price_avg - 5 if new_max < price_avg + 5: new_max = price_avg + 5 # If min dropped to 0, bump it back to 1. if new_min <= 0: new_min = 1 # Adjust max to have the same spread from average. new_max = price_avg + (price_avg - new_min) # Put them back. price_node.set('min', str(int(new_min))) price_node.set('max', str(int(new_max))) wares_file.Update_Root(xml_root) return
def Remove_Highway_Blobs(): highway_file = Load_File('libraries/highwayconfigurations.xml') xml_root = highway_file.Get_Root() ''' Set the config for superhighways: <blockerconfiguration ref="super_hw_blocker_config" /> to <blockerconfiguration ref="empty_blocker_config" /> TODO: maybe play around with other highway properties, eg. the blur effect, any ad signs (are they here?), mass traffic. ''' superhighway_node = xml_root.find('./configurations/configuration[@id="defaultsuperhighwayconfiguration"]') blocker_node = superhighway_node.find('./blockerconfiguration') blocker_node.set('ref', 'empty_blocker_config') highway_file.Update_Root(xml_root)
def Decrease_Radar(empty_diffs = 0): ''' Reduce radar ranges, to reduce some compute load for npc ships. ''' ''' Test on 20k ships save above trinity sanctum. 5/4 : 35 fps (50km) 1/1 : 37 fps (40km, vanilla, fresh retest) 3/4 : 39 fps (30km, same as x3 triplex) 1/2 : 42 fps (20km, same as x3 duplex) 1/4 : 46 fps (10km, probably too short) TODO: small ships 20k, large ships 30k, very large 40k? ''' game_file = Load_File('libraries/defaults.xml') xml_root = game_file.Get_Root() for node in xml_root.xpath('.//radar'): range = node.get('range') if range: range = float(range) node.set('range', str(range * 2/4)) game_file.Update_Root(xml_root) return
def Adjust_Ware_Prices( # Allow multipliers to be given as a loose list of args. *match_rule_multipliers ): ''' Adjusts ware prices. This should be used with care when selecting production chain related wares. * match_rule_multipliers: - Series of matching rules paired with the spread multipliers to use. ''' wares_file = Load_File('libraries/wares.xml') xml_root = wares_file.Get_Root() # Get wars paired with multipliers. for ware, multiplier in Gen_Wares_Matched_To_Args(xml_root, match_rule_multipliers): # Adjust everything in the price subnode. price = ware.find('price') for name, value in price.items(): XML_Multiply_Int_Attribute(price, name, multiplier) wares_file.Update_Root(xml_root) return
def Clean_Dirty_Glass(): ''' Cleans the dirty glass on ship windows. ''' ''' It looks like the common element in window dirt is the material, eg.: '<connection name="Connection21" tags="part nocollision iklink detail_s " parent="anim_cockpit_frame"> ' ... ' <parts> ' <part name="detail_xl_glass_inside_dirt"> ' <lods> ' <lod index="0"> ' <materials> ' <material id="1" ref="p1effects.p1_window_trim_01"/> ' <connection name="Connection62" tags="part detail_s forceoutline noshadowcaster platformcollision "> ' ... ' <parts> ' <part name="fx_glass_inside"> ' <lods> ' <lod index="0"> ' <materials> ' <material id="1" ref="p1.cockpit_glass_inside_01"/> The "dirt" in the name is not consistent. "p1effects.p1_window_trim_01" may be consistent; it is common in the ships sampled. Or is the trim just the outside of the window? Unclear. Testing: p1effects.p1_window_trim_01 Haze removed from the edges of the window, around the model cockpit frame. p1.cockpit_glass_inside_01 Haze removed from most of the window. There is an awkward cuttoff high on the screen where haze reappears. May remove some collision? Can seem to walk into the window a bit. p1.cockpit_glass_outside_02 Haze removed from high part of the window, not main part. arcp1.cp1_modes No change noticed. If all haze removed, there is no collision for the window, and the player can fall out of the ship. Note: removing just the material node from the connection didn't have any effect; needed to remove the whole connection. Suggested to try replacing material with p1effects.p1_chair_ui. Maybe try dummy.transparent instead. Results: neither had any effect (even with a restart). In discussion with others, the material itself may be defined as part of the model, and only relies on the connection being present. Ultimately, solved below with edit to materials library instead. ''' #ship_macro_files = File_System.Get_All_Indexed_Files('macros','ship_*') #ship_files = File_System.Get_All_Indexed_Files('components','ship_*') # Test with the gorgon. #ship_files = [Load_File('assets/units/size_m/ship_par_m_frigate_01.xml')] #ship_files = [] #for game_file in ship_files: # xml_root = game_file.Get_Root() # # for mat_name in [ # #'p1effects.p1_window_trim_01', # #'p1.cockpit_glass_inside_01', # #'p1.cockpit_glass_outside_02', # #'arcp1.cp1_modes', # ]: # # results = xml_root.xpath( # ".//connection[parts/part/lods/lod/materials/material/@ref='{}']".format(mat_name)) # if not results: # continue # # for conn in results: # # mat = conn.find('./parts/part/lods/lod/materials/material') # # Try removing the mat link. # #mat.getparent().remove(mat) # # Try editing the mat link. # mat.set('ref', 'dummy.transparent') # # # Remove it from its parent. # #conn.getparent().remove(conn) # # # Commit changes right away; don't bother delaying for errors. # game_file.Update_Root(xml_root) ''' With advice from others looking at the model, there is a reference to the cockpit_glass_inside_02 material. Perhaps this can be handled by editing the materials.xml to play with that. ' <material name="cockpit_glass_inside_02" shader="p1_glass.fx" blendmode="ALPHA8_SINGLE" preview="none" priority="-1"> ' <properties> ' <!--property type="Color" name="diffuse_color" r="200" g="200" b="200" a="100" value="(color 1 1 2)" /--> ' <property type="BitMap" name="diffuse_map" value="assets\textures\fx\ships_cockpit_glass_inside_02_diff" /> ' <property type="BitMap" name="smooth_map" value="assets\textures\fx\ships_cockpit_glass_inside_02_spec" /> ' <property type="BitMap" name="specular_map" value="assets\textures\fx\ships_cockpit_glass_inside_02_spec" /> ' <property type="BitMap" name="normal_map" value="assets\textures\fx\ships_cockpit_glass_inside_02_normal" /> ' <property type="Float" name="normalStr" value="1.0" /> ' <property type="Float" name="environmentStr" value="0.1" /> ' <property type="Float" name="envi_lightStr" value="0.40" /> ' <property type="Float" name="smoothness" value="0.2" /> ' <property type="Float" name="metalness" value="0.0" /> ' <property type="Float" name="specularStr" value="0.1" /> ' </properties> ' </material> Note: in testing, mat lib diff patches only evaluate at game start, so need to restart between changes. (Test was to delete the p1 collection, turning the game purple; game stayed purple after undoing the diff patch deletion and reloading the save.) Tests: Removing material nodes Purple textures. Removing the bitmaps Generic white or grey coloring. Maybe try replacing it with the data from dummy.transparent? Textures replaced with the custom nuke icon spammed out around the trim, and central window is a stretched out and red-shifted copy. Why?? Try replacing with p1effects.p1_chair_ui. Get a random texture tiled out again, some purplish paranid looking keyboard like thing? Try adding/changing attributes to look for some way to add transparency. No noticed effects for properties tried. Remove just the normal bitmap. No noticed effect. Change all bitmaps to use assets\textures\fx\transparent_diff Success! Note: removing the outside glass texture means there is no apparent cockpit glass when viewed from outside. So try to leave the outside glass intact. ''' if 1: material_file = Load_File('libraries/material_library.xml') xml_root = material_file.Get_Root() #dummy_node = xml_root.find(".//material[@name='transparent']") #dummy_node = xml_root.find(".//material[@name='p1_chair_ui']") ## New properties to add. #propeties = [ # etree.fromstring('<property type="Float" name="diffuseStr" value="1.0" />'), # etree.fromstring('<property type="Float" name="color_dirtStr" value="0.0" />'), # etree.fromstring('<property type="Float" name="translucency" value="0.0" />'), # etree.fromstring('<property type="Float" name="trans_scale" value="0.0" />'), # etree.fromstring('<property type="Color" name="diffuse_color" r="200" g="200" b="200" a="0" value="(color 1 1 2)" />'), # ] for mat_name in [ 'cockpit_glass_inside_01', 'cockpit_glass_inside_02', #'cockpit_glass_outside_01', #'cockpit_glass_outside_02', 'p1_window_trim_01', # TODO: other trims? #'p1_window_trim_02', - exists, unclear which ships use it. # p1_window_trim_03 - Added in 3.10hf1b1, no mat entry yet? ]: mat_node = xml_root.xpath( ".//material[@name='{}']".format(mat_name)) assert len(mat_node) == 1 mat_node = mat_node[0] # Removing bitmaps - failure, saturated colors #for bitmap in mat_node.xpath("./properties/property[@type='BitMap']"): # bitmap.getparent().remove(bitmap) # Removing mat node - failure, purple error #mat_node.getparent().remove(mat_node) # Replacing with transparent node copy. # Note: deepcopy here doesn't traverse up to the parent. # - failure, grabs random textures to fill in. #new_node = deepcopy(dummy_node) #new_node.set('name', mat_node.get('name')) #mat_node.addnext(new_node) #mat_node.getparent().remove(mat_node) ## Try adding extra properties as children. #for property in propeties: # new_prop = deepcopy(property) # # If one already exists, delete the old one. # old_props = mat_node.xpath("./properties/property[@name='{}']".format(new_prop.get('name'))) # for old_prop in old_props: # old_prop.getparent().remove(old_prop) # # Append the new one. # mat_node.append(new_prop) # Remove the normal bitmap only. - no effect #for bitmap in mat_node.xpath("./properties/property[@name='normal_map']"): # bitmap.getparent().remove(bitmap) # Change all bitmaps to use assets\textures\fx\transparent_diff for bitmap in mat_node.xpath( "./properties/property[@type='BitMap']"): bitmap.set('value', r'assets\textures\fx\transparent_diff') material_file.Update_Root(xml_root) return
def Remove_Dock_Symbol(): ''' Removes the red dock symbol from small docks. ''' ''' These are present in the dockingbay_arg_s_01* files. These connect to the material: <material id="1" ref="p1effects.p1_holograph_dockingbay_noentry"/> This happens twice, once in a part named "fx_NE_base" (remove part), and once as a part named "fx_NE_dots" (can remove this whole connection, since it is a child of fx_NE_base). Can either delete the connection (was done originally), or modify the material. Go with the latter, as a better catch-all and safe against game version changes. See remove_dirty_glass for comments on this, but in short, test changes with game restarts, and swap bitmaps to be transparent. Test result: - Only the dots were removed; the solid part of the symbol remains. ''' if 0: # Material edits. Failed to fully remove the symbol. material_file = Load_File('libraries/material_library.xml') xml_root = material_file.Get_Root() for mat_name in [ 'p1_holograph_dockingbay_noentry', ]: mat_node = xml_root.xpath( ".//material[@name='{}']".format(mat_name)) assert len(mat_node) == 1 mat_node = mat_node[0] # Change all bitmaps to use assets\textures\fx\transparent_diff for bitmap in mat_node.xpath( "./properties/property[@type='BitMap']"): bitmap.set('value', r'assets\textures\fx\transparent_diff') material_file.Update_Root(xml_root) if 1: # Direct connection edits. dock_files = Load_Files('*dockingbay_arg_*.xml') for game_file in dock_files: xml_root = game_file.Get_Root() # Remove fx_NE_base part. results = xml_root.xpath(".//parts/part[@name='fx_NE_base']") if not results: continue for part in results: # Remove it from its parent. part.getparent().remove(part) # Remove fx_NE_dots parent connection. results = xml_root.xpath( ".//connection[parts/part/@name='fx_NE_dots']") for conn in results: # Remove it from its parent. conn.getparent().remove(conn) # Commit changes right away; don't bother delaying for errors. game_file.Update_Root(xml_root) # Encourage a better xpath match rule. game_file.Add_Forced_Xpath_Attributes( "name,parts/part/@name='fx_NE_dots'") return
def Adjust_Job_Count( # Allow job multipliers to be given as a loose list of args. *job_multipliers, ): ''' Adjusts job ship counts using a multiplier, affecting all quota fields. Input is a list of matching rules, determining which jobs get adjusted. Resulting non-integer job counts are rounded, with a minimum of 1 unless the multiplier or original count were 0. * job_multipliers - Tuples holding the matching rules and job count multipliers, ("key value", multiplier). - The "key" specifies the job field to look up, which will be checked for a match with "value". - If a job matches multiple rules, the first match is used. - Subordinates will never be matched except by an exact 'id' match, to avoid accidental job multiplication (eg. XL ship with a wing of L ships which have wings of M ships which have wings of S ships). - Supported keys: - 'id' : Name of the job entry; supports wildcards for non-wings. - 'faction' : The name of the faction. - 'tags' : One or more tags, space separated. - 'size' : The ship size suffix, 'xs','s','m','l', or 'xl'. - '*' : Matches all non-wing jobs; takes no value term. Examples: <code> Adjust_Job_Count(1.2) Adjust_Job_Count( ('id masstraffic*' , 0.5), ('tags military destroyer', 2 ), ('tags miner' , 1.5), ('size s' , 1.5), ('faction argon' , 1.2), ('*' , 1.1) ) </code> ''' # Put matching rules in standard form. rules = Standardize_Match_Rules(job_multipliers) jobs_game_file = Load_File('libraries/jobs.xml') xml_root = jobs_game_file.Get_Root() # Loop over the jobs. for job in xml_root.findall('./job'): quota = job.find('quota') # Check if this is a wing ship. # Could also check modifiers.subordinate. is_wing = quota.get('wing') != None # Look up the tags and a couple other properties of interest. job_id = job.get('id') # The category node may not be present. category = job.find('category') if category != None: faction = category.get('faction') size = category.get('size') # Parse the tags to separate them, removing # brackets and commas splitting. tags = [ x.strip(' []') for x in category.get('tags').split(',') if x ] else: faction = None size = None tags = [] # Always ignore the dummy_job, to be safe. if job_id == 'dummy_job': continue # Check the matching rules. multiplier = None for key, value, mult in rules: # Non-wing matches. if not is_wing: if ((key == '*') or (key == 'id' and fnmatch(job_id, value)) or (key == 'faction' and faction == value) # Check all tags, space separated. or (key == 'tags' and all(x in tags for x in value.split(' '))) # For sizes, add a 'ship_' prefix to the match_str. or (key == 'size' and size == ('ship_' + value))): multiplier = mult break # Restrictive wing matches. else: # Only support on a perfect id match. if key == 'id' and job_id == value: multiplier = mult break # Skip if no match. if multiplier == None: continue # Apply the multiplier to fields of the quota node. for name, value in quota.items(): XML_Multiply_Int_Attribute(quota, name, multiplier) jobs_game_file.Update_Root(xml_root) return
def Adjust_Mission_Rewards( # Allow multipliers to be given as a loose list of args. multiplier=1, adjust_credits=True, adjust_notoriety=True, ): ''' Adjusts generic mission credit and notoriety rewards by a flat multiplier. * multiplier - Float, value to adjust rewards by. * adjust_credits - Bool, if True (default) changes the credit reward. * adjust_notoriety - Bool, if True (default) changes the notoriety reward. ''' ''' The generic missions make use of LIB_Reward_Balancing, giving it a couple mission aspects (difficulty, complexity level) to get the reward credits and notoriety. To globally adjust payouts, just edit that LIB script. For credits: There are a few places to make the edit, but want to do it before the value gets rounded to the nearest 10. Can either edit an expression in an existing node, eg. the faction adjustment, or can insert a new instruction. Easiest is probably just to edit the rounding operation directly, since that is likely to be stable across version changes. For notoriety: There is no convenient rounding node, so edit the scaling node. That is more likely to change in patches, so do it carefully. ''' lib_reward_file = Load_File('md/LIB_Reward_Balancing.xml') xml_root = lib_reward_file.Get_Root() if adjust_credits: # Find the credit rounding instruction. credit_node = xml_root.findall( './/cue[@name="Reward_Money"]//set_value[@name="$Value"][@exact="(($Value)i / 10) * 10"]' ) # Ensure 1 match. assert len(credit_node) == 1 credit_node = credit_node[0] # Edit the operation to include an extra multiply. mult_str = Float_to_String(multiplier) credit_node.set('exact', '(($Value * {})i / 10) * 10'.format(mult_str)) if adjust_notoriety: # Find the notoriety scaling instruction. # Don't do a strict match on the operation; leave it flexible. notoriety_node = xml_root.findall( './/cue[@name="Reward_Notoriety"]//set_value[@name="$Value"]') # Ensure 1 match. assert len(notoriety_node) == 1 notoriety_node = notoriety_node[0] # Edit the min and max attributes to include an extra multiply. for attrib in ['min', 'max']: op = notoriety_node.get(attrib) op = op.replace('$Value', '($Value * {})'.format(mult_str)) notoriety_node.set(attrib, op) lib_reward_file.Update_Root(xml_root) return
def Scale_Sector_Size( scaling_factor, scaling_factor_2=None, transition_size_start=200000, transition_size_end=400000, # TODO: make a decision on if this is good or not. # Maybe helps with hatikvahs highway/asteroid overlap? recenter_sectors=False, precision_steps=10, remove_ring_highways=False, remove_nonring_highways=False, extra_scaling_for_removed_highways=0.7, scale_regions=True, move_free_ships=True, debug=True, _test=False): ''' Change the size of the maps by moving contents (zones, etc.) closer together or further apart. Note: this will require a new game to take effect, as positions become part of a save file. * scaling_factor - Float, how much to adjust distances by. - Eg. 0.5 to cut sector size roughly in half. * scaling_factor_2 - Float, optional, secondary scaling factor to apply to large sectors. - If not given, scaling_factor is used for all sectors. * transition_size_start - Int, sector size at which to start transitioning from scaling_factor to scaling_factor_2. - Defaults to 200000. - Sectors smaller than this will use scaling_factor. * transition_size_end - Int, optional, sector size at which to finish transitioning to scaling_factor_2. - Defaults to 400000 (400 km). - Sectors larger than this will use scaling_factor_2. - Sectors of intermediate size have their scaling factor interpolated. * recenter_sectors - Adjust objects in a sector to approximately place the coreposition near 0,0,0. - Defaults False. - In testing, this makes debugging harder, and may lead to unwanted results. Pending further testing to improve confidence. * num_steps - Int, over how many movement steps to perform the scaling. - Higher step counts take longer to process, but each movement is smaller and will better detect objects getting too close to each other. - Recommend lower step counts when testing, high step count for a final map. - Defaults to 10. * remove_ring_highways - Bool, set True to remove the ring highways. * remove_nonring_highways - Bool, set True to remove non-ring highways. * extra_scaling_for_removed_highways - Float, extra scaling factor to apply to sectors that had highways removed. - Defaults to 0.7. * scale_regions - Bool, if resource and debris regions should be scaled as well. - May be slightly off from sector scalings, since many regions are shared between sectors. - Defaults True. * move_free_ships - Bool, if ownerless ships spawned at game start should be moved along with the other sector contents. - May impact difficulty of finding these ships. - Defaults True. * debug - Bool, if True then write runtime state to the plugin log. ''' # Use a pattern to pick up the base and dlc sectors. # Store the game_file as key, xml root as value. # Below transforms exit the xml root, which gets committed at the end # if there were no errors. # Note: for quick testing, just grab the basic files, no wildcards. if _test: gamefile_roots = { 'sectors': [(x, x.Get_Root()) for x in [Load_File('maps/xu_ep2_universe/sectors.xml')]], 'zones': [(x, x.Get_Root()) for x in [Load_File('maps/xu_ep2_universe/zones.xml')]], 'zone_highways': [(x, x.Get_Root()) for x in [Load_File('maps/xu_ep2_universe/zonehighways.xml')]], 'clusters': [(x, x.Get_Root()) for x in [Load_File('maps/xu_ep2_universe/clusters.xml')]], 'sec_highways': [(x, x.Get_Root()) for x in [Load_File('maps/xu_ep2_universe/sechighways.xml')]], } else: gamefile_roots = { 'sectors': [(x, x.Get_Root()) for x in Load_Files('*maps/xu_ep2_universe/*sectors.xml')], 'zones': [(x, x.Get_Root()) for x in Load_Files('*maps/xu_ep2_universe/*zones.xml')], 'zone_highways': [(x, x.Get_Root()) for x in Load_Files('*maps/xu_ep2_universe/*zonehighways.xml')], 'clusters': [(x, x.Get_Root()) for x in Load_Files('*maps/xu_ep2_universe/*clusters.xml')], 'sec_highways': [(x, x.Get_Root()) for x in Load_Files('*maps/xu_ep2_universe/*sechighways.xml')], } gamefile_roots.update({ 'region_defs': [(x, x.Get_Root()) for x in [Load_File('libraries/region_definitions.xml')]], 'md_hq': [(x, x.Get_Root()) for x in [Load_File('md/X4Ep1_Mentor_Subscription.xml')]], 'md_stations': [(x, x.Get_Root()) for x in [Load_File('md/FactionLogic_Stations.xml')]], 'md_objects': [(x, x.Get_Root()) for x in [Load_File('md/PlacedObjects.xml')]], 'md_gs_split1': [(x, x.Get_Root()) for x in [Load_File('extensions/ego_dlc_split/md/gs_split1.xml')]], 'god': [(x, x.Get_Root()) for x in [Load_File('libraries/god.xml')]], 'gamestarts': [(x, x.Get_Root()) for x in [Load_File('libraries/gamestarts.xml')]], }) def Safe_Update_MD(xml_root, xpath, attr, old_text, new_text): 'Helper function for editing md nodes.' # Note: add some safety incase the lookups fail. nodes = xml_root.xpath(xpath) if not nodes: msg = 'Scale_Sector_Size failed to find a target MD script node; skipping this node.' Plugin_Log.Print(msg) Print(msg) else: nodes[0].set(attr, nodes[0].get(attr).replace(old_text, new_text)) # Tweak faction logic to spawn stations closer/further. faction_stations_file = Load_File('md/FactionLogic_Stations.xml') faction_stations_root = faction_stations_file.Get_Root() # TODO: what is the difference between sector.size and sector.coresize? Safe_Update_MD( faction_stations_root, ".//match_distance[@max='[$ChosenSector.coresize / 2.0f, 400km].min']", 'max', '400km', str(int(400 * scaling_factor))) Safe_Update_MD( faction_stations_root, ".//set_value[@exact='[$ChosenSector.size / 2.0f, 400km].min']", 'exact', '400km', str(int(400 * scaling_factor))) # FactionLogic.xml: # (Used in selecting existing sector; coreposition usage okay.) # <match_distance space="$Sector" value="$Sector.coreposition" max="[$Sector.coresize, 400km].min"/> faction_logic_file = Load_File('md/FactionLogic.xml') faction_logic_root = faction_logic_file.Get_Root() Safe_Update_MD(faction_logic_root, ".//match_distance[@max='[$Sector.coresize, 400km].min']", 'max', '400km', str(int(400 * scaling_factor))) # Adjust the deepspace search lib to have a shorter distance. lib_deepspace_file = Load_File('aiscripts/lib.find.point.indeepspace.xml') lib_deepspace_root = lib_deepspace_file.Get_Root() # The logic here checks for the furthest station position, then adds # a random 50-150km to it when picking a point. # All calls to this use the default params (50,150). # Can reduce the defaults. for name in ['mindistance', 'maxdistance']: param_node = lib_deepspace_root.find(f'./params/param[@name="{name}"]') # These should be 50km and 150km, but dynamically scale to be safe. value = param_node.get('default') if 'km' in value: value = float(value.replace('km', '')) * 1000 else: value = float(value) new_value = value * scaling_factor param_node.set('default', f'{new_value:.0f}') # Build station missions can select points pretty far out. Scale. # Of interest are a couple attributes of 300km. # (Be lazy here and assume unchanged.) gm_build_stations_file = Load_File('md/gm_buildstation.xml') gm_build_stations_root = gm_build_stations_file.Get_Root() for node in gm_build_stations_root.xpath( '//create_position[@max="300km"]'): node.set('max', '{:.0f}'.format(300000 * scaling_factor)) # The defeated split start has a tight oxygen timer (3.5 minutes); # boost it up for more slack in the rescaled time for the rescue # ship to arrive. md_gs_split1_root = gamefile_roots['md_gs_split1'][0][1] oxygen_node = md_gs_split1_root.find('.//set_spacesuit_oxygen') # Was 12%. Bump to 30 (~10 minutes). oxygen_node.set('percent', '30') # Load in data of interest, to the local data structure. galaxy = Galaxy(gamefile_roots, recenter_sectors=recenter_sectors, move_free_ships=move_free_ships) # TODO: record starting sector size for debug. # Set scaling factor per sector based on size. sector_scaling_factors = {} for sector in galaxy.class_macros['sectors'].values(): # Get raw size, with no minimum. size = sector.Get_Size(apply_minimum=False) if scaling_factor_2 == None or size < transition_size_start: sector_scaling_factors[sector] = scaling_factor elif size > transition_size_end: sector_scaling_factors[sector] = scaling_factor_2 else: # Linear interpolate. ratio = (size - transition_size_start) / (transition_size_end - transition_size_start) scaling = scaling_factor_2 * ratio + scaling_factor * (1 - ratio) sector_scaling_factors[sector] = scaling # Handle highway removal. for sector in galaxy.class_macros['sectors'].values(): # Two types of removal, but one flag to indicate removal happened. highways_removed = False if remove_ring_highways and sector.Has_Ring_Highway(): sector.Remove_Ring_Highways() highways_removed = True if remove_nonring_highways and sector.Has_Nonring_Highway(): sector.Remove_Nonring_Highways() highways_removed = True # Adjust the scaling. if highways_removed: sector_scaling_factors[ sector] *= extra_scaling_for_removed_highways # Run the repositioning routines. # TODO: region scaling factor? if scale_regions: Scale_Regions(galaxy, sector_scaling_factors, debug) Scale_Sectors(galaxy, sector_scaling_factors, debug, precision_steps=precision_steps) # Update the xml nodes. galaxy.Update_XML() # TODO: print sector size change summary. # If here, everything worked, so commit the updates. for file_roots in gamefile_roots.values(): # TODO: maybe skip clusters, as unmodified. for game_file, new_root in file_roots: game_file.Update_Root(new_root) faction_stations_file.Update_Root(faction_stations_root) faction_logic_file.Update_Root(faction_logic_root) lib_deepspace_file.Update_Root(lib_deepspace_root) gm_build_stations_file.Update_Root(gm_build_stations_root) return
def Fadein_Asteroids(empty_diffs=0): ''' Uniquify asteroid materials, and set fade-in rules to match when they first start drawing, for a smoother fade-in period. ''' ''' Asteroids are selected from their component files, often with multiple variations of the same size category (eg. large, medium, etc.). Size is defined in the component as part of the connection to a material. Different sizes asteroids often share the same material entry. Asteroid appearance rules are set by region_lodvalues.xml, which defines when the asteroid spawns and (slightly closer) when it starts drawing based on asteroid size. Asteroid material refs in the xml are dummies; the actual material is defined in the xmf binary, which needs a binary patch. Uniquified materials will use names of the same length, for easy patching. ''' # Gather all of the asteroid components. # TODO: maybe reuse the database stuff for this. for pattern in ['env_ast_*', 'asteroid_*']: # Load the files first. Get_All_Indexed_Files('components', pattern) # Filter for asteroids. asteroid_files = Get_Asset_Files_By_Class('components', 'asteroid') # Dict of (game file : xml root), for writeback later. game_file_xml_roots = {x: x.Get_Root() for x in asteroid_files} # Extract component xml nodes to work with, indexed by name. component_nodes = {} for xml_root in game_file_xml_roots.values(): # Loop over the components (probably just one). for component in xml_root.xpath('./component'): if component.get('class') != 'asteroid': continue ast_name = component.get('name') component_nodes[ast_name] = component # Match up asteroids with their materials used, prepping to uniquify. # Dict matching material names to asteroids using it. # Note: 168 asteroids use 19 materials. mat_asteroid_dict = defaultdict(list) for component in component_nodes.values(): # An asteroid may use more than one material. mats = [] for material in component.xpath('.//material'): mat_name = material.get('ref') # Ignore duplicates (fairly common). if mat_name not in mats: mats.append(mat_name) for mat in mats: mat_asteroid_dict[mat].append(component) # Handle the specs for when asteroids draw, by size. lodvalues_file = Load_File('libraries/region_lodvalues.xml') lodvalues_root = lodvalues_file.Get_Root() # Change the xxl distance; needs to be <200km (original 250km) for # lighting to work. Make even closer, based on zone vis distance. # Reduce the component spawning range on xxl asteroids, since it prevents # them from displaying when their zone isn't visible. # Closest observed pop-in is 42km, so 40km may be mostly safe, but # can also drop to 30km like other asteroids. # Update: asteroids seen popping in under ~28km. # Note: zone visibility range is 25km; can limit to that to be super safe. # TODO: maybe bump up zone visibility range to help out (small perf # impact). # TODO: consider asteroid size, as surface several km from center. # Note: 4.0 adds an extra entra for super large objects, comment indicates # it is for fog, rendering out to 5000km; leave that alone. for distance_node in lodvalues_root.xpath('./distances/distance'): # Ignore super big objects. if float(distance_node.get('minobjectsize')) > 5000: continue # Cap to lighting range. if float(distance_node.get('render')) > 200000: distance_node.set('render', '198000') distance_node.set('calculation', '200000') # Limit component distance. if float(distance_node.get('component')) > 25000: distance_node.set('component', '25000') # Read out the show distances and size cuttoffs. minsize_renderdists = {} for distance_node in lodvalues_root.xpath('./distances/distance'): min_size = float(distance_node.get('minobjectsize')) render_dist = float(distance_node.get('render')) minsize_renderdists[min_size] = render_dist # In region_lodvalues are also the parameters for the engine-calculated # transparency. Terms are for time-based fading when an asteroid spawns, # and distance based (based on square of distance according to xsd). # A custom distance transparency will be used below and in the shader, # so disable it here. Keep the time-based fade. # To remove, undefine the value (according to xsd). fade_node = lodvalues_root.xpath('./fading')[0] # Tinkering with range. # Default distance factor is 0.15. # Note: even when setting this high, the initial fade-in goes # fast enough to feel like a pop; this distance only affects the # gradual fading after the initial fast ~30%. # -Removed #fade_node.set('distance','0.8') if disable_ego_fade: del fade_node.attrib['distance'] # Pull up the materials file. material_file = Load_File('libraries/material_library.xml') material_root = material_file.Get_Root() # Gather materials for each asteroid, uniquifying as needed. asteroid_materials = defaultdict(list) # Names of all materials by collection, used to ensure uniqueness # of generated names. collection_names = defaultdict(set) # Note: loading of the xmf files can be slow if done individually for # each asteroid, due to pattern searches. # Do a single xmf lod pattern search here, organized by folder name. # Try to limit to expected folder names, else this is really slow. xmf_folder_files = defaultdict(list) for xmf_file in Load_Files('*/asteroids/*lod*.xmf'): folder = xmf_file.virtual_path.rsplit('/', 1)[0] xmf_folder_files[folder].append(xmf_file) for mat_ref, asteroids in mat_asteroid_dict.items(): # Break the mat_name into a collection and proper name, since # the components use <collection>.<mat> collection_name, mat_name = mat_ref.split('.', 1) # To do binary edits safely, ensure the new name is the same # length as the old. Adjusting just the last character isn't quite # enough in the worst case that needs >30, so swap the last two # characters for a number. numbers = '0123456789' suffixes = [] for char0 in numbers: for char1 in numbers: suffixes.append(char0 + char1) # If the collection isn't checked for names yet, check it now. if collection_name not in collection_names: for mat_node in material_root.xpath( f'./collection[@name="{collection_name}"]/material'): collection_names[collection_name].add(mat_node.get('name')) material = material_root.find( f'./collection[@name="{collection_name}"]/material[@name="{mat_name}"]' ) # Should always be found. assert material != None # If just one asteroid user, no duplication needed. if len(asteroids) == 1: asteroid_materials[asteroids[0]].append(material) continue # Otherwise, make duplicates for each asteroid. # (Don't bother reusing the original entry for now.) for i, asteroid in enumerate(asteroids): mat_copy = deepcopy(material) mat_copy.tail = None # Give a new, unique name. old_name = mat_copy.get('name') # Replace the last 2 characters until unique (probably first try # will work, except when it is the same as the existing last chars). while suffixes: char_pair = suffixes.pop(0) new_name = old_name[0:-2] + char_pair if new_name not in collection_names[collection_name]: collection_names[collection_name].add(new_name) break # Don't expect to ever run out of suffixes. assert suffixes # Ensure same length. assert len(old_name) == len(new_name) #print(f'copying {old_name} -> {new_name}') mat_copy.set('name', new_name) # Insert back into the library. material.addnext(mat_copy) # Screw around with messed up tails. if mat_copy.tail != None: material.tail = mat_copy.tail mat_copy.tail = None # Update the asteroid to use this material. asteroid_materials[asteroid].append(mat_copy) # Updating the xml doesn't actually matter in practice, since the # game reads from xmf lod values. But do it anyway. old_ref_name = collection_name + '.' + old_name new_ref_name = collection_name + '.' + new_name for material_ref in asteroid.xpath( f'.//material[@ref="{old_ref_name}"]'): material_ref.set('ref', new_ref_name) # Look up the xmf lod binaries. # These have the old ref name as a string packed with the binary. # The data is in a folder, defined in the component. # These use backslashes; convert to forward. geometry_folder = asteroid.find('./source').get( 'geometry').replace('\\', '/') # Lod files appear to have the name form <prefix>lod<#>.xmf. lod_files = xmf_folder_files[geometry_folder] assert lod_files for lod_file in lod_files: print(f'Binary patching {lod_file.virtual_path}') # Make a binary edit path, looking to replace the old name # with the new name. patch = Binary_Patch( file=lod_file.virtual_path, # Convert the strings to hex, each character becoming # two hex digits. These are null terminated in xmf. # (Prevents eg. ast_ore_01 also matching ast_ore_01_frac.) ref_code=String_To_Hex_String(old_ref_name) + '00', new_code=String_To_Hex_String(new_ref_name) + '00', expected_matches=[0, 1], ) # Try the patch; if successful it will tag the file # as modified. if not Apply_Binary_Patch(patch): # Some mismatch; just breakpoint to look at it. bla = 0 # Now with all materials being unique, check the asteroid sizes against # lodvalues, and update their material fade distances. for asteroid, materials in asteroid_materials.items(): # Determine the asteroid size, based on its max dimension. ast_size = 0 size_max_node = asteroid.find('.//size/max') for attr in ['x', 'y', 'z']: dim = size_max_node.get(attr) assert dim != None dim = float(dim) if dim > ast_size: ast_size = dim # Determine the render distance, based on largest matching rule. # Loop goes from largest to smallest; break on first match. render_dist = None for minsize, dist in sorted(minsize_renderdists.items(), reverse=True): if ast_size >= minsize: render_dist = dist break # Fade should end at render_dist, start somewhat closer. # How much closer is harder to define, but vanilla files are # often around 20%, and even a small amount would be enough to # offset pop-in. # Note: render_dist is for the asteroid center point when it shows # up, but camera distance is per-pixel and will be closer, so have # fade end a little sooner. Go with 1% for now. # Note: dither effect is more noticeable than proper alpha, do try # to lowball the fading period, and not use the full 20% except for # close stuff. fade_end = render_dist * 0.99 fade_start = render_dist * 0.80 # Cap the fade range. Assuming travel at 5km/s, 15km would still take # 3 seconds to fade in, so might be a decent cap. closest_start = fade_end - 15000 # If making use of egosofts fade, the dither can be done a bit faster, # allowing ego fade to take over afterwards. #if not disable_ego_fade: # fade_start = render_dist * 0.95 # closest_start = fade_end - 5000 fade_start = max(fade_start, closest_start) # Apply these to the material properties. for material in materials: for type, prop_name, value in [ ('Float', 'ast_camera_fade_range_start', f'{fade_start:.0f}'), ('Float', 'ast_camera_fade_range_stop', f'{fade_end:.0f}'), ]: # Check if there is already a matching property. property = material.find( f'./properties/property[@name="{prop_name}"]') # If not found, add it. if property == None: property = etree.Element('property', type=type, name=prop_name) properties = material.find('./properties') properties.append(property) assert property.tail == None # Set or update the value. property.set('value', value) # Collect from all materials the shaders used, and uniquify their # names. Patch_Shader_Files will create the new ones. shader_names = [] for materials in asteroid_materials.values(): for material in materials: mat_shader_name = material.get('shader') # Actual file names use ogl instead of fx extension. shader_name = mat_shader_name.replace('.fx', '.ogl') if shader_name not in shader_names: shader_names.append(shader_name) material.set('shader', mat_shader_name.replace('.fx', '_faded.fx')) # Send them over for updating. Patch_Shader_Files(shader_names) # Put back the modified materials and asteroids. material_file.Update_Root(material_root) lodvalues_file.Update_Root(lodvalues_root) for game_file, xml_root in game_file_xml_roots.items(): game_file.Update_Root(xml_root) return
def Update_Content_XML_Dependencies(): ''' Update the dependencies in the content xml file, based on which other extensions touched files modified by the current script. If applied to an existing content.xml (not one created here), existing dependencies are kept, and only customizer dependencies are updated. Note: an existing xml file may loose custom formatting. ''' # TODO: framework needs more development to handle cases with an # existing content.xml cleanly, since currently the output extension is # always ignored, and there is no particular method of dealing with # output-ext new files not having an extensions/... path. # Try to load a locally created content.xml. content_file = Load_File('content.xml', error_if_not_found = False) # If not found, then search for an existing content.xml on disk. if not content_file: # Manually load it. content_path = Settings.Get_Output_Folder() / 'content.xml' # Verify the file exists. if not content_path.exists(): Print('Error in Update_Content_XML_Dependencies: could not find an existing content.xml file') return content_file = File_System.Add_File( XML_File( # Plain file name as path, since this will write back to the # extension folder. virtual_path = 'content.xml', binary = content_path.read_bytes(), # Edit the existing file. edit_in_place = True, )) root = content_file.Get_Root() # Set the ID based on replacing spaces. this_id = Settings.extension_name.replace(' ','_') # Remove old dependencies from the customizer, and record others. existing_deps = [] for dep in root.xpath('./dependency'): if dep.get('from_customizer'): dep.getparent().remove(dep) else: existing_deps.append(dep.get('id')) # Add in dependencies to existing extensions. # These should be limited to only those extensions which sourced # any of the files which were modified. # TODO: future work can track specific node edits, and set dependencies # only where transform modified nodes might overlap with extension # modified nodes. # Dependencies use extension ids, so this will do name to id # translation. # Note: multiple dependencies may share the same ID if those extensions # have conflicting ids; don't worry about that here. source_extension_ids = set() for file_name, game_file in File_System.game_file_dict.items(): if not game_file.modified: continue for ext_name in game_file.source_extension_names: # Translate extension names to ids. ext_id = File_System.source_reader.extension_source_readers[ ext_name].extension_summary.ext_id source_extension_ids.add(ext_id) # Add the elements; keep alphabetical for easy reading. for ext_id in sorted(source_extension_ids): # Omit self, just in case; shouldn't come up, but might. if ext_id == this_id: Print('Error: output extension appears in its own dependencies,' ' indicating it transformed its own prior output.') continue # Skip if already defined. if ext_id in existing_deps: continue # Add it, with a tag to indicate it came from the customizer. # TODO: optional dependencies? root.append( ET.Element('dependency', id = ext_id, from_customizer = 'true' )) content_file.Update_Root(root) return
def Set_Default_Radar_Ranges(**class_distances): ''' Sets default radar ranges. Granularity is station, type of satellite, or per ship size. Ranges are in km, eg. 40 for vanilla game defaults of non-satellites. Note: ranges below 40km will affect when an unidentified object becomes identified, but objects will still show up out to 40km. Supported arguments: * ship_s * ship_m * ship_l * ship_xl * spacesuit * station * satellite * adv_satellite ''' ''' Test on 20k ships save above trinity sanctum, multiplying radar ranges: 5/4 : 35 fps (50km) 1/1 : 37 fps (40km, vanilla, fresh retest) 3/4 : 39 fps (30km, same as x3 triplex) 1/2 : 42 fps (20km, same as x3 duplex) 1/4 : 46 fps (10km, probably too short) Satellites: eq_arg_satellite_01_macro - 30k eq_arg_satellite_02_macro - 75k Ships: Defined in libraries/defaults.xml per ship class. Note: there is a basic radar node, and a max/radar node, which presumably is related to ship mods. Radars are normally 40k, maxing at 48k. ''' # Start with defaults. defaults_file = Load_File('libraries/defaults.xml') defaults_root = defaults_file.Get_Root() # Scale ranges to meters. for class_name, new_range in class_distances.items(): class_distances[class_name] = new_range * 1000 # Look for each specified class. This will skip satellites. for class_name, new_range in class_distances.items(): dataset = defaults_root.find(f"./dataset[@class='{class_name}']") if dataset == None: continue # Update range directly. radar_node = dataset.find('./properties/radar') if radar_node == None: continue radar_node.set('range', str(new_range)) # Check if there is a max range. max_radar_node = dataset.find('./properties/statistics/max/radar') if max_radar_node == None: continue # Set this at +20%, similar to vanilla. new_max = new_range * 1.2 max_radar_node.set('range', str(f'{new_max:.0f}')) defaults_file.Update_Root(defaults_root) # Now look for satellites. for class_name, new_range in class_distances.items(): if class_name == 'satellite': macro_name = 'eq_arg_satellite_01_macro' elif class_name == 'adv_satellite': macro_name = 'eq_arg_satellite_02_macro' else: continue # Load the game file and xml. sat_file = Load_File( f'assets/props/equipment/satelite/macros/{macro_name}.xml') sat_root = sat_file.Get_Root() radar_node = sat_root.find( f'./macro[@name="{macro_name}"]/properties/radar') radar_node.set('range', str(new_range)) sat_file.Update_Root(sat_root) return
def Decrease_Fog(empty_diffs = 0): ''' Reduce fog density to increase fps. ''' ''' Test in heart of acrymony fog cloud, cockpit hidden, which has baseline density of 1.0 using p1fogs.fog_04_alpha_dim material. 1.0: 8.5 fps 0.5: 15 0.3: 25 0.2: 51 0.1: 72 0.0: 95 Since 51 fps is decently playable, aim for an 80% reduction at 1.0 fog. To keep relative fog amounts sorted, all fogs will be scaled, but by a reduced % at lower base densities. Note: high yield ore/silicon region has 2.0 density. Idea 1: new_density = density * (1 - 0.8 * density) 1.0 -> 0.2 0.5 -> 0.3 Reject, doesn't maintain ordering. Idea 2: new_density = density * (1 - 0.8 * (density^0.25)) 1.0 -> 0.2 0.5 -> 0.16 0.2 -> 0.09 Stringer reduction below 0.1 than wanted. Idea 3: if density < 0.1: new_density = density else = (d-0.1) * (1 - 0.9 * ((d-0.1)^0.10)) + 0.1 Linear below 0.1 Pretty smooth 0.1 to 1.0 (goes up to ~0.2). Update: X4 4.0 changes to volumetric fog. While defined similarly, densityfactors are a bit odd, sometimes being the same as the old commented out fog entries, other times being 1/10th. Also, these new entries add a "multiplier" term of unclear intent. Highest observed vf densities are: 1x 1.0 (heart of acrimony), mult 0.05 1x 0.7, mult 1.0 1x 0.6, undef 2x 0.5, mult 0.15, undef 6x 0.3, mult 1.0, undef Note: xsd indicates undefined mult is 0, though could be wrong as it is unfinished for vf. TODO: revisit; fog perf in 4.0 is much improved, but maybe need to test with AA turned on. Note: discontinuing this mod for now. ''' game_file = Load_File('libraries/region_definitions.xml') xml_root = game_file.Get_Root() # Different scalings are possible. # This will try to somewhat preserve relative fog amounts. # Note: in X4 3.3, fog was part of a "positional" node with an # attribute "ref='fog_outside_...' term. In X4 4.0 this has changed # to be "volumetricfog" nodes. #for positional in xml_root.xpath('.//positional'): # # Skip non-fog # if not positional.get('ref').startswith('fog_outside_'): # continue for positional in xml_root.xpath('.//volumetricfog'): # Density is between 0 and 1. density = float(positional.get('densityfactor')) if density < 0.1: continue # Preserve the lower 0.1. d = density - 0.1 # Get the reduction factor; limit to 0.9. reduction = min(0.9, 0.9 * (d ** 0.10)) # Scale it. d = d * (1 - reduction) # Add 0.1 back in. new_density = d + 0.1 #print(f'{density:0.2f} -> {new_density:0.2f}') positional.set('densityfactor', f'{new_density:.4f}') # Testing a fix for second contact 2 fog cutting out. # Note: tests failed to help. TODO: move this off to a testing mod. if 0: # Try removing the minnoise from the region. second_contact_region = xml_root.find('./region[@name="region_cluster_13_sector_001"]') second_contact_region.set('minnoisevalue', '0') # The above doesnt prevent cutout. Try the falloff values. for step in second_contact_region.xpath('./falloff/lateral/step'): if step.get('value') == '0.0': step.set('value', '0.1') for step in second_contact_region.xpath('./falloff/radial/step'): if step.get('value') == '0.0': step.set('value', '0.1') game_file.Update_Root(xml_root) return
def Apply_Live_Editor_Patches(file_name=None): ''' This will apply all patches created by hand through the live editor in the GUI. This should be called no more than once per script, and currently should be called before any other transforms which might read the edited values. Pending support for running some transforms prior to hand edits. * file_name - Optional, alternate name of a json file holding the Live_Editor generated patches file. - Default uses the name in Settings. ''' # Make sure the live editor is up to date with patches. # TODO: think about how safe this is, or if it could overwrite # meaningful existing state. Live_Editor.Load_Patches(file_name) # TODO: fork the xml game files at this point, keeping a copy # of the pre-patch state, so that live editor pages loaded # after this point and properly display the xml version from # before the hand edits and later transforms. # This may need to be done wherever pre-edit transform testing # is handled. # Work through the patches. # To do a cleaner job loading/saving game files, categorize # the patches by virtual_path first. path_patches_dict = defaultdict(list) for patch in Live_Editor.Get_Patches(): path_patches_dict[patch.virtual_path].append(patch) for virtual_path, patch_list in path_patches_dict.items(): # Note: if patches get out of date, they may end up failing at # any of these steps. # Load the file. game_file = Load_File(virtual_path) if game_file == None: Plugin_Log.Print(('Warning: Apply_Live_Editor_Patches could' ' not find file "{}"').format(virtual_path)) continue # Modify it in one pass. root = game_file.Get_Root() for patch in patch_list: # Look up the edited node; assume just one xpath match. nodes = root.xpath(patch.xpath) if not nodes: Plugin_Log.Print(('Warning: Apply_Live_Editor_Patches could' ' not find node "{}" in file "{}"').format( patch.xpath, virtual_path)) continue node = nodes[0] # Either update or remove the attribute. # Assume it is safe to delete if the value is an empty string. if patch.value == '': if patch.attribute in node.keys(): node.attrib.pop(patch.attribute) else: node.set(patch.attribute, patch.value) # Put changes back. # TODO: maybe delay this until all patches get applied, putting # back before returning. game_file.Update_Root(root) return