aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xmodpackman.py85
-rw-r--r--pack.ini7
-rwxr-xr-xutil.py293
-rw-r--r--version.txt82
4 files changed, 461 insertions, 6 deletions
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: <jarname> <hex digested sha1> <direct download url>\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: <jarname> <hex digested sha1> <direct download url>
-#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