Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
23 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
6da6649
Removed return statement from new __init__ calls
Aurumbi Apr 14, 2026
5c07028
Moved page cache dir to `renpy.config.savedir`, as to make it consistent
Aurumbi Apr 14, 2026
25ba747
Improved modlist error reporting. Made cache file check raise instead…
Aurumbi Apr 14, 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
6 changes: 3 additions & 3 deletions modloader/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,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() #TODO: Change to call the new thing...
Comment thread
Aurumbi marked this conversation as resolved.
Outdated

# 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
9 changes: 7 additions & 2 deletions modloader/modconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
if workshop_enabled:
from steam_workshop.steam_config import has_valid_signature
import steam_workshop.steamhandler as steamhandler
import steamhandler_ex
Comment thread
Aurumbi marked this conversation as resolved.
Outdated


BRANCHES_API = "https://api.github.com/repos/AWSW-Modding/AWSW-Modtools/branches"
Expand Down Expand Up @@ -102,16 +103,20 @@ def github_downloadable_mods():
def steam_downloadable_mods():
# A different format,
# (id, mod_name, author, desc, image_url)

# This uses GetAllItems(), Which is affected by the QueryApi crash.
# therefore, steamhandler_ex is preferred

mods = []
for mod in sorted(steamhandler.get_instance().GetAllItems(), key=lambda mod: mod[1]):
for mod in sorted(steamhandler_ex.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>", ""))
else:
print "NOT VALID SIG", mod
print "NOT VALID SIG", mod[1] # Note: printing only the mod name, instead of the whi=ole thing SIGNIFICANTLY speeds up this call
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 is slightly worrying - how many mods don't have a valid signature? I thought I'd signed most of them

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.

These 15 fail, though I haven't checked why

(3336106514) ALL WORKING MODS! 32 (Flight of Love doesn't show)
(3651582794) Angels With Scaly Wings: Chinese Simplified
(3666234554) Card Game Expanded 卡牌游戏增强(Two Languages Support)
(2801825863) Casual Arson
(2697982171) Casual Vandalism
(2096774388) Lorem_RPG
(2742645268) MagmaClient
(2990207385) Meet Naomi
(1305731599) Modtools
(2766323849) Name Re-entry
(3465212790) Naomi Maze Skip
(2736325690) Skip Credits
(2987066957) The Morning After
(3645564443) The Traitor
(3657449256) Vykoupení

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.

Most of those I can't remember anyone asking to sign, though I have signed 2 recently. The modtools itself is actually never signed so that's fine

Copy link
Copy Markdown
Author

@Aurumbi Aurumbi Feb 21, 2026

Choose a reason for hiding this comment

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

Ah, that's fair. I'm still hesitant to return the printing to how it was, As it seems to double the runtime of the validity check (from ~2 seconds per mod to ~5 seconds per failing mod)...
As for the modtools, I'll add a special case for it (to be ignored)

return mods


Expand Down
257 changes: 257 additions & 0 deletions modloader/steamhandler_ex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import os
import shutil
import time
import errno
import cPickle
import threading
from itertools import islice
import copy

import renpy.config

from steam_workshop.steamhandler import SteamMgr, PyCallback, cache
from steam_workshop import steamhandler


class CachedSteamMgr:
"""Holds a SteamMgr instance, and caches results of problematic actions (QueryApi, which gets workshop data as a whole, not parts),
So that calls to them will not fail when done repeatedly.
In QueryApi's case, This is done to bypass an existing cache which causes failures.
It is highly recommended to use this whenever one needs lists of steam mods (for example, the mod browser).
"""

__PAGE_CACHE_DIR = os.path.join(renpy.config.gamedir, "page_cache")

def __init__(self, steam_manager):
if not isinstance(steam_manager, SteamMgr):
raise TypeError("steam_manager must be a steam_workshop.steamhandler.SteamMgr instance!")
self._steam_manager = steam_manager
self.threads = [] # This can create threads in QueryApi, which will persist after the call returns (as is required).
Comment thread
Aurumbi marked this conversation as resolved.
Outdated
# We at least keep track of them, so that the dillagent programmer can ensure they're handled properly.
return


def register_callback(self, type, func):
return self._steam_manager.register_callback(type, func)

def unregister_callback(self, type, func):
return self._steam_manager.unregister_callback(type, func)


def is_file_stale(self, file_path):
"""True if cache file given by file_path is stale."""
# Testing if file has been updated in the last 15 minutes. If not, stale.
# Empirically, the problematic cache always becomes stale by this point.
curr_time = time.time()
cache_time = os.path.getmtime(file_path)
return (curr_time - cache_time) >= 15 * 60

def get_cache_filename(self, page):
"""Returns the cache filename matching this page number."""
if not isinstance(page, int):
raise TypeError("Page number must an integer!")
if page <= 0:
raise ValueError("Page number must be positive!")
return os.path.join(self.__PAGE_CACHE_DIR, "page_{:02}.pkl".format(page))


def _CallQueryApi(self, page):
"""Gets super's QueryApi(page), strictly by calling QueryApi.
Fills the cache as well, and keeps python thread trapped until cache has been filled."""
print "Cache callback: Current query callbacks:", "\n".join(str(func) for func in self._steam_manager.Callbacks[PyCallback.Query])
print "Cache callback: Current persona callbacks:", "\n".join(str(func) for func in self._steam_manager.Callbacks[PyCallback.Persona])


def fill_cache_query_cb(array, arr_len):
try:
print "Cache callback called with: (len={0}), array={1}".format(arr_len, array)

# Get cache file name
cache_file_name = self.get_cache_filename(page)
print "Cache file target: \"{}\"\n".format(cache_file_name)

# Ensure file can be created (ensure directories)
to_ensure = os.path.dirname(cache_file_name)
if not os.path.exists(to_ensure): # If not exists: create
os.makedirs(os.path.dirname(cache_file_name))
elif not os.path.isdir(to_ensure): # If exists and not dir: problem
raise OSError(errno.ENOTDIR, "The attempted directory \"{}\" exists and is not a directory.".format(to_ensure))
# else: already done

# Write cache file
with open(cache_file_name, "wb") as cache_file:
Comment thread
Aurumbi marked this conversation as resolved.
Outdated
cPickle.dump(arr_len, cache_file)
cPickle.dump(tuple(islice(array, arr_len)), cache_file)

except Exception as e:
print "Cache callback raised Exception:", str(e)
Comment thread
Aurumbi marked this conversation as resolved.
Outdated
finally:
print "Cache callback done."
fill_cache_query_cb.done = True
return


self.register_callback(PyCallback.Query, fill_cache_query_cb)
try:
fill_cache_query_cb.done = False

print "Calling QueryApi({})".format(page)
self._steam_manager.QueryApi(page)

reps = 0
while not fill_cache_query_cb.done:
print "Waiting for cache callback, rep {}".format(int(reps))
reps += 1
time.sleep(1)
print "Done cache callback"
return

finally:
self.unregister_callback(PyCallback.Query, fill_cache_query_cb)
return

def QueryApi(self, page):
"""Gets super's QueryApi(page), using the cache if available and not stale.
Cache is not stale for about 15 minutes, after which the problematic one should also be stale and not fail the program."""

print "Called cached queryAPI with page={}".format(page)

# Get most recent cache file if exists
cache_file_name = self.get_cache_filename(page)

print "cache file is: \"{}\"".format(cache_file_name)

# Check if cache file is available for use, and try to reclaim it if it is detected as a non-file entity.
is_cache_availbable = False
if os.path.exists(cache_file_name):
print "cache file Exists"
if os.path.isfile(cache_file_name):
print "cache file is a file"
is_cache_availbable = not self.is_file_stale(cache_file_name)
elif os.path.islink(cache_file_name):
print "cache file is a link to dir"
os.unlink(cache_file_name) # Clear symlink to directory in the position of the cache file...
else:
print "cache file is a dir"
shutil.rmtree(cache_file_name) # Clear directory in the position of the cache file...


if is_cache_availbable:
print "Using cache file"
# Read cache file
print "Reading cahce file \"{}\"".format(cache_file_name)
with open(cache_file_name, "rb") as cache_file:
arr_len = cPickle.load(cache_file)
array = cPickle.load(cache_file)
print arr_len

# Thread is used to match the behaviour of QueryApi, where the function returns quickly and before the callbacks are called
qapi_thread = threading.Thread(target=self._steam_manager.query_callback, kwargs={"array": array, "arr_len": arr_len})
self.threads.append(qapi_thread)
qapi_thread.start()
else:
print "Not using cache file"
self._CallQueryApi(page)
return



def GetSubscribedItems(self):
return self._steam_manager.GetSubscribedItems()

def GetAllItems(self, get_all=False):

# Implemented here with a performance boost (see commented out print of item),
# And with fix to overzealous repeat calls

# It seems the only way the callback can access these variables is through global variables
# Be careful!
results = []

def cb(array, arr_len):
print "Recieve items..."
cb.complete = False
# Querying a page is 50 results maximum
if arr_len == 51:
cb.should_run_next = False
cb.complete = True
return

for x in range(arr_len):
item = array[x]
if get_all:
all_data = copy.deepcopy((item.m_nPublishedFileId, item.m_eResult, item.m_eFileType,
item.m_nCreatorAppID, item.m_nConsumerAppID, item.m_rgchTitle,
item.m_rgchDescription, item.m_ulSteamIDOwner, item.m_rtimeCreated,
item.m_rtimeUpdated, item.m_rtimeAddedToUserList, item.m_eVisibility,
item.m_bBanned, item.m_bAcceptedForUse, item.m_bTagsTruncated,
item.m_rgchTags, item.m_hFile, item.m_hPreviewFile, item.m_pchFileName,
item.m_nFileSize, item.m_nPreviewFileSize, item.m_rgchURL, item.m_unVotesUp,
item.m_unVotesDown, item.m_flScore, item.m_unNumChildren,
item.m_pchPreviewLink, item.m_metadata))
results.append(all_data)
else:
not_all_data = copy.deepcopy((item.m_nPublishedFileId, item.m_rgchTitle, item.m_ulSteamIDOwner,
item.m_rgchDescription, item.m_pchPreviewLink, item.m_rtimeCreated,
item.m_rtimeUpdated, item.m_metadata))
results.append(not_all_data)

cb.should_run_next = (arr_len == 50)
cb.i += 1
cb.complete = True
return

cb.should_run_next = True
cb.i = 1
cb.complete = False

self.register_callback(PyCallback.Query, cb)

while cb.should_run_next:
cb.complete = False # Important! make sure that consecutive runs don't claim that the function is already finished!
self.QueryApi(cb.i)

# Block
while not cb.complete:
pass

# # Remove duplicates
# results = {item[0]: item for item in results}.values()

if not get_all:
adj_results = []
for i, item in enumerate(results):
print "Getting persona", i, item[1] #, item # Printing full items made this ~100x slower...
item = list(item)
item[2] = self.GetPersona(item[2])
adj_results.append(tuple(item))
results = adj_results

self.unregister_callback(PyCallback.Query, cb)

return results


def GetItemFromID(self, id):
return self._steam_manager.GetItemFromID(id)

@cache
def GetPersona(self, id):
return self._steam_manager.GetPersona(id)

@cache
def GetItemDownloadInfo(self, id):
return self._steam_manager.GetItemDownloadInfo(id)





def get_instance():
global _cached_instance

if "_cached_instance" not in globals():
_cached_instance = CachedSteamMgr(steamhandler.get_instance())

print "steamhandler_ex id={}".format(id(_cached_instance))
return _cached_instance
Loading