#!/usr/bin/python3 """ Converts CSL packages to the original XSB format for use in LiveTraffic (and probably XSquawkBox) For usage info call python3 CSL2XSB.py -h MIT License Copyright (c) 2019 B.Hoppe Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ from pathlib import Path, PurePath import argparse # handling command line arguments _currAircraft = '' # name of currently processed aircraft _warnings = 0 # number of warnings # dataRef replacement table: Very simple: # if finding the string on the left-hand side (non-libxplanemp dataRef) # then replace it with the string on the right-hand side (libxplanemp dataRef) _DR = { 'cjs/world_traffic/engine_rotation_angle1': 'libxplanemp/engines/engine_rotation_angle_deg1', 'cjs/world_traffic/engine_rotation_angle2': 'libxplanemp/engines/engine_rotation_angle_deg2', 'cjs/world_traffic/engine_rotation_angle3': 'libxplanemp/engines/engine_rotation_angle_deg3', 'cjs/world_traffic/engine_rotation_angle4': 'libxplanemp/engines/engine_rotation_angle_deg4', 'cjs/world_traffic/engine_rpm1': 'libxplanemp/engines/engine_rotation_speed_rpm1', 'cjs/world_traffic/engine_rpm2': 'libxplanemp/engines/engine_rotation_speed_rpm2', 'cjs/world_traffic/engine_rpm3': 'libxplanemp/engines/engine_rotation_speed_rpm3', 'cjs/world_traffic/engine_rpm4': 'libxplanemp/engines/engine_rotation_speed_rpm4', 'cjs/world_traffic/engine_rad_per_sec1': 'libxplanemp/engines/engine_rotation_speed_rad_sec1', 'cjs/world_traffic/engine_rad_per_sec2': 'libxplanemp/engines/engine_rotation_speed_rad_sec2', 'cjs/world_traffic/engine_rad_per_sec3': 'libxplanemp/engines/engine_rotation_speed_rad_sec3', 'cjs/world_traffic/engine_rad_per_sec4': 'libxplanemp/engines/engine_rotation_speed_rad_sec4', 'cjs/world_traffic/thrust_reverser_position': 'libxplanemp/engines/thrust_reverser_deploy_ratio', 'cjs/world_traffic/touch_down': 'libxplanemp/misc/touch_down', 'cjs/world_traffic/main_gear_deflection': 'libxplanemp/gear/tire_vertical_deflection_mtr', 'cjs/world_traffic/main_gear_wheel_angle': 'libxplanemp/gear/tire_rotation_angle_deg', 'cjs/world_traffic/nose_gear_deflection': 'libxplanemp/gear/nose_gear_deflection_mtr', 'cjs/world_traffic/nose_gear_wheel_angle': 'libxplanemp/gear/tire_rotation_angle_deg', 'cjs/world_traffic/nose_gear_steering_angle': 'libxplanemp/controls/nws_ratio', 'cjs/wolrd_traffic/landing_lights_on': 'libxplanemp/controls/landing_lites_on', } def OBJ8ReplaceDataRefs(line:str) -> str: """Replaces dataRefs 1. Replaces by replacement table, e.g. to replace world_traffic dataRefs with libxplanemp/PE dataRefs 2. If commanded, replaces root 'libxplanemp' by 'LT' (or whatever has been specified) """ global _warnings # replace dataRefs as per replacement table for old, new in _DR.items(): line = line.replace(old, new) # if requested replace libxplanemp with something else if args.replaceDR is not None: line = line.replace('libxplanemp/', args.replaceDR + '/') return line def UpdateOBJ8File(in_p:Path, out_p:Path, textureLivery:str = None, textureLit:str = None): """Updates the OBJ8 file: TEXTURE and dataRefs.""" global _warnings if args.verbose: print (' -- Writing ', out_p.name, ' (from ' + in_p.name + '):') assert(in_p.is_file()) in_f = None out_f = None try: # open in/out files in_f = in_p.open(mode='r',encoding='ascii',errors='replace') out_f = out_p.open(mode='w',encoding='ascii',errors='replace') # read all lines, copy most of them 1:1 to the output for line in in_f: # remove the newline char from the end line = line.rstrip('\n\r') origLine = line word = line.split() numWords = len(word) if numWords >= 1: # replace texture if textureLivery is not None and word[0] == 'TEXTURE': line = 'TEXTURE ' + textureLivery # replace LIT texture if textureLit is not None and word[0] == 'TEXTURE_LIT': line = 'TEXTURE_LIT ' + textureLit # dataRefs replacements (only if there is a chance for dataRefs in the line) if '/' in line: line = OBJ8ReplaceDataRefs(line) # write to output if args.verbose and line != origLine: print (' Written: ' + line + ' (instead of ' + origLine + ')') out_f.write(line+'\n') except IOError as e: parser.error('UpdateOBJ8File failed:\n' + e.filename + ':\n'+ e.strerror +'\n') finally: # cleanup if in_f is not None: in_f.close() if out_f is not None: out_f.close() def HandleXsbObj8Solid(path: Path, line: str) -> str: """Deals with the OBJ8 SOLID line. Example: OBJ8 SOLID YES MD80/MD80.obj AZA.png MD80_LIT.png Means: - Identify that it has additional texture info and, hence, requires treatment - Have the original OBJ8 file (MD80.obj) treated and MD80_AZA.obj created Returns the line for the new xsb_aircraft file, which would be: BJ8 SOLID YES MD80/MD80_AZA.obj or None if no change occurred. """ global _warnings # --- split the line into its parameters (after removing any line ending) --- word = line.split() numWords = len(word) if numWords < 4: # too few parameters! print (' ERROR - Too few parameters, skipped: ' + line) return None # the object file's name (replace colons [X-CSL comes this way...?] with forward slash) object_in_p = PurePath(word[3].replace(':','/')) package_name = object_in_p.parts[0] # the first part of the Object path just refers to the arbitrary CSL EXPORT_NAME, # strip it to get the file name of the current OBJ8 file: obj8_in_file_p = path / object_in_p.relative_to(package_name) # --- No additional parameters for livery textures? # Example: OBJ8 SOLID YES MD80/MD80.obj if numWords == 4: if args.noupdate: # and no update of OBJ8? if args.verbose: print (' No change to: ' + line) return None # return with no change # so we rename the existing file to ...orig obj8_out_file_p = obj8_in_file_p obj8_in_file_p = obj8_in_file_p.with_name(obj8_in_file_p.name + '.orig') if not obj8_in_file_p.is_file(): # ...if it isn't there already if not obj8_out_file_p.is_file(): print (' ERROR - ' + _currAircraft + ': Cannot access OBJ8 file, skipped: ' + str(obj8_out_file_p)) return None obj8_out_file_p.rename(obj8_in_file_p) if args.verbose: print (' Renamed', obj8_out_file_p,'-->',obj8_in_file_p.name) # Update the OBJ8 file UpdateOBJ8File(obj8_in_file_p, obj8_out_file_p) # but no change to the xsb_aircraft line return line # --- Normal case: additional texture parameters on the line, # so we need to create a new OBJ8 file else: # original OBJ8 file must be accessible if not obj8_in_file_p.is_file(): print (' ERROR - ' + _currAircraft + ': Cannot access OBJ8 file, skipped: ' + str(obj8_in_file_p)) return None # livery texture and Lit texture (relative to the in-obj8-file) textureLivery_p = obj8_in_file_p.parent / word[4] if not textureLivery_p.is_file(): if textureLivery_p.with_suffix('.png').is_file(): # X-CSL sometimes has wrong suffix in xsb_aircraft.txt, i.e. couldn't have worked, fix it, too. print (' WARNING - {}: Could not find texture file {}, but found and used {}'.format(_currAircraft, textureLivery_p, textureLivery_p.with_suffix('.png').name)) textureLivery_p = textureLivery_p.with_suffix('.png') if textureLivery_p.with_suffix('.dds').is_file(): print (' WARNING - {}: Could not find texture file {}, but found and used {}'.format(_currAircraft, textureLivery_p, textureLivery_p.with_suffix('.dds').name)) textureLivery_p = textureLivery_p.with_suffix('.dds') else: print (' WARNING - '+_currAircraft+': Cannot find texture file, continue anyway: ', textureLivery_p) _warnings += 1 # also Lit texture defined? (relative to the in-obj8-file) textureLit_p = None if numWords >= 6: textureLit_p = obj8_in_file_p.parent / word[5] if not textureLit_p.is_file(): if textureLit_p.with_suffix('.png').is_file(): print (' WARNING - {}: Could not find lit texture file {}, but found and used {}'.format(_currAircraft, textureLit_p, textureLit_p.with_suffix('.png').name)) textureLit_p = textureLit_p.with_suffix('.png') elif textureLit_p.with_suffix('.dds').is_file(): print (' WARNING - {}: Could not find lit texture file {}, but found and used {}'.format(_currAircraft, textureLit_p, textureLit_p.with_suffix('.dds').name)) textureLit_p = textureLit_p.with_suffix('.dds') else: print (' WARNING - '+_currAircraft+': Cannot find lit texture file, continue anyway: ', textureLit_p) _warnings += 1 # compile the new object file's name, combined from original and livery files: obj8_out_file_p = obj8_in_file_p.with_name(obj8_in_file_p.stem + '_' + textureLivery_p.stem + obj8_in_file_p.suffix) object_out_p = PurePath(package_name) / obj8_out_file_p.relative_to(path) # Update the OBJ8 file UpdateOBJ8File(obj8_in_file_p, obj8_out_file_p, \ str(textureLivery_p.relative_to(obj8_out_file_p.parent)), \ str(textureLit_p.relative_to(obj8_out_file_p.parent)) if textureLit_p is not None else None) # --- return the new line for the xsb_aircraft file --- newLn = word[0] + ' ' + word[1] + ' ' + word[2] + ' ' + str(object_out_p) return newLn def ConvFolder(path: Path) -> int: """Converts the CSL package in the given path, recursively for each folder. Returns the number of written OBJ8 objects.""" numObj = 0; global _currAircraft _currAircraft = '?' commentingOut = 0 # Currently commenting out (due to outdated OBJECT format)? # --- Save the current version of xsb_aircraft as .orig, which we then read from --- assert (path.is_dir()) xsb_aircraft_p = path / 'xsb_aircraft.txt' xsb_aircraft_orig_p = path / 'xsb_aircraft.txt.orig' # First check of any of the two exists, otherwise we consider this folder empty: if (not xsb_aircraft_p.is_file() and not xsb_aircraft_orig_p.is_file()): if args.verbose: print ('===(skipped)', path) # --- Recursion: We check for containing folders and try again there! if not args.norecursion: for folder in path.iterdir(): if folder.is_dir(): numObj += ConvFolder(folder) return numObj; # So we will process this path print ('=========> ', path) try: # If the .orig version _not_ already exists then assume current xsb_aircraft.txt _is_ the original and rename it # (Never change the .orig file!) if not xsb_aircraft_orig_p.exists(): xsb_aircraft_p.replace(xsb_aircraft_orig_p) if args.verbose: print ('Renamed',xsb_aircraft_p,'-->',xsb_aircraft_orig_p) # --- Read from .orig as the original master --- xsb_aircraft_orig_f = xsb_aircraft_orig_p.open(mode='r',encoding='ascii',errors='replace') if args.verbose: print ('Reading from', xsb_aircraft_orig_p) # --- Write to a new xsb_aircraft.txt file (overwrite existing one!) --- xsb_aircraft_f = xsb_aircraft_p.open(mode='w',encoding='ascii',errors='replace') if args.verbose: print ('Writing to ', xsb_aircraft_p) # --- Read all lines from .orig file # Most of them are just copied 1:1 to the output file for line in xsb_aircraft_orig_f: # remove the newline char from the end line = line.rstrip('\n\r') origLine = line word = line.split() numWords = len(word) if numWords >= 2: # This is a line with at least two words # OBJECT is an outdated format modern XPMP2 no longer supports # Comment out the entire aircraft if word[0] == 'OBJECT' or word[0] == 'AIRCRAFT': # assume all else is the aircraft name _currAircraft = ' '.join(word[1:]) commentingOut = 1 print (' WARNING - {}: Outdated format, commented out'.format(_currAircraft)) line = "--- Outdated format, no longer valid\n# " + line # OBJ8_AIRCRAFT identifies the start of another aircraft. # Technically, we don't need that info, but it's nice for user info if word[0] == 'OBJ8_AIRCRAFT': # assume all else is the aircraft name _currAircraft = ' '.join(word[1:]) if args.verbose: print ('-- ' + _currAircraft) # replace spaces in the aircraft name (PE does that) if ' ' in _currAircraft: _currAircraft = _currAircraft.replace(' ', '_') # replace colons in the aircraft name (X-CSL does that, although there is no need for a path here) if ':' in _currAircraft: _currAircraft = _currAircraft.replace(':', '_') # re-write the OBJ8_AIRCRAFT line word[1] = _currAircraft line = 'OBJ8_AIRCRAFT ' + _currAircraft # Valid format, shall not be commented out commentingOut = 0 # X-CSL uses non-existing ICAO code 'MD80', replace with MD81 if word[1] == 'MD80' and \ (word[0] == 'ICAO' or \ word[0] == 'AIRLINE' or \ word[0] == 'LIVERY'): word[1] = 'MD81' line = ' '.join(word) # -- now decide what to do with the line # ignore deprecated or PE-extension commands if (word[0] == 'OBJ8' and word[1] == 'LOW_LOD') or \ word[0] == 'HASGEAR' or \ word[0] == 'TEXTURE': line = None # OBJ8 is the one line we _really_ need to work on! elif (word[0] == 'OBJ8'): Obj8SolidLine = HandleXsbObj8Solid(path, line) if Obj8SolidLine is not None: # and we did something to the OBJ8 line: line = Obj8SolidLine numObj += 1 # -- write the resulting line out to the new xsb_aircraft file if line is not None: if commentingOut: xsb_aircraft_f.write("# ") xsb_aircraft_f.write(line + '\n') if args.verbose and origLine != line: print (' Written: ' + line + ' (instead of: ' + origLine + ')') else: if args.verbose: print (' Removed line: ' + origLine) # --- Done, cleanup xsb_aircraft_f.close() xsb_aircraft_orig_f.close() print (' ', path, 'done, converted', numObj, 'OBJ8 files') return numObj except IOError as e: parser.error('Converting folder ' + str(path) + ' failed:\n' + e.filename + ':\n'+ e.strerror +'\n') """ === MAIN === """ # --- Handling command line argumens --- parser = argparse.ArgumentParser(description='CSL2XSB 0.3.1: Convert CSL packages to XPMP2 format, convert some animation dataRefs. Tested with: Bluebell, X-CSL.',fromfile_prefix_chars='@') parser.add_argument('path', help='Base path, searched recursively for CSL packages identified by existing xsb_aircraft.txt files', nargs='?', default='NULL') parser.add_argument('--noupdate', help='Suppress update of OBJ8 files if there are no additional textures', action='store_true') parser.add_argument('--norecursion', help='Do not search directories recursively', action='store_true') parser.add_argument('-v', '--verbose', help='More detailed output about every change', action='store_true') parser.add_argument('--replaceDR', metavar="TEXT", help="Replace dataRef's root 'libxplanemp' with TEXT.\nCAUTION: This works with LiveTraffic up to v1.5 only. CSLs' animations/lights will no longer work with standard multipayer clients nor with LiveTraffic starting from v2.0!") args = parser.parse_args() # If called with no argument (e.g. by double-clicking the script) ask the user interactively # if (s)he likes to work on the current directory. UserWantsIt = None if args.path == 'NULL': args.path = Path.cwd() print (parser.description) print ('Do you want to run CSL2XSB on the current directory "'+str(args.path)+'"?') while True: UserWantsIt = input ('Answer "y" or "n": ') if UserWantsIt.upper() == 'N': print ('You answered "N", so we exit without doing anything. Try "python CSL2XSBpy -h" for help.') exit() if UserWantsIt.upper() == 'Y': break # normalize the path, resolves relative paths and makes nice directory delimiters basePath = Path(args.path) if not basePath.exists() or not basePath.is_dir(): parser.error('Base bath "' + str(basePath) + '" does not exist or is no directory.') if args.verbose: print ('Base path: ', basePath) # --- Do it --- numConverted = ConvFolder(basePath) print ('Done. Converted ' + str(numConverted) + ' OBJ8 files in total. Produced ' + str(_warnings) + ' warning(s).') # Running interactively? if UserWantsIt is not None: input ("Hit [Enter] to finish.") # --- Done --- exit(0)