Files
logan 611a1a5952 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.
2026-10-14 18:15:32 -04:00

147 lines
7.2 KiB
Python
Raw Permalink 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
"""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()