From 60e10b35a3fca9fdddb322e9c60fa0177253e935 Mon Sep 17 00:00:00 2001 From: Logan Date: Sat, 17 Oct 2026 22:28:20 -0400 Subject: [PATCH] fix(bench): reset instance JVM settings for sessions without --jvm apply_jvm wrote instance.cfg and never restored it, so B5-B12a in run 1 ran on B4's last set (Shenandoah, 6 GB). Within-block verdicts hold (same JVM on both sides); B7's no-shader baseline is re-run in run 2. Also: B14 spawn-count automation (b14/: fresh seeded world per run, offline-located biome sites, datapack entity counts, journaled config variants) and RESULTS-proxy.md with run-1 verdicts. --- analysis/client-perf/RESULTS-proxy.md | 102 +++ analysis/client-perf/harness/b14/README.md | 169 ++++ analysis/client-perf/harness/b14/b14.py | 855 ++++++++++++++++++ analysis/client-perf/harness/b14/nbt.py | 162 ++++ analysis/client-perf/harness/b14/sites.json | 49 + .../client-perf/harness/b14/tests/test_b14.py | 413 +++++++++ analysis/client-perf/harness/bench.py | 12 + .../harness/tests/test_bench_flags.py | 11 + 8 files changed, 1773 insertions(+) create mode 100644 analysis/client-perf/RESULTS-proxy.md create mode 100644 analysis/client-perf/harness/b14/README.md create mode 100644 analysis/client-perf/harness/b14/b14.py create mode 100644 analysis/client-perf/harness/b14/nbt.py create mode 100644 analysis/client-perf/harness/b14/sites.json create mode 100644 analysis/client-perf/harness/b14/tests/test_b14.py diff --git a/analysis/client-perf/RESULTS-proxy.md b/analysis/client-perf/RESULTS-proxy.md new file mode 100644 index 0000000..5b37935 --- /dev/null +++ b/analysis/client-perf/RESULTS-proxy.md @@ -0,0 +1,102 @@ +# Bench results — proxy machine, run 1 (B0–B12a) + +**Proxy data only.** One machine (Ryzen 9 9900X / RX 7800 XT, Manjaro), not the fleet (P0 Deck, +P1 7700K+1060, P2 6700K+1080 Ti). Run 1: 2026-10-16 22:25 → ~2026-10-17 05:00 EDT, 56 sessions +(57 launches incl. one auto-retry), 306/309 capture windows valid. Source: +`~/onion-bench/results/session.jsonl` (`ts >= 2026-10-16T22:24`), verdicts from +`harness/analyze.py --baseline C1 --noise B0 --since 2026-10-16T22:24`. +Run 2 (B4h, B7 shader rows, B12b) is in progress; B13/B14 are manual and not done. + +## Method + +- **Harness**: `harness/bench.py` + `matrix.py`. Every session is one cold launch at + 1920×1080 in headless gamescope, world restored from `bench-world-v1*.zip` (DH LODs + pre-generated, `generatorPlan=DISABLED`), scenes driven by an in-world datapack, spectator + camera, FTB Backups off, dedicated server stopped. +- **Captures**: per scene 15 s warm-up + 30 s capture (S5 tour 120 s, no warm-up) × 3 reps, + 45 s settle after join. Frametimes from MangoHud; metrics = p50/p99 frametime, 1 % low + (frametime-based), avg fps. +- **Win rule (BENCH-MATRIX §4)**: win = median p99 improves by more than the B0 p99 spread + AND 1 % low doesn't drop by more than the B0 1 %-low spread; loss = worse beyond either; + else tie (ties keep the current/upstream value). +- **Noise floor (B0, C0 × 3 sessions on S1)**: p99 spread **0.40 ms**, 1 %-low spread + **6.6 fps** (p99 9.64 ms, 1 % low 88.3 fps). +- **Baselines**: each variant is compared with the C1 session(s) of its own block + (`C1@B3`, `C1@B11.baseline`, …), i.e. same JVM and same time window. + +## Highlights + +- **The shipped tuning (C1) beats upstream defaults (C0) on every scene.** C0 (B1) vs C1 + (B2), same JVM (Prism default, 8 GB): + + | Scene | p99 C0 → C1 (ms) | 1 % low C0 → C1 (fps) | avg fps C0 → C1 | + |---|---|---|---| + | S1 vista | 9.33 → 8.23 | 93.0 → 104.9 | 218 → 272 | + | S2 Create floor | 3.79 → 3.22 | 218.7 → 259.8 | 694 → 975 | + | S3 mob pen | 7.43 → 4.46 | 110.1 → 186.0 | 255 → 395 | + | S4 airships | 2.93 → 2.44 | 298.0 → 341.3 | 789 → 1073 | + | S5 tour | 3.47 → 2.95 | 194.2 → 223.8 | 788 → 1017 | + + Startup ("Game took"): C0 31.38 s → C1 25.36 s. Atlas: C0 4096×4096×4 → C1 4096×4096×2. +- **Distant Horizons is the biggest single render cost.** Turning DH off (B3.dh-off) gives + S1 p99 7.99 → 5.38 ms, 1 % low 105.5 → 144.0; S5 3.09 → 2.34 ms, 211.2 → 282.4. + DH's LOW quality preset at C1's radius 128 (`dh-qlow`) is a **win** on S1 (−0.96 ms, + +9.5 fps). SSAO costs 1.25 ms p99 on S1. +- **Fresh Animations is expensive.** S3 p99 4.32 → 8.65 ms (FA) / 10.20 ms (FA + + Extensions); 1 % low 184.6 → 104.9 / 89.0. It is opt-in (not enabled by default) — keep + it that way and document the cost. +- **The added performance mods earn their place.** Removing BadOptimizations, Particle Core + or Sodium Extra each loses (Particle Core / Sodium Extra: S3 1 % low −24 / −25 fps). +- **ModernFix `dynamic_resources` (B6)**: startup 7.6 s faster (26.32 vs 33.92 s median) and + better frametimes with it on. Ship is gated only on the manual missing-texture check + (see B6 row). + +## Per-block verdicts + +Numbers are p99 ms / 1 % low fps; Δ is vs the block's C1 baseline (+ = better p99). + +| Block | Change tested | Result | Verdict | Ship action | +|---|---|---|---|---| +| B0 | noise floor, C0 × 3 on S1 | p99 9.64 (spread 0.40), 1 % low 88.3 (spread 6.6) | — | thresholds for all blocks | +| B1 | C0 baseline, S1–S5 | see highlights table; startup 31.38 s | reference | — | +| B2 | C1 vs C0 | C1 wins S1–S5 (table above) | **win** | C1 stays shipped; no bisect needed | +| B2 | C1-deck vs C1 | S1 7.08/115.0 vs 8.23/104.9 (win); S2/S4/S5 tie; S3 4.84/167.5 vs 4.46/186.0 (loss) | mixed | keep Deck tier as is (S3 loss: check `entityShadows`/particles on fleet P0) | +| B2 | C1-high vs C1 | S1 tie; S2–S5 losses (e.g. S4 2.66/311.6 vs 2.44/341.3) | expected cost | keep High as opt-in quality tier | +| B3 | DH radius 64 | S1 7.46/111.1 vs 7.99/105.5 (win); S5 tie | win S1 | already the Deck value; no change to C1 | +| B3 | DH radius 192 / 256 | S1 tie (7.69/105.5, 8.07/101.5); S5 tie | tie | **decision**: on this GPU 256 (upstream) ties 128; fleet floors decide — keep 128 until fleet data | +| B3 | DH LOW quality preset @ r128 (`dh-qlow`) | S1 7.03/115.0 vs 7.99/105.5 (+0.96, +9.5) | **win** (S1 only) | candidate for DEFAULT after an S5 run + visual check | +| B3 | DH SSAO on | S1 9.24/95.3 (−1.25, −10.2) | loss | confirms C1 SSAO off | +| B3 | DH threads 4 | S1 tie; S5 3.32/197.4 vs 3.09/211.2 (loss) | loss | threads stay 2 | +| B3 | DH off (reference) | S1 5.38/144.0; S5 2.34/282.4 | — | cost reference only | +| B4 | GC J0/J1/J2/J3 @ 6 GB, S2+S5 | S5: J0 3.07/226.8, J1 3.11/228.1 (tie), J2 3.40/216.6 (loss), J3 3.75/173.4 (loss); S2 all tie | **J0** | keep J0; heap ladder = B4h (run 2) | +| B5 | Sable ships 0/1/4 (proxy) | S4 3.00/280.6, 3.12/261.6, 2.97/273.2 | — | ships nearly free on this GPU | +| B5 | SSRD 32/64/128, sable-noshade (4 ships) | 3.02/265.0, 3.03/264.7, 3.11/261.9, 3.04/263.0 vs pooled C1 (−8 to −11 fps 1 % low) | loss/noise | keep C1 (SSRD 16, shading on); decay run deferred (fleet P0/P1) | +| B6 | ModernFix dynamic_resources off vs on (C1) | S1 9.11/83.8 vs 8.48/98.6; S2 3.29/236.1 vs 3.18/264.0; startup 33.92 vs 26.32 s | dynres on **wins** | ship `modernfix-mixins.properties` **after** the manual missing-texture check | +| B7 | shaders ×4 profiles | no-shader baseline only (S1 8.24/105.3, S2 3.21/266.9, S4 2.53/326.0) | pending run 2 | — | +| B8 | Fresh Animations (+Ext) on S3 | 8.65/104.9 (FA), 10.20/89.0 (FA+Ext) vs 4.32/184.6 | large cost | keep opt-in; document in PLAYER-TUNING | +| B8 | EMF animation LOD 20 / 8 (with FA) | vs C1-fa (LOD 12) 8.65/104.9: LOD 20 8.91/101.8 (tie), LOD 8 8.16/108.0 (+0.49, +3.1 → win) | LOD 8 wins under FA | optional: LOD 8 for FA users (PLAYER-TUNING note) | +| B8 | LambDynamicLights fancy / fastest | 4.41/181.8, 4.35/187.7 vs 4.32/184.6 | tie | keep C1 `fast` | +| B9 | Flywheel light flat / smooth (S2) | 3.45/246.6 (tie), 3.46/238.9 (−8.6, loss) vs 3.44/247.5 | tie/loss | keep C1 TRI_LINEAR | +| B10 | Sound Physics rays up / strict / no caps (S2s) | 3.69/216.2 (loss), 3.58/233.7 (tie), 3.64/216.9 (loss) vs 3.52/237.6 | loss/tie | keep C1 | +| B11 | Sodium 3 builder threads, blend 0/2, mipmaps 4 | all losses except blend 2 (S5 tie); e.g. mip4 S1 8.82/97.6 vs 8.12/104.2 | loss/tie | keep C1 | +| B12a | remove BadOptimizations / Particle Core / Sodium Extra | each loses ≥1 scene (e.g. S3 3.93/205.3, 4.63/188.4, 4.61/187.5 vs 3.87/212.5) | keep all | no change | +| B4h, B7 shader rows, B12b | heap ladder, shader profiles, trial mods | — | **pending run 2** | — | +| B13, B14 | spark profile; spawn-tuning counts | — | **pending (manual)** | — | + +## Caveats + +- **Single proxy machine.** Fleet machines (P0–P2) were unreachable; the fleet fps floors + (P0 1 % low ≥ 30, P1/P2 ≥ 60) don't map to a 7800 XT — every verdict here is relative. + B2's "C1 wins on ≥ 2 machines" gate is satisfied on one machine only. +- **This GPU hides render costs.** Airships (B5), Flywheel lighting and DH radius are cheap + here; the fleet's older GPUs may rank them differently. B5 decay is deferred to P0/P1. +- **GPU clocks unpinned** (amdgpu DPM `auto`, no root); every row carries `gpu_dpm_auto`. +- **JVM differed between block groups (harness bug).** B0–B3 ran Prism's default args at + 8 GB; B4 set each GC at 6 GB; `bench.py` never restored `instance.cfg`, so **B5–B12a all + ran on J3 Shenandoah, 6 GB**. Every verdict compares sessions inside one block (same JVM), + so verdicts stand, but absolute numbers aren't comparable across those groups and the B0 + noise floor was measured under the other JVM. +- **One crash**: B10 `spr-nocap`, first attempt — `OutOfMemoryError: Ran out of memory + reading level data` after an unusually slow (~1.5 min) resource load, on the leaked + Shenandoah/6 GB JVM. The automatic retry was clean. +- Single sessions per variant (except B0/B6, 3 each); 3 reps per scene per session. diff --git a/analysis/client-perf/harness/b14/README.md b/analysis/client-perf/harness/b14/README.md new file mode 100644 index 0000000..2dcd2f4 --- /dev/null +++ b/analysis/client-perf/harness/b14/README.md @@ -0,0 +1,169 @@ +# B14: spawn-tuning measurement + +`b14.py` automates SPAWN-TUNING.md §B14. It compares the upstream spawn defaults with the +shipped partial files (`d80f85b`, `8e8483d`: `alexsmobs-common.toml`, `fowlplay.json5`, +`crittersandcompanions-common.json5`). Each comparison stands still for 10 minutes at a +river, a jungle and a warm ocean, and counts entities within 128 blocks. + +It reuses `../bench.py` as a module: `launch`, `kill_client`, `LogTail`, `monitor`, the +`Journal` / `restore_pending` crash-safe restore, `apply_pins`, `apply_dh`, and +`zip_world_root`. It needs nothing beyond the Python 3 standard library. + +## Loop + +```sh +cd analysis/client-perf/harness +python3 b14/b14.py run --config defaults --dry-run # world, config plan, datapack +python3 b14/b14.py run --config defaults # ~32 min, all three biomes +python3 b14/b14.py run --config shipped +python3 b14/b14.py summary # SPAWN-TUNING §B14 verdict +``` + +- `--biome river,jungle,warm_ocean` (default `all`) runs the sites in that order in one + launch. +- `--minutes 10` (default) sets the stay at each site. +- Repeating runs (for example two of each config, interleaved + defaults → shipped → defaults → shipped) gives medians. The summary takes the median + across runs. +- Results go to `~/onion-bench/results/b14.jsonl`. Each count line is a `b14_count` row, + and each launch is a `b14_session` row (outcome, config check, git/mods hash, sites, + archived log at `results/logs/session-.log`). +- `b14.py restore` (or `bench.py restore`, which uses the same journal) undoes a + hard-killed run. +- `b14.py parse LOG` prints the count rows found in any archived log. + +The script refuses to start in these cases: +- a restore journal is pending; +- a bench client is running; +- any `matrix.py run`, `bench.py session` or other `b14.py run` process exists. It shares + the OnionBench instance with the matrix, so **never run it while the matrix runs.** + +## Design + +### Throwaway world `saves/onionspawn`, one per run +- It contains only the bench world's `level.dat` (seed `onionbench`, gamerules, datapack + list), the `onion-bench` datapack (it has no load or tick tags) and `serverconfig/`. +- It has no `region/`, `entities/` or `data/` (no forceloads, no DH database). Every chunk + around a site is therefore generated during the run, under the config being measured. + That covers both kinds of spawning: the initial creatures placed at world generation + and natural spawning. +- The seed is the same, so the terrain is identical between runs. The world is journaled + as a `tree` entry and deleted on restore. The bench world `saves/onionbench` is never + touched. + +### Sites are pre-located offline (`b14.py locate`) +`/locate biome` can't be used from a datapack. Functions run with suppressed output, and +`execute store` only captures the distance, not the coordinates. + +So `locate` parses the overworld region files in `bench-world-v1.zip` with a small NBT +reader (`nbt.py`): +- It reads each chunk's biome palette at the surface, using the `MOTION_BLOCKING_NO_LEAVES` + heightmap in full chunks and `WORLD_SURFACE_WG` in proto chunks. +- For each target biome it picks the chunk whose 5×5 neighbourhood has the largest share + of that biome. +- Sites must be at least 192 blocks from the world spawn and the saved player position + (spawn chunks and the first-load area stay outside the 128-block count sphere), and at + least 320 blocks from each other (the count spheres don't overlap). + +Biomes depend only on the seed, so the coordinates are valid for any world with this seed +and mod set. They are committed in `sites.json` (from zip sha256 +`144caf5f…`, seed −306676217): + +| biome | x y z | biome at site | share in 5×5 chunks | +|---|---|---|---| +| river | 904 63 88 | minecraft:river | 0.37 | +| jungle | −8 107 −201 | minecraft:jungle | 0.52 | +| warm_ocean | −200 63 56 | minecraft:warm_ocean | 0.75 | + +Every count line carries `biome_ok`, an in-game `execute if biome` check at the player's +feet (`minecraft:river`, `#minecraft:is_jungle`, `minecraft:warm_ocean`). If the +regenerated terrain doesn't match the offline lookup, the summary marks that biome +invalid instead of silently measuring the wrong place. + +### Config variants (journaled, bench instance only) +- `defaults` deletes the three files. Each mod regenerates its upstream defaults on load + (the same as "these three config files removed" in SPAWN-TUNING). They aren't in + `config/defaultoptions/extra/`, so Default Options can't put them back. +- `shipped` writes `git show :config/` (default `HEAD`), meaning the committed + partials, not the working tree's boot re-saves. +- After the run, and before the restore, the files the mods re-saved are read back and + checked against the expected values. A mismatch fails the run (`config_check.ok=false`), + and the summary ignores that run. The expected upstream defaults were verified from the + jars: + - Alex's Mobs: the `# Default:` comments in its re-saved TOML: gazelle 40, capuchin 28, + elephant 30, straddler 70, rocky roller 60, underminer 50, blobfish 30, + mudskipper 28, caiman 29. + - Fowl Play: `FPConfig.` bytecode: duck 4/max 6, goose 4/4, gull 5/8, + sparrow 20/6. + - Critters and Companions: `CACCommonConfig$SpawningConfig.`: sea bunny warm + ocean 32 (max 4), other oceans 16; leaf insect jungle 14, forest 6; dragonfly lush 10. + +### Datapack (`saves/onionspawn/datapacks/b14-autoplan`, pack_format 48) +- **Start-up:** a load tag polls once a second until a player exists. Then it pins the + rules that matter for spawning: + - `gamemode creative` (it counts for spawning and despawning, where spectators don't, + and the player can't die); + - `doMobSpawning true`; + - the daylight, weather, patrol, trader, insomnia and warden rules; + - time 6000 and clear weather. +- **Per site:** + 1. Teleport above the site. + 2. 5 s later: clear a 3×3×3 pocket, put a barrier at y−1 (a platform over water), and + teleport onto it. + 3. Count at t=0 (+10 s), then every 60 s up to t=10. Each count re-asserts the barrier + and the teleport, so the player never drifts. +- **Each count** runs `execute store result score # b14 if entity + @e[type=,distance=..128]` as the player, for the 22 tuned species and 10 vanilla + spot-check species. It also stores: + - `total` = `@e[type=!player,distance=..128]`, as SPAWN-TUNING defines it; + - `nonitem`, which also excludes items and XP orbs; + - `tuned`, the sum of the tuned species. +- **Output:** a `tellraw` prints one line, which the client logs as `[System] [CHAT]`: + `B14 t= biome_ok=1 total=N nonitem=N tuned=N alexsmobs:gazelle=N ...`. +- **Errors:** `Failed to load function b14:` in the log (an unknown entity id makes the + whole function unloadable) aborts the run as `datapack_error`. + +### Entity ids +All are checked against the jars in the bench instance. Each has an `entity..` +lang key, and is either in the mod's registry class strings (`CACEntities`) or has a +`loot_table/entities/.json`. +- **Alex's Mobs** (`alexsmobs-1.22.17`): `gazelle`, `capuchin_monkey`, `elephant`, + `straddler`, `rocky_roller`, `underminer`, `blobfish`, `mudskipper`, `caiman`. +- **Fowl Play** (`fowlplay-1.2.3`): `duck`, `goose`, `gull`, `sparrow`. +- **Critters and Companions** (`crittersandcompanions 2.7.0`): `leaf_insect`, `sea_bunny`, + `dragonfly`, `ladybug`, `stag_beetle`, `roly_poly`, `snail`, `stick_bug`, `weevil`. + +### Verdict (`summary`, SPAWN-TUNING §B14) +- **Per biome**, using the final count (t = minutes): + - **keep** if the median `total` drops under shipped *and* no tuned species that + appeared at any minute under defaults is absent under shipped; + - **back off** (listing the species) if one disappears; + - **no drop** otherwise. +- **Warnings:** `rare_warn` flags species whose shipped maximum is under 25 % of the + defaults maximum (when defaults saw at least 4). `vanilla_gone` flags cows or sheep + that vanished. +- **Overall:** `keep` only if all three biomes keep. +- **Excluded runs:** runs with a failed config check or a non-ok outcome are ignored. A + biome with `biome_ok != 1` is `invalid`. + +## Manual prep and limits +- **Prep:** nothing beyond what's committed. `sites.json` is already generated. Re-run + `b14.py locate --out b14/sites.json` only if the seed, the source zip or the + biome-affecting mods (Terralith, Tectonic) change. +- **Thin biome coverage:** the source world has only about 2300 chunks with final biomes, + so the river and jungle patches are small (37 % and 52 % of the 5×5 neighbourhood). The + 128-block count sphere also covers neighbouring biomes. This is the same for both + configs, but it lowers the absolute counts. +- **Better sites (optional):** on `~/onion-bench/server-bench` (seed `onionbench`), run + `/locate biome minecraft:river` (and jungle, warm_ocean), check the share around each + result, and put the coordinates into `sites.json` by hand. The `y` value is the first + free block above the surface or the water. +- **Jungle site from a proto chunk:** its `y` is the height before trees are placed. The + 3×3×3 air pocket keeps a tree from burying the player, and `biome_ok` still guards the + biome. +- **Hostile mobs in `total`:** difficulty stays as in `level.dat` (normal), so hostiles + from caves within 128 blocks count toward `total`. `tuned` and `nonitem` are recorded + separately for reading the result. +- **Machine:** this is proxy-machine data (singleplayer, simulation distance 8 pinned). + Spawning is server logic, so the GPU doesn't matter, but CPU load does affect chunk + generation speed. diff --git a/analysis/client-perf/harness/b14/b14.py b/analysis/client-perf/harness/b14/b14.py new file mode 100644 index 0000000..5542317 --- /dev/null +++ b/analysis/client-perf/harness/b14/b14.py @@ -0,0 +1,855 @@ +#!/usr/bin/env python3 +"""B14 spawn-tuning measurement (SPAWN-TUNING.md §B14), automated. + + b14.py locate [--zip bench-world-v1.zip] [--out sites.json] # offline, once + b14.py run --config {defaults,shipped} [--biome river,jungle,warm_ocean] + [--minutes 10] [--dry-run] + b14.py summary [--file ~/onion-bench/results/b14.jsonl] + b14.py parse LOG # debug: count lines + b14.py restore # = bench.py restore + +One `run` = one client launch into a throwaway world `saves/onionspawn` that holds only +the bench world's level.dat (seed `onionbench`, gamerules, datapack list), so every +chunk around the sites is generated fresh under the config being measured. A generated +datapack sets doMobSpawning true + Creative, parks the player on a barrier block at each +pre-located site (sites.json), and every 60 s prints a parsable chat line +`B14 t= biome_ok=1 total=N =N ...` counted within 128 +blocks. The spawn config variant is journaled through bench.Journal, so `bench.py +restore` undoes a hard-killed run. Stdlib only. +""" +import argparse +import json +import os +import re +import signal +import statistics +import subprocess +import sys +import time +import zipfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(HERE.parent)) +import bench # noqa: E402 (harness module: launch, Journal, LogTail, monitor, ...) +import nbt # noqa: E402 + +WORLD = "onionspawn" +PACK = "b14-autoplan" +SITES_FILE = HERE / "sites.json" +SOURCE_ZIP = bench.BENCH / "worlds" / "bench-world-v1.zip" +RESULTS_FILE = bench.RESULTS / "b14.jsonl" + +# The three shipped partial files (d80f85b, 8e8483d). `defaults` deletes them so each mod +# regenerates its upstream defaults; `shipped` writes the committed partials. +SPAWN_FILES = ["alexsmobs-common.toml", "fowlplay.json5", "crittersandcompanions-common.json5"] + +# Tuned species (verified against the jars' lang keys + registries, see README). +TUNED = [ + ("am_gazelle", "alexsmobs:gazelle"), + ("am_capuchin", "alexsmobs:capuchin_monkey"), + ("am_elephant", "alexsmobs:elephant"), + ("am_straddler", "alexsmobs:straddler"), + ("am_rocky_roller", "alexsmobs:rocky_roller"), + ("am_underminer", "alexsmobs:underminer"), + ("am_blobfish", "alexsmobs:blobfish"), + ("am_mudskipper", "alexsmobs:mudskipper"), + ("am_caiman", "alexsmobs:caiman"), + ("fp_duck", "fowlplay:duck"), + ("fp_goose", "fowlplay:goose"), + ("fp_gull", "fowlplay:gull"), + ("fp_sparrow", "fowlplay:sparrow"), + ("cac_leaf_insect", "crittersandcompanions:leaf_insect"), + ("cac_sea_bunny", "crittersandcompanions:sea_bunny"), + ("cac_dragonfly", "crittersandcompanions:dragonfly"), + ("cac_ladybug", "crittersandcompanions:ladybug"), + ("cac_stag_beetle", "crittersandcompanions:stag_beetle"), + ("cac_roly_poly", "crittersandcompanions:roly_poly"), + ("cac_snail", "crittersandcompanions:snail"), + ("cac_stick_bug", "crittersandcompanions:stick_bug"), + ("cac_weevil", "crittersandcompanions:weevil"), +] +# Vanilla spot-check (SPAWN-TUNING: "cows, sheep still spawn normally") + biome natives. +VANILLA = [ + ("mc_cow", "minecraft:cow"), ("mc_sheep", "minecraft:sheep"), ("mc_pig", "minecraft:pig"), + ("mc_chicken", "minecraft:chicken"), ("mc_parrot", "minecraft:parrot"), + ("mc_ocelot", "minecraft:ocelot"), ("mc_tropical_fish", "minecraft:tropical_fish"), + ("mc_cod", "minecraft:cod"), ("mc_squid", "minecraft:squid"), + ("mc_dolphin", "minecraft:dolphin"), +] +# Species SPAWN-TUNING §B14 names as the point of each biome (reported first). +FOCUS = { + "river": ["fowlplay:duck", "fowlplay:gull"], + "jungle": ["alexsmobs:capuchin_monkey", "crittersandcompanions:leaf_insect"], + "warm_ocean": ["crittersandcompanions:sea_bunny"], +} +BIOMES = { + "river": {"match": {"minecraft:river"}, "check": "minecraft:river"}, + "jungle": {"match": {"minecraft:jungle", "minecraft:sparse_jungle", + "minecraft:bamboo_jungle"}, "check": "#minecraft:is_jungle"}, + "warm_ocean": {"match": {"minecraft:warm_ocean"}, "check": "minecraft:warm_ocean"}, +} + +# Upstream defaults of the keys we check after load (SPAWN-TUNING "default -> new"). +EXPECT_DEFAULTS = { + "alexsmobs-common.toml": { + "gazelleSpawnWeight": 40, "capuchinMonkeySpawnWeight": 28, "elephantSpawnWeight": 30, + "straddlerSpawnWeight": 70, "rockyRollerSpawnWeight": 60, "underminerSpawnWeight": 50, + "blobfishSpawnWeight": 30, "mudskipperSpawnWeight": 28, "caimanSpawnWeight": 29}, + "fowlplay.json5": { + "duckSpawnWeight": 4, "duckMaxGroupSize": 6, "gooseSpawnWeight": 4, + "gooseMaxGroupSize": 4, "gullSpawnWeight": 5, "gullMaxGroupSize": 8, + "sparrowSpawnWeight": 20, "sparrowMaxGroupSize": 6}, + "crittersandcompanions-common.json5": { + "sea_bunny@minecraft:warm_ocean": 32, "leaf_insect@#c:is_jungle": 14, + "leaf_insect@#c:is_forest": 6, "dragonfly@#c:is_lush": 10}, +} + +COUNT_RE = re.compile(r"\bB14 (?Pb14-\d+) (?P[a-z_]+) t=(?P\d+) (?P.*)$") +DONE_RE = re.compile(r"B14_DONE (b14-\d+)") +FUNC_FAIL_RE = re.compile(r"Failed to load function b14:|Unknown function b14:") + + +def log(msg): + print(f"[b14] {msg}", flush=True) + + +# ---------------------------------------------------------------- config variants + +def shipped_texts(rev="HEAD", repo=bench.REPO): + out = {} + for name in SPAWN_FILES: + r = subprocess.run(["git", "-C", str(repo), "show", f"{rev}:config/{name}"], + capture_output=True, text=True) + if r.returncode != 0: + raise RuntimeError(f"git show {rev}:config/{name} failed: {r.stderr.strip()}") + out[name] = r.stdout + return out + + +def render_variant(config, shipped): + """{file name: text to write, or None = delete (mod regenerates defaults)}.""" + if config == "defaults": + return {name: None for name in SPAWN_FILES} + if config == "shipped": + return {name: shipped[name] for name in SPAWN_FILES} + raise ValueError(f"unknown config {config!r}") + + +def _cac_weight(text, mob, spec): + m = re.search(rf'"?{re.escape(mob)}"?\s*:\s*\[(.*?)\]', text, re.S) + if not m: + return None + for entry in m.group(1).split("}"): + if f'"{spec}"' in entry: + w = re.search(r'"?weight"?\s*:\s*(-?\d+)', entry) + return int(w.group(1)) if w else None + return None + + +def extract_values(name, text, keys): + """Values of `keys` from a partial or mod-re-saved spawn file (None = absent).""" + out = {} + for k in keys: + if name.endswith(".toml"): + m = re.search(rf"^\s*{re.escape(k)}\s*=\s*(-?\d+)", text, re.M) + out[k] = int(m.group(1)) if m else None + elif "@" in k: + mob, spec = k.split("@", 1) + out[k] = _cac_weight(text, mob, spec) + else: + m = re.search(rf'"?{re.escape(k)}"?\s*:\s*(-?\d+)', text) + out[k] = int(m.group(1)) if m else None + return out + + +def expected_values(config, shipped): + if config == "defaults": + return {n: dict(v) for n, v in EXPECT_DEFAULTS.items()} + return {n: extract_values(n, shipped[n], EXPECT_DEFAULTS[n]) for n in SPAWN_FILES} + + +def check_config(game, config, shipped): + """Compare what the mods re-saved after load with what the variant should produce.""" + want = expected_values(config, shipped) + got, mismatch = {}, [] + for name in SPAWN_FILES: + p = Path(game) / "config" / name + if not p.is_file() and config == "defaults": + got[name] = dict(want[name]) # never written: the mod ran on its defaults + continue + text = p.read_text(errors="replace") if p.is_file() else "" + got[name] = extract_values(name, text, want[name]) + for k, v in want[name].items(): + if got[name][k] != v: + mismatch.append(f"{name}:{k} want {v} got {got[name][k]}") + return {"ok": not mismatch, "mismatch": mismatch, "values": got} + + +def apply_spawn_config(game, journal, files): + for name, text in files.items(): + rel = f"config/{name}" + journal.backup_file(rel) + p = Path(game) / rel + if text is None: + if p.exists(): + p.unlink() + else: + p.write_text(text) + + +def config_diff(game, files, shipped): + """Key-level plan of the config change (for --dry-run): checked keys now -> after.""" + out = [] + for name, text in files.items(): + p = Path(game) / "config" / name + cur = p.read_text(errors="replace") if p.is_file() else None + keys = EXPECT_DEFAULTS[name] + now = extract_values(name, cur, keys) if cur else {} + if text is None: + out.append(f"--- config/{name}: DELETE ({len(cur)} bytes now)" if cur + else f"--- config/{name}: DELETE (absent now)") + out.append(" mod regenerates upstream defaults on load") + after = keys + else: + out.append(f"--- config/{name}: WRITE shipped partial ({len(text)} bytes)") + after = extract_values(name, shipped[name], keys) + for k in keys: + mark = "" if now.get(k) == after[k] else " <- changes" + out.append(f" {k}: {now.get(k)} -> {after[k]}{mark}") + if text is not None: + out.extend(" | " + ln for ln in text.splitlines()) + return "\n".join(out) + + +# ---------------------------------------------------------------- datapack + +def _score(name): + return {"score": {"name": f"#{name}", "objective": "b14"}} + + +def count_function(): + lines = ["# B14 entity counts around the executing position (the player)", + "scoreboard players set #tuned b14 0"] + for alias, eid in TUNED + VANILLA: + lines.append(f"execute store result score #{alias} b14 " + f"if entity @e[type={eid},distance=..128]") + for alias, _ in TUNED: + lines.append(f"scoreboard players operation #tuned b14 += #{alias} b14") + lines.append("execute store result score #total b14 " + "if entity @e[type=!minecraft:player,distance=..128]") + lines.append("execute store result score #nonitem b14 if entity " + "@e[type=!minecraft:player,type=!minecraft:item," + "type=!minecraft:experience_orb,distance=..128]") + return "\n".join(lines) + "\n" + + +def tellraw(sid, biome, t): + parts = ["", {"text": f"B14 {sid} {biome} t={t} biome_ok="}, _score("biome_ok")] + for key, alias in [("total", "total"), ("nonitem", "nonitem"), ("tuned", "tuned")]: + parts += [{"text": f" {key}="}, _score(alias)] + for alias, eid in TUNED + VANILLA: + parts += [{"text": f" {eid}="}, _score(alias)] + return "tellraw @a " + json.dumps(parts, separators=(",", ":")) + + +def build_timeline(sid, sites, minutes, settle_s, arrive_s=10, gen_s=5): + """[(commands, delay_ticks_to_next)] for the unrolled plan.""" + ns = f"b14:{sid}" + tl = [] + for biome, s in sites: + x, y, z = s["x"], s["y"], s["z"] + pos = f"{x + 0.5} {y} {z + 0.5}" + # proto-chunk sites come from the pre-feature heightmap: clear a 3x3x3 pocket in + # case a tree landed on the column, then stand on a barrier (also over water). + hold = [f"fill {x - 1} {y} {z - 1} {x + 1} {y + 2} {z + 1} minecraft:air", + f"setblock {x} {y - 1} {z} minecraft:barrier", + f"execute in minecraft:overworld run tp @a {pos} 0 0"] + tl.append(([f"execute in minecraft:overworld run tp @a {x + 0.5} {y + 2} {z + 0.5} 0 0"], + gen_s * 20)) + tl.append((hold + [f"say B14_ARRIVE {sid} {biome} {x} {y} {z}"], arrive_s * 20)) + for t in range(minutes + 1): + cmds = hold + [ + f"execute as @a[limit=1] at @s run function {ns}/count", + f"execute as @a[limit=1] at @s store success score #biome_ok b14 " + f"if biome ~ ~ ~ {BIOMES[biome]['check']}", + tellraw(sid, biome, t)] + tl.append((cmds, 1200 if t < minutes else 20)) + tl.append(([f"say B14_DONE {sid}"], 0)) + return tl + + +def plan_seconds(n_sites, minutes, settle_s, arrive_s=10, gen_s=5): + return settle_s + n_sites * (gen_s + arrive_s + minutes * 60 + 1) + + +def generate_datapack(sid, sites, minutes, settle_s): + """{relative path: text}; namespace b14, functions under b14:/.""" + ns, holder = f"b14:{sid}", "#armed" + base = f"data/b14/function/{sid}" + files = { + "pack.mcmeta": json.dumps({"pack": { + "pack_format": 48, + "description": f"Onion B14 spawn plan {sid} (generated by b14.py; " + f"throwaway world)"}}) + "\n", + "data/minecraft/tags/function/load.json": json.dumps({"values": [f"{ns}/load"]}) + "\n", + f"{base}/load.mcfunction": + "scoreboard objectives add b14 dummy\n" + f"execute unless score {holder} b14 matches 1.. run schedule function {ns}/poll 20t\n", + f"{base}/poll.mcfunction": + f"execute if entity @a run function {ns}/begin\n" + f"execute unless entity @a run schedule function {ns}/poll 20t\n", + f"{base}/begin.mcfunction": + f"scoreboard players set {holder} b14 1\n" + # Creative: counts as a spawning/despawning player (spectators don't), and + # can't die. Every rule that changes spawning is pinned, not inherited. + "gamemode creative @a\n" + "gamerule doMobSpawning true\n" + "gamerule doDaylightCycle false\n" + "gamerule doWeatherCycle false\n" + "gamerule doPatrolSpawning false\n" + "gamerule doTraderSpawning false\n" + "gamerule doInsomnia false\n" + "gamerule doWardenSpawning false\n" + "time set 6000\n" + "weather clear\n" + f"say B14_PLAN {sid} begin\n" + f"schedule function {ns}/step0 {max(1, settle_s * 20)}t\n", + f"{base}/count.mcfunction": count_function(), + } + tl = build_timeline(sid, sites, minutes, settle_s) + for i, (cmds, delay) in enumerate(tl): + body = list(cmds) + if delay > 0 and i + 1 < len(tl): + body.append(f"schedule function {ns}/step{i + 1} {delay}t") + files[f"{base}/step{i}.mcfunction"] = "\n".join(body) + "\n" + return files + + +# ---------------------------------------------------------------- world + +def world_members(zip_path): + """Members copied into the throwaway world: level.dat, the bench datapack (it is in + level.dat's enabled list; no load/tick tags) and serverconfig/. No chunks/entities/ + data/ (forceloads, DH db) so every chunk is generated under the measured config.""" + prefix = bench.zip_world_root(zip_path) + with zipfile.ZipFile(zip_path) as z: + names = z.namelist() + keep = [] + for n in names: + rel = n[len(prefix):] + if n.endswith("/") or not n.startswith(prefix): + continue + if rel == "level.dat" or rel.startswith(("datapacks/onion-bench/", "serverconfig/")): + keep.append((n, rel)) + if not any(rel == "level.dat" for _, rel in keep): + raise ValueError(f"{zip_path}: no level.dat") + return keep + + +def build_world(game, journal, zip_path, files): + root = Path(game) / "saves" / WORLD + if root.exists(): + raise ValueError(f"{root} exists (stale B14 world?) - run `b14.py restore` or remove it") + members = world_members(zip_path) + journal.record({"op": "tree", "path": f"saves/{WORLD}"}) + with zipfile.ZipFile(zip_path) as z: + for n, rel in members: + dst = root / rel + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_bytes(z.read(n)) + write_pack(root, files) + return root + + +def write_pack(world_root, files): + d = Path(world_root) / "datapacks" / PACK + for rel, text in files.items(): + p = d / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text) + + +# ---------------------------------------------------------------- locate (offline) + +# Chunk statuses whose biome palette is final (before "biomes" it is a placeholder). +BIOME_STATUSES = {"biomes", "noise", "surface", "carvers", "features", "initialize_light", + "light", "spawn", "full"} + + +def _chunk_info(chunk): + """Surface samples of one chunk, or None. Full chunks use MOTION_BLOCKING_NO_LEAVES + (ground under trees, water surface); proto chunks the worldgen WORLD_SURFACE_WG.""" + status = str(chunk.get("Status", "")).split(":")[-1] + if status not in BIOME_STATUSES: + return None + hm = nbt.heightmap(chunk) or nbt.heightmap(chunk, "WORLD_SURFACE_WG") + if hm is None: + return None + ys = {} + for y in hm: + ys[y] = ys.get(y, 0) + 1 + mode_y = max(ys, key=lambda k: (ys[k], -k)) + samples = [] + for lx, lz in ((2, 2), (2, 10), (10, 2), (10, 10), (8, 8)): + y = hm[lz * 16 + lx] + samples.append(nbt.biome_at(chunk, lx, y - 1, lz)) + col = None # a column at the modal height (ground, not a trunk or hole), near centre + best = None + for lz in range(16): + for lx in range(16): + if hm[lz * 16 + lx] == mode_y: + d = (lx - 8) ** 2 + (lz - 8) ** 2 + if best is None or d < best: + best, col = d, (lx, lz) + centre = nbt.biome_at(chunk, col[0], mode_y - 1, col[1]) + return {"samples": samples, "y": mode_y, "col": col, "centre": centre, + "full": status == "full"} + + +def scan_world(zip_path, stride=1): + """{(cx, cz): info} for every fully generated overworld chunk in the zip.""" + prefix = bench.zip_world_root(zip_path) + out = {} + with zipfile.ZipFile(zip_path) as z: + regions = [i for i in z.infolist() + if i.filename.startswith(prefix + "region/") and i.filename.endswith(".mca") + and i.file_size > 8192] + for info in regions: + rx, rz = (int(v) for v in info.filename.rsplit("/", 1)[1].split(".")[1:3]) + for lx, lz, chunk in nbt.region_chunks(z.read(info.filename)): + if stride > 1 and (lx % stride or lz % stride): + continue + ci = _chunk_info(chunk) + if ci: + out[(rx * 32 + lx, rz * 32 + lz)] = ci + return out + + +def pick_sites(chunks, avoid, min_gap=320, avoid_gap=192, radius=2): + """Best chunk per target biome: centre column in the biome, maximal share of the + biome in the (2r+1)^2 neighbourhood; far from `avoid` points and from each other.""" + chosen = {} + for biome, spec in BIOMES.items(): + match = spec["match"] + cands = [] + for (cx, cz), ci in chunks.items(): + if ci["centre"] not in match: + continue + hit = tot = forest = 0 + for dx in range(-radius, radius + 1): + for dz in range(-radius, radius + 1): + n = chunks.get((cx + dx, cz + dz)) + if not n: + continue + for b in n["samples"]: + tot += 1 + hit += b in match + forest += bool(b) and "forest" in b + if tot < 5 * 9: + continue # fewer than 9 known chunks around it + score = hit / tot + (0.5 * forest / tot if biome == "river" else 0.0) \ + + (0.05 if ci["full"] else 0.0) + x, z = cx * 16 + ci["col"][0], cz * 16 + ci["col"][1] + cands.append((score, x, ci["y"], z, hit / tot, ci["full"], ci["centre"])) + for score, x, y, z, frac, full, centre in sorted(cands, reverse=True): + pts = list(avoid) + [(s["x"], s["z"]) for s in chosen.values()] + gaps = [avoid_gap] * len(avoid) + [min_gap] * len(chosen) + if all((x - px) ** 2 + (z - pz) ** 2 >= g * g for (px, pz), g in zip(pts, gaps)): + chosen[biome] = {"x": x, "y": y, "z": z, "biome": centre, + "frac": round(frac, 3), "score": round(score, 3), + "full_chunk": full, "candidates": len(cands)} + break + return chosen + + +def avoid_points(zip_path): + prefix = bench.zip_world_root(zip_path) + with zipfile.ZipFile(zip_path) as z: + _, ld = nbt.load_gzip(z.read(prefix + "level.dat")) + d = ld.get("Data", {}) + pts = [(d.get("SpawnX", 0), d.get("SpawnZ", 0))] + pos = (d.get("Player") or {}).get("Pos") + if pos: + pts.append((int(pos[0]), int(pos[2]))) + seed = (d.get("WorldGenSettings") or {}).get("seed") + return pts, seed + + +def cmd_locate(a): + t0 = time.time() + chunks = scan_world(a.zip, a.stride) + avoid, seed = avoid_points(a.zip) + sites = pick_sites(chunks, avoid) + hist = {} + for ci in chunks.values(): + hist[ci["centre"]] = hist.get(ci["centre"], 0) + 1 + log(f"scanned {len(chunks)} chunks with final biomes in {time.time() - t0:.0f}s; avoid {avoid}") + for b, n in sorted(hist.items(), key=lambda kv: -kv[1])[:25]: + print(f" {n:6d} {b}") + missing = sorted(set(BIOMES) - set(sites)) + doc = {"source_zip": str(a.zip), "source_sha256": bench.sha256_file(a.zip), + "seed": seed, "avoid": avoid, "stride": a.stride, + "note": "y = first free block (full chunks: MOTION_BLOCKING_NO_LEAVES, proto " + "chunks: WORLD_SURFACE_WG); the player stands on a barrier at y-1. " + "Valid only for this seed + mod set (biomes are seed-deterministic).", + "sites": sites} + print(json.dumps(doc, indent=1)) + if a.out: + Path(a.out).write_text(json.dumps(doc, indent=1) + "\n") + log(f"wrote {a.out}") + if missing: + log(f"NO SITE for {missing}: not in the generated chunks; see README (manual locate)") + return 1 + return 0 + + +# ---------------------------------------------------------------- parse / summary + +def parse_count_line(line): + m = COUNT_RE.search(line) + if not m: + return None + vals = {} + for tok in m.group("rest").split(): + k, sep, v = tok.partition("=") + if sep and re.fullmatch(r"-?\d+", v): + vals[k] = int(v) + return {"sid": m.group("sid"), "biome": m.group("biome"), "t": int(m.group("t")), + "biome_ok": vals.pop("biome_ok", None), "total": vals.pop("total", None), + "nonitem": vals.pop("nonitem", None), "tuned": vals.pop("tuned", None), + "counts": vals} + + +def parse_lines(lines, sid=None): + """Count rows from log lines; the client logs each tellraw once as [CHAT].""" + rows, seen = [], set() + for ln in lines: + r = parse_count_line(ln) + if not r or (sid and r["sid"] != sid): + continue + key = (r["sid"], r["biome"], r["t"]) + if key in seen: + continue + seen.add(key) + rows.append(r) + return rows + + +def load_rows(path): + p = Path(path) + if not p.exists(): + return [], [] + rows = [json.loads(ln) for ln in p.read_text().splitlines() if ln.strip()] + return ([r for r in rows if r.get("kind") == "b14_count"], + [r for r in rows if r.get("kind") == "b14_session"]) + + +def summarize(counts, sessions): + """{biome: {config: stats}} plus verdict per SPAWN-TUNING §B14.""" + bad = {s["sid"] for s in sessions + if s.get("outcome") != "ok" or not (s.get("config_check") or {}).get("ok")} + runs = {} + for r in counts: + if r["sid"] in bad: + continue + runs.setdefault((r["biome"], r["config"], r["sid"]), []).append(r) + out = {} + for (biome, config, sid), rs in runs.items(): + rs.sort(key=lambda r: r["t"]) + final = rs[-1] + st = out.setdefault(biome, {}).setdefault(config, { + "runs": 0, "final_total": [], "final_nonitem": [], "final_tuned": [], + "seen": {}, "final": {}, "biome_ok": True, "minutes": []}) + st["runs"] += 1 + st["minutes"].append(final["t"]) + st["final_total"].append(final["total"]) + st["final_nonitem"].append(final["nonitem"]) + st["final_tuned"].append(final["tuned"]) + st["biome_ok"] &= all(r.get("biome_ok") == 1 for r in rs) + for eid in set().union(*(r["counts"] for r in rs)): + mx = max(r["counts"].get(eid, 0) for r in rs) + st["seen"][eid] = max(st["seen"].get(eid, 0), mx) + st["final"].setdefault(eid, []).append(final["counts"].get(eid, 0)) + tuned_ids = {eid for _, eid in TUNED} + verdicts = {} + for biome, by in out.items(): + d, s = by.get("defaults"), by.get("shipped") + if not (d and s): + verdicts[biome] = {"verdict": "incomplete", + "have": sorted(by)} + continue + dt, stt = statistics.median(d["final_total"]), statistics.median(s["final_total"]) + gone = sorted(e for e in tuned_ids if d["seen"].get(e, 0) > 0 and s["seen"].get(e, 0) == 0) + rare = sorted(e for e in tuned_ids + if d["seen"].get(e, 0) >= 4 and 0 < s["seen"].get(e, 0) < 0.25 * d["seen"][e]) + vanilla_gone = sorted(e for _, e in VANILLA if e in ("minecraft:cow", "minecraft:sheep") + and d["seen"].get(e, 0) > 0 and s["seen"].get(e, 0) == 0) + ok_biome = d["biome_ok"] and s["biome_ok"] + v = "keep" if stt < dt and not gone else ("back off" if gone else "no drop") + verdicts[biome] = {"verdict": v if ok_biome else "invalid (biome check failed)", + "total_defaults": dt, "total_shipped": stt, "disappeared": gone, + "rare_warn": rare, "vanilla_gone": vanilla_gone} + vs = [v["verdict"] for v in verdicts.values()] + overall = ("incomplete" if not vs or len(verdicts) < len(BIOMES) + or any(v not in ("keep", "back off", "no drop") for v in vs) + else "back off" if "back off" in vs else "keep" if all(v == "keep" for v in vs) + else "review") + return out, verdicts, overall + + +def render_summary(out, verdicts, overall): + lines = ["| biome | config | runs | final total | nonitem | tuned | focus (max seen) |", + "|---|---|---|---|---|---|---|"] + for biome in sorted(out): + for config in ("defaults", "shipped"): + st = out[biome].get(config) + if not st: + continue + focus = ", ".join(f"{e.split(':')[1]}={st['seen'].get(e, 0)}" + for e in FOCUS.get(biome, [])) + lines.append(f"| {biome} | {config} | {st['runs']} | " + f"{statistics.median(st['final_total'])} | " + f"{statistics.median(st['final_nonitem'])} | " + f"{statistics.median(st['final_tuned'])} | {focus} |") + lines.append("") + for biome, v in sorted(verdicts.items()): + lines.append(f"- **{biome}**: {v['verdict']}" + + (f" (total {v['total_defaults']} -> {v['total_shipped']}" + f"{'; disappeared ' + ', '.join(v['disappeared']) if v['disappeared'] else ''}" + f"{'; rare ' + ', '.join(v['rare_warn']) if v['rare_warn'] else ''}" + f"{'; vanilla gone ' + ', '.join(v['vanilla_gone']) if v['vanilla_gone'] else ''})" + if "total_defaults" in v else f" (have {v.get('have')})")) + lines.append(f"\n**Overall: {overall}** (keep = totals drop in every biome and no tuned " + f"species that appears under defaults vanishes; SPAWN-TUNING §B14)") + return "\n".join(lines) + + +def cmd_summary(a): + counts, sessions = load_rows(a.file) + if not counts: + log(f"no B14 rows in {a.file}") + return 1 + out, verdicts, overall = summarize(counts, sessions) + print(render_summary(out, verdicts, overall)) + return 0 + + +def cmd_parse(a): + rows = parse_lines(Path(a.log).read_text(errors="replace").splitlines()) + for r in rows: + print(json.dumps(r, sort_keys=True)) + return 0 if rows else 1 + + +# ---------------------------------------------------------------- run + +def _ancestors(pid): + out = set() + while pid > 1 and pid not in out: + out.add(pid) + try: + stat = Path(f"/proc/{pid}/stat").read_text() + pid = int(stat.rsplit(")", 1)[1].split()[1]) + except (OSError, ValueError, IndexError): + break + return out + + +def bench_busy(): + """Other harness processes (matrix runner / bench.py session / another B14 run) that + share the instance; our own process and its parent shells are excluded.""" + mine = _ancestors(os.getpid()) + busy = [] + for d in Path("/proc").iterdir(): + if not d.name.isdigit() or int(d.name) in mine: + continue + try: + args = (d / "cmdline").read_bytes().split(b"\0") + except OSError: + continue + s = " ".join(x.decode(errors="replace") for x in args) + if re.search(r"\bmatrix\.py run\b|\bbench\.py session\b|\bb14\.py run\b", s) \ + and "--dry-run" not in s: + busy.append(int(d.name)) + return busy + + +def load_sites(path, biomes): + doc = json.loads(Path(path).read_text()) + sites = doc.get("sites", {}) + missing = [b for b in biomes if b not in sites] + if missing: + raise ValueError(f"{path}: no site for {missing} (run `b14.py locate` or add by hand)") + return [(b, sites[b]) for b in biomes], doc + + +def cmd_run(a): + biomes = list(BIOMES) if a.biome == "all" else bench.split_list(a.biome) + for b in biomes: + if b not in BIOMES: + sys.exit(f"unknown biome {b!r}; choose from {sorted(BIOMES)}") + try: + sites, sites_doc = load_sites(a.sites, biomes) + shipped = shipped_texts(a.rev) + files = render_variant(a.config, shipped) + world_members(a.source_zip) + except (ValueError, OSError, RuntimeError, zipfile.BadZipFile) as e: + sys.exit(str(e)) + sid = "b14-" + time.strftime("%Y%m%d%H%M%S") + pack = generate_datapack(sid, sites, a.minutes, a.settle) + budget = plan_seconds(len(sites), a.minutes, a.settle) + timeout = a.timeout or int(a.boot_timeout + budget + 180) + game = bench.GAME + if a.dry_run: + log(f"dry run {sid}: config={a.config} rev={a.rev} biomes={biomes} " + f"minutes={a.minutes} plan {budget}s timeout {timeout}s dh={a.dh}") + for b, s in sites: + log(f" site {b}: {s}") + log(f"world saves/{WORLD} <- {a.source_zip}: " + f"{[rel for _, rel in world_members(a.source_zip)][:6]}...") + print(config_diff(game, files, shipped)) + if bench.pending_exists(): + log(f"WARNING: {game / bench.PENDING} exists; a real run would refuse") + busy = bench_busy() + if busy: + log(f"WARNING: harness processes running {busy}; a real run would refuse") + for rel, text in pack.items(): + print(f"--- {PACK}/{rel}") + print(text, end="") + return 0 + bench.refuse_pending() + busy = bench_busy() + if busy: + sys.exit(f"harness processes running (pids {busy}); B14 shares the bench instance") + if bench.server_procs() and not a.allow_server: + log("WARNING: a dedicated server is running (noise only; spawning is client-side)") + env = bench.bench_env() + bench.preflight(a.kill_stale) + signal.signal(signal.SIGTERM, lambda *_: sys.exit(143)) + journal = bench.Journal.begin(game, sid) + prism, state, tail = None, {"done": False, "fail": None}, None + outcome, detail, check, meta, failed = "error", None, None, None, [] + t_launch = time.time() + try: + bench.reset_resource_packs(game) + bench.apply_pins() + bench.apply_dh(game, journal, a.dh == "on") + apply_spawn_config(game, journal, files) + build_world(game, journal, a.source_zip, pack) + meta = {"git": bench.git_state(), "mods": bench.mods_hash(game / "mods"), + "variant": bench.read_text(game / "BENCH_VARIANT.txt")} + before = bench.crash_files() + log(f"{sid}: config={a.config} biomes={biomes} {a.minutes} min, timeout {timeout}s") + t_launch = time.time() + prism = bench.launch(env, WORLD) + tail = bench.LogTail(game / "logs" / "latest.log") + + def on_line(line): + r = parse_count_line(line) + if r and r["sid"] == sid and "[CHAT]" in line: + log(f" {r['biome']} t={r['t']} total={r['total']} tuned={r['tuned']} " + f"biome_ok={r['biome_ok']}") + if DONE_RE.search(line): + state["done"] = True + if FUNC_FAIL_RE.search(line): + state["fail"] = line.strip()[:300] + + outcome, detail, _ = bench.monitor( + tail, prism, before, lambda: state["done"] or state["fail"], timeout, on_line) + if state["fail"]: + outcome, detail = "datapack_error", state["fail"] + finally: + if prism is not None: + bench.kill_client(prism) + if tail is not None: + tail.poll() + try: + check = check_config(game, a.config, shipped) # mods' re-saved files, pre-restore + except OSError as e: + check = {"ok": False, "mismatch": [f"read failed: {e}"], "values": {}} + failed = bench.restore_pending(game) + for e, err in failed: + log(f"RESTORE FAILED {e}: {err}") + if failed: + log(f"journal kept at {game / bench.PENDING}; fix and run `b14.py restore`") + log_path = bench.archive_log(f"session-{sid}") + rows = parse_lines(tail.lines if tail else [], sid) + base = {"sid": sid, "config": a.config, "rev": a.rev, "minutes": a.minutes, + "ts": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(t_launch)), + "machine": bench.MACHINE} + for r in rows: + bench.append_jsonl(RESULTS_FILE, {**base, **r, "kind": "b14_count", + "site": dict(sites)[r["biome"]]}) + bench.append_jsonl(RESULTS_FILE, { + **base, "kind": "b14_session", "outcome": outcome, "detail": detail, + "biomes": biomes, "sites": dict(sites), "sites_sha256": sites_doc.get("source_sha256"), + "config_check": check, "rows": len(rows), "log": log_path, + "git_sha": meta["git"][0] if meta else None, + "git_dirty": meta["git"][1] if meta else None, + "mods_hash": meta["mods"][0] if meta else None, + "mods_count": meta["mods"][1] if meta else None, + "bench_variant": meta["variant"] if meta else None, + "restore_failed": [e for e, _ in failed]}) + log(f"{sid} {outcome}{': ' + detail if detail else ''}; {len(rows)} count rows; " + f"config check {'ok' if check and check['ok'] else check and check['mismatch']}") + want = len(sites) * (a.minutes + 1) + return 0 if outcome == "ok" and len(rows) == want and check and check["ok"] else 1 + + +def cmd_restore(a): + if bench.client_pids(): + sys.exit("bench client is running; close it before restoring") + if not bench.pending_exists(): + log("nothing pending") + return 0 + failed = bench.restore_pending(bench.GAME) + for e, err in failed: + log(f"FAILED {e}: {err}") + return 1 if failed else 0 + + +def build_parser(): + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + r = sub.add_parser("run", help="one client launch: config variant x biome sites") + r.add_argument("--config", required=True, choices=["defaults", "shipped"]) + r.add_argument("--biome", default="all", + help="comma list of river,jungle,warm_ocean (in this order), or all") + r.add_argument("--minutes", type=int, default=10) + r.add_argument("--settle", type=int, default=20, help="seconds after join before site 1") + r.add_argument("--sites", default=str(SITES_FILE)) + r.add_argument("--source-zip", default=str(SOURCE_ZIP), + help="world whose level.dat (seed, gamerules) seeds the throwaway world") + r.add_argument("--rev", default="HEAD", help="git rev of the shipped partial files") + r.add_argument("--dh", choices=["on", "off"], default="off") + r.add_argument("--boot-timeout", type=int, default=300) + r.add_argument("--timeout", type=int, default=None) + r.add_argument("--kill-stale", action="store_true") + r.add_argument("--allow-server", action="store_true", help="silence the server warning") + r.add_argument("--dry-run", action="store_true") + r.set_defaults(fn=cmd_run) + lo = sub.add_parser("locate", help="find biome sites offline from a world zip") + lo.add_argument("--zip", default=str(SOURCE_ZIP)) + lo.add_argument("--out", default=None, help=f"write sites json (e.g. {SITES_FILE})") + lo.add_argument("--stride", type=int, default=1, help="sample every Nth chunk") + lo.set_defaults(fn=cmd_locate) + s = sub.add_parser("summary", help="defaults vs shipped, SPAWN-TUNING §B14 rule") + s.add_argument("--file", default=str(RESULTS_FILE)) + s.set_defaults(fn=cmd_summary) + p = sub.add_parser("parse", help="print count rows found in a log file") + p.add_argument("log") + p.set_defaults(fn=cmd_parse) + rs = sub.add_parser("restore", help="undo a hard-killed run (same journal as bench.py)") + rs.set_defaults(fn=cmd_restore) + return ap + + +def main(argv=None): + a = build_parser().parse_args(argv) + return a.fn(a) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/analysis/client-perf/harness/b14/nbt.py b/analysis/client-perf/harness/b14/nbt.py new file mode 100644 index 0000000..7e197cc --- /dev/null +++ b/analysis/client-perf/harness/b14/nbt.py @@ -0,0 +1,162 @@ +"""Minimal read-only NBT / Anvil region reader (stdlib only) for B14 site finding. + +Enough of the format to read level.dat and 1.18+ chunk biomes + heightmaps: +https://minecraft.wiki/w/NBT_format, /Region_file_format, /Chunk_format. +""" +import gzip +import io +import math +import struct +import zlib + +END, BYTE, SHORT, INT, LONG, FLOAT, DOUBLE, BYTE_ARRAY, STRING, LIST, COMPOUND, \ + INT_ARRAY, LONG_ARRAY = range(13) + + +class _Reader: + def __init__(self, data): + self.b, self.i = data, 0 + + def take(self, n): + s = self.b[self.i:self.i + n] + if len(s) != n: + raise ValueError("truncated NBT") + self.i += n + return s + + def unpack(self, fmt): + size = struct.calcsize(fmt) + return struct.unpack(fmt, self.take(size)) + + def string(self): + (n,) = self.unpack(">H") + return self.take(n).decode("utf-8", "replace") + + def payload(self, t): + if t == BYTE: + return self.unpack(">b")[0] + if t == SHORT: + return self.unpack(">h")[0] + if t == INT: + return self.unpack(">i")[0] + if t == LONG: + return self.unpack(">q")[0] + if t == FLOAT: + return self.unpack(">f")[0] + if t == DOUBLE: + return self.unpack(">d")[0] + if t == BYTE_ARRAY: + (n,) = self.unpack(">i") + return self.take(n) + if t == STRING: + return self.string() + if t == LIST: + et, n = self.unpack(">bi") + return [self.payload(et) for _ in range(max(0, n))] + if t == COMPOUND: + out = {} + while True: + (ct,) = self.unpack(">b") + if ct == END: + return out + name = self.string() + out[name] = self.payload(ct) + if t == INT_ARRAY: + (n,) = self.unpack(">i") + return list(struct.unpack(f">{n}i", self.take(4 * n))) + if t == LONG_ARRAY: + (n,) = self.unpack(">i") + return list(struct.unpack(f">{n}q", self.take(8 * n))) + raise ValueError(f"bad NBT tag type {t}") + + +def loads(data): + """Parse an uncompressed NBT blob -> (root name, root compound).""" + r = _Reader(data) + (t,) = r.unpack(">b") + if t != COMPOUND: + raise ValueError("NBT root is not a compound") + name = r.string() + return name, r.payload(COMPOUND) + + +def load_gzip(data): + """level.dat style (gzip) NBT.""" + return loads(gzip.decompress(data)) + + +def region_chunks(data): + """Yield (local_x, local_z, chunk_compound) for every chunk in an .mca blob.""" + if len(data) < 8192: + return + for idx in range(1024): + off = int.from_bytes(data[idx * 4:idx * 4 + 3], "big") + if off == 0: + continue + start = off * 4096 + if start + 5 > len(data): + continue + length = int.from_bytes(data[start:start + 4], "big") + comp = data[start + 4] + blob = data[start + 5:start + 4 + length] + try: + if comp == 2: + raw = zlib.decompress(blob) + elif comp == 1: + raw = gzip.GzipFile(fileobj=io.BytesIO(blob)).read() + elif comp == 3: + raw = blob + else: # 4 = lz4 (not in vanilla defaults) / 128+ = external .mcc + continue + _, chunk = loads(raw) + except (ValueError, zlib.error, OSError, struct.error): + continue + yield idx % 32, idx // 32, chunk + + +def _unsigned(v): + return v & 0xFFFFFFFFFFFFFFFF + + +def unpack_packed(longs, bits, count): + """1.16+ packed array: entries never span longs.""" + per = 64 // bits + mask = (1 << bits) - 1 + out = [] + for i in range(count): + word = _unsigned(longs[i // per]) + out.append((word >> ((i % per) * bits)) & mask) + return out + + +def section_biome_grid(section): + """64 biome ids (index = (y4*4 + z4)*4 + x4) for one chunk section, or None.""" + b = section.get("biomes") + if not b or not b.get("palette"): + return None + pal = b["palette"] + if len(pal) == 1 or "data" not in b: + return [pal[0]] * 64 + bits = max(1, math.ceil(math.log2(len(pal)))) + return [pal[i] if i < len(pal) else pal[0] for i in unpack_packed(b["data"], bits, 64)] + + +def heightmap(chunk, kind="MOTION_BLOCKING_NO_LEAVES", min_y=-64, height=384): + """256 world-y values (first free block above the top matching block), idx = z*16+x.""" + hm = (chunk.get("Heightmaps") or {}).get(kind) + if not hm: + return None + bits = math.ceil(math.log2(height + 1)) + return [v + min_y for v in unpack_packed(hm, bits, 256)] + + +def biome_at(chunk, x, y, z): + """Biome id at block (x, y, z) in this chunk (x, z may be world coords).""" + sy = y >> 4 + for s in chunk.get("sections", []): + if s.get("Y") == sy: + grid = section_biome_grid(s) + if grid is None: + return None + return grid[(((y & 15) >> 2) * 4 + ((z & 15) >> 2)) * 4 + ((x & 15) >> 2)] + return None diff --git a/analysis/client-perf/harness/b14/sites.json b/analysis/client-perf/harness/b14/sites.json new file mode 100644 index 0000000..dc1dba8 --- /dev/null +++ b/analysis/client-perf/harness/b14/sites.json @@ -0,0 +1,49 @@ +{ + "source_zip": "/home/logan/onion-bench/worlds/bench-world-v1.zip", + "source_sha256": "144caf5fcdfea1a0eac0c787fdcded949e1a67df6ac112400f37be30ce7fb76e", + "seed": -306676217, + "avoid": [ + [ + 0, + 0 + ], + [ + 3000, + -9 + ] + ], + "stride": 1, + "note": "y = first free block (full chunks: MOTION_BLOCKING_NO_LEAVES, proto chunks: WORLD_SURFACE_WG); the player stands on a barrier at y-1. Valid only for this seed + mod set (biomes are seed-deterministic).", + "sites": { + "river": { + "x": 904, + "y": 63, + "z": 88, + "biome": "minecraft:river", + "frac": 0.373, + "score": 0.373, + "full_chunk": false, + "candidates": 17 + }, + "jungle": { + "x": -8, + "y": 107, + "z": -201, + "biome": "minecraft:jungle", + "frac": 0.52, + "score": 0.52, + "full_chunk": false, + "candidates": 19 + }, + "warm_ocean": { + "x": -200, + "y": 63, + "z": 56, + "biome": "minecraft:warm_ocean", + "frac": 0.75, + "score": 0.75, + "full_chunk": false, + "candidates": 31 + } + } +} diff --git a/analysis/client-perf/harness/b14/tests/test_b14.py b/analysis/client-perf/harness/b14/tests/test_b14.py new file mode 100644 index 0000000..24bb7d9 --- /dev/null +++ b/analysis/client-perf/harness/b14/tests/test_b14.py @@ -0,0 +1,413 @@ +"""Offline tests for b14.py (temp dirs only; no game, no git writes).""" +import gzip +import json +import struct +import sys +import tempfile +import unittest +import zipfile +import zlib +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parent)) +sys.path.insert(0, str(HERE.parent.parent)) +import b14 # noqa: E402 +import bench # noqa: E402 +import nbt # noqa: E402 + +AM_PARTIAL = """# partial +[spawning] +\tgazelleSpawnWeight = 20 +\tcapuchinMonkeySpawnWeight = 14 +\telephantSpawnWeight = 15 +\tstraddlerSpawnWeight = 35 +\trockyRollerSpawnWeight = 30 +\tunderminerSpawnWeight = 25 +\tblobfishSpawnWeight = 15 +\tmudskipperSpawnWeight = 14 +\tcaimanSpawnWeight = 15 +""" +FP_PARTIAL = """// partial +{ + "duckSpawnWeight": 2, "duckMaxGroupSize": 4, "gooseSpawnWeight": 2, + "gooseMaxGroupSize": 3, "gullSpawnWeight": 3, "gullMaxGroupSize": 5, + "sparrowSpawnWeight": 12, "sparrowMaxGroupSize": 4 +} +""" +CAC_PARTIAL = """{ + "configVersion": 2, + "spawning": { + "leaf_insect": [ + {"biome_spec": "#c:is_jungle", "weight": 8, "min": 1, "max": 1}, + {"biome_spec": "#c:is_forest", "weight": 4, "min": 1, "max": 1} + ], + "sea_bunny": [ + {"biome_spec": "minecraft:ocean", "weight": 10, "min": 1, "max": 2}, + {"biome_spec": "minecraft:warm_ocean", "weight": 20, "min": 1, "max": 3} + ], + "dragonfly": [ + {"biome_spec": "#c:is_river", "weight": 4, "min": 1, "max": 1}, + {"biome_spec": "#c:is_lush", "weight": 6, "min": 1, "max": 1} + ] + } +} +""" +SHIPPED = {"alexsmobs-common.toml": AM_PARTIAL, "fowlplay.json5": FP_PARTIAL, + "crittersandcompanions-common.json5": CAC_PARTIAL} + +# What the mods write back (re-saved, json5 unquoted keys, tabs) with default values. +AM_RESAVED_DEFAULT = "[spawning]\n" + "".join( + f"\t#Spawn Weight\n\t# Default: {v}\n\t{k} = {v}\n" + for k, v in b14.EXPECT_DEFAULTS["alexsmobs-common.toml"].items()) +FP_RESAVED_DEFAULT = "{\n" + ",\n".join( + f"\t{k}: {v}" for k, v in b14.EXPECT_DEFAULTS["fowlplay.json5"].items()) + "\n}" +CAC_RESAVED_DEFAULT = """{ +\tconfigVersion: 2, +\tspawning: { +\t\tleaf_insect: [ +\t\t\t{ +\t\t\t\tbiome_spec: "#c:is_jungle", +\t\t\t\tweight: 14, +\t\t\t\tmin: 1, +\t\t\t\tmax: 1 +\t\t\t}, +\t\t\t{ biome_spec: "#c:is_forest", weight: 6, min: 1, max: 1 } +\t\t], +\t\tred_panda: [ { biome_spec: "#c:is_jungle", weight: 8, min: 1, max: 2 } ], +\t\tsea_bunny: [ +\t\t\t{ biome_spec: "minecraft:ocean", weight: 16, min: 1, max: 2 }, +\t\t\t{ biome_spec: "minecraft:warm_ocean", weight: 32, min: 1, max: 4 } +\t\t], +\t\tdragonfly: [ +\t\t\t{ biome_spec: "#c:is_river", weight: 4, min: 1, max: 1 }, +\t\t\t{ biome_spec: "#c:is_lush", weight: 10, min: 1, max: 1 } +\t\t] +\t} +}""" + + +class ConfigVariants(unittest.TestCase): + def test_render(self): + d = b14.render_variant("defaults", SHIPPED) + self.assertEqual(set(d), set(b14.SPAWN_FILES)) + self.assertTrue(all(v is None for v in d.values())) + s = b14.render_variant("shipped", SHIPPED) + self.assertEqual(s["fowlplay.json5"], FP_PARTIAL) + with self.assertRaises(ValueError): + b14.render_variant("nope", SHIPPED) + + def test_extract_partial_and_resaved(self): + keys = b14.EXPECT_DEFAULTS + got = b14.extract_values("crittersandcompanions-common.json5", CAC_PARTIAL, + keys["crittersandcompanions-common.json5"]) + self.assertEqual(got["sea_bunny@minecraft:warm_ocean"], 20) + self.assertEqual(got["leaf_insect@#c:is_jungle"], 8) + self.assertEqual(got["dragonfly@#c:is_lush"], 6) + got = b14.extract_values("crittersandcompanions-common.json5", CAC_RESAVED_DEFAULT, + keys["crittersandcompanions-common.json5"]) + self.assertEqual(got, keys["crittersandcompanions-common.json5"]) + self.assertEqual(b14.extract_values("fowlplay.json5", FP_RESAVED_DEFAULT, + keys["fowlplay.json5"]), keys["fowlplay.json5"]) + self.assertEqual(b14.extract_values("alexsmobs-common.toml", AM_RESAVED_DEFAULT, + keys["alexsmobs-common.toml"]), + keys["alexsmobs-common.toml"]) + # "Default: 40" comment lines must not be mistaken for the value + self.assertEqual(b14.extract_values("alexsmobs-common.toml", AM_PARTIAL, + ["gazelleSpawnWeight"]), {"gazelleSpawnWeight": 20}) + + def test_check_config_and_journaled_apply(self): + with tempfile.TemporaryDirectory() as td: + game = Path(td) + (game / "config").mkdir() + originals = {"alexsmobs-common.toml": AM_RESAVED_DEFAULT + "# orig\n", + "fowlplay.json5": FP_RESAVED_DEFAULT} # CAC absent on purpose + for n, t in originals.items(): + (game / "config" / n).write_text(t) + j = bench.Journal.begin(game, "b14-1") + b14.apply_spawn_config(game, j, b14.render_variant("shipped", SHIPPED)) + for n in b14.SPAWN_FILES: + self.assertEqual((game / "config" / n).read_text(), SHIPPED[n]) + self.assertTrue(b14.check_config(game, "shipped", SHIPPED)["ok"]) + chk = b14.check_config(game, "defaults", SHIPPED) + self.assertFalse(chk["ok"]) + self.assertIn("fowlplay.json5:duckSpawnWeight want 4 got 2", chk["mismatch"]) + self.assertEqual(bench.restore_pending(game), []) + for n, t in originals.items(): + self.assertEqual((game / "config" / n).read_text(), t) + self.assertFalse((game / "config" / "crittersandcompanions-common.json5").exists()) + self.assertFalse((game / bench.PENDING).exists()) + + def test_defaults_delete_and_restore(self): + with tempfile.TemporaryDirectory() as td: + game = Path(td) + (game / "config").mkdir() + for n in b14.SPAWN_FILES: + (game / "config" / n).write_text("x" + n) + j = bench.Journal.begin(game, "b14-2") + b14.apply_spawn_config(game, j, b14.render_variant("defaults", SHIPPED)) + self.assertFalse(any((game / "config" / n).exists() for n in b14.SPAWN_FILES)) + self.assertTrue(b14.check_config(game, "defaults", SHIPPED)["ok"]) # absent = defaults + self.assertFalse(b14.check_config(game, "shipped", SHIPPED)["ok"]) + # what the mods write after load + (game / "config" / "alexsmobs-common.toml").write_text(AM_RESAVED_DEFAULT) + (game / "config" / "fowlplay.json5").write_text(FP_RESAVED_DEFAULT) + (game / "config" / "crittersandcompanions-common.json5").write_text(CAC_RESAVED_DEFAULT) + self.assertTrue(b14.check_config(game, "defaults", SHIPPED)["ok"]) + bench.restore_pending(game) + for n in b14.SPAWN_FILES: + self.assertEqual((game / "config" / n).read_text(), "x" + n) + + def test_config_diff(self): + with tempfile.TemporaryDirectory() as td: + game = Path(td) + (game / "config").mkdir() + (game / "config" / "fowlplay.json5").write_text(FP_RESAVED_DEFAULT) + txt = b14.config_diff(game, b14.render_variant("shipped", SHIPPED), SHIPPED) + self.assertIn("duckSpawnWeight: 4 -> 2 <- changes", txt) + self.assertIn("config/alexsmobs-common.toml: WRITE", txt) + txt = b14.config_diff(game, b14.render_variant("defaults", SHIPPED), SHIPPED) + self.assertIn("config/fowlplay.json5: DELETE", txt) + + +SITES = [("river", {"x": 904, "y": 63, "z": 88}), ("warm_ocean", {"x": -200, "y": 63, "z": 56})] + + +class Datapack(unittest.TestCase): + def setUp(self): + self.files = b14.generate_datapack("b14-20261018000000", SITES, 2, 20) + self.base = "data/b14/function/b14-20261018000000" + + def test_structure(self): + f = self.files + self.assertEqual(json.loads(f["pack.mcmeta"])["pack"]["pack_format"], 48) + self.assertEqual(json.loads(f["data/minecraft/tags/function/load.json"]), + {"values": ["b14:b14-20261018000000/load"]}) + begin = f[f"{self.base}/begin.mcfunction"] + self.assertIn("gamerule doMobSpawning true", begin) + self.assertIn("gamemode creative @a", begin) + self.assertNotIn("spectator", "".join(f.values())) + self.assertIn("step0 400t", begin) + # 2 sites x (tp, arrive, t=0..2) + done + steps = [k for k in f if "/step" in k] + self.assertEqual(len(steps), 2 * (2 + 3) + 1) + self.assertIn("B14_DONE", f[f"{self.base}/step{len(steps) - 1}.mcfunction"]) + + def test_counts_and_chain(self): + count = self.files[f"{self.base}/count.mcfunction"] + for _, eid in b14.TUNED + b14.VANILLA: + self.assertIn(f"@e[type={eid},distance=..128]", count) + self.assertIn("@e[type=!minecraft:player,distance=..128]", count) + self.assertEqual(count.count("+= #"), len(b14.TUNED)) + tl = b14.build_timeline("b14-20261018000000", SITES, 2, 20) + delays = [d for _, d in tl] + self.assertEqual(delays[:5], [100, 200, 1200, 1200, 20]) + self.assertEqual(delays[-1], 0) + for i in range(len(tl) - 1): + body = self.files[f"{self.base}/step{i}.mcfunction"] + self.assertIn(f"schedule function b14:b14-20261018000000/step{i + 1} ", body) + s2 = self.files[f"{self.base}/step2.mcfunction"] + self.assertIn("setblock 904 62 88 minecraft:barrier", s2) + self.assertIn("tp @a 904.5 63 88.5", s2) + self.assertIn("if biome ~ ~ ~ minecraft:river", s2) + + def test_tellraw_roundtrip(self): + """The rendered tellraw text, with scores substituted, parses back.""" + cmd = b14.tellraw("b14-20261018000000", "river", 3) + parts = json.loads(cmd[len("tellraw @a "):]) + text = "".join(p if isinstance(p, str) else p.get("text", "7") for p in parts) + line = ("[18Oct2026 00:10:00.000] [Render thread/INFO] [net.minecraft.client.gui." + f"components.ChatComponent/]: [System] [CHAT] {text}") + r = b14.parse_count_line(line) + self.assertEqual((r["sid"], r["biome"], r["t"], r["total"], r["biome_ok"]), + ("b14-20261018000000", "river", 3, 7, 7)) + self.assertEqual(set(r["counts"]), {eid for _, eid in b14.TUNED + b14.VANILLA}) + + def test_world_build(self): + with tempfile.TemporaryDirectory() as td: + td = Path(td) + zp = td / "w.zip" + with zipfile.ZipFile(zp, "w") as z: + z.writestr("onionbench/level.dat", b"lvl") + z.writestr("onionbench/region/r.0.0.mca", b"x") + z.writestr("onionbench/data/chunks.dat", b"x") + z.writestr("onionbench/datapacks/onion-bench/pack.mcmeta", "{}") + z.writestr("onionbench/serverconfig/a.toml", "a") + game = td / "game" + (game / "saves").mkdir(parents=True) + j = bench.Journal.begin(game, "b14-3") + root = b14.build_world(game, j, zp, self.files) + self.assertEqual((root / "level.dat").read_bytes(), b"lvl") + self.assertFalse((root / "region").exists()) + self.assertFalse((root / "data").exists()) + self.assertTrue((root / "serverconfig/a.toml").exists()) + self.assertTrue((root / "datapacks" / b14.PACK / "pack.mcmeta").exists()) + with self.assertRaises(ValueError): + b14.build_world(game, bench.Journal(game, "x"), zp, self.files) + bench.restore_pending(game) + self.assertFalse(root.exists()) + + +def _line(sid, biome, t, total, counts, ok=1): + body = " ".join(f"{k}={v}" for k, v in counts.items()) + return (f"[x] [Render thread/INFO] [ChatComponent/]: [System] [CHAT] B14 {sid} {biome} " + f"t={t} biome_ok={ok} total={total} nonitem={total} tuned=0 {body}") + + +class ParseAndSummary(unittest.TestCase): + def test_parse_dedupes_and_filters(self): + lines = [_line("b14-1", "river", 0, 5, {"fowlplay:duck": 1}), + _line("b14-1", "river", 0, 5, {"fowlplay:duck": 1}), + "[Server thread/INFO]: [Server] B14_ARRIVE b14-1 river 1 2 3", + _line("b14-2", "river", 0, 9, {"fowlplay:duck": 2}), + "noise B14 garbage"] + rows = b14.parse_lines(lines) + self.assertEqual(len(rows), 2) + self.assertEqual(len(b14.parse_lines(lines, "b14-2")), 1) + self.assertEqual(rows[0]["counts"], {"fowlplay:duck": 1}) + + def _rows(self, config, sid, biome, totals, duck): + out = [] + for t, (tot, d) in enumerate(zip(totals, duck)): + r = b14.parse_count_line(_line(sid, biome, t, tot, {"fowlplay:duck": d, + "minecraft:cow": 1})) + out.append({**r, "config": config, "kind": "b14_count"}) + return out + + def _sess(self, sid, ok=True): + return {"kind": "b14_session", "sid": sid, "outcome": "ok", + "config_check": {"ok": ok}} + + def test_keep_and_back_off(self): + counts = (self._rows("defaults", "b14-1", "river", [10, 40], [2, 6]) + + self._rows("shipped", "b14-2", "river", [8, 25], [0, 3])) + sess = [self._sess("b14-1"), self._sess("b14-2")] + out, verdicts, overall = b14.summarize(counts, sess) + self.assertEqual(verdicts["river"]["verdict"], "keep") + self.assertEqual(out["river"]["shipped"]["seen"]["fowlplay:duck"], 3) + self.assertEqual(overall, "incomplete") # jungle / warm_ocean missing + counts2 = (self._rows("defaults", "b14-1", "river", [10, 40], [2, 6]) + + self._rows("shipped", "b14-2", "river", [8, 25], [0, 0])) + _, v2, o2 = b14.summarize(counts2, sess) + self.assertEqual(v2["river"]["verdict"], "back off") + self.assertEqual(v2["river"]["disappeared"], ["fowlplay:duck"]) + self.assertIn("back off", b14.render_summary(*b14.summarize(counts2, sess))) + + def test_bad_sessions_and_biome_check_excluded(self): + counts = (self._rows("defaults", "b14-1", "river", [10, 40], [2, 6]) + + self._rows("shipped", "b14-2", "river", [8, 25], [1, 3])) + _, v, _ = b14.summarize(counts, [self._sess("b14-1"), self._sess("b14-2", ok=False)]) + self.assertEqual(v["river"]["verdict"], "incomplete") + for r in counts: + r["biome_ok"] = 0 + _, v, _ = b14.summarize(counts, [self._sess("b14-1"), self._sess("b14-2")]) + self.assertTrue(v["river"]["verdict"].startswith("invalid")) + + def test_all_biomes_keep(self): + counts, sess = [], [] + for i, biome in enumerate(b14.BIOMES): + counts += self._rows("defaults", f"b14-{i}0", biome, [5, 30], [1, 2]) + counts += self._rows("shipped", f"b14-{i}1", biome, [5, 20], [1, 1]) + sess += [self._sess(f"b14-{i}0"), self._sess(f"b14-{i}1")] + self.assertEqual(b14.summarize(counts, sess)[2], "keep") + + +# ---------------------------------------------------------------- nbt / locate + +def _tag(t, name, payload): + n = name.encode() + return bytes([t]) + struct.pack(">H", len(n)) + n + payload + + +def _str(s): + b = s.encode() + return struct.pack(">H", len(b)) + b + + +def _compound(*items): + return b"".join(items) + b"\x00" + + +def _chunk_nbt(biome, height=70, status="minecraft:full"): + hm_longs = [] + vals = [height + 64] * 256 # stored relative to min_y -64, 9 bits, 7 per long + for i in range(0, 256, 7): + w = 0 + for k, v in enumerate(vals[i:i + 7]): + w |= v << (9 * k) + hm_longs.append(w if w < 2 ** 63 else w - 2 ** 64) + hm = struct.pack(">i", len(hm_longs)) + struct.pack(f">{len(hm_longs)}q", *hm_longs) + section = _compound( + _tag(nbt.BYTE, "Y", struct.pack(">b", (height - 1) >> 4)), + _tag(nbt.COMPOUND, "biomes", _compound( + _tag(nbt.LIST, "palette", bytes([nbt.STRING]) + struct.pack(">i", 1) + _str(biome))))) + root = _compound( + _tag(nbt.STRING, "Status", _str(status)), + _tag(nbt.LIST, "sections", bytes([nbt.COMPOUND]) + struct.pack(">i", 1) + section), + _tag(nbt.COMPOUND, "Heightmaps", _compound( + _tag(nbt.LONG_ARRAY, "MOTION_BLOCKING_NO_LEAVES", hm)))) + return bytes([nbt.COMPOUND]) + _str("") + root + + +def _region(chunks): + """chunks: {(lx, lz): nbt bytes} -> .mca bytes.""" + header, body, sector = bytearray(8192), bytearray(), 2 + for (lx, lz), raw in chunks.items(): + data = zlib.compress(raw) + blob = struct.pack(">I", len(data) + 1) + b"\x02" + data + blob += b"\x00" * (-len(blob) % 4096) + n = len(blob) // 4096 + idx = lz * 32 + lx + header[idx * 4:idx * 4 + 4] = sector.to_bytes(3, "big") + bytes([n]) + body += blob + sector += n + return bytes(header) + bytes(body) + + +class NbtAndLocate(unittest.TestCase): + def test_packed(self): + self.assertEqual(nbt.unpack_packed([0b10_01_00 | (3 << 62)], 2, 3), [0, 1, 2]) + self.assertEqual(nbt.unpack_packed([-1], 4, 2), [15, 15]) + + def test_chunk_roundtrip(self): + _, c = nbt.loads(_chunk_nbt("minecraft:river", 63)) + self.assertEqual(nbt.heightmap(c)[0], 63) + self.assertEqual(nbt.biome_at(c, 5, 62, 9), "minecraft:river") + ld = gzip.compress(bytes([nbt.COMPOUND]) + _str("") + _compound( + _tag(nbt.COMPOUND, "Data", _compound(_tag(nbt.INT, "SpawnX", struct.pack(">i", 7)))))) + self.assertEqual(nbt.load_gzip(ld)[1]["Data"]["SpawnX"], 7) + + def test_scan_and_pick(self): + reg = {} + for lx in range(5): + for lz in range(5): + reg[(lx, lz)] = _chunk_nbt("minecraft:river" if lx < 3 else "minecraft:forest", 63) + reg[(lx + 20, lz)] = _chunk_nbt("minecraft:warm_ocean", 63) + reg[(lx + 10, lz + 20)] = _chunk_nbt("minecraft:jungle", 80, + "minecraft:structure_starts") + with tempfile.TemporaryDirectory() as td: + zp = Path(td) / "w.zip" + with zipfile.ZipFile(zp, "w") as z: + z.writestr("w/level.dat", b"") + z.writestr("w/region/r.0.0.mca", _region(reg)) + chunks = b14.scan_world(zp) + self.assertEqual(len(chunks), 50) # structure_starts placeholders skipped + sites = b14.pick_sites(chunks, avoid=[(-1000, -1000)]) + self.assertEqual(sites["river"]["biome"], "minecraft:river") + self.assertEqual(sites["river"]["y"], 63) + self.assertIn("warm_ocean", sites) + self.assertNotIn("jungle", sites) + self.assertGreaterEqual(abs(sites["warm_ocean"]["x"] - sites["river"]["x"]), 320) + # avoid radius excludes everything near the avoid point + self.assertEqual(b14.pick_sites(chunks, avoid=[(40, 40), (360, 40)]), {}) + + +class Busy(unittest.TestCase): + def test_ancestors_include_self(self): + import os + self.assertIn(os.getpid(), b14._ancestors(os.getpid())) + self.assertNotIn(os.getpid(), b14.bench_busy()) + + +if __name__ == "__main__": + unittest.main() diff --git a/analysis/client-perf/harness/bench.py b/analysis/client-perf/harness/bench.py index 0a1ac05..f36900a 100644 --- a/analysis/client-perf/harness/bench.py +++ b/analysis/client-perf/harness/bench.py @@ -778,6 +778,16 @@ def apply_jvm(jvm, heap_mb): cfg.write_text("\n".join(out) + "\n") +def reset_jvm(inst_dir=None): + """Restore instance.cfg from the pre-harness copy (undoes an earlier --jvm/--heap).""" + d = Path(inst_dir) if inst_dir else INST_DIR + orig = d / "instance.cfg.harness-orig" + if orig.exists(): + shutil.copyfile(orig, d / "instance.cfg") + return True + return False + + def plan_dir(): return GAME / "saves" / WORLD / "datapacks" / PLAN_PACK @@ -1234,6 +1244,8 @@ def cmd_session(a): apply_pins() if a.jvm or a.heap: apply_jvm(a.jvm, a.heap) + else: + reset_jvm() # --jvm/--heap must not leak into later sessions apply_shader(GAME, journal, a.shader, a.shader_options) apply_dh(GAME, journal, a.dh == "on") disable_mods(GAME, journal, to_disable) diff --git a/analysis/client-perf/harness/tests/test_bench_flags.py b/analysis/client-perf/harness/tests/test_bench_flags.py index 18de97e..4f23ec8 100644 --- a/analysis/client-perf/harness/tests/test_bench_flags.py +++ b/analysis/client-perf/harness/tests/test_bench_flags.py @@ -281,3 +281,14 @@ class ShaderLoaded(unittest.TestCase): self.assertEqual(bench.shader_flags("X.zip", ok), []) self.assertEqual(bench.shader_flags("X.zip", []), ["shader_not_loaded"]) self.assertEqual(bench.shader_flags("X.zip", bad), ["shader_not_loaded"]) + + +class ResetJvm(unittest.TestCase): + def test_reset(self): + with tempfile.TemporaryDirectory() as d: + d = Path(d) + (d / "instance.cfg").write_text("JvmArgs=-XX:+UseShenandoahGC\nMaxMemAlloc=6144\n") + self.assertFalse(bench.reset_jvm(d)) + (d / "instance.cfg.harness-orig").write_text("JvmArgs=\nMaxMemAlloc=8192\n") + self.assertTrue(bench.reset_jvm(d)) + self.assertEqual((d / "instance.cfg").read_text(), "JvmArgs=\nMaxMemAlloc=8192\n")