Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5f79ae5
Avoid CachePersonas crash
Aurumbi Feb 17, 2026
639dbdc
Implemented cached steamhandler, To prevent crashes related to it.
Aurumbi Feb 17, 2026
2c8e315
Used steamhandler_ex in modconfig.py to fix mod browser crashing on call
Aurumbi Feb 17, 2026
9e87fdd
Paged mod browser implementation
Aurumbi Feb 17, 2026
1cc566a
Renamed steamhandler_ex to steamhandler_extensions
Aurumbi Feb 22, 2026
bfd9f1f
Transitioned page cache from unsafe pickle files to json files.
Aurumbi Feb 22, 2026
7246c68
Added proper thread management and cleanup to the threads created by …
Aurumbi Feb 23, 2026
dd96c57
Updated steam modlist preload to a more standard and manageable form,…
Aurumbi Feb 23, 2026
f0f534f
Removed extraneous comments. Improved comments.
Aurumbi Feb 23, 2026
02b1fd1
Added exception to CachedSteamMgr.QueryApi for when the cache file fa…
Aurumbi Mar 2, 2026
40c2126
Added and implemented error screen for modlist errors
Aurumbi Mar 2, 2026
13c951f
Changed modtools modlist removal from name-based to id-based
Aurumbi Mar 23, 2026
4b88d7f
Fixed multi-load_module error on some installations
Aurumbi Mar 23, 2026
c01f4de
Improved modlist error screen
Aurumbi Mar 23, 2026
5fc8bef
Made modlist preload dependant on internet, to prevent errors on offl…
Aurumbi Mar 23, 2026
e84597d
Fixed closing issues with no-internet modlist screen
Aurumbi Mar 23, 2026
980ae45
Fixed CacheWriteError getting cancelled via erroneous return
Aurumbi Mar 23, 2026
edae939
Updated page cache format to remove redundant information
Aurumbi Mar 23, 2026
0815c00
Improved unclear comments and error messages
Aurumbi Mar 23, 2026
6b3df50
Aligned SteamModlist's interface more closely with asyncio.Future to …
Aurumbi Mar 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions modloader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,41 @@ def report_mod_errors(errors): # NoReturn
raise


def report_modlist_errors(errors): # NoReturn
"""
Reports a modlist error to the user by displaying the modlist
error screen.

As the only actions available on the modlist error screen
are to "Reload" and "Quit", this function is guaranteed
not to return to the caller (except through Ren'Py's
control exceptions' unwinding.)

The structure of this function is taken from
report_mod_errors
"""
if not renpy.exports.has_screen("_modlist_errors"):
return True

renpy.display.error.init_display()

reload_action = renpy.exports.utter_restart

try:
renpy.game.invoke_in_new_context(
renpy.display.error.call_exception_screen,
"_modlist_errors",
reload_action=reload_action,
errors=errors,
)
except renpy.game.CONTROL_EXCEPTIONS:
raise
except:
renpy.display.log.write("While handling exception:")
renpy.display.log.exception()
raise


def resolve_dependencies():
"""Resolve mod dependencies and create mod load order"""
from modloader import modinfo
Expand Down Expand Up @@ -238,9 +273,9 @@ def main(reload_mods=False):

report_duplicate_labels()

if has_steam():
steammgr = get_instance()
steammgr.CachePersonas()
# if has_steam():
# steammgr = get_instance()
# steammgr.CachePersonas()

# By appending the mod folder to the import path we can do something like
# `import test` to import the mod named test in the mod folder.
Expand Down
137 changes: 122 additions & 15 deletions modloader/modconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from cStringIO import StringIO
import zipfile
from collections import namedtuple
import threading

import renpy
from renpy.audio.music import stop as _stop_music
Expand All @@ -19,8 +20,9 @@
from modloader.modinfo import get_mods
from modloader import get_mod_path, workshop_enabled
if workshop_enabled:
from steam_workshop.steam_config import has_valid_signature
from steam_workshop.steam_config import has_valid_signature, MODTOOLS_ID
import steam_workshop.steamhandler as steamhandler
import steamhandler_extensions


BRANCHES_API = "https://api.github.com/repos/AWSW-Modding/AWSW-Modtools/branches"
Expand Down Expand Up @@ -98,21 +100,126 @@ def github_downloadable_mods():
return sorted(data, key=lambda mod: mod[1].lower())


@cache
def steam_downloadable_mods():
# A different format,
# (id, mod_name, author, desc, image_url)
mods = []
for mod in sorted(steamhandler.get_instance().GetAllItems(), key=lambda mod: mod[1]):
file_id = mod[0]
create_time, modify_time, signature = mod[5:8]
is_valid, verified = has_valid_signature(file_id, create_time, modify_time, signature)
if is_valid:
mods.append(list(mod[:5]))
mods[-1][3] += "\n\nVerified by {}".format(verified.username.replace("<postmaster@example.com>", ""))
class TimeoutError(Exception):
"""Represents a timeout for waiting for a load. mostly allows the timeout parameter in the get() method, to differentiate between a timeout and a failure."""
pass

class SteamModlist:
"""Manages the steam modlist, as is gotten by the steam_downloadable_mods method.
It supports loading the modlist in a separate thread via the load method,
And caching such results.
This is needed as loading the modlist takes quite a while,
And is an operation we would much rather do at startup, without delaying anything else.
Any exceptions raised in the loading process will be available through the get() method.
Once the load finishes, get() will either return a value (if no exception was raised during loading), or raise the exception raised during loading.
"""

