export_standalone_monolithic.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. #
  2. # Copyright (c) Contributors to the Open 3D Engine Project.
  3. # For complete copyright and license terms please see the LICENSE at the root of https://www.github.com/o3de/o3de.
  4. #
  5. # SPDX-License-Identifier: Apache-2.0 OR MIT
  6. #
  7. #
  8. import argparse
  9. import pathlib
  10. import logging
  11. import os
  12. import time
  13. import glob
  14. import sys
  15. import platform
  16. from o3de.validation import valid_o3de_project_json, valid_o3de_engine_json
  17. from queue import Queue, Empty
  18. from threading import Thread
  19. from typing import List
  20. from subprocess import Popen, PIPE
  21. logger = logging.getLogger('o3de.mps_export')
  22. LOG_FORMAT = '[%(levelname)s] %(name)s: %(message)s'
  23. logging.basicConfig(format=LOG_FORMAT)
  24. # This is an export script for MPS
  25. # this has to be a complete standalone script, b/c project export doesnt exist in main branch yet
  26. # View the argparse parameters for options available. An example invocation:
  27. # @<O3DE_ENGINE_ROOT_PATH>
  28. # > python\python.cmd <O3DE_MPS_PROJECT_ROOT_PATH>\ExportScripts\export_standalone_monolithic.py -pp <O3DE_MPS_PROJECT_ROOT_PATH> -ep <O3DE_ENGINE_ROOT_PATH> -bnmt -out <MPS_OUTPUT_RELEASE_DIR_PATH> -a -aof zip
  29. def enqueue_output(out, queue):
  30. for line in iter(out.readline, b''):
  31. queue.put(line)
  32. out.close()
  33. def safe_kill_processes(*processes: List[Popen], process_logger: logging.Logger = None) -> None:
  34. """
  35. Kills a given process without raising an error
  36. :param processes: An iterable of processes to kill
  37. :param process_logger: (Optional) logger to use
  38. """
  39. def on_terminate(proc) -> None:
  40. try:
  41. process_logger.info(f"process '{proc.args[0]}' with PID({proc.pid}) terminated with exit code {proc.returncode}")
  42. except Exception: # purposefully broad
  43. process_logger.error("Exception encountered with termination request, with stacktrace:", exc_info=True)
  44. if not process_logger:
  45. process_logger = logger
  46. for proc in processes:
  47. try:
  48. process_logger.info(f"Terminating process '{proc.args[0]}' with PID({proc.pid})")
  49. proc.kill()
  50. except Exception: # purposefully broad
  51. process_logger.error("Unexpected exception ignored while terminating process, with stacktrace:", exc_info=True)
  52. try:
  53. for proc in processes:
  54. proc.wait(timeout=30)
  55. on_terminate(proc)
  56. except Exception: # purposefully broad
  57. process_logger.error("Unexpected exception while waiting for processes to terminate, with stacktrace:", exc_info=True)
  58. class CLICommand(object):
  59. """
  60. CLICommand is an interface for storing CLI commands as list of string arguments to run later in a script.
  61. A current working directory, pre-existing OS environment, and desired logger can also be specified.
  62. To execute a command, use the run() function.
  63. This class is responsible for starting a new process, polling it for updates and logging, and safely terminating it.
  64. """
  65. def __init__(self,
  66. args: list,
  67. cwd: pathlib.Path,
  68. logger: logging.Logger,
  69. env: os._Environ=None) -> None:
  70. self.args = args
  71. self.cwd = cwd
  72. self.env = env
  73. self.logger = logger
  74. self._stdout_lines = []
  75. self._stderr_lines = []
  76. @property
  77. def stdout_lines(self) -> List[str]:
  78. """The result of stdout, separated by newlines."""
  79. return self._stdout_lines
  80. @property
  81. def stdout(self) -> str:
  82. """The result of stdout, as a single string."""
  83. return "\n".join(self._stdout_lines)
  84. @property
  85. def stderr_lines(self) -> List[str]:
  86. """The result of stderr, separated by newlines."""
  87. return self._stderr_lines
  88. @property
  89. def stderr(self) -> str:
  90. """The result of stderr, as a single string."""
  91. return "\n".join(self._stderr_lines)
  92. def _poll_process(self, process, queue) -> None:
  93. # while process is not done, read any log lines coming from subprocess
  94. while process.poll() is None:
  95. #handle readline in a non-blocking manner
  96. try: line = queue.get_nowait()
  97. except Empty:
  98. pass
  99. else: # got line
  100. if not line: break
  101. log_line = line.decode('utf-8', 'ignore')
  102. self._stdout_lines.append(log_line)
  103. self.logger.info(log_line)
  104. def _cleanup_process(self, process, queue) -> str:
  105. # flush remaining log lines
  106. while not queue.empty():
  107. try: line = queue.get_nowait()
  108. except Empty:
  109. pass
  110. else:
  111. if not line: break
  112. log_line = line.decode('utf-8', 'ignore')
  113. self._stdout_lines.append(log_line)
  114. self.logger.info(log_line)
  115. stderr = process.stderr.read()
  116. safe_kill_processes(process, process_logger = self.logger)
  117. return stderr
  118. def run(self) -> int:
  119. """
  120. Takes the arguments specified during CLICommand initialization, and opens a new subprocess to handle it.
  121. This function automatically manages polling the process for logs, error reporting, and safely cleaning up the process afterwards.
  122. :return return code on success or failure
  123. """
  124. ret = 1
  125. try:
  126. with Popen(self.args, cwd=self.cwd, env=self.env, stdout=PIPE, stderr=PIPE) as process:
  127. self.logger.info(f"Running process '{self.args[0]}' with PID({process.pid}): {self.args}")
  128. q = Queue()
  129. t = Thread(target=enqueue_output, args=(process.stdout, q))
  130. t.daemon = True
  131. t.start()
  132. process.stdout.flush()
  133. self._poll_process(process, q)
  134. stderr = self._cleanup_process(process, q)
  135. ret = process.returncode
  136. # print out errors if there are any
  137. if stderr:
  138. # bool(ret) --> if the process returns a FAILURE code (>0)
  139. logger_func = self.logger.error if bool(ret) else self.logger.warning
  140. err_txt = stderr.decode('utf-8', 'ignore')
  141. logger_func(err_txt)
  142. self._stderr_lines = err_txt.split("\n")
  143. except Exception as err:
  144. self.logger.error(err)
  145. raise err
  146. return ret
  147. # Helper API
  148. def process_command(args: list,
  149. cwd: pathlib.Path = None,
  150. env: os._Environ = None) -> int:
  151. """
  152. Wrapper for subprocess.Popen, which handles polling the process for logs, reacting to failure, and cleaning up the process.
  153. :param args: A list of space separated strings which build up the entire command to run. Similar to the command list of subprocess.Popen
  154. :param cwd: (Optional) The desired current working directory of the command. Useful for commands which require a differing starting environment.
  155. :param env: (Optional) Environment to use when processing this command.
  156. :return the exit code of the program that is run or 1 if no arguments were supplied
  157. """
  158. if len(args) == 0:
  159. logging.error("function `process_command` must be supplied a non-empty list of arguments")
  160. return 1
  161. return CLICommand(args, cwd, logging.getLogger(), env=env).run()
  162. # EXPORT SCRIPT STARTS HERE!
  163. if __name__ == "__main__":
  164. parser = argparse.ArgumentParser(prog='Exporter for MultiplayerSample as a standalone build',
  165. description = "Exports O3DE's MultiplayerSample to the desired output directory with release layout. "
  166. "In order to use this script, the engine and project must be setup and registered beforehand. "
  167. "See this example on the MPS Github page: "
  168. "https://github.com/o3de/o3de-multiplayersample/blob/development/README.md#required-step-to-compile")
  169. parser.add_argument('-pp', '--project-path', type=pathlib.Path, required=True, help='Path to the intended O3DE project.')
  170. parser.add_argument('-ep', '--engine-path', type=pathlib.Path, required=True, help='Path to the intended O3DE engine.')
  171. parser.add_argument('-out', '--output-path', type=pathlib.Path, required=True, help='Path that describes the final resulting Release Directory path location.')
  172. parser.add_argument('-cfg', '--config', type=str, default='profile', choices=['release', 'profile'],
  173. help='The CMake build configuration to use when building project binaries. If tool binaries are built with this script, they will use profile mode.')
  174. parser.add_argument('-ll', '--log-level', default='ERROR',
  175. choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
  176. help="Set the log level")
  177. parser.add_argument('-aof', '--archive-output-format',
  178. type=str,
  179. help="Format of archive to create from the output directory",
  180. choices=["zip", "gzip", "bz2", "xz"], default="zip")
  181. parser.add_argument('-bnmt', '--build-non-mono-tools', action='store_true')
  182. parser.add_argument('-nmbp', '--non-mono-build-path', type=pathlib.Path, default=None)
  183. parser.add_argument('-mbp', '--mono-build-path', type=pathlib.Path, default=None)
  184. parser.add_argument('-nogame', '--no-game-launcher', action='store_true', help='this flag skips building the Game Launcher on a platform if not needed.')
  185. parser.add_argument('-noserver', '--no-server-launcher', action='store_true', help='this flag skips building the Server Launcher on a platform if not needed.')
  186. parser.add_argument('-nounified', '--no-unified-launcher', action='store_true', help='this flag skips building the Unified Launcher on a platform if not needed.')
  187. parser.add_argument('-pl', '--platform', type=str, default=None, choices=['pc', 'linux', 'mac'])
  188. parser.add_argument('-a', '--archive-output', action='store_true', help='This option places the final output of the build into a compressed archive')
  189. parser.add_argument('-q', '--quiet', action='store_true', help='Suppresses logging information unless an error occurs.')
  190. args = parser.parse_args()
  191. if args.quiet:
  192. logging.getLogger().setLevel(logging.ERROR)
  193. else:
  194. logging.getLogger().setLevel(args.log_level)
  195. non_mono_build_path = (args.engine_path) / 'build' / 'non_mono' if args.non_mono_build_path is None else args.non_mono_build_path
  196. mono_build_path = (args.engine_path) / 'build' / 'mono' if args.mono_build_path is None else args.mono_build_path
  197. #validation
  198. assert valid_o3de_project_json(args.project_path / 'project.json') and valid_o3de_engine_json(args.engine_path / 'engine.json')
  199. #commands are based on
  200. #https://github.com/o3de/o3de-multiplayersample/blob/development/Documentation/PackedAssetBuilds.md
  201. selected_platform = args.platform
  202. system_platform = platform.system().lower()
  203. if not selected_platform:
  204. logger.info("Platform not specified! Defaulting to Host platform...")
  205. if not system_platform:
  206. logger.error("Unable to identify host platform! Please supply the platform using '--platform'. Options are [pc, linux, mac].")
  207. sys.exit(1)
  208. if system_platform == "windows":
  209. selected_platform = "pc"
  210. elif system_platform == "linux":
  211. selected_platform = "linux"
  212. elif system_platform == "darwin":
  213. selected_platform = "mac"
  214. else:
  215. logger.error(f"MPS exporting for {system_platform} is currently unsupported! Please use either a Windows, Mac or Linux machine to build project.")
  216. sys.exit(1)
  217. logger.info(f"Project path for MPS: {args.project_path}")
  218. logger.info(f"Engine path to build MPS: {args.engine_path}")
  219. logger.info(f"Build path for non-monolithic executables: {args.non_mono_build_path}")
  220. logger.info(f"Build path for monolithic executables: {args.mono_build_path}")
  221. output_cache_path = args.output_path / 'Cache' / selected_platform
  222. output_aws_gem_path = args.output_path / 'Gems' / 'AWSCore'
  223. os.makedirs(output_cache_path, exist_ok=True)
  224. os.makedirs(output_aws_gem_path, exist_ok=True)
  225. #Build o3de-multiplayersample and the engine (non-monolithic)
  226. if args.build_non_mono_tools:
  227. process_command(['cmake', '-S', '.', '-B', str(non_mono_build_path), '-DLY_MONOLITHIC_GAME=0', f'-DLY_PROJECTS={args.project_path}'], cwd=args.engine_path)
  228. process_command(['cmake', '--build', str(non_mono_build_path), '--target', 'AssetBundler', 'AssetBundlerBatch', 'AssetProcessor', 'AssetProcessorBatch', '--config','profile'], cwd=args.engine_path)
  229. process_command(['cmake', '--build', str(non_mono_build_path), '--target', 'MultiplayerSample.Assets', '--config', 'profile'], cwd=args.engine_path)
  230. #Build monolithic game
  231. process_command(['cmake', '-S', '.', '-B', str(mono_build_path), '-DLY_MONOLITHIC_GAME=1', '-DALLOW_SETTINGS_REGISTRY_DEVELOPMENT_OVERRIDES=0', f'-DLY_PROJECTS={args.project_path}'], cwd=args.engine_path)
  232. if not args.no_game_launcher:
  233. process_command(['cmake', '--build', str(mono_build_path), '--target', 'MultiplayerSample.GameLauncher', '--config', args.config], cwd=args.engine_path)
  234. if not args.no_server_launcher:
  235. process_command(['cmake', '--build', str(mono_build_path), '--target', 'MultiplayerSample.ServerLauncher', '--config', args.config], cwd=args.engine_path)
  236. if not args.no_unified_launcher:
  237. process_command(['cmake', '--build', str(mono_build_path), '--target', 'MultiplayerSample.UnifiedLauncher', '--config', args.config], cwd=args.engine_path)
  238. #Before bundling content, make sure that the necessary executables exist
  239. asset_bundler_batch_path = non_mono_build_path / 'bin' / 'profile' / ('AssetBundlerBatch' + ('.exe' if system_platform=='windows' else ''))
  240. if not asset_bundler_batch_path.is_file():
  241. logger.error(f"AssetBundlerBatch not found at path '{asset_bundler_batch_path}'. In order to bundle the data for MPS, this executable must be present!")
  242. logger.error("To correct this issue, do 1 of the following: "
  243. "1) Use the --build-non-mono-tools flag in the CLI parameters"
  244. "2) If you are trying to build in a project-centric fashion, please switch to engine-centric for this export script"
  245. f"3) Build AssetBundlerBatch by hand and make sure it is available at {asset_bundler_batch_path}"
  246. "4) Set the --non-mono-build-path to point at a directory which contains this executable")
  247. sys.exit(1)
  248. #Bundle content
  249. engine_asset_list_path = args.project_path / 'AssetBundling' / 'AssetLists' / f'engine_{selected_platform}.assetlist'
  250. process_command([asset_bundler_batch_path, 'assetLists','--addDefaultSeedListFiles', '--assetListFile', engine_asset_list_path, '--project-path', args.project_path, '--allowOverwrites' ], cwd=args.engine_path)
  251. game_asset_list_path = args.project_path /'AssetBundling'/'AssetLists'/ f'game_{selected_platform}.assetlist'
  252. seed_folder_path = args.project_path/'AssetBundling'/'SeedLists'
  253. game_asset_list_command = [asset_bundler_batch_path, 'assetLists', '--assetListFile', game_asset_list_path,
  254. '--seedListFile', seed_folder_path / 'BasePopcornFxSeedList.seed',
  255. '--seedListFile', seed_folder_path / 'GameSeedList.seed']
  256. if args.config == 'profile':
  257. # Dev branch has removed the profile seed list, but it still remains in main for now.
  258. # This will be removed after the next release, when both branches are synchronized
  259. profile_seed_list_path = seed_folder_path / 'ProfileOnlySeedList.seed'
  260. if profile_seed_list_path.is_file():
  261. game_asset_list_command += ['--seedListFile', profile_seed_list_path]
  262. game_asset_list_command += ['--seedListFile', seed_folder_path / 'VFXSeedList.seed', '--project-path', args.project_path, '--allowOverwrites']
  263. process_command(game_asset_list_command, cwd=args.engine_path)
  264. engine_bundle_path = output_cache_path / f'engine_{selected_platform}.pak'
  265. process_command([asset_bundler_batch_path, 'bundles', '--assetListFile', engine_asset_list_path, '--outputBundlePath', engine_bundle_path, '--project-path', args.project_path, '--allowOverwrites'], cwd=args.engine_path)
  266. # This is to prevent any accidental file locking mechanism from failing subsequent bundling operations
  267. time.sleep(1)
  268. game_bundle_path = output_cache_path / f'game_{selected_platform}.pak'
  269. process_command([asset_bundler_batch_path, 'bundles', '--assetListFile', game_asset_list_path, '--outputBundlePath', game_bundle_path, '--project-path', args.project_path, '--allowOverwrites'], cwd=args.engine_path)
  270. # Create Launcher Layout Directory
  271. import shutil
  272. for file in glob.glob(str(pathlib.PurePath(mono_build_path / 'bin' / args.config / '*.*'))):
  273. shutil.copy(file, args.output_path)
  274. for file in glob.glob(str(pathlib.PurePath(mono_build_path / 'bin' / args.config / 'Gems' / 'AWSCore' / '*.*'))):
  275. shutil.copy(file, output_aws_gem_path)
  276. for file in glob.glob(str(pathlib.PurePath(args.project_path / 'launch_*.*'))):
  277. shutil.copy(file, args.output_path)
  278. # Optionally zip the layout directory if the user requests
  279. if args.archive_output:
  280. archive_name = args.output_path
  281. logger.info("Archiving output directory (this may take a while)...")
  282. shutil.make_archive(args.output_path, args.archive_output_format, root_dir = args.output_path)
  283. logger.info(f"Exporting project is complete! Release Directory can be found at {args.output_path}")