Files

1004 lines
35 KiB
Python

"""Zip introspection helpers for category-specific version metadata.
Parsed once at upload time and stored on the Version row, so the detail page
never re-reads large archives. All functions take the on-disk path of an
adopted file (under MEDIA_ROOT) and return plain dicts/values.
"""
import io
import json
import os
import re
import struct
import zipfile
INJECTOR_NAMES = {'V': 'Vaginal', 'M': 'Mouth', 'A': 'Anal'}
PROBLEM_TAGS = {'broken', 'bugged', 'borked'}
# Blockbench-compatible model formats accepted by the explicit Model category.
MODEL_FILE_EXTENSIONS = ('.bbmodel', '.geo.json', '.jem', '.gltf', '.glb')
def _png_size(head):
"""(width, height) from a PNG IHDR header, or None when not a PNG."""
if len(head) < 24 or head[:8] != b'\x89PNG\r\n\x1a\n':
return None
return (struct.unpack('>I', head[16:20])[0], struct.unpack('>I', head[20:24])[0])
SKIN_SIZES = {(64, 32), (64, 64), (128, 128)}
def read_skins_manifest(path):
"""Scan the archive for Minecraft skin PNGs (64x32, 64x64 or 128x128).
Returns None when no skins are found; otherwise {skins: [{name, member,
width, height}]} where ``member`` is the zip member served via pack_asset.
"""
zf = _open_zip(path)
if zf is None:
return None
try:
skins = []
for name in sorted(n for n in set(zf.namelist()) if n.lower().endswith('.png')):
try:
with zf.open(name) as fh:
head = fh.read(24)
except (KeyError, OSError, zipfile.BadZipFile):
continue
size = _png_size(head)
if size and size in SKIN_SIZES:
skins.append({
'name': name.rsplit('/', 1)[-1],
'member': name,
'width': size[0],
'height': size[1],
})
if not skins:
return None
return {'skins': skins}
finally:
zf.close()
def _open_zip_any(source):
"""Open a zip from a path or raw bytes."""
if isinstance(source, (bytes, bytearray)):
return zipfile.ZipFile(io.BytesIO(source))
return _open_zip(source)
def _mc_from_filename(filename):
"""Best-effort MC version from a mod filename like 'x-1.0+mc26.2.jar'."""
m = re.search(r'mc(\d+(?:\.\d+)*)', str(filename).lower())
return m.group(1) if m else ''
def _manifest_value(raw, key):
for line in raw.splitlines():
if line.startswith(key + ':'):
return line.split(':', 1)[1].strip()
return ''
def read_mod_manifest(source):
"""Parse a single Minecraft mod jar (path or bytes) into metadata or None.
Loader detection order: fabric.mod.json → quilt.mod.json →
META-INF/neoforge.mods.toml → META-INF/mods.toml → META-INF/MANIFEST.MF.
"""
zf = _open_zip_any(source)
if zf is None:
return None
try:
names = set(zf.namelist())
if 'fabric.mod.json' in names:
data = _decode(zf.read('fabric.mod.json'))
depends = data.get('depends') or {}
deps = [
{'modId': k, 'type': 'required', 'versionRange': v}
for k, v in depends.items() if k and v
]
return {
'id': data.get('id') or '',
'name': data.get('name') or data.get('id') or '',
'version': data.get('version') or '',
'modloader': 'fabric',
'description': data.get('description') or '',
'environment': data.get('environment') or '',
'minecraft': depends.get('minecraft') or '',
'dependencies': deps,
}
if 'quilt.mod.json' in names:
data = _decode(zf.read('quilt.mod.json'))
ql = data.get('quilt_loader') or {}
meta = ql.get('metadata') or {}
deps = []
for d in ql.get('depends') or []:
deps.append({
'modId': d.get('id') or '',
'type': d.get('reason') or 'required',
'versionRange': d.get('versions') or '',
})
minecraft = next((d['versionRange'] for d in deps if d['modId'] == 'minecraft'), '')
return {
'id': ql.get('id') or '',
'name': meta.get('name') or ql.get('id') or '',
'version': ql.get('version') or '',
'modloader': 'quilt',
'description': meta.get('description') or '',
'environment': '',
'minecraft': minecraft,
'dependencies': deps,
}
for member, loader in (('META-INF/neoforge.mods.toml', 'neoforge'), ('META-INF/mods.toml', 'forge')):
if member in names:
parsed = _parse_mods_toml(zf.read(member).decode('utf-8', 'replace'), loader)
if parsed:
return parsed
if 'META-INF/MANIFEST.MF' in names:
raw = zf.read('META-INF/MANIFEST.MF').decode('utf-8', 'replace')
title = _manifest_value(raw, 'Implementation-Title')
version = _manifest_value(raw, 'Implementation-Version')
if title or version:
return {
'id': title or '',
'name': title or 'unknown',
'version': version or '',
'modloader': 'unknown',
'description': '',
'environment': '',
'minecraft': '',
'dependencies': [],
}
return None
finally:
zf.close()
def _parse_mods_toml(text, loader):
"""Parse a Forge/NeoForge mods.toml / neoforge.mods.toml."""
try:
import tomllib
data = tomllib.loads(text)
except Exception:
return None
mods = data.get('mods') or []
if not mods:
return None
m = mods[0]
mod_id = m.get('modId') or ''
deps = []
for d in (data.get('dependencies') or {}).get(mod_id) or []:
deps.append({
'modId': d.get('modId') or '',
'type': d.get('type') or 'required',
'versionRange': d.get('versionRange') or '',
})
minecraft = next((d['versionRange'] for d in deps if d['modId'] == 'minecraft'), '')
return {
'id': mod_id,
'name': m.get('displayName') or mod_id,
'version': m.get('version') or '',
'modloader': loader,
'description': m.get('description') or '',
'environment': '',
'minecraft': minecraft,
'dependencies': deps,
}
def read_mod_version_manifest(file_entries):
"""Build the Mods manifest for a mod-category version.
file_entries: list of (filename, path). Accepts standalone .jar files and
.zip archives whose root holds .jar members (subfolders are ignored).
Returns {mods: [...], no_mods: bool}.
"""
mods = []
for filename, path in file_entries:
lower = filename.lower()
if lower.endswith('.jar'):
meta = read_mod_manifest(path)
if meta:
if not meta.get('minecraft'):
meta['minecraft'] = _mc_from_filename(filename)
meta['file_name'] = filename
meta['member'] = ''
mods.append(meta)
elif lower.endswith('.zip'):
zf = _open_zip(path)
if zf is None:
continue
try:
root_jars = sorted(n for n in zf.namelist() if n.lower().endswith('.jar') and '/' not in n)
for member in root_jars:
meta = read_mod_manifest(zf.read(member))
if meta:
if not meta.get('minecraft'):
meta['minecraft'] = _mc_from_filename(member)
meta['file_name'] = member
meta['member'] = member
mods.append(meta)
finally:
zf.close()
return {'mods': mods, 'no_mods': len(mods) == 0}
def _in_plugins_dir(name):
"""True when a member lives inside a plugins/ folder (any nesting)."""
parts = name.split('/')
return 'plugins' in parts[:-1]
def read_plugins_manifest(path):
"""Scan the archive's plugins/ folder for Blockbench plugins.
A ``.js`` file under a ``plugins/`` directory that registers a Blockbench
plugin (contains ``Plugin.register(`` or ``BBPlugin.register(``) is listed.
``plugins/autoload.txt`` holds one plugin name per line; matching plugins
are marked ``autoload`` so the viewer can hotload them when a model opens.
Returns None when the pack has no plugins.
"""
zf = _open_zip(path)
if zf is None:
return None
try:
names = set(zf.namelist())
autoload = set()
for n in names:
if _in_plugins_dir(n) and n.endswith('autoload.txt'):
try:
raw = zf.read(n).decode('utf-8', 'replace')
except KeyError:
continue
for line in raw.splitlines():
line = line.strip()
if line and not line.startswith('#'):
autoload.add(line)
plugins = []
for n in sorted(names):
if not _in_plugins_dir(n) or not n.endswith('.js'):
continue
try:
content = zf.read(n)
except KeyError:
continue
if len(content) > 8 * 1024 * 1024:
continue
text = content.decode('utf-8', 'replace')
if 'Plugin.register(' not in text and 'BBPlugin.register(' not in text:
continue
base = n.rsplit('/', 1)[-1]
stem = base[:-3] if base.lower().endswith('.js') else base
plugins.append({
'name': base,
'member': n,
'size': len(content),
'autoload': base in autoload or stem in autoload,
})
if not plugins:
return None
return {
'plugins': plugins,
'has_plugins': True,
'autoload_count': sum(1 for p in plugins if p['autoload']),
}
finally:
zf.close()
def _decode(data):
"""Decode JSON text, tolerating a UTF-8 BOM (common in hand-edited packs)."""
return json.loads(data.decode('utf-8-sig', 'replace'))
def _open_zip(path):
"""Return a ZipFile for the path (None when it's not a zip)."""
try:
return zipfile.ZipFile(path)
except (zipfile.BadZipFile, OSError):
return None
DEFAULT_PACK_BLOCK = {
'pack_format': 64,
'supported_formats': [64, 81],
'min_format': 64,
'max_format': 81,
}
def inject_animationframework(path, fields, create_pack_block=None):
"""Rewrite the zip at ``path`` in place so pack.mcmeta carries the
animationframework block (id/name/author/version/description).
When the archive already has pack.mcmeta, only the animationframework key
is added/updated and every other field is preserved. When it has none, a
whole pack.mcmeta is created (pack block from ``create_pack_block``, which
defaults to the current NoN pack format). Returns True on success.
"""
tmp = f'{path}.nfnorm'
try:
with zipfile.ZipFile(path, 'r') as zin:
names = set(zin.namelist())
if 'pack.mcmeta' in names:
try:
mcmeta = _decode(zin.read('pack.mcmeta'))
except (KeyError, json.JSONDecodeError):
mcmeta = {}
if not isinstance(mcmeta.get('pack'), dict):
mcmeta['pack'] = dict(create_pack_block or DEFAULT_PACK_BLOCK)
else:
block = dict(create_pack_block or DEFAULT_PACK_BLOCK)
mcmeta = {'pack': block}
mcmeta['animationframework'] = fields
with zipfile.ZipFile(tmp, 'w', zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename == 'pack.mcmeta':
continue
zout.writestr(item, zin.read(item.filename))
zout.writestr('pack.mcmeta', json.dumps(mcmeta, indent=2))
os.replace(tmp, path)
return True
except (OSError, zipfile.BadZipFile):
try:
if os.path.exists(tmp):
os.remove(tmp)
except OSError:
pass
return False
def read_pack_mcmeta(path):
"""Extract the NoN-relevant data from a datapack's pack.mcmeta.
Returns (pack_format, description) or (None, '').
"""
zf = _open_zip(path)
if zf is None:
return None, ''
try:
try:
data = _decode(zf.read('pack.mcmeta'))
except (KeyError, json.JSONDecodeError):
return None, ''
pack = data.get('pack') or {}
try:
pack_format = int(pack.get('pack_format'))
except (TypeError, ValueError):
pack_format = None
description = pack.get('description', '') or ''
if isinstance(description, (dict, list)):
description = json.dumps(description)
return pack_format, str(description)
finally:
zf.close()
def _friendly_entity(entity):
"""minecraft:zombie -> Zombie; needsofnature:horse_liquid_collector -> Horse Liquid Collector."""
name = entity.rsplit(':', 1)[-1]
return name.replace('_', ' ').title()
def _gender(actor_tags):
for tag in actor_tags or []:
if str(tag).startswith('gender.'):
return str(tag).split('.', 1)[1]
return None
def _summarize_animdef(filename, data):
stem = filename.rsplit('/', 1)[-1]
if stem.endswith('.json'):
stem = stem[:-5]
display = data.get('display_name') or stem.replace('_', ' ').title()
participants = len(data.get('actors') or [])
actors = []
has_entity = False
entity_names = []
for actor in data.get('actors') or []:
entity_types = actor.get('entity_types') or []
entity = 'player'
if entity_types:
non_players = [e for e in entity_types if e != 'minecraft:player']
if non_players:
entity = _friendly_entity(non_players[0])
has_entity = True
entity_names.append(entity)
injector = actor.get('injector')
inj = None
inj_name = None
if injector is True:
inj = 'injector'
inj_name = 'injector'
elif isinstance(injector, str) and injector:
inj = injector
inj_name = INJECTOR_NAMES.get(injector, injector)
actors.append({
'label': actor.get('label') or '',
'entity': entity,
'gender': _gender(actor.get('actor_tags')),
'activity': actor.get('activity') or '',
'injector': inj,
'injector_name': inj_name,
'receiver': bool(actor.get('receiver')),
})
content_tags = [t for t in data.get('content_tags') or []]
animation_tags = data.get('animation_tags') or []
problem_tags = sorted({
t for t in list(content_tags) + list(animation_tags)
if str(t).lower() in PROBLEM_TAGS
})
if participants == 1:
type_ = 'Solo'
elif has_entity:
type_ = 'Entity x Player'
elif participants == 2:
type_ = 'Pair'
elif participants == 3:
type_ = 'Threesome'
else:
type_ = f'Group of {participants}'
stages = []
for stage in data.get('stages') or []:
stages.append({
'stage': stage.get('stage'),
'loop': bool(stage.get('loop')),
'cycle_seconds': stage.get('cycle_seconds'),
'speed': stage.get('speed'),
'climax': bool(stage.get('non_peak') or stage.get('manual_peak')),
'use_stage': stage.get('use_stage'),
'joinable': bool(stage.get('allow_join', True)),
'escapable': bool(stage.get('escapable', True)),
})
return {
'name': display,
'content_tags': content_tags,
'animation_tags': list(animation_tags),
'type': type_,
'entity_names': entity_names,
'participants': participants,
'actors': actors,
'stages': stages,
'weight': data.get('weight'),
'block_requirements': bool(data.get('block_requirements')),
'water': bool(data.get('water')),
'problem_tags': problem_tags,
}
def read_pack_meta(path):
"""Read the animationframework block from pack.mcmeta regardless of whether
the pack has animdefs (models/textures packs). None when absent."""
zf = _open_zip(path)
if zf is None:
return None
try:
try:
mcmeta = _decode(zf.read('pack.mcmeta'))
except (KeyError, json.JSONDecodeError):
return None
af = mcmeta.get('animationframework')
if not af:
return None
authors = []
if af.get('author'):
authors = [a.strip() for a in str(af['author']).split(',') if a.strip()]
return {
'animation_id': af.get('id') or None,
'name': str(af.get('name') or ''),
'version': str(af.get('version') or ''),
'authors': authors,
'description': str(af.get('description') or ''),
}
finally:
zf.close()
def _resource_to_member(resource):
"""needsofnature:textures/entity/zombie/zombie.png → assets/needsofnature/..."""
if ':' in resource:
ns, _, path = resource.partition(':')
return f'assets/{ns}/{path}'
if resource.startswith('assets/'):
return resource
return f'assets/{resource}'
def read_models_manifest(path, category='non_pack'):
"""Build the Models/Textures manifest.
For the explicit ``model`` category this scans the archive for any
Blockbench-compatible model file (``MODEL_FILE_EXTENSIONS``) anywhere in
the zip. For other categories it keeps the NoN GeckoLib geo-model
detection (``geckolib/models/*.geo.json``). Returns None when no models
are found. Each model records its filename, zip member, format, bone/cube
counts, and the afw_bone_textures map (bone → texture zip member).
"""
zf = _open_zip(path)
if zf is None:
return None
try:
names = set(zf.namelist())
if category == 'model':
model_names = sorted(
n for n in names
if not n.endswith('/') and n.lower().endswith(MODEL_FILE_EXTENSIONS)
)
else:
model_names = sorted(
n for n in names if '/geckolib/models/' in n and n.endswith('.geo.json')
)
if not model_names:
return None
models = []
for name in model_names:
ext = _model_ext(name)
data = None
try:
data = _decode(zf.read(name))
except (KeyError, json.JSONDecodeError, UnicodeDecodeError):
data = None
bone_textures = {}
bone_count = 0
cube_count = 0
identifier = ''
if data and isinstance(data, dict):
for bone, tex in (data.get('afw_bone_textures') or {}).items():
member = _resource_to_member(str(tex))
if member in names:
bone_textures[str(bone)] = member
if ext == '.geo.json':
for geom in data.get('minecraft:geometry') or []:
identifier = identifier or (geom.get('description') or {}).get('identifier', '')
for bone in geom.get('bones') or []:
bone_count += 1
cube_count += len(bone.get('cubes') or [])
elif ext == '.jem':
bone_count = len(data.get('models') or [])
entity = _entity_name(name)
default_texture = ''
if not bone_textures and entity:
for n in names:
if n.endswith(f'/textures/entity/{entity}/{entity}.png') or n.endswith(f'/textures/entity/{entity}.png'):
default_texture = n
break
models.append({
'name': name.rsplit('/', 1)[-1],
'member': name,
'ext': ext,
'mode': 'pack',
'identifier': identifier,
'entity': entity,
'default_texture': default_texture,
'bones': bone_count,
'cubes': cube_count,
'has_embedded_texture': ext in ('.bbmodel', '.gltf', '.glb'),
'bone_textures': bone_textures,
'textures': sorted(set(bone_textures.values())),
})
textures = sorted(n for n in names if n.startswith('assets/') and n.endswith('.png'))
return {'models': models, 'textures': textures}
finally:
zf.close()
def _model_ext(name):
"""The matching MODEL_FILE_EXTENSIONS suffix for a member, or ''."""
lower = name.lower()
for ext in MODEL_FILE_EXTENSIONS:
if lower.endswith(ext):
return ext
return ''
def _entity_name(name):
"""Derive the entity name from a model filename (strip ext + gender suffix)."""
base = name.rsplit('/', 1)[-1]
for ext in MODEL_FILE_EXTENSIONS:
if base.lower().endswith(ext):
base = base[: -len(ext)]
break
for suffix in ('.mf', '.fm', '.m', '.f', '.g'):
if base.endswith(suffix):
base = base[:-len(suffix)]
break
return base
def _item_name_from_member(member):
parts = member.split('/')
try:
idx = parts.index('items')
ns = parts[idx - 1]
name = parts[idx + 1]
if name.endswith('.json'):
name = name[:-5]
return f'{ns}:{name}'
except (ValueError, IndexError):
return member
def _ingredient_names(recipe):
if isinstance(recipe.get('key'), dict):
raw = list(recipe['key'].values())
elif 'ingredients' in recipe:
raw = recipe['ingredients']
else:
raw = []
names = set()
for item in raw:
if isinstance(item, dict):
item = item.get('id')
if item:
names.add(str(item))
return sorted(names)
def _result_name(recipe):
res = recipe.get('result')
if isinstance(res, dict):
rid = res.get('id') or res.get('item') or ''
count = res.get('count')
if count and int(count) != 1:
return f'{rid} x{count}'
return str(rid)
return str(res) if res else ''
def read_logical_manifest(path):
"""Build the Logical manifest outlining what a datapack adds (items,
recipes, custom crafting, advancements, functions, NoN extras)."""
zf = _open_zip(path)
if zf is None:
return None
try:
names = set(zf.namelist())
def _dir_members(subdir, suffix):
"""data/<ns>/<subdir>/<file> or assets/<ns>/<subdir>/<file> (4 parts)."""
out = []
for n in names:
parts = n.split('/')
if len(parts) == 4 and parts[2] == subdir and n.endswith(suffix):
out.append(n)
return sorted(out)
items = _dir_members('items', '.json')
recipes = _dir_members('recipe', '.json')
functions = sorted(
n for n in names
if n.startswith('data/') and '/function/' in n and n.endswith('.mcfunction')
)
liquid_gains = [n for n in names if '/non_liquid_gains/' in n]
entity_profiles = [n for n in names if '/non_entity_profiles/' in n]
trinkets = [n for n in names if '/trinkets/' in n]
if not any([items, recipes, functions, liquid_gains, entity_profiles, trinkets]):
return None
advancement_members = sorted(
n for n in names
if n.startswith('data/') and '/advancement/' in n and n.endswith('.json')
)
custom_recipes = [n for n in advancement_members if '/advancement/recipe/' in n]
advancements_plain = [n for n in advancement_members if '/advancement/recipe/' not in n]
item_list = []
for member in items:
name = _item_name_from_member(member)
ns, _, base = name.partition(':')
tex_member = f'assets/{ns}/textures/item/{base}.png'
item_list.append({
'name': name,
'texture': tex_member if tex_member in names else None,
})
recipe_list = []
for member in recipes:
base = member.rsplit('/', 1)[-1]
disabled = '.disabled' in base
clean = base.replace('.json', '').replace('.disabled', '')
try:
data = _decode(zf.read(member))
except (KeyError, json.JSONDecodeError):
data = {}
recipe_list.append({
'name': clean,
'type': (data.get('type') or '').rsplit(':', 1)[-1],
'ingredients': _ingredient_names(data),
'result': _result_name(data),
'disabled': disabled,
})
custom_recipes_out = []
advancements_out = []
for member in advancement_members:
try:
data = _decode(zf.read(member))
except (KeyError, json.JSONDecodeError):
continue
reward = (data.get('rewards') or {}).get('function')
name = member.rsplit('/', 1)[-1][:-5]
if member in custom_recipes:
custom_recipes_out.append({'name': name, 'reward': reward})
else:
advancements_out.append({'name': name, 'reward': reward})
function_list = sorted(n for n in functions if '/recipe/' not in n)
return {
'items': item_list,
'recipes': recipe_list,
'custom_recipes': custom_recipes_out,
'advancements': advancements_out,
'functions': function_list,
'custom_functions': len(functions) - len(function_list),
'liquid_gains': liquid_gains,
'entity_profiles': entity_profiles,
'trinkets': trinkets,
}
finally:
zf.close()
def read_animation_manifest(path):
"""Build the animation manifest for a NoN animation pack.
Detected by the presence of data/*/afw_animdefs/*.json. Returns None when
the archive isn't an animation pack. Reads the animationframework block
(id/name/version/authors/description) plus a summary of every animdef.
"""
zf = _open_zip(path)
if zf is None:
return None
try:
names = set(zf.namelist())
animdef_names = sorted(
n for n in names if '/afw_animdefs/' in n and n.endswith('.json')
)
if not animdef_names:
return None
animation_id = None
pack_name = ''
pack_version = ''
description = ''
authors = []
try:
mcmeta = _decode(zf.read('pack.mcmeta'))
af = mcmeta.get('animationframework') or {}
animation_id = af.get('id') or None
pack_name = str(af.get('name') or '')
pack_version = str(af.get('version') or '')
description = str(af.get('description') or '')
author = af.get('author')
if author:
authors = [a.strip() for a in str(author).split(',') if a.strip()]
except (KeyError, json.JSONDecodeError):
pass
animations = []
distinct_content_tags = set()
for name in animdef_names:
try:
data = _decode(zf.read(name))
except (KeyError, json.JSONDecodeError):
continue
summary = _summarize_animdef(name, data)
distinct_content_tags.update(summary['content_tags'])
animations.append(summary)
return {
'animation_id': animation_id,
'name': pack_name,
'version': pack_version,
'authors': authors,
'description': description,
'animations': animations,
'content_tags': sorted(distinct_content_tags),
}
finally:
zf.close()
def _normalize_modloader(loader_id):
"""'fabricloader-0.16.9' → 'fabric', 'neoforge-21.1.66' → 'neoforge'."""
low = str(loader_id or '').lower().strip()
if not low:
return ''
for prefix, name in (
('fabricloader', 'fabric'),
('neoforge', 'neoforge'),
('quilt', 'quilt'),
('liteloader', 'liteloader'),
('forge', 'forge'),
('fml', 'forge'),
('rift', 'rift'),
('vanilla', 'vanilla'),
):
if low.startswith(prefix):
return name
return low.split('-')[0]
def _mod_env(env):
"""Normalize an mrpack env object ({client, server}) to a label."""
client = str((env or {}).get('client') or '')
server = str((env or {}).get('server') or '')
if 'optional' in (client, server):
return 'optional'
if client == 'required' and server == 'unsupported':
return 'client'
if server == 'required' and client == 'unsupported':
return 'server'
return ''
def _modpack_totals(files):
total = len(files)
if any('required' in f for f in files):
required = sum(1 for f in files if f.get('required'))
optional = total - required
elif any('env' in f for f in files):
optional = sum(1 for f in files if f.get('env') == 'optional')
required = total - optional
else:
required = optional = None
return {
'total': total,
'required': required,
'optional': optional,
'client': sum(1 for f in files if f.get('env') == 'client'),
'server': sum(1 for f in files if f.get('env') == 'server'),
}
def _loader_from_filename(filename):
"""Best-effort loader from a mod filename ('x_FABRIC_1.0.jar' → 'fabric')."""
low = str(filename).lower()
for token in ('fabricloader', 'fabric', 'neoforge', 'forge', 'quilt', 'rift', 'liteloader'):
if token in low:
return _normalize_modloader(token)
return ''
def _modpack_loaders(zf, members, limit=12):
"""Unique modloaders inferred for a modpack.
Folder/instance archives embed their jars, so we parse the first few for
their metadata. Modrinth .mrpack files only carry config overrides, so we
fall back to the loader encoded in the mod filenames. A sample is enough
because a modpack almost always targets a single loader.
"""
loaders = []
for member in members[:limit]:
loader = ''
if str(member).lower().endswith('.jar'):
try:
meta = read_mod_manifest(zf.read(member))
except Exception:
meta = None
loader = (meta or {}).get('modloader') or ''
if not loader:
loader = _loader_from_filename(member)
if loader and loader not in loaders:
loaders.append(loader)
return loaders
def parse_mods_manifest(path, filename=''):
"""Inspect a modpack archive and build a manifest for the Mods tab.
- Modrinth .mrpack: modrinth.index.json → per-file paths + env
- Curseforge modpack: manifest.json → MC version, loaders, file list
- Full instance / folder: mods under mods/, minecraft/mods/, .minecraft/mods/
Returns a dict: {source, minecraft, modloaders, files, totals}.
"""
zf = _open_zip(path)
if zf is None:
return _modpack_result('unknown', '', [], [])
try:
names = set(zf.namelist())
if filename.lower().endswith('.mrpack') or 'modrinth.index.json' in names:
try:
index = json.loads(zf.read('modrinth.index.json').decode('utf-8', 'replace'))
except (KeyError, json.JSONDecodeError):
index = {}
files = []
for entry in index.get('files') or []:
path_name = str(entry.get('path') or '').replace('\\', '/')
if not path_name:
continue
files.append({
'name': path_name.rsplit('/', 1)[-1],
'file_name': path_name,
'env': _mod_env(entry.get('env')),
})
modloaders = _modpack_loaders(zf, [f['file_name'] for f in files])
return _modpack_result('modrinth', '', modloaders, files)
if 'manifest.json' in names:
try:
manifest = json.loads(zf.read('manifest.json').decode('utf-8', 'replace'))
except (KeyError, json.JSONDecodeError):
manifest = {}
mc = manifest.get('minecraft') or {}
minecraft = str(mc.get('version') or '')
modloaders = []
for loader in mc.get('modLoaders') or []:
norm = _normalize_modloader(loader.get('id'))
if norm and norm not in modloaders:
modloaders.append(norm)
files = []
for entry in manifest.get('files') or []:
file_name = str(entry.get('fileName') or '')
slug = str(entry.get('projectSlug') or '')
project_id = entry.get('projectID')
pid = str(project_id) if project_id else ''
name = slug or file_name or (f'Mod #{pid}' if pid else 'unknown')
files.append({
'name': name,
'file_name': file_name,
'required': bool(entry.get('required', True)),
'slug': slug,
'project_id': pid,
'url': f'https://www.curseforge.com/projects/{pid}' if pid else '',
})
if files or minecraft or modloaders:
return _modpack_result('curseforge', minecraft, modloaders, files)
# Full instance / folder: mod jars under a mods/ dir at any nesting
# (minecraft/mods, .minecraft/mods, <instance>/minecraft/mods, mods).
mods = []
seen = set()
is_instance = False
for name in sorted(names):
if name.endswith('/') or not name.lower().endswith('.jar'):
continue
parts = name.split('/')
if 'mods' not in parts or parts.index('mods') >= len(parts) - 1:
continue
# Full instance = .minecraft/mods or a nested <instance>/minecraft/mods;
# a root-level minecraft/mods folder is the classic folder layout.
if '.minecraft' in parts or (('minecraft' in parts) and parts.index('minecraft') > 0):
is_instance = True
fname = parts[-1]
if fname and fname not in seen:
seen.add(fname)
mods.append({'name': fname, 'file_name': name})
if mods:
modloaders = _modpack_loaders(zf, [m['file_name'] for m in mods])
return _modpack_result('instance' if is_instance else 'folder', '', modloaders, mods)
return _modpack_result('unknown', '', [], [])
finally:
zf.close()
def _modpack_result(source, minecraft, modloaders, files):
return {
'source': source,
'minecraft': minecraft,
'modloaders': modloaders,
'files': files,
'totals': _modpack_totals(files),
}