DarkCubes.net

Changes to cuwo

Changes made to the cuwo server code

cuwo is the open-source Cube World server software used by DarkCubes. Its latest public release dates from 2018.

This page documents the changes made to the cuwo codebase, the reason for each change, and the corresponding diff.

These changes are limited to the server software. They are not intended to alter terrain, loot, combat, or progression, with the goal of preserving the original 2013 Alpha gameplay.

Security09 changes

Pre-login connection handling

Holes that let an unauthenticated or hostile client burn server resources, impersonate another player, or run code on the machine. Every one of these was reachable over plain TCP, before any login.

Gameplay packets accepted before joining

cuwo/server.py

Why it changed

The packet dispatcher ran every handler regardless of whether the client had finished the handshake. A socket that had sent nothing but a version number could already drop items, interact with chunks, fire hit packets and drive world state.

The worst of these was interact: it queued terrain generation, so a client that never joined could keep the generation thread busy indefinitely.

Only two packets are meaningful before joining — the version handshake, and the entity update that carries the player name and triggers the join. Everything else is now dropped, with one log line per connection so the console does not fill up.

Code changes
@@ -42,6 +44,13 @@
 join_packet = packets.JoinPacket()
 seed_packet = packets.SeedData()
 chat_packet = packets.ServerChatMessage()
+
+
+# Packets a client may send before it has joined: the version handshake, and
+# the entity update that carries the player name (which is what triggers
+# on_join). Everything else is rejected until the join completes.
+PREJOIN_PACKETS = frozenset((packets.ClientVersion.packet_id,
+                             packets.EntityUpdate.packet_id))
 entity_packet = packets.EntityUpdate()
@@ -128,6 +137,7 @@
     has_joined = False
+    prejoin_warned = False
     entity_id = None
@@ -253,6 +263,18 @@
         if packet is None:
             self.on_invalid_packet('data')
             return
+        # Gameplay packets are only meaningful once the client has completed
+        # the handshake and joined. Accepting them earlier let an unjoined
+        # socket drive world state -- most seriously, interact packets could
+        # queue terrain generation for arbitrary chunks. Only the version
+        # handshake and the entity update that carries the name (which is
+        # what triggers on_join) are valid before joining.
+        if not self.has_joined and packet.packet_id not in PREJOIN_PACKETS:
+            if not self.prejoin_warned:
+                self.prejoin_warned = True
+                print('Ignoring pre-join packet %s from %s'
+                      % (packet.packet_id, self.address[0]))
+            return
         handler = self.packet_handlers.get(packet.packet_id, None)

Interact packets could force unlimited terrain generation

cuwo/server.py

Why it changed

Both interact paths resolved the target chunk with world.get_chunk(), which does not look a chunk up — it creates one and queues it for generation, for any coordinate pair it is handed.

The coordinates come straight off the wire. A client sending interact packets with rising coordinates could queue unbounded terrain generation: roughly a second of CPU and a few megabytes of RAM per chunk, on a server with one generation thread.

Interaction now resolves against chunks that are already loaded, after a bounds check. A player can only interact with terrain that exists.

Code changes
@@ -357,19 +379,43 @@
         elif interact_type == packets.INTERACT_PICKUP:
-            chunk = self.world.get_chunk((packet.chunk_x, packet.chunk_y))
+            chunk = self.get_interact_chunk(packet)
+            if chunk is None:
+                return
             try:
                 item = chunk.remove_item(packet.item_index)
             except IndexError:
                 return
             self.give_item(item)
         elif interact_type == packets.INTERACT_NORMAL:
-            chunk = self.world.get_chunk((packet.chunk_x, packet.chunk_y))
+            chunk = self.get_interact_chunk(packet)
+            if chunk is None:
+                return
             try:
                 chunk.get_entity(packet.item_index).interact(self)
             except KeyError:
                 return
 
+    def get_interact_chunk(self, packet):
+        """Resolve the chunk an interact packet refers to.
+
+        Returns None if the coordinates are out of world bounds or the chunk
+        is not currently loaded. Deliberately does NOT use world.get_chunk(),
+        which creates and queues generation for any coordinate pair: that let
+        a client force unbounded terrain generation (~seconds of CPU and
+        megabytes of RAM each) just by sending interact packets. A player can
+        only legitimately interact with a chunk that is already resident.
+        """
+        pos = (packet.chunk_x, packet.chunk_y)
+        if not validate_chunk_pos(pos[0], pos[1]):
+            return None
+        return self.world.chunks.get(pos, None)

Negative item index stole the last item in a chunk

cuwo/server.py

Applies on top of interact packets could force unlimited terrain generation, which rewrote the same block.

Why it changed

The item index in a pickup packet is a signed 32-bit integer, passed straight to list.pop(). Python lists accept negative indices, so pop(-1) quietly removed the last item in the chunk — no error, no bounds failure, and the item went to whoever asked.

The existing IndexError guard never fired, because there was no error to catch. Negative indices are now rejected before the lookup.

Code changes
@@ -357,6 +379,14 @@
             chunk = self.get_interact_chunk(packet)
             if chunk is None:
                 return
+            # item_index is a signed int32 straight off the wire, and
+            # list.pop() accepts negatives, so pop(-1) would quietly take the
+            # last item in the chunk. Only non-negative indices are valid.
+            if packet.item_index < 0:
+                return
             try:
                 item = chunk.remove_item(packet.item_index)
             except IndexError:
                 return

Actions could be attributed to another player

cuwo/server.py

Why it changed

Shoot and passive packets carry an entity_id field, and the server rebroadcast that field to every other client untouched.

A modified client could therefore put someone else’s entity id on its own projectiles and abilities, and every other player would see that player firing them. The server now overwrites the field with the id of the connection the packet actually arrived on.

Code changes
@@ -396,10 +442,17 @@
     def on_shoot_packet(self, packet):
+        # entity_id arrives from the client and is rebroadcast to everyone
+        # untouched, so a crafted client could attribute its actions to
+        # another player. Stamp our own id over it.
+        packet.entity_id = self.entity_id
         self.server.update_packet.shoot_actions.append(packet)
 
     def on_passive_packet(self, packet):
+        packet.entity_id = self.entity_id
         self.world.add_passive(packet)

Decompression bomb in entity updates

cuwo/packet.py

Why it changed

Entity data arrives zlib-compressed and was handed to zlib.decompress(), which has no output limit. A few kilobytes of crafted input expands to gigabytes, and the process is killed by the OOM reaper before anything else in the server gets a say.

A real entity blob is under 3 KB. Decompression is now incremental with a 256 KB ceiling, and anything that does not fit is rejected as a malformed packet.

Bounding the output is not on its own enough to establish that a stream is well formed. A truncated stream consumes all of its input, so the size check above never fires, and the decompressor returns a partial body that looks valid — one with its final byte removed was accepted. Trailing bytes after a complete stream, including a second zlib member concatenated onto the first, were accepted the same way. Both are now rejected, along with a body too short to hold the entity id and field mask that must open it.

Code changes
@@ -117,7 +117,33 @@
 class EntityUpdate(Packet):
     def read(self, reader):
         size = reader.read_uint32()
-        self.data = zlib.decompress(reader.read(size))
+        raw = reader.read(size)
+        # zlib.decompress() has no output limit, so a few KB of crafted input
+        # can expand to gigabytes and exhaust memory before anything else in
+        # the server gets a say. A full entity blob is under 3 KB.
+        obj = zlib.decompressobj()
+        try:
+            self.data = obj.decompress(raw, MAX_ENTITY_DATA)
+        except zlib.error:
+            raise InvalidPacket('corrupt entity data')
+        if obj.unconsumed_tail:
+            raise InvalidPacket('oversized entity data')
+        if not obj.eof:
+            # All the input was consumed but the stream never reached its
+            # end marker, so unconsumed_tail is empty and the check above
+            # does not fire. The body is truncated and self.data holds only
+            # part of an entity.
+            raise InvalidPacket('truncated entity data')
+        if obj.unused_data:
+            # A complete stream followed by further bytes: trailing padding,
+            # or a second zlib member concatenated onto the first. The
+            # client sends neither.
+            raise InvalidPacket('trailing entity data')
+        if len(self.data) < MIN_ENTITY_DATA:
+            # Too short to hold the entity id and the field mask that must
+            # follow it. Rejecting here names the fault instead of leaving
+            # it to a short read further down.
+            raise InvalidPacket('undersized entity data')
         reader = ByteReader(self.data)

