Files
shacraft-core/scripts/catalog_generate.py

97 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""Reproduce the Java 26.2 factual catalog without starting a Minecraft server.
Uses a pinned official Mojang JAR and public runtime APIs. JAR/runtime/data reports
stay in the ignored artifacts cache; no Minecraft textures, sounds, models, or
source code are copied. Requires Python 3 and an existing Java 25+ JDK.
"""
from pathlib import Path
import argparse, datetime, gzip, hashlib, json, os, shutil, subprocess, urllib.request, zipfile
ROOT = Path(__file__).resolve().parents[1]
JAR_URL = 'https://piston-data.mojang.com/v1/objects/823e2250d24b3ddac457a60c92a6a941943fcd6a/server.jar'
JAR_SHA1 = '823e2250d24b3ddac457a60c92a6a941943fcd6a'
JAR_SIZE = 60894273
def canonical(name, props):
return name + ('[' + ','.join(f'{k}={v}' for k,v in sorted(props.items())) + ']' if props else '')
def digest(path, algorithm='sha256'):
h = hashlib.new(algorithm)
with open(path,'rb') as f:
for part in iter(lambda:f.read(1024*1024), b''): h.update(part)
return h.hexdigest()
def write_gzip(path, obj):
raw = json.dumps(obj,sort_keys=True,separators=(',',':'),ensure_ascii=True).encode()
path.write_bytes(gzip.compress(raw,mtime=0))
def generate(cache, java, reuse):
cache.mkdir(parents=True,exist_ok=True)
jar = cache/'server.jar'
if not jar.exists():
part=cache/'server.jar.part'
urllib.request.urlretrieve(JAR_URL, part)
if part.stat().st_size!=JAR_SIZE or digest(part,'sha1')!=JAR_SHA1:
raise ValueError('Downloaded artifact does not match pinned Mojang source')
part.replace(jar)
if jar.stat().st_size!=JAR_SIZE or digest(jar,'sha1')!=JAR_SHA1:
raise ValueError('Local artifact does not match pinned Mojang source')
java = Path(java).resolve()
java_version=subprocess.run([str(java),'-version'],capture_output=True,text=True,check=True).stderr.strip()
with zipfile.ZipFile(jar) as z: version=json.loads(z.read('version.json'))
if version['id']!='26.2' or version['world_version']!=4903: raise ValueError('Unexpected version')
reports=cache/'generated/reports'
if not reuse or not (reports/'blocks.json').exists():
with open(cache/'reports.log','w') as log:
subprocess.run([str(java),'-Xmx1G','-DbundlerMainClass=net.minecraft.data.Main','-jar',str(jar),'--reports','--output',str(cache/'generated')],cwd=cache,stdout=log,stderr=subprocess.STDOUT,check=True)
cp=os.pathsep.join(str(x) for x in [cache/'versions/26.2/server-26.2.jar',*sorted((cache/'libraries').rglob('*.jar'))])
if not reuse or not (cache/'measurements.json').exists():
subprocess.run([str(java.with_name('javac')),'-cp',cp,'-d',str(cache),str(ROOT/'scripts/catalog_extract.java')],check=True)
with open(cache/'measurements.log','w') as log:
subprocess.run([str(java),'-Xmx1G','-cp',str(cache)+os.pathsep+cp,'catalog_extract',str(cache/'measurements.json')],cwd=cache,stdout=log,stderr=subprocess.STDOUT,check=True)
report=json.loads((reports/'blocks.json').read_text())
registries=json.loads((reports/'registries.json').read_text())
measured=json.loads((cache/'measurements.json').read_text())
if measured['errors']: raise ValueError('Collision measurement errors; refusing incomplete catalog')
block_defs=[]; states=[]; shapes=[]; shape_ids={}; report_identities={}
for name,b in sorted(report.items()):
default=next(s for s in b['states'] if s.get('default'))
index=len(block_defs)
block_defs.append({'name':name,'default_state':canonical(name,default.get('properties',{})),
'properties':b.get('properties',{}),'family':b['definition']['type'].removeprefix('minecraft:')})
report_identities[name]={'properties':b.get('properties',{}),'states':b['states']}
for s in b['states']:
m=measured['states'][str(s['id'])]
boxes=m['collision']; shape_key=json.dumps(boxes,separators=(',',':'))
if shape_key not in shape_ids:
shape_ids[shape_key]=len(shapes);shapes.append(boxes)
states.append({'state':canonical(name,s.get('properties',{})),'minecraft_id':s['id'],'block':index,
'shape':shape_ids[shape_key],'context':m['sample_context_dependent'],'light':m['light'],'hardness':m['hardness']})
states.sort(key=lambda x:x['minecraft_id'])
entity_ids=sorted(registries['minecraft:entity_type']['entries'])
if entity_ids!=sorted(measured['entities']):raise ValueError('Entity measurements do not match official report')
if set(s['minecraft_id'] for s in states)!=set(map(int,measured['states'])):raise ValueError('State measurements do not match official report')
compact={'version':'26.2','data_version':4903,'blocks':block_defs,'states':states,'shapes':shapes,'entities':measured['entities']}
out=ROOT/'crates/shacraft-content/data';out.mkdir(parents=True,exist_ok=True)
write_gzip(out/'catalog.json.gz',compact)
write_gzip(out/'report-identities.json.gz',{'blocks':report_identities,'entities':entity_ids})
provenance={'source':{'url':JAR_URL,'size':JAR_SIZE,'sha1':JAR_SHA1,'sha256':digest(jar)},
'version':version,'java':java_version,'extracted_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),
'command':'python3 scripts/catalog_generate.py --java /path/to/java25/bin/java',
'data_command':'java -Xmx1G -DbundlerMainClass=net.minecraft.data.Main -jar server.jar --reports --output generated',
'collision_context':'All states at BlockPos.ZERO, with air neighbors and CollisionContext.empty(); compare solid neighbors and positionContext(+10/-10). Entity-dependent/block-entity contexts remain approximate.',
'counts':{'blocks':len(block_defs),'states':len(states),'entities':len(entity_ids),'distinct_collision_shapes':len(shapes),'collision_errors':measured['errors'],'context_sample_differences':measured['context_dependent_samples']},
'inputs':{p.name:digest(p) for p in [reports/'blocks.json',reports/'registries.json',cache/'measurements.json',ROOT/'scripts/catalog_extract.java']},
'outputs':{p.name:digest(p) for p in [out/'catalog.json.gz',out/'report-identities.json.gz']},
'license_note':'Factual interoperability data derived through public APIs. No Minecraft visual/audio assets, server binaries or decompiled code are redistributed. Authored runtime and render templates: MIT OR Apache-2.0.'}
(out/'provenance.json').write_text(json.dumps(provenance,indent=2)+'\n')
print(json.dumps(provenance['counts'],indent=2))
if __name__=='__main__':
ap=argparse.ArgumentParser(description=__doc__)
ap.add_argument('--cache',type=Path,default=ROOT/'artifacts/catalog-cache')
ap.add_argument('--java',default=shutil.which('java'))
ap.add_argument('--reuse-reports',action='store_true',help='Skip Java tools only when verified local reports/measurements already exist')
args=ap.parse_args();generate(args.cache.resolve(),args.java,args.reuse_reports)