diff --git a/craftium/craftium_env.py b/craftium/craftium_env.py index 55ce821ec..ec1bd2414 100644 --- a/craftium/craftium_env.py +++ b/craftium/craftium_env.py @@ -2,7 +2,7 @@ from typing import Optional, Any import time -from .mt_client import MtClient +from .mt_channel import MtChannel from .minetest import Minetest import numpy as np @@ -35,6 +35,7 @@ class CraftiumEnv(Env): :param minetest_dir: Path to the craftium's minetest build directory. If not given, defaults to the directory where craftium is installed. This option is intended for debugging purposes. :param tcp_port: Port number used to communicate with minetest. If not provided a random free port in the range [49152, 65535] is selected. :param minetest_conf: Extra configuration options added to the default minetest.conf file generated by craftium. Setting options here will overwrite default values. Check [mintest.conf.example](https://github.com/minetest/minetest/blob/master/minetest.conf.example) for all available configuration options. + :param pipe_proc: If `True`, the minetest process stderr and stdout will be piped into two files inside the run's directory. Otherwise, the minetest process will not be piped and its output will be shown in the terminal. This option is disabled by default to reduce verbosity, but can be useful for debugging. """ metadata = {"render_modes": ["human", "rgb_array"], "render_fps": 30} @@ -53,6 +54,7 @@ def __init__( minetest_dir: Optional[str] = None, tcp_port: Optional[int] = None, minetest_conf: dict[str, Any] = dict(), + pipe_proc: bool = True, ): super(CraftiumEnv, self).__init__() @@ -88,10 +90,14 @@ def __init__( minetest_dir=minetest_dir, tcp_port=tcp_port, minetest_conf=minetest_conf, + pipe_proc=pipe_proc, ) - # variable initialized in the `reset` method - self.client = None # client that connects to minetest + self.mt_chann = MtChannel( + img_width=self.obs_width, + img_height=self.obs_height, + port=self.mt.port, + ) self.last_observation = None # used in render if "rgb_array" self.timesteps = 0 # the timesteps counter @@ -115,41 +121,23 @@ def reset( super().reset(seed=seed) self.timesteps = 0 - # kill the active mt process and the python client if any - if self.client is not None: - self.client.close() - self.mt.kill_process() + # close the active (if any) channel with mintest + self.mt_chann.close_conn() + # kill the active mt process if there's any + self.mt.kill_process() # start the new MT process - self.mt.start_process() # launch the new MT process - - # connect the client to the MT process - try: - self.client = MtClient( - img_width=self.obs_width, - img_height=self.obs_height, - port=self.mt.port, - ) - except Exception as e: - print("\n\n[!] Error connecting to Minetest. Minetest probably failed to launch.") - print(" => Run's scratch directory should be available, containing stderr.txt and stdout.txt useful for checking what went wrong.") - print(" Content of the stderr.txt file in the run's sratch directory:") - print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n") - with open(f"{self.mt.run_dir}/stderr.txt", "r") as f: - print(f.read()) - print("\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~") - print("The raised exception (in case it's useful):") - print("~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n") - print(e) - quit(1) + self.mt.start_process() + # open communication channel with minetest + self.mt_chann.open_conn() # HACK skip some frames to let the game initialize for _ in range(self.init_frames): - _observation, _reward, _term = self.client.receive() - self.client.send([0]*21, 0, 0) # nop action + _observation, _reward, _term = self.mt_chann.receive() + self.mt_chann.send([0]*21, 0, 0) # nop action - observation, _reward, _term = self.client.receive() + observation, _reward, _term = self.mt_chann.receive() self.last_observation = observation info = self._get_info() @@ -165,7 +153,7 @@ def step(self, action): """ self.timesteps += 1 - # convert the action dict to a format to be sent to MT through mt_client + # convert the action dict to a format to be sent to MT through mt_chann keys = [0]*21 # all commands (keys) except the mouse mouse_x, mouse_y = 0, 0 for k, v in action.items(): @@ -176,10 +164,10 @@ def step(self, action): else: keys[ACTION_ORDER.index(k)] = v # send the action to MT - self.client.send(keys, mouse_x, mouse_y) + self.mt_chann.send(keys, mouse_x, mouse_y) # receive the new info from minetest - observation, reward, termination = self.client.receive() + observation, reward, termination = self.mt_chann.receive() self.last_observation = observation info = self._get_info() @@ -193,8 +181,6 @@ def render(self): return self.last_observation def close(self): - if self.client is not None: - self.client.close() - + self.mt_chann.close() self.mt.kill_process() self.mt.clear() diff --git a/craftium/minetest.py b/craftium/minetest.py index 99cdd974d..6c676b11d 100644 --- a/craftium/minetest.py +++ b/craftium/minetest.py @@ -13,22 +13,6 @@ def is_port_in_use(port: int) -> bool: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: return s.connect_ex(('localhost', port)) == 0 - -def launch_process(cmd: str, cwd: Optional[os.PathLike] = None, env_vars: dict[str, str] = dict()): - def launch_fn(): - # set env vars - for key, value in env_vars.items(): - os.environ[key] = value - - # open files for piping stderr and stdout into - stderr = open(os.path.join(cwd, "stderr.txt"), "a") - stdout = open(os.path.join(cwd, "stdout.txt"), "a") - - subprocess.run(cmd, cwd=cwd, stderr=stderr, stdout=stdout) - process = multiprocessing.Process(target=launch_fn, args=[]) - process.start() - return process - def is_minetest_build_dir(path: os.PathLike) -> bool: # list of directories required by craftium to exist in the a minetest build directory req_dirs = ["builtin", "fonts", "locale", "textures", "bin", "client"] @@ -53,7 +37,10 @@ def __init__( minetest_dir: Optional[str] = None, tcp_port: Optional[int] = None, minetest_conf: dict[str, Any] = dict(), + pipe_proc: bool = True, ): + self.pipe_proc = pipe_proc + # create a dedicated directory for this run if run_dir is None: self.run_dir = f"./minetest-run-{uuid4()}" @@ -147,22 +134,43 @@ def __init__( "--worldname", world_name, ] - self.proc = None # will hold the mintest's process + self.proc = None # holds mintest's process + self.stderr, self.stdout = None, None self.mt_env = {} if headless: self.mt_env["SDL_VIDEODRIVER"] = "offscreen" def start_process(self): - self.proc = launch_process( - self.launch_cmd, - self.run_dir, - env_vars=self.mt_env - ) + if self.pipe_proc: + # open files for piping stderr and stdout into + self.stderr = open(os.path.join(self.run_dir, "stderr.txt"), "a") + self.stdout = open(os.path.join(self.run_dir, "stdout.txt"), "a") + + def launch_fn(): + # set env vars + for key, value in self.mt_env.items(): + os.environ[key] = value + # launch the process (pipeing stderr and stdout if necessary) + if self.pipe_proc: + subprocess.run(self.launch_cmd, cwd=self.run_dir, stderr=self.stderr, stdout=self.stdout) + else: + subprocess.run(self.launch_cmd, cwd=self.run_dir) + + process = multiprocessing.Process(target=launch_fn, args=[]) + process.start() + self.proc = process def kill_process(self): + # close the files where the process is being piped + # into berfore the process itself + if self.stderr is not None: + self.stderr.close() + if self.stdout is not None: + self.stdout.close() + if self.proc is not None: - self.proc.terminate() + self.proc.kill() def clear(self): # delete the run's directory diff --git a/craftium/mt_client.py b/craftium/mt_channel.py similarity index 67% rename from craftium/mt_client.py rename to craftium/mt_channel.py index da0eb9af8..cd7046e00 100644 --- a/craftium/mt_client.py +++ b/craftium/mt_channel.py @@ -5,30 +5,19 @@ import numpy as np -MT_IP = "127.0.0.1" -MT_DEFAULT_PORT = 4343 +MT_DEFAULT_PORT = 55555 -class MtClient(): +class MtChannel(): def __init__(self, img_width: int, img_height: int, port: Optional[int] = None, connect_timeout: int = 30): self.img_width = img_width self.img_height = img_height - # create client's socket self.s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.s.settimeout(10) - - # make some trials to connect to the minetest server - trial_start = time.time() - while True: - time.sleep(0.1) # wait some time after each trial - try: - self.s.connect((MT_IP, MT_DEFAULT_PORT if port is None else port)) - break - except Exception as e: - # check if the timeout is reached - if (time.time() - trial_start) >= connect_timeout: - print("[*] Craftium client reached timeout while waiting for minetest's server") - raise e + self.s.settimeout(30) + self.s.bind(("127.0.0.1", MT_DEFAULT_PORT if port is None else port)) + + # initialized in `reset_connection` + self.conn = None # pre-compute the number of bytes that we should receive from MT. # the RGB image + 8 bytes of the reward + 1 byte of the termination flag @@ -37,7 +26,7 @@ def __init__(self, img_width: int, img_height: int, port: Optional[int] = None, def receive(self): data = [] while len(data) < self.rec_bytes: - data += self.s.recv(self.rec_bytes) + data += self.conn.recv(self.rec_bytes) data = data[:self.rec_bytes] # reward bytes (8) + termination bytes (1) @@ -66,7 +55,19 @@ def send(self, keys: list[int], mouse_x: int, mouse_y: int): mouse = list(struct.pack("getU16("server_map_save_interval"); m_mesh_grid = { g_settings->getU16("client_mesh_chunk") }; - startPyServer(); + startPyConn(); } -void Client::startPyServer() +void Client::startPyConn() { // Get the craftium port from the config file - pyserv_port = g_settings->getU32("craftium_port"); + py_port = g_settings->getU32("craftium_port"); - printf("[*] Minetest using port %d to communicate with craftium\n", pyserv_port); + printf("[*] Minetest using port %d to communicate with craftium\n", py_port); // Create socket file descriptor - if ( (pyserv_sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0 ) { - perror("[ERROR] Obs. server socket creation failed"); + if ( (py_sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0 ) { + perror("[ERROR] PyConn socket creation failed"); exit(EXIT_FAILURE); } - int opt = 1; + py_servaddr = (struct sockaddr_in*) malloc(sizeof(struct sockaddr_in)); - // Forcefully attaching socket to the port - if (setsockopt(pyserv_sockfd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) { - perror("[ERROR] Failed to set SO_REUSEADDR in server's socket"); - close(pyserv_sockfd); - exit(EXIT_FAILURE); - } - - pyserv_servaddr = (struct sockaddr_in*) malloc(sizeof(struct sockaddr_in)); - pyserv_cliaddr = (struct sockaddr_in*) malloc(sizeof(struct sockaddr_in)); - - memset(pyserv_servaddr, 0, sizeof(*pyserv_servaddr)); - memset(pyserv_cliaddr, 0, sizeof(*pyserv_cliaddr)); + memset(py_servaddr, 0, sizeof(*py_servaddr)); - pyserv_servaddr->sin_family = AF_INET; // IPv4 - pyserv_servaddr->sin_addr.s_addr = INADDR_ANY; - pyserv_servaddr->sin_port = htons(pyserv_port); - - // Bind the socket with the server address - if (bind(pyserv_sockfd, - (const struct sockaddr *)pyserv_servaddr, - sizeof(*pyserv_servaddr)) < 0) - { - perror("[ERROR] Obs. server bind failed"); - exit(EXIT_FAILURE); - } + py_servaddr->sin_family = AF_INET; // IPv4 + py_servaddr->sin_addr.s_addr = inet_addr("127.0.0.1"); + py_servaddr->sin_port = htons(py_port); - // Now server is ready to listen and verification - if ((listen(pyserv_sockfd, 5)) != 0) { - printf("[ERROR] Obs. server listen failed...\n"); - exit(EXIT_FAILURE); - } - else - printf("[INFO] Obs. server listening...\n"); - - // Accept the data packet from client and verification - socklen_t len = sizeof(*pyserv_cliaddr); - pyserv_conn = accept(pyserv_sockfd, (struct sockaddr*)pyserv_cliaddr, &len); - if (pyserv_conn < 0) { - printf("[ERROR] Obs. server accept failed...\n"); - exit(EXIT_FAILURE); - } - else - printf("[INFO] Obs. server accepted the client\n"); - - // Set receive and send timeout on the socket - struct timeval timeout; - timeout.tv_sec = 2; /* timeout time in seconds */ - timeout.tv_usec = 0; - if (setsockopt(pyserv_conn, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout, sizeof(timeout)) < 0) { - printf("[ERROR] setsockopt failed\n"); - exit(EXIT_FAILURE); - } - if (setsockopt(pyserv_conn, SOL_SOCKET, SO_SNDTIMEO, (const char*)&timeout, sizeof(timeout)) < 0) { - printf("[ERROR] setsockopt failed\n"); + // sending connection request + if (::connect(py_sockfd, (struct sockaddr*)py_servaddr, sizeof(struct sockaddr_in)) < 0) { + perror("[ERROR] PyConn failed to connect to server"); exit(EXIT_FAILURE); } - printf("\n[INFO] Obs. server started in port %d\n\n", pyserv_port); + printf("\n[INFO] PyConn started in port %d\n\n", py_port); } -void Client::pyServerListener() { +void Client::pyConnStep() { char actions[25]; int n_send, n_recv, W, H, obs_rwd_buffer_size; u32 c; // stores the RGBA pixel color @@ -292,10 +247,10 @@ void Client::pyServerListener() { } /* Send the obs_rwd_buffer over TCP to Python */ - n_send = send(pyserv_conn, obs_rwd_buffer, obs_rwd_buffer_size, 0); + n_send = send(py_sockfd, obs_rwd_buffer, obs_rwd_buffer_size, 0); /* Receive a buffer of bytes with the actions to take */ - n_recv = recv(pyserv_conn, &actions, sizeof(actions), 0); + n_recv = recv(py_sockfd, &actions, sizeof(actions), 0); virtual_key_presses[KeyType::FORWARD] = actions[0]; virtual_key_presses[KeyType::BACKWARD] = actions[1]; @@ -754,7 +709,7 @@ void Client::step(float dtime) */ LocalPlayer *player = m_env.getLocalPlayer(); - pyServerListener(); + pyConnStep(); // Step environment (also handles player controls) m_env.step(dtime); diff --git a/src/client/client.h b/src/client/client.h index 4a8bb008c..e0eb6b6b3 100644 --- a/src/client/client.h +++ b/src/client/client.h @@ -489,15 +489,13 @@ class Client : public con::PeerHandler, public InventoryManager, public IGameDef MtEventManager *m_event; RenderingEngine *m_rendering_engine; - /* Python API server related */ - int pyserv_port = 0; /* Port value selected in the startPyServer method */ - int pyserv_sockfd = 0; - int pyserv_conn = 0; - struct sockaddr_in *pyserv_servaddr = nullptr; - struct sockaddr_in *pyserv_cliaddr = nullptr; + /* Craftium's communication channel related */ + int py_port = 0; + int py_sockfd = 0; + struct sockaddr_in *py_servaddr = nullptr; unsigned char *obs_rwd_buffer = 0; - void startPyServer(); - void pyServerListener(); + void startPyConn(); + void pyConnStep(); std::unique_ptr m_mesh_update_manager; ClientEnvironment m_env;