#!/usr/bin/env python3 """Export a terrain scenario using Escape's universal TerrainConnector prefab. Copy the exported Missions/ and Worlds/ into the terrain author's addon and add Escape as a dependency. --standalone creates a separate addon instead. Neither mode modifies the terrain source or generates navigation. """ import argparse, hashlib, json, math, re from pathlib import Path ROOT=Path(__file__).resolve().parents[1] CORE='2528466DE220D91C'; BASE='58D0FB3206B6F859' CONNECTOR='{8FB1559AE3F29722}Prefabs/ERR/TerrainConnector.et' PROJECTS=('Soldiers','BTRlike','LowRes') RESOURCE=re.compile(r'\{([A-F0-9]{16})\}([A-Za-z0-9_./-]+)') def guid(label):return hashlib.sha256(('ERR_TERRAIN_V1:'+label).encode()).hexdigest()[:16].upper() def resource(value,suffix): m=RESOURCE.fullmatch(value) if isinstance(value,str) else None if not m or any(part in ('', '.', '..') for part in m[2].split('/')) or not m[2].endswith(suffix):raise ValueError('Invalid resource: '+str(value)) return value def validate(profile): if not isinstance(profile,dict):raise ValueError('Terrain profile must be an object') if set(profile)-{'id','title','addon','world','navigation','ai_world','inherit_ai_world','airfields','notes'}:raise ValueError('Unknown terrain profile fields') if not re.fullmatch(r'[a-z][a-z0-9_]{0,39}',profile.get('id','')):raise ValueError('Invalid terrain id') if not re.fullmatch(r'[A-Za-z0-9 _-]{1,48}',profile.get('title','')):raise ValueError('Invalid terrain title') if not re.fullmatch('[A-F0-9]{16}',profile.get('addon','')):raise ValueError('Invalid terrain addon GUID') resource(profile.get('world'),'.ent') nav=profile.get('navigation') if not isinstance(nav,dict) or set(nav)-set(PROJECTS):raise ValueError('Unknown navigation project') for files in nav.values(): if not isinstance(files,list) or not len(files)==1:raise ValueError('Navigation needs one .nmn file or native .conf per project') for f in files:resource(f,('.nmn','.conf')) if 'ai_world' in profile: resource(profile['ai_world'],'.et') if nav:raise ValueError('Use a terrain AI world prefab OR navigation resources, not both') if type(profile.get('inherit_ai_world',False)) is not bool:raise ValueError('inherit_ai_world must be a boolean') if profile.get('inherit_ai_world') and (nav or profile.get('ai_world')):raise ValueError('Inherited terrain AI world must not be duplicated by adapter navigation') anchors=profile.get('airfields',[]) if not isinstance(anchors,list) or len(anchors)>16:raise ValueError('At most 16 airfield centres are supported') for anchor in anchors: if not isinstance(anchor,list) or len(anchor)!=3 or any(type(v) not in (int,float) or not math.isfinite(v) or abs(v)>1000000 for v in anchor):raise ValueError('Airfield centres must be finite [x,y,z] coordinates') return profile def meta(path,identity,kind='ResourceClass'): return f'MetaFileClass {{\n Name "{{{identity}}}{path}"\n Configurations {{\n {kind} PC {{}}\n {kind} HEADLESS : PC {{}}\n }}\n}}\n' def files(profile, standalone=False): validate(profile);name=profile['id'];title=profile['title'];identity=guid(name+':addon') world=f'Worlds/ERR_{name}.ent';mission=f'Missions/ERR_{name}.conf' nav=profile['navigation'];ready=profile.get('inherit_ai_world',False) or bool(profile.get('ai_world')) or all(p in nav for p in PROJECTS) deps=list(dict.fromkeys([BASE,CORE,profile['addon']])) result={} if standalone: result['addon.gproj']='GameProject {\n ID "ERR_Terrain_'+name+'"\n GUID "'+identity+'"\n TITLE "Escape - '+title+'"\n Dependencies {\n'+''.join(' "'+d+'"\n' for d in deps)+' }\n}\n' result[world]='SubScene {\n Parent "'+profile['world']+'"\n}\n' result[world+'.meta']=meta(world,guid(name+':world'),'ENTResourceClass') result[mission]=f'SCR_MissionHeader {{\n World "{{{guid(name+":world")}}}{world}"\n SystemsConfig "{{C50579A2EC48E8EF}}Configs/Systems/MissionSystems.conf"\n m_sName "Co10 Escape Reintroduced - {title}"\n m_sAuthor "Escape Reintroduced"\n m_sDescription "Escape captivity, gather intelligence, find the radio and flee by helicopter."\n m_sGameMode "Escape"\n m_iPlayerCount 12\n m_eSaveTypes 0\n m_bShowInScenarioMenu {int(ready)}\n}}\n' result[mission+'.meta']=meta(mission,guid(name+':mission')) # All maps reference the same prefab. Only navigation and airport hints vary. layer='ERR_GameMode Reintroduced : "'+CONNECTOR+'" {}\n' ai_world=profile.get('ai_world','{E0A05C76552E7F58}Prefabs/AI/SCR_AIWorld.et') ai='SCR_AIWorld AIWorld : "'+ai_world+'" {\n' if nav: ai+=' components {\n' components=['5584F30E67F617AD','5584F30EEFEE1223','5C8C9B750D124A63'] settings=['NavmeshWorld','ChimeraNavmeshWorld','NavmeshWorld'] settings_ids=['50FC63BEBE3973C5','50FC63BEBE3973C5','5C8C9B750B60C6E2'] config_ids=['60CA9BE5536BF701','60CA9BE5536BF701','5C90BD0EC793647D'] for i,project in enumerate(PROJECTS): if project not in nav:continue navfile=nav[project][0] config=(f': "{navfile}" {{}}' if navfile.endswith('.conf') else f'{{\n NavmeshFile "{navfile}"\n }}') ai+=f' NavmeshWorldComponent "{{{components[i]}}}" {{\n NavmeshSettings {settings[i]} "{{{settings_ids[i]}}}" {{\n NavmeshFilesConfig BaseNavmeshFilesConfig "{{{config_ids[i]}}}" {config}\n }}\n }}\n' ai+=' }\n' ai+='}\n' if profile.get('inherit_ai_world'):ai='' layer+=ai for index,anchor in enumerate(profile.get('airfields',[])): coords=' '.join(format(v,'.9g') for v in anchor) layer+=f'GenericEntity ERR_Airfield_{index} {{\n coords {coords}\n}}\n' result[f'Worlds/ERR_{name}_Layers/default.layer']=layer result['terrain-adapter.json']=json.dumps({'schema':2,'profile':profile,'mode':'standalone' if standalone else 'embedded','addon':identity if standalone else profile['addon'],'required_dependencies':deps if standalone else [BASE,CORE],'connector':CONNECTOR,'scenarioId':'{'+guid(name+':mission')+'}'+mission,'world':world,'navigation_configured':ready,'qualification':'unverified'},indent=2)+'\n' return result def generate(profile,destination,standalone=False): rendered=files(profile,standalone=standalone);destination=Path(destination) if destination.exists():raise ValueError('Output already exists; use a new destination to preserve authored/navigation data') destination.mkdir(parents=True) for name,content in rendered.items(): path=destination/name;path.parent.mkdir(parents=True,exist_ok=True);path.write_text(content) return json.loads(rendered['terrain-adapter.json']) def main(): p=argparse.ArgumentParser(description=__doc__);p.add_argument('profile',type=Path);p.add_argument('--output',type=Path,required=True) p.add_argument('--standalone',action='store_true',help='Create a separate addon project instead of files to include in the terrain addon') a=p.parse_args() try:report=generate(json.loads(a.profile.read_text()),a.output,standalone=a.standalone) except (OSError,ValueError) as e:p.error(str(e)) print(json.dumps(report,indent=2)) if not a.standalone:print('Copy Missions/ and Worlds/ into the terrain addon, add Escape '+CORE+' to its project dependencies, then publish that terrain. Do not also load a standalone adapter with the same scenario resources.') if not report['navigation_configured']:print('Navigation references missing: supply the published terrain addon’s AI-world prefab or its Soldiers/BTRlike/LowRes resources before running Escape.') if __name__=='__main__':main()