From 525aacab1cf0ae2993603c040b2f12c650f90cec Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 4 Aug 2026 17:56:32 +0300 Subject: [PATCH 01/13] Refactored existing things to allow for child list. Created mod changelist class to improve usability and clarity, and as a base for dependency resolution. --- modloader/modconfig.py | 12 +- modloader/steamhandler_extensions.py | 17 +- mods/core/download_mods.rpy | 245 +++++++++++++++++---------- mods/core/modules/modmenu_search.py | 12 +- mods/core/stubs.rpy | 1 - 5 files changed, 184 insertions(+), 103 deletions(-) diff --git a/modloader/modconfig.py b/modloader/modconfig.py index e2debb5..23764b8 100644 --- a/modloader/modconfig.py +++ b/modloader/modconfig.py @@ -71,7 +71,8 @@ def github_downloadable_mods(): name.replace("mod-", "", 1).encode("utf-8"), "DummyAuthor", "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?", - "http://s-media-cache-ak0.pinimg.com/originals/42/41/90/424190c7f88c514a1c26a79572d61191.png" + "http://s-media-cache-ak0.pinimg.com/originals/42/41/90/424190c7f88c514a1c26a79572d61191.png", + [] ]) return sorted(data, key=lambda mod: mod[1].lower()) @@ -80,7 +81,7 @@ def github_downloadable_mods(): def load_steam_modlist(): """Loads and verifies the steam modlist data.""" # A different format, - # (id, mod_name, author, desc, image_url) + # (id, mod_name, author, desc, image_url, child_list) # This uses GetAllItems(), Which is affected by the QueryApi crash. # therefore, steamhandler_extensions are preferred @@ -88,11 +89,16 @@ def load_steam_modlist(): 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... + + n_children, child_list = mod[8:10] + # as m_pvecChildrenId may still be a C type, we would rather just make it a list + child_list = [child_list[i] for i in range(n_children)] + 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.append(list(mod[:5]) + [child_list]) mods[-1][3] += "\n\nVerified by {}".format(verified.username.replace("", "")) else: print "NOT VALID SIG", mod[1] # Note: printing only the mod name, instead of the whole thing SIGNIFICANTLY speeds up this call diff --git a/modloader/steamhandler_extensions.py b/modloader/steamhandler_extensions.py index cbf8818..c39d06b 100644 --- a/modloader/steamhandler_extensions.py +++ b/modloader/steamhandler_extensions.py @@ -146,6 +146,11 @@ def fill_cache_query_cb(array, arr_len): field_names = [name for name, _ in WorkshopData._fields_] to_write = [{name: getattr(array[i], name) for name in field_names} for i in range(arr_len)] + # m_pvecChildrenId is a type that doesn't work for json, so we fix it up to be one + + for item in to_write: + item["m_pvecChildrenId"] = [item["m_pvecChildrenId"][i] for i in range(item["m_unNumChildren"])] + # Write cache file with open(cache_file_name, "w") as cache_file: json.dump(to_write, cache_file, encoding="utf-8") # While not strictly necessary, I'd rather be explicit with the encoding. @@ -260,8 +265,11 @@ def cb(array, arr_len): cb.page_complete.set() return + # child_nums = {} + for x in range(arr_len): item = array[x] + # child_nums[item.m_nPublishedFileId] = item.m_pvecChildrenId if get_all: results.append(copy.deepcopy((item.m_nPublishedFileId, item.m_eResult, item.m_eFileType, item.m_nCreatorAppID, item.m_nConsumerAppID, item.m_rgchTitle, @@ -270,14 +278,19 @@ def cb(array, arr_len): 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_unVotesDown, item.m_flScore, item.m_unNumChildren, item.m_pvecChildrenId, item.m_pchPreviewLink, item.m_metadata))) else: results.append(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)) + item.m_rtimeUpdated, item.m_metadata, + item.m_unNumChildren, item.m_pvecChildrenId)) ) + # with open(r"P:\AWSW_Messing_About\childinfo.txt", "a") as f: + # for k, v in child_nums.iteritems(): + # f.write("{}: {}\n".format(k, v)) + cb.should_run_next = (arr_len == 50) cb.page_complete.set() diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 8a36a67..8cbe535 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -161,7 +161,7 @@ init python: :param modlist: The base modlist to manage :param page_size: The page size of each mod page - :param filter_map: mapping from name (str) to filter func: the initial filter types which are available. if None (default), the initial filter map is empty + :param filter_map: mapping from name (str) to (filter_func, add_args): the initial filter types which are available. if None (default), the initial filter map is empty """ if page_size <= 0: raise ValueError("page_size={} must be positive!".format(page_size)) @@ -248,13 +248,16 @@ init python: self._filter_activity[name] = active self._apply_filters() - def register_filter(self, name, func): + def register_filter(self, name, func, add_args=tuple()): """Register a new filter func with the name name + :param name: the name of the new filter + :param func: the filter function. must take a mod tuple as its first argument, and the rest of the arguments (if present) must match add_args + :param add_args: an iterable of additional arguments for func, which are added to each call to it. default empty tuple, which adds no arguments. :raises KeyError if name is already present""" if name in self._filter_funcs: raise KeyError("\'{}\' is already registered".format(name)) - self._filter_funcs[name] = func + self._filter_funcs[name] = (func, add_args) self._filter_activity[name] = None @@ -274,22 +277,24 @@ init python: for entry in self._reordered_modlist: passed = True for name, status in self._filter_activity.iteritems(): - if status is not None and self._filter_funcs[name](entry) != status: - passed = False - break + if status is not None: + fulter_func, add_args = self._filter_funcs[name] + if fulter_func(entry, add_args) != status: + passed = False + break if passed: result_contents.append(entry) self._filtered_modlist = result_contents # _refresh_modlist(self, self.use_steam) - def _modmenu_do_then_refresh(func, modlist_manager, use_steam): + def _modmenu_do_then_refresh(func, modlist_manager, mod_changes, use_steam): """A wrapper which calls func and then refreshes the modlist display. necessary for searchbars, as they don't accept Actions""" def inner(*args, **kwargs): try: return func(*args, **kwargs) finally: - _refresh_modlist(modlist_manager, use_steam) + _refresh_modlist(modlist_manager, mod_changes, use_steam) return inner @@ -470,65 +475,107 @@ init -1 python: - def _refresh_modlist(modlist_manager, use_steam): + def _refresh_modlist(modlist_manager, mod_changes, use_steam): renpy.hide_screen('modmenu_paged_modlist') - renpy.show_screen('modmenu_paged_modlist', contents=modlist_manager.get_current_modlist_page(), use_steam=use_steam) + renpy.show_screen('modmenu_paged_modlist', contents=modlist_manager.get_current_modlist_page(), mod_changes=mod_changes, use_steam=use_steam) renpy.restart_interaction() - # Mod selection methods - _modmenu_mods_to_add = {} # Mods are Subscribed to once they are added the first time. they are installed on exit if they have not been removed. - _modmenu_mods_to_remove = {} # Mods are Unsubscribed and deleted on exit. this means that a mod that has been added then removed is deleted like any other removed mod. - + # modchange lists methods def _modmenu_is_mod_installed(mod_id, mod_name): """Is mod actually installed on the computer, regardless of modmenu status.""" return str(mod_id) in modinfo.get_mod_folders() or str(mod_name) in modinfo.get_mod_folders() - def _modmenu_is_mod_present(mod_id, mod_name): - """Will the mod be installed once the modmenu changes have been applied.""" - return (_modmenu_is_mod_installed(mod_id, mod_name) or _modmenu_is_mod_added(mod_id)) and not _modmenu_is_mod_removed(mod_id) + class Modchanges: + def __init__(self, base_modlist): + """A class holding the full modlist, along with functionality to request to install or uninstall a mod. + + :param base_modlist: The vendor modlist for this class + """ + + self._modlist = base_modlist + self._add_map = {} + self._dependant_add_map = {} + self._remove_map = {} + self._dependant_remove_map = {} + self._dependency_map = {} + self._r_dependency_map = {} + + for mod in self._modlist: + mod_id = mod[0] + mod_name = mod[1] + child_list = mod[5] + self._dependency_map[mod_id] = child_list + for child_key in child_list: + if child_key in self._r_dependency_map: + self._r_dependency_map[child_key].append(mod_id) + else: + self._r_dependency_map[child_key] = [mod_id] + + def get_mod(self, mod_id): + idx = [mod[0] for mod in self._modlist].index(mod_id) # Raises ValueError if mod is not present. intentionally not caught + return self._modlist[idx] + + def get_mod_by_name(self, mod_name): + idx = [mod[1] for mod in self._modlist].index(mod_name) # Raises ValueError if mod is not present. intentionally not caught + return self._modlist[idx] + + + def get_added_mods(self): + return self._add_map + + def get_removed_mods(self): + return self._remove_map - def _modmenu_add_mod(mod_id, mod_name=""): - print "adding mod:", mod_id, mod_name - if mod_id in _modmenu_mods_to_remove: # Added, then removed this session - _modmenu_mods_to_remove.pop(mod_id) - elif mod_id not in _modmenu_mods_to_add: # Not added yet, and not an existing mod being reinstated - _modmenu_mods_to_add[mod_id] = mod_name - # else: nothing to do... + def is_mod_added(self, mod_id): + return mod_id in self.get_added_mods() - def _modmenu_remove_mod(mod_id, mod_name="", filename=""): - print "removing mod:", mod_id, mod_name, filename - if mod_id in _modmenu_mods_to_add: - _modmenu_mods_to_add.pop(mod_id) - elif mod_id not in _modmenu_mods_to_remove: - _modmenu_mods_to_remove[mod_id] = (mod_name, filename) + def is_mod_removed(self, mod_id): + return mod_id in self.get_removed_mods() - def _modmenu_clear_added_mods(): - _modmenu_mods_to_add.clear() - def _modmenu_clear_removed_mods(): - _modmenu_mods_to_remove.clear() + def is_mod_installed(self, mod_id): + """Version of _modmenu_is_mod_installed which matches the rest of the functions here""" + mod = self.get_mod(mod_id) + return _modmenu_is_mod_installed(mod_id, mod[1]) - def _modmenu_get_added_mods(): - return _modmenu_mods_to_add + def is_mod_present(self, mod_id): + """Will the mod be installed once the modmenu changes have been applied.""" + return (self.is_mod_installed(mod_id) or self.is_mod_added(mod_id)) and not self.is_mod_removed(mod_id) - def _modmenu_get_removed_mods(): - return _modmenu_mods_to_remove - def _modmenu_is_mod_added(mod_id): - return mod_id in _modmenu_mods_to_add + def add_mod(self, mod_id): + print "adding mod:", mod_id + if mod_id in self._remove_map: # Added, then removed this session + self._remove_map.pop(mod_id) + elif mod_id not in self._add_map: # Not added yet, and not an existing mod being reinstated + self._add_map[mod_id] = self.get_mod(mod_id)[1] + # else: nothing to do... - def _modmenu_is_mod_removed(mod_id): - return mod_id in _modmenu_mods_to_remove + def remove_mod(self, mod_id, filename=""): + print "removing mod:", mod_id, filename + if mod_id in self._add_map: + self._add_map.pop(mod_id) + elif mod_id not in self._remove_map: + self._remove_map[mod_id] = (self.get_mod(mod_id)[1], filename) - def _modmenu_get_mod_actions(): - """Using the add and remove list, get the list of mods to install, and the list of mods to uninstall. - This is distinct from the basic getters as the list of mods to install may not contain any mod that should be removed. - :returns 2-tuple containing a dict[int: str], and a set[int]. - the dict is the dict of mods to install, from mod id to modname. - the set is the set of mods to uninstall, by mod id.""" - mods_to_install = {mod_id: mod_name for mod_id, mod_name in _modmenu_mods_to_add.iteritems() if mod_id not in _modmenu_mods_to_remove} - return mods_to_install, _modmenu_get_removed_mods() + def clear_added_mods(self): + self._add_map.clear() + + def clear_removed_mods(self): + self._remove_map.clear() + + def clear_mods(self): + self.clear_added_mods() + self.clear_removed_mods() + + + # Vendor modfolder functions + def _steam_get_modfolder(mod_id, mod_name): + return str(mod_id) + + def _github_get_modfolder(mod_id, mod_name): + return mod_name @@ -576,14 +623,17 @@ screen modmenu_entrance(use_steam): screen modmenu_paged(contents, use_steam): modal True + default mod_changes = Modchanges(contents) + python: - filter_map = {"install": lambda mod: _modmenu_is_mod_installed(mod[0], mod[1]), - "select": lambda mod: _modmenu_is_mod_added(mod[0]) or _modmenu_is_mod_removed(mod[0]), - "present": lambda mod: _modmenu_is_mod_present(mod[0], mod[1]), + filter_map = {"install": (lambda mod, mod_changes: mod_changes.is_mod_installed(mod[0]), mod_changes), + "select": (lambda mod, mod_changes: mod_changes.is_mod_added(mod[0]) or mod_changes.is_mod_removed(mod[0]), mod_changes), + "present": (lambda mod, mod_changes: mod_changes.is_mod_present(mod[0]), mod_changes), } default modlist_manager = ModscreenModlistManager(contents, filter_map=filter_map, use_steam=use_steam) + frame id "modmenu_paged" at alpha_dissolve: add "image/ui/ingame_menu_bg3.png" @@ -632,7 +682,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 # Tried to bind this to shift+scroll, but it didn't work... action [Function(modlist_manager.move_current_page, -5), - Function(_refresh_modlist, modlist_manager, use_steam) + Function(_refresh_modlist, modlist_manager, mod_changes, use_steam) ] sensitive (modlist_manager.get_current_page() > 1) @@ -641,7 +691,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 keysym "mousedown_4" action [Function(modlist_manager.move_current_page, -1), - Function(_refresh_modlist, modlist_manager, use_steam) + Function(_refresh_modlist, modlist_manager, mod_changes, use_steam) ] sensitive (modlist_manager.get_current_page() > 1) @@ -658,7 +708,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 keysym "mousedown_5" action [Function(modlist_manager.move_current_page, 1), - Function(_refresh_modlist, modlist_manager, use_steam) + Function(_refresh_modlist, modlist_manager, mod_changes, use_steam) ] sensitive (modlist_manager.get_current_page() < modlist_manager.get_max_page()) @@ -667,12 +717,12 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 # Also tried to bind this to shift+scroll, but it didn't work... action [Function(modlist_manager.move_current_page, 5), - Function(_refresh_modlist, modlist_manager, use_steam) + Function(_refresh_modlist, modlist_manager, mod_changes, use_steam) ] sensitive (modlist_manager.get_current_page() < modlist_manager.get_max_page()) - $ n_added_mods = len(_modmenu_get_added_mods()) - $ n_removed_mods = len(_modmenu_get_removed_mods()) + $ n_added_mods = len(mod_changes.get_added_mods()) + $ n_removed_mods = len(mod_changes.get_removed_mods()) if n_added_mods: if n_removed_mods: @@ -700,8 +750,8 @@ screen modmenu_paged(contents, use_steam): xsize 425 ysize 125 - action [Function(print, "added:", _modmenu_get_added_mods(), "\nremoved:", _modmenu_get_removed_mods()), - Show("modmenu_apply_confirm", use_steam=use_steam)] + action [Function(print, "added:", mod_changes.get_added_mods(), "\nremoved:", mod_changes.get_removed_mods()), + Show("modmenu_apply_confirm", mod_changes=mod_changes, use_steam=use_steam)] sensitive bool(n_added_mods) or bool(n_removed_mods) # Searchbars @@ -760,7 +810,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 size 24 pixel_width 320 # While the horizontal space is supposed to be 340, The inputs have a tendency to drop down a row... - changed _modmenu_do_then_refresh(modlist_manager.set_author_query, modlist_manager, use_steam) + changed _modmenu_do_then_refresh(modlist_manager.set_author_query, modlist_manager, mod_changes, use_steam) button: @@ -779,7 +829,7 @@ screen modmenu_paged(contents, use_steam): ycenter 0.5 size 24 pixel_width 320 - changed _modmenu_do_then_refresh(modlist_manager.set_query, modlist_manager, use_steam) + changed _modmenu_do_then_refresh(modlist_manager.set_query, modlist_manager, mod_changes, use_steam) key "K_ESCAPE" action [SetScreenVariable("focus_query_input", False), SetScreenVariable("focus_author_query_input", False)] key "K_TAB" action [ToggleScreenVariable("focus_author_query_input"), If(focus_author_query_input, @@ -841,14 +891,14 @@ screen modmenu_paged(contents, use_steam): idle im.Scale("ui/nsfw_chbox-unchecked.png", _im_size, _im_size) action [Function(modlist_manager.set_filter_active, filter_name, If(_status is True, None, True)), - Function(_refresh_modlist, modlist_manager, use_steam) + Function(_refresh_modlist, modlist_manager, mod_changes, use_steam) ] alternate [Function(modlist_manager.set_filter_active, filter_name, If(_status is False, None, False)), - Function(_refresh_modlist, modlist_manager, use_steam) + Function(_refresh_modlist, modlist_manager, mod_changes, use_steam) ] - on "show" action [Function(_refresh_modlist, modlist_manager, use_steam), + on "show" action [Function(_refresh_modlist, modlist_manager, mod_changes, use_steam), Function(_preload_mod_images, contents, None), Function(im.cache.clear) # I tended to get 'out of memory' errors on this menu, so we use this precaution ] @@ -856,13 +906,17 @@ screen modmenu_paged(contents, use_steam): on "hide" action [Function(mod_image_preloader.clear), # Cleanup after ourselves Function(im.cache.clear), Function(modmenu_search.clear_cache), - Function(_modmenu_clear_added_mods), - Function(_modmenu_clear_removed_mods) + Function(mod_changes.clear_mods), ] -screen modmenu_paged_modlist(contents, use_steam): +screen modmenu_paged_modlist(contents, mod_changes, use_steam): + if use_steam: + $ _get_modfolder = _steam_get_modfolder + else: + $ _get_modfolder = _github_get_modfolder + frame: background None yminimum 900 @@ -883,7 +937,7 @@ screen modmenu_paged_modlist(contents, use_steam): cols 1 spacing 30 - for modid, name, author, description, url in contents: + for modid, name, author, description, url, child_list in contents: $ modname = modmenu_name_cleaner(name) if len(modname) > 21: @@ -898,20 +952,20 @@ screen modmenu_paged_modlist(contents, use_steam): $ modname = modname[:30] $ modname = "{size=-10}" + modname + "{/size}" - if _modmenu_is_mod_installed(modid, name): + if mod_changes.is_mod_installed(modid): $ modname += "\n{size=-5}(Installed" - if _modmenu_is_mod_removed(modid): + if mod_changes.is_mod_removed(modid): $ modname += ", removed" $ modname += "){/size}" - elif _modmenu_is_mod_added(modid): + elif mod_changes.is_mod_added(modid): $ modname += "\n{size=-5}(Added){/size}" textbutton "[modname]": style "modmenu_select_btn" - if _modmenu_is_mod_added(modid): + if mod_changes.is_mod_added(modid): background "#007f009B" hover_background "#7fff7f9B" - elif _modmenu_is_mod_removed(modid): + elif mod_changes.is_mod_removed(modid): background "#7f00009B" hover_background "#ff7f7f9B" @@ -922,13 +976,15 @@ screen modmenu_paged_modlist(contents, use_steam): author=unicode(author, "utf8"), description=unicode(description, "utf8"), url=url, + child_list=child_list, + mod_changes=mod_changes, use_steam=use_steam, ), Play("audio", "se/sounds/open.ogg")] - alternate [If(_modmenu_is_mod_present(modid, name), - Function(_modmenu_remove_mod, modid, name, If(use_steam, str(modid), name)), - Function(_modmenu_add_mod, modid, name)), + alternate [If(mod_changes.is_mod_present(modid), + Function(mod_changes.remove_mod, modid, _get_modfolder(modid, name)), + Function(mod_changes.add_mod, modid)), Play("audio", "se/sounds/open.ogg")] ] @@ -936,7 +992,12 @@ screen modmenu_paged_modlist(contents, use_steam): -screen modmenu_mod_content(modid, name, author, description, url, use_steam): +screen modmenu_mod_content(modid, name, author, description, url, child_list, mod_changes, use_steam): + if use_steam: + $ _get_modfolder = _steam_get_modfolder + else: + $ _get_modfolder = _github_get_modfolder + frame: add "ui/modcontent_frame.png": xoffset -10 yoffset 10 xpos 0.275 ypos 0.21 @@ -995,10 +1056,10 @@ screen modmenu_mod_content(modid, name, author, description, url, use_steam): null width 350 - if _modmenu_is_mod_present(modid, name): + if mod_changes.is_mod_present(modid): textbutton "Uninstall": ycenter 0.5 - action [Function(_modmenu_remove_mod, modid, name, If(use_steam, str(modid), name)), + action [Function(mod_changes.remove_mod, modid, _get_modfolder(str(modid), name)), Play("audio", "se/sounds/open.ogg")] style "modmenu_content_btn" text_style "modmenu_select_btn_text" @@ -1007,8 +1068,7 @@ screen modmenu_mod_content(modid, name, author, description, url, use_steam): else: textbutton "Install": ycenter 0.5 - action [Function(_modmenu_add_mod, modid, name), -# Show("modmenu_install_confirm", modid=modid, modname=name, use_steam=use_steam), + action [Function(mod_changes.add_mod, modid), Play("audio", "se/sounds/open.ogg")] style "modmenu_content_btn" text_style "modmenu_select_btn_text" @@ -1042,13 +1102,13 @@ transform _button_zoom: zoom 40.0 / 54.0 -screen modmenu_apply_confirm(use_steam): +screen modmenu_apply_confirm(mod_changes, use_steam): modal True python: from modloader.modconfig import apply_mod_changes as apply_mod_changes - mods_to_install = _modmenu_get_added_mods() - mods_to_uninstall = {mod_name: filename for mod_name, filename in _modmenu_get_removed_mods().itervalues()} + mods_to_install = mod_changes.get_added_mods() + mods_to_uninstall = {mod_name: filename for mod_name, filename in mod_changes.get_removed_mods().itervalues()} n_mods_to_install = len(mods_to_install) n_mods_to_uninstall = len(mods_to_uninstall) @@ -1077,8 +1137,8 @@ screen modmenu_apply_confirm(use_steam): xcenter 0.5 yanchor 0.0 - $ mods_to_add_text = "\n".join(_modmenu_get_added_mods().itervalues()) - $ mods_to_remove_text = "\n".join(modname for modname, filename in _modmenu_get_removed_mods().itervalues()) + $ mods_to_add_text = "\n".join(mods_to_install.itervalues()) + $ mods_to_remove_text = "\n".join(modname for modname, filename in mods_to_uninstall.iteritems()) fixed: # Added mods list: fixed is used to force list+scrollbar to stay within their region xalign 0.0 @@ -1099,7 +1159,7 @@ screen modmenu_apply_confirm(use_steam): xfill True mousewheel "change" - for modid, modname in _modmenu_get_added_mods().iteritems(): + for modid, modname in mods_to_install.iteritems(): hbox: spacing 20 ysize 60 @@ -1109,7 +1169,7 @@ screen modmenu_apply_confirm(use_steam): hover "image/ui/close_hover.png" yalign 0.5 - action Function(_modmenu_remove_mod, modid) # As mod is guaranteed to be in the add list, we only need id to remove. + action Function(mod_changes.remove_mod, modid) # As mod is guaranteed to be in the add list, we only need id to remove. text modname: if len(modname) > 30: @@ -1141,7 +1201,7 @@ screen modmenu_apply_confirm(use_steam): xfill True mousewheel "change" - for modid, (modname, _) in _modmenu_get_removed_mods().iteritems(): + for modname in mods_to_uninstall.iterkeys(): hbox: spacing 20 ysize 60 @@ -1150,8 +1210,7 @@ screen modmenu_apply_confirm(use_steam): idle "image/ui/close_idle.png" at _button_zoom hover "image/ui/close_hover.png" yalign 0.5 - - action Function(_modmenu_add_mod, modid) # As mod is guaranteed to be in the remove list, we only need id to add. + action Function(mod_changes.add_mod, mod_changes.get_mod_by_name(modname)[0]) text modname: if len(modname) > 30: diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 7fe8484..482d5cd 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -42,7 +42,9 @@ def jaro_split_compare(query, modlist): comps = {} query_words = set(query.lower().split()) - for _, name, _, desc, _ in modlist: + for mod in modlist: + name = mod[1] + desc = mod[3] comps[name] = jaro_set_similarity(query_words, name, desc) return comps @@ -52,7 +54,9 @@ def jaro_author_compare(author_query, modlist): comps = {} author_query = author_query.lower() - for _, name, author, _, _ in modlist: + for mod in modlist: + name = mod[1] + author = mod[2] comps[name] = jaro_similarity(author_query, author.lower()) return comps @@ -63,7 +67,7 @@ def sort_best(query, modlist, author_query="", return_score=False): if query.strip(): similarities = jaro_split_compare(query, modlist) else: - similarities = {name: (0.0, 0.0) for _, name, _, _, _ in modlist} + similarities = {mod[1]: (0.0, 0.0) for mod in modlist} if author_query.strip(): author_similarities = jaro_author_compare(author_query, modlist) else: @@ -79,7 +83,7 @@ def sort_best(query, modlist, author_query="", return_score=False): if comp_func(mod_order[0]) <= 0.3: # All bad matches, don't reorder print "No good matches. reordering suppressed" - mod_order = list((name, 0.0) for _, name, _, _, _ in modlist) + mod_order = list((mod[1], 0.0) for mod in modlist) mods_by_name = {mod[1]: mod for mod in modlist} diff --git a/mods/core/stubs.rpy b/mods/core/stubs.rpy index 7a43625..685e349 100644 --- a/mods/core/stubs.rpy +++ b/mods/core/stubs.rpy @@ -62,7 +62,6 @@ init python: substitute=False), .1 if not is_removing: - print use_steam if use_steam: mod_id = curr steammgr = get_instance() From 8e92d6a4c0401c76d21189fe2875d49aa390c68a Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Sun, 9 Aug 2026 20:11:51 +0300 Subject: [PATCH 02/13] Created ModlistEntry namedtuple to allow by-name access to fields in the modlists, and used by-name access to fields. standardized names of mod entry fields. --- modloader/modconfig.py | 13 ++-- mods/core/download_mods.rpy | 105 ++++++++++++++-------------- mods/core/modules/modmenu_search.py | 12 ++-- 3 files changed, 65 insertions(+), 65 deletions(-) diff --git a/modloader/modconfig.py b/modloader/modconfig.py index 23764b8..b7bfd55 100644 --- a/modloader/modconfig.py +++ b/modloader/modconfig.py @@ -8,6 +8,7 @@ from urllib2 import urlopen import json from cStringIO import StringIO +from collections import namedtuple import zipfile import renpy @@ -56,6 +57,7 @@ def report_exception(overview, error_str): #steammgr.HandleException(exception_str) +ModlistEntry = namedtuple("ModlistEntry", ["id", "name", "author", "desc", "image_url", "child_list"]) @cache def github_downloadable_mods(): @@ -66,14 +68,14 @@ def github_downloadable_mods(): for branch in branches: name = branch["name"] if name.startswith("mod-"): - data.append([ + data.append(ModlistEntry( ZIP_LOCATION.format(mod_name=name), name.replace("mod-", "", 1).encode("utf-8"), "DummyAuthor", "Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?", "http://s-media-cache-ak0.pinimg.com/originals/42/41/90/424190c7f88c514a1c26a79572d61191.png", [] - ]) + )) return sorted(data, key=lambda mod: mod[1].lower()) @@ -81,7 +83,7 @@ def github_downloadable_mods(): def load_steam_modlist(): """Loads and verifies the steam modlist data.""" # A different format, - # (id, mod_name, author, desc, image_url, child_list) + # (id, name, author, desc, image_url, child_list) # This uses GetAllItems(), Which is affected by the QueryApi crash. # therefore, steamhandler_extensions are preferred @@ -98,8 +100,9 @@ def load_steam_modlist(): 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]) + [child_list]) - mods[-1][3] += "\n\nVerified by {}".format(verified.username.replace("", "")) + next_mod = list(mod[:5]) + [child_list] + next_mod[3] += "\n\nVerified by {}".format(verified.username.replace("", "")) + mods.append(ModlistEntry(*next_mod)) 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 diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 8cbe535..95e42e8 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -396,7 +396,7 @@ init -1 python: if internet_on(): self.set_state(EntranceStates.MODLIST) try: - # (modid, name, author, description, image) (for github) + # (mod_url, name, author, description, image) (for github) # (id, name, author, desc, image) (for steam) if use_steam: from modloader.modconfig import steam_downloadable_mods as download_mods @@ -502,9 +502,8 @@ init -1 python: self._r_dependency_map = {} for mod in self._modlist: - mod_id = mod[0] - mod_name = mod[1] - child_list = mod[5] + mod_id = mod.id + child_list = mod.child_list self._dependency_map[mod_id] = child_list for child_key in child_list: if child_key in self._r_dependency_map: @@ -513,11 +512,11 @@ init -1 python: self._r_dependency_map[child_key] = [mod_id] def get_mod(self, mod_id): - idx = [mod[0] for mod in self._modlist].index(mod_id) # Raises ValueError if mod is not present. intentionally not caught + idx = [mod.id for mod in self._modlist].index(mod_id) # Raises ValueError if mod is not present. intentionally not caught return self._modlist[idx] def get_mod_by_name(self, mod_name): - idx = [mod[1] for mod in self._modlist].index(mod_name) # Raises ValueError if mod is not present. intentionally not caught + idx = [mod.name for mod in self._modlist].index(mod_name) # Raises ValueError if mod is not present. intentionally not caught return self._modlist[idx] @@ -537,7 +536,7 @@ init -1 python: def is_mod_installed(self, mod_id): """Version of _modmenu_is_mod_installed which matches the rest of the functions here""" mod = self.get_mod(mod_id) - return _modmenu_is_mod_installed(mod_id, mod[1]) + return _modmenu_is_mod_installed(mod_id, mod.name) def is_mod_present(self, mod_id): """Will the mod be installed once the modmenu changes have been applied.""" @@ -549,7 +548,7 @@ init -1 python: if mod_id in self._remove_map: # Added, then removed this session self._remove_map.pop(mod_id) elif mod_id not in self._add_map: # Not added yet, and not an existing mod being reinstated - self._add_map[mod_id] = self.get_mod(mod_id)[1] + self._add_map[mod_id] = self.get_mod(mod_id).name # else: nothing to do... def remove_mod(self, mod_id, filename=""): @@ -557,7 +556,7 @@ init -1 python: if mod_id in self._add_map: self._add_map.pop(mod_id) elif mod_id not in self._remove_map: - self._remove_map[mod_id] = (self.get_mod(mod_id)[1], filename) + self._remove_map[mod_id] = (self.get_mod(mod_id).name, filename) def clear_added_mods(self): self._add_map.clear() @@ -626,9 +625,9 @@ screen modmenu_paged(contents, use_steam): default mod_changes = Modchanges(contents) python: - filter_map = {"install": (lambda mod, mod_changes: mod_changes.is_mod_installed(mod[0]), mod_changes), - "select": (lambda mod, mod_changes: mod_changes.is_mod_added(mod[0]) or mod_changes.is_mod_removed(mod[0]), mod_changes), - "present": (lambda mod, mod_changes: mod_changes.is_mod_present(mod[0]), mod_changes), + filter_map = {"install": (lambda mod, mod_changes: mod_changes.is_mod_installed(mod.id), mod_changes), + "select": (lambda mod, mod_changes: mod_changes.is_mod_added(mod.id) or mod_changes.is_mod_removed(mod.id), mod_changes), + "present": (lambda mod, mod_changes: mod_changes.is_mod_present(mod.id), mod_changes), } default modlist_manager = ModscreenModlistManager(contents, filter_map=filter_map, use_steam=use_steam) @@ -659,8 +658,7 @@ screen modmenu_paged(contents, use_steam): Hide("modmenu_entrance", transition=dissolve), Stop("modmenu_music", fadeout=1.0), Play("music", "mx/menu.ogg", fadein=1.0), - Play("audio", "se/sounds/close.ogg") - ] + Play("audio", "se/sounds/close.ogg")] xpos 0.94 ypos 0.02 @@ -937,54 +935,50 @@ screen modmenu_paged_modlist(contents, mod_changes, use_steam): cols 1 spacing 30 - for modid, name, author, description, url, child_list in contents: - $ modname = modmenu_name_cleaner(name) + for mod in contents: + $ mod_id = mod.id + $ child_list = mod.child_list + $ mod_button_text = modmenu_name_cleaner(mod.name) - if len(modname) > 21: - #if modname is greater than 21 characters, decrease size of font by 5 - if len(modname) <= 25: - $ modname = "{size=-5}" + modname + "{/size}" + if len(mod_button_text) > 21: + #if mod_button_text is greater than 21 characters, decrease size of font by 5 + if len(mod_button_text) <= 25: + $ mod_button_text = "{size=-5}" + mod_button_text + "{/size}" - #if modname is greater than 25 characters, decrease size of font by 10 + #if mod_button_text is greater than 25 characters, decrease size of font by 10 else: - #if modname is greater than 30 characters, decrease size of font by 10 and cut all text after 30 places -# if len(modname) > 30: - $ modname = modname[:30] - $ modname = "{size=-10}" + modname + "{/size}" - - if mod_changes.is_mod_installed(modid): - $ modname += "\n{size=-5}(Installed" - if mod_changes.is_mod_removed(modid): - $ modname += ", removed" - $ modname += "){/size}" - elif mod_changes.is_mod_added(modid): - $ modname += "\n{size=-5}(Added){/size}" - - textbutton "[modname]": + #if mod_button_text is greater than 30 characters, decrease size of font by 10 and cut all text after 30 places + $ mod_button_text = mod_button_text[:30] + $ mod_button_text = "{size=-10}" + mod_button_text + "{/size}" + + if mod_changes.is_mod_installed(mod_id): + $ mod_button_text += "\n{size=-5}(Installed" + if mod_changes.is_mod_removed(mod_id): + $ mod_button_text += ", removed" + $ mod_button_text += "){/size}" + elif mod_changes.is_mod_added(mod_id): + $ mod_button_text += "\n{size=-5}(Added){/size}" + + textbutton "[mod_button_text]": style "modmenu_select_btn" - if mod_changes.is_mod_added(modid): + if mod_changes.is_mod_added(mod_id): background "#007f009B" hover_background "#7fff7f9B" - elif mod_changes.is_mod_removed(modid): + elif mod_changes.is_mod_removed(mod_id): background "#7f00009B" hover_background "#ff7f7f9B" action [Hide("modmenu_mod_content"), Show("modmenu_mod_content", - modid=modid, - name=unicode(name, "utf8"), - author=unicode(author, "utf8"), - description=unicode(description, "utf8"), - url=url, - child_list=child_list, + mod=mod, mod_changes=mod_changes, use_steam=use_steam, ), Play("audio", "se/sounds/open.ogg")] - alternate [If(mod_changes.is_mod_present(modid), - Function(mod_changes.remove_mod, modid, _get_modfolder(modid, name)), - Function(mod_changes.add_mod, modid)), + alternate [If(mod_changes.is_mod_present(mod_id), + Function(mod_changes.remove_mod, mod_id, _get_modfolder(mod_id, mod.name)), + Function(mod_changes.add_mod, mod_id)), Play("audio", "se/sounds/open.ogg")] ] @@ -992,7 +986,14 @@ screen modmenu_paged_modlist(contents, mod_changes, use_steam): -screen modmenu_mod_content(modid, name, author, description, url, child_list, mod_changes, use_steam): + +screen modmenu_mod_content(mod, mod_changes, use_steam): + $ mod_id = mod.id + $ name = unicode(mod.name, "utf8") + $ author = unicode(mod.author, "utf8") + $ description = unicode(mod.desc, "utf8") + $ url = mod.image_url + if use_steam: $ _get_modfolder = _steam_get_modfolder else: @@ -1056,10 +1057,10 @@ screen modmenu_mod_content(modid, name, author, description, url, child_list, mo null width 350 - if mod_changes.is_mod_present(modid): + if mod_changes.is_mod_present(mod_id): textbutton "Uninstall": ycenter 0.5 - action [Function(mod_changes.remove_mod, modid, _get_modfolder(str(modid), name)), + action [Function(mod_changes.remove_mod, mod_id, _get_modfolder(str(mod_id), name)), Play("audio", "se/sounds/open.ogg")] style "modmenu_content_btn" text_style "modmenu_select_btn_text" @@ -1068,7 +1069,7 @@ screen modmenu_mod_content(modid, name, author, description, url, child_list, mo else: textbutton "Install": ycenter 0.5 - action [Function(mod_changes.add_mod, modid), + action [Function(mod_changes.add_mod, mod_id), Play("audio", "se/sounds/open.ogg")] style "modmenu_content_btn" text_style "modmenu_select_btn_text" @@ -1159,7 +1160,7 @@ screen modmenu_apply_confirm(mod_changes, use_steam): xfill True mousewheel "change" - for modid, modname in mods_to_install.iteritems(): + for mod_id, modname in mods_to_install.iteritems(): hbox: spacing 20 ysize 60 @@ -1169,7 +1170,7 @@ screen modmenu_apply_confirm(mod_changes, use_steam): hover "image/ui/close_hover.png" yalign 0.5 - action Function(mod_changes.remove_mod, modid) # As mod is guaranteed to be in the add list, we only need id to remove. + action Function(mod_changes.remove_mod, mod_id) # As mod is guaranteed to be in the add list, we only need id to remove. text modname: if len(modname) > 30: diff --git a/mods/core/modules/modmenu_search.py b/mods/core/modules/modmenu_search.py index 482d5cd..8ece18e 100644 --- a/mods/core/modules/modmenu_search.py +++ b/mods/core/modules/modmenu_search.py @@ -43,9 +43,7 @@ def jaro_split_compare(query, modlist): query_words = set(query.lower().split()) for mod in modlist: - name = mod[1] - desc = mod[3] - comps[name] = jaro_set_similarity(query_words, name, desc) + comps[mod.name] = jaro_set_similarity(query_words, mod.name, mod.desc) return comps @@ -55,9 +53,7 @@ def jaro_author_compare(author_query, modlist): author_query = author_query.lower() for mod in modlist: - name = mod[1] - author = mod[2] - comps[name] = jaro_similarity(author_query, author.lower()) + comps[mod.name] = jaro_similarity(author_query, mod.author.lower()) return comps @@ -67,7 +63,7 @@ def sort_best(query, modlist, author_query="", return_score=False): if query.strip(): similarities = jaro_split_compare(query, modlist) else: - similarities = {mod[1]: (0.0, 0.0) for mod in modlist} + similarities = {mod.name: (0.0, 0.0) for mod in modlist} if author_query.strip(): author_similarities = jaro_author_compare(author_query, modlist) else: @@ -83,7 +79,7 @@ def sort_best(query, modlist, author_query="", return_score=False): if comp_func(mod_order[0]) <= 0.3: # All bad matches, don't reorder print "No good matches. reordering suppressed" - mod_order = list((mod[1], 0.0) for mod in modlist) + mod_order = list((mod.name, 0.0) for mod in modlist) mods_by_name = {mod[1]: mod for mod in modlist} From 3314d4c9b3983a68ca0165fbb9a587285d266232 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Wed, 5 Aug 2026 16:26:32 +0300 Subject: [PATCH 03/13] Implemented dependency list methods --- mods/core/download_mods.rpy | 64 +++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 95e42e8..385477e 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -305,6 +305,7 @@ init -1 python: import math import traceback import threading + from collections import Counter import modloader from modloader import modconfig, steamhandler_extensions @@ -493,45 +494,58 @@ init -1 python: :param base_modlist: The vendor modlist for this class """ - self._modlist = base_modlist + self._modlist = {mod.id: mod for mod in base_modlist} self._add_map = {} - self._dependant_add_map = {} + self._dependant_add_map = Counter() self._remove_map = {} - self._dependant_remove_map = {} self._dependency_map = {} self._r_dependency_map = {} - for mod in self._modlist: - mod_id = mod.id + for mod_id, mod in self._modlist.items(): child_list = mod.child_list - self._dependency_map[mod_id] = child_list + self._dependency_map[mod_id] = tuple(child_list) for child_key in child_list: if child_key in self._r_dependency_map: self._r_dependency_map[child_key].append(mod_id) else: self._r_dependency_map[child_key] = [mod_id] + for key, value in self._r_dependency_map.iteritems(): + self._r_dependency_map[key] = tuple(value) + + def get_mod(self, mod_id): - idx = [mod.id for mod in self._modlist].index(mod_id) # Raises ValueError if mod is not present. intentionally not caught - return self._modlist[idx] + return self._modlist[mod_id] def get_mod_by_name(self, mod_name): - idx = [mod.name for mod in self._modlist].index(mod_name) # Raises ValueError if mod is not present. intentionally not caught - return self._modlist[idx] + result = [mod_id for mod_id, mod in self._modlist.iteritems() if mod.name == mod_name] + if not result: + raise ValueError("mod \"{}\" not present in modlist".format(mod_name)) + return result[0] def get_added_mods(self): return self._add_map + def get_added_dependencies(self): + return {mod_id: self.get_mod(mod_id).name for mod_id in self._dependant_add_map.iterkeys()} + def get_removed_mods(self): return self._remove_map def is_mod_added(self, mod_id): return mod_id in self.get_added_mods() + def is_mod_dependency(self, mod_id): + return mod_id in self.get_added_dependencies() + def is_mod_removed(self, mod_id): return mod_id in self.get_removed_mods() + def is_all_dependencies_present(self, mod_id): + """Are all of mod_id's dependencies present in the modlist""" + return all((dep_id in self._modlist) for dep_id in self._dependency_map[mod_id]) + def is_mod_installed(self, mod_id): """Version of _modmenu_is_mod_installed which matches the rest of the functions here""" @@ -543,18 +557,48 @@ init -1 python: return (self.is_mod_installed(mod_id) or self.is_mod_added(mod_id)) and not self.is_mod_removed(mod_id) + def _add_dependencies_count_recursive(self, mod_id, subtract=False): + """Recursively find all of mod_id's dependencies, then add them to self's self's dependency counts, using self's modlist. + + :param mod_id: the mod that was added to self, which it's recursive dependency counts will be tracked. + :param subtract: if False (default), dependency counts will be added to self. if True, dependency counts will be subtracted. + """ + print "adding dependencies for", mod_id + # Find recursive dependency counts + dependencies = set() + unchecked_mod_set = set((mod_id,)) + while unchecked_mod_set: + new_mod_set = set() + # get current counts (repetitions are not counted) + for m_id in unchecked_mod_set: + new_mod_set.update(self._dependency_map[m_id]) + new_mod_set = (new_mod_set - dependencies).intersection(self._modlist.keys()) # Cut down mods that have already been found, and non-present mods + dependencies |= new_mod_set + unchecked_mod_set = new_mod_set + + # += and -= create and destroy entries with count 0, which is why they're used + if subtract: + self._dependant_add_map -= Counter(dependencies) + else: + self._dependant_add_map += Counter(dependencies) + + print "deplist", self._dependant_add_map + + def add_mod(self, mod_id): print "adding mod:", mod_id if mod_id in self._remove_map: # Added, then removed this session self._remove_map.pop(mod_id) elif mod_id not in self._add_map: # Not added yet, and not an existing mod being reinstated self._add_map[mod_id] = self.get_mod(mod_id).name + self._add_dependencies_count_recursive(mod_id) # else: nothing to do... def remove_mod(self, mod_id, filename=""): print "removing mod:", mod_id, filename if mod_id in self._add_map: self._add_map.pop(mod_id) + self._add_dependencies_count_recursive(mod_id, subtract=True) elif mod_id not in self._remove_map: self._remove_map[mod_id] = (self.get_mod(mod_id).name, filename) From 5145d38fd91ae19624cc5f5e8e34e114c42cf305 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Wed, 5 Aug 2026 17:55:37 +0300 Subject: [PATCH 04/13] Dependency install works! --- mods/core/download_mods.rpy | 53 +++++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 385477e..522da14 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -518,7 +518,7 @@ init -1 python: return self._modlist[mod_id] def get_mod_by_name(self, mod_name): - result = [mod_id for mod_id, mod in self._modlist.iteritems() if mod.name == mod_name] + result = [mod for mod in self._modlist.itervalues() if mod.name == mod_name] if not result: raise ValueError("mod \"{}\" not present in modlist".format(mod_name)) return result[0] @@ -527,7 +527,18 @@ init -1 python: def get_added_mods(self): return self._add_map - def get_added_dependencies(self): + def get_added_dependencies(self, missing=False): + """Get all added mod dependencies. + + :param missing: if True, only include missing (as in, not installed) dependencies. if False (default), include all dependencies + """ + if missing: + result = {} + for mod_id in self._dependant_add_map.iterkeys(): + mod_name = self.get_mod(mod_id).name + if not _modmenu_is_mod_installed(mod_id, mod_name): + result[mod_id] = mod_name + return result return {mod_id: self.get_mod(mod_id).name for mod_id in self._dependant_add_map.iterkeys()} def get_removed_mods(self): @@ -1005,9 +1016,17 @@ screen modmenu_paged_modlist(contents, mod_changes, use_steam): textbutton "[mod_button_text]": style "modmenu_select_btn" + # Recolor the button if involved with add/remove/dependency lists if mod_changes.is_mod_added(mod_id): - background "#007f009B" - hover_background "#7fff7f9B" + if not mod_changes.is_all_dependencies_present(mod_id): + background "#7f7f00CF" + hover_background "#ffff7fCF" + else: + background "#007f009B" + hover_background "#7fff7f9B" + elif mod_changes.is_mod_dependency(mod_id): + background "#007f3f9B" + hover_background "#7fffaf9B" elif mod_changes.is_mod_removed(mod_id): background "#7f00009B" hover_background "#ff7f7f9B" @@ -1150,10 +1169,15 @@ transform _button_zoom: screen modmenu_apply_confirm(mod_changes, use_steam): modal True python: - from modloader.modconfig import apply_mod_changes as apply_mod_changes - - mods_to_install = mod_changes.get_added_mods() - mods_to_uninstall = {mod_name: filename for mod_name, filename in mod_changes.get_removed_mods().itervalues()} + from modloader.modconfig import apply_mod_changes + + mods_to_install = dict(mod_changes.get_added_mods()) + mods_to_install.update(mod_changes.get_added_dependencies(missing=True)) + mods_to_uninstall = {} # For some reason, the dict comprehension created problems when I added in if clause to it... + for mod_id, (mod_name, filename) in mod_changes.get_removed_mods().iteritems(): + if not mod_changes.is_mod_dependency(mod_id): + mods_to_uninstall[mod_name] = filename +# mods_to_uninstall = {mod_name: filename for mod_id, (mod_name, filename) in mod_changes.get_removed_mods().iteritems() if not mod_changes.is_mod_dependency(mod_id)} n_mods_to_install = len(mods_to_install) n_mods_to_uninstall = len(mods_to_uninstall) @@ -1209,12 +1233,13 @@ screen modmenu_apply_confirm(mod_changes, use_steam): spacing 20 ysize 60 - imagebutton: - idle "image/ui/close_idle.png" at _button_zoom - hover "image/ui/close_hover.png" - yalign 0.5 + if not mod_changes.is_mod_dependency(mod_id): + imagebutton: + idle "image/ui/close_idle.png" at _button_zoom + hover "image/ui/close_hover.png" + yalign 0.5 - action Function(mod_changes.remove_mod, mod_id) # As mod is guaranteed to be in the add list, we only need id to remove. + action Function(mod_changes.remove_mod, mod_id) # As mod is guaranteed to be in the add list, we only need id to remove. text modname: if len(modname) > 30: @@ -1255,7 +1280,7 @@ screen modmenu_apply_confirm(mod_changes, use_steam): idle "image/ui/close_idle.png" at _button_zoom hover "image/ui/close_hover.png" yalign 0.5 - action Function(mod_changes.add_mod, mod_changes.get_mod_by_name(modname)[0]) + action Function(mod_changes.add_mod, mod_changes.get_mod_by_name(modname).id) text modname: if len(modname) > 30: From f4d3a89e57e5a9c8734dcb2f45c4938759e866f8 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Thu, 6 Aug 2026 13:02:45 +0300 Subject: [PATCH 05/13] Pulled modmenu_entrance to the top of the file so it doesn't get in the way of everything else --- mods/core/download_mods.rpy | 334 ++++++++++++++++++------------------ 1 file changed, 168 insertions(+), 166 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 522da14..89cd660 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -1,3 +1,169 @@ +# modmenu entrance stuff +init -1 python: + class EntranceStates: + INTERNET = "internet" + INTERNET_FAILED = "internet_failed" + MODLIST = "modlist" + MODLIST_FAILED = "modlist_failed" + DONE = "done" + + class ModmenuEntranceLoadManager: + """Keeps the DynamicDisplayable at the heart of the modmenu_entrance aligned with the actual modlist loading.""" + + def __init__(self, use_steam): + self.use_steam = use_steam + + self._state = EntranceStates.INTERNET + self._state_lock = threading.Lock() + + self._result = None + self._exception = None + self._done_signal = threading.Event() # Used to gat access to these two. a lock is not necessary as they are only accessible after being set for the last time. + + self.disabled = threading.Event() # I've had issues with calls coming after a transition was supposed to take effect, so this allows me to disable the screen under those cases. + + self._running_thread = threading.Thread(target=self._load_modlist, args=(self.use_steam,)) + self._running_thread.daemonic = True + self._running_thread.start() + + def set_state(self, state): + with self._state_lock: + self._state = state + + def get_state(self): + with self._state_lock: + return self._state + + + def get_modlist(self): + self._done_signal.wait() + if self._exception is not None: + raise self._exception + return self._result + + + def _load_modlist(self, use_steam): + if internet_on(): + self.set_state(EntranceStates.MODLIST) + try: + # (mod_url, name, author, description, image) (for github) + # (id, name, author, desc, image) (for steam) + if use_steam: + from modloader.modconfig import steam_downloadable_mods as download_mods + else: + from modloader.modconfig import github_downloadable_mods as download_mods + + self._result = download_mods() + self.set_state(EntranceStates.DONE) + except Exception as e: + self._exception = e + self._exception.traceback = sys.exc_info()[2] + self.set_state(EntranceStates.MODLIST_FAILED) + else: + print "Internet failed" + self.set_state(EntranceStates.INTERNET_FAILED) + + self._done_signal.set() + + _dots = 1 + _MAX_DOTS = 3 + + def _cycle_dots(): + global _dots, _MAX_DOTS + _dots = (_dots % _MAX_DOTS) + 1 + return _dots + + + def _modmenu_entrance_transition_to(screen, load_manager, **kwargs): + """As transitioning out of the modmenu entrance without issues requires setting a few things, this helps ensure it is done correctly""" + renpy.show_screen(screen, **kwargs) + renpy.hide_screen('modmenu_entrance') + load_manager.disabled.set() + renpy.restart_interaction() + + + _modmenu_entrance_cancelled = False + + def _modmenu_entrance_progress(st, at, load_manager, use_steam): + state = load_manager.get_state() + if load_manager.disabled.is_set(): # Multi-calls made the transitions occur multiple times, causing screens which don't close properly. this prevents that. + if state == EntranceStates.DONE and not _modmenu_entrance_cancelled: + return Text("Modlist load done, showing modmenu..."), None + return Text(""), None + + print "{:.4}".format(st), state + + if state == EntranceStates.INTERNET: + t = Text("Connecting to network{}".format("." * _cycle_dots())) + return t, 1.5 + elif state == EntranceStates.INTERNET_FAILED: + _modmenu_entrance_transition_to('modmenu_nointernet', load_manager) + return Text("Network connection failed!"), None + elif state == EntranceStates.MODLIST: + t = Text("Loading modlist{}".format("." * _cycle_dots())) + return t, 1.0 + elif state == EntranceStates.MODLIST_FAILED: + if use_steam: + _ensure_modlist_okay(strict=True) # This will fail + else: + try: + load_manager.get_modlist() # This will fail + except Exception as exception: + modloader.report_modlist_errors("An error has occurred in trying to load the steam mod list.\n" + "Error raised:\n" + + "".join(traceback.format_exception(type(exception), exception, exception.traceback)) + ) + return Text("Modlist load failed!"), None + else: # state == EntranceStates.DONE + contents = load_manager.get_modlist() + _modmenu_entrance_transition_to('modmenu_paged', load_manager, contents=contents, use_steam=use_steam) + return Text("Modlist load done, showing modmenu..."), None + + + def _enter_modmenu(use_steam): + renpy.show_screen('modmenu_entrance', use_steam=use_steam) + +screen modmenu_entrance(use_steam): + modal True + + default load_manager = ModmenuEntranceLoadManager(use_steam) + + frame id "modmenu_entrance" at alpha_dissolve: + add "image/ui/ingame_menu_bg3.png" + + add "image/ui/ingame_menu_bg_light.png" at ingame_menu_light + + text "MOD MENU": + size 65 + xpos 0.5 + ypos 0.05 + xcenter 0.5 + yanchor 0.5 + font "Ardnas.otf" + + #Close Button + imagebutton: + idle "image/ui/close_idle.png" + hover "image/ui/close_hover.png" + action [Show("modmenu", transition=dissolve), + Hide("modmenu_entrance", transition=dissolve), + Stop("modmenu_music", fadeout=1.0), + Play("music", "mx/menu.ogg", fadein=1.0), + Play("audio", "se/sounds/close.ogg"), + Function(load_manager.disabled.set), + SetVariable("_modmenu_entrance_cancelled", True)] + + xpos 0.94 + ypos 0.02 + + add DynamicDisplayable(_modmenu_entrance_progress, load_manager, use_steam): + xalign 0.5 + yalign 0.5 + + on "show" action SetVariable("_modmenu_entrance_cancelled", False) + + + init python: renpy.music.register_channel("modmenu_music", "music", True) @@ -151,7 +317,7 @@ init python: if internet_on() and modloader.has_steam(): modconfig.steam_modlist_preloader.load() - + # modmenu search stuff import modmenu_search import time @@ -314,6 +480,7 @@ init -1 python: def is_modlist_loaded(): return modconfig.steam_modlist_preloader.is_loaded() + # modlist error stuff def _ensure_modlist_okay(strict=False): """Wait until the steam modlist is loaded, then check for errors and report any that have been detected. :parameter strict: default (False) only reports severe errors. if set to True, all errors are reported""" @@ -351,129 +518,6 @@ init -1 python: if not renpy.exports.has_screen("_modlist_errors"): renpy.load_module("modloader/patch_errorhandling_screens") - class EntranceStates: - INTERNET = "internet" - INTERNET_FAILED = "internet_failed" - MODLIST = "modlist" - MODLIST_FAILED = "modlist_failed" - DONE = "done" - - class ModmenuEntranceLoadManager: - """Keeps the DynamicDisplayable at the heart of the modmenu_entrance aligned with the actual modlist loading.""" - - def __init__(self, use_steam): - self.use_steam = use_steam - - self._state = EntranceStates.INTERNET - self._state_lock = threading.Lock() - - self._result = None - self._exception = None - self._done_signal = threading.Event() # Used to gat access to these two. a lock is not necessary as they are only accessible after being set for the last time. - - self.disabled = threading.Event() # I've had issues with calls coming after a transition was supposed to take effect, so this allows me to disable the screen under those cases. - - self._running_thread = threading.Thread(target=self._load_modlist, args=(self.use_steam,)) - self._running_thread.daemonic = True - self._running_thread.start() - - def set_state(self, state): - with self._state_lock: - self._state = state - - def get_state(self): - with self._state_lock: - return self._state - - - def get_modlist(self): - self._done_signal.wait() - if self._exception is not None: - raise self._exception - return self._result - - - def _load_modlist(self, use_steam): - if internet_on(): - self.set_state(EntranceStates.MODLIST) - try: - # (mod_url, name, author, description, image) (for github) - # (id, name, author, desc, image) (for steam) - if use_steam: - from modloader.modconfig import steam_downloadable_mods as download_mods - else: - from modloader.modconfig import github_downloadable_mods as download_mods - - self._result = download_mods() - self.set_state(EntranceStates.DONE) - except Exception as e: - self._exception = e - self._exception.traceback = sys.exc_info()[2] - self.set_state(EntranceStates.MODLIST_FAILED) - else: - print "Internet failed" - self.set_state(EntranceStates.INTERNET_FAILED) - - self._done_signal.set() - - _dots = 1 - _MAX_DOTS = 3 - - def _cycle_dots(): - global _dots, _MAX_DOTS - _dots = (_dots % _MAX_DOTS) + 1 - return _dots - - - def _modmenu_entrance_transition_to(screen, load_manager, **kwargs): - """As transitioning out of the modmenu entrance without issues requires setting a few things, this helps ensure it is done correctly""" - renpy.show_screen(screen, **kwargs) - renpy.hide_screen('modmenu_entrance') - load_manager.disabled.set() - renpy.restart_interaction() - - - _modmenu_entrance_cancelled = False - - def _modmenu_entrance_progress(st, at, load_manager, use_steam): - state = load_manager.get_state() - if load_manager.disabled.is_set(): # Multi-calls made the transitions occur multiple times, causing screens which don't close properly. this prevents that. - if state == EntranceStates.DONE and not _modmenu_entrance_cancelled: - return Text("Modlist load done, showing modmenu..."), None - return Text(""), None - - print "{:.4}".format(st), state - - if state == EntranceStates.INTERNET: - t = Text("Connecting to network{}".format("." * _cycle_dots())) - return t, 1.5 - elif state == EntranceStates.INTERNET_FAILED: - _modmenu_entrance_transition_to('modmenu_nointernet', load_manager) - return Text("Network connection failed!"), None - elif state == EntranceStates.MODLIST: - t = Text("Loading modlist{}".format("." * _cycle_dots())) - return t, 1.0 - elif state == EntranceStates.MODLIST_FAILED: - if use_steam: - _ensure_modlist_okay(strict=True) # This will fail - else: - try: - load_manager.get_modlist() # This will fail - except Exception as exception: - modloader.report_modlist_errors("An error has occurred in trying to load the steam mod list.\n" - "Error raised:\n" - + "".join(traceback.format_exception(type(exception), exception, exception.traceback)) - ) - return Text("Modlist load failed!"), None - else: # state == EntranceStates.DONE - contents = load_manager.get_modlist() - _modmenu_entrance_transition_to('modmenu_paged', load_manager, contents=contents, use_steam=use_steam) - return Text("Modlist load done, showing modmenu..."), None - - - def _enter_modmenu(use_steam): - renpy.show_screen('modmenu_entrance', use_steam=use_steam) - def _refresh_modlist(modlist_manager, mod_changes, use_steam): @@ -632,48 +676,6 @@ init -1 python: return mod_name - -screen modmenu_entrance(use_steam): - modal True - - default load_manager = ModmenuEntranceLoadManager(use_steam) - - frame id "modmenu_entrance" at alpha_dissolve: - add "image/ui/ingame_menu_bg3.png" - - add "image/ui/ingame_menu_bg_light.png" at ingame_menu_light - - text "MOD MENU": - size 65 - xpos 0.5 - ypos 0.05 - xcenter 0.5 - yanchor 0.5 - font "Ardnas.otf" - - #Close Button - imagebutton: - idle "image/ui/close_idle.png" - hover "image/ui/close_hover.png" - action [Show("modmenu", transition=dissolve), - Hide("modmenu_entrance", transition=dissolve), - Stop("modmenu_music", fadeout=1.0), - Play("music", "mx/menu.ogg", fadein=1.0), - Play("audio", "se/sounds/close.ogg"), - Function(load_manager.disabled.set), - SetVariable("_modmenu_entrance_cancelled", True)] - - xpos 0.94 - ypos 0.02 - - add DynamicDisplayable(_modmenu_entrance_progress, load_manager, use_steam): - xalign 0.5 - yalign 0.5 - - on "show" action SetVariable("_modmenu_entrance_cancelled", False) - - - screen modmenu_paged(contents, use_steam): modal True From 21fb8cd2acea13fcd1f56a77e6ae83735634a736 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Fri, 7 Aug 2026 13:29:54 +0300 Subject: [PATCH 06/13] Display dependencies of the currently viewed mod --- mods/core/download_mods.rpy | 41 +++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 89cd660..d281b19 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -1105,7 +1105,8 @@ screen modmenu_mod_content(mod, mod_changes, use_steam): font "Ardnas.otf" #Mods image - vbox xpos 0.72 ypos 0.21: +# vbox xpos 0.72 ypos 0.21: + vbox xpos 0.73 ypos 0.21: python: ModmenuContentImageURL(url) @@ -1147,8 +1148,8 @@ screen modmenu_mod_content(mod, mod_changes, use_steam): #scrollbars "vertical" draggable True mousewheel True - xminimum 1100 - xmaximum 1100 + xminimum 1050 + xmaximum 1050 yminimum 355 ymaximum 355 @@ -1161,9 +1162,41 @@ screen modmenu_mod_content(mod, mod_changes, use_steam): bar value YScrollValue("modcontent_vp"): style "modmenu_content_slider" ypos 472 - xpos 1682 + xpos 1582 #yalign 0.95 + # dependency area + frame xpos 1632 ypos 0.21 xsize 280 ysize 600: + background "#0000009B" + xpadding 15 + + vbox xfill True ymaximum 600: + text "Dependencies:" size 40 + + for dep_id in mod.child_list: + python: + try: + dep_name = mod_changes.get_mod(dep_id).name + + if mod_changes.is_mod_installed(dep_id): + dep_color = "#ffffffFF" + elif mod_changes.is_mod_added(dep_id): + dep_color = "#bfff00FF" + else: + dep_color = "#00ffbfFF" + + except KeyError: + dep_name = "" + dep_color = "#7f7f7fFF" + +# textbutton dep_name xsize 250: +# text_size 20 + text dep_name: + size 20 + color dep_color + + + transform _button_zoom: zoom 40.0 / 54.0 From 954279cfe534f095f009851c8815e451e31d3f18 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Fri, 7 Aug 2026 16:06:12 +0300 Subject: [PATCH 07/13] Recursive mod dependencies for whole Modchanges class --- mods/core/download_mods.rpy | 105 +++++++++++++++++++++--------------- 1 file changed, 63 insertions(+), 42 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index d281b19..6b9d960 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -531,6 +531,50 @@ init -1 python: """Is mod actually installed on the computer, regardless of modmenu status.""" return str(mod_id) in modinfo.get_mod_folders() or str(mod_name) in modinfo.get_mod_folders() + + def _modmenu_get_recursive_deps(dep_map, reverse_dep_map, strict=True): + """Generate a recursive dependency map from the simple forward and reverse dependency maps + + :param dep_map: mapping from mod_id to iterable(mod_id), from each mod to it's dependencies + :param reverse_dep_map: reverse mapping, from each mod to is users. used for efficiency + :param strict: if True (default), raise KeyError on dependencies which do not appear as keys in dep_map. if False, they are treated as mods without dependencies + :return mapping from mod_id to set(mod_id), recursive mod mapping + :raises KeyError when strict=True and a mod has a dependency not in the mapping + :raises ValueError when a circular dependency is detected + """ + result = {} + if strict: + unfulfilled_deps = {mod_id: set(child_list) for mod_id, child_list in dep_map.iteritems()} + else: + checked_deps = dep_map.keys() + unfulfilled_deps = {mod_id: set(child_list).intersection(checked_deps) for mod_id, child_list in dep_map.iteritems()} + all_fulfilled = {mod_id: dep_map[mod_id] for mod_id, unfulfilled_list in unfulfilled_deps.iteritems() if not len(unfulfilled_list)} + unfulfilled_deps = {mod_id: child_list for mod_id, child_list in unfulfilled_deps.iteritems() if mod_id not in all_fulfilled} # remove fulfilled mods + while len(all_fulfilled): + for mod_id, child_list in all_fulfilled.iteritems(): + # add entry to dependency map + if strict: + recursive_deps = reduce(set.union, (dep_map[dep_id] for dep_id in child_list), set()) + else: + recursive_deps = reduce(set.union, (dep_map.get(dep_id, set()) for dep_id in child_list), set()) + result[mod_id] = set(child_list).union(recursive_deps) + + # update that they're fulfilled + for use_id in reverse_dep_map[mod_id]: + unfulfilled_deps[use_id].remove(mod_id) + + all_fulfilled = {mod_id: dep_map[mod_id] for mod_id, unfulfilled_list in unfulfilled_deps.iteritems() if not len(unfulfilled_list)} + unfulfilled_deps = {mod_id: child_list for mod_id, child_list in unfulfilled_deps.iteritems() if mod_id not in all_fulfilled} + + + # all missing are circular dependencies. this should really never happen... + circular_deps = set(dep_map.iterkeys()) - set(result.iterkeys()) + if circular_deps: + raise ValueError("Circular dependency detected regarding these mods: {}".format(tuple(circular_deps))) + + return result + + class Modchanges: def __init__(self, base_modlist): """A class holding the full modlist, along with functionality to request to install or uninstall a mod. @@ -542,20 +586,22 @@ init -1 python: self._add_map = {} self._dependant_add_map = Counter() self._remove_map = {} - self._dependency_map = {} - self._r_dependency_map = {} - for mod_id, mod in self._modlist.items(): - child_list = mod.child_list - self._dependency_map[mod_id] = tuple(child_list) + forward_dep_map = {mod_id: set(mod.child_list) for mod_id, mod in self._modlist.iteritems()} + reverse_dep_map = {mod_id: set() for mod_id in self._modlist} + for mod_id, child_list in forward_dep_map.iteritems(): for child_key in child_list: - if child_key in self._r_dependency_map: - self._r_dependency_map[child_key].append(mod_id) - else: - self._r_dependency_map[child_key] = [mod_id] + if child_key not in reverse_dep_map: + reverse_dep_map[child_key] = set() + reverse_dep_map[child_key].add(mod_id) + + # Fixup forward_dep_map to include missing keys from reverse_dep_map as _modmenu_get_recursive_deps is annoying about them + missing_keys = set(reverse_dep_map.keys()) - set(forward_dep_map.keys()) + forward_dep_map.update({key: set() for key in missing_keys}) - for key, value in self._r_dependency_map.iteritems(): - self._r_dependency_map[key] = tuple(value) + + self._dependency_map = _modmenu_get_recursive_deps(forward_dep_map, reverse_dep_map, strict=False) + self._r_dependency_map = _modmenu_get_recursive_deps(reverse_dep_map, forward_dep_map, strict=False) def get_mod(self, mod_id): @@ -567,6 +613,9 @@ init -1 python: raise ValueError("mod \"{}\" not present in modlist".format(mod_name)) return result[0] + def get_mod_dependencies(self, mod_id): + return self._dependency_map[mod_id] + def get_added_mods(self): return self._add_map @@ -612,48 +661,20 @@ init -1 python: return (self.is_mod_installed(mod_id) or self.is_mod_added(mod_id)) and not self.is_mod_removed(mod_id) - def _add_dependencies_count_recursive(self, mod_id, subtract=False): - """Recursively find all of mod_id's dependencies, then add them to self's self's dependency counts, using self's modlist. - - :param mod_id: the mod that was added to self, which it's recursive dependency counts will be tracked. - :param subtract: if False (default), dependency counts will be added to self. if True, dependency counts will be subtracted. - """ - print "adding dependencies for", mod_id - # Find recursive dependency counts - dependencies = set() - unchecked_mod_set = set((mod_id,)) - while unchecked_mod_set: - new_mod_set = set() - # get current counts (repetitions are not counted) - for m_id in unchecked_mod_set: - new_mod_set.update(self._dependency_map[m_id]) - new_mod_set = (new_mod_set - dependencies).intersection(self._modlist.keys()) # Cut down mods that have already been found, and non-present mods - dependencies |= new_mod_set - unchecked_mod_set = new_mod_set - - # += and -= create and destroy entries with count 0, which is why they're used - if subtract: - self._dependant_add_map -= Counter(dependencies) - else: - self._dependant_add_map += Counter(dependencies) - - print "deplist", self._dependant_add_map - - def add_mod(self, mod_id): print "adding mod:", mod_id if mod_id in self._remove_map: # Added, then removed this session self._remove_map.pop(mod_id) elif mod_id not in self._add_map: # Not added yet, and not an existing mod being reinstated self._add_map[mod_id] = self.get_mod(mod_id).name - self._add_dependencies_count_recursive(mod_id) + self._dependant_add_map += Counter(self.get_mod_dependencies(mod_id)) # else: nothing to do... def remove_mod(self, mod_id, filename=""): print "removing mod:", mod_id, filename if mod_id in self._add_map: self._add_map.pop(mod_id) - self._add_dependencies_count_recursive(mod_id, subtract=True) + self._dependant_add_map -= Counter(self.get_mod_dependencies(mod_id)) elif mod_id not in self._remove_map: self._remove_map[mod_id] = (self.get_mod(mod_id).name, filename) @@ -1173,7 +1194,7 @@ screen modmenu_mod_content(mod, mod_changes, use_steam): vbox xfill True ymaximum 600: text "Dependencies:" size 40 - for dep_id in mod.child_list: + for dep_id in mod_changes.get_mod_dependencies(mod_id): python: try: dep_name = mod_changes.get_mod(dep_id).name From 7c88a96570ff0f6bf83262de1c4bf85a96641a94 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Fri, 7 Aug 2026 16:45:22 +0300 Subject: [PATCH 08/13] Modmenu no longer allows removal of mods which break a dependency chain. such removals will be marked and ignored, and will only be allowed when all parents dependant upon it are removed --- mods/core/download_mods.rpy | 53 ++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 6b9d960..d2b2cb7 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -616,6 +616,9 @@ init -1 python: def get_mod_dependencies(self, mod_id): return self._dependency_map[mod_id] + def get_mod_parents(self, mod_id): + return self._r_dependency_map[mod_id] + def get_added_mods(self): return self._add_map @@ -656,9 +659,15 @@ init -1 python: mod = self.get_mod(mod_id) return _modmenu_is_mod_installed(mod_id, mod.name) - def is_mod_present(self, mod_id): - """Will the mod be installed once the modmenu changes have been applied.""" - return (self.is_mod_installed(mod_id) or self.is_mod_added(mod_id)) and not self.is_mod_removed(mod_id) + def is_mod_present(self, mod_id, dependency=False): + """Will the mod be installed once the modmenu changes have been applied + :param mod_id: the mod id to check presence + :param dependency: if False (default), ignore dependency install for this calculation. if True, then include such case""" + return ((self.is_mod_installed(mod_id) or self.is_mod_added(mod_id)) and not self.is_mod_removed(mod_id)) or (dependency and self.is_mod_dependency(mod_id)) + + def is_mod_removable(self, mod_id): + """Can a mod be removed without breaking dependency constraints. this decides if a remove_mod call will produce a visible effect""" + return all(not self.is_mod_present(parent_id, dependency=True) for parent_id in self.get_mod_parents(mod_id)) def add_mod(self, mod_id): @@ -1029,13 +1038,32 @@ screen modmenu_paged_modlist(contents, mod_changes, use_steam): $ mod_button_text = mod_button_text[:30] $ mod_button_text = "{size=-10}" + mod_button_text + "{/size}" + $ mod_button_text_extras = [] + if mod_changes.is_mod_installed(mod_id): - $ mod_button_text += "\n{size=-5}(Installed" + $ mod_button_text_extras.append("Installed") if mod_changes.is_mod_removed(mod_id): - $ mod_button_text += ", removed" - $ mod_button_text += "){/size}" + if mod_changes.is_mod_removable(mod_id): + $ mod_button_text_extras.append("Removed") + else: + $ mod_button_text_extras.append("{s}Removed{/s}") elif mod_changes.is_mod_added(mod_id): - $ mod_button_text += "\n{size=-5}(Added){/size}" + $ mod_button_text_extras.append("Added") + if not mod_changes.is_all_dependencies_present(mod_id): + $ mod_button_text_extras.append("Missing") + + if mod_changes.is_mod_dependency(mod_id): + $ mod_button_text_extras.append("Dependency") + + if mod_button_text_extras: + $ mod_button_status_text = ", ".join(mod_button_text_extras) + if len(mod_button_status_text) >= 25: + $ mod_button_text += "\n{size=-10}(" + else: + $ mod_button_text += "\n{size=-5}(" + $ mod_button_text += mod_button_status_text + "){/size}" + + # format: "\n{size=-5}(eff, eff, eff){/size}" textbutton "[mod_button_text]": style "modmenu_select_btn" @@ -1051,8 +1079,13 @@ screen modmenu_paged_modlist(contents, mod_changes, use_steam): background "#007f3f9B" hover_background "#7fffaf9B" elif mod_changes.is_mod_removed(mod_id): - background "#7f00009B" - hover_background "#ff7f7f9B" + if mod_changes.is_mod_removable(mod_id): + background "#7f00009B" + hover_background "#ff7f7f9B" + else: + background "#7f3f009B" + hover_background "#ffbf7f9B" + action [Hide("modmenu_mod_content"), Show("modmenu_mod_content", @@ -1231,7 +1264,7 @@ screen modmenu_apply_confirm(mod_changes, use_steam): mods_to_install.update(mod_changes.get_added_dependencies(missing=True)) mods_to_uninstall = {} # For some reason, the dict comprehension created problems when I added in if clause to it... for mod_id, (mod_name, filename) in mod_changes.get_removed_mods().iteritems(): - if not mod_changes.is_mod_dependency(mod_id): + if mod_changes.is_mod_removable(mod_id): mods_to_uninstall[mod_name] = filename # mods_to_uninstall = {mod_name: filename for mod_id, (mod_name, filename) in mod_changes.get_removed_mods().iteritems() if not mod_changes.is_mod_dependency(mod_id)} n_mods_to_install = len(mods_to_install) From 383e06adfc27c2f5703b410cbc8f0674cdaa8320 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Mon, 10 Aug 2026 16:39:11 +0300 Subject: [PATCH 09/13] Made basic Modchanges methods consider dependencies by default, along with better handling of installed mod dependencies --- mods/core/download_mods.rpy | 124 +++++++++++++++++++++++------------- 1 file changed, 78 insertions(+), 46 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index d2b2cb7..c4b097f 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -586,7 +586,9 @@ init -1 python: self._add_map = {} self._dependant_add_map = Counter() self._remove_map = {} + self._installed_dependant_map = Counter() # equivalent of _dependant_add_map used to keep track when a mod is safe to delete + # setup recursive dependency maps forward_dep_map = {mod_id: set(mod.child_list) for mod_id, mod in self._modlist.iteritems()} reverse_dep_map = {mod_id: set() for mod_id in self._modlist} for mod_id, child_list in forward_dep_map.iteritems(): @@ -599,10 +601,19 @@ init -1 python: missing_keys = set(reverse_dep_map.keys()) - set(forward_dep_map.keys()) forward_dep_map.update({key: set() for key in missing_keys}) - self._dependency_map = _modmenu_get_recursive_deps(forward_dep_map, reverse_dep_map, strict=False) self._r_dependency_map = _modmenu_get_recursive_deps(reverse_dep_map, forward_dep_map, strict=False) + self._reset_installed_dependant_map() + + + def _reset_installed_dependant_map(self): + self._installed_dependant_map.clear() + + for mod_id in self._modlist: + if self.is_mod_installed(mod_id): + self._installed_dependant_map += Counter(self._dependency_map[mod_id]) + def get_mod(self, mod_id): return self._modlist[mod_id] @@ -620,12 +631,26 @@ init -1 python: return self._r_dependency_map[mod_id] - def get_added_mods(self): - return self._add_map + def get_added_mods(self, dependency=True): + """Get all mods selected for addition, optionally along with their uninstalled dependencies + :param dependency: if True (default), result also includes all uninstalled dependencies of the selected mods. if False, only mods specifically added will be included + """ + if not dependency: + return self._add_map + result = self.get_added_dependencies(missing=True) + result.update(self._add_map) + return result + + def get_removed_mods(self, dependency=True): + """Get all mods selected for removal, optionally depending on dependency coherence + :param dependency: if True (default), only returns mods which have no remaining dependency after removal (whether existing or added). if False, all mods requesting removal will be returned + """ + if not dependency: + return self._remove_map + return {mod_id: item for mod_id, item in self._remove_map.iteritems() if self.is_mod_removable(mod_id)} def get_added_dependencies(self, missing=False): """Get all added mod dependencies. - :param missing: if True, only include missing (as in, not installed) dependencies. if False (default), include all dependencies """ if missing: @@ -637,19 +662,26 @@ init -1 python: return result return {mod_id: self.get_mod(mod_id).name for mod_id in self._dependant_add_map.iterkeys()} - def get_removed_mods(self): - return self._remove_map + def get_installed_dependencies(self): + return {mod_id for mod_id in self._installed_dependant_map.iterkeys()} - def is_mod_added(self, mod_id): - return mod_id in self.get_added_mods() + + def is_mod_added(self, mod_id, dependency=True): + """Is mod_id selected for addition, or optionally will be installed. + :param dependency: if True (default), returns True if mod_id is selected or is an uninstalled dependency of a selected mod. if False, only returns True if mod_id is selected for addition + """ + return mod_id in self.get_added_mods(dependency) def is_mod_dependency(self, mod_id): return mod_id in self.get_added_dependencies() - def is_mod_removed(self, mod_id): - return mod_id in self.get_removed_mods() + def is_mod_removed(self, mod_id, dependency=True): + """Is mod_id selected for removal, or optionally will be removed. + :param dependency: if True (default), returns True only if mod_id is selected and can be safely removed by dependency logic. if False, returns True if mod_id is selected for removal + """ + return mod_id in self.get_removed_mods(dependency) - def is_all_dependencies_present(self, mod_id): + def is_all_dependencies_available(self, mod_id): """Are all of mod_id's dependencies present in the modlist""" return all((dep_id in self._modlist) for dep_id in self._dependency_map[mod_id]) @@ -663,35 +695,43 @@ init -1 python: """Will the mod be installed once the modmenu changes have been applied :param mod_id: the mod id to check presence :param dependency: if False (default), ignore dependency install for this calculation. if True, then include such case""" - return ((self.is_mod_installed(mod_id) or self.is_mod_added(mod_id)) and not self.is_mod_removed(mod_id)) or (dependency and self.is_mod_dependency(mod_id)) + return (self.is_mod_installed(mod_id) and not self.is_mod_removed(mod_id, dependency)) or self.is_mod_added(mod_id, dependency) def is_mod_removable(self, mod_id): """Can a mod be removed without breaking dependency constraints. this decides if a remove_mod call will produce a visible effect""" - return all(not self.is_mod_present(parent_id, dependency=True) for parent_id in self.get_mod_parents(mod_id)) + return mod_id not in self._installed_dependant_map and mod_id not in self._dependant_add_map def add_mod(self, mod_id): print "adding mod:", mod_id - if mod_id in self._remove_map: # Added, then removed this session + is_installed = self.is_mod_installed(mod_id) + + if is_installed and mod_id in self._remove_map: # Added, then removed this session self._remove_map.pop(mod_id) - elif mod_id not in self._add_map: # Not added yet, and not an existing mod being reinstated + self._installed_dependant_map += Counter(self.get_mod_dependencies(mod_id)) + elif not is_installed and mod_id not in self._add_map: # Not added yet, and not an existing mod being reinstated self._add_map[mod_id] = self.get_mod(mod_id).name self._dependant_add_map += Counter(self.get_mod_dependencies(mod_id)) # else: nothing to do... def remove_mod(self, mod_id, filename=""): print "removing mod:", mod_id, filename - if mod_id in self._add_map: + is_installed = self.is_mod_installed(mod_id) + + if not is_installed and mod_id in self._add_map: self._add_map.pop(mod_id) self._dependant_add_map -= Counter(self.get_mod_dependencies(mod_id)) - elif mod_id not in self._remove_map: + elif is_installed and mod_id not in self._remove_map: self._remove_map[mod_id] = (self.get_mod(mod_id).name, filename) + self._installed_dependant_map -= Counter(self.get_mod_dependencies(mod_id)) def clear_added_mods(self): self._add_map.clear() + self._dependant_add_map.clear() def clear_removed_mods(self): self._remove_map.clear() + self._reset_installed_dependant_map() def clear_mods(self): self.clear_added_mods() @@ -713,8 +753,8 @@ screen modmenu_paged(contents, use_steam): python: filter_map = {"install": (lambda mod, mod_changes: mod_changes.is_mod_installed(mod.id), mod_changes), - "select": (lambda mod, mod_changes: mod_changes.is_mod_added(mod.id) or mod_changes.is_mod_removed(mod.id), mod_changes), - "present": (lambda mod, mod_changes: mod_changes.is_mod_present(mod.id), mod_changes), + "select": (lambda mod, mod_changes: mod_changes.is_mod_added(mod.id, dependency=False) or mod_changes.is_mod_removed(mod.id, dependency=False), mod_changes), + "present": (lambda mod, mod_changes: mod_changes.is_mod_present(mod.id, dependency=True), mod_changes), } default modlist_manager = ModscreenModlistManager(contents, filter_map=filter_map, use_steam=use_steam) @@ -806,6 +846,7 @@ screen modmenu_paged(contents, use_steam): ] sensitive (modlist_manager.get_current_page() < modlist_manager.get_max_page()) + # As we wish to include dependencies in both calculations, we use the non-selection methods $ n_added_mods = len(mod_changes.get_added_mods()) $ n_removed_mods = len(mod_changes.get_removed_mods()) @@ -1042,14 +1083,14 @@ screen modmenu_paged_modlist(contents, mod_changes, use_steam): if mod_changes.is_mod_installed(mod_id): $ mod_button_text_extras.append("Installed") - if mod_changes.is_mod_removed(mod_id): + if mod_changes.is_mod_removed(mod_id, dependency=False): if mod_changes.is_mod_removable(mod_id): $ mod_button_text_extras.append("Removed") else: $ mod_button_text_extras.append("{s}Removed{/s}") - elif mod_changes.is_mod_added(mod_id): + elif mod_changes.is_mod_added(mod_id, dependency=False): $ mod_button_text_extras.append("Added") - if not mod_changes.is_all_dependencies_present(mod_id): + if not mod_changes.is_all_dependencies_available(mod_id): $ mod_button_text_extras.append("Missing") if mod_changes.is_mod_dependency(mod_id): @@ -1068,8 +1109,8 @@ screen modmenu_paged_modlist(contents, mod_changes, use_steam): textbutton "[mod_button_text]": style "modmenu_select_btn" # Recolor the button if involved with add/remove/dependency lists - if mod_changes.is_mod_added(mod_id): - if not mod_changes.is_all_dependencies_present(mod_id): + if mod_changes.is_mod_added(mod_id, dependency=False): + if not mod_changes.is_all_dependencies_available(mod_id): background "#7f7f00CF" hover_background "#ffff7fCF" else: @@ -1078,7 +1119,7 @@ screen modmenu_paged_modlist(contents, mod_changes, use_steam): elif mod_changes.is_mod_dependency(mod_id): background "#007f3f9B" hover_background "#7fffaf9B" - elif mod_changes.is_mod_removed(mod_id): + elif mod_changes.is_mod_removed(mod_id, dependency=False): if mod_changes.is_mod_removable(mod_id): background "#7f00009B" hover_background "#ff7f7f9B" @@ -1095,7 +1136,7 @@ screen modmenu_paged_modlist(contents, mod_changes, use_steam): ), Play("audio", "se/sounds/open.ogg")] - alternate [If(mod_changes.is_mod_present(mod_id), + alternate [If(mod_changes.is_mod_present(mod_id, dependency=False), Function(mod_changes.remove_mod, mod_id, _get_modfolder(mod_id, mod.name)), Function(mod_changes.add_mod, mod_id)), Play("audio", "se/sounds/open.ogg")] @@ -1104,8 +1145,6 @@ screen modmenu_paged_modlist(contents, mod_changes, use_steam): - - screen modmenu_mod_content(mod, mod_changes, use_steam): $ mod_id = mod.id $ name = unicode(mod.name, "utf8") @@ -1177,7 +1216,7 @@ screen modmenu_mod_content(mod, mod_changes, use_steam): null width 350 - if mod_changes.is_mod_present(mod_id): + if mod_changes.is_mod_present(mod_id, dependency=False): textbutton "Uninstall": ycenter 0.5 action [Function(mod_changes.remove_mod, mod_id, _get_modfolder(str(mod_id), name)), @@ -1243,8 +1282,6 @@ screen modmenu_mod_content(mod, mod_changes, use_steam): dep_name = "" dep_color = "#7f7f7fFF" -# textbutton dep_name xsize 250: -# text_size 20 text dep_name: size 20 color dep_color @@ -1260,13 +1297,8 @@ screen modmenu_apply_confirm(mod_changes, use_steam): python: from modloader.modconfig import apply_mod_changes - mods_to_install = dict(mod_changes.get_added_mods()) - mods_to_install.update(mod_changes.get_added_dependencies(missing=True)) - mods_to_uninstall = {} # For some reason, the dict comprehension created problems when I added in if clause to it... - for mod_id, (mod_name, filename) in mod_changes.get_removed_mods().iteritems(): - if mod_changes.is_mod_removable(mod_id): - mods_to_uninstall[mod_name] = filename -# mods_to_uninstall = {mod_name: filename for mod_id, (mod_name, filename) in mod_changes.get_removed_mods().iteritems() if not mod_changes.is_mod_dependency(mod_id)} + mods_to_install = mod_changes.get_added_mods() + mods_to_uninstall = mod_changes.get_removed_mods() n_mods_to_install = len(mods_to_install) n_mods_to_uninstall = len(mods_to_uninstall) @@ -1296,7 +1328,7 @@ screen modmenu_apply_confirm(mod_changes, use_steam): yanchor 0.0 $ mods_to_add_text = "\n".join(mods_to_install.itervalues()) - $ mods_to_remove_text = "\n".join(modname for modname, filename in mods_to_uninstall.iteritems()) + $ mods_to_remove_text = "\n".join(modname for modname, _ in mods_to_uninstall.itervalues()) fixed: # Added mods list: fixed is used to force list+scrollbar to stay within their region xalign 0.0 @@ -1317,7 +1349,7 @@ screen modmenu_apply_confirm(mod_changes, use_steam): xfill True mousewheel "change" - for mod_id, modname in mods_to_install.iteritems(): + for mod_id, mod_name in mods_to_install.iteritems(): hbox: spacing 20 ysize 60 @@ -1330,8 +1362,8 @@ screen modmenu_apply_confirm(mod_changes, use_steam): action Function(mod_changes.remove_mod, mod_id) # As mod is guaranteed to be in the add list, we only need id to remove. - text modname: - if len(modname) > 30: + text mod_name: + if len(mod_name) > 30: size 30 xfill True ysize 60 @@ -1360,7 +1392,7 @@ screen modmenu_apply_confirm(mod_changes, use_steam): xfill True mousewheel "change" - for modname in mods_to_uninstall.iterkeys(): + for mod_id, (mod_name, _) in mods_to_uninstall.iteritems(): hbox: spacing 20 ysize 60 @@ -1369,10 +1401,10 @@ screen modmenu_apply_confirm(mod_changes, use_steam): idle "image/ui/close_idle.png" at _button_zoom hover "image/ui/close_hover.png" yalign 0.5 - action Function(mod_changes.add_mod, mod_changes.get_mod_by_name(modname).id) + action Function(mod_changes.add_mod, mod_id) - text modname: - if len(modname) > 30: + text mod_name: + if len(mod_name) > 30: size 30 xfill True ysize 60 From be678dfedf9b7a2a432393d70e648d9f0dfef963 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 11 Aug 2026 09:59:02 +0300 Subject: [PATCH 10/13] Updated confirm changes menu to properly show installed dependencies and overriden removals --- mods/core/download_mods.rpy | 123 ++++++++++++++++++++++++++---------- 1 file changed, 88 insertions(+), 35 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index c4b097f..89426d8 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -1298,7 +1298,16 @@ screen modmenu_apply_confirm(mod_changes, use_steam): from modloader.modconfig import apply_mod_changes mods_to_install = mod_changes.get_added_mods() + mods_added_to_install = dict(mods_to_install) + mod_deps_to_install = mod_changes.get_added_dependencies(missing=True) + for mod_id in mod_deps_to_install: + if mod_id in mods_added_to_install: + del mods_added_to_install[mod_id] mods_to_uninstall = mod_changes.get_removed_mods() + overridden_mods = dict(mod_changes.get_removed_mods(dependency=False)) + for mod_id in mods_to_uninstall: + if mod_id in overridden_mods: + del overridden_mods[mod_id] n_mods_to_install = len(mods_to_install) n_mods_to_uninstall = len(mods_to_uninstall) @@ -1327,9 +1336,6 @@ screen modmenu_apply_confirm(mod_changes, use_steam): xcenter 0.5 yanchor 0.0 - $ mods_to_add_text = "\n".join(mods_to_install.itervalues()) - $ mods_to_remove_text = "\n".join(modname for modname, _ in mods_to_uninstall.itervalues()) - fixed: # Added mods list: fixed is used to force list+scrollbar to stay within their region xalign 0.0 xsize 850 @@ -1344,17 +1350,19 @@ screen modmenu_apply_confirm(mod_changes, use_steam): ysize 100 xfill True - vpgrid id "_mod_add_list": - cols 1 + viewport id "_mod_add_list": xfill True mousewheel "change" - for mod_id, mod_name in mods_to_install.iteritems(): - hbox: - spacing 20 - ysize 60 + vbox: + spacing 5 + xfill True + + for mod_id, mod_name in mods_added_to_install.iteritems(): + hbox: + spacing 20 + ysize 60 - if not mod_changes.is_mod_dependency(mod_id): imagebutton: idle "image/ui/close_idle.png" at _button_zoom hover "image/ui/close_hover.png" @@ -1362,11 +1370,28 @@ screen modmenu_apply_confirm(mod_changes, use_steam): action Function(mod_changes.remove_mod, mod_id) # As mod is guaranteed to be in the add list, we only need id to remove. - text mod_name: - if len(mod_name) > 30: - size 30 - xfill True - ysize 60 + text mod_name: + if len(mod_name) > 30: + size 30 + xfill True + ysize 60 + + if mod_deps_to_install: + null height 5 + text "Including these dependencies:": + size 50 + null height 10 + + for mod_name in mod_deps_to_install.itervalues(): + hbox: + spacing 20 + ysize 60 + + text mod_name: + if len(mod_name) > 30: + size 30 + xfill True + ysize 60 vbar value YScrollValue("_mod_add_list"): style "modmenu_select_slider" @@ -1387,28 +1412,59 @@ screen modmenu_apply_confirm(mod_changes, use_steam): ysize 100 xfill True - vpgrid id "_mod_remove_list": - cols 1 + viewport id "_mod_remove_list": xfill True mousewheel "change" - for mod_id, (mod_name, _) in mods_to_uninstall.iteritems(): - hbox: - spacing 20 - ysize 60 - - imagebutton: - idle "image/ui/close_idle.png" at _button_zoom - hover "image/ui/close_hover.png" - yalign 0.5 - action Function(mod_changes.add_mod, mod_id) - - text mod_name: - if len(mod_name) > 30: - size 30 - xfill True + vbox: + spacing 5 + xfill True + for mod_id, (mod_name, _) in mods_to_uninstall.iteritems(): + hbox: + spacing 20 ysize 60 + imagebutton: + idle "image/ui/close_idle.png" at _button_zoom + hover "image/ui/close_hover.png" + yalign 0.5 + action Function(mod_changes.add_mod, mod_id) + + text mod_name: + if len(mod_name) > 30: + size 30 + xfill True + ysize 60 + + + if overridden_mods: + # If null is the first thing in the vpgrid, then nothing shows up + if not mods_to_uninstall: + text "---": + xfill True + ysize 60 + null height 5 + text "Removal suppressed:": + size 50 + null height 10 + + for mod_id, (mod_name, _) in overridden_mods.iteritems(): + hbox: + spacing 20 + ysize 60 + + imagebutton: + idle "image/ui/close_idle.png" at _button_zoom + hover "image/ui/close_hover.png" + yalign 0.5 + action Function(mod_changes.add_mod, mod_id) + + text mod_name: + if len(mod_name) > 30: + size 30 + xfill True + ysize 60 + vbar value YScrollValue("_mod_remove_list"): style "modmenu_select_slider" xalign 1.0 @@ -1449,9 +1505,6 @@ screen modmenu_apply_confirm(mod_changes, use_steam): screen modmenu_nointernet() tag smallscreen2: modal True - python: - #from modloader.modconfig import download_github_mod - pass add "image/ui/nvlscreen.png" at zoom_fade_in: xcenter 0.5 ycenter 0.5 size (1921, 1081) xoffset -1 yoffset -1 From cee582d8497a8f411c22be9c944dd99537dad595 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 11 Aug 2026 16:47:46 +0300 Subject: [PATCH 11/13] Added parents list to mod description, and scrollbars to it and the dependency list --- mods/core/download_mods.rpy | 95 +++++++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 20 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 89426d8..9cb1e63 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -711,7 +711,9 @@ init -1 python: self._installed_dependant_map += Counter(self.get_mod_dependencies(mod_id)) elif not is_installed and mod_id not in self._add_map: # Not added yet, and not an existing mod being reinstated self._add_map[mod_id] = self.get_mod(mod_id).name - self._dependant_add_map += Counter(self.get_mod_dependencies(mod_id)) + # filter add map to only care about mods which actually exist in the modlist + + self._dependant_add_map += Counter(self.get_mod_dependencies(mod_id).intersection(self._modlist.keys())) # else: nothing to do... def remove_mod(self, mod_id, filename=""): @@ -1259,32 +1261,86 @@ screen modmenu_mod_content(mod, mod_changes, use_steam): #yalign 0.95 # dependency area - frame xpos 1632 ypos 0.21 xsize 280 ysize 600: + frame xpos 1632 ypos 0.21 xsize 280 ysize 290: background "#0000009B" xpadding 15 - vbox xfill True ymaximum 600: + vbox xsize 230 yfill True: text "Dependencies:" size 40 - for dep_id in mod_changes.get_mod_dependencies(mod_id): - python: - try: - dep_name = mod_changes.get_mod(dep_id).name + viewport id "_dep_list" xfill True: + mousewheel "change" + + vbox: + for dep_id in mod_changes.get_mod_dependencies(mod_id): + python: + try: + dep_name = mod_changes.get_mod(dep_id).name + + if mod_changes.is_mod_removed(dep_id): + dep_color = "#ff3f00FF" + elif mod_changes.is_mod_installed(dep_id): + dep_color = "#ffffffFF" + elif mod_changes.is_mod_added(dep_id): + dep_color = "#bfff00FF" + else: + dep_color = "#00ffbfFF" + + except KeyError: + dep_name = "" + dep_color = "#7f7f7fFF" + + text "[dep_name]": + size 20 + color dep_color + + bar value YScrollValue("_dep_list"): + style "modmenu_content_slider" + ysize 250 + yalign 1.0 + xalign 1.0 + unscrollable "hide" - if mod_changes.is_mod_installed(dep_id): - dep_color = "#ffffffFF" - elif mod_changes.is_mod_added(dep_id): - dep_color = "#bfff00FF" - else: - dep_color = "#00ffbfFF" - except KeyError: - dep_name = "" - dep_color = "#7f7f7fFF" + # parents area + frame xpos 1632 ypos 0.497 xsize 280 ysize 290: + background "#0000009B" + xpadding 15 - text dep_name: - size 20 - color dep_color + vbox xsize 230 yfill True: + text "Dependants:" size 40 + + viewport id "_parent_list" xfill True: + mousewheel "change" + + vbox: + for dep_id in mod_changes.get_mod_parents(mod_id): + python: + try: + parent_name = mod_changes.get_mod(dep_id).name + if mod_changes.is_mod_removed(dep_id): + dep_color = "#ff3f00FF" + elif mod_changes.is_mod_installed(dep_id): + dep_color = "#00bfffFF" + elif mod_changes.is_mod_added(dep_id): + dep_color = "#bfff00FF" + else: + dep_color = "#ffffffFF" + + except KeyError: + parent_name = "" + dep_color = "#7f7f7fFF" + + text "[parent_name]": + size 20 + color dep_color + + bar value YScrollValue("_parent_list"): + style "modmenu_content_slider" + ysize 250 + yalign 1.0 + xalign 1.0 + unscrollable "hide" @@ -1438,7 +1494,6 @@ screen modmenu_apply_confirm(mod_changes, use_steam): if overridden_mods: - # If null is the first thing in the vpgrid, then nothing shows up if not mods_to_uninstall: text "---": xfill True From 52170f59e6cb65bd5d48f69917fdaf35b394cf75 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 11 Aug 2026 20:25:56 +0300 Subject: [PATCH 12/13] Fixed typo --- mods/core/download_mods.rpy | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 9cb1e63..9efd83c 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -584,9 +584,9 @@ init -1 python: self._modlist = {mod.id: mod for mod in base_modlist} self._add_map = {} - self._dependant_add_map = Counter() + self._dependent_add_map = Counter() self._remove_map = {} - self._installed_dependant_map = Counter() # equivalent of _dependant_add_map used to keep track when a mod is safe to delete + self._installed_dependent_map = Counter() # equivalent of _dependent_add_map used to keep track when a mod is safe to delete # setup recursive dependency maps forward_dep_map = {mod_id: set(mod.child_list) for mod_id, mod in self._modlist.iteritems()} @@ -604,15 +604,15 @@ init -1 python: self._dependency_map = _modmenu_get_recursive_deps(forward_dep_map, reverse_dep_map, strict=False) self._r_dependency_map = _modmenu_get_recursive_deps(reverse_dep_map, forward_dep_map, strict=False) - self._reset_installed_dependant_map() + self._reset_installed_dependent_map() - def _reset_installed_dependant_map(self): - self._installed_dependant_map.clear() + def _reset_installed_dependent_map(self): + self._installed_dependent_map.clear() for mod_id in self._modlist: if self.is_mod_installed(mod_id): - self._installed_dependant_map += Counter(self._dependency_map[mod_id]) + self._installed_dependent_map += Counter(self._dependency_map[mod_id]) def get_mod(self, mod_id): @@ -655,15 +655,15 @@ init -1 python: """ if missing: result = {} - for mod_id in self._dependant_add_map.iterkeys(): + for mod_id in self._dependent_add_map.iterkeys(): mod_name = self.get_mod(mod_id).name if not _modmenu_is_mod_installed(mod_id, mod_name): result[mod_id] = mod_name return result - return {mod_id: self.get_mod(mod_id).name for mod_id in self._dependant_add_map.iterkeys()} + return {mod_id: self.get_mod(mod_id).name for mod_id in self._dependent_add_map.iterkeys()} def get_installed_dependencies(self): - return {mod_id for mod_id in self._installed_dependant_map.iterkeys()} + return {mod_id for mod_id in self._installed_dependent_map.iterkeys()} def is_mod_added(self, mod_id, dependency=True): @@ -699,7 +699,7 @@ init -1 python: def is_mod_removable(self, mod_id): """Can a mod be removed without breaking dependency constraints. this decides if a remove_mod call will produce a visible effect""" - return mod_id not in self._installed_dependant_map and mod_id not in self._dependant_add_map + return mod_id not in self._installed_dependent_map and mod_id not in self._dependent_add_map def add_mod(self, mod_id): @@ -708,12 +708,12 @@ init -1 python: if is_installed and mod_id in self._remove_map: # Added, then removed this session self._remove_map.pop(mod_id) - self._installed_dependant_map += Counter(self.get_mod_dependencies(mod_id)) + self._installed_dependent_map += Counter(self.get_mod_dependencies(mod_id)) elif not is_installed and mod_id not in self._add_map: # Not added yet, and not an existing mod being reinstated self._add_map[mod_id] = self.get_mod(mod_id).name # filter add map to only care about mods which actually exist in the modlist - self._dependant_add_map += Counter(self.get_mod_dependencies(mod_id).intersection(self._modlist.keys())) + self._dependent_add_map += Counter(self.get_mod_dependencies(mod_id).intersection(self._modlist.keys())) # else: nothing to do... def remove_mod(self, mod_id, filename=""): @@ -722,18 +722,18 @@ init -1 python: if not is_installed and mod_id in self._add_map: self._add_map.pop(mod_id) - self._dependant_add_map -= Counter(self.get_mod_dependencies(mod_id)) + self._dependent_add_map -= Counter(self.get_mod_dependencies(mod_id)) elif is_installed and mod_id not in self._remove_map: self._remove_map[mod_id] = (self.get_mod(mod_id).name, filename) - self._installed_dependant_map -= Counter(self.get_mod_dependencies(mod_id)) + self._installed_dependent_map -= Counter(self.get_mod_dependencies(mod_id)) def clear_added_mods(self): self._add_map.clear() - self._dependant_add_map.clear() + self._dependent_add_map.clear() def clear_removed_mods(self): self._remove_map.clear() - self._reset_installed_dependant_map() + self._reset_installed_dependent_map() def clear_mods(self): self.clear_added_mods() @@ -1308,7 +1308,7 @@ screen modmenu_mod_content(mod, mod_changes, use_steam): xpadding 15 vbox xsize 230 yfill True: - text "Dependants:" size 40 + text "Dependents:" size 40 viewport id "_parent_list" xfill True: mousewheel "change" From 7ba858b5bd4b7f97d46b570ff014e99a3857e790 Mon Sep 17 00:00:00 2001 From: Aurumbi <109061320+Aurumbi@users.noreply.github.com.> Date: Tue, 11 Aug 2026 21:01:08 +0300 Subject: [PATCH 13/13] Made dependencies and dependents lists clickable like the modlist buttons. color consistency and informativity improvements --- mods/core/download_mods.rpy | 114 +++++++++++++++++++++++++++++------- 1 file changed, 92 insertions(+), 22 deletions(-) diff --git a/mods/core/download_mods.rpy b/mods/core/download_mods.rpy index 9efd83c..731e274 100644 --- a/mods/core/download_mods.rpy +++ b/mods/core/download_mods.rpy @@ -1128,6 +1128,9 @@ screen modmenu_paged_modlist(contents, mod_changes, use_steam): else: background "#7f3f009B" hover_background "#ffbf7f9B" + elif mod_changes.is_mod_installed(mod_id): + background "#001f3f9B" + hover_background "#003f7f9B" action [Hide("modmenu_mod_content"), @@ -1274,25 +1277,59 @@ screen modmenu_mod_content(mod, mod_changes, use_steam): vbox: for dep_id in mod_changes.get_mod_dependencies(mod_id): python: + dep_present = True try: dep_name = mod_changes.get_mod(dep_id).name - if mod_changes.is_mod_removed(dep_id): - dep_color = "#ff3f00FF" + if mod_changes.is_mod_removed(dep_id, dependency=False): + if mod_changes.is_mod_removable(dep_id): + dep_color = "#ff1f1fFF" + dep_hover = "#ff4f4fFF" + else: + dep_color = "#9f7f7fFF" + dep_hover = "#7f5f5fFF" elif mod_changes.is_mod_installed(dep_id): - dep_color = "#ffffffFF" - elif mod_changes.is_mod_added(dep_id): + dep_color = "#00bfffFF" + dep_hover = "#007fbfFF" + elif mod_changes.is_mod_added(dep_id, dependency=False): dep_color = "#bfff00FF" - else: + dep_hover = "#7fbf00FF" + elif mod_changes.is_mod_dependency(dep_id): dep_color = "#00ffbfFF" + dep_hover = "#00bf7fFF" + else: + dep_color = "#ffffffFF" + dep_hover = "#bfbfbfFF" except KeyError: + dep_present = False dep_name = "" dep_color = "#7f7f7fFF" + dep_hover = "#afafafFF" + + textbutton "[dep_name]": + background "#0000" # Clear background, as I don't wish there to be + padding (0, 0) + text_size 20 + text_color dep_color + text_hover_color dep_hover + + if dep_present: + action [Hide("modmenu_mod_content"), + Show("modmenu_mod_content", + mod=mod_changes.get_mod(dep_id), + mod_changes=mod_changes, + use_steam=use_steam, + ), + Play("audio", "se/sounds/open.ogg")] + + alternate [If(mod_changes.is_mod_present(dep_id, dependency=False), + Function(mod_changes.remove_mod, dep_id, _get_modfolder(dep_id, mod_changes.get_mod(dep_id).name)), + Function(mod_changes.add_mod, dep_id)), + Play("audio", "se/sounds/open.ogg")] + ] + - text "[dep_name]": - size 20 - color dep_color bar value YScrollValue("_dep_list"): style "modmenu_content_slider" @@ -1314,26 +1351,59 @@ screen modmenu_mod_content(mod, mod_changes, use_steam): mousewheel "change" vbox: - for dep_id in mod_changes.get_mod_parents(mod_id): + for parent_id in mod_changes.get_mod_parents(mod_id): python: + parent_present = True try: - parent_name = mod_changes.get_mod(dep_id).name - if mod_changes.is_mod_removed(dep_id): - dep_color = "#ff3f00FF" - elif mod_changes.is_mod_installed(dep_id): - dep_color = "#00bfffFF" - elif mod_changes.is_mod_added(dep_id): - dep_color = "#bfff00FF" + parent_name = mod_changes.get_mod(parent_id).name + + if mod_changes.is_mod_removed(parent_id, dependency=False): + if mod_changes.is_mod_removable(parent_id): + parent_color = "#ff1f1fFF" + parent_hover = "#ff4f4fFF" + else: + parent_color = "#9f7f7fFF" + parent_hover = "#7f5f5fFF" + elif mod_changes.is_mod_installed(parent_id): + parent_color = "#00bfffFF" + parent_hover = "#007fbfFF" + elif mod_changes.is_mod_added(parent_id, dependency=False): + parent_color = "#bfff00FF" + parent_hover = "#7fbf00FF" + elif mod_changes.is_mod_dependency(parent_id): + parent_color = "#00ffbfFF" + parent_hover = "#00bf7fFF" else: - dep_color = "#ffffffFF" + parent_color = "#ffffffFF" + parent_hover = "#bfbfbfFF" except KeyError: + parent_present = False parent_name = "" - dep_color = "#7f7f7fFF" - - text "[parent_name]": - size 20 - color dep_color + parent_color = "#7f7f7fFF" + parent_hover = "#afafafFF" + + textbutton "[parent_name]": + background "#0000" # Clear background, as I don't wish there to be + padding (0, 0) + text_size 20 + text_color parent_color + text_hover_color parent_hover + + if parent_present: + action [Hide("modmenu_mod_content"), + Show("modmenu_mod_content", + mod=mod_changes.get_mod(parent_id), + mod_changes=mod_changes, + use_steam=use_steam, + ), + Play("audio", "se/sounds/open.ogg")] + + alternate [If(mod_changes.is_mod_present(parent_id, dependency=False), + Function(mod_changes.remove_mod, parent_id, _get_modfolder(parent_id, mod_changes.get_mod(parent_id).name)), + Function(mod_changes.add_mod, parent_id)), + Play("audio", "se/sounds/open.ogg")] + ] bar value YScrollValue("_parent_list"): style "modmenu_content_slider"