diff --git a/.github/workflows/scan-build.yml b/.github/workflows/scan-build.yml
index 5234d862b4..ac84207062 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/Main.py b/Main.py
index 3a11181bd9..d105bd4ad0 100644
--- a/Main.py
+++ b/Main.py
@@ -242,6 +242,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
def write_multidata():
import NetUtils
+ from NetUtils import HintStatus
slot_data = {}
client_versions = {}
games = {}
@@ -266,10 +267,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
for slot in multiworld.player_ids:
slot_data[slot] = multiworld.worlds[slot].fill_slot_data()
- def precollect_hint(location):
+ def precollect_hint(location: Location, auto_status: HintStatus):
entrance = er_hint_data.get(location.player, {}).get(location.address, "")
hint = NetUtils.Hint(location.item.player, location.player, location.address,
- location.item.code, False, entrance, location.item.flags, False)
+ location.item.code, False, entrance, location.item.flags, auto_status)
precollected_hints[location.player].add(hint)
if location.item.player not in multiworld.groups:
precollected_hints[location.item.player].add(hint)
@@ -288,13 +289,16 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No
f"{locations_data[location.player][location.address]}")
locations_data[location.player][location.address] = \
location.item.code, location.item.player, location.item.flags
+ auto_status = HintStatus.HINT_AVOID if location.item.trap else HintStatus.HINT_PRIORITY
if location.name in multiworld.worlds[location.player].options.start_location_hints:
- precollect_hint(location)
+ if not location.item.trap: # Unspecified status for location hints, except traps
+ auto_status = HintStatus.HINT_UNSPECIFIED
+ precollect_hint(location, auto_status)
elif location.item.name in multiworld.worlds[location.item.player].options.start_hints:
- precollect_hint(location)
+ precollect_hint(location, auto_status)
elif any([location.item.name in multiworld.worlds[player].options.start_hints
for player in multiworld.groups.get(location.item.player, {}).get("players", [])]):
- precollect_hint(location)
+ precollect_hint(location, auto_status)
# embedded data package
data_package = {
diff --git a/MultiServer.py b/MultiServer.py
index 80fcd32fd1..0601e17915 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}])
@@ -1929,6 +1929,11 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
[{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'UpdateHint: Invalid Status', "original_cmd": cmd}])
return
+ if status == HintStatus.HINT_FOUND:
+ await ctx.send_msgs(client,
+ [{'cmd': 'InvalidPacket', "type": "arguments",
+ "text": 'UpdateHint: Cannot manually update status to "HINT_FOUND"', "original_cmd": cmd}])
+ return
new_hint = new_hint.re_prioritize(ctx, status)
if hint == new_hint:
return
diff --git a/NetUtils.py b/NetUtils.py
index ec6ff3eb1d..a961850639 100644
--- a/NetUtils.py
+++ b/NetUtils.py
@@ -232,7 +232,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
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 @@ class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tu
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 d3b2e6c1ba..4e26a0d56c 100644
--- a/Options.py
+++ b/Options.py
@@ -754,7 +754,7 @@ class NamedRange(Range):
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():
@@ -1180,7 +1180,7 @@ class PlandoConnections(Option[typing.List[PlandoConnection]], metaclass=Connect
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.
@@ -1198,7 +1198,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.
@@ -1249,12 +1249,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 = {}
@@ -1276,6 +1280,8 @@ class CommonOptions(metaclass=OptionsMetaProperty):
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 2cc3c18aa0..36b7a07fb4 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,8 @@ Currently, the following games are supported:
* Mega Man 2
* Yacht Dice
* Faxanadu
+* Saving Princess
+* Castlevania: Circle of the Moon
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 e28f501da5..375670feb8 100644
--- a/Utils.py
+++ b/Utils.py
@@ -557,7 +557,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO,
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 3790a5f6f4..768eeb5122 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 9b2b6736f1..9c713419c9 100644
--- a/WebHostLib/__init__.py
+++ b/WebHostLib/__init__.py
@@ -39,6 +39,8 @@ app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-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 08a1309ebc..8ba093e014 100644
--- a/WebHostLib/autolauncher.py
+++ b/WebHostLib/autolauncher.py
@@ -6,6 +6,7 @@ import multiprocessing
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 autogen(config: dict):
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 8e76dafc12..c5996d181e 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 6b2a4b0ed7..b95b8820a7 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 dc039e3365..2ad1a2953a 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