Chat length and receive buffer had no ceiling

cuwo/packet.py

Why it changed

Two related holes in the parser.

A chat packet declares its length as an unaudited 32-bit count, and the server then waits for twice that many bytes. A client could declare four billion characters and the connection would sit there holding the buffer open.

Separately, an incomplete packet is retained until the rest of it arrives — correct for TCP, but with no upper bound. A client could stream bytes that never complete a packet and grow that buffer until the server ran out of memory.

Chat is now capped at 4096 characters and the pending buffer at 512 KB, both far above anything the real client sends.

Code changes
@@ -620,6 +646,28 @@
         writer.write_uint32(self.something28)
 
 
+# Protocol sanity limits. A full entity blob measures under 3 KB and no real
+# chat line runs to thousands of characters, so these sit far above anything
+# legitimate while keeping a hostile client bounded.
+MAX_ENTITY_DATA = 256 * 1024
+MAX_CHAT_CHARS = 4096
+MAX_BUFFER = 512 * 1024
+
+# An entity blob opens with an 8 byte entity id followed by an 8 byte field
+# mask, so nothing shorter can be parsed at all. This is a lower bound only:
+# the exact length is mask driven, and an exact check that is even slightly
+# wrong would disconnect legitimate players, so it is not made here.
+MIN_ENTITY_DATA = 16
+
+
+class InvalidPacket(Exception):
+    """A packet is malformed beyond recovery and the client should go away.
+
+    Distinct from OutOfData, which only means 'not all the bytes have
+    arrived yet' and causes the data to be retained until they do.
+    """
+
+
 ENCODING = 'utf_16_le'
@@ -640,6 +688,10 @@
 class ClientChatMessage(Packet):
     def read(self, reader):
         size = reader.read_uint32()
+        # size is an unaudited uint32 and the read is size*2 bytes, so a
+        # crafted value makes the server wait for gigabytes that never come.
+        if size > MAX_CHAT_CHARS:
+            raise InvalidPacket('oversized chat message')
         data = reader.read(size * 2)
@@ -728,6 +780,13 @@
     def feed(self, data):
         self.data += data
+        # An incomplete packet is retained until the rest arrives. Without a
+        # ceiling, a client can stream bytes that never complete a packet and
+        # grow this buffer without bound.
+        if len(self.data) > MAX_BUFFER:
+            self.data = b''
+            self.callback(None)
+            return
         reader = ByteReader(self.data)

Saved data files were executed, not parsed

cuwo/server.py

Why it changed

Ban lists and similar save files were read back with eval(). Those files hold player names and ban reasons — text that came from outside the server — and eval() runs whatever it is handed rather than reading it as data.

A name containing Python source would execute on the next server start, as the server user. Replaced with ast.literal_eval(), which only ever builds plain data structures, plus an error path so one bad file no longer stops the boot.

Code changes
@@ -847,7 +999,14 @@
                 data = fp.read()
         except IOError:
             return default
-        return eval(data)
+        # These files hold player names and ban reasons, i.e. text that
+        # originated outside the server. eval() would execute anything it
+        # was handed; literal_eval only ever builds plain data structures.
+        try:
+            return ast.literal_eval(data)
+        except (ValueError, SyntaxError) as e:
+            print('Could not parse %s: %s' % (path, e))
+            return default

Connection flood protection banned addresses forever

scripts/ddos.py

Why it changed

The flood guard put any address that opened too many connections into a permanent in-memory set. There was no expiry and no way to clear it short of restarting the server.

On a shared address — a household, a university, anyone behind carrier-grade NAT — one person tripping the limit locked out everybody else on that address for the life of the process. Blocks now expire after five minutes.

Code changes
@@ -45,11 +45,27 @@
     def on_load(self):
-        self.hard_bans = set()
+        # host -> unix time the block expires. Previously a permanent set,
+        # which meant one user behind a shared address (a household, or any
+        # carrier-grade NAT) could lock out everyone else on that address
+        # for the remaining life of the process.
+        self.hard_bans = {}
+
+    def get_ban_time(self):
+        return getattr(self.server.config.base, 'connection_ban_time', 300)
+
+    def is_banned(self, host):
+        expiry = self.hard_bans.get(host)
+        if expiry is None:
+            return False
+        if self.loop.time() >= expiry:
+            del self.hard_bans[host]
+            return False
+        return True
 
     def on_connection_attempt(self, event):
         host = event.address[0]
-        if host in self.hard_bans:
+        if self.is_banned(host):
             return False
@@ -60,8 +76,10 @@
-        print('Too many connections from %s, closing...' % host)
-        self.hard_bans.add(host)
+        ban_time = self.get_ban_time()
+        print('Too many connections from %s, blocking for %ds...'
+              % (host, ban_time))
+        self.hard_bans[host] = self.loop.time() + ban_time
         return False

Terrain cleanup crashed the server from the wrong thread

cuwo/world.py

Applies to a stock checkout on its own, needs no other change here, and needs no recompilation. Ground loot came back, and multiplied on its own applies on top of it.

Why it changed

cuwo runs the emulated 2013 server on two threads: the main game loop, and one background thread that generates terrain. The emulated code is not thread safe, and cuwo guards it with a single lock — but only around the places where a call into it is written as a call.

Retiring a chunk drops the last reference to its generated terrain, and releasing that object runs a destructor that calls into the terrain generator. An ordinary assignment was therefore a hidden entry into non-thread-safe code with no lock held. If the generation thread was freeing a region at that moment, both threads were inside the same structures and the process died with a segmentation fault.

Ordinary play keeps that window narrow. Anything that retires many chunks at once while the generator is busy — a client moving far faster than the game permits — widens it until the crash stops being rare and becomes reliable. It was demonstrated against the live server.

The terrain object is now handed to the generation thread, which releases it inside the lock. An audit of the same file found five further calls into the generator made from the main thread without it, and those take the lock as well. Chunk generation itself deliberately stays outside it: holding the lock for a full generation would stall the world for every player.

Shutdown had the same shape in miniature. Stopping the server emptied the queue of pending generation work from the main thread — harmless when that queue held nothing but instructions, but the fix above puts retired terrain into it. Emptying the queue therefore dropped the last reference to that terrain on the main thread, running the same destructor outside the lock while the generation thread could still be working. The pending items are now detached and handed over instead, and the generation thread releases them under the lock like every other release.

Code changes
@@ -129,7 +129,8 @@
         self.make_standalone_copy()
-        tgen.remove_creature(self.creature)
+        with self.world.chunk_lock:
+            tgen.remove_creature(self.creature)
         self.unlink()
@@ -232,7 +233,8 @@
     def update_data(self):
         if self.data_ref_count == 0:
             return
-        self.data = tgen.get_region(*self.pos)
+        with self.world.chunk_lock:
+            self.data = tgen.get_region(*self.pos)
@@ -281,6 +283,15 @@
+def generate_chunk(x, y):
+    """Runs on the generation thread. The ZoneData is wrapped in a list so
+    that the main thread never becomes the last owner of it: releasing one
+    calls into tgen, which may only happen from the generation thread or
+    while holding chunk_lock. See World.release_data.
+    """
+    return [tgen.generate(x, y)]
+
+
 class Chunk:
@@ -372,10 +383,22 @@
         if self.has_generation:
-            def destroy_chunk(lock, pos):
+            # ZoneData.__dealloc__ calls tgen_destroy_chunk. Dropping the
+            # last reference here would run that call on the main thread
+            # without chunk_lock, concurrently with whatever the generation
+            # thread is doing inside tgen (segfault). Hand the object to the
+            # generation thread instead and let it drop the reference while
+            # holding the lock. The list is the only strong reference left
+            # once self.data is cleared below.
+            holder = [self.data]
+            self.data = None
+
+            def destroy_chunk(lock, pos, holder):
                 with lock:
