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.
This commit is contained in:
logan committed 2026-10-17 22:28:20 -04:00
1 parent d20b0fb11a
commit 60e10b35a3
8 files changed
+1773

No files matched your search

+102
View File
@@ -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.
+169
View File
@@ -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-<sid>.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 <rev>:config/<file>` (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.<init>` bytecode: duck 4/max 6, goose 4/4, gull 5/8,
sparrow 20/6.
- Critters and Companions: `CACCommonConfig$SpawningConfig.<init>`: 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 #<alias> b14 if entity
@e[type=<id>,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 <sid> <biome> t=<min> 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.<mod>.<name>`
lang key, and is either in the mod's registry class strings (`CACEntities`) or has a
`loot_table/entities/<name>.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.
+855
View File
@@ -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 <sid> <biome> t=<min> biome_ok=1 total=N <entity id>=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 (?P<sid>b14-\d+) (?P<biome>[a-z_]+) t=(?P<t>\d+) (?P<rest>.*)$")
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:<sid>/."""
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())
+162
View File
@@ -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
@@ -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
}
}
}
@@ -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()
+12
View File
@@ -778,6 +778,16 @@ def apply_jvm(jvm, heap_mb):
cfg.write_text("\n".join(out) + "\n") 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(): def plan_dir():
return GAME / "saves" / WORLD / "datapacks" / PLAN_PACK return GAME / "saves" / WORLD / "datapacks" / PLAN_PACK
@@ -1234,6 +1244,8 @@ def cmd_session(a):
apply_pins() apply_pins()
if a.jvm or a.heap: if a.jvm or a.heap:
apply_jvm(a.jvm, 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_shader(GAME, journal, a.shader, a.shader_options)
apply_dh(GAME, journal, a.dh == "on") apply_dh(GAME, journal, a.dh == "on")
disable_mods(GAME, journal, to_disable) disable_mods(GAME, journal, to_disable)
@@ -281,3 +281,14 @@ class ShaderLoaded(unittest.TestCase):
self.assertEqual(bench.shader_flags("X.zip", ok), []) 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", []), ["shader_not_loaded"])
self.assertEqual(bench.shader_flags("X.zip", bad), ["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")