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:
1 parent
d80f85b3f6
commit
611a1a5952
102 files changed
+25853
No files matched your search
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static client-perf surface scan of every mod jar in the pack.
|
||||
|
||||
Usage: jarscan.py <jar-dir> <repo-root> <out.json>
|
||||
|
||||
Per mod (including jar-in-jar children, attributed to the parent):
|
||||
- mixins: count by side, @Mixin targets, injector kinds (Overwrite/Redirect...)
|
||||
- client hot-path classification of mixin targets
|
||||
- classes referencing per-frame / per-tick client event types
|
||||
- texture pressure: block/item atlas pixels, entity textures, animated textures
|
||||
- models, blockstates, shaders (incl. vanilla core shader overrides), sounds
|
||||
- geckolib geo/animation files, EMF/OptiFine CEM files, particles
|
||||
- registered-content proxies from en_us.json keys
|
||||
"""
|
||||
import io, json, os, re, struct, sys, zipfile, tomllib
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
# ---------- class file parsing (constant pool + annotations) ----------
|
||||
|
||||
def parse_class(data):
|
||||
"""Return (utf8 set, class annotations, method annotations) for a class file.
|
||||
Annotations are dicts {type: {elem: value}} for RuntimeInvisible/Visible."""
|
||||
if data[:4] != b'\xca\xfe\xba\xbe':
|
||||
return None
|
||||
pos = 10
|
||||
count = struct.unpack('>H', data[8:10])[0]
|
||||
cp = [None] * count
|
||||
i = 1
|
||||
while i < count:
|
||||
tag = data[pos]
|
||||
if tag == 1:
|
||||
ln = struct.unpack('>H', data[pos+1:pos+3])[0]
|
||||
cp[i] = ('utf8', data[pos+3:pos+3+ln].decode('utf-8', 'replace'))
|
||||
pos += 3 + ln
|
||||
elif tag in (3, 4):
|
||||
cp[i] = ('num', None); pos += 5
|
||||
elif tag in (5, 6):
|
||||
cp[i] = ('num', None); pos += 9; i += 1
|
||||
elif tag == 7:
|
||||
cp[i] = ('class', struct.unpack('>H', data[pos+1:pos+3])[0]); pos += 3
|
||||
elif tag in (8, 16, 19, 20):
|
||||
cp[i] = ('ref', struct.unpack('>H', data[pos+1:pos+3])[0]); pos += 3
|
||||
elif tag in (9, 10, 11, 12, 17, 18):
|
||||
cp[i] = ('ref2', None); pos += 5
|
||||
elif tag == 15:
|
||||
cp[i] = ('mh', None); pos += 4
|
||||
else:
|
||||
return None
|
||||
i += 1
|
||||
|
||||
def u(idx):
|
||||
e = cp[idx]
|
||||
return e[1] if e and e[0] == 'utf8' else None
|
||||
|
||||
utf8 = {e[1] for e in cp if e and e[0] == 'utf8'}
|
||||
|
||||
def read_elem(p):
|
||||
tag = chr(data[p]); p += 1
|
||||
if tag in 'BCDFIJSZs':
|
||||
return u(struct.unpack('>H', data[p:p+2])[0]), p + 2
|
||||
if tag == 'e':
|
||||
return u(struct.unpack('>H', data[p+2:p+4])[0]), p + 4
|
||||
if tag == 'c':
|
||||
return u(struct.unpack('>H', data[p:p+2])[0]), p + 2
|
||||
if tag == '@':
|
||||
return read_ann(p)
|
||||
if tag == '[':
|
||||
n = struct.unpack('>H', data[p:p+2])[0]; p += 2
|
||||
out = []
|
||||
for _ in range(n):
|
||||
v, p = read_elem(p); out.append(v)
|
||||
return out, p
|
||||
raise ValueError(tag)
|
||||
|
||||
def read_ann(p):
|
||||
t = u(struct.unpack('>H', data[p:p+2])[0])
|
||||
n = struct.unpack('>H', data[p+2:p+4])[0]; p += 4
|
||||
elems = {}
|
||||
for _ in range(n):
|
||||
name = u(struct.unpack('>H', data[p:p+2])[0]); p += 2
|
||||
v, p = read_elem(p); elems[name] = v
|
||||
return (t, elems), p
|
||||
|
||||
def read_attrs(p):
|
||||
anns = []
|
||||
n = struct.unpack('>H', data[p:p+2])[0]; p += 2
|
||||
for _ in range(n):
|
||||
name = u(struct.unpack('>H', data[p:p+2])[0])
|
||||
ln = struct.unpack('>I', data[p+2:p+6])[0]
|
||||
body = p + 6
|
||||
if name in ('RuntimeInvisibleAnnotations', 'RuntimeVisibleAnnotations'):
|
||||
k = struct.unpack('>H', data[body:body+2])[0]; q = body + 2
|
||||
for _ in range(k):
|
||||
a, q = read_ann(q); anns.append(a)
|
||||
p = body + ln
|
||||
return anns, p
|
||||
|
||||
try:
|
||||
p = pos + 6
|
||||
ni = struct.unpack('>H', data[p:p+2])[0]; p += 2 + 2 * ni
|
||||
method_anns = []
|
||||
for _member in ('fields', 'methods'):
|
||||
n = struct.unpack('>H', data[p:p+2])[0]; p += 2
|
||||
for _ in range(n):
|
||||
anns, p = read_attrs(p + 6)
|
||||
if _member == 'methods':
|
||||
method_anns.extend(anns)
|
||||
class_anns, p = read_attrs(p)
|
||||
except Exception:
|
||||
return utf8, [], []
|
||||
return utf8, class_anns, method_anns
|
||||
|
||||
|
||||
# ---------- classification ----------
|
||||
|
||||
HOT = [ # (category, regex on internal class name of mixin target)
|
||||
('blaze3d', r'^com/mojang/blaze3d/'),
|
||||
('sodium', r'^net/caffeinemc/'),
|
||||
('iris', r'^net/irisshaders/'),
|
||||
('world_render', r'^net/minecraft/client/renderer/(LevelRenderer|GameRenderer|LightTexture|FogRenderer|DimensionSpecialEffects|SectionOcclusionGraph|ViewArea|chunk/|culling/|PostChain|RenderType|RenderStateShard|ShaderInstance|LevelEventHandler)'),
|
||||
('entity_render', r'^net/minecraft/client/(renderer/entity/|model/|renderer/blockentity/|renderer/ItemInHandRenderer)'),
|
||||
('item_render', r'^net/minecraft/client/renderer/(ItemRenderer|item/)'),
|
||||
('block_model', r'^net/minecraft/client/(renderer/block/|resources/model/|renderer/ItemBlockRenderTypes)'),
|
||||
('texture_load', r'^net/minecraft/client/(renderer/texture/|resources/)'),
|
||||
('camera', r'^net/minecraft/client/Camera'),
|
||||
('gui', r'^net/minecraft/client/gui/'),
|
||||
('particle', r'^net/minecraft/client/particle/'),
|
||||
('sound', r'^(net/minecraft/client/sounds/|com/mojang/blaze3d/audio/)'),
|
||||
('client_core', r'^net/minecraft/client/(Minecraft|multiplayer/|player/|KeyboardHandler|MouseHandler|Options)'),
|
||||
('client_other', r'^net/minecraft/client/'),
|
||||
('entity_common', r'^net/minecraft/world/entity/'),
|
||||
('level_common', r'^net/minecraft/world/level/'),
|
||||
('server', r'^net/minecraft/server/'),
|
||||
('mc_other', r'^net/minecraft/'),
|
||||
('other_mod', r'.'),
|
||||
]
|
||||
HOT_RE = [(c, re.compile(r)) for c, r in HOT]
|
||||
FRAME_CATS = {'blaze3d', 'sodium', 'iris', 'world_render', 'entity_render', 'item_render', 'camera', 'gui', 'particle'}
|
||||
|
||||
def classify(target):
|
||||
for c, r in HOT_RE:
|
||||
if r.search(target):
|
||||
return c
|
||||
return 'other_mod'
|
||||
|
||||
EVENTS = {
|
||||
# per-frame
|
||||
'RenderLevelStageEvent': 'frame', 'RenderFrameEvent': 'frame', 'RenderGuiEvent': 'frame',
|
||||
'RenderGuiLayerEvent': 'frame', 'CustomizeGuiOverlayEvent': 'frame', 'RenderLivingEvent': 'frame',
|
||||
'RenderPlayerEvent': 'frame', 'RenderHandEvent': 'frame', 'RenderArmEvent': 'frame',
|
||||
'RenderHighlightEvent': 'frame', 'ViewportEvent': 'frame', 'RenderNameTagEvent': 'frame',
|
||||
'RenderBlockScreenEffectEvent': 'frame', 'RenderTooltipEvent': 'frame', 'ScreenEvent': 'screen',
|
||||
'ComputeFovModifierEvent': 'frame',
|
||||
# per-tick
|
||||
'ClientTickEvent': 'tick', 'PlayerTickEvent': 'tick', 'EntityTickEvent': 'tick', 'LevelTickEvent': 'tick',
|
||||
# load
|
||||
'ModelEvent': 'load', 'RegisterShadersEvent': 'load', 'TextureAtlasStitchedEvent': 'load',
|
||||
}
|
||||
EVENT_RE = re.compile(r'net/neoforged/neoforge/(?:client/)?event/(?:[a-z/]+/)?(' + '|'.join(EVENTS) + r')(?:\$(\w+))?')
|
||||
OTHER_FRAME_RE = re.compile(r'(dev/architectury/event/events/client/(ClientGuiEvent|ClientTickEvent)|net/fabricmc/fabric/api/client/rendering/v1/(WorldRenderEvents|HudRenderCallback|HudLayerRegistrationCallback)|net/blay09/mods/balm/api/event/client/(GuiDrawEvent|RenderHandEvent|ClientTickHandler))')
|
||||
|
||||
INJECTORS = {
|
||||
'Lorg/spongepowered/asm/mixin/Overwrite;': 'Overwrite',
|
||||
'Lorg/spongepowered/asm/mixin/injection/Redirect;': 'Redirect',
|
||||
'Lorg/spongepowered/asm/mixin/injection/Inject;': 'Inject',
|
||||
'Lorg/spongepowered/asm/mixin/injection/ModifyVariable;': 'ModifyVariable',
|
||||
'Lorg/spongepowered/asm/mixin/injection/ModifyArg;': 'ModifyArg',
|
||||
'Lorg/spongepowered/asm/mixin/injection/ModifyArgs;': 'ModifyArgs',
|
||||
'Lorg/spongepowered/asm/mixin/injection/ModifyConstant;': 'ModifyConstant',
|
||||
'Lcom/llamalad7/mixinextras/injector/wrapoperation/WrapOperation;': 'WrapOperation',
|
||||
'Lcom/llamalad7/mixinextras/injector/wrapmethod/WrapMethod;': 'WrapMethod',
|
||||
'Lcom/llamalad7/mixinextras/injector/ModifyExpressionValue;': 'ModifyExpressionValue',
|
||||
'Lcom/llamalad7/mixinextras/injector/ModifyReturnValue;': 'ModifyReturnValue',
|
||||
'Lcom/llamalad7/mixinextras/injector/WrapWithCondition;': 'WrapWithCondition',
|
||||
'Lcom/llamalad7/mixinextras/injector/v2/WrapWithCondition;': 'WrapWithCondition',
|
||||
}
|
||||
|
||||
|
||||
def png_size(b):
|
||||
if b[:8] == b'\x89PNG\r\n\x1a\n' and b[12:16] == b'IHDR':
|
||||
return struct.unpack('>II', b[16:24])
|
||||
return None
|
||||
|
||||
|
||||
def load_json_lenient(b):
|
||||
s = b.decode('utf-8', 'replace')
|
||||
s = re.sub(r'^\s*//.*$', '', s, flags=re.M)
|
||||
try:
|
||||
return json.loads(s)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def mod_ids(z):
|
||||
for name in ('META-INF/neoforge.mods.toml', 'META-INF/mods.toml'):
|
||||
if name in z.namelist():
|
||||
try:
|
||||
t = tomllib.loads(z.read(name).decode('utf-8', 'replace'))
|
||||
except Exception:
|
||||
return [], []
|
||||
ids = [m.get('modId') for m in t.get('mods', [])]
|
||||
mix = [m.get('config') for m in t.get('mixins', []) if m.get('config')]
|
||||
return ids, mix
|
||||
return [], []
|
||||
|
||||
|
||||
def scan_zip(z, acc, depth=0):
|
||||
names = z.namelist()
|
||||
nameset = set(names)
|
||||
ids, mixcfgs = mod_ids(z)
|
||||
acc['mod_ids'].extend(i for i in ids if i)
|
||||
if 'META-INF/MANIFEST.MF' in nameset:
|
||||
mf = z.read('META-INF/MANIFEST.MF').decode('utf-8', 'replace').replace('\r\n ', '').replace('\n ', '')
|
||||
m = re.search(r'^MixinConfigs:\s*(.+)$', mf, re.M)
|
||||
if m:
|
||||
mixcfgs += [c.strip() for c in m.group(1).split(',')]
|
||||
if 'fabric.mod.json' in nameset:
|
||||
fj = load_json_lenient(z.read('fabric.mod.json')) or {}
|
||||
for c in fj.get('mixins', []):
|
||||
mixcfgs.append(c if isinstance(c, str) else c.get('config'))
|
||||
mixcfgs = sorted({c for c in mixcfgs if c and c in nameset})
|
||||
|
||||
# mixin classes
|
||||
mixin_classes = {} # path -> side
|
||||
for cfg in mixcfgs:
|
||||
j = load_json_lenient(z.read(cfg)) or {}
|
||||
pkg = (j.get('package') or '').replace('.', '/')
|
||||
for side in ('mixins', 'client', 'server'):
|
||||
for c in j.get(side, []) or []:
|
||||
mixin_classes[f'{pkg}/{c.replace(".", "/")}.class'] = {'mixins': 'common'}.get(side, side)
|
||||
acc['mixin_configs'] += len(mixcfgs)
|
||||
|
||||
tex = acc['tex']
|
||||
for n in names:
|
||||
if n.endswith('/'):
|
||||
continue
|
||||
if n.endswith('.class'):
|
||||
try:
|
||||
data = z.read(n)
|
||||
except Exception:
|
||||
continue
|
||||
parsed = parse_class(data)
|
||||
if not parsed:
|
||||
continue
|
||||
utf8, canns, manns = parsed
|
||||
if n in mixin_classes:
|
||||
side = mixin_classes[n]
|
||||
acc['mixins'][side] += 1
|
||||
targets = []
|
||||
for t, el in canns:
|
||||
if t == 'Lorg/spongepowered/asm/mixin/Mixin;':
|
||||
for v in el.get('value', []) or []:
|
||||
if v: targets.append(v[1:-1])
|
||||
for v in el.get('targets', []) or []:
|
||||
if v: targets.append(v.replace('.', '/'))
|
||||
kinds = Counter(INJECTORS[t] for t, _ in manns if t in INJECTORS)
|
||||
acc['injectors'].update(kinds)
|
||||
for tg in targets or ['?']:
|
||||
cat = classify(tg)
|
||||
acc['targets'][tg] += 1
|
||||
acc['target_cats'][cat] += 1
|
||||
if cat in FRAME_CATS:
|
||||
acc['frame_mixins'] += 1
|
||||
acc['frame_targets'][tg.rsplit('/', 1)[-1]] += 1
|
||||
acc['frame_injectors'].update(kinds)
|
||||
evs = set()
|
||||
for s in utf8:
|
||||
for m in EVENT_RE.finditer(s):
|
||||
evs.add(m.group(1) + ('$' + m.group(2) if m.group(2) else ''))
|
||||
m = OTHER_FRAME_RE.search(s)
|
||||
if m:
|
||||
evs.add(m.group(1).rsplit('/', 1)[-1])
|
||||
for e in evs:
|
||||
acc['events'][e] += 1
|
||||
continue
|
||||
if n.startswith(('META-INF/jarjar/', 'META-INF/jars/')) and n.endswith('.jar') and depth < 3:
|
||||
try:
|
||||
sub = zipfile.ZipFile(io.BytesIO(z.read(n)))
|
||||
subnames = set(sub.namelist())
|
||||
if 'fabric.mod.json' in subnames and not subnames & {'META-INF/neoforge.mods.toml', 'META-INF/mods.toml'}:
|
||||
continue # Fabric-only payload of a multi-loader jar; never loaded on NeoForge
|
||||
acc['nested'].append(n.rsplit('/', 1)[-1])
|
||||
scan_zip(sub, acc, depth + 1)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
parts = n.split('/')
|
||||
if parts[0] != 'assets' or len(parts) < 3:
|
||||
continue
|
||||
ns, kind = parts[1], parts[2]
|
||||
if kind == 'textures' and n.endswith('.png'):
|
||||
sub = parts[3] if len(parts) > 4 else ''
|
||||
info = z.getinfo(n)
|
||||
with z.open(n) as fh:
|
||||
wh = png_size(fh.read(24))
|
||||
px = wh[0] * wh[1] if wh else 0
|
||||
anim = None
|
||||
if wh and n + '.mcmeta' in nameset:
|
||||
mm = load_json_lenient(z.read(n + '.mcmeta')) or {}
|
||||
anim = mm.get('animation') if isinstance(mm, dict) else None
|
||||
if isinstance(anim, dict):
|
||||
# only one frame lives in the atlas; the rest is CPU-side upload source
|
||||
fw = anim.get('width', min(wh)); fh = anim.get('height', fw if 'width' in anim else min(wh))
|
||||
frames = max(1, (wh[0] // max(1, fw)) * (wh[1] // max(1, fh)))
|
||||
tex['anim_frames'] += frames
|
||||
px = fw * fh
|
||||
bucket = {'block': 'atlas', 'item': 'atlas', 'blocks': 'atlas', 'items': 'atlas',
|
||||
'entity': 'entity', 'models': 'entity', 'particle': 'particle',
|
||||
'gui': 'gui', 'environment': 'env', 'painting': 'painting', 'mob_effect': 'gui'}.get(sub, 'other')
|
||||
tex[bucket + '_n'] += 1
|
||||
tex[bucket + '_px'] += px
|
||||
tex['bytes'] += info.file_size
|
||||
if wh and max(wh) > 512 and bucket in ('atlas', 'entity'):
|
||||
acc['big_textures'].append(f'{n} {wh[0]}x{wh[1]}' + (' (animated)' if anim else ''))
|
||||
if isinstance(anim, dict) and bucket == 'atlas':
|
||||
tex['animated_n'] += 1
|
||||
elif kind == 'models' and n.endswith('.json'):
|
||||
acc['assets']['models'] += 1
|
||||
elif kind == 'blockstates' and n.endswith('.json'):
|
||||
acc['assets']['blockstates'] += 1
|
||||
elif kind == 'shaders':
|
||||
if n.endswith(('.vsh', '.fsh', '.glsl', '.json')):
|
||||
acc['assets']['shader_files'] += 1
|
||||
if ns == 'minecraft' and parts[3] == 'core':
|
||||
acc['core_shader_overrides'].append(n)
|
||||
if parts[3] == 'post':
|
||||
acc['assets']['post_effects'] += 1
|
||||
elif kind == 'sounds' and n.endswith('.ogg'):
|
||||
acc['assets']['ogg_n'] += 1
|
||||
acc['assets']['ogg_bytes'] += z.getinfo(n).file_size
|
||||
elif kind in ('geo', 'geckolib') or (kind == 'animations' and n.endswith('.json')):
|
||||
acc['assets']['geckolib_files'] += 1
|
||||
elif kind == 'particles' and n.endswith('.json'):
|
||||
acc['assets']['particle_defs'] += 1
|
||||
elif kind == 'optifine' or kind == 'emf' or kind == 'etf':
|
||||
acc['assets']['cem_files'] += 1
|
||||
elif kind == 'lang' and parts[-1] == 'en_us.json':
|
||||
j = load_json_lenient(z.read(n)) or {}
|
||||
for k in j:
|
||||
p0 = k.split('.', 1)[0]
|
||||
if p0 in ('block', 'item', 'entity', 'biome', 'enchantment', 'effect'):
|
||||
acc['lang'][p0] += 1
|
||||
|
||||
|
||||
def new_acc():
|
||||
return {'mod_ids': [], 'nested': [], 'mixin_configs': 0, 'mixins': Counter(),
|
||||
'targets': Counter(), 'target_cats': Counter(), 'frame_mixins': 0,
|
||||
'frame_targets': Counter(), 'frame_injectors': Counter(), 'injectors': Counter(),
|
||||
'events': Counter(), 'tex': Counter(), 'big_textures': [], 'assets': Counter(),
|
||||
'core_shader_overrides': [], 'lang': Counter()}
|
||||
|
||||
|
||||
def main():
|
||||
jar_dir, repo, out = sys.argv[1:4]
|
||||
meta = {}
|
||||
for d in ('mods', 'resourcepacks', 'shaderpacks'):
|
||||
for f in os.listdir(os.path.join(repo, d)):
|
||||
if not f.endswith('.pw.toml'):
|
||||
continue
|
||||
t = tomllib.load(open(os.path.join(repo, d, f), 'rb'))
|
||||
meta[t['filename']] = {'slug': f[:-8], 'name': t['name'], 'side': t.get('side', 'both'), 'dir': d,
|
||||
'optional': bool(t.get('option', {}).get('optional'))}
|
||||
results = {}
|
||||
for fn, m in sorted(meta.items()):
|
||||
path = os.path.join(jar_dir, fn)
|
||||
if not os.path.exists(path):
|
||||
print('missing', fn, file=sys.stderr)
|
||||
continue
|
||||
acc = new_acc()
|
||||
try:
|
||||
scan_zip(zipfile.ZipFile(path), acc)
|
||||
except zipfile.BadZipFile:
|
||||
print('badzip', fn, file=sys.stderr)
|
||||
continue
|
||||
acc['jar_bytes'] = os.path.getsize(path)
|
||||
acc['big_textures'] = acc['big_textures'][:15]
|
||||
acc['targets'] = dict(acc['targets'].most_common(40))
|
||||
results[m['slug']] = {**m, 'filename': fn, **{k: (dict(v) if isinstance(v, Counter) else v) for k, v in acc.items()}}
|
||||
json.dump(results, open(out, 'w'), indent=1, sort_keys=True)
|
||||
print(f'scanned {len(results)} files -> {out}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Jar inspection without unzip/java.
|
||||
|
||||
jx.py ls JAR[!nested.jar...] [regex] list entries
|
||||
jx.py cat JAR[!nested.jar...] entry print entry
|
||||
jx.py str JAR[!nested.jar...] classregex strregex UTF8 constants of classes
|
||||
"""
|
||||
import io, re, struct, sys, zipfile
|
||||
|
||||
|
||||
def open_jar(spec):
|
||||
parts = spec.split("!")
|
||||
z = zipfile.ZipFile(parts[0])
|
||||
for p in parts[1:]:
|
||||
z = zipfile.ZipFile(io.BytesIO(z.read(p)))
|
||||
return z
|
||||
|
||||
|
||||
def utf8_consts(data):
|
||||
out = []
|
||||
if data[:4] != b"\xca\xfe\xba\xbe":
|
||||
return out
|
||||
n = struct.unpack(">H", data[8:10])[0]
|
||||
i, k = 10, 1
|
||||
while k < n:
|
||||
tag = data[i]
|
||||
if tag == 1:
|
||||
ln = struct.unpack(">H", data[i + 1:i + 3])[0]
|
||||
out.append(data[i + 3:i + 3 + ln].decode("utf-8", "replace"))
|
||||
i += 3 + ln
|
||||
elif tag in (3, 4):
|
||||
i += 5
|
||||
elif tag in (5, 6):
|
||||
i += 9; k += 1
|
||||
elif tag in (7, 8, 16, 19, 20):
|
||||
i += 3
|
||||
elif tag in (9, 10, 11, 12, 17, 18):
|
||||
i += 5
|
||||
elif tag == 15:
|
||||
i += 4
|
||||
else:
|
||||
break
|
||||
k += 1
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
cmd, spec = sys.argv[1], sys.argv[2]
|
||||
z = open_jar(spec)
|
||||
if cmd == "ls":
|
||||
rx = re.compile(sys.argv[3]) if len(sys.argv) > 3 else None
|
||||
for n in z.namelist():
|
||||
if not rx or rx.search(n):
|
||||
print(n)
|
||||
elif cmd == "cat":
|
||||
sys.stdout.write(z.read(sys.argv[3]).decode("utf-8", "replace"))
|
||||
elif cmd == "str":
|
||||
crx, srx = re.compile(sys.argv[3]), re.compile(sys.argv[4])
|
||||
for n in z.namelist():
|
||||
if n.endswith(".class") and crx.search(n):
|
||||
hits = [s for s in utf8_consts(z.read(n)) if srx.search(s)]
|
||||
if hits:
|
||||
print("==", n)
|
||||
for h in hits:
|
||||
print(" ", repr(h))
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render data/jarscan.json into ranked markdown tables (data/jarscan-report.md)."""
|
||||
import json, math, sys
|
||||
|
||||
d = json.load(open(sys.argv[1]))
|
||||
out = open(sys.argv[2], 'w')
|
||||
P = lambda *a: print(*a, file=out)
|
||||
|
||||
FRAME_EVENTS = ('RenderLevelStageEvent', 'RenderFrameEvent', 'RenderGuiEvent', 'RenderGuiLayerEvent',
|
||||
'CustomizeGuiOverlayEvent', 'RenderLivingEvent', 'RenderPlayerEvent', 'RenderHandEvent',
|
||||
'RenderArmEvent', 'RenderHighlightEvent', 'ViewportEvent', 'RenderNameTagEvent',
|
||||
'RenderBlockScreenEffectEvent', 'RenderTooltipEvent', 'ComputeFovModifierEvent',
|
||||
'WorldRenderEvents', 'HudRenderCallback', 'HudLayerRegistrationCallback', 'ClientGuiEvent', 'GuiDrawEvent')
|
||||
TICK_EVENTS = ('ClientTickEvent', 'PlayerTickEvent', 'EntityTickEvent', 'LevelTickEvent', 'ClientTickHandler')
|
||||
|
||||
|
||||
def base_events(r, names):
|
||||
return sorted({e.split('$')[0] for e in r['events'] if e.split('$')[0] in names})
|
||||
|
||||
|
||||
rows = []
|
||||
for slug, r in d.items():
|
||||
if r['dir'] != 'mods':
|
||||
continue
|
||||
mx = r['mixins']
|
||||
client_side = r['side'] != 'server'
|
||||
t = r['tex']
|
||||
a = r['assets']
|
||||
fi = r['frame_injectors']
|
||||
rows.append(dict(
|
||||
slug=slug, side=r['side'],
|
||||
mix=sum(mx.values()), cmix=mx.get('client', 0),
|
||||
frame=r['frame_mixins'],
|
||||
risky=fi.get('Overwrite', 0) + fi.get('Redirect', 0),
|
||||
fev=base_events(r, FRAME_EVENTS), tev=base_events(r, TICK_EVENTS),
|
||||
atlas=t.get('atlas_px', 0), atlas_n=t.get('atlas_n', 0), anim=t.get('animated_n', 0),
|
||||
ent_px=t.get('entity_px', 0), tex_mb=t.get('bytes', 0) / 1e6,
|
||||
models=a.get('models', 0), bstates=a.get('blockstates', 0),
|
||||
shaders=a.get('shader_files', 0), post=a.get('post_effects', 0), core=len(r['core_shader_overrides']),
|
||||
geo=a.get('geckolib_files', 0), ogg_mb=a.get('ogg_bytes', 0) / 1e6, particles=a.get('particle_defs', 0),
|
||||
ents=r['lang'].get('entity', 0), blocks=r['lang'].get('block', 0), items=r['lang'].get('item', 0),
|
||||
jar_mb=r['jar_bytes'] / 1e6, client_side=client_side,
|
||||
top=', '.join(f'{k}×{v}' for k, v in sorted(r['frame_targets'].items(), key=lambda kv: -kv[1])[:6]),
|
||||
))
|
||||
|
||||
cl = [r for r in rows if r['client_side']]
|
||||
SP = 256 # one 16x16 sprite
|
||||
|
||||
P('# Static client-perf surface scan')
|
||||
P()
|
||||
P('Generated by `scripts/jarscan.py` + `scripts/report.py` from the exact jars pinned in `mods/*.pw.toml`.')
|
||||
P('All numbers are static (what the jar *contains*), not runtime cost. They tell us where cost *can* come from.')
|
||||
P()
|
||||
P('- **frame mixins** = mixin classes whose `@Mixin` target is on a per-frame path (LevelRenderer, GameRenderer, entity/item/block-entity renderers, Gui, ParticleEngine, blaze3d, Sodium, Iris, Camera).')
|
||||
P('- **risky** = `@Overwrite`/`@Redirect` injectors inside those frame mixins (replace vanilla logic; most likely to conflict with Sodium/Iris/ModernFix).')
|
||||
P('- **frame events** = NeoForge/Architectury/Fabric per-frame event types referenced by the mod’s classes.')
|
||||
P('- **atlas sprites** = block+item textures in 16×16-sprite equivalents (what lands in `blocks.png-atlas`, mipmapped, in VRAM).')
|
||||
P()
|
||||
|
||||
tot_atlas = sum(r['atlas'] for r in cl)
|
||||
tot = lambda k: sum(r[k] for r in cl)
|
||||
P('## Pack totals (client-loaded mods)')
|
||||
P()
|
||||
P('| metric | value |')
|
||||
P('| --- | --- |')
|
||||
P(f'| client-loaded mods | {len(cl)} (server-only: {len(rows) - len(cl)}) |')
|
||||
P(f'| mixin classes (client-loaded) | {tot("mix")} |')
|
||||
P(f'| frame-path mixin classes | {tot("frame")} |')
|
||||
P(f'| frame-path @Overwrite/@Redirect | {tot("risky")} |')
|
||||
P(f'| mods hooking ≥1 per-frame event | {sum(1 for r in cl if r["fev"])} |')
|
||||
P(f'| mods hooking client/player/entity tick events | {sum(1 for r in cl if r["tev"])} |')
|
||||
P(f'| block+item textures | {tot("atlas_n")} files, {tot_atlas / 1e6:.1f} Mpx ≈ {tot_atlas // SP} 16×16 sprites |')
|
||||
side = 2 ** math.ceil(math.log2(math.sqrt(tot_atlas))) if tot_atlas else 0
|
||||
P(f'| implied blocks atlas (lower bound, + vanilla) | ≥ {side}×{side if side * side // 2 < tot_atlas else side // 2} ; ×4 B ×1.33 mip ≈ {side * side * 4 * 1.33 / 2**20:.0f} MiB VRAM at the square size |')
|
||||
P(f'| animated block/item textures | {tot("anim")} |')
|
||||
P(f'| entity textures | {tot("ent_px") / 1e6:.1f} Mpx |')
|
||||
P(f'| models / blockstates | {tot("models")} / {tot("bstates")} |')
|
||||
P(f'| entity types (lang proxy) | {tot("ents")} |')
|
||||
P(f'| blocks / items (lang proxy) | {tot("blocks")} / {tot("items")} |')
|
||||
P(f'| geckolib geo+animation files | {tot("geo")} |')
|
||||
P(f'| vanilla core-shader overrides | {", ".join(r["slug"] for r in cl if r["core"]) or "none"} |')
|
||||
P(f'| post-effect files | {", ".join(r["slug"] + f"({r['post']})" for r in cl if r["post"]) or "none"} |')
|
||||
P()
|
||||
|
||||
|
||||
def table(title, key, cols, n=25, filt=lambda r: True, note=''):
|
||||
rs = sorted((r for r in cl if filt(r) and r[key]), key=lambda r: -r[key])[:n]
|
||||
P(f'## {title}')
|
||||
P()
|
||||
if note:
|
||||
P(note)
|
||||
P()
|
||||
P('| mod | side | ' + ' | '.join(c[0] for c in cols) + ' |')
|
||||
P('| --- | --- | ' + ' | '.join('---:' if c[2] else '---' for c in cols) + ' |')
|
||||
for r in rs:
|
||||
P(f'| {r["slug"]} | {r["side"]} | ' + ' | '.join(c[1](r) for c in cols) + ' |')
|
||||
P()
|
||||
|
||||
|
||||
table('Per-frame mixin surface', 'frame', [
|
||||
('frame mixins', lambda r: str(r['frame']), 1),
|
||||
('risky', lambda r: str(r['risky']), 1),
|
||||
('all mixins', lambda r: str(r['mix']), 1),
|
||||
('hottest targets', lambda r: r['top'], 0)], n=40)
|
||||
|
||||
P('## Per-frame event subscribers')
|
||||
P()
|
||||
P('| mod | side | frame events | tick events |')
|
||||
P('| --- | --- | --- | --- |')
|
||||
for r in sorted((r for r in cl if r['fev']), key=lambda r: (-len(r['fev']), r['slug'])):
|
||||
P(f'| {r["slug"]} | {r["side"]} | {", ".join(r["fev"])} | {", ".join(r["tev"])} |')
|
||||
P()
|
||||
|
||||
table('Block/item atlas pressure', 'atlas', [
|
||||
('sprites (16² eq.)', lambda r: f'{r["atlas"] // SP:,}', 1),
|
||||
('files', lambda r: str(r['atlas_n']), 1),
|
||||
('animated', lambda r: str(r['anim']), 1),
|
||||
('% of pack', lambda r: f'{100 * r["atlas"] / tot_atlas:.1f}', 1),
|
||||
('models', lambda r: str(r['models']), 1),
|
||||
('blockstates', lambda r: str(r['bstates']), 1)], n=30)
|
||||
|
||||
table('Animated atlas textures (uploaded every tick unless culled by Sodium)', 'anim', [
|
||||
('animated', lambda r: str(r['anim']), 1)], n=20)
|
||||
|
||||
table('Entity content (renderer + animation load)', 'ents', [
|
||||
('entity types', lambda r: str(r['ents']), 1),
|
||||
('entity tex Mpx', lambda r: f'{r["ent_px"] / 1e6:.2f}', 1),
|
||||
('geckolib files', lambda r: str(r['geo']), 1)], n=30)
|
||||
|
||||
table('Shader assets', 'shaders', [
|
||||
('shader files', lambda r: str(r['shaders']), 1),
|
||||
('post effects', lambda r: str(r['post']), 1),
|
||||
('core overrides', lambda r: str(r['core']), 1)], n=30)
|
||||
|
||||
table('Sound payload', 'ogg_mb', [('ogg MB', lambda r: f'{r["ogg_mb"]:.1f}', 1)], n=15)
|
||||
table('Jar size', 'jar_mb', [('MB', lambda r: f'{r["jar_mb"]:.1f}', 1)], n=20)
|
||||
|
||||
P('## Resource packs / shader packs')
|
||||
P()
|
||||
for slug, r in d.items():
|
||||
if r['dir'] == 'mods':
|
||||
continue
|
||||
t, a = r['tex'], r['assets']
|
||||
P(f'- **{slug}** ({r["side"]}): atlas sprites {t.get("atlas_px", 0) // SP:,}, entity tex {t.get("entity_px", 0) / 1e6:.2f} Mpx, '
|
||||
f'models {a.get("models", 0)}, blockstates {a.get("blockstates", 0)}, CEM/EMF files {a.get("cem_files", 0)}, shader files {a.get("shader_files", 0)}')
|
||||
P()
|
||||
Reference in new issue
Block a user