aboutsummaryrefslogtreecommitdiff
path: root/bin/salis.py
diff options
context:
space:
mode:
authorPaul Oliver <contact@pauloliver.dev>2024-02-29 02:29:13 +0100
committerPaul Oliver <contact@pauloliver.dev>2024-02-29 02:29:13 +0100
commitca118555214a176728b9aab87849391344306d6d (patch)
tree833cffdd4066a7114b1d79d6eeaa2e0152408fc8 /bin/salis.py
Initial commit.
Diffstat (limited to 'bin/salis.py')
-rwxr-xr-xbin/salis.py346
1 files changed, 346 insertions, 0 deletions
diff --git a/bin/salis.py b/bin/salis.py
new file mode 100755
index 0000000..6dd82b0
--- /dev/null
+++ b/bin/salis.py
@@ -0,0 +1,346 @@
+#!/usr/bin/env python3
+
+""" SALIS: Viewer/controller for the SALIS simulator.
+
+File: salis.py
+Author: Paul Oliver
+Email: paul.t.oliver.design@gmail.com
+
+Main handler for the Salis simulator. The Salis class takes care of
+initializing, running and shutting down the simulator and other sub-modules. It
+also takes care of parsing the command-line arguments and linking to the Salis
+library with the help of ctypes.
+
+To execute this script, make sure to have python3 installed and in your path,
+as well as the cython package. Also, make sure it has correct execute
+permissions (chmod).
+"""
+
+import os
+import re
+import sys
+import time
+import traceback
+from argparse import ArgumentParser, HelpFormatter
+from ctypes import CDLL, c_bool, c_uint8, c_uint32, c_char_p, POINTER
+from handler import Handler
+from printer import Printer
+
+
+__version__ = "2.0"
+
+
+class Salis:
+ def __init__(self):
+ """ Salis constructor. Arguments are passed through the command line
+ and parsed with the 'argparse' module. Library is loaded with 'CDLL'
+ and C headers are parsed to detect function argument and return types.
+ """
+ self._path = self._get_path()
+ self._args = self._parse_args()
+ self._log = self._open_log_file()
+ self._save_file_path = self._get_save_file_path()
+ self._common_pipe = self._get_common_pipe()
+ self._lib = self._parse_lib()
+ self._printer = Printer(self)
+ self._handler = Handler(self)
+ self._state = "paused"
+ self._autosave = "---"
+ self._exit = False
+
+ # Based on CLI arguments, initialize a new Salis simulation or load
+ # existing one from file.
+ if self._args.action == "new":
+ self._lib.sal_main_init(
+ self._args.order, self._common_pipe.encode("utf-8")
+ )
+ elif self._args.action == "load":
+ self._lib.sal_main_load(
+ self._save_file_path.encode("utf-8"),
+ self._common_pipe.encode("utf-8")
+ )
+
+ def __del__(self):
+ """ Salis destructor.
+ """
+ # In case an error occurred early during initialization, checks whether
+ # Salis has been initialized correctly before attempting to shut it
+ # down.
+ if hasattr(self, "_lib") and hasattr(self._lib, "sal_main_quit"):
+ if self._lib.sal_main_is_init():
+ self._lib.sal_main_quit()
+
+ # If simulation ended correctly, 'error.log' should be empty. Delete
+ # file it exists and its empty.
+ if (
+ hasattr(self, "_log") and
+ os.path.isfile(self._log) and
+ os.stat(self._log).st_size == 0
+ ):
+ os.remove(self._log)
+
+ def run(self):
+ """ Runs main simulation loop. Curses may be placed on non-blocking
+ mode, which allows simulation to run freely while still listening to
+ user input.
+ """
+ while not self._exit:
+ self._printer.print_page()
+ self._handler.process_cmd(self._printer.get_cmd())
+
+ # If in non-blocking mode, re-print data once every 15
+ # milliseconds.
+ if self._state == "running":
+ end = time.time() + 0.015
+
+ while time.time() < end:
+ self._lib.sal_main_cycle()
+ self.check_autosave()
+
+ def toggle_state(self):
+ """ Toggle between 'paused' and 'running' states. On 'running' curses
+ gets placed in non-blocking mode.
+ """
+ if self._state == "paused":
+ self._state = "running"
+ self._printer.set_nodelay(True)
+ else:
+ self._state = "paused"
+ self._printer.set_nodelay(False)
+
+ def rename(self, new_name):
+ """ Give the simulation a new name.
+ """
+ self._args.file = new_name
+ self._save_file_path = self._get_save_file_path()
+
+ def set_autosave(self, interval):
+ """ Set the simulation's auto-save interval. When set to zero, auto
+ saving is disabled,
+ """
+ if not interval:
+ self._autosave = "---"
+ else:
+ self._autosave = interval
+
+ def check_autosave(self):
+ """ Save simulation to './sims/auto/*' whenever the autosave interval
+ is reached. We use the following naming convention for auto-saved files:
+
+ >>> ./sims/auto/<file-name>.<sim-epoch>.<sim-cycle>.auto
+ """
+ if self._autosave != "---":
+ if not self._lib.sal_main_get_cycle() % self._autosave:
+ auto_path = os.path.join(self._path, "sims/auto", ".".join([
+ self._args.file,
+ "{:08x}".format(self._lib.sal_main_get_epoch()),
+ "{:08x}".format(self._lib.sal_main_get_cycle()),
+ "auto"
+ ]))
+ self._lib.sal_main_save(auto_path.encode("utf-8"))
+
+ def exit(self):
+ """ Signal we want to exit the simulator.
+ """
+ self._exit = True
+
+ @property
+ def path(self):
+ return self._path
+
+ @property
+ def save_file_path(self):
+ return self._save_file_path
+
+ @property
+ def common_pipe(self):
+ return self._common_pipe
+
+ @property
+ def args(self):
+ return self._args
+
+ @property
+ def lib(self):
+ return self._lib
+
+ @property
+ def printer(self):
+ return self._printer
+
+ @property
+ def handler(self):
+ return self._handler
+
+ @property
+ def state(self):
+ return self._state
+
+ @property
+ def autosave(self):
+ return self._autosave
+
+ def _get_path(self):
+ """ Retrieve the absolute path of this script. We need to do this in
+ order to detect the './lib', './sims' and './genomes' subdirectories.
+ """
+ return os.path.dirname(__file__)
+
+ def _get_save_file_path(self):
+ """ Retrieve the absolute path of the file to which we will save this
+ simulation when we exit Salis.
+ """
+ return os.path.join(self._path, "sims", self._args.file)
+
+ def _get_common_pipe(self):
+ """ Get absolute path of the common pipe. This FIFO object may be used
+ by concurrent Salis simulations to share data between themselves.
+ """
+ return os.path.join(self._path, "common/pipe")
+
+ def _parse_args(self):
+ """ Parse command-line arguments with the 'argparse' module. To learn
+ more about each command, invoke the simulator in one of the following
+ ways:
+
+ (venv) $ python tsalis.py --help
+ (venv) $ python tsalis.py new --help
+ (venv) $ python tsalis.py load --help
+
+ """
+ # Custom formatter helps keep all help data aligned.
+ formatter = lambda prog: HelpFormatter(prog, max_help_position=30)
+
+ # Initialize the main parser with our custom formatter.
+ parser = ArgumentParser(
+ description="Viewer/controller for the Salis simulator.",
+ formatter_class=formatter
+ )
+ parser.add_argument(
+ "-v", "--version", action="version",
+ version="Salis: A-Life Simulator (" + __version__ + ")"
+ )
+
+ # Initialize the 'new/load' action subparsers.
+ subparsers = parser.add_subparsers(
+ dest="action", help="Possible actions..."
+ )
+ subparsers.required = True
+
+ # Set up subparser for the create 'new' action.
+ new_parser = subparsers.add_parser("new", formatter_class=formatter)
+ new_parser.add_argument(
+ "-o", "--order", required=True, type=lambda x: int(x, 0),
+ metavar="[1-31]", help="Create new simulation of given ORDER"
+ )
+ new_parser.add_argument(
+ "-f", "--file", required=True, type=str, metavar="FILE",
+ help="Name of FILE to save simulation to on exit"
+ )
+
+ # Set up subparser for the 'load' existing action.
+ load_parser = subparsers.add_parser("load", formatter_class=formatter)
+ load_parser.add_argument(
+ "-f", "--file", required=True, type=str, metavar="FILE",
+ help="Load previously saved simulation from FILE"
+ )
+
+ # Finally, parse all arguments.
+ args = parser.parse_args()
+
+ # Revise that parsed CL arguments are valid.
+ if args.action == "new":
+ if args.order not in range(1, 32):
+ parser.error("Order must be an integer between 1 and 31")
+ else:
+ savefile = os.path.join(self._path, "sims", args.file)
+
+ # No save-file with given name has been detected.
+ if not os.path.isfile(savefile):
+ parser.error(
+ "Save file provided '{}' does not exist".format(savefile)
+ )
+
+ return args
+
+ def _open_log_file(self):
+ """ Create a log file to store errors on. It will get deleted if no
+ errors are detected.
+ """
+ log_file = os.path.join(self._path, "error.log")
+ sys.stderr = open(log_file, "w")
+ return log_file
+
+ def _parse_lib(self):
+ """ Dynamically parse the Salis library C header files. We do this in
+ order to more easily set the correct input/output types of all loaded
+ functions. C functions to be parsed must be declared in a '.h' file
+ located on the '../include' directory, using the following syntax:
+
+ SALIS_API restype func_name(arg1_type arg1, arg2_type arg2);
+
+ Note to developers: the 'SALIS_API' keyword should *NOT* be used
+ anywhere else in the header files (not even in comments)!
+ """
+ lib = CDLL(os.path.join(self._path, "lib/libsalis.so"))
+ include_dir = os.path.join(self._path, "../include")
+ c_includes = [
+ os.path.join(include_dir, f)
+ for f in os.listdir(include_dir)
+ # Only parse '.h' header files.
+ if os.path.isfile(os.path.join(include_dir, f)) and f[-2:] == ".h"
+ ]
+ funcs_to_set = []
+
+ for include in c_includes:
+ with open(include, "r") as f:
+ text = f.read()
+
+ # Regexp to detect C functions to parse. This is a *very lazy*
+ # parser. So, if you want to expand/tweak Salis, be careful when
+ # declaring new functions!
+ funcs = re.findall(r"SALIS_API([\s\S]+?);", text, re.MULTILINE)
+
+ for func in funcs:
+ func = func.replace("\n", "")
+ func = func.replace("\t", "")
+ func = func.strip()
+ restype = func.split()[0]
+ name = func.split()[1].split("(")[0]
+ args = [
+ arg.split()[0]
+ for arg in func.split("(")[1].split(")")[0].split(",")
+ ]
+ funcs_to_set.append({
+ "name": name,
+ "restype": restype,
+ "args": args
+ })
+
+ # All Salis typedefs must be included here, associated to their CTYPES
+ # equivalents.
+ type_convert = {
+ "void": None,
+ "boolean": c_bool,
+ "uint8": c_uint8,
+ "uint8_p": POINTER(c_uint8),
+ "uint32": c_uint32,
+ "uint32_p": POINTER(c_uint32),
+ "string": c_char_p,
+ "Process": None,
+ }
+
+ # Finally, set correct arguments and return types of all Salis
+ # functions.
+ for func in funcs_to_set:
+ func["restype"] = type_convert[func["restype"]]
+ func["args"] = [type_convert[arg] for arg in func["args"]]
+ getattr(lib, func["name"]).restype = func["restype"]
+ getattr(lib, func["name"]).argtype = func["args"]
+
+ return lib
+
+if __name__ == "__main__":
+ """ Entry point...
+ """
+ Salis().run()