Files
sanctuary-beta/scripts/export_textures.py
T
koka 126a70f217
Build Sanctuary / build (push) Canceled after 0s
Deliver beta.250 insects, sounds and review gallery
Add the 47 supplied Wild World insects with calendar-aware habitats,
animated sprites, a 16x16 net, silent captures and natural positional audio.
Ground hoppers now travel in arcs and flee through rapid consecutive hops.
Include the isolated noon/clear review map and full texture export tool.

Consolidate the previously validated beta.242-244 zipline/grappling and
player-head/chunk-synchronized map changes retained in this checkout.

Validation: check build assemblePack assembleTestPack succeeded;
all 16 required insect GameTests and the native Vulkan client passed.
Both beta.250 MRpacks have verified versions and embedded JARs.
Runtime worlds, generated JARs and build artifacts remain ignored.
2026-10-08 17:46:53 +02:00

213 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Export editable static textures from the exact Sanctuary client launcher."""
import argparse
import csv
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shutil
import struct
import tempfile
import zipfile
ROOT = Path(__file__).resolve().parents[1]
IMAGE_SUFFIXES = ('.png', '.jpg', '.jpeg', '.tga', '.webp')
def image(path):
return path.lower().endswith(IMAGE_SUFFIXES)
def texture(path):
return image(path) or (path.endswith('.mcmeta') and image(path[:-7]))
def safe(path):
parts = PurePosixPath(path).parts
if not parts or PurePosixPath(path).is_absolute() or '..' in parts:
raise ValueError('Invalid asset path: ' + path)
return path
def sha(data):
return hashlib.sha256(data).hexdigest()
def export(launcher, output):
values = [json.loads(line) for line in launcher.read_text().splitlines()]
classpath = list(dict.fromkeys(values[values.index('-classpath') + 1].split(os.pathsep)))
version = re.search(r'^mod_version=(.+)$', (ROOT / 'gradle.properties').read_text(), re.M)[1]
output.parent.mkdir(parents=True, exist_ok=True)
if output.exists():
raise SystemExit('Preserving existing texture folder; choose a new output path: ' + str(output))
entries, effective, sources = [], {}, []
game = None
with tempfile.TemporaryDirectory(prefix='sanctuary-textures-', dir=output.parent) as temporary:
staging = Path(temporary)
work = staging / 'a-modifier'
def collect(source, origin, files, priority):
nonlocal entries
identifier = re.sub(r'[^a-zA-Z0-9_.-]+', '-', source)
count = 0
for path, raw in files:
if not path.startswith('assets/') or not texture(path):
continue
safe(path)
copied = staging / 'sources' / identifier / path
copied.parent.mkdir(parents=True, exist_ok=True)
copied.write_bytes(raw)
width = height = None
if raw.startswith(b'\x89PNG\r\n\x1a\n'):
width, height = struct.unpack('>II', raw[16:24])
row = dict(path=path, source=identifier, origin=origin,
width=width, height=height, bytes=len(raw), sha256=sha(raw),
source_copy=str(copied.relative_to(staging)))
entries.append(row)
if path not in effective or priority >= effective[path][0]:
effective[path] = (priority, row, raw)
count += image(path)
if count:
sources.append(dict(id=identifier, origin=origin, images=count))
for name in classpath:
path = Path(name)
if path.is_dir():
assets = path / 'assets'
if assets.is_dir():
collect('classpath-' + '-'.join(path.parts[-5:]), str(path),
((str(p.relative_to(path)), p.read_bytes()) for p in sorted(assets.rglob('*')) if p.is_file() and texture(p.name)), 20)
continue
if path.suffix != '.jar':
continue
with zipfile.ZipFile(path) as archive:
names = archive.namelist()
native = 'version.json' in names and 'assets/minecraft/textures/block/stone.png' in names
if native:
game = json.loads(archive.read('version.json'))
source = 'minecraft-' + game['id']
priority = 0
elif 'fabric.mod.json' in names:
mod = json.loads(archive.read('fabric.mod.json'))
source = 'mod-' + mod['id'] + '-' + mod['version']
priority = 20
else:
source = path.stem
priority = 20
collect(source, str(path), ((n, archive.read(n)) for n in sorted(names) if n.startswith('assets/') and texture(n)), priority)
builtin = 'resourcepacks/textures/'
if any(n.startswith(builtin + 'assets/') for n in names):
collect('pack-sanctuary-textures', str(path) + '!/' + builtin,
((n[len(builtin):], archive.read(n)) for n in sorted(names) if n.startswith(builtin + 'assets/') and texture(n)), 30)
if any(s['id'] == re.sub(r'[^a-zA-Z0-9_.-]+', '-', source) for s in sources):
for n in names:
if '/' not in n and n.upper().startswith(('LICENSE', 'NOTICE', 'THIRD_PARTY', 'PROVENANCE')):
target = staging / 'credits' / re.sub(r'[^a-zA-Z0-9_.-]+', '-', source) / n
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(archive.read(n))
if game is None:
raise SystemExit('The launcher must include its exact Minecraft client JAR.')
config_arg = next(v for v in values if v.startswith('-Dfabric.dli.config='))
config = Path(config_arg.split('=', 1)[1]).read_text().splitlines()
asset_directory = Path(config[config.index('\t--assetsDir') + 1].strip())
asset_index = config[config.index('\t--assetIndex') + 1].strip()
index = json.loads((asset_directory / 'indexes' / (asset_index + '.json')).read_text())['objects']
assets = []
icons = 0
for name, info in sorted(index.items()):
if not texture(name):
continue
original = asset_directory / 'objects' / info['hash'][:2] / info['hash']
raw = original.read_bytes()
if hashlib.sha1(raw).hexdigest() != info['hash']:
raise ValueError('Asset-index hash mismatch: ' + name)
if name.startswith('icons/'):
target = staging / 'icones-du-client' / safe(name)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(raw)
icons += 1
else:
assets.append(('assets/' + name, raw))
collect('minecraft-asset-index-' + asset_index, str(asset_directory / 'indexes' / (asset_index + '.json')), assets, 10)
selected = []
for path, (_, row, raw) in sorted(effective.items()):
target = work / path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(raw)
row['selected'] = True
if image(path):
selected.append(row)
for row in entries:
row.setdefault('selected', False)
pack_format = [game['pack_version']['resource_major'], game['pack_version']['resource_minor']]
(work / 'pack.mcmeta').write_text(json.dumps({'pack': {'description': 'Sanctuary — textures à remplacer / editable textures — ' + version, 'min_format': pack_format, 'max_format': pack_format}}, ensure_ascii=False, indent=2) + '\n')
report = dict(version=version, minecraft=game['id'], resource_format=pack_format,
effective_images=len(selected), source_images=sum(s['images'] for s in sources),
metadata_files=sum(path.endswith('.mcmeta') for path in effective),
client_icons=icons, sources=sources, entries=entries)
(staging / 'inventaire.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n')
with (staging / 'inventaire.csv').open('w', encoding='utf-8-sig', newline='') as stream:
columns = ['path', 'source', 'width', 'height', 'bytes', 'sha256', 'source_copy', 'origin']
writer = csv.DictWriter(stream, fieldnames=columns, extrasaction='ignore')
writer.writeheader()
writer.writerows(selected)
(staging / 'README.md').write_text(f'''# Textures Sanctuary {version}
{len(selected)} images statiques du jeu, {report['metadata_files']} fichiers de métadonnées
et {icons} icônes du client. Minecraft {game['id']}, format de ressources
{pack_format[0]}.{pack_format[1]}. Export local depuis le client exact et le pack
« Textures Sanctuary » actif. Aucun fichier du jeu installé n’a été modifié.
- `a-modifier/assets/` : textures à dessiner, classées par namespace puis par type.
Ce sont les versions retenues après les remplacements du pack Sanctuary.
- `sources/` : chaque origine séparément, y compris les versions remplacées.
- `inventaire.csv` : chemins, dimensions, origine et empreintes des images visibles.
- `inventaire.json` : inventaire complet, variantes et métadonnées comprises.
- `credits/` : notices fournies dans les distributions des mods.
- `icones-du-client/` : icônes de fenêtre et du lanceur, hors resource pack.
Les 47 insectes sont dans `a-modifier/assets/sanctuary/textures/insect/`.
Le filet est dans `a-modifier/assets/sanctuary/textures/item/insect_net.png`.
Les blocs, objets, entités et interfaces Sanctuary sont dans les autres
sous-dossiers de `assets/sanctuary/`. Les textures natives Minecraft sont
sous `assets/minecraft/`, celles de JEI sous `assets/jei/`.
Garder les noms, chemins, transparence et dimensions d’origine. Conserver les
fichiers `.mcmeta` associés aux sprites animés et aux interfaces découpées.
Pour tester les dessins, copier `a-modifier` dans les packs de ressources
*d’une instance choisie*, l’activer au-dessus de Textures Sanctuary et recharger
avec F3 + T. Le dossier est prêt à être chargé en tant que resource pack.
Les modèles et règles de jeu restent fournis par les mods.
Les sources sont mixtes : textures Minecraft/Mojang, textures d’insectes
Animal Crossing/Nintendo et ressources Sanctuary d’origines diverses.
Les notices et chemins de l’inventaire servent de référence pour le remplacement ;
ils ne transfèrent pas la propriété des images. Les skins de joueurs,
cartes et textures générées à l’exécution ne sont pas des fichiers statiques
et ne sont pas inclus. Les archives historiques non chargées ne sont pas utilisées.
English: edit images in `a-modifier/assets/` without renaming their paths.
Keep dimensions, transparency and matching `.mcmeta` files. `sources/` retains
original variants, and CSV/JSON inventories record exact source paths and hashes.
The editable folder is a resource pack for Minecraft {game['id']}; enable it above
Sanctuary textures in your chosen test instance, then reload with F3 + T.
''', encoding='utf-8')
shutil.copy2(ROOT / 'THIRD_PARTY_NOTICES.md', staging / 'credits' / 'Sanctuary-THIRD_PARTY_NOTICES.md')
# Validate every copied source and selected image before exposing the folder.
for row in entries:
assert sha((staging / row['source_copy']).read_bytes()) == row['sha256']
for row in selected:
assert sha((work / row['path']).read_bytes()) == row['sha256']
staging.rename(output)
print(json.dumps(dict(output=str(output), images=len(selected), source_images=report['source_images'], metadata=report['metadata_files'], icons=icons), ensure_ascii=False))
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--launcher', type=Path, default=ROOT / 'build/duo/client.args')
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
export(args.launcher.resolve(), args.output.expanduser().resolve())