def edit_gameinfo(self, add_line=False) -> None: """Modify all gameinfo.txt files to add or remove our line. Add_line determines if we are adding or removing it. """ for folder in self.dlc_priority(): info_path = os.path.join(self.root, folder, 'gameinfo.txt') if os.path.isfile(info_path): with open(info_path, encoding='utf8') as file: data = list(file) for line_num, line in reversed(list(enumerate(data))): clean_line = srctools.clean_line(line) if add_line: if clean_line == GAMEINFO_LINE: break # Already added! elif '|gameinfo_path|' in clean_line: LOGGER.debug( "Adding gameinfo hook to {}", info_path, ) # Match the line's indentation data.insert( line_num + 1, utils.get_indent(line) + GAMEINFO_LINE + '\n', ) break else: if clean_line == GAMEINFO_LINE: LOGGER.debug("Removing gameinfo hook from {}", info_path) data.pop(line_num) break else: if add_line: LOGGER.warning( 'Failed editing "{}" to add our special folder!', info_path, ) continue with srctools.AtomicWriter(info_path) as file: for line in data: file.write(line) if not add_line: # Restore the original files! for name, file, ext in FILES_TO_BACKUP: item_path = self.abs_path(file + ext) backup_path = self.abs_path(file + '_original' + ext) old_version = self.abs_path(file + '_styles' + ext) if os.path.isfile(old_version): LOGGER.info('Restoring Stylechanger version of "{}"!', name) shutil.copy(old_version, item_path) elif os.path.isfile(backup_path): LOGGER.info('Restoring original "{}"!', name) shutil.move(backup_path, item_path) self.clear_cache()
def __init__(self, filename, root='../config', auto_load=True): """Initialise the config file. filename is the name of the config file, in the 'root' directory. If auto_load is true, this file will immediately be read and parsed. """ super().__init__() self.filename = os.path.join(root, filename) self.writer = srctools.AtomicWriter(self.filename) self.has_changed = False if auto_load: self.load()
def edit_fgd(self, add_lines: bool = False) -> None: """Add our FGD files to the game folder. This is necessary so that VBSP offsets the entities properly, if they're in instances. Add_line determines if we are adding or removing it. """ # We do this in binary to ensure non-ASCII characters pass though # untouched. fgd_path = self.abs_path('bin/portal2.fgd') try: with open(fgd_path, 'rb') as file: data = file.readlines() except FileNotFoundError: LOGGER.warning('No FGD file? ("{}")', fgd_path) return for i, line in enumerate(data): match = re.match( br'// BEE\W*2 EDIT FLAG\W*=\W*([01])', line, re.IGNORECASE, ) if match: if match.group(1) == b'0': LOGGER.info('FGD editing disabled by file.') return # User specifically disabled us. # Delete all data after this line. del data[i:] break with srctools.AtomicWriter(fgd_path, is_bytes=True) as file: for line in data: file.write(line) if add_lines: file.write( b'// BEE 2 EDIT FLAG = 1 \n' b'// Added automatically by BEE2. Set above to "0" to ' b'allow editing below text without being overwritten.\n' b'\n\n') with utils.install_path('BEE2.fgd').open('rb') as bee2_fgd: shutil.copyfileobj(bee2_fgd, file) file.write(imp_res_read_binary(srctools, 'srctools.fgd'))
def add_editor_sounds( self, sounds: Iterable[packages.EditorSound], ) -> None: """Add soundscript items so they can be used in the editor.""" # PeTI only loads game_sounds_editor, so we must modify that. # First find the highest-priority file for folder in self.dlc_priority(): file = self.abs_path( os.path.join(folder, 'scripts', 'game_sounds_editor.txt')) if os.path.isfile(file): break # We found it else: # Assume it's in dlc2 file = self.abs_path( os.path.join( 'portal2_dlc2', 'scripts', 'game_sounds_editor.txt', )) try: with open(file, encoding='utf8') as f: file_data = list(f) except FileNotFoundError: # If the file doesn't exist, we'll just write our stuff in. file_data = [] for i, line in enumerate(file_data): if line.strip() == EDITOR_SOUND_LINE: # Delete our marker line and everything after it del file_data[i:] break # Then add our stuff! with srctools.AtomicWriter(file) as f: f.writelines(file_data) f.write(EDITOR_SOUND_LINE + '\n') for sound in sounds: for line in sound.data.export(): f.write(line) f.write('\n') # Add a little spacing
def export( self, style: packageLoader.Style, selected_objects: dict, should_refresh=False, ) -> Tuple[bool, bool]: """Generate the editoritems.txt and vbsp_config. - If no backup is present, the original editoritems is backed up. - For each object type, run its .export() function with the given - item. - Styles are a special case. """ LOGGER.info('-' * 20) LOGGER.info('Exporting Items and Style for "{}"!', self.name) LOGGER.info('Style = {}', style.id) for obj, selected in selected_objects.items(): # Skip lists and dicts etc - too long if selected is None or isinstance(selected, str): LOGGER.info('{} = {}', obj, selected) # VBSP, VRAD, editoritems export_screen.set_length('BACK', len(FILES_TO_BACKUP)) # files in compiler/ try: num_compiler_files = len(os.listdir('../compiler')) except FileNotFoundError: num_compiler_files = 0 if self.steamID == utils.STEAM_IDS['APERTURE TAG']: # Coop paint gun instance num_compiler_files += 1 if num_compiler_files == 0: LOGGER.warning('No compiler files!') export_screen.skip_stage('COMP') else: export_screen.set_length('COMP', num_compiler_files) LOGGER.info('Should refresh: {}', should_refresh) if should_refresh: # Check to ensure the cache needs to be copied over.. should_refresh = self.cache_invalid() if should_refresh: LOGGER.info("Cache invalid - copying..") else: LOGGER.info("Skipped copying cache!") # The items, plus editoritems, vbsp_config and the instance list. export_screen.set_length('EXP', len(packageLoader.OBJ_TYPES) + 3) # Do this before setting music and resources, # those can take time to compute. export_screen.show() export_screen.grab_set_global() # Stop interaction with other windows if should_refresh: # Count the files. export_screen.set_length( 'RES', sum(1 for file in res_system.walk_folder_repeat()), ) else: export_screen.skip_stage('RES') export_screen.skip_stage('MUS') # Make the folders we need to copy files to, if desired. os.makedirs(self.abs_path('bin/bee2/'), exist_ok=True) # Start off with the style's data. editoritems, vbsp_config = style.export() export_screen.step('EXP') vpk_success = True # Export each object type. for obj_name, obj_data in packageLoader.OBJ_TYPES.items(): if obj_name == 'Style': continue # Done above already LOGGER.info('Exporting "{}"', obj_name) selected = selected_objects.get(obj_name, None) try: obj_data.cls.export(packageLoader.ExportData( game=self, selected=selected, editoritems=editoritems, vbsp_conf=vbsp_config, selected_style=style, )) except packageLoader.NoVPKExport: # Raised by StyleVPK to indicate it failed to copy. vpk_success = False export_screen.step('EXP') vbsp_config.set_key( ('Options', 'BEE2_loc'), os.path.dirname(os.getcwd()) # Go up one dir to our actual location ) vbsp_config.set_key( ('Options', 'Game_ID'), self.steamID, ) # If there are multiple of these blocks, merge them together. # They will end up in this order. vbsp_config.merge_children( 'Textures', 'Fizzler', 'Options', 'StyleVars', 'Conditions', 'Voice', 'PackTriggers', ) for name, file, ext in FILES_TO_BACKUP: item_path = self.abs_path(file + ext) backup_path = self.abs_path(file + '_original' + ext) if os.path.isfile(item_path) and not os.path.isfile(backup_path): LOGGER.info('Backing up original {}!', name) shutil.copy(item_path, backup_path) export_screen.step('BACK') # Backup puzzles, if desired backup.auto_backup(selected_game, export_screen) # This is the connection "heart" and "error" models. # These have to come last, so we need to special case it. editoritems += style.editor.find_key("Renderables", []).copy() # Special-case: implement the UnlockDefault stlylevar here, # so all items are modified. if selected_objects['StyleVar']['UnlockDefault']: LOGGER.info('Unlocking Items!') for item in editoritems.find_all('Item'): # If the Unlock Default Items stylevar is enabled, we # want to force the corridors and obs room to be # deletable and copyable # Also add DESIRES_UP, so they place in the correct orientation if item['type', ''] in _UNLOCK_ITEMS: editor_section = item.find_key("Editor", []) editor_section['deletable'] = '1' editor_section['copyable'] = '1' editor_section['DesiredFacing'] = 'DESIRES_UP' LOGGER.info('Editing Gameinfo!') self.edit_gameinfo(True) LOGGER.info('Writing instance list!') with open(self.abs_path('bin/bee2/instances.cfg'), 'w', encoding='utf8') as inst_file: for line in self.build_instance_data(editoritems): inst_file.write(line) export_screen.step('EXP') # AtomicWriter writes to a temporary file, then renames in one step. # This ensures editoritems won't be half-written. LOGGER.info('Writing Editoritems!') with srctools.AtomicWriter(self.abs_path( 'portal2_dlc2/scripts/editoritems.txt')) as editor_file: for line in editoritems.export(): editor_file.write(line) export_screen.step('EXP') LOGGER.info('Writing VBSP Config!') os.makedirs(self.abs_path('bin/bee2/'), exist_ok=True) with open(self.abs_path('bin/bee2/vbsp_config.cfg'), 'w', encoding='utf8') as vbsp_file: for line in vbsp_config.export(): vbsp_file.write(line) export_screen.step('EXP') if num_compiler_files > 0: LOGGER.info('Copying Custom Compiler!') for file in os.listdir('../compiler'): src_path = os.path.join('../compiler', file) if not os.path.isfile(src_path): continue dest = self.abs_path('bin/' + file) LOGGER.info('\t* compiler/{0} -> bin/{0}', file) try: if os.path.isfile(dest): # First try and give ourselves write-permission, # if it's set read-only. utils.unset_readonly(dest) shutil.copy( src_path, self.abs_path('bin/') ) except PermissionError: # We might not have permissions, if the compiler is currently # running. export_screen.grab_release() export_screen.reset() messagebox.showerror( title=_('BEE2 - Export Failed!'), message=_('Copying compiler file {file} failed.' 'Ensure the {game} is not running.').format( file=file, game=self.name, ), master=TK_ROOT, ) return False, vpk_success export_screen.step('COMP') if should_refresh: LOGGER.info('Copying Resources!') self.refresh_cache() self.copy_mod_music() if self.steamID == utils.STEAM_IDS['APERTURE TAG']: os.makedirs(self.abs_path('sdk_content/maps/instances/bee2/'), exist_ok=True) with open(self.abs_path('sdk_content/maps/instances/bee2/tag_coop_gun.vmf'), 'w') as f: TAG_COOP_INST_VMF.export(f) export_screen.grab_release() export_screen.reset() # Hide loading screen, we're done return True, vpk_success
def export( self, style: packages.Style, selected_objects: dict, should_refresh=False, ) -> Tuple[bool, bool]: """Generate the editoritems.txt and vbsp_config. - If no backup is present, the original editoritems is backed up. - For each object type, run its .export() function with the given - item. - Styles are a special case. """ LOGGER.info('-' * 20) LOGGER.info('Exporting Items and Style for "{}"!', self.name) LOGGER.info('Style = {}', style.id) for obj, selected in selected_objects.items(): # Skip the massive dict in items if obj == 'Item': selected = selected[0] LOGGER.info('{} = {}', obj, selected) # VBSP, VRAD, editoritems export_screen.set_length('BACK', len(FILES_TO_BACKUP)) # files in compiler/ try: num_compiler_files = sum( 1 for file in utils.install_path('compiler').rglob('*')) except FileNotFoundError: num_compiler_files = 0 if self.steamID == utils.STEAM_IDS['APERTURE TAG']: # Coop paint gun instance num_compiler_files += 1 if num_compiler_files == 0: LOGGER.warning('No compiler files!') export_screen.skip_stage('COMP') else: export_screen.set_length('COMP', num_compiler_files) LOGGER.info('Should refresh: {}', should_refresh) if should_refresh: # Check to ensure the cache needs to be copied over.. should_refresh = self.cache_invalid() if should_refresh: LOGGER.info("Cache invalid - copying..") else: LOGGER.info("Skipped copying cache!") # Each object type # Editoritems # VBSP_config # Instance list # Editor models. # FGD file # Gameinfo export_screen.set_length('EXP', len(packages.OBJ_TYPES) + 6) # Do this before setting music and resources, # those can take time to compute. export_screen.show() try: if should_refresh: # Count the files. export_screen.set_length( 'RES', sum(1 for file in res_system.walk_folder_repeat()), ) else: export_screen.skip_stage('RES') export_screen.skip_stage('MUS') # Make the folders we need to copy files to, if desired. os.makedirs(self.abs_path('bin/bee2/'), exist_ok=True) # Start off with the style's data. vbsp_config = Property(None, []) vbsp_config += style.config.copy() all_items = style.items.copy() renderables = style.renderables.copy() export_screen.step('EXP') vpk_success = True # Export each object type. for obj_name, obj_data in packages.OBJ_TYPES.items(): if obj_name == 'Style': continue # Done above already LOGGER.info('Exporting "{}"', obj_name) selected = selected_objects.get(obj_name, None) try: obj_data.cls.export( packages.ExportData( game=self, selected=selected, all_items=all_items, renderables=renderables, vbsp_conf=vbsp_config, selected_style=style, )) except packages.NoVPKExport: # Raised by StyleVPK to indicate it failed to copy. vpk_success = False export_screen.step('EXP') vbsp_config.set_key(('Options', 'Game_ID'), self.steamID) vbsp_config.set_key( ('Options', 'dev_mode'), srctools.bool_as_int(optionWindow.DEV_MODE.get())) # If there are multiple of these blocks, merge them together. # They will end up in this order. vbsp_config.merge_children( 'Textures', 'Fizzlers', 'Options', 'StyleVars', 'DropperItems', 'Conditions', 'Quotes', 'PackTriggers', ) for name, file, ext in FILES_TO_BACKUP: item_path = self.abs_path(file + ext) backup_path = self.abs_path(file + '_original' + ext) if not os.path.isfile(item_path): # We can't backup at all. should_backup = False elif name == 'Editoritems': should_backup = not os.path.isfile(backup_path) else: # Always backup the non-_original file, it'd be newer. # But only if it's Valves - not our own. should_backup = should_backup_app(item_path) backup_is_good = should_backup_app(backup_path) LOGGER.info( '{}{}: normal={}, backup={}', file, ext, 'Valve' if should_backup else 'BEE2', 'Valve' if backup_is_good else 'BEE2', ) if not should_backup and not backup_is_good: # It's a BEE2 application, we have a problem. # Both the real and backup are bad, we need to get a # new one. try: os.remove(backup_path) except FileNotFoundError: pass try: os.remove(item_path) except FileNotFoundError: pass export_screen.reset() if messagebox.askokcancel( title=_('BEE2 - Export Failed!'), message=_( 'Compiler file {file} missing. ' 'Exit Steam applications, then press OK ' 'to verify your game cache. You can then ' 'export again.').format(file=file + ext, ), master=TK_ROOT, ): webbrowser.open('steam://validate/' + str(self.steamID)) return False, vpk_success if should_backup: LOGGER.info('Backing up original {}!', name) shutil.copy(item_path, backup_path) export_screen.step('BACK') # Backup puzzles, if desired backup.auto_backup(selected_game, export_screen) # Special-case: implement the UnlockDefault stlylevar here, # so all items are modified. if selected_objects['StyleVar']['UnlockDefault']: LOGGER.info('Unlocking Items!') for i, item in enumerate(all_items): # If the Unlock Default Items stylevar is enabled, we # want to force the corridors and obs room to be # deletable and copyable # Also add DESIRES_UP, so they place in the correct orientation if item.id in _UNLOCK_ITEMS: all_items[i] = copy.copy(item) item.deletable = item.copiable = True item.facing = editoritems.DesiredFacing.UP LOGGER.info('Editing Gameinfo...') self.edit_gameinfo(True) export_screen.step('EXP') if not GEN_OPTS.get_bool('General', 'preserve_bee2_resource_dir'): LOGGER.info('Adding ents to FGD.') self.edit_fgd(True) export_screen.step('EXP') # AtomicWriter writes to a temporary file, then renames in one step. # This ensures editoritems won't be half-written. LOGGER.info('Writing Editoritems script...') with srctools.AtomicWriter( self.abs_path('portal2_dlc2/scripts/editoritems.txt') ) as editor_file: editoritems.Item.export(editor_file, all_items, renderables) export_screen.step('EXP') LOGGER.info('Writing Editoritems database...') with open(self.abs_path('bin/bee2/editor.bin'), 'wb') as inst_file: pick = pickletools.optimize(pickle.dumps(all_items)) inst_file.write(pick) export_screen.step('EXP') LOGGER.info('Writing VBSP Config!') os.makedirs(self.abs_path('bin/bee2/'), exist_ok=True) with open(self.abs_path('bin/bee2/vbsp_config.cfg'), 'w', encoding='utf8') as vbsp_file: for line in vbsp_config.export(): vbsp_file.write(line) export_screen.step('EXP') if num_compiler_files > 0: LOGGER.info('Copying Custom Compiler!') compiler_src = utils.install_path('compiler') for comp_file in compiler_src.rglob('*'): # Ignore folders. if comp_file.is_dir(): continue dest = self.abs_path('bin' / comp_file.relative_to(compiler_src)) LOGGER.info('\t* {} -> {}', comp_file, dest) folder = Path(dest).parent if not folder.exists(): folder.mkdir(parents=True, exist_ok=True) try: if os.path.isfile(dest): # First try and give ourselves write-permission, # if it's set read-only. utils.unset_readonly(dest) shutil.copy(comp_file, dest) except PermissionError: # We might not have permissions, if the compiler is currently # running. export_screen.reset() messagebox.showerror( title=_('BEE2 - Export Failed!'), message=_('Copying compiler file {file} failed. ' 'Ensure {game} is not running.').format( file=comp_file, game=self.name, ), master=TK_ROOT, ) return False, vpk_success export_screen.step('COMP') if should_refresh: LOGGER.info('Copying Resources!') music_files = self.copy_mod_music() self.refresh_cache(music_files) LOGGER.info('Optimizing editor models...') self.clean_editor_models(all_items) export_screen.step('EXP') self.generate_fizzler_sides(vbsp_config) if self.steamID == utils.STEAM_IDS['APERTURE TAG']: os.makedirs(self.abs_path('sdk_content/maps/instances/bee2/'), exist_ok=True) with open( self.abs_path( 'sdk_content/maps/instances/bee2/tag_coop_gun.vmf' ), 'w') as f: TAG_COOP_INST_VMF.export(f) export_screen.reset() # Hide loading screen, we're done return True, vpk_success except loadScreen.Cancelled: return False, False