def __init__(self):
self._loading_thread = None
self._loading_thread_lock = threading.Lock()
self._loaded_data = None
self._exception = None
self._is_loaded = threading.Event()
return

def _loading_function(self):
"""Loads and verifies the steam modlist data.
It must take no arguments, and return a single value: the loaded data.
It may raise an exception, in which case it'll be stored in self._exception.
"""

# A different format,
# (id, mod_name, author, desc, image_url)

# This uses GetAllItems(), Which is affected by the QueryApi crash.
# therefore, steamhandler_extensions are preferred
mods = []
for mod in sorted(steamhandler_extensions.get_instance().GetAllItems(), key=lambda mod: mod[1]):
if mod[0] == MODTOOLS_ID:
continue # The modtools themselves need not be here (as they're already present and can't be removed using themselves), nor should the signing system complain about them...
file_id = mod[0]
create_time, modify_time, signature = mod[5:8]
is_valid, verified = has_valid_signature(file_id, create_time, modify_time, signature)
if is_valid:
mods.append(list(mod[:5]))
mods[-1][3] += "\n\nVerified by {}".format(verified.username.replace("<postmaster@example.com>", ""))
else:
print "NOT VALID SIG", mod[1] # Note: printing only the mod name, instead of the whole thing SIGNIFICANTLY speeds up this call
return mods

def _load_and_set(self):
"""Calls the _loading_function and sets the internal values based on its results."""
try:
mods = self._loading_function()
self._loaded_data = mods
print "Finished steam modlist load without errors"
except Exception as e:
print "Finished steam modlist load with errors"
self._exception = e
self._exception.traceback = sys.exc_info()[2]

self._is_loaded.set()
print "Done loading steam modlist"
return

def load(self):
"""Starts loading the steam modlist data if it is not already being loaded.
This method starts a thread which loads the modlist data.
It guarantees that for any number of repeated calls to it from any number of threads, only one loading thread will be started.
Once the data is loaded, it is available through the get method.
"""
with self._loading_thread_lock:
if self._loading_thread is None:
print "Steam modlist thread not present, Starting..."
self._loading_thread = threading.Thread(target=self._load_and_set, name=u"Thread-load-SteamModlist")
self._loading_thread.start()
else:
print "Steam modlist thread already present"
return self._loading_thread

def get(self, timeout=None):
"""Get the steam modlist data.
If the data has already loaded, this method returns with it immediately,
Otherwise, load() is called, and this method blocks using self.wait(timeout).
:returns steam modlist data, If timeout has not been reached and the loading thread has not raised an error.
:raises Exception, If timeout has not been reached and the loading thread has raised an error. this raises that very exception.
:raises TimeoutError, If timeout has been reached.
"""
if self.is_loaded():
# Note: while the value of is_done can change between checking it here and referring to _loaded_data,
# It can only change from False to True.
# In that case the load() method has been called before and is currently finishing,
# And it'll be called again here, ignored, and _is_loaded will be waited upon, which will finish only once _loaded_data is available.

print "Steam modlist data already available"
else:
print "NOT VALID SIG", mod
return mods
print "Steam modlist data not available, calling load"
self.load()
self.wait(timeout)
print "Loading done, fetching data"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't seem to handle exceptions?

Copy link
Copy Markdown
Author

@Aurumbi Aurumbi Mar 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is designed such that any exception raised in loading (which can only come from self._loading_function) is captured by self._load_and_set, and made available through self.get_exception. as the loading is done away from the main thread, it shouldn't raise exceptions in the main thread, and it's not able to report them by itself. this way, the error information is present, and the responsibility for handling them is on the caller.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Though I do see that this preload class should probably behave more like asyncio.Future, as in removing the get_exception method and having this method raise those exceptions...


if self._exception is not None:
raise self._exception

return self._loaded_data

def is_loaded(self):
return self._is_loaded.is_set()

def wait(self, timeout=None):
"""Waits for timeout seconds until loading is finished. if timeout is None (default), waits indefinitely until loading is finished.
:returns None if loading is finished before timeout elapsed.
:raises TimeoutError if timeout has expired before loading is finished."""
if not self._is_loaded.wait(timeout):
raise TimeoutError(type(self).__name__)
return


steam_mod_list = SteamModlist()


def steam_downloadable_mods():
return steam_mod_list.get()


def download_github_mod(download_link, name, show_download=True, reload_script=True):
Expand Down
49 changes: 49 additions & 0 deletions modloader/patch_errorhandling_screens.rpym
Original file line number Diff line number Diff line change
Expand Up @@ -476,3 +476,52 @@ screen _modloader_errors(errors,reload_action):

# Tooltip.
text tt.value


# Screen for displaying steam modlist loading errors
# Note: this is copied over from _modloader_errors
screen _modlist_errors(errors,reload_action):
modal True
zorder 1090

default tt = __Tooltip("")

frame:
style_group ""

has side "t c b":
spacing gui._scale(10)

label _("Could not load the steam modlist.")

viewport:
id "viewport"
child_size (1400, None)
mousewheel True
scrollbars "both"
xfill True
yfill True

has vbox

text __format_traceback(errors) substitute False

vbox:

hbox:
spacing gui._scale(25)

textbutton _("Reload"):
action reload_action
hovered tt.action(_("Reloads the game from disk. (The same as quitting and restarting.)"))

vbox:
xfill True

textbutton _("Quit"):
action __ErrorQuit()
hovered tt.action(_("Quits the game."))
xalign 1.0

# Tooltip.
text tt.value
Loading