diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml
index 5234d862b4d3..ac842070625f 100644
--- a/.github/workflows/scan-build.yml
+++ b/.github/workflows/scan-build.yml
@@ -40,10 +40,10 @@ jobs:
run: |
wget https://apt.llvm.org/llvm.sh
chmod +x ./llvm.sh
- sudo ./llvm.sh 17
+ sudo ./llvm.sh 19
- name: Install scan-build command
run: |
- sudo apt install clang-tools-17
+ sudo apt install clang-tools-19
- name: Get a recent python
uses: actions/setup-python@v5
with:
@@ -56,7 +56,7 @@ jobs:
- name: scan-build
run: |
source venv/bin/activate
- scan-build-17 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
+ scan-build-19 --status-bugs -o scan-build-reports -disable-checker deadcode.DeadStores python setup.py build -y
- name: Store report
if: failure()
uses: actions/upload-artifact@v4
diff --git a/MultiServer.py b/MultiServer.py
index 2561b0692a3c..0601e179152c 100644
--- a/MultiServer.py
+++ b/MultiServer.py
@@ -1914,7 +1914,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
hint = ctx.get_hint(client.team, player, location)
if not hint:
return # Ignored safely
- if hint.receiving_player != client.slot:
+ if client.slot not in ctx.slot_set(hint.receiving_player):
await ctx.send_msgs(client,
[{'cmd': 'InvalidPacket', "type": "arguments", "text": 'UpdateHint: No Permission',
"original_cmd": cmd}])
diff --git a/NetUtils.py b/NetUtils.py
index ec6ff3eb1d81..a961850639a0 100644
--- a/NetUtils.py
+++ b/NetUtils.py
@@ -232,7 +232,7 @@ def _handle_text(self, node: JSONMessagePart):
def _handle_player_id(self, node: JSONMessagePart):
player = int(node["text"])
- node["color"] = 'magenta' if player == self.ctx.slot else 'yellow'
+ node["color"] = 'magenta' if self.ctx.slot_concerns_self(player) else 'yellow'
node["text"] = self.ctx.player_names[player]
return self._handle_color(node)
@@ -410,6 +410,8 @@ def get_checked(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]
checked = state[team, slot]
if not checked:
# This optimizes the case where everyone connects to a fresh game at the same time.
+ if slot not in self:
+ raise KeyError(slot)
return []
return [location_id for
location_id in self[slot] if
diff --git a/Options.py b/Options.py
index 6e2c4624a41c..25367f378282 100644
--- a/Options.py
+++ b/Options.py
@@ -760,7 +760,7 @@ def __init__(self, value: int) -> None:
elif value > self.range_end and value not in self.special_range_names.values():
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " +
f"and is also not one of the supported named special values: {self.special_range_names}")
-
+
# See docstring
for key in self.special_range_names:
if key != key.lower():
@@ -1186,7 +1186,7 @@ def __len__(self) -> int:
class Accessibility(Choice):
"""
Set rules for reachability of your items/locations.
-
+
**Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
@@ -1204,7 +1204,7 @@ class Accessibility(Choice):
class ItemsAccessibility(Accessibility):
"""
Set rules for reachability of your items/locations.
-
+
**Full:** ensure everything can be reached and acquired.
**Minimal:** ensure what is needed to reach your goal can be acquired.
@@ -1255,12 +1255,16 @@ class CommonOptions(metaclass=OptionsMetaProperty):
progression_balancing: ProgressionBalancing
accessibility: Accessibility
- def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, typing.Any]:
+ def as_dict(self,
+ *option_names: str,
+ casing: typing.Literal["snake", "camel", "pascal", "kebab"] = "snake",
+ toggles_as_bools: bool = False) -> typing.Dict[str, typing.Any]:
"""
Returns a dictionary of [str, Option.value]
:param option_names: names of the options to return
:param casing: case of the keys to return. Supports `snake`, `camel`, `pascal`, `kebab`
+ :param toggles_as_bools: whether toggle options should be output as bools instead of strings
"""
assert option_names, "options.as_dict() was used without any option names."
option_results = {}
@@ -1282,6 +1286,8 @@ def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str,
value = getattr(self, option_name).value
if isinstance(value, set):
value = sorted(value)
+ elif toggles_as_bools and issubclass(type(self).type_hints[option_name], Toggle):
+ value = bool(value)
option_results[display_name] = value
else:
raise ValueError(f"{option_name} not found in {tuple(type(self).type_hints)}")
diff --git a/README.md b/README.md
index 2cc3c18aa09d..21a6faaa2698 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,7 @@ Currently, the following games are supported:
* Mega Man 2
* Yacht Dice
* Faxanadu
+* Saving Princess
For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/).
Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled
diff --git a/Utils.py b/Utils.py
index 4f99d26ac402..50adb18f42be 100644
--- a/Utils.py
+++ b/Utils.py
@@ -557,7 +557,7 @@ def _cleanup():
import platform
logging.info(
f"Archipelago ({__version__}) logging initialized"
- f" on {platform.platform()}"
+ f" on {platform.platform()} process {os.getpid()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
f"{' (frozen)' if is_frozen() else ''}"
)
diff --git a/WebHost.py b/WebHost.py
index 3790a5f6f4d2..768eeb512289 100644
--- a/WebHost.py
+++ b/WebHost.py
@@ -34,7 +34,7 @@ def get_app() -> "Flask":
app.config.from_file(configpath, yaml.safe_load)
logging.info(f"Updated config from {configpath}")
# inside get_app() so it's usable in systems like gunicorn, which do not run WebHost.py, but import it.
- parser = argparse.ArgumentParser()
+ parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument('--config_override', default=None,
help="Path to yaml config file that overrules config.yaml.")
args = parser.parse_known_args()[0]
diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py
index 9b2b6736f13c..9c713419c986 100644
--- a/WebHostLib/__init__.py
+++ b/WebHostLib/__init__.py
@@ -39,6 +39,8 @@
app.config["JOB_THRESHOLD"] = 1
# after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable.
app.config["JOB_TIME"] = 600
+# memory limit for generator processes in bytes
+app.config["GENERATOR_MEMORY_LIMIT"] = 4294967296
app.config['SESSION_PERMANENT'] = True
# waitress uses one thread for I/O, these are for processing of views that then get sent
diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py
index 08a1309ebc73..8ba093e014c5 100644
--- a/WebHostLib/autolauncher.py
+++ b/WebHostLib/autolauncher.py
@@ -6,6 +6,7 @@
import typing
from datetime import timedelta, datetime
from threading import Event, Thread
+from typing import Any
from uuid import UUID
from pony.orm import db_session, select, commit
@@ -53,7 +54,21 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation):
generation.state = STATE_STARTED
-def init_db(pony_config: dict):
+def init_generator(config: dict[str, Any]) -> None:
+ try:
+ import resource
+ except ModuleNotFoundError:
+ pass # unix only module
+ else:
+ # set soft limit for memory to from config (default 4GiB)
+ soft_limit = config["GENERATOR_MEMORY_LIMIT"]
+ old_limit, hard_limit = resource.getrlimit(resource.RLIMIT_AS)
+ if soft_limit != old_limit:
+ resource.setrlimit(resource.RLIMIT_AS, (soft_limit, hard_limit))
+ logging.debug(f"Changed AS mem limit {old_limit} -> {soft_limit}")
+ del resource, soft_limit, hard_limit
+
+ pony_config = config["PONY"]
db.bind(**pony_config)
db.generate_mapping()
@@ -105,8 +120,8 @@ def keep_running():
try:
with Locker("autogen"):
- with multiprocessing.Pool(config["GENERATORS"], initializer=init_db,
- initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool:
+ with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator,
+ initargs=(config,), maxtasksperchild=10) as generator_pool:
with db_session:
to_start = select(generation for generation in Generation if generation.state == STATE_STARTED)
diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html
index 8e76dafc12fa..c5996d181ee0 100644
--- a/WebHostLib/templates/hostRoom.html
+++ b/WebHostLib/templates/hostRoom.html
@@ -178,8 +178,15 @@
})
.then(text => new DOMParser().parseFromString(text, 'text/html'))
.then(newDocument => {
- let el = newDocument.getElementById("host-room-info");
- document.getElementById("host-room-info").innerHTML = el.innerHTML;
+ ["host-room-info", "slots-table"].forEach(function(id) {
+ const newEl = newDocument.getElementById(id);
+ const oldEl = document.getElementById(id);
+ if (oldEl && newEl) {
+ oldEl.innerHTML = newEl.innerHTML;
+ } else if (newEl) {
+ console.warn(`Did not find element to replace for ${id}`)
+ }
+ });
});
}
diff --git a/WebHostLib/templates/macros.html b/WebHostLib/templates/macros.html
index 6b2a4b0ed784..b95b8820a72f 100644
--- a/WebHostLib/templates/macros.html
+++ b/WebHostLib/templates/macros.html
@@ -8,7 +8,7 @@
{%- endmacro %}
{% macro list_patches_room(room) %}
{% if room.seed.slots %}
-
+
Id |
diff --git a/_speedups.pyx b/_speedups.pyx
index dc039e336500..2ad1a2953a2b 100644
--- a/_speedups.pyx
+++ b/_speedups.pyx
@@ -69,6 +69,14 @@ cdef struct IndexEntry:
size_t count
+if TYPE_CHECKING:
+ State = Dict[Tuple[int, int], Set[int]]
+else:
+ State = Union[Tuple[int, int], Set[int], defaultdict]
+
+T = TypeVar('T')
+
+
@cython.auto_pickle(False)
cdef class LocationStore:
"""Compact store for locations and their items in a MultiServer"""
@@ -137,10 +145,16 @@ cdef class LocationStore:
warnings.warn("Game has no locations")
# allocate the arrays and invalidate index (0xff...)
- self.entries = self._mem.alloc(count, sizeof(LocationEntry))
+ if count:
+ # leaving entries as NULL if there are none, makes potential memory errors more visible
+ self.entries = self._mem.alloc(count, sizeof(LocationEntry))
self.sender_index = self._mem.alloc(max_sender + 1, sizeof(IndexEntry))
self._raw_proxies = self._mem.alloc(max_sender + 1, sizeof(PyObject*))
+ assert (not self.entries) == (not count)
+ assert self.sender_index
+ assert self._raw_proxies
+
# build entries and index
cdef size_t i = 0
for sender, locations in sorted(locations_dict.items()):
@@ -190,8 +204,6 @@ cdef class LocationStore:
raise KeyError(key)
return