From 48c5159402d4d5a6e0dcf3fac1c56d8a6b99c10d Mon Sep 17 00:00:00 2001 From: Alexander Hayden Date: Fri, 27 Nov 2020 19:20:16 -0500 Subject: refactor. hopefully it works. --- modpackman.py | 85 +++++++++++++++++ pack.ini | 7 +- util.py | 293 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ version.txt | 82 +++++++++++++++- 4 files changed, 461 insertions(+), 6 deletions(-) create mode 100755 modpackman.py create mode 100755 util.py diff --git a/modpackman.py b/modpackman.py new file mode 100755 index 0000000..e5c355d --- /dev/null +++ b/modpackman.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +import argparse +import os +import sys +import shutil + +import util + + +parser = argparse.ArgumentParser( + description="A Simple Git-Based Modpack Manager", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog='''\ +Available commands: + install : Downloads mods listed in version.txt and populates the mods folder specified in pack-location.txt + apply_updates : Using the urls in mods.txt, repopulates version.txt to reflect the most recent mod versions + check_updates : Compares version.txt and mods.txt to see if any mods can be updated +''') + +parser.add_argument('command', + nargs='?', + default='install', + help="The action to perform (default: install)") +parser.add_argument('filename', + nargs='?', + default="mods.txt", + help="Optional filename to specify latest mods (default: mods.txt)") +parser.add_argument('--version-file', + type=str, + default="version.txt", + help="Optional custom version file to download mods from (default: version.txt)") +parser.add_argument('--pack-location', + type=str, + help="Optional custom modpack folder location (default: read from pack-location.txt)") +parser.add_argument('--whitelist-file', + type=str, + default="whitelist.txt", + help="Optional custom whitelist file that tells 'install' which files not to remove (default: whitelist.txt)") +parser.add_argument("--game-version", + type=str, + default=None, + help="The maximum game version to update mods to") + +COMMAND_MAP = ["install", "apply_updates", "check_updates"] + +if __name__ == "__main__": + args = parser.parse_args() + config = util.load_config() + mods = config['mods'] + pack = config['pack'] + + GAME_VERSION = util.game_version_from_string(pack["game_version"]) + if args.pack_location: + pack['location'] = args.pack_location + + if not os.path.exists(args.version_file): + print("Error: version file\"" + args.version_file + "\" does not exist.") + parser.print_help() + sys.exit(1) + if args.pack_location and not os.path.exists(args.pack_location): + print("Error: mod folder \"" + args.pack_location + "\" does not exist.") + parser.print_help() + sys.exit(1) + elif args.pack_location and not os.path.isdir(args.pack_location): + print("Error: mod folder \"" + args.pack_location + "\" is not actually a folder.") + parser.print_help() + sys.exit(1) + if not os.path.exists(args.whitelist_file): + print("Error: whitelist file \"" + args.whitelist_file + "\" does not exist.") + sys.exit(1) + if args.game_version: + GAME_VERSION = util.game_version_from_string(args.game_version) + + if not (args.command in COMMAND_MAP): + print("Error: command \"" + args.command + "\" does not exist") + parser.print_help() + sys.exit(1) + + # run the command + if args.command == "install": + util.install(args.version_file, args.whitelist_file, pack['location']) + elif args.command == "apply_updates": + util.apply_updates(mods, args.version_file, GAME_VERSION) + elif args.command == "check_updates": + util.check_updates(mods, args.version_file, GAME_VERSION) diff --git a/pack.ini b/pack.ini index e775b8f..2175975 100644 --- a/pack.ini +++ b/pack.ini @@ -1,7 +1,8 @@ [pack] -name = "J.E.F.F.R.E.Y 3" -forge_url = "forge.com" -icon_name = "jeffrey-3.png" +name = J.E.F.F.R.E.Y 3 +forge_url = forge.com +game_version = 1.16.4 +icon_name = jeffrey-3.png [mods] # Possibly controversial mods - subject to further review diff --git a/util.py b/util.py new file mode 100755 index 0000000..c50ca87 --- /dev/null +++ b/util.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +import os +import sys +import hashlib +import shutil +import re +import collections +import urllib.parse +import multiprocessing +from configparser import ConfigParser + +import requests + + +def load_config(filename="pack.ini"): + config = ConfigParser() + config.read(filename) + config["pack"]["sanitized_name"] = sanitize_text(config["pack"]["name"]) + #TODO generate a default pack location + config["pack"]["location"] = f"/tmp/{config['pack']['sanitized_name']}" + # return the whole config file, pack configuration and modlist + return config + +# take a string and only keep filename-friendly parts +def sanitize_text(text): + sanitized = "" + replacement_map = {" ": "-"} + for char in text: + if char.isalnum(): + sanitized += char.lower() + elif char in replacement_map: + sanitized += replacement_map[char] + return sanitized + + +def read_file(fil): + """ + Given a filename, read its contents in as a list of tuples. + This function strips out comment lines and whitespaces. + """ + strings = [] + with open(fil) as f: + for line in f: + string = line.strip().split() + if len(line) > 1 and line[0] != '#': + # run strip on each element + string = tuple(map(lambda x: x.strip(), string)) + strings.append(string) + + return strings + +def get_version_from_file(fil): + with open(fil) as f: + for line in f: + if line.strip().split()[0] == "#VERSION": + return int(line.strip().split()[1]) + return 0 + +def game_version_from_string(string): + if string is not None: + try: + return tuple(int(x) for x in string.split('.')) + except: + pass + return (2, 0, 0) + + +# Apply updates to the actual mod pack +def install(version_file, whitelist_file, pack_location): + pack_version = get_version_from_file(version_file) + print("Updating pack with version " + str(pack_version) + "...") + print() + # (fname, checksum, url) + mods = read_file(version_file) + names = [mod[0] for mod in mods] + # whitelist client mods (e.g. optifine) + names += [line[0] for line in read_file(whitelist_file)] + + i = 0 + for mod in mods: + mod_path = os.path.join(pack_location, mod[0]) + i += 1 + if os.path.exists(mod_path) and os.path.isfile(mod_path) and \ + hashlib.sha1(open(mod_path, 'rb').read()).hexdigest() == mod[1]: + print("Skipping {mod[0]}, already up to date".format(mod=mod)) + else: + print('Installing {mod[0]} from {mod[2]}...'.format(mod=mod)) + print(' ({i} of {x})'.format(i=i,x=len(mods)), end='\r') + download_obj = requests.get(mod[2], stream=True) + with open(mod_path, "wb") as write_file: + shutil.copyfileobj(download_obj.raw, write_file) + print("Done!" + " " * 8) + + print() + print("Removing old mods...") + for jar in os.listdir(pack_location): + if jar not in names and os.path.splitext(jar)[1] == ".jar": + os.remove(os.path.join(pack_location, jar)) + print("Removing '{jar}'".format(jar=jar)) + + print() + print("Finished installing mods!") + + +# Using the latest urls, update downloads.txt to match and have the correct sha1 +def apply_updates(mods, version_file, game_version=(2, 0, 0)): + pack_version = get_version_from_file(version_file) + print("Populating version file...") + print("Getting new versions of all mods...") + mod_urls = find_updated_urls([x for x in mods.values()], game_version, threads=3) + print("Downloading and checksumming all mods...") + checksums = find_checksums(mod_urls) + + # Write information out to version.txt + with open(version_file, 'w') as f: + f.write('# Format: \n') + f.write("#VERSION " + str(pack_version + 1) + "\n") + for name, checksum, url in zip((k+'.jar' for k in mods.keys()), checksums, mod_urls): + f.write(f'{name} {checksum} {url}\n') + + print() + print("Done!") + print(f"Updates applied to {version_file}") + print("New pack version is " + str(pack_version + 1)) + print("[!] No mods were installed. To update your mods folder, run 'update.py install'") + + +# Find if any updates are available +def check_updates(mods, version_file, version=(2, 0, 0)): + pack_version = get_version_from_file(version_file) + print("Checking for updates to version " + str(pack_version) + "...") + latest = [(k, mods[k]) for k in mods.keys()] + old = read_file(version_file) + old_urls = [mod[2] for mod in old] + num_updates = 0 + + print("Checking updates...") + ffx = firefox() + + for mod in latest: + print("Checking for updates to {mod[0]}...".format(mod=mod), end="") + sys.stdout.flush() # takes care of line-buffered terminals + if 'curseforge' in mod[1]: + url = find_cdn(ffx, mod[1], version) + else: + url = requests.get(mod[1]).url + if url in old_urls: + print(" No updates") + else: + print(" Found update: " + url.split('/')[-1]) + num_updates += 1 + ffx.close() + + print("Finished checking for updates. {num} mods can be updated".format(num=num_updates)) + if num_updates >= 0: + print("Run 'python update.py apply_updates' to create a new version with these updates applied.") + + +def threaded_find_url(homepage_url, game_version): + """ + Helper function that finds a single mod URL based on the homepage. + """ + if 'curseforge' in homepage_url: + ffx = firefox() + final_url = find_cdn(ffx, homepage_url, game_version) + ffx.close() + else: + final_url = requests.get(homepage_url).url + return final_url + + +def find_updated_urls(forge_urls, game_version, threads=20): + """ + Given a list of mod homepage URLs, find all of their direct download links in parallel. + """ + + # First, check that we can successfully open a Firefox instance in the main thread. + # This provides us with a much nicer error message and quicker feedback. + f = firefox() + f.close() + + with multiprocessing.Pool(threads) as pool: + # No progress indicator possible + # return pool.map(threaded_find_url, forge_urls) + + # Much longer, but allows us to do a nice progress indicator + result_futures = [] + for url in forge_urls: + result_futures.append(pool.apply_async(threaded_find_url, (url, game_version))) + + results = [] + for i,f in enumerate(result_futures): + results.append(f.get()) + print(f'\r{i+1}/{len(result_futures)} URLs updated ({round((i+1)/len(result_futures)*100)}%)', end='') + print() + + return results + + +def threaded_calc_sha1(direct_url): + """ + Helper function that downloads and calculates a single SHA1 hash from a direct download URL. + """ + resp = requests.get(direct_url) + hsh = hashlib.sha1(resp.content).hexdigest() + return hsh + + +def find_checksums(direct_urls, threads=8): + """ + Given a list of direct download URLs, download them all and calculate the SHA1 checksum of the file at that location. + """ + + with multiprocessing.Pool(threads) as pool: + # Much longer, but allows us to do a nice progress indicator + result_futures = [] + for url in direct_urls: + result_futures.append(pool.apply_async(threaded_calc_sha1, (url,))) + + results = [] + for i,f in enumerate(result_futures): + results.append(f.get()) + print(f'\r{i+1}/{len(result_futures)} checksums calculated ({round((i+1)/len(result_futures)*100)}%)', end='') + print() + + return results + + +def find_cdn(ffx, url, version): + """ + Given a mod home URL, finds the most up-to-date mod version compatible with the given game version. + Returns the direct Forge CDN download URL + """ + try: + # This goes to the "all files" page, where we get a table view of all + ffx.get(url + '/files/all') + mod_versions = ffx.find_elements_by_class_name("listing")[0].find_elements_by_xpath("tbody/tr") # extract the table of files from the page + row_info = collections.namedtuple("row_info", ["type", "filename", "cdn_id", "game_version"]) # create a custom tuple because data + rows = [] + for version_entry in mod_versions: + # parse out the four fields that we use + entry_cells = version_entry.find_elements_by_tag_name("td") + release_type = entry_cells[0].text + # Note that this is NOT the final filename - this is just the "release name". + filename = urllib.parse.quote(entry_cells[1].find_elements_by_tag_name("a")[0].text) + try: + game_version = tuple([int(x) for x in entry_cells[4].find_element_by_class_name("mr-2").text.split(".")]) # get game version and convert to tuple + except: + game_version = (0, 0, 0) + cdn_id = entry_cells[1].find_element_by_tag_name("a").get_property("href").split("/")[-1] + + #TODO make this configurable + if 'fabric' not in filename.lower() or 'forge' in filename.lower(): + rows.append(row_info(release_type, filename, cdn_id, game_version)) + rows.sort(key=lambda x: x.game_version, reverse=True) + best_row = next(x for x in rows if x.game_version <= version) + + # We need to find the real, ForgeCDN compatible filename now by going to the file page. + ffx.get(f'{url}/files/{best_row.cdn_id}') + # This will probably break in the future + filename = ffx.find_elements_by_xpath("html/body/div/main/div/div/section/div/div/div/section/section/article/div/div/span")[1].text + # URL escape the filename! + filename = urllib.parse.quote(filename) + + # ForgeCDN requires that the leading zeroes are stripped from each portion of the CDN ID, hence the int() cast. + return f'https://media.forgecdn.net/files/{int(best_row.cdn_id[:4])}/{int(best_row.cdn_id[4:])}/{filename}' + + except: + print(url) + open('temp.txt', 'a').write(url) + import traceback; traceback.print_exc() + return None + + +def firefox(): + """ + Start a headless Firefox instance and return the Selenium refrence to it. + """ + #print("Starting Selenium...") + try: + from selenium.webdriver import Firefox + from selenium.webdriver.firefox.options import Options + except: + print("Applying updates requires the `selenium` package") + exit(0) + options = Options() + options.add_argument('-headless') + options.add_argument('--window-size 1920,1080') + #for ~~cursed~~ windows people, put geckodriver in this folder + if(os.path.exists("./geckodriver")): + return Firefox(executable_path='./geckodriver', options=options) + return Firefox(options=options) + diff --git a/version.txt b/version.txt index 839d65f..710244e 100644 --- a/version.txt +++ b/version.txt @@ -1,4 +1,80 @@ # Format: -#VERSION 34 -thaumcraft.jar fe0899048f1796df04e9727bbf1898df30492a00 https://media.forgecdn.net/files/2629/23/Thaumcraft-1.12.2-6.1.BETA26.jar -baubles.jar cb13fcfb18a9cb0cbd825fd5fe8d813c77368549 https://media.forgecdn.net/files/2518/667/Baubles-1.12-1.5.2.jar +#VERSION 35 +building-gadgets.jar 13e89349f2929c205c6c80615c6e927f70c9b1e0 https://media.forgecdn.net/files/3097/299/buildinggadgets-3.7.2.jar +item-collectors.jar a4b3eeb92dd79736ce622e92f49bd5ff2b0b84f0 https://media.forgecdn.net/files/3101/223/itemcollectors-1.0.6-mc1.16.4.jar +natures-compass.jar 30ae4fe62fbfdff8d81f84a821588ba882851a04 https://media.forgecdn.net/files/3123/531/NaturesCompass-1.16.4-1.8.5.jar +packing-tape.jar 46060f5793a82a87967bf8137cbff73f34c40ac7 https://media.forgecdn.net/files/3082/552/PackingTape-1.16.3-0.10.0.jar +seals.jar 8f83c9e18cae5929a24c528c4a0703d4f5202cac https://media.forgecdn.net/files/3073/361/seals-1.16.3-2.0.0.jar +simple-planes.jar 8c520c2bcd10c2e86c8e03cc0355e373610330dc https://media.forgecdn.net/files/3118/221/simpleplanes-1.16.3-3.2.0.2.jar +ender-chests.jar c30300af94311a46cc533cf9549f93432bbbcbe1 https://media.forgecdn.net/files/3106/960/enderchests-1.16-1.7.5.jar +ender-tanks.jar a750b0d7fe4d50c23c647818b2bd11ea63b95ce9 https://media.forgecdn.net/files/3055/892/endertanks-1.16-1.9.3.jar +gauges-and-switches.jar 438f5cdc2665016b5524eb2a9cf0d782019fba10 https://media.forgecdn.net/files/3104/34/rsgauges-1.16.4-1.2.6-b1.jar +moving-elevators.jar aac2bf90b49f07875971e05d7cc00b1ddf440548 https://media.forgecdn.net/files/3117/14/movingelevators-1.2.27-mc1.16.4.jar +cooking-for-blockheads.jar cb9d33c55eefc4d876b2590a9bd099c0ef1cea77 https://media.forgecdn.net/files/3098/223/CookingForBlockheads_1.16.3-9.2.2.jar +discord-presence.jar cf7250f8cdd6dcbd0a3048a4bc992cb0f3964671 https://media.forgecdn.net/files/3103/834/SimpleDiscordRichPresence-1.16.4-1.3.5.jar +shetiphiancore.jar b9e612714fc2bb7a6bd1be8b04a143b45d76b121 https://media.forgecdn.net/files/3090/382/shetiphiancore-1.16-3.8.4.jar +quark.jar caf968bf01def88d248cdef3beebca4fa6690b30 https://media.forgecdn.net/files/3117/57/Quark-r2.4-276.jar +blood-magic.jar 1a1254ba9bd8ffd2da3c5b2ba78b87fdadb07a2f https://media.forgecdn.net/files/3118/746/BloodMagic-1.16.3-3.0.1-6.jar +astral-sorcery.jar fe95397955f0e14f43b390d2d655766f4507e2c2 https://media.forgecdn.net/files/3118/579/astralsorcery-1.16.4-1.13.1.jar +ae2.jar 7d2b29fceaed530813aae3b60bef27a28ecc3257 https://media.forgecdn.net/files/3118/473/appliedenergistics2-8.2.0-alpha.2.jar +mekanism.jar 6ada0dcce6673613dd26fea9afda663e697b3886 https://media.forgecdn.net/files/3100/510/Mekanism-1.16.4-10.0.17.444.jar +mekanism-tools.jar 07a5c41e013229c417d74e343ef05e63ae55f273 https://media.forgecdn.net/files/3100/513/MekanismTools-1.16.4-10.0.17.444.jar +mekanism-generators.jar e04ea7214b587e5d2ce56830c9d8de5039de6874 https://media.forgecdn.net/files/3100/512/MekanismGenerators-1.16.4-10.0.17.444.jar +mekanism-additions.jar 0ad7c37f23a1fb5369bbf6cc655f1dc71884b53b https://media.forgecdn.net/files/3100/514/MekanismAdditions-1.16.4-10.0.17.444.jar +psi.jar 35ff69060bbdf7514da60d224ffd9db8bdd0f772 https://media.forgecdn.net/files/3106/707/Psi%201.16-88.jar +cc-tweaked.jar 569a119e39aa59d03d10189bec829f7bc8d6fb9b https://media.forgecdn.net/files/3104/639/cc-tweaked-1.16.4-1.94.0.jar +immersive-engineering.jar a1263bd875531ee5bf70937fa207f4509efb1cfe https://media.forgecdn.net/files/3102/280/ImmersiveEngineering-1.16.4-4.1.1-128.jar +botania.jar c890bd2ec0d13ead2627b0bf0c7599de6b2f6194 https://media.forgecdn.net/files/3086/502/Botania-1.16.3-409.jar +jei.jar 2bbcffbbe7ac7c93e75783637a0a78fddcb4109d https://media.forgecdn.net/files/3109/181/jei-1.16.4-7.6.0.58.jar +tetra.jar 6e097ef3053cc56b95973607db632d08ebf6c554 https://media.forgecdn.net/files/3104/240/tetra-1.16.4-3.3.1.jar +immersive-portals.jar 3fc06df79a96fc9e695c3f1091772e8cd2b175cd https://github-production-release-asset-2e65be.s3.amazonaws.com/316149911/5d2ca300-2f8e-11eb-8586-1e03f0825404?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIWNJYAX4CSVEH53A%2F20201127%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20201127T234755Z&X-Amz-Expires=300&X-Amz-Signature=2aa1740e6eb705062090827613c00ac60066b159a2530448633dddaf86d6093d&X-Amz-SignedHeaders=host&actor_id=0&key_id=0&repo_id=316149911&response-content-disposition=attachment%3B%20filename%3Dimmersive-portals-0.9.jar&response-content-type=application%2Foctet-stream +inventory-sorter.jar fb538c5a8eb1e8a7a2bc35f3344b816634d53e4e https://media.forgecdn.net/files/3077/903/inventorysorter-1.16.1-18.1.0.jar +creeper-confetti.jar c1d06621ba453680fe31dd30fe11f6ea9bcc8e93 https://media.forgecdn.net/files/3063/500/creeperconfetti-3.4.jar +hwyla.jar 79123ef4c447affe57e77ee241d78c494bd8948d https://media.forgecdn.net/files/3033/593/Hwyla-forge-1.10.11-B78_1.16.2.jar +jer.jar dcede97c594d1f89a071bd2f904f232f789aea44 https://media.forgecdn.net/files/3109/962/JustEnoughResources-1.16.4-0.12.0.103.jar +ender-tendril.jar caa72968398bf124e5c2fcb03833547b74b68056 https://media.forgecdn.net/files/3069/322/EnderTendril-1.16.3-1.1.1%2B4.jar +discord-integration.jar d144c5a7918f3ccd7ab01b8768893c0562388cb3 https://media.forgecdn.net/files/3118/761/dcintegration-2.0.2-1.16.jar +step.jar dcc4854053a27c8c893fb3809b6d2a2cda0741ab https://media.forgecdn.net/files/3100/371/step-1.16.4-1.0.3.jar +simply-backpacks.jar d4bb5d2d4d8078debef3e7f0788f21db3f637bfa https://media.forgecdn.net/files/3076/926/simplybackpacks-1.16.3-1.4.13.jar +better-mineshafts.jar aab53b56b16d0d6627a5717f2e79c384e416535b https://media.forgecdn.net/files/3112/344/BetterMineshafts-Forge-1.16.3-1.1.jar +comforts.jar dee90d20dd732ee14d1e600bd886cb4f75271d94 https://media.forgecdn.net/files/3107/489/comforts-forge-1.16.4-4.0.0.2.jar +better-than-llamas.jar 29cecac936279cc87cc4a957acfbcca8d5551b2e https://media.forgecdn.net/files/3062/115/BetterThanLlamas-1.16.3-1.1.1.jar +better-than-bunnies.jar 58640158f2aedbb0547d37568c0ccca66fd49944 https://media.forgecdn.net/files/3062/114/BetterThanBunnies-1.16.3-1.2.0.jar +corpse.jar d5bf41cad1a7c7d90972697e3f5494dd0444e15c https://media.forgecdn.net/files/3123/623/corpse-1.16.4-1.0.4.jar +gilded-armor.jar 2eae828bcf1ef89c302f7100fe02505ac048c5e1 https://media.forgecdn.net/files/3069/303/gildedarmor-1.16.3-1.0.3.jar +jei-integration.jar e9368114e66bf4fc23cd21dd97b95c469115dd55 https://media.forgecdn.net/files/3122/292/jeiintegration_1.16.4-6.1.1.11.jar +openblocks-elevator.jar 6e3e440447a57b7b2b3256d4acbf1519be3a09c8 https://media.forgecdn.net/files/3110/386/elevatorid-1.16.4-1.7.8.jar +ranged-pumps.jar 202c2aba870b852638d6ed28074dd0e91b2f76c1 https://media.forgecdn.net/files/3065/697/rangedpumps-0.8.2.jar +the-conjurer.jar 3dd29349f2190d2f5b40cebed4fa67f6bb716324 https://media.forgecdn.net/files/3107/953/the-conjurer-1.16.4-1.0.13.jar +trashcans.jar cc59ed3017aca041de132cbfe14cff279be00312 https://media.forgecdn.net/files/3101/203/trashcans-1.0.3-mc1.16.4.jar +controlling.jar d7d04f585795b3d3040a6ad863a8737cf2efa6ac https://media.forgecdn.net/files/3110/995/Controlling-7.0.0.11.jar +ding.jar d984f82495e1ed12236aeea6e6be20d2bd094d72 https://media.forgecdn.net/files/3062/74/Ding-1.16.3-1.2.0.jar +appleskin.jar d0e2d9d9bf17806d3fbd263a8702822ab33edeb1 https://media.forgecdn.net/files/3035/787/AppleSkin-mc1.16.2-forge-1.0.14.jar +enchantment-descriptions.jar 30439e14507a5b942d4b696af8a1dbc463134199 https://media.forgecdn.net/files/3112/901/EnchantmentDescriptions-1.16.4-6.0.2.jar +emojiful.jar 994b1e4de03a22880a904339c64d35db93d09732 https://media.forgecdn.net/files/3099/65/emojiful-1.16.3-2.1.1.jar +clumps.jar d7b9dad4a2ca23b4e262cfc0d793b5565a21bd95 https://media.forgecdn.net/files/3099/275/Clumps-6.0.0.12.jar +toast-control.jar 6ebe524115df53b9d9a8068e1490ce20b9e139dc https://media.forgecdn.net/files/3069/51/Toast-Control-1.16.3-4.3.0.jar +mouse-tweaks.jar b9c5ac6c2183eee2c4acda20dc1dd7fc2c387fa2 https://media.forgecdn.net/files/3035/780/MouseTweaks-2.13-mc1.16.2.jar +recipe-buffers.jar b6a5af3c63651ae8f5434cec4fa753a5bfc5fdbb https://media.forgecdn.net/files/3086/475/recipebuffers-1.0.jar +harvest.jar f9618079d9456c23133ab89909fdd1c9fbae56b8 https://media.forgecdn.net/files/3087/381/harvest-1.16.3-1.0.3.jar +light-overlay.jar a3caff7768581d5d97b5433dd834dfa6d676aea9 https://media.forgecdn.net/files/3091/376/light-overlay-5.5.4.jar +morpheus.jar da30b18acd23c270a7da07ed8feb693291efcf90 https://media.forgecdn.net/files/3114/135/Morpheus-1.16.4-4.2.68.jar +extreme-sound-muffler.jar 8144221087d309afcf68926849d4095becf9e5ab https://media.forgecdn.net/files/3117/97/extremeSoundMuffler-3.0_Forge-1.16.4.jar +fast-workbench.jar c7f0296b5c569f1fe11aa6368b87bebfa01c9ddc https://media.forgecdn.net/files/3112/661/FastWorkbench-1.16.3-4.4.1.jar +findme.jar 7f18ff3af51b46c388d780c0b0afb9b400cc7962 https://media.forgecdn.net/files/3073/336/findme-1.16.3-2.1.0.0.jar +wawla.jar d9a4dbdfe97fa58e591aa0c4c714c13abb93c318 https://media.forgecdn.net/files/3113/92/WAWLA-1.16.4-7.0.1.jar +iron-chests.jar 3e7a9131d1ea836527d5cf7a1761d8b4dc7026a3 https://media.forgecdn.net/files/3105/315/ironchest-1.16.4-11.2.10.jar +reauth.jar f5bdf682d64bcde250c8a2a9c1261e285540ac9b https://media.forgecdn.net/files/3105/779/ReAuth-1.16-Forge-3.9.3.jar +yungs-caves.jar 6b4603a8fc2cb2aa13e8cf745865b25e10e25bde https://media.forgecdn.net/files/3110/318/BetterCaves-1.16.3-1.0.5.jar +mgui.jar 847a29f1913b3957d384e57be8ee415a51c3e8ff https://media.forgecdn.net/files/3104/239/mgui-1.16.4-3.1.3.jar +obfuscate.jar 0c486bc9a07bbd4aa100ef5fffe5b045e141de77 https://media.forgecdn.net/files/3067/472/obfuscate-0.5.0-1.16.3.jar +placebo.jar 1513d08e468654c8893f00f6b844fa374fd56620 https://media.forgecdn.net/files/3092/113/Placebo-1.16.3-4.3.3.jar +cloth-config.jar 1ee61a4974ce5c720b495a22d1221a4906a4334b https://media.forgecdn.net/files/3112/227/cloth-config-forge-4.1.1.jar +structure-jel.jar e60cf626b505ce13aee752b2bdbe2c273c383f17 https://media.forgecdn.net/files/3120/237/structure-gel-api-1.16.4-1.7.1.jar +bookshelf.jar eb2a3f92ef92cab0362adc40d97c3b7accfc08bb https://media.forgecdn.net/files/3118/648/Bookshelf-1.16.4-9.1.13.jar +ichunutil.jar 902ee274d713242aae0339a3ddefabc277b65632 https://media.forgecdn.net/files/3062/89/iChunUtil-1.16.3-10.0.0.jar +titanium.jar 21aae20ed2002e20ade0a3722f00c05ac586d9bc https://media.forgecdn.net/files/3113/257/titanium-1.16.4-3.2.1.jar +curios.jar 744de4d8889fbb0c5af43416e3bec16df056fe70 https://media.forgecdn.net/files/3122/651/curios-forge-1.16.4-4.0.3.0.jar +autoreglib.jar c722edbeaf70abf9179b1d1613e1622d630621c3 https://media.forgecdn.net/files/3088/870/AutoRegLib-1.6-46.jar +observerlib.jar c3154cb3f0e78ff9ef526f6de80dca802a70d31a https://media.forgecdn.net/files/3123/187/observerlib-1.16.4-1.4.4.jar +patchouli.jar 0aa68e4c4015b6a85f8b466bf658cc14695c738d https://media.forgecdn.net/files/3086/492/Patchouli-1.16.2-47.jar -- cgit v1.2.3