969 lines
34 KiB
Python
969 lines
34 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 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')),
|
|
})
|
|
return _modpack_result('modrinth', '', [], 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:
|
|
return _modpack_result('instance' if is_instance else 'folder', '', [], 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),
|
|
}
|