#!/usr/bin/env -S PYTHONDONTWRITEBYTECODE=1 python import ctypes import json import os import random import shutil import subprocess import sys from argparse import ArgumentDefaultsHelpFormatter, ArgumentParser, ArgumentTypeError, RawTextHelpFormatter from importlib.machinery import SourceFileLoader from tempfile import TemporaryDirectory # ------------------------------------------------------------------------------ # Parse CLI arguments # ------------------------------------------------------------------------------ description = "Salis: Simple A-Life Simulator" prog = sys.argv[0] epilog = f"Use '-h' to list arguments for each command.\nExample: '{prog} new -h'" main_parser = ArgumentParser(description=description, epilog=epilog, formatter_class=RawTextHelpFormatter, prog=prog) sub_parsers = main_parser.add_subparsers(dest="command", required=True) formatter_class = lambda prog: ArgumentDefaultsHelpFormatter(prog, max_help_position=32) new = sub_parsers.add_parser("new", formatter_class=formatter_class, help="create new simulation") load = sub_parsers.add_parser("load", formatter_class=formatter_class, help="load saved simulation") server = sub_parsers.add_parser("server", formatter_class=formatter_class, help="run data server") client = sub_parsers.add_parser("client", formatter_class=formatter_class, help="run data client") architectures = os.listdir("./arch") uis = os.listdir("./ui") def seed(i): ival = int(i, 0) if ival < -1: raise ArgumentTypeError("invalid seed value") return ival def ipos(i): ival = int(i, 0) if ival < 0: raise ArgumentTypeError("value must be positive integer") return ival def inat(i): ival = int(i, 0) if ival < 1: raise ArgumentTypeError("value must be greater than zero") return ival def iport(i): ival = int(i, 0) if not 0 <= ival <= 65535: raise ArgumentTypeError("value must be valid port number") return ival option_keys = ["short", "long", "metavar", "help", "default", "required", "type", "parsers"] option_list = [ ["A", "anc", "ANC", "ancestor file name without extension, to be compiled on all cores (ANC points to 'anc/{arch}/{ANC}.asm')", None, True, str, [new]], ["a", "arch", architectures, "VM architecture", "dummy", False, str, [new]], ["C", "clones", "N", "number of ancestor clones on each core", 1, False, inat, [new]], ["c", "cores", "N", "number of simulator cores", 2, False, inat, [new]], ["d", "data-push-pow", "POW", "data aggregation interval exponent (interval == 2^{POW} >= {sync-pow}); a value of 0 disables data aggregation (requires 'sqlite')", 28, False, ipos, [new]], ["f", "force", None, "overwrite existing simulation of given name", False, False, bool, [new]], ["F", "muta-flip", None, "cosmic rays flip bits instead of randomizing whole bytes", False, False, bool, [new]], ["g", "compiler", "CC", "C compiler to use", "gcc", False, str, [new, load, server, client]], ["G", "compiler-flags", "FLAGS", "base set of flags to pass to C compiler", "-Wall -Wextra -Werror -pedantic", False, str, [new, load, server, client]], ["H", "home", "PATH", "salis home directory", os.path.join(os.environ["HOME"], ".salis"), False, str, [new, load, server]], ["i", "ip", "IP", "ip address of data server", "127.0.0.1", False, str, [client]], ["M", "muta-pow", "POW", "mutator range exponent (range == 2^{POW})", 32, False, ipos, [new]], ["m", "mvec-pow", "POW", "memory vector size exponent (size == 2^{POW})", 20, False, ipos, [new]], ["n", "name", "NAME", "name of new or loaded simulation", "def.sim", False, str, [new, load, server]], ["o", "optimized", None, "build with optimizations", False, False, bool, [new, load, server, client]], ["P", "port", "PORT", "port number for data server", 8080, False, iport, [server, client]], ["p", "pre-cmd", "CMD", "shell command to wrap call to executable (e.g. gdb, time, valgrind, etc.)", None, False, str, [new, load, server, client]], ["s", "seed", "SEED", "seed value for new simulation; a value of 0 disables cosmic rays; a value of -1 creates a random seed", 0, False, seed, [new]], ["T", "keep-temp-dir", None, "keep temporary directory on exit", False, False, bool, [new, load, server, client]], ["t", "thread-gap", "N", "memory gap between cores in bytes (may help reduce cache misses)", 0x100, False, inat, [new, load]], ["u", "ui", uis, "user interface", "curses", False, str, [new, load]], ["x", "no-compress", None, "do not compress save files (useful if 'zlib' is unavailable)", True, False, bool, [new]], ["y", "sync-pow", "POW", "core sync interval exponent (interval == 2^{POW})", 20, False, ipos, [new]], ["z", "auto-save-pow", "POW", "auto-save interval exponent (interval == 2^{POW})", 36, False, ipos, [new]], ] options = list(map(lambda option: dict(zip(option_keys, option)), option_list)) parser_map = ((parser, option) for option in options for parser in option["parsers"]) for parser, option in parser_map: arg_kwargs = {} def push_same(key): arg_kwargs[key] = option[key] def push_diff(tgt_key, src_key): arg_kwargs[tgt_key] = option[src_key] def push_val(key, val): arg_kwargs[key] = val push_same("help") push_same("required") if option["metavar"] is None: push_val("action", "store_true") else: push_same("default") push_same("type") if type(option["metavar"]) is list: push_diff("choices", "metavar") if type(option["metavar"]) is str: push_same("metavar") parser.add_argument(f"-{option["short"]}", f"--{option["long"]}", **arg_kwargs) args = main_parser.parse_args() # ------------------------------------------------------------------------------ # Define build class # ------------------------------------------------------------------------------ tempdir = TemporaryDirectory(prefix="salis_", delete=not args.keep_temp_dir) class Build: def __init__(self, name, library=False): self.srcfile = f"core/{name}.c" self.argfile = os.path.join(tempdir.name, f"{name}.arg") self.binfile = os.path.join(tempdir.name, f"{name}.{"so" if library else "bin"}") self.flags = { *args.compiler_flags.split(), *({"-shared", "-fPIC"} if library else set()), *({"-O3"} if args.optimized else {"-ggdb"}), } self.defines = {"-DNDEBUG"} if args.optimized else set() self.includes = set() self.links = set() self.build_cmd = [args.compiler, f"@{self.argfile}", self.srcfile, "-o", self.binfile] def build(self): fmt_nl = lambda l: f"{l}\n" fmt_include = lambda l: f"-include {l}\n" fmt_define = lambda l: f"{l.replace(" ", "\\ ").replace("\"", "\\\"").replace("'", "\\'")}\n" with open(self.argfile, "w") as f: f.writelines(map(fmt_nl, sorted(self.flags))) f.writelines(map(fmt_include, sorted(self.includes))) f.writelines(map(fmt_define, sorted(self.defines))) f.writelines(map(fmt_nl, sorted(self.links))) subprocess.run(self.build_cmd, check=True) # ------------------------------------------------------------------------------ # Bootstrap logging system # ------------------------------------------------------------------------------ logger_build = Build("logger", library=True) logger_build.build() logger_dll = ctypes.CDLL(logger_build.binfile) def info(msg): logger_dll.log_info(msg.encode()) def warn(msg): logger_dll.log_warn(msg.encode()) # ------------------------------------------------------------------------------ # Application start - source main configuration # ------------------------------------------------------------------------------ info(description) info(f"Called '{prog} {args.command}' with the following options: {json.dumps(vars(args), indent=2)}") info(f"With temporary directory: {tempdir.name}") info(f"With logging library: {logger_build.binfile}") info(f"Logging library built with command: '{" ".join(logger_build.build_cmd)}'") if args.command in ["new", "load", "server"]: info(f"With home directory: {args.home}") sim_dir = os.path.join(args.home, args.name) sim_opts = os.path.join(sim_dir, "opts.json") sim_path = os.path.join(sim_dir, args.name) if args.command in ["new"]: assert not args.data_push_pow or args.data_push_pow >= args.sync_pow, "Data push interval must be equal or greater than thread sync interval" if os.path.isdir(sim_dir) and args.force: warn(f"Force flag used! Wiping old simulation at: {sim_dir}") shutil.rmtree(sim_dir) assert not os.path.isdir(sim_dir), f"Simulation directory found at: {sim_dir} (use --force flag to remove)" if args.seed == -1: args.seed = random.getrandbits(64) info(f"Using random seed: {args.seed}") info(f"Creating new simulation directory at: {sim_dir}") info(f"Creating configuration file at: {sim_opts}") os.mkdir(sim_dir) opts = { option["long"]: getattr(args, option["long"].replace("-", "_")) for option in options if new in option["parsers"] and load not in option["parsers"] } with open(sim_opts, "w") as f: f.write(f"{json.dumps(opts, indent=2)}\n") if args.command in ["load", "server"]: assert os.path.isdir(sim_dir), f"No simulation found named {args.name}" with open(sim_opts, "r") as f: opts = json.loads(f.read()) for key, val in opts.items(): setattr(args, key.replace("-", "_"), val) info(f"Sourced configuration from: {sim_opts}: {json.dumps(opts, indent=2)}") # ------------------------------------------------------------------------------ # Load architecture variables # ------------------------------------------------------------------------------ if args.command in ["new", "load", "server"]: arch_path = os.path.join("arch", args.arch, "arch_vars.py") info(f"Loading architecture variables from: {arch_path}") arch_mod = SourceFileLoader("arch_vars", arch_path).load_module() arch_vars = arch_mod.ArchVars(args) # ------------------------------------------------------------------------------ # Compile ancestor organism # ------------------------------------------------------------------------------ if args.command in ["new", "load", "server"]: anc_path = os.path.join("anc", args.arch, f"{args.anc}.asm") assert os.path.isfile(anc_path), f"Could not find ancestor file: {anc_path}" with open(anc_path, "r") as file: lines = file.read().splitlines() lines = filter(lambda line: not line.startswith(";"), lines) lines = filter(lambda line: not line.isspace(), lines) lines = filter(lambda line: line, lines) lines = map(lambda line: line.split(), lines) anc_bytes = [] for line in lines: found = False for byte, tup in enumerate(arch_vars.inst_set): if line == tup[0]: anc_bytes.append(byte) found = True break assert found, f"Unrecognized instruction in ancestor file: {line}" anc_bytes_repr = ",".join(map(str, anc_bytes)) info(f"Compiled ancestor file '{anc_path}' into byte array: {{{anc_bytes_repr}}}") # ------------------------------------------------------------------------------ # Populate compiler flags # ------------------------------------------------------------------------------ if args.command in ["new", "load"]: ui_path = os.path.join("ui", args.ui, "ui_vars.py") info(f"Loading UI variables from: {ui_path}") ui_mod = SourceFileLoader("ui_vars", ui_path).load_module() ui_vars = ui_mod.UIVars(args) build = Build("salis") build.flags.update({*ui_vars.flags, f"-Iarch/{args.arch}", "-Icore", f"-Iui/{args.ui}"}) build.includes.update({*ui_vars.includes}) build.defines.update({*ui_vars.defines, f"-DTHREAD_GAP={args.thread_gap}"}) build.links.update({*ui_vars.links}) if args.data_push_pow: sim_db = os.path.join(sim_dir, f"{args.name}.sqlite3") build.defines.update({f"-DDATA_PUSH_PATH=\"{sim_db}\""}) build.includes.update({"sqlite3.h", "zlib.h"}) build.links.update({"-lsqlite3", "-lz"}) info(f"Data will be aggregated at: {sim_db}") else: warn("Data aggregation disabled") if not args.no_compress: build.includes.update({"zlib.h"}) build.links.update({"-lz"}) info("Save file compression enabled") else: warn("Save file compression disabled") if args.command in ["server"]: build = Build("server") build.defines.update({f"-DPORT={args.port}"}) build.links.update({"-ljson-c"}) if args.command in ["new", "load", "server"]: build.defines.update({ f"-DANC=\"{args.anc}\"", f"-DANC_BYTES={{{anc_bytes_repr}}}", f"-DANC_SIZE={len(anc_bytes)}", f"-DARCH=\"{args.arch}\"", f"-DAUTOSAVE_INTERVAL={2 ** args.auto_save_pow}ul", f"-DAUTOSAVE_NAME_LEN={len(sim_path) + 20}", f"-DCLONES={args.clones}", f"-DCOMMAND_{args.command.upper()}", f"-DCOMPRESS={1 if not args.no_compress else 0}", f"-DCORE_DATA_FIELDS={" ".join(f"CORE_DATA_FIELD({", ".join(field)})" for field in arch_vars.core_data_fields)}", f"-DCORE_FIELD_COUNT={len(arch_vars.core_fields)}", f"-DCORE_FIELDS={" ".join(f"CORE_FIELD({", ".join(field)})" for field in arch_vars.core_fields)}", f"-DCORES={args.cores}", f"-DDATA_PUSH={1 if args.data_push_pow else 0}", f"-DDATA_PUSH_INTERVAL={2 ** args.data_push_pow}ul", f"-DFOR_CORES={" ".join(f"FOR_CORE({i})" for i in range(args.cores))}", f"-DINST_COUNT={len(arch_vars.inst_set)}", f"-DINST_SET={" ".join(f"INST({index}, {"_".join(inst[0])}, \"{" ".join(inst[0])}\", L'{inst[1]}')" for index, inst in enumerate(arch_vars.inst_set))}", f"-DMUTA_FLIP={1 if args.muta_flip else 0}", f"-DMUTA_RANGE={2 ** args.muta_pow}ul", f"-DMVEC_LOOP={1 if arch_vars.mvec_loop else 0}", f"-DMVEC_SIZE={2 ** args.mvec_pow}ul", f"-DNAME=\"{args.name}\"", f"-DPROC_FIELD_COUNT={len(arch_vars.proc_fields)}", f"-DPROC_FIELDS={" ".join(f"PROC_FIELD({", ".join(field)})" for field in arch_vars.proc_fields)}", f"-DSEED={args.seed}ul", f"-DSIM_PATH=\"{sim_path}\"", f"-DSYNC_INTERVAL={2 ** args.sync_pow}ul", }) if args.command in ["client"]: build = Build("client") build.defines.update({f"-DIP=\"{args.ip}\"", f"-DPORT={args.port}"}) build.links.update({"-ljson-c"}) # ------------------------------------------------------------------------------ # Build and run executable # ------------------------------------------------------------------------------ info(f"Building binary with command: '{" ".join(build.build_cmd)}'") build.build() run_cmd = args.pre_cmd.split() if args.pre_cmd else [] run_cmd.append(build.binfile) info(f"Running binary with command: '{" ".join(run_cmd)}'") proc = subprocess.Popen(run_cmd, stdout=sys.stdout, stderr=sys.stderr) # When using signals (e.g. SIGTERM), they must be sent to the entire process group # to make sure both the simulator and the interpreter get shut down. try: proc.wait() except KeyboardInterrupt: proc.terminate() proc.wait() code = proc.returncode assert code == 0, f"Binary returned code: {code}"