+                    holder[0] = None
                     tgen.destroy_chunk(*pos)
-            self.world.call_gen(destroy_chunk, self.world.chunk_lock, self.pos)
+            self.world.call_gen(destroy_chunk, self.world.chunk_lock,
+                                self.pos, holder)
@@ -460,7 +483,8 @@
-        creature = tgen.add_creature(entity_id)
+        with self.chunk_lock:
+            creature = tgen.add_creature(entity_id)
@@ -507,11 +531,28 @@
         def on_chunk(f):
-            data = f.result()
-            chunk.on_gen(data)
+            # The result is a one element list, not the ZoneData itself: if
+            # the chunk was retired while it was generating, on_gen() drops
+            # the data and nothing else owns it. Releasing it here would run
+            # ZoneData.__dealloc__ (which calls into tgen) on the main
+            # thread without chunk_lock, so the release is handed back to
+            # the generation thread. Do not bind the ZoneData to a local.
+            holder = f.result()
+            chunk.on_gen(holder[0])
+            self.release_data(holder)
             self.generating = None
             self.update_chunk_queue()
-        self.call_gen(tgen.generate, *chunk.pos).add_done_callback(on_chunk)
+        self.call_gen(generate_chunk, *chunk.pos).add_done_callback(on_chunk)
+
+    def release_data(self, holder):
+        """Drop a reference to a tgen backed object on the generation thread
+        while holding chunk_lock. If something else still owns the object
+        (a live chunk adopted it) this is a no-op beyond clearing the list.
+        """
+        def release(lock, holder):
+            with lock:
+                holder[0] = None
+        self.call_gen(release, self.chunk_lock, holder)
@@ -528,7 +569,8 @@
-        tgen.set_in_packets(hits, passives)
+        with self.chunk_lock:
+            tgen.set_in_packets(hits, passives)
@@ -542,7 +584,8 @@
         with self.chunk_lock:
             tgen.step(int(dt * 1000.0))
 
-        creatures = tgen.get_creatures()
+        with self.chunk_lock:
+            creatures = tgen.get_creatures()
@@ -573,7 +616,8 @@
-        out_packets = tgen.get_out_packets()
+        with self.chunk_lock:
+            out_packets = tgen.get_out_packets()
@@ -584,10 +628,23 @@
     def stop(self):
+        # Queued work items can own a ZoneData (see Chunk.destroy and
+        # release_data). Clearing the queue here would drop the last
+        # reference on the main thread, running ZoneData.__dealloc__ and its
+        # tgen_destroy_chunk call outside chunk_lock while the generation
+        # thread may still be inside tgen. Detach the pending items instead
+        # and hand them to the generation thread, which drops them under the
+        # lock like every other release.
+        def release_pending(lock, pending):
+            with lock:
+                pending.clear()
+
         with self.gen_queue.mutex:
+            pending = list(self.gen_queue.queue)
             self.gen_queue.queue.clear()
+        self.call_gen(release_pending, self.chunk_lock, pending)
         self.gen_queue.put(None)

Stability & bugs09 changes

Crashes, wedges, and one missing letter

Connections that locked up until the player gave up, a reload that quietly did nothing, and a typo that had been swallowing every error message the server ever wrote.

Passive abilities looped forever

cuwo/server.py

First of three changes to the same broadcast loop. The diff below is this change on its own; one serialization per player, per tick and every pickup was announced to every player rewrite it again, and the third is what runs today.

Why it changed

The long-standing passive ability bug, reported upstream as issue #197. The server broadcast every passive action to every player, including back to the player who sent it. That client treats the echo as a new instruction, restarts the ability, and sends it again — a loop that never ends and grows with each player doing it.

The server now records which connection each passive arrived on and sends every player an update with their own passives removed.

Code changes
@@ -396,10 +442,17 @@
     def on_passive_packet(self, packet):
         packet.entity_id = self.entity_id
         self.world.add_passive(packet)
+        # remember who sent this passive so we never echo it back to them
+        self.server.received_passives[packet] = self
         self.server.update_packet.passive_actions.append(packet)
@@ -515,6 +568,7 @@
         # game-related
         self.update_packet = packets.ServerUpdate()
         self.update_packet.reset()
+        self.received_passives = dict()
@@ -728,7 +811,20 @@
         for chunk in self.updated_chunks:
             chunk.on_update(update_packet)
         if not update_packet.is_empty():
-            self.broadcast_packet(update_packet)
+            # A client that receives its own passive action back will restart
+            # the ability and resend it, looping forever (issue #197). Send a
+            # per-player copy of the update with that player's own passives
+            # filtered out. passive_actions is rewritten each time round, so
+            # keep a reference to the original list and restore it after.
+            old_passives = update_packet.passive_actions
+            for player in self.players.values():
+                update_packet.passive_actions = [
+                    x for x in old_passives
+                    if self.received_passives.get(x, None) != player]
+                data = packets.write_packet(update_packet)
+                player.send_data(data)
+            update_packet.passive_actions = old_passives
+        self.received_passives.clear()
         update_packet.reset()

One malformed packet wedged the connection permanently

cuwo/packet.py

Needs the InvalidPacket exception added by chat length and receive buffer had no ceiling.

Why it changed

The parser only handled “not enough bytes yet”. Anything that was malformed rather than incomplete — an impossible length field, or a text field that is not valid UTF-16, such as a lone surrogate in a chat message — raised out of the feed loop.

Because the exception escaped before the buffer position was advanced, the offending bytes stayed in the buffer. Every subsequent TCP segment re-parsed them and raised again. The connection was stuck, burning CPU and filling the log, until the player gave up and closed the game.

Both cases are now caught where they happen and the connection is closed cleanly.

One case was left uncovered. An entity update carries its body compressed, and the parser reads that body with a second reader of its own. When the body is too short, the shortfall is reported against that inner reader rather than the socket buffer — and the original fix deliberately re-raised those, on the reasoning that they were not the outer buffer’s problem. They were: re-raising escaped the same way and wedged the connection identically. A well-formed compressed stream containing an empty body was enough to trigger it. Shortfalls from the inner reader now close the connection, while the outer reader keeps its original meaning of “waiting for the rest of this packet”.

Code changes
@@ -799,11 +799,30 @@
                     return
         except OutOfData as e:
             if e.reader is not reader:
-                raise e
-        except InvalidPacket:
-            # Not recoverable by waiting for more bytes. callback(None) is
-            # what the connection treats as an invalid packet, and it
-            # disconnects.
+                # A nested reader ran out of data. EntityUpdate parses the
+                # decompressed body with a ByteReader of its own, so a body
+                # that is structurally too short raises against that reader
+                # rather than this one. More bytes can never fix it, and
+                # re-raising would escape feed() with self.data unadvanced:
+                # the same bytes are re-parsed on every later segment, and
+                # every packet already in the buffer is delivered again each
+                # time round. Outer-reader OutOfData still means "waiting for
+                # the rest of this packet" and falls through untouched.
+                self.data = b''
+                self.callback(None)
+                return
+        except (InvalidPacket, UnicodeDecodeError):
+            # InvalidPacket: a length or size field is impossible.
+            # UnicodeDecodeError: a text field is not valid in its encoding
+            # (a lone UTF-16 surrogate in a chat message, for example).
+            #
+            # Neither is recoverable by waiting for more bytes, and both must
+            # be caught here: an exception escaping feed() would leave
+            # self.data unadvanced, so the offending bytes stay in the buffer
+            # and every later TCP segment re-parses them and raises again.
+            #
+            # callback(None) is what the connection treats as an invalid
+            # packet, and it disconnects.
             self.data = b''
             self.callback(None)
             return

Errors were never written to the log file

scripts/log.py

Why it changed

A single missing character. The logging script redirected standard output into the log file and then assigned standard error to sys.sterr — an attribute that does not exist and that Python is happy to create.

The result: every traceback, every warning and every error the server ever produced went to a variable nobody read. Anyone running the server detached, under systemd or in a closed terminal, had no record of a single crash.

