Created
June 10, 2025 05:43
-
-
Save sschr15/95b132d708e9d3df92424080e88b9fbf to your computer and use it in GitHub Desktop.
Using Quilt's hashed mappings, count how many unique mappings have ever existed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
from hashlib import sha1 | |
from os import path, makedirs | |
from requests import get | |
from sys import stderr | |
from tempfile import gettempdir | |
HEADERS = { | |
'User-Agent': 'sschr15 - unique hash counter', | |
} | |
def get_hash_versions() -> list[str]: | |
""" | |
Fetches the collection of Minecraft versions with hashed mappings. | |
This is equivalent to every version since 1.14.4, unless Mojang | |
or QuiltMC stop producing mappings in the future. | |
(i.e., releases, snapshots, pre-releases, experimental versions, and April Fools' versions). | |
:return: A list of (non-normalized) Minecraft versions | |
""" | |
hashed_metadata_url = 'https://meta.quiltmc.org/v3/versions/game/hashed' | |
response = get(hashed_metadata_url, headers=HEADERS) | |
response.raise_for_status() | |
return response.json() | |
def download_hashes(version: str, use_temp: bool = False) -> str: | |
""" | |
Downloads the hashes for a specific Minecraft version. | |
:param version: The Minecraft version to download hashes for. | |
:param use_temp: If True, the hashes will be downloaded to a temporary file. | |
If False, the hashes will be downloaded to the `hashes` directory. | |
:return: The location of the downloaded hashes file. | |
""" | |
hashes_url_base = 'https://maven.quiltmc.org/repository/release/org/quiltmc/hashed' | |
if use_temp: | |
hashes_dir = path.join(gettempdir(), 'hashes') | |
else: | |
hashes_dir = path.join(path.dirname(__file__), 'hashes') | |
makedirs(hashes_dir, exist_ok=True) | |
hashes_file = path.join(hashes_dir, f'{version}.tiny') | |
if not path.exists(hashes_file): | |
hashes_url = f'{hashes_url_base}/{version}/hashed-{version}.tiny' | |
response = get(hashes_url, headers=HEADERS) | |
response.raise_for_status() | |
mappings_sha1 = sha1(response.content).hexdigest() | |
checksum_response = get(f'{hashes_url}.sha1', headers=HEADERS) | |
checksum_response.raise_for_status() | |
if mappings_sha1 != checksum_response.text.strip(): | |
raise ValueError(f'Checksum mismatch for {version}: expected {mappings_sha1}, got {checksum_response.text.strip()}') | |
with open(hashes_file, 'w', encoding='utf-8') as file: | |
file.write(response.text) | |
return hashes_file | |
def get_hashes(version_file: str) -> tuple[set[str], set[str], set[str]]: | |
""" | |
Reads the hashes file and returns the version, hash, and mappings. | |
:param version_file: The path to the hashes file. | |
:return: A tuple containing three sets: one each for classes, methods, and fields. | |
""" | |
classes = set() | |
methods = set() | |
fields = set() | |
with open(version_file, 'r', encoding='utf-8') as file: | |
for line in file: | |
if line == 'tiny\t2\t0\tofficial\thashed\n': | |
# Skip the header line | |
continue | |
if line.startswith('#'): | |
continue | |
parts = line.strip().split('\t') | |
if len(parts) < 3: | |
print(f'Invalid line in file: {line.strip()}', file=stderr) | |
continue | |
if parts[0] not in 'cmf': | |
print(f'Unknown type in line: {line.strip()}', file=stderr) | |
continue | |
match parts[0]: | |
case 'c': # classes (or javadoc) | |
if not line.startswith('c'): | |
# skip javadoc | |
continue | |
_, _, full_class_name = parts | |
class_name = full_class_name.split('/')[-1] | |
classes.add(class_name) | |
case 'm': # methods | |
_, _, _, method_name = parts | |
methods.add(method_name) | |
case 'f': | |
_, _, _, field_name = parts | |
fields.add(field_name) | |
return classes, methods, fields | |
def count_hashes(version_file: str) -> tuple[int, int, int]: | |
""" | |
Counts the number of unique classes, methods, and fields in the hashes file. | |
:param version_file: The path to the hashes file. | |
:return: A tuple containing the counts of classes, methods, and fields. | |
""" | |
classes, methods, fields = get_hashes(version_file) | |
return len(classes), len(methods), len(fields) | |
def main(): | |
import argparse | |
parser = argparse.ArgumentParser(description='Count unique hashes in Minecraft version files.') | |
parser.add_argument('--version', type=str, help='Minecraft version to count hashes for. Ignored if --all-versions is set.', default='1.20.4') | |
parser.add_argument('--all-versions', action='store_true', help='Count hashes for all available Minecraft versions.') | |
parser.add_argument('--temp', action='store_true', help='Use a temporary directory for hashes.') | |
args = parser.parse_args() | |
if args.all_versions: | |
try: | |
versions = get_hash_versions() | |
all_classes = set() | |
all_methods = set() | |
all_fields = set() | |
for version in versions: | |
print(f'Processing version: {version}') | |
version_file = download_hashes(version, use_temp=args.temp) | |
classes, methods, fields = get_hashes(version_file) | |
all_classes.update(classes) | |
all_methods.update(methods) | |
all_fields.update(fields) | |
print(f'Total unique hashes across all versions:') | |
print(f'Classes: {len(all_classes)}, Methods: {len(all_methods)}, Fields: {len(all_fields)}') | |
except Exception as e: | |
print(f'Error: {e}', file=stderr) | |
else: | |
try: | |
version_file = download_hashes(args.version, use_temp=args.temp) | |
classes_count, methods_count, fields_count = count_hashes(version_file) | |
print(f'Classes: {classes_count}, Methods: {methods_count}, Fields: {fields_count}') | |
except Exception as e: | |
print(f'Error: {e}', file=stderr) | |
if __name__ == '__main__': | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment