docs: client-perf analysis, bench harness and work journal

Everything the next agent needs to run the Phase 2 optimisation loop on a
machine that can launch the client:

- analysis/client-perf/: LEVERS (ranked levers + corrections), BENCH-MATRIX
  (world, scenes, config IDs, blocks B0-B14 with decision rules),
  PLAYER-TUNING, SPAWN-TUNING, research briefs, the jar scanner and its
  output, observed default configs, and proposed-configs/ (build.py
  generates tiers and bench variants and fails on unknown keys;
  apply_variant.py applies a variant and merges options.txt).
- analysis/client-perf/bench/onion-bench/: bench-world datapack (scene
  teleports, Create grid, mob pen, sound storm, 120 s teleport tour).
- analysis/audit/: the static recipe/dependency audit, including gen_fixes.py,
  which generates the shipped Paxi datapack and was never tracked.
- phase0/: per-machine measurement harness (Steam Deck, 7700K/1060,
  6700K/1080 Ti).
- .pi/journal.md: running work log; read it first.

All of it is packwizignored, so nothing here ships to players.
This commit is contained in:
logan committed 2026-10-14 18:15:32 -04:00
1 parent d80f85b3f6
commit 611a1a5952
102 files changed
+25853

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
import pickle,json,collections,re
A=pickle.load(open("active.pkl","rb"));act=A["active"];tc=A["tagcache"]
prod=collections.defaultdict(list); cons=collections.defaultdict(set)
tagsof=collections.defaultdict(set)
for t,s in tc.items():
for i in s: tagsof[i].add(t)
for rid,(jar,j) in act.items():
r=json.dumps(j.get("result",""))+json.dumps(j.get("results",""))+json.dumps(j.get("output",""))
for m in set(re.findall(r'"(?:id|item)": "([^"]+)"',r)): prod[m].append(rid)
body=json.dumps({k:v for k,v in j.items() if k not in("result","results","output")})
for m in re.findall(r'"item": "([^"]+)"|"id": "([^"]+)"',body):
cons[m[0] or m[1]].add(rid)
for m in re.findall(r'"tag": "([^"]+)"',body): cons["#"+m].add(rid)
def info(i):
c=set(cons[i])
for t in tagsof[i]: c|=cons["#"+t]
return len(prod[i]),len(c),sorted(prod[i])[:6]
for i in """extradelight:bread_slice moredelight:bread_slice extradelight:sliced_potato moredelight:diced_potatoes rusticdelight:potato_slices
dungeonsdelight:fiery_knife twilightdelight:fiery_knife dungeonsdelight:ironwood_knife twilightdelight:ironwood_knife dungeonsdelight:knightmetal_knife twilightdelight:knightmetal_knife dungeonsdelight:steeleaf_knife twilightdelight:steeleaf_knife
frightsdelight:poisonous_potato_crate dungeonsdelight:poisonous_potato_crate frightsdelight:rotten_tomato_crate dungeonsdelight:rotten_tomato_crate
handcrafted:wood_plate twilightforest:oak_drying_rack comforts:sleeping_bag_red handcrafted:red_sheet
adorablehamsterpets:cucumber_crate extradelight:cucumber_crate_item fruitsdelight:apple_crate extradelight:apple_crate
rusticdelight:coffee_beans_bag extradelight:coffee_bean_sack extradelight:coffee_cherry_crate
fishermans_haven:worm aquaculture:worm create:copper_nugget twilightforest:copper_nugget twilightdelight:rainbow_ice_cream twilightdelight:refreshing_ice_cream twilightdelight:twilight_ice_cream""".split():
print(i, *info(i))
+144
View File
@@ -0,0 +1,144 @@
import pickle,json,collections,sys
sys.setrecursionlimit(10000)
exec(open("recipes.py").read().split("# ---------- active recipes")[0].split("# ---------- resource-level")[0]) # imports only
A=pickle.load(open("active.pkl","rb")); active=A["active"]; tagcache=A["tagcache"]
exec(open("recipes.py").read().split("# ---------- ingredients ----------")[1].split("# ---------- active recipes")[0].replace("resolve(","R("))
def full(x): return x if ":" in x else "minecraft:"+x
def R(t): return tagcache.get(t,set())
def ns(x): return x.split(":")[0]
EMPTY=frozenset(["<empty>"])
def shaped(j):
pat=j.get("pattern"); key=j.get("key",{})
if not pat: return None
g=[]
for row in pat:
r=[]
for ch in row:
if ch==" ": r.append(EMPTY)
else:
s=ing(key.get(ch))
if s is None: return None
r.append(s)
g.append(tuple(r))
return tuple(g)
def mirror(g): return tuple(tuple(reversed(r)) for r in g)
def inter(a,b): return bool(a&b)
def grid_match(a,b):
return len(a)==len(b) and len(a[0])==len(b[0]) and all(inter(x,y) for ra,rb in zip(a,b) for x,y in zip(ra,rb))
def bip(a,b):
if len(a)!=len(b): return False
match={}
def tr(i,seen):
for k in range(len(b)):
if k in seen or not inter(a[i],b[k]): continue
seen.add(k)
if k not in match or tr(match[k],seen): match[k]=i; return True
return False
return all(tr(i,set()) for i in range(len(a)))
def out(j):
try: return result(j)
except Exception: return str(j.get("result"))[:80]
groups=collections.defaultdict(list)
for rid,(jar,j) in active.items(): groups[j.get("type","?")].append((rid,jar,j))
print({k:len(v) for k,v in sorted(groups.items(),key=lambda x:-len(x[1]))[:40]})
res=collections.defaultdict(list)
def report(kind,a,b,same):
res[(kind,same)].append((a,b))
# --- crafting: shaped vs shaped, shapeless vs shapeless, shaped vs shapeless ---
SH=[]; SL=[]
for rid,jar,j in groups["minecraft:crafting_shaped"]:
g=shaped(j)
if g: SH.append((rid,g,out(j)))
for rid,jar,j in groups["minecraft:crafting_shapeless"]:
l=[ing(x) for x in j.get("ingredients",[])]
if None in l or not l: continue
SL.append((rid,l,out(j)))
idx=collections.defaultdict(set)
for i,(rid,g,o) in enumerate(SH):
for it in g[0][0]: idx[(len(g),len(g[0]),it)].add(i)
pairs=set()
for i,(rid,g,o) in enumerate(SH):
for gg in (g,mirror(g)):
for it in gg[0][0]:
for k in idx[(len(g),len(g[0]),it)]:
if k>i and grid_match(gg,SH[k][1]): pairs.add((i,k))
for i,k in pairs:
a,b=SH[i],SH[k]; report("shaped",(a[0],a[2]),(b[0],b[2]),a[2]==b[2])
idx=collections.defaultdict(set)
for i,(rid,l,o) in enumerate(SL):
for it in set().union(*l): idx[(len(l),it)].add(i)
pairs=set()
for i,(rid,l,o) in enumerate(SL):
small=min(l,key=len)
for it in small:
for k in idx[(len(l),it)]:
if k>i and bip(l,SL[k][1]): pairs.add((i,k))
for i,k in pairs:
a,b=SL[i],SL[k]; report("shapeless",(a[0],a[2]),(b[0],b[2]),a[2]==b[2])
# shaped vs shapeless (shapeless matches any arrangement with same nonempty cells)
for rid,g,o in SH:
cells=[c for r in g for c in r if c is not EMPTY]
for it in cells[0]:
for k in idx[(len(cells),it)]:
if bip(cells,SL[k][1]): report("shaped~shapeless",(rid,o),(SL[k][0],SL[k][2]),o==SL[k][2])
# --- single-input processing types ---
def single(j):
x=j.get("ingredient")
if x is None and isinstance(j.get("ingredients"),list) and len(j["ingredients"])==1: x=j["ingredients"][0]
return ing(x) if x is not None else None
for t in ["minecraft:smelting","minecraft:blasting","minecraft:smoking","minecraft:campfire_cooking"]:
L=[(rid,single(j),out(j)) for rid,jar,j in groups[t]]
L=[x for x in L if x[1]]
idx=collections.defaultdict(list)
for i,(rid,s,o) in enumerate(L):
for it in s: idx[it].append(i)
seen=set()
for it,ix in idx.items():
for a in ix:
for b in ix:
if a<b and (a,b) not in seen:
seen.add((a,b)); report(t,(L[a][0],L[a][2]),(L[b][0],L[b][2]),L[a][2]==L[b][2])
# cutting board
def tool(j):
t=j.get("tool")
if isinstance(t,dict) and t.get("type","").endswith("ability"): return ("ability",t.get("action") or t.get("item_ability"))
s=ing(t); return ("items",s)
def tools_overlap(a,b):
if a[0]!=b[0]: return False
return a[1]==b[1] if a[0]=="ability" else inter(a[1] or frozenset(),b[1] or frozenset())
for t in [k for k in groups if k.endswith(":cutting")]:
L=[]
for rid,jar,j in groups[t]:
s=ing(j.get("ingredients"));
if s: L.append((rid,s,tool(j),out(j)))
idx=collections.defaultdict(list)
for i,x in enumerate(L):
for it in x[1]: idx[it].append(i)
seen=set()
for it,ix in idx.items():
for a in ix:
for b in ix:
if a<b and (a,b) not in seen and tools_overlap(L[a][2],L[b][2]):
seen.add((a,b)); report(t,(L[a][0],L[a][3],sorted(L[a][1])[:2] if len(L[a][1])>2 else sorted(L[a][1])),(L[b][0],L[b][3]),L[a][3]==L[b][3])
# multi-ingredient machine types (exact multiset w/ overlap): cooking pots, mixers etc
GENERIC=[k for k in groups if not k.startswith("minecraft:") and not k.endswith(":cutting")]
for t in GENERIC:
L=[]
for rid,jar,j in groups[t]:
x=j.get("ingredients") or ([j["ingredient"]] if "ingredient" in j else None)
if not isinstance(x,list): continue
l=[ing(y) for y in x]
if None in l or not l or any(len(s)==0 for s in l): continue
extra=json.dumps({k:j.get(k) for k in ("container","tool","heatRequirement","heat_requirement","processing_time","fluid","inputFluid") if k in j},sort_keys=True)
L.append((rid,l,out(j),extra))
idx=collections.defaultdict(set)
for i,(rid,l,o,e) in enumerate(L):
for it in set().union(*l): idx[(len(l),it)].add(i)
for i,(rid,l,o,e) in enumerate(L):
for it in min(l,key=len):
for k in idx[(len(l),it)]:
if k>i and L[k][3]==e and bip(l,L[k][1]):
report(t,(rid,o),(L[k][0],L[k][2]),o==L[k][2])
pickle.dump(dict(res),open("coll.pkl","wb"))
for (kind,same),l in sorted(res.items()):
print(f"## {kind} {'SAME-OUTPUT(dup)' if same else 'CONFLICT'}: {len(set(map(str,l)))}")
+42
View File
@@ -0,0 +1,42 @@
import pickle,re
D=pickle.load(open("data.pkl","rb")); mods=D["mods"]
mods.setdefault("minecraft",[dict(jar="base",version="1.21.1",deps=[],nested=False)])
def vt(v): return [int(x) if x.isdigit() else x for x in re.split(r'[.\-+_]',v)]
def cmp(a,b):
a,b=vt(a),vt(b)
for x,y in zip(a,b):
if x==y: continue
if type(x)==type(y): return -1 if x<y else 1
return -1 if isinstance(x,str) else 1
return (len(a)>len(b))-(len(a)<len(b))
def inrange(v,r):
r=r.strip()
if not r or r=="*": return True
for part in re.findall(r'[\[\(][^\]\)]*[\]\)]',r) or [None]:
if part is None: return cmp(v,r)>=0
lo,_,hi=part[1:-1].partition(",")
if "," not in part:
if cmp(v,lo)==0: return True
continue
ok=True
if lo.strip(): c=cmp(v,lo.strip()); ok&= c>0 or (c==0 and part[0]=="[")
if hi.strip(): c=cmp(v,hi.strip()); ok&= c<0 or (c==0 and part[-1]=="]")
if ok: return True
return False
# duplicate modids among top-level jars
for mid,l in mods.items():
top=[x for x in l if not x["nested"]]
if len(top)>1: print("DUP-MODID",mid,[x["jar"] for x in top])
for mid,l in sorted(mods.items()):
e=[x for x in l if not x["nested"]] or l
for d in e[0]["deps"]:
t=str(d.get("type",d.get("mandatory") and "required" or "optional")).lower()
if d.get("mandatory") is True: t="required"
did=d.get("modId"); side=d.get("side","BOTH")
if t not in("required","incompatible","discouraged"): continue
if did not in mods:
if t=="required": print("MISSING",mid,"->",did,d.get("versionRange"),side)
continue
v=mods[did][0]["version"]; r=d.get("versionRange","")
if t=="required" and not v.startswith("$") and not inrange(v,r): print("VERSION",mid,"->",did,"needs",r,"have",v)
if t in("incompatible","discouraged") and (not r or inrange(v,r)): print(t.upper(),mid,"->",did,r,"have",v)
+29
View File
@@ -0,0 +1,29 @@
import pickle,collections,re,json
D=pickle.load(open("data.pkl","rb")); A=pickle.load(open("active.pkl","rb")); tc=A["tagcache"]
models=D["models"]
name={}
for k,v in D["lang"].items():
_,ns,p=k.split("."); name.setdefault(ns+":"+p,v[0][1])
spec=collections.defaultdict(set)
for t,s in tc.items():
if t.startswith("c:") and "/" in t and len(s)<=8:
for i in s: spec[i].add(t)
new=set("""extradelight moredelight rusticdelight dungeonsdelight frightsdelight fruitsdelight minersdelight veggiesdelight overweight_farming barbequesdelight fishermans_haven fowlplay etched music_disc_maker too_many_bows mermod tarotcards chalk charta comforts petcemetery friendsandfoes""".split())
def norm(n): return re.sub(r'[^a-z]','',n.lower())
by=collections.defaultdict(set)
for i,n in name.items():
if i in models and isinstance(n,str) and "spawn_egg" not in i and not i.startswith(("sophisticated",)):
by[norm(n)].add(i)
# also group by registry path
for i in models:
p=i.split(":")[1]
if len(p)>3: by["path:"+p].add(i)
seen=set()
for k,s in sorted(by.items()):
nss={i.split(":")[0] for i in s}
if len(nss)<2 or "minecraft" in nss or not (nss&new): continue
key=frozenset(s)
if key in seen: continue
seen.add(key)
s=sorted(s); common=set.intersection(*[spec.get(i,set()) for i in s])
print(("UNIFIED " if common else "SPLIT ")+name.get(s[0],s[0])+": "+", ".join(s)+((" via "+",".join(sorted(common))) if common else ""))
+48
View File
@@ -0,0 +1,48 @@
import zipfile, io, json, tomllib, re, pickle, os, glob
def lenient_json(b):
s=b.decode("utf-8-sig",errors="replace")
try: return json.loads(s)
except Exception:
s2=re.sub(r'//[^\n]*','',s); s2=re.sub(r',(\s*[}\]])',r'\1',s2)
try: return json.loads(s2)
except Exception: return None
mods={} # modid -> dict(jar, version, deps, nested)
files=[] # (jar, path, json) for data recipe/tags + lang
lang={}; models=set(); errors=[]
def walk(z, jarname, nested=False):
names=z.namelist()
for t in ("META-INF/neoforge.mods.toml","META-INF/mods.toml"):
if t in names:
raw=z.read(t).decode("utf-8",errors="replace")
try: m=tomllib.loads(raw)
except Exception as e: errors.append((jarname,"toml",str(e))); m={}
for md in m.get("mods",[]):
mid=md.get("modId"); ver=str(md.get("version"))
if ver.startswith("${"):
mf=z.read("META-INF/MANIFEST.MF").decode(errors="replace") if "META-INF/MANIFEST.MF" in names else ""
mm=re.search(r'Implementation-Version: *(\S+)',mf); ver=mm.group(1) if mm else ver
deps=m.get("dependencies",{}).get(mid,[])
mods.setdefault(mid,[]).append(dict(jar=jarname,version=ver,deps=deps,nested=nested,file=t))
break
for n in names:
if n.startswith("META-INF/jarjar/") and n.endswith(".jar"):
try: walk(zipfile.ZipFile(io.BytesIO(z.read(n))), jarname+"!"+n.split("/")[-1], True)
except Exception as e: errors.append((jarname,n,str(e)))
elif n.startswith("data/") and n.endswith(".json"):
p=n.split("/")
if len(p)>=4 and (p[2] in("recipe","recipes") or (p[2]=="tags" and p[3] in("item","items","block","blocks"))):
j=lenient_json(z.read(n))
if j is None: errors.append((jarname,n,"badjson"))
files.append((jarname,n,j))
elif n.startswith("assets/") and n.endswith("lang/en_us.json"):
j=lenient_json(z.read(n))
if isinstance(j,dict):
for k,v in j.items():
if k.startswith(("item.","block.")) and k.count(".")==2: lang.setdefault(k,[]).append((jarname,v))
elif n.startswith("assets/") and "/models/item/" in n and n.endswith(".json"):
p=n.split("/"); models.add(p[1]+":"+"/".join(p[4:])[:-5])
jars=["base_minecraft.jar","base_neoforge.jar"]+sorted(glob.glob("jars/*.jar"))
for j in jars: walk(zipfile.ZipFile(j), os.path.basename(j))
pickle.dump(dict(mods=mods,files=files,lang=lang,models=models,errors=errors),open("data.pkl","wb"))
print(len(mods),len(files),len(lang),len(models),len(errors))
for e in errors[:20]: print(e)
+21
View File
@@ -0,0 +1,21 @@
import tomllib, hashlib, os, glob, shutil, urllib.request, concurrent.futures as cf
P="/home/logan/Development/onion-mc/mods"
srcs=[os.path.expanduser("~/.local/share/PrismLauncher/instances/Onion MC/minecraft/mods"),"/tmp/fd/jars"]
def h(p): return hashlib.new
def one(f):
t=tomllib.load(open(f,"rb")); fn=t["filename"]; d=t["download"]; out=f"jars/{fn}"
def ok(p):
hh=hashlib.new(d["hash-format"]); hh.update(open(p,"rb").read()); return hh.hexdigest()==d["hash"]
if os.path.exists(out) and ok(out): return fn,"cached"
for s in srcs:
p=os.path.join(s,fn)
if os.path.exists(p) and ok(p): shutil.copy(p,out); return fn,"local"
url=d.get("url")
if not url: return fn,"NOURL "+str(t.get("update"))
req=urllib.request.Request(url.replace(" ","%20"),headers={"User-Agent":"onion-mc-audit"})
open(out,"wb").write(urllib.request.urlopen(req,timeout=120).read())
return fn,"dl" if ok(out) else "BADHASH"
with cf.ThreadPoolExecutor(8) as ex:
for fn,s in ex.map(one,sorted(glob.glob(P+"/*.pw.toml"))):
if s not in("cached","local","dl"): print(s,fn)
print(len(os.listdir("jars")))
+107
View File
@@ -0,0 +1,107 @@
"""Generate config/paxi/datapacks/onion-fixes from the audit findings.
Run from repo root after extract.py has built /tmp/onion/data.pkl."""
import json, os, pickle, shutil, copy
D = pickle.load(open("/tmp/onion/data.pkl", "rb"))
orig = {}
for jar, p, j in D["files"]:
orig.setdefault(p, {})[jar.split("-")[0]] = j
OUT = "config/paxi/datapacks/onion-fixes"
shutil.rmtree(OUT, ignore_errors=True)
def write(path, obj):
f = os.path.join(OUT, path)
os.makedirs(os.path.dirname(f), exist_ok=True)
with open(f, "w") as fh:
json.dump(obj, fh, indent=2); fh.write("\n")
def recipe(rid, obj):
ns, p = rid.split(":")
write(f"data/{ns}/recipe/{p}.json", obj)
def disable(rid):
recipe(rid, {"neoforge:conditions": [{"type": "neoforge:false"}]})
def src(path, mod):
return copy.deepcopy(orig[path][mod])
write("pack.mcmeta", {"pack": {"pack_format": 48,
"description": "Onion MC cross-mod recipe/tag fixes"}})
KNIFE = {"tag": "c:tools/knife"}
def cut(ing, out, n):
return {"type": "farmersdelight:cutting", "ingredients": [ing],
"result": [{"item": {"id": out, "count": n}}], "tool": KNIFE}
# 1. Potato: one board recipe per input, chained so each mod keeps its yield.
# potato -> 2 rustic slices (rustic's recipe, untouched)
# rustic slice -> 2 extradelight sliced potato (4/potato, as before)
# 2 rustic slices -> 1 moredelight diced potatoes (1/potato, as before;
# moredelight's Create saw recipe still gives 2/potato)
recipe("extradelight:cutting/sliced_potato",
cut({"item": "rusticdelight:potato_slices"}, "extradelight:sliced_potato", 2))
disable("moredelight:cutting/diced_potatoes")
recipe("onion:diced_potatoes_from_potato_slices", {
"type": "minecraft:crafting_shapeless", "category": "misc",
"ingredients": [{"item": "rusticdelight:potato_slices"}] * 2,
"result": {"id": "moredelight:diced_potatoes", "count": 1}})
# Slices are not whole potatoes (dupe loop via stews / re-cutting).
write("data/c/tags/item/crops/potato.json", {"replace": True, "values": [
"minecraft:potato", "veggiesdelight:sweet_potato",
{"id": "#forge:crops/potato", "required": False}]})
# 2. Bread: extradelight's slice wins; moredelight's sandwiches use c:bread_slices.
# Widen extradelight's input to c:foods/bread so cauliflower bread still slices.
r = src("data/extradelight/recipe/cutting/bread_knife.json", "extradelight")
r["ingredients"] = [{"tag": "c:foods/bread"}]
recipe("extradelight:cutting/bread_knife", r)
disable("moredelight:cutting/bread_slice")
# 3. Coffee: raw rustic beans are not roasted beans; crates use their own items.
write("data/c/tags/item/crops/coffee/cooked.json", {"replace": True, "values": [
"extradelight:coffee_beans", "rusticdelight:roasted_coffee_beans"]})
for rid, item in [("extradelight:coffee_cherries_to_block", "extradelight:coffee_cherries"),
("extradelight:coffee_beans_to_block", "extradelight:coffee_beans")]:
ns, p = rid.split(":")
r = src(f"data/{ns}/recipe/{p}.json", "extradelight")
r["ingredients"] = [{"item": item}] * 9
recipe(rid, r)
# 4. Twilight Forest knives: twilightdelight owns them; drop dungeonsdelight's copies.
for k in ["fiery_knife", "fiery_iron_knife", "ironwood_knife", "knightmetal_knife", "steeleaf_knife"]:
disable(f"dungeonsdelight:integration/twilightforest/crafting_table/{k}")
# 5. Crates: keep one crafting recipe per crate (uncrafting recipes untouched).
disable("minecraft:poisonous_potato_crate") # dungeonsdelight's; frightsdelight keeps it
disable("minecraft:rotten_tomato_crate") # dungeonsdelight's; frightsdelight keeps it
disable("fruitsdelight:apple_crate") # extradelight keeps it
disable("adorablehamsterpets:cucumber_crate") # extradelight's accepts any c:crops/cucumber
# 6. Handcrafted sheets: shapeless 3 wool collided with Comforts' vertical
# sleeping bags; make sheets a horizontal row.
COLORS = "white orange magenta light_blue yellow lime pink gray light_gray cyan purple blue brown green red black".split()
for c in COLORS:
r = src(f"data/handcrafted/recipe/{c}_sheet.json", "handcrafted")
recipe(f"handcrafted:{c}_sheet", {"type": "minecraft:crafting_shaped",
"category": r.get("category", "misc"), "key": {"#": r["ingredients"][0]},
"pattern": ["###"], "result": r["result"]})
# 7. Handcrafted wood plate (any 3 slabs in a row) shadowed all TF drying racks.
r = src("data/handcrafted/recipe/wood_plate.json", "handcrafted")
r["pattern"] = ["##"]
r["result"]["count"] = 2
recipe("handcrafted:wood_plate", r)
# 8. Fisherman's Haven worms can: one recipe, plus 1:1 worm conversions.
disable("fishermans_haven:recipe_worms_aquaculture")
for a, b in [("fishermans_haven:worm", "aquaculture:worm"), ("aquaculture:worm", "fishermans_haven:worm")]:
recipe(f"onion:{b.split(':')[0]}_worm_from_{a.split(':')[0]}_worm", {
"type": "minecraft:crafting_shapeless", "category": "misc",
"ingredients": [{"item": a}], "result": {"id": b, "count": 1}})
# 9. Twilight Delight ice creams need Neapolitan (absent): gate instead of log errors.
for k in ["rainbow_ice_cream", "refreshing_ice_cream", "twilight_ice_cream"]:
r = src(f"data/twilightdelight/recipe/{k}.json", "twilightdelight")
r = {"neoforge:conditions": [{"type": "neoforge:mod_loaded", "modid": "neapolitan"}], **r}
recipe(f"twilightdelight:{k}", r)
# 10. Beef stew: rustic replaces it, extradelight disables it -> load-order roulette.
# Pin rustic's version so the cooking pot recipe always exists.
recipe("farmersdelight:cooking/beef_stew",
src("data/farmersdelight/recipe/cooking/beef_stew.json", "rusticdelight"))
print("ok")
+161
View File
@@ -0,0 +1,161 @@
import pickle,json,collections,re,itertools
D=pickle.load(open("data.pkl","rb"))
mods=set(D["mods"])|{"minecraft","c","neoforge"}
files=D["files"]
# ---------- resource-level overrides ----------
byres=collections.defaultdict(list)
for jar,path,j in files: byres[path].append((jar,j))
legacy=collections.Counter()
for path,l in byres.items():
p=path.split("/")
if p[2]=="recipes" or (p[2]=="tags" and p[3] in("items","blocks")): legacy[l[0][0]]+=1
# ---------- tags ----------
def is_tag_path(p): return p[2]=="tags" and p[3]=="item"
tagdefs=collections.defaultdict(list) # tag -> [(jar, values, replace)]
for path,l in byres.items():
p=path.split("/")
if not is_tag_path(p): continue
tag=p[1]+":"+"/".join(p[4:])[:-5]
for jar,j in l:
if not isinstance(j,dict): continue
tagdefs[tag].append((jar,j.get("values",[]),j.get("replace",False)))
items=set()
for k in D["lang"]:
_,ns,pa=k.split("."); items.add(ns+":"+pa)
items|=D["models"]
def ns(x): return x.split(":")[0] if ":" in x else "minecraft"
def full(x): return x if ":" in x else "minecraft:"+x
tagcache={}; tagissues=[]
def resolve(tag,stack=()):
if tag in tagcache: return tagcache[tag]
if tag in stack: return set()
out=set()
for jar,vals,rep in tagdefs.get(tag,[]):
if rep: out=set()
for v in vals:
req=True
if isinstance(v,dict): req=v.get("required",True); v=v.get("id")
if not isinstance(v,str): continue
if v.startswith("#"):
t=full(v[1:])
if req and t not in tagdefs: tagissues.append((tag,jar,v,"missing-tag"))
out|=resolve(t,stack+(tag,))
else:
v=full(v)
if v not in items:
if req: tagissues.append((tag,jar,v,"missing-item" if ns(v) in mods else "missing-mod-item"))
continue
out.add(v)
tagcache[tag]=out; return out
for t in list(tagdefs): resolve(t)
# ---------- conditions ----------
unknown_cond=collections.Counter()
def cond(c):
if isinstance(c,list): return all(cond(x) for x in c)
t=c.get("type","")
if t in("neoforge:mod_loaded","forge:mod_loaded"): return c.get("modid") in mods
if t in("neoforge:not","forge:not"): return not cond(c["value"])
if t in("neoforge:and","forge:and"): return all(cond(x) for x in c["values"])
if t in("neoforge:or","forge:or"): return any(cond(x) for x in c["values"])
if t in("neoforge:item_exists","forge:item_exists"): return full(c["item"]) in items
if t in("neoforge:tag_empty","forge:tag_empty"): return not resolve(full(c["tag"]))
if t in("neoforge:true","forge:true"): return True
if t in("neoforge:false","forge:false"): return False
unknown_cond[t]+=1; return True
# ---------- ingredients ----------
def ing(x):
"""-> frozenset of items, or None if unknown type"""
if x is None: return frozenset()
if isinstance(x,str):
return frozenset(resolve(full(x[1:]))) if x.startswith("#") else frozenset([full(x)])
if isinstance(x,list):
s=set()
for y in x:
r=ing(y)
if r is None: return None
s|=r
return frozenset(s)
if isinstance(x,dict):
if "item" in x and isinstance(x["item"],str): return frozenset([full(x["item"])])
if "tag" in x: return frozenset(resolve(full(x["tag"])))
if "id" in x and "count" in x and isinstance(x["id"],str): return frozenset([full(x["id"])])
if "ingredient" in x: return ing(x["ingredient"])
t=x.get("type","")
if t in("neoforge:compound",): return ing(x.get("children"))
if t=="neoforge:components": return ing(x.get("items"))
if "items" in x: return ing(x["items"])
if t=="neoforge:difference":
b=ing(x.get("base")); s=ing(x.get("subtracted"))
return None if b is None or s is None else b-s
if t=="neoforge:intersection":
ch=[ing(c) for c in x.get("children",[])]
if None in ch or not ch: return None
return frozenset(set.intersection(*map(set,ch)))
return None
def result(j):
r=j.get("result")
if r is None: return None
if isinstance(r,str): return (full(r),1)
if isinstance(r,dict):
i=r.get("id") or r.get("item")
if isinstance(i,dict): i=i.get("id") or i.get("item")
return (full(i) if isinstance(i,str) else str(i), r.get("count",1))
if isinstance(r,list):
return tuple(sorted(result({"result":(e.get("item",e) if isinstance(e,dict) else e)}) + ((e.get("chance",1),) if isinstance(e,dict) else ()) for e in r))
return str(r)
# ---------- active recipes (last jar wins per path; note collisions) ----------
active={}; overrides=[]; missingrefs=[]
for path,l in byres.items():
p=path.split("/")
if p[2]!="recipe" or not path.endswith(".json"): continue
rid=p[1]+":"+"/".join(p[3:])[:-5]
live=[]
for jar,j in l:
if not isinstance(j,dict): continue
c=j.get("neoforge:conditions")
if c is not None and not cond(c): continue
live.append((jar,j))
if len(live)>1:
uniq={json.dumps(j,sort_keys=True) for _,j in live}
if len(uniq)>1: overrides.append((rid,[jr for jr,_ in live]))
if live: active[rid]=live[-1]
# cross-mod overrides of another namespace's recipes (single providing jar from other mod)
owner={}
for m,l in D["mods"].items():
for x in l: owner.setdefault(x["jar"].split("!")[0],set()).add(m)
foreign=collections.defaultdict(list)
for rid,(jar,j) in active.items():
n=ns(rid)
if n not in owner.get(jar,set()) and jar not in("base_minecraft.jar",) and n not in("c",):
foreign[(jar,n)].append(rid)
# ---------- unknown refs in active recipes ----------
def refs(o,acc):
if isinstance(o,dict):
for k,v in o.items():
if k in("item","id","tag") and isinstance(v,str) and k!="type": acc.append((k,v))
else: refs(v,acc)
elif isinstance(o,list):
for v in o: refs(v,acc)
return acc
for rid,(jar,j) in active.items():
if j.get("type","").startswith(("neoforge:","forge:")) and "conditional" in j.get("type",""): continue
for k,v in refs({kk:vv for kk,vv in j.items() if kk not in("neoforge:conditions",)},[]):
if k=="tag": continue
if ":" in v and ns(v) not in mods and not v.startswith("#"):
missingrefs.append((rid,jar,v)); break
pickle.dump(dict(active=active,tagcache=tagcache,items=items),open("active.pkl","wb"))
print("== legacy (pre-1.21 'recipes/' or 'tags/items/') files, inert ==")
for k,v in legacy.most_common(): print(f" {k}: {v}")
print("== recipe path collisions with differing content (load order decides) ==",len(overrides))
for r,js in overrides: print(" ",r,js)
print("== jars overriding another namespace's recipes ==")
for (jar,n),l in sorted(foreign.items(),key=lambda x:-len(x[1])): print(f" {jar} -> {n}: {len(l)} e.g. {l[:3]}")
print("== active recipes referencing items from absent mods (will fail to load) ==",len(missingrefs))
for x in missingrefs[:80]: print(" ",x)
print("== tag issues (required entry missing => whole tag fails to load) ==")
seen=set()
for t,jar,v,k in tagissues:
if (t,v) in seen: continue
seen.add((t,v)); print(" ",k,t,"<-",v,"from",jar)
print("== unknown condition types (assumed true) ==",dict(unknown_cond))
print("active recipes",len(active))