Code changes
@@ -60,7 +84,7 @@
         # also write stdout/stderr to log file
         sys.stdout = LoggerWriter(sys.__stdout__, logger, logging.INFO)
-        sys.sterr = LoggerWriter(sys.__stderr__, logger, logging.ERROR)
+        sys.stderr = LoggerWriter(sys.__stderr__, logger, logging.ERROR)

Logging could recurse into itself when the disk filled

scripts/log.py

Only necessary because of errors were never written to the log file, which is what routes errors into the logger in the first place.

Why it changed

Fixing the typo above exposed the reason it may have been left alone. With standard error genuinely routed into the logger, a failing log handler becomes self-feeding: Python’s logging module reports handler failures by writing to standard error, which is now the logger, which calls the handler that just failed.

A full disk would have turned into an unbreakable loop. A reentrancy guard shared across all writers now detects the second entry and puts the text on the real stream instead, so the message stays visible without re-entering the failing path.

Code changes
@@ -33,6 +33,11 @@
     data = ''
     errors = 'strict'
 
+    # Shared by every LoggerWriter, not per-instance: the loop we are
+    # guarding against crosses instances (stdout writer -> failing handler
+    # -> logging.handleError -> stderr writer -> same failing handler).
+    in_write = False
+
     def __init__(self, fp, logger, level):
@@ -41,11 +46,30 @@
     def write(self, message):
-        self.data += message
-        splitted = self.data.split('\n')
-        for message in splitted[:-1]:
-            self.logger.log(self.level, message)
-        self.data = splitted[-1]
+        if LoggerWriter.in_write:
+            try:
+                self.fp.write(message)
+                self.fp.flush()
+            except Exception:
+                pass
+            return
+
+        LoggerWriter.in_write = True
+        try:
+            self.data += message
+            splitted = self.data.split('\n')
+            for message in splitted[:-1]:
+                self.logger.log(self.level, message)
+            self.data = splitted[-1]
+        finally:
+            LoggerWriter.in_write = False

Reloading a script kept the old configuration

cuwo/server.py

Why it changed

/reload <script> reimported the script but the configuration object caches config modules in a dictionary. The reloaded script read the same cached values it had at startup, so edits to config/<name>.py did nothing and the only way to apply a setting was a full restart, dropping every player.

The cache entry for that one script is now dropped on reload. Other scripts keep their configuration, so a reload stays surgical.

Code changes
@@ -795,6 +941,12 @@
             mod = __import__('scripts.%s' % name, globals(), locals(), [name])
             if update:
                 importlib.reload(mod)
+                # ConfigObject caches config modules in config_dict, so a
+                # reload would otherwise keep using the values read at
+                # startup. Drop this script's cached config so edits to
+                # config/<name>.py take effect. Done here rather than in
+                # ConfigObject.reload() so other scripts keep their configs.
+                self.config.config_dict.pop(name, None)
         except ImportError as e:

Unloading the console script crashed when not on a terminal

scripts/console.py

Why it changed

The console script returns early when the server is not attached to a terminal, so self.task is never assigned. Unloading it then raised AttributeError — which is exactly the situation on any server started as a service.

Code changes
@@ -78,6 +76,7 @@
 class ConsoleServer(ServerScript):
     connection_class = None
+    task = None
 
     def on_load(self):
@@ -109,7 +106,8 @@
     def on_unload(self):
-        self.task.cancel()
+        if self.task is not None:
+            self.task.cancel()

Completed quests announced themselves every five seconds

cuwo/server.py

Applies to a stock checkout on its own. Finished quests were announced again on every join applies on top of it.

Why it changed

Every few seconds the server rebuilt and sent a packet for every quest within a region of every player, keeping no record of what it had already sent. The client shows its completion notification each time it receives a mission packet, so a quest that had been finished announced itself again on every pass, indefinitely.

The same loop also put those packets in the shared broadcast, so every player received every nearby player’s quests regardless of where they were standing.

Each connection now keeps a record of what it has been sent, and a quest goes out only when it is new to that player or its contents have actually changed. Quest updates that come from the emulated 2013 server are recorded the same way, so the periodic pass does not immediately send a second copy of a change that has just been delivered. In the steady state this sends nothing at all.

Code changes
@@ -149,6 +149,9 @@
         self.server = server
         self.world = server.world
         self.loop = server.loop
+        # (mission x, mission y) -> bytes of the MissionInfo last sent to
+        # this player, so the same mission is not pushed again unchanged
+        self.sent_missions = {}
@@ -672,10 +675,22 @@
+        mission_start = len(p.missions)
         self.add_packet_list(p.missions, in_queue.missions,
                              in_queue.missions_size)
 
+        # Missions pushed by tgen (state and progress changes) go out to
+        # every player, so record them as already sent. Without this the
+        # next update_missions cycle would read contents that differ from
+        # what it last sent itself and re-send the mission, making the
+        # client fire a second completion notification a few seconds after
+        # the first.
+        for mission_packet in p.missions[mission_start:]:
+            key = (mission_packet.x, mission_packet.y)
+            data = bytes(mission_packet.info)
+            for connection in self.players.values():
+                connection.sent_missions[key] = data
+
+    def get_mission_at(self, x, y):
+        reg_x = x // constants.MISSIONS_IN_REGION
+        reg_y = y // constants.MISSIONS_IN_REGION
+        try:
+            reg = self.world.get_region((reg_x, reg_y))
+        except KeyError:
+            return None
+        local_x = x % constants.MISSIONS_IN_REGION
+        local_y = y % constants.MISSIONS_IN_REGION
+        try:
+            return reg.get_mission((local_x, local_y))
+        except (IndexError, ValueError):
+            return None
+
     def update_missions(self):
+        # This used to append every mission in range to the shared broadcast
+        # packet on every cycle, with no record of what had already been
+        # sent. The client fires its completion notification on every
+        # mission packet it receives, so a completed mission was announced
+        # again every mission_update_rate seconds, forever.
         max_dist = self.config.base.mission_max_distance
-        p = self.update_packet
-        added = set()
+        looked_up = {}
         for connection in self.players.values():
             player_entity = connection.entity
             if player_entity is None:
                 continue
+            sent = connection.sent_missions
+            new_missions = []
             min_pos = (player_entity.pos - max_dist) // constants.MISSION_SCALE
             max_pos = (player_entity.pos + max_dist) // constants.MISSION_SCALE
             for x in range(min_pos.x, max_pos.x):
                 for y in range(min_pos.y, max_pos.y):
-                    if (x, y) in added:
-                        continue
-                    added.add((x, y))
+                    key = (x, y)
                     try:
-                        reg = self.world.get_region((reg_x, reg_y))
+                        m = looked_up[key]
                     except KeyError:
+                        m = self.get_mission_at(x, y)
+                        looked_up[key] = m
+                    if m is None:
                         continue
-                    try:
-                        m = reg.get_mission((local_x, local_y))
-                    except (IndexError, ValueError):
+                    data = bytes(m.info)
+                    if sent.get(key) == data:
                         continue
+                    sent[key] = data
                     mission_packet = packets.MissionPacket()
                     mission_packet.x = x
                     mission_packet.y = y
                     mission_packet.info = m.info
-                    p.missions.append(mission_packet)
+                    new_missions.append(mission_packet)
+            if not new_missions:
+                continue
+            extra_server_update.reset()
+            extra_server_update.missions = new_missions
+            connection.send_packet(extra_server_update)

Finished quests were announced again on every join

cuwo/server.py

Applies on top of completed quests announced themselves every five seconds, which is where the per-player record and get_mission_at come from.

Why it changed

Stopping the repeat left one notification behind. The record of what a player has been sent starts empty when they connect, so everything in range goes out once on joining — including quests that were already finished, which the client announces as freshly completed.

Two things had to be established before that could be fixed properly.

The first is that the mission table in a region is a fixed 64 entries with no count field anywhere, and cuwo sent all 64 regardless of how many held a real quest. Measured on the live server, 12 of 256 entries were genuine and the rest were uninitialised memory. A real entry can be told apart because its recorded origin is a world coordinate lying inside the region that owns it, which held for every entry tested in both directions.

