export_standalone_monolithic_windows.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  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 sys
  14. import subprocess
  15. import json
  16. from queue import Queue, Empty
  17. from threading import Thread
  18. from typing import List
  19. from subprocess import Popen, PIPE
  20. logger = logging.getLogger('o3de.gamejam')
  21. LOG_FORMAT = '[%(levelname)s] %(name)s: %(message)s'
  22. logging.basicConfig(format=LOG_FORMAT)
  23. #This is an export script for MPS on the Windows platform
  24. # this has to be a complete standalone script, b/c project export doesnt exist in main branch yet
  25. #View the argparse parameters for options available. An example invocation:
  26. # @<O3DE_ENGINE_ROOT_PATH>
  27. # > python\python.cmd <O3DE_MPS_PROJECT_ROOT_PATH>\ExportScripts\export_standalone_monolithic_windows.py -ps <O3DE_MPS_PROJECT_ROOT_PATH> -egn <O3DE_ENGINE_ROOT_PATH> -bnmt -out <MPS_OUTPUT_RELEASE_DIR_PATH> -zip
  28. def enqueue_output(out, queue):
  29. for line in iter(out.readline, b''):
  30. queue.put(line)
  31. out.close()
  32. def safe_kill_processes(*processes: List[Popen], process_logger: logging.Logger = None) -> None:
  33. """
  34. Kills a given process without raising an error
  35. :param processes: An iterable of processes to kill
  36. :param process_logger: (Optional) logger to use
  37. """
  38. def on_terminate(proc) -> None:
  39. try:
  40. process_logger.info(f"process '{proc.args[0]}' with PID({proc.pid}) terminated with exit code {proc.returncode}")
  41. except Exception: # purposefully broad
  42. process_logger.error("Exception encountered with termination request, with stacktrace:", exc_info=True)
  43. if not process_logger:
  44. process_logger = logger
  45. for proc in processes:
  46. try:
  47. process_logger.info(f"Terminating process '{proc.args[0]}' with PID({proc.pid})")
  48. proc.kill()
  49. except Exception: # purposefully broad
  50. process_logger.error("Unexpected exception ignored while terminating process, with stacktrace:", exc_info=True)
  51. try:
  52. for proc in processes:
  53. proc.wait(timeout=30)
  54. on_terminate(proc)
  55. except Exception: # purposefully broad
  56. process_logger.error("Unexpected exception while waiting for processes to terminate, with stacktrace:", exc_info=True)
  57. class CLICommand(object):
  58. """
  59. CLICommand is an interface for storing CLI commands as list of string arguments to run later in a script.
  60. A current working directory, pre-existing OS environment, and desired logger can also be specified.
  61. To execute a command, use the run() function.
  62. This class is responsible for starting a new process, polling it for updates and logging, and safely terminating it.
  63. """
  64. def __init__(self,
  65. args: list,
  66. cwd: pathlib.Path,
  67. logger: logging.Logger,
  68. env: os._Environ=None) -> None:
  69. self.args = args
  70. self.cwd = cwd
  71. self.env = env
  72. self.logger = logger
  73. self._stdout_lines = []
  74. self._stderr_lines = []
  75. @property
  76. def stdout_lines(self) -> List[str]:
  77. """The result of stdout, separated by newlines."""
  78. return self._stdout_lines
  79. @property
  80. def stdout(self) -> str:
  81. """The result of stdout, as a single string."""
  82. return "\n".join(self._stdout_lines)
  83. @property
  84. def stderr_lines(self) -> List[str]:
  85. """The result of stderr, separated by newlines."""
  86. return self._stderr_lines
  87. @property
  88. def stderr(self) -> str:
  89. """The result of stderr, as a single string."""
  90. return "\n".join(self._stderr_lines)
  91. def _poll_process(self, process, queue) -> None:
  92. # while process is not done, read any log lines coming from subprocess
  93. while process.poll() is None:
  94. #handle readline in a non-blocking manner
  95. try: line = queue.get_nowait()
  96. except Empty:
  97. pass
  98. else: # got line
  99. if not line: break
  100. log_line = line.decode('utf-8', 'ignore')
  101. self._stdout_lines.append(log_line)
  102. self.logger.info(log_line)
  103. def _cleanup_process(self, process, queue) -> str:
  104. # flush remaining log lines
  105. while not queue.empty():
  106. try: line = queue.get_nowait()
  107. except Empty:
  108. pass
  109. else:
  110. if not line: break
  111. log_line = line.decode('utf-8', 'ignore')
  112. self._stdout_lines.append(log_line)
  113. self.logger.info(log_line)
  114. stderr = process.stderr.read()
  115. safe_kill_processes(process, process_logger = self.logger)
  116. return stderr
  117. def run(self) -> int:
  118. """
  119. Takes the arguments specified during CLICommand initialization, and opens a new subprocess to handle it.
  120. This function automatically manages polling the process for logs, error reporting, and safely cleaning up the process afterwards.
  121. :return return code on success or failure
  122. """
  123. ret = 1
  124. try:
  125. with Popen(self.args, cwd=self.cwd, env=self.env, stdout=PIPE, stderr=PIPE) as process:
  126. self.logger.info(f"Running process '{self.args[0]}' with PID({process.pid}): {self.args}")
  127. q = Queue()
  128. t = Thread(target=enqueue_output, args=(process.stdout, q))
  129. t.daemon = True
  130. t.start()
  131. process.stdout.flush()
  132. self._poll_process(process, q)
  133. stderr = self._cleanup_process(process, q)
  134. ret = process.returncode
  135. # print out errors if there are any
  136. if stderr:
  137. # bool(ret) --> if the process returns a FAILURE code (>0)
  138. logger_func = self.logger.error if bool(ret) else self.logger.warning
  139. err_txt = stderr.decode('utf-8', 'ignore')
  140. logger_func(err_txt)
  141. self._stderr_lines = err_txt.split("\n")
  142. except Exception as err:
  143. self.logger.error(err)
  144. raise err
  145. return ret
  146. # Helper API
  147. def process_command(args: list,
  148. cwd: pathlib.Path = None,
  149. env: os._Environ = None) -> int:
  150. """
  151. Wrapper for subprocess.Popen, which handles polling the process for logs, reacting to failure, and cleaning up the process.
  152. :param args: A list of space separated strings which build up the entire command to run. Similar to the command list of subprocess.Popen
  153. :param cwd: (Optional) The desired current working directory of the command. Useful for commands which require a differing starting environment.
  154. :param env: (Optional) Environment to use when processing this command.
  155. :return the exit code of the program that is run or 1 if no arguments were supplied
  156. """
  157. if len(args) == 0:
  158. logging.error("function `process_command` must be supplied a non-empty list of arguments")
  159. return 1
  160. return CLICommand(args, cwd, logging.getLogger(), env=env).run()
  161. # EXPORT SCRIPT STARTS HERE!
  162. if __name__ == "__main__":
  163. parser = argparse.ArgumentParser(prog='Exporter for MultiplayerSample on windows',
  164. description = "Exports O3DE's MultiplayerSample to the desired install directory in project layout...")
  165. parser.add_argument('-ps', '--project-path', type=pathlib.Path, required=True, help='Path to the intended O3DE project.')
  166. parser.add_argument('-egn', '--engine-path', type=pathlib.Path, required=True, help='Path to the intended O3DE engine copy.')
  167. parser.add_argument('-out', '--output-path', type=pathlib.Path, required=True, help='Path that describes the final resulting Release Directory path location.')
  168. parser.add_argument('-cfg', '--config', type=str, default='profile', choices=['release', 'profile'], help='The CMake build configuration to use when building project binaries.')
  169. parser.add_argument('-ll', '--log-level', default='INFO',
  170. choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
  171. help="Set the log level")
  172. parser.add_argument('-bnmt', '--build-non-mono-tools', action='store_true')
  173. parser.add_argument('-nmbp', '--non-mono-build-path', type=pathlib.Path, default=None)
  174. parser.add_argument('-mbp', '--mono-build-path', type=pathlib.Path, default=None)
  175. parser.add_argument('-zip', '--zip-output', action='store_true', help='This option places the final output of the build into a zipped archive')
  176. args = parser.parse_args()
  177. logging.getLogger().setLevel(args.log_level)
  178. non_mono_build_path = (args.engine_path) / 'build' / 'non_mono' if args.non_mono_build_path is None else args.non_mono_build_path
  179. mono_build_path = (args.engine_path) / 'build' / 'mono' if args.mono_build_path is None else args.mono_build_path
  180. #validation
  181. assert args.engine_path.is_dir() and (args.engine_path / 'engine.json').is_file(), "Invalid Engine path provided!"
  182. assert args.project_path.is_dir() and (args.project_path / 'project.json').is_file(), "Invalid Project path provided!"
  183. #commands are based on
  184. #https://github.com/o3de/o3de-multiplayersample/blob/development/Documentation/PackedAssetBuilds.md
  185. #Build o3de-multiplayersample and the engine (non-monolithic)
  186. if args.build_non_mono_tools:
  187. process_command(['cmake', '-S', '.', '-B', str(non_mono_build_path), '-DLY_MONOLITHIC_GAME=0', f'-DLY_PROJECTS={args.project_path}'], cwd=args.engine_path)
  188. process_command(['cmake', '--build', str(non_mono_build_path), '--target', 'Editor', 'AssetBundler', 'AssetBundlerBatch', 'AssetProcessor', 'AssetProcessorBatch', '--config','profile'], cwd=args.engine_path)
  189. asset_processor_batch_path = non_mono_build_path / 'bin' / 'profile' / 'AssetProcessorBatch'
  190. process_command([asset_processor_batch_path, '--project-path', args.project_path], cwd=args.engine_path)
  191. #Build monolithic game
  192. 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)
  193. process_command(['cmake', '--build', str(mono_build_path), '--target', 'MultiplayerSample.GameLauncher', 'MultiplayerSample.ServerLauncher', 'MultiplayerSample.UnifiedLauncher', '--config', args.config], cwd=args.engine_path)
  194. #Bundle content
  195. asset_bundler_batch_path = non_mono_build_path / 'bin' / 'profile' / 'AssetBundlerBatch'
  196. engine_asset_list_path = args.project_path / 'AssetBundling' / 'AssetLists' / 'engine_pc.assetlist'
  197. process_command([asset_bundler_batch_path, 'assetLists','--addDefaultSeedListFiles', '--assetListFile', engine_asset_list_path, '--project-path', args.project_path, '--allowOverwrites' ], cwd=args.engine_path)
  198. game_asset_list_path = args.project_path /'AssetBundling'/'AssetLists'/'game_pc.assetlist'
  199. seed_folder_path = args.project_path/'AssetBundling'/'SeedLists'
  200. game_asset_list_command = [asset_bundler_batch_path, 'assetLists', '--assetListFile', game_asset_list_path,
  201. '--seedListFile', seed_folder_path / 'BasePopcornFxSeedList.seed',
  202. '--seedListFile', seed_folder_path / 'GameSeedList.seed']
  203. if args.config == 'profile':
  204. game_asset_list_command += ['--seedListFile', seed_folder_path / 'ProfileOnlySeedList.seed']
  205. game_asset_list_command += ['--seedListFile', seed_folder_path / 'VFXSeedList.seed', '--project-path', args.project_path, '--allowOverwrites']
  206. process_command(game_asset_list_command, cwd=args.engine_path)
  207. engine_bundle_path = args.project_path / 'AssetBundling' / 'Bundles' / 'engine_pc.pak'
  208. 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)
  209. # This is to prevent any accidental file locking mechanism from failing subsequent bundling operations
  210. time.sleep(1)
  211. game_bundle_path = args.project_path / 'AssetBundling' / 'Bundles' / 'game_pc.pak'
  212. 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)
  213. #Create Launcher Zip File
  214. os.makedirs( args.output_path / 'Cache' / 'pc', exist_ok=True)
  215. os.makedirs( args.output_path / 'Gems' / 'AWSCore', exist_ok=True)
  216. process_command(['xcopy', '/Y', pathlib.PurePath(args.project_path / 'AssetBundling' / 'Bundles' / '*.pak'),
  217. args.output_path / 'Cache' / 'pc'], cwd=args.engine_path)
  218. process_command(['xcopy', '/Y', pathlib.PurePath(mono_build_path / 'bin' / 'profile' / '*.*'),
  219. args.output_path], cwd=args.engine_path)
  220. process_command(['xcopy', '/Y', pathlib.PurePath(mono_build_path / 'bin' / 'profile' / 'Gems' / 'AWSCore' / '*.*'),
  221. args.output_path / 'Gems' / 'AWSCore'], cwd=args.engine_path)
  222. process_command(['xcopy', '/Y', pathlib.PurePath(args.project_path / 'launch_*.*'), args.output_path], cwd=args.engine_path)
  223. if args.zip_output:
  224. import shutil
  225. archive_name = args.output_path
  226. print("Zipping output directory (this may take a while)...")
  227. shutil.make_archive(args.output_path, 'zip', root_dir = args.output_path)
  228. print(f"Exporting project is complete! Release Directory can be found at {args.output_path}")
  229. process_command(['explorer', args.output_path])