The second is the meaning of the quest state field, which cuwo does not document. Two independent reverse-engineering projects by LastExceed — the C# CubeworldNetworking library and the Rust berld server — agree that it reads 0 for ready, 1 for in progress and 2 for finished. Quests already marked finished are no longer sent at all.

A side effect worth stating plainly: the quest markers players used to see on the map near spawn were being drawn from that uninitialised memory and pointed at nothing. They are gone. Markers for real quests are unaffected, but they are much rarer than the noise made them look.

Still open, and not addressed here: the coordinates cuwo stamps on a mission packet are taken from the entry’s position in the table rather than from the quest’s own recorded origin, and the two do not agree. That is being looked at separately.

Code changes
@@ -58,6 +58,13 @@
 extra_server_update = packets.ServerUpdate()
 
+# MissionInfo.state: 0 = Ready, 1 = InProgress, 2 = Finished. Mapping taken
+# from LastExceed's CubeworldNetworking (C#) and berld (Rust), which agree.
+MISSION_FINISHED = 2
+
+# cells whose finished mission has already been reported once
+mission_finished_logged = set()
+
@@ -691,6 +698,17 @@
+    def is_valid_mission(self, x, y, m):
+        # A real mission's origin is a world coordinate inside the region
+        # that owns its slot. Unused slots in the fixed 64-entry region
+        # table hold uninitialised memory whose origin lands nowhere near,
+        # so this separates the two without relying on any of the
+        # undocumented fields.
+        return (m.origin_x // constants.REGION_SCALE ==
+                x // constants.MISSIONS_IN_REGION and
+                m.origin_y // constants.REGION_SCALE ==
+                y // constants.MISSIONS_IN_REGION)
+
     def get_mission_at(self, x, y):
@@ -733,6 +751,21 @@
                     except KeyError:
                         m = self.get_mission_at(x, y)
+                        if m is not None and not self.is_valid_mission(x, y,
+                                                                      m):
+                            # uninitialised slot, not a mission at all
+                            m = None
+                        if (m is not None
+                                and m.info.state == MISSION_FINISHED):
+                            # already completed: sending it makes the client
+                            # replay its completion notification
+                            if key not in mission_finished_logged:
+                                mission_finished_logged.add(key)
+                                print('mission already finished, not sent: '
+                                      'cell=%s,%s id=%s objective=%s'
+                                      % (x, y, m.info.mission_id,
+                                         m.info.mission_desc_id))
+                            m = None
                         looked_up[key] = m
                     if m is None:
                         continue

Ground loot came back, and multiplied on its own

cuwo/world.py

Applies on top of terrain cleanup crashed the server from the wrong thread, which is where generate_chunk and release_data come from. It will not apply to a stock checkout.

Why it changed

Reported upstream twice and open since 2018, as issues #217 and #237: items dropped by monsters reappear on the ground after being collected, and can be collected again. The second report notes it happens mostly in dungeons, a few seconds after the pickup. Neither report has a diagnosis attached.

cuwo does not simulate the world. It runs the original 2013 Server.exe inside an emulator, and that emulated server keeps its own list of what is lying on the ground in each chunk. When a chunk is first generated, cuwo copies that list into its own, then empties the emulated one — an ownership transfer, after which only cuwo tracks those items.

It never performed that transfer again. Everything a monster dropped afterwards stayed owned by both sides at once.

That matters because of how the emulated server reports drops. It does not send the new item; it re-sends its entire list for that chunk every time the list changes. cuwo appended everything it received. So a single new drop caused every item already on that patch of ground to be added a second time — which is exactly why dungeons were the worst case, and why the duplicate appeared a few seconds later rather than immediately. Nobody had to pick anything up for this to happen.

Pickups then made it permanent. Collecting an item removed cuwo’s copy and nothing else, because there is no way to tell the emulated server that an item is gone — the interface it exposes has no such call. Its own copy stayed on its list, came back on the next report, and came back again whenever the chunk was unloaded and regenerated.

An earlier attempt tried to recognise duplicates by comparing item contents. That was abandoned: the same physical item does not have the same bytes in the report as it does in the emulated list, and genuinely different items frequently do have identical bytes. Item contents are neither a reliable identity nor a reliable way to match one copy to another.

The fix is to finish the transfer that already happens at generation, and do it on every report instead of only the first. One question had to be settled first, because the fix clears the emulated list: if that list had been only the new entries rather than all of them, clearing it would have deleted loot nobody had taken. So a logging build was run on the live server before anything was changed. Thirteen reports across eight chunks, every one of them matching the emulated list exactly in length, order and contents. The same session recorded an Iron Shield duplicating before anyone touched it, being collected twice, and returning a third time after the chunk regenerated.

Items are now copied out and the emulated list cleared in the same locked step, so the server owns each drop from the moment it is reported. One drop, one item.

Two smaller things came with it. The copy performed at generation was reading and writing emulated memory from the main thread without the lock that the terrain fix above established as mandatory, and now takes it. And a failure anywhere inside chunk generation used to leave the queue believing a generation was still in flight, which stopped the world generating any further terrain for the rest of the run while the server otherwise appeared healthy; the queue now always advances.

Not addressed here: items on the ground still have no despawn. Loot that nobody collects accumulates until the chunk is unloaded. That is a separate issue and it is unchanged — what has stopped is the multiplication.

Code changes
@@ -347,9 +347,11 @@
         for reg in self.get_neighborhood_regions(7):
             reg.update_seed()
 
-        chunk_items = self.data.items
-        self.items.extend([item.copy() for item in chunk_items])
-        chunk_items.clear()
+        with self.world.chunk_lock:
+            chunk_items = self.data.items
+            transferred = [item.copy() for item in chunk_items]
+            chunk_items.clear()
+            self.items.extend(transferred)
 
         for entity_id, data in enumerate(self.data.static_entities):
             header = data.header
@@ -537,11 +539,17 @@
             # thread without chunk_lock, so the release is handed back to
             # the generation thread. Do not bind the ZoneData to a local.
-            holder = f.result()
-            chunk.on_gen(holder[0])
-            self.release_data(holder)
-            self.generating = None
-            self.update_chunk_queue()
+            holder = None
+            try:
+                holder = f.result()
+                chunk.on_gen(holder[0])
+            finally:
+                try:
+                    if holder is not None:
+                        self.release_data(holder)
+                finally:
+                    self.generating = None
+                    self.update_chunk_queue()
         self.call_gen(generate_chunk, *chunk.pos).add_done_callback(on_chunk)
@@ -618,13 +626,36 @@
         with self.chunk_lock:
             out_packets = tgen.get_out_packets()
-        for chunk_items in iterate_packet_list(out_packets.chunk_items):
-            chunk_pos = (chunk_items.chunk_x, chunk_items.chunk_y)
-            chunk = self.chunks.get(chunk_pos, None)
-            if chunk is None:
-                continue
-            for item_data in iterate_packet_list(chunk_items.data):
-                chunk.add_item(item_data.data.copy())
+
+            # Chunk item packets are full snapshots of the tgen Zone.items
+            # vector, not deltas. Keep only the last snapshot per chunk in
+            # case tgen emits the same chunk more than once in one step.
+            snapshots = {}
+            for chunk_items in iterate_packet_list(out_packets.chunk_items):
+                chunk_pos = (chunk_items.chunk_x, chunk_items.chunk_y)
+                snapshots[chunk_pos] = [
+                    item_data.data.copy()
+                    for item_data in iterate_packet_list(chunk_items.data)]
+
+            updated_chunks = []
+            for chunk_pos, transferred in snapshots.items():
+                chunk = self.chunks.get(chunk_pos, None)
+                if chunk is None or chunk.data is None:
+                    # on_gen() will transfer and clear the tgen vector once
+                    # the generated ZoneData is bound to this chunk.
+                    continue
+                if not transferred:
+                    continue
+
+                # Transfer ownership from tgen to Python atomically. Keep the
+                # clear and extend adjacent so an exception cannot strand the
+                # only detached copies in a local variable.
+                chunk.data.items.clear()
+                chunk.items.extend(transferred)
+                updated_chunks.append(chunk)
+
+        for chunk in updated_chunks:
+            chunk.update()
 
         return out_packets

Performance03 changes

Work the main thread did not need to do

cuwo runs the world on a single asyncio thread, and everything else waits on it. These are the three places it was spending that time on nothing.

Entity packets were built whether or not anyone wanted them

cuwo/server.py

First of two changes to send_entity_data. distance checks were 57% of the main thread, and wrong applies on top of it.

Why it changed

Serializing an entity means zlib-compressing it, and it is the most expensive operation in the broadcast loop. The original code built three variants up front — for every entity, every tick, whether or not a single player needed any of them.

Two of the three used the same position-only mask, so they were byte-identical to each other. And in the ordinary steady state, where players are close together and nothing new has come into view, none of the three is used at all.

Each variant is now built on first use and shared for the rest of the loop.

Code changes
@@ -659,24 +713,19 @@
     def send_entity_data(self, entity):
         base = self.config.base
 
-        # full entity packet for new, close players
-        entity_packet.set_entity(entity, entity.entity_id)
-        full = packets.write_packet(entity_packet)
-
-        # pos entity packet
-        if not entity.is_tgen:
-            entity_packet.set_entity(entity, entity.entity_id,
-                                     entitydata.POS_FLAG)
-            only_pos = packets.write_packet(entity_packet)
-
-        # reduced rate packet
-        entity_packet.set_entity(entity, entity.entity_id,
-                                 entitydata.POS_FLAG)
-        reduced = packets.write_packet(entity_packet)
+        # Build each variant on first use instead, and share the one pos
+        # packet. In the common steady state (players close, nothing newly
+        # visible) none of them are needed at all.
+        full = None
+        pos_only = None
 
         skip_reduced = self.skip_index != 0
@@ -687,21 +752,26 @@
             if entity.full_update:
+                if full is None:
+                    entity_packet.set_entity(entity, entity.entity_id)
+                    full = packets.write_packet(entity_packet)
                 connection.send_data(full)
@@ -696,7 +786,11 @@
             if dist > max_reduce_distance and skip_reduced:
-                connection.send_data(reduced)
+                if pos_only is None:
+                    entity_packet.set_entity(entity, entity.entity_id,
+                                             entitydata.POS_FLAG)
+                    pos_only = packets.write_packet(entity_packet)
+                connection.send_data(pos_only)

Distance checks were 57% of the main thread, and wrong

cuwo/server.py

Applies on top of entity packets were built whether or not anyone wanted them, which is where pos_only comes from.

Why it changed

Every tick, for every entity, against every player, the server computed (a.pos - b.pos).length. That is a pyrr Vector3 subtraction, dispatched through multipledispatch and then into numpy. Profiling put that single line at 57% of main-thread time: 45 microseconds per call, 2.6 million calls in three minutes.

It was also incorrect. pyrr computes the length as a sum of squares in 64-bit integers, which overflows past roughly 105 chunks of separation and returns NaN. Every comparison against NaN is false, so entities far enough apart were treated as adjacent and sent full updates instead of being skipped — the failure mode costs the most exactly when the world is at its busiest.

Plain Python integers are exact at any magnitude and about 13× faster. Comparing squared distances also removes the square root.

Code changes
@@ -659,8 +713,21 @@
-        max_distance = base.max_distance
-        max_reduce_distance = base.max_reduce_distance
+        # Distance was computed as (a.pos - b.pos).length, which is a pyrr
+        # Vector3 subtraction routed through multipledispatch and then numpy.
+        # Profiling showed that single line accounting for 57% of the main
+        # thread: 45 us per call, 2.6 million calls in three minutes.
+        #
+        # It was also wrong. pyrr's .length does sum(v**2) in int64, which
+        # overflows past roughly 105 chunks of separation and yields NaN.
+        # NaN > max_distance is False, so entities far enough apart were
+        # treated as adjacent and sent full updates.
+        #
+        # Plain Python integers are exact at any range and about 13x faster.
+        # Comparing squared distances also avoids the square root.
+        max_distance_sq = base.max_distance ** 2
+        max_reduce_distance_sq = base.max_reduce_distance ** 2
+        ex, ey, ez = entity.pos.tolist()
@@ -687,8 +752,13 @@
-            dist = (player_entity.pos - entity.pos).length
-            if dist > max_distance:
+            px, py, pz = player_entity.pos.tolist()
+            dx = px - ex
+            dy = py - ey
+            dz = pz - ez
+            dist_sq = dx * dx + dy * dy + dz * dz
+            if dist_sq > max_distance_sq:
@@ -696,7 +786,7 @@
-            if dist > max_reduce_distance and skip_reduced:
+            if dist_sq > max_reduce_distance_sq and skip_reduced:

One serialization per player, per tick

cuwo/server.py

Applies on top of passive abilities looped forever and is itself rewritten by every pickup was announced to every player.

Why it changed

The passive fix is correct but costly as written: filtering each player’s own passives out means serializing — and zlib-compressing — the whole world update once per connection, every tick, forever.

Only the handful of players who actually sent a passive that tick need a filtered copy. Everyone else receives identical bytes, so those are built once and reused. The cost drops from one serialization per player to one plus the number of players who used a passive, which is usually zero.

Code changes
@@ -733,17 +811,28 @@
+            # Serializing per player would mean one zlib.compress of the whole
+            # update per connection per tick. Only the few players that sent a
+            # passive this tick need a filtered copy; everyone else gets the
+            # identical unfiltered bytes, so serialize that once and reuse it.
+            # This is 1 + len(senders) serializations instead of len(players).
             old_passives = update_packet.passive_actions
+            senders = set(self.received_passives.values())
+            shared_data = None
             for player in self.players.values():
-                update_packet.passive_actions = [
-                    x for x in old_passives
-                    if self.received_passives.get(x, None) != player]
-                data = packets.write_packet(update_packet)
-                player.send_data(data)
+                if player in senders:
+                    update_packet.passive_actions = [
+                        x for x in old_passives
+                        if self.received_passives.get(x, None) != player]
+                    player.send_data(packets.write_packet(update_packet))
+                    continue
+                if shared_data is None:
+                    update_packet.passive_actions = old_passives
+                    shared_data = packets.write_packet(update_packet)
+                player.send_data(shared_data)

Anti-cheat05 changes

Kicking players who were not cheating

cuwo's anti-cheat removes a player the instant a check trips, with no grace and no appeal. Several of those checks trip during ordinary play. These changes take out the false positives without softening the checks that work.

Observe-only mode for noisy checks

scripts/anticheat/__init__.py, config/anticheat.py

Why it changed

cuwo’s anti-cheat has one response to a detection: remove the player. That is a hard thing to tune on a live server, because the only way to find out whether a check produces false positives is to keep losing players to it.

Individual detections can now be listed in the configuration as observe-only. Those are logged with a running count and the player stays. Everything not listed still removes on detection, byte for byte as before, and an empty list restores stock behaviour exactly.

Repeat detections collapse to one line per reason per connection per interval, so a check that fires continuously produces a count rather than a flooded log. This is a measurement tool, not a permanent exemption: entries come off the list once the counts say what the real fix is.

Code changes
--- a/config/anticheat.py
+++ b/config/anticheat.py
@@ -1,3 +1,18 @@
+# Detections listed here are logged but do NOT kick the player.
+# Entries are the exact reason strings passed to remove_cheater, e.g.
+# 'illegal charge multiplier'. Use this to observe a noisy check on a live
+# server instead of losing players to it while you work out whether it is
+# reliable. An empty list restores normal kick-on-detect behaviour.
+# Repeat detections are collapsed to one log line per reason per
+# observe_log_interval seconds, with the suppressed count included.
+observe_only_reasons = [
+    'illegal charge multiplier',
+]
+
+# Seconds between repeat log lines for the same observe-only reason on the
+# same connection.
+observe_log_interval = 60
+
 # Logging level 2 = Verbose, 1 = Default, or 0 = None
 log_level = 2
 
--- a/scripts/anticheat/__init__.py
+++ b/scripts/anticheat/__init__.py
@@ -367,8 +372,36 @@
+    def log_observed(self, reason):
+        connection = self.connection
+        now = self.loop.time()
+
+        try:
+            state = self.observed_counts[reason]
+        except KeyError:
+            state = self.observed_counts[reason] = [0, None]
+
+        state[0] += 1
+        count, last_log = state
+
+        if last_log is not None and now - last_log < self.observe_log_interval:
+            return
+        state[1] = now
+
+        self.log("{playername}({ip}) NOT removed (observe only) for: "
+                 "{reason}. count={count}"
+                 .format(playername=connection.name,
+                         ip=connection.address[0],
+                         reason=reason,
+                         count=count))
+
     def remove_cheater(self, reason):
         connection = self.connection
+
+        if reason in self.observe_only_reasons:
+            self.log_observed(reason)
+            return
+
         self.log(self.log_message

Health check kicked players on a stale cached maximum

scripts/anticheat/__init__.py

Why it changed

Maximum health is cached and refreshed only on join, equipment change and level up, while the check runs on every health change. If the snapshot was taken while the entity data was still incomplete — which is normal during a join — the cached maximum was wrong, and nothing ever recomputed it.

From that point on every heal looked like a hack, for the rest of the session.

The value is now recomputed before anyone is accused. A second guard treats a computed maximum of zero or less as not a limit at all, rather than as a bound every player instantly exceeds — that case cannot arise during a join, since the multiplier check runs earlier and requires an exact value, so it is a floor rather than a fix for anything observed.

Code changes
@@ -145,6 +145,27 @@
     def check_max_health(self, no_strikes=False):
         entity = self.connection.entity
         if entity.hp > self.max_health + 1:
+            # self.max_health is a cached snapshot, refreshed only on join,
+            # equipment change and level change, while this check runs on
+            # every hp change. If the snapshot was taken while the entity
+            # data was still incomplete the cache is wrong and nothing ever
+            # recomputes it, so every later hp change looks like a hack.
+            # Recompute from the entity as it stands before accusing anyone.
+            self.update_max_health()
+
+            if entity.hp <= self.max_health + 1:
+                self.max_hp_strikes = 0
+                return
+
+            if self.max_health <= 0:
+                # A computed maximum of zero or less is not a limit and
+                # nothing can legitimately sit below it, so never strike on
+                # it. Not reachable during a join -- on_multiplier_update
+                # runs first and requires max_hp_multiplier to be exactly
+                # 100 -- but it costs nothing as a floor.
+                self.max_hp_strikes = 0
+                return
+
             self.max_hp_strikes += 1

Warrior blocking read as a charge exploit

scripts/anticheat/__init__.py

Currently paired with observe-only mode for noisy checks, which is what stops this check removing players while readings are collected.

Why it changed

The client has a bug that drives a warrior’s charge value negative while blocking. The check already allowed for it, with a floor at −2.

Real players went past that floor. The floor was widened to −4, then a player produced −4.47 and was removed mid-session. Since the true range of the value under the blocking bug is undocumented, widening it again would be guesswork with no principled stopping point — so this check is currently the one running in observe-only mode while the counts accumulate.

Note that the exploit this check exists to stop is a charge multiplier above one, which is checked separately and remains fully enforced.

Code changes
@@ -764,10 +809,12 @@
         if entity.class_type == 1:
-            # -1 check for warriors because they have a bug that can make them
-            # go negative while blocking
-            if entity.charged_mp < -2:
-                self.log("charged mp multiplier below 2, charged_mp={mult}"
+            # Warriors have a client bug that drives charged_mp negative
+            # while blocking, so normal play has to be tolerated here.
+            # Readings a little past -2 have been seen from players who
+            # were not cheating, hence the wider floor.
+            if entity.charged_mp < -4:
+                self.log("charged mp multiplier below 4, charged_mp={mult}"
                          .format(mult=entity.charged_mp),
                          LOG_LEVEL_VERBOSE)
                 return True

Appearance checks crashed instead of reporting

scripts/anticheat/__init__.py

Why it changed

Three of the appearance checks logged a field that does not exist on the object being checked: movement_flags, bounding_radius and bounding_height, on a structure whose fields are flags and scale.

These lines only run when a check has already failed, which is why they survived: on the rare occasion the anti-cheat did catch something here, it raised AttributeError instead of logging it. Corrected to the real field names.

Code changes
@@ -786,7 +833,7 @@
         if appearance.flags != 0:
             self.log("invalid appearance flags={flags}"
-                     .format(flags=appearance.movement_flags),
+                     .format(flags=appearance.flags),
                      LOG_LEVEL_VERBOSE)
             return True
@@ -806,14 +853,14 @@
         if not is_similar(appearance.scale.y, app['radius']):
             self.log("invalid appearance, radius={field} entity_type={t}"
-                     .format(field=appearance.bounding_radius,
+                     .format(field=appearance.scale.y,
                              t=entity.entity_type),
                      LOG_LEVEL_VERBOSE)
             return True
 
         if not is_similar(appearance.scale.z, app['height']):
             self.log("invalid appearance, height={field} entity_type={t}"
-                     .format(field=appearance.bounding_height,
+                     .format(field=appearance.scale.z,
                              t=entity.entity_type),
                      LOG_LEVEL_VERBOSE)
             return True

Consumables with a rarity were treated as forged

scripts/anticheat/__init__.py

Why it changed

The item validator rejected any consumable with a rarity above zero, on the assumption that consumables are always common. The alpha client generates them at higher rarities in ordinary loot, so this removed players for picking up an item the game had just given them.

The check is gone. The rarity cap that limits how good an item may be still applies to consumables like everything else.

Code changes
@@ -406,13 +458,6 @@
                      rarity=item.rarity), LOG_LEVEL_VERBOSE)
             return True
 
-        if item.type == 1 and item.rarity > 0:
-            self.log(("consumable with rarity above 0, item={item}" +
-                     " item rarity: {rarity}")
-                     .format(item=get_item_name(item),
-                             rarity=item.rarity), LOG_LEVEL_VERBOSE)
-            return True
-
         # Item type 2 is a recipe they are handled differently.
         # minus modifier is the item type of the crafted item
         if item.type == 2:

Modern platform07 changes

From Python 3.4 to Python 3.12

cuwo was last touched in 2018, against Python 3.4 and GCC 5. On a current system most of it will not compile, and what does compile will not start. These are the changes that get it building and running.

Python 3.11 removed the float packing API

cuwo/bytes_c.cpp

Why it changed

The C++ byte reader used CPython’s private float pack and unpack helpers. Python 3.11 renamed them, dropping the leading underscore, and changed their buffer type from unsigned char* to char*. The extension no longer compiled.

Both spellings are kept behind a version check, so the same source still builds against older Python versions.

Code changes
@@ -21,6 +21,21 @@
 #include "Python.h"
+
+/* Python 3.11 renamed the private float pack/unpack helpers (dropping the
+   leading underscore) and changed their buffer type from unsigned char* to
+   char*. Keep both spellings working. */
+#if PY_VERSION_HEX >= 0x030B0000
+#define CUWO_FLOAT_UNPACK4(p)   PyFloat_Unpack4((const char *)(p), 1)
+#define CUWO_FLOAT_UNPACK8(p)   PyFloat_Unpack8((const char *)(p), 1)
+#define CUWO_FLOAT_PACK4(v, p)  PyFloat_Pack4((v), (char *)(p), 1)
+#define CUWO_FLOAT_PACK8(v, p)  PyFloat_Pack8((v), (char *)(p), 1)
+#else
+#define CUWO_FLOAT_UNPACK4(p)   _PyFloat_Unpack4((const unsigned char *)(p), 1)
+#define CUWO_FLOAT_UNPACK8(p)   _PyFloat_Unpack8((const unsigned char *)(p), 1)
+#define CUWO_FLOAT_PACK4(v, p)  _PyFloat_Pack4((v), (unsigned char *)(p), 1)
+#define CUWO_FLOAT_PACK8(v, p)  _PyFloat_Pack8((v), (unsigned char *)(p), 1)
+#endif
 using namespace std;
@@ -89,12 +104,12 @@
 inline double read_float(char * data)
 {
-    return _PyFloat_Unpack4((const unsigned char*)data, true);
+    return CUWO_FLOAT_UNPACK4(data);
 }

The command system used a function removed in Python 3.11

cuwo/script.py

Why it changed

Every command the server exposes — the whole /kick, /say, /setclock family — is registered through inspect.getargspec() to work out its argument count. That function was deprecated for a decade and removed in Python 3.11, so nothing that defined a command could load.

Code changes
@@ -69,7 +69,7 @@
         # get min args
-        func_info = inspect.getargspec(base)
+        func_info = inspect.getfullargspec(base)
         self.min_args = len(func_info.args) - 1
@@ -93,7 +93,7 @@
     def get_syntax(self):
-        func_info = inspect.getargspec(self.base)
+        func_info = inspect.getfullargspec(self.base)
         has_defaults = func_info.defaults is not None

Console used a coroutine style removed in Python 3.11

scripts/console.py

Why it changed

The console script was written with @asyncio.coroutine and yield from, the generator-based syntax that predates async/await. It was removed in Python 3.11. Converted to native coroutines, with no change in behaviour.

Code changes
@@ -34,8 +34,7 @@
 if sys.platform == 'win32':
     import msvcrt
 
-    @asyncio.coroutine
-    def async_stdin():
+    async def async_stdin():
         current = ''
@@ -56,25 +55,24 @@
-            yield from asyncio.sleep(0.04)
+            await asyncio.sleep(0.04)
         return current
@@ -66,17 +64,15 @@
-    @asyncio.coroutine
-    def get_stdin():
+    async def get_stdin():
         loop = asyncio.get_event_loop()
         reader = asyncio.StreamReader()
         reader_protocol = asyncio.StreamReaderProtocol(reader)
-        yield from loop.connect_read_pipe(lambda: reader_protocol, sys.stdin)
+        await loop.connect_read_pipe(lambda: reader_protocol, sys.stdin)
         return reader
 
-    @asyncio.coroutine
-    def async_stdin():
+    async def async_stdin():
         global reader
         if reader is None:
-            reader = yield from get_stdin()
-        line = yield from reader.readline()
+            reader = await get_stdin()
+        line = await reader.readline()
         return line.decode('utf8')

Terrain generator would not compile on GCC 12 and later

terraingen/tgen2/src/mem.h

Why it changed

The memory header for the terrain generator uses uint32_t and size_t without including the headers that define them. Older GCC pulled them in transitively; GCC 12 stopped doing that, and the build failed before it reached anything interesting.

Code changes
@@ -1,6 +1,11 @@
 #ifndef TGEN_MEM_H
 #define TGEN_MEM_H
 
+/* GCC 12+ no longer pulls these in transitively; this header uses
+   uint32_t and size_t, so include them explicitly. */
+#include <stdint.h>
+#include <stddef.h>
+
 void * alloc_exec(size_t size);
 void * alloc_mem(size_t size);
 void free_mem(void * ptr, size_t size);

Event loop created through a deprecated path

cuwo/server.py

Why it changed

Startup called asyncio.get_event_loop() with no loop running. That has raised a DeprecationWarning since Python 3.10 and is scheduled to become an error.

The loop is now created explicitly. new_event_loop() honours the policy set just above it, so uvloop is still picked up when it is installed — which on DarkCubes cut steady-state CPU by 25%.

Code changes
@@ -930,7 +1089,12 @@
             print('(using uvloop)')
         except ImportError:
             pass
-        loop = asyncio.get_event_loop()
+        # asyncio.get_event_loop() is deprecated when no loop is running
+        # (DeprecationWarning since 3.10, error in a future version).
+        # Create one explicitly -- new_event_loop() honours the policy set
+        # above, so uvloop is still used when available.
+        loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(loop)

Invalid escape sequences in the name filter

config/anticheat.py

Why it changed

The regular expression that validates player names was written as a normal string containing backslash escapes. Python 3.12 turns those into SyntaxWarning and will make them errors. Marked as a raw string, which is what it always meant to be.

Code changes
@@ -13,7 +28,7 @@
 # Regex name filter, anyone that does not match this will be removed.
 # currently all keys normally on a keyboard. cubeworld really isnt picky.
-name_filter = "^[a-zA-Z0-9_!@#$%\^&*()\[\]|:;'.,/\-+ <>\\\"{}~`=?]{2,16}$"
+name_filter = r"^[a-zA-Z0-9_!@#$%\^&*()\[\]|:;'.,/\-+ <>\"{}~`=?]{2,16}$"

Declared dependencies did not produce a working build

requirements.txt

Why it changed

The dependency list read Cython and pyrr, unpinned, and that was the whole file. numpy was never listed despite setup.py importing it directly, and neither was setuptools — which matters more now than it did in 2018, because Python 3.12 removed distutils from the standard library and python -m venv no longer installs setuptools by default. Since setup.py opens with from distutils.core import setup, a clean 3.12 environment fails on the first import.

The two entries that were present had no upper bound, so installing the file as written selects Cython 3 and NumPy 2, both of which fail to build this tree. The source changes elsewhere in this section were correct, but anyone following the published requirements still could not build the server. The list now names every dependency and bounds the two that break. This pins a tested build matrix rather than migrating to Cython 3 and NumPy 2; that migration is separate work and is not done.

Code changes
@@ -1,2 +1,4 @@
-Cython
+Cython<3
+numpy<2
 pyrr
+setuptools

Gameplay01 change

Fine for two players, wrong for twelve

Behaviour that only becomes a problem once more than a couple of people are online at the same time.

Every pickup was announced to every player

cuwo/server.py

Applies on top of passive abilities looped forever and one serialization per player, per tick. This is the version running on the server.

Why it changed

Item pickups were broadcast to everyone, and each receiving client prints a “picked up” line in chat for them. With one or two players that is a curiosity. With a dozen players farming, chat becomes unusable.

A player only needs the pickups tagged with their own entity id — that is what their client acts on. Everyone else’s pickups are pure noise. Pickups are now filtered per player, reusing the machinery already built for the passive fix, so the common case is still a single serialization.

Code changes
@@ -829,19 +829,58 @@
+            # A pickup broadcast to everyone makes each other client print a
+            # "<name> picked up <item>" chat line, which floods chat on a
+            # populated server. A player only needs to receive pickups tagged
+            # with their OWN entity_id (their client applies the item); every
+            # other player's pickups are pure log spam to them.
+            old_pickups = update_packet.pickups
+            pickers = set(a.entity_id for a in old_pickups)
+
             shared_data = None
             for player in self.players.values():
+                is_sender = player in senders
+                is_picker = player.entity_id in pickers
+
+                if is_sender or is_picker:
+                    if is_sender:
+                        update_packet.passive_actions = [
+                            x for x in old_passives
+                            if self.received_passives.get(x, None) != player]
+                    else:
+                        update_packet.passive_actions = old_passives
+                    if is_picker:
+                        update_packet.pickups = [
+                            a for a in old_pickups
+                            if a.entity_id == player.entity_id]
+                    else:
+                        update_packet.pickups = []
                     player.send_data(packets.write_packet(update_packet))
                     continue
+
+                # Stripping pickups can leave the update empty; in that case
+                # there is nothing to send to non-pickers at all.
                 if shared_data is None:
                     update_packet.passive_actions = old_passives
+                    update_packet.pickups = []
+                    if update_packet.is_empty():
+                        shared_data = False
+                    else:
+                        shared_data = packets.write_packet(update_packet)
+                if shared_data is not False:
+                    player.send_data(shared_data)

Notes

What is not on this page

Server-side scripts

DarkCubes runs a few scripts of its own — scheduled announcements, a day and night cycle, the status API that feeds the live banner, and vote rewards. They sit on top of cuwo rather than inside it, so they are not listed here. Only changes to cuwo’s own files are.

Configuration

Player limits, tick rates, view distances and anti-cheat thresholds are settings, not code, and they get tuned as the server is watched. Only changes that add new configuration — or fix configuration that was outright broken